@react-native-harness/jest 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/cli-args.d.ts +3 -4
  2. package/dist/cli-args.d.ts.map +1 -1
  3. package/dist/cli-args.js +3 -8
  4. package/dist/diagnostics/index.d.ts +4 -1
  5. package/dist/diagnostics/index.d.ts.map +1 -1
  6. package/dist/diagnostics/index.js +2 -1
  7. package/dist/diagnostics/report.d.ts +81 -1
  8. package/dist/diagnostics/report.d.ts.map +1 -1
  9. package/dist/diagnostics/report.js +225 -3
  10. package/dist/diagnostics/telemetry.d.ts +42 -0
  11. package/dist/diagnostics/telemetry.d.ts.map +1 -0
  12. package/dist/diagnostics/telemetry.js +120 -0
  13. package/dist/errors.d.ts +8 -2
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js +29 -3
  16. package/dist/execute-run.d.ts.map +1 -1
  17. package/dist/execute-run.js +73 -21
  18. package/dist/format-harness-error.d.ts.map +1 -1
  19. package/dist/format-harness-error.js +3 -0
  20. package/dist/harness-session.d.ts +8 -1
  21. package/dist/harness-session.d.ts.map +1 -1
  22. package/dist/harness-session.js +110 -23
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +7 -7
  25. package/dist/jest-preset.d.ts +3 -0
  26. package/dist/jest-preset.d.ts.map +1 -1
  27. package/dist/jest-preset.js +18 -0
  28. package/dist/relative-path.d.ts +8 -0
  29. package/dist/relative-path.d.ts.map +1 -0
  30. package/dist/relative-path.js +8 -0
  31. package/dist/resource-lock.d.ts.map +1 -1
  32. package/dist/resource-lock.js +47 -27
  33. package/dist/run.d.ts.map +1 -1
  34. package/dist/run.js +4 -4
  35. package/dist/watch-session.d.ts +6 -0
  36. package/dist/watch-session.d.ts.map +1 -0
  37. package/dist/watch-session.js +37 -0
  38. package/package.json +8 -8
  39. package/src/cli-args.ts +5 -13
  40. package/src/diagnostics/index.ts +12 -1
  41. package/src/diagnostics/report.ts +326 -5
  42. package/src/diagnostics/telemetry.ts +186 -0
  43. package/src/errors.ts +42 -5
  44. package/src/execute-run.ts +93 -23
  45. package/src/format-harness-error.ts +5 -0
  46. package/src/harness-session.ts +143 -22
  47. package/src/index.ts +8 -7
  48. package/src/jest-preset.ts +18 -0
  49. package/src/relative-path.ts +10 -0
  50. package/src/resource-lock.ts +64 -33
  51. package/src/run.ts +4 -4
  52. package/src/watch-session.ts +65 -0
@@ -89,6 +89,37 @@ const isMissingFileError = (error: unknown): boolean => {
89
89
  );
90
90
  };
91
91
 
