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

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 (67) hide show
  1. package/dist/__tests__/errors.test.js +6 -1
  2. package/dist/__tests__/harness-cache.test.js +1 -1
  3. package/dist/__tests__/harness.test.js +205 -3
  4. package/dist/__tests__/metro-port.test.d.ts +2 -0
  5. package/dist/__tests__/metro-port.test.d.ts.map +1 -0
  6. package/dist/__tests__/metro-port.test.js +52 -0
  7. package/dist/__tests__/resource-lock.test.d.ts +2 -0
  8. package/dist/__tests__/resource-lock.test.d.ts.map +1 -0
  9. package/dist/__tests__/resource-lock.test.js +138 -0
  10. package/dist/action-hooks.d.ts +9 -0
  11. package/dist/action-hooks.d.ts.map +1 -0
  12. package/dist/action-hooks.js +36 -0
  13. package/dist/cli-args.test.d.ts +2 -0
  14. package/dist/cli-args.test.d.ts.map +1 -0
  15. package/dist/cli-args.test.js +1 -0
  16. package/dist/crash-monitor.d.ts +16 -0
  17. package/dist/crash-monitor.d.ts.map +1 -0
  18. package/dist/crash-monitor.js +75 -0
  19. package/dist/errors.d.ts +9 -0
  20. package/dist/errors.d.ts.map +1 -1
  21. package/dist/errors.js +20 -2
  22. package/dist/event-mapper.d.ts +16 -0
  23. package/dist/event-mapper.d.ts.map +1 -0
  24. package/dist/event-mapper.js +120 -0
  25. package/dist/global-setup.d.ts +3 -0
  26. package/dist/global-setup.d.ts.map +1 -0
  27. package/dist/global-setup.js +60 -0
  28. package/dist/global-teardown.d.ts +3 -0
  29. package/dist/global-teardown.d.ts.map +1 -0
  30. package/dist/global-teardown.js +11 -0
  31. package/dist/harness-reporter.d.ts +8 -0
  32. package/dist/harness-reporter.d.ts.map +1 -0
  33. package/dist/harness-reporter.js +12 -0
  34. package/dist/harness.d.ts +1 -0
  35. package/dist/harness.d.ts.map +1 -1
  36. package/dist/harness.js +491 -386
  37. package/dist/jest-options.d.ts +2 -0
  38. package/dist/jest-options.d.ts.map +1 -0
  39. package/dist/jest-options.js +1 -0
  40. package/dist/logs.d.ts +4 -0
  41. package/dist/logs.d.ts.map +1 -1
  42. package/dist/logs.js +12 -0
  43. package/dist/metro-port.d.ts +16 -0
  44. package/dist/metro-port.d.ts.map +1 -0
  45. package/dist/metro-port.js +48 -0
  46. package/dist/resource-lock.d.ts +25 -0
  47. package/dist/resource-lock.d.ts.map +1 -0
  48. package/dist/resource-lock.js +299 -0
  49. package/dist/setup.d.ts.map +1 -1
  50. package/dist/setup.js +12 -1
  51. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  52. package/dist/types.d.ts +17 -0
  53. package/dist/types.d.ts.map +1 -0
  54. package/dist/types.js +1 -0
  55. package/package.json +7 -7
  56. package/src/__tests__/errors.test.ts +18 -6
  57. package/src/__tests__/harness-cache.test.ts +2 -2
  58. package/src/__tests__/harness.test.ts +342 -19
  59. package/src/__tests__/metro-port.test.ts +61 -0
  60. package/src/__tests__/resource-lock.test.ts +192 -0
  61. package/src/action-hooks.ts +66 -0
  62. package/src/errors.ts +23 -4
  63. package/src/harness.ts +609 -447
  64. package/src/logs.ts +24 -3
  65. package/src/metro-port.ts +76 -0
  66. package/src/resource-lock.ts +472 -0
  67. package/src/setup.ts +22 -2
