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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/__tests__/errors.test.js +6 -1
  2. package/dist/__tests__/harness-cache.test.d.ts +2 -0
  3. package/dist/__tests__/harness-cache.test.d.ts.map +1 -0
  4. package/dist/__tests__/harness-cache.test.js +51 -0
  5. package/dist/__tests__/harness.test.js +484 -47
  6. package/dist/__tests__/index.test.d.ts +2 -0
  7. package/dist/__tests__/index.test.d.ts.map +1 -0
  8. package/dist/__tests__/index.test.js +42 -0
  9. package/dist/__tests__/resource-lock.test.d.ts +2 -0
  10. package/dist/__tests__/resource-lock.test.d.ts.map +1 -0
  11. package/dist/__tests__/resource-lock.test.js +103 -0
  12. package/dist/action-hooks.d.ts +9 -0
  13. package/dist/action-hooks.d.ts.map +1 -0
  14. package/dist/action-hooks.js +36 -0
  15. package/dist/cli-args.d.ts +1 -0
  16. package/dist/cli-args.d.ts.map +1 -1
  17. package/dist/cli-args.js +5 -0
  18. package/dist/crash-supervisor.d.ts.map +1 -1
  19. package/dist/crash-supervisor.js +7 -6
  20. package/dist/errors.d.ts +5 -0
  21. package/dist/errors.d.ts.map +1 -1
  22. package/dist/errors.js +10 -2
  23. package/dist/harness.d.ts +23 -1
  24. package/dist/harness.d.ts.map +1 -1
  25. package/dist/harness.js +574 -136
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +205 -58
  28. package/dist/logs.d.ts +4 -0
  29. package/dist/logs.d.ts.map +1 -1
  30. package/dist/logs.js +12 -0
  31. package/dist/resource-lock.d.ts +25 -0
  32. package/dist/resource-lock.d.ts.map +1 -0
  33. package/dist/resource-lock.js +278 -0
  34. package/dist/run.d.ts +7 -1
  35. package/dist/run.d.ts.map +1 -1
  36. package/dist/run.js +13 -8
  37. package/dist/setup.d.ts.map +1 -1
  38. package/dist/setup.js +29 -2
  39. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  40. package/package.json +7 -6
  41. package/src/__tests__/errors.test.ts +18 -6
  42. package/src/__tests__/harness-cache.test.ts +74 -0
  43. package/src/__tests__/harness.test.ts +702 -49
  44. package/src/__tests__/index.test.ts +61 -0
  45. package/src/__tests__/resource-lock.test.ts +138 -0
  46. package/src/action-hooks.ts +66 -0
  47. package/src/cli-args.ts +6 -0
  48. package/src/crash-supervisor.ts +13 -6
  49. package/src/env.d.ts +1 -1
  50. package/src/errors.ts +15 -4
  51. package/src/harness.ts +743 -157
  52. package/src/index.ts +276 -64
  53. package/src/logs.ts +17 -3
  54. package/src/resource-lock.ts +450 -0
  55. package/src/run.ts +20 -10
  56. package/src/setup.ts +42 -1
  57. package/tsconfig.json +3 -3
  58. package/tsconfig.lib.json +6 -6
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { Harness } from '../harness.js';
3
+ import type { Config as HarnessConfig } from '@react-native-harness/config';
4
+ import type { Config, Test, TestWatcher } from 'jest-runner';
5
+ import JestHarness from '../index.js';
6
+ import { StartupStallError } from '../errors.js';
7
+
8
+ describe('JestHarness', () => {
9
+ it('reports StartupStallError without a stack trace', async () => {
10
+ const runner = new JestHarness({} as Config.GlobalConfig);
11
+ const onFailure = vi.fn();
12
+
13
+ const harness = {
14
+ ensureAppReady: vi
15
+ .fn()
16
+ .mockRejectedValue(new StartupStallError(1500, 3)),
17
+ callHook: vi.fn(async () => undefined),
18
+ setRunState: vi.fn(),
19
+ getRunState: vi.fn(() => null),
20
+ crashSupervisor: {
21
+ beginTestRun: vi.fn(),
22
+ waitForCrash: vi.fn(),
23
+ cancelCrashWaiters: vi.fn(),
24
+ reset: vi.fn(),
25
+ },
26
+ } as unknown as Harness;
27
+
28
+ await runner._createInBandTestRun(
29
+ [
30
+ {
31
+ path: '/tmp/example.harness.ts',
32
+ context: {
33
+ config: {},
34
+ },
35
+ } as Test,
36
+ ],
37
+ {
38
+ isInterrupted: () => false,
39
+ } as TestWatcher,
40
+ harness,
41
+ {
42
+ detectNativeCrashes: true,
43
+ resetEnvironmentBetweenTestFiles: false,
44
+ } as HarnessConfig,
45
+ () => Promise.resolve(),
46
+ () => Promise.resolve(),
47
+ onFailure
48
+ );
49
+
50
+ expect(onFailure).toHaveBeenCalledWith(
51
+ expect.objectContaining({
52
+ path: '/tmp/example.harness.ts',
53
+ }),
54
+ {
55
+ message:
56
+ 'The app did not request its Metro bundle after 3 launch attempts within 1500ms. Last Metro status: unknown.',
57
+ stack: '',
58
+ }
59
+ );
60
+ });
61
+ });
@@ -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 } from 'vitest';
5
+ import {
6
+ createResourceLockManager,
7
+ hashResourceLockKey,
8
+ } from '../resource-lock.js';
9
+
10
+ describe('resource lock manager', () => {
11
+ let rootDir: string;
12
+
13
+ beforeEach(async () => {
14
+ rootDir = await fs.mkdtemp(
15
+ path.join(os.tmpdir(), 'react-native-harness-resource-lock-test-')
16
+ );
17
+ });
18
+
19
+ afterEach(async () => {
20
+ await fs.rm(rootDir, { recursive: true, force: true });
21
+ });
22
+
23
+ it('queues access in FIFO order', async () => {
24
+ const manager = createResourceLockManager({
25
+ rootDir,
26
+ pollIntervalMs: 5,
27
+ heartbeatIntervalMs: 20,
28
+ staleLockTimeoutMs: 200,
29
+ });
30
+ const order: string[] = [];
31
+
32
+ const firstLease = await manager.acquire(
33
+ 'ios:simulator:iPhone 17 Pro:26.2'
34
+ );
35
+ const secondAcquire = manager
36
+ .acquire('ios:simulator:iPhone 17 Pro:26.2', {
37
+ onWait: () => {
38
+ order.push('waiting');
39
+ },
40
+ })
41
+ .then(async (lease) => {
42
+ order.push('acquired');
43
+ await lease.release();
44
+ });
45
+
46
+ await new Promise((resolve) => setTimeout(resolve, 30));
47
+ expect(order).toEqual(['waiting']);
48
+
49
+ await firstLease.release();
50
+ await secondAcquire;
51
+
52
+ expect(order).toEqual(['waiting', 'acquired']);
53
+ });
54
+
55
+ it('removes the queued ticket when waiting is aborted', async () => {
56
+ const manager = createResourceLockManager({
57
+ rootDir,
58
+ pollIntervalMs: 5,
59
+ heartbeatIntervalMs: 20,
60
+ staleLockTimeoutMs: 200,
61
+ });
62
+ const key = 'android:emulator:Pixel_8_API_35';
63
+ const firstLease = await manager.acquire(key);
64
+ const controller = new AbortController();
65
+
66
+ const acquirePromise = manager.acquire(key, {
67
+ signal: controller.signal,
68
+ });
69
+
70
+ await new Promise((resolve) => setTimeout(resolve, 30));
71
+ controller.abort();
72
+
73
+ await expect(acquirePromise).rejects.toMatchObject({
74
+ name: 'AbortError',
75
+ });
76
+
77
+ const queueDir = path.join(rootDir, hashResourceLockKey(key), 'queue');
78
+ const queuedEntries = await fs.readdir(queueDir);
79
+ expect(queuedEntries).toHaveLength(0);
80
+
81
+ await firstLease.release();
82
+ });
83
+
84
+ it('keeps queued tickets alive while the waiting process is still active', async () => {
85
+ const manager = createResourceLockManager({
86
+ rootDir,
87
+ pollIntervalMs: 5,
88
+ heartbeatIntervalMs: 20,
89
+ staleLockTimeoutMs: 30,
90
+ isProcessActive: () => true,
91
+ });
92
+ const key = 'ios:simulator:iPhone 17 Pro:26.2';
93
+ const firstLease = await manager.acquire(key);
94
+
95
+ const secondAcquire = manager.acquire(key);
96
+
97
+ await new Promise((resolve) => setTimeout(resolve, 80));
98
+
99
+ await firstLease.release();
100
+ const secondLease = await secondAcquire;
101
+ await secondLease.release();
102
+ });
103
+
104
+ it('reclaims a stale owner before granting the lock', async () => {
105
+ const manager = createResourceLockManager({
106
+ rootDir,
107
+ pollIntervalMs: 5,
108
+ heartbeatIntervalMs: 20,
109
+ staleLockTimeoutMs: 50,
110
+ isProcessActive: () => false,
111
+ });
112
+ const key = 'web:browser:chromium';
113
+ const keyDir = path.join(rootDir, hashResourceLockKey(key));
114
+ const queueDir = path.join(keyDir, 'queue');
115
+ const ownerFilePath = path.join(keyDir, 'owner.json');
116
+
117
+ await fs.mkdir(queueDir, { recursive: true });
118
+ await fs.writeFile(
119
+ ownerFilePath,
120
+ JSON.stringify({
121
+ ticketId: 'stale-owner',
122
+ key,
123
+ pid: 999999,
124
+ createdAt: Date.now() - 1000,
125
+ heartbeatAt: Date.now() - 1000,
126
+ }),
127
+ 'utf8'
128
+ );
129
+
130
+ const lease = await manager.acquire(key);
131
+ const owner = JSON.parse(await fs.readFile(ownerFilePath, 'utf8')) as {
132
+ ticketId: string;
133
+ };
134
+ expect(owner.ticketId).not.toBe('stale-owner');
135
+
136
+ await lease.release();
137
+ });
138
+ });
@@ -0,0 +1,66 @@
1
+ import {
2
+ definePlugin,
3
+ type HarnessPlugin,
4
+ } from '@react-native-harness/plugins';
5
+ import type { Config as HarnessConfig } from '@react-native-harness/config';
6
+ import type { HarnessPlatform } from '@react-native-harness/platforms';
7
+ import { spawn } from '@react-native-harness/tools';
8
+
9
+ type ActionHookState = {
10
+ _unused?: never;
11
+ };
12
+
13
+ const getInlineHookScript = (
14
+ name: 'PRE_RUN_HOOK' | 'AFTER_RUN_HOOK'
15
+ ): string | null => {
16
+ const value = process.env[name]?.trim();
17
+
18
+ return value ? value : null;
19
+ };
20
+
21
+ const runInlineHook = async (script: string): Promise<void> => {
22
+ const env = Object.fromEntries(
23
+ Object.entries(process.env).filter(
24
+ (entry): entry is [string, string] => entry[1] != null
25
+ )
26
+ );
27
+
28
+ await spawn('bash', ['-lc', script], {
29
+ env,
30
+ cwd: process.env.HARNESS_PROJECT_ROOT,
31
+ });
32
+ };
33
+
34
+ export const createActionHooksPlugin = (): HarnessPlugin<
35
+ ActionHookState,
36
+ HarnessConfig,
37
+ HarnessPlatform
38
+ > =>
39
+ definePlugin({
40
+ name: 'github-action-hooks',
41
+ createState: () => ({}),
42
+ hooks: {
43
+ harness: {
44
+ beforeRun: async () => {
45
+ const script = getInlineHookScript('PRE_RUN_HOOK');
46
+
47
+ if (!script) {
48
+ return;
49
+ }
50
+
51
+ await runInlineHook(script);
52
+ },
53
+ afterRun: async (ctx) => {
54
+ const script = getInlineHookScript('AFTER_RUN_HOOK');
55
+
56
+ if (!script) {
57
+ return;
58
+ }
59
+
60
+ process.env.HARNESS_EXIT_CODE = ctx.status === 'passed' ? '0' : '1';
61
+
62
+ await runInlineHook(script);
63
+ },
64
+ },
65
+ },
66
+ });
package/src/cli-args.ts CHANGED
@@ -3,6 +3,7 @@ import { hideBin } from 'yargs/helpers';
3
3
 
