@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/execute-run.ts
CHANGED
|
@@ -5,11 +5,12 @@ import type {
|
|
|
5
5
|
TestEvents,
|
|
6
6
|
} from 'jest-runner';
|
|
7
7
|
import { randomUUID } from 'node:crypto';
|
|
8
|
-
import path from 'node:path';
|
|
9
8
|
import { type HarnessSession, type HarnessRunState } from './harness-session.js';
|
|
10
9
|
import { runHarnessTestFile } from './run.js';
|
|
10
|
+
import { toRelativePosixPath } from './relative-path.js';
|
|
11
11
|
import { resolveResetStrategyKind } from './environment-reset.js';
|
|
12
12
|
import {
|
|
13
|
+
HostSurfaceReplacedError,
|
|
13
14
|
NativeCrashError,
|
|
14
15
|
RuntimeDisconnectError,
|
|
15
16
|
StartupStallError,
|
|
@@ -31,7 +32,13 @@ import {
|
|
|
31
32
|
} from './test-file-platform-filter.js';
|
|
32
33
|
import { formatHarnessErrorMessage } from './format-harness-error.js';
|
|
33
34
|
import { logger } from '@react-native-harness/tools';
|
|
34
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
appendStepSummary,
|
|
37
|
+
printSummary,
|
|
38
|
+
summarizeRunKpis,
|
|
39
|
+
writeSummaryFile,
|
|
40
|
+
writeTraceFile,
|
|
41
|
+
} from './diagnostics/index.js';
|
|
35
42
|
import { toJestStatus } from './toJestStatus.js';
|
|
36
43
|
|
|
37
44
|
const diagnosticsLogger = logger.child('diagnostics');
|
|
@@ -60,15 +67,30 @@ class CancelRun extends Error {
|
|
|
60
67
|
}
|
|
61
68
|
}
|
|
62
69
|
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
const isRuntimeFailure = (err: unknown): err is Error =>
|
|
71
|
+
err instanceof NativeCrashError ||
|
|
72
|
+
err instanceof RuntimeDisconnectError ||
|
|
73
|
+
err instanceof StartupStallError ||
|
|
74
|
+
err instanceof AppBridgeDisconnectedError ||
|
|
75
|
+
err instanceof DeviceNotRespondingError;
|
|
76
|
+
|
|
77
|
+
const formatRunningTest = (test: TestRunnerTestStartedEvent): string =>
|
|
78
|
+
`Running test: "${[...test.ancestorTitles, test.name].join(' › ')}"`;
|
|
79
|
+
|
|
80
|
+
const buildTestFailure = (
|
|
81
|
+
err: unknown,
|
|
82
|
+
runningTest: TestRunnerTestStartedEvent | null = null,
|
|
83
|
+
): { message: string; stack: string } => {
|
|
84
|
+
if (isRuntimeFailure(err)) {
|
|
85
|
+
if (!runningTest) {
|
|
86
|
+
return { message: err.message, stack: '' };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const [headline, ...details] = err.message.split('\n');
|
|
90
|
+
return {
|
|
91
|
+
message: [headline, formatRunningTest(runningTest), ...details].join('\n'),
|
|
92
|
+
stack: '',
|
|
93
|
+
};
|
|
72
94
|
}
|
|
73
95
|
return err as { message: string; stack: string };
|
|
74
96
|
};
|
|
@@ -147,10 +169,19 @@ export const executeRun = async (
|
|
|
147
169
|
const startTime = Date.now();
|
|
148
170
|
const watchMode = globalConfig.watch || globalConfig.watchAll;
|
|
149
171
|
const rootDir = globalConfig.rootDir ?? process.cwd();
|
|
150
|
-
const testFiles = tests.map((t) =>
|
|
172
|
+
const testFiles = tests.map((t) => toRelativePosixPath(rootDir, t.path));
|
|
151
173
|
const summary = createRunSummary();
|
|
152
174
|
let caseEventChain = Promise.resolve();
|
|
175
|
+
// The device runs one test at a time, so a test that started and has not
|
|
176
|
+
// finished is the one the app was running if it crashes or disconnects.
|
|
177
|
+
let runningTest: TestRunnerTestStartedEvent | null = null;
|
|
153
178
|
const unsubscribe = session.onTestRunnerEvent((event) => {
|
|
179
|
+
if (event.type === 'test-started') {
|
|
180
|
+
runningTest = event;
|
|
181
|
+
} else if (event.type === 'test-finished') {
|
|
182
|
+
runningTest = null;
|
|
183
|
+
}
|
|
184
|
+
|
|
154
185
|
if (isHarnessCaseEvent(event)) {
|
|
155
186
|
caseEventChain = caseEventChain.then(() =>
|
|
156
187
|
event.type === 'test-started'
|
|
@@ -177,6 +208,11 @@ export const executeRun = async (
|
|
|
177
208
|
};
|
|
178
209
|
|
|
179
210
|
updateRunState();
|
|
211
|
+
session.lifecycle.emit({
|
|
212
|
+
type: 'phase',
|
|
213
|
+
phase: 'run.start',
|
|
214
|
+
attrs: { runId },
|
|
215
|
+
});
|
|
180
216
|
|
|
181
217
|
await session.callHook('run:started', {
|
|
182
218
|
runId,
|
|
@@ -206,8 +242,9 @@ export const executeRun = async (
|
|
|
206
242
|
for (const test of tests) {
|
|
207
243
|
if (watcher.isInterrupted()) throw new CancelRun();
|
|
208
244
|
|
|
209
|
-
const relativeTestPath =
|
|
245
|
+
const relativeTestPath = toRelativePosixPath(rootDir, test.path);
|
|
210
246
|
const fileStartedAt = Date.now();
|
|
247
|
+
runningTest = null;
|
|
211
248
|
let emittedTestFileFinished = false;
|
|
212
249
|
|
|
213
250
|
const emitTestFileFinished = async (options: {
|
|
@@ -227,6 +264,11 @@ export const executeRun = async (
|
|
|
227
264
|
|
|
228
265
|
await session.callHook('test-file:started', { runId, file: relativeTestPath });
|
|
229
266
|
const fileSpan = diagnostics.start('run.file', { file: relativeTestPath });
|
|
267
|
+
session.lifecycle.emit({
|
|
268
|
+
type: 'phase',
|
|
269
|
+
phase: 'test-file.start',
|
|
270
|
+
attrs: { file: relativeTestPath },
|
|
271
|
+
});
|
|
230
272
|
let fileStatus: 'passed' | 'failed' | 'skipped' | 'todo' = 'failed';
|
|
231
273
|
|
|
232
274
|
try {
|
|
@@ -304,7 +346,11 @@ export const executeRun = async (
|
|
|
304
346
|
await session.restartApp(test.path);
|
|
305
347
|
shouldRestartAfterTimeout = false;
|
|
306
348
|
}
|
|
307
|
-
} catch (
|
|
349
|
+
} catch (error) {
|
|
350
|
+
const err =
|
|
351
|
+
error instanceof Error && error.name === 'HostSurfaceReplacedError'
|
|
352
|
+
? new HostSurfaceReplacedError(test.path, error)
|
|
353
|
+
: error;
|
|
308
354
|
fileStatus = 'failed';
|
|
309
355
|
if (!emittedTestFileFinished) {
|
|
310
356
|
await emitTestFileFinished({
|
|
@@ -314,14 +360,10 @@ export const executeRun = async (
|
|
|
314
360
|
});
|
|
315
361
|
}
|
|
316
362
|
|
|
317
|
-
const
|
|
318
|
-
err instanceof
|
|
319
|
-
err instanceof RuntimeDisconnectError ||
|
|
320
|
-
err instanceof StartupStallError ||
|
|
321
|
-
err instanceof AppBridgeDisconnectedError ||
|
|
322
|
-
err instanceof DeviceNotRespondingError;
|
|
363
|
+
const isFileRuntimeFailure =
|
|
364
|
+
err instanceof HostSurfaceReplacedError || isRuntimeFailure(err);
|
|
323
365
|
|
|
324
|
-
if (
|
|
366
|
+
if (isFileRuntimeFailure) {
|
|
325
367
|
summary.failed += 1;
|
|
326
368
|
updateRunState();
|
|
327
369
|
}
|
|
@@ -330,12 +372,26 @@ export const executeRun = async (
|
|
|
330
372
|
session.resetCrashState();
|
|
331
373
|
}
|
|
332
374
|
|
|
333
|
-
updateRunState({ error:
|
|
375
|
+
updateRunState({ error: isFileRuntimeFailure ? undefined : err });
|
|
334
376
|
await caseEventChain;
|
|
335
|
-
await emitEvent(
|
|
377
|
+
await emitEvent(
|
|
378
|
+
'test-file-failure',
|
|
379
|
+
test,
|
|
380
|
+
buildTestFailure(err, runningTest),
|
|
381
|
+
);
|
|
382
|
+
if (err instanceof HostSurfaceReplacedError) {
|
|
383
|
+
// Abandoned async test work may still be running in this process.
|
|
384
|
+
await session.restartApp(test.path);
|
|
385
|
+
isFirstTest = true;
|
|
386
|
+
}
|
|
336
387
|
}
|
|
337
388
|
} finally {
|
|
338
389
|
fileSpan.end({ status: fileStatus });
|
|
390
|
+
session.lifecycle.emit({
|
|
391
|
+
type: 'phase',
|
|
392
|
+
phase: 'test-file.end',
|
|
393
|
+
attrs: { file: relativeTestPath, status: fileStatus },
|
|
394
|
+
});
|
|
339
395
|
}
|
|
340
396
|
}
|
|
341
397
|
} catch (err) {
|
|
@@ -358,6 +414,12 @@ export const executeRun = async (
|
|
|
358
414
|
await caseEventChain;
|
|
359
415
|
unsubscribe();
|
|
360
416
|
runSpan.end({ runId, status: finalStatus });
|
|
417
|
+
// Idle time between runs (watch mode) is not part of any run's metrics.
|
|
418
|
+
session.lifecycle.emit({
|
|
419
|
+
type: 'phase',
|
|
420
|
+
phase: 'run.end',
|
|
421
|
+
attrs: { runId, status: finalStatus },
|
|
422
|
+
});
|
|
361
423
|
|
|
362
424
|
if (session.diagnostics.enabled) {
|
|
363
425
|
const entries = session.diagnostics.flush();
|
|
@@ -365,6 +427,14 @@ export const executeRun = async (
|
|
|
365
427
|
printSummary(entries, (line) => diagnosticsLogger.info(line));
|
|
366
428
|
const tracePath = await writeTraceFile(entries, { projectRoot: rootDir, runId });
|
|
367
429
|
diagnosticsLogger.info(`wrote diagnostics trace to ${tracePath}`);
|
|
430
|
+
const kpis = summarizeRunKpis(entries);
|
|
431
|
+
const summaryPath = await writeSummaryFile(kpis, {
|
|
432
|
+
projectRoot: rootDir,
|
|
433
|
+
runId,
|
|
434
|
+
sessionId: session.sessionId,
|
|
435
|
+
});
|
|
436
|
+
diagnosticsLogger.info(`wrote diagnostics summary to ${summaryPath}`);
|
|
437
|
+
await appendStepSummary(kpis);
|
|
368
438
|
} catch (reportError) {
|
|
369
439
|
diagnosticsLogger.warn('failed to write diagnostics report: %s', reportError);
|
|
370
440
|
}
|
|
@@ -54,6 +54,11 @@ export const formatHarnessErrorMessage = (
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
const parts = [error.message];
|
|
57
|
+
|
|
58
|
+
if (error.diff) {
|
|
59
|
+
parts.push(error.diff);
|
|
60
|
+
}
|
|
61
|
+
|
|
57
62
|
const pendingPromiseDetails = error.diagnostics?.pendingPromises
|
|
58
63
|
? formatPendingPromises(
|
|
59
64
|
error.diagnostics.pendingPromises,
|
package/src/harness-session.ts
CHANGED
|
@@ -43,7 +43,13 @@ import {
|
|
|
43
43
|
getTimeoutSignal,
|
|
44
44
|
ensureSignalsDeliverable,
|
|
45
45
|
waitForAbort,
|
|
46
|
+
checkHostHealth,
|
|
47
|
+
formatGitHubWarning,
|
|
48
|
+
formatSaturatedHostMessage,
|
|
49
|
+
isCI,
|
|
50
|
+
readProcessUsage,
|
|
46
51
|
type Diagnostics,
|
|
52
|
+
type HostPressure,
|
|
47
53
|
} from '@react-native-harness/tools';
|
|
48
54
|
import {
|
|
49
55
|
getConfig,
|
|
@@ -75,7 +81,12 @@ import {
|
|
|
75
81
|
type ResourceLockManager,
|
|
76
82
|
type ResourceLease,
|
|
77
83
|
} from './resource-lock.js';
|
|
78
|
-
import {
|
|
84
|
+
import {
|
|
85
|
+
observeBridgeEvents,
|
|
86
|
+
observeMetroEvents,
|
|
87
|
+
startSessionTelemetry,
|
|
88
|
+
type SessionTelemetry,
|
|
89
|
+
} from './diagnostics/index.js';
|
|
79
90
|
import { resolveHarnessMetroPort } from './metro-port.js';
|
|
80
91
|
import { getAdditionalCliArgs } from './cli-args.js';
|
|
81
92
|
import {
|
|
@@ -96,6 +107,8 @@ const isBridgeDisconnectError = (error: unknown) =>
|
|
|
96
107
|
error instanceof Error && error.message === 'App bridge disconnected';
|
|
97
108
|
const TEST_RUN_BRIDGE_STABILITY_WAIT_MS = 500;
|
|
98
109
|
const DISPOSE_HOOK_TIMEOUT_MS = 5_000;
|
|
110
|
+
// Upper bound on waiting for end-of-run telemetry before tearing resources down.
|
|
111
|
+
const DISPOSE_TELEMETRY_TIMEOUT_MS = 10_000;
|
|
99
112
|
|
|
100
113
|
const waitForBoundedSettlement = async <T>(
|
|
101
114
|
promise: Promise<T>,
|
|
@@ -194,6 +207,10 @@ export type HarnessSession = {
|
|
|
194
207
|
readonly config: HarnessConfig;
|
|
195
208
|
readonly context: HarnessContext;
|
|
196
209
|
readonly diagnostics: Diagnostics;
|
|
210
|
+
/** Identifies this session; names its incremental diagnostics log. */
|
|
211
|
+
readonly sessionId: string;
|
|
212
|
+
/** Run lifecycle phases, for telemetry probes (see `SessionTelemetry`). */
|
|
213
|
+
readonly lifecycle: SessionTelemetry['lifecycle'];
|
|
197
214
|
onTestRunnerEvent: (listener: (event: TestRunnerEvents) => void) => () => void;
|
|
198
215
|
runTestFile: (path: string, options: HarnessRunTestsOptions) => Promise<TestSuiteResult>;
|
|
199
216
|
ensureAppReady: (testFilePath: string) => Promise<void>;
|
|
@@ -235,6 +252,8 @@ export const withPlatformReadyTimeout = async <T>(options: {
|
|
|
235
252
|
timeout: number;
|
|
236
253
|
signal: AbortSignal;
|
|
237
254
|
work: (signal: AbortSignal) => Promise<T>;
|
|
255
|
+
/** Host pressure at the moment of the timeout, attached to the error. */
|
|
256
|
+
getHostPressure?: () => HostPressure | null;
|
|
238
257
|
}): Promise<T> => {
|
|
239
258
|
const timeoutSignal = getTimeoutSignal(options.timeout);
|
|
240
259
|
const abortWait = waitForAbort(timeoutSignal);
|
|
@@ -242,7 +261,10 @@ export const withPlatformReadyTimeout = async <T>(options: {
|
|
|
242
261
|
return await Promise.race([
|
|
243
262
|
options.work(options.signal),
|
|
244
263
|
abortWait.promise.catch(() => {
|
|
245
|
-
throw new PlatformReadyTimeoutError(
|
|
264
|
+
throw new PlatformReadyTimeoutError(
|
|
265
|
+
options.timeout,
|
|
266
|
+
options.getHostPressure?.() ?? null
|
|
267
|
+
);
|
|
246
268
|
}),
|
|
247
269
|
]);
|
|
248
270
|
} finally {
|
|
@@ -250,6 +272,39 @@ export const withPlatformReadyTimeout = async <T>(options: {
|
|
|
250
272
|
}
|
|
251
273
|
};
|
|
252
274
|
|
|
275
|
+
// A saturated host is exactly where `ps` is slow; it took over 10s on CI.
|
|
276
|
+
const HOST_HEALTH_PROCESS_LISTING_TIMEOUT_MS = 30_000;
|
|
277
|
+
|
|
278
|
+
// Tells, before anything is booted, whether the host is already saturated by
|
|
279
|
+
// something other than this run. Only a warning: a saturated runner can still
|
|
280
|
+
// pass, but a later startup timeout then has an explanation.
|
|
281
|
+
const reportSaturatedHost = async (diagnostics: Diagnostics): Promise<void> => {
|
|
282
|
+
try {
|
|
283
|
+
const verdict = await checkHostHealth();
|
|
284
|
+
diagnostics.mark('telemetry.host_health', {
|
|
285
|
+
load1: verdict.load1,
|
|
286
|
+
cpuCount: verdict.cpuCount,
|
|
287
|
+
idlePct: verdict.idlePct ?? undefined,
|
|
288
|
+
saturated: verdict.saturated,
|
|
289
|
+
});
|
|
290
|
+
if (!verdict.saturated) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const processes = await readProcessUsage(
|
|
295
|
+
AbortSignal.timeout(HOST_HEALTH_PROCESS_LISTING_TIMEOUT_MS)
|
|
296
|
+
).catch(() => null);
|
|
297
|
+
const message = formatSaturatedHostMessage(verdict, processes?.topByCpu);
|
|
298
|
+
sessionLogger.warn(message);
|
|
299
|
+
const annotation = formatGitHubWarning('Saturated CI runner', message);
|
|
300
|
+
if (annotation) {
|
|
301
|
+
process.stdout.write(`${annotation}\n`);
|
|
302
|
+
}
|
|
303
|
+
} catch (error) {
|
|
304
|
+
sessionLogger.debug('host health check failed: %s', error);
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
|
|
253
308
|
type AppReadyOptions = {
|
|
254
309
|
metroInstance: MetroInstance;
|
|
255
310
|
bridge: HarnessBridge;
|
|
@@ -484,6 +539,9 @@ export const createHarnessSession = async (
|
|
|
484
539
|
|
|
485
540
|
const setupStartedAt = Date.now();
|
|
486
541
|
const { harnessConfig, platform, projectRoot, configProjectRoot } = await loadConfig(globalConfig);
|
|
542
|
+
// Metro platform for prewarm and startup detection. Vega apps request
|
|
543
|
+
// "kepler" bundles while the harness platform id stays "vega".
|
|
544
|
+
const bundlePlatform = platform.bundlePlatform ?? platform.platformId;
|
|
487
545
|
applyEnvVars(harnessConfig, globalConfig);
|
|
488
546
|
|
|
489
547
|
const diagnostics = createDiagnostics({
|
|
@@ -494,6 +552,25 @@ export const createHarnessSession = async (
|
|
|
494
552
|
duration: Date.now() - setupStartedAt,
|
|
495
553
|
});
|
|
496
554
|
|
|
555
|
+
// The one composition point for run telemetry. Everything below only emits
|
|
556
|
+
// lifecycle phases; probes translate them and polled data into diagnostics.
|
|
557
|
+
const telemetry = startSessionTelemetry({
|
|
558
|
+
diagnostics,
|
|
559
|
+
config: harnessConfig,
|
|
560
|
+
projectRoot,
|
|
561
|
+
});
|
|
562
|
+
telemetry.phase('session.created');
|
|
563
|
+
if (isCI()) {
|
|
564
|
+
void reportSaturatedHost(diagnostics);
|
|
565
|
+
}
|
|
566
|
+
// Telemetry runs timers and listens on `exit`, so a setup failure before the
|
|
567
|
+
// main try/catch below must still stop it.
|
|
568
|
+
const disposeTelemetryOnFailure = <T>(work: Promise<T>): Promise<T> =>
|
|
569
|
+
work.catch(async (error) => {
|
|
570
|
+
await telemetry.dispose();
|
|
571
|
+
throw error;
|
|
572
|
+
});
|
|
573
|
+
|
|
497
574
|
sessionLogger.debug(
|
|
498
575
|
'creating session for runner=%s platform=%s',
|
|
499
576
|
platform.name,
|
|
@@ -515,13 +592,15 @@ export const createHarnessSession = async (
|
|
|
515
592
|
process.once('SIGTERM', onEarlySignal);
|
|
516
593
|
process.once('SIGINT', onEarlySignal);
|
|
517
594
|
|
|
518
|
-
const resourceLockKey = await (
|
|
595
|
+
const resourceLockKey = await disposeTelemetryOnFailure(
|
|
596
|
+
Promise.resolve(platform.getResourceLockKey?.() ?? getDefaultResourceLockKey(platform))
|
|
597
|
+
);
|
|
519
598
|
let didWaitForResourceLock = false;
|
|
520
599
|
let lastStillWaitingLogAt = 0;
|
|
521
600
|
|
|
522
601
|
logTestRunHeader(platform);
|
|
523
602
|
|
|
524
|
-
const resourceLease = await diagnostics.measure(
|
|
603
|
+
const resourceLease = await disposeTelemetryOnFailure(diagnostics.measure(
|
|
525
604
|
'session.lock.wait',
|
|
526
605
|
() =>
|
|
527
606
|
lockManager.acquire(resourceLockKey, {
|
|
@@ -539,7 +618,7 @@ export const createHarnessSession = async (
|
|
|
539
618
|
},
|
|
540
619
|
}),
|
|
541
620
|
{ lockKey: resourceLockKey }
|
|
542
|
-
);
|
|
621
|
+
));
|
|
543
622
|
|
|
544
623
|
if (didWaitForResourceLock) logRunnerStarting(platform);
|
|
545
624
|
sessionLogger.debug('resource lock acquired for runner=%s key=%s', platform.name, resourceLockKey);
|
|
@@ -590,7 +669,10 @@ export const createHarnessSession = async (
|
|
|
590
669
|
const getCurrentRunId = () => currentRun?.runId;
|
|
591
670
|
const clientLogCollector = createClientLogCollector();
|
|
592
671
|
|
|
593
|
-
const context
|
|
672
|
+
const context = {
|
|
673
|
+
platform,
|
|
674
|
+
updateSnapshot: globalConfig.updateSnapshot,
|
|
675
|
+
} satisfies Required<HarnessContext>;
|
|
594
676
|
const crashArtifactWriter = createCrashArtifactWriter({
|
|
595
677
|
runnerName: platform.name,
|
|
596
678
|
platformId: platform.platformId,
|
|
@@ -602,6 +684,11 @@ export const createHarnessSession = async (
|
|
|
602
684
|
noServer: true,
|
|
603
685
|
timeout: runtimeConfig.bridgeTimeout,
|
|
604
686
|
context,
|
|
687
|
+
liveness: {
|
|
688
|
+
// Loading a test file runs user code that has no timeout of its
|
|
689
|
+
// own, so it gets the same allowance as starting the bundle.
|
|
690
|
+
jsBlockBudget: { fileLoadMs: runtimeConfig.bundleStartTimeout },
|
|
691
|
+
},
|
|
605
692
|
})
|
|
606
693
|
);
|
|
607
694
|
sessionLogger.debug('bridge initialized on Metro websocket path %s', HARNESS_BRIDGE_PATH);
|
|
@@ -616,6 +703,29 @@ export const createHarnessSession = async (
|
|
|
616
703
|
// Noop until the Metro instance resolves and the deriver is attached.
|
|
617
704
|
let disposeMetroDiagnostics: () => void = () => undefined;
|
|
618
705
|
|
|
706
|
+
const eagerPrewarm = runtimeConfig.eagerPrewarm !== false;
|
|
707
|
+
// On small hosts the bundle build and the platform boot compete for the
|
|
708
|
+
// same few cores, so the platform may ask to boot first.
|
|
709
|
+
const deferPrewarm = eagerPrewarm && (platform.deferMetroPrewarm?.() ?? false);
|
|
710
|
+
if (deferPrewarm) {
|
|
711
|
+
sessionLogger.debug('deferring Metro prewarm until the platform is ready');
|
|
712
|
+
}
|
|
713
|
+
const startPrewarm = (instance: MetroInstance) => {
|
|
714
|
+
// prewarm() memoizes this first call, so its session-lifetime setup
|
|
715
|
+
// signal is the one that matters — later callers' signals cannot
|
|
716
|
+
// cancel it. Not awaited: callers must not wait for the bundle.
|
|
717
|
+
const prewarmSpan = diagnostics.start('metro.prewarm', { deferred: deferPrewarm });
|
|
718
|
+
instance
|
|
719
|
+
.prewarm({
|
|
720
|
+
platform: bundlePlatform,
|
|
721
|
+
signal: sessionController.signal,
|
|
722
|
+
})
|
|
723
|
+
.then((completed) => prewarmSpan.end({ completed }))
|
|
724
|
+
// Swallow AbortError on teardown so it never becomes an
|
|
725
|
+
// unhandled rejection; non-abort failures resolve false.
|
|
726
|
+
.catch((error) => prewarmSpan.fail(error));
|
|
727
|
+
};
|
|
728
|
+
|
|
619
729
|
try {
|
|
620
730
|
[metroInstance, platformInstance] = await Promise.all([
|
|
621
731
|
diagnostics.measure('metro.init', () =>
|
|
@@ -638,24 +748,13 @@ export const createHarnessSession = async (
|
|
|
638
748
|
}
|
|
639
749
|
initialized.metro = instance;
|
|
640
750
|
sessionLogger.debug('Metro initialized');
|
|
751
|
+
telemetry.phase('metro.ready');
|
|
641
752
|
// Attach the Metro diagnostics deriver before the eager prewarm
|
|
642
753
|
// fires so its bundle build/request events land in the trace.
|
|
643
754
|
disposeMetroDiagnostics = observeMetroEvents(instance.events, diagnostics);
|
|
644
|
-
if (
|
|
755
|
+
if (eagerPrewarm && !deferPrewarm) {
|
|
645
756
|
// Kick off the first bundle build while the platform is still booting.
|
|
646
|
-
|
|
647
|
-
// signal is the one that matters — later callers' signals cannot
|
|
648
|
-
// cancel it. Not awaited: metro.init must resolve immediately.
|
|
649
|
-
const prewarmSpan = diagnostics.start('metro.prewarm');
|
|
650
|
-
instance
|
|
651
|
-
.prewarm({
|
|
652
|
-
platform: platform.platformId,
|
|
653
|
-
signal: sessionController.signal,
|
|
654
|
-
})
|
|
655
|
-
.then((completed) => prewarmSpan.end({ completed }))
|
|
656
|
-
// Swallow AbortError on teardown so it never becomes an
|
|
657
|
-
// unhandled rejection; non-abort failures resolve false.
|
|
658
|
-
.catch((error) => prewarmSpan.fail(error));
|
|
757
|
+
startPrewarm(instance);
|
|
659
758
|
}
|
|
660
759
|
return instance;
|
|
661
760
|
})
|
|
@@ -666,6 +765,7 @@ export const createHarnessSession = async (
|
|
|
666
765
|
withPlatformReadyTimeout({
|
|
667
766
|
timeout: runtimeConfig.platformReadyTimeout,
|
|
668
767
|
signal: sessionController.signal,
|
|
768
|
+
getHostPressure: telemetry.hostPressure,
|
|
669
769
|
work: async (signal) => {
|
|
670
770
|
return await import(platform.runner).then((module) =>
|
|
671
771
|
module.default(platform.config, runtimeConfig, {
|
|
@@ -679,6 +779,8 @@ export const createHarnessSession = async (
|
|
|
679
779
|
}
|
|
680
780
|
initialized.platform = instance;
|
|
681
781
|
sessionLogger.debug('platform runner initialized');
|
|
782
|
+
telemetry.attachPlatform(instance);
|
|
783
|
+
telemetry.phase('platform.ready');
|
|
682
784
|
return instance;
|
|
683
785
|
});
|
|
684
786
|
},
|
|
@@ -700,6 +802,10 @@ export const createHarnessSession = async (
|
|
|
700
802
|
throw error;
|
|
701
803
|
}
|
|
702
804
|
|
|
805
|
+
if (deferPrewarm) {
|
|
806
|
+
startPrewarm(metroInstance);
|
|
807
|
+
}
|
|
808
|
+
|
|
703
809
|
const appLaunchOptions = (platform.config as { appLaunchOptions?: AppLaunchOptions }).appLaunchOptions;
|
|
704
810
|
|
|
705
811
|
let currentAppSession: AppSession | null = null;
|
|
@@ -731,7 +837,7 @@ export const createHarnessSession = async (
|
|
|
731
837
|
const appReadyBaseOptions: AppReadyOptions = {
|
|
732
838
|
metroInstance,
|
|
733
839
|
bridge,
|
|
734
|
-
platformId:
|
|
840
|
+
platformId: bundlePlatform,
|
|
735
841
|
bundleStartTimeout: runtimeConfig.bundleStartTimeout ?? 60000,
|
|
736
842
|
readyTimeout: runtimeConfig.bridgeTimeout,
|
|
737
843
|
maxAppRestarts: runtimeConfig.maxAppRestarts ?? 2,
|
|
@@ -808,6 +914,7 @@ export const createHarnessSession = async (
|
|
|
808
914
|
// Abort only cancels finite/in-flight work. Ownership remains explicit
|
|
809
915
|
// below, so this cannot race a platform or app-session disposal path.
|
|
810
916
|
sessionController.abort();
|
|
917
|
+
telemetry.phase('dispose');
|
|
811
918
|
|
|
812
919
|
const hookWork = (async () => {
|
|
813
920
|
await hooks.drain();
|
|
@@ -840,6 +947,13 @@ export const createHarnessSession = async (
|
|
|
840
947
|
hookError = hookResult.reason;
|
|
841
948
|
}
|
|
842
949
|
|
|
950
|
+
// End-of-run telemetry has to observe the platform before it is torn
|
|
951
|
+
// down, but must never be able to hold teardown up for long.
|
|
952
|
+
await waitForBoundedSettlement(
|
|
953
|
+
telemetry.settled(),
|
|
954
|
+
DISPOSE_TELEMETRY_TIMEOUT_MS
|
|
955
|
+
);
|
|
956
|
+
|
|
843
957
|
if (runtimeConfig.forwardClientLogs) {
|
|
844
958
|
metroInstance.events.removeListener(clientLogListener);
|
|
845
959
|
}
|
|
@@ -900,7 +1014,7 @@ export const createHarnessSession = async (
|
|
|
900
1014
|
};
|
|
901
1015
|
|
|
902
1016
|
const dispose = (reason: 'normal' | 'abort' | 'error' = 'normal') => {
|
|
903
|
-
disposePromise ??= disposeOnce(reason);
|
|
1017
|
+
disposePromise ??= disposeOnce(reason).finally(() => telemetry.dispose());
|
|
904
1018
|
return disposePromise;
|
|
905
1019
|
};
|
|
906
1020
|
|
|
@@ -966,6 +1080,7 @@ export const createHarnessSession = async (
|
|
|
966
1080
|
await hooks.drain();
|
|
967
1081
|
sessionLogger.debug('app is ready for %s', testFilePath);
|
|
968
1082
|
appReadySpan.end({ file: testFilePath, reused: false });
|
|
1083
|
+
telemetry.phase('app.ready', { file: testFilePath });
|
|
969
1084
|
};
|
|
970
1085
|
|
|
971
1086
|
// Shared by restartApp (crash/timeout recovery) and the 'process' reset
|
|
@@ -1118,6 +1233,8 @@ export const createHarnessSession = async (
|
|
|
1118
1233
|
config: runtimeConfig,
|
|
1119
1234
|
context,
|
|
1120
1235
|
diagnostics,
|
|
1236
|
+
sessionId: telemetry.sessionId,
|
|
1237
|
+
lifecycle: telemetry.lifecycle,
|
|
1121
1238
|
onTestRunnerEvent: (listener) => {
|
|
1122
1239
|
testRunnerEventListeners.add(listener);
|
|
1123
1240
|
return () => {
|
|
@@ -1141,6 +1258,9 @@ export const createHarnessSession = async (
|
|
|
1141
1258
|
},
|
|
1142
1259
|
setRunState: (state) => {
|
|
1143
1260
|
currentRun = state;
|
|
1261
|
+
if (state) {
|
|
1262
|
+
telemetry.trackRun(state.runId);
|
|
1263
|
+
}
|
|
1144
1264
|
},
|
|
1145
1265
|
dispose: async (reason = 'normal') => {
|
|
1146
1266
|
try {
|
|
@@ -1157,6 +1277,7 @@ export const createHarnessSession = async (
|
|
|
1157
1277
|
// Cancels any inflight init work still holding sessionController.signal
|
|
1158
1278
|
// (e.g. a platform init call abandoned after a readiness timeout race).
|
|
1159
1279
|
sessionController.abort();
|
|
1280
|
+
await telemetry.dispose();
|
|
1160
1281
|
await Promise.allSettled([resourceLease.release(), metroPortLease?.release()]);
|
|
1161
1282
|
if (receivedEarlySignal) {
|
|
1162
1283
|
process.exit(1);
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
TestWatcher,
|
|
8
8
|
} from 'jest-runner';
|
|
9
9
|
import { createHarnessSession, type HarnessSession } from './harness-session.js';
|
|
10
|
+
import { getWatchSession } from './watch-session.js';
|
|
10
11
|
import { executeRun } from './execute-run.js';
|
|
11
12
|
import { HarnessError } from '@react-native-harness/tools';
|
|
12
13
|
import { getErrorMessage } from './logs.js';
|
|
@@ -20,7 +21,6 @@ export default class JestHarness implements EmittingTestRunnerInterface {
|
|
|
20
21
|
readonly supportsEventEmitters = true as const;
|
|
21
22
|
|
|
22
23
|
#globalConfig: Config.GlobalConfig;
|
|
23
|
-
#session: HarnessSession | null = null;
|
|
24
24
|
#listeners = new Map<keyof TestEvents, Set<TestEventListener<keyof TestEvents>>>();
|
|
25
25
|
|
|
26
26
|
constructor(globalConfig: Config.GlobalConfig) {
|
|
@@ -54,13 +54,15 @@ export default class JestHarness implements EmittingTestRunnerInterface {
|
|
|
54
54
|
|
|
55
55
|
const isWatchMode = this.#globalConfig.watch || this.#globalConfig.watchAll;
|
|
56
56
|
|
|
57
|
+
let session: HarnessSession | null = null;
|
|
58
|
+
|
|
57
59
|
try {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
session = isWatchMode
|
|
61
|
+
? await getWatchSession(this.#globalConfig)
|
|
62
|
+
: await createHarnessSession(this.#globalConfig);
|
|
61
63
|
|
|
62
64
|
await executeRun(
|
|
63
|
-
|
|
65
|
+
session,
|
|
64
66
|
tests,
|
|
65
67
|
watcher,
|
|
66
68
|
this.#emit,
|
|
@@ -73,8 +75,7 @@ export default class JestHarness implements EmittingTestRunnerInterface {
|
|
|
73
75
|
throw error;
|
|
74
76
|
} finally {
|
|
75
77
|
if (!isWatchMode) {
|
|
76
|
-
await
|
|
77
|
-
this.#session = null;
|
|
78
|
+
await session?.dispose();
|
|
78
79
|
}
|
|
79
80
|
}
|
|
80
81
|
}
|
package/src/jest-preset.ts
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
1
|
export default {
|
|
2
2
|
runner: '@react-native-harness/jest',
|
|
3
|
+
// Tests run serially on the device, so Jest only uses its workers to build
|
|
4
|
+
// the haste map. On a cold cache or after any file change that starts a pool
|
|
5
|
+
// of `availableParallelism() - 1` processes, which roughly doubles Jest's
|
|
6
|
+
// CPU time without making the run faster.
|
|
7
|
+
maxWorkers: 1,
|
|
8
|
+
// Build and cache output never contains tests, but it can hold tens of
|
|
9
|
+
// thousands of files. Jest checks these patterns before descending into a
|
|
10
|
+
// directory, so its crawl skips them entirely. Jest appends a project's own
|
|
11
|
+
// patterns to the preset's.
|
|
12
|
+
modulePathIgnorePatterns: [
|
|
13
|
+
'/\\.harness(/|$)',
|
|
14
|
+
'/ios/(Pods|build)(/|$)',
|
|
15
|
+
'/android/(build|\\.gradle|app/build|app/\\.cxx)(/|$)',
|
|
16
|
+
],
|
|
17
|
+
// With the build output skipped, Jest's own crawl is cheaper than querying
|
|
18
|
+
// watchman, which costs two CLI process spawns per run and still lists every
|
|
19
|
+
// file. Metro keeps using watchman.
|
|
20
|
+
watchman: false,
|
|
3
21
|
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns `filePath` relative to `rootDir`, always using `/` as the separator.
|
|
5
|
+
*
|
|
6
|
+
* These paths leave the host (they are sent to the device and used in Metro
|
|
7
|
+
* bundle URLs and plugin hooks), so they must not depend on the host OS.
|
|
8
|
+
*/
|
|
9
|
+
export const toRelativePosixPath = (rootDir: string, filePath: string): string =>
|
|
10
|
+
path.relative(rootDir, filePath).split(path.sep).join('/');
|