From 39e0e20a39e0acb77b273c4bc88abb97718a1b85 Mon Sep 17 00:00:00 2001 From: Marc Rousavy Date: Thu, 15 Oct 2026 16:49:44 +0200 Subject: [PATCH] feat(jest): name the running test when the app crashes or disconnects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit executeRun now keeps the test that started on the device but has not finished yet, and clears it when a new file starts. When the file fails with a runtime failure (native crash, runtime or bridge disconnect, startup stall, device not responding), the failure message gets a `Running test: "Suite › test"` line under its headline. Failures outside a test keep their current message. --- .../version-plan-1792075735820.md | 9 ++ .../jest/src/__tests__/execute-run.test.ts | 127 ++++++++++++++++++ packages/jest/src/execute-run.ts | 60 ++++++--- 3 files changed, 177 insertions(+), 19 deletions(-) create mode 100644 .nx/version-plans/version-plan-1792075735820.md diff --git a/.nx/version-plans/version-plan-1792075735820.md b/.nx/version-plans/version-plan-1792075735820.md new file mode 100644 index 00000000..a73056fd --- /dev/null +++ b/.nx/version-plans/version-plan-1792075735820.md @@ -0,0 +1,9 @@ +--- +__default__: patch +--- + +When the app crashes, disconnects, or stops responding in the middle of a test +file, the failure now names the test that was running, for example +`Running test: "TestObjectCpp › thisObject returns a HybridObject of the same type"`. +Failures outside a test, such as during startup or in `beforeAll`, keep their +current message. diff --git a/packages/jest/src/__tests__/execute-run.test.ts b/packages/jest/src/__tests__/execute-run.test.ts index 3c234f1d..a4f6f62b 100644 --- a/packages/jest/src/__tests__/execute-run.test.ts +++ b/packages/jest/src/__tests__/execute-run.test.ts @@ -591,6 +591,133 @@ describe('executeRun', () => { ]); }); + describe('running test', () => { + const startedEvent = ( + name: string, + ancestorTitles: string[], + ): TestRunnerTestStartedEvent => ({ + type: 'test-started', + file: 'example.ts', + suite: ancestorTitles[ancestorTitles.length - 1] ?? 'root', + name, + ancestorTitles, + fullName: [...ancestorTitles, name].join(' '), + startedAt: 10, + }); + + const finishedEvent = ( + started: TestRunnerTestStartedEvent, + ): TestRunnerTestFinishedEvent => ({ + ...started, + type: 'test-finished', + duration: 5, + status: 'passed', + }); + + const makeListeningSession = (overrides: Partial = {}) => { + let listener: + | ((event: TestRunnerTestStartedEvent | TestRunnerTestFinishedEvent) => void) + | undefined; + const session = makeSession({ + onTestRunnerEvent: vi.fn((nextListener) => { + listener = nextListener as typeof listener; + return () => undefined; + }), + ...overrides, + }); + + return { + session, + emit: (event: TestRunnerTestStartedEvent | TestRunnerTestFinishedEvent) => + listener?.(event), + }; + }; + + const getFailureMessages = (calls: Array) => + calls + .filter(([eventName]) => eventName === 'test-file-failure') + .map(([, , failure]) => (failure as { message: string }).message); + + it('names the test that was running when the app crashed', async () => { + const { emitEvent, calls } = makeEmitEvent(); + const { session, emit } = makeListeningSession(); + const passed = startedEvent('creates an object', ['TestObjectCpp']); + + mockRunHarnessTestFile.mockImplementation(async () => { + emit(passed); + emit(finishedEvent(passed)); + emit( + startedEvent('thisObject returns a HybridObject of the same type', [ + 'TestObjectCpp', + ]), + ); + throw new NativeCrashError('example.ts', { + phase: 'execution', + source: 'polling', + }); + }); + + await executeRun(session, [makeTest()], makeWatcher(), emitEvent, makeGlobalConfig()); + + expect(getFailureMessages(calls)).toEqual([ + [ + 'The native app crashed during test execution.', + 'Running test: "TestObjectCpp › thisObject returns a HybridObject of the same type"', + "Harness couldn't extract the crash log.", + ].join('\n'), + ]); + }); + + it('keeps the original message when no test was running', async () => { + const { emitEvent, calls } = makeEmitEvent(); + const { session, emit } = makeListeningSession(); + const error = new AppBridgeDisconnectedError('app-disconnected'); + const completed = startedEvent('works', ['suite']); + + mockRunHarnessTestFile.mockImplementation(async () => { + emit(completed); + emit(finishedEvent(completed)); + throw error; + }); + + await executeRun(session, [makeTest()], makeWatcher(), emitEvent, makeGlobalConfig()); + + expect(getFailureMessages(calls)).toEqual([error.message]); + }); + + it('does not carry the running test over to the next file', async () => { + const { emitEvent, calls } = makeEmitEvent(); + const startupError = new StartupStallError(1500, 3); + const { session, emit } = makeListeningSession({ + ensureAppReady: vi + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(startupError), + }); + + mockRunHarnessTestFile.mockImplementationOnce(async () => { + emit(startedEvent('crashes', ['first file'])); + throw new NativeCrashError('a.ts', { + phase: 'execution', + source: 'polling', + }); + }); + + await executeRun( + session, + [makeTest('/project/a.ts'), makeTest('/project/b.ts')], + makeWatcher(), + emitEvent, + makeGlobalConfig(), + ); + + const messages = getFailureMessages(calls); + expect(messages).toHaveLength(2); + expect(messages[0]).toContain('Running test: "first file › crashes"'); + expect(messages[1]).toBe(startupError.message); + }); + }); + it('calls resetCrashState after a NativeCrashError', async () => { const session = makeSession({ ensureAppReady: vi.fn().mockRejectedValue( diff --git a/packages/jest/src/execute-run.ts b/packages/jest/src/execute-run.ts index 04a14e8f..486f32fe 100644 --- a/packages/jest/src/execute-run.ts +++ b/packages/jest/src/execute-run.ts @@ -60,15 +60,30 @@ class CancelRun extends Error { } } -const buildTestFailure = (err: unknown): { message: string; stack: string } => { - if ( - err instanceof NativeCrashError || - err instanceof RuntimeDisconnectError || - err instanceof StartupStallError || - err instanceof AppBridgeDisconnectedError || - err instanceof DeviceNotRespondingError - ) { - return { message: (err as Error).message, stack: '' }; +const isRuntimeFailure = (err: unknown): err is Error => + err instanceof NativeCrashError || + err instanceof RuntimeDisconnectError || + err instanceof StartupStallError || + err instanceof AppBridgeDisconnectedError || + err instanceof DeviceNotRespondingError; + +const formatRunningTest = (test: TestRunnerTestStartedEvent): string => + `Running test: "${[...test.ancestorTitles, test.name].join(' › ')}"`; + +const buildTestFailure = ( + err: unknown, + runningTest: TestRunnerTestStartedEvent | null = null, +): { message: string; stack: string } => { + if (isRuntimeFailure(err)) { + if (!runningTest) { + return { message: err.message, stack: '' }; + } + + const [headline, ...details] = err.message.split('\n'); + return { + message: [headline, formatRunningTest(runningTest), ...details].join('\n'), + stack: '', + }; } return err as { message: string; stack: string }; }; @@ -150,7 +165,16 @@ export const executeRun = async ( const testFiles = tests.map((t) => path.relative(rootDir, t.path)); const summary = createRunSummary(); let caseEventChain = Promise.resolve(); + // The device runs one test at a time, so a test that started and has not + // finished is the one the app was running if it crashes or disconnects. + let runningTest: TestRunnerTestStartedEvent | null = null; const unsubscribe = session.onTestRunnerEvent((event) => { + if (event.type === 'test-started') { + runningTest = event; + } else if (event.type === 'test-finished') { + runningTest = null; + } + if (isHarnessCaseEvent(event)) { caseEventChain = caseEventChain.then(() => event.type === 'test-started' @@ -208,6 +232,7 @@ export const executeRun = async ( const relativeTestPath = path.relative(rootDir, test.path); const fileStartedAt = Date.now(); + runningTest = null; let emittedTestFileFinished = false; const emitTestFileFinished = async (options: { @@ -314,14 +339,7 @@ export const executeRun = async ( }); } - const isRuntimeFailure = - err instanceof NativeCrashError || - err instanceof RuntimeDisconnectError || - err instanceof StartupStallError || - err instanceof AppBridgeDisconnectedError || - err instanceof DeviceNotRespondingError; - - if (isRuntimeFailure) { + if (isRuntimeFailure(err)) { summary.failed += 1; updateRunState(); } @@ -330,9 +348,13 @@ export const executeRun = async ( session.resetCrashState(); } - updateRunState({ error: isRuntimeFailure ? undefined : err }); + updateRunState({ error: isRuntimeFailure(err) ? undefined : err }); await caseEventChain; - await emitEvent('test-file-failure', test, buildTestFailure(err)); + await emitEvent( + 'test-file-failure', + test, + buildTestFailure(err, runningTest), + ); } } finally { fileSpan.end({ status: fileStatus });