mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
test(mobile): certify the browser pane's golden families and render it in a page (OTA phase C, C6.5) (#21777)
* test(mobile): pin the browser pane's golden families The half pin for C6: 4 families, 15 goldens, every verdict the one C2's rule predicts. Measured per family with vitest `-t` over the full 787-golden corpus, with C1's 103 reproduced golden-for-golden as the control: 6 byte-identical, 9 result-absent-settlement. No composed `c6-page-closure.ts`: a composed table is pinned against a route and the browser is a pane, so C7's route is what composes this with C1's. The derivation census does not wait for that route. `mobileWebAppRoute- Closure` becomes one case of `mobileWebAppModuleClosure`, which takes any entries, so the pane's own closure can be read from the module. Two cases: the pane alone reaches exactly the pinned four, and the pane beside `app/h/_layout` adds exactly those four and no other, with the layout reproducing C1's 22 as the control for the difference. Closure at this base: 48 local modules alone, 34 beyond the layout, 30 under `src/browser` and four through the web siblings. The design said 23, all under `src/browser`; it was measured before C6.2 and C6.3 added those siblings, so the pin carries the re-measured number. `browser.screencast` has no golden at all, so this certifies the input path and says nothing about the frame path. Red first: with `browser.wheel` dropped from the table, both census cases fail naming the missing family; restored, the file's 10 cases pass and the parity suite reports "15 goldens in 4 families, 6 byte-identical". Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the frame budget against the shell's real frame Ruling 2's pin. `binaryEventEnvelopeBytes()` sizes the mobile view's device scale from a skeleton it builds itself, and until now its only check was another skeleton of the same shape in the same file: two copies of one assumption agreeing with each other. This measures the real thing. A frame with CDP's nine metadata fields and a real `Page.screencastFrame` timestamp, encoded by C6.1's `encodeBridgeScreencastFrame` and serialized by the real `BridgeHostSubscriptions`, posted through the host harness: 303 bytes besides the image, against a bound of 516. Held above is not enough on its own — 213 bytes of slack is room for the shell to grow the envelope by a field the page never hears about — so the bound is reconstructed exactly instead. Every byte of that slack is a number this frame prints narrower than a double can; adding those back gives 516 on the nose. The budget cases run a generated noise image at the budgeted scale, not a committed fixture: the worst case is the image JPEG compresses least, and a photograph sits a tenth of the way to it. 901,161 px at 0.545 bytes per pixel is 491,132 bytes, which the shell posts at 654,857 of the 655,360-byte cap. One envelope more and the shell drops it, which is ruling 1 read from the budget's side. Red first, two ways. Drop the metadata widening from the bound and three cases fail, the sharpest being the real shell answering the frame the page thought it could send with zero posts. Add a field to the shell's own envelope and the reconstruction fails at 516 against 548, where the existing suite stays green on all 14 — which is the drift this file exists for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): record the measured frame bytes, correctinge7cd24ef10The previous commit message says the shell posts 654,857 bytes for a frame at the budgeted area. That number was not measured; I wrote it from the budget arithmetic instead of reading it off the harness. The measured value is 655,147, which is 213 under the cap rather than 503. Nothing in the assertions changes — they compare against the cap and the bound, never against a literal — but the figure now lives in the file where it was measured rather than only in a message that has it wrong. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): render the browser pane in a page and paint a real frame The only place C6's whole frame path runs. Every other check reads one half: the shell suites drive the host with no page, the page suites drive the hooks with no shell, and the parity pin certifies the input path from a recording. Ruling 4: no route is added. `bundleMobileWebApp` already takes an `appDir`, so this builds a one-route tree of its own and nothing under `mobile/app` moves. The shell double grows a screencast lane to serve it: it accepts a subscribe, posts `event.binary`, and prices each frame the way `BridgeHostSubscriptions` does, so an over-cap frame is dropped where the page can watch the stream survive it. Six cases: the pane subscribes with `wantsBinary` and paints the frame it is handed; a second frame flips the double buffer; an over-cap frame is dropped and the next one paints on the same subscription; the grant withheld produces the update-the-app copy and no subscribe at all; a tap issues one `browser.mouseClick` at the centre of the source viewport; and no request leaves the bundle's own origin. Measured. The frame the pane asks this viewport for is 390x698, which as noise is 201,924 base64 characters. The phone's mobile-mode frame is 780x1424 and encodes to 811,168, which is 124% of the cap and the reason the area budget exists; the over-cap case uses 2400x2160 at 3,761,580, 574% of it. The tap maps to (194, 356) against a 390x712 source, one device pixel off centre because the rendered width is 382.33 CSS pixels for 390 source pixels. One finding, recorded rather than fixed because it is not the pane's. The page files a CSP `script-src` violation on every load, on any route: Zod 4 feature-detects its compiled path with `new Function('')`, the shell's `script-src 'self'` blocks it, Zod catches the throw and takes the interpreted path. The page is correct and the report is filed anyway. One case names it so a second `eval` is visible, and every other case asserts no violation beyond it. Red first, twice, both by reverting behaviour C6.2 landed. Stub out the decode probe in `whenBrowserFrameDisplayable` and the flip case fails on two identical frame digests. Point `updateBrowserImageSource` at the host element instead of the surface child and the paint and flip cases both fail. The first case's comment is corrected by the first of those: it claimed a visible layer proved the decode-then-flip, and the frame still paints with the probe gone, so the flip case is what proves it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): sweep the worst-case JPEG cost instead of taking one point The reviewer is right, and it is worse than the report says. At 0.545 bytes per pixel, 90 of 143 viewports posted a frame over the cap, and 59 of the 111 the budget claims to fit were dropped outright by the real shell — 390x712 at scale 1.8 among them. The old number came from one 2400x2160 frame. A single large frame is the cheapest per pixel in the whole range, so a worst case measured there is not a worst case anywhere else. Swept 143 viewports, widths 320 to 1400 and heights 480 to 1600, each encoded by Chromium at the scale the real budget picks for it. Across the 111 the budget fits, the cost ranges 0.54470 to 0.55351 bytes per pixel. The constant is now 0.56: that maximum plus 0.00649, about 1.2%, for the encoder version it was not swept on. The docstring carries the sweep, the range, the margin and the date. 0.56 is a fixed point, not a guess. Raising the constant shrinks the budget, which lowers the scale, which moves the cost; 0.555, 0.56 and 0.565 all leave the same 31 viewports over the cap, and every one of those sits at the scale floor of 1, where the module already declines to go blurrier and C6 ruling 1's drop rule is the protection. The new test asserts both halves: nothing the budget fits goes over, and the largest viewport it cannot fit is dropped by the real shell. The sweep lives in `config/scripts` because it needs Chromium: the frames are CDP screencast frames, so Chromium's encoder is the oracle and a Node JPEG library would calibrate against the wrong bytes. It drives the real budget, the real scale function and the real `BridgeHostSubscriptions`, and runs in about 4 seconds. Ruling 2's block in `browser-screencast-budget-at-the-shell.test.ts` now says plainly what it measures. It feeds `noise(area * theConstant)`, a byte count the constant itself produced, so it can falsify the expansion and the drop rule but never the constant. It read as if it validated the worst case, and it did not. Red first: put 0.545 back and the sweep fails with 59 viewports, each naming its scale and reporting `null` — the real shell dropping the frame rather than posting it over the cap. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): turn Zod's JIT probe off for the page, before any module The page filed a CSP `script-src` violation on every load: Zod decides whether it may compile by constructing `new Function('')` and reading the throw as "no JIT here", the shell's `script-src 'self'` is exactly that throw, and the browser reports it before Zod catches it. Zod's own source gates the probe on `jitless` for this case. `z.config({ jitless: true })` at the entry does not work, and the reviewer's suggestion of putting it there was measured losing the race. `$ZodObject` reads `allowsEval` when a schema is constructed, not when one is parsed, so the first module-scope `z.object(...)` in the bundle fires the probe — and esbuild evaluates the chunk holding zod and its callers before the chunk holding any module of ours that imports zod. A Function-constructor trap in the page put the call under `new ZodObject` ahead of the entry's first statement. `globalConfig` is `globalThis.__zod_globalConfig`, which zod adopts with `??=` rather than replacing, so the banner can set the flag before any module runs. That is where it now lives, beside the `process` shim and under the same `MOBILE_WEB_APP_SHIMS` contract, which asserts it is applied. Nothing is lost: the compiled path was never reachable in a page under this policy. The render check's `newCsp()` filter is gone. It dropped violations by `blockedURI === 'eval'`, which would have hidden a real one, and every case now asserts zero. The first case walks load and first paint, which is where the second of the two reports fired. The dead `violations` array is deleted. Red first: blank the banner constant and four of the six cases fail, each naming a `blockedUri: 'eval'` the filter used to swallow. Finding, not fixed here and reported instead: the page bundles two copies of zod, mobile's 4.4.3 and the repo root's 4.5.4, because `src/shared/zod-salvage.ts` resolves upward. That is 808 KB of duplicate source. Aliasing `zod` to one copy in the builder fixes it and was measured working, but it changes which zod shared code runs in the shipped page, which is a call to make on its own rather than inside a CSP fix. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): give the shell double the whole canCarry rule and the acks The double reproduced one arm of `BridgeHostSubscriptions.canCarry`, the message cap, and silently carried anything the other two would have refused: a window already holding its maximum frames, and a window whose bytes the frame would push past the limit. It also ignored the page's `ack` frames, so its window never reopened — which was invisible only because no case streamed far enough to close it. Both arms are in now, and the `ack` arm consumes the page's acks exactly as the host does. The three caps are read out of `bridge-caps.ts` and `bridge-host-subscriptions.ts` rather than retyped, the same way the harness already reads the protocol version and the CSP, so a double carrying a stale number is not possible. The render check's own `640 * 1024` is gone with them. One case for it: thirty frames of about 200 KB, roughly 6 MB through a 4 MiB window, nothing over the message cap, so a drop can only come from the window. Every frame posts, nothing is dropped, and the page's ack seqs are read back to show the window stayed open because the page acked rather than because the double was generous. The file docstring said the double answers no RPC. It serves a screencast stream now, so it says that instead, and says what it still is not: it decides no domain behaviour. The dead `violations` array is gone, folded with the CSP commit. Red first: make the `ack` arm inert, as it was before this commit, and the case fails with `Set{'posted','dropped'}` against `Set{'posted'}`. A first attempt at that mutation left the byte subtraction in place and stayed green, which is the mutation being wrong rather than the case. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * ci(mobile): run the whole mobile-web-app family, not a list that goes stale The `mobile_web_app` job hand-listed ten files. The render check this chain added was not among them, so it would have skipped in CI — and it was not the first: three landed censuses were already unlisted, and their closure blocks only run with `ORCA_MOBILE_WEB_APP_DEPS_REQUIRED=1`, so they are green in the sharded `test` job whether or not they ever ran here. Nobody could see it. The list is now two vitest filename filters, `config/scripts/mobile-web- app-` and the one builder test outside that prefix. Quoted, because vitest matches a positional as a substring against the discovered files rather than expanding a glob: `mobile-web-app-*.test.mjs` finds nothing, and it fails by reporting no test files rather than by running fewer. Both forms were tried before this one was written. It runs 18 files and 205 cases, against 10 files before. With mobile dependencies absent, 111 of those 205 skip, which is the measure of what only this job runs. Per file, cases CI has never run: browser-pane-render 7 of 7 (this chain) source-control-external-links 9 of 9 source-control-keyboard 6 of 6 source-control-text-inputs 6 of 19 (C4.2) frame-budget-sweep 4 of 4 (this chain) route-manifest 2 of 17 Two more files the filter adds run fully in the sharded job already and change nothing here: `browser-pane-text-inputs` (C6.4 — it censuses a hand-written closure and never bundles, so unlike the report it was not skipping) and `external-link-seam`. The sweep is renamed into the family for the same reason. As `mobile-browser-frame-budget-sweep.test.ts` it matched neither the job's filter nor `pr-code-change-scope.mjs`'s `config/scripts/mobile-web-app-` prefix, so a change to it alone would not have run the job that runs it. It is also gated on the dependency check now: it needs no react-native-web, but it launches Chromium, and that flag is what tells the job with a browser from the one without. Unguarded it would have failed the sharded `test` job outright. That filter is a prefix match. `config/scripts/mobile-web-app-` and `mobile/src/` both fire this job, and `.github/workflows/pr.yml` is in GLOBAL_FORCE_PREFIXES, so this commit runs everything. Also: `postedFrame` in the ruling-2 pin and in the sweep both reached the binary lane through `?.`, so a subscribe that opened no stream read as zero posts — indistinguishable from a dropped frame, which is the verdict both files are about. They throw now. The render check's restated `640 * 1024` went with the window caps in c73b405f81; the cap is read from `bridge-caps.ts`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * ci(mobile): record what the mobile-web-app job costs to run The filter that replaced the hand list runs 18 files where the list ran 10, so the step's cost is now a function of what anyone names into the family rather than of what a reviewer remembered to add. Measured on this machine: 25-30s wall for the whole step, of which the frame-budget sweep is 2.5s. The sweep is the one part whose cost is a choice. It encodes 111 noise JPEGs in Chromium, one per viewport the budget fits, so adding rows to that set is a decision about this job's runtime and the comment says so where someone would make it. Found, not fixed, and reported for its own PR rather than folded here: the page bundles two copies of zod, mobile's 4.4.3 and the repo root's 4.5.4, reached through `src/shared/zod-salvage.ts`, which resolves upward while `mobile/src/` resolves to mobile's. That is 808 KB of duplicate source and two module instances in the shipped page. Aliasing `zod` in `mobileWebAppBuildOptions` fixes it and was measured working during this chain; it is reverted and stays reverted, because it changes which zod shared code runs in the page and that is not a call to make inside a CI commit. The CSP fix in71254ab3a9does not depend on it: `globalConfig` lives on `globalThis`, so the banner covers both copies. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): load the frame-budget sweep's mobile modules after the dependency guard vite transforms every file under mobile/ against mobile/tsconfig.json, which extends expo/tsconfig.base.json; the sharded test job installs no mobile dependencies, so the sweep's static imports failed the file at load before describe.skip ran. Type-only imports stay static; the values load in beforeAll behind mobileWebAppDependenciesPresent(). Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): certify the frame budget at the quality the pane ships Round 2: the sweep and the render check encoded fixtures at a retyped 0.72; both now read BROWSER_FRAME_QUALITY (the sweep from the module, the render check through the harness reader), so a quality change fails the certification instead of leaving it green. Every render case now asserts zero CSP violations; the sweep pins the 32 viewports left at scale 1; three references to a renamed file and a file that never existed are corrected; a shim count comment is made count-agnostic. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): assert the frame-budget sweep against the constant, not the measured maximum The margin above the measured 0.55351 is what an encoder drift is allowed to spend; pinning the measurement made a drift inside the margin fail a budget that still held. The number stays in the docstring as the sweep's record. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): move the sweep's measured-maximum note beside the assertion it explains Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
+12
-10
@@ -704,21 +704,23 @@ jobs:
|
||||
# The bundling tests skip themselves where mobile dependencies are absent, which is how they
|
||||
# stay green in the sharded `test` job. This is the job that installs them, so here a missing
|
||||
# install has to fail rather than skip everything the job exists to run.
|
||||
# Why a prefix and not a file list: the list this replaces had gone stale twice without
|
||||
# anyone noticing, because a census whose closure block skips without the env flag below is
|
||||
# green in the sharded `test` job whether or not it ever runs here. The prefix is the same
|
||||
# one `pr-code-change-scope.mjs` fires this job on, so naming a test into the family is all
|
||||
# it takes to have it run. Quoted because these are vitest filename filters, matched as
|
||||
# substrings against the discovered files, and the shell must not touch them.
|
||||
#
|
||||
# Cost: 18 files in 25-30s wall, of which the frame-budget sweep is 2.5s. That sweep encodes
|
||||
# 111 noise JPEGs in Chromium, so it is the one step here whose cost grows with its viewport
|
||||
# set; adding rows to that set is a decision about this job's runtime.
|
||||
- name: Builder, override census and render checks
|
||||
env:
|
||||
ORCA_MOBILE_WEB_APP_DEPS_REQUIRED: '1'
|
||||
run: |
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
config/scripts/build-mobile-web-app-bundle.test.mjs \
|
||||
config/scripts/mobile-web-app-web-overrides.test.mjs \
|
||||
config/scripts/mobile-web-app-render.test.mjs \
|
||||
config/scripts/mobile-web-app-drawer-render.test.mjs \
|
||||
config/scripts/mobile-web-app-agent-history-render.test.mjs \
|
||||
config/scripts/mobile-web-app-tasks-render.test.mjs \
|
||||
config/scripts/mobile-web-app-tasks-external-links.test.mjs \
|
||||
config/scripts/mobile-web-app-files-external-links.test.mjs \
|
||||
config/scripts/mobile-web-app-files-render.test.mjs \
|
||||
config/scripts/mobile-web-app-page-closure-families.test.mjs
|
||||
'config/scripts/mobile-web-app-' \
|
||||
'config/scripts/build-mobile-web-app-bundle.test.mjs'
|
||||
|
||||
cross-version-wire:
|
||||
name: cross-version wire compatibility
|
||||
|
||||
@@ -54,6 +54,13 @@ export const MOBILE_WEB_APP_SHIMS = [
|
||||
name: 'process-banner',
|
||||
appliesTo: (options) => options.banner?.js?.includes('globalThis.process ??=') === true
|
||||
},
|
||||
{
|
||||
// Zod probes for a usable JIT with `new Function('')`, which the shell's CSP reports even
|
||||
// though Zod catches the throw and runs interpreted. Turned off before any module, because a
|
||||
// schema constructed at module scope reaches the probe before our own code can run.
|
||||
name: 'zod-jitless-banner',
|
||||
appliesTo: (options) => options.banner?.js?.includes('__zod_globalConfig') === true
|
||||
},
|
||||
{
|
||||
// lucide-react-native@1.14.0's barrel re-exports LucideProvider from a context.mjs that does
|
||||
// not export it. Metro's loose CJS interop tolerates it; esbuild's strict ESM does not.
|
||||
@@ -111,6 +118,27 @@ const PAGE_ASYNC_STORAGE_MODULE = join(
|
||||
'page-async-storage.ts'
|
||||
)
|
||||
|
||||
/**
|
||||
* Zod's compiled path, off before any module runs.
|
||||
*
|
||||
* Zod decides whether it may compile by constructing `new Function('')` and reading the throw as
|
||||
* "no JIT here". Under the shell's `script-src 'self'` that throw is exactly what happens, Zod
|
||||
* catches it and takes the interpreted path — but the browser files a `securitypolicyviolation`
|
||||
* report first, and it does so on every page load. Zod's own source gates the probe on `jitless`
|
||||
* for this case, so nothing here is a workaround.
|
||||
*
|
||||
* In the banner rather than a module that calls `z.config`, because a module cannot win the race.
|
||||
* `$ZodObject` reads `allowsEval` when a schema is *constructed*, not parsed, so the first
|
||||
* module-scope `z.object(...)` in the bundle fires the probe — and esbuild evaluates the chunk
|
||||
* holding zod and its callers before the chunk holding any module of ours that imports zod. An
|
||||
* entry import placed first was measured losing that race; the banner runs before every module.
|
||||
*
|
||||
* `globalConfig` is `globalThis.__zod_globalConfig`, which zod adopts with `??=` rather than
|
||||
* replacing, so setting the flag on it here is what zod itself reads.
|
||||
*/
|
||||
const ZOD_JITLESS_BANNER =
|
||||
'globalThis.__zod_globalConfig ??= {}; globalThis.__zod_globalConfig.jitless = true;'
|
||||
|
||||
const ROUTE_MANIFEST_PLUGIN_NAME = 'orca-route-manifest'
|
||||
const LUCIDE_PLUGIN_NAME = 'orca-lucide-barrel-provider'
|
||||
|
||||
@@ -210,7 +238,7 @@ export function mobileWebAppBuildOptions(routes) {
|
||||
// script would resolve against the route instead.
|
||||
publicPath: '/assets',
|
||||
banner: {
|
||||
js: "globalThis.process ??= { env: { NODE_ENV: 'production', EXPO_OS: 'web' }, platform: 'web', version: '', nextTick: (fn) => setTimeout(fn, 0) };"
|
||||
js: `globalThis.process ??= { env: { NODE_ENV: 'production', EXPO_OS: 'web' }, platform: 'web', version: '', nextTick: (fn) => setTimeout(fn, 0) };${ZOD_JITLESS_BANNER}`
|
||||
},
|
||||
define: {
|
||||
global: 'globalThis',
|
||||
@@ -341,26 +369,39 @@ const isScriptOutput = (path) => path.endsWith('.js')
|
||||
/**
|
||||
* Every source module one page route reaches, as the builder itself resolves them.
|
||||
*
|
||||
* One definition of "what a page contains", read from `metafile.inputs` — the modules the route
|
||||
* pulls in — rather than from `entryStaticClosure`, which walks emitted chunks and answers what a
|
||||
* browser must download. Both entry points are needed: `app/h/_layout.tsx` wraps every route under
|
||||
* it, and its imports are part of the page as surely as the route module's.
|
||||
* Both entry points are needed: `app/h/_layout.tsx` wraps every route under it, and its imports are
|
||||
* part of the page as surely as the route module's.
|
||||
*/
|
||||
export async function mobileWebAppRouteClosure(routeModule) {
|
||||
return await mobileWebAppModuleClosure(['app/h/_layout', routeModule])
|
||||
}
|
||||
|
||||
/**
|
||||
* The same closure for any entry modules, which a route plus the layout is one case of.
|
||||
*
|
||||
* `splitting: false` and a per-name output are required for a two-entry build; with the defaults
|
||||
* One definition of "what a page contains", read from `metafile.inputs` — the modules the entries
|
||||
* pull in — rather than from `entryStaticClosure`, which walks emitted chunks and answers what a
|
||||
* browser must download.
|
||||
*
|
||||
* A component a route mounts rather than one the router registers — `MobileBrowserPane` is the
|
||||
* first with a pin of its own — has a closure to certify and no route to name it by. Pass it alone
|
||||
* to read what it reaches on its own, or beside `app/h/_layout` to read what it adds to a page.
|
||||
*
|
||||
* `splitting: false` and a per-name output are required for a multi-entry build; with the defaults
|
||||
* esbuild fails on two outputs claiming `dist/entry.js`.
|
||||
*
|
||||
* Note for anyone comparing this with a parity pin: `c1-page-closure.ts`, and the closures C2.6,
|
||||
* C5.2 and C3.2 generate, derive theirs by the C1.6 method inside the mobile suite. The two are
|
||||
* not the same computation, and a divergence between them is a finding rather than noise.
|
||||
*/
|
||||
export async function mobileWebAppRouteClosure(routeModule) {
|
||||
export async function mobileWebAppModuleClosure(entryModules) {
|
||||
const base = mobileWebAppBuildOptions(MOBILE_WEB_PAGE_ROUTES)
|
||||
const result = await esbuild.build({
|
||||
...base,
|
||||
// Extensionless, so `resolveExtensions` picks the same file the bundle ships: a route with a
|
||||
// `.web.tsx` sibling resolves to that one, and naming the `.tsx` path explicitly would measure
|
||||
// the native switch no browser ever loads.
|
||||
entryPoints: ['app/h/_layout', routeModule.replace(/\.tsx?$/, '')],
|
||||
entryPoints: entryModules.map((entry) => entry.replace(/\.tsx?$/, '')),
|
||||
splitting: false,
|
||||
entryNames: '[name]',
|
||||
plugins: base.plugins.filter((plugin) => plugin.name !== ROUTE_MANIFEST_PLUGIN_NAME),
|
||||
|
||||
@@ -374,8 +374,8 @@ describeBundling('the app bundle', () => {
|
||||
|
||||
it('fails the named shim, not the whole build, when its option goes missing', async () => {
|
||||
const options = mobileWebAppBuildOptions(await collectMobileWebAppRoutes(appDir))
|
||||
// Each shim reads a different option, so removing one leaves the other five true. Without
|
||||
// that, the list could name a shim the build stopped applying.
|
||||
// Each shim reads an option of its own (two read `banner.js`), so stripping every option
|
||||
// leaves none applying. Without that, the list could name a shim the build stopped applying.
|
||||
const stripped = {
|
||||
...options,
|
||||
alias: {},
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
/**
|
||||
* The browser pane, mounted in a real page and painted with a real frame.
|
||||
*
|
||||
* Every other C6 check reads one half: the shell suites drive `BridgeHostSubscriptions` with no
|
||||
* page, the page suites drive the hooks with no shell, and the parity pin certifies the input
|
||||
* path from a recording. This is the only place the whole frame path runs — the encoder's base64
|
||||
* crossing the bridge, the page's decoder rebuilding the frame, the `.web.ts` layer writes
|
||||
* painting it, and the decode-then-flip that native gets for free from `Image.onLoad`.
|
||||
*
|
||||
* C6 ruling 4: no route is added for it. `bundleMobileWebApp` already takes an `appDir`, so the
|
||||
* check builds a one-route tree of its own, mounts the pane in it, and nothing under `mobile/app`
|
||||
* moves or is registered.
|
||||
*
|
||||
* The frames are JPEGs the page encodes from a noise canvas, for the reason the budget uses noise:
|
||||
* it is the image JPEG compresses least, so the over-cap case is over the cap for the reason a
|
||||
* real page would be rather than because the check inflated one.
|
||||
*/
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { chromium } from 'playwright-core'
|
||||
import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs'
|
||||
import { MOBILE_WEB_APP_ROUTE_ROOT } from './mobile-web-app-route-manifest.mjs'
|
||||
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
|
||||
import {
|
||||
createBundleServer,
|
||||
installShellDouble,
|
||||
readBridgeFaultGrant,
|
||||
readBridgeProtocolVersion,
|
||||
readBridgeWindowCaps,
|
||||
readBrowserFrameQuality,
|
||||
readShellCsp
|
||||
} from './mobile-web-app-render-harness.mjs'
|
||||
|
||||
const mobileDir = fileURLToPath(new URL('../../mobile', import.meta.url))
|
||||
|
||||
const HOST_ID = 'render-check-host'
|
||||
const ROUTE = { pathname: '/h' }
|
||||
const SHELL_HOST = {
|
||||
id: HOST_ID,
|
||||
name: 'Render Check Host',
|
||||
endpoint: 'ws://render-check',
|
||||
lastConnected: 1
|
||||
}
|
||||
const VIEWPORT = { width: 390, height: 844 }
|
||||
/** The grant C6.1 named for the binary lane, and the one the negative case withholds. */
|
||||
const BINARY_GRANT = 'screencastBinary'
|
||||
const SCREENCAST = 'browser.screencast'
|
||||
|
||||
/** The frame's source viewport, which is what a tap is mapped back into. */
|
||||
const SOURCE = { deviceWidth: 390, deviceHeight: 712 }
|
||||
|
||||
/**
|
||||
* The frame the pane asks this viewport for, read off its own subscribe: `maxWidth` 390 by
|
||||
* `maxHeight` 698 in web view mode.
|
||||
*
|
||||
* Not the phone's mobile-mode frame, which is 780x1424 and, as noise, encodes to 811,168 base64
|
||||
* characters — 124% of the cap, which is the measurement the area budget exists for. That one is
|
||||
* the over-cap case below rather than the frame that paints.
|
||||
*/
|
||||
const FRAME = { width: 390, height: 698 }
|
||||
/** Noise at the largest layout the clamps admit, measured at 3,761,580 characters: 574% of the cap. */
|
||||
const OVER_CAP_FRAME = { width: 2400, height: 2160 }
|
||||
|
||||
/**
|
||||
* The scratch route: the pane and nothing else.
|
||||
*
|
||||
* The imports are relative and of a fixed depth, so this source carries no path from the machine
|
||||
* that generated it. `screencastSupported` is the desktop's answer, which this route stands in for
|
||||
* because the capability probe is the session screen's to make and C7's to prove.
|
||||
*/
|
||||
const ROUTE_SOURCE = `
|
||||
import { useHostClient } from '../../../src/transport/client-context'
|
||||
import { MobileBrowserPane } from '../../../src/browser/MobileBrowserPane'
|
||||
|
||||
const TAB = {
|
||||
type: 'browser',
|
||||
id: 'render-check-tab',
|
||||
title: 'Render check',
|
||||
browserWorkspaceId: 'render-check-workspace',
|
||||
browserPageId: 'render-check-page',
|
||||
url: 'https://example.test/',
|
||||
loading: false,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
isActive: true
|
||||
}
|
||||
|
||||
export default function BrowserPaneRenderCheckRoute() {
|
||||
const { client } = useHostClient('${HOST_ID}')
|
||||
return (
|
||||
<MobileBrowserPane
|
||||
client={client}
|
||||
worktreeId="render-check-worktree"
|
||||
tab={TAB}
|
||||
screencastSupported={true}
|
||||
keyboardLift={0}
|
||||
bottomInset={0}
|
||||
onToast={() => {}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
`
|
||||
|
||||
const bundles = mobileWebAppDependenciesPresent()
|
||||
const describePane = bundles ? describe : describe.skip
|
||||
|
||||
let scratch = null
|
||||
let server = null
|
||||
let origin = null
|
||||
let browser = null
|
||||
let cspHeader = null
|
||||
let bridgeVersion = null
|
||||
let faultGrant = null
|
||||
let windowCaps = null
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!bundles) {
|
||||
return
|
||||
}
|
||||
cspHeader = await readShellCsp()
|
||||
bridgeVersion = await readBridgeProtocolVersion()
|
||||
faultGrant = await readBridgeFaultGrant()
|
||||
windowCaps = await readBridgeWindowCaps()
|
||||
// Inside mobile/ rather than the system temp dir: the route resolves `react-native` and the
|
||||
// pane's own modules, and esbuild resolves a bare specifier from the importer upward.
|
||||
await mkdir(join(mobileDir, '.tmp'), { recursive: true })
|
||||
scratch = await mkdtemp(join(mobileDir, '.tmp', 'browser-pane-render-'))
|
||||
const routeDir = join(scratch, MOBILE_WEB_APP_ROUTE_ROOT)
|
||||
await mkdir(routeDir, { recursive: true })
|
||||
await writeFile(join(routeDir, 'index.tsx'), ROUTE_SOURCE)
|
||||
const { outDir } = await buildMobileWebAppBundle({
|
||||
appDir: scratch,
|
||||
outDir: join(scratch, 'bundle'),
|
||||
pageRoutes: [{ pathname: ROUTE.pathname, grants: [BINARY_GRANT] }]
|
||||
})
|
||||
const served = await createBundleServer({ outDir, cspHeader })
|
||||
server = served.server
|
||||
origin = served.origin
|
||||
const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(executablePath ? { executablePath } : {})
|
||||
})
|
||||
}, 300_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
server?.close()
|
||||
if (scratch) {
|
||||
// This run's directory only. `mobile/.tmp` is a shared ignored root and another suite may be
|
||||
// holding one of its own.
|
||||
await rm(scratch, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
/** One page with the shell double installed, its console and its requests watched. */
|
||||
async function openPane({ grants }) {
|
||||
const context = await browser.newContext({ viewport: VIEWPORT })
|
||||
const page = await context.newPage()
|
||||
const consoleErrors = []
|
||||
const foreignRequests = []
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
consoleErrors.push(message.text())
|
||||
}
|
||||
})
|
||||
page.on('pageerror', (error) => consoleErrors.push(error.message))
|
||||
page.on('request', (request) => {
|
||||
if (!request.url().startsWith(origin) && !request.url().startsWith('data:')) {
|
||||
foreignRequests.push(request.url())
|
||||
}
|
||||
})
|
||||
await page.addInitScript(installShellDouble, {
|
||||
version: bridgeVersion,
|
||||
sessionId: 'render-check-session',
|
||||
buildId: 'render-check-build-id',
|
||||
route: ROUTE,
|
||||
host: SHELL_HOST,
|
||||
storage: {},
|
||||
faultGrant,
|
||||
grants,
|
||||
pageRoutes: [ROUTE.pathname],
|
||||
replies: { 'browser.mouseClick': { ok: true } },
|
||||
streams: [SCREENCAST],
|
||||
windowCaps
|
||||
})
|
||||
// The page reports a CSP violation as a document event; the header is the shell's own.
|
||||
await page.addInitScript(() => {
|
||||
globalThis.__orcaRenderCheckCsp = []
|
||||
document.addEventListener('securitypolicyviolation', (event) => {
|
||||
globalThis.__orcaRenderCheckCsp.push({
|
||||
directive: event.violatedDirective,
|
||||
blockedUri: event.blockedURI
|
||||
})
|
||||
})
|
||||
})
|
||||
await page.goto(`${origin}${ROUTE.pathname}`, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForFunction(() => document.querySelector('#root')?.childElementCount > 0)
|
||||
return {
|
||||
page,
|
||||
context,
|
||||
consoleErrors,
|
||||
foreignRequests,
|
||||
csp: () => page.evaluate(() => globalThis.__orcaRenderCheckCsp)
|
||||
}
|
||||
}
|
||||
|
||||
/** A JPEG of deterministic noise, encoded in the page, returned as the base64 the bridge carries. */
|
||||
async function encodeNoiseJpeg(page, { width, height, seed }) {
|
||||
const quality = await readBrowserFrameQuality()
|
||||
return page.evaluate(
|
||||
({ width, height, seed, quality }) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const context = canvas.getContext('2d')
|
||||
const image = context.createImageData(width, height)
|
||||
let state = seed >>> 0
|
||||
for (let index = 0; index < image.data.length; index += 4) {
|
||||
state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0
|
||||
image.data[index] = (state >>> 24) & 0xff
|
||||
image.data[index + 1] = (state >>> 16) & 0xff
|
||||
image.data[index + 2] = (state >>> 8) & 0xff
|
||||
image.data[index + 3] = 255
|
||||
}
|
||||
context.putImageData(image, 0, 0)
|
||||
return canvas.toDataURL('image/jpeg', quality).split(',')[1]
|
||||
},
|
||||
{ width, height, seed, quality }
|
||||
)
|
||||
}
|
||||
|
||||
/** Hand the page one frame, and say what the double did with it. */
|
||||
function emitFrame(page, { b64, frameSeq, width, height }) {
|
||||
return page.evaluate(
|
||||
({ b64, frameSeq, width, height, source }) => {
|
||||
const subscription = globalThis.__orcaRenderCheckSubscribes.at(-1)
|
||||
if (!subscription) {
|
||||
return 'no-subscription'
|
||||
}
|
||||
return globalThis.__orcaRenderCheckEmitBinary(subscription.id, {
|
||||
b64,
|
||||
format: 'jpeg',
|
||||
frameSeq,
|
||||
metadata: {
|
||||
offsetTop: 0,
|
||||
pageScaleFactor: 1,
|
||||
deviceWidth: source.deviceWidth,
|
||||
deviceHeight: source.deviceHeight,
|
||||
imageWidth: width,
|
||||
imageHeight: height,
|
||||
scrollOffsetX: 0,
|
||||
scrollOffsetY: 0,
|
||||
timestamp: 1_758_326_400.123456
|
||||
}
|
||||
})
|
||||
},
|
||||
{ b64, frameSeq, width, height, source: SOURCE }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The frame on screen, read off the DOM the way RN Web paints it.
|
||||
*
|
||||
* Selected by the inline `background-image` rather than by a testID, because that write is the
|
||||
* thing under test: `browser-frame-layer-paint.web.ts` puts the data URI on the element RN Web
|
||||
* gives `<Image>` a background on, and a handle added for this check could be on an element the
|
||||
* paint never touches.
|
||||
*/
|
||||
function readPaintedLayers(page) {
|
||||
return page.evaluate(() => {
|
||||
const painted = [...document.querySelectorAll('*')].filter((element) =>
|
||||
element.style?.backgroundImage?.startsWith('url("data:image/jpeg')
|
||||
)
|
||||
return painted.map((element) => {
|
||||
// The layer whose opacity the flip writes is the `<View>` above the `<Image>` surface.
|
||||
let layer = element.parentElement
|
||||
while (layer && layer.style.opacity === '') {
|
||||
layer = layer.parentElement
|
||||
}
|
||||
return {
|
||||
uri: element.style.backgroundImage.length,
|
||||
digest: element.style.backgroundImage.slice(-24),
|
||||
opacity: layer?.style.opacity ?? null
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const waitForPaint = (page, count) =>
|
||||
page.waitForFunction(
|
||||
(expected) =>
|
||||
[...document.querySelectorAll('*')].filter((element) =>
|
||||
element.style?.backgroundImage?.startsWith('url("data:image/jpeg')
|
||||
).length >= expected,
|
||||
count,
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
|
||||
describePane('the browser pane in a page', () => {
|
||||
/**
|
||||
* Zero, which it was not until the Zod jitless flag moved into the bundler banner.
|
||||
*
|
||||
* Zod decided whether it could compile by constructing `new Function('')`, which the shell's
|
||||
* `script-src 'self'` reports even though Zod catches the throw — once on load and again on
|
||||
* first paint. This file filtered those out by `blockedURI === 'eval'` for one round, which
|
||||
* would also have hidden a real one, so the filter is gone and the cause is fixed instead.
|
||||
*/
|
||||
it('files no CSP violation at all, through load and first paint', async () => {
|
||||
const view = await openPane({ grants: [faultGrant, BINARY_GRANT] })
|
||||
try {
|
||||
await view.page.waitForFunction(() => globalThis.__orcaRenderCheckSubscribes.length > 0)
|
||||
const b64 = await encodeNoiseJpeg(view.page, { ...FRAME, seed: 33 })
|
||||
await emitFrame(view.page, { b64, frameSeq: 1, ...FRAME })
|
||||
await waitForPaint(view.page, 1)
|
||||
|
||||
expect(await view.csp()).toEqual([])
|
||||
expect(view.consoleErrors).toEqual([])
|
||||
} finally {
|
||||
await view.context.close()
|
||||
}
|
||||
}, 120_000)
|
||||
|
||||
it('subscribes over the binary lane and paints the frame it is handed', async () => {
|
||||
const view = await openPane({ grants: [faultGrant, BINARY_GRANT] })
|
||||
try {
|
||||
await view.page.waitForFunction(() => globalThis.__orcaRenderCheckSubscribes.length > 0)
|
||||
const subscribes = await view.page.evaluate(() => globalThis.__orcaRenderCheckSubscribes)
|
||||
expect(subscribes).toHaveLength(1)
|
||||
expect(subscribes[0]).toMatchObject({ method: SCREENCAST, wantsBinary: true })
|
||||
|
||||
const b64 = await encodeNoiseJpeg(view.page, { ...FRAME, seed: 1 })
|
||||
expect(await emitFrame(view.page, { b64, frameSeq: 1, ...FRAME })).toBe('posted')
|
||||
await waitForPaint(view.page, 1)
|
||||
|
||||
const layers = await readPaintedLayers(view.page)
|
||||
// Both layers, because a render repaints both from `renderedFrameSource`, and one visible.
|
||||
// This does not prove the decode-then-flip ran: with the probe removed entirely, the first
|
||||
// frame still paints and a layer is still visible, because the visible layer starts at 0 and
|
||||
// never needed to move. The flip is the next case's to prove.
|
||||
expect(layers.length).toBeGreaterThan(0)
|
||||
expect(layers.filter((layer) => layer.opacity === '1')).toHaveLength(1)
|
||||
expect(view.consoleErrors).toEqual([])
|
||||
expect(await view.csp()).toEqual([])
|
||||
expect(view.foreignRequests).toEqual([])
|
||||
} finally {
|
||||
await view.context.close()
|
||||
}
|
||||
}, 120_000)
|
||||
|
||||
it('flips the double buffer on the second frame', async () => {
|
||||
const view = await openPane({ grants: [faultGrant, BINARY_GRANT] })
|
||||
try {
|
||||
await view.page.waitForFunction(() => globalThis.__orcaRenderCheckSubscribes.length > 0)
|
||||
const first = await encodeNoiseJpeg(view.page, { ...FRAME, seed: 7 })
|
||||
await emitFrame(view.page, { b64: first, frameSeq: 1, ...FRAME })
|
||||
await waitForPaint(view.page, 1)
|
||||
const before = await readPaintedLayers(view.page)
|
||||
|
||||
const second = await encodeNoiseJpeg(view.page, { ...FRAME, seed: 99 })
|
||||
expect(second).not.toBe(first)
|
||||
await emitFrame(view.page, { b64: second, frameSeq: 2, ...FRAME })
|
||||
await view.page.waitForFunction(
|
||||
(stale) =>
|
||||
[...document.querySelectorAll('*')].some(
|
||||
(element) =>
|
||||
element.style?.backgroundImage?.startsWith('url("data:image/jpeg') &&
|
||||
element.style.backgroundImage.slice(-24) !== stale
|
||||
),
|
||||
before[0].digest,
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
|
||||
const after = await readPaintedLayers(view.page)
|
||||
const visible = after.filter((layer) => layer.opacity === '1')
|
||||
expect(visible).toHaveLength(1)
|
||||
expect(visible[0].digest).not.toBe(before.find((l) => l.opacity === '1')?.digest)
|
||||
expect(view.consoleErrors).toEqual([])
|
||||
expect(await view.csp()).toEqual([])
|
||||
} finally {
|
||||
await view.context.close()
|
||||
}
|
||||
}, 120_000)
|
||||
|
||||
it('drops an over-cap frame, keeps the stream, and paints the next one', async () => {
|
||||
const view = await openPane({ grants: [faultGrant, BINARY_GRANT] })
|
||||
try {
|
||||
await view.page.waitForFunction(() => globalThis.__orcaRenderCheckSubscribes.length > 0)
|
||||
const small = await encodeNoiseJpeg(view.page, { ...FRAME, seed: 3 })
|
||||
await emitFrame(view.page, { b64: small, frameSeq: 1, ...FRAME })
|
||||
await waitForPaint(view.page, 1)
|
||||
const before = await readPaintedLayers(view.page)
|
||||
|
||||
// Noise at the largest layout the clamps admit, which §1 measured at 574% of the cap.
|
||||
const huge = await encodeNoiseJpeg(view.page, { ...OVER_CAP_FRAME, seed: 5 })
|
||||
expect(huge.length).toBeGreaterThan(windowCaps.maxMessageBytes)
|
||||
expect(await emitFrame(view.page, { b64: huge, frameSeq: 2, ...OVER_CAP_FRAME })).toBe(
|
||||
'dropped'
|
||||
)
|
||||
|
||||
// The stream is still open: the next frame arrives on the same subscription and paints.
|
||||
const next = await encodeNoiseJpeg(view.page, { ...FRAME, seed: 11 })
|
||||
expect(await emitFrame(view.page, { b64: next, frameSeq: 3, ...FRAME })).toBe('posted')
|
||||
await view.page.waitForFunction(
|
||||
(stale) =>
|
||||
[...document.querySelectorAll('*')].some(
|
||||
(element) =>
|
||||
element.style?.backgroundImage?.startsWith('url("data:image/jpeg') &&
|
||||
element.style.backgroundImage.slice(-24) !== stale
|
||||
),
|
||||
before[0].digest,
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
|
||||
expect(await view.page.evaluate(() => globalThis.__orcaRenderCheckDroppedFrames)).toEqual([2])
|
||||
expect(await view.page.evaluate(() => globalThis.__orcaRenderCheckSubscribes.length)).toBe(1)
|
||||
expect(await view.csp()).toEqual([])
|
||||
expect(view.consoleErrors).toEqual([])
|
||||
} finally {
|
||||
await view.context.close()
|
||||
}
|
||||
}, 120_000)
|
||||
|
||||
it('asks for no binary lane at all when the shell withholds the grant', async () => {
|
||||
const view = await openPane({ grants: [faultGrant] })
|
||||
try {
|
||||
await view.page.waitForFunction(() =>
|
||||
document.body.innerText.includes('Update the Orca app to stream browser tabs here.')
|
||||
)
|
||||
expect(await view.page.evaluate(() => globalThis.__orcaRenderCheckSubscribes)).toEqual([])
|
||||
expect(view.consoleErrors).toEqual([])
|
||||
expect(await view.csp()).toEqual([])
|
||||
expect(view.foreignRequests).toEqual([])
|
||||
} finally {
|
||||
await view.context.close()
|
||||
}
|
||||
}, 120_000)
|
||||
|
||||
it('streams past the unacked window because the page acks, and drops nothing', async () => {
|
||||
// The two `canCarry` arms the size check hides. Thirty frames of about 200 KB is roughly 6 MB
|
||||
// through a 4 MiB window, so a page that did not ack, or a shell double that ignored the acks
|
||||
// it sent, starts dropping partway. Nothing here is over the message cap, so a drop can only
|
||||
// come from the window.
|
||||
const view = await openPane({ grants: [faultGrant, BINARY_GRANT] })
|
||||
try {
|
||||
await view.page.waitForFunction(() => globalThis.__orcaRenderCheckSubscribes.length > 0)
|
||||
const b64 = await encodeNoiseJpeg(view.page, { ...FRAME, seed: 55 })
|
||||
const cumulative = b64.length * 30
|
||||
expect(cumulative).toBeGreaterThan(windowCaps.maxUnackedBytes)
|
||||
|
||||
const outcomes = []
|
||||
for (let frameSeq = 1; frameSeq <= 30; frameSeq += 1) {
|
||||
outcomes.push(await emitFrame(view.page, { b64, frameSeq, ...FRAME }))
|
||||
}
|
||||
|
||||
expect(new Set(outcomes)).toEqual(new Set(['posted']))
|
||||
expect(await view.page.evaluate(() => globalThis.__orcaRenderCheckDroppedFrames)).toEqual([])
|
||||
// And the acks are real rather than the window merely being generous.
|
||||
const acks = await view.page.evaluate(() => globalThis.__orcaRenderCheckAcks)
|
||||
expect(acks.length).toBeGreaterThan(0)
|
||||
expect(await view.csp()).toEqual([])
|
||||
expect(view.consoleErrors).toEqual([])
|
||||
} finally {
|
||||
await view.context.close()
|
||||
}
|
||||
}, 120_000)
|
||||
|
||||
it('issues one mouseClick with the geometry the native pane would send', async () => {
|
||||
const view = await openPane({ grants: [faultGrant, BINARY_GRANT] })
|
||||
try {
|
||||
await view.page.waitForFunction(() => globalThis.__orcaRenderCheckSubscribes.length > 0)
|
||||
const b64 = await encodeNoiseJpeg(view.page, { ...FRAME, seed: 21 })
|
||||
await emitFrame(view.page, { b64, frameSeq: 1, ...FRAME })
|
||||
await waitForPaint(view.page, 1)
|
||||
|
||||
// The centre of the rendered frame, which maps back to the centre of the source viewport
|
||||
// whatever the letterboxing did, so the expectation is exact rather than approximate.
|
||||
const box = await view.page.evaluate(() => {
|
||||
const painted = [...document.querySelectorAll('*')].find((element) =>
|
||||
element.style?.backgroundImage?.startsWith('url("data:image/jpeg')
|
||||
)
|
||||
const rect = painted.getBoundingClientRect()
|
||||
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }
|
||||
})
|
||||
await view.page.mouse.click(box.x, box.y)
|
||||
await view.page.waitForFunction(() => globalThis.__orcaRenderCheckRequests.length > 0)
|
||||
|
||||
const requests = await view.page.evaluate(() => globalThis.__orcaRenderCheckRequests)
|
||||
// One, not four: the double replies, so the pane never takes its move/down/up fallback. With
|
||||
// the refusal the double gives every other method, this is four requests instead.
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0].method).toBe('browser.mouseClick')
|
||||
expect(requests[0].params).toMatchObject({
|
||||
worktree: 'id:render-check-worktree',
|
||||
page: 'render-check-page',
|
||||
button: 'left',
|
||||
modifiers: []
|
||||
})
|
||||
// The centre of the rendered frame is the centre of the source viewport, to within the one
|
||||
// device pixel the rendered width's own fraction costs: the frame is 382.33 CSS px wide for
|
||||
// 390 source px, so the centre is not on a pixel boundary in either space. Wider than that
|
||||
// is a scale, an axis or a letterbox offset being wrong, which is what this is here for.
|
||||
expect(Math.abs(requests[0].params.x - SOURCE.deviceWidth / 2)).toBeLessThanOrEqual(1)
|
||||
expect(Math.abs(requests[0].params.y - SOURCE.deviceHeight / 2)).toBeLessThanOrEqual(1)
|
||||
expect(await view.csp()).toEqual([])
|
||||
expect(view.consoleErrors).toEqual([])
|
||||
} finally {
|
||||
await view.context.close()
|
||||
}
|
||||
}, 120_000)
|
||||
})
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* The mobile-view frame budget, held against Chromium's own JPEG encoder across the viewport range.
|
||||
*
|
||||
* `WORST_CASE_JPEG_BYTES_PER_PIXEL` is the one number the budget cannot derive, and every other
|
||||
* check of it is circular: a case that encodes `noise(area * theConstant)` is measuring a byte
|
||||
* count the constant just produced, so it agrees with the constant whatever the constant says. This
|
||||
* encodes a real noise JPEG per viewport, at the scale the real budget picks, and posts it through
|
||||
* the real `BridgeHostSubscriptions`. It is the only thing here that can falsify the number.
|
||||
*
|
||||
* Chromium rather than a Node encoder, because the frames are CDP screencast frames: the bytes the
|
||||
* budget has to survive are the ones Chromium produces, not the ones another library would.
|
||||
*
|
||||
* In `config/scripts` rather than the mobile suite for that reason — this is where a browser is
|
||||
* available — and it drives the mobile modules directly, so the budget, the scale and the host are
|
||||
* all the real ones.
|
||||
*
|
||||
* Named into the `mobile-web-app-` family so two things hold without anyone remembering them: the
|
||||
* `mobile_web_app` job's filter picks it up, and `pr-code-change-scope.mjs` fires that job when
|
||||
* this file changes. Both key off that prefix.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { chromium, type Browser, type Page } from 'playwright-core'
|
||||
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
|
||||
import type { BrowserScreencastFrame } from '../../mobile/src/transport/browser-screencast-protocol'
|
||||
|
||||
/**
|
||||
* The mobile modules load lazily, after the dependency check, never at the top of the file: vite
|
||||
* transforms anything under `mobile/` against `mobile/tsconfig.json`, which extends
|
||||
* `expo/tsconfig.base.json`, so a static import fails at load in the sharded `test` job before
|
||||
* `describe.skip` gets a say. Type-only imports are erased and stay static.
|
||||
*/
|
||||
async function loadSweepModules() {
|
||||
const [request, parameters, caps, fakes, harnessModule, protocol] = await Promise.all([
|
||||
import('../../mobile/src/browser/browser-screencast-request.web'),
|
||||
import('../../mobile/src/browser/browser-screencast-request-parameters'),
|
||||
import('../../mobile/src/mobile-web-shell/bridge/bridge-caps'),
|
||||
import('../../mobile/src/mobile-web-shell/bridge-host-test-fakes'),
|
||||
import('../../mobile/src/mobile-web-shell/bridge-host-test-harness'),
|
||||
import('../../mobile/src/transport/browser-screencast-protocol')
|
||||
])
|
||||
return {
|
||||
budgetedMobileViewDeviceScaleFactor: request.budgetedMobileViewDeviceScaleFactor,
|
||||
mobileBrowserFrameAreaBudget: request.mobileBrowserFrameAreaBudget,
|
||||
WORST_CASE_JPEG_BYTES_PER_PIXEL: request.WORST_CASE_JPEG_BYTES_PER_PIXEL,
|
||||
MOBILE_VIEW_DEVICE_SCALE_FACTOR: parameters.MOBILE_VIEW_DEVICE_SCALE_FACTOR,
|
||||
BROWSER_FRAME_QUALITY: parameters.BROWSER_FRAME_QUALITY,
|
||||
BRIDGE_MAX_MESSAGE_BYTES: caps.BRIDGE_MAX_MESSAGE_BYTES,
|
||||
utf8ByteLength: caps.utf8ByteLength,
|
||||
clientFrame: fakes.clientFrame,
|
||||
harness: harnessModule.harness,
|
||||
ID: harnessModule.ID,
|
||||
BrowserScreencastOpcode: protocol.BrowserScreencastOpcode
|
||||
}
|
||||
}
|
||||
|
||||
let loaded: Awaited<ReturnType<typeof loadSweepModules>> | null = null
|
||||
|
||||
function sweep() {
|
||||
if (loaded === null) {
|
||||
throw new Error('the sweep modules are not loaded')
|
||||
}
|
||||
return loaded
|
||||
}
|
||||
|
||||
/** The viewport range the pane is mounted in, phone through tablet, in CSS pixels. */
|
||||
const VIEWPORT_WIDTHS = [320, 360, 390, 393, 412, 430, 480, 600, 768, 834, 1024, 1280, 1400]
|
||||
const VIEWPORT_HEIGHTS = [480, 640, 712, 720, 800, 896, 932, 1024, 1180, 1366, 1600]
|
||||
|
||||
type Viewport = { width: number; height: number }
|
||||
|
||||
const VIEWPORTS: Viewport[] = VIEWPORT_WIDTHS.flatMap((width) =>
|
||||
VIEWPORT_HEIGHTS.map((height) => ({ width, height }))
|
||||
)
|
||||
|
||||
let browser: Browser | null = null
|
||||
let page: Page | null = null
|
||||
|
||||
/**
|
||||
* Skipped where the bundling tests skip, which is the sharded `test` job.
|
||||
*
|
||||
* Not because this needs react-native-web — it does not — but because that job has no browser to
|
||||
* launch, and this is the flag that tells the two jobs apart. In the `mobile_web_app` job the
|
||||
* required-env check turns a missing install into a failure, so it cannot skip there silently.
|
||||
*/
|
||||
const describeSweep = mobileWebAppDependenciesPresent() ? describe : describe.skip
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!mobileWebAppDependenciesPresent()) {
|
||||
return
|
||||
}
|
||||
loaded = await loadSweepModules()
|
||||
const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(executablePath ? { executablePath } : {})
|
||||
})
|
||||
page = await (await browser.newContext()).newPage()
|
||||
await page.goto('about:blank')
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
})
|
||||
|
||||
/**
|
||||
* A noise JPEG at the quality the pane ships, encoded by Chromium, returned as the base64 the
|
||||
* bridge carries. The quality is read, not retyped: at 90 every budgeted viewport posts over the cap.
|
||||
*/
|
||||
async function encodeNoiseJpeg(size: { width: number; height: number }, seed: number) {
|
||||
if (page === null) {
|
||||
throw new Error('the sweep has no page')
|
||||
}
|
||||
const quality = sweep().BROWSER_FRAME_QUALITY / 100
|
||||
return await page.evaluate(
|
||||
({ width, height, seed, quality }) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const context = canvas.getContext('2d')
|
||||
if (context === null) {
|
||||
throw new Error('no 2d context')
|
||||
}
|
||||
const image = context.createImageData(width, height)
|
||||
let state = seed >>> 0
|
||||
for (let index = 0; index < image.data.length; index += 4) {
|
||||
state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0
|
||||
image.data[index] = (state >>> 24) & 0xff
|
||||
image.data[index + 1] = (state >>> 16) & 0xff
|
||||
image.data[index + 2] = (state >>> 8) & 0xff
|
||||
image.data[index + 3] = 255
|
||||
}
|
||||
context.putImageData(image, 0, 0)
|
||||
return canvas.toDataURL('image/jpeg', quality).split(',')[1] ?? ''
|
||||
},
|
||||
{ ...size, seed, quality }
|
||||
)
|
||||
}
|
||||
|
||||
/** Base64 back to the byte length it stands for, which is what the shell is handed. */
|
||||
function base64ByteLength(b64: string): number {
|
||||
const padding = b64.endsWith('==') ? 2 : b64.endsWith('=') ? 1 : 0
|
||||
return (b64.length / 4) * 3 - padding
|
||||
}
|
||||
|
||||
function screencastFrame(image: Uint8Array, frame: { width: number; height: number }) {
|
||||
return {
|
||||
opcode: sweep().BrowserScreencastOpcode.Frame,
|
||||
seq: 1,
|
||||
format: 'jpeg',
|
||||
metadata: {
|
||||
offsetTop: 0,
|
||||
pageScaleFactor: 1,
|
||||
deviceWidth: frame.width,
|
||||
deviceHeight: frame.height,
|
||||
imageWidth: frame.width,
|
||||
imageHeight: frame.height,
|
||||
scrollOffsetX: 0,
|
||||
scrollOffsetY: 0,
|
||||
timestamp: 1_758_326_400.123456
|
||||
},
|
||||
image
|
||||
} satisfies BrowserScreencastFrame
|
||||
}
|
||||
|
||||
/** What the real host does with this frame: the bytes it posted, or null when it dropped it. */
|
||||
function postThroughShell(
|
||||
image: Uint8Array,
|
||||
frame: { width: number; height: number }
|
||||
): number | null {
|
||||
const bridge = sweep().harness({ ready: true })
|
||||
bridge.host.receive(
|
||||
sweep().clientFrame({
|
||||
type: 'subscribe',
|
||||
id: sweep().ID,
|
||||
method: 'browser.screencast',
|
||||
params: { worktree: 'id:w', page: 'p' },
|
||||
wantsBinary: true
|
||||
})
|
||||
)
|
||||
const before = bridge.posted.length
|
||||
// Same reason as the sibling pin: a subscribe that opened no binary lane would read here as a
|
||||
// dropped frame, and this sweep's whole verdict is which frames were dropped.
|
||||
const emitBinary = bridge.client.streams[0]?.emitBinary
|
||||
if (emitBinary === null || emitBinary === undefined) {
|
||||
throw new Error('the subscribe opened no binary stream')
|
||||
}
|
||||
emitBinary(screencastFrame(image, frame))
|
||||
if (bridge.posted.length === before) {
|
||||
return null
|
||||
}
|
||||
return sweep().utf8ByteLength(bridge.posted.at(-1) ?? '')
|
||||
}
|
||||
|
||||
/** The device-pixel frame the budget asks this viewport for. */
|
||||
function budgetedFrame(viewport: Viewport) {
|
||||
const scale = sweep().budgetedMobileViewDeviceScaleFactor(viewport)
|
||||
return {
|
||||
scale,
|
||||
width: Math.round(viewport.width * scale),
|
||||
height: Math.round(viewport.height * scale)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The viewports the budget can actually fit, which are the ones it makes a promise about.
|
||||
*
|
||||
* Below a scale of one the module stops: asking for fewer device pixels than CSS pixels is a
|
||||
* blurry frame rather than a working one, so a viewport too large for the cap keeps scale 1 and
|
||||
* the frame that does not fit is C6 ruling 1's to drop. Split here so the promise and the
|
||||
* exception are both asserted rather than averaged.
|
||||
*/
|
||||
const withinBudget = (viewport: Viewport) => budgetedFrame(viewport).scale > 1
|
||||
|
||||
describeSweep('the frame budget across the viewport range', () => {
|
||||
it('keeps every viewport it budgets for inside one bridge message', async () => {
|
||||
const overCap: string[] = []
|
||||
let worstBytesPerPixel = 0
|
||||
let bestBytesPerPixel = 1
|
||||
for (const viewport of VIEWPORTS.filter(withinBudget)) {
|
||||
const frame = budgetedFrame(viewport)
|
||||
const b64 = await encodeNoiseJpeg(frame, viewport.width * 7_919 + viewport.height)
|
||||
const imageBytes = base64ByteLength(b64)
|
||||
const bytesPerPixel = imageBytes / (frame.width * frame.height)
|
||||
worstBytesPerPixel = Math.max(worstBytesPerPixel, bytesPerPixel)
|
||||
bestBytesPerPixel = Math.min(bestBytesPerPixel, bytesPerPixel)
|
||||
const posted = postThroughShell(new Uint8Array(imageBytes), frame)
|
||||
if (posted === null || posted > sweep().BRIDGE_MAX_MESSAGE_BYTES) {
|
||||
overCap.push(
|
||||
`${viewport.width}x${viewport.height} at scale ${frame.scale}: ${String(posted)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
expect(overCap).toEqual([])
|
||||
// And the constant is above every cost that sweep just measured. Against the constant, not the
|
||||
// 0.55351 measured on 2026-09-20 that its docstring records: the margin above that is what an
|
||||
// encoder drift may spend, and a drift inside it is not a budget failure. Without this the
|
||||
// assertion above passes by the budget being merely generous.
|
||||
expect(worstBytesPerPixel).toBeLessThanOrEqual(sweep().WORST_CASE_JPEG_BYTES_PER_PIXEL)
|
||||
// The low end too, so a sweep that silently stopped encoding real images is visible: every
|
||||
// frame here is noise, and noise never compresses to a tenth of a byte per pixel.
|
||||
expect(bestBytesPerPixel).toBeGreaterThan(0.5)
|
||||
}, 300_000)
|
||||
|
||||
it('does not budget below one device pixel per CSS pixel, and the shell drops what will not fit', async () => {
|
||||
// The exception the split above names. These are real: a 1400x1180 viewport posts 1.2 MB.
|
||||
const tooLarge = VIEWPORTS.filter((viewport) => !withinBudget(viewport))
|
||||
// The 32 of the 143 the budget leaves at scale 1, a fixed number because the set is fixed.
|
||||
expect(tooLarge.length).toBe(32)
|
||||
|
||||
const largest = tooLarge.reduce((left, right) =>
|
||||
left.width * left.height > right.width * right.height ? left : right
|
||||
)
|
||||
const frame = budgetedFrame(largest)
|
||||
expect(frame.scale).toBe(1)
|
||||
const b64 = await encodeNoiseJpeg(frame, 1)
|
||||
expect(postThroughShell(new Uint8Array(base64ByteLength(b64)), frame)).toBeNull()
|
||||
}, 120_000)
|
||||
|
||||
it('never asks for more density than native, anywhere in the range', () => {
|
||||
for (const viewport of VIEWPORTS) {
|
||||
expect(budgetedFrame(viewport).scale).toBeLessThanOrEqual(
|
||||
sweep().MOBILE_VIEW_DEVICE_SCALE_FACTOR
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('sweeps a range wide enough to contain the phones the pane runs on', () => {
|
||||
// The set is fixed, so this is what says it still covers the case the old constant missed.
|
||||
expect(VIEWPORTS).toContainEqual({ width: 390, height: 712 })
|
||||
expect(VIEWPORTS).toContainEqual({ width: 393, height: 720 })
|
||||
expect(VIEWPORTS).toContainEqual({ width: 360, height: 640 })
|
||||
expect(VIEWPORTS.length).toBe(143)
|
||||
expect(sweep().mobileBrowserFrameAreaBudget()).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,10 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mobileWebAppRouteClosure } from './build-mobile-web-app-bundle.mjs'
|
||||
import {
|
||||
mobileWebAppModuleClosure,
|
||||
mobileWebAppRouteClosure
|
||||
} from './build-mobile-web-app-bundle.mjs'
|
||||
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
|
||||
import { pageClosureFamilies, pinnedFamilyNames } from './mobile-web-app-page-closure-families.mjs'
|
||||
|
||||
@@ -26,6 +29,8 @@ const PIN_TABLES = [
|
||||
'mobile/src/test-support/bridged-parity/c2-work-item-closure-families.ts',
|
||||
'mobile/src/test-support/bridged-parity/c2-task-source-closure-families.ts'
|
||||
]
|
||||
const C6_PIN_TABLE = 'mobile/src/test-support/bridged-parity/c6-browser-closure-families.ts'
|
||||
|
||||
const FILES_PIN_TABLES = [
|
||||
C1_TABLE,
|
||||
'mobile/src/test-support/bridged-parity/c3-explorer-closure-families.ts',
|
||||
@@ -127,3 +132,31 @@ describeClosure('the files page closures', () => {
|
||||
expect(previewFamilies).not.toContain('files.explorer-screen')
|
||||
}, 120_000)
|
||||
})
|
||||
|
||||
describeClosure('the browser pane closure', () => {
|
||||
/** The pane is mounted by a route, not registered as one, so its closure is read from the module
|
||||
* itself. C7 composes this half with the session route and censuses the route the usual way. */
|
||||
const PANE = 'src/browser/MobileBrowserPane'
|
||||
|
||||
it('reaches exactly the golden families its pin table commits, on its own', async () => {
|
||||
const closure = await mobileWebAppModuleClosure([PANE])
|
||||
await expectClosureFamilies(closure.local, [C6_PIN_TABLE])
|
||||
}, 60_000)
|
||||
|
||||
it('adds exactly those families to a page, and no other', async () => {
|
||||
// The pin is a half: alone it would also pass if the pane dragged in a family C1 already pins
|
||||
// and the table happened to list it. This reads the difference the pane makes to the layout.
|
||||
const scenarios = JSON.parse(read('mobile/rpc-foundation/pilot-scenarios.json')).scenarios
|
||||
const [layout, withPane] = await Promise.all([
|
||||
mobileWebAppModuleClosure(['app/h/_layout']),
|
||||
mobileWebAppModuleClosure(['app/h/_layout', PANE])
|
||||
])
|
||||
const layoutFamilies = pageClosureFamilies(layout.local, scenarios)
|
||||
const added = pageClosureFamilies(withPane.local, scenarios).filter(
|
||||
(family) => !layoutFamilies.includes(family)
|
||||
)
|
||||
expect(added).toEqual(pinnedFamilyNames(read(C6_PIN_TABLE)).sort())
|
||||
// And that the layout is the C1 control it is everywhere else, so the difference above is real.
|
||||
expect(layoutFamilies).toEqual(pinnedFamilyNames(read(C1_TABLE)).sort())
|
||||
}, 120_000)
|
||||
})
|
||||
|
||||
@@ -59,6 +59,54 @@ export async function readBridgeProtocolVersion() {
|
||||
return Number(match[1])
|
||||
}
|
||||
|
||||
/**
|
||||
* The bridge's window caps, read from the modules that define them.
|
||||
*
|
||||
* The shell double below has to price a frame the way `BridgeHostSubscriptions` does, and a double
|
||||
* carrying its own copy of these numbers is a double that goes on passing after the real host's
|
||||
* changed. `BRIDGE_MAX_UNACKED_BYTES` is written as a product, so the reader evaluates one.
|
||||
*/
|
||||
export async function readBridgeWindowCaps() {
|
||||
const sources = await Promise.all(
|
||||
[
|
||||
'mobile/src/mobile-web-shell/bridge/bridge-caps.ts',
|
||||
'mobile/src/mobile-web-shell/bridge-host-subscriptions.ts'
|
||||
].map((path) => readFile(join(projectDir, path), 'utf8'))
|
||||
)
|
||||
const source = sources.join('\n')
|
||||
const read = (name) => {
|
||||
const match = new RegExp(`${name} = ([0-9*\\s]+)`).exec(source)
|
||||
if (!match) {
|
||||
throw new Error(`could not read ${name}`)
|
||||
}
|
||||
return match[1]
|
||||
.split('*')
|
||||
.map((part) => Number(part.trim()))
|
||||
.reduce((product, factor) => product * factor, 1)
|
||||
}
|
||||
return {
|
||||
maxMessageBytes: read('BRIDGE_MAX_MESSAGE_BYTES'),
|
||||
maxUnackedFrames: read('BRIDGE_MAX_UNACKED_FRAMES'),
|
||||
maxUnackedBytes: read('BRIDGE_MAX_UNACKED_BYTES')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The JPEG quality the pane asks Chromium for, read from the module that sends it. A test that
|
||||
* encoded its fixtures at a retyped quality would certify the budget at a number nothing ships.
|
||||
*/
|
||||
export async function readBrowserFrameQuality() {
|
||||
const source = await readFile(
|
||||
join(projectDir, 'mobile/src/browser/browser-screencast-request-parameters.ts'),
|
||||
'utf8'
|
||||
)
|
||||
const match = /BROWSER_FRAME_QUALITY = (\d+)/.exec(source)
|
||||
if (!match) {
|
||||
throw new Error('could not read BROWSER_FRAME_QUALITY')
|
||||
}
|
||||
return Number(match[1]) / 100
|
||||
}
|
||||
|
||||
/** The grant the shell offers every page, read from the same source for the same reason. */
|
||||
export async function readBridgeFaultGrant() {
|
||||
const source = await readFile(
|
||||
@@ -83,6 +131,11 @@ export async function readBridgeFaultGrant() {
|
||||
* because a control that handed something to the shell and one that did nothing look the same on
|
||||
* the document.
|
||||
*
|
||||
* It answers RPC the way a refusing host does and serves a screencast stream the way the real
|
||||
* `BridgeHostSubscriptions` does, including its whole `canCarry` rule and the page's acks. It is
|
||||
* not the host: it decides no domain behaviour, and every reply a screen sees is one a check
|
||||
* named.
|
||||
*
|
||||
* Serialized as a page init script, so it takes plain data and closes over nothing.
|
||||
*/
|
||||
export function installShellDouble({
|
||||
@@ -95,7 +148,9 @@ export function installShellDouble({
|
||||
faultGrant,
|
||||
grants,
|
||||
pageRoutes = null,
|
||||
replies
|
||||
replies,
|
||||
streams = [],
|
||||
windowCaps = null
|
||||
}) {
|
||||
// Where the page's own fault reports land. Read back after the render, so a route that threw
|
||||
// under the boundary names itself instead of timing out as a page that never mounted.
|
||||
@@ -104,6 +159,19 @@ export function installShellDouble({
|
||||
// something to the shell and a control that did nothing look identical on the document; this is
|
||||
// the only thing that tells them apart.
|
||||
globalThis.__orcaRenderCheckNotifies = []
|
||||
// Every request the page issued, whole and in order, so a check can say which verb a gesture
|
||||
// produced and with what geometry rather than only that something was sent.
|
||||
globalThis.__orcaRenderCheckRequests = []
|
||||
// The subscriptions the double accepted, with the `wantsBinary` each one asked for: the negative
|
||||
// case is "the page did not ask", which no assertion on the frames can see.
|
||||
globalThis.__orcaRenderCheckSubscribes = []
|
||||
// Binary events this double refused to post because they exceeded the frame cap, which is the
|
||||
// shell's drop rule reproduced where the page can watch it survive one.
|
||||
globalThis.__orcaRenderCheckDroppedFrames = []
|
||||
// Every ack seq the page posted, in order. Without this a stream that never acked and one that
|
||||
// acked every frame look the same from the page's side.
|
||||
globalThis.__orcaRenderCheckAcks = []
|
||||
const openStreams = new Map()
|
||||
const channel = {
|
||||
postMessage: (json) => {
|
||||
const frame = JSON.parse(json)
|
||||
@@ -151,6 +219,43 @@ export function installShellDouble({
|
||||
// The result the caller named for this method, carried in the envelope a real host uses.
|
||||
// Anything unnamed still takes the refusal below, so a screen only ever sees data a test
|
||||
// asked for.
|
||||
if (frame.type === 'subscribe' && streams.includes(frame.method)) {
|
||||
globalThis.__orcaRenderCheckSubscribes.push({
|
||||
id: frame.id,
|
||||
method: frame.method,
|
||||
params: frame.params,
|
||||
wantsBinary: frame.wantsBinary === true
|
||||
})
|
||||
// Accepted by saying nothing, exactly as the real host does: a subscription is open until
|
||||
// an `error` or an `end` closes it, and the first thing the page hears is an event.
|
||||
openStreams.set(frame.id, { seq: 0, unacked: [], unackedBytes: 0 })
|
||||
return
|
||||
}
|
||||
if (frame.type === 'ack') {
|
||||
// The page's ack is what reopens the window, so a double that ignored it would drop
|
||||
// frames the real host carries. Read exactly as `BridgeHostSubscriptions.ack` reads it.
|
||||
const stream = openStreams.get(frame.id)
|
||||
if (stream) {
|
||||
let acked = 0
|
||||
for (const pending of stream.unacked) {
|
||||
if (pending.seq > frame.seq) {
|
||||
break
|
||||
}
|
||||
stream.unackedBytes -= pending.bytes
|
||||
acked += 1
|
||||
}
|
||||
stream.unacked.splice(0, acked)
|
||||
globalThis.__orcaRenderCheckAcks.push(frame.seq)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (frame.type === 'cancel') {
|
||||
openStreams.delete(frame.id)
|
||||
return
|
||||
}
|
||||
if (frame.type === 'request') {
|
||||
globalThis.__orcaRenderCheckRequests.push({ method: frame.method, params: frame.params })
|
||||
}
|
||||
if (frame.type === 'request' && replies && Object.hasOwn(replies, frame.method)) {
|
||||
answer({
|
||||
v: version,
|
||||
@@ -175,6 +280,41 @@ export function installShellDouble({
|
||||
},
|
||||
onmessage: null
|
||||
}
|
||||
/**
|
||||
* One screencast frame from the shell, priced the way `BridgeHostSubscriptions` prices it.
|
||||
*
|
||||
* All three arms of the host's `canCarry`, not just the size one: a frame over the message cap,
|
||||
* a window already holding the most frames it may, and a window whose bytes this frame would
|
||||
* push past the limit. Dropping is the behaviour under test — the event goes nowhere, the
|
||||
* stream stays open, and the next frame paints — so a double that posted an uncarriable frame
|
||||
* would prove the page decodes something no shell could have sent.
|
||||
*
|
||||
* The window only stays open because the page acks, which the `ack` arm above consumes. That is
|
||||
* what makes a long stream a real test of both rather than of neither.
|
||||
*/
|
||||
globalThis.__orcaRenderCheckEmitBinary = (id, binary) => {
|
||||
const stream = openStreams.get(id)
|
||||
if (!stream) {
|
||||
return 'no-stream'
|
||||
}
|
||||
const seq = stream.seq + 1
|
||||
const json = JSON.stringify({ v: version, type: 'event', id, seq, binary })
|
||||
const bytes = new TextEncoder().encode(json).length
|
||||
const carries =
|
||||
windowCaps === null ||
|
||||
(bytes <= windowCaps.maxMessageBytes &&
|
||||
stream.unacked.length < windowCaps.maxUnackedFrames &&
|
||||
stream.unackedBytes + bytes <= windowCaps.maxUnackedBytes)
|
||||
if (!carries) {
|
||||
globalThis.__orcaRenderCheckDroppedFrames.push(binary.frameSeq)
|
||||
return 'dropped'
|
||||
}
|
||||
stream.seq = seq
|
||||
stream.unacked.push({ seq, bytes })
|
||||
stream.unackedBytes += bytes
|
||||
channel.onmessage?.({ data: json })
|
||||
return 'posted'
|
||||
}
|
||||
globalThis.orcaBridge = channel
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* The page's frame budget, checked against the frame the shell really posts.
|
||||
*
|
||||
* `browser-screencast-request.web.ts` sizes the mobile view from `binaryEventEnvelopeBytes()`, a
|
||||
* bound it derives from a skeleton it builds itself. Its own suite checks that bound against
|
||||
* another skeleton of the same shape, which is two copies of one assumption agreeing. This is the
|
||||
* case that makes it evidence: a real `event.binary` encoded by C6.1's encoder and serialized by
|
||||
* the real `BridgeHostSubscriptions`, measured, and the bound held above it.
|
||||
*
|
||||
* C6 ruling 2's pin. The frames are generated noise at the budgeted scale rather than a fixture
|
||||
* committed to the tree: the budget's worst case is the image JPEG compresses least, and a
|
||||
* downloaded photograph would sit a tenth of the way to it and prove nothing.
|
||||
*
|
||||
* Measured at this base, so a later reader can tell drift from a rewrite: the real envelope costs
|
||||
* 303 bytes against a bound of 516, and a frame at the budgeted area is an image the shell posts
|
||||
* just under the 655,360-byte cap. The area itself moved when the worst case was swept properly,
|
||||
* so the figures are derived here rather than written down.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BRIDGE_MAX_MESSAGE_BYTES, utf8ByteLength } from '../mobile-web-shell/bridge/bridge-caps'
|
||||
import { clientFrame } from '../mobile-web-shell/bridge-host-test-fakes'
|
||||
import { harness, ID } from '../mobile-web-shell/bridge-host-test-harness'
|
||||
import {
|
||||
BrowserScreencastOpcode,
|
||||
METADATA_KEYS,
|
||||
type BrowserScreencastFrame
|
||||
} from '../transport/browser-screencast-protocol'
|
||||
import {
|
||||
binaryEventEnvelopeBytes,
|
||||
mobileBrowserFrameAreaBudget,
|
||||
WORST_CASE_JPEG_BYTES_PER_PIXEL
|
||||
} from './browser-screencast-request.web'
|
||||
|
||||
/** What the bound spends per number: seventeen significant digits and the widest fixed notation
|
||||
* JSON writes, `-0.0000012345678901234567`, and `Number.MAX_SAFE_INTEGER` for the two counters. */
|
||||
const WIDEST_JSON_DOUBLE_CHARS = 25
|
||||
const LARGEST_INTEGER_CHARS = JSON.stringify(Number.MAX_SAFE_INTEGER).length
|
||||
|
||||
/**
|
||||
* A frame's metadata as Chromium sends it: all nine fields, and a `timestamp` that is a real
|
||||
* `Page.screencastFrame` value rather than a toy integer.
|
||||
*
|
||||
* The timestamp is the whole difference between a plausible envelope and the real one — epoch
|
||||
* seconds with microseconds is sixteen characters where `1` is one — so a pin written with a small
|
||||
* number would measure an envelope no frame ever has.
|
||||
*/
|
||||
const CDP_METADATA = {
|
||||
offsetTop: 0,
|
||||
pageScaleFactor: 1,
|
||||
deviceWidth: 390,
|
||||
deviceHeight: 712,
|
||||
imageWidth: 780,
|
||||
imageHeight: 1424,
|
||||
scrollOffsetX: 0,
|
||||
scrollOffsetY: 2048.5,
|
||||
timestamp: 1_758_326_400.123456
|
||||
}
|
||||
|
||||
/** Noise, which is what the worst case is: any structure at all is something JPEG would compress.
|
||||
* Deterministic rather than seeded off the clock, so the measured bytes are the same every run. */
|
||||
function noise(byteLength: number): Uint8Array {
|
||||
const bytes = new Uint8Array(byteLength)
|
||||
let state = 0x9e37_79b9
|
||||
for (let index = 0; index < byteLength; index += 1) {
|
||||
state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0
|
||||
bytes[index] = (state >>> 24) & 0xff
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
function screencastFrame(image: Uint8Array): BrowserScreencastFrame {
|
||||
return {
|
||||
opcode: BrowserScreencastOpcode.Frame,
|
||||
seq: FRAME_SEQ,
|
||||
format: 'jpeg',
|
||||
metadata: CDP_METADATA,
|
||||
image
|
||||
}
|
||||
}
|
||||
|
||||
/** The screencast counter the frame below carries, and the event seq the shell gives its first
|
||||
* event, both needed to reconstruct what the bound assumed about them. */
|
||||
const FRAME_SEQ = 4_096
|
||||
const FIRST_EVENT_SEQ = 1
|
||||
|
||||
/** The one frame the shell posted for this image, and what it cost besides the image. */
|
||||
function postedFrame(image: Uint8Array): { bytes: number; envelope: number; posts: number } {
|
||||
const bridge = harness({ ready: true })
|
||||
bridge.host.receive(
|
||||
clientFrame({
|
||||
type: 'subscribe',
|
||||
id: ID,
|
||||
method: 'browser.screencast',
|
||||
params: { worktree: 'id:w', page: 'p' },
|
||||
wantsBinary: true
|
||||
})
|
||||
)
|
||||
const before = bridge.posted.length
|
||||
// Loudly, because the optional chain below would otherwise turn a subscribe that opened no
|
||||
// binary lane into zero posts, which is what a dropped frame looks like.
|
||||
const emitBinary = bridge.client.streams[0]?.emitBinary
|
||||
if (emitBinary === null || emitBinary === undefined) {
|
||||
throw new Error('the subscribe opened no binary stream')
|
||||
}
|
||||
emitBinary(screencastFrame(image))
|
||||
const json = bridge.posted.at(-1) ?? ''
|
||||
const posts = bridge.posted.length - before
|
||||
const event = posts === 0 ? null : bridge.last()
|
||||
const b64 = event !== null && 'binary' in event ? event.binary.b64 : ''
|
||||
return { bytes: utf8ByteLength(json), envelope: utf8ByteLength(json) - b64.length, posts }
|
||||
}
|
||||
|
||||
describe('the envelope bound against a real posted frame', () => {
|
||||
it('holds above what the shell spends on everything but the image', () => {
|
||||
const { envelope } = postedFrame(noise(64 * 1024))
|
||||
|
||||
expect(binaryEventEnvelopeBytes()).toBeGreaterThanOrEqual(envelope)
|
||||
})
|
||||
|
||||
it('is the same cost whatever the image is, which is what makes it an envelope', () => {
|
||||
// Base64 is ASCII and JSON escapes none of it, so the image contributes its characters and
|
||||
// nothing else. This is the premise the shell prices an unencoded frame on.
|
||||
expect(postedFrame(noise(1_024)).envelope).toBe(postedFrame(noise(256 * 1024)).envelope)
|
||||
})
|
||||
|
||||
/**
|
||||
* The bound, reconstructed from the real frame rather than merely held above it.
|
||||
*
|
||||
* Above-it alone is satisfied by 213 bytes of slack, which is room for the shell to grow the
|
||||
* envelope by a field the page never hears about. The slack is not arbitrary: every byte of it
|
||||
* is a value this frame prints narrower than a double can. Adding exactly those back is the
|
||||
* whole difference, so an envelope field the bound does not know about fails here at one byte.
|
||||
*/
|
||||
it('is exactly what this frame costs once every number is widened to a double', () => {
|
||||
const widen = (value: number): number => WIDEST_JSON_DOUBLE_CHARS - JSON.stringify(value).length
|
||||
const widened =
|
||||
postedFrame(noise(1_024)).envelope +
|
||||
(LARGEST_INTEGER_CHARS - JSON.stringify(FIRST_EVENT_SEQ).length) +
|
||||
(LARGEST_INTEGER_CHARS - JSON.stringify(FRAME_SEQ).length) +
|
||||
METADATA_KEYS.reduce((total, key) => total + widen(CDP_METADATA[key]), 0)
|
||||
|
||||
expect(binaryEventEnvelopeBytes()).toBe(widened)
|
||||
})
|
||||
|
||||
it('covers every metadata field the protocol declares, not the ones this case sends', () => {
|
||||
// If a tenth field is added, `METADATA_KEYS` grows, the bound grows with it, and the frame
|
||||
// above keeps fitting. The guard is that this case sends all of them.
|
||||
expect(Object.keys(CDP_METADATA).sort()).toEqual([...METADATA_KEYS].sort())
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The arithmetic between the budget and the cap, and nothing about what a JPEG really costs.
|
||||
*
|
||||
* Every case here feeds `noise(area * WORST_CASE_JPEG_BYTES_PER_PIXEL)` — a byte count the constant
|
||||
* itself produced — so they cannot falsify the constant, only the expansion and the drop rule
|
||||
* around it. Said plainly because the earlier version of this block read as if it validated the
|
||||
* worst case: it did not, and the constant it agreed with was wrong by enough to post a phone's
|
||||
* frame over the cap. `config/scripts/mobile-web-app-frame-budget-sweep.test.ts` is what encodes
|
||||
* real Chromium JPEGs across the viewport range and holds the constant to them.
|
||||
*/
|
||||
describe('a frame at exactly the budgeted area', () => {
|
||||
/** The image the budget says the mobile view's worst case produces, to the byte. */
|
||||
const BUDGETED_IMAGE_BYTES = Math.floor(
|
||||
mobileBrowserFrameAreaBudget() * WORST_CASE_JPEG_BYTES_PER_PIXEL
|
||||
)
|
||||
|
||||
it('encodes under the frame cap and the shell posts it', () => {
|
||||
const { bytes, posts } = postedFrame(noise(BUDGETED_IMAGE_BYTES))
|
||||
|
||||
expect(posts).toBe(1)
|
||||
expect(bytes).toBeLessThanOrEqual(BRIDGE_MAX_MESSAGE_BYTES)
|
||||
})
|
||||
|
||||
it('is tight: the budget spends nearly the whole cap', () => {
|
||||
// A budget with room to spare is pixels the pane could have had. Within one base64 group plus
|
||||
// the slack the envelope bound deliberately carries.
|
||||
const { bytes } = postedFrame(noise(BUDGETED_IMAGE_BYTES))
|
||||
|
||||
expect(BRIDGE_MAX_MESSAGE_BYTES - bytes).toBeLessThan(binaryEventEnvelopeBytes())
|
||||
})
|
||||
|
||||
it('is a ceiling: an image past it is dropped by the shell, not sent over the cap', () => {
|
||||
// C6 ruling 1 from the budget's side. The area is a worst case, so a real frame this size is
|
||||
// the one the page could not predict, and the shell is what keeps it off the wire.
|
||||
const { posts } = postedFrame(noise(BUDGETED_IMAGE_BYTES + binaryEventEnvelopeBytes()))
|
||||
|
||||
expect(posts).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -19,12 +19,21 @@ export type {
|
||||
/**
|
||||
* The bytes one pixel of this pane's JPEG costs at its worst.
|
||||
*
|
||||
* Measured on this lane's fixtures at quality 72: uniform random noise, which is the image JPEG
|
||||
* compresses least and the ceiling every real page sits under, encoded at 0.545 bytes per pixel.
|
||||
* Photographic content measured near a tenth of that. The number is the worst case rather than a
|
||||
* typical one because it is the one a budget has to survive.
|
||||
* Uniform random noise at quality 72, which is the image JPEG compresses least and the ceiling
|
||||
* every real page sits under; photographic content measures near a tenth of it.
|
||||
*
|
||||
* Swept 2026-09-20 over 143 viewports — widths 320 to 1400 and heights 480 to 1600 — each encoded
|
||||
* by Chromium at the scale `budgetedMobileViewDeviceScaleFactor` picks for it. Across the 111 the
|
||||
* budget fits, the measured cost ranged from 0.54470 to 0.55351 bytes per pixel. This is that
|
||||
* maximum plus a margin of 0.00649, about 1.2%, for the encoder version it was not swept on.
|
||||
*
|
||||
* It was 0.545 before that sweep, taken from one 2400x2160 frame. A single large frame is the
|
||||
* cheapest per pixel in the whole range, so the number it gave was under 90 of those 143 viewports
|
||||
* and the budget it produced posted a frame over the cap on a phone. A worst case measured at one
|
||||
* point is not a worst case; `mobile-web-app-frame-budget-sweep.test.ts` is what holds this one to
|
||||
* the whole range, and re-running it is how this number is changed.
|
||||
*/
|
||||
export const WORST_CASE_JPEG_BYTES_PER_PIXEL = 0.545
|
||||
export const WORST_CASE_JPEG_BYTES_PER_PIXEL = 0.56
|
||||
|
||||
/** Base64 carries three bytes in four characters, and a character is one UTF-8 byte here. */
|
||||
export const BASE64_BYTES_PER_CHARACTER = 3 / 4
|
||||
@@ -62,8 +71,9 @@ const NARROWEST_JSON_DOUBLE_CHARS = 1
|
||||
* predict: the metadata object is a loose one, so a shell may send keys this list has never heard
|
||||
* of, and web view mode's frame is a letterboxed desktop viewport the page cannot size.
|
||||
*
|
||||
* Pinning this against C6.1's real encoder belongs to C6.5, once the encoder and this are both on
|
||||
* main; until then the bound is checked against a serialized envelope of the same shape.
|
||||
* `browser-screencast-budget-at-the-shell.test.ts` is what makes this evidence rather than an
|
||||
* assumption checked against a copy of itself: it reconstructs this number, to the byte, from a
|
||||
* frame the real encoder produced and the real host serialized.
|
||||
*/
|
||||
export function binaryEventEnvelopeBytes(): number {
|
||||
const skeleton = JSON.stringify({
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { C1_PAGE_CLOSURE } from './c1-page-closure'
|
||||
import { C2_PAGE_CLOSURE } from './c2-page-closure'
|
||||
import { C3_PAGE_CLOSURE } from './c3-page-closure'
|
||||
import { C5_PAGE_CLOSURE } from './c5-page-closure'
|
||||
import { C6_BROWSER_CLOSURE_FAMILIES } from './c6-browser-closure-families'
|
||||
import { pageClosureTotals } from './page-closure'
|
||||
|
||||
describe('the C6 browser closure half', () => {
|
||||
it('is the census the design named: 4 families, 15 goldens', () => {
|
||||
const goldens = Object.values(C6_BROWSER_CLOSURE_FAMILIES).flatMap((family) =>
|
||||
Object.keys(family)
|
||||
)
|
||||
expect({
|
||||
families: Object.keys(C6_BROWSER_CLOSURE_FAMILIES).length,
|
||||
goldens: goldens.length
|
||||
}).toEqual({ families: 4, goldens: 15 })
|
||||
expect(new Set(goldens).size).toBe(goldens.length)
|
||||
})
|
||||
|
||||
/** The counts a per-id walk cannot see move: a table wrong the same way twice agrees with itself. */
|
||||
it('pins six byte-identical goldens and nine in one named class', () => {
|
||||
expect(pageClosureTotals(C6_BROWSER_CLOSURE_FAMILIES)).toEqual({
|
||||
identical: 6,
|
||||
'result-absent-settlement': 9
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Every family has a byte-identical golden, which is more than C1 or C2 could say of all of
|
||||
* theirs: for these four the pin holds bytes and not only the name of a divergence.
|
||||
*/
|
||||
it('leaves no family excluded whole', () => {
|
||||
for (const [family, goldens] of Object.entries(C6_BROWSER_CLOSURE_FAMILIES)) {
|
||||
expect({ family, identical: Object.values(goldens).includes('identical') }).toEqual({
|
||||
family,
|
||||
identical: true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/** C2's rule over the new families, which predicted all fifteen; the table is generated, not
|
||||
* hand-corrected, and this is what says so. */
|
||||
it('agrees with the C2 classification rule on every golden', () => {
|
||||
for (const goldens of Object.values(C6_BROWSER_CLOSURE_FAMILIES)) {
|
||||
for (const [id, verdict] of Object.entries(goldens)) {
|
||||
expect({ id, verdict }).toEqual({
|
||||
id,
|
||||
verdict: id.startsWith('matrix-') ? 'result-absent-settlement' : 'identical'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* No family here is pinned by another series, so a golden pinned twice is pinned once.
|
||||
*
|
||||
* The four are the pane's own and nothing else imports its call sites; `session.browser-tab-create`
|
||||
* is recorded at the session screen and belongs to C7.
|
||||
*/
|
||||
it('shares no family with C1, C2, C3 or C5', () => {
|
||||
const others = new Set([
|
||||
...Object.keys(C1_PAGE_CLOSURE),
|
||||
...Object.keys(C2_PAGE_CLOSURE),
|
||||
...Object.keys(C3_PAGE_CLOSURE),
|
||||
...Object.keys(C5_PAGE_CLOSURE)
|
||||
])
|
||||
expect(Object.keys(C6_BROWSER_CLOSURE_FAMILIES).filter((family) => others.has(family))).toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { PageClosurePins } from './page-closure'
|
||||
|
||||
/**
|
||||
* The goldens recorded at a call site inside the browser pane's closure, and what each one did at
|
||||
* the bridge.
|
||||
*
|
||||
* A half with no composed `c6-page-closure.ts` beside it, unlike every series before this one. A
|
||||
* composed table is pinned against a route and C6 has none: the browser is a pane of the session
|
||||
* screen, mounted from `MobileSessionActiveContent`, so C7 is what registers a route that reaches
|
||||
* it. C7 spreads this table beside C1's. The derivation census does not wait for that route: it reads
|
||||
* the pane's closure from the module itself, in `mobile-web-app-page-closure-families.test.mjs`.
|
||||
*
|
||||
* Derived from the value-import closure of `MobileBrowserPane` with `.web.*` resolution applied,
|
||||
* measured through the builder's own options: 48 local modules on its own, and 34 beyond the shared
|
||||
* layout, of which 30 are under `src/browser` and four are reached through its web siblings —
|
||||
* `bridge-envelope.ts`, `bridge-error-capture.ts`, `browser-screencast-protocol.ts` and
|
||||
* `rpc-response-shape.ts`, which the frame budget and the binary decoder pull in. The corpus records
|
||||
* the pane at exactly two sites, `use-mobile-browser-request.ts` and
|
||||
* `use-mobile-browser-commands.ts`, and the pane alone reaches all four families: the shared layout
|
||||
* contributes none of them.
|
||||
*
|
||||
* **What 15 certified does not say.** `browser.screencast` has no golden at all. The corpus records
|
||||
* the thirteen page commands and never the stream, so this pin certifies the input path — the taps,
|
||||
* the wheel, the keyboard and the dialogs — byte for byte, and says nothing whatever about the
|
||||
* frame path that C6.1 through C6.4 built. That is what the device proof has to carry.
|
||||
*
|
||||
* Every verdict is what C2's rule predicts: a `matrix-` golden is `result-absent-settlement`, for
|
||||
* the reason the parity suite's own docstring gives, and every other replays byte-identically. All
|
||||
* fifteen were measured per family with vitest `-t` over the full 787-golden corpus, with C1's 103
|
||||
* pins reproduced golden-for-golden as the control for the harness that measured them.
|
||||
*/
|
||||
export const C6_BROWSER_CLOSURE_FAMILIES: PageClosurePins = {
|
||||
'browser.dialog': {
|
||||
'browser-dialog-accepted': 'identical',
|
||||
'browser-dialog-dismissed': 'identical',
|
||||
'matrix-browser.dialog-browser.dialogaccept-1': 'result-absent-settlement'
|
||||
},
|
||||
'browser.keyboard': {
|
||||
'browser-keyboard-input': 'identical',
|
||||
'matrix-browser.keyboard-browser.keyboardinserttext-1': 'result-absent-settlement',
|
||||
'matrix-browser.keyboard-browser.keypress-1': 'result-absent-settlement'
|
||||
},
|
||||
'browser.pointer-click': {
|
||||
'browser-pointer-click-accepted': 'identical',
|
||||
'browser-pointer-click-fallback': 'identical',
|
||||
'matrix-browser.pointer-click-browser.mouseclick-1': 'result-absent-settlement',
|
||||
'matrix-browser.pointer-click-browser.mousedown-1': 'result-absent-settlement',
|
||||
'matrix-browser.pointer-click-browser.mousemove-1': 'result-absent-settlement',
|
||||
'matrix-browser.pointer-click-browser.mouseup-1': 'result-absent-settlement'
|
||||
},
|
||||
'browser.wheel': {
|
||||
'browser-wheel-scrolled': 'identical',
|
||||
'matrix-browser.wheel-browser.mousemove-1': 'result-absent-settlement',
|
||||
'matrix-browser.wheel-browser.mousewheel-1': 'result-absent-settlement'
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type BridgedParityEvidence
|
||||
} from '../bridged-parity/divergence-classes'
|
||||
import { C5_PAGE_CLOSURE } from '../bridged-parity/c5-page-closure'
|
||||
import { C6_BROWSER_CLOSURE_FAMILIES } from '../bridged-parity/c6-browser-closure-families'
|
||||
import { C2_PAGE_CLOSURE } from '../bridged-parity/c2-page-closure'
|
||||
import { C1_PAGE_CLOSURE } from '../bridged-parity/c1-page-closure'
|
||||
import { C3_PAGE_CLOSURE } from '../bridged-parity/c3-page-closure'
|
||||
@@ -401,6 +402,23 @@ describe.skipIf(process.env[BRIDGED_PARITY_FLAG] === BRIDGED_PARITY_OFF)(
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* The browser pane's half, checked the same way and for the same reason the composed tables are.
|
||||
*
|
||||
* A half rather than a page closure because C6 registers no route — C7 composes this beside
|
||||
* C1's — but a table nothing reads is not a pin, so the run is held to it here from the series
|
||||
* that derived it rather than from the one that will inherit it.
|
||||
*/
|
||||
it('gives every golden the C6 browser closure records the verdict it is pinned to', () => {
|
||||
process.stdout.write(readPageClosure('C6', C6_BROWSER_CLOSURE_FAMILIES, observed))
|
||||
expect({ closure: pageClosureDrift(C6_BROWSER_CLOSURE_FAMILIES, observed) }).toEqual({
|
||||
closure: []
|
||||
})
|
||||
expect(pageClosureRunTotals(C6_BROWSER_CLOSURE_FAMILIES, observed)).toEqual(
|
||||
pageClosureTotals(C6_BROWSER_CLOSURE_FAMILIES)
|
||||
)
|
||||
})
|
||||
|
||||
it('gives every golden the C3 page closure records the verdict it is pinned to', () => {
|
||||
process.stdout.write(readPageClosure('C3', C3_PAGE_CLOSURE, observed))
|
||||
// 28 families and 125 goldens, C1's 22 among them and inherited rather than re-derived, so
|
||||
|
||||
Reference in New Issue
Block a user