@react-native-harness/jest 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/dist/__tests__/bridge.test.js +29 -4
  2. package/dist/__tests__/crash-monitor.test.js +194 -176
  3. package/dist/__tests__/environment-reset.test.d.ts +2 -0
  4. package/dist/__tests__/environment-reset.test.d.ts.map +1 -0
  5. package/dist/__tests__/environment-reset.test.js +238 -0
  6. package/dist/__tests__/errors.test.js +39 -0
  7. package/dist/__tests__/execute-run.test.js +439 -29
  8. package/dist/__tests__/format-harness-error.test.d.ts +2 -0
  9. package/dist/__tests__/format-harness-error.test.d.ts.map +1 -0
  10. package/dist/__tests__/format-harness-error.test.js +27 -0
  11. package/dist/__tests__/harness-cache.test.js +62 -41
  12. package/dist/__tests__/harness-session-signal.test.d.ts +2 -0
  13. package/dist/__tests__/harness-session-signal.test.d.ts.map +1 -0
  14. package/dist/__tests__/harness-session-signal.test.js +298 -0
  15. package/dist/__tests__/harness-session.test.js +217 -234
  16. package/dist/__tests__/index.test.js +62 -11
  17. package/dist/__tests__/metro-port.test.js +2 -0
  18. package/dist/__tests__/run.test.d.ts +2 -0
  19. package/dist/__tests__/run.test.d.ts.map +1 -0
  20. package/dist/__tests__/run.test.js +48 -0
  21. package/dist/__tests__/test-file-platform-filter.test.d.ts +2 -0
  22. package/dist/__tests__/test-file-platform-filter.test.d.ts.map +1 -0
  23. package/dist/__tests__/test-file-platform-filter.test.js +47 -0
  24. package/dist/crash-monitor.d.ts +12 -4
  25. package/dist/crash-monitor.d.ts.map +1 -1
  26. package/dist/crash-monitor.js +135 -102
  27. package/dist/diagnostics/__tests__/derivers.test.d.ts +2 -0
  28. package/dist/diagnostics/__tests__/derivers.test.d.ts.map +1 -0
  29. package/dist/diagnostics/__tests__/derivers.test.js +248 -0
  30. package/dist/diagnostics/__tests__/report.test.d.ts +2 -0
  31. package/dist/diagnostics/__tests__/report.test.d.ts.map +1 -0
  32. package/dist/diagnostics/__tests__/report.test.js +97 -0
  33. package/dist/diagnostics/derivers.d.ts +24 -0
  34. package/dist/diagnostics/derivers.d.ts.map +1 -0
  35. package/dist/diagnostics/derivers.js +146 -0
  36. package/dist/diagnostics/index.d.ts +3 -0
  37. package/dist/diagnostics/index.d.ts.map +1 -0
  38. package/dist/diagnostics/index.js +2 -0
  39. package/dist/diagnostics/report.d.ts +7 -0
  40. package/dist/diagnostics/report.d.ts.map +1 -0
  41. package/dist/diagnostics/report.js +84 -0
  42. package/dist/environment-reset.d.ts +51 -0
  43. package/dist/environment-reset.d.ts.map +1 -0
  44. package/dist/environment-reset.js +83 -0
  45. package/dist/errors.d.ts +10 -0
  46. package/dist/errors.d.ts.map +1 -1
  47. package/dist/errors.js +41 -2
  48. package/dist/execute-run.d.ts +4 -2
  49. package/dist/execute-run.d.ts.map +1 -1
  50. package/dist/execute-run.js +179 -49
  51. package/dist/format-harness-error.d.ts +5 -0
  52. package/dist/format-harness-error.d.ts.map +1 -0
  53. package/dist/format-harness-error.js +43 -0
  54. package/dist/harness-session.d.ts +46 -1
  55. package/dist/harness-session.d.ts.map +1 -1
  56. package/dist/harness-session.js +386 -118
  57. package/dist/index.d.ts +7 -3
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +24 -2
  60. package/dist/run.d.ts.map +1 -1
  61. package/dist/run.js +22 -4
  62. package/dist/test-file-platform-filter.d.ts +5 -0
  63. package/dist/test-file-platform-filter.d.ts.map +1 -0
  64. package/dist/test-file-platform-filter.js +40 -0
  65. package/dist/toTestResult.d.ts +1 -0
  66. package/dist/toTestResult.d.ts.map +1 -1
  67. package/dist/toTestResult.js +2 -2
  68. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  69. package/package.json +8 -7
  70. package/src/__tests__/bridge.test.ts +45 -5
  71. package/src/__tests__/crash-monitor.test.ts +234 -199
  72. package/src/__tests__/environment-reset.test.ts +308 -0
  73. package/src/__tests__/errors.test.ts +75 -0
  74. package/src/__tests__/execute-run.test.ts +561 -37
  75. package/src/__tests__/format-harness-error.test.ts +32 -0
  76. package/src/__tests__/harness-cache.test.ts +91 -0
  77. package/src/__tests__/harness-session.test.ts +303 -0
  78. package/src/__tests__/index.test.ts +92 -12
  79. package/src/__tests__/metro-port.test.ts +2 -0
  80. package/src/__tests__/run.test.ts +74 -0
  81. package/src/__tests__/test-file-platform-filter.test.ts +83 -0
  82. package/src/crash-monitor.ts +220 -131
  83. package/src/diagnostics/__tests__/derivers.test.ts +300 -0
  84. package/src/diagnostics/__tests__/report.test.ts +128 -0
  85. package/src/diagnostics/derivers.ts +170 -0
  86. package/src/diagnostics/index.ts +2 -0
  87. package/src/diagnostics/report.ts +124 -0
  88. package/src/environment-reset.ts +138 -0
  89. package/src/errors.ts +62 -1
  90. package/src/execute-run.ts +230 -57
  91. package/src/format-harness-error.ts +73 -0
  92. package/src/harness-session.ts +535 -162
  93. package/src/index.ts +40 -11
  94. package/src/run.ts +24 -4
  95. package/src/test-file-platform-filter.ts +57 -0
  96. package/src/toTestResult.ts +3 -2
  97. package/tsconfig.json +3 -0
  98. package/tsconfig.lib.json +3 -0
