diff --git a/mobile/.gitignore b/mobile/.gitignore index fc97af19a22..7fdf3640809 100644 --- a/mobile/.gitignore +++ b/mobile/.gitignore @@ -2,6 +2,7 @@ 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/terminal/terminal-webview-document-factory.generated.ts src/components/pr-sidebar/mermaid-webview-engine.generated.ts .expo/ dist/ diff --git a/mobile/.oxlintrc.json b/mobile/.oxlintrc.json index aa73bfe8583..09b9d7cae16 100644 --- a/mobile/.oxlintrc.json +++ b/mobile/.oxlintrc.json @@ -1,7 +1,10 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", "extends": ["../.oxlintrc.json"], - "ignorePatterns": ["src/terminal/terminal-webview-engine.generated.ts"], + "ignorePatterns": [ + "src/terminal/terminal-webview-engine.generated.ts", + "src/terminal/terminal-webview-document-factory.generated.ts" + ], "rules": { "react-hooks/exhaustive-deps": "off", "react/no-unescaped-entities": "off", diff --git a/mobile/scripts/build-terminal-document-script.mjs b/mobile/scripts/build-terminal-document-script.mjs index f62d6beab6e..1b8664a595f 100644 --- a/mobile/scripts/build-terminal-document-script.mjs +++ b/mobile/scripts/build-terminal-document-script.mjs @@ -154,6 +154,19 @@ export async function emitTerminalDocumentModule(modulePath) { const documentDirectory = path.join(import.meta.dirname, '..', 'src', 'terminal', 'document') +const GENERATED_HEADER = + `// Generated by scripts/build-terminal-document-script.mjs. Do not edit.\n` + + `// The source is mobile/src/terminal/document/, in the order\n` + + `// scripts/terminal-document-module-order.mjs pins.` + +export const TERMINAL_DOCUMENT_FACTORY_MODULE_PATH = path.join( + import.meta.dirname, + '..', + 'src', + 'terminal', + 'terminal-webview-document-factory.generated.ts' +) + export const TERMINAL_DOCUMENT_SCRIPT_PATH = path.join( import.meta.dirname, '..', @@ -229,8 +242,8 @@ export async function emitDocumentedTerminalModule(moduleName) { } /** - * The document's whole script: one factory whose body is every module in the order the document - * had, the call sequence that starts them, and the handle that stops them again. + * The factory's body: every module in the order the document had, the call sequence that starts + * them, the handle that stops them again, and the return. Shared by both artifacts (ruling 23). * * Ruling 22. The concatenation already gave the modules one function scope with one local `scope`; * naming that scope a function is what makes it the shape both hosts run. Every call gets its own @@ -238,7 +251,7 @@ export async function emitDocumentedTerminalModule(moduleName) { * claim on the page — a second terminal is a second call. The native document is this function and * one call with no argument, which is what it has always been. */ -export async function buildTerminalDocumentScript() { +export async function buildTerminalDocumentFactoryBody() { 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 @@ -271,25 +284,62 @@ export async function buildTerminalDocumentScript() { `${INDENT}}` ] return [ - `function ${TERMINAL_DOCUMENT_FACTORY_NAME}(${HOST_PARAMETER}) {`, ...emitted, ...calls, ...stopBody, - `${INDENT}return { send: handleMsg, stop: stop };`, + `${INDENT}return { send: handleMsg, stop: stop };` + ].join('\n') +} + +/** The document's script, as the native WebView carries it: the factory, then the one call. */ +export async function buildTerminalDocumentScript() { + const body = await buildTerminalDocumentFactoryBody() + return [ + `function ${TERMINAL_DOCUMENT_FACTORY_NAME}(${HOST_PARAMETER}) {`, + body, `}`, `${TERMINAL_DOCUMENT_FACTORY_NAME}();` ].join('\n') } +/** + * The same factory, as a module the page imports. + * + * Ruling 23: one emitted body, two wrappers. The page cannot run the native script — building a + * function from a string needs `eval`, which its policy refuses — and it cannot run the modules + * either, because they are one singleton and the whole point of the factory is a scope per call. + * So it imports this, whose body is the native factory's body line for line; the artifact test + * holds the two equal, which is how the byte golden ends up pinning this file too. + * + * `@ts-nocheck` covers exactly one generated file. Every line below is esbuild output from a module + * that was type-checked at its source, with its `declare global` blocks and type re-exports already + * erased and its constants already substituted; the one line a caller reads is the signature, and + * the generator writes that with its types. + */ +export async function buildTerminalDocumentFactoryModule() { + const body = await buildTerminalDocumentFactoryBody() + return [ + GENERATED_HEADER, + '// @ts-nocheck -- ruling 23: the body is emitted text, type-checked at each source module.', + `import type { TerminalDocument, TerminalDocumentHost } from './document/document-host-seams'`, + '', + `export function ${TERMINAL_DOCUMENT_FACTORY_NAME}(`, + `${INDENT}${HOST_PARAMETER}: TerminalDocumentHost`, + `): TerminalDocument {`, + body, + `}`, + '' + ].join('\n') +} + async function main() { const script = await buildTerminalDocumentScript() await writeFile( TERMINAL_DOCUMENT_SCRIPT_PATH, - `// Generated by scripts/build-terminal-document-script.mjs. Do not edit.\n` + - `// The source is mobile/src/terminal/document/, in the order\n` + - `// scripts/terminal-document-module-order.mjs pins.\n` + - `export const TERMINAL_DOCUMENT_SCRIPT = ${JSON.stringify(script)}\n` + `${GENERATED_HEADER}\nexport const TERMINAL_DOCUMENT_SCRIPT = ${JSON.stringify(script)}\n` ) + // One run writes both, so the page's factory can never be a build behind the WebView's. + await writeFile(TERMINAL_DOCUMENT_FACTORY_MODULE_PATH, await buildTerminalDocumentFactoryModule()) } if (process.argv[1] === import.meta.filename) { diff --git a/mobile/src/terminal/document/document-factory-artifacts.test.ts b/mobile/src/terminal/document/document-factory-artifacts.test.ts new file mode 100644 index 00000000000..901041f04c7 --- /dev/null +++ b/mobile/src/terminal/document/document-factory-artifacts.test.ts @@ -0,0 +1,64 @@ +import { readFile } from 'node:fs/promises' +import { describe, expect, it } from 'vitest' +import { + buildTerminalDocumentFactoryBody, + buildTerminalDocumentFactoryModule, + buildTerminalDocumentScript, + TERMINAL_DOCUMENT_FACTORY_MODULE_PATH, + TERMINAL_DOCUMENT_FACTORY_NAME +} from '../../../scripts/build-terminal-document-script.mjs' + +/** + * The two artifacts the generator writes, held to one body (ruling 23). + * + * The WebView gets a string it loads; the page gets a module it imports, because building a + * function from that string needs `eval` and the page's policy refuses it. Two files is the cost of + * that, and the risk is the obvious one: they drift, and the terminal on the page stops being the + * terminal on the phone while every other test stays green. So the wrappers are stripped and the + * remainder compared byte for byte, which is also what makes the byte golden pin the page's file. + */ + +/** What is left of the native script once its declaration line, closing brace and call are gone. */ +function nativeFactoryBody(script: string): string { + const open = `function ${TERMINAL_DOCUMENT_FACTORY_NAME}(host) {\n` + const close = `\n}\n${TERMINAL_DOCUMENT_FACTORY_NAME}();` + expect(script.startsWith(open), 'the native script opens with the factory').toBe(true) + expect(script.endsWith(close), 'the native script ends with the closing brace and the call').toBe( + true + ) + return script.slice(open.length, script.length - close.length) +} + +/** What is left of the page module once its header, directive, import and signature are gone. */ +function pageFactoryBody(module: string): string { + const open = `): TerminalDocument {\n` + const at = module.indexOf(open) + expect(at, 'the page module declares the annotated signature').toBeGreaterThan(0) + const close = '\n}\n' + expect(module.endsWith(close), 'the page module ends with the closing brace').toBe(true) + return module.slice(at + open.length, module.length - close.length) +} + +describe('the two terminal document artifacts', () => { + it('carry the same factory body, byte for byte', async () => { + const body = await buildTerminalDocumentFactoryBody() + expect(nativeFactoryBody(await buildTerminalDocumentScript())).toBe(body) + expect(pageFactoryBody(await buildTerminalDocumentFactoryModule())).toBe(body) + }) + + it('is on disk as the generator would write it now', async () => { + // The page's file is gitignored and built by postinstall, so a tree whose modules moved after + // the last build would import yesterday's document. The native script's staleness is already + // caught by the byte golden; this is the same reading for the file beside it. + const onDisk = await readFile(TERMINAL_DOCUMENT_FACTORY_MODULE_PATH, 'utf8') + expect(onDisk).toBe(await buildTerminalDocumentFactoryModule()) + }) + + it('gives the page a factory it can call and no call of its own', async () => { + // A trailing call would start a document as the module was imported, which is the parse-time + // work ruling 20 removed — and on the page it would run before any host element existed. + const module = await buildTerminalDocumentFactoryModule() + expect(module).toContain(`export function ${TERMINAL_DOCUMENT_FACTORY_NAME}(`) + expect(module).not.toContain(`\n${TERMINAL_DOCUMENT_FACTORY_NAME}();`) + }) +}) diff --git a/mobile/src/terminal/document/document-host-seams.ts b/mobile/src/terminal/document/document-host-seams.ts index f3a697294ad..8b18bf4dc86 100644 --- a/mobile/src/terminal/document/document-host-seams.ts +++ b/mobile/src/terminal/document/document-host-seams.ts @@ -56,6 +56,18 @@ export type TerminalDocumentHostSeams = { */ export type TerminalDocumentHost = Partial +/** + * A running document: the two things a host can do to one it has started. + * + * `send` is the router the WebView already reached through its message listener, which the page + * calls directly. `stop` runs every module's stop and takes back the frames the document is owed; + * the page's dispose calls it, and the WebView never does. + */ +export type TerminalDocument = { + send: (message: Record) => void + stop: () => void +} + declare global { interface Window { ReactNativeWebView?: { postMessage: (message: string) => void } diff --git a/mobile/src/terminal/terminal-webview-consumer-census.test.ts b/mobile/src/terminal/terminal-webview-consumer-census.test.ts index 9e93e3c9f9c..14b4542d796 100644 --- a/mobile/src/terminal/terminal-webview-consumer-census.test.ts +++ b/mobile/src/terminal/terminal-webview-consumer-census.test.ts @@ -25,7 +25,10 @@ const ALLOWED_INSIDE_TERMINAL = new Set([ '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' + 'terminal-webview-mouse-test-harness.ts', + // Generated, and it *is* the document: its body is the modules' own emitted text (ruling 23). + // Its one import is the host contract the signature is written against. + 'terminal-webview-document-factory.generated.ts' ]) const FORBIDDEN_ABOVE_THE_CONTRACT = [