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