import { mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import esbuild from "esbuild"; import sveltePlugin from "esbuild-svelte"; import { JSDOM } from "jsdom"; const root = new URL("..", import.meta.url).pathname; const fixtures = join(root, "tests/fixtures"); const FOLDER = "Boards"; const out = join(mkdtempSync(join(tmpdir(), "bcal-")), "bundle.mjs"); writeFileSync( join(root, "tests/.entry.ts"), `export { default as App } from "../src/ui/app.svelte"; export { BoardStore } from "../src/vault/store.svelte"; export { mount, unmount, flushSync } from "svelte"; export { TFile, Component } from "obsidian"; `, ); await esbuild.build({ entryPoints: [join(root, "tests/.entry.ts")], bundle: true, format: "esm", target: "es2022", outfile: out, logLevel: "error", conditions: ["svelte", "browser"], mainFields: ["svelte", "browser", "module", "main"], alias: { obsidian: join(root, "tests/obsidian-stub.ts") }, plugins: [sveltePlugin({ compilerOptions: { css: "injected", runes: true } })], }); const dom = new JSDOM("", { pretendToBeVisual: true, url: "http://localhost/", }); for (const key of Object.getOwnPropertyNames(dom.window)) { if (key.startsWith("_")) continue; if (globalThis[key] !== undefined) continue; try { globalThis[key] = dom.window[key]; } catch { /* getter-only window properties are fine to skip */ } } globalThis.window = dom.window; globalThis.document = dom.window.document; dom.window.Element.prototype.getBoundingClientRect = () => ({ bottom: 0, height: 0, left: 0, right: 0, top: 0, width: 0, x: 0, y: 0, toJSON: () => ({}), }); dom.window.Element.prototype.setPointerCapture = () => {}; dom.window.Element.prototype.releasePointerCapture = () => {}; dom.window.Element.prototype.hasPointerCapture = () => false; dom.window.Element.prototype.scrollIntoView = () => {}; globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} }; const { App, BoardStore, mount, unmount, flushSync, TFile, Component } = await import(pathToFileURL(out).href); const names = readdirSync(fixtures).filter((name) => name.endsWith(".md")); const files = new Map( names.map((name) => [ `${FOLDER}/${name}`, readFileSync(join(fixtures, name), "utf8"), ]), ); const handles = new Map([...files.keys()].map((path) => [path, new TFile(path)])); const writes = []; const app = { vault: { getMarkdownFiles: () => [...handles.values()], getAbstractFileByPath: (path) => handles.get(path) ?? null, cachedRead: async (file) => files.get(file.path), read: async (file) => files.get(file.path), process: async (file, fn) => { const next = fn(files.get(file.path)); writes.push({ path: file.path, content: next }); files.set(file.path, next); return next; }, on: () => ({}), }, workspace: { getLeaf: () => ({}), activeEditor: null }, plugins: { plugins: {} }, }; const settings = { boardsFolder: FOLDER, archiveHeading: "Archive", view: "list", groupBy: "sphere", listSort: "due", calendarSort: "priority", showDone: false, includeArchive: false, railCollapsed: false, undatedCollapsed: false, }; const failures = []; function check(name, condition, detail = "") { if (condition) console.log(` ok ${name}`); else { failures.push(name); console.log(` FAIL ${name}${detail ? ` -- ${detail}` : ""}`); } } const settle = () => new Promise((resolve) => setTimeout(resolve, 60)); const store = new BoardStore(app, settings, () => {}); await store.reloadAll(); // Every task outside the archive, done ones included; the done filter // applies to `visible`, not to the pool. const outsideArchive = 15; check("parsed every board", store.spheres.length === names.length, `${store.spheres.length}`); check(`${outsideArchive} tasks outside the archive`, store.all.length === outsideArchive, `got ${store.all.length}`); const target = dom.window.document.body; const instance = mount(App, { target, props: { app, store, settings, component: new Component() }, }); flushSync(); await settle(); flushSync(); const text = () => target.textContent ?? ""; check("list rendered a task", text().includes("renew the gym membership")); check("sphere rail rendered", text().includes("All spheres")); check("group header rendered", text().includes("personal")); check("project chip rendered", text().includes("errands")); check("no crash markers", !text().includes("undefined")); const links = target.querySelectorAll("a.internal-link"); check("wikilink rendered as a link", links.length > 0, `${links.length} links`); check("alias is displayed, not the target", text().includes("Alex")); check("no raw wikilink syntax leaked", !text().includes("[[")); store.includeArchive = true; store.showDone = true; flushSync(); await settle(); check("archive appears when both toggles are on", text().includes("pick up the parcel")); check("archived task carries the Archive badge", text().includes("Archive")); store.includeArchive = false; flushSync(); check("archive hidden again", !text().includes("pick up the parcel")); store.showDone = false; flushSync(); store.view = "calendar"; flushSync(); await settle(); check("calendar rendered a month label", /\d{4}/.test(store.monthLabel)); check("calendar rendered the undated tray", text().includes("No date")); store.view = "list"; store.groupBy = "due"; flushSync(); const BUCKETS = ["Overdue", "Today", "Tomorrow", "This week", "Later", "No date"]; check( "due grouping produced known buckets", store.groups.length > 0 && store.groups.every((g) => BUCKETS.includes(g.label)), store.groups.map((g) => g.label).join(", "), ); const button = target.querySelector('button[aria-label^="Status"]'); check("status button present", button !== null); if (button) { button.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); await settle(); check("clicking status wrote to the vault", writes.length > 0, `${writes.length}`); if (writes.length > 0) { const after = writes[writes.length - 1]; const before = readFileSync( join(fixtures, after.path.slice(FOLDER.length + 1)), "utf8", ).split("\n"); const now = after.content.split("\n"); const diff = now.filter((line, index) => line !== before[index]); check("exactly one line changed", diff.length === 1, JSON.stringify(diff)); check("line is now done", diff[0]?.startsWith("- [x] "), diff[0]); check("done date stamped", /✅ \d{4}-\d{2}-\d{2}$/u.test(diff[0] ?? ""), diff[0]); check("settings block intact", after.content.includes("%% kanban:settings")); } } await unmount(instance); console.log(failures.length === 0 ? "\nsmoke: all good" : `\nsmoke: ${failures.length} FAILED`); process.exit(failures.length === 0 ? 0 : 1);