mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 01:27:35 +00:00
* [comment audit] Align legacy failure-read counts with cached display * [comment audit] Condense shared provider lifecycle and browser recovery comments [skip ci] * [comment audit] Condense shared generation, path and preview comments [skip ci] * [comment audit] Condense host, file and Git contract rationale [skip ci] * [comment audit] Tighten Git lifecycle and provider contract comments [skip ci] * [comment audit] Clarify shared host authority and launch isolation [skip ci] * [comment audit] simplify local access mobile and native chat contracts [skip ci] * [comment audit] simplify native chat presentation and state contracts [skip ci] * [comment audit] simplify native runtime and provider contracts [skip ci] * [comment audit] simplify migration and orchestration contracts [skip ci] * [comment audit] simplify plugin trust and process capture rationale [skip ci] * [comment audit] simplify terminal delivery and remote compatibility rationale [skip ci] * [comment audit] clarify remote recovery and launch wire rationale [skip ci] * [comment audit] shorten shared RPC compatibility and runtime evidence comments [skip ci] * [comment audit] simplify secure storage shell startup and sidebar rationale [skip ci] * [comment audit] clarify sidebar, skill and SSH invariants [skip ci] * [comment audit] condense structured chat delivery and timing rationale [skip ci] * [comment audit] condense terminal replay and telemetry invariants [skip ci] * [comment audit] clarify launch permission and terminal delivery rationale [skip ci] * [comment audit] clarify workspace ownership and layout contracts [skip ci] * [comment audit] wait for fitted and painted SSH terminal links * [comment audit] clarify layout persistence and session ownership [skip ci] * [comment audit] clarify host routing and workspace lifecycle contracts [skip ci] * [comment audit] clarify startup persistence and app lifetime comments [skip ci] * [comment audit] clarify recovery and activity boundaries [skip ci] * [comment audit] condense activity and artifact invariants [skip ci] * [comment audit] condense automation authority contracts [skip ci] * [comment audit] simplify automation scheduling and history contracts [skip ci] * [comment audit] clarify browser chrome and annotation lifecycles [skip ci] * [comment audit] clarify browser routing and retained guest ownership [skip ci] * [comment audit] clarify browser recovery and document ownership comments [skip ci] * [comment audit] restore hosted runners required by native IME fixtures * [comment audit] shorten preview, palette and dashboard invariants [skip ci] * [comment audit] clarify terminal preview, dashboard and retention comments [skip ci] * [comment audit] clarify diff-note recovery and editor lifecycle comments [skip ci] * [comment audit] shorten rich-text and combined-diff ownership comments [skip ci] * [comment audit] shorten autosave, draft recovery and model ownership comments [skip ci] * [comment audit] shorten save-queue, notebook and Markdown constraints [skip ci] * [comment audit] shorten Monaco, PDF and rich Markdown safeguards [skip ci] * [comment audit] clarify rich Markdown source and selection safeguards [skip ci] * [comment audit] clarify editor loading and emulator stream lifetimes [skip ci] * [comment audit] trim emulator and feature tour narration [skip ci] * [comment audit] clarify floating workspace and GitHub cache ownership [skip ci] * [comment audit] trim provider controls and Linear filter explanations [skip ci] * [comment audit] clarify pairing and chat resume lifetimes [skip ci] * [comment audit] clarify chat delivery and transcript lifetimes [skip ci] * [comment audit] clarify session options and host-owned status [skip ci] * [comment audit] clarify draft durability and live-status precedence [skip ci] * [comment audit] Trim chat send and model ownership explanations [skip ci] * [comment audit] Shorten transcript ownership and send recovery comments [skip ci] * [comment audit] Simplify native chat composer and transcript hook comments [skip ci] * [comment audit] Shorten native chat scroll and structured session explanations [skip ci] * [comment audit] Trim workspace picker and onboarding commentary [skip ci] * [comment audit] Simplify pet rendering, review cache and quick open comments [skip ci] * [comment audit] Trim sidebar and session history explanations [skip ci] * [comment audit] Condense session resume and review checks comments [skip ci] * [comment audit] Shorten checks state and file explorer refresh comments [skip ci] * [comment audit] Trim plugin bridge and source-control action rationale [skip ci] * [comment audit] Trim source-control listing and review rationale [skip ci] * [comment audit] Shorten sidebar lifecycle and explorer safeguards [skip ci] * [comment audit] Condense Settings account and appearance rationale [skip ci] * [comment audit] Trim pairing and repository Settings explanations [skip ci] * [comment audit] Condense Settings search SSH and terminal safeguards [skip ci] * [comment audit] Shorten Settings voice and provider ownership comments [skip ci] * [comment audit] Condense Settings draft persistence and host-state rationale [skip ci] * [comment audit] Trim Settings hooks and sidebar setup rationale [skip ci] * [comment audit] Condense sidebar deletion and board-filter safeguards [skip ci] * [comment audit] Condense worktree cards, host ownership and Markdown contracts [skip ci] * [comment audit] Condense sidebar drag, folder and filtering contracts [skip ci] * [comment audit] Condense sidebar parity, sleep and host ownership contracts [skip ci] * [comment audit] Prune focus-read baseline after comment cleanup * [comment audit] Clarify sidebar visibility, board drops and agent evidence [skip ci] * [comment audit] Tighten sidebar listing, drag and reveal contracts [skip ci] * [comment audit] Clarify viewport geometry and skill update safeguards [skip ci] * [comment audit] Tighten skill inventory, usage and host status comments [skip ci] * [comment audit] Clarify usage ownership and resource kill safeguards [skip ci] * [comment audit] Tighten tab selection, rename and search contracts [skip ci] * [comment audit] Tighten tab-strip geometry and drag ownership comments [skip ci] * [comment audit] Tighten Tasks recovery, pagination and mutation comments [skip ci] * [comment audit] Tighten provider loading and terminal recovery comments [skip ci] * [comment audit] Tighten completion, cadence and PTY ownership comments [skip ci] * [comment audit] Clarify terminal restore and parked-pane boundaries [skip ci] * [comment audit] Tighten PTY startup and replay explanations [skip ci] * [comment audit] Clarify PTY identity and delivery ownership [skip ci] * [comment audit] Shorten remote reconnect and input replay rationale [skip ci] * [comment audit] Condense terminal parking clipboard and file link comments [skip ci] * [comment audit] Clarify IME ownership and trim trace history [skip ci] * [comment audit] Shorten pane recovery and parked watcher explanations [skip ci] * [comment audit] Condense snapshot replay and visibility recovery rationale [skip ci] * [comment audit] Shorten terminal parking and keyboard ownership rationale [skip ci] * [comment audit] Trim renderer lifecycle and menu rationale [skip ci] * [comment audit] Condense workspace cleanup ownership and settlement notes [skip ci] * [comment audit] Clarify renderer hook evidence and source ownership [skip ci] * [comment audit] Tighten IPC ownership and reconnect explanations [skip ci] * [comment audit] Trim renderer focus, paste and attention comments [skip ci] * [comment audit] Shorten scroll, locale and agent lifecycle comments [skip ci] * [comment audit] Trim agent launch, resume and ownership commentary [skip ci] * [comment audit] Shorten account, browser and editor comments [skip ci] * [comment audit] Clarify host-mirror waits, previews and input ownership [skip ci] * [comment audit] Condense launch recovery and editor tokenization comments [skip ci] * [comment audit] Condense workspace selection and terminal pane rationale [skip ci] * [comment audit] Condense terminal fitting and rendering recovery comments [skip ci] * [comment audit] Condense terminal input and host routing explanations [skip ci] * [comment audit] Condense ownership and sleeping-session recovery rationale [skip ci] * [comment audit] Condense structured launch and terminal link explanations [skip ci] * [comment audit] Condense terminal ownership and workspace file routing rationale [skip ci] * [comment audit] Condense session persistence and workspace activation rationale [skip ci] * [comment audit] Condense workspace routing and browser ownership rationale [skip ci] * [comment audit] Condense mirror recovery and layout authority rationale [skip ci] * [comment audit] Condense runtime transport and file safety rationale [skip ci] * [comment audit] Condense runtime graph and structured session rationale [skip ci] * [comment audit] Condense browser creation and remote tab recovery rationale [skip ci] * [comment audit] Condense snapshot authority and publisher recovery rationale [skip ci] * [comment audit] Condense frame, startup and provider cache rationale [skip ci] * [comment audit] Condense host twin and agent lifecycle rationale [skip ci] * [comment audit] Condense browser authority and editor cleanup rationale [skip ci] * [comment audit] Condense editor buffer identity and review cache rationale [skip ci] * [comment audit] Condense provider lifetime and repository ownership rationale [skip ci] * [comment audit] Condense reconnect, hydration and terminal retirement rationale [skip ci] * [comment audit] Condense recovery, reconciliation and persistence rationale [skip ci] * [comment audit] Condense cleanup ownership and listing admission rationale [skip ci] * [comment audit] Condense teardown ordering and terminal ownership rationale [skip ci] * [comment audit] Condense recovery ledger and web host-boundary rationale [skip ci] * [comment audit] Condense web reconnect and credential lifecycle rationale [skip ci] * [comment audit] Condense cloud rollout and assignment safety rationale [skip ci] * [comment audit] Condense relay session and database recovery rationale [skip ci] * [comment audit] Condense relay placement and protocol compatibility rationale [skip ci] * [comment audit] Condense build and packaging constraints [skip ci] * [comment audit] Condense CI gates and validation isolation rationale [skip ci] * [comment audit] Condense catalog identity and browser validation rationale [skip ci] * [comment audit] Condense mobile browser and frame-budget rationale [skip ci] * [comment audit] Shorten mobile route and browser test explanations [skip ci] * [comment audit] Condense editor and session seam test rationale [skip ci] * [comment audit] Trim session and terminal closure history [skip ci] * [comment audit] Shorten terminal browser and census explanations [skip ci] * [comment audit] Preserve root-only E2E diagnostic baseline * [comment audit] Condense mobile seam and HTML preview test rationale [skip ci] * [comment audit] Condense preview instrumentation and render harness rationale [skip ci] * [comment audit] Condense bundle contracts and native-build constraints [skip ci] * [comment audit] Shorten native packaging and CI routing rationale [skip ci] * [comment audit] Condense release tooling and benchmark constraints [skip ci] * [comment audit] Condense packaging and platform gate rationale [skip ci] * [comment audit] Shorten mobile route and history rationale [skip ci] * [comment audit] Condense mobile browser and component rationale [skip ci] * [comment audit] Shorten drawer and workspace-form rationale [skip ci] * [comment audit] Trim mobile review and editor-document explanations [skip ci] * [comment audit] Condense editor lifecycle and Markdown explanations [skip ci] * [comment audit] Trim mobile file, diagnostics and dictation commentary [skip ci] * [comment audit] Simplify mobile host-list and workspace-view explanations [skip ci] * [comment audit] Clarify mobile shell lifetime and bridge delivery constraints [skip ci] * [comment audit] Condense bridge stream, native request and audio rationale [skip ci] * [comment audit] Reduce bridge schema and page-client commentary [skip ci] * [comment audit] Tighten mobile page bridge contracts [skip ci] * [comment audit] Clarify mobile cache and session constraints [skip ci] * [comment audit] Condense mobile route and lifecycle rationale [skip ci] * [comment audit] Tighten mobile shell hook timing rationale [skip ci] * [comment audit] Clarify route handoff and notification compatibility [skip ci] * [comment audit] Clarify push registration and dictation cleanup constraints [skip ci] * [comment audit] Condense keyboard geometry and native media ownership rationale [skip ci] * [comment audit] Tighten mobile chat scroll and composer concurrency rationale [skip ci] * [comment audit] Condense mobile reply compatibility and upload ownership rationale [skip ci] * [comment audit] Condense optimistic echo and scroll ownership rationale [skip ci] * [comment audit] Tighten stream replay and queued session ownership rationale [skip ci] * [comment audit] Condense mobile composer and reply-schema constraints [skip ci] * [comment audit] Clarify mobile transcript recovery and session ownership [skip ci] * [comment audit] Clarify mobile queue and source-control compatibility [skip ci] * [comment audit] Condense storage and task launch constraints [skip ci] * [comment audit] Clarify task schemas and terminal document ownership [skip ci] * [comment audit] Tighten terminal input and viewport rationale [skip ci] * [comment audit] Shorten terminal and RPC recorder contracts [skip ci] * [comment audit] Condense recorder engine and connection contracts [skip ci] * [comment audit] Shorten host credential and relay recovery comments [skip ci] * [comment audit] Tighten pairing recovery and bundle transport rationale [skip ci] * [comment audit] Clarify RPC delivery and workspace catalog boundaries [skip ci] * [comment audit] Condense catalog and relay entry contracts [skip ci] * [comment audit] Shorten shutdown and SSH relay delivery contracts [skip ci] * [comment audit] Condense WSL relay and E2E regression rationale [skip ci] * [comment audit] Shorten cross-version harness and renderer fixture explanations [skip ci] * [comment audit] Condense paired-host and E2E lifecycle rationale [skip ci] * [comment audit] Shorten layout oracle and regression explanations [skip ci] * [comment audit] Condense paired recovery and ownership regression contracts [skip ci] * [comment audit] Shorten restart, sidebar and SSH lifecycle contracts [skip ci] * [comment audit] Condense terminal rendering and IME regression rationale [skip ci] * [comment audit] Shorten terminal recovery and benchmark contracts [skip ci] * [comment audit] Condense tooling and Windows lifecycle proof rationale [skip ci] * [comment audit] Condense workflow routing and release rationale [skip ci] * [comment audit] Condense mobile security and installer contracts [skip ci] * [comment audit] Shorten native rationale and stylesheet workarounds [skip ci] * [comment audit] Shorten Windows visibility probe rationale [skip ci] * [comment audit] Condense embedded release and signing rationale [skip ci] * [comment audit] Retain current main test behavior in final reconciliation [skip ci] * [comment audit] Complete final comment review across current source inventory * [comment audit] Handle consolidated audit reports before final CI * [comment audit] Preserve credential storage census detail [skip ci] * [comment audit] Scale broad changed E2E selections with verified timing shards * [comment audit] Align E2E assertions with browser close and settled session metadata * [comment audit] Match existing E2Es to current resource and windowing contracts * [comment audit] Observe the shared server outage notice in reconnect coverage * [comment audit] Make existing renderer probes measure settled tab and PTY work * [comment audit] Align palette E2E selector with task search * [comment audit] Refresh remaining palette search selectors * [comment audit] Keep selector correction within selected audit specs * [comment audit] Await emoji fixture settings inside renderer evaluation * [comment audit] Cancel fixture fit retries between terminal tests --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
699 lines
29 KiB
JavaScript
699 lines
29 KiB
JavaScript
import { mkdtemp, readFile, 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 {
|
||
createBundleServer,
|
||
waitForPageFrames,
|
||
installShellDouble,
|
||
parseCspDirectives,
|
||
projectDir,
|
||
readBridgeFaultGrant,
|
||
readBridgePagePainted,
|
||
readBridgeProtocolVersion,
|
||
readShellCsp
|
||
} from './preview/mobile-web-app-render-harness.mjs'
|
||
|
||
// Mount real ExpoRoot: only rendering proves synthesized RequireContext has expected shape.
|
||
const HOST_ROUTE = '/h/render-check-host'
|
||
const HOST_ROUTE_PATTERN = '/h/[hostId]'
|
||
|
||
const SHELL_SESSION_ID = 'render-check-session'
|
||
const SHELL_BUILD_ID = 'render-check-build'
|
||
// Install init host: web expo-secure-store is empty, otherwise valid host paints Host not found.
|
||
const SHELL_HOST = {
|
||
id: 'render-check-host',
|
||
name: 'Render Check Host',
|
||
endpoint: 'ws://render-check',
|
||
lastConnected: 1
|
||
}
|
||
|
||
// Root shards lack mobile dependencies; browser bundling runs in mobile_web_app, while CSP tests run in both.
|
||
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
|
||
|
||
// Poison real route chunk to test boundary without substituting a synthetic route tree.
|
||
const poisonedChunks = new Set()
|
||
const POISON_MESSAGE = 'render check poisoned this route chunk'
|
||
|
||
// Hold chunks until released so pending-load assertions do not depend on winning a network race.
|
||
const heldChunks = new Map()
|
||
let paintName = null
|
||
|
||
beforeAll(async () => {
|
||
cspHeader = await readShellCsp()
|
||
bridgeVersion = await readBridgeProtocolVersion()
|
||
faultGrant = await readBridgeFaultGrant()
|
||
paintName = await readBridgePagePainted()
|
||
if (!bundles) {
|
||
return
|
||
}
|
||
scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-render-'))
|
||
const built = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') })
|
||
const { outDir } = built
|
||
routeChunks = built.routeChunks
|
||
// Prepend throw to retain exports/linking; replacing body would test link failure rather than evaluation failure.
|
||
const served = await createBundleServer({
|
||
outDir,
|
||
cspHeader,
|
||
transformChunk: (path, real) =>
|
||
poisonedChunks.has(path)
|
||
? `throw new Error(${JSON.stringify(POISON_MESSAGE)});\n${real.toString('utf8')}`
|
||
: real,
|
||
handleRequest: (request, response, path) => {
|
||
const held = heldChunks.get(path)
|
||
if (!held) {
|
||
return false
|
||
}
|
||
held
|
||
.then(() => readFile(join(outDir, path.slice(1))))
|
||
.then((real) => {
|
||
response.writeHead(200, { 'content-type': 'text/javascript' })
|
||
response.end(real)
|
||
})
|
||
return true
|
||
}
|
||
})
|
||
server = served.server
|
||
origin = served.origin
|
||
// Use runner Chrome override to avoid CI browser downloads.
|
||
const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
|
||
browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) })
|
||
}, 180_000)
|
||
|
||
afterAll(async () => {
|
||
await browser?.close()
|
||
server?.close()
|
||
if (scratch) {
|
||
await rm(scratch, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
// Require route-specific content; Expo Unmatched also paints text without errors.
|
||
const UNMATCHED = 'Unmatched Route'
|
||
|
||
// Observe fetched scripts as proof navigation loads next chunk; undefined shellRoute omits double, null installs shell without screen.
|
||
async function openPage({
|
||
shellRoute,
|
||
shellHost = SHELL_HOST,
|
||
shellStorage = {},
|
||
shellGrants,
|
||
shellPageRoutes = null
|
||
} = {}) {
|
||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
|
||
if (shellRoute !== undefined) {
|
||
// 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: shellRoute,
|
||
host: shellHost,
|
||
storage: shellStorage,
|
||
faultGrant,
|
||
grants: shellGrants ?? [faultGrant],
|
||
pageRoutes: shellPageRoutes
|
||
})
|
||
}
|
||
const errors = []
|
||
const scripts = []
|
||
let reportUncaught = () => {}
|
||
// Race mount wait with entry errors to report native-only import failures without 30s timeout.
|
||
// Resolve rather than reject: error can settle during goto before any waiter attaches.
|
||
const uncaught = new Promise((resolve) => {
|
||
reportUncaught = resolve
|
||
})
|
||
page.on('pageerror', (error) => {
|
||
errors.push(`${error.name}: ${error.message}`)
|
||
reportUncaught(error)
|
||
})
|
||
page.on('console', (message) => {
|
||
if (message.type() === 'error') {
|
||
errors.push(`console.error: ${message.text()}`)
|
||
}
|
||
})
|
||
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, uncaught }
|
||
}
|
||
|
||
// Wait for route-specific content after mounted: deferred route chunk can still leave suspense body empty.
|
||
async function waitForRoute({ page, errors, uncaught }, route, awaitText) {
|
||
const named = (cause, what) =>
|
||
new Error(`${route} ${what}: ${errors.join(' | ') || 'no page or console error'}`, { cause })
|
||
const race = async (wait) =>
|
||
Promise.race([
|
||
wait.then(
|
||
() => null,
|
||
(error) => error
|
||
),
|
||
uncaught
|
||
])
|
||
// Require committed Expo tree signal; root children can be an error/fallback. Timer polling works even when page never paints rAF.
|
||
const cause = await race(
|
||
page.waitForFunction(() => document.documentElement.dataset.orcaWebEntry === 'mounted', {
|
||
timeout: 30_000,
|
||
polling: 250
|
||
})
|
||
)
|
||
if (cause) {
|
||
const state = await page.evaluate(
|
||
() => document.documentElement.dataset.orcaWebEntry ?? 'absent'
|
||
)
|
||
throw named(cause, `never mounted (entry ${state})`)
|
||
}
|
||
const paintCause = await race(
|
||
page.waitForFunction((needle) => document.body.innerText.includes(needle), awaitText, {
|
||
timeout: 30_000,
|
||
polling: 250
|
||
})
|
||
)
|
||
if (paintCause) {
|
||
throw named(paintCause, `mounted but never painted ${JSON.stringify(awaitText)}`)
|
||
}
|
||
// Fold boundary faults into asserted errors: caught throws emit no pageerror and may leave blank body.
|
||
for (const fault of await page.evaluate(() => globalThis.__orcaRenderCheckFaults ?? [])) {
|
||
errors.push(`page fault: ${fault}`)
|
||
}
|
||
}
|
||
|
||
// Open / as shell does, then route from init; direct route navigation would hide entry routing bugs.
|
||
async function render(route, awaitText, { shellRoute = { pathname: route }, ...shell } = {}) {
|
||
const opened = await openPage({ shellRoute, ...shell })
|
||
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
|
||
await waitForRoute(opened, route, awaitText)
|
||
const text = await opened.page.evaluate(() => document.body.innerText)
|
||
// Read page’s session identity to catch mounting without shell’s session or inventing one.
|
||
const session = await opened.page.evaluate(() => ({
|
||
sessionId: document.documentElement.dataset.orcaWebSessionId ?? null,
|
||
buildId: document.documentElement.dataset.orcaWebBuildId ?? null
|
||
}))
|
||
// Require rewrite from / before render or every host route resolves to Unmatched.
|
||
const url = await opened.page.evaluate(() => location.pathname + location.search)
|
||
await opened.page.close()
|
||
// Name CSP refusals within asserted console errors for useful diagnostics.
|
||
return {
|
||
errors: opened.errors,
|
||
cspErrors: opened.errors.filter((entry) => entry.includes('Content Security Policy')),
|
||
text,
|
||
session,
|
||
url
|
||
}
|
||
}
|
||
|
||
const paintReports = (page, name) =>
|
||
page.evaluate(
|
||
(paint) =>
|
||
(globalThis.__orcaRenderCheckNotifies ?? []).filter((frame) => frame.name === paint).length,
|
||
name
|
||
)
|
||
|
||
async function renderWithoutTree({ shellRoute } = {}) {
|
||
const { page, errors } = await openPage({ shellRoute })
|
||
// Read immediately after load: entry decides synchronously, so further polling would hide an unsettled decision.
|
||
await page.goto(`${origin}/`, { waitUntil: 'load' })
|
||
const entry = await page.evaluate(() => document.documentElement.dataset.orcaWebEntry ?? 'absent')
|
||
const rootChildren = await page.evaluate(() => document.getElementById('root').childElementCount)
|
||
const text = await page.evaluate(() => document.body.innerText)
|
||
const url = await page.evaluate(() => location.pathname + location.search)
|
||
await page.close()
|
||
return { entry, errors, rootChildren, text, url }
|
||
}
|
||
|
||
describe('the shell policy this page is tested under', () => {
|
||
it('is the same on both platforms, so one render check covers both', async () => {
|
||
const swift = await readFile(
|
||
join(projectDir, 'mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift'),
|
||
'utf8'
|
||
)
|
||
expect(parseCspDirectives(swift, 'static let header = [', '].joined')).toBe(cspHeader)
|
||
})
|
||
|
||
it('reads directives from the source and not from the comments around them', () => {
|
||
const source = [
|
||
'static let header = [',
|
||
" // React Native Web needs \"style-src 'self' 'unsafe-inline'\" and nothing more.",
|
||
' "default-src \'none\'",',
|
||
' "script-src \'self\'",',
|
||
" \"style-src 'self' 'unsafe-inline'\",",
|
||
' "img-src \'self\'",',
|
||
' "connect-src \'self\'",',
|
||
' "worker-src \'none\'",',
|
||
' "frame-src \'none\'",',
|
||
' "child-src \'none\'",',
|
||
' "object-src \'none\'",',
|
||
' "base-uri \'none\'",',
|
||
' "form-action \'none\'",',
|
||
' "frame-ancestors \'none\'"',
|
||
'].joined'
|
||
].join('\n')
|
||
const parsed = parseCspDirectives(source, 'static let header = [', '].joined')
|
||
expect(parsed.split('; ')[0]).toBe("default-src 'none'")
|
||
expect(parsed.split('; ').filter((entry) => entry.includes('unsafe-inline'))).toEqual([
|
||
"style-src 'self' 'unsafe-inline'"
|
||
])
|
||
})
|
||
|
||
it('admits only cached Blob URLs for media', () => {
|
||
expect(cspHeader.split('; ').filter((entry) => entry.startsWith('media-src'))).toEqual([
|
||
'media-src blob:'
|
||
])
|
||
})
|
||
|
||
it('still refuses inline script, which is the directive that matters', () => {
|
||
expect(cspHeader).toContain("script-src 'self';")
|
||
expect(cspHeader).not.toContain("script-src 'self' 'unsafe-inline'")
|
||
})
|
||
|
||
it('admits embedded fonts and limits external sources to images', () => {
|
||
expect(cspHeader.split('; ').filter((entry) => entry.includes('data:'))).toEqual([
|
||
"img-src 'self' data: blob: https:",
|
||
"font-src 'self' data:"
|
||
])
|
||
expect(cspHeader.split('; ').filter((entry) => entry.includes('https:'))).toEqual([
|
||
"img-src 'self' data: blob: https:"
|
||
])
|
||
expect(cspHeader).not.toContain('http:')
|
||
})
|
||
})
|
||
|
||
// 1×1 PNG proves decoding, not merely policy admission.
|
||
const DATA_URI_IMAGE =
|
||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
|
||
|
||
describeRender('an image preview under the shell policy', () => {
|
||
it('decodes a data: URI, which is the only shape a file preview has', async () => {
|
||
// Match RN-Web ImageLoader’s new Image/onload path for data:<mime>;base64 preview URLs.
|
||
// Component’s hidden img lacks load/error handlers and serves context menu/getBackgroundSize, so it cannot prove preview load succeeds.
|
||
const { page, errors } = await openPage()
|
||
await page.goto(`${origin}/`, { waitUntil: 'load' })
|
||
const naturalWidth = await page.evaluate(
|
||
(uri) =>
|
||
new Promise((resolve) => {
|
||
const image = new Image()
|
||
image.addEventListener('load', () => resolve(image.naturalWidth))
|
||
image.addEventListener('error', () => resolve(0))
|
||
image.src = uri
|
||
}),
|
||
DATA_URI_IMAGE
|
||
)
|
||
await page.close()
|
||
expect({
|
||
naturalWidth,
|
||
refused: errors.filter((entry) => entry.includes('Content Security Policy'))
|
||
}).toEqual({ naturalWidth: 1, refused: [] })
|
||
})
|
||
})
|
||
|
||
describeRender('the page server this check runs against', () => {
|
||
it('404s a file path the bundle does not contain', async () => {
|
||
// Restrict served prefix or catch-all server would hide unusable script publicPath.
|
||
expect((await fetch(`${origin}/wrong-prefix/entry.js`)).status).toBe(404)
|
||
expect((await fetch(`${origin}/assets/not-a-real-hash.js`)).status).toBe(404)
|
||
})
|
||
|
||
it('answers the icon a browser asks for without an error', async () => {
|
||
expect((await fetch(`${origin}/favicon.ico`)).status).toBe(204)
|
||
})
|
||
|
||
it('still serves the document at every route depth', async () => {
|
||
for (const route of ['/', HOST_ROUTE, `${HOST_ROUTE}/tasks`]) {
|
||
const response = await fetch(`${origin}${route}`)
|
||
expect(response.status, route).toBe(200)
|
||
expect(await response.text(), route).toContain('<div id="root">')
|
||
}
|
||
})
|
||
})
|
||
|
||
describeRender('the Route A page in a real browser', () => {
|
||
it('mounts the worktree list route, not the unmatched screen', async () => {
|
||
const { errors, cspErrors, text, session, url } = await render(HOST_ROUTE, SHELL_HOST.name)
|
||
expect(cspErrors).toEqual([])
|
||
expect(errors).toEqual([])
|
||
expect(session).toEqual({ sessionId: SHELL_SESSION_ID, buildId: SHELL_BUILD_ID })
|
||
expect(url).toBe(HOST_ROUTE)
|
||
expect(text).toContain(SHELL_HOST.name)
|
||
expect(text).not.toContain('Host not found')
|
||
expect(text).not.toContain(UNMATCHED)
|
||
}, 60_000)
|
||
|
||
it('fills the view, so what it mounted is painted and takes a tap', async () => {
|
||
const opened = await openPage({ shellRoute: { pathname: HOST_ROUTE } })
|
||
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
|
||
await waitForRoute(opened, HOST_ROUTE, SHELL_HOST.name)
|
||
const layout = await opened.page.evaluate(() => {
|
||
// No-RPC control at root bottom moves furthest if layout collapses.
|
||
const fab = [...document.querySelectorAll('[role="button"]')].find(
|
||
(element) => element.getAttribute('aria-label') === 'New workspace'
|
||
)
|
||
const box = fab?.getBoundingClientRect() ?? null
|
||
const hit =
|
||
box === null
|
||
? null
|
||
: document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2)
|
||
return {
|
||
rootHeight: document.getElementById('root').getBoundingClientRect().height,
|
||
viewportHeight: window.innerHeight,
|
||
fabTop: box?.top ?? null,
|
||
fabBottom: box?.bottom ?? null,
|
||
reachesTheControl: hit !== null && fab.contains(hit)
|
||
}
|
||
})
|
||
await opened.page.close()
|
||
expect(opened.errors).toEqual([])
|
||
// Measure height: mounted text/innerText can pass while collapsed root paints blank phone screen.
|
||
expect(layout.rootHeight).toBe(layout.viewportHeight)
|
||
expect(layout.fabTop).toBeGreaterThan(0)
|
||
expect(layout.fabBottom).toBeLessThanOrEqual(layout.viewportHeight)
|
||
// Require reachable tap: clipped scroller rows retain layout rects.
|
||
expect(layout.reachesTheControl).toBe(true)
|
||
}, 60_000)
|
||
|
||
it('routes a nested dynamic segment through the same context', async () => {
|
||
const { errors, cspErrors, text, session } = await render(`${HOST_ROUTE}/tasks`, 'Tasks')
|
||
expect(cspErrors).toEqual([])
|
||
expect(errors).toEqual([])
|
||
expect(session.sessionId).toBe(SHELL_SESSION_ID)
|
||
expect(text).toContain('Tasks')
|
||
expect(text).toContain('Issues')
|
||
expect(text).not.toContain(UNMATCHED)
|
||
}, 60_000)
|
||
|
||
// Native files routes throw requireNativeViewManager at import; deferred import hides this until actual route opens, requiring web siblings.
|
||
it('mounts the file explorer, which its native route module cannot do', async () => {
|
||
const worktreeRoute = `${HOST_ROUTE}/files/worktree-a`
|
||
const { errors, cspErrors, text } = await render(worktreeRoute, 'Files', {
|
||
shellRoute: { pathname: worktreeRoute, params: { name: 'Example Worktree' } }
|
||
})
|
||
expect(cspErrors).toEqual([])
|
||
expect(errors).toEqual([])
|
||
expect(text).toContain('Files')
|
||
expect(text).toContain('Example Worktree')
|
||
expect(text).not.toContain(UNMATCHED)
|
||
}, 60_000)
|
||
|
||
it('mounts the file preview, reading the file path out of a param and not a segment', async () => {
|
||
const previewRoute = `${HOST_ROUTE}/files/preview/worktree-a`
|
||
const { errors, cspErrors, text, url } = await render(previewRoute, 'readme.md', {
|
||
shellRoute: {
|
||
pathname: previewRoute,
|
||
params: { relativePath: 'docs/my notes/readme.md', source: 'worktree' }
|
||
}
|
||
})
|
||
expect(cspErrors).toEqual([])
|
||
// Require no unsupported RN-Web BackHandler registration; Android hardware Back pops native stack while page Back owns unsaved-draft prompt.
|
||
expect(errors).toEqual([])
|
||
// Title proves final path segment only; URL checks outbound encoding, not middle-segment decoding.
|
||
// Decode hazards are covered by mobile-file-path-route-encoding.test.ts and mobile-file-preview-route.test.ts.
|
||
expect(text).toContain('readme.md')
|
||
expect(url).toBe(`${previewRoute}?relativePath=docs%2Fmy+notes%2Freadme.md&source=worktree`)
|
||
expect(text).not.toContain(UNMATCHED)
|
||
}, 60_000)
|
||
|
||
it('refuses a host-scoped path with no module rather than crashing', async () => {
|
||
// Catch-all host route must paint refusal and avoid Unmatched; both assertions distinguish refusal from blank screen.
|
||
const refusal = 'This workspace screen is not available on this host.'
|
||
const { errors, cspErrors, text } = await render(`${HOST_ROUTE}/not-a-route`, refusal)
|
||
expect(cspErrors).toEqual([])
|
||
expect(errors).toEqual([])
|
||
expect(text).toContain(refusal)
|
||
expect(text).not.toContain(UNMATCHED)
|
||
}, 60_000)
|
||
|
||
it('carries the params the shell named into the url the screen reads', async () => {
|
||
const { errors, url } = await render(HOST_ROUTE, SHELL_HOST.name, {
|
||
shellRoute: { pathname: HOST_ROUTE, params: { from: 'render check' } }
|
||
})
|
||
expect(errors).toEqual([])
|
||
expect(url).toBe(`${HOST_ROUTE}?from=render+check`)
|
||
}, 60_000)
|
||
|
||
it('paints the not-found state when the shell named no host, which is what makes the row real', async () => {
|
||
const { errors, text } = await render(HOST_ROUTE, 'Host not found', { shellHost: null })
|
||
expect(errors).toEqual([])
|
||
expect(text).toContain('Host not found')
|
||
expect(text).not.toContain(SHELL_HOST.name)
|
||
}, 60_000)
|
||
|
||
it('mounts nothing at all when no shell answered, which is what makes the rest real', async () => {
|
||
// Control catches pages ignoring init entirely.
|
||
const { entry, errors, rootChildren } = await renderWithoutTree()
|
||
expect(entry).toBe('unbridged')
|
||
expect(rootChildren).toBe(0)
|
||
expect(errors).toEqual([])
|
||
}, 60_000)
|
||
|
||
it('says to update the app when the shell that opened it named no screen', async () => {
|
||
const { entry, errors, text, url } = await renderWithoutTree({ shellRoute: null })
|
||
expect(entry).toBe('shell-too-old')
|
||
expect(errors).toEqual([])
|
||
expect(text).toContain('Update Orca to open this workspace')
|
||
// Missing route must not mount Unmatched tree at /.
|
||
expect(text).not.toContain(UNMATCHED)
|
||
expect(url).toBe('/')
|
||
}, 60_000)
|
||
|
||
it('tells the shell when a route chunk throws, rather than sitting on a blank page', async () => {
|
||
const chunk = routeChunks['./h/[hostId]/index.tsx']
|
||
expect(chunk, Object.keys(routeChunks).join(' ')).toBeTruthy()
|
||
poisonedChunks.add(`/assets/${chunk}`)
|
||
try {
|
||
const opened = await openPage({ shellRoute: { pathname: HOST_ROUTE } })
|
||
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
|
||
const reported = await opened.page
|
||
.waitForFunction(
|
||
() => {
|
||
const faults = globalThis.__orcaRenderCheckFaults ?? []
|
||
return faults.length > 0 ? faults : null
|
||
},
|
||
{ timeout: 30_000, polling: 250 }
|
||
)
|
||
.then((handle) => handle.jsonValue())
|
||
// Require poisoned module’s exact error; any-fault check could pass with wrong boundary report.
|
||
expect(reported.join(' | ')).toContain(POISON_MESSAGE)
|
||
// Router shell commits mounted before deferred rejection; require boundary removes screen beneath it.
|
||
const text = await opened.page.evaluate(() => document.body.innerText)
|
||
expect(text).not.toContain('Host not found')
|
||
expect(text).not.toContain(UNMATCHED)
|
||
await opened.page.close()
|
||
} finally {
|
||
poisonedChunks.delete(`/assets/${chunk}`)
|
||
}
|
||
}, 60_000)
|
||
|
||
it('reports its frame from the route screen, not from the router shell above it', async () => {
|
||
// Suspense wrapper commits before route chunk arrives; reporting paint there would uncover empty body.
|
||
const chunk = routeChunks['./h/[hostId]/index.tsx']
|
||
expect(chunk, Object.keys(routeChunks).join(' ')).toBeTruthy()
|
||
const path = `/assets/${chunk}`
|
||
let arrive = () => {}
|
||
heldChunks.set(
|
||
path,
|
||
new Promise((resolve) => {
|
||
arrive = resolve
|
||
})
|
||
)
|
||
try {
|
||
const opened = await openPage({ shellRoute: { pathname: HOST_ROUTE } })
|
||
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
|
||
await opened.page.waitForFunction(
|
||
() => document.documentElement.dataset.orcaWebEntry === 'mounted',
|
||
{ timeout: 30_000, polling: 250 }
|
||
)
|
||
// Mounted with empty fallback body reproduces premature paint signal.
|
||
expect(await opened.page.evaluate(() => document.body.innerText)).not.toContain(
|
||
SHELL_HOST.name
|
||
)
|
||
await waitForPageFrames(opened.page)
|
||
expect(await paintReports(opened.page, paintName)).toBe(0)
|
||
|
||
arrive()
|
||
await waitForRoute(opened, HOST_ROUTE, SHELL_HOST.name)
|
||
await opened.page.waitForFunction(
|
||
(name) =>
|
||
(globalThis.__orcaRenderCheckNotifies ?? []).filter((frame) => frame.name === name)
|
||
.length > 0,
|
||
paintName,
|
||
{ timeout: 30_000, polling: 250 }
|
||
)
|
||
expect(opened.errors).toEqual([])
|
||
await opened.page.close()
|
||
} finally {
|
||
arrive()
|
||
heldChunks.delete(path)
|
||
}
|
||
}, 120_000)
|
||
|
||
it('says nothing for a redirect screen whose target is still behind its chunk', async () => {
|
||
// PR redirect remains mounted behind destination’s pending chunk; its own commit must not report screen paint.
|
||
const target = routeChunks['./h/[hostId]/source-control/[worktreeId].tsx']
|
||
expect(target, Object.keys(routeChunks).join(' ')).toBeTruthy()
|
||
const path = `/assets/${target}`
|
||
let arrive = () => {}
|
||
heldChunks.set(
|
||
path,
|
||
new Promise((resolve) => {
|
||
arrive = resolve
|
||
})
|
||
)
|
||
try {
|
||
const route = `${HOST_ROUTE}/pr/render-check-tree`
|
||
const opened = await openPage({
|
||
shellRoute: { pathname: route },
|
||
shellPageRoutes: [HOST_ROUTE_PATTERN, '/h/[hostId]/pr/[worktreeId]']
|
||
})
|
||
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
|
||
await opened.page.waitForFunction(
|
||
() => location.pathname.includes('/source-control/'),
|
||
undefined,
|
||
{ timeout: 30_000, polling: 250 }
|
||
)
|
||
// Router has moved but pending hub chunk still leaves document empty.
|
||
await waitForPageFrames(opened.page)
|
||
expect(await paintReports(opened.page, paintName)).toBe(0)
|
||
|
||
arrive()
|
||
await opened.page.waitForFunction(
|
||
(name) =>
|
||
(globalThis.__orcaRenderCheckNotifies ?? []).filter((frame) => frame.name === name)
|
||
.length > 0,
|
||
paintName,
|
||
{ timeout: 30_000, polling: 250 }
|
||
)
|
||
await opened.page.close()
|
||
} finally {
|
||
arrive()
|
||
heldChunks.delete(path)
|
||
}
|
||
}, 120_000)
|
||
|
||
it('refuses a target the shell will not take, rather than opening it in the page', async () => {
|
||
// Fault-only grant makes notifyNavigate refuse like disposed/older shell; local fallback once mounted Unmatched or native-only session route.
|
||
const opened = await openPage({ shellRoute: { pathname: HOST_ROUTE } })
|
||
const { page, errors } = opened
|
||
await page.goto(`${origin}/`, { waitUntil: 'load' })
|
||
await waitForRoute(opened, HOST_ROUTE, SHELL_HOST.name)
|
||
await page.getByLabel('Back to hosts').click()
|
||
// Settle handoff microtask before asserting no navigation.
|
||
await waitForPageFrames(page)
|
||
expect(await page.evaluate(() => location.pathname)).toBe(HOST_ROUTE)
|
||
const text = await page.evaluate(() => document.body.innerText)
|
||
expect(text).toContain(SHELL_HOST.name)
|
||
expect(text).not.toContain(UNMATCHED)
|
||
// Require no notify as well as unchanged page; successful shell handoff also leaves document in place.
|
||
const notifies = await page.evaluate(() => globalThis.__orcaRenderCheckNotifies ?? [])
|
||
expect(notifies.filter((frame) => frame.name === 'navigate')).toEqual([])
|
||
// Refused target declines navigation without faulting page.
|
||
expect(await page.evaluate(() => globalThis.__orcaRenderCheckFaults ?? [])).toEqual([])
|
||
expect(errors).toEqual([])
|
||
await page.close()
|
||
}, 60_000)
|
||
|
||
it("fetches the next route's chunks on a client-side navigation", async () => {
|
||
const opened = await openPage({ shellRoute: { pathname: HOST_ROUTE } })
|
||
const { page, errors, scripts } = opened
|
||
await page.goto(`${origin}/`, { waitUntil: 'load' })
|
||
await waitForRoute(opened, HOST_ROUTE, SHELL_HOST.name)
|
||
const loadedForFirstRoute = [...scripts]
|
||
// Shell fetches document once; later route must arrive as a chunk through in-page history navigation.
|
||
await page.evaluate((to) => {
|
||
history.pushState(null, '', to)
|
||
dispatchEvent(new PopStateEvent('popstate'))
|
||
}, `${HOST_ROUTE}/tasks`)
|
||
await waitForRoute(opened, `${HOST_ROUTE}/tasks`, 'Issues')
|
||
expect(new URL(page.url()).pathname).toBe(`${HOST_ROUTE}/tasks`)
|
||
const fetchedOnNavigation = scripts.filter((path) => !loadedForFirstRoute.includes(path))
|
||
// Require builder-identified tasks chunk, not any fetched script or code already loaded by entry.
|
||
const tasksChunk = routeChunks['./h/[hostId]/tasks.tsx']
|
||
expect(tasksChunk, Object.keys(routeChunks).join(' ')).toBeTruthy()
|
||
expect(fetchedOnNavigation, scripts.join(' ')).toContain(`/assets/${tasksChunk}`)
|
||
expect(loadedForFirstRoute).not.toContain(`/assets/${tasksChunk}`)
|
||
const text = await page.evaluate(() => document.body.innerText)
|
||
expect(text).toContain('Tasks')
|
||
expect(text).not.toContain(UNMATCHED)
|
||
expect(errors).toEqual([])
|
||
await page.close()
|
||
}, 60_000)
|
||
})
|
||
|
||
// Probe mounted React Navigation canGoBack through real route chevron/router.back; page exposes no direct test hook.
|
||
// First prove same-family tap reaches shell, since an inert handler and refused local Back both leave document unchanged.
|
||
describeRender('the stack the page Back button rests on', () => {
|
||
const EDIT_ROUTE = `${HOST_ROUTE}/edit`
|
||
const BACK_ON_EDIT = '[aria-label="Back"]'
|
||
|
||
async function clickAndSettle(page, selector) {
|
||
await page.click(selector)
|
||
await waitForPageFrames(page)
|
||
return page.evaluate(() => location.pathname + location.search)
|
||
}
|
||
|
||
it('carries a handoff the shell granted across the bridge from a real tap', async () => {
|
||
// Grant navigate: this chevron proves handler/bridge path used by navigate-back before interpreting absent notifies.
|
||
const opened = await openPage({
|
||
shellRoute: { pathname: HOST_ROUTE },
|
||
shellGrants: [faultGrant, 'navigate'],
|
||
shellPageRoutes: [HOST_ROUTE_PATTERN]
|
||
})
|
||
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
|
||
await waitForRoute(opened, HOST_ROUTE, SHELL_HOST.name)
|
||
const url = await clickAndSettle(opened.page, '[aria-label="Back to hosts"]')
|
||
await expect
|
||
.poll(() =>
|
||
opened.page.evaluate(() =>
|
||
(globalThis.__orcaRenderCheckNotifies ?? []).filter((frame) => frame.name === 'navigate')
|
||
)
|
||
)
|
||
.toEqual([{ v: bridgeVersion, type: 'notify', name: 'navigate', href: '/' }])
|
||
// Require page stays on served route; local fallthrough would mount a screen document cannot serve.
|
||
expect(url).toBe(HOST_ROUTE)
|
||
expect(opened.errors).toEqual([])
|
||
await opened.page.close()
|
||
}, 60_000)
|
||
|
||
it('cannot go back on the document the shell just opened, which is the one screen it has', async () => {
|
||
const opened = await openPage({ shellRoute: { pathname: EDIT_ROUTE } })
|
||
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
|
||
await waitForRoute(opened, EDIT_ROUTE, 'Edit host')
|
||
expect(await opened.page.locator(BACK_ON_EDIT).count()).toBe(1)
|
||
expect(await clickAndSettle(opened.page, BACK_ON_EDIT)).toBe(EDIT_ROUTE)
|
||
expect(opened.errors).toEqual([])
|
||
await opened.page.close()
|
||
}, 60_000)
|
||
|
||
it('is given no stack by a location change either, only by a push this page makes itself', async () => {
|
||
// Entry replaceState and external location changes reset router instead of stacking it; extra browser history cannot make canGoBack true.
|
||
// Local Back branch requires an in-page useRouteHandoff push, which this tree currently has none of.
|
||
const opened = await openPage({ shellRoute: { pathname: HOST_ROUTE } })
|
||
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
|
||
await waitForRoute(opened, HOST_ROUTE, SHELL_HOST.name)
|
||
const entriesBefore = await opened.page.evaluate(() => history.length)
|
||
await opened.page.evaluate((to) => {
|
||
history.pushState(null, '', to)
|
||
dispatchEvent(new PopStateEvent('popstate'))
|
||
}, EDIT_ROUTE)
|
||
await waitForRoute(opened, EDIT_ROUTE, 'Edit host')
|
||
expect(await opened.page.evaluate(() => history.length)).toBe(entriesBefore + 1)
|
||
expect(await clickAndSettle(opened.page, BACK_ON_EDIT)).toBe(EDIT_ROUTE)
|
||
// Synthetic popstate faults could leave page unchanged; require no boundary fault to validate absence.
|
||
expect(opened.errors).toEqual([])
|
||
await opened.page.close()
|
||
}, 60_000)
|
||
})
|