Skip to content

Commit e2a4941

Browse files
committed
feat(jest): emit per-test case events
1 parent ced42ed commit e2a4941

12 files changed

Lines changed: 552 additions & 100 deletions

File tree

packages/bridge/src/shared/test-collector.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import type { HarnessTestContext } from './test-context.js';
22

33
export type TestStatus = 'active' | 'skipped' | 'todo';
44

5+
export type TestDeclarationMode = 'only' | 'skip' | 'todo';
6+
57
export type TestFn = (context: HarnessTestContext) => void | Promise<void>;
68

79
export type SuiteHookFn = () => void | Promise<void>;
@@ -10,6 +12,7 @@ export type TestCase = {
1012
name: string;
1113
fn: TestFn;
1214
status: TestStatus;
15+
declarationMode?: TestDeclarationMode;
1316
};
1417

1518
export type TestSuite = {

packages/bridge/src/shared/test-runner.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { TestDeclarationMode } from './test-collector.js';
2+
13
export type CodeFrame = {
24
content: string;
35
location?: {
@@ -37,13 +39,21 @@ export type TestRunnerTestStartedEvent = {
3739
name: string;
3840
suite: string;
3941
file: string;
42+
ancestorTitles: string[];
43+
fullName: string;
44+
startedAt: number;
45+
declarationMode?: TestDeclarationMode;
4046
};
4147

4248
export type TestRunnerTestFinishedEvent = {
4349
type: 'test-finished';
4450
name: string;
4551
suite: string;
4652
file: string;
53+
ancestorTitles: string[];
54+
fullName: string;
55+
startedAt: number;
56+
declarationMode?: TestDeclarationMode;
4757
duration: number;
4858
error?: SerializedError;
4959
status: TestResultStatus;
@@ -71,6 +81,10 @@ export type TestResult = {
7181
status: TestResultStatus;
7282
error?: SerializedError;
7383
duration: number;
84+
ancestorTitles?: string[];
85+
fullName?: string;
86+
startedAt?: number;
87+
declarationMode?: TestDeclarationMode;
7488
};
7589

7690
export type TestSuiteResult = {

packages/jest/src/__tests__/execute-run.test.ts

Lines changed: 133 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
11
import { describe, expect, it, vi, beforeEach } from 'vitest';
22
import type { Config, Test, TestWatcher } from 'jest-runner';
33
import type { TestResult as JestTestResult } from '@jest/test-result';
4-
import type { TestSuiteResult } from '@react-native-harness/bridge';
4+
import type {
5+
TestRunnerTestFinishedEvent,
6+
TestRunnerTestStartedEvent,
7+
TestSuiteResult,
8+
} from '@react-native-harness/bridge';
59
import { NativeCrashError, StartupStallError } from '../errors.js';
610
import { DeviceNotRespondingError } from '@react-native-harness/bridge/server';
711
import type { HarnessSession } from '../harness-session.js';
812
import { executeRun } from '../execute-run.js';
913

14+
type EmitEvent = Parameters<typeof executeRun>[3];
15+
type RecordedEmitEvent = {
16+
emitEvent: EmitEvent;
17+
calls: Array<unknown[]>;
18+
};
19+
1020
const resolveUndefined = async () => undefined;
1121

1222
// Mock the file-runner so we control what jestResult/harnessResult each test returns
@@ -93,11 +103,21 @@ const makeSession = (overrides: Partial<HarnessSession> = {}): HarnessSession =>
93103
resetCrashState: vi.fn(),
94104
flushClientLogs: vi.fn(() => []),
95105
callHook: vi.fn(resolveUndefined),
106+
onTestRunnerEvent: vi.fn(() => () => undefined),
96107
setRunState: vi.fn(),
97108
dispose: vi.fn(resolveUndefined),
98109
...overrides,
99110
});
100111

112+
const makeEmitEvent = (): RecordedEmitEvent => {
113+
const calls: Array<unknown[]> = [];
114+
const emitEvent: EmitEvent = (async (...eventData) => {
115+
calls.push(eventData);
116+
}) as EmitEvent;
117+
118+
return { emitEvent, calls };
119+
};
120+
101121
// ---------------------------------------------------------------------------
102122
// Tests
103123
// ---------------------------------------------------------------------------
@@ -115,7 +135,7 @@ describe('executeRun', () => {
115135
callHook: vi.fn(async (name) => { hookNames.push(name as string); }),
116136
});
117137

118-
await executeRun(session, [makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeGlobalConfig());
138+
await executeRun(session, [makeTest()], makeWatcher(), makeEmitEvent().emitEvent, makeGlobalConfig());
119139

120140
expect(hookNames[0]).toBe('run:started');
121141
expect(hookNames[hookNames.length - 1]).toBe('run:finished');
@@ -127,7 +147,7 @@ describe('executeRun', () => {
127147
callHook: vi.fn(async (name) => { hookNames.push(name as string); }),
128148
});
129149

130-
await executeRun(session, [makeTest('/a.ts'), makeTest('/b.ts')], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeGlobalConfig());
150+
await executeRun(session, [makeTest('/a.ts'), makeTest('/b.ts')], makeWatcher(), makeEmitEvent().emitEvent, makeGlobalConfig());
131151

132152
const fileHooks = hookNames.filter((n) =>
133153
n === 'test-file:started' || n === 'test-file:finished',
@@ -145,29 +165,41 @@ describe('executeRun', () => {
145165
ensureAppReady: vi.fn(async () => { throw new Error('unexpected'); }),
146166
});
147167

148-
await executeRun(session, [makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeGlobalConfig());
168+
await executeRun(session, [makeTest()], makeWatcher(), makeEmitEvent().emitEvent, makeGlobalConfig());
149169

150170
expect(hookNames).toContain('run:finished');
151171
});
152172
});
153173

154174
describe('happy path', () => {
155-
it('calls onStart, ensureAppReady, runTestFile, onResult in order', async () => {
175+
it('emits file start, ensureAppReady, runTestFile, file success in order', async () => {
156176
const order: string[] = [];
157177
const session = makeSession({
158178
ensureAppReady: vi.fn(async () => { order.push('ensureAppReady'); }),
159179
});
160-
const onStart = vi.fn(async () => { order.push('onStart'); });
161-
const onResult = vi.fn(async () => { order.push('onResult'); });
180+
const emitEvent: EmitEvent = (async (eventName, ..._eventData) => {
181+
if (eventName === 'test-file-start') {
182+
order.push('test-file-start');
183+
}
184+
185+
if (eventName === 'test-file-success') {
186+
order.push('test-file-success');
187+
}
188+
}) as EmitEvent;
162189

163190
mockRunHarnessTestFile.mockImplementation(async () => {
164191
order.push('runTestFile');
165192
return makeFileRunResult();
166193
});
167194

168-
await executeRun(session, [makeTest()], makeWatcher(), onStart, onResult, vi.fn(), makeGlobalConfig());
195+
await executeRun(session, [makeTest()], makeWatcher(), emitEvent, makeGlobalConfig());
169196

170-
expect(order).toEqual(['onStart', 'ensureAppReady', 'runTestFile', 'onResult']);
197+
expect(order).toEqual([
198+
'test-file-start',
199+
'ensureAppReady',
200+
'runTestFile',
201+
'test-file-success',
202+
]);
171203
});
172204

173205
it('accumulates passing test counts in run:finished summary', async () => {
@@ -182,7 +214,7 @@ describe('executeRun', () => {
182214
makeFileRunResult({ jestResult: makeJestResult({ numPassingTests: 3 }) }),
183215
);
184216

185-
await executeRun(session, [makeTest(), makeTest('/b.ts')], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeGlobalConfig());
217+
await executeRun(session, [makeTest(), makeTest('/b.ts')], makeWatcher(), makeEmitEvent().emitEvent, makeGlobalConfig());
186218

187219
const payload = finishedPayload as { summary: { passed: number }; status: string };
188220
expect(payload.summary.passed).toBe(6);
@@ -196,55 +228,126 @@ describe('executeRun', () => {
196228
.mockReturnValueOnce([])
197229
.mockReturnValueOnce(clientLogs),
198230
});
199-
const onResult = vi.fn();
231+
const { emitEvent, calls } = makeEmitEvent();
200232

201233
await executeRun(
202234
session,
203235
[makeTest()],
204236
makeWatcher(),
205-
vi.fn(),
206-
onResult,
207-
vi.fn(),
237+
emitEvent,
208238
makeGlobalConfig(),
209239
);
210240

211241
expect(session.flushClientLogs).toHaveBeenCalledTimes(2);
212-
expect(onResult).toHaveBeenCalledWith(
242+
expect(calls).toContainEqual([
243+
'test-file-success',
213244
expect.anything(),
214245
expect.objectContaining({ console: clientLogs }),
215-
);
246+
]);
247+
});
248+
249+
it('emits test-case events before file success', async () => {
250+
let testRunnerListener:
251+
| ((event: TestRunnerTestStartedEvent | TestRunnerTestFinishedEvent) => void)
252+
| undefined;
253+
const { emitEvent, calls: emittedEvents } = makeEmitEvent();
254+
const session = makeSession({
255+
onTestRunnerEvent: vi.fn((listener) => {
256+
testRunnerListener = listener as typeof testRunnerListener;
257+
return () => undefined;
258+
}),
259+
});
260+
261+
mockRunHarnessTestFile.mockImplementation(async () => {
262+
testRunnerListener?.({
263+
type: 'test-started',
264+
file: 'example.ts',
265+
suite: 'suite',
266+
name: 'works',
267+
ancestorTitles: ['suite'],
268+
fullName: 'suite works',
269+
startedAt: 10,
270+
declarationMode: 'only',
271+
});
272+
testRunnerListener?.({
273+
type: 'test-finished',
274+
file: 'example.ts',
275+
suite: 'suite',
276+
name: 'works',
277+
ancestorTitles: ['suite'],
278+
fullName: 'suite works',
279+
startedAt: 10,
280+
declarationMode: 'only',
281+
duration: 5,
282+
status: 'passed',
283+
});
284+
285+
return makeFileRunResult();
286+
});
287+
288+
await executeRun(session, [makeTest()], makeWatcher(), emitEvent, makeGlobalConfig());
289+
290+
expect(emittedEvents).toEqual([
291+
['test-file-start', expect.anything()],
292+
[
293+
'test-case-start',
294+
'example.ts',
295+
expect.objectContaining({
296+
ancestorTitles: ['suite'],
297+
fullName: 'suite works',
298+
mode: 'only',
299+
title: 'works',
300+
startedAt: 10,
301+
}),
302+
],
303+
[
304+
'test-case-result',
305+
'example.ts',
306+
expect.objectContaining({
307+
ancestorTitles: ['suite'],
308+
fullName: 'suite works',
309+
numPassingAsserts: 1,
310+
startedAt: 10,
311+
status: 'passed',
312+
title: 'works',
313+
}),
314+
],
315+
['test-file-success', expect.anything(), expect.anything()],
316+
]);
216317
});
217318
});
218319

219320
describe('runtime failures', () => {
220321
it('passes StartupStallError to onFailure with an empty stack', async () => {
221-
const onFailure = vi.fn();
322+
const { emitEvent, calls } = makeEmitEvent();
222323
const session = makeSession({
223324
ensureAppReady: vi.fn().mockRejectedValue(new StartupStallError(1500, 3)),
224325
});
225326

226-
await executeRun(session, [makeTest()], makeWatcher(), vi.fn(), vi.fn(), onFailure, makeGlobalConfig());
327+
await executeRun(session, [makeTest()], makeWatcher(), emitEvent, makeGlobalConfig());
227328

228-
expect(onFailure).toHaveBeenCalledWith(
329+
expect(calls).toContainEqual([
330+
'test-file-failure',
229331
expect.objectContaining({ path: '/test/example.ts' }),
230332
expect.objectContaining({ message: expect.stringContaining('1500'), stack: '' }),
231-
);
333+
]);
232334
});
233335

234336
it('passes DeviceNotRespondingError to onFailure with an empty stack', async () => {
235-
const onFailure = vi.fn();
337+
const { emitEvent, calls } = makeEmitEvent();
236338
const session = makeSession({
237339
ensureAppReady: vi.fn().mockRejectedValue(
238340
new DeviceNotRespondingError('runTests', []),
239341
),
240342
});
241343

242-
await executeRun(session, [makeTest()], makeWatcher(), vi.fn(), vi.fn(), onFailure, makeGlobalConfig());
344+
await executeRun(session, [makeTest()], makeWatcher(), emitEvent, makeGlobalConfig());
243345

244-
expect(onFailure).toHaveBeenCalledWith(
346+
expect(calls).toContainEqual([
347+
'test-file-failure',
245348
expect.anything(),
246349
expect.objectContaining({ stack: '' }),
247-
);
350+
]);
248351
});
249352

250353
it('calls resetCrashState after a NativeCrashError', async () => {
@@ -254,7 +357,7 @@ describe('executeRun', () => {
254357
),
255358
});
256359

257-
await executeRun(session, [makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeGlobalConfig());
360+
await executeRun(session, [makeTest()], makeWatcher(), makeEmitEvent().emitEvent, makeGlobalConfig());
258361

259362
expect(session.resetCrashState).toHaveBeenCalled();
260363
});
@@ -268,7 +371,7 @@ describe('executeRun', () => {
268371
ensureAppReady: vi.fn().mockRejectedValue(new StartupStallError(1000, 2)),
269372
});
270373

271-
await executeRun(session, [makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeGlobalConfig());
374+
await executeRun(session, [makeTest()], makeWatcher(), makeEmitEvent().emitEvent, makeGlobalConfig());
272375

273376
const payload = finishedPayload as { summary: { failed: number }; status: string };
274377
expect(payload.summary.failed).toBe(1);
@@ -282,20 +385,18 @@ describe('executeRun', () => {
282385
const session = makeSession({
283386
callHook: vi.fn(async (name) => { hookNames.push(name as string); }),
284387
});
285-
const onStart = vi.fn();
388+
const { emitEvent, calls } = makeEmitEvent();
286389

287390
await executeRun(
288391
session,
289392
[makeTest('/a.ts'), makeTest('/b.ts')],
290393
makeWatcher(true /* interrupted */),
291-
onStart,
292-
vi.fn(),
293-
vi.fn(),
394+
emitEvent,
294395
makeGlobalConfig(),
295396
);
296397

297398
// No test was started; watcher was already interrupted.
298-
expect(onStart).not.toHaveBeenCalled();
399+
expect(calls).not.toContainEqual(['test-file-start', expect.anything()]);
299400
expect(hookNames).toContain('run:finished');
300401
});
301402
});
@@ -314,7 +415,7 @@ describe('executeRun', () => {
314415
session,
315416
[makeTest('/a.ts'), makeTest('/b.ts'), makeTest('/c.ts')],
316417
makeWatcher(),
317-
vi.fn(), vi.fn(), vi.fn(),
418+
makeEmitEvent().emitEvent,
318419
makeGlobalConfig(),
319420
);
320421

0 commit comments

Comments
 (0)