@@ -0,0 +1,17 @@
1
+ import type { TestContext, TestResult } from '@jest/test-result';
2
+ import type { Config, TestRunnerOptions } from 'jest-runner';
3
+ import type { CustomEnvironment } from '@react-native-harness/cli';
4
+ export type RunTestOptions<ExtraOptions extends Record<string, unknown> = Record<string, unknown>> = {
5
+ config: Config.ProjectConfig;
6
+ extraOptions: ExtraOptions;
7
+ globalConfig: Config.GlobalConfig;
8
+ rawModuleMap: ReturnType<TestContext['moduleMap']['getRawModuleMap']> | null;
9
+ options: TestRunnerOptions;
10
+ testPath: string;
11
+ };
12
+ export type RunTest<ExtraOptions extends Record<string, unknown> = Record<string, unknown>> = (options: RunTestOptions<ExtraOptions>, environment: CustomEnvironment) => TestResult | Promise<TestResult>;
13
+ export interface TestDetail {
14
+ title: string;
15
+ path: string;
16
+ }
17
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACjE,OAAO,KAAK,EAAE,MAAM,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAC7D,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,2BAA2B,CAAC;AAEnE,MAAM,MAAM,cAAc,CACxB,YAAY,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,IACpE;IACF,MAAM,EAAE,MAAM,CAAC,aAAa,CAAC;IAC7B,YAAY,EAAE,YAAY,CAAC;IAC3B,YAAY,EAAE,MAAM,CAAC,YAAY,CAAC;IAClC,YAAY,EAAE,UAAU,CAAC,WAAW,CAAC,WAAW,CAAC,CAAC,iBAAiB,CAAC,CAAC,GAAG,IAAI,CAAC;IAC7E,OAAO,EAAE,iBAAiB,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,OAAO,CACjB,YAAY,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,IACpE,CACF,OAAO,EAAE,cAAc,CAAC,YAAY,CAAC,EACrC,WAAW,EAAE,iBAAiB,KAC3B,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;AAEtC,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;CACd"}
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@react-native-harness/jest",
3
- "version": "1.1.0-rc.2",
3
+ "version": "1.1.0-rc.4",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -40,12 +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/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"
43
+ "@react-native-harness/bridge": "1.1.0-rc.4",
44
+ "@react-native-harness/plugins": "1.1.0-rc.4",
45
+ "@react-native-harness/platforms": "1.1.0-rc.4",
46
+ "@react-native-harness/tools": "1.1.0-rc.4",
47
+ "@react-native-harness/config": "1.1.0-rc.4",
48
+ "@react-native-harness/bundler-metro": "1.1.0-rc.4"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@types/yargs": "^17.0.32",
@@ -1,5 +1,13 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { NativeCrashError } from '../errors.js';
2
+ import { NativeCrashError, PlatformReadyTimeoutError } from '../errors.js';
3
+
4
+ describe('PlatformReadyTimeoutError', () => {
5
+ it('includes the configured timeout and config hint', () => {
6
+ expect(new PlatformReadyTimeoutError(300000).message).toBe(
7
+ 'The platform did not become ready within 300000ms. Increase "platformReadyTimeout" if your device, simulator, or emulator needs more time to start.'
8
+ );
9
+ });
10
+ });
3
11
 
4
12
  describe('NativeCrashError', () => {
5
13
  it('formats the extracted stack trace in the error message', () => {
@@ -35,7 +43,9 @@ describe('NativeCrashError', () => {
35
43
  exceptionType: 'EXC_CRASH',
36
44
  });
37
45
 
38
- expect(error.message).not.toContain('notify_get_state check indicated test daemon not ready');
46
+ expect(error.message).not.toContain(
47
+ 'notify_get_state check indicated test daemon not ready'
48
+ );
39
49
  expect(error.message).toContain('Signal: SIGABRT');
40
50
  expect(error.message).toContain('Exception: EXC_CRASH');
41
51
  expect(error.message).toContain('Process: HarnessPlayground (pid 18007)');
@@ -54,9 +64,9 @@ describe('NativeCrashError', () => {
54
64
  stackTrace: [frame],
55
65
  });
56
66
 
57
- expect(error.message.match(/MainActivity\.onCreate\(MainActivity\.kt:38\)/g)).toHaveLength(
58
- 1
59
- );
67
+ expect(
68
+ error.message.match(/MainActivity\.onCreate\(MainActivity\.kt:38\)/g)
69
+ ).toHaveLength(1);
60
70
  });
61
71
 
62
72
  it('collapses the native crash stack header so jest does not reprint multiline messages', () => {
@@ -65,6 +75,8 @@ describe('NativeCrashError', () => {
65
75
  summary: ['line one', 'line two'].join('\n'),
66
76
  });
67
77
 
68
- expect(error.stack).toBe('NativeCrashError: The native app crashed while preparing to run this test file.');
78
+ expect(error.stack).toBe(
79
+ 'NativeCrashError: The native app crashed while preparing to run this test file.'
80
+ );
69
81
  });
70
82
  });
