@react-native-harness/jest 1.1.0-rc.2 → 1.1.0-rc.4

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.
Files changed (67) hide show
  1. package/dist/__tests__/errors.test.js +6 -1
  2. package/dist/__tests__/harness-cache.test.js +1 -1
  3. package/dist/__tests__/harness.test.js +205 -3
  4. package/dist/__tests__/metro-port.test.d.ts +2 -0
  5. package/dist/__tests__/metro-port.test.d.ts.map +1 -0
  6. package/dist/__tests__/metro-port.test.js +52 -0
  7. package/dist/__tests__/resource-lock.test.d.ts +2 -0
  8. package/dist/__tests__/resource-lock.test.d.ts.map +1 -0
  9. package/dist/__tests__/resource-lock.test.js +138 -0
  10. package/dist/action-hooks.d.ts +9 -0
  11. package/dist/action-hooks.d.ts.map +1 -0
  12. package/dist/action-hooks.js +36 -0
  13. package/dist/cli-args.test.d.ts +2 -0
  14. package/dist/cli-args.test.d.ts.map +1 -0
  15. package/dist/cli-args.test.js +1 -0
  16. package/dist/crash-monitor.d.ts +16 -0
  17. package/dist/crash-monitor.d.ts.map +1 -0
  18. package/dist/crash-monitor.js +75 -0
  19. package/dist/errors.d.ts +9 -0
  20. package/dist/errors.d.ts.map +1 -1
  21. package/dist/errors.js +20 -2
  22. package/dist/event-mapper.d.ts +16 -0
  23. package/dist/event-mapper.d.ts.map +1 -0
  24. package/dist/event-mapper.js +120 -0
  25. package/dist/global-setup.d.ts +3 -0
  26. package/dist/global-setup.d.ts.map +1 -0
  27. package/dist/global-setup.js +60 -0
  28. package/dist/global-teardown.d.ts +3 -0
  29. package/dist/global-teardown.d.ts.map +1 -0
  30. package/dist/global-teardown.js +11 -0
  31. package/dist/harness-reporter.d.ts +8 -0
  32. package/dist/harness-reporter.d.ts.map +1 -0
  33. package/dist/harness-reporter.js +12 -0
  34. package/dist/harness.d.ts +1 -0
  35. package/dist/harness.d.ts.map +1 -1
  36. package/dist/harness.js +491 -386
  37. package/dist/jest-options.d.ts +2 -0
  38. package/dist/jest-options.d.ts.map +1 -0
  39. package/dist/jest-options.js +1 -0
  40. package/dist/logs.d.ts +4 -0
  41. package/dist/logs.d.ts.map +1 -1
  42. package/dist/logs.js +12 -0
  43. package/dist/metro-port.d.ts +16 -0
  44. package/dist/metro-port.d.ts.map +1 -0
  45. package/dist/metro-port.js +48 -0
  46. package/dist/resource-lock.d.ts +25 -0
  47. package/dist/resource-lock.d.ts.map +1 -0
  48. package/dist/resource-lock.js +299 -0
  49. package/dist/setup.d.ts.map +1 -1
  50. package/dist/setup.js +12 -1
  51. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  52. package/dist/types.d.ts +17 -0
  53. package/dist/types.d.ts.map +1 -0
  54. package/dist/types.js +1 -0
  55. package/package.json +7 -7
  56. package/src/__tests__/errors.test.ts +18 -6
  57. package/src/__tests__/harness-cache.test.ts +2 -2
  58. package/src/__tests__/harness.test.ts +342 -19
  59. package/src/__tests__/metro-port.test.ts +61 -0
  60. package/src/__tests__/resource-lock.test.ts +192 -0
  61. package/src/action-hooks.ts +66 -0
  62. package/src/errors.ts +23 -4
  63. package/src/harness.ts +609 -447
  64. package/src/logs.ts +24 -3
  65. package/src/metro-port.ts +76 -0
  66. package/src/resource-lock.ts +472 -0
  67. package/src/setup.ts +22 -2
package/dist/harness.js CHANGED
@@ -1,20 +1,24 @@
1
1
  import { getBridgeServer, } from '@react-native-harness/bridge/server';
2
+ import { HARNESS_BRIDGE_PATH, } from '@react-native-harness/bridge';
2
3
  import { getMetroInstance, isMetroCacheReusable, waitForMetroBackedAppReady, } from '@react-native-harness/bundler-metro';
3
4
  import { createHarnessPluginManager, } from '@react-native-harness/plugins';
4
- import { logger, createCrashArtifactWriter } from '@react-native-harness/tools';
5
- import { InitializationTimeoutError } from './errors.js';
5
+ import { logger, createCrashArtifactWriter, getTimeoutSignal, raceAbortSignals, } from '@react-native-harness/tools';
6
+ import { PlatformReadyTimeoutError } from './errors.js';
6
7
  import { createCrashSupervisor, } from './crash-supervisor.js';
7
8
  import { createClientLogListener } from './client-log-handler.js';
