Files

622 lines
22 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 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"),
]),
);
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.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);