import { test } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { EventEmitter } from "node:events"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { App, parseKeys, quitLine, runApp } from "../src/app.mjs"; import { main, resolveDecision, SEAT_USAGE, USAGE } from "../src/cli.mjs"; import { CliError } from "../src/errors.mjs"; import { hostDir, hostFile, startTimeOf } from "../src/host.mjs"; import { decideShown, draw, strip, width, wrap as wrapCells } from "../src/screens.mjs"; import { broker, humanEnv, io, tmp } from "./helpers.mjs"; const here = dirname(fileURLToPath(import.meta.url)); const REPO = join(here, "..", "..", ".."); const NOW = Date.parse("2026-10-11T18:00:00.000Z"); const SIZES = [ [120, 36], [80, 24], [100, 30], [99, 24], [40, 10], ]; // Below 40x10 the frame says the terminal is too small. const TOO_SMALL = [ [39, 10], [40, 9], [3, 3], [1, 1], ]; const exitOf = async (p) => { try { await p; return 0; } catch (e) { if (!(e instanceof CliError)) throw e; return e.exitCode; } }; // An app on the in-process broker. `wrap` may replace verbs of the transport. function app(t, { wrap = (call) => call, business = "demo", ...opts } = {}) { const bus = broker(t); const out = { text: "", quit: null }; const a = new App({ business, transport: (b) => wrap(bus.transport(b)), write: (s) => (out.text = s), now: () => NOW, refreshMs: 0, askMs: 0, onQuit: (r) => (out.quit = r), ...opts, }); return { ...bus, a, out, screen: () => a.frame.map(strip).join("\n") }; } // Text a hostile or careless agent could put in a decision: control // characters, a bidi override, a newline, wide characters and a long word. const HOSTILE = "Push \x1b[31mred\x1b[0m \u202eevil\u202c to 発行 now?\nSecond line " + "x".repeat(150); // A transport with one of everything, in the broker's shapes, for the // screens the broker cannot seed (task snapshots need the tracker adapter). function fake(extra = {}) { const decision = (n, more = {}) => ({ id: `${String(n).repeat(8)}-aaaa-4bbb-8ccc-${"d".repeat(12)}`, action: "git.push.protected", class: "gated", blocking: n === 1, at: "2026-10-11T17:00:00.000Z", raised_by_role: "coder", raised_by_run: "coder-run-with-a-long-name-0123456789", question: n === 1 ? HOSTILE : "Push the release?", options: [ { key: "yes", text: "Allow" }, { key: "no", text: "Decline" }, ], recommendation: "no", authorization: { action: "git.push.protected", target: "refactor-with-a-very-long-branch-name-indeed", approvalChoice: "yes" }, task_ref: "vikunja:1/7", ...more, }); const data = { // Emoji and CJK that are two cells wide (P3b in the round 1 review). inbox: [ decision(1), decision(2, { action: "✅✅ ship", authorization: { action: "git.push.protected", target: "⚡ main", approvalChoice: "yes" } }), decision(3, { authorization: { action: "git.push.protected", target: "🅰️ branch", approvalChoice: "yes" } }), ], tasks: [ { seq: 1, at: "2026-10-11T16:00:00.000Z", business: "demo", task_ref: "vikunja:1/7", updated: "x", digest: "d", fields: { title: HOSTILE, status: "in progress" }, source: "poll", via: "board", read_at: "2026-10-11T16:00:00.000Z" }, { seq: 2, at: "2026-10-11T16:00:00.000Z", business: "demo", task_ref: "vikunja:1/8", updated: "x", digest: "d", fields: { title: "発行 ✅", done: true }, source: "self", via: null, read_at: null }, ], agents: [{ role: "coder", holder_run: "coder-run-with-a-long-name-0123456789", harness: "pi", at: "2026-10-11T15:00:00.000Z" }], trail: [ { at: "2026-10-11T17:00:00.000Z", table: "decisions", seq: 4, id: "x", task_ref: "vikunja:1/7", question: HOSTILE }, { at: "2026-10-11T17:05:00.000Z", table: "events", seq: 9, kind: "task.changed.external", body: { changed: ["bucket"] } }, ], ...extra, }; return () => async (verb) => structuredClone(data[verb] ?? []); } function fits(frame, cols, rows, label) { assert.equal(frame.length, rows, `${label}: ${frame.length} rows`); frame.forEach((l, i) => assert.equal(width(strip(l)), cols, `${label}, row ${i}: ${JSON.stringify(strip(l))}`)); } // Every view and overlay, drawn at the given size. async function everyScreen(t, cols, rows, color) { const shots = []; const a = new App({ business: "demo", transport: fake(), cols, rows, color, now: () => NOW, refreshMs: 0 }); const shot = (label) => shots.push([label, [...a.render()]]); await a.start(); shot("inbox"); for (const [key, label] of [["t", "tasks"], ["a", "agents"], ["c", "talk"]]) { await a.input(key); shot(label); } await a.input("i"); await a.input("v"); shot("trail"); await a.input("\r"); shot("trail detail"); await a.input("\x1b"); for (const [key, label] of [["t", "tasks detail"], ["a", "agents detail"], ["i", "inbox detail"]]) { await a.input(key); await a.input("\r"); shot(label); await a.input("\x1b"); } await a.input("?"); shot("help"); await a.input("\x1b"); await a.input(":trail vikunja:1/7"); shot("command line"); await a.input("\x1b"); for (const o of [ { kind: "decide", lines: ["decision 11111111", ` question: ${HOSTILE}`, "your choice: yes (Allow); this authorizes the action"], asking: "resolve 11111111 with yes? [y/N] " }, { kind: "decide", lines: Array.from({ length: 40 }, (_, i) => `line ${i}`).concat("your choice: yes (Allow)"), asking: "resolve 11111111 with yes? [y/N] " }, { kind: "unknown", message: "outcome unknown: the broker may have recorded it. Check mosaic inbox or mosaic trail 11111111-aaaa-4bbb-8ccc-dddddddddddd before trying again", id: "x" }, { kind: "error", title: "decide", message: `failed: ${HOSTILE}` }, ]) { a.state.overlay = o; shot(`overlay ${o.kind}${o.lines?.length > 10 ? " long" : ""}`); } a.state.overlay = null; // Empty, failed-with-stale-data, refused and no-business states. a.state.data.inbox = { list: [], at: new Date(NOW).toISOString() }; shot("inbox empty"); a.state.data.inbox = { list: await fake()()("inbox"), at: "2026-10-11T17:59:00.000Z", error: "invalid-response" }; shot("inbox stale"); a.state.data.inbox = { error: "human-required", refused: true }; shot("inbox refused"); a.state.business = null; shot("no business"); assert.equal(a.closed, false, `${cols}x${rows}: ${a.error?.stack}`); a.quit("q"); return shots; } test("every screen and overlay fits 120x36, 80x24 and the narrow layout, row for row", async (t) => { for (const [cols, rows] of SIZES) { for (const color of [true, false]) { for (const [label, frame] of await everyScreen(t, cols, rows, color)) fits(frame, cols, rows, `${cols}x${rows} ${color ? "colour" : "NO_COLOR"} ${label}`); } // A decide box too tall to show, with no question to cut, says the // decision is hidden and that any key answers No (Dewey B1, round 3). const long = (await everyScreen(t, cols, rows, false)).find(([l]) => l === "overlay decide long")[1].join("\n"); assert.match(long, /Too small to show this decision\.[\s\S]*Any key answers No\./, `${cols}x${rows}`); assert.doesNotMatch(long, /y resolves/, `${cols}x${rows}`); } // Too small: every screen still fits, and says why. for (const [cols, rows] of TOO_SMALL) { for (const [label, frame] of await everyScreen(t, cols, rows, true)) fits(frame, cols, rows, `${cols}x${rows} ${label}`); } assert.match((await everyScreen(t, 39, 10, false))[0][1].join(""), /The terminal is 39x10; the app needs at least 40x10/); // Designed at 120x36: the inbox shows the raiser; under 100 columns the nav shows keys only. const [wide] = await everyScreen(t, 120, 36, false); assert.match(strip(wide[1][0]), /\[i Inbox 3\]/); assert.match(wide[1].map(strip).join("\n"), /RAISED BY/); const [narrow] = await everyScreen(t, 99, 24, false); assert.match(strip(narrow[1][0]), /\[i 3\] t a /); assert.doesNotMatch(narrow[1].map(strip).join("\n"), /RAISED BY/); }); test("width counts terminal cells: wide CJK and emoji 2, U+FE0F after a one-cell base 1, marks 0", async (t) => { for (const [s, cells] of [["a", 1], ["発行", 4], ["✅", 2], ["⚡", 2], ["❌", 2], ["⭐", 2], ["😀", 2], ["🅰", 1], ["🅰\uFE0F", 2], ["❤\uFE0F", 2], ["😀\uFE0F", 2], ["e\u0301", 1], ["✓", 1], ["›", 1], ["\u{1F210}", 2]]) { assert.equal(width(s), cells, JSON.stringify(s)); } // East Asian Width W and F outside the blocks above: one from each range. for (const n of [0x2329, 0x232a, 0xa960, 0xfe10, 0xfe50, 0x16fe0, 0x17000, 0x1aff0, 0x1b2fb]) assert.equal(width(String.fromCodePoint(n)), 2, n.toString(16)); // A line break never splits ❤️ from its U+FE0F; no line is wider than asked. assert.deepEqual(wrapCells("abc❤\uFE0F x", 4), ["abc", "❤\uFE0F x"]); for (const l of wrapCells("ab ❤\uFE0F❤\uFE0F e\u0301e\u0301e\u0301", 4)) assert.ok(width(l) <= 4, JSON.stringify(l)); // On screen, independent of width(): each wide character takes two cells // for one UTF-16 unit, so a row of `cols` cells is that much shorter in // units. 🅰️ is three units for two cells. const frames = Object.fromEntries(await everyScreen(t, 80, 24, false)); const row = (label, text) => strip(frames[label].find((l) => l.includes(text))); assert.equal(row("inbox", "✅✅ ship").length, 80 - 3, "two ✅ and one ⚡"); assert.equal(row("inbox", "🅰️ branch").length, 80 + 1, "🅰️"); assert.equal(row("tasks", "発行 ✅").length, 80 - 3, "発行 and ✅, not cut"); }); test("broker text is shown, never interpreted: no raw control character or bidi override reaches the terminal", async (t) => { for (const [label, frame] of await everyScreen(t, 120, 36, true)) { for (const l of frame) { const bare = l.replace(/\x1b\[[0-9;]*m/g, ""); assert.doesNotMatch(bare, /[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/u, label); } } const shots = await everyScreen(t, 120, 36, true); assert.match(shots.find(([l]) => l === "inbox detail")[1].join("\n"), /\^\[\[31mred\^\[\[0m evil/); }); test("NO_COLOR: no escape codes, except reverse video on the selected row", async (t) => { for (const [label, frame] of await everyScreen(t, 120, 36, false)) { for (const l of frame) assert.doesNotMatch(l.replace(/\x1b\[(7|27)m/g, ""), /\x1b/, label); } // With nothing to select, nothing at all. const a = new App({ business: "demo", transport: fake({ inbox: [], tasks: [], agents: [] }), color: false, now: () => NOW, refreshMs: 0 }); await a.start(); for (const key of ["t", "a", "c", "r", "?"]) { await a.input(key); assert.doesNotMatch(a.render().join("\n"), /\x1b/, key); } a.quit("q"); // The selected row is reversed, with colour and without. const [[, plain]] = await everyScreen(t, 120, 36, false); assert.equal(plain.filter((l) => l.startsWith("\x1b[7m") && l.endsWith("\x1b[27m")).length, 1); // The colour build does use colour. const [[, coloured]] = await everyScreen(t, 120, 36, true); assert.equal(coloured.filter((l) => l.startsWith("\x1b[7m") && l.endsWith("\x1b[27m")).length, 1); assert.match(coloured.join("\n"), /\x1b\[3[0-9]m/); }); test("1-9 shows the decide flow's own lines and question; only y resolves", async (t) => { const s = app(t); const d = s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7" }); await s.a.start(); assert.match(s.screen(), new RegExp(d.id.slice(0, 8))); assert.match(s.a.state.status, /Press 1-2 to choose/); await s.a.input("1"); const box = s.screen(); assert.match(box, /your choice: yes \(Allow\); this authorizes the action/); assert.match(box, new RegExp(`resolve ${d.id.slice(0, 8)} with yes\\? \\[y/N\\]`)); assert.match(box, /y resolves · any other key cancels \(the default is No\)/); assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"), "nothing is sent before y"); await s.a.input("y"); assert.deepEqual(s.calls.filter((c) => c.verb === "decision.resolve"), [{ business: "demo", verb: "decision.resolve", args: { id: d.id, choice: "yes" } }]); assert.deepEqual(s.read("inbox"), []); assert.equal(s.a.state.status, `resolved ${d.id}: yes`); assert.equal(s.a.state.overlay, null); assert.equal(s.a.resolved, 1); assert.match(s.screen(), /Nothing is waiting on you\./); // The second option declines. const e = s.raise("git.push.protected", { target: "next" }); await s.a.input("i"); await s.a.input("2"); assert.match(s.screen(), /your choice: no \(Decline\); this declines the action/); await s.a.input("Y"); assert.equal(s.a.state.status, `resolved ${e.id}: no`); s.a.input("q"); assert.deepEqual(s.out.quit, { how: "q", resolved: 2, error: undefined }); assert.equal(quitLine(s.out.quit), "(app closed with q; 2 decisions resolved this session)"); assert.equal(quitLine({ how: "Ctrl-C", resolved: 1 }), "(app closed with Ctrl-C; 1 decision resolved this session)"); }); test("the app shows the same decide text as mosaic decide", async (t) => { const s = app(t); const d = s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7" }); // Without --yes and off a terminal, mosaic decide prints the decision and // the choice, then refuses to ask: the lines before its question. const x = io(); assert.equal(await exitOf(main(["decide", d.id, "no", "--business", "demo"], x, { system: { dataRoot: tmp(t) }, transport: s.transport })), 4); await s.a.start(); await s.a.input("2"); assert.deepEqual(s.a.state.overlay.lines, x.out.text.trimEnd().split("\n")); assert.equal(s.a.state.overlay.asking, `resolve ${d.id.slice(0, 8)} with no? [y/N] `); await s.a.input("n"); }); test("any key but y or Y at the question answers No; nothing is sent", async (t) => { for (const key of ["n", "N", "\x1b", "\r", "q", "x", " ", "2", "\x03", "\x1b[A"]) { const s = app(t); s.raise("git.push.protected", { target: "refactor" }); await s.a.start(); await s.a.input("1"); assert.ok(s.a.state.overlay?.asking, JSON.stringify(key)); await s.a.input(key); assert.equal(s.a.state.overlay, null, JSON.stringify(key)); assert.equal(s.a.state.status, "not resolved"); assert.equal(s.read("inbox").length, 1); assert.ok(!s.calls.some((c) => c.verb === "decision.resolve")); assert.equal(s.out.quit, null, "q and Ctrl-C at the question answer No; they do not quit"); } }); test("below 40x10 the question is hidden, so every key answers it No, y included (Filbert R3)", async (t) => { const flat = (s) => s.a.frame.map(strip).join(" ").replace(/\s+/g, " "); const notSent = (s, label) => { assert.equal(s.a.state.overlay, null, label); assert.equal(s.a.state.status, "not resolved", label); assert.equal(s.read("inbox").length, 1, label); assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"), label); }; // Too small from the start: the frame says a question is open, and y answers No. for (const [cols, rows] of [[30, 24], [80, 9]]) { const s = app(t, { cols, rows }); s.raise("git.push.protected", { target: "refactor" }); await s.a.start(); await s.a.input("1"); assert.ok(s.a.state.overlay?.asking, `${cols}x${rows}`); assert.match(flat(s), /A decision question is open; any key answers No\./, `${cols}x${rows}`); assert.doesNotMatch(flat(s), /q quits/, `${cols}x${rows}`); await s.a.input("y"); notSent(s, `${cols}x${rows}`); assert.match(flat(s), /Esc closes what is open; q quits\./, "the usual text once the question is gone"); } // Shrunk while the question is open. const s = app(t); s.raise("git.push.protected", { target: "refactor" }); await s.a.start(); await s.a.input("1"); assert.match(s.screen(), /y resolves/); s.a.resize(30, 24); assert.match(flat(s), /A decision question is open; any key answers No\./); await s.a.input("Y"); notSent(s, "shrunk"); // Shrunk and grown back before the key: the question is on screen again, so y resolves. const g = app(t); const d = g.raise("git.push.protected", { target: "refactor" }); await g.a.start(); await g.a.input("1"); g.a.resize(30, 24); g.a.resize(80, 24); assert.match(g.screen(), /y resolves/); await g.a.input("y"); assert.equal(g.a.state.status, `resolved ${d.id}: yes`); }); test("a throw while answering the question goes to crash, sends nothing and leaves no job pending", async (t) => { const boom = new Error("the terminal write failed"); let fail = false; const s = app(t, { write: () => { if (fail) throw boom; }, }); s.raise("git.push.protected", { target: "refactor" }); await s.a.start(); await s.a.input("1"); assert.ok(s.a.state.overlay?.asking); fail = true; await s.a.input("y"); assert.deepEqual(s.out.quit, { how: "an error", resolved: 0, error: boom }); for (let i = 0; s.a.job && i < 100; i++) await new Promise((r) => setImmediate(r)); assert.equal(s.a.job, null, "the decide job ended"); assert.ok(!s.calls.some((c) => c.verb === "decision.resolve")); }); test("type-ahead never answers the question: a paste, keys typed before it is drawn, keys inside askMs", async (t) => { const asking = (s) => Boolean(s.a.state.overlay?.asking); const sent = (s) => s.calls.filter((c) => c.verb === "decision.resolve").length; // P2a: "1 yes" pasted on the Inbox. const a = app(t); a.raise("git.push.protected", { target: "refactor" }); await a.a.start(); await a.a.input("1 yes"); assert.ok(asking(a), "P2a"); assert.equal(sent(a), 0, "P2a"); // P2b: "decide 1a2b yes" typed without the colon. const b = app(t); b.raise("git.push.protected", { target: "refactor" }); await b.a.start(); await b.a.input("decide 1a2b yes"); assert.ok(asking(b), "P2b"); assert.equal(sent(b), 0, "P2b"); // P2c: 1 and y as separate events, before the question is drawn. const c = app(t); c.raise("git.push.protected", { target: "refactor" }); await c.a.start(); await Promise.all([c.a.input("1"), c.a.input("y")]); assert.ok(asking(c), "P2c"); assert.equal(sent(c), 0, "P2c"); // A y that arrives after the question is drawn but within askMs, as keys // typed while a read blocked the screen do: one at once, one 10 ms later. let early; let d; d = app(t, { askMs: 300, write: (text) => { if (!early && text.includes("[y/N]")) { void d.a.input("y"); early = new Promise((r) => setTimeout(() => d.a.input("y").then(r), 10)); } }, }); d.raise("git.push.protected", { target: "refactor" }); await d.a.start(); await d.a.input("1"); await early; assert.ok(asking(d), "within askMs"); assert.equal(sent(d), 0, "within askMs"); while (!d.a.answer) await new Promise((r) => setTimeout(r, 10)); // After that, y resolves. for (const s of [a, b, c, d]) { await s.a.input("y"); assert.equal(sent(s), 1); assert.match(s.a.state.status, /^resolved /); } }); test("quitting at the question answers No and sends nothing, armed or not", { timeout: 10000 }, async (t) => { for (const askMs of [0, 60000]) { const s = app(t, { askMs }); s.raise("git.push.protected", { target: "refactor" }); await s.a.start(); const pending = s.a.input("1"); while (!s.a.state.overlay?.asking) await new Promise((r) => setImmediate(r)); s.a.quit("SIGTERM"); await pending; while (s.a.job) await new Promise((r) => setImmediate(r)); assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"), `askMs ${askMs}`); assert.deepEqual(s.out.quit, { how: "SIGTERM", resolved: 0, error: undefined }); } }); test("a timer tick reads nothing under an overlay, never answers the question, and keeps a resolved status", async (t) => { const s = app(t); const d = s.raise("git.push.protected", { target: "refactor" }); s.raise("git.push.protected", { target: "next" }); await s.a.start(); const reads = () => s.calls.filter((c) => c.verb === "inbox").length; const before = reads(); await s.a.input("?"); await s.a.input("\0refresh"); assert.equal(reads(), before, "help open"); await s.a.input("\x1b:"); await s.a.input("\0refresh"); assert.equal(reads(), before, "command line open"); await s.a.input("\x1b"); await s.a.input("\0refresh"); assert.equal(reads(), before + 1, "nothing open"); await s.a.input("1"); const asked = reads(); await s.a.input("\0refresh"); assert.ok(s.a.state.overlay?.asking, "a tick is not an answer"); assert.equal(reads(), asked); await s.a.input("y"); assert.equal(s.a.state.status, `resolved ${d.id}: yes`); await s.a.input("\0refresh"); assert.equal(s.a.state.status, `resolved ${d.id}: yes`, "N4"); assert.equal(s.a.state.data.inbox.list.length, 1); }); test("a long question is cut inside the question; the action, target, choice and prompt stay (Dewey B1)", async (t) => { const s = app(t, { cols: 80, rows: 24 }); const question = Array.from({ length: 90 }, (_, i) => `word${i}`).join(" "); assert.ok(question.length > 530); const d = s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7", question }); await s.a.start(); await s.a.input("1"); const screen = s.screen(); for (const want of [`decision ${d.id}`, " question: word0 word1", "options: yes = Allow; no = Decline", "recommended: no", "action: git.push.protected on refactor", 'choosing "yes" authorizes it; any other choice declines', "task: vikunja:1/7", "your choice: yes (Allow); this authorizes the action", `resolve ${d.id.slice(0, 8)} with yes? [y/N]`, "y resolves", "question cut to fit; the whole text is in mosaic inbox --json"]) { assert.ok(screen.includes(want), `${want}\n${screen}`); } assert.ok(!screen.includes("word89"), "the end of the question is what goes"); fits(s.a.frame, 80, 24, "long question"); }); // A gated, blocking decision with a task, and every line its decide box // must show before y can resolve it (Dewey B1, round 3). function gated(t, cols, rows, long) { const s = app(t, { cols, rows }); const question = long ? Array.from({ length: 90 }, (_, i) => `word${i}`).join(" ") : "Push the release?"; const d = s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7", question }); const keep = [`decision ${d.id}`, long ? "question: word0" : "question: Push the release?", "options: yes = Allow; no = Decline", "recommended: no", "action: git.push.protected on refactor", 'choosing "yes" authorizes it; any other choice declines', "task: vikunja:1/7", "your choice: yes (Allow); this authorizes the action", `resolve ${d.id.slice(0, 8)} with yes? [y/N]`, "y resolves · any other key cancels (the default is No)"]; return { ...s, d, keep }; } // Box borders and wrapping removed, so a line broken across rows still matches. const squash = (text) => text.replace(/[\s│]/g, ""); test("a decide box too big for the terminal says so, and every key answers it No, y included (Dewey B1, round 3)", async (t) => { const hidden = async (s, key, label) => { assert.ok(s.a.state.overlay?.asking, label); assert.match(s.screen(), /Too small to show this decision\.[\s\S]*Any key answers No\./, label); assert.doesNotMatch(s.screen(), /y resolves/, label); await s.a.input(key); assert.equal(s.a.state.overlay, null, label); assert.equal(s.a.state.status, "not resolved", label); assert.equal(s.read("inbox").length, 1, label); assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"), label); }; // Dewey's probe sizes: a short question at 80x18 and 40x24, a long one at 80x20. for (const [cols, rows, long] of [[80, 18, false], [40, 24, false], [80, 20, true], [120, 16, false], [80, 10, false]]) { const s = gated(t, cols, rows, long); await s.a.start(); await s.a.input("1"); await hidden(s, "y", `${cols}x${rows}${long ? " long" : ""}`); } // One row more and the whole box shows, so y resolves. for (const [cols, rows, long] of [[80, 20, false], [80, 21, true]]) { const s = gated(t, cols, rows, long); await s.a.start(); await s.a.input("1"); for (const want of s.keep) assert.ok(squash(s.screen()).includes(squash(want)), `${cols}x${rows}: ${want}\n${s.screen()}`); await s.a.input("y"); assert.equal(s.a.state.status, `resolved ${s.d.id}: yes`, `${cols}x${rows}`); } // Shrunk after the question is armed: checked at the key. const s = gated(t, 80, 24, false); await s.a.start(); await s.a.input("1"); assert.match(s.screen(), /y resolves/); s.a.resize(80, 15); await hidden(s, "Y", "shrunk to 80x15"); }); test("at every size the decide box shows each line y depends on, or says it is hidden", async (t) => { for (const long of [false, true]) { const s = gated(t, 120, 40, long); await s.a.start(); await s.a.input("1"); const seen = { shown: 0, hidden: 0 }; for (let cols = 40; cols <= 120; cols += 4) { for (let rows = 10; rows <= 40; rows++) { const frame = draw(s.a.state, { cols, rows, color: false, now: NOW }); const label = `${cols}x${rows}${long ? " long" : ""}`; fits(frame, cols, rows, label); const text = frame.map(strip).join("\n"); if (decideShown(s.a.state.overlay, cols, rows)) { seen.shown++; for (const want of s.keep) assert.ok(squash(text).includes(squash(want)), `${label}: ${want}`); } else { seen.hidden++; assert.match(text, /Too small to show this decision\.[\s\S]*Any key answers No\./, label); assert.doesNotMatch(text, /y resolves/, label); } } } assert.ok(seen.shown > 100 && seen.hidden > 100, JSON.stringify(seen)); s.a.quit("q"); } }); test("outcome unknown: the CLI's sentence, no retry, then the trail or a re-read", async (t) => { let resolves = 0; const s = app(t, { wrap: (call) => async (verb, args) => { if (verb !== "decision.resolve") return call(verb, args); resolves += 1; const e = new Error("outcome-unknown"); e.code = "outcome-unknown"; throw e; }, }); const d = s.raise("git.push.protected", { target: "refactor" }); await s.a.start(); await s.a.input("1"); await s.a.input("y"); assert.equal(resolves, 1); assert.equal(s.a.state.overlay.kind, "unknown"); assert.equal(s.a.state.overlay.message, `outcome unknown: the broker may have recorded it. Check mosaic inbox or mosaic trail ${d.id} before trying again`); assert.match(s.screen(), /Nothing was retried, and the choice was not sent twice\./); await s.a.input("1y:decide x yes\ry"); assert.equal(resolves, 1, "no key sends the choice again"); await s.a.input("v"); assert.equal(s.a.state.view, "trail"); assert.equal(s.a.state.data.trail.subject, d.id); assert.ok(s.a.state.data.trail.list.length > 0); // The transport's own "did not finish" error has no code, only the // hyphenated message; it opens the same box, and Esc closes it. const hung = app(t, { wrap: (call) => async (verb, args) => { if (verb === "decision.resolve") throw new CliError("outcome-unknown: the bus transport did not finish", 1); return call(verb, args); }, }); hung.raise("git.push.protected", { target: "refactor" }); await hung.a.start(); await hung.a.input("1"); await hung.a.input("y"); assert.deepEqual([hung.a.state.overlay.kind, hung.a.state.overlay.message], ["unknown", "outcome-unknown: the bus transport did not finish"]); await hung.a.input("\x1b"); assert.equal(hung.a.state.overlay, null); }); test("refused and failed reads keep the last list, marked stale; a refused decide says refused", async (t) => { let fail = null; const s = app(t, { wrap: (call) => async (verb, args) => { if (fail && verb === "inbox") { const e = new Error(fail); e.code = fail; throw e; } return call(verb, args); } }); s.raise("git.push.protected", { target: "refactor" }); await s.a.start(); fail = "invalid-response"; await s.a.input("i"); assert.equal(s.a.state.data.inbox.list.length, 1); assert.match(s.screen(), /stale: invalid-response/); assert.equal(s.a.state.status, "read failed: invalid-response"); fail = "human-required"; await s.a.input("i"); assert.equal(s.a.state.status, "refused: human-required"); assert.equal(s.a.state.data.inbox.refused, true); fail = null; const decide = app(t, { wrap: (call) => async (verb, args) => { if (verb !== "decision.resolve") return call(verb, args); const e = new Error("unauthenticated"); e.code = "unauthenticated"; throw e; } }); decide.raise("git.push.protected", { target: "refactor" }); await decide.a.start(); await decide.a.input("1"); await decide.a.input("y"); assert.deepEqual([decide.a.state.overlay.kind, decide.a.state.overlay.message], ["error", "refused: unauthenticated"]); }); test("keys: views, moving, trail of a selection, the command line, help and quit", async (t) => { const s = app(t); const a = s.raise("git.push.protected", { target: "one" }); const b = s.raise("git.push.protected", { target: "two" }); await s.a.start(); await s.a.input("j"); assert.equal(s.a.state.sel.inbox, 1); await s.a.input("\x1b[B\x1b[B"); assert.equal(s.a.state.sel.inbox, 1, "the selection stops at the last row"); await s.a.input("k"); await s.a.input("v"); assert.equal(s.a.state.data.trail.subject, s.read("inbox")[0].id); await s.a.input("a"); assert.match(s.screen(), /coder-run/); await s.a.input(":trail " + b.id + "\r"); assert.equal(s.a.state.data.trail.subject, b.id); await s.a.input(":decide " + a.id.slice(0, 8) + " no\r"); assert.equal(s.a.state.overlay.kind, "decide"); await s.a.input("y"); assert.equal(s.a.state.status, `resolved ${a.id}: no`); await s.a.input(":nope\r"); assert.match(s.a.state.status, /unknown command :nope/); await s.a.input(":abc\x7f\x7f\x7fq\r"); assert.deepEqual(s.out.quit, { how: "q", resolved: 1, error: undefined }); // ? opens help; Esc closes it; Ctrl-C quits. const h = app(t); await h.a.start(); await h.a.input("?"); assert.match(h.screen(), /Decisions resolve only here or with mosaic decide/); await h.a.input("\x1b"); assert.equal(h.a.state.overlay, null); await h.a.input("\x03"); assert.equal(h.out.quit.how, "Ctrl-C"); assert.deepEqual(parseKeys("a\x1b[A\x1b[5~\x1bOB\x1b[1;5C\r\x1b"), ["a", "up", "pgup", "down", "enter", "esc"]); // Arrows on the command line move nothing and type nothing. await h.a.start(); const c = app(t); await c.a.start(); await c.a.input(":ab\x1b[A\x1b[Bc"); assert.equal(c.a.state.command, "abc"); // v on Tasks opens the trail of the task. const f = app(t, { transport: fake() }); await f.a.start(); await f.a.input("t"); await f.a.input("v"); assert.equal(f.a.state.data.trail.subject, "vikunja:1/7"); assert.match(f.screen(), /Trail {2}vikunja:1\/7 · 2 row\(s\)/); // :business switches: the old business's lists and selections go. await f.a.input("t"); await f.a.input("j"); assert.equal(f.a.state.sel.tasks, 1); const calls = []; f.a.transport = (b) => async (verb) => (calls.push([b, verb]), []); await f.a.input(":business other\r"); assert.equal(f.a.state.business, "other"); assert.equal(f.a.state.sel.tasks, 0); assert.equal(f.a.state.data.trail, undefined); assert.deepEqual(f.a.state.data.tasks.list, []); assert.deepEqual(calls, [["other", "tasks"]]); }); test("one option: the status line says Press 1, not 1-1", async (t) => { const base = fake(); const s = app(t, { transport: () => async (verb) => { const rows = await base()(verb); if (verb === "inbox") rows.forEach((d) => (d.options = d.options.slice(0, 1))); return rows; }, }); await s.a.start(); assert.match(s.screen(), /Press 1 to choose\./); const two = app(t); two.raise("git.push.protected", { target: "refactor" }); await two.a.start(); assert.match(two.screen(), /Press 1-2 to choose\./); }); test("no business: the app says so and :business picks one", async (t) => { const s = app(t, { business: null }); s.raise("git.push.protected", { target: "refactor" }); await s.a.start(); assert.match(s.screen(), /No bus host runs here, so the app knows no business\./); assert.equal(s.calls.length, 0); await s.a.input(":business demo\r"); assert.equal(s.a.state.business, "demo"); assert.equal(s.a.state.data.inbox.list.length, 1); }); test("mosaic with no argument: exit 3 in an agent run, exit 4 with the usage off a terminal, the app on one", async (t) => { const dataRoot = tmp(t); const tty = (env = humanEnv()) => { const x = io(env); x.stdin.isTTY = true; x.stdout.isTTY = true; return x; }; // An agent run refuses before anything else, terminal or not. for (const marker of ["CLAUDECODE", "MOSAIC_AGENT_RUN", "PI_AGENT_DIR"]) { const x = tty({ ...humanEnv(), [marker]: "1" }); const err = await main([], x, { system: { dataRoot }, runApp: () => assert.fail("opened") }).catch((e) => e); assert.equal(err.exitCode, 3); assert.equal(err.message, `refused: this runs only from a human shell, outside any agent run (found ${marker} in the environment)`); } // Not a terminal on stdin, or on stdout: today's usage, exit 4. for (const [inTty, outTty] of [[false, true], [true, false], [false, false]]) { const x = io(); x.stdin.isTTY = inTty; x.stdout.isTTY = outTty; const err = await main([], x, { system: { dataRoot }, runApp: () => assert.fail("opened") }).catch((e) => e); assert.equal(err.exitCode, 4); assert.ok(err.message.endsWith(`\n${SEAT_USAGE}\n${USAGE}`), err.message); } // On a terminal: the app, then the quit line. const x = tty(humanEnv({ NO_COLOR: "1" })); let opened; await main([], x, { system: { dataRoot }, transport: () => async () => [], runApp: async (o) => ((opened = o), { how: "q", resolved: 0 }) }); assert.equal(opened.business, null, "no host state, no business"); assert.equal(opened.color, false); assert.equal(x.out.text, "(app closed with q; 0 decisions resolved this session)\n"); }); test("scripts/mosaic with no argument reaches the app entry; the seat usage it printed before has not drifted", () => { const env = humanEnv({ MOSAIC_CONFIG: "/nonexistent/config.json" }); const app = spawnSync(join(REPO, "scripts", "mosaic"), [], { env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); assert.equal(app.status, 4, app.stderr); assert.ok(app.stderr.startsWith("mosaic: the app needs a terminal on stdin and stdout"), app.stderr); assert.ok(app.stderr.endsWith(`${SEAT_USAGE}\n${USAGE}\n`)); const agent = spawnSync(join(REPO, "scripts", "mosaic"), [], { env: { ...env, CLAUDECODE: "1" }, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); assert.equal(agent.status, 3); const seat = spawnSync(process.execPath, [join(REPO, "packages", "seat", "src", "cli.mjs")], { env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); assert.equal(seat.status, 4); assert.equal(seat.stderr, `refused: ${SEAT_USAGE}\n`); }); test("strings to keep exactly live once, in the decide flow and the transport; the app does not restate them", () => { const src = (f) => readFileSync(join(here, "..", "src", f), "utf8"); const cli = src("cli.mjs"); for (const s of [ "`your choice: ${choice} (${option.text})${effect ? `; ${effect}` : \"\"}\\n`", '"this authorizes the action"', '"this declines the action"', "`resolve ${shortId(d.id)} with ${choice}? [y/N] `", "`resolved ${d.id}: ${choice}\\n`", "`outcome unknown: the broker may have recorded it. Check mosaic inbox or mosaic trail ${d.id} before trying again`", ]) assert.ok(cli.includes(s), s); assert.ok(src("transport.mjs").includes("`refused: this runs only from a human shell, outside any agent run (found ${found.join(\", \")} in the environment)`")); for (const f of ["app.mjs", "screens.mjs"]) { for (const s of ["authorizes the action", "declines the action", "[y/N]", "may have recorded it", "runs only from a human shell"]) assert.ok(!src(f).includes(s), `${f} restates ${s}`); } }); test("decide with --note still asks; a No sends nothing", { timeout: 10000 }, async (t) => { const s = broker(t); const d = s.raise("git.push.protected", { target: "refactor" }); const call = s.transport("demo"); const asked = []; const err = await resolveDecision({ call, ref: d.id, choice: "yes", note: "a reason", write: () => {}, ask: async (q) => (asked.push(q), false) }).catch((e) => e); assert.equal(err.message, "not resolved"); assert.equal(asked.length, 1); assert.ok(!s.calls.some((c) => c.verb === "decision.resolve")); // Off a terminal, --note without --yes refuses before anything is sent. const x = io(humanEnv()); x.stdin.isTTY = false; const off = await main(["decide", d.id, "yes", "--note", "a reason", "--business", "demo"], x, { system: { dataRoot: tmp(t) }, transport: s.transport }).catch((e) => e); assert.equal(off.exitCode, 4); assert.match(off.message, /stdin is not a terminal; pass --yes/); assert.ok(!s.calls.some((c) => c.verb === "decision.resolve")); }); test("the app's business comes from a live bus host only", async (t) => { for (const [startTime, want] of [ ["not-this-process", null], [startTimeOf(process.pid), "demo"], ]) { const dataRoot = tmp(t); mkdirSync(hostDir(dataRoot), { recursive: true, mode: 0o700 }); writeFileSync(hostFile(dataRoot), JSON.stringify({ pid: process.pid, startTime, business: "demo" }), { mode: 0o600 }); const x = io(humanEnv()); x.stdin.isTTY = true; x.stdout.isTTY = true; let opened; await main([], x, { system: { dataRoot }, transport: () => async () => [], runApp: async (o) => ((opened = o), { how: "q", resolved: 0 }) }); assert.equal(opened.business, want, startTime); } }); // A terminal for runApp: raw mode and listeners recorded, writes kept. function terminal({ throwOnResize = false, throwOnFrame = false } = {}) { const stdin = Object.assign(new EventEmitter(), { raw: [], setRawMode: (on) => stdin.raw.push(on), pause: () => {}, resume: () => {} }); const write = (s) => { if (throwOnFrame && s.startsWith("\x1b[H")) throw new Error("frame failed"); stdout.writes.push(s); }; const stdout = Object.assign(new EventEmitter(), { writes: [], write, rows: 24 }); let resized = false; Object.defineProperty(stdout, "columns", { get: () => { if (resized && throwOnResize) throw new Error("columns failed"); return 80; }, }); const signals = new EventEmitter(); const resize = () => ((resized = true), stdout.emit("resize")); return { io: { stdin, stdout }, stdin, stdout, signals, resize }; } test("runApp leaves the terminal as it found it on q, end of input, SIGTERM, SIGINT, SIGHUP and an error, the first frame's included", { timeout: 10000 }, async () => { const cases = [ ["q", (x) => x.stdin.emit("data", Buffer.from("q"))], ["end of input", (x) => x.stdin.emit("end")], ["SIGTERM", (x) => x.signals.emit("SIGTERM")], ["SIGINT", (x) => x.signals.emit("SIGINT")], ["SIGHUP", (x) => x.signals.emit("SIGHUP")], ["an error", (x) => x.resize(), { throwOnResize: true }], ["an error", () => {}, { throwOnFrame: true }], ]; for (const [how, act, opts] of cases) { const x = terminal(opts); // Handled at once: an error on the first frame rejects before the next tick. const running = runApp({ io: x.io, business: "demo", transport: () => async () => [], color: false, refreshMs: 0, askMs: 0, signals: x.signals }).then( (r) => r, (e) => ({ how: "an error", error: e }), ); await new Promise((r) => setImmediate(r)); act(x); const result = await running; assert.equal(result.how, how); if (how === "an error") assert.equal(result.error.message, opts.throwOnFrame ? "frame failed" : "columns failed"); assert.equal(x.stdout.writes[0], "\x1b[?1049h\x1b[?25l", how); assert.equal(x.stdout.writes.at(-1), "\x1b[?25h\x1b[?1049l", how); assert.deepEqual(x.stdin.raw, [true, false], how); for (const name of ["SIGTERM", "SIGINT", "SIGHUP"]) assert.equal(x.signals.listenerCount(name), 0, `${how} ${name}`); assert.equal(x.stdin.listenerCount("data") + x.stdin.listenerCount("end") + x.stdout.listenerCount("resize"), 0, how); } }); test("draw is pure: same state, same frame", () => { const state = { business: "demo", view: "inbox", data: { inbox: { list: [], at: new Date(NOW).toISOString() } }, sel: { inbox: 0, tasks: 0, agents: 0, trail: 0 }, overlay: null, command: null, status: "" }; assert.deepEqual(draw(state, { cols: 80, rows: 24, color: false, now: NOW }), draw(state, { cols: 80, rows: 24, color: false, now: NOW })); });