8
9
  import path from 'node:path';
9
- import { logMetroCacheReused } from './logs.js';
10
+ import { logMetroCacheReused, logMetroPortFallback, logRunnerStarting, logRunnerStillWaitingInQueue, logRunnerWaitingInQueue, } from './logs.js';
11
+ import { createResourceLockManager } from './resource-lock.js';
12
+ import { resolveHarnessMetroPort } from './metro-port.js';
10
13
  const harnessLogger = logger.child('runtime');
14
+ const resourceLockManager = createResourceLockManager();
11
15
  export const maybeLogMetroCacheReuse = (config, platform, projectRoot) => {
12
- if (config.unstable__enableMetroCache &&
13
- isMetroCacheReusable(projectRoot)) {
16
+ if (config.unstable__enableMetroCache && isMetroCacheReusable(projectRoot)) {
14
17
  logMetroCacheReused(platform);
15
18
  }
16
19
  };
17
20
  const createAbortError = () => new DOMException('The operation was aborted', 'AbortError');
21
+ const getDefaultResourceLockKey = (platform) => `${platform.platformId}:${platform.name}`;
18
22
  const waitForAbort = (signal) => {
19
23
  if (signal.aborted) {
20
24
  return Promise.reject(signal.reason ?? createAbortError());
@@ -25,6 +29,22 @@ const waitForAbort = (signal) => {
25
29
  }, { once: true });
26
30
  });
27
31
  };
