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

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 (68) hide show
  1. package/dist/__tests__/harness-cache.test.d.ts +2 -0
  2. package/dist/__tests__/harness-cache.test.d.ts.map +1 -0
  3. package/dist/__tests__/harness-cache.test.js +51 -0
  4. package/dist/__tests__/harness.test.js +340 -47
  5. package/dist/__tests__/index.test.d.ts +2 -0
  6. package/dist/__tests__/index.test.d.ts.map +1 -0
  7. package/dist/__tests__/index.test.js +42 -0
  8. package/dist/cli-args.d.ts +1 -0
  9. package/dist/cli-args.d.ts.map +1 -1
  10. package/dist/cli-args.js +5 -0
  11. package/dist/crash-supervisor.d.ts.map +1 -1
  12. package/dist/crash-supervisor.js +7 -6
  13. package/dist/errors.d.ts +1 -0
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js +1 -0
  16. package/dist/harness.d.ts +23 -1
  17. package/dist/harness.d.ts.map +1 -1
  18. package/dist/harness.js +396 -50
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +205 -58
  21. package/dist/logs.d.ts +1 -0
  22. package/dist/logs.d.ts.map +1 -1
  23. package/dist/logs.js +3 -0
  24. package/dist/run.d.ts +7 -1
  25. package/dist/run.d.ts.map +1 -1
  26. package/dist/run.js +13 -8
  27. package/dist/setup.d.ts.map +1 -1
  28. package/dist/setup.js +18 -2
  29. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  30. package/package.json +7 -6
  31. package/src/__tests__/harness-cache.test.ts +74 -0
  32. package/src/__tests__/harness.test.ts +469 -51
  33. package/src/__tests__/index.test.ts +61 -0
  34. package/src/cli-args.ts +6 -0
  35. package/src/crash-supervisor.ts +13 -6
  36. package/src/env.d.ts +1 -1
  37. package/src/errors.ts +5 -0
  38. package/src/harness.ts +504 -58
  39. package/src/index.ts +276 -64
  40. package/src/logs.ts +4 -0
  41. package/src/run.ts +20 -10
  42. package/src/setup.ts +22 -1
  43. package/tsconfig.json +3 -3
  44. package/tsconfig.lib.json +6 -6
  45. package/dist/cli-args.test.d.ts +0 -2
  46. package/dist/cli-args.test.d.ts.map +0 -1
  47. package/dist/cli-args.test.js +0 -1
  48. package/dist/crash-monitor.d.ts +0 -16
  49. package/dist/crash-monitor.d.ts.map +0 -1
  50. package/dist/crash-monitor.js +0 -75
  51. package/dist/event-mapper.d.ts +0 -16
  52. package/dist/event-mapper.d.ts.map +0 -1
  53. package/dist/event-mapper.js +0 -120
  54. package/dist/global-setup.d.ts +0 -3
  55. package/dist/global-setup.d.ts.map +0 -1
  56. package/dist/global-setup.js +0 -60
  57. package/dist/global-teardown.d.ts +0 -3
  58. package/dist/global-teardown.d.ts.map +0 -1
  59. package/dist/global-teardown.js +0 -11
  60. package/dist/harness-reporter.d.ts +0 -8
  61. package/dist/harness-reporter.d.ts.map +0 -1
  62. package/dist/harness-reporter.js +0 -12
  63. package/dist/jest-options.d.ts +0 -2
  64. package/dist/jest-options.d.ts.map +0 -1
  65. package/dist/jest-options.js +0 -1
  66. package/dist/types.d.ts +0 -17
  67. package/dist/types.d.ts.map +0 -1
  68. package/dist/types.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@react-native-harness/jest",
3
- "version": "1.1.0-rc.1",
3
+ "version": "1.1.0-rc.2",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -40,11 +40,12 @@
40
40
  "p-limit": "^7.1.1",
41
41
  "tslib": "^2.3.0",
42
42
  "yargs": "^17.7.2",
