diff --git a/config/scripts/mobile-mirrored-storage-write-path.test.mjs b/config/scripts/mobile-mirrored-storage-write-path.test.mjs new file mode 100644 index 00000000000..d626677cbec --- /dev/null +++ b/config/scripts/mobile-mirrored-storage-write-path.test.mjs @@ -0,0 +1,105 @@ +/** + * One owner for the mirror the hybrid shell reads (ruling 35). + * + * `mirrored-storage-keys.ts` holds the map the shell builds every `init` from, synchronously, and + * before this the fourteen writers of a mirrored key noted it themselves — first, then persisted. + * On the page a persist can be refused, so twelve of them left the map holding a value no store + * had taken and the next `init` handed the page exactly that; the other two undid it by hand. + * + * Source-scanning rather than behavioural, and about existence rather than shape: what a + * behavioural case cannot say is that no thirteenth writer appears next week. Each row below is a + * module that owns a mirrored key, so deleting its write path reds that row by name, and every + * failure quotes the line it found. + */ +import { globSync, readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const mobileDir = fileURLToPath(new URL('../../mobile/', import.meta.url)) + +const MIRROR_MODULE = 'src/storage/mirrored-storage-keys.ts' + +/** Every module that persists a key the shell mirrors, with the constant each one writes. */ +const MIRRORED_WRITERS = [ + { + file: 'src/storage/preferences.ts', + keys: ['TEXT_SCALE_KEY', 'SIDEBAR_WIDTH_KEY', 'DOCK_WIDTH_KEY'] + }, + { file: 'src/storage/session-view-preferences.ts', keys: ['DEFAULT_SESSION_VIEW_KEY'] }, + { + file: 'src/terminal/terminal-accessory-layout.ts', + keys: ['TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY'] + }, + { file: 'src/components/CustomKeyModal.tsx', keys: ['CUSTOM_ACCESSORY_KEYS_STORAGE_KEY'] }, + { file: 'src/session/mobile-structured-send-operation-journal.ts', keys: ['STORAGE_KEY'] }, + { + file: 'src/worktree/last-visited-worktree-repo.ts', + keys: ['LAST_VISITED_WORKTREE_STORAGE_KEY'] + } +] + +/** + * The one caller of the note-then-persist path, which is the shell taking a value the page has + * already applied into a store that refuses nothing. + * + * Counted rather than described: the module says the census holds it to one caller, and until + * this row nothing did. A second caller is either a writer that wants the ordering without the + * store that earns it, or a page-reachable module that would note a refusal as an accepted write. + */ +const NOTE_FIRST_CALLER = 'src/mobile-web-shell/use-page-host-snapshot.ts' + +/** Every module under `mobile/src`, so a new caller cannot arrive in a file no row names. */ +function mobileSources() { + return globSync('src/**/*.{ts,tsx}', { cwd: mobileDir }).sort() +} + +/** The line a match sits on, so a failure names what it found rather than only that it found one. */ +function linesMatching(source, pattern) { + return source + .split('\n') + .map((line, index) => ({ line: line.trim(), at: index + 1 })) + .filter((entry) => pattern.test(entry.line)) +} + +function read(file) { + return readFileSync(new URL(file, new URL(mobileDir, 'file:///')), 'utf8') +} + +describe('the mirrored storage write path', () => { + it('is the only thing that writes the map, which no other module can reach', () => { + const owner = read(MIRROR_MODULE) + // The map itself: a second module holding a reference to it would be a second owner, and the + // map is not exported, so this is what says so. + expect(linesMatching(owner, /^export (const|let) mirror\b/)).toEqual([]) + expect(linesMatching(owner, /^export function note\b/)).toEqual([]) + }) + + it(`calls the note-first path from ${NOTE_FIRST_CALLER} and nowhere else`, () => { + const callers = mobileSources().filter((file) => { + if (file === MIRROR_MODULE) { + return false + } + return linesMatching(read(file), /\bwriteMirroredStorage\(/).length > 0 + }) + expect(callers).toEqual([NOTE_FIRST_CALLER]) + }) + + for (const row of MIRRORED_WRITERS) { + it(`writes ${row.file} through the one path and never around it`, () => { + const source = read(row.file) + expect(linesMatching(source, /\bpersistMirrored\(/).length).toBeGreaterThan(0) + // Around it would be a store call naming a key the map holds, which is the shape every one + // of these had before: note the map, then persist, and nothing between the two agreeing. + for (const key of row.keys) { + expect( + linesMatching(source, new RegExp(`AsyncStorage\\.(setItem|removeItem)\\(\\s*${key}\\b`)), + `${row.file} writes ${key} past the mirror` + ).toEqual([]) + } + expect( + linesMatching(source, /\bnoteMirroredWrite\b/), + `${row.file} notes the map itself` + ).toEqual([]) + }) + } +}) diff --git a/config/scripts/mobile-web-app-haptics-seam.test.mjs b/config/scripts/mobile-web-app-haptics-seam.test.mjs index d8f81c726c4..96c02c4b29b 100644 --- a/config/scripts/mobile-web-app-haptics-seam.test.mjs +++ b/config/scripts/mobile-web-app-haptics-seam.test.mjs @@ -17,6 +17,10 @@ import { describe, expect, it } from 'vitest' import { mobileWebAppRouteClosure } from './build-mobile-web-app-bundle.mjs' import { MOBILE_WEB_PAGE_ROUTES } from './mobile-web-page-routes.mjs' import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' +import { + PAGE_ROUTE_MODULES, + pageRouteModulesCoverTheManifest +} from './mobile-web-app-page-route-modules.mjs' import { HAPTICS_KINDS_MODULE, HAPTICS_NATIVE, @@ -33,16 +37,8 @@ const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe. const read = (file) => readFileSync(join(mobileDir, file), 'utf8') -/** The route module behind each declared page route, which is what a closure is read from. */ -const ROUTE_MODULES = new Map([ - ['/h/[hostId]', 'app/h/[hostId]/index.tsx'], - ['/h/[hostId]/agent-history/[worktreeId]', 'app/h/[hostId]/agent-history/[worktreeId].tsx'], - ['/h/[hostId]/tasks', 'app/h/[hostId]/tasks.tsx'], - ['/h/[hostId]/files/[worktreeId]', 'app/h/[hostId]/files/[worktreeId].tsx'], - ['/h/[hostId]/files/preview/[worktreeId]', 'app/h/[hostId]/files/preview/[worktreeId].tsx'], - ['/h/[hostId]/source-control/[worktreeId]', 'app/h/[hostId]/source-control/[worktreeId].tsx'], - ['/h/[hostId]/review/[worktreeId]', 'app/h/[hostId]/review/[worktreeId].tsx'] -]) +/** The route module behind each declared page route, shared with the screencast-lane census. */ +const ROUTE_MODULES = PAGE_ROUTE_MODULES const HAPTICS_GRANT = 'haptics' @@ -269,10 +265,9 @@ describeClosure( }) it('covers every declared page route, so a new one cannot be missed by this file', () => { - // The map above is a hand list of route modules; this is what holds it to the declarations. - expect([...ROUTE_MODULES.keys()].sort()).toEqual( - MOBILE_WEB_PAGE_ROUTES.map((route) => route.pathname).sort() - ) + // The shared map is a hand list of route modules; this is what holds it to the declarations. + const { mapped, declared } = pageRouteModulesCoverTheManifest(MOBILE_WEB_PAGE_ROUTES) + expect(mapped).toEqual(declared) }) /** diff --git a/config/scripts/mobile-web-app-page-closure-families.test.mjs b/config/scripts/mobile-web-app-page-closure-families.test.mjs index a800faec9b6..ea6808ca155 100644 --- a/config/scripts/mobile-web-app-page-closure-families.test.mjs +++ b/config/scripts/mobile-web-app-page-closure-families.test.mjs @@ -143,6 +143,32 @@ describeClosure('the browser pane closure', () => { await expectClosureFamilies(closure.local, [C6_PIN_TABLE]) }, 60_000) + it('adds exactly those families to the session route, which is the route that mounts it', async () => { + // C6 ruling 3: a composed table is pinned against a route, and the pane had none — it is + // mounted by `MobileSessionActiveContent`, not registered. C7.7 registers that route, so the + // half is measured here against the page the shell actually serves rather than against a + // module closure read on its own. The difference matters: the session route reaches the whole + // of `src/session` around the pane, and a family the pane shares with the screen it sits in + // would be invisible in the module reading and present here. + const scenarios = JSON.parse(read('mobile/rpc-foundation/pilot-scenarios.json')).scenarios + const [layout, route] = await Promise.all([ + mobileWebAppModuleClosure(['app/h/_layout']), + mobileWebAppRouteClosure('app/h/[hostId]/session/[worktreeId].tsx') + ]) + const layoutFamilies = pageClosureFamilies(layout.local, scenarios) + const routeFamilies = pageClosureFamilies(route.local, scenarios) + // The pane's four are in the route's set, and they are not the layout's, so the route is what + // brings them. Asserted as containment rather than as a difference: the session route reaches + // far more than the pane, and C7.8 is what pins its whole set. + for (const family of pinnedFamilyNames(read(C6_PIN_TABLE))) { + expect(routeFamilies, family).toContain(family) + expect(layoutFamilies, family).not.toContain(family) + } + // And the layout is the C1 control it is everywhere else, so the line above is a real + // difference rather than a set that happens to contain everything. + expect(layoutFamilies).toEqual(pinnedFamilyNames(read(C1_TABLE)).sort()) + }, 300_000) + it('adds exactly those families to a page, and no other', async () => { // The pin is a half: alone it would also pass if the pane dragged in a family C1 already pins // and the table happened to list it. This reads the difference the pane makes to the layout. diff --git a/config/scripts/mobile-web-app-page-grant-call-sites.mjs b/config/scripts/mobile-web-app-page-grant-call-sites.mjs new file mode 100644 index 00000000000..bc3ba5dfb8a --- /dev/null +++ b/config/scripts/mobile-web-app-page-grant-call-sites.mjs @@ -0,0 +1,165 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' + +/** + * What a page route's own closure asks the shell for, read from the call sites rather than listed. + * + * Grants are resolved once, from the route the shell opened, and carried for the life of the + * session: a route that reaches a seam it did not declare is a page whose action is refused at the + * host with nothing on screen to say why. Six of the session route's fourteen grants were pinned + * only by the list they were copied from (ruling 33.3); this is the rule the other eight already + * had, written once and driven over every row. + * + * Parsed, not matched. A regex over source text finds the seam named in a comment, in a string and + * in an import it does not call, and the first two are exactly what a census must not count. + */ + +/** A grant whose call site is a function this closure calls. */ +const callRow = (grants, callee, seam, why) => ({ kind: 'call', grants, callee, seam, why }) +/** A grant whose call site is an import: the bundler substitutes the module, so reaching it is use. */ +const importRow = (grants, specifier, why) => ({ kind: 'import', grants, specifier, why }) + +/** + * One row per grant the page can ask for through a call site of its own. + * + * `haptics` and `screencastBinary` have their own files (`mobile-web-app-haptics-seam.test.mjs`, + * `mobile-web-app-screencast-lane-grant.test.mjs`) and the four audio grants have + * `mobile-web-app-session-dictation-capture.test.mjs`, so those eight are not repeated here. The + * media three share one seam and one row: `useMediaPicker` is the only way in, and `canPickMedia` + * is `pick && read && release`, so a route reaching it needs all three or none of them. + */ +export const PAGE_GRANT_CALL_SITES = [ + callRow( + ['navigate'], + 'useRouteHandoff', + 'src/navigation/route-handoff.web.ts', + 'the page keeps a route it renders and hands every other one back to the shell' + ), + importRow( + ['storage'], + '@react-native-async-storage/async-storage', + 'the bundler substitutes `page-async-storage.ts`, whose writes ride the storage notify' + ), + callRow( + ['externalLink'], + 'openExternalLink', + 'src/platform/external-link.web.ts', + 'the shell is the only thing on the page that can open a URL outside the app' + ), + callRow( + ['native.clipboard.write'], + 'useClipboardWriter', + 'src/platform/clipboard.web.ts', + "the browser's own clipboard write is refused without a user gesture the page cannot prove" + ), + callRow( + ['native.clipboard.read'], + 'useClipboardReader', + 'src/platform/clipboard.web.ts', + 'a paste needs the device pasteboard, which the WebView does not hand the page' + ), + callRow( + ['native.media.pick', 'native.media.read', 'native.media.release'], + 'useMediaPicker', + 'src/platform/media-picker.web.ts', + 'the picker runs the OS permission prompt inside the shell and hands back a handle' + ) +] + +function parse(source, fileName) { + return ts.createSourceFile( + fileName, + source, + ts.ScriptTarget.Latest, + true, + fileName.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS + ) +} + +/** + * Whether this module reaches the row's seam: calls the function, or imports the substituted module. + * + * A call and nothing else. An import of the name without a call is a module that re-exports it, and + * a mention in a comment or a string is not a call at all — both would put a grant on a route that + * can never ask for it, which is the failure a hand-written list already had. + */ +export function moduleReachesGrantRow(source, fileName, row) { + let reached = false + const walk = (node) => { + if ( + row.kind === 'call' && + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === row.callee + ) { + reached = true + } + if ( + row.kind === 'import' && + ts.isImportDeclaration(node) && + ts.isStringLiteral(node.moduleSpecifier) && + node.moduleSpecifier.text === row.specifier + ) { + reached = true + } + ts.forEachChild(node, walk) + } + walk(parse(source, fileName)) + return reached +} + +/** Every module in the closure that reaches the row, the seam itself never counting as its own use. */ +export function grantCallSites(mobileDir, closure, row) { + return closure.local.filter((file) => { + if (!/\.tsx?$/.test(file) || file === row.seam) { + return false + } + return moduleReachesGrantRow(readFileSync(join(mobileDir, file), 'utf8'), file, row) + }) +} + +/** Every grant this closure's own call sites need, in row order. */ +export function grantsNeeded(mobileDir, closure) { + return PAGE_GRANT_CALL_SITES.filter( + (row) => grantCallSites(mobileDir, closure, row).length > 0 + ).flatMap((row) => row.grants) +} + +/** + * One row's verdict: every route whose closure reaches that seam and whose entry does not name its + * grants, as ` needs `. + * + * Per row rather than per manifest, so a grant struck out of an entry reds a case named after that + * grant. A single whole-manifest check would red under every row at once and say only that + * something was missing. + */ +export async function grantsMissingForRow(mobileDir, routes, closureOf, row) { + const missing = [] + for (const route of routes) { + const closure = await closureOf(route.pathname) + if (grantCallSites(mobileDir, closure, row).length === 0) { + continue + } + for (const grant of row.grants) { + if (!route.grants.includes(grant)) { + missing.push(`${route.pathname} needs ${grant}`) + } + } + } + return missing +} + +/** + * Every row's verdict at once, in row order. + * + * One implementation under both the check and its control: a control that re-implemented the + * filter would prove the control works and say nothing about the rule. + */ +export async function grantsMissingForRoutes(mobileDir, routes, closureOf) { + const missing = [] + for (const row of PAGE_GRANT_CALL_SITES) { + missing.push(...(await grantsMissingForRow(mobileDir, routes, closureOf, row))) + } + return missing +} diff --git a/config/scripts/mobile-web-app-page-grant-call-sites.test.mjs b/config/scripts/mobile-web-app-page-grant-call-sites.test.mjs new file mode 100644 index 00000000000..5b440aab93c --- /dev/null +++ b/config/scripts/mobile-web-app-page-grant-call-sites.test.mjs @@ -0,0 +1,219 @@ +/** + * The six grants that were pinned only by the list they were copied from (ruling 33.3). + * + * `haptics`, `screencastBinary` and the four audio grants already have call-site censuses of their + * own; these six did not, so removing any of them from a manifest entry reddened nothing. Each row + * below gets its own named case, and each case's control is the same rule driven over the entry + * that route would have had with the grant struck out. + */ +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 { + PAGE_ROUTE_MODULES, + pageRouteModulesCoverTheManifest +} from './mobile-web-app-page-route-modules.mjs' +import { MOBILE_WEB_PAGE_ROUTES } from './mobile-web-page-routes.mjs' +import { + PAGE_GRANT_CALL_SITES, + grantCallSites, + grantsMissingForRow, + grantsNeeded, + moduleReachesGrantRow +} from './mobile-web-app-page-grant-call-sites.mjs' + +const mobileDir = fileURLToPath(new URL('../../mobile/', import.meta.url)) +const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.skip + +const SESSION = '/h/[hostId]/session/[worktreeId]' + +/** Memoised: every case below walks all eight, and a closure is a bundle the walk builds. */ +const closures = new Map() + +function closureOf(pathname) { + const mod = PAGE_ROUTE_MODULES.get(pathname) + if (mod === undefined) { + throw new Error(`${pathname} has no route module, so no closure can be read for it`) + } + const held = closures.get(pathname) ?? mobileWebAppRouteClosure(mod) + closures.set(pathname, held) + return held +} + +/** + * The one place a page route reaches a seam it does not declare, recorded rather than exempted. + * + * `app/h/_layout.tsx` wraps every `/h` route in `HostProtocolGate`, whose `ProtocolBlockScreen` + * offers an Update Orca link through `openExternalLink`. Six routes declare `externalLink` and two + * do not, so on those two the wall's link posts a notify the shell refuses — a dead tap with + * nothing on screen. Pre-existing on main and not C7.7's to change: widening two other routes' + * grants is a capability decision, and this lane reports rather than fixes it. + * + * Exact, so it reds in both directions: adding the grant to either route empties an entry here and + * a new gap anywhere adds one. + */ +const KNOWN_UNDECLARED = new Map([ + [ + 'externalLink', + ['/h/[hostId] needs externalLink', '/h/[hostId]/agent-history/[worktreeId] needs externalLink'] + ] +]) + +describe('the call-site reader', () => { + const navigate = PAGE_GRANT_CALL_SITES[0] + const storage = PAGE_GRANT_CALL_SITES[1] + + it('counts a call and not an import that never calls it', () => { + expect( + moduleReachesGrantRow( + "import { useRouteHandoff } from '../navigation/route-handoff'\nexport { useRouteHandoff }\n", + 'a.ts', + navigate + ) + ).toBe(false) + expect( + moduleReachesGrantRow( + "import { useRouteHandoff } from '../navigation/route-handoff'\nconst r = useRouteHandoff()\n", + 'a.ts', + navigate + ) + ).toBe(true) + }) + + it('ignores the seam named in a comment or a string, which text matching cannot', () => { + expect( + moduleReachesGrantRow( + ['// const r = useRouteHandoff()', 'const hint = "useRouteHandoff()"'].join('\n'), + 'a.ts', + navigate + ) + ).toBe(false) + }) + + it('reads a .tsx file as TSX, so nothing after the first element is swallowed', () => { + expect( + moduleReachesGrantRow( + ['export const view = ', 'export const use = () => useRouteHandoff()'].join('\n'), + 'a.tsx', + navigate + ) + ).toBe(true) + }) + + it('counts the substituted module as reached when it is imported at all', () => { + expect( + moduleReachesGrantRow( + "import AsyncStorage from '@react-native-async-storage/async-storage'\n", + 'a.ts', + storage + ) + ).toBe(true) + expect( + moduleReachesGrantRow("import AsyncStorage from './other-storage'\n", 'a.ts', storage) + ).toBe(false) + }) + + it('names six rows covering eight grants, none of them a grant another census owns', () => { + const grants = PAGE_GRANT_CALL_SITES.flatMap((row) => row.grants) + expect(PAGE_GRANT_CALL_SITES).toHaveLength(6) + expect(grants).toEqual([ + 'navigate', + 'storage', + 'externalLink', + 'native.clipboard.write', + 'native.clipboard.read', + 'native.media.pick', + 'native.media.read', + 'native.media.release' + ]) + for (const owned of ['haptics', 'screencastBinary', 'native.audio.start']) { + expect(grants).not.toContain(owned) + } + }) +}) + +describeClosure( + 'what each page route reaches, against what it declared', + () => { + it('covers every declared page route, so a new one cannot be missed by this file', () => { + const { mapped, declared } = pageRouteModulesCoverTheManifest(MOBILE_WEB_PAGE_ROUTES) + expect(mapped).toEqual(declared) + }) + + /** + * One case per row, named after its own grants. + * + * Per row rather than one check over the manifest, because the point is attribution: striking + * `native.clipboard.read` out of an entry has to red a case that says so, and a single + * whole-manifest assertion reds the same way whichever grant went missing. + */ + it.each(PAGE_GRANT_CALL_SITES.map((row) => [row.grants.join(' + '), row]))( + 'declares %s on every registered route whose own call sites reach it', + async (name, row) => { + expect( + await grantsMissingForRow(mobileDir, MOBILE_WEB_PAGE_ROUTES, closureOf, row) + ).toEqual(KNOWN_UNDECLARED.get(name) ?? []) + } + ) + + /** + * The control for each of those, self-contained on purpose. + * + * Built from what the session route's own closure reaches rather than from what its entry + * declares, so a case stays green whatever the manifest says and reds only when the rule stops + * working. Reading the manifest here instead would make every row red as soon as any one grant + * went missing, which is the attribution the case above exists to give. + */ + it.each(PAGE_GRANT_CALL_SITES.map((row) => [row.grants.join(' + '), row]))( + 'reds the session route when it is registered without %s', + async (_name, row) => { + const needed = grantsNeeded(mobileDir, await closureOf(SESSION)) + expect(needed, 'the session route reaches this row').toEqual( + expect.arrayContaining(row.grants) + ) + const entry = (grants) => [{ pathname: SESSION, grants }] + // Declaring everything it reaches passes, so each case is a rule and not a wall. + expect(await grantsMissingForRow(mobileDir, entry(needed), closureOf, row)).toEqual([]) + const without = needed.filter((grant) => !row.grants.includes(grant)) + expect(await grantsMissingForRow(mobileDir, entry(without), closureOf, row)).toEqual( + row.grants.map((grant) => `${SESSION} needs ${grant}`) + ) + } + ) + + it('reaches every one of the eight through the session route, and names where', async () => { + const closure = await closureOf(SESSION) + // The precondition an assertion about a closure needs: the walk read a page, not nothing. + expect(closure.local.length).toBeGreaterThan(250) + expect(grantsNeeded(mobileDir, closure)).toEqual( + PAGE_GRANT_CALL_SITES.flatMap((row) => row.grants) + ) + for (const row of PAGE_GRANT_CALL_SITES) { + expect( + grantCallSites(mobileDir, closure, row).length, + row.grants.join(' + ') + ).toBeGreaterThan(0) + } + }) + + it('finds the clipboard reader and the media picker on the session route alone', async () => { + const readerRow = PAGE_GRANT_CALL_SITES[4] + const mediaRow = PAGE_GRANT_CALL_SITES[5] + const reaching = { reader: [], media: [] } + for (const pathname of PAGE_ROUTE_MODULES.keys()) { + const closure = await closureOf(pathname) + if (grantCallSites(mobileDir, closure, readerRow).length > 0) { + reaching.reader.push(pathname) + } + if (grantCallSites(mobileDir, closure, mediaRow).length > 0) { + reaching.media.push(pathname) + } + } + // Both are the session screen's and nowhere else's, which is why no other route carries them. + expect(reaching.reader).toEqual([SESSION]) + expect(reaching.media).toEqual([SESSION]) + }) + }, + 240_000 +) diff --git a/config/scripts/mobile-web-app-page-route-modules.mjs b/config/scripts/mobile-web-app-page-route-modules.mjs new file mode 100644 index 00000000000..ebed987170f --- /dev/null +++ b/config/scripts/mobile-web-app-page-route-modules.mjs @@ -0,0 +1,34 @@ +/** + * The route module behind each declared page route, which is what a closure is read from. + * + * `MOBILE_WEB_PAGE_ROUTES` names URL patterns and the bundler walks files, so something has to + * join the two. Shared rather than restated in each census for the reason + * `mobile-web-app-external-link-seam.mjs` is: a second copy is a list that stops growing when the + * first one does, and every census over it goes quietly green on a route nobody added. + * + * Extensionless is deliberate on neither side: the `.tsx` is named because that is the file on + * disk, and the builder's own `resolveExtensions` picks the `.web.tsx` sibling ahead of it exactly + * as it would for the page. + * + * `pageRouteModulesCoverTheManifest` is the guard that holds this map to the manifest; every + * census that reads it asserts that too, so a route registered without a row here is a route no + * closure census reads. + */ +export const PAGE_ROUTE_MODULES = new Map([ + ['/h/[hostId]', 'app/h/[hostId]/index.tsx'], + ['/h/[hostId]/agent-history/[worktreeId]', 'app/h/[hostId]/agent-history/[worktreeId].tsx'], + ['/h/[hostId]/tasks', 'app/h/[hostId]/tasks.tsx'], + ['/h/[hostId]/files/[worktreeId]', 'app/h/[hostId]/files/[worktreeId].tsx'], + ['/h/[hostId]/files/preview/[worktreeId]', 'app/h/[hostId]/files/preview/[worktreeId].tsx'], + ['/h/[hostId]/source-control/[worktreeId]', 'app/h/[hostId]/source-control/[worktreeId].tsx'], + ['/h/[hostId]/review/[worktreeId]', 'app/h/[hostId]/review/[worktreeId].tsx'], + ['/h/[hostId]/session/[worktreeId]', 'app/h/[hostId]/session/[worktreeId].tsx'] +]) + +/** The map's pathnames and the manifest's, each sorted, for a caller to compare. */ +export function pageRouteModulesCoverTheManifest(routes) { + return { + mapped: [...PAGE_ROUTE_MODULES.keys()].sort(), + declared: routes.map((route) => route.pathname).sort() + } +} diff --git a/config/scripts/mobile-web-app-render-harness.mjs b/config/scripts/mobile-web-app-render-harness.mjs index 152670935bd..00b21e12d38 100644 --- a/config/scripts/mobile-web-app-render-harness.mjs +++ b/config/scripts/mobile-web-app-render-harness.mjs @@ -79,13 +79,16 @@ export async function readShellDocumentHeaders() { * bumped `v` would otherwise reach a test as a 30s timeout naming nothing. */ export async function readBridgeProtocolVersion() { + // The module that declares it, which is the one both halves of the envelope import: the envelope + // re-exports the name, so a reader keyed on the re-export would answer for whichever file the + // last split left it in. const source = await readFile( - join(projectDir, 'mobile/src/mobile-web-shell/bridge/bridge-envelope.ts'), + join(projectDir, 'mobile/src/mobile-web-shell/bridge/bridge-frame-fields.ts'), 'utf8' ) const match = /BRIDGE_PROTOCOL_VERSION = (\d+)/.exec(source) if (!match) { - throw new Error('could not read BRIDGE_PROTOCOL_VERSION') + throw new Error('could not read BRIDGE_PROTOCOL_VERSION from bridge-frame-fields.ts') } return Number(match[1]) } @@ -163,12 +166,12 @@ export async function readBrowserFrameQuality() { /** The grant the shell offers every page, read from the same source for the same reason. */ export async function readBridgeFaultGrant() { const source = await readFile( - join(projectDir, 'mobile/src/mobile-web-shell/bridge/bridge-envelope.ts'), + join(projectDir, 'mobile/src/mobile-web-shell/bridge/bridge-frame-fields.ts'), 'utf8' ) const match = /BRIDGE_FAULT_GRANT = '([a-zA-Z]+)'/.exec(source) if (!match) { - throw new Error('could not read BRIDGE_FAULT_GRANT') + throw new Error('could not read BRIDGE_FAULT_GRANT from bridge-frame-fields.ts') } return match[1] } diff --git a/config/scripts/mobile-web-app-route-chunk-closure.mjs b/config/scripts/mobile-web-app-route-chunk-closure.mjs index b4cee5aaf27..c624622631a 100644 --- a/config/scripts/mobile-web-app-route-chunk-closure.mjs +++ b/config/scripts/mobile-web-app-route-chunk-closure.mjs @@ -6,6 +6,20 @@ import { collectMobileWebAppRoutes } from './mobile-web-app-route-manifest.mjs' const mobileDir = fileURLToPath(new URL('../../mobile', import.meta.url)) +/** + * The file the build actually put in a chunk for this route, which is not always the one named. + * + * `resolveExtensions` puts `.web.tsx` ahead of `.tsx`, so a route with a sibling is bundled as the + * sibling and the named path appears in no output at all. Until C7.7 the session route had none + * and the lookup below was exact; the first route with a sibling to be asked for reached "no + * output" instead — a route that is served on the page reading as one the bundle never built. + */ +function chunkOwnerPaths(routeModule) { + const named = resolve(mobileDir, routeModule) + const sibling = named.replace(/\.(tsx?)$/, '.web.$1') + return sibling === named ? [named] : [sibling, named] +} + /** * What a browser must download before one page route can paint, and what it may defer. * @@ -26,12 +40,14 @@ export async function mobileWebAppRouteChunkClosure(routeModule) { metafile: true, write: false }) - const routePath = resolve(mobileDir, routeModule) + const routePaths = chunkOwnerPaths(routeModule) const owner = Object.entries(metafile.outputs).find(([, output]) => - Object.keys(output.inputs ?? {}).some((input) => resolve(mobileDir, input) === routePath) + Object.keys(output.inputs ?? {}).some((input) => routePaths.includes(resolve(mobileDir, input))) ) if (!owner) { - throw new Error(`[mobile-web-app-route-chunk-closure] ${routeModule} reached no output`) + throw new Error( + `[mobile-web-app-route-chunk-closure] ${routeModule} reached no output (tried ${routePaths.join(', ')})` + ) } const reached = entryStaticClosure(metafile, owner[0]) const inputsOf = (outputs) => diff --git a/config/scripts/mobile-web-app-screencast-lane-grant.test.mjs b/config/scripts/mobile-web-app-screencast-lane-grant.test.mjs new file mode 100644 index 00000000000..a568b1ae87b --- /dev/null +++ b/config/scripts/mobile-web-app-screencast-lane-grant.test.mjs @@ -0,0 +1,102 @@ +/** + * Which page routes mount the browser pane, and the grant the pane needs from each of them. + * + * Natively the socket carries the screencast's binary frames and the app is both halves of that + * path, so there is nothing to negotiate. In the page the frames come through a shell that may + * predate the encoder, and the pane asks first: `use-browser-binary-screencast-grant.web.ts` reads + * `init.grants.native`, and a route that did not declare `screencastBinary` subscribes without + * `wantsBinary` — a live pane on a stream no frame arrives on, with nothing on screen to say why. + * + * C6 could not write this census: the pane is mounted by a route rather than registered as one, so + * there was no route to pin the grant against (C6 ruling 3 deferred it to C7). The session route is + * that route, and this is the general rule rather than an entry for it — the haptics seam census's + * shape, against the other grant a shared component brings into a closure. + */ +import { readFileSync } from 'node:fs' +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 { + PAGE_ROUTE_MODULES, + pageRouteModulesCoverTheManifest +} from './mobile-web-app-page-route-modules.mjs' +import { MOBILE_WEB_PAGE_ROUTES } from './mobile-web-page-routes.mjs' + +const mobileDir = fileURLToPath(new URL('../../mobile/', import.meta.url)) +const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.skip + +/** The seam as the web build resolves it, and the native sibling the page must never reach. */ +const SEAM = 'src/browser/use-browser-binary-screencast-grant.web.ts' +const NATIVE = 'src/browser/use-browser-binary-screencast-grant.ts' +/** Where the grant token is declared, so this file reads it rather than spelling it again. */ +const GRANT_MODULE = 'src/mobile-web-shell/bridge/bridge-screencast-grant.ts' + +/** The token, parsed off its own declaration: a second spelling is one that can drift. */ +function screencastGrantToken() { + const source = readFileSync(join(mobileDir, GRANT_MODULE), 'utf8') + const declared = /BRIDGE_SCREENCAST_BINARY_GRANT = '([^']+)'/.exec(source) + if (declared === null) { + throw new Error(`${GRANT_MODULE} no longer declares the grant this census reads`) + } + return declared[1] +} + +/** The modules that call the hook, which is the pane and whatever else grows one. */ +function screencastGrantCallers(closure) { + return closure.local.filter((file) => { + if (!/\.tsx?$/.test(file) || file === SEAM || file === NATIVE) { + return false + } + return /\buseBrowserBinaryScreencastGrant\s*\(/.test( + readFileSync(join(mobileDir, file), 'utf8') + ) + }) +} + +describe('the grant token this census is written against', () => { + it('is the one the shell declares', () => { + expect(screencastGrantToken()).toBe('screencastBinary') + }) +}) + +describeClosure( + 'the routes that mount the browser pane', + () => { + it('declares the screencast lane on exactly the routes whose closure asks for it', async () => { + const asking = [] + for (const [route, mod] of PAGE_ROUTE_MODULES) { + const closure = await mobileWebAppRouteClosure(mod) + if (screencastGrantCallers(closure).length > 0) { + asking.push(route) + } + } + // One route today, and the precondition an assertion about a derived set needs: an empty + // list is also what a walk that read nothing produces. + expect(asking).toEqual(['/h/[hostId]/session/[worktreeId]']) + const declared = MOBILE_WEB_PAGE_ROUTES.filter((route) => + route.grants.includes(screencastGrantToken()) + ).map((route) => route.pathname) + expect([...declared].sort()).toEqual([...asking].sort()) + }) + + it('reaches the seam through its web sibling, and the caller is the pane', async () => { + const closure = await mobileWebAppRouteClosure( + PAGE_ROUTE_MODULES.get('/h/[hostId]/session/[worktreeId]') + ) + expect(closure.local).toContain(SEAM) + expect(closure.local).not.toContain(NATIVE) + expect(screencastGrantCallers(closure)).toEqual(['src/browser/MobileBrowserPane.tsx']) + // The pane is mounted by the session's content row rather than by a route of its own, which + // is the whole reason this grant had no route to be pinned against until now. + expect(closure.local).toContain('src/session/MobileSessionActiveContent.tsx') + }) + + it('covers every declared page route, so a new one cannot be missed by this file', () => { + const { mapped, declared } = pageRouteModulesCoverTheManifest(MOBILE_WEB_PAGE_ROUTES) + expect(mapped).toEqual(declared) + }) + }, + 240_000 +) diff --git a/config/scripts/mobile-web-app-session-dictation-capture.test.mjs b/config/scripts/mobile-web-app-session-dictation-capture.test.mjs index cd6fb84870f..6a793c2ff68 100644 --- a/config/scripts/mobile-web-app-session-dictation-capture.test.mjs +++ b/config/scripts/mobile-web-app-session-dictation-capture.test.mjs @@ -4,10 +4,11 @@ * * A census rather than a hand list, because a grant row written by hand is a row that stops * agreeing with the closure the moment a screen moves: the rule below reads what each registered - * page route actually reaches and holds its `grants` to it. Vacuous today — the session route is - * the only closure that reaches the seam and `MOBILE_WEB_PAGE_ROUTES` does not carry it yet (C7.7 - * registers it) — so the control beside it applies the same rule to the session route module and - * shows the rule failing without the four names. + * page route actually reaches and holds its `grants` to it. It was vacuous when it was written — + * the session route is the only closure that reaches the seam and the manifest did not carry it — + * and C7.7 registers that route, so the rule now binds a real entry and the four names in it were + * taken from this census rather than copied. The control beside it stays: it is what shows the + * rule failing, which a green rule over a satisfied manifest cannot. * * The closure also says what the seam took out of the page. Without its web half the bundler * resolves the native one, and the vendored `@orca/expo-two-way-audio` web stub lands in the @@ -141,17 +142,17 @@ describeClosure( reaching.push(route.pathname) } } - // None today: dictation lives on the session screen, and that route is not registered yet. - // Which is why the rule above passes without a grant row moving, and why the control below - // is what proves the rule can fail at all. - expect(reaching).toEqual([]) + // One, now that C7.7 registers it: dictation lives on the session screen and nowhere else, + // so this is both the list and the reason no other route carries an audio grant. The control + // below is still what proves the rule can fail at all. + expect(reaching).toEqual([SESSION_PATHNAME]) const session = await closureOf(SESSION) expect(session.local).toContain(SEAM) }) it('reds the same rule when the session route is registered without them', async () => { - // The control for the rule above, which is vacuous until C7.7 registers this route: the same - // loop, driven over the entry C7.7 would write if it copied its neighbours' grants. + // The control for the rule above: the same loop, driven over the entry C7.7 would have + // written if it had copied its neighbours' grants instead of reading this census. expect( await grantsMissingForRoutes([ { pathname: SESSION_PATHNAME, grants: ['navigate', 'storage'] } @@ -212,7 +213,8 @@ describe('the census rule itself', () => { 'app/h/[hostId]/files/[worktreeId].tsx', 'app/h/[hostId]/files/preview/[worktreeId].tsx', 'app/h/[hostId]/source-control/[worktreeId].tsx', - 'app/h/[hostId]/review/[worktreeId].tsx' + 'app/h/[hostId]/review/[worktreeId].tsx', + 'app/h/[hostId]/session/[worktreeId].tsx' ]) }) diff --git a/config/scripts/mobile-web-app-session-render.test.mjs b/config/scripts/mobile-web-app-session-render.test.mjs new file mode 100644 index 00000000000..b4989a5a199 --- /dev/null +++ b/config/scripts/mobile-web-app-session-render.test.mjs @@ -0,0 +1,326 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { chromium } from 'playwright-core' +import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs' +import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' +import { MOBILE_WEB_PAGE_ROUTES } from './mobile-web-page-routes.mjs' +import { + createBundleServer, + installShellDouble, + readBridgeFaultGrant, + readBridgeProtocolVersion, + readShellCsp +} from './mobile-web-app-render-harness.mjs' + +/** + * The session route in a real browser, on the bundle the shell would serve, under its own header. + * + * What only a browser answers for this route: that every module in the largest closure of the + * series imports and evaluates under React Native Web, that the route paints the session screen + * rather than the Unmatched route, that its chunk arrives over the wire on a client-side + * navigation, and that nothing it paints leaves the origin or violates the policy. The unit tests + * cannot say any of it, because they mock react-native away — it is Flow source vitest will not + * parse. + * + * Two defects this file found, both invisible natively and both a console line rather than a crash. + * One is fixed in the commit beside it and one is reported rather than fixed: + * + * - **Fixed.** `use-mobile-session-markdown-actions.ts` registered `BackHandler` with no platform + * guard, and the effect re-registers whenever the dirty-draft list changes. React Native Web + * answers with "BackHandler is not supported on web and should not be used." and an inert + * subscription: two lines on the console at mount, and a hardware-back guard never armed anyway. + * - **Reported.** `use-mobile-session-diff-comments.ts` runs `void loadDiffComments()` in an effect + * with no catch. The loader returns on a *refused* `worktree.show` and nothing catches a + * *rejected* one, so a host that will not answer raises an unhandled rejection on every session + * mount. `.catch` is the fix and it is one line, but the corpus certifies the rejection — + * `matrix-session.diff-notes-worktree.show-1` lists it as an effect of the loaded checkpoint — so + * fixing it is a golden re-record and a review event rather than something this lane lands. + * + * So the error assertion below is an exact list rather than `toEqual([])` or a filter: that one + * rejection and nothing else. A second error reds it, and so does the rejection going away, which + * is what makes this file the place the fix is noticed when it lands. + * + * **The terminal is not painted here, and this file must not look as though it is.** Putting a + * terminal on screen needs the host protocol handshake, a tab snapshot, a terminal inventory and a + * `terminal.subscribe` stream, which is five hand-written fixtures against five Zod schemas inside + * a transport double — the thing the harness's own docstring says it must not become. What the + * terminal does under the shipped header, opening xterm with zero CSP violations and a byte-exact + * transcript, is `mobile-web-app-terminal-render.test.mjs`, which drives the same component on the + * same build options through a probe route. The rest of what this file does not claim is at the + * bottom. + */ + +const HOST_ROUTE = '/h/render-check-host' +const WORKTREE = 'wt-1' +const SESSION_ROUTE = `${HOST_ROUTE}/session/${WORKTREE}` +const SESSION_PATTERN = '/h/[hostId]/session/[worktreeId]' +/** The patterns `init.pageRoutes` names, which is what the page matches a navigation against. */ +const PAGE_ROUTE_PATTERNS = ['/h/[hostId]', SESSION_PATTERN] +const SHELL_SESSION_ID = 'session-render-session' +const SHELL_BUILD_ID = 'session-render-build' +const SHELL_HOST = { + id: 'render-check-host', + name: 'Render Check Host', + endpoint: 'ws://render-check', + lastConnected: 1 +} +const UNMATCHED = 'Unmatched Route' +const SESSION_CHUNK_KEY = './h/[hostId]/session/[worktreeId].tsx' + +/** + * Exactly what the route declares, read off the manifest rather than restated. + * + * The page's own seams are gated on these: a list written by hand here would let the route grow a + * grant this check never exercises, which is the case where a control renders and refuses. + */ +function sessionGrants() { + const declared = MOBILE_WEB_PAGE_ROUTES.find((route) => route.pathname === SESSION_PATTERN) + if (!declared) { + throw new Error(`${SESSION_PATTERN} is not registered`) + } + return declared.grants +} + +const bundles = mobileWebAppDependenciesPresent() +const describeRender = bundles ? describe : describe.skip + +let scratch +let server +let browser +let origin +let routeChunks = {} +let cspHeader = null +let bridgeVersion = null +let faultGrant = null + +beforeAll(async () => { + if (!bundles) { + return + } + cspHeader = await readShellCsp() + bridgeVersion = await readBridgeProtocolVersion() + faultGrant = await readBridgeFaultGrant() + scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-session-')) + const built = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') }) + routeChunks = built.routeChunks + const served = await createBundleServer({ outDir: built.outDir, cspHeader }) + server = served.server + origin = served.origin + const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER + browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) }) +}, 240_000) + +afterAll(async () => { + await browser?.close() + server?.close() + if (scratch) { + await rm(scratch, { recursive: true, force: true }) + } +}) + +/** A page carrying every signal these cases read: uncaught errors, console errors, request paths. */ +async function openPage(route) { + const page = await browser.newPage({ viewport: { width: 390, height: 844 } }) + // At document start, where the native shell installs the real channel: the entry reads it while + // its own script runs, so a channel added after `load` would already be too late. + await page.addInitScript(installShellDouble, { + version: bridgeVersion, + sessionId: SHELL_SESSION_ID, + buildId: SHELL_BUILD_ID, + route: { pathname: route }, + host: SHELL_HOST, + storage: {}, + faultGrant, + grants: [faultGrant, ...sessionGrants()], + pageRoutes: PAGE_ROUTE_PATTERNS, + replies: {} + }) + const errors = [] + const scripts = [] + const requestedHosts = [] + page.on('pageerror', (error) => errors.push(`${error.name}: ${error.message}`)) + page.on('console', (message) => { + if (message.type() === 'error') { + errors.push(`console.error: ${message.text()}`) + } + }) + // Every request, not only the ones that answered: a CSP refusal fails the request, and a check + // reading responses alone would read a blocked fetch as one that never happened. + page.on('request', (request) => requestedHosts.push(new URL(request.url()).host)) + page.on('response', (response) => { + const path = new URL(response.url()).pathname + if (response.status() === 200 && path.endsWith('.js')) { + scripts.push(path) + } + }) + return { page, errors, scripts, requestedHosts } +} + +/** + * Wait for the entry to mount and then for the route's own content, polled rather than read once: + * every screen is deferred behind `import()`, so `mounted` lands while the chunk is still arriving. + */ +async function waitForRoute({ page, errors }, route, awaitText) { + const named = (what) => + new Error(`${route} ${what}: ${errors.join(' | ') || 'no page or console error'}`) + try { + await page.waitForFunction(() => document.documentElement.dataset.orcaWebEntry === 'mounted', { + timeout: 60_000, + polling: 250 + }) + } catch { + throw named('never mounted') + } + try { + await page.waitForFunction((needle) => document.body.innerText.includes(needle), awaitText, { + timeout: 60_000, + polling: 250 + }) + } catch { + throw named(`mounted but never painted ${JSON.stringify(awaitText)}`) + } + // A route that threw under the page's own error boundary names itself here rather than timing + // out as a page that never mounted. + for (const fault of await page.evaluate(() => globalThis.__orcaRenderCheckFaults ?? [])) { + errors.push(`page fault: ${fault}`) + } +} + +async function openRoute(route, awaitText) { + const opened = await openPage(route) + await opened.page.goto(`${origin}/`, { waitUntil: 'load' }) + await waitForRoute(opened, route, awaitText) + return opened +} + +/** The session header renders it, so the chrome is on screen before this reads the tree. */ +const BACK_LABEL = 'Back to worktrees' + +/** + * The one error this page is expected to produce, named in full. + * + * `use-mobile-session-diff-comments.ts`'s uncaught `loadDiffComments()` against a double that + * answers no RPC. The category is the double's own, so this string is stable for this file and + * says which refusal reached the document rather than only that something did. + */ +const KNOWN_UNCAUGHT = 'RenderCheckShellDouble: the render check answers no RPC' + +describeRender( + 'the session route in a real browser', + () => { + it('mounts the session screen rather than the unmatched route, with nothing on the console', async () => { + const opened = await openRoute(SESSION_ROUTE, 'Terminal') + const text = await opened.page.evaluate(() => document.body.innerText) + // The command dock's own keys, which is the session screen and not a header that happens to + // say the word: no other page route renders an accessory bar. + for (const key of ['Esc', 'Tab', 'Ctrl+C', 'Ctrl+R']) { + expect(text).toContain(key) + } + expect(text).not.toContain(UNMATCHED) + // Exact, because this closure's defects are exactly console lines. The unguarded + // `BackHandler` put two here and is fixed; the uncaught diff-notes rejection is the one + // entry left and is a golden re-record away from going too. + expect(opened.errors).toEqual([KNOWN_UNCAUGHT]) + await opened.page.close() + }, 120_000) + + it('puts the Back control in the accessibility tree by name', async () => { + // Inside the shell there is no native chrome behind this control, so a bare Pressable is + // absent from the tree: a screen reader has nothing to announce and the device proof has + // nothing to find. The source census + // (`mobile/src/mobile-web-shell/page-served-back-control-a11y.test.ts`) holds the role and + // the wording; this is the half only a browser answers, that the two reach the rendered DOM. + const opened = await openRoute(SESSION_ROUTE, 'Terminal') + const control = await opened.page.evaluate((label) => { + const found = document.querySelector(`[aria-label="${label}"]`) + return found === null ? null : { role: found.getAttribute('role'), tag: found.tagName } + }, BACK_LABEL) + // A real `