c960713f8c
The worker transport wrote "in-page worker · <doc>" when it spawned the worker, before anything had answered, so a tab whose worker could not start showed a success-looking status over a blank page. It now writes "starting the worker · <doc>…", and createShell's arm() takes an `answer` that sawFrame writes on the first frame, but only while the watchdog is still armed. A ReadOnly or SeedMissing notice lands BEFORE the Snapshot, and fail() has already disarmed, so the claim cannot overwrite the precise reason. The two-tab spec now logs every #status and denies both the premature claim and "did not answer" before the page clock is walked. A new spec pins the exact status sequence on the writable path. Reasoning: context/code/clients.md, "The watchdog". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
425 lines
15 KiB
JavaScript
425 lines
15 KiB
JavaScript
// What the page decides between the boundaries: transport selection, the two
|
|
// status facts, the watchdog and the extent arithmetic. No DOM is involved.
|
|
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import {
|
|
classifyWorkerMessage,
|
|
createShell,
|
|
createToasts,
|
|
documentTitle,
|
|
extentOf,
|
|
frameCostOf,
|
|
indicator,
|
|
isFatal,
|
|
NOTICE_OPEN_BY_DEFAULT,
|
|
noticeOpen,
|
|
noticeState,
|
|
noticeText,
|
|
socketUrl,
|
|
} from "../shell.js";
|
|
|
|
test("a bare peer takes the page's own scheme, and https never falls back", () => {
|
|
assert.equal(socketUrl("127.0.0.1:8787", "http:"), "ws://127.0.0.1:8787");
|
|
assert.equal(socketUrl("example.test", "https:"), "wss://example.test");
|
|
// An explicit `ws://` on an https page is UPGRADED, not honoured: the
|
|
// browser would refuse the mixed-content connection either way.
|
|
assert.equal(socketUrl("ws://example.test", "https:"), "wss://example.test");
|
|
assert.equal(socketUrl("ws://example.test", "http:"), "ws://example.test");
|
|
// `wss://` is honoured on both, and the match is case-insensitive.
|
|
assert.equal(socketUrl("WSS://example.test", "http:"), "WSS://example.test");
|
|
});
|
|
|
|
test("a peer whose host merely starts with ws is not a scheme", () => {
|
|
assert.equal(socketUrl("wsserver.test:9000", "http:"), "ws://wsserver.test:9000");
|
|
});
|
|
|
|
test("a repaint's model status cannot reach the transport's sink at all", () => {
|
|
// improvise-n6t8. The old `·` join kept this by convention; two sinks make
|
|
// it structural. A single sink would show the last write, so the fault this
|
|
// separates from is `shell` LOSING its text, not merely being appended to.
|
|
const seen = [];
|
|
const said = [];
|
|
const shell = createShell({
|
|
onShell: (t) => seen.push(t),
|
|
onModel: (t) => said.push(t),
|
|
timeoutMs: 10,
|
|
});
|
|
shell.fail("cannot reach 127.0.0.1:8787");
|
|
shell.setModel("Filter: Product = Latte");
|
|
shell.setModel("");
|
|
assert.deepEqual(seen, ["cannot reach 127.0.0.1:8787"]);
|
|
assert.deepEqual(said, ["Filter: Product = Latte", ""]);
|
|
});
|
|
|
|
/// `status_msg` is STATE: `repaint()` re-offers it on every revision, so a
|
|
/// toast dismissed on a timer alone comes back on the next keypress.
|
|
test("the same message offered again is a repaint, not a second event", () => {
|
|
const clock = fakeClock();
|
|
const shown = [];
|
|
let visible = false;
|
|
const toasts = createToasts({
|
|
onShow: (t) => {
|
|
shown.push(t);
|
|
visible = true;
|
|
},
|
|
onHide: () => {
|
|
visible = false;
|
|
},
|
|
ttlMs: 6000,
|
|
setTimer: clock.setTimer,
|
|
clearTimer: clock.clearTimer,
|
|
});
|
|
|
|
toasts.offer("records mode is not available in the browser yet");
|
|
assert.equal(visible, true);
|
|
clock.fire();
|
|
assert.equal(visible, false);
|
|
|
|
// The keypress. Nothing cleared `status_msg` in Rust, so the page is
|
|
// offered the identical string — and must NOT resurrect the toast.
|
|
toasts.offer("records mode is not available in the browser yet");
|
|
assert.equal(visible, false);
|
|
assert.equal(clock.count(), 0, "a repaint must not arm a second timer");
|
|
assert.deepEqual(shown, ["records mode is not available in the browser yet"]);
|
|
});
|
|
|
|
test("a different message shows, and replaces rather than queues", () => {
|
|
const clock = fakeClock();
|
|
const shown = [];
|
|
const toasts = createToasts({
|
|
onShow: (t) => shown.push(t),
|
|
onHide: () => shown.push(null),
|
|
ttlMs: 6000,
|
|
setTimer: clock.setTimer,
|
|
clearTimer: clock.clearTimer,
|
|
});
|
|
toasts.offer("drill is not available in the browser yet");
|
|
toasts.offer("drilling into an aggregate needs the host");
|
|
// One timer, not two: the second message's window starts fresh.
|
|
assert.equal(clock.count(), 1);
|
|
assert.deepEqual(shown, [
|
|
"drill is not available in the browser yet",
|
|
"drilling into an aggregate needs the host",
|
|
]);
|
|
});
|
|
|
|
test("a host that clears the status hides the toast without waiting", () => {
|
|
const clock = fakeClock();
|
|
let visible = false;
|
|
const toasts = createToasts({
|
|
onShow: () => {
|
|
visible = true;
|
|
},
|
|
onHide: () => {
|
|
visible = false;
|
|
},
|
|
ttlMs: 6000,
|
|
setTimer: clock.setTimer,
|
|
clearTimer: clock.clearTimer,
|
|
});
|
|
toasts.offer("saved");
|
|
toasts.offer("");
|
|
assert.equal(visible, false);
|
|
assert.equal(clock.count(), 0, "a cleared message leaves no timer behind");
|
|
});
|
|
|
|
test("an empty status on the first frame shows nothing", () => {
|
|
// The common case: every repaint before any command offers "". A feed that
|
|
// treated the first offer as new would flash an empty toast on load.
|
|
const shown = [];
|
|
const toasts = createToasts({
|
|
onShow: (t) => shown.push(t),
|
|
onHide: () => shown.push(null),
|
|
setTimer: () => 1,
|
|
clearTimer: () => {},
|
|
});
|
|
toasts.offer("");
|
|
toasts.offer(undefined);
|
|
assert.deepEqual(shown, []);
|
|
});
|
|
|
|
/// A clock the test drives, so the watchdog's arming is asserted rather than
|
|
/// waited for. `fire()` runs what is pending, as the event loop would.
|
|
function fakeClock() {
|
|
let next = 1;
|
|
const pending = new Map();
|
|
return {
|
|
setTimer: (fn) => {
|
|
const id = next++;
|
|
pending.set(id, fn);
|
|
return id;
|
|
},
|
|
clearTimer: (id) => pending.delete(id),
|
|
fire: () => {
|
|
const due = [...pending.values()];
|
|
pending.clear();
|
|
for (const fn of due) fn();
|
|
},
|
|
count: () => pending.size,
|
|
};
|
|
}
|
|
|
|
test("the watchdog speaks when the peer never answers the handshake", () => {
|
|
const clock = fakeClock();
|
|
const seen = [];
|
|
const shell = createShell({
|
|
onShell: (t) => seen.push(t),
|
|
timeoutMs: 8000,
|
|
setTimer: clock.setTimer,
|
|
clearTimer: clock.clearTimer,
|
|
});
|
|
shell.setShell("connecting…");
|
|
shell.arm("sent no document");
|
|
assert.equal(shell.armed(), true);
|
|
clock.fire();
|
|
assert.equal(seen.at(-1), "sent no document");
|
|
});
|
|
|
|
test("a loud failure DISARMS the watchdog rather than being replaced by it", () => {
|
|
const clock = fakeClock();
|
|
const seen = [];
|
|
const shell = createShell({
|
|
onShell: (t) => seen.push(t),
|
|
timeoutMs: 8000,
|
|
setTimer: clock.setTimer,
|
|
clearTimer: clock.clearTimer,
|
|
});
|
|
shell.arm("sent no document");
|
|
shell.fail("disconnected");
|
|
assert.equal(shell.armed(), false);
|
|
assert.equal(clock.count(), 0);
|
|
clock.fire();
|
|
// The precise message is what stands, eight seconds later and forever.
|
|
assert.equal(seen.at(-1), "disconnected");
|
|
});
|
|
|
|
test("a frame that arrives is the answer the watchdog was waiting for", () => {
|
|
const clock = fakeClock();
|
|
const seen = [];
|
|
const shell = createShell({
|
|
onShell: (t) => seen.push(t),
|
|
timeoutMs: 8000,
|
|
setTimer: clock.setTimer,
|
|
clearTimer: clock.clearTimer,
|
|
});
|
|
shell.setShell("connected");
|
|
shell.arm("sent no document");
|
|
shell.sawFrame();
|
|
clock.fire();
|
|
assert.equal(seen.at(-1), "connected");
|
|
});
|
|
|
|
test("arming twice leaves one timer, not two", () => {
|
|
const clock = fakeClock();
|
|
const shell = createShell({
|
|
onShell: () => {},
|
|
timeoutMs: 8000,
|
|
setTimer: clock.setTimer,
|
|
clearTimer: clock.clearTimer,
|
|
});
|
|
shell.arm("first");
|
|
shell.arm("second");
|
|
assert.equal(clock.count(), 1);
|
|
});
|
|
|
|
test("the frame is charged for before the extent is announced", () => {
|
|
// 10 rows and 4 columns fit; one header level and one row-label column are
|
|
// not data, so the peer is asked for 9 by 3.
|
|
const box = { height: 10 * 26, width: 4 * 110 };
|
|
assert.deepEqual(extentOf(box, { rows: 1, cols: 1 }), { rows: 9, cols: 3 });
|
|
});
|
|
|
|
test("a window smaller than its own frame still asks for one cell", () => {
|
|
assert.deepEqual(extentOf({ height: 0, width: 0 }, { rows: 3, cols: 2 }), {
|
|
rows: 1,
|
|
cols: 1,
|
|
});
|
|
});
|
|
|
|
test("the frame costs a line per column level, plus one for a group line", () => {
|
|
const model = (levels, groups, rowLevels, totals = []) => ({
|
|
col_levels: levels,
|
|
col_groups: groups,
|
|
row_levels: rowLevels,
|
|
totals,
|
|
});
|
|
assert.deepEqual(frameCostOf(model([["a"]], [], 1)), { rows: 1, cols: 1 });
|
|
// Two category levels on the column axis, and a group line above them.
|
|
assert.deepEqual(frameCostOf(model([["a"], ["b"]], [{ span: 1 }], 2)), {
|
|
rows: 3,
|
|
cols: 2,
|
|
});
|
|
// And the totals strip, which is chrome on the same terms as a header
|
|
// level: always there when there are totals, never a data row.
|
|
assert.deepEqual(frameCostOf(model([["a"]], [], 1, [{ text: "9" }])), {
|
|
rows: 2,
|
|
cols: 1,
|
|
});
|
|
});
|
|
|
|
test("the collapse glyph points right when collapsed", () => {
|
|
assert.equal(indicator(true), "▶");
|
|
assert.equal(indicator(false), "▼");
|
|
});
|
|
|
|
test("the title bar spells the document the way the TUI's own title does", () => {
|
|
// `src/draw.rs::draw_title`: name, then what it is stored as in parens, then
|
|
// the dirty marker. The harness cannot pin `[+]` — the standalone deployment
|
|
// persists on every accepted batch, so the marker is transient there.
|
|
assert.equal(
|
|
documentTitle("Corner Coffee", true, "demo.improv"),
|
|
"Corner Coffee (demo.improv) [+]",
|
|
);
|
|
assert.equal(documentTitle("Corner Coffee", false, "demo.improv"), "Corner Coffee (demo.improv)");
|
|
// A host with nowhere to write it: `stored_as` is `None` over the wire, which
|
|
// arrives as `null`, and an empty pair of parens would be a lie about storage.
|
|
assert.equal(documentTitle("Untitled", true, null), "Untitled [+]");
|
|
});
|
|
|
|
test("a protocol frame is a string and a shell notice is an object", () => {
|
|
assert.deepEqual(classifyWorkerMessage('{"Commands":[]}'), {
|
|
kind: "frame",
|
|
text: '{"Commands":[]}',
|
|
});
|
|
const notice = { kind: "ReadOnly", document: "a.improv", detail: "held" };
|
|
assert.deepEqual(classifyWorkerMessage(notice), { kind: "notice", notice });
|
|
// The discriminating case: a notice serialized as a STRING would be fed to
|
|
// the fold, which is exactly the confusion the two shapes exist to prevent.
|
|
assert.equal(classifyWorkerMessage(JSON.stringify(notice)).kind, "frame");
|
|
});
|
|
|
|
test("a message that is neither is named rather than silently dropped", () => {
|
|
for (const junk of [null, undefined, 42, {}, { detail: "no kind" }]) {
|
|
assert.equal(
|
|
classifyWorkerMessage(junk).kind,
|
|
"unknown",
|
|
`classified ${JSON.stringify(junk)}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("every notice reads as a reason, and names its document", () => {
|
|
const of = (kind) => noticeText({ kind, document: "books.improv", detail: "404" });
|
|
// improvise-i0v9: the page must say what is wrong, not blame the browser.
|
|
assert.match(of("ReadOnly"), /read-only/);
|
|
assert.match(of("ReadOnly"), /another tab/);
|
|
// improvise-iobc: an empty document must not pass for an intentional one.
|
|
assert.match(of("SeedMissing"), /EMPTY/);
|
|
assert.match(of("SeedMissing"), /404/);
|
|
// improvise-zre1: "not saved" must not read like "saving".
|
|
assert.match(of("SaveFailed"), /NOT SAVED/);
|
|
assert.match(of("BootFailed"), /could not start/);
|
|
for (const kind of ["ReadOnly", "SeedMissing", "SaveFailed", "BootFailed"]) {
|
|
assert.match(of(kind), /books\.improv/, `${kind} did not name the document`);
|
|
}
|
|
});
|
|
|
|
test("a notice kind this page has never heard of still carries its reason", () => {
|
|
const text = noticeText({ kind: "SomethingNew", document: "a.improv", detail: "because" });
|
|
assert.match(text, /SomethingNew/);
|
|
assert.match(text, /because/);
|
|
});
|
|
|
|
test("only a failed boot is fatal — read-only, empty and unsaved all still paint", () => {
|
|
assert.equal(isFatal({ kind: "BootFailed" }), true);
|
|
for (const kind of ["ReadOnly", "SeedMissing", "SaveFailed"]) {
|
|
assert.equal(isFatal({ kind }), false, `${kind} was treated as fatal`);
|
|
}
|
|
});
|
|
|
|
test("a notice DISARMS the watchdog rather than being replaced by it", () => {
|
|
const clock = fakeClock();
|
|
const seen = [];
|
|
const shell = createShell({
|
|
onShell: (t) => seen.push(t),
|
|
timeoutMs: 8000,
|
|
setTimer: clock.setTimer,
|
|
clearTimer: clock.clearTimer,
|
|
});
|
|
// The order improvise-vep8 puts them in: armed first, then the reason.
|
|
shell.arm("the in-page worker did not answer (see console)");
|
|
assert.equal(shell.timesArmed(), 1, "the denial below is vacuous if it was never armed");
|
|
shell.fail(noticeText({ kind: "ReadOnly", document: "a.improv", detail: "" }));
|
|
clock.fire();
|
|
assert.match(seen.at(-1), /read-only/);
|
|
assert.equal(
|
|
seen.some((t) => t.includes("did not answer")),
|
|
false,
|
|
`the watchdog spoke over a precise reason: ${JSON.stringify(seen)}`,
|
|
);
|
|
});
|
|
|
|
// improvise-i0v9: "in-page worker · <doc>" was written before the worker had
|
|
// answered anything, and read as success over a blank page for eight seconds.
|
|
test("only the first frame claims the peer answered, and not before it", () => {
|
|
const clock = fakeClock();
|
|
const seen = [];
|
|
const shell = createShell({
|
|
onShell: (t) => seen.push(t),
|
|
timeoutMs: 8000,
|
|
setTimer: clock.setTimer,
|
|
clearTimer: clock.clearTimer,
|
|
});
|
|
shell.arm("did not answer", { answer: "in-page worker · a.improv" });
|
|
assert.deepEqual(seen, [], "the claim was made before any frame arrived");
|
|
shell.sawFrame();
|
|
shell.sawFrame();
|
|
assert.deepEqual(seen, ["in-page worker · a.improv"]);
|
|
});
|
|
|
|
// The order worker.js really produces on a locked document: the ReadOnly
|
|
// notice is posted after `start()` and BEFORE the Snapshot answers the handshake.
|
|
test("a reason given before the first frame is not replaced by the frame's claim", () => {
|
|
const clock = fakeClock();
|
|
const seen = [];
|
|
const shell = createShell({
|
|
onShell: (t) => seen.push(t),
|
|
timeoutMs: 8000,
|
|
setTimer: clock.setTimer,
|
|
clearTimer: clock.clearTimer,
|
|
});
|
|
shell.arm("did not answer", { answer: "in-page worker · a.improv" });
|
|
shell.fail(noticeText({ kind: "ReadOnly", document: "a.improv", detail: "" }));
|
|
shell.sawFrame();
|
|
clock.fire();
|
|
assert.equal(seen.length, 1, `#status said: ${JSON.stringify(seen)}`);
|
|
assert.match(seen[0], /read-only/);
|
|
});
|
|
|
|
// The notice's remembered state. The value crosses `localStorage`, which is
|
|
// allowed to forget and allowed to throw, so the interesting inputs are the
|
|
// ones that are NOT either spelling.
|
|
test("a notice state this page did not write is unanswered, not a third state", () => {
|
|
assert.equal(noticeOpen("open"), true);
|
|
assert.equal(noticeOpen("closed"), false);
|
|
// Everything storage can hand back when it has forgotten, been cleared,
|
|
// been blocked, or been written by an older page.
|
|
for (const stored of [null, undefined, "", "true", "false", "1", "0", "OPEN", {}]) {
|
|
assert.equal(
|
|
noticeOpen(stored),
|
|
NOTICE_OPEN_BY_DEFAULT,
|
|
`${JSON.stringify(stored)} was read as an answer`,
|
|
);
|
|
}
|
|
});
|
|
|
|
// A first-time visitor lands on the application, not on its configuration
|
|
// reference. The argument is in context/code/clients.md; this pins the call so
|
|
// that changing it is a change to a stated decision rather than a stray edit.
|
|
test("a visitor who has never answered gets the notice collapsed", () => {
|
|
assert.equal(NOTICE_OPEN_BY_DEFAULT, false);
|
|
assert.equal(noticeOpen(null), false);
|
|
});
|
|
|
|
test("the two spellings round-trip, in both directions", () => {
|
|
for (const open of [true, false]) {
|
|
assert.equal(noticeOpen(noticeState(open)), open);
|
|
}
|
|
// And `noticeState` produces only spellings `noticeOpen` recognises — the
|
|
// half that would break silently, since an unrecognised value reads as the
|
|
// default and a visitor's answer would simply stop being kept.
|
|
assert.deepEqual([noticeState(true), noticeState(false)].sort(), ["closed", "open"]);
|
|
});
|