review(54): Dewey rounds 1 and 2 build packet (candidate afb2ae0e)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
packages/queue/README.md
|
||||
packages/queue/src/store.mjs
|
||||
packages/queue/tests/write.test.mjs
|
||||
packages/webui/README.md
|
||||
packages/webui/src/cli.mjs
|
||||
packages/webui/src/public/bus.js
|
||||
packages/webui/src/queue-read.mjs
|
||||
packages/webui/src/reads.mjs
|
||||
packages/webui/src/serve.mjs
|
||||
packages/webui/tests/reads-fixture.mjs
|
||||
packages/webui/tests/reads.test.mjs
|
||||
packages/webui/tests/shell.test.mjs
|
||||
packages/webui/tests/views.test.mjs
|
||||
@@ -0,0 +1,14 @@
|
||||
packages/queue/README.md
|
||||
packages/queue/src/store.mjs
|
||||
packages/queue/tests/write.test.mjs
|
||||
packages/webui/README.md
|
||||
packages/webui/src/cli.mjs
|
||||
packages/webui/src/public/app.js
|
||||
packages/webui/src/public/bus.js
|
||||
packages/webui/src/queue-read.mjs
|
||||
packages/webui/src/reads.mjs
|
||||
packages/webui/src/serve.mjs
|
||||
packages/webui/tests/reads-fixture.mjs
|
||||
packages/webui/tests/reads.test.mjs
|
||||
packages/webui/tests/shell.test.mjs
|
||||
packages/webui/tests/views.test.mjs
|
||||
@@ -0,0 +1,13 @@
|
||||
3db6ee2a4d4b05de6768709da0c21c6815c0d7899fb9389d659444828264275d packages/queue/README.md
|
||||
352d96c5cc02d01e5d524c1a9106985c0f0a12b7312d7de157d9bf733dead4d5 packages/queue/src/store.mjs
|
||||
b44f6f8b07cad4f35dfc92bee2c762845052b2b79f990f7cd5f590efd2b05544 packages/queue/tests/write.test.mjs
|
||||
2cf78273507099675f1f9da2edff7d66fb6bfca8c83d6372283d78a673a4eb29 packages/webui/README.md
|
||||
07d2c06523dd89f82c9ea14757852b16434f7207df57d7dfcbc58425e6e75c1d packages/webui/src/cli.mjs
|
||||
47544013a87fce98a112cbfcded0d8cce3b5743ee1cd6edbc6eb837ff13e0508 packages/webui/src/public/bus.js
|
||||
4b10c9522f7d2061853abbc98b1d8425791ebde0f9a3af2ef7f19119c23c5cb6 packages/webui/src/queue-read.mjs
|
||||
1e7505a9cb2509d97dd671399aca8bb5f68ea72320c8f6cb0648c8f0eed9db31 packages/webui/src/reads.mjs
|
||||
8121915343ad5000cf66d474d9e4a994ce6c44ff1977822a067ad5ffd2fbbf29 packages/webui/src/serve.mjs
|
||||
f1896e52c29ab4d211a946a70c0d94cecedfb4a0099f8dc37b42e10f55377bc8 packages/webui/tests/reads-fixture.mjs
|
||||
9eaad7bdf3159e68715b2f63ba3a7d03746ea87ff9ae42cb36b2205639b39f44 packages/webui/tests/reads.test.mjs
|
||||
2f079e90e769054793beeebfce7e18973f7725ef6676d13f99928d3f52acbd6a packages/webui/tests/shell.test.mjs
|
||||
b1c93811b18abd6843527f7e64a7807a3596e5aacfc7a469157360d0e3cecd64 packages/webui/tests/views.test.mjs
|
||||
@@ -0,0 +1,14 @@
|
||||
3db6ee2a4d4b05de6768709da0c21c6815c0d7899fb9389d659444828264275d packages/queue/README.md
|
||||
352d96c5cc02d01e5d524c1a9106985c0f0a12b7312d7de157d9bf733dead4d5 packages/queue/src/store.mjs
|
||||
b44f6f8b07cad4f35dfc92bee2c762845052b2b79f990f7cd5f590efd2b05544 packages/queue/tests/write.test.mjs
|
||||
17b243db3290abe9417b989dfc1f42432eb05468b3a3c4f271e35e13ea05489f packages/webui/README.md
|
||||
07d2c06523dd89f82c9ea14757852b16434f7207df57d7dfcbc58425e6e75c1d packages/webui/src/cli.mjs
|
||||
5a611e2339fa9e31c489d24460f82852ca2bc892f07265ffdc5eca03b579541b packages/webui/src/public/app.js
|
||||
0e1305c1cfb27617c8df66c77a5bf0fca822d65b9da8f75aadc27e11fdf7c176 packages/webui/src/public/bus.js
|
||||
4b10c9522f7d2061853abbc98b1d8425791ebde0f9a3af2ef7f19119c23c5cb6 packages/webui/src/queue-read.mjs
|
||||
1e7505a9cb2509d97dd671399aca8bb5f68ea72320c8f6cb0648c8f0eed9db31 packages/webui/src/reads.mjs
|
||||
8121915343ad5000cf66d474d9e4a994ce6c44ff1977822a067ad5ffd2fbbf29 packages/webui/src/serve.mjs
|
||||
f1896e52c29ab4d211a946a70c0d94cecedfb4a0099f8dc37b42e10f55377bc8 packages/webui/tests/reads-fixture.mjs
|
||||
9eaad7bdf3159e68715b2f63ba3a7d03746ea87ff9ae42cb36b2205639b39f44 packages/webui/tests/reads.test.mjs
|
||||
9f6a52c90168bfdb024111b35c589c46233db51085f6b7d2df6eacb48826ce82 packages/webui/tests/shell.test.mjs
|
||||
c3b23f81dfc5a88e1a0865dbabfc8168ba4a0749cc171bb21615e2c113b46e57 packages/webui/tests/views.test.mjs
|
||||
@@ -0,0 +1,14 @@
|
||||
3db6ee2a4d4b05de6768709da0c21c6815c0d7899fb9389d659444828264275d packages/queue/README.md
|
||||
352d96c5cc02d01e5d524c1a9106985c0f0a12b7312d7de157d9bf733dead4d5 packages/queue/src/store.mjs
|
||||
b44f6f8b07cad4f35dfc92bee2c762845052b2b79f990f7cd5f590efd2b05544 packages/queue/tests/write.test.mjs
|
||||
cd146196c98d8492a2b7f0c9bc046db0a69bd8c4539de8454563bd91cda24d9b packages/webui/README.md
|
||||
07d2c06523dd89f82c9ea14757852b16434f7207df57d7dfcbc58425e6e75c1d packages/webui/src/cli.mjs
|
||||
5a611e2339fa9e31c489d24460f82852ca2bc892f07265ffdc5eca03b579541b packages/webui/src/public/app.js
|
||||
0e1305c1cfb27617c8df66c77a5bf0fca822d65b9da8f75aadc27e11fdf7c176 packages/webui/src/public/bus.js
|
||||
4b10c9522f7d2061853abbc98b1d8425791ebde0f9a3af2ef7f19119c23c5cb6 packages/webui/src/queue-read.mjs
|
||||
1e7505a9cb2509d97dd671399aca8bb5f68ea72320c8f6cb0648c8f0eed9db31 packages/webui/src/reads.mjs
|
||||
8121915343ad5000cf66d474d9e4a994ce6c44ff1977822a067ad5ffd2fbbf29 packages/webui/src/serve.mjs
|
||||
f1896e52c29ab4d211a946a70c0d94cecedfb4a0099f8dc37b42e10f55377bc8 packages/webui/tests/reads-fixture.mjs
|
||||
9eaad7bdf3159e68715b2f63ba3a7d03746ea87ff9ae42cb36b2205639b39f44 packages/webui/tests/reads.test.mjs
|
||||
85f3bd247e6f99da717a51d71db13b65d511fffa8f0232ecbafa6c5000430c4e packages/webui/tests/shell.test.mjs
|
||||
c3b23f81dfc5a88e1a0865dbabfc8168ba4a0749cc171bb21615e2c113b46e57 packages/webui/tests/views.test.mjs
|
||||
@@ -0,0 +1,393 @@
|
||||
# Row 54 (#1543): Read-only console views: Queue, Business and Settings
|
||||
|
||||
Dewey, 2026-10-10. Brief: `docs/plans/2026-10-10_design-implementation.md`,
|
||||
section "Read-only console views: Queue, Business and Settings" (blob
|
||||
a360554c), with `docs/design/IMPLEMENTING.md` "Strings", "Boundaries" and
|
||||
"Acceptance" as for row 53. Lead decisions 81 (appearance follows the
|
||||
system by default) and 83 (52c5a1f8: `rows()` in packages/queue, path
|
||||
redaction, recorded choices stand). The brief's "No change to
|
||||
`packages/queue`" is superseded by decision 83 for exactly three paths;
|
||||
the brief file was left alone because rows 53-61 pin its blob.
|
||||
|
||||
## Base
|
||||
|
||||
Row 54 is `after: 53 done`. Row 53 landed at d64f434f (feat 1bdb6f9b,
|
||||
#1542 closed in comment 27175), and this packet is built and gated on
|
||||
d64f434f. It was first built on row 53's round 2 candidate (row 54
|
||||
manifest `8258420b`), then rebased onto round 3 (`3347bb61`); round 3's
|
||||
`round3.patch` applied cleanly. The rebase onto d64f434f changed nothing in
|
||||
row 54's files: `row54.patch` as built on round 3, applied at d64f434f,
|
||||
reproduced `3347bb61` (14 OK). Filbert's T8 test was then added (see
|
||||
"Tests added from review").
|
||||
|
||||
Row 54's candidate is 14 files, listed in `candidate-files.txt`, with
|
||||
sha256 in `candidate-manifest.sha256` (manifest sha256
|
||||
`dba2429e411ae22b1e939344247e9f3857a48e488dc7da11c578d1092288ea8a`).
|
||||
`row54.patch` is the delta over d64f434f: applied to a clean d64f434f
|
||||
export, it reproduces the manifest (14 OK, checked). `index.html` is
|
||||
unchanged; the views live in `bus.js`, and `app.js` changes only for two
|
||||
of the fixes to HEAD behaviour.
|
||||
|
||||
## What changed
|
||||
|
||||
- `packages/queue/src/store.mjs` (decision 83): `export function rows(opts)`,
|
||||
`{ rows: rowsArray(state), err: notes, code: 0 }`, the same read as `list`
|
||||
with no write. `packages/queue/README.md` names it;
|
||||
`packages/queue/tests/write.test.mjs` adds one test: the rows match `list`
|
||||
and `show` row for row, no queue, view or head file changes, the
|
||||
reader-between notes match `list`'s, and a disagreement refuses with
|
||||
exit code 2. Nothing else in packages/queue changes.
|
||||
- `packages/webui/src/queue-read.mjs` (new): a child process that calls
|
||||
`rows()` with its cwd at the checkout and prints JSON. A refusal prints
|
||||
the store's message on stderr and exits with the store's code.
|
||||
- `packages/webui/src/reads.mjs` (new): `consoleReads` returns `queue`,
|
||||
`row`, `business` and `settings`. The queue read runs the child with a
|
||||
timeout and an output cap. Exit 2 is `queue-refused` (fail closed). Any
|
||||
other failure is `read-failed`, and the view keeps its last read. Business
|
||||
is `loadBusiness` projected to names, vars on an allowlist,
|
||||
`resolveInstance`/`classify` authority per action, and credential metadata
|
||||
(service, account, role, date kind, date, state from the date alone).
|
||||
Settings is `RELEASE`, the business id and `readNotifyConfig`'s binding
|
||||
name. `redactor` maps the config dir to `<config>`, dataRoot to
|
||||
`<dataRoot>`, any other absolute path to `<path>`, a 17 to 20 digit run
|
||||
to `<id>`, and drops V8's JSON parse quote. `scrub` applies it to every
|
||||
string in a body.
|
||||
- `packages/webui/src/serve.mjs`: GET `/api/queue`, `/api/queue/<id>`,
|
||||
`/api/business`, `/api/settings`. Any other method gets 405, and a bad
|
||||
row id gets 400 `invalid-request`. Statuses: 503 for a refusal or nothing
|
||||
to read, 404 not-found, 502 an unusable answer. A non-`ReadError` answers
|
||||
with its code only. Settings answers 200 without a system config, with
|
||||
`notifier.refused`. The existing CSP, Host and Origin checks are
|
||||
unchanged and cover the new paths.
|
||||
- `packages/webui/src/cli.mjs`: builds `consoleReads` and runs the
|
||||
"Bus: not configured (…)" startup line through the redactor.
|
||||
- `packages/webui/src/public/bus.js`: views `#/queue` (state filter),
|
||||
`#/queue/<id>`, `#/business`, `#/settings`; three section links; queue
|
||||
rows in the Ctrl+K palette; `queue-refused` is a refusal; the command bar
|
||||
names the source; a Settings refusal keeps appearance (its selects mirror
|
||||
the command bar's). It also carries two of the fixes to HEAD behaviour
|
||||
below.
|
||||
- `packages/webui/src/public/app.js`: the other two fixes to HEAD behaviour,
|
||||
three lines. Nothing for the views.
|
||||
- `packages/webui/README.md`: the row 54 section, data and boundaries,
|
||||
the verify commands, the two `app.js` fixes, and the failed first read
|
||||
in the shell tests line.
|
||||
- Tests: `reads-fixture.mjs` (seeded fixture), `reads.test.mjs` (8),
|
||||
`views.test.mjs` (3), `shell.test.mjs` (seven sections in the list and
|
||||
palette, the two `app.js` fixes, and Filbert's T8: a failed first read).
|
||||
|
||||
## Acceptance against the brief
|
||||
|
||||
| Brief item | Where it is shown |
|
||||
|---|---|
|
||||
| GET JSON `/api/queue`, `/api/queue/<id>`, `/api/business`, `/api/settings` | reads.test "queue: rows, one row, ids and methods, notes"; serve routes |
|
||||
| CSP, Host and Origin checks kept | serve.mjs unchanged around the new paths; serve.test and bus-routes tests green |
|
||||
| No write, no network, no new dependency | views.test PROBE: every request is GET; `rows()` test: queue, view and head files byte-identical; no package.json change |
|
||||
| Five states on each view | views.test test 1: loading, normal, stale over kept rows, refusal, empty on `#/queue`, `#/queue/6`, `#/business`, `#/settings` |
|
||||
| Missing or invalid business file: refusal with the module's text | views.test test 2: "business file not found: `<config>/businesses/acme.json`" and "business file is not valid JSON (`<config>/…`)"; reads.test "business: missing or invalid file" |
|
||||
| Secrets as metadata only | reads.test "business: … credential metadata only"; views.test test 2 runs `clean()` on every page and every response body |
|
||||
| No value, token or path in any response or log | `clean()` asserts no tmp base, no `/srv/secret`, no `"file"`, `"env"`, `"tokenFile"`, `CODER_GITEA_SECRET_ENV`, `placeholder-not-a-token`; views.test test 3 asserts the startup log names no config path and no secret |
|
||||
| Notifier: binding names only, no channel, guild or user id | seeded fixture holds five Discord ids in the binding, the business vars and a queue note; `clean()` asserts none appears; the note shows as `token at <path> for <id>` |
|
||||
| Settings: appearance, notifications, about | views.test test 1: the settings mirror sets mode dark and localStorage records it; settings dl shows binding name, release 0.0.99, loopback address |
|
||||
| Queue rows: state, owner, reviewers, brief link | views.test test 1 checks the table cells as text |
|
||||
| Move only through `queue move`; command, not a button | views.test test 1: the row page's `.s1-cmd code` is `scripts/mosaic queue move 6 <state> --op <op> --by <seat>`; no control on any of the four views but Copy and Read again |
|
||||
| Tests as in row 53 | 400px viewport, no sideways scroll (`flat`), focus to the H1 on navigation and kept on refresh, hostile text inert (row 6's piece holds markup), palette rows as text, no script errors |
|
||||
|
||||
## Tests
|
||||
|
||||
Full webui suite 38 pass (row 53: 27, plus 8 in reads.test and 3 in
|
||||
views.test; the T8 and fix assertions sit inside existing tests). packages/queue node suite 149 pass, test-queue 27 passed.
|
||||
|
||||
## Mutations
|
||||
|
||||
Each mutant was applied to a scratch copy and run against its test
|
||||
(`~/dewey-scratch/r54/mutants.py`, outputs in `~/dewey-scratch/r54/out/`).
|
||||
17 of 17 killed (rerun 2026-10-10T19:54Z on d64f434f, with all four
|
||||
fixes to HEAD behaviour and the T8 test in the tree;
|
||||
`out/mutants-run3.txt`).
|
||||
|
||||
| Mutant | Killed by |
|
||||
|---|---|
|
||||
| startup log without the redactor | "the log names the temporary directory" |
|
||||
| `queue-refused` not a refusal | no refusal banner (timed out) |
|
||||
| a Move button on the row page | "#/queue/6: no control but Copy and Read again" |
|
||||
| credential spreads its ref (file, env) | "response carries CODER_GITEA_SECRET_ENV" |
|
||||
| Settings refusal drops appearance | "#/settings": 0 mirror selects, expected 2 |
|
||||
| module refusal keeps data | "#/queue: no kept queue row in the palette after not-configured" (2, expected 0) |
|
||||
| palette queue rows unescaped | row 6's `<b id="q-b">` appears in the palette (1, expected 0) |
|
||||
| no Discord id redaction | "response carries id 523456789012345678" |
|
||||
| no generic path redaction | "response carries /srv/secret" |
|
||||
| no H1 focus kept on refresh | "#/queue keeps heading focus" |
|
||||
| no `clearTimeout(timer)` at the top of `render()` | "the navigation cleared the due refresh" |
|
||||
| no `#conv-pick` clear when a conversation opens | "no stale session in the picker" |
|
||||
| no focus fallback at the end of `error()` | "focus after a refusal closes the conversation" |
|
||||
| the fallback without the "had focus" check | "a failure with nothing focused leaves focus alone" |
|
||||
| empty board only while the refusal state is up (Filbert's `nodata-after-refusal-only`) | "no skeleton after a failed first read"; survives without T8, checked on the pre-T8 tree |
|
||||
| `rows()` drops its notes | `rows:` test, reader-between notes |
|
||||
| queue child exits 1 on a refusal | "a refused read fails closed": `read-failed`, expected `queue-refused` |
|
||||
|
||||
## Gate
|
||||
|
||||
Worktree at d64f434f (row 53 landed), then row 54's 14 files
|
||||
(`sha256sum -c` of this manifest: 14 OK). node_modules linked from the
|
||||
checkout, TMPDIR in scratch, Docker available, suites run one after
|
||||
another, 2026-10-10T19:54:26Z to 19:59:36Z. `core.hooksPath` unset.
|
||||
Script `~/dewey-scratch/r54/gate.sh`, outputs `~/dewey-scratch/r54/gate/out/`.
|
||||
|
||||
| Suite | Result |
|
||||
|---|---|
|
||||
| `node --test 'packages/webui/tests/*.test.mjs'` | 38 pass, 0 fail |
|
||||
| node suites of business, bus, cli, control-board, conversation, discord, ledger, mosaic, queue, runs, seat, tasks | 60, 67, 66, 124, 182, 178, 78, 69, 149, 41, 19, 51 pass; 0 fail in each |
|
||||
| `node docs/design/tools/build-tokens.mjs --check` | rc 0, "tokens.css is current" |
|
||||
| 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 (decision 83) | 27 passed, 0 failed (verify and render --check skip outside the canonical root) |
|
||||
| test-release | 14 passed, 0 failed |
|
||||
| test-task | 98 passed, 0 failed |
|
||||
|
||||
The secret, id and path assertions are in the webui count: reads.test
|
||||
(8) and views.test (3). packages/queue is 149 against row 53's 148: the
|
||||
one `rows()` test.
|
||||
|
||||
## Fixes to HEAD behaviour (not row 54 features)
|
||||
|
||||
Four defects, none a row 54 feature, all in HEAD at d64f434f. The two in
|
||||
`bus.js` were there before row 53. Of the two in `app.js` (Darkwing's
|
||||
non-blocking notes on row 53 round 3, #1542 comment 27171), the picker
|
||||
was there before row 53 and the focus loss came with row 53 round 3. Sage put all four in this row, each with its own test
|
||||
(2026-10-10).
|
||||
|
||||
### Refresh timer during a navigation
|
||||
|
||||
Darkwing's row 53 round 2 note 3 (#1542 comment 27165), moved here by
|
||||
Sage. `render()` set the 10 s refresh timer after each read but never
|
||||
cleared it when a new render started. If the timer fired while a
|
||||
navigation's read was pending, the refresh bumped the render generation,
|
||||
and the navigation's render returned early without focusing its H1, so
|
||||
focus fell to `<body>`. The fix is `clearTimeout(timer)` at the top of
|
||||
`render()`.
|
||||
|
||||
views.test test 1 pins it without a real clock. The PROBE wraps
|
||||
`setTimeout` and `clearTimeout` for the 10 s `render(false)` timer only
|
||||
(app.js's 10 s board timer is left alone) and exposes `fireRefresh()`. The
|
||||
test checks that one refresh is due on `#/queue`, holds the reads, and
|
||||
navigates to `#/business`. It then asserts that `fireRefresh()` finds no
|
||||
refresh due, releases the reads, and asserts that the Business H1 has
|
||||
focus. The "no timer clear" mutant is killed at "the navigation cleared the
|
||||
due refresh". With that count assertion removed, the mutant still fails
|
||||
at "the navigation keeps heading focus", so the race reproduces every run.
|
||||
|
||||
### Heading focus on a same-page refresh
|
||||
|
||||
A refresh
|
||||
of the same page rebuilds the view, and `restore()` put focus back only on
|
||||
a link, Copy, Retry or a mirror select. If the H1 held focus, as it does
|
||||
right after navigation, focus fell to `<body>`. With the 10 s refresh this
|
||||
can fail bus-browser's "activeElement is H1" check under load. One full
|
||||
webui run here failed it at 40 s, and three runs of that file alone passed.
|
||||
Sage's row 53 round 2 gate didn't hit it. The fix: `keep()` records an H1
|
||||
with focus, and `restore()` focuses the new H1. views.test pins it, and the
|
||||
"no H1 focus kept on refresh" mutant confirms the test.
|
||||
|
||||
### Session picker after a failed catalogue read (`app.js`)
|
||||
|
||||
`openConversation()` replaced the `#conv-pick` options only after a good
|
||||
catalogue read. If that read failed, the picker kept the last seat's or
|
||||
the last read's sessions under "History unavailable". The fix empties the
|
||||
picker where the view already empties `#conv-meta` and `#conv-log`. It is
|
||||
not disabled there: a change of session starts from the picker, and
|
||||
disabling a focused control drops focus to `<body>`.
|
||||
|
||||
The shell browser test opens History (one session in the picker), makes
|
||||
the board answer 500, opens History again, waits for "History
|
||||
unavailable", and asserts the picker has no option. The "no picker clear"
|
||||
mutant is killed at "no stale session in the picker".
|
||||
|
||||
### Focus after a refusal closes a conversation (`app.js`)
|
||||
|
||||
Row 53 round 3's `error()` closes an open conversation on a refusal.
|
||||
`closeConversation()` gives focus back to the History button, and the
|
||||
empty board then removes that button, so focus fell to `<body>`. The fix:
|
||||
`error()` notes whether anything had focus when it started, and if it
|
||||
ends with focus on `<body>`, focuses `<main>`, the same fallback
|
||||
`closeConversation()` uses. Darkwing proposed the fallback without the
|
||||
first check. With nothing focused, that would move focus and scroll to
|
||||
`<main>` on every failed read, including a failed first load and each
|
||||
failing ten-second refresh.
|
||||
|
||||
The shell browser test asserts that after the refusal closes the
|
||||
conversation, focus is on `#main`. It also blurs before the 503 that
|
||||
follows the first refusal and asserts focus stays on `<body>`. Mutants:
|
||||
"no focus fallback" is killed at "focus after a refusal closes the
|
||||
conversation", and "fallback without the check" at "a failure with
|
||||
nothing focused leaves focus alone".
|
||||
|
||||
## Tests added from review
|
||||
|
||||
Filbert's row 53 round 3 verdict (#1542 comment 27174) named two
|
||||
non-blocking items. Sage put T8 in this row (2026-10-10).
|
||||
|
||||
- T8: round 3 draws the empty board after a failed first read, but no test
|
||||
asserted it, so `nodata-after-refusal-only` survived. Test only, no code
|
||||
change. The shell browser test reloads the page with the board answering
|
||||
503 and asserts the error banner ("No board data loaded."), no
|
||||
`aria-busy` skeleton, empty `#sessions` and `#waiting`, the three counts
|
||||
`–`, and "the read failed" in `#status`, the footer, `#fresh-board` and
|
||||
the tree. It then answers 200 and refreshes back to the empty board. The
|
||||
reload resets `window.errors`, so the test asserts it is empty before
|
||||
reloading.
|
||||
- N8 is Darkwing's note 2, fixed above under "Focus after a refusal closes
|
||||
a conversation".
|
||||
|
||||
## Decisions and deviations
|
||||
|
||||
- A refusal from any source (`queue-refused`, `not-configured`,
|
||||
`no-bus-host`, 403) clears every kept read, as row 53 round 2 does for the
|
||||
bus, so no kept queue row survives a refusal in the palette.
|
||||
- The brief link shows as the path and anchor, not a link to the file:
|
||||
Console serves no repository files.
|
||||
- The queue read runs as a child process, like the bus read, so a slow read
|
||||
never holds up the HTTP server. Like `list`, it takes the queue lock only
|
||||
to recheck a disagreement before reporting it. It writes nothing.
|
||||
- The business shown is `--business`, else the running bus host's. Without a
|
||||
system config, Business is `not-configured` and Settings answers with
|
||||
`notifier.refused`.
|
||||
- `app.js` carries no view code. The brief expected both rows to change it
|
||||
for the views, but they fit the bus view's router in `bus.js`. Its only
|
||||
change is the two fixes to HEAD behaviour.
|
||||
|
||||
## Not done here
|
||||
|
||||
- `cli.mjs` prints an error from its outer catch unredacted
|
||||
(`refused: …`). That catch only reaches option, board-origin and bind
|
||||
errors, none of which carry a path, so it is left as it was.
|
||||
- Out of scope per the brief: Ledger, runs and releases, bus backend reads.
|
||||
|
||||
## Boundaries kept
|
||||
|
||||
No change under `packages/business`, `packages/cli`, `packages/bus`,
|
||||
`packages/tasks`, `scripts/` or `public/shared/`. packages/queue changes only
|
||||
in decision 83's three paths. No new dependency. No live tracker request,
|
||||
no Gitea write other than this row's posts, no push.
|
||||
|
||||
## Round 2 (answers #1543 comments 27185 and 27186)
|
||||
|
||||
Filbert (comment 27185, rev 376) and Darkwing (comment 27186, rev 377)
|
||||
both asked for changes. Sage combined them into one round 2 list: B1, R1,
|
||||
Arbiters and the queue-read import leak are required; D29 and the
|
||||
views.test :167 timeout are optional, with a finding reported either way.
|
||||
Same base, d64f434f. Candidate manifest `candidate-manifest.sha256`
|
||||
(sha256 afb2ae0e…) covers the same 14 files. `row54.patch` is against
|
||||
d64f434f; applied to a `git archive` of d64f434f it reproduces all 14
|
||||
files (14 OK). `row54-r1-to-r2.patch` is the change from round 1 (7
|
||||
files). Round 1's packet is kept as `candidate-manifest-r1.sha256` and
|
||||
`row54-r1.patch`.
|
||||
|
||||
| Item | Fix | Test |
|
||||
|---|---|---|
|
||||
| B1 (Filbert, blocker) | `afterRef` renders an `{id, when}` entry as a link to the row and its condition, "6 settled", as QUEUE.md writes it | Seeded views test: `#/queue/9` After is `<a href="#/queue/6">6</a> settled`; row 11 carries `{id: 9, when: 'done'}`, the shape of real row 54's `{id: 53, when: 'done'}`, and reads `<a href="#/queue/9">9</a> done`. Both go through the real store. Reads test: `/api/queue/11` keeps `after` as stored |
|
||||
| R1 (Filbert), with Sage's `file:/x` note | The path rule also matches `~/`, and a path after `:` or `<`. A `:` followed by `//` and a host is a URL and keeps its path; `file:///x` is still a path | Redactor test: row 35's own phrase, row 8's `` `~/.mosaic` ``, `key=~/k`, `file:/x`, `file:///srv/y`, `</srv/z.token>`; three URLs unchanged; `a~/b`, a bare `~` and a relative path unchanged. The seeded note is shaped like row 35's and is asserted redacted in the reads and views tests; `clean()` now also refuses `~/` and `secrets/mosaic-stack` in any body or page |
|
||||
| Arbiters (Darkwing, required) | A business file's `arbiters` is an object, `{delivery: 'pm', technical: 'cto'}`; `names()` took only arrays, so the view always said "none". `arbiterRefs` renders each pair as "instance (kind)"; an array still goes through `names()` | Seeded views test, through `loadBusiness`: Arbiters reads `pm (delivery), cto (technical)`. Stub views test: `pm (delivery)` |
|
||||
| Import leak (Darkwing, required) | Both, since both were cheap. `queue-read.mjs` imports the store and its error class with `await import()` inside its `try`, so a store that fails to load prints "the queue read failed" and exits 1. The reader forwards the child's stderr only on exit 2 (the store's refusal, `queue-refused`); any other exit is the fixed `read-failed` "the queue read failed (exit N)" | Reads test, in a `queueRepo` copy: store.mjs with a syntax error, then store.mjs missing. Each asserts the child's exact status, stdout and stderr (`1`, empty, `the queue read failed\n`), then `/api/queue` gives 503 with that fixed body and `clean()` finds no base or repo path. A fake child that prints a `file:///srv/q/x.mjs` stack and exits 3 gives the fixed exit-3 message |
|
||||
| D29 (Darkwing, optional) | none needed: the code keeps the mirror select's focus | Five-state views test: focus the mirror select on `#/settings`, mark the H1, fire the 10 s refresh, wait for the redraw, assert focus is on the mirror select |
|
||||
| D20, D21 (Darkwing, optional) | none needed | Reads test, with the reader pointed at a fake child: one that never exits, with `timeoutMs: 300`, gives "the queue read did not finish in time"; one that prints 64 KiB, with `maxBytes: 1024`, gives "the queue read printed too much" |
|
||||
| :167 timeout (Sage item 5) | Test only. Cause below | Five-state views test |
|
||||
| N1 (Filbert) | `settings()` refuses `not-configured` in Console's words when there is no data root, before `readNotifyConfig` | Reads settings test: `--business acme` with no system config gives that notifier refusal, not Node's TypeError |
|
||||
| N2 (Filbert) | Required reads "yes", with "since …" only for a real date | Seeded views test on `#/queue/9` and `#/queue/11` |
|
||||
| N3 (Filbert), Darkwing note 4 | `const GROUPS = [[` | none needed |
|
||||
| T1 (Filbert) | none needed: the scrub was already there | Fixture: the reviewer's `model` var (the one allowlisted free-text var) holds `/srv/secret/models/local.gguf`; the business test asserts `{ model: '<path>' }` |
|
||||
|
||||
### The :167 timeout
|
||||
|
||||
The five-state test's server had reads but no bus. Opening the palette
|
||||
reads `/api/bus/inbox` and `/api/bus/tasks`, and with no bus those
|
||||
answer 503 `not-configured`. That is a refusal, so the row 53 rule,
|
||||
"a refusal forgets everything", cleared the last reads, `api:queue`
|
||||
included, and the palette rebuilt without the queue rows. The check
|
||||
`palette includes #/queue/7` passed only when its first poll ran before
|
||||
those two reads returned. Under load they returned first and the wait
|
||||
timed out. It was a race, not a timeout set tighter than the work it
|
||||
waits on, so a wider timeout would not have fixed it.
|
||||
|
||||
The fix is in the test. Its server now has a stub bus that answers
|
||||
empty lists, and the check first waits for the palette's own inbox and
|
||||
tasks reads to answer, then asserts the queue rows are still listed. The
|
||||
`views-no-bus` mutant drops the stub bus. It is killed at that check on
|
||||
every run, which confirms the cause.
|
||||
|
||||
Product follow-up, not changed in this row: on a Console with reads and
|
||||
no bus, each palette open forgets the queue rows. That follows the row 53
|
||||
refusal rule as written. Whether `not-configured` on a bus route should
|
||||
forget the reads is a question for row 53's owner and for Sage.
|
||||
|
||||
### A second flake, found while testing D29
|
||||
|
||||
The new D29 check failed 3 of 8 times under mutant load: focus was on the
|
||||
H1, not the mirror select. An instrumented run traced the late focus to
|
||||
the palette dialog's `close` handler (`bus.js`, `cmdk-dialog`'s `close`
|
||||
listener calls `pkBack.focus()`). Esc closes the dialog at once, but
|
||||
the `close` event is a later task, and under load it ran after the test
|
||||
had focused the mirror select. The test's `closePalette()` now waits for
|
||||
that event before going on. Both Esc sites use it. After the change: 16
|
||||
of 16 parallel runs of the five-state test passed, and the webui suite
|
||||
was 39/0 while the mutant set ran beside it.
|
||||
|
||||
### Not done, as follow-ups
|
||||
|
||||
- D22, host state without `.live`: no test. It needs a live bus host's
|
||||
state file in a fixture; Darkwing's P5c shows the read works.
|
||||
- `cli.mjs`'s outer catch prints its error unredacted. Its inputs carry no
|
||||
path today (Darkwing note 3 agrees).
|
||||
- Darkwing's P2 redactor gaps, run through the round 2 redactor:
|
||||
`~/secret/x.token` is `<path>`, `config:/home/u/x.token` is
|
||||
`config:<path>`, and `path</home/u/x>` is `path<<path>>`, since `<` is
|
||||
now a path prefix. Still unchanged:
|
||||
`./secret/x.token`, a Windows path, `id_123456789012345678`, and digit
|
||||
runs of 16 or 21. The README states the rule.
|
||||
|
||||
The queue store refuses `<` in a note, so a note can't carry the `<…>` form;
|
||||
that case is in the redactor unit test only. Known gap, stated in the
|
||||
README: a path stops at the first space, so a path containing a space is
|
||||
redacted only up to that space. A bare `~`, `~user/` and relative paths
|
||||
are not paths to the redactor.
|
||||
|
||||
### Mutations, round 2
|
||||
|
||||
`mutants.py`, 33 of 33 killed, each site matched once
|
||||
(`out/mutants-r2-full.txt` in scratch), run after the `closePalette()`
|
||||
change. The 17 from round 1 still apply (the `no-path-redaction` site
|
||||
updated to the new rule), and 16 are new:
|
||||
|
||||
| Mutant | Change | Killed by |
|
||||
|---|---|---|
|
||||
| qlink-all-text | After maps through `qLink` again (round 1's code) | seeded views, `#/queue/9` After |
|
||||
| after-no-when | the condition dropped | seeded views |
|
||||
| required-bare-true | Required shows the boolean again | seeded views |
|
||||
| path-no-tilde | `~/` not a path | redactor test |
|
||||
| path-no-colon | `:` not a path prefix | redactor test |
|
||||
| path-no-lt | `<` not a path prefix | redactor test |
|
||||
| path-eats-urls | the URL guard dropped | redactor test |
|
||||
| business-noscrub | `/api/business` not scrubbed (Filbert's) | business test, `/srv/secret` |
|
||||
| settings-null-dataroot | the N1 refusal dropped | settings test |
|
||||
| arbiters-names | Arbiters through `names()` again (Darkwing's) | seeded views, Arbiters |
|
||||
| static-store-import | static imports of the store and its error class | reads test, broken store, child's stderr |
|
||||
| forward-any-exit | any exit forwards the child's stderr | reads test, broken store |
|
||||
| no-mirror-keep | `keep()` without the mirror (Darkwing's D29) | five-state views, mirror focus after the refresh |
|
||||
| no-output-cap | the output cap dropped (Darkwing's D20) | reads test, fake child, 64 KiB |
|
||||
| timeout-x1000 | the timeout times 1000 (Darkwing's D21) | reads test, fake child, by the test's 60 s timeout |
|
||||
| views-no-bus | the five-state test's stub bus dropped | five-state views, palette check |
|
||||
|
||||
### Gate, round 2
|
||||
|
||||
`gate.sh` on a worktree at d64f434f plus the 14 files, manifest 14 OK,
|
||||
Docker 29.7.2 up, 2026-10-10T21:10:20Z to 21:15:34Z: webui 39/0;
|
||||
business 60/0, bus 67/0, cli 66/0, control-board 124/0, conversation
|
||||
182/0, discord 178/0, ledger 78/0, mosaic 69/0, queue 149/0, runs 41/0,
|
||||
seat 19/0, tasks 51/0; build-tokens --check current; test-auth 15/0,
|
||||
test-conductor 17/0, test-config 24/0, test-discord 66/0,
|
||||
test-extension-package 18/0, test-foundation 44/0, test-queue 27/0,
|
||||
test-release 14/0, test-task 98/0. `core.hooksPath` unset.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,437 @@
|
||||
--- a/packages/webui/README.md
|
||||
+++ b/packages/webui/README.md
|
||||
@@ -157,7 +157,8 @@
|
||||
with a Copy button and has no button that moves a row. The brief is shown
|
||||
as its path and anchor, because Console serves no repository files.
|
||||
- `#/business` shows the business named by `--business`, else the running
|
||||
- bus host's business: the human, arbiters, projects, launch rules, the vars
|
||||
+ bus host's business: the human, the arbiters as instance and kind ("pm
|
||||
+ (delivery), cto (technical)"), projects, launch rules, the vars
|
||||
on an allowlist, each role instance with its holder and harness, the
|
||||
authority of each instance for each action as the business package
|
||||
classifies it (`resolveInstance`, `classify`), and credentials as
|
||||
@@ -173,9 +174,14 @@
|
||||
response, page or log carries one, a file path or a Discord channel, guild or
|
||||
user id. The server redacts every message and string field before it answers
|
||||
(`redactor` in `src/reads.mjs`): the config directory becomes `<config>`, the
|
||||
-data root `<dataRoot>`, any other absolute path `<path>`, a 17 to 20 digit run
|
||||
-`<id>`, and a JSON parse error's quote of the file is dropped. The startup
|
||||
-log goes through the same redactor.
|
||||
+data root `<dataRoot>`, any other absolute or `~/` path `<path>`, a 17 to 20
|
||||
+digit run `<id>`, and a JSON parse error's quote of the file is dropped. The
|
||||
+startup log goes through the same redactor. A path starts at the beginning
|
||||
+of the text or after a space, an opening bracket, a quote, `=`, `,`, `<` or
|
||||
+`:`, so `file:/x` and `file:///x` are redacted and `https://host/x` is not.
|
||||
+Known gap: a path stops at the first space, so a path containing a space is
|
||||
+redacted only up to that space and the rest shows. A bare `~` or `~user/`
|
||||
+is not treated as a path, and neither is a relative path.
|
||||
|
||||
## Data and boundaries
|
||||
|
||||
@@ -211,7 +217,12 @@
|
||||
400. The queue is read in a child process (`src/queue-read.mjs`) through
|
||||
`rows()` from `packages/queue/src/store.mjs` (lead decision 83): the same
|
||||
read as `queue list`, with no write and no network. Like `list`, it takes
|
||||
-the queue lock only to recheck a disagreement before reporting it. The business
|
||||
+the queue lock only to recheck a disagreement before reporting it. The child
|
||||
+imports the store inside its `try`, so a store that fails to load reports
|
||||
+"the queue read failed". Only the store's exit 2 (invalid data or refused)
|
||||
+forwards the child's text, as `queue-refused`; any other exit is the fixed
|
||||
+`read-failed` message "the queue read failed (exit N)", because that stderr
|
||||
+may be Node's own, with a `file://` path and a stack. The business
|
||||
read uses `packages/business` in process. No read writes, calls out or adds a
|
||||
dependency. Answers carry `at`; failures are `{ error, message }` with 503
|
||||
for `not-configured`, `no-bus-host`, `queue-refused` and `read-failed`, 404
|
||||
@@ -282,16 +293,26 @@
|
||||
palette's own reads and from the board page, and that palette labels are text.
|
||||
|
||||
Row 54's tests share the seeded fixture in `reads-fixture.mjs`: a scratch
|
||||
-queue whose row note names a token path and an id, and a business, notifier
|
||||
-config and Discord binding that hold token files, an environment variable
|
||||
-name, and guild, channel, bot and user ids. `reads.test.mjs` checks each
|
||||
+queue whose row note names an id and token paths shaped like real notes
|
||||
+(absolute, `~/` and after `file:`; the store refuses `<` in a note, so the
|
||||
+`<…>` case is in the redactor test), and a business,
|
||||
+notifier config and Discord binding that hold token files, an environment
|
||||
+variable name, an agent `model` var holding a path, and guild, channel, bot
|
||||
+and user ids. Row 9's After entry has the `{id, when}` shape the real queue
|
||||
+stores. `reads.test.mjs` checks each
|
||||
route's body and status, the redactor, the credential state, methods and
|
||||
-Origin, and that no body carries any seeded value or the temporary directory.
|
||||
+Origin, and that no body carries any seeded value or the temporary directory;
|
||||
+that a `store.mjs` with a syntax error, or none, gives the fixed message with
|
||||
+no path; and that the child's timeout, output cap and non-2 exits hold, over a
|
||||
+fake child script.
|
||||
`views.test.mjs` walks Queue, a queue row, Business and Settings through the
|
||||
five states over a stub read, with each refusal code the view can meet, at
|
||||
400px; checks that a refusal leaves no queue row in the palette, that no view
|
||||
has a control but Copy and Read again, and that the row page shows the
|
||||
-`queue move` command; then, over the seeded fixture, that neither the page nor
|
||||
+`queue move` command, that the 10 s refresh keeps focus on a Settings mirror
|
||||
+select, and that the palette keeps its queue rows after its own inbox and tasks
|
||||
+reads answer; then, over the seeded fixture, that the Arbiters line reads the
|
||||
+business file's object, that neither the page nor
|
||||
any response it read carries a seeded value, that a missing or invalid
|
||||
business file shows the module's text, and that the startup log names no
|
||||
config path.
|
||||
--- a/packages/webui/src/public/bus.js
|
||||
+++ b/packages/webui/src/public/bus.js
|
||||
@@ -345,7 +345,11 @@
|
||||
const QSTATE = { done: 'ok', blocked: 'danger', 'waiting-on-jason': 'attn', 'in-review': 'attn', 'in-progress': 'attn' };
|
||||
const qState = s => badge(s, QSTATE[s] || 'muted');
|
||||
const qLink = id => Number.isInteger(id) ? `<a href="#/queue/${esc(id)}">${esc(id)}</a>` : txt(id);
|
||||
+ // An after entry is {id, when}, as QUEUE.md writes it: "53 done".
|
||||
+ const afterRef = a => a && typeof a === 'object' ? `${qLink(a.id)}${a.when ? ` ${txt(a.when)}` : ''}` : qLink(a);
|
||||
const names = list => Array.isArray(list) && list.length ? list.map(txt).join(', ') : none;
|
||||
+ // The business file's arbiters are {delivery, technical}, each a role instance: "pm (delivery)".
|
||||
+ const arbiterRefs = a => a && typeof a === 'object' && !Array.isArray(a) ? Object.entries(a).map(([kind, who]) => `${txt(who)} (${txt(kind)})`).join(', ') || none : names(a);
|
||||
const issueRefs = list => Array.isArray(list) && list.length ? list.map(n => `#${txt(n)}`).join(', ') : none;
|
||||
// Console serves no repository files, so the brief is its path and anchor, as the row names it.
|
||||
const briefRef = b => b?.path ? `<code>${txt(b.path)}${b.anchor ? `#${txt(b.anchor)}` : ''}</code>` : none;
|
||||
@@ -377,7 +381,7 @@
|
||||
const cmd = `scripts/mosaic queue move ${id} <state> --op <op> --by <seat>`;
|
||||
return `${head(`Row ${esc(id)}: ${txt(r.piece)}`, `${qState(r.state)}${r.previousState ? ` <span class="small muted">was ${txt(r.previousState)}</span>` : ''} ${readLine()}`)}
|
||||
${storeNotes(doc.notes)}
|
||||
- <section class="panel" aria-labelledby="q-row"><h2 id="q-row">Row</h2><dl class="kv"><dt>Owner</dt><dd>${txt(r.owner) || none}</dd><dt>Reviewers</dt><dd>${names(r.reviewers)}</dd><dt>Gate</dt><dd>${txt(r.gateOwner) || none}</dd><dt>Issues</dt><dd>${issueRefs(r.issues)}</dd><dt>Closes</dt><dd>${issueRefs(r.closes)}</dd><dt>Brief</dt><dd>${briefRef(r.brief)}${r.brief?.blob ? ` <span class="source">blob ${txt(String(r.brief.blob).slice(0, 12))}</span>` : ''}</dd><dt>After</dt><dd>${Array.isArray(r.after) && r.after.length ? r.after.map(qLink).join(', ') : none}</dd><dt>Claim</dt><dd>${r.claim ? `${txt(r.claim.seat)} <span class="source">${txt(r.claim.op)}</span>` : none}</dd>${r.required ? `<dt>Required</dt><dd>${txt(r.required)}${r.requiredSince ? ` since ${when(r.requiredSince)}` : ''}</dd>` : ''}${r.blockedReason ? `<dt>Blocked</dt><dd>${txt(r.blockedReason)}</dd>` : ''}${r.note ? `<dt>Note</dt><dd class="s1-q">${txt(r.note)}</dd>` : ''}<dt>Created</dt><dd>${when(r.createdAt)}</dd><dt>Updated</dt><dd>${when(r.updatedAt)}${r.updatedBy ? ` by ${txt(r.updatedBy)}` : ''}</dd></dl></section>
|
||||
+ <section class="panel" aria-labelledby="q-row"><h2 id="q-row">Row</h2><dl class="kv"><dt>Owner</dt><dd>${txt(r.owner) || none}</dd><dt>Reviewers</dt><dd>${names(r.reviewers)}</dd><dt>Gate</dt><dd>${txt(r.gateOwner) || none}</dd><dt>Issues</dt><dd>${issueRefs(r.issues)}</dd><dt>Closes</dt><dd>${issueRefs(r.closes)}</dd><dt>Brief</dt><dd>${briefRef(r.brief)}${r.brief?.blob ? ` <span class="source">blob ${txt(String(r.brief.blob).slice(0, 12))}</span>` : ''}</dd><dt>After</dt><dd>${Array.isArray(r.after) && r.after.length ? r.after.map(afterRef).join(', ') : none}</dd><dt>Claim</dt><dd>${r.claim ? `${txt(r.claim.seat)} <span class="source">${txt(r.claim.op)}</span>` : none}</dd>${r.required ? `<dt>Required</dt><dd>yes${Number.isFinite(Date.parse(r.requiredSince)) ? ` since ${when(r.requiredSince)}` : ''}</dd>` : ''}${r.blockedReason ? `<dt>Blocked</dt><dd>${txt(r.blockedReason)}</dd>` : ''}${r.note ? `<dt>Note</dt><dd class="s1-q">${txt(r.note)}</dd>` : ''}<dt>Created</dt><dd>${when(r.createdAt)}</dd><dt>Updated</dt><dd>${when(r.updatedAt)}${r.updatedBy ? ` by ${txt(r.updatedBy)}` : ''}</dd></dl></section>
|
||||
<section aria-labelledby="q-review"><h2 id="q-review">Review <span class="count">${rounds.length}</span></h2>${rounds.length ? `<ol class="s1-plain">${rounds.map(round).join('')}</ol>` : '<p class="muted">No review round yet.</p>'}</section>
|
||||
<section aria-labelledby="q-move"><h2 id="q-move">Moving this row</h2><p class="small muted">${moveHelp} Copy only puts the command on your clipboard; fill in the state, op and seat before you run it.</p>
|
||||
<div class="s1-cmd"><code>${txt(cmd)}</code><button type="button" class="btn" data-copy="${esc(cmd)}" aria-label="Copy the move command for row ${esc(id)}">Copy</button></div><p class="small muted" id="copy-status"></p>
|
||||
@@ -401,7 +405,7 @@
|
||||
const refused = instances.filter(i => i.refused).map(i => `<li>${txt(i.instance)}: ${txt(i.refused)}</li>`).join('');
|
||||
const credRows = creds.map(c => `<tr><td>${txt(c.service)}</td><td>${txt(c.account) || '<span class="muted">not named in the business file</span>'}</td><td>${txt(c.role)}</td><td>${txt(c.dateKind === 'expires' ? 'expires' : 'rotate by')} <time datetime="${esc(c.date)}">${txt(c.date)}</time></td><td>${badge(c.state, CRED[c.state] || 'muted')}</td></tr>`).join('');
|
||||
return `${head(`Business ${txt(b.id)}`, `${readLine()} The business file is edited where it lives; Console only reads it.`)}
|
||||
- <section class="panel" aria-labelledby="bz-about"><h2 id="bz-about">Business</h2><dl class="kv"><dt>Human</dt><dd>${txt(b.human) || none}</dd><dt>Arbiters</dt><dd>${names(b.arbiters)}</dd><dt>Projects</dt><dd>${names(b.projects)}</dd><dt>Launcher</dt><dd>${txt(launch.by) || none}</dd><dt>May launch</dt><dd>${names(launch.instances)}</dd><dt>At most</dt><dd>${Object.entries(launch.max || {}).map(([f, c]) => `${txt(c)} ${txt(f)}`).join(', ') || none}</dd></dl>
|
||||
+ <section class="panel" aria-labelledby="bz-about"><h2 id="bz-about">Business</h2><dl class="kv"><dt>Human</dt><dd>${txt(b.human) || none}</dd><dt>Arbiters</dt><dd>${arbiterRefs(b.arbiters)}</dd><dt>Projects</dt><dd>${names(b.projects)}</dd><dt>Launcher</dt><dd>${txt(launch.by) || none}</dd><dt>May launch</dt><dd>${names(launch.instances)}</dd><dt>At most</dt><dd>${Object.entries(launch.max || {}).map(([f, c]) => `${txt(c)} ${txt(f)}`).join(', ') || none}</dd></dl>
|
||||
<h3>Vars</h3>${kvRows(b.vars, 'No vars Console shows are set.')}</section>
|
||||
<section aria-labelledby="bz-roles"><h2 id="bz-roles">Role instances <span class="count">${instances.length}</span></h2>${instances.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Role instances, scroll for all columns"><table class="s1-table s1-roles"><thead><tr>${['Instance', 'Definition', 'Holder', 'Harness'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${roleRows}</tbody></table></div>` : empty('This business has no role instances.', 'An instance shows here when the business file names one under roles.')}</section>
|
||||
${instances.length ? `<section aria-labelledby="bz-auth"><h2 id="bz-auth">Authority</h2><p class="small muted">Within the role, cross-role (needs the other role), or gated (needs the human), as the business package classifies each action.</p>${matrix}${refused ? `<ul class="s1-plain">${refused}</ul>` : ''}</section>` : ''}
|
||||
@@ -425,7 +429,7 @@
|
||||
}
|
||||
|
||||
// Trail
|
||||
- const GROUPS =[['all', 'All'], ['request', 'Requests'], ['decision', 'Decisions'], ['launch', 'Launches'], ['task', 'Task changes'], ['review', 'Review']];
|
||||
+ const GROUPS = [['all', 'All'], ['request', 'Requests'], ['decision', 'Decisions'], ['launch', 'Launches'], ['task', 'Task changes'], ['review', 'Review']];
|
||||
function trailList(rows, filter) {
|
||||
const shown = filter === 'all' ? rows : rows.filter(r => r.group === filter);
|
||||
if (!shown.length) return empty('Nothing of this kind in the trail.', 'Every step the stack takes on this subject writes a trail row.');
|
||||
--- a/packages/webui/src/queue-read.mjs
|
||||
+++ b/packages/webui/src/queue-read.mjs
|
||||
@@ -3,14 +3,19 @@
|
||||
// calls rows() in packages/queue (lead decision 83): the same unlocked read
|
||||
// as `queue list`, which writes nothing. JSON on stdout; a refusal's message
|
||||
// on stderr with the store's exit code.
|
||||
+//
|
||||
+// The store is imported inside the try, so a store.mjs that fails to load
|
||||
+// (a half-written file in a shared checkout) prints "the queue read failed",
|
||||
+// never Node's own message, which names the file:// path and a stack.
|
||||
|
||||
-import { rows } from '../../queue/src/store.mjs';
|
||||
-import { QueueError } from '../../queue/src/errors.mjs';
|
||||
-
|
||||
+let QueueError = null;
|
||||
try {
|
||||
+ ({ QueueError } = await import('../../queue/src/errors.mjs'));
|
||||
+ const { rows } = await import('../../queue/src/store.mjs');
|
||||
const r = rows({});
|
||||
process.stdout.write(`${JSON.stringify({ rows: r.rows, notes: r.err })}\n`);
|
||||
} catch (err) {
|
||||
- process.stderr.write(`${err instanceof QueueError ? err.message : 'the queue read failed'}\n`);
|
||||
- process.exitCode = err instanceof QueueError && Number.isInteger(err.code) && err.code > 0 ? err.code : 1;
|
||||
+ const refused = QueueError !== null && err instanceof QueueError;
|
||||
+ process.stderr.write(`${refused ? err.message : 'the queue read failed'}\n`);
|
||||
+ process.exitCode = refused && Number.isInteger(err.code) && err.code > 0 ? err.code : 1;
|
||||
}
|
||||
--- a/packages/webui/src/reads.mjs
|
||||
+++ b/packages/webui/src/reads.mjs
|
||||
@@ -10,7 +10,11 @@
|
||||
//
|
||||
// Every string a response carries passes through the redactor: the config
|
||||
// directory and dataRoot become <config> and <dataRoot>, any other absolute
|
||||
-// path <path>, and a 17 to 20 digit run (a Discord id) <id>.
|
||||
+// or ~/ path <path>, and a 17 to 20 digit run (a Discord id) <id>. A path
|
||||
+// starts at the beginning, a space, an opening bracket, a quote, `=`, `,`,
|
||||
+// `<` or `:`, so `file:/x` and `file:///x` are paths and `https://host/x`
|
||||
+// is not. A path stops at a space, so one with a space in it is redacted
|
||||
+// only up to the space.
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -41,7 +45,7 @@
|
||||
return text => {
|
||||
let s = unquote(String(text));
|
||||
for (const [re, label] of known) s = s.replace(re, label);
|
||||
- return s.replace(/(^|[\s('"`=,[{])\/[^\s'"`),;\]}]+/g, '$1<path>').replace(/(?<![\w.-])\d{17,20}(?![\w.-])/g, '<id>');
|
||||
+ return s.replace(/(^|[\s('"`=,[{<]|:(?!\/\/[^/]))~?\/[^\s'"`),;\]}>]+/g, '$1<path>').replace(/(?<![\w.-])\d{17,20}(?![\w.-])/g, '<id>');
|
||||
};
|
||||
}
|
||||
// Every string in a value, deep. Keys are left alone: they are fixed names or ids.
|
||||
@@ -75,10 +79,12 @@
|
||||
child.on('close', status => {
|
||||
const said = Buffer.concat(err).toString('utf8').trim().slice(0, 2000);
|
||||
if (status === null) return done(reject, new ReadError('read-failed', 'the queue read did not finish'));
|
||||
- // 2 is the store's "invalid data or refused": fail closed. Anything
|
||||
- // else failed to run; the view keeps its last read.
|
||||
+ // 2 is the store's "invalid data or refused": fail closed with the
|
||||
+ // store's text. Anything else failed to run, and its stderr may be
|
||||
+ // Node's own (a path and a stack), so it is not shown; the view keeps
|
||||
+ // its last read.
|
||||
if (status === 2) return done(reject, new ReadError('queue-refused', said || 'the queue refused the read'));
|
||||
- if (status !== 0) return done(reject, new ReadError('read-failed', said || `the queue read exited ${status}`));
|
||||
+ if (status !== 0) return done(reject, new ReadError('read-failed', `the queue read failed (exit ${status})`));
|
||||
try {
|
||||
const doc = JSON.parse(Buffer.concat(out).toString('utf8'));
|
||||
if (!Array.isArray(doc?.rows) || !Array.isArray(doc?.notes)) throw new Error('shape');
|
||||
@@ -193,6 +199,7 @@
|
||||
let id = null, notifier;
|
||||
try {
|
||||
id = businessId({ business, dataRoot });
|
||||
+ if (!dataRoot) throw new ReadError('not-configured', 'the Console has no system config, so it knows no notifier config');
|
||||
notifier = { binding: readNotifyConfig(dataRoot, id) };
|
||||
} catch (err) {
|
||||
notifier = { refused: err instanceof ReadError ? err.code : 'not-configured', message: err.message };
|
||||
--- a/packages/webui/tests/reads-fixture.mjs
|
||||
+++ b/packages/webui/tests/reads-fixture.mjs
|
||||
@@ -1,9 +1,11 @@
|
||||
// Row 54 (#1543): the seeded fixture the reads and views tests share. A
|
||||
// business holds a Discord user id, token files, an environment variable
|
||||
// name and bot ids; a data root holds a notifier config and a Discord
|
||||
-// binding with guild, channel and user ids and a token file. A queue row's
|
||||
-// note names a token path and an id. None may reach a response, the page or
|
||||
-// a log, nor may the temporary directory.
|
||||
+// binding with guild, channel and user ids and a token file; an agent's
|
||||
+// model var holds a path. A queue row's note names an id and token paths in
|
||||
+// the shapes real notes use (row 35: "~/.config/mosaic-dev/secrets/…").
|
||||
+// None may reach a response, the page or a log, nor may the temporary
|
||||
+// directory. Rows 9 and 11 have After entries in the stored {id, when} shape.
|
||||
import assert from 'node:assert/strict';
|
||||
import { cpSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
@@ -15,10 +17,13 @@
|
||||
export const SNOWFLAKES = ['123456789012345678', '223456789012345678', '323456789012345678', '423456789012345678', '523456789012345678'];
|
||||
export const day = n => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
|
||||
|
||||
+export const NOTE = `token at /srv/secret/x.token for ${SNOWFLAKES[4]}; tokens 0600 under ~/.config/mosaic-dev/secrets/mosaic-stack (receipt file:/srv/secret/r.json)`;
|
||||
+export const NOTE_SHOWN = 'token at <path> for <id>; tokens 0600 under <path> (receipt file:<path>)';
|
||||
+
|
||||
// A canonical scratch checkout with a committed queue, the reader script
|
||||
// beside a copy of packages/queue/src, and a RELEASE file.
|
||||
export function queueRepo(t) {
|
||||
- const rows = MAP_ROWS.map(r => r.id === 6 ? { ...r, note: `token at /srv/secret/x.token for ${SNOWFLAKES[4]}` } : r);
|
||||
+ const rows = MAP_ROWS.map(r => r.id === 6 ? { ...r, note: NOTE } : r.id === 11 ? { ...r, after: [{ id: 9, when: 'done' }] } : r);
|
||||
const repo = scratchRepo(t, { map: mapText(rows) });
|
||||
genesisCommitted(repo);
|
||||
mkdirSync(join(repo.root, 'packages/webui/src'), { recursive: true });
|
||||
@@ -37,6 +42,7 @@
|
||||
doc.roles.coder.credentials.gitea = { env: 'CODER_GITEA_SECRET_ENV', rotateBy: day(-1) };
|
||||
doc.roles.coder.credentials.vikunja.expires = day(3);
|
||||
doc.roles.reviewer.credentials.vikunja.expires = day(-2);
|
||||
+ doc.roles.reviewer.vars = { model: '/srv/secret/models/local.gguf' };
|
||||
if (business) writeJson(join(configDir, 'businesses', 'acme.json'), doc);
|
||||
if (notify) writeJson(join(dataRoot, 'notify', 'acme', 'notify.json'), { notifyVersion: 1, binding: 'jason-dm' });
|
||||
writeJson(join(dataRoot, 'discord', 'jason-dm.json'), {
|
||||
@@ -51,5 +57,5 @@
|
||||
export function clean(text, ...bases) {
|
||||
for (const b of bases) assert.equal(text.includes(b), false, `response names ${b}`);
|
||||
for (const s of SNOWFLAKES) assert.equal(text.includes(s), false, `response carries id ${s}`);
|
||||
- for (const s of ['placeholder-not-a-token', 'CODER_GITEA_SECRET_ENV', '"file"', '"env"', '"botId"', 'discordUserId', '"tokenFile"', '"guildId"', '"channels"', '/srv/secret']) assert.equal(text.includes(s), false, `response carries ${s}`);
|
||||
+ for (const s of ['placeholder-not-a-token', 'CODER_GITEA_SECRET_ENV', '"file"', '"env"', '"botId"', 'discordUserId', '"tokenFile"', '"guildId"', '"channels"', '/srv/secret', '~/', 'secrets/mosaic-stack']) assert.equal(text.includes(s), false, `response carries ${s}`);
|
||||
}
|
||||
--- a/packages/webui/tests/reads.test.mjs
|
||||
+++ b/packages/webui/tests/reads.test.mjs
|
||||
@@ -3,11 +3,13 @@
|
||||
// the temporary directory.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
+import { spawnSync } from 'node:child_process';
|
||||
+import { rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
-import { consoleReads, credentialState, redactor } from '../src/reads.mjs';
|
||||
+import { consoleReads, credentialState, queueReader, redactor } from '../src/reads.mjs';
|
||||
import { writeJson, REPO_ROLES } from '../../business/tests/helpers.mjs';
|
||||
-import { SRC, SNOWFLAKES, day, queueRepo, seeded, clean } from './reads-fixture.mjs';
|
||||
+import { SRC, SNOWFLAKES, NOTE_SHOWN, day, queueRepo, seeded, clean } from './reads-fixture.mjs';
|
||||
|
||||
async function serve(t, reads) {
|
||||
const server = await startServer({ port: 0, reads });
|
||||
@@ -23,6 +25,12 @@
|
||||
assert.equal(r('file /run/x.token, see https://git.example/a'), 'file <path>, see https://git.example/a');
|
||||
assert.equal(r('/home/u/.config/mosaic-devX/y'), '<path>');
|
||||
assert.equal(r(`user ${SNOWFLAKES[0]} ok`), 'user <id> ok');
|
||||
+ // ~/ paths, and a path after `:` or `<` (Filbert R1 and Sage, #1543 comment 27185). A URL keeps its path.
|
||||
+ assert.equal(r('tokens 0600 under ~/.config/mosaic-dev/secrets/mosaic-stack (lead decision 74)'), 'tokens 0600 under <path> (lead decision 74)');
|
||||
+ assert.equal(r('no `~/.mosaic` changes; key=~/k'), 'no `<path>` changes; key=<path>');
|
||||
+ assert.equal(r('file:/x, file:///srv/y and </srv/z.token>'), 'file:<path>, file:<path> and <<path>>');
|
||||
+ assert.equal(r('https://git.example/a, http://127.0.0.1:3456 and http://h:80/p'), 'https://git.example/a, http://127.0.0.1:3456 and http://h:80/p');
|
||||
+ assert.equal(r('a~/b, ~ and agents/sage/work/'), 'a~/b, ~ and agents/sage/work/');
|
||||
const quoted = r('business file is not valid JSON (/home/u/.config/mosaic-dev/businesses/a.json): Unexpected token \'s\', "s3cret-value" is not valid JSON');
|
||||
assert.equal(quoted.includes('s3cret-value'), false);
|
||||
assert.match(quoted, /^business file is not valid JSON \(<config>\/businesses\/a\.json\): the parser quoted/);
|
||||
@@ -46,7 +54,7 @@
|
||||
assert.deepEqual(body.rows.map(r => r.id), [1, 6, 8, 9, 11]);
|
||||
const six = body.rows.find(r => r.id === 6);
|
||||
assert.deepEqual([six.state, six.owner, six.reviewers, six.brief], ['in-progress', 'darkwing', ['filbert'], { path: 'docs/plans/brief-a.md', anchor: 'Row six' }]);
|
||||
- assert.equal(six.note, 'token at <path> for <id>');
|
||||
+ assert.equal(six.note, NOTE_SHOWN);
|
||||
assert.equal(Object.hasOwn(six, 'gate'), false);
|
||||
assert.ok(Array.isArray(body.notes));
|
||||
clean(list.text, repo.base);
|
||||
@@ -55,6 +63,7 @@
|
||||
const nine = JSON.parse(one.text).row;
|
||||
assert.deepEqual([nine.id, nine.gate, nine.after], [9, 'filbert approves', [{ id: 6, when: 'settled' }]]);
|
||||
clean(one.text, repo.base);
|
||||
+ assert.deepEqual(JSON.parse((await get(base, '/api/queue/11')).text).row.after, [{ id: 9, when: 'done' }]);
|
||||
assert.equal((await get(base, '/api/queue/7')).status, 404);
|
||||
for (const bad of ['abc', '0', '01', '1234567', '9/x', '-1']) assert.equal((await get(base, `/api/queue/${bad}`)).status, 400, bad);
|
||||
for (const path of ['/api/queue', '/api/queue/9', '/api/business', '/api/settings']) {
|
||||
@@ -75,6 +84,27 @@
|
||||
assert.equal((await get(base, '/api/queue/6')).status, 503);
|
||||
});
|
||||
|
||||
+test('queue: a store that fails to load, or a child that dies, gives a fixed message with no path or stack', { timeout: 60000 }, async t => {
|
||||
+ // Darkwing 27186: a half-written store.mjs used to send Node's own error, file:// path and stack included.
|
||||
+ for (const [name, breakStore] of [['syntax error', p => writeFileSync(p, 'export const rows = (;\n')], ['missing', p => rmSync(p)]]) {
|
||||
+ const repo = queueRepo(t);
|
||||
+ breakStore(join(repo.root, 'packages/queue/src/store.mjs'));
|
||||
+ const child = spawnSync(process.execPath, [join(repo.root, 'packages/webui/src/queue-read.mjs')], { cwd: repo.root, env: repo.env, encoding: 'utf8' });
|
||||
+ assert.deepEqual([child.status, child.stdout, child.stderr], [1, '', 'the queue read failed\n'], name);
|
||||
+ const base = await serve(t, consoleReads({ root: repo.root, env: repo.env, rolesDir: REPO_ROLES }));
|
||||
+ const r = await get(base, '/api/queue');
|
||||
+ assert.equal(r.status, 503, name);
|
||||
+ assert.deepEqual(JSON.parse(r.text), { error: 'read-failed', message: 'the queue read failed (exit 1)' }, name);
|
||||
+ clean(r.text, repo.base, repo.root);
|
||||
+ }
|
||||
+ // Only exit 2 forwards the child's text; any other exit is a fixed message.
|
||||
+ const repo = queueRepo(t), script = join(repo.root, 'packages/webui/src/queue-read.mjs');
|
||||
+ const fake = (body, opts = {}) => { writeFileSync(script, body); return queueReader({ root: repo.root, env: repo.env, ...opts })(); };
|
||||
+ await assert.rejects(fake('process.stderr.write("Error: boom\\n at main (file:///srv/q/x.mjs:1:1)\\n"); process.exitCode = 3;\n'), { code: 'read-failed', message: 'the queue read failed (exit 3)' });
|
||||
+ await assert.rejects(fake('setInterval(() => {}, 1000);\n', { timeoutMs: 300 }), { code: 'read-failed', message: 'the queue read did not finish in time' });
|
||||
+ await assert.rejects(fake('process.stdout.write("x".repeat(65536));\n', { maxBytes: 1024 }), { code: 'invalid-response', message: 'the queue read printed too much' });
|
||||
+});
|
||||
+
|
||||
test('business: names, vars on the allowlist, authority, credential metadata only', async t => {
|
||||
const s = seeded(t);
|
||||
const base = await serve(t, consoleReads({ root: SRC, system: s.system, configDir: s.configDir, rolesDir: REPO_ROLES, business: 'acme' }));
|
||||
@@ -86,6 +116,8 @@
|
||||
assert.deepEqual(b.vars, { 'tracker.baseUrl': 'http://127.0.0.1:3456', 'gitea.baseUrl': 'https://git.example', 'tracker.pollSeconds': 60 });
|
||||
assert.deepEqual(b.instances.map(i => [i.instance, i.definition, i.holder]), [['pm', 'pm', 'sage'], ['cto', 'cto', 'darkwing'], ['coder', 'coder', null], ['reviewer', 'reviewer', null]]);
|
||||
assert.deepEqual(b.instances.find(i => i.instance === 'coder').vars, { harness: 'pi', 'limits.network': 'none' });
|
||||
+ // An allowlisted var is scrubbed too (Filbert T1).
|
||||
+ assert.deepEqual(b.instances.find(i => i.instance === 'reviewer').vars, { model: '<path>' });
|
||||
for (const i of b.instances) {
|
||||
assert.deepEqual(Object.keys(i.authority), b.actions);
|
||||
assert.ok(Object.values(i.authority).every(v => ['within', 'cross', 'gated'].includes(v)));
|
||||
@@ -158,4 +190,9 @@
|
||||
r = await get(base, '/api/settings');
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(JSON.parse(r.text).notifier.refused, 'not-configured');
|
||||
+ // --business with no system config: refused in Console's words, not Node's (Filbert N1).
|
||||
+ base = await serve(t, consoleReads({ root: repo.root, env: repo.env, system: null, configDir: s.configDir, rolesDir: REPO_ROLES, business: 'acme' }));
|
||||
+ r = await get(base, '/api/settings');
|
||||
+ assert.equal(r.status, 200);
|
||||
+ assert.deepEqual(JSON.parse(r.text).notifier, { refused: 'not-configured', message: 'the Console has no system config, so it knows no notifier config' });
|
||||
});
|
||||
--- a/packages/webui/tests/views.test.mjs
|
||||
+++ b/packages/webui/tests/views.test.mjs
|
||||
@@ -15,7 +15,7 @@
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { consoleReads, ReadError } from '../src/reads.mjs';
|
||||
import { writeJson, REPO_ROLES } from '../../business/tests/helpers.mjs';
|
||||
-import { SRC, queueRepo, seeded, clean } from './reads-fixture.mjs';
|
||||
+import { SRC, NOTE_SHOWN, queueRepo, seeded, clean } from './reads-fixture.mjs';
|
||||
|
||||
const PROBE = 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));window.requests=[];window.bodies=[];const f=window.fetch;window.fetch=async(u,o={})=>{requests.push([(o.method||"GET").toUpperCase(),String(u)]);const r=await f(u,o);bodies.push(await r.clone().text());return r};'
|
||||
// The bus view's 10s refresh is recorded so a test can fire it early, unless it was cleared.
|
||||
@@ -62,11 +62,19 @@
|
||||
|
||||
test('Queue, Business and Settings in their five states, with no move button', { timeout: 180000 }, async () => {
|
||||
const s = stubReads();
|
||||
- const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', reads: s.reads });
|
||||
+ // A bus that answers, so the palette's own inbox and tasks reads succeed and forget nothing.
|
||||
+ const bus = { inbox: async () => [], tasks: async () => [], agents: async () => [], trail: async () => [] };
|
||||
+ const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', reads: s.reads, bus });
|
||||
const b = await browser();
|
||||
try {
|
||||
const { wait, text, count, flat, refresh, go } = await page(b, `http://127.0.0.1:${web.address().port}/`);
|
||||
const palette = '[...document.querySelectorAll("#cmdk-list [data-href]")].map(e=>e.dataset.href)';
|
||||
+ // Esc closes the palette at once, but its close event, which puts focus back, is a later task.
|
||||
+ // Waiting for that event keeps a late focus from landing on the next step (seen under load).
|
||||
+ const closePalette = async () => {
|
||||
+ await b.evaluate('window.paletteClosed=new Promise(r=>document.querySelector("#cmdk-dialog").addEventListener("close",()=>setTimeout(r),{once:true}));true');
|
||||
+ await b.key('Escape'); await b.evaluate('window.paletteClosed.then(()=>true)');
|
||||
+ };
|
||||
for (const [hash, h1, source, rows, refusals, emptied, emptyText] of [
|
||||
['#/queue', 'Queue', 'Queue', '#bus-view table.s1-queue tbody tr', ['queue-refused', 'not-configured'], () => { s.docs.queue.rows = []; }, 'The queue has no rows.'],
|
||||
['#/queue/6', 'Row 6', 'Queue', '#bus-view [aria-labelledby=q-row] dl', ['queue-refused'], null, null],
|
||||
@@ -114,7 +122,7 @@
|
||||
assert.equal(await count('#bus-view select[data-mirror]'), hash === '#/settings' ? 2 : 0, hash);
|
||||
await b.key('k', 'KeyK', 2); await wait('document.querySelector("#cmdk-dialog").open');
|
||||
assert.equal(await b.evaluate(`${palette}.filter(h=>h.startsWith("#/queue/")).length`), 0, `${hash}: no kept queue row in the palette after ${code}`);
|
||||
- await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open');
|
||||
+ await closePalette();
|
||||
await flat(`${hash} refused`);
|
||||
s.fail(null); await refresh();
|
||||
await wait(`!document.querySelector("#bus-view .banner") && document.querySelectorAll(${JSON.stringify(rows)}).length===${n}`);
|
||||
@@ -157,16 +165,23 @@
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-queue tbody tr")].map(e=>e.cells[0].textContent)'), ['7 Second row']);
|
||||
await go('#/queue/6', 'Row 6');
|
||||
assert.equal(await text('#bus-view .s1-cmd code'), 'scripts/mosaic queue move 6 <state> --op <op> --by <seat>');
|
||||
+ await go('#/business', 'Business acme');
|
||||
+ assert.equal(await b.evaluate('[...document.querySelectorAll("#bus-view [aria-labelledby=bz-about] dt")].find(e=>e.textContent==="Arbiters").nextElementSibling.textContent'), 'pm (delivery)');
|
||||
for (const hash of ['#/queue', '#/queue/6', '#/business', '#/settings']) {
|
||||
await go(hash, { '#/queue': 'Queue', '#/queue/6': 'Row 6', '#/business': 'Business', '#/settings': 'Settings' }[hash]);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view button, #bus-view form, #bus-view input")].filter(e=>!e.matches("button[data-copy], button[data-retry]")).length'), 0, `${hash}: no control but Copy and Read again`);
|
||||
assert.equal(await b.evaluate('[...document.querySelectorAll("#bus-view button")].some(e=>/move|resolve|approve|save|edit/i.test(e.textContent))'), false, hash);
|
||||
}
|
||||
|
||||
- // The palette lists queue rows from the last queue read, as text.
|
||||
- await b.key('k', 'KeyK', 2); await wait(`${palette}.includes("#/queue/7")`);
|
||||
+ // The palette lists queue rows from the last queue read, as text, and still does once its
|
||||
+ // inbox and tasks reads have answered. Over a server with no bus they were refusals that
|
||||
+ // forgot the queue rows, and the check passed only when it ran before they returned.
|
||||
+ const asked = await b.evaluate('window.requests.length');
|
||||
+ await b.key('k', 'KeyK', 2);
|
||||
+ await wait(`window.requests.slice(${asked}).filter(([,u])=>/\\/api\\/bus\\/(inbox|tasks)$/.test(u)).length===2 && window.bodies.length===window.requests.length`);
|
||||
+ await wait(`${palette}.includes("#/queue/7")`);
|
||||
assert.equal(await count('#cmdk-list #q-b'), 0);
|
||||
- await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open');
|
||||
+ await closePalette();
|
||||
|
||||
// Settings: appearance is the one control, mirrored with the command bar and kept in this browser.
|
||||
await go('#/settings', 'Settings');
|
||||
@@ -176,6 +191,11 @@
|
||||
await b.evaluate('{const m=document.querySelector("#bus-view select[data-mirror=mode]");m.value="dark";m.dispatchEvent(new Event("change",{bubbles:true}))}');
|
||||
await wait('document.querySelector("#mode").value==="dark"');
|
||||
assert.equal(await b.evaluate('JSON.parse(localStorage.getItem("mosaic-console-appearance")).mode'), 'dark');
|
||||
+ // The 10 s refresh redraws Settings and keeps focus on the mirror select (Darkwing D29).
|
||||
+ await b.evaluate('document.querySelector("#bus-view select[data-mirror=mode]").focus(); document.querySelector("#bus-view h1").dataset.old = "1"');
|
||||
+ assert.equal(await b.evaluate('fireRefresh()'), 1, 'a refresh is due on #/settings');
|
||||
+ await wait('!document.querySelector("#bus-view h1").dataset.old && !document.querySelector("#bus-view [aria-busy]")');
|
||||
+ assert.equal(await b.evaluate('document.activeElement?.matches("#bus-view select[data-mirror=mode]") ?? false'), true, 'the refresh keeps focus on the mirror select');
|
||||
|
||||
assert.deepEqual(await b.evaluate('window.errors'), []);
|
||||
assert.deepEqual(await b.evaluate('window.requests.filter(([m])=>m!=="GET")'), []);
|
||||
@@ -191,10 +211,21 @@
|
||||
const seen = async () => clean(await b.evaluate('document.documentElement.outerHTML + "\\n" + window.bodies.join("\\n")'), s.base, repo.base, repo.root, tmpdir());
|
||||
await go('#/queue', 'Queue'); await seen();
|
||||
await go('#/queue/6', 'Row 6');
|
||||
- assert.equal(await text('#bus-view dd.s1-q'), 'token at <path> for <id>');
|
||||
+ assert.equal(await text('#bus-view dd.s1-q'), NOTE_SHOWN);
|
||||
+ await seen();
|
||||
+ // After as the store keeps it, {id, when}: a link to the row and its condition (Filbert B1).
|
||||
+ const kv = (dt) => b.evaluate(`[...document.querySelectorAll("#bus-view [aria-labelledby=q-row] dt")].find(e=>e.textContent===${JSON.stringify(dt)})?.nextElementSibling?.innerHTML ?? null`);
|
||||
+ await go('#/queue/9', 'Row 9');
|
||||
+ assert.equal(await kv('After'), '<a href="#/queue/6">6</a> settled');
|
||||
+ assert.match(await kv('Required'), /^yes( since |$)/);
|
||||
+ await go('#/queue/11', 'Row 11');
|
||||
+ assert.equal(await kv('After'), '<a href="#/queue/9">9</a> done');
|
||||
+ assert.equal(await kv('Required'), null);
|
||||
await seen();
|
||||
await go('#/business', 'Business acme');
|
||||
assert.match(await text('#bus-view [aria-labelledby=bz-creds]'), /bot-acme-coder/);
|
||||
+ // The business file's arbiters object, as loadBusiness gives it (Darkwing 27186).
|
||||
+ assert.equal(await b.evaluate('[...document.querySelectorAll("#bus-view [aria-labelledby=bz-about] dt")].find(e=>e.textContent==="Arbiters").nextElementSibling.textContent'), 'pm (delivery), cto (technical)');
|
||||
await seen();
|
||||
await go('#/settings', 'Settings');
|
||||
assert.equal(await text('#bus-view [aria-labelledby=st-notify] dd code'), 'jason-dm');
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user