@@ -24,6 +24,7 @@ const platform: HarnessPlatform = {
24
24
  platformId: 'ios',
25
25
  runner: '/virtual/platform-runner.js',
26
26
  config: {},
27
+ getResourceLockKey: () => 'ios:simulator:iPhone 17 Pro:26.2',
27
28
  };
28
29
 
29
30
  const createHarnessConfig = (
@@ -31,14 +32,13 @@ const createHarnessConfig = (
31
32
  ): HarnessConfig =>
32
33
  ({
33
34
  appRegistryComponentName: 'App',
34
- webSocketPort: 8081,
35
35
  disableViewFlattening: false,
36
36
  bridgeTimeout: 5000,
37
37
  entryPoint: 'index.js',
38
38
  unstable__enableMetroCache: true,
39
39
  forwardClientLogs: false,
40
40
  ...overrides,
41
- }) as HarnessConfig;
41
+ } as HarnessConfig);
42
42
 
43
43
  describe('maybeLogMetroCacheReuse', () => {
44
44
  beforeEach(() => {
@@ -1,6 +1,8 @@
1
1
  import { EventEmitter } from 'node:events';
2
+ import { HARNESS_BRIDGE_PATH } from '@react-native-harness/bridge';
2
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
4
  import type { Config as HarnessConfig } from '@react-native-harness/config';
5
+ import { MetroPortRangeExhaustedError } from '../errors.js';
4
6
  import { definePlugin } from '@react-native-harness/plugins';
5
7
  import type {
6
8
  AppMonitor,
@@ -24,18 +26,23 @@ const mocks = vi.hoisted(() => ({
24
26
  getMetroInstance: vi.fn(),
25
27
  isMetroCacheReusable: vi.fn(() => false),
26
28
  logMetroCacheReused: vi.fn(),
29
+ logMetroPortFallback: vi.fn(),
30
+ logRunnerStarting: vi.fn(),
31
+ logRunnerStillWaitingInQueue: vi.fn(),
32
+ logRunnerWaitingInQueue: vi.fn(),
27
33
  waitForMetroBackedAppReady: vi.fn(),
34
+ isPortAvailable: vi.fn(async () => true),
28
35
  }));
29
36
 
30
37
  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
- );
38
+ const actual = await vi.importActual<
39
+ typeof import('@react-native-harness/bundler-metro')
40
+ >('@react-native-harness/bundler-metro');
35
41
 
36
42
  return {
37
43
  ...actual,
38
44
  getMetroInstance: mocks.getMetroInstance,
45
+ isPortAvailable: mocks.isPortAvailable,
39
46
  isMetroCacheReusable: mocks.isMetroCacheReusable,
40
47
  waitForMetroBackedAppReady: mocks.waitForMetroBackedAppReady,
41
48
  };
@@ -47,13 +54,16 @@ vi.mock('@react-native-harness/bridge/server', () => ({
47
54
 
48
55
  vi.mock('../logs.js', () => ({
49
56
  logMetroCacheReused: mocks.logMetroCacheReused,
57
+ logMetroPortFallback: mocks.logMetroPortFallback,
58
+ logRunnerStarting: mocks.logRunnerStarting,
59
+ logRunnerStillWaitingInQueue: mocks.logRunnerStillWaitingInQueue,
60
+ logRunnerWaitingInQueue: mocks.logRunnerWaitingInQueue,
50
61
  }));
51
62
 
52
63
  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
- );
64
+ const actual = await vi.importActual<
65
+ typeof import('@react-native-harness/tools')
66
+ >('@react-native-harness/tools');
57
67
 
58
68
  return {
59
69
  ...actual,
@@ -62,13 +72,14 @@ vi.mock('@react-native-harness/tools', async () => {
62
72
  });
63
73
 
64
74
  import { getHarness, waitForAppReady } from '../harness.js';
65
- import { StartupStallError } from '../errors.js';
75
+ import { PlatformReadyTimeoutError, StartupStallError } from '../errors.js';
66
76
 
67
77
  const createBridgeServer = () => {
68
78
  const emitter = new EventEmitter();
69
79
 
70
80
  return {
71
81
  serverBridge: {
82
+ ws: {} as BridgeServer['ws'],
72
83
  rpc: {
73
84
  clients: [],
74
85
  },
@@ -117,6 +128,8 @@ const createMetroInstance = (
117
128
  overrides: Partial<MetroInstance> = {}
118
129
  ): MetroInstance => ({
119
130
  events: createReporter(),
131
+ httpServer: {} as never,
132
+ websocketEndpoints: {},
120
133
  waitUntilHealthy: vi.fn(async () => 'HTTP 200: packager-status:running'),
121
134
  prewarm: vi.fn(async () => false),
122
135
  dispose: vi.fn(async () => undefined),
@@ -176,16 +189,19 @@ const createHarnessConfig = (
176
189
  forwardClientLogs: false,
177
190
  maxAppRestarts: 2,
178
191
  metroPort: 8081,
192
+ platformReadyTimeout: 300_000,
179
193
  resetEnvironmentBetweenTestFiles: true,
180
194
  runners: [],
181
195
  unstable__enableMetroCache: false,
182
196
  unstable__skipAlreadyIncludedModules: false,
183
197
  webSocketPort: 3001,
184
198
  ...overrides,
185
- }) as HarnessConfig;
199
+ } as HarnessConfig);
186
200
 
187
201
  beforeEach(() => {
188
202
  vi.clearAllMocks();
203
+ mocks.isPortAvailable.mockReset();
204
+ mocks.isPortAvailable.mockResolvedValue(true);
189
205
  });
190
206
 
191
207
  afterEach(() => {
@@ -291,6 +307,208 @@ describe('waitForAppReady', () => {
291
307
  });
292
308
 
293
309
  describe('getHarness', () => {
310
+ it('fails when the platform runner does not become ready within platformReadyTimeout', async () => {
311
+ const { serverBridge } = createBridgeServer();
312
+ const metroInstance = createMetroInstance();
313
+
314
+ mocks.getBridgeServer.mockResolvedValue(serverBridge);
315
+ mocks.getMetroInstance.mockResolvedValue(metroInstance);
316
+
317
+ (
318
+ globalThis as typeof globalThis & {
319
+ __HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise<unknown>;
320
+ }
321
+ ).__HARNESS_PLATFORM_RUNNER__ = vi.fn(
322
+ async () =>
323
+ await new Promise((_, reject) => {
324
+ setTimeout(() => {
325
+ reject(new DOMException('The operation was aborted', 'AbortError'));
326
+ }, 20);
327
+ })
328
+ );
329
+
330
+ const platform: HarnessPlatform = {
331
+ config: {},
332
+ getResourceLockKey: () => 'ios:test-platform-ready-timeout',
333
+ name: 'ios',
334
+ platformId: 'ios',
335
+ runner: `data:text/javascript,${encodeURIComponent(
336
+ 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);'
337
+ )}`,
338
+ };
339
+
340
+ await expect(
341
+ getHarness(
342
+ createHarnessConfig({
343
+ platformReadyTimeout: 10,
344
+ }),
345
+ platform,
346
+ '/tmp/project'
347
+ )
348
+ ).rejects.toBeInstanceOf(PlatformReadyTimeoutError);
349
+ });
350
+
351
+ it('passes a platform init signal to the runner factory', async () => {
352
+ const { serverBridge } = createBridgeServer();
353
+ const appMonitor = createAppMonitor();
354
+ const platformInstance = createPlatformRunner({
355
+ createAppMonitor: () => appMonitor.appMonitor,
356
+ });
357
+ const metroInstance = createMetroInstance();
358
+
359
+ mocks.getBridgeServer.mockResolvedValue(serverBridge);
360
+ mocks.getMetroInstance.mockResolvedValue(metroInstance);
361
+
362
+ const runner = vi.fn(async () => platformInstance);
363
+ (
364
+ globalThis as typeof globalThis & {
365
+ __HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise<unknown>;
366
+ }
367
+ ).__HARNESS_PLATFORM_RUNNER__ = runner;
368
+
369
+ const platform: HarnessPlatform = {
370
+ config: {},
371
+ getResourceLockKey: () => 'ios:test-platform-init-signal',
372
+ name: 'ios',
373
+ platformId: 'ios',
374
+ runner: `data:text/javascript,${encodeURIComponent(
375
+ 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);'
376
+ )}`,
377
+ };
378
+
379
+ const harness = await getHarness(
380
+ createHarnessConfig(),
381
+ platform,
382
+ '/tmp/project'
383
+ );
384
+
385
+ expect(runner).toHaveBeenCalledWith(
386
+ platform.config,
387
+ expect.objectContaining({
388
+ metroPort: 8081,
389
+ }),
390
+ expect.objectContaining({
391
+ signal: expect.any(AbortSignal),
392
+ })
393
+ );
394
+
395
+ await harness.dispose();
396
+ });
397
+
398
+ it('resolves and exposes a fallback Metro port before platform init', async () => {
399
+ const { serverBridge } = createBridgeServer();
400
+ const appMonitor = createAppMonitor();
401
+ const platformInstance = createPlatformRunner({
402
+ createAppMonitor: () => appMonitor.appMonitor,
403
+ });
404
+ const metroInstance = createMetroInstance();
405
+
406
+ mocks.getBridgeServer.mockResolvedValue(serverBridge);
407
+ mocks.getMetroInstance.mockResolvedValue(metroInstance);
408
+ mocks.isPortAvailable
409
+ .mockResolvedValueOnce(false)
410
+ .mockResolvedValueOnce(true);
411
+
412
+ const runner = vi.fn(async () => platformInstance);
413
+ (
414
+ globalThis as typeof globalThis & {
415
+ __HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise<unknown>;
416
+ }
417
+ ).__HARNESS_PLATFORM_RUNNER__ = runner;
418
+
419
+ const platform: HarnessPlatform = {
420
+ config: {},
421
+ getResourceLockKey: () => 'android:emulator:Pixel_8_API_35',
422
+ name: 'android',
423
+ platformId: 'android',
424
+ runner: `data:text/javascript,${encodeURIComponent(
425
+ 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);'
426
+ )}`,
427
+ };
428
+
429
+ const harness = await getHarness(
430
+ createHarnessConfig(),
431
+ platform,
432
+ '/tmp/project'
433
+ );
434
+
435
+ expect(harness.config.metroPort).toBe(8082);
436
+ expect(mocks.getMetroInstance).toHaveBeenCalledWith(
437
+ expect.objectContaining({
438
+ harnessConfig: expect.objectContaining({
439
+ metroPort: 8082,
440
+ }),
441
+ }),
442
+ expect.any(AbortSignal)
443
+ );
444
+ expect(runner).toHaveBeenCalledWith(
445
+ platform.config,
446
+ expect.objectContaining({
447
+ metroPort: 8082,
448
+ }),
449
+ expect.objectContaining({
450
+ signal: expect.any(AbortSignal),
451
+ })
452
+ );
453
+ expect(mocks.logMetroPortFallback).toHaveBeenCalledWith(8081, 8082);
454
+
455
+ await harness.dispose();
456
+ });
457
+
458
+ it('fails when no Metro port is available in the retry window', async () => {
459
+ mocks.isPortAvailable.mockResolvedValue(false);
460
+
461
+ const platform: HarnessPlatform = {
462
+ config: {},
463
+ getResourceLockKey: () => 'android:emulator:Pixel_8_API_35',
464
+ name: 'android',
465
+ platformId: 'android',
466
+ runner: 'data:text/javascript,export default async () => ({})',
467
+ };
468
+
469
+ await expect(
470
+ getHarness(createHarnessConfig(), platform, '/tmp/project')
471
+ ).rejects.toBeInstanceOf(MetroPortRangeExhaustedError);
472
+
473
+ expect(mocks.getBridgeServer).not.toHaveBeenCalled();
474
+ expect(mocks.getMetroInstance).not.toHaveBeenCalled();
475
+ });
476
+
477
+ it('falls back to a default resource lock key for platforms without getResourceLockKey', async () => {
478
+ const { serverBridge } = createBridgeServer();
479
+ const appMonitor = createAppMonitor();
480
+ const platformInstance = createPlatformRunner({
481
+ createAppMonitor: () => appMonitor.appMonitor,
482
+ });
483
+ const metroInstance = createMetroInstance();
484
+
485
+ mocks.getBridgeServer.mockResolvedValue(serverBridge);
486
+ mocks.getMetroInstance.mockResolvedValue(metroInstance);
487
+
488
+ (
489
+ globalThis as typeof globalThis & {
490
+ __HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise<unknown>;
491
+ }
492
+ ).__HARNESS_PLATFORM_RUNNER__ = vi.fn(async () => platformInstance);
493
+
494
+ const platform: HarnessPlatform = {
495
+ config: {},
496
+ name: 'legacy-ios',
497
+ platformId: 'ios',
498
+ runner: `data:text/javascript,${encodeURIComponent(
499
+ 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);'
500
+ )}`,
501
+ };
502
+
503
+ const harness = await getHarness(
504
+ createHarnessConfig(),
505
+ platform,
506
+ '/tmp/project'
507
+ );
508
+
509
+ await harness.dispose();
510
+ });
511
+
294
512
  it('routes ensureAppReady through the shared Metro startup helper', async () => {
295
513
  const { serverBridge, emitReady } = createBridgeServer();
296
514
  const appMonitor = createAppMonitor();
@@ -306,9 +524,7 @@ describe('getHarness', () => {
306
524
  mocks.waitForMetroBackedAppReady.mockImplementationOnce(
307
525
  async (options: WaitForMetroBackedAppReadyOptions) => {
308
526
  await options.startAttempt();
309
- const readyPromise = options.waitForReady(
310
- new AbortController().signal
311
- );
527
+ const readyPromise = options.waitForReady(new AbortController().signal);
312
528
  emitReady();
313
529
  await readyPromise;
314
530
  }
@@ -333,10 +549,13 @@ describe('getHarness', () => {
333
549
  runner: `data:text/javascript,${encodeURIComponent(
334
550
  'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);'
335
551
  )}`,
552
+ getResourceLockKey: () => 'ios:simulator:iPhone 17 Pro:26.2',
336
553
  };
337
554
 
338
555
  const harness = await getHarness(
339
- createHarnessConfig(),
556
+ createHarnessConfig({
557
+ bridgeTimeout: 1,
558
+ }),
340
559
  platform,
341
560
  '/tmp/project'
342
561
  );
@@ -371,9 +590,7 @@ describe('getHarness', () => {
371
590
  mocks.waitForMetroBackedAppReady.mockImplementationOnce(
372
591
  async (options: WaitForMetroBackedAppReadyOptions) => {
373
592
  await options.startAttempt();
374
- const readyPromise = options.waitForReady(
375
- new AbortController().signal
376
- );
593
+ const readyPromise = options.waitForReady(new AbortController().signal);
377
594
  emitReady();
378
595
  await readyPromise;
379
596
  }
@@ -398,6 +615,7 @@ describe('getHarness', () => {
398
615
  runner: `data:text/javascript,${encodeURIComponent(
399
616
  'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);'
400
617
  )}`,
618
+ getResourceLockKey: () => 'ios:simulator:iPhone 17 Pro:26.2',
401
619
  };
402
620
 
403
621
  const harness = await getHarness(
@@ -406,6 +624,19 @@ describe('getHarness', () => {
406
624
  '/tmp/project'
407
625
  );
408
626
 
627
+ expect(mocks.getBridgeServer).toHaveBeenCalledWith(
628
+ expect.objectContaining({
629
+ noServer: true,
630
+ })
631
+ );
632
+ expect(mocks.getMetroInstance).toHaveBeenCalledWith(
633
+ expect.objectContaining({
634
+ websocketEndpoints: {
635
+ [HARNESS_BRIDGE_PATH]: serverBridge.ws,
636
+ },
637
+ }),
638
+ expect.any(AbortSignal)
639
+ );
409
640
  await harness.restart('/tmp/restart.harness.ts');
410
641
 
411
642
  expect(stopApp).toHaveBeenCalledTimes(1);
@@ -456,7 +687,25 @@ describe('plugins', () => {
456
687
  beforeCreation: (ctx) => {
457
688
  ctx.state.creationCount += 1;
458
689
  observedHooks.push(
459
- `beforeCreation:${ctx.platform.platformId}:${ctx.appLaunchOptions == null ? 'no-launch-options' : 'launch-options'}`
690
+ `beforeCreation:${ctx.platform.platformId}:${
691
+ ctx.appLaunchOptions == null
692
+ ? 'no-launch-options'
693
+ : 'launch-options'
694
+ }`
695
+ );
696
+ },
697
+ beforeRun: (ctx) => {
698
+ observedHooks.push(
699
+ `beforeRun:${ctx.platform.platformId}:${
700
+ ctx.appLaunchOptions == null
701
+ ? 'no-launch-options'
702
+ : 'launch-options'
703
+ }`
704
+ );
705
+ },
706
+ afterRun: (ctx) => {
707
+ observedHooks.push(
708
+ `afterRun:${ctx.state.creationCount}:${ctx.reason}`
460
709
  );
461
710
  },
462
711
  beforeDispose: (ctx) => {
@@ -467,7 +716,9 @@ describe('plugins', () => {
467
716
  },
468
717
  runtime: {
469
718
  ready: (ctx) => {
470
- observedHooks.push(`runtime.ready:${ctx.runId}:${ctx.device.platform}`);
719
+ observedHooks.push(
720
+ `runtime.ready:${ctx.runId}:${ctx.device.platform}`
721
+ );
471
722
  },
472
723
  disconnected: (ctx) => {
473
724
  observedHooks.push(`runtime.disconnected:${ctx.reason}`);
@@ -494,6 +745,7 @@ describe('plugins', () => {
494
745
  runner: `data:text/javascript,${encodeURIComponent(
495
746
  'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);'
496
747
  )}`,
748
+ getResourceLockKey: () => 'ios:simulator:iPhone 17 Pro:26.2',
497
749
  };
498
750
 
499
751
  const harness = await getHarness(
@@ -530,12 +782,83 @@ describe('plugins', () => {
530
782
 
531
783
  expect(observedHooks).toEqual([
532
784
  'beforeCreation:ios:launch-options',
785
+ 'beforeRun:ios:launch-options',
533
786
  'runtime.ready:run-1:ios',
534
787
  'collection.started:example.harness.ts',
535
788
  'runtime.disconnected:bridge-disconnected',
789
+ 'afterRun:1:normal',
536
790
  'beforeDispose:1:normal',
537
791
  ]);
538
792
  });
793
+
794
+ it('waits in queue before starting Metro and releases the lock on dispose', async () => {
795
+ const resourceKey = 'ios:simulator:iPhone 17 Pro:26.2';
796
+ const firstPlatformRunner = createPlatformRunner();
797
+ const secondPlatformRunner = createPlatformRunner();
798
+ const secondAppMonitor = createAppMonitor();
799
+ const firstMetroInstance = createMetroInstance();
800
+ const secondMetroInstance = createMetroInstance();
801
+ const firstBridge = createBridgeServer();
802
+ const secondBridge = createBridgeServer();
803
+
804
+ mocks.getBridgeServer
805
+ .mockResolvedValueOnce(firstBridge.serverBridge)
806
+ .mockResolvedValueOnce(secondBridge.serverBridge);
807
+ mocks.getMetroInstance
808
+ .mockResolvedValueOnce(firstMetroInstance)
809
+ .mockResolvedValueOnce(secondMetroInstance);
810
+
811
+ let invocationCount = 0;
812
+ (
813
+ globalThis as typeof globalThis & {
814
+ __HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise<unknown>;
815
+ }
816
+ ).__HARNESS_PLATFORM_RUNNER__ = vi.fn(async () => {
817
+ invocationCount += 1;
818
+ return invocationCount === 1
819
+ ? firstPlatformRunner
820
+ : createPlatformRunner({
821
+ createAppMonitor: () => secondAppMonitor.appMonitor,
822
+ dispose: secondPlatformRunner.dispose,
823
+ });
824
+ });
825
+
826
+ const platform: HarnessPlatform = {
827
+ config: {},
828
+ name: 'ios',
829
+ platformId: 'ios',
830
+ runner: `data:text/javascript,${encodeURIComponent(
831
+ 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);'
832
+ )}`,
833
+ getResourceLockKey: () => resourceKey,
834
+ };
835
+
836
+ const firstHarness = await getHarness(
837
+ createHarnessConfig(),
838
+ platform,
839
+ '/tmp/project'
840
+ );
841
+
842
+ const secondHarnessPromise = getHarness(
843
+ createHarnessConfig(),
844
+ platform,
845
+ '/tmp/project'
846
+ );
847
+
848
+ await new Promise((resolve) => setTimeout(resolve, 1100));
849
+
850
+ expect(mocks.logRunnerWaitingInQueue).toHaveBeenCalledWith(platform);
851
+ expect(mocks.logRunnerStarting).not.toHaveBeenCalled();
852
+ expect(mocks.getMetroInstance).toHaveBeenCalledTimes(1);
853
+
854
+ await firstHarness.dispose();
855
+ const secondHarness = await secondHarnessPromise;
856
+
857
+ expect(mocks.logRunnerStarting).toHaveBeenCalledWith(platform);
858
+ expect(mocks.getMetroInstance).toHaveBeenCalledTimes(2);
859
+
860
+ await secondHarness.dispose();
861
+ });
539
862
  });
540
863
 
541
864
  describe('StartupStallError', () => {
@@ -0,0 +1,61 @@
1
+ import { 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
+ import { resolveHarnessMetroPort } from '../metro-port.js';
5
+
6
+ const mocks = vi.hoisted(() => ({
7
+ isPortAvailable: vi.fn(async () => true),
8
+ }));
9
+
10
+ vi.mock('@react-native-harness/bundler-metro', () => ({
11
+ isPortAvailable: mocks.isPortAvailable,
12
+ }));
13
+
14
+ const createConfig = (overrides: Partial<HarnessConfig> = {}): HarnessConfig =>
15
+ ({
16
+ appRegistryComponentName: 'App',
17
+ bridgeTimeout: 60_000,
18
+ bundleStartTimeout: 60_000,
19
+ crashDetectionInterval: 500,
20
+ defaultRunner: 'ios-device',
21
+ detectNativeCrashes: true,
22
+ disableViewFlattening: false,
23
+ entryPoint: 'index.js',
24
+ forwardClientLogs: false,
25
+ maxAppRestarts: 2,
26
+ metroPort: 8081,
27
+ platformReadyTimeout: 300_000,
28
+ resetEnvironmentBetweenTestFiles: true,
29
+ runners: [],
30
+ unstable__enableMetroCache: false,
31
+ unstable__skipAlreadyIncludedModules: false,
32
+ ...overrides,
33
+ } as HarnessConfig);
34
+
35
+ describe('resolveHarnessMetroPort', () => {
36
+ it('skips fallback allocation for iOS physical device runners', async () => {
37
+ const acquire = vi.fn();
38
+ const config = createConfig();
39
+ const platform: HarnessPlatform = {
40
+ config: {},
41
+ name: 'ios-device',
42
+ platformId: 'ios',
43
+ runner: 'unused',
44
+ };
45
+
46
+ const result = await resolveHarnessMetroPort({
47
+ config,
48
+ platform,
49
+ resourceLockManager: {
50
+ acquire,
51
+ },
52
+ signal: new AbortController().signal,
53
+ });
54
+
55
+ expect(result.config).toBe(config);
56
+ expect(result.metroPortLease).toBeNull();
57
+ expect(result.didFallback).toBe(false);
58
+ expect(acquire).not.toHaveBeenCalled();
59
+ expect(mocks.isPortAvailable).not.toHaveBeenCalled();
60
+ });
61
+ });