@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.
Files changed (58) hide show
  1. package/dist/__tests__/errors.test.js +6 -1
  2. package/dist/__tests__/harness-cache.test.js +1 -1
  3. package/dist/__tests__/harness.test.js +147 -3
  4. package/dist/__tests__/resource-lock.test.d.ts +2 -0
  5. package/dist/__tests__/resource-lock.test.d.ts.map +1 -0
  6. package/dist/__tests__/resource-lock.test.js +103 -0
  7. package/dist/action-hooks.d.ts +9 -0
  8. package/dist/action-hooks.d.ts.map +1 -0
  9. package/dist/action-hooks.js +36 -0
  10. package/dist/cli-args.test.d.ts +2 -0
  11. package/dist/cli-args.test.d.ts.map +1 -0
  12. package/dist/cli-args.test.js +1 -0
  13. package/dist/crash-monitor.d.ts +16 -0
  14. package/dist/crash-monitor.d.ts.map +1 -0
  15. package/dist/crash-monitor.js +75 -0
  16. package/dist/errors.d.ts +4 -0
  17. package/dist/errors.d.ts.map +1 -1
  18. package/dist/errors.js +9 -2
  19. package/dist/event-mapper.d.ts +16 -0
  20. package/dist/event-mapper.d.ts.map +1 -0
  21. package/dist/event-mapper.js +120 -0
  22. package/dist/global-setup.d.ts +3 -0
  23. package/dist/global-setup.d.ts.map +1 -0
  24. package/dist/global-setup.js +60 -0
  25. package/dist/global-teardown.d.ts +3 -0
  26. package/dist/global-teardown.d.ts.map +1 -0
  27. package/dist/global-teardown.js +11 -0
  28. package/dist/harness-reporter.d.ts +8 -0
  29. package/dist/harness-reporter.d.ts.map +1 -0
  30. package/dist/harness-reporter.js +12 -0
  31. package/dist/harness.d.ts.map +1 -1
  32. package/dist/harness.js +478 -386
  33. package/dist/jest-options.d.ts +2 -0
  34. package/dist/jest-options.d.ts.map +1 -0
  35. package/dist/jest-options.js +1 -0
  36. package/dist/logs.d.ts +3 -0
  37. package/dist/logs.d.ts.map +1 -1
  38. package/dist/logs.js +9 -0
  39. package/dist/resource-lock.d.ts +25 -0
  40. package/dist/resource-lock.d.ts.map +1 -0
  41. package/dist/resource-lock.js +278 -0
  42. package/dist/setup.d.ts.map +1 -1
  43. package/dist/setup.js +11 -0
  44. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  45. package/dist/types.d.ts +17 -0
  46. package/dist/types.d.ts.map +1 -0
  47. package/dist/types.js +1 -0
  48. package/package.json +7 -7
  49. package/src/__tests__/errors.test.ts +18 -6
  50. package/src/__tests__/harness-cache.test.ts +2 -2
  51. package/src/__tests__/harness.test.ts +254 -19
  52. package/src/__tests__/resource-lock.test.ts +138 -0
  53. package/src/action-hooks.ts +66 -0
  54. package/src/errors.ts +10 -4
  55. package/src/harness.ts +585 -445
  56. package/src/logs.ts +13 -3
  57. package/src/resource-lock.ts +450 -0
  58. package/src/setup.ts +21 -1
package/src/logs.ts CHANGED
@@ -25,10 +25,20 @@ export const logTestEnvironmentReady = (runner: HarnessPlatform): void => {
25
25
  log(`${TAG} Runner ${chalk.bold(runner.name)} ready\n`);
26
26
  };
27
27
 
28
+ export const logRunnerWaitingInQueue = (runner: HarnessPlatform): void => {
29
+ log(`${TAG} Runner ${chalk.bold(runner.name)} is busy, waiting in queue\n`);
30
+ };
31
+
32
+ export const logRunnerStillWaitingInQueue = (runner: HarnessPlatform): void => {
33
+ log(`${TAG} Still waiting in queue for ${chalk.bold(runner.name)} runner\n`);
34
+ };
35
+
36
+ export const logRunnerStarting = (runner: HarnessPlatform): void => {
37
+ log(`${TAG} Runner ${chalk.bold(runner.name)} is starting\n`);
38
+ };
39
+
28
40
  export const logMetroPrewarmCompleted = (runner: HarnessPlatform): void => {
29
- log(
30
- `${TAG} Metro pre-warm for ${chalk.bold(runner.name)} completed\n`
31
- );
41
+ log(`${TAG} Metro pre-warm for ${chalk.bold(runner.name)} completed\n`);
32
42
  };
