Skip to content
8 changes: 8 additions & 0 deletions .changeset/runtime-test-timeouts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@react-native-harness/bridge': patch
'@react-native-harness/config': patch
'@react-native-harness/jest': patch
'@react-native-harness/runtime': patch
---

Report stalled runtime test cases through per-test timeouts instead of letting the whole `runTests` bridge RPC fail generically. Harness now leaves `runTests` guarded by bridge heartbeat traffic, forwards the configured Harness test timeout into the runtime, marks the timed-out test as failed, skips the remaining tests in the file, restarts the app before continuing, and includes pending promise diagnostics in timeout failures.
1 change: 1 addition & 0 deletions actions/shared/index.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4417,6 +4417,7 @@ var ConfigSchema = external_exports.object({
metroPort: external_exports.number().int("Metro port must be an integer").min(1, "Metro port must be at least 1").max(65535, "Metro port must be at most 65535").optional().default(DEFAULT_METRO_PORT),
webSocketPort: external_exports.number().optional().describe("Deprecated. Bridge traffic now uses metroPort and this value is ignored."),
bridgeTimeout: external_exports.number().min(1e3, "Bridge timeout must be at least 1 second").default(6e4),
testTimeout: external_exports.number().min(1e3, "Test timeout must be at least 1 second").default(5e3),
platformReadyTimeout: external_exports.number().min(1e3, "Platform ready timeout must be at least 1 second").default(3e5),
bundleStartTimeout: external_exports.number().min(1e3, "Bundle start timeout must be at least 1 second").default(6e4),
maxAppRestarts: external_exports.number().min(0, "Max app restarts must be at least 0").default(2),
Expand Down
25 changes: 25 additions & 0 deletions packages/bridge/src/__tests__/rpc-peer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,31 @@ describe('rpc-peer', () => {
await expect(pending).rejects.toThrow('timed out');
});

it('does not time out calls when method-specific timeout returns undefined', async () => {
const peer = createRpcPeer<
Record<string, never>,
{ runTests: (path: string, options: { runner: string }) => Promise<void> },
BridgeEvents
>({
localMethods: {},
transport: createMockTransport(),
callTimeoutMs: () => undefined,
createTimeoutError: () => new Error('timed out'),
});

let rejected = false;
const pending = peer.invoke('runTests', 'example.ts', { runner: '/runner.js' });
pending.catch(() => {
rejected = true;
});

await new Promise((resolve) => setTimeout(resolve, 20));

expect(rejected).toBe(false);
peer.close(new Error('closed'));
await expect(pending).rejects.toThrow('closed');
});

it('throws on malformed messages', async () => {
const peer = createRpcPeer<Record<string, never>, Record<string, never>, BridgeEvents>({
localMethods: {},
Expand Down
11 changes: 8 additions & 3 deletions packages/bridge/src/rpc-peer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export type CreateRpcPeerOptions<
localMethods: Local;
transport: RpcTransport;
onEvent?: (event: Event) => void;
callTimeoutMs?: number;
callTimeoutMs?: number | ((method: string, args: unknown[]) => number | undefined);
createTimeoutError?: (method: string, args: unknown[]) => Error;
};

Expand Down Expand Up @@ -107,14 +107,19 @@ export const createRpcPeer = <
timeout: null,
};

if (options.callTimeoutMs !== undefined) {
const callTimeoutMs =
typeof options.callTimeoutMs === 'function'
? options.callTimeoutMs(methodName, args)
: options.callTimeoutMs;

if (callTimeoutMs !== undefined) {
invocation.timeout = setTimeout(() => {
pendingInvocations.delete(id);
reject(
options.createTimeoutError?.(methodName, args) ??
new Error(`RPC call timed out: ${methodName}`),
);
}, options.callTimeoutMs);
}, callTimeoutMs);
}

pendingInvocations.set(id, invocation);
Expand Down
2 changes: 1 addition & 1 deletion packages/bridge/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ export const createHarnessBridge = async (
onEvent: (event) => {
emitter.emit('event', event);
},
callTimeoutMs: timeout,
callTimeoutMs: (method) => method === 'runTests' ? undefined : timeout,
createTimeoutError: (functionName, args) => {
return new DeviceNotRespondingError(functionName, args) as unknown as Error;
},
Expand Down
1 change: 1 addition & 0 deletions packages/bridge/src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export type TestExecutionOptions = {
testNamePattern?: string;
setupFiles?: string[];
setupFilesAfterEnv?: string[];
testTimeout?: number;
runner: string;
};

Expand Down
10 changes: 10 additions & 0 deletions packages/bridge/src/shared/test-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ export type SerializedError = {
name: string;
message: string;
codeFrame?: CodeFrame;
diagnostics?: {
pendingPromises?: {
total: number;
items: Array<{
id: number;
createdAt: number;
stack?: string;
}>;
};
};
};

export type TestRunnerFileStartedEvent = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const makeConfig = (): Config => ({
metroPort: 8081,
webSocketPort: undefined,
bridgeTimeout: 60000,
testTimeout: 5000,
platformReadyTimeout: 300000,
bundleStartTimeout: 60000,
maxAppRestarts: 2,
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/__tests__/platform-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ describe('platform CLI command discovery', () => {
metroPort: 8081,
webSocketPort: undefined,
bridgeTimeout: 60000,
testTimeout: 5000,
platformReadyTimeout: 300000,
bundleStartTimeout: 60000,
maxAppRestarts: 2,
Expand Down Expand Up @@ -106,6 +107,7 @@ describe('platform CLI command discovery', () => {
metroPort: 8081,
webSocketPort: undefined,
bridgeTimeout: 60000,
testTimeout: 5000,
platformReadyTimeout: 300000,
bundleStartTimeout: 60000,
maxAppRestarts: 2,
Expand Down Expand Up @@ -146,6 +148,7 @@ describe('platform CLI command discovery', () => {
metroPort: 8081,
webSocketPort: undefined,
bridgeTimeout: 60000,
testTimeout: 5000,
platformReadyTimeout: 300000,
bundleStartTimeout: 60000,
maxAppRestarts: 2,
Expand Down Expand Up @@ -208,6 +211,7 @@ describe('platform CLI command discovery', () => {
metroPort: 8081,
webSocketPort: undefined,
bridgeTimeout: 60000,
testTimeout: 5000,
platformReadyTimeout: 300000,
bundleStartTimeout: 60000,
maxAppRestarts: 2,
Expand Down
5 changes: 5 additions & 0 deletions packages/config/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ export const ConfigSchema = z
.min(1000, 'Bridge timeout must be at least 1 second')
.default(60000),

testTimeout: z
.number()
.min(1000, 'Test timeout must be at least 1 second')
.default(5000),

platformReadyTimeout: z
.number()
.min(1000, 'Platform ready timeout must be at least 1 second')
Expand Down
187 changes: 186 additions & 1 deletion packages/jest/src/__tests__/execute-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ describe('executeRun', () => {
const session = makeSession({
ensureAppReady: vi.fn(async () => { order.push('ensureAppReady'); }),
});
const emitEvent: EmitEvent = (async (eventName, ..._eventData) => {
const emitEvent: EmitEvent = (async (eventName) => {
if (eventName === 'test-file-start') {
order.push('test-file-start');
}
Expand Down Expand Up @@ -330,6 +330,65 @@ describe('executeRun', () => {
['test-file-success', expect.anything(), expect.anything()],
]);
});

it('includes pending promise diagnostics in live test-case failures', async () => {
let testRunnerListener:
| ((event: TestRunnerTestStartedEvent | TestRunnerTestFinishedEvent) => void)
| undefined;
const { emitEvent, calls: emittedEvents } = makeEmitEvent();
const session = makeSession({
onTestRunnerEvent: vi.fn((listener) => {
testRunnerListener = listener as typeof testRunnerListener;
return () => undefined;
}),
});

mockRunHarnessTestFile.mockImplementation(async () => {
testRunnerListener?.({
type: 'test-finished',
file: 'example.ts',
suite: 'suite',
name: 'hangs',
ancestorTitles: ['suite'],
fullName: 'suite hangs',
startedAt: 100,
duration: 50,
status: 'failed',
error: {
name: 'TestCaseTimeoutError',
message: 'Test timed out after 50ms: suite hangs',
diagnostics: {
pendingPromises: {
total: 1,
items: [
{
id: 7,
createdAt: 110,
stack: 'Error: Promise created\n at hangs (example.ts:10:5)',
},
],
},
},
},
});

return makeFileRunResult();
});

await executeRun(session, [makeTest()], makeWatcher(), emitEvent, makeGlobalConfig());

expect(emittedEvents).toContainEqual([
'test-case-result',
'example.ts',
expect.objectContaining({
failureMessages: [
expect.stringContaining(
'Pending promises at timeout: 1\n\nPromise #7, created 10ms after test start:',
),
],
}),
]);
});
});

describe('runtime failures', () => {
Expand Down Expand Up @@ -461,6 +520,132 @@ describe('executeRun', () => {
// restartApp should be called for tests 2 and 3, not test 1.
expect(session.restartApp).toHaveBeenCalledTimes(2);
});

it('restarts after a test case timeout before the next runnable file', async () => {
const timedOutResult = makeHarnessResult('failed');
timedOutResult.tests = [
{
name: 'hangs',
status: 'failed',
duration: 10,
error: {
name: 'TestCaseTimeoutError',
message: 'Test timed out after 10ms: hangs',
},
},
];
mockRunHarnessTestFile
.mockResolvedValueOnce(makeFileRunResult({
harnessResult: timedOutResult,
jestResult: makeJestResult({
numFailingTests: 1,
numPassingTests: 0,
}),
}))
.mockResolvedValueOnce(makeFileRunResult());
const session = makeSession({
config: {
metroPort: 8081,
resetEnvironmentBetweenTestFiles: false,
detectNativeCrashes: false,
runners: [
{ platformId: 'android', name: 'android' },
{ platformId: 'ios', name: 'ios' },
],
} as HarnessSession['config'],
});

await executeRun(
session,
[makeTest('/a.ts'), makeTest('/b.ts')],
makeWatcher(),
makeEmitEvent().emitEvent,
makeGlobalConfig(),
);

expect(session.restartApp).toHaveBeenCalledTimes(1);
expect(session.restartApp).toHaveBeenCalledWith('/a.ts');
});

it('restarts after a timeout in the last runnable file', async () => {
const timedOutResult = makeHarnessResult('failed');
timedOutResult.tests = [
{
name: 'hangs',
status: 'failed',
duration: 10,
error: {
name: 'TestCaseTimeoutError',
message: 'Test timed out after 10ms: hangs',
},
},
];
mockRunHarnessTestFile.mockResolvedValueOnce(makeFileRunResult({
harnessResult: timedOutResult,
jestResult: makeJestResult({
numFailingTests: 1,
numPassingTests: 0,
}),
}));
const session = makeSession({
config: {
metroPort: 8081,
resetEnvironmentBetweenTestFiles: false,
detectNativeCrashes: false,
runners: [
{ platformId: 'android', name: 'android' },
{ platformId: 'ios', name: 'ios' },
],
} as HarnessSession['config'],
});

await executeRun(
session,
[makeTest('/a.ts')],
makeWatcher(),
makeEmitEvent().emitEvent,
makeGlobalConfig({ watch: true }),
);

expect(session.restartApp).toHaveBeenCalledTimes(1);
expect(session.restartApp).toHaveBeenCalledWith('/a.ts');
});

it('restarts after a suite hook timeout', async () => {
const timedOutResult = makeHarnessResult('failed');
timedOutResult.suites = [
{
name: 'suite',
tests: [],
suites: [],
status: 'failed',
duration: 10,
error: {
name: 'SuiteHookTimeoutError',
message: 'beforeAll hook timed out after 10ms in suite: suite',
},
},
];
mockRunHarnessTestFile.mockResolvedValueOnce(makeFileRunResult({
harnessResult: timedOutResult,
jestResult: makeJestResult({
numFailingTests: 1,
numPassingTests: 0,
}),
}));
const session = makeSession();

await executeRun(
session,
[makeTest('/a.ts')],
makeWatcher(),
makeEmitEvent().emitEvent,
makeGlobalConfig(),
);

expect(session.restartApp).toHaveBeenCalledTimes(1);
expect(session.restartApp).toHaveBeenCalledWith('/a.ts');
});
});

describe('platform-specific test files', () => {
Expand Down
Loading
Loading