92
+ // On Windows, a file being deleted by another process can briefly fail
93
+ // reads and deletes with EPERM/EBUSY instead of ENOENT.
94
+ const isTransientFsError = (error: unknown): boolean => {
95
+ return (
96
+ error instanceof Error &&
97
+ 'code' in error &&
98
+ (error.code === 'EPERM' || error.code === 'EBUSY')
99
+ );
100
+ };
101
+
102
+ const TRANSIENT_FS_RETRIES = 5;
103
+ const TRANSIENT_FS_RETRY_DELAY_MS = 20;
104
+
105
+ const retryTransientFsError = async <T>(
106
+ operation: () => Promise<T>,
107
+ ): Promise<T> => {
108
+ for (let attempt = 1; ; attempt += 1) {
109
+ try {
110
+ return await operation();
111
+ } catch (error) {
112
+ if (!isTransientFsError(error) || attempt >= TRANSIENT_FS_RETRIES) {
113
+ throw error;
114
+ }
115
+
116
+ await new Promise((resolve) =>
117
+ setTimeout(resolve, TRANSIENT_FS_RETRY_DELAY_MS),
118
+ );
119
+ }
120
+ }
121
+ };
122
+
92
123
  const isExclusiveCreateError = (error: unknown): boolean => {
93
124
  return (
94
125
  error instanceof Error &&
@@ -104,7 +135,9 @@ const ensureLockDirectories = async (paths: LockPaths): Promise<void> => {
104
135
 
105
136
  const readJsonFile = async <T>(filePath: string): Promise<T | null> => {
106
137
  try {
107
- const content = await fs.readFile(filePath, 'utf8');
138
+ const content = await retryTransientFsError(() =>
139
+ fs.readFile(filePath, 'utf8'),
140
+ );
108
141
  return JSON.parse(content) as T;
109
142
  } catch (error) {
110
143
  if (isMissingFileError(error)) {
@@ -132,7 +165,7 @@ const writeJsonFileAtomic = async <T>(
132
165
 
133
166
  const removeFileIfPresent = async (filePath: string): Promise<void> => {
134
167
  try {
135
- await fs.rm(filePath, { force: true });
168
+ await retryTransientFsError(() => fs.rm(filePath, { force: true }));
136
169
  } catch (error) {
137
170
  if (!isMissingFileError(error)) {
138
171
  throw error;
@@ -338,7 +371,7 @@ export const createResourceLockManager = (
338
371
  scopedLogger.debug('queued ticket %s for key %s', ticketId, key);
339
372
 
340
373
  let heartbeatTimer: NodeJS.Timeout | null = null;
341
- let heartbeatInFlight = false;
374
+ let heartbeatInFlight: Promise<void> | null = null;
342
375
  let released = false;
343
376
  let didNotifyWait = false;
344
377
  const waitStartedAt = Date.now();
@@ -354,6 +387,10 @@ export const createResourceLockManager = (
354
387
  heartbeatTimer = null;
355
388
  }
356
389
 
390
+ // Let an in-flight refresh finish so it cannot recreate owner.json
391
+ // after we remove it.
392
+ await heartbeatInFlight;
393
+
357
394
  const owner = await readJsonFile<ResourceLockMetadata>(
358
395
  paths.ownerFilePath,
359
396
  );
@@ -366,13 +403,7 @@ export const createResourceLockManager = (
366
403
  };
367
404
 
368
405
  const startHeartbeat = () => {
369
- heartbeatTimer = setInterval(async () => {
370
- if (heartbeatInFlight) {
371
- return;
372
- }
373
-
374
- heartbeatInFlight = true;
375
-
406
+ const refreshHeartbeat = async () => {
376
407
  try {
377
408
  const nextHeartbeatAt = Date.now();
378
409
  const owner = await readJsonFile<ResourceLockMetadata>(
@@ -383,33 +414,33 @@ export const createResourceLockManager = (
383
414
  return;
384
415
  }
385
416
 
386
- const nextMetadata: ResourceLockMetadata = {
417
+ await writeJsonFileAtomic(paths.ownerFilePath, {
387
418
  ...owner,
388
419
  heartbeatAt: nextHeartbeatAt,
389
- };
390
-
391
- if (released) {
392
- return;
393
- }
420
+ });
421
+ scopedLogger.debug('refreshed heartbeat for ticket %s', ticketId);
422
+ } catch (error) {
423
+ // A failed refresh is not fatal by design: the lock goes stale
424
+ // and another holder reclaims it. Swallow it so a transient
425
+ // error (e.g. the owner file racing a concurrent release, or an
426
+ // EPERM on Windows while a file is being deleted) never surfaces
427
+ // as an unhandled rejection from this interval.
428
+ scopedLogger.debug(
429
+ 'heartbeat refresh for ticket %s failed: %s',
430
+ ticketId,
431
+ error instanceof Error ? error.message : String(error),
432
+ );
433
+ }
434
+ };
394
435
 
395
- try {
396
- await writeJsonFileAtomic(paths.ownerFilePath, nextMetadata);
397
- scopedLogger.debug('refreshed heartbeat for ticket %s', ticketId);
398
- } catch (error) {
399
- // A failed refresh is not fatal by design: the lock goes stale
400
- // and another holder reclaims it. Swallow it so a transient
401
- // write error (e.g. the owner file racing a concurrent release,
402
- // or an EPERM on Windows when the directory is being torn down)
403
- // never surfaces as an unhandled rejection from this interval.
404
- scopedLogger.debug(
405
- 'heartbeat refresh for ticket %s failed: %s',
406
- ticketId,
407
- error instanceof Error ? error.message : String(error),
408
- );
409
- }
410
- } finally {
411
- heartbeatInFlight = false;
436
+ heartbeatTimer = setInterval(() => {
437
+ if (heartbeatInFlight) {
438
+ return;
412
439
  }
440
+
441
+ heartbeatInFlight = refreshHeartbeat().finally(() => {
442
+ heartbeatInFlight = null;
443
+ });
413
444
  }, heartbeatIntervalMs);
414
445
  heartbeatTimer.unref?.();
415
446
  };
package/src/run.ts CHANGED
@@ -1,4 +1,3 @@
1
- import path from 'node:path';
2
1
  import type { Config as JestConfig } from 'jest-runner';
3
2
  import { TestResult as JestTestResult } from '@jest/test-result';
4
3
  import type {
@@ -9,6 +8,7 @@ import type { HarnessSession } from './harness-session.js';
9
8
  import { formatResultsErrors } from 'jest-message-util';
10
9
  import { toTestResult } from './toTestResult.js';
11
10
  import { formatHarnessErrorMessage } from './format-harness-error.js';
11
+ import { toRelativePosixPath } from './relative-path.js';
12
12
 
13
13
  // Helper function to flatten nested test suites into a flat array of tests with hierarchy
14
14
  const flattenTests = (
@@ -91,14 +91,14 @@ export const runHarnessTestFile: RunHarnessTestFile = async ({
91
91
  session,
92
92
  }) => {
93
93
  const start = Date.now();
94
- const relativeTestPath = path.relative(globalConfig.rootDir, testPath);
94
+ const relativeTestPath = toRelativePosixPath(globalConfig.rootDir, testPath);
95
95
 
96
96
  // Extract setup files from Jest config and convert to relative paths
97
97
  const setupFiles = projectConfig.setupFiles?.map((setupFile) =>
98
- path.relative(globalConfig.rootDir, setupFile)
98
+ toRelativePosixPath(globalConfig.rootDir, setupFile)
99
99
  );
100
100
  const setupFilesAfterEnv = projectConfig.setupFilesAfterEnv?.map(
101
- (setupFile) => path.relative(globalConfig.rootDir, setupFile)
101
+ (setupFile) => toRelativePosixPath(globalConfig.rootDir, setupFile)
102
102
  );
103
103
  const testTimeout =
104
104
  session.config.testTimeout ??
@@ -0,0 +1,65 @@
1
+ import type { Config } from 'jest-runner';
2
+ import {
3
+ createHarnessSession,
4
+ type HarnessSession,
5
+ } from './harness-session.js';
6
+
7
+ // Jest constructs a new runner (and scheduler) for every watch-mode run, so a
8
+ // per-instance session would leave the previous run's session holding the
9
+ // resource lock while the next run queues behind it. Watch-mode sessions
10
+ // therefore live at module scope and are shared by all runner instances.
11
+ //
12
+ // There is no explicit teardown here: in interactive watch mode Jest exits
13
+ // via process.exit on Ctrl-C / `q`, which does not dispose the session. The
14
+ // lock is reclaimed by the lock manager's pid / stale-owner detection.
15
+ type WatchSessionEntry = {
16
+ key: string;
17
+ promise: Promise<HarnessSession>;
18
+ };
19
+
20
+ let current: WatchSessionEntry | null = null;
21
+
22
+ // Everything createHarnessSession reads from the Jest config.
23
+ const getSessionKey = (globalConfig: Config.GlobalConfig): string =>
24
+ JSON.stringify([globalConfig.rootDir, Boolean(globalConfig.collectCoverage)]);
25
+
26
+ export const getWatchSession = (
27
+ globalConfig: Config.GlobalConfig
28
+ ): Promise<HarnessSession> => {
29
+ const key = getSessionKey(globalConfig);
30
+
31
+ if (current?.key === key) {
32
+ return current.promise;
33
+ }
34
+
35
+ const stale = current;
36
+ // The entry is installed synchronously so concurrent callers share it, even
37
+ // while a stale session is still being disposed.
38
+ const entry: WatchSessionEntry = {
39
+ key,
40
+ promise: (async () => {
41
+ if (stale) {
42
+ // A failed disposal must not fail the run.
43
+ await stale.promise
44
+ .then((session) => session.dispose())
45
+ .catch(() => undefined);
46
+ }
47
+ return createHarnessSession(globalConfig);
48
+ })(),
49
+ };
50
+ current = entry;
51
+
52
+ // Never cache a failed creation; the next run retries.
53
+ entry.promise.catch(() => {
54
+ if (current === entry) {
55
+ current = null;
56
+ }
57
+ });
58
+
59
+ return entry.promise;
60
+ };
61
+
62
+ /** @internal Test-only: forget the module-level session without disposing. */
63
+ export const resetWatchSessionForTests = (): void => {
64
+ current = null;
65
+ };