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 `