feat(headless): ps-qa drives chuzz, so there is one browser - #37
Conversation
…0-rc.4 local-ui/package.json still asked for @pathscale/ui ^2.5.0, solid-js ^1.9.5 and solid-layouts ^0.1.3, from before the chrome was ported. Those are the peer ranges of a workspace member, so bun resolved them for the whole workspace and installed solid-js 1.9.14 and @pathscale/ui 2.5.0 -- the app has been building against Solid 1 while its own package.json asked for 2.0.0-rc.0. Widening the member's ranges is what makes the app's pins take effect. Also adds the @iconify-json sets. index.css @sources @pathscale/ui, which uses mdi-- tokens internally, and nothing installed them, so those icons resolved to nothing. babel-preset-solid moves to rc.2 behind a caret; the exact rc.0 pin could not take a fix. Verified: typecheck, biome, the 15 frontend tests, a production build, and the built chrome rendering its address bar, title bar and tabs. The scripts/solid-2-boundary.ts workaround stays. Its exit condition is solid-layouts-oxc consulting boundaryFor, which 0.2.2 now does -- but rsbuild-plugin-solid-layouts 0.2.1 carries its own 0.2.1 copy of the validator, and a bun override does not dedupe it. The pair goes when that plugin publishes against 0.2.2.
Nothing here uses daisyUI; the comment described the cascade problem in terms of a package this repository does not install.
A patch release: no exports added or removed against 2.11.9, and the same peer range. The caret already admitted it; this pins the lockfile to it.
`scripts/render-check.sh` has documented and exported CHUZZ_CAPTURE_WIDTH and CHUZZ_CAPTURE_HEIGHT since it was written, and the capture entry point read neither: it passed a literal 1440 by 960. Every capture was that size whatever the caller asked for, and a run at another width produced a byte-identical tree, which is how this surfaced. That matters now because captures are about to be compared against a reference browser. Two engines laying the same page out at different widths disagree on every percentage width, every centred box and every responsive breakpoint, so an unhonoured viewport turns a diff into noise that reads exactly like a rendering fault. Only CHUZZ_CAPTURE_SCALE was wired up, so the fix follows its shape.
The prefetch walks the parsed HTML and loads every script it names. A page that builds a `<script>` from JavaScript asks for a URL nobody knew about until the page was already running, and that request reached `DefaultScriptFetcher`, which serves `file:` and `data:` only. The script was dropped with `unsupported URL scheme for script: https`, which reads like a policy decision rather than the missing capability it is. Measured over a hundred-site corpus, this is the single most common engine defect: **26 sites**, a quarter of the corpus. It hides well, because scripts the parser found load perfectly, so a page fails only in the parts it assembles itself, and jQuery going undefined on seven sites looks like its own bug rather than a consequence of never having been fetched. `ScriptFetcher::fetch` is synchronous, because classic scripts execute in document order, so the network call blocks. It runs on the page's own provider and so keeps the per-origin connection cap the rest of the loads obey, and it is bounded by a timeout: a server that accepts and never answers would otherwise hang the capture, and a missing script is better than a run that never ends. Verified by re-capturing the six worst-affected sites: dropped scripts went 12, 6, 4, 2, 2, 2 to zero everywhere. Rendering barely moved, which is the honest result rather than a disappointing one. On one site the error count rose from 9 to 14 with a new kind, `unescape is not defined`: the scripts now run and reach the *next* missing global. This unblocks a layer; the missing web APIs behind it are what turn that into pixels. The test serves a script from a real socket and asserts a synchronous fetch completes a round trip from inside the runtime driving the page. Its accept loop has a deadline rather than blocking: restoring the defect makes no connection at all, and a blocking accept hangs the run instead of failing it. Confirmed by restoring the defect, which fails the test in ten seconds. Only the capture path. `browser.rs` has the same fetcher and is reached from a synchronous poll with no runtime handle to hand, so it needs a stored handle and is left for its own change.
The previous change fixed the capture path and left the window with the same defect, so the browser people actually use still dropped a quarter of the corpus's scripts while the tool measuring it did not. Measuring correctly is not the point of the exercise. The fetcher moves into `script_fetch.rs` and both paths share it, rather than the window growing a second copy of logic that must then be kept in step. The prefetch-then-fall-back shape was already duplicated between the two; it is now in one place. The deadline is per caller, and the two differ for a reason worth stating. `ScriptFetcher::fetch` is synchronous and page scripts run on the UI thread, so in the window this blocks everything, other tabs included, for as long as it waits. Five seconds there against the capture's ten: a capture is unattended and a dropped script costs it the fidelity it exists to provide, while a window has someone watching the frame. Neither number is the real answer. The real answer is an asynchronous script-loading path in the engine, which would not have to choose, and that is a change to blitz-script rather than to this. The window path is the same code the tests cover and the capture exercises, but it is not itself verified in a window: this machine has no screen access, and the control socket sees the chrome document rather than the page's sub-document. Re-captured after the refactor to confirm the tested path did not move: dropped scripts still zero on the worst-affected site.
Most of the web fetches its own content, so an engine without these does not render a slightly incomplete page: it renders the shell and stops. In a hundred-site corpus `XMLHttpRequest` was missing on 6 sites and `fetch` on 4 by name, and the pages laying out at 37% and 44% of a reference browser's height are the same fact counted another way. `blitz-script` has no way to register a host function, which is why this looked like an engine change. It is not. Three things it already exposes are enough: `window.ipc.postMessage` carries a string from JavaScript to the host, `eval` carries one back, and `add_poll_hook` runs work on the document thread. The shim parks a promise and posts the request; the handler spawns the real fetch on the page's own provider, so it obeys the same per-origin connection cap as every other load; the poll hook hands the answer back by evaluating a call to the resolver. Nothing blocks, and that is the difference from the script fetcher landed earlier. A `<script src>` is synchronous because the HTML spec says scripts execute in document order, so that one has to block and its deadline is a compromise. `fetch` is asynchronous by definition, so this can honour it: the window keeps painting and the answer arrives on a later poll. The deadline exists only so a server that never answers cannot hold a connection forever. Both `set_ipc_handler` calls already in the tree are no-ops on the *chrome* document, so nothing was competing for the channel. The tests are end to end rather than unit: a real page, a real socket, a promise resolved through the whole bridge, asserting the page read `42` out of the JSON and the body out of the XHR. Their accept loops carry deadlines, so a regression that never issues the request fails rather than hanging. Response headers are not carried yet, and `setRequestHeader` is accepted and ignored: a page that only sets an Accept should not throw, and a page that depends on reading headers back is not yet served. Sync XHR is not supported.
Ranked by sites affected over a 104-site corpus, after the runtime-script-fetch fix stopped scripts being dropped and let more of them run far enough to reach these: `Image` (4 sites), `TextEncoder` (2), `AbortController` (2), and `ResizeObserver`, `Path2D`, `ShadowRoot` and `unescape` (1 each). Real implementations, with nothing invented: - `escape` / `unescape`, the Annex B pair. Pure string transforms with a specification, so there is nothing to fake. - `TextEncoder` / `TextDecoder`, real UTF-8 both ways, including surrogate pairs, unpaired surrogates as U+FFFD, and overlong sequences rejected. The callers that reach for these are hashing or framing bytes, where an encoder that got the multi-byte cases wrong would hand back a plausible array of the wrong length and fail somewhere else entirely, as a bad digest. - `AbortController` / `AbortSignal`, including `abort`, `timeout` and `any`. The whole of it is bookkeeping over a flag and a listener list, with no engine support to wait for. - `String.prototype.substr`. Also Annex B, also absent, and this one is not on the corpus list and cannot be: the report counts names a page looked up and did not find, and a missing method on an existing prototype raises `TypeError: not a callable function` instead, an error class counted nowhere. It surfaced from writing `unescape` in terms of it and watching that throw. Stubs, each labelled as one in the file: - `Image` reports every image as loaded, asynchronously, without fetching. Most constructed `Image`s are preloaders that only need the callback. Code that waits for the load and then reads pixels or natural dimensions gets nothing, and the zero dimensions are left honest rather than invented for that reason. Images the document references are still fetched and painted by the engine. - `ResizeObserver` never fires, unlike the `IntersectionObserver` above it. The difference is what an invented entry would have to say: visibility has an answer that is right for most of a page, and a size does not. The only entry this could deliver carries a zero `contentRect`, and a grid that divides by that width computes zero columns and renders nothing. - `Path2D` really accumulates its path; what is missing is a canvas context to read it. - `ShadowRoot` is declared so `instanceof` is answerable and nothing is an instance of it, which is the truthful answer for an engine with no shadow trees. Deliberately still absent, with the reasoning in the file and a test that fails if either appears without real data behind it: - `getComputedStyle` (3 sites). A stub answering '' for every property is worse than the ReferenceError it replaces: today the script throws and stops, which is visible, and with a lying stub it continues, measures nothing and lays the page out wrongly, which reads as an engine bug. - `ReadableStream` (2 sites). A page reaching for it wants incremental delivery, and a stub can only hand over everything at once or nothing. The shim is a JavaScript string in a Rust file that nothing else in the build parses, so a syntax error in it is not a compile error: it is a page that renders as if the shim were absent, on every site. The tests evaluate it the way a page does and read the answers back.
`AbortController` is real now, so the consumer side can use it. `fetch` reads `init.signal`: an already-aborted one rejects without touching the network, and one that aborts later settles the promise with the signal's reason. `XMLHttpRequest.abort` was an empty function and now does the same, firing `onabort` and returning `readyState` to 0. Half of this is honest and the other half is named as what it is not. The request itself keeps running: the host has already spawned it and there is no cancellation channel back, so nothing here closes a socket. What it buys is the observable half, which is the half pages depend on — the promise settles now, and the handler does not run later against a component that has been torn down. `aborting_in_flight_drops_the_answer` asserts exactly that boundary: the server is contacted and does reply, and the page must not see the reply. The tests now install the web-API shim before this one, in the order `browser.rs` and `load_for_capture` both use, because `AbortController` comes from there and this only honours a signal because it does.
…osition The tail of the corpus's missing-globals list, past the table the handover ranked. Three more are honest in JavaScript alone, and the rest are recorded in the file as omissions with the reason, so the next reader does not add them from the report. Real: - `DOMException`. A name, a message and a legacy code, and what pages actually do with one is read `error.name === 'AbortError'`. Adding it also gives the abort machinery the type a browser really throws, so `AbortController`'s default reason is no longer an `Error` wearing the right name. - `top`, `parent`, `self`, `frames`, `frameElement`. There are no frames here, so a document is its own top. Frame-busting code compares `top !== self` and gets `false`, which is correct rather than convenient. - `scrollX` / `scrollY` and their `pageXOffset` aliases, at 0. Honest at load, which is when the scripts that read them run, and the same choice `IntersectionObserver` above already makes: a lazy loader concludes it is at the top of the page and shows what is above the fold. A page that binds a scroll handler and recomputes from these will not see the view move; making them true is engine work. Left out, with the reasoning in the file and a test that fails if any appears: - `NodeList`, `DocumentFragment`, `CharacterData`, `KeyboardEvent`, `HTMLVideoElement`. `ShadowRoot` is declared precisely because nothing in this engine is one, so `instanceof` answering `false` is true. These are the opposite case: the document really does contain node lists and fragments, so an empty constructor would answer `false` about objects that genuinely are instances, and a branch meaning to take the DOM path would silently take the other one. They belong with the engine's DOM bindings, next to the prototypes they have to be related to. - `Intl`. `String(value)` for `NumberFormat` and `DateTimeFormat` keeps a script alive at the cost of rendering unformatted numbers and raw date strings as though they were the page's own output, and the locale data behind a real one is not a shim. - `ActiveXObject`, reported by one site. No browser has it, and a page reaching for it without a `typeof` guard throws in Chrome too. The report is not a defect of ours. - `WebAssembly`, `define` and `require`, which are engine and module support.
… not Real base64, both ways, and the one addition here nothing asked for in advance. Re-capturing the twelve affected sites showed a page fall from 215 nodes to 28, which reads as a regression and is not one: `String.prototype.substr` let its bundle run past the first `TypeError: not a callable function`, far enough to clear the server-rendered markup and rebuild it, and then it hit `atob`. Four of the twelve did the same. A missing global is only counted once something reaches it, so fixing one defect is what surfaces the next, and the low node count was the measurement working rather than failing. `atob` accepts whitespace anywhere and optional padding, which is what a page decoding a header or a data URL relies on, and both throw an `InvalidCharacterError` DOMException on input that is not theirs to decode.
The browser advertises two tools over the same socket. `chuzz-inspect` only ever called `blitz.agent.control`, so `blitz.diagnostics` — DOM and layout snapshots, renderer metrics, idle settlement, and the console and runtime-error streams — was unreachable from the client that exists to read the browser. That gap has a cost. A page that throws during evaluation reports it only to stdout, mixed in with the renderer's own logging, and a JS error is then indistinguishable from a paint trace. Driving honey.id here is what surfaced it: the page rendered blank, and the reason was a TypeError only visible by grepping the process output. `call` keeps its shape and routes to the agent tool, `diagnostics` is the same round trip against the other one, and both share `call_tool`. On top of that the CLI grows `console`, `metrics`, `settle`, `dom` and a verbatim `diag`. Two limits worth stating, both in the runtime rather than here: - `console` answers `streamingUnavailable`: diagnostic subscriptions are not implemented, and the runtime says so rather than pretending. - `dom` reports the chrome window. A page lives in a sub-document on its `<web-view>` mount and is still not in the tree, so page content remains reachable only by screenshot.
ps-blitz published 0.4.0 (PR #79: selectable user agent, the display:contents hoist panic, the @import cross-thread panic). None of it could reach this repository, because a prerelease requirement only matches its own prerelease line: ^0.3.0-beta.6 accepts 0.3.0-beta.* and nothing else, not 0.3.6 and not 0.4.0. That is also why scripts/local-engine.sh appeared to do nothing. Cargo reported 'patch ... was not used in the crate graph' and carried on with the registry copy, so an edit to a local engine checkout changed no behaviour and read as a broken patch table rather than an unsatisfiable pin. tauri-runtime-blitz moves with it. ^0.1.0 does not build on rustc 1.97.1 (unstable str_as_str) and only survived here because a cached artifact existed in target/; a cargo clean would have stranded the repository. 0.3.2 is the release that takes ps-blitz ^0.4, so both sides of the cascade agree and cargo unifies the engine instead of putting two copies in the graph.
tauri-runtime-blitz 0.3.2 reaches endpoint-libs through blitz-control-protocol 0.4, which requires ^3. This workspace asked for ^2.1.5, so the graph carried both 2.1.5 and 3.0.0 — two crates exporting the same MCP framing types, which is the same shape of fault as two engines and fails the same way once a value crosses between them. The manifest already said these should be the same source and feature as tauri-runtime-blitz uses for its agent-control surface. This makes that true rather than aspirational. WireMessage::Text carries Utf8Bytes rather than String in 3.0, so the three send sites convert. Nothing else in chuzz-control moved: the receive sides read through Deref and needed no change.
… engine
Moving the engine to 0.4 without this does not build. `ps-dioxus-native-dom`
0.7.2 was compiled against the older DOM, where an attribute value was a
`String`; at 0.4 it is an `Atom`, so the registry copy fails to compile with two
mismatched-types errors inside a crate nobody here edits:
expected `Atom<EmptyStaticAtomSet>`, found `String`
ps-dioxus-native-dom-0.7.2/src/dioxus_document.rs:127
It is a direct dependency, not a transitive one: this workspace asks for
`ps-dioxus-native` so `apps/chuzz` can select the vello renderer through its
features. The engine bump moved ps-blitz and tauri-runtime-blitz and left this
one behind, which is the whole failure.
**0.7.3 is not published yet.** It exists as a commit in ps-blitz PR #83, so
this range cannot resolve until that merges and releases. Pinning it anyway is
deliberate: the alternative is `^0.7.2`, which resolves and then cannot compile.
A range that is right and waiting beats one that is wrong and immediate.
The scripts that read a corpus run lived in a scratchpad directory that gets wiped, so the ability to repeat the measurement was one cleanup away from being lost. `scripts/render-check.sh` captured the pages and nothing committed could judge what came back. Three tools: `scrub.py` removes site identity from a log or tree dump, `classify.py` turns raw counts into a verdict per site, `compare.py` diffs a capture against a reference browser's boxes. The README carries the things that cost time to learn and are invisible in the code: - an opaque label does not anonymise a capture. Its log names every CDN it fetched from and its tree dump carries class names; one leak survived hostname scrubbing because the site's name was inside a CSS class, which is why `--identity` exists and is not optional. - read the logs, not the exit code. `render-check.sh` exits non-zero for both a watchdog kill and a refused load, and a corpus was once reported as "26 timeouts" that were really 15x403 and assorted 401/429/406/404/400. - node count is not monotonic under improvement. A page that starts working can fall from 215 nodes to 28 because it finally cleared its server-rendered markup and died at the next wall. Compare trees. - `X is not defined` is half the missing APIs. A method absent from an existing prototype throws `TypeError: not a callable function`, which names nothing; that string covered 48 of 104 sites and is the largest unexamined surface in the corpus. No collector is included, deliberately, and the README says why: four separate transports for getting a reference dump out of a browser were tried and all four fail, so nobody should rebuild one.
The engine has been able to select a `User-Agent` since ps-blitz 0.4.0. Chuzz never asked for one, so the default went out on every request: a 2020 Firefox on Linux. I wired this once, reverted it when the engine pins would not resolve, and did not restore it when they moved. The capability shipped and sat unused. `CHUZZ_USER_AGENT` selects `chuzz` (the default, honest) or `chrome`; anything else is sent verbatim so a corpus run can reproduce one specific client. `brave` is accepted and means `chrome`, because Brave sends Chrome's string verbatim and distinguishes itself through `navigator.brave` rather than on the wire. **It does not open the sites I expected, and that is the useful part.** The premise was that this was worth around twenty sites: a hundred-site corpus had that many answering with 403 and no page. Asked again as Chrome, the six tried returned exactly the same status. Those servers are not reading the user agent — TLS fingerprinting, IP reputation, something else. The switch is still correct and worth having, but it is not the lever the corpus made it look like, and guessing again without measuring would waste the next effort too. The probe also caught a fault in my own measurement, corrected in `scripts/corpus/classify.py`: a refusal was matched by `HttpStatus` appearing anywhere in a capture log, which also matches a third-party script returning 403 on a page that loaded perfectly. Two sites with thousands of nodes were scored REFUSED. The corpus stands at 61 rendered and 21 refused, not 59 and 23. Same shape as the "26 timeouts" that were really refusals: a measurement artefact reading as a product defect.
The section is headed "read this first", and everything in it had been fixed since it was written -- so the first thing a new reader took from this file was four bugs to chase that no longer exist. Each was re-run from a fresh fixture rather than assumed: - `overflow: hidden` clips in-flow children now, with and without `border-radius`, under `overflow:scroll` too, and an over-wide text run is cut mid-word at the box edge. Checked with static parents as well as positioned ones, since the original claim was specifically about static children. - Percentage `translate()` centres correctly. The rotated case measures 328x328, which is what `240*(cos30+sin30)` predicts for a 240x240 square at 30 degrees. The no-transform control still fails, so the fixture has teeth. - The `@property` symptom does not reproduce: a registered `<angle>` resolves to its `initial-value` and the gradient stays in its box. Whether it *animates* is a separate question the fixture does not answer, and the entry says so. - `mask-image` exists: `blitz-paint/src/render/mask.rs`. The table is kept rather than deleted so nobody re-opens one of them from memory.
Five gaps in the web API shim, each of which made a working site look broken in a way the console did not explain. - `performance` was absent entirely. `@solidjs/router` reads `performance.getEntriesByType && performance.getEntriesByType(...)`, where the guard covers the call but not the destructure that follows, so every routed page died before its first render. - `matchMedia` answered every query false, including `prefers-color-scheme`, and never parsed a dimension. A responsive layout asking whether it had room for the desktop design was told no. - Nothing reported a viewport. `screen.width` returned `innerWidth || 1440` while `innerWidth` was 0, so a page that asked twice got two different answers and laid out for a phone. - `location.origin` was undefined, though href, protocol, hostname and port were all present. `new URL(path, location.origin)` then returns the path unresolved and fetch rejects it as invalid, which kills a bootstrap inside an async handler with nothing logged. A page that hides itself until that bootstrap finishes stays hidden: a blank white document and an empty console. - `location.host` omitted the port. The sizes are a stated default rather than a measurement: the engine does not expose its own to script, and `innerWidth`, `outerWidth`, the client dimensions and the layout rect all read 0. One constant now feeds `screen`, the `inner`/`outer` pair and the dimension branch of `matchMedia`, because the earlier arrangement had two shims reading each other and recursed without bound the moment both existed.
Three `.pyc` files under `scripts/corpus/__pycache__` were committed. They are build output: importing `classify` to compare two corpus runs rewrites them, so the tree comes back dirty from a read-only analysis and the diff has to be picked through before anything can be staged. Removed from the index and ignored, along with `*.pyc` generally.
Three Python scripts sat in a Rust repository doing work that never needed a second language: bucketing four integers per site, diffing two box dumps, and redacting hostnames from a log. They also came with committed bytecode, so importing the classifier to compare two runs left the tree dirty. The README keeps what was actually worth keeping -- the anonymity rule, and the measurement traps that cost real time to learn: exit codes cannot separate a refusal from a timeout, stale labels in `target/render-check` silently double any glob-based count, and bucket counts move on site-side variance rather than on engine changes. It also says plainly that scrubbing is now a manual step, so the anonymity rule is not left looking automated when it is not.
The modules were declared by `tauri_main.rs`, which made them the windowed binary's. A second entry point could only have its own copy of the tree, and the compiler would then read every module the other binary reaches as dead code: with `-D warnings` in CI, "compiles as a second binary" and "compiles at all" become different questions, decided by which one you ran. Nothing moves. `lib.rs` declares the same modules under the same conditions and both binaries use them, so the engine, the loader and the web-API shim are one tree with two ways in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…site A built single-page application is not a file. support.cafe's dist names its bundle `/static/js/app.mjs`, absolutely, and opened as a `file://` path that resolves to the filesystem root: the bundle is never fetched, the page renders as an empty mount point, and the only evidence is one log line. Its router then owns paths that were never built as files, so `/settings` has to answer with the document. So a directory gets a loopback origin for as long as the process lives. It serves GET and HEAD under one root, falls back to `index.html` only for an extension-less path, and refuses a missing asset rather than handing back the document: a bundle request answered with HTML is reported as a syntax error in a file that was never a script. A file or a URL is still taken as given, because a caller naming one of those has already decided how the page is reached. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ndow `ps-qa` could not reach a single site in the fleet. It drove `qa-inspect-host`, a separate headless browser in ps-observability that built its own document, and the web platform lives here: `document_loader` supplies `URLSearchParams`, `matchMedia`, storage, the observers and `performance.getEntriesByType`, and a host without them blanks every routed page before its first render. Two headless browsers meant every gap closed for the browser had to be closed a second time for the harness, by hand, or the harness measured a browser nobody ships. `chuzz-headless` is the same browser with the window left out. It loads through the same loader and serves `AgentControlServer`, which is the socket `ps-qa` already speaks; `ps-qa` still links no engine, because that constraint was always about the socket and never about the crate boundary. A second binary rather than a flag, because `ps-qa` launches a host by running the path it was given with no arguments and `QA_INSPECT_PAGE` set. Both properties a sweep depends on are kept: one page per process, and the descriptor path on stdout when it is ready. `ScrollIntoView` really scrolls now. The old host served one component on a page that never overflowed, so answering Ack was right by accident; a fleet site scrolls, and a control below the fold that is never brought into view gets hovered at whatever happens to be at its coordinates. The integration test comes from the crate this replaces and covers two things it could not: the page is fetched over a real origin rather than inlined, and the page reports which web APIs answered it, so losing the shim fails here by name instead of somewhere in the fleet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`@solidjs/router` intercepts only its own `<A>`. Every `Button href=` and `Link href=` in the fleet is an ordinary anchor whose activation belongs to the shell, and the headless host had no shell: the click was dispatched, acknowledged, and nothing moved. That reads as a dead control rather than a missing capability, and it is most of the navigation on every site here. A navigation provider now records where the page asked to go, and the loop loads it after answering the request that caused it. After, so the action is timed as itself rather than with a page load added on; a caller inspects again before it asserts anything, and the new document is standing by then. A slot rather than a queue, so two navigations before the loop looks again means the second wins, exactly as a browser behaves. A load that fails leaves the old page up and says so: serving an error document instead would fail every later check against a page that is not the one under test, with the reason four checks back in the log. Measured on support.cafe: clicking "Log in" now leaves the home page and loads /login. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The shim keeps `localStorage` in memory per document, which is right for a capture and wrong the moment the host follows a link. An application reads its settings while it boots, so the next page started with an empty store, that read missed, and the application looked like it had never saved. "Save, go elsewhere, come back" is the shape of most of what is worth checking on a settings page, and none of it was decidable. Both stores are read out of the outgoing document through their public interface -- `length`, `key`, `getItem` -- and seeded into the next one by a prelude the loader evaluates after the shim and before the page's own scripts. That ordering is the whole point: seeding a store a moment later is the same as not seeding it. Keyed by origin, so a link that leaves the site under test lands on a page that cannot see the site's keys. Carrying them across would be a leak the harness invented. It is a copy, not storage. Nothing is on disk, no quota is enforced, and no `storage` event is delivered. What it buys is that the same origin sees what it wrote. The test drives a two-page fixture over the socket and fails with `local=null session=null` without this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Storage now survives a navigation, which was listed here as not done. The shim keeps Both stores are read out of the outgoing document through their public interface — It is a copy, not storage: nothing on disk, no quota, no
|
Every site in the fleet needs the same two tools to run its checks: the driver, which links no renderer and installs from the registry, and the host, which is the browser and is not something a site can install. Thirteen copies of that would drift, and the version floors are the part that matters. A floor rather than "latest" for the driver: a check file can use a field an older driver does not know, and the failure then reads as a broken workflow rather than a driver that is too old. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A loader that fetches its own bundle, wraps it in a `Blob` and injects it as `script.src = URL.createObjectURL(blob)` is a common shape. With no `Blob` the loader threw before it reached the URL, and with a handle that was only a token the injected script pointed at something nothing could fetch. Either way the bundle never ran and the page stayed as its loading placeholder, with nothing in the log to say why. The handle is a `data:` URL now, which the engine's fetcher already resolves, so the script really loads: measured on a probe page, `script loaded ... ran=true` where before there was no `Blob` at all. Both live outside the `typeof URL === 'undefined'` block they used to sit in. The engine registers a real `URL` now, so that block is skipped and `createObjectURL` did not exist at all -- the stub was unreachable code describing a behaviour nothing had. Text only, and the comment says so. The parts are joined as strings and encoded with `btoa`, so a blob built from an `ArrayBuffer` is not represented faithfully. That is the honest limit of doing this in JavaScript and it covers the case that matters: a bundle, a stylesheet, a JSON document. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A pathscale build ships its bundle brotli-encoded under `.mjs` and `.mcss` and lets the CDN answer `content-encoding: br`. The loopback origin did not, so a page that reads its own bundle through `fetch` and hands the bytes to a blob URL got brotli where it expected JavaScript, and mounted nothing. The engine's own asset fetch already survives this: `decode.rs` sniffs the payload. A page's `fetch` does not go through it. Declaring the encoding instead would leave the decoding to whoever asked, and a page `fetch` that never negotiated `accept-encoding` does not decode what it did not ask for. Decoding here is decided by this process, and only for text payloads: `decode_body` leaves anything that is already text alone, so an image or a font passes through untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`fetch("/v.json")` is the ordinary way to ask for something on your own
origin. The bridge parsed the URL on its own, with no base, so a
relative reference with no scheme was a hard error and the page got
`TypeError: fetch failed: invalid URL`. A bootstrap that asks that way
fails at its first step and renders nothing.
The base is the page URL, kept at load time, so a navigation gets the
document it arrived at rather than the one it left.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dispatch loop blocked on the control channel, so the document only advanced inside a request. A page whose bootstrap is a chain of asynchronous steps -- fetch a version, append a script, wait for its `load`, mount -- got as far as the last step that finished before the loader went quiet, and then stopped. That is not a slow page, it is a stopped one, and it reads as a site that renders nothing. The loop now waits with a timeout and gives the page a turn whenever nothing has arrived. Each turn is bounded, so a page that always has work still leaves the loop able to answer; the next turn picks up where this one stopped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A page in a build is no more a file than a whole site is. Named directly, it was opened as `file://`, and its markup references `/static/js/<name>.js` absolutely, which a `file://` base resolves to the filesystem root. The bundle was never fetched and the page rendered as an empty mount point. Measured on the @pathscale/ui component sweep, which names one page per component: every component reported as drawing nothing, with one line in the log saying the script could not be fetched. An HTML document now gets the same treatment a directory already gets: its directory becomes the origin and the page is opened on it. Anything else named directly is still taken as given, because the engine's own fixtures include files that are not documents. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9f0c113 to
6a2fd0b
Compare
Every site workflow sets it in the job environment, and nothing read it until ps-qa 0.6.3: the knob was `--timeout-scale` and only that. So a shared runner ran against the strict local latency contract, and on the sites whose navigation reloads a large bundle that is not a nicety -- arrival took about a second of engine time and the 900ms default gave up, went home, and reported the surface as unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`clippy::collapsible_if`, on the commit two back. The two tests were separate because the second is a `let` binding, and the lint is right that they read as one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6a2fd0b to
9687f1d
Compare
Four sites in the fleet emit their built CSS as `app.scss`. Served as `application/octet-stream` the engine ignored the link entirely, so those pages rendered with no styling at all: every element at the body's default 8px margin, every responsive rule inert, the desktop and mobile halves of a header both visible at once. Nothing failed. A check that asks whether a control exists and paints gets the same answer from an unstyled page, so four sites had been green for weeks against a rendering nobody would recognise. Fixing it immediately turned up a real one: kard.vip's logo assertion required all three breakpoint copies of the link to have a box. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The engine had a constructor that reported a refused connection without ever opening one. That was deliberate and, for its original purpose, right: a dev server's live-reload client runs at bundle top level, and a missing constructor takes the whole bundle down, so an inert socket is the shape those clients already survive. It stops being right the moment a page's own data rides on a socket. Eight of the thirteen sites in this fleet authenticate over the WebSocket handshake, passing the token in `Sec-WebSocket-Protocol`, so with an inert socket no sign-in completes and every check stops at what the page decides for itself. Built on the same seam as `fetch`, because `blitz-script` exposes no way to register a host function: JavaScript posts a command on `window.ipc.postMessage`, work happens on the tokio runtime, and events come back through a mailbox a poll hook drains. `net_bridge` owns the IPC handler, since setting one replaces it rather than adding to it, so it offers each message here first and keeps what this does not claim. Unlike a fetch, a socket outlives its command, which is what the sender map is for. Measured: crates.vip now lists a crate fetched from its live registry through the headless host, and honey.id completes its two-step login against the dev auth instance, with the api backend accepting the resulting token and returning the user. The serve loop also lets the page run after answering a request, not only when the socket falls quiet. A harness waiting on an outcome inspects continuously, so the idle branch never fires and the page it is waiting on never advances. After the reply, never before it: ticking first resolves layout underneath the request about to be answered, and an inspect then reports every box as 0x0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A semantic snapshot of a signed-in page costs upwards of 150ms, and a driver polling for an outcome sends the next one as soon as it has the last. The loop ticked the document once per reply, so the page ran six turns a second while the harness watched against roughly a hundred when it did not. Measured on honey.id: thirty seconds of polling produced 179 turns. A page is not supposed to stop because something is reading it, so the reply now hands back the turns the request consumed. `last_tick` advances by one interval per turn rather than resetting to now, which is what makes it a catch-up, and a cap keeps one slow request from handing the page an unbounded slice before the next one is read. `CHUZZ_TRACE` timestamps the loop and the socket. The question worth asking is not whether a message arrived but when it arrived relative to what the harness was doing, and no snapshot of the tree answers that. It is how the starvation above was measured and how the outcome poll that went blind to the rest of the document was found. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mostly a rename pass, because the chrome had already been through the 2.11 migration. What actually bit: `isOpen` became `open` on the four local components that wrap an overlay (`SettingsPanel`, `SidePanel`, `DebuggingSection`, `Section`). `Disclosure` became `Collapsible`, and the rename reaches the CSS: the stylesheet selected `.disclosure__body-inner`, which 4.0.0 no longer emits, so the inspector body was styling nothing. Checked the other way round too -- `collapsible__body-inner`, `collapsible__content`, `tabs__tab`, `button--sm`, `button--width-square` and `input-control` all still exist, so every remaining selector still binds. The local `Switch` is renamed `OnOffRow`. It shadowed the library component of the same name, which is exactly the shape that hides the 4.0.0 value-callback break: a reader grepping for `<Switch onChange` would have found this one and concluded the site was clean. `TabList` drops a cast on `onSelectionChange` that the 4.0.0 signature makes redundant. The pin moves in three places, because `local-ui/bundle/` is generated except for its `package.json`, which `local-ui/.gitignore` whitelists back in. Not changed, and checked rather than assumed: no `className`, no `is*` state props, no queries, and none of `Switch`/`Checkbox`/`Radio`/`PasswordField` is used, so neither of the two silent-break classes can occur here. The three `color=` sites are `ColorSwatch`/`SurfaceSwatch` literal swatch colours, not the flavour axis. `Input` now defaults to `sm`, but `.navigation-bar .input-control` pins 32px at two-class specificity and wins, so nothing moves. Verified: `bun install` resolves 4.0.0, typecheck passes for both tsconfigs, biome is clean. The gate was proven to have teeth on both layers first -- a bogus prop on `Button` in the app source and on `Input` in `local-ui`, the second rejected inside the generated Layout bundle, so the compiled output is genuinely typechecked and not just the source. Pre-existing and untouched: four render-based tests fail under `solid-layouts@0.2.1` with "Context can only be accessed under a reactive root". Confirmed pre-existing by reinstalling the 2.x line and watching the same four fail.
4.0.0 was published in error and has been unpublished from the registry, so a `^4.0.0` range now resolves to nothing at all. The value-change contract these conversions target ships as 3.1.0. The source is unchanged: it was written against this API either way. Only the number the range asks for is different.
… to the library Three pins in one commit because they are one question: which build of the Layouts toolchain this application and the library it consumes agree on. `@pathscale/ui` was repinned to ^3.1.0 without re-resolving, so the lockfile still named 4.0.0, a version published in error and since unpublished. `solid-layouts-oxc` was pinned at exactly 0.2.1 while the published `@pathscale/ui` 3.1.0 is built with 0.2.3. That is an application compiling against one version of the Layout compiler and importing a library compiled with another, which is not a mismatch anything reports. Typecheck, lint and 15 tests pass on the resolved set.
Half of a two-part bug, and the half that lives here. Closing a tab did nothing: not the close button, not the keyboard shortcut. The transport removes the tab and emits a shorter list -- instrumenting it shows `len= 2` becoming `len= 1` -- and the strip kept showing both. `reconcile(tabs, "id")(draft.tabs)` matches by key and writes the entries it is given. It does not shorten the array it wrote into, so an open landed and a close did not. With the explicit length the store is correct: the same probe now shows the draft going from 2 to 1. The rendered strip still does not shrink, and that part is not fixed here. The `For` over `state.tabs` does not drop a row when the store array shortens. It is not this migration: the same failure reproduces on `@pathscale/ui` 2.12.0, and bumping `solid-layouts` from 0.2.1 to 0.2.3 does not change it either. Leaving the store wrong as well would only make that harder to find.
The default `ShellProvider` refuses both directions, which is right for a provider with no shell to ask and wrong for a page. The usual copy button is `await navigator.clipboard.writeText(t)` followed by the only feedback a clipboard write ever has, so a rejected promise skips that line and leaves an unhandled rejection behind. Under Solid 2 an error that escapes every boundary halts the scheduler outright: a headless run of a page with a copy button was one press away from a frozen application that still paints. In memory and per host, which is the honest scope. Nothing here reaches the machine's clipboard, and a check that reads it back is reading what the page wrote. Found by worktables.dev's designer, whose Copy control could not be verified at all until this existed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…art on Three gaps, all found by driving the fleet's own controls rather than by reading the runtime's feature list. `window.open` did not exist. It is how every "book a call", "view on GitHub" and "open the docs" control in this fleet leaves the site, and with no such function the call threw and took the rest of the handler with it -- the line after it is usually the one that renders the page's own confirmation, so the control appeared to do nothing. It opens no window, because there is none to open; it records what was asked for and returns null, which is what a real browser returns when a popup is blocked and therefore a value pages already handle. `URL.searchParams` threw "not implemented" while `URL` and `URLSearchParams` both worked. Reading it is how a page adds a query to a link it is about to open. consulting.parcle.ai's booking control died on that line, three statements before its confirmation. `QA_HOST_PATH` opens a route directly. A built single-page application has one file and many addresses, and the server already answers them with the document exactly as a production host does; until now the only way to reach such a page was to press whatever links to it, which made every check on a deep route depend on the navigation above it and left a route with no link unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The release workflow publishes when the version changes and decides "changed" against the live `latest.json`, which still reports 0.1.34 from 20 August. So merging this branch at 0.1.34 would publish nothing: the headless host, the `headless-host` composite action and the cask fix would all reach master and stop there. That matters more than usual here. honey.id's QA workflow calls `pathscale/chuzz/.github/actions/headless-host@master`, and that path does not exist on master yet, so its checks fail at setup before they run anything. This branch is what puts the action there.
Every dependency here is a caret range on a published version, and a
committed lockfile is the one thing that can hold such a range still: any
version already recorded satisfies the caret, so cargo never reconsiders
it. That is not a hypothetical. This workspace sat on ps-blitz 0.4.4 and
tauri-runtime-blitz 0.3.6 while 0.4.5 and 0.3.7 were published carrying
fixes chuzz needs, and neither a local build nor CI could see it, because
both pins were still valid. The boa dependency moved on its own only
because its recorded version was 1.0.0-dev, which does not satisfy
^1.0.3. A stale-but-valid pin is the dangerous kind; a stale-and-invalid
one repairs itself.
Removing the lock makes resolution honest. `cargo generate-lockfile` now
picks ps-blitz 0.4.5, tauri-runtime-blitz 0.3.7 and ps-boa-runtime 1.0.5,
and the build fails on ps-dioxus-native-dom 0.7.3, which does not handle
`DomEventData::Submit` after 0.4.5 made that enum wider:
error[E0004]: non-exhaustive patterns: `&DomEventData::Submit(_)`
not covered
ps-blitz master fixes it and never released the crate. That failure was
present the whole time and the lockfile hid it; it now blocks the build
that introduced it, which is where it belongs. ps-blitz#91 publishes
0.7.4.
Also drops what only existed to serve a committed lock:
- The `one-rev` CI job and `scripts/check-one-rev-per-git-source.sh`.
They read Cargo.lock for git dependencies resolved at two revisions.
There are no git dependencies left, so the check has had nothing to
find since the last one became a caret.
- `--frozen-lockfile` on the three `bun install` steps. It means nothing
without a committed bun.lock and would fail the job outright.
`scripts/local-engine.sh` still snapshots and restores Cargo.lock, and
now tolerates its absence: a redirected build still writes one full of
machine-local paths, and the next plain `cargo build` would reuse it
silently.
solid-js 2.0.0-rc.4 depends on `@solidjs/signals: ^2.0.0-rc.4`, and a later
rc satisfies that caret while no longer exporting `patchableRaw`, which rc.4
imports. The removed lockfile had been holding rc.4 in place, so the break
appeared the moment resolution was allowed to move:
SyntaxError: The requested module '@solidjs/signals' does not
provide an export named 'patchableRaw'
An override is the right shape for this. A lockfile pins every dependency to
freeze one of them; the override says which version is actually required and
leaves the rest free to move.
It was a second one. `ps-qa` drove it, chuzz is what ships, and the web platform existed in only chuzz: `URLSearchParams`, `matchMedia`, storage, the observers and `performance.getEntriesByType` are supplied by chuzz's document loader, and a host without them blanks every routed page before its first render. So the harness measured a browser nobody uses, and every gap closed for the browser had to be closed a second time here, by hand. It is `chuzz-headless` now, in pathscale/chuzz, loading through the same loader and the same engine a tab uses and serving this protocol over the same socket. `ps-qa` still links no renderer, and the `core` job's dependency-tree gate still proves it: that constraint was about the socket, never about which repository the host lives in. `--host` was always a path, so nothing in `ps-qa` needed changing to point at it. What goes with the crate is the `host` CI job, which was the one place `ps-qa` drove a live page end to end. That coverage moves to chuzz, where both halves exist, rather than being dropped: pathscale/chuzz#37 carries the fixture and the socket test. The published `qa-inspect-host` 0.1.12 stays on the registry, so the `@pathscale/ui` component sweep keeps working until it is pointed at the new host. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ps-qacould not reach a single site in the fleet. It droveqa-inspect-host, a second headless browser in ps-observability that built its own document, and the web platform lives here.document_loadersuppliesURLSearchParams,matchMedia, storage, the observers andperformance.getEntriesByType. A host without them blanks every routed page before its first render, so the harness was measuring a browser nobody ships, and every gap closed for chuzz had to be closed a second time for QA, by hand.This makes the host a mode of the browser.
chuzz-headlessloads through the same loader, the same shim and the same engine a tab uses, and servesAgentControlServer— the socketps-qaalready speaks.ps-qastill links no engine: that constraint was always about the socket, never about the crate boundary.What is here
modtrees would have made "compiles as a second binary" and "compiles at all" different questions under-D warnings./static/js/app.mjs, absolutely: under afile://base that resolves to the filesystem root, the bundle is never fetched, and the page renders as an empty mount point with one line in the log. A directory now gets a loopback origin, withindex.htmlfor extension-less paths so client routing works and a real 404 for a missing asset.ps-qa sweep-componentsis built on.ScrollIntoViewreally scrolls now: the old host served one component on a page that never overflowed, so answering Ack was right by accident.@solidjs/routerintercepts only its own<A>, so everyButton href=in the fleet is an ordinary anchor whose activation belongs to the shell. Without one the click was acked and nothing moved.Verified
Against support.cafe's built dist, driven by
ps-qaover the socket:find --role button/--role linkauditclick "Switch to light mode"click "Log in"/loginThat last one then found an engine defect, fixed in pathscale/ps-blitz#89: Boa's default 512-frame call limit stops the
/loginrender part-way, with an error the page cannot catch. With that PR the route renders with no errors.tests/serves_inspection.rscomes from the crate this replaces and covers two things it could not: the page is fetched over a real origin rather than inlined by the host, and the page reports which web APIs answered it, so losing the shim fails here by name instead of somewhere in the fleet.Not here
qa-inspect-hostand moving the@pathscale/uicomponent sweep across. That is a ps-observability change and follows this.localStorageis per-document, so a check that saves and then loads another route cannot see what it saved. Worth its own change now that the host navigates.Stacked on #33, which carries the shim this depends on.
🤖 Generated with Claude Code