@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
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { formatHarnessErrorMessage } from '../format-harness-error.js';
3
+
4
+ describe('formatHarnessErrorMessage', () => {
5
+ it('formats pending promise diagnostics for timeout errors', () => {
6
+ const message = formatHarnessErrorMessage(
7
+ {
8
+ name: 'TestCaseTimeoutError',
9
+ message: 'Test timed out after 50ms: suite hangs',
10
+ diagnostics: {
11
+ pendingPromises: {
12
+ total: 2,
13
+ items: [
14
+ {
15
+ id: 7,
16
+ createdAt: 110,
17
+ stack: 'Error: Promise created\n at hangs (example.ts:10:5)',
18
+ },
19
+ ],
20
+ },
21
+ },
22
+ },
23
+ { testStartedAt: 100 },
24
+ );
25
+
26
+ expect(message).toContain('Test timed out after 50ms: suite hangs');
27
+ expect(message).toContain('Pending promises at timeout: 2');
28
+ expect(message).toContain('Showing 1 of 2 pending promises.');
29
+ expect(message).toContain('Promise #7, created 10ms after test start:');
30
+ expect(message).toContain(' Error: Promise created');
31
+ });
32
+ });
@@ -0,0 +1,91 @@
1
+ import type { HarnessPlatform } from '@react-native-harness/platforms';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { maybeLogMetroCacheReused } from '../harness-session.js';
4
+
5
+ const { createHarnessCache, isWarm, logMetroCacheReused } = vi.hoisted(() => {
6
+ const isWarm = vi.fn();
7
+
8
+ return {
9
+ isWarm,
10
+ createHarnessCache: vi.fn((options: { projectRoot: string }) => ({
11
+ paths: {
12
+ root: `${options.projectRoot}/.harness/cache`,
13
+ metroTransform: `${options.projectRoot}/.harness/cache/metro`,
14
+ metroFileMap: `${options.projectRoot}/.harness/cache/metro-file-map`,
15
+ },
16
+ isWarm,
17
+ ensureDomainDirectories: vi.fn(),
18
+ })),
19
+ logMetroCacheReused: vi.fn(),
20
+ };
21
+ });
22
+
23
+ vi.mock('@react-native-harness/cache', () => ({
24
+ createHarnessCache,
25
+ }));
26
+
27
+ vi.mock('../logs.js', async (importOriginal) => ({
28
+ ...(await importOriginal<typeof import('../logs.js')>()),
29
+ logMetroCacheReused,
30
+ }));
31
+
32
+ const platform = { name: 'test-runner' } as HarnessPlatform;
33
+
34
+ describe('maybeLogMetroCacheReused', () => {
35
+ beforeEach(() => {
36
+ vi.clearAllMocks();
37
+ });
38
+
39
+ it('logs the reuse message when caching is enabled and the cache is warm', () => {
40
+ isWarm.mockReturnValue(true);
41
+
42
+ maybeLogMetroCacheReused({
43
+ config: { cache: { metro: true } },
44
+ projectRoot: '/tmp/app',
45
+ platform,
46
+ });
47
+
48
+ expect(createHarnessCache).toHaveBeenCalledWith({
49
+ projectRoot: '/tmp/app',
50
+ });
51
+ expect(isWarm).toHaveBeenCalledWith('metro');
52
+ expect(logMetroCacheReused).toHaveBeenCalledWith(platform);
53
+ });
54
+
55
+ it('logs the reuse message with a warm cache when caching is on by default', () => {
56
+ isWarm.mockReturnValue(true);
57
+
58
+ maybeLogMetroCacheReused({
59
+ config: {},
60
+ projectRoot: '/tmp/app',
61
+ platform,
62
+ });
63
+
64
+ expect(logMetroCacheReused).toHaveBeenCalledWith(platform);
65
+ });
66
+
67
+ it('does not log when caching is enabled but the cache is cold', () => {
68
+ isWarm.mockReturnValue(false);
69
+
70
+ maybeLogMetroCacheReused({
71
+ config: { cache: { metro: true } },
72
+ projectRoot: '/tmp/app',
73
+ platform,
74
+ });
75
+
76
+ expect(logMetroCacheReused).not.toHaveBeenCalled();
77
+ });
78
+
79
+ it('does not log when caching is disabled, regardless of cache state', () => {
80
+ isWarm.mockReturnValue(true);
81
+
82
+ maybeLogMetroCacheReused({
83
+ config: { cache: { metro: false } },
84
+ projectRoot: '/tmp/app',
85
+ platform,
86
+ });
87
+
88
+ expect(createHarnessCache).not.toHaveBeenCalled();
89
+ expect(logMetroCacheReused).not.toHaveBeenCalled();
90
+ });
91
+ });
@@ -0,0 +1,303 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import type { AppConnection } from '@react-native-harness/bridge/server';
3
+ import {
4
+ getSignalExitCodeForRunState,
5
+ waitForBridgeDisconnectOrTimeout,
6
+ waitForNextConnected,
7
+ waitForStartupCrash,
8
+ withPlatformReadyTimeout,
9
+ type HarnessRunState,
10
+ } from '../harness-session.js';
11
+ import type { CrashMonitor } from '../crash-monitor.js';
12
+ import { PlatformReadyTimeoutError } from '../errors.js';
13
+
14
+ const createConnection = (): AppConnection => ({
15
+ device: {
16
+ platform: 'ios',
17
+ manufacturer: 'Apple',
18
+ model: 'iPhone',
19
+ osVersion: '18.0',
20
+ },
21
+ runTests: vi.fn(),
22
+ resetEnvironment: vi.fn(),
23
+ });
24
+
25
+ const createBridge = (connection: AppConnection | null) => {
26
+ let currentConnection = connection;
27
+ let disconnectedListener: (() => void) | null = null;
28
+
29
+ return {
30
+ get connection() {
31
+ return currentConnection;
32
+ },
33
+ on: vi.fn((event: 'disconnected', listener: () => void) => {
34
+ if (event === 'disconnected') {
35
+ disconnectedListener = listener;
36
+ }
37
+ }),
38
+ off: vi.fn((event: 'disconnected', listener: () => void) => {
39
+ if (event === 'disconnected' && disconnectedListener === listener) {
40
+ disconnectedListener = null;
41
+ }
42
+ }),
43
+ disconnect: () => {
44
+ currentConnection = null;
45
+ disconnectedListener?.();
46
+ },
47
+ };
48
+ };
49
+
50
+ describe('waitForBridgeDisconnectOrTimeout', () => {
51
+ it('returns true when the bridge disconnects before the timeout', async () => {
52
+ const connection = createConnection();
53
+ const bridge = createBridge(connection);
54
+
55
+ const waitPromise = waitForBridgeDisconnectOrTimeout({
56
+ bridge,
57
+ connection,
58
+ timeoutMs: 50,
59
+ });
60
+
61
+ bridge.disconnect();
62
+
63
+ await expect(waitPromise).resolves.toBe(true);
64
+ });
65
+
66
+ it('returns false when the bridge stays connected through the timeout', async () => {
67
+ const connection = createConnection();
68
+ const bridge = createBridge(connection);
69
+
70
+ await expect(
71
+ waitForBridgeDisconnectOrTimeout({
72
+ bridge,
73
+ connection,
74
+ timeoutMs: 10,
75
+ }),
76
+ ).resolves.toBe(false);
77
+ });
78
+ });
79
+
80
+ describe('waitForNextConnected', () => {
81
+ const createConnectableBridge = () => {
82
+ let listener: (() => void) | null = null;
83
+ const bridge: Pick<
84
+ import('@react-native-harness/bridge/server').HarnessBridge,
85
+ 'on' | 'off'
86
+ > = {
87
+ on: vi.fn((event, l) => {
88
+ if (event === 'connected') listener = l as () => void;
89
+ }),
90
+ off: vi.fn((event, l) => {
91
+ if (event === 'connected' && listener === l) listener = null;
92
+ }),
93
+ };
94
+ return { bridge, emitConnected: () => listener?.() };
95
+ };
96
+
97
+ it('resolves when the bridge emits a connected event', async () => {
98
+ const { bridge, emitConnected } = createConnectableBridge();
99
+ const controller = new AbortController();
100
+
101
+ const waitPromise = waitForNextConnected({ bridge, signal: controller.signal });
102
+ emitConnected();
103
+
104
+ await expect(waitPromise).resolves.toBeUndefined();
105
+ });
106
+
107
+ it('rejects when the signal aborts before a connected event', async () => {
108
+ const { bridge } = createConnectableBridge();
109
+ const controller = new AbortController();
110
+
111
+ const waitPromise = waitForNextConnected({ bridge, signal: controller.signal });
112
+ controller.abort(new DOMException('Aborted', 'AbortError'));
113
+
114
+ await expect(waitPromise).rejects.toThrow('Aborted');
115
+ });
116
+
117
+ it('rejects immediately when the signal is already aborted', async () => {
118
+ const { bridge } = createConnectableBridge();
119
+ const controller = new AbortController();
120
+ controller.abort(new DOMException('Aborted', 'AbortError'));
121
+
122
+ await expect(
123
+ waitForNextConnected({ bridge, signal: controller.signal }),
124
+ ).rejects.toThrow('Aborted');
125
+ });
126
+
127
+ it('unsubscribes the connected listener once settled', async () => {
128
+ const { bridge, emitConnected } = createConnectableBridge();
129
+ const controller = new AbortController();
130
+
131
+ const waitPromise = waitForNextConnected({ bridge, signal: controller.signal });
132
+ emitConnected();
133
+ await waitPromise;
134
+
135
+ expect(bridge.off).toHaveBeenCalledOnce();
136
+ });
137
+ });
138
+
139
+ describe('waitForStartupCrash', () => {
140
+ it('does not install a startup crash watch when native crash detection is disabled', async () => {
141
+ const watch = vi.fn();
142
+ const crashMonitor = {
143
+ watch,
144
+ } as unknown as CrashMonitor;
145
+ const controller = new AbortController();
146
+ const waitPromise = waitForStartupCrash({
147
+ crashMonitor,
148
+ detectNativeCrashes: false,
149
+ testFilePath: '/test.harness.ts',
150
+ signal: controller.signal,
151
+ });
152
+
153
+ controller.abort(new DOMException('Aborted', 'AbortError'));
154
+
155
+ await expect(waitPromise).rejects.toThrow('Aborted');
156
+ expect(watch).not.toHaveBeenCalled();
157
+ });
158
+
159
+ it('removes its abort listener on the session signal once the crash watch wins', async () => {
160
+ let rejectWatch!: (error: unknown) => void;
161
+ const cancel = vi.fn();
162
+ const watch = vi.fn(() => ({
163
+ promise: new Promise<never>((_, reject) => {
164
+ rejectWatch = reject;
165
+ }),
166
+ cancel,
167
+ }));
168
+ const crashMonitor = { watch } as unknown as CrashMonitor;
169
+ const controller = new AbortController();
170
+ const addSpy = vi.spyOn(controller.signal, 'addEventListener');
171
+ const removeSpy = vi.spyOn(controller.signal, 'removeEventListener');
172
+
173
+ const waitPromise = waitForStartupCrash({
174
+ crashMonitor,
175
+ detectNativeCrashes: true,
176
+ testFilePath: '/test.harness.ts',
177
+ signal: controller.signal,
178
+ });
179
+
180
+ // The crash watch wins the race, not the session signal.
181
+ rejectWatch(new Error('native crash detected'));
182
+
183
+ await expect(waitPromise).rejects.toThrow('native crash detected');
184
+ expect(cancel).toHaveBeenCalledTimes(1);
185
+
186
+ // Regression: forgetting to cancel the losing waitForAbort() left its
187
+ // listener attached to this session-lifetime signal for the rest of
188
+ // the session — one leaked listener per test file.
189
+ expect(addSpy).toHaveBeenCalledTimes(1);
190
+ expect(removeSpy).toHaveBeenCalledTimes(1);
191
+ });
192
+ });
193
+
194
+ describe('withPlatformReadyTimeout', () => {
195
+ afterEach(() => {
196
+ vi.useRealTimers();
197
+ });
198
+
199
+ it('passes the real session signal to work, unmodified by the timeout', async () => {
200
+ const controller = new AbortController();
201
+ const work = vi.fn(async (signal: AbortSignal) => {
202
+ expect(signal).toBe(controller.signal);
203
+ return 'ready';
204
+ });
205
+
206
+ await expect(
207
+ withPlatformReadyTimeout({
208
+ timeout: 1_000,
209
+ signal: controller.signal,
210
+ work,
211
+ }),
212
+ ).resolves.toBe('ready');
213
+ expect(work).toHaveBeenCalledTimes(1);
214
+ });
215
+
216
+ it('throws PlatformReadyTimeoutError on timeout without aborting the session signal', async () => {
217
+ vi.useFakeTimers();
218
+ const controller = new AbortController();
219
+ // Never resolves within the test; simulates a hung platform init.
220
+ const work = () => new Promise<void>(() => undefined);
221
+
222
+ const resultPromise = withPlatformReadyTimeout({
223
+ timeout: 1_000,
224
+ signal: controller.signal,
225
+ work,
226
+ });
227
+
228
+ await vi.advanceTimersByTimeAsync(1_000);
229
+
230
+ await expect(resultPromise).rejects.toBeInstanceOf(PlatformReadyTimeoutError);
231
+ expect(controller.signal.aborted).toBe(false);
232
+ });
233
+
234
+ it('propagates a genuine abort from the session signal as-is', async () => {
235
+ const controller = new AbortController();
236
+ const abortReason = new DOMException('Aborted', 'AbortError');
237
+ const work = (signal: AbortSignal) =>
238
+ new Promise<never>((_, reject) => {
239
+ signal.addEventListener('abort', () => reject(signal.reason));
240
+ });
241
+
242
+ const resultPromise = withPlatformReadyTimeout({
243
+ timeout: 1_000,
244
+ signal: controller.signal,
245
+ work,
246
+ });
247
+
248
+ controller.abort(abortReason);
249
+
250
+ await expect(resultPromise).rejects.toBe(abortReason);
251
+ });
252
+ });
253
+
254
+ describe('getSignalExitCodeForRunState', () => {
255
+ const createRunState = (
256
+ overrides: Partial<HarnessRunState> = {}
257
+ ): HarnessRunState => ({
258
+ completed: true,
259
+ coverageEnabled: false,
260
+ runId: 'run-1',
261
+ startTime: 0,
262
+ status: 'passed',
263
+ summary: {
264
+ failed: 0,
265
+ passed: 1,
266
+ skipped: 0,
267
+ todo: 0,
268
+ },
269
+ testFiles: ['test.harness.ts'],
270
+ watchMode: false,
271
+ ...overrides,
272
+ });
273
+
274
+ it('preserves a successful exit after a completed passing run', () => {
275
+ expect(getSignalExitCodeForRunState(createRunState())).toBe(0);
276
+ });
277
+
278
+ it('fails when the run has not completed yet', () => {
279
+ expect(
280
+ getSignalExitCodeForRunState(createRunState({ completed: false }))
281
+ ).toBe(1);
282
+ });
283
+
284
+ it('fails when the completed run has failures', () => {
285
+ expect(
286
+ getSignalExitCodeForRunState(
287
+ createRunState({
288
+ status: 'failed',
289
+ summary: {
290
+ failed: 1,
291
+ passed: 0,
292
+ skipped: 0,
293
+ todo: 0,
294
+ },
295
+ })
296
+ )
297
+ ).toBe(1);
298
+ });
299
+
300
+ it('fails when no run state is available', () => {
301
+ expect(getSignalExitCodeForRunState(null)).toBe(1);
302
+ });
303
+ });
@@ -1,8 +1,9 @@
1
1
  import { describe, expect, it, vi, beforeEach } from 'vitest';
