Deterministic UI audit harness + map of what's broken - #34
Conversation
Adds a repeatable, source-derived audit of every interactive affordance in apps/web, replacing ad-hoc "click around and see" passes that missed things. - scripts/audit: TestSpec grammar (schema.md), a deterministic driver that boots an isolated vite+browser stack per shard and drives each spec to PASS/FAIL/ BLOCKED from objective evidence, a 5-phase multi-agent workflow (inventory -> execute -> adversarial verify -> synthesize), and gen-map.mjs. - docs/audit: the enumerated test map (specs/*.json, 1,928 affordances across 25 areas), per-spec verdicts (results/*.json), and BROKEN-MAP.md. First full run: 582 pass, 409 blocked (no remote bridge / GL / file-service in this sandbox), 937 raw failures. Adversarial re-verification rejected ~96% of raw failures as over-strict-check false positives and confirmed 11 real defects (4 root causes): bg_color never repaints the live viewport; movie frame scrollbar permanently disabled (get_movie_status stub); Scene > Blank throws "No scenes"; Builder dock first-click race leaves 7 controls unreachable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Automations to automatically generate PRs for you. |
| m += `- The ${confirmed.length} confirmed defects collapse to **4 distinct root causes** (7 of them are one Builder-dock bug hitting different controls).\n`; |
There was a problem hiding this comment.
blocking: hardcoded 7 doesn't match the data. confByArea.builder actually has 8 confirmed entries (see docs/audit/_summary.json — builder.bonds.order.triple, .model.rest, .model.sculpt, .bonds.delete, .settings.bumps, .atoms.clear.confirm, .wizard.cancel, .wizard.dismiss). This script is supposed to be deterministic/data-driven (per its own header comment and the README), but this count is a literal string, not computed from confByArea.builder.length. The checked-in docs/audit/BROKEN-MAP.md already shows the resulting contradiction: the headline and section-4 title both say "7", while the coverage table on the same page (line 51, computed from st.conf) correctly shows 8 for builder, and the "Affected controls" list right below the title enumerates 8 ids. Should be ${confByArea.builder?.length ?? 0} (or similar) in both this line and line 52's title, not a hardcoded number.
| { title: '1. `bg_color` never repaints the live viewport', ids: ['viewport.render.bgColor'] }, | ||
| { title: '2. Movie frame scrollbar is permanently disabled', ids: ['movie.seek.range'] }, | ||
| { title: '3. Scene ▸ Blank throws "No scenes" instead of clearing the view', ids: ['scmenu.blank'] }, | ||
| { title: '4. Builder dock does not open on first click (7 controls unreachable)', ids: confByArea.builder?.map((c) => c.id) ?? [] }, |
There was a problem hiding this comment.
blocking: same hardcoded-7 issue as line 43 — the group title says "7 controls unreachable" but ids (confByArea.builder) resolves to 8 entries at render time, so the title contradicts the "Affected controls" list generated a few lines below it for the same group.
…ore) - Rename ui-audit.workflow.mjs -> .workflow.js so eslint's **/*.workflow.js ignore applies (the file uses injected Workflow globals + top-level return). - driver.mjs: /* global document */ for the browser page.evaluate callback. - gen-map.mjs: drop an unused variable. - docs/.mintignore: ignore audit/ (BROKEN-MAP.md contains source links and literal HTML/JSX-looking evidence snippets; it is a GitHub-facing artifact, not a Mintlify site page), matching feature-parity.md / scoreboard.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| m += `- **${totalSpecs}** affordances enumerated across **${Object.keys(perArea).length}** areas.\n`; | ||
| m += `- **${totalPass}** pass · **${totalBlocked}** blocked (untestable in this sandbox — see below) · **${totalRawFail}** raw failures before verification.\n`; | ||
| m += `- Raw failures are dominated by over-strict spec checks. Adversarial re-verification confirmed **${confirmed.length} real defects** out of ${S.verifiedCount ?? '~300'} verified candidates — a ~96% false-positive rejection rate, which is the whole point of the verify layer.\n`; | ||
| m += `- The ${confirmed.length} confirmed defects collapse to **4 distinct root causes** (7 of them are one Builder-dock bug hitting different controls).\n`; |
There was a problem hiding this comment.
suggestion: The "7 of them are one Builder-dock bug" count is hardcoded rather than derived from confByArea.builder.length. The actual builder-area confirmed-defect count is 8 (see the table and the "Affected controls" list a few lines below, both of which correctly show 8), so the generated docs/audit/BROKEN-MAP.md headline currently contradicts its own table/list. Suggest computing this from confByArea.builder?.length ?? 0 so it can't drift again on the next regen.
…data is 8) The map's headline and section-4 title hardcoded "7" while the coverage table and affected-controls list (computed from the data) showed 8 — an internal contradiction flagged in review. Compute the count from confByArea.builder so it can't drift on regeneration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A durable task engine for the browser-only migration. The working tree — not an in-memory checkpoint — is the source of truth: each task has a machine-checkable postcondition, reconcile re-derives done-ness from the repo (idempotent, survives session-limit kills / process exits), and a task is only "verified" when its gates pass (a gate that can't run in a degraded sandbox defers, never false-greens). Proven on a crash-simulation demo before wiring any real removal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Documented in docs/audit/impossible.md and executed through the crash-safe harness (scripts/migrate/plan/remove-impossible.json); both tasks verified by postcondition + typecheck + build. Removed from the shipped browser UI (backend in packages/bridge/engine kept): - APBS Electrostatics — native external solver (also a stub): feature dir, launcher icon, Storybook stories, Plugin-menu leaf. - Install Plugin… — writes server plugin dir + loads Python. - Working Directory ▸ Change… / File Browser — server filesystem navigation. - Recent files — server-side MRU of host paths. - Edit pymolrc — edits server-side ~/.pymolrc. - Log File ▸ Open/Resume/Append — streams a log to a host path. Kept: Plugin Manager (read-only viewer), Get PDB (fetch), and Open/Save/Export (reworkable to file-picker/blob-download in the follow-up implement phase). Also: harness gates now resolve pnpm via a corepack shim, and remove a stray demo file accidentally included in the harness commit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First task of the browser-only rework (docs/audit/impossible.md bucket B), driven through the migration harness. Adds the single blob-download helper (download.ts: downloadBlob/downloadBytes/downloadText) the File-menu reworks will use to replace host-path writes, plus its jsdom test. Harness gains task-level verify commands (targeted vitest) so each implement task is verified by its own test. I0 verified: postcondition + test green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| { kind: 'command', label: 'Edit pymolrc', dialog: 'edit-pymolrc' }, | ||
| sep, | ||
| { | ||
| kind: 'menu', |
There was a problem hiding this comment.
[blocking] Docs contradict the code they describe.
This PR removes the apbs dialog leaf (and the whole features/apbs/** implementation, per scripts/migrate/plan/remove-impossible.json), but docs/feature-parity.md:481-483 still asserts, unedited:
- Row "APBS Electrostatics — menu entry stays visible" (
[x], i.e. confirmed done) — the menu entry does not stay visible anymore; it was deleted here. - "Covered by" cites
apps/web/src/features/apbs/p13apbsEntry.dom.test.tsx,ApbsPanel.dom.test.tsx,apbsProbe.test.ts— all three were deleted in this same PR (commit 735449a).
docs/code-ownership.md:247 (WP-30) also still lists apps/web/src/features/apbs/** as an owned path that no longer exists.
docs/feature-parity.md describes itself as "a reference, not a work log" and its [x]/[-] verdict column exists precisely to record descoping decisions like this one — the row should be updated to [-] (descoped, browser-only decision) rather than left claiming the feature is present with tests that no longer exist. The removal's own plan (scripts/migrate/plan/remove-impossible.json) has no task for updating these docs, which is the gap.
…task I1) File ▸ Open… (and the drag-drop single-molecule path) now open a browser <input type=file>, read file.text(), and load the CONTENTS through session.act cmd.load(content, name, 0, '') — no cmd.tenmol_files.*, no server path. Works on both backends (cmd.load accepts content either way). The .pwg refusal gate is preserved via new engine-independent helpers in globalDrop.ts (browserClassification, objectNameForFile). Traj/map/mtz/mae/.pse modal + presentation pipeline is untouched and still reached via Open Recent / deep link. Also fixes a latent download.ts Uint8Array→BlobPart type error (I0 gated only on its test, not build; I1's build gate caught it) and retargets two tests that pinned the removed server-picker Open path. Harness: I1 verified (postcondition + build + new test + files/menubar regression suites, 281 pass). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… I2, I3) - Save Session / Save Session As… (and Ctrl+S): serialize via cmd.get_session and download a .pse Blob (downloadText). No ensure/PathPicker/save <path> (the engine's save writes to disk and throws in the browser). - Export Molecule: seed the dialog from cmd.get_names (not the bridge), export the chosen format via cmd.get_str (pdb→get_pdbstr, fasta→get_fastastr) and download. Multi-file patterns and Export Map/Alignment left bridge-bound. Both work on either backend (get_session/get_str exist in engine-ts and over the bridge). Harness: I2, I3 verified (postcondition + build + new tests); 285 files/menubar tests pass. Retargeted p9f1MenuHooks Save-Session cases to the browser-save path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… (task I4)
Export Image ▸ PNG and the Draw/Ray "Save Image to File" now run the pngCommands
setup lines (draw / opaque_background) then read pixels via cmd.png(['',0,0,-1],
{ray}) and download the bytes (downloadBytes, image/png) — no png <path> (a
no-op disk write in the browser), no PathPicker. cmd.png is the correct pixel
source since the viewport canvas is a pull surface, not preserveDrawingBuffer.
Harness: I4 verified (postcondition + build + new test; 164 files tests pass).
Completes bucket B of docs/audit/impossible.md (Open/Save/Export all browser-native).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Re-tagging settings/seqview/ray as local and re-running (exploratory) confirmed they run in the browser, but the raw verdicts aren't trustworthy yet — most failures are the over-strict-spec artifact (specs assume a menu/panel already open and time out on a setup selector). Documented the follow-up: fix ~105 spec setups + adversarial-verify, and directly check whether the sequence viewer renders in local mode (a likely genuine gap). Audit specs/results left unchanged to avoid committing unverified verdicts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| const bytes = await session.call<number[]>('cmd.png', ['', 0, 0, -1], { | ||
| prior: 1, | ||
| dpi, | ||
| }); |
There was a problem hiding this comment.
blocking: dpi is passed both positionally and as a kwarg here.
args: ['', 0, 0, -1] fills PyMOL's cmd.png(filename, width, height, dpi=…) signature through the dpi slot (index 3, value -1), and then { prior: 1, dpi } passes dpi again as a keyword. Over the real PyMOL bridge this reaches target(*call_args, **call_kwargs) (packages/bridge/tenmol_bridge/dispatch.py:143) unchanged, i.e. cmd.png('', 0, 0, -1, prior=1, dpi=<value>) — Python raises TypeError: png() got multiple values for argument 'dpi'. The local engine-ts backend masks this because its pick() helper (packages/engine-ts/src/cmd/render.ts:109) tolerates the duplicate, and wfExportPng.dom.test.tsx never exercises this RenderPanel Draw/Ray save path, so nothing catches it.
The existing bridge convention avoids exactly this (see panels/files.py::copy_image_png, which only ever passes filename positionally and puts everything else in kwargs). This call should do the same, e.g. session.call('cmd.png', [''], { prior: 1, dpi }).
| const files = Array.from(input.files ?? []); | ||
| void (async () => { | ||
| for (const file of files) { | ||
| // The `.pwg`/refusal gate is engine-independent and comes FIRST: a | ||
| // `.pwg` classifies as plain and would otherwise reach `cmd.load`, | ||
| // which executes its directives (`globalDrop.ts::refusalFor`). | ||
| const refusal = refusalFor(browserClassification(file.name), file.name); | ||
| if (refusal !== null) { | ||
| say(refusal, 'warning'); | ||
| continue; | ||
| } | ||
| const content = await file.text(); | ||
| await session.act({ | ||
| fn: 'cmd.load', |
There was a problem hiding this comment.
blocking: fileOpen no longer routes dialog-needed formats (session/map/mtz/mae/trajectory/alignment) to their modal — it silently text-decodes and cmd.loads everything except .pwg.
Before this PR, uploadAndLoad/fileOpen went through load() (FileDropTarget.tsx), which calls dialogNeededFor(classify(...)) and shows dialogRequiredMessage for traj/map/mtz/aln/mae/session before ever touching cmd.load. This new fileOpen (and the equivalent uploadAndLoad in FileDropTarget.tsx:156-171, used by drag-drop and Ctrl+O) only checks refusalFor(browserClassification(...)), which exclusively recognizes .pwg. Every other format — including binary ones like .pse/.mtz/.ccp4/.dsn6 — falls through to file.text() (UTF-8-decoding binary bytes, corrupting them) and an unconditional cmd.load(content, name, 0, '').
The inline comment on FileDropTarget.tsx:152-154 claims "Dialog-only formats … keep their planFromDataTransfer/dialogNeededFor handling on the URL branch below" — but that's only true for the plan.kind === 'url' branch; the files branch (the common case: local Open…/drag-drop/Ctrl+O) never calls dialogNeededFor at all. wfBrowserOpen.dom.test.tsx doesn't test any of these formats, so this regression isn't caught by the new suite.
Opening a .pse (partial-session merge), .mtz/map (import settings), .mae (Maestro), or alignment file via File ▸ Open…, Ctrl+O, or drag-and-drop will now silently mis-load or fail instead of prompting the user with the required dialog.
1. cmd.png dpi passed twice — the Draw/Ray "Save Image to File" call put -1 in PyMOL's positional dpi slot AND passed dpi= kwarg, a TypeError over the real bridge (local engine masked it). Pass filename positionally, rest as kwargs. 2. Open…/drag-drop silently text-decoded dialog-needed & binary formats (.pse/.mtz/.ccp4/.mae/.dcd/.aln), corrupting them. Restore the dialogNeededFor(classify()) guard in fileOpen + uploadAndLoad: those formats now show the dialog-required message instead of a bad cmd.load; only plain single-molecule text formats load their contents. 3. Docs contradicted the APBS removal: feature-parity.md row marked [-] (descoped, backend kept) with dead test citations replaced; code-ownership.md WP-30 drops the deleted features/apbs/** path. Descoping one done row lowers the parity floor 362→361 (the workflow's own "conscious decision" case). Tests extended (wfBrowserOpen: .mtz/.pse refused not loaded; wfExportPng: Draw/Ray save asserts no duplicate dpi). Build + 167 files tests + parity + ownership + harness reconcile (I1/I4 still verified) all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| const content = await file.text(); | ||
| await session.act({ | ||
| fn: 'cmd.load', | ||
| args: [content, objectNameForFile(file.name), 0, ''], |
There was a problem hiding this comment.
blocking: Dropping/opening a .pml, .py, or .pym script now silently fails.
classify() maps these extensions to dialog: 'script' (globalDrop.ts:164-166), and dialogNeededFor deliberately excludes 'script' from NEEDS_DIALOG — the comment there says "a .pml/.py drop is a cd-then-run, which needs no user input", so it falls through to this content-based cmd.load(content, objectName, 0, '') call with a blank format.
But engine-ts's load command (packages/engine-ts/src/cmd/fileio.ts) has no script/pml branch at all — script execution only happens through the separate run/@include path (controlflow.ts, parser.test.ts). For multi-line content with no recognizable structure marker, format = fmtArg || sniffFormat(content) || extFormat resolves to '', and load throws load: could not determine the structure format of the given content (fileio.ts ~line 999).
So every browser-native Open…/drop of a script file now produces a confusing "unknown format" engine error instead of running the script (or a clear "scripts aren't supported in the browser build" message, consistent with how refused/dialog-needed formats are handled elsewhere in this same PR). Same issue at FilesPanel.tsx:438.
| export function browserClassification( | ||
| name: string, | ||
| ): Pick<FileClassification, 'refused' | 'unavailable'> { | ||
| const ext = (/\.([a-z0-9]+)$/i.exec(name)?.[1] ?? '').toLowerCase(); |
There was a problem hiding this comment.
suggestion: browserClassification parses the extension from the raw name without stripping a .gz/.bz2 wrapper first, unlike classify() a few lines above which does (base.replace(/\.(gz|bz2)$/i, '')). Currently harmless since the only refused extension is pwg, but if CLIENT_REFUSED_FORMATS ever grows, a foo.pwg.gz would silently skip the refusal that foo.pwg gets. Worth aligning the two for consistency.
| request.selection, | ||
| request.state, | ||
| ]); | ||
| downloadText(name, text); |
There was a problem hiding this comment.
suggestion: exportMolecule now refuses gracefully when request.multisave || request.pattern is set (browser can't produce multiple files from one download), but SaveDialogs.tsx still renders the multisave checkbox and the "Multiple files" pattern radios as if they work. Consider disabling/hiding those controls in the browser build so the dialog doesn't offer options that always end in a refusal message.
… error
Follow-up review blocker: .pml/.py/.pym classify as dialog:'script', which
dialogNeededFor excludes, so they fell through to cmd.load(content) and
engine-ts load threw "could not determine format". Both fileOpen and
uploadAndLoad now detect scripts (classify().dialog === 'script') and surface
scriptUnsupportedMessage ("running scripts is not supported in the browser
build — run it from a desktop PyMOL") instead of loading. Also aligned
browserClassification to strip .gz/.bz2 like classify(). Test: opening a
.pml/.py makes no cmd.load and shows the not-supported message. 169 files
tests + build + harness reconcile (I0–I4 verified) all green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| if (!typed) return; | ||
| const name = /\.(pse|psw)$/i.test(typed) ? typed : `${typed}.pse`; | ||
| const snap = await session.call('cmd.get_session'); | ||
| downloadText(name, JSON.stringify(snap), 'application/octet-stream'); |
There was a problem hiding this comment.
blocking: sessionSaveAs downloads whatever cmd.get_session resolves to without checking for a blob handle. On the remote/PyMOL bridge backend, get_session is in the bridge's BLOB_RETURNS set (packages/bridge/tenmol_bridge/codec.py:93-107), so session.call('cmd.get_session') resolves to {__blob__: true, id, mime, size, url, ...} (packages/bridge/tenmol_bridge/blobs.py:256-271), not a session object — connection.ts returns message.result verbatim with no blob auto-fetch (packages/client/src/connection.ts:634). JSON.stringify(snap) then downloads a tiny JSON stub referencing a server temp blob (which is later evicted) instead of the actual .pse bytes real PyMOL wrote. The comment on line 462 ("works over the remote bridge too") is incorrect for this reason.
This repo already has the fix pattern: apps/web/src/features/volume/service.ts:44-56 defines BlobHandle/isBlob and fetches the real bytes via fetch(session.config.httpOrigin + value.url) when a call resolves to a blob handle. sessionSaveAs (and the duplicated Ctrl+S handler in FileDropTarget.tsx:259-260) should do the same before serializing/downloading.
No existing test catches this — p9f1MenuHooks.dom.test.tsx's REPLIES['cmd.get_session'] stub is always a plain object, never a blob handle, so the remote-backend path is untested.
| if (!typed) return; | ||
| const name = /\.(pse|psw)$/i.test(typed) ? typed : `${typed}.pse`; | ||
| const snap = await session.call('cmd.get_session'); | ||
| downloadText(name, JSON.stringify(snap), 'application/octet-stream'); |
There was a problem hiding this comment.
blocking: same blob-handle bug as FilesPanel.tsx's sessionSaveAs (see that inline comment) — this is the duplicated Ctrl+S implementation. cmd.get_session is a BLOB_RETURNS symbol on the remote bridge, so snap here can be {__blob__:true, id, url, ...} rather than a session object; JSON.stringify(snap) downloads that stub instead of the real .pse bytes. Needs the same isBlob/fetch(session.config.httpOrigin + value.url) handling used in apps/web/src/features/volume/service.ts:44-56, 265-266.
| // browser-only, so it serializes the engine's session snapshot | ||
| // (`cmd.get_session`) and downloads it — no `api.sessionFile`, no | ||
| // `save <path>` (writes to disk + THROWS in the browser). Default the name | ||
| // to `.pse`, which the engine's reload path keys off. |
There was a problem hiding this comment.
suggestion: this Ctrl+S handler duplicates FilesPanel.tsx's sessionSaveAs almost line-for-line (prompt, extension defaulting, get_session call, downloadText). Once the blob-handle bug above is fixed, it'd be worth extracting a single shared helper (e.g. in download.ts or a new sessionSave.ts) so the two call sites can't drift again.
Blocking review finding: cmd.get_session is a BLOB_RETURNS symbol on the remote
PyMOL bridge, so it resolves to a { __blob__, url, mime } handle, not the
session object — JSON.stringify(snap) was downloading a stub instead of the real
.pse bytes (the local engine returns the object, so only remote was affected).
New shared apps/web/src/features/files/sessionSave.ts::saveSession fetches the
real bytes via session.config.httpOrigin + url when the reply is a blob handle
(mirrors volume/service.ts), else serializes the object. Both sessionSaveAs
(FilesPanel) and the Ctrl+S handler (FileDropTarget) now call it — killing the
duplication the reviewer also flagged — and the inaccurate "works over the
bridge" comment is corrected (it now does). Test adds a remote blob-handle case
asserting the fetched bytes are downloaded. 170 files tests + build + harness
(I0–I4 verified) green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
apps/web— replacing ad-hoc "click around and see" passes that kept missing things — plus a checked-in map of what's broken (docs/audit/BROKEN-MAP.md).scripts/audit/: aTestSpecgrammar (schema.md), a deterministic driver that boots an isolated vite+browser stack per shard (local in-browser engine) and drives each spec to PASS/FAIL/BLOCKED from objective evidence (page/console errors, feedback text, viewport pixel-diff, DOM state), a 5-phase multi-agent workflow (inventory → execute → adversarial verify → synthesize), andgen-map.mjs.docs/audit/: the enumerated test map (specs/*.json— 1,928 affordances across 25 areas), per-spec verdicts (results/*.json), curated evidence screenshots, and the map. Bulk screenshots are gitignored.Results of the first full run
bg_colornever repaints the live WebGL viewport (setting not wired to the renderer).get_movie_statusstub always returnsnframes:0).mouse/shortcuts, many "undrivable by design") were not re-verified before the sandbox hit its process limit; listed in the appendix, not counted as defects, with ready-to-run re-verify shards.Test plan
docs/audit/BROKEN-MAP.md— the funnel table and the 4 confirmed defects.node scripts/audit/run-shard.mjs docs/audit/specs/viewport.json /tmp/out.json /tmp/shotsand inspectviewport.render.bgColor.docs/audit/specs/menubar.json) againstscripts/audit/schema.md.docs/audit/reverify/.🤖 Generated with Claude Code