@@ -1,12 +1,26 @@
1
1
  import { describe, expect, it, vi, beforeEach } from 'vitest';
2
2
  import type { Config, Test, TestWatcher } from 'jest-runner';
3
3
  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';
5
9
  import { NativeCrashError, StartupStallError } from '../errors.js';
6
- import { DeviceNotRespondingError } from '@react-native-harness/bridge/server';
10
+ import {
11
+ AppBridgeDisconnectedError,
12
+ DeviceNotRespondingError,
13
+ } from '@react-native-harness/bridge/server';
14
+ import { createDiagnostics } from '@react-native-harness/tools';
7
15
  import type { HarnessSession } from '../harness-session.js';
8
16
  import { executeRun } from '../execute-run.js';
9
17
 
18
+ type EmitEvent = Parameters<typeof executeRun>[3];
19
+ type RecordedEmitEvent = {
20
+ emitEvent: EmitEvent;
21
+ calls: Array<unknown[]>;
22
+ };
23
+
10
24
  const resolveUndefined = async () => undefined;
11
25
 
12
26
  // Mock the file-runner so we control what jestResult/harnessResult each test returns
@@ -16,6 +30,16 @@ vi.mock('../run.js', () => ({
16
30
  runHarnessTestFile: mockRunHarnessTestFile,
17
31
  }));
18
32
 
33
+ // Mock the reporting module so diagnostics tests can inspect the entries that
34
+ // would have been reported without touching the real filesystem, and so
35
+ // asserting on them doesn't race with executeRun's own diagnostics.flush().
36
+ const mockPrintSummary = vi.hoisted(() => vi.fn());
37
+ const mockWriteTraceFile = vi.hoisted(() => vi.fn(async () => '/fake/trace.json'));
38
+ vi.mock('../diagnostics/index.js', () => ({
39
+ printSummary: mockPrintSummary,
40
+ writeTraceFile: mockWriteTraceFile,
41
+ }));
42
+
19
43
  // ---------------------------------------------------------------------------
20
44
  // Helpers
21
45
  // ---------------------------------------------------------------------------
@@ -85,19 +109,43 @@ const makeSession = (overrides: Partial<HarnessSession> = {}): HarnessSession =>
85
109
  host: undefined,
86
110
  resetEnvironmentBetweenTestFiles: false,
87
111
  detectNativeCrashes: true,
112
+ runners: [
113
+ { platformId: 'android', name: 'android' },
114
+ { platformId: 'ios', name: 'ios' },
115
+ ],
88
116
  } as HarnessSession['config'],
89
- context: {} as HarnessSession['context'],
117
+ context: {
118
+ platform: {
119
+ platformId: 'android',
120
+ name: 'android',
121
+ runner: '/virtual/android-runner.js',
122
+ cli: '/virtual/android-cli.js',
123
+ config: {},
124
+ },
125
+ } as HarnessSession['context'],
126
+ diagnostics: createDiagnostics({ enabled: false }),
90
127
  ensureAppReady: vi.fn(resolveUndefined),
91
128
  runTestFile: vi.fn(async () => makeHarnessResult()),
92
129
  restartApp: vi.fn(resolveUndefined),
130
+ resetEnvironment: vi.fn(resolveUndefined),
93
131
  resetCrashState: vi.fn(),
94
132
  flushClientLogs: vi.fn(() => []),
95
133
  callHook: vi.fn(resolveUndefined),
134
+ onTestRunnerEvent: vi.fn(() => () => undefined),
96
135
  setRunState: vi.fn(),
97
136
  dispose: vi.fn(resolveUndefined),
98
137
  ...overrides,
99
138
  });
100
139
 
