diff --git a/config/scripts/build-mobile-web-app-bundle.test.mjs b/config/scripts/build-mobile-web-app-bundle.test.mjs index b32044ff5a5..fa68caaaa1e 100644 --- a/config/scripts/build-mobile-web-app-bundle.test.mjs +++ b/config/scripts/build-mobile-web-app-bundle.test.mjs @@ -79,16 +79,22 @@ async function withScratch(run) { * this file under the 600-line cap. */ const EXPECTED_PAGE_ROUTES = [ - { pathname: '/h/[hostId]', grants: ['navigate', 'storage'] }, - { pathname: '/h/[hostId]/agent-history/[worktreeId]', grants: ['navigate', 'storage'] }, + { pathname: '/h/[hostId]', grants: ['navigate', 'storage', 'haptics'] }, + { + pathname: '/h/[hostId]/agent-history/[worktreeId]', + grants: ['navigate', 'storage', 'haptics'] + }, { pathname: '/h/[hostId]/tasks', - grants: ['navigate', 'storage', 'externalLink', 'native.clipboard.write'] + grants: ['navigate', 'storage', 'externalLink', 'haptics', 'native.clipboard.write'] + }, + { + pathname: '/h/[hostId]/files/[worktreeId]', + grants: ['navigate', 'storage', 'externalLink', 'haptics'] }, - { pathname: '/h/[hostId]/files/[worktreeId]', grants: ['navigate', 'storage', 'externalLink'] }, { pathname: '/h/[hostId]/files/preview/[worktreeId]', - grants: ['navigate', 'storage', 'externalLink'] + grants: ['navigate', 'storage', 'externalLink', 'haptics'] } ] @@ -530,15 +536,17 @@ describe('the Phase C budget', () => { }) it('derives the chunk ceiling from the route count, not from a measured number', async () => { - // A chunk is emitted per distinct set of importers, so the count is combinatorial rather than - // one per route. Measured while building this: 8 routes emit 23 chunks, 10 emit 40, 12 emit - // 47, 14 emit 53 -- about 3 more per route at the top. The ceiling allows 4 and starts 16 - // above zero, so the next few routes land under it instead of failing on a pinned number. + // A chunk is emitted per distinct set of importers, so the count is not a function of the + // route count alone. Re-measured on this head, by copying the route tree and dropping routes + // from the end of the sorted key list -- both siblings of each, because deleting a .web.tsx + // alone leaves the native file for the builder to resolve and measures a different closure. + // The 14-route reading is the real tree and includes the one script the deferred mermaid + // artifact costs. for (const [routes, measured] of [ - [8, 23], - [10, 40], - [12, 47], - [14, 53] + [8, 32], + [10, 43], + [12, 61], + [14, 69] ]) { expect(mobileWebAppBundleMaxChunks(routes), `${String(routes)} routes`).toBeGreaterThan( measured @@ -546,6 +554,29 @@ describe('the Phase C budget', () => { } expect(mobileWebAppBundleMaxChunks(14)).toBe(72) expect(mobileWebAppBundleMaxChunks(15) - mobileWebAppBundleMaxChunks(14)).toBe(4) + // Between four and nine more per route above, so the ceiling is a bound and not a fit -- and + // at 14 routes it is a close one. 69 measured against 72, with the last two routes having cost + // the 8 the ceiling grants for two: the next route that shares less than its neighbours fails + // here, which is what this is for. + expect(mobileWebAppBundleMaxChunks(14) - mobileWebAppBundleMaxChunks(12)).toBe(8) + }) + + it('refuses an engine chunked along its own lazy boundaries, and passes one artifact', () => { + // The two builds this ceiling has to tell apart, both measured at 14 routes. + // + // The page reaches mermaid through one pre-bundled artifact and the bundle emits 69 scripts + // (68 of them the page's own split, one the deferred engine). Importing the package instead + // emitted 172: mermaid lazily imports each of its own diagram types and esbuild splits along + // those boundaries, all of it inside the generation the phone has already downloaded. The + // route term is the only term precisely so that the second of those fails here -- a ceiling + // raised to admit 172 would have admitted any split at all. + const ROUTES = 14 + const WITH_ONE_ARTIFACT = 69 + const CHUNKED_ALONG_THE_ENGINE = 172 + expect(WITH_ONE_ARTIFACT).toBeLessThanOrEqual(mobileWebAppBundleMaxChunks(ROUTES)) + expect(CHUNKED_ALONG_THE_ENGINE).toBeGreaterThan(mobileWebAppBundleMaxChunks(ROUTES)) + // And the assets that came with it: 215 against 112, of the 256 the shell will load. + expect(mobileWebAppBundleMaxAssets(ROUTES, 42)).toBeLessThan(CHUNKED_ALONG_THE_ENGINE + 42 + 1) }) it('derives the asset ceiling so the chunk ceiling is always the one that trips first', () => { @@ -586,7 +617,8 @@ describe('the Phase C budget', () => { it('fails the build when the derived ceiling passes what the phone will accept', async () => { // The shell hands back null for a manifest over its own ceiling, so a derived ceiling above // that ships a green build no device can open. At the 42 images the tree carries, 4r + 16 + - // 42 + 1 crosses 256 at 50 routes, which Phase C reaches. + // 42 + 1 crosses 256 at 50 routes, which Phase C reaches. A deferred engine kept to one + // artifact leaves that where it is; the 103-script version of it moved the crossing to 24. expect(await readMobileWebBundleMaxAssets()).toBe(MOBILE_WEB_BUNDLE_MAX_ASSETS) expect(assertAssetCeilingFitsShell(49, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toBe(255) expect(() => assertAssetCeilingFitsShell(50, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toThrow( diff --git a/config/scripts/mobile-web-app-files-render.test.mjs b/config/scripts/mobile-web-app-files-render.test.mjs index 52ab7a50927..5c8baedd48b 100644 --- a/config/scripts/mobile-web-app-files-render.test.mjs +++ b/config/scripts/mobile-web-app-files-render.test.mjs @@ -48,7 +48,7 @@ const SHELL_HOST = { lastConnected: 1 } /** Exactly what the preview declares in `MOBILE_WEB_PAGE_ROUTES`, plus the protocol's own grant. */ -const PREVIEW_GRANTS = ['navigate', 'storage', 'externalLink'] +const PREVIEW_GRANTS = ['navigate', 'storage', 'externalLink', 'haptics'] /** * A terminal artifact, which is the only preview this screen lets anyone edit. diff --git a/config/scripts/mobile-web-app-haptics-seam.mjs b/config/scripts/mobile-web-app-haptics-seam.mjs new file mode 100644 index 00000000000..d7caa005297 --- /dev/null +++ b/config/scripts/mobile-web-app-haptics-seam.mjs @@ -0,0 +1,213 @@ +/** + * The haptics seam, and how a census tells a page function that asks the shell from one that does + * nothing. + * + * `haptics.web.ts` used to be five empty bodies, which is a shape no scan can distinguish from a + * file it failed to read: an empty result and a green census meant the same thing. Now each of the + * five posts one `native.haptics.trigger` notify carrying its own kind, so the census measures the + * kinds it found — and runs the same walk over the native sibling, where the same five functions + * exist and none of them posts, as the control that says the walk can tell the two apart. + * + * Shared rather than restated in each route's census, for the reason + * `mobile-web-app-external-link-seam.mjs` is: two spellings of one rule drift. + */ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' + +/** The seam as the web build resolves it: `.web.ts` wins under the builder's `resolveExtensions`. */ +export const HAPTICS_SEAM = 'src/platform/haptics.web.ts' + +/** Its native sibling, which the page must never resolve to — it imports `expo-haptics`. */ +export const HAPTICS_NATIVE = 'src/platform/haptics.ts' + +/** The module that declares the kinds, so a census reads them instead of listing them again. */ +export const HAPTICS_KINDS_MODULE = 'src/mobile-web-shell/bridge/bridge-haptics-notify.ts' + +const KINDS_CONST = 'BRIDGE_HAPTICS_KINDS' +const PUBLISH_FUNCTION = 'publishHapticsNotifier' + +const parse = (source, fileName) => + ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true) + +/** + * The kinds the notify admits, read off the tuple that declares them. + * + * Parsed rather than matched, so a mention of the name in a comment or a docstring is not a + * declaration, and so the quote style is settled for free. + */ +export function bridgeHapticsKinds(source, fileName = 'bridge-haptics-notify.ts') { + const parsed = parse(source, fileName) + for (const statement of parsed.statements) { + if (!ts.isVariableStatement(statement)) { + continue + } + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name) || declaration.name.text !== KINDS_CONST) { + continue + } + // `as const` wraps the literal in an assertion expression; the tuple is inside it. + const initializer = + declaration.initializer !== undefined && ts.isAsExpression(declaration.initializer) + ? declaration.initializer.expression + : declaration.initializer + if (initializer === undefined || !ts.isArrayLiteralExpression(initializer)) { + continue + } + return initializer.elements + .filter((element) => ts.isStringLiteral(element)) + .map((element) => element.text) + } + } + return [] +} + +/** + * The name of the module-level binding `publishHapticsNotifier` assigns, or null in a module that + * publishes nothing. + * + * Derived rather than assumed: the census must not be keyed on a local called `post`, because + * renaming it would silently turn every posting site into a non-posting one and leave the census + * green on a page with no haptics at all. + */ +function notifierBinding(parsed) { + let binding = null + const visit = (node) => { + if ( + ts.isFunctionDeclaration(node) && + node.name !== undefined && + node.name.text === PUBLISH_FUNCTION + ) { + const assign = (inner) => { + if ( + ts.isBinaryExpression(inner) && + inner.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isIdentifier(inner.left) + ) { + binding = inner.left.text + } + ts.forEachChild(inner, assign) + } + ts.forEachChild(node, assign) + return + } + ts.forEachChild(node, visit) + } + ts.forEachChild(parsed, visit) + return binding +} + +/** Every string literal this call is handed, so a site that posts a computed kind reports none. */ +function literalArguments(call) { + return call.arguments.filter((argument) => ts.isStringLiteral(argument)).map((a) => a.text) +} + +/** + * Every exported `trigger…` function in a haptics module, and the kind it posts. + * + * `kind` is null for a function that posts nothing, which is what the native sibling's five are and + * what the web sibling's five used to be. Reported as sites rather than as a boolean because a + * census whose red names `path:line` is read once and one that names a file is grepped for. + */ +export function hapticsTriggerSites(source, fileName = 'haptics.ts') { + const parsed = parse(source, fileName) + const binding = notifierBinding(parsed) + const lineOf = (node) => parsed.getLineAndCharacterOfPosition(node.getStart(parsed)).line + 1 + const sites = [] + for (const statement of parsed.statements) { + if ( + !ts.isFunctionDeclaration(statement) || + statement.name === undefined || + !statement.name.text.startsWith('trigger') || + statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) !== + true + ) { + continue + } + const posted = [] + if (binding !== null && statement.body !== undefined) { + const visit = (node) => { + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { + if (node.expression.text === binding) { + posted.push(...literalArguments(node)) + } + } + ts.forEachChild(node, visit) + } + ts.forEachChild(statement.body, visit) + } + sites.push({ + name: statement.name.text, + line: lineOf(statement), + // One kind per call and one call per function: two would be two taps for one gesture. + kind: posted.length === 1 ? posted[0] : null + }) + } + return sites +} + +/** The kinds a module posts, in the order its functions are declared. */ +export function hapticsPostedKinds(source, fileName = 'haptics.ts') { + return hapticsTriggerSites(source, fileName) + .map((site) => site.kind) + .filter((kind) => kind !== null) +} + +/** + * The names a module imports from the app's haptics, which is how the shell's mapping is held to it. + * + * `page-haptics.ts` names each function as a named import rather than reaching a namespace, so a + * row naming something `haptics.ts` does not export is already a compile error. This is the other + * direction, which no type states: a haptic that file grows with no kind of its own would be one + * the page can never ask for, and comparing this list against the file's own exports is the only + * thing that sees it. + */ +export function hapticsImportedNames(source, fileName = 'module.ts') { + const parsed = parse(source, fileName) + const names = [] + for (const statement of parsed.statements) { + if ( + !ts.isImportDeclaration(statement) || + !ts.isStringLiteral(statement.moduleSpecifier) || + !/(?:\.\.?\/)+platform\/haptics$/.test(statement.moduleSpecifier.text) + ) { + continue + } + const bindings = statement.importClause?.namedBindings + if (bindings !== undefined && ts.isNamedImports(bindings)) { + // The imported name, not the local one: a renamed import is the same export. + names.push(...bindings.elements.map((element) => (element.propertyName ?? element.name).text)) + } + } + return [...new Set(names)].sort() +} + +/** + * Every module in a closure that imports the haptics seam, as the path the closure reports. + * + * The specifier is read extensionless, because that is how a consumer writes it and how the builder + * resolves it: a module importing `../platform/haptics` gets the `.web.ts` on the page and the + * native file on a phone, and the census's job is to say which one the closure ended up with. + */ +export function hapticsSeamImporters(mobileDir, closure) { + const specifier = /(?:^|['"])(?:\.\.?\/)+platform\/haptics(?:\.web)?['"]$/ + return closure.local + .filter((file) => file !== HAPTICS_SEAM && file !== HAPTICS_NATIVE) + .filter((file) => { + let source + try { + source = readFileSync(join(mobileDir, file), 'utf8') + } catch { + return false + } + const parsed = parse(source, file) + return parsed.statements.some( + (statement) => + (ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)) && + statement.moduleSpecifier !== undefined && + ts.isStringLiteral(statement.moduleSpecifier) && + specifier.test(`'${statement.moduleSpecifier.text}'`) + ) + }) + .sort() +} diff --git a/config/scripts/mobile-web-app-haptics-seam.test.mjs b/config/scripts/mobile-web-app-haptics-seam.test.mjs new file mode 100644 index 00000000000..3ddf53a74d1 --- /dev/null +++ b/config/scripts/mobile-web-app-haptics-seam.test.mjs @@ -0,0 +1,301 @@ +/** + * What a page's taps reach for a haptic, and the grant every page route needs to get one. + * + * Inside the shell's WebView `expo-haptics` fakes an iOS haptic by clicking a hidden checkbox it + * appends to `document.head`, which is what killed a long press on the worktree list (C1.9). So the + * page's seam posts `native.haptics.trigger` instead and the app plays the device's own — and a + * route that imports the seam without declaring `haptics` is a page whose taps go quiet, because + * grants are resolved once from the route the shell opened. + * + * The scan has a control rather than an empty list: the same walk over the native sibling finds the + * same five functions and no posting site, which is what says it can tell the two apart. + */ +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 { MOBILE_WEB_PAGE_ROUTES } from './mobile-web-page-routes.mjs' +import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' +import { + HAPTICS_KINDS_MODULE, + HAPTICS_NATIVE, + HAPTICS_SEAM, + bridgeHapticsKinds, + hapticsImportedNames, + hapticsPostedKinds, + hapticsSeamImporters, + hapticsTriggerSites +} from './mobile-web-app-haptics-seam.mjs' + +const mobileDir = fileURLToPath(new URL('../../mobile/', import.meta.url)) +const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.skip + +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'] +]) + +const HAPTICS_GRANT = 'haptics' + +/** The shell's mapping from a notify kind to one of the app's own functions. */ +const SHELL_MAPPING = 'src/mobile-web-shell/page-haptics.ts' + +describe('the seam reader', () => { + it('names the kind each exported trigger posts', () => { + expect( + hapticsTriggerSites( + [ + 'let post = () => false', + 'export function publishHapticsNotifier(notify) {', + ' post = notify', + '}', + "export function triggerSelection() { post('selection') }" + ].join('\n'), + 'haptics.web.ts' + ) + ).toEqual([{ name: 'triggerSelection', line: 5, kind: 'selection' }]) + }) + + it('reads the binding the publisher assigns rather than a name called post', () => { + // Keyed on `post`, renaming the local would turn every posting site into a non-posting one and + // leave this census green on a page whose taps buzz for nothing. + expect( + hapticsPostedKinds( + [ + 'let ask = () => false', + 'export function publishHapticsNotifier(notify) { ask = notify }', + "export function triggerError() { ask('error') }" + ].join('\n'), + 'haptics.web.ts' + ) + ).toEqual(['error']) + }) + + it('reports a function that posts nothing as a site with no kind', () => { + expect( + hapticsTriggerSites('export function triggerSelection() {}\n', 'haptics.web.ts') + ).toEqual([{ name: 'triggerSelection', line: 1, kind: null }]) + }) + + it('reports no kind for a function that posts one it computed, which nothing can pin', () => { + expect( + hapticsPostedKinds( + [ + 'let post = () => false', + 'export function publishHapticsNotifier(notify) { post = notify }', + 'export function triggerSelection(kind) { post(kind) }' + ].join('\n'), + 'haptics.web.ts' + ) + ).toEqual([]) + }) + + it('reports no kind for a function that posts twice, which is two taps for one gesture', () => { + expect( + hapticsPostedKinds( + [ + 'let post = () => false', + 'export function publishHapticsNotifier(notify) { post = notify }', + "export function triggerSelection() { post('selection'); post('success') }" + ].join('\n'), + 'haptics.web.ts' + ) + ).toEqual([]) + }) + + it('leaves alone a trigger the module does not export', () => { + expect( + hapticsTriggerSites( + [ + 'let post = () => false', + 'export function publishHapticsNotifier(notify) { post = notify }', + "function triggerLocal() { post('selection') }" + ].join('\n'), + 'haptics.web.ts' + ) + ).toEqual([]) + }) + + it('ignores the seam named inside a comment or a string, which text matching cannot', () => { + expect( + hapticsPostedKinds( + [ + 'let post = () => false', + 'export function publishHapticsNotifier(notify) { post = notify }', + "// export function triggerSelection() { post('selection') }", + 'const hint = "post(\'success\')"' + ].join('\n'), + 'haptics.web.ts' + ) + ).toEqual([]) + }) + + it('reads the kinds off the tuple that declares them', () => { + expect(bridgeHapticsKinds("export const BRIDGE_HAPTICS_KINDS = ['a', 'b'] as const\n")).toEqual( + ['a', 'b'] + ) + // A mention is not a declaration, which is why this is parsed rather than matched. + expect(bridgeHapticsKinds("// BRIDGE_HAPTICS_KINDS = ['a']\n")).toEqual([]) + }) +}) + +/** + * The two siblings measured against each other, which is what makes "all five post" a number. + * + * The native file is the control: same five names, same walk, no posting site. Without it an empty + * result and a file the scan could not read would report the same thing. + */ +describe('the two haptics siblings', () => { + it('posts every kind the notify admits from the web sibling, and nothing more', () => { + const kinds = bridgeHapticsKinds(read(HAPTICS_KINDS_MODULE), HAPTICS_KINDS_MODULE) + expect(kinds).toHaveLength(5) + const posted = hapticsPostedKinds(read(HAPTICS_SEAM), HAPTICS_SEAM) + expect([...posted].sort()).toEqual([...kinds].sort()) + }) + + it('finds five functions in the native sibling and no posting site at all', () => { + const sites = hapticsTriggerSites(read(HAPTICS_NATIVE), HAPTICS_NATIVE) + expect(sites).toHaveLength(5) + expect(sites.filter((site) => site.kind !== null)).toEqual([]) + }) + + it('exports the same five names from both, which is what makes one a substitution', () => { + const names = (file) => hapticsTriggerSites(read(file), file).map((site) => site.name) + expect(names(HAPTICS_SEAM)).toEqual(names(HAPTICS_NATIVE)) + }) + + /** + * The third direction, which no type in the app states. + * + * The shell's table refuses a kind with no row and a row naming a function that does not exist, + * both at compile time. It says nothing about a haptic `haptics.ts` grows with no kind of its own, + * which would be one the page can never ask for however many rows the table has. + */ + it('maps every function the app exports from the shell side, so none is unreachable', () => { + const exported = hapticsTriggerSites(read(HAPTICS_NATIVE), HAPTICS_NATIVE).map( + (site) => site.name + ) + expect(exported).toHaveLength(5) + expect(hapticsImportedNames(read(SHELL_MAPPING), SHELL_MAPPING)).toEqual([...exported].sort()) + }) +}) + +describe('the imported-name reader', () => { + it('names what a module takes from the app haptics', () => { + expect( + hapticsImportedNames( + "import { triggerError, triggerSuccess } from '../platform/haptics'\n", + 'page-haptics.ts' + ) + ).toEqual(['triggerError', 'triggerSuccess']) + }) + + it('reads the imported name and not the local one, a renamed import being the same export', () => { + expect( + hapticsImportedNames( + "import { triggerError as boom } from '../platform/haptics'\n", + 'page-haptics.ts' + ) + ).toEqual(['triggerError']) + }) + + it('leaves alone an import of the web sibling or of something else entirely', () => { + expect( + hapticsImportedNames( + [ + "import { triggerError } from '../platform/haptics.web'", + "import { triggerSuccess } from './other-haptics'", + "// import { triggerEdgeBump } from '../platform/haptics'" + ].join('\n'), + 'page-haptics.ts' + ) + ).toEqual([]) + }) +}) + +describeClosure( + 'every page route closure and the haptics seam', + () => { + it.each([...ROUTE_MODULES])('resolves the seam to the web sibling: %s', async (_route, mod) => { + const closure = await mobileWebAppRouteClosure(mod) + expect(closure.local).toContain(HAPTICS_SEAM) + expect(closure.local).not.toContain(HAPTICS_NATIVE) + // The precondition an assertion about a closure needs: the walk read a page, not nothing. + expect(closure.local.length).toBeGreaterThan(250) + }) + + it.each([...ROUTE_MODULES])( + 'imports the seam from at least one module, so the grant is not idle: %s', + async (_route, mod) => { + const closure = await mobileWebAppRouteClosure(mod) + expect(hapticsSeamImporters(mobileDir, closure).length).toBeGreaterThan(0) + } + ) + + /** + * The grant list derived from the closures rather than written by hand. + * + * Grants are resolved once, from the route the shell opened, and carried for the life of the + * session. A route that imports the seam and declares nothing is a page whose taps are silent + * with nothing on screen to say why. + * + * The cost of the answer being every route: `implementedPageRoutes` filters on + * `grants.every(implementsGrant)`, so against a shell that does not carry the token no page + * route is served at all and the phone renders the native screens. The mechanism is pinned in + * `mobile/src/mobile-web-shell/page-route-policy.test.ts`. + */ + it('declares haptics on exactly the routes whose closure reaches the seam', async () => { + const reaching = [] + for (const [route, mod] of ROUTE_MODULES) { + const closure = await mobileWebAppRouteClosure(mod) + if (hapticsSeamImporters(mobileDir, closure).length > 0) { + reaching.push(route) + } + } + expect(reaching.length).toBeGreaterThan(0) + const declared = MOBILE_WEB_PAGE_ROUTES.filter((route) => + route.grants.includes(HAPTICS_GRANT) + ).map((route) => route.pathname) + expect([...declared].sort()).toEqual([...reaching].sort()) + }) + + 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() + ) + }) + + /** + * What the notify costs a page to download: one module. + * + * Measured, not assumed: every page closure grew by exactly `bridge-haptics-notify.ts`, and it + * arrives through `page-route-policy.ts` reading the grant token rather than through the seam, + * whose own import of the kind type is erased. Its only dependency is `zod`, which the envelope + * already put in every closure, so the module total moved by the same one. + * + * Pinned structurally rather than as a total: an absolute closure count is main's to move, and a + * number that drifts for unrelated reasons is one nobody reads. + */ + it('adds one module to a page closure, and only the two haptics modules are in it', async () => { + for (const mod of ROUTE_MODULES.values()) { + const closure = await mobileWebAppRouteClosure(mod) + expect(closure.local.filter((file) => file.includes('haptics')).sort(), mod).toEqual([ + HAPTICS_KINDS_MODULE, + HAPTICS_SEAM + ]) + // The engine of the delta: the grant token is a value the route policy reads, and the + // policy is in every page closure. Without this the +1 would have no stated cause. + expect(closure.local, mod).toContain('src/mobile-web-shell/page-route-policy.ts') + } + }) + }, + 240_000 +) diff --git a/config/scripts/mobile-web-app-mermaid-render.test.mjs b/config/scripts/mobile-web-app-mermaid-render.test.mjs new file mode 100644 index 00000000000..f89dd02195b --- /dev/null +++ b/config/scripts/mobile-web-app-mermaid-render.test.mjs @@ -0,0 +1,564 @@ +/** + * Mermaid rendered in the page, in a real browser, under the policy the shell ships. + * + * The native component seals an untrusted diagram inside a `WebView` whose document embeds the + * whole engine as a string. The page has no second content process, so what replaces it is + * `import('mermaid')` on demand and mermaid's own `securityLevel: 'strict'` output. That makes + * three claims this file measures rather than asserts: that rendering violates no directive and + * asks for no JIT, that what the page paints is the diagram the phone already paints, and that a + * hostile diagram reaches the document inert. + * + * The equality oracle is the native `buildHtml` itself, bundled and served as its own document in + * the same browser. Two differences survive and are normalised away: the diagram id (mermaid's own + * `mermaid-` on the native path, the component's `useId` on the page) and the `xmlns:xlink` + * declaration the native document's `innerHTML` serialization adds. Everything else — the viewBox, + * the `max-width`, the injected `