@react-native-harness/jest 1.1.0-rc.1 → 1.1.0-rc.3

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 (58) hide show
  1. package/dist/__tests__/errors.test.js +6 -1
  2. package/dist/__tests__/harness-cache.test.d.ts +2 -0
  3. package/dist/__tests__/harness-cache.test.d.ts.map +1 -0
  4. package/dist/__tests__/harness-cache.test.js +51 -0
  5. package/dist/__tests__/harness.test.js +484 -47
  6. package/dist/__tests__/index.test.d.ts +2 -0
  7. package/dist/__tests__/index.test.d.ts.map +1 -0
  8. package/dist/__tests__/index.test.js +42 -0
  9. package/dist/__tests__/resource-lock.test.d.ts +2 -0
  10. package/dist/__tests__/resource-lock.test.d.ts.map +1 -0
  11. package/dist/__tests__/resource-lock.test.js +103 -0
  12. package/dist/action-hooks.d.ts +9 -0
  13. package/dist/action-hooks.d.ts.map +1 -0
  14. package/dist/action-hooks.js +36 -0
  15. package/dist/cli-args.d.ts +1 -0
  16. package/dist/cli-args.d.ts.map +1 -1
  17. package/dist/cli-args.js +5 -0
  18. package/dist/crash-supervisor.d.ts.map +1 -1
  19. package/dist/crash-supervisor.js +7 -6
  20. package/dist/errors.d.ts +5 -0
  21. package/dist/errors.d.ts.map +1 -1
  22. package/dist/errors.js +10 -2
  23. package/dist/harness.d.ts +23 -1
  24. package/dist/harness.d.ts.map +1 -1
  25. package/dist/harness.js +574 -136
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +205 -58
  28. package/dist/logs.d.ts +4 -0
  29. package/dist/logs.d.ts.map +1 -1
  30. package/dist/logs.js +12 -0
  31. package/dist/resource-lock.d.ts +25 -0
  32. package/dist/resource-lock.d.ts.map +1 -0
  33. package/dist/resource-lock.js +278 -0
  34. package/dist/run.d.ts +7 -1
  35. package/dist/run.d.ts.map +1 -1
  36. package/dist/run.js +13 -8
  37. package/dist/setup.d.ts.map +1 -1
  38. package/dist/setup.js +29 -2
  39. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  40. package/package.json +7 -6
  41. package/src/__tests__/errors.test.ts +18 -6
  42. package/src/__tests__/harness-cache.test.ts +74 -0
  43. package/src/__tests__/harness.test.ts +702 -49
  44. package/src/__tests__/index.test.ts +61 -0
  45. package/src/__tests__/resource-lock.test.ts +138 -0
  46. package/src/action-hooks.ts +66 -0
  47. package/src/cli-args.ts +6 -0
  48. package/src/crash-supervisor.ts +13 -6
  49. package/src/env.d.ts +1 -1
  50. package/src/errors.ts +15 -4
  51. package/src/harness.ts +743 -157
  52. package/src/index.ts +276 -64
  53. package/src/logs.ts +17 -3
  54. package/src/resource-lock.ts +450 -0
  55. package/src/run.ts +20 -10
  56. package/src/setup.ts +42 -1
  57. package/tsconfig.json +3 -3
  58. package/tsconfig.lib.json +6 -6
@@ -1,70 +1,285 @@
1
1
  import { EventEmitter } from 'node:events';
2
- import { describe, expect, it, vi } from 'vitest';
3
- import { waitForAppReady } from '../harness.js';
4
- import type { BridgeServer } from '@react-native-harness/bridge/server';
2
+ import { HARNESS_BRIDGE_PATH } from '@react-native-harness/bridge';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import type { Config as HarnessConfig } from '@react-native-harness/config';
5
+ import { definePlugin } from '@react-native-harness/plugins';
5
6
  import type {
6
7
  AppMonitor,
8
+ AppMonitorEvent,
7
9
  AppMonitorListener,
10
+ HarnessPlatform,
8
11
  HarnessPlatformRunner,
9
12
  } from '@react-native-harness/platforms';
13
+ import type { BridgeServer } from '@react-native-harness/bridge/server';
14
+ import type {
15
+ MetroInstance,
16
+ Reporter,
17
+ ReportableEvent,
18
+ WaitForMetroBackedAppReadyOptions,
19
+ } from '@react-native-harness/bundler-metro';
10
20
  import { createCrashSupervisor } from '../crash-supervisor.js';
