diff --git a/package-lock.json b/package-lock.json index 08d9231..ed5aa53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@types/node": "^20.19.37", "@types/pdfmake": "^0.3.2", "@vitest/coverage-v8": "^4.1.1", + "happy-dom": "^20.8.9", "obsidian": "latest", "oxfmt": "^0.42.0", "oxlint": "^1.57.0", @@ -1320,6 +1321,23 @@ "@types/estree": "*" } }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitest/coverage-v8": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.1.tgz", @@ -1582,6 +1600,19 @@ "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", "license": "MIT" }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", @@ -1674,6 +1705,24 @@ "node": ">=10" } }, + "node_modules/happy-dom": { + "version": "20.8.9", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.9.tgz", + "integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2733,6 +2782,16 @@ "license": "MIT", "peer": true }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2750,6 +2809,28 @@ "node": ">=8" } }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xmldoc": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/xmldoc/-/xmldoc-2.0.3.tgz", diff --git a/package.json b/package.json index 8a750dd..3df915f 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@types/node": "^20.19.37", "@types/pdfmake": "^0.3.2", "@vitest/coverage-v8": "^4.1.1", + "happy-dom": "^20.8.9", "obsidian": "latest", "oxfmt": "^0.42.0", "oxlint": "^1.57.0", diff --git a/src/__mocks__/obsidian.ts b/src/__mocks__/obsidian.ts index d140adb..61b8d6e 100644 --- a/src/__mocks__/obsidian.ts +++ b/src/__mocks__/obsidian.ts @@ -298,3 +298,122 @@ export const MockNotice = vi.fn().mockImplementation(function ( ) { return {}; }); + +/** + * Helper to create the {@link Node.createEl} function added by obsidian + * + * @param el Container to create elements within + * @returns The createDiv function for that container + */ +function createMockCreateEl(el: Node) { + return ( + tag: K, + o?: DomElementInfo | string, + callback?: (el: HTMLElementTagNameMap[K]) => void + ): HTMLElementTagNameMap[K] => { + const child = document.createElement(tag); + + // Apply options + if (o) { + if (typeof o === "string") { + child.textContent = o; + } else { + applyDomElementInfo(child, o); + } + } + + // Setup helpers + setObsidianMockElementHelpers(child); + + // Add to the parent + el.appendChild(child); + + // Callback from creation + if (callback) callback(child); + + return child; + }; +} + +/** + * Sets the createEl/createDiv/createSpan helper functions + * on the provided node + * + * @param node The node to add the helper functions to + */ +export function setObsidianMockElementHelpers(node: Node) { + node.createEl = createMockCreateEl(node); + node.createDiv = (o?: DomElementInfo | string, callback?: (el: HTMLDivElement) => void) => + node.createEl("div", o, callback); + node.createSpan = (o?: DomElementInfo | string, callback?: (el: HTMLDivElement) => void) => + node.createEl("span", o, callback); +} + +/** + * Applies the attributes from the DomElementInfo onto + * the provided HTML element + * + * @param el The element to apply to + * @param info The dom element info to apply + */ +function applyDomElementInfo(el: HTMLElement, info: DomElementInfo) { + if (info.cls) { + if (Array.isArray(info.cls)) { + el.classList.add(...info.cls); + } else { + el.classList.add(info.cls); + } + } + + if (info.text) { + if (info.text instanceof DocumentFragment) { + el.appendChild(info.text); + } else { + el.textContent = info.text; + } + } + + if (info.attr) { + for (const [key, value] of Object.entries(info.attr)) { + if (value === null || value === false) { + el.removeAttribute(key); + } else { + el.setAttribute(key, String(value)); + } + } + } + + if (info.title) { + el.title = info.title; + } + + if (info.parent) { + if (info.prepend && info.parent.firstChild) { + info.parent.insertBefore(el, info.parent.firstChild); + } else { + info.parent.appendChild(el); + } + } + + if (el instanceof HTMLInputElement) { + if (info.value !== undefined) { + el.value = info.value; + } + if (info.placeholder !== undefined) { + el.placeholder = info.placeholder; + } + } + + if (info.href && el instanceof HTMLAnchorElement) { + el.href = info.href; + } +} + +/** + * Helper for creating a mock container element extended with + */ +export function createMockContainer() { + const container = document.createElement("div"); + setObsidianMockElementHelpers(container); + return container; +} diff --git a/src/__mocks__/obsidianStub.ts b/src/__mocks__/obsidianStub.ts new file mode 100644 index 0000000..239e40b --- /dev/null +++ b/src/__mocks__/obsidianStub.ts @@ -0,0 +1,7 @@ +/** + * Stub file for testing module resolution, this file doesn't contain anything just + * vitest needs a module that it can resolve for browser testing mode otherwise + * it will error from being unable to resolve the "obsidian" module + */ + +export {}; diff --git a/src/__mocks__/setupObsidianMocks.ts b/src/__mocks__/setupObsidianMocks.ts new file mode 100644 index 0000000..20b3d24 --- /dev/null +++ b/src/__mocks__/setupObsidianMocks.ts @@ -0,0 +1,21 @@ +import { vi } from "vitest"; + +import { + MockComponent, + MockNotice, + MockTAbstractFile, + MockTFile, + MockTFolder, + MockVault, +} from "@/__mocks__/obsidian"; + +vi.mock("obsidian", () => { + return { + TFile: MockTFile, + TFolder: MockTFolder, + TAbstractFile: MockTAbstractFile, + Vault: MockVault, + Component: MockComponent, + Notice: MockNotice, + }; +}); diff --git a/src/commands/stopAllTimekeeps.test.ts b/src/commands/stopAllTimekeeps.test.ts index 85d6de6..f1ecc3d 100644 --- a/src/commands/stopAllTimekeeps.test.ts +++ b/src/commands/stopAllTimekeeps.test.ts @@ -1,30 +1,12 @@ import type { App } from "obsidian"; -import { describe, vi, it, expect } from "vitest"; +import { describe, it, expect } from "vitest"; -import { - MockComponent, - MockNotice, - MockTAbstractFile, - MockTFile, - MockTFolder, - MockVault, -} from "@/__mocks__/obsidian"; +import { MockNotice, MockVault } from "@/__mocks__/obsidian"; import { createCodeBlock } from "@/utils/codeblock"; import stopAllTimekeeps from "./stopAllTimekeeps"; -vi.mock("obsidian", () => { - return { - TFile: MockTFile, - TFolder: MockTFolder, - TAbstractFile: MockTAbstractFile, - Vault: MockVault, - Component: MockComponent, - Notice: MockNotice, - }; -}); - describe("stopFileTimekeeps", () => { it("when nothing is stopped should get a notice", async () => { const vault = new MockVault(); diff --git a/src/commands/stopFileTimekeeps.test.ts b/src/commands/stopFileTimekeeps.test.ts index 4aa0603..c410a3a 100644 --- a/src/commands/stopFileTimekeeps.test.ts +++ b/src/commands/stopFileTimekeeps.test.ts @@ -2,29 +2,11 @@ import type { App } from "obsidian"; import { describe, vi, it, expect } from "vitest"; -import { - MockComponent, - MockNotice, - MockTAbstractFile, - MockTFile, - MockTFolder, - MockVault, -} from "@/__mocks__/obsidian"; +import { MockNotice, MockVault } from "@/__mocks__/obsidian"; import { createCodeBlock } from "@/utils/codeblock"; import stopFileTimekeeps from "./stopFileTimekeeps"; -vi.mock("obsidian", () => { - return { - TFile: MockTFile, - TFolder: MockTFolder, - TAbstractFile: MockTAbstractFile, - Vault: MockVault, - Component: MockComponent, - Notice: MockNotice, - }; -}); - describe("stopFileTimekeeps", () => { it("when no active file is detected should get a notice", async () => { const app = { diff --git a/src/components/timesheetTimer.test.ts b/src/components/timesheetTimer.test.ts new file mode 100644 index 0000000..32eac46 --- /dev/null +++ b/src/components/timesheetTimer.test.ts @@ -0,0 +1,95 @@ +// @vitest-environment happy-dom + +import { describe, it, beforeEach, expect } from "vitest"; + +import { createMockContainer } from "@/__mocks__/obsidian"; + +import { TimesheetTimer } from "./timesheetTimer"; + +describe("TimesheetTimer", () => { + let container: ReturnType; + let timer: TimesheetTimer; + + beforeEach(() => { + container = createMockContainer(); + timer = new TimesheetTimer(container, "Test Label"); + }); + + it("should create timer elements on load", () => { + timer.onload(); + + const timerEl = container.children[0] as HTMLElement; + expect(timerEl).toBeDefined(); + expect(timerEl.className).toBe("timekeep-timer"); + + const primary = timerEl.children[0] as HTMLElement; + const secondary = timerEl.children[1] as HTMLElement; + const label = timerEl.children[2] as HTMLElement; + + expect(primary.className).toBe("timekeep-timer-value"); + expect(secondary.className).toBe("timekeep-timer-value-small"); + expect(label.textContent).toBe("Test Label"); + + expect(primary.textContent).toBe(""); + expect(secondary.textContent).toBe(" "); + expect(secondary.hidden).toBe(false); + }); + + it("should remove timer element on unload", () => { + timer.onload(); + const timerEl = container.children[0] as HTMLElement; + expect(timerEl).toBeDefined(); + + timer.onunload(); + expect(timerEl.parentElement).toBeNull(); + }); + + it("should hide or show the timer element", () => { + timer.onload(); + const timerEl = container.children[0] as HTMLElement; + + timer.setHidden(true); + expect(timerEl.hidden).toBe(true); + + timer.setHidden(false); + expect(timerEl.hidden).toBe(false); + }); + + it("should set primary and secondary values correctly", () => { + timer.onload(); + const timerEl = container.children[0] as HTMLElement; + const primary = timerEl.children[0] as HTMLDivElement; + const secondary = timerEl.children[1] as HTMLDivElement; + + timer.setValues("1h 55m 30s", "1.9h"); + expect(primary.textContent).toBe("1h 55m 30s"); + expect(secondary.textContent).toBe("1.9h"); + expect(secondary.hidden).toBe(false); + + timer.setValues("2h 0m 5s", ""); + expect(primary.textContent).toBe("2h 0m 5s"); + expect(secondary.textContent).toBe(""); + expect(secondary.hidden).toBe(true); + }); + + it("secondary value should be hidden if it is empty", () => { + timer.onload(); + + const timerEl = container.children[0] as HTMLElement; + const secondary = timerEl.children[1] as HTMLDivElement; + + timer.setValues("1h 55m 30s", "1.9h"); + expect(secondary.hidden).toBe(false); + + timer.setValues("2h 0m 5s", ""); + expect(secondary.hidden).toBe(true); + }); + + it("should not throw if setValues called before onload", () => { + expect(() => timer.setValues("1", "2")).not.toThrow(); + }); + + it("should not throw if setHidden called before onload", () => { + expect(() => timer.setHidden(true)).not.toThrow(); + }); +}); diff --git a/src/components/timesheetTimer.ts b/src/components/timesheetTimer.ts index 466097a..7bb0398 100644 --- a/src/components/timesheetTimer.ts +++ b/src/components/timesheetTimer.ts @@ -28,20 +28,11 @@ export class TimesheetTimer extends Component { onload(): void { super.onload(); - const timerEl = this.#containerEl.createDiv({ - cls: "timekeep-timer", - }); - + const timerEl = this.#containerEl.createDiv({ cls: "timekeep-timer" }); this.#timerEl = timerEl; - const primaryValueEl = timerEl.createDiv({ - cls: "timekeep-timer-value", - }); - - const secondaryValueEl = timerEl.createDiv({ - cls: "timekeep-timer-value-small", - }); - + const primaryValueEl = timerEl.createDiv({ cls: "timekeep-timer-value" }); + const secondaryValueEl = timerEl.createDiv({ cls: "timekeep-timer-value-small" }); timerEl.createSpan({ text: this.#label }); this.#primaryValueEl = primaryValueEl; diff --git a/src/service/autocomplete.test.ts b/src/service/autocomplete.test.ts index 3241555..bb56cf5 100644 --- a/src/service/autocomplete.test.ts +++ b/src/service/autocomplete.test.ts @@ -2,13 +2,7 @@ import moment from "moment"; import { v4 } from "uuid"; import { describe, expect, it, vi } from "vitest"; -import { - MockComponent, - MockTAbstractFile, - MockTFile, - MockTFolder, - MockVault, -} from "@/__mocks__/obsidian"; +import { MockVault } from "@/__mocks__/obsidian"; import { defaultSettings } from "@/settings"; import { createStore } from "@/store"; import { Timekeep } from "@/timekeep/schema"; @@ -17,16 +11,6 @@ import { createCodeBlock } from "@/utils/codeblock"; import { TimekeepAutocomplete } from "./autocomplete"; import { TimekeepRegistry } from "./registry"; -vi.mock("obsidian", () => { - return { - TFile: MockTFile, - TFolder: MockTFolder, - TAbstractFile: MockTAbstractFile, - Vault: MockVault, - Component: MockComponent, - }; -}); - describe("TimekeepAutocomplete", () => { describe("autocomplete enabled", () => { it("empty vault should have no names", async () => { diff --git a/src/service/registry.test.ts b/src/service/registry.test.ts index 0454f7f..2167c0b 100644 --- a/src/service/registry.test.ts +++ b/src/service/registry.test.ts @@ -2,13 +2,7 @@ import moment from "moment"; import { v4 } from "uuid"; import { describe, vi, it, expect } from "vitest"; -import { - MockComponent, - MockTAbstractFile, - MockTFile, - MockTFolder, - MockVault, -} from "@/__mocks__/obsidian"; +import { MockVault } from "@/__mocks__/obsidian"; import { defaultSettings } from "@/settings"; import { createStore } from "@/store"; import { stripTimekeepRuntimeData, Timekeep } from "@/timekeep/schema"; @@ -16,16 +10,6 @@ import { createCodeBlock } from "@/utils/codeblock"; import { TimekeepEntryItemType, TimekeepRegistry, TimekeepRegistryEntryMarkdown } from "./registry"; -vi.mock("obsidian", () => { - return { - TFile: MockTFile, - TFolder: MockTFolder, - TAbstractFile: MockTAbstractFile, - Vault: MockVault, - Component: MockComponent, - }; -}); - describe("TimekeepRegistry", () => { describe("getFileRegistryEntry", () => { it("returns null for markdown without timekeeps", async () => { diff --git a/vite.config.js b/vite.config.js index e9b52a0..5d91c1b 100644 --- a/vite.config.js +++ b/vite.config.js @@ -51,10 +51,12 @@ export default defineConfig((env) => ({ resolve: { alias: { "@": path.resolve(__dirname, "src"), + obsidian: path.resolve(__dirname, "src", "__mocks__", "obsidianStub"), }, }, test: { + setupFiles: path.resolve(__dirname, "src", "__mocks__", "setupObsidianMocks.ts"), coverage: { // Exclude mocks and fixtures from coverage exclude: ["**/__mocks__/**", "**/__fixtures__/**"],