@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.
- package/dist/__tests__/errors.test.js +6 -1
- package/dist/__tests__/harness-cache.test.js +1 -1
- package/dist/__tests__/harness.test.js +205 -3
- package/dist/__tests__/metro-port.test.d.ts +2 -0
- package/dist/__tests__/metro-port.test.d.ts.map +1 -0
- package/dist/__tests__/metro-port.test.js +52 -0
- package/dist/__tests__/resource-lock.test.d.ts +2 -0
- package/dist/__tests__/resource-lock.test.d.ts.map +1 -0
- package/dist/__tests__/resource-lock.test.js +138 -0
- package/dist/action-hooks.d.ts +9 -0
- package/dist/action-hooks.d.ts.map +1 -0
- package/dist/action-hooks.js +36 -0
- package/dist/cli-args.test.d.ts +2 -0
- package/dist/cli-args.test.d.ts.map +1 -0
- package/dist/cli-args.test.js +1 -0
- package/dist/crash-monitor.d.ts +16 -0
- package/dist/crash-monitor.d.ts.map +1 -0
- package/dist/crash-monitor.js +75 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +20 -2
- package/dist/event-mapper.d.ts +16 -0
- package/dist/event-mapper.d.ts.map +1 -0
- package/dist/event-mapper.js +120 -0
- package/dist/global-setup.d.ts +3 -0
- package/dist/global-setup.d.ts.map +1 -0
- package/dist/global-setup.js +60 -0
- package/dist/global-teardown.d.ts +3 -0
- package/dist/global-teardown.d.ts.map +1 -0
- package/dist/global-teardown.js +11 -0
- package/dist/harness-reporter.d.ts +8 -0
- package/dist/harness-reporter.d.ts.map +1 -0
- package/dist/harness-reporter.js +12 -0
- package/dist/harness.d.ts +1 -0
- package/dist/harness.d.ts.map +1 -1
- package/dist/harness.js +491 -386
- package/dist/jest-options.d.ts +2 -0
- package/dist/jest-options.d.ts.map +1 -0
- package/dist/jest-options.js +1 -0
- package/dist/logs.d.ts +4 -0
- package/dist/logs.d.ts.map +1 -1
- package/dist/logs.js +12 -0
- package/dist/metro-port.d.ts +16 -0
- package/dist/metro-port.d.ts.map +1 -0
- package/dist/metro-port.js +48 -0
- package/dist/resource-lock.d.ts +25 -0
- package/dist/resource-lock.d.ts.map +1 -0
- package/dist/resource-lock.js +299 -0
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +12 -1
- package/dist/tsconfig.lib.tsbuildinfo +1 -1
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +7 -7
- package/src/__tests__/errors.test.ts +18 -6
- package/src/__tests__/harness-cache.test.ts +2 -2
- package/src/__tests__/harness.test.ts +342 -19
- package/src/__tests__/metro-port.test.ts +61 -0
- package/src/__tests__/resource-lock.test.ts +192 -0
- package/src/action-hooks.ts +66 -0
- package/src/errors.ts +23 -4
- package/src/harness.ts +609 -447
- package/src/logs.ts +24 -3
- package/src/metro-port.ts +76 -0
- package/src/resource-lock.ts +472 -0
- package/src/setup.ts +22 -2
|
@@ -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', {
|
|
@@ -17,10 +17,10 @@ const platform = {
|
|
|
17
17
|
platformId: 'ios',
|
|
18
18
|
runner: '/virtual/platform-runner.js',
|
|
19
19
|
config: {},
|
|
20
|
+
getResourceLockKey: () => 'ios:simulator:iPhone 17 Pro:26.2',
|
|
20
21
|
};
|
|
21
22
|
const createHarnessConfig = (overrides = {}) => ({
|
|
22
23
|
appRegistryComponentName: 'App',
|
|
23
|
-
webSocketPort: 8081,
|
|
24
24
|
disableViewFlattening: false,
|
|
25
25
|
bridgeTimeout: 5000,
|
|
26
26
|
entryPoint: 'index.js',
|
|
@@ -1,5 +1,7 @@
|
|
|
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';
|
|
4
|
+
import { MetroPortRangeExhaustedError } from '../errors.js';
|
|
3
5
|
import { definePlugin } from '@react-native-harness/plugins';
|
|
4
6
|
import { createCrashSupervisor } from '../crash-supervisor.js';
|
|
5
7
|
const mocks = vi.hoisted(() => ({
|
|
@@ -8,13 +10,19 @@ const mocks = vi.hoisted(() => ({
|
|
|
8
10
|
getMetroInstance: vi.fn(),
|
|
9
11
|
isMetroCacheReusable: vi.fn(() => false),
|
|
10
12
|
logMetroCacheReused: vi.fn(),
|
|
13
|
+
logMetroPortFallback: vi.fn(),
|
|
14
|
+
logRunnerStarting: vi.fn(),
|
|
15
|
+
logRunnerStillWaitingInQueue: vi.fn(),
|
|
16
|
+
logRunnerWaitingInQueue: vi.fn(),
|
|
11
17
|
waitForMetroBackedAppReady: vi.fn(),
|
|
18
|
+
isPortAvailable: vi.fn(async () => true),
|
|
12
19
|
}));
|
|
13
20
|
vi.mock('@react-native-harness/bundler-metro', async () => {
|
|
14
21
|
const actual = await vi.importActual('@react-native-harness/bundler-metro');
|
|
15
22
|
return {
|
|
16
23
|
...actual,
|
|
17
24
|
getMetroInstance: mocks.getMetroInstance,
|
|
25
|
+
isPortAvailable: mocks.isPortAvailable,
|
|
18
26
|
isMetroCacheReusable: mocks.isMetroCacheReusable,
|
|
19
27
|
waitForMetroBackedAppReady: mocks.waitForMetroBackedAppReady,
|
|
20
28
|
};
|
|
@@ -24,6 +32,10 @@ vi.mock('@react-native-harness/bridge/server', () => ({
|
|
|
24
32
|
}));
|
|
25
33
|
vi.mock('../logs.js', () => ({
|
|
26
34
|
logMetroCacheReused: mocks.logMetroCacheReused,
|
|
35
|
+
logMetroPortFallback: mocks.logMetroPortFallback,
|
|
36
|
+
logRunnerStarting: mocks.logRunnerStarting,
|
|
37
|
+
logRunnerStillWaitingInQueue: mocks.logRunnerStillWaitingInQueue,
|
|
38
|
+
logRunnerWaitingInQueue: mocks.logRunnerWaitingInQueue,
|
|
27
39
|
}));
|
|
28
40
|
vi.mock('@react-native-harness/tools', async () => {
|
|
29
41
|
const actual = await vi.importActual('@react-native-harness/tools');
|
|
@@ -33,11 +45,12 @@ vi.mock('@react-native-harness/tools', async () => {
|
|
|
33
45
|
};
|
|
34
46
|
});
|
|
35
47
|
import { getHarness, waitForAppReady } from '../harness.js';
|
|
36
|
-
import { StartupStallError } from '../errors.js';
|
|
48
|
+
import { PlatformReadyTimeoutError, StartupStallError } from '../errors.js';
|
|
37
49
|
const createBridgeServer = () => {
|
|
38
50
|
const emitter = new EventEmitter();
|
|
39
51
|
return {
|
|
40
52
|
serverBridge: {
|
|
53
|
+
ws: {},
|
|
41
54
|
rpc: {
|
|
42
55
|
clients: [],
|
|
43
56
|
},
|
|
@@ -81,6 +94,8 @@ const createReporter = () => {
|
|
|
81
94
|
};
|
|
82
95
|
const createMetroInstance = (overrides = {}) => ({
|
|
83
96
|
events: createReporter(),
|
|
97
|
+
httpServer: {},
|
|
98
|
+
websocketEndpoints: {},
|
|
84
99
|
waitUntilHealthy: vi.fn(async () => 'HTTP 200: packager-status:running'),
|
|
85
100
|
prewarm: vi.fn(async () => false),
|
|
86
101
|
dispose: vi.fn(async () => undefined),
|
|
@@ -128,6 +143,7 @@ const createHarnessConfig = (overrides = {}) => ({
|
|
|
128
143
|
forwardClientLogs: false,
|
|
129
144
|
maxAppRestarts: 2,
|
|
130
145
|
metroPort: 8081,
|
|
146
|
+
platformReadyTimeout: 300_000,
|
|
131
147
|
resetEnvironmentBetweenTestFiles: true,
|
|
132
148
|
runners: [],
|
|
133
149
|
unstable__enableMetroCache: false,
|
|
@@ -137,6 +153,8 @@ const createHarnessConfig = (overrides = {}) => ({
|
|
|
137
153
|
});
|
|
138
154
|
beforeEach(() => {
|
|
139
155
|
vi.clearAllMocks();
|
|
156
|
+
mocks.isPortAvailable.mockReset();
|
|
157
|
+
mocks.isPortAvailable.mockResolvedValue(true);
|
|
140
158
|
});
|
|
141
159
|
afterEach(() => {
|
|
142
160
|
delete globalThis.__HARNESS_PLATFORM_RUNNER__;
|
|
@@ -220,6 +238,121 @@ describe('waitForAppReady', () => {
|
|
|
220
238
|
});
|
|
221
239
|
});
|
|
222
240
|
describe('getHarness', () => {
|
|
241
|
+
it('fails when the platform runner does not become ready within platformReadyTimeout', async () => {
|
|
242
|
+
const { serverBridge } = createBridgeServer();
|
|
243
|
+
const metroInstance = createMetroInstance();
|
|
244
|
+
mocks.getBridgeServer.mockResolvedValue(serverBridge);
|
|
245
|
+
mocks.getMetroInstance.mockResolvedValue(metroInstance);
|
|
246
|
+
globalThis.__HARNESS_PLATFORM_RUNNER__ = vi.fn(async () => await new Promise((_, reject) => {
|
|
247
|
+
setTimeout(() => {
|
|
248
|
+
reject(new DOMException('The operation was aborted', 'AbortError'));
|
|
249
|
+
}, 20);
|
|
250
|
+
}));
|
|
251
|
+
const platform = {
|
|
252
|
+
config: {},
|
|
253
|
+
getResourceLockKey: () => 'ios:test-platform-ready-timeout',
|
|
254
|
+
name: 'ios',
|
|
255
|
+
platformId: 'ios',
|
|
256
|
+
runner: `data:text/javascript,${encodeURIComponent('export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);')}`,
|
|
257
|
+
};
|
|
258
|
+
await expect(getHarness(createHarnessConfig({
|
|
259
|
+
platformReadyTimeout: 10,
|
|
260
|
+
}), platform, '/tmp/project')).rejects.toBeInstanceOf(PlatformReadyTimeoutError);
|
|
261
|
+
});
|
|
262
|
+
it('passes a platform init signal to the runner factory', async () => {
|
|
263
|
+
const { serverBridge } = createBridgeServer();
|
|
264
|
+
const appMonitor = createAppMonitor();
|
|
265
|
+
const platformInstance = createPlatformRunner({
|
|
266
|
+
createAppMonitor: () => appMonitor.appMonitor,
|
|
267
|
+
});
|
|
268
|
+
const metroInstance = createMetroInstance();
|
|
269
|
+
mocks.getBridgeServer.mockResolvedValue(serverBridge);
|
|
270
|
+
mocks.getMetroInstance.mockResolvedValue(metroInstance);
|
|
271
|
+
const runner = vi.fn(async () => platformInstance);
|
|
272
|
+
globalThis.__HARNESS_PLATFORM_RUNNER__ = runner;
|
|
273
|
+
const platform = {
|
|
274
|
+
config: {},
|
|
275
|
+
getResourceLockKey: () => 'ios:test-platform-init-signal',
|
|
276
|
+
name: 'ios',
|
|
277
|
+
platformId: 'ios',
|
|
278
|
+
runner: `data:text/javascript,${encodeURIComponent('export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);')}`,
|
|
279
|
+
};
|
|
280
|
+
const harness = await getHarness(createHarnessConfig(), platform, '/tmp/project');
|
|
281
|
+
expect(runner).toHaveBeenCalledWith(platform.config, expect.objectContaining({
|
|
282
|
+
metroPort: 8081,
|
|
283
|
+
}), expect.objectContaining({
|
|
284
|
+
signal: expect.any(AbortSignal),
|
|
285
|
+
}));
|
|
286
|
+
await harness.dispose();
|
|
287
|
+
});
|
|
288
|
+
it('resolves and exposes a fallback Metro port before platform init', async () => {
|
|
289
|
+
const { serverBridge } = createBridgeServer();
|
|
290
|
+
const appMonitor = createAppMonitor();
|
|
291
|
+
const platformInstance = createPlatformRunner({
|
|
292
|
+
createAppMonitor: () => appMonitor.appMonitor,
|
|
293
|
+
});
|
|
294
|
+
const metroInstance = createMetroInstance();
|
|
295
|
+
mocks.getBridgeServer.mockResolvedValue(serverBridge);
|
|
296
|
+
mocks.getMetroInstance.mockResolvedValue(metroInstance);
|
|
297
|
+
mocks.isPortAvailable
|
|
298
|
+
.mockResolvedValueOnce(false)
|
|
299
|
+
.mockResolvedValueOnce(true);
|
|
300
|
+
const runner = vi.fn(async () => platformInstance);
|
|
301
|
+
globalThis.__HARNESS_PLATFORM_RUNNER__ = runner;
|
|
302
|
+
const platform = {
|
|
303
|
+
config: {},
|
|
304
|
+
getResourceLockKey: () => 'android:emulator:Pixel_8_API_35',
|
|
305
|
+
name: 'android',
|
|
306
|
+
platformId: 'android',
|
|
307
|
+
runner: `data:text/javascript,${encodeURIComponent('export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);')}`,
|
|
308
|
+
};
|
|
309
|
+
const harness = await getHarness(createHarnessConfig(), platform, '/tmp/project');
|
|
310
|
+
expect(harness.config.metroPort).toBe(8082);
|
|
311
|
+
expect(mocks.getMetroInstance).toHaveBeenCalledWith(expect.objectContaining({
|
|
312
|
+
harnessConfig: expect.objectContaining({
|
|
313
|
+
metroPort: 8082,
|
|
314
|
+
}),
|
|
315
|
+
}), expect.any(AbortSignal));
|
|
316
|
+
expect(runner).toHaveBeenCalledWith(platform.config, expect.objectContaining({
|
|
317
|
+
metroPort: 8082,
|
|
318
|
+
}), expect.objectContaining({
|
|
319
|
+
signal: expect.any(AbortSignal),
|
|
320
|
+
}));
|
|
321
|
+
expect(mocks.logMetroPortFallback).toHaveBeenCalledWith(8081, 8082);
|
|
322
|
+
await harness.dispose();
|
|
323
|
+
});
|
|
324
|
+
it('fails when no Metro port is available in the retry window', async () => {
|
|
325
|
+
mocks.isPortAvailable.mockResolvedValue(false);
|
|
326
|
+
const platform = {
|
|
327
|
+
config: {},
|
|
328
|
+
getResourceLockKey: () => 'android:emulator:Pixel_8_API_35',
|
|
329
|
+
name: 'android',
|
|
330
|
+
platformId: 'android',
|
|
331
|
+
runner: 'data:text/javascript,export default async () => ({})',
|
|
332
|
+
};
|
|
333
|
+
await expect(getHarness(createHarnessConfig(), platform, '/tmp/project')).rejects.toBeInstanceOf(MetroPortRangeExhaustedError);
|
|
334
|
+
expect(mocks.getBridgeServer).not.toHaveBeenCalled();
|
|
335
|
+
expect(mocks.getMetroInstance).not.toHaveBeenCalled();
|
|
336
|
+
});
|
|
337
|
+
it('falls back to a default resource lock key for platforms without getResourceLockKey', async () => {
|
|
338
|
+
const { serverBridge } = createBridgeServer();
|
|
339
|
+
const appMonitor = createAppMonitor();
|
|
340
|
+
const platformInstance = createPlatformRunner({
|
|
341
|
+
createAppMonitor: () => appMonitor.appMonitor,
|
|
342
|
+
});
|
|
343
|
+
const metroInstance = createMetroInstance();
|
|
344
|
+
mocks.getBridgeServer.mockResolvedValue(serverBridge);
|
|
345
|
+
mocks.getMetroInstance.mockResolvedValue(metroInstance);
|
|
346
|
+
globalThis.__HARNESS_PLATFORM_RUNNER__ = vi.fn(async () => platformInstance);
|
|
347
|
+
const platform = {
|
|
348
|
+
config: {},
|
|
349
|
+
name: 'legacy-ios',
|
|
350
|
+
platformId: 'ios',
|
|
351
|
+
runner: `data:text/javascript,${encodeURIComponent('export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);')}`,
|
|
352
|
+
};
|
|
353
|
+
const harness = await getHarness(createHarnessConfig(), platform, '/tmp/project');
|
|
354
|
+
await harness.dispose();
|
|
355
|
+
});
|
|
223
356
|
it('routes ensureAppReady through the shared Metro startup helper', async () => {
|
|
224
357
|
const { serverBridge, emitReady } = createBridgeServer();
|
|
225
358
|
const appMonitor = createAppMonitor();
|
|
@@ -249,8 +382,11 @@ describe('getHarness', () => {
|
|
|
249
382
|
name: 'ios',
|
|
250
383
|
platformId: 'ios',
|
|
251
384
|
runner: `data:text/javascript,${encodeURIComponent('export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);')}`,
|
|
385
|
+
getResourceLockKey: () => 'ios:simulator:iPhone 17 Pro:26.2',
|
|
252
386
|
};
|
|
253
|
-
const harness = await getHarness(createHarnessConfig(
|
|
387
|
+
const harness = await getHarness(createHarnessConfig({
|
|
388
|
+
bridgeTimeout: 1,
|
|
389
|
+
}), platform, '/tmp/project');
|
|
254
390
|
await harness.ensureAppReady('/tmp/example.harness.ts');
|
|
255
391
|
expect(mocks.waitForMetroBackedAppReady).toHaveBeenCalledTimes(1);
|
|
256
392
|
expect(restartApp).toHaveBeenCalledWith({
|
|
@@ -292,8 +428,17 @@ describe('getHarness', () => {
|
|
|
292
428
|
name: 'ios',
|
|
293
429
|
platformId: 'ios',
|
|
294
430
|
runner: `data:text/javascript,${encodeURIComponent('export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);')}`,
|
|
431
|
+
getResourceLockKey: () => 'ios:simulator:iPhone 17 Pro:26.2',
|
|
295
432
|
};
|
|
296
433
|
const harness = await getHarness(createHarnessConfig(), platform, '/tmp/project');
|
|
434
|
+
expect(mocks.getBridgeServer).toHaveBeenCalledWith(expect.objectContaining({
|
|
435
|
+
noServer: true,
|
|
436
|
+
}));
|
|
437
|
+
expect(mocks.getMetroInstance).toHaveBeenCalledWith(expect.objectContaining({
|
|
438
|
+
websocketEndpoints: {
|
|
439
|
+
[HARNESS_BRIDGE_PATH]: serverBridge.ws,
|
|
440
|
+
},
|
|
441
|
+
}), expect.any(AbortSignal));
|
|
297
442
|
await harness.restart('/tmp/restart.harness.ts');
|
|
298
443
|
expect(stopApp).toHaveBeenCalledTimes(1);
|
|
299
444
|
expect(mocks.waitForMetroBackedAppReady).toHaveBeenCalledTimes(1);
|
|
@@ -326,7 +471,17 @@ describe('plugins', () => {
|
|
|
326
471
|
harness: {
|
|
327
472
|
beforeCreation: (ctx) => {
|
|
328
473
|
ctx.state.creationCount += 1;
|
|
329
|
-
observedHooks.push(`beforeCreation:${ctx.platform.platformId}:${ctx.appLaunchOptions == null
|
|
474
|
+
observedHooks.push(`beforeCreation:${ctx.platform.platformId}:${ctx.appLaunchOptions == null
|
|
475
|
+
? 'no-launch-options'
|
|
476
|
+
: 'launch-options'}`);
|
|
477
|
+
},
|
|
478
|
+
beforeRun: (ctx) => {
|
|
479
|
+
observedHooks.push(`beforeRun:${ctx.platform.platformId}:${ctx.appLaunchOptions == null
|
|
480
|
+
? 'no-launch-options'
|
|
481
|
+
: 'launch-options'}`);
|
|
482
|
+
},
|
|
483
|
+
afterRun: (ctx) => {
|
|
484
|
+
observedHooks.push(`afterRun:${ctx.state.creationCount}:${ctx.reason}`);
|
|
330
485
|
},
|
|
331
486
|
beforeDispose: (ctx) => {
|
|
332
487
|
observedHooks.push(`beforeDispose:${ctx.state.creationCount}:${ctx.reason}`);
|
|
@@ -358,6 +513,7 @@ describe('plugins', () => {
|
|
|
358
513
|
name: 'ios',
|
|
359
514
|
platformId: 'ios',
|
|
360
515
|
runner: `data:text/javascript,${encodeURIComponent('export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);')}`,
|
|
516
|
+
getResourceLockKey: () => 'ios:simulator:iPhone 17 Pro:26.2',
|
|
361
517
|
};
|
|
362
518
|
const harness = await getHarness(createHarnessConfig({
|
|
363
519
|
plugins: [plugin],
|
|
@@ -385,12 +541,58 @@ describe('plugins', () => {
|
|
|
385
541
|
await harness.dispose();
|
|
386
542
|
expect(observedHooks).toEqual([
|
|
387
543
|
'beforeCreation:ios:launch-options',
|
|
544
|
+
'beforeRun:ios:launch-options',
|
|
388
545
|
'runtime.ready:run-1:ios',
|
|
389
546
|
'collection.started:example.harness.ts',
|
|
390
547
|
'runtime.disconnected:bridge-disconnected',
|
|
548
|
+
'afterRun:1:normal',
|
|
391
549
|
'beforeDispose:1:normal',
|
|
392
550
|
]);
|
|
393
551
|
});
|
|
552
|
+
it('waits in queue before starting Metro and releases the lock on dispose', async () => {
|
|
553
|
+
const resourceKey = 'ios:simulator:iPhone 17 Pro:26.2';
|
|
554
|
+
const firstPlatformRunner = createPlatformRunner();
|
|
555
|
+
const secondPlatformRunner = createPlatformRunner();
|
|
556
|
+
const secondAppMonitor = createAppMonitor();
|
|
557
|
+
const firstMetroInstance = createMetroInstance();
|
|
558
|
+
const secondMetroInstance = createMetroInstance();
|
|
559
|
+
const firstBridge = createBridgeServer();
|
|
560
|
+
const secondBridge = createBridgeServer();
|
|
561
|
+
mocks.getBridgeServer
|
|
562
|
+
.mockResolvedValueOnce(firstBridge.serverBridge)
|
|
563
|
+
.mockResolvedValueOnce(secondBridge.serverBridge);
|
|
564
|
+
mocks.getMetroInstance
|
|
565
|
+
.mockResolvedValueOnce(firstMetroInstance)
|
|
566
|
+
.mockResolvedValueOnce(secondMetroInstance);
|
|
567
|
+
let invocationCount = 0;
|
|
568
|
+
globalThis.__HARNESS_PLATFORM_RUNNER__ = vi.fn(async () => {
|
|
569
|
+
invocationCount += 1;
|
|
570
|
+
return invocationCount === 1
|
|
571
|
+
? firstPlatformRunner
|
|
572
|
+
: createPlatformRunner({
|
|
573
|
+
createAppMonitor: () => secondAppMonitor.appMonitor,
|
|
574
|
+
dispose: secondPlatformRunner.dispose,
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
const platform = {
|
|
578
|
+
config: {},
|
|
579
|
+
name: 'ios',
|
|
580
|
+
platformId: 'ios',
|
|
581
|
+
runner: `data:text/javascript,${encodeURIComponent('export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);')}`,
|
|
582
|
+
getResourceLockKey: () => resourceKey,
|
|
583
|
+
};
|
|
584
|
+
const firstHarness = await getHarness(createHarnessConfig(), platform, '/tmp/project');
|
|
585
|
+
const secondHarnessPromise = getHarness(createHarnessConfig(), platform, '/tmp/project');
|
|
586
|
+
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
587
|
+
expect(mocks.logRunnerWaitingInQueue).toHaveBeenCalledWith(platform);
|
|
588
|
+
expect(mocks.logRunnerStarting).not.toHaveBeenCalled();
|
|
589
|
+
expect(mocks.getMetroInstance).toHaveBeenCalledTimes(1);
|
|
590
|
+
await firstHarness.dispose();
|
|
591
|
+
const secondHarness = await secondHarnessPromise;
|
|
592
|
+
expect(mocks.logRunnerStarting).toHaveBeenCalledWith(platform);
|
|
593
|
+
expect(mocks.getMetroInstance).toHaveBeenCalledTimes(2);
|
|
594
|
+
await secondHarness.dispose();
|
|
595
|
+
});
|
|
394
596
|
});
|
|
395
597
|
describe('StartupStallError', () => {
|
|
396
598
|
it('includes the configured timeout and attempt count', () => {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"metro-port.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/metro-port.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { resolveHarnessMetroPort } from '../metro-port.js';
|
|
3
|
+
const mocks = vi.hoisted(() => ({
|
|
4
|
+
isPortAvailable: vi.fn(async () => true),
|
|
5
|
+
}));
|
|
6
|
+
vi.mock('@react-native-harness/bundler-metro', () => ({
|
|
7
|
+
isPortAvailable: mocks.isPortAvailable,
|
|
8
|
+
}));
|
|
9
|
+
const createConfig = (overrides = {}) => ({
|
|
10
|
+
appRegistryComponentName: 'App',
|
|
11
|
+
bridgeTimeout: 60_000,
|
|
12
|
+
bundleStartTimeout: 60_000,
|
|
13
|
+
crashDetectionInterval: 500,
|
|
14
|
+
defaultRunner: 'ios-device',
|
|
15
|
+
detectNativeCrashes: true,
|
|
16
|
+
disableViewFlattening: false,
|
|
17
|
+
entryPoint: 'index.js',
|
|
18
|
+
forwardClientLogs: false,
|
|
19
|
+
maxAppRestarts: 2,
|
|
20
|
+
metroPort: 8081,
|
|
21
|
+
platformReadyTimeout: 300_000,
|
|
22
|
+
resetEnvironmentBetweenTestFiles: true,
|
|
23
|
+
runners: [],
|
|
24
|
+
unstable__enableMetroCache: false,
|
|
25
|
+
unstable__skipAlreadyIncludedModules: false,
|
|
26
|
+
...overrides,
|
|
27
|
+
});
|
|
28
|
+
describe('resolveHarnessMetroPort', () => {
|
|
29
|
+
it('skips fallback allocation for iOS physical device runners', async () => {
|
|
30
|
+
const acquire = vi.fn();
|
|
31
|
+
const config = createConfig();
|
|
32
|
+
const platform = {
|
|
33
|
+
config: {},
|
|
34
|
+
name: 'ios-device',
|
|
35
|
+
platformId: 'ios',
|
|
36
|
+
runner: 'unused',
|
|
37
|
+
};
|
|
38
|
+
const result = await resolveHarnessMetroPort({
|
|
39
|
+
config,
|
|
40
|
+
platform,
|
|
41
|
+
resourceLockManager: {
|
|
42
|
+
acquire,
|
|
43
|
+
},
|
|
44
|
+
signal: new AbortController().signal,
|
|
45
|
+
});
|
|
46
|
+
expect(result.config).toBe(config);
|
|
47
|
+
expect(result.metroPortLease).toBeNull();
|
|
48
|
+
expect(result.didFallback).toBe(false);
|
|
49
|
+
expect(acquire).not.toHaveBeenCalled();
|
|
50
|
+
expect(mocks.isPortAvailable).not.toHaveBeenCalled();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resource-lock.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/resource-lock.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { createResourceLockManager, hashResourceLockKey, } from '../resource-lock.js';
|
|
6
|
+
describe('resource lock manager', () => {
|
|
7
|
+
let rootDir;
|
|
8
|
+
beforeEach(async () => {
|
|
9
|
+
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'react-native-harness-resource-lock-test-'));
|
|
10
|
+
});
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
await fs.rm(rootDir, { recursive: true, force: true });
|
|
13
|
+
});
|
|
14
|
+
it('queues access in FIFO order', async () => {
|
|
15
|
+
const manager = createResourceLockManager({
|
|
16
|
+
rootDir,
|
|
17
|
+
pollIntervalMs: 5,
|
|
18
|
+
heartbeatIntervalMs: 20,
|
|
19
|
+
staleLockTimeoutMs: 200,
|
|
20
|
+
});
|
|
21
|
+
const order = [];
|
|
22
|
+
const firstLease = await manager.acquire('ios:simulator:iPhone 17 Pro:26.2');
|
|
23
|
+
const secondAcquire = manager
|
|
24
|
+
.acquire('ios:simulator:iPhone 17 Pro:26.2', {
|
|
25
|
+
onWait: () => {
|
|
26
|
+
order.push('waiting');
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
.then(async (lease) => {
|
|
30
|
+
order.push('acquired');
|
|
31
|
+
await lease.release();
|
|
32
|
+
});
|
|
33
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
34
|
+
expect(order).toEqual(['waiting']);
|
|
35
|
+
await firstLease.release();
|
|
36
|
+
await secondAcquire;
|
|
37
|
+
expect(order).toEqual(['waiting', 'acquired']);
|
|
38
|
+
});
|
|
39
|
+
it('removes the queued ticket when waiting is aborted', async () => {
|
|
40
|
+
const manager = createResourceLockManager({
|
|
41
|
+
rootDir,
|
|
42
|
+
pollIntervalMs: 5,
|
|
43
|
+
heartbeatIntervalMs: 20,
|
|
44
|
+
staleLockTimeoutMs: 200,
|
|
45
|
+
});
|
|
46
|
+
const key = 'android:emulator:Pixel_8_API_35';
|
|
47
|
+
const firstLease = await manager.acquire(key);
|
|
48
|
+
const controller = new AbortController();
|
|
49
|
+
const acquirePromise = manager.acquire(key, {
|
|
50
|
+
signal: controller.signal,
|
|
51
|
+
});
|
|
52
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
53
|
+
controller.abort();
|
|
54
|
+
await expect(acquirePromise).rejects.toMatchObject({
|
|
55
|
+
name: 'AbortError',
|
|
56
|
+
});
|
|
57
|
+
const queueDir = path.join(rootDir, hashResourceLockKey(key), 'queue');
|
|
58
|
+
const queuedEntries = await fs.readdir(queueDir);
|
|
59
|
+
expect(queuedEntries).toHaveLength(0);
|
|
60
|
+
await firstLease.release();
|
|
61
|
+
});
|
|
62
|
+
it('keeps queued tickets alive while the waiting process is still active', async () => {
|
|
63
|
+
const manager = createResourceLockManager({
|
|
64
|
+
rootDir,
|
|
65
|
+
pollIntervalMs: 5,
|
|
66
|
+
heartbeatIntervalMs: 20,
|
|
67
|
+
staleLockTimeoutMs: 30,
|
|
68
|
+
isProcessActive: () => true,
|
|
69
|
+
});
|
|
70
|
+
const key = 'ios:simulator:iPhone 17 Pro:26.2';
|
|
71
|
+
const firstLease = await manager.acquire(key);
|
|
72
|
+
const secondAcquire = manager.acquire(key);
|
|
73
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
74
|
+
await firstLease.release();
|
|
75
|
+
const secondLease = await secondAcquire;
|
|
76
|
+
await secondLease.release();
|
|
77
|
+
});
|
|
78
|
+
it('reclaims a stale owner before granting the lock', async () => {
|
|
79
|
+
const manager = createResourceLockManager({
|
|
80
|
+
rootDir,
|
|
81
|
+
pollIntervalMs: 5,
|
|
82
|
+
heartbeatIntervalMs: 20,
|
|
83
|
+
staleLockTimeoutMs: 50,
|
|
84
|
+
isProcessActive: () => false,
|
|
85
|
+
});
|
|
86
|
+
const key = 'web:browser:chromium';
|
|
87
|
+
const keyDir = path.join(rootDir, hashResourceLockKey(key));
|
|
88
|
+
const queueDir = path.join(keyDir, 'queue');
|
|
89
|
+
const ownerFilePath = path.join(keyDir, 'owner.json');
|
|
90
|
+
await fs.mkdir(queueDir, { recursive: true });
|
|
91
|
+
await fs.writeFile(ownerFilePath, JSON.stringify({
|
|
92
|
+
ticketId: 'stale-owner',
|
|
93
|
+
key,
|
|
94
|
+
pid: 999999,
|
|
95
|
+
createdAt: Date.now() - 1000,
|
|
96
|
+
heartbeatAt: Date.now() - 1000,
|
|
97
|
+
}), 'utf8');
|
|
98
|
+
const lease = await manager.acquire(key);
|
|
99
|
+
const owner = JSON.parse(await fs.readFile(ownerFilePath, 'utf8'));
|
|
100
|
+
expect(owner.ticketId).not.toBe('stale-owner');
|
|
101
|
+
await lease.release();
|
|
102
|
+
});
|
|
103
|
+
it('keeps owner metadata valid when heartbeat writes overlap', async () => {
|
|
104
|
+
const manager = createResourceLockManager({
|
|
105
|
+
rootDir,
|
|
106
|
+
pollIntervalMs: 5,
|
|
107
|
+
heartbeatIntervalMs: 10,
|
|
108
|
+
staleLockTimeoutMs: 200,
|
|
109
|
+
});
|
|
110
|
+
const key = 'ios:simulator:iPhone 17 Pro:26.2';
|
|
111
|
+
const ownerFilePath = path.join(rootDir, hashResourceLockKey(key), 'owner.json');
|
|
112
|
+
const actualWriteFile = fs.writeFile.bind(fs);
|
|
113
|
+
const writeFileSpy = vi
|
|
114
|
+
.spyOn(fs, 'writeFile')
|
|
115
|
+
.mockImplementation(async (file, data, options) => {
|
|
116
|
+
if (typeof file === 'string' &&
|
|
117
|
+
file.startsWith(ownerFilePath) &&
|
|
118
|
+
file !== ownerFilePath) {
|
|
119
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
120
|
+
}
|
|
121
|
+
return await actualWriteFile(file, data, options);
|
|
122
|
+
});
|
|
123
|
+
try {
|
|
124
|
+
const lease = await manager.acquire(key);
|
|
125
|
+
const initialOwner = JSON.parse(await fs.readFile(ownerFilePath, 'utf8'));
|
|
126
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
127
|
+
for (let index = 0; index < 5; index += 1) {
|
|
128
|
+
const owner = JSON.parse(await fs.readFile(ownerFilePath, 'utf8'));
|
|
129
|
+
expect(owner.ticketId).toBe(initialOwner.ticketId);
|
|
130
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
131
|
+
}
|
|
132
|
+
await lease.release();
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
writeFileSpy.mockRestore();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type HarnessPlugin } from '@react-native-harness/plugins';
|
|
2
|
+
import type { Config as HarnessConfig } from '@react-native-harness/config';
|
|
3
|
+
import type { HarnessPlatform } from '@react-native-harness/platforms';
|
|
4
|
+
type ActionHookState = {
|
|
5
|
+
_unused?: never;
|
|
6
|
+
};
|
|
7
|
+
export declare const createActionHooksPlugin: () => HarnessPlugin<ActionHookState, HarnessConfig, HarnessPlatform>;
|
|
8
|
+
export {};
|
|
9
|
+
//# sourceMappingURL=action-hooks.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"action-hooks.d.ts","sourceRoot":"","sources":["../src/action-hooks.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,aAAa,EACnB,MAAM,+BAA+B,CAAC;AACvC,OAAO,KAAK,EAAE,MAAM,IAAI,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAC5E,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AAGvE,KAAK,eAAe,GAAG;IACrB,OAAO,CAAC,EAAE,KAAK,CAAC;CACjB,CAAC;AAuBF,eAAO,MAAM,uBAAuB,QAAO,aAAa,CACtD,eAAe,EACf,aAAa,EACb,eAAe,CA6Bb,CAAC"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { definePlugin, } from '@react-native-harness/plugins';
|
|
2
|
+
import { spawn } from '@react-native-harness/tools';
|
|
3
|
+
const getInlineHookScript = (name) => {
|
|
4
|
+
const value = process.env[name]?.trim();
|
|
5
|
+
return value ? value : null;
|
|
6
|
+
};
|
|
7
|
+
const runInlineHook = async (script) => {
|
|
8
|
+
const env = Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] != null));
|
|
9
|
+
await spawn('bash', ['-lc', script], {
|
|
10
|
+
env,
|
|
11
|
+
cwd: process.env.HARNESS_PROJECT_ROOT,
|
|
12
|
+
});
|
|
13
|
+
};
|
|
14
|
+
export const createActionHooksPlugin = () => definePlugin({
|
|
15
|
+
name: 'github-action-hooks',
|
|
16
|
+
createState: () => ({}),
|
|
17
|
+
hooks: {
|
|
18
|
+
harness: {
|
|
19
|
+
beforeRun: async () => {
|
|
20
|
+
const script = getInlineHookScript('PRE_RUN_HOOK');
|
|
21
|
+
if (!script) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
await runInlineHook(script);
|
|
25
|
+
},
|
|
26
|
+
afterRun: async (ctx) => {
|
|
27
|
+
const script = getInlineHookScript('AFTER_RUN_HOOK');
|
|
28
|
+
if (!script) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
process.env.HARNESS_EXIT_CODE = ctx.status === 'passed' ? '0' : '1';
|
|
32
|
+
await runInlineHook(script);
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli-args.test.d.ts","sourceRoot":"","sources":["../src/cli-args.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { HarnessPlatformRunner } from '@react-native-harness/platforms';
|
|
2
|
+
import { BridgeServer } from '@react-native-harness/bridge/server';
|
|
3
|
+
export type CrashMonitor = {
|
|
4
|
+
startMonitoring(testFilePath: string): Promise<never>;
|
|
5
|
+
stopMonitoring(): void;
|
|
6
|
+
markIntentionalRestart(): void;
|
|
7
|
+
clearIntentionalRestart(): void;
|
|
8
|
+
dispose(): void;
|
|
9
|
+
};
|
|
10
|
+
export type CrashMonitorOptions = {
|
|
11
|
+
interval: number;
|
|
12
|
+
platformRunner: HarnessPlatformRunner;
|
|
13
|
+
bridgeServer: BridgeServer;
|
|
14
|
+
};
|
|
15
|
+
export declare const createCrashMonitor: ({ interval, platformRunner, bridgeServer, }: CrashMonitorOptions) => CrashMonitor;
|
|
16
|
+
//# sourceMappingURL=crash-monitor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"crash-monitor.d.ts","sourceRoot":"","sources":["../src/crash-monitor.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,qBAAqB,EAAE,MAAM,iCAAiC,CAAC;AACxE,OAAO,EAAE,YAAY,EAAE,MAAM,qCAAqC,CAAC;AAInE,MAAM,MAAM,YAAY,GAAG;IACzB,eAAe,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC;IACtD,cAAc,IAAI,IAAI,CAAC;IACvB,sBAAsB,IAAI,IAAI,CAAC;IAC/B,uBAAuB,IAAI,IAAI,CAAC;IAChC,OAAO,IAAI,IAAI,CAAC;CACjB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,qBAAqB,CAAC;IACtC,YAAY,EAAE,YAAY,CAAC;CAC5B,CAAC;AAEF,eAAO,MAAM,kBAAkB,GAAI,6CAIhC,mBAAmB,KAAG,YAqFxB,CAAC"}
|