review(53): Dewey rounds 1 to 3 build packet (candidate 2562c05d)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
packages/webui/README.md
|
||||
packages/webui/src/public/app.js
|
||||
packages/webui/src/public/assets/fonts/jetbrains-mono-400.woff2
|
||||
packages/webui/src/public/assets/fonts/jetbrains-mono-500.woff2
|
||||
packages/webui/src/public/assets/fonts/jetbrains-mono-700.woff2
|
||||
packages/webui/src/public/assets/fonts/jetbrains-mono-OFL.txt
|
||||
packages/webui/src/public/assets/fonts/jetbrains-mono-sources.txt
|
||||
packages/webui/src/public/bus.js
|
||||
packages/webui/src/public/icons.svg
|
||||
packages/webui/src/public/index.html
|
||||
packages/webui/src/public/shell.css
|
||||
packages/webui/src/public/tokens.css
|
||||
packages/webui/src/serve.mjs
|
||||
packages/webui/tests/browser.mjs
|
||||
packages/webui/tests/serve.test.mjs
|
||||
packages/webui/tests/shell.test.mjs
|
||||
@@ -0,0 +1,348 @@
|
||||
# Row 53 (#1542): Console tokens and shell restyle
|
||||
|
||||
Dewey, 2026-10-10. Brief: `docs/plans/2026-10-10_design-implementation.md`,
|
||||
section "Console tokens and shell restyle" (origin/refactor a81915e8), with
|
||||
`docs/design/IMPLEMENTING.md` sections "Tokens", "Icons and the mark",
|
||||
"Components", "Strings", "Boundaries" and "Acceptance". Lead decision 81
|
||||
(Harbor following the system setting by default, the Relay placeholder mark
|
||||
stays, no Decision Seen from the web).
|
||||
|
||||
Built on 923957e2; no file under `packages/webui`, `docs/design` or the
|
||||
test scripts changed between it and the gate base 8a7871ff (only
|
||||
`scripts/mosaic-task.mjs`, row 56). Candidate: 16 files, uncommitted in the
|
||||
canonical checkout, listed in `candidate-manifest.sha256` beside this file
|
||||
(round 1 manifest sha256 `75569953c1a2cd7b7266208d6e6607290335f1bfe0cf94d96ed224e480a1dda8`;
|
||||
rounds 2 and 3 below; round 3's is current).
|
||||
|
||||
## Round 3 (answers #1542 comments 27163 and 27165)
|
||||
|
||||
Scope from Sage: two fixes, each with a test. 1: a board 403 followed by a
|
||||
503 or 500 must not bring back the refused scan's rows or waiting card
|
||||
(Filbert C4, Darkwing P9). 2: a board 403 must clear an open conversation's
|
||||
history (Darkwing P10).
|
||||
|
||||
Round 3 candidate: the same 16 files, manifest sha256
|
||||
`2562c05d4a92f90096c0fe68ef8c2adfa7244fa547f3390155f5bee5cce64cba`
|
||||
(`candidate-manifest.sha256` beside this file, replacing round 2's
|
||||
`78c3aef9`). Three files changed from round 2: `README.md`,
|
||||
`src/public/app.js`, `tests/shell.test.mjs`. The round 2 to round 3 delta is
|
||||
`round3.patch` beside this file. Applied to the round 2 files, it reproduces
|
||||
the manifest (16 OK, checked). Base for the gate: 03969219.
|
||||
|
||||
### Fixes
|
||||
|
||||
| Item | Change | Test |
|
||||
|---|---|---|
|
||||
| Filbert C4, Darkwing P9: a 403 then a 500 or 503 showed the refused scan's table and waiting card again | `error()` now draws the empty board whenever it holds no data, not only on a refusal. It clears `#waiting`, `#seen` and `#sessions`, sets the three counts to `–`, closes the inspector, and says why in the tree, the footer and `#status`: "the board refused the read" or "the read failed". | Shell browser test: after the refusal, `brd.unavailable()` (503) and a refresh. The banner is `banner err` with "No board data loaded."; `#board-view` shows again; no session row, open or history button, waiting card or seen card; the three counts are `–`; the footer and `#fresh-board` read "Board not read: the read failed", `#status` "Not read: the read failed", the tree "Not read: the read failed.". Screenshot `board-refused-then-failed-400`. |
|
||||
| Darkwing P10: a 403 with History open left `#conversation` and the old `#conv-log` messages | On a refusal with a conversation open, `error()` empties `#conv-log` and calls `closeConversation()`, which hides the panel and its reply form. | A session with `registered` set; History open; the fixture message "Fixture history line" in `#conv-log` and the reply form shown. After a 403: `#conversation` hidden, no `has-conversation` on `<body>`, `#conv-log` has no child, `#conv-form` not visible, the board hidden and the refusal state shown. Then recovery. |
|
||||
|
||||
Test fixtures in `shell.test.mjs`'s stub board: `one()` adds a third
|
||||
session that is seen (so the seen card is tested), `registered()` answers
|
||||
one live session with a conversation, `unavailable()` answers 503, and with
|
||||
status 200 the stub answers `/api/conversations` and `/api/conversation`
|
||||
with one fixture page. The new assertions sit in the "shell in a browser"
|
||||
test, where the board refusal state already lives.
|
||||
|
||||
### Also answered
|
||||
|
||||
| Item | Done |
|
||||
|---|---|
|
||||
| Filbert T7: the counts after a refusal | Asserted: `#waiting-count`, `#seen-count` and `#session-count` are `–` after the 403 and after the 503 that follows it. |
|
||||
| Filbert N7 (my call): the project filter on a refusal | Tested, not changed: a project is picked before the refusal (`aria-pressed` true), and after recovery the "all" filter is the pressed one. |
|
||||
| README | The refusal line in the command bar section now names the cards, the project filter, the empty board on a later failure and the closed conversation. The shell tests line names the two new cases. |
|
||||
|
||||
### Changes in behaviour beyond the two fixes
|
||||
|
||||
- A failed read before the first scan (no data yet, not a refusal) now draws
|
||||
the empty board with "Not read: the read failed" in the tree, footer and
|
||||
`#status`, where round 2 left the skeleton rows, "Loading board…" and
|
||||
"Loading projects…" under the error banner. This is
|
||||
the same code path as the C4 fix; the banner was already shown in both.
|
||||
- `error()` no longer calls `convForm()`. With P10, a refusal closes the
|
||||
conversation and `convForm()` does nothing without one; a non-refusal
|
||||
failure with no data never had an open conversation. The `error-noconvform`
|
||||
mutant survived for that reason and is dropped.
|
||||
|
||||
### Round 3 mutants
|
||||
|
||||
Each applied to a scratch copy of the round 3 tree, then `shell.test.mjs`
|
||||
run (`~/dewey-scratch/r53b/r3/mutants.py`, outputs in `r3/out/`). 13 of 13
|
||||
killed, every one by the "shell in a browser" test.
|
||||
|
||||
| Mutant | Change | Killed at |
|
||||
|---|---|---|
|
||||
| c4-no-clear | no-data branch leaves the cards and the table | no rows or cards after the 503 (11, expected 0) |
|
||||
| c4-keep-sessions | the table kept | same (7) |
|
||||
| c4-keep-waiting | the waiting cards kept | same (2) |
|
||||
| c4-keep-seen | the seen cards kept | same (2) |
|
||||
| c4-refused-only | the empty board only on a refusal (round 2) | footer "refused" after the 503 |
|
||||
| c4-footer-refused | footer always says "refused" | footer after the 503 |
|
||||
| c4-status-refused | `#status` always says "refused" | `#status` after the 503 |
|
||||
| c4-tree-refused | the tree always says "refused" | tree after the 503 |
|
||||
| error-keeps-counts | the counts left as they were | `#waiting-count` after the 403 |
|
||||
| error-noinspect | the inspector left open | `#inspector` hidden after the 403 |
|
||||
| error-keeps-project | a 403 keeps the project filter | "all" filter pressed after recovery |
|
||||
| p10-no-close | the conversation left open | `#conversation` hidden after the 403 |
|
||||
| p10-no-clear | the panel closed but the history kept | `#conv-log` empty after the 403 |
|
||||
|
||||
### Round 3 gate
|
||||
|
||||
Worktree at 03969219 plus the 16 round 3 files (`sha256sum -c` of the
|
||||
manifest: 16 OK), node_modules linked from the checkout, TMPDIR in
|
||||
scratch, `DOCKER_HOST` pointed at a missing socket, suites sequential,
|
||||
2026-10-10T19:13:43Z to 19:18:19Z. `core.hooksPath` unset. Script
|
||||
`~/dewey-scratch/r53b/r3/gate.sh`, outputs `~/dewey-scratch/r53b/r3/gate/out/`.
|
||||
|
||||
| Suite | Result |
|
||||
|---|---|
|
||||
| `node --test 'packages/webui/tests/*.test.mjs'` | 27 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, 148, 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 | 27 passed, 0 failed |
|
||||
| test-release, no Docker | 4 passed, 0 failed (state-machine cases skipped: daemon unavailable) |
|
||||
| test-task, no Docker | 26 passed, 0 failed (adapter seam, workspace and live cases skipped) |
|
||||
|
||||
Round 2's gate had Docker for test-release and test-task (14 and 98).
|
||||
Round 3 changes nothing those Docker cases run; Sage's gate rerun covers them.
|
||||
|
||||
### Not in this round
|
||||
|
||||
Darkwing note 3, the refresh-timer focus race in `bus.js` `render()`: it is
|
||||
in HEAD, so per Sage it goes to row 54 with its own test, named there as a
|
||||
fix to HEAD behaviour beside the H1 `keep()`/`restore()` fix.
|
||||
|
||||
## Round 2 (answers #1542 comments 27143 and 27148)
|
||||
|
||||
Round 2 candidate: the same 16 files, manifest sha256
|
||||
`78c3aef963b7e35500c9f8afa6341057f1a6372ced70915d175e507b5fac8406`
|
||||
(`candidate-manifest.sha256` beside this file, replacing round 1's
|
||||
`75569953`). Five files changed from round 1: `README.md`,
|
||||
`src/public/app.js`, `src/public/bus.js`, `src/public/shell.css`,
|
||||
`tests/shell.test.mjs`. The round 1 to round 2 delta is
|
||||
`round2.patch` beside this file. Base for the gate: 2d4d4e7d (nothing under
|
||||
`packages/webui`, `docs/design` or `scripts` changed since 8a7871ff).
|
||||
|
||||
### Blocking items
|
||||
|
||||
| Item | Change | Test |
|
||||
|---|---|---|
|
||||
| Filbert C1, Darkwing 1: a board 403 left the inspector, project tree, counts, footer and `#status` "Scanned" line | `error()` on a 403 drops `data`, `selected` and `project`, closes the inspector, and sets the tree to "Not read: the board refused the read.", the three counts to `–`, the footer to "Board not read: refused" and `#status` to "Not read: the board refused the read". The next good read restores them through `accept()` and `render()`. | Shell browser test opens the inspector and checks the footer "waiting 1 · working 1" and two tree counts before the refusal; after it, the inspector is hidden, `<body>` has no `has-inspector`, the tree has no `.count` or `[data-project]`, the footer and `#fresh-board` read "Board not read: refused", `#status` has no "Scanned". After recovery the footer is back. |
|
||||
| Filbert C3: the freshness line called a refusal "stale" | `fresh()` with no data: "Board not read: refused", "Board not read: the read failed" or "Board not read yet". | Same test: `#fresh-board` equals "Board not read: refused". |
|
||||
| Filbert C2, Darkwing 2 and note 6: a bus refusal left the Inbox count, BLOCKING chip and palette rows | `forget()` clears `last` and the Inbox count on any refusal: in the view's read, in the board route's inbox read (was `catch {}`), and in `pkOpen`'s refetch, which then redraws the section list. A refused read anywhere drops every kept bus read, so the pre-refusal rows can't come back as stale on a later failure either (Darkwing's P7). The bus freshness line reads "Bus not read: refused". | Five-states test: `#sections` first reads "Inbox 1 1 blocking"; after the refusal it has no `.count` or `.tag-block`, and Ctrl+K lists only the four views. A second step refuses only `pkOpen`'s own refetch and checks the kept tasks and the count are gone. On the board page a `no-bus-host` refusal of the board route's inbox read drops the count and the palette shows views only. |
|
||||
| Filbert T1, Darkwing 4 (M01): only 403 was tested as a refusal | none needed | The five-states refusal step runs for `human-required` (403) and `no-bus-host` over kept rows: banner code, 0 rows, "Nothing to show.", "Bus not read: refused", no section counts, then recovery. |
|
||||
| Darkwing 3: tests with the inspector open | as C1 | as C1 |
|
||||
| Darkwing 5 (M08): palette labels with markup | none needed | The inbox question is `<img src=x onerror="errors.push(1)" id="pk-q">Push?` and a task title `<b id="pk-t">Bold</b> task`. The palette lists views plus three items, no `img` or `b` in the list, the labels equal the literal text, and `window.errors` stays empty. |
|
||||
| Sage: the section list below 760px | The deviation is withdrawn. Below 760px `.s1-sections` is one strip, `flex-wrap:nowrap; overflow-x:auto`, items `flex:none`; it scrolls inside itself, so row 54's three sections add no rows. The command bar keeps its four controls on one row. | At 360px and 400px: every section link has one `offsetTop`, the list's computed `overflow-x` is `auto`, no label wraps, and the page has no sideways scroll. |
|
||||
|
||||
### Non-blocking items
|
||||
|
||||
| Item | Done |
|
||||
|---|---|
|
||||
| Filbert T2 (`accept()` keeps `failed`) | Test: a failed refresh, then a good one; `#fresh-board` no longer says "stale". |
|
||||
| Filbert T3 (clipboard-denied toast stays) | Test: after 3 s the error toast's class is still `toast on err`. |
|
||||
| Filbert T4 (empty states' second line) | Each bus empty state and the trail's is matched whole, both lines, anchored. The board's was already. |
|
||||
| Filbert T5 (nav wrap untested) | Covered by Sage's strip test. |
|
||||
| Filbert T6 (Ctrl+Alt+K) | Pinned: Ctrl+Alt+K opens nothing. Only Ctrl+K and Cmd+K open the palette. |
|
||||
| Filbert N1 (local board time, UTC bus time) | Both are UTC now: "Board scanned 18:01:02 UTC (4s ago)". Test matches `^Board scanned \d\d:\d\d:\d\d UTC`. |
|
||||
| Filbert N2 (strings counted across files) | The strings test counts each string per file, with the expected count (`No task ${txt(ref)} in this business.` twice in bus.js; "Nothing is waiting on you." once in app.js and once in bus.js). |
|
||||
| Filbert N3, round 1 pkback | Unchanged: equivalent in Chromium; the listener stays for other browsers. |
|
||||
| Darkwing note 3, M09 (focus offset) | Test: `#cmdk`'s `outline-offset` is 2px. Table rows keep their inset ring (`-2px`), unchanged and untested. |
|
||||
| Darkwing note 4 (toast live region `display:none` before the first copy) | The toast is always rendered; while off it is empty and visually hidden (`clip-path: inset(50%)`, 1px box), so it stays in the accessibility tree. Test: before the first copy it is not `display:none`, is visible, is empty and is at most 1px. |
|
||||
| Darkwing note 7, M16 (tabular numbers) | Test: `font-variant-numeric` is `tabular-nums` on `#fresh` and `#session-count`. |
|
||||
| Darkwing M07 (toast text escaping) | Not added. The toast text is `Copied: <command>`, the commands come from validated refs, and `toast()` passes the text through `esc()`. |
|
||||
| Darkwing note 2 (freshness hidden below 760px) | Unchanged. The command bar has no room at 360px, and the stale and refusal banners still state it in the view. Left for row 54 or the design session. |
|
||||
| Darkwing note 5 (Seen 403 wording, prose in `<code>`) | Unchanged. A Seen 403 now also drops the board data, the same as a read 403; since only control-board's Host/Origin check gives it, the read would be refused too. |
|
||||
|
||||
### Round 2 mutants
|
||||
|
||||
Each applied to a scratch copy of the round 2 webui, then `shell.test.mjs`
|
||||
run (`mut/tools/mutants.py`, `run.sh`). 20 of 20 killed.
|
||||
|
||||
| Mutant | Change | Killed by |
|
||||
|---|---|---|
|
||||
| C1-keep-data | a board 403 keeps `data` | shell browser test |
|
||||
| C1-keep-inspector | a board 403 keeps `selected` and the inspector | shell browser test |
|
||||
| C1-keep-footer | a board 403 leaves the footer | shell browser test |
|
||||
| C3-fresh-wording | "stale" wording on a refusal | shell browser test |
|
||||
| C2-render-forget | the view's refused read doesn't `forget()` | five-states test |
|
||||
| C2-pk-forget | `pkOpen`'s refused refetch doesn't `forget()` | five-states test |
|
||||
| C2-board-forget | the board route's refused inbox read doesn't `forget()` | five-states test |
|
||||
| C2-forget-keeps-count | `forget()` clears `last` but not the count | five-states test |
|
||||
| M01-refusal-403-only | `refusal()` only for 403 | five-states test |
|
||||
| M08-pk-label-raw | palette label set as HTML | five-states test |
|
||||
| nav-wraps | the round 1 wrapping section list | shell browser test (strip) |
|
||||
| toast-display-none | the round 1 `display:none` toast | shell browser test |
|
||||
| M09-offset-0 | focus `outline-offset: 0` | shell browser test |
|
||||
| M16-no-tabular | no `tabular-nums` | shell browser test |
|
||||
| N1-local-time | board time in local time | shell browser test |
|
||||
| N2-one-copy | one copy of the duplicated task string changed | strings test |
|
||||
| T6-ctrl-alt-k | Ctrl+Alt+K opens the palette | shell browser test |
|
||||
| T3-err-toast-hides | the error toast closes after 2.6 s | shell browser test |
|
||||
| T2-stale-sticks | `accept()` keeps `failed` | shell browser test |
|
||||
| bus-fresh-refused | bus refusal shows the round 1 freshness wording | shell browser test |
|
||||
|
||||
### Round 2 gate
|
||||
|
||||
Worktree at 2d4d4e7d plus the 16 round 2 files (`sha256sum -c` of the
|
||||
manifest: all OK), node_modules linked from the checkout, TMPDIR in scratch,
|
||||
suites sequential, 2026-10-10T18:15:17Z to 18:19:16Z. Outputs:
|
||||
`~/dewey-scratch/r53b/gate/out/`.
|
||||
|
||||
| Suite | Result |
|
||||
|---|---|
|
||||
| `node --test 'packages/webui/tests/*.test.mjs'` | 27 pass, 0 fail |
|
||||
| `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 | 27 passed, 0 failed |
|
||||
| test-release | 14 passed, 0 failed |
|
||||
| test-task, Docker present | 98 passed, 0 failed |
|
||||
|
||||
The round 1 packet follows unchanged, except where rounds 2 and 3 supersede it
|
||||
(the nav deviation is withdrawn; the manifest above replaces `75569953`).
|
||||
|
||||
# Round 1 packet
|
||||
|
||||
## What changed
|
||||
|
||||
All in `packages/webui`. No new endpoint, read, write or dependency.
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/public/tokens.css` | New. Byte copy of `docs/design/tokens.css`. |
|
||||
| `src/public/icons.svg` | New. Byte copy of `docs/design/icons.svg`: 17 icons and the `mark` symbol. |
|
||||
| `src/public/shell.css` | New. The components on top of the tokens: command bar, freshness line, section list with icons, dense table with tabular numbers, inspector, status mark (glyph plus word), class chip and BLOCKING chip, copy command, toast, Ctrl+K palette, empty, not found, banners (`err`, `ref`), skeleton rows, `@font-face` for JetBrains Mono, `--r` 6px and `--r-lg` 10px use, focus rings, the 400px layout. Loaded last. |
|
||||
| `src/public/assets/fonts/jetbrains-mono-{400,500,700}.woff2`, `jetbrains-mono-OFL.txt`, `jetbrains-mono-sources.txt` | JetBrains Mono 2.304 from the official release archive (URL and archive sha256 in the sources file), each file's sha256, and the OFL text from the archive. |
|
||||
| `src/public/index.html` | Loads `tokens.css` after `shared/app.css` and `shell.css` last; no `data-mode` on `<html>`; the mark from the sprite in the wordmark; the Ctrl+K button, freshness line, System option, banner error element, board refusal empty state, board skeleton, toast and palette `<dialog>`. |
|
||||
| `src/public/app.js` | `theme()` sets no property inline: it sets `data-palette`, and `data-mode` unless the mode is System, which removes it. Default mode `system`. Same key `mosaic-console-appearance`. Status mark classes on the state badge. Empty states with "what would appear and where it comes from". Board freshness line. A 403 from the board fails closed (kept rows hidden, `banner ref`); any other failure keeps the rows with their scan time. |
|
||||
| `src/public/bus.js` | Class and BLOCKING chips, section icons, empty states, the skeleton loading view, not found that names the address and points to the Board and Ctrl K, freshness for bus pages, a refusal (403, `not-configured`, `no-bus-host`) never showing kept rows, the toast on copy, the Ctrl+K palette, ↑/↓ between task links. |
|
||||
| `src/serve.mjs` | The files map only: `tokens.css`, `shell.css`, `icons.svg`, the three mono fonts. |
|
||||
| `tests/shell.test.mjs` | New: copies, font digests, strings and the write boundary; two browser tests (below). |
|
||||
| `tests/serve.test.mjs` | The new static files are served with their types. |
|
||||
| `tests/browser.mjs` | `ArrowUp` and `k` key codes; no text insert when a modifier is held (so Ctrl+K doesn't type `k`). |
|
||||
| `README.md` | "Shell (row 53, #1542)" section; the stale/refusal rule; the verify lines. |
|
||||
|
||||
## Acceptance against the brief
|
||||
|
||||
| Brief line | Where it is checked |
|
||||
|---|---|
|
||||
| `tokens.css` served, `theme()` no inline properties | serve.test (type); shell browser test: `<html>` has no `style` attribute; inline mutant fails |
|
||||
| `data-palette` and `data-mode` stay; System removes `data-mode`; key kept; Harbor + System default | shell browser test: fresh load has no `data-mode`, palette `harbor`, select `system`; dark and light OS give dark and light pages; picking dark sets it and stores it under `mosaic-console-appearance`; System removes it again. Mode mutant fails. |
|
||||
| `--r` 6px, `--r-lg` 10px, type scale, tabular numbers | computed `--r` and `--r-lg` asserted; type scale and `font-variant-numeric: tabular-nums` in shell.css |
|
||||
| JetBrains Mono 400/500/700 with sha256 and OFL | static test: shipped files equal the listed set, each digest matches, OFL heading present, `@font-face` for each weight; `document.fonts.check` in the browser |
|
||||
| Icon sprite; mark in one component; Relay placeholder | `icons.svg` copy test; the mark is drawn once, in the wordmark, from `#mark` |
|
||||
| Components | each rendered in the browser test and its screenshots: command bar, section list (4 icons asserted), dense table, inspector (opened by keyboard), status mark (`◐` glyph asserted), class chip (`code` action, uppercase BLOCKING asserted), copy command and toast (success and clipboard-denied), palette, not found |
|
||||
| Five states on Board, Inbox, Tasks, Agents, Trail | second browser test walks each view through loading (a held read shows the skeleton), normal, error over kept rows (same row count under `banner err s1-stale`), refusal (`banner ref`, code in mono, no rows, "Nothing to show.") and back. Empty is seeded per view in the first test. |
|
||||
| Browser: no script errors, no sideways scroll at 400px, keyboard ↑/↓, Enter, Esc returning focus, visible focus | `window.errors` empty at the end of both tests; `scrollWidth <= clientWidth` after every state; board ↑/↓/Enter/Esc, task-link ↑/↓, palette ↑/↓/Enter/Esc with focus back on the opener; outline ≥2px on `#cmdk`, a section link and Refresh |
|
||||
| String grep test | static test: the kept strings from bus.js, app.js and index.html, no Resolve/Decide/Approve button, no non-GET method in bus.js, no script in the sprite |
|
||||
| No writes | both browser tests: every request the page made is a GET |
|
||||
|
||||
## Tests
|
||||
|
||||
Outputs in `~/dewey-scratch/r53/out/`.
|
||||
|
||||
| Run | Result |
|
||||
|---|---|
|
||||
| `node --test 'tests/*.test.mjs'` in `packages/webui`, with `WEBUI_EVIDENCE` (`full-4.txt`) | 27 pass, 0 fail |
|
||||
| `shell.test.mjs` alone, three repeats after the last test change (`shell-rep-{1,2,3}.txt`) | 5 pass each |
|
||||
| Five-states test alone (`states-1.txt`) | pass |
|
||||
|
||||
Screenshots (`out/evidence-4/`, 400px unless named): `shell-board-empty`,
|
||||
`-board-stale`, `-board-refused`, `-bus-empty-{inbox,tasks,agents,trail}`,
|
||||
`-bus-stale`, `-bus-refused`, `-bus-inbox`, `-palette`, `-toast`,
|
||||
`-bus-tasks-1440`, `-board-1440`, and `states-{board,inbox,tasks,agents,trail}-{loading,normal,stale,refused}`
|
||||
(board: loading and normal). I looked at each.
|
||||
|
||||
## Mutations
|
||||
|
||||
Each applied to a scratch copy of the candidate, then `shell.test.mjs` run
|
||||
(`mutbin/run.sh`, outputs `out/mut-*.txt`).
|
||||
|
||||
| Mutant | Result |
|
||||
|---|---|
|
||||
| tokens: a comment appended to the `src/public/tokens.css` copy | fails: "tokens.css differs from docs/design/tokens.css" |
|
||||
| inline: `theme()` sets one property inline again | fails: `<html>` style is `--canvas: #fff;` |
|
||||
| mode: System sets `data-mode="light"` instead of removing it | fails: `true !== false` |
|
||||
| refusal: drop `if (refusal(err)) throw err` (refusal shows kept rows) | fails: no `.banner.ref` |
|
||||
| focus: `:focus-visible{outline:none}` appended to shell.css | fails: `#cmdk focus ring` |
|
||||
| foot: the side column's top and height rule removed (760px and up) | fails: column bottom 930 against 900 |
|
||||
| post: `method: 'POST'` in `fetchRead` | fails: static check and the GET-only browser check |
|
||||
| loading: no loading view on navigation | fails: no `#bus-view .skel-rows[aria-busy=true]` (five-states test) |
|
||||
| skel: no board skeleton | fails: no `#sessions .skel-rows[aria-busy=true]` (five-states test) |
|
||||
| pkback: drop the `b.focus()` in the palette's close listener | survives: Chromium's native `<dialog>` close puts focus back on the opener, so the listener is redundant there and the mutant is equivalent in this browser. Kept for browsers that don't. |
|
||||
|
||||
## Gate
|
||||
|
||||
Worktree at 8a7871ff plus the 16 candidate files (`sha256sum -c` of the
|
||||
manifest: all OK), node_modules linked from the checkout, TMPDIR in scratch,
|
||||
suites sequential, 2026-10-10T17:24:05Z to 17:28:05Z. Outputs:
|
||||
`~/dewey-scratch/r53/gate/out/`.
|
||||
|
||||
| Suite | Result |
|
||||
|---|---|
|
||||
| `node --test 'packages/webui/tests/*.test.mjs'` | 27 pass, 0 fail |
|
||||
| `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 | 27 passed, 0 failed (verify and render --check skip outside the canonical root) |
|
||||
| test-release | 14 passed, 0 failed |
|
||||
| test-task, Docker present | 98 passed, 0 failed |
|
||||
|
||||
An earlier gate run on 3265383f with the manifest before the five-states
|
||||
test was also green (26 webui tests); it is superseded by this one.
|
||||
|
||||
## Decisions and deviations from the design
|
||||
|
||||
- **Bus refusal fails closed.** Before this row a 403 from the bus still
|
||||
showed kept rows under a stale banner. Now a refusal (403,
|
||||
`not-configured`, `no-bus-host`) shows only the failure: "Console shows
|
||||
nothing rather than a guess". Other failures keep the rows, labelled with
|
||||
their read time. The board does the same for its 403.
|
||||
- **Footer is a sticky status bar from 760px.** With the side column
|
||||
sticky under the command bar, a page taller than the viewport slid the
|
||||
column under the bar at 1440×900. The footer now sits at the bottom,
|
||||
the column fills the space between, and the browser test checks the
|
||||
edges on a short page and on a long one scrolled to the end.
|
||||
- **Nav wraps below 760px** (withdrawn in round 2: the section list is
|
||||
one strip that scrolls inside itself, per IMPLEMENTING.md) instead of
|
||||
scrolling sideways, and the command bar keeps Palette, Appearance, Refresh and Pause on one row down
|
||||
to 360px.
|
||||
- **Manrope 800 renders as 700**: only 400 to 700 are shipped (row 53
|
||||
adds no Manrope weight). Visible on the large headings only.
|
||||
- **No business pill** in the command bar: there is one business and no
|
||||
business read yet; the Business view is row 54.
|
||||
- **Not found keeps the existing string** "No page at this address." and
|
||||
adds the address, a Board link and Ctrl K.
|
||||
- **`#copy-status` lost `role="status"`.** The toast is the live region
|
||||
now; both announcing the same line would read it twice. The status line
|
||||
still shows the text.
|
||||
- **Class chip colours** use the design's four classes; an unknown class
|
||||
gets the plain chip.
|
||||
|
||||
## Not done here (follow-ups)
|
||||
|
||||
- Row 54 (#1543) adds Queue, Business and Settings and will reuse these
|
||||
components; both rows change `app.js`, so 54 starts after 53 lands.
|
||||
- The pkback mutant above: a test in a browser without native dialog
|
||||
focus restore would need another engine; none is in the repo.
|
||||
|
||||
## Boundaries kept
|
||||
|
||||
Commits: none (Sage commits). No change outside `packages/webui` and this
|
||||
directory. `docs/design/` read only. The Q14 frozen paths untouched. No
|
||||
request to the tracker from node. No new dependency. Scratch worktrees
|
||||
removed after use; `core.hooksPath` unset.
|
||||
@@ -0,0 +1,386 @@
|
||||
--- a/packages/webui/README.md
|
||||
+++ b/packages/webui/README.md
|
||||
@@ -91,7 +91,9 @@
|
||||
open sections and the copy status survive a refresh.
|
||||
- A failed read keeps the last good answer for that read and says it is old,
|
||||
with the time it was read. A refusal (403, `not-configured` or `no-bus-host`)
|
||||
- never shows kept rows (row 53). A page never read, or refused, shows the failure instead,
|
||||
+ never shows kept rows (row 53), and it drops every kept bus read: the Inbox
|
||||
+ count and the palette's decisions and tasks go too, until a read succeeds.
|
||||
+ A page never read, or refused, shows the failure instead,
|
||||
titled "Bus refused the read" (403: the bus refused the Console's read, for
|
||||
example `human-required` from a Console started inside an agent run), "No bus to read" (no system
|
||||
config, or no bus host and no `--business`) or "The read failed".
|
||||
@@ -120,12 +122,16 @@
|
||||
and tasks; arrows move, Enter opens, Esc closes and returns focus. Arrow
|
||||
keys also move between task links in the bus tables.
|
||||
- Copy reports in a toast. If the clipboard is unavailable, the toast stays
|
||||
- until dismissed and the command is selected for Ctrl+C.
|
||||
-- The command bar shows when the board was scanned and the bus read, and
|
||||
- says when the last attempt failed. A board refusal hides the board rather
|
||||
- than showing the last scan.
|
||||
+ until dismissed and the command is selected for Ctrl+C. When no toast shows,
|
||||
+ the toast is an empty live region, visually hidden but still in the
|
||||
+ accessibility tree, so the first copy is announced.
|
||||
+- The command bar shows, in UTC, when the board was scanned and the bus read,
|
||||
+ and says when the last attempt failed. A board refusal drops the last scan
|
||||
+ and everything drawn from it: the table, the inspector, the project tree,
|
||||
+ the counts, the footer and the scan line all say the board was not read.
|
||||
- From 760px the footer is a one-line status bar and the side column fits
|
||||
- between it and the command bar. Below 760px the section list wraps.
|
||||
+ between it and the command bar. Below 760px the section list is one strip
|
||||
+ that scrolls inside itself.
|
||||
|
||||
## Data and boundaries
|
||||
|
||||
@@ -204,11 +210,15 @@
|
||||
|
||||
Shell tests (`shell.test.mjs`) check the copies, the font hashes and the kept
|
||||
strings, and in the browser at 400px: System mode under an emulated dark and
|
||||
-light OS, the empty, stale and refused states of the board and the bus views,
|
||||
-not found, the palette, the toast, keyboard and visible focus, no sideways
|
||||
+light OS, the empty, stale and refused states of the board and the bus views
|
||||
+(a board refusal with the inspector open), not found, the palette, the toast,
|
||||
+keyboard and visible focus, the section strip at 360 and 400px, no sideways
|
||||
scroll, no script error and no request other than GET. A second test walks
|
||||
the Board, Inbox, Tasks, Agents and a decision trail through loading (a held
|
||||
-read), normal, a failed read over kept rows, and a refusal.
|
||||
+read), normal, a failed read over kept rows, and a refusal by the bus
|
||||
+(`human-required`) and for want of one (`no-bus-host`). It then checks that a
|
||||
+refusal empties the palette and the Inbox count, from a view, from the
|
||||
+palette's own reads and from the board page, and that palette labels are text.
|
||||
|
||||
No root CI workflow is configured for this package. These local tests are not a
|
||||
claim of CI, deployment, live-seat delivery or user acceptance.
|
||||
--- a/packages/webui/src/public/app.js
|
||||
+++ b/packages/webui/src/public/app.js
|
||||
@@ -282,8 +282,8 @@
|
||||
// Freshness line in the command bar: when the board last scanned, and warning words when the last refresh failed.
|
||||
function fresh() {
|
||||
const at = Date.parse(data?.generatedAt), ago = Number.isFinite(at) ? age(Math.max(0, Math.round((Date.now() - at) / 1000))) : null;
|
||||
- if (!data) { $('fresh-board').innerHTML = failed ? 'Board <span class="warn">not read: the read failed</span>' : 'Board not read yet'; return; }
|
||||
- const scan = `Board scanned <b>${esc(Number.isFinite(at) ? new Date(at).toLocaleTimeString([], { hour12: false }) : 'unknown')}</b>${ago ? ` (${ago} ago)` : ''}`;
|
||||
+ if (!data) { $('fresh-board').innerHTML = failed === 'refused' ? 'Board <span class="warn">not read: refused</span>' : failed ? 'Board <span class="warn">not read: the read failed</span>' : 'Board not read yet'; return; }
|
||||
+ const scan = `Board scanned <b>${esc(Number.isFinite(at) ? `${new Date(at).toISOString().slice(11, 19)} UTC` : 'unknown')}</b>${ago ? ` (${ago} ago)` : ''}`;
|
||||
$('fresh-board').innerHTML = failed ? `${scan} · <span class="warn">stale: the last refresh failed</span>` : scan;
|
||||
}
|
||||
setInterval(() => { if (data) fresh(); }, 5000);
|
||||
@@ -300,10 +300,20 @@
|
||||
for (const id of awaiting.keys()) { const r = row(id); if (r && !pending(r)) awaiting.delete(id); }
|
||||
render();
|
||||
}
|
||||
- // A refusal (403) fails closed: the last data is hidden, not shown as current. Any other failure keeps the last data with its scan time.
|
||||
+ // A refusal (403) fails closed: the last data is dropped, not kept or shown as current, and so is
|
||||
+ // everything drawn from it: the inspector, the project tree, the counts and the footer. Any other
|
||||
+ // failure keeps the last data with its scan time.
|
||||
function error(err) {
|
||||
- failed = true; $('error').hidden = false;
|
||||
const refused = err.status === 403;
|
||||
+ failed = refused ? 'refused' : true; $('error').hidden = false;
|
||||
+ if (refused) {
|
||||
+ data = null; selected = null; project = null;
|
||||
+ $('projects').innerHTML = '<p class="muted">Not read: the board refused the read.</p>';
|
||||
+ for (const id of ['waiting-count', 'seen-count', 'session-count']) $(id).textContent = '–';
|
||||
+ $('footer').textContent = 'Board not read: refused';
|
||||
+ $('status').textContent = 'Not read: the board refused the read';
|
||||
+ inspect(); convForm();
|
||||
+ }
|
||||
$('error').className = refused ? 'banner ref' : 'banner err';
|
||||
$('board-refused').hidden = !refused; document.body.classList.toggle('board-refused', refused);
|
||||
$('error').innerHTML = refused
|
||||
--- a/packages/webui/src/public/bus.js
|
||||
+++ b/packages/webui/src/public/bus.js
|
||||
@@ -369,12 +369,14 @@
|
||||
const failTitle = err => err.status === 403 ? 'Bus refused the read' : ['not-configured', 'no-bus-host'].includes(err.code) ? 'No bus to read' : 'The read failed';
|
||||
// A refusal fails closed: no kept data, the code in mono. Any other failure keeps the last read, labelled with its time.
|
||||
const refusal = err => err.status === 403 || ['not-configured', 'no-bus-host'].includes(err.code);
|
||||
+ // After a refusal nothing read before is shown anywhere: not in a view, the palette or the Inbox count.
|
||||
+ function forget() { last.clear(); inboxCount = null; }
|
||||
const failedView = err => `${head(failTitle(err))}<div class="banner ${refusal(err) ? 'ref' : 'err'}" role="alert"><p>${refusal(err) ? `<b>refused: <code>${txt(err.code)}</code>.</b> ` : ''}${why(err)} Console shows nothing rather than a guess. Nothing was written.</p>${retry}</div>${empty('Nothing to show.', refusal(err) ? 'The view stays empty until the problem above is fixed.' : 'Read again, or come back when the bus answers.')}`;
|
||||
const staleBanner = err => `<div class="banner err s1-stale" role="alert"><p><b>The last read failed.</b> ${why(err)} This is what was read before, ${when(readAt)}. Nothing was changed.</p>${retry}</div>`;
|
||||
// Freshness line in the command bar for bus pages.
|
||||
const stamp = iso => { const t = Date.parse(iso); return Number.isFinite(t) ? `${new Date(t).toISOString().slice(11, 19)} UTC` : 'unknown'; };
|
||||
function freshBus(state) {
|
||||
- $('fresh-bus').innerHTML = state === 'failed' ? 'Bus <span class="warn">not read: the read failed</span>'
|
||||
+ $('fresh-bus').innerHTML = state === 'refused' ? 'Bus <span class="warn">not read: refused</span>' : state === 'failed' ? 'Bus <span class="warn">not read: the read failed</span>'
|
||||
: `Bus read <b>${esc(stamp(readAt))}</b>${readAt ? ` (${esc(ago(readAt))})` : ''}${state === 'stale' ? ' · <span class="warn">stale: the last read failed</span>' : ''}`;
|
||||
}
|
||||
|
||||
@@ -420,7 +422,8 @@
|
||||
if (board) {
|
||||
// The board page keeps its own title; the Inbox count still follows the bus.
|
||||
if (shown !== null) { shown = null; $('bus-view').innerHTML = ''; document.title = 'Mosaic Console'; if (navigated) $('main').focus(); }
|
||||
- try { const r = await fetchRead('inbox'); last.set('inbox', r); if (g === gen) { countInbox(r.rows); sections(path); } } catch {}
|
||||
+ try { const r = await fetchRead('inbox'); last.set('inbox', r); if (g === gen) { countInbox(r.rows); sections(path); } }
|
||||
+ catch (err) { if (refusal(err)) { forget(); if (g === gen) sections(path); } }
|
||||
if (g === gen) schedule();
|
||||
return;
|
||||
}
|
||||
@@ -431,7 +434,8 @@
|
||||
catch (err) {
|
||||
const kept = reader(false);
|
||||
try { if (refusal(err)) throw err; html = await view(kept, path, new URLSearchParams(query)); readAt = kept.oldest(); html = staleBanner(err) + html; state = 'stale'; }
|
||||
- catch { html = failedView(err); state = 'failed'; }
|
||||
+ catch { html = failedView(err); state = refusal(err) ? 'refused' : 'failed'; }
|
||||
+ if (refusal(err)) forget();
|
||||
}
|
||||
if (g !== gen) return;
|
||||
freshBus(state);
|
||||
@@ -506,7 +510,10 @@
|
||||
if (d.open) return;
|
||||
pkBack = document.activeElement; pkAt = 0; $('cmdk-input').value = '';
|
||||
pkItems(); pkFill(); d.showModal(); $('cmdk-input').focus();
|
||||
- Promise.allSettled(['inbox', 'tasks'].map(async v => last.set(v, await fetchRead(v)))).then(() => { if (d.open) { pkItems(); pkFill(); } });
|
||||
+ Promise.allSettled(['inbox', 'tasks'].map(async v => last.set(v, await fetchRead(v)))).then(rs => {
|
||||
+ if (rs.some(r => r.status === 'rejected' && refusal(r.reason))) { forget(); sections(routeOf().split('?')[0]); }
|
||||
+ if (d.open) { pkItems(); pkFill(); }
|
||||
+ });
|
||||
}
|
||||
function pkGo(href) {
|
||||
pkBack = null; $('cmdk-dialog').close();
|
||||
--- a/packages/webui/src/public/shell.css
|
||||
+++ b/packages/webui/src/public/shell.css
|
||||
@@ -101,8 +101,9 @@
|
||||
body.on-bus #board-refused{display:none}
|
||||
|
||||
/* Toast: one line, polite, bottom centre. */
|
||||
-.toast{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);display:none;align-items:center;gap:10px;background:var(--strong);color:var(--surface);padding:6px 8px 6px 14px;border-radius:var(--r);font-size:13px;z-index:60;max-width:calc(100% - 24px);box-shadow:var(--shadow)}
|
||||
-.toast.on{display:flex}
|
||||
+.toast{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);display:flex;align-items:center;gap:10px;background:var(--strong);color:var(--surface);padding:6px 8px 6px 14px;border-radius:var(--r);font-size:13px;z-index:60;max-width:calc(100% - 24px);box-shadow:var(--shadow)}
|
||||
+/* Off, the toast stays in the accessibility tree, empty and visually hidden, so the first copy is announced. */
|
||||
+.toast:not(.on){width:1px;height:1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;box-shadow:none}
|
||||
.toast span{min-width:0;overflow-wrap:anywhere}
|
||||
.toast.err{background:var(--danger)}
|
||||
.toast button{font:inherit;font-weight:600;border:1px solid currentColor;background:none;color:inherit;border-radius:4px;padding:1px 8px;cursor:pointer}
|
||||
@@ -164,8 +165,9 @@
|
||||
.cmd-right{margin-left:0;flex:1 1 100%;flex-wrap:nowrap;min-width:0}
|
||||
.cmd-right label{flex:1 1 0;min-width:0}
|
||||
.cmd-right .btn{flex:none}
|
||||
- /* The section list wraps rather than scrolling, so a blocking count never hides a section. */
|
||||
- .s1-sections{display:flex;flex-wrap:wrap;overflow-x:visible;flex-basis:100%;gap:2px;padding-bottom:2px}
|
||||
+ /* The section list is one strip that scrolls inside itself (IMPLEMENTING.md), so more sections never add rows. */
|
||||
+ .s1-sections{display:flex;flex-wrap:nowrap;overflow-x:auto;flex-basis:100%;min-width:0;max-width:100%;gap:2px;padding-bottom:2px;scrollbar-width:thin}
|
||||
+ .s1-sections>li{flex:none}
|
||||
.s1-section{border-radius:var(--r);padding:5px 8px;white-space:nowrap;flex:none}
|
||||
.inspector{inset:0;top:0;width:100%;z-index:40;box-shadow:none}
|
||||
}
|
||||
--- a/packages/webui/tests/shell.test.mjs
|
||||
+++ b/packages/webui/tests/shell.test.mjs
|
||||
@@ -35,17 +35,21 @@
|
||||
});
|
||||
|
||||
test('shipped strings are unchanged, and the web keeps its write boundary', () => {
|
||||
- // Markup in the source (<code>, <b>) is not part of the string.
|
||||
+ // Markup in the source (<code>, <b>) is not part of the string. Each string is counted in the
|
||||
+ // file that ships it, so dropping one of two copies fails too.
|
||||
const src = ['app.js', 'bus.js', 'index.html'].map(f => read(f).toString()).join('\n');
|
||||
- const plain = src.replace(/<\/?(code|b)>/g, '');
|
||||
- for (const s of [
|
||||
- 'Copied: ${text}',
|
||||
- 'The clipboard is unavailable. The command is selected; press Ctrl+C to copy it.',
|
||||
- 'Decisions are answered in a terminal with mosaic decide; Console only shows them.',
|
||||
- 'No task ${txt(ref)} in this business.', 'No decision with id ${txt(id)}.', '${txt(b)} is not a decision id.', '${txt(b)} is not a task reference.',
|
||||
- 'No page at this address.', 'Nothing is waiting on you.', 'No tasks in this business yet.', 'Tasks appear after the PM creates them or the poller reads them from Vikunja.',
|
||||
- 'No open tasks.', 'No role is held right now.', 'Nothing of this kind in the trail.', 'Console shows nothing rather than a guess. Nothing was written.',
|
||||
- ]) assert.ok(plain.includes(s), s);
|
||||
+ const plain = Object.fromEntries(['app.js', 'bus.js'].map(f => [f, read(f).toString().replace(/<\/?(code|b)>/g, '')]));
|
||||
+ for (const [file, s, n = 1] of [
|
||||
+ ['bus.js', 'Copied: ${text}'],
|
||||
+ ['bus.js', 'The clipboard is unavailable. The command is selected; press Ctrl+C to copy it.'],
|
||||
+ ['bus.js', 'Decisions are answered in a terminal with mosaic decide; Console only shows them.'],
|
||||
+ ['bus.js', 'No task ${txt(ref)} in this business.', 2], ['bus.js', 'No decision with id ${txt(id)}.'],
|
||||
+ ['bus.js', '${txt(b)} is not a decision id.'], ['bus.js', '${txt(b)} is not a task reference.'],
|
||||
+ ['bus.js', 'No page at this address.'], ['app.js', 'Nothing is waiting on you.'], ['bus.js', 'Nothing is waiting on you.'],
|
||||
+ ['bus.js', 'No tasks in this business yet.'], ['bus.js', 'Tasks appear after the PM creates them or the poller reads them from Vikunja.'],
|
||||
+ ['bus.js', 'No open tasks.'], ['bus.js', 'No role is held right now.'], ['bus.js', 'Nothing of this kind in the trail.'],
|
||||
+ ['bus.js', 'Console shows nothing rather than a guess. Nothing was written.'],
|
||||
+ ]) assert.equal(plain[file].split(s).length - 1, n, `${file}: ${s}`);
|
||||
// No Resolve control and no bus write: the only POSTs are the board's Seen and reply.
|
||||
assert.doesNotMatch(src, /<button[^>]*>[^<]*(Resolve|Decide|Approve)/i);
|
||||
assert.doesNotMatch(read('bus.js').toString(), /method:\s*['"](POST|PUT|PATCH|DELETE)/);
|
||||
@@ -121,7 +125,7 @@
|
||||
// Board, empty: what would appear and where it comes from.
|
||||
assert.match(await text('#sessions .empty'), /No sessions match these filters\.Sessions appear here when the board scan finds/);
|
||||
assert.match(await text('#waiting .empty'), /Nothing is waiting on you\./);
|
||||
- assert.match(await text('#fresh-board'), /^Board scanned \d\d:\d\d:\d\d/);
|
||||
+ assert.match(await text('#fresh-board'), /^Board scanned \d\d:\d\d:\d\d UTC/);
|
||||
await flat(); await shot('board-empty-400');
|
||||
|
||||
// Board, a failed read over kept data: the data stays, labelled with its scan time, and the bar says stale.
|
||||
@@ -135,16 +139,30 @@
|
||||
assert.match(await text('#fresh-board'), /stale: the last refresh failed/);
|
||||
await flat(); await shot('board-stale-400');
|
||||
|
||||
- // Board, a refusal: fails closed, the kept rows are hidden, not shown as current.
|
||||
+ // Board, a refusal: fails closed. The kept rows are hidden, and so is everything drawn from
|
||||
+ // them: the open inspector, the project tree's counts and the footer.
|
||||
+ await b.evaluate('document.querySelector("table.sessions [data-open]").click()'); await wait('!document.querySelector("#inspector").hidden');
|
||||
+ assert.equal(await text('#footer'), 'waiting 1 · working 1');
|
||||
+ assert.equal(await b.evaluate('document.querySelectorAll("#projects .count").length'), 2);
|
||||
brd.refuse(); await refresh();
|
||||
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner ref');
|
||||
assert.match(await text('#error'), /The board refused the read\. refused: board-refused/);
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
|
||||
assert.equal(await b.evaluate('document.querySelector("#board-refused").hidden'), false);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#inspector").hidden'), true);
|
||||
+ assert.equal(await b.evaluate('document.body.classList.contains("has-inspector")'), false);
|
||||
+ assert.equal(await b.evaluate('document.querySelectorAll("#projects .count, #projects [data-project]").length'), 0);
|
||||
+ assert.equal(await text('#footer'), 'Board not read: refused');
|
||||
+ assert.equal(await text('#fresh-board'), 'Board not read: refused');
|
||||
+ assert.doesNotMatch(await text('#status'), /Scanned/);
|
||||
await flat(); await shot('board-refused-400');
|
||||
brd.one(); await refresh();
|
||||
assert.equal(await b.evaluate('document.querySelector("#error").hidden'), true);
|
||||
assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
|
||||
+ assert.equal(await text('#footer'), 'waiting 1 · working 1');
|
||||
+ // A good read after a failed one drops the stale words.
|
||||
+ brd.fail(); await refresh(); assert.match(await text('#fresh-board'), /stale/);
|
||||
+ brd.one(); await refresh(); assert.match(await text('#fresh-board'), /^Board scanned \d\d:\d\d:\d\d UTC/); assert.doesNotMatch(await text('#fresh-board'), /stale/);
|
||||
|
||||
// Keyboard on the board: down moves between rows, Enter opens, Esc closes and returns focus.
|
||||
await b.evaluate('document.querySelector("table.sessions [data-open]").focus()');
|
||||
@@ -159,12 +177,16 @@
|
||||
await b.evaluate('document.querySelector(".skip").focus();document.querySelector(".skip").blur()');
|
||||
for (const sel of ['#cmdk', '#sections a', '#refresh']) {
|
||||
await b.evaluate(`document.querySelector(${JSON.stringify(sel)}).focus()`);
|
||||
- const ring = await b.evaluate(`(()=>{const c=getComputedStyle(document.activeElement);return [c.outlineStyle, parseFloat(c.outlineWidth)]})()`);
|
||||
- assert.notEqual(ring[0], 'none', `${sel} focus ring`); assert.ok(ring[1] >= 2, `${sel} outline ${ring[1]}px`);
|
||||
+ const ring = await b.evaluate(`(()=>{const c=getComputedStyle(document.activeElement);return [c.outlineStyle, parseFloat(c.outlineWidth), parseFloat(c.outlineOffset)]})()`);
|
||||
+ assert.notEqual(ring[0], 'none', `${sel} focus ring`); assert.ok(ring[1] >= 2, `${sel} outline ${ring[1]}px`); assert.equal(ring[2], 2, `${sel} outline offset`);
|
||||
}
|
||||
|
||||
// Bus pages, empty: each says what would appear and where it comes from.
|
||||
- for (const [hash, h1, words] of [['#/inbox', 'Inbox', /Nothing is waiting on you\./], ['#/tasks', 'Tasks', /No tasks in this business yet\./], ['#/agents', 'Agents', /No role is held right now\./]]) {
|
||||
+ for (const [hash, h1, words] of [
|
||||
+ ['#/inbox', 'Inbox', /^Nothing is waiting on you\.A decision shows here when an agent raises one routed to you through the bus\.$/],
|
||||
+ ['#/tasks', 'Tasks', /^No tasks in this business yet\.Tasks appear after the PM creates them or the poller reads them from Vikunja\.$/],
|
||||
+ ['#/agents', 'Agents', /^No role is held right now\.A role shows here while a run holds its claim on the bus\.$/],
|
||||
+ ]) {
|
||||
await go(hash, h1);
|
||||
assert.match(await text('#bus-view .empty'), words, hash);
|
||||
assert.match(await text('#fresh-bus'), /^Bus read \d\d:\d\d:\d\d UTC/, hash);
|
||||
@@ -172,11 +194,23 @@
|
||||
}
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#fresh-board")).display'), 'none');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#sections svg use").length'), 4);
|
||||
+ for (const sel of ['#fresh', '#session-count']) assert.equal(await b.evaluate(`getComputedStyle(document.querySelector(${JSON.stringify(sel)})).fontVariantNumeric`), 'tabular-nums', sel);
|
||||
+ // Before any copy the toast is an empty live region still in the accessibility tree, out of sight.
|
||||
+ assert.deepEqual(await b.evaluate('(()=>{const t=document.querySelector("#toast"),c=getComputedStyle(t),r=t.getBoundingClientRect();return [c.display!=="none",c.visibility,t.textContent,r.width<=1&&r.height<=1]})()'), [true, 'visible', '', true]);
|
||||
+ // Below 760px the section list is one strip that scrolls inside itself: every link on one
|
||||
+ // line, no label wrapped, and still no sideways scroll on the page. 360px, then back to 400px.
|
||||
+ for (const w of [360, 400]) {
|
||||
+ await b.viewport(w, 900);
|
||||
+ assert.equal(await b.evaluate('new Set([...document.querySelectorAll("#sections a")].map(a=>a.offsetTop)).size'), 1, `one row at ${w}px`);
|
||||
+ assert.match(await b.evaluate('getComputedStyle(document.querySelector("#sections")).overflowX'), /^(auto|scroll)$/, `strip scrolls at ${w}px`);
|
||||
+ assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#sections a")].filter(a=>a.getClientRects().length>1||a.scrollWidth>a.clientWidth+1).map(a=>a.textContent)'), [], `no wrapped label at ${w}px`);
|
||||
+ await flat();
|
||||
+ }
|
||||
|
||||
// Bus, a failed read over kept data: the kept rows stay under a stale banner; the bar says stale.
|
||||
s.rows.tasks = [{ task_ref: 'vikunja:32/7', fields: { title: 'Kept task', bucket: 2, done: false, assignees: [] }, source: 'self' }, { task_ref: 'vikunja:32/8', fields: { title: 'Second task', bucket: 2, done: false, assignees: [] }, source: 'self' }];
|
||||
await go('#/trail/task/vikunja%3A32%2F7', 'Trail for task #7');
|
||||
- assert.match(await text('#bus-view .empty'), /Nothing of this kind in the trail\./);
|
||||
+ assert.match(await text('#bus-view .empty'), /^Nothing of this kind in the trail\.Every step the stack takes on this subject writes a trail row\.$/);
|
||||
await flat(); await shot('bus-empty-trail-400');
|
||||
await go('#/tasks', 'Tasks');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view td.s1-title a").length'), 2);
|
||||
@@ -197,7 +231,7 @@
|
||||
assert.equal(await b.evaluate('document.querySelector("#bus-view .banner.ref code").textContent'), 'human-required');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view td.s1-title a").length'), 0);
|
||||
assert.match(await text('#bus-view .empty'), /Nothing to show\./);
|
||||
- assert.match(await text('#fresh-bus'), /not read: the read failed/);
|
||||
+ assert.equal(await text('#fresh-bus'), 'Bus not read: refused');
|
||||
await flat(); await shot('bus-refused-400');
|
||||
s.fail(null);
|
||||
|
||||
@@ -209,6 +243,8 @@
|
||||
// Ctrl+K: opens on the views and the kept tasks, filters, Enter goes, Esc restores focus.
|
||||
await go('#/inbox', 'Inbox');
|
||||
await b.evaluate('document.querySelector("#refresh").focus()');
|
||||
+ await b.key('k', 'KeyK', 3); await new Promise(r => setTimeout(r, 200));
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#cmdk-dialog").open'), false, 'Ctrl+Alt+K opens nothing');
|
||||
await b.key('k', 'KeyK', 2); await wait('document.querySelector("#cmdk-dialog").open');
|
||||
assert.equal(await b.evaluate('document.activeElement.id'), 'cmdk-input');
|
||||
await wait('[...document.querySelectorAll("#cmdk-list [data-href]")].some(e=>e.dataset.href==="#/tasks/vikunja%3A32%2F7")');
|
||||
@@ -251,6 +287,8 @@
|
||||
await wait('document.querySelector("#toast").classList.contains("err")');
|
||||
assert.equal(await text('#toast span'), 'The clipboard is unavailable. The command is selected; press Ctrl+C to copy it.');
|
||||
assert.equal(await b.evaluate('getSelection().toString()'), `mosaic decide ${'d'.repeat(32)} yes`);
|
||||
+ await new Promise(r => setTimeout(r, 3000));
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#toast").className'), 'toast on err', 'the error toast stays until dismissed');
|
||||
await b.evaluate('document.querySelector("#toast [data-dismiss]").click()');
|
||||
assert.equal(await b.evaluate('document.querySelector("#toast").classList.contains("on")'), false);
|
||||
|
||||
@@ -293,6 +331,7 @@
|
||||
const count = sel => b.evaluate(`document.querySelectorAll(${JSON.stringify(sel)}).length`);
|
||||
const flat = async () => assert.equal(await b.evaluate('document.documentElement.scrollWidth<=document.documentElement.clientWidth'), true, 'sideways scroll at 400px');
|
||||
const refresh = async () => { await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled'); };
|
||||
+ const go = async (hash, h1) => { await b.evaluate(`location.hash=${JSON.stringify(hash)}`); await wait(`document.querySelector("#bus-view h1")?.textContent.startsWith(${JSON.stringify(h1)}) && !document.querySelector("#bus-view [aria-busy]")`); };
|
||||
try {
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));window.requests=[];const f=window.fetch;window.fetch=(u,o={})=>{requests.push([(o.method||"GET").toUpperCase(),String(u)]);return f(u,o)};' });
|
||||
await b.viewport(400, 900);
|
||||
@@ -332,17 +371,55 @@
|
||||
assert.equal(await count(rows), n, `${hash} keeps its rows`);
|
||||
assert.match(await text('#fresh-bus'), /stale: the last read failed/, hash);
|
||||
await flat(); await shot(`${name}-stale-400`);
|
||||
- // A refusal: no kept rows, the code shown, nothing current.
|
||||
- s.fail('human-required'); await refresh();
|
||||
- await wait('!!document.querySelector("#bus-view .banner.ref")');
|
||||
- assert.equal(await text('#bus-view .banner.ref code'), 'human-required', hash);
|
||||
- assert.equal(await count(rows), 0, `${hash} hides kept rows on a refusal`);
|
||||
- assert.match(await text('#bus-view .empty'), /Nothing to show\./, hash);
|
||||
- await flat(); await shot(`${name}-refused-400`);
|
||||
- s.fail(null); await refresh();
|
||||
- await wait(`!document.querySelector("#bus-view .banner") && document.querySelectorAll(${JSON.stringify(rows)}).length===${n}`);
|
||||
+ // A refusal, by the bus (403) or for want of one (no-bus-host): no kept rows, the code
|
||||
+ // shown, nothing current, and no count in the section list.
|
||||
+ for (const code of ['human-required', 'no-bus-host']) {
|
||||
+ s.fail(code); await refresh();
|
||||
+ await wait('!!document.querySelector("#bus-view .banner.ref")');
|
||||
+ assert.equal(await text('#bus-view .banner.ref code'), code, hash);
|
||||
+ assert.equal(await count(rows), 0, `${hash} hides kept rows on a ${code} refusal`);
|
||||
+ assert.match(await text('#bus-view .empty'), /Nothing to show\./, hash);
|
||||
+ assert.equal(await text('#fresh-bus'), 'Bus not read: refused', hash);
|
||||
+ assert.equal(await count('#sections .count, #sections .tag-block'), 0, `${hash} drops the Inbox count on a ${code} refusal`);
|
||||
+ if (code === 'human-required') { await flat(); await shot(`${name}-refused-400`); }
|
||||
+ s.fail(null); await refresh();
|
||||
+ await wait(`!document.querySelector("#bus-view .banner") && document.querySelectorAll(${JSON.stringify(rows)}).length===${n}`);
|
||||
+ }
|
||||
}
|
||||
|
||||
+ // After a refusal the palette lists only the views: no kept decision or task.
|
||||
+ const views = ['#/', '#/inbox', '#/tasks', '#/agents'];
|
||||
+ const palette = '[...document.querySelectorAll("#cmdk-list [data-href]")].map(e=>e.dataset.href)';
|
||||
+ await b.evaluate('location.hash="#/inbox"'); await wait('document.querySelectorAll("#sections .count").length===1');
|
||||
+ assert.match(await text('#sections'), /Inbox 1 1 blocking/);
|
||||
+ s.fail('human-required'); await refresh(); await wait('!!document.querySelector("#bus-view .banner.ref")');
|
||||
+ await b.key('k', 'KeyK', 2); await wait('document.querySelector("#cmdk-dialog").open');
|
||||
+ assert.deepEqual(await b.evaluate(palette), views);
|
||||
+ await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open');
|
||||
+ s.fail(null);
|
||||
+ // The palette's own reads: kept rows show until a refused refetch, then go, with the count.
|
||||
+ await go('#/tasks', 'Tasks'); await b.evaluate('location.hash="#/inbox"'); await wait('document.querySelectorAll("#sections .count").length===1');
|
||||
+ await b.key('k', 'KeyK', 2); await wait(`${palette}.some(h=>h.startsWith("#/tasks/"))`);
|
||||
+ await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open');
|
||||
+ s.fail('human-required');
|
||||
+ await b.key('k', 'KeyK', 2); await wait(`document.querySelector("#cmdk-dialog").open && ${palette}.length===${views.length} && !document.querySelector("#sections .count")`);
|
||||
+ assert.deepEqual(await b.evaluate(palette), views);
|
||||
+ await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open');
|
||||
+ // Palette labels are bus text: markup in a question or a task title shows as text.
|
||||
+ s.fail(null);
|
||||
+ s.rows.inbox = [{ ...s.rows.inbox[0], question: '<img src=x onerror="errors.push(1)" id="pk-q">Push?' }];
|
||||
+ s.rows.tasks = [{ ...s.rows.tasks[0], fields: { ...s.rows.tasks[0].fields, title: '<b id="pk-t">Bold</b> task' } }, s.rows.tasks[1]];
|
||||
+ await b.key('k', 'KeyK', 2); await wait(`${palette}.length===${views.length + 3}`);
|
||||
+ assert.equal(await count('#cmdk-list #pk-q, #cmdk-list #pk-t, #cmdk-list img, #cmdk-list b'), 0);
|
||||
+ assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#cmdk-list [data-href] span:first-child")].slice(4).map(e=>e.textContent)'), ['<img src=x onerror="errors.push(1)" id="pk-q">Push?', '#7 <b id="pk-t">Bold</b> task', '#8 Second task']);
|
||||
+ await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open');
|
||||
+ // The board page's Inbox read: a refusal there drops the count too.
|
||||
+ s.fail(null); await refresh(); await wait('document.querySelectorAll("#sections .count").length===1');
|
||||
+ s.fail('no-bus-host'); await b.evaluate('location.hash="#/"'); await wait('!document.querySelector("#sections .count")');
|
||||
+ await b.key('k', 'KeyK', 2); await wait('document.querySelector("#cmdk-dialog").open');
|
||||
+ assert.deepEqual(await b.evaluate(palette), views);
|
||||
+ await b.key('Escape'); s.fail(null);
|
||||
+
|
||||
assert.deepEqual(await b.evaluate('window.errors'), []);
|
||||
assert.deepEqual(await b.evaluate('window.requests.filter(([m])=>m!=="GET")'), []);
|
||||
} finally { await b.close(); await close(web); await close(brd.server); }
|
||||
@@ -0,0 +1,160 @@
|
||||
--- a/packages/webui/README.md
|
||||
+++ b/packages/webui/README.md
|
||||
@@ -127,8 +127,12 @@
|
||||
accessibility tree, so the first copy is announced.
|
||||
- The command bar shows, in UTC, when the board was scanned and the bus read,
|
||||
and says when the last attempt failed. A board refusal drops the last scan
|
||||
- and everything drawn from it: the table, the inspector, the project tree,
|
||||
- the counts, the footer and the scan line all say the board was not read.
|
||||
+ and everything drawn from it: the cards, the table, the inspector, the
|
||||
+ project filter and tree, the counts, the footer and the scan line all say
|
||||
+ the board was not read. A failed read with no scan held, after a refusal or
|
||||
+ before the first scan, shows the board empty and says the read failed; it
|
||||
+ never brings back rows from a refused scan. A refusal also closes an open
|
||||
+ conversation and clears its history.
|
||||
- From 760px the footer is a one-line status bar and the side column fits
|
||||
between it and the command bar. Below 760px the section list is one strip
|
||||
that scrolls inside itself.
|
||||
@@ -211,7 +215,8 @@
|
||||
Shell tests (`shell.test.mjs`) check the copies, the font hashes and the kept
|
||||
strings, and in the browser at 400px: System mode under an emulated dark and
|
||||
light OS, the empty, stale and refused states of the board and the bus views
|
||||
-(a board refusal with the inspector open), not found, the palette, the toast,
|
||||
+(a board refusal with the inspector open and a project picked, then a failed
|
||||
+read, and a refusal with a conversation open), not found, the palette, the toast,
|
||||
keyboard and visible focus, the section strip at 360 and 400px, no sideways
|
||||
scroll, no script error and no request other than GET. A second test walks
|
||||
the Board, Inbox, Tasks, Agents and a decision trail through loading (a held
|
||||
--- a/packages/webui/src/public/app.js
|
||||
+++ b/packages/webui/src/public/app.js
|
||||
@@ -301,18 +301,22 @@
|
||||
render();
|
||||
}
|
||||
// A refusal (403) fails closed: the last data is dropped, not kept or shown as current, and so is
|
||||
- // everything drawn from it: the inspector, the project tree, the counts and the footer. Any other
|
||||
- // failure keeps the last data with its scan time.
|
||||
+ // everything drawn from it: the cards, the table, the inspector, an open conversation and its
|
||||
+ // history, the project tree, the counts and the footer. Any other failure keeps the last data with
|
||||
+ // its scan time. With no data, from a refusal or before the first scan, the board is drawn empty
|
||||
+ // and says why, so a later failure never brings back old rows.
|
||||
function error(err) {
|
||||
const refused = err.status === 403;
|
||||
failed = refused ? 'refused' : true; $('error').hidden = false;
|
||||
- if (refused) {
|
||||
- data = null; selected = null; project = null;
|
||||
- $('projects').innerHTML = '<p class="muted">Not read: the board refused the read.</p>';
|
||||
+ if (refused) { data = null; selected = null; project = null; if (conv) { $('conv-log').replaceChildren(); closeConversation(); } }
|
||||
+ if (!data) {
|
||||
+ const why = refused ? 'the board refused the read' : 'the read failed';
|
||||
+ $('projects').innerHTML = `<p class="muted">Not read: ${why}.</p>`;
|
||||
+ for (const id of ['waiting', 'seen', 'sessions']) $(id).replaceChildren();
|
||||
for (const id of ['waiting-count', 'seen-count', 'session-count']) $(id).textContent = '–';
|
||||
- $('footer').textContent = 'Board not read: refused';
|
||||
- $('status').textContent = 'Not read: the board refused the read';
|
||||
- inspect(); convForm();
|
||||
+ $('footer').textContent = `Board not read: ${refused ? 'refused' : 'the read failed'}`;
|
||||
+ $('status').textContent = `Not read: ${why}`;
|
||||
+ inspect();
|
||||
}
|
||||
$('error').className = refused ? 'banner ref' : 'banner err';
|
||||
$('board-refused').hidden = !refused; document.body.classList.toggle('board-refused', refused);
|
||||
--- a/packages/webui/tests/shell.test.mjs
|
||||
+++ b/packages/webui/tests/shell.test.mjs
|
||||
@@ -59,20 +59,28 @@
|
||||
function board() {
|
||||
let reply = { status: 200, body: { sessions: [], counts: {}, generatedAt: new Date().toISOString() } };
|
||||
const one = { agent: 'agent1', project: 'proj', state: 'waiting', waitingOnYou: true, task: 'Fixture task', lastActivity: '2026-10-10T00:00:00Z', lastAssistantText: 'Input needed: fixture', registered: null };
|
||||
+ // While the board answers, agent1 has one conversation with one message. A failure or refusal answers every path.
|
||||
+ const catalogue = { conversations: [{ project: 'proj', seat: 'agent1', conversation: 'c1', availability: 'available', title: 'Fixture', conversationCreatedAt: '2026-10-10T00:00:00Z', lastActivityAt: '2026-10-10T00:00:00Z' }] };
|
||||
+ const page = { page: { branch: 'b1', entries: [{ message: 'm1', role: 'assistant', createdAt: '2026-10-10T00:00:00Z', content: [{ block: 0, type: 'text', text: 'Fixture history line' }] }] }, view: { incomplete: false, defaultBranch: 'b1', branch: 'b1' }, cursor: null, follow: null };
|
||||
let gate = null;
|
||||
const server = createServer(async (req, res) => {
|
||||
for await (const chunk of req) { void chunk; }
|
||||
await gate;
|
||||
- res.writeHead(reply.status, { 'content-type': 'application/json' }); res.end(JSON.stringify(reply.body));
|
||||
+ const path = new URL(req.url, 'http://localhost').pathname;
|
||||
+ const body = reply.status !== 200 ? reply.body : path === '/api/conversations' ? catalogue : path === '/api/conversation' ? page : reply.body;
|
||||
+ res.writeHead(reply.status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body));
|
||||
});
|
||||
return {
|
||||
server,
|
||||
// Holds every reply until the returned function is called.
|
||||
hold: () => { let go; gate = new Promise(r => { go = r; }); return () => { gate = null; go(); }; },
|
||||
empty: () => { reply = { status: 200, body: { sessions: [], counts: {}, generatedAt: new Date().toISOString() } }; },
|
||||
- one: () => { reply = { status: 200, body: { sessions: [one, { ...one, agent: 'agent2', state: 'working', waitingOnYou: false }], counts: { waiting: 1, working: 1 }, generatedAt: new Date().toISOString() } }; },
|
||||
+ one: () => { reply = { status: 200, body: { sessions: [one, { ...one, agent: 'agent2', state: 'working', waitingOnYou: false }, { ...one, agent: 'agent3', state: 'idle', waitingOnYou: false, seen: true }], counts: { waiting: 1, working: 1 }, generatedAt: new Date().toISOString() } }; },
|
||||
fail: () => { reply = { status: 500, body: { error: 'board scan failed' } }; },
|
||||
refuse: () => { reply = { status: 403, body: { error: 'board-refused' } }; },
|
||||
+ // One seat a reply can reach: registered, alive, with a tmux session.
|
||||
+ registered: () => { reply = { status: 200, body: { sessions: [{ ...one, registered: { alive: true, tmux: { session: 'fixture' } } }], counts: { waiting: 1 }, generatedAt: new Date().toISOString() } }; },
|
||||
+ unavailable: () => { reply = { status: 503, body: { error: 'board unavailable' } }; },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,6 +103,7 @@
|
||||
const shot = async name => { if (out) await b.screenshot(join(out, `shell-${name}.png`)); };
|
||||
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
|
||||
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
|
||||
+ const count = sel => b.evaluate(`document.querySelectorAll(${JSON.stringify(sel)}).length`);
|
||||
const flat = async () => assert.equal(await b.evaluate('document.documentElement.scrollWidth<=document.documentElement.clientWidth'), true, 'sideways scroll at 400px');
|
||||
const refresh = async () => { await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled'); };
|
||||
const go = async (hash, h1) => { await b.evaluate(`location.hash=${JSON.stringify(hash)}`); await wait(`document.querySelector("#bus-view h1")?.textContent.startsWith(${JSON.stringify(h1)}) && !document.querySelector("#bus-view [aria-busy]")`); };
|
||||
@@ -140,10 +149,13 @@
|
||||
await flat(); await shot('board-stale-400');
|
||||
|
||||
// Board, a refusal: fails closed. The kept rows are hidden, and so is everything drawn from
|
||||
- // them: the open inspector, the project tree's counts and the footer.
|
||||
+ // them: the open inspector, the project filter, the project tree's counts and the footer.
|
||||
+ await b.evaluate('document.querySelector("#projects [data-project=proj]").click()');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#projects [data-project=proj]").getAttribute("aria-pressed")'), 'true');
|
||||
await b.evaluate('document.querySelector("table.sessions [data-open]").click()'); await wait('!document.querySelector("#inspector").hidden');
|
||||
assert.equal(await text('#footer'), 'waiting 1 · working 1');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#projects .count").length'), 2);
|
||||
+ assert.equal(await count('#seen .wait-item'), 1);
|
||||
brd.refuse(); await refresh();
|
||||
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner ref');
|
||||
assert.match(await text('#error'), /The board refused the read\. refused: board-refused/);
|
||||
@@ -155,14 +167,43 @@
|
||||
assert.equal(await text('#footer'), 'Board not read: refused');
|
||||
assert.equal(await text('#fresh-board'), 'Board not read: refused');
|
||||
assert.doesNotMatch(await text('#status'), /Scanned/);
|
||||
+ for (const id of ['#waiting-count', '#seen-count', '#session-count']) assert.equal(await text(id), '–', id);
|
||||
await flat(); await shot('board-refused-400');
|
||||
+ // A failed read after the refusal shows the board again, empty: no row, card or count from
|
||||
+ // the refused scan, and the footer says what the banner says.
|
||||
+ brd.unavailable(); await refresh();
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner err');
|
||||
+ assert.match(await text('#error'), /board unavailable.*No board data loaded\./);
|
||||
+ assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
|
||||
+ assert.equal(await count('#sessions tr, #sessions [data-open], #waiting .wait-item, #seen .wait-item, #board-view [data-history]'), 0);
|
||||
+ for (const id of ['#waiting-count', '#seen-count', '#session-count']) assert.equal(await text(id), '–', id);
|
||||
+ assert.equal(await text('#footer'), 'Board not read: the read failed');
|
||||
+ assert.equal(await text('#fresh-board'), 'Board not read: the read failed');
|
||||
+ assert.equal(await text('#status'), 'Not read: the read failed');
|
||||
+ assert.equal(await text('#projects'), 'Not read: the read failed.');
|
||||
+ await flat(); await shot('board-refused-then-failed-400');
|
||||
brd.one(); await refresh();
|
||||
assert.equal(await b.evaluate('document.querySelector("#error").hidden'), true);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#projects [data-project=\'\']").getAttribute("aria-pressed")'), 'true');
|
||||
assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
|
||||
assert.equal(await text('#footer'), 'waiting 1 · working 1');
|
||||
// A good read after a failed one drops the stale words.
|
||||
brd.fail(); await refresh(); assert.match(await text('#fresh-board'), /stale/);
|
||||
brd.one(); await refresh(); assert.match(await text('#fresh-board'), /^Board scanned \d\d:\d\d:\d\d UTC/); assert.doesNotMatch(await text('#fresh-board'), /stale/);
|
||||
+ // A refusal with a conversation open closes it and clears its history and reply form: the
|
||||
+ // session is in no scan the page holds.
|
||||
+ brd.registered(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===1');
|
||||
+ await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
|
||||
+ await wait('document.querySelector("#conv-log").textContent.includes("Fixture history line")');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#conv-form").hidden'), false);
|
||||
+ brd.refuse(); await refresh();
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#conversation").hidden'), true);
|
||||
+ assert.equal(await b.evaluate('document.body.classList.contains("has-conversation")'), false);
|
||||
+ assert.equal(await count('#conv-log > *'), 0);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#conv-form").checkVisibility()'), false);
|
||||
+ assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#board-refused").hidden'), false);
|
||||
+ brd.one(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===2');
|
||||
|
||||
// Keyboard on the board: down moves between rows, Enter opens, Esc closes and returns focus.
|
||||
await b.evaluate('document.querySelector("table.sessions [data-open]").focus()');
|
||||
Reference in New Issue
Block a user