Files
orca/config/scripts/mobile-web-app-terminal-render.test.mjs
T
Jinwoo Hong 2077956254 fix(mobile): size a terminal's first subscribe from the document's reported cell box (#23080)
* fix(mobile): size a terminal's first subscribe from the document's reported cell box

#22960 sent phone dims on a terminal's first subscribe by opening a throwaway
empty terminal (init 80x24 ""), awaiting its ready and measuring, behind a
per-document first-subscribe mark whose lifetime was tied to web-ready. That
cost a second xterm/WebGL instance and ~150 ms per open, plus lifecycle state.

The document now measures the cell box without a terminal (xterm 6's
CharSizeService strategy, rounded as the renderer rounds it) for every
text-size preset and reports it with its viewport in web-ready; a table,
because the text scale only reaches the document after that notify. Each
init's ready reports the box xterm actually laid out, which replaces the
probe's entry. The controller answers fitDimensions/measureFitDimensions from
that table and the view's layout with no message; without a table it asks the
document as before.

The session seeds an unmeasured viewport synchronously in subscribeToTerminal,
so the first subscribe carries dims by construction. Deleted: the empty init,
its awaitReady gate, deferFirstSubscribeUntilViewportMeasured and the
subscribedDocuments mark. The fit pass is unchanged and still covers a
document that reports no cell box.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): read the reported cell box through in-narrowing, not Reflect.get

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): correct the probe's cell-box guess from the box xterm lays out

The web-ready probe is a guess: building the WebGL addon creates no context,
so a context that fails on load lands on the DOM renderer, whose width is not
snapped and depends on the column count. Before, a ready box that differed was
only logged; the first subscribe had carried the wrong column count, the host
echoed it, the fit pass saw the viewport equal to the host's dims, and the grid
stayed slightly shrunk. The store also kept the WebGL width after a context loss.

The document now reports the box xterm laid out whenever it changes (from
onRender, which covers a renderer swap and a DPR change that
onDimensionsChange does not fire for, and at ready). The store replaces the
guess; when that changes the current text size's entry, the view calls
onCellBoxChange with xterm's grid and the session re-fits, running the bounded
fit pass if the dims moved (one resubscribe). Equal boxes do nothing.

The RN layout box now survives a document reload; the document's own viewport
only stands in until the view reports a layout (on the page, web-ready arrives
first). The mismatch console.log is gone, and the probe's rounding names the
xterm version it copies.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): correct each cell-box guess at most once, so a DOM renderer cannot loop

On the DOM renderer the cell width is the rounded canvas width divided by the
column count, so every re-init at new cols reported a new box. Each one counted
as a correction, a floor over floats could flip the fit between two sizes, and
each flip landed converged, which reset the resubscribe budget: an unbounded
series of full-snapshot resubscribes.

Only the first laid-out box for a guessed text size may be a correction; later
reports still update the store, so fits stay truthful, but never resubscribe on
their own. The fit's floor gains a 1e-6 epsilon so floating-point error at an
exact boundary cannot flip a column or row. New document tests pin the render
report after a renderer swap and the report at ready for a paused renderer.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): make xterm the only terminal cell measurer

The document builds its real terminal before web-ready, at the app's
text scale, and reports the box xterm laid out; the first init reuses
that terminal. The page-side prediction, the per-scale guess table and
the once-per-document correction are gone. The app remembers the box
per text scale for its lifetime, so a later open at a known scale
subscribes with phone dims at once. A box that changes at the same grid
(renderer swap, pixel ratio) refits the open terminal in place.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep commands queued before the terminal WebView first loads

A subscribe sized from the stored cell box can queue init before the
native WebView reports its first load start, which cleared the queue
and left the terminal blank. Only a reload now drops queued commands.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): re-init a document that lacks the subscription's init, and fit one frame width

- Web-ready now says whether the document holds the terminal's latest init
  (a reload before the first ready drops a queued one); the session
  resubscribes any initialized terminal whose document lacks it.
- One grid fit, shared by the app and the document, fed the unrounded frame
  width React Native laid out; it keeps exact fits whole at fractional pixel
  ratios. The document's viewport-width fits are gone.
- The page builds every document at the scale the view mounted with, as the
  native WebView does.
- A new document's first cell box is compared against the grid the
  subscribe fitted from the stored box.
- The terminal built before ready stays hidden until its first init.
- The cell-box census matches glyph-measurement techniques, not names;
  the store's unused clear() is gone.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): build the terminal before ready only for the view shown at mount

A session mounts one terminal view per tab, and each built xterm and a
WebGL context before ready: 20 tabs made 20 contexts at load, past the
~16 a page (or Android's shared WebView renderer) holds, and native logged
32 context losses. Only the view shown when it mounts builds early now;
the rest build at their first init as before. Deferring the WebGL addon
instead would change the reported box: the DOM renderer lays out 7.8x15
where WebGL lays out 7.667x15 at the same font.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write a WebView document's start values into its page, not an injected script

Android ran the pre-content injected script after the document's own in
1 of 22 documents on the emulator; that document started with no text
scale or shown flag and built a terminal it should not have. The values
now sit in the page ahead of the document script, one source object per
start pair so a render never reloads the WebView.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): the pre-ready terminal measures and reports while hidden

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): measure only the laid-out frame, and refit on a new grid, not a new width

- A measure needs both of the frame's dimensions from React Native; the
  document's viewport-height fallback is gone, and before the first layout
  the handle answers no fit without asking the document.
- A frame width change that still fits the PTY's grid from the stored box is
  a no-op, so sub-pixel layout jitter no longer re-measures. The width ref is
  written in that effect rather than during render (react-doctor).