11
21
 
22
+ const mocks = vi.hoisted(() => ({
23
+ createCrashArtifactWriter: vi.fn(() => ({})),
24
+ getBridgeServer: vi.fn(),
25
+ getMetroInstance: vi.fn(),
26
+ isMetroCacheReusable: vi.fn(() => false),
27
+ logMetroCacheReused: vi.fn(),
28
+ logRunnerStarting: vi.fn(),
29
+ logRunnerStillWaitingInQueue: vi.fn(),
30
+ logRunnerWaitingInQueue: vi.fn(),
31
+ waitForMetroBackedAppReady: vi.fn(),
32
+ }));
33
+
34
+ vi.mock('@react-native-harness/bundler-metro', async () => {
35
+ const actual = await vi.importActual<
36
+ typeof import('@react-native-harness/bundler-metro')
37
+ >('@react-native-harness/bundler-metro');
38
+
39
+ return {
40
+ ...actual,
41
+ getMetroInstance: mocks.getMetroInstance,
42
+ isMetroCacheReusable: mocks.isMetroCacheReusable,
43
+ waitForMetroBackedAppReady: mocks.waitForMetroBackedAppReady,
44
+ };
45
+ });
46
+
47
+ vi.mock('@react-native-harness/bridge/server', () => ({
48
+ getBridgeServer: mocks.getBridgeServer,
49
+ }));
50
+
51
+ vi.mock('../logs.js', () => ({
52
+ logMetroCacheReused: mocks.logMetroCacheReused,
53
+ logRunnerStarting: mocks.logRunnerStarting,
54
+ logRunnerStillWaitingInQueue: mocks.logRunnerStillWaitingInQueue,
55
+ logRunnerWaitingInQueue: mocks.logRunnerWaitingInQueue,
56
+ }));
57
+
58
+ vi.mock('@react-native-harness/tools', async () => {
59
+ const actual = await vi.importActual<
60
+ typeof import('@react-native-harness/tools')
61
+ >('@react-native-harness/tools');
62
+
63
+ return {
64
+ ...actual,
65
+ createCrashArtifactWriter: mocks.createCrashArtifactWriter,
66
+ };
67
+ });
68
+
69
+ import { getHarness, waitForAppReady } from '../harness.js';
70
+ import { PlatformReadyTimeoutError, StartupStallError } from '../errors.js';
71
+
12
72
  const createBridgeServer = () => {
13
73
  const emitter = new EventEmitter();
14
74
 
15
75
  return {
16
76
  serverBridge: {
77
+ ws: {} as BridgeServer['ws'],
78
+ rpc: {
79
+ clients: [],
80
+ },
17
81
  on: emitter.on.bind(emitter),
18
82
  once: emitter.once.bind(emitter),
19
83
  off: emitter.off.bind(emitter),
84
+ dispose: vi.fn(),
20
85
  } as unknown as BridgeServer,
21
86
  emitReady: () => {
22
- emitter.emit('ready');
87
+ emitter.emit('ready', {
88
+ platform: 'ios',
89
+ manufacturer: 'Apple',
90
+ model: 'Simulator',
91
+ osVersion: '18.0',
92
+ });
93
+ },
94
+ emitEvent: (event: unknown) => {
95
+ emitter.emit('event', event);
96
+ },
97
+ emitDisconnect: () => {
98
+ emitter.emit('disconnect');
23
99
  },
24
100
  };
25
101
  };
26
102
 