2
- import type { Config, Test, TestRunnerOptions, TestWatcher } from 'jest-runner';
2
+ import type { Config, Test, TestEvents, TestRunnerOptions, TestWatcher } from 'jest-runner';
3
3
  import type { TestSuiteResult } from '@react-native-harness/bridge';
4
4
  import type { HarnessSession } from '../harness-session.js';
5
- import { HarnessError } from '@react-native-harness/tools';
5
+ import type { executeRun as ExecuteRun } from '../execute-run.js';
6
+ import { createDiagnostics, HarnessError } from '@react-native-harness/tools';
6
7
  import JestHarness from '../index.js';
7
8
 
8
9
  const resolveUndefined = async () => undefined;
@@ -14,6 +15,8 @@ const resolveUndefined = async () => undefined;
14
15
  const mockSession: HarnessSession = {
15
16
  config: { metroPort: 8081 } as HarnessSession['config'],
16
17
  context: {} as HarnessSession['context'],
18
+ diagnostics: createDiagnostics({ enabled: false }),
19
+ onTestRunnerEvent: vi.fn(() => () => undefined),
17
20
  ensureAppReady: vi.fn(resolveUndefined),
18
21
  runTestFile: vi.fn(async (): Promise<TestSuiteResult> => ({
19
22
  name: '',
@@ -23,6 +26,7 @@ const mockSession: HarnessSession = {
23
26
  duration: 0,
24
27
  })),
25
28
  restartApp: vi.fn(resolveUndefined),
29
+ resetEnvironment: vi.fn(resolveUndefined),
26
30
  resetCrashState: vi.fn(),
27
31
  flushClientLogs: vi.fn(() => []),
28
32
  callHook: vi.fn(resolveUndefined),
@@ -55,6 +59,41 @@ const makeTest = (): Test => ({
55
59
  const makeGlobalConfig = (overrides: Partial<Config.GlobalConfig> = {}): Config.GlobalConfig =>
56
60
  ({ rootDir: '/project', watch: false, watchAll: false, collectCoverage: false, ...overrides } as Config.GlobalConfig);
57
61
 
62
+ type ExecuteRunMock = typeof ExecuteRun;
63
+ type TypedExecuteRunMock = typeof mockExecuteRun & {
64
+ mockImplementationOnce: (implementation: ExecuteRunMock) => typeof mockExecuteRun;
65
+ mockResolvedValue: (value: Awaited<ReturnType<ExecuteRunMock>>) => typeof mockExecuteRun;
66
+ };
67
+ type ExecuteRunParams = Parameters<ExecuteRunMock>;
68
+
69
+ const typedMockExecuteRun = mockExecuteRun as TypedExecuteRunMock;
70
+
71
+ const makeTestCaseStartPayload = (): TestEvents['test-case-start'] => [
72
+ '/test/example.ts',
73
+ {
74
+ ancestorTitles: [],
75
+ fullName: 'suite case',
76
+ mode: undefined,
77
+ title: 'case',
78
+ startedAt: 1,
79
+ },
80
+ ];
81
+
82
+ const makeTestCaseResultPayload = (): TestEvents['test-case-result'] => [
83
+ '/test/example.ts',
84
+ {
85
+ ancestorTitles: [],
86
+ duration: 1,
87
+ failureDetails: [],
88
+ failureMessages: [],
89
+ fullName: 'suite case',
90
+ numPassingAsserts: 1,
91
+ startedAt: 1,
92
+ status: 'passed',
93
+ title: 'case',
94
+ },
95
+ ];
96
+
58
97
  // ---------------------------------------------------------------------------
59
98
  // Tests
60
99
  // ---------------------------------------------------------------------------
@@ -62,7 +101,7 @@ const makeGlobalConfig = (overrides: Partial<Config.GlobalConfig> = {}): Config.
62
101
  beforeEach(() => {
63
102
  vi.clearAllMocks();
64
103
  (mockSession.dispose as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
65
- mockExecuteRun.mockResolvedValue(undefined);
104
+ typedMockExecuteRun.mockResolvedValue(undefined);
66
105
  mockCreateHarnessSession.mockResolvedValue(mockSession);
67
106
  });
68
107
 
@@ -71,7 +110,7 @@ describe('JestHarness', () => {
71
110
  it('creates a session on the first run', async () => {
72
111
  const runner = new JestHarness(makeGlobalConfig());
73
112
 
74
- await runner.runTests([makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeOptions());
113
+ await runner.runTests([makeTest()], makeWatcher(), makeOptions());
75
114
 
76
115
  expect(mockCreateHarnessSession).toHaveBeenCalledOnce();
77
116
  });
@@ -79,8 +118,8 @@ describe('JestHarness', () => {
79
118
  it('reuses the session across runs in watch mode', async () => {
80
119
  const runner = new JestHarness(makeGlobalConfig({ watch: true }));
81
120
 
82
- await runner.runTests([makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeOptions());
83
- await runner.runTests([makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeOptions());
121
+ await runner.runTests([makeTest()], makeWatcher(), makeOptions());
122
+ await runner.runTests([makeTest()], makeWatcher(), makeOptions());
84
123
 
85
124
  expect(mockCreateHarnessSession).toHaveBeenCalledOnce();
86
125
  expect(mockSession.dispose).not.toHaveBeenCalled();
@@ -89,7 +128,7 @@ describe('JestHarness', () => {
89
128
  it('disposes the session after each run in normal mode', async () => {
90
129
  const runner = new JestHarness(makeGlobalConfig());
91
130
 
92
- await runner.runTests([makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeOptions());
131
+ await runner.runTests([makeTest()], makeWatcher(), makeOptions());
93
132
 
94
133
  expect(mockSession.dispose).toHaveBeenCalledOnce();
95
134
  });
@@ -97,11 +136,52 @@ describe('JestHarness', () => {
97
136
  it('creates a fresh session for each run in normal mode', async () => {
98
137
  const runner = new JestHarness(makeGlobalConfig());
99
138
 
100
- await runner.runTests([makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeOptions());
101
- await runner.runTests([makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeOptions());
139
+ await runner.runTests([makeTest()], makeWatcher(), makeOptions());
140
+ await runner.runTests([makeTest()], makeWatcher(), makeOptions());
102
141
 
103
142
  expect(mockCreateHarnessSession).toHaveBeenCalledTimes(2);
104
143
  });
144
+
145
+ it('emits registered Jest events', async () => {
146
+ const runner = new JestHarness(makeGlobalConfig());
147
+ const listener = vi.fn();
148
+ const eventPayload = makeTestCaseStartPayload();
149
+
150
+ runner.on('test-case-start', listener);
151
+ typedMockExecuteRun.mockImplementationOnce(async (
152
+ _session: ExecuteRunParams[0],
153
+ _tests: ExecuteRunParams[1],
154
+ _watcher: ExecuteRunParams[2],
155
+ emitEvent: ExecuteRunParams[3],
156
+ ) => {
157
+ await emitEvent('test-case-start', ...eventPayload);
158
+ });
159
+
160
+ await runner.runTests([makeTest()], makeWatcher(), makeOptions());
161
+
162
+ expect(listener).toHaveBeenCalledWith(eventPayload);
163
+ });
164
+
165
+ it('stops emitting after listener unsubscribe', async () => {
166
+ const runner = new JestHarness(makeGlobalConfig());
167
+ const listener = vi.fn();
168
+ const unsubscribe = runner.on('test-case-result', listener);
169
+ const eventPayload = makeTestCaseResultPayload();
170
+
171
+ unsubscribe();
172
+ typedMockExecuteRun.mockImplementationOnce(async (
173
+ _session: ExecuteRunParams[0],
174
+ _tests: ExecuteRunParams[1],
175
+ _watcher: ExecuteRunParams[2],
176
+ emitEvent: ExecuteRunParams[3],
177
+ ) => {
178
+ await emitEvent('test-case-result', ...eventPayload);
179
+ });
180
+
181
+ await runner.runTests([makeTest()], makeWatcher(), makeOptions());
182
+
183
+ expect(listener).not.toHaveBeenCalled();
184
+ });
105
185
  });
106
186
 
107
187
  describe('error handling', () => {
@@ -115,7 +195,7 @@ describe('JestHarness', () => {
115
195
  const runner = new JestHarness(makeGlobalConfig());
116
196
 
117
197
  await expect(
118
- runner.runTests([makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeOptions()),
198
+ runner.runTests([makeTest()], makeWatcher(), makeOptions()),
119
199
  ).rejects.toBeTypeOf('string');
120
200
  });
121
201
 
@@ -126,7 +206,7 @@ describe('JestHarness', () => {
126
206
  const runner = new JestHarness(makeGlobalConfig());
127
207
 
128
208
  await expect(
129
- runner.runTests([makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeOptions()),
209
+ runner.runTests([makeTest()], makeWatcher(), makeOptions()),
130
210
  ).rejects.toBe(cause);
131
211
  });
132
212
 
@@ -134,7 +214,7 @@ describe('JestHarness', () => {
134
214
  const runner = new JestHarness(makeGlobalConfig());
135
215
 
136
216
  await expect(
137
- runner.runTests([makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), { serial: false }),
217
+ runner.runTests([makeTest()], makeWatcher(), { serial: false }),
138
218
  ).rejects.toThrow('Parallel test running is not supported');
139
219
  });
140
220
  });
@@ -22,11 +22,13 @@ const createConfig = (overrides: Partial<HarnessConfig> = {}): HarnessConfig =>
22
22
  disableViewFlattening: false,
23
23
  entryPoint: 'index.js',
24
24
  forwardClientLogs: false,
25
+ diagnostics: false,
25
26
  maxAppRestarts: 2,
26
27
  metroPort: 8081,
27
28
  platformReadyTimeout: 300_000,
28
29
  resetEnvironmentBetweenTestFiles: true,
29
30
  runners: [],
31
+ testTimeout: 5_000,
30
32
  unstable__enableMetroCache: false,
31
33
  unstable__skipAlreadyIncludedModules: false,
32
34
  ...overrides,
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { Config as JestConfig } from 'jest-runner';
3
+ import type { TestSuiteResult } from '@react-native-harness/bridge';
4
+ import type { HarnessSession } from '../harness-session.js';
5
+ import { runHarnessTestFile } from '../run.js';
6
+
7
+ vi.mock('jest-message-util', () => ({
8
+ formatResultsErrors: vi.fn(() => ''),
9
+ }));
10
+
11
+ const createHarnessResult = (): TestSuiteResult => ({
12
+ name: 'root',
13
+ tests: [],
14
+ suites: [],
15
+ status: 'passed',
16
+ duration: 0,
17
+ });
18
+
19
+ const createSession = (testTimeout: number): HarnessSession =>
20
+ ({
21
+ config: { testTimeout },
22
+ context: {
23
+ platform: {
24
+ runner: './runner.js',
25
+ },
26
+ },
27
+ runTestFile: vi.fn(async () => createHarnessResult()),
28
+ }) as unknown as HarnessSession;
29
+
30
+ const createGlobalConfig = (
31
+ overrides: Partial<JestConfig.GlobalConfig> = {},
32
+ ): JestConfig.GlobalConfig =>
33
+ ({
34
+ rootDir: '/project',
35
+ ...overrides,
36
+ }) as JestConfig.GlobalConfig;
37
+
38
+ const createProjectConfig = (
39
+ overrides: Partial<JestConfig.ProjectConfig> = {},
40
+ ): JestConfig.ProjectConfig => overrides as JestConfig.ProjectConfig;
41
+
42
+ describe('runHarnessTestFile', () => {
43
+ it('uses Harness config testTimeout when Jest config does not set one', async () => {
44
+ const session = createSession(15000);
45
+
46
+ await runHarnessTestFile({
47
+ testPath: '/project/example.harness.ts',
48
+ session,
49
+ globalConfig: createGlobalConfig(),
50
+ projectConfig: createProjectConfig(),
51
+ });
52
+
53
+ expect(session.runTestFile).toHaveBeenCalledWith(
54
+ 'example.harness.ts',
55
+ expect.objectContaining({ testTimeout: 15000 }),
56
+ );
57
+ });
58
+
59
+ it('keeps Harness config testTimeout above Jest config testTimeout', async () => {
60
+ const session = createSession(15000);
61
+
62
+ await runHarnessTestFile({
63
+ testPath: '/project/example.harness.ts',
64
+ session,
65
+ globalConfig: createGlobalConfig({ testTimeout: 20000 } as never),
66
+ projectConfig: createProjectConfig({ testTimeout: 30000 } as never),
67
+ });
68
+
69
+ expect(session.runTestFile).toHaveBeenCalledWith(
70
+ 'example.harness.ts',
71
+ expect.objectContaining({ testTimeout: 15000 }),
72
+ );
73
+ });
74
+ });