diff --git a/packages/bundler-metro/src/__tests__/bundle-request-middleware.test.ts b/packages/bundler-metro/src/__tests__/bundle-request-middleware.test.ts new file mode 100644 index 00000000..bdf5e282 --- /dev/null +++ b/packages/bundler-metro/src/__tests__/bundle-request-middleware.test.ts @@ -0,0 +1,123 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Config as HarnessConfig } from '@react-native-harness/config'; +import type { Reporter, ReportableEvent } from '../reporter.js'; +import { getBundleRequestObserverMiddleware } from '../middlewares/bundle-request-middleware.js'; +import { HARNESS_REQUEST_KIND_HEADER } from '../request-kind.js'; + +const createReporter = () => { + const events: ReportableEvent[] = []; + + const reporter: Reporter = { + addListener: vi.fn(), + removeListener: vi.fn(), + clearAllListeners: vi.fn(), + emit: (event) => { + events.push(event); + }, + }; + + return { events, reporter }; +}; + +const createProjectRoot = () => { + const projectRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'rn-harness-bundle-request-') + ); + tempDirs.push(projectRoot); + fs.writeFileSync(path.join(projectRoot, 'index.js'), 'module.exports = {};'); + return projectRoot; +}; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const tempDir of tempDirs.splice(0)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +const createHarnessConfig = (): HarnessConfig => + ({ + entryPoint: './index.js', + }) as HarnessConfig; + +describe('bundle request observer middleware', () => { + it('emits app-originated entry bundle requests', () => { + const { events, reporter } = createReporter(); + const middleware = getBundleRequestObserverMiddleware( + createProjectRoot(), + createHarnessConfig(), + reporter + ); + const next = vi.fn(); + + middleware( + { + headers: {}, + url: '/index.bundle?platform=ios&dev=true', + } as never, + {} as never, + next + ); + + expect(events).toEqual([ + expect.objectContaining({ + type: 'bundle_request_observed', + platform: 'ios', + requestKind: 'app', + url: '/index.bundle?platform=ios&dev=true', + }), + ]); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('tags prewarm requests using the Harness header', () => { + const { events, reporter } = createReporter(); + const middleware = getBundleRequestObserverMiddleware( + createProjectRoot(), + createHarnessConfig(), + reporter + ); + + middleware( + { + headers: { + [HARNESS_REQUEST_KIND_HEADER]: 'prewarm', + }, + url: '/index.bundle?platform=android', + } as never, + {} as never, + vi.fn() + ); + + expect(events).toEqual([ + expect.objectContaining({ + platform: 'android', + requestKind: 'prewarm', + }), + ]); + }); + + it('ignores non-entry bundle requests', () => { + const { events, reporter } = createReporter(); + const middleware = getBundleRequestObserverMiddleware( + createProjectRoot(), + createHarnessConfig(), + reporter + ); + + middleware( + { + headers: {}, + url: '/other.bundle?platform=ios', + } as never, + {} as never, + vi.fn() + ); + + expect(events).toEqual([]); + }); +}); diff --git a/packages/bundler-metro/src/__tests__/startup.test.ts b/packages/bundler-metro/src/__tests__/startup.test.ts new file mode 100644 index 00000000..97262216 --- /dev/null +++ b/packages/bundler-metro/src/__tests__/startup.test.ts @@ -0,0 +1,369 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getEmitter } from '@react-native-harness/tools'; +import { waitForMetroBackedAppReady } from '../startup.js'; +import type { ReportableEvent } from '../reporter.js'; +import type { MetroInstance } from '../types.js'; + +const createAbortError = () => + new DOMException('The operation was aborted', 'AbortError'); + +const waitForAbort = (signal: AbortSignal): Promise => { + if (signal.aborted) { + return Promise.reject(signal.reason ?? createAbortError()); + } + + return new Promise((_, reject) => { + signal.addEventListener( + 'abort', + () => { + reject(signal.reason ?? createAbortError()); + }, + { once: true } + ); + }); +}; + +const createMetroInstance = ( + overrides: Partial = {} +): MetroInstance => ({ + events: getEmitter(), + waitUntilHealthy: vi.fn(async () => 'HTTP 200: packager-status:running'), + prewarm: vi.fn(async () => false), + dispose: vi.fn(async () => undefined), + ...overrides, +}); + +const emitBundleRequestObserved = ( + metroInstance: MetroInstance, + requestKind: 'app' | 'prewarm', + platform = 'ios' +) => { + metroInstance.events.emit({ + type: 'bundle_request_observed', + platform, + requestKind, + timestamp: new Date().toISOString(), + url: `/index.bundle?platform=${platform}`, + }); +}; + +const emitMetroEvent = ( + metroInstance: MetroInstance, + event: ReportableEvent +) => { + metroInstance.events.emit(event); +}; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('waitForMetroBackedAppReady', () => { + it('fails when Metro never becomes healthy', async () => { + const metroInstance = createMetroInstance({ + waitUntilHealthy: vi.fn( + async () => 'HTTP 503: packager-status:starting' + ), + }); + const startAttempt = vi.fn(async () => undefined); + + await expect( + waitForMetroBackedAppReady({ + metro: metroInstance, + platformId: 'ios', + bundleStartTimeout: 1_000, + readyTimeout: 2_000, + maxAppRestarts: 2, + signal: new AbortController().signal, + startAttempt, + waitForReady: async () => undefined, + waitForCrash: async (signal) => await waitForAbort(signal), + }) + ).rejects.toMatchObject({ + name: 'StartupStallError', + code: 'metro_not_ready', + }); + + expect(metroInstance.prewarm).not.toHaveBeenCalled(); + expect(startAttempt).not.toHaveBeenCalled(); + }); + + it('keeps prewarm as warm-up only and still retries until an app request appears', async () => { + vi.useFakeTimers(); + + const metroInstance = createMetroInstance({ + prewarm: vi.fn(async () => true), + }); + const startAttempt = vi.fn(async () => undefined); + + const promise = waitForMetroBackedAppReady({ + metro: metroInstance, + platformId: 'ios', + bundleStartTimeout: 1_000, + readyTimeout: 2_000, + maxAppRestarts: 1, + signal: new AbortController().signal, + startAttempt, + waitForReady: async (signal) => await waitForAbort(signal), + waitForCrash: async (signal) => await waitForAbort(signal), + }); + + await vi.advanceTimersByTimeAsync(2_000); + + await expect(promise).rejects.toMatchObject({ + name: 'StartupStallError', + code: 'bundle_request_not_observed', + attempts: 2, + sawPrewarmRequest: true, + }); + expect(startAttempt).toHaveBeenCalledTimes(2); + }); + + it('completes once the app requests its bundle and reports ready', async () => { + const metroInstance = createMetroInstance(); + const startAttempt = vi.fn(async () => { + emitBundleRequestObserved(metroInstance, 'app'); + }); + const waitForReady = vi.fn(async () => undefined); + + await waitForMetroBackedAppReady({ + metro: metroInstance, + platformId: 'ios', + bundleStartTimeout: 1_000, + readyTimeout: 2_000, + maxAppRestarts: 2, + signal: new AbortController().signal, + startAttempt, + waitForReady, + waitForCrash: async (signal) => await waitForAbort(signal), + }); + + expect(startAttempt).toHaveBeenCalledTimes(1); + expect(waitForReady).toHaveBeenCalledTimes(1); + }); + + it('does not miss ready events emitted before bundle-request handling moves to the ready phase', async () => { + const metroInstance = createMetroInstance(); + const readyListeners = new Set<() => void>(); + let readyAlreadyReported = false; + + const emitReady = () => { + readyAlreadyReported = true; + for (const listener of readyListeners) { + listener(); + } + readyListeners.clear(); + }; + + const waitForReady = vi.fn(async (signal: AbortSignal) => { + if (readyAlreadyReported) { + return await waitForAbort(signal); + } + + return await new Promise((resolve, reject) => { + const onReady = () => { + cleanup(); + resolve(); + }; + const onAbort = () => { + cleanup(); + reject(signal.reason ?? createAbortError()); + }; + const cleanup = () => { + readyListeners.delete(onReady); + signal.removeEventListener('abort', onAbort); + }; + + readyListeners.add(onReady); + signal.addEventListener('abort', onAbort, { once: true }); + }); + }); + + const startAttempt = vi.fn(async () => { + emitReady(); + emitBundleRequestObserved(metroInstance, 'app'); + }); + + await waitForMetroBackedAppReady({ + metro: metroInstance, + platformId: 'web', + bundleStartTimeout: 1_000, + readyTimeout: 2_000, + maxAppRestarts: 2, + signal: new AbortController().signal, + startAttempt, + waitForReady, + waitForCrash: async (signal) => await waitForAbort(signal), + }); + + expect(startAttempt).toHaveBeenCalledTimes(1); + expect(waitForReady).toHaveBeenCalledTimes(1); + }); + + it('does not count Metro bundle build time against readyTimeout', async () => { + vi.useFakeTimers(); + + const metroInstance = createMetroInstance(); + let resolveReady!: () => void; + const startAttempt = vi.fn(async () => { + emitBundleRequestObserved(metroInstance, 'app'); + setTimeout(() => { + emitMetroEvent(metroInstance, { type: 'bundle_build_started' } as never); + }, 0); + }); + const waitForReady = vi.fn( + async () => + await new Promise((resolve) => { + resolveReady = resolve; + }) + ); + + let settled = false; + const promise = waitForMetroBackedAppReady({ + metro: metroInstance, + platformId: 'ios', + bundleStartTimeout: 1_000, + readyTimeout: 2_000, + maxAppRestarts: 2, + signal: new AbortController().signal, + startAttempt, + waitForReady, + waitForCrash: async (signal) => await waitForAbort(signal), + }).finally(() => { + settled = true; + }); + + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(5_000); + + expect(settled).toBe(false); + + emitMetroEvent(metroInstance, { type: 'bundle_build_done' } as never); + await vi.advanceTimersByTimeAsync(1_500); + + expect(settled).toBe(false); + + resolveReady(); + await promise; + + expect(waitForReady).toHaveBeenCalledTimes(1); + }); + + it('fails when the app requests its bundle but never reports ready', async () => { + vi.useFakeTimers(); + + const metroInstance = createMetroInstance(); + const startAttempt = vi.fn(async () => { + emitBundleRequestObserved(metroInstance, 'app'); + setTimeout(() => { + emitMetroEvent(metroInstance, { type: 'bundle_build_started' } as never); + emitMetroEvent(metroInstance, { type: 'bundle_build_done' } as never); + }, 0); + }); + + const promise = waitForMetroBackedAppReady({ + metro: metroInstance, + platformId: 'ios', + bundleStartTimeout: 1_000, + readyTimeout: 2_000, + maxAppRestarts: 2, + signal: new AbortController().signal, + startAttempt, + waitForReady: async (signal) => await waitForAbort(signal), + waitForCrash: async (signal) => await waitForAbort(signal), + }); + + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(2_000); + + await expect(promise).rejects.toMatchObject({ + name: 'StartupStallError', + code: 'ready_not_reported', + attempts: 1, + }); + expect(startAttempt).toHaveBeenCalledTimes(1); + }); + + it('starts readyTimeout immediately when Metro does not emit bundle build events', async () => { + vi.useFakeTimers(); + + const metroInstance = createMetroInstance(); + const startAttempt = vi.fn(async () => { + emitBundleRequestObserved(metroInstance, 'app'); + }); + + const promise = waitForMetroBackedAppReady({ + metro: metroInstance, + platformId: 'ios', + bundleStartTimeout: 1_000, + readyTimeout: 2_000, + maxAppRestarts: 2, + signal: new AbortController().signal, + startAttempt, + waitForReady: async (signal) => await waitForAbort(signal), + waitForCrash: async (signal) => await waitForAbort(signal), + }); + + await vi.advanceTimersByTimeAsync(2_000); + + await expect(promise).rejects.toMatchObject({ + name: 'StartupStallError', + code: 'ready_not_reported', + attempts: 1, + }); + expect(startAttempt).toHaveBeenCalledTimes(1); + }); + + it('surfaces crash failures immediately instead of retrying', async () => { + const metroInstance = createMetroInstance(); + const crashError = new Error('native crash'); + const startAttempt = vi.fn(async () => undefined); + + await expect( + waitForMetroBackedAppReady({ + metro: metroInstance, + platformId: 'ios', + bundleStartTimeout: 1_000, + readyTimeout: 2_000, + maxAppRestarts: 2, + signal: new AbortController().signal, + startAttempt, + waitForReady: async (signal) => await waitForAbort(signal), + waitForCrash: async () => { + throw crashError; + }, + }) + ).rejects.toBe(crashError); + + expect(startAttempt).toHaveBeenCalledTimes(1); + }); + + it('stops after maxAppRestarts when no app request is ever observed', async () => { + vi.useFakeTimers(); + + const metroInstance = createMetroInstance(); + const startAttempt = vi.fn(async () => undefined); + + const promise = waitForMetroBackedAppReady({ + metro: metroInstance, + platformId: 'ios', + bundleStartTimeout: 1_000, + readyTimeout: 2_000, + maxAppRestarts: 2, + signal: new AbortController().signal, + startAttempt, + waitForReady: async (signal) => await waitForAbort(signal), + waitForCrash: async (signal) => await waitForAbort(signal), + }); + + await vi.advanceTimersByTimeAsync(3_000); + + await expect(promise).rejects.toMatchObject({ + name: 'StartupStallError', + code: 'bundle_request_not_observed', + attempts: 3, + sawPrewarmRequest: false, + }); + expect(startAttempt).toHaveBeenCalledTimes(3); + }); +}); diff --git a/packages/bundler-metro/src/errors.ts b/packages/bundler-metro/src/errors.ts index 8d0c89ba..f69d3e13 100644 --- a/packages/bundler-metro/src/errors.ts +++ b/packages/bundler-metro/src/errors.ts @@ -15,3 +15,73 @@ export class MetroNotInstalledError extends HarnessError { this.name = 'MetroNotInstalledError'; } } + +export type StartupStallCode = + | 'metro_not_ready' + | 'bundle_request_not_observed' + | 'ready_not_reported'; + +export type StartupStallDetails = { + code?: StartupStallCode; + lastMetroStatus?: string; + sawPrewarmRequest?: boolean; +}; + +const getStartupStallMessage = ( + timeoutMs: number, + attempts: number, + details: Required> & StartupStallDetails +) => { + const lastMetroStatus = details.lastMetroStatus ?? 'unknown'; + + switch (details.code) { + case 'metro_not_ready': + return ( + `Metro did not report a healthy /status response within ${timeoutMs}ms. ` + + `Last status: ${lastMetroStatus}.` + ); + case 'ready_not_reported': + return ( + `The app requested its Metro bundle but Harness did not become ready within ${timeoutMs}ms ` + + `after ${attempts} launch attempt${attempts === 1 ? '' : 's'}. ` + + `Last Metro status: ${lastMetroStatus}.` + ); + case 'bundle_request_not_observed': + default: { + const prewarmSuffix = details.sawPrewarmRequest + ? ' Only prewarm traffic was observed.' + : ''; + + return ( + `The app did not request its Metro bundle after ${attempts} launch attempt${ + attempts === 1 ? '' : 's' + } within ${timeoutMs}ms. ` + + `Last Metro status: ${lastMetroStatus}.${prewarmSuffix}` + ); + } + } +}; + +export class StartupStallError extends HarnessError { + public readonly code: StartupStallCode; + public readonly lastMetroStatus?: string; + public readonly sawPrewarmRequest: boolean; + + constructor( + public readonly timeoutMs: number, + public readonly attempts: number, + details: StartupStallDetails = {} + ) { + const normalizedDetails = { + code: details.code ?? 'bundle_request_not_observed', + lastMetroStatus: details.lastMetroStatus, + sawPrewarmRequest: details.sawPrewarmRequest ?? false, + }; + + super(getStartupStallMessage(timeoutMs, attempts, normalizedDetails)); + this.name = 'StartupStallError'; + this.code = normalizedDetails.code; + this.lastMetroStatus = normalizedDetails.lastMetroStatus; + this.sawPrewarmRequest = normalizedDetails.sawPrewarmRequest; + } +} diff --git a/packages/bundler-metro/src/factory.ts b/packages/bundler-metro/src/factory.ts index 591d8e5a..6d8999aa 100644 --- a/packages/bundler-metro/src/factory.ts +++ b/packages/bundler-metro/src/factory.ts @@ -1,4 +1,4 @@ -import { logger } from '@react-native-harness/tools'; +import { logger, withAbortTimeout } from '@react-native-harness/tools'; import type { Server as HttpServer } from 'node:http'; import type { Server as HttpsServer } from 'node:https'; import connect from 'connect'; @@ -12,9 +12,59 @@ import { type ReportableEvent, } from './reporter.js'; import { getExpoMiddleware } from './middlewares/expo-middleware.js'; +import { getBundleRequestObserverMiddleware } from './middlewares/bundle-request-middleware.js'; import { getStatusMiddleware } from './middlewares/status-middleware.js'; +import { prewarmMetroBundle } from './prewarm.js'; import { withRnHarness } from './withRnHarness.js'; +const METRO_STATUS_POLL_INTERVAL_MS = 500; +const METRO_STATUS_REQUEST_TIMEOUT_MS = 1000; + +const getMetroStatusUrl = (port: number) => `http://localhost:${port}/status`; + +const waitForMetroStatus = async (options: { + port: number; + timeoutMs: number; + signal: AbortSignal; +}): Promise => { + const { port, timeoutMs, signal } = options; + const deadline = Date.now() + timeoutMs; + let lastStatus = 'waiting for first /status response'; + + while (Date.now() < deadline) { + signal.throwIfAborted(); + + try { + const response = await fetch(getMetroStatusUrl(port), { + signal: withAbortTimeout(signal, METRO_STATUS_REQUEST_TIMEOUT_MS), + }); + const body = await response.text(); + + lastStatus = `HTTP ${response.status}: ${body.trim()}`; + + if (response.ok && body.includes('packager-status:running')) { + return lastStatus; + } + } catch (error) { + if ( + error instanceof DOMException && + error.name === 'AbortError' && + signal.aborted + ) { + throw error; + } + + lastStatus = error instanceof Error ? error.message : String(error); + } + + await new Promise((resolve) => + setTimeout(resolve, METRO_STATUS_POLL_INTERVAL_MS) + ); + } + + return lastStatus; +}; + const waitForBundler = async ( reporter: Reporter, abortSignal: AbortSignal @@ -62,6 +112,7 @@ export const getMetroInstance = async ( const middleware = connect() .use(nocache()) + .use('/', getBundleRequestObserverMiddleware(projectRoot, harnessConfig, reporter)) .use('/', getExpoMiddleware(projectRoot, harnessConfig)) .use('/status', getStatusMiddleware(projectRoot)); @@ -89,8 +140,46 @@ export const getMetroInstance = async ( logger.debug('Metro server is running'); + let prewarmResult: Promise | null = null; + return { events: reporter, + waitUntilHealthy: async ({ timeoutMs, signal }) => + waitForMetroStatus({ port: metroPort, timeoutMs, signal }), + prewarm: ({ platform, signal }) => { + if (!prewarmResult) { + prewarmResult = (async () => { + try { + await prewarmMetroBundle({ + projectRoot, + entryPoint: harnessConfig.entryPoint, + port: metroPort, + platform, + dev: true, + minify: false, + signal, + }); + return true; + } catch (error) { + if ( + error instanceof DOMException && + error.name === 'AbortError' && + signal.aborted + ) { + throw error; + } + + logger.warn( + `Metro pre-warm for ${platform} failed; continuing without pre-warm.`, + error + ); + return false; + } + })(); + } + + return prewarmResult; + }, dispose: () => new Promise((resolve) => { server.close(() => resolve()); diff --git a/packages/bundler-metro/src/index.ts b/packages/bundler-metro/src/index.ts index a5da200d..c237d1ea 100644 --- a/packages/bundler-metro/src/index.ts +++ b/packages/bundler-metro/src/index.ts @@ -1,5 +1,13 @@ export { getMetroInstance } from './factory.js'; export type { MetroInstance, MetroFactory, MetroOptions } from './types.js'; -export { prewarmMetroBundle } from './prewarm.js'; export type { Reporter, ReportableEvent } from './reporter.js'; export { isMetroCacheReusable } from './paths.js'; +export { + StartupStallError, + type StartupStallCode, + type StartupStallDetails, +} from './errors.js'; +export { + waitForMetroBackedAppReady, + type WaitForMetroBackedAppReadyOptions, +} from './startup.js'; diff --git a/packages/bundler-metro/src/middlewares/bundle-request-middleware.ts b/packages/bundler-metro/src/middlewares/bundle-request-middleware.ts new file mode 100644 index 00000000..ff62ac31 --- /dev/null +++ b/packages/bundler-metro/src/middlewares/bundle-request-middleware.ts @@ -0,0 +1,59 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { NextFunction } from 'connect'; +import type { Config as HarnessConfig } from '@react-native-harness/config'; +import type { Reporter } from '../reporter.js'; +import { getResolvedEntryPointWithoutExtension } from '../entry-point-utils.js'; +import { + HARNESS_REQUEST_KIND_HEADER, + type HarnessBundleRequestKind, +} from '../request-kind.js'; + +const getRequestKind = ( + req: IncomingMessage +): HarnessBundleRequestKind => { + const header = req.headers[HARNESS_REQUEST_KIND_HEADER]; + const value = Array.isArray(header) ? header[0] : header; + + return value === 'prewarm' ? 'prewarm' : 'app'; +}; + +export const getBundleRequestObserverMiddleware = ( + projectRoot: string, + harnessConfig: HarnessConfig, + reporter: Reporter +) => { + const resolvedEntryPoint = getResolvedEntryPointWithoutExtension( + projectRoot, + harnessConfig.entryPoint + ); + const expectedPathname = `/${resolvedEntryPoint}.bundle`; + + return ( + req: IncomingMessage, + _res: ServerResponse, + next: NextFunction + ) => { + if (!req.url) { + next(); + return; + } + + const url = new URL(req.url, 'http://localhost'); + + if (decodeURIComponent(url.pathname) === expectedPathname) { + const platform = url.searchParams.get('platform'); + + if (platform) { + reporter.emit({ + type: 'bundle_request_observed', + platform, + requestKind: getRequestKind(req), + timestamp: new Date().toISOString(), + url: req.url, + }); + } + } + + next(); + }; +}; diff --git a/packages/bundler-metro/src/prewarm.ts b/packages/bundler-metro/src/prewarm.ts index fe7e5492..3fbd606c 100644 --- a/packages/bundler-metro/src/prewarm.ts +++ b/packages/bundler-metro/src/prewarm.ts @@ -1,4 +1,5 @@ import { getResolvedEntryPointWithoutExtension } from './entry-point-utils.js'; +import { HARNESS_REQUEST_KIND_HEADER } from './request-kind.js'; type PrewarmOptions = { projectRoot: string; @@ -26,7 +27,12 @@ export const prewarmMetroBundle = async ( }); const url = `http://localhost:${port}/${resolvedEntryPoint}.bundle?${searchParams.toString()}`; - const response = await fetch(url, { signal }); + const response = await fetch(url, { + signal, + headers: { + [HARNESS_REQUEST_KIND_HEADER]: 'prewarm', + }, + }); if (!response.ok) { const snippet = (await response.text()).trim(); diff --git a/packages/bundler-metro/src/reporter.ts b/packages/bundler-metro/src/reporter.ts index ea72c580..f44209e3 100644 --- a/packages/bundler-metro/src/reporter.ts +++ b/packages/bundler-metro/src/reporter.ts @@ -2,6 +2,7 @@ import { getEmitter, type EventEmitter } from '@react-native-harness/tools'; import type { ReportableEvent as MetroReportableEvent } from 'metro'; import type { ConfigT as MetroConfig } from 'metro-config'; import { NotReadOnly } from './utils.js'; +import type { HarnessBundleRequestKind } from './request-kind.js'; export type ReportableEvent = | MetroReportableEvent @@ -12,6 +13,13 @@ export type ReportableEvent = type: 'client_log'; level: 'trace' | 'info' | 'warn' | 'log' | 'group' | 'groupCollapsed' | 'groupEnd' | 'debug' | 'error'; data: unknown[]; + } + | { + type: 'bundle_request_observed'; + platform: string; + requestKind: HarnessBundleRequestKind; + timestamp: string; + url: string; }; export type Reporter = EventEmitter; diff --git a/packages/bundler-metro/src/request-kind.ts b/packages/bundler-metro/src/request-kind.ts new file mode 100644 index 00000000..99472e6f --- /dev/null +++ b/packages/bundler-metro/src/request-kind.ts @@ -0,0 +1,4 @@ +export type HarnessBundleRequestKind = 'app' | 'prewarm'; + +export const HARNESS_REQUEST_KIND_HEADER = + 'x-react-native-harness-request-kind'; diff --git a/packages/bundler-metro/src/startup.ts b/packages/bundler-metro/src/startup.ts new file mode 100644 index 00000000..e84a2968 --- /dev/null +++ b/packages/bundler-metro/src/startup.ts @@ -0,0 +1,330 @@ +import { raceAbortSignals, withAbortTimeout } from '@react-native-harness/tools'; +import { StartupStallError } from './errors.js'; +import type { ReportableEvent } from './reporter.js'; +import type { MetroInstance } from './types.js'; + +type WaitForBundleRequestOptions = { + events: MetroInstance['events']; + platformId: string; + timeoutMs: number; + signal: AbortSignal; + initialPrewarmSeen?: boolean; +}; + +type BundleRequestObservation = { + sawPrewarmRequest: boolean; +}; + +class ReadyTimeoutError extends Error { + constructor() { + super('Timed out waiting for the app to become ready after Metro bundling.'); + this.name = 'ReadyTimeoutError'; + } +} + +export type WaitForMetroBackedAppReadyOptions = { + metro: MetroInstance; + platformId: string; + bundleStartTimeout: number; + readyTimeout: number; + maxAppRestarts: number; + signal: AbortSignal; + startAttempt: () => Promise; + waitForReady: (signal: AbortSignal) => Promise; + waitForCrash: (signal: AbortSignal) => Promise; + onAttemptStart?: () => void; + onAttemptReset?: () => void; +}; + +class BundleRequestTimeoutError extends Error { + constructor(public readonly sawPrewarmRequest: boolean) { + super('Timed out waiting for an app-originated Metro bundle request.'); + this.name = 'BundleRequestTimeoutError'; + } +} + +const isAbortError = (error: unknown): error is DOMException => { + return error instanceof DOMException && error.name === 'AbortError'; +}; + +const waitForBundleRequest = async ({ + events, + platformId, + timeoutMs, + signal, + initialPrewarmSeen = false, +}: WaitForBundleRequestOptions): Promise => { + let sawPrewarmRequest = initialPrewarmSeen; + + return await new Promise((resolve, reject) => { + const requestSignal = withAbortTimeout(signal, timeoutMs); + + const cleanup = () => { + events.removeListener(onMetroEvent); + requestSignal.removeEventListener('abort', onAbort); + }; + + const resolveOnce = () => { + cleanup(); + resolve({ + sawPrewarmRequest, + }); + }; + + const rejectOnce = (error: unknown) => { + cleanup(); + reject(error); + }; + + const onAbort = () => { + if (signal.aborted) { + rejectOnce(signal.reason ?? new DOMException('The operation was aborted', 'AbortError')); + return; + } + + rejectOnce(new BundleRequestTimeoutError(sawPrewarmRequest)); + }; + + const onMetroEvent = (event: ReportableEvent) => { + if (event.type !== 'bundle_request_observed') { + return; + } + + if (event.requestKind === 'prewarm') { + sawPrewarmRequest = true; + return; + } + + if (event.requestKind === 'app' && event.platform === platformId) { + resolveOnce(); + } + }; + + events.addListener(onMetroEvent); + requestSignal.addEventListener('abort', onAbort, { once: true }); + }); +}; + +const waitForReadyAfterBundleRequest = async (options: { + events: MetroInstance['events']; + readyTimeout: number; + signal: AbortSignal; + readyPromise: Promise; + cancelReadyWait: () => void; +}): Promise => { + const { events, readyTimeout, signal, readyPromise, cancelReadyWait } = options; + + return await new Promise((resolve, reject) => { + let bundlingInProgress = false; + let settled = false; + let timeoutId: ReturnType | null = null; + + const clearReadyTimer = () => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + }; + + const cleanup = () => { + clearReadyTimer(); + events.removeListener(onMetroEvent); + signal.removeEventListener('abort', onAbort); + }; + + const resolveOnce = () => { + if (settled) { + return; + } + + settled = true; + cleanup(); + resolve(); + }; + + const rejectOnce = (error: unknown) => { + if (settled) { + return; + } + + settled = true; + cleanup(); + reject(error); + }; + + const startReadyTimer = () => { + clearReadyTimer(); + timeoutId = setTimeout(() => { + cancelReadyWait(); + rejectOnce(new ReadyTimeoutError()); + }, readyTimeout); + }; + + const onAbort = () => { + rejectOnce(signal.reason ?? new DOMException('The operation was aborted', 'AbortError')); + }; + + const onMetroEvent = (event: ReportableEvent) => { + if (event.type === 'bundle_build_started') { + bundlingInProgress = true; + clearReadyTimer(); + return; + } + + if ( + bundlingInProgress && + (event.type === 'bundle_build_done' || + event.type === 'bundle_build_failed') + ) { + bundlingInProgress = false; + startReadyTimer(); + } + }; + + startReadyTimer(); + events.addListener(onMetroEvent); + signal.addEventListener('abort', onAbort, { once: true }); + + void readyPromise + .then(() => { + resolveOnce(); + }) + .catch((error) => { + if ( + error instanceof DOMException && + error.name === 'AbortError' + ) { + if (signal.aborted) { + rejectOnce( + signal.reason ?? new DOMException('The operation was aborted', 'AbortError') + ); + } + return; + } + + rejectOnce(error); + }); + }); +}; + +export const waitForMetroBackedAppReady = async ({ + metro, + platformId, + bundleStartTimeout, + readyTimeout, + maxAppRestarts, + signal, + startAttempt, + waitForReady, + waitForCrash, + onAttemptStart, + onAttemptReset, +}: WaitForMetroBackedAppReadyOptions): Promise => { + const lastMetroStatus = await metro.waitUntilHealthy({ + timeoutMs: bundleStartTimeout, + signal, + }); + + if (!lastMetroStatus.includes('packager-status:running')) { + throw new StartupStallError(bundleStartTimeout, 1, { + code: 'metro_not_ready', + lastMetroStatus, + }); + } + + const prewarmCompleted = await metro.prewarm({ + platform: platformId, + signal, + }); + + let sawPrewarmRequest = prewarmCompleted; + const totalAttempts = maxAppRestarts + 1; + + for (let attempt = 1; attempt <= totalAttempts; attempt += 1) { + signal.throwIfAborted(); + onAttemptReset?.(); + onAttemptStart?.(); + + const attemptController = new AbortController(); + const attemptSignal = raceAbortSignals([signal, attemptController.signal]); + const crashPromise = waitForCrash(attemptSignal); + const readyController = new AbortController(); + const readyPromise = waitForReady( + raceAbortSignals([attemptSignal, readyController.signal]) + ); + + try { + const bundleRequestPromise = waitForBundleRequest({ + events: metro.events, + platformId, + timeoutMs: bundleStartTimeout, + signal: attemptSignal, + initialPrewarmSeen: sawPrewarmRequest, + }); + + await startAttempt(); + + const bundleRequestResult = await Promise.race([ + bundleRequestPromise, + crashPromise, + ]); + sawPrewarmRequest = bundleRequestResult.sawPrewarmRequest; + + const readyAfterBundleRequestPromise = waitForReadyAfterBundleRequest({ + events: metro.events, + readyTimeout, + signal: attemptSignal, + readyPromise, + cancelReadyWait: () => { + readyController.abort( + new DOMException('The operation was aborted', 'AbortError') + ); + }, + }); + await Promise.race([readyAfterBundleRequestPromise, crashPromise]); + attemptController.abort(); + onAttemptReset?.(); + return; + } catch (error) { + readyController.abort( + new DOMException('The operation was aborted', 'AbortError') + ); + attemptController.abort(); + onAttemptReset?.(); + + if (isAbortError(error) && signal.aborted) { + throw error; + } + + if (error instanceof BundleRequestTimeoutError) { + sawPrewarmRequest = error.sawPrewarmRequest; + + if (attempt >= totalAttempts) { + throw new StartupStallError(bundleStartTimeout, totalAttempts, { + code: 'bundle_request_not_observed', + lastMetroStatus, + sawPrewarmRequest, + }); + } + + continue; + } + + if (error instanceof ReadyTimeoutError) { + throw new StartupStallError(readyTimeout, attempt, { + code: 'ready_not_reported', + lastMetroStatus, + sawPrewarmRequest, + }); + } + + if (isAbortError(error)) { + throw error; + } + + throw error; + } + } + + throw new Error('Metro-backed app startup exited unexpectedly.'); +}; diff --git a/packages/bundler-metro/src/types.ts b/packages/bundler-metro/src/types.ts index 3662b2bf..c4a3ffb0 100644 --- a/packages/bundler-metro/src/types.ts +++ b/packages/bundler-metro/src/types.ts @@ -6,8 +6,20 @@ export type MetroOptions = { harnessConfig: HarnessConfig; }; +export type WaitForMetroHealthOptions = { + timeoutMs: number; + signal: AbortSignal; +}; + +export type PrewarmMetroBundleOptions = { + platform: string; + signal: AbortSignal; +}; + export type MetroInstance = { events: Reporter; + waitUntilHealthy: (options: WaitForMetroHealthOptions) => Promise; + prewarm: (options: PrewarmMetroBundleOptions) => Promise; dispose: () => Promise; }; diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index df6ab817..21e9769e 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -50,7 +50,7 @@ export const ConfigSchema = z bundleStartTimeout: z .number() .min(1000, 'Bundle start timeout must be at least 1 second') - .default(15000), + .default(60000), maxAppRestarts: z .number() .min(0, 'Max app restarts must be at least 0') diff --git a/packages/jest/src/__tests__/harness.test.ts b/packages/jest/src/__tests__/harness.test.ts index 48c7ea99..43fd9235 100644 --- a/packages/jest/src/__tests__/harness.test.ts +++ b/packages/jest/src/__tests__/harness.test.ts @@ -10,8 +10,13 @@ import type { HarnessPlatformRunner, } from '@react-native-harness/platforms'; import type { BridgeServer } from '@react-native-harness/bridge/server'; +import type { + MetroInstance, + Reporter, + ReportableEvent, + WaitForMetroBackedAppReadyOptions, +} from '@react-native-harness/bundler-metro'; import { createCrashSupervisor } from '../crash-supervisor.js'; -import type { Reporter, ReportableEvent } from '@react-native-harness/bundler-metro'; const mocks = vi.hoisted(() => ({ createCrashArtifactWriter: vi.fn(() => ({})), @@ -19,26 +24,29 @@ const mocks = vi.hoisted(() => ({ getMetroInstance: vi.fn(), isMetroCacheReusable: vi.fn(() => false), logMetroCacheReused: vi.fn(), - logMetroPrewarmCompleted: vi.fn(), - prewarmMetroBundle: vi.fn(), + waitForMetroBackedAppReady: vi.fn(), })); -vi.mock('@react-native-harness/bundler-metro', () => ({ - getMetroInstance: mocks.getMetroInstance, - prewarmMetroBundle: mocks.prewarmMetroBundle, -})); +vi.mock('@react-native-harness/bundler-metro', async () => { + const actual = + await vi.importActual( + '@react-native-harness/bundler-metro' + ); + + return { + ...actual, + getMetroInstance: mocks.getMetroInstance, + isMetroCacheReusable: mocks.isMetroCacheReusable, + waitForMetroBackedAppReady: mocks.waitForMetroBackedAppReady, + }; +}); vi.mock('@react-native-harness/bridge/server', () => ({ getBridgeServer: mocks.getBridgeServer, })); -vi.mock('@react-native-harness/metro', () => ({ - isMetroCacheReusable: mocks.isMetroCacheReusable, -})); - vi.mock('../logs.js', () => ({ logMetroCacheReused: mocks.logMetroCacheReused, - logMetroPrewarmCompleted: mocks.logMetroPrewarmCompleted, })); vi.mock('@react-native-harness/tools', async () => { @@ -86,33 +94,35 @@ const createBridgeServer = () => { }; }; -const createMetroReporter = (): { - reporter: Reporter; - emit: (event: ReportableEvent) => void; -} => { +const createReporter = (): Reporter => { const listeners = new Set<(event: ReportableEvent) => void>(); return { - reporter: { - addListener: (listener) => { - listeners.add(listener); - }, - removeListener: (listener) => { - listeners.delete(listener); - }, - emit: (event) => { - listeners.forEach((listener) => listener(event)); - }, - clearAllListeners: () => { - listeners.clear(); - }, + addListener: (listener) => { + listeners.add(listener); + }, + removeListener: (listener) => { + listeners.delete(listener); }, emit: (event) => { listeners.forEach((listener) => listener(event)); }, + clearAllListeners: () => { + listeners.clear(); + }, }; }; +const createMetroInstance = ( + overrides: Partial = {} +): MetroInstance => ({ + events: createReporter(), + waitUntilHealthy: vi.fn(async () => 'HTTP 200: packager-status:running'), + prewarm: vi.fn(async () => false), + dispose: vi.fn(async () => undefined), + ...overrides, +}); + const createAppMonitor = (): { appMonitor: AppMonitor; emit: (event: AppMonitorEvent) => void; @@ -157,7 +167,7 @@ const createHarnessConfig = ( ({ appRegistryComponentName: 'App', bridgeTimeout: 60_000, - bundleStartTimeout: 1_000, + bundleStartTimeout: 60_000, crashDetectionInterval: 500, defaultRunner: 'ios', detectNativeCrashes: true, @@ -165,25 +175,20 @@ const createHarnessConfig = ( entryPoint: 'index.js', forwardClientLogs: false, maxAppRestarts: 2, + metroPort: 8081, resetEnvironmentBetweenTestFiles: true, runners: [], unstable__enableMetroCache: false, unstable__skipAlreadyIncludedModules: false, - webSocketPort: 8081, + webSocketPort: 3001, ...overrides, }) as HarnessConfig; -const flush = async () => { - await Promise.resolve(); - await Promise.resolve(); -}; - beforeEach(() => { vi.clearAllMocks(); }); afterEach(() => { - vi.useRealTimers(); delete ( globalThis as typeof globalThis & { __HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise; @@ -192,154 +197,59 @@ afterEach(() => { }); describe('waitForAppReady', () => { - it('retries startup when Metro is idle and passes launch options on every attempt', async () => { - vi.useFakeTimers(); - - const { serverBridge, emitReady } = createBridgeServer(); - const { reporter } = createMetroReporter(); - const restartApp = vi.fn().mockResolvedValue(undefined); - const platformInstance = createPlatformRunner({ restartApp }); - const { appMonitor } = createAppMonitor(); - const crashSupervisor = createCrashSupervisor({ - appMonitor, - platformRunner: platformInstance, - }); - - const promise = waitForAppReady({ - metroEvents: reporter, - serverBridge, - platformInstance, - bundleStartTimeout: 1_000, - maxAppRestarts: 2, - testFilePath: '/tmp/test.harness.ts', - crashSupervisor, - appLaunchOptions: { - extras: { - mode: 'startup', - }, - }, - }); - - await flush(); - expect(restartApp).toHaveBeenCalledTimes(1); - expect(restartApp).toHaveBeenNthCalledWith(1, { - extras: { - mode: 'startup', - }, - }); - - await vi.advanceTimersByTimeAsync(1_000); - await flush(); - - expect(restartApp).toHaveBeenCalledTimes(2); - expect(restartApp).toHaveBeenNthCalledWith(2, { - extras: { - mode: 'startup', - }, - }); - - emitReady(); - await promise; - await crashSupervisor.dispose(); - }); - - it('does not retry while Metro is still bundling', async () => { - vi.useFakeTimers(); - + it('delegates startup orchestration to bundler-metro and resolves readiness from the bridge', async () => { const { serverBridge, emitReady } = createBridgeServer(); - const { reporter, emit } = createMetroReporter(); - const restartApp = vi.fn().mockResolvedValue(undefined); - const platformInstance = createPlatformRunner({ restartApp }); + const metroInstance = createMetroInstance(); + const platformInstance = createPlatformRunner(); const { appMonitor } = createAppMonitor(); const crashSupervisor = createCrashSupervisor({ appMonitor, platformRunner: platformInstance, }); - const promise = waitForAppReady({ - metroEvents: reporter, - serverBridge, - platformInstance, - bundleStartTimeout: 1_000, - maxAppRestarts: 2, - testFilePath: '/tmp/test.harness.ts', - crashSupervisor, - }); - - emit({ - type: 'bundle_build_started', - buildID: 'startup', - bundleDetails: { entryFile: 'index.js', platform: 'ios', dev: true, minify: false, bundleType: 'bundle' }, - } as ReportableEvent); - - await flush(); - await vi.advanceTimersByTimeAsync(1_000); - await flush(); - - expect(restartApp).toHaveBeenCalledTimes(1); - - emitReady(); - await promise; - await crashSupervisor.dispose(); - }); - - it('resumes retries once bundling finishes', async () => { - vi.useFakeTimers(); - - const { serverBridge, emitReady } = createBridgeServer(); - const { reporter, emit } = createMetroReporter(); - const restartApp = vi.fn().mockResolvedValue(undefined); - const platformInstance = createPlatformRunner({ restartApp }); - const { appMonitor } = createAppMonitor(); - const crashSupervisor = createCrashSupervisor({ - appMonitor, - platformRunner: platformInstance, - }); + mocks.waitForMetroBackedAppReady.mockImplementationOnce( + async (options: WaitForMetroBackedAppReadyOptions) => { + options.onAttemptStart?.(); + const readyPromise = options.waitForReady(new AbortController().signal); + emitReady(); + await readyPromise; + options.onAttemptReset?.(); + } + ); - const promise = waitForAppReady({ - metroEvents: reporter, + await waitForAppReady({ + metroInstance, serverBridge, platformInstance, - bundleStartTimeout: 1_000, - maxAppRestarts: 2, + platformId: 'ios', + bundleStartTimeout: 1_500, + readyTimeout: 2_500, + maxAppRestarts: 3, testFilePath: '/tmp/test.harness.ts', crashSupervisor, }); - emit({ - type: 'bundle_build_started', - buildID: 'startup', - bundleDetails: { entryFile: 'index.js', platform: 'ios', dev: true, minify: false, bundleType: 'bundle' }, - } as ReportableEvent); - - await flush(); - await vi.advanceTimersByTimeAsync(1_000); - await flush(); - - expect(restartApp).toHaveBeenCalledTimes(1); - - emit({ - type: 'bundle_build_done', - buildID: 'startup', - } as ReportableEvent); - - await flush(); - await vi.advanceTimersByTimeAsync(1_000); - await flush(); - - expect(restartApp).toHaveBeenCalledTimes(2); + expect(mocks.waitForMetroBackedAppReady).toHaveBeenCalledWith( + expect.objectContaining({ + metro: metroInstance, + platformId: 'ios', + bundleStartTimeout: 1_500, + readyTimeout: 2_500, + maxAppRestarts: 3, + startAttempt: expect.any(Function), + waitForReady: expect.any(Function), + waitForCrash: expect.any(Function), + }) + ); + expect(crashSupervisor.isReady()).toBe(true); - emitReady(); - await promise; await crashSupervisor.dispose(); }); - it('throws a startup stall error when all launch attempts are exhausted', async () => { - vi.useFakeTimers(); - + it('passes launch options through the shared Metro startup helper', async () => { const { serverBridge } = createBridgeServer(); - const { reporter } = createMetroReporter(); - const restartApp = vi.fn().mockResolvedValue(undefined); + const metroInstance = createMetroInstance(); + const restartApp = vi.fn(async () => undefined); const platformInstance = createPlatformRunner({ restartApp }); const { appMonitor } = createAppMonitor(); const crashSupervisor = createCrashSupervisor({ @@ -347,151 +257,127 @@ describe('waitForAppReady', () => { platformRunner: platformInstance, }); - const promise = waitForAppReady({ - metroEvents: reporter, - serverBridge, - platformInstance, - bundleStartTimeout: 1_000, - maxAppRestarts: 2, - testFilePath: '/tmp/test.harness.ts', - crashSupervisor, - }); - const expectation = expect(promise).rejects.toEqual( - expect.objectContaining({ - name: 'StartupStallError', - message: - 'The app never became ready after 3 launch attempts with a startup stall timeout of 1000ms and no native crash signal.', - }) + mocks.waitForMetroBackedAppReady.mockImplementationOnce( + async (options: WaitForMetroBackedAppReadyOptions) => { + await options.startAttempt(); + } ); - await flush(); - await vi.advanceTimersByTimeAsync(3_000); - - await expectation; - expect(restartApp).toHaveBeenCalledTimes(3); - - await crashSupervisor.dispose(); - }); - - it('fails immediately on a confirmed startup crash', async () => { - const { serverBridge } = createBridgeServer(); - const { reporter } = createMetroReporter(); - const restartApp = vi.fn().mockResolvedValue(undefined); - const platformInstance = createPlatformRunner({ restartApp }); - const { appMonitor, emit } = createAppMonitor(); - const crashSupervisor = createCrashSupervisor({ - appMonitor, - platformRunner: { - ...platformInstance, - isAppRunning: vi.fn(async () => false), - }, - }); - - const promise = waitForAppReady({ - metroEvents: reporter, + await waitForAppReady({ + metroInstance, serverBridge, platformInstance, - bundleStartTimeout: 1_000, - maxAppRestarts: 2, + platformId: 'ios', + bundleStartTimeout: 1_500, + readyTimeout: 2_500, + maxAppRestarts: 3, testFilePath: '/tmp/test.harness.ts', crashSupervisor, + appLaunchOptions: { + extras: { + mode: 'startup', + }, + }, }); - await flush(); - - emit({ - type: 'app_exited', - source: 'polling', - isConfirmed: true, - pid: 123, - crashDetails: { - summary: 'fatal startup crash', + expect(restartApp).toHaveBeenCalledWith({ + extras: { + mode: 'startup', }, - } as AppMonitorEvent); - - await expect(promise).rejects.toMatchObject({ - name: 'NativeCrashError', - phase: 'startup', }); - expect(restartApp).toHaveBeenCalledTimes(1); await crashSupervisor.dispose(); }); +}); - it('stops retrying once a crash is reported after an earlier stall', async () => { - vi.useFakeTimers(); - - const { serverBridge } = createBridgeServer(); - const { reporter } = createMetroReporter(); - const restartApp = vi.fn().mockResolvedValue(undefined); - const platformInstance = createPlatformRunner({ restartApp }); - const { appMonitor, emit } = createAppMonitor(); - const crashSupervisor = createCrashSupervisor({ - appMonitor, - platformRunner: { - ...platformInstance, - isAppRunning: vi.fn(async () => false), - }, - }); - - const promise = waitForAppReady({ - metroEvents: reporter, - serverBridge, - platformInstance, - bundleStartTimeout: 1_000, - maxAppRestarts: 2, - testFilePath: '/tmp/test.harness.ts', - crashSupervisor, +describe('getHarness', () => { + it('routes ensureAppReady through the shared Metro startup helper', async () => { + const { serverBridge, emitReady } = createBridgeServer(); + const appMonitor = createAppMonitor(); + const restartApp = vi.fn(async () => undefined); + const platformInstance = createPlatformRunner({ + restartApp, + createAppMonitor: () => appMonitor.appMonitor, }); + const metroInstance = createMetroInstance(); - await flush(); - await vi.advanceTimersByTimeAsync(1_000); - await flush(); + mocks.getBridgeServer.mockResolvedValue(serverBridge); + mocks.getMetroInstance.mockResolvedValue(metroInstance); + mocks.waitForMetroBackedAppReady.mockImplementationOnce( + async (options: WaitForMetroBackedAppReadyOptions) => { + await options.startAttempt(); + const readyPromise = options.waitForReady( + new AbortController().signal + ); + emitReady(); + await readyPromise; + } + ); - expect(restartApp).toHaveBeenCalledTimes(2); + ( + globalThis as typeof globalThis & { + __HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise; + } + ).__HARNESS_PLATFORM_RUNNER__ = vi.fn(async () => platformInstance); - emit({ - type: 'possible_crash', - source: 'polling', - isConfirmed: true, - pid: 456, - crashDetails: { - summary: 'crashed on retry', + const platform: HarnessPlatform = { + config: { + appLaunchOptions: { + extras: { + source: 'ensure-ready', + }, + }, }, - } as AppMonitorEvent); + name: 'ios', + platformId: 'ios', + runner: `data:text/javascript,${encodeURIComponent( + 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);' + )}`, + }; + + const harness = await getHarness( + createHarnessConfig(), + platform, + '/tmp/project' + ); + + await harness.ensureAppReady('/tmp/example.harness.ts'); - await expect(promise).rejects.toMatchObject({ - name: 'NativeCrashError', - phase: 'startup', + expect(mocks.waitForMetroBackedAppReady).toHaveBeenCalledTimes(1); + expect(restartApp).toHaveBeenCalledWith({ + extras: { + source: 'ensure-ready', + }, }); - expect(restartApp).toHaveBeenCalledTimes(2); - await crashSupervisor.dispose(); + await harness.dispose(); }); -}); - -describe('restart(testFilePath)', () => { - it('stops the app and relaunches through the shared startup recovery helper', async () => { - vi.useFakeTimers(); + it('routes restart(testFilePath) through the shared Metro startup helper', async () => { const { serverBridge, emitReady } = createBridgeServer(); const appMonitor = createAppMonitor(); - const restartApp = vi.fn().mockResolvedValue(undefined); - const stopApp = vi.fn().mockResolvedValue(undefined); + const restartApp = vi.fn(async () => undefined); + const stopApp = vi.fn(async () => undefined); const platformInstance = createPlatformRunner({ restartApp, stopApp, isAppRunning: vi.fn(async () => false), createAppMonitor: () => appMonitor.appMonitor, }); + const metroInstance = createMetroInstance(); mocks.getBridgeServer.mockResolvedValue(serverBridge); - const metroReporter = createMetroReporter(); - mocks.getMetroInstance.mockResolvedValue({ - events: metroReporter.reporter, - dispose: vi.fn(async () => undefined), - }); - mocks.prewarmMetroBundle.mockResolvedValue(undefined); + mocks.getMetroInstance.mockResolvedValue(metroInstance); + mocks.waitForMetroBackedAppReady.mockImplementationOnce( + async (options: WaitForMetroBackedAppReadyOptions) => { + await options.startAttempt(); + const readyPromise = options.waitForReady( + new AbortController().signal + ); + emitReady(); + await readyPromise; + } + ); ( globalThis as typeof globalThis & { @@ -515,39 +401,21 @@ describe('restart(testFilePath)', () => { }; const harness = await getHarness( - createHarnessConfig({ - bundleStartTimeout: 1_000, - maxAppRestarts: 2, - }), + createHarnessConfig(), platform, '/tmp/project' ); - const restartPromise = harness.restart('/tmp/restart.harness.ts'); - - await flush(); - await flush(); + await harness.restart('/tmp/restart.harness.ts'); expect(stopApp).toHaveBeenCalledTimes(1); - expect(restartApp).toHaveBeenCalledTimes(0); - - await vi.advanceTimersByTimeAsync(1_000); - await flush(); - - expect(restartApp).toHaveBeenCalledTimes(2); - expect(restartApp).toHaveBeenNthCalledWith(1, { - extras: { - source: 'restart', - }, - }); - expect(restartApp).toHaveBeenNthCalledWith(2, { + expect(mocks.waitForMetroBackedAppReady).toHaveBeenCalledTimes(1); + expect(restartApp).toHaveBeenCalledWith({ extras: { source: 'restart', }, }); - emitReady(); - await restartPromise; await harness.dispose(); }); }); @@ -563,12 +431,8 @@ describe('plugins', () => { const observedHooks: string[] = []; mocks.getBridgeServer.mockResolvedValue(serverBridge); - const metroReporter = createMetroReporter(); - mocks.getMetroInstance.mockResolvedValue({ - events: metroReporter.reporter, - dispose: vi.fn(async () => undefined), - }); - mocks.prewarmMetroBundle.mockResolvedValue(undefined); + const metroInstance = createMetroInstance(); + mocks.getMetroInstance.mockResolvedValue(metroInstance); ( globalThis as typeof globalThis & { @@ -677,7 +541,7 @@ describe('plugins', () => { describe('StartupStallError', () => { it('includes the configured timeout and attempt count', () => { expect(new StartupStallError(1_500, 4).message).toBe( - 'The app never became ready after 4 launch attempts with a startup stall timeout of 1500ms and no native crash signal.' + 'The app did not request its Metro bundle after 4 launch attempts within 1500ms. Last Metro status: unknown.' ); }); }); diff --git a/packages/jest/src/__tests__/index.test.ts b/packages/jest/src/__tests__/index.test.ts index e99b413b..d95e9d76 100644 --- a/packages/jest/src/__tests__/index.test.ts +++ b/packages/jest/src/__tests__/index.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it, vi } from 'vitest'; import type { Harness } from '../harness.js'; import type { Config as HarnessConfig } from '@react-native-harness/config'; -import type { Test } from 'jest-runner'; +import type { Config, Test, TestWatcher } from 'jest-runner'; import JestHarness from '../index.js'; import { StartupStallError } from '../errors.js'; describe('JestHarness', () => { it('reports StartupStallError without a stack trace', async () => { - const runner = new JestHarness({} as any); + const runner = new JestHarness({} as Config.GlobalConfig); const onFailure = vi.fn(); const harness = { @@ -36,7 +36,7 @@ describe('JestHarness', () => { ], { isInterrupted: () => false, - } as any, + } as TestWatcher, harness, { detectNativeCrashes: true, @@ -53,7 +53,7 @@ describe('JestHarness', () => { }), { message: - 'The app never became ready after 3 launch attempts with a startup stall timeout of 1500ms and no native crash signal.', + 'The app did not request its Metro bundle after 3 launch attempts within 1500ms. Last Metro status: unknown.', stack: '', } ); diff --git a/packages/jest/src/errors.ts b/packages/jest/src/errors.ts index f7ba11bd..0c90b2ca 100644 --- a/packages/jest/src/errors.ts +++ b/packages/jest/src/errors.ts @@ -1,5 +1,10 @@ import { HarnessError } from '@react-native-harness/tools'; import type { AppCrashDetails } from '@react-native-harness/platforms'; +export { + StartupStallError, + type StartupStallCode, + type StartupStallDetails, +} from '@react-native-harness/bundler-metro'; export class NoRunnerSpecifiedError extends HarnessError { constructor() { @@ -22,20 +27,6 @@ export class InitializationTimeoutError extends HarnessError { } } -export class StartupStallError extends HarnessError { - constructor( - public readonly timeoutMs: number, - public readonly attempts: number - ) { - super( - `The app never became ready after ${attempts} launch attempt${ - attempts === 1 ? '' : 's' - } with a startup stall timeout of ${timeoutMs}ms and no native crash signal.` - ); - this.name = 'StartupStallError'; - } -} - export type NativeCrashPhase = 'startup' | 'execution'; export type NativeCrashDetails = AppCrashDetails & { diff --git a/packages/jest/src/harness.ts b/packages/jest/src/harness.ts index 58d45fa3..b404f400 100644 --- a/packages/jest/src/harness.ts +++ b/packages/jest/src/harness.ts @@ -18,8 +18,8 @@ import { import { getMetroInstance, isMetroCacheReusable, - prewarmMetroBundle, - type Reporter, + waitForMetroBackedAppReady, + type MetroInstance, type ReportableEvent, } from '@react-native-harness/bundler-metro'; import { createCrashArtifactWriter } from '@react-native-harness/tools'; @@ -33,7 +33,6 @@ import { } from '@react-native-harness/plugins'; import { InitializationTimeoutError, - StartupStallError, } from './errors.js'; import { Config as HarnessConfig } from '@react-native-harness/config'; import { @@ -41,8 +40,8 @@ import { type CrashSupervisor, } from './crash-supervisor.js'; import { createClientLogListener } from './client-log-handler.js'; -import { logMetroCacheReused, logMetroPrewarmCompleted } from './logs.js'; import path from 'node:path'; +import { logMetroCacheReused } from './logs.js'; export type HarnessRunTestsOptions = Exclude; @@ -85,142 +84,102 @@ export const maybeLogMetroCacheReuse = ( } }; +const createAbortError = () => + new DOMException('The operation was aborted', 'AbortError'); + +const waitForAbort = (signal: AbortSignal): Promise => { + if (signal.aborted) { + return Promise.reject(signal.reason ?? createAbortError()); + } + + return new Promise((_, reject) => { + signal.addEventListener( + 'abort', + () => { + reject(signal.reason ?? createAbortError()); + }, + { once: true } + ); + }); +}; + export const waitForAppReady = async (options: { - metroEvents: Reporter; + metroInstance: MetroInstance; serverBridge: BridgeServer; platformInstance: HarnessPlatformRunner; + platformId: string; bundleStartTimeout: number; + readyTimeout: number; maxAppRestarts: number; testFilePath: string; crashSupervisor: CrashSupervisor; + signal?: AbortSignal; appLaunchOptions?: AppLaunchOptions; launchApp?: () => Promise; }): Promise => { const { - metroEvents, + metroInstance, serverBridge, platformInstance, + platformId, bundleStartTimeout, + readyTimeout, maxAppRestarts, testFilePath, crashSupervisor, appLaunchOptions, launchApp = () => platformInstance.restartApp(appLaunchOptions), } = options; + const signal = options.signal ?? new AbortController().signal; - const totalAttempts = maxAppRestarts + 1; - let restartCount = 0; - let isBundling = false; - let timeoutId: NodeJS.Timeout | null = null; - let settled = false; - - const clearStartupTimer = () => { - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; - } - }; - - return await new Promise((resolve, reject) => { - const cleanup = () => { - settled = true; - clearStartupTimer(); - metroEvents.removeListener(onMetroEvent); - serverBridge.off('ready', onReady); - crashSupervisor.cancelCrashWaiters(); - }; - - const rejectOnce = (error: unknown) => { - if (settled) { - return; - } - - cleanup(); - reject(error); - }; - - const resolveOnce = () => { - if (settled) { - return; - } - - cleanup(); - resolve(); - }; - - const startStartupTimer = () => { - clearStartupTimer(); - timeoutId = setTimeout(() => { - if (settled || isBundling) { - return; - } - - if (restartCount >= maxAppRestarts) { - rejectOnce( - new StartupStallError(bundleStartTimeout, totalAttempts) - ); - return; - } - - restartCount += 1; - void startAttempt(); - }, bundleStartTimeout); - }; - - const onReady = () => { - if (settled) { - return; - } - - crashSupervisor.markReady(); - resolveOnce(); - }; - - const onMetroEvent = (event: ReportableEvent) => { - if (event.type === 'bundle_build_started') { - isBundling = true; - clearStartupTimer(); - return; - } - - if ( - event.type === 'bundle_build_done' || - event.type === 'bundle_build_failed' - ) { - isBundling = false; - - if (!settled && !crashSupervisor.isReady()) { - // Keep the historical behavior: once bundling settles, give RN a fresh timeout window. - startStartupTimer(); - } - } - }; - - const startAttempt = async () => { - if (settled || crashSupervisor.isReady()) { - resolveOnce(); - return; - } - - crashSupervisor.cancelCrashWaiters(); - crashSupervisor.beginLaunch(testFilePath); - startStartupTimer(); - - void crashSupervisor.waitForCrash(testFilePath).catch((error) => { - rejectOnce(error); - }); - + return await waitForMetroBackedAppReady({ + metro: metroInstance, + platformId, + bundleStartTimeout, + readyTimeout, + maxAppRestarts, + signal, + startAttempt: async () => { + await launchApp(); + }, + waitForReady: async (signal) => { + return await Promise.race([ + new Promise((resolve) => { + const onReady = () => { + cleanup(); + crashSupervisor.markReady(); + resolve(); + }; + const onAbort = () => { + cleanup(); + }; + const cleanup = () => { + serverBridge.off('ready', onReady); + signal.removeEventListener('abort', onAbort); + }; + + serverBridge.on('ready', onReady); + signal.addEventListener('abort', onAbort, { once: true }); + }), + waitForAbort(signal), + ]); + }, + waitForCrash: async (signal) => { try { - await launchApp(); - } catch (error) { - rejectOnce(error); + return await Promise.race([ + crashSupervisor.waitForCrash(testFilePath), + waitForAbort(signal), + ]); + } finally { + crashSupervisor.cancelCrashWaiters(); } - }; - - metroEvents.addListener(onMetroEvent); - serverBridge.on('ready', onReady); - - void startAttempt(); + }, + onAttemptStart: () => { + crashSupervisor.beginLaunch(testFilePath); + }, + onAttemptReset: () => { + crashSupervisor.cancelCrashWaiters(); + }, }); }; @@ -572,16 +531,6 @@ const getHarnessInternal = async ( await pluginManager.callHook('harness:before-creation', { appLaunchOptions, }); - await prewarmMetroBundle({ - projectRoot, - entryPoint: config.entryPoint, - port: config.metroPort, - platform: platform.platformId, - dev: true, - minify: false, - signal, - }); - logMetroPrewarmCompleted(platform); await appMonitor.start(); } catch (error) { const runState = currentRun as HarnessRunState | null; @@ -605,10 +554,12 @@ const getHarnessInternal = async ( crashSupervisor.reset(); await waitForAppReady({ - metroEvents: metroInstance.events, + metroInstance, serverBridge, platformInstance: platformInstance as HarnessPlatformRunner, - bundleStartTimeout: config.bundleStartTimeout ?? 15000, + platformId: platform.platformId, + bundleStartTimeout: config.bundleStartTimeout ?? 60000, + readyTimeout: config.bridgeTimeout, maxAppRestarts: config.maxAppRestarts ?? 2, testFilePath, crashSupervisor, diff --git a/packages/jest/tsconfig.lib.json b/packages/jest/tsconfig.lib.json index 5f7f1ef8..6e003874 100644 --- a/packages/jest/tsconfig.lib.json +++ b/packages/jest/tsconfig.lib.json @@ -8,7 +8,7 @@ "emitDeclarationOnly": false, "forceConsistentCasingInFileNames": true, "types": ["node"], - "lib": ["DOM"] + "lib": ["DOM", "ES2022"] }, "include": ["src/**/*.ts"], "references": [ diff --git a/packages/tools/src/__tests__/abort.test.ts b/packages/tools/src/__tests__/abort.test.ts new file mode 100644 index 00000000..498bc1dd --- /dev/null +++ b/packages/tools/src/__tests__/abort.test.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getTimeoutSignal, raceAbortSignals, withAbortTimeout } from '../abort.js'; + +const createAbortError = () => + new DOMException('The operation was aborted', 'AbortError'); + +const waitForAbort = (signal: AbortSignal): Promise => { + if (signal.aborted) { + return Promise.resolve(signal.reason); + } + + return new Promise((resolve) => { + signal.addEventListener( + 'abort', + () => { + resolve(signal.reason); + }, + { once: true } + ); + }); +}; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('abort helpers', () => { + it('aborts timeout signals after the configured duration', async () => { + vi.useFakeTimers(); + + const signal = getTimeoutSignal(1_000); + const abortPromise = waitForAbort(signal); + + await vi.advanceTimersByTimeAsync(1_000); + + await expect(abortPromise).resolves.toBeInstanceOf(DOMException); + expect(signal.aborted).toBe(true); + expect(signal.reason).toBeInstanceOf(DOMException); + }); + + it('races abort signals and preserves the first abort reason', async () => { + const first = new AbortController(); + const second = new AbortController(); + const signal = raceAbortSignals([first.signal, second.signal]); + const abortPromise = waitForAbort(signal); + const secondReason = new Error('second'); + + second.abort(secondReason); + first.abort(new Error('first')); + + await expect(abortPromise).resolves.toBe(secondReason); + }); + + it('combines parent cancellation and timeout behavior', async () => { + vi.useFakeTimers(); + + const controller = new AbortController(); + const signal = withAbortTimeout(controller.signal, 1_000); + const abortPromise = waitForAbort(signal); + + controller.abort(createAbortError()); + await expect(abortPromise).resolves.toBeInstanceOf(DOMException); + + const timedSignal = withAbortTimeout(new AbortController().signal, 1_000); + const timedAbortPromise = waitForAbort(timedSignal); + + await vi.advanceTimersByTimeAsync(1_000); + + await expect(timedAbortPromise).resolves.toBeInstanceOf(DOMException); + }); +}); diff --git a/packages/tools/src/abort.ts b/packages/tools/src/abort.ts index 52ab0988..e0932e4c 100644 --- a/packages/tools/src/abort.ts +++ b/packages/tools/src/abort.ts @@ -1,5 +1,17 @@ export const getTimeoutSignal = (timeout: number): AbortSignal => { - const controller = new AbortController(); - setTimeout(() => controller.abort(), timeout); - return controller.signal; + return AbortSignal.timeout(timeout); +}; + +export const raceAbortSignals = (signals: AbortSignal[]): AbortSignal => { + if (signals.length === 0) { + return new AbortController().signal; + } + return AbortSignal.any(signals); +}; + +export const withAbortTimeout = ( + signal: AbortSignal, + timeout: number +): AbortSignal => { + return raceAbortSignals([signal, getTimeoutSignal(timeout)]); }; diff --git a/packages/tools/tsconfig.lib.json b/packages/tools/tsconfig.lib.json index db2f74f6..36c16990 100644 --- a/packages/tools/tsconfig.lib.json +++ b/packages/tools/tsconfig.lib.json @@ -7,7 +7,8 @@ "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo", "emitDeclarationOnly": false, "forceConsistentCasingInFileNames": true, - "types": ["node"] + "types": ["node"], + "lib": ["DOM", "ES2022"] }, "include": ["src/**/*.ts"], "references": []