- One "last grid" ref: the last reported grid, or the one a subscribe fitted
  from the stored box.
- The page render rig measures through the frame it laid out, as the session
  does, and lets the replay's fit settle before its resize-refit witness.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): let only the current terminal document's ready flush

A reload kept the WebView and its onMessage, so the old document's late
web-ready flushed the queue into the reloading view and the new document
got a second init. Each document now gets its own view (keyed on a
generation the controller owns), every notify carries the generation of
the view that received it, and a web-ready from a replaced document
flushes nothing and stamps nothing.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): drop every notify from a replaced terminal document

One rule at the receive boundary: a notify from any generation but the
current one is dropped, whatever its type, not only web-ready.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make fitDimensions a pure question; name each generation counter

- fitDimensions no longer records the grid. A width change to a new grid
  asked it first, so the DOM renderer's report of that grid's box read as
  "same grid, new box" and refit again. Only the first-subscribe seed
  (seedFitDimensions) records the grid the document's first report is
  checked against.
- viewGeneration counts the views, readyGeneration counts web-readies.
- replaceDocument no longer resets the load flag; the load-start reset
  stays as the guard for a view that reloads itself.
- The name-based lifecycle census is replaced by a behavioural test.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): typecheck the handle mocks, drop the unused cell-box get

- The two handle mocks carry both fitDimensions and seedFitDimensions,
  and the fake-timer acts return nothing, so the three test files check
  under tsconfig.test.json again.
- terminalCellBoxes.get had no product caller; the store's tests assert
  through fit.
- The load-start comment says what the controller does now.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hold the grid the document has, ignore a replaced view's load start, dispose a failed pre-ready terminal

- The document reports a new grid even with an unchanged box, so an
  in-place reflow on WebGL is held before a later renderer swap at that
  grid; the swap then refits. The app's apply paths do not hold the grid
  themselves: the DOM renderer's box follows cols, and a grid held on
  apply would read its own box as a renderer change and loop. One
  writer (holdGrid) holds the seeded or reported grid.
- A load start from a view a replacement unmounted is ignored, as its
  notifies already are.
- A terminal whose open throws before ready is disposed, not only
  unreferenced.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): ignore every native event from a replaced terminal view

One wrapper binds each WebView lifecycle event (load start, error, HTTP
error, render process gone, content process terminated) to the view's
generation, so a replaced view's late event cannot reset, replace or
put an error over the current document.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): a DOM seed refits once on its first report, not on the refit's own

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): subscribe a terminal only after its document is ready

The document still builds its terminal before ready and reports the cell
box xterm laid out in web-ready; the app now subscribes after that ready
and fits from that box, so nothing is sent to a document before it is
ready. Everything that made a pre-ready subscribe safe goes: the
app-lifetime box store, the seed fit, the per-document view generations
and their event filtering, the init tracker and the hasInit resubscribe.
The native view reloads in place again and web-ready keeps main's reload
rule. Boxes are kept per view; the grid a document last reported still
guards the in-place refit against the DOM renderer's cols-dependent box.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): hold one reported cell box and the grid the subscribe fitted

The controller keeps only the box the current document last reported,
not a per-text-size store: the document re-reports on a scale change.
The subscribe after ready fits from that box and holds the grid it
fitted, so the DOM renderer's first report at that grid (a new box)
refits once in place and converges; refit and apply paths hold nothing.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): fit only a ready box at the app's scale; forget a reloaded document's box and grid

A reload keeps the document's mount scale, so a ready after a text-size
change reports a box at the old scale; that box no longer sizes the first
subscribe, which then takes the no-box path. A readiness reset drops the
old document's box and held grid, so the new document's first DOM report
at the same grid does not refit.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): give the terminal document its frame at init, and fit text scale over it only

A subscribe sized from the ready box sends no measure, so the document
had no frame when the text size changed and reported the pre-refit row
pitch. The app's init now carries the frame it laid out, in the fields a
measure uses; the router takes it from either. The text-scale fit reads
only that frame, with no viewport fallback.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say why a frameless text-scale change skips the resize

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): one cell box per terminal notify, not an array

web-ready and cell-metrics carry `cellBox: {fontScale, cellWidth,
cellHeight} | null`; the document's `laidOutCellBox` returns one or
null and the parser validates one object. The text-scale match moves
from web-ready into `handle.fitDimensions`, the one place a box is fitted.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): fit terminals in the app from the reported box; drop the measure round trip

The app already holds the box the document reported, so the refit and
the fit pass await the init's ready and call `handle.fitDimensions`
instead of posting `measure` and waiting on `measure-result`. The
document's measure, its retries, and the measure promise and timeout go.
The document still resizes locally on a text-size change, so every grid
the app sends (init, resize, reflow) carries the laid-out frame it was
fitted to. `holdSubscribedGrid` replaces `subscribeFitDimensions`, so
the only fits are `fitDimensionsFromCell` and `handle.fitDimensions`.
The render rig reads its fit from the ready box. The recorder adapter
mounts the new handle with the same recorded effects; the goldens it
mounts move on their adapterSha256 header only.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): keep the terminal frame in one ref, and notify a new width imperatively

The session held the frame in a height ref, a width ref, a width state
and the refit's own width ref. It now holds one `terminalFrameRef`
({width, height} | null until the first layout; a hidden 0x0 layout
keeps the last box). onLayout notifies a new width imperatively, as it
does height, and the refit's notify skips a width whose fit is the grid
the PTY has. `terminal-frame-width-refit.ts`, the width state and its
effect go. The subscribe's layout gate reads "no frame yet" directly.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): subscribe a held-back terminal on the frame's first layout only

