import { writeAttemptEvent } from './attempt-events.cjs' import type { Page, Request, Response } from 'playwright' export type SootsimNavigationIdentity = { browserId: number contextId: number operation: 'launchApp' | 'openURL' } type SootsimPageState = { bodyText: string bridges: { sootsim: boolean test: boolean } globals: string[] readyState: string title: string url: string } type ActiveNavigation = SootsimNavigationIdentity & { navigationUrl: string navigationId: number } type NavigationMilestone = | 'document-request' | 'document-request-failed' | 'document-request-finished' | 'document-response' | 'domcontentloaded' | 'error' | 'load' | 'start' type NavigationDiagnostic = { browserId: number capturedAt: string contextId: number errorText?: string failurePhase?: 'bridge' | 'navigation' headersReceived?: boolean method?: string milestone: NavigationMilestone navigationUrl: string navigationId: number operation: SootsimNavigationIdentity['operation'] pageState?: SootsimPageState | { evaluateError: string } status?: number target: string type: 'rnx-navigation' url: string } type PageDiagnostic = | NavigationDiagnostic | { capturedAt: string level: string message: string type: 'page-console' } | { capturedAt: string message: string type: 'page-error' } | { capturedAt: string errorText: string method: string type: 'subresource-request-failed' url: string } const DIAGNOSTIC_LIMIT = 40 const pageDiagnostics = new WeakMap() const activeNavigations = new WeakMap() const lastNavigations = new WeakMap() let nextNavigationIdentity = 0 function recordPageDiagnostic(page: Page, diagnostic: PageDiagnostic, persist = false) { const entries = pageDiagnostics.get(page) if (!entries) return entries.push(diagnostic) if (entries.length > DIAGNOSTIC_LIMIT) entries.shift() if (persist) writeAttemptEvent(diagnostic) } function isTopLevelDocumentRequest(page: Page, request: Request) { return ( request.isNavigationRequest() && request.resourceType() === 'document' && request.frame() === page.mainFrame() ) } function recordNavigationMilestoneFor( page: Page, active: ActiveNavigation, milestone: NavigationMilestone, details: Partial< Pick< NavigationDiagnostic, | 'errorText' | 'failurePhase' | 'headersReceived' | 'method' | 'pageState' | 'status' | 'url' > > = {}, ) { recordPageDiagnostic( page, { ...active, ...details, capturedAt: new Date().toISOString(), milestone, target: process.env.CONFORMANCE_PROOF_TARGET || 'sootsim', type: 'rnx-navigation', url: details.url ?? active.navigationUrl, }, true, ) } function recordNavigationMilestone( page: Page, milestone: NavigationMilestone, details: Parameters[3] = {}, ) { const active = activeNavigations.get(page) if (active) recordNavigationMilestoneFor(page, active, milestone, details) } function attachPageDiagnostics(page: Page) { if (pageDiagnostics.has(page)) return pageDiagnostics.set(page, []) page.on('console', (message) => { recordPageDiagnostic(page, { capturedAt: new Date().toISOString(), level: message.type(), message: message.text(), type: 'page-console', }) }) page.on('pageerror', (error) => { recordPageDiagnostic(page, { capturedAt: new Date().toISOString(), message: error.message, type: 'page-error', }) }) page.on('request', (request) => { if (!isTopLevelDocumentRequest(page, request)) return recordNavigationMilestone(page, 'document-request', { method: request.method(), url: request.url(), }) }) page.on('response', (response) => { const request = response.request() if (!isTopLevelDocumentRequest(page, request)) return recordNavigationMilestone(page, 'document-response', { headersReceived: true, status: response.status(), url: request.url(), }) }) page.on('requestfinished', (request) => { if (!isTopLevelDocumentRequest(page, request)) return recordNavigationMilestone(page, 'document-request-finished', { url: request.url() }) }) page.on('requestfailed', (request) => { const failure = request.failure() const errorText = failure?.errorText ?? '' if (isTopLevelDocumentRequest(page, request)) { recordNavigationMilestone(page, 'document-request-failed', { errorText, url: request.url(), }) return } recordPageDiagnostic(page, { capturedAt: new Date().toISOString(), errorText, method: request.method(), type: 'subresource-request-failed', url: request.url(), }) }) page.on('domcontentloaded', () => { recordNavigationMilestone(page, 'domcontentloaded') }) page.on('load', () => { recordNavigationMilestone(page, 'load') }) } async function readSootsimPageState( page: Page, ): Promise { try { return await page.evaluate(() => ({ bodyText: document.body?.innerText?.slice(0, 500) ?? '', bridges: { sootsim: Boolean(window.SootSim), test: Boolean(window.__sootsimTest), }, globals: Object.keys(window) .filter((key) => key.startsWith('__sootsim') || key === 'SootSim') .sort(), readyState: document.readyState, title: document.title, url: window.location.href, })) } catch (error) { return { evaluateError: error instanceof Error ? error.message : String(error), } } } export async function describeSootsimBridgeFailure( page: Page, cause: unknown, summary = 'timed out waiting for sootsim test bridge', pageState?: SootsimPageState | { evaluateError: string }, ) { const recordBridgeFailure = pageState === undefined const state = pageState ?? (await readSootsimPageState(page)) const navigation = lastNavigations.get(page) if (recordBridgeFailure && navigation) { recordNavigationMilestoneFor(page, navigation, 'error', { errorText: cause instanceof Error ? cause.message : String(cause), failurePhase: 'bridge', pageState: state, }) } return new Error( [ summary, `cause: ${cause instanceof Error ? cause.message : String(cause)}`, `page: ${JSON.stringify(state)}`, `recent page diagnostics:\n${JSON.stringify(getSootsimNavigationDiagnostics(page))}`, ].join('\n'), ) } export async function navigateSootsimPage( page: Page, url: string, identity: SootsimNavigationIdentity, ): Promise { attachPageDiagnostics(page) const navigation = { ...identity, navigationUrl: url, navigationId: ++nextNavigationIdentity, } activeNavigations.set(page, navigation) lastNavigations.set(page, navigation) recordNavigationMilestone(page, 'start') try { return await page.goto(url, { waitUntil: 'load', timeout: 30000 }) } catch (error) { const pageState = await readSootsimPageState(page) recordNavigationMilestone(page, 'error', { errorText: error instanceof Error ? error.message : String(error), failurePhase: 'navigation', pageState, }) throw await describeSootsimBridgeFailure( page, error, 'sootsim navigation failed', pageState, ) } finally { activeNavigations.delete(page) } } export function getSootsimNavigationDiagnostics(page: Page): readonly PageDiagnostic[] { return [...(pageDiagnostics.get(page) ?? [])] }