32
+ const withPlatformReadyTimeout = async (options) => {
33
+ const timeoutSignal = getTimeoutSignal(options.timeout);
34
+ const combinedSignal = raceAbortSignals([options.signal, timeoutSignal]);
35
+ try {
36
+ return await options.work(combinedSignal);
37
+ }
38
+ catch (error) {
39
+ if (error instanceof DOMException &&
40
+ error.name === 'AbortError' &&
41
+ timeoutSignal.aborted &&
42
+ !options.signal.aborted) {
43
+ throw new PlatformReadyTimeoutError(options.timeout);
44
+ }
45
+ throw error;
46
+ }
47
+ };
28
48
  export const waitForAppReady = async (options) => {
29
49
  const { metroInstance, serverBridge, platformInstance, platformId, bundleStartTimeout, readyTimeout, maxAppRestarts, testFilePath, crashSupervisor, appLaunchOptions, launchApp = () => platformInstance.restartApp(appLaunchOptions), } = options;
30
50
  const signal = options.signal ?? new AbortController().signal;
@@ -93,417 +113,502 @@ const getHarnessInternal = async (config, platform, projectRoot, signal) => {
93
113
  platform,
94
114
  };
95
115
  harnessLogger.debug('creating Harness internals for runner=%s platform=%s', platform.name, platform.platformId);
96
- maybeLogMetroCacheReuse(config, platform, projectRoot);
97
- const pluginAbortController = new AbortController();
98
- const pluginManager = createHarnessPluginManager({
99
- plugins: (config.plugins ?? []),
100
- projectRoot,
101
- config,
102
- runner: platform,
103
- abortSignal: pluginAbortController.signal,
116
+ const resourceLockKey = await (platform.getResourceLockKey?.() ??
117
+ getDefaultResourceLockKey(platform));
118
+ let didWaitForResourceLock = false;
119
+ let lastStillWaitingLogAt = 0;
120
+ const resourceLease = await resourceLockManager.acquire(resourceLockKey, {
121
+ signal,
122
+ onWait: () => {
123
+ didWaitForResourceLock = true;
124
+ logRunnerWaitingInQueue(platform);
125
+ harnessLogger.debug('waiting in queue for runner=%s key=%s', platform.name, resourceLockKey);
126
+ },
127
+ onStillWaiting: (elapsedMs) => {
128
+ if (elapsedMs - lastStillWaitingLogAt < 5000) {
129
+ return;
130
+ }
131
+ lastStillWaitingLogAt = elapsedMs;
132
+ logRunnerStillWaitingInQueue(platform);
133
+ harnessLogger.debug('still waiting in queue for runner=%s key=%s elapsedMs=%d', platform.name, resourceLockKey, elapsedMs);
134
+ },
104
135
  });
105
- let currentRun = null;
106
- let activeTestFilePath;
107
- const pendingHookPromises = new Set();
108
- let pendingHookError;
109
- const getCurrentRunId = () => currentRun?.runId;
110
- const toRelativeTestFilePath = (testFilePath) => testFilePath == null ? undefined : path.relative(projectRoot, testFilePath);
111
- const setActiveTestFilePath = (testFilePath) => {
112
- activeTestFilePath = toRelativeTestFilePath(testFilePath);
113
- };
114
- const flushPendingHooks = async () => {
115
- if (pendingHookPromises.size > 0) {
116
- await Promise.allSettled([...pendingHookPromises]);
117
- }
118
- if (pendingHookError !== undefined) {
119
- const error = pendingHookError;
120
- pendingHookError = undefined;
121
- throw error;
136
+ if (didWaitForResourceLock) {
137
+ logRunnerStarting(platform);
138
+ }
139
+ harnessLogger.debug('resource lock acquired for runner=%s key=%s', platform.name, resourceLockKey);
140
+ try {
141
+ const { config: runtimeConfig, metroPortLease, initialMetroPort, didFallback, } = await resolveHarnessMetroPort({
142
+ config,
143
+ platform,
144
+ resourceLockManager,
145
+ signal,
146
+ });
147
+ if (didFallback) {
148
+ logMetroPortFallback(initialMetroPort, runtimeConfig.metroPort);
122
149
  }
123
- };
124
- const trackHook = (promise) => {
125
- const trackedPromise = promise
126
- .catch((error) => {
127
- pendingHookError ??= error;
128
- })
129
- .finally(() => {
130
- pendingHookPromises.delete(trackedPromise);
150
+ maybeLogMetroCacheReuse(runtimeConfig, platform, projectRoot);
151
+ const pluginAbortController = new AbortController();
152
+ const pluginManager = createHarnessPluginManager({
153
+ plugins: (runtimeConfig.plugins ?? []),
154
+ projectRoot,
155
+ config: runtimeConfig,
156
+ runner: platform,
157
+ abortSignal: pluginAbortController.signal,
131
158
  });
132
- pendingHookPromises.add(trackedPromise);
133
- };
134
- const scheduleHook = (name, payload) => {
135
- trackHook(pluginManager.callHook(name, payload));
136
- };
137
- harnessLogger.debug('starting Metro, platform runner, and bridge initialization');
138
- const [metroInstance, platformInstance, serverBridge] = await Promise.all([
139
- getMetroInstance({ projectRoot, harnessConfig: config }, signal).then((instance) => {
140
- harnessLogger.debug('Metro initialized');
141
- return instance;
142
- }),
143
- import(platform.runner)
144
- .then((module) => module.default(platform.config, config))
145
- .then((instance) => {
146
- harnessLogger.debug('platform runner initialized');
147
- return instance;
148
- }),
149
- getBridgeServer({
150
- port: config.webSocketPort,
151
- timeout: config.bridgeTimeout,
159
+ let currentRun = null;
160
+ let activeTestFilePath;
161
+ const pendingHookPromises = new Set();
162
+ let pendingHookError;
163
+ const getCurrentRunId = () => currentRun?.runId;
164
+ const toRelativeTestFilePath = (testFilePath) => testFilePath == null
165
+ ? undefined
166
+ : path.relative(projectRoot, testFilePath);
167
+ const setActiveTestFilePath = (testFilePath) => {
168
+ activeTestFilePath = toRelativeTestFilePath(testFilePath);
169
+ };
170
+ const flushPendingHooks = async () => {
171
+ if (pendingHookPromises.size > 0) {
172
+ await Promise.allSettled([...pendingHookPromises]);
173
+ }
174
+ if (pendingHookError !== undefined) {
175
+ const error = pendingHookError;
176
+ pendingHookError = undefined;
177
+ throw error;
178
+ }
179
+ };
180
+ const trackHook = (promise) => {
181
+ const trackedPromise = promise
182
+ .catch((error) => {
183
+ pendingHookError ??= error;
184
+ })
185
+ .finally(() => {
186
+ pendingHookPromises.delete(trackedPromise);
187
+ });
188
+ pendingHookPromises.add(trackedPromise);
189
+ };
190
+ const scheduleHook = (name, payload) => {
191
+ trackHook(pluginManager.callHook(name, payload));
192
+ };
193
+ const serverBridge = await getBridgeServer({
194
+ noServer: true,
195
+ timeout: runtimeConfig.bridgeTimeout,
152
196
  context,
153
- }).then((bridge) => {
154
- harnessLogger.debug('bridge server initialized on port %d', config.webSocketPort);
155
- return bridge;
156
- }),
157
- ]);
158
- const crashArtifactWriter = createCrashArtifactWriter({
159
- runnerName: platform.name,
160
- platformId: platform.platformId,
161
- });
162
- const appMonitor = platformInstance.createAppMonitor({
163
- crashArtifactWriter,
164
- });
165
- const appLaunchOptions = platform.config.appLaunchOptions;
166
- const clientLogListener = createClientLogListener();
167
- const bridgeEventListener = (event) => {
168
- const runId = getCurrentRunId();
169
- if (!runId) {
170
- return;
171
- }
172
- switch (event.type) {
173
- case 'collection-started':
174
- scheduleHook('collection:started', {
175
- runId,
176
- file: event.file,
177
- });
178
- break;
179
- case 'collection-finished':
180
- scheduleHook('collection:finished', {
181
- runId,
182
- file: event.file,
183
- duration: event.duration,
184
- totalTests: event.totalTests,
185
- });
186
- break;
187
- case 'suite-started':
188
- scheduleHook('suite:started', {
189
- runId,
190
- file: event.file,
191
- name: event.name,
192
- });
193
- break;
194
- case 'suite-finished':
195
- scheduleHook('suite:finished', {
196
- runId,
197
- file: event.file,
198
- name: event.name,
199
- duration: event.duration,
200
- status: event.status,
201
- error: event.error,
202
- });
203
- break;
204
- case 'test-started':
205
- scheduleHook('test:started', {
206
- runId,
207
- file: event.file,
208
- suite: event.suite,
209
- name: event.name,
210
- });
211
- break;
212
- case 'test-finished':
213
- scheduleHook('test:finished', {
214
- runId,
215
- file: event.file,
216
- suite: event.suite,
217
- name: event.name,
218
- duration: event.duration,
219
- status: event.status,
220
- error: event.error,
221
- });
222
- break;
223
- case 'module-bundling-started':
224
- scheduleHook('metro:bundle-started', {
197
+ });
198
+ harnessLogger.debug('starting Metro, platform runner, and bridge initialization');
199
+ harnessLogger.debug('bridge server initialized on Metro websocket path %s', HARNESS_BRIDGE_PATH);
200
+ const [metroInstance, platformInstance] = await (async () => {
201
+ try {
202
+ return await Promise.all([
203
+ getMetroInstance({
204
+ projectRoot,
205
+ harnessConfig: runtimeConfig,
206
+ websocketEndpoints: {
207
+ [HARNESS_BRIDGE_PATH]: serverBridge.ws,
208
+ },
209
+ }, signal).then((instance) => {
210
+ harnessLogger.debug('Metro initialized');
211
+ return instance;
212
+ }),
213
+ withPlatformReadyTimeout({
214
+ timeout: runtimeConfig.platformReadyTimeout,
215
+ signal,
216
+ work: async () => {
217
+ return await import(platform.runner)
218
+ .then((module) => module.default(platform.config, runtimeConfig, {
219
+ signal,
220
+ }))
221
+ .then((instance) => {
222
+ harnessLogger.debug('platform runner initialized');
223
+ return instance;
224
+ });
225
+ },
226
+ }),
227
+ ]);
228
+ }
229
+ catch (error) {
230
+ await Promise.allSettled([
231
+ resourceLease.release(),
232
+ metroPortLease?.release(),
233
+ serverBridge.dispose(),
234
+ ]);
235
+ throw error;
236
+ }
237
+ })();
238
+ const crashArtifactWriter = createCrashArtifactWriter({
239
+ runnerName: platform.name,
240
+ platformId: platform.platformId,
241
+ });
242
+ const appMonitor = platformInstance.createAppMonitor({
243
+ crashArtifactWriter,
244
+ });
245
+ const appLaunchOptions = platform.config.appLaunchOptions;
246
+ const clientLogListener = createClientLogListener();
247
+ const bridgeEventListener = (event) => {
248
+ const runId = getCurrentRunId();
249
+ if (!runId) {
250
+ return;
251
+ }
252
+ switch (event.type) {
253
+ case 'collection-started':
254
+ scheduleHook('collection:started', {
255
+ runId,
256
+ file: event.file,
257
+ });
258
+ break;
259
+ case 'collection-finished':
260
+ scheduleHook('collection:finished', {
261
+ runId,
262
+ file: event.file,
263
+ duration: event.duration,
264
+ totalTests: event.totalTests,
265
+ });
266
+ break;
267
+ case 'suite-started':
268
+ scheduleHook('suite:started', {
269
+ runId,
270
+ file: event.file,
271
+ name: event.name,
272
+ });
273
+ break;
274
+ case 'suite-finished':
275
+ scheduleHook('suite:finished', {
276
+ runId,
277
+ file: event.file,
278
+ name: event.name,
279
+ duration: event.duration,
280
+ status: event.status,
281
+ error: event.error,
282
+ });
283
+ break;
284
+ case 'test-started':
285
+ scheduleHook('test:started', {
286
+ runId,
287
+ file: event.file,
288
+ suite: event.suite,
289
+ name: event.name,
290
+ });
291
+ break;
292
+ case 'test-finished':
293
+ scheduleHook('test:finished', {
294
+ runId,
295
+ file: event.file,
296
+ suite: event.suite,
297
+ name: event.name,
298
+ duration: event.duration,
299
+ status: event.status,
300
+ error: event.error,
301
+ });
302
+ break;
303
+ case 'module-bundling-started':
304
+ scheduleHook('metro:bundle-started', {
305
+ runId,
306
+ target: 'module',
307
+ file: event.file,
308
+ });
309
+ break;
310
+ case 'module-bundling-finished':
311
+ scheduleHook('metro:bundle-finished', {
312
+ runId,
313
+ target: 'module',
314
+ file: event.file,
315
+ duration: event.duration,
316
+ });
317
+ break;
318
+ case 'module-bundling-failed':
319
+ scheduleHook('metro:bundle-failed', {
320
+ runId,
321
+ target: 'module',
322
+ file: event.file,
323
+ duration: event.duration,
324
+ error: event.error,
325
+ });
326
+ break;
327
+ case 'setup-file-bundling-started':
328
+ scheduleHook('metro:bundle-started', {
329
+ runId,
330
+ target: 'setupFile',
331
+ file: event.file,
332
+ setupType: event.setupType,
333
+ });
334
+ break;
335
+ case 'setup-file-bundling-finished':
336
+ scheduleHook('metro:bundle-finished', {
337
+ runId,
338
+ target: 'setupFile',
339
+ file: event.file,
340
+ setupType: event.setupType,
341
+ duration: event.duration,
342
+ });
343
+ break;
344
+ case 'setup-file-bundling-failed':
345
+ scheduleHook('metro:bundle-failed', {
346
+ runId,
347
+ target: 'setupFile',
348
+ file: event.file,
349
+ setupType: event.setupType,
350
+ duration: event.duration,
351
+ error: event.error,
352
+ });
353
+ break;
354
+ }
355
+ };
356
+ const onMetroEvent = (event) => {
357
+ const runId = getCurrentRunId();
358
+ if (runId && event.type === 'client_log') {
359
+ scheduleHook('metro:client-log', {
225
360
  runId,
226
- target: 'module',
227
- file: event.file,
361
+ level: event.level,
362
+ data: event.data,
228
363
  });
229
- break;
230
- case 'module-bundling-finished':
231
- scheduleHook('metro:bundle-finished', {
364
+ }
365
+ };
366
+ const crashSupervisor = createCrashSupervisor({
367
+ appMonitor,
368
+ platformRunner: platformInstance,
369
+ });
370
+ const onReady = (device) => {
371
+ crashSupervisor.markReady();
372
+ const runId = getCurrentRunId();
373
+ if (!runId) {
374
+ return;
375
+ }
376
+ scheduleHook('runtime:ready', {
377
+ runId,
378
+ device,
379
+ });
380
+ };
381
+ const onDisconnect = () => {
382
+ const runId = getCurrentRunId();
383
+ if (!runId) {
384
+ return;
385
+ }
386
+ scheduleHook('runtime:disconnected', {
387
+ runId,
388
+ reason: 'bridge-disconnected',
389
+ });
390
+ };
391
+ const onAppMonitorEvent = (event) => {
392
+ const runId = getCurrentRunId();
393
+ if (!runId) {
394
+ return;
395
+ }
396
+ if (event.type === 'app_started') {
397
+ scheduleHook('app:started', {
232
398
  runId,
233
- target: 'module',
234
- file: event.file,
235
- duration: event.duration,
399
+ testFile: activeTestFilePath,
400
+ pid: event.pid,
401
+ source: event.source,
402
+ line: event.line,
236
403
  });
237
- break;
238
- case 'module-bundling-failed':
239
- scheduleHook('metro:bundle-failed', {
404
+ return;
405
+ }
406
+ if (event.type === 'app_exited') {
407
+ scheduleHook('app:exited', {
240
408
  runId,
241
- target: 'module',
242
- file: event.file,
243
- duration: event.duration,
244
- error: event.error,
409
+ testFile: activeTestFilePath,
410
+ pid: event.pid,
411
+ source: event.source,
412
+ line: event.line,
413
+ isConfirmed: event.isConfirmed,
414
+ crashDetails: event.crashDetails,
245
415
  });
246
- break;
247
- case 'setup-file-bundling-started':
248
- scheduleHook('metro:bundle-started', {
416
+ return;
417
+ }
418
+ if (event.type === 'possible_crash') {
419
+ scheduleHook('app:possible-crash', {
249
420
  runId,
250
- target: 'setupFile',
251
- file: event.file,
252
- setupType: event.setupType,
421
+ testFile: activeTestFilePath,
422
+ pid: event.pid,
423
+ source: event.source,
424
+ line: event.line,
425
+ isConfirmed: event.isConfirmed,
426
+ crashDetails: event.crashDetails,
253
427
  });
254
- break;
255
- case 'setup-file-bundling-finished':
256
- scheduleHook('metro:bundle-finished', {
257
- runId,
258
- target: 'setupFile',
259
- file: event.file,
260
- setupType: event.setupType,
261
- duration: event.duration,
428
+ }
429
+ };
430
+ serverBridge.on('ready', onReady);
431
+ serverBridge.on('disconnect', onDisconnect);
432
+ serverBridge.on('event', bridgeEventListener);
433
+ metroInstance.events.addListener(onMetroEvent);
434
+ appMonitor.addListener(onAppMonitorEvent);
435
+ harnessLogger.debug('registered runtime, bridge, and Metro listeners');
436
+ if (runtimeConfig.forwardClientLogs) {
437
+ metroInstance.events.addListener(clientLogListener);
438
+ harnessLogger.debug('client log forwarding enabled');
439
+ }
440
+ const dispose = async (reason = 'normal') => {
441
+ harnessLogger.debug('disposing Harness (reason=%s)', reason);
442
+ let hookError;
443
+ try {
444
+ await flushPendingHooks();
445
+ await pluginManager.callHook('harness:after-run', {
446
+ runId: currentRun?.runId,
447
+ reason,
448
+ summary: currentRun?.summary,
449
+ status: currentRun?.status,
450
+ error: currentRun?.error,
262
451
  });
263
- break;
264
- case 'setup-file-bundling-failed':
265
- scheduleHook('metro:bundle-failed', {
266
- runId,
267
- target: 'setupFile',
268
- file: event.file,
269
- setupType: event.setupType,
270
- duration: event.duration,
271
- error: event.error,
452
+ await flushPendingHooks();
453
+ await pluginManager.callHook('harness:before-dispose', {
454
+ runId: currentRun?.runId,
455
+ reason,
456
+ summary: currentRun?.summary,
457
+ status: currentRun?.status,
458
+ error: currentRun?.error,
272
459
  });
273
- break;
274
- }
275
- };
276
- const onMetroEvent = (event) => {
277
- const runId = getCurrentRunId();
278
- if (runId && event.type === 'client_log') {
279
- scheduleHook('metro:client-log', {
280
- runId,
281
- level: event.level,
282
- data: event.data,
283
- });
284
- }
285
- };
286
- const crashSupervisor = createCrashSupervisor({
287
- appMonitor,
288
- platformRunner: platformInstance,
289
- });
290
- const onReady = (device) => {
291
- crashSupervisor.markReady();
292
- const runId = getCurrentRunId();
293
- if (!runId) {
294
- return;
295
- }
296
- scheduleHook('runtime:ready', {
297
- runId,
298
- device,
299
- });
300
- };
301
- const onDisconnect = () => {
302
- const runId = getCurrentRunId();
303
- if (!runId) {
304
- return;
305
- }
306
- scheduleHook('runtime:disconnected', {
307
- runId,
308
- reason: 'bridge-disconnected',
309
- });
310
- };
311
- const onAppMonitorEvent = (event) => {
312
- const runId = getCurrentRunId();
313
- if (!runId) {
314
- return;
315
- }
316
- if (event.type === 'app_started') {
317
- scheduleHook('app:started', {
318
- runId,
319
- testFile: activeTestFilePath,
320
- pid: event.pid,
321
- source: event.source,
322
- line: event.line,
323
- });
324
- return;
325
- }
326
- if (event.type === 'app_exited') {
327
- scheduleHook('app:exited', {
328
- runId,
329
- testFile: activeTestFilePath,
330
- pid: event.pid,
331
- source: event.source,
332
- line: event.line,
333
- isConfirmed: event.isConfirmed,
334
- crashDetails: event.crashDetails,
335
- });
336
- return;
337
- }
338
- if (event.type === 'possible_crash') {
339
- scheduleHook('app:possible-crash', {
340
- runId,
341
- testFile: activeTestFilePath,
342
- pid: event.pid,
343
- source: event.source,
344
- line: event.line,
345
- isConfirmed: event.isConfirmed,
346
- crashDetails: event.crashDetails,
347
- });
460
+ await flushPendingHooks();
461
+ }
462
+ catch (error) {
463
+ hookError = error;
464
+ }
465
+ if (runtimeConfig.forwardClientLogs) {
466
+ metroInstance.events.removeListener(clientLogListener);
467
+ }
468
+ metroInstance.events.removeListener(onMetroEvent);
469
+ appMonitor.removeListener(onAppMonitorEvent);
470
+ serverBridge.off('ready', onReady);
471
+ serverBridge.off('disconnect', onDisconnect);
472
+ serverBridge.off('event', bridgeEventListener);
473
+ let cleanupError;
474
+ try {
475
+ await Promise.all([
476
+ crashSupervisor.dispose(),
477
+ serverBridge.dispose(),
478
+ platformInstance.dispose(),
479
+ metroInstance.dispose(),
480
+ metroPortLease?.release(),
481
+ ]);
482
+ }
483
+ catch (error) {
484
+ cleanupError = error;
485
+ }
486
+ finally {
487
+ await resourceLease.release();
488
+ pluginAbortController.abort();
489
+ }
490
+ harnessLogger.debug('Harness resources disposed');
491
+ if (hookError) {
492
+ throw hookError;
493
+ }
494
+ if (cleanupError) {
495
+ throw cleanupError;
496
+ }
497
+ };
498
+ if (signal.aborted) {
499
+ await dispose('abort');
500
+ throw new DOMException('The operation was aborted', 'AbortError');
348
501
  }
349
- };
350
- serverBridge.on('ready', onReady);
351
- serverBridge.on('disconnect', onDisconnect);
352
- serverBridge.on('event', bridgeEventListener);
353
- metroInstance.events.addListener(onMetroEvent);
354
- appMonitor.addListener(onAppMonitorEvent);
355
- harnessLogger.debug('registered runtime, bridge, and Metro listeners');
356
- if (config.forwardClientLogs) {
357
- metroInstance.events.addListener(clientLogListener);
358
- harnessLogger.debug('client log forwarding enabled');
359
- }
360
- const dispose = async (reason = 'normal') => {
361
- harnessLogger.debug('disposing Harness (reason=%s)', reason);
362
- let hookError;
363
502
  try {
503
+ await pluginManager.callHook('harness:before-creation', {
504
+ appLaunchOptions,
505
+ });
364
506
  await flushPendingHooks();
365
- await pluginManager.callHook('harness:before-dispose', {
366
- runId: currentRun?.runId,
367
- reason,
368
- summary: currentRun?.summary,
369
- status: currentRun?.status,
370
- error: currentRun?.error,
507
+ await appMonitor.start();
508
+ harnessLogger.debug('app monitor started');
509
+ await pluginManager.callHook('harness:before-run', {
510
+ appLaunchOptions,
371
511
  });
372
512
  await flushPendingHooks();
373
513
  }
374
514
  catch (error) {
375
- hookError = error;
376
- }
377
- if (config.forwardClientLogs) {
378
- metroInstance.events.removeListener(clientLogListener);
379
- }
380
- metroInstance.events.removeListener(onMetroEvent);
381
- appMonitor.removeListener(onAppMonitorEvent);
382
- serverBridge.off('ready', onReady);
383
- serverBridge.off('disconnect', onDisconnect);
384
- serverBridge.off('event', bridgeEventListener);
385
- await Promise.all([
386
- crashSupervisor.dispose(),
387
- serverBridge.dispose(),
388
- platformInstance.dispose(),
389
- metroInstance.dispose(),
390
- ]);
391
- pluginAbortController.abort();
392
- harnessLogger.debug('Harness resources disposed');
393
- if (hookError) {
394
- throw hookError;
395
- }
396
- };
397
- if (signal.aborted) {
398
- await dispose('abort');
399
- throw new DOMException('The operation was aborted', 'AbortError');
400
- }
401
- try {
402
- await pluginManager.callHook('harness:before-creation', {
403
- appLaunchOptions,
404
- });
405
- await appMonitor.start();
406
- harnessLogger.debug('app monitor started');
407
- }
408
- catch (error) {
409
- const runState = currentRun;
410
- if (runState) {
411
- runState.error = error;
412
- currentRun = runState;
413
- }
414
- await dispose(error instanceof DOMException && error.name === 'AbortError' ? 'abort' : 'error');
415
- throw error;
416
- }
417
- const ensureAppReady = async (testFilePath) => {
418
- await flushPendingHooks();
419
- setActiveTestFilePath(testFilePath);
420
- crashSupervisor.setActiveTestFile(testFilePath);
421
- harnessLogger.debug('ensuring app is ready for %s', testFilePath);
422
- if (crashSupervisor.isReady() && (await platformInstance.isAppRunning())) {
423
- harnessLogger.debug('reusing existing ready app for %s', testFilePath);
424
- return;
425
- }
426
- crashSupervisor.reset();
427
- harnessLogger.debug('app not ready, waiting for launch and runtime readiness');
428
- await waitForAppReady({
429
- metroInstance,
430
- serverBridge,
431
- platformInstance: platformInstance,
432
- platformId: platform.platformId,
433
- bundleStartTimeout: config.bundleStartTimeout ?? 60000,
434
- readyTimeout: config.bridgeTimeout,
435
- maxAppRestarts: config.maxAppRestarts ?? 2,
436
- testFilePath,
437
- crashSupervisor,
438
- appLaunchOptions,
439
- });
440
- await flushPendingHooks();
441
- harnessLogger.debug('app is ready for %s', testFilePath);
442
- };
443
- const restart = async (testFilePath) => {
444
- await flushPendingHooks();
445
- await crashSupervisor.stop();
446
- setActiveTestFilePath(testFilePath);
447
- harnessLogger.debug('restarting app (testFile=%s mode=%s)', testFilePath ?? 'n/a', testFilePath ? 'stop-and-ensure-ready' : 'direct-restart');
448
- if (testFilePath) {
449
- harnessLogger.debug('stopping app before restart');
450
- await platformInstance.stopApp();
451
- }
452
- else {
453
- harnessLogger.debug('requesting direct app restart');
454
- await platformInstance.restartApp(appLaunchOptions);
455
- }
456
- crashSupervisor.reset();
457
- await crashSupervisor.start();
458
- if (testFilePath) {
459
- await ensureAppReady(testFilePath);
515
+ const runState = currentRun;
516
+ if (runState) {
517
+ runState.error = error;
518
+ currentRun = runState;
519
+ }
520
+ await dispose(error instanceof DOMException && error.name === 'AbortError'
521
+ ? 'abort'
522
+ : 'error');
523
+ throw error;
460
524
  }
461
- await flushPendingHooks();
462
- harnessLogger.debug('restart completed');
463
- };
464
- return {
465
- context,
466
- runTests: async (path, options) => {
525
+ const ensureAppReady = async (testFilePath) => {
467
526
  await flushPendingHooks();
468
- activeTestFilePath = path;
469
- const client = serverBridge.rpc.clients.at(-1);
470
- if (!client) {
471
- throw new Error('No client found');
527
+ setActiveTestFilePath(testFilePath);
528
+ crashSupervisor.setActiveTestFile(testFilePath);
529
+ harnessLogger.debug('ensuring app is ready for %s', testFilePath);
530
+ if (crashSupervisor.isReady() &&
531
+ (await platformInstance.isAppRunning())) {
532
+ harnessLogger.debug('reusing existing ready app for %s', testFilePath);
533
+ return;
472
534
  }
473
- harnessLogger.debug('running test file on client: %s', path);
474
- const result = await client.runTests(path, {
475
- ...options,
476
- runner: platform.runner,
535
+ crashSupervisor.reset();
536
+ harnessLogger.debug('app not ready, waiting for launch and runtime readiness');
537
+ await waitForAppReady({
538
+ metroInstance,
539
+ serverBridge,
540
+ platformInstance: platformInstance,
541
+ platformId: platform.platformId,
542
+ bundleStartTimeout: runtimeConfig.bundleStartTimeout ?? 60000,
543
+ readyTimeout: runtimeConfig.bridgeTimeout,
544
+ maxAppRestarts: runtimeConfig.maxAppRestarts ?? 2,
545
+ testFilePath,
546
+ crashSupervisor,
547
+ appLaunchOptions,
477
548
  });
478
549
  await flushPendingHooks();
479
- return result;
480
- },
481
- ensureAppReady,
482
- restart,
483
- dispose: () => dispose('normal'),
484
- crashSupervisor,
485
- callHook: async (name, payload) => {
550
+ harnessLogger.debug('app is ready for %s', testFilePath);
551
+ };
552
+ const restart = async (testFilePath) => {
486
553
  await flushPendingHooks();
487
- await pluginManager.callHook(name, payload);
554
+ await crashSupervisor.stop();
555
+ setActiveTestFilePath(testFilePath);
556
+ harnessLogger.debug('restarting app (testFile=%s mode=%s)', testFilePath ?? 'n/a', testFilePath ? 'stop-and-ensure-ready' : 'direct-restart');
557
+ if (testFilePath) {
558
+ harnessLogger.debug('stopping app before restart');
559
+ await platformInstance.stopApp();
560
+ }
561
+ else {
562
+ harnessLogger.debug('requesting direct app restart');
563
+ await platformInstance.restartApp(appLaunchOptions);
564
+ }
565
+ crashSupervisor.reset();
566
+ await crashSupervisor.start();
567
+ if (testFilePath) {
568
+ await ensureAppReady(testFilePath);
569
+ }
488
570
  await flushPendingHooks();
489
- },
490
- setRunState: (runState) => {
491
- currentRun = runState;
492
- },
493
- getRunState: () => currentRun,
494
- };
495
- };
496
- export const getHarness = async (config, platform, projectRoot) => {
497
- const abortSignal = AbortSignal.timeout(config.bridgeTimeout);
498
- harnessLogger.debug('creating Harness with bridge timeout %dms', config.bridgeTimeout);
499
- try {
500
- const harness = await getHarnessInternal(config, platform, projectRoot, abortSignal);
501
- return harness;
571
+ harnessLogger.debug('restart completed');
572
+ };
573
+ return {
574
+ context,
575
+ config: runtimeConfig,
576
+ runTests: async (path, options) => {
577
+ await flushPendingHooks();
578
+ activeTestFilePath = path;
579
+ const client = serverBridge.rpc.clients.at(-1);
580
+ if (!client) {
581
+ throw new Error('No client found');
582
+ }
583
+ harnessLogger.debug('running test file on client: %s', path);
584
+ const result = await client.runTests(path, {
585
+ ...options,
586
+ runner: platform.runner,
587
+ });
588
+ await flushPendingHooks();
589
+ return result;
590
+ },
591
+ ensureAppReady,
592
+ restart,
593
+ dispose: () => dispose('normal'),
594
+ crashSupervisor,
595
+ callHook: async (name, payload) => {
596
+ await flushPendingHooks();
597
+ await pluginManager.callHook(name, payload);
598
+ await flushPendingHooks();
599
+ },
600
+ setRunState: (runState) => {
601
+ currentRun = runState;
602
+ },
603
+ getRunState: () => currentRun,
604
+ };
502
605
  }
503
606
  catch (error) {
504
- if (error instanceof DOMException && error.name === 'AbortError') {
505
- throw new InitializationTimeoutError();
506
- }
607
+ await resourceLease.release();
507
608
  throw error;
508
609
  }
509
610
  };
611
+ export const getHarness = async (config, platform, projectRoot) => {
612
+ harnessLogger.debug('creating Harness with platform ready timeout %dms', config.platformReadyTimeout);
613
+ return await getHarnessInternal(config, platform, projectRoot, new AbortController().signal);
614
+ };