docs(queue-55): row 55 round 3 candidate packet, R3 and B1 (darkwing)
One rule for Filbert's R3 and Dewey's B1: y resolves only while the
whole decide box is shown. Adds N9 widths, the start-unguarded and
timeout fixes, and two defects found by the cheap tests (wrap at
graphemes, answer render throw). 51/51 mutants killed; gate on
122b1223 green, cli 110.
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, rounds 1 and 2
|
# Row 55 (#1544): the full-screen terminal app, candidate packet, rounds 1, 2 and 3
|
||||||
|
|
||||||
Author: Darkwing. Reviewers: Dewey and Filbert. Brief:
|
Author: Darkwing. Reviewers: Dewey and Filbert. Brief:
|
||||||
`docs/plans/2026-10-10_design-implementation.md`, section "Full-screen
|
`docs/plans/2026-10-10_design-implementation.md`, section "Full-screen
|
||||||
@@ -375,3 +375,172 @@ sequence.
|
|||||||
one test.
|
one test.
|
||||||
- Candidate manifest sha256:
|
- Candidate manifest sha256:
|
||||||
`3f2d8f8ea3fea1cf7a002297ee9b23059f1522e8060c1d981fba19c85baeabd7`.
|
`3f2d8f8ea3fea1cf7a002297ee9b23059f1522e8060c1d981fba19c85baeabd7`.
|
||||||
|
|
||||||
|
## Round 3
|
||||||
|
|
||||||
|
Round 2 drew changes requested from both reviewers on candidate
|
||||||
|
`3f2d8f8e`. Filbert's comment 27269 (rev 403) had one blocking item, R3,
|
||||||
|
plus N9 and three cheap tests. Dewey's comment 27278 (rev 407) had one
|
||||||
|
blocking item, B1 again, plus two non-blocking ones. Dewey asked for one
|
||||||
|
rule covering B1 and R3, and this round has one. `build.patch`,
|
||||||
|
`files.txt` and `candidate-manifest.sha256` now hold the round 3
|
||||||
|
candidate. The round 2 versions are in this packet's history at
|
||||||
|
`fa6d889c`. The same nine files: 9 files, +1932/−16 against `fe5c68fa`. Base for the apply check:
|
||||||
|
`122b1223` (`out/gate-round3/apply.txt`). None of the nine files changed
|
||||||
|
between `fe5c68fa` and `122b1223`.
|
||||||
|
|
||||||
|
### Blocking: R3 and B1, one rule
|
||||||
|
|
||||||
|
**The defects.** R3 (Filbert): below 40x10 the frame shows only the
|
||||||
|
too-small text, and `y` still resolved the question it hid. B1 (Dewey):
|
||||||
|
between 40x10 and about 80x21 the decide box didn't fit, and `draw()`'s
|
||||||
|
general fallback cut it from the top and the bottom. The action, what
|
||||||
|
the choice authorizes, the recommendation and the task were dropped,
|
||||||
|
and at 80x10 and 40x12 "your choice:" too, while `y` still resolved.
|
||||||
|
|
||||||
|
**The rule.** `y` or `Y` resolves only while the frame shows the whole
|
||||||
|
decide box, with the end of a long question as the one thing it may
|
||||||
|
cut. Every other case answers No and sends nothing.
|
||||||
|
|
||||||
|
- `decideBox(o, w, room)` in `screens.mjs` builds the box. If it is
|
||||||
|
taller than the body, it cuts the end of the question and keeps the
|
||||||
|
rest: the decision line, the options, the recommendation, the action
|
||||||
|
and target, what the choice authorizes, the task, "your choice:", the
|
||||||
|
question and its keys. If those don't fit either, it returns null.
|
||||||
|
- `decideShown(o, cols, rows)` is true when the terminal is at least
|
||||||
|
40x10 and `decideBox` returns a box. It is exported and `App#ask`
|
||||||
|
uses it: `answer((k === "y" || k === "Y") && decideShown(...))`.
|
||||||
|
- `App#ask` reads the size at each key, not when the question was
|
||||||
|
armed. A terminal shrunk after the question appeared answers No, and
|
||||||
|
one grown back first lets `y` resolve.
|
||||||
|
- When `decideBox` returns null, `overlayBox` draws a small box: "Too
|
||||||
|
small to show this decision." and "Any key answers No." (or
|
||||||
|
"resolving…" when the question isn't open yet).
|
||||||
|
- Below 40x10 the too-small text ends "A decision question is open; any
|
||||||
|
key answers No." while the question is open, instead of "q quits",
|
||||||
|
since q answers No there.
|
||||||
|
- `draw()`'s general fallback now applies only to help and to a long
|
||||||
|
detail. A decide box always fits by the time it reaches `draw()`. The
|
||||||
|
fallback no longer slices: the body is at least 5 rows, so top, cut
|
||||||
|
line and last two lines are exactly that many.
|
||||||
|
|
||||||
|
The overlayBox comment and the README bullet said the must-keep lines
|
||||||
|
were never lost. Both now describe the rule and the hidden box. The
|
||||||
|
README states the sizes I measured for a gated decision with a task:
|
||||||
|
20 rows at 80 columns, 21 with a cut question, and 29 rows at 40
|
||||||
|
columns. `out/probe-round3.mjs` measured them, and its output is
|
||||||
|
`out/probe-round3.txt`. The smallest row count per width, short question
|
||||||
|
40:29, 50:26, 60:22, 80:20, 120:20; long question 40:30, 50:27, 60:23,
|
||||||
|
80:21, 120:21.
|
||||||
|
|
||||||
|
**Tests.**
|
||||||
|
|
||||||
|
- "below 40x10 the question is hidden, so every key answers it No, y
|
||||||
|
included (Filbert R3)": at 30x24 and 80x9 from the start, and at 80x24
|
||||||
|
shrunk to 30x24 after arming, `y`/`Y` sends nothing, the status is
|
||||||
|
`not resolved` and the decision stays in the Inbox. The frame shows
|
||||||
|
the new text and not "q quits". Shrunk and grown back before the key,
|
||||||
|
`y` resolves.
|
||||||
|
- "a decide box too big for the terminal says so, and every key answers
|
||||||
|
it No, y included (Dewey B1, round 3)": a gated decision with a task.
|
||||||
|
Dewey's sizes, short question at 80x18 and 40x24 and long question at
|
||||||
|
80x20, plus 120x16 and 80x10: the frame says "Too small to show this
|
||||||
|
decision" and `y` sends nothing. At 80x20 short and 80x21 long, the
|
||||||
|
smallest sizes that show it, every must-keep line is on screen and `y`
|
||||||
|
resolves. Armed at 80x24 and shrunk to 80x15 before the key, `Y`
|
||||||
|
answers No.
|
||||||
|
- "at every size the decide box shows each line y depends on, or says
|
||||||
|
it is hidden": columns 40 to 120 in steps of 4, rows 10 to 40, short
|
||||||
|
and long questions. At each size either `decideShown` is true and the
|
||||||
|
frame has all ten must-keep lines, or it is false and the frame has
|
||||||
|
the hidden box. Each side holds more than 100 sizes.
|
||||||
|
- The "every screen and overlay fits" test now expects the hidden box
|
||||||
|
for its long overlay at the narrow layout, not a cut box.
|
||||||
|
|
||||||
|
### Non-blocking
|
||||||
|
|
||||||
|
**Dewey: `start-unguarded` survived.** The runApp test now has a case
|
||||||
|
where the terminal's write throws on the first frame. runApp must
|
||||||
|
reject with that error and leave the terminal as it found it. The
|
||||||
|
`.then` handler is attached at the call, because the first frame's
|
||||||
|
error rejects before the next tick.
|
||||||
|
|
||||||
|
**Dewey: four mutants were killed only by a hang.** `{ timeout: 10000 }`
|
||||||
|
is now on "decide with --note still asks", "quitting at the question",
|
||||||
|
and the runApp test, which covers `no-sigterm` and `no-sighup`.
|
||||||
|
|
||||||
|
**N9 (Filbert).** `cells()` now counts the remaining East Asian Width W
|
||||||
|
and F ranges as 2: U+2329, U+232A, U+A960-A97C, U+FE10-FE19,
|
||||||
|
U+FE50-FE6B, U+16FE0-16FF1, U+17000-18D08, U+1AFF0-1B2FB. The width
|
||||||
|
test checks one character from each, plus U+1AFF0's range end, U+1B2FB.
|
||||||
|
|
||||||
|
**Cheap tests (Filbert).**
|
||||||
|
|
||||||
|
- U+1F210 measures 2 (width test).
|
||||||
|
- A throw from `answer`. The test makes the terminal write throw after the
|
||||||
|
question is drawn, then presses `y`. This found a defect. `answer` threw
|
||||||
|
from its `render()` before calling `resolve`, so the decide job never
|
||||||
|
ended: the pump crashed the app, but `this.job` stayed pending.
|
||||||
|
`answer` now resolves No when its render throws, then rethrows to the
|
||||||
|
pump. The test checks the quit is `an error` with that error, the job
|
||||||
|
ends, and nothing is sent.
|
||||||
|
- ❤️ at a wrap edge. This also found a defect. `wrap()` broke long words
|
||||||
|
between code points, so `wrap("abc❤️ x", 4)` gave `abc❤` and a line
|
||||||
|
starting with a lone U+FE0F. It now breaks between graphemes
|
||||||
|
(`Intl.Segmenter`), so the result is `abc` and `❤️ x`. The test also
|
||||||
|
checks a line of marks and ❤️ never comes out wider than asked.
|
||||||
|
|
||||||
|
The "runLoop never overlaps ticks" flake is in the notifier tests, not
|
||||||
|
in this candidate. I left it to the notifier's owner.
|
||||||
|
|
||||||
|
### Tests, round 3
|
||||||
|
|
||||||
|
`packages/cli/tests/app.test.mjs` has 27 tests (23 in round 2); the cli
|
||||||
|
suite has 110. New: the R3 test, the answer-throw test and the two B1
|
||||||
|
tests. The width test gained the N9 characters, U+1F210 and the wrap
|
||||||
|
checks. The runApp test gained the first-frame error case.
|
||||||
|
|
||||||
|
### Evidence, round 3
|
||||||
|
|
||||||
|
All runs were in a detached worktree of `122b1223` with the round 3
|
||||||
|
`build.patch` applied and `node_modules` linked. `out/gate-round3/` has
|
||||||
|
the logs, `summary.txt` and `gate.sh`, the script that ran them in
|
||||||
|
sequence.
|
||||||
|
|
||||||
|
- `packages/cli`: 110/110, `out/gate-round3/cli-tests.log`.
|
||||||
|
- Every `scripts/test-*.sh` exited 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 rounds 1 and 2 |
|
||||||
|
|
||||||
|
A `gate_default` Docker network appeared during the run. It had no
|
||||||
|
containers afterwards, and I removed it.
|
||||||
|
- `out/gate-round3/apply.txt`: `build.patch` applies to `122b1223` and
|
||||||
|
the manifest matches.
|
||||||
|
- `out/gate-round3/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, and the exit code was 0.
|
||||||
|
- `out/probe-round3.mjs` and `out/probe-round3.txt`: the size probe the
|
||||||
|
README numbers come from. Run it with `CLI=<checkout>/packages/cli node
|
||||||
|
--test out/probe-round3.mjs`.
|
||||||
|
|
||||||
|
- `out/mutants-round3/`: `run3.mjs` and its output `round3.txt`, 51
|
||||||
|
mutants. Round 2's 30 less `overlay-cut-loses-prompt`, which edited a
|
||||||
|
line this round removed. Four re-anchored to lines this round changed
|
||||||
|
(`other-key-waits`, `no-capital-y`, `tiny-no-guard`,
|
||||||
|
`b1-cut-keeps-end`). Then 22 for round 3, among them
|
||||||
|
`r3-y-ignores-shown`, `r3-shown-is-min-size` (y checks only 40x10),
|
||||||
|
`r3-shown-at-arm` (the size read once, when armed),
|
||||||
|
`r3-no-question-whole-box` and `r3-start-unguarded`. All 51 were
|
||||||
|
killed, and the three source files matched the candidate afterwards.
|
||||||
|
One run, no fix needed.
|
||||||
|
- Candidate manifest sha256: `59f1b9b4dabfb47dc79641de209317cfd538cb27bd484b5391a0703a8c84c60b`.
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ index b96e9012..5a675611 100644
|
|||||||
`3` refused or config problem · `4` usage. Details: `packages/cli/README.md`.
|
`3` refused or config problem · `4` usage. Details: `packages/cli/README.md`.
|
||||||
|
|
||||||
diff --git a/packages/cli/README.md b/packages/cli/README.md
|
diff --git a/packages/cli/README.md b/packages/cli/README.md
|
||||||
index 32977afa..dbb03138 100644
|
index 32977afa..8114753c 100644
|
||||||
--- a/packages/cli/README.md
|
--- a/packages/cli/README.md
|
||||||
+++ b/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`,
|
@@ -4,12 +4,16 @@ Jason's front door to the bus (slice 1 row S4, #1521): `mosaic inbox`,
|
||||||
@@ -44,7 +44,7 @@ index 32977afa..dbb03138 100644
|
|||||||
scripts/mosaic inbox
|
scripts/mosaic inbox
|
||||||
scripts/mosaic decide 3f2a9c1e yes --note "ship it"
|
scripts/mosaic decide 3f2a9c1e yes --note "ship it"
|
||||||
scripts/mosaic trail 3f2a9c1e-… # then: scripts/mosaic trail vikunja:1/7
|
scripts/mosaic trail 3f2a9c1e-… # then: scripts/mosaic trail vikunja:1/7
|
||||||
@@ -70,6 +74,65 @@ mosaic launches list [--json]
|
@@ -70,6 +74,74 @@ mosaic launches list [--json]
|
||||||
`launches list` does not, because the file is readable to the same user
|
`launches list` does not, because the file is readable to the same user
|
||||||
anyway.
|
anyway.
|
||||||
|
|
||||||
@@ -92,13 +92,22 @@ index 32977afa..dbb03138 100644
|
|||||||
+ visible without colour). Frames fit 80x24; the layout is designed at
|
+ visible without colour). Frames fit 80x24; the layout is designed at
|
||||||
+ 120x36, and under 100 columns the nav shows keys only and the Inbox
|
+ 120x36, and under 100 columns the nav shows keys only and the Inbox
|
||||||
+ drops RAISED BY. Below 40x10 the frame only says the terminal is too
|
+ 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
|
+ small. If the decide question is open then, the frame says so, and any
|
||||||
+ base with U+FE0F 2. ZWJ sequences and flags count more than they draw,
|
+ key answers it No, `y` included. Widths count terminal cells: East
|
||||||
+ so such a row can come out short, never too wide.
|
+ Asian Width W and F characters and emoji 2, a one-cell emoji base with
|
||||||
+- A decide box taller than the screen cuts the question, not the lines
|
+ U+FE0F 2. ZWJ sequences and flags count more than they draw, so such a
|
||||||
+ after it: the action and target, what the choice authorizes, the task,
|
+ row can come out short, never too wide. Wrapping breaks between
|
||||||
+ your choice and the `[y/N]` question stay. The cut line points to
|
+ graphemes, so U+FE0F and combining marks stay with their base.
|
||||||
+ `mosaic inbox --json`.
|
+- A decide box taller than the screen cuts the end of the question and
|
||||||
|
+ nothing else: the decision line, the options, the recommendation, 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`. When even those lines don't fit, the box says only "Too small
|
||||||
|
+ to show this decision. Any key answers No." `y` resolves only while the
|
||||||
|
+ frame shows the whole box apart from the cut, and the app checks the
|
||||||
|
+ size at each key, so a terminal shrunk after the question appeared
|
||||||
|
+ answers No. A gated decision with a task needs 20 rows at 80 columns
|
||||||
|
+ (21 with a cut question), and 29 rows at 40 columns.
|
||||||
+- The transport is the verbs' `spawnSync` child, so the screen does not
|
+- 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).
|
+ redraw while a read is in flight (up to its 30 s timeout).
|
||||||
+- On quit, end of input, SIGTERM, SIGINT, SIGHUP or an error (one in a
|
+- On quit, end of input, SIGTERM, SIGINT, SIGHUP or an error (one in a
|
||||||
@@ -112,10 +121,10 @@ index 32977afa..dbb03138 100644
|
|||||||
```
|
```
|
||||||
diff --git a/packages/cli/src/app.mjs b/packages/cli/src/app.mjs
|
diff --git a/packages/cli/src/app.mjs b/packages/cli/src/app.mjs
|
||||||
new file mode 100644
|
new file mode 100644
|
||||||
index 00000000..3cb3ab71
|
index 00000000..3f5418b0
|
||||||
--- /dev/null
|
--- /dev/null
|
||||||
+++ b/packages/cli/src/app.mjs
|
+++ b/packages/cli/src/app.mjs
|
||||||
@@ -0,0 +1,480 @@
|
@@ -0,0 +1,489 @@
|
||||||
+// The full-screen app: `mosaic` with no argument on a terminal (src/cli.mjs
|
+// 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
|
+// main). Inbox, Tasks, Agents, Trail and Help over the same broker reads as
|
||||||
+// the verbs; src/screens.mjs draws the frames.
|
+// the verbs; src/screens.mjs draws the frames.
|
||||||
@@ -137,7 +146,7 @@ index 00000000..3cb3ab71
|
|||||||
+import { resolveDecision } from "./cli.mjs";
|
+import { resolveDecision } from "./cli.mjs";
|
||||||
+import { CliError } from "./errors.mjs";
|
+import { CliError } from "./errors.mjs";
|
||||||
+import { formatDecision } from "./format.mjs";
|
+import { formatDecision } from "./format.mjs";
|
||||||
+import { draw } from "./screens.mjs";
|
+import { decideShown, draw } from "./screens.mjs";
|
||||||
+import { busExit } from "./transport.mjs";
|
+import { busExit } from "./transport.mjs";
|
||||||
+
|
+
|
||||||
+export const REFRESH_MS = 30000;
|
+export const REFRESH_MS = 30000;
|
||||||
@@ -282,7 +291,10 @@ index 00000000..3cb3ab71
|
|||||||
+ // any other key. Only a key typed after the question is up can answer:
|
+ // 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 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
|
+ // keys typed while a read blocked the screen) and are dropped. Quitting
|
||||||
+ // answers no.
|
+ // answers no. y counts only while the frame shows the whole box, the end
|
||||||
|
+ // of a long question aside (decideShown); on a terminal too small for it,
|
||||||
|
+ // every key answers no, y included. The size is read at each key, so a
|
||||||
|
+ // shrink after the question is armed is covered.
|
||||||
+ #ask(question) {
|
+ #ask(question) {
|
||||||
+ this.state.overlay = { ...this.state.overlay, asking: question };
|
+ this.state.overlay = { ...this.state.overlay, asking: question };
|
||||||
+ this.render();
|
+ this.render();
|
||||||
@@ -291,13 +303,19 @@ index 00000000..3cb3ab71
|
|||||||
+ clearTimeout(arm);
|
+ clearTimeout(arm);
|
||||||
+ this.answer = this.cancelAsk = null;
|
+ this.answer = this.cancelAsk = null;
|
||||||
+ this.state.overlay = { ...this.state.overlay, asking: null };
|
+ this.state.overlay = { ...this.state.overlay, asking: null };
|
||||||
+ this.render();
|
+ try {
|
||||||
|
+ this.render();
|
||||||
|
+ } catch (e) {
|
||||||
|
+ // The pump sends this to crash; the decide flow ends with No.
|
||||||
|
+ resolve(false);
|
||||||
|
+ throw e;
|
||||||
|
+ }
|
||||||
+ resolve(yes);
|
+ resolve(yes);
|
||||||
+ };
|
+ };
|
||||||
+ this.cancelAsk = () => answer(false);
|
+ this.cancelAsk = () => answer(false);
|
||||||
+ const arm = setTimeout(() => {
|
+ const arm = setTimeout(() => {
|
||||||
+ this.keys = [];
|
+ this.keys = [];
|
||||||
+ this.answer = (k) => answer(k === "y" || k === "Y");
|
+ this.answer = (k) => answer((k === "y" || k === "Y") && decideShown(this.state.overlay, this.cols, this.rows));
|
||||||
+ this.wake?.();
|
+ this.wake?.();
|
||||||
+ }, this.askMs);
|
+ }, this.askMs);
|
||||||
+ });
|
+ });
|
||||||
@@ -708,10 +726,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(" ");
|
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
|
diff --git a/packages/cli/src/screens.mjs b/packages/cli/src/screens.mjs
|
||||||
new file mode 100644
|
new file mode 100644
|
||||||
index 00000000..c0194cb2
|
index 00000000..38ac5f94
|
||||||
--- /dev/null
|
--- /dev/null
|
||||||
+++ b/packages/cli/src/screens.mjs
|
+++ b/packages/cli/src/screens.mjs
|
||||||
@@ -0,0 +1,405 @@
|
@@ -0,0 +1,429 @@
|
||||||
+// The full-screen app's frames (src/app.mjs). Pure: draw(state, opts) takes
|
+// 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
|
+// 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
|
+// once its escape codes are dropped. Nothing here reads the bus or the
|
||||||
@@ -750,12 +768,20 @@ index 00000000..c0194cb2
|
|||||||
+ if (
|
+ if (
|
||||||
+ /\p{Emoji_Presentation}/u.test(c) ||
|
+ /\p{Emoji_Presentation}/u.test(c) ||
|
||||||
+ (n >= 0x1100 && n <= 0x115f) ||
|
+ (n >= 0x1100 && n <= 0x115f) ||
|
||||||
|
+ n === 0x2329 ||
|
||||||
|
+ n === 0x232a ||
|
||||||
+ (n >= 0x2e80 && n <= 0xa4cf && n !== 0x303f) ||
|
+ (n >= 0x2e80 && n <= 0xa4cf && n !== 0x303f) ||
|
||||||
|
+ (n >= 0xa960 && n <= 0xa97c) ||
|
||||||
+ (n >= 0xac00 && n <= 0xd7a3) ||
|
+ (n >= 0xac00 && n <= 0xd7a3) ||
|
||||||
+ (n >= 0xf900 && n <= 0xfaff) ||
|
+ (n >= 0xf900 && n <= 0xfaff) ||
|
||||||
|
+ (n >= 0xfe10 && n <= 0xfe19) ||
|
||||||
+ (n >= 0xfe30 && n <= 0xfe4f) ||
|
+ (n >= 0xfe30 && n <= 0xfe4f) ||
|
||||||
|
+ (n >= 0xfe50 && n <= 0xfe6b) ||
|
||||||
+ (n >= 0xff00 && n <= 0xff60) ||
|
+ (n >= 0xff00 && n <= 0xff60) ||
|
||||||
+ (n >= 0xffe0 && n <= 0xffe6) ||
|
+ (n >= 0xffe0 && n <= 0xffe6) ||
|
||||||
|
+ (n >= 0x16fe0 && n <= 0x16ff1) ||
|
||||||
|
+ (n >= 0x17000 && n <= 0x18d08) ||
|
||||||
|
+ (n >= 0x1aff0 && n <= 0x1b2fb) ||
|
||||||
+ (n >= 0x1f200 && n <= 0x1f2ff) ||
|
+ (n >= 0x1f200 && n <= 0x1f2ff) ||
|
||||||
+ (n >= 0x20000 && n <= 0x3fffd)
|
+ (n >= 0x20000 && n <= 0x3fffd)
|
||||||
+ )
|
+ )
|
||||||
@@ -790,8 +816,11 @@ index 00000000..c0194cb2
|
|||||||
+ return out + "…" + " ".repeat(w - 1 - used);
|
+ return out + "…" + " ".repeat(w - 1 - used);
|
||||||
+}
|
+}
|
||||||
+
|
+
|
||||||
|
+const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||||
|
+
|
||||||
+// Splits `s` into lines of at most `w` cells, at spaces where it can;
|
+// Splits `s` into lines of at most `w` cells, at spaces where it can;
|
||||||
+// later lines start with `indent`.
|
+// later lines start with `indent`. A long word breaks between graphemes,
|
||||||
|
+// so a mark or U+FE0F stays with its base.
|
||||||
+export function wrap(s, w, indent = "") {
|
+export function wrap(s, w, indent = "") {
|
||||||
+ const out = [];
|
+ const out = [];
|
||||||
+ let cur = "";
|
+ let cur = "";
|
||||||
@@ -801,7 +830,7 @@ index 00000000..c0194cb2
|
|||||||
+ };
|
+ };
|
||||||
+ for (const word of s.split(/(?<= )/)) {
|
+ for (const word of s.split(/(?<= )/)) {
|
||||||
+ if (cur.trim() !== "" && width(cur + word.trimEnd()) > w) push();
|
+ if (cur.trim() !== "" && width(cur + word.trimEnd()) > w) push();
|
||||||
+ for (const c of word) {
|
+ for (const { segment: c } of graphemes.segment(word)) {
|
||||||
+ if (width(cur + c) > w) push();
|
+ if (width(cur + c) > w) push();
|
||||||
+ cur += c;
|
+ cur += c;
|
||||||
+ }
|
+ }
|
||||||
@@ -1052,23 +1081,35 @@ index 00000000..c0194cb2
|
|||||||
+// `room` is the body's height. A decide box taller than that loses the end
|
+// `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
|
+// of the question, never the decision line, the options, the
|
||||||
+// recommendation, the authorization lines, the task, the choice, the
|
+// recommendation, the authorization lines, the task, the choice, the
|
||||||
+// question to answer or its keys.
|
+// question to answer or its keys. If those don't fit either, the box only
|
||||||
|
+// says the decision is hidden, and App#ask answers every key No.
|
||||||
|
+function decideBox(o, w, room) {
|
||||||
|
+ const lines = o.lines.map((t) => (t.startsWith("your choice: ") ? [t, "bold"] : t));
|
||||||
|
+ if (o.asking) lines.push("", [o.asking, "bold"], "", ["y resolves · any other key cancels (the default is No)", "muted"]);
|
||||||
|
+ else lines.push("", ["resolving…", "muted"]);
|
||||||
|
+ const b = box("decide", lines, w);
|
||||||
|
+ if (b.length <= room) return b;
|
||||||
|
+ const q = o.lines.findIndex((t) => t.startsWith(" question: "));
|
||||||
|
+ const end = o.lines.findIndex((t, i) => i > q && t.startsWith(" options: "));
|
||||||
|
+ if (q < 0 || end < 0) return null;
|
||||||
|
+ 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] : null;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// Whether a frame of cols x rows shows the whole decide box, the end of a
|
||||||
|
+// long question aside. y resolves only then (App#ask).
|
||||||
|
+export function decideShown(o, cols, rows) {
|
||||||
|
+ return cols >= MIN_COLS && rows >= MIN_ROWS && o?.kind === "decide" && decideBox(o, Math.min(84, cols - 2), rows - 5) !== null;
|
||||||
|
+}
|
||||||
|
+
|
||||||
+function overlayBox(o, cols, room) {
|
+function overlayBox(o, cols, room) {
|
||||||
+ if (o.kind === "help") return box("help", HELP, Math.min(84, cols - 2));
|
+ if (o.kind === "help") return box("help", HELP, Math.min(84, cols - 2));
|
||||||
+ const w = Math.min(84, cols - 2);
|
+ const w = Math.min(84, cols - 2);
|
||||||
+ if (o.kind === "decide") {
|
+ if (o.kind === "decide") {
|
||||||
+ const lines = o.lines.map((t) => (t.startsWith("your choice: ") ? [t, "bold"] : t));
|
+ return decideBox(o, w, room) ?? box("decide", [["Too small to show this decision.", "warning"], o.asking ? "Any key answers No." : ["resolving…", "muted"]], w);
|
||||||
+ if (o.asking) lines.push("", [o.asking, "bold"], "", ["y resolves · any other key cancels (the default is No)", "muted"]);
|
|
||||||
+ else lines.push("", ["resolving…", "muted"]);
|
|
||||||
+ 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") {
|
+ 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);
|
+ 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);
|
||||||
@@ -1079,7 +1120,7 @@ index 00000000..c0194cb2
|
|||||||
+
|
+
|
||||||
+// Draws the frame. opts: cols, rows, color (false under NO_COLOR), now (ms).
|
+// Draws the frame. opts: cols, rows, color (false under NO_COLOR), now (ms).
|
||||||
+export function draw(state, { cols, rows, color = true, now = Date.now() }) {
|
+export function draw(state, { cols, rows, color = true, now = Date.now() }) {
|
||||||
+ if (cols < MIN_COLS || rows < MIN_ROWS) return tooSmall(cols, rows, color);
|
+ if (cols < MIN_COLS || rows < MIN_ROWS) return tooSmall(cols, rows, color, Boolean(state.overlay?.asking));
|
||||||
+ const room = rows - 5;
|
+ const room = rows - 5;
|
||||||
+ let body;
|
+ let body;
|
||||||
+ if (!state.business && state.view !== "talk") body = [line([` ${VIEWS.find((v) => v[2] === state.view)[1]}`, "bold"]), blank(), ...noBusiness()];
|
+ if (!state.business && state.view !== "talk") body = [line([` ${VIEWS.find((v) => v[2] === state.view)[1]}`, "bold"]), blank(), ...noBusiness()];
|
||||||
@@ -1092,14 +1133,13 @@ index 00000000..c0194cb2
|
|||||||
+ while (body.length < room) body.push(blank());
|
+ while (body.length < room) body.push(blank());
|
||||||
+ if (state.overlay) {
|
+ if (state.overlay) {
|
||||||
+ let b = overlayBox(state.overlay, cols, room);
|
+ let b = overlayBox(state.overlay, cols, room);
|
||||||
+ // Still too tall (a small screen, or a box with no question): keep the
|
+ // Still too tall (help, or a long detail on a small screen; a decide
|
||||||
+ // top and, for decide, everything from the choice on; past that, the
|
+ // box always fits): the top, a cut line and the last two lines. room is
|
||||||
+ // bottom wins, so the question to answer and its keys stay.
|
+ // at least 5, so that is exactly room lines.
|
||||||
+ if (b.length > room) {
|
+ if (b.length > room) {
|
||||||
+ const at = b.findIndex((l) => l.pieces.some(([t]) => t.startsWith("your choice: ")));
|
+ const tail = b.slice(-2);
|
||||||
+ const tail = b.slice(at > 0 ? at : -2);
|
|
||||||
+ const inner = Math.min(84, cols - 2) - 4;
|
+ 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 --json"})`, 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];
|
||||||
+ }
|
+ }
|
||||||
+ const left = Math.max(0, Math.floor((cols - width(b[0].pieces.map(([t]) => t).join(""))) / 2));
|
+ 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));
|
+ const top = Math.max(0, Math.min(2, room - b.length));
|
||||||
@@ -1111,18 +1151,20 @@ index 00000000..c0194cb2
|
|||||||
+}
|
+}
|
||||||
+
|
+
|
||||||
+// The whole frame below MIN_COLS x MIN_ROWS: what is wrong, and how to quit.
|
+// The whole frame below MIN_COLS x MIN_ROWS: what is wrong, and how to quit.
|
||||||
+function tooSmall(cols, rows, color) {
|
+// With the decide question open, any key answers it No (App#ask).
|
||||||
+ 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));
|
+function tooSmall(cols, rows, color, asking) {
|
||||||
|
+ const keys = asking ? "A decision question is open; any key answers No." : "Esc closes what is open; q quits.";
|
||||||
|
+ const say = wrap(`The terminal is ${cols}x${rows}; the app needs at least ${MIN_COLS}x${MIN_ROWS}. Make it bigger. ${keys}`, Math.max(1, cols));
|
||||||
+ const body = say.slice(0, Math.max(0, rows)).map((t) => line([t, "warning"]));
|
+ const body = say.slice(0, Math.max(0, rows)).map((t) => line([t, "warning"]));
|
||||||
+ while (body.length < rows) body.push(blank());
|
+ while (body.length < rows) body.push(blank());
|
||||||
+ return body.map((l) => paint(l, Math.max(0, cols), color));
|
+ 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
|
diff --git a/packages/cli/tests/app.test.mjs b/packages/cli/tests/app.test.mjs
|
||||||
new file mode 100644
|
new file mode 100644
|
||||||
index 00000000..b01abcb6
|
index 00000000..a679886a
|
||||||
--- /dev/null
|
--- /dev/null
|
||||||
+++ b/packages/cli/tests/app.test.mjs
|
+++ b/packages/cli/tests/app.test.mjs
|
||||||
@@ -0,0 +1,719 @@
|
@@ -0,0 +1,868 @@
|
||||||
+import { test } from "node:test";
|
+import { test } from "node:test";
|
||||||
+import assert from "node:assert/strict";
|
+import assert from "node:assert/strict";
|
||||||
+import { spawnSync } from "node:child_process";
|
+import { spawnSync } from "node:child_process";
|
||||||
@@ -1134,7 +1176,7 @@ index 00000000..b01abcb6
|
|||||||
+import { main, resolveDecision, SEAT_USAGE, USAGE } from "../src/cli.mjs";
|
+import { main, resolveDecision, SEAT_USAGE, USAGE } from "../src/cli.mjs";
|
||||||
+import { CliError } from "../src/errors.mjs";
|
+import { CliError } from "../src/errors.mjs";
|
||||||
+import { hostDir, hostFile, startTimeOf } from "../src/host.mjs";
|
+import { hostDir, hostFile, startTimeOf } from "../src/host.mjs";
|
||||||
+import { draw, strip, width } from "../src/screens.mjs";
|
+import { decideShown, draw, strip, width, wrap as wrapCells } from "../src/screens.mjs";
|
||||||
+import { broker, humanEnv, io, tmp } from "./helpers.mjs";
|
+import { broker, humanEnv, io, tmp } from "./helpers.mjs";
|
||||||
+
|
+
|
||||||
+const here = dirname(fileURLToPath(import.meta.url));
|
+const here = dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -1291,11 +1333,11 @@ index 00000000..b01abcb6
|
|||||||
+ for (const color of [true, false]) {
|
+ 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}`);
|
+ 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.
|
+ // A decide box too tall to show, with no question to cut, says the
|
||||||
|
+ // decision is hidden and that any key answers No (Dewey B1, round 3).
|
||||||
+ const long = (await everyScreen(t, cols, rows, false)).find(([l]) => l === "overlay decide long")[1].join("\n");
|
+ 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, /Too small to show this decision\.[\s\S]*Any key answers No\./, `${cols}x${rows}`);
|
||||||
+ assert.match(long, /y resolves/, `${cols}x${rows}`);
|
+ assert.doesNotMatch(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.
|
+ // Too small: every screen still fits, and says why.
|
||||||
+ for (const [cols, rows] of TOO_SMALL) {
|
+ for (const [cols, rows] of TOO_SMALL) {
|
||||||
@@ -1312,9 +1354,14 @@ index 00000000..b01abcb6
|
|||||||
+});
|
+});
|
||||||
+
|
+
|
||||||
+test("width counts terminal cells: wide CJK and emoji 2, U+FE0F after a one-cell base 1, marks 0", async (t) => {
|
+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]]) {
|
+ for (const [s, cells] of [["a", 1], ["発行", 4], ["✅", 2], ["⚡", 2], ["❌", 2], ["⭐", 2], ["😀", 2], ["🅰", 1], ["🅰\uFE0F", 2], ["❤\uFE0F", 2], ["😀\uFE0F", 2], ["e\u0301", 1], ["✓", 1], ["›", 1], ["\u{1F210}", 2]]) {
|
||||||
+ assert.equal(width(s), cells, JSON.stringify(s));
|
+ assert.equal(width(s), cells, JSON.stringify(s));
|
||||||
+ }
|
+ }
|
||||||
|
+ // East Asian Width W and F outside the blocks above: one from each range.
|
||||||
|
+ for (const n of [0x2329, 0x232a, 0xa960, 0xfe10, 0xfe50, 0x16fe0, 0x17000, 0x1aff0, 0x1b2fb]) assert.equal(width(String.fromCodePoint(n)), 2, n.toString(16));
|
||||||
|
+ // A line break never splits ❤️ from its U+FE0F; no line is wider than asked.
|
||||||
|
+ assert.deepEqual(wrapCells("abc❤\uFE0F x", 4), ["abc", "❤\uFE0F x"]);
|
||||||
|
+ for (const l of wrapCells("ab ❤\uFE0F❤\uFE0F e\u0301e\u0301e\u0301", 4)) assert.ok(width(l) <= 4, JSON.stringify(l));
|
||||||
+ // On screen, independent of width(): each wide character takes two cells
|
+ // 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
|
+ // for one UTF-16 unit, so a row of `cols` cells is that much shorter in
|
||||||
+ // units. 🅰️ is three units for two cells.
|
+ // units. 🅰️ is three units for two cells.
|
||||||
@@ -1419,6 +1466,69 @@ index 00000000..b01abcb6
|
|||||||
+ }
|
+ }
|
||||||
+});
|
+});
|
||||||
+
|
+
|
||||||
|
+test("below 40x10 the question is hidden, so every key answers it No, y included (Filbert R3)", async (t) => {
|
||||||
|
+ const flat = (s) => s.a.frame.map(strip).join(" ").replace(/\s+/g, " ");
|
||||||
|
+ const notSent = (s, label) => {
|
||||||
|
+ assert.equal(s.a.state.overlay, null, label);
|
||||||
|
+ assert.equal(s.a.state.status, "not resolved", label);
|
||||||
|
+ assert.equal(s.read("inbox").length, 1, label);
|
||||||
|
+ assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"), label);
|
||||||
|
+ };
|
||||||
|
+ // Too small from the start: the frame says a question is open, and y answers No.
|
||||||
|
+ for (const [cols, rows] of [[30, 24], [80, 9]]) {
|
||||||
|
+ const s = app(t, { cols, rows });
|
||||||
|
+ s.raise("git.push.protected", { target: "refactor" });
|
||||||
|
+ await s.a.start();
|
||||||
|
+ await s.a.input("1");
|
||||||
|
+ assert.ok(s.a.state.overlay?.asking, `${cols}x${rows}`);
|
||||||
|
+ assert.match(flat(s), /A decision question is open; any key answers No\./, `${cols}x${rows}`);
|
||||||
|
+ assert.doesNotMatch(flat(s), /q quits/, `${cols}x${rows}`);
|
||||||
|
+ await s.a.input("y");
|
||||||
|
+ notSent(s, `${cols}x${rows}`);
|
||||||
|
+ assert.match(flat(s), /Esc closes what is open; q quits\./, "the usual text once the question is gone");
|
||||||
|
+ }
|
||||||
|
+ // Shrunk while the question is open.
|
||||||
|
+ const s = app(t);
|
||||||
|
+ s.raise("git.push.protected", { target: "refactor" });
|
||||||
|
+ await s.a.start();
|
||||||
|
+ await s.a.input("1");
|
||||||
|
+ assert.match(s.screen(), /y resolves/);
|
||||||
|
+ s.a.resize(30, 24);
|
||||||
|
+ assert.match(flat(s), /A decision question is open; any key answers No\./);
|
||||||
|
+ await s.a.input("Y");
|
||||||
|
+ notSent(s, "shrunk");
|
||||||
|
+ // Shrunk and grown back before the key: the question is on screen again, so y resolves.
|
||||||
|
+ const g = app(t);
|
||||||
|
+ const d = g.raise("git.push.protected", { target: "refactor" });
|
||||||
|
+ await g.a.start();
|
||||||
|
+ await g.a.input("1");
|
||||||
|
+ g.a.resize(30, 24);
|
||||||
|
+ g.a.resize(80, 24);
|
||||||
|
+ assert.match(g.screen(), /y resolves/);
|
||||||
|
+ await g.a.input("y");
|
||||||
|
+ assert.equal(g.a.state.status, `resolved ${d.id}: yes`);
|
||||||
|
+});
|
||||||
|
+
|
||||||
|
+test("a throw while answering the question goes to crash, sends nothing and leaves no job pending", async (t) => {
|
||||||
|
+ const boom = new Error("the terminal write failed");
|
||||||
|
+ let fail = false;
|
||||||
|
+ const s = app(t, {
|
||||||
|
+ write: () => {
|
||||||
|
+ if (fail) throw boom;
|
||||||
|
+ },
|
||||||
|
+ });
|
||||||
|
+ s.raise("git.push.protected", { target: "refactor" });
|
||||||
|
+ await s.a.start();
|
||||||
|
+ await s.a.input("1");
|
||||||
|
+ assert.ok(s.a.state.overlay?.asking);
|
||||||
|
+ fail = true;
|
||||||
|
+ await s.a.input("y");
|
||||||
|
+ assert.deepEqual(s.out.quit, { how: "an error", resolved: 0, error: boom });
|
||||||
|
+ for (let i = 0; s.a.job && i < 100; i++) await new Promise((r) => setImmediate(r));
|
||||||
|
+ assert.equal(s.a.job, null, "the decide job ended");
|
||||||
|
+ assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"));
|
||||||
|
+});
|
||||||
|
+
|
||||||
+test("type-ahead never answers the question: a paste, keys typed before it is drawn, keys inside askMs", async (t) => {
|
+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 asking = (s) => Boolean(s.a.state.overlay?.asking);
|
||||||
+ const sent = (s) => s.calls.filter((c) => c.verb === "decision.resolve").length;
|
+ const sent = (s) => s.calls.filter((c) => c.verb === "decision.resolve").length;
|
||||||
@@ -1471,7 +1581,7 @@ index 00000000..b01abcb6
|
|||||||
+ }
|
+ }
|
||||||
+});
|
+});
|
||||||
+
|
+
|
||||||
+test("quitting at the question answers No and sends nothing, armed or not", async (t) => {
|
+test("quitting at the question answers No and sends nothing, armed or not", { timeout: 10000 }, async (t) => {
|
||||||
+ for (const askMs of [0, 60000]) {
|
+ for (const askMs of [0, 60000]) {
|
||||||
+ const s = app(t, { askMs });
|
+ const s = app(t, { askMs });
|
||||||
+ s.raise("git.push.protected", { target: "refactor" });
|
+ s.raise("git.push.protected", { target: "refactor" });
|
||||||
@@ -1529,6 +1639,81 @@ index 00000000..b01abcb6
|
|||||||
+ fits(s.a.frame, 80, 24, "long question");
|
+ fits(s.a.frame, 80, 24, "long question");
|
||||||
+});
|
+});
|
||||||
+
|
+
|
||||||
|
+// A gated, blocking decision with a task, and every line its decide box
|
||||||
|
+// must show before y can resolve it (Dewey B1, round 3).
|
||||||
|
+function gated(t, cols, rows, long) {
|
||||||
|
+ const s = app(t, { cols, rows });
|
||||||
|
+ const question = long ? Array.from({ length: 90 }, (_, i) => `word${i}`).join(" ") : "Push the release?";
|
||||||
|
+ const d = s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7", question });
|
||||||
|
+ const keep = [`decision ${d.id}`, long ? "question: word0" : "question: Push the release?", "options: yes = Allow; no = Decline", "recommended: no", "action: git.push.protected on refactor", 'choosing "yes" authorizes it; any other choice declines', "task: vikunja:1/7", "your choice: yes (Allow); this authorizes the action", `resolve ${d.id.slice(0, 8)} with yes? [y/N]`, "y resolves · any other key cancels (the default is No)"];
|
||||||
|
+ return { ...s, d, keep };
|
||||||
|
+}
|
||||||
|
+// Box borders and wrapping removed, so a line broken across rows still matches.
|
||||||
|
+const squash = (text) => text.replace(/[\s│]/g, "");
|
||||||
|
+
|
||||||
|
+test("a decide box too big for the terminal says so, and every key answers it No, y included (Dewey B1, round 3)", async (t) => {
|
||||||
|
+ const hidden = async (s, key, label) => {
|
||||||
|
+ assert.ok(s.a.state.overlay?.asking, label);
|
||||||
|
+ assert.match(s.screen(), /Too small to show this decision\.[\s\S]*Any key answers No\./, label);
|
||||||
|
+ assert.doesNotMatch(s.screen(), /y resolves/, label);
|
||||||
|
+ await s.a.input(key);
|
||||||
|
+ assert.equal(s.a.state.overlay, null, label);
|
||||||
|
+ assert.equal(s.a.state.status, "not resolved", label);
|
||||||
|
+ assert.equal(s.read("inbox").length, 1, label);
|
||||||
|
+ assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"), label);
|
||||||
|
+ };
|
||||||
|
+ // Dewey's probe sizes: a short question at 80x18 and 40x24, a long one at 80x20.
|
||||||
|
+ for (const [cols, rows, long] of [[80, 18, false], [40, 24, false], [80, 20, true], [120, 16, false], [80, 10, false]]) {
|
||||||
|
+ const s = gated(t, cols, rows, long);
|
||||||
|
+ await s.a.start();
|
||||||
|
+ await s.a.input("1");
|
||||||
|
+ await hidden(s, "y", `${cols}x${rows}${long ? " long" : ""}`);
|
||||||
|
+ }
|
||||||
|
+ // One row more and the whole box shows, so y resolves.
|
||||||
|
+ for (const [cols, rows, long] of [[80, 20, false], [80, 21, true]]) {
|
||||||
|
+ const s = gated(t, cols, rows, long);
|
||||||
|
+ await s.a.start();
|
||||||
|
+ await s.a.input("1");
|
||||||
|
+ for (const want of s.keep) assert.ok(squash(s.screen()).includes(squash(want)), `${cols}x${rows}: ${want}\n${s.screen()}`);
|
||||||
|
+ await s.a.input("y");
|
||||||
|
+ assert.equal(s.a.state.status, `resolved ${s.d.id}: yes`, `${cols}x${rows}`);
|
||||||
|
+ }
|
||||||
|
+ // Shrunk after the question is armed: checked at the key.
|
||||||
|
+ const s = gated(t, 80, 24, false);
|
||||||
|
+ await s.a.start();
|
||||||
|
+ await s.a.input("1");
|
||||||
|
+ assert.match(s.screen(), /y resolves/);
|
||||||
|
+ s.a.resize(80, 15);
|
||||||
|
+ await hidden(s, "Y", "shrunk to 80x15");
|
||||||
|
+});
|
||||||
|
+
|
||||||
|
+test("at every size the decide box shows each line y depends on, or says it is hidden", async (t) => {
|
||||||
|
+ for (const long of [false, true]) {
|
||||||
|
+ const s = gated(t, 120, 40, long);
|
||||||
|
+ await s.a.start();
|
||||||
|
+ await s.a.input("1");
|
||||||
|
+ const seen = { shown: 0, hidden: 0 };
|
||||||
|
+ for (let cols = 40; cols <= 120; cols += 4) {
|
||||||
|
+ for (let rows = 10; rows <= 40; rows++) {
|
||||||
|
+ const frame = draw(s.a.state, { cols, rows, color: false, now: NOW });
|
||||||
|
+ const label = `${cols}x${rows}${long ? " long" : ""}`;
|
||||||
|
+ fits(frame, cols, rows, label);
|
||||||
|
+ const text = frame.map(strip).join("\n");
|
||||||
|
+ if (decideShown(s.a.state.overlay, cols, rows)) {
|
||||||
|
+ seen.shown++;
|
||||||
|
+ for (const want of s.keep) assert.ok(squash(text).includes(squash(want)), `${label}: ${want}`);
|
||||||
|
+ } else {
|
||||||
|
+ seen.hidden++;
|
||||||
|
+ assert.match(text, /Too small to show this decision\.[\s\S]*Any key answers No\./, label);
|
||||||
|
+ assert.doesNotMatch(text, /y resolves/, label);
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ assert.ok(seen.shown > 100 && seen.hidden > 100, JSON.stringify(seen));
|
||||||
|
+ s.a.quit("q");
|
||||||
|
+ }
|
||||||
|
+});
|
||||||
|
+
|
||||||
+test("outcome unknown: the CLI's sentence, no retry, then the trail or a re-read", async (t) => {
|
+test("outcome unknown: the CLI's sentence, no retry, then the trail or a re-read", async (t) => {
|
||||||
+ let resolves = 0;
|
+ let resolves = 0;
|
||||||
+ const s = app(t, {
|
+ const s = app(t, {
|
||||||
@@ -1759,7 +1944,7 @@ index 00000000..b01abcb6
|
|||||||
+ }
|
+ }
|
||||||
+});
|
+});
|
||||||
+
|
+
|
||||||
+test("decide with --note still asks; a No sends nothing", async (t) => {
|
+test("decide with --note still asks; a No sends nothing", { timeout: 10000 }, async (t) => {
|
||||||
+ const s = broker(t);
|
+ const s = broker(t);
|
||||||
+ const d = s.raise("git.push.protected", { target: "refactor" });
|
+ const d = s.raise("git.push.protected", { target: "refactor" });
|
||||||
+ const call = s.transport("demo");
|
+ const call = s.transport("demo");
|
||||||
@@ -1795,9 +1980,13 @@ index 00000000..b01abcb6
|
|||||||
+});
|
+});
|
||||||
+
|
+
|
||||||
+// A terminal for runApp: raw mode and listeners recorded, writes kept.
|
+// A terminal for runApp: raw mode and listeners recorded, writes kept.
|
||||||
+function terminal({ throwOnResize = false } = {}) {
|
+function terminal({ throwOnResize = false, throwOnFrame = false } = {}) {
|
||||||
+ const stdin = Object.assign(new EventEmitter(), { raw: [], setRawMode: (on) => stdin.raw.push(on), pause: () => {}, resume: () => {} });
|
+ 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 });
|
+ const write = (s) => {
|
||||||
|
+ if (throwOnFrame && s.startsWith("\x1b[H")) throw new Error("frame failed");
|
||||||
|
+ stdout.writes.push(s);
|
||||||
|
+ };
|
||||||
|
+ const stdout = Object.assign(new EventEmitter(), { writes: [], write, rows: 24 });
|
||||||
+ let resized = false;
|
+ let resized = false;
|
||||||
+ Object.defineProperty(stdout, "columns", {
|
+ Object.defineProperty(stdout, "columns", {
|
||||||
+ get: () => {
|
+ get: () => {
|
||||||
@@ -1810,7 +1999,7 @@ index 00000000..b01abcb6
|
|||||||
+ return { io: { stdin, stdout }, stdin, stdout, signals, 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 () => {
|
+test("runApp leaves the terminal as it found it on q, end of input, SIGTERM, SIGINT, SIGHUP and an error, the first frame's included", { timeout: 10000 }, async () => {
|
||||||
+ const cases = [
|
+ const cases = [
|
||||||
+ ["q", (x) => x.stdin.emit("data", Buffer.from("q"))],
|
+ ["q", (x) => x.stdin.emit("data", Buffer.from("q"))],
|
||||||
+ ["end of input", (x) => x.stdin.emit("end")],
|
+ ["end of input", (x) => x.stdin.emit("end")],
|
||||||
@@ -1818,18 +2007,20 @@ index 00000000..b01abcb6
|
|||||||
+ ["SIGINT", (x) => x.signals.emit("SIGINT")],
|
+ ["SIGINT", (x) => x.signals.emit("SIGINT")],
|
||||||
+ ["SIGHUP", (x) => x.signals.emit("SIGHUP")],
|
+ ["SIGHUP", (x) => x.signals.emit("SIGHUP")],
|
||||||
+ ["an error", (x) => x.resize(), { throwOnResize: true }],
|
+ ["an error", (x) => x.resize(), { throwOnResize: true }],
|
||||||
|
+ ["an error", () => {}, { throwOnFrame: true }],
|
||||||
+ ];
|
+ ];
|
||||||
+ for (const [how, act, opts] of cases) {
|
+ for (const [how, act, opts] of cases) {
|
||||||
+ const x = terminal(opts);
|
+ const x = terminal(opts);
|
||||||
+ const running = runApp({ io: x.io, business: "demo", transport: () => async () => [], color: false, refreshMs: 0, askMs: 0, signals: x.signals });
|
+ // Handled at once: an error on the first frame rejects before the next tick.
|
||||||
+ await new Promise((r) => setImmediate(r));
|
+ const running = runApp({ io: x.io, business: "demo", transport: () => async () => [], color: false, refreshMs: 0, askMs: 0, signals: x.signals }).then(
|
||||||
+ act(x);
|
|
||||||
+ const result = await running.then(
|
|
||||||
+ (r) => r,
|
+ (r) => r,
|
||||||
+ (e) => ({ how: "an error", error: e }),
|
+ (e) => ({ how: "an error", error: e }),
|
||||||
+ );
|
+ );
|
||||||
|
+ await new Promise((r) => setImmediate(r));
|
||||||
|
+ act(x);
|
||||||
|
+ const result = await running;
|
||||||
+ assert.equal(result.how, how);
|
+ assert.equal(result.how, how);
|
||||||
+ if (how === "an error") assert.equal(result.error.message, "columns failed");
|
+ if (how === "an error") assert.equal(result.error.message, opts.throwOnFrame ? "frame failed" : "columns failed");
|
||||||
+ assert.equal(x.stdout.writes[0], "\x1b[?1049h\x1b[?25l", how);
|
+ assert.equal(x.stdout.writes[0], "\x1b[?1049h\x1b[?25l", how);
|
||||||
+ assert.equal(x.stdout.writes.at(-1), "\x1b[?25h\x1b[?1049l", how);
|
+ assert.equal(x.stdout.writes.at(-1), "\x1b[?25h\x1b[?1049l", how);
|
||||||
+ assert.deepEqual(x.stdin.raw, [true, false], how);
|
+ assert.deepEqual(x.stdin.raw, [true, false], how);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
fcc2791aa2f80d7e196b0ce18f02475f9fc6730c74cbb56d2db3f9aae333253e docs/TOOLS.md
|
fcc2791aa2f80d7e196b0ce18f02475f9fc6730c74cbb56d2db3f9aae333253e docs/TOOLS.md
|
||||||
ac058d0aa7eaaf7a52a681f79fcedb3ea15051226697542029cb5fa15fc15600 packages/cli/README.md
|
08fbf0fd72ca5832a33b189a3a64c89dc10d752bfbb94b99042125e9f38cd30a packages/cli/README.md
|
||||||
dfdeb9b91eb41f67ecec8e4835f631d6ec47b967897d349ed9b27d06045a9d93 packages/cli/src/app.mjs
|
6dd4cc049bdb166bcd63b708093e87bc77726001b83a5b0091bc70a24dc3dc3e packages/cli/src/app.mjs
|
||||||
c33d3fedb91be7977cf7b468c908719d8f07cd7a2dad1bb7d9c24d62bcdac432 packages/cli/src/cli.mjs
|
c33d3fedb91be7977cf7b468c908719d8f07cd7a2dad1bb7d9c24d62bcdac432 packages/cli/src/cli.mjs
|
||||||
c2f6852226c7071dec132d422773d76bdfc356450f582ac28cb7a5077860b4c0 packages/cli/src/format.mjs
|
c2f6852226c7071dec132d422773d76bdfc356450f582ac28cb7a5077860b4c0 packages/cli/src/format.mjs
|
||||||
ff24eb0f78a64f98892eca372337ce141f30bc15169200fde37b6db34c6c8474 packages/cli/src/screens.mjs
|
3cc80840f7492b3271835cc88bd99420566b9079098fef8adb9d0d4258a2921c packages/cli/src/screens.mjs
|
||||||
10b15b25f607100f5a1a6e315334668c014d01452c92fbd1c04dd2bfba276d30 packages/cli/tests/app.test.mjs
|
1b7b31f0f438a6390315a824b0e300ab78f4caaa6885f86705a08860638ebd47 packages/cli/tests/app.test.mjs
|
||||||
bc972d153603311b08a2173d5b912f5fa7e9277605d3ce60d87161bf28ea80ab packages/queue/tests/dispatch.test.mjs
|
bc972d153603311b08a2173d5b912f5fa7e9277605d3ce60d87161bf28ea80ab packages/queue/tests/dispatch.test.mjs
|
||||||
ab246b3e39ea0c503d97e7af93ef8b958caedb3cf3006e387f14fa93b329856c scripts/mosaic
|
ab246b3e39ea0c503d97e7af93ef8b958caedb3cf3006e387f14fa93b329856c scripts/mosaic
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
round 3 apply check, 2026-10-11T19:52:01Z
|
||||||
|
base for apply: 122b122374ce93523efa13cd30676918c416f106
|
||||||
|
git apply --index build.patch: ok
|
||||||
|
9 files changed, 1932 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
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
✔ every screen and overlay fits 120x36, 80x24 and the narrow layout, row for row (1641.123987ms)
|
||||||
|
✔ width counts terminal cells: wide CJK and emoji 2, U+FE0F after a one-cell base 1, marks 0 (91.625067ms)
|
||||||
|
✔ broker text is shown, never interpreted: no raw control character or bidi override reaches the terminal (204.389251ms)
|
||||||
|
✔ NO_COLOR: no escape codes, except reverse video on the selected row (297.651664ms)
|
||||||
|
✔ 1-9 shows the decide flow's own lines and question; only y resolves (134.985026ms)
|
||||||
|
✔ the app shows the same decide text as mosaic decide (65.507892ms)
|
||||||
|
✔ any key but y or Y at the question answers No; nothing is sent (1393.693801ms)
|
||||||
|
✔ below 40x10 the question is hidden, so every key answers it No, y included (Filbert R3) (323.453488ms)
|
||||||
|
✔ a throw while answering the question goes to crash, sends nothing and leaves no job pending (100.439482ms)
|
||||||
|
✔ type-ahead never answers the question: a paste, keys typed before it is drawn, keys inside askMs (1158.438577ms)
|
||||||
|
✔ quitting at the question answers No and sends nothing, armed or not (173.081799ms)
|
||||||
|
✔ a timer tick reads nothing under an overlay, never answers the question, and keeps a resolved status (80.1729ms)
|
||||||
|
✔ a long question is cut inside the question; the action, target, choice and prompt stay (Dewey B1) (89.855673ms)
|
||||||
|
✔ a decide box too big for the terminal says so, and every key answers it No, y included (Dewey B1, round 3) (700.017659ms)
|
||||||
|
✔ at every size the decide box shows each line y depends on, or says it is hidden (11063.134882ms)
|
||||||
|
✔ outcome unknown: the CLI's sentence, no retry, then the trail or a re-read (170.541821ms)
|
||||||
|
✔ refused and failed reads keep the last list, marked stale; a refused decide says refused (114.46264ms)
|
||||||
|
✔ keys: views, moving, trail of a selection, the command line, help and quit (250.788525ms)
|
||||||
|
✔ one option: the status line says Press 1, not 1-1 (108.728774ms)
|
||||||
|
✔ no business: the app says so and :business picks one (54.240423ms)
|
||||||
|
✔ mosaic with no argument: exit 3 in an agent run, exit 4 with the usage off a terminal, the app on one (1.82506ms)
|
||||||
|
✔ scripts/mosaic with no argument reaches the app entry; the seat usage it printed before has not drifted (211.846348ms)
|
||||||
|
✔ strings to keep exactly live once, in the decide flow and the transport; the app does not restate them (0.784937ms)
|
||||||
|
✔ decide with --note still asks; a No sends nothing (61.39156ms)
|
||||||
|
✔ the app's business comes from a live bus host only (1.253556ms)
|
||||||
|
✔ runApp leaves the terminal as it found it on q, end of input, SIGTERM, SIGINT, SIGHUP and an error, the first frame's included (9.450749ms)
|
||||||
|
✔ draw is pure: same state, same frame (1.014652ms)
|
||||||
|
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (160.228747ms)
|
||||||
|
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (173.981743ms)
|
||||||
|
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (178.01175ms)
|
||||||
|
✔ decide prints a declining choice as declining (186.91029ms)
|
||||||
|
✔ an unknown outcome is reported once and never resent (114.294151ms)
|
||||||
|
✔ a decision closed before the answer arrives exits 2 and points at its trail (228.413474ms)
|
||||||
|
✔ a prefix that matches two open decisions exits 2 and resolves neither (127.665424ms)
|
||||||
|
✔ without --business a command uses the live host's business, and a stale host.json is not a host (103.648788ms)
|
||||||
|
✔ every human command refuses inside an agent run before it touches the bus (85.556081ms)
|
||||||
|
✔ usage errors exit 4; no business and no host is a usage error (77.506148ms)
|
||||||
|
✔ agents and tasks print through the broker (103.476436ms)
|
||||||
|
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.603133ms)
|
||||||
|
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (68.648006ms)
|
||||||
|
✔ trackers come from the tracker.* variables of the one project that names a tracker project (42.037548ms)
|
||||||
|
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (45.478142ms)
|
||||||
|
✔ two projects that each name a tracker project refuse, since the boot shape holds one (41.178111ms)
|
||||||
|
✔ a business without tracker.baseUrl gets no trackers entry (36.924054ms)
|
||||||
|
✔ an unknown business and a broken system config refuse with exit 3 (80.616129ms)
|
||||||
|
✔ empty views say so (1.155964ms)
|
||||||
|
✔ the trail keeps the broker's order and names a decision's task without its rows (1.417509ms)
|
||||||
|
✔ tasks print the tracker fields the snapshot carries (0.207258ms)
|
||||||
|
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (1017.640757ms)
|
||||||
|
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (301.029227ms)
|
||||||
|
✔ a second host for the same data root refuses with exit 3 while the first runs (181.630331ms)
|
||||||
|
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1184.644329ms)
|
||||||
|
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (629.304517ms)
|
||||||
|
✔ a broker reply with no request waiting breaks the channel and the host exits 1 (170.37376ms)
|
||||||
|
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (356.511996ms)
|
||||||
|
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (228.131282ms)
|
||||||
|
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (148.593046ms)
|
||||||
|
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (725.077976ms)
|
||||||
|
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (137.715237ms)
|
||||||
|
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (195.313058ms)
|
||||||
|
✔ watchChildren reports a child that died before it was called, and one that dies later (33.728711ms)
|
||||||
|
✔ bus stop refuses to signal a live pid that is not a bus host (204.116213ms)
|
||||||
|
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (285.402332ms)
|
||||||
|
✔ bus start refuses with exit 3 without a notifier config (120.124432ms)
|
||||||
|
✔ bus start runs until bus stop; status reports it while it runs (889.37193ms)
|
||||||
|
✔ bus-service.sh renders the unit and installs it into a given directory (41.46377ms)
|
||||||
|
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1381.127614ms)
|
||||||
|
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (736.176939ms)
|
||||||
|
✔ a runner that stops at once ends its launch with the runner's reason (231.500014ms)
|
||||||
|
✔ 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 (1198.727217ms)
|
||||||
|
✔ 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 (3819.084307ms)
|
||||||
|
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (139.667775ms)
|
||||||
|
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (152.104942ms)
|
||||||
|
✔ a launch client that never closes its side doesn't hold the host's close (148.765746ms)
|
||||||
|
✔ a runner that ignores SIGTERM is killed when the host closes (1266.599402ms)
|
||||||
|
✔ zoned uses the IANA zone across DST (30.213719ms)
|
||||||
|
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (145.882722ms)
|
||||||
|
✔ two blocking decisions get two DMs with different nonces (155.970689ms)
|
||||||
|
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.394037ms)
|
||||||
|
✔ a failed DM is journaled, backs off, and is retried until it lands (196.602478ms)
|
||||||
|
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (177.977332ms)
|
||||||
|
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (142.200148ms)
|
||||||
|
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (200.443925ms)
|
||||||
|
✔ 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 (224.492906ms)
|
||||||
|
✔ a restart after the second refusal does not send before that refusal's 30 min are up (157.445891ms)
|
||||||
|
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (118.512391ms)
|
||||||
|
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (116.363451ms)
|
||||||
|
✔ an inbox read failure is logged and the next poll retries (0.899012ms)
|
||||||
|
✔ no Discord id reaches the journal or the log (102.631507ms)
|
||||||
|
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (26.753715ms)
|
||||||
|
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (62.706102ms)
|
||||||
|
✔ the journal: a whole file that is one torn line truncates to empty (21.628484ms)
|
||||||
|
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (1.956257ms)
|
||||||
|
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.751353ms)
|
||||||
|
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (8.66405ms)
|
||||||
|
✔ the journal: a symlinked directory refuses and says it is a link (0.453577ms)
|
||||||
|
✔ 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.611063ms)
|
||||||
|
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.507789ms)
|
||||||
|
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.50937ms)
|
||||||
|
✔ digest content stays within Discord's 2000 characters (0.312002ms)
|
||||||
|
✔ runLoop never overlaps ticks and stops after the one in flight (111.79346ms)
|
||||||
|
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 (455.287493ms)
|
||||||
|
✔ the transport writes {business, verb, args} to the child and reads its JSON (53.100004ms)
|
||||||
|
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2188.870587ms)
|
||||||
|
✔ busExit and refuseInsideAgent (0.676038ms)
|
||||||
|
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (265.655593ms)
|
||||||
|
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1221.198035ms)
|
||||||
|
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (109.069472ms)
|
||||||
|
✔ launches off and on go to the broker and change the business's launch state (101.594541ms)
|
||||||
|
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (133.681892ms)
|
||||||
|
ℹ tests 110
|
||||||
|
ℹ suites 0
|
||||||
|
ℹ pass 110
|
||||||
|
ℹ fail 0
|
||||||
|
ℹ cancelled 0
|
||||||
|
ℹ skipped 0
|
||||||
|
ℹ todo 0
|
||||||
|
ℹ duration_ms 18672.304682
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
export TMPDIR=~/darkwing-scratch/r55/tmp
|
||||||
|
G=~/darkwing-scratch/r55/gate; O=~/darkwing-scratch/r55/out3; 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,55 @@
|
|||||||
|
Note: switching to '122b122374ce93523efa13cd30676918c416f106'.
|
||||||
|
|
||||||
|
You are in 'detached HEAD' state. You can look around, make experimental
|
||||||
|
changes and commit them, and you can discard any commits you make in this
|
||||||
|
state without impacting any branches by switching back to a branch.
|
||||||
|
|
||||||
|
If you want to create a new branch to retain commits you create, you may
|
||||||
|
do so (now or later) by using -c with the switch command. Example:
|
||||||
|
|
||||||
|
git switch -c <new-branch-name>
|
||||||
|
|
||||||
|
Or undo this operation with:
|
||||||
|
|
||||||
|
git switch -
|
||||||
|
|
||||||
|
Turn off this advice by setting config variable advice.detachedHead to false
|
||||||
|
|
||||||
|
Not currently on any branch.
|
||||||
|
nothing to commit, working tree clean
|
||||||
|
Note: switching to '122b122374ce93523efa13cd30676918c416f106'.
|
||||||
|
|
||||||
|
You are in 'detached HEAD' state. You can look around, make experimental
|
||||||
|
changes and commit them, and you can discard any commits you make in this
|
||||||
|
state without impacting any branches by switching back to a branch.
|
||||||
|
|
||||||
|
If you want to create a new branch to retain commits you create, you may
|
||||||
|
do so (now or later) by using -c with the switch command. Example:
|
||||||
|
|
||||||
|
git switch -c <new-branch-name>
|
||||||
|
|
||||||
|
Or undo this operation with:
|
||||||
|
|
||||||
|
git switch -
|
||||||
|
|
||||||
|
Turn off this advice by setting config variable advice.detachedHead to false
|
||||||
|
|
||||||
|
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: 3158942 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,52 @@
|
|||||||
|
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 | runLoop never overlaps ticks and stops after the one in fl
|
||||||
|
cells-no-fe0f: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F
|
||||||
|
b1-cut-keeps-end: killed by a long question is cut inside the question; the action, ta | a decide box too big for the terminal says so, and every k
|
||||||
|
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: killed by type-ahead never answers the question: a paste, keys typed
|
||||||
|
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 | below 40x10 the question is hidden, so every key answers i
|
||||||
|
r3-y-ignores-shown: killed by below 40x10 the question is hidden, so every key answers i | a decide box too big for the terminal says so, and every k
|
||||||
|
r3-shown-is-min-size: killed by a decide box too big for the terminal says so, and every k
|
||||||
|
r3-shown-at-arm: killed by below 40x10 the question is hidden, so every key answers i | a decide box too big for the terminal says so, and every k
|
||||||
|
r3-no-question-whole-box: killed by every screen and overlay fits 120x36, 80x24 and the narrow
|
||||||
|
r3-keep-zero: killed by a decide box too big for the terminal says so, and every k | at every size the decide box shows each line y depends on,
|
||||||
|
r3-hidden-no-any-key: killed by every screen and overlay fits 120x36, 80x24 and the narrow | a decide box too big for the terminal says so, and every k
|
||||||
|
r3-start-unguarded: killed by runApp leaves the terminal as it found it on q, end of inp
|
||||||
|
r3-text-never-asking: killed by below 40x10 the question is hidden, so every key answers i
|
||||||
|
r3-text-always-asking: killed by below 40x10 the question is hidden, so every key answers i
|
||||||
|
r3-answer-render-unguarded: killed by a throw while answering the question goes to crash, sends
|
||||||
|
r3-answer-throw-keeps-yes: killed by a throw while answering the question goes to crash, sends
|
||||||
|
r3-wrap-code-points: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F
|
||||||
|
r3-cells-no-2329: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F
|
||||||
|
r3-cells-no-232a: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F
|
||||||
|
r3-cells-no-a960: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F
|
||||||
|
r3-cells-no-fe10: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F
|
||||||
|
r3-cells-no-fe50: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F
|
||||||
|
r3-cells-no-16fe0: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F
|
||||||
|
r3-cells-no-17000: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F
|
||||||
|
r3-cells-no-1aff0: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F
|
||||||
|
r3-cells-1aff0-short: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F
|
||||||
|
r3-cells-no-1f200: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F
|
||||||
|
survived 0
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// Round 3 mutants: round 2's 30 less overlay-cut-loses-prompt (its line is
|
||||||
|
// gone), four re-anchored to lines round 3 changed, then round 3's own,
|
||||||
|
// Dewey's B1 among them. Each edits the source, 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;", ""],
|
||||||
|
["b1-cut-keeps-end", "screens.mjs", "return keep >= 1 ? [...head, ...question.slice(0, keep), cutLine(\"question\", w - 4), ...tail] : null;", "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") && decideShown(this.state.overlay, this.cols, this.rows));', 'this.answer = (k) => (k === "y" || k === "Y" ? answer(decideShown(this.state.overlay, this.cols, this.rows)) : ["n", "N", "esc", "enter", "q"].includes(k) ? answer(false) : null);'],
|
||||||
|
["no-capital-y", "app.mjs", '(k === "y" || k === "Y") && decideShown', '(k === "y") && decideShown'],
|
||||||
|
["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, Boolean(state.overlay?.asking));", ""],
|
||||||
|
// Round 3.
|
||||||
|
["r3-y-ignores-shown", "app.mjs", '(k === "y" || k === "Y") && decideShown(this.state.overlay, this.cols, this.rows)', '(k === "y" || k === "Y")'],
|
||||||
|
["r3-shown-is-min-size", "app.mjs", "&& decideShown(this.state.overlay, this.cols, this.rows))", "&& this.cols >= 40 && this.rows >= 10)"],
|
||||||
|
["r3-shown-at-arm", "app.mjs", 'this.answer = (k) => answer((k === "y" || k === "Y") && decideShown(this.state.overlay, this.cols, this.rows));', 'const shown = decideShown(this.state.overlay, this.cols, this.rows);\n this.answer = (k) => answer((k === "y" || k === "Y") && shown);'],
|
||||||
|
["r3-no-question-whole-box", "screens.mjs", "if (q < 0 || end < 0) return null;", "if (q < 0 || end < 0) return b;"],
|
||||||
|
["r3-keep-zero", "screens.mjs", "return keep >= 1 ?", "return keep >= 0 ?"],
|
||||||
|
["r3-hidden-no-any-key", "screens.mjs", 'o.asking ? "Any key answers No." : ', ""],
|
||||||
|
["r3-start-unguarded", "app.mjs", "guard(() => void app.start());", "void app.start();"],
|
||||||
|
["r3-text-never-asking", "screens.mjs", "Boolean(state.overlay?.asking)", "false"],
|
||||||
|
["r3-text-always-asking", "screens.mjs", "const keys = asking ?", "const keys = true ?"],
|
||||||
|
["r3-answer-render-unguarded", "app.mjs", "try {\n this.render();\n } catch (e) {\n // The pump sends this to crash; the decide flow ends with No.\n resolve(false);\n throw e;\n }", "this.render();"],
|
||||||
|
["r3-answer-throw-keeps-yes", "app.mjs", "resolve(false);\n throw e;", "resolve(yes);\n throw e;"],
|
||||||
|
["r3-wrap-code-points", "screens.mjs", "for (const { segment: c } of graphemes.segment(word))", "for (const c of word)"],
|
||||||
|
["r3-cells-no-2329", "screens.mjs", " n === 0x2329 ||\n", ""],
|
||||||
|
["r3-cells-no-232a", "screens.mjs", " n === 0x232a ||\n", ""],
|
||||||
|
["r3-cells-no-a960", "screens.mjs", " (n >= 0xa960 && n <= 0xa97c) ||\n", ""],
|
||||||
|
["r3-cells-no-fe10", "screens.mjs", " (n >= 0xfe10 && n <= 0xfe19) ||\n", ""],
|
||||||
|
["r3-cells-no-fe50", "screens.mjs", " (n >= 0xfe50 && n <= 0xfe6b) ||\n", ""],
|
||||||
|
["r3-cells-no-16fe0", "screens.mjs", " (n >= 0x16fe0 && n <= 0x16ff1) ||\n", ""],
|
||||||
|
["r3-cells-no-17000", "screens.mjs", " (n >= 0x17000 && n <= 0x18d08) ||\n", ""],
|
||||||
|
["r3-cells-no-1aff0", "screens.mjs", " (n >= 0x1aff0 && n <= 0x1b2fb) ||\n", ""],
|
||||||
|
["r3-cells-1aff0-short", "screens.mjs", "(n >= 0x1aff0 && n <= 0x1b2fb)", "(n >= 0x1aff0 && n <= 0x1b2fa)"],
|
||||||
|
["r3-cells-no-1f200", "screens.mjs", " (n >= 0x1f200 && n <= 0x1f2ff) ||\n", ""],
|
||||||
|
];
|
||||||
|
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}`);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
// The smallest row count that shows a gated decision with a task, per width, for a short and a long
|
||||||
|
// question. Run: CLI=<checkout>/packages/cli node --test probe-round3.mjs
|
||||||
|
const CLI = process.env.CLI;
|
||||||
|
const { App } = await import(`${CLI}/src/app.mjs`);
|
||||||
|
const { decideShown } = await import(`${CLI}/src/screens.mjs`);
|
||||||
|
const { broker } = await import(`${CLI}/tests/helpers.mjs`);
|
||||||
|
test("probe", async (t) => {
|
||||||
|
for (const long of [false, true]) {
|
||||||
|
const bus = broker(t);
|
||||||
|
const question = long ? Array.from({ length: 90 }, (_, i) => `word${i}`).join(" ") : "Push the release?";
|
||||||
|
bus.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7", question });
|
||||||
|
const a = new App({ business: "demo", transport: (b) => bus.transport(b), cols: 120, rows: 40, refreshMs: 0, askMs: 0 });
|
||||||
|
await a.start(); await a.input("1");
|
||||||
|
const o = a.state.overlay;
|
||||||
|
const out = [];
|
||||||
|
for (const cols of [40, 50, 60, 80, 120]) {
|
||||||
|
let min = null;
|
||||||
|
for (let rows = 10; rows <= 40; rows++) if (decideShown(o, cols, rows)) { min = rows; break; }
|
||||||
|
out.push(`${cols}:${min}`);
|
||||||
|
}
|
||||||
|
console.log(long ? "long" : "short", out.join(" "));
|
||||||
|
a.quit("q");
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
short 40:29 50:26 60:22 80:20 120:20
|
||||||
|
long 40:30 50:27 60:23 80:21 120:21
|
||||||
|
✔ probe (843.280351ms)
|
||||||
|
ℹ tests 1
|
||||||
|
ℹ suites 0
|
||||||
|
ℹ pass 1
|
||||||
|
ℹ fail 0
|
||||||
|
ℹ cancelled 0
|
||||||
|
ℹ skipped 0
|
||||||
|
ℹ todo 0
|
||||||
|
ℹ duration_ms 974.31265
|
||||||
Reference in New Issue
Block a user