@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/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';
|
|
@@ -7,18 +8,20 @@ import {
|
|
|
7
8
|
HARNESS_BRIDGE_PATH,
|
|
8
9
|
type HarnessContext,
|
|
9
10
|
type BridgeEvents,
|
|
11
|
+
type TestRunnerEvents,
|
|
10
12
|
type TestExecutionOptions,
|
|
11
13
|
type TestSuiteResult,
|
|
12
14
|
} from '@react-native-harness/bridge';
|
|
13
15
|
import {
|
|
14
16
|
type AppLaunchOptions,
|
|
17
|
+
type AppSession,
|
|
15
18
|
type HarnessPlatform,
|
|
16
19
|
type HarnessPlatformInitOptions,
|
|
17
20
|
type HarnessPlatformRunner,
|
|
18
21
|
} from '@react-native-harness/platforms';
|
|
22
|
+
import { createHarnessCache } from '@react-native-harness/cache';
|
|
19
23
|
import {
|
|
20
24
|
getMetroInstance,
|
|
21
|
-
isMetroCacheReusable,
|
|
22
25
|
waitForMetroBackedAppReady,
|
|
23
26
|
type MetroInstance,
|
|
24
27
|
type MetroWebSocketEndpoint,
|
|
@@ -33,21 +36,33 @@ import {
|
|
|
33
36
|
type HarnessRunSummary,
|
|
34
37
|
} from '@react-native-harness/plugins';
|
|
35
38
|
import {
|
|
36
|
-
logger,
|
|
37
39
|
createCrashArtifactWriter,
|
|
40
|
+
createDiagnostics,
|
|
41
|
+
logger,
|
|
38
42
|
getTimeoutSignal,
|
|
39
|
-
|
|
43
|
+
ensureSignalsDeliverable,
|
|
44
|
+
waitForAbort,
|
|
45
|
+
type Diagnostics,
|
|
40
46
|
} from '@react-native-harness/tools';
|
|
41
47
|
import {
|
|
42
48
|
getConfig,
|
|
49
|
+
isDiagnosticsEnabled,
|
|
50
|
+
resolveMetroCacheEnabled,
|
|
43
51
|
type Config as HarnessConfig,
|
|
44
52
|
ConfigSchema,
|
|
45
53
|
} from '@react-native-harness/config';
|
|
46
54
|
import type { Config as JestConfig } from 'jest-runner';
|
|
47
55
|
import { preRunMessage } from 'jest-util';
|
|
56
|
+
import path from 'node:path';
|
|
48
57
|
import { PlatformReadyTimeoutError } from './errors.js';
|
|
49
58
|
import { NoRunnerSpecifiedError, RunnerNotFoundError } from './errors.js';
|
|
50
59
|
import { createCrashMonitor, type CrashMonitor } from './crash-monitor.js';
|
|
60
|
+
import {
|
|
61
|
+
createProcessResetStrategy,
|
|
62
|
+
createRuntimeResetStrategy,
|
|
63
|
+
resolveResetStrategyKind,
|
|
64
|
+
withEscalation,
|
|
65
|
+
} from './environment-reset.js';
|
|
51
66
|
import { createHookQueue, type HookQueue } from './hook-queue.js';
|
|
52
67
|
import {
|
|
53
68
|
createClientLogCollector,
|
|
@@ -59,6 +74,7 @@ import {
|
|
|
59
74
|
type ResourceLockManager,
|
|
60
75
|
type ResourceLease,
|
|
61
76
|
} from './resource-lock.js';
|
|
77
|
+
import { observeBridgeEvents, observeMetroEvents } from './diagnostics/index.js';
|
|
62
78
|
import { resolveHarnessMetroPort } from './metro-port.js';
|
|
63
79
|
import { getAdditionalCliArgs } from './cli-args.js';
|
|
64
80
|
import {
|
|
@@ -75,6 +91,70 @@ import {
|
|
|
75
91
|
const sessionLogger = logger.child('runtime');
|
|
76
92
|
const defaultResourceLockManager = createResourceLockManager();
|
|
77
93
|
const ignorePromiseRejection = () => undefined;
|
|
94
|
+
const isBridgeDisconnectError = (error: unknown) =>
|
|
95
|
+
error instanceof Error && error.message === 'App bridge disconnected';
|
|
96
|
+
const TEST_RUN_BRIDGE_STABILITY_WAIT_MS = 500;
|
|
97
|
+
|
|
98
|
+
type DisconnectObservableBridge = {
|
|
99
|
+
readonly connection: AppConnection | null;
|
|
100
|
+
on: (event: 'disconnected', listener: () => void) => void;
|
|
101
|
+
off: (event: 'disconnected', listener: () => void) => void;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const waitForBridgeDisconnectOrTimeout = async ({
|
|
105
|
+
bridge,
|
|
106
|
+
connection,
|
|
107
|
+
timeoutMs,
|
|
108
|
+
}: {
|
|
109
|
+
bridge: DisconnectObservableBridge;
|
|
110
|
+
connection: AppConnection;
|
|
111
|
+
timeoutMs: number;
|
|
112
|
+
}): Promise<boolean> => {
|
|
113
|
+
if (bridge.connection !== connection) {
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return await new Promise<boolean>((resolve) => {
|
|
118
|
+
const onDisconnected = () => {
|
|
119
|
+
cleanup();
|
|
120
|
+
resolve(true);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const cleanup = () => {
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
bridge.off('disconnected', onDisconnected);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const timer = setTimeout(() => {
|
|
129
|
+
cleanup();
|
|
130
|
+
resolve(bridge.connection !== connection);
|
|
131
|
+
}, timeoutMs);
|
|
132
|
+
|
|
133
|
+
bridge.on('disconnected', onDisconnected);
|
|
134
|
+
});
|
|
135
|
+
};
|
|
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
|
+
});
|
|
78
158
|
|
|
79
159
|
export type HarnessRunState = {
|
|
80
160
|
readonly runId: string;
|
|
@@ -82,6 +162,7 @@ export type HarnessRunState = {
|
|
|
82
162
|
readonly testFiles: string[];
|
|
83
163
|
readonly watchMode: boolean;
|
|
84
164
|
readonly coverageEnabled: boolean;
|
|
165
|
+
readonly completed?: boolean;
|
|
85
166
|
readonly summary?: HarnessRunSummary;
|
|
86
167
|
readonly status?: HarnessRunStatus;
|
|
87
168
|
readonly error?: unknown;
|
|
@@ -92,9 +173,12 @@ export type HarnessRunTestsOptions = Exclude<TestExecutionOptions, 'platform'>;
|
|
|
92
173
|
export type HarnessSession = {
|
|
93
174
|
readonly config: HarnessConfig;
|
|
94
175
|
readonly context: HarnessContext;
|
|
176
|
+
readonly diagnostics: Diagnostics;
|
|
177
|
+
onTestRunnerEvent: (listener: (event: TestRunnerEvents) => void) => () => void;
|
|
95
178
|
runTestFile: (path: string, options: HarnessRunTestsOptions) => Promise<TestSuiteResult>;
|
|
96
179
|
ensureAppReady: (testFilePath: string) => Promise<void>;
|
|
97
180
|
restartApp: (testFilePath?: string) => Promise<void>;
|
|
181
|
+
resetEnvironment: (testFilePath: string) => Promise<void>;
|
|
98
182
|
resetCrashState: () => void;
|
|
99
183
|
flushClientLogs: () => ClientLogBuffer;
|
|
100
184
|
callHook: HarnessPluginManager<HarnessConfig, HarnessPlatform>['callHook'];
|
|
@@ -102,58 +186,86 @@ export type HarnessSession = {
|
|
|
102
186
|
dispose: (reason?: 'normal' | 'abort' | 'error') => Promise<void>;
|
|
103
187
|
};
|
|
104
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
|
+
|
|
105
204
|
// ---------------------------------------------------------------------------
|
|
106
205
|
// Internal helpers
|
|
107
206
|
// ---------------------------------------------------------------------------
|
|
108
207
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
return new Promise((_, reject) => {
|
|
117
|
-
signal.addEventListener(
|
|
118
|
-
'abort',
|
|
119
|
-
() => reject(signal.reason ?? createAbortError()),
|
|
120
|
-
{ once: true },
|
|
121
|
-
);
|
|
122
|
-
});
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
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: {
|
|
126
215
|
timeout: number;
|
|
127
216
|
signal: AbortSignal;
|
|
128
217
|
work: (signal: AbortSignal) => Promise<T>;
|
|
129
218
|
}): Promise<T> => {
|
|
130
219
|
const timeoutSignal = getTimeoutSignal(options.timeout);
|
|
131
|
-
const
|
|
220
|
+
const abortWait = waitForAbort(timeoutSignal);
|
|
132
221
|
try {
|
|
133
|
-
return await
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
)
|
|
141
|
-
throw new PlatformReadyTimeoutError(options.timeout);
|
|
142
|
-
}
|
|
143
|
-
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();
|
|
144
230
|
}
|
|
145
231
|
};
|
|
146
232
|
|
|
147
233
|
type AppReadyOptions = {
|
|
148
234
|
metroInstance: MetroInstance;
|
|
149
235
|
bridge: HarnessBridge;
|
|
150
|
-
platformInstance: HarnessPlatformRunner;
|
|
151
236
|
platformId: string;
|
|
152
237
|
bundleStartTimeout: number;
|
|
153
238
|
readyTimeout: number;
|
|
154
239
|
maxAppRestarts: number;
|
|
155
240
|
crashMonitor: CrashMonitor;
|
|
156
|
-
|
|
241
|
+
detectNativeCrashes: boolean;
|
|
242
|
+
restartAppSession: () => Promise<AppSession>;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
export const waitForStartupCrash = async ({
|
|
246
|
+
crashMonitor,
|
|
247
|
+
detectNativeCrashes,
|
|
248
|
+
testFilePath,
|
|
249
|
+
signal,
|
|
250
|
+
}: {
|
|
251
|
+
crashMonitor: CrashMonitor;
|
|
252
|
+
detectNativeCrashes: boolean;
|
|
253
|
+
testFilePath: string;
|
|
254
|
+
signal: AbortSignal;
|
|
255
|
+
}) => {
|
|
256
|
+
if (!detectNativeCrashes) {
|
|
257
|
+
return await waitForAbort(signal).promise;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const watch = crashMonitor.watch(testFilePath, 'startup');
|
|
261
|
+
watch.promise.catch(ignorePromiseRejection); // suppress unhandled-rejection when abort wins race
|
|
262
|
+
const abortWait = waitForAbort(signal);
|
|
263
|
+
try {
|
|
264
|
+
return await Promise.race([watch.promise, abortWait.promise]);
|
|
265
|
+
} finally {
|
|
266
|
+
watch.cancel();
|
|
267
|
+
abortWait.cancel();
|
|
268
|
+
}
|
|
157
269
|
};
|
|
158
270
|
|
|
159
271
|
const waitForAppReady = async (
|
|
@@ -163,13 +275,13 @@ const waitForAppReady = async (
|
|
|
163
275
|
const {
|
|
164
276
|
metroInstance,
|
|
165
277
|
bridge,
|
|
166
|
-
platformInstance,
|
|
167
278
|
platformId,
|
|
168
279
|
bundleStartTimeout,
|
|
169
280
|
readyTimeout,
|
|
170
281
|
maxAppRestarts,
|
|
171
282
|
crashMonitor,
|
|
172
|
-
|
|
283
|
+
detectNativeCrashes,
|
|
284
|
+
restartAppSession,
|
|
173
285
|
} = base;
|
|
174
286
|
|
|
175
287
|
const logWait = (message: string, ...args: unknown[]) =>
|
|
@@ -184,7 +296,7 @@ const waitForAppReady = async (
|
|
|
184
296
|
signal: new AbortController().signal,
|
|
185
297
|
startAttempt: async () => {
|
|
186
298
|
logWait('launching app for %s', testFilePath);
|
|
187
|
-
await
|
|
299
|
+
await restartAppSession();
|
|
188
300
|
logWait('launch request completed, waiting for bridge ready');
|
|
189
301
|
},
|
|
190
302
|
waitForReady: async (signal) => {
|
|
@@ -194,28 +306,17 @@ const waitForAppReady = async (
|
|
|
194
306
|
// is already set. waitForReady is called before startAttempt, so a stale
|
|
195
307
|
// connection from a previous run would resolve the promise before startAttempt
|
|
196
308
|
// even restarts the app — leaving bridge.connection null after the restart.
|
|
197
|
-
await
|
|
198
|
-
const onConnected = (_conn: AppConnection) => { cleanup(); resolve(); };
|
|
199
|
-
const onAbort = () => { cleanup(); reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); };
|
|
200
|
-
const cleanup = () => {
|
|
201
|
-
bridge.off('connected', onConnected);
|
|
202
|
-
signal.removeEventListener('abort', onAbort);
|
|
203
|
-
};
|
|
204
|
-
if (signal.aborted) { onAbort(); return; }
|
|
205
|
-
bridge.on('connected', onConnected);
|
|
206
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
207
|
-
});
|
|
309
|
+
await waitForNextConnected({ bridge, signal });
|
|
208
310
|
logWait('runtime ready received');
|
|
209
311
|
},
|
|
210
312
|
waitForCrash: async (signal) => {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
313
|
+
logWait('waiting for crash or runtime ready');
|
|
314
|
+
return await waitForStartupCrash({
|
|
315
|
+
crashMonitor,
|
|
316
|
+
detectNativeCrashes,
|
|
317
|
+
testFilePath,
|
|
318
|
+
signal,
|
|
319
|
+
});
|
|
219
320
|
},
|
|
220
321
|
onAttemptStart: () => {
|
|
221
322
|
logWait('beginning launch attempt for %s', testFilePath);
|
|
@@ -289,13 +390,37 @@ const applyEnvVars = (
|
|
|
289
390
|
}
|
|
290
391
|
};
|
|
291
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
|
+
|
|
292
415
|
const loadConfig = async (globalConfig: JestConfig.GlobalConfig): Promise<{
|
|
293
416
|
harnessConfig: HarnessConfig;
|
|
294
417
|
platform: HarnessPlatform;
|
|
295
418
|
projectRoot: string;
|
|
419
|
+
configProjectRoot: string;
|
|
296
420
|
}> => {
|
|
297
421
|
const projectRoot = globalConfig.rootDir;
|
|
298
|
-
const { config: rawConfig } =
|
|
422
|
+
const { config: rawConfig, projectRoot: configProjectRoot } =
|
|
423
|
+
await getConfig(projectRoot);
|
|
299
424
|
|
|
300
425
|
const cliArgs = getAdditionalCliArgs();
|
|
301
426
|
let harnessConfig = cliArgs.metroPort != null
|
|
@@ -324,7 +449,7 @@ const loadConfig = async (globalConfig: JestConfig.GlobalConfig): Promise<{
|
|
|
324
449
|
const platform = harnessConfig.runners.find((r) => r.name === selectedRunnerName);
|
|
325
450
|
if (!platform) throw new RunnerNotFoundError(selectedRunnerName);
|
|
326
451
|
|
|
327
|
-
return { harnessConfig, platform, projectRoot };
|
|
452
|
+
return { harnessConfig, platform, projectRoot, configProjectRoot };
|
|
328
453
|
};
|
|
329
454
|
|
|
330
455
|
// ---------------------------------------------------------------------------
|
|
@@ -337,20 +462,32 @@ export const createHarnessSession = async (
|
|
|
337
462
|
): Promise<HarnessSession> => {
|
|
338
463
|
preRunMessage.remove(process.stderr);
|
|
339
464
|
|
|
340
|
-
const
|
|
465
|
+
const setupStartedAt = Date.now();
|
|
466
|
+
const { harnessConfig, platform, projectRoot, configProjectRoot } = await loadConfig(globalConfig);
|
|
341
467
|
applyEnvVars(harnessConfig, globalConfig);
|
|
342
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
|
+
|
|
343
477
|
sessionLogger.debug(
|
|
344
478
|
'creating session for runner=%s platform=%s',
|
|
345
479
|
platform.name,
|
|
346
480
|
platform.platformId,
|
|
347
481
|
);
|
|
348
482
|
|
|
349
|
-
// Single AbortController
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
|
|
353
|
-
|
|
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();
|
|
354
491
|
process.once('SIGTERM', onEarlySignal);
|
|
355
492
|
process.once('SIGINT', onEarlySignal);
|
|
356
493
|
|
|
@@ -360,20 +497,25 @@ export const createHarnessSession = async (
|
|
|
360
497
|
|
|
361
498
|
logTestRunHeader(platform);
|
|
362
499
|
|
|
363
|
-
const resourceLease = await
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
+
);
|
|
377
519
|
|
|
378
520
|
if (didWaitForResourceLock) logRunnerStarting(platform);
|
|
379
521
|
sessionLogger.debug('resource lock acquired for runner=%s key=%s', platform.name, resourceLockKey);
|
|
@@ -383,28 +525,40 @@ export const createHarnessSession = async (
|
|
|
383
525
|
let metroPortLease: ResourceLease | null = null;
|
|
384
526
|
|
|
385
527
|
try {
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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,
|
|
391
544
|
});
|
|
392
545
|
metroPortLease = resolution.metroPortLease;
|
|
393
546
|
const { config: runtimeConfig, initialMetroPort, didFallback } = resolution;
|
|
394
547
|
|
|
395
548
|
if (didFallback) logMetroPortFallback(initialMetroPort, runtimeConfig.metroPort);
|
|
396
549
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
550
|
+
maybeLogMetroCacheReused({
|
|
551
|
+
config: runtimeConfig,
|
|
552
|
+
projectRoot: configProjectRoot,
|
|
553
|
+
platform,
|
|
554
|
+
});
|
|
400
555
|
|
|
401
|
-
const pluginAbortController = new AbortController();
|
|
402
556
|
const pluginManager = createHarnessPluginManager<HarnessConfig, HarnessPlatform>({
|
|
403
557
|
plugins: (runtimeConfig.plugins ?? []) as Array<HarnessPlugin<object, HarnessConfig, HarnessPlatform>>,
|
|
404
558
|
projectRoot,
|
|
405
559
|
config: runtimeConfig,
|
|
406
560
|
runner: platform,
|
|
407
|
-
abortSignal:
|
|
561
|
+
abortSignal: sessionController.signal,
|
|
408
562
|
});
|
|
409
563
|
|
|
410
564
|
const hooks = createHookQueue();
|
|
@@ -413,74 +567,131 @@ export const createHarnessSession = async (
|
|
|
413
567
|
const clientLogCollector = createClientLogCollector();
|
|
414
568
|
|
|
415
569
|
const context: HarnessContext = { platform };
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
context,
|
|
570
|
+
const crashArtifactWriter = createCrashArtifactWriter({
|
|
571
|
+
runnerName: platform.name,
|
|
572
|
+
platformId: platform.platformId,
|
|
573
|
+
rootDir: path.join(projectRoot, '.harness', 'crash-reports'),
|
|
421
574
|
});
|
|
575
|
+
|
|
576
|
+
const bridge = await diagnostics.measure('session.bridge.init', () =>
|
|
577
|
+
createHarnessBridge({
|
|
578
|
+
noServer: true,
|
|
579
|
+
timeout: runtimeConfig.bridgeTimeout,
|
|
580
|
+
context,
|
|
581
|
+
})
|
|
582
|
+
);
|
|
422
583
|
sessionLogger.debug('bridge initialized on Metro websocket path %s', HARNESS_BRIDGE_PATH);
|
|
423
584
|
|
|
424
585
|
let metroInstance: MetroInstance;
|
|
425
586
|
let platformInstance: HarnessPlatformRunner;
|
|
587
|
+
// Noop until the Metro instance resolves and the deriver is attached.
|
|
588
|
+
let disposeMetroDiagnostics: () => void = () => undefined;
|
|
426
589
|
|
|
427
590
|
try {
|
|
428
591
|
[metroInstance, platformInstance] = await Promise.all([
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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,
|
|
435
601
|
},
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
+
),
|
|
456
648
|
]);
|
|
457
649
|
} catch (error) {
|
|
458
|
-
// Only bridge
|
|
650
|
+
// Only bridge and the Metro diagnostics listener need cleanup here;
|
|
651
|
+
// leases are released by the outer catch.
|
|
652
|
+
disposeMetroDiagnostics();
|
|
459
653
|
await bridge.dispose();
|
|
460
654
|
throw error;
|
|
461
655
|
}
|
|
462
656
|
|
|
463
|
-
const crashArtifactWriter = createCrashArtifactWriter({
|
|
464
|
-
runnerName: platform.name,
|
|
465
|
-
platformId: platform.platformId,
|
|
466
|
-
});
|
|
467
|
-
const appMonitor = platformInstance.createAppMonitor({ crashArtifactWriter });
|
|
468
657
|
const appLaunchOptions = (platform.config as { appLaunchOptions?: AppLaunchOptions }).appLaunchOptions;
|
|
469
658
|
|
|
470
|
-
|
|
659
|
+
let currentAppSession: AppSession | null = null;
|
|
660
|
+
const crashMonitor = createCrashMonitor();
|
|
661
|
+
|
|
662
|
+
const disposeCurrentAppSession = async () => {
|
|
663
|
+
const session = currentAppSession;
|
|
664
|
+
currentAppSession = null;
|
|
665
|
+
crashMonitor.setAppSession(null);
|
|
666
|
+
if (session) {
|
|
667
|
+
await session.dispose();
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
const restartAppSession = async (): Promise<AppSession> => {
|
|
672
|
+
await crashMonitor.stop();
|
|
673
|
+
await disposeCurrentAppSession();
|
|
674
|
+
const session = await diagnostics.measure('app.session.create', () =>
|
|
675
|
+
platformInstance.createAppSession(appLaunchOptions)
|
|
676
|
+
);
|
|
677
|
+
currentAppSession = session;
|
|
678
|
+
crashMonitor.setAppSession(session);
|
|
679
|
+
await crashMonitor.start();
|
|
680
|
+
return session;
|
|
681
|
+
};
|
|
471
682
|
|
|
472
683
|
// Pre-build the options that are constant across all app-ready calls;
|
|
473
684
|
// only testFilePath varies per call.
|
|
474
685
|
const appReadyBaseOptions: AppReadyOptions = {
|
|
475
686
|
metroInstance,
|
|
476
687
|
bridge,
|
|
477
|
-
platformInstance,
|
|
478
688
|
platformId: platform.platformId,
|
|
479
689
|
bundleStartTimeout: runtimeConfig.bundleStartTimeout ?? 60000,
|
|
480
690
|
readyTimeout: runtimeConfig.bridgeTimeout,
|
|
481
691
|
maxAppRestarts: runtimeConfig.maxAppRestarts ?? 2,
|
|
482
692
|
crashMonitor,
|
|
483
|
-
|
|
693
|
+
detectNativeCrashes: runtimeConfig.detectNativeCrashes !== false,
|
|
694
|
+
restartAppSession,
|
|
484
695
|
};
|
|
485
696
|
|
|
486
697
|
// --- Event listeners ---
|
|
@@ -497,6 +708,19 @@ export const createHarnessSession = async (
|
|
|
497
708
|
const flushClientLogs = (): ClientLogBuffer => clientLogCollector.flush();
|
|
498
709
|
|
|
499
710
|
const clientLogListener = clientLogCollector.handleEvent;
|
|
711
|
+
const testRunnerEventListeners = new Set<(event: TestRunnerEvents) => void>();
|
|
712
|
+
const onTestRunnerEvent = (event: BridgeEvents) => {
|
|
713
|
+
if (
|
|
714
|
+
event.type === 'test-started' ||
|
|
715
|
+
event.type === 'test-finished' ||
|
|
716
|
+
event.type === 'suite-started' ||
|
|
717
|
+
event.type === 'suite-finished' ||
|
|
718
|
+
event.type === 'file-started' ||
|
|
719
|
+
event.type === 'file-finished'
|
|
720
|
+
) {
|
|
721
|
+
testRunnerEventListeners.forEach((listener) => listener(event));
|
|
722
|
+
}
|
|
723
|
+
};
|
|
500
724
|
|
|
501
725
|
const onConnected = (conn: AppConnection) => {
|
|
502
726
|
const runId = getCurrentRunId();
|
|
@@ -508,16 +732,22 @@ export const createHarnessSession = async (
|
|
|
508
732
|
const runId = getCurrentRunId();
|
|
509
733
|
if (!runId) return;
|
|
510
734
|
hooks.schedule(() => pluginManager.callHook('runtime:disconnected', { runId, reason: 'bridge-disconnected' }));
|
|
735
|
+
if (runtimeConfig.detectNativeCrashes !== false) {
|
|
736
|
+
crashMonitor.handleBridgeDisconnect();
|
|
737
|
+
}
|
|
511
738
|
};
|
|
512
739
|
|
|
513
740
|
bridge.on('connected', onConnected);
|
|
514
741
|
bridge.on('disconnected', onDisconnected);
|
|
515
742
|
bridge.on('event', bridgeEventListener);
|
|
743
|
+
bridge.on('event', onTestRunnerEvent);
|
|
516
744
|
metroInstance.events.addListener(onMetroEvent);
|
|
517
745
|
if (runtimeConfig.forwardClientLogs) {
|
|
518
746
|
metroInstance.events.addListener(clientLogListener);
|
|
519
747
|
}
|
|
520
748
|
|
|
749
|
+
const disposeBridgeDiagnostics = observeBridgeEvents(bridge, diagnostics);
|
|
750
|
+
|
|
521
751
|
sessionLogger.debug('registered runtime, bridge, and Metro listeners');
|
|
522
752
|
|
|
523
753
|
// --- Dispose ---
|
|
@@ -526,6 +756,7 @@ export const createHarnessSession = async (
|
|
|
526
756
|
|
|
527
757
|
const disposeOnce = async (reason: 'normal' | 'abort' | 'error') => {
|
|
528
758
|
sessionLogger.debug('disposing session (reason=%s)', reason);
|
|
759
|
+
const disposeSpan = diagnostics.start('dispose.total', { reason });
|
|
529
760
|
let hookError: unknown;
|
|
530
761
|
|
|
531
762
|
try {
|
|
@@ -557,11 +788,15 @@ export const createHarnessSession = async (
|
|
|
557
788
|
bridge.off('connected', onConnected);
|
|
558
789
|
bridge.off('disconnected', onDisconnected);
|
|
559
790
|
bridge.off('event', bridgeEventListener);
|
|
791
|
+
bridge.off('event', onTestRunnerEvent);
|
|
792
|
+
disposeMetroDiagnostics();
|
|
793
|
+
disposeBridgeDiagnostics();
|
|
560
794
|
|
|
561
795
|
const nativeCoverageConfig = runtimeConfig.coverage?.native?.ios;
|
|
562
796
|
if (nativeCoverageConfig?.pods?.length && platformInstance.collectNativeCoverage) {
|
|
563
797
|
try {
|
|
564
|
-
await
|
|
798
|
+
await crashMonitor.stop();
|
|
799
|
+
await disposeCurrentAppSession();
|
|
565
800
|
const lcovPath = await platformInstance.collectNativeCoverage({
|
|
566
801
|
pods: nativeCoverageConfig.pods,
|
|
567
802
|
outputDir: projectRoot,
|
|
@@ -576,8 +811,13 @@ export const createHarnessSession = async (
|
|
|
576
811
|
|
|
577
812
|
let cleanupError: unknown;
|
|
578
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()]);
|
|
579
820
|
await Promise.all([
|
|
580
|
-
crashMonitor.dispose(),
|
|
581
821
|
bridge.dispose(),
|
|
582
822
|
platformInstance.dispose(),
|
|
583
823
|
metroInstance.dispose(),
|
|
@@ -587,13 +827,20 @@ export const createHarnessSession = async (
|
|
|
587
827
|
cleanupError = error;
|
|
588
828
|
} finally {
|
|
589
829
|
await resourceLease.release();
|
|
590
|
-
|
|
830
|
+
sessionController.abort();
|
|
591
831
|
}
|
|
592
832
|
|
|
593
833
|
sessionLogger.debug('session resources disposed');
|
|
594
834
|
|
|
595
|
-
if (hookError)
|
|
596
|
-
|
|
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();
|
|
597
844
|
};
|
|
598
845
|
|
|
599
846
|
const dispose = (reason: 'normal' | 'abort' | 'error' = 'normal') => {
|
|
@@ -605,7 +852,13 @@ export const createHarnessSession = async (
|
|
|
605
852
|
// that all infrastructure is up.
|
|
606
853
|
process.off('SIGTERM', onEarlySignal);
|
|
607
854
|
process.off('SIGINT', onEarlySignal);
|
|
608
|
-
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
|
+
};
|
|
609
862
|
process.once('SIGTERM', onSignal);
|
|
610
863
|
process.once('SIGINT', onSignal);
|
|
611
864
|
|
|
@@ -614,8 +867,6 @@ export const createHarnessSession = async (
|
|
|
614
867
|
try {
|
|
615
868
|
await pluginManager.callHook('harness:before-creation', { appLaunchOptions });
|
|
616
869
|
await hooks.drain();
|
|
617
|
-
await appMonitor.start();
|
|
618
|
-
sessionLogger.debug('app monitor started');
|
|
619
870
|
await pluginManager.callHook('harness:before-run', { appLaunchOptions });
|
|
620
871
|
await hooks.drain();
|
|
621
872
|
} catch (error) {
|
|
@@ -626,6 +877,10 @@ export const createHarnessSession = async (
|
|
|
626
877
|
}
|
|
627
878
|
|
|
628
879
|
logTestEnvironmentReady(platform);
|
|
880
|
+
diagnostics.record({
|
|
881
|
+
label: 'session.setup',
|
|
882
|
+
duration: Date.now() - setupStartedAt,
|
|
883
|
+
});
|
|
629
884
|
sessionLogger.debug('session ready');
|
|
630
885
|
|
|
631
886
|
// --- Public API ---
|
|
@@ -633,43 +888,124 @@ export const createHarnessSession = async (
|
|
|
633
888
|
const ensureAppReady = async (testFilePath: string): Promise<void> => {
|
|
634
889
|
await hooks.drain();
|
|
635
890
|
sessionLogger.debug('ensuring app is ready for %s', testFilePath);
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
891
|
+
const appReadySpan = diagnostics.start('app.ready.total');
|
|
892
|
+
|
|
893
|
+
if (crashMonitor.isAlive() && bridge.connection !== null && currentAppSession) {
|
|
894
|
+
const state = await currentAppSession.getState();
|
|
895
|
+
if (state.status === 'running') {
|
|
896
|
+
sessionLogger.debug('reusing existing ready app for %s', testFilePath);
|
|
897
|
+
appReadySpan.end({ file: testFilePath, reused: true });
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
640
900
|
}
|
|
641
901
|
|
|
642
902
|
crashMonitor.reset();
|
|
643
903
|
sessionLogger.debug('app not ready, waiting for launch and runtime readiness');
|
|
644
|
-
|
|
904
|
+
try {
|
|
905
|
+
await waitForAppReady(appReadyBaseOptions, testFilePath);
|
|
906
|
+
} catch (error) {
|
|
907
|
+
appReadySpan.fail(error, { file: testFilePath, reused: false });
|
|
908
|
+
throw error;
|
|
909
|
+
}
|
|
645
910
|
await hooks.drain();
|
|
646
911
|
sessionLogger.debug('app is ready for %s', testFilePath);
|
|
912
|
+
appReadySpan.end({ file: testFilePath, reused: false });
|
|
913
|
+
};
|
|
914
|
+
|
|
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> => {
|
|
919
|
+
await crashMonitor.stop();
|
|
920
|
+
await disposeCurrentAppSession();
|
|
921
|
+
|
|
922
|
+
if (testFilePath) {
|
|
923
|
+
await ensureAppReady(testFilePath);
|
|
924
|
+
} else {
|
|
925
|
+
const session = await platformInstance.createAppSession(appLaunchOptions);
|
|
926
|
+
currentAppSession = session;
|
|
927
|
+
crashMonitor.setAppSession(session);
|
|
928
|
+
crashMonitor.reset();
|
|
929
|
+
await crashMonitor.start();
|
|
930
|
+
}
|
|
647
931
|
};
|
|
648
932
|
|
|
649
933
|
const restartApp = async (testFilePath?: string): Promise<void> => {
|
|
650
934
|
await hooks.drain();
|
|
651
|
-
|
|
935
|
+
const reason: 'stop-and-ensure-ready' | 'direct-restart' = testFilePath
|
|
936
|
+
? 'stop-and-ensure-ready'
|
|
937
|
+
: 'direct-restart';
|
|
652
938
|
sessionLogger.debug(
|
|
653
939
|
'restarting app (testFile=%s mode=%s)',
|
|
654
940
|
testFilePath ?? 'n/a',
|
|
655
|
-
|
|
941
|
+
reason,
|
|
656
942
|
);
|
|
943
|
+
const restartSpan = diagnostics.start('app.restart', { reason });
|
|
657
944
|
|
|
658
|
-
|
|
659
|
-
await
|
|
660
|
-
|
|
661
|
-
|
|
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;
|
|
662
953
|
}
|
|
954
|
+
};
|
|
663
955
|
|
|
664
|
-
|
|
665
|
-
|
|
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
|
+
});
|
|
666
968
|
|
|
667
|
-
|
|
668
|
-
|
|
969
|
+
const resetEnvironment = async (testFilePath: string): Promise<void> => {
|
|
970
|
+
const requestedKind = resolveResetStrategyKind(
|
|
971
|
+
runtimeConfig.resetEnvironmentBetweenTestFiles,
|
|
972
|
+
);
|
|
973
|
+
if (requestedKind === null) {
|
|
974
|
+
return;
|
|
669
975
|
}
|
|
670
976
|
|
|
671
977
|
await hooks.drain();
|
|
672
|
-
sessionLogger.debug(
|
|
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
|
+
}
|
|
673
1009
|
};
|
|
674
1010
|
|
|
675
1011
|
const runTestFile = async (
|
|
@@ -681,7 +1017,7 @@ export const createHarnessSession = async (
|
|
|
681
1017
|
if (!conn) throw new Error('No active app connection');
|
|
682
1018
|
sessionLogger.debug('running test file on client: %s', testPath);
|
|
683
1019
|
|
|
684
|
-
if (
|
|
1020
|
+
if (runtimeConfig.detectNativeCrashes === false) {
|
|
685
1021
|
const result = await conn.runTests(testPath, { ...options, runner: platform.runner });
|
|
686
1022
|
await hooks.drain();
|
|
687
1023
|
return result;
|
|
@@ -692,12 +1028,31 @@ export const createHarnessSession = async (
|
|
|
692
1028
|
// crash wins the race or cancel() is called after the test run wins.
|
|
693
1029
|
crashWatch.promise.catch(ignorePromiseRejection);
|
|
694
1030
|
try {
|
|
695
|
-
const
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
1031
|
+
const testRunPromise = conn.runTests(testPath, {
|
|
1032
|
+
...options,
|
|
1033
|
+
runner: platform.runner,
|
|
1034
|
+
});
|
|
1035
|
+
const result = await Promise.race([testRunPromise, crashWatch.promise]);
|
|
1036
|
+
const bridgeDisconnected = await waitForBridgeDisconnectOrTimeout({
|
|
1037
|
+
bridge,
|
|
1038
|
+
connection: conn,
|
|
1039
|
+
timeoutMs: TEST_RUN_BRIDGE_STABILITY_WAIT_MS,
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
if (bridgeDisconnected) {
|
|
1043
|
+
return await crashWatch.promise;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
699
1046
|
await hooks.drain();
|
|
700
1047
|
return result;
|
|
1048
|
+
} catch (error) {
|
|
1049
|
+
if (isBridgeDisconnectError(error)) {
|
|
1050
|
+
const result = await crashWatch.promise;
|
|
1051
|
+
await hooks.drain();
|
|
1052
|
+
return result;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
throw error;
|
|
701
1056
|
} finally {
|
|
702
1057
|
crashWatch.cancel();
|
|
703
1058
|
}
|
|
@@ -706,28 +1061,46 @@ export const createHarnessSession = async (
|
|
|
706
1061
|
return {
|
|
707
1062
|
config: runtimeConfig,
|
|
708
1063
|
context,
|
|
1064
|
+
diagnostics,
|
|
1065
|
+
onTestRunnerEvent: (listener) => {
|
|
1066
|
+
testRunnerEventListeners.add(listener);
|
|
1067
|
+
return () => {
|
|
1068
|
+
testRunnerEventListeners.delete(listener);
|
|
1069
|
+
};
|
|
1070
|
+
},
|
|
709
1071
|
runTestFile,
|
|
710
1072
|
ensureAppReady,
|
|
711
1073
|
restartApp,
|
|
1074
|
+
resetEnvironment,
|
|
712
1075
|
resetCrashState: () => crashMonitor.reset(),
|
|
713
1076
|
flushClientLogs,
|
|
714
1077
|
callHook: async (name, payload) => {
|
|
715
1078
|
await hooks.drain();
|
|
716
|
-
await
|
|
1079
|
+
await diagnostics.measure(
|
|
1080
|
+
'plugin.hook',
|
|
1081
|
+
() => pluginManager.callHook(name, payload),
|
|
1082
|
+
{ hook: String(name) }
|
|
1083
|
+
);
|
|
717
1084
|
await hooks.drain();
|
|
718
1085
|
},
|
|
719
1086
|
setRunState: (state) => {
|
|
720
1087
|
currentRun = state;
|
|
721
1088
|
},
|
|
722
|
-
dispose: (reason = 'normal') => {
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
1089
|
+
dispose: async (reason = 'normal') => {
|
|
1090
|
+
try {
|
|
1091
|
+
await dispose(reason);
|
|
1092
|
+
} finally {
|
|
1093
|
+
process.off('SIGTERM', onSignal);
|
|
1094
|
+
process.off('SIGINT', onSignal);
|
|
1095
|
+
}
|
|
726
1096
|
},
|
|
727
1097
|
};
|
|
728
1098
|
} catch (error) {
|
|
729
1099
|
process.off('SIGTERM', onEarlySignal);
|
|
730
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();
|
|
731
1104
|
await Promise.allSettled([resourceLease.release(), metroPortLease?.release()]);
|
|
732
1105
|
throw error;
|
|
733
1106
|
}
|