33
43
 
34
44
  export const logMetroCacheReused = (runner: HarnessPlatform): void => {
@@ -0,0 +1,450 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { logger, type HarnessLogger } from '@react-native-harness/tools';
6
+
7
+ const resourceLockLogger = logger.child('resource-lock');
8
+
9
+ const DEFAULT_ROOT_DIR = path.join(os.tmpdir(), 'react-native-harness-locks');
10
+ const DEFAULT_POLL_INTERVAL_MS = 1000;
11
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 2000;
12
+ const DEFAULT_STALE_LOCK_TIMEOUT_MS = 15000;
13
+
14
+ type ResourceLockMetadata = {
15
+ ticketId: string;
16
+ key: string;
17
+ pid: number;
18
+ createdAt: number;
19
+ heartbeatAt: number;
20
+ };
21
+
22
+ export type ResourceLockAcquireOptions = {
23
+ signal?: AbortSignal;
24
+ onWait?: () => void;
25
+ onStillWaiting?: (elapsedMs: number) => void;
26
+ };
27
+
28
+ export type ResourceLease = {
29
+ release: () => Promise<void>;
30
+ };
31
+
32
+ export type ResourceLockManager = {
33
+ acquire: (
34
+ key: string,
35
+ options?: ResourceLockAcquireOptions
36
+ ) => Promise<ResourceLease>;
37
+ };
38
+
39
+ type ResourceLockManagerOptions = {
40
+ rootDir?: string;
41
+ pollIntervalMs?: number;
42
+ heartbeatIntervalMs?: number;
43
+ staleLockTimeoutMs?: number;
44
+ pid?: number;
45
+ logger?: HarnessLogger;
46
+ isProcessActive?: (pid: number) => boolean;
47
+ };
48
+
49
+ type LockPaths = {
50
+ rootDir: string;
51
+ keyDir: string;
52
+ queueDir: string;
53
+ ownerFilePath: string;
54
+ };
55
+
56
+ const wait = async (ms: number): Promise<void> => {
57
+ await new Promise((resolve) => setTimeout(resolve, ms));
58
+ };
59
+
60
+ const createAbortError = () =>
61
+ new DOMException('The operation was aborted', 'AbortError');
62
+
63
+ export const hashResourceLockKey = (key: string): string => {
64
+ return crypto.createHash('sha256').update(key).digest('hex');
65
+ };
66
+
67
+ const getPathsForKey = (rootDir: string, key: string): LockPaths => {
68
+ const keyDir = path.join(rootDir, hashResourceLockKey(key));
69
+ return {
70
+ rootDir,
71
+ keyDir,
72
+ queueDir: path.join(keyDir, 'queue'),
73
+ ownerFilePath: path.join(keyDir, 'owner.json'),
74
+ };
75
+ };
76
+
77
+ const createTicketId = (createdAt: number, pid: number): string => {
78
+ return `${createdAt
79
+ .toString()
80
+ .padStart(16, '0')}-${pid}-${crypto.randomUUID()}`;
81
+ };
82
+
83
+ const isMissingFileError = (error: unknown): boolean => {
84
+ return (
85
+ error instanceof Error &&
86
+ 'code' in error &&
87
+ typeof error.code === 'string' &&
88
+ error.code === 'ENOENT'
89
+ );
90
+ };
91
+
92
+ const isExclusiveCreateError = (error: unknown): boolean => {
93
+ return (
94
+ error instanceof Error &&
95
+ 'code' in error &&
96
+ typeof error.code === 'string' &&
97
+ error.code === 'EEXIST'
98
+ );
99
+ };
100
+
101
+ const ensureLockDirectories = async (paths: LockPaths): Promise<void> => {
102
+ await fs.mkdir(paths.queueDir, { recursive: true });
103
+ };
104
+
105
+ const readJsonFile = async <T>(filePath: string): Promise<T | null> => {
106
+ try {
107
+ const content = await fs.readFile(filePath, 'utf8');
108
+ return JSON.parse(content) as T;
109
+ } catch (error) {
110
+ if (isMissingFileError(error)) {
111
+ return null;
112
+ }
113
+
114
+ throw error;
115
+ }
116
+ };
117
+
118
+ const removeFileIfPresent = async (filePath: string): Promise<void> => {
119
+ try {
120
+ await fs.rm(filePath, { force: true });
121
+ } catch (error) {
122
+ if (!isMissingFileError(error)) {
123
+ throw error;
124
+ }
125
+ }
126
+ };
127
+
128
+ const isPidActive = (pid: number): boolean => {
129
+ try {
130
+ process.kill(pid, 0);
131
+ return true;
132
+ } catch (error) {
133
+ return !(
134
+ error instanceof Error &&
135
+ 'code' in error &&
136
+ typeof error.code === 'string' &&
137
+ error.code === 'ESRCH'
138
+ );
139
+ }
140
+ };
141
+
142
+ const isMetadataStale = (
143
+ metadata: ResourceLockMetadata,
144
+ now: number,
145
+ staleLockTimeoutMs: number,
146
+ isProcessActive: (pid: number) => boolean
147
+ ): boolean => {
148
+ if (!isProcessActive(metadata.pid)) {
149
+ return true;
150
+ }
151
+
152
+ return now - metadata.heartbeatAt > staleLockTimeoutMs;
153
+ };
154
+
155
+ const isQueuedTicketStale = (
156
+ metadata: ResourceLockMetadata,
157
+ isProcessActive: (pid: number) => boolean
158
+ ): boolean => {
159
+ return !isProcessActive(metadata.pid);
160
+ };
161
+
162
+ const waitForPollInterval = (
163
+ ms: number,
164
+ signal?: AbortSignal
165
+ ): Promise<void> => {
166
+ if (!signal) {
167
+ return wait(ms);
168
+ }
169
+
170
+ if (signal.aborted) {
171
+ return Promise.reject(signal.reason ?? createAbortError());
172
+ }
173
+
174
+ return new Promise((resolve, reject) => {
175
+ const timer = setTimeout(() => {
176
+ signal.removeEventListener('abort', onAbort);
177
+ resolve();
178
+ }, ms);
179
+ const onAbort = () => {
180
+ clearTimeout(timer);
181
+ signal.removeEventListener('abort', onAbort);
182
+ reject(signal.reason ?? createAbortError());
183
+ };
184
+
185
+ signal.addEventListener('abort', onAbort, { once: true });
186
+ });
187
+ };
188
+
189
+ const readQueueTickets = async (
190
+ queueDir: string
191
+ ): Promise<ResourceLockMetadata[]> => {
192
+ const ticketEntries = await fs.readdir(queueDir, { withFileTypes: true });
193
+ const tickets = await Promise.all(
194
+ ticketEntries
195
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
196
+ .map(async (entry) => ({
197
+ name: entry.name,
198
+ metadata: await readJsonFile<ResourceLockMetadata>(
199
+ path.join(queueDir, entry.name)
200
+ ),
201
+ }))
202
+ );
203
+
204
+ return tickets
205
+ .filter(
206
+ (entry): entry is { name: string; metadata: ResourceLockMetadata } =>
207
+ entry.metadata !== null
208
+ )
209
+ .sort((left, right) => left.name.localeCompare(right.name))
210
+ .map((entry) => entry.metadata);
211
+ };
212
+
213
+ const cleanupQueue = async (options: {
214
+ paths: LockPaths;
215
+ currentTicketId: string;
216
+ logger: HarnessLogger;
217
+ isProcessActive: (pid: number) => boolean;
218
+ }): Promise<ResourceLockMetadata[]> => {
219
+ const { paths, currentTicketId, logger, isProcessActive } = options;
220
+ const tickets = await readQueueTickets(paths.queueDir);
221
+ const activeTickets: ResourceLockMetadata[] = [];
222
+
223
+ for (const ticket of tickets) {
224
+ const isCurrentTicket = ticket.ticketId === currentTicketId;
225
+ const isStale =
226
+ !isCurrentTicket && isQueuedTicketStale(ticket, isProcessActive);
227
+
228
+ if (isStale) {
229
+ logger.debug(
230
+ 'removing stale queued ticket %s for key %s',
231
+ ticket.ticketId,
232
+ ticket.key
233
+ );
234
+ await removeFileIfPresent(
235
+ path.join(paths.queueDir, `${ticket.ticketId}.json`)
236
+ );
237
+ continue;
238
+ }
239
+
240
+ activeTickets.push(ticket);
241
+ }
242
+
243
+ return activeTickets;
244
+ };
245
+
246
+ const maybeClearStaleOwner = async (options: {
247
+ ownerFilePath: string;
248
+ staleLockTimeoutMs: number;
249
+ now: number;
250
+ logger: HarnessLogger;
251
+ isProcessActive: (pid: number) => boolean;
252
+ }): Promise<ResourceLockMetadata | null> => {
253
+ const { ownerFilePath, staleLockTimeoutMs, now, logger, isProcessActive } =
254
+ options;
255
+ const owner = await readJsonFile<ResourceLockMetadata>(ownerFilePath);
256
+
257
+ if (!owner) {
258
+ return null;
259
+ }
260
+
261
+ if (!isMetadataStale(owner, now, staleLockTimeoutMs, isProcessActive)) {
262
+ return owner;
263
+ }
264
+
265
+ logger.debug(
266
+ 'removing stale owner ticket %s for key %s',
267
+ owner.ticketId,
268
+ owner.key
269
+ );
270
+ await removeFileIfPresent(ownerFilePath);
271
+ return null;
272
+ };
273
+
274
+ const claimOwnership = async (
275
+ ownerFilePath: string,
276
+ metadata: ResourceLockMetadata
277
+ ): Promise<boolean> => {
278
+ try {
279
+ await fs.writeFile(ownerFilePath, JSON.stringify(metadata), {
280
+ encoding: 'utf8',
281
+ flag: 'wx',
282
+ });
283
+ return true;
284
+ } catch (error) {
285
+ if (isExclusiveCreateError(error)) {
286
+ return false;
287
+ }
288
+
289
+ throw error;
290
+ }
291
+ };
292
+
293
+ export const createResourceLockManager = (
294
+ options: ResourceLockManagerOptions = {}
295
+ ): ResourceLockManager => {
296
+ const rootDir = options.rootDir ?? DEFAULT_ROOT_DIR;
297
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
298
+ const heartbeatIntervalMs =
299
+ options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
300
+ const staleLockTimeoutMs =
301
+ options.staleLockTimeoutMs ?? DEFAULT_STALE_LOCK_TIMEOUT_MS;
302
+ const pid = options.pid ?? process.pid;
303
+ const scopedLogger = options.logger ?? resourceLockLogger;
304
+ const isProcessActive = options.isProcessActive ?? isPidActive;
305
+
306
+ return {
307
+ acquire: async (key, acquireOptions = {}) => {
308
+ const paths = getPathsForKey(rootDir, key);
309
+ await ensureLockDirectories(paths);
310
+
311
+ const createdAt = Date.now();
312
+ const ticketId = createTicketId(createdAt, pid);
313
+ const ticketPath = path.join(paths.queueDir, `${ticketId}.json`);
314
+ const metadata: ResourceLockMetadata = {
315
+ ticketId,
316
+ key,
317
+ pid,
318
+ createdAt,
319
+ heartbeatAt: createdAt,
320
+ };
321
+
322
+ await fs.writeFile(ticketPath, JSON.stringify(metadata), 'utf8');
323
+ scopedLogger.debug('queued ticket %s for key %s', ticketId, key);
324
+
325
+ let heartbeatTimer: NodeJS.Timeout | null = null;
326
+ let released = false;
327
+ let didNotifyWait = false;
328
+ const waitStartedAt = Date.now();
329
+
330
+ const release = async () => {
331
+ if (released) {
332
+ return;
333
+ }
334
+
335
+ released = true;
336
+ if (heartbeatTimer) {
337
+ clearInterval(heartbeatTimer);
338
+ heartbeatTimer = null;
339
+ }
340
+
341
+ const owner = await readJsonFile<ResourceLockMetadata>(
342
+ paths.ownerFilePath
343
+ );
344
+ if (owner?.ticketId === ticketId) {
345
+ await removeFileIfPresent(paths.ownerFilePath);
346
+ }
347
+
348
+ await removeFileIfPresent(ticketPath);
349
+ scopedLogger.debug('released ticket %s for key %s', ticketId, key);
350
+ };
351
+
352
+ const startHeartbeat = () => {
353
+ heartbeatTimer = setInterval(async () => {
354
+ const nextHeartbeatAt = Date.now();
355
+ const owner = await readJsonFile<ResourceLockMetadata>(
356
+ paths.ownerFilePath
357
+ );
358
+
359
+ if (released || owner?.ticketId !== ticketId) {
360
+ return;
361
+ }
362
+
363
+ const nextMetadata: ResourceLockMetadata = {
364
+ ...owner,
365
+ heartbeatAt: nextHeartbeatAt,
366
+ };
367
+
368
+ if (released) {
369
+ return;
370
+ }
371
+
372
+ await fs.writeFile(
373
+ paths.ownerFilePath,
374
+ JSON.stringify(nextMetadata),
375
+ 'utf8'
376
+ );
377
+ scopedLogger.debug('refreshed heartbeat for ticket %s', ticketId);
378
+ }, heartbeatIntervalMs);
379
+ heartbeatTimer.unref?.();
380
+ };
381
+
382
+ try {
383
+ while (true) {
384
+ acquireOptions.signal?.throwIfAborted();
385
+
386
+ const now = Date.now();
387
+ const activeTickets = await cleanupQueue({
388
+ paths,
389
+ currentTicketId: ticketId,
390
+ logger: scopedLogger,
391
+ isProcessActive,
392
+ });
393
+ const ownIndex = activeTickets.findIndex(
394
+ (entry) => entry.ticketId === ticketId
395
+ );
396
+
397
+ if (ownIndex === -1) {
398
+ throw new Error(
399
+ `Queued ticket ${ticketId} disappeared before acquisition.`
400
+ );
401
+ }
402
+
403
+ const owner = await maybeClearStaleOwner({
404
+ ownerFilePath: paths.ownerFilePath,
405
+ staleLockTimeoutMs,
406
+ now,
407
+ logger: scopedLogger,
408
+ isProcessActive,
409
+ });
410
+
411
+ if (ownIndex === 0 && owner === null) {
412
+ const claimed = await claimOwnership(paths.ownerFilePath, {
413
+ ...metadata,
414
+ heartbeatAt: Date.now(),
415
+ });
416
+
417
+ if (claimed) {
418
+ await removeFileIfPresent(ticketPath);
419
+ startHeartbeat();
420
+ scopedLogger.debug(
421
+ 'acquired lock for key %s with ticket %s',
422
+ key,
423
+ ticketId
424
+ );
425
+ return { release };
426
+ }
427
+ }
428
+
429
+ if (!didNotifyWait) {
430
+ didNotifyWait = true;
431
+ acquireOptions.onWait?.();
432
+ }
433
+
434
+ acquireOptions.onStillWaiting?.(Date.now() - waitStartedAt);
435
+ scopedLogger.debug(
436
+ 'waiting for key %s with ticket %s at queue position %d',
437
+ key,
438
+ ticketId,
439
+ ownIndex + 1
440
+ );
441
+
442
+ await waitForPollInterval(pollIntervalMs, acquireOptions.signal);
443
+ }
444
+ } catch (error) {
445
+ await release();
446
+ throw error;
447
+ }
448
+ },
449
+ };
450
+ };
package/src/setup.ts CHANGED
@@ -11,6 +11,7 @@ import { logTestEnvironmentReady, logTestRunHeader } from './logs.js';
11
11
  import { NoRunnerSpecifiedError, RunnerNotFoundError } from './errors.js';
12
12
  import { HarnessPlatform } from '@react-native-harness/platforms';
13
13
  import { logger } from '@react-native-harness/tools';
14
+ import { createActionHooksPlugin } from './action-hooks.js';
14
15
 
15
16
  const setupLogger = logger.child('setup');
16
17
 
@@ -66,15 +67,34 @@ export const setup = async (globalConfig: JestConfig.GlobalConfig) => {
66
67
  const cliArgs = getAdditionalCliArgs();
67
68
 
68
69
  if (cliArgs.metroPort != null) {
69
- setupLogger.debug('applying CLI metro port override: %d', cliArgs.metroPort);
70
+ setupLogger.debug(
71
+ 'applying CLI metro port override: %d',
72
+ cliArgs.metroPort
73
+ );
70
74
  harnessConfig = ConfigSchema.parse({
71
75
  ...harnessConfig,
72
76
  metroPort: cliArgs.metroPort,
73
77
  });
74
78
  }
75
79
 
80
+ if (process.env.PRE_RUN_HOOK || process.env.AFTER_RUN_HOOK) {
81
+ harnessConfig = ConfigSchema.parse({
82
+ ...harnessConfig,
83
+ plugins: [...(harnessConfig.plugins ?? []), createActionHooksPlugin()],
84
+ });
85
+ }
86
+
76
87
  const selectedRunner = getHarnessRunner(harnessConfig, cliArgs);
77
88
 
89
+ if (
90
+ harnessConfig.webSocketPort != null &&
91
+ harnessConfig.webSocketPort !== harnessConfig.metroPort
92
+ ) {
93
+ logger.warn(
94
+ `Config option "webSocketPort" is deprecated and ignored. Harness now uses metroPort (${harnessConfig.metroPort}) for bridge traffic.`
95
+ );
96
+ }
97
+
78
98
  if (globalConfig.collectCoverage) {
79
99
  // This is going to be used by @react-native-harness/babel-preset
80
100
  // to enable instrumentation of test files.