`handleTerminalFrameLayout` ran on every onLayout; it now runs once, when
the frame first has a size. Later layouts only notify a new width.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): size the first subscribe inline in subscribeToTerminal

`sizeTerminalViewportFromCellBox` wrapped five lines in a 37-line
module; the subscribe now fits the ready box against the frame, holds
that grid and records the diagnostic itself. The helper's tests fold
into the subscription tests, which move to the subscription's name.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): drop the unreachable font-size guard on the reported cell box

xterm 6.1.0-beta.303 updates the render service's cell box in the same
task that sets `options.fontSize`: CharSizeService.measure fires
onCharSizeChange, and RenderService.handleCharSizeChanged runs the
renderer's `_updateDimensions` (DomRenderer.ts:359, WebglRenderer.ts:229).
`term.onRender` fires from RenderService._renderRows after the rows
are drawn (RenderService.ts:213, CoreBrowserTerminal.ts:538), and the
document writes its text scale and the font size in one task
(text-scaling.ts applyTextScale, terminal-init.ts init). So no report
can read a box between the font and the scale; the guard and its test
go. A new test pins the real order: no report when the font is set,
the new box at the new scale on the next render.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): one start seam, no source cache, the reported box as an object

- `useState` already pins each view's WebView source at mount (a new
  test re-renders at another text scale and gets the same object), so
  the module-level `webViewSources` Map goes.
- `initialTextScale` and `buildsTerminalBeforeReady` become one
  `start(): { textScale, shown }` seam.
- `reportedCellBox` holds the last reported box and grid, not a string key.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the RPC recordings to this branch and re-record

The terminal refit now fits in the app from the reported box and reads
one frame ref, so the recorder's terminal adapter mounts the new handle
(`awaitReady` + `fitDimensions`) and options (`terminalFrameRef`),
keeping its recorded effects. `baseline` is repinned to 21954dbd2f, the
last commit to touch a fenced path, and every golden is re-recorded.
Proof by class against HEAD: 787 header-only, 0 body moved, 0 added,
0 deleted. Header keys moved: `baseline` on all 787, and `adapterSha256`
on the 14 goldens `terminal-mount-adapters.ts` mounts (query-reply 3,
accessory-raw-send 4, takeover-report 4, viewport-refit 3). No recorded
traffic moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): hold the reported cell box and its grid in one ref

The controller kept the box in `cellBoxRef`, the grid in a string
`lastGridRef` and wrote it through `terminal-held-grid.ts`. One
`heldRef` now holds `{ cellBox, grid }`, as the document's own
`reportedCellBox` does: web-ready writes the box, every cell-metrics
report writes both, `holdSubscribedGrid` writes the grid, and a
readiness reset clears it. Same write points, so the one-refit bound
holds; the DOM-loop and refit-once tests pass unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the RPC recordings to the hold-rule commit and re-record

H (f00bebba48) touched a fenced path after the last repin, so
`baseline` moves to it and every golden is re-recorded. Against the
corpus before this branch's refreshes (21954dbd2f): 787 header-only,
0 body moved, 0 added, 0 deleted; `baseline` on all 787 and
`adapterSha256` on the 14 goldens `terminal-mount-adapters.ts` mounts.
Against the previous refresh: `baseline` only. No recorded traffic moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the RPC recordings to the main merge and re-record

The merge (b3b1b0def2) is the last commit to touch a fenced path, so
`baseline` moves to it and every golden is re-recorded. Against
97b5bb2b9a: 787 header-only, 0 body moved, 0 added, 0 deleted;
`baseline` on all 787, and `adapterSha256` on the 14
session.diff-review-actions goldens whose adapter #22951 edited. Against
origin/main: 787 header-only, 0 body moved/added/deleted; `baseline` on
all 787 and `adapterSha256` on this branch's 14 terminal goldens.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): the terminal document holds the grid and decides each refit

The document already kept the last reported box and grid; the app kept
a mirror of both to decide the refit. Now the document decides: its
`cell-box` notify carries `{ cellBox, refit }`, sent only when the box
changes, with `refit` a box that changed at a kept grid. web-ready
records the pre-ready terminal's box at its 80x24 grid, and the first
init that reuses that terminal holds the init's grid, so the DOM
renderer's first report refits once, as the subscribe's hold did. A
re-init no longer clears the record, so a new renderer at the same grid
still refits. The app keeps one `cellBoxRef` and `holdSubscribedGrid`,
`heldRef` and the grid on the notify go. The one-refit, DOM-loop and
renderer-swap tests move to the document with the same scenarios.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): one init options object, and a frame on every grid

`init` takes `{ cols, rows, data, preserveScroll, oscLinks, frame }`
instead of six positionals, and `init`, `resize` and `reflow` (handle
and messages) require `frame: TerminalFrame | null`. The refit's reflow
check reads `!dims` alone, and the controller's test file is named for
the `cell-box` notify it now covers.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): one notifyTerminalFrame for the frame's layout

The frame's onLayout made four calls and held the classification
itself. It now calls `notifyTerminalFrame({ width, height })`, and the
session's terminal-webview hook keeps the one frame ref, notifies the
height, subscribes the document held back for the first layout, and
notifies a later width change. `handleTerminalFrameLayout` is named for
what it does: `subscribeIntendedActiveTerminal`. The layout tests move
to that hook.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the RPC recordings to the round-8 head and re-record

