Files
orca/config/scripts/mobile-web-app/mobile-web-app-source-control-render.test.mjs
T
Kelvin Amoaba 78484aac31 feat(mobile): one next-step bar under both Source Control tabs (#37104)
* feat(mobile): one next-step bar under both Source Control tabs

Changes and Pull request are the only segments. A bar at the foot of both
shows the branch's one next action: the commit box, push or pull, create
the review, resolve or fix with the workspace's agent, merge when ready,
merge, or why it can't. Commits opens as a pushed screen.

* fix(mobile): keep Merge reachable and show bar failures on both tabs

* test(mobile): expect the two Source Control segments in the page render test
2026-11-01 20:17:11 +00:00

220 lines
8.6 KiB
JavaScript

import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
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 {
createBundleServer,
installShellDouble,
readBridgeFaultGrant,
readBridgeProtocolVersion,
readShellCsp
} from './preview/mobile-web-app-render-harness.mjs'
// Render real source-control/review routes under shipped CSP to prove RN-Web evaluation, own-screen paint and client-side chunk fetch.
// No comment card renders without PR RPC chain: this suite cannot prove avatar skip; pr-comment-card-web-avatar.test.tsx covers that branch.
// Off-origin/policy assertions cover only content actually painted here.
const projectDir = fileURLToPath(new URL('../../..', import.meta.url))
const HOST_ROUTE = '/h/render-check-host'
const WORKTREE = 'wt-1'
const HUB_ROUTE = `${HOST_ROUTE}/source-control/${WORKTREE}`
const REVIEW_ROUTE = `${HOST_ROUTE}/review/${WORKTREE}`
const PAGE_ROUTE_PATTERNS = [HOST_ROUTE.replace('render-check-host', '[hostId]')].concat(
'/h/[hostId]/source-control/[worktreeId]',
'/h/[hostId]/review/[worktreeId]'
)
const SHELL_SESSION_ID = 'render-check-session'
const SHELL_BUILD_ID = 'render-check-build'
const SHELL_HOST = {
id: 'render-check-host',
name: 'Render Check Host',
endpoint: 'ws://render-check',
lastConnected: 1
}
const UNMATCHED = 'Unmatched Route'
const HUB_CHUNK_KEY = './h/[hostId]/source-control/[worktreeId].tsx'
const REVIEW_CHUNK_KEY = './h/[hostId]/review/[worktreeId].tsx'
const C4_GRANTS = ['navigate', 'storage', 'externalLink', 'haptics', 'native.clipboard.write']
// Answer only GitHub remote to expose PR triggers; scripting downstream domain RPC chain would make transport double decide behavior.
const REPLIES = { 'github.repoSlug': { owner: 'orca', repo: 'orca' } }
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-source-control-'))
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) {
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
// 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, ...C4_GRANTS],
pageRoutes: PAGE_ROUTE_PATTERNS,
replies: 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()}`)
}
})
// Observe attempted requests too: CSP-blocked fetches lack responses.
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 route-specific 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 error directly instead of mount timeout.
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
}
describeRender(
'the source-control and review routes in a real browser',
() => {
it('mounts the source-control hub rather than the unmatched route', async () => {
const opened = await openRoute(HUB_ROUTE, 'Source Control')
const text = await opened.page.evaluate(() => document.body.innerText)
// Require hub segment chips, beyond generic header text.
for (const chip of ['Changes', 'Pull request']) {
expect(text).toContain(chip)
}
expect(text).not.toContain(UNMATCHED)
expect(opened.errors).toEqual([])
expect(opened.requestedHosts.filter((host) => host !== new URL(origin).host)).toEqual([])
await opened.page.close()
}, 120_000)
it('mounts diff review rather than the unmatched route', async () => {
const opened = await openRoute(REVIEW_ROUTE, 'Changes')
const text = await opened.page.evaluate(() => document.body.innerText)
// The review body (loading, waiting or failed) distinguishes the diff screen from the hub, which shares the Changes title.
expect(text).toMatch(/review/i)
expect(text).not.toContain('Pull request')
expect(text).not.toContain(UNMATCHED)
expect(opened.errors).toEqual([])
expect(opened.requestedHosts.filter((host) => host !== new URL(origin).host)).toEqual([])
await opened.page.close()
}, 120_000)
it.each([
['source-control', HUB_ROUTE, 'Source Control', HUB_CHUNK_KEY],
['review', REVIEW_ROUTE, 'Changes', REVIEW_CHUNK_KEY]
])(
"fetches %s's own chunk on a client-side navigation",
async (_name, route, text, key) => {
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'))
}, route)
await waitForRoute(opened, route, text)
const chunk = routeChunks[key]
expect(chunk, Object.keys(routeChunks).join(' ')).toBeTruthy()
// Builder-identified route chunk proves fresh fetch rather than 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('configures the shipped Android and iOS image policy', async () => {
// Read shipped Kotlin CSP instead of duplicating a header fixture.
expect(cspHeader).toContain("img-src 'self' data: blob: https:")
const swift = await readFile(
join(projectDir, 'mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift'),
'utf8'
)
// Check iOS source too; served Android header cannot prove other shell agrees.
expect(swift).toContain(`"img-src 'self' data: blob: https:"`)
}, 180_000)
},
600_000
)
// PR cards/drawer need unscripted github.prForBranch/prComments and worktree/repo RPC chain; avatar branch uses component test, drawer scrolling device proof.
// Data-gated link/clipboard/router controls use sibling source censuses and host/port-pair suites.