Files
beaver-calendar/tests/smoke.mjs
T
hh 9c0e57e29b feat(archive): completed cards move to a mirror of the boards folder
"Archive completed tasks" empties every board of what is finished into a
twin of the boards folder - `Boards/work.md` into `Boards/archive/work.md` -
itself a Kanban board with the same lanes, so the history opens like any
other board and the live one stays short. The same pass lifts a board's
native archive (everything below the `***` separator) into that mirror, so
finished work is not left sitting in two places.

Archived tasks render beside live ones when "Include the archive" and "Show
completed" are both on, from the mirrors and from any `***` section still in
a board. They carry an Archive badge instead of a project chip, ride with
the sphere they came from in the rail, and refuse drops.
2026-09-05 00:21:36 +02:00

674 lines
23 KiB
JavaScript

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 ARCHIVE = "archive";
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("<!doctype html><html><body></body></html>", {
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"),
]),
);
// A mirror file, the way the archive command leaves one behind.
const MIRROR = `${FOLDER}/${ARCHIVE}/personal.md`;
files.set(
MIRROR,
[
"---",
"",
"kanban-plugin: board",
"",
"---",
"",
"## errands",
"",
"- [x] hang the shelf 📅 2026-07-02 ✅ 2026-07-03",
"",
].join("\n"),
);
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,
archiveFolder: 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}`);
check(
"the mirror file is not a sphere of its own",
store.spheres.every((sphere) => !sphere.path.includes(`/${ARCHIVE}/`)),
store.spheres.map((sphere) => sphere.path).join(", "),
);
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"));
check("the archive folder is rendered too", text().includes("hang the shelf"));
const mirrored = store.all.find((task) => task.text === "hang the shelf");
check("a mirrored task is archive", mirrored?.inArchive === true);
check(
"it keeps the lane it was archived from",
mirrored?.project === "errands",
`${mirrored?.project}`,
);
check(
"it groups under the board it mirrors, not beside it",
store.groups.filter((group) => group.label === "personal").length === 1,
store.groups.map((group) => group.label).join(", "),
);
// The rail knows nothing of the mirror file, so archived cards have to ride
// with the sphere they came from.
store.soloSphere(`${FOLDER}/personal.md`);
flushSync();
await settle();
check("the rail keeps mirrored cards with their sphere", text().includes("hang the shelf"));
store.soloSphere(`${FOLDER}/work.md`);
flushSync();
await settle();
check("another sphere leaves them out", !text().includes("hang the shelf"));
store.filterAll();
flushSync();
await settle();
store.includeArchive = false;
flushSync();
check("archive hidden again", !text().includes("pick up the parcel"));
check("the archive folder is hidden with it", !text().includes("hang the shelf"));
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.filterAll();
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` opens the spheres and steps in; `b` again shuts them and steps out,
// whether or not they were open before.
store.undatedCollapsed = false;
store.railCollapsed = false;
key(viewRoot, { key: "b", code: "KeyB" });
flushSync();
check("b took the cursor into the rail", store.zone === "rail", store.zone);
check("the rail stayed open", store.railCollapsed === false);
key(viewRoot, { key: "b", code: "KeyB" });
flushSync();
check("b again stepped out", store.zone !== "rail", store.zone);
check("and shut the rail even though it was open before", store.railCollapsed === true);
key(viewRoot, { key: "b", code: "KeyB" });
flushSync();
check("b reopened the shut rail and stepped in", store.zone === "rail" && !store.railCollapsed);
key(viewRoot, { key: "Escape", code: "Escape" });
flushSync();
check("escape stepped out and left the rail open", store.zone !== "rail" && !store.railCollapsed, store.zone);
// The rail is a gate over lanes: Enter picks a row out, space flips one.
key(viewRoot, { key: "b", code: "KeyB" });
flushSync();
const everything = store.visible.length;
key(viewRoot, { key: "j", code: "KeyJ" });
key(viewRoot, { key: "Enter", code: "Enter" });
flushSync();
check("enter on a sphere shows only that sphere", store.railState === "some" && store.visible.length < everything, `${store.visible.length} of ${everything}`);
check("the cursor stayed in the rail", store.zone === "rail", store.zone);
check("the first sphere is wholly on", store.sphereState(store.spheres[0].path) === "all");
check("the second is off", store.sphereState(store.spheres[1].path) === "none");
check("the rail says how much is through", text().includes("lanes") && text().includes("Show all"));
key(viewRoot, { key: "Enter", code: "Enter" });
flushSync();
check("enter again on the same lone sphere opens the gate", store.railState === "all" && store.visible.length === everything);
key(viewRoot, { key: "j", code: "KeyJ" });
key(viewRoot, { key: " ", code: "Space" });
flushSync();
check("space flips one sphere off, the rest stays", store.sphereState(store.spheres[1].path) === "none" && store.sphereState(store.spheres[0].path) === "all");
key(viewRoot, { key: " ", code: "Space" });
flushSync();
check("space again flips it back on, and a full set is the open gate", store.railState === "all");
key(viewRoot, { key: "k", code: "KeyK" });
key(viewRoot, { key: "k", code: "KeyK" });
key(viewRoot, { key: "Enter", code: "Enter" });
flushSync();
check("enter on the top row with everything on shuts the gate", store.railState === "none" && store.visible.length === 0);
check("the list says why it is empty", text().includes("Nothing let through") || store.view === "calendar");
key(viewRoot, { key: "j", code: "KeyJ" });
key(viewRoot, { key: "l", code: "KeyL" });
key(viewRoot, { key: "j", code: "KeyJ" });
key(viewRoot, { key: " ", code: "Space" });
flushSync();
check("a lane can be let through on its own", store.railState === "some" && store.visible.length > 0 && store.visible.every((task) => task.path === store.spheres[0].path), `${store.visible.length}`);
check("its sphere reads as partly on", store.sphereState(store.spheres[0].path) === "some" || store.spheres[0].projects.length === 1);
key(viewRoot, { key: "k", code: "KeyK" });
key(viewRoot, { key: "k", code: "KeyK" });
key(viewRoot, { key: "Enter", code: "Enter" });
flushSync();
check("enter on the top row with some on opens the gate", store.railState === "all");
key(viewRoot, { key: "b", code: "KeyB" });
flushSync();
// The list has no day grid, so its cursor starts on a task from the first key.
store.view = "list";
store.railCollapsed = false;
store.leaveToMain();
store.cursorTask = null;
store.filterAll();
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);
}
// Space over a task is the quick look: the whole text, and the actions.
{
store.view = "list";
store.leaveToMain();
store.cursorTask = null;
flushSync();
key(viewRoot, { key: "j", code: "KeyJ" });
flushSync();
const under = store.taskById(store.cursorTask);
key(viewRoot, { key: " ", code: "Space" });
flushSync();
await settle();
const lens = dom.window.document.querySelector("[aria-label='Quick look']");
check("space opened the quick look", store.peeking && lens !== null);
check("it shows the whole task", lens?.textContent.includes(under.text.slice(0, 20)));
check("it shows the actions", lens?.textContent.includes("edit") && lens?.textContent.includes("priority"));
key(viewRoot, { key: "j", code: "KeyJ" });
flushSync();
check("the lens follows the cursor", store.peeking && store.cursorTask !== under.id);
const toggled = store.cursorTask;
const was = store.taskById(toggled).status;
key(viewRoot, { key: "x", code: "KeyX" });
await settle();
flushSync();
check("keys still reach the task underneath", store.taskById(toggled)?.status !== was || store.taskById(toggled) === null);
key(viewRoot, { key: " ", code: "Space" });
flushSync();
check("space again puts the lens away", !store.peeking);
key(viewRoot, { key: " ", code: "Space" });
flushSync();
key(viewRoot, { key: "Escape", code: "Escape" });
flushSync();
check("escape puts it away too, without leaving the task", !store.peeking && store.zone === "task");
key(viewRoot, { key: " ", code: "Space" });
flushSync();
key(viewRoot, { key: "e", code: "KeyE" });
flushSync();
check("edit closes the lens and opens the editor", !store.peeking && store.editing !== null);
panelOf()?.dispatchEvent(new dom.window.KeyboardEvent("keydown", { bubbles: true, key: "Escape", code: "Escape" }));
flushSync();
}
// The clock is a signal: the editor offers the store's today, not the day
// the view was opened on.
{
store.today = "2031-03-09";
store.leaveToMain();
flushSync();
key(viewRoot, { key: "n", code: "KeyN" });
flushSync();
check("opening the editor re-reads the clock", store.today !== "2031-03-09");
store.today = "2031-03-09";
flushSync();
const panel = panelOf();
const tab = () => {
(dom.window.document.activeElement ?? panel).dispatchEvent(
new dom.window.KeyboardEvent("keydown", { bubbles: true, key: "Tab", code: "Tab" }),
);
flushSync();
};
tab(); tab(); tab();
check("the date field offers the clock's tomorrow", panel.textContent.includes("Mar 10"), panel.textContent.slice(0, 200));
panel.dispatchEvent(new dom.window.KeyboardEvent("keydown", { bubbles: true, key: "Escape", code: "Escape" }));
flushSync();
store.tick();
}
// 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);