From 227fa59155e88ace304946a692ce8f680b56826c Mon Sep 17 00:00:00 2001 From: Marc Rousavy Date: Tue, 2 Jun 2026 20:35:19 +0200 Subject: [PATCH] fix: Report active test on device timeout --- .../report-active-test-on-timeout.md | 5 + .../jest/src/__tests__/execute-run.test.ts | 91 ++++++++++++++++++- packages/jest/src/execute-run.ts | 64 ++++++++++++- 3 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 .nx/version-plans/report-active-test-on-timeout.md diff --git a/.nx/version-plans/report-active-test-on-timeout.md b/.nx/version-plans/report-active-test-on-timeout.md new file mode 100644 index 0000000..fddf177 --- /dev/null +++ b/.nx/version-plans/report-active-test-on-timeout.md @@ -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. diff --git a/packages/jest/src/__tests__/execute-run.test.ts b/packages/jest/src/__tests__/execute-run.test.ts index 632ce14..9c60f41 100644 --- a/packages/jest/src/__tests__/execute-run.test.ts +++ b/packages/jest/src/__tests__/execute-run.test.ts @@ -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, @@ -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({ @@ -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({ diff --git a/packages/jest/src/execute-run.ts b/packages/jest/src/execute-run.ts index 6d326bb..97c7ebd 100644 --- a/packages/jest/src/execute-run.ts +++ b/packages/jest/src/execute-run.ts @@ -20,6 +20,8 @@ import { import type { TestCaseResult } from '@jest/test-result'; import type { TestRunnerEvents, + TestRunnerFileStartedEvent, + TestRunnerSuiteStartedEvent, TestRunnerTestFinishedEvent, TestRunnerTestStartedEvent, } from '@react-native-harness/bridge'; @@ -52,7 +54,32 @@ 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 || @@ -60,6 +87,16 @@ const buildTestFailure = (err: unknown): { message: string; stack: string } => { 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 }; @@ -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(); 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' @@ -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) {