85d421963c is the last commit to touch a fenced path. Against
a676c1b65a: 787 header-only, 0 body moved/added/deleted, `baseline`
only. Against origin/main: 787 header-only, 0 body moved/added/deleted;
`baseline` on all 787 and `adapterSha256` on this branch's 14 terminal
goldens. No recorded traffic moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): name the init option initialData, as the message does

The init option `data` becomes `initialData`, the message field's name,
so the controller passes it through unrenamed. The `preserveScroll` why
stays on the message type only, and the document test's title names the
three grids that carry the frame.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the RPC recordings to the round-9 head and re-record

486566c82b is the last commit to touch a fenced path. Against
3371c39715: 787 header-only, 0 body moved/added/deleted, `baseline`
only. Against origin/main: 787 header-only, 0 body moved/added/deleted;
`baseline` on all 787 and `adapterSha256` on this branch's 14 terminal
goldens. No recorded traffic moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* ci: rerun checks against main with #23560 landed

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-28 03:26:49 -04:00

686 lines
32 KiB
JavaScript

import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
import {
escapeDenseStream,
FIRST_MARKER,
LAST_MARKER,
MIN_STREAM_BYTES
} from './mobile-web-app-terminal-probe-route.mjs'
import {
CONTROL_ROUTE,
openProbeTerminal,
PROBE_ROUTE,
startTerminalRenderFixture
} from './mobile-web-app-terminal-render-fixture.mjs'
import { readRootComputedStyles, terminalStyleReach } from './mobile-web-app-render-harness.mjs'
/**
* The page's terminal, in a real browser, under the policy the shell sends.
*
* Everything below the contract is new on the page: xterm is an import rather than a 612 KiB
* string in a WebView document, the document's modules run in the page's own realm, and the
* stylesheet and the elements they read by id are planted by the component. None of that is
* settled by a module test. What a browser settles is whether it opens at all under
* `script-src 'self'` with no `unsafe-inline` and no `unsafe-eval`, whether a real terminal byte
* stream reaches the buffer intact, and whether anything the page does is refused by the policy.
*
* The stream is deliberately escape-dense: colour changes, cursor moves and erases at every cell
* boundary, which is the shape that expands worst through the transport and the shape a TUI
* actually paints. It is read back through the document's own selection path — select all, then
* the Copy button the overlay carries — so the oracle is the component's `onSelectionCopy` prop
* and not a private reach into xterm.
*
* No route serves this screen until C7.7, so the component is bundled through a scratch route
* tree. That step retires the moment the session route is registered.
*/
const bundles = mobileWebAppDependenciesPresent()
const describeRender = bundles ? describe : describe.skip
let fixture = null
let controlCspViolations = []
const stream = escapeDenseStream()
const openPage = (pathname, options) => fixture.openPage(pathname, options)
const openTerminal = (options) => fixture.openTerminal(options)
beforeAll(async () => {
if (!bundles) {
return
}
fixture = await startTerminalRenderFixture()
}, 600_000)
afterAll(async () => {
await fixture?.close()
})
/** Violations this page recorded that the control did not, which is the terminal's own account. */
async function terminalCspViolations(page) {
const seen = await page.evaluate(() => globalThis.__orcaCspViolations)
const shared = new Set(controlCspViolations.map(stripAssetPath))
return seen.map(stripAssetPath).filter((entry) => !shared.has(entry))
}
/** The asset name is a content hash and the port is per run; neither is part of the finding. */
function stripAssetPath(entry) {
return entry.replace(/ @ .*$/, '')
}
describeRender(
'the terminal on the page',
() => {
it('records what the page refuses before any terminal is on it', async () => {
// Run first, and the two cases below subtract it, so their zero is the terminal's own
// account rather than the bundle's. A control that mounted nothing would report nothing for
// the wrong reason, so the route's own marker is the precondition.
const { page } = await openPage(CONTROL_ROUTE)
await page.waitForFunction(() => globalThis.__orcaTerminalControlMounted === true, {
timeout: 60_000,
polling: 100
})
controlCspViolations = await page.evaluate(() => globalThis.__orcaCspViolations)
console.log('[c7.5][csp-control]', JSON.stringify(controlCspViolations.map(stripAssetPath)))
// Nothing, which is a stronger fact than this case was built for. It first read
// `script-src: eval` — Zod probing for a JIT with `new Function` and swallowing the throw,
// so no page error and no console line reported it — and main's jitless banner closed that
// before this branch merged it. The subtraction stays: it is what makes the cases below say
// "the terminal added none" rather than "none were seen".
expect(controlCspViolations.map(stripAssetPath)).toEqual([])
await page.close()
}, 300_000)
it('opens xterm under the shipped policy and paints a dense stream into its buffer', async () => {
const { errors, page } = await openTerminal()
await openProbeTerminal(page)
const applied = await page.evaluate((data) => {
globalThis.__orcaTerminalProbe.write(data)
return data.length
}, stream)
expect(applied).toBeGreaterThanOrEqual(MIN_STREAM_BYTES)
// Read back through the document's own path: select all, then the overlay's Copy button,
// which posts the buffer text to the component's onSelectionCopy prop.
await page.evaluate(() => globalThis.__orcaTerminalProbe.selectAll())
await page.waitForFunction(
() => document.getElementById('selection-overlay')?.classList.contains('active') === true,
{ timeout: 30_000, polling: 100 }
)
await page.evaluate(() => document.getElementById('sel-menu-copy').click())
await page.waitForFunction(() => typeof globalThis.__orcaTerminalCopied === 'string', {
timeout: 30_000,
polling: 100
})
const copied = await page.evaluate(() => globalThis.__orcaTerminalCopied)
console.log(
'[c7.5][stream]',
JSON.stringify({ appliedBytes: applied, readBackChars: copied.length })
)
expect(copied).toContain(FIRST_MARKER)
expect(copied).toContain(LAST_MARKER)
// The escapes were consumed by the parser rather than printed as text.
expect(copied).not.toContain('\u001b')
expect(copied).not.toContain('[31;1m')
expect(await terminalCspViolations(page)).toEqual([])
expect(await page.evaluate(() => globalThis.__orcaTerminalEngineErrors)).toEqual([])
expect(errors).toEqual([])
await page.close()
}, 300_000)
it('leaves the page its own window.onerror across mount and dispose', async () => {
// The page installs a handler before the bundle loads, so the terminal meets one that is
// not its to take. Identity is checked in the page: the same function object at all three
// points, not merely a non-null one and not merely the same shape.
const { page } = await openTerminal({ errorSentinel: true })
expect(await page.evaluate(() => window.onerror === globalThis.__orcaSentinel)).toBe(true)
await openProbeTerminal(page)
expect(await page.evaluate(() => window.onerror === globalThis.__orcaSentinel)).toBe(true)
// Both reporters see the same uncaught error: the page keeps the one it installed, and the
// terminal's own listener still works. Without the second half the readings above would
// pass on a terminal that had simply stopped reporting.
await page.evaluate(() => {
setTimeout(() => {
throw new Error('orca-terminal-render-uncaught')
}, 0)
})
const sawIt = (entries) =>
entries.some((entry) => entry.includes('orca-terminal-render-uncaught'))
await page.waitForFunction(
() =>
globalThis.__orcaTerminalEngineErrors.some((entry) =>
entry.includes('orca-terminal-render-uncaught')
),
{ timeout: 30_000, polling: 100 }
)
expect(sawIt(await page.evaluate(() => globalThis.__orcaSentinelCalls))).toBe(true)
// Dispose takes the terminal's listener off and leaves the page's handler where it was.
await page.evaluate(() => globalThis.__orcaTerminalProbe.setMounted(false))
await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 })
expect(await page.evaluate(() => window.onerror === globalThis.__orcaSentinel)).toBe(true)
const before = await page.evaluate(() => {
setTimeout(() => {
throw new Error('orca-terminal-render-after-dispose')
}, 0)
return globalThis.__orcaTerminalEngineErrors.length
})
await page.waitForFunction(
() =>
globalThis.__orcaSentinelCalls.some((entry) =>
entry.includes('orca-terminal-render-after-dispose')
),
{ timeout: 30_000, polling: 100 }
)
// The page's handler saw it and the terminal's did not, which is what dispose has to mean.
expect(await page.evaluate(() => globalThis.__orcaTerminalEngineErrors.length)).toBe(before)
await page.close()
}, 300_000)
it('installs no window.onerror on a page that had none', async () => {
// The other half: with nothing installed the terminal must not leave one behind either, so
// a later consumer still finds the slot free.
const { page } = await openTerminal()
expect(await page.evaluate(() => window.onerror)).toBe(null)
await openProbeTerminal(page)
expect(await page.evaluate(() => window.onerror)).toBe(null)
await page.evaluate(() => globalThis.__orcaTerminalProbe.setMounted(false))
await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 })
expect(await page.evaluate(() => window.onerror)).toBe(null)
await page.close()
}, 300_000)
/**
* A terminal that is mounted, taken down and mounted again has to be a terminal again.
*
* The document's modules are ES modules: their bodies run once per page, so anything they did
* as they were parsed — reading their elements by id, installing the error reporter, adding
* listeners — a second mount would inherit from the first, pointing at elements that are no
* longer in the document. Nothing above the contract would notice: `onWebReady` still fires,
* because readiness is the component's own handshake and not a claim about the engine.
*
* So the assertions are about the live DOM and the live paths, not about readiness.
*/
/** The listeners the page holds with no terminal on it, which is what two mounts can differ by. */
async function listenersWithNoTerminal(page) {
await page.evaluate(() => globalThis.__orcaTerminalProbe.setMounted(false))
await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 })
return page.evaluate(() => globalThis.__orcaListeners.snapshot())
}
async function assertLiveTerminal(page, label) {
await page.locator('#terminal-surface .xterm').waitFor({ state: 'attached', timeout: 30_000 })
expect(
await page.evaluate(() => document.querySelectorAll('#terminal-surface .xterm').length),
`${label}: xterm elements in the live DOM`
).toBeGreaterThan(0)
// The selection overlay is the document's own element, reached through the handle: it only
// activates if `handleMsg` is talking to the elements that are actually on the page.
await page.evaluate(() => globalThis.__orcaTerminalProbe.selectAll())
await page.waitForFunction(
() => document.getElementById('selection-overlay')?.classList.contains('active') === true,
{ timeout: 30_000, polling: 100 }
)
// And the reporter, which is the seam that is installed once per mount.
const marker = `orca-remount-${label}`
await page.evaluate((thrown) => {
globalThis.__orcaTerminalEngineErrors = []
setTimeout(() => {
throw new Error(thrown)
}, 0)
}, marker)
await page.waitForFunction(
(thrown) => globalThis.__orcaTerminalEngineErrors.some((entry) => entry.includes(thrown)),
marker,
{ timeout: 30_000, polling: 100 }
)
}
it('is a live terminal again after an unmount and a remount', async () => {
const { page } = await openTerminal()
await openProbeTerminal(page)
await assertLiveTerminal(page, 'first-mount')
await page.evaluate(() => globalThis.__orcaTerminalProbe.setMounted(false))
await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 })
await page.evaluate(() => {
globalThis.__orcaTerminalReady = false
globalThis.__orcaTerminalProbe.setMounted(true)
})
await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, {
timeout: 60_000,
polling: 100
})
await openProbeTerminal(page)
await assertLiveTerminal(page, 'remount')
await page.close()
}, 300_000)
it('is a live terminal again after the user reloads a failed one', async () => {
// The other way a second mount happens, and the one a user reaches: the terminal fails
// before it is ready, the engine-error overlay appears, and Reload disposes the document
// and builds another inside the same component. Driven end to end rather than by calling
// the handler — an uncaught error before the first `init` is fatal by the document's own
// rule, which is what puts the overlay on screen.
const { page } = await openTerminal()
await page.locator('#terminal-container').waitFor({ state: 'attached', timeout: 30_000 })
await page.evaluate(() => {
setTimeout(() => {
throw new Error('orca-terminal-render-fatal')
}, 0)
})
const reload = page.getByText('Reload')
await reload.waitFor({ timeout: 30_000 })
await page.evaluate(() => {
globalThis.__orcaTerminalReady = false
})
await reload.click()
await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, {
timeout: 60_000,
polling: 100
})
await openProbeTerminal(page)
await assertLiveTerminal(page, 'after-reload')
await page.close()
}, 300_000)
it('leaves the page the listeners it found, across a mount and a dispose', async () => {
// Ruling 20 moved every install into a start function and ruling 21 gave each one a stop,
// and the document installs on `window` and `document` both: the resize refit, the error
// reporter, the tap and gesture listeners the surface modules arm. A stop that forgets one
// does not fail anything visible — the next mount simply adds a second copy, and the page
// accumulates a listener per terminal it has ever shown.
//
// The comparison is drawn across a second mount rather than against the bare page: the
// component mounts as the route does, so there is no moment before the first terminal to
// photograph. Both readings are taken with no terminal on the page, so a mount that leaks
// once leaks again and the two disagree.
const { page } = await openTerminal({ listeners: true })
await openProbeTerminal(page)
const before = await listenersWithNoTerminal(page)
await page.evaluate(() => {
globalThis.__orcaTerminalReady = false
globalThis.__orcaTerminalProbe.setMounted(true)
})
await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, {
timeout: 60_000,
polling: 100
})
await openProbeTerminal(page)
const whileLive = await page.evaluate(() => globalThis.__orcaListeners.snapshot())
const after = await listenersWithNoTerminal(page)
// The precondition: a mount that installed nothing would satisfy the equality below for
// exactly the reason the case exists to refuse.
expect(whileLive, 'the mount installed listeners the dispose has to take back').not.toEqual(
before
)
expect(after).toEqual(before)
await page.close()
}, 300_000)
it('still reports runtime errors after a first mount spent the non-fatal budget', async () => {
// Ruling 21's finding, end to end. `reportEngineError` caps non-fatal notifies at five so a
// per-frame thrower cannot flood the host. That counter is the document's, not the mount's:
// a first terminal that spends it leaves the second one mute, reporting nothing however it
// fails, while every other signal — readiness, paint, selection — says the terminal is fine.
const { page } = await openTerminal()
await openProbeTerminal(page)
await page.evaluate(() => {
for (let index = 0; index < 6; index++) {
setTimeout(() => {
throw new Error(`orca-budget-burn-${String(index)}`)
}, 0)
}
})
await page.waitForFunction(
() =>
globalThis.__orcaTerminalEngineErrors.filter((entry) =>
entry.includes('orca-budget-burn')
).length >= 5,
{ timeout: 30_000, polling: 100 }
)
await page.evaluate(() => globalThis.__orcaTerminalProbe.setMounted(false))
await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 })
await page.evaluate(() => {
globalThis.__orcaTerminalReady = false
globalThis.__orcaTerminalProbe.setMounted(true)
})
await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, {
timeout: 60_000,
polling: 100
})
await openProbeTerminal(page)
await page.evaluate(() => {
globalThis.__orcaTerminalEngineErrors = []
setTimeout(() => {
throw new Error('orca-second-mount-error')
}, 0)
})
await page.waitForFunction(
() =>
globalThis.__orcaTerminalEngineErrors.some((entry) =>
entry.includes('orca-second-mount-error')
),
{ timeout: 30_000, polling: 100 }
)
await page.close()
}, 300_000)
it('cancels the timers it armed, so none of the first mount fires into the second', async () => {
// The other half of the same rule. A timer the first terminal armed has no owner after
// dispose, and on the second mount it acts on the terminal that replaced it — hiding an
// indicator nobody raised. Frames are the case below, which provokes them deliberately;
// each asserts on its own witness so neither can stand in for the other.
// The document is its own chunk, and the point is what *it* scheduled: xterm's renderer
// schedules frames of its own that a disposed terminal simply ignores, and the browser
// cannot unschedule those. So the chunk is identified on the wire, by a literal only
// `host-notify` carries, and a leak is a callback that chunk scheduled.
let documentChunk = null
const { page } = await openPage(PROBE_ROUTE, {
scheduler: true,
beforeNavigate: async (opened) => {
await opened.route('**/*.js', async (route) => {
const response = await route.fetch()
const body = await response.text()
if (body.includes('terminal runtime error')) {
documentChunk = new URL(route.request().url()).pathname
}
await route.fulfill({ response, body })
})
}
})
await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, {
timeout: 60_000,
polling: 100
})
await openProbeTerminal(page)
expect(documentChunk, 'the document was served as its own chunk').not.toBe(null)
// Enough rows for a scrollback, so the wheel below reveals the scroll indicator: that is
// the document's longest-lived piece of scheduled work, a 550 ms timer to hide it again,
// which outlives an unmount even on a loaded machine. The same wheel leaves the
// smooth-scroll frame owed. Both are asked for in the task that tells the component to go.
// One touch on the surface arms the long-press timer: 500 ms, held on the scope, cancelled
// by `stopTapDispatch`. It is the document's own timer and it needs nothing rendered, so
// the provocation cannot race the engine — the precondition below says whether it landed.
await page.evaluate(() => {
globalThis.__orcaScheduler.watching = true
const surface = document.getElementById('terminal-surface')
surface.dispatchEvent(
new TouchEvent('touchstart', {
bubbles: true,
cancelable: true,
touches: [new Touch({ identifier: 1, target: surface, clientX: 100, clientY: 400 })],
changedTouches: [
new Touch({ identifier: 1, target: surface, clientX: 100, clientY: 400 })
]
})
)
globalThis.__orcaTerminalProbe.setMounted(false)
})
await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 })
await page.evaluate(() => {
globalThis.__orcaTerminalReady = false
globalThis.__orcaTerminalProbe.setMounted(true)
})
await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, {
timeout: 60_000,
polling: 100
})
await openProbeTerminal(page)
// Long enough for the slowest timer of the first mount to have fired if it survived.
await page.evaluate(() => new Promise((resolve) => globalThis.setTimeout(resolve, 3000)))
const scheduler = await page.evaluate(() => globalThis.__orcaScheduler)
// The precondition: there was something to leak. A wheel that reached nothing would agree
// with the empty list below for the wrong reason.
expect(
scheduler.scheduled.filter(
(entry) => entry.owned && entry.kind === 'timer' && entry.caller.includes(documentChunk)
).length
).toBeGreaterThan(0)
expect(
scheduler.leaked.filter(
(entry) => entry.startsWith('timer ') && entry.includes(documentChunk)
)
).toEqual([])
await page.unrouteAll({ behavior: 'ignoreErrors' })
await page.close()
}, 300_000)
it.each([
{ name: 'takes back the frames it is owed, not only the timers', cancelFrames: true },
{ name: 'detects leaked frames when disposal cannot cancel them', cancelFrames: false }
])(
'$name',
async ({ cancelFrames }) => {
// Hold a real refit frame across disposal; ResizeObserver delivery cannot race the witness.
let documentChunk = null
const { page } = await openPage(PROBE_ROUTE, {
scheduler: true,
beforeNavigate: async (opened) => {
await opened.route('**/*.js', async (route) => {
const response = await route.fetch()
const body = await response.text()
if (body.includes('terminal runtime error')) {
documentChunk = new URL(route.request().url()).pathname
}
await route.fulfill({ response, body })
})
}
})
try {
await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, {
timeout: 60_000,
polling: 100
})
await openProbeTerminal(page)
expect(documentChunk, 'the document was served as its own chunk').not.toBe(null)
// Ready is notified with the replay's fit still a frame away. Left pending, that fit
// commits the resized box below, the resize refit then has nothing to do, and there is no
// frame to hold; so the document settles first. Since the terminal is built before
// ready, the fixture returns fast enough to land inside that frame.
await page.evaluate(
() =>
new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
)
await page.evaluate((chunk) => {
const state = globalThis.__orcaScheduler
state.disposed = null
state.watching = true
state.holdFramesFrom = chunk
const host = document.querySelector('.orca-terminal-document-host')
// Dispose drops this class after cancelling frames; DOM detachment precedes cleanup.
const observer = new MutationObserver(() => {
if (
state.disposed !== null ||
host.classList.contains('orca-terminal-document-host')
) {
return
}
state.disposed = {
owed: state.scheduled.filter(
(entry) => entry.kind === 'frame' && !entry.fired && entry.caller.includes(chunk)
).length,
leakedBefore: state.leaked.length
}
observer.disconnect()
})
observer.observe(host, { attributes: true, attributeFilter: ['class'] })
host.style.width = '80%'
}, documentChunk)
await page.waitForFunction(() => globalThis.__orcaScheduler.heldFrames > 0, undefined, {
timeout: 30_000
})
await page.evaluate((cancelFrames) => {
globalThis.__orcaRestoreFrameCancellation = globalThis.cancelAnimationFrame
if (!cancelFrames) {
// The negative control must expose held callbacks after the real document disposes.
globalThis.cancelAnimationFrame = () => {}
}
globalThis.__orcaTerminalProbe.setMounted(false)
}, cancelFrames)
await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 })
await page.waitForFunction(() => globalThis.__orcaScheduler.disposed !== null)
await page.evaluate(() => {
globalThis.cancelAnimationFrame = globalThis.__orcaRestoreFrameCancellation
globalThis.__orcaScheduler.holdFramesFrom = null
globalThis.__orcaTerminalReady = false
globalThis.__orcaTerminalProbe.setMounted(true)
})
await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, {
timeout: 60_000,
polling: 100
})
await openProbeTerminal(page)
// Uncancelled work must actually run against the replacement, so the hold cannot hide leaks.
await page.evaluate(
() =>
new Promise((resolve) => {
globalThis.__orcaReleaseFrames()
requestAnimationFrame(() => requestAnimationFrame(resolve))
})
)
const scheduler = await page.evaluate(() => globalThis.__orcaScheduler)
expect(
scheduler.disposed?.owed,
'the document owed a frame at the moment dispose returned'
).toBeGreaterThan(0)
const leakedFrames = scheduler.leaked
.slice(scheduler.disposed.leakedBefore)
.filter((entry) => entry.startsWith('frame ') && entry.includes(documentChunk))
if (cancelFrames) {
expect(leakedFrames).toEqual([])
} else {
expect(
leakedFrames.length,
'the recorder must expose uncancelled work'
).toBeGreaterThan(0)
}
} finally {
await page.unrouteAll({ behavior: 'ignoreErrors' }).finally(() => page.close())
}
},
300_000
)
it('styles what it owns, and only that', async () => {
// The document's sheet says `*`, `html` and `body` because inside a WebView it owns the
// page. Appended to the head of a React Native Web application it owns nothing: those three
// selectors set the application's background, its overflow and every element's box model,
// on every screen the shell can show, and go on doing it after the terminal is gone.
//
// Ruling 19's shape: the page mount may style only what it owns. So the document-level
// rules are never injected and every remaining selector is held under the host's class.
// The oracle is a page of the same application with no terminal on it.
const control = await openPage(CONTROL_ROUTE)
const expected = await readRootComputedStyles(control.page)
await control.page.close()
const { page } = await openTerminal()
await openProbeTerminal(page)
expect(await readRootComputedStyles(page), 'roots while the terminal is mounted').toEqual(
expected
)
// And nothing in the sheet reaches past the host, which is the rule the comparison above
// cannot see: a selector that matched something outside would not have to change `body`.
const mounted = await terminalStyleReach(page)
// The precondition: there are rules to escape with.
expect(mounted.rules).toBeGreaterThan(0)
expect(mounted.outside).toEqual([])
// The positive half, which the two above cannot give: a sheet that reached nothing at all
// would satisfy both of them. These are four things the terminal looks like only because
// the rules arrive — one from xterm's sheet, three from the document's own — read off the
// live elements rather than off the stylesheet text.
expect(
await page.evaluate(() => {
const host = document.querySelector('.orca-terminal-document-host')
const xterm = host.querySelector('.xterm')
const viewport = host.querySelector('.xterm-viewport')
const overlay = host.querySelector('#selection-overlay')
return {
// xterm's own sheet: the grid is positioned against this, and its rows are absolute.
xtermPosition: getComputedStyle(xterm).position,
// The document's: the terminal scrolls itself, so the viewport shows no scrollbar
// and reserves no width for one.
viewportOverflowY: getComputedStyle(viewport).overflowY,
viewportReservesScrollbar: viewport.offsetWidth !== viewport.clientWidth,
// The mount's: the overlay sits in the host's unscaled coordinates above the grid,
// not over the page's header as `fixed` would put it.
overlayPosition: getComputedStyle(overlay).position
}
})
).toEqual({
xtermPosition: 'relative',
viewportOverflowY: 'hidden',
viewportReservesScrollbar: false,
overlayPosition: 'absolute'
})
await page.evaluate(() => globalThis.__orcaTerminalProbe.setMounted(false))
await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 })
expect(await readRootComputedStyles(page), 'roots after dispose').toEqual(expected)
// The sheet stays in the head for the next mount, and matches nothing until there is one.
const disposed = await terminalStyleReach(page)
expect(disposed.rules).toBe(mounted.rules)
expect(disposed.outside).toEqual([])
await page.close()
}, 300_000)
it('fits through the handle from the ready box and records what beforeinput reports', async () => {
const { page } = await openTerminal()
await openProbeTerminal(page)
// The fit is the app's own, from the cell box the document put in web-ready, against the
// frame React Native laid out, as the session's is. Null would mean the ready carried no box,
// or a grid too small to fit.
const fit = await page.evaluate(() => globalThis.__orcaTerminalProbe.fit())
expect(fit).not.toBeNull()
expect(fit.cols).toBeGreaterThanOrEqual(20)
expect(fit.rows).toBeGreaterThanOrEqual(8)
// xterm's own textarea is inert by the document's design — `query-reply.ts` makes it
// read-only, untabbable and `inputmode=none` so touch and hardware keys go to the screen's
// input instead. Asserted rather than assumed, because it is why the probe below types
// somewhere else.
const textarea = await page.evaluate(() => {
const element = document.querySelector('#terminal-surface .xterm-helper-textarea')
return element === null
? null
: {
readOnly: element.readOnly,
tabIndex: element.tabIndex,
inputMode: element.getAttribute('inputmode')
}
})
expect(textarea).toEqual({ readOnly: true, tabIndex: -1, inputMode: 'none' })
// Design §8's cheap half of the IME question: what a browser reports for text entering a
// terminal on the page, which arrives at the screen's own input. A composing IME on a real
// soft keyboard is the device step, which this does not claim to answer.
await page.getByTestId('terminal-live-input').focus()
await page.keyboard.type('ab')
await page.waitForFunction(() => globalThis.__orcaTerminalBeforeInput.length >= 2, {
timeout: 30_000,
polling: 100
})
const beforeInput = await page.evaluate(() => globalThis.__orcaTerminalBeforeInput)
console.log('[c7.5][beforeinput]', JSON.stringify(beforeInput.slice(0, 4)))
expect(beforeInput.map((entry) => entry.inputType)).toContain('insertText')
expect(beforeInput.map((entry) => entry.data)).toContain('a')
expect(beforeInput.every((entry) => entry.isComposing === false)).toBe(true)
expect(await terminalCspViolations(page)).toEqual([])
await page.close()
}, 300_000)
},
900_000
)