@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.
- package/dist/cli-args.d.ts +3 -4
- package/dist/cli-args.d.ts.map +1 -1
- package/dist/cli-args.js +3 -8
- package/dist/diagnostics/index.d.ts +4 -1
- package/dist/diagnostics/index.d.ts.map +1 -1
- package/dist/diagnostics/index.js +2 -1
- package/dist/diagnostics/report.d.ts +81 -1
- package/dist/diagnostics/report.d.ts.map +1 -1
- package/dist/diagnostics/report.js +225 -3
- package/dist/diagnostics/telemetry.d.ts +42 -0
- package/dist/diagnostics/telemetry.d.ts.map +1 -0
- package/dist/diagnostics/telemetry.js +120 -0
- package/dist/errors.d.ts +8 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +29 -3
- package/dist/execute-run.d.ts.map +1 -1
- package/dist/execute-run.js +73 -21
- package/dist/format-harness-error.d.ts.map +1 -1
- package/dist/format-harness-error.js +3 -0
- package/dist/harness-session.d.ts +8 -1
- package/dist/harness-session.d.ts.map +1 -1
- package/dist/harness-session.js +110 -23
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -7
- package/dist/jest-preset.d.ts +3 -0
- package/dist/jest-preset.d.ts.map +1 -1
- package/dist/jest-preset.js +18 -0
- package/dist/relative-path.d.ts +8 -0
- package/dist/relative-path.d.ts.map +1 -0
- package/dist/relative-path.js +8 -0
- package/dist/resource-lock.d.ts.map +1 -1
- package/dist/resource-lock.js +47 -27
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +4 -4
- package/dist/watch-session.d.ts +6 -0
- package/dist/watch-session.d.ts.map +1 -0
- package/dist/watch-session.js +37 -0
- package/package.json +8 -8
- package/src/cli-args.ts +5 -13
- package/src/diagnostics/index.ts +12 -1
- package/src/diagnostics/report.ts +326 -5
- package/src/diagnostics/telemetry.ts +186 -0
- package/src/errors.ts +42 -5
- package/src/execute-run.ts +93 -23
- package/src/format-harness-error.ts +5 -0
- package/src/harness-session.ts +143 -22
- package/src/index.ts +8 -7
- package/src/jest-preset.ts +18 -0
- package/src/relative-path.ts +10 -0
- package/src/resource-lock.ts +64 -33
- package/src/run.ts +4 -4
- package/src/watch-session.ts +65 -0
package/src/resource-lock.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
417
|
+
await writeJsonFileAtomic(paths.ownerFilePath, {
|
|
387
418
|
...owner,
|
|
388
419
|
heartbeatAt: nextHeartbeatAt,
|
|
389
|
-
};
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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 =
|
|
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
|
-
|
|
98
|
+
toRelativePosixPath(globalConfig.rootDir, setupFile)
|
|
99
99
|
);
|
|
100
100
|
const setupFilesAfterEnv = projectConfig.setupFilesAfterEnv?.map(
|
|
101
|
-
(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
|
+
};
|