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; // Node ships its own Event classes; jsdom rejects those, so prefer jsdom's. globalThis.Event = dom.window.Event; globalThis.CustomEvent = dom.window.CustomEvent; 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 = () => {}; // jsdom has no Web Animations API; Svelte transitions expect one. dom.window.Element.prototype.animate = () => { const animation = { currentTime: 0, startTime: 0, playState: "finished", effect: { getComputedTiming: () => ({ delay: 0, duration: 0 }) }, finished: Promise.resolve(), onfinish: null, cancel() {}, finish() {}, pause() {}, play() {}, reverse() {}, commitStyles() {}, addEventListener() {}, removeEventListener() {}, }; queueMicrotask(() => animation.onfinish?.()); return animation; }; 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); // Obsidian fires a modify event after a write; mirror that here so the // store reflects the vault the way it does in the real plugin. store.queueReload(file.path); 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}` : ""}`); } } // Long enough to clear the store's 150 ms reload debounce. const settle = () => new Promise((resolve) => setTimeout(resolve, 220)); 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")); } } // The whole point of the composer: pick the day and the lane without ever // touching the sidebar, and write the line straight into that lane. store.view = "calendar"; store.sphereFilter = null; store.projectFilter = null; flushSync(); await settle(); const viewRoot = target.querySelector(".bcal-root"); const key = (node, init) => node.dispatchEvent( new dom.window.KeyboardEvent("keydown", { bubbles: true, ...init }), ); viewRoot.focus(); key(viewRoot, { key: "t", code: "KeyT" }); key(viewRoot, { key: "n", code: "KeyN" }); flushSync(); await settle(); const composer = dom.window.document.querySelector( 'input[placeholder="What needs doing?"]', ); check("n opened the composer on the cursor day", composer !== null); if (composer) { // Tab to the sphere field, filter it down, and take the highlighted match. key(composer, { key: "Tab", code: "Tab" }); flushSync(); const sphereField = dom.window.document.querySelector( 'input[placeholder="home"], input[placeholder="personal"], input[placeholder="reading"], input[placeholder="work"]', ); check("tab moved into the sphere field", sphereField !== null); if (sphereField) { sphereField.value = "work"; sphereField.dispatchEvent(new dom.window.Event("input", { bubbles: true })); flushSync(); key(sphereField, { key: "Tab", code: "Tab" }); flushSync(); } const title = dom.window.document.querySelector( 'input[placeholder="What needs doing?"]', ); title.value = "ship the composer"; title.dispatchEvent(new dom.window.Event("input", { bubbles: true })); flushSync(); key(title, { key: "Enter", code: "Enter" }); await settle(); const board = files.get(`${FOLDER}/work.md`) ?? ""; const line = board.split("\n").find((each) => each.includes("ship the composer")); check("the task landed in the sphere the composer picked", line !== undefined, board.slice(0, 80)); check("it carries today's due date", /📅 \d{4}-\d{2}-\d{2}/u.test(line ?? ""), line); check("the composer closed after writing", store.editing === null); check( "the lane is remembered for the next task", store.lastLane?.path === `${FOLDER}/work.md`, JSON.stringify(store.lastLane), ); } // Rescheduling from the keyboard keeps the cursor on the task it moved. store.zone = "day"; store.cursorTask = null; flushSync(); key(viewRoot, { key: "Enter", code: "Enter" }); flushSync(); const picked = store.cursorTask; check("enter dropped the cursor onto a task", picked !== null, `${picked}`); if (picked) { const before = store.taskById(picked)?.due; key(viewRoot, { key: "L", code: "KeyL", shiftKey: true }); await settle(); flushSync(); const after = store.taskById(store.cursorTask)?.due; check("shift+l pushed the task a day forward", after !== before, `${before} -> ${after}`); check("the day cursor followed it", store.cursorDay === after, `${store.cursorDay}`); } // Cursor chrome is earned by a key press and given up by a click. store.leaveToMain(); store.keyboard = false; flushSync(); check( "no outline before a key is pressed", target.querySelector("[data-here='true']") === null, ); key(viewRoot, { key: "l", code: "KeyL" }); flushSync(); check( "the outline appears once the keyboard is used", target.querySelector("[data-here='true']") !== null, ); viewRoot.dispatchEvent( new dom.window.PointerEvent("pointerdown", { bubbles: true }), ); flushSync(); check( "and goes away again on a click", target.querySelector("[data-here='true']") === null, ); // `u` is a round trip: into the tray, then out and shut. key(viewRoot, { key: "u", code: "KeyU" }); flushSync(); check("u took the cursor into the tray", store.zone === "undated", store.zone); key(viewRoot, { key: "u", code: "KeyU" }); flushSync(); check("u again handed it back", store.zone === "day", store.zone); check("and closed the tray", store.undatedCollapsed === true); // `b` does the same for the spheres, and Enter filters by the row. store.undatedCollapsed = false; key(viewRoot, { key: "b", code: "KeyB" }); flushSync(); check("b took the cursor into the rail", store.zone === "rail", store.zone); key(viewRoot, { key: "j", code: "KeyJ" }); key(viewRoot, { key: "Enter", code: "Enter" }); flushSync(); check("enter filtered by the row", store.sphereFilter !== null, `${store.sphereFilter}`); check("and stepped back out", store.zone !== "rail", store.zone); // The list has no day grid, so its cursor starts on a task from the first key. store.view = "list"; store.leaveToMain(); store.cursorTask = null; store.sphereFilter = null; flushSync(); await settle(); key(viewRoot, { key: "j", code: "KeyJ" }); flushSync(); check("j picks up the first task in the list", store.cursorTask !== null); // Grouping reorders the list, so the cursor has to follow the drawn order and // not the flat sorted one underneath it. for (const grouping of ["sphere", "project", "due", "status"]) { store.groupBy = grouping; store.cursorTask = null; flushSync(); const drawn = [...target.querySelectorAll("[data-drop^='task:']")].map( (row) => row.getAttribute("data-drop").slice(5), ); const walked = []; for (let i = 0; i < drawn.length; i += 1) { key(viewRoot, { key: "j", code: "KeyJ" }); flushSync(); walked.push(store.cursorTask); } check( `j walks the list in drawn order, grouped by ${grouping}`, walked.join("|") === drawn.join("|"), `${walked.length} of ${drawn.length}`, ); key(viewRoot, { key: "k", code: "KeyK" }); flushSync(); check( `k comes back up the same way, grouped by ${grouping}`, store.cursorTask === drawn[drawn.length - 2], ); } // d, m and p summon a panel with no trigger behind them; if it fails to mount, // the view goes on swallowing every key and the keyboard looks dead. const panelOf = () => dom.window.document.querySelector("[role='dialog']"); for (const [name, init] of [ ["p", { key: "p", code: "KeyP" }], ["e", { key: "e", code: "KeyE" }], ]) { store.cursorTask = null; store.request = null; flushSync(); key(viewRoot, { key: "j", code: "KeyJ" }); flushSync(); key(viewRoot, init); flushSync(); check(`${name} opened its panel`, panelOf() !== null); panelOf()?.dispatchEvent( new dom.window.KeyboardEvent("keydown", { bubbles: true, key: "Escape", code: "Escape", }), ); flushSync(); check( `${name} let go on escape`, store.request === null && store.editing === null && panelOf() === null, ); const before = store.cursorTask; key(viewRoot, { key: "j", code: "KeyJ" }); flushSync(); check(`the keyboard answers again after ${name}`, store.cursorTask !== before); } // The date field takes words, which is the whole point of tabbing to it. { store.view = "list"; store.leaveToMain(); flushSync(); await settle(); key(viewRoot, { key: "n", code: "KeyN" }); flushSync(); const panel = panelOf(); check("n opened the editor", panel !== null); const type = (node, value) => { node.value = value; node.dispatchEvent(new dom.window.Event("input", { bubbles: true })); flushSync(); }; const tab = () => { (dom.window.document.activeElement ?? panel).dispatchEvent( new dom.window.KeyboardEvent("keydown", { bubbles: true, key: "Tab", code: "Tab", }), ); flushSync(); }; type(panel.querySelector("input"), "flight to lisbon"); tab(); // sphere tab(); // project tab(); // date const dateField = dom.window.document.activeElement; check("three tabs land on the date", dateField?.placeholder !== undefined); type(dateField, "in 3 days"); check( "the words are read back before they are taken", panel.textContent.includes( new Intl.DateTimeFormat("en-US", { weekday: "short", day: "numeric", month: "long", }).format(new Date(Date.now() + 3 * 86_400_000)), ), panel.textContent.slice(0, 120), ); dateField.dispatchEvent( new dom.window.KeyboardEvent("keydown", { bubbles: true, key: "Enter", code: "Enter", }), ); await settle(); const wanted = new Date(Date.now() + 3 * 86_400_000); const stamp = `${wanted.getFullYear()}-${String(wanted.getMonth() + 1).padStart(2, "0")}-${String(wanted.getDate()).padStart(2, "0")}`; const written = [...files.values()] .flatMap((board) => board.split("\n")) .find((line) => line.includes("flight to lisbon")); check("the task was written with the date those words meant", written?.includes(stamp), written); } // A request whose task vanished must not leave the view deaf. store.request = "priority"; store.cursorTask = "nothing/at/all#0"; flushSync(); check("a request with no task clears itself", store.request === null); await unmount(instance); console.log(failures.length === 0 ? "\nsmoke: all good" : `\nsmoke: ${failures.length} FAILED`); process.exit(failures.length === 0 ? 0 : 1);