@react-native-harness/jest 1.4.0-rc.1 → 1.4.1

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 (98) hide show
  1. package/dist/crash-monitor.d.ts +7 -0
  2. package/dist/crash-monitor.d.ts.map +1 -1
  3. package/dist/crash-monitor.js +19 -0
  4. package/dist/diagnostics/derivers.d.ts +24 -0
  5. package/dist/diagnostics/derivers.d.ts.map +1 -0
  6. package/dist/diagnostics/derivers.js +146 -0
  7. package/dist/diagnostics/index.d.ts +3 -0
  8. package/dist/diagnostics/index.d.ts.map +1 -0
  9. package/dist/diagnostics/index.js +2 -0
  10. package/dist/diagnostics/report.d.ts +7 -0
  11. package/dist/diagnostics/report.d.ts.map +1 -0
  12. package/dist/diagnostics/report.js +84 -0
  13. package/dist/environment-reset.d.ts +51 -0
  14. package/dist/environment-reset.d.ts.map +1 -0
  15. package/dist/environment-reset.js +83 -0
  16. package/dist/execute-run.d.ts.map +1 -1
  17. package/dist/execute-run.js +121 -69
  18. package/dist/format-harness-error.d.ts +5 -0
  19. package/dist/format-harness-error.d.ts.map +1 -0
  20. package/dist/format-harness-error.js +43 -0
  21. package/dist/harness-session.d.ts +26 -1
  22. package/dist/harness-session.d.ts.map +1 -1
  23. package/dist/harness-session.js +260 -87
  24. package/dist/run.d.ts.map +1 -1
  25. package/dist/run.js +21 -4
  26. package/package.json +8 -7
  27. package/src/crash-monitor.ts +28 -0
  28. package/src/diagnostics/derivers.ts +170 -0
  29. package/src/diagnostics/index.ts +2 -0
  30. package/src/diagnostics/report.ts +124 -0
  31. package/src/environment-reset.ts +138 -0
  32. package/src/execute-run.ts +136 -77
  33. package/src/format-harness-error.ts +73 -0
  34. package/src/harness-session.ts +369 -131
  35. package/src/run.ts +23 -4
  36. package/tsconfig.json +3 -0
  37. package/tsconfig.lib.json +3 -0
  38. package/dist/__tests__/bridge.test.d.ts +0 -2
  39. package/dist/__tests__/bridge.test.d.ts.map +0 -1
  40. package/dist/__tests__/bridge.test.js +0 -163
  41. package/dist/__tests__/client-log-handler.test.d.ts +0 -2
  42. package/dist/__tests__/client-log-handler.test.d.ts.map +0 -1
  43. package/dist/__tests__/client-log-handler.test.js +0 -306
  44. package/dist/__tests__/crash-monitor.test.d.ts +0 -2
  45. package/dist/__tests__/crash-monitor.test.d.ts.map +0 -1
  46. package/dist/__tests__/crash-monitor.test.js +0 -166
  47. package/dist/__tests__/crash-supervisor.test.d.ts +0 -2
  48. package/dist/__tests__/crash-supervisor.test.d.ts.map +0 -1
  49. package/dist/__tests__/crash-supervisor.test.js +0 -151
  50. package/dist/__tests__/errors.test.d.ts +0 -2
  51. package/dist/__tests__/errors.test.d.ts.map +0 -1
  52. package/dist/__tests__/errors.test.js +0 -98
  53. package/dist/__tests__/execute-run.test.d.ts +0 -2
  54. package/dist/__tests__/execute-run.test.d.ts.map +0 -1
  55. package/dist/__tests__/execute-run.test.js +0 -393
  56. package/dist/__tests__/fixtures/mock-platform-runner.d.ts +0 -7
  57. package/dist/__tests__/fixtures/mock-platform-runner.d.ts.map +0 -1
  58. package/dist/__tests__/fixtures/mock-platform-runner.js +0 -6
  59. package/dist/__tests__/harness-cache.test.d.ts +0 -2
  60. package/dist/__tests__/harness-cache.test.d.ts.map +0 -1
  61. package/dist/__tests__/harness-cache.test.js +0 -51
  62. package/dist/__tests__/harness-session.test.d.ts +0 -2
  63. package/dist/__tests__/harness-session.test.d.ts.map +0 -1
  64. package/dist/__tests__/harness-session.test.js +0 -74
  65. package/dist/__tests__/harness.test.d.ts +0 -2
  66. package/dist/__tests__/harness.test.d.ts.map +0 -1
  67. package/dist/__tests__/harness.test.js +0 -636
  68. package/dist/__tests__/hook-queue.test.d.ts +0 -2
  69. package/dist/__tests__/hook-queue.test.d.ts.map +0 -1
  70. package/dist/__tests__/hook-queue.test.js +0 -56
  71. package/dist/__tests__/index.test.d.ts +0 -2
  72. package/dist/__tests__/index.test.d.ts.map +0 -1
  73. package/dist/__tests__/index.test.js +0 -148
  74. package/dist/__tests__/metro-port.test.d.ts +0 -2
  75. package/dist/__tests__/metro-port.test.d.ts.map +0 -1
  76. package/dist/__tests__/metro-port.test.js +0 -52
  77. package/dist/__tests__/resource-lock.test.d.ts +0 -2
  78. package/dist/__tests__/resource-lock.test.d.ts.map +0 -1
  79. package/dist/__tests__/resource-lock.test.js +0 -138
  80. package/dist/__tests__/test-file-platform-filter.test.d.ts +0 -2
  81. package/dist/__tests__/test-file-platform-filter.test.d.ts.map +0 -1
  82. package/dist/__tests__/test-file-platform-filter.test.js +0 -47
  83. package/dist/cli-args.test.d.ts +0 -2
  84. package/dist/cli-args.test.d.ts.map +0 -1
  85. package/dist/cli-args.test.js +0 -1
  86. package/dist/tsconfig.lib.tsbuildinfo +0 -1
  87. package/src/__tests__/bridge.test.ts +0 -219
  88. package/src/__tests__/client-log-handler.test.ts +0 -364
  89. package/src/__tests__/crash-monitor.test.ts +0 -239
  90. package/src/__tests__/errors.test.ts +0 -157
  91. package/src/__tests__/execute-run.test.ts +0 -526
  92. package/src/__tests__/harness-session.test.ts +0 -93
  93. package/src/__tests__/hook-queue.test.ts +0 -77
  94. package/src/__tests__/index.test.ts +0 -219
  95. package/src/__tests__/metro-port.test.ts +0 -61
  96. package/src/__tests__/resource-lock.test.ts +0 -179
  97. package/src/__tests__/test-file-platform-filter.test.ts +0 -83
  98. package/tsconfig.tsbuildinfo +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@react-native-harness/jest",
