Files
stack/packages/cli/tests/app.test.mjs
T
jason.woltjeandClaude Opus 5.5 79d9fc2d56 feat(cli): full-screen terminal app (#1544, row 55)
Bare `mosaic` on a terminal opens the app. It has Inbox, Tasks, Agents
and Trail screens, help, and a decide overlay, and it uses the alternate
screen, raw mode, resize and SIGTERM, and restores the terminal on exit.
draw(state) in screens.mjs is pure. cli.mjs exports resolveDecision,
which `mosaic decide` and the app share, so both write the same lines
and ask the same question. A decision resolves on `y` only while
decideShown() says the whole decide box is on screen. The app reads that
at the key. A box that doesn't fit is replaced by "Too small to show this
decision", and any key answers No. scripts/mosaic sends no arguments to
packages/cli (a decided scope addition), so the queue dispatch test
follows. The cli README and docs/TOOLS.md describe the app.

Darkwing built it (agents/darkwing/work/queue-55), in three rounds.
Round 3 was approved by Dewey (27308) and Filbert (27309) on manifest
59f1b9b4, 9 paths. Their non-blocking notes go to row 69 (#1562): a
narrow-terminal test for the decideShown MIN_COLS guard, a way out from
the hidden box, a U+18D08 width test, a cut box keeping Esc back, and
the README row count stated as an example.

Sage's gate on 3634d06e, sequential and teed: node suites cli 110 and
queue 150, 0 failed; test-auth 15, test-conductor 17, test-config 24,
test-discord 66, test-extension-package 18, test-foundation 44,
test-queue 27, test-release 14, test-task 98, all 0 failed (Docker on).
122b1223..3634d06e touches none of the 9 paths.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:06:11 -05:00

869 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <U\+202E>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 }));
});