27
- const createAppMonitor = (): AppMonitor => {
28
- const listeners = new Set<AppMonitorListener>();
103
+ const createReporter = (): Reporter => {
104
+ const listeners = new Set<(event: ReportableEvent) => void>();
29
105
 
30
106
  return {
31
- start: async () => undefined,
32
- stop: async () => undefined,
33
- dispose: async () => undefined,
34
107
  addListener: (listener) => {
35
108
  listeners.add(listener);
36
109
  },
37
110
  removeListener: (listener) => {
38
111
  listeners.delete(listener);
39
112
  },
113
+ emit: (event) => {
114
+ listeners.forEach((listener) => listener(event));
115
+ },
116
+ clearAllListeners: () => {
117
+ listeners.clear();
118
+ },
119
+ };
120
+ };
121
+
122
+ const createMetroInstance = (
123
+ overrides: Partial<MetroInstance> = {}
124
+ ): MetroInstance => ({
125
+ events: createReporter(),
126
+ httpServer: {} as never,
127
+ websocketEndpoints: {},
128
+ waitUntilHealthy: vi.fn(async () => 'HTTP 200: packager-status:running'),
129
+ prewarm: vi.fn(async () => false),
130
+ dispose: vi.fn(async () => undefined),
131
+ ...overrides,
132
+ });
133
+
134
+ const createAppMonitor = (): {
135
+ appMonitor: AppMonitor;
136
+ emit: (event: AppMonitorEvent) => void;
137
+ } => {
138
+ const listeners = new Set<AppMonitorListener>();
139
+
140
+ return {
141
+ appMonitor: {
142
+ start: vi.fn(async () => undefined),
143
+ stop: vi.fn(async () => undefined),
144
+ dispose: vi.fn(async () => undefined),
145
+ addListener: (listener) => {
146
+ listeners.add(listener);
147
+ },
148
+ removeListener: (listener) => {
149
+ listeners.delete(listener);
150
+ },
151
+ },
152
+ emit: (event) => {
153
+ for (const listener of listeners) {
154
+ listener(event);
155
+ }
156
+ },
40
157
  };
41
158
  };
42
159
 
43
160
  const createPlatformRunner = (
44
- restartApp: HarnessPlatformRunner['restartApp']
161
+ overrides: Partial<HarnessPlatformRunner> = {}
45
162
  ): HarnessPlatformRunner => ({
46
- startApp: async () => undefined,
47
- restartApp,
48
- stopApp: async () => undefined,
49
- dispose: async () => undefined,
50
- isAppRunning: async () => true,
51
- createAppMonitor: createAppMonitor,
163
+ startApp: vi.fn(async () => undefined),
164
+ restartApp: vi.fn(async () => undefined),
165
+ stopApp: vi.fn(async () => undefined),
166
+ dispose: vi.fn(async () => undefined),
167
+ isAppRunning: vi.fn(async () => true),
168
+ createAppMonitor: () => createAppMonitor().appMonitor,
169
+ ...overrides,
170
+ });
171
+
172
+ const createHarnessConfig = (
173
+ overrides: Partial<HarnessConfig> = {}
174
+ ): HarnessConfig =>
175
+ ({
176
+ appRegistryComponentName: 'App',
177
+ bridgeTimeout: 60_000,
178
+ bundleStartTimeout: 60_000,
179
+ crashDetectionInterval: 500,
180
+ defaultRunner: 'ios',
181
+ detectNativeCrashes: true,
182
+ disableViewFlattening: false,
183
+ entryPoint: 'index.js',
184
+ forwardClientLogs: false,
185
+ maxAppRestarts: 2,
186
+ metroPort: 8081,
187
+ platformReadyTimeout: 300_000,
188
+ resetEnvironmentBetweenTestFiles: true,
189
+ runners: [],
190
+ unstable__enableMetroCache: false,
191
+ unstable__skipAlreadyIncludedModules: false,
192
+ webSocketPort: 3001,
193
+ ...overrides,
194
+ } as HarnessConfig);
195
+
196
+ beforeEach(() => {
197
+ vi.clearAllMocks();
198
+ });
199
+
200
+ afterEach(() => {
201
+ delete (
202
+ globalThis as typeof globalThis & {
203
+ __HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise<unknown>;
204
+ }
205
+ ).__HARNESS_PLATFORM_RUNNER__;
52
206
  });
53
207
 
54
208
  describe('waitForAppReady', () => {
55
- it('passes launch options to the initial launch', async () => {
209
+ it('delegates startup orchestration to bundler-metro and resolves readiness from the bridge', async () => {
56
210
  const { serverBridge, emitReady } = createBridgeServer();
57
- const restartApp = vi.fn().mockResolvedValue(undefined);
58
- const platformInstance = createPlatformRunner(restartApp);
211
+ const metroInstance = createMetroInstance();
212
+ const platformInstance = createPlatformRunner();
213
+ const { appMonitor } = createAppMonitor();
214
+ const crashSupervisor = createCrashSupervisor({
215
+ appMonitor,
216
+ platformRunner: platformInstance,
217
+ });
218
+
219
+ mocks.waitForMetroBackedAppReady.mockImplementationOnce(
220
+ async (options: WaitForMetroBackedAppReadyOptions) => {
221
+ options.onAttemptStart?.();
222
+ const readyPromise = options.waitForReady(new AbortController().signal);
223
+ emitReady();
224
+ await readyPromise;
225
+ options.onAttemptReset?.();
226
+ }
227
+ );
228
+
229
+ await waitForAppReady({
230
+ metroInstance,
231
+ serverBridge,
232
+ platformInstance,
233
+ platformId: 'ios',
234
+ bundleStartTimeout: 1_500,
235
+ readyTimeout: 2_500,
236
+ maxAppRestarts: 3,
237
+ testFilePath: '/tmp/test.harness.ts',
238
+ crashSupervisor,
239
+ });
240
+
241
+ expect(mocks.waitForMetroBackedAppReady).toHaveBeenCalledWith(
242
+ expect.objectContaining({
243
+ metro: metroInstance,
244
+ platformId: 'ios',
245
+ bundleStartTimeout: 1_500,
246
+ readyTimeout: 2_500,
247
+ maxAppRestarts: 3,
248
+ startAttempt: expect.any(Function),
249
+ waitForReady: expect.any(Function),
250
+ waitForCrash: expect.any(Function),
251
+ })
252
+ );
253
+ expect(crashSupervisor.isReady()).toBe(true);
254
+
255
+ await crashSupervisor.dispose();
256
+ });
257
+
258
+ it('passes launch options through the shared Metro startup helper', async () => {
259
+ const { serverBridge } = createBridgeServer();
260
+ const metroInstance = createMetroInstance();
261
+ const restartApp = vi.fn(async () => undefined);
262
+ const platformInstance = createPlatformRunner({ restartApp });
263
+ const { appMonitor } = createAppMonitor();
59
264
  const crashSupervisor = createCrashSupervisor({
60
- appMonitor: createAppMonitor(),
265
+ appMonitor,
61
266
  platformRunner: platformInstance,
62
267
  });
63
268
 
64
- const promise = waitForAppReady({
269
+ mocks.waitForMetroBackedAppReady.mockImplementationOnce(
270
+ async (options: WaitForMetroBackedAppReadyOptions) => {
271
+ await options.startAttempt();
272
+ }
273
+ );
274
+
275
+ await waitForAppReady({
276
+ metroInstance,
65
277
  serverBridge,
66
278
  platformInstance,
67
- bridgeTimeout: 5000,
279
+ platformId: 'ios',
280
+ bundleStartTimeout: 1_500,
281
+ readyTimeout: 2_500,
282
+ maxAppRestarts: 3,
68
283
  testFilePath: '/tmp/test.harness.ts',
69
284
  crashSupervisor,
70
285
  appLaunchOptions: {
@@ -74,56 +289,494 @@ describe('waitForAppReady', () => {
74
289
  },
75
290
  });
76
291
 
77
- await Promise.resolve();
78
292
  expect(restartApp).toHaveBeenCalledWith({
79
293
  extras: {
80
294
  mode: 'startup',
81
295
  },
82
296
  });
83
297
 
84
- emitReady();
85
- await promise;
86
298
  await crashSupervisor.dispose();
87
299
  });
300
+ });
88
301
 
89
- it('does not retry launch when the app never becomes ready', async () => {
90
- vi.useFakeTimers();
302
+ describe('getHarness', () => {
303
+ it('fails when the platform runner does not become ready within platformReadyTimeout', async () => {
304
+ const { serverBridge } = createBridgeServer();
305
+ const metroInstance = createMetroInstance();
306
+
307
+ mocks.getBridgeServer.mockResolvedValue(serverBridge);
308
+ mocks.getMetroInstance.mockResolvedValue(metroInstance);
309
+
310
+ (
311
+ globalThis as typeof globalThis & {
312
+ __HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise<unknown>;
313
+ }
314
+ ).__HARNESS_PLATFORM_RUNNER__ = vi.fn(
315
+ async () =>
316
+ await new Promise((_, reject) => {
317
+ setTimeout(() => {
318
+ reject(new DOMException('The operation was aborted', 'AbortError'));
319
+ }, 20);
320
+ })
321
+ );
322
+
323
+ const platform: HarnessPlatform = {
324
+ config: {},
325
+ getResourceLockKey: () => 'ios:test-platform-ready-timeout',
326
+ name: 'ios',
327
+ platformId: 'ios',
328
+ runner: `data:text/javascript,${encodeURIComponent(
329
+ 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);'
330
+ )}`,
331
+ };
332
+
333
+ await expect(
334
+ getHarness(
335
+ createHarnessConfig({
336
+ platformReadyTimeout: 10,
337
+ }),
338
+ platform,
339
+ '/tmp/project'
340
+ )
341
+ ).rejects.toBeInstanceOf(PlatformReadyTimeoutError);
342
+ });
91
343
 
344
+ it('passes a platform init signal to the runner factory', async () => {
92
345
  const { serverBridge } = createBridgeServer();
93
- const restartApp = vi.fn().mockResolvedValue(undefined);
94
- const platformInstance = createPlatformRunner(restartApp);
95
- const crashSupervisor = createCrashSupervisor({
96
- appMonitor: createAppMonitor(),
97
- platformRunner: platformInstance,
346
+ const appMonitor = createAppMonitor();
347
+ const platformInstance = createPlatformRunner({
348
+ createAppMonitor: () => appMonitor.appMonitor,
98
349
  });
350
+ const metroInstance = createMetroInstance();
99
351
 
100
- const promise = waitForAppReady({
101
- serverBridge,
102
- platformInstance,
103
- bridgeTimeout: 1000,
104
- testFilePath: '/tmp/test.harness.ts',
105
- crashSupervisor,
106
- appLaunchOptions: {
107
- extras: {
108
- mode: 'startup',
352
+ mocks.getBridgeServer.mockResolvedValue(serverBridge);
353
+ mocks.getMetroInstance.mockResolvedValue(metroInstance);
354
+
355
+ const runner = vi.fn(async () => platformInstance);
356
+ (
357
+ globalThis as typeof globalThis & {
358
+ __HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise<unknown>;
359
+ }
360
+ ).__HARNESS_PLATFORM_RUNNER__ = runner;
361
+
362
+ const platform: HarnessPlatform = {
363
+ config: {},
364
+ getResourceLockKey: () => 'ios:test-platform-init-signal',
365
+ name: 'ios',
366
+ platformId: 'ios',
367
+ runner: `data:text/javascript,${encodeURIComponent(
368
+ 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);'
369
+ )}`,
370
+ };
371
+
372
+ const harness = await getHarness(
373
+ createHarnessConfig(),
374
+ platform,
375
+ '/tmp/project'
376
+ );
377
+
378
+ expect(runner).toHaveBeenCalledWith(
379
+ platform.config,
380
+ expect.any(Object),
381
+ expect.objectContaining({
382
+ signal: expect.any(AbortSignal),
383
+ })
384
+ );
385
+
386
+ await harness.dispose();
387
+ });
388
+
389
+ it('falls back to a default resource lock key for platforms without getResourceLockKey', async () => {
390
+ const { serverBridge } = createBridgeServer();
391
+ const appMonitor = createAppMonitor();
392
+ const platformInstance = createPlatformRunner({
393
+ createAppMonitor: () => appMonitor.appMonitor,
394
+ });
395
+ const metroInstance = createMetroInstance();
396
+
397
+ mocks.getBridgeServer.mockResolvedValue(serverBridge);
398
+ mocks.getMetroInstance.mockResolvedValue(metroInstance);
399
+
400
+ (
401
+ globalThis as typeof globalThis & {
402
+ __HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise<unknown>;
403
+ }
404
+ ).__HARNESS_PLATFORM_RUNNER__ = vi.fn(async () => platformInstance);
405
+
406
+ const platform: HarnessPlatform = {
407
+ config: {},
408
+ name: 'legacy-ios',
409
+ platformId: 'ios',
410
+ runner: `data:text/javascript,${encodeURIComponent(
411
+ 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);'
412
+ )}`,
413
+ };
414
+
415
+ const harness = await getHarness(
416
+ createHarnessConfig(),
417
+ platform,
418
+ '/tmp/project'
419
+ );
420
+
421
+ await harness.dispose();
422
+ });
423
+
424
+ it('routes ensureAppReady through the shared Metro startup helper', async () => {
425
+ const { serverBridge, emitReady } = createBridgeServer();
426
+ const appMonitor = createAppMonitor();
427
+ const restartApp = vi.fn(async () => undefined);
428
+ const platformInstance = createPlatformRunner({
429
+ restartApp,
430
+ createAppMonitor: () => appMonitor.appMonitor,
431
+ });
432
+ const metroInstance = createMetroInstance();
433
+
434
+ mocks.getBridgeServer.mockResolvedValue(serverBridge);
435
+ mocks.getMetroInstance.mockResolvedValue(metroInstance);
436
+ mocks.waitForMetroBackedAppReady.mockImplementationOnce(
437
+ async (options: WaitForMetroBackedAppReadyOptions) => {
438
+ await options.startAttempt();
439
+ const readyPromise = options.waitForReady(new AbortController().signal);
440
+ emitReady();
441
+ await readyPromise;
442
+ }
443
+ );
444
+
445
+ (
446
+ globalThis as typeof globalThis & {
447
+ __HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise<unknown>;
448
+ }
449
+ ).__HARNESS_PLATFORM_RUNNER__ = vi.fn(async () => platformInstance);
450
+
451
+ const platform: HarnessPlatform = {
452
+ config: {
453
+ appLaunchOptions: {
454
+ extras: {
455
+ source: 'ensure-ready',
456
+ },
109
457
  },
110
458
  },
459
+ name: 'ios',
460
+ platformId: 'ios',
461
+ runner: `data:text/javascript,${encodeURIComponent(
462
+ 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);'
463
+ )}`,
464
+ getResourceLockKey: () => 'ios:simulator:iPhone 17 Pro:26.2',
465
+ };
466
+
467
+ const harness = await getHarness(
468
+ createHarnessConfig({
469
+ bridgeTimeout: 1,
470
+ }),
471
+ platform,
472
+ '/tmp/project'
473
+ );
474
+
475
+ await harness.ensureAppReady('/tmp/example.harness.ts');
476
+
477
+ expect(mocks.waitForMetroBackedAppReady).toHaveBeenCalledTimes(1);
478
+ expect(restartApp).toHaveBeenCalledWith({
479
+ extras: {
480
+ source: 'ensure-ready',
481
+ },
111
482
  });
112
483
 
113
- await Promise.resolve();
114
- await vi.advanceTimersByTimeAsync(1000);
484
+ await harness.dispose();
485
+ });
115
486
 
116
- await expect(promise).rejects.toMatchObject({
117
- name: 'AbortError',
487
+ it('routes restart(testFilePath) through the shared Metro startup helper', async () => {
488
+ const { serverBridge, emitReady } = createBridgeServer();
489
+ const appMonitor = createAppMonitor();
490
+ const restartApp = vi.fn(async () => undefined);
491
+ const stopApp = vi.fn(async () => undefined);
492
+ const platformInstance = createPlatformRunner({
493
+ restartApp,
494
+ stopApp,
495
+ isAppRunning: vi.fn(async () => false),
496
+ createAppMonitor: () => appMonitor.appMonitor,
118
497
  });
119
- expect(restartApp).toHaveBeenCalledTimes(1);
498
+ const metroInstance = createMetroInstance();
499
+
500
+ mocks.getBridgeServer.mockResolvedValue(serverBridge);
501
+ mocks.getMetroInstance.mockResolvedValue(metroInstance);
502
+ mocks.waitForMetroBackedAppReady.mockImplementationOnce(
503
+ async (options: WaitForMetroBackedAppReadyOptions) => {
504
+ await options.startAttempt();
505
+ const readyPromise = options.waitForReady(new AbortController().signal);
506
+ emitReady();
507
+ await readyPromise;
508
+ }
509
+ );
510
+
511
+ (
512
+ globalThis as typeof globalThis & {
513
+ __HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise<unknown>;
514
+ }
515
+ ).__HARNESS_PLATFORM_RUNNER__ = vi.fn(async () => platformInstance);
516
+
517
+ const platform: HarnessPlatform = {
518
+ config: {
519
+ appLaunchOptions: {
520
+ extras: {
521
+ source: 'restart',
522
+ },
523
+ },
524
+ },
525
+ name: 'ios',
526
+ platformId: 'ios',
527
+ runner: `data:text/javascript,${encodeURIComponent(
528
+ 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);'
529
+ )}`,
530
+ getResourceLockKey: () => 'ios:simulator:iPhone 17 Pro:26.2',
531
+ };
532
+
533
+ const harness = await getHarness(
534
+ createHarnessConfig(),
535
+ platform,
536
+ '/tmp/project'
537
+ );
538
+
539
+ expect(mocks.getBridgeServer).toHaveBeenCalledWith(
540
+ expect.objectContaining({
541
+ noServer: true,
542
+ })
543
+ );
544
+ expect(mocks.getMetroInstance).toHaveBeenCalledWith(
545
+ expect.objectContaining({
546
+ websocketEndpoints: {
547
+ [HARNESS_BRIDGE_PATH]: serverBridge.ws,
548
+ },
549
+ }),
550
+ expect.any(AbortSignal)
551
+ );
552
+ await harness.restart('/tmp/restart.harness.ts');
553
+
554
+ expect(stopApp).toHaveBeenCalledTimes(1);
555
+ expect(mocks.waitForMetroBackedAppReady).toHaveBeenCalledTimes(1);
120
556
  expect(restartApp).toHaveBeenCalledWith({
121
557
  extras: {
122
- mode: 'startup',
558
+ source: 'restart',
123
559
  },
124
560
  });
125
561
 
126
- await crashSupervisor.dispose();
127
- vi.useRealTimers();
562
+ await harness.dispose();
563
+ });
564
+ });
565
+
566
+ describe('plugins', () => {
567
+ it('invokes lifecycle and runtime plugin handlers for configured plugins', async () => {
568
+ const { serverBridge, emitReady, emitEvent, emitDisconnect } =
569
+ createBridgeServer();
570
+ const appMonitor = createAppMonitor();
571
+ const platformInstance = createPlatformRunner({
572
+ createAppMonitor: () => appMonitor.appMonitor,
573
+ });
574
+ const observedHooks: string[] = [];
575
+
576
+ mocks.getBridgeServer.mockResolvedValue(serverBridge);
577
+ const metroInstance = createMetroInstance();
578
+ mocks.getMetroInstance.mockResolvedValue(metroInstance);
579
+
580
+ (
581
+ globalThis as typeof globalThis & {
582
+ __HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise<unknown>;
583
+ }
584
+ ).__HARNESS_PLATFORM_RUNNER__ = vi.fn(async () => platformInstance);
585
+
586
+ const plugin = definePlugin<
587
+ {
588
+ creationCount: number;
589
+ },
590
+ HarnessConfig,
591
+ HarnessPlatform
592
+ >({
593
+ name: 'test-plugin',
594
+ createState: () => ({
595
+ creationCount: 0,
596
+ }),
597
+ hooks: {
598
+ harness: {
599
+ beforeCreation: (ctx) => {
600
+ ctx.state.creationCount += 1;
601
+ observedHooks.push(
602
+ `beforeCreation:${ctx.platform.platformId}:${
603
+ ctx.appLaunchOptions == null
604
+ ? 'no-launch-options'
605
+ : 'launch-options'
606
+ }`
607
+ );
608
+ },
609
+ beforeRun: (ctx) => {
610
+ observedHooks.push(
611
+ `beforeRun:${ctx.platform.platformId}:${
612
+ ctx.appLaunchOptions == null
613
+ ? 'no-launch-options'
614
+ : 'launch-options'
615
+ }`
616
+ );
617
+ },
618
+ afterRun: (ctx) => {
619
+ observedHooks.push(
620
+ `afterRun:${ctx.state.creationCount}:${ctx.reason}`
621
+ );
622
+ },
623
+ beforeDispose: (ctx) => {
624
+ observedHooks.push(
625
+ `beforeDispose:${ctx.state.creationCount}:${ctx.reason}`
626
+ );
627
+ },
628
+ },
629
+ runtime: {
630
+ ready: (ctx) => {
631
+ observedHooks.push(
632
+ `runtime.ready:${ctx.runId}:${ctx.device.platform}`
633
+ );
634
+ },
635
+ disconnected: (ctx) => {
636
+ observedHooks.push(`runtime.disconnected:${ctx.reason}`);
637
+ },
638
+ },
639
+ collection: {
640
+ started: (ctx) => {
641
+ observedHooks.push(`collection.started:${ctx.file}`);
642
+ },
643
+ },
644
+ },
645
+ });
646
+
647
+ const platform: HarnessPlatform = {
648
+ config: {
649
+ appLaunchOptions: {
650
+ environment: {
651
+ MODE: 'test',
652
+ },
653
+ },
654
+ },
655
+ name: 'ios',
656
+ platformId: 'ios',
657
+ runner: `data:text/javascript,${encodeURIComponent(
658
+ 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);'
659
+ )}`,
660
+ getResourceLockKey: () => 'ios:simulator:iPhone 17 Pro:26.2',
661
+ };
662
+
663
+ const harness = await getHarness(
664
+ createHarnessConfig({
665
+ plugins: [plugin],
666
+ }),
667
+ platform,
668
+ '/tmp/project'
669
+ );
670
+
671
+ harness.setRunState({
672
+ runId: 'run-1',
673
+ startTime: Date.now(),
674
+ testFiles: ['example.harness.ts'],
675
+ watchMode: false,
676
+ coverageEnabled: false,
677
+ summary: {
678
+ passed: 1,
679
+ failed: 0,
680
+ skipped: 0,
681
+ todo: 0,
682
+ },
683
+ status: 'passed',
684
+ });
685
+
686
+ emitReady();
687
+ emitEvent({
688
+ type: 'collection-started',
689
+ file: 'example.harness.ts',
690
+ });
691
+ emitDisconnect();
692
+
693
+ await harness.dispose();
694
+
695
+ expect(observedHooks).toEqual([
696
+ 'beforeCreation:ios:launch-options',
697
+ 'beforeRun:ios:launch-options',
698
+ 'runtime.ready:run-1:ios',
699
+ 'collection.started:example.harness.ts',
700
+ 'runtime.disconnected:bridge-disconnected',
701
+ 'afterRun:1:normal',
702
+ 'beforeDispose:1:normal',
703
+ ]);
704
+ });
705
+
706
+ it('waits in queue before starting Metro and releases the lock on dispose', async () => {
707
+ const resourceKey = 'ios:simulator:iPhone 17 Pro:26.2';
708
+ const firstPlatformRunner = createPlatformRunner();
709
+ const secondPlatformRunner = createPlatformRunner();
710
+ const secondAppMonitor = createAppMonitor();
711
+ const firstMetroInstance = createMetroInstance();
712
+ const secondMetroInstance = createMetroInstance();
713
+ const firstBridge = createBridgeServer();
714
+ const secondBridge = createBridgeServer();
715
+
716
+ mocks.getBridgeServer
717
+ .mockResolvedValueOnce(firstBridge.serverBridge)
718
+ .mockResolvedValueOnce(secondBridge.serverBridge);
719
+ mocks.getMetroInstance
720
+ .mockResolvedValueOnce(firstMetroInstance)
721
+ .mockResolvedValueOnce(secondMetroInstance);
722
+
723
+ let invocationCount = 0;
724
+ (
725
+ globalThis as typeof globalThis & {
726
+ __HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise<unknown>;
727
+ }
728
+ ).__HARNESS_PLATFORM_RUNNER__ = vi.fn(async () => {
729
+ invocationCount += 1;
730
+ return invocationCount === 1
731
+ ? firstPlatformRunner
732
+ : createPlatformRunner({
733
+ createAppMonitor: () => secondAppMonitor.appMonitor,
734
+ dispose: secondPlatformRunner.dispose,
735
+ });
736
+ });
737
+
738
+ const platform: HarnessPlatform = {
739
+ config: {},
740
+ name: 'ios',
741
+ platformId: 'ios',
742
+ runner: `data:text/javascript,${encodeURIComponent(
743
+ 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);'
744
+ )}`,
745
+ getResourceLockKey: () => resourceKey,
746
+ };
747
+
748
+ const firstHarness = await getHarness(
749
+ createHarnessConfig(),
750
+ platform,
751
+ '/tmp/project'
752
+ );
753
+
754
+ const secondHarnessPromise = getHarness(
755
+ createHarnessConfig(),
756
+ platform,
757
+ '/tmp/project'
758
+ );
759
+
760
+ await new Promise((resolve) => setTimeout(resolve, 1100));
761
+
762
+ expect(mocks.logRunnerWaitingInQueue).toHaveBeenCalledWith(platform);
763
+ expect(mocks.logRunnerStarting).not.toHaveBeenCalled();
764
+ expect(mocks.getMetroInstance).toHaveBeenCalledTimes(1);
765
+
766
+ await firstHarness.dispose();
767
+ const secondHarness = await secondHarnessPromise;
768
+
769
+ expect(mocks.logRunnerStarting).toHaveBeenCalledWith(platform);
770
+ expect(mocks.getMetroInstance).toHaveBeenCalledTimes(2);
771
+
772
+ await secondHarness.dispose();
773
+ });
774
+ });
775
+
776
+ describe('StartupStallError', () => {
777
+ it('includes the configured timeout and attempt count', () => {
778
+ expect(new StartupStallError(1_500, 4).message).toBe(
779
+ 'The app did not request its Metro bundle after 4 launch attempts within 1500ms. Last Metro status: unknown.'
780
+ );
128
781
  });
129
782
  });