Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .nx/version-plans/report-active-test-on-timeout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
__default__: patch
---

Harness now includes the last started file, suite, or unfinished test name when the device stops responding during a test file run. This makes bridge timeout failures easier to diagnose without adding custom wrapper scripts around Harness.
91 changes: 90 additions & 1 deletion packages/jest/src/__tests__/execute-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest';
import type { Config, Test, TestWatcher } from 'jest-runner';
import type { TestResult as JestTestResult } from '@jest/test-result';
import type {
TestRunnerEvents,
TestRunnerTestFinishedEvent,
TestRunnerTestStartedEvent,
TestSuiteResult,
Expand Down Expand Up @@ -263,7 +264,9 @@ describe('executeRun', () => {

it('emits test-case events before file success', async () => {
let testRunnerListener:
| ((event: TestRunnerTestStartedEvent | TestRunnerTestFinishedEvent) => void)
| ((
event: TestRunnerTestStartedEvent | TestRunnerTestFinishedEvent
) => void)
| undefined;
const { emitEvent, calls: emittedEvents } = makeEmitEvent();
const session = makeSession({
Expand Down Expand Up @@ -365,6 +368,92 @@ describe('executeRun', () => {
]);
});

it('includes the active harness test when a file run stops responding', async () => {
let testRunnerListener: ((event: TestRunnerEvents) => void) | undefined;
const { emitEvent, calls } = makeEmitEvent();
const session = makeSession({
onTestRunnerEvent: vi.fn((listener) => {
testRunnerListener = listener as typeof testRunnerListener;
return () => undefined;
}),
});

mockRunHarnessTestFile.mockImplementation(async () => {
testRunnerListener?.({
type: 'test-started',
file: 'example.ts',
suite: 'suite',
name: 'hangs',
ancestorTitles: ['suite'],
fullName: 'suite hangs',
startedAt: 10,
});
throw new DeviceNotRespondingError('runTests', []);
});

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

expect(calls).toContainEqual([
'test-file-failure',
expect.objectContaining({ path: '/project/example.ts' }),
expect.objectContaining({
message: expect.stringContaining(
'Last started test before the device stopped responding: suite hangs',
),
stack: '',
}),
]);
});

it('includes the active harness suite when a file stops responding before a test starts', async () => {
let testRunnerListener: ((event: TestRunnerEvents) => void) | undefined;
const { emitEvent, calls } = makeEmitEvent();
const session = makeSession({
onTestRunnerEvent: vi.fn((listener) => {
testRunnerListener = listener as typeof testRunnerListener;
return () => undefined;
}),
});

mockRunHarnessTestFile.mockImplementation(async () => {
testRunnerListener?.({
type: 'file-started',
file: 'example.ts',
});
testRunnerListener?.({
type: 'suite-started',
file: 'example.ts',
name: 'suite',
});
throw new DeviceNotRespondingError('runTests', []);
});

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

expect(calls).toContainEqual([
'test-file-failure',
expect.objectContaining({ path: '/project/example.ts' }),
expect.objectContaining({
message: expect.stringContaining(
'Last started suite before the device stopped responding: suite',
),
stack: '',
}),
]);
});

it('passes AppBridgeDisconnectedError to onFailure with an empty stack', async () => {
const { emitEvent, calls } = makeEmitEvent();
const session = makeSession({
Expand Down
64 changes: 62 additions & 2 deletions packages/jest/src/execute-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
import type { TestCaseResult } from '@jest/test-result';
import type {
TestRunnerEvents,
TestRunnerFileStartedEvent,
TestRunnerSuiteStartedEvent,
TestRunnerTestFinishedEvent,
TestRunnerTestStartedEvent,
} from '@react-native-harness/bridge';
Expand Down Expand Up @@ -52,14 +54,49 @@ class CancelRun extends Error {
}
}

const buildTestFailure = (err: unknown): { message: string; stack: string } => {
type ActiveHarnessContext = {
file?: TestRunnerFileStartedEvent;
suite?: TestRunnerSuiteStartedEvent;
test?: TestRunnerTestStartedEvent;
};

const formatActiveHarnessContext = (context?: ActiveHarnessContext | null) => {
if (!context) {
return null;
}
if (context.test) {
return `Last started test before the device stopped responding: ${context.test.fullName}`;
}
if (context.suite) {
return `Last started suite before the device stopped responding: ${context.suite.name}`;
}
if (context.file) {
return `Last started test file before the device stopped responding: ${context.file.file}`;
}
return null;
};

const buildTestFailure = (
err: unknown,
activeContext?: ActiveHarnessContext | null,
): { message: string; stack: string } => {
if (
err instanceof NativeCrashError ||
err instanceof RuntimeDisconnectError ||
err instanceof StartupStallError ||
err instanceof AppBridgeDisconnectedError ||
err instanceof DeviceNotRespondingError
) {
const activeContextMessage =
err instanceof DeviceNotRespondingError
? formatActiveHarnessContext(activeContext)
: null;
if (activeContextMessage) {
return {
message: [(err as Error).message, '', activeContextMessage].join('\n'),
stack: '',
};
}
return { message: (err as Error).message, stack: '' };
}
return err as { message: string; stack: string };
Expand Down Expand Up @@ -128,7 +165,26 @@ export const executeRun = async (
const testFiles = tests.map((t) => path.relative(rootDir, t.path));
const summary = createRunSummary();
let caseEventChain = Promise.resolve();
const activeContextByFile = new Map<string, ActiveHarnessContext>();
const unsubscribe = session.onTestRunnerEvent((event) => {
if (event.type === 'file-started') {
activeContextByFile.set(event.file, { file: event });
} else if (event.type === 'suite-started') {
const context = activeContextByFile.get(event.file) ?? {};
activeContextByFile.set(event.file, { ...context, suite: event });
} else if (event.type === 'test-started') {
const context = activeContextByFile.get(event.file) ?? {};
activeContextByFile.set(event.file, { ...context, test: event });
} else if (
event.type === 'test-finished' &&
activeContextByFile.get(event.file)?.test?.fullName === event.fullName
) {
const { test, ...context } = activeContextByFile.get(event.file) ?? {};
activeContextByFile.set(event.file, context);
} else if (event.type === 'file-finished') {
activeContextByFile.delete(event.file);
}

if (isHarnessCaseEvent(event)) {
caseEventChain = caseEventChain.then(() =>
event.type === 'test-started'
Expand Down Expand Up @@ -288,7 +344,11 @@ export const executeRun = async (

updateRunState({ error: isRuntimeFailure ? undefined : err });
await caseEventChain;
await emitEvent('test-file-failure', test, buildTestFailure(err));
await emitEvent(
'test-file-failure',
test,
buildTestFailure(err, activeContextByFile.get(relativeTestPath)),
);
}
}
} catch (err) {
Expand Down