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]>
869 lines
40 KiB
JavaScript
869 lines
40 KiB
JavaScript
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 }));
|
||
});
|