queue-55: round 2 candidate packet (darkwing)
Answers Filbert's comment 27251 and Dewey's comment 27253: R1 type-ahead
window, R2 terminal cell widths, B1 cut inside the question, 30 mutants
killed, N1-N8. Candidate manifest sha256 3f2d8f8e. Gate on c506c6ec:
cli 106/106 and every scripts/test-*.sh exit 0. The feature stays
uncommitted.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
# Row 55 (#1544): the full-screen terminal app, candidate packet, round 1
|
||||
# Row 55 (#1544): the full-screen terminal app, candidate packet, rounds 1 and 2
|
||||
|
||||
Author: Darkwing. Reviewers: Dewey and Filbert. Brief:
|
||||
`docs/plans/2026-10-10_design-implementation.md`, section "Full-screen
|
||||
@@ -156,7 +156,7 @@ The tests in `packages/cli/tests/app.test.mjs`:
|
||||
screen doesn't redraw during a read (30 s timeout). That is the existing
|
||||
transport, unchanged. The README says so.
|
||||
|
||||
## Evidence
|
||||
## Evidence, round 1
|
||||
|
||||
All runs in a detached worktree of `23d566d6` with `build.patch` applied
|
||||
and `node_modules` linked (`out/gate/`).
|
||||
@@ -203,3 +203,175 @@ and `node_modules` linked (`out/gate/`).
|
||||
queues it with the 2026-10-12 batch.
|
||||
- Talk and stop in the app, Decision Seen, and the web console stay out of
|
||||
scope, as the brief says.
|
||||
|
||||
|
||||
## Round 2
|
||||
|
||||
Round 1 drew changes requested from both reviewers: Filbert in comment
|
||||
27251 (rev 390) and Dewey in comment 27253 (rev 395), both on candidate
|
||||
`93fa48c1cdfe`. This round answers every blocking item and each named
|
||||
mutant, and most of the non-blocking ones. `build.patch`, `files.txt`
|
||||
and `candidate-manifest.sha256` now hold the round 2 candidate. The
|
||||
round 1 versions are in this packet's history at `4acadaae`. The same
|
||||
nine files: +1741/−16. Base for the apply check: `c506c6ec`
|
||||
(`out/gate-round2/apply.txt`). None of the commits since `fe5c68fa` touch the nine
|
||||
files.
|
||||
|
||||
### Blocking
|
||||
|
||||
**R1 (Filbert): the question took type-ahead.** Now y or Y resolves and
|
||||
any other key answers No. Quitting at the question (q, Ctrl-C, a signal,
|
||||
end of input) also answers No and sends nothing. Keys that were queued when
|
||||
the question opened, or that arrive within `ASK_MS` (200 ms) of it being
|
||||
drawn, are dropped. The timer arms the question; arming empties the key
|
||||
queue. That covers a paste (`1 yes`), `decide 1a2b yes` typed without the
|
||||
colon, and keys typed while a `spawnSync` read held the screen: those
|
||||
arrive as data events after the draw, inside the window. A timer refresh
|
||||
tick is never taken as an answer. The hint now reads "y resolves · any
|
||||
other key cancels (the default is No)".
|
||||
|
||||
200 ms is my choice. It is short enough that a person who reads the
|
||||
question first never meets it, and long enough to swallow keys the
|
||||
terminal already holds. A y pressed inside it is dropped silently; the
|
||||
next one counts. Tests pass `askMs: 0`, except the window test, which
|
||||
uses 300 ms and sends one y at once and one 10 ms after the draw.
|
||||
|
||||
**R2 (Filbert): `cells()` miscounted emoji.** `\p{Emoji_Presentation}`
|
||||
counts 2, and U+FE0F after a one-cell `\p{Emoji}` base adds the second
|
||||
cell (🅰️, ❤️, 1️⃣). The old range 1F300-1FAFF became Emoji_Presentation
|
||||
plus 1F200-1F2FF. `width`, `fit` and `wrap` pass the previous code point.
|
||||
The fake inbox now carries ✅✅, ⚡ and 🅰️; a task title carries 発行 ✅.
|
||||
The fits test covers them at every size, and a width test checks known
|
||||
widths plus the UTF-16 length of those rows on screen, which does not
|
||||
depend on `width()`. Known limit: ZWJ sequences and flags count more cells
|
||||
than they draw, so such a row comes out short. It can't overflow.
|
||||
|
||||
**B1 (Dewey): a tall decide box cut the authorization lines.** The box now
|
||||
cuts inside the question. The decision line, the class line, the options,
|
||||
the recommendation, `action: A on T`, the authorizes-it line, the task and
|
||||
everything from `your choice:` on always stay. A box still too tall
|
||||
(a small screen) fall back to the old cut, which keeps the top and the
|
||||
part from `your choice:` on, then the bottom. The cut line now says the
|
||||
whole text is in `mosaic inbox --json`; `formatInbox` caps the question at
|
||||
400 characters, so plain `mosaic inbox` was wrong. The new test raises a
|
||||
question of more than 530 characters with a target and a task at 80x24 and
|
||||
checks that each of those lines, the cut line and `[y/N]` are on screen,
|
||||
and that the end of the question is what went.
|
||||
|
||||
### Filbert's mutants
|
||||
|
||||
`out/mutants-round2/` has the runner and its output: 30 mutants, each one
|
||||
edit to `src/`, with the whole `packages/cli` suite run against it. All 30
|
||||
are killed. `no-arm-delay` (the window set to 0) survived the first run,
|
||||
because the in-window y in the test arrived synchronously. The test now
|
||||
also sends one 10 ms later, and the rerun kills it.
|
||||
|
||||
| Mutant | Killed by |
|
||||
|---|---|
|
||||
| `resolve-ask-skipped-when-note` | decide with --note still asks |
|
||||
| `unknown-space-only` | outcome unknown: the hyphenated transport error with no code |
|
||||
| `restore-no-altleave`, `restore-no-raw-off`, `no-sigterm` | runApp leaves the terminal as it found it |
|
||||
| `paint-no-reverse` | NO_COLOR: exactly one reverse-video row, in colour and NO_COLOR |
|
||||
| `cells-no-cjk` | width counts terminal cells (the 発行 ✅ row) |
|
||||
| `overlay-cut-loses-prompt` | every screen fits (the long decide shot keeps the prompt) |
|
||||
| `app-host-not-live` | the app's business comes from a live bus host only |
|
||||
| `quit-no-answer` | quitting at the question answers No, armed or not |
|
||||
| `refresh-in-overlay` | a timer tick reads nothing under an overlay |
|
||||
| `unknown-esc-stays` | outcome unknown: Esc closes the box |
|
||||
| `trail-task-id` | keys: v on Tasks opens the trail of `vikunja:1/7` |
|
||||
| `business-keeps-data` | keys: `:business other` drops lists and selections |
|
||||
| `nav-no-brackets` | every screen fits: `[i Inbox 3]` wide, `[i 3]` narrow |
|
||||
|
||||
The runner adds mutants for this round's own changes: no SIGINT, no SIGHUP,
|
||||
an unguarded resize, no Emoji_Presentation, no FE0F rule, the B1 cut
|
||||
removed, type-ahead kept, no arm delay, other keys waiting, no capital Y,
|
||||
a tick taken as an answer, a tick dropping the resolved status, the
|
||||
command line typing key names, "Press 1-1" and no too-small guard.
|
||||
|
||||
### Non-blocking
|
||||
|
||||
- **N1 (Filbert; Dewey concurs).** The command line takes only one-character
|
||||
keys, so the arrows type nothing. Tested.
|
||||
- **N2.** `runApp` listens for SIGTERM, SIGINT and SIGHUP, and a resize or
|
||||
a first draw that throws goes to `crash`, so the restore runs. Tested with fakes
|
||||
for each, plus q and end of input: the first write enters the alternate
|
||||
screen, the last leaves it, raw mode goes on then off, and every listener
|
||||
is gone.
|
||||
- **N3.** Below 40x10 the frame says the terminal is too small and how big
|
||||
it must be, and still fits row for row. Tested at 39x10, 40x9, 3x3 and
|
||||
1x1; 40x10 joins the fits sizes.
|
||||
- **N4.** A timer tick keeps a "resolved" status. Tested.
|
||||
- **N5.** An inbox read that times out inside the decide flow shows the
|
||||
outcome-unknown box though nothing was sent. Not changed here. It is
|
||||
added to `FOLLOWUP-transport-code.md`, because the fix belongs with the
|
||||
transport's missing `.code`.
|
||||
- **N6.** One option says "Press 1 to choose". Tested.
|
||||
- **N7.** `runApp` has a test (N2 above).
|
||||
- **N8.** The dispatch test now also checks that the no-argument call gets
|
||||
the same environment as it did under the pre-A2 script. The old non-TTY
|
||||
bytes: deviation 2 stands. If Sage wants them back, it is one line in
|
||||
`cli.mjs`, and test 12 already holds the bytes as `SEAT_USAGE`.
|
||||
- **Dewey: Ctrl-C on the command line.** It closes the command line; it
|
||||
does not quit. The `app.mjs` header and the README now say so.
|
||||
- **Dewey: REACHES YOU.** Listed below as deviation 5.
|
||||
|
||||
### Deviation 5
|
||||
|
||||
5. **The Inbox has no REACHES YOU column.** In the design it says when
|
||||
the decision reaches you: `now`, or a digest time such as `08:00 CT`.
|
||||
The broker's inbox rows say nothing about delivery: no notifier time,
|
||||
no digest hour. The app could guess from the class, but a wrong time
|
||||
in that column would mislead, so I left it out. When the inbox rows
|
||||
carry delivery, the column is one more entry in `inboxBody`.
|
||||
|
||||
### Tests, round 2
|
||||
|
||||
`packages/cli/tests/app.test.mjs` has 23 tests (14 in round 1). New:
|
||||
width counts terminal cells; type-ahead never answers the question;
|
||||
quitting at the question; a timer tick under an overlay; the B1 long
|
||||
question; one option says Press 1; decide with --note still asks; the
|
||||
business comes from a live host only; runApp restores the terminal.
|
||||
Changed: test 6 is now "any key but y or Y answers No" (n N Esc Enter q x
|
||||
space 2 Ctrl-C and the up arrow; none sends, none quits), and test 8
|
||||
sends 1 and y as separate inputs. Tests 1, 3, 4, 7 and 9 gained the
|
||||
assertions named above.
|
||||
|
||||
### Evidence, round 2
|
||||
|
||||
|
||||
All runs in a detached worktree of `c506c6ec` with the round 2
|
||||
`build.patch` applied and `node_modules` linked. `out/gate-round2/` has
|
||||
the logs, `summary.txt` and `gate.sh`, the script that ran them in
|
||||
sequence.
|
||||
|
||||
- `packages/cli`: 106/106, `out/gate-round2/cli-tests.log`.
|
||||
- Every `scripts/test-*.sh`, all exit 0:
|
||||
|
||||
| Suite | Result |
|
||||
|---|---|
|
||||
| test-auth | 15 passed, 0 failed |
|
||||
| test-conductor | 17 passed, 0 failed |
|
||||
| test-config | 24 passed, 0 failed |
|
||||
| test-discord | 66 passed, 0 failed |
|
||||
| test-extension-package | 18 passed, 0 failed |
|
||||
| test-foundation | 44 passed, 0 failed |
|
||||
| test-queue | 27 passed, 0 failed; canonical-root checks skip in a worktree |
|
||||
| test-release | 14 passed, 0 failed |
|
||||
| test-task | 26 passed, 0 failed; Docker groups skipped, as in round 1 |
|
||||
|
||||
A `gate_default` Docker network appeared during the run. It had no
|
||||
containers afterwards, and I removed it.
|
||||
- `out/gate-round2/apply.txt`: `build.patch` applies to `c506c6ec` and
|
||||
the manifest matches. The nine files are byte-identical at `fe5c68fa`
|
||||
and `c506c6ec`.
|
||||
- `out/gate-round2/smoke.txt`: `smoke.sh` at 80x24 against a fixture
|
||||
config, never the real bus. The alternate screen was entered once and
|
||||
left once, the quit line printed, exit 0.
|
||||
- `out/mutants-round2/`: `run2.mjs` and its output. `round2.txt` is the
|
||||
run of all 30 mutants: 29 killed, `no-arm-delay` survived. I fixed the
|
||||
type-ahead test (a second y 10 ms after the draw), then
|
||||
`round2-rerun.txt` reran the two type-ahead mutants: both killed. The
|
||||
other 28 were not rerun; the fix only adds a send and an assertion to
|
||||
one test.
|
||||
- Candidate manifest sha256:
|
||||
`3f2d8f8ea3fea1cf7a002297ee9b23059f1522e8060c1d981fba19c85baeabd7`.
|
||||
|
||||
@@ -48,3 +48,15 @@ No other check in `packages/cli/src` compares `.code` against
|
||||
|
||||
Files: `packages/cli/src/transport.mjs`, `packages/cli/tests/cli.test.mjs`,
|
||||
`packages/cli/tests/transport.test.mjs`.
|
||||
|
||||
## A related case: a timed-out inbox read (Filbert N5, row 55 round 1)
|
||||
|
||||
`resolveDecision` reads the inbox before it sends anything. If that read
|
||||
hits the same did-not-finish error, the app shows its outcome-unknown
|
||||
overlay ("the broker may have recorded it"), though no choice was sent.
|
||||
Once the transport sets `error.code = "outcome-unknown"`, `mosaic decide`
|
||||
would print the same sentence for that read. The fix should tell the two
|
||||
apart: only an error from `decision.resolve` is outcome unknown. A failed
|
||||
inbox read before it is a plain failure, exit 1, with nothing sent. A test
|
||||
for each, in `packages/cli/tests/cli.test.mjs` and
|
||||
`packages/cli/tests/app.test.mjs`.
|
||||
|
||||
@@ -22,7 +22,7 @@ index b96e9012..5a675611 100644
|
||||
`3` refused or config problem · `4` usage. Details: `packages/cli/README.md`.
|
||||
|
||||
diff --git a/packages/cli/README.md b/packages/cli/README.md
|
||||
index 32977afa..3c3255f5 100644
|
||||
index 32977afa..dbb03138 100644
|
||||
--- a/packages/cli/README.md
|
||||
+++ b/packages/cli/README.md
|
||||
@@ -4,12 +4,16 @@ Jason's front door to the bus (slice 1 row S4, #1521): `mosaic inbox`,
|
||||
@@ -44,7 +44,7 @@ index 32977afa..3c3255f5 100644
|
||||
scripts/mosaic inbox
|
||||
scripts/mosaic decide 3f2a9c1e yes --note "ship it"
|
||||
scripts/mosaic trail 3f2a9c1e-… # then: scripts/mosaic trail vikunja:1/7
|
||||
@@ -70,6 +74,52 @@ mosaic launches list [--json]
|
||||
@@ -70,6 +74,65 @@ mosaic launches list [--json]
|
||||
`launches list` does not, because the file is readable to the same user
|
||||
anyway.
|
||||
|
||||
@@ -68,13 +68,16 @@ index 32977afa..3c3255f5 100644
|
||||
+- Keys: `c i t a r` switch views (Talk, Inbox, Tasks, Agents, Trail). `↑↓`
|
||||
+ or `j k` move, PgUp and PgDn page, Enter opens the selection whole, `v`
|
||||
+ opens its trail, `:` the command line, `?` help, Esc closes a box, `q`
|
||||
+ or Ctrl-C quits. Commands: `:decide <decision> <option>`, `:trail
|
||||
+ or Ctrl-C quits. On the command line, Esc or Ctrl-C closes it and the
|
||||
+ arrows type nothing. Commands: `:decide <decision> <option>`, `:trail
|
||||
+ <task|decision>`, `:business <id>`, `:q`.
|
||||
+- Deciding: in the Inbox, digit `n` picks the selected decision's `n`th
|
||||
+ option and runs `resolveDecision` in `src/cli.mjs`, the code `mosaic
|
||||
+ decide` runs. The box shows the same lines and the same `[y/N]`
|
||||
+ question; only `y` resolves. `n`, Esc, Enter and `q` cancel, and other
|
||||
+ keys wait. After `outcome unknown` the app shows the CLI's sentence and
|
||||
+ question. `y` or `Y` resolves; any other key answers No, and so does
|
||||
+ quitting. Keys that arrive within 200 ms of the question being drawn
|
||||
+ are type-ahead and are dropped: a paste such as `1 yes`, or keys typed
|
||||
+ while a read held the screen, never answer it. After `outcome unknown` the app shows the CLI's sentence and
|
||||
+ offers the trail (`v`) or a re-read (`r`). No key sends the choice
|
||||
+ again.
|
||||
+- Reads happen on entering a view, after a decision, and every 30 s while
|
||||
@@ -88,21 +91,31 @@ index 32977afa..3c3255f5 100644
|
||||
+ reverse video on the selected row (the design's tokens keep selection
|
||||
+ visible without colour). Frames fit 80x24; the layout is designed at
|
||||
+ 120x36, and under 100 columns the nav shows keys only and the Inbox
|
||||
+ drops RAISED BY.
|
||||
+ drops RAISED BY. Below 40x10 the frame only says the terminal is too
|
||||
+ small. Widths count terminal cells: CJK and emoji 2, a one-cell emoji
|
||||
+ base with U+FE0F 2. ZWJ sequences and flags count more than they draw,
|
||||
+ so such a row can come out short, never too wide.
|
||||
+- A decide box taller than the screen cuts the question, not the lines
|
||||
+ after it: the action and target, what the choice authorizes, the task,
|
||||
+ your choice and the `[y/N]` question stay. The cut line points to
|
||||
+ `mosaic inbox --json`.
|
||||
+- The transport is the verbs' `spawnSync` child, so the screen does not
|
||||
+ redraw while a read is in flight (up to its 30 s timeout).
|
||||
+- On quit the app restores the screen and the cursor, then prints
|
||||
+ `(app closed with q; N decisions resolved this session)`.
|
||||
+- On quit, end of input, SIGTERM, SIGINT, SIGHUP or an error (one in a
|
||||
+ resize redraw included) the app restores the screen, the cursor and
|
||||
+ cooked mode. After anything but an error it then prints
|
||||
+ `(app closed with q; N decisions resolved this session)`; an error
|
||||
+ goes to stderr instead.
|
||||
+
|
||||
## Bus host
|
||||
|
||||
```
|
||||
diff --git a/packages/cli/src/app.mjs b/packages/cli/src/app.mjs
|
||||
new file mode 100644
|
||||
index 00000000..208bc41f
|
||||
index 00000000..3cb3ab71
|
||||
--- /dev/null
|
||||
+++ b/packages/cli/src/app.mjs
|
||||
@@ -0,0 +1,447 @@
|
||||
@@ -0,0 +1,480 @@
|
||||
+// The full-screen app: `mosaic` with no argument on a terminal (src/cli.mjs
|
||||
+// main). Inbox, Tasks, Agents, Trail and Help over the same broker reads as
|
||||
+// the verbs; src/screens.mjs draws the frames.
|
||||
@@ -110,13 +123,16 @@ index 00000000..208bc41f
|
||||
+// Reads and the decide flow go through the human transport, like the verbs.
|
||||
+// A decision resolves only through resolveDecision (src/cli.mjs), the code
|
||||
+// `mosaic decide` runs: a digit picks an option, the app shows the CLI's
|
||||
+// lines and its yes/no question, and only y resolves. After "outcome unknown"
|
||||
+// the app offers the trail and a re-read, never a retry.
|
||||
+// lines and its yes/no question. y or Y resolves; any other key, or quitting,
|
||||
+// answers No. Keys that arrive within ASK_MS of the question being drawn are
|
||||
+// type-ahead and never answer it. After "outcome unknown" the app offers the
|
||||
+// trail and a re-read, never a retry.
|
||||
+//
|
||||
+// Keys: c i t a r views; ↑↓ or j k move; PgUp PgDn page; Enter opens the
|
||||
+// selection's detail; v the trail of the selection; 1-9 choose; `:` the
|
||||
+// command line; ? help; Esc back; q or Ctrl-C quits. Talk (c) is a pointer
|
||||
+// to `mosaic talk`; the conversation pane is a later row.
|
||||
+// command line (Esc or Ctrl-C closes it); ? help; Esc back; q or Ctrl-C
|
||||
+// quits. Below 40x10 the frame says the terminal is too small. Talk (c) is a
|
||||
+// pointer to `mosaic talk`; the conversation pane is a later row.
|
||||
+
|
||||
+import { resolveDecision } from "./cli.mjs";
|
||||
+import { CliError } from "./errors.mjs";
|
||||
@@ -125,6 +141,8 @@ index 00000000..208bc41f
|
||||
+import { busExit } from "./transport.mjs";
|
||||
+
|
||||
+export const REFRESH_MS = 30000;
|
||||
+// How long the decide question is up before a key can answer it.
|
||||
+export const ASK_MS = 200;
|
||||
+
|
||||
+// Terminal input to key names: "up", "down", "pgup", "pgdn", "enter", "esc",
|
||||
+// "tab", "backspace", "ctrl-c", or the character itself. Other escape
|
||||
@@ -157,7 +175,7 @@ index 00000000..208bc41f
|
||||
+export class App {
|
||||
+ // transport(business) returns call(verb, args). business may be null:
|
||||
+ // the app then says so and waits for :business <id>.
|
||||
+ constructor({ business = null, transport, write = () => {}, cols = 80, rows = 24, color = true, now = () => Date.now(), onQuit = () => {}, refreshMs = REFRESH_MS }) {
|
||||
+ constructor({ business = null, transport, write = () => {}, cols = 80, rows = 24, color = true, now = () => Date.now(), onQuit = () => {}, refreshMs = REFRESH_MS, askMs = ASK_MS }) {
|
||||
+ this.transport = transport;
|
||||
+ this.write = write;
|
||||
+ this.cols = cols;
|
||||
@@ -165,6 +183,7 @@ index 00000000..208bc41f
|
||||
+ this.color = color;
|
||||
+ this.now = now;
|
||||
+ this.onQuit = onQuit;
|
||||
+ this.askMs = askMs;
|
||||
+ this.state = { business, view: "inbox", data: {}, sel: { inbox: 0, tasks: 0, agents: 0, trail: 0 }, overlay: null, command: null, status: "", statusStyle: null };
|
||||
+ this.call = business ? transport(business) : null;
|
||||
+ this.resolved = 0;
|
||||
@@ -172,6 +191,7 @@ index 00000000..208bc41f
|
||||
+ this.keys = [];
|
||||
+ this.job = null;
|
||||
+ this.answer = null;
|
||||
+ this.cancelAsk = null;
|
||||
+ this.wake = null;
|
||||
+ this.idle = [];
|
||||
+ this.pumping = false;
|
||||
@@ -219,7 +239,14 @@ index 00000000..208bc41f
|
||||
+ for (;;) {
|
||||
+ if (this.closed) break;
|
||||
+ if (this.answer && this.keys.length) {
|
||||
+ this.answer(this.keys.shift());
|
||||
+ // A timer tick is not an answer.
|
||||
+ const k = this.keys.shift();
|
||||
+ try {
|
||||
+ if (k !== "refresh") this.answer(k);
|
||||
+ } catch (e) {
|
||||
+ this.crash(e);
|
||||
+ break;
|
||||
+ }
|
||||
+ continue;
|
||||
+ }
|
||||
+ if (this.job) {
|
||||
@@ -251,22 +278,28 @@ index 00000000..208bc41f
|
||||
+ for (const r of this.idle.splice(0)) r();
|
||||
+ }
|
||||
+
|
||||
+ // Asks the decide flow's question; resolves true on y. n, Esc, Enter, q
|
||||
+ // and Ctrl-C say no; other keys are ignored.
|
||||
+ // Asks the decide flow's question; resolves true on y or Y and false on
|
||||
+ // any other key. Only a key typed after the question is up can answer:
|
||||
+ // keys that arrive within askMs of drawing it are type-ahead (a paste, or
|
||||
+ // keys typed while a read blocked the screen) and are dropped. Quitting
|
||||
+ // answers no.
|
||||
+ #ask(question) {
|
||||
+ this.state.overlay = { ...this.state.overlay, asking: question };
|
||||
+ this.render();
|
||||
+ return new Promise((resolve) => {
|
||||
+ const take = (k) => {
|
||||
+ if (k === "y" || k === "Y") resolve(true);
|
||||
+ else if (["n", "N", "esc", "enter", "q", "ctrl-c"].includes(k)) resolve(false);
|
||||
+ else return void (this.answer = take);
|
||||
+ this.answer = null;
|
||||
+ const answer = (yes) => {
|
||||
+ clearTimeout(arm);
|
||||
+ this.answer = this.cancelAsk = null;
|
||||
+ this.state.overlay = { ...this.state.overlay, asking: null };
|
||||
+ this.render();
|
||||
+ resolve(yes);
|
||||
+ };
|
||||
+ this.answer = take;
|
||||
+ this.wake?.();
|
||||
+ this.cancelAsk = () => answer(false);
|
||||
+ const arm = setTimeout(() => {
|
||||
+ this.keys = [];
|
||||
+ this.answer = (k) => answer(k === "y" || k === "Y");
|
||||
+ this.wake?.();
|
||||
+ }, this.askMs);
|
||||
+ });
|
||||
+ }
|
||||
+
|
||||
@@ -278,7 +311,10 @@ index 00000000..208bc41f
|
||||
+ // Handles one key; returns a promise when it starts a read or a decision.
|
||||
+ #key(k) {
|
||||
+ const s = this.state;
|
||||
+ if (k === "start" || k === "refresh") return k === "refresh" && (s.overlay || s.command !== null) ? null : this.#read(s.view);
|
||||
+ // A timer tick keeps a "resolved" status; it reads nothing under an
|
||||
+ // overlay or the command line.
|
||||
+ if (k === "start") return this.#read(s.view);
|
||||
+ if (k === "refresh") return s.overlay || s.command !== null ? null : this.#read(s.view, s.statusStyle === "success");
|
||||
+ if (s.command !== null) return this.#commandKey(k);
|
||||
+ if (k === "ctrl-c") return this.quit("Ctrl-C");
|
||||
+ if (s.overlay) return this.#overlayKey(k);
|
||||
@@ -437,7 +473,8 @@ index 00000000..208bc41f
|
||||
+ try {
|
||||
+ const list = await this.call(view, view === "trail" ? { subject: before.subject } : {});
|
||||
+ this.state.data[view] = { subject: before.subject, list, at: new Date(this.now()).toISOString() };
|
||||
+ if (!keep) this.#status(view === "inbox" && list.length ? `Press 1-${Math.min(9, this.#selected("inbox")?.options.length ?? 1)} to choose. You will see the decision again and confirm with y.` : "");
|
||||
+ const n = Math.min(9, this.#selected("inbox")?.options.length ?? 1);
|
||||
+ if (!keep) this.#status(view === "inbox" && list.length ? `Press ${n === 1 ? "1" : `1-${n}`} to choose. You will see the decision again and confirm with y.` : "");
|
||||
+ } catch (e) {
|
||||
+ if (!(e instanceof CliError) && !e.code) throw e;
|
||||
+ // Exit 3 codes are refusals (src/transport.mjs), not failed reads.
|
||||
@@ -456,7 +493,7 @@ index 00000000..208bc41f
|
||||
+ const text = s.command.trim();
|
||||
+ s.command = null;
|
||||
+ return this.#run(text);
|
||||
+ } else if (k.length <= 2 && k >= " ") s.command += k;
|
||||
+ } else if ([...k].length === 1 && k >= " ") s.command += k;
|
||||
+ this.render();
|
||||
+ }
|
||||
+
|
||||
@@ -487,7 +524,7 @@ index 00000000..208bc41f
|
||||
+ if (this.closed) return;
|
||||
+ this.closed = true;
|
||||
+ if (this.timer) clearInterval(this.timer);
|
||||
+ this.answer?.("q");
|
||||
+ this.cancelAsk?.();
|
||||
+ this.wake?.();
|
||||
+ this.onQuit({ how, resolved: this.resolved, error: this.error });
|
||||
+ }
|
||||
@@ -507,30 +544,39 @@ index 00000000..208bc41f
|
||||
+}
|
||||
+
|
||||
+// Runs the app on a terminal until it quits: raw mode, the alternate screen,
|
||||
+// no cursor; all three restored on quit, on an error and on SIGTERM.
|
||||
+// Resolves with { how, resolved }.
|
||||
+export function runApp({ io, business, transport, color, refreshMs }) {
|
||||
+// no cursor; all three restored on quit, on an error (a render that throws
|
||||
+// included) and on SIGTERM, SIGINT or SIGHUP. Resolves with { how, resolved }.
|
||||
+export function runApp({ io, business, transport, color, refreshMs, askMs, signals = process }) {
|
||||
+ const { stdin, stdout } = io;
|
||||
+ const SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"];
|
||||
+ return new Promise((resolve, reject) => {
|
||||
+ let app;
|
||||
+ const restore = () => {
|
||||
+ stdin.off("data", onData);
|
||||
+ stdin.off("end", onEnd);
|
||||
+ stdout.off?.("resize", onResize);
|
||||
+ process.off("SIGTERM", onTerm);
|
||||
+ for (const [name, on] of onSignal) signals.off(name, on);
|
||||
+ stdin.setRawMode?.(false);
|
||||
+ stdin.pause?.();
|
||||
+ stdout.write("\x1b[?25h\x1b[?1049l");
|
||||
+ };
|
||||
+ const guard = (f) => {
|
||||
+ try {
|
||||
+ f();
|
||||
+ } catch (e) {
|
||||
+ app.crash(e);
|
||||
+ }
|
||||
+ };
|
||||
+ const onData = (c) => void app.input(c.toString("utf8"));
|
||||
+ const onEnd = () => app.quit("end of input");
|
||||
+ const onResize = () => app.resize(stdout.columns || 80, stdout.rows || 24);
|
||||
+ const onTerm = () => app.quit("SIGTERM");
|
||||
+ const onResize = () => guard(() => app.resize(stdout.columns || 80, stdout.rows || 24));
|
||||
+ const onSignal = SIGNALS.map((name) => [name, () => app.quit(name)]);
|
||||
+ app = new App({
|
||||
+ business,
|
||||
+ transport,
|
||||
+ color,
|
||||
+ refreshMs,
|
||||
+ askMs,
|
||||
+ cols: stdout.columns || 80,
|
||||
+ rows: stdout.rows || 24,
|
||||
+ write: (s) => stdout.write(s),
|
||||
@@ -545,9 +591,9 @@ index 00000000..208bc41f
|
||||
+ stdin.on("data", onData);
|
||||
+ stdin.on("end", onEnd);
|
||||
+ stdout.on?.("resize", onResize);
|
||||
+ process.on("SIGTERM", onTerm);
|
||||
+ for (const [name, on] of onSignal) signals.on(name, on);
|
||||
+ stdin.resume?.();
|
||||
+ void app.start();
|
||||
+ guard(() => void app.start());
|
||||
+ });
|
||||
+}
|
||||
diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs
|
||||
@@ -662,10 +708,10 @@ index 381596b4..7bbfe39c 100644
|
||||
return [r.kind, r.actor_role ?? null, r.body?.operation ?? null, r.body?.decision ? `decision ${shortId(r.body.decision)}` : null].filter(Boolean).join(" ");
|
||||
diff --git a/packages/cli/src/screens.mjs b/packages/cli/src/screens.mjs
|
||||
new file mode 100644
|
||||
index 00000000..d6e09981
|
||||
index 00000000..c0194cb2
|
||||
--- /dev/null
|
||||
+++ b/packages/cli/src/screens.mjs
|
||||
@@ -0,0 +1,367 @@
|
||||
@@ -0,0 +1,405 @@
|
||||
+// The full-screen app's frames (src/app.mjs). Pure: draw(state, opts) takes
|
||||
+// the app state and returns exactly `rows` strings, each `cols` cells wide
|
||||
+// once its escape codes are dropped. Nothing here reads the bus or the
|
||||
@@ -685,17 +731,24 @@ index 00000000..d6e09981
|
||||
+
|
||||
+// Below this width the nav shows keys only and Inbox drops RAISED BY.
|
||||
+export const NARROW_BELOW = 100;
|
||||
+// Below this size the frame only says the terminal is too small.
|
||||
+export const MIN_COLS = 40;
|
||||
+export const MIN_ROWS = 10;
|
||||
+
|
||||
+const SGR = { action: ["34", "39"], accent: ["33", "39"], success: ["32", "39"], warning: ["93", "39"], danger: ["31", "39"], muted: ["90", "39"], bold: ["1", "22"] };
|
||||
+
|
||||
+const oneLine = (s) => visible(s ?? "").replace(/\r?\n/g, "^J");
|
||||
+
|
||||
+// Terminal cells for one code point: 0 for combining marks, joiners and
|
||||
+// variation selectors, 2 for East Asian wide characters and emoji, else 1.
|
||||
+function cells(c) {
|
||||
+// Terminal cells for one code point after `prev`: 0 for combining marks,
|
||||
+// joiners and variation selectors, 2 for East Asian wide characters and
|
||||
+// emoji shown as emoji, else 1. U+FE0F after a one-cell emoji base (🅰️, ❤️)
|
||||
+// asks for emoji presentation and adds the second cell.
|
||||
+function cells(c, prev = "") {
|
||||
+ const n = c.codePointAt(0);
|
||||
+ if (/\p{M}/u.test(c) || n === 0x200d || n === 0x200c || (n >= 0xfe00 && n <= 0xfe0f)) return 0;
|
||||
+ if (n === 0xfe0f) return prev && /\p{Emoji}/u.test(prev) && cells(prev) === 1 ? 1 : 0;
|
||||
+ if (/\p{M}/u.test(c) || n === 0x200d || n === 0x200c || (n >= 0xfe00 && n <= 0xfe0e)) return 0;
|
||||
+ if (
|
||||
+ /\p{Emoji_Presentation}/u.test(c) ||
|
||||
+ (n >= 0x1100 && n <= 0x115f) ||
|
||||
+ (n >= 0x2e80 && n <= 0xa4cf && n !== 0x303f) ||
|
||||
+ (n >= 0xac00 && n <= 0xd7a3) ||
|
||||
@@ -703,7 +756,7 @@ index 00000000..d6e09981
|
||||
+ (n >= 0xfe30 && n <= 0xfe4f) ||
|
||||
+ (n >= 0xff00 && n <= 0xff60) ||
|
||||
+ (n >= 0xffe0 && n <= 0xffe6) ||
|
||||
+ (n >= 0x1f300 && n <= 0x1faff) ||
|
||||
+ (n >= 0x1f200 && n <= 0x1f2ff) ||
|
||||
+ (n >= 0x20000 && n <= 0x3fffd)
|
||||
+ )
|
||||
+ return 2;
|
||||
@@ -712,7 +765,11 @@ index 00000000..d6e09981
|
||||
+
|
||||
+export function width(s) {
|
||||
+ let w = 0;
|
||||
+ for (const c of s) w += cells(c);
|
||||
+ let prev = "";
|
||||
+ for (const c of s) {
|
||||
+ w += cells(c, prev);
|
||||
+ prev = c;
|
||||
+ }
|
||||
+ return w;
|
||||
+}
|
||||
+
|
||||
@@ -722,11 +779,13 @@ index 00000000..d6e09981
|
||||
+ if (width(s) <= w) return s + " ".repeat(w - width(s));
|
||||
+ let out = "";
|
||||
+ let used = 0;
|
||||
+ let prev = "";
|
||||
+ for (const c of s) {
|
||||
+ const n = cells(c);
|
||||
+ const n = cells(c, prev);
|
||||
+ if (used + n > w - 1) break;
|
||||
+ out += c;
|
||||
+ used += n;
|
||||
+ prev = c;
|
||||
+ }
|
||||
+ return out + "…" + " ".repeat(w - 1 - used);
|
||||
+}
|
||||
@@ -743,7 +802,7 @@ index 00000000..d6e09981
|
||||
+ for (const word of s.split(/(?<= )/)) {
|
||||
+ if (cur.trim() !== "" && width(cur + word.trimEnd()) > w) push();
|
||||
+ for (const c of word) {
|
||||
+ if (width(cur) + cells(c) > w) push();
|
||||
+ if (width(cur + c) > w) push();
|
||||
+ cur += c;
|
||||
+ }
|
||||
+ }
|
||||
@@ -984,18 +1043,32 @@ index 00000000..d6e09981
|
||||
+ const [text, style] = Array.isArray(l) ? l : [l, null];
|
||||
+ for (const t of wrap(oneLine(text), inner, " ")) out.push(line(["│ ", "accent"], [fit(t, inner), style], [" │", "accent"]));
|
||||
+ }
|
||||
+ out.push(line([`└${"─".repeat(w - 2)}┘`, "accent"]));
|
||||
+ out.push(line([`└${"─".repeat(Math.max(0, w - 2))}┘`, "accent"]));
|
||||
+ return out;
|
||||
+}
|
||||
+
|
||||
+function overlayBox(o, cols) {
|
||||
+const cutLine = (what, inner) => line(["│ ", "accent"], fit(`… (${what} cut to fit; the whole text is in mosaic inbox --json)`, inner), [" │", "accent"]);
|
||||
+
|
||||
+// `room` is the body's height. A decide box taller than that loses the end
|
||||
+// of the question, never the decision line, the options, the
|
||||
+// recommendation, the authorization lines, the task, the choice, the
|
||||
+// question to answer or its keys.
|
||||
+function overlayBox(o, cols, room) {
|
||||
+ if (o.kind === "help") return box("help", HELP, Math.min(84, cols - 2));
|
||||
+ const w = Math.min(84, cols - 2);
|
||||
+ if (o.kind === "decide") {
|
||||
+ const lines = o.lines.map((t) => (t.startsWith("your choice: ") ? [t, "bold"] : t));
|
||||
+ if (o.asking) lines.push("", [o.asking, "bold"], "", ["y resolves · n, Esc or Enter cancels (the default is No)", "muted"]);
|
||||
+ if (o.asking) lines.push("", [o.asking, "bold"], "", ["y resolves · any other key cancels (the default is No)", "muted"]);
|
||||
+ else lines.push("", ["resolving…", "muted"]);
|
||||
+ return box("decide", lines, w);
|
||||
+ const b = box("decide", lines, w);
|
||||
+ const q = o.lines.findIndex((t) => t.startsWith(" question: "));
|
||||
+ const end = o.lines.findIndex((t, i) => i > q && t.startsWith(" options: "));
|
||||
+ if (b.length <= room || q < 0 || end < 0) return b;
|
||||
+ const head = box("decide", lines.slice(0, q), w).slice(0, -1);
|
||||
+ const question = box("decide", lines.slice(q, end), w).slice(1, -1);
|
||||
+ const tail = box("decide", lines.slice(end), w).slice(1);
|
||||
+ const keep = room - head.length - tail.length - 1;
|
||||
+ return keep >= 1 ? [...head, ...question.slice(0, keep), cutLine("question", w - 4), ...tail] : b;
|
||||
+ }
|
||||
+ if (o.kind === "unknown") {
|
||||
+ return box("outcome unknown", [[o.message, "warning"], "", "Nothing was retried, and the choice was not sent twice.", "", "v open the trail r re-read the inbox Esc back"], w);
|
||||
@@ -1006,6 +1079,7 @@ index 00000000..d6e09981
|
||||
+
|
||||
+// Draws the frame. opts: cols, rows, color (false under NO_COLOR), now (ms).
|
||||
+export function draw(state, { cols, rows, color = true, now = Date.now() }) {
|
||||
+ if (cols < MIN_COLS || rows < MIN_ROWS) return tooSmall(cols, rows, color);
|
||||
+ const room = rows - 5;
|
||||
+ let body;
|
||||
+ if (!state.business && state.view !== "talk") body = [line([` ${VIEWS.find((v) => v[2] === state.view)[1]}`, "bold"]), blank(), ...noBusiness()];
|
||||
@@ -1017,13 +1091,15 @@ index 00000000..d6e09981
|
||||
+ body = body.slice(0, room);
|
||||
+ while (body.length < room) body.push(blank());
|
||||
+ if (state.overlay) {
|
||||
+ let b = overlayBox(state.overlay, cols);
|
||||
+ // Too tall: cut the middle, never the choice, the prompt or the keys.
|
||||
+ let b = overlayBox(state.overlay, cols, room);
|
||||
+ // Still too tall (a small screen, or a box with no question): keep the
|
||||
+ // top and, for decide, everything from the choice on; past that, the
|
||||
+ // bottom wins, so the question to answer and its keys stay.
|
||||
+ if (b.length > room) {
|
||||
+ const at = b.findIndex((l) => l.pieces.some(([t]) => t.startsWith("your choice: ")));
|
||||
+ const tail = b.slice(at > 0 ? at : -2);
|
||||
+ const inner = Math.min(84, cols - 2) - 4;
|
||||
+ b = [...b.slice(0, Math.max(1, room - tail.length - 1)), line(["│ ", "accent"], fit(`… (cut to fit; the whole text is in ${state.overlay.whole ?? "mosaic inbox"})`, inner), [" │", "accent"]), ...tail].slice(-room);
|
||||
+ b = [...b.slice(0, Math.max(1, room - tail.length - 1)), line(["│ ", "accent"], fit(`… (cut to fit; the whole text is in ${state.overlay.whole ?? "mosaic inbox --json"})`, inner), [" │", "accent"]), ...tail].slice(-room);
|
||||
+ }
|
||||
+ const left = Math.max(0, Math.floor((cols - width(b[0].pieces.map(([t]) => t).join(""))) / 2));
|
||||
+ const top = Math.max(0, Math.min(2, room - b.length));
|
||||
@@ -1033,21 +1109,31 @@ index 00000000..d6e09981
|
||||
+ const frame = [nav(state, cols, now), rule(cols), ...body, rule(cols), status, line([cols < NARROW_BELOW ? HINT_NARROW : HINT_WIDE, "muted"])];
|
||||
+ return frame.map((l) => paint(l, cols, color));
|
||||
+}
|
||||
+
|
||||
+// The whole frame below MIN_COLS x MIN_ROWS: what is wrong, and how to quit.
|
||||
+function tooSmall(cols, rows, color) {
|
||||
+ const say = wrap(`The terminal is ${cols}x${rows}; the app needs at least ${MIN_COLS}x${MIN_ROWS}. Make it bigger. Esc closes what is open; q quits.`, Math.max(1, cols));
|
||||
+ const body = say.slice(0, Math.max(0, rows)).map((t) => line([t, "warning"]));
|
||||
+ while (body.length < rows) body.push(blank());
|
||||
+ return body.map((l) => paint(l, Math.max(0, cols), color));
|
||||
+}
|
||||
diff --git a/packages/cli/tests/app.test.mjs b/packages/cli/tests/app.test.mjs
|
||||
new file mode 100644
|
||||
index 00000000..b88b2230
|
||||
index 00000000..b01abcb6
|
||||
--- /dev/null
|
||||
+++ b/packages/cli/tests/app.test.mjs
|
||||
@@ -0,0 +1,427 @@
|
||||
@@ -0,0 +1,719 @@
|
||||
+import { test } from "node:test";
|
||||
+import assert from "node:assert/strict";
|
||||
+import { spawnSync } from "node:child_process";
|
||||
+import { readFileSync } from "node:fs";
|
||||
+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 } from "../src/app.mjs";
|
||||
+import { main, SEAT_USAGE, USAGE } from "../src/cli.mjs";
|
||||
+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 { draw, strip, width } from "../src/screens.mjs";
|
||||
+import { broker, humanEnv, io, tmp } from "./helpers.mjs";
|
||||
+
|
||||
@@ -1059,6 +1145,14 @@ index 00000000..b88b2230
|
||||
+ [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) => {
|
||||
@@ -1081,6 +1175,7 @@ index 00000000..b88b2230
|
||||
+ write: (s) => (out.text = s),
|
||||
+ now: () => NOW,
|
||||
+ refreshMs: 0,
|
||||
+ askMs: 0,
|
||||
+ onQuit: (r) => (out.quit = r),
|
||||
+ ...opts,
|
||||
+ });
|
||||
@@ -1113,10 +1208,15 @@ index 00000000..b88b2230
|
||||
+ ...more,
|
||||
+ });
|
||||
+ const data = {
|
||||
+ inbox: [1, 2, 3].map((n) => decision(n)),
|
||||
+ // 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: "Short", done: true }, source: "self", via: null, read_at: null },
|
||||
+ { 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: [
|
||||
@@ -1169,7 +1269,7 @@ index 00000000..b88b2230
|
||||
+ { kind: "error", title: "decide", message: `failed: ${HOSTILE}` },
|
||||
+ ]) {
|
||||
+ a.state.overlay = o;
|
||||
+ shot(`overlay ${o.kind}`);
|
||||
+ shot(`overlay ${o.kind}${o.lines?.length > 10 ? " long" : ""}`);
|
||||
+ }
|
||||
+ a.state.overlay = null;
|
||||
+ // Empty, failed-with-stale-data, refused and no-business states.
|
||||
@@ -1181,6 +1281,7 @@ index 00000000..b88b2230
|
||||
+ 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;
|
||||
+}
|
||||
@@ -1190,15 +1291,40 @@ index 00000000..b88b2230
|
||||
+ 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 taller than the screen keeps its question and keys.
|
||||
+ const long = (await everyScreen(t, cols, rows, false)).find(([l]) => l === "overlay decide long")[1].join("\n");
|
||||
+ assert.match(long, /resolve 11111111 with yes\?/, `${cols}x${rows}`);
|
||||
+ assert.match(long, /y resolves/, `${cols}x${rows}`);
|
||||
+ if (rows >= 24) assert.match(long, /cut to fit[\s\S]*your choice: yes \(Allow\)/, `${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]), /Inbox/);
|
||||
+ 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]]) {
|
||||
+ assert.equal(width(s), cells, JSON.stringify(s));
|
||||
+ }
|
||||
+ // 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) {
|
||||
@@ -1222,8 +1348,12 @@ index 00000000..b88b2230
|
||||
+ 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/);
|
||||
+});
|
||||
+
|
||||
@@ -1237,7 +1367,7 @@ index 00000000..b88b2230
|
||||
+ 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 · n, Esc or Enter cancels \(the default is No\)/);
|
||||
+ 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" } }]);
|
||||
@@ -1273,23 +1403,132 @@ index 00000000..b88b2230
|
||||
+ await s.a.input("n");
|
||||
+});
|
||||
+
|
||||
+test("n, Esc, Enter and q at the question cancel; other keys wait; nothing is sent", async (t) => {
|
||||
+ for (const key of ["n", "N", "\x1b", "\r", "q"]) {
|
||||
+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");
|
||||
+ await s.a.input("x");
|
||||
+ assert.ok(s.a.state.overlay?.asking, `${JSON.stringify(key)}: still asking after x`);
|
||||
+ 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 at the question cancels; it does not quit");
|
||||
+ assert.equal(s.out.quit, null, "q and Ctrl-C at the question answer No; they do not quit");
|
||||
+ }
|
||||
+});
|
||||
+
|
||||
+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", 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");
|
||||
+});
|
||||
+
|
||||
+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, {
|
||||
@@ -1315,6 +1554,21 @@ index 00000000..b88b2230
|
||||
+ 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) => {
|
||||
@@ -1347,7 +1601,8 @@ index 00000000..b88b2230
|
||||
+ } });
|
||||
+ decide.raise("git.push.protected", { target: "refactor" });
|
||||
+ await decide.a.start();
|
||||
+ await decide.a.input("1y");
|
||||
+ 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"]);
|
||||
+});
|
||||
+
|
||||
@@ -1385,6 +1640,48 @@ index 00000000..b88b2230
|
||||
+ 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) => {
|
||||
@@ -1462,12 +1759,91 @@ index 00000000..b88b2230
|
||||
+ }
|
||||
+});
|
||||
+
|
||||
+test("decide with --note still asks; a No sends nothing", 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 } = {}) {
|
||||
+ const stdin = Object.assign(new EventEmitter(), { raw: [], setRawMode: (on) => stdin.raw.push(on), pause: () => {}, resume: () => {} });
|
||||
+ const stdout = Object.assign(new EventEmitter(), { writes: [], write: (s) => stdout.writes.push(s), 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", 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 }],
|
||||
+ ];
|
||||
+ for (const [how, act, opts] of cases) {
|
||||
+ const x = terminal(opts);
|
||||
+ const running = runApp({ io: x.io, business: "demo", transport: () => async () => [], color: false, refreshMs: 0, askMs: 0, signals: x.signals });
|
||||
+ await new Promise((r) => setImmediate(r));
|
||||
+ act(x);
|
||||
+ const result = await running.then(
|
||||
+ (r) => r,
|
||||
+ (e) => ({ how: "an error", error: e }),
|
||||
+ );
|
||||
+ assert.equal(result.how, how);
|
||||
+ if (how === "an error") assert.equal(result.error.message, "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 }));
|
||||
+});
|
||||
diff --git a/packages/queue/tests/dispatch.test.mjs b/packages/queue/tests/dispatch.test.mjs
|
||||
index aa425c0b..ddd341b8 100644
|
||||
index aa425c0b..2280cdb2 100644
|
||||
--- a/packages/queue/tests/dispatch.test.mjs
|
||||
+++ b/packages/queue/tests/dispatch.test.mjs
|
||||
@@ -1,8 +1,9 @@
|
||||
@@ -1506,7 +1882,7 @@ index aa425c0b..ddd341b8 100644
|
||||
[""],
|
||||
["launch", "queue"],
|
||||
["Queue", "list"],
|
||||
@@ -73,6 +73,17 @@ test("every call but `queue` reaches the seat CLI exactly as before A2", (t) =>
|
||||
@@ -73,6 +73,19 @@ test("every call but `queue` reaches the seat CLI exactly as before A2", (t) =>
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1516,9 +1892,11 @@ index aa425c0b..ddd341b8 100644
|
||||
+ assert.deepEqual([got.status, got.out, got.err], [7, "fake out\n", "fake err\n"]);
|
||||
+ const c = JSON.parse(got.capture);
|
||||
+ assert.deepEqual([c.cli, c.argv, c.cwd, c.stdin], ["/packages/cli/src/cli.mjs", [], "<ROOT>/work", "stdin line\nsecond\n"]);
|
||||
+ // Before row 55 the same call went to the seat CLI.
|
||||
+ // Before row 55 the same call went to the seat CLI, with the same
|
||||
+ // environment the app now gets.
|
||||
+ const before = call(root(t, join(HERE, "fixtures", "mosaic-pre-a2.sh")), []);
|
||||
+ assert.equal(JSON.parse(before.capture).cli, "/packages/seat/src/cli.mjs");
|
||||
+ assert.deepEqual(c.env, JSON.parse(before.capture).env);
|
||||
+});
|
||||
+
|
||||
test("`queue` reaches the queue CLI with the rest of the arguments", (t) => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
fcc2791aa2f80d7e196b0ce18f02475f9fc6730c74cbb56d2db3f9aae333253e docs/TOOLS.md
|
||||
001ae6159638f783520fa300c3f9ce71c25701568058664a032760dc6d79062e packages/cli/README.md
|
||||
534fdf2e70f7459789c32f31e467e78400a44d90df53dd319a774d8cf959506e packages/cli/src/app.mjs
|
||||
ac058d0aa7eaaf7a52a681f79fcedb3ea15051226697542029cb5fa15fc15600 packages/cli/README.md
|
||||
dfdeb9b91eb41f67ecec8e4835f631d6ec47b967897d349ed9b27d06045a9d93 packages/cli/src/app.mjs
|
||||
c33d3fedb91be7977cf7b468c908719d8f07cd7a2dad1bb7d9c24d62bcdac432 packages/cli/src/cli.mjs
|
||||
c2f6852226c7071dec132d422773d76bdfc356450f582ac28cb7a5077860b4c0 packages/cli/src/format.mjs
|
||||
681279edb4982ce103cdbb1a5fe3490721bca27f0877163f5a8c2f5ce6ace419 packages/cli/src/screens.mjs
|
||||
17e4a3ccb8a8e896c0d9fb6d0781db9636ceff65ba3d48dd7bdff75934150e4d packages/cli/tests/app.test.mjs
|
||||
5c892aea6a455f05aabb28d312f5fc4bb256584254357c6499319ac956f58109 packages/queue/tests/dispatch.test.mjs
|
||||
ff24eb0f78a64f98892eca372337ce141f30bc15169200fde37b6db34c6c8474 packages/cli/src/screens.mjs
|
||||
10b15b25f607100f5a1a6e315334668c014d01452c92fbd1c04dd2bfba276d30 packages/cli/tests/app.test.mjs
|
||||
bc972d153603311b08a2173d5b912f5fa7e9277605d3ce60d87161bf28ea80ab packages/queue/tests/dispatch.test.mjs
|
||||
ab246b3e39ea0c503d97e7af93ef8b958caedb3cf3006e387f14fa93b329856c scripts/mosaic
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
round 2 apply check, 2026-10-11T18:34:57Z
|
||||
base for apply: c506c6ec12aac53df408645802111799d9aad63c
|
||||
git apply --index build.patch: ok
|
||||
9 files changed, 1741 insertions(+), 16 deletions(-)
|
||||
docs/TOOLS.md: OK
|
||||
packages/cli/README.md: OK
|
||||
packages/cli/src/app.mjs: OK
|
||||
packages/cli/src/cli.mjs: OK
|
||||
packages/cli/src/format.mjs: OK
|
||||
packages/cli/src/screens.mjs: OK
|
||||
packages/cli/tests/app.test.mjs: OK
|
||||
packages/queue/tests/dispatch.test.mjs: OK
|
||||
scripts/mosaic: OK
|
||||
candidate-manifest.sha256: 3f2d8f8ea3fea1cf7a002297ee9b23059f1522e8060c1d981fba19c85baeabd7
|
||||
@@ -0,0 +1,116 @@
|
||||
✔ every screen and overlay fits 120x36, 80x24 and the narrow layout, row for row (1388.082384ms)
|
||||
✔ width counts terminal cells: wide CJK and emoji 2, U+FE0F after a one-cell base 1, marks 0 (67.593336ms)
|
||||
✔ broker text is shown, never interpreted: no raw control character or bidi override reaches the terminal (143.85161ms)
|
||||
✔ NO_COLOR: no escape codes, except reverse video on the selected row (226.278082ms)
|
||||
✔ 1-9 shows the decide flow's own lines and question; only y resolves (190.559659ms)
|
||||
✔ the app shows the same decide text as mosaic decide (109.597544ms)
|
||||
✔ any key but y or Y at the question answers No; nothing is sent (874.466315ms)
|
||||
✔ type-ahead never answers the question: a paste, keys typed before it is drawn, keys inside askMs (660.940214ms)
|
||||
✔ quitting at the question answers No and sends nothing, armed or not (180.705251ms)
|
||||
✔ a timer tick reads nothing under an overlay, never answers the question, and keeps a resolved status (219.873581ms)
|
||||
✔ a long question is cut inside the question; the action, target, choice and prompt stay (Dewey B1) (264.814429ms)
|
||||
✔ outcome unknown: the CLI's sentence, no retry, then the trail or a re-read (236.352318ms)
|
||||
✔ refused and failed reads keep the last list, marked stale; a refused decide says refused (136.823699ms)
|
||||
✔ keys: views, moving, trail of a selection, the command line, help and quit (374.348794ms)
|
||||
✔ one option: the status line says Press 1, not 1-1 (123.773035ms)
|
||||
✔ no business: the app says so and :business picks one (84.30717ms)
|
||||
✔ mosaic with no argument: exit 3 in an agent run, exit 4 with the usage off a terminal, the app on one (4.148559ms)
|
||||
✔ scripts/mosaic with no argument reaches the app entry; the seat usage it printed before has not drifted (275.068261ms)
|
||||
✔ strings to keep exactly live once, in the decide flow and the transport; the app does not restate them (0.6017ms)
|
||||
✔ decide with --note still asks; a No sends nothing (54.813604ms)
|
||||
✔ the app's business comes from a live bus host only (1.680961ms)
|
||||
✔ runApp leaves the terminal as it found it on q, end of input, SIGTERM, SIGINT, SIGHUP and an error (9.900172ms)
|
||||
✔ draw is pure: same state, same frame (1.563689ms)
|
||||
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (558.465697ms)
|
||||
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (226.333914ms)
|
||||
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (124.030399ms)
|
||||
✔ decide prints a declining choice as declining (123.202347ms)
|
||||
✔ an unknown outcome is reported once and never resent (100.701958ms)
|
||||
✔ a decision closed before the answer arrives exits 2 and points at its trail (106.502603ms)
|
||||
✔ a prefix that matches two open decisions exits 2 and resolves neither (107.996238ms)
|
||||
✔ without --business a command uses the live host's business, and a stale host.json is not a host (84.361031ms)
|
||||
✔ every human command refuses inside an agent run before it touches the bus (47.368796ms)
|
||||
✔ usage errors exit 4; no business and no host is a usage error (91.207182ms)
|
||||
✔ agents and tasks print through the broker (82.943846ms)
|
||||
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.528757ms)
|
||||
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (91.398158ms)
|
||||
✔ trackers come from the tracker.* variables of the one project that names a tracker project (58.642973ms)
|
||||
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (58.964757ms)
|
||||
✔ two projects that each name a tracker project refuse, since the boot shape holds one (56.269289ms)
|
||||
✔ a business without tracker.baseUrl gets no trackers entry (48.660207ms)
|
||||
✔ an unknown business and a broken system config refuse with exit 3 (89.684246ms)
|
||||
✔ empty views say so (1.526241ms)
|
||||
✔ the trail keeps the broker's order and names a decision's task without its rows (1.600511ms)
|
||||
✔ tasks print the tracker fields the snapshot carries (0.358094ms)
|
||||
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (1475.971054ms)
|
||||
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (301.319751ms)
|
||||
✔ a second host for the same data root refuses with exit 3 while the first runs (229.25187ms)
|
||||
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1228.180989ms)
|
||||
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (345.996602ms)
|
||||
✔ a broker reply with no request waiting breaks the channel and the host exits 1 (189.581835ms)
|
||||
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (550.192871ms)
|
||||
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (218.85895ms)
|
||||
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (179.181892ms)
|
||||
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (298.228093ms)
|
||||
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (187.817774ms)
|
||||
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (264.604371ms)
|
||||
✔ watchChildren reports a child that died before it was called, and one that dies later (32.060309ms)
|
||||
✔ bus stop refuses to signal a live pid that is not a bus host (203.20409ms)
|
||||
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (332.872065ms)
|
||||
✔ bus start refuses with exit 3 without a notifier config (180.02518ms)
|
||||
✔ bus start runs until bus stop; status reports it while it runs (939.74172ms)
|
||||
✔ bus-service.sh renders the unit and installs it into a given directory (49.869693ms)
|
||||
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1835.799228ms)
|
||||
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (791.19158ms)
|
||||
✔ a runner that stops at once ends its launch with the runner's reason (248.693167ms)
|
||||
✔ a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again (861.342895ms)
|
||||
✔ a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again (4052.369698ms)
|
||||
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (162.656426ms)
|
||||
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (159.332156ms)
|
||||
✔ a launch client that never closes its side doesn't hold the host's close (172.449402ms)
|
||||
✔ a runner that ignores SIGTERM is killed when the host closes (1275.853097ms)
|
||||
✔ zoned uses the IANA zone across DST (56.263686ms)
|
||||
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (628.011326ms)
|
||||
✔ two blocking decisions get two DMs with different nonces (143.867589ms)
|
||||
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.421052ms)
|
||||
✔ a failed DM is journaled, backs off, and is retried until it lands (129.80481ms)
|
||||
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (110.686399ms)
|
||||
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (122.930633ms)
|
||||
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (114.02775ms)
|
||||
✔ polled every POLL_MS against a permanent 403, a DM is sent at 0, 30, 60, 90 and 120 min and gives up only then (200.131735ms)
|
||||
✔ a restart after the second refusal does not send before that refusal's 30 min are up (137.077578ms)
|
||||
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (88.191016ms)
|
||||
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (76.683336ms)
|
||||
✔ an inbox read failure is logged and the next poll retries (1.291748ms)
|
||||
✔ no Discord id reaches the journal or the log (108.749226ms)
|
||||
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (29.499338ms)
|
||||
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (65.404216ms)
|
||||
✔ the journal: a whole file that is one torn line truncates to empty (22.595781ms)
|
||||
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (4.271563ms)
|
||||
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (1.311849ms)
|
||||
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (10.18965ms)
|
||||
✔ the journal: a symlinked directory refuses and says it is a link (0.546915ms)
|
||||
✔ the journal: a dangling directory link, a parent that is a file and a journal that is a directory each refuse with exit 3 (0.930554ms)
|
||||
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.744786ms)
|
||||
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.925587ms)
|
||||
✔ digest content stays within Discord's 2000 characters (0.55609ms)
|
||||
✔ runLoop never overlaps ticks and stops after the one in flight (111.970759ms)
|
||||
task.close {} answered: invalid-request; fake saw 18 requests, first GET /info 200, GET /projects/1 200, GET /projects/1/views 200
|
||||
task.close on a missing task answered: task-not-found; it made GET /tasks/999 404
|
||||
✔ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (893.910564ms)
|
||||
✔ the transport writes {business, verb, args} to the child and reads its JSON (75.055927ms)
|
||||
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2278.818425ms)
|
||||
✔ busExit and refuseInsideAgent (0.555669ms)
|
||||
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (732.667829ms)
|
||||
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1152.250705ms)
|
||||
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (132.977272ms)
|
||||
✔ launches off and on go to the broker and change the business's launch state (110.37747ms)
|
||||
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (95.505355ms)
|
||||
ℹ tests 106
|
||||
ℹ suites 0
|
||||
ℹ pass 106
|
||||
ℹ fail 0
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 9738.674718
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
export TMPDIR=~/darkwing-scratch/r55/tmp
|
||||
G=~/darkwing-scratch/r55/gate; O=~/darkwing-scratch/r55/out; mkdir -p $O; cd $G
|
||||
env -u NODE_TEST_CONTEXT node --test 'packages/cli/tests/*.test.mjs' > $O/cli-tests.log 2>&1; echo "cli rc=$?" >> $O/summary.txt
|
||||
for s in scripts/test-*.sh; do
|
||||
n=$(basename $s .sh)
|
||||
if [ $n = test-task ]; then DOCKER_HOST=unix:///nonexistent bash $s > $O/$n.log 2>&1; else bash $s > $O/$n.log 2>&1; fi
|
||||
echo "$n rc=$?" >> $O/summary.txt
|
||||
done
|
||||
echo DONE >> $O/summary.txt
|
||||
@@ -0,0 +1,4 @@
|
||||
(app closed with q; 0 decisions resolved this session)
|
||||
EXIT=0
|
||||
alternate screen entered: 1
|
||||
alternate screen left: 1
|
||||
@@ -0,0 +1,11 @@
|
||||
cli rc=0
|
||||
test-auth rc=0
|
||||
test-conductor rc=0
|
||||
test-config rc=0
|
||||
test-discord rc=0
|
||||
test-extension-package rc=0
|
||||
test-foundation rc=0
|
||||
test-queue rc=0
|
||||
test-release rc=0
|
||||
test-task rc=0
|
||||
DONE
|
||||
@@ -0,0 +1,17 @@
|
||||
OK status with missing harness credential exits 3 and still lists accounts
|
||||
OK status reports harness credential (read-only) + mosaic accounts
|
||||
OK api key material never reaches output
|
||||
OK oauth token material never reaches output
|
||||
OK unparseable credential file exits 2
|
||||
OK symlinked credential file exits 4
|
||||
OK env-side credential names reported
|
||||
OK env var values never reach output
|
||||
OK accounts without an accounts dir reports none and creates nothing
|
||||
OK accounts lists files and marks the active one
|
||||
OK loose account perms flagged in listing
|
||||
OK agent --auth with missing account file refuses (exit 4)
|
||||
OK agent --auth with non-0600 account file refuses
|
||||
OK agent --auth with invalid account name refuses
|
||||
OK auth.sh without valid config refuses
|
||||
|
||||
selftest: 15 passed, 0 failed
|
||||
@@ -0,0 +1,23 @@
|
||||
On branch refactor
|
||||
Your branch is up to date with 'origin/refactor'.
|
||||
|
||||
nothing to commit, working tree clean
|
||||
OK dry-run: allowed change, exit 0, nothing committed (exit 0)
|
||||
OK dry-run committed nothing
|
||||
OK apply: allowed change exits 0 (exit 0)
|
||||
OK apply: attribution in commit subject
|
||||
OK apply: target tree clean after commit
|
||||
OK disallowed path refused (exit 1)
|
||||
OK disallowed path: target untouched
|
||||
OK syntax gate refused broken .mjs (exit 1)
|
||||
OK syntax gate: target untouched
|
||||
OK suite failure refused (exit 1)
|
||||
OK suite failure: target reverted to clean
|
||||
OK disabled policy refused (exit 2)
|
||||
OK disabled policy: target untouched
|
||||
OK failed run refused (exit 1)
|
||||
OK failed run: target untouched
|
||||
OK missing run exits 4 (exit 4)
|
||||
OK invalid policy exits 2 (exit 2)
|
||||
|
||||
selftest: 17 passed, 0 failed
|
||||
@@ -0,0 +1,26 @@
|
||||
OK absent adapter defaults to pi
|
||||
OK adapter mock validates (exit 0)
|
||||
OK unsupported adapter exits 2 (exit 2)
|
||||
OK env exports adapter
|
||||
OK bootstrap creates default when absent (exit 0)
|
||||
OK bootstrap wrote config file
|
||||
OK bootstrap is idempotent on existing config (exit 0)
|
||||
OK bootstrap did not rewrite existing config
|
||||
OK validate missing config exits 3 (exit 3)
|
||||
OK malformed JSON exits 2 (exit 2)
|
||||
OK unsupported configVersion exits 2 (exit 2)
|
||||
OK unknown top-level key exits 2 (exit 2)
|
||||
OK unknown execution key exits 2 (exit 2)
|
||||
OK unsupported backend exits 2 (exit 2)
|
||||
OK unsupported environment exits 2 (exit 2)
|
||||
OK relative dataRoot exits 2 (exit 2)
|
||||
OK non-canonical dataRoot exits 2 (exit 2)
|
||||
OK filesystem root dataRoot exits 2 (exit 2)
|
||||
OK home directory dataRoot exits 2 (exit 2)
|
||||
OK dataRoot containing config dir exits 2 (exit 2)
|
||||
OK control character in provider exits 2 (exit 2)
|
||||
OK symlinked config file exits 2 (exit 2)
|
||||
OK env exports resolve correctly
|
||||
OK failed validation modified nothing
|
||||
|
||||
selftest: 24 passed, 0 failed
|
||||
@@ -0,0 +1,70 @@
|
||||
toolchain: node v26.8.1
|
||||
|
||||
OK syntax: packages/discord/src/approvals.mjs
|
||||
OK syntax: packages/discord/src/authorize.mjs
|
||||
OK syntax: packages/discord/src/binding.mjs
|
||||
OK syntax: packages/discord/src/cli.mjs
|
||||
OK syntax: packages/discord/src/connector.mjs
|
||||
OK syntax: packages/discord/src/context.mjs
|
||||
OK syntax: packages/discord/src/engine-pi.mjs
|
||||
OK syntax: packages/discord/src/errors.mjs
|
||||
OK syntax: packages/discord/src/gateway.mjs
|
||||
OK syntax: packages/discord/src/git.mjs
|
||||
OK syntax: packages/discord/src/journal.mjs
|
||||
OK syntax: packages/discord/src/notify.mjs
|
||||
OK syntax: packages/discord/src/rest.mjs
|
||||
OK syntax: packages/discord/src/setspark.mjs
|
||||
OK syntax: packages/discord/src/tools.mjs
|
||||
OK syntax: packages/discord/src/web.mjs
|
||||
OK syntax: packages/discord/bin/git-credential.mjs
|
||||
OK syntax: packages/discord/extension/tools.mjs
|
||||
OK syntax: packages/discord/tests/approvals.test.mjs
|
||||
OK syntax: packages/discord/tests/authorize.test.mjs
|
||||
OK syntax: packages/discord/tests/binding.test.mjs
|
||||
OK syntax: packages/discord/tests/connector.test.mjs
|
||||
OK syntax: packages/discord/tests/context.test.mjs
|
||||
OK syntax: packages/discord/tests/engine.test.mjs
|
||||
OK syntax: packages/discord/tests/fake-pi.mjs
|
||||
OK syntax: packages/discord/tests/gateway.test.mjs
|
||||
OK syntax: packages/discord/tests/git.test.mjs
|
||||
OK syntax: packages/discord/tests/helpers.mjs
|
||||
OK syntax: packages/discord/tests/journal.test.mjs
|
||||
OK syntax: packages/discord/tests/notify.test.mjs
|
||||
OK syntax: packages/discord/tests/recover.test.mjs
|
||||
OK syntax: packages/discord/tests/rest.test.mjs
|
||||
OK syntax: packages/discord/tests/setspark.test.mjs
|
||||
OK syntax: packages/discord/tests/tools.test.mjs
|
||||
OK syntax: packages/discord/tests/web.test.mjs
|
||||
OK syntax: packages/discord/fixtures/claim-worker.mjs
|
||||
OK syntax: packages/discord/fixtures/legacy-owner-worker.mjs
|
||||
OK syntax: scripts/discord.sh
|
||||
OK syntax: scripts/discord-service.sh
|
||||
OK packages/discord declares no dependencies
|
||||
OK no bot-token-shaped string in packages/discord
|
||||
OK fixture binding uses placeholder ids only
|
||||
OK fixture binding validates
|
||||
OK real pi with the extension exposes exactly list_dir, read_file, search and no built-in tool
|
||||
OK real pi with a writable root exposes exactly the three reads plus write_file and edit_file, and writes nothing at start
|
||||
OK real pi with a web key exposes the three reads plus web_fetch and web_search, and no write tool without a writable root
|
||||
OK real pi with a git root exposes the reads, writes and the four git verbs, commits nothing at start, and never shows the token
|
||||
OK real pi with protocol vault adds reserve_id to the git verbs
|
||||
OK real pi with a setspark key exposes the reads and the eight record verbs, no counters, and never shows the key
|
||||
OK real pi refuses a git key on a read-only root (fail closed)
|
||||
OK real pi with the pilot flags (--no-tools) exposes no tool at all
|
||||
OK real pi exits non-zero without MOSAIC_DISCORD_TOOLS: no session, no tools (fail closed)
|
||||
OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT
|
||||
OK node --test packages/discord/tests/ (ℹ pass 178)
|
||||
OK scripts/discord.sh --help exits 0
|
||||
OK scripts/discord.sh check without a binding exits 4
|
||||
OK scripts/discord.sh recover without a binding exits 4
|
||||
OK scripts/discord.sh reload without a binding exits 4
|
||||
OK scripts/discord-service.sh without a command exits 4
|
||||
OK service unit renders with the repository path, a supervised run as the main process, exit 3 never retried, and reload as SIGHUP
|
||||
OK service install writes the rendered unit (0644) and leaves no temp file
|
||||
OK service install a second time reports unchanged
|
||||
OK systemd-analyze verify accepts the rendered unit
|
||||
OK service uninstall removes the unit file
|
||||
OK service install with an unknown flag exits 4
|
||||
OK service install with USER unset finishes and names the account for lingering
|
||||
|
||||
discord suite: 66 passed, 0 failed
|
||||
@@ -0,0 +1,21 @@
|
||||
OK initial ordinary-file install
|
||||
OK installed tree matches canonical source
|
||||
OK installed tree has no symlinks
|
||||
OK check detects installation drift
|
||||
OK sync refuses to overwrite installation drift
|
||||
OK check detects an extra destination file
|
||||
OK check detects an extra destination directory
|
||||
OK check rejects a destination symlink
|
||||
OK sync accepts a canonical source update
|
||||
OK updated installation matches canonical source
|
||||
scripts/test-extension-package.sh: line 14: 1455682 Killed "$@" > /dev/null 2>&1
|
||||
OK forced interruption kills the replacing process
|
||||
OK next invocation recovers old consistent installation
|
||||
OK interrupted replacement rolled back
|
||||
OK sync succeeds after interruption recovery
|
||||
OK unlocked stale lock file does not block
|
||||
OK active lock refuses a concurrent sync
|
||||
OK source symlink fails closed
|
||||
OK nested second entrypoint fails closed
|
||||
|
||||
extension package selftest: 18 passed, 0 failed
|
||||
@@ -0,0 +1,53 @@
|
||||
toolchain: node v26.8.1, python 3.12.8, jsonschema 4.26.0
|
||||
|
||||
OK syntax: scripts/foundation-inspect.mjs
|
||||
OK syntax: scripts/foundation/strict-json.mjs
|
||||
OK syntax: scripts/foundation/canonical.mjs
|
||||
OK syntax: scripts/foundation/resolve.mjs
|
||||
OK syntax: scripts/foundation/validate-record.mjs
|
||||
OK syntax: scripts/foundation/fixtures/build-fixtures.mjs
|
||||
OK syntax: scripts/foundation/canonical.test.mjs
|
||||
OK syntax: scripts/foundation/cli.test.mjs
|
||||
OK syntax: scripts/foundation/fixtures.test.mjs
|
||||
OK syntax: scripts/foundation/resolve.test.mjs
|
||||
OK syntax: scripts/foundation/strict-json.test.mjs
|
||||
OK syntax: scripts/foundation/verify-schema.py (ast only; no bytecode written)
|
||||
OK fixture generator runs
|
||||
OK checked-in fixtures/bundles equal a fresh generation
|
||||
OK checked-in fixtures/raw equal a fresh generation
|
||||
OK checked-in fixtures/index.json equal a fresh generation
|
||||
OK checked-in demo bundles equal a fresh generation
|
||||
OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT
|
||||
OK node --test scripts/foundation/ (ℹ pass 80)
|
||||
OK differential schema oracle: PASS: differential schema oracle (finite corpus; compatibility evidence, not equivalence proof)
|
||||
platform witness: strftime('%Y') for year 999 -> '999' (pinned checker refuses years 0001..0999)
|
||||
node v26.8.1; corpus 1568 records (38 pinned fixtures, 478 unique bundle records, 1052 typeCase/mutation/lexical cases)
|
||||
schema column: agree-valid 540, agree-invalid 991, DISAGREEMENTS 0; strict-only (parser-bound) cases: 27; unsupported-kind records not schema-assessed by the inspector: 10
|
||||
profile column (schema-valid records only): profile-valid 510, profile-invalid 30
|
||||
profile refusals asserted: 30 schema-agreed-valid records refused only by the strict typed-string profile (rule profile-pattern-mismatch), 12 declared by name; 73 named probes verified against declared schema/profile columns
|
||||
OK oracle: zero schema-column disagreements with the pinned checker
|
||||
OK oracle: strict-only profile refusals are counted and asserted
|
||||
OK demo: permitted read preview exits 0 (exit 0)
|
||||
OK demo: permitted file.change preview exits 0 (exit 0)
|
||||
OK demo: assignment.change proposal is unresolved (exit 3) (exit 3)
|
||||
OK demo: revoked registration is refused (exit 3) (exit 3)
|
||||
OK demo: message is not authority (exit 3) (exit 3)
|
||||
OK usage: no arguments exits 2 (exit 2)
|
||||
OK io: missing file exits 4 (exit 4)
|
||||
OK io: directory exits 4 (exit 4)
|
||||
OK io: symlink exits 4 (O_NOFOLLOW) (exit 4)
|
||||
OK bound: oversize fixture exits 2 (exit 2)
|
||||
OK profile: one final LF in a typed selection id is refused before admission (exit 2) (exit 2)
|
||||
OK profile: two final LFs fail the schema pattern itself (exit 2) (exit 2)
|
||||
OK profile: escaped newlines in free-form text stay allowed (exit 0) (exit 0)
|
||||
OK profile refusal is invalid-request/profile-pattern-mismatch with selection and operation withheld, value not echoed
|
||||
OK text output starts with the disclaimer
|
||||
OK json output is valid JSON with result allowed and exactly the charter §7 fields
|
||||
OK json golden matches byte-for-byte
|
||||
OK sandboxed bundle run (env -i, PATH=/nonexistent) produced the unresolved proposal
|
||||
OK sandbox inventory (path/type/size/mode/uid/gid/inode/mtime/sha256) unchanged by runs
|
||||
OK canary never printed (bundle run and credential-file run)
|
||||
OK a non-bundle JSON file is refused at the shape gate, not read into output
|
||||
OK no field of the non-bundle file is echoed
|
||||
|
||||
selftest: 44 passed, 0 failed
|
||||
@@ -0,0 +1,35 @@
|
||||
toolchain: node v26.8.1, git version 2.55.0
|
||||
|
||||
OK syntax: packages/queue/src/cli.mjs
|
||||
OK syntax: packages/queue/src/errors.mjs
|
||||
OK syntax: packages/queue/src/io.mjs
|
||||
OK syntax: packages/queue/src/lock.mjs
|
||||
OK syntax: packages/queue/src/queue.mjs
|
||||
OK syntax: packages/queue/src/review.mjs
|
||||
OK syntax: packages/queue/src/store.mjs
|
||||
OK syntax: packages/queue/tests/commit.test.mjs
|
||||
OK syntax: packages/queue/tests/data.test.mjs
|
||||
OK syntax: packages/queue/tests/dispatch.test.mjs
|
||||
OK syntax: packages/queue/tests/helpers.mjs
|
||||
OK syntax: packages/queue/tests/lock.test.mjs
|
||||
OK syntax: packages/queue/tests/migration.test.mjs
|
||||
OK syntax: packages/queue/tests/review.test.mjs
|
||||
OK syntax: packages/queue/tests/store.test.mjs
|
||||
OK syntax: packages/queue/tests/write.test.mjs
|
||||
OK syntax: packages/queue/tests/fixtures/fake-gitea.mjs
|
||||
OK syntax: packages/queue/tests/fixtures/kill-at.mjs
|
||||
OK syntax: packages/queue/tests/fixtures/lock-child.mjs
|
||||
OK syntax: packages/queue/tests/fixtures/mosaic-pre-a2.sh
|
||||
OK syntax: scripts/queue-commit.sh
|
||||
OK syntax: scripts/git-hooks/pre-commit
|
||||
OK syntax: scripts/mosaic
|
||||
OK queue-commit.sh, the guard and scripts/mosaic are executable
|
||||
OK packages/queue declares no dependencies
|
||||
ℹ tests 150
|
||||
ℹ pass 150
|
||||
ℹ fail 0
|
||||
OK node --test packages/queue/tests/
|
||||
OK scripts/mosaic queue help
|
||||
skip queue verify and render --check: this checkout (/home/jwoltje/darkwing-scratch/r55/gate) is not the queue's canonical root (/mnt/storage/src/mosaic-stack)
|
||||
|
||||
queue suite: 27 passed, 0 failed
|
||||
@@ -0,0 +1,16 @@
|
||||
OK valid RELEASE resolves (exit 0)
|
||||
OK invalid RELEASE exits 1 (exit 1)
|
||||
OK missing RELEASE exits 1 (exit 1)
|
||||
OK valid RELEASE leaves image tag consistent with version
|
||||
OK status safe on empty state (exit 0)
|
||||
OK status created no pointer
|
||||
OK fault-injected activation refuses (exit 1)
|
||||
OK refused activation wrote no pointer
|
||||
OK refusal logged exactly once with valid fields
|
||||
OK healthy activation succeeds (exit 0)
|
||||
OK pointer written with valid fields
|
||||
OK repeat activation succeeds (log grows) (exit 0)
|
||||
OK log is append-only across activations
|
||||
OK rollback without previous refuses (exit 1)
|
||||
|
||||
selftest: 14 passed, 0 failed
|
||||
@@ -0,0 +1,32 @@
|
||||
OK valid task validates (exit 0)
|
||||
OK unknown task key exits 2 (exit 2)
|
||||
OK unsupported taskVersion exits 2 (exit 2)
|
||||
OK invalid task id exits 2 (exit 2)
|
||||
OK empty prompt exits 2 (exit 2)
|
||||
OK NUL in expectExact exits 2 (exit 2)
|
||||
OK out-of-range timeout exits 2 (exit 2)
|
||||
OK missing mission file exits 4 (exit 4)
|
||||
OK task with valid mission validates (exit 0)
|
||||
OK invalid mission exits 2 (exit 2)
|
||||
OK validate missing task exits 4 (exit 4)
|
||||
OK validation does not modify the task file
|
||||
OK prune dry-run exits 0 (exit 0)
|
||||
OK dry-run deleted nothing
|
||||
OK prune --keep=2 --yes removes oldest (exit 0)
|
||||
OK kept exactly 2 newest runs
|
||||
OK newest run kept, oldest pruned
|
||||
OK append-only receipt written (3 entries)
|
||||
OK sessions/workspaces untouched by prune
|
||||
OK prune with invalid keep exits 4 (exit 4)
|
||||
skip adapter seam cases (docker daemon unavailable)
|
||||
skip workspace/capability cases (docker daemon unavailable)
|
||||
skip live task cases (docker unavailable)
|
||||
OK onboard without name exits 4 (non-interactive) (exit 4)
|
||||
OK onboard --name renders profile (exit 0)
|
||||
OK profile written
|
||||
OK canon structure: required filled, optional placeholdered
|
||||
OK canon sections present
|
||||
skip live recall pair: user recall run, recalled user name (docker daemon unavailable)
|
||||
OK no agent identity on headless run
|
||||
|
||||
selftest: 26 passed, 0 failed
|
||||
@@ -0,0 +1,3 @@
|
||||
typeahead-kept: killed by type-ahead never answers the question: a paste, keys typed
|
||||
no-arm-delay: killed by type-ahead never answers the question: a paste, keys typed
|
||||
survived 0
|
||||
@@ -0,0 +1,31 @@
|
||||
resolve-ask-skipped-when-note: killed by decide with --note still asks; a No sends nothing (
|
||||
unknown-space-only: killed by outcome unknown: the CLI's sentence, no retry, then the tr
|
||||
restore-no-altleave: killed by runApp leaves the terminal as it found it on q, end of inp
|
||||
restore-no-raw-off: killed by runApp leaves the terminal as it found it on q, end of inp
|
||||
no-sigterm: killed by runApp leaves the terminal as it found it on q, end of inp
|
||||
no-sigint: killed by runApp leaves the terminal as it found it on q, end of inp
|
||||
no-sighup: killed by runApp leaves the terminal as it found it on q, end of inp
|
||||
resize-unguarded: killed by runApp leaves the terminal as it found it on q, end of inp
|
||||
paint-no-reverse: killed by NO_COLOR: no escape codes, except reverse video on the sel
|
||||
cells-no-cjk: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F
|
||||
cells-no-emoji-presentation: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F
|
||||
cells-no-fe0f: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F
|
||||
overlay-cut-loses-prompt: killed by every screen and overlay fits 120x36, 80x24 and the narrow
|
||||
b1-cut-keeps-end: killed by a long question is cut inside the question; the action, ta
|
||||
app-host-not-live: killed by the app's business comes from a live bus host only (
|
||||
quit-no-answer: killed by quitting at the question answers No and sends nothing, arm
|
||||
refresh-in-overlay: killed by a timer tick reads nothing under an overlay, never answers
|
||||
refresh-answers: killed by a timer tick reads nothing under an overlay, never answers
|
||||
refresh-drops-resolved: killed by a timer tick reads nothing under an overlay, never answers
|
||||
unknown-esc-stays: killed by outcome unknown: the CLI's sentence, no retry, then the tr
|
||||
trail-task-id: killed by keys: views, moving, trail of a selection, the command lin
|
||||
business-keeps-data: killed by keys: views, moving, trail of a selection, the command lin
|
||||
nav-no-brackets: killed by every screen and overlay fits 120x36, 80x24 and the narrow
|
||||
typeahead-kept: killed by type-ahead never answers the question: a paste, keys typed
|
||||
no-arm-delay: SURVIVED
|
||||
other-key-waits: killed by any key but y or Y at the question answers No; nothing is
|
||||
no-capital-y: killed by 1-9 shows the decide flow's own lines and question; only y
|
||||
command-types-names: killed by keys: views, moving, trail of a selection, the command lin
|
||||
press-1-1: killed by one option: the status line says Press 1, not 1-1 (
|
||||
tiny-no-guard: killed by every screen and overlay fits 120x36, 80x24 and the narrow
|
||||
survived 1
|
||||
@@ -0,0 +1,57 @@
|
||||
// Round 2 mutants: each edits one source line, runs the cli suite, restores.
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
const CLI = process.argv[2];
|
||||
const M = [
|
||||
["resolve-ask-skipped-when-note", "cli.mjs", "if (ask && !(await ask(", "if (ask && note === undefined && !(await ask("],
|
||||
["unknown-space-only", "app.mjs", "/^outcome[ -]unknown/", "/^outcome unknown/"],
|
||||
["restore-no-altleave", "app.mjs", 'stdout.write("\\x1b[?25h\\x1b[?1049l");', ""],
|
||||
["restore-no-raw-off", "app.mjs", "stdin.setRawMode?.(false);", ""],
|
||||
["no-sigterm", "app.mjs", 'const SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"];', 'const SIGNALS = ["SIGINT", "SIGHUP"];'],
|
||||
["no-sigint", "app.mjs", 'const SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"];', 'const SIGNALS = ["SIGTERM", "SIGHUP"];'],
|
||||
["no-sighup", "app.mjs", 'const SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"];', 'const SIGNALS = ["SIGTERM", "SIGINT"];'],
|
||||
["resize-unguarded", "app.mjs", "const onResize = () => guard(() => app.resize(stdout.columns || 80, stdout.rows || 24));", "const onResize = () => app.resize(stdout.columns || 80, stdout.rows || 24);"],
|
||||
["paint-no-reverse", "screens.mjs", "return l.sel ? `\\x1b[7m${body}\\x1b[27m` : body;", "return body;"],
|
||||
["cells-no-cjk", "screens.mjs", "(n >= 0x2e80 && n <= 0xa4cf && n !== 0x303f) ||", ""],
|
||||
["cells-no-emoji-presentation", "screens.mjs", "/\\p{Emoji_Presentation}/u.test(c) ||", ""],
|
||||
["cells-no-fe0f", "screens.mjs", "if (n === 0xfe0f) return prev && /\\p{Emoji}/u.test(prev) && cells(prev) === 1 ? 1 : 0;", ""],
|
||||
["overlay-cut-loses-prompt", "screens.mjs", "...tail].slice(-room);", "].slice(0, room);"],
|
||||
["b1-cut-keeps-end", "screens.mjs", "return keep >= 1 ? [...head, ...question.slice(0, keep), cutLine(\"question\", w - 4), ...tail] : b;", "return b;"],
|
||||
["app-host-not-live", "cli.mjs", "const business = state?.live ? state.business : null;", "const business = state?.business ?? null;"],
|
||||
["quit-no-answer", "app.mjs", " this.cancelAsk?.();\n this.wake?.();", " this.answer?.(\"y\");\n this.wake?.();"],
|
||||
["refresh-in-overlay", "app.mjs", "s.overlay || s.command !== null ? null : this.#read", "this.#read"],
|
||||
["refresh-answers", "app.mjs", 'if (k !== "refresh") this.answer(k);', "this.answer(k);"],
|
||||
["refresh-drops-resolved", "app.mjs", 'this.#read(s.view, s.statusStyle === "success")', "this.#read(s.view)"],
|
||||
["unknown-esc-stays", "app.mjs", ' if (k === "esc") this.state.overlay = null;', ""],
|
||||
["trail-task-id", "app.mjs", 'view === "tasks" ? item.task_ref : view === "trail"', 'view === "tasks" ? item.title : view === "trail"'],
|
||||
["business-keeps-data", "app.mjs", " this.state.data = {};", ""],
|
||||
["nav-no-brackets", "screens.mjs", "piece(`[${label}]`, \"bold\")", "piece(` ${label} `, \"bold\")"],
|
||||
["typeahead-kept", "app.mjs", " this.keys = [];\n this.answer", " this.answer"],
|
||||
["no-arm-delay", "app.mjs", " }, this.askMs);", " }, 0);"],
|
||||
["other-key-waits", "app.mjs", 'this.answer = (k) => answer(k === "y" || k === "Y");', 'this.answer = (k) => (k === "y" || k === "Y" ? answer(true) : ["n", "N", "esc", "enter", "q"].includes(k) ? answer(false) : null);'],
|
||||
["no-capital-y", "app.mjs", 'answer(k === "y" || k === "Y")', 'answer(k === "y")'],
|
||||
["command-types-names", "app.mjs", '[...k].length === 1 && k >= " "', 'k >= " "'],
|
||||
["press-1-1", "app.mjs", 'n === 1 ? "1" : `1-${n}`', "`1-${n}`"],
|
||||
["tiny-no-guard", "screens.mjs", "if (cols < MIN_COLS || rows < MIN_ROWS) return tooSmall(cols, rows, color);", ""],
|
||||
];
|
||||
const only = process.argv.slice(3);
|
||||
let survived = 0;
|
||||
for (const [name, file, from, to] of M) {
|
||||
if (only.length && !only.includes(name)) continue;
|
||||
const path = join(CLI, "src", file);
|
||||
const orig = readFileSync(path, "utf8");
|
||||
const n = orig.split(from).length - 1;
|
||||
if (n !== 1) { console.log(`${name}: SKIP, found ${n} times`); continue; }
|
||||
try {
|
||||
writeFileSync(path, orig.replace(from, to));
|
||||
const r = spawnSync(process.execPath, ["--test", "--test-timeout=20000", "tests/"], { cwd: CLI, encoding: "utf8", timeout: 180000 });
|
||||
const fails = (r.stdout.match(/^✖ (.*?) \(/gm) ?? []).slice(0, 2).map((s) => s.slice(2, 60));
|
||||
const killed = r.status !== 0;
|
||||
if (!killed) survived++;
|
||||
console.log(`${name}: ${killed ? "killed" : "SURVIVED"}${killed ? ` by ${[...new Set(fails)].join(" | ")}` : ""}`);
|
||||
} finally {
|
||||
writeFileSync(path, orig);
|
||||
}
|
||||
}
|
||||
console.log(`survived ${survived}`);
|
||||
Reference in New Issue
Block a user