mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 16:02:03 +00:00
* 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 to21954dbd2f, 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. Against97b5bb2b9a: 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-record85d421963cis the last commit to touch a fenced path. Againsta676c1b65a: 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-record486566c82bis the last commit to touch a fenced path. Against3371c39715: 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
686 lines
32 KiB
JavaScript
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
|
|
)
|