43
- "@react-native-harness/bridge": "1.1.0-rc.1",
44
- "@react-native-harness/bundler-metro": "1.1.0-rc.1",
45
- "@react-native-harness/config": "1.1.0-rc.1",
46
- "@react-native-harness/platforms": "1.1.0-rc.1",
47
- "@react-native-harness/tools": "1.1.0-rc.1"
43
+ "@react-native-harness/config": "1.1.0-rc.2",
44
+ "@react-native-harness/bundler-metro": "1.1.0-rc.2",
45
+ "@react-native-harness/bridge": "1.1.0-rc.2",
46
+ "@react-native-harness/plugins": "1.1.0-rc.2",
47
+ "@react-native-harness/tools": "1.1.0-rc.2",
48
+ "@react-native-harness/platforms": "1.1.0-rc.2"
48
49
  },
49
50
  "devDependencies": {
50
51
  "@types/yargs": "^17.0.32",
@@ -0,0 +1,74 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import type { Config as HarnessConfig } from '@react-native-harness/config';
3
+ import type { HarnessPlatform } from '@react-native-harness/platforms';
4
+
5
+ const mocks = vi.hoisted(() => ({
6
+ isMetroCacheReusable: vi.fn(),
7
+ logMetroCacheReused: vi.fn(),
8
+ logMetroPrewarmCompleted: vi.fn(),
9
+ }));
10
+
11
+ vi.mock('@react-native-harness/bundler-metro', () => ({
12
+ isMetroCacheReusable: mocks.isMetroCacheReusable,
13
+ }));
14
+
15
+ vi.mock('../logs.js', () => ({
16
+ logMetroCacheReused: mocks.logMetroCacheReused,
17
+ logMetroPrewarmCompleted: mocks.logMetroPrewarmCompleted,
18
+ }));
19
+
20
+ import { maybeLogMetroCacheReuse } from '../harness.js';
21
+
22
+ const platform: HarnessPlatform = {
23
+ name: 'ios',
24
+ platformId: 'ios',
25
+ runner: '/virtual/platform-runner.js',
26
+ config: {},
27
+ };
28
+
29
+ const createHarnessConfig = (
30
+ overrides: Partial<HarnessConfig> = {}
31
+ ): HarnessConfig =>
32
+ ({
33
+ appRegistryComponentName: 'App',
34
+ webSocketPort: 8081,
35
+ disableViewFlattening: false,
36
+ bridgeTimeout: 5000,
37
+ entryPoint: 'index.js',
38
+ unstable__enableMetroCache: true,
39
+ forwardClientLogs: false,
40
+ ...overrides,
41
+ }) as HarnessConfig;
42
+
43
+ describe('maybeLogMetroCacheReuse', () => {
44
+ beforeEach(() => {
45
+ vi.clearAllMocks();
46
+ mocks.isMetroCacheReusable.mockReturnValue(false);
47
+ });
48
+
49
+ it('logs when an existing metro cache will be reused', () => {
50
+ mocks.isMetroCacheReusable.mockReturnValue(true);
51
+
52
+ maybeLogMetroCacheReuse(createHarnessConfig(), platform, '/tmp/project');
53
+
54
+ expect(mocks.isMetroCacheReusable).toHaveBeenCalledWith('/tmp/project');
55
+ expect(mocks.logMetroCacheReused).toHaveBeenCalledWith(platform);
56
+ });
57
+
58
+ it('does not log when metro cache reuse is disabled', () => {
59
+ maybeLogMetroCacheReuse(
60
+ createHarnessConfig({ unstable__enableMetroCache: false }),
61
+ platform,
62
+ '/tmp/project'
63
+ );
64
+
65
+ expect(mocks.isMetroCacheReusable).not.toHaveBeenCalled();
66
+ expect(mocks.logMetroCacheReused).not.toHaveBeenCalled();
67
+ });
68
+
69
+ it('does not log when the metro cache is absent', () => {
70
+ maybeLogMetroCacheReuse(createHarnessConfig(), platform, '/tmp/project');
71
+
72
+ expect(mocks.logMetroCacheReused).not.toHaveBeenCalled();
73
+ });
74
+ });
@@ -1,106 +1,276 @@
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import type { Config as HarnessConfig } from '@react-native-harness/config';
4
+ import { definePlugin } from '@react-native-harness/plugins';
5
5
  import type {
6
6
  AppMonitor,
7
+ AppMonitorEvent,
7
8
  AppMonitorListener,
9
+ HarnessPlatform,
8
10
  HarnessPlatformRunner,
9
11
  } from '@react-native-harness/platforms';
12
+ import type { BridgeServer } from '@react-native-harness/bridge/server';
13
+ import type {
14
+ MetroInstance,
15
+ Reporter,
16
+ ReportableEvent,
17
+ WaitForMetroBackedAppReadyOptions,
18
+ } from '@react-native-harness/bundler-metro';
10
19
  import { createCrashSupervisor } from '../crash-supervisor.js';
11
20
 
21
+ const mocks = vi.hoisted(() => ({
22
+ createCrashArtifactWriter: vi.fn(() => ({})),
23
+ getBridgeServer: vi.fn(),
24
+ getMetroInstance: vi.fn(),
25
+ isMetroCacheReusable: vi.fn(() => false),
26
+ logMetroCacheReused: vi.fn(),
27
+ waitForMetroBackedAppReady: vi.fn(),
28
+ }));
29
+
30
+ vi.mock('@react-native-harness/bundler-metro', async () => {
31
+ const actual =
32
+ await vi.importActual<typeof import('@react-native-harness/bundler-metro')>(
33
+ '@react-native-harness/bundler-metro'
34
+ );
35
+
36
+ return {
37
+ ...actual,
38
+ getMetroInstance: mocks.getMetroInstance,
39
+ isMetroCacheReusable: mocks.isMetroCacheReusable,
40
+ waitForMetroBackedAppReady: mocks.waitForMetroBackedAppReady,
41
+ };
42
+ });
43
+
44
+ vi.mock('@react-native-harness/bridge/server', () => ({
45
+ getBridgeServer: mocks.getBridgeServer,
46
+ }));
47
+
48
+ vi.mock('../logs.js', () => ({
49
+ logMetroCacheReused: mocks.logMetroCacheReused,
50
+ }));
51
+
52
+ vi.mock('@react-native-harness/tools', async () => {
53
+ const actual =
54
+ await vi.importActual<typeof import('@react-native-harness/tools')>(
55
+ '@react-native-harness/tools'
56
+ );
57
+
58
+ return {
59
+ ...actual,
60
+ createCrashArtifactWriter: mocks.createCrashArtifactWriter,
61
+ };
62
+ });
63
+
64
+ import { getHarness, waitForAppReady } from '../harness.js';
65
+ import { StartupStallError } from '../errors.js';
66
+
12
67
  const createBridgeServer = () => {
13
68
  const emitter = new EventEmitter();
14
69
 
15
70
  return {
16
71
  serverBridge: {
72
+ rpc: {
73
+ clients: [],
74
+ },
17
75
  on: emitter.on.bind(emitter),
18
76
  once: emitter.once.bind(emitter),
19
77
  off: emitter.off.bind(emitter),
78
+ dispose: vi.fn(),
20
79
  } as unknown as BridgeServer,
21
80
  emitReady: () => {
22
- emitter.emit('ready');
81
+ emitter.emit('ready', {
82
+ platform: 'ios',
83
+ manufacturer: 'Apple',
84
+ model: 'Simulator',
85
+ osVersion: '18.0',
86
+ });
87
+ },
88
+ emitEvent: (event: unknown) => {
89
+ emitter.emit('event', event);
90
+ },
91
+ emitDisconnect: () => {
92
+ emitter.emit('disconnect');
23
93
  },
24
94
  };
25
95
  };
26
96
 
27
- const createAppMonitor = (): AppMonitor => {
28
- const listeners = new Set<AppMonitorListener>();
97
+ const createReporter = (): Reporter => {
98
+ const listeners = new Set<(event: ReportableEvent) => void>();
29
99
 
30
100
  return {
31
- start: async () => undefined,
32
- stop: async () => undefined,
33
- dispose: async () => undefined,
34
101
  addListener: (listener) => {
35
102
  listeners.add(listener);
36
103
  },
37
104
  removeListener: (listener) => {
38
105
  listeners.delete(listener);
39
106
  },
107
+ emit: (event) => {
108
+ listeners.forEach((listener) => listener(event));
109
+ },
110
+ clearAllListeners: () => {
111
+ listeners.clear();
112
+ },
113
+ };
114
+ };
115
+
116
+ const createMetroInstance = (
117
+ overrides: Partial<MetroInstance> = {}
118
+ ): MetroInstance => ({
119
+ events: createReporter(),
120
+ waitUntilHealthy: vi.fn(async () => 'HTTP 200: packager-status:running'),
121
+ prewarm: vi.fn(async () => false),
122
+ dispose: vi.fn(async () => undefined),
123
+ ...overrides,
124
+ });
125
+
126
+ const createAppMonitor = (): {
127
+ appMonitor: AppMonitor;
128
+ emit: (event: AppMonitorEvent) => void;
129
+ } => {
130
+ const listeners = new Set<AppMonitorListener>();
131
+
132
+ return {
133
+ appMonitor: {
134
+ start: vi.fn(async () => undefined),
135
+ stop: vi.fn(async () => undefined),
136
+ dispose: vi.fn(async () => undefined),
137
+ addListener: (listener) => {
138
+ listeners.add(listener);
139
+ },
140
+ removeListener: (listener) => {
141
+ listeners.delete(listener);
142
+ },
143
+ },
144
+ emit: (event) => {
145
+ for (const listener of listeners) {
146
+ listener(event);
147
+ }
148
+ },
40
149
  };
41
150
  };
42
151
 
43
152
  const createPlatformRunner = (
44
- restartApp: HarnessPlatformRunner['restartApp']
153
+ overrides: Partial<HarnessPlatformRunner> = {}
45
154
  ): HarnessPlatformRunner => ({
46
- startApp: async () => undefined,
47
- restartApp,
48
- stopApp: async () => undefined,
49
- dispose: async () => undefined,
50
- isAppRunning: async () => true,
51
- createAppMonitor: createAppMonitor,
155
+ startApp: vi.fn(async () => undefined),
156
+ restartApp: vi.fn(async () => undefined),
157
+ stopApp: vi.fn(async () => undefined),
158
+ dispose: vi.fn(async () => undefined),
159
+ isAppRunning: vi.fn(async () => true),
160
+ createAppMonitor: () => createAppMonitor().appMonitor,
161
+ ...overrides,
162
+ });
163
+
164
+ const createHarnessConfig = (
165
+ overrides: Partial<HarnessConfig> = {}
166
+ ): HarnessConfig =>
167
+ ({
168
+ appRegistryComponentName: 'App',
169
+ bridgeTimeout: 60_000,
170
+ bundleStartTimeout: 60_000,
171
+ crashDetectionInterval: 500,
172
+ defaultRunner: 'ios',
173
+ detectNativeCrashes: true,
174
+ disableViewFlattening: false,
175
+ entryPoint: 'index.js',
176
+ forwardClientLogs: false,
177
+ maxAppRestarts: 2,
178
+ metroPort: 8081,
179
+ resetEnvironmentBetweenTestFiles: true,
180
+ runners: [],
181
+ unstable__enableMetroCache: false,
182
+ unstable__skipAlreadyIncludedModules: false,
183
+ webSocketPort: 3001,
184
+ ...overrides,
185
+ }) as HarnessConfig;
186
+
187
+ beforeEach(() => {
188
+ vi.clearAllMocks();
189
+ });
190
+
191
+ afterEach(() => {
192
+ delete (
193
+ globalThis as typeof globalThis & {
194
+ __HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise<unknown>;
195
+ }
196
+ ).__HARNESS_PLATFORM_RUNNER__;
52
197
  });
53
198
 
54
199
  describe('waitForAppReady', () => {
55
- it('passes launch options to the initial launch', async () => {
200
+ it('delegates startup orchestration to bundler-metro and resolves readiness from the bridge', async () => {
56
201
  const { serverBridge, emitReady } = createBridgeServer();
57
- const restartApp = vi.fn().mockResolvedValue(undefined);
58
- const platformInstance = createPlatformRunner(restartApp);
202
+ const metroInstance = createMetroInstance();
203
+ const platformInstance = createPlatformRunner();
204
+ const { appMonitor } = createAppMonitor();
59
205
  const crashSupervisor = createCrashSupervisor({
60
- appMonitor: createAppMonitor(),
206
+ appMonitor,
61
207
  platformRunner: platformInstance,
62
208
  });
63
209
 
64
- const promise = waitForAppReady({
210
+ mocks.waitForMetroBackedAppReady.mockImplementationOnce(
211
+ async (options: WaitForMetroBackedAppReadyOptions) => {
212
+ options.onAttemptStart?.();
213
+ const readyPromise = options.waitForReady(new AbortController().signal);
214
+ emitReady();
215
+ await readyPromise;
216
+ options.onAttemptReset?.();
217
+ }
218
+ );
219
+
220
+ await waitForAppReady({
221
+ metroInstance,
65
222
  serverBridge,
66
223
  platformInstance,
67
- bridgeTimeout: 5000,
224
+ platformId: 'ios',
225
+ bundleStartTimeout: 1_500,
226
+ readyTimeout: 2_500,
227
+ maxAppRestarts: 3,
68
228
  testFilePath: '/tmp/test.harness.ts',
69
229
  crashSupervisor,
70
- appLaunchOptions: {
71
- extras: {
72
- mode: 'startup',
73
- },
74
- },
75
230
  });
76
231
 
77
- await Promise.resolve();
78
- expect(restartApp).toHaveBeenCalledWith({
79
- extras: {
80
- mode: 'startup',
81
- },
82
- });
232
+ expect(mocks.waitForMetroBackedAppReady).toHaveBeenCalledWith(
233
+ expect.objectContaining({
234
+ metro: metroInstance,
235
+ platformId: 'ios',
236
+ bundleStartTimeout: 1_500,
237
+ readyTimeout: 2_500,
238
+ maxAppRestarts: 3,
239
+ startAttempt: expect.any(Function),
240
+ waitForReady: expect.any(Function),
241
+ waitForCrash: expect.any(Function),
242
+ })
243
+ );
244
+ expect(crashSupervisor.isReady()).toBe(true);
83
245
 
84
- emitReady();
85
- await promise;
86
246
  await crashSupervisor.dispose();
87
247
  });
88
248
 
89
- it('does not retry launch when the app never becomes ready', async () => {
90
- vi.useFakeTimers();
91
-
249
+ it('passes launch options through the shared Metro startup helper', async () => {
92
250
  const { serverBridge } = createBridgeServer();
93
- const restartApp = vi.fn().mockResolvedValue(undefined);
94
- const platformInstance = createPlatformRunner(restartApp);
251
+ const metroInstance = createMetroInstance();
252
+ const restartApp = vi.fn(async () => undefined);
253
+ const platformInstance = createPlatformRunner({ restartApp });
254
+ const { appMonitor } = createAppMonitor();
95
255
  const crashSupervisor = createCrashSupervisor({
96
- appMonitor: createAppMonitor(),
256
+ appMonitor,
97
257
  platformRunner: platformInstance,
98
258
  });
99
259
 
100
- const promise = waitForAppReady({
260
+ mocks.waitForMetroBackedAppReady.mockImplementationOnce(
261
+ async (options: WaitForMetroBackedAppReadyOptions) => {
262
+ await options.startAttempt();
263
+ }
264
+ );
265
+
266
+ await waitForAppReady({
267
+ metroInstance,
101
268
  serverBridge,
102
269
  platformInstance,
103
- bridgeTimeout: 1000,
270
+ platformId: 'ios',
271
+ bundleStartTimeout: 1_500,
272
+ readyTimeout: 2_500,
273
+ maxAppRestarts: 3,
104
274
  testFilePath: '/tmp/test.harness.ts',
105
275
  crashSupervisor,
106
276
  appLaunchOptions: {
@@ -110,13 +280,6 @@ describe('waitForAppReady', () => {
110
280
  },
111
281
  });
112
282
 
113
- await Promise.resolve();
114
- await vi.advanceTimersByTimeAsync(1000);
115
-
116
- await expect(promise).rejects.toMatchObject({
117
- name: 'AbortError',
118
- });
119
- expect(restartApp).toHaveBeenCalledTimes(1);
120
283
  expect(restartApp).toHaveBeenCalledWith({
121
284
  extras: {
122
285
  mode: 'startup',
@@ -124,6 +287,261 @@ describe('waitForAppReady', () => {
124
287
  });
125
288
 
126
289
  await crashSupervisor.dispose();
127
- vi.useRealTimers();
290
+ });
291
+ });
292
+
293
+ describe('getHarness', () => {
294
+ it('routes ensureAppReady through the shared Metro startup helper', async () => {
295
+ const { serverBridge, emitReady } = createBridgeServer();
296
+ const appMonitor = createAppMonitor();
297
+ const restartApp = vi.fn(async () => undefined);
298
+ const platformInstance = createPlatformRunner({
299
+ restartApp,
300
+ createAppMonitor: () => appMonitor.appMonitor,
301
+ });
302
+ const metroInstance = createMetroInstance();
303
+
304
+ mocks.getBridgeServer.mockResolvedValue(serverBridge);
305
+ mocks.getMetroInstance.mockResolvedValue(metroInstance);
306
+ mocks.waitForMetroBackedAppReady.mockImplementationOnce(
307
+ async (options: WaitForMetroBackedAppReadyOptions) => {
308
+ await options.startAttempt();
309
+ const readyPromise = options.waitForReady(
310
+ new AbortController().signal
311
+ );
312
+ emitReady();
313
+ await readyPromise;
314
+ }
315
+ );
316
+
317
+ (
318
+ globalThis as typeof globalThis & {
319
+ __HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise<unknown>;
320
+ }
321
+ ).__HARNESS_PLATFORM_RUNNER__ = vi.fn(async () => platformInstance);
322
+
323
+ const platform: HarnessPlatform = {
324
+ config: {
325
+ appLaunchOptions: {
326
+ extras: {
327
+ source: 'ensure-ready',
328
+ },
329
+ },
330
+ },
331
+ name: 'ios',
332
+ platformId: 'ios',
333
+ runner: `data:text/javascript,${encodeURIComponent(
334
+ 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);'
335
+ )}`,
336
+ };
337
+
338
+ const harness = await getHarness(
339
+ createHarnessConfig(),
340
+ platform,
341
+ '/tmp/project'
342
+ );
343
+
344
+ await harness.ensureAppReady('/tmp/example.harness.ts');
345
+
346
+ expect(mocks.waitForMetroBackedAppReady).toHaveBeenCalledTimes(1);
347
+ expect(restartApp).toHaveBeenCalledWith({
348
+ extras: {
349
+ source: 'ensure-ready',
350
+ },
351
+ });
352
+
353
+ await harness.dispose();
354
+ });
355
+
356
+ it('routes restart(testFilePath) through the shared Metro startup helper', async () => {
357
+ const { serverBridge, emitReady } = createBridgeServer();
358
+ const appMonitor = createAppMonitor();
359
+ const restartApp = vi.fn(async () => undefined);
360
+ const stopApp = vi.fn(async () => undefined);
361
+ const platformInstance = createPlatformRunner({
362
+ restartApp,
363
+ stopApp,
364
+ isAppRunning: vi.fn(async () => false),
365
+ createAppMonitor: () => appMonitor.appMonitor,
366
+ });
367
+ const metroInstance = createMetroInstance();
368
+
369
+ mocks.getBridgeServer.mockResolvedValue(serverBridge);
370
+ mocks.getMetroInstance.mockResolvedValue(metroInstance);
371
+ mocks.waitForMetroBackedAppReady.mockImplementationOnce(
372
+ async (options: WaitForMetroBackedAppReadyOptions) => {
373
+ await options.startAttempt();
374
+ const readyPromise = options.waitForReady(
375
+ new AbortController().signal
376
+ );
377
+ emitReady();
378
+ await readyPromise;
379
+ }
380
+ );
381
+
382
+ (
383
+ globalThis as typeof globalThis & {
384
+ __HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise<unknown>;
385
+ }
386
+ ).__HARNESS_PLATFORM_RUNNER__ = vi.fn(async () => platformInstance);
387
+
388
+ const platform: HarnessPlatform = {
389
+ config: {
390
+ appLaunchOptions: {
391
+ extras: {
392
+ source: 'restart',
393
+ },
394
+ },
395
+ },
396
+ name: 'ios',
397
+ platformId: 'ios',
398
+ runner: `data:text/javascript,${encodeURIComponent(
399
+ 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);'
400
+ )}`,
401
+ };
402
+
403
+ const harness = await getHarness(
404
+ createHarnessConfig(),
405
+ platform,
406
+ '/tmp/project'
407
+ );
408
+
409
+ await harness.restart('/tmp/restart.harness.ts');
410
+
411
+ expect(stopApp).toHaveBeenCalledTimes(1);
412
+ expect(mocks.waitForMetroBackedAppReady).toHaveBeenCalledTimes(1);
413
+ expect(restartApp).toHaveBeenCalledWith({
414
+ extras: {
415
+ source: 'restart',
416
+ },
417
+ });
418
+
419
+ await harness.dispose();
420
+ });
421
+ });
422
+
423
+ describe('plugins', () => {
424
+ it('invokes lifecycle and runtime plugin handlers for configured plugins', async () => {
425
+ const { serverBridge, emitReady, emitEvent, emitDisconnect } =
426
+ createBridgeServer();
427
+ const appMonitor = createAppMonitor();
428
+ const platformInstance = createPlatformRunner({
429
+ createAppMonitor: () => appMonitor.appMonitor,
430
+ });
431
+ const observedHooks: string[] = [];
432
+
433
+ mocks.getBridgeServer.mockResolvedValue(serverBridge);
434
+ const metroInstance = createMetroInstance();
435
+ mocks.getMetroInstance.mockResolvedValue(metroInstance);
436
+
437
+ (
438
+ globalThis as typeof globalThis & {
439
+ __HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise<unknown>;
440
+ }
441
+ ).__HARNESS_PLATFORM_RUNNER__ = vi.fn(async () => platformInstance);
442
+
443
+ const plugin = definePlugin<
444
+ {
445
+ creationCount: number;
446
+ },
447
+ HarnessConfig,
448
+ HarnessPlatform
449
+ >({
450
+ name: 'test-plugin',
451
+ createState: () => ({
452
+ creationCount: 0,
453
+ }),
454
+ hooks: {
455
+ harness: {
456
+ beforeCreation: (ctx) => {
457
+ ctx.state.creationCount += 1;
458
+ observedHooks.push(
459
+ `beforeCreation:${ctx.platform.platformId}:${ctx.appLaunchOptions == null ? 'no-launch-options' : 'launch-options'}`
460
+ );
461
+ },
462
+ beforeDispose: (ctx) => {
463
+ observedHooks.push(
464
+ `beforeDispose:${ctx.state.creationCount}:${ctx.reason}`
465
+ );
466
+ },
467
+ },
468
+ runtime: {
469
+ ready: (ctx) => {
470
+ observedHooks.push(`runtime.ready:${ctx.runId}:${ctx.device.platform}`);
471
+ },
472
+ disconnected: (ctx) => {
473
+ observedHooks.push(`runtime.disconnected:${ctx.reason}`);
474
+ },
475
+ },
476
+ collection: {
477
+ started: (ctx) => {
478
+ observedHooks.push(`collection.started:${ctx.file}`);
479
+ },
480
+ },
481
+ },
482
+ });
483
+
484
+ const platform: HarnessPlatform = {
485
+ config: {
486
+ appLaunchOptions: {
487
+ environment: {
488
+ MODE: 'test',
489
+ },
490
+ },
491
+ },
492
+ name: 'ios',
493
+ platformId: 'ios',
494
+ runner: `data:text/javascript,${encodeURIComponent(
495
+ 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);'
496
+ )}`,
497
+ };
498
+
499
+ const harness = await getHarness(
500
+ createHarnessConfig({
501
+ plugins: [plugin],
502
+ }),
503
+ platform,
504
+ '/tmp/project'
505
+ );
506
+
507
+ harness.setRunState({
508
+ runId: 'run-1',
509
+ startTime: Date.now(),
510
+ testFiles: ['example.harness.ts'],
511
+ watchMode: false,
512
+ coverageEnabled: false,
513
+ summary: {
514
+ passed: 1,
515
+ failed: 0,
516
+ skipped: 0,
517
+ todo: 0,
518
+ },
519
+ status: 'passed',
520
+ });
521
+
522
+ emitReady();
523
+ emitEvent({
524
+ type: 'collection-started',
525
+ file: 'example.harness.ts',
526
+ });
527
+ emitDisconnect();
528
+
529
+ await harness.dispose();
530
+
531
+ expect(observedHooks).toEqual([
532
+ 'beforeCreation:ios:launch-options',
533
+ 'runtime.ready:run-1:ios',
534
+ 'collection.started:example.harness.ts',
535
+ 'runtime.disconnected:bridge-disconnected',
536
+ 'beforeDispose:1:normal',
537
+ ]);
538
+ });
539
+ });
540
+
541
+ describe('StartupStallError', () => {
542
+ it('includes the configured timeout and attempt count', () => {
543
+ expect(new StartupStallError(1_500, 4).message).toBe(
544
+ 'The app did not request its Metro bundle after 4 launch attempts within 1500ms. Last Metro status: unknown.'
545
+ );
128
546
  });
129
547
  });