Skip to content
Open
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
9 changes: 9 additions & 0 deletions .nx/version-plans/version-plan-1792075735820.md
Original file line number Diff line number Diff line change
@@ -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.
127 changes: 127 additions & 0 deletions packages/jest/src/__tests__/execute-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HarnessSession> = {}) => {
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<unknown[]>) =>
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(
Expand Down
60 changes: 41 additions & 19 deletions packages/jest/src/execute-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
};
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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();
}
Expand All @@ -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 });
Expand Down
Loading