3
- "version": "1.4.0-rc.1",
3
+ "version": "1.4.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -39,12 +39,13 @@
39
39
  "jest-util": "^30.2.0",
40
40
  "tslib": "^2.3.0",
41
41
  "yargs": "^17.7.2",
42
- "@react-native-harness/plugins": "1.4.0-rc.1",
43
- "@react-native-harness/platforms": "1.4.0-rc.1",
44
- "@react-native-harness/bridge": "1.4.0-rc.1",
45
- "@react-native-harness/config": "1.4.0-rc.1",
46
- "@react-native-harness/tools": "1.4.0-rc.1",
47
- "@react-native-harness/bundler-metro": "1.4.0-rc.1"
42
+ "@react-native-harness/bridge": "1.4.1",
43
+ "@react-native-harness/cache": "1.4.1",
44
+ "@react-native-harness/config": "1.4.1",
45
+ "@react-native-harness/bundler-metro": "1.4.1",
46
+ "@react-native-harness/plugins": "1.4.1",
47
+ "@react-native-harness/tools": "1.4.1",
48
+ "@react-native-harness/platforms": "1.4.1"
48
49
  },
49
50
  "devDependencies": {
50
51
  "@types/yargs": "^17.0.32",
@@ -40,6 +40,13 @@ export type CrashMonitor = {
40
40
  reset: () => void;
41
41
  setAppSession: (session: AppSession | null) => void;
42
42
  handleBridgeDisconnect: () => void;
43
+ /**
44
+ * Opens a window during which handleBridgeDisconnect() is suppressed,
45
+ * for use around operations (like a runtime reset) that intentionally
46
+ * disconnect the bridge without the app process dying. Returns a closer
47
+ * that ends the window; safe to call multiple times or nest.
48
+ */
49
+ expectDisconnect: () => () => void;
43
50
  dispose: () => Promise<void>;
44
51
  };
45
52
 
@@ -131,6 +138,7 @@ export const createCrashMonitor = ({
131
138
 
132
139
  let currentTestFilePath = '';
133
140
  let currentPhase: NativeCrashPhase = 'startup';
141
+ let expectedDisconnectDepth = 0;
134
142
  const watchers = new Set<(err: HarnessRuntimeFailure) => void>();
135
143
 
136
144
  const notifyFailure = (err: HarnessRuntimeFailure) => {
@@ -273,6 +281,19 @@ export const createCrashMonitor = ({
273
281
  return { promise, cancel };
274
282
  };
275
283
 
284
+ const expectDisconnect = (): (() => void) => {
285
+ expectedDisconnectDepth += 1;
286
+ let closed = false;
287
+
288
+ return () => {
289
+ if (closed) {
290
+ return;
291
+ }
292
+ closed = true;
293
+ expectedDisconnectDepth = Math.max(0, expectedDisconnectDepth - 1);
294
+ };
295
+ };
296
+
276
297
  return {
277
298
  watch,
278
299
  isAlive: () => alive,
@@ -289,15 +310,22 @@ export const createCrashMonitor = ({
289
310
  watchers.clear();
290
311
  isResolvingCrash = false;
291
312
  currentTestFilePath = '';
313
+ expectedDisconnectDepth = 0;
292
314
  clearPendingTimer();
293
315
  },
294
316
  setAppSession,
295
317
  handleBridgeDisconnect: () => {
318
+ if (expectedDisconnectDepth > 0) {
319
+ crashLogger.debug('ignoring expected bridge disconnect');
320
+ return;
321
+ }
296
322
  startCrashResolution('bridge-disconnect');
297
323
  },
324
+ expectDisconnect,
298
325
  dispose: async () => {
299
326
  disposed = true;
300
327
  monitoring = false;
328
+ expectedDisconnectDepth = 0;
301
329
  watchers.clear();
302
330
  isResolvingCrash = false;
303
331
  clearPendingTimer();
@@ -0,0 +1,170 @@
1
+ import type { Diagnostics, DiagnosticSpan } from '@react-native-harness/tools';
2
+ import type { HarnessBridge } from '@react-native-harness/bridge/server';
3
+ import type { BridgeEvents } from '@react-native-harness/bridge';
4
+ import type { MetroInstance, ReportableEvent } from '@react-native-harness/bundler-metro';
5
+
6
+ /**
7
+ * Span/mark derivers translate existing domain event streams (Metro build
8
+ * events, bridge/device events) into diagnostics entries. This is the only
9
+ * place in the jest package that maps event shapes to diagnostic labels -
10
+ * business logic elsewhere stays free of diagnostics calls.
11
+ */
12
+
13
+ const noopDispose = (): void => undefined;
14
+
15
+ /**
16
+ * Observes Metro's reporter event stream and derives:
17
+ * - `metro.bundle.build` spans, correlated by `buildID` across
18
+ * bundle_build_started -> bundle_build_done|bundle_build_failed.
19
+ * - `metro.bundle.request` marks from bundle_request_observed events.
20
+ */
21
+ export const observeMetroEvents = (
22
+ events: MetroInstance['events'],
23
+ diagnostics: Diagnostics
24
+ ): (() => void) => {
25
+ if (!diagnostics.enabled) {
26
+ return noopDispose;
27
+ }
28
+
29
+ const inflightBuilds = new Map<string, DiagnosticSpan>();
30
+
31
+ const listener = (event: ReportableEvent): void => {
32
+ switch (event.type) {
33
+ case 'bundle_build_started': {
34
+ const span = diagnostics.start('metro.bundle.build', {
35
+ buildID: event.buildID,
36
+ platform: event.bundleDetails.platform,
37
+ entryFile: event.bundleDetails.entryFile,
38
+ dev: event.bundleDetails.dev,
39
+ });
40
+ inflightBuilds.set(event.buildID, span);
41
+ return;
42
+ }
43
+ case 'bundle_build_done': {
44
+ const span = inflightBuilds.get(event.buildID);
45
+ inflightBuilds.delete(event.buildID);
46
+ span?.end();
47
+ return;
48
+ }
49
+ case 'bundle_build_failed': {
50
+ const span = inflightBuilds.get(event.buildID);
51
+ inflightBuilds.delete(event.buildID);
52
+ span?.fail(new Error(`Metro bundle build ${event.buildID} failed`));
53
+ return;
54
+ }
55
+ case 'bundle_request_observed': {
56
+ diagnostics.mark('metro.bundle.request', {
57
+ requestKind: event.requestKind,
58
+ platform: event.platform,
59
+ });
60
+ return;
61
+ }
62
+ default:
63
+ return;
64
+ }
65
+ };
66
+
67
+ events.addListener(listener);
68
+ return () => {
69
+ events.removeListener(listener);
70
+ };
71
+ };
72
+
73
+ type MinimalBridge = Pick<HarnessBridge, 'on' | 'off'>;
74
+
75
+ /**
76
+ * Observes bridge connection lifecycle and device-reported bridge events and
77
+ * derives:
78
+ * - `bridge.connected` / `bridge.disconnected` marks.
79
+ * - `client.bundle.module`, `client.setup-file`, `client.collect`,
80
+ * `client.suite`, `client.test` completed spans, recorded via
81
+ * diagnostics.record() since these events arrive with a `duration` already
82
+ * measured on the (unsynced) device clock; we anchor them at host receipt
83
+ * time instead of trusting the device's absolute clock.
84
+ */
85
+ export const observeBridgeEvents = (
86
+ bridge: MinimalBridge,
87
+ diagnostics: Diagnostics
88
+ ): (() => void) => {
89
+ if (!diagnostics.enabled) {
90
+ return noopDispose;
91
+ }
92
+
93
+ const onConnected = (): void => {
94
+ diagnostics.mark('bridge.connected');
95
+ };
96
+
97
+ const onDisconnected = (): void => {
98
+ diagnostics.mark('bridge.disconnected');
99
+ };
100
+
101
+ const onEvent = (event: BridgeEvents): void => {
102
+ switch (event.type) {
103
+ case 'module-bundling-finished':
104
+ diagnostics.record({
105
+ label: 'client.bundle.module',
106
+ duration: event.duration,
107
+ attrs: { file: event.file },
108
+ });
109
+ return;
110
+ case 'module-bundling-failed':
111
+ diagnostics.record({
112
+ label: 'client.bundle.module',
113
+ duration: event.duration,
114
+ attrs: { file: event.file },
115
+ status: 'error',
116
+ });
117
+ return;
118
+ case 'setup-file-bundling-finished':
119
+ diagnostics.record({
120
+ label: 'client.setup-file',
121
+ duration: event.duration,
122
+ attrs: { file: event.file, setupType: event.setupType },
123
+ });
124
+ return;
125
+ case 'setup-file-bundling-failed':
126
+ diagnostics.record({
127
+ label: 'client.setup-file',
128
+ duration: event.duration,
129
+ attrs: { file: event.file, setupType: event.setupType },
130
+ status: 'error',
131
+ });
132
+ return;
133
+ case 'collection-finished':
134
+ diagnostics.record({
135
+ label: 'client.collect',
136
+ duration: event.duration,
137
+ attrs: { file: event.file, totalTests: event.totalTests },
138
+ });
139
+ return;
140
+ case 'suite-finished':
141
+ diagnostics.record({
142
+ label: 'client.suite',
143
+ duration: event.duration,
144
+ attrs: { file: event.file, name: event.name, status: event.status },
145
+ status: event.status === 'failed' ? 'error' : 'ok',
146
+ });
147
+ return;
148
+ case 'test-finished':
149
+ diagnostics.record({
150
+ label: 'client.test',
151
+ duration: event.duration,
152
+ attrs: { file: event.file, name: event.name, status: event.status },
153
+ status: event.status === 'failed' ? 'error' : 'ok',
154
+ });
155
+ return;
156
+ default:
157
+ return;
158
+ }
159
+ };
160
+
161
+ bridge.on('connected', onConnected);
162
+ bridge.on('disconnected', onDisconnected);
163
+ bridge.on('event', onEvent);
164
+
165
+ return () => {
166
+ bridge.off('connected', onConnected);
167
+ bridge.off('disconnected', onDisconnected);
168
+ bridge.off('event', onEvent);
169
+ };
170
+ };
@@ -0,0 +1,2 @@
1
+ export { observeMetroEvents, observeBridgeEvents } from './derivers.js';
2
+ export { writeTraceFile, printSummary } from './report.js';
@@ -0,0 +1,124 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import type { DiagnosticEntry } from '@react-native-harness/tools';
4
+ import { getHarnessRootPath } from '@react-native-harness/tools';
5
+
6
+ type TraceEvent = {
7
+ name: string;
8
+ ph: 'X' | 'i';
9
+ ts: number;
10
+ dur?: number;
11
+ s?: 'g';
12
+ pid: number;
13
+ tid: number;
14
+ args?: Record<string, unknown>;
15
+ };
16
+
17
+ const TRACE_PID = 1;
18
+ const TRACE_TID = 1;
19
+
20
+ const toTraceEvent = (entry: DiagnosticEntry): TraceEvent => {
21
+ if (entry.kind === 'mark') {
22
+ return {
23
+ name: entry.label,
24
+ ph: 'i',
25
+ s: 'g',
26
+ ts: entry.startTime * 1000,
27
+ pid: TRACE_PID,
28
+ tid: TRACE_TID,
29
+ args: entry.attrs,
30
+ };
31
+ }
32
+
33
+ return {
34
+ name: entry.label,
35
+ ph: 'X',
36
+ ts: entry.startTime * 1000,
37
+ dur: entry.duration * 1000,
38
+ pid: TRACE_PID,
39
+ tid: TRACE_TID,
40
+ args: entry.attrs,
41
+ };
42
+ };
43
+
44
+ export const writeTraceFile = async (
45
+ entries: DiagnosticEntry[],
46
+ options: { projectRoot: string; runId: string }
47
+ ): Promise<string> => {
48
+ const dir = path.join(getHarnessRootPath(options.projectRoot), 'diagnostics');
49
+ await fs.mkdir(dir, { recursive: true });
50
+
51
+ const filePath = path.join(dir, `${options.runId}.json`);
52
+ const trace = { traceEvents: entries.map(toTraceEvent) };
53
+
54
+ await fs.writeFile(filePath, JSON.stringify(trace), 'utf-8');
55
+
56
+ return filePath;
57
+ };
58
+
59
+ const formatDuration = (ms: number): string => {
60
+ if (ms >= 1000) {
61
+ return `${(ms / 1000).toFixed(1)}s`;
62
+ }
63
+ return `${Math.round(ms)}ms`;
64
+ };
65
+
66
+ const getNamespace = (label: string): string => label.split('.')[0] ?? label;
67
+
68
+ type LabelStats = {
69
+ label: string;
70
+ count: number;
71
+ totalMs: number;
72
+ maxMs: number;
73
+ };
74
+
75
+ export const printSummary = (
76
+ entries: DiagnosticEntry[],
77
+ log: (line: string) => void
78
+ ): void => {
79
+ if (entries.length === 0) {
80
+ return;
81
+ }
82
+
83
+ const statsByLabel = new Map<string, LabelStats>();
84
+ for (const entry of entries) {
85
+ const existing = statsByLabel.get(entry.label);
86
+ if (existing) {
87
+ existing.count += 1;
88
+ existing.totalMs += entry.duration;
89
+ existing.maxMs = Math.max(existing.maxMs, entry.duration);
90
+ } else {
91
+ statsByLabel.set(entry.label, {
92
+ label: entry.label,
93
+ count: 1,
94
+ totalMs: entry.duration,
95
+ maxMs: entry.duration,
96
+ });
97
+ }
98
+ }
99
+
100
+ const byNamespace = new Map<string, LabelStats[]>();
101
+ for (const stats of statsByLabel.values()) {
102
+ const namespace = getNamespace(stats.label);
103
+ const group = byNamespace.get(namespace);
104
+ if (group) {
105
+ group.push(stats);
106
+ } else {
107
+ byNamespace.set(namespace, [stats]);
108
+ }
109
+ }
110
+
111
+ log('Diagnostics summary:');
112
+ const sortedNamespaces = [...byNamespace.entries()].sort(([a], [b]) =>
113
+ a.localeCompare(b)
114
+ );
115
+ for (const [namespace, group] of sortedNamespaces) {
116
+ log(` ${namespace}`);
117
+ group.sort((a, b) => b.totalMs - a.totalMs);
118
+ for (const stats of group) {
119
+ log(
120
+ ` ${stats.label}: count=${stats.count} total=${formatDuration(stats.totalMs)} max=${formatDuration(stats.maxMs)}`
121
+ );
122
+ }
123
+ }
124
+ };
@@ -0,0 +1,138 @@
1
+ import { getTimeoutSignal } from '@react-native-harness/tools';
2
+ import type { Config as HarnessConfig } from '@react-native-harness/config';
3
+
4
+ const ignorePromiseRejection = () => undefined;
5
+
6
+ export type ResetStrategyKind = 'process' | 'runtime';
7
+
8
+ export type EnvironmentResetStrategy = {
9
+ readonly kind: ResetStrategyKind;
10
+ // Post-condition: bridge connected, runtime ready, crash-monitor state consistent.
11
+ reset(ctx: { testFilePath: string }): Promise<void>;
12
+ };
13
+
14
+ /**
15
+ * Normalizes the `resetEnvironmentBetweenTestFiles` config value into a
16
+ * strategy kind, or `null` when no reset should happen between test files
17
+ * (i.e. the value is `false`).
18
+ */
19
+ export const resolveResetStrategyKind = (
20
+ value: HarnessConfig['resetEnvironmentBetweenTestFiles']
21
+ ): ResetStrategyKind | null => {
22
+ if (value === false) {
23
+ return null;
24
+ }
25
+ if (value === true) {
26
+ return 'process';
27
+ }
28
+ return value;
29
+ };
30
+
31
+ export type ProcessResetStrategyDeps = {
32
+ // Shared with restartApp; killing and relaunching the app process.
33
+ restart: (testFilePath: string) => Promise<void>;
34
+ };
35
+
36
+ /**
37
+ * The 'process' strategy: kill and cold-restart the app process. This is
38
+ * the same implementation `restartApp` uses for crash/timeout recovery.
39
+ */
40
+ export const createProcessResetStrategy = (
41
+ deps: ProcessResetStrategyDeps
42
+ ): EnvironmentResetStrategy => ({
43
+ kind: 'process',
44
+ reset: ({ testFilePath }) => deps.restart(testFilePath),
45
+ });
46
+
47
+ export type RuntimeResetConnection = {
48
+ resetEnvironment: () => Promise<void>;
49
+ };
50
+
51
+ export type RuntimeResetStrategyDeps = {
52
+ // Returns the active app connection, or null if there isn't one.
53
+ getConnection: () => RuntimeResetConnection | null;
54
+ // Opens a window that suppresses crash-monitor disconnect handling;
55
+ // returns a closer to end the window.
56
+ expectDisconnect: () => () => void;
57
+ // Resolves the next time the bridge reports a 'connected' event, or
58
+ // rejects when `signal` aborts.
59
+ waitForReconnect: (signal: AbortSignal) => Promise<void>;
60
+ // Identifies RPC rejections caused by the bridge disconnecting. Inside the
61
+ // expected-disconnect window these mean the reload tore the runtime down
62
+ // before the RPC ack flushed — the reload is in flight, not failed.
63
+ isDisconnectError: (error: unknown) => boolean;
64
+ // Reconnect timeout in ms (the configured bundleStartTimeout).
65
+ timeoutMs: number;
66
+ };
67
+
68
+ /**
69
+ * The 'runtime' strategy: reload the JS runtime in place (DevSettings.reload()
70
+ * on native, window.location.reload() on web) via the resetEnvironment RPC,
71
+ * and wait for the bridge to reconnect. The app process stays alive
72
+ * throughout, so the crash monitor's expected-disconnect window is used to
73
+ * avoid misclassifying the intentional disconnect as a runtime failure.
74
+ */
75
+ export const createRuntimeResetStrategy = (
76
+ deps: RuntimeResetStrategyDeps
77
+ ): EnvironmentResetStrategy => ({
78
+ kind: 'runtime',
79
+ reset: async () => {
80
+ const connection = deps.getConnection();
81
+ if (!connection) {
82
+ throw new Error('No active app connection to reset the runtime on');
83
+ }
84
+
85
+ const closeWindow = deps.expectDisconnect();
86
+ try {
87
+ // Start listening for the reconnect before firing the RPC, so a fast
88
+ // reload can't reconnect before we're watching for it.
89
+ const reconnected = deps.waitForReconnect(getTimeoutSignal(deps.timeoutMs));
90
+ // Observe the rejection even when the RPC throws first, so the timeout
91
+ // abort firing later can't become an unhandled rejection. (.catch()
92
+ // returns a new promise; the original is what gets awaited below.)
93
+ reconnected.catch(ignorePromiseRejection);
94
+
95
+ try {
96
+ await connection.resetEnvironment();
97
+ } catch (error) {
98
+ // The reload can tear the runtime down before the RPC ack flushes.
99
+ // A disconnect-flavored rejection means the reload is in flight, so
100
+ // keep waiting for the reconnect; anything else is a real failure.
101
+ if (!deps.isDisconnectError(error)) {
102
+ throw error;
103
+ }
104
+ }
105
+
106
+ await reconnected;
107
+ } finally {
108
+ closeWindow();
109
+ }
110
+ },
111
+ });
112
+
113
+ export type EscalationOptions = {
114
+ // Invoked when the primary strategy fails and the fallback is about to run.
115
+ onEscalate?: (error: unknown) => void;
116
+ };
117
+
118
+ /**
119
+ * Wraps a primary strategy so that any failure (no active connection, RPC
120
+ * error, reconnect timeout, ...) falls back to running `fallback` instead of
121
+ * propagating the error. The composite reports the primary's `kind`, since
122
+ * that's the strategy that was requested.
123
+ */
124
+ export const withEscalation = (
125
+ primary: EnvironmentResetStrategy,
126
+ fallback: EnvironmentResetStrategy,
127
+ options: EscalationOptions = {}
128
+ ): EnvironmentResetStrategy => ({
129
+ kind: primary.kind,
130
+ reset: async (ctx) => {
131
+ try {
132
+ await primary.reset(ctx);
133
+ } catch (error) {
134
+ options.onEscalate?.(error);
135
+ await fallback.reset(ctx);
136
+ }
137
+ },
138
+ });