@react-native-harness/jest 1.3.0 → 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 +29 -4
- package/dist/__tests__/crash-monitor.test.js +194 -176
- 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__/errors.test.js +39 -0
- package/dist/__tests__/execute-run.test.js +439 -29
- 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 +217 -234
- package/dist/__tests__/index.test.js +62 -11
- 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/__tests__/test-file-platform-filter.test.d.ts +2 -0
- package/dist/__tests__/test-file-platform-filter.test.d.ts.map +1 -0
- package/dist/__tests__/test-file-platform-filter.test.js +47 -0
- package/dist/crash-monitor.d.ts +12 -4
- package/dist/crash-monitor.d.ts.map +1 -1
- package/dist/crash-monitor.js +135 -102
- 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/errors.d.ts +10 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +41 -2
- package/dist/execute-run.d.ts +4 -2
- package/dist/execute-run.d.ts.map +1 -1
- package/dist/execute-run.js +179 -49
- 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 +46 -1
- package/dist/harness-session.d.ts.map +1 -1
- package/dist/harness-session.js +386 -118
- package/dist/index.d.ts +7 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -2
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +22 -4
- package/dist/test-file-platform-filter.d.ts +5 -0
- package/dist/test-file-platform-filter.d.ts.map +1 -0
- package/dist/test-file-platform-filter.js +40 -0
- package/dist/toTestResult.d.ts +1 -0
- package/dist/toTestResult.d.ts.map +1 -1
- package/dist/toTestResult.js +2 -2
- package/dist/tsconfig.lib.tsbuildinfo +1 -1
- package/package.json +8 -7
- package/src/__tests__/bridge.test.ts +45 -5
- package/src/__tests__/crash-monitor.test.ts +234 -199
- package/src/__tests__/environment-reset.test.ts +308 -0
- package/src/__tests__/errors.test.ts +75 -0
- package/src/__tests__/execute-run.test.ts +561 -37
- 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 +303 -0
- package/src/__tests__/index.test.ts +92 -12
- package/src/__tests__/metro-port.test.ts +2 -0
- package/src/__tests__/run.test.ts +74 -0
- package/src/__tests__/test-file-platform-filter.test.ts +83 -0
- package/src/crash-monitor.ts +220 -131
- 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/errors.ts +62 -1
- package/src/execute-run.ts +230 -57
- package/src/format-harness-error.ts +73 -0
- package/src/harness-session.ts +535 -162
- package/src/index.ts +40 -11
- package/src/run.ts +24 -4
- package/src/test-file-platform-filter.ts +57 -0
- package/src/toTestResult.ts +3 -2
- package/tsconfig.json +3 -0
- package/tsconfig.lib.json +3 -0
package/dist/harness-session.js
CHANGED
|
@@ -1,53 +1,116 @@
|
|
|
1
|
-
import { createHarnessBridge, } from '@react-native-harness/bridge/server';
|
|
1
|
+
import { createHarnessBridge, AppBridgeDisconnectedError, } from '@react-native-harness/bridge/server';
|
|
2
2
|
import { HARNESS_BRIDGE_PATH, } from '@react-native-harness/bridge';
|
|
3
|
-
import {
|
|
3
|
+
import { createHarnessCache } from '@react-native-harness/cache';
|
|
4
|
+
import { getMetroInstance, waitForMetroBackedAppReady, } from '@react-native-harness/bundler-metro';
|
|
4
5
|
import { createHarnessPluginManager, } from '@react-native-harness/plugins';
|
|
5
|
-
import {
|
|
6
|
-
import { getConfig, ConfigSchema, } from '@react-native-harness/config';
|
|
6
|
+
import { createCrashArtifactWriter, createDiagnostics, logger, getTimeoutSignal, ensureSignalsDeliverable, waitForAbort, } from '@react-native-harness/tools';
|
|
7
|
+
import { getConfig, isDiagnosticsEnabled, resolveMetroCacheEnabled, ConfigSchema, } from '@react-native-harness/config';
|
|
7
8
|
import { preRunMessage } from 'jest-util';
|
|
9
|
+
import path from 'node:path';
|
|
8
10
|
import { PlatformReadyTimeoutError } from './errors.js';
|
|
9
11
|
import { NoRunnerSpecifiedError, RunnerNotFoundError } from './errors.js';
|
|
10
12
|
import { createCrashMonitor } from './crash-monitor.js';
|
|
13
|
+
import { createProcessResetStrategy, createRuntimeResetStrategy, resolveResetStrategyKind, withEscalation, } from './environment-reset.js';
|
|
11
14
|
import { createHookQueue } from './hook-queue.js';
|
|
12
15
|
import { createClientLogCollector, } from './client-log-handler.js';
|
|
13
16
|
import { createActionHooksPlugin } from './action-hooks.js';
|
|
14
17
|
import { createResourceLockManager, } from './resource-lock.js';
|
|
18
|
+
import { observeBridgeEvents, observeMetroEvents } from './diagnostics/index.js';
|
|
15
19
|
import { resolveHarnessMetroPort } from './metro-port.js';
|
|
16
20
|
import { getAdditionalCliArgs } from './cli-args.js';
|
|
17
21
|
import { logMetroCacheReused, logMetroPortFallback, logNativeCoverageCollected, logRunnerStarting, logRunnerStillWaitingInQueue, logRunnerWaitingInQueue, logTestEnvironmentReady, logTestRunHeader, } from './logs.js';
|
|
18
22
|
const sessionLogger = logger.child('runtime');
|
|
19
23
|
const defaultResourceLockManager = createResourceLockManager();
|
|
20
24
|
const ignorePromiseRejection = () => undefined;
|
|
25
|
+
const isBridgeDisconnectError = (error) => error instanceof Error && error.message === 'App bridge disconnected';
|
|
26
|
+
const TEST_RUN_BRIDGE_STABILITY_WAIT_MS = 500;
|
|
27
|
+
export const waitForBridgeDisconnectOrTimeout = async ({ bridge, connection, timeoutMs, }) => {
|
|
28
|
+
if (bridge.connection !== connection) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
return await new Promise((resolve) => {
|
|
32
|
+
const onDisconnected = () => {
|
|
33
|
+
cleanup();
|
|
34
|
+
resolve(true);
|
|
35
|
+
};
|
|
36
|
+
const cleanup = () => {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
bridge.off('disconnected', onDisconnected);
|
|
39
|
+
};
|
|
40
|
+
const timer = setTimeout(() => {
|
|
41
|
+
cleanup();
|
|
42
|
+
resolve(bridge.connection !== connection);
|
|
43
|
+
}, timeoutMs);
|
|
44
|
+
bridge.on('disconnected', onDisconnected);
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
// Resolves the NEXT time the bridge reports a 'connected' event, rejecting
|
|
48
|
+
// if `signal` aborts first. Deliberately does not use bridge.nextConnection(),
|
|
49
|
+
// which would resolve immediately for a stale, already-set connection.
|
|
50
|
+
export const waitForNextConnected = ({ bridge, signal, }) => new Promise((resolve, reject) => {
|
|
51
|
+
const onConnected = () => { cleanup(); resolve(); };
|
|
52
|
+
const onAbort = () => { cleanup(); reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); };
|
|
53
|
+
const cleanup = () => {
|
|
54
|
+
bridge.off('connected', onConnected);
|
|
55
|
+
signal.removeEventListener('abort', onAbort);
|
|
56
|
+
};
|
|
57
|
+
if (signal.aborted) {
|
|
58
|
+
onAbort();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
bridge.on('connected', onConnected);
|
|
62
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
63
|
+
});
|
|
64
|
+
export const getSignalExitCodeForRunState = (state) => {
|
|
65
|
+
if (state?.completed &&
|
|
66
|
+
state.status === 'passed' &&
|
|
67
|
+
!state.error &&
|
|
68
|
+
(state.summary?.failed ?? 0) === 0) {
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
return 1;
|
|
72
|
+
};
|
|
21
73
|
// ---------------------------------------------------------------------------
|
|
22
74
|
// Internal helpers
|
|
23
75
|
// ---------------------------------------------------------------------------
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
76
|
+
// Bounds only the init *call* with a readiness timeout; `options.signal` (the
|
|
77
|
+
// session-lifetime signal) is passed to `work` unmodified, so a slow init
|
|
78
|
+
// call keeps observing real session teardown, not a synthetic timeout abort.
|
|
79
|
+
// On timeout this throws PlatformReadyTimeoutError without cancelling `work`
|
|
80
|
+
// itself — the caller is expected to abort the session signal as part of
|
|
81
|
+
// tearing down, which then cancels the still-inflight call cooperatively.
|
|
82
|
+
export const withPlatformReadyTimeout = async (options) => {
|
|
83
|
+
const timeoutSignal = getTimeoutSignal(options.timeout);
|
|
84
|
+
const abortWait = waitForAbort(timeoutSignal);
|
|
85
|
+
try {
|
|
86
|
+
return await Promise.race([
|
|
87
|
+
options.work(options.signal),
|
|
88
|
+
abortWait.promise.catch(() => {
|
|
89
|
+
throw new PlatformReadyTimeoutError(options.timeout);
|
|
90
|
+
}),
|
|
91
|
+
]);
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
abortWait.cancel();
|
|
28
95
|
}
|
|
29
|
-
return new Promise((_, reject) => {
|
|
30
|
-
signal.addEventListener('abort', () => reject(signal.reason ?? createAbortError()), { once: true });
|
|
31
|
-
});
|
|
32
96
|
};
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
97
|
+
export const waitForStartupCrash = async ({ crashMonitor, detectNativeCrashes, testFilePath, signal, }) => {
|
|
98
|
+
if (!detectNativeCrashes) {
|
|
99
|
+
return await waitForAbort(signal).promise;
|
|
100
|
+
}
|
|
101
|
+
const watch = crashMonitor.watch(testFilePath, 'startup');
|
|
102
|
+
watch.promise.catch(ignorePromiseRejection); // suppress unhandled-rejection when abort wins race
|
|
103
|
+
const abortWait = waitForAbort(signal);
|
|
36
104
|
try {
|
|
37
|
-
return await
|
|
105
|
+
return await Promise.race([watch.promise, abortWait.promise]);
|
|
38
106
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
timeoutSignal.aborted &&
|
|
43
|
-
!options.signal.aborted) {
|
|
44
|
-
throw new PlatformReadyTimeoutError(options.timeout);
|
|
45
|
-
}
|
|
46
|
-
throw error;
|
|
107
|
+
finally {
|
|
108
|
+
watch.cancel();
|
|
109
|
+
abortWait.cancel();
|
|
47
110
|
}
|
|
48
111
|
};
|
|
49
112
|
const waitForAppReady = async (base, testFilePath) => {
|
|
50
|
-
const { metroInstance, bridge,
|
|
113
|
+
const { metroInstance, bridge, platformId, bundleStartTimeout, readyTimeout, maxAppRestarts, crashMonitor, detectNativeCrashes, restartAppSession, } = base;
|
|
51
114
|
const logWait = (message, ...args) => sessionLogger.debug(`waitForAppReady: ${message}`, ...args);
|
|
52
115
|
return await waitForMetroBackedAppReady({
|
|
53
116
|
metro: metroInstance,
|
|
@@ -58,7 +121,7 @@ const waitForAppReady = async (base, testFilePath) => {
|
|
|
58
121
|
signal: new AbortController().signal,
|
|
59
122
|
startAttempt: async () => {
|
|
60
123
|
logWait('launching app for %s', testFilePath);
|
|
61
|
-
await
|
|
124
|
+
await restartAppSession();
|
|
62
125
|
logWait('launch request completed, waiting for bridge ready');
|
|
63
126
|
},
|
|
64
127
|
waitForReady: async (signal) => {
|
|
@@ -68,32 +131,17 @@ const waitForAppReady = async (base, testFilePath) => {
|
|
|
68
131
|
// is already set. waitForReady is called before startAttempt, so a stale
|
|
69
132
|
// connection from a previous run would resolve the promise before startAttempt
|
|
70
133
|
// even restarts the app — leaving bridge.connection null after the restart.
|
|
71
|
-
await
|
|
72
|
-
const onConnected = (_conn) => { cleanup(); resolve(); };
|
|
73
|
-
const onAbort = () => { cleanup(); reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); };
|
|
74
|
-
const cleanup = () => {
|
|
75
|
-
bridge.off('connected', onConnected);
|
|
76
|
-
signal.removeEventListener('abort', onAbort);
|
|
77
|
-
};
|
|
78
|
-
if (signal.aborted) {
|
|
79
|
-
onAbort();
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
bridge.on('connected', onConnected);
|
|
83
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
84
|
-
});
|
|
134
|
+
await waitForNextConnected({ bridge, signal });
|
|
85
135
|
logWait('runtime ready received');
|
|
86
136
|
},
|
|
87
137
|
waitForCrash: async (signal) => {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
watch.cancel();
|
|
96
|
-
}
|
|
138
|
+
logWait('waiting for crash or runtime ready');
|
|
139
|
+
return await waitForStartupCrash({
|
|
140
|
+
crashMonitor,
|
|
141
|
+
detectNativeCrashes,
|
|
142
|
+
testFilePath,
|
|
143
|
+
signal,
|
|
144
|
+
});
|
|
97
145
|
},
|
|
98
146
|
onAttemptStart: () => {
|
|
99
147
|
logWait('beginning launch attempt for %s', testFilePath);
|
|
@@ -150,9 +198,23 @@ const applyEnvVars = (harnessConfig, globalConfig) => {
|
|
|
150
198
|
process.env.RN_HARNESS_VIEW_FLATTENING = 'false';
|
|
151
199
|
}
|
|
152
200
|
};
|
|
201
|
+
/**
|
|
202
|
+
* Logs the "Reusing Metro cache" message when persistent Metro caching is
|
|
203
|
+
* enabled and the cache is warm. `projectRoot` must be the config-resolved
|
|
204
|
+
* project root so the warmth check looks at the same directory Metro caches
|
|
205
|
+
* into.
|
|
206
|
+
*/
|
|
207
|
+
export const maybeLogMetroCacheReused = (options) => {
|
|
208
|
+
if (!resolveMetroCacheEnabled(options.config)) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (createHarnessCache({ projectRoot: options.projectRoot }).isWarm('metro')) {
|
|
212
|
+
logMetroCacheReused(options.platform);
|
|
213
|
+
}
|
|
214
|
+
};
|
|
153
215
|
const loadConfig = async (globalConfig) => {
|
|
154
216
|
const projectRoot = globalConfig.rootDir;
|
|
155
|
-
const { config: rawConfig } = await getConfig(projectRoot);
|
|
217
|
+
const { config: rawConfig, projectRoot: configProjectRoot } = await getConfig(projectRoot);
|
|
156
218
|
const cliArgs = getAdditionalCliArgs();
|
|
157
219
|
let harnessConfig = cliArgs.metroPort != null
|
|
158
220
|
? ConfigSchema.parse({ ...rawConfig, metroPort: cliArgs.metroPort })
|
|
@@ -173,29 +235,40 @@ const loadConfig = async (globalConfig) => {
|
|
|
173
235
|
const platform = harnessConfig.runners.find((r) => r.name === selectedRunnerName);
|
|
174
236
|
if (!platform)
|
|
175
237
|
throw new RunnerNotFoundError(selectedRunnerName);
|
|
176
|
-
return { harnessConfig, platform, projectRoot };
|
|
238
|
+
return { harnessConfig, platform, projectRoot, configProjectRoot };
|
|
177
239
|
};
|
|
178
240
|
// ---------------------------------------------------------------------------
|
|
179
241
|
// Session creation
|
|
180
242
|
// ---------------------------------------------------------------------------
|
|
181
243
|
export const createHarnessSession = async (globalConfig, { lockManager = defaultResourceLockManager } = {}) => {
|
|
182
244
|
preRunMessage.remove(process.stderr);
|
|
183
|
-
const
|
|
245
|
+
const setupStartedAt = Date.now();
|
|
246
|
+
const { harnessConfig, platform, projectRoot, configProjectRoot } = await loadConfig(globalConfig);
|
|
184
247
|
applyEnvVars(harnessConfig, globalConfig);
|
|
248
|
+
const diagnostics = createDiagnostics({
|
|
249
|
+
enabled: isDiagnosticsEnabled(harnessConfig),
|
|
250
|
+
});
|
|
251
|
+
diagnostics.record({
|
|
252
|
+
label: 'session.config.load',
|
|
253
|
+
duration: Date.now() - setupStartedAt,
|
|
254
|
+
});
|
|
185
255
|
sessionLogger.debug('creating session for runner=%s platform=%s', platform.name, platform.platformId);
|
|
186
|
-
// Single AbortController
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
256
|
+
// Single, session-lifetime AbortController: it aborts on early SIGTERM/SIGINT
|
|
257
|
+
// during setup (below) and, once the session is up, in dispose()'s finally
|
|
258
|
+
// (unchanged lifetime). It is the one signal the plugin manager and platform
|
|
259
|
+
// runner see for the entire session, so its abort reaches every long-lived
|
|
260
|
+
// child the runner threads it into.
|
|
261
|
+
ensureSignalsDeliverable();
|
|
262
|
+
const sessionController = new AbortController();
|
|
263
|
+
const onEarlySignal = () => sessionController.abort();
|
|
191
264
|
process.once('SIGTERM', onEarlySignal);
|
|
192
265
|
process.once('SIGINT', onEarlySignal);
|
|
193
266
|
const resourceLockKey = await (platform.getResourceLockKey?.() ?? getDefaultResourceLockKey(platform));
|
|
194
267
|
let didWaitForResourceLock = false;
|
|
195
268
|
let lastStillWaitingLogAt = 0;
|
|
196
269
|
logTestRunHeader(platform);
|
|
197
|
-
const resourceLease = await lockManager.acquire(resourceLockKey, {
|
|
198
|
-
signal:
|
|
270
|
+
const resourceLease = await diagnostics.measure('session.lock.wait', () => lockManager.acquire(resourceLockKey, {
|
|
271
|
+
signal: sessionController.signal,
|
|
199
272
|
onWait: () => {
|
|
200
273
|
didWaitForResourceLock = true;
|
|
201
274
|
logRunnerWaitingInQueue(platform);
|
|
@@ -208,7 +281,7 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
|
|
|
208
281
|
logRunnerStillWaitingInQueue(platform);
|
|
209
282
|
sessionLogger.debug('still waiting in queue for runner=%s key=%s elapsedMs=%d', platform.name, resourceLockKey, elapsedMs);
|
|
210
283
|
},
|
|
211
|
-
});
|
|
284
|
+
}), { lockKey: resourceLockKey });
|
|
212
285
|
if (didWaitForResourceLock)
|
|
213
286
|
logRunnerStarting(platform);
|
|
214
287
|
sessionLogger.debug('resource lock acquired for runner=%s key=%s', platform.name, resourceLockKey);
|
|
@@ -216,90 +289,147 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
|
|
|
216
289
|
// port resolution but before the inner try/catch (e.g. bridge creation failure).
|
|
217
290
|
let metroPortLease = null;
|
|
218
291
|
try {
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
292
|
+
const portResolveSpan = diagnostics.start('session.port.resolve');
|
|
293
|
+
let resolution;
|
|
294
|
+
try {
|
|
295
|
+
resolution = await resolveHarnessMetroPort({
|
|
296
|
+
config: harnessConfig,
|
|
297
|
+
platform,
|
|
298
|
+
resourceLockManager: lockManager,
|
|
299
|
+
signal: sessionController.signal,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
portResolveSpan.fail(error);
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
portResolveSpan.end({
|
|
307
|
+
port: resolution.config.metroPort,
|
|
308
|
+
didFallback: resolution.didFallback,
|
|
224
309
|
});
|
|
225
310
|
metroPortLease = resolution.metroPortLease;
|
|
226
311
|
const { config: runtimeConfig, initialMetroPort, didFallback } = resolution;
|
|
227
312
|
if (didFallback)
|
|
228
313
|
logMetroPortFallback(initialMetroPort, runtimeConfig.metroPort);
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
314
|
+
maybeLogMetroCacheReused({
|
|
315
|
+
config: runtimeConfig,
|
|
316
|
+
projectRoot: configProjectRoot,
|
|
317
|
+
platform,
|
|
318
|
+
});
|
|
233
319
|
const pluginManager = createHarnessPluginManager({
|
|
234
320
|
plugins: (runtimeConfig.plugins ?? []),
|
|
235
321
|
projectRoot,
|
|
236
322
|
config: runtimeConfig,
|
|
237
323
|
runner: platform,
|
|
238
|
-
abortSignal:
|
|
324
|
+
abortSignal: sessionController.signal,
|
|
239
325
|
});
|
|
240
326
|
const hooks = createHookQueue();
|
|
241
327
|
let currentRun = null;
|
|
242
328
|
const getCurrentRunId = () => currentRun?.runId;
|
|
243
329
|
const clientLogCollector = createClientLogCollector();
|
|
244
330
|
const context = { platform };
|
|
245
|
-
const
|
|
331
|
+
const crashArtifactWriter = createCrashArtifactWriter({
|
|
332
|
+
runnerName: platform.name,
|
|
333
|
+
platformId: platform.platformId,
|
|
334
|
+
rootDir: path.join(projectRoot, '.harness', 'crash-reports'),
|
|
335
|
+
});
|
|
336
|
+
const bridge = await diagnostics.measure('session.bridge.init', () => createHarnessBridge({
|
|
246
337
|
noServer: true,
|
|
247
338
|
timeout: runtimeConfig.bridgeTimeout,
|
|
248
339
|
context,
|
|
249
|
-
});
|
|
340
|
+
}));
|
|
250
341
|
sessionLogger.debug('bridge initialized on Metro websocket path %s', HARNESS_BRIDGE_PATH);
|
|
251
342
|
let metroInstance;
|
|
252
343
|
let platformInstance;
|
|
344
|
+
// Noop until the Metro instance resolves and the deriver is attached.
|
|
345
|
+
let disposeMetroDiagnostics = () => undefined;
|
|
253
346
|
try {
|
|
254
347
|
[metroInstance, platformInstance] = await Promise.all([
|
|
255
|
-
getMetroInstance({
|
|
348
|
+
diagnostics.measure('metro.init', () => getMetroInstance({
|
|
256
349
|
projectRoot,
|
|
257
350
|
harnessConfig: runtimeConfig,
|
|
258
351
|
websocketEndpoints: {
|
|
259
352
|
[HARNESS_BRIDGE_PATH]: bridge.ws,
|
|
260
353
|
},
|
|
261
|
-
|
|
354
|
+
watchMode: globalConfig.watch || globalConfig.watchAll,
|
|
355
|
+
}, sessionController.signal).then((instance) => {
|
|
262
356
|
sessionLogger.debug('Metro initialized');
|
|
357
|
+
// Attach the Metro diagnostics deriver before the eager prewarm
|
|
358
|
+
// fires so its bundle build/request events land in the trace.
|
|
359
|
+
disposeMetroDiagnostics = observeMetroEvents(instance.events, diagnostics);
|
|
360
|
+
if (runtimeConfig.eagerPrewarm !== false) {
|
|
361
|
+
// Kick off the first bundle build while the platform is still booting.
|
|
362
|
+
// prewarm() memoizes this first call, so its session-lifetime setup
|
|
363
|
+
// signal is the one that matters — later callers' signals cannot
|
|
364
|
+
// cancel it. Not awaited: metro.init must resolve immediately.
|
|
365
|
+
const prewarmSpan = diagnostics.start('metro.prewarm');
|
|
366
|
+
instance
|
|
367
|
+
.prewarm({
|
|
368
|
+
platform: platform.platformId,
|
|
369
|
+
signal: sessionController.signal,
|
|
370
|
+
})
|
|
371
|
+
.then((completed) => prewarmSpan.end({ completed }))
|
|
372
|
+
// Swallow AbortError on teardown so it never becomes an
|
|
373
|
+
// unhandled rejection; non-abort failures resolve false.
|
|
374
|
+
.catch((error) => prewarmSpan.fail(error));
|
|
375
|
+
}
|
|
263
376
|
return instance;
|
|
264
|
-
}),
|
|
265
|
-
withPlatformReadyTimeout({
|
|
377
|
+
})),
|
|
378
|
+
diagnostics.measure('platform.init', () => withPlatformReadyTimeout({
|
|
266
379
|
timeout: runtimeConfig.platformReadyTimeout,
|
|
267
|
-
signal:
|
|
380
|
+
signal: sessionController.signal,
|
|
268
381
|
work: async (signal) => {
|
|
269
382
|
return await import(platform.runner).then((module) => module.default(platform.config, runtimeConfig, {
|
|
270
383
|
signal,
|
|
384
|
+
crashArtifactWriter,
|
|
385
|
+
diagnostics,
|
|
271
386
|
})).then((instance) => {
|
|
272
387
|
sessionLogger.debug('platform runner initialized');
|
|
273
388
|
return instance;
|
|
274
389
|
});
|
|
275
390
|
},
|
|
276
|
-
}),
|
|
391
|
+
}), { runner: platform.runner, platformId: platform.platformId }),
|
|
277
392
|
]);
|
|
278
393
|
}
|
|
279
394
|
catch (error) {
|
|
280
|
-
// Only bridge
|
|
395
|
+
// Only bridge and the Metro diagnostics listener need cleanup here;
|
|
396
|
+
// leases are released by the outer catch.
|
|
397
|
+
disposeMetroDiagnostics();
|
|
281
398
|
await bridge.dispose();
|
|
282
399
|
throw error;
|
|
283
400
|
}
|
|
284
|
-
const crashArtifactWriter = createCrashArtifactWriter({
|
|
285
|
-
runnerName: platform.name,
|
|
286
|
-
platformId: platform.platformId,
|
|
287
|
-
});
|
|
288
|
-
const appMonitor = platformInstance.createAppMonitor({ crashArtifactWriter });
|
|
289
401
|
const appLaunchOptions = platform.config.appLaunchOptions;
|
|
290
|
-
|
|
402
|
+
let currentAppSession = null;
|
|
403
|
+
const crashMonitor = createCrashMonitor();
|
|
404
|
+
const disposeCurrentAppSession = async () => {
|
|
405
|
+
const session = currentAppSession;
|
|
406
|
+
currentAppSession = null;
|
|
407
|
+
crashMonitor.setAppSession(null);
|
|
408
|
+
if (session) {
|
|
409
|
+
await session.dispose();
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
const restartAppSession = async () => {
|
|
413
|
+
await crashMonitor.stop();
|
|
414
|
+
await disposeCurrentAppSession();
|
|
415
|
+
const session = await diagnostics.measure('app.session.create', () => platformInstance.createAppSession(appLaunchOptions));
|
|
416
|
+
currentAppSession = session;
|
|
417
|
+
crashMonitor.setAppSession(session);
|
|
418
|
+
await crashMonitor.start();
|
|
419
|
+
return session;
|
|
420
|
+
};
|
|
291
421
|
// Pre-build the options that are constant across all app-ready calls;
|
|
292
422
|
// only testFilePath varies per call.
|
|
293
423
|
const appReadyBaseOptions = {
|
|
294
424
|
metroInstance,
|
|
295
425
|
bridge,
|
|
296
|
-
platformInstance,
|
|
297
426
|
platformId: platform.platformId,
|
|
298
427
|
bundleStartTimeout: runtimeConfig.bundleStartTimeout ?? 60000,
|
|
299
428
|
readyTimeout: runtimeConfig.bridgeTimeout,
|
|
300
429
|
maxAppRestarts: runtimeConfig.maxAppRestarts ?? 2,
|
|
301
430
|
crashMonitor,
|
|
302
|
-
|
|
431
|
+
detectNativeCrashes: runtimeConfig.detectNativeCrashes !== false,
|
|
432
|
+
restartAppSession,
|
|
303
433
|
};
|
|
304
434
|
// --- Event listeners ---
|
|
305
435
|
const bridgeEventListener = buildBridgeHookScheduler(hooks, pluginManager, getCurrentRunId);
|
|
@@ -311,6 +441,17 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
|
|
|
311
441
|
};
|
|
312
442
|
const flushClientLogs = () => clientLogCollector.flush();
|
|
313
443
|
const clientLogListener = clientLogCollector.handleEvent;
|
|
444
|
+
const testRunnerEventListeners = new Set();
|
|
445
|
+
const onTestRunnerEvent = (event) => {
|
|
446
|
+
if (event.type === 'test-started' ||
|
|
447
|
+
event.type === 'test-finished' ||
|
|
448
|
+
event.type === 'suite-started' ||
|
|
449
|
+
event.type === 'suite-finished' ||
|
|
450
|
+
event.type === 'file-started' ||
|
|
451
|
+
event.type === 'file-finished') {
|
|
452
|
+
testRunnerEventListeners.forEach((listener) => listener(event));
|
|
453
|
+
}
|
|
454
|
+
};
|
|
314
455
|
const onConnected = (conn) => {
|
|
315
456
|
const runId = getCurrentRunId();
|
|
316
457
|
if (!runId)
|
|
@@ -322,19 +463,25 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
|
|
|
322
463
|
if (!runId)
|
|
323
464
|
return;
|
|
324
465
|
hooks.schedule(() => pluginManager.callHook('runtime:disconnected', { runId, reason: 'bridge-disconnected' }));
|
|
466
|
+
if (runtimeConfig.detectNativeCrashes !== false) {
|
|
467
|
+
crashMonitor.handleBridgeDisconnect();
|
|
468
|
+
}
|
|
325
469
|
};
|
|
326
470
|
bridge.on('connected', onConnected);
|
|
327
471
|
bridge.on('disconnected', onDisconnected);
|
|
328
472
|
bridge.on('event', bridgeEventListener);
|
|
473
|
+
bridge.on('event', onTestRunnerEvent);
|
|
329
474
|
metroInstance.events.addListener(onMetroEvent);
|
|
330
475
|
if (runtimeConfig.forwardClientLogs) {
|
|
331
476
|
metroInstance.events.addListener(clientLogListener);
|
|
332
477
|
}
|
|
478
|
+
const disposeBridgeDiagnostics = observeBridgeEvents(bridge, diagnostics);
|
|
333
479
|
sessionLogger.debug('registered runtime, bridge, and Metro listeners');
|
|
334
480
|
// --- Dispose ---
|
|
335
481
|
let disposePromise = null;
|
|
336
482
|
const disposeOnce = async (reason) => {
|
|
337
483
|
sessionLogger.debug('disposing session (reason=%s)', reason);
|
|
484
|
+
const disposeSpan = diagnostics.start('dispose.total', { reason });
|
|
338
485
|
let hookError;
|
|
339
486
|
try {
|
|
340
487
|
await hooks.drain();
|
|
@@ -365,10 +512,14 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
|
|
|
365
512
|
bridge.off('connected', onConnected);
|
|
366
513
|
bridge.off('disconnected', onDisconnected);
|
|
367
514
|
bridge.off('event', bridgeEventListener);
|
|
515
|
+
bridge.off('event', onTestRunnerEvent);
|
|
516
|
+
disposeMetroDiagnostics();
|
|
517
|
+
disposeBridgeDiagnostics();
|
|
368
518
|
const nativeCoverageConfig = runtimeConfig.coverage?.native?.ios;
|
|
369
519
|
if (nativeCoverageConfig?.pods?.length && platformInstance.collectNativeCoverage) {
|
|
370
520
|
try {
|
|
371
|
-
await
|
|
521
|
+
await crashMonitor.stop();
|
|
522
|
+
await disposeCurrentAppSession();
|
|
372
523
|
const lcovPath = await platformInstance.collectNativeCoverage({
|
|
373
524
|
pods: nativeCoverageConfig.pods,
|
|
374
525
|
outputDir: projectRoot,
|
|
@@ -383,8 +534,13 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
|
|
|
383
534
|
}
|
|
384
535
|
let cleanupError;
|
|
385
536
|
try {
|
|
537
|
+
// The app session (and its logcat/device log stream) must be fully
|
|
538
|
+
// torn down before the platform is disposed: platform disposal can
|
|
539
|
+
// shut down the device/emulator, and doing that concurrently with
|
|
540
|
+
// the app session's own cleanup races the device's log stream
|
|
541
|
+
// against the device disappearing out from under it.
|
|
542
|
+
await Promise.all([crashMonitor.dispose(), disposeCurrentAppSession()]);
|
|
386
543
|
await Promise.all([
|
|
387
|
-
crashMonitor.dispose(),
|
|
388
544
|
bridge.dispose(),
|
|
389
545
|
platformInstance.dispose(),
|
|
390
546
|
metroInstance.dispose(),
|
|
@@ -396,13 +552,18 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
|
|
|
396
552
|
}
|
|
397
553
|
finally {
|
|
398
554
|
await resourceLease.release();
|
|
399
|
-
|
|
555
|
+
sessionController.abort();
|
|
400
556
|
}
|
|
401
557
|
sessionLogger.debug('session resources disposed');
|
|
402
|
-
if (hookError)
|
|
558
|
+
if (hookError) {
|
|
559
|
+
disposeSpan.fail(hookError);
|
|
403
560
|
throw hookError;
|
|
404
|
-
|
|
561
|
+
}
|
|
562
|
+
if (cleanupError) {
|
|
563
|
+
disposeSpan.fail(cleanupError);
|
|
405
564
|
throw cleanupError;
|
|
565
|
+
}
|
|
566
|
+
disposeSpan.end();
|
|
406
567
|
};
|
|
407
568
|
const dispose = (reason = 'normal') => {
|
|
408
569
|
disposePromise ??= disposeOnce(reason);
|
|
@@ -412,15 +573,16 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
|
|
|
412
573
|
// that all infrastructure is up.
|
|
413
574
|
process.off('SIGTERM', onEarlySignal);
|
|
414
575
|
process.off('SIGINT', onEarlySignal);
|
|
415
|
-
const onSignal = () =>
|
|
576
|
+
const onSignal = () => {
|
|
577
|
+
const exitCode = getSignalExitCodeForRunState(currentRun);
|
|
578
|
+
void dispose('abort').then(() => process.exit(exitCode), () => process.exit(1));
|
|
579
|
+
};
|
|
416
580
|
process.once('SIGTERM', onSignal);
|
|
417
581
|
process.once('SIGINT', onSignal);
|
|
418
582
|
// --- Startup hooks ---
|
|
419
583
|
try {
|
|
420
584
|
await pluginManager.callHook('harness:before-creation', { appLaunchOptions });
|
|
421
585
|
await hooks.drain();
|
|
422
|
-
await appMonitor.start();
|
|
423
|
-
sessionLogger.debug('app monitor started');
|
|
424
586
|
await pluginManager.callHook('harness:before-run', { appLaunchOptions });
|
|
425
587
|
await hooks.drain();
|
|
426
588
|
}
|
|
@@ -431,38 +593,112 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
|
|
|
431
593
|
throw error;
|
|
432
594
|
}
|
|
433
595
|
logTestEnvironmentReady(platform);
|
|
596
|
+
diagnostics.record({
|
|
597
|
+
label: 'session.setup',
|
|
598
|
+
duration: Date.now() - setupStartedAt,
|
|
599
|
+
});
|
|
434
600
|
sessionLogger.debug('session ready');
|
|
435
601
|
// --- Public API ---
|
|
436
602
|
const ensureAppReady = async (testFilePath) => {
|
|
437
603
|
await hooks.drain();
|
|
438
604
|
sessionLogger.debug('ensuring app is ready for %s', testFilePath);
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
605
|
+
const appReadySpan = diagnostics.start('app.ready.total');
|
|
606
|
+
if (crashMonitor.isAlive() && bridge.connection !== null && currentAppSession) {
|
|
607
|
+
const state = await currentAppSession.getState();
|
|
608
|
+
if (state.status === 'running') {
|
|
609
|
+
sessionLogger.debug('reusing existing ready app for %s', testFilePath);
|
|
610
|
+
appReadySpan.end({ file: testFilePath, reused: true });
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
442
613
|
}
|
|
443
614
|
crashMonitor.reset();
|
|
444
615
|
sessionLogger.debug('app not ready, waiting for launch and runtime readiness');
|
|
445
|
-
|
|
616
|
+
try {
|
|
617
|
+
await waitForAppReady(appReadyBaseOptions, testFilePath);
|
|
618
|
+
}
|
|
619
|
+
catch (error) {
|
|
620
|
+
appReadySpan.fail(error, { file: testFilePath, reused: false });
|
|
621
|
+
throw error;
|
|
622
|
+
}
|
|
446
623
|
await hooks.drain();
|
|
447
624
|
sessionLogger.debug('app is ready for %s', testFilePath);
|
|
625
|
+
appReadySpan.end({ file: testFilePath, reused: false });
|
|
448
626
|
};
|
|
449
|
-
|
|
450
|
-
|
|
627
|
+
// Shared by restartApp (crash/timeout recovery) and the 'process' reset
|
|
628
|
+
// strategy: kill the current app session and either wait for a fresh one
|
|
629
|
+
// to become ready for testFilePath, or (no testFilePath) just launch one.
|
|
630
|
+
const performRestart = async (testFilePath) => {
|
|
451
631
|
await crashMonitor.stop();
|
|
452
|
-
|
|
632
|
+
await disposeCurrentAppSession();
|
|
453
633
|
if (testFilePath) {
|
|
454
|
-
await
|
|
634
|
+
await ensureAppReady(testFilePath);
|
|
455
635
|
}
|
|
456
636
|
else {
|
|
457
|
-
await platformInstance.
|
|
637
|
+
const session = await platformInstance.createAppSession(appLaunchOptions);
|
|
638
|
+
currentAppSession = session;
|
|
639
|
+
crashMonitor.setAppSession(session);
|
|
640
|
+
crashMonitor.reset();
|
|
641
|
+
await crashMonitor.start();
|
|
458
642
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
643
|
+
};
|
|
644
|
+
const restartApp = async (testFilePath) => {
|
|
645
|
+
await hooks.drain();
|
|
646
|
+
const reason = testFilePath
|
|
647
|
+
? 'stop-and-ensure-ready'
|
|
648
|
+
: 'direct-restart';
|
|
649
|
+
sessionLogger.debug('restarting app (testFile=%s mode=%s)', testFilePath ?? 'n/a', reason);
|
|
650
|
+
const restartSpan = diagnostics.start('app.restart', { reason });
|
|
651
|
+
try {
|
|
652
|
+
await performRestart(testFilePath);
|
|
653
|
+
await hooks.drain();
|
|
654
|
+
sessionLogger.debug('restart completed');
|
|
655
|
+
restartSpan.end();
|
|
656
|
+
}
|
|
657
|
+
catch (error) {
|
|
658
|
+
restartSpan.fail(error);
|
|
659
|
+
throw error;
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
const processResetStrategy = createProcessResetStrategy({
|
|
663
|
+
restart: performRestart,
|
|
664
|
+
});
|
|
665
|
+
const runtimeResetStrategy = createRuntimeResetStrategy({
|
|
666
|
+
getConnection: () => bridge.connection,
|
|
667
|
+
expectDisconnect: crashMonitor.expectDisconnect,
|
|
668
|
+
waitForReconnect: (signal) => waitForNextConnected({ bridge, signal }),
|
|
669
|
+
// Pending rpc.invoke calls reject with AppBridgeDisconnectedError when
|
|
670
|
+
// the app connection drops (see bridge server's disconnect()).
|
|
671
|
+
isDisconnectError: (error) => error instanceof AppBridgeDisconnectedError,
|
|
672
|
+
timeoutMs: runtimeConfig.bundleStartTimeout ?? 60000,
|
|
673
|
+
});
|
|
674
|
+
const resetEnvironment = async (testFilePath) => {
|
|
675
|
+
const requestedKind = resolveResetStrategyKind(runtimeConfig.resetEnvironmentBetweenTestFiles);
|
|
676
|
+
if (requestedKind === null) {
|
|
677
|
+
return;
|
|
463
678
|
}
|
|
464
679
|
await hooks.drain();
|
|
465
|
-
sessionLogger.debug('
|
|
680
|
+
sessionLogger.debug('resetting environment (testFile=%s kind=%s)', testFilePath, requestedKind);
|
|
681
|
+
let escalated = false;
|
|
682
|
+
const strategy = requestedKind === 'runtime'
|
|
683
|
+
? withEscalation(runtimeResetStrategy, processResetStrategy, {
|
|
684
|
+
onEscalate: (error) => {
|
|
685
|
+
escalated = true;
|
|
686
|
+
sessionLogger.debug('runtime reset failed, escalating to process restart: %s', error);
|
|
687
|
+
},
|
|
688
|
+
})
|
|
689
|
+
: processResetStrategy;
|
|
690
|
+
const resetSpan = diagnostics.start('app.reset', { requested: requestedKind });
|
|
691
|
+
const performed = () => (escalated ? 'process' : requestedKind);
|
|
692
|
+
try {
|
|
693
|
+
await strategy.reset({ testFilePath });
|
|
694
|
+
await hooks.drain();
|
|
695
|
+
sessionLogger.debug('environment reset completed');
|
|
696
|
+
resetSpan.end({ performed: performed(), escalated });
|
|
697
|
+
}
|
|
698
|
+
catch (error) {
|
|
699
|
+
resetSpan.fail(error, { performed: performed(), escalated });
|
|
700
|
+
throw error;
|
|
701
|
+
}
|
|
466
702
|
};
|
|
467
703
|
const runTestFile = async (testPath, options) => {
|
|
468
704
|
await hooks.drain();
|
|
@@ -470,7 +706,7 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
|
|
|
470
706
|
if (!conn)
|
|
471
707
|
throw new Error('No active app connection');
|
|
472
708
|
sessionLogger.debug('running test file on client: %s', testPath);
|
|
473
|
-
if (
|
|
709
|
+
if (runtimeConfig.detectNativeCrashes === false) {
|
|
474
710
|
const result = await conn.runTests(testPath, { ...options, runner: platform.runner });
|
|
475
711
|
await hooks.drain();
|
|
476
712
|
return result;
|
|
@@ -480,13 +716,30 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
|
|
|
480
716
|
// crash wins the race or cancel() is called after the test run wins.
|
|
481
717
|
crashWatch.promise.catch(ignorePromiseRejection);
|
|
482
718
|
try {
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
719
|
+
const testRunPromise = conn.runTests(testPath, {
|
|
720
|
+
...options,
|
|
721
|
+
runner: platform.runner,
|
|
722
|
+
});
|
|
723
|
+
const result = await Promise.race([testRunPromise, crashWatch.promise]);
|
|
724
|
+
const bridgeDisconnected = await waitForBridgeDisconnectOrTimeout({
|
|
725
|
+
bridge,
|
|
726
|
+
connection: conn,
|
|
727
|
+
timeoutMs: TEST_RUN_BRIDGE_STABILITY_WAIT_MS,
|
|
728
|
+
});
|
|
729
|
+
if (bridgeDisconnected) {
|
|
730
|
+
return await crashWatch.promise;
|
|
731
|
+
}
|
|
487
732
|
await hooks.drain();
|
|
488
733
|
return result;
|
|
489
734
|
}
|
|
735
|
+
catch (error) {
|
|
736
|
+
if (isBridgeDisconnectError(error)) {
|
|
737
|
+
const result = await crashWatch.promise;
|
|
738
|
+
await hooks.drain();
|
|
739
|
+
return result;
|
|
740
|
+
}
|
|
741
|
+
throw error;
|
|
742
|
+
}
|
|
490
743
|
finally {
|
|
491
744
|
crashWatch.cancel();
|
|
492
745
|
}
|
|
@@ -494,29 +747,44 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
|
|
|
494
747
|
return {
|
|
495
748
|
config: runtimeConfig,
|
|
496
749
|
context,
|
|
750
|
+
diagnostics,
|
|
751
|
+
onTestRunnerEvent: (listener) => {
|
|
752
|
+
testRunnerEventListeners.add(listener);
|
|
753
|
+
return () => {
|
|
754
|
+
testRunnerEventListeners.delete(listener);
|
|
755
|
+
};
|
|
756
|
+
},
|
|
497
757
|
runTestFile,
|
|
498
758
|
ensureAppReady,
|
|
499
759
|
restartApp,
|
|
760
|
+
resetEnvironment,
|
|
500
761
|
resetCrashState: () => crashMonitor.reset(),
|
|
501
762
|
flushClientLogs,
|
|
502
763
|
callHook: async (name, payload) => {
|
|
503
764
|
await hooks.drain();
|
|
504
|
-
await pluginManager.callHook(name, payload);
|
|
765
|
+
await diagnostics.measure('plugin.hook', () => pluginManager.callHook(name, payload), { hook: String(name) });
|
|
505
766
|
await hooks.drain();
|
|
506
767
|
},
|
|
507
768
|
setRunState: (state) => {
|
|
508
769
|
currentRun = state;
|
|
509
770
|
},
|
|
510
|
-
dispose: (reason = 'normal') => {
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
771
|
+
dispose: async (reason = 'normal') => {
|
|
772
|
+
try {
|
|
773
|
+
await dispose(reason);
|
|
774
|
+
}
|
|
775
|
+
finally {
|
|
776
|
+
process.off('SIGTERM', onSignal);
|
|
777
|
+
process.off('SIGINT', onSignal);
|
|
778
|
+
}
|
|
514
779
|
},
|
|
515
780
|
};
|
|
516
781
|
}
|
|
517
782
|
catch (error) {
|
|
518
783
|
process.off('SIGTERM', onEarlySignal);
|
|
519
784
|
process.off('SIGINT', onEarlySignal);
|
|
785
|
+
// Cancels any inflight init work still holding sessionController.signal
|
|
786
|
+
// (e.g. a platform init call abandoned after a readiness timeout race).
|
|
787
|
+
sessionController.abort();
|
|
520
788
|
await Promise.allSettled([resourceLease.release(), metroPortLease?.release()]);
|
|
521
789
|
throw error;
|
|
522
790
|
}
|