diff --git a/.nx/version-plans/restore-startup-stall-recovery.md b/.nx/version-plans/restore-startup-stall-recovery.md new file mode 100644 index 0000000..7da09a5 --- /dev/null +++ b/.nx/version-plans/restore-startup-stall-recovery.md @@ -0,0 +1,5 @@ +--- +__default__: patch +--- + +Harness now restores app startup stall recovery for RN-ready launches, including restart-between-files. Apps are retried when startup stalls without a crash, while confirmed native crashes still fail immediately with crash diagnostics. diff --git a/actions/shared/index.cjs b/actions/shared/index.cjs index 6794be6..87531d9 100644 --- a/actions/shared/index.cjs +++ b/actions/shared/index.cjs @@ -4223,10 +4223,8 @@ var ConfigSchema = external_exports.object({ host: external_exports.string().min(1, "Host is required").optional(), webSocketPort: external_exports.number().optional().default(3001), bridgeTimeout: external_exports.number().min(1e3, "Bridge timeout must be at least 1 second").default(6e4), - /** @deprecated Removed in favor of crash supervisor. Accepted for backwards compatibility. */ - bundleStartTimeout: external_exports.number().optional(), - /** @deprecated Removed in favor of crash supervisor. Accepted for backwards compatibility. */ - maxAppRestarts: external_exports.number().optional(), + bundleStartTimeout: external_exports.number().min(1e3, "Bundle start timeout must be at least 1 second").default(15e3), + maxAppRestarts: external_exports.number().min(0, "Max app restarts must be at least 0").default(2), resetEnvironmentBetweenTestFiles: external_exports.boolean().optional().default(true), unstable__skipAlreadyIncludedModules: external_exports.boolean().optional().default(false), unstable__enableMetroCache: external_exports.boolean().optional().default(false), @@ -4413,20 +4411,6 @@ var import_node_path5 = __toESM(require("path"), 1); var import_node_fs5 = __toESM(require("fs"), 1); var import_node_module2 = require("module"); var import_meta = {}; -var DEPRECATED_PROPERTIES = { - bundleStartTimeout: '"bundleStartTimeout" is no longer used and can be removed from your config. Startup crash detection is now handled automatically by the crash supervisor.', - maxAppRestarts: '"maxAppRestarts" is no longer used and can be removed from your config. Startup crash detection is now handled automatically by the crash supervisor.' -}; -var warnDeprecatedProperties = (rawConfig) => { - if (typeof rawConfig !== "object" || rawConfig === null) { - return; - } - for (const [key, message] of Object.entries(DEPRECATED_PROPERTIES)) { - if (key in rawConfig) { - console.warn(`[react-native-harness] Deprecation warning: ${message}`); - } - } -}; var extensions = [".js", ".mjs", ".cjs", ".json"]; var importUp = async (dir, name) => { const filePath = import_node_path5.default.join(dir, name); @@ -4445,7 +4429,6 @@ var importUp = async (dir, name) => { throw new ConfigLoadError(filePathWithExt, error instanceof Error ? error : void 0); } try { - warnDeprecatedProperties(rawConfig); const config = ConfigSchema.parse(rawConfig); return { config, filePathWithExt, configDir: dir }; } catch (error) { diff --git a/packages/config/src/reader.ts b/packages/config/src/reader.ts index a54ab0e..8318378 100644 --- a/packages/config/src/reader.ts +++ b/packages/config/src/reader.ts @@ -9,25 +9,6 @@ import fs from 'node:fs'; import { createRequire } from 'node:module'; import { ZodError } from 'zod'; -const DEPRECATED_PROPERTIES: Record = { - bundleStartTimeout: - '"bundleStartTimeout" is no longer used and can be removed from your config. Startup crash detection is now handled automatically by the crash supervisor.', - maxAppRestarts: - '"maxAppRestarts" is no longer used and can be removed from your config. Startup crash detection is now handled automatically by the crash supervisor.', -}; - -const warnDeprecatedProperties = (rawConfig: unknown) => { - if (typeof rawConfig !== 'object' || rawConfig === null) { - return; - } - - for (const [key, message] of Object.entries(DEPRECATED_PROPERTIES)) { - if (key in rawConfig) { - console.warn(`[react-native-harness] Deprecation warning: ${message}`); - } - } -}; - const extensions = ['.js', '.mjs', '.cjs', '.json']; const importUp = async ( @@ -62,7 +43,6 @@ const importUp = async ( } try { - warnDeprecatedProperties(rawConfig); const config = ConfigSchema.parse(rawConfig); return { config, filePathWithExt, configDir: dir }; } catch (error) { diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 7cd4051..8417222 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -28,10 +28,14 @@ export const ConfigSchema = z .min(1000, 'Bridge timeout must be at least 1 second') .default(60000), - /** @deprecated Removed in favor of crash supervisor. Accepted for backwards compatibility. */ - bundleStartTimeout: z.number().optional(), - /** @deprecated Removed in favor of crash supervisor. Accepted for backwards compatibility. */ - maxAppRestarts: z.number().optional(), + bundleStartTimeout: z + .number() + .min(1000, 'Bundle start timeout must be at least 1 second') + .default(15000), + maxAppRestarts: z + .number() + .min(0, 'Max app restarts must be at least 0') + .default(2), resetEnvironmentBetweenTestFiles: z.boolean().optional().default(true), unstable__skipAlreadyIncludedModules: z.boolean().optional().default(false), diff --git a/packages/jest/src/__tests__/harness.test.ts b/packages/jest/src/__tests__/harness.test.ts index 9c4bb46..c374b50 100644 --- a/packages/jest/src/__tests__/harness.test.ts +++ b/packages/jest/src/__tests__/harness.test.ts @@ -1,70 +1,209 @@ import { EventEmitter } from 'node:events'; -import { describe, expect, it, vi } from 'vitest'; -import { waitForAppReady } from '../harness.js'; -import type { BridgeServer } from '@react-native-harness/bridge/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Config as HarnessConfig } from '@react-native-harness/config'; import type { AppMonitor, + AppMonitorEvent, AppMonitorListener, + HarnessPlatform, HarnessPlatformRunner, } from '@react-native-harness/platforms'; +import type { BridgeServer } from '@react-native-harness/bridge/server'; import { createCrashSupervisor } from '../crash-supervisor.js'; +import type { Reporter, ReportableEvent } from '@react-native-harness/bundler-metro'; + +const mocks = vi.hoisted(() => ({ + createCrashArtifactWriter: vi.fn(() => ({})), + getBridgeServer: vi.fn(), + getMetroInstance: vi.fn(), + isMetroCacheReusable: vi.fn(() => false), + logMetroCacheReused: vi.fn(), + logMetroPrewarmCompleted: vi.fn(), + prewarmMetroBundle: vi.fn(), +})); + +vi.mock('@react-native-harness/bundler-metro', () => ({ + getMetroInstance: mocks.getMetroInstance, + prewarmMetroBundle: mocks.prewarmMetroBundle, +})); + +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 () => { + const actual = + await vi.importActual( + '@react-native-harness/tools' + ); + + return { + ...actual, + createCrashArtifactWriter: mocks.createCrashArtifactWriter, + }; +}); + +import { getHarness, waitForAppReady } from '../harness.js'; +import { StartupStallError } from '../errors.js'; const createBridgeServer = () => { const emitter = new EventEmitter(); return { serverBridge: { + rpc: { + clients: [], + }, on: emitter.on.bind(emitter), once: emitter.once.bind(emitter), off: emitter.off.bind(emitter), + dispose: vi.fn(), } as unknown as BridgeServer, emitReady: () => { - emitter.emit('ready'); + emitter.emit('ready', { + platform: 'ios', + manufacturer: 'Apple', + model: 'Simulator', + osVersion: '18.0', + }); + }, + }; +}; + +const createMetroReporter = (): { + reporter: Reporter; + emit: (event: ReportableEvent) => void; +} => { + 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(); + }, + }, + emit: (event) => { + listeners.forEach((listener) => listener(event)); }, }; }; -const createAppMonitor = (): AppMonitor => { +const createAppMonitor = (): { + appMonitor: AppMonitor; + emit: (event: AppMonitorEvent) => void; +} => { const listeners = new Set(); return { - start: async () => undefined, - stop: async () => undefined, - dispose: async () => undefined, - addListener: (listener) => { - listeners.add(listener); + appMonitor: { + start: vi.fn(async () => undefined), + stop: vi.fn(async () => undefined), + dispose: vi.fn(async () => undefined), + addListener: (listener) => { + listeners.add(listener); + }, + removeListener: (listener) => { + listeners.delete(listener); + }, }, - removeListener: (listener) => { - listeners.delete(listener); + emit: (event) => { + for (const listener of listeners) { + listener(event); + } }, }; }; const createPlatformRunner = ( - restartApp: HarnessPlatformRunner['restartApp'] + overrides: Partial = {} ): HarnessPlatformRunner => ({ - startApp: async () => undefined, - restartApp, - stopApp: async () => undefined, - dispose: async () => undefined, - isAppRunning: async () => true, - createAppMonitor: createAppMonitor, + startApp: vi.fn(async () => undefined), + restartApp: vi.fn(async () => undefined), + stopApp: vi.fn(async () => undefined), + dispose: vi.fn(async () => undefined), + isAppRunning: vi.fn(async () => true), + createAppMonitor: () => createAppMonitor().appMonitor, + ...overrides, +}); + +const createHarnessConfig = ( + overrides: Partial = {} +): HarnessConfig => + ({ + appRegistryComponentName: 'App', + bridgeTimeout: 60_000, + bundleStartTimeout: 1_000, + crashDetectionInterval: 500, + defaultRunner: 'ios', + detectNativeCrashes: true, + disableViewFlattening: false, + entryPoint: 'index.js', + forwardClientLogs: false, + maxAppRestarts: 2, + resetEnvironmentBetweenTestFiles: true, + runners: [], + unstable__enableMetroCache: false, + unstable__skipAlreadyIncludedModules: false, + webSocketPort: 8081, + ...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; + } + ).__HARNESS_PLATFORM_RUNNER__; }); describe('waitForAppReady', () => { - it('passes launch options to the initial launch', async () => { + 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 platformInstance = createPlatformRunner({ restartApp }); + const { appMonitor } = createAppMonitor(); const crashSupervisor = createCrashSupervisor({ - appMonitor: createAppMonitor(), + appMonitor, platformRunner: platformInstance, }); const promise = waitForAppReady({ + metroEvents: reporter, serverBridge, platformInstance, - bridgeTimeout: 5000, + bundleStartTimeout: 1_000, + maxAppRestarts: 2, testFilePath: '/tmp/test.harness.ts', crashSupervisor, appLaunchOptions: { @@ -74,56 +213,341 @@ describe('waitForAppReady', () => { }, }); - await Promise.resolve(); - expect(restartApp).toHaveBeenCalledWith({ + 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(); + + 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, + }); + + 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, + }); + + 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); + + emit({ + type: 'bundle_build_done', + buildID: 'startup', + } as ReportableEvent); + + await flush(); + await vi.advanceTimersByTimeAsync(1_000); + await flush(); + + expect(restartApp).toHaveBeenCalledTimes(2); + emitReady(); await promise; await crashSupervisor.dispose(); }); - it('does not retry launch when the app never becomes ready', async () => { + it('throws a startup stall error when all launch attempts are exhausted', async () => { vi.useFakeTimers(); const { serverBridge } = createBridgeServer(); + const { reporter } = createMetroReporter(); const restartApp = vi.fn().mockResolvedValue(undefined); - const platformInstance = createPlatformRunner(restartApp); + const platformInstance = createPlatformRunner({ restartApp }); + const { appMonitor } = createAppMonitor(); const crashSupervisor = createCrashSupervisor({ - appMonitor: createAppMonitor(), + appMonitor, platformRunner: platformInstance, }); const promise = waitForAppReady({ + metroEvents: reporter, serverBridge, platformInstance, - bridgeTimeout: 1000, + bundleStartTimeout: 1_000, + maxAppRestarts: 2, testFilePath: '/tmp/test.harness.ts', crashSupervisor, - appLaunchOptions: { - extras: { - mode: 'startup', - }, + }); + 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.', + }) + ); + + 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), }, }); - await Promise.resolve(); - await vi.advanceTimersByTimeAsync(1000); + const promise = waitForAppReady({ + metroEvents: reporter, + serverBridge, + platformInstance, + bundleStartTimeout: 1_000, + maxAppRestarts: 2, + testFilePath: '/tmp/test.harness.ts', + crashSupervisor, + }); + + await flush(); + + emit({ + type: 'app_exited', + source: 'polling', + isConfirmed: true, + pid: 123, + crashDetails: { + summary: 'fatal startup crash', + }, + } as AppMonitorEvent); await expect(promise).rejects.toMatchObject({ - name: 'AbortError', + name: 'NativeCrashError', + phase: 'startup', }); expect(restartApp).toHaveBeenCalledTimes(1); - expect(restartApp).toHaveBeenCalledWith({ - extras: { - mode: 'startup', + + 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, + }); + + await flush(); + await vi.advanceTimersByTimeAsync(1_000); + await flush(); + + expect(restartApp).toHaveBeenCalledTimes(2); + + emit({ + type: 'possible_crash', + source: 'polling', + isConfirmed: true, + pid: 456, + crashDetails: { + summary: 'crashed on retry', + }, + } as AppMonitorEvent); + + await expect(promise).rejects.toMatchObject({ + name: 'NativeCrashError', + phase: 'startup', + }); + expect(restartApp).toHaveBeenCalledTimes(2); + await crashSupervisor.dispose(); - vi.useRealTimers(); + }); +}); + +describe('restart(testFilePath)', () => { + it('stops the app and relaunches through the shared startup recovery helper', async () => { + vi.useFakeTimers(); + + const { serverBridge, emitReady } = createBridgeServer(); + const appMonitor = createAppMonitor(); + const restartApp = vi.fn().mockResolvedValue(undefined); + const stopApp = vi.fn().mockResolvedValue(undefined); + const platformInstance = createPlatformRunner({ + restartApp, + stopApp, + createAppMonitor: () => appMonitor.appMonitor, + }); + + mocks.getBridgeServer.mockResolvedValue(serverBridge); + const metroReporter = createMetroReporter(); + mocks.getMetroInstance.mockResolvedValue({ + events: metroReporter.reporter, + dispose: vi.fn(async () => undefined), + }); + mocks.prewarmMetroBundle.mockResolvedValue(undefined); + + ( + globalThis as typeof globalThis & { + __HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise; + } + ).__HARNESS_PLATFORM_RUNNER__ = vi.fn(async () => platformInstance); + + const platform: HarnessPlatform = { + config: { + appLaunchOptions: { + extras: { + source: 'restart', + }, + }, + }, + name: 'ios', + platformId: 'ios', + runner: `data:text/javascript,${encodeURIComponent( + 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);' + )}`, + }; + + const harness = await getHarness( + createHarnessConfig({ + bundleStartTimeout: 1_000, + maxAppRestarts: 2, + }), + platform, + '/tmp/project' + ); + + const restartPromise = harness.restart('/tmp/restart.harness.ts'); + + await flush(); + await flush(); + + expect(stopApp).toHaveBeenCalledTimes(1); + expect(restartApp).toHaveBeenCalledTimes(1); + expect(restartApp).toHaveBeenNthCalledWith(1, { + extras: { + source: 'restart', + }, + }); + + await vi.advanceTimersByTimeAsync(1_000); + await flush(); + + expect(restartApp).toHaveBeenCalledTimes(2); + expect(restartApp).toHaveBeenNthCalledWith(2, { + extras: { + source: 'restart', + }, + }); + + emitReady(); + await restartPromise; + await harness.dispose(); + }); +}); + +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.' + ); }); }); diff --git a/packages/jest/src/__tests__/index.test.ts b/packages/jest/src/__tests__/index.test.ts new file mode 100644 index 0000000..db8d86a --- /dev/null +++ b/packages/jest/src/__tests__/index.test.ts @@ -0,0 +1,58 @@ +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 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 onFailure = vi.fn(); + + const harness = { + ensureAppReady: vi + .fn() + .mockRejectedValue(new StartupStallError(1500, 3)), + crashSupervisor: { + beginTestRun: vi.fn(), + waitForCrash: vi.fn(), + cancelCrashWaiters: vi.fn(), + reset: vi.fn(), + }, + } as unknown as Harness; + + await runner._createInBandTestRun( + [ + { + path: '/tmp/example.harness.ts', + context: { + config: {}, + }, + } as Test, + ], + { + isInterrupted: () => false, + } as any, + harness, + { + detectNativeCrashes: true, + resetEnvironmentBetweenTestFiles: false, + } as HarnessConfig, + () => Promise.resolve(), + () => Promise.resolve(), + onFailure + ); + + expect(onFailure).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/tmp/example.harness.ts', + }), + { + message: + 'The app never became ready after 3 launch attempts with a startup stall timeout of 1500ms and no native crash signal.', + stack: '', + } + ); + }); +}); diff --git a/packages/jest/src/errors.ts b/packages/jest/src/errors.ts index a7f5c7e..f7ba11b 100644 --- a/packages/jest/src/errors.ts +++ b/packages/jest/src/errors.ts @@ -22,6 +22,20 @@ 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 29a09ce..3041a85 100644 --- a/packages/jest/src/harness.ts +++ b/packages/jest/src/harness.ts @@ -15,10 +15,15 @@ import { import { getMetroInstance, prewarmMetroBundle, + type Reporter, + type ReportableEvent, } from '@react-native-harness/bundler-metro'; import { isMetroCacheReusable } from '@react-native-harness/metro'; import { createCrashArtifactWriter } from '@react-native-harness/tools'; -import { InitializationTimeoutError } from './errors.js'; +import { + InitializationTimeoutError, + StartupStallError, +} from './errors.js'; import { Config as HarnessConfig } from '@react-native-harness/config'; import { createCrashSupervisor, @@ -55,58 +60,141 @@ export const maybeLogMetroCacheReuse = ( }; export const waitForAppReady = async (options: { + metroEvents: Reporter; serverBridge: BridgeServer; platformInstance: HarnessPlatformRunner; - bridgeTimeout: number; + bundleStartTimeout: number; + maxAppRestarts: number; testFilePath: string; crashSupervisor: CrashSupervisor; appLaunchOptions?: AppLaunchOptions; + launchApp?: () => Promise; }): Promise => { const { + metroEvents, serverBridge, platformInstance, - bridgeTimeout, + bundleStartTimeout, + maxAppRestarts, testFilePath, crashSupervisor, appLaunchOptions, + launchApp = () => platformInstance.restartApp(appLaunchOptions), } = options; - const signal = AbortSignal.timeout(bridgeTimeout); + const totalAttempts = maxAppRestarts + 1; + let restartCount = 0; + let isBundling = false; + let timeoutId: NodeJS.Timeout | null = null; + let settled = false; - return new Promise((resolve, reject) => { - const launchApp = async () => { - crashSupervisor.beginLaunch(testFilePath); - await platformInstance.restartApp(appLaunchOptions); + 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 onReady = () => { - crashSupervisor.markReady(); + const rejectOnce = (error: unknown) => { + if (settled) { + return; + } + cleanup(); - resolve(); + reject(error); }; - const onAbort = () => { + const resolveOnce = () => { + if (settled) { + return; + } + cleanup(); - reject(new DOMException('The operation was aborted', 'AbortError')); + resolve(); }; - const cleanup = () => { - serverBridge.off('ready', onReady); - signal.removeEventListener('abort', onAbort); + 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); + }); + + try { + await launchApp(); + } catch (error) { + rejectOnce(error); + } }; - signal.addEventListener('abort', onAbort); - serverBridge.once('ready', onReady); - void crashSupervisor.waitForCrash(testFilePath).catch((error) => { - cleanup(); - reject(error); - }); + metroEvents.addListener(onMetroEvent); + serverBridge.on('ready', onReady); - void launchApp().catch((error) => { - cleanup(); - reject(error); - }); + void startAttempt(); }); }; @@ -199,9 +287,11 @@ const getHarnessInternal = async ( crashSupervisor.reset(); await waitForAppReady({ + metroEvents: metroInstance.events, serverBridge, platformInstance: platformInstance as HarnessPlatformRunner, - bridgeTimeout: config.bridgeTimeout, + bundleStartTimeout: config.bundleStartTimeout ?? 15000, + maxAppRestarts: config.maxAppRestarts ?? 2, testFilePath, crashSupervisor, appLaunchOptions, diff --git a/packages/jest/src/index.ts b/packages/jest/src/index.ts index d3e25db..4f54b7d 100644 --- a/packages/jest/src/index.ts +++ b/packages/jest/src/index.ts @@ -17,7 +17,7 @@ import { teardown } from './teardown.js'; import { HarnessError } from '@react-native-harness/tools'; import { getErrorMessage } from './logs.js'; import { DeviceNotRespondingError } from '@react-native-harness/bridge/server'; -import { NativeCrashError } from './errors.js'; +import { NativeCrashError, StartupStallError } from './errors.js'; class CancelRun extends Error { constructor(message?: string) { @@ -149,6 +149,15 @@ export default class JestHarness implements CallbackTestRunnerInterface { return; } + if (err instanceof StartupStallError) { + onFailure(test, { + message: err.message, + stack: '', + }); + + return; + } + if (err instanceof DeviceNotRespondingError) { onFailure(test, { message: err.message,