140
+ const makeEmitEvent = (): RecordedEmitEvent => {
141
+ const calls: Array<unknown[]> = [];
142
+ const emitEvent: EmitEvent = (async (...eventData) => {
143
+ calls.push(eventData);
144
+ }) as EmitEvent;
145
+
146
+ return { emitEvent, calls };
147
+ };
148
+
101
149
  // ---------------------------------------------------------------------------
102
150
  // Tests
103
151
  // ---------------------------------------------------------------------------
@@ -115,7 +163,7 @@ describe('executeRun', () => {
115
163
  callHook: vi.fn(async (name) => { hookNames.push(name as string); }),
116
164
  });
117
165
 
118
- await executeRun(session, [makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeGlobalConfig());
166
+ await executeRun(session, [makeTest()], makeWatcher(), makeEmitEvent().emitEvent, makeGlobalConfig());
119
167
 
120
168
  expect(hookNames[0]).toBe('run:started');
121
169
  expect(hookNames[hookNames.length - 1]).toBe('run:finished');
@@ -127,7 +175,7 @@ describe('executeRun', () => {
127
175
  callHook: vi.fn(async (name) => { hookNames.push(name as string); }),
128
176
  });
129
177
 
130
- await executeRun(session, [makeTest('/a.ts'), makeTest('/b.ts')], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeGlobalConfig());
178
+ await executeRun(session, [makeTest('/a.ts'), makeTest('/b.ts')], makeWatcher(), makeEmitEvent().emitEvent, makeGlobalConfig());
131
179
 
132
180
  const fileHooks = hookNames.filter((n) =>
133
181
  n === 'test-file:started' || n === 'test-file:finished',
@@ -145,29 +193,96 @@ describe('executeRun', () => {
145
193
  ensureAppReady: vi.fn(async () => { throw new Error('unexpected'); }),
146
194
  });
147
195
 
148
- await executeRun(session, [makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeGlobalConfig());
196
+ await executeRun(session, [makeTest()], makeWatcher(), makeEmitEvent().emitEvent, makeGlobalConfig());
149
197
 
150
198
  expect(hookNames).toContain('run:finished');
151
199
  });
152
200
  });
153
201
 
