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 cb856e69..c155113f 100644 --- a/packages/jest/src/__tests__/execute-run.test.ts +++ b/packages/jest/src/__tests__/execute-run.test.ts @@ -774,6 +774,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 6e86a67c..5b3ca356 100644 --- a/packages/jest/src/execute-run.ts +++ b/packages/jest/src/execute-run.ts @@ -67,15 +67,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 }; }; @@ -157,7 +172,16 @@ export const executeRun = async ( const testFiles = tests.map((t) => toRelativePosixPath(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' @@ -220,6 +244,7 @@ export const executeRun = async ( const relativeTestPath = toRelativePosixPath(rootDir, test.path); const fileStartedAt = Date.now(); + runningTest = null; let emittedTestFileFinished = false; const emitTestFileFinished = async (options: { @@ -335,15 +360,10 @@ export const executeRun = async ( }); } - const isRuntimeFailure = - err instanceof HostSurfaceReplacedError || - err instanceof NativeCrashError || - err instanceof RuntimeDisconnectError || - err instanceof StartupStallError || - err instanceof AppBridgeDisconnectedError || - err instanceof DeviceNotRespondingError; + const isFileRuntimeFailure = + err instanceof HostSurfaceReplacedError || isRuntimeFailure(err); - if (isRuntimeFailure) { + if (isFileRuntimeFailure) { summary.failed += 1; updateRunState(); } @@ -352,9 +372,13 @@ export const executeRun = async ( session.resetCrashState(); } - updateRunState({ error: isRuntimeFailure ? undefined : err }); + updateRunState({ error: isFileRuntimeFailure ? undefined : err }); await caseEventChain; - await emitEvent('test-file-failure', test, buildTestFailure(err)); + await emitEvent( + 'test-file-failure', + test, + buildTestFailure(err, runningTest), + ); if (err instanceof HostSurfaceReplacedError) { // Abandoned async test work may still be running in this process. await session.restartApp(test.path);