Files
beaver-calendar/tests/smoke.mjs
T
hh b70331f7bd fix(ui): panels stay above the keyboard, the day two offers share is listed once
The date field would not open at all on some days: on a Friday "Friday" and
"In a week" resolve to the same day, and the option list was keyed by the day
it writes, so Svelte tore it down with each_key_duplicate. On a desktop this
took the whole view with it - the same bug, not two. Duplicate offers are
dropped (the more specific name wins) and the list is keyed by position, so
no caller can tear it down that way again.

Panels are placed against the visual viewport rather than the window, which
a phone's keyboard shrinks without resizing anything: they scroll inside what
is left, re-place as the keyboard animates in, and come back into view when
the keyboard covers the very row they hang off - visible beats staying next
to the anchor. The quick look centres the same way.

Its actions are thumb-sized where the pointer is coarse, with the keycaps
hidden - there is no keyboard to press them on - and it gains "no date" and a
close button, since a phone has no Escape. Both menus gain a plain "Edit"
beside "Edit in Tasks"; it was only ever reachable by pressing e.
2026-09-05 00:22:07 +02:00

702 lines
24 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();
}
// On a Friday "Friday" and "In a week" are the same day. The date field used
// to be keyed by that day and tore itself down, so the picker never opened.
{
store.today = "2026-09-04";
store.leaveToMain();
flushSync();
key(viewRoot, { key: "n", code: "KeyN" });
flushSync();
store.today = "2026-09-04";
flushSync();
const panel = panelOf();
for (let i = 0; i < 3; i += 1) {
(dom.window.document.activeElement ?? panel).dispatchEvent(
new dom.window.KeyboardEvent("keydown", { bubbles: true, key: "Tab", code: "Tab" }),
);
flushSync();
}
check("the date field opens on a friday", panelOf() !== null && store.editing !== null);
check(
"the day two offers share is listed once",
(panel.textContent.match(/Sep 11/gu) ?? []).length === 1,
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);