Skip to content

fix(webui): three crashes the suite could not reach, and a doc that promised a release that does not happen - #30

Open
modacker wants to merge 2 commits into
webuifrom
fix/review-round1
Open

modacker wants to merge 2 commits into
webuifrom
fix/review-round1

Conversation

@modacker

@modacker modacker commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

Base: f56b26f. Found by reviewing the 23 merged PRs that carry no review at all (14 of them self-submitted and self-merged), then clicking through the real UI.

None of the four code fixes is caught by a test. Three were found by hand, and each one replaced the whole app tree with the error page.

The crashes

1 · attachments was the one field the message normalizer missed (#17)

normalizeWebuiClientMessage strips and re-adds ten fields so its guards are authoritative. attachments was in neither list, so it rode through ...remainder and projectMessageAttachments ran a bare for...of over it. Measured on f56b26f:

attachments={"a":1}  -> TypeError: attachments is not iterable
attachments=42       -> TypeError: attachments is not iterable
attachments=true     -> TypeError: attachments is not iterable

#17's title claims the malformed-field crash class is gone. It was gone for ten fields and present in the eleventh.

2 · listing?.entries.length — the ?. stopped one level too early

The ?. guarded listing; .entries was bare. A body with no entries threw Cannot read properties of undefined (reading 'length'). It reads as safe, which is why it survived review. Fixed at the door instead of the use site: the browseWorkspaceDirs result is normalised before it reaches state.

3 · The sign-in panel was stored with no validation at all

getSigninPanel()'s resolved value went straight into setSignin({ panel }) and SigninProgress does [...days].sort(...) unguarded. A body without days threw days is not iterable. The server does validate here (validateSigninPanel throws), so this is defence at the boundary — worth having because the blast radius is the whole UI, not one row.

4 · The forced reconnect never reported the channel down (#26)

reconnectWhenAvailable — tab back into view, network back — cleared socket then called previous.close(). The close handler guards on socket !== ws, always true on that path, so markWebuiEventWatcherDown was skipped and the banner read 已连接 for the whole reconnect window. That file had zero dispatchEvent calls before this.

The doc that was lying

gh secret list is empty — the repo has no NPM_TOKEN secret — so HAS_NPM_TOKEN is false and the publish step never runs. docs/releasing-webui-npm.md still promised 「Publishing is fully automatic」. The job is also not a merge gate (protection is 404) and #15 relaxed the step from failing to a ::warning::, so "no credential" and "published" are the same colour. The doc now says what is true and what turning it back on costs.

The fixture could not open three surfaces

fixture.mjs answered every unknown operation with return {}, which is why 93 specs passed while a human could not get through the app. Added getSigninPanel, browseWorkspaceDirs and a real setPermissionMode — the last one because changePermissionMode writes and then reads back to verify, and no-op write + constant read meant every mode but default raised 「授权模式未能保存,请重试」. That read-back is the correct pattern and is left untouched; the fixture was what could not satisfy it.

Tests

  • message-parts.test.ts — a non-array attachments no longer throws; a real array still renders
  • connection-health.test.ts — a forced reconnect marks the channel down, driven through the online listener
  • recovered-surfaces.spec.mjs (new, 3 browser specs) — the sign-in row, the workspace picker and the permission-mode write all reach the page without tripping the error boundary

Verified locally on f56b26f + this branch

gate result
typecheck:webui server / client / test + root all EXIT=0
test:webui 1753 passed (1750 before, +3 new)
test:capabilities 5181 passed, 17 skipped
test:status-contract 9 passed
Playwright 96 passed (93 before, +3 new)

Built before the browser run, per #14's own finding that the harness only proves the process is right, never the artifact.

Not in this PR

Deliberately left, because they are not bugs in the same sense and each needs a call:

…romised a release that does not happen

Found by reviewing the 23 merged PRs that carry no review at all (14 of them
self-submitted and self-merged) and then clicking through the real UI. None of
the four below is caught by a test; three of them were found by hand, and one
of those three took the whole app down.

## 1 · `attachments` was the one field the message normalizer missed (#17)

`normalizeWebuiClientMessage` strips and re-adds ten fields so its `typeof` /
`Array.isArray` guards are authoritative. `attachments` was in neither list, so
it rode through `...remainder` and `projectMessageAttachments` ran a bare
`for...of` over it. Measured on `f56b26f`:

    attachments={"a":1}  -> TypeError: attachments is not iterable
    attachments=42       -> TypeError: attachments is not iterable
    attachments=true     -> TypeError: attachments is not iterable

#17's title claims the malformed-field crash class is gone. It was gone for ten
fields and present in the eleventh.

## 2 · `listing?.entries.length` — the `?.` stopped one level too early

`WebuiWorkspaceDirectoryBrowser` read `listing?.entries.length`. The `?.`
guarded `listing`; `.entries` was bare. A body with no `entries` threw
`Cannot read properties of undefined (reading 'length')`.

The `?.` looked correct, which is why the render read as safe. The real fix
belongs at the door: `browseWorkspaceDirs`'s result is now normalised before it
reaches state, so a bad body degrades to an empty listing instead of an error
page.

## 3 · The sign-in panel was stored with no validation at all

`getSigninPanel()`'s resolved value went straight into `setSignin({ panel })`,
and `SigninProgress` does `[...days].sort(...)` unguarded. A body without
`days` threw `days is not iterable`.

Here the server does validate — `validateSigninPanel` throws
`Invalid sign-in panel` — so this is defence at the boundary, not a known live
shape. It is worth fixing anyway: the blast radius is the entire UI, and a
malformed panel should cost you the seven-day strip, not the page.

Both crashes above share one root: the transport resolves the server body
verbatim (`resolve(frame.body as T)` in `transport.ts`), so every consumer
inherits the server's shape with nothing in between. The three fixes above
normalise at each consumer rather than at the transport, because only the
consumer knows what a missing field should degrade to.

## 4 · The forced reconnect never reported the channel down (#26)

`reconnectWhenAvailable` — tab back into view, network back — cleared `socket`
and then called `previous.close()`. The close handler guards on
`socket !== ws`, which is therefore always true on that path, so
`markWebuiEventWatcherDown` was skipped and the banner read 已连接 for the whole
reconnect window: the exact silent lie the watcher exists to remove. The path
had no test at all — this file had zero `dispatchEvent` calls.

## 5 · The npm preview package has not been publishing, and the docs said it was

`gh secret list` is empty — the repository has no Actions secret named
`NPM_TOKEN` — so `HAS_NPM_TOKEN` in `webui-npm-preview.yml` is `false` and the
publish step never runs. `docs/releasing-webui-npm.md` still promised
「Publishing is fully automatic: every push to the `webui` branch … publishes a
new preview version」. The job is also not a merge gate (branch protection is
404), and #15 relaxed the step from failing to a `::warning::`, so "no
credential" and "published" are the same colour. The doc now says what is true
and what turning it back on involves.

## 6 · The browser fixture could not open three surfaces

`fixture.mjs` answered every unknown operation with `return {}`. Three real user
paths therefore crashed instead of rendering, which is why 93 specs passed while
a human could not get through the app:

- `getSigninPanel` — no case at all, so the sign-in row threw on every open
- `browseWorkspaceDirs` — no case, so the workspace picker threw on every open
- `setPermissionMode` — a no-op write, while `getPermissionMode` always answered
  `"default"`. `SessionComposer.changePermissionMode` writes and then reads back
  to verify; with this pair every mode but `default` raised
  「授权模式未能保存,请重试」. That read-back is the correct pattern and is left
  alone — the fixture was the thing that could not satisfy it.

## Tests

- `message-parts.test.ts`: a non-array `attachments` no longer throws, and a
  real attachments array still renders
- `connection-health.test.ts`: a forced reconnect now marks the channel down,
  driven through the `online` listener

Verified locally on `f56b26f` + this branch: `typecheck:webui` (server / client /
test / root), `test:webui`, `test:capabilities`, `test:status-contract`, and the
93-spec Playwright suite. The three surfaces that used to crash were also
clicked through by hand in a real browser.
…ntory

`check:source` fails on an unregistered path, which is what turned the three
platform failures on #30 red: `Added: test/webui-browser/recovered-surfaces.spec.mjs`.
The verification job is only the gate that requires the matrix, so it went red
with them.

One line, alphabetical, next to the other browser specs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant