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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/__tests__/harness.test.js +59 -1
  2. package/dist/__tests__/metro-port.test.d.ts +2 -0
  3. package/dist/__tests__/metro-port.test.d.ts.map +1 -0
  4. package/dist/__tests__/metro-port.test.js +52 -0
  5. package/dist/__tests__/resource-lock.test.js +36 -1
  6. package/dist/errors.d.ts +5 -0
  7. package/dist/errors.d.ts.map +1 -1
  8. package/dist/errors.js +11 -0
  9. package/dist/harness.d.ts +1 -0
  10. package/dist/harness.d.ts.map +1 -1
  11. package/dist/harness.js +26 -13
  12. package/dist/logs.d.ts +1 -0
  13. package/dist/logs.d.ts.map +1 -1
  14. package/dist/logs.js +3 -0
  15. package/dist/metro-port.d.ts +16 -0
  16. package/dist/metro-port.d.ts.map +1 -0
  17. package/dist/metro-port.js +48 -0
  18. package/dist/resource-lock.d.ts.map +1 -1
  19. package/dist/resource-lock.js +32 -11
  20. package/dist/setup.js +1 -1
  21. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  22. package/package.json +7 -7
  23. package/src/__tests__/harness.test.ts +89 -1
  24. package/src/__tests__/metro-port.test.ts +61 -0
  25. package/src/__tests__/resource-lock.test.ts +58 -4
  26. package/src/errors.ts +13 -0
  27. package/src/harness.ts +34 -12
  28. package/src/logs.ts +11 -0
  29. package/src/metro-port.ts +76 -0
  30. package/src/resource-lock.ts +59 -37
  31. package/src/setup.ts +1 -1
  32. package/dist/cli-args.test.d.ts +0 -2
  33. package/dist/cli-args.test.d.ts.map +0 -1
  34. package/dist/cli-args.test.js +0 -1
  35. package/dist/crash-monitor.d.ts +0 -16
  36. package/dist/crash-monitor.d.ts.map +0 -1
  37. package/dist/crash-monitor.js +0 -75
  38. package/dist/event-mapper.d.ts +0 -16
  39. package/dist/event-mapper.d.ts.map +0 -1
  40. package/dist/event-mapper.js +0 -120
  41. package/dist/global-setup.d.ts +0 -3
  42. package/dist/global-setup.d.ts.map +0 -1
  43. package/dist/global-setup.js +0 -60
  44. package/dist/global-teardown.d.ts +0 -3
  45. package/dist/global-teardown.d.ts.map +0 -1
  46. package/dist/global-teardown.js +0 -11
  47. package/dist/harness-reporter.d.ts +0 -8
  48. package/dist/harness-reporter.d.ts.map +0 -1
  49. package/dist/harness-reporter.js +0 -12
  50. package/dist/jest-options.d.ts +0 -2
  51. package/dist/jest-options.d.ts.map +0 -1
  52. package/dist/jest-options.js +0 -1
  53. package/dist/types.d.ts +0 -17
  54. package/dist/types.d.ts.map +0 -1
  55. 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.3",
3
+ "version": "1.1.0",
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/bridge": "1.1.0-rc.3",
44
- "@react-native-harness/config": "1.1.0-rc.3",
45
- "@react-native-harness/bundler-metro": "1.1.0-rc.3",
46
- "@react-native-harness/platforms": "1.1.0-rc.3",
47
- "@react-native-harness/tools": "1.1.0-rc.3",
48
- "@react-native-harness/plugins": "1.1.0-rc.3"
43
+ "@react-native-harness/bridge": "1.1.0",
44
+ "@react-native-harness/plugins": "1.1.0",
45
+ "@react-native-harness/bundler-metro": "1.1.0",
46
+ "@react-native-harness/platforms": "1.1.0",
47
+ "@react-native-harness/config": "1.1.0",
48
+ "@react-native-harness/tools": "1.1.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@types/yargs": "^17.0.32",
@@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events';
2
2
  import { HARNESS_BRIDGE_PATH } from '@react-native-harness/bridge';
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
4
  import type { Config as HarnessConfig } from '@react-native-harness/config';
5
+ import { MetroPortRangeExhaustedError } from '../errors.js';
5
6
  import { definePlugin } from '@react-native-harness/plugins';
6
7
  import type {
7
8
  AppMonitor,
@@ -25,10 +26,12 @@ const mocks = vi.hoisted(() => ({
25
26
  getMetroInstance: vi.fn(),
26
27
  isMetroCacheReusable: vi.fn(() => false),
27
28
  logMetroCacheReused: vi.fn(),
29
+ logMetroPortFallback: vi.fn(),
28
30
  logRunnerStarting: vi.fn(),
29
31
  logRunnerStillWaitingInQueue: vi.fn(),
30
32
  logRunnerWaitingInQueue: vi.fn(),
31
33
  waitForMetroBackedAppReady: vi.fn(),
34
+ isPortAvailable: vi.fn(async () => true),
32
35
  }));
33
36
 
34
37
  vi.mock('@react-native-harness/bundler-metro', async () => {
@@ -39,6 +42,7 @@ vi.mock('@react-native-harness/bundler-metro', async () => {
39
42
  return {
40
43
  ...actual,
41
44
  getMetroInstance: mocks.getMetroInstance,
45
+ isPortAvailable: mocks.isPortAvailable,
42
46
  isMetroCacheReusable: mocks.isMetroCacheReusable,
43
47
  waitForMetroBackedAppReady: mocks.waitForMetroBackedAppReady,
44
48
  };
@@ -50,6 +54,7 @@ vi.mock('@react-native-harness/bridge/server', () => ({
50
54
 
51
55
  vi.mock('../logs.js', () => ({
52
56
  logMetroCacheReused: mocks.logMetroCacheReused,
57
+ logMetroPortFallback: mocks.logMetroPortFallback,
53
58
  logRunnerStarting: mocks.logRunnerStarting,
54
59
  logRunnerStillWaitingInQueue: mocks.logRunnerStillWaitingInQueue,
55
60
  logRunnerWaitingInQueue: mocks.logRunnerWaitingInQueue,
@@ -195,6 +200,8 @@ const createHarnessConfig = (
195
200
 
196
201
  beforeEach(() => {
197
202
  vi.clearAllMocks();
203
+ mocks.isPortAvailable.mockReset();
204
+ mocks.isPortAvailable.mockResolvedValue(true);
198
205
  });
199
206
 
200
207
  afterEach(() => {
@@ -377,7 +384,9 @@ describe('getHarness', () => {
377
384
 
378
385
  expect(runner).toHaveBeenCalledWith(
379
386
  platform.config,
380
- expect.any(Object),
387
+ expect.objectContaining({
388
+ metroPort: 8081,
389
+ }),
381
390
  expect.objectContaining({
382
391
  signal: expect.any(AbortSignal),
383
392
  })
@@ -386,6 +395,85 @@ describe('getHarness', () => {
386
395
  await harness.dispose();
387
396
  });
388
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
+
389
477
  it('falls back to a default resource lock key for platforms without getResourceLockKey', async () => {
390
478
  const { serverBridge } = createBridgeServer();
391
479
  const appMonitor = createAppMonitor();
@@ -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
+ });
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
5
  import {
6
6
  createResourceLockManager,
7
7
  hashResourceLockKey,
@@ -12,7 +12,7 @@ describe('resource lock manager', () => {
12
12
 
13
13
  beforeEach(async () => {
14
14
  rootDir = await fs.mkdtemp(
15
- path.join(os.tmpdir(), 'react-native-harness-resource-lock-test-')
15
+ path.join(os.tmpdir(), 'react-native-harness-resource-lock-test-'),
16
16
  );
17
17
  });
18
18
 
@@ -30,7 +30,7 @@ describe('resource lock manager', () => {
30
30
  const order: string[] = [];
31
31
 
32
32
  const firstLease = await manager.acquire(
33
- 'ios:simulator:iPhone 17 Pro:26.2'
33
+ 'ios:simulator:iPhone 17 Pro:26.2',
34
34
  );
35
35
  const secondAcquire = manager
36
36
  .acquire('ios:simulator:iPhone 17 Pro:26.2', {
@@ -124,7 +124,7 @@ describe('resource lock manager', () => {
124
124
  createdAt: Date.now() - 1000,
125
125
  heartbeatAt: Date.now() - 1000,
126
126
  }),
127
- 'utf8'
127
+ 'utf8',
128
128
  );
129
129
 
130
130
  const lease = await manager.acquire(key);
@@ -135,4 +135,58 @@ describe('resource lock manager', () => {
135
135
 
136
136
  await lease.release();
137
137
  });
138
+
139
+ it('keeps owner metadata valid when heartbeat writes overlap', async () => {
140
+ const manager = createResourceLockManager({
141
+ rootDir,
142
+ pollIntervalMs: 5,
143
+ heartbeatIntervalMs: 10,
144
+ staleLockTimeoutMs: 200,
145
+ });
146
+ const key = 'ios:simulator:iPhone 17 Pro:26.2';
147
+ const ownerFilePath = path.join(
148
+ rootDir,
149
+ hashResourceLockKey(key),
150
+ 'owner.json',
151
+ );
152
+ const actualWriteFile = fs.writeFile.bind(fs);
153
+ const writeFileSpy = vi
154
+ .spyOn(fs, 'writeFile')
155
+ .mockImplementation(async (file, data, options) => {
156
+ if (
157
+ typeof file === 'string' &&
158
+ file.startsWith(ownerFilePath) &&
159
+ file !== ownerFilePath
160
+ ) {
161
+ await new Promise((resolve) => setTimeout(resolve, 25));
162
+ }
163
+
164
+ return await actualWriteFile(file, data, options);
165
+ });
166
+
167
+ try {
168
+ const lease = await manager.acquire(key);
169
+ const initialOwner = JSON.parse(
170
+ await fs.readFile(ownerFilePath, 'utf8'),
171
+ ) as ResourceLockOwner;
172
+
173
+ await new Promise((resolve) => setTimeout(resolve, 80));
174
+
175
+ for (let index = 0; index < 5; index += 1) {
176
+ const owner = JSON.parse(
177
+ await fs.readFile(ownerFilePath, 'utf8'),
178
+ ) as ResourceLockOwner;
179
+ expect(owner.ticketId).toBe(initialOwner.ticketId);
180
+ await new Promise((resolve) => setTimeout(resolve, 10));
181
+ }
182
+
183
+ await lease.release();
184
+ } finally {
185
+ writeFileSpy.mockRestore();
186
+ }
187
+ });
138
188
  });
189
+
190
+ type ResourceLockOwner = {
191
+ ticketId: string;
192
+ };
package/src/errors.ts CHANGED
@@ -36,6 +36,19 @@ export class PlatformReadyTimeoutError extends HarnessError {
36
36
  }
37
37
  }
38
38
 
39
+ export class MetroPortRangeExhaustedError extends HarnessError {
40
+ constructor(
41
+ public readonly initialPort: number,
42
+ public readonly attempts: number
43
+ ) {
44
+ const finalPort = initialPort + attempts - 1;
45
+ super(
46
+ `Harness could not find an available Metro port in the range ${initialPort}-${finalPort}.`
47
+ );
48
+ this.name = 'MetroPortRangeExhaustedError';
49
+ }
50
+ }
51
+
39
52
  export type NativeCrashPhase = 'startup' | 'execution';
40
53
 
41
54
  export type NativeCrashDetails = AppCrashDetails & {
package/src/harness.ts CHANGED
@@ -49,11 +49,13 @@ import { createClientLogListener } from './client-log-handler.js';
49
49
  import path from 'node:path';
50
50
  import {
51
51
  logMetroCacheReused,
52
+ logMetroPortFallback,
52
53
  logRunnerStarting,
53
54
  logRunnerStillWaitingInQueue,
54
55
  logRunnerWaitingInQueue,
55
56
  } from './logs.js';
56
57
  import { createResourceLockManager } from './resource-lock.js';
58
+ import { resolveHarnessMetroPort } from './metro-port.js';
57
59
 
58
60
  const harnessLogger = logger.child('runtime');
59
61
  const resourceLockManager = createResourceLockManager();
@@ -73,6 +75,7 @@ export type HarnessRunState = {
73
75
 
74
76
  export type Harness = {
75
77
  context: HarnessContext;
78
+ config: HarnessConfig;
76
79
  runTests: (
77
80
  path: string,
78
81
  options: HarnessRunTestsOptions
@@ -285,17 +288,33 @@ const getHarnessInternal = async (
285
288
  resourceLockKey
286
289
  );
287
290
  try {
288
- maybeLogMetroCacheReuse(config, platform, projectRoot);
291
+ const {
292
+ config: runtimeConfig,
293
+ metroPortLease,
294
+ initialMetroPort,
295
+ didFallback,
296
+ } = await resolveHarnessMetroPort({
297
+ config,
298
+ platform,
299
+ resourceLockManager,
300
+ signal,
301
+ });
302
+
303
+ if (didFallback) {
304
+ logMetroPortFallback(initialMetroPort, runtimeConfig.metroPort);
305
+ }
306
+
307
+ maybeLogMetroCacheReuse(runtimeConfig, platform, projectRoot);
289
308
  const pluginAbortController = new AbortController();
290
309
  const pluginManager = createHarnessPluginManager<
291
310
  HarnessConfig,
292
311
  HarnessPlatform
293
312
  >({
294
- plugins: (config.plugins ?? []) as Array<
313
+ plugins: (runtimeConfig.plugins ?? []) as Array<
295
314
  HarnessPlugin<object, HarnessConfig, HarnessPlatform>
296
315
  >,
297
316
  projectRoot,
298
- config,
317
+ config: runtimeConfig,
299
318
  runner: platform,
300
319
  abortSignal: pluginAbortController.signal,
301
320
  });
@@ -361,7 +380,7 @@ const getHarnessInternal = async (
361
380
 
362
381
  const serverBridge = await getBridgeServer({
363
382
  noServer: true,
364
- timeout: config.bridgeTimeout,
383
+ timeout: runtimeConfig.bridgeTimeout,
365
384
  context,
366
385
  });
367
386
  harnessLogger.debug(
@@ -377,7 +396,7 @@ const getHarnessInternal = async (
377
396
  getMetroInstance(
378
397
  {
379
398
  projectRoot,
380
- harnessConfig: config,
399
+ harnessConfig: runtimeConfig,
381
400
  websocketEndpoints: {
382
401
  [HARNESS_BRIDGE_PATH]:
383
402
  serverBridge.ws as unknown as MetroWebSocketEndpoint,
@@ -389,12 +408,12 @@ const getHarnessInternal = async (
389
408
  return instance;
390
409
  }),
391
410
  withPlatformReadyTimeout({
392
- timeout: config.platformReadyTimeout,
411
+ timeout: runtimeConfig.platformReadyTimeout,
393
412
  signal,
394
413
  work: async () => {
395
414
  return await import(platform.runner)
396
415
  .then((module) =>
397
- module.default(platform.config, config, {
416
+ module.default(platform.config, runtimeConfig, {
398
417
  signal,
399
418
  } satisfies HarnessPlatformInitOptions)
400
419
  )
@@ -408,6 +427,7 @@ const getHarnessInternal = async (
408
427
  } catch (error) {
409
428
  await Promise.allSettled([
410
429
  resourceLease.release(),
430
+ metroPortLease?.release(),
411
431
  serverBridge.dispose(),
412
432
  ]);
413
433
  throw error;
@@ -625,7 +645,7 @@ const getHarnessInternal = async (
625
645
  appMonitor.addListener(onAppMonitorEvent);
626
646
  harnessLogger.debug('registered runtime, bridge, and Metro listeners');
627
647
 
628
- if (config.forwardClientLogs) {
648
+ if (runtimeConfig.forwardClientLogs) {
629
649
  metroInstance.events.addListener(clientLogListener);
630
650
  harnessLogger.debug('client log forwarding enabled');
631
651
  }
@@ -656,7 +676,7 @@ const getHarnessInternal = async (
656
676
  hookError = error;
657
677
  }
658
678
 
659
- if (config.forwardClientLogs) {
679
+ if (runtimeConfig.forwardClientLogs) {
660
680
  metroInstance.events.removeListener(clientLogListener);
661
681
  }
662
682
  metroInstance.events.removeListener(onMetroEvent);
@@ -671,6 +691,7 @@ const getHarnessInternal = async (
671
691
  serverBridge.dispose(),
672
692
  platformInstance.dispose(),
673
693
  metroInstance.dispose(),
694
+ metroPortLease?.release(),
674
695
  ]);
675
696
  } catch (error) {
676
697
  cleanupError = error;
@@ -744,9 +765,9 @@ const getHarnessInternal = async (
744
765
  serverBridge,
745
766
  platformInstance: platformInstance as HarnessPlatformRunner,
746
767
  platformId: platform.platformId,
747
- bundleStartTimeout: config.bundleStartTimeout ?? 60000,
748
- readyTimeout: config.bridgeTimeout,
749
- maxAppRestarts: config.maxAppRestarts ?? 2,
768
+ bundleStartTimeout: runtimeConfig.bundleStartTimeout ?? 60000,
769
+ readyTimeout: runtimeConfig.bridgeTimeout,
770
+ maxAppRestarts: runtimeConfig.maxAppRestarts ?? 2,
750
771
  testFilePath,
751
772
  crashSupervisor,
752
773
  appLaunchOptions,
@@ -786,6 +807,7 @@ const getHarnessInternal = async (
786
807
 
787
808
  return {
788
809
  context,
810
+ config: runtimeConfig,
789
811
  runTests: async (path, options) => {
790
812
  await flushPendingHooks();
791
813
  activeTestFilePath = path;
package/src/logs.ts CHANGED
@@ -45,6 +45,17 @@ export const logMetroCacheReused = (runner: HarnessPlatform): void => {
45
45
  log(`${TAG} Reusing Metro cache for ${chalk.bold(runner.name)}\n`);
46
46
  };
47
47
 
48
+ export const logMetroPortFallback = (
49
+ initialPort: number,
50
+ selectedPort: number
51
+ ): void => {
52
+ log(
53
+ `${TAG} Harness could not use Metro port ${chalk.bold(
54
+ String(initialPort)
55
+ )} and is using ${chalk.bold(String(selectedPort))} instead.\n`
56
+ );
57
+ };
58
+
48
59
  export const getErrorMessage = (error: HarnessError): string => {
49
60
  return `${ERROR_TAG} ${error.message}\n`;
50
61
  };
@@ -0,0 +1,76 @@
1
+ import type { Config as HarnessConfig } from '@react-native-harness/config';
2
+ import { isPortAvailable } from '@react-native-harness/bundler-metro';
3
+ import type { HarnessPlatform } from '@react-native-harness/platforms';
4
+ import { MetroPortRangeExhaustedError } from './errors.js';
5
+ import type { ResourceLease, ResourceLockManager } from './resource-lock.js';
6
+
7
+ export const METRO_PORT_SCAN_ATTEMPTS = 10;
8
+
9
+ const getPortLockKey = (host: string | undefined, port: number): string => {
10
+ return `metro-port:${host?.trim() || '*'}:${port}`;
11
+ };
12
+
13
+ const isIosPhysicalRunner = (platform: HarnessPlatform): boolean => {
14
+ return platform.platformId === 'ios' && platform.name.includes('device');
15
+ };
16
+
17
+ export const resolveHarnessMetroPort = async (options: {
18
+ config: HarnessConfig;
19
+ platform: HarnessPlatform;
20
+ resourceLockManager: ResourceLockManager;
21
+ signal: AbortSignal;
22
+ }): Promise<{
23
+ config: HarnessConfig;
24
+ metroPortLease: ResourceLease | null;
25
+ initialMetroPort: number;
26
+ didFallback: boolean;
27
+ }> => {
28
+ const { config, platform, resourceLockManager, signal } = options;
29
+ const initialMetroPort = config.metroPort;
30
+ const host = config.host?.trim();
31
+
32
+ if (isIosPhysicalRunner(platform)) {
33
+ return {
34
+ config,
35
+ metroPortLease: null,
36
+ initialMetroPort,
37
+ didFallback: false,
38
+ };
39
+ }
40
+
41
+ for (let attempt = 0; attempt < METRO_PORT_SCAN_ATTEMPTS; attempt += 1) {
42
+ const candidatePort = initialMetroPort + attempt;
43
+ const metroPortLease = await resourceLockManager.acquire(
44
+ getPortLockKey(host, candidatePort),
45
+ { signal }
46
+ );
47
+
48
+ try {
49
+ if (!(await isPortAvailable(candidatePort, host))) {
50
+ await metroPortLease.release();
51
+ continue;
52
+ }
53
+
54
+ return {
55
+ config:
56
+ candidatePort === initialMetroPort
57
+ ? config
58
+ : {
59
+ ...config,
60
+ metroPort: candidatePort,
61
+ },
62
+ metroPortLease,
63
+ initialMetroPort,
64
+ didFallback: candidatePort !== initialMetroPort,
65
+ };
66
+ } catch (error) {
67
+ await metroPortLease.release();
68
+ throw error;
69
+ }
70
+ }
71
+
72
+ throw new MetroPortRangeExhaustedError(
73
+ initialMetroPort,
74
+ METRO_PORT_SCAN_ATTEMPTS
75
+ );
76
+ };