@react-native-harness/jest 1.6.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 +106 -22
- 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 +139 -21
- 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/dist/crash-supervisor.d.ts +0 -21
- package/dist/crash-supervisor.d.ts.map +0 -1
- package/dist/crash-supervisor.js +0 -210
- package/dist/event-mapper.d.ts +0 -16
- package/dist/event-mapper.d.ts.map +0 -1
- package/dist/event-mapper.js +0 -120
- package/dist/global-setup.d.ts +0 -3
- package/dist/global-setup.d.ts.map +0 -1
- package/dist/global-setup.js +0 -60
- package/dist/global-teardown.d.ts +0 -3
- package/dist/global-teardown.d.ts.map +0 -1
- package/dist/global-teardown.js +0 -11
- package/dist/harness-reporter.d.ts +0 -8
- package/dist/harness-reporter.d.ts.map +0 -1
- package/dist/harness-reporter.js +0 -12
- package/dist/harness.d.ts +0 -47
- package/dist/harness.d.ts.map +0 -1
- package/dist/harness.js +0 -619
- package/dist/jest-options.d.ts +0 -2
- package/dist/jest-options.d.ts.map +0 -1
- package/dist/jest-options.js +0 -1
- package/dist/setup.d.ts +0 -3
- package/dist/setup.d.ts.map +0 -1
- package/dist/setup.js +0 -80
- package/dist/teardown.d.ts +0 -3
- package/dist/teardown.d.ts.map +0 -1
- package/dist/teardown.js +0 -10
- package/dist/types.d.ts +0 -17
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -1
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;
|
|
@@ -497,6 +552,25 @@ export const createHarnessSession = async (
|
|
|
497
552
|
duration: Date.now() - setupStartedAt,
|
|
498
553
|
});
|
|
499
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
|
+
|
|
500
574
|
sessionLogger.debug(
|
|
501
575
|
'creating session for runner=%s platform=%s',
|
|
502
576
|
platform.name,
|
|
@@ -518,13 +592,15 @@ export const createHarnessSession = async (
|
|
|
518
592
|
process.once('SIGTERM', onEarlySignal);
|
|
519
593
|
process.once('SIGINT', onEarlySignal);
|
|
520
594
|
|
|
521
|
-
const resourceLockKey = await (
|
|
595
|
+
const resourceLockKey = await disposeTelemetryOnFailure(
|
|
596
|
+
Promise.resolve(platform.getResourceLockKey?.() ?? getDefaultResourceLockKey(platform))
|
|
597
|
+
);
|
|
522
598
|
let didWaitForResourceLock = false;
|
|
523
599
|
let lastStillWaitingLogAt = 0;
|
|
524
600
|
|
|
525
601
|
logTestRunHeader(platform);
|
|
526
602
|
|
|
527
|
-
const resourceLease = await diagnostics.measure(
|
|
603
|
+
const resourceLease = await disposeTelemetryOnFailure(diagnostics.measure(
|
|
528
604
|
'session.lock.wait',
|
|
529
605
|
() =>
|
|
530
606
|
lockManager.acquire(resourceLockKey, {
|
|
@@ -542,7 +618,7 @@ export const createHarnessSession = async (
|
|
|
542
618
|
},
|
|
543
619
|
}),
|
|
544
620
|
{ lockKey: resourceLockKey }
|
|
545
|
-
);
|
|
621
|
+
));
|
|
546
622
|
|
|
547
623
|
if (didWaitForResourceLock) logRunnerStarting(platform);
|
|
548
624
|
sessionLogger.debug('resource lock acquired for runner=%s key=%s', platform.name, resourceLockKey);
|
|
@@ -593,7 +669,10 @@ export const createHarnessSession = async (
|
|
|
593
669
|
const getCurrentRunId = () => currentRun?.runId;
|
|
594
670
|
const clientLogCollector = createClientLogCollector();
|
|
595
671
|
|
|
596
|
-
const context
|
|
672
|
+
const context = {
|
|
673
|
+
platform,
|
|
674
|
+
updateSnapshot: globalConfig.updateSnapshot,
|
|
675
|
+
} satisfies Required<HarnessContext>;
|
|
597
676
|
const crashArtifactWriter = createCrashArtifactWriter({
|
|
598
677
|
runnerName: platform.name,
|
|
599
678
|
platformId: platform.platformId,
|
|
@@ -605,6 +684,11 @@ export const createHarnessSession = async (
|
|
|
605
684
|
noServer: true,
|
|
606
685
|
timeout: runtimeConfig.bridgeTimeout,
|
|
607
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
|
+
},
|
|
608
692
|
})
|
|
609
693
|
);
|
|
610
694
|
sessionLogger.debug('bridge initialized on Metro websocket path %s', HARNESS_BRIDGE_PATH);
|
|
@@ -619,6 +703,29 @@ export const createHarnessSession = async (
|
|
|
619
703
|
// Noop until the Metro instance resolves and the deriver is attached.
|
|
620
704
|
let disposeMetroDiagnostics: () => void = () => undefined;
|
|
621
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
|
+
|
|
622
729
|
try {
|
|
623
730
|
[metroInstance, platformInstance] = await Promise.all([
|
|
624
731
|
diagnostics.measure('metro.init', () =>
|
|
@@ -641,24 +748,13 @@ export const createHarnessSession = async (
|
|
|
641
748
|
}
|
|
642
749
|
initialized.metro = instance;
|
|
643
750
|
sessionLogger.debug('Metro initialized');
|
|
751
|
+
telemetry.phase('metro.ready');
|
|
644
752
|
// Attach the Metro diagnostics deriver before the eager prewarm
|
|
645
753
|
// fires so its bundle build/request events land in the trace.
|
|
646
754
|
disposeMetroDiagnostics = observeMetroEvents(instance.events, diagnostics);
|
|
647
|
-
if (
|
|
755
|
+
if (eagerPrewarm && !deferPrewarm) {
|
|
648
756
|
// Kick off the first bundle build while the platform is still booting.
|
|
649
|
-
|
|
650
|
-
// signal is the one that matters — later callers' signals cannot
|
|
651
|
-
// cancel it. Not awaited: metro.init must resolve immediately.
|
|
652
|
-
const prewarmSpan = diagnostics.start('metro.prewarm');
|
|
653
|
-
instance
|
|
654
|
-
.prewarm({
|
|
655
|
-
platform: bundlePlatform,
|
|
656
|
-
signal: sessionController.signal,
|
|
657
|
-
})
|
|
658
|
-
.then((completed) => prewarmSpan.end({ completed }))
|
|
659
|
-
// Swallow AbortError on teardown so it never becomes an
|
|
660
|
-
// unhandled rejection; non-abort failures resolve false.
|
|
661
|
-
.catch((error) => prewarmSpan.fail(error));
|
|
757
|
+
startPrewarm(instance);
|
|
662
758
|
}
|
|
663
759
|
return instance;
|
|
664
760
|
})
|
|
@@ -669,6 +765,7 @@ export const createHarnessSession = async (
|
|
|
669
765
|
withPlatformReadyTimeout({
|
|
670
766
|
timeout: runtimeConfig.platformReadyTimeout,
|
|
671
767
|
signal: sessionController.signal,
|
|
768
|
+
getHostPressure: telemetry.hostPressure,
|
|
672
769
|
work: async (signal) => {
|
|
673
770
|
return await import(platform.runner).then((module) =>
|
|
674
771
|
module.default(platform.config, runtimeConfig, {
|
|
@@ -682,6 +779,8 @@ export const createHarnessSession = async (
|
|
|
682
779
|
}
|
|
683
780
|
initialized.platform = instance;
|
|
684
781
|
sessionLogger.debug('platform runner initialized');
|
|
782
|
+
telemetry.attachPlatform(instance);
|
|
783
|
+
telemetry.phase('platform.ready');
|
|
685
784
|
return instance;
|
|
686
785
|
});
|
|
687
786
|
},
|
|
@@ -703,6 +802,10 @@ export const createHarnessSession = async (
|
|
|
703
802
|
throw error;
|
|
704
803
|
}
|
|
705
804
|
|
|
805
|
+
if (deferPrewarm) {
|
|
806
|
+
startPrewarm(metroInstance);
|
|
807
|
+
}
|
|
808
|
+
|
|
706
809
|
const appLaunchOptions = (platform.config as { appLaunchOptions?: AppLaunchOptions }).appLaunchOptions;
|
|
707
810
|
|
|
708
811
|
let currentAppSession: AppSession | null = null;
|
|
@@ -811,6 +914,7 @@ export const createHarnessSession = async (
|
|
|
811
914
|
// Abort only cancels finite/in-flight work. Ownership remains explicit
|
|
812
915
|
// below, so this cannot race a platform or app-session disposal path.
|
|
813
916
|
sessionController.abort();
|
|
917
|
+
telemetry.phase('dispose');
|
|
814
918
|
|
|
815
919
|
const hookWork = (async () => {
|
|
816
920
|
await hooks.drain();
|
|
@@ -843,6 +947,13 @@ export const createHarnessSession = async (
|
|
|
843
947
|
hookError = hookResult.reason;
|
|
844
948
|
}
|
|
845
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
|
+
|
|
846
957
|
if (runtimeConfig.forwardClientLogs) {
|
|
847
958
|
metroInstance.events.removeListener(clientLogListener);
|
|
848
959
|
}
|
|
@@ -903,7 +1014,7 @@ export const createHarnessSession = async (
|
|
|
903
1014
|
};
|
|
904
1015
|
|
|
905
1016
|
const dispose = (reason: 'normal' | 'abort' | 'error' = 'normal') => {
|
|
906
|
-
disposePromise ??= disposeOnce(reason);
|
|
1017
|
+
disposePromise ??= disposeOnce(reason).finally(() => telemetry.dispose());
|
|
907
1018
|
return disposePromise;
|
|
908
1019
|
};
|
|
909
1020
|
|
|
@@ -969,6 +1080,7 @@ export const createHarnessSession = async (
|
|
|
969
1080
|
await hooks.drain();
|
|
970
1081
|
sessionLogger.debug('app is ready for %s', testFilePath);
|
|
971
1082
|
appReadySpan.end({ file: testFilePath, reused: false });
|
|
1083
|
+
telemetry.phase('app.ready', { file: testFilePath });
|
|
972
1084
|
};
|
|
973
1085
|
|
|
974
1086
|
// Shared by restartApp (crash/timeout recovery) and the 'process' reset
|
|
@@ -1121,6 +1233,8 @@ export const createHarnessSession = async (
|
|
|
1121
1233
|
config: runtimeConfig,
|
|
1122
1234
|
context,
|
|
1123
1235
|
diagnostics,
|
|
1236
|
+
sessionId: telemetry.sessionId,
|
|
1237
|
+
lifecycle: telemetry.lifecycle,
|
|
1124
1238
|
onTestRunnerEvent: (listener) => {
|
|
1125
1239
|
testRunnerEventListeners.add(listener);
|
|
1126
1240
|
return () => {
|
|
@@ -1144,6 +1258,9 @@ export const createHarnessSession = async (
|
|
|
1144
1258
|
},
|
|
1145
1259
|
setRunState: (state) => {
|
|
1146
1260
|
currentRun = state;
|
|
1261
|
+
if (state) {
|
|
1262
|
+
telemetry.trackRun(state.runId);
|
|
1263
|
+
}
|
|
1147
1264
|
},
|
|
1148
1265
|
dispose: async (reason = 'normal') => {
|
|
1149
1266
|
try {
|
|
@@ -1160,6 +1277,7 @@ export const createHarnessSession = async (
|
|
|
1160
1277
|
// Cancels any inflight init work still holding sessionController.signal
|
|
1161
1278
|
// (e.g. a platform init call abandoned after a readiness timeout race).
|
|
1162
1279
|
sessionController.abort();
|
|
1280
|
+
await telemetry.dispose();
|
|
1163
1281
|
await Promise.allSettled([resourceLease.release(), metroPortLease?.release()]);
|
|
1164
1282
|
if (receivedEarlySignal) {
|
|
1165
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('/');
|