mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
feat(mobile): mount the terminal document in the page over its own modules (OTA phase C, C7.5) (#21809)
* test(mobile): pin the terminal WebView document byte for byte The document is already pinned as a digest, which says whether the emitted bytes moved and nothing about where. C7.1 moves the hand-written script inside it into modules the web page can import and rebuilds the document from them, and the claim that has to hold through every one of those commits is that the native screen kept the document it had. A digest cannot be the instrument for that: it fails as two hexadecimal strings. So the document is also committed as itself. The fixture is generated by `scripts/build-terminal-document-fixture.mjs`, never pasted, and the test rebuilds the comparison through that script's own substitution rather than restating it, so a fixture written by one rule and read by another cannot agree with itself. The generated xterm engine is stored as two placeholders. It is already covered by the digest test, postinstall regenerates it from whatever xterm the lockfile holds, and inlining it would put 612 KiB of vendored bytes into the file whose job is to isolate hand-written changes. Two further cases keep that from becoming a hole: the placeholders must each appear exactly once and the engine must not appear at all, and the restored document must equal the real one. Regenerating the fixture is a review event. It is only correct when the emitted document was meant to change, and the diff in that commit is the evidence. Red-first: flipping one character inside a comment in `write-queue.ts` fails both identity cases with a one-line diff naming the comment, where the digest test reports a hash. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): compare two terminal documents as programs, not as bytes The C7.1 flip commit moves the document's 57 reassigned variables onto a scope object, because a variable assigned across ES modules is a syntax error, and every read and write of them gains a qualifier. The ruling asks that the review of that commit be a test rather than a 515-line read. This is that test's instrument. It cannot be a byte comparison. Once the script's source is modules, `oxfmt` owns its style, and the repository's style has no semicolons where the hand-written document has one on nearly every line. A byte diff would therefore be dominated by changes that are not the refactor, which is the opposite of what the reviewer needs. So the comparison is over tokens: semicolons are excluded for the same reason they moved, comments never reach the stream, and one difference is allowed — `name` becoming `<qualifier>.name`, three tokens for one — which it counts and reports. It is stricter than "it still runs": a reordered statement, a changed literal, a dropped operator, a renamed local and a qualifier under the wrong object name all diverge, each reported with the token index and both sides. Acorn carries `value` on its tokens but does not declare it, so the field is read through a narrowing check rather than asserted onto the declared type. Red-first, by mutation: dropping the qualifier-name check fails the case that names it; removing the leftover-token check fails the dropped- and added-statement cases; treating semicolons as significant fails the three cases that depend on ignoring them. The acceptance case runs on the real 2,758-line script rather than on a fixture, so the instrument is known to survive everything the document actually contains. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): count each normalisation the move makes, separately Measured while extracting the first group: the document's ES5 style is not a style this repository's own rules permit. `curly` braces 279 brace-less if/else/for/while bodies, `no-unused-vars` unbinds 38 catch clauses, and 446 `var` declarators become `const`, `let` or a scope field. Those rewrites land before the qualifier is considered at all, so "the qualifier and nothing else" was never reachable once the source is a linted module. The comparison now allows exactly four classes and counts each on its own: a reference that gained the qualifier, a declaration that moved onto the scope object, a `var` that only changed keyword, a body that gained braces, and a catch clause that lost its binding. Separate counters rather than a total, because the flip commit pins each number and a total would let one class absorb another — which is the drift the pin exists to catch. The two `var` classes partition the 446, and the qualifier's 641 sites partition into references that kept their declaration and declarations that moved. Two ordering facts the cases pin. The catch rule is tried before the brace rule, or the inserted-brace rule eats the `{` that follows `catch` and the streams never resynchronise. A body braced at the very end leaves its closing brace after the baseline has run out, so trailing closes are absorbed after the walk rather than reported as a length difference. Everything outside the four classes still refuses with the token index and both sides: a changed literal, a dropped operator, a reordered pair, a renamed local, a qualifier under another object's name, a brace opened and never closed, and a brace closed where none was opened. Red-first, by mutation: disabling the catch rule, disabling the trailing-brace absorption, folding scope-field declarations into plain references, and not counting brace insertions each fail exactly the case that covers them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): make the mouse-report cell a module the page can import The first of the twelve groups the document already names. `*-injected.ts` has been splicing JS strings into the document for a while, and tests evaluate those strings, so the one-source-two-consumers shape is already there; what is missing is that a string cannot be imported by the web page, typechecked, or linted. This turns one of them into a module and adds the generator that puts it back into the document. The generator is a transform, not a bundle: a bundler orders its output by the dependency graph, and the document's order is part of what the equivalence test holds fixed. Imports are dropped rather than resolved, because inside the document every name is already in scope — that is what the single IIFE means — and `document-externals.ts` declares the names whose groups have not moved yet and emits nothing at all. esbuild prints an ESM module's exports as a trailing block, so that block is dropped whole rather than by its keyword; leaving the keyword behind would put a bare block statement in the document. Both sides of the comparison now go through that same printer before being read. Otherwise every choice the printer makes — semicolons, property shorthand, quote style — reads as a difference in the program when it is a difference in who typed it, and each would need its own rule. A script that does not parse is reported as a refusal naming its side, not thrown. `let` is contextual outside strict mode, so acorn reports it as a name and not as a keyword; without that the var-to-let rewrite the linter performs would be refused on every reassigned local. The group's counts are pinned exactly: nine references gained the qualifier (`term` seven times, `panX` and `panY` once each), nine locals became `const` or `let`, thirteen one-statement `if` bodies gained braces, no declaration moved onto the scope object and no catch clause lost a binding. The document is untouched, so the byte pin from3006d8dfdfis still green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): make the query-reply gate a module the page can import The second of the twelve groups, and the one that corrects the scope table's membership rule. `terminalDataRepliesEnabled` is written from four places, so the whole-script census counted it among the 57 variables that cannot stay free across modules. All four writes are in this group. Once the script is modules, a variable written only inside the module that declares it is that module's own state, not the document's, and it stays a `let` there. So the scope object holds what crosses a module boundary, and the 57 is an upper bound rather than the answer; the qualifier count the flip commit pins will be lower than the 641 measured over the single scope, and by how much is a function of where the boundaries fall. Two references do cross here and are qualified: the write-queue generation this group compares against, and the observer-disposal list it pushes onto. Counts pinned: two qualified references, one `var` to `let`, two one-statement `if` bodies braced, both `catch (e) {}` clauses unbound, no declaration moved. The document is untouched, so the byte pin is still green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): make reflow a module, and give the generator its own tests The third group, and the defect it found: esbuild wraps a long import list across lines, and the generator was skipping only the first of them, which left the remaining names loose in the emitted script. The document did not parse, and the equivalence check said so by name rather than throwing — which is what that refusal path was added for. Both lists, import and export, are now skipped to their closer instead of by their first line. The generator's own tests cover what the per-group comparisons cannot say on their own: an export is unmarked and indented into the document scope, a one-line import is dropped, a wrapped import is dropped whole, the trailing export block esbuild prints is dropped rather than left as a bare block statement, and types are erased without touching the program. Reflow's counts: eleven qualified references — the terminal ten times and the settled row count once — six locals that became `const`, and the two early returns braced. The row count is written from three groups, so unlike the query-reply flag it is the document's state rather than one module's. The document is untouched, so the byte pin is still green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): make the keyboard-avoidance metrics a module The fourth group, and the first that needed a non-null assertion. `lineHasVisibleContent` reads the terminal's column count with no guard of its own; the guard is in `computeContentBottomRow`, which is its only caller. Adding a guard would change the program, and optional chaining would change what happens when there is no terminal — the document throws there today. TypeScript erases a non-null assertion, so the emitted script is unchanged and the invariant is written down where the reader needs it. Reflow now imports the metrics call from this module rather than declaring it an external, which is the shape every group takes as its neighbours arrive. Counts: fourteen qualified references, nine locals rebound, ten one-statement bodies braced, and the two `catch (e) {}` clauses — the row scan and the alternate-screen probe — unbound. The scope table's rule is stated more precisely with it: a variable is this module's own only when the group both declares and assigns it. While the rest of the document is still strings, one the main slice declares stays shared even if every use is in one group, because emitting a second declaration beside the one the slice still carries would not be the same program. The document is untouched, so the byte pin is still green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): make WebGL loss recovery a module The fifth group, and the first carrying a top-level statement rather than only declarations: the visibility listener it registers. In the document that runs when the IIFE reaches it; as a module it runs on import, which is the same single registration. The context-loss listener disposes the addon it is registered on, so it cannot run before that addon exists, but the assignment is to a `let` a closure captures and TypeScript will not carry the narrowing across it. A non-null assertion, erased by the compiler, keeps the emitted script identical and puts the invariant where the reader is. Counts: twenty-three qualified references across the terminal, the addon, its retry timer and the theme the host last sent; three locals rebound; twelve one-statement bodies braced; five of the six catch clauses unbound, the sixth keeping its binding because the attach failure reads the error into its diagnostic. The document is untouched, so the byte pin is still green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): make indirect-pointer scroll a module, and count a fifth class The sixth group found a rule the four classes do not cover, so I measured the whole script rather than meeting them one at a time: linting all 2,757 lines as a module trips `curly` 279 times and `no-unused-vars` 38, both already counted, and then five further rules at 23 sites — `prefer-number-properties` 17, `prefer-includes` 2, `no-useless-escape` 2, `prefer-exponentiation-operator` 1 and `no-unused-expressions` 1. Seventeen of those 23 are one rewrite: a global numeric function moved onto `Number`. It has the same token shape as the qualifier, so it is counted as its own class rather than folded into anything, and only the four numeric globals are admitted — anything else appearing under `Number` is refused, which a case pins. Every site is already behind a `typeof … === 'number'` check or is parsing a string, so the two forms are the same test. The remaining six sites are each a different shape and too few to be worth matching; they will surface as refusals in whichever group carries them, and I will report each rather than widen this. The scroll accumulator is the first declaration to move onto the scope: it is declared in this group but a touch scroll in another slice resets it, so the `var` becomes an assignment to the shared field and the class that exists for exactly that counts one. Counts: five qualified references, one declaration moved, four locals rebound, eight bodies braced, one `Number` rewrite. The document is untouched, so the byte pin is still green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal surface-swap group into a module The seventh named group. `surface` and the uncommitted terminal are read by other slices, so both move onto the scope; the two committed handles and the pending surface are declared and assigned only here and stay module locals. Counts: qualified 7, scope declarations 1, rebindings 4, braced bodies 2, unbound catches 2, number properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): substitute build-time constants into the emitted document The document's script text is not all hand-written: parts of it are template literals interpolating real values, starting with the theme background. A module cannot interpolate and still be the same program, so the generator now derives an esbuild `define` from `document-constants.ts` and substitutes after the import lines are dropped, when the names are free again. The page imports the very same bindings, so there is one source either way. The fixture script's TypeScript loader moves beside it rather than being written twice. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal theme group into a module The eighth named group, and the first parameterised one: its background fallback comes from the mobile theme through `document-constants.ts`. Two sites carry a line-scoped lint disable rather than the rewrite the rule asks for: `indexOf(',') >= 0` and `Math.pow`. Both rewrites are outside every normalisation class the equivalence instrument counts, so taking them would change the program the native document carries, which is the one thing this branch holds fixed. The reason is on the disable line. Counts: qualified 12, scope declarations 0, rebindings 28, braced bodies 13, unbound catches 0, number properties 9. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal path-tap group into a module The ninth named group, and a pure query: it reads no shared state, so it has no qualifier sites at all. Two things this group forced. The generator now drops lint directive lines before the transform, because a directive inside an expression makes esbuild parenthesise that expression to keep the comment where it was, and those parentheses are tokens the document does not have. And the two regexes keep their `no-useless-escape` escapes behind a line-scoped disable, for the same reason the theme group keeps `Math.pow`. One name the document declares twice in one function stays `var`. Two block-scoped declarations would be two bindings where the document has one, and esbuild renames the inner one to say so. Counts: qualified 0, scope declarations 0, rebindings 31, braced bodies 20, unbound catches 0, number properties 2. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal tap-dispatch group into a module The tenth named group, and the heaviest reader of shared state: the selection, its elements, its thresholds and both press origins are all declared by the overlay slice, which is still document text, so all of them move onto the scope with their declarations left where they are. Counts: qualified 49, scope declarations 0, rebindings 15, braced bodies 11, unbound catches 0, number properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal mouse-click-drag group into a module The eleventh named group. The escape byte and both SGR mouse modes join the scope from the runtime slice; the gesture itself is declared here and never read outside, so it stays a module local. Counts: qualified 17, scope declarations 0, rebindings 22, braced bodies 27, unbound catches 1, number properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal url-tap group into three modules The twelfth and last named group, and the second parameterised one: both candidate patterns and the length bound come through `document-constants.ts`. Three modules rather than one. At 303 lines it was over the file cap, and the document's own order interleaves the OSC 8 lookup with the file-URL parsing, so the split follows that order and the group's text is the three emissions joined. The test does the joining. Note for a later lane: `terminal-webview-url-tap.ts` and `terminal-file-url-tap.ts` already hold TypeScript twins of some of this, written for the React Native side and not identical to what the document carries. Collapsing the two is a behaviour change and does not belong in a branch whose whole claim is that the document did not move. Counts: qualified 10, scope declarations 0, rebindings 41, braced bodies 25, unbound catches 6, number properties 4. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the mouse-mode DECSET scan slice into a module The first of the thirteen inline slices. Both control-sequence introducers, the straddling scan tail and all three mode fields are declared by the runtime-state slice, which is still document text, so they move onto the scope with their declarations left where they are. Counts: qualified 20, scope declarations 0, rebindings 10, braced bodies 9, unbound catches 0, number properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal message-bridge slice into a module The script and the document end in the same slice, so the slice splits in two at the point where the IIFE closes: the script half becomes a module, the document half stays text. The byte pin proves the join is unchanged. The second catch keeps its binding: it names the error and reports it. Counts: qualified 1, scope declarations 0, rebindings 1, braced bodies 0, unbound catches 1, number properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the document close its own slice file The previous commit put two exports in one slice file, which the slice-count guard reads as a mismatch: it derives the slice list from the composer's imports and cross-checks it against the composed entries, one per file. Five suites failed to load. Splitting the file rather than the constant is the better shape anyway. The file was called `message-bridge-and-document-close` because it carried two concerns; now each has its own. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal term-observers slice into modules This slice interpolates the already-extracted keyboard-avoidance group between its own two halves, so its text is three emissions joined in that order and the test does the joining. A sixth normalisation class, measured here rather than assumed: the printer writes `{ name: name }` back as shorthand, and qualifying the value makes the property name unavoidable again, so one baseline token faces four. It is counted on its own like the others, with its own acceptance case in the instrument's test, and every existing group's pin now carries a zero for it. Counts: qualified 36, scope declarations 1, rebindings 12, braced bodies 12, unbound catches 6, number properties 0, shorthand properties 4. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the selection-state-and-eviction slice into a module The slice that declares most of the shared selection state: every threshold, every overlay element and the selection itself, twenty-two scope declarations in one place. The eviction counter is declared and assigned only here, so it stays a module local. Counts: qualified 12, scope declarations 22, rebindings 2, braced bodies 3, unbound catches 0, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the smooth-scroll and cell-geometry slice Two modules, not one: the slice carries the normal-buffer smooth scroll and then the cell-to-pixel geometry, and the split follows that order so the group's text is the two emissions joined. Four names stop being externals and become real imports. Counts: qualified 39, scope declarations 0, rebindings 15, braced bodies 16, unbound catches 0, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal write-queue slice into a module The slice also carries `disposeTermObservers` and `extractMouseModeScanTail`, which belong to other concerns but sit here because emitted-document order pins them here; four names stop being externals as a result. The observer disposal keeps its guard-as-expression form behind a line-scoped disable: the rewrite the rule asks for is outside every counted class. Counts: qualified 50, scope declarations 0, rebindings 11, braced bodies 10, unbound catches 1, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal fit-scale slice into a module The slice opens with the already-extracted theme group, so its text is two emissions joined. Four more names stop being externals. Counts: qualified 47, scope declarations 0, rebindings 47, braced bodies 20, unbound catches 0, number properties 9, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal init-and-write slice into a module The slice opens with the already-extracted webgl-recovery group, so its text is two emissions joined. init() resets almost every field the document shares, which makes this the densest qualifier site in the script. The caret options were interpolated from the theme module, so they join `document-constants.ts` as four exports: a substitution is keyed by name, not by property path. One local the document declares and never reads keeps a line-scoped `no-unused-vars` disable. Removing it would be a different program, which is the one thing this branch does not do. Counts: qualified 83, scope declarations 0, rebindings 11, braced bodies 18, unbound catches 7, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the runtime-state and text-scaling slice The document's declaration block, where almost everything it shares is declared, with the query-reply and surface-swap groups interpolated inside it. Three modules: the two declarations that come before the groups, the text scaling, and the viewport transform with the scroll indicator. Seven more names stop being externals. Two things this slice forced. The scope-declaration rule now counts each declarator of one `var`, because `var panX = 0, panY = 0` becomes two assignments onto the scope. It has its own acceptance case in the instrument's test. The two halves are compared against their own text rather than as one joined program. The declaration the slice opens with is shadowed by a parameter inside one of the interpolated groups, and printing the baseline as one program renames that parameter; qualifying the outer name removes the shadow, so the rename has nothing to correspond to. Splitting the slice on the group constants compares like with like, and those groups have their own tests. Build-time constants are now substituted textually rather than through an esbuild `define`: a `define` whose value is an object or an array is injected as a helper binding instead of being inlined. Counts, head: scope declarations 2. Tail: qualified 31, scope declarations 38, rebindings 25, braced bodies 13, unbound catches 1. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): format the two test files the last commit left unformatted Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the mouse-report and scroll-routing slice Two modules around the already-extracted mouse-report-cell group: the viewport cell lookup that precedes it, and the mouse input encoding and scroll routing that follow. Eight more names stop being externals, which leaves ten. Counts: qualified 49, scope declarations 0, rebindings 49, braced bodies 42, unbound catches 3, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the host-message-router slice into modules Two modules after the already-extracted reflow group: the postMessage bridge with the engine error reporting that rides on it, and the router itself. `notify`, `handleMsg` and `reportEngineError` stop being externals, which leaves seven. The catch binding handed to the error reporter keeps a cast: a catch variable is `unknown` under strict mode, and the reporter reads only `message` before falling back to `String()`. The reason is on the line. Counts: qualified 48, scope declarations 0, rebindings 20, braced bodies 12, unbound catches 2, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the selection-overlay slice into modules Two modules after the already-extracted path-tap and url-tap groups: the selection range with the xterm mirror, and the overlay positioning with the edge scroll. Six more names stop being externals, which leaves one. Counts: qualified 77, scope declarations 0, rebindings 96, braced bodies 63, unbound catches 9, number properties 6, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the surface-touch-gestures slice into modules The last of the thirteen slices. Two modules after the three already-extracted groups: the selection menu's buttons, and the touch gestures with the pinch and the momentum scroll. `attachSurfaceEventHandlers` was the last external, so `document-externals.ts` is gone: every name the document uses now resolves to a module. The instrument reads both sides strict. A loose script has to defend Annex B's block-scoped function declarations, and the printer does that by hoisting a `var` and renaming the function, so one side carried a rename the other could not. Neither name escapes its block, so the two readings agree on behaviour and only the strict one can be compared. It has its own acceptance case. Counts: qualified 104, scope declarations 1, rebindings 69, braced bodies 57, unbound catches 2, number properties 2, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the document's opening declarations into a module The document shell carried the IIFE opener and the eight declarations inside it, so it splits the way the message-bridge slice did: the shell keeps the HTML and the opener, a new slice file holds the declarations, and the byte pin proves the join is unchanged. With this every line of the document's script has a module behind it. Counts: qualified 3, scope declarations 8, rebindings 0, braced bodies 0, unbound catches 0, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the whole document script against the modules Every line of the script now has a module behind it, so the whole thing can be compared at once. This is the review of the move, as one number per class: qualifier 609 references + 73 declarations = 682 sites var rebindings 373, the document's 446 declarators less those 73 curly braces 279, the number measured before any of this started unbound catches 36 of 38; two name their error and report it Number properties 17, also measured up front shorthand properties 4, two SGR flags written twice each unshadowed names 7 A seventh class was needed and is counted like the others: a binding that shadowed a document variable stops being a shadow once that variable moves onto the scope, so the printer stops disambiguating it. It has its own acceptance case. The module order lives in one file that both this test and the generator read, so neither can drift from the other. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): keep only the lint directives that do something Seventeen of the disables were inert: `typescript/no-non-null-assertion` is not enabled here, and a directive naming two rules on one line is not parsed at all, so the one rule that did apply was being ignored too. The changed-code quality gate reports an inert directive as a finding. The two that matter are back, one rule per line: the guard-as-expression in the observer disposal, and the local the document declares and never reads. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): generate the terminal document from its modules The WebView document is no longer a hand-written IIFE pasted into a template string. `scripts/build-terminal-document-script.mjs` reads `document-scope.ts` and the 36 modules under `src/terminal/document/` in document order, strips their imports, exports and line-scoped lint directives, substitutes the `document-constants.ts` exports textually, reprints each with esbuild and wraps the result in one IIFE. `terminal-webview-html.ts` composes the shell, that generated script and the close fragment. The artifact is gitignored and built by postinstall, like the two engine artifacts. The emitted document is token-equivalent to the old one under eight counted normalisation classes, each pinned as an exact number in `document/terminal-document-flip.test.ts` against the pre-flip text: qualifiedReferences 609 scopeFieldDeclarations 73 rebindings 373 bracedBodies 279 unboundCatches 36 numberProperties 17 shorthandProperties 4 unshadowedNames 7 Any other difference fails with the token index and both sides. The second case pins that the new document adds the scope object and nothing else. Ruling 17: the behavioural tests now grep the generated document through `XTERM_HTML`, never a module source, so every assertion still speaks about what the WebView runs. Every assertion stays and the `expect` count per file is unchanged: scroll-routing 95, text-zoom 59, engine 49, url-tap 33, reflow 22, keyboard-avoidance 18, query-reply 14. One control per file was run by deleting the module line the updated pattern guards; all seven red, and the tree restores green. Pattern changes, old -> new. terminal-webview-scroll-routing.test.ts var deltaY = ts.lastY - y; -> const deltaY = ts.lastY - y; smoothScrollOffsetY -= deltaY; -> scope.smoothScrollOffsetY -= deltaY; var lines = Math.trunc(-smoothScrollOffsetY / effectiveCellH); -> const lines = Math.trunc(-scope.smoothScrollOffsetY / effectiveCellH); 'touchmove' single-quoted, one line -> "touchmove" double-quoted, printer line break }, { capture: true, passive: false }); -> { capture: true, passive: false } function momentumStep() -> let momentumStep = function() pendingNormalScrollDeltaY += deltaY; -> scope.pendingNormalScrollDeltaY += deltaY; if (normalScrollFrameId !== null) return true; -> if (scope.normalScrollFrameId !== null) { normalScrollFrameId = requestAnimationFrame( -> scope.normalScrollFrameId = requestAnimationFrame( pendingNormalScrollDeltaY = 0; -> scope.pendingNormalScrollDeltaY = 0; cancelAnimationFrame(normalScrollFrameId); -> cancelAnimationFrame(scope.normalScrollFrameId); var writeQueueHead = 0; -> scope.writeQueueHead = 0; writeQueueHead++; -> scope.writeQueueHead++; writeQueue = writeQueue.slice(writeQueueHead); -> scope.writeQueue = scope.writeQueue.slice(scope.writeQueueHead); surface.style.transform = 'translate(' + panX -> scope.surface.style.transform = "translate(" + scope.panX getVisualPanY() + 'px) scale(' -> getVisualPanY() + "px) scale(" var FRICTION = 0.972; -> const FRICTION = 0.972; var MIN_VEL = 0.012; -> const MIN_VEL = 0.012; edgeScrollDir = dir; -> scope.edgeScrollDir = dir; term.scrollLines(edgeScrollDir); -> scope.term.scrollLines(scope.edgeScrollDir); // Latching document-level touch dispatcher -> function attachSurfaceEventHandlers( edgeScrollClientX = clientX; -> scope.edgeScrollClientX = clientX; edgeScrollClientY = clientY; -> scope.edgeScrollClientY = clientY; return mode !== 'none'; -> return mode !== "none"; var pixelX = cell.x; -> const pixelX = cell.x; var pixelY = cell.y; -> const pixelY = cell.y; ...isSafeSgrMouseCoordinate(cell.y)) return -> ...isSafeSgrMouseCoordinate(cell.y)) { ...isSafeSgrMouseCoordinate(sgrRow)) return -> ...isSafeSgrMouseCoordinate(sgrRow)) { if (mouseTrackingMode === 'x10') return pixelPress; -> if (mouseTrackingMode === "x10") { return pixelPress; if (mouseTrackingMode === 'x10') return sgrPress; -> if (mouseTrackingMode === "x10") { return sgrPress; if (mouseTrackingMode === 'x10') return press; -> if (mouseTrackingMode === "x10") { return press; if (col > 126 || row > 126) return ''; -> if (col > 126 || row > 126) { return ""; document.addEventListener('touchend' -> document.addEventListener( "touchend" }, { capture: true, passive: true }); -> { capture: true, passive: true } notifyTerminalSurfaceTap(tapCandidate.x, ...) -> notifyTerminalSurfaceTap(scope.tapCandidate.x, ...) document.addEventListener('touchstart' -> document.addEventListener( "touchstart" var clickInput = buildMouseClickInput -> const clickInput = buildMouseClickInput notify({ type: 'open-url', url: tappedUrl }); -> notify({ type: "open-url", url: tappedUrl }); notify({ type: 'terminal-input', bytes: clickInput }); -> notify({ type: "terminal-input", bytes: clickInput }); terminal-webview-text-zoom.test.ts var CLAUDE_STATUS_DOT = -> scope.CLAUDE_STATUS_DOT = var PRIVATE_MODE_SCAN_TAIL_LIMIT -> scope.PRIVATE_MODE_SCAN_TAIL_LIMIT \n\n function enqueueWrite -> \n function enqueueWrite var terminalFontFamily = -> scope.terminalFontFamily = output = terminalFontFamily; -> output = scope.terminalFontFamily; String.fromCharCode(0x23fa) -> String.fromCharCode(9210) TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e) -> scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(65038) EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f) -> scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(65039) data.replace(CLAUDE_STATUS_DOT_PATTERN, ...) -> data.replace( scope.CLAUDE_STATUS_DOT_PATTERN, scope.CLAUDE_STATUS_DOT + scope.TEXT_PRESENTATION_SELECTOR ) writeQueue.push(normalizeStatusDotPresentation(data)) -> scope.writeQueue.push(normalizeStatusDotPresentation(data)) var replayData = normalizeInitialData(initialData) -> const replayData = normalizeInitialData(initialData) } else if (msg.type === 'clear') { -> } else if (msg.type === "clear") { } else if (msg.type === 'measure') -> } else if (msg.type === "measure") statusDotPendingSelector = false -> scope.statusDotPendingSelector = false (x2) term.open(surface) -> scope.term.open(scope.surface) term.unicode.activeVersion = '11' -> scope.term.unicode.activeVersion = "11" enqueueWrite(ESC + '[0m' + replayData) -> enqueueWrite(scope.ESC + "[0m" + replayData) fontFamily: terminalFontFamily -> fontFamily: scope.terminalFontFamily fontWeight: '300' -> fontWeight: "300" fontWeightBold: '500' -> fontWeightBold: "500" terminal-webview-engine.test.ts var webglAddon = null; .. var webglRecoveryTimer = null; -> the refreshTerminalSurface()..init( block, with the scope preamble window.addEventListener('resize' -> window.addEventListener("resize" 'terminal init failed' -> "terminal init failed" 'terminal message failed' -> "terminal message failed" var everReady = false; -> scope.everReady = false; everReady = true; -> scope.everReady = true; fatal === undefined ? !everReady : !!fatal -> fatal === void 0 ? !scope.everReady : !!fatal msg.type === 'init' && !everReady -> msg.type === "init" && !scope.everReady /fatal === undefined \? !ready\b/ -> /fatal === void 0 \? !scope\.ready\b/ if (msg.type === 'ping') -> if (msg.type === "ping") notify({ type: 'pong', pingId: msg.id }) -> notify({ type: "pong", pingId: msg.id }) terminal-webview-reflow.test.ts } else if (msg.type === 'reflow') { -> } else if (msg.type === "reflow") { (x2) var MIN_FIT_COLS = 20; -> scope.MIN_FIT_COLS = 20; if (cols < MIN_FIT_COLS) return; -> if (cols < scope.MIN_FIT_COLS) { flog('measure-skip-small-width' -> flog("measure-skip-small-width" notify({ type: 'measure-result', ... }) -> notify({ type: "measure-result", ... }) var dispatch = { mode: 'idle' -> const dispatch = { mode: "idle" window.addEventListener('message' -> window.addEventListener("message" terminal-keyboard-avoidance-webview.test.ts \n // reflow() -> \n function reflow( } else if (msg.type === 'clear') { -> } else if (msg.type === "clear") { } else if (msg.type === 'measure') -> } else if (msg.type === "measure") \n var panX -> \n scope.panX TERMINAL_REFLOW_JS fragment import -> the reflow(cols, rows)..notify( slice of the document terminal-webview-query-reply.test.ts attachTerminalQueryReplyBridge(term, gen) -> attachTerminalQueryReplyBridge(scope.term, gen) (x2) term.attachCustomKeyEventHandler(function() { return false; }) -> term.attachCustomKeyEventHandler(function() { \n return false; \n }); term.textarea.readOnly = true -> term.textarea.readOnly = true; } else if (msg.type === 'clear') { -> } else if (msg.type === "clear") { } else if (msg.type === 'measure') -> } else if (msg.type === "measure") terminal-webview-url-tap.test.ts notify({ type: 'open-url', url: tappedUrl }); -> notify({ type: "open-url", url: tappedUrl }); terminal-webview-payload-hash.test.ts is the document byte pin; it moves to the generated document's digest, 730472 -> 723480 bytes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): delete the slice constants and injected fragments The document is generated from its modules now, so the strings it used to be pasted together from are dead. Deleted: the fourteen slice constants under `terminal-webview-html/` (host-message-router, message-bridge, mouse-mode-decset-scan, mouse-report-and-scroll-routing, runtime-constants, runtime-state-and-text-scaling, selection-overlay, selection-state-and-eviction, smooth-scroll-and-cell-geometry, surface-touch-gestures, term-observers-and-mode-mirroring, terminal-fit-scale, terminal-init-and-write, write-queue) and the eleven `*-injected.ts` files. `document-shell.ts`, `document-close.ts` and `theme.ts` stay: the shell and close are still the document's HTML, and `theme.ts` is where `document-constants.ts` reads the palette from. Ruling 17, second commit. Tests that asserted the extraction mechanism itself went with it: they compared one module's emission against the slice text it was extracted from, and the flip test now pins the whole document against the whole pre-flip script with the same eight classes. Deleted, all under `document/`: fit-scale, host-message-router, keyboard-avoidance-metrics, message-bridge, mouse-click-drag, mouse-mode-decset-scan, mouse-report-and-scroll-routing, mouse-report-cell, path-tap, query-reply, reflow, runtime-constants, runtime-state, selection-overlay, selection-state-and-eviction, smooth-scroll-and-cell-geometry, surface-swap, surface-touch-gestures, tap-dispatch, term-observers, terminal-init, terminal-theme, webgl-recovery, wheel-scroll. `document/url-tap.test.ts` stays: it pins against `URL_TAP_WEBVIEW_JS`, which is neither a slice constant nor an injected file and still has a consumer. Tests that asserted behaviour through a deleted string now read the generated document. `document/generated-document-region.test-support.ts` is the one way in: `documentScopePreamble()` returns the scope object the document opens with, and `generatedDocumentModule(name)` re-emits a module and refuses unless the document carries that text verbatim, so an evaluated block is the WebView's own bytes. The two local copies of the preamble in the engine and text-zoom tests were folded into it. Moved, with every assertion kept and the `expect` count per file unchanged: terminal-webview-html/write-queue.test.ts -> document/write-queue.test.ts 34 terminal-webview-theme-injected.test.ts -> terminal-webview-theme.test.ts 14 terminal-webview-query-reply.test.ts 14 terminal-path-tap.test.ts 25 terminal-webview-url-tap.test.ts 33 terminal-keyboard-avoidance-webview.test.ts 18 terminal-webview-reflow.test.ts 22 terminal-webview-text-zoom.test.ts 59 terminal-webview-engine.test.ts 49 Pattern changes, old -> new. terminal-webview-reflow.test.ts if (!term || isAlternateBufferActive()) return; -> if (!scope.term || isAlternateBufferActive()) { term.resize(nextCols, nextRows); -> scope.term.resize(nextCols, nextRows); var wasAtBottom = buffer.viewportY >= buffer.baseY; -> const wasAtBottom = buffer.viewportY >= buffer.baseY; term.scrollToBottom(); -> scope.term.scrollToBottom(); if (nextCols === term.cols && nextRows === term.rows) return; -> if (nextCols === scope.term.cols && nextRows === scope.term.rows) { The other eight files kept their patterns; only the text they read changed, from a deleted constant to the document block. The harnesses that evaluate a block now build the document's scope object instead of declaring the vars it replaced, and hand the terminal in as `scope.term`. Controls, one per file: the module line an updated pattern guards was removed, the document rebuilt, and the test run. All red, and the tree restores green. query-reply terminalDataRepliesEnabled = true -> query-reply test, 2 failed path-tap const parsed = parsePathLineCol(...) -> path-tap test, red keyboard-avoidance-metrics contentBottomRow -> keyboard-avoidance test, 4 failed reflow scope.term.resize(nextCols, nextRows) -> reflow test, 2 failed webgl-recovery new window.WebglAddon.WebglAddon() -> engine and text-zoom tests, 4 failed osc-link-tap return parsePathLineCol(value) -> url-tap test, 1 failed terminal-theme scope.term.options.minimumContrastRatio = ... -> theme test, 4 failed write-queue scope.writeQueue[scope.writeQueueHead] = undefined -> write-queue test, 4 failed `document-scope.ts` docstrings named the slice each field belonged to; they name the owning module now. Three module comments pointed at deleted injected files and point at the modules instead. Neither changes the document: esbuild drops comments, and the byte pin is unmoved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): name the right number of counted classes The flip test's title still said seven; the table it asserts has eight. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name the shape applyTerminalTheme writes through The anti-slop gate refused `loadThemeApplier(term: object)` in the theme test. `applyTerminalTheme` touches exactly two slots on the terminal it is handed, so `terminal-theme.ts` now exports that shape as `TerminalDocumentThemeTarget` and the test's parameter and both fixtures use it. The theme is optional on the way in because `applyTerminalTheme` is what writes it. No cast. The type is erased by the generator's transform, so the document is unchanged and the flip test's class table and the byte pin both still hold. Control: restoring the `object` parameter reproduces the finding at terminal-webview-theme.test.ts:35:33 and the gate exits 1; with the named type it exits 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): retire the flip pin, leaving the byte golden as the document's fence `terminal-document-flip.test.ts` compared the emitted modules against `terminal-document-pre-flip-script.txt`, the hand-written script as it stood before C7.1, and held exactly while no module changed. That is the proof of the flip, not a standing fence: the first lane that must change a module has to retire it or restate its counted classes for a reason that has nothing to do with the move. C7.5 is that lane — the document's host seams become scope fields so the page can set them — so both go here, while the test is still green. The flip proof lives at51ae7b1b03("test(mobile): name the right number of counted classes"), which is where anyone reviewing the move should read it. From here the standing pin is the whole-document byte golden, `terminal-document-golden.txt`, checked by `terminal-document-identity.test.ts` and by the payload-hash digest beside it. Regenerating it is a review event: the emitted diff is listed old to new in the commit message and in the PR body, and a golden that moves without a listed diff is a blocking finding. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): give the terminal document's host seams a field on its scope Ruling 19: on the page `window.ReactNativeWebView` is the *shell's* bridge, so a terminal `notify` through it would post raw terminal JSON into the bridge's channel, and there is no engine IIFE hanging `Terminal` and the two addons off `window` because the page imports xterm. Four reads had to become seams: host-notify.ts notify() -> scope.postToHost viewport-transform flog() -> scope.postToHost terminal-init.ts new Terminal(...) -> scope.createTerminal terminal-init.ts window.Unicode11Addon-> scope.createUnicode11Addon webgl-recovery.ts window.WebglAddon -> scope.createWebglAddon Each default is the window read the site already did, still performed at call time and not captured when the scope is built, so inside the WebView the program is the one it was. `document-host-seams.ts` holds the four and is emitted ahead of the scope object, because the scope's defaults are those functions and the factory runs as the script is parsed. `document-terminal-shape.ts` takes the xterm-shape types out of the scope's file, which the four fields pushed over the 300-line cap; document-scope re-exports them, so no importer moves. The page's side of the seam lands in C7.5's later commits. Two shapes kept faithful rather than tidied. The unicode11 addon is still built inside the `try` it was built in, so a constructor that throws is still swallowed; and no WebGL addon still returns false from `attachWebglAddon` without reaching the `catch`, which is the DOM-renderer fallback rather than a failure. Golden regenerated: terminal-document-golden.txt 105,446 -> 105,968 bytes, document 723,480 -> 724,002. 20 lines out, 36 in, all at the five sites above and nowhere else: + (new, top of the IIFE) function postToReactNativeWebView(message) { if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify(message)); } } + (new) function createEngineTerminal(options) { return new Terminal(options); } + (new) function createEngineUnicode11Addon() { return window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon ? new window.Unicode11Addon.Unicode11Addon() : null; } + (new) function createEngineWebglAddon() { return window.WebglAddon && window.WebglAddon.WebglAddon ? new window.WebglAddon.WebglAddon() : null; } - " pendingTerm: null" + " pendingTerm: null," and four fields: postToHost: postToReactNativeWebView, createTerminal: createEngineTerminal, createUnicode11Addon: createEngineUnicode11Addon, createWebglAddon: createEngineWebglAddon - flog's nine lines "if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify({ type: "log", tag: "[fit]" + tag, payload })); }" + flog's five lines "scope.postToHost({ type: "log", tag: "[fit]" + tag, payload });" - " if (!scope.term || !window.WebglAddon || !window.WebglAddon.WebglAddon) {" + " if (!scope.term) {" - " addon = new window.WebglAddon.WebglAddon();" + " addon = scope.createWebglAddon();" then " if (!addon) {" / " return false;" / " }" - " scope.term = new Terminal({" + " scope.term = scope.createTerminal({" - " if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) {" / " try {" / " scope.term.loadAddon(new window.Unicode11Addon.Unicode11Addon());" / " } catch {" + " try {" / " const unicodeAddon = scope.createUnicode11Addon();" / " if (unicodeAddon) {" / " scope.term.loadAddon(unicodeAddon);" / " } catch {" - notify's three lines "if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify(msg)); }" + " scope.postToHost(msg);" Nothing else in the document moved: the emitted indentation, statement order and every other literal are byte for byte what they were. Two pinned readers follow the move. `terminal-webview-payload-hash.test.ts` takes the new length and digest. `terminal-webview-text-zoom.test.ts` kept both WebGL assertions and aimed them where the text now is: `window.WebglAddon.WebglAddon` and `new window.WebglAddon.WebglAddon()` are asserted on the scope preamble rather than on the recovery module, and the recovery module is asserted to call `scope.createWebglAddon()`. `host-seams.test.ts` is the new pin: it builds a scope before the globals exist to show the defaults read the window when they post, shows each addon factory answering null when the engine has none, and drives a host message in and a notify out with all four fields set, asserting the bridge is never touched. Red before this commit at 6 of 7 cases. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * build(mobile): write the xterm stylesheet as its own generated artifact The page mounts xterm itself, so it needs the engine's stylesheet and must never resolve the engine string: 612 KiB of minified IIFE built to be injected as text into a WebView document, unusable under the shell's `script-src 'self'` with no nested frame to load one into, and the largest single module the session route's closure would carry. Both lived in `terminal-webview-engine.generated.ts`, so one import of the CSS pulled the string in behind it. `build-terminal-webview-engine.mjs` now writes `terminal-webview-engine-css.generated.ts` beside it from the same read of `@xterm/xterm/css/xterm.css`, with the same comment strip and the same `http%3A//` scrub the no-external-URL gate wants. Gitignored beside its neighbour and written by the same postinstall step, so a fresh tree gets both or neither. `document-shell.ts` takes the CSS from the new module and the engine string from the old one; `build-terminal-document-fixture.mjs` and the two tests that hold both constants read them from their new homes. The document did not move: `terminal-document-golden.txt` is byte for byte what the last commit left, 105,968 bytes, and the payload digest is unchanged. The fence is `config/scripts/mobile-web-terminal-engine-closure.test.mjs`. It walks every module under `src/terminal/document/` as an entry point — the document is one script whose modules reach each other by side effect, so no single one of them roots a graph holding the rest — and asserts the engine string is in none of their closures, with two modules named as the precondition that the walk resolved anything at all. The native document's own closure is asserted to still hold both generated modules, so the first case cannot pass by the CSS having gone missing. And the third case plants a document module that imports the engine string in a scratch tree and shows the walk reports it, which is what makes the absence above a measurement. `mobileWebAppRouteClosure` is now a caller of `mobileWebAppEntryClosure`, which takes the entry points and an optional working directory; the route closure's own two entry points and its extensionless-specifier reason are unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the dead URL-tap constant and two stale reflow guards Round 1 fixes, all three folded here. 1. `URL_TAP_WEBVIEW_JS` is gone from terminal-webview-url-tap.ts, with `document/url-tap.test.ts` deleted alongside it. The document is generated from its modules now, so that constant was a second copy of the URL-tap group with no consumer but its own tests. terminal-webview-url-tap.test.ts's resolver harness reads the document's own text instead, the path-tap, url-tap, osc-link-tap and surface-tap modules in document order through `generatedDocumentModule`, which refuses unless the document carries each verbatim. Its 33 expects all stay. One mechanism-only assertion went with the file: `document/url-tap.test.ts`'s single `compareTerminalDocumentScripts` pin of the three emissions against the constant, which the flip test's whole-document pin already covers. The file's other exports stay. The deletion surfaced a third reader. terminal-webview-scroll-routing.test.ts concatenated terminal-webview-url-tap.ts into its `source`, and its `notify({ type: 'terminal-tap' });` assertion was matching the constant's single-quoted text, not the document. The read is dropped, since nothing else in that file needed it, and the assertion is the document's form: notify({ type: 'terminal-tap' }); -> notify({ type: "terminal-tap" }); Its 95 expects stay. Leaving the read in place would let a document assertion pass against a module source, which is the hazard this lane exists to remove. 2. terminal-webview-reflow.test.ts guarded a template placeholder that no longer exists, so it could not fail: expect(XTERM_HTML).not.toContain('TERMINAL_REFLOW_JS}') -> expect(XTERM_HTML.split(reflowSource).length - 1).toBe(1) Same intent against the generated document: the reflow module's emitted text is in the document exactly once. The case is renamed to say so and the comment above it describes the generator, not the deleted template. 3. Same file, the routine assertion still passed as a substring of the qualified call; qualified as line 30 already was: term.resize(nextCols, nextRows); -> scope.term.resize(nextCols, nextRows); Its 22 expects stay. Controls, each verified to have changed the file first, all red, tree green after restore: osc-link-tap return parsePathLineCol(value) -> url-tap test, 3 failed surface-tap notify({ type: 'terminal-tap' }) -> scroll-routing, 1 failed reflow scope.term.resize(nextCols, nextRows) -> reflow test, 2 failed module order 'reflow' listed twice -> reflow test, expected 2 to be 1 Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): mount the terminal document in the page instead of a WebView `react-native-webview` has no web build that renders anything: measured, it paints the line "React Native WebView does not support this platform" where the terminal was. So the page mounts the document itself — xterm imported from `@xterm/xterm` with the unicode11 and webgl addons, and the document's own modules imported in the order the generator emits them — behind the identical `TerminalWebViewProps` and `TerminalWebViewHandle`. Written as one implementation, not two. `use-terminal-webview-controller.ts` is everything `TerminalWebView.tsx` did that was not about `react-native-webview`: the readiness handshake, the pending queue, the write coalescer, the notify dispatch and the whole imperative handle. Its two arguments are the difference between the hosts — a sink that takes one `TerminalWebViewCommand`, and whether a foreground return has to re-prove the document with a ping. The native component posts across the bridge and answers yes on iOS; the web component calls `handleMsg` and answers no, because its document is the page's own modules and there is no second content process to lose. A second copy of that file is the fork the series exists to avoid, since the handle is the contract every consumer holds. `terminal-webview-ready-promises.ts` carries the two promises the handle hands out, `awaitReady` and `measureFitDimensions`, which the controller's length made a module. `document-style.ts` and `document-markup.ts` carry the stylesheet and the elements out of the document shell; the shell composes them and the golden is byte for byte unchanged, 105,968 bytes. `terminal-webview-html.web.ts` answers those two and the caret options and nothing else, so the page resolves no document string and no engine string. `terminal-web-document-mount.ts` is what the WebView's HTML used to be: it plants the stylesheet and the markup, sets the four scope seams, and reaches the modules by one dynamic import — they read their elements as they are parsed, so a static import would hoist above the planting and leave every one of them holding null. `page-document-modules.ts` is the order, `message-bridge` excluded per ruling 19 because on the page those `message` frames belong to the shell; its one non-bridge duty, the window-resize refit, is re-armed by the mount. `page-document-module-order.test.ts` holds that list against the generator's own, so a sorted import list or a module added on one side cannot pass. Two page-side degradations, both bounded and both stated. The document assigns `window.onerror` as it is parsed, so while a terminal is mounted page errors reach its reporter; the mount restores the previous handler on dispose. And a browser that refuses a WebGL context gets the DOM renderer, which is the fallback `webgl-recovery` already has for a context loss, with a `[fit]webgl-unavailable` notify saying so rather than a silent halving of the drain rate. `terminal-webview-consumer-census.test.ts` is the pin the substitution rests on: it scans `src/session` and the terminal directory for an import of the component file by name, of `terminal-webview-html`, of either generated engine module or of anything under `document/`, finds none outside the component and its mount, and shows on planted text that it would report each. `mobile-web-terminal-engine-closure.test.mjs` gains the component's own closure: `TerminalWebView.web.tsx` and `terminal-webview-html.web.ts` are in it, the engine string, the native HTML module and `message-bridge` are not. Four source greps follow the code into its new home, every assertion kept: `terminal-write-coalescer-boundaries` reads the coalescer's four boundaries in the controller, and reads the two lifecycle clears once in `resetReadiness` plus both WebView callers in the component; `terminal-webview-reflow` and `terminal-webview-scroll-routing` read the handle in the controller and the two timers in the promises module (`measureResolveRef.current === finish` -> `measureResolve === finish`, `void p.finally` -> `void pending.finally`). One behaviour was nearly lost and is pinned by an existing case: the native foreground-recovery ping reads `Platform.OS` at the moment of recovery, not at render, so the transport asks a predicate rather than carrying a boolean. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): render the page's terminal in a browser under the shell's policy 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 elements they read by id are planted by the component. No module test settles whether that opens at all under `script-src 'self'` with neither `unsafe-inline` nor `unsafe-eval`, or whether a real terminal byte stream reaches the buffer intact. Three cases in the C6 render harness, against the bundle built by the real builder and served under the policy parsed out of the shell's own Kotlin constant. The stream is built for the grid rather than committed: an SGR colour change per cell, an erase-to-end and an absolute cursor position per row, run out past the host's own 48 KiB chunk. 49,302 bytes applied through `handle.write`. It is read back through the document's own 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: 6,133 characters, both edge markers present, and no escape byte or SGR text left in them, which is what says the parser consumed the stream instead of printing it. The second case takes a fit through the handle, which on the page is a command in and a notify back with no bridge between, and carries design §8's cheap half of the IME question. It first pins something that changes where that probe can even point: 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 — so text entering a terminal on the page arrives at a `TextInput`, and that is what is typed into. Chrome reports `insertText` with `isComposing` false for each character, logged as `[c7.5][beforeinput]`. A composing IME on a real soft keyboard is the device step and this does not claim to answer it. CSP violations are counted with a `securitypolicyviolation` listener installed before anything else runs, which is stricter than the console-error filter the other render checks use — and the first thing it found was not the terminal's. The page entry carries Zod, whose `new Function` probe is swallowed by its own catch, so `script-src: eval` is refused once on any page route with no page error and no console line. The first case is the control that names it, on a route that mounts a marker and no terminal; the two terminal cases subtract it and report zero of their own. Zero page errors and zero console errors besides. No route serves this screen until C7.7, so the component is bundled through a scratch route tree, naming it extensionlessly so the bundler resolves `TerminalWebView.web.tsx` exactly as a real route would. That step retires when the session route is registered. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): retire the last module concatenator and guard the order list Round 2 fixes, all five folded here. 1. Deleted terminal-webview-html-source.test-support.ts. `readTerminalWebViewHtmlSource()` had no consumers left once the behavioural tests moved to the generated document, and it was the last thing that built a document-shaped string by concatenating module sources — its filter admitted `.test-support.ts` files too, so it could have grown one. Confirmed by grep that the only occurrence of either name in the repository was its own declaration. 2. New document-module-order.test.ts asserts both directions: the non-test, non-test-support `.ts` files under `document/` are exactly `{document-scope} + TERMINAL_DOCUMENT_MODULE_ORDER + {document-constants}`, and no name is listed twice. `document-constants` is the one exception because it is never emitted: its exports are substituted into the modules that import them as literals, so the document carries its values without carrying the module. A module added here and forgotten there would be dead code that reads as live; a name left after its file goes makes the generator throw at build time rather than at review time. 3. terminal-document-flip.test.ts's docstring now carries the retirement policy from ruling 18: the test is the proof of the flip and holds only while no module changes, the first lane that must change one retires it together with `terminal-document-pre-flip-script.txt`, and the standing pin from then on is `terminal-document-identity.test.ts`, whose fixture regeneration is a review event. Comment only. 4. terminal-document-equivalence.test-support.ts said 57 reassigned variables and "Four classes and no others". It now says 73 declaration sites and eight classes, with each class's measured figure named. Two doc comments sat above the wrong declaration and were moved onto what they describe: the `NUMBER_GLOBALS` one down to that constant, and the printing one down to `significantTokens`, with `STRICT_DIRECTIVE` given its own line. 5. build-terminal-document-script.mjs substituted constants with `replaceAll(regexp, literal)`, where `$&`, `` $` ``, `$'` and `$n` in a constant's value are read as replacement patterns. The substitution is now `substituteDocumentConstants`, exported so it can be tested directly, and replaces with a function. Controls, each verified to have changed its input first, all red, tree green after restore: plant document/zz-planted-module.ts -> order guard, "+ zz-planted-module" drop 'wheel-scroll' from the order -> order guard, "+ wheel-scroll" revert to the string replacer -> 4 failed, "a $& b" became "a marker b" The `$n` case is deliberately absent from that table: the pattern has no capture group, so `$1` is already literal under either form and a case for it could not tell them apart. The document did not move. The byte golden, the digest and the flip test's class table are all unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): measure what the page terminal costs the session route's closure The session route is not served on the page until C7.7, but the closure the bundler would walk is the same one and the terminal is the largest thing in it. Measured against this branch's base, `ota-c7-1-terminal-document` at51ae7b1b03: modules 4316 -> 4363 (+47) local modules 927 -> 971 (+44) minified bytes 3,930,787 -> 3,883,532 (-47,255) The route gets smaller. It sheds six modules — the native component, the 612 KiB engine string, the 105 KiB generated document script, the HTML module and the shell and close around it — all string literals of a program the page cannot run, and gains fifty: the component, its mount, the stylesheet and markup modules, the two the controller split made, and the document's own thirty-nine, with xterm and the two addons behind them at 607,945 bytes minified ESM on their own. `document-terminal-shape.ts` is not among them: it declares types and esbuild emits nothing for it. The census pins the trade in both directions, because "the engine string is absent" passes just as well on a closure that resolved nothing: the six shed modules are asserted gone, the eight gained ones and the three xterm packages asserted present, and the document asserted whole except `message-bridge`, which ruling 19 keeps off the page. It also holds the 16 px seam where C7.2 found it — nine offenders, no unresolved styles — since the terminal's modules joining this closure is exactly the change that could add a tenth unread. The page-closure families were run before and after on the full corpus, never a filtered scenarios file. Both sides: 7 files, 879 tests, exit 0 — and those 879 include the four page-closure pins, which assert the verdict of every golden C1, C2, C3 and C5 record, so an unchanged run is an unchanged verdict table rather than an unmeasured one. Per family with `vitest -t "session.terminal"`, both sides 19 passed and 773 skipped. No family moved, which is what an inert lane should show: this branch changes no RPC, no opcode, no grant and nothing the recorder reads. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): clear the changed-code gate findings this lane introduced Eleven findings from `check-changed-code-quality.mjs` against the base, all in code this lane added, none of them a behaviour change. Two type assertions lost their directive to the formatter. The xterm `Terminal` cast sits on the second line of a wrapped arrow body, so a directive above the assignment aims at the wrong line; it moves onto the line the assertion is on. The WebGL addon cast had no directive at all. Both keep the same `SAFETY:` rationale on one line, which is the only shape oxlint reads. Two more assertions in `host-seams.test.ts` are gone rather than annotated. The terminal double's `element` is a getter over a local the double's own `open` writes, and `withSeams` reads each field it is about to overwrite through `getOwnPropertyDescriptor` instead of indexing the scope with a cast. Then three `eslint-disable no-console` directives that disabled nothing, an `oxlint-disable` for `react-hooks/exhaustive-deps` that the rule never fired on — the reason it carried stays as a comment, since the dependency list is still deliberate — and one duplicated `node:fs/promises` import. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(config): name the closure helper what main already named it A trial merge against `origin/main` conflicts on this function: main grew the same generalisation independently, as `mobileWebAppModuleClosure(entryModules)` with `mobileWebAppRouteClosure` delegating to it and three callers in the page-closure families census. This branch is based on `ota-c7-1-terminal-document` and so cannot merge main, but it can stop being a second spelling of the same thing. Taken over wholesale: main's name, its parameter, its extension stripping and its comment, with `mobileWebAppRouteClosure` reduced to the one-line delegation main already has. The only addition is an options bag carrying `absWorkingDir`, which the engine-closure census needs to plant a module in a tree of its own and show the walk would report it; the real measurements never pass it. What was a whole-function conflict is now that one hunk. The census case that measured the native document had named `terminal-webview-html.ts` with its extension, which main's stripping does not allow. It names `terminal-webview-html/document-shell` instead — the module that actually reads both generated ones — which is the better probe anyway and needs no extension to resolve, since it has no `.web` sibling. `web-overrides.json` also conflicts and is left alone: both sides append entries to one list and the resolution is mechanical. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(config): put the two closure helpers in main's order The previous commit took main's name and signature but left the route closure below the module closure, where this branch had written it. Git merged both orderings and produced two copies of `mobileWebAppRouteClosure` on the merged tree, which oxlint reports as a duplicated export — a red the trial merge found and neither side's own lint could. Same order as main now: the route closure and its docstring first, the module closure under it. The trial merge is down to one hunk, the `absWorkingDir` parameter, and the merged tree lints clean. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make the flip comparator refuse what it was accepting Round 2 items 6 and 7, both in the equivalence instrument. 6. `isPrinterDisambiguation` accepted any `name2` facing `name` without proving the two were the same binding, so an unrelated rename ending in a digit would have been counted rather than refused. It is replaced by `UNSHADOWED_RENAMES`, an explicit list of pre-flip name, generated name and declaring module. The whole script has one entry: `term2` -> `term` in `query-reply`, which is the `term` parameter of `attachTerminalQueryReplyBridge` and its six uses, seven sites in all. That is stated in the docstring rather than encoded as a second pin, since the flip test already pins the total. 7. Brace absorption treated every unexpected `{` as a linter-added body and absorbed any later `}` while one was outstanding, so a bare block anywhere would have been swallowed. `isBraceableHeadBody` now requires the open to be the body of `if`, `for`, `while`, `else` or `do` — walking a `)` back to its `(` and reading the keyword before it — and `matchingCloseIndex` records the index the close must appear at, so the absorbed `}` is that body's own. That check had to move ahead of the equality check. Wherever a braced body ends a block, the baseline's next token is a `}` as well, so pairing them would consume the wrong one and leave the counts right for the wrong reason. Both refusals are tested over snippets: function f() { return value2; } vs return value; -> token 6: expected name value2, generated name value let value = 1; use(value); vs { let value = 1; } use(value); -> token 0: expected name let, generated { and the braceable heads are tested one by one, `if`, `for`, `while`, `if`/`else` and `do`, so the new rule is shown to accept every shape the `curly` rule produces and not only the one the document happens to exercise. Controls: restoring the shape rule fails the first refusal case and nothing else; restoring the accept-any-brace rule fails the second and nothing else. The eight counts did not move: 609, 73, 373, 279, 36, 17, 4, 7. Splitting out `terminal-document-tokens.test-support.ts` is not cosmetic. The tightened rules put the file over the 300-line cap, and a `max-lines` disable is forbidden, so the token reader moved to its own module: that side answers what a script says, and says nothing about which differences between two of them are allowed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(config): take main's docstrings for the two closure helpers The order matched but the prose did not, so the trial merge still conflicted on the whole block. Both docstrings are now main's own text, with one sentence trimmed: main names `MobileBrowserPane` as the first component with a pin of its own, which is C6's fact and not one this branch can assert. What remains between this branch and main in this file is the `absWorkingDir` parameter, which is what the engine-closure census plants a module with. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the page terminal's notify sink in an effect, not during render React Doctor's one error on this branch, and a real one: `receiveRef.current = receive` ran during render. React may replay or discard render work, so a mutation made there can leak from UI that never commits — and this ref is read from a callback the mounted document keeps, which outlives the render that installed it. Moved into its own effect, declared above the mount effect so the first read already sees a sink. `check-react-doctor-changed.mjs` goes from exit 1 to exit 0. Found late because the first run of that gate was read through `| tail`, which reports the pipeline's last command rather than the gate's own exit code. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): teach C7.1's order guard the three modules this lane added The guard C7.1 landed says the document directory and the order list name the same modules. On this branch three files are in that directory and not in that list, so it was red on the merge — which is the guard working, and the fix is to name each of them with its reason rather than to loosen the scan. document-host-seams emitted, but ahead of the scope rather than inside the order list, because the scope's defaults are its four functions and the factory runs as the script is parsed document-terminal-shape types only; esbuild emits nothing and an empty emission would add a blank line to the document page-document-modules the page's entry, not the WebView's, holding the same order for a host that has no generator to splice them Named one by one, not filtered by a pattern, so a fourth cannot join them by looking similar. A third case asserts the seams module is neither in the order list nor the scope module, which is the ordering the first two cannot see. Red before this commit: C7.1's version of the file on this tree reports `document-host-seams` and the other two as directory modules the list does not name. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): re-measure the session closure against the merged C7.1 base Same module counts — 4316 -> 4363 and 927 -> 971 local — but the minified figure moved from -47,255 to -55,561, and the 8,306-byte difference is C7.1's rather than this lane's. Its round-1 fold deleted `URL_TAP_WEBVIEW_JS` from `terminal-webview-url-tap.ts`, a module that enters this closure only once the page's component reaches it, so the saving shows on the after side and cannot show on the base. Both readings are recorded with the commit each was taken against, because a number with one base named and another used is the kind of thing a reviewer cannot check. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): retire the flip comparator with the pin it was built for The token comparator had exactly two consumers and neither survives. `document/url-tap.test.ts` went in C7.1's own round-1 fold at8da7680c9b, and `terminal-document-flip.test.ts` went in this lane's first commit under ruling 18, because the flip pin holds only while no module changes and C7.5 is the lane that changes them. What was left was a tool, its token reader and a test of the tool, answering to nothing. So `terminal-document-equivalence.test-support.ts`, the `terminal-document-tokens.test-support.ts` C7.1 split out of it, and `terminal-document-equivalence.test.ts` all go. That closes round 3's two LOW notes on the comparator — bounding an absorbed body to one statement, and refusing a bare block as `use();` against `{ use(); }` — since there is no comparator left to tighten. The standing pin on the document is the whole-document byte golden, which is a stronger claim than token equivalence ever was: it admits no normalisation at all. `document-module-order.test.ts` gains the case its exception list was asserting in prose. `document-terminal-shape` is not in the order list because esbuild erases a module of type declarations to the empty string, and emitting it would put a blank line in the document rather than a program; that emission is now measured and pinned as `''`. If the module ever declares a value the case goes red and the module belongs in the order list with its own line in the golden diff. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): make the document's error reporter the sixth host seam Ruling 19 reaches `window.onerror`. The document assigned it as it was parsed, which inside the WebView is taking nothing from anyone — that document owns its page — and on the page is a guest displacing whatever the host installed. Restoring it on dispose was a patch over the takeover, not an answer to it: while a terminal was mounted, every page error still went to the terminal's reporter. So `scope.installErrorReporter` joins the five, with today's assignment as its default. `host-notify` hands it the same handler it always installed, and the WebView's document is the program it was. The page supplies its own: an `error` listener that adapts the event to the reporter's arguments, added on mount and removed on dispose, and `window.onerror` is never written. This one seam is *called* as the modules are parsed rather than later, so the mount now reaches `document-scope` on its own first and sets every field before a single document module runs — which is also the safer order for the other five. Golden regenerated: 105,968 -> 106,116 bytes, document 724,002 -> 724,150. Three lines out, seven in, and nowhere else: + (new, beside the other defaults) function installWindowErrorReporter(report) { window.onerror = report; } - " createWebglAddon: createEngineWebglAddon" + " createWebglAddon: createEngineWebglAddon," and " installErrorReporter: installWindowErrorReporter" - " window.onerror = function(msg, source, line, column, err) {" + " scope.installErrorReporter(function(msg, source, line, column, err) {" - " };" + " });" `terminal-webview-payload-hash.test.ts` takes the new length and digest. Pinned on both sides. `host-seams.test.ts` gains the default taking `window.onerror` and a host that installs its reporter elsewhere leaving it null. The render check adds a browser case: `window.onerror` is null before the mount, null after it, and null after the component unmounts — with a real uncaught error thrown in between and asserted to reach `onEngineError`, so the first reading cannot pass on a terminal that had simply stopped reporting, and a second error after dispose asserted to reach nothing. Red with the mount's override removed: `expected undefined to be null`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): empty the session closure's react-native-webview list C7.6's census on main names the terminal as the last consumer and says whose work it is: "The terminal is the third and is C7.5's, which drops the engine string and mounts xterm in the document". This is that lane, so the list it left is now empty and the session closure reaches `react-native-webview` from nothing at all. Emptying a list weakens the case that reads it, because an empty result is also what a scan that read no file reports, so two things change with it. The main case gains its preconditions: the walk read a closure of more than 500 local modules, and it read the three web siblings whose native halves are exactly the modules that would have imported the package. And the control stops walking the list — with the list empty that compared nothing against nothing — and walks the three native files instead, which do import it, alongside the three web siblings, which do not. `TerminalWebView.web.tsx` joins the answered list, so the case that the builder resolves a web sibling rather than its native file now covers all three. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): pin the onerror seam against a handler the page actually owns The case read `null` before the mount, while mounted and after dispose. That is true but weak: a terminal that assigned `null` over a real handler would pass it, which is exactly the takeover ruling 19 forbids. So the page now installs a handler of its own in an init script, before the bundle loads, and the assertion is identity — `window.onerror === globalThis.__orcaSentinel`, compared inside the page because a function does not survive `evaluate` — at all three points. Between them an uncaught error is thrown and both reporters are asserted to see it: the page keeps the handler it installed, and the terminal's own listener still works, so the readings cannot pass on a terminal that had simply stopped reporting. After dispose a second error reaches the page's handler and not the terminal's, which is what taking the listener off has to mean. The `null` reading stays as its own case, because the other half matters too: on a page that installed nothing the terminal must not leave a handler behind for the next consumer to find. Both go red with the mount's `installErrorReporter` override removed — `expected false to be true` and `expected undefined to be null`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): start the terminal document per mount (ruling 20) Round 1's blocking finding: ES module bodies run once per page, so the page's second mount re-imported nothing and inherited the first mount's elements, listeners and error reporter. Measured after a remount: zero .xterm nodes in the live DOM, no selection overlay, nothing reaching onEngineError, and onWebReady still firing. Ruling 20: no emitted module does work as it is parsed. Every top-level effect moved into an exported per-module start function — 86 statements across 14 modules, plus three parse-time captures whose declarations became typed lets. The generator emits one call sequence in module order at the foot of the document, so the native script still runs them once at parse; the page runs the same sequence per mount and dispose undoes the three that outlive the host element (tap-dispatch, webgl-recovery, host-notify). installErrorReporter now hands back its own undo, so it stays five seams at six document sites rather than growing a sixth. M2: a failed document chunk was an unhandled rejection with no engine error. It now goes down the document's own reporting path, so the overlay names the cause instead of the 15s readiness watchdog. Pinned by refusing that chunk at the wire in the render check. L3: the seam count now reads five fields / six sites / three files everywhere. L4: three unrelated web-overrides entries keep main's escaping. Golden: 106116 -> 108134 bytes; payload 724150 -> 726168, sha256 2d089b8d9ab9491eed79cf7fe353dde6444799a3d297269ab660aee63ba56c82. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the parse-time census tree without assertions The changed-code gate refuses type assertions. The walker reached node fields through `as Record<string, unknown>`; it now reads them with Object.entries, which is checked and says the same thing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): move the document's state onto the scope (ruling 21) Round 2's blocking finding, and ruling 20's second half: moving parse-time effects out of the module bodies left the state behind. Nine module-level bindings survived a mount, so the second terminal inherited a spent non-fatal error budget (reporting nothing however it failed), the first terminal as its committed surface (disposing it twice), and the first mount's momentum loop. Every mutable binding now lives on the scope, and the scope carries one reset the start sequence calls first: native once at parse, the page once per mount. Moved, by module: query-reply 1, surface-swap 3, text-scaling 2, fit-scale 1, host-notify 2, selection-state-and-eviction 1, mouse-click-drag 1, tap-dispatch 1, surface-touch-gestures 1 — thirteen fields, two of them the objects tap-dispatch and surface-touch-gestures used to own outright. Because the reset is now the one initialiser, the start functions keep only what it cannot do: element reads, listener installs and the reporter install. Four start functions emptied and went; terminal-handle held nothing else and is deleted from the order list. The scope type splits into state and host seams, because a reset must restore the first and never the second. Every stop function cancels what its module scheduled. Timers go back through the handles the scope already held; frames go through the scope's own scheduleDocumentFrame, so dispose can take back the ones no module tracks by id. terminalGeneration and fitRetryToken carry forward across a reset, because a stale callback tests itself against them and a reset to zero would make the old number match again. L2: the seams-before-scope case asserts the order in the emitted document, not just non-membership. L3: the style docstring says what is true — one scope per page, so mount refuses a second live document and gives the page back when a mount fails. Golden: 108134 -> 108047 bytes; payload 726168 -> 726081, sha256 6a5a3216aab7b99daeb26bcdcfe6e325c415e5ef60c16405eea329ca141405fe. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse frames from a stopped document The frame case went red under full-suite load: tearing the terminal down runs the engine's own disposal, which calls back into these modules, and a frame asked for on the way out was owed by nobody because the cancel had already run. A stopped document now asks for no frames at all, so the ordering inside dispose stops mattering. The render case is also rewritten around the work that survives a loaded machine. It gives the terminal a scrollback and sends one wheel, which reveals the scroll indicator and arms the 550 ms timer to hide it again, and the boundary between the two mounts is drawn when the first terminal leaves the page rather than when the component is told to go — React unmounts on its own schedule, and a callback that runs while the first terminal is still up is not a leak. The precondition counts what the document scheduled under the first mount, so an empty leak list cannot mean the wheel reached nothing. Verified both ways at this head: red with stopViewportTransform and cancelDocumentFrames removed, green with them, and green in the whole config/scripts suite. Payload 726081 -> 726195, sha256 67a7b82bcd87b811214d02ca0e2f29bb634da47607e50f701bf153b9bf7323ef. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): style only what the page mount owns CodeRabbit on document-style.ts:16. The mount appended the document's whole stylesheet to the page head, so its `*`, `html` and `body` rules restyled every screen the shell can show and went on doing it after unmount. Ruling 19's shape: the native document owns its page and keeps the sheet as it is; the page mount may style only what it owns. The sheet splits into TERMINAL_DOCUMENT_ROOT_STYLE and TERMINAL_DOCUMENT_ELEMENT_STYLE, composed in the same order, so the emitted document does not move for the split - verified byte-identical before the seam below. The page injects the element half only, with every selector held under the host's own class, and xterm's sheet goes through the same rewrite. The rewrite refuses an at-rule rather than passing its inner selectors through unscoped. A second leak of the same kind was in the same measurement: applyTerminalTheme wrote the terminal background straight onto `html` and `body`. That is a sixth seam - six fields at seven document sites now. Its default does exactly the two writes it did; the page paints the host element instead. Emitted lines, old to new: `paintWindowDocumentBackground` added beside the other defaults (3 lines); `paintDocumentBackground: paintWindowDocumentBackground` added to the seam factory (1 line); in applyTerminalTheme, the two `document...style.background` writes become one `scope.paintDocumentBackground(background)`. Leaving the sheet in the head after unmount is kept, and is now defensible: the host drops the class on dispose, so every rule in it matches nothing until the next mount. The render check gains a case comparing `body` and `html` computed styles, while mounted and after dispose, against a page of the same application with no terminal on it, and asserting no rule of the injected sheet matches an element outside the host. Verified red both ways at this head: unscoped sheet moves `background-color` and `box-sizing`, and the inline theme write moves `background-color`. Payload 726195 -> 726363, sha256 9950f1770cd85ad2f80c69e074111869f6c66a724c87b66ba81f1ff10318a0ce. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): give the page mount's rules and frames their own oracles Round 3 blocks on evidence, not on shipped behaviour. Each item: H1. The scoping had no positive oracle: dropping the host class, or injecting an empty xterm sheet, left the render check green, because every assertion was about rules not escaping. The containment case now also reads four things off the live elements under the host — xterm's own `position: relative`, the viewport's `overflow-y: hidden`, that the viewport reserves no scrollbar width, and the overlay's `position: fixed`. Red both ways: no host class reds all four, an empty engine sheet reds the first. H3. `cancelDocumentFrames` had no witness: the only leak the timer case could see was the 550 ms hide timer, which its own module's stop cancels. There is now a case whose witness is a frame taken through `scheduleDocumentFrame` — the fit retry loop, with the surface hidden so the fit never commits and one frame is always owed at dispose — and it reds when only `cancelDocumentFrames` is removed. A unit covers the registry itself: a frame is held until it runs, a cancel takes back every pending one and then refuses to schedule, and a reset re-enables it. The two scheduling cases now assert on their own witness kind, so neither can stand in for the other, and the recorder judges a leak by whether the `#terminal-container` that was on the page at schedule time is still in the document — React unmounts on its own schedule, and a callback that runs while the first terminal is still up is not a leak. The timer witness moved from the scroll-indicator timer to the long-press timer, because the first needed a drained scrollback and raced the engine under load; its precondition caught that rather than passing. L1. The two seam docstrings each sit on their own function. L2. The parse-time census plants an element-read initialiser, which the statement filter cannot see, and an inert object literal, which a reader that flagged every initialiser would wrongly report. L3. Dispose disposes `scope.committedTerm` as well as `scope.term`: a swap that never committed leaves two terminals and only one was reached. Deduplicated, because they are the same object whenever no swap is open, and pinned both ways. L5. `document-style-scoping.ts` joins GAINED_OUTSIDE_THE_DOCUMENT. Golden unchanged at 108,329 bytes; payload and its hash unchanged. Render check: 12 cases. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the page document's dispose idempotent and owner-checked CodeRabbit on terminal-web-document-mount.ts:180. Dispose was neither. A handle outlives what it built - the component keeps one in a ref and React can run a cleanup after a later mount has started - and everything dispose touches is shared: the scope, the module sequences, window.__engineErrors. So a second call, or a call from a handle whose document had already been replaced, tore down the terminal that was on the screen and handed the page away while it was still in use. Each mount now carries a token, and dispose acts only when that token is still the live one. A token rather than the host element or its class: two mounts can be handed the same element, because the page remounts into a host React has reused, so an element is not an identity and the class says only that some document is using the host. The failed-mount path releases the page under the same check. Pinned both ways, red with the check removed: disposing twice leaves a terminal put back after the first teardown alone, and a stale handle disposed after a second document mounted changes nothing - the live markup stays, its terminal is not disposed, and the page is still refused to a third mount. Golden unchanged at 108,329 bytes; payload and hash unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let a pending page mount be disposed before its import lands Round 4 on #21809. H1. The mount claimed the page before its dynamic import and handed back a promise, so a component cleanup that ran while the chunk was still in flight had nothing to dispose: the claim outlived the mount it was made for, and Reload — the recovery ruling 20 names — was refused as a second document. The claim, the markup and the handle are now made synchronously, `ready` settles on its own, and a mount disposed while its import was in flight releases without starting anything. Pinned in the render check by holding the document chunk 20 s past the 15 s readiness watchdog, clicking Reload and waiting for the second mount to become live; red at that wait before the change. M1. The frame case's precondition asserted that a frame had been asked for while the document owned the page, not that one was owed when it was disposed. The fit retry commits on its first attempt whenever the grid still measures, so a dispose between two refits owed nothing and agreed with an empty leak list for exactly the reason under test — one run in five. The refit and the unmount now share one discrete click, which React flushes before the event returns, and a mutation observer reads the registry at the instant the host is emptied. Five red runs without `cancelDocumentFrames`, all on the leak and none on the precondition, and five green with it. M2. Two mounts handed the same element, which is what the token is for: the other six cases use a different element each, so a host comparison passes all of them. L1. A throw inside the start sequence released the token but ran no stop, leaving the host-notify error listener installed until the next reset nulled its undo. The sequence now unwinds the starts that completed, in reverse, before it rethrows. L2. A render case comparing the window and document listeners the page holds with no terminal on it, before and after a mount, so a stop that forgets one is a failure rather than a second copy per terminal ever shown. L4. Separated the stacked docstrings in the parse-time-effects census. The render check's bundle, server, browser and page helpers move to their own fixture module: the cases are what is under review and the scratch route tree is not, and the file was 16 code lines under its cap. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): count the page document's leaked frames from dispose, not from detach CI's addendum to round 4's M1: the frame case failed with the fix present, `expected [ Array(1) ] to deeply equal []`, on a slower runner. What scheduled it: `applyFitScale`, through `scheduleDocumentFrame` like every other frame the document asks for — the document has no other rAF call site. It is not an escape from the registry, so the registry is not what changes here. Why it was counted: React unmounts in two steps. The mutation phase detaches the host, and the passive cleanup that calls `dispose` runs after it — about 1 ms later here, 20 to 35 ms later with the CPU throttled 20x, which is the runner shape this failed on. A frame served in that gap runs with a detached container while the document is still live and has not been asked to stop, and nothing could have taken it back: `cancelDocumentFrames` had not been called yet. The oracle judged by the captured container's connectedness, so it read the gap as a leak. It now counts only what runs after the last statement of `dispose`, which is the class coming off the host, observed on the element because React may have detached it already. The same reading fixes the other direction. The precondition is read at that same moment, and the witness is a refit re-armed from a frame of the test's own, so the document is owed a frame at the end of every frame the browser serves and a dispose cannot land where nothing is owed. The single refit the case used before bought one frame, and the retry loop commits on its first attempt whenever the grid still measures. Evidence: with the boundary removed the case reproduces CI's `Array(1)` in two runs of three unthrottled, and in five of five with the CPU throttled 20x, where the detach-to-dispose gap measures 20 to 35 ms; with it, five green runs; with `cancelDocumentFrames` removed, five red runs, all on the leak read and none on the precondition. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): stop a page mount that lost its claim before it writes the scope Round 5 on #21809. F1 (blocking). `buildTerminalWebDocument` had no token, so after its `await import(...)` the whole body ran whatever had happened in the meantime: it overwrote the six seams, called `startPageDocumentModules` and added the resize listener, and only then did the caller's `.then` read the claim and throw the result away. Everything after that await is shared — the seams are fields on a module-singleton scope, and the start sequence resets that scope and installs the document's listeners — so a mount disposed while its chunk was in flight was writing over a mount that owns the page. The claim is now re-read the instant the import lands, before any of it, and the build returns null. `ready` for such a mount resolves rather than rejecting. Nothing failed: the caller asked for the terminal and then asked for it to go away, and the chunk arriving afterwards is not something for the error overlay to name. Before this it rejected with a TypeError from `startSelectionMenuButtons` reaching for an emptied host. F2. The rejection handler called `release()` unconditionally, emptying a host the mount may no longer own. It now releases only when the page is still its own. Pins, both red first. In happy-dom: mount, dispose, then await ready — no listener, timer or frame added while it resolves, the six seams unchanged, `terminalGeneration` unmoved because the start sequence never ran, and the page free for the next mount. Without the fix that case rejects with the `startSelectionMenuButtons` TypeError. In the browser, the Reload-while-in-flight case now reads the page's listeners with no terminal on it and compares them against a page that mounted once and disposed once; without the fix the abandoned mount leaves `window error` and `window resize` behind, because the second mount's scope reset nulls the first mount's reporter undo. The listener snapshot helper is shared with the mount-and-dispose case rather than written twice. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(config): give the render fixture's server and scratch tree back when it cannot start CodeRabbit on the render fixture, plus its note on `release`. The fixture. `chromium.launch` is the last step of the setup and the one that fails in practice — no Chromium on the machine, an `ORCA_MOBILE_WEB_RENDER_BROWSER` pointing nowhere — and by then the bundle server is listening and the scratch tree is on disk. Rejecting there left the caller without a handle, so `afterAll` had nothing to close and both stayed allocated; the listening socket is the one that bites, because an open server handle keeps the vitest worker alive after its last test has reported. The setup after `mkdtemp` is now wrapped, gives back whatever it managed to take, and rethrows the original error rather than anything the cleanup raised. The normal close path awaits the server-close callback instead of firing it. `release` in the page mount. The ownership check covered the claim but not the two lines that make the terminal disappear, so a release that skipped the claim would still empty the host and drop its class. The check now guards the whole function, and round 5's caller-side check is gone as a duplicate of it: one rule, inside the thing it governs. Both existing callers are unchanged in behaviour — the synchronous planting catch always owns the page, and the rejection handler was already guarded. Pinned red first. The new case points the launch at an executable that is not there, then asks the port the fixture actually served on for a connection and reads the scratch directories in the temp dir. Without the rollback the port still accepts and the scratch tree is still there; with it, neither. The port is recorded by wrapping the real `createBundleServer` rather than standing a double in front of it, and the case asserts a server was created at all, or the refusal would mean nothing. Two oracles were discarded on the way. `rejects.toThrow()` with no argument passes for a build that broke for its own reason, so the rejection is matched by message. `process.getActiveResourcesInfo()` reports `TCPServerWrap`, not `TCPSERVERWRAP`, so a count filtered on the upper-case spelling was zero in both arms and agreed with everything; it also still lists the handle at the moment the close callback runs. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): read the render fixture's rollback in a temp root of its own Two defects in the case I committed incb1833e675, both found by running it. The anti-slop gate refuses module mocking, and it is right to: the case recorded the served port by mocking the harness module around the real `createBundleServer`. Gone, with no disable. Its replacement read the shared temp directory for the fixture's scratch prefix, which the render check next door writes to from a worker of its own. So the case watched that tree appear and be swept up mid-run and called it a change: one red in four alone, and red in the full suite, where the two run together. `TMPDIR` now points at a directory this worker made, so the fixture's scratch tree lands somewhere nothing else writes and what is left in there afterwards was left by the setup under test. The failed launch also leaves Playwright artifacts and a browser profile in there, which are Playwright's to clean, so the reading is filtered to the name the fixture gives its own trees. The listening-socket half is unchanged and was right: spelled `TCPServerWrap` as Node spells it, and read a tick after the close callback, because the handle is still listed while that callback runs. Both halves now fail on their own without the thing they measure: with no rollback at all the socket count is one above its baseline, twice out of twice; with the rollback but no `rm`, the scratch tree is still there. Three green runs with both. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hand the started document to the mount in the turn that started it Round 6's two LOW items, and the pins for the owner-checked release. LOW 1. `started` was assigned in the `.then` after the build, a microtask later than the start sequence and the resize listener it installs. A dispose in that window found nothing started, skipped the teardown and released the page with the document still running on it. The build now takes an `adopt` callback and calls it as its last statement, inside the guarded region, so whoever has to undo the start is holding it before that turn ends. Pinned by queuing the dispose behind the document import the build awaits, which lands in exactly that window: without the change the started document's resize listener survives the dispose, five red runs out of five. The owner-checked release, which landed in8b37221b57without a pin of its own. The one path that reaches a mount's cleanup holding someone else's page is a rejected import: everywhere else the build re-reads the claim after its await and stops, but a rejection never gets that far. So the pin drives that — the chunk fails for the first mount only, the mount is disposed while pending, a second one is built into the same element as Reload does, and then the first rejection arrives. Without the guard inside `release` it empties the live mount's host: three red runs out of three, on the markup. It also disposes the abandoned handle a second time afterwards and asserts nothing moves, which is LOW 2's missing pin for round 5's F2. That case is its own file because the import has to fail before the mount module loads, and the mocking the failure needs is only permitted in `.test.ts` — the anti-slop override does not cover `.test.mjs`, which is what refused the port recording in the render fixture's case. It fails once, so the mount that replaces it gets real modules and is a live document worth protecting; its own resize listener is the witness that it started. Two oracles were dropped. Vitest reports its own message when a mock factory throws, not the one thrown, so which import failed is read from the factory's counter instead. And a counter of successful factory calls read zero even though the second mount got a working document, which measures vitest's caching rather than this code; the live mount's listener replaced it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the listener wrappers the mount pins install The mobile tests-typecheck ratchet was red on63eb8a40ae: six TS7006 implicit `any` parameters in each of the two mount pins, from arrow functions assigned over `window.addEventListener` and `window.removeEventListener`. An overloaded method gives an assigned arrow no contextual parameter types, so each wrapper's `type`, `listener` and `options` were implicitly `any` under `tsconfig.test.json`, which the product typecheck does not read. Both wrappers now take their parameters from the bound original as `Parameters<typeof realAdd>` and spread them through, so the signature is the real one rather than three widened parameters. No casts and no `any`. Re-verified that the change did not quietly disarm either pin, because a recorder that counted nothing would also go green: with `release` unguarded the rejection case still fails on the live mount's markup, and with the adopt deferred by a microtask the single-mount case still fails on the started document's resize listener surviving its dispose. The ratchet itself is the finding worth keeping. It is not part of the mobile `tsc` the rest of my gate set runs, and it had dropped out of that set when these folds began, so three reports listed the other ratchets and not this one. It is back in, and stays in. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop what a disposed page mount adopted, and close the fixture's three resources apart Round 7's five items. 1. The queued-dispose case's precondition was vacuous. It read the host for a missing container, which dispose empties on every path, so a build that returned straight after its ownership check satisfied it. The wrapper now counts resize adds and the case asserts exactly one, which is the document having started. Red under that mutation, on the count. 2. The render fixture's rollback awaited its cleanup unguarded, so a cleanup that also refused replaced the error the caller needs — the reason the setup failed. The rollback is best-effort now and the original error is what comes back. 3. That cleanup stopped at the first throw, so a browser refusing to close took the socket and the scratch tree with it, which is the leak the rollback exists to prevent. Each of the three is asked independently and the first failure is rethrown after all three have been tried. 4. The rejection case restores its `window` patch in a `finally`, as its sibling does, so a failure part way through no longer leaves the patched functions behind for everything that runs after it. 5. `dispose` left `started` set. `send` reads it, and what it holds names the page's one set of document modules, so a stale handle could route a host command into whichever document is live next. Nulled, and pinned: the stale handle pings, and with the old code the *live* mount's `receive` answers `pong`, because the scope's seam belongs to it by then. The precondition is the live handle's own ping being answered, so the silence is the stale handle declining rather than the command doing nothing. Items 2 and 3 have no pin of their own. Both are failure paths of the cleanup itself, reachable only by making a browser or a socket refuse to close, and standing something in front of Playwright to do it is what the anti-slop gate refuses in this file's suffix. The rollback's own pin still covers the path that matters, and both changes are read by it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -394,10 +394,13 @@ export async function mobileWebAppRouteClosure(routeModule) {
|
||||
* C5.2 and C3.2 generate, derive theirs by the C1.6 method inside the mobile suite. The two are
|
||||
* not the same computation, and a divergence between them is a finding rather than noise.
|
||||
*/
|
||||
export async function mobileWebAppModuleClosure(entryModules) {
|
||||
export async function mobileWebAppModuleClosure(entryModules, { absWorkingDir } = {}) {
|
||||
const base = mobileWebAppBuildOptions(MOBILE_WEB_PAGE_ROUTES)
|
||||
const result = await esbuild.build({
|
||||
...base,
|
||||
// A census that plants a module to show the walk would report it needs a tree of its own; the
|
||||
// real ones never pass this and keep measuring `mobile/`.
|
||||
...(absWorkingDir ? { absWorkingDir } : {}),
|
||||
// Extensionless, so `resolveExtensions` picks the same file the bundle ships: a route with a
|
||||
// `.web.tsx` sibling resolves to that one, and naming the `.tsx` path explicitly would measure
|
||||
// the native switch no browser ever loads.
|
||||
|
||||
@@ -384,3 +384,173 @@ export async function createBundleServer({ outDir, cspHeader, transformChunk })
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
return { server, origin: `http://127.0.0.1:${String(server.address().port)}` }
|
||||
}
|
||||
|
||||
/**
|
||||
* A handler of the page's own, installed before the bundle so the terminal meets a `window.onerror`
|
||||
* that belongs to someone else.
|
||||
*
|
||||
* Reading `null` three times would pass on a terminal that assigned `null` over a real handler,
|
||||
* which is the failure this seam exists to prevent. The sentinel is identity-checked in the page
|
||||
* rather than marshalled out of it — a function does not survive `evaluate` — and it returns
|
||||
* false so the browser still reports the error normally.
|
||||
*/
|
||||
export function installPageErrorSentinel() {
|
||||
globalThis.__orcaSentinelCalls = []
|
||||
const sentinel = (message) => {
|
||||
globalThis.__orcaSentinelCalls.push(String(message))
|
||||
return false
|
||||
}
|
||||
globalThis.__orcaSentinel = sentinel
|
||||
window.onerror = sentinel
|
||||
}
|
||||
|
||||
/**
|
||||
* Every animation frame and timer, tagged with the mount that scheduled it.
|
||||
*
|
||||
* Installed before the bundle loads, so the document's own scheduling goes through it. Each
|
||||
* schedule remembers the `#terminal-container` that was on the page at the time; a callback that
|
||||
* runs once that element has left the document is a frame or timer of the first mount firing
|
||||
* into the second, which is the whole finding. The element rather than a counter the test bumps,
|
||||
* because React unmounts on its own schedule and a callback that runs while the first terminal is
|
||||
* still up is not a leak. Every schedule is kept, not just the ones still owed, so the test can
|
||||
* say that there was something to leak before it says that nothing did.
|
||||
*/
|
||||
export function installSchedulerRecorder() {
|
||||
globalThis.__orcaScheduler = { watching: false, scheduled: [], leaked: [] }
|
||||
const state = globalThis.__orcaScheduler
|
||||
const wrap = (schedule, kind) =>
|
||||
function (callback, ...rest) {
|
||||
if (!state.watching || typeof callback !== 'function') {
|
||||
return schedule(callback, ...rest)
|
||||
}
|
||||
// The line that called this, which is the script the work belongs to. Line 0 is the error's
|
||||
// own header and line 1 is this wrapper.
|
||||
const caller = ((new Error('scheduled').stack ?? '').split('\n')[2] ?? '').trim()
|
||||
const container = document.getElementById('terminal-container')
|
||||
// `fired` is what makes "owed" readable: a callback that has not run is still owed, whether
|
||||
// it was cancelled or is merely waiting, and cancelling never sets it.
|
||||
const entry = { kind, caller, owned: container !== null, fired: false }
|
||||
state.scheduled.push(entry)
|
||||
return schedule(
|
||||
(...args) => {
|
||||
entry.fired = true
|
||||
if (container !== null && !container.isConnected) {
|
||||
state.leaked.push(`${kind} from ${caller}`)
|
||||
}
|
||||
return callback(...args)
|
||||
},
|
||||
...rest
|
||||
)
|
||||
}
|
||||
globalThis.requestAnimationFrame = wrap(
|
||||
globalThis.requestAnimationFrame.bind(globalThis),
|
||||
'frame'
|
||||
)
|
||||
globalThis.setTimeout = wrap(globalThis.setTimeout.bind(globalThis), 'timer')
|
||||
globalThis.setInterval = wrap(globalThis.setInterval.bind(globalThis), 'interval')
|
||||
}
|
||||
|
||||
/** Recorded before anything else runs, so a refusal during the page's own boot is counted. */
|
||||
/**
|
||||
* Every window and document listener the page holds, by target, type and phase.
|
||||
*
|
||||
* Identity, not a tally: `addEventListener` with a listener the target already holds is a no-op in
|
||||
* the DOM, and `removeEventListener` with one it does not hold is too, so counting calls would
|
||||
* report leaks a browser does not have. The set is the live listeners, which is what a snapshot
|
||||
* before and after a mount can be compared on.
|
||||
*/
|
||||
export function installListenerRecorder() {
|
||||
const live = new Map()
|
||||
globalThis.__orcaListeners = {
|
||||
snapshot: () =>
|
||||
Object.fromEntries(
|
||||
[...live.entries()]
|
||||
.map(([key, listeners]) => [key, listeners.size])
|
||||
.filter(([, n]) => n > 0)
|
||||
)
|
||||
}
|
||||
const keyFor = (target, type, options) => {
|
||||
const where = target === globalThis ? 'window' : target === document ? 'document' : null
|
||||
if (where === null) {
|
||||
return null
|
||||
}
|
||||
const capture = typeof options === 'object' && options !== null ? !!options.capture : !!options
|
||||
return `${where} ${type}${capture ? ' capture' : ''}`
|
||||
}
|
||||
const add = EventTarget.prototype.addEventListener
|
||||
const remove = EventTarget.prototype.removeEventListener
|
||||
EventTarget.prototype.addEventListener = function (type, listener, options) {
|
||||
const key = keyFor(this, type, options)
|
||||
if (key !== null && listener) {
|
||||
if (!live.has(key)) {
|
||||
live.set(key, new Set())
|
||||
}
|
||||
live.get(key).add(listener)
|
||||
}
|
||||
return add.call(this, type, listener, options)
|
||||
}
|
||||
EventTarget.prototype.removeEventListener = function (type, listener, options) {
|
||||
const key = keyFor(this, type, options)
|
||||
if (key !== null && listener) {
|
||||
live.get(key)?.delete(listener)
|
||||
}
|
||||
return remove.call(this, type, listener, options)
|
||||
}
|
||||
}
|
||||
|
||||
export function installCspViolationRecorder() {
|
||||
globalThis.__orcaCspViolations = []
|
||||
document.addEventListener('securitypolicyviolation', (event) => {
|
||||
globalThis.__orcaCspViolations.push(
|
||||
`${event.violatedDirective}: ${event.blockedURI || 'inline'} @ ${event.sourceFile ?? '?'}:${String(event.lineNumber ?? 0)}`
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Every computed property of `html` and `body`, as one string each.
|
||||
*
|
||||
* The oracle for "the page mount styles only what it owns" is a page of the same application with
|
||||
* no terminal on it, so the comparison is against another page rather than against a list of
|
||||
* properties someone chose. A rule that escaped the host would have to move one of these.
|
||||
*/
|
||||
export async function readRootComputedStyles(page) {
|
||||
return await page.evaluate(() => {
|
||||
const read = (element) => {
|
||||
const computed = getComputedStyle(element)
|
||||
const entries = []
|
||||
for (const property of computed) {
|
||||
entries.push(`${property}: ${computed.getPropertyValue(property)}`)
|
||||
}
|
||||
return entries.join('\n')
|
||||
}
|
||||
return { body: read(document.body), html: read(document.documentElement) }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* What the terminal's injected sheet matches, and how much of it there is.
|
||||
*
|
||||
* The rule count is the precondition for the empty list: a sheet that was never planted, or one
|
||||
* the browser refused, would match nothing for a reason that has nothing to do with scoping.
|
||||
*/
|
||||
export async function terminalStyleReach(page) {
|
||||
return await page.evaluate(() => {
|
||||
const sheet = [...document.styleSheets].find(
|
||||
(one) => one.ownerNode?.id === 'orca-terminal-document-style'
|
||||
)
|
||||
if (!sheet) {
|
||||
return { rules: 0, outside: ['the terminal stylesheet is not in the head'] }
|
||||
}
|
||||
const host = document.querySelector('.orca-terminal-document-host')
|
||||
const outside = []
|
||||
for (const rule of sheet.cssRules) {
|
||||
for (const element of document.querySelectorAll(rule.selectorText)) {
|
||||
if (!host || !host.contains(element)) {
|
||||
outside.push(`${rule.selectorText} matched ${element.tagName}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { rules: sheet.cssRules.length, outside }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mobileWebAppRouteClosure } from './build-mobile-web-app-bundle.mjs'
|
||||
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
|
||||
import {
|
||||
textInputFontSizeOffenders,
|
||||
unresolvedTextInputStyles
|
||||
} from './mobile-web-app-text-input-font-size-seam.mjs'
|
||||
|
||||
/**
|
||||
* What putting the terminal on the page costs the session route's closure.
|
||||
*
|
||||
* The route is not served on the page until C7.7 — its module is still the native switch and
|
||||
* there is no `.web.tsx` beside it — but the closure the bundler would walk is the same one, and
|
||||
* the terminal is by far the largest thing in it. Measured here so the trade is a number rather
|
||||
* than a claim, and so that a later change cannot quietly put the engine string back.
|
||||
*
|
||||
* Measured against `origin/main` at 9fbdfc592c, which is the merge base this branch now sits on:
|
||||
*
|
||||
* modules 4277 -> 4320 (+43)
|
||||
* local modules 926 -> 970 (+44)
|
||||
* minified bytes 3,868,833 -> 3,812,418 (-56,415)
|
||||
*
|
||||
* The route gets smaller. It sheds six modules — the native component, the 612 KiB engine string,
|
||||
* the 105 KiB generated document script, the HTML module and the shell and close around it, all
|
||||
* string literals of a program the page cannot run — and gains fifty: the document's own 39, the
|
||||
* component, its mount, the stylesheet and markup, the two the controller split made, and xterm
|
||||
* with its two addons behind them at 607,945 bytes minified ESM on their own.
|
||||
*
|
||||
* Two earlier readings of the same measurement, against the bases this branch sat on before:
|
||||
* -47,255 at 51ae7b1b03 and -55,561 at 0ce0fc99a2. They differ because C7.1's own round-1 fold
|
||||
* deleted `URL_TAP_WEBVIEW_JS` from a module only the page's component brings into this closure,
|
||||
* so the saving lands on the after side and no base can show it.
|
||||
*/
|
||||
|
||||
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const mobileDir = join(projectDir, 'mobile')
|
||||
|
||||
const SESSION_ROUTE = 'app/h/[hostId]/session/[worktreeId].tsx'
|
||||
|
||||
/** Gone with the WebView: string literals of a program the page has no way to run. */
|
||||
const SHED = [
|
||||
'src/terminal/TerminalWebView.tsx',
|
||||
'src/terminal/terminal-webview-engine.generated.ts',
|
||||
'src/terminal/terminal-webview-document-script.generated.ts',
|
||||
'src/terminal/terminal-webview-html.ts',
|
||||
'src/terminal/terminal-webview-html/document-shell.ts',
|
||||
'src/terminal/terminal-webview-html/document-close.ts'
|
||||
]
|
||||
|
||||
/** The component, its mount, the stylesheet and the markup, and the modules the splits made. */
|
||||
const GAINED_OUTSIDE_THE_DOCUMENT = [
|
||||
'src/terminal/TerminalWebView.web.tsx',
|
||||
'src/terminal/terminal-web-document-mount.ts',
|
||||
'src/terminal/terminal-webview-engine-css.generated.ts',
|
||||
'src/terminal/terminal-webview-html.web.ts',
|
||||
'src/terminal/terminal-webview-html/document-markup.ts',
|
||||
'src/terminal/terminal-webview-html/document-style.ts',
|
||||
// The page's half of the stylesheet: the document-level rules are dropped and the rest is held
|
||||
// under the host, so what the page injects can only reach what the terminal owns.
|
||||
'src/terminal/terminal-webview-html/document-style-scoping.ts',
|
||||
'src/terminal/terminal-webview-ready-promises.ts',
|
||||
'src/terminal/use-terminal-webview-controller.ts'
|
||||
]
|
||||
|
||||
const XTERM_PACKAGES = ['@xterm/xterm', '@xterm/addon-unicode11', '@xterm/addon-webgl']
|
||||
|
||||
/**
|
||||
* The 16 px seam's verdict for this route, which C7.5 must leave exactly where C7.2 left it.
|
||||
*
|
||||
* Design §3 counted nine inputs under the floor here and C7.2 moved all nine onto the seam, so the
|
||||
* answer is now none. Asserted rather than left unmeasured because the terminal's own modules
|
||||
* joining this closure is precisely the kind of change that could add a tenth unread.
|
||||
*/
|
||||
const EXPECTED_OFFENDERS = 0
|
||||
|
||||
const bundles = mobileWebAppDependenciesPresent()
|
||||
const describeClosure = bundles ? describe : describe.skip
|
||||
|
||||
describeClosure(
|
||||
"the session route's page closure with the terminal on it",
|
||||
() => {
|
||||
it('gains the document, xterm and the addons, and sheds the engine string', async () => {
|
||||
const { local, modules } = await mobileWebAppRouteClosure(SESSION_ROUTE)
|
||||
for (const gone of SHED) {
|
||||
expect(local, `${gone} is still in the closure`).not.toContain(gone)
|
||||
}
|
||||
for (const gained of GAINED_OUTSIDE_THE_DOCUMENT) {
|
||||
expect(local, `${gained} is not in the closure`).toContain(gained)
|
||||
}
|
||||
for (const name of XTERM_PACKAGES) {
|
||||
expect(
|
||||
modules.some((module) => module.includes(`node_modules/${name}/`)),
|
||||
`${name} is not in the closure`
|
||||
).toBe(true)
|
||||
}
|
||||
// The document, whole: every module the generator emits except the bridge, which ruling 19
|
||||
// keeps off the page because those `message` frames belong to the shell.
|
||||
const documentModules = local.filter((module) => module.startsWith('src/terminal/document/'))
|
||||
expect(documentModules.length).toBeGreaterThanOrEqual(36)
|
||||
expect(documentModules).not.toContain('src/terminal/document/message-bridge.ts')
|
||||
expect(documentModules).toContain('src/terminal/document/page-document-modules.ts')
|
||||
}, 300_000)
|
||||
|
||||
it('leaves the 16px seam census exactly where C7.2 left it', async () => {
|
||||
const closure = await mobileWebAppRouteClosure(SESSION_ROUTE)
|
||||
// Two preconditions, because zero offenders is what a walk that read nothing also reports:
|
||||
// the seam's own web module has to be in the closure, and no style may be unresolved.
|
||||
expect(closure.local).toContain('src/platform/text-input-font-size.web.ts')
|
||||
expect(unresolvedTextInputStyles(mobileDir, closure)).toEqual([])
|
||||
expect(textInputFontSizeOffenders(mobileDir, closure)).toHaveLength(EXPECTED_OFFENDERS)
|
||||
}, 300_000)
|
||||
},
|
||||
900_000
|
||||
)
|
||||
@@ -5,9 +5,14 @@
|
||||
* where its consumer was, so a page does not go down over one — but nothing it was mounted for
|
||||
* works either, and the closure pays for a module that cannot do its job.
|
||||
*
|
||||
* C7.6 gives the two editors the plain states they already degrade to (`rulings-ota-c7.md` ruling
|
||||
* 8). The terminal is the third and is C7.5's, which drops the engine string and mounts xterm in
|
||||
* the document; it is listed here rather than left unsaid so the list is the work remaining.
|
||||
* C7.6 gave the two editors the plain states they already degrade to (`rulings-ota-c7.md` ruling
|
||||
* 8) and left the terminal listed as the work remaining, which was C7.5's. C7.5 has done it: the
|
||||
* page mounts xterm in the document and drops the engine string, so the list is now empty and
|
||||
* this closure reaches that package from nowhere at all.
|
||||
*
|
||||
* An empty list is also what a scan that read nothing reports, so the control below no longer
|
||||
* uses the list — it runs the same walk over three native modules that do import the package and
|
||||
* over the three web siblings that replace them.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
@@ -21,13 +26,21 @@ const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.
|
||||
|
||||
const SESSION = 'app/h/[hostId]/session/[worktreeId].tsx'
|
||||
|
||||
/** Still on the native component, and whose PR it is. */
|
||||
const REMAINING = ['src/terminal/TerminalWebView.tsx']
|
||||
/** Nothing: every consumer this closure had now resolves to a web sibling that needs no WebView. */
|
||||
const REMAINING = []
|
||||
|
||||
/** The two this PR answered, whose `.web.tsx` the builder resolves instead. */
|
||||
/** The three answered, whose `.web.tsx` the builder resolves instead of the native file. */
|
||||
const ANSWERED = [
|
||||
'src/components/MobileRichMarkdownEditor.web.tsx',
|
||||
'src/components/MobileHtmlPreview.web.tsx'
|
||||
'src/components/MobileHtmlPreview.web.tsx',
|
||||
'src/terminal/TerminalWebView.web.tsx'
|
||||
]
|
||||
|
||||
/** The native files behind those three, which do import the package. The scan's own control. */
|
||||
const NATIVE_CONSUMERS = [
|
||||
'src/components/MobileRichMarkdownEditor.tsx',
|
||||
'src/components/MobileHtmlPreview.tsx',
|
||||
'src/terminal/TerminalWebView.tsx'
|
||||
]
|
||||
|
||||
const IMPORTS_WEBVIEW = /(?:from|import)\s*'[^']*react-native-webview'/
|
||||
@@ -45,9 +58,15 @@ function webViewConsumers(closure) {
|
||||
describeClosure(
|
||||
'the session closure and react-native-webview',
|
||||
() => {
|
||||
it('reaches it from the terminal and from nothing else', async () => {
|
||||
it('reaches it from nothing at all', async () => {
|
||||
const closure = await mobileWebAppRouteClosure(SESSION)
|
||||
expect(webViewConsumers(closure)).toEqual(REMAINING)
|
||||
// The precondition an empty list needs: the walk read a closure, and read the very modules
|
||||
// whose native halves are the ones that would have imported the package.
|
||||
expect(closure.local.length).toBeGreaterThan(500)
|
||||
for (const file of ANSWERED) {
|
||||
expect(closure.local, file).toContain(file)
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves both editors to their web siblings, not to the native files', async () => {
|
||||
@@ -58,9 +77,10 @@ describeClosure(
|
||||
}
|
||||
})
|
||||
|
||||
it('finds a consumer when there is one, so the list above is a measurement', async () => {
|
||||
// The control: the same walk over the module the list names, which does import it.
|
||||
expect(webViewConsumers({ local: REMAINING })).toEqual(REMAINING)
|
||||
it('finds a consumer when there is one, so the empty list above is a measurement', () => {
|
||||
// The control, run over the native files rather than over the list: with the list empty,
|
||||
// walking it would compare nothing against nothing and pass on a scan that reads no file.
|
||||
expect(webViewConsumers({ local: NATIVE_CONSUMERS })).toEqual(NATIVE_CONSUMERS)
|
||||
expect(webViewConsumers({ local: ANSWERED })).toEqual([])
|
||||
})
|
||||
},
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* The scratch route tree the terminal render check bundles, and the streams it drives.
|
||||
*
|
||||
* No route serves this screen until C7.7, so the component is reached through a route tree
|
||||
* written to a temporary directory. That is a bundler entry and a page under test, not an
|
||||
* assertion, and it is here so the check itself stays the list of things being measured. This
|
||||
* step retires the moment the session route is registered.
|
||||
*/
|
||||
|
||||
export const COLS = 80
|
||||
export const ROWS = 24
|
||||
/** Design §2 measured the host's own chunker at 48 KiB, so the sample is at least one full one. */
|
||||
export const MIN_STREAM_BYTES = 48 * 1024
|
||||
/** Printed at the top of the stream and again at the end, so the read-back covers both edges. */
|
||||
export const FIRST_MARKER = 'ORCA-TERMINAL-RENDER-FIRST'
|
||||
export const LAST_MARKER = 'ORCA-TERMINAL-RENDER-LAST'
|
||||
|
||||
/**
|
||||
* An escape-dense sample of at least 48 KiB: an SGR colour change every cell, an erase-to-end and
|
||||
* an absolute cursor position per row. Built here rather than committed because it is a function
|
||||
* of the grid, and a fixture sized from the constant it is meant to exercise proves nothing.
|
||||
*/
|
||||
export function escapeDenseStream() {
|
||||
const esc = '\u001b'
|
||||
const rows = []
|
||||
rows.push(`${esc}[2J${esc}[H${FIRST_MARKER}\r\n`)
|
||||
let row = 2
|
||||
let bytes = rows[0].length
|
||||
while (bytes < MIN_STREAM_BYTES) {
|
||||
const cells = []
|
||||
for (let column = 0; column < COLS - 1; column++) {
|
||||
const colour = 31 + ((row + column) % 7)
|
||||
cells.push(`${esc}[${String(colour)};1m${String.fromCharCode(97 + ((row + column) % 26))}`)
|
||||
}
|
||||
const line = `${esc}[${String(row)};1H${esc}[K${cells.join('')}${esc}[0m\r\n`
|
||||
rows.push(line)
|
||||
bytes += line.length
|
||||
row += 1
|
||||
}
|
||||
rows.push(`${LAST_MARKER}\r\n`)
|
||||
return rows.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* The scratch route: the component under test, its handle and its notifies on `globalThis`.
|
||||
*
|
||||
* Written rather than committed because it is the bundler's entry and nothing else — a file under
|
||||
* `mobile/app` would register a route the shell could open. `beforeinput` is recorded off the
|
||||
* xterm helper textarea, which is design §8's cheap half of the IME question: it says what the
|
||||
* browser reports for text entering a terminal on the page, and leaves a composing IME on a real
|
||||
* keyboard to the device step it cannot answer.
|
||||
*/
|
||||
export function probeRouteSource(componentPath) {
|
||||
return `import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { TextInput, View } from 'react-native'
|
||||
import { TerminalWebView } from ${JSON.stringify(componentPath)}
|
||||
|
||||
export default function TerminalProbeRoute() {
|
||||
const handleRef = useRef(null)
|
||||
const [mounted, setMounted] = useState(true)
|
||||
const onSelectionCopy = useCallback((text) => {
|
||||
globalThis.__orcaTerminalCopied = text
|
||||
}, [])
|
||||
const onWebReady = useCallback(() => {
|
||||
globalThis.__orcaTerminalReady = true
|
||||
}, [])
|
||||
const onEngineError = useCallback((message) => {
|
||||
globalThis.__orcaTerminalEngineErrors.push(message)
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
globalThis.__orcaTerminalEngineErrors = globalThis.__orcaTerminalEngineErrors ?? []
|
||||
globalThis.__orcaTerminalBeforeInput = []
|
||||
globalThis.__orcaTerminalProbe = {
|
||||
init: (cols, rows, data) => handleRef.current?.init(cols, rows, data, false, []),
|
||||
write: (data) => handleRef.current?.write(data),
|
||||
selectAll: () => handleRef.current?.doSelectAll(),
|
||||
measure: () => handleRef.current?.measureFitDimensions(),
|
||||
awaitReady: () => handleRef.current?.awaitReady(),
|
||||
setMounted: (next) => setMounted(next)
|
||||
}
|
||||
const onBeforeInput = (event) => {
|
||||
globalThis.__orcaTerminalBeforeInput.push({
|
||||
inputType: event.inputType,
|
||||
data: event.data === null ? null : String(event.data),
|
||||
isComposing: !!event.isComposing
|
||||
})
|
||||
}
|
||||
document.addEventListener('beforeinput', onBeforeInput, true)
|
||||
return () => document.removeEventListener('beforeinput', onBeforeInput, true)
|
||||
}, [])
|
||||
return (
|
||||
<View testID="terminal-probe" style={{ flex: 1 }}>
|
||||
{mounted ? (
|
||||
<TerminalWebView
|
||||
ref={handleRef}
|
||||
onWebReady={onWebReady}
|
||||
onEngineError={onEngineError}
|
||||
onSelectionCopy={onSelectionCopy}
|
||||
/>
|
||||
) : null}
|
||||
{/* The shape the terminal's live input takes on the page: xterm's own textarea is inert by
|
||||
the document's design, so this is where typed text arrives. */}
|
||||
<TextInput testID="terminal-live-input" style={{ fontSize: 16 }} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* The same page with no terminal on it.
|
||||
*
|
||||
* The page entry already carries Zod, which probes for `new Function` and swallows the
|
||||
* `EvalError`, so the shell's `script-src 'self'` records one refusal on any route before a line
|
||||
* of terminal code runs. Comparing against this control is what makes "zero violations" a
|
||||
* statement about the terminal rather than about the bundle it lives in.
|
||||
*/
|
||||
export const CONTROL_SOURCE = `import { View } from 'react-native'
|
||||
|
||||
export default function ControlRoute() {
|
||||
globalThis.__orcaTerminalControlMounted = true
|
||||
return <View testID="terminal-control" />
|
||||
}
|
||||
`
|
||||
|
||||
export const LAYOUT_SOURCE = `import { Slot } from 'expo-router'
|
||||
export default function ProbeLayout() {
|
||||
return <Slot />
|
||||
}
|
||||
`
|
||||
@@ -0,0 +1,212 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium } from 'playwright-core'
|
||||
import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs'
|
||||
import { MOBILE_WEB_APP_ROUTE_ROOT } from './mobile-web-app-route-manifest.mjs'
|
||||
import {
|
||||
COLS,
|
||||
CONTROL_SOURCE,
|
||||
LAYOUT_SOURCE,
|
||||
probeRouteSource,
|
||||
ROWS
|
||||
} from './mobile-web-app-terminal-probe-route.mjs'
|
||||
import {
|
||||
createBundleServer,
|
||||
installCspViolationRecorder,
|
||||
installListenerRecorder,
|
||||
installPageErrorSentinel,
|
||||
installSchedulerRecorder,
|
||||
installShellDouble,
|
||||
readBridgeFaultGrant,
|
||||
readBridgeProtocolVersion,
|
||||
readShellCsp
|
||||
} from './mobile-web-app-render-harness.mjs'
|
||||
|
||||
/**
|
||||
* The scratch bundle the terminal render check runs against, and the two ways to open a page on it.
|
||||
*
|
||||
* Its own module because the check's cases are the thing under review and the server, the browser
|
||||
* and the scratch route tree are not. Nothing here is module-scoped: the fixture holds what it
|
||||
* built in the closures it hands back, so two of them could not read each other's browser.
|
||||
*/
|
||||
|
||||
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const mobileDir = join(projectDir, 'mobile')
|
||||
|
||||
export const PROBE_ROUTE = '/h/terminal-probe'
|
||||
export const CONTROL_ROUTE = '/h/terminal-control'
|
||||
const PAGE_ROUTE_PATTERNS = [PROBE_ROUTE, CONTROL_ROUTE]
|
||||
const SHELL_SESSION_ID = 'terminal-render-session'
|
||||
const SHELL_BUILD_ID = 'terminal-render-build'
|
||||
const SHELL_HOST = {
|
||||
id: 'terminal-render-host',
|
||||
name: 'Terminal Render Host',
|
||||
endpoint: 'ws://terminal-render',
|
||||
lastConnected: 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the fixture allocated, in the reverse of the order it took it.
|
||||
*
|
||||
* Shared by the normal close and the rollback, because a setup that fell over halfway has exactly
|
||||
* the same things to give back as one that ran to the end — it just has fewer of them. The server
|
||||
* close is awaited rather than fired: it holds a listening socket, and a socket still open when
|
||||
* the file finishes keeps the vitest worker alive after its last test has reported.
|
||||
*/
|
||||
async function closeTerminalRenderFixture({ browser, scratch, server }) {
|
||||
// Each one is asked independently, because stopping at the first refusal is how the socket and
|
||||
// the scratch tree survived in the first place: a browser that will not close would take the
|
||||
// other two down with it. The first failure is what comes back, after all three have been tried.
|
||||
const failures = []
|
||||
const attempt = async (close) => {
|
||||
try {
|
||||
await close()
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
await attempt(() => browser?.close())
|
||||
await attempt(
|
||||
() =>
|
||||
server &&
|
||||
new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()))
|
||||
})
|
||||
)
|
||||
await attempt(() => rm(scratch, { recursive: true, force: true }))
|
||||
if (failures.length > 0) {
|
||||
throw failures[0]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the bundle, serves it under the shell's own policy, and launches the browser.
|
||||
*
|
||||
* Nothing survives a setup that throws. The browser is launched last and is the step most likely
|
||||
* to fail — no Chromium on the machine, an `ORCA_MOBILE_WEB_RENDER_BROWSER` that points nowhere —
|
||||
* and by then the server is listening and the scratch tree is on disk. A caller that never got a
|
||||
* handle back has nothing to close, so this closes them itself and rethrows what actually went
|
||||
* wrong rather than whatever the cleanup might say.
|
||||
*/
|
||||
export async function startTerminalRenderFixture() {
|
||||
const cspHeader = await readShellCsp()
|
||||
const bridgeVersion = await readBridgeProtocolVersion()
|
||||
const faultGrant = await readBridgeFaultGrant()
|
||||
const scratch = await mkdtemp(join(tmpdir(), 'orca-c75-terminal-render-'))
|
||||
let browser = null
|
||||
let served = null
|
||||
try {
|
||||
const appDir = join(scratch, 'app')
|
||||
const routeDir = join(appDir, MOBILE_WEB_APP_ROUTE_ROOT)
|
||||
await mkdir(routeDir, { recursive: true })
|
||||
await writeFile(join(routeDir, '_layout.tsx'), LAYOUT_SOURCE)
|
||||
// Extensionless, so the bundler resolves the `.web.tsx` sibling exactly as it would for a
|
||||
// real route. Naming the `.tsx` would mount the WebView wrapper no browser can render.
|
||||
await writeFile(
|
||||
join(routeDir, 'terminal-probe.tsx'),
|
||||
probeRouteSource(join(mobileDir, 'src', 'terminal', 'TerminalWebView'))
|
||||
)
|
||||
await writeFile(join(routeDir, 'terminal-control.tsx'), CONTROL_SOURCE)
|
||||
const built = await buildMobileWebAppBundle({
|
||||
appDir,
|
||||
outDir: join(scratch, 'bundle'),
|
||||
pageRoutes: [
|
||||
{ pathname: PROBE_ROUTE, grants: [] },
|
||||
{ pathname: CONTROL_ROUTE, grants: [] }
|
||||
]
|
||||
})
|
||||
served = await createBundleServer({ outDir: built.outDir, cspHeader })
|
||||
const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(executablePath ? { executablePath } : {})
|
||||
})
|
||||
} catch (error) {
|
||||
// Swallowed on purpose: what the caller needs is the reason the setup failed, and a cleanup
|
||||
// that also refuses would replace it with something about a socket. The rollback is
|
||||
// best-effort; the original error is the contract.
|
||||
await closeTerminalRenderFixture({ browser, scratch, server: served?.server }).catch(() => {})
|
||||
throw error
|
||||
}
|
||||
const { origin } = served
|
||||
|
||||
async function openPage(
|
||||
pathname,
|
||||
{ errorSentinel = false, listeners = false, scheduler = false, beforeNavigate } = {}
|
||||
) {
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
|
||||
await beforeNavigate?.(page)
|
||||
if (scheduler) {
|
||||
await page.addInitScript(installSchedulerRecorder)
|
||||
}
|
||||
if (listeners) {
|
||||
await page.addInitScript(installListenerRecorder)
|
||||
}
|
||||
await page.addInitScript(installCspViolationRecorder)
|
||||
if (errorSentinel) {
|
||||
await page.addInitScript(installPageErrorSentinel)
|
||||
}
|
||||
await page.addInitScript(installShellDouble, {
|
||||
version: bridgeVersion,
|
||||
sessionId: SHELL_SESSION_ID,
|
||||
buildId: SHELL_BUILD_ID,
|
||||
route: { pathname, params: {} },
|
||||
host: SHELL_HOST,
|
||||
storage: {},
|
||||
faultGrant,
|
||||
grants: [faultGrant],
|
||||
pageRoutes: PAGE_ROUTE_PATTERNS,
|
||||
replies: {}
|
||||
})
|
||||
const errors = []
|
||||
page.on('pageerror', (error) => errors.push(`${error.name}: ${error.message}`))
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
errors.push(`console.error: ${message.text()}`)
|
||||
}
|
||||
})
|
||||
await page.goto(`${origin}/`, { waitUntil: 'load' })
|
||||
await page.waitForFunction(() => document.documentElement.dataset.orcaWebEntry === 'mounted', {
|
||||
timeout: 60_000,
|
||||
polling: 250
|
||||
})
|
||||
return { errors, page }
|
||||
}
|
||||
|
||||
async function openTerminal(options) {
|
||||
const opened = await openPage(PROBE_ROUTE, options)
|
||||
await opened.page.waitForFunction(() => globalThis.__orcaTerminalReady === true, {
|
||||
timeout: 60_000,
|
||||
polling: 100
|
||||
})
|
||||
return opened
|
||||
}
|
||||
|
||||
return {
|
||||
openPage,
|
||||
openTerminal,
|
||||
close: () => closeTerminalRenderFixture({ browser, scratch, server: served.server })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The markup, then `init`, then the engine.
|
||||
*
|
||||
* xterm is opened by the document's `init`, not by the mount: the component plants the elements
|
||||
* and the modules read them, and the terminal appears on the first host command. So the order
|
||||
* here is the order a session screen uses, and each step is waited for rather than assumed —
|
||||
* `.xterm` before `init` would time out on a page that was working perfectly.
|
||||
*/
|
||||
export async function openProbeTerminal(page) {
|
||||
await page.locator('#terminal-container').waitFor({ state: 'attached', timeout: 30_000 })
|
||||
await page.evaluate(
|
||||
([cols, rows]) => globalThis.__orcaTerminalProbe.init(cols, rows, ''),
|
||||
[COLS, ROWS]
|
||||
)
|
||||
// Attached rather than visible: the replacement surface is hidden until its writes drain, and
|
||||
// the commit that reveals it is the last step of the same rAF chain `awaitReady` waits on.
|
||||
await page.locator('#terminal-surface .xterm').waitFor({ state: 'attached', timeout: 30_000 })
|
||||
await page.evaluate(() => globalThis.__orcaTerminalProbe.awaitReady())
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { mkdtemp, readdir, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
|
||||
import { startTerminalRenderFixture } from './mobile-web-app-terminal-render-fixture.mjs'
|
||||
|
||||
/**
|
||||
* What the render fixture gives back when it never finishes starting.
|
||||
*
|
||||
* The handle is the only way to close it, so a setup that throws before returning one leaves the
|
||||
* caller nothing to call: `afterAll` has no fixture, and the listening socket and the scratch tree
|
||||
* stay where they are. The socket is the part that bites — an open server handle keeps the vitest
|
||||
* worker alive after its last test has reported, so the file hangs rather than failing.
|
||||
*
|
||||
* The browser is the step that fails in practice and the last one taken, so by then everything
|
||||
* else is allocated. It is made to fail the way it actually does, by pointing the launch at an
|
||||
* executable that is not there, rather than by standing a double in front of Playwright.
|
||||
*/
|
||||
|
||||
/** The name the fixture gives its scratch tree, which is the only thing in here it owns. */
|
||||
const SCRATCH_PREFIX = 'orca-c75-terminal-render-'
|
||||
const describeFixture = mobileWebAppDependenciesPresent() ? describe : describe.skip
|
||||
|
||||
let temporaryRoot = null
|
||||
let realTemporaryRoot = null
|
||||
|
||||
beforeAll(async () => {
|
||||
// The fixture names its scratch tree after `os.tmpdir()`, and the render check next door names
|
||||
// its own the same way in a worker of its own — so reading the shared temp directory reports
|
||||
// that one appearing and being swept up mid-case, which is not this case's business. Pointing
|
||||
// `TMPDIR` at a directory of this worker's own makes the reading exact: whatever is left in
|
||||
// here afterwards was left by the setup under test.
|
||||
temporaryRoot = await mkdtemp(join(tmpdir(), 'orca-c75-fixture-rollback-'))
|
||||
realTemporaryRoot = process.env.TMPDIR
|
||||
process.env.TMPDIR = temporaryRoot
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (realTemporaryRoot === undefined) {
|
||||
delete process.env.TMPDIR
|
||||
} else {
|
||||
process.env.TMPDIR = realTemporaryRoot
|
||||
}
|
||||
await rm(temporaryRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/**
|
||||
* Listening sockets this process holds, which is the handle that keeps a worker alive.
|
||||
*
|
||||
* Spelled as Node spells it: filtering on `TCPSERVERWRAP` matches nothing and reads zero in both
|
||||
* arms, which agrees with everything. And the handle is still listed while the close callback
|
||||
* runs, so the reading is taken a tick later, once the loop has let it go.
|
||||
*/
|
||||
async function settledListeningSockets() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
return process.getActiveResourcesInfo().filter((resource) => resource === 'TCPServerWrap').length
|
||||
}
|
||||
|
||||
describeFixture('the terminal render fixture', () => {
|
||||
it('takes back the server and the scratch tree when the browser will not start', async () => {
|
||||
const socketsBefore = await settledListeningSockets()
|
||||
const realBrowser = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
|
||||
process.env.ORCA_MOBILE_WEB_RENDER_BROWSER = join(temporaryRoot, 'orca-c75-no-such-browser')
|
||||
try {
|
||||
// Named, not merely thrown, and this is also the precondition the two readings below need.
|
||||
// A bare `toThrow` passes for a build that broke for its own reason, which would leave
|
||||
// nothing serving and nothing on disk and agree with both assertions for the wrong reason.
|
||||
// Reaching the launch at all means `createBundleServer` returned, because it is the
|
||||
// statement before it.
|
||||
await expect(startTerminalRenderFixture()).rejects.toThrow(
|
||||
/Failed to launch chromium because executable doesn't exist/
|
||||
)
|
||||
} finally {
|
||||
if (realBrowser === undefined) {
|
||||
delete process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
|
||||
} else {
|
||||
process.env.ORCA_MOBILE_WEB_RENDER_BROWSER = realBrowser
|
||||
}
|
||||
}
|
||||
|
||||
expect(await settledListeningSockets()).toBe(socketsBefore)
|
||||
// The fixture's own trees, by the name it gives them. The launch that failed leaves Playwright
|
||||
// artifacts and a browser profile in here too, and those are Playwright's to clean, not the
|
||||
// rollback's.
|
||||
const left = (await readdir(temporaryRoot)).filter((entry) => entry.startsWith(SCRATCH_PREFIX))
|
||||
expect(left).toEqual([])
|
||||
}, 600_000)
|
||||
})
|
||||
@@ -0,0 +1,793 @@
|
||||
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('names the cause when the document chunk will not load', async () => {
|
||||
// The component reaches the document through a dynamic import, so the document is its own
|
||||
// chunk and the chunk can fail: offline, a hashed filename that no longer exists after a
|
||||
// deploy, a module that throws as it evaluates. That is a rejected promise and nothing
|
||||
// else — no engine ran, so no `error` notify is ever posted. Without the rejection being
|
||||
// routed it is an unhandled rejection and a blank frame until the 15 s readiness watchdog.
|
||||
//
|
||||
// The fault is the real one: the chunk is identified by what it carries and refused at the
|
||||
// wire, rather than a stub swapped in for the mount.
|
||||
// A string literal only `host-notify` carries, so the chunk is recognised by its contents
|
||||
// rather than by a filename that is a content hash or by a declaration name a minifier
|
||||
// renames. It has to be unique to the document: the route's own chunk carries the
|
||||
// component, the controller and the notification dispatcher, and refusing that one would
|
||||
// take the whole route down instead of the document.
|
||||
const documentChunkMarker = 'terminal runtime error'
|
||||
let aborted = null
|
||||
const served = []
|
||||
const { page } = await openPage(PROBE_ROUTE, {
|
||||
beforeNavigate: async (opened) => {
|
||||
await opened.route('**/*.js', async (route) => {
|
||||
const response = await route.fetch()
|
||||
const body = await response.text()
|
||||
served.push(route.request().url())
|
||||
if (aborted === null && body.includes(documentChunkMarker)) {
|
||||
aborted = route.request().url()
|
||||
await route.abort('failed')
|
||||
return
|
||||
}
|
||||
await route.fulfill({ response, body })
|
||||
})
|
||||
}
|
||||
})
|
||||
// The chunk is fetched when the component mounts, which is after the page entry is up, so
|
||||
// the refusal is waited for rather than asserted on the way past. A run where nothing
|
||||
// matched would otherwise fail below for the wrong reason.
|
||||
await expect
|
||||
.poll(() => aborted, {
|
||||
timeout: 60_000,
|
||||
message: `no served script carried the document; saw ${served.join(', ')}`
|
||||
})
|
||||
.not.toBe(null)
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
(globalThis.__orcaTerminalEngineErrors ?? []).some((entry) =>
|
||||
entry.includes('terminal document failed to load')
|
||||
),
|
||||
undefined,
|
||||
{ timeout: 60_000, polling: 100 }
|
||||
)
|
||||
// And the user-visible half: the overlay, with its Reload, rather than a blank frame.
|
||||
await page.getByText('Reload').waitFor({ timeout: 30_000 })
|
||||
await page.unrouteAll({ behavior: 'ignoreErrors' })
|
||||
await page.close()
|
||||
}, 300_000)
|
||||
|
||||
it('comes back from Reload while the chunk it is waiting on is still in flight', async () => {
|
||||
// The other end of the case above: the chunk does not fail, it is merely slow — a cold CDN
|
||||
// edge, a phone on a train. The document is reached by a dynamic import, so the mount is in
|
||||
// flight while the 15 s readiness watchdog runs out and puts the overlay on the screen, and
|
||||
// ruling 20 names that overlay's Reload as the way back. Reload is a second mount, so it is
|
||||
// refused outright unless the first mount's cleanup could give the page back while its
|
||||
// import was still unresolved — which is what the handle being synchronous is for.
|
||||
//
|
||||
// Held past the watchdog rather than mocked past it, because the window under test is the
|
||||
// one between the claim and the import resolving, and only a real pending request has it.
|
||||
const HOLD_MS = 20_000
|
||||
let held = null
|
||||
const { page } = await openPage(PROBE_ROUTE, {
|
||||
listeners: true,
|
||||
beforeNavigate: async (opened) => {
|
||||
await opened.route('**/*.js', async (route) => {
|
||||
const response = await route.fetch()
|
||||
const body = await response.text()
|
||||
if (held === null && body.includes('terminal runtime error')) {
|
||||
held = route.request().url()
|
||||
await new Promise((resolve) => setTimeout(resolve, HOLD_MS))
|
||||
}
|
||||
await route.fulfill({ response, body })
|
||||
})
|
||||
}
|
||||
})
|
||||
await expect.poll(() => held, { timeout: 60_000 }).not.toBe(null)
|
||||
// The watchdog, named: the overlay has to be the one the stall raises, not an engine error
|
||||
// from somewhere else, or Reload would be answering a different question.
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
(globalThis.__orcaTerminalEngineErrors ?? []).some((entry) =>
|
||||
entry.includes('no ready signal')
|
||||
),
|
||||
undefined,
|
||||
{ timeout: 60_000, polling: 100 }
|
||||
)
|
||||
const reload = page.getByText('Reload')
|
||||
await reload.waitFor({ timeout: 30_000 })
|
||||
expect(
|
||||
await page.evaluate(() => globalThis.__orcaTerminalReady === true),
|
||||
'the first mount was still waiting on its chunk when Reload appeared'
|
||||
).toBe(false)
|
||||
|
||||
await reload.click()
|
||||
await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, {
|
||||
timeout: 60_000,
|
||||
polling: 100
|
||||
})
|
||||
await openProbeTerminal(page)
|
||||
await assertLiveTerminal(page, 'reload-during-import')
|
||||
|
||||
// And the mount the Reload abandoned has to have come to nothing. Its chunk arrives while
|
||||
// the second mount is running on the same scope, so a build that resumed without re-reading
|
||||
// the claim would install its listeners into this page and reset the live mount's scope,
|
||||
// nulling the undo that takes the error reporter off. Read against a page that mounted once
|
||||
// and disposed once: the abandoned mount is the only difference between them, so zero
|
||||
// difference is the abandoned mount having touched nothing.
|
||||
const afterAbandoned = await listenersWithNoTerminal(page)
|
||||
const control = await openTerminal({ listeners: true })
|
||||
await openProbeTerminal(control.page)
|
||||
expect(afterAbandoned).toEqual(await listenersWithNoTerminal(control.page))
|
||||
await control.page.close()
|
||||
await page.unrouteAll({ behavior: 'ignoreErrors' })
|
||||
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('takes back the frames it is owed, not only the timers', async () => {
|
||||
// The timer case above is witnessed by a 550 ms timeout, which every module's own stop
|
||||
// cancels by the handle the scope holds. A frame is the other shape: `applyFitScale` asks
|
||||
// for one through the scope's registry and never holds its id, so `stopFitScale` can only
|
||||
// bump the token it tests itself against — the frame still runs. Nothing but
|
||||
// `cancelDocumentFrames` takes it back.
|
||||
//
|
||||
// Two things have to be pinned down for that to be readable, and the first version of this
|
||||
// case had neither.
|
||||
//
|
||||
// The witness has to be owed whenever the dispose lands. A single refit is not: the retry
|
||||
// loop commits on its first attempt whenever the grid still measures, so one resize buys
|
||||
// one frame and a dispose after it owes nothing — which agrees with an empty leak list for
|
||||
// exactly the reason under test, once in five runs. So the refit is re-armed from a frame
|
||||
// of the test's own, which leaves the document owed a frame at the end of every frame the
|
||||
// browser serves, and dispose cannot land inside one.
|
||||
//
|
||||
// And the leak has to be counted from the moment dispose returned, not from the moment the
|
||||
// host element left the DOM. React unmounts in two steps: the mutation phase detaches the
|
||||
// host, and the passive cleanup that calls `dispose` runs after it — 1 ms apart here, 20 to
|
||||
// 35 ms apart with the CPU throttled 20x, which is the CI runner this failed on. A frame
|
||||
// served in that gap runs with a detached container while the document is still live and
|
||||
// has not been asked to stop, and no registry could take it back. It went through
|
||||
// `scheduleDocumentFrame` like every other; the old oracle called it a leak because it
|
||||
// judged by the container rather than by dispose. Only what runs after the last statement
|
||||
// of `dispose` is the document keeping something it gave up.
|
||||
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)
|
||||
|
||||
await page.evaluate((chunk) => {
|
||||
const state = globalThis.__orcaScheduler
|
||||
state.disposed = null
|
||||
state.watching = true
|
||||
// `dispose` empties the host and drops its class last, after `cancelDocumentFrames`, so
|
||||
// the class going is the moment it returned. Observed on the element rather than on the
|
||||
// tree because React may have detached it already.
|
||||
const host = document.querySelector('.orca-terminal-document-host')
|
||||
const observer = new MutationObserver(() => {
|
||||
if (state.disposed !== null || host.classList.contains('orca-terminal-document-host')) {
|
||||
return
|
||||
}
|
||||
state.disposed = {
|
||||
// A cancelled frame never runs, so it is still owed here. That is the point.
|
||||
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'] })
|
||||
const pulse = () => {
|
||||
if (state.disposed !== null) {
|
||||
return
|
||||
}
|
||||
globalThis.dispatchEvent(new Event('resize'))
|
||||
requestAnimationFrame(pulse)
|
||||
}
|
||||
requestAnimationFrame(pulse)
|
||||
globalThis.setTimeout(() => globalThis.__orcaTerminalProbe.setMounted(false), 200)
|
||||
}, documentChunk)
|
||||
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(() => new Promise((resolve) => globalThis.setTimeout(resolve, 3000)))
|
||||
|
||||
const scheduler = await page.evaluate(() => globalThis.__orcaScheduler)
|
||||
expect(
|
||||
scheduler.disposed?.owed,
|
||||
'the document owed a frame at the moment dispose returned'
|
||||
).toBeGreaterThan(0)
|
||||
expect(
|
||||
scheduler.leaked
|
||||
.slice(scheduler.disposed.leakedBefore)
|
||||
.filter((entry) => entry.startsWith('frame ') && entry.includes(documentChunk))
|
||||
).toEqual([])
|
||||
await page.unrouteAll({ behavior: 'ignoreErrors' })
|
||||
await 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 document's: the overlay sits in unscaled viewport coordinates above the grid.
|
||||
overlayPosition: getComputedStyle(overlay).position
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
xtermPosition: 'relative',
|
||||
viewportOverflowY: 'hidden',
|
||||
viewportReservesScrollbar: false,
|
||||
overlayPosition: 'fixed'
|
||||
})
|
||||
|
||||
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('measures a fit through the handle and records what beforeinput reports', async () => {
|
||||
const { page } = await openTerminal()
|
||||
await openProbeTerminal(page)
|
||||
|
||||
// The handle's own round trip: a measure is a command in and a notify back, and on the page
|
||||
// both halves are direct calls rather than a bridge. Null would mean the document answered
|
||||
// nothing, or answered a grid too small to fit.
|
||||
const fit = await page.evaluate(() => globalThis.__orcaTerminalProbe.measure())
|
||||
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
|
||||
)
|
||||
@@ -0,0 +1,114 @@
|
||||
import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mobileWebAppModuleClosure } from './build-mobile-web-app-bundle.mjs'
|
||||
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
|
||||
|
||||
/**
|
||||
* The 612 KiB xterm engine string, and where the page must never meet it.
|
||||
*
|
||||
* `terminal-webview-engine.generated.ts` is one minified IIFE of xterm plus two addons, built to
|
||||
* be injected into the WebView's HTML document as text. On the page the same engine arrives as an
|
||||
* import, so the string is 610 KiB of dead weight — the largest single module in the session
|
||||
* route's closure, and unusable there besides, because the shell's CSP has `script-src 'self'`
|
||||
* and no nested frame to load a document into.
|
||||
*
|
||||
* Nothing stops it entering: the module the document's `<style>` needs exported the engine string
|
||||
* beside it, so one import of the CSS would have pulled the whole thing in. The CSS is now its own
|
||||
* generated artifact and this is the fence. The document's modules are the entry set, because they
|
||||
* are what the page imports; the plant below is what says the walk would notice.
|
||||
*/
|
||||
|
||||
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const mobileDir = join(projectDir, 'mobile')
|
||||
const documentDir = join(mobileDir, 'src', 'terminal', 'document')
|
||||
|
||||
const ENGINE_MODULE = 'src/terminal/terminal-webview-engine.generated.ts'
|
||||
const ENGINE_CSS_MODULE = 'src/terminal/terminal-webview-engine-css.generated.ts'
|
||||
|
||||
const bundles = mobileWebAppDependenciesPresent()
|
||||
const describeClosure = bundles ? describe : describe.skip
|
||||
|
||||
/**
|
||||
* Every module the document is made of, as entry points.
|
||||
*
|
||||
* The document is one script whose modules run in a pinned order and mostly reach each other by
|
||||
* side effect, so no single one of them is the root of a graph that holds the rest. Naming all of
|
||||
* them is what makes "the engine string is in none of their closures" a claim about the document
|
||||
* rather than about whichever module happened to be picked.
|
||||
*/
|
||||
async function documentEntryPoints() {
|
||||
const names = (await readdir(documentDir))
|
||||
.filter((name) => name.endsWith('.ts'))
|
||||
.filter((name) => !name.includes('.test'))
|
||||
.sort()
|
||||
expect(names.length).toBeGreaterThan(30)
|
||||
return names.map((name) => `src/terminal/document/${name.replace(/\.ts$/, '')}`)
|
||||
}
|
||||
|
||||
describeClosure(
|
||||
'the terminal engine string against the page',
|
||||
() => {
|
||||
it('is in no closure of the document modules the page imports', async () => {
|
||||
const { local } = await mobileWebAppModuleClosure(await documentEntryPoints())
|
||||
expect(local).not.toContain(ENGINE_MODULE)
|
||||
// The precondition: a walk that resolved nothing would also contain nothing.
|
||||
expect(local).toContain('src/terminal/document/document-scope.ts')
|
||||
expect(local).toContain('src/terminal/document/terminal-init.ts')
|
||||
}, 180_000)
|
||||
|
||||
it('is in no closure of the page terminal component either', async () => {
|
||||
const { local } = await mobileWebAppModuleClosure(['src/terminal/TerminalWebView'])
|
||||
expect(local).not.toContain(ENGINE_MODULE)
|
||||
// The extensionless specifier is what the bundle ships, so this is the page's component and
|
||||
// its `.web.ts` half of the HTML — naming the `.tsx` would measure the WebView no browser
|
||||
// loads. Both are asserted, because the assertion above holds vacuously for the native pair.
|
||||
expect(local).toContain('src/terminal/TerminalWebView.web.tsx')
|
||||
expect(local).toContain('src/terminal/terminal-webview-html.web.ts')
|
||||
expect(local).toContain(ENGINE_CSS_MODULE)
|
||||
expect(local).not.toContain('src/terminal/terminal-webview-html.ts')
|
||||
expect(local).not.toContain('src/terminal/document/message-bridge.ts')
|
||||
}, 180_000)
|
||||
|
||||
it('is still what the native document reads its CSS beside', async () => {
|
||||
// The shell rather than `terminal-webview-html`, which now has a `.web.ts` sibling the walk
|
||||
// would resolve instead and so measure the page's half — the opposite of the claim. The
|
||||
// shell is the module that reads both generated ones, so the cases above cannot pass by the
|
||||
// CSS having quietly gone missing.
|
||||
const { local } = await mobileWebAppModuleClosure([
|
||||
'src/terminal/terminal-webview-html/document-shell'
|
||||
])
|
||||
expect(local).toContain(ENGINE_MODULE)
|
||||
expect(local).toContain(ENGINE_CSS_MODULE)
|
||||
}, 180_000)
|
||||
|
||||
it('would be reported if a document module imported it', async () => {
|
||||
// Planted in a scratch tree rather than under src/terminal/document, so nothing else in the
|
||||
// repository ever walks the plant and no other census has to know it exists.
|
||||
const scratch = await mkdtemp(join(tmpdir(), 'orca-c75-engine-closure-'))
|
||||
try {
|
||||
const planted = join(scratch, 'src', 'terminal', 'document')
|
||||
await mkdir(planted, { recursive: true })
|
||||
await writeFile(
|
||||
join(planted, 'planted.ts'),
|
||||
"import { XTERM_ENGINE_JS } from '../terminal-webview-engine.generated'\n" +
|
||||
'export const planted = XTERM_ENGINE_JS.length\n'
|
||||
)
|
||||
await mkdir(join(scratch, 'src', 'terminal'), { recursive: true })
|
||||
await writeFile(
|
||||
join(scratch, 'src', 'terminal', 'terminal-webview-engine.generated.ts'),
|
||||
"export const XTERM_ENGINE_JS = 'planted'\n"
|
||||
)
|
||||
const { local } = await mobileWebAppModuleClosure(['./src/terminal/document/planted'], {
|
||||
absWorkingDir: scratch
|
||||
})
|
||||
expect(local).toContain(ENGINE_MODULE)
|
||||
} finally {
|
||||
await rm(scratch, { recursive: true, force: true })
|
||||
}
|
||||
}, 180_000)
|
||||
},
|
||||
600_000
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
node_modules/
|
||||
src/terminal/terminal-webview-engine.generated.ts
|
||||
src/terminal/terminal-webview-engine-css.generated.ts
|
||||
src/terminal/terminal-webview-document-script.generated.ts
|
||||
src/components/pr-sidebar/mermaid-webview-engine.generated.ts
|
||||
.expo/
|
||||
|
||||
@@ -24,6 +24,12 @@ import { importTypeScriptModule } from './import-typescript-module.mjs'
|
||||
const mobileRoot = path.resolve(import.meta.dirname, '..')
|
||||
const entry = path.join(mobileRoot, 'src', 'terminal', 'terminal-webview-html.ts')
|
||||
const enginePath = path.join(mobileRoot, 'src', 'terminal', 'terminal-webview-engine.generated.ts')
|
||||
const engineCssPath = path.join(
|
||||
mobileRoot,
|
||||
'src',
|
||||
'terminal',
|
||||
'terminal-webview-engine-css.generated.ts'
|
||||
)
|
||||
|
||||
export const TERMINAL_DOCUMENT_FIXTURE_PATH = path.join(
|
||||
mobileRoot,
|
||||
@@ -62,9 +68,10 @@ export function terminalDocumentFixture(document, engineJs, engineCss) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [{ XTERM_HTML }, { XTERM_ENGINE_JS, XTERM_ENGINE_CSS }] = await Promise.all([
|
||||
const [{ XTERM_HTML }, { XTERM_ENGINE_JS }, { XTERM_ENGINE_CSS }] = await Promise.all([
|
||||
importTypeScriptModule(entry),
|
||||
importTypeScriptModule(enginePath)
|
||||
importTypeScriptModule(enginePath),
|
||||
importTypeScriptModule(engineCssPath)
|
||||
])
|
||||
const fixture = terminalDocumentFixture(XTERM_HTML, XTERM_ENGINE_JS, XTERM_ENGINE_CSS)
|
||||
await writeFile(TERMINAL_DOCUMENT_FIXTURE_PATH, fixture)
|
||||
|
||||
@@ -3,8 +3,12 @@ import path from 'node:path'
|
||||
import * as esbuild from 'esbuild'
|
||||
import { importTypeScriptModule } from './import-typescript-module.mjs'
|
||||
import {
|
||||
TERMINAL_DOCUMENT_HOST_SEAMS_MODULE,
|
||||
TERMINAL_DOCUMENT_MODULE_ORDER,
|
||||
TERMINAL_DOCUMENT_SCOPE_MODULE
|
||||
TERMINAL_DOCUMENT_RESET_CALL,
|
||||
TERMINAL_DOCUMENT_SCOPE_MODULE,
|
||||
terminalDocumentStartFunctionName,
|
||||
terminalDocumentStopFunctionName
|
||||
} from './terminal-document-module-order.mjs'
|
||||
|
||||
/**
|
||||
@@ -158,19 +162,60 @@ export const TERMINAL_DOCUMENT_SCRIPT_PATH = path.join(
|
||||
'terminal-webview-document-script.generated.ts'
|
||||
)
|
||||
|
||||
/**
|
||||
* The start functions the emitted document calls, in module order (ruling 20).
|
||||
*
|
||||
* Presence is read from the source rather than listed here: a module that has no top-level effect
|
||||
* exports no start function, and one that grows an effect is reached the moment it does. The
|
||||
* declaration is matched on its own line because that is how esbuild's TypeScript prints it and
|
||||
* how every module in this directory writes it.
|
||||
*/
|
||||
export async function terminalDocumentStartCalls(moduleNames) {
|
||||
return await declaredFunctions(moduleNames, terminalDocumentStartFunctionName)
|
||||
}
|
||||
|
||||
/** The stop functions, in module order. The page runs them in reverse; the WebView never stops. */
|
||||
export async function terminalDocumentStopCalls(moduleNames) {
|
||||
return await declaredFunctions(moduleNames, terminalDocumentStopFunctionName)
|
||||
}
|
||||
|
||||
async function declaredFunctions(moduleNames, nameFor) {
|
||||
const found = []
|
||||
for (const name of moduleNames) {
|
||||
const source = await readFile(path.join(documentDirectory, `${name}.ts`), 'utf8')
|
||||
const declared = nameFor(name)
|
||||
if (new RegExp(`^export function ${declared}\\(\\) \\{$`, 'm').test(source)) {
|
||||
found.push(declared)
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/**
|
||||
* The document's whole script: every module in the order the document had, inside the one function
|
||||
* scope it has always been.
|
||||
* scope it has always been, and then the one call sequence that starts them.
|
||||
*/
|
||||
export async function buildTerminalDocumentScript() {
|
||||
const emitted = []
|
||||
// The scope object goes first: every module below reads it, and the document is one function
|
||||
// scope, so it has to exist before any of them run. It is the only part of the emitted script
|
||||
// the hand-written document did not have.
|
||||
for (const name of [TERMINAL_DOCUMENT_SCOPE_MODULE, ...TERMINAL_DOCUMENT_MODULE_ORDER]) {
|
||||
// the hand-written document did not have, and the host seams come ahead of it because its
|
||||
// defaults are those six functions.
|
||||
const order = [
|
||||
TERMINAL_DOCUMENT_HOST_SEAMS_MODULE,
|
||||
TERMINAL_DOCUMENT_SCOPE_MODULE,
|
||||
...TERMINAL_DOCUMENT_MODULE_ORDER
|
||||
]
|
||||
for (const name of order) {
|
||||
emitted.push(await emitTerminalDocumentModule(path.join(documentDirectory, `${name}.ts`)))
|
||||
}
|
||||
return `(function() {\n${emitted.join('\n')}\n})();`
|
||||
// Rulings 20 and 21: the modules above only declare. The scope's reset comes first, so the
|
||||
// state every module reads is the state a fresh parse has; then every element read, listener
|
||||
// and reporter install runs, once here and per mount on the page, in the order both hosts share.
|
||||
const calls = [TERMINAL_DOCUMENT_RESET_CALL, ...(await terminalDocumentStartCalls(order))].map(
|
||||
(name) => `${INDENT}${name}();`
|
||||
)
|
||||
return `(function() {\n${emitted.join('\n')}\n${calls.join('\n')}\n})();`
|
||||
}
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -7,6 +7,19 @@ const require = createRequire(import.meta.url)
|
||||
const scriptDir = import.meta.dirname
|
||||
const mobileRoot = path.resolve(scriptDir, '..')
|
||||
const outputPath = path.join(mobileRoot, 'src', 'terminal', 'terminal-webview-engine.generated.ts')
|
||||
/**
|
||||
* The stylesheet is written beside the engine rather than inside it because the two have different
|
||||
* consumers. The WebView document needs both, as text; the web page needs the CSS and must never
|
||||
* resolve the 612 KiB engine string, which is unusable under the shell's `script-src 'self'` and
|
||||
* is the largest module the session route's closure would otherwise carry. One file each is what
|
||||
* lets the page import one without reaching the other.
|
||||
*/
|
||||
const cssOutputPath = path.join(
|
||||
mobileRoot,
|
||||
'src',
|
||||
'terminal',
|
||||
'terminal-webview-engine-css.generated.ts'
|
||||
)
|
||||
const target = 'chrome74'
|
||||
|
||||
const packages = ['@xterm/xterm', '@xterm/addon-unicode11', '@xterm/addon-webgl']
|
||||
@@ -93,16 +106,29 @@ async function main() {
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/http:\/\/www\.w3\.org\/2000\/svg/g, 'http%3A//www.w3.org/2000/svg')
|
||||
|
||||
const source = [
|
||||
const header = [
|
||||
'// Generated by scripts/build-terminal-webview-engine.mjs.',
|
||||
`// Packages: ${versions.join(', ')}.`,
|
||||
`// Target: ${target}. Do not edit by hand; regenerate via pnpm postinstall.`,
|
||||
`export const XTERM_ENGINE_JS = ${JSON.stringify(htmlText(engineJs, 'script'))}`,
|
||||
`export const XTERM_ENGINE_CSS = ${JSON.stringify(htmlText(engineCss, 'style'))}`,
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
await writeFile(outputPath, source)
|
||||
`// Target: ${target}. Do not edit by hand; regenerate via pnpm postinstall.`
|
||||
]
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
outputPath,
|
||||
[
|
||||
...header,
|
||||
`export const XTERM_ENGINE_JS = ${JSON.stringify(htmlText(engineJs, 'script'))}`,
|
||||
''
|
||||
].join('\n')
|
||||
),
|
||||
writeFile(
|
||||
cssOutputPath,
|
||||
[
|
||||
...header,
|
||||
`export const XTERM_ENGINE_CSS = ${JSON.stringify(htmlText(engineCss, 'style'))}`,
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
await main()
|
||||
|
||||
@@ -5,12 +5,17 @@
|
||||
*
|
||||
* Both the generator and the equivalence test read this, so neither can drift from the other.
|
||||
*/
|
||||
/**
|
||||
* The host seams, emitted ahead of the scope object: the scope's defaults *are* these functions,
|
||||
* and the factory that reads them runs as the script is parsed.
|
||||
*/
|
||||
export const TERMINAL_DOCUMENT_HOST_SEAMS_MODULE = 'document-host-seams'
|
||||
|
||||
/** The scope object, emitted ahead of everything else because everything else reads it. */
|
||||
export const TERMINAL_DOCUMENT_SCOPE_MODULE = 'document-scope'
|
||||
|
||||
export const TERMINAL_DOCUMENT_MODULE_ORDER = [
|
||||
'runtime-constants',
|
||||
'terminal-handle',
|
||||
'query-reply',
|
||||
'surface-swap',
|
||||
'text-scaling',
|
||||
@@ -46,3 +51,34 @@ export const TERMINAL_DOCUMENT_MODULE_ORDER = [
|
||||
'surface-touch-gestures',
|
||||
'message-bridge'
|
||||
]
|
||||
|
||||
/**
|
||||
* The per-module start function's name, by convention rather than by a second list.
|
||||
*
|
||||
* Ruling 20: no module does work as it is parsed, so each one that had a top-level effect now
|
||||
* exports one function holding it. The generator calls the ones that exist, in module order, at
|
||||
* the foot of the document; the page calls the same names per mount. A convention rather than a
|
||||
* list because a list is a second place to forget.
|
||||
*/
|
||||
export function terminalDocumentStartFunctionName(moduleName) {
|
||||
return (
|
||||
'start' +
|
||||
moduleName
|
||||
.split('-')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join('')
|
||||
)
|
||||
}
|
||||
|
||||
/** The per-module stop function's name, by the same convention (ruling 21). */
|
||||
export function terminalDocumentStopFunctionName(moduleName) {
|
||||
return terminalDocumentStartFunctionName(moduleName).replace(/^start/, 'stop')
|
||||
}
|
||||
|
||||
/**
|
||||
* The scope's reset, called ahead of every start (ruling 21).
|
||||
*
|
||||
* Module top level holds no mutable state, so a second mount's state comes from here and nowhere
|
||||
* else. The WebView runs it once at parse, where it restores what the factory just built.
|
||||
*/
|
||||
export const TERMINAL_DOCUMENT_RESET_CALL = 'resetTerminalDocumentScope'
|
||||
|
||||
@@ -1,396 +1,95 @@
|
||||
import { useRef, useCallback, forwardRef, useImperativeHandle, useEffect, useMemo } from 'react'
|
||||
import { useRef, useCallback, forwardRef, useImperativeHandle } from 'react'
|
||||
import { Platform, View } from 'react-native'
|
||||
import { WebView, type WebViewMessageEvent } from 'react-native-webview'
|
||||
import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges'
|
||||
import type { TerminalWebViewHandle, TerminalWebViewProps } from './terminal-webview-contract'
|
||||
import {
|
||||
TerminalWebViewEngineErrorOverlay,
|
||||
useTerminalWebViewEngineErrorState
|
||||
} from './terminal-webview-engine-error-state'
|
||||
import { TerminalWebViewEngineErrorOverlay } from './terminal-webview-engine-error-state'
|
||||
import { TERMINAL_WEBVIEW_FRAME_STYLES } from './terminal-webview-frame-styles'
|
||||
import { useTerminalWebReadyWatchdog } from './terminal-webview-ready-watchdog'
|
||||
import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html'
|
||||
import type { TerminalWebViewCommand } from './terminal-webview-messages'
|
||||
import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages'
|
||||
import { dispatchTerminalWebViewNotification } from './terminal-webview-notification-dispatch'
|
||||
import { routeTerminalQueryReply } from './terminal-webview-query-reply-routing'
|
||||
import { createTerminalWriteCoalescer } from './terminal-write-coalescer'
|
||||
import { useTerminalWebViewController } from './use-terminal-webview-controller'
|
||||
|
||||
type Props = TerminalWebViewProps
|
||||
|
||||
export type { TerminalWebViewHandle } from './terminal-webview-contract'
|
||||
|
||||
export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function TerminalWebView(
|
||||
{
|
||||
style,
|
||||
terminalTheme,
|
||||
textScale = 1,
|
||||
onWebReady,
|
||||
onEngineError,
|
||||
onSelectionMode,
|
||||
onSelectionCopy,
|
||||
onSelectionEvicted,
|
||||
onModesChanged,
|
||||
onKeyboardAvoidanceMetrics,
|
||||
onHaptic,
|
||||
onTerminalInput,
|
||||
onTerminalQueryReply,
|
||||
onTerminalTap,
|
||||
onFileTap,
|
||||
onOpenUrl,
|
||||
onTextScaleChange
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const webViewRef = useRef<WebView>(null)
|
||||
const isWebReadyRef = useRef(false)
|
||||
const pendingMessages = useMemo(() => createTerminalWebViewPendingMessages(), [])
|
||||
const messageIdRef = useRef(0)
|
||||
const pendingPingIdRef = useRef<number | null>(null)
|
||||
const terminalThemeKey = useMemo(() => JSON.stringify(terminalTheme ?? null), [terminalTheme])
|
||||
const measureResolveRef = useRef<
|
||||
((result: { cols: number; rows: number } | null) => void) | null
|
||||
>(null)
|
||||
// Why: each init() call posts 'init' to the WebView and arms a fresh
|
||||
// ready promise. WebView's init() rAF chain ends with a 'ready' notify
|
||||
// that resolves it. measureFitDimensions awaits this so it doesn't
|
||||
// race ahead of term.open() / renderService population.
|
||||
const readyPromiseRef = useRef<Promise<void> | null>(null)
|
||||
const readyResolveRef = useRef<(() => void) | null>(null)
|
||||
const { clearEngineError, engineError, reportEngineError, reportNativeEngineError } =
|
||||
useTerminalWebViewEngineErrorState(onEngineError)
|
||||
const { armWebReadyWatchdog, clearWebReadyWatchdog } = useTerminalWebReadyWatchdog(
|
||||
isWebReadyRef,
|
||||
reportEngineError
|
||||
)
|
||||
export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(
|
||||
function TerminalWebView(props, ref) {
|
||||
const webViewRef = useRef<WebView>(null)
|
||||
|
||||
const sendToWebView = useCallback((msg: TerminalWebViewCommand) => {
|
||||
messageIdRef.current += 1
|
||||
const id = messageIdRef.current
|
||||
webViewRef.current?.postMessage(JSON.stringify({ ...msg, id }))
|
||||
return id
|
||||
}, [])
|
||||
const post = useCallback((command: TerminalWebViewCommand & { id: number }) => {
|
||||
webViewRef.current?.postMessage(JSON.stringify(command))
|
||||
}, [])
|
||||
|
||||
const flushPendingMessages = useCallback(() => {
|
||||
pendingMessages.flush(sendToWebView)
|
||||
}, [pendingMessages, sendToWebView])
|
||||
|
||||
const postMessage = useCallback(
|
||||
(msg: TerminalWebViewCommand) => {
|
||||
if (!isWebReadyRef.current) {
|
||||
pendingMessages.queue(msg)
|
||||
return
|
||||
}
|
||||
sendToWebView(msg)
|
||||
},
|
||||
[pendingMessages, sendToWebView]
|
||||
)
|
||||
|
||||
// Why: a busy PTY delivers ~200 stream frames/s; coalescing here collapses the
|
||||
// per-frame bridge + WebKit IPC + paint cost that runs the phone hot (#9302).
|
||||
const writeCoalescer = useMemo(
|
||||
() => createTerminalWriteCoalescer((data) => postMessage({ type: 'write', data })),
|
||||
[postMessage]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
writeCoalescer.clear()
|
||||
}
|
||||
}, [writeCoalescer])
|
||||
|
||||
const confirmWebReady = useCallback(
|
||||
(notifyParent: boolean) => {
|
||||
pendingPingIdRef.current = null
|
||||
isWebReadyRef.current = true
|
||||
clearWebReadyWatchdog()
|
||||
clearEngineError()
|
||||
if (notifyParent) {
|
||||
onWebReady?.()
|
||||
}
|
||||
// Why: reload clears queued commands, so readiness must always restore the
|
||||
// native-selected theme even when its value did not change in React.
|
||||
sendToWebView({ type: 'set-theme', terminalTheme })
|
||||
flushPendingMessages()
|
||||
},
|
||||
[
|
||||
const {
|
||||
clearEngineError,
|
||||
clearWebReadyWatchdog,
|
||||
flushPendingMessages,
|
||||
onWebReady,
|
||||
sendToWebView,
|
||||
terminalTheme
|
||||
]
|
||||
)
|
||||
engineError,
|
||||
handle,
|
||||
receive,
|
||||
reportNativeEngineError,
|
||||
resetReadiness
|
||||
} = useTerminalWebViewController(props, {
|
||||
post,
|
||||
// iOS can preserve the native view while discarding its JS/backing-store state.
|
||||
pingsOnForegroundRecovery: () => Platform.OS === 'ios'
|
||||
})
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(event: WebViewMessageEvent) => {
|
||||
let msg: Record<string, unknown>
|
||||
try {
|
||||
msg = JSON.parse(event.nativeEvent.data) as Record<string, unknown>
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
routeTerminalQueryReply(msg, onTerminalQueryReply)
|
||||
useImperativeHandle(ref, () => handle, [handle])
|
||||
|
||||
if (msg.type === 'web-ready') {
|
||||
confirmWebReady(true)
|
||||
} else if (
|
||||
msg.type === 'pong' &&
|
||||
typeof msg.pingId === 'number' &&
|
||||
msg.pingId === pendingPingIdRef.current
|
||||
) {
|
||||
confirmWebReady(false)
|
||||
} else if (msg.type === 'ready') {
|
||||
// Why: the WebView's init() rAF chain has run — term is open,
|
||||
// renderService is populated, first paint has happened. Resolve
|
||||
// any pending awaitReady() so a queued measure can now safely
|
||||
// read cell dims.
|
||||
const resolve = readyResolveRef.current
|
||||
readyResolveRef.current = null
|
||||
readyPromiseRef.current = null
|
||||
resolve?.()
|
||||
} else if (msg.type === 'measure-result') {
|
||||
const resolve = measureResolveRef.current
|
||||
measureResolveRef.current = null
|
||||
if (resolve) {
|
||||
const cols = typeof msg.cols === 'number' ? msg.cols : null
|
||||
const rows = typeof msg.rows === 'number' ? msg.rows : null
|
||||
resolve(cols && rows && cols >= 20 && rows >= 8 ? { cols, rows } : null)
|
||||
}
|
||||
} else {
|
||||
dispatchTerminalWebViewNotification(msg, {
|
||||
reportEngineError,
|
||||
onSelectionMode,
|
||||
onSelectionCopy,
|
||||
onSelectionEvicted,
|
||||
onModesChanged,
|
||||
onKeyboardAvoidanceMetrics,
|
||||
onHaptic,
|
||||
onTerminalInput,
|
||||
onTerminalTap,
|
||||
onFileTap,
|
||||
onOpenUrl,
|
||||
onTextScaleChange
|
||||
})
|
||||
}
|
||||
},
|
||||
[
|
||||
confirmWebReady,
|
||||
reportEngineError,
|
||||
onSelectionMode,
|
||||
onSelectionCopy,
|
||||
onSelectionEvicted,
|
||||
onModesChanged,
|
||||
onKeyboardAvoidanceMetrics,
|
||||
onHaptic,
|
||||
onTerminalInput,
|
||||
onTerminalQueryReply,
|
||||
onTerminalTap,
|
||||
onFileTap,
|
||||
onOpenUrl,
|
||||
onTextScaleChange
|
||||
]
|
||||
)
|
||||
|
||||
const handleLoadStart = useCallback(() => {
|
||||
isWebReadyRef.current = false
|
||||
pendingPingIdRef.current = null
|
||||
armWebReadyWatchdog()
|
||||
// Why: messages queued for a previous WebView generation are stale after a reload;
|
||||
// dropping them avoids replaying terminal chunks before the next init snapshot.
|
||||
pendingMessages.clear()
|
||||
writeCoalescer.clear()
|
||||
}, [armWebReadyWatchdog, pendingMessages, writeCoalescer])
|
||||
|
||||
const handleReload = useCallback(() => {
|
||||
clearEngineError()
|
||||
webViewRef.current?.reload()
|
||||
}, [clearEngineError])
|
||||
|
||||
const handleContentProcessDidTerminate = useCallback(() => {
|
||||
// Why: WKWebView content-process loss is recoverable; stale commands belong
|
||||
// to the dead document and the replacement must prove readiness before replay.
|
||||
isWebReadyRef.current = false
|
||||
pendingPingIdRef.current = null
|
||||
pendingMessages.clear()
|
||||
writeCoalescer.clear()
|
||||
clearEngineError()
|
||||
armWebReadyWatchdog()
|
||||
webViewRef.current?.reload()
|
||||
}, [armWebReadyWatchdog, clearEngineError, pendingMessages, writeCoalescer])
|
||||
|
||||
useEffect(() => {
|
||||
postMessage({ type: 'set-theme', terminalTheme })
|
||||
}, [postMessage, terminalThemeKey, terminalTheme])
|
||||
|
||||
// Why: live-apply text-size changes to an already-mounted terminal (the pane
|
||||
// stays alive while the user visits Settings), so no terminal reload is needed.
|
||||
useEffect(() => {
|
||||
postMessage({ type: 'set-font-scale', fontScale: textScale })
|
||||
}, [postMessage, textScale])
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
prepareForForegroundRecovery() {
|
||||
if (Platform.OS !== 'ios') {
|
||||
const handleMessage = useCallback(
|
||||
(event: WebViewMessageEvent) => {
|
||||
let msg: Record<string, unknown>
|
||||
try {
|
||||
msg = JSON.parse(event.nativeEvent.data) as Record<string, unknown>
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
// Why: direct ping is the only command allowed through while readiness is
|
||||
// invalid; init/write commands queue until this exact document answers.
|
||||
isWebReadyRef.current = false
|
||||
armWebReadyWatchdog()
|
||||
pendingPingIdRef.current = sendToWebView({ type: 'ping' })
|
||||
receive(msg)
|
||||
},
|
||||
write(data: string) {
|
||||
writeCoalescer.write(data)
|
||||
},
|
||||
init(
|
||||
cols: number,
|
||||
rows: number,
|
||||
initialData?: string,
|
||||
preserveScroll?: boolean,
|
||||
oscLinks?: TerminalOscLinkRange[]
|
||||
) {
|
||||
// Why: arm a fresh ready promise BEFORE posting init. The WebView
|
||||
// resolves it via the 'ready' notify at the end of its rAF chain.
|
||||
// Resolve any prior in-flight ready first so awaiters from the
|
||||
// previous generation don't sit on the 3s setTimeout fallback —
|
||||
// each leaked timer + closure pinned an awaiting measure caller
|
||||
// for the full 3s under rapid re-init (orientation change,
|
||||
// multiple resubscribes), delaying cold-start fit chains.
|
||||
const priorResolve = readyResolveRef.current
|
||||
if (priorResolve) {
|
||||
readyResolveRef.current = null
|
||||
readyPromiseRef.current = null
|
||||
priorResolve()
|
||||
}
|
||||
readyPromiseRef.current = new Promise<void>((resolve) => {
|
||||
readyResolveRef.current = resolve
|
||||
})
|
||||
// Why: pending chunks are pre-snapshot data; the init snapshot supersedes
|
||||
// them, and writing them after init would corrupt the fresh buffer.
|
||||
writeCoalescer.clear()
|
||||
postMessage({
|
||||
type: 'init',
|
||||
cols,
|
||||
rows,
|
||||
initialData,
|
||||
oscLinks,
|
||||
terminalTheme,
|
||||
fontScale: textScale,
|
||||
preserveScroll
|
||||
})
|
||||
},
|
||||
resize(cols: number, rows: number) {
|
||||
// Why: resize/reflow must observe all prior writes or bytes reorder.
|
||||
writeCoalescer.flushNow()
|
||||
postMessage({ type: 'resize', cols, rows })
|
||||
},
|
||||
reflow(cols: number, rows: number) {
|
||||
writeCoalescer.flushNow()
|
||||
postMessage({ type: 'reflow', cols, rows })
|
||||
},
|
||||
clear() {
|
||||
writeCoalescer.clear()
|
||||
postMessage({ type: 'clear' })
|
||||
},
|
||||
measureFitDimensions(
|
||||
containerHeight?: number
|
||||
): Promise<{ cols: number; rows: number } | null> {
|
||||
if (!isWebReadyRef.current) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
measureResolveRef.current?.(null)
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
const finish = (result: { cols: number; rows: number } | null) => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
if (measureResolveRef.current === finish) {
|
||||
measureResolveRef.current = null
|
||||
}
|
||||
resolve(result)
|
||||
[receive]
|
||||
)
|
||||
|
||||
const handleReload = useCallback(() => {
|
||||
clearEngineError()
|
||||
webViewRef.current?.reload()
|
||||
}, [clearEngineError])
|
||||
|
||||
const handleContentProcessDidTerminate = useCallback(() => {
|
||||
// Why: WKWebView content-process loss is recoverable; stale commands belong
|
||||
// to the dead document and the replacement must prove readiness before replay.
|
||||
resetReadiness()
|
||||
clearEngineError()
|
||||
webViewRef.current?.reload()
|
||||
}, [clearEngineError, resetReadiness])
|
||||
|
||||
return (
|
||||
<View style={[TERMINAL_WEBVIEW_FRAME_STYLES.container, props.style]}>
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
source={XTERM_WEBVIEW_SOURCE}
|
||||
style={TERMINAL_WEBVIEW_FRAME_STYLES.webview}
|
||||
originWhitelist={['*']}
|
||||
javaScriptEnabled
|
||||
scrollEnabled={false}
|
||||
// Why: Android parent gesture containers can intercept vertical drags
|
||||
// before the injected xterm scroll router sees them.
|
||||
nestedScrollEnabled
|
||||
scalesPageToFit={false}
|
||||
// Why: Android WebView defaults textZoom to the system font scale, inflating
|
||||
// xterm's DOM glyphs past its canvas-measured cell grid (#4579). iOS ignores it.
|
||||
textZoom={100}
|
||||
onLoadStart={resetReadiness}
|
||||
onMessage={handleMessage}
|
||||
onError={(event) => reportNativeEngineError('Terminal WebView load failed', event)}
|
||||
onHttpError={(event) => reportNativeEngineError('Terminal WebView HTTP error', event)}
|
||||
onRenderProcessGone={(event) =>
|
||||
reportNativeEngineError('Terminal WebView render process ended', event)
|
||||
}
|
||||
measureResolveRef.current = finish
|
||||
sendToWebView({ type: 'measure', containerHeight })
|
||||
// Why: if the WebView doesn't respond within 2s (e.g., xterm
|
||||
// failed to load), resolve null so the caller can disable
|
||||
// Fit to Phone rather than hanging indefinitely.
|
||||
timeout = setTimeout(() => {
|
||||
if (measureResolveRef.current === finish) {
|
||||
finish(null)
|
||||
}
|
||||
}, 2000)
|
||||
})
|
||||
},
|
||||
resetZoom() {
|
||||
postMessage({ type: 'reset-zoom' })
|
||||
},
|
||||
cancelSelect() {
|
||||
postMessage({ type: 'cancel-select' })
|
||||
},
|
||||
doSelectAll() {
|
||||
postMessage({ type: 'do-select-all' })
|
||||
},
|
||||
async awaitReady(): Promise<void> {
|
||||
// Why: returns the in-flight ready promise (set by init); resolves
|
||||
// immediately if no init is pending. Capped at 3s so a stuck
|
||||
// WebView doesn't hang the caller.
|
||||
const p = readyPromiseRef.current
|
||||
if (!p) {
|
||||
return
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false
|
||||
const timeout = setTimeout(() => {
|
||||
settled = true
|
||||
resolve()
|
||||
}, 3000)
|
||||
void p.finally(() => {
|
||||
if (!settled) {
|
||||
clearTimeout(timeout)
|
||||
settled = true
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}),
|
||||
[armWebReadyWatchdog, postMessage, sendToWebView, terminalTheme, textScale, writeCoalescer]
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[TERMINAL_WEBVIEW_FRAME_STYLES.container, style]}>
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
source={XTERM_WEBVIEW_SOURCE}
|
||||
style={TERMINAL_WEBVIEW_FRAME_STYLES.webview}
|
||||
originWhitelist={['*']}
|
||||
javaScriptEnabled
|
||||
scrollEnabled={false}
|
||||
// Why: Android parent gesture containers can intercept vertical drags
|
||||
// before the injected xterm scroll router sees them.
|
||||
nestedScrollEnabled
|
||||
scalesPageToFit={false}
|
||||
// Why: Android WebView defaults textZoom to the system font scale, inflating
|
||||
// xterm's DOM glyphs past its canvas-measured cell grid (#4579). iOS ignores it.
|
||||
textZoom={100}
|
||||
onLoadStart={handleLoadStart}
|
||||
onMessage={handleMessage}
|
||||
onError={(event) => reportNativeEngineError('Terminal WebView load failed', event)}
|
||||
onHttpError={(event) => reportNativeEngineError('Terminal WebView HTTP error', event)}
|
||||
onRenderProcessGone={(event) =>
|
||||
reportNativeEngineError('Terminal WebView render process ended', event)
|
||||
}
|
||||
onContentProcessDidTerminate={handleContentProcessDidTerminate}
|
||||
/>
|
||||
{engineError ? (
|
||||
<TerminalWebViewEngineErrorOverlay message={engineError} onReload={handleReload} />
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
onContentProcessDidTerminate={handleContentProcessDidTerminate}
|
||||
/>
|
||||
{engineError ? (
|
||||
<TerminalWebViewEngineErrorOverlay message={engineError} onReload={handleReload} />
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
|
||||
import { View } from 'react-native'
|
||||
import type { TerminalWebViewHandle, TerminalWebViewProps } from './terminal-webview-contract'
|
||||
import { TerminalWebViewEngineErrorOverlay } from './terminal-webview-engine-error-state'
|
||||
import { TERMINAL_WEBVIEW_FRAME_STYLES } from './terminal-webview-frame-styles'
|
||||
import type { TerminalWebViewCommand } from './terminal-webview-messages'
|
||||
import { mountTerminalWebDocument, type TerminalWebDocument } from './terminal-web-document-mount'
|
||||
import { useTerminalWebViewController } from './use-terminal-webview-controller'
|
||||
|
||||
type Props = TerminalWebViewProps
|
||||
|
||||
export type { TerminalWebViewHandle } from './terminal-webview-contract'
|
||||
|
||||
/**
|
||||
* The same terminal, with the WebView taken out.
|
||||
*
|
||||
* `react-native-webview` has no web build that renders anything: on the page it paints the line
|
||||
* "React Native WebView does not support this platform" where the terminal was. So the page mounts
|
||||
* the document itself — xterm as an import, the document's own modules as modules — and keeps the
|
||||
* contract above it exactly as it was. `TerminalPaneView` and the subscription foundation hold
|
||||
* `TerminalWebViewProps` and `TerminalWebViewHandle` and cannot tell which of the two they have.
|
||||
*
|
||||
* Both halves of the transport are the same objects the native component uses: the commands are
|
||||
* `TerminalWebViewCommand`, handed to the document's own `handleMsg` instead of across a bridge,
|
||||
* and every notify goes back through the controller's `receive`, which is the same dispatch.
|
||||
*/
|
||||
export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(
|
||||
function TerminalWebView(props, ref) {
|
||||
const hostRef = useRef<View>(null)
|
||||
const documentRef = useRef<TerminalWebDocument | null>(null)
|
||||
// The document is mounted in an effect and commands can be handled before it answers, so the
|
||||
// controller's queue is not enough on its own: a `set-theme` posted on the first render would
|
||||
// otherwise be dropped rather than queued. Held here and replayed when the mount resolves.
|
||||
const beforeMountRef = useRef<(TerminalWebViewCommand & { id: number })[]>([])
|
||||
const receiveRef = useRef<((message: Record<string, unknown>) => void) | null>(null)
|
||||
|
||||
const post = useCallback((command: TerminalWebViewCommand & { id: number }) => {
|
||||
const mounted = documentRef.current
|
||||
if (mounted) {
|
||||
mounted.send(command)
|
||||
return
|
||||
}
|
||||
beforeMountRef.current.push(command)
|
||||
}, [])
|
||||
|
||||
const controller = useTerminalWebViewController(props, {
|
||||
post,
|
||||
// No second content process to lose: the document is this page's own modules, and if they
|
||||
// were gone so was the component holding this handle.
|
||||
pingsOnForegroundRecovery: () => false
|
||||
})
|
||||
const { clearEngineError, confirmWebReady, engineError, handle, receive, resetReadiness } =
|
||||
controller
|
||||
// The page's answer to the WebView's reload: drop the document and build another one. The host
|
||||
// element is keyed on it so React replaces the div rather than handing back one xterm left in.
|
||||
const [generation, setGeneration] = useState(0)
|
||||
|
||||
useImperativeHandle(ref, () => handle, [handle])
|
||||
// In an effect, not during render: React may replay or discard render work, and the document
|
||||
// reads this ref from a callback that outlives the render that mounted it. The mount effect
|
||||
// below is declared after this one, so the first read already sees a sink.
|
||||
useEffect(() => {
|
||||
receiveRef.current = receive
|
||||
}, [receive])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: react-native-web renders View as a div and forwards the ref to it; this module only ever runs in that build.
|
||||
const host = hostRef.current as unknown as HTMLElement | null
|
||||
if (!host) {
|
||||
return
|
||||
}
|
||||
// The handle comes back before the document does, which is what makes the cleanup below
|
||||
// able to answer for a mount whose import is still in flight. Without it a slow chunk left
|
||||
// the page claimed by a mount that had already been torn down, and Reload — the way out the
|
||||
// overlay offers — was refused as a second document.
|
||||
const reportMountFailure = (error: unknown) => {
|
||||
// The document is reached by a dynamic import, so its chunk can fail to load — offline, a
|
||||
// stale hashed filename after a deploy, an evaluation error in a module body. No engine
|
||||
// ever ran, so no `error` notify is coming. It goes down the document's own reporting
|
||||
// path, which names the cause in the overlay instead of leaving the readiness watchdog to
|
||||
// say "no ready after 15s".
|
||||
receiveRef.current?.({
|
||||
type: 'error',
|
||||
fatal: true,
|
||||
message: `terminal document failed to load - ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
})
|
||||
}
|
||||
let mounted
|
||||
try {
|
||||
mounted = mountTerminalWebDocument(host, (message) => receiveRef.current?.(message))
|
||||
} catch (error) {
|
||||
// The mount refuses synchronously when the page is already taken, and the refusal is the
|
||||
// overlay's to show rather than the tree's to crash on.
|
||||
reportMountFailure(error)
|
||||
return
|
||||
}
|
||||
void mounted.ready.then(
|
||||
() => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
documentRef.current = mounted
|
||||
for (const command of beforeMountRef.current) {
|
||||
mounted.send(command)
|
||||
}
|
||||
beforeMountRef.current = []
|
||||
// The WebView's document posts this as its last parsed statement, once it has seen the
|
||||
// engine. Here the engine is an import that already resolved, so the mount is the moment.
|
||||
confirmWebReady(true)
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
reportMountFailure(error)
|
||||
}
|
||||
)
|
||||
const live = mounted
|
||||
return () => {
|
||||
cancelled = true
|
||||
documentRef.current = null
|
||||
live.dispose()
|
||||
}
|
||||
// Mounted once per generation: re-running this would throw away a live terminal and its
|
||||
// scrollback, and the controller's identity changes with every callback prop.
|
||||
// `confirmWebReady` is read on the mount path only, which is why it is not a dependency.
|
||||
}, [generation])
|
||||
|
||||
const handleReload = useCallback(() => {
|
||||
clearEngineError()
|
||||
resetReadiness()
|
||||
beforeMountRef.current = []
|
||||
setGeneration((previous) => previous + 1)
|
||||
}, [clearEngineError, resetReadiness])
|
||||
|
||||
return (
|
||||
<View style={[TERMINAL_WEBVIEW_FRAME_STYLES.container, props.style]}>
|
||||
<View key={generation} ref={hostRef} style={TERMINAL_WEBVIEW_FRAME_STYLES.webview} />
|
||||
{engineError ? (
|
||||
<TerminalWebViewEngineErrorOverlay message={engineError} onReload={handleReload} />
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
cancelDocumentFrames,
|
||||
resetTerminalDocumentScope,
|
||||
scheduleDocumentFrame,
|
||||
scope
|
||||
} from './document-scope'
|
||||
|
||||
/**
|
||||
* The frames the document is owed, and the two things `cancelDocumentFrames` has to do.
|
||||
*
|
||||
* Taking back the pending ones is the obvious half. The other half is refusing new ones: tearing
|
||||
* the terminal down runs the engine's own disposal, which calls back into these modules, and a
|
||||
* frame asked for on the way out would be owed by nobody because the cancel has already run. A
|
||||
* generation guard cannot help there — it makes a stale frame do nothing, but the frame still
|
||||
* runs, and on the page the mount it belonged to may be gone and the next one already up.
|
||||
*/
|
||||
describe('the document frame registry', () => {
|
||||
afterEach(() => {
|
||||
resetTerminalDocumentScope()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('holds a frame until it runs, then forgets it', () => {
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||
frames.push(callback)
|
||||
return frames.length
|
||||
})
|
||||
resetTerminalDocumentScope()
|
||||
|
||||
const id = scheduleDocumentFrame(() => {})
|
||||
expect(scope.scheduledFrames).toEqual([id])
|
||||
frames[0]!(0)
|
||||
expect(scope.scheduledFrames).toEqual([])
|
||||
})
|
||||
|
||||
it('takes back every pending frame and then refuses to schedule', () => {
|
||||
const cancelled: number[] = []
|
||||
let next = 0
|
||||
vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation(() => {
|
||||
next += 1
|
||||
return next
|
||||
})
|
||||
vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation((id) => cancelled.push(id))
|
||||
resetTerminalDocumentScope()
|
||||
|
||||
const first = scheduleDocumentFrame(() => {})
|
||||
const second = scheduleDocumentFrame(() => {})
|
||||
cancelDocumentFrames()
|
||||
expect(cancelled).toEqual([first, second])
|
||||
expect(scope.scheduledFrames).toEqual([])
|
||||
|
||||
const requests = vi.mocked(globalThis.requestAnimationFrame).mock.calls.length
|
||||
expect(scheduleDocumentFrame(() => {})).toBe(-1)
|
||||
expect(vi.mocked(globalThis.requestAnimationFrame).mock.calls.length).toBe(requests)
|
||||
expect(scope.scheduledFrames).toEqual([])
|
||||
})
|
||||
|
||||
it('schedules again once the scope is reset, which is what the next mount does', () => {
|
||||
vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation(() => 7)
|
||||
vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation(() => {})
|
||||
resetTerminalDocumentScope()
|
||||
|
||||
cancelDocumentFrames()
|
||||
expect(scope.framesStopped).toBe(true)
|
||||
resetTerminalDocumentScope()
|
||||
expect(scope.framesStopped).toBe(false)
|
||||
expect(scheduleDocumentFrame(() => {})).toBe(7)
|
||||
expect(scope.scheduledFrames).toEqual([7])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import type {
|
||||
TerminalDocumentTerminal,
|
||||
TerminalDocumentWebglAddon
|
||||
} from './document-terminal-shape'
|
||||
|
||||
/**
|
||||
* The six seams between the document and whatever is hosting it, as the document's own
|
||||
* defaults. The document reads them at seven places: `postToHost` twice, `createTerminal`,
|
||||
* `createUnicode11Addon`, `createWebglAddon`, `installErrorReporter` and
|
||||
* `paintDocumentBackground` once each.
|
||||
*
|
||||
* Inside the WebView the host is React Native and the engine is an IIFE that hangs its
|
||||
* constructors off `window`; on the page the host is the component that mounted these modules and
|
||||
* the engine is an import. Each function below is the window read or write the document already
|
||||
* did, kept at call time rather than captured when the script is parsed, and the scope carries it
|
||||
* as a field the page assigns over.
|
||||
*/
|
||||
|
||||
/** What a thrown value can be here: an Error-shaped object, a string, or nothing. */
|
||||
export type TerminalEngineError = string | null | undefined | { message?: unknown }
|
||||
|
||||
/** The document's runtime error reporter, taking the window error handler's own arguments. */
|
||||
export type TerminalDocumentErrorReporter = (
|
||||
message: string | (Event & { message?: unknown }),
|
||||
source?: string,
|
||||
line?: number,
|
||||
column?: number,
|
||||
error?: TerminalEngineError
|
||||
) => void
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
ReactNativeWebView?: { postMessage: (message: string) => void }
|
||||
Unicode11Addon?: { Unicode11Addon: new () => TerminalDocumentWebglAddon }
|
||||
WebglAddon?: { WebglAddon?: new () => TerminalDocumentWebglAddon }
|
||||
}
|
||||
const Terminal: new (options: Record<string, unknown>) => TerminalDocumentTerminal
|
||||
}
|
||||
|
||||
export function postToReactNativeWebView(message: Record<string, unknown>) {
|
||||
if (window.ReactNativeWebView) {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify(message))
|
||||
}
|
||||
}
|
||||
|
||||
export function createEngineTerminal(options: Record<string, unknown>) {
|
||||
return new Terminal(options)
|
||||
}
|
||||
|
||||
export function createEngineUnicode11Addon() {
|
||||
return window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon
|
||||
? new window.Unicode11Addon.Unicode11Addon()
|
||||
: null
|
||||
}
|
||||
|
||||
export function createEngineWebglAddon() {
|
||||
return window.WebglAddon && window.WebglAddon.WebglAddon
|
||||
? new window.WebglAddon.WebglAddon()
|
||||
: null
|
||||
}
|
||||
|
||||
/**
|
||||
* The WebView's own background: the document owns `html` and `body` there, and the terminal's
|
||||
* theme is the page's colour. A page mounting these modules owns neither, so this is a field —
|
||||
* painting the application's roots would recolour every screen the shell can show, and leave them
|
||||
* recoloured after the terminal is gone.
|
||||
*/
|
||||
export function paintWindowDocumentBackground(background: string) {
|
||||
document.documentElement.style.background = background
|
||||
document.body.style.background = background
|
||||
}
|
||||
|
||||
/**
|
||||
* The WebView's own installation: the document owns that page, so taking `window.onerror` is
|
||||
* taking nothing from anyone. A page mounting these modules must not, which is why this is a
|
||||
* field rather than a statement.
|
||||
*
|
||||
* It hands back its own undo, because ruling 20 makes the install a per-mount act and the page's
|
||||
* override is a listener that has to come off again.
|
||||
*/
|
||||
export function installWindowErrorReporter(report: TerminalDocumentErrorReporter) {
|
||||
window.onerror = report
|
||||
return function () {
|
||||
window.onerror = null
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import { readdirSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildTerminalDocumentScript,
|
||||
emitTerminalDocumentModule
|
||||
} from '../../../scripts/build-terminal-document-script.mjs'
|
||||
import {
|
||||
TERMINAL_DOCUMENT_HOST_SEAMS_MODULE,
|
||||
TERMINAL_DOCUMENT_MODULE_ORDER,
|
||||
TERMINAL_DOCUMENT_SCOPE_MODULE
|
||||
} from '../../../scripts/terminal-document-module-order.mjs'
|
||||
@@ -12,11 +18,21 @@ import {
|
||||
* there is dead code that reads as live, and a name left in the list after its file goes makes the
|
||||
* generator throw at build time rather than at review time. Both directions are asserted.
|
||||
*
|
||||
* `document-constants` is the one file that is deliberately not emitted: its exports are
|
||||
* substituted into the modules that import them as literals, so the document carries its values
|
||||
* without carrying the module.
|
||||
* Three files are deliberately not emitted into the document, each for its own reason, and they
|
||||
* are named rather than filtered by a pattern so a fourth cannot join them by looking similar.
|
||||
*/
|
||||
const NOT_EMITTED = 'document-constants'
|
||||
const NOT_EMITTED = [
|
||||
// Its exports are substituted into the modules that import them as literals, so the document
|
||||
// carries its values without carrying the module.
|
||||
'document-constants',
|
||||
// Types only. esbuild emits nothing for it, and an empty emission would add a blank line to the
|
||||
// document rather than a program.
|
||||
'document-terminal-shape',
|
||||
// The page's entry, not the WebView's: it imports the modules below in the order the generator
|
||||
// emits them, because on the page nothing splices them into one scope.
|
||||
// `page-document-module-order.test.ts` holds its list against this one.
|
||||
'page-document-modules'
|
||||
]
|
||||
|
||||
function documentModuleNames(): string[] {
|
||||
return readdirSync(new URL('.', import.meta.url))
|
||||
@@ -29,7 +45,8 @@ function documentModuleNames(): string[] {
|
||||
describe('the document module order', () => {
|
||||
it('names every module the directory holds, and only those', () => {
|
||||
const expected = [
|
||||
NOT_EMITTED,
|
||||
...NOT_EMITTED,
|
||||
TERMINAL_DOCUMENT_HOST_SEAMS_MODULE,
|
||||
TERMINAL_DOCUMENT_SCOPE_MODULE,
|
||||
...TERMINAL_DOCUMENT_MODULE_ORDER
|
||||
].sort()
|
||||
@@ -37,7 +54,45 @@ describe('the document module order', () => {
|
||||
})
|
||||
|
||||
it('names each module once, so the generator cannot emit one twice', () => {
|
||||
const listed = [TERMINAL_DOCUMENT_SCOPE_MODULE, ...TERMINAL_DOCUMENT_MODULE_ORDER]
|
||||
const listed = [
|
||||
TERMINAL_DOCUMENT_HOST_SEAMS_MODULE,
|
||||
TERMINAL_DOCUMENT_SCOPE_MODULE,
|
||||
...TERMINAL_DOCUMENT_MODULE_ORDER
|
||||
]
|
||||
expect(listed).toHaveLength(new Set(listed).size)
|
||||
})
|
||||
|
||||
it('emits nothing for the types-only module, which is why it is an exception', async () => {
|
||||
// The reason `document-terminal-shape` is not in the order list, measured rather than
|
||||
// asserted in prose: esbuild erases a module of type declarations to the empty string, and
|
||||
// emitting it would put a blank line in the document instead of a program. If it ever
|
||||
// declared a value this goes red, and the module belongs in the order list with its own line
|
||||
// in the golden diff.
|
||||
const emitted = await emitTerminalDocumentModule(
|
||||
fileURLToPath(new URL('./document-terminal-shape.ts', import.meta.url))
|
||||
)
|
||||
expect(emitted).toBe('')
|
||||
})
|
||||
|
||||
it('emits the host seams ahead of the scope, whose defaults are those six functions', async () => {
|
||||
// Order in the emitted document, not membership in a list: `createTerminalDocumentScope()`
|
||||
// runs as the script is parsed and reads the six by name, so a seams module emitted after it
|
||||
// would throw on the document's first line. Non-membership cannot see that — it is satisfied
|
||||
// by any arrangement — so the two texts are located in the document the generator produces.
|
||||
expect(TERMINAL_DOCUMENT_MODULE_ORDER).not.toContain(TERMINAL_DOCUMENT_HOST_SEAMS_MODULE)
|
||||
expect(TERMINAL_DOCUMENT_HOST_SEAMS_MODULE).not.toBe(TERMINAL_DOCUMENT_SCOPE_MODULE)
|
||||
|
||||
const script = await buildTerminalDocumentScript()
|
||||
const emittedAt = async (name: string) => {
|
||||
const text = await emitTerminalDocumentModule(
|
||||
fileURLToPath(new URL(`./${name}.ts`, import.meta.url))
|
||||
)
|
||||
const at = script.indexOf(text)
|
||||
expect(at, `${name} is not in the emitted document`).toBeGreaterThanOrEqual(0)
|
||||
return at
|
||||
}
|
||||
expect(await emittedAt(TERMINAL_DOCUMENT_HOST_SEAMS_MODULE)).toBeLessThan(
|
||||
await emittedAt(TERMINAL_DOCUMENT_SCOPE_MODULE)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { parseSync } from 'oxc-parser'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
TERMINAL_DOCUMENT_HOST_SEAMS_MODULE,
|
||||
TERMINAL_DOCUMENT_MODULE_ORDER,
|
||||
TERMINAL_DOCUMENT_SCOPE_MODULE
|
||||
} from '../../../scripts/terminal-document-module-order.mjs'
|
||||
|
||||
/**
|
||||
* Rulings 20 and 21: no module in the document does work as it is parsed, and none owns state.
|
||||
*
|
||||
* ES module bodies run once per page. Inside the WebView that was invisible — the script is
|
||||
* parsed once per document and the document is the page — but the web component mounts these same
|
||||
* modules, and a second mount re-imports nothing. An element read, a listener, or a reporter
|
||||
* install left in a module body would therefore keep the *first* mount's elements forever: that is
|
||||
* the defect round 1 measured, with zero `.xterm` nodes in the live DOM after a remount.
|
||||
*
|
||||
* So the rule is structural rather than behavioural, and it is checked structurally. Every
|
||||
* emitted module may declare; none may run. What used to run lives in that module's start
|
||||
* function, which both hosts call — the generated script once at the foot of the document, the
|
||||
* page once per mount.
|
||||
*
|
||||
* Ruling 21 is the same argument about state rather than about effects. A module-level `let`
|
||||
* survives a mount just as a module body does, so the second terminal inherited a spent error
|
||||
* budget, the first terminal's committed surface and its momentum loop. Every mutable binding
|
||||
* therefore lives on the scope, which the start sequence resets first, and module top level holds
|
||||
* constants, functions and types only.
|
||||
*/
|
||||
const EMITTED = [
|
||||
TERMINAL_DOCUMENT_HOST_SEAMS_MODULE,
|
||||
TERMINAL_DOCUMENT_SCOPE_MODULE,
|
||||
...TERMINAL_DOCUMENT_MODULE_ORDER
|
||||
]
|
||||
|
||||
/**
|
||||
* The one module that does build something as it is parsed: the scope object every other module
|
||||
* reads. It has to exist before any of them, and on the page it is one object for the life of the
|
||||
* tab — which is safe precisely because the rule below holds for everything else. Each start
|
||||
* function writes every field it owns, so a remount resets the scope rather than inheriting it.
|
||||
* Its parse-time work touches no element, which is asserted rather than asserted-in-prose.
|
||||
*/
|
||||
const BUILDS_THE_SCOPE = TERMINAL_DOCUMENT_SCOPE_MODULE
|
||||
|
||||
/** Statement kinds that only declare. Anything else at the top level is work. */
|
||||
const DECLARATION_KINDS = new Set([
|
||||
'ImportDeclaration',
|
||||
'ExportNamedDeclaration',
|
||||
'ExportDefaultDeclaration',
|
||||
'ExportAllDeclaration',
|
||||
'FunctionDeclaration',
|
||||
'ClassDeclaration',
|
||||
'VariableDeclaration',
|
||||
'TSTypeAliasDeclaration',
|
||||
'TSInterfaceDeclaration',
|
||||
'TSEnumDeclaration',
|
||||
'TSModuleDeclaration',
|
||||
'TSDeclareFunction',
|
||||
'TSImportEqualsDeclaration',
|
||||
'EmptyStatement'
|
||||
])
|
||||
|
||||
function moduleSource(name: string): string {
|
||||
return readFileSync(new URL(`./${name}.ts`, import.meta.url), 'utf8')
|
||||
}
|
||||
|
||||
/** A node's own properties, or nothing when it is not one. Read rather than asserted. */
|
||||
function fieldsOf(node: unknown): [string, unknown][] {
|
||||
return node !== null && typeof node === 'object' && !Array.isArray(node)
|
||||
? Object.entries(node)
|
||||
: []
|
||||
}
|
||||
|
||||
function stringField(node: unknown, key: string): string {
|
||||
const found = fieldsOf(node).find(([name]) => name === key)?.[1]
|
||||
return typeof found === 'string' ? found : ''
|
||||
}
|
||||
|
||||
function field(node: unknown, key: string): unknown {
|
||||
return fieldsOf(node).find(([name]) => name === key)?.[1]
|
||||
}
|
||||
|
||||
const RUNS_NOW = new Set([
|
||||
'CallExpression',
|
||||
'NewExpression',
|
||||
'AwaitExpression',
|
||||
'TaggedTemplateExpression'
|
||||
])
|
||||
/** What an initialiser *is* rather than what it does: its body runs later, not now. */
|
||||
const RUNS_LATER = new Set(['FunctionExpression', 'ArrowFunctionExpression', 'ClassExpression'])
|
||||
|
||||
function isElementGlobal(node: unknown): boolean {
|
||||
const name = stringField(node, 'name')
|
||||
return stringField(node, 'type') === 'Identifier' && (name === 'document' || name === 'window')
|
||||
}
|
||||
|
||||
function initialiserRuns(node: unknown): boolean {
|
||||
if (Array.isArray(node)) {
|
||||
return node.some(initialiserRuns)
|
||||
}
|
||||
const type = stringField(node, 'type')
|
||||
if (RUNS_NOW.has(type)) {
|
||||
return true
|
||||
}
|
||||
if (RUNS_LATER.has(type)) {
|
||||
return false
|
||||
}
|
||||
if (type === 'MemberExpression' && isElementGlobal(field(node, 'object'))) {
|
||||
return true
|
||||
}
|
||||
return fieldsOf(node).some(([key, value]) => key !== 'type' && initialiserRuns(value))
|
||||
}
|
||||
|
||||
/** Whether anything at a module's top level reaches an element, at any depth. */
|
||||
function readsTheDocument(node: unknown): boolean {
|
||||
if (Array.isArray(node)) {
|
||||
return node.some(readsTheDocument)
|
||||
}
|
||||
if (isElementGlobal(node)) {
|
||||
return true
|
||||
}
|
||||
return fieldsOf(node).some(([key, value]) => key !== 'type' && readsTheDocument(value))
|
||||
}
|
||||
|
||||
/** Every top-level `let` or `var`: state the module owns, which a second mount would inherit. */
|
||||
function mutableBindingsIn(name: string, source: string): string[] {
|
||||
const { program } = parseSync(`${name}.ts`, source, { lang: 'ts' })
|
||||
const found: string[] = []
|
||||
for (const statement of program.body) {
|
||||
const declaration =
|
||||
statement.type === 'ExportNamedDeclaration' ? (statement.declaration ?? statement) : statement
|
||||
if (declaration.type !== 'VariableDeclaration' || declaration.kind === 'const') {
|
||||
continue
|
||||
}
|
||||
found.push(`${name}: ${source.slice(declaration.start, declaration.end).split('\n')[0]}`)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
function mutableBindings(name: string): string[] {
|
||||
return mutableBindingsIn(name, moduleSource(name))
|
||||
}
|
||||
|
||||
/**
|
||||
* The top-level statements that are not declarations, and the initialisers that run something.
|
||||
*
|
||||
* A declaration counts as work when its initialiser calls, constructs, awaits, or reaches into
|
||||
* `document` or `window`: `const scrollIndicator = document.getElementById(...)` is a declaration
|
||||
* by shape and a parse-time element read by effect, and it is the exact form that survived a
|
||||
* remount still holding the first mount's node. Object and regex literals are not work, which is
|
||||
* why this reads the tree rather than the text.
|
||||
*/
|
||||
function parseTimeEffects(name: string): string[] {
|
||||
return parseTimeEffectsIn(name, moduleSource(name))
|
||||
}
|
||||
|
||||
function parseTimeEffectsIn(name: string, source: string): string[] {
|
||||
const { program, errors } = parseSync(`${name}.ts`, source, { lang: 'ts' })
|
||||
expect(errors).toEqual([])
|
||||
const effects: string[] = []
|
||||
for (const statement of program.body) {
|
||||
if (!DECLARATION_KINDS.has(statement.type)) {
|
||||
effects.push(`${name}: ${statement.type}`)
|
||||
continue
|
||||
}
|
||||
const declaration =
|
||||
statement.type === 'ExportNamedDeclaration' ? (statement.declaration ?? statement) : statement
|
||||
if (declaration.type !== 'VariableDeclaration') {
|
||||
continue
|
||||
}
|
||||
for (const declarator of declaration.declarations) {
|
||||
if (declarator.init && initialiserRuns(declarator.init)) {
|
||||
effects.push(`${name}: ${source.slice(declarator.start, declarator.end)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return effects
|
||||
}
|
||||
|
||||
describe('the document modules at parse time', () => {
|
||||
it('do no work: every effect is in a start function the hosts call', () => {
|
||||
const modules = EMITTED.filter((name) => name !== BUILDS_THE_SCOPE)
|
||||
expect(modules).toContain('runtime-constants')
|
||||
expect(modules.flatMap(parseTimeEffects)).toEqual([])
|
||||
})
|
||||
|
||||
it('build the scope, and only the scope, before the rest of them', () => {
|
||||
// The exception, measured. It is one module, it is the one the order list already names as
|
||||
// the scope, and nothing it does at parse time reaches an element — so a remount inherits an
|
||||
// object of fields, never a stale node.
|
||||
expect(parseTimeEffects(BUILDS_THE_SCOPE).length).toBeGreaterThan(0)
|
||||
const source = moduleSource(BUILDS_THE_SCOPE)
|
||||
const { program } = parseSync(`${BUILDS_THE_SCOPE}.ts`, source, { lang: 'ts' })
|
||||
const topLevel = program.body.filter(
|
||||
(statement) =>
|
||||
statement.type === 'VariableDeclaration' ||
|
||||
(statement.type === 'ExportNamedDeclaration' &&
|
||||
statement.declaration?.type === 'VariableDeclaration')
|
||||
)
|
||||
expect(topLevel.some(readsTheDocument)).toBe(false)
|
||||
})
|
||||
|
||||
it('own no mutable state: no top-level let or var outside the scope', () => {
|
||||
expect(EMITTED.filter((name) => name !== BUILDS_THE_SCOPE).flatMap(mutableBindings)).toEqual([])
|
||||
})
|
||||
|
||||
it('would report a planted one, so the empty list above is a measurement', () => {
|
||||
// The precondition for the case above, run against the same reader: a module body with a
|
||||
// `let` in it is the exact shape the rule refuses, and the reader has to say so.
|
||||
const planted = `import { scope } from './document-scope'\nlet spent = 0\nexport function n() {\n spent++\n return scope.term\n}\n`
|
||||
expect(mutableBindingsIn('planted', planted)).toEqual(['planted: let spent = 0'])
|
||||
})
|
||||
|
||||
it('would report a planted element read, which the statement filter cannot see', () => {
|
||||
// The second reader has its own precondition. A `const` initialised from the document is a
|
||||
// declaration by shape and a parse-time element read by effect — the exact form that survived
|
||||
// a remount holding the first mount's node — and the statement-kind filter waves it through.
|
||||
const planted =
|
||||
"import { scope } from './document-scope'\n" +
|
||||
"const indicator = document.getElementById('scroll-indicator')\n" +
|
||||
'export function n() {\n return indicator ?? scope.term\n}\n'
|
||||
expect(parseTimeEffectsIn('planted', planted)).toEqual([
|
||||
"planted: indicator = document.getElementById('scroll-indicator')"
|
||||
])
|
||||
// And the other direction, because a reader that flagged every initialiser would agree with
|
||||
// the empty list above only by refusing everything: a plain literal is not work.
|
||||
const inert =
|
||||
"import { scope } from './document-scope'\n" +
|
||||
'const options = { capture: true, passive: false }\n' +
|
||||
'export function n() {\n return options.capture && scope.term !== null\n}\n'
|
||||
expect(parseTimeEffectsIn('inert', inert)).toEqual([])
|
||||
})
|
||||
|
||||
it('would report one, so the empty list above is a measurement', () => {
|
||||
// The precondition. A walk that matched nothing would agree with an empty expectation just as
|
||||
// happily, so the same reader is aimed at a module that does have a top-level effect: this
|
||||
// test file itself, whose `describe` call is exactly the shape the rule refuses.
|
||||
const source = readFileSync(
|
||||
new URL('./document-parse-time-effects.test.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const { program } = parseSync('probe.ts', source, { lang: 'ts' })
|
||||
const running = program.body.filter((statement) => !DECLARATION_KINDS.has(statement.type))
|
||||
expect(running.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('still start and stop: the functions holding what was moved out are exported', () => {
|
||||
// The other half. Moving an effect out is only correct if something calls it, and the caller
|
||||
// is pinned by `page-document-module-order.test.ts` against the generator's own sequence;
|
||||
// this holds the shape of the names so that sequence can be derived rather than listed.
|
||||
const declaring = (keyword: string) =>
|
||||
EMITTED.filter((name) =>
|
||||
new RegExp(`^export function ${keyword}[A-Za-z]+\\(\\) \\{$`, 'm').test(moduleSource(name))
|
||||
)
|
||||
expect(declaring('start').length).toBe(10)
|
||||
// Ruling 21: a module that schedules a frame, a timer or a retry owes an undo for it.
|
||||
expect(declaring('stop').length).toBe(9)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,28 @@
|
||||
import { terminalDefaultTheme, terminalTextScalePresets } from './document-constants'
|
||||
import {
|
||||
createEngineTerminal,
|
||||
createEngineUnicode11Addon,
|
||||
createEngineWebglAddon,
|
||||
installWindowErrorReporter,
|
||||
paintWindowDocumentBackground,
|
||||
postToReactNativeWebView,
|
||||
type TerminalDocumentErrorReporter
|
||||
} from './document-host-seams'
|
||||
import type {
|
||||
TerminalDocumentDisposable,
|
||||
TerminalDocumentTerminal,
|
||||
TerminalDocumentTheme,
|
||||
TerminalDocumentWebglAddon,
|
||||
TerminalInitialOscLink
|
||||
} from './document-terminal-shape'
|
||||
import type { TerminalMouseGesture } from './mouse-click-drag'
|
||||
import type { TerminalTouchState } from './surface-touch-gestures'
|
||||
import type { TerminalTouchDispatch } from './tap-dispatch'
|
||||
import type { TerminalDocumentThemeMessage } from './terminal-theme'
|
||||
|
||||
// Re-exported so every module that reads the scope keeps naming one import for both: the split is
|
||||
// about this file's length, not about a second place to look for the engine's shape.
|
||||
export type * from './document-terminal-shape'
|
||||
/**
|
||||
* The state the in-WebView terminal document shares across its parts.
|
||||
*
|
||||
@@ -23,106 +46,8 @@ import type { TerminalDocumentThemeMessage } from './terminal-theme'
|
||||
* The table grows one group at a time as C7.1 extracts them; a field arrives with its group.
|
||||
*/
|
||||
|
||||
/** One cell of a buffer line, as the document inspects it. */
|
||||
/** xterm's OSC 8 link service, reached through internals and always guarded. */
|
||||
export type TerminalOscLinkService = { getLinkData?: (id: number) => { uri?: string } | undefined }
|
||||
|
||||
/** The xterm internals the OSC 8 lookup walks. */
|
||||
export type TerminalDocumentCore = {
|
||||
_renderService?: { dimensions?: { css: { cell: { height: number; width: number } } } }
|
||||
_oscLinkService?: TerminalOscLinkService
|
||||
_inputHandler?: { _oscLinkService?: TerminalOscLinkService }
|
||||
}
|
||||
|
||||
/** An OSC 8 link the host captured from scrollback before xterm replayed it. */
|
||||
export type TerminalInitialOscLink = {
|
||||
uri?: string
|
||||
row: number
|
||||
startCol: number
|
||||
endCol: number
|
||||
text?: string
|
||||
}
|
||||
|
||||
export type TerminalDocumentCell = {
|
||||
isBgDefault: () => boolean
|
||||
extended?: { urlId?: number }
|
||||
isInverse: () => boolean
|
||||
isUnderline?: () => boolean
|
||||
isStrikethrough?: () => boolean
|
||||
isOverline?: () => boolean
|
||||
}
|
||||
|
||||
/** One buffer line, as the document inspects it. */
|
||||
export type TerminalDocumentLine = {
|
||||
readonly length: number
|
||||
translateToString: (trimRight: boolean, startColumn?: number, endColumn?: number) => string
|
||||
getCell?: (x: number, cell?: TerminalDocumentCell | null) => TerminalDocumentCell | null
|
||||
}
|
||||
|
||||
/** One side of xterm's buffer, as the document reads it. */
|
||||
export type TerminalDocumentBuffer = {
|
||||
readonly length: number
|
||||
readonly viewportY: number
|
||||
readonly baseY: number
|
||||
readonly cursorY: number
|
||||
readonly type: string
|
||||
getNullCell?: () => TerminalDocumentCell
|
||||
getLine: (index: number) => TerminalDocumentLine | undefined
|
||||
}
|
||||
|
||||
/** As much of xterm's terminal as the document's own code touches. */
|
||||
/** A terminal colour theme: xterm reads it as a flat map of slot to CSS colour. */
|
||||
export type TerminalDocumentTheme = Record<string, string>
|
||||
|
||||
/** The xterm options the document writes; each field is owned by the group that sets it. */
|
||||
export type TerminalDocumentTerminalOptions = {
|
||||
theme: TerminalDocumentTheme
|
||||
minimumContrastRatio: number
|
||||
fontSize: number
|
||||
}
|
||||
|
||||
export type TerminalDocumentTerminal = {
|
||||
readonly cols: number
|
||||
readonly rows: number
|
||||
readonly buffer: { readonly active: TerminalDocumentBuffer }
|
||||
options: TerminalDocumentTerminalOptions
|
||||
write: (data: string, callback?: () => void) => void
|
||||
open: (element: HTMLElement) => void
|
||||
scrollToLine: (line: number) => void
|
||||
clear: () => void
|
||||
reset: () => void
|
||||
selectAll: () => void
|
||||
getSelection?: () => string
|
||||
select: (col: number, row: number, length: number) => void
|
||||
clearSelection: () => void
|
||||
readonly unicode: { activeVersion: string }
|
||||
attachCustomKeyEventHandler: (handler: () => boolean) => void
|
||||
onData: (listener: (data: string) => void) => TerminalDocumentDisposable
|
||||
readonly textarea?: {
|
||||
readOnly: boolean
|
||||
tabIndex: number
|
||||
setAttribute: (name: string, value: string) => void
|
||||
}
|
||||
readonly element?: HTMLElement
|
||||
readonly _core?: TerminalDocumentCore
|
||||
readonly modes?: {
|
||||
bracketedPasteMode?: boolean
|
||||
mouseTrackingMode?: string
|
||||
applicationCursorKeysMode?: boolean
|
||||
}
|
||||
onLineFeed?: (listener: () => void) => TerminalDocumentDisposable
|
||||
onScroll?: (listener: () => void) => TerminalDocumentDisposable
|
||||
onWriteParsed?: (listener: () => void) => TerminalDocumentDisposable
|
||||
resize: (cols: number, rows: number) => void
|
||||
refresh: (start: number, end: number) => void
|
||||
dispose: () => void
|
||||
loadAddon: (addon: TerminalDocumentWebglAddon) => void
|
||||
scrollToBottom: () => void
|
||||
scrollLines: (amount: number) => void
|
||||
}
|
||||
|
||||
export type TerminalDocumentScope = {
|
||||
/** `terminal-handle`: the live xterm terminal, or null before the first init. */
|
||||
export type TerminalDocumentState = {
|
||||
/** `terminal-init`: the live xterm terminal, or null before the first init. */
|
||||
term: TerminalDocumentTerminal | null
|
||||
/** `viewport-transform`: the surface's pan offset, in viewport pixels. */
|
||||
panX: number
|
||||
@@ -267,9 +192,59 @@ export type TerminalDocumentScope = {
|
||||
surface: HTMLElement | null
|
||||
/** `surface-swap`: the terminal of a hidden replacement surface that has not committed. */
|
||||
pendingTerm: TerminalDocumentTerminal | null
|
||||
/** `surface-swap`: the terminal the committed surface is showing. */
|
||||
committedTerm: TerminalDocumentTerminal | null
|
||||
/** `surface-swap`: the surface the committed terminal is mounted on. */
|
||||
committedSurface: HTMLElement | null
|
||||
/** `surface-swap`: the hidden replacement surface, until it commits. */
|
||||
pendingSurface: HTMLElement | null
|
||||
/** `text-scaling`: the scroll indicator's track and its thumb. */
|
||||
scrollIndicator: HTMLElement | null
|
||||
scrollThumb: HTMLElement | null
|
||||
/** `query-reply`: whether the host asked for terminal data replies. */
|
||||
terminalDataRepliesEnabled: boolean
|
||||
/** `selection-state-and-eviction`: rows written since the terminal opened. */
|
||||
linesEverWritten: number
|
||||
/** `host-notify`: non-fatal reports already sent, against the flood cap. */
|
||||
nonFatalErrorNotifies: number
|
||||
/** `host-notify`: undoes the host's reporter install, or null before one. */
|
||||
uninstallErrorReporter: (() => void) | null
|
||||
/** `fit-scale`: the generation of the retry loop; a bump abandons the one in flight. */
|
||||
fitRetryToken: number
|
||||
/** `mouse-click-drag`: the mouse gesture in progress, or null. */
|
||||
mouseGesture: TerminalMouseGesture | null
|
||||
/** `tap-dispatch`: what the document-level dispatcher has latched onto. */
|
||||
touchDispatch: TerminalTouchDispatch
|
||||
/** `surface-touch-gestures`: the surface touch, its velocity and its momentum frame. */
|
||||
touchGesture: TerminalTouchState
|
||||
/** Every animation frame the document has asked for and not yet run. */
|
||||
scheduledFrames: number[]
|
||||
/** Whether the document has been stopped, and so asks for no more frames. */
|
||||
framesStopped: boolean
|
||||
}
|
||||
|
||||
/** An xterm listener handle, as the document disposes of one. */
|
||||
/**
|
||||
* The six host seams, kept out of the state above because they are the one thing a reset must
|
||||
* not touch: the page sets them once per mount, before the start sequence runs.
|
||||
*/
|
||||
export type TerminalDocumentHostSeams = {
|
||||
/** `host-notify`, `viewport-transform`: where a message for the host goes. */
|
||||
postToHost: (message: Record<string, unknown>) => void
|
||||
/** `terminal-init`: builds the xterm terminal. */
|
||||
createTerminal: (options: Record<string, unknown>) => TerminalDocumentTerminal
|
||||
/** `terminal-init`: builds the unicode11 addon, or answers null when the host has none. */
|
||||
createUnicode11Addon: () => TerminalDocumentWebglAddon | null
|
||||
/** `webgl-recovery`: builds the WebGL addon, or answers null when the host has none. */
|
||||
createWebglAddon: () => TerminalDocumentWebglAddon | null
|
||||
/** `host-notify`: installs the document's runtime error reporter with the host. */
|
||||
installErrorReporter: (report: TerminalDocumentErrorReporter) => () => void
|
||||
/** `terminal-theme`: paints the terminal's background behind the grid. */
|
||||
paintDocumentBackground: (background: string) => void
|
||||
}
|
||||
|
||||
/** The document's whole scope: its state, and the seams to whatever is hosting it. */
|
||||
export type TerminalDocumentScope = TerminalDocumentState & TerminalDocumentHostSeams
|
||||
|
||||
/** The live selection; only the dragged handle is read outside the overlay slice. */
|
||||
export type TerminalDocumentSelection = {
|
||||
anchor: { row: number; col: number }
|
||||
@@ -295,15 +270,6 @@ export type TerminalDocumentModes = {
|
||||
/** One entry of the write queue: a chunk, a boundary callback, or a consumed slot. */
|
||||
export type TerminalWriteQueueEntry = string | (() => void) | undefined
|
||||
|
||||
export type TerminalDocumentDisposable = { dispose?: () => void }
|
||||
|
||||
/** xterm's WebGL addon, as the document loads, repaints and disposes of it. */
|
||||
export type TerminalDocumentWebglAddon = {
|
||||
onContextLoss?: (listener: () => void) => void
|
||||
clearTextureAtlas?: () => void
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The initial values, which are the ones the document's own declarations carried.
|
||||
*
|
||||
@@ -315,7 +281,7 @@ const statusDot = String.fromCharCode(0x23fa)
|
||||
const textPresentationSelector = String.fromCharCode(0xfe0e)
|
||||
const emojiPresentationSelector = String.fromCharCode(0xfe0f)
|
||||
|
||||
export function createTerminalDocumentScope(): TerminalDocumentScope {
|
||||
function createTerminalDocumentState(): TerminalDocumentState {
|
||||
return {
|
||||
term: null,
|
||||
panX: 0,
|
||||
@@ -352,7 +318,7 @@ export function createTerminalDocumentScope(): TerminalDocumentScope {
|
||||
handledMessageIds: [],
|
||||
currentTextScale: 1,
|
||||
terminalFontFamily: '',
|
||||
firstDataPending: true,
|
||||
firstDataPending: false,
|
||||
activeAltScreenSnapshot: false,
|
||||
currentScale: 1,
|
||||
userScale: 1,
|
||||
@@ -398,9 +364,115 @@ export function createTerminalDocumentScope(): TerminalDocumentScope {
|
||||
tapCandidate: null,
|
||||
wheelAccumDeltaY: 0,
|
||||
surface: null,
|
||||
pendingTerm: null
|
||||
pendingTerm: null,
|
||||
committedTerm: null,
|
||||
committedSurface: null,
|
||||
pendingSurface: null,
|
||||
scrollIndicator: null,
|
||||
scrollThumb: null,
|
||||
terminalDataRepliesEnabled: false,
|
||||
linesEverWritten: 0,
|
||||
nonFatalErrorNotifies: 0,
|
||||
uninstallErrorReporter: null,
|
||||
fitRetryToken: 0,
|
||||
mouseGesture: null,
|
||||
touchDispatch: {
|
||||
mode: 'idle',
|
||||
touchId: null,
|
||||
touchIds: null,
|
||||
longPressFingerInsideOverlay: false
|
||||
},
|
||||
scheduledFrames: [],
|
||||
framesStopped: false,
|
||||
touchGesture: {
|
||||
lastX: 0,
|
||||
lastY: 0,
|
||||
lastTime: 0,
|
||||
velY: 0,
|
||||
accumDelta: 0,
|
||||
momentumId: null,
|
||||
isPinching: false,
|
||||
pinchDist: 0,
|
||||
pinchScale: 0,
|
||||
pinchSurfX: 0,
|
||||
pinchSurfY: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The seams' defaults: the window reads and writes the document already did. */
|
||||
function createTerminalDocumentHostSeams(): TerminalDocumentHostSeams {
|
||||
return {
|
||||
postToHost: postToReactNativeWebView,
|
||||
createTerminal: createEngineTerminal,
|
||||
createUnicode11Addon: createEngineUnicode11Addon,
|
||||
createWebglAddon: createEngineWebglAddon,
|
||||
installErrorReporter: installWindowErrorReporter,
|
||||
paintDocumentBackground: paintWindowDocumentBackground
|
||||
}
|
||||
}
|
||||
|
||||
export function createTerminalDocumentScope(): TerminalDocumentScope {
|
||||
return { ...createTerminalDocumentState(), ...createTerminalDocumentHostSeams() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The scope back at the state a freshly parsed document has (ruling 21).
|
||||
*
|
||||
* The page mounts these modules more than once and an ES module body runs once per page, so this
|
||||
* is what makes a second mount a second document: the start sequence calls it first, and on the
|
||||
* WebView it runs once at parse, where it changes nothing. The seams are left alone — the page
|
||||
* sets them before the sequence runs, and they belong to the host rather than to the terminal.
|
||||
*
|
||||
* Two counters carry forward instead of resetting, because they are what a stale callback is
|
||||
* tested against: a frame scheduled by the mount that just went away compares its captured number
|
||||
* with the one here, and a reset to zero would make the old number match again.
|
||||
*/
|
||||
export function resetTerminalDocumentScope() {
|
||||
const generations = {
|
||||
terminalGeneration: scope.terminalGeneration + 1,
|
||||
fitRetryToken: scope.fitRetryToken + 1
|
||||
}
|
||||
Object.assign(scope, createTerminalDocumentState(), generations)
|
||||
}
|
||||
|
||||
/** The document's own scope. The generator emits this declaration at the top of the script. */
|
||||
export const scope: TerminalDocumentScope = createTerminalDocumentScope()
|
||||
|
||||
/**
|
||||
* An animation frame the document can take back (ruling 21).
|
||||
*
|
||||
* A generation guard makes a stale frame *do* nothing; it still runs, and inside a WebView that
|
||||
* is the same thing. On the page it is not: the mount that scheduled the frame may be gone and
|
||||
* the next one already up, and a callback that reads the scope reads the new mount's. Every frame
|
||||
* the document asks for is registered here so `cancelDocumentFrames` can take the pending ones
|
||||
* back, which is what the page's dispose does. The id is dropped as the frame runs, so the list
|
||||
* holds only what is still owed.
|
||||
*/
|
||||
export function scheduleDocumentFrame(callback: FrameRequestCallback) {
|
||||
// A stopped document asks for nothing. Tearing the terminal down runs the engine's own
|
||||
// disposal, which calls back into these modules, and a frame asked for on the way out would be
|
||||
// owed by nobody — the cancel has already run. `-1` is not a live frame id, so a caller that
|
||||
// holds one and cancels it later is cancelling nothing.
|
||||
if (scope.framesStopped) {
|
||||
return -1
|
||||
}
|
||||
const id = requestAnimationFrame(function (time) {
|
||||
const at = scope.scheduledFrames.indexOf(id)
|
||||
if (at !== -1) {
|
||||
scope.scheduledFrames.splice(at, 1)
|
||||
}
|
||||
callback(time)
|
||||
})
|
||||
scope.scheduledFrames.push(id)
|
||||
return id
|
||||
}
|
||||
|
||||
/** Takes back every frame the document is still owed, and stops it asking for more. */
|
||||
export function cancelDocumentFrames() {
|
||||
scope.framesStopped = true
|
||||
for (const id of scope.scheduledFrames) {
|
||||
cancelAnimationFrame(id)
|
||||
}
|
||||
scope.scheduledFrames = []
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* The shape of xterm, as the document uses it.
|
||||
*
|
||||
* Only the members the document's own code reaches: the terminal, its buffer, the internals the
|
||||
* OSC 8 lookup and the cell-geometry walk go through, and its two addons. This describes the
|
||||
* engine rather than the document, which is why it is not in the scope's own file: the WebView's
|
||||
* engine is a bundle on `window` and the page's is an import, and both answer exactly this.
|
||||
*/
|
||||
|
||||
/** One cell of a buffer line, as the document inspects it. */
|
||||
/** xterm's OSC 8 link service, reached through internals and always guarded. */
|
||||
export type TerminalOscLinkService = { getLinkData?: (id: number) => { uri?: string } | undefined }
|
||||
|
||||
/** The xterm internals the OSC 8 lookup walks. */
|
||||
export type TerminalDocumentCore = {
|
||||
_renderService?: { dimensions?: { css: { cell: { height: number; width: number } } } }
|
||||
_oscLinkService?: TerminalOscLinkService
|
||||
_inputHandler?: { _oscLinkService?: TerminalOscLinkService }
|
||||
}
|
||||
|
||||
/** An OSC 8 link the host captured from scrollback before xterm replayed it. */
|
||||
export type TerminalInitialOscLink = {
|
||||
uri?: string
|
||||
row: number
|
||||
startCol: number
|
||||
endCol: number
|
||||
text?: string
|
||||
}
|
||||
|
||||
export type TerminalDocumentCell = {
|
||||
isBgDefault: () => boolean
|
||||
extended?: { urlId?: number }
|
||||
isInverse: () => boolean
|
||||
isUnderline?: () => boolean
|
||||
isStrikethrough?: () => boolean
|
||||
isOverline?: () => boolean
|
||||
}
|
||||
|
||||
/** One buffer line, as the document inspects it. */
|
||||
export type TerminalDocumentLine = {
|
||||
readonly length: number
|
||||
translateToString: (trimRight: boolean, startColumn?: number, endColumn?: number) => string
|
||||
getCell?: (x: number, cell?: TerminalDocumentCell | null) => TerminalDocumentCell | null
|
||||
}
|
||||
|
||||
/** One side of xterm's buffer, as the document reads it. */
|
||||
export type TerminalDocumentBuffer = {
|
||||
readonly length: number
|
||||
readonly viewportY: number
|
||||
readonly baseY: number
|
||||
readonly cursorY: number
|
||||
readonly type: string
|
||||
getNullCell?: () => TerminalDocumentCell
|
||||
getLine: (index: number) => TerminalDocumentLine | undefined
|
||||
}
|
||||
|
||||
/** As much of xterm's terminal as the document's own code touches. */
|
||||
/** A terminal colour theme: xterm reads it as a flat map of slot to CSS colour. */
|
||||
export type TerminalDocumentTheme = Record<string, string>
|
||||
|
||||
/** The xterm options the document writes; each field is owned by the group that sets it. */
|
||||
export type TerminalDocumentTerminalOptions = {
|
||||
theme: TerminalDocumentTheme
|
||||
minimumContrastRatio: number
|
||||
fontSize: number
|
||||
}
|
||||
|
||||
export type TerminalDocumentTerminal = {
|
||||
readonly cols: number
|
||||
readonly rows: number
|
||||
readonly buffer: { readonly active: TerminalDocumentBuffer }
|
||||
options: TerminalDocumentTerminalOptions
|
||||
write: (data: string, callback?: () => void) => void
|
||||
open: (element: HTMLElement) => void
|
||||
scrollToLine: (line: number) => void
|
||||
clear: () => void
|
||||
reset: () => void
|
||||
selectAll: () => void
|
||||
getSelection?: () => string
|
||||
select: (col: number, row: number, length: number) => void
|
||||
clearSelection: () => void
|
||||
readonly unicode: { activeVersion: string }
|
||||
attachCustomKeyEventHandler: (handler: () => boolean) => void
|
||||
onData: (listener: (data: string) => void) => TerminalDocumentDisposable
|
||||
readonly textarea?: {
|
||||
readOnly: boolean
|
||||
tabIndex: number
|
||||
setAttribute: (name: string, value: string) => void
|
||||
}
|
||||
readonly element?: HTMLElement
|
||||
readonly _core?: TerminalDocumentCore
|
||||
readonly modes?: {
|
||||
bracketedPasteMode?: boolean
|
||||
mouseTrackingMode?: string
|
||||
applicationCursorKeysMode?: boolean
|
||||
}
|
||||
onLineFeed?: (listener: () => void) => TerminalDocumentDisposable
|
||||
onScroll?: (listener: () => void) => TerminalDocumentDisposable
|
||||
onWriteParsed?: (listener: () => void) => TerminalDocumentDisposable
|
||||
resize: (cols: number, rows: number) => void
|
||||
refresh: (start: number, end: number) => void
|
||||
dispose: () => void
|
||||
loadAddon: (addon: TerminalDocumentWebglAddon) => void
|
||||
scrollToBottom: () => void
|
||||
scrollLines: (amount: number) => void
|
||||
}
|
||||
|
||||
/** An xterm listener handle, as the document disposes of one. */
|
||||
export type TerminalDocumentDisposable = { dispose?: () => void }
|
||||
|
||||
/** xterm's WebGL addon, as the document loads, repaints and disposes of it. */
|
||||
export type TerminalDocumentWebglAddon = {
|
||||
onContextLoss?: (listener: () => void) => void
|
||||
clearTextureAtlas?: () => void
|
||||
dispose: () => void
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
getTotalScale,
|
||||
updateTransform
|
||||
} from './viewport-transform'
|
||||
import { scope } from './document-scope'
|
||||
import { scope, scheduleDocumentFrame } from './document-scope'
|
||||
|
||||
export function getCellHeight() {
|
||||
if (!scope.term || !scope.term._core) {
|
||||
@@ -61,16 +61,15 @@ export function adjustRowsForViewport() {}
|
||||
// scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz)
|
||||
// so a backgrounded WebView never spins forever.
|
||||
const FIT_RETRY_MAX_FRAMES = 60
|
||||
let fitRetryToken = 0
|
||||
export function applyFitScale(reason: string) {
|
||||
if (!scope.term || !scope.term.element) {
|
||||
return
|
||||
}
|
||||
const token = ++fitRetryToken
|
||||
const token = ++scope.fitRetryToken
|
||||
let attempts = 0
|
||||
let lastScrollWidth = -1
|
||||
function attempt() {
|
||||
if (token !== fitRetryToken) {
|
||||
if (token !== scope.fitRetryToken) {
|
||||
return
|
||||
}
|
||||
if (!scope.term || !scope.term.element) {
|
||||
@@ -99,9 +98,9 @@ export function applyFitScale(reason: string) {
|
||||
commitFitScale(reason, attempts, 'timeout')
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(attempt)
|
||||
scheduleDocumentFrame(attempt)
|
||||
}
|
||||
requestAnimationFrame(attempt)
|
||||
scheduleDocumentFrame(attempt)
|
||||
}
|
||||
|
||||
export function commitFitScale(reason: string, attempts: number, gate: string) {
|
||||
@@ -144,3 +143,11 @@ export function commitFitScale(reason: string, attempts: number, gate: string) {
|
||||
}
|
||||
repositionOverlay()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruling 21: the retry loop is abandoned by bumping the token it compares itself against, which is
|
||||
* how it already abandons a superseded attempt.
|
||||
*/
|
||||
export function stopFitScale() {
|
||||
scope.fitRetryToken++
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ import { emitTerminalDocumentModule } from '../../../scripts/build-terminal-docu
|
||||
import { XTERM_HTML } from '../terminal-webview-html'
|
||||
|
||||
const SCOPE_OPEN = '(function() {\n'
|
||||
// The first statement the document runs once the scope object exists.
|
||||
const FIRST_STATEMENT_AFTER_SCOPE = ' scope.surface = document.getElementById'
|
||||
// The first declaration the document makes once the scope object exists. Ruling 20 left the
|
||||
// modules below with no top-level statements at all, so the anchor is a declaration rather than
|
||||
// the surface read that used to open them.
|
||||
const FIRST_STATEMENT_AFTER_SCOPE = ' function startRuntimeConstants() {'
|
||||
|
||||
/**
|
||||
* The scope object the document opens with. Every block below it reads and writes document state
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { scope } from './document-scope'
|
||||
import { scope, scheduleDocumentFrame } from './document-scope'
|
||||
import { applyFitScale } from './fit-scale'
|
||||
import { notify } from './host-notify'
|
||||
import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics'
|
||||
@@ -49,7 +49,12 @@ export function measureFitDimensions(containerHeightPx: unknown, retriesLeft?: n
|
||||
}
|
||||
if (notReady || cellWidth <= 0 || cellHeight <= 0) {
|
||||
if (retriesLeft > 0) {
|
||||
requestAnimationFrame(function () {
|
||||
// Ruling 21: a retry that outlives its mount would answer the next mount's measure.
|
||||
const gen = scope.terminalGeneration
|
||||
scheduleDocumentFrame(function () {
|
||||
if (gen !== scope.terminalGeneration) {
|
||||
return
|
||||
}
|
||||
measureFitDimensions(containerHeightPx, retriesLeft - 1)
|
||||
})
|
||||
return
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { scope } from './document-scope'
|
||||
|
||||
// Declared beside the seam that hands it out, and re-exported here because this is where the
|
||||
// document's readers have always named it.
|
||||
export type { TerminalEngineError } from './document-host-seams'
|
||||
import type { TerminalEngineError } from './document-host-seams'
|
||||
|
||||
/**
|
||||
* The postMessage bridge to the host, and the engine error reporting that rides on it.
|
||||
*
|
||||
@@ -14,14 +19,9 @@ declare global {
|
||||
}
|
||||
|
||||
export function notify(msg: Record<string, unknown>) {
|
||||
if (window.ReactNativeWebView) {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify(msg))
|
||||
}
|
||||
scope.postToHost(msg)
|
||||
}
|
||||
|
||||
/** What a thrown value can be here: an Error-shaped object, a string, or nothing. */
|
||||
export type TerminalEngineError = string | null | undefined | { message?: unknown }
|
||||
|
||||
export function engineErrorText(err: TerminalEngineError) {
|
||||
if (!err) {
|
||||
return ''
|
||||
@@ -44,15 +44,13 @@ export function chromeVersionText() {
|
||||
return match ? 'Chrome ' + match[1] : 'Chrome version unknown'
|
||||
}
|
||||
|
||||
let nonFatalErrorNotifies = 0
|
||||
|
||||
export function reportEngineError(context: string, err: TerminalEngineError, fatal?: unknown) {
|
||||
const isFatal = fatal === undefined ? !scope.everReady : !!fatal
|
||||
if (!isFatal) {
|
||||
// Why: a constructed-but-degraded engine can throw per frame; cap
|
||||
// non-fatal notifies so RN isn't flooded. Fatal reports always emit.
|
||||
nonFatalErrorNotifies++
|
||||
if (nonFatalErrorNotifies > 5) {
|
||||
scope.nonFatalErrorNotifies++
|
||||
if (scope.nonFatalErrorNotifies > 5) {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -72,15 +70,24 @@ export function reportEngineError(context: string, err: TerminalEngineError, fat
|
||||
})
|
||||
}
|
||||
|
||||
window.onerror = function (
|
||||
msg: string | (Event & { message?: unknown }),
|
||||
source,
|
||||
line,
|
||||
column,
|
||||
err?: TerminalEngineError
|
||||
) {
|
||||
if (window.__engineErrors.length < 20) {
|
||||
window.__engineErrors.push(String(msg))
|
||||
export function startHostNotify() {
|
||||
scope.uninstallErrorReporter = scope.installErrorReporter(function (
|
||||
msg: string | (Event & { message?: unknown }),
|
||||
source,
|
||||
line,
|
||||
column,
|
||||
err?: TerminalEngineError
|
||||
) {
|
||||
if (window.__engineErrors.length < 20) {
|
||||
window.__engineErrors.push(String(msg))
|
||||
}
|
||||
reportEngineError('terminal runtime error', err || msg)
|
||||
})
|
||||
}
|
||||
|
||||
export function stopHostNotify() {
|
||||
if (scope.uninstallErrorReporter) {
|
||||
scope.uninstallErrorReporter()
|
||||
scope.uninstallErrorReporter = null
|
||||
}
|
||||
reportEngineError('terminal runtime error', err || msg)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalDocumentScope } from './document-scope'
|
||||
|
||||
/**
|
||||
* The six host seams the page sets, and the window reads and writes they default to.
|
||||
*
|
||||
* The document reached its host through `window.ReactNativeWebView` and built its engine from
|
||||
* `window.Terminal` and the two addon globals the engine bundle installs. On the page neither is
|
||||
* available the way the document assumes: `window.ReactNativeWebView` is the *shell's* bridge, so
|
||||
* a terminal notify posted through it would put raw terminal JSON into the bridge's own channel,
|
||||
* and there is no engine bundle at all because the page imports xterm as a module.
|
||||
*
|
||||
* So each of the six is a scope field. The default is the window read the document already did,
|
||||
* unchanged and still performed at call time rather than captured when the scope is built; the
|
||||
* page assigns the field instead. Both halves are asserted here, because a seam whose default
|
||||
* quietly stopped reading the window would leave the native document mute with every other
|
||||
* terminal test still green — they stub those globals and would be stubbing nothing.
|
||||
*/
|
||||
|
||||
const SURFACE_MARKUP =
|
||||
'<div id="terminal-container"><div id="terminal-surface"></div></div>' +
|
||||
'<div id="selection-overlay"><div id="sel-handle-start"></div>' +
|
||||
'<div id="sel-handle-end"></div><div id="sel-menu">' +
|
||||
'<button id="sel-menu-copy"></button><button id="sel-menu-all"></button></div></div>' +
|
||||
'<div id="scroll-indicator"><div id="scroll-thumb"></div></div>'
|
||||
|
||||
// Imported after the markup exists: ruling 20 leaves the module bodies inert, but the start
|
||||
// sequence below reads the elements as the document does, and it has to find them.
|
||||
let createTerminalDocumentScope: () => TerminalDocumentScope
|
||||
let scope: TerminalDocumentScope
|
||||
let handleMsg: typeof import('./host-message-router').handleMsg
|
||||
let notify: typeof import('./host-notify').notify
|
||||
let flog: typeof import('./viewport-transform').flog
|
||||
let attachWebglAddon: typeof import('./webgl-recovery').attachWebglAddon
|
||||
|
||||
beforeAll(async () => {
|
||||
document.body.innerHTML = SURFACE_MARKUP
|
||||
// The page's own entry and the page's own sequence, rather than a hand-picked subset: the
|
||||
// elements `runtime-constants`, `surface-swap` and `selection-state-and-eviction` take are read
|
||||
// in the one order both hosts run them in, and a module added to that order is covered here
|
||||
// without this file being edited.
|
||||
const pageModules = await import('./page-document-modules')
|
||||
pageModules.startPageDocumentModules()
|
||||
const documentScope = await import('./document-scope')
|
||||
createTerminalDocumentScope = documentScope.createTerminalDocumentScope
|
||||
scope = documentScope.scope
|
||||
;({ handleMsg } = await import('./host-message-router'))
|
||||
;({ notify } = await import('./host-notify'))
|
||||
;({ flog } = await import('./viewport-transform'))
|
||||
;({ attachWebglAddon } = await import('./webgl-recovery'))
|
||||
})
|
||||
|
||||
function terminalDouble() {
|
||||
const loaded: unknown[] = []
|
||||
let opened: HTMLElement | undefined
|
||||
const terminal = {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
options: { theme: {}, minimumContrastRatio: 3, fontSize: 13 },
|
||||
buffer: { active: { baseY: 0, viewportY: 0, cursorY: 0, length: 1, type: 'normal' } },
|
||||
get element() {
|
||||
return opened
|
||||
},
|
||||
unicode: { activeVersion: '6' },
|
||||
loaded,
|
||||
write(_data: string, callback?: () => void) {
|
||||
callback?.()
|
||||
},
|
||||
open(element: HTMLElement) {
|
||||
opened = element
|
||||
},
|
||||
loadAddon: (addon: unknown) => loaded.push(addon),
|
||||
attachCustomKeyEventHandler() {},
|
||||
onData: () => ({ dispose() {} }),
|
||||
onLineFeed: () => ({ dispose() {} }),
|
||||
onScroll: () => ({ dispose() {} }),
|
||||
onWriteParsed: () => ({ dispose() {} }),
|
||||
clear() {},
|
||||
reset() {},
|
||||
refresh() {},
|
||||
resize() {},
|
||||
selectAll() {},
|
||||
select() {},
|
||||
clearSelection() {},
|
||||
scrollLines() {},
|
||||
scrollToLine() {},
|
||||
scrollToBottom() {},
|
||||
dispose() {}
|
||||
}
|
||||
return terminal
|
||||
}
|
||||
|
||||
/** Restores every field a case assigns, so one of them cannot leave the singleton scope moved. */
|
||||
function withSeams(seams: Partial<TerminalDocumentScope>, run: () => void) {
|
||||
const previous: Record<string, unknown> = {}
|
||||
for (const key of Object.keys(seams)) {
|
||||
previous[key] = Object.getOwnPropertyDescriptor(scope, key)?.value
|
||||
}
|
||||
Object.assign(scope, seams)
|
||||
try {
|
||||
run()
|
||||
} finally {
|
||||
Object.assign(scope, previous)
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('the document host seams, by default', () => {
|
||||
it('posts to the React Native bridge, reading it at call time', () => {
|
||||
const postMessage = vi.fn<(data: string) => void>()
|
||||
// Built before the global exists: the default must read the window when it posts, not when
|
||||
// the scope was created, because the document's scope is built as its script is parsed.
|
||||
const built = createTerminalDocumentScope()
|
||||
vi.stubGlobal('ReactNativeWebView', { postMessage })
|
||||
built.postToHost({ type: 'ready', cols: 80, rows: 24 })
|
||||
expect(postMessage.mock.calls).toEqual([['{"type":"ready","cols":80,"rows":24}']])
|
||||
})
|
||||
|
||||
it('posts nothing when there is no bridge, which is the guard the document carried', () => {
|
||||
expect(() => createTerminalDocumentScope().postToHost({ type: 'ready' })).not.toThrow()
|
||||
})
|
||||
|
||||
it('builds the terminal from the engine bundle global', () => {
|
||||
const constructed: Record<string, unknown>[] = []
|
||||
function TerminalStub(this: unknown, options: Record<string, unknown>) {
|
||||
constructed.push(options)
|
||||
}
|
||||
vi.stubGlobal('Terminal', TerminalStub)
|
||||
const term = createTerminalDocumentScope().createTerminal({ cols: 80, rows: 24 })
|
||||
expect(constructed).toEqual([{ cols: 80, rows: 24 }])
|
||||
expect(term).toBeInstanceOf(TerminalStub)
|
||||
})
|
||||
|
||||
it('installs the runtime error reporter by taking window.onerror, and hands back its undo', () => {
|
||||
const previous = window.onerror
|
||||
try {
|
||||
const report = () => {}
|
||||
const uninstall = createTerminalDocumentScope().installErrorReporter(report)
|
||||
expect(window.onerror).toBe(report)
|
||||
// Ruling 20 made the install a per-mount act, so the seam owes the caller a way back.
|
||||
uninstall()
|
||||
expect(window.onerror).toBe(null)
|
||||
} finally {
|
||||
window.onerror = previous
|
||||
}
|
||||
})
|
||||
|
||||
it('paints the document roots, which is what owning the page means', () => {
|
||||
// Inside the WebView the terminal's theme is the page's own background, so the document sets
|
||||
// it on `html` and `body`. On the page those belong to the application, which is why this is
|
||||
// a field: the render check holds that neither root moves while a terminal is mounted.
|
||||
const roots = [document.documentElement, document.body]
|
||||
const previous = roots.map((element) => element.style.background)
|
||||
try {
|
||||
createTerminalDocumentScope().paintDocumentBackground('rgb(1, 2, 3)')
|
||||
expect(roots.map((element) => element.style.background)).toEqual([
|
||||
'rgb(1, 2, 3)',
|
||||
'rgb(1, 2, 3)'
|
||||
])
|
||||
} finally {
|
||||
roots.forEach((element, index) => {
|
||||
element.style.background = previous[index]!
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('builds each addon from its engine global, and answers null when the engine has none', () => {
|
||||
const built = createTerminalDocumentScope()
|
||||
expect(built.createUnicode11Addon()).toBe(null)
|
||||
expect(built.createWebglAddon()).toBe(null)
|
||||
class Unicode11Addon {
|
||||
dispose() {}
|
||||
}
|
||||
class WebglAddon {
|
||||
dispose() {}
|
||||
}
|
||||
vi.stubGlobal('Unicode11Addon', { Unicode11Addon })
|
||||
vi.stubGlobal('WebglAddon', { WebglAddon })
|
||||
expect(built.createUnicode11Addon()).toBeInstanceOf(Unicode11Addon)
|
||||
expect(built.createWebglAddon()).toBeInstanceOf(WebglAddon)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the document host seams, once the page sets them', () => {
|
||||
it('routes every notify to the field and nothing to the bridge', () => {
|
||||
const postMessage = vi.fn<(data: string) => void>()
|
||||
vi.stubGlobal('ReactNativeWebView', { postMessage })
|
||||
const posted: Record<string, unknown>[] = []
|
||||
withSeams({ postToHost: (message) => posted.push(message) }, () => {
|
||||
notify({ type: 'pong', pingId: 7 })
|
||||
flog('probe', { n: 1 })
|
||||
})
|
||||
expect(posted).toEqual([
|
||||
{ type: 'pong', pingId: 7 },
|
||||
{ type: 'log', tag: '[fit]probe', payload: { n: 1 } }
|
||||
])
|
||||
// The whole reason the seam exists: on the page this object belongs to the shell.
|
||||
expect(postMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes a host message into the document and builds the engine from the fields', () => {
|
||||
const terminal = terminalDouble()
|
||||
const options: Record<string, unknown>[] = []
|
||||
const unicodeAddon = { dispose() {} }
|
||||
const webglAddon = { dispose() {} }
|
||||
const posted: Record<string, unknown>[] = []
|
||||
withSeams(
|
||||
{
|
||||
createTerminal: (created) => {
|
||||
options.push(created)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the double implements every member `init` reaches; the calls below are what check it.
|
||||
return terminal as unknown as ReturnType<typeof scope.createTerminal>
|
||||
},
|
||||
createUnicode11Addon: () => unicodeAddon,
|
||||
createWebglAddon: () => webglAddon,
|
||||
postToHost: (message) => posted.push(message)
|
||||
},
|
||||
() => {
|
||||
handleMsg({ type: 'init', cols: 80, rows: 24, initialData: '', preserveScroll: false })
|
||||
expect(options).toHaveLength(1)
|
||||
expect(options[0]!.cols).toBe(80)
|
||||
expect(terminal.loaded).toContain(webglAddon)
|
||||
expect(terminal.loaded).toContain(unicodeAddon)
|
||||
expect(terminal.unicode.activeVersion).toBe('11')
|
||||
// The other direction: a document-side report reaches the page's sink, not the bridge.
|
||||
handleMsg({ type: 'ping', id: 3 })
|
||||
expect(posted).toContainEqual({ type: 'pong', pingId: 3 })
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves window.onerror alone when the host installs the reporter its own way', () => {
|
||||
// The page's case, which is the whole reason this one is a field: on a page that object is
|
||||
// not the terminal's to take. A host that installs its reporter elsewhere must leave it null.
|
||||
const previous = window.onerror
|
||||
window.onerror = null
|
||||
const installed: unknown[] = []
|
||||
try {
|
||||
const built = createTerminalDocumentScope()
|
||||
const undos: unknown[] = []
|
||||
built.installErrorReporter = (report) => {
|
||||
installed.push(report)
|
||||
return () => undos.push(report)
|
||||
}
|
||||
built.installErrorReporter(() => {})()
|
||||
expect(installed).toHaveLength(1)
|
||||
expect(undos).toHaveLength(1)
|
||||
expect(window.onerror).toBe(null)
|
||||
} finally {
|
||||
window.onerror = previous
|
||||
}
|
||||
})
|
||||
|
||||
it('reports no webgl addon as a DOM-renderer fallback rather than as a failure', () => {
|
||||
withSeams({ createWebglAddon: () => null }, () => {
|
||||
expect(attachWebglAddon(true)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -30,24 +30,23 @@ export function handleIncomingMessage(e: Event & { data?: TerminalHostMessage |
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleIncomingMessage)
|
||||
|
||||
document.addEventListener('message', handleIncomingMessage)
|
||||
|
||||
window.addEventListener('resize', function () {
|
||||
// Why: viewport changed (keyboard open/close, orientation, RN container
|
||||
// size update). Re-fit so the scale matches the new vpWidth — without
|
||||
// this, opening the keyboard leaves the terminal at the old scale even
|
||||
// though there's now less vertical room and the fit ratio may differ.
|
||||
applyFitScale('window-resize')
|
||||
adjustRowsForViewport()
|
||||
repositionOverlay()
|
||||
clampPan()
|
||||
updateTransform()
|
||||
})
|
||||
|
||||
if (window.Terminal) {
|
||||
notify({ type: 'web-ready' })
|
||||
} else {
|
||||
reportEngineError('terminal engine missing', 'xterm failed to load', true)
|
||||
export function startMessageBridge() {
|
||||
window.addEventListener('message', handleIncomingMessage)
|
||||
document.addEventListener('message', handleIncomingMessage)
|
||||
window.addEventListener('resize', function () {
|
||||
// Why: viewport changed (keyboard open/close, orientation, RN container
|
||||
// size update). Re-fit so the scale matches the new vpWidth — without
|
||||
// this, opening the keyboard leaves the terminal at the old scale even
|
||||
// though there's now less vertical room and the fit ratio may differ.
|
||||
applyFitScale('window-resize')
|
||||
adjustRowsForViewport()
|
||||
repositionOverlay()
|
||||
clampPan()
|
||||
updateTransform()
|
||||
})
|
||||
if (window.Terminal) {
|
||||
notify({ type: 'web-ready' })
|
||||
} else {
|
||||
reportEngineError('terminal engine missing', 'xterm failed to load', true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,10 +37,3 @@ export function emitModesIfChanged() {
|
||||
})
|
||||
}
|
||||
}
|
||||
scope.lastEmittedModes = {
|
||||
bracketedPasteMode: false,
|
||||
altScreen: false,
|
||||
mouseTrackingMode: 'none',
|
||||
sgrMouseMode: false,
|
||||
sgrMousePixelsMode: false
|
||||
}
|
||||
|
||||
@@ -20,8 +20,6 @@ export type TerminalMouseGesture = {
|
||||
dismissedSelection: boolean
|
||||
}
|
||||
|
||||
let mouseGesture: TerminalMouseGesture | null = null
|
||||
|
||||
// One report per transition, built with the same encoding ladder as
|
||||
// buildMouseClickInput: SGR pixels (1016) > SGR (1006) > default. Returns ''
|
||||
// when the mode does not report this transition (x10 has no release, only
|
||||
@@ -81,8 +79,8 @@ export function mouseReportCellKey(clientX: number, clientY: number) {
|
||||
}
|
||||
|
||||
export function abandonMouseGesture() {
|
||||
const gesture = mouseGesture
|
||||
mouseGesture = null
|
||||
const gesture = scope.mouseGesture
|
||||
scope.mouseGesture = null
|
||||
if (!gesture) {
|
||||
return
|
||||
}
|
||||
@@ -141,7 +139,7 @@ export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) {
|
||||
}
|
||||
// Why: a pointerup lost outside the WebView must not leave the previous
|
||||
// gesture latched (tracking press with no release) when the next one lands.
|
||||
if (mouseGesture) {
|
||||
if (scope.mouseGesture) {
|
||||
abandonMouseGesture()
|
||||
}
|
||||
// Why: mouse pointers have no implicit capture; without it a drag that
|
||||
@@ -151,7 +149,7 @@ export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) {
|
||||
targetSurface.setPointerCapture(e.pointerId)
|
||||
}
|
||||
} catch {}
|
||||
mouseGesture = {
|
||||
scope.mouseGesture = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
lastX: e.clientX,
|
||||
@@ -165,7 +163,7 @@ export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) {
|
||||
// Why: touch parity — pressing outside the pill dismisses the current
|
||||
// selection; the same press may still start a new drag selection.
|
||||
cancelSelect()
|
||||
mouseGesture.dismissedSelection = true
|
||||
scope.mouseGesture.dismissedSelection = true
|
||||
}
|
||||
},
|
||||
true
|
||||
@@ -174,7 +172,7 @@ export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) {
|
||||
targetSurface.addEventListener(
|
||||
'pointermove',
|
||||
function (e) {
|
||||
const gesture = mouseGesture
|
||||
const gesture = scope.mouseGesture
|
||||
if (e.pointerType !== 'mouse' || !gesture || gesture.mode === 'cancelled') {
|
||||
return
|
||||
}
|
||||
@@ -220,11 +218,11 @@ export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) {
|
||||
targetSurface.addEventListener(
|
||||
'pointerup',
|
||||
function (e) {
|
||||
const gesture = mouseGesture
|
||||
const gesture = scope.mouseGesture
|
||||
if (e.pointerType !== 'mouse' || !gesture || e.button !== 0) {
|
||||
return
|
||||
}
|
||||
mouseGesture = null
|
||||
scope.mouseGesture = null
|
||||
if (gesture.mode === 'cancelled' || !scope.term) {
|
||||
return
|
||||
}
|
||||
@@ -274,7 +272,7 @@ export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) {
|
||||
targetSurface.addEventListener(
|
||||
'touchstart',
|
||||
function () {
|
||||
if (mouseGesture) {
|
||||
if (scope.mouseGesture) {
|
||||
abandonMouseGesture()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getCellHeight } from './fit-scale'
|
||||
import { getTotalScale, updateScrollIndicator } from './viewport-transform'
|
||||
import { scope } from './document-scope'
|
||||
import { scope, scheduleDocumentFrame } from './document-scope'
|
||||
|
||||
export function clampNormalScrollLines(lines: number) {
|
||||
if (!scope.term || !scope.term.buffer || !scope.term.buffer.active || lines === 0) {
|
||||
@@ -77,7 +77,7 @@ export function enqueueNormalBufferScrollDelta(deltaY: number) {
|
||||
// Why: dense terminal rows are expensive to repaint. Coalesce touchmove
|
||||
// deltas into one xterm row-scroll per frame instead of repainting from
|
||||
// the input event stream.
|
||||
scope.normalScrollFrameId = requestAnimationFrame(function () {
|
||||
scope.normalScrollFrameId = scheduleDocumentFrame(function () {
|
||||
scope.normalScrollFrameId = null
|
||||
const delta = scope.pendingNormalScrollDeltaY
|
||||
scope.pendingNormalScrollDeltaY = 0
|
||||
@@ -100,3 +100,8 @@ export function resetSmoothScrollOffset() {
|
||||
scope.smoothScrollOffsetY = 0
|
||||
updateScrollIndicator(false)
|
||||
}
|
||||
|
||||
/** Ruling 21: the smooth-scroll frame, which would otherwise scroll the next mount's buffer. */
|
||||
export function stopNormalBufferSmoothScroll() {
|
||||
resetSmoothScrollOffset()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { terminalDocumentStartCalls } from '../../../scripts/build-terminal-document-script.mjs'
|
||||
import {
|
||||
TERMINAL_DOCUMENT_HOST_SEAMS_MODULE,
|
||||
TERMINAL_DOCUMENT_MODULE_ORDER,
|
||||
TERMINAL_DOCUMENT_SCOPE_MODULE
|
||||
} from '../../../scripts/terminal-document-module-order.mjs'
|
||||
|
||||
/**
|
||||
* The page runs the document's modules in the order the WebView's script runs them.
|
||||
*
|
||||
* It has to: the document is one function scope, so `runtime-constants` taking the surface before
|
||||
* `surface-swap` captures it is not a dependency the graph records. Inside the WebView the
|
||||
* generator reads the order from one file; on the page the order is the import list in
|
||||
* `page-document-modules.ts`, and nothing but this holds the two together. A formatter that sorted
|
||||
* that list, or a module added to the generator and not to the page, would leave both sides green
|
||||
* and the page running a different program.
|
||||
*
|
||||
* Read as text rather than by importing the module, because importing it would run the document
|
||||
* against an empty body and prove only that the file parses.
|
||||
*/
|
||||
const pageEntry = readFileSync(new URL('./page-document-modules.ts', import.meta.url), 'utf8')
|
||||
|
||||
/** `message-bridge` is ruling 19's exclusion: on the page those frames belong to the shell. */
|
||||
const EXCLUDED = ['message-bridge']
|
||||
|
||||
function importedModules(): string[] {
|
||||
return [...pageEntry.matchAll(/^import (?:\{[^}]*\} from )?'\.\/([a-z0-9-]+)'$/gm)].map(
|
||||
(match) => match[1]!
|
||||
)
|
||||
}
|
||||
|
||||
describe('the page entry for the terminal document', () => {
|
||||
it('imports every module the generator emits, in the same order, minus the bridge', () => {
|
||||
expect(importedModules()).toEqual([
|
||||
TERMINAL_DOCUMENT_HOST_SEAMS_MODULE,
|
||||
TERMINAL_DOCUMENT_SCOPE_MODULE,
|
||||
...TERMINAL_DOCUMENT_MODULE_ORDER.filter((name) => !EXCLUDED.includes(name))
|
||||
])
|
||||
})
|
||||
|
||||
it('names its exclusion, and the exclusion is a module the generator does emit', () => {
|
||||
for (const name of EXCLUDED) {
|
||||
expect(TERMINAL_DOCUMENT_MODULE_ORDER).toContain(name)
|
||||
expect(importedModules()).not.toContain(name)
|
||||
}
|
||||
})
|
||||
|
||||
it('calls the same start sequence the generated document calls, minus the bridge', async () => {
|
||||
// Ruling 20's other half. The import list above only proves the page reaches the same
|
||||
// modules; what runs is the call sequence, and the generator writes its own from the same
|
||||
// sources. A module that grows a start function and is not called here would leave the page
|
||||
// with an element nobody read.
|
||||
const sequence = [...pageEntry.matchAll(/^ {2,4}(start[A-Za-z]+)\(\)$/gm)].map(
|
||||
(match) => match[1]!
|
||||
)
|
||||
const emitted = await terminalDocumentStartCalls([
|
||||
TERMINAL_DOCUMENT_HOST_SEAMS_MODULE,
|
||||
TERMINAL_DOCUMENT_SCOPE_MODULE,
|
||||
...TERMINAL_DOCUMENT_MODULE_ORDER
|
||||
])
|
||||
expect(sequence.length).toBeGreaterThan(0)
|
||||
expect(sequence).toEqual(emitted.filter((name) => name !== 'startMessageBridge'))
|
||||
})
|
||||
|
||||
it('would report a reordered list', () => {
|
||||
// The precondition for the first case: a matcher that found nothing would agree with an empty
|
||||
// expectation just as happily. Swapping the first two names must break it.
|
||||
const [first, second, ...rest] = importedModules()
|
||||
expect([second, first, ...rest]).not.toEqual([
|
||||
TERMINAL_DOCUMENT_HOST_SEAMS_MODULE,
|
||||
TERMINAL_DOCUMENT_SCOPE_MODULE,
|
||||
...TERMINAL_DOCUMENT_MODULE_ORDER.filter((name) => !EXCLUDED.includes(name))
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* The document's modules, and the two sequences that start and stop them.
|
||||
*
|
||||
* The document is one function scope, not a dependency graph: `runtime-constants` takes the
|
||||
* surface, `surface-swap` captures the surface it was handed, and `selection-state-and-eviction`
|
||||
* takes the overlay elements. Ruling 20 moved those out of the module bodies into start
|
||||
* functions; ruling 21 moved every mutable binding onto the scope, so what is left at module top
|
||||
* level is constants, functions and types. Importing this file therefore does nothing at all.
|
||||
*
|
||||
* That is what makes a second mount a second terminal. ES module bodies run once per page, so a
|
||||
* remount re-imports nothing: it resets the scope, then runs the same start sequence the WebView's
|
||||
* generated script runs once at parse, against the markup the host has just replanted.
|
||||
*
|
||||
* `message-bridge` is deliberately absent (ruling 19). It installs `window`/`document` `message`
|
||||
* listeners, and on the page those frames belong to the shell: the document would read a bridge
|
||||
* envelope as a terminal command. The component calls `handleMsg` instead, and re-arms the one
|
||||
* other thing that module does, the window-resize refit.
|
||||
*
|
||||
* `page-document-module-order.test.ts` holds these lists against
|
||||
* `scripts/terminal-document-module-order.mjs`, so the page and the WebView cannot run different
|
||||
* programs and a reordering edit cannot pass unread.
|
||||
*/
|
||||
import './document-host-seams'
|
||||
import { cancelDocumentFrames, resetTerminalDocumentScope } from './document-scope'
|
||||
import { startRuntimeConstants } from './runtime-constants'
|
||||
import './query-reply'
|
||||
import { startSurfaceSwap } from './surface-swap'
|
||||
import { startTextScaling } from './text-scaling'
|
||||
import { stopViewportTransform } from './viewport-transform'
|
||||
import './terminal-theme'
|
||||
import { stopFitScale } from './fit-scale'
|
||||
import './mouse-mode-decset-scan'
|
||||
import './write-queue'
|
||||
import { startWebglRecovery, stopWebglRecovery } from './webgl-recovery'
|
||||
import { stopTerminalInit } from './terminal-init'
|
||||
import './reflow'
|
||||
import { startHostNotify, stopHostNotify } from './host-notify'
|
||||
import './host-message-router'
|
||||
import { startSelectionStateAndEviction } from './selection-state-and-eviction'
|
||||
import './mode-mirroring'
|
||||
import './keyboard-avoidance-metrics'
|
||||
import './term-observers'
|
||||
import './viewport-cell'
|
||||
import './mouse-report-cell'
|
||||
import './mouse-input-encoding'
|
||||
import { stopNormalBufferSmoothScroll } from './normal-buffer-smooth-scroll'
|
||||
import './cell-geometry'
|
||||
import './path-tap'
|
||||
import './url-tap'
|
||||
import './osc-link-tap'
|
||||
import './surface-tap'
|
||||
import './selection-range'
|
||||
import { stopSelectionOverlay } from './selection-overlay'
|
||||
import { startTapDispatch, stopTapDispatch } from './tap-dispatch'
|
||||
import './wheel-scroll'
|
||||
import './mouse-click-drag'
|
||||
import { startSelectionMenuButtons } from './selection-menu-buttons'
|
||||
import { startSurfaceTouchGestures, stopSurfaceTouchGestures } from './surface-touch-gestures'
|
||||
|
||||
/**
|
||||
* The scope's reset and every module's start function, in module order: what the WebView's
|
||||
* document runs once as its script is parsed, run here once per mount.
|
||||
*/
|
||||
export function startPageDocumentModules() {
|
||||
resetTerminalDocumentScope()
|
||||
// Unwound if one of them throws: a start that completed has already taken a listener or
|
||||
// installed the reporter, and leaving those behind would outlive the mount that never happened.
|
||||
// Only the starts with an undo need recording; the rest write scope fields the next reset
|
||||
// overwrites.
|
||||
const undo: (() => void)[] = []
|
||||
try {
|
||||
startRuntimeConstants()
|
||||
startSurfaceSwap()
|
||||
startTextScaling()
|
||||
startWebglRecovery()
|
||||
undo.unshift(stopWebglRecovery)
|
||||
startHostNotify()
|
||||
undo.unshift(stopHostNotify)
|
||||
startSelectionStateAndEviction()
|
||||
startTapDispatch()
|
||||
undo.unshift(stopTapDispatch)
|
||||
startSelectionMenuButtons()
|
||||
startSurfaceTouchGestures()
|
||||
undo.unshift(stopSurfaceTouchGestures)
|
||||
} catch (error) {
|
||||
for (const stop of undo) {
|
||||
stop()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The undo, in reverse module order: every listener that outlives the host element, and every
|
||||
* frame, timer and retry a module scheduled (ruling 21).
|
||||
*/
|
||||
export function stopPageDocumentModules() {
|
||||
// Last frames first: a module's own stop nulls the handle it holds, and this takes back every
|
||||
// frame the document is still owed, including the ones no module tracks by id.
|
||||
cancelDocumentFrames()
|
||||
stopSurfaceTouchGestures()
|
||||
stopTapDispatch()
|
||||
stopSelectionOverlay()
|
||||
stopNormalBufferSmoothScroll()
|
||||
stopHostNotify()
|
||||
stopTerminalInit()
|
||||
stopWebglRecovery()
|
||||
stopFitScale()
|
||||
stopViewportTransform()
|
||||
}
|
||||
|
||||
export { scope } from './document-scope'
|
||||
export { handleMsg } from './host-message-router'
|
||||
export { adjustRowsForViewport, applyFitScale, clampPan } from './fit-scale'
|
||||
export { repositionOverlay } from './selection-overlay'
|
||||
export { updateTransform } from './viewport-transform'
|
||||
@@ -0,0 +1,60 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* A start sequence that throws leaves nothing of itself behind.
|
||||
*
|
||||
* The starts are not all writes to the scope: `startHostNotify` installs the host's error
|
||||
* reporter and `startTapDispatch` takes four document listeners. If one of the later starts
|
||||
* throws, the mount fails and its handle releases the page — but the reporter and the listeners
|
||||
* are already installed, and nothing else would reach them: the next mount's reset nulls the undo
|
||||
* the install handed back, so the listener would stay for the life of the tab.
|
||||
*
|
||||
* The provocation is the document's own markup with the selection menu missing, which is what
|
||||
* `startSelectionMenuButtons` reads and the only thing it does.
|
||||
*/
|
||||
const MARKUP_WITHOUT_THE_MENU =
|
||||
'<div id="terminal-container"><div id="terminal-surface"></div></div>' +
|
||||
'<div id="selection-overlay"><div id="sel-handle-start"></div>' +
|
||||
'<div id="sel-handle-end"></div></div>' +
|
||||
'<div id="scroll-indicator"><div id="scroll-thumb"></div></div>'
|
||||
|
||||
describe('the page start sequence', () => {
|
||||
it('unwinds the starts that completed when a later one throws', async () => {
|
||||
document.body.innerHTML = MARKUP_WITHOUT_THE_MENU
|
||||
const { startPageDocumentModules } = await import('./page-document-modules')
|
||||
const previous = window.onerror
|
||||
window.onerror = null
|
||||
try {
|
||||
expect(() => startPageDocumentModules()).toThrow()
|
||||
// `startHostNotify` ran and installed the default reporter, which takes `window.onerror`.
|
||||
// The unwind is the only thing that gives it back: the next mount's reset nulls the undo it
|
||||
// handed out, so an install left standing here is permanent.
|
||||
expect(window.onerror).toBe(null)
|
||||
} finally {
|
||||
window.onerror = previous
|
||||
}
|
||||
})
|
||||
|
||||
it('would have installed one, so the null above is a measurement', async () => {
|
||||
// The precondition. With the menu present the same sequence completes, and the reporter it
|
||||
// installs is exactly what the case above asserts was taken back.
|
||||
document.body.innerHTML = MARKUP_WITHOUT_THE_MENU.replace(
|
||||
'<div id="sel-handle-end"></div></div>',
|
||||
'<div id="sel-handle-end"></div><div id="sel-menu">' +
|
||||
'<button id="sel-menu-copy"></button><button id="sel-menu-all"></button></div></div>'
|
||||
)
|
||||
const { startPageDocumentModules, stopPageDocumentModules } =
|
||||
await import('./page-document-modules')
|
||||
const previous = window.onerror
|
||||
window.onerror = null
|
||||
try {
|
||||
startPageDocumentModules()
|
||||
expect(window.onerror).not.toBe(null)
|
||||
stopPageDocumentModules()
|
||||
expect(window.onerror).toBe(null)
|
||||
} finally {
|
||||
window.onerror = previous
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -18,20 +18,16 @@ export type QueryReplyTerminal = {
|
||||
onData: (listener: (data: string) => void) => TerminalDocumentDisposable
|
||||
}
|
||||
|
||||
// Written from four places, all of them here, so it is this module's state rather than the
|
||||
// document's and stays a local.
|
||||
let terminalDataRepliesEnabled = false
|
||||
|
||||
export function resetTerminalDataReplyAuthority() {
|
||||
terminalDataRepliesEnabled = false
|
||||
scope.terminalDataRepliesEnabled = false
|
||||
}
|
||||
|
||||
export function resumeTerminalDataReplyAuthority() {
|
||||
terminalDataRepliesEnabled = true
|
||||
scope.terminalDataRepliesEnabled = true
|
||||
}
|
||||
|
||||
export function forwardTerminalDataReply(data: string) {
|
||||
if (terminalDataRepliesEnabled) {
|
||||
if (scope.terminalDataRepliesEnabled) {
|
||||
notify({ type: 'terminal-data', bytes: data })
|
||||
}
|
||||
}
|
||||
@@ -39,7 +35,7 @@ export function forwardTerminalDataReply(data: string) {
|
||||
export function enqueueTerminalDataReplyBoundary(gen: number) {
|
||||
enqueueWriteBoundary(function () {
|
||||
if (gen === scope.terminalGeneration) {
|
||||
terminalDataRepliesEnabled = true
|
||||
scope.terminalDataRepliesEnabled = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,18 +6,7 @@ import { scope } from './document-scope'
|
||||
* All eight are read by other parts of the script, so all eight are scope fields; the document
|
||||
* shell opens the function they live in and `document-close.ts` closes it.
|
||||
*/
|
||||
scope.surface = document.getElementById('terminal-surface')
|
||||
scope.ESC = String.fromCharCode(27)
|
||||
scope.C1_CSI = String.fromCharCode(155)
|
||||
scope.CLAUDE_STATUS_DOT = String.fromCharCode(0x23fa)
|
||||
scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e)
|
||||
scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f)
|
||||
scope.CLAUDE_STATUS_DOT_PATTERN = new RegExp(
|
||||
scope.CLAUDE_STATUS_DOT +
|
||||
'[' +
|
||||
scope.TEXT_PRESENTATION_SELECTOR +
|
||||
scope.EMOJI_PRESENTATION_SELECTOR +
|
||||
']*',
|
||||
'g'
|
||||
)
|
||||
scope.statusDotPendingSelector = false
|
||||
|
||||
export function startRuntimeConstants() {
|
||||
scope.surface = document.getElementById('terminal-surface')
|
||||
}
|
||||
|
||||
@@ -3,34 +3,35 @@ import { notify } from './host-notify'
|
||||
import { cancelSelect } from './selection-range'
|
||||
import { repositionOverlay } from './selection-overlay'
|
||||
|
||||
scope.btnCopy!.addEventListener('click', function (e) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!scope.term) {
|
||||
return
|
||||
}
|
||||
const text = scope.term.getSelection ? scope.term.getSelection() : ''
|
||||
if (text && text.length > 0) {
|
||||
notify({ type: 'selection', text: text })
|
||||
} else {
|
||||
cancelSelect()
|
||||
}
|
||||
})
|
||||
|
||||
scope.btnSelAll!.addEventListener('click', function (e) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!scope.term) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
scope.term.selectAll()
|
||||
const b = scope.term.buffer.active
|
||||
scope.sel = {
|
||||
anchor: { col: 0, row: 0 },
|
||||
focus: { col: scope.term.cols - 1, row: b.length - 1 },
|
||||
activeHandle: null
|
||||
export function startSelectionMenuButtons() {
|
||||
scope.btnCopy!.addEventListener('click', function (e) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!scope.term) {
|
||||
return
|
||||
}
|
||||
repositionOverlay()
|
||||
} catch {}
|
||||
})
|
||||
const text = scope.term.getSelection ? scope.term.getSelection() : ''
|
||||
if (text && text.length > 0) {
|
||||
notify({ type: 'selection', text: text })
|
||||
} else {
|
||||
cancelSelect()
|
||||
}
|
||||
})
|
||||
scope.btnSelAll!.addEventListener('click', function (e) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!scope.term) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
scope.term.selectAll()
|
||||
const b = scope.term.buffer.active
|
||||
scope.sel = {
|
||||
anchor: { col: 0, row: 0 },
|
||||
focus: { col: scope.term.cols - 1, row: b.length - 1 },
|
||||
activeHandle: null
|
||||
}
|
||||
repositionOverlay()
|
||||
} catch {}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -139,3 +139,8 @@ export function handleDragMove(handle: string, clientX: number, clientY: number)
|
||||
}
|
||||
|
||||
// Latching document-level touch dispatcher: see tap-dispatch.ts.
|
||||
|
||||
/** Ruling 21: the edge-scroll interval, which outlives the selection that started it. */
|
||||
export function stopSelectionOverlay() {
|
||||
stopEdgeScroll()
|
||||
}
|
||||
|
||||
@@ -6,55 +6,36 @@ import { scope } from './document-scope'
|
||||
// ============================================================
|
||||
// SELECTION MODE (long-press → handles → Copy)
|
||||
// ============================================================
|
||||
scope.WORD_RE = /[\p{L}\p{N}_./:@~+=?&#%-]/u
|
||||
scope.LONG_PRESS_MS = 500
|
||||
scope.LONG_PRESS_SLOP = 10
|
||||
|
||||
// Why: a tap that opens a link/path must survive small finger jitter. The
|
||||
// long-press slop (10px) only cancels the press-to-select timer; reusing it
|
||||
// to gate the tap dropped any URL/file tap that wandered >10px — at fit scale
|
||||
// a few screen px of jitter is a normal tap. Use a wider, time-bounded tap
|
||||
// window so deliberate scrolls/pans still don't fire a tap.
|
||||
scope.TAP_SLOP = 24
|
||||
scope.TAP_MAX_MS = 700
|
||||
scope.EDGE_SCROLL_PX = 40
|
||||
scope.EDGE_SCROLL_INTERVAL = 60
|
||||
|
||||
scope.selectionOverlay = document.getElementById('selection-overlay')
|
||||
scope.handleStart = document.getElementById('sel-handle-start')
|
||||
scope.handleEnd = document.getElementById('sel-handle-end')
|
||||
scope.selMenu = document.getElementById('sel-menu')
|
||||
scope.btnCopy = document.getElementById('sel-menu-copy')
|
||||
scope.btnSelAll = document.getElementById('sel-menu-all')
|
||||
|
||||
// mode: 'navigate' | 'select'
|
||||
scope.selMode = 'navigate'
|
||||
scope.sel = null // { anchor:{col,row}, focus:{col,row}, activeHandle:null|'start'|'end' }
|
||||
scope.longPressTimer = null
|
||||
scope.longPressOrigin = null // {x,y, identifier}
|
||||
|
||||
// { anchor:{col,row}, focus:{col,row}, activeHandle:null|'start'|'end' }
|
||||
|
||||
// {x,y, identifier}
|
||||
// Why: tap detection is tracked separately from the long-press timer so a
|
||||
// small jitter that cancels the press-to-select timer does not also cancel
|
||||
// the tap (which opens links/paths). {x,y,t,identifier} or null once the
|
||||
// gesture is disqualified as a tap (moved too far or held too long).
|
||||
scope.tapCandidate = null
|
||||
scope.edgeScrollTimer = null
|
||||
scope.edgeScrollDir = 0
|
||||
scope.edgeScrollClientX = 0
|
||||
scope.edgeScrollClientY = 0
|
||||
|
||||
// Eviction watchdog: linesEverWritten counts onLineFeed since last init.
|
||||
// Eviction watchdog: linesEverWritten counts onLineFeed since the last init.
|
||||
// Once buffer is full, every onLineFeed evicts the top row in xterm and
|
||||
// we mirror that by decrementing stored absolute rows.
|
||||
let linesEverWritten = 0
|
||||
|
||||
export function resetEvictionCounter() {
|
||||
linesEverWritten = 0
|
||||
scope.linesEverWritten = 0
|
||||
}
|
||||
|
||||
export function isBufferFull() {
|
||||
if (!scope.term) {
|
||||
return false
|
||||
}
|
||||
return linesEverWritten >= 5000 + (scope.term.rows || 0)
|
||||
return scope.linesEverWritten >= 5000 + (scope.term.rows || 0)
|
||||
}
|
||||
|
||||
export function checkEviction() {
|
||||
@@ -69,7 +50,7 @@ export function checkEviction() {
|
||||
}
|
||||
|
||||
export function logFeedAndEvict() {
|
||||
linesEverWritten++
|
||||
scope.linesEverWritten++
|
||||
if (scope.initialOscLinkEvictionReady && isBufferFull()) {
|
||||
scope.initialOscLinkRowOffset += 1
|
||||
}
|
||||
@@ -80,3 +61,12 @@ export function logFeedAndEvict() {
|
||||
repositionOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
export function startSelectionStateAndEviction() {
|
||||
scope.selectionOverlay = document.getElementById('selection-overlay')
|
||||
scope.handleStart = document.getElementById('sel-handle-start')
|
||||
scope.handleEnd = document.getElementById('sel-handle-end')
|
||||
scope.selMenu = document.getElementById('sel-menu')
|
||||
scope.btnCopy = document.getElementById('sel-menu-copy')
|
||||
scope.btnSelAll = document.getElementById('sel-menu-all')
|
||||
}
|
||||
|
||||
@@ -9,31 +9,24 @@ export type TerminalSurfaceSwap = {
|
||||
nextSurface: HTMLElement
|
||||
}
|
||||
|
||||
// Why: phone-fit startup can issue several init() calls before xterm finishes
|
||||
// replaying. Track the last painted surface separately from its replacement.
|
||||
let committedTerm: TerminalDocumentTerminal | null = null
|
||||
let committedSurface = scope.surface
|
||||
scope.pendingTerm = null
|
||||
let pendingSurface: HTMLElement | null = null
|
||||
|
||||
export function beginTerminalSurfaceSwap() {
|
||||
// Why: a superseded hidden replacement must not remain between the last
|
||||
// painted surface and the newest one, or the newest commits below the viewport.
|
||||
if (pendingSurface) {
|
||||
if (scope.pendingSurface) {
|
||||
try {
|
||||
pendingSurface.remove()
|
||||
scope.pendingSurface.remove()
|
||||
} catch {}
|
||||
if (scope.pendingTerm) {
|
||||
try {
|
||||
scope.pendingTerm.dispose()
|
||||
} catch {}
|
||||
}
|
||||
pendingSurface = null
|
||||
scope.pendingSurface = null
|
||||
scope.pendingTerm = null
|
||||
}
|
||||
const swap = {
|
||||
oldTerm: committedTerm,
|
||||
oldSurface: committedSurface,
|
||||
oldTerm: scope.committedTerm,
|
||||
oldSurface: scope.committedSurface,
|
||||
nextSurface: document.createElement('div')
|
||||
}
|
||||
disposeTermObservers()
|
||||
@@ -44,7 +37,7 @@ export function beginTerminalSurfaceSwap() {
|
||||
swap.nextSurface.style.top = '0'
|
||||
document.getElementById('terminal-container')!.appendChild(swap.nextSurface)
|
||||
scope.surface = swap.nextSurface
|
||||
pendingSurface = swap.nextSurface
|
||||
scope.pendingSurface = swap.nextSurface
|
||||
attachSurfaceEventHandlers(scope.surface)
|
||||
swap.oldSurface!.removeAttribute('id')
|
||||
return swap
|
||||
@@ -62,8 +55,15 @@ export function commitTerminalSurfaceSwap(
|
||||
if (swap.oldTerm) {
|
||||
swap.oldTerm.dispose()
|
||||
}
|
||||
committedTerm = nextTerm
|
||||
committedSurface = swap.nextSurface
|
||||
scope.committedTerm = nextTerm
|
||||
scope.committedSurface = swap.nextSurface
|
||||
scope.pendingTerm = null
|
||||
pendingSurface = null
|
||||
scope.pendingSurface = null
|
||||
}
|
||||
|
||||
// Why: phone-fit startup can issue several init() calls before xterm finishes replaying, so the
|
||||
// last painted surface is tracked apart from its replacement — on the scope (ruling 21), because
|
||||
// the page mounts this module more than once and a second mount must not inherit the first's.
|
||||
export function startSurfaceSwap() {
|
||||
scope.committedSurface = scope.surface
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { scope } from './document-scope'
|
||||
import { scope, scheduleDocumentFrame } from './document-scope'
|
||||
import { clampPan, getCellHeight } from './fit-scale'
|
||||
import { notify } from './host-notify'
|
||||
import { attachSurfaceMouseClickDragHandler } from './mouse-click-drag'
|
||||
@@ -17,7 +17,7 @@ import { attachSurfaceWheelHandler } from './wheel-scroll'
|
||||
type TerminalGestureSurface = HTMLElement & { __orcaSurfaceHandlersAttached?: boolean }
|
||||
|
||||
/** The live touch gesture: the last point, the velocity, and the pinch it may be in. */
|
||||
type TerminalTouchState = {
|
||||
export type TerminalTouchState = {
|
||||
lastX: number
|
||||
lastY: number
|
||||
lastTime: number
|
||||
@@ -31,20 +31,6 @@ type TerminalTouchState = {
|
||||
pinchSurfY: number
|
||||
}
|
||||
|
||||
const ts: TerminalTouchState = {
|
||||
lastX: 0,
|
||||
lastY: 0,
|
||||
lastTime: 0,
|
||||
velY: 0,
|
||||
accumDelta: 0,
|
||||
momentumId: null,
|
||||
isPinching: false,
|
||||
pinchDist: 0,
|
||||
pinchScale: 0,
|
||||
pinchSurfX: 0,
|
||||
pinchSurfY: 0
|
||||
}
|
||||
|
||||
export function updateTouchVelocity(deltaY: number, dt: number) {
|
||||
if (dt <= 0) {
|
||||
return
|
||||
@@ -55,7 +41,10 @@ export function updateTouchVelocity(deltaY: number, dt: number) {
|
||||
}
|
||||
// Why: touchmove cadence is uneven in WebView. Blend recent samples so
|
||||
// momentum launch doesn't inherit a one-frame spike or stall.
|
||||
ts.velY = ts.velY === 0 ? instantVelocity : ts.velY * 0.55 + instantVelocity * 0.45
|
||||
scope.touchGesture.velY =
|
||||
scope.touchGesture.velY === 0
|
||||
? instantVelocity
|
||||
: scope.touchGesture.velY * 0.55 + instantVelocity * 0.45
|
||||
}
|
||||
|
||||
export function getDistance(a: Touch, b: Touch) {
|
||||
@@ -97,27 +86,27 @@ export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface
|
||||
if (dispatcherShouldBlockSurface()) {
|
||||
return
|
||||
}
|
||||
if (ts.momentumId) {
|
||||
cancelAnimationFrame(ts.momentumId)
|
||||
ts.momentumId = null
|
||||
if (scope.touchGesture.momentumId) {
|
||||
cancelAnimationFrame(scope.touchGesture.momentumId)
|
||||
scope.touchGesture.momentumId = null
|
||||
}
|
||||
if (e.touches.length === 2) {
|
||||
ts.isPinching = true
|
||||
scope.touchGesture.isPinching = true
|
||||
scope.smoothScrollOffsetY = 0
|
||||
ts.pinchDist = getDistance(e.touches[0], e.touches[1])
|
||||
ts.pinchScale = scope.userScale
|
||||
scope.touchGesture.pinchDist = getDistance(e.touches[0], e.touches[1])
|
||||
scope.touchGesture.pinchScale = scope.userScale
|
||||
const mx = (e.touches[0].clientX + e.touches[1].clientX) / 2
|
||||
const my = (e.touches[0].clientY + e.touches[1].clientY) / 2
|
||||
const total = getTotalScale()
|
||||
ts.pinchSurfX = (mx - scope.panX) / total
|
||||
ts.pinchSurfY = (my - scope.panY) / total
|
||||
scope.touchGesture.pinchSurfX = (mx - scope.panX) / total
|
||||
scope.touchGesture.pinchSurfY = (my - scope.panY) / total
|
||||
} else if (e.touches.length === 1) {
|
||||
ts.isPinching = false
|
||||
ts.lastX = e.touches[0].clientX
|
||||
ts.lastY = e.touches[0].clientY
|
||||
ts.lastTime = Date.now()
|
||||
ts.velY = 0
|
||||
ts.accumDelta = 0
|
||||
scope.touchGesture.isPinching = false
|
||||
scope.touchGesture.lastX = e.touches[0].clientX
|
||||
scope.touchGesture.lastY = e.touches[0].clientY
|
||||
scope.touchGesture.lastTime = Date.now()
|
||||
scope.touchGesture.velY = 0
|
||||
scope.touchGesture.accumDelta = 0
|
||||
}
|
||||
},
|
||||
{ capture: true, passive: true }
|
||||
@@ -136,28 +125,31 @@ export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface
|
||||
e.stopPropagation()
|
||||
|
||||
if (e.touches.length === 2) {
|
||||
ts.isPinching = true
|
||||
scope.touchGesture.isPinching = true
|
||||
const dist = getDistance(e.touches[0], e.touches[1])
|
||||
const mx = (e.touches[0].clientX + e.touches[1].clientX) / 2
|
||||
const my = (e.touches[0].clientY + e.touches[1].clientY) / 2
|
||||
|
||||
const ratio = dist / ts.pinchDist
|
||||
const ratio = dist / scope.touchGesture.pinchDist
|
||||
// Why: userScale is a CSS multiplier on the current font size; bound it so
|
||||
// the resulting apparent size (currentTextScale × userScale) stays within
|
||||
// the preset range, since release snaps to one of those presets.
|
||||
const loScale = scope.MIN_TEXT_SCALE / scope.currentTextScale
|
||||
const hiScale = scope.MAX_TEXT_SCALE / scope.currentTextScale
|
||||
scope.userScale = Math.max(loScale, Math.min(hiScale, ts.pinchScale * ratio))
|
||||
scope.userScale = Math.max(
|
||||
loScale,
|
||||
Math.min(hiScale, scope.touchGesture.pinchScale * ratio)
|
||||
)
|
||||
const total = getTotalScale()
|
||||
scope.panX = mx - ts.pinchSurfX * total
|
||||
scope.panY = my - ts.pinchSurfY * total
|
||||
scope.panX = mx - scope.touchGesture.pinchSurfX * total
|
||||
scope.panY = my - scope.touchGesture.pinchSurfY * total
|
||||
clampPan()
|
||||
updateTransform()
|
||||
} else if (e.touches.length === 1 && !ts.isPinching) {
|
||||
} else if (e.touches.length === 1 && !scope.touchGesture.isPinching) {
|
||||
const x = e.touches[0].clientX,
|
||||
y = e.touches[0].clientY
|
||||
const now = Date.now(),
|
||||
dt = now - ts.lastTime
|
||||
dt = now - scope.touchGesture.lastTime
|
||||
|
||||
// Why: pan horizontally only when content overflows the viewport (larger
|
||||
// than fit) — same check clampPan() uses. Vertical always drives buffer
|
||||
@@ -168,32 +160,32 @@ export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface
|
||||
scope.term.element &&
|
||||
scope.term.element.scrollWidth * getTotalScale() > window.innerWidth + 1
|
||||
) {
|
||||
scope.panX += x - ts.lastX
|
||||
scope.panX += x - scope.touchGesture.lastX
|
||||
clampPan()
|
||||
updateTransform()
|
||||
}
|
||||
|
||||
const deltaY = ts.lastY - y
|
||||
ts.lastTime = now
|
||||
const deltaY = scope.touchGesture.lastY - y
|
||||
scope.touchGesture.lastTime = now
|
||||
if (shouldRouteScrollToTerminalInput()) {
|
||||
updateTouchVelocity(deltaY, dt)
|
||||
resetSmoothScrollOffset()
|
||||
const effectiveCellH = getCellHeight() * getTotalScale()
|
||||
ts.accumDelta += deltaY
|
||||
const lines = Math.trunc(ts.accumDelta / effectiveCellH)
|
||||
scope.touchGesture.accumDelta += deltaY
|
||||
const lines = Math.trunc(scope.touchGesture.accumDelta / effectiveCellH)
|
||||
if (lines !== 0) {
|
||||
ts.accumDelta -= lines * effectiveCellH
|
||||
scope.touchGesture.accumDelta -= lines * effectiveCellH
|
||||
routeScrollLines(lines, x, y)
|
||||
}
|
||||
} else {
|
||||
if (enqueueNormalBufferScrollDelta(deltaY)) {
|
||||
updateTouchVelocity(deltaY, dt)
|
||||
} else {
|
||||
ts.velY = 0
|
||||
scope.touchGesture.velY = 0
|
||||
}
|
||||
}
|
||||
ts.lastX = x
|
||||
ts.lastY = y
|
||||
scope.touchGesture.lastX = x
|
||||
scope.touchGesture.lastY = y
|
||||
}
|
||||
},
|
||||
{ capture: true, passive: false }
|
||||
@@ -209,8 +201,8 @@ export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface
|
||||
return
|
||||
}
|
||||
|
||||
if (ts.isPinching && e.touches.length < 2) {
|
||||
ts.isPinching = false
|
||||
if (scope.touchGesture.isPinching && e.touches.length < 2) {
|
||||
scope.touchGesture.isPinching = false
|
||||
// Why: a finished pinch snaps to the nearest preset and becomes the new
|
||||
// font size (reflowing the grid), so pinch-to-zoom IS the in-terminal way
|
||||
// to set the text size. The CSS pinch zoom (userScale) is reset; the real
|
||||
@@ -227,45 +219,45 @@ export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface
|
||||
notify({ type: 'haptic', kind: 'selection' })
|
||||
}
|
||||
if (e.touches.length === 1) {
|
||||
ts.lastX = e.touches[0].clientX
|
||||
ts.lastY = e.touches[0].clientY
|
||||
ts.lastTime = Date.now()
|
||||
ts.velY = 0
|
||||
ts.accumDelta = 0
|
||||
scope.touchGesture.lastX = e.touches[0].clientX
|
||||
scope.touchGesture.lastY = e.touches[0].clientY
|
||||
scope.touchGesture.lastTime = Date.now()
|
||||
scope.touchGesture.velY = 0
|
||||
scope.touchGesture.accumDelta = 0
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (e.touches.length === 0) {
|
||||
let vel = ts.velY
|
||||
let vel = scope.touchGesture.velY
|
||||
const FRICTION = 0.972
|
||||
const MIN_VEL = 0.012
|
||||
function momentumStep() {
|
||||
vel *= FRICTION
|
||||
if (Math.abs(vel) < MIN_VEL) {
|
||||
ts.momentumId = null
|
||||
scope.touchGesture.momentumId = null
|
||||
return
|
||||
}
|
||||
const delta = vel * 16
|
||||
if (shouldRouteScrollToTerminalInput()) {
|
||||
resetSmoothScrollOffset()
|
||||
const effectiveCellH = getCellHeight() * getTotalScale()
|
||||
ts.accumDelta += delta
|
||||
const lines = Math.trunc(ts.accumDelta / effectiveCellH)
|
||||
scope.touchGesture.accumDelta += delta
|
||||
const lines = Math.trunc(scope.touchGesture.accumDelta / effectiveCellH)
|
||||
if (lines !== 0) {
|
||||
ts.accumDelta -= lines * effectiveCellH
|
||||
routeScrollLines(lines, ts.lastX, ts.lastY)
|
||||
scope.touchGesture.accumDelta -= lines * effectiveCellH
|
||||
routeScrollLines(lines, scope.touchGesture.lastX, scope.touchGesture.lastY)
|
||||
}
|
||||
} else {
|
||||
if (!applyNormalBufferScrollDelta(delta)) {
|
||||
ts.momentumId = null
|
||||
scope.touchGesture.momentumId = null
|
||||
return
|
||||
}
|
||||
}
|
||||
ts.momentumId = requestAnimationFrame(momentumStep)
|
||||
scope.touchGesture.momentumId = scheduleDocumentFrame(momentumStep)
|
||||
}
|
||||
if (Math.abs(vel) > MIN_VEL) {
|
||||
ts.momentumId = requestAnimationFrame(momentumStep)
|
||||
scope.touchGesture.momentumId = scheduleDocumentFrame(momentumStep)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -273,4 +265,14 @@ export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface
|
||||
)
|
||||
}
|
||||
|
||||
attachSurfaceEventHandlers(scope.surface!)
|
||||
export function startSurfaceTouchGestures() {
|
||||
attachSurfaceEventHandlers(scope.surface!)
|
||||
}
|
||||
|
||||
/** Ruling 21: the momentum loop, which would keep scrolling into the terminal that replaced it. */
|
||||
export function stopSurfaceTouchGestures() {
|
||||
if (scope.touchGesture.momentumId !== null) {
|
||||
cancelAnimationFrame(scope.touchGesture.momentumId)
|
||||
scope.touchGesture.momentumId = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,13 +20,6 @@ export type TerminalTouchDispatch = {
|
||||
/** An element a target can be tested against; a method so a real element satisfies it. */
|
||||
type TerminalDocumentTargetContainer = { contains(other: EventTarget | null): boolean }
|
||||
|
||||
const dispatch: TerminalTouchDispatch = {
|
||||
mode: 'idle',
|
||||
touchId: null,
|
||||
touchIds: null,
|
||||
longPressFingerInsideOverlay: false
|
||||
}
|
||||
|
||||
export function touchById(touches: TouchList, id: number | null) {
|
||||
for (let i = 0; i < touches.length; i++) {
|
||||
if (touches[i].identifier === id) {
|
||||
@@ -81,168 +74,180 @@ export function touchSlopExceeded(t: Touch) {
|
||||
// Why: existing surface handlers stay attached to surface but we wrap
|
||||
// their entry to no-op when the dispatcher latches into select-drag.
|
||||
export function dispatcherShouldBlockSurface() {
|
||||
return dispatch.mode === 'select-drag'
|
||||
return scope.touchDispatch.mode === 'select-drag'
|
||||
}
|
||||
|
||||
document.addEventListener(
|
||||
'touchstart',
|
||||
function (e) {
|
||||
const t = e.touches[0]
|
||||
const target = e.target
|
||||
const onHandle = target === scope.handleStart || target === scope.handleEnd
|
||||
const inOverlay = targetInside(target, scope.selectionOverlay)
|
||||
const inSurface = targetInside(target, scope.surface)
|
||||
// Why: clear any stale tap candidate up front; only a fresh single-finger
|
||||
// surface touch (below) re-arms it, so handle drags / pinches / dismiss
|
||||
// taps never resolve as a link tap on touchend.
|
||||
scope.tapCandidate = null
|
||||
/**
|
||||
* The options each document handler is registered with, named so `stopTapDispatch` takes it off
|
||||
* with the identical `capture` flag it went on with.
|
||||
*/
|
||||
const CAPTURE_ACTIVE = { capture: true, passive: false }
|
||||
const CAPTURE_PASSIVE = { capture: true, passive: true }
|
||||
|
||||
if (e.touches.length === 2) {
|
||||
// pinch latch
|
||||
if (scope.selMode === 'select') {
|
||||
notify({ type: 'mobile-clip-cancel-by-pinch' })
|
||||
cancelSelect()
|
||||
}
|
||||
dispatch.mode = 'pinch'
|
||||
dispatch.touchIds = [e.touches[0].identifier, e.touches[1].identifier]
|
||||
clearLongPress()
|
||||
return
|
||||
}
|
||||
function onDocumentTouchStart(e: TouchEvent) {
|
||||
const t = e.touches[0]
|
||||
const target = e.target
|
||||
const onHandle = target === scope.handleStart || target === scope.handleEnd
|
||||
const inOverlay = targetInside(target, scope.selectionOverlay)
|
||||
const inSurface = targetInside(target, scope.surface)
|
||||
// Why: clear any stale tap candidate up front; only a fresh single-finger
|
||||
// surface touch (below) re-arms it, so handle drags / pinches / dismiss
|
||||
// taps never resolve as a link tap on touchend.
|
||||
scope.tapCandidate = null
|
||||
|
||||
if (onHandle && scope.selMode === 'select') {
|
||||
// start handle drag
|
||||
const handleName = target === scope.handleStart ? 'start' : 'end'
|
||||
scope.sel!.activeHandle = handleName
|
||||
dispatch.mode = 'select-drag'
|
||||
dispatch.touchId = t.identifier
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (inOverlay) {
|
||||
// tap on menu pill — let the buttons' own handlers fire
|
||||
return
|
||||
}
|
||||
|
||||
if (inSurface && scope.selMode === 'select') {
|
||||
// Why: tap-to-dismiss matches native iOS/Android — touching outside the
|
||||
// selection clears it. We cancel immediately and latch to 'surface' so
|
||||
// the same gesture still drives scroll/pan without a second touch.
|
||||
if (e.touches.length === 2) {
|
||||
// pinch latch
|
||||
if (scope.selMode === 'select') {
|
||||
notify({ type: 'mobile-clip-cancel-by-pinch' })
|
||||
cancelSelect()
|
||||
dispatch.mode = 'surface'
|
||||
dispatch.touchId = t.identifier
|
||||
}
|
||||
scope.touchDispatch.mode = 'pinch'
|
||||
scope.touchDispatch.touchIds = [e.touches[0].identifier, e.touches[1].identifier]
|
||||
clearLongPress()
|
||||
return
|
||||
}
|
||||
|
||||
if (onHandle && scope.selMode === 'select') {
|
||||
// start handle drag
|
||||
const handleName = target === scope.handleStart ? 'start' : 'end'
|
||||
scope.sel!.activeHandle = handleName
|
||||
scope.touchDispatch.mode = 'select-drag'
|
||||
scope.touchDispatch.touchId = t.identifier
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (inOverlay) {
|
||||
// tap on menu pill — let the buttons' own handlers fire
|
||||
return
|
||||
}
|
||||
|
||||
if (inSurface && scope.selMode === 'select') {
|
||||
// Why: tap-to-dismiss matches native iOS/Android — touching outside the
|
||||
// selection clears it. We cancel immediately and latch to 'surface' so
|
||||
// the same gesture still drives scroll/pan without a second touch.
|
||||
cancelSelect()
|
||||
scope.touchDispatch.mode = 'surface'
|
||||
scope.touchDispatch.touchId = t.identifier
|
||||
return
|
||||
}
|
||||
|
||||
if (inSurface) {
|
||||
scope.touchDispatch.mode = 'surface'
|
||||
scope.touchDispatch.touchId = t.identifier
|
||||
scope.tapCandidate = { x: t.clientX, y: t.clientY, t: Date.now(), identifier: t.identifier }
|
||||
armLongPress(t)
|
||||
}
|
||||
}
|
||||
|
||||
function onDocumentTouchMove(e: TouchEvent) {
|
||||
if (scope.touchDispatch.mode === 'select-drag') {
|
||||
const t = touchById(e.touches, scope.touchDispatch.touchId)
|
||||
if (!t || !scope.sel || !scope.sel.activeHandle) {
|
||||
return
|
||||
}
|
||||
|
||||
if (inSurface) {
|
||||
dispatch.mode = 'surface'
|
||||
dispatch.touchId = t.identifier
|
||||
scope.tapCandidate = { x: t.clientX, y: t.clientY, t: Date.now(), identifier: t.identifier }
|
||||
armLongPress(t)
|
||||
}
|
||||
},
|
||||
{ capture: true, passive: false }
|
||||
)
|
||||
|
||||
document.addEventListener(
|
||||
'touchmove',
|
||||
function (e) {
|
||||
if (dispatch.mode === 'select-drag') {
|
||||
const t = touchById(e.touches, dispatch.touchId)
|
||||
if (!t || !scope.sel || !scope.sel.activeHandle) {
|
||||
return
|
||||
e.preventDefault()
|
||||
handleDragMove(scope.sel.activeHandle, t.clientX, t.clientY)
|
||||
return
|
||||
}
|
||||
if (scope.touchDispatch.mode === 'surface' || scope.touchDispatch.mode === 'pinch') {
|
||||
// long-press slop check
|
||||
if (scope.longPressTimer && e.touches.length === 1) {
|
||||
if (touchSlopExceeded(e.touches[0])) {
|
||||
clearLongPress()
|
||||
}
|
||||
e.preventDefault()
|
||||
handleDragMove(scope.sel.activeHandle, t.clientX, t.clientY)
|
||||
return
|
||||
}
|
||||
if (dispatch.mode === 'surface' || dispatch.mode === 'pinch') {
|
||||
// long-press slop check
|
||||
if (scope.longPressTimer && e.touches.length === 1) {
|
||||
if (touchSlopExceeded(e.touches[0])) {
|
||||
clearLongPress()
|
||||
// Why: disqualify the tap only once the finger travels past TAP_SLOP
|
||||
// (a scroll/pan), independent of the long-press timer — so a tap that
|
||||
// jitters under TAP_SLOP still opens the link/path under the finger.
|
||||
if (scope.tapCandidate && e.touches.length === 1) {
|
||||
const mt = e.touches[0]
|
||||
if (mt.identifier === scope.tapCandidate.identifier) {
|
||||
const dx = Math.abs(mt.clientX - scope.tapCandidate.x)
|
||||
const dy = Math.abs(mt.clientY - scope.tapCandidate.y)
|
||||
if (dx + dy > scope.TAP_SLOP) {
|
||||
scope.tapCandidate = null
|
||||
}
|
||||
}
|
||||
// Why: disqualify the tap only once the finger travels past TAP_SLOP
|
||||
// (a scroll/pan), independent of the long-press timer — so a tap that
|
||||
// jitters under TAP_SLOP still opens the link/path under the finger.
|
||||
if (scope.tapCandidate && e.touches.length === 1) {
|
||||
const mt = e.touches[0]
|
||||
if (mt.identifier === scope.tapCandidate.identifier) {
|
||||
const dx = Math.abs(mt.clientX - scope.tapCandidate.x)
|
||||
const dy = Math.abs(mt.clientY - scope.tapCandidate.y)
|
||||
if (dx + dy > scope.TAP_SLOP) {
|
||||
scope.tapCandidate = null
|
||||
}
|
||||
}
|
||||
} else if (e.touches.length !== 1) {
|
||||
scope.tapCandidate = null
|
||||
}
|
||||
// existing surface handler will run from its own listener
|
||||
}
|
||||
},
|
||||
{ capture: true, passive: false }
|
||||
)
|
||||
|
||||
document.addEventListener(
|
||||
'touchend',
|
||||
function (e) {
|
||||
if (dispatch.mode === 'select-drag') {
|
||||
if (scope.sel) {
|
||||
scope.sel.activeHandle = null
|
||||
}
|
||||
stopEdgeScroll()
|
||||
dispatch.mode = 'idle'
|
||||
dispatch.touchId = null
|
||||
return
|
||||
}
|
||||
if (dispatch.mode === 'pinch') {
|
||||
if (e.touches.length < 2) {
|
||||
dispatch.mode = e.touches.length === 1 ? 'surface' : 'idle'
|
||||
dispatch.touchIds = null
|
||||
if (e.touches.length === 1) {
|
||||
dispatch.touchId = e.touches[0].identifier
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (dispatch.mode === 'surface') {
|
||||
// Why: fire the tap from the tap-candidate origin (survives jitter under
|
||||
// TAP_SLOP) rather than longPressOrigin, which the press-to-select slop
|
||||
// can null mid-tap — that was dropping URL/file taps that moved a few px.
|
||||
if (
|
||||
e.touches.length === 0 &&
|
||||
scope.tapCandidate &&
|
||||
scope.selMode !== 'select' &&
|
||||
Date.now() - scope.tapCandidate.t <= scope.TAP_MAX_MS
|
||||
) {
|
||||
notifyTerminalSurfaceTap(scope.tapCandidate.x, scope.tapCandidate.y, true)
|
||||
}
|
||||
clearLongPress()
|
||||
} else if (e.touches.length !== 1) {
|
||||
scope.tapCandidate = null
|
||||
if (e.touches.length === 0) {
|
||||
dispatch.mode = 'idle'
|
||||
dispatch.touchId = null
|
||||
}
|
||||
// existing surface handler will run from its own listener
|
||||
}
|
||||
}
|
||||
|
||||
function onDocumentTouchEnd(e: TouchEvent) {
|
||||
if (scope.touchDispatch.mode === 'select-drag') {
|
||||
if (scope.sel) {
|
||||
scope.sel.activeHandle = null
|
||||
}
|
||||
stopEdgeScroll()
|
||||
scope.touchDispatch.mode = 'idle'
|
||||
scope.touchDispatch.touchId = null
|
||||
return
|
||||
}
|
||||
if (scope.touchDispatch.mode === 'pinch') {
|
||||
if (e.touches.length < 2) {
|
||||
scope.touchDispatch.mode = e.touches.length === 1 ? 'surface' : 'idle'
|
||||
scope.touchDispatch.touchIds = null
|
||||
if (e.touches.length === 1) {
|
||||
scope.touchDispatch.touchId = e.touches[0].identifier
|
||||
}
|
||||
}
|
||||
},
|
||||
{ capture: true, passive: true }
|
||||
)
|
||||
|
||||
document.addEventListener(
|
||||
'touchcancel',
|
||||
function () {
|
||||
return
|
||||
}
|
||||
if (scope.touchDispatch.mode === 'surface') {
|
||||
// Why: fire the tap from the tap-candidate origin (survives jitter under
|
||||
// TAP_SLOP) rather than longPressOrigin, which the press-to-select slop
|
||||
// can null mid-tap — that was dropping URL/file taps that moved a few px.
|
||||
if (
|
||||
e.touches.length === 0 &&
|
||||
scope.tapCandidate &&
|
||||
scope.selMode !== 'select' &&
|
||||
Date.now() - scope.tapCandidate.t <= scope.TAP_MAX_MS
|
||||
) {
|
||||
notifyTerminalSurfaceTap(scope.tapCandidate.x, scope.tapCandidate.y, true)
|
||||
}
|
||||
clearLongPress()
|
||||
scope.tapCandidate = null
|
||||
stopEdgeScroll()
|
||||
if (dispatch.mode === 'select-drag') {
|
||||
if (scope.sel) {
|
||||
scope.sel.activeHandle = null
|
||||
}
|
||||
if (e.touches.length === 0) {
|
||||
scope.touchDispatch.mode = 'idle'
|
||||
scope.touchDispatch.touchId = null
|
||||
}
|
||||
dispatch.mode = 'idle'
|
||||
dispatch.touchId = null
|
||||
dispatch.touchIds = null
|
||||
},
|
||||
{ capture: true, passive: true }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function onDocumentTouchCancel() {
|
||||
clearLongPress()
|
||||
scope.tapCandidate = null
|
||||
stopEdgeScroll()
|
||||
if (scope.touchDispatch.mode === 'select-drag') {
|
||||
if (scope.sel) {
|
||||
scope.sel.activeHandle = null
|
||||
}
|
||||
}
|
||||
scope.touchDispatch.mode = 'idle'
|
||||
scope.touchDispatch.touchId = null
|
||||
scope.touchDispatch.touchIds = null
|
||||
}
|
||||
|
||||
/**
|
||||
* The dispatcher's four document listeners, per mount (ruling 20).
|
||||
*
|
||||
* They are on `document` rather than on the surface, so unlike every surface handler they outlive
|
||||
* the host element a remount replaces — which is exactly why the undo below exists.
|
||||
*/
|
||||
export function startTapDispatch() {
|
||||
document.addEventListener('touchstart', onDocumentTouchStart, CAPTURE_ACTIVE)
|
||||
document.addEventListener('touchmove', onDocumentTouchMove, CAPTURE_ACTIVE)
|
||||
document.addEventListener('touchend', onDocumentTouchEnd, CAPTURE_PASSIVE)
|
||||
document.addEventListener('touchcancel', onDocumentTouchCancel, CAPTURE_PASSIVE)
|
||||
}
|
||||
|
||||
export function stopTapDispatch() {
|
||||
document.removeEventListener('touchstart', onDocumentTouchStart, CAPTURE_ACTIVE)
|
||||
document.removeEventListener('touchmove', onDocumentTouchMove, CAPTURE_ACTIVE)
|
||||
document.removeEventListener('touchend', onDocumentTouchEnd, CAPTURE_PASSIVE)
|
||||
document.removeEventListener('touchcancel', onDocumentTouchCancel, CAPTURE_PASSIVE)
|
||||
clearLongPress()
|
||||
}
|
||||
|
||||
@@ -1,391 +0,0 @@
|
||||
import {
|
||||
describeToken,
|
||||
readScriptTokens,
|
||||
type DocumentToken
|
||||
} from './terminal-document-tokens.test-support'
|
||||
|
||||
/**
|
||||
* Whether two versions of the in-WebView document script are the same program, allowing only the
|
||||
* scope qualifier that moving it into modules requires.
|
||||
*
|
||||
* C7.1 turns the document's one 2,758-line IIFE into modules the web page can import. A variable
|
||||
* the script assigns across what became a module boundary cannot stay a free variable — assigning
|
||||
* an imported binding is a syntax error — so those become fields of one scope object, 73
|
||||
* declaration sites in all, and every read and write of them gains a qualifier. Nothing else about
|
||||
* the program may change.
|
||||
*
|
||||
* Byte comparison cannot make that claim once the source is formatter-owned: `oxfmt` writes the
|
||||
* repository's style, which drops the semicolons the hand-written document carries, so the emitted
|
||||
* text necessarily differs on almost every line for reasons that are not the refactor. Tokens are
|
||||
* the level where the claim is exactly true. Semicolons are excluded for the same reason they moved
|
||||
* — they are the formatter's, not the program's — and comments never reach the stream.
|
||||
*
|
||||
* This is deliberately stricter than "it still runs": a reordered statement, a changed literal, a
|
||||
* dropped `!`, a renamed local, all diverge here and are reported with the token index and both
|
||||
* sides, so the flip commit is reviewed by running this rather than by reading a 515-line diff.
|
||||
*/
|
||||
/**
|
||||
* The differences moving the script into modules is allowed to make, each counted on its own.
|
||||
*
|
||||
* Eight classes and no others. Six are the repository's own rules and the printer rewriting the
|
||||
* document's ES5 style the moment its source is a linted module — measured over the whole script,
|
||||
* not assumed: `curly` braces 279 brace-less bodies, `no-unused-vars` unbinds 36 catch clauses, 373
|
||||
* `var` declarators become `const` or `let`, `unicorn/prefer-number-properties` moves 17 globals
|
||||
* onto `Number`, the printer spells out 4 shorthand properties whose value gained a qualifier, and
|
||||
* it stops renaming 7 bindings that are no longer shadows. The other two are the move itself: 609
|
||||
* qualified references and 73 declarations onto the scope. Semicolons and whitespace are the
|
||||
* formatter's and never reach the token stream at all.
|
||||
*
|
||||
* Counted separately because the flip commit pins each number: a total would let one class absorb
|
||||
* another, which is exactly the drift the pin exists to catch.
|
||||
*/
|
||||
export type TerminalDocumentNormalisations = {
|
||||
/** `name` became `<qualifier>.name`; the declaration stayed where it was. */
|
||||
readonly qualifiedReferences: number
|
||||
/**
|
||||
* `var name` became `<qualifier>.name`; the declaration moved onto the scope object. A `var`
|
||||
* with several declarators counts once per declarator, because each becomes its own assignment.
|
||||
*/
|
||||
readonly scopeFieldDeclarations: number
|
||||
/** `var` became `const` or `let`, the binding staying local to the emitted script. */
|
||||
readonly rebindings: number
|
||||
/** A brace-less `if`/`else`/`for`/`while` body gained its braces. */
|
||||
readonly bracedBodies: number
|
||||
/** `catch (e)` became `catch`, the unused binding dropped. */
|
||||
readonly unboundCatches: number
|
||||
/** A global numeric function became its `Number` property. */
|
||||
readonly numberProperties: number
|
||||
/** `{ name: name }` was shorthand; qualifying the value spells the property out again. */
|
||||
readonly shorthandProperties: number
|
||||
/**
|
||||
* An inner binding that shadowed a document variable stopped being a shadow once that variable
|
||||
* moved onto the scope, so the printer stopped renaming it.
|
||||
*/
|
||||
readonly unshadowedNames: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The bindings the printer renamed on the baseline and leaves alone in the modules, listed.
|
||||
*
|
||||
* A parameter named for a document variable shadowed it while both lived in one function scope, so
|
||||
* the printer gave the inner one a decimal suffix; once the outer name is a scope field there is no
|
||||
* shadow and the inner one keeps its own name. Listed rather than matched by shape: a rule that
|
||||
* accepted any `name2` facing `name` would also accept an unrelated rename that happens to end in a
|
||||
* digit, which is a changed program, not a normalisation.
|
||||
*
|
||||
* One entry covers all seven sites the whole script has: the `term` parameter of
|
||||
* `attachTerminalQueryReplyBridge` in `query-reply.ts` and its six uses.
|
||||
*/
|
||||
const UNSHADOWED_RENAMES: readonly {
|
||||
readonly baseline: string
|
||||
readonly generated: string
|
||||
readonly module: string
|
||||
}[] = [{ baseline: 'term2', generated: 'term', module: 'query-reply' }]
|
||||
|
||||
/** Whether this exact baseline-to-generated pair is one of the listed unshadowed renames. */
|
||||
function isListedUnshadowedRename(baseline: string, generated: string): boolean {
|
||||
return UNSHADOWED_RENAMES.some(
|
||||
(entry) => entry.baseline === baseline && entry.generated === generated
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The globals `unicorn/prefer-number-properties` moves onto `Number`.
|
||||
*
|
||||
* Measured over the whole script: seventeen sites, and the rule is the only one of its kind that
|
||||
* appears often enough to be worth matching. Each is equivalent here because every call is already
|
||||
* behind a `typeof … === 'number'` check or is parsing a string, which is what the `Number` form
|
||||
* does with no coercion of its own.
|
||||
*/
|
||||
const NUMBER_GLOBALS = new Set(['isFinite', 'isNaN', 'parseInt', 'parseFloat'])
|
||||
|
||||
export type TerminalDocumentEquivalence =
|
||||
| { readonly equivalent: true; readonly normalisations: TerminalDocumentNormalisations }
|
||||
| { readonly equivalent: false; readonly reason: string }
|
||||
|
||||
/**
|
||||
* `baseline` is the script as it stood before the move, `candidate` the one the modules generate.
|
||||
*
|
||||
* The qualifier is read from `qualifier`, not assumed, so the test names the object it expects and
|
||||
* a rename cannot quietly satisfy this.
|
||||
*/
|
||||
/** The statement heads `curly` braces: everything whose body may be a single unbraced statement. */
|
||||
const BRACEABLE_HEAD_KEYWORDS = new Set(['if', 'for', 'while'])
|
||||
|
||||
/**
|
||||
* Whether the `{` at `open` is the body of a braceable head rather than some other block.
|
||||
*
|
||||
* `else` and `do` are followed by their body directly. The rest put a parenthesised head first, so
|
||||
* the `)` is walked back to its `(` and the keyword before that is what decides. Without this a
|
||||
* bare block anywhere in the generated script would be absorbed as a linter-added body, when it is
|
||||
* a statement the baseline does not have.
|
||||
*/
|
||||
function isBraceableHeadBody(tokens: readonly DocumentToken[], open: number): boolean {
|
||||
const previous = tokens[open - 1]
|
||||
if (previous === undefined) {
|
||||
return false
|
||||
}
|
||||
if (previous.label === 'else' || previous.label === 'do') {
|
||||
return true
|
||||
}
|
||||
if (previous.label !== ')') {
|
||||
return false
|
||||
}
|
||||
let depth = 0
|
||||
for (let i = open - 1; i >= 0; i--) {
|
||||
const label = tokens[i]?.label
|
||||
if (label === ')') {
|
||||
depth += 1
|
||||
continue
|
||||
}
|
||||
if (label === '(') {
|
||||
depth -= 1
|
||||
if (depth === 0) {
|
||||
return BRACEABLE_HEAD_KEYWORDS.has(tokens[i - 1]?.label ?? '')
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** The index of the `}` closing the `{` at `open`, or -1 when the generated script has none. */
|
||||
function matchingCloseIndex(tokens: readonly DocumentToken[], open: number): number {
|
||||
let depth = 0
|
||||
for (let i = open; i < tokens.length; i++) {
|
||||
const label = tokens[i]?.label
|
||||
if (label === '{') {
|
||||
depth += 1
|
||||
continue
|
||||
}
|
||||
if (label === '}') {
|
||||
depth -= 1
|
||||
if (depth === 0) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
export function compareTerminalDocumentScripts(
|
||||
baseline: string,
|
||||
candidate: string,
|
||||
qualifier: string
|
||||
): TerminalDocumentEquivalence {
|
||||
const baselineTokens = readScriptTokens(baseline, 'the baseline')
|
||||
if (!baselineTokens.ok) {
|
||||
return { equivalent: false, reason: baselineTokens.reason }
|
||||
}
|
||||
const candidateTokens = readScriptTokens(candidate, 'the generated script')
|
||||
if (!candidateTokens.ok) {
|
||||
return { equivalent: false, reason: candidateTokens.reason }
|
||||
}
|
||||
const before = baselineTokens.tokens
|
||||
const after = candidateTokens.tokens
|
||||
let qualifiedReferences = 0
|
||||
let scopeFieldDeclarations = 0
|
||||
let rebindings = 0
|
||||
let bracedBodies = 0
|
||||
let unboundCatches = 0
|
||||
let numberProperties = 0
|
||||
let shorthandProperties = 0
|
||||
let unshadowedNames = 0
|
||||
// The generated index each inserted `{` expects its `}` at, innermost last. Recording the index
|
||||
// rather than counting means an absorbed close is the one that closes that body and no other.
|
||||
const insertedBraceCloses: number[] = []
|
||||
let lastMatched: DocumentToken | undefined
|
||||
let left = 0
|
||||
let right = 0
|
||||
while (left < before.length && right < after.length) {
|
||||
const expected = before[left]
|
||||
const actual = after[right]
|
||||
// Ahead of the equality check on purpose: the baseline's next token is a `}` too wherever a
|
||||
// braced body ends a block, and this index is known to close the inserted body, so matching
|
||||
// them as a pair would consume the wrong one and leave the counts right for the wrong reason.
|
||||
if (actual.label === '}' && insertedBraceCloses.at(-1) === right) {
|
||||
insertedBraceCloses.pop()
|
||||
right += 1
|
||||
continue
|
||||
}
|
||||
if (expected.label === actual.label && expected.text === actual.text) {
|
||||
lastMatched = expected
|
||||
left += 1
|
||||
right += 1
|
||||
continue
|
||||
}
|
||||
// `term2` -> `term`: the printer disambiguated a shadowed binding on the baseline side, and
|
||||
// qualifying the outer name removed the shadow, so the inner one keeps its own name.
|
||||
if (
|
||||
expected.label === 'name' &&
|
||||
actual.label === 'name' &&
|
||||
isListedUnshadowedRename(expected.text, actual.text)
|
||||
) {
|
||||
unshadowedNames += 1
|
||||
lastMatched = actual
|
||||
left += 1
|
||||
right += 1
|
||||
continue
|
||||
}
|
||||
// `{ name }` -> `{ name: <qualifier>.name }`: the printer writes the baseline's shorthand back
|
||||
// as one token, and qualifying the value makes the property name unavoidable again.
|
||||
if (
|
||||
actual.label === ':' &&
|
||||
lastMatched?.label === 'name' &&
|
||||
after[right + 1]?.label === 'name' &&
|
||||
after[right + 1]?.text === qualifier &&
|
||||
after[right + 2]?.label === '.' &&
|
||||
after[right + 3]?.text === lastMatched.text
|
||||
) {
|
||||
shorthandProperties += 1
|
||||
right += 4
|
||||
continue
|
||||
}
|
||||
// `name` -> `<qualifier>.name`, three tokens for one.
|
||||
if (isQualified(after, right, expected, qualifier)) {
|
||||
qualifiedReferences += 1
|
||||
left += 1
|
||||
right += 3
|
||||
continue
|
||||
}
|
||||
// `parseInt` -> `Number.parseInt`, the same shape under a different object.
|
||||
if (NUMBER_GLOBALS.has(expected.text) && isQualified(after, right, expected, 'Number')) {
|
||||
numberProperties += 1
|
||||
left += 1
|
||||
right += 3
|
||||
continue
|
||||
}
|
||||
// `var name` -> `<qualifier>.name`: the declaration itself moved onto the scope object.
|
||||
if (
|
||||
expected.label === 'var' &&
|
||||
before[left + 1] !== undefined &&
|
||||
isQualified(after, right, before[left + 1], qualifier)
|
||||
) {
|
||||
scopeFieldDeclarations += 1
|
||||
left += 2
|
||||
right += 3
|
||||
continue
|
||||
}
|
||||
// `var a = 1, b = 2` where both moved onto the scope: the comma introduces the second
|
||||
// declaration, which is written as its own assignment.
|
||||
if (
|
||||
expected.label === ',' &&
|
||||
before[left + 1] !== undefined &&
|
||||
isQualified(after, right, before[left + 1], qualifier)
|
||||
) {
|
||||
scopeFieldDeclarations += 1
|
||||
left += 2
|
||||
right += 3
|
||||
continue
|
||||
}
|
||||
if (expected.label === 'var' && isBlockScopedKeyword(actual)) {
|
||||
rebindings += 1
|
||||
lastMatched = actual
|
||||
left += 1
|
||||
right += 1
|
||||
continue
|
||||
}
|
||||
// `catch (e) {` -> `catch {`: three baseline tokens the linted form does not carry.
|
||||
if (
|
||||
lastMatched?.label === 'catch' &&
|
||||
expected.label === '(' &&
|
||||
before[left + 1]?.label === 'name' &&
|
||||
before[left + 2]?.label === ')' &&
|
||||
actual.label === '{'
|
||||
) {
|
||||
unboundCatches += 1
|
||||
left += 3
|
||||
continue
|
||||
}
|
||||
// `if (a) b;` -> `if (a) { b; }`: the body the repository's `curly` rule braced. Only a
|
||||
// braceable head's body qualifies, and only that body's own close is absorbed.
|
||||
if (actual.label === '{' && isBraceableHeadBody(after, right)) {
|
||||
const close = matchingCloseIndex(after, right)
|
||||
if (close !== -1) {
|
||||
bracedBodies += 1
|
||||
insertedBraceCloses.push(close)
|
||||
right += 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
return {
|
||||
equivalent: false,
|
||||
reason: `token ${left}: expected ${describeToken(expected)}, generated ${describeToken(actual)}`
|
||||
}
|
||||
}
|
||||
// A body braced at the very end of the script leaves its close after the baseline has run out.
|
||||
while (insertedBraceCloses.at(-1) === right && after[right]?.label === '}') {
|
||||
insertedBraceCloses.pop()
|
||||
right += 1
|
||||
}
|
||||
if (left !== before.length || right !== after.length) {
|
||||
return {
|
||||
equivalent: false,
|
||||
reason: `length: ${before.length - left} token(s) left in the baseline, ${after.length - right} in the generated script`
|
||||
}
|
||||
}
|
||||
if (insertedBraceCloses.length !== 0) {
|
||||
return {
|
||||
equivalent: false,
|
||||
reason: `${insertedBraceCloses.length} inserted brace(s) never closed`
|
||||
}
|
||||
}
|
||||
return {
|
||||
equivalent: true,
|
||||
normalisations: {
|
||||
qualifiedReferences,
|
||||
scopeFieldDeclarations,
|
||||
rebindings,
|
||||
bracedBodies,
|
||||
unboundCatches,
|
||||
numberProperties,
|
||||
shorthandProperties,
|
||||
unshadowedNames
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a token is the `const` or `let` a `var` became.
|
||||
*
|
||||
* `let` is contextual outside strict mode, so acorn reports it as a name rather than as a keyword;
|
||||
* matching on the label alone would refuse every `let` the linter introduced.
|
||||
*/
|
||||
function isBlockScopedKeyword(token: DocumentToken): boolean {
|
||||
return token.label === 'const' || (token.label === 'name' && token.text === 'let')
|
||||
}
|
||||
|
||||
/** Whether the generated stream reads `<qualifier>.<expected>` where the baseline read `expected`. */
|
||||
function isQualified(
|
||||
after: DocumentToken[],
|
||||
right: number,
|
||||
expected: DocumentToken,
|
||||
qualifier: string
|
||||
): boolean {
|
||||
return (
|
||||
after[right]?.label === 'name' &&
|
||||
after[right]?.text === qualifier &&
|
||||
after[right + 1]?.label === '.' &&
|
||||
after[right + 2]?.label === expected.label &&
|
||||
after[right + 2]?.text === expected.text
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The hand-written script out of the whole document, which is the part C7.1 moves.
|
||||
*
|
||||
* Read by locating the generated engine rather than by an index into the text, so a slice added
|
||||
* above or below it does not silently shift what gets compared.
|
||||
*/
|
||||
export function readTerminalDocumentScript(document: string, engineJs: string): string {
|
||||
const opener = `<script>${engineJs}</script>`
|
||||
const start = document.indexOf(opener)
|
||||
if (start === -1) {
|
||||
throw new Error('the document does not carry the generated engine script')
|
||||
}
|
||||
const scriptStart = document.indexOf('<script>', start + opener.length)
|
||||
const scriptEnd = document.lastIndexOf('</script>')
|
||||
if (scriptStart === -1 || scriptEnd <= scriptStart) {
|
||||
throw new Error('the document does not carry a hand-written script after the engine')
|
||||
}
|
||||
return document.slice(scriptStart + '<script>'.length, scriptEnd)
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { XTERM_ENGINE_JS } from '../terminal-webview-engine.generated'
|
||||
import { XTERM_HTML } from '../terminal-webview-html'
|
||||
import {
|
||||
compareTerminalDocumentScripts,
|
||||
readTerminalDocumentScript,
|
||||
type TerminalDocumentNormalisations
|
||||
} from './terminal-document-equivalence.test-support'
|
||||
|
||||
/**
|
||||
* The instrument the C7.1 flip commit is reviewed with, exercised on what it will be asked.
|
||||
*
|
||||
* Each refusal below is one way the move could go wrong, and they matter more than the
|
||||
* acceptances: a comparison that let a reordered statement or a changed literal through would pass
|
||||
* the flip while the document had quietly become a different program.
|
||||
*/
|
||||
const QUALIFIER = 'scope'
|
||||
const script = readTerminalDocumentScript(XTERM_HTML, XTERM_ENGINE_JS)
|
||||
|
||||
const NONE: TerminalDocumentNormalisations = {
|
||||
qualifiedReferences: 0,
|
||||
scopeFieldDeclarations: 0,
|
||||
rebindings: 0,
|
||||
bracedBodies: 0,
|
||||
unboundCatches: 0,
|
||||
numberProperties: 0,
|
||||
shorthandProperties: 0,
|
||||
unshadowedNames: 0
|
||||
}
|
||||
|
||||
function normalisationsOf(before: string, after: string): TerminalDocumentNormalisations | string {
|
||||
const result = compareTerminalDocumentScripts(before, after, QUALIFIER)
|
||||
return result.equivalent ? result.normalisations : result.reason
|
||||
}
|
||||
|
||||
describe('terminal document script equivalence', () => {
|
||||
it('reads the hand-written script out of the real document', () => {
|
||||
expect(script.trimStart().startsWith('(function() {')).toBe(true)
|
||||
expect(script.trimEnd().endsWith('})();')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts the real script against itself, normalising nothing', () => {
|
||||
// The instrument on the real 2,758-line program rather than on a toy, which is the only way to
|
||||
// know it survives everything the document actually contains.
|
||||
expect(normalisationsOf(script, script)).toEqual(NONE)
|
||||
})
|
||||
|
||||
it('ignores the semicolons the formatter drops and the comments it keeps', () => {
|
||||
const before = '// one\nvar a = 1;\nfunction f() {\n b(a);\n}\n'
|
||||
const after = '/* other */\nvar a = 1\nfunction f() {\n b(a)\n}\n'
|
||||
expect(normalisationsOf(before, after)).toEqual(NONE)
|
||||
})
|
||||
|
||||
it('counts a reference that gained the qualifier', () => {
|
||||
expect(
|
||||
normalisationsOf(
|
||||
'function f() { return a + a; }',
|
||||
'function f() { return scope.a + scope.a }'
|
||||
)
|
||||
).toEqual({ ...NONE, qualifiedReferences: 2 })
|
||||
})
|
||||
|
||||
it('counts a declaration that moved onto the scope object', () => {
|
||||
// `var a = 1` and `a = 1` are different sites: one dropped a `var`, the other never had one.
|
||||
expect(normalisationsOf('var a = 1;\na = 2;', 'scope.a = 1\nscope.a = 2')).toEqual({
|
||||
...NONE,
|
||||
scopeFieldDeclarations: 1,
|
||||
qualifiedReferences: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('counts a var that stayed local and only changed keyword', () => {
|
||||
expect(normalisationsOf('var a = 1;', 'const a = 1')).toEqual({ ...NONE, rebindings: 1 })
|
||||
})
|
||||
|
||||
it('counts a var that became a let, which acorn reports as a name', () => {
|
||||
// `let` is contextual outside strict mode, so a rule matching on the token label alone would
|
||||
// refuse every reassigned local the linter rewrote.
|
||||
expect(normalisationsOf('var a = 1;\na = 2;', 'let a = 1\na = 2')).toEqual({
|
||||
...NONE,
|
||||
rebindings: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('counts braces the linter adds to a brace-less body', () => {
|
||||
const before = 'if (a) b();\nfor (;;) c();\n'
|
||||
const after = 'if (a) {\n b()\n}\nfor (;;) {\n c()\n}\n'
|
||||
expect(normalisationsOf(before, after)).toEqual({ ...NONE, bracedBodies: 2 })
|
||||
})
|
||||
|
||||
it('counts a catch clause the linter unbound', () => {
|
||||
expect(normalisationsOf('try { a(); } catch (e) {}', 'try {\n a()\n} catch {}')).toEqual({
|
||||
...NONE,
|
||||
unboundCatches: 1,
|
||||
numberProperties: 0,
|
||||
shorthandProperties: 0,
|
||||
unshadowedNames: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('counts a global numeric function the linter moved onto Number', () => {
|
||||
expect(
|
||||
normalisationsOf('if (isFinite(a)) b();', 'if (Number.isFinite(a)) {\n b()\n}')
|
||||
).toEqual({
|
||||
...NONE,
|
||||
numberProperties: 1,
|
||||
bracedBodies: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a global the linter does not move, qualified as if it did', () => {
|
||||
// Only the four numeric globals are this rewrite; anything else under `Number` is a change.
|
||||
expect(normalisationsOf('a = setTimeout(f);', 'a = Number.setTimeout(f)')).toContain('token 2')
|
||||
})
|
||||
|
||||
it('refuses a qualifier under a name it was not told to expect', () => {
|
||||
expect(normalisationsOf('a = 1;', 'state.a = 1')).toContain('token 0')
|
||||
})
|
||||
|
||||
it('counts a shorthand property the qualifier had to spell out', () => {
|
||||
expect(
|
||||
normalisationsOf('var o = { alt: alt, n: 1 };', 'var o = { alt: scope.alt, n: 1 };')
|
||||
).toEqual({ ...NONE, shorthandProperties: 1 })
|
||||
})
|
||||
|
||||
it('counts each declarator of one var that moved onto the scope', () => {
|
||||
expect(normalisationsOf('var a = 1, b = 2;', 'scope.a = 1; scope.b = 2;')).toEqual({
|
||||
...NONE,
|
||||
scopeFieldDeclarations: 2
|
||||
})
|
||||
})
|
||||
|
||||
it('reads a block-scoped function declaration the same way on both sides', () => {
|
||||
const script = 'function f() { if (a) { function g() { return 1; } return g(); } }'
|
||||
expect(normalisationsOf(script, script)).toEqual(NONE)
|
||||
})
|
||||
|
||||
it('counts a name the printer no longer has to disambiguate', () => {
|
||||
expect(
|
||||
normalisationsOf(
|
||||
'var term = null; function f(term) { return term; }',
|
||||
'scope.term = null; function f(term) { return term; }'
|
||||
)
|
||||
).toEqual({ ...NONE, scopeFieldDeclarations: 1, unshadowedNames: 2 })
|
||||
})
|
||||
|
||||
it('refuses a numeric-suffix rename that is not a listed unshadowed binding', () => {
|
||||
// The shape `value2` -> `value` is what the printer does to a shadow, but this pair is not one
|
||||
// of the document's, so it is a renamed local: a changed program, not a normalisation.
|
||||
expect(
|
||||
normalisationsOf('function f() { return value2; }', 'function f() { return value; }')
|
||||
).toBe('token 6: expected name value2, generated name value')
|
||||
})
|
||||
|
||||
it('refuses a bare block the baseline does not have', () => {
|
||||
// A block that is nobody's body cannot be the `curly` rule's work, so absorbing it would hide
|
||||
// a statement boundary the baseline never had.
|
||||
expect(normalisationsOf('let value = 1; use(value);', '{ let value = 1; } use(value);')).toBe(
|
||||
'token 0: expected name let, generated {'
|
||||
)
|
||||
})
|
||||
|
||||
it('counts a braced body only for a head that can carry an unbraced one', () => {
|
||||
expect(normalisationsOf('if (a) b();', 'if (a) { b(); }')).toEqual({
|
||||
...NONE,
|
||||
bracedBodies: 1
|
||||
})
|
||||
expect(normalisationsOf('for (;;) b();', 'for (;;) { b(); }')).toEqual({
|
||||
...NONE,
|
||||
bracedBodies: 1
|
||||
})
|
||||
expect(normalisationsOf('while (a) b();', 'while (a) { b(); }')).toEqual({
|
||||
...NONE,
|
||||
bracedBodies: 1
|
||||
})
|
||||
expect(normalisationsOf('if (a) b(); else c();', 'if (a) { b(); } else { c(); }')).toEqual({
|
||||
...NONE,
|
||||
bracedBodies: 2
|
||||
})
|
||||
expect(normalisationsOf('do b(); while (a);', 'do { b(); } while (a);')).toEqual({
|
||||
...NONE,
|
||||
bracedBodies: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a changed literal', () => {
|
||||
expect(normalisationsOf('var a = 1;', 'var a = 2')).toBe(
|
||||
'token 3: expected num 1, generated num 2'
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a dropped operator', () => {
|
||||
expect(normalisationsOf('if (!a) return;', 'if (a) return')).toBe(
|
||||
'token 2: expected !/~ !, generated name a'
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a reordered pair of statements', () => {
|
||||
expect(normalisationsOf('a();\nb();', 'b()\na()')).toContain('token 0')
|
||||
})
|
||||
|
||||
it('refuses a dropped statement', () => {
|
||||
expect(normalisationsOf('a();\nb();', 'a()')).toBe(
|
||||
'length: 3 token(s) left in the baseline, 0 in the generated script'
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a renamed local', () => {
|
||||
expect(normalisationsOf('function f(x) { return x; }', 'function f(y) { return y }')).toBe(
|
||||
'token 3: expected name x, generated name y'
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a generated script the printer cannot parse', () => {
|
||||
// Both sides go through the printer, so something broken is reported here with its own
|
||||
// message rather than thrown out of the comparison.
|
||||
expect(normalisationsOf('if (a) b();', 'if (a) {\n b()')).toContain(
|
||||
'the generated script does not parse'
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a baseline the printer cannot parse, naming that side', () => {
|
||||
expect(normalisationsOf('a()\n}', 'a()')).toContain('the baseline does not parse')
|
||||
})
|
||||
})
|
||||
@@ -1,80 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
buildTerminalDocumentScript,
|
||||
emitTerminalDocumentModule
|
||||
} from '../../../scripts/build-terminal-document-script.mjs'
|
||||
import { TERMINAL_DOCUMENT_MODULE_ORDER } from '../../../scripts/terminal-document-module-order.mjs'
|
||||
import { compareTerminalDocumentScripts } from './terminal-document-equivalence.test-support'
|
||||
|
||||
/**
|
||||
* The review of the move, as one number per difference class.
|
||||
*
|
||||
* `terminal-document-pre-flip-script.txt` is the hand-written script exactly as it stood before any
|
||||
* of this, taken from the byte fixture that pinned it. This says the modules emit the same program
|
||||
* modulo the qualifier and the repository's own rules rewriting an ES5 document the moment its
|
||||
* source is a linted module. Anything outside those classes refuses with the token index and both
|
||||
* sides, so a reordered statement, a changed literal or a renamed local cannot pass here.
|
||||
*
|
||||
* The scope object is the one thing the emitted script has that the document did not, so it is
|
||||
* pinned on its own below rather than folded into a count.
|
||||
*
|
||||
* Retirement, per ruling 18: this test is the proof of the flip and holds only while no module
|
||||
* changes, so the first lane that must change one retires it together with
|
||||
* `terminal-document-pre-flip-script.txt`, and the standing pin from then on is
|
||||
* `terminal-document-identity.test.ts`, whose fixture regeneration is a review event.
|
||||
*/
|
||||
const preFlipScript = readFileSync(
|
||||
new URL('../terminal-document-pre-flip-script.txt', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
describe('the whole terminal document script', () => {
|
||||
it('is what the modules emit, modulo the eight counted classes', async () => {
|
||||
const emitted = await Promise.all(
|
||||
TERMINAL_DOCUMENT_MODULE_ORDER.map((name) =>
|
||||
emitTerminalDocumentModule(fileURLToPath(new URL(`./${name}.ts`, import.meta.url)))
|
||||
)
|
||||
)
|
||||
const candidate = `(function() {\n${emitted.join('\n')}\n})();`
|
||||
expect(compareTerminalDocumentScripts(preFlipScript, candidate, 'scope')).toEqual({
|
||||
equivalent: true,
|
||||
normalisations: {
|
||||
// The qualifier, partitioned: 609 reads and writes of a name whose declaration stayed put,
|
||||
// and 73 declarations that moved onto the scope object. 682 sites in all.
|
||||
qualifiedReferences: 609,
|
||||
scopeFieldDeclarations: 73,
|
||||
// The document's 446 `var` declarators, less the 73 that became scope fields.
|
||||
rebindings: 373,
|
||||
// `curly`, measured over the whole script before any of this started.
|
||||
bracedBodies: 279,
|
||||
// Of the document's 38 catch clauses, two name their error and report it, so they keep it.
|
||||
unboundCatches: 36,
|
||||
// `unicorn/prefer-number-properties`, also measured up front.
|
||||
numberProperties: 17,
|
||||
// Two SGR mode flags written twice each: shorthand cannot survive a qualified value.
|
||||
shorthandProperties: 4,
|
||||
// Names the printer had to disambiguate while an outer binding of the same name existed.
|
||||
unshadowedNames: 7
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('adds the scope object and nothing else', async () => {
|
||||
const script = await buildTerminalDocumentScript()
|
||||
const emitted = await Promise.all(
|
||||
TERMINAL_DOCUMENT_MODULE_ORDER.map((name) =>
|
||||
emitTerminalDocumentModule(fileURLToPath(new URL(`./${name}.ts`, import.meta.url)))
|
||||
)
|
||||
)
|
||||
const body = emitted.join('\n')
|
||||
const at = script.indexOf(body)
|
||||
expect(at).toBeGreaterThan(-1)
|
||||
const preamble = script.slice('(function() {\n'.length, at)
|
||||
expect(script.slice(at + body.length)).toBe('\n})();')
|
||||
expect(preamble).toContain('function createTerminalDocumentScope()')
|
||||
expect(preamble).toContain('const scope = createTerminalDocumentScope();')
|
||||
expect(preamble.split('createTerminalDocumentScope').length - 1).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -1,94 +0,0 @@
|
||||
import { tokenizer } from 'acorn'
|
||||
import { transformSync } from 'esbuild'
|
||||
|
||||
/**
|
||||
* Reading a version of the in-WebView document script as a token stream.
|
||||
*
|
||||
* Kept apart from the comparison that consumes it: this side answers what the script says, and
|
||||
* says nothing about which differences between two of them are allowed.
|
||||
*/
|
||||
/** One token as this comparison reads it: what kind it is, and the text it carried. */
|
||||
export type DocumentToken = { readonly label: string; readonly text: string }
|
||||
|
||||
/**
|
||||
* Acorn's `Token` class declares `type`, `start` and `end` and not `value`, which it does carry,
|
||||
* so the field is read through a narrowing check rather than asserted onto the declared type.
|
||||
*/
|
||||
function readDocumentToken(token: unknown): DocumentToken | null {
|
||||
if (typeof token !== 'object' || token === null || !('type' in token) || !('value' in token)) {
|
||||
return null
|
||||
}
|
||||
const type: unknown = token.type
|
||||
if (typeof type !== 'object' || type === null || !('label' in type)) {
|
||||
return null
|
||||
}
|
||||
const label: unknown = type.label
|
||||
if (typeof label !== 'string') {
|
||||
return null
|
||||
}
|
||||
const value: unknown = token.value
|
||||
return { label, text: value === undefined || value === null ? '' : String(value) }
|
||||
}
|
||||
|
||||
/** The directive prepended to both sides, and checked to have survived printing. */
|
||||
const STRICT_DIRECTIVE = 'use strict'
|
||||
|
||||
/**
|
||||
* Both sides are printed by the generator's own printer before being read.
|
||||
*
|
||||
* Otherwise every choice the printer makes — semicolons, property shorthand, quote style — reads as
|
||||
* a difference in the program, when it is a difference in who typed it. Printing both sides with
|
||||
* one printer removes that whole class by construction rather than by a rule per symptom, and
|
||||
* leaves only what the eight counted classes cover.
|
||||
*/
|
||||
function significantTokens(source: string): DocumentToken[] {
|
||||
// Read strict on both sides. A loose script has to defend Annex B's block-scoped function
|
||||
// declarations, and the printer does that by hoisting a `var` and renaming the function; a module
|
||||
// does not, so one side would carry a rename the other cannot. Neither name escapes its block, so
|
||||
// the two readings agree on behaviour and only the strict one can be compared.
|
||||
const printed = transformSync(`'${STRICT_DIRECTIVE}';\n${source}`, {
|
||||
loader: 'js',
|
||||
target: 'chrome74',
|
||||
minify: false
|
||||
}).code
|
||||
const kept: DocumentToken[] = []
|
||||
for (const raw of tokenizer(printed, { ecmaVersion: 2020 })) {
|
||||
const token = readDocumentToken(raw)
|
||||
if (token === null) {
|
||||
throw new Error('acorn produced a token this comparison cannot read')
|
||||
}
|
||||
if (token.label === ';' || token.label === 'eof') {
|
||||
continue
|
||||
}
|
||||
kept.push(token)
|
||||
}
|
||||
if (kept[0]?.text !== STRICT_DIRECTIVE) {
|
||||
throw new Error('the strict directive this comparison prepends did not survive printing')
|
||||
}
|
||||
return kept.slice(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* The tokens of one side, or the reason it could not be read.
|
||||
*
|
||||
* A script that does not parse is a refusal with the printer's own message rather than an
|
||||
* exception out of the comparison: a generator that emitted something broken should say so where
|
||||
* the other differences are reported.
|
||||
*/
|
||||
export function readScriptTokens(
|
||||
source: string,
|
||||
side: string
|
||||
): { ok: true; tokens: DocumentToken[] } | { ok: false; reason: string } {
|
||||
try {
|
||||
return { ok: true, tokens: significantTokens(source) }
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `${side} does not parse: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function describeToken(token: DocumentToken | undefined): string {
|
||||
return token === undefined ? '(end of script)' : `${token.label} ${token.text}`.trim()
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { scope } from './document-scope'
|
||||
|
||||
/**
|
||||
* The two fields the document declares before the query-reply bridge that follows it.
|
||||
*
|
||||
* They are one module because the emitted document puts them on one line, ahead of an injected
|
||||
* group; nothing else joins them.
|
||||
*/
|
||||
scope.PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096
|
||||
scope.term = null
|
||||
@@ -7,11 +7,7 @@ import {
|
||||
} from './document-constants'
|
||||
import { notify } from './host-notify'
|
||||
import { fontPxForScale } from './text-scaling'
|
||||
import {
|
||||
scope,
|
||||
type TerminalDocumentTerminal,
|
||||
type TerminalDocumentWebglAddon
|
||||
} from './document-scope'
|
||||
import { scope, scheduleDocumentFrame } from './document-scope'
|
||||
import { applyFitScale } from './fit-scale'
|
||||
import {
|
||||
isAltScreenActive,
|
||||
@@ -28,13 +24,6 @@ import { applyTerminalTheme } from './terminal-theme'
|
||||
import { attachWebglAddon, cancelWebglContextRecovery } from './webgl-recovery'
|
||||
import { afterWritesDrained, enqueueWrite, pumpWrites, resetWriteQueue } from './write-queue'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Unicode11Addon?: { Unicode11Addon: new () => TerminalDocumentWebglAddon }
|
||||
}
|
||||
const Terminal: new (options: Record<string, unknown>) => TerminalDocumentTerminal
|
||||
}
|
||||
|
||||
export function init(
|
||||
cols: number,
|
||||
rows: number,
|
||||
@@ -95,7 +84,7 @@ export function init(
|
||||
const nextSurface = surfaceSwap.nextSurface
|
||||
|
||||
applyTerminalTheme(nextTheme)
|
||||
scope.term = new Terminal({
|
||||
scope.term = scope.createTerminal({
|
||||
cols: cols || 80,
|
||||
rows: rows || 24,
|
||||
theme: scope.terminalTheme,
|
||||
@@ -121,12 +110,13 @@ export function init(
|
||||
scope.pendingTerm = nextTerm
|
||||
scope.term.open(scope.surface!)
|
||||
attachWebglAddon(true)
|
||||
if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) {
|
||||
try {
|
||||
scope.term.loadAddon(new window.Unicode11Addon.Unicode11Addon())
|
||||
try {
|
||||
const unicodeAddon = scope.createUnicode11Addon()
|
||||
if (unicodeAddon) {
|
||||
scope.term.loadAddon(unicodeAddon)
|
||||
scope.term.unicode.activeVersion = '11'
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
if (typeof replayData === 'string' && replayData.length > 0) {
|
||||
// Why no trailing reset: the snapshot pen belongs to the live host TUI receiving later output.
|
||||
enqueueWrite(scope.ESC + '[0m' + replayData)
|
||||
@@ -138,7 +128,7 @@ export function init(
|
||||
attachTermObservers()
|
||||
attachTerminalQueryReplyBridge(scope.term, gen)
|
||||
|
||||
requestAnimationFrame(function () {
|
||||
scheduleDocumentFrame(function () {
|
||||
if (gen !== scope.terminalGeneration) {
|
||||
return
|
||||
}
|
||||
@@ -199,3 +189,11 @@ export function resize(cols: number, rows: number) {
|
||||
}
|
||||
|
||||
// reflow(): see reflow.ts.
|
||||
|
||||
/**
|
||||
* Ruling 21: init's own frames carry the generation they were scheduled under, so bumping it is
|
||||
* what abandons them — the same guard a re-init already uses against its predecessor.
|
||||
*/
|
||||
export function stopTerminalInit() {
|
||||
scope.terminalGeneration++
|
||||
}
|
||||
|
||||
@@ -164,8 +164,7 @@ export function applyTerminalTheme(input: TerminalDocumentThemeMessage) {
|
||||
scope.terminalThemeInput = input
|
||||
scope.terminalTheme = normalizeTerminalTheme(input)
|
||||
const background = scope.terminalTheme.background || terminalBackgroundFallback
|
||||
document.documentElement.style.background = background
|
||||
document.body.style.background = background
|
||||
scope.paintDocumentBackground(background)
|
||||
// Why prefer the published value: the desktop user may have lowered or disabled the floor (#10754);
|
||||
// an older host omits the field and the luminance gate stays authoritative.
|
||||
const publishedFloor = normalizeTerminalContrastOverride(
|
||||
|
||||
@@ -1,37 +1,25 @@
|
||||
import { terminalTextScalePresets } from './document-constants'
|
||||
import { scope } from './document-scope'
|
||||
import { scope, scheduleDocumentFrame } from './document-scope'
|
||||
import { applyFitScale, getCellHeight } from './fit-scale'
|
||||
import { getCellWidth } from './viewport-transform'
|
||||
import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics'
|
||||
|
||||
export const scrollIndicator = document.getElementById('scroll-indicator')
|
||||
export const scrollThumb = document.getElementById('scroll-thumb')
|
||||
scope.scrollIndicatorHideTimer = null
|
||||
scope.writeQueue = []
|
||||
scope.writeQueueHead = 0
|
||||
scope.writesDraining = false
|
||||
scope.afterDrainCallbacks = []
|
||||
scope.termObserverDisposables = []
|
||||
scope.ready = false
|
||||
// Why: init() flips ready false on every re-init (live width reflow included)
|
||||
// while the old surface stays visible; a document-scoped latch drives the
|
||||
// fatal/non-fatal decision so a transient reflow cannot blank a live terminal.
|
||||
scope.everReady = false
|
||||
scope.currentScale = 1
|
||||
|
||||
// Why: userScale is transient pinch zoom (CSS) for smooth feedback DURING a
|
||||
// gesture only; it resets to 1 on release. The persistent "text size" is the
|
||||
// real xterm fontSize (currentTextScale × BASE_FONT_PX), so changing it
|
||||
// reflows the grid: a bigger cell means fewer columns fit, and RN re-measures
|
||||
// and resizes the PTY (terminal.updateViewport) so the shell rewraps to the
|
||||
// new width. A finished pinch snaps to the nearest preset and reports it to RN.
|
||||
scope.userScale = 1
|
||||
|
||||
const BASE_FONT_PX = 13
|
||||
const MIN_FONT_PX = 6
|
||||
scope.MIN_FIT_COLS = 20
|
||||
scope.currentTextScale = 1
|
||||
|
||||
const TEXT_SCALE_PRESETS = terminalTextScalePresets
|
||||
scope.MIN_TEXT_SCALE = TEXT_SCALE_PRESETS[0]
|
||||
scope.MAX_TEXT_SCALE = TEXT_SCALE_PRESETS[TEXT_SCALE_PRESETS.length - 1]
|
||||
|
||||
export function snapToTextScalePreset(value: number) {
|
||||
let best = TEXT_SCALE_PRESETS[0],
|
||||
bestDelta = Infinity
|
||||
@@ -57,8 +45,7 @@ export function isIOSWebView() {
|
||||
// fall to a non-monospace face; lead with the ui-monospace generic to avoid that.
|
||||
const TERMINAL_FONT_FALLBACKS =
|
||||
'"Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Symbols Nerd Font Mono", monospace'
|
||||
scope.terminalFontFamily =
|
||||
(isIOSWebView() ? 'ui-monospace, ' : '"SF Mono", ') + TERMINAL_FONT_FALLBACKS
|
||||
|
||||
// Why: change the real font size, then resize the grid to fit the viewport at
|
||||
// the new cell metrics so the text shows at its true size immediately. RN's
|
||||
// refit (measure → updateViewport) then makes the server reflow the PTY to the
|
||||
@@ -74,8 +61,11 @@ export function applyTextScale(scale: number) {
|
||||
return
|
||||
}
|
||||
scope.term.options.fontSize = px
|
||||
requestAnimationFrame(function () {
|
||||
if (!scope.term) {
|
||||
// Ruling 21: the generation this frame was scheduled under. `scope.term` alone is not enough —
|
||||
// a mount that came and went leaves a live terminal here, and this would resize that one.
|
||||
const gen = scope.terminalGeneration
|
||||
scheduleDocumentFrame(function () {
|
||||
if (!scope.term || gen !== scope.terminalGeneration) {
|
||||
return
|
||||
}
|
||||
const cellW = getCellWidth()
|
||||
@@ -92,3 +82,10 @@ export function applyTextScale(scale: number) {
|
||||
applyFitScale('text-scale')
|
||||
})
|
||||
}
|
||||
|
||||
export function startTextScaling() {
|
||||
scope.scrollIndicator = document.getElementById('scroll-indicator')
|
||||
scope.scrollThumb = document.getElementById('scroll-thumb')
|
||||
scope.terminalFontFamily =
|
||||
(isIOSWebView() ? 'ui-monospace, ' : '"SF Mono", ') + TERMINAL_FONT_FALLBACKS
|
||||
}
|
||||
|
||||
@@ -1,56 +1,21 @@
|
||||
import { terminalDefaultTheme } from './document-constants'
|
||||
import { repositionOverlay } from './selection-overlay'
|
||||
import { shouldRouteScrollToTerminalInput } from './mouse-input-encoding'
|
||||
import { scope } from './document-scope'
|
||||
import { scrollIndicator, scrollThumb } from './text-scaling'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
ReactNativeWebView?: { postMessage: (message: string) => void }
|
||||
}
|
||||
}
|
||||
|
||||
scope.panX = 0
|
||||
scope.panY = 0
|
||||
scope.smoothScrollOffsetY = 0
|
||||
scope.pendingNormalScrollDeltaY = 0
|
||||
scope.normalScrollFrameId = null
|
||||
scope.initRows = 24
|
||||
scope.terminalGeneration = 0
|
||||
scope.defaultTheme = terminalDefaultTheme
|
||||
scope.terminalThemeInput = null
|
||||
scope.terminalTheme = scope.defaultTheme
|
||||
scope.terminalMinimumContrastRatio = 3
|
||||
scope.webglAddon = null
|
||||
scope.webglRecoveryTimer = null
|
||||
scope.activeAltScreenSnapshot = false
|
||||
scope.trackedMouseTrackingMode = 'none'
|
||||
scope.sgrMouseMode = false
|
||||
scope.sgrMousePixelsMode = false
|
||||
scope.initialOscLinks = []
|
||||
scope.initialOscLinkRowOffset = 0
|
||||
scope.initialOscLinkEvictionReady = false
|
||||
scope.mouseModeScanTail = ''
|
||||
scope.handledMessageIds = []
|
||||
// Why: after init() the initial scrollback applyFitScale may have run
|
||||
// against an empty buffer (or one without the widest line yet). Re-fit
|
||||
// once when the first live data chunk arrives so a wider line that pushes
|
||||
// scrollWidth past the previously-measured value gets re-scaled to fit.
|
||||
scope.firstDataPending = false
|
||||
|
||||
// Diagnostic logger — bridges WebView console.log to RN via postMessage.
|
||||
// Tag with [fit] so it's easy to filter in the Expo/Metro logs.
|
||||
export function flog(tag: string, payload: Record<string, unknown>) {
|
||||
try {
|
||||
if (window.ReactNativeWebView) {
|
||||
window.ReactNativeWebView.postMessage(
|
||||
JSON.stringify({
|
||||
type: 'log',
|
||||
tag: '[fit]' + tag,
|
||||
payload: payload
|
||||
})
|
||||
)
|
||||
}
|
||||
scope.postToHost({
|
||||
type: 'log',
|
||||
tag: '[fit]' + tag,
|
||||
payload: payload
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -104,8 +69,8 @@ export function updateTransform() {
|
||||
|
||||
export function updateScrollIndicator(reveal: boolean) {
|
||||
if (
|
||||
!scrollIndicator ||
|
||||
!scrollThumb ||
|
||||
!scope.scrollIndicator ||
|
||||
!scope.scrollThumb ||
|
||||
!scope.term ||
|
||||
!scope.term.buffer ||
|
||||
!scope.term.buffer.active
|
||||
@@ -115,7 +80,7 @@ export function updateScrollIndicator(reveal: boolean) {
|
||||
const buffer = scope.term.buffer.active
|
||||
const maxViewportY = buffer.baseY || 0
|
||||
if (maxViewportY <= 0 || shouldRouteScrollToTerminalInput()) {
|
||||
scrollIndicator.classList.remove('visible')
|
||||
scope.scrollIndicator.classList.remove('visible')
|
||||
return
|
||||
}
|
||||
const trackHeight = Math.max(0, window.innerHeight - 8)
|
||||
@@ -126,17 +91,25 @@ export function updateScrollIndicator(reveal: boolean) {
|
||||
const thumbHeight = Math.max(24, (trackHeight * (scope.term.rows || 0)) / totalRows)
|
||||
const maxTop = Math.max(0, trackHeight - thumbHeight)
|
||||
const top = maxViewportY > 0 ? (buffer.viewportY / maxViewportY) * maxTop : 0
|
||||
scrollThumb.style.height = thumbHeight + 'px'
|
||||
scrollThumb.style.transform = 'translateY(' + top + 'px)'
|
||||
scope.scrollThumb.style.height = thumbHeight + 'px'
|
||||
scope.scrollThumb.style.transform = 'translateY(' + top + 'px)'
|
||||
if (!reveal) {
|
||||
return
|
||||
}
|
||||
scrollIndicator.classList.add('visible')
|
||||
scope.scrollIndicator.classList.add('visible')
|
||||
if (scope.scrollIndicatorHideTimer) {
|
||||
clearTimeout(scope.scrollIndicatorHideTimer)
|
||||
}
|
||||
scope.scrollIndicatorHideTimer = setTimeout(function () {
|
||||
scrollIndicator!.classList.remove('visible')
|
||||
scope.scrollIndicator!.classList.remove('visible')
|
||||
scope.scrollIndicatorHideTimer = null
|
||||
}, 550)
|
||||
}
|
||||
|
||||
/** Ruling 21: the hide timer is the one thing this module schedules. */
|
||||
export function stopViewportTransform() {
|
||||
if (scope.scrollIndicatorHideTimer) {
|
||||
clearTimeout(scope.scrollIndicatorHideTimer)
|
||||
scope.scrollIndicatorHideTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,6 @@ import { flog } from './viewport-transform'
|
||||
import { applyTerminalTheme } from './terminal-theme'
|
||||
import { scope, type TerminalDocumentWebglAddon } from './document-scope'
|
||||
|
||||
/** xterm's WebGL addon constructor, as the engine bundle puts it on `window`. */
|
||||
type WebglAddonGlobal = { WebglAddon?: new () => TerminalDocumentWebglAddon }
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
WebglAddon?: WebglAddonGlobal
|
||||
}
|
||||
}
|
||||
|
||||
export function refreshTerminalSurface() {
|
||||
if (!scope.term) {
|
||||
return
|
||||
@@ -29,12 +20,17 @@ export function cancelWebglContextRecovery() {
|
||||
}
|
||||
|
||||
export function attachWebglAddon(allowRecovery: boolean) {
|
||||
if (!scope.term || !window.WebglAddon || !window.WebglAddon.WebglAddon) {
|
||||
if (!scope.term) {
|
||||
return false
|
||||
}
|
||||
let addon: TerminalDocumentWebglAddon | null = null
|
||||
try {
|
||||
addon = new window.WebglAddon.WebglAddon()
|
||||
addon = scope.createWebglAddon()
|
||||
// Why: no addon is the DOM renderer, which is a fallback rather than a failure; the
|
||||
// catch below is for an engine that has one and threw building it.
|
||||
if (!addon) {
|
||||
return false
|
||||
}
|
||||
scope.webglAddon = addon
|
||||
if (addon.onContextLoss) {
|
||||
addon.onContextLoss(function () {
|
||||
@@ -89,7 +85,7 @@ export function attachWebglAddon(allowRecovery: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
function onDocumentVisibilityChange() {
|
||||
if (document.visibilityState !== 'visible') {
|
||||
return
|
||||
}
|
||||
@@ -102,4 +98,13 @@ document.addEventListener('visibilitychange', function () {
|
||||
}
|
||||
} catch {}
|
||||
refreshTerminalSurface()
|
||||
})
|
||||
}
|
||||
|
||||
export function startWebglRecovery() {
|
||||
document.addEventListener('visibilitychange', onDocumentVisibilityChange)
|
||||
}
|
||||
|
||||
export function stopWebglRecovery() {
|
||||
document.removeEventListener('visibilitychange', onDocumentVisibilityChange)
|
||||
cancelWebglContextRecovery()
|
||||
}
|
||||
|
||||
@@ -8,8 +8,6 @@ import {
|
||||
} from './normal-buffer-smooth-scroll'
|
||||
import { scope } from './document-scope'
|
||||
|
||||
scope.wheelAccumDeltaY = 0
|
||||
|
||||
export function wheelEventPixelDeltaY(e: WheelEvent) {
|
||||
const delta = e.deltaY
|
||||
if (typeof delta !== 'number' || !Number.isFinite(delta) || delta === 0) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,8 @@ import {
|
||||
TERMINAL_DOCUMENT_FIXTURE_PATH,
|
||||
terminalDocumentFixture
|
||||
} from '../../scripts/build-terminal-document-fixture.mjs'
|
||||
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
|
||||
import { XTERM_ENGINE_CSS } from './terminal-webview-engine-css.generated'
|
||||
import { XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
|
||||
import { XTERM_HTML } from './terminal-webview-html'
|
||||
|
||||
/**
|
||||
@@ -23,6 +24,11 @@ import { XTERM_HTML } from './terminal-webview-html'
|
||||
* kept the document it had. Regenerate the fixture with
|
||||
* `node scripts/build-terminal-document-fixture.mjs` only when the emitted document was meant to
|
||||
* change; the diff in that commit is the evidence, and reviewing it is the point.
|
||||
*
|
||||
* It is also the only standing pin on the document now. `terminal-document-flip.test.ts` compared
|
||||
* the modules against the pre-flip script and held exactly while no module changed, so it was the
|
||||
* proof of the flip rather than a fence; the first lane that had to change a module retired it.
|
||||
* A golden that moves without its diff listed in the commit message is a blocking finding.
|
||||
*/
|
||||
const fixture = readFileSync(TERMINAL_DOCUMENT_FIXTURE_PATH, 'utf8')
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* What a mount whose chunk never arrived is allowed to touch on its way out.
|
||||
*
|
||||
* The one path where a mount reaches its own cleanup holding a page that belongs to someone else.
|
||||
* Everywhere else the build reads the claim again after its import and stops, but a rejected
|
||||
* import never gets that far: the failure arrives at the mount's error handler directly, and by
|
||||
* then the overlay's Reload may already have built a second document into the same element. A
|
||||
* release that emptied the host anyway would blank the terminal on the screen and hand the page
|
||||
* back while its document ran on.
|
||||
*
|
||||
* Its own file because making the import fail is the only way to reach this, and the mock has to
|
||||
* be in place before the mount module is loaded. It fails once, so the second mount gets the real
|
||||
* modules and can be a live document to protect.
|
||||
*/
|
||||
|
||||
const { chunk } = vi.hoisted(() => ({ chunk: { failures: 0 } }))
|
||||
vi.mock('./document/page-document-modules', async (importOriginal) => {
|
||||
if (chunk.failures === 0) {
|
||||
chunk.failures += 1
|
||||
throw new Error('orca-document-chunk-failed')
|
||||
}
|
||||
return importOriginal()
|
||||
})
|
||||
|
||||
const { mountTerminalWebDocument } = await import('./terminal-web-document-mount')
|
||||
|
||||
const HOST_CLASS = 'orca-terminal-document-host'
|
||||
|
||||
describe('a page mount whose document chunk failed', () => {
|
||||
it('leaves the document that replaced it alone', async () => {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
let resizeListeners = 0
|
||||
const realAdd = window.addEventListener.bind(window)
|
||||
const realRemove = window.removeEventListener.bind(window)
|
||||
// Parameters taken from the bound original, so the wrapper carries the real signature rather
|
||||
// than three implicit `any`s the tests typecheck refuses.
|
||||
window.addEventListener = (...added: Parameters<typeof realAdd>) => {
|
||||
resizeListeners += added[0] === 'resize' ? 1 : 0
|
||||
realAdd(...added)
|
||||
}
|
||||
window.removeEventListener = (...removed: Parameters<typeof realRemove>) => {
|
||||
resizeListeners -= removed[0] === 'resize' ? 1 : 0
|
||||
realRemove(...removed)
|
||||
}
|
||||
|
||||
// Put back whatever happens, as the sibling case does: a failure part way through would
|
||||
// otherwise leave the patched functions on `window` for everything that runs after it.
|
||||
try {
|
||||
const abandoned = mountTerminalWebDocument(host, () => {})
|
||||
abandoned.dispose()
|
||||
// The same element, as React hands it back on the overlay's Reload.
|
||||
const live = mountTerminalWebDocument(host, () => {})
|
||||
// The message is the mocking layer's, not the one thrown, so the two counters are what say
|
||||
// which import did what: the abandoned mount's failed, and the live mount's did not.
|
||||
await expect(abandoned.ready).rejects.toThrow()
|
||||
expect(chunk.failures, 'the abandoned mount is the one whose chunk failed').toBe(1)
|
||||
|
||||
expect(host.querySelector('#terminal-container')).not.toBe(null)
|
||||
expect(host.classList.contains(HOST_CLASS)).toBe(true)
|
||||
// Still claimed, so the release did not hand the page back either.
|
||||
expect(() => mountTerminalWebDocument(host, () => {})).toThrow(
|
||||
'the terminal document is already mounted on this page'
|
||||
)
|
||||
|
||||
await live.ready
|
||||
// The other half of the precondition: the mount that replaced it is a real started document,
|
||||
// not a second casualty. Its resize listener is the one the start sequence adds.
|
||||
expect(resizeListeners, 'the live mount started its document').toBe(1)
|
||||
// And disposing the abandoned handle a second time changes nothing.
|
||||
abandoned.dispose()
|
||||
expect(host.querySelector('#terminal-container')).not.toBe(null)
|
||||
expect(host.classList.contains(HOST_CLASS)).toBe(true)
|
||||
live.dispose()
|
||||
expect(host.querySelector('#terminal-container')).toBe(null)
|
||||
expect(resizeListeners, 'and it took its listener back on the way out').toBe(0)
|
||||
} finally {
|
||||
window.addEventListener = realAdd
|
||||
window.removeEventListener = realRemove
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,308 @@
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11'
|
||||
import { WebglAddon } from '@xterm/addon-webgl'
|
||||
import type { TerminalDocumentWebglAddon } from './document/document-terminal-shape'
|
||||
import { TERMINAL_DOCUMENT_ELEMENT_STYLE, TERMINAL_DOCUMENT_MARKUP } from './terminal-webview-html'
|
||||
import { scopeStyleToHost } from './terminal-webview-html/document-style-scoping'
|
||||
import { XTERM_ENGINE_CSS } from './terminal-webview-engine-css.generated'
|
||||
import type { TerminalWebViewCommand } from './terminal-webview-messages'
|
||||
|
||||
/**
|
||||
* The terminal document, mounted in the page instead of in a WebView.
|
||||
*
|
||||
* Same program: the modules the WebView's script is generated from, started here in the order the
|
||||
* generator emits them. What the WebView's HTML gave them — the stylesheet, the elements they read
|
||||
* by id, the engine on `window` and a `postMessage` back to React Native — this supplies instead,
|
||||
* through the six scope seams and the host's own element.
|
||||
*
|
||||
* Ruling 20 is what makes a remount work. ES module bodies run once per page, so the second mount
|
||||
* re-imports nothing: every element read, listener and reporter install lives in a start function,
|
||||
* and this runs that sequence per mount against the markup it has just replanted. `dispose` takes
|
||||
* back the three that outlive the host element.
|
||||
*
|
||||
* The import is still dynamic, because the page bundle must not carry the document into every
|
||||
* route that never opens a terminal.
|
||||
*/
|
||||
|
||||
export type TerminalWebDocument = {
|
||||
/** Hands one host command to the document, as `postMessage` does inside the WebView. */
|
||||
send: (command: TerminalWebViewCommand & { id: number }) => void
|
||||
dispose: () => void
|
||||
/**
|
||||
* Settles when the document is live, or rejects with what stopped it.
|
||||
*
|
||||
* The handle itself is returned before this: the document is reached by a dynamic import, and a
|
||||
* caller that had to await the import to get a handle would have nothing to dispose while the
|
||||
* import was in flight. That is not a corner — a slow chunk is what the readiness watchdog is
|
||||
* for, and the overlay's Reload is what ruling 20 names as the way out of it.
|
||||
*
|
||||
* A mount disposed before its import landed resolves rather than rejecting. Nothing failed:
|
||||
* the caller asked for the terminal and then asked for it to go away, and the chunk arriving
|
||||
* afterwards is not an error to report. The caller learns which it got from `dispose` being
|
||||
* the thing it called, not from this.
|
||||
*/
|
||||
ready: Promise<void>
|
||||
}
|
||||
|
||||
const STYLE_ELEMENT_ID = 'orca-terminal-document-style'
|
||||
|
||||
/** The class the host carries, and the prefix every injected rule is held under. */
|
||||
const HOST_CLASS = 'orca-terminal-document-host'
|
||||
|
||||
/**
|
||||
* The stylesheet, planted in the head once per page and reaching only inside the host.
|
||||
*
|
||||
* `<style>` rather than a constructed sheet or inline attributes: the document's own rules and
|
||||
* xterm's are written against ids and classes, and the document reads its elements with
|
||||
* `document.getElementById`, which a shadow root would break.
|
||||
*
|
||||
* What is planted is not what the WebView's `<head>` carries. The document-level rules are left
|
||||
* behind entirely and every remaining selector is prefixed with the host's class, so nothing here
|
||||
* can match an element the terminal does not own. That is also what makes leaving the sheet in
|
||||
* the head after unmount the right trade: it matches nothing once the host has dropped the class,
|
||||
* the next mount wants it back, and re-parsing 11 KiB per mount is all removing it would buy.
|
||||
* Two terminals at once is not the case — `document-scope` is a module singleton, so there is one
|
||||
* scope per page and `mount` refuses a second live document rather than letting the two share it.
|
||||
*/
|
||||
function ensureDocumentStyle() {
|
||||
if (document.getElementById(STYLE_ELEMENT_ID)) {
|
||||
return
|
||||
}
|
||||
const style = document.createElement('style')
|
||||
style.id = STYLE_ELEMENT_ID
|
||||
const prefix = `.${HOST_CLASS}`
|
||||
const engine = scopeStyleToHost(XTERM_ENGINE_CSS, prefix)
|
||||
style.textContent = `${engine}\n${scopeStyleToHost(TERMINAL_DOCUMENT_ELEMENT_STYLE, prefix)}`
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
/**
|
||||
* The WebGL addon, or null when the browser refuses it.
|
||||
*
|
||||
* `webgl-recovery` treats null as the DOM renderer, which is the fallback the document already
|
||||
* has for a context loss; the page reaches it one step earlier, when the context was never
|
||||
* granted at all. The caller is told, because a terminal quietly on the slow renderer is worth a
|
||||
* line in the log rather than a silent halving of the drain rate.
|
||||
*/
|
||||
function createPageWebglAddon(onFallback: (reason: string) => void) {
|
||||
try {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the addon's public surface is `dispose`, which the document's shape names; the two optional members it also reads are absent here and guarded at every call.
|
||||
return new WebglAddon() as unknown as TerminalDocumentWebglAddon
|
||||
} catch (error) {
|
||||
onFallback(error instanceof Error ? error.message : String(error))
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which document is live, if any: one per page, because there is one scope per page.
|
||||
*
|
||||
* `document-scope` is a module singleton and every module reads it, so a second mount while the
|
||||
* first is up would not be a second terminal: both would drive the same fields, the same elements
|
||||
* and the same start sequence. The component mounts and disposes in one effect and cannot reach
|
||||
* this state, which is exactly why the refusal is named rather than left to surface as two
|
||||
* terminals writing over each other.
|
||||
*
|
||||
* A token per mount rather than the host element or the host's class. Two mounts can be handed
|
||||
* the same element — the page remounts a terminal into a host React has reused — so an element is
|
||||
* not an identity, and the class says only that *some* document is using this host. The token is
|
||||
* what each handle holds, and it is what `dispose` checks before it touches anything shared.
|
||||
*/
|
||||
let liveDocument: symbol | null = null
|
||||
|
||||
/** What a mount has built so far, which is nothing until its import resolves. */
|
||||
type StartedDocument = {
|
||||
modules: typeof import('./document/page-document-modules')
|
||||
onWindowResize: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The document, mounted. The handle comes back before the document exists.
|
||||
*
|
||||
* Synchronous on purpose. The modules arrive through a dynamic import, and the caller's cleanup
|
||||
* can run while that import is still in flight — a slow chunk, a cold cache, a tab that was
|
||||
* backgrounded. A caller that had to await the import to get a handle would have nothing to
|
||||
* dispose in that window, and the claim below would outlive the mount that made it: the next
|
||||
* mount, the one the error overlay's Reload asks for, would be refused as a second document and
|
||||
* the terminal would never come back. So the claim and the handle are made here, together, and
|
||||
* `dispose` answers for whichever state the mount is in when it is called.
|
||||
*/
|
||||
export function mountTerminalWebDocument(
|
||||
host: HTMLElement,
|
||||
receive: (message: Record<string, unknown>) => void
|
||||
): TerminalWebDocument {
|
||||
if (liveDocument) {
|
||||
throw new Error('the terminal document is already mounted on this page')
|
||||
}
|
||||
const token = Symbol('orca terminal document')
|
||||
liveDocument = token
|
||||
let started: StartedDocument | null = null
|
||||
/**
|
||||
* Gives the page back, and only if it is still this mount's to give.
|
||||
*
|
||||
* The check covers the element too, not just the claim. Emptying a host and taking its class
|
||||
* off are what make the terminal disappear, so a release that skipped the claim but did those
|
||||
* anyway would blank the terminal a later mount has on the screen. One rule, inside the thing
|
||||
* it governs, rather than at each caller.
|
||||
*/
|
||||
const release = () => {
|
||||
if (liveDocument !== token) {
|
||||
return
|
||||
}
|
||||
liveDocument = null
|
||||
host.innerHTML = ''
|
||||
// The sheet stays in the head; the class does not, so every rule in it matches nothing
|
||||
// again the moment the terminal is gone.
|
||||
host.classList.remove(HOST_CLASS)
|
||||
}
|
||||
|
||||
try {
|
||||
ensureDocumentStyle()
|
||||
host.classList.add(HOST_CLASS)
|
||||
host.innerHTML = TERMINAL_DOCUMENT_MARKUP
|
||||
// The WebView's `<head>` declares this before anything runs, and the document's error
|
||||
// reporter reads it unguarded. Without it the first report throws inside `window.onerror`.
|
||||
window.__engineErrors = []
|
||||
} catch (error) {
|
||||
// The claim is made before this runs, so it has to come back if the planting fails.
|
||||
release()
|
||||
throw error
|
||||
}
|
||||
|
||||
// Adopted by the build itself, in the same turn as the start sequence and the listener it adds,
|
||||
// rather than when this promise settles. A `.then` runs a microtask later, and a dispose in
|
||||
// between would find nothing started, skip the teardown and hand the page back with the
|
||||
// document still running on it.
|
||||
const ready = buildTerminalWebDocument(host, receive, token, (built) => {
|
||||
started = built
|
||||
}).catch((error: unknown) => {
|
||||
// The import failed, so nothing was started and the page has to go back — the overlay's
|
||||
// Reload is a second mount and it must be allowed to make one. A later mount may already
|
||||
// hold the page, which `release` answers for.
|
||||
release()
|
||||
throw error
|
||||
})
|
||||
|
||||
return {
|
||||
send: (command) => {
|
||||
started?.modules.handleMsg(command)
|
||||
},
|
||||
dispose: () => {
|
||||
// Once, and only by the document that is live. A handle outlives what it built — the
|
||||
// component holds one in a ref and React may run a cleanup after a later mount has already
|
||||
// started — so a second call, or a call from a handle whose document has been replaced,
|
||||
// would tear down the terminal that is on the screen now. Everything below this line is
|
||||
// shared: the scope, the module sequences, the `window.__engineErrors` array.
|
||||
if (liveDocument !== token) {
|
||||
return
|
||||
}
|
||||
liveDocument = null
|
||||
if (started) {
|
||||
teardownStartedDocument(started)
|
||||
}
|
||||
// Dropped, not just torn down. `send` reads this, and the modules it names are the page's one
|
||||
// singleton — so a handle that kept them would route a command into whatever document is
|
||||
// live next, which is the mount that replaced this one.
|
||||
started = null
|
||||
host.innerHTML = ''
|
||||
host.classList.remove(HOST_CLASS)
|
||||
},
|
||||
ready
|
||||
}
|
||||
}
|
||||
|
||||
/** Undoes a document that did start: its listener, its module sequence and its terminals. */
|
||||
function teardownStartedDocument({ modules, onWindowResize }: StartedDocument) {
|
||||
window.removeEventListener('resize', onWindowResize)
|
||||
modules.stopPageDocumentModules()
|
||||
const { scope } = modules
|
||||
// Both terminals, because a swap that never committed leaves two. `beginTerminalSurfaceSwap`
|
||||
// opens a hidden replacement and `commitTerminalSurfaceSwap` disposes the one it replaced; an
|
||||
// unmount between the two leaves the committed terminal live with nothing pointing at it. They
|
||||
// are the same object whenever no swap is open, so the pair is deduplicated.
|
||||
for (const terminal of new Set([scope.term, scope.committedTerm])) {
|
||||
try {
|
||||
terminal?.dispose()
|
||||
} catch {}
|
||||
}
|
||||
scope.term = null
|
||||
scope.committedTerm = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and starts the document, and hands it to `adopt` — or returns having done neither.
|
||||
*
|
||||
* The token is read again the instant the import lands, before anything below it runs. Every
|
||||
* statement after this point writes shared state: the six seams are fields on a module-singleton
|
||||
* scope, `startPageDocumentModules` resets that scope and installs listeners, and the resize
|
||||
* listener outlives the host. A mount disposed while its chunk was in flight owns none of it, and
|
||||
* running the body anyway would plant its elements' listeners into a page a later mount is using
|
||||
* and reset that mount's scope out from under it. Checking only when this resolves is too late:
|
||||
* by then the writes have happened and the caller can do nothing but discard the result.
|
||||
*
|
||||
* `adopt` rather than a return value for the same reason: what it hands over is what undoes all of
|
||||
* that, and the caller has to be holding it before this function's turn ends.
|
||||
*/
|
||||
async function buildTerminalWebDocument(
|
||||
host: HTMLElement,
|
||||
receive: (message: Record<string, unknown>) => void,
|
||||
token: symbol,
|
||||
adopt: (built: StartedDocument) => void
|
||||
): Promise<void> {
|
||||
const documentModules = await import('./document/page-document-modules')
|
||||
if (liveDocument !== token) {
|
||||
return
|
||||
}
|
||||
const { scope } = documentModules
|
||||
|
||||
// Ruling 19 reaches `window.onerror` too: the WebView's document owns its page and may take
|
||||
// that handler, but this one is a guest. An `error` listener reports the same failures without
|
||||
// displacing whatever the page installed, and it hands back its own removal so `stopHostNotify`
|
||||
// takes it off with everything else.
|
||||
scope.installErrorReporter = (report) => {
|
||||
const errorListener = (event: ErrorEvent) => {
|
||||
report(event.message, event.filename, event.lineno, event.colno, event.error)
|
||||
}
|
||||
window.addEventListener('error', errorListener)
|
||||
return () => window.removeEventListener('error', errorListener)
|
||||
}
|
||||
|
||||
// Ruling 19 again, for colour: inside the WebView the terminal's theme is the page's own
|
||||
// background and the document paints `html` and `body` with it. Here those belong to the
|
||||
// application, and a repaint would outlive the terminal, so the host element takes it instead —
|
||||
// it is the element the grid sits on, which is what the paint was for.
|
||||
scope.paintDocumentBackground = (background) => {
|
||||
host.style.background = background
|
||||
}
|
||||
|
||||
scope.postToHost = receive
|
||||
scope.createTerminal = (options) =>
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: xterm's own Terminal is the engine the document was written against; its options are declared optional where the document's shape declares them present, which is the only difference.
|
||||
new Terminal(options) as unknown as ReturnType<typeof scope.createTerminal>
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the addon's public surface is `dispose`, which the document's shape names; the two optional members it also reads are absent here and guarded there.
|
||||
scope.createUnicode11Addon = () => new Unicode11Addon() as unknown as TerminalDocumentWebglAddon
|
||||
scope.createWebglAddon = () =>
|
||||
createPageWebglAddon((reason) =>
|
||||
receive({
|
||||
type: 'log',
|
||||
tag: '[fit]webgl-unavailable',
|
||||
payload: { renderer: 'dom', message: reason }
|
||||
})
|
||||
)
|
||||
|
||||
// Now the document itself, with every seam already in place.
|
||||
documentModules.startPageDocumentModules()
|
||||
|
||||
// `message-bridge` is not imported (ruling 19), so its one non-bridge duty is re-armed here:
|
||||
// a viewport change has to re-fit, or opening the keyboard leaves the terminal at the old scale.
|
||||
const onWindowResize = () => {
|
||||
documentModules.applyFitScale('window-resize')
|
||||
documentModules.adjustRowsForViewport()
|
||||
documentModules.repositionOverlay()
|
||||
documentModules.clampPan()
|
||||
documentModules.updateTransform()
|
||||
}
|
||||
window.addEventListener('resize', onWindowResize)
|
||||
|
||||
adopt({ modules: documentModules, onWindowResize })
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mountTerminalWebDocument } from './terminal-web-document-mount'
|
||||
|
||||
/**
|
||||
* One live document per page, and the undo that frees the page for the next one.
|
||||
*
|
||||
* `document-scope` is a module singleton: every module in `document/` reads that one object, so
|
||||
* two mounts at once would not be two terminals but two drivers of the same fields and the same
|
||||
* elements. The component cannot reach that state — it mounts and disposes in one effect — which
|
||||
* is why the refusal is named here rather than left to surface as two terminals overwriting each
|
||||
* other's surface. The second half is the one the page actually uses: dispose has to give the
|
||||
* page back, or a remount and the error overlay's Reload would both be refused.
|
||||
*/
|
||||
describe('the page terminal document', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
document.head.innerHTML = ''
|
||||
})
|
||||
|
||||
it('refuses a second mount while one is live, and takes it back on dispose', async () => {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const second = document.createElement('div')
|
||||
document.body.appendChild(second)
|
||||
|
||||
const mounted = mountTerminalWebDocument(host, () => {})
|
||||
await mounted.ready
|
||||
expect(() => mountTerminalWebDocument(second, () => {})).toThrow(
|
||||
'the terminal document is already mounted on this page'
|
||||
)
|
||||
|
||||
mounted.dispose()
|
||||
const remounted = mountTerminalWebDocument(second, () => {})
|
||||
await remounted.ready
|
||||
expect(second.querySelector('#terminal-container')).not.toBe(null)
|
||||
remounted.dispose()
|
||||
})
|
||||
|
||||
it('disposes the terminal a swap left behind, not only the live one', async () => {
|
||||
// `beginTerminalSurfaceSwap` opens a hidden replacement and hands the committed terminal to
|
||||
// `commitTerminalSurfaceSwap`, which disposes it. An unmount between the two is the case this
|
||||
// covers: the committed terminal is nobody's, and a dispose that reached only `scope.term`
|
||||
// would leave it holding its renderer, its observers and its buffers for the life of the tab.
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const mounted = mountTerminalWebDocument(host, () => {})
|
||||
await mounted.ready
|
||||
const { scope } = await import('./document/page-document-modules')
|
||||
|
||||
const disposed: string[] = []
|
||||
const fake = (name: string) => ({ dispose: () => disposed.push(name) })
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: dispose is the only member this case reaches, and the two doubles carry it.
|
||||
scope.committedTerm = fake('committed') as unknown as typeof scope.committedTerm
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above; the mount's dispose calls nothing else on either.
|
||||
scope.term = fake('live') as unknown as typeof scope.term
|
||||
|
||||
mounted.dispose()
|
||||
expect(disposed.sort()).toEqual(['committed', 'live'])
|
||||
expect(scope.term).toBe(null)
|
||||
expect(scope.committedTerm).toBe(null)
|
||||
})
|
||||
|
||||
it('disposes one terminal once when no swap is open', async () => {
|
||||
// The other half: with no swap in flight the two fields are the same object, and disposing it
|
||||
// twice is what the deduplication exists to stop.
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const mounted = mountTerminalWebDocument(host, () => {})
|
||||
await mounted.ready
|
||||
const { scope } = await import('./document/page-document-modules')
|
||||
|
||||
let disposals = 0
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: dispose is the only member the mount's dispose reaches.
|
||||
const only = { dispose: () => (disposals += 1) } as unknown as typeof scope.term
|
||||
scope.term = only
|
||||
scope.committedTerm = only
|
||||
|
||||
mounted.dispose()
|
||||
expect(disposals).toBe(1)
|
||||
})
|
||||
|
||||
it('tears down once, however many times the handle is disposed', async () => {
|
||||
// A handle outlives what it built: the component keeps one in a ref, and React may run a
|
||||
// cleanup twice. Everything dispose touches is shared, so what a second run would reach is
|
||||
// whatever owns the scope by then — stood in for here by a terminal put back after the first
|
||||
// dispose, which is what the next mount does.
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const mounted = mountTerminalWebDocument(host, () => {})
|
||||
await mounted.ready
|
||||
const { scope } = await import('./document/page-document-modules')
|
||||
|
||||
mounted.dispose()
|
||||
|
||||
let disposals = 0
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: dispose is the only member the mount's dispose reaches on a terminal.
|
||||
scope.term = { dispose: () => (disposals += 1) } as unknown as typeof scope.term
|
||||
|
||||
mounted.dispose()
|
||||
mounted.dispose()
|
||||
expect(disposals).toBe(0)
|
||||
expect(scope.term).not.toBe(null)
|
||||
})
|
||||
|
||||
it('does nothing when a stale handle is disposed after another document mounted', async () => {
|
||||
// The case the idempotence check alone would miss. The first handle is spent, a second
|
||||
// document is up, and the first handle's dispose arrives late — from a ref, from a cleanup
|
||||
// React deferred. Comparing a token rather than the host or its class is what makes this
|
||||
// answerable: the two mounts can be handed the same element.
|
||||
const first = document.createElement('div')
|
||||
const second = document.createElement('div')
|
||||
document.body.append(first, second)
|
||||
|
||||
const stale = mountTerminalWebDocument(first, () => {})
|
||||
await stale.ready
|
||||
stale.dispose()
|
||||
const live = mountTerminalWebDocument(second, () => {})
|
||||
await live.ready
|
||||
const { scope } = await import('./document/page-document-modules')
|
||||
|
||||
let disposals = 0
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above; the live document's terminal is only ever disposed here.
|
||||
scope.term = { dispose: () => (disposals += 1) } as unknown as typeof scope.term
|
||||
|
||||
stale.dispose()
|
||||
|
||||
expect(disposals).toBe(0)
|
||||
expect(second.querySelector('#terminal-container')).not.toBe(null)
|
||||
expect(scope.term).not.toBe(null)
|
||||
// And the page is still taken, so the live document is still the one that owns it.
|
||||
expect(() => mountTerminalWebDocument(first, () => {})).toThrow(
|
||||
'the terminal document is already mounted on this page'
|
||||
)
|
||||
live.dispose()
|
||||
})
|
||||
|
||||
it('tells two mounts of the same element apart, which a host comparison cannot', async () => {
|
||||
// Why the claim is a token and not the host. React reuses elements, so the page can hand the
|
||||
// second mount the very element the first one used — that is the ordinary remount, not a
|
||||
// corner. A dispose that asked "is this my host?" would answer yes for both handles, and the
|
||||
// stale one would tear down the live document while leaving the page claimed.
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
|
||||
const stale = mountTerminalWebDocument(host, () => {})
|
||||
await stale.ready
|
||||
stale.dispose()
|
||||
const live = mountTerminalWebDocument(host, () => {})
|
||||
await live.ready
|
||||
const { scope } = await import('./document/page-document-modules')
|
||||
|
||||
let disposals = 0
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: dispose is the only member the mount's dispose reaches on a terminal.
|
||||
scope.term = { dispose: () => (disposals += 1) } as unknown as typeof scope.term
|
||||
|
||||
stale.dispose()
|
||||
|
||||
expect(disposals).toBe(0)
|
||||
expect(host.querySelector('#terminal-container')).not.toBe(null)
|
||||
expect(host.classList.contains('orca-terminal-document-host')).toBe(true)
|
||||
expect(() => mountTerminalWebDocument(host, () => {})).toThrow(
|
||||
'the terminal document is already mounted on this page'
|
||||
)
|
||||
live.dispose()
|
||||
})
|
||||
|
||||
it('touches nothing when it is disposed before its chunk lands', async () => {
|
||||
// The window the synchronous handle opened. `dispose` can now run while the import is still
|
||||
// unresolved, so the build resumes on a page it no longer owns — and everything after its
|
||||
// await writes shared state: the six seams are fields on one module scope, and
|
||||
// `startPageDocumentModules` resets that scope and installs the document's listeners. A mount
|
||||
// that checked only when it resolved would have done all of that first and then discarded the
|
||||
// result, leaving the listeners behind and a later mount's scope reset out from under it.
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const { scope } = await import('./document/page-document-modules')
|
||||
const seamsBefore = {
|
||||
postToHost: scope.postToHost,
|
||||
installErrorReporter: scope.installErrorReporter,
|
||||
paintDocumentBackground: scope.paintDocumentBackground,
|
||||
createTerminal: scope.createTerminal,
|
||||
createUnicode11Addon: scope.createUnicode11Addon,
|
||||
createWebglAddon: scope.createWebglAddon
|
||||
}
|
||||
const generationBefore = scope.terminalGeneration
|
||||
|
||||
const mounted = mountTerminalWebDocument(host, () => {})
|
||||
mounted.dispose()
|
||||
// Armed after the dispose, so anything they catch is the resuming build and nothing else.
|
||||
const listeners = vi.spyOn(EventTarget.prototype, 'addEventListener')
|
||||
const timers = vi.spyOn(globalThis, 'setTimeout')
|
||||
const frames = vi.spyOn(globalThis, 'requestAnimationFrame')
|
||||
try {
|
||||
// Resolves: the caller asked for the terminal and then asked for it to go away, so the
|
||||
// chunk landing afterwards is not a failure to report to the error overlay.
|
||||
await expect(mounted.ready).resolves.toBeUndefined()
|
||||
} finally {
|
||||
listeners.mockRestore()
|
||||
timers.mockRestore()
|
||||
frames.mockRestore()
|
||||
}
|
||||
|
||||
expect(listeners).not.toHaveBeenCalled()
|
||||
expect(timers).not.toHaveBeenCalled()
|
||||
expect(frames).not.toHaveBeenCalled()
|
||||
expect({
|
||||
postToHost: scope.postToHost,
|
||||
installErrorReporter: scope.installErrorReporter,
|
||||
paintDocumentBackground: scope.paintDocumentBackground,
|
||||
createTerminal: scope.createTerminal,
|
||||
createUnicode11Addon: scope.createUnicode11Addon,
|
||||
createWebglAddon: scope.createWebglAddon
|
||||
}).toEqual(seamsBefore)
|
||||
// `startPageDocumentModules` resets the scope, which carries this forward by one. Unchanged
|
||||
// is the start sequence never having run.
|
||||
expect(scope.terminalGeneration).toBe(generationBefore)
|
||||
// And the page is free, which is what the overlay's Reload needs.
|
||||
const remounted = mountTerminalWebDocument(host, () => {})
|
||||
await remounted.ready
|
||||
expect(host.querySelector('#terminal-container')).not.toBe(null)
|
||||
remounted.dispose()
|
||||
})
|
||||
|
||||
it('tears down a document that started, however late the dispose is', async () => {
|
||||
// The start sequence and the handle on what undoes it have to land in one turn. They did not:
|
||||
// the build started the document, installed its listeners and returned, and the assignment
|
||||
// that recorded it ran a microtask later — so a dispose in between found nothing started,
|
||||
// skipped the teardown and handed the page back with the document still running on it. The
|
||||
// dispose here is queued behind the document module import the build awaits, which puts it in
|
||||
// that window rather than before or after it.
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const listeners = new Set<unknown>()
|
||||
let adds = 0
|
||||
const realAdd = window.addEventListener.bind(window)
|
||||
const realRemove = window.removeEventListener.bind(window)
|
||||
// Parameters taken from the bound original, so the wrapper carries the real signature rather
|
||||
// than three implicit `any`s the tests typecheck refuses.
|
||||
window.addEventListener = (...added: Parameters<typeof realAdd>) => {
|
||||
if (added[0] === 'resize') {
|
||||
adds += 1
|
||||
listeners.add(added[1])
|
||||
}
|
||||
realAdd(...added)
|
||||
}
|
||||
window.removeEventListener = (...removed: Parameters<typeof realRemove>) => {
|
||||
if (removed[0] === 'resize') {
|
||||
listeners.delete(removed[1])
|
||||
}
|
||||
realRemove(...removed)
|
||||
}
|
||||
|
||||
const mounted = mountTerminalWebDocument(host, () => {})
|
||||
try {
|
||||
await import('./document/page-document-modules')
|
||||
mounted.dispose()
|
||||
await mounted.ready
|
||||
} finally {
|
||||
window.addEventListener = realAdd
|
||||
window.removeEventListener = realRemove
|
||||
}
|
||||
|
||||
// The precondition: the document did start, so there was something to tear down. A build that
|
||||
// returned right after its ownership check would add nothing and satisfy the emptiness below
|
||||
// for the one reason this case exists to refuse. Counted on the way in rather than read off
|
||||
// the host afterwards — dispose empties the host on every path, so that told us nothing.
|
||||
expect(adds, 'the document started and added its resize listener').toBe(1)
|
||||
expect(listeners.size, 'the resize listener the started document added').toBe(0)
|
||||
const { scope } = await import('./document/page-document-modules')
|
||||
expect(scope.term).toBe(null)
|
||||
// And the page is free, which a handle that lost track of what it started would not have left.
|
||||
const remounted = mountTerminalWebDocument(host, () => {})
|
||||
await remounted.ready
|
||||
remounted.dispose()
|
||||
})
|
||||
|
||||
it('routes nothing into the document that replaced it', async () => {
|
||||
// `send` reads what the mount adopted, and what it adopted names the page's one set of
|
||||
// document modules. A handle that kept them after its dispose would hand a host command to
|
||||
// whichever document is live next: same modules, same scope, a terminal that is not its own.
|
||||
// `ping` is the cheapest way to see it, because the document answers it by posting through the
|
||||
// scope's `postToHost` seam — which by then belongs to the mount that replaced this one.
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const stale = mountTerminalWebDocument(host, () => {})
|
||||
await stale.ready
|
||||
stale.dispose()
|
||||
|
||||
const posts: unknown[] = []
|
||||
const live = mountTerminalWebDocument(host, (message) => posts.push(message.type))
|
||||
await live.ready
|
||||
stale.send({ id: 4242, type: 'ping' })
|
||||
expect(posts).toEqual([])
|
||||
|
||||
// The precondition: the live document does answer a ping, so the silence above is the stale
|
||||
// handle declining to speak rather than the command doing nothing.
|
||||
live.send({ id: 4243, type: 'ping' })
|
||||
expect(posts).toEqual(['pong'])
|
||||
live.dispose()
|
||||
})
|
||||
|
||||
it('gives the page back when the mount itself fails, so Reload can try again', async () => {
|
||||
// The overlay's Reload path. A mount that threw holds nothing, and a flag left set would
|
||||
// refuse every later attempt — the document's chunk failing to load is exactly that case.
|
||||
const detached = document.createElement('div')
|
||||
Object.defineProperty(detached, 'innerHTML', {
|
||||
set() {
|
||||
throw new Error('orca-mount-failed')
|
||||
},
|
||||
get() {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
expect(() => mountTerminalWebDocument(detached, () => {})).toThrow('orca-mount-failed')
|
||||
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const mounted = mountTerminalWebDocument(host, () => {})
|
||||
await mounted.ready
|
||||
expect(host.querySelector('#terminal-container')).not.toBe(null)
|
||||
mounted.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Nothing above the terminal component knows which of the two it has.
|
||||
*
|
||||
* The page's `TerminalWebView.web.tsx` is resolved by the bundler, not chosen by a caller, so the
|
||||
* whole substitution rests on every consumer holding only `terminal-webview-contract` — the props
|
||||
* and the handle — and on the one that renders the component naming it without an extension. A
|
||||
* consumer that reached into `TerminalWebView.tsx` for a type, or imported `terminal-webview-html`
|
||||
* for the document string, would work natively and break on the page in a way no native test sees.
|
||||
*
|
||||
* Scanned rather than listed, so a new consumer joins the rule by existing.
|
||||
*/
|
||||
|
||||
const terminalDir = import.meta.dirname
|
||||
const sessionDir = join(terminalDir, '..', 'session')
|
||||
|
||||
/** The modules that may reach into the component's own file or the document's HTML. */
|
||||
const ALLOWED_INSIDE_TERMINAL = new Set([
|
||||
'TerminalWebView.tsx',
|
||||
'TerminalWebView.web.tsx',
|
||||
'terminal-web-document-mount.ts',
|
||||
'terminal-webview-html.ts',
|
||||
'terminal-webview-html.web.ts',
|
||||
// Test scaffolding that runs the WebView's own document text; it is not shipped in either build.
|
||||
'terminal-webview-mouse-test-harness.ts'
|
||||
])
|
||||
|
||||
const FORBIDDEN_ABOVE_THE_CONTRACT = [
|
||||
/from '(\.\.\/terminal|\.)\/TerminalWebView\.(web\.)?tsx?'/,
|
||||
/from '(\.\.\/terminal|\.)\/terminal-webview-html'/,
|
||||
/from '(\.\.\/terminal|\.)\/terminal-webview-engine(-css)?\.generated'/,
|
||||
/from '(\.\.\/terminal|\.)\/document\//
|
||||
]
|
||||
|
||||
function productModules(directory: string): string[] {
|
||||
return readdirSync(directory)
|
||||
.filter((name) => /\.tsx?$/.test(name))
|
||||
.filter((name) => !name.includes('.test') && !name.includes('.test-support'))
|
||||
.sort()
|
||||
}
|
||||
|
||||
function offenders(directory: string, skip: (name: string) => boolean): string[] {
|
||||
const found: string[] = []
|
||||
for (const name of productModules(directory)) {
|
||||
if (skip(name)) {
|
||||
continue
|
||||
}
|
||||
const source = readFileSync(join(directory, name), 'utf8')
|
||||
for (const pattern of FORBIDDEN_ABOVE_THE_CONTRACT) {
|
||||
if (pattern.test(source)) {
|
||||
found.push(`${name}: ${pattern.source}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
describe('the terminal component contract', () => {
|
||||
it('is all that src/session imports of the terminal', () => {
|
||||
expect(offenders(sessionDir, () => false)).toEqual([])
|
||||
// The precondition: a scan that read no session module would report nothing either.
|
||||
const rendering = readFileSync(join(sessionDir, 'TerminalPaneView.tsx'), 'utf8')
|
||||
expect(rendering).toContain("from '../terminal/TerminalWebView'")
|
||||
expect(rendering).toContain("from '../terminal/terminal-webview-contract'")
|
||||
})
|
||||
|
||||
it('is all that the terminal directory itself imports, outside the component and its document', () => {
|
||||
expect(offenders(terminalDir, (name) => ALLOWED_INSIDE_TERMINAL.has(name))).toEqual([])
|
||||
expect(productModules(terminalDir).length).toBeGreaterThan(40)
|
||||
})
|
||||
|
||||
it('would report a consumer that named the component file', () => {
|
||||
// The scan tested on the text it is meant to refuse, so an empty offender list above is a
|
||||
// measurement rather than a regex that matches nothing.
|
||||
const planted = "import { TerminalWebView } from '../terminal/TerminalWebView.tsx'\n"
|
||||
expect(FORBIDDEN_ABOVE_THE_CONTRACT.some((pattern) => pattern.test(planted))).toBe(true)
|
||||
const html = "import { XTERM_HTML } from '../terminal/terminal-webview-html'\n"
|
||||
expect(FORBIDDEN_ABOVE_THE_CONTRACT.some((pattern) => pattern.test(html))).toBe(true)
|
||||
const extensionless = "import { TerminalWebView } from '../terminal/TerminalWebView'\n"
|
||||
expect(FORBIDDEN_ABOVE_THE_CONTRACT.some((pattern) => pattern.test(extensionless))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Script } from 'node:vm'
|
||||
import { parse } from 'acorn'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
|
||||
import { XTERM_ENGINE_CSS } from './terminal-webview-engine-css.generated'
|
||||
import { XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
|
||||
import { documentScopePreamble } from './document/generated-document-region.test-support'
|
||||
import { XTERM_HTML } from './terminal-webview-html'
|
||||
|
||||
@@ -75,6 +76,7 @@ scope.term = term;
|
||||
scope.terminalGeneration = terminalGeneration;
|
||||
scope.terminalThemeInput = terminalThemeInput;
|
||||
${terminalHtmlSource.slice(recoveryStart, recoveryEnd)}
|
||||
startWebglRecovery();
|
||||
attachWebglAddon(true);`).runInNewContext(context)
|
||||
return {
|
||||
addons,
|
||||
@@ -169,7 +171,8 @@ describe('terminal WebView bundled engine', () => {
|
||||
// old surface visible meanwhile), so the fatal default and the init-catch must
|
||||
// key off `everReady` — otherwise a transient reflow error blanks a live
|
||||
// terminal behind the fatal overlay. The latch stays set for the document.
|
||||
expect(terminalHtmlSource).toContain('scope.everReady = false;')
|
||||
// Ruling 21: the latch's initial value is in the scope factory, not in a parse-time write.
|
||||
expect(terminalHtmlSource).toContain('everReady: false,')
|
||||
expect(terminalHtmlSource).toContain('scope.everReady = true;')
|
||||
expect(terminalHtmlSource).toContain('fatal === void 0 ? !scope.everReady : !!fatal')
|
||||
expect(terminalHtmlSource).toContain('msg.type === "init" && !scope.everReady')
|
||||
|
||||
@@ -3,6 +3,14 @@ import { TERMINAL_HTML_DOCUMENT_CLOSE } from './terminal-webview-html/document-c
|
||||
import { TERMINAL_HTML_DOCUMENT_SHELL } from './terminal-webview-html/document-shell'
|
||||
|
||||
export { MOBILE_TERMINAL_CARET_OPTIONS } from './terminal-webview-html/theme'
|
||||
// Re-exported so the page's `.web.ts` sibling can answer the same names without the document
|
||||
// string: whatever imports this gets markup and style on both platforms. The page takes the
|
||||
// element half only — the document-level rules are this document's alone.
|
||||
export { TERMINAL_DOCUMENT_MARKUP } from './terminal-webview-html/document-markup'
|
||||
export {
|
||||
TERMINAL_DOCUMENT_ELEMENT_STYLE,
|
||||
TERMINAL_DOCUMENT_STYLE
|
||||
} from './terminal-webview-html/document-style'
|
||||
|
||||
// Why: the script the WebView runs is generated from `src/terminal/document/`, the same modules the
|
||||
// web page imports, so there is one source for both. The shell and the close are still text: they
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* What the page needs from the terminal's HTML, which is the markup and the stylesheet and
|
||||
* nothing else.
|
||||
*
|
||||
* The native file composes a whole document: the shell, the 612 KiB engine string and the
|
||||
* generated script. On the page none of those three can be used. The shell's CSP is
|
||||
* `script-src 'self'` with `frame-src 'none'`, so there is no nested document to load and no
|
||||
* inline script to run; the engine arrives as an import instead, and the script's modules are
|
||||
* imported directly. Exporting the document string here would put all of it in the page's closure
|
||||
* to be dropped — `mobile-web-terminal-engine-closure.test.mjs` is the fence that says it is not.
|
||||
*
|
||||
* The stylesheet is the element half only. The document-level rules — `*`, `html`, `body` — are
|
||||
* the WebView's alone: on the page they would restyle the whole application and go on doing it
|
||||
* after the terminal is gone. `scopeStyleToHost` is what holds the rest under the host element.
|
||||
*
|
||||
* `MOBILE_TERMINAL_CARET_OPTIONS` is re-exported because both hosts build the same caret.
|
||||
*/
|
||||
export { MOBILE_TERMINAL_CARET_OPTIONS } from './terminal-webview-html/theme'
|
||||
export { TERMINAL_DOCUMENT_MARKUP } from './terminal-webview-html/document-markup'
|
||||
export { TERMINAL_DOCUMENT_ELEMENT_STYLE } from './terminal-webview-html/document-style'
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* The elements the document's modules reach for by id: the surface xterm is opened on, the
|
||||
* scroll indicator, and the selection overlay with its two handles and its menu pill.
|
||||
*
|
||||
* They are read as the modules are parsed, so whatever hosts the document — the WebView's body
|
||||
* or the page's own container — has to have planted this first.
|
||||
*/
|
||||
export const TERMINAL_DOCUMENT_MARKUP = `<div id="terminal-container">
|
||||
<div id="terminal-surface"></div>
|
||||
</div>
|
||||
<div id="scroll-indicator"><div id="scroll-thumb"></div></div>
|
||||
<div id="selection-overlay">
|
||||
<div id="sel-handle-start" class="sel-handle start"></div>
|
||||
<div id="sel-handle-end" class="sel-handle end"></div>
|
||||
<div id="sel-menu">
|
||||
<button id="sel-menu-copy">Copy</button>
|
||||
<button id="sel-menu-all">Select All</button>
|
||||
</div>
|
||||
</div>`
|
||||
@@ -1,5 +1,7 @@
|
||||
import { colors } from '../../theme/mobile-theme'
|
||||
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from '../terminal-webview-engine.generated'
|
||||
import { TERMINAL_DOCUMENT_MARKUP } from './document-markup'
|
||||
import { TERMINAL_DOCUMENT_STYLE } from './document-style'
|
||||
import { XTERM_ENGINE_CSS } from '../terminal-webview-engine-css.generated'
|
||||
import { XTERM_ENGINE_JS } from '../terminal-webview-engine.generated'
|
||||
|
||||
export const TERMINAL_HTML_DOCUMENT_SHELL = `<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -16,149 +18,11 @@ window.onerror = function(msg) {
|
||||
</script>
|
||||
<style>${XTERM_ENGINE_CSS}</style>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
background: ${colors.terminalBg};
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
#terminal-container {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
#terminal-surface {
|
||||
transform-origin: top left;
|
||||
display: inline-block;
|
||||
}
|
||||
.xterm { -webkit-user-select: none; user-select: none; font-variant-emoji: text; }
|
||||
.xterm .xterm-viewport {
|
||||
overflow-y: hidden !important;
|
||||
scrollbar-width: none !important;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.xterm .xterm-viewport::-webkit-scrollbar {
|
||||
display: none !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .xterm-scrollbar,
|
||||
.xterm .xterm-scrollbar {
|
||||
display: none !important;
|
||||
width: 0 !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
#scroll-indicator {
|
||||
position: fixed;
|
||||
top: 4px;
|
||||
right: 3px;
|
||||
bottom: 4px;
|
||||
width: 3px;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms linear;
|
||||
z-index: 7;
|
||||
}
|
||||
#scroll-indicator.visible { opacity: 0.72; }
|
||||
#scroll-thumb {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 3px;
|
||||
min-height: 24px;
|
||||
border-radius: 999px;
|
||||
background: ${colors.textSecondary};
|
||||
will-change: transform, height;
|
||||
}
|
||||
/* Why: selection overlay sits in unscaled viewport coords, above the
|
||||
transformed surface, so handle hit areas and Copy menu positions
|
||||
don't depend on getTotalScale() for their on-screen size. */
|
||||
#selection-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
display: none;
|
||||
}
|
||||
#selection-overlay.active { display: block; }
|
||||
.sel-handle {
|
||||
position: absolute;
|
||||
width: 44px; height: 44px;
|
||||
margin-left: -22px; margin-top: -22px;
|
||||
pointer-events: auto;
|
||||
background: transparent;
|
||||
}
|
||||
.sel-handle::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%; top: 22px;
|
||||
transform: translateX(-50%);
|
||||
width: 14px; height: 14px;
|
||||
background: #7aa2f7;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #c0caf5;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.sel-handle.start::before { top: 8px; }
|
||||
.sel-handle.start::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%; top: 22px;
|
||||
transform: translateX(-50%);
|
||||
width: 2px; height: 16px;
|
||||
background: #7aa2f7;
|
||||
}
|
||||
.sel-handle.end::before { top: 22px; }
|
||||
.sel-handle.end::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%; top: 6px;
|
||||
transform: translateX(-50%);
|
||||
width: 2px; height: 16px;
|
||||
background: #7aa2f7;
|
||||
}
|
||||
#sel-menu {
|
||||
position: absolute;
|
||||
pointer-events: auto;
|
||||
background: #2a2f4a;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
transform: translateY(-100%);
|
||||
margin-top: -12px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
#sel-menu button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #c0caf5;
|
||||
font: 600 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#sel-menu button:active { background: #414868; }
|
||||
#sel-menu button + button { border-left: 1px solid #414868; }
|
||||
${TERMINAL_DOCUMENT_STYLE}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="terminal-container">
|
||||
<div id="terminal-surface"></div>
|
||||
</div>
|
||||
<div id="scroll-indicator"><div id="scroll-thumb"></div></div>
|
||||
<div id="selection-overlay">
|
||||
<div id="sel-handle-start" class="sel-handle start"></div>
|
||||
<div id="sel-handle-end" class="sel-handle end"></div>
|
||||
<div id="sel-menu">
|
||||
<button id="sel-menu-copy">Copy</button>
|
||||
<button id="sel-menu-all">Select All</button>
|
||||
</div>
|
||||
</div>
|
||||
${TERMINAL_DOCUMENT_MARKUP}
|
||||
<script>${XTERM_ENGINE_JS}</script>
|
||||
<script>
|
||||
`
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { XTERM_ENGINE_CSS } from '../terminal-webview-engine-css.generated'
|
||||
import {
|
||||
TERMINAL_DOCUMENT_ELEMENT_STYLE,
|
||||
TERMINAL_DOCUMENT_ROOT_STYLE,
|
||||
TERMINAL_DOCUMENT_STYLE
|
||||
} from './document-style'
|
||||
import {
|
||||
documentLevelRules,
|
||||
isDocumentLevelSelector,
|
||||
scopeStyleToHost
|
||||
} from './document-style-scoping'
|
||||
|
||||
/**
|
||||
* What the page is allowed to inject, and what the split leaves the WebView.
|
||||
*
|
||||
* 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 and restyles everything,
|
||||
* including after the terminal is gone. So the page takes the element half and holds every
|
||||
* selector under its host; these are the two halves of that claim, measured rather than asserted
|
||||
* in prose.
|
||||
*/
|
||||
const PREFIX = '.orca-terminal-document-host'
|
||||
|
||||
function selectorsOf(css: string): string[] {
|
||||
return [...css.matchAll(/(?:^|\})\s*([^{}]+)\{/g)].flatMap((match) =>
|
||||
match[1]!.split(',').map((one) => one.trim())
|
||||
)
|
||||
}
|
||||
|
||||
describe('the terminal document stylesheet', () => {
|
||||
it('splits into two halves that still compose the sheet the WebView carries', () => {
|
||||
// The native document must not move for the split, which is what the byte golden says; this
|
||||
// is the same claim one level down, where a reordering would be visible as text.
|
||||
expect(TERMINAL_DOCUMENT_STYLE).toBe(
|
||||
`${TERMINAL_DOCUMENT_ROOT_STYLE}\n${TERMINAL_DOCUMENT_ELEMENT_STYLE}`
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps every document-level rule out of the half the page takes', () => {
|
||||
expect(documentLevelRules(TERMINAL_DOCUMENT_ROOT_STYLE)).toEqual(['*', 'html, body'])
|
||||
expect(documentLevelRules(TERMINAL_DOCUMENT_ELEMENT_STYLE)).toEqual([])
|
||||
// The precondition for the empty list: the reader does find them when they are there.
|
||||
expect(isDocumentLevelSelector('body')).toBe(true)
|
||||
expect(isDocumentLevelSelector('html, body')).toBe(true)
|
||||
expect(isDocumentLevelSelector('*')).toBe(true)
|
||||
expect(isDocumentLevelSelector('#terminal-container')).toBe(false)
|
||||
expect(isDocumentLevelSelector('.xterm .xterm-viewport')).toBe(false)
|
||||
})
|
||||
|
||||
it('holds every selector of both injected sheets under the host', () => {
|
||||
for (const sheet of [TERMINAL_DOCUMENT_ELEMENT_STYLE, XTERM_ENGINE_CSS]) {
|
||||
const scoped = scopeStyleToHost(sheet, PREFIX)
|
||||
const selectors = selectorsOf(scoped)
|
||||
expect(selectors.length).toBeGreaterThan(0)
|
||||
expect(selectors.filter((one) => !one.startsWith(`${PREFIX} `))).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('drops a document-level rule rather than prefixing it', () => {
|
||||
// `.host *` is not what `*` meant, and a page has no use for either reading.
|
||||
const scoped = scopeStyleToHost(TERMINAL_DOCUMENT_ROOT_STYLE, PREFIX)
|
||||
expect(scoped.trim()).toBe('')
|
||||
})
|
||||
|
||||
it('refuses a sheet whose shape it cannot rewrite', () => {
|
||||
// The rewrite is textual because the input is flat. A sheet that grew an at-rule would have
|
||||
// its inner selectors passed through unscoped, so it throws instead.
|
||||
expect(() =>
|
||||
scopeStyleToHost('@media (min-width: 1px) { .a { color: red; } }', PREFIX)
|
||||
).toThrow('at-rules cannot be scoped')
|
||||
expect(() => scopeStyleToHost('.a { color: red;', PREFIX)).toThrow('never closes')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* The terminal's own rules, rewritten to reach only what the host element contains.
|
||||
*
|
||||
* Inside the WebView the document owns its page, so its stylesheet says `*`, `html` and `body`
|
||||
* and means it. On the page the document is a guest: the same sheet, appended to the head of a
|
||||
* React Native Web application, restyles every screen the shell can show and keeps doing it after
|
||||
* the terminal is gone. Ruling 19's shape applies to CSS as it does to `window.onerror` — the
|
||||
* page mount may style only what it owns — so the document-level rules are dropped and every
|
||||
* remaining selector is held under the host's own class.
|
||||
*
|
||||
* A prefix rather than a shadow root: the document reads its elements by id through
|
||||
* `document.getElementById`, which does not cross a shadow boundary, and xterm's own sheet is
|
||||
* written against `.xterm` in the same document. Both would need a different program.
|
||||
*
|
||||
* The rewrite is textual because the input is: two flat stylesheets this repository writes or
|
||||
* generates, with no at-rules and no nesting. Anything else throws rather than passing a rule
|
||||
* through unscoped, and `document-style-scoping.test.ts` holds that.
|
||||
*/
|
||||
|
||||
/** A rule's selector list and its declaration block, as the source text writes them. */
|
||||
type StyleRule = { selectors: string; declarations: string }
|
||||
|
||||
function stripComments(text: string): string {
|
||||
return text.replaceAll(/\/\*[\s\S]*?\*\//g, '')
|
||||
}
|
||||
|
||||
/** The sheet as a flat list of rules; comments and whitespace between them are dropped. */
|
||||
function parseStyleRules(css: string): StyleRule[] {
|
||||
const rules: StyleRule[] = []
|
||||
let at = 0
|
||||
while (at < css.length) {
|
||||
const open = css.indexOf('{', at)
|
||||
if (open === -1) {
|
||||
break
|
||||
}
|
||||
const close = css.indexOf('}', open)
|
||||
if (close === -1) {
|
||||
throw new Error('the stylesheet has a rule that never closes')
|
||||
}
|
||||
const selectors = stripComments(css.slice(at, open)).trim()
|
||||
if (selectors.includes('@')) {
|
||||
throw new Error(`at-rules cannot be scoped to a host: ${selectors}`)
|
||||
}
|
||||
if (selectors.length === 0) {
|
||||
throw new Error('the stylesheet has a declaration block with no selector')
|
||||
}
|
||||
rules.push({ selectors, declarations: css.slice(open, close + 1) })
|
||||
at = close + 1
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
/** The element a selector starts from, or the empty string when it starts from a class or an id. */
|
||||
function leadingElement(selector: string): string {
|
||||
return selector.trim().split(/[\s>+~:.[#]/)[0] ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a selector addresses the document itself rather than something inside it.
|
||||
*
|
||||
* These are the rules a page must not carry: one that kept them would set the application's own
|
||||
* background and overflow, and every element's box model, for as long as the sheet is in the head.
|
||||
*/
|
||||
export function isDocumentLevelSelector(selectors: string): boolean {
|
||||
return selectors.split(',').some((one) => ['*', 'html', 'body'].includes(leadingElement(one)))
|
||||
}
|
||||
|
||||
/** The rules a page may not inject, as one line each. Exported so a test can name them. */
|
||||
export function documentLevelRules(css: string): string[] {
|
||||
return parseStyleRules(css)
|
||||
.filter((rule) => isDocumentLevelSelector(rule.selectors))
|
||||
.map((rule) => rule.selectors)
|
||||
}
|
||||
|
||||
/**
|
||||
* The same stylesheet with every selector held under `prefix`.
|
||||
*
|
||||
* A rule that addresses the document itself is dropped rather than prefixed: `.host *` is not
|
||||
* what `*` meant, and the page has no use for either reading.
|
||||
*/
|
||||
export function scopeStyleToHost(css: string, prefix: string): string {
|
||||
return parseStyleRules(css)
|
||||
.filter((rule) => !isDocumentLevelSelector(rule.selectors))
|
||||
.map((rule) => {
|
||||
const scoped = rule.selectors
|
||||
.split(',')
|
||||
.map((one) => `${prefix} ${one.trim()}`)
|
||||
.join(',\n')
|
||||
return `${scoped} ${rule.declarations}`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { colors } from '../../theme/mobile-theme'
|
||||
|
||||
/**
|
||||
* The rules that style the document itself, which only the WebView's document may carry.
|
||||
*
|
||||
* Inside the WebView this is the terminal's own page and these say so. On the page the document
|
||||
* is a guest in a React Native Web application, and the same three selectors would set that
|
||||
* application's background, its overflow and every element's box model — and keep doing it after
|
||||
* the terminal is gone. So the page never injects them; `document-style-scoping.ts` is what
|
||||
* separates them from the rules below, and it recognises them by their selectors rather than by
|
||||
* this split, so a fourth one added here is still caught there.
|
||||
*/
|
||||
export const TERMINAL_DOCUMENT_ROOT_STYLE = ` * { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
background: ${colors.terminalBg};
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}`
|
||||
|
||||
/**
|
||||
* The terminal's own elements, beside xterm's sheet.
|
||||
*
|
||||
* Split out of the document shell because the page needs exactly this and must not reach the
|
||||
* engine string the shell also splices in. The rules are addressed at the ids and classes
|
||||
* `document-markup.ts` declares, which is the other half of the same pair; the page holds every
|
||||
* one of them under its host element rather than letting them loose in the application.
|
||||
*/
|
||||
export const TERMINAL_DOCUMENT_ELEMENT_STYLE = ` #terminal-container {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
#terminal-surface {
|
||||
transform-origin: top left;
|
||||
display: inline-block;
|
||||
}
|
||||
.xterm { -webkit-user-select: none; user-select: none; font-variant-emoji: text; }
|
||||
.xterm .xterm-viewport {
|
||||
overflow-y: hidden !important;
|
||||
scrollbar-width: none !important;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.xterm .xterm-viewport::-webkit-scrollbar {
|
||||
display: none !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .xterm-scrollbar,
|
||||
.xterm .xterm-scrollbar {
|
||||
display: none !important;
|
||||
width: 0 !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
#scroll-indicator {
|
||||
position: fixed;
|
||||
top: 4px;
|
||||
right: 3px;
|
||||
bottom: 4px;
|
||||
width: 3px;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms linear;
|
||||
z-index: 7;
|
||||
}
|
||||
#scroll-indicator.visible { opacity: 0.72; }
|
||||
#scroll-thumb {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 3px;
|
||||
min-height: 24px;
|
||||
border-radius: 999px;
|
||||
background: ${colors.textSecondary};
|
||||
will-change: transform, height;
|
||||
}
|
||||
/* Why: selection overlay sits in unscaled viewport coords, above the
|
||||
transformed surface, so handle hit areas and Copy menu positions
|
||||
don't depend on getTotalScale() for their on-screen size. */
|
||||
#selection-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
display: none;
|
||||
}
|
||||
#selection-overlay.active { display: block; }
|
||||
.sel-handle {
|
||||
position: absolute;
|
||||
width: 44px; height: 44px;
|
||||
margin-left: -22px; margin-top: -22px;
|
||||
pointer-events: auto;
|
||||
background: transparent;
|
||||
}
|
||||
.sel-handle::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%; top: 22px;
|
||||
transform: translateX(-50%);
|
||||
width: 14px; height: 14px;
|
||||
background: #7aa2f7;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #c0caf5;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.sel-handle.start::before { top: 8px; }
|
||||
.sel-handle.start::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%; top: 22px;
|
||||
transform: translateX(-50%);
|
||||
width: 2px; height: 16px;
|
||||
background: #7aa2f7;
|
||||
}
|
||||
.sel-handle.end::before { top: 22px; }
|
||||
.sel-handle.end::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%; top: 6px;
|
||||
transform: translateX(-50%);
|
||||
width: 2px; height: 16px;
|
||||
background: #7aa2f7;
|
||||
}
|
||||
#sel-menu {
|
||||
position: absolute;
|
||||
pointer-events: auto;
|
||||
background: #2a2f4a;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
transform: translateY(-100%);
|
||||
margin-top: -12px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
#sel-menu button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #c0caf5;
|
||||
font: 600 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#sel-menu button:active { background: #414868; }
|
||||
#sel-menu button + button { border-left: 1px solid #414868; }`
|
||||
|
||||
/**
|
||||
* Both halves, in the order the WebView's `<head>` has always carried them.
|
||||
*
|
||||
* The concatenation is what the document shell splices in, so the emitted document does not move
|
||||
* for this split — the byte golden says whether that held.
|
||||
*/
|
||||
export const TERMINAL_DOCUMENT_STYLE = `${TERMINAL_DOCUMENT_ROOT_STYLE}
|
||||
${TERMINAL_DOCUMENT_ELEMENT_STYLE}`
|
||||
@@ -6,8 +6,8 @@ import { XTERM_HTML } from './terminal-webview-html'
|
||||
// uncovered region ships silently. A diff here means the emitted WebView source changed —
|
||||
// update these values only when that change is deliberate, and only after checking the
|
||||
// document still runs. Refactors that merely move slice boundaries must leave them alone.
|
||||
const EXPECTED_SHA256 = 'c84ce5fc7343546427ad875aeebea90e54560579a1d18b3b700076a1c4b4623f'
|
||||
const EXPECTED_LENGTH = 723480
|
||||
const EXPECTED_SHA256 = '9950f1770cd85ad2f80c69e074111869f6c66a724c87b66ba81f1ff10318a0ce'
|
||||
const EXPECTED_LENGTH = 726363
|
||||
|
||||
describe('terminal WebView payload', () => {
|
||||
it('composes the expected document', () => {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { TerminalWebViewCommand } from './terminal-webview-messages'
|
||||
|
||||
/**
|
||||
* The two promises the terminal handle hands out, and the notifies that settle them.
|
||||
*
|
||||
* `awaitReady` waits for the document's `init` rAF chain — `term.open`, renderService population,
|
||||
* first paint — because a measure that runs synchronously after init finds `term` null or cells
|
||||
* of size zero. `measureFitDimensions` waits for the document's answer. Both are promises held
|
||||
* across a message round trip, both have a timeout for the case where the document never answers,
|
||||
* and both are the same on either host, so they live here rather than in the controller that owns
|
||||
* the readiness handshake.
|
||||
*/
|
||||
|
||||
const READY_TIMEOUT_MS = 3000
|
||||
const MEASURE_TIMEOUT_MS = 2000
|
||||
/** Below these the fit is not a terminal anyone can read, and the caller disables fit-to-phone. */
|
||||
const MIN_FIT_COLS = 20
|
||||
const MIN_FIT_ROWS = 8
|
||||
|
||||
export type TerminalFitDimensions = { cols: number; rows: number }
|
||||
|
||||
export function createTerminalWebViewReadyPromises() {
|
||||
let readyPromise: Promise<void> | null = null
|
||||
let readyResolve: (() => void) | null = null
|
||||
let measureResolve: ((result: TerminalFitDimensions | null) => void) | null = null
|
||||
|
||||
/**
|
||||
* Arms a fresh ready promise, resolving any prior one first.
|
||||
*
|
||||
* Why: an awaiter from the previous generation would otherwise sit on the timeout below — each
|
||||
* leaked timer and closure pinned an awaiting measure caller for the full 3s under rapid
|
||||
* re-init (orientation change, multiple resubscribes), delaying cold-start fit chains.
|
||||
*/
|
||||
function armReady() {
|
||||
const priorResolve = readyResolve
|
||||
readyResolve = null
|
||||
readyPromise = null
|
||||
priorResolve?.()
|
||||
readyPromise = new Promise<void>((resolve) => {
|
||||
readyResolve = resolve
|
||||
})
|
||||
}
|
||||
|
||||
function resolveReady() {
|
||||
const resolve = readyResolve
|
||||
readyResolve = null
|
||||
readyPromise = null
|
||||
resolve?.()
|
||||
}
|
||||
|
||||
async function awaitReady(): Promise<void> {
|
||||
const pending = readyPromise
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false
|
||||
const timeout = setTimeout(() => {
|
||||
settled = true
|
||||
resolve()
|
||||
}, READY_TIMEOUT_MS)
|
||||
void pending.finally(() => {
|
||||
if (!settled) {
|
||||
clearTimeout(timeout)
|
||||
settled = true
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function measure(
|
||||
send: (command: TerminalWebViewCommand) => void,
|
||||
containerHeight?: number
|
||||
): Promise<TerminalFitDimensions | null> {
|
||||
return new Promise((resolve) => {
|
||||
measureResolve?.(null)
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
const finish = (result: TerminalFitDimensions | null) => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
if (measureResolve === finish) {
|
||||
measureResolve = null
|
||||
}
|
||||
resolve(result)
|
||||
}
|
||||
measureResolve = finish
|
||||
send({ type: 'measure', containerHeight })
|
||||
// Why: if the document doesn't respond (e.g., xterm failed to load), resolve null so the
|
||||
// caller can disable Fit to Phone rather than hanging indefinitely.
|
||||
timeout = setTimeout(() => {
|
||||
if (measureResolve === finish) {
|
||||
finish(null)
|
||||
}
|
||||
}, MEASURE_TIMEOUT_MS)
|
||||
})
|
||||
}
|
||||
|
||||
function resolveMeasure(msg: Record<string, unknown>) {
|
||||
const resolve = measureResolve
|
||||
measureResolve = null
|
||||
if (!resolve) {
|
||||
return
|
||||
}
|
||||
const cols = typeof msg.cols === 'number' ? msg.cols : null
|
||||
const rows = typeof msg.rows === 'number' ? msg.rows : null
|
||||
resolve(cols && rows && cols >= MIN_FIT_COLS && rows >= MIN_FIT_ROWS ? { cols, rows } : null)
|
||||
}
|
||||
|
||||
return { armReady, awaitReady, measure, resolveMeasure, resolveReady }
|
||||
}
|
||||
|
||||
export function useTerminalWebViewReadyPromises() {
|
||||
return useMemo(() => createTerminalWebViewReadyPromises(), [])
|
||||
}
|
||||
@@ -9,7 +9,11 @@ import { XTERM_HTML } from './terminal-webview-html'
|
||||
const reflowSource = await generatedDocumentModule('reflow')
|
||||
// Use the assembled document so the test covers what the WebView actually runs.
|
||||
const htmlSource = XTERM_HTML
|
||||
const handleSource = readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8')
|
||||
// The handle is built by the controller both components share, which is where the wiring is read.
|
||||
const handleSource = readFileSync(
|
||||
new URL('./use-terminal-webview-controller.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function reflowFnBody(): string {
|
||||
const start = reflowSource.indexOf('function reflow(cols, rows) {')
|
||||
@@ -49,7 +53,8 @@ describe('terminal WebView reflow', () => {
|
||||
})
|
||||
|
||||
it('does not locally resize hidden WebViews to a one-column grid', () => {
|
||||
expect(htmlSource).toContain('scope.MIN_FIT_COLS = 20;')
|
||||
// Ruling 21: the floor's value is in the scope factory, not in a parse-time write.
|
||||
expect(htmlSource).toContain('MIN_FIT_COLS: 20,')
|
||||
expect(htmlSource).toContain('if (cols < scope.MIN_FIT_COLS) {')
|
||||
expect(htmlSource).toContain('flog("measure-skip-small-width"')
|
||||
expect(htmlSource).toContain('notify({ type: "measure-result", cols: null, rows: null });')
|
||||
@@ -77,7 +82,9 @@ describe('terminal WebView reflow', () => {
|
||||
// between them; if its IIFE-time code threw, the listener below would
|
||||
// never bind and reflow messages would silently no-op.
|
||||
const reflowAt = XTERM_HTML.indexOf('function reflow(cols, rows) {')
|
||||
const dispatchAt = XTERM_HTML.indexOf('const dispatch = {\n mode: "idle"')
|
||||
// Ruling 21 moved the dispatcher's latch onto the scope, so the dispatcher is located by
|
||||
// its own first handler rather than by the object it used to declare.
|
||||
const dispatchAt = XTERM_HTML.indexOf('function onDocumentTouchStart(e) {')
|
||||
const listenerAt = XTERM_HTML.indexOf('window.addEventListener("message"')
|
||||
expect(reflowAt).toBeGreaterThanOrEqual(0)
|
||||
expect(dispatchAt).toBeGreaterThan(reflowAt)
|
||||
|
||||
@@ -6,6 +6,8 @@ import { XTERM_HTML } from './terminal-webview-html'
|
||||
// generated document. Concatenated so assertions resolve regardless of file.
|
||||
const source =
|
||||
readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8') +
|
||||
readFileSync(new URL('./use-terminal-webview-controller.ts', import.meta.url), 'utf8') +
|
||||
readFileSync(new URL('./terminal-webview-ready-promises.ts', import.meta.url), 'utf8') +
|
||||
readFileSync(new URL('./terminal-webview-pending-messages.ts', import.meta.url), 'utf8') +
|
||||
XTERM_HTML
|
||||
const sessionSource = readFileSync(
|
||||
@@ -31,7 +33,7 @@ describe('TerminalWebView scroll routing', () => {
|
||||
})
|
||||
|
||||
it('maps a downward pull at the bottom to older scrollback rows', () => {
|
||||
expect(source).toContain('const deltaY = ts.lastY - y;')
|
||||
expect(source).toContain('const deltaY = scope.touchGesture.lastY - y;')
|
||||
expect(source).toContain('scope.smoothScrollOffsetY -= deltaY;')
|
||||
expect(source).toContain(
|
||||
'const lines = Math.trunc(-scope.smoothScrollOffsetY / effectiveCellH);'
|
||||
@@ -69,7 +71,9 @@ describe('TerminalWebView scroll routing', () => {
|
||||
expect(momentumBlock.indexOf('if (shouldRouteScrollToTerminalInput())')).toBeLessThan(
|
||||
momentumBlock.indexOf('if (!applyNormalBufferScrollDelta(delta))')
|
||||
)
|
||||
expect(momentumBlock).toContain('routeScrollLines(lines, ts.lastX, ts.lastY);')
|
||||
expect(momentumBlock).toContain(
|
||||
'routeScrollLines(lines, scope.touchGesture.lastX, scope.touchGesture.lastY);'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not rubber-band normal scroll at scrollback edges', () => {
|
||||
@@ -88,14 +92,14 @@ describe('TerminalWebView scroll routing', () => {
|
||||
'{ capture: true, passive: false }'
|
||||
)
|
||||
expect(touchMoveBlock).toContain('if (enqueueNormalBufferScrollDelta(deltaY))')
|
||||
expect(touchMoveBlock).toContain('ts.velY = 0;')
|
||||
expect(touchMoveBlock).toContain('scope.touchGesture.velY = 0;')
|
||||
|
||||
const momentumBlock = sliceBetween(
|
||||
'let momentumStep = function()',
|
||||
'if (Math.abs(vel) > MIN_VEL)'
|
||||
)
|
||||
expect(momentumBlock).toContain('if (!applyNormalBufferScrollDelta(delta))')
|
||||
expect(momentumBlock).toContain('ts.momentumId = null;')
|
||||
expect(momentumBlock).toContain('scope.touchGesture.momentumId = null;')
|
||||
})
|
||||
|
||||
it('coalesces normal touch scroll row commits onto animation frames', () => {
|
||||
@@ -105,7 +109,9 @@ describe('TerminalWebView scroll routing', () => {
|
||||
)
|
||||
expect(enqueueBlock).toContain('scope.pendingNormalScrollDeltaY += deltaY;')
|
||||
expect(enqueueBlock).toContain('if (scope.normalScrollFrameId !== null) {')
|
||||
expect(enqueueBlock).toContain('scope.normalScrollFrameId = requestAnimationFrame(function()')
|
||||
// Ruling 21: every document frame goes through the scope's registry so dispose can take it
|
||||
// back; the id is still held here, which is what the reset below cancels.
|
||||
expect(enqueueBlock).toContain('scope.normalScrollFrameId = scheduleDocumentFrame(function()')
|
||||
expect(enqueueBlock).toContain('applyNormalBufferScrollDelta(delta)')
|
||||
|
||||
const resetBlock = sliceBetween(
|
||||
@@ -137,13 +143,15 @@ describe('TerminalWebView scroll routing', () => {
|
||||
})
|
||||
|
||||
it('clears WebView await timers when the real response wins', () => {
|
||||
const measureBlock = sliceBetween('measureFitDimensions(', 'resetZoom()')
|
||||
// C7.5 moved both promises into `terminal-webview-ready-promises.ts`, which both components
|
||||
// reach through the controller; the two blocks are the same code in their new home.
|
||||
const measureBlock = sliceBetween('function measure(', 'function resolveMeasure')
|
||||
expect(measureBlock).toContain('clearTimeout(timeout)')
|
||||
expect(measureBlock).toContain('measureResolveRef.current === finish')
|
||||
expect(measureBlock).toContain('measureResolve === finish')
|
||||
|
||||
const readyBlock = sliceBetween('async awaitReady()', '})')
|
||||
const readyBlock = sliceBetween('async function awaitReady()', 'function measure(')
|
||||
expect(readyBlock).toContain('clearTimeout(timeout)')
|
||||
expect(readyBlock).toContain('void p.finally')
|
||||
expect(readyBlock).toContain('void pending.finally')
|
||||
})
|
||||
|
||||
it('hides xterm scrollbars and drives the mobile scroll indicator from committed rows', () => {
|
||||
@@ -175,7 +183,7 @@ describe('TerminalWebView scroll routing', () => {
|
||||
|
||||
it('smooths velocity samples and uses lower friction for mobile momentum', () => {
|
||||
expect(source).toContain('function updateTouchVelocity(deltaY, dt)')
|
||||
expect(source).toContain('ts.velY * 0.55 + instantVelocity * 0.45')
|
||||
expect(source).toContain('scope.touchGesture.velY * 0.55 + instantVelocity * 0.45')
|
||||
expect(source).toContain('const FRICTION = 0.972;')
|
||||
expect(source).toContain('const MIN_VEL = 0.012;')
|
||||
})
|
||||
@@ -215,17 +223,14 @@ describe('TerminalWebView scroll routing', () => {
|
||||
expect(source).toContain('if (mouseTrackingMode === "x10") {\n return press;')
|
||||
expect(source).toContain('if (col > 126 || row > 126) {\n return "";')
|
||||
|
||||
const touchEndBlock = sliceBetween(
|
||||
'document.addEventListener(\n "touchend"',
|
||||
'{ capture: true, passive: true }'
|
||||
)
|
||||
const touchEndBlock = sliceBetween('function onDocumentTouchEnd(e)', '\n function ')
|
||||
expect(touchEndBlock).toContain(
|
||||
'notifyTerminalSurfaceTap(scope.tapCandidate.x, scope.tapCandidate.y, true)'
|
||||
)
|
||||
|
||||
const tapHandlerBlock = sliceBetween(
|
||||
'function notifyTerminalSurfaceTap(originX, originY, focusKeyboard)',
|
||||
'document.addEventListener(\n "touchstart"'
|
||||
'function onDocumentTouchStart(e)'
|
||||
)
|
||||
expect(tapHandlerBlock.indexOf('oscLinkAtViewportPoint')).toBeLessThan(
|
||||
tapHandlerBlock.indexOf('urlAtViewportPoint')
|
||||
|
||||
@@ -26,15 +26,15 @@ const terminalHtmlSource = XTERM_HTML
|
||||
const terminalWebglRecoverySource = await generatedDocumentModule('webgl-recovery')
|
||||
|
||||
function extractStatusDotNormalizer() {
|
||||
const declarationStart = terminalHtmlSource.indexOf(' scope.CLAUDE_STATUS_DOT =')
|
||||
const declarationEnd = terminalHtmlSource.indexOf(' scope.PRIVATE_MODE_SCAN_TAIL_LIMIT')
|
||||
// Ruling 21 put the dot constants in the scope factory, which the preamble already carries, so
|
||||
// what is sliced here is the normalizer itself and nothing else.
|
||||
const declarationAt = terminalHtmlSource.indexOf(' const statusDot = String.fromCharCode(9210);')
|
||||
const functionStart = terminalHtmlSource.indexOf(' function isStatusDotPresentationSelector')
|
||||
const functionEnd = terminalHtmlSource.indexOf('\n function enqueueWrite', functionStart)
|
||||
expect(declarationStart).toBeGreaterThanOrEqual(0)
|
||||
expect(declarationEnd).toBeGreaterThan(declarationStart)
|
||||
expect(functionStart).toBeGreaterThan(declarationEnd)
|
||||
expect(declarationAt).toBeGreaterThanOrEqual(0)
|
||||
expect(functionStart).toBeGreaterThan(declarationAt)
|
||||
expect(functionEnd).toBeGreaterThan(functionStart)
|
||||
return `${documentScopePreamble()}${terminalHtmlSource.slice(declarationStart, declarationEnd)}\n${terminalHtmlSource.slice(functionStart, functionEnd)}`
|
||||
return `${documentScopePreamble()}${terminalHtmlSource.slice(functionStart, functionEnd)}`
|
||||
}
|
||||
|
||||
function normalizeStatusDotChunks(chunks: string[]) {
|
||||
@@ -54,16 +54,25 @@ function resolveTerminalFontFamily(navigatorValue: {
|
||||
// Slice only the font block itself (isIOSWebView + terminalFontFamily), anchored
|
||||
// on font-related markers so unrelated edits below it can't break this extraction.
|
||||
const functionStart = terminalHtmlSource.indexOf(' function isIOSWebView()')
|
||||
const declarationLine = terminalHtmlSource.indexOf(' scope.terminalFontFamily =', functionStart)
|
||||
// Ruling 20 put the assignment inside `startTextScaling`, whose earlier statements read
|
||||
// elements and constants this has nothing to do with. So the declarations come from one slice
|
||||
// and the font line from another, which is what "only the font block itself" already meant.
|
||||
const declarationsEnd = terminalHtmlSource.indexOf(' function startTextScaling()', functionStart)
|
||||
const declarationLine = terminalHtmlSource.indexOf(
|
||||
' scope.terminalFontFamily =',
|
||||
declarationsEnd
|
||||
)
|
||||
const declarationEnd = terminalHtmlSource.indexOf(';\n', declarationLine) + 1
|
||||
expect(functionStart).toBeGreaterThanOrEqual(0)
|
||||
expect(declarationLine).toBeGreaterThan(functionStart)
|
||||
expect(declarationsEnd).toBeGreaterThan(functionStart)
|
||||
expect(declarationLine).toBeGreaterThan(declarationsEnd)
|
||||
expect(declarationEnd).toBeGreaterThan(declarationLine)
|
||||
const context: { navigator: typeof navigatorValue; output?: string } = {
|
||||
navigator: navigatorValue
|
||||
}
|
||||
new Script(`
|
||||
${documentScopePreamble()}${terminalHtmlSource.slice(functionStart, declarationEnd)}
|
||||
${documentScopePreamble()}${terminalHtmlSource.slice(functionStart, declarationsEnd)}
|
||||
${terminalHtmlSource.slice(declarationLine, declarationEnd)}
|
||||
output = scope.terminalFontFamily;
|
||||
`).runInNewContext(context)
|
||||
return context.output ?? ''
|
||||
@@ -94,12 +103,13 @@ describe('TerminalWebView text zoom', () => {
|
||||
|
||||
it('forces the Claude status dot to text presentation before xterm writes', () => {
|
||||
expect(terminalHtmlSource).toContain('font-variant-emoji: text')
|
||||
expect(terminalHtmlSource).toContain('scope.CLAUDE_STATUS_DOT = String.fromCharCode(9210)')
|
||||
// Ruling 21: the dot's value is in the scope factory, not in a parse-time write.
|
||||
expect(terminalHtmlSource).toContain('const statusDot = String.fromCharCode(9210);')
|
||||
expect(terminalHtmlSource).toContain(
|
||||
'scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(65038)'
|
||||
'const textPresentationSelector = String.fromCharCode(65038);'
|
||||
)
|
||||
expect(terminalHtmlSource).toContain(
|
||||
'scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(65039)'
|
||||
'const emojiPresentationSelector = String.fromCharCode(65039);'
|
||||
)
|
||||
expect(terminalHtmlSource).toContain('function normalizeStatusDotPresentation(data)')
|
||||
expect(terminalHtmlSource).toContain(
|
||||
@@ -168,12 +178,16 @@ describe('TerminalWebView text zoom', () => {
|
||||
|
||||
it('uses the bundled WebGL-capable xterm stack and platform-safe font fallbacks', () => {
|
||||
expect(terminalHtmlSource).not.toContain('cdn.jsdelivr.net')
|
||||
expect(terminalWebglRecoverySource).toContain('window.WebglAddon.WebglAddon')
|
||||
// C7.5 moved the engine constructors onto the scope so the page can set them; inside the
|
||||
// document the default still reads the bundled engine, and it is now the preamble that
|
||||
// carries the read rather than the recovery module.
|
||||
expect(documentScopePreamble()).toContain('window.WebglAddon.WebglAddon')
|
||||
expect(terminalWebglRecoverySource).toContain('scope.createWebglAddon()')
|
||||
expect(terminalHtmlSource).toContain('function isIOSWebView()')
|
||||
expect(terminalHtmlSource).toContain('fontFamily: scope.terminalFontFamily')
|
||||
expect(terminalHtmlSource).toContain('fontWeight: "300"')
|
||||
expect(terminalHtmlSource).toContain('fontWeightBold: "500"')
|
||||
expect(terminalWebglRecoverySource).toContain('new window.WebglAddon.WebglAddon()')
|
||||
expect(documentScopePreamble()).toContain('new window.WebglAddon.WebglAddon()')
|
||||
})
|
||||
|
||||
const IOS_IPHONE_NAVIGATOR = {
|
||||
|
||||
@@ -8,6 +8,13 @@ import {
|
||||
} from './terminal-write-coalescer'
|
||||
|
||||
const webViewSource = readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8')
|
||||
// C7.5 moved everything that is not `react-native-webview` into the controller both components
|
||||
// share, so the coalescer's boundaries are read there; the component is still read for the two
|
||||
// WebView lifecycle events that reach it.
|
||||
const controllerSource = readFileSync(
|
||||
new URL('./use-terminal-webview-controller.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
// Simulates TerminalWebView's postMessage: ready → deliver, not ready → queue.
|
||||
// There is no React render harness in the node environment, so the boundary
|
||||
@@ -105,27 +112,27 @@ describe('terminal write coalescer boundaries', () => {
|
||||
})
|
||||
|
||||
it('routes handle.write through the coalescer whose delivery posts the write command', () => {
|
||||
expect(webViewSource).toContain(
|
||||
expect(controllerSource).toContain(
|
||||
"createTerminalWriteCoalescer((data) => postMessage({ type: 'write', data }))"
|
||||
)
|
||||
const writeStart = webViewSource.indexOf('write(data: string) {')
|
||||
const writeStart = controllerSource.indexOf('write(data: string) {')
|
||||
expect(writeStart).toBeGreaterThanOrEqual(0)
|
||||
const writeBody = webViewSource.slice(writeStart, writeStart + 120)
|
||||
const writeBody = controllerSource.slice(writeStart, writeStart + 120)
|
||||
expect(writeBody).toContain('writeCoalescer.write(data)')
|
||||
expect(writeBody).not.toContain('postMessage')
|
||||
})
|
||||
|
||||
it('clears the coalescer before posting init and clear (snapshot supersession)', () => {
|
||||
// Anchor on the init() signature (unique) — 'init(' alone also matches comments.
|
||||
const initStart = webViewSource.indexOf('initialData?: string,')
|
||||
const initClear = webViewSource.indexOf('writeCoalescer.clear()', initStart)
|
||||
const initPost = webViewSource.indexOf("type: 'init'", initStart)
|
||||
const initStart = controllerSource.indexOf('initialData?: string,')
|
||||
const initClear = controllerSource.indexOf('writeCoalescer.clear()', initStart)
|
||||
const initPost = controllerSource.indexOf("type: 'init'", initStart)
|
||||
expect(initStart).toBeGreaterThanOrEqual(0)
|
||||
expect(initClear).toBeGreaterThan(initStart)
|
||||
expect(initClear).toBeLessThan(initPost)
|
||||
|
||||
const clearStart = webViewSource.indexOf('clear() {', initPost)
|
||||
const clearBody = webViewSource.slice(clearStart, clearStart + 160)
|
||||
const clearStart = controllerSource.indexOf('clear() {', initPost)
|
||||
const clearBody = controllerSource.slice(clearStart, clearStart + 160)
|
||||
expect(clearStart).toBeGreaterThanOrEqual(0)
|
||||
expect(clearBody.indexOf('writeCoalescer.clear()')).toBeGreaterThanOrEqual(0)
|
||||
expect(clearBody.indexOf('writeCoalescer.clear()')).toBeLessThan(
|
||||
@@ -135,9 +142,9 @@ describe('terminal write coalescer boundaries', () => {
|
||||
|
||||
it('flushes pending writes before resize and reflow so boundaries observe prior bytes', () => {
|
||||
for (const method of ['resize', 'reflow'] as const) {
|
||||
const start = webViewSource.indexOf(`${method}(cols: number, rows: number) {`)
|
||||
const start = controllerSource.indexOf(`${method}(cols: number, rows: number) {`)
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
const body = webViewSource.slice(start, start + 300)
|
||||
const body = controllerSource.slice(start, start + 300)
|
||||
const flushIndex = body.indexOf('writeCoalescer.flushNow()')
|
||||
const postIndex = body.indexOf(`postMessage({ type: '${method}'`)
|
||||
expect(flushIndex).toBeGreaterThanOrEqual(0)
|
||||
@@ -146,19 +153,26 @@ describe('terminal write coalescer boundaries', () => {
|
||||
})
|
||||
|
||||
it('clears the coalescer in both document-lifecycle hooks alongside pendingMessages', () => {
|
||||
for (const hook of ['const handleLoadStart', 'const handleContentProcessDidTerminate']) {
|
||||
const start = webViewSource.indexOf(hook)
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
const body = webViewSource.slice(start, webViewSource.indexOf('}, [', start))
|
||||
expect(body).toContain('pendingMessages.clear()')
|
||||
expect(body).toContain('writeCoalescer.clear()')
|
||||
}
|
||||
// Both hooks now reach one function, so the clearing is asserted once where it lives and the
|
||||
// two WebView events are asserted to be the callers. Reading only the component would pass on
|
||||
// a `resetReadiness` that had quietly stopped clearing either one.
|
||||
const start = controllerSource.indexOf('const resetReadiness = useCallback')
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
const body = controllerSource.slice(start, controllerSource.indexOf('}, [', start))
|
||||
expect(body).toContain('pendingMessages.clear()')
|
||||
expect(body).toContain('writeCoalescer.clear()')
|
||||
expect(webViewSource).toContain('onLoadStart={resetReadiness}')
|
||||
const terminated = webViewSource.indexOf('const handleContentProcessDidTerminate')
|
||||
expect(terminated).toBeGreaterThanOrEqual(0)
|
||||
expect(webViewSource.slice(terminated, webViewSource.indexOf('}, [', terminated))).toContain(
|
||||
'resetReadiness()'
|
||||
)
|
||||
})
|
||||
|
||||
it('clears the coalescer on unmount so no timer leaks', () => {
|
||||
const cleanupStart = webViewSource.indexOf('useEffect(() => {\n return () => {')
|
||||
const cleanupStart = controllerSource.indexOf('useEffect(() => {\n return () => {')
|
||||
expect(cleanupStart).toBeGreaterThanOrEqual(0)
|
||||
const cleanupBody = webViewSource.slice(cleanupStart, cleanupStart + 160)
|
||||
const cleanupBody = controllerSource.slice(cleanupStart, cleanupStart + 160)
|
||||
expect(cleanupBody).toContain('writeCoalescer.clear()')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges'
|
||||
import type { TerminalWebViewHandle, TerminalWebViewProps } from './terminal-webview-contract'
|
||||
import { useTerminalWebViewEngineErrorState } from './terminal-webview-engine-error-state'
|
||||
import { useTerminalWebReadyWatchdog } from './terminal-webview-ready-watchdog'
|
||||
import type { TerminalWebViewCommand } from './terminal-webview-messages'
|
||||
import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages'
|
||||
import { dispatchTerminalWebViewNotification } from './terminal-webview-notification-dispatch'
|
||||
import { routeTerminalQueryReply } from './terminal-webview-query-reply-routing'
|
||||
import { useTerminalWebViewReadyPromises } from './terminal-webview-ready-promises'
|
||||
import { createTerminalWriteCoalescer } from './terminal-write-coalescer'
|
||||
|
||||
/**
|
||||
* Everything `TerminalWebView` does that is not about `react-native-webview`.
|
||||
*
|
||||
* The document is the same program on both platforms — inside the WebView it is the generated
|
||||
* script, on the page it is the modules that script is generated from — so the readiness
|
||||
* handshake, the pending queue, the write coalescer, the ready and measure promises and the whole
|
||||
* imperative handle are the same too. What differs is only how a command reaches the document and
|
||||
* how a notify comes back: a `postMessage` across the WebView bridge, or a direct call.
|
||||
*
|
||||
* So that difference is the two arguments, and both components are the small part that is left.
|
||||
* Writing the page's half as a second copy of this would be the fork the series exists to avoid:
|
||||
* the handle is the contract every consumer above it holds, and two implementations of it drift.
|
||||
*/
|
||||
|
||||
export type TerminalWebViewTransport = {
|
||||
/** Hands one command, with its id already assigned, to the document. */
|
||||
post: (command: TerminalWebViewCommand & { id: number }) => void
|
||||
/**
|
||||
* Whether returning to the foreground has to re-prove the document is alive.
|
||||
*
|
||||
* iOS can keep the native view while discarding the WebView's JS state, so the native side
|
||||
* pings and replays nothing until that exact document answers. The page has no second content
|
||||
* process to lose: its document is the page's own modules, and if they were gone so was the
|
||||
* component holding this handle.
|
||||
*
|
||||
* Asked at the moment of recovery rather than at render, because the platform the native
|
||||
* component reads is the running one and a handle built once must not cache it.
|
||||
*/
|
||||
pingsOnForegroundRecovery: () => boolean
|
||||
}
|
||||
|
||||
export function useTerminalWebViewController(
|
||||
props: TerminalWebViewProps,
|
||||
transport: TerminalWebViewTransport
|
||||
) {
|
||||
const {
|
||||
terminalTheme,
|
||||
textScale = 1,
|
||||
onWebReady,
|
||||
onEngineError,
|
||||
onSelectionMode,
|
||||
onSelectionCopy,
|
||||
onSelectionEvicted,
|
||||
onModesChanged,
|
||||
onKeyboardAvoidanceMetrics,
|
||||
onHaptic,
|
||||
onTerminalInput,
|
||||
onTerminalQueryReply,
|
||||
onTerminalTap,
|
||||
onFileTap,
|
||||
onOpenUrl,
|
||||
onTextScaleChange
|
||||
} = props
|
||||
const { pingsOnForegroundRecovery, post } = transport
|
||||
const isWebReadyRef = useRef(false)
|
||||
const pendingMessages = useMemo(() => createTerminalWebViewPendingMessages(), [])
|
||||
const messageIdRef = useRef(0)
|
||||
const pendingPingIdRef = useRef<number | null>(null)
|
||||
const terminalThemeKey = useMemo(() => JSON.stringify(terminalTheme ?? null), [terminalTheme])
|
||||
// Why: each init() call posts 'init' to the document and arms a fresh ready promise. The
|
||||
// document's init() rAF chain ends with a 'ready' notify that resolves it. measureFitDimensions
|
||||
// awaits this so it doesn't race ahead of term.open() / renderService population.
|
||||
const promises = useTerminalWebViewReadyPromises()
|
||||
const { clearEngineError, engineError, reportEngineError, reportNativeEngineError } =
|
||||
useTerminalWebViewEngineErrorState(onEngineError)
|
||||
const { armWebReadyWatchdog, clearWebReadyWatchdog } = useTerminalWebReadyWatchdog(
|
||||
isWebReadyRef,
|
||||
reportEngineError
|
||||
)
|
||||
|
||||
const sendToDocument = useCallback(
|
||||
(msg: TerminalWebViewCommand) => {
|
||||
messageIdRef.current += 1
|
||||
const id = messageIdRef.current
|
||||
post({ ...msg, id })
|
||||
return id
|
||||
},
|
||||
[post]
|
||||
)
|
||||
|
||||
const flushPendingMessages = useCallback(() => {
|
||||
pendingMessages.flush(sendToDocument)
|
||||
}, [pendingMessages, sendToDocument])
|
||||
|
||||
const postMessage = useCallback(
|
||||
(msg: TerminalWebViewCommand) => {
|
||||
if (!isWebReadyRef.current) {
|
||||
pendingMessages.queue(msg)
|
||||
return
|
||||
}
|
||||
sendToDocument(msg)
|
||||
},
|
||||
[pendingMessages, sendToDocument]
|
||||
)
|
||||
|
||||
// Why: a busy PTY delivers ~200 stream frames/s; coalescing here collapses the
|
||||
// per-frame bridge + WebKit IPC + paint cost that runs the phone hot (#9302).
|
||||
const writeCoalescer = useMemo(
|
||||
() => createTerminalWriteCoalescer((data) => postMessage({ type: 'write', data })),
|
||||
[postMessage]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
writeCoalescer.clear()
|
||||
}
|
||||
}, [writeCoalescer])
|
||||
|
||||
const confirmWebReady = useCallback(
|
||||
(notifyParent: boolean) => {
|
||||
pendingPingIdRef.current = null
|
||||
isWebReadyRef.current = true
|
||||
clearWebReadyWatchdog()
|
||||
clearEngineError()
|
||||
if (notifyParent) {
|
||||
onWebReady?.()
|
||||
}
|
||||
// Why: reload clears queued commands, so readiness must always restore the
|
||||
// native-selected theme even when its value did not change in React.
|
||||
sendToDocument({ type: 'set-theme', terminalTheme })
|
||||
flushPendingMessages()
|
||||
},
|
||||
[
|
||||
clearEngineError,
|
||||
clearWebReadyWatchdog,
|
||||
flushPendingMessages,
|
||||
onWebReady,
|
||||
sendToDocument,
|
||||
terminalTheme
|
||||
]
|
||||
)
|
||||
|
||||
/** One notify from the document, already parsed. */
|
||||
const receive = useCallback(
|
||||
(msg: Record<string, unknown>) => {
|
||||
routeTerminalQueryReply(msg, onTerminalQueryReply)
|
||||
|
||||
if (msg.type === 'web-ready') {
|
||||
confirmWebReady(true)
|
||||
} else if (
|
||||
msg.type === 'pong' &&
|
||||
typeof msg.pingId === 'number' &&
|
||||
msg.pingId === pendingPingIdRef.current
|
||||
) {
|
||||
confirmWebReady(false)
|
||||
} else if (msg.type === 'ready') {
|
||||
// Why: the document's init() rAF chain has run — term is open, renderService is
|
||||
// populated, first paint has happened. Resolve any pending awaitReady() so a queued
|
||||
// measure can now safely read cell dims.
|
||||
promises.resolveReady()
|
||||
} else if (msg.type === 'measure-result') {
|
||||
promises.resolveMeasure(msg)
|
||||
} else {
|
||||
dispatchTerminalWebViewNotification(msg, {
|
||||
reportEngineError,
|
||||
onSelectionMode,
|
||||
onSelectionCopy,
|
||||
onSelectionEvicted,
|
||||
onModesChanged,
|
||||
onKeyboardAvoidanceMetrics,
|
||||
onHaptic,
|
||||
onTerminalInput,
|
||||
onTerminalTap,
|
||||
onFileTap,
|
||||
onOpenUrl,
|
||||
onTextScaleChange
|
||||
})
|
||||
}
|
||||
},
|
||||
[
|
||||
confirmWebReady,
|
||||
promises,
|
||||
reportEngineError,
|
||||
onSelectionMode,
|
||||
onSelectionCopy,
|
||||
onSelectionEvicted,
|
||||
onModesChanged,
|
||||
onKeyboardAvoidanceMetrics,
|
||||
onHaptic,
|
||||
onTerminalInput,
|
||||
onTerminalQueryReply,
|
||||
onTerminalTap,
|
||||
onFileTap,
|
||||
onOpenUrl,
|
||||
onTextScaleChange
|
||||
]
|
||||
)
|
||||
|
||||
/**
|
||||
* The document is gone or about to be replaced: nothing queued belongs to the next one.
|
||||
*
|
||||
* Why: messages queued for a previous generation are stale after a reload; dropping them avoids
|
||||
* replaying terminal chunks before the next init snapshot.
|
||||
*/
|
||||
const resetReadiness = useCallback(() => {
|
||||
isWebReadyRef.current = false
|
||||
pendingPingIdRef.current = null
|
||||
pendingMessages.clear()
|
||||
writeCoalescer.clear()
|
||||
armWebReadyWatchdog()
|
||||
}, [armWebReadyWatchdog, pendingMessages, writeCoalescer])
|
||||
|
||||
useEffect(() => {
|
||||
postMessage({ type: 'set-theme', terminalTheme })
|
||||
}, [postMessage, terminalThemeKey, terminalTheme])
|
||||
|
||||
// Why: live-apply text-size changes to an already-mounted terminal (the pane
|
||||
// stays alive while the user visits Settings), so no terminal reload is needed.
|
||||
useEffect(() => {
|
||||
postMessage({ type: 'set-font-scale', fontScale: textScale })
|
||||
}, [postMessage, textScale])
|
||||
|
||||
const handle = useMemo<TerminalWebViewHandle>(
|
||||
() => ({
|
||||
prepareForForegroundRecovery() {
|
||||
if (!pingsOnForegroundRecovery()) {
|
||||
return
|
||||
}
|
||||
// Why: direct ping is the only command allowed through while readiness is
|
||||
// invalid; init/write commands queue until this exact document answers.
|
||||
isWebReadyRef.current = false
|
||||
armWebReadyWatchdog()
|
||||
pendingPingIdRef.current = sendToDocument({ type: 'ping' })
|
||||
},
|
||||
write(data: string) {
|
||||
writeCoalescer.write(data)
|
||||
},
|
||||
init(
|
||||
cols: number,
|
||||
rows: number,
|
||||
initialData?: string,
|
||||
preserveScroll?: boolean,
|
||||
oscLinks?: TerminalOscLinkRange[]
|
||||
) {
|
||||
// Why: arm a fresh ready promise BEFORE posting init. The document resolves it via the
|
||||
// 'ready' notify at the end of its rAF chain.
|
||||
promises.armReady()
|
||||
// Why: pending chunks are pre-snapshot data; the init snapshot supersedes
|
||||
// them, and writing them after init would corrupt the fresh buffer.
|
||||
writeCoalescer.clear()
|
||||
postMessage({
|
||||
type: 'init',
|
||||
cols,
|
||||
rows,
|
||||
initialData,
|
||||
oscLinks,
|
||||
terminalTheme,
|
||||
fontScale: textScale,
|
||||
preserveScroll
|
||||
})
|
||||
},
|
||||
resize(cols: number, rows: number) {
|
||||
// Why: resize/reflow must observe all prior writes or bytes reorder.
|
||||
writeCoalescer.flushNow()
|
||||
postMessage({ type: 'resize', cols, rows })
|
||||
},
|
||||
reflow(cols: number, rows: number) {
|
||||
writeCoalescer.flushNow()
|
||||
postMessage({ type: 'reflow', cols, rows })
|
||||
},
|
||||
clear() {
|
||||
writeCoalescer.clear()
|
||||
postMessage({ type: 'clear' })
|
||||
},
|
||||
measureFitDimensions(containerHeight?: number) {
|
||||
if (!isWebReadyRef.current) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
return promises.measure(sendToDocument, containerHeight)
|
||||
},
|
||||
resetZoom() {
|
||||
postMessage({ type: 'reset-zoom' })
|
||||
},
|
||||
cancelSelect() {
|
||||
postMessage({ type: 'cancel-select' })
|
||||
},
|
||||
doSelectAll() {
|
||||
postMessage({ type: 'do-select-all' })
|
||||
},
|
||||
// Why: waits on the in-flight ready promise (set by init); resolves immediately if no init
|
||||
// is pending, and is capped so a stuck document doesn't hang the caller.
|
||||
awaitReady: promises.awaitReady
|
||||
}),
|
||||
[
|
||||
armWebReadyWatchdog,
|
||||
pingsOnForegroundRecovery,
|
||||
postMessage,
|
||||
promises,
|
||||
sendToDocument,
|
||||
terminalTheme,
|
||||
textScale,
|
||||
writeCoalescer
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
armWebReadyWatchdog,
|
||||
clearEngineError,
|
||||
confirmWebReady,
|
||||
engineError,
|
||||
handle,
|
||||
receive,
|
||||
reportNativeEngineError,
|
||||
resetReadiness
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,14 @@
|
||||
"file": "src/browser/browser-address-field-styles.web.ts",
|
||||
"reason": "The address bar renders at the theme's 12px meta size, and in a browser an input under 16px makes iOS zoom the page on focus and never zoom back. keyboard-occlusion.web.ts reads that scale as 'no keyboard' and answers 0, so one focus would stop the pane lifting for the rest of the typing session. This file puts the input and its overlaid label on TEXT_INPUT_FONT_SIZE with a line box to match; the native sibling keeps 12px, which is what a phone has always rendered and where no page can zoom."
|
||||
},
|
||||
{
|
||||
"file": "src/terminal/TerminalWebView.web.tsx",
|
||||
"reason": "react-native-webview has no web build that renders anything: on the page it paints the line \"React Native WebView does not support this platform\" where the terminal was, which is a red line and no terminal rather than a crash. This file mounts the same document the WebView loads — xterm imported from @xterm/xterm with the unicode11 and webgl addons, and the document's own modules imported in the order the generator emits them — behind the identical TerminalWebViewProps and TerminalWebViewHandle, so nothing above the contract can tell the two apart."
|
||||
},
|
||||
{
|
||||
"file": "src/terminal/terminal-webview-html.web.ts",
|
||||
"reason": "The native file composes the whole WebView document, which splices in the 612 KiB minified xterm engine string. On the page that string is unusable — the shell's CSP is script-src 'self' with frame-src 'none', so there is no nested document to load it into — and it would be the largest single module in the session route's closure. The web file answers the caret options, the markup and the stylesheet, which is everything the page mounts, and nothing else; mobile-web-terminal-engine-closure.test.mjs is the fence."
|
||||
},
|
||||
{
|
||||
"file": "src/platform/media-picker.web.ts",
|
||||
"reason": "expo-image-picker and expo-document-picker are native modules whose import runs a codegen lookup that throws in a browser, and the route manifest imports every route, so one of them in a page closure takes the whole bundle down rather than one picker. This one asks the shell through native.media.pick, reads the bytes back a chunk at a time over native.media.read because a picked image reaches 18 MiB raw against an 8 MiB reply ceiling, and releases every handle it was handed, including the ones its caller never took. The pasteboard is not here: clipboard.web.ts owns it on both platforms and reaches the same verbs for an image."
|
||||
|
||||
Reference in New Issue
Block a user