@react-native-harness/jest 1.1.0-rc.2 → 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.
- package/dist/__tests__/errors.test.js +6 -1
- package/dist/__tests__/harness-cache.test.js +1 -1
- package/dist/__tests__/harness.test.js +147 -3
- 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 +103 -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 +4 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +9 -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.map +1 -1
- package/dist/harness.js +478 -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 +3 -0
- package/dist/logs.d.ts.map +1 -1
- package/dist/logs.js +9 -0
- package/dist/resource-lock.d.ts +25 -0
- package/dist/resource-lock.d.ts.map +1 -0
- package/dist/resource-lock.js +278 -0
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +11 -0
- 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 +254 -19
- package/src/__tests__/resource-lock.test.ts +138 -0
- package/src/action-hooks.ts +66 -0
- package/src/errors.ts +10 -4
- package/src/harness.ts +585 -445
- package/src/logs.ts +13 -3
- package/src/resource-lock.ts +450 -0
- package/src/setup.ts +21 -1
|
@@ -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,4 +1,5 @@
|
|
|
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 { definePlugin } from '@react-native-harness/plugins';
|
|
4
5
|
import { createCrashSupervisor } from '../crash-supervisor.js';
|
|
@@ -8,6 +9,9 @@ const mocks = vi.hoisted(() => ({
|
|
|
8
9
|
getMetroInstance: vi.fn(),
|
|
9
10
|
isMetroCacheReusable: vi.fn(() => false),
|
|
10
11
|
logMetroCacheReused: vi.fn(),
|
|
12
|
+
logRunnerStarting: vi.fn(),
|
|
13
|
+
logRunnerStillWaitingInQueue: vi.fn(),
|
|
14
|
+
logRunnerWaitingInQueue: vi.fn(),
|
|
11
15
|
waitForMetroBackedAppReady: vi.fn(),
|
|
12
16
|
}));
|
|
13
17
|
vi.mock('@react-native-harness/bundler-metro', async () => {
|
|
@@ -24,6 +28,9 @@ vi.mock('@react-native-harness/bridge/server', () => ({
|
|
|
24
28
|
}));
|
|
25
29
|
vi.mock('../logs.js', () => ({
|
|
26
30
|
logMetroCacheReused: mocks.logMetroCacheReused,
|
|
31
|
+
logRunnerStarting: mocks.logRunnerStarting,
|
|
32
|
+
logRunnerStillWaitingInQueue: mocks.logRunnerStillWaitingInQueue,
|
|
33
|
+
logRunnerWaitingInQueue: mocks.logRunnerWaitingInQueue,
|
|
27
34
|
}));
|
|
28
35
|
vi.mock('@react-native-harness/tools', async () => {
|
|
29
36
|
const actual = await vi.importActual('@react-native-harness/tools');
|
|
@@ -33,11 +40,12 @@ vi.mock('@react-native-harness/tools', async () => {
|
|
|
33
40
|
};
|
|
34
41
|
});
|
|
35
42
|
import { getHarness, waitForAppReady } from '../harness.js';
|
|
36
|
-
import { StartupStallError } from '../errors.js';
|
|
43
|
+
import { PlatformReadyTimeoutError, StartupStallError } from '../errors.js';
|
|
37
44
|
const createBridgeServer = () => {
|
|
38
45
|
const emitter = new EventEmitter();
|
|
39
46
|
return {
|
|
40
47
|
serverBridge: {
|
|
48
|
+
ws: {},
|
|
41
49
|
rpc: {
|
|
42
50
|
clients: [],
|
|
43
51
|
},
|
|
@@ -81,6 +89,8 @@ const createReporter = () => {
|
|
|
81
89
|
};
|
|
82
90
|
const createMetroInstance = (overrides = {}) => ({
|
|
83
91
|
events: createReporter(),
|
|
92
|
+
httpServer: {},
|
|
93
|
+
websocketEndpoints: {},
|
|
84
94
|
waitUntilHealthy: vi.fn(async () => 'HTTP 200: packager-status:running'),
|
|
85
95
|
prewarm: vi.fn(async () => false),
|
|
86
96
|
dispose: vi.fn(async () => undefined),
|
|
@@ -128,6 +138,7 @@ const createHarnessConfig = (overrides = {}) => ({
|
|
|
128
138
|
forwardClientLogs: false,
|
|
129
139
|
maxAppRestarts: 2,
|
|
130
140
|
metroPort: 8081,
|
|
141
|
+
platformReadyTimeout: 300_000,
|
|
131
142
|
resetEnvironmentBetweenTestFiles: true,
|
|
132
143
|
runners: [],
|
|
133
144
|
unstable__enableMetroCache: false,
|
|
@@ -220,6 +231,70 @@ describe('waitForAppReady', () => {
|
|
|
220
231
|
});
|
|
221
232
|
});
|
|
222
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
|
+
});
|
|
223
298
|
it('routes ensureAppReady through the shared Metro startup helper', async () => {
|
|
224
299
|
const { serverBridge, emitReady } = createBridgeServer();
|
|
225
300
|
const appMonitor = createAppMonitor();
|
|
@@ -249,8 +324,11 @@ describe('getHarness', () => {
|
|
|
249
324
|
name: 'ios',
|
|
250
325
|
platformId: 'ios',
|
|
251
326
|
runner: `data:text/javascript,${encodeURIComponent('export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);')}`,
|
|
327
|
+
getResourceLockKey: () => 'ios:simulator:iPhone 17 Pro:26.2',
|
|
252
328
|
};
|
|
253
|
-
const harness = await getHarness(createHarnessConfig(
|
|
329
|
+
const harness = await getHarness(createHarnessConfig({
|
|
330
|
+
bridgeTimeout: 1,
|
|
331
|
+
}), platform, '/tmp/project');
|
|
254
332
|
await harness.ensureAppReady('/tmp/example.harness.ts');
|
|
255
333
|
expect(mocks.waitForMetroBackedAppReady).toHaveBeenCalledTimes(1);
|
|
256
334
|
expect(restartApp).toHaveBeenCalledWith({
|
|
@@ -292,8 +370,17 @@ describe('getHarness', () => {
|
|
|
292
370
|
name: 'ios',
|
|
293
371
|
platformId: 'ios',
|
|
294
372
|
runner: `data:text/javascript,${encodeURIComponent('export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);')}`,
|
|
373
|
+
getResourceLockKey: () => 'ios:simulator:iPhone 17 Pro:26.2',
|
|
295
374
|
};
|
|
296
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));
|
|
297
384
|
await harness.restart('/tmp/restart.harness.ts');
|
|
298
385
|
expect(stopApp).toHaveBeenCalledTimes(1);
|
|
299
386
|
expect(mocks.waitForMetroBackedAppReady).toHaveBeenCalledTimes(1);
|
|
@@ -326,7 +413,17 @@ describe('plugins', () => {
|
|
|
326
413
|
harness: {
|
|
327
414
|
beforeCreation: (ctx) => {
|
|
328
415
|
ctx.state.creationCount += 1;
|
|
329
|
-
observedHooks.push(`beforeCreation:${ctx.platform.platformId}:${ctx.appLaunchOptions == null
|
|
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}`);
|
|
330
427
|
},
|
|
331
428
|
beforeDispose: (ctx) => {
|
|
332
429
|
observedHooks.push(`beforeDispose:${ctx.state.creationCount}:${ctx.reason}`);
|
|
@@ -358,6 +455,7 @@ describe('plugins', () => {
|
|
|
358
455
|
name: 'ios',
|
|
359
456
|
platformId: 'ios',
|
|
360
457
|
runner: `data:text/javascript,${encodeURIComponent('export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);')}`,
|
|
458
|
+
getResourceLockKey: () => 'ios:simulator:iPhone 17 Pro:26.2',
|
|
361
459
|
};
|
|
362
460
|
const harness = await getHarness(createHarnessConfig({
|
|
363
461
|
plugins: [plugin],
|
|
@@ -385,12 +483,58 @@ describe('plugins', () => {
|
|
|
385
483
|
await harness.dispose();
|
|
386
484
|
expect(observedHooks).toEqual([
|
|
387
485
|
'beforeCreation:ios:launch-options',
|
|
486
|
+
'beforeRun:ios:launch-options',
|
|
388
487
|
'runtime.ready:run-1:ios',
|
|
389
488
|
'collection.started:example.harness.ts',
|
|
390
489
|
'runtime.disconnected:bridge-disconnected',
|
|
490
|
+
'afterRun:1:normal',
|
|
391
491
|
'beforeDispose:1:normal',
|
|
392
492
|
]);
|
|
393
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
|
+
});
|
|
394
538
|
});
|
|
395
539
|
describe('StartupStallError', () => {
|
|
396
540
|
it('includes the configured timeout and attempt count', () => {
|
|
@@ -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,103 @@
|
|
|
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 } 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
|
+
});
|
|
@@ -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"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { NativeCrashError } from './errors.js';
|
|
2
|
+
import { logger } from '@react-native-harness/tools';
|
|
3
|
+
export const createCrashMonitor = ({ interval, platformRunner, bridgeServer, }) => {
|
|
4
|
+
let pollingInterval = null;
|
|
5
|
+
let isIntentionalRestart = false;
|
|
6
|
+
let currentTestFilePath = null;
|
|
7
|
+
let rejectFn = null;
|
|
8
|
+
const handleDisconnect = () => {
|
|
9
|
+
// Verify if it's actually a crash by checking if app is still running
|
|
10
|
+
if (!isIntentionalRestart && currentTestFilePath) {
|
|
11
|
+
// Capture the value to avoid it being null when setTimeout callback runs
|
|
12
|
+
const testFilePath = currentTestFilePath;
|
|
13
|
+
logger.debug('Bridge disconnected, checking if app crashed');
|
|
14
|
+
// Use a slight delay to allow the OS to clean up the process
|
|
15
|
+
setTimeout(async () => {
|
|
16
|
+
const isRunning = await platformRunner.isAppRunning();
|
|
17
|
+
if (!isRunning && !isIntentionalRestart && rejectFn) {
|
|
18
|
+
logger.debug(`Native crash detected during: ${testFilePath}`);
|
|
19
|
+
rejectFn(new NativeCrashError(testFilePath));
|
|
20
|
+
}
|
|
21
|
+
}, 100);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const startMonitoring = (testFilePath) => {
|
|
25
|
+
currentTestFilePath = testFilePath;
|
|
26
|
+
return new Promise((_, reject) => {
|
|
27
|
+
rejectFn = reject;
|
|
28
|
+
// Listen for bridge disconnect as early indicator
|
|
29
|
+
bridgeServer.on('disconnect', handleDisconnect);
|
|
30
|
+
// Poll for app running status
|
|
31
|
+
pollingInterval = setInterval(async () => {
|
|
32
|
+
// Skip check during intentional restarts
|
|
33
|
+
if (isIntentionalRestart) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const isRunning = await platformRunner.isAppRunning();
|
|
38
|
+
if (!isRunning && currentTestFilePath) {
|
|
39
|
+
logger.debug(`Native crash detected during: ${currentTestFilePath}`);
|
|
40
|
+
stopMonitoring();
|
|
41
|
+
reject(new NativeCrashError(currentTestFilePath));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
logger.error('Error checking app status:', error);
|
|
46
|
+
}
|
|
47
|
+
}, interval);
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
const stopMonitoring = () => {
|
|
51
|
+
if (pollingInterval) {
|
|
52
|
+
clearInterval(pollingInterval);
|
|
53
|
+
pollingInterval = null;
|
|
54
|
+
}
|
|
55
|
+
bridgeServer.off('disconnect', handleDisconnect);
|
|
56
|
+
currentTestFilePath = null;
|
|
57
|
+
rejectFn = null;
|
|
58
|
+
};
|
|
59
|
+
const markIntentionalRestart = () => {
|
|
60
|
+
isIntentionalRestart = true;
|
|
61
|
+
};
|
|
62
|
+
const clearIntentionalRestart = () => {
|
|
63
|
+
isIntentionalRestart = false;
|
|
64
|
+
};
|
|
65
|
+
const dispose = () => {
|
|
66
|
+
stopMonitoring();
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
startMonitoring,
|
|
70
|
+
stopMonitoring,
|
|
71
|
+
markIntentionalRestart,
|
|
72
|
+
clearIntentionalRestart,
|
|
73
|
+
dispose,
|
|
74
|
+
};
|
|
75
|
+
};
|
package/dist/errors.d.ts
CHANGED
|
@@ -11,6 +11,10 @@ export declare class RunnerNotFoundError extends HarnessError {
|
|
|
11
11
|
export declare class InitializationTimeoutError extends HarnessError {
|
|
12
12
|
constructor();
|
|
13
13
|
}
|
|
14
|
+
export declare class PlatformReadyTimeoutError extends HarnessError {
|
|
15
|
+
readonly timeout: number;
|
|
16
|
+
constructor(timeout: number);
|
|
17
|
+
}
|
|
14
18
|
export type NativeCrashPhase = 'startup' | 'execution';
|
|
15
19
|
export type NativeCrashDetails = AppCrashDetails & {
|
|
16
20
|
phase: NativeCrashPhase;
|
package/dist/errors.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAC3D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AACvE,OAAO,EACL,iBAAiB,EACjB,KAAK,gBAAgB,EACrB,KAAK,mBAAmB,GACzB,MAAM,qCAAqC,CAAC;AAE7C,qBAAa,sBAAuB,SAAQ,YAAY;;CAKvD;AAED,qBAAa,mBAAoB,SAAQ,YAAY;aACvB,UAAU,EAAE,MAAM;gBAAlB,UAAU,EAAE,MAAM;CAI/C;AAED,qBAAa,0BAA2B,SAAQ,YAAY;;CAK3D;AAED,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,WAAW,CAAC;AAEvD,MAAM,MAAM,kBAAkB,GAAG,eAAe,GAAG;IACjD,KAAK,EAAE,gBAAgB,CAAC;CACzB,CAAC;
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAC3D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AACvE,OAAO,EACL,iBAAiB,EACjB,KAAK,gBAAgB,EACrB,KAAK,mBAAmB,GACzB,MAAM,qCAAqC,CAAC;AAE7C,qBAAa,sBAAuB,SAAQ,YAAY;;CAKvD;AAED,qBAAa,mBAAoB,SAAQ,YAAY;aACvB,UAAU,EAAE,MAAM;gBAAlB,UAAU,EAAE,MAAM;CAI/C;AAED,qBAAa,0BAA2B,SAAQ,YAAY;;CAK3D;AAED,qBAAa,yBAA0B,SAAQ,YAAY;aAC7B,OAAO,EAAE,MAAM;gBAAf,OAAO,EAAE,MAAM;CAM5C;AAED,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,WAAW,CAAC;AAEvD,MAAM,MAAM,kBAAkB,GAAG,eAAe,GAAG;IACjD,KAAK,EAAE,gBAAgB,CAAC;CACzB,CAAC;AAmDF,qBAAa,gBAAiB,SAAQ,YAAY;aAE9B,YAAY,EAAE,MAAM;aACpB,OAAO,EAAE,kBAAkB;aAC3B,aAAa,CAAC,EAAE,MAAM;gBAFtB,YAAY,EAAE,MAAM,EACpB,OAAO,EAAE,kBAAkB,EAC3B,aAAa,CAAC,EAAE,MAAM,YAAA;IAOxC,IAAI,KAAK,qBAER;CACF"}
|
package/dist/errors.js
CHANGED
|
@@ -20,6 +20,14 @@ export class InitializationTimeoutError extends HarnessError {
|
|
|
20
20
|
this.name = 'InitializationTimeoutError';
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
|
+
export class PlatformReadyTimeoutError extends HarnessError {
|
|
24
|
+
timeout;
|
|
25
|
+
constructor(timeout) {
|
|
26
|
+
super(`The platform did not become ready within ${timeout}ms. Increase "platformReadyTimeout" if your device, simulator, or emulator needs more time to start.`);
|
|
27
|
+
this.timeout = timeout;
|
|
28
|
+
this.name = 'PlatformReadyTimeoutError';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
23
31
|
const buildNativeCrashMessage = ({ phase, summary, signal, exceptionType, processName, pid, stackTrace, artifactType, }) => {
|
|
24
32
|
const lines = [
|
|
25
33
|
phase === 'startup'
|
|
@@ -28,8 +36,7 @@ const buildNativeCrashMessage = ({ phase, summary, signal, exceptionType, proces
|
|
|
28
36
|
];
|
|
29
37
|
const hasCrashBlock = summary?.includes('\n') ?? false;
|
|
30
38
|
const shouldRenderSummary = Boolean(summary) &&
|
|
31
|
-
!(!hasCrashBlock &&
|
|
32
|
-
artifactType === 'ios-crash-report');
|
|
39
|
+
!(!hasCrashBlock && artifactType === 'ios-crash-report');
|
|
33
40
|
if (shouldRenderSummary && summary) {
|
|
34
41
|
lines.push('');
|
|
35
42
|
lines.push(summary);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { TestEvents } from '@jest/test-result';
|
|
2
|
+
import type { Test } from 'jest-runner';
|
|
3
|
+
import type { BridgeEvents } from '@react-native-harness/bridge';
|
|
4
|
+
import type { TestCaseResult } from '@jest/test-result';
|
|
5
|
+
export type EventMapperState = {
|
|
6
|
+
currentTest?: Test;
|
|
7
|
+
suiteHierarchy: string[];
|
|
8
|
+
testResults: Map<string, TestCaseResult>;
|
|
9
|
+
fileStartTime: number;
|
|
10
|
+
};
|
|
11
|
+
export type HarnessEventMapper = {
|
|
12
|
+
handleBridgeEvent: (event: BridgeEvents, test: Test) => void;
|
|
13
|
+
reset: () => void;
|
|
14
|
+
};
|
|
15
|
+
export declare const getHarnessEventMapper: (emit: <T extends keyof TestEvents>(eventName: T, eventData: TestEvents[T]) => void) => HarnessEventMapper;
|
|
16
|
+
//# sourceMappingURL=event-mapper.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"event-mapper.d.ts","sourceRoot":"","sources":["../src/event-mapper.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;AACxC,OAAO,KAAK,EASV,YAAY,EACb,MAAM,8BAA8B,CAAC;AACtC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAGxD,MAAM,MAAM,gBAAgB,GAAG;IAC7B,WAAW,CAAC,EAAE,IAAI,CAAC;IACnB,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;IACzC,aAAa,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,iBAAiB,EAAE,CAAC,KAAK,EAAE,YAAY,EAAE,IAAI,EAAE,IAAI,KAAK,IAAI,CAAC;IAC7D,KAAK,EAAE,MAAM,IAAI,CAAC;CACnB,CAAC;AAEF,eAAO,MAAM,qBAAqB,GAChC,MAAM,CAAC,CAAC,SAAS,MAAM,UAAU,EAC/B,SAAS,EAAE,CAAC,EACZ,SAAS,EAAE,UAAU,CAAC,CAAC,CAAC,KACrB,IAAI,KACR,kBA8JF,CAAC"}
|