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..7c8bc1d8db9 --- /dev/null +++ b/config/scripts/mobile-web-app-session-render.test.mjs @@ -0,0 +1,305 @@ +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 and the commit beside it fixed, both invisible natively and both a + * console line rather than a crash: + * + * - `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 that was never armed anyway. + * - `use-mobile-session-diff-comments.ts` ran `void loadDiffComments()` in an effect with no catch. + * The loader tolerates a *refused* `worktree.show` and nothing caught a *rejected* one, so a host + * that will not answer put an unhandled rejection on every session mount. + * + * Both are asserted as the absence of any page or console error below, which is why that assertion + * is the strict `toEqual([])` and not a filter. + * + * **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' + +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) + // Strict, because two of this closure's defects were exactly a console line: the unguarded + // `BackHandler` and the uncaught `loadDiffComments` rejection. + expect(opened.errors).toEqual([]) + 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 `