// 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"); }); /// improvise-1x9h: a message that stands beside an open editor is the user's /// until the editor closes, not the timer's. Evidence: clients.md. test("a standing message outlives the timer and goes with its editor", () => { 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("Formula error", true); assert.equal(clock.count(), 0, "a standing message arms no timer"); clock.fire(); assert.equal(visible, true, "the refusal hid while its editor was open"); toasts.offer("Formula error", true); assert.deepEqual(shown, ["Formula error"], "a repaint is not a second event"); toasts.offer("Formula error", false); assert.equal(visible, false, "the refusal outlived its editor"); assert.equal(clock.count(), 0, "leaving hides now, not on a timer"); toasts.offer("Formula error", true); assert.equal(visible, false, "a message that stopped standing came back"); }); /// The other direction: a message that does not stand is on the timer exactly /// as before (improvise-1x9h). test("a message that does not stand keeps its timer", () => { 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("Formula added", false); assert.equal(clock.count(), 1); clock.fire(); assert.equal(visible, false); }); 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 · " 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"]); });