202
+ describe('diagnostics', () => {
203
+ beforeEach(() => {
204
+ mockPrintSummary.mockClear();
205
+ mockWriteTraceFile.mockClear();
206
+ });
207
+
208
+ it('records run.total and run.file spans when diagnostics is enabled', async () => {
209
+ const diagnostics = createDiagnostics({ enabled: true });
210
+ const session = makeSession({ diagnostics });
211
+
212
+ await executeRun(session, [makeTest('/a.ts')], makeWatcher(), makeEmitEvent().emitEvent, makeGlobalConfig());
213
+
214
+ // executeRun flushes and hands entries to the (mocked) reporter in its
215
+ // finally block, so we inspect what was reported rather than flushing
216
+ // the diagnostics instance ourselves.
217
+ expect(mockPrintSummary).toHaveBeenCalledTimes(1);
218
+ const entries = mockPrintSummary.mock.calls[0]?.[0] as Array<{ label: string; status: string; attrs?: Record<string, unknown> }>;
219
+ const runEntry = entries.find((e) => e.label === 'run.total');
220
+ const fileEntry = entries.find((e) => e.label === 'run.file');
221
+
222
+ expect(runEntry).toMatchObject({ status: 'ok', attrs: { status: 'passed' } });
223
+ expect(runEntry?.attrs?.runId).toBeTypeOf('string');
224
+ expect(fileEntry).toMatchObject({ status: 'ok', attrs: { file: '../a.ts', status: 'passed' } });
225
+ expect(fileEntry?.attrs?.runId).toBeTypeOf('string');
226
+ expect(mockWriteTraceFile).toHaveBeenCalledWith(entries, expect.objectContaining({ runId: expect.any(String) }));
227
+ });
228
+
229
+ it('still records run.total when a test throws', async () => {
230
+ const diagnostics = createDiagnostics({ enabled: true });
231
+ const session = makeSession({
232
+ diagnostics,
233
+ ensureAppReady: vi.fn(async () => { throw new Error('unexpected'); }),
234
+ });
235
+
236
+ await executeRun(session, [makeTest()], makeWatcher(), makeEmitEvent().emitEvent, makeGlobalConfig());
237
+
238
+ const entries = mockPrintSummary.mock.calls[0]?.[0] as Array<{ label: string; attrs?: Record<string, unknown> }>;
239
+ expect(entries.find((e) => e.label === 'run.total')).toBeDefined();
240
+ expect(entries.find((e) => e.label === 'run.file')).toMatchObject({
241
+ attrs: { status: 'failed' },
242
+ });
243
+ });
244
+
245
+ it('is a no-op and does not generate a report when diagnostics is disabled', async () => {
246
+ const diagnostics = createDiagnostics({ enabled: false });
247
+ const session = makeSession({ diagnostics });
248
+
249
+ await executeRun(session, [makeTest()], makeWatcher(), makeEmitEvent().emitEvent, makeGlobalConfig());
250
+
251
+ expect(diagnostics.flush()).toEqual([]);
252
+ expect(mockPrintSummary).not.toHaveBeenCalled();
253
+ expect(mockWriteTraceFile).not.toHaveBeenCalled();
254
+ });
255
+ });
256
+
154
257
  describe('happy path', () => {
155
- it('calls onStart, ensureAppReady, runTestFile, onResult in order', async () => {
258
+ it('emits file start, ensureAppReady, runTestFile, file success in order', async () => {
156
259
  const order: string[] = [];
157
260
  const session = makeSession({
158
261
  ensureAppReady: vi.fn(async () => { order.push('ensureAppReady'); }),
159
262
  });
160
- const onStart = vi.fn(async () => { order.push('onStart'); });
161
- const onResult = vi.fn(async () => { order.push('onResult'); });
263
+ const emitEvent: EmitEvent = (async (eventName) => {
264
+ if (eventName === 'test-file-start') {
265
+ order.push('test-file-start');
266
+ }
267
+
268
+ if (eventName === 'test-file-success') {
269
+ order.push('test-file-success');
270
+ }
271
+ }) as EmitEvent;
162
272
 
163
273
  mockRunHarnessTestFile.mockImplementation(async () => {
164
274
  order.push('runTestFile');
165
275
  return makeFileRunResult();
166
276
  });
167
277
 
168
- await executeRun(session, [makeTest()], makeWatcher(), onStart, onResult, vi.fn(), makeGlobalConfig());
278
+ await executeRun(session, [makeTest()], makeWatcher(), emitEvent, makeGlobalConfig());
169
279
 
170
- expect(order).toEqual(['onStart', 'ensureAppReady', 'runTestFile', 'onResult']);
280
+ expect(order).toEqual([
281
+ 'test-file-start',
282
+ 'ensureAppReady',
283
+ 'runTestFile',
284
+ 'test-file-success',
285
+ ]);
171
286
  });
172
287
 
173
288
  it('accumulates passing test counts in run:finished summary', async () => {
@@ -182,13 +297,32 @@ describe('executeRun', () => {
182
297
  makeFileRunResult({ jestResult: makeJestResult({ numPassingTests: 3 }) }),
183
298
  );
184
299
 
185
- await executeRun(session, [makeTest(), makeTest('/b.ts')], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeGlobalConfig());
300
+ await executeRun(session, [makeTest(), makeTest('/b.ts')], makeWatcher(), makeEmitEvent().emitEvent, makeGlobalConfig());
186
301
 
187
302
  const payload = finishedPayload as { summary: { passed: number }; status: string };
188
303
  expect(payload.summary.passed).toBe(6);
189
304
  expect(payload.status).toBe('passed');
190
305
  });
191
306
 
307
+ it('marks the final run state as completed', async () => {
308
+ const session = makeSession();
309
+
310
+ await executeRun(
311
+ session,
312
+ [makeTest()],
313
+ makeWatcher(),
314
+ makeEmitEvent().emitEvent,
315
+ makeGlobalConfig(),
316
+ );
317
+
318
+ expect(session.setRunState).toHaveBeenLastCalledWith(
319
+ expect.objectContaining({
320
+ completed: true,
321
+ status: 'passed',
322
+ }),
323
+ );
324
+ });
325
+
192
326
  it('attaches buffered client logs to the Jest result', async () => {
193
327
  const clientLogs = [{ message: 'Loaded screen', origin: '', type: 'warn' }] satisfies NonNullable<JestTestResult['console']>;
194
328
  const session = makeSession({
@@ -196,55 +330,205 @@ describe('executeRun', () => {
196
330
  .mockReturnValueOnce([])
197
331
  .mockReturnValueOnce(clientLogs),
198
332
  });
199
- const onResult = vi.fn();
333
+ const { emitEvent, calls } = makeEmitEvent();
200
334
 
201
335
  await executeRun(
202
336
  session,
203
337
  [makeTest()],
204
338
  makeWatcher(),
205
- vi.fn(),
206
- onResult,
207
- vi.fn(),
339
+ emitEvent,
208
340
  makeGlobalConfig(),
209
341
  );
210
342
 
211
343
  expect(session.flushClientLogs).toHaveBeenCalledTimes(2);
212
- expect(onResult).toHaveBeenCalledWith(
344
+ expect(calls).toContainEqual([
345
+ 'test-file-success',
213
346
  expect.anything(),
214
347
  expect.objectContaining({ console: clientLogs }),
215
- );
348
+ ]);
349
+ });
350
+
351
+ it('emits test-case events before file success', async () => {
352
+ let testRunnerListener:
353
+ | ((event: TestRunnerTestStartedEvent | TestRunnerTestFinishedEvent) => void)
354
+ | undefined;
355
+ const { emitEvent, calls: emittedEvents } = makeEmitEvent();
356
+ const session = makeSession({
357
+ onTestRunnerEvent: vi.fn((listener) => {
358
+ testRunnerListener = listener as typeof testRunnerListener;
359
+ return () => undefined;
360
+ }),
361
+ });
362
+
363
+ mockRunHarnessTestFile.mockImplementation(async () => {
364
+ testRunnerListener?.({
365
+ type: 'test-started',
366
+ file: 'example.ts',
367
+ suite: 'suite',
368
+ name: 'works',
369
+ ancestorTitles: ['suite'],
370
+ fullName: 'suite works',
371
+ startedAt: 10,
372
+ declarationMode: 'only',
373
+ });
374
+ testRunnerListener?.({
375
+ type: 'test-finished',
376
+ file: 'example.ts',
377
+ suite: 'suite',
378
+ name: 'works',
379
+ ancestorTitles: ['suite'],
380
+ fullName: 'suite works',
381
+ startedAt: 10,
382
+ declarationMode: 'only',
383
+ duration: 5,
384
+ status: 'passed',
385
+ });
386
+
387
+ return makeFileRunResult();
388
+ });
389
+
390
+ await executeRun(session, [makeTest()], makeWatcher(), emitEvent, makeGlobalConfig());
391
+
392
+ expect(emittedEvents).toEqual([
393
+ ['test-file-start', expect.anything()],
394
+ [
395
+ 'test-case-start',
396
+ 'example.ts',
397
+ expect.objectContaining({
398
+ ancestorTitles: ['suite'],
399
+ fullName: 'suite works',
400
+ mode: 'only',
401
+ title: 'works',
402
+ startedAt: 10,
403
+ }),
404
+ ],
405
+ [
406
+ 'test-case-result',
407
+ 'example.ts',
408
+ expect.objectContaining({
409
+ ancestorTitles: ['suite'],
410
+ fullName: 'suite works',
411
+ numPassingAsserts: 1,
412
+ startedAt: 10,
413
+ status: 'passed',
414
+ title: 'works',
415
+ }),
416
+ ],
417
+ ['test-file-success', expect.anything(), expect.anything()],
418
+ ]);
419
+ });
420
+
421
+ it('includes pending promise diagnostics in live test-case failures', async () => {
422
+ let testRunnerListener:
423
+ | ((event: TestRunnerTestStartedEvent | TestRunnerTestFinishedEvent) => void)
424
+ | undefined;
425
+ const { emitEvent, calls: emittedEvents } = makeEmitEvent();
426
+ const session = makeSession({
427
+ onTestRunnerEvent: vi.fn((listener) => {
428
+ testRunnerListener = listener as typeof testRunnerListener;
429
+ return () => undefined;
430
+ }),
431
+ });
432
+
433
+ mockRunHarnessTestFile.mockImplementation(async () => {
434
+ testRunnerListener?.({
435
+ type: 'test-finished',
436
+ file: 'example.ts',
437
+ suite: 'suite',
438
+ name: 'hangs',
439
+ ancestorTitles: ['suite'],
440
+ fullName: 'suite hangs',
441
+ startedAt: 100,
442
+ duration: 50,
443
+ status: 'failed',
444
+ error: {
445
+ name: 'TestCaseTimeoutError',
446
+ message: 'Test timed out after 50ms: suite hangs',
447
+ diagnostics: {
448
+ pendingPromises: {
449
+ total: 1,
450
+ items: [
451
+ {
452
+ id: 7,
453
+ createdAt: 110,
454
+ stack: 'Error: Promise created\n at hangs (example.ts:10:5)',
455
+ },
456
+ ],
457
+ },
458
+ },
459
+ },
460
+ });
461
+
462
+ return makeFileRunResult();
463
+ });
464
+
465
+ await executeRun(session, [makeTest()], makeWatcher(), emitEvent, makeGlobalConfig());
466
+
467
+ expect(emittedEvents).toContainEqual([
468
+ 'test-case-result',
469
+ 'example.ts',
470
+ expect.objectContaining({
471
+ failureMessages: [
472
+ expect.stringContaining(
473
+ 'Pending promises at timeout: 1\n\nPromise #7, created 10ms after test start:',
474
+ ),
475
+ ],
476
+ }),
477
+ ]);
216
478
  });
217
479
  });
218
480
 
219
481
  describe('runtime failures', () => {
220
482
  it('passes StartupStallError to onFailure with an empty stack', async () => {
221
- const onFailure = vi.fn();
483
+ const { emitEvent, calls } = makeEmitEvent();
222
484
  const session = makeSession({
223
485
  ensureAppReady: vi.fn().mockRejectedValue(new StartupStallError(1500, 3)),
224
486
  });
225
487
 
226
- await executeRun(session, [makeTest()], makeWatcher(), vi.fn(), vi.fn(), onFailure, makeGlobalConfig());
488
+ await executeRun(session, [makeTest()], makeWatcher(), emitEvent, makeGlobalConfig());
227
489
 
228
- expect(onFailure).toHaveBeenCalledWith(
490
+ expect(calls).toContainEqual([
491
+ 'test-file-failure',
229
492
  expect.objectContaining({ path: '/test/example.ts' }),
230
493
  expect.objectContaining({ message: expect.stringContaining('1500'), stack: '' }),
231
- );
494
+ ]);
232
495
  });
233
496
 
234
497
  it('passes DeviceNotRespondingError to onFailure with an empty stack', async () => {
235
- const onFailure = vi.fn();
498
+ const { emitEvent, calls } = makeEmitEvent();
236
499
  const session = makeSession({
237
500
  ensureAppReady: vi.fn().mockRejectedValue(
238
501
  new DeviceNotRespondingError('runTests', []),
239
502
  ),
240
503
  });
241
504
 
242
- await executeRun(session, [makeTest()], makeWatcher(), vi.fn(), vi.fn(), onFailure, makeGlobalConfig());
505
+ await executeRun(session, [makeTest()], makeWatcher(), emitEvent, makeGlobalConfig());
243
506
 
244
- expect(onFailure).toHaveBeenCalledWith(
507
+ expect(calls).toContainEqual([
508
+ 'test-file-failure',
245
509
  expect.anything(),
246
510
  expect.objectContaining({ stack: '' }),
247
- );
511
+ ]);
512
+ });
513
+
514
+ it('passes AppBridgeDisconnectedError to onFailure with an empty stack', async () => {
515
+ const { emitEvent, calls } = makeEmitEvent();
516
+ const session = makeSession({
517
+ ensureAppReady: vi.fn().mockRejectedValue(
518
+ new AppBridgeDisconnectedError('app-disconnected'),
519
+ ),
520
+ });
521
+
522
+ await executeRun(session, [makeTest()], makeWatcher(), emitEvent, makeGlobalConfig());
523
+
524
+ expect(calls).toContainEqual([
525
+ 'test-file-failure',
526
+ expect.anything(),
527
+ expect.objectContaining({
528
+ message: expect.stringContaining('The app bridge disconnected during test execution.'),
529
+ stack: '',
530
+ }),
531
+ ]);
248
532
  });
249
533
 
250
534
  it('calls resetCrashState after a NativeCrashError', async () => {
@@ -254,7 +538,7 @@ describe('executeRun', () => {
254
538
  ),
255
539
  });
256
540
 
257
- await executeRun(session, [makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeGlobalConfig());
541
+ await executeRun(session, [makeTest()], makeWatcher(), makeEmitEvent().emitEvent, makeGlobalConfig());
258
542
 
259
543
  expect(session.resetCrashState).toHaveBeenCalled();
260
544
  });
@@ -268,7 +552,7 @@ describe('executeRun', () => {
268
552
  ensureAppReady: vi.fn().mockRejectedValue(new StartupStallError(1000, 2)),
269
553
  });
270
554
 
271
- await executeRun(session, [makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeGlobalConfig());
555
+ await executeRun(session, [makeTest()], makeWatcher(), makeEmitEvent().emitEvent, makeGlobalConfig());
272
556
 
273
557
  const payload = finishedPayload as { summary: { failed: number }; status: string };
274
558
  expect(payload.summary.failed).toBe(1);
@@ -282,31 +566,33 @@ describe('executeRun', () => {
282
566
  const session = makeSession({
283
567
  callHook: vi.fn(async (name) => { hookNames.push(name as string); }),
284
568
  });
285
- const onStart = vi.fn();
569
+ const { emitEvent, calls } = makeEmitEvent();
286
570
 
287
571
  await executeRun(
288
572
  session,
289
573
  [makeTest('/a.ts'), makeTest('/b.ts')],
290
574
  makeWatcher(true /* interrupted */),
291
- onStart,
292
- vi.fn(),
293
- vi.fn(),
575
+ emitEvent,
294
576
  makeGlobalConfig(),
295
577
  );
296
578
 
297
579
  // No test was started; watcher was already interrupted.
298
- expect(onStart).not.toHaveBeenCalled();
580
+ expect(calls).not.toContainEqual(['test-file-start', expect.anything()]);
299
581
  expect(hookNames).toContain('run:finished');
300
582
  });
301
583
  });
302
584
 
303
585
  describe('resetEnvironmentBetweenTestFiles', () => {
304
- it('calls restartApp before the second test but not the first', async () => {
586
+ it('calls resetEnvironment before the second test but not the first', async () => {
305
587
  const session = makeSession({
306
588
  config: {
307
589
  metroPort: 8081,
308
590
  resetEnvironmentBetweenTestFiles: true,
309
591
  detectNativeCrashes: false,
592
+ runners: [
593
+ { platformId: 'android', name: 'android' },
594
+ { platformId: 'ios', name: 'ios' },
595
+ ],
310
596
  } as HarnessSession['config'],
311
597
  });
312
598
 
@@ -314,12 +600,250 @@ describe('executeRun', () => {
314
600
  session,
315
601
  [makeTest('/a.ts'), makeTest('/b.ts'), makeTest('/c.ts')],
316
602
  makeWatcher(),
317
- vi.fn(), vi.fn(), vi.fn(),
603
+ makeEmitEvent().emitEvent,
604
+ makeGlobalConfig(),
605
+ );
606
+
607
+ // resetEnvironment should be called for tests 2 and 3, not test 1.
608
+ expect(session.resetEnvironment).toHaveBeenCalledTimes(2);
609
+ expect(session.restartApp).not.toHaveBeenCalled();
610
+ });
611
+
612
+ it('calls resetEnvironment with the "runtime" strategy configured', async () => {
613
+ const session = makeSession({
614
+ config: {
615
+ metroPort: 8081,
616
+ resetEnvironmentBetweenTestFiles: 'runtime',
617
+ detectNativeCrashes: false,
618
+ runners: [
619
+ { platformId: 'android', name: 'android' },
620
+ { platformId: 'ios', name: 'ios' },
621
+ ],
622
+ } as HarnessSession['config'],
623
+ });
624
+
625
+ await executeRun(
626
+ session,
627
+ [makeTest('/a.ts'), makeTest('/b.ts')],
628
+ makeWatcher(),
629
+ makeEmitEvent().emitEvent,
630
+ makeGlobalConfig(),
631
+ );
632
+
633
+ expect(session.resetEnvironment).toHaveBeenCalledTimes(1);
634
+ expect(session.resetEnvironment).toHaveBeenCalledWith('/b.ts');
635
+ });
636
+
637
+ it('does not call resetEnvironment when resetEnvironmentBetweenTestFiles is false', async () => {
638
+ const session = makeSession({
639
+ config: {
640
+ metroPort: 8081,
641
+ resetEnvironmentBetweenTestFiles: false,
642
+ detectNativeCrashes: false,
643
+ runners: [
644
+ { platformId: 'android', name: 'android' },
645
+ { platformId: 'ios', name: 'ios' },
646
+ ],
647
+ } as HarnessSession['config'],
648
+ });
649
+
650
+ await executeRun(
651
+ session,
652
+ [makeTest('/a.ts'), makeTest('/b.ts')],
653
+ makeWatcher(),
654
+ makeEmitEvent().emitEvent,
655
+ makeGlobalConfig(),
656
+ );
657
+
658
+ expect(session.resetEnvironment).not.toHaveBeenCalled();
659
+ });
660
+
661
+ it('restarts after a test case timeout before the next runnable file', async () => {
662
+ const timedOutResult = makeHarnessResult('failed');
663
+ timedOutResult.tests = [
664
+ {
665
+ name: 'hangs',
666
+ status: 'failed',
667
+ duration: 10,
668
+ error: {
669
+ name: 'TestCaseTimeoutError',
670
+ message: 'Test timed out after 10ms: hangs',
671
+ },
672
+ },
673
+ ];
674
+ mockRunHarnessTestFile
675
+ .mockResolvedValueOnce(makeFileRunResult({
676
+ harnessResult: timedOutResult,
677
+ jestResult: makeJestResult({
678
+ numFailingTests: 1,
679
+ numPassingTests: 0,
680
+ }),
681
+ }))
682
+ .mockResolvedValueOnce(makeFileRunResult());
683
+ const session = makeSession({
684
+ config: {
685
+ metroPort: 8081,
686
+ resetEnvironmentBetweenTestFiles: false,
687
+ detectNativeCrashes: false,
688
+ runners: [
689
+ { platformId: 'android', name: 'android' },
690
+ { platformId: 'ios', name: 'ios' },
691
+ ],
692
+ } as HarnessSession['config'],
693
+ });
694
+
695
+ await executeRun(
696
+ session,
697
+ [makeTest('/a.ts'), makeTest('/b.ts')],
698
+ makeWatcher(),
699
+ makeEmitEvent().emitEvent,
700
+ makeGlobalConfig(),
701
+ );
702
+
703
+ expect(session.restartApp).toHaveBeenCalledTimes(1);
704
+ expect(session.restartApp).toHaveBeenCalledWith('/a.ts');
705
+ });
706
+
707
+ it('restarts after a timeout in the last runnable file', async () => {
708
+ const timedOutResult = makeHarnessResult('failed');
709
+ timedOutResult.tests = [
710
+ {
711
+ name: 'hangs',
712
+ status: 'failed',
713
+ duration: 10,
714
+ error: {
715
+ name: 'TestCaseTimeoutError',
716
+ message: 'Test timed out after 10ms: hangs',
717
+ },
718
+ },
719
+ ];
720
+ mockRunHarnessTestFile.mockResolvedValueOnce(makeFileRunResult({
721
+ harnessResult: timedOutResult,
722
+ jestResult: makeJestResult({
723
+ numFailingTests: 1,
724
+ numPassingTests: 0,
725
+ }),
726
+ }));
727
+ const session = makeSession({
728
+ config: {
729
+ metroPort: 8081,
730
+ resetEnvironmentBetweenTestFiles: false,
731
+ detectNativeCrashes: false,
732
+ runners: [
733
+ { platformId: 'android', name: 'android' },
734
+ { platformId: 'ios', name: 'ios' },
735
+ ],
736
+ } as HarnessSession['config'],
737
+ });
738
+
739
+ await executeRun(
740
+ session,
741
+ [makeTest('/a.ts')],
742
+ makeWatcher(),
743
+ makeEmitEvent().emitEvent,
744
+ makeGlobalConfig({ watch: true }),
745
+ );
746
+
747
+ expect(session.restartApp).toHaveBeenCalledTimes(1);
748
+ expect(session.restartApp).toHaveBeenCalledWith('/a.ts');
749
+ });
750
+
751
+ it('restarts after a suite hook timeout', async () => {
752
+ const timedOutResult = makeHarnessResult('failed');
753
+ timedOutResult.suites = [
754
+ {
755
+ name: 'suite',
756
+ tests: [],
757
+ suites: [],
758
+ status: 'failed',
759
+ duration: 10,
760
+ error: {
761
+ name: 'SuiteHookTimeoutError',
762
+ message: 'beforeAll hook timed out after 10ms in suite: suite',
763
+ },
764
+ },
765
+ ];
766
+ mockRunHarnessTestFile.mockResolvedValueOnce(makeFileRunResult({
767
+ harnessResult: timedOutResult,
768
+ jestResult: makeJestResult({
769
+ numFailingTests: 1,
770
+ numPassingTests: 0,
771
+ }),
772
+ }));
773
+ const session = makeSession();
774
+
775
+ await executeRun(
776
+ session,
777
+ [makeTest('/a.ts')],
778
+ makeWatcher(),
779
+ makeEmitEvent().emitEvent,
780
+ makeGlobalConfig(),
781
+ );
782
+
783
+ expect(session.restartApp).toHaveBeenCalledTimes(1);
784
+ expect(session.restartApp).toHaveBeenCalledWith('/a.ts');
785
+ });
786
+ });
787
+
788
+ describe('platform-specific test files', () => {
789
+ it('skips files for other platforms without running them on the device', async () => {
790
+ const session = makeSession();
791
+ const { emitEvent, calls } = makeEmitEvent();
792
+
793
+ await executeRun(
794
+ session,
795
+ [
796
+ makeTest('/project/smoke.harness.ts'),
797
+ makeTest('/project/kotlin.android.harness.ts'),
798
+ makeTest('/project/swift.ios.harness.ts'),
799
+ ],
800
+ makeWatcher(),
801
+ emitEvent,
802
+ makeGlobalConfig(),
803
+ );
804
+
805
+ expect(mockRunHarnessTestFile).toHaveBeenCalledTimes(2);
806
+ expect(mockRunHarnessTestFile).toHaveBeenCalledWith(
807
+ expect.objectContaining({ testPath: '/project/smoke.harness.ts' }),
808
+ );
809
+ expect(mockRunHarnessTestFile).toHaveBeenCalledWith(
810
+ expect.objectContaining({ testPath: '/project/kotlin.android.harness.ts' }),
811
+ );
812
+ expect(session.ensureAppReady).toHaveBeenCalledTimes(2);
813
+ expect(calls).toContainEqual([
814
+ 'test-file-success',
815
+ expect.objectContaining({ path: '/project/swift.ios.harness.ts' }),
816
+ expect.objectContaining({ skipped: true }),
817
+ ]);
818
+ });
819
+
820
+ it('does not restart the app for skipped platform-specific files', async () => {
821
+ const session = makeSession({
822
+ config: {
823
+ metroPort: 8081,
824
+ resetEnvironmentBetweenTestFiles: true,
825
+ detectNativeCrashes: false,
826
+ runners: [
827
+ { platformId: 'android', name: 'android' },
828
+ { platformId: 'ios', name: 'ios' },
829
+ ],
830
+ } as HarnessSession['config'],
831
+ });
832
+
833
+ await executeRun(
834
+ session,
835
+ [
836
+ makeTest('/project/swift.ios.harness.ts'),
837
+ makeTest('/project/kotlin.android.harness.ts'),
838
+ ],
839
+ makeWatcher(),
840
+ vi.fn(),
318
841
  makeGlobalConfig(),
319
842
  );
320
843
 
321
- // restartApp should be called for tests 2 and 3, not test 1.
322
- expect(session.restartApp).toHaveBeenCalledTimes(2);
844
+ expect(session.restartApp).not.toHaveBeenCalled();
845
+ expect(session.resetEnvironment).not.toHaveBeenCalled();
846
+ expect(mockRunHarnessTestFile).toHaveBeenCalledTimes(1);
323
847
  });
324
848
  });
325
849
  });