@react-native-harness/jest 1.4.0-rc.1 → 1.4.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/__tests__/bridge.test.js +6 -2
- package/dist/__tests__/crash-monitor.test.js +64 -0
- package/dist/__tests__/environment-reset.test.d.ts +2 -0
- package/dist/__tests__/environment-reset.test.d.ts.map +1 -0
- package/dist/__tests__/environment-reset.test.js +238 -0
- package/dist/__tests__/execute-run.test.js +250 -4
- package/dist/__tests__/format-harness-error.test.d.ts +2 -0
- package/dist/__tests__/format-harness-error.test.d.ts.map +1 -0
- package/dist/__tests__/format-harness-error.test.js +27 -0
- package/dist/__tests__/harness-cache.test.js +62 -41
- package/dist/__tests__/harness-session-signal.test.d.ts +2 -0
- package/dist/__tests__/harness-session-signal.test.d.ts.map +1 -0
- package/dist/__tests__/harness-session-signal.test.js +298 -0
- package/dist/__tests__/harness-session.test.js +161 -2
- package/dist/__tests__/index.test.js +3 -1
- package/dist/__tests__/metro-port.test.js +2 -0
- package/dist/__tests__/run.test.d.ts +2 -0
- package/dist/__tests__/run.test.d.ts.map +1 -0
- package/dist/__tests__/run.test.js +48 -0
- package/dist/crash-monitor.d.ts +7 -0
- package/dist/crash-monitor.d.ts.map +1 -1
- package/dist/crash-monitor.js +19 -0
- package/dist/diagnostics/__tests__/derivers.test.d.ts +2 -0
- package/dist/diagnostics/__tests__/derivers.test.d.ts.map +1 -0
- package/dist/diagnostics/__tests__/derivers.test.js +248 -0
- package/dist/diagnostics/__tests__/report.test.d.ts +2 -0
- package/dist/diagnostics/__tests__/report.test.d.ts.map +1 -0
- package/dist/diagnostics/__tests__/report.test.js +97 -0
- package/dist/diagnostics/derivers.d.ts +24 -0
- package/dist/diagnostics/derivers.d.ts.map +1 -0
- package/dist/diagnostics/derivers.js +146 -0
- package/dist/diagnostics/index.d.ts +3 -0
- package/dist/diagnostics/index.d.ts.map +1 -0
- package/dist/diagnostics/index.js +2 -0
- package/dist/diagnostics/report.d.ts +7 -0
- package/dist/diagnostics/report.d.ts.map +1 -0
- package/dist/diagnostics/report.js +84 -0
- package/dist/environment-reset.d.ts +51 -0
- package/dist/environment-reset.d.ts.map +1 -0
- package/dist/environment-reset.js +83 -0
- package/dist/execute-run.d.ts.map +1 -1
- package/dist/execute-run.js +121 -69
- package/dist/format-harness-error.d.ts +5 -0
- package/dist/format-harness-error.d.ts.map +1 -0
- package/dist/format-harness-error.js +43 -0
- package/dist/harness-session.d.ts +26 -1
- package/dist/harness-session.d.ts.map +1 -1
- package/dist/harness-session.js +260 -87
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +21 -4
- package/dist/tsconfig.lib.tsbuildinfo +1 -1
- package/package.json +8 -7
- package/src/__tests__/bridge.test.ts +8 -3
- package/src/__tests__/crash-monitor.test.ts +84 -0
- package/src/__tests__/environment-reset.test.ts +308 -0
- package/src/__tests__/execute-run.test.ts +327 -4
- package/src/__tests__/format-harness-error.test.ts +32 -0
- package/src/__tests__/harness-cache.test.ts +91 -0
- package/src/__tests__/harness-session.test.ts +211 -1
- package/src/__tests__/index.test.ts +3 -1
- package/src/__tests__/metro-port.test.ts +2 -0
- package/src/__tests__/run.test.ts +74 -0
- package/src/crash-monitor.ts +28 -0
- package/src/diagnostics/__tests__/derivers.test.ts +300 -0
- package/src/diagnostics/__tests__/report.test.ts +128 -0
- package/src/diagnostics/derivers.ts +170 -0
- package/src/diagnostics/index.ts +2 -0
- package/src/diagnostics/report.ts +124 -0
- package/src/environment-reset.ts +138 -0
- package/src/execute-run.ts +136 -77
- package/src/format-harness-error.ts +73 -0
- package/src/harness-session.ts +369 -131
- package/src/run.ts +23 -4
- package/tsconfig.json +3 -0
- package/tsconfig.lib.json +3 -0
package/src/harness-session.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createHarnessBridge,
|
|
3
|
+
AppBridgeDisconnectedError,
|
|
3
4
|
type HarnessBridge,
|
|
4
5
|
type AppConnection,
|
|
5
6
|
} from '@react-native-harness/bridge/server';
|
|
@@ -18,9 +19,9 @@ import {
|
|
|
18
19
|
type HarnessPlatformInitOptions,
|
|
19
20
|
type HarnessPlatformRunner,
|
|
20
21
|
} from '@react-native-harness/platforms';
|
|
22
|
+
import { createHarnessCache } from '@react-native-harness/cache';
|
|
21
23
|
import {
|
|
22
24
|
getMetroInstance,
|
|
23
|
-
isMetroCacheReusable,
|
|
24
25
|
waitForMetroBackedAppReady,
|
|
25
26
|
type MetroInstance,
|
|
26
27
|
type MetroWebSocketEndpoint,
|
|
@@ -36,12 +37,17 @@ import {
|
|
|
36
37
|
} from '@react-native-harness/plugins';
|
|
37
38
|
import {
|
|
38
39
|
createCrashArtifactWriter,
|
|
40
|
+
createDiagnostics,
|
|
39
41
|
logger,
|
|
40
42
|
getTimeoutSignal,
|
|
41
|
-
|
|
43
|
+
ensureSignalsDeliverable,
|
|
44
|
+
waitForAbort,
|
|
45
|
+
type Diagnostics,
|
|
42
46
|
} from '@react-native-harness/tools';
|
|
43
47
|
import {
|
|
44
48
|
getConfig,
|
|
49
|
+
isDiagnosticsEnabled,
|
|
50
|
+
resolveMetroCacheEnabled,
|
|
45
51
|
type Config as HarnessConfig,
|
|
46
52
|
ConfigSchema,
|
|
47
53
|
} from '@react-native-harness/config';
|
|
@@ -51,6 +57,12 @@ import path from 'node:path';
|
|
|
51
57
|
import { PlatformReadyTimeoutError } from './errors.js';
|
|
52
58
|
import { NoRunnerSpecifiedError, RunnerNotFoundError } from './errors.js';
|
|
53
59
|
import { createCrashMonitor, type CrashMonitor } from './crash-monitor.js';
|
|
60
|
+
import {
|
|
61
|
+
createProcessResetStrategy,
|
|
62
|
+
createRuntimeResetStrategy,
|
|
63
|
+
resolveResetStrategyKind,
|
|
64
|
+
withEscalation,
|
|
65
|
+
} from './environment-reset.js';
|
|
54
66
|
import { createHookQueue, type HookQueue } from './hook-queue.js';
|
|
55
67
|
import {
|
|
56
68
|
createClientLogCollector,
|
|
@@ -62,6 +74,7 @@ import {
|
|
|
62
74
|
type ResourceLockManager,
|
|
63
75
|
type ResourceLease,
|
|
64
76
|
} from './resource-lock.js';
|
|
77
|
+
import { observeBridgeEvents, observeMetroEvents } from './diagnostics/index.js';
|
|
65
78
|
import { resolveHarnessMetroPort } from './metro-port.js';
|
|
66
79
|
import { getAdditionalCliArgs } from './cli-args.js';
|
|
67
80
|
import {
|
|
@@ -121,12 +134,35 @@ export const waitForBridgeDisconnectOrTimeout = async ({
|
|
|
121
134
|
});
|
|
122
135
|
};
|
|
123
136
|
|
|
137
|
+
// Resolves the NEXT time the bridge reports a 'connected' event, rejecting
|
|
138
|
+
// if `signal` aborts first. Deliberately does not use bridge.nextConnection(),
|
|
139
|
+
// which would resolve immediately for a stale, already-set connection.
|
|
140
|
+
export const waitForNextConnected = ({
|
|
141
|
+
bridge,
|
|
142
|
+
signal,
|
|
143
|
+
}: {
|
|
144
|
+
bridge: Pick<HarnessBridge, 'on' | 'off'>;
|
|
145
|
+
signal: AbortSignal;
|
|
146
|
+
}): Promise<void> =>
|
|
147
|
+
new Promise<void>((resolve, reject) => {
|
|
148
|
+
const onConnected = () => { cleanup(); resolve(); };
|
|
149
|
+
const onAbort = () => { cleanup(); reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); };
|
|
150
|
+
const cleanup = () => {
|
|
151
|
+
bridge.off('connected', onConnected);
|
|
152
|
+
signal.removeEventListener('abort', onAbort);
|
|
153
|
+
};
|
|
154
|
+
if (signal.aborted) { onAbort(); return; }
|
|
155
|
+
bridge.on('connected', onConnected);
|
|
156
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
157
|
+
});
|
|
158
|
+
|
|
124
159
|
export type HarnessRunState = {
|
|
125
160
|
readonly runId: string;
|
|
126
161
|
readonly startTime: number;
|
|
127
162
|
readonly testFiles: string[];
|
|
128
163
|
readonly watchMode: boolean;
|
|
129
164
|
readonly coverageEnabled: boolean;
|
|
165
|
+
readonly completed?: boolean;
|
|
130
166
|
readonly summary?: HarnessRunSummary;
|
|
131
167
|
readonly status?: HarnessRunStatus;
|
|
132
168
|
readonly error?: unknown;
|
|
@@ -137,10 +173,12 @@ export type HarnessRunTestsOptions = Exclude<TestExecutionOptions, 'platform'>;
|
|
|
137
173
|
export type HarnessSession = {
|
|
138
174
|
readonly config: HarnessConfig;
|
|
139
175
|
readonly context: HarnessContext;
|
|
176
|
+
readonly diagnostics: Diagnostics;
|
|
140
177
|
onTestRunnerEvent: (listener: (event: TestRunnerEvents) => void) => () => void;
|
|
141
178
|
runTestFile: (path: string, options: HarnessRunTestsOptions) => Promise<TestSuiteResult>;
|
|
142
179
|
ensureAppReady: (testFilePath: string) => Promise<void>;
|
|
143
180
|
restartApp: (testFilePath?: string) => Promise<void>;
|
|
181
|
+
resetEnvironment: (testFilePath: string) => Promise<void>;
|
|
144
182
|
resetCrashState: () => void;
|
|
145
183
|
flushClientLogs: () => ClientLogBuffer;
|
|
146
184
|
callHook: HarnessPluginManager<HarnessConfig, HarnessPlatform>['callHook'];
|
|
@@ -148,45 +186,47 @@ export type HarnessSession = {
|
|
|
148
186
|
dispose: (reason?: 'normal' | 'abort' | 'error') => Promise<void>;
|
|
149
187
|
};
|
|
150
188
|
|
|
189
|
+
export const getSignalExitCodeForRunState = (
|
|
190
|
+
state: HarnessRunState | null
|
|
191
|
+
): number => {
|
|
192
|
+
if (
|
|
193
|
+
state?.completed &&
|
|
194
|
+
state.status === 'passed' &&
|
|
195
|
+
!state.error &&
|
|
196
|
+
(state.summary?.failed ?? 0) === 0
|
|
197
|
+
) {
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return 1;
|
|
202
|
+
};
|
|
203
|
+
|
|
151
204
|
// ---------------------------------------------------------------------------
|
|
152
205
|
// Internal helpers
|
|
153
206
|
// ---------------------------------------------------------------------------
|
|
154
207
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
return new Promise((_, reject) => {
|
|
163
|
-
signal.addEventListener(
|
|
164
|
-
'abort',
|
|
165
|
-
() => reject(signal.reason ?? createAbortError()),
|
|
166
|
-
{ once: true },
|
|
167
|
-
);
|
|
168
|
-
});
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
const withPlatformReadyTimeout = async <T>(options: {
|
|
208
|
+
// Bounds only the init *call* with a readiness timeout; `options.signal` (the
|
|
209
|
+
// session-lifetime signal) is passed to `work` unmodified, so a slow init
|
|
210
|
+
// call keeps observing real session teardown, not a synthetic timeout abort.
|
|
211
|
+
// On timeout this throws PlatformReadyTimeoutError without cancelling `work`
|
|
212
|
+
// itself — the caller is expected to abort the session signal as part of
|
|
213
|
+
// tearing down, which then cancels the still-inflight call cooperatively.
|
|
214
|
+
export const withPlatformReadyTimeout = async <T>(options: {
|
|
172
215
|
timeout: number;
|
|
173
216
|
signal: AbortSignal;
|
|
174
217
|
work: (signal: AbortSignal) => Promise<T>;
|
|
175
218
|
}): Promise<T> => {
|
|
176
219
|
const timeoutSignal = getTimeoutSignal(options.timeout);
|
|
177
|
-
const
|
|
220
|
+
const abortWait = waitForAbort(timeoutSignal);
|
|
178
221
|
try {
|
|
179
|
-
return await
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
)
|
|
187
|
-
throw new PlatformReadyTimeoutError(options.timeout);
|
|
188
|
-
}
|
|
189
|
-
throw error;
|
|
222
|
+
return await Promise.race([
|
|
223
|
+
options.work(options.signal),
|
|
224
|
+
abortWait.promise.catch(() => {
|
|
225
|
+
throw new PlatformReadyTimeoutError(options.timeout);
|
|
226
|
+
}),
|
|
227
|
+
]);
|
|
228
|
+
} finally {
|
|
229
|
+
abortWait.cancel();
|
|
190
230
|
}
|
|
191
231
|
};
|
|
192
232
|
|
|
@@ -214,15 +254,17 @@ export const waitForStartupCrash = async ({
|
|
|
214
254
|
signal: AbortSignal;
|
|
215
255
|
}) => {
|
|
216
256
|
if (!detectNativeCrashes) {
|
|
217
|
-
return await waitForAbort(signal);
|
|
257
|
+
return await waitForAbort(signal).promise;
|
|
218
258
|
}
|
|
219
259
|
|
|
220
260
|
const watch = crashMonitor.watch(testFilePath, 'startup');
|
|
221
261
|
watch.promise.catch(ignorePromiseRejection); // suppress unhandled-rejection when abort wins race
|
|
262
|
+
const abortWait = waitForAbort(signal);
|
|
222
263
|
try {
|
|
223
|
-
return await Promise.race([watch.promise,
|
|
264
|
+
return await Promise.race([watch.promise, abortWait.promise]);
|
|
224
265
|
} finally {
|
|
225
266
|
watch.cancel();
|
|
267
|
+
abortWait.cancel();
|
|
226
268
|
}
|
|
227
269
|
};
|
|
228
270
|
|
|
@@ -264,17 +306,7 @@ const waitForAppReady = async (
|
|
|
264
306
|
// is already set. waitForReady is called before startAttempt, so a stale
|
|
265
307
|
// connection from a previous run would resolve the promise before startAttempt
|
|
266
308
|
// even restarts the app — leaving bridge.connection null after the restart.
|
|
267
|
-
await
|
|
268
|
-
const onConnected = () => { cleanup(); resolve(); };
|
|
269
|
-
const onAbort = () => { cleanup(); reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); };
|
|
270
|
-
const cleanup = () => {
|
|
271
|
-
bridge.off('connected', onConnected);
|
|
272
|
-
signal.removeEventListener('abort', onAbort);
|
|
273
|
-
};
|
|
274
|
-
if (signal.aborted) { onAbort(); return; }
|
|
275
|
-
bridge.on('connected', onConnected);
|
|
276
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
277
|
-
});
|
|
309
|
+
await waitForNextConnected({ bridge, signal });
|
|
278
310
|
logWait('runtime ready received');
|
|
279
311
|
},
|
|
280
312
|
waitForCrash: async (signal) => {
|
|
@@ -358,13 +390,37 @@ const applyEnvVars = (
|
|
|
358
390
|
}
|
|
359
391
|
};
|
|
360
392
|
|
|
393
|
+
/**
|
|
394
|
+
* Logs the "Reusing Metro cache" message when persistent Metro caching is
|
|
395
|
+
* enabled and the cache is warm. `projectRoot` must be the config-resolved
|
|
396
|
+
* project root so the warmth check looks at the same directory Metro caches
|
|
397
|
+
* into.
|
|
398
|
+
*/
|
|
399
|
+
export const maybeLogMetroCacheReused = (options: {
|
|
400
|
+
config: Pick<HarnessConfig, 'cache' | 'unstable__enableMetroCache'>;
|
|
401
|
+
projectRoot: string;
|
|
402
|
+
platform: HarnessPlatform;
|
|
403
|
+
}): void => {
|
|
404
|
+
if (!resolveMetroCacheEnabled(options.config)) {
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (
|
|
409
|
+
createHarnessCache({ projectRoot: options.projectRoot }).isWarm('metro')
|
|
410
|
+
) {
|
|
411
|
+
logMetroCacheReused(options.platform);
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
|
|
361
415
|
const loadConfig = async (globalConfig: JestConfig.GlobalConfig): Promise<{
|
|
362
416
|
harnessConfig: HarnessConfig;
|
|
363
417
|
platform: HarnessPlatform;
|
|
364
418
|
projectRoot: string;
|
|
419
|
+
configProjectRoot: string;
|
|
365
420
|
}> => {
|
|
366
421
|
const projectRoot = globalConfig.rootDir;
|
|
367
|
-
const { config: rawConfig } =
|
|
422
|
+
const { config: rawConfig, projectRoot: configProjectRoot } =
|
|
423
|
+
await getConfig(projectRoot);
|
|
368
424
|
|
|
369
425
|
const cliArgs = getAdditionalCliArgs();
|
|
370
426
|
let harnessConfig = cliArgs.metroPort != null
|
|
@@ -393,7 +449,7 @@ const loadConfig = async (globalConfig: JestConfig.GlobalConfig): Promise<{
|
|
|
393
449
|
const platform = harnessConfig.runners.find((r) => r.name === selectedRunnerName);
|
|
394
450
|
if (!platform) throw new RunnerNotFoundError(selectedRunnerName);
|
|
395
451
|
|
|
396
|
-
return { harnessConfig, platform, projectRoot };
|
|
452
|
+
return { harnessConfig, platform, projectRoot, configProjectRoot };
|
|
397
453
|
};
|
|
398
454
|
|
|
399
455
|
// ---------------------------------------------------------------------------
|
|
@@ -406,20 +462,32 @@ export const createHarnessSession = async (
|
|
|
406
462
|
): Promise<HarnessSession> => {
|
|
407
463
|
preRunMessage.remove(process.stderr);
|
|
408
464
|
|
|
409
|
-
const
|
|
465
|
+
const setupStartedAt = Date.now();
|
|
466
|
+
const { harnessConfig, platform, projectRoot, configProjectRoot } = await loadConfig(globalConfig);
|
|
410
467
|
applyEnvVars(harnessConfig, globalConfig);
|
|
411
468
|
|
|
469
|
+
const diagnostics = createDiagnostics({
|
|
470
|
+
enabled: isDiagnosticsEnabled(harnessConfig),
|
|
471
|
+
});
|
|
472
|
+
diagnostics.record({
|
|
473
|
+
label: 'session.config.load',
|
|
474
|
+
duration: Date.now() - setupStartedAt,
|
|
475
|
+
});
|
|
476
|
+
|
|
412
477
|
sessionLogger.debug(
|
|
413
478
|
'creating session for runner=%s platform=%s',
|
|
414
479
|
platform.name,
|
|
415
480
|
platform.platformId,
|
|
416
481
|
);
|
|
417
482
|
|
|
418
|
-
// Single AbortController
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
|
|
422
|
-
|
|
483
|
+
// Single, session-lifetime AbortController: it aborts on early SIGTERM/SIGINT
|
|
484
|
+
// during setup (below) and, once the session is up, in dispose()'s finally
|
|
485
|
+
// (unchanged lifetime). It is the one signal the plugin manager and platform
|
|
486
|
+
// runner see for the entire session, so its abort reaches every long-lived
|
|
487
|
+
// child the runner threads it into.
|
|
488
|
+
ensureSignalsDeliverable();
|
|
489
|
+
const sessionController = new AbortController();
|
|
490
|
+
const onEarlySignal = () => sessionController.abort();
|
|
423
491
|
process.once('SIGTERM', onEarlySignal);
|
|
424
492
|
process.once('SIGINT', onEarlySignal);
|
|
425
493
|
|
|
@@ -429,20 +497,25 @@ export const createHarnessSession = async (
|
|
|
429
497
|
|
|
430
498
|
logTestRunHeader(platform);
|
|
431
499
|
|
|
432
|
-
const resourceLease = await
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
500
|
+
const resourceLease = await diagnostics.measure(
|
|
501
|
+
'session.lock.wait',
|
|
502
|
+
() =>
|
|
503
|
+
lockManager.acquire(resourceLockKey, {
|
|
504
|
+
signal: sessionController.signal,
|
|
505
|
+
onWait: () => {
|
|
506
|
+
didWaitForResourceLock = true;
|
|
507
|
+
logRunnerWaitingInQueue(platform);
|
|
508
|
+
sessionLogger.debug('waiting in queue for runner=%s key=%s', platform.name, resourceLockKey);
|
|
509
|
+
},
|
|
510
|
+
onStillWaiting: (elapsedMs) => {
|
|
511
|
+
if (elapsedMs - lastStillWaitingLogAt < 5000) return;
|
|
512
|
+
lastStillWaitingLogAt = elapsedMs;
|
|
513
|
+
logRunnerStillWaitingInQueue(platform);
|
|
514
|
+
sessionLogger.debug('still waiting in queue for runner=%s key=%s elapsedMs=%d', platform.name, resourceLockKey, elapsedMs);
|
|
515
|
+
},
|
|
516
|
+
}),
|
|
517
|
+
{ lockKey: resourceLockKey }
|
|
518
|
+
);
|
|
446
519
|
|
|
447
520
|
if (didWaitForResourceLock) logRunnerStarting(platform);
|
|
448
521
|
sessionLogger.debug('resource lock acquired for runner=%s key=%s', platform.name, resourceLockKey);
|
|
@@ -452,28 +525,40 @@ export const createHarnessSession = async (
|
|
|
452
525
|
let metroPortLease: ResourceLease | null = null;
|
|
453
526
|
|
|
454
527
|
try {
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
528
|
+
const portResolveSpan = diagnostics.start('session.port.resolve');
|
|
529
|
+
let resolution: Awaited<ReturnType<typeof resolveHarnessMetroPort>>;
|
|
530
|
+
try {
|
|
531
|
+
resolution = await resolveHarnessMetroPort({
|
|
532
|
+
config: harnessConfig,
|
|
533
|
+
platform,
|
|
534
|
+
resourceLockManager: lockManager,
|
|
535
|
+
signal: sessionController.signal,
|
|
536
|
+
});
|
|
537
|
+
} catch (error) {
|
|
538
|
+
portResolveSpan.fail(error);
|
|
539
|
+
throw error;
|
|
540
|
+
}
|
|
541
|
+
portResolveSpan.end({
|
|
542
|
+
port: resolution.config.metroPort,
|
|
543
|
+
didFallback: resolution.didFallback,
|
|
460
544
|
});
|
|
461
545
|
metroPortLease = resolution.metroPortLease;
|
|
462
546
|
const { config: runtimeConfig, initialMetroPort, didFallback } = resolution;
|
|
463
547
|
|
|
464
548
|
if (didFallback) logMetroPortFallback(initialMetroPort, runtimeConfig.metroPort);
|
|
465
549
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
550
|
+
maybeLogMetroCacheReused({
|
|
551
|
+
config: runtimeConfig,
|
|
552
|
+
projectRoot: configProjectRoot,
|
|
553
|
+
platform,
|
|
554
|
+
});
|
|
469
555
|
|
|
470
|
-
const pluginAbortController = new AbortController();
|
|
471
556
|
const pluginManager = createHarnessPluginManager<HarnessConfig, HarnessPlatform>({
|
|
472
557
|
plugins: (runtimeConfig.plugins ?? []) as Array<HarnessPlugin<object, HarnessConfig, HarnessPlatform>>,
|
|
473
558
|
projectRoot,
|
|
474
559
|
config: runtimeConfig,
|
|
475
560
|
runner: platform,
|
|
476
|
-
abortSignal:
|
|
561
|
+
abortSignal: sessionController.signal,
|
|
477
562
|
});
|
|
478
563
|
|
|
479
564
|
const hooks = createHookQueue();
|
|
@@ -488,49 +573,83 @@ export const createHarnessSession = async (
|
|
|
488
573
|
rootDir: path.join(projectRoot, '.harness', 'crash-reports'),
|
|
489
574
|
});
|
|
490
575
|
|
|
491
|
-
const bridge = await
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
576
|
+
const bridge = await diagnostics.measure('session.bridge.init', () =>
|
|
577
|
+
createHarnessBridge({
|
|
578
|
+
noServer: true,
|
|
579
|
+
timeout: runtimeConfig.bridgeTimeout,
|
|
580
|
+
context,
|
|
581
|
+
})
|
|
582
|
+
);
|
|
496
583
|
sessionLogger.debug('bridge initialized on Metro websocket path %s', HARNESS_BRIDGE_PATH);
|
|
497
584
|
|
|
498
585
|
let metroInstance: MetroInstance;
|
|
499
586
|
let platformInstance: HarnessPlatformRunner;
|
|
587
|
+
// Noop until the Metro instance resolves and the deriver is attached.
|
|
588
|
+
let disposeMetroDiagnostics: () => void = () => undefined;
|
|
500
589
|
|
|
501
590
|
try {
|
|
502
591
|
[metroInstance, platformInstance] = await Promise.all([
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
592
|
+
diagnostics.measure('metro.init', () =>
|
|
593
|
+
getMetroInstance(
|
|
594
|
+
{
|
|
595
|
+
projectRoot,
|
|
596
|
+
harnessConfig: runtimeConfig,
|
|
597
|
+
websocketEndpoints: {
|
|
598
|
+
[HARNESS_BRIDGE_PATH]: bridge.ws as unknown as MetroWebSocketEndpoint,
|
|
599
|
+
},
|
|
600
|
+
watchMode: globalConfig.watch || globalConfig.watchAll,
|
|
509
601
|
},
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
602
|
+
sessionController.signal,
|
|
603
|
+
).then((instance) => {
|
|
604
|
+
sessionLogger.debug('Metro initialized');
|
|
605
|
+
// Attach the Metro diagnostics deriver before the eager prewarm
|
|
606
|
+
// fires so its bundle build/request events land in the trace.
|
|
607
|
+
disposeMetroDiagnostics = observeMetroEvents(instance.events, diagnostics);
|
|
608
|
+
if (runtimeConfig.eagerPrewarm !== false) {
|
|
609
|
+
// Kick off the first bundle build while the platform is still booting.
|
|
610
|
+
// prewarm() memoizes this first call, so its session-lifetime setup
|
|
611
|
+
// signal is the one that matters — later callers' signals cannot
|
|
612
|
+
// cancel it. Not awaited: metro.init must resolve immediately.
|
|
613
|
+
const prewarmSpan = diagnostics.start('metro.prewarm');
|
|
614
|
+
instance
|
|
615
|
+
.prewarm({
|
|
616
|
+
platform: platform.platformId,
|
|
617
|
+
signal: sessionController.signal,
|
|
618
|
+
})
|
|
619
|
+
.then((completed) => prewarmSpan.end({ completed }))
|
|
620
|
+
// Swallow AbortError on teardown so it never becomes an
|
|
621
|
+
// unhandled rejection; non-abort failures resolve false.
|
|
622
|
+
.catch((error) => prewarmSpan.fail(error));
|
|
623
|
+
}
|
|
624
|
+
return instance;
|
|
625
|
+
})
|
|
626
|
+
),
|
|
627
|
+
diagnostics.measure(
|
|
628
|
+
'platform.init',
|
|
629
|
+
() =>
|
|
630
|
+
withPlatformReadyTimeout({
|
|
631
|
+
timeout: runtimeConfig.platformReadyTimeout,
|
|
632
|
+
signal: sessionController.signal,
|
|
633
|
+
work: async (signal) => {
|
|
634
|
+
return await import(platform.runner).then((module) =>
|
|
635
|
+
module.default(platform.config, runtimeConfig, {
|
|
636
|
+
signal,
|
|
637
|
+
crashArtifactWriter,
|
|
638
|
+
diagnostics,
|
|
639
|
+
} satisfies HarnessPlatformInitOptions),
|
|
640
|
+
).then((instance) => {
|
|
641
|
+
sessionLogger.debug('platform runner initialized');
|
|
642
|
+
return instance;
|
|
643
|
+
});
|
|
644
|
+
},
|
|
645
|
+
}),
|
|
646
|
+
{ runner: platform.runner, platformId: platform.platformId }
|
|
647
|
+
),
|
|
531
648
|
]);
|
|
532
649
|
} catch (error) {
|
|
533
|
-
// Only bridge
|
|
650
|
+
// Only bridge and the Metro diagnostics listener need cleanup here;
|
|
651
|
+
// leases are released by the outer catch.
|
|
652
|
+
disposeMetroDiagnostics();
|
|
534
653
|
await bridge.dispose();
|
|
535
654
|
throw error;
|
|
536
655
|
}
|
|
@@ -552,7 +671,9 @@ export const createHarnessSession = async (
|
|
|
552
671
|
const restartAppSession = async (): Promise<AppSession> => {
|
|
553
672
|
await crashMonitor.stop();
|
|
554
673
|
await disposeCurrentAppSession();
|
|
555
|
-
const session = await
|
|
674
|
+
const session = await diagnostics.measure('app.session.create', () =>
|
|
675
|
+
platformInstance.createAppSession(appLaunchOptions)
|
|
676
|
+
);
|
|
556
677
|
currentAppSession = session;
|
|
557
678
|
crashMonitor.setAppSession(session);
|
|
558
679
|
await crashMonitor.start();
|
|
@@ -625,6 +746,8 @@ export const createHarnessSession = async (
|
|
|
625
746
|
metroInstance.events.addListener(clientLogListener);
|
|
626
747
|
}
|
|
627
748
|
|
|
749
|
+
const disposeBridgeDiagnostics = observeBridgeEvents(bridge, diagnostics);
|
|
750
|
+
|
|
628
751
|
sessionLogger.debug('registered runtime, bridge, and Metro listeners');
|
|
629
752
|
|
|
630
753
|
// --- Dispose ---
|
|
@@ -633,6 +756,7 @@ export const createHarnessSession = async (
|
|
|
633
756
|
|
|
634
757
|
const disposeOnce = async (reason: 'normal' | 'abort' | 'error') => {
|
|
635
758
|
sessionLogger.debug('disposing session (reason=%s)', reason);
|
|
759
|
+
const disposeSpan = diagnostics.start('dispose.total', { reason });
|
|
636
760
|
let hookError: unknown;
|
|
637
761
|
|
|
638
762
|
try {
|
|
@@ -665,6 +789,8 @@ export const createHarnessSession = async (
|
|
|
665
789
|
bridge.off('disconnected', onDisconnected);
|
|
666
790
|
bridge.off('event', bridgeEventListener);
|
|
667
791
|
bridge.off('event', onTestRunnerEvent);
|
|
792
|
+
disposeMetroDiagnostics();
|
|
793
|
+
disposeBridgeDiagnostics();
|
|
668
794
|
|
|
669
795
|
const nativeCoverageConfig = runtimeConfig.coverage?.native?.ios;
|
|
670
796
|
if (nativeCoverageConfig?.pods?.length && platformInstance.collectNativeCoverage) {
|
|
@@ -685,9 +811,13 @@ export const createHarnessSession = async (
|
|
|
685
811
|
|
|
686
812
|
let cleanupError: unknown;
|
|
687
813
|
try {
|
|
814
|
+
// The app session (and its logcat/device log stream) must be fully
|
|
815
|
+
// torn down before the platform is disposed: platform disposal can
|
|
816
|
+
// shut down the device/emulator, and doing that concurrently with
|
|
817
|
+
// the app session's own cleanup races the device's log stream
|
|
818
|
+
// against the device disappearing out from under it.
|
|
819
|
+
await Promise.all([crashMonitor.dispose(), disposeCurrentAppSession()]);
|
|
688
820
|
await Promise.all([
|
|
689
|
-
crashMonitor.dispose(),
|
|
690
|
-
disposeCurrentAppSession(),
|
|
691
821
|
bridge.dispose(),
|
|
692
822
|
platformInstance.dispose(),
|
|
693
823
|
metroInstance.dispose(),
|
|
@@ -697,13 +827,20 @@ export const createHarnessSession = async (
|
|
|
697
827
|
cleanupError = error;
|
|
698
828
|
} finally {
|
|
699
829
|
await resourceLease.release();
|
|
700
|
-
|
|
830
|
+
sessionController.abort();
|
|
701
831
|
}
|
|
702
832
|
|
|
703
833
|
sessionLogger.debug('session resources disposed');
|
|
704
834
|
|
|
705
|
-
if (hookError)
|
|
706
|
-
|
|
835
|
+
if (hookError) {
|
|
836
|
+
disposeSpan.fail(hookError);
|
|
837
|
+
throw hookError;
|
|
838
|
+
}
|
|
839
|
+
if (cleanupError) {
|
|
840
|
+
disposeSpan.fail(cleanupError);
|
|
841
|
+
throw cleanupError;
|
|
842
|
+
}
|
|
843
|
+
disposeSpan.end();
|
|
707
844
|
};
|
|
708
845
|
|
|
709
846
|
const dispose = (reason: 'normal' | 'abort' | 'error' = 'normal') => {
|
|
@@ -715,7 +852,13 @@ export const createHarnessSession = async (
|
|
|
715
852
|
// that all infrastructure is up.
|
|
716
853
|
process.off('SIGTERM', onEarlySignal);
|
|
717
854
|
process.off('SIGINT', onEarlySignal);
|
|
718
|
-
const onSignal = () =>
|
|
855
|
+
const onSignal = () => {
|
|
856
|
+
const exitCode = getSignalExitCodeForRunState(currentRun);
|
|
857
|
+
void dispose('abort').then(
|
|
858
|
+
() => process.exit(exitCode),
|
|
859
|
+
() => process.exit(1),
|
|
860
|
+
);
|
|
861
|
+
};
|
|
719
862
|
process.once('SIGTERM', onSignal);
|
|
720
863
|
process.once('SIGINT', onSignal);
|
|
721
864
|
|
|
@@ -734,6 +877,10 @@ export const createHarnessSession = async (
|
|
|
734
877
|
}
|
|
735
878
|
|
|
736
879
|
logTestEnvironmentReady(platform);
|
|
880
|
+
diagnostics.record({
|
|
881
|
+
label: 'session.setup',
|
|
882
|
+
duration: Date.now() - setupStartedAt,
|
|
883
|
+
});
|
|
737
884
|
sessionLogger.debug('session ready');
|
|
738
885
|
|
|
739
886
|
// --- Public API ---
|
|
@@ -741,31 +888,35 @@ export const createHarnessSession = async (
|
|
|
741
888
|
const ensureAppReady = async (testFilePath: string): Promise<void> => {
|
|
742
889
|
await hooks.drain();
|
|
743
890
|
sessionLogger.debug('ensuring app is ready for %s', testFilePath);
|
|
891
|
+
const appReadySpan = diagnostics.start('app.ready.total');
|
|
744
892
|
|
|
745
893
|
if (crashMonitor.isAlive() && bridge.connection !== null && currentAppSession) {
|
|
746
894
|
const state = await currentAppSession.getState();
|
|
747
895
|
if (state.status === 'running') {
|
|
748
896
|
sessionLogger.debug('reusing existing ready app for %s', testFilePath);
|
|
897
|
+
appReadySpan.end({ file: testFilePath, reused: true });
|
|
749
898
|
return;
|
|
750
899
|
}
|
|
751
900
|
}
|
|
752
901
|
|
|
753
902
|
crashMonitor.reset();
|
|
754
903
|
sessionLogger.debug('app not ready, waiting for launch and runtime readiness');
|
|
755
|
-
|
|
904
|
+
try {
|
|
905
|
+
await waitForAppReady(appReadyBaseOptions, testFilePath);
|
|
906
|
+
} catch (error) {
|
|
907
|
+
appReadySpan.fail(error, { file: testFilePath, reused: false });
|
|
908
|
+
throw error;
|
|
909
|
+
}
|
|
756
910
|
await hooks.drain();
|
|
757
911
|
sessionLogger.debug('app is ready for %s', testFilePath);
|
|
912
|
+
appReadySpan.end({ file: testFilePath, reused: false });
|
|
758
913
|
};
|
|
759
914
|
|
|
760
|
-
|
|
761
|
-
|
|
915
|
+
// Shared by restartApp (crash/timeout recovery) and the 'process' reset
|
|
916
|
+
// strategy: kill the current app session and either wait for a fresh one
|
|
917
|
+
// to become ready for testFilePath, or (no testFilePath) just launch one.
|
|
918
|
+
const performRestart = async (testFilePath?: string): Promise<void> => {
|
|
762
919
|
await crashMonitor.stop();
|
|
763
|
-
sessionLogger.debug(
|
|
764
|
-
'restarting app (testFile=%s mode=%s)',
|
|
765
|
-
testFilePath ?? 'n/a',
|
|
766
|
-
testFilePath ? 'stop-and-ensure-ready' : 'direct-restart',
|
|
767
|
-
);
|
|
768
|
-
|
|
769
920
|
await disposeCurrentAppSession();
|
|
770
921
|
|
|
771
922
|
if (testFilePath) {
|
|
@@ -777,9 +928,84 @@ export const createHarnessSession = async (
|
|
|
777
928
|
crashMonitor.reset();
|
|
778
929
|
await crashMonitor.start();
|
|
779
930
|
}
|
|
931
|
+
};
|
|
780
932
|
|
|
933
|
+
const restartApp = async (testFilePath?: string): Promise<void> => {
|
|
781
934
|
await hooks.drain();
|
|
782
|
-
|
|
935
|
+
const reason: 'stop-and-ensure-ready' | 'direct-restart' = testFilePath
|
|
936
|
+
? 'stop-and-ensure-ready'
|
|
937
|
+
: 'direct-restart';
|
|
938
|
+
sessionLogger.debug(
|
|
939
|
+
'restarting app (testFile=%s mode=%s)',
|
|
940
|
+
testFilePath ?? 'n/a',
|
|
941
|
+
reason,
|
|
942
|
+
);
|
|
943
|
+
const restartSpan = diagnostics.start('app.restart', { reason });
|
|
944
|
+
|
|
945
|
+
try {
|
|
946
|
+
await performRestart(testFilePath);
|
|
947
|
+
await hooks.drain();
|
|
948
|
+
sessionLogger.debug('restart completed');
|
|
949
|
+
restartSpan.end();
|
|
950
|
+
} catch (error) {
|
|
951
|
+
restartSpan.fail(error);
|
|
952
|
+
throw error;
|
|
953
|
+
}
|
|
954
|
+
};
|
|
955
|
+
|
|
956
|
+
const processResetStrategy = createProcessResetStrategy({
|
|
957
|
+
restart: performRestart,
|
|
958
|
+
});
|
|
959
|
+
const runtimeResetStrategy = createRuntimeResetStrategy({
|
|
960
|
+
getConnection: () => bridge.connection,
|
|
961
|
+
expectDisconnect: crashMonitor.expectDisconnect,
|
|
962
|
+
waitForReconnect: (signal) => waitForNextConnected({ bridge, signal }),
|
|
963
|
+
// Pending rpc.invoke calls reject with AppBridgeDisconnectedError when
|
|
964
|
+
// the app connection drops (see bridge server's disconnect()).
|
|
965
|
+
isDisconnectError: (error) => error instanceof AppBridgeDisconnectedError,
|
|
966
|
+
timeoutMs: runtimeConfig.bundleStartTimeout ?? 60000,
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
const resetEnvironment = async (testFilePath: string): Promise<void> => {
|
|
970
|
+
const requestedKind = resolveResetStrategyKind(
|
|
971
|
+
runtimeConfig.resetEnvironmentBetweenTestFiles,
|
|
972
|
+
);
|
|
973
|
+
if (requestedKind === null) {
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
await hooks.drain();
|
|
978
|
+
sessionLogger.debug(
|
|
979
|
+
'resetting environment (testFile=%s kind=%s)',
|
|
980
|
+
testFilePath,
|
|
981
|
+
requestedKind,
|
|
982
|
+
);
|
|
983
|
+
|
|
984
|
+
let escalated = false;
|
|
985
|
+
const strategy = requestedKind === 'runtime'
|
|
986
|
+
? withEscalation(runtimeResetStrategy, processResetStrategy, {
|
|
987
|
+
onEscalate: (error) => {
|
|
988
|
+
escalated = true;
|
|
989
|
+
sessionLogger.debug(
|
|
990
|
+
'runtime reset failed, escalating to process restart: %s',
|
|
991
|
+
error,
|
|
992
|
+
);
|
|
993
|
+
},
|
|
994
|
+
})
|
|
995
|
+
: processResetStrategy;
|
|
996
|
+
|
|
997
|
+
const resetSpan = diagnostics.start('app.reset', { requested: requestedKind });
|
|
998
|
+
const performed = () => (escalated ? 'process' : requestedKind);
|
|
999
|
+
|
|
1000
|
+
try {
|
|
1001
|
+
await strategy.reset({ testFilePath });
|
|
1002
|
+
await hooks.drain();
|
|
1003
|
+
sessionLogger.debug('environment reset completed');
|
|
1004
|
+
resetSpan.end({ performed: performed(), escalated });
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
resetSpan.fail(error, { performed: performed(), escalated });
|
|
1007
|
+
throw error;
|
|
1008
|
+
}
|
|
783
1009
|
};
|
|
784
1010
|
|
|
785
1011
|
const runTestFile = async (
|
|
@@ -835,6 +1061,7 @@ export const createHarnessSession = async (
|
|
|
835
1061
|
return {
|
|
836
1062
|
config: runtimeConfig,
|
|
837
1063
|
context,
|
|
1064
|
+
diagnostics,
|
|
838
1065
|
onTestRunnerEvent: (listener) => {
|
|
839
1066
|
testRunnerEventListeners.add(listener);
|
|
840
1067
|
return () => {
|
|
@@ -844,25 +1071,36 @@ export const createHarnessSession = async (
|
|
|
844
1071
|
runTestFile,
|
|
845
1072
|
ensureAppReady,
|
|
846
1073
|
restartApp,
|
|
1074
|
+
resetEnvironment,
|
|
847
1075
|
resetCrashState: () => crashMonitor.reset(),
|
|
848
1076
|
flushClientLogs,
|
|
849
1077
|
callHook: async (name, payload) => {
|
|
850
1078
|
await hooks.drain();
|
|
851
|
-
await
|
|
1079
|
+
await diagnostics.measure(
|
|
1080
|
+
'plugin.hook',
|
|
1081
|
+
() => pluginManager.callHook(name, payload),
|
|
1082
|
+
{ hook: String(name) }
|
|
1083
|
+
);
|
|
852
1084
|
await hooks.drain();
|
|
853
1085
|
},
|
|
854
1086
|
setRunState: (state) => {
|
|
855
1087
|
currentRun = state;
|
|
856
1088
|
},
|
|
857
|
-
dispose: (reason = 'normal') => {
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
1089
|
+
dispose: async (reason = 'normal') => {
|
|
1090
|
+
try {
|
|
1091
|
+
await dispose(reason);
|
|
1092
|
+
} finally {
|
|
1093
|
+
process.off('SIGTERM', onSignal);
|
|
1094
|
+
process.off('SIGINT', onSignal);
|
|
1095
|
+
}
|
|
861
1096
|
},
|
|
862
1097
|
};
|
|
863
1098
|
} catch (error) {
|
|
864
1099
|
process.off('SIGTERM', onEarlySignal);
|
|
865
1100
|
process.off('SIGINT', onEarlySignal);
|
|
1101
|
+
// Cancels any inflight init work still holding sessionController.signal
|
|
1102
|
+
// (e.g. a platform init call abandoned after a readiness timeout race).
|
|
1103
|
+
sessionController.abort();
|
|
866
1104
|
await Promise.allSettled([resourceLease.release(), metroPortLease?.release()]);
|
|
867
1105
|
throw error;
|
|
868
1106
|
}
|