Files
orca/config/scripts/mobile-web-app/session/mobile-web-app-session-render.test.mjs

247 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/build-mobile-web-app-bundle.mjs'
import { mobileWebAppDependenciesPresent } from '../mobile-web-app-bundle-dependencies.mjs'
import { MOBILE_WEB_PAGE_ROUTES } from '../../build/mobile-web-app/mobile-web-page-routes.mjs'
import {
createBundleServer,
installShellDouble,
readBridgeFaultGrant,
readBridgeProtocolVersion,
readShellCsp,
waitForRecordedRequests,
waitForPageFrames
} from '../preview/mobile-web-app-render-harness.mjs'
// Render real session closure under shipped CSP; unit tests mock RN and cannot prove browser import/evaluation, route paint or deferred fetch.
// Assert every console error: unguarded RN-Web BackHandler and rejected diff-note loads once logged silently on mount.
// No terminal attaches against no-RPC double; terminal-render proves terminal content/CSP, session-terminal-init proves session attachment.
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]'
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'
// Read grants from manifest so new route grants are exercised instead of silently omitted by copied fixture.
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 })
}
})
async function openPage(route, replies = {}, { domStorageOff = false } = {}) {
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
if (domStorageOff) {
// Match Android WebView storage-off behavior: localStorage is null, not undefined; import-time push storage reads once threw getItem here.
await page.addInitScript(() => {
Object.defineProperty(window, 'localStorage', { configurable: true, get: () => null })
})
}
// Install channel at document start like native shell; entry reads it before load.
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 warnings = []
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()}`)
}
// Track warnings separately: storage-write-dropped would evade error-only assertions.
if (message.type() === 'warning') {
warnings.push(message.text())
}
})
// Observe requests, not just responses; CSP-blocked fetches must still count as attempts.
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, warnings, scripts, requestedHosts }
}
// Wait for route content after mounted: deferred screen chunk may still be 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)}`)
}
// Report boundary-caught route fault directly instead of timing out on mount.
for (const fault of await page.evaluate(() => globalThis.__orcaRenderCheckFaults ?? [])) {
errors.push(`page fault: ${fault}`)
}
}
async function openRoute(route, awaitText, replies = {}, options = {}) {
const opened = await openPage(route, replies, options)
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
await waitForRoute(opened, route, awaitText)
return opened
}
const SESSION_MOUNT_READS = ['worktree.show', 'session.tabs.list', 'terminal.list']
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)
// Accessory-bar keys prove session screen, beyond generic header text.
for (const key of ['Esc', 'Tab', 'Ctrl+C', 'Ctrl+R']) {
expect(text).toContain(key)
}
expect(text).not.toContain(UNMATCHED)
// Require exact empty list: BackHandler and uncaught diff-note defects were console-only.
expect(opened.errors).toEqual([])
expect(opened.requestedHosts.filter((host) => host !== new URL(origin).host)).toEqual([])
await opened.page.close()
}, 120_000)
it("fetches the session route's own chunk on a client-side navigation", async () => {
const opened = await openRoute(HOST_ROUTE, SHELL_HOST.name)
const loadedForFirstRoute = [...opened.scripts]
await opened.page.evaluate((to) => {
history.pushState(null, '', to)
dispatchEvent(new PopStateEvent('popstate'))
}, SESSION_ROUTE)
await waitForRoute(opened, SESSION_ROUTE, 'Terminal')
const chunk = routeChunks[SESSION_CHUNK_KEY]
expect(chunk, Object.keys(routeChunks).join(' ')).toBeTruthy()
// Identify route chunk through builder to prove fresh fetch, not code already loaded by entry.
expect(opened.scripts.filter((path) => !loadedForFirstRoute.includes(path))).toContain(
`/assets/${chunk}`
)
expect(loadedForFirstRoute).not.toContain(`/assets/${chunk}`)
await opened.page.close()
}, 120_000)
it('paints with DOM storage off, which is how the Android shell serves it', async () => {
// Null-storage arm reproduces Android import-time expo-notifications failure; shell owns push and pages must not load it.
const opened = await openRoute(SESSION_ROUTE, 'Terminal', {}, { domStorageOff: true })
// Require all errors absent; filtering known push failure could hide other illegal storage users.
expect(opened.errors).toEqual([])
await opened.page.close()
}, 120_000)
it('writes no storage key it was never handed, on a mount that read the host status', async () => {
// Answer status.get: host-status-gates once wrote unlisted orca:host-app-version key only after readable reply, invisible to no-RPC cases.
const opened = await openRoute(SESSION_ROUTE, 'Terminal', {
'status.get': {
protocolVersion: 9,
minCompatibleMobileVersion: 1,
appVersion: '1.4.191',
capabilities: []
}
})
await waitForRecordedRequests(opened.page, ['status.get'])
await waitForPageFrames(opened.page)
// Reject every dropped storage write, not just known key, to catch future unlisted writers.
expect(opened.warnings.filter((text) => text.includes('storage-write-dropped'))).toEqual([])
await opened.page.close()
}, 120_000)
it('asks the desktop for the session it was opened on, so the page above is live', async () => {
// Require title/tab/inventory RPCs with correct workspace or same chrome could pass without host reads.
// Await effects after paint: under 20× CPU throttle none had fired at initial Terminal commit.
const opened = await openRoute(SESSION_ROUTE, 'Terminal')
const requests = await waitForRecordedRequests(opened.page, SESSION_MOUNT_READS)
for (const method of SESSION_MOUNT_READS) {
const observed = requests.filter((request) => request.method === method)
expect(observed.length, method).toBeGreaterThan(0)
for (const request of observed) {
expect(request.params.worktree, method).toBe(`id:${WORKTREE}`)
}
}
await opened.page.close()
}, 120_000)
},
600_000
)
// Coverage boundaries: terminal-render proves xterm/CSP/transcript; session-terminal-init proves attachment.
// Data-gated chat/picker/clipboard controls use sibling source censuses and host/port-pair suites.
// browser-pane-render proves frame paint; screencast-lane-grant derives route grant; page-async-storage tests store write refusals.