4
4
  export type HarnessCliArgs = {
5
5
  harnessRunner?: string;
6
+ metroPort?: number;
6
7
  };
7
8
 
8
9
  export const getAdditionalCliArgs = (): HarnessCliArgs => {
@@ -11,6 +12,10 @@ export const getAdditionalCliArgs = (): HarnessCliArgs => {
11
12
  type: 'string',
12
13
  description: 'Specify which Harness runner to use',
13
14
  })
15
+ .option('metroPort', {
16
+ type: 'number',
17
+ description: 'Override the Metro bundler port',
18
+ })
14
19
  .strict(false)
15
20
  .help(false)
16
21
  .version(false)
@@ -19,5 +24,6 @@ export const getAdditionalCliArgs = (): HarnessCliArgs => {
19
24
 
20
25
  return {
21
26
  harnessRunner: argv.harnessRunner,
27
+ metroPort: argv.metroPort,
22
28
  };
23
29
  };
@@ -12,6 +12,8 @@ import {
12
12
  } from './errors.js';
13
13
  import { logger } from '@react-native-harness/tools';
14
14
 
15
+ const crashLogger = logger.child('crash');
16
+
15
17
  export type CrashSupervisorState =
16
18
  | 'idle'
17
19
  | 'launching'
@@ -113,15 +115,20 @@ export const createCrashSupervisor = ({
113
115
  }
114
116
 
115
117
  if (!activeTestFilePath) {
116
- logger.debug(`Ignoring crash signal without active test: ${reason}`);
118
+ crashLogger.debug('ignoring crash signal without active test: %s', reason);
117
119
  return;
118
120
  }
119
121
 
120
122
  isResolvingCrash = true;
121
- logger.debug(`Native crash detected during ${activeTestFilePath} (state: ${state}, reason: ${reason || '(none)'})`);
123
+ crashLogger.debug(
124
+ 'native crash detected during %s (state=%s reason=%s)',
125
+ activeTestFilePath,
126
+ state,
127
+ reason || '(none)'
128
+ );
122
129
 
123
130
  for (const line of details?.rawLines ?? []) {
124
- logger.debug(line);
131
+ crashLogger.debug('%s', line);
125
132
  }
126
133
 
127
134
  const phase = getCrashPhase(state);
@@ -136,7 +143,7 @@ export const createCrashSupervisor = ({
136
143
  });
137
144
 
138
145
  const mergedDetails = mergeCrashDetails(phase, details, enrichedDetails, reason);
139
- logger.debug('Crash details:', {
146
+ crashLogger.debug('crash details: %o', {
140
147
  phase: mergedDetails.phase,
141
148
  source: mergedDetails.source,
142
149
  summary: mergedDetails.summary,
@@ -160,10 +167,10 @@ export const createCrashSupervisor = ({
160
167
  const isRunning = await platformRunner.isAppRunning();
161
168
 
162
169
  if (!isRunning) {
163
- handleCrash(reason, details);
170
+ void handleCrash(reason, details);
164
171
  }
165
172
  } catch (error) {
166
- logger.debug('Crash confirmation failed', error);
173
+ crashLogger.debug('crash confirmation failed', error);
167
174
  }
168
175
  };
169
176
 
package/src/env.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Harness } from '@react-native-harness/cli/external';
1
+ import type { Harness } from './harness.js';
2
2
  import type { Config as HarnessConfig } from '@react-native-harness/config';
3
3
 
4
4
  declare global {
package/src/errors.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  import { HarnessError } from '@react-native-harness/tools';
2
2
  import type { AppCrashDetails } from '@react-native-harness/platforms';
3
+ export {
4
+ StartupStallError,
5
+ type StartupStallCode,
6
+ type StartupStallDetails,
7
+ } from '@react-native-harness/bundler-metro';
3
8
 
4
9
  export class NoRunnerSpecifiedError extends HarnessError {
5
10
  constructor() {
@@ -22,6 +27,15 @@ export class InitializationTimeoutError extends HarnessError {
22
27
  }
23
28
  }
24
29
 
30
+ export class PlatformReadyTimeoutError extends HarnessError {
31
+ constructor(public readonly timeout: number) {
32
+ super(
33
+ `The platform did not become ready within ${timeout}ms. Increase "platformReadyTimeout" if your device, simulator, or emulator needs more time to start.`
34
+ );
35
+ this.name = 'PlatformReadyTimeoutError';
36
+ }
37
+ }
38
+
25
39
  export type NativeCrashPhase = 'startup' | 'execution';
26
40
 
27
41
  export type NativeCrashDetails = AppCrashDetails & {
@@ -46,10 +60,7 @@ const buildNativeCrashMessage = ({
46
60
  const hasCrashBlock = summary?.includes('\n') ?? false;
47
61
  const shouldRenderSummary =
48
62
  Boolean(summary) &&
49
- !(
50
- !hasCrashBlock &&
51
- artifactType === 'ios-crash-report'
52
- );
63
+ !(!hasCrashBlock && artifactType === 'ios-crash-report');
53
64
 
54
65
  if (shouldRenderSummary && summary) {
55
66
  lines.push('');