@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.
Files changed (98) hide show
  1. package/dist/__tests__/bridge.test.js +29 -4
  2. package/dist/__tests__/crash-monitor.test.js +194 -176
  3. package/dist/__tests__/environment-reset.test.d.ts +2 -0
  4. package/dist/__tests__/environment-reset.test.d.ts.map +1 -0
  5. package/dist/__tests__/environment-reset.test.js +238 -0
  6. package/dist/__tests__/errors.test.js +39 -0
  7. package/dist/__tests__/execute-run.test.js +439 -29
  8. package/dist/__tests__/format-harness-error.test.d.ts +2 -0
  9. package/dist/__tests__/format-harness-error.test.d.ts.map +1 -0
  10. package/dist/__tests__/format-harness-error.test.js +27 -0
  11. package/dist/__tests__/harness-cache.test.js +62 -41
  12. package/dist/__tests__/harness-session-signal.test.d.ts +2 -0
  13. package/dist/__tests__/harness-session-signal.test.d.ts.map +1 -0
  14. package/dist/__tests__/harness-session-signal.test.js +298 -0
  15. package/dist/__tests__/harness-session.test.js +217 -234
  16. package/dist/__tests__/index.test.js +62 -11
  17. package/dist/__tests__/metro-port.test.js +2 -0
  18. package/dist/__tests__/run.test.d.ts +2 -0
  19. package/dist/__tests__/run.test.d.ts.map +1 -0
  20. package/dist/__tests__/run.test.js +48 -0
  21. package/dist/__tests__/test-file-platform-filter.test.d.ts +2 -0
  22. package/dist/__tests__/test-file-platform-filter.test.d.ts.map +1 -0
  23. package/dist/__tests__/test-file-platform-filter.test.js +47 -0
  24. package/dist/crash-monitor.d.ts +12 -4
  25. package/dist/crash-monitor.d.ts.map +1 -1
  26. package/dist/crash-monitor.js +135 -102
  27. package/dist/diagnostics/__tests__/derivers.test.d.ts +2 -0
  28. package/dist/diagnostics/__tests__/derivers.test.d.ts.map +1 -0
  29. package/dist/diagnostics/__tests__/derivers.test.js +248 -0
  30. package/dist/diagnostics/__tests__/report.test.d.ts +2 -0
  31. package/dist/diagnostics/__tests__/report.test.d.ts.map +1 -0
  32. package/dist/diagnostics/__tests__/report.test.js +97 -0
  33. package/dist/diagnostics/derivers.d.ts +24 -0
  34. package/dist/diagnostics/derivers.d.ts.map +1 -0
  35. package/dist/diagnostics/derivers.js +146 -0
  36. package/dist/diagnostics/index.d.ts +3 -0
  37. package/dist/diagnostics/index.d.ts.map +1 -0
  38. package/dist/diagnostics/index.js +2 -0
  39. package/dist/diagnostics/report.d.ts +7 -0
  40. package/dist/diagnostics/report.d.ts.map +1 -0
  41. package/dist/diagnostics/report.js +84 -0
  42. package/dist/environment-reset.d.ts +51 -0
  43. package/dist/environment-reset.d.ts.map +1 -0
  44. package/dist/environment-reset.js +83 -0
  45. package/dist/errors.d.ts +10 -0
  46. package/dist/errors.d.ts.map +1 -1
  47. package/dist/errors.js +41 -2
  48. package/dist/execute-run.d.ts +4 -2
  49. package/dist/execute-run.d.ts.map +1 -1
  50. package/dist/execute-run.js +179 -49
  51. package/dist/format-harness-error.d.ts +5 -0
  52. package/dist/format-harness-error.d.ts.map +1 -0
  53. package/dist/format-harness-error.js +43 -0
  54. package/dist/harness-session.d.ts +46 -1
  55. package/dist/harness-session.d.ts.map +1 -1
  56. package/dist/harness-session.js +386 -118
  57. package/dist/index.d.ts +7 -3
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +24 -2
  60. package/dist/run.d.ts.map +1 -1
  61. package/dist/run.js +22 -4
  62. package/dist/test-file-platform-filter.d.ts +5 -0
  63. package/dist/test-file-platform-filter.d.ts.map +1 -0
  64. package/dist/test-file-platform-filter.js +40 -0
  65. package/dist/toTestResult.d.ts +1 -0
  66. package/dist/toTestResult.d.ts.map +1 -1
  67. package/dist/toTestResult.js +2 -2
  68. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  69. package/package.json +8 -7
  70. package/src/__tests__/bridge.test.ts +45 -5
  71. package/src/__tests__/crash-monitor.test.ts +234 -199
  72. package/src/__tests__/environment-reset.test.ts +308 -0
  73. package/src/__tests__/errors.test.ts +75 -0
  74. package/src/__tests__/execute-run.test.ts +561 -37
  75. package/src/__tests__/format-harness-error.test.ts +32 -0
  76. package/src/__tests__/harness-cache.test.ts +91 -0
  77. package/src/__tests__/harness-session.test.ts +303 -0
  78. package/src/__tests__/index.test.ts +92 -12
  79. package/src/__tests__/metro-port.test.ts +2 -0
  80. package/src/__tests__/run.test.ts +74 -0
  81. package/src/__tests__/test-file-platform-filter.test.ts +83 -0
  82. package/src/crash-monitor.ts +220 -131
  83. package/src/diagnostics/__tests__/derivers.test.ts +300 -0
  84. package/src/diagnostics/__tests__/report.test.ts +128 -0
  85. package/src/diagnostics/derivers.ts +170 -0
  86. package/src/diagnostics/index.ts +2 -0
  87. package/src/diagnostics/report.ts +124 -0
  88. package/src/environment-reset.ts +138 -0
  89. package/src/errors.ts +62 -1
  90. package/src/execute-run.ts +230 -57
  91. package/src/format-harness-error.ts +73 -0
  92. package/src/harness-session.ts +535 -162
  93. package/src/index.ts +40 -11
  94. package/src/run.ts +24 -4
  95. package/src/test-file-platform-filter.ts +57 -0
  96. package/src/toTestResult.ts +3 -2
  97. package/tsconfig.json +3 -0
  98. package/tsconfig.lib.json +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@react-native-harness/jest",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
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/bridge": "1.3.0",
43
- "@react-native-harness/config": "1.3.0",
44
- "@react-native-harness/plugins": "1.3.0",
45
- "@react-native-harness/tools": "1.3.0",
46
- "@react-native-harness/bundler-metro": "1.3.0",
47
- "@react-native-harness/platforms": "1.3.0"
42
+ "@react-native-harness/cache": "1.4.0",
43
+ "@react-native-harness/bundler-metro": "1.4.0",
44
+ "@react-native-harness/config": "1.4.0",
45
+ "@react-native-harness/bridge": "1.4.0",
46
+ "@react-native-harness/plugins": "1.4.0",
47
+ "@react-native-harness/platforms": "1.4.0",
48
+ "@react-native-harness/tools": "1.4.0"
48
49
  },
49
50
  "devDependencies": {
50
51
  "@types/yargs": "^17.0.32",
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * Integration test pairing createHarnessBridge (CLI side) with
3
3
  * connectToHarness (app side). Tests the full connection lifecycle and
4
- * RPC round-trip without knowing birpc internals.
4
+ * RPC round-trip without coupling tests to the transport internals.
5
5
  */
6
6
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
7
  import type { HarnessBridge } from '@react-native-harness/bridge/server';
8
8
  import { createHarnessBridge } from '@react-native-harness/bridge/server';
9
9
  import { connectToHarness } from '@react-native-harness/bridge/client';
10
10
  import type { HarnessContext } from '@react-native-harness/bridge';
11
+ import type { TestSuiteResult } from '@react-native-harness/bridge';
11
12
 
12
13
  const makeContext = (): HarnessContext => ({
13
14
  platform: {
@@ -36,8 +37,12 @@ afterEach(async () => {
36
37
  await new Promise((r) => setTimeout(r, 10));
37
38
  });
38
39
 
39
- const connect = (callbacks: Parameters<typeof connectToHarness>[1] = { runTests: vi.fn() }) =>
40
- connectToHarness(`ws://127.0.0.1:${bridgePort}`, callbacks);
40
+ const connect = (
41
+ callbacks: Parameters<typeof connectToHarness>[1] = {
42
+ runTests: vi.fn(),
43
+ resetEnvironment: vi.fn(),
44
+ },
45
+ ) => connectToHarness(`ws://127.0.0.1:${bridgePort}`, callbacks);
41
46
 
42
47
  const device = {
43
48
  platform: 'ios' as const,
@@ -139,7 +144,7 @@ describe('bridge: createHarnessBridge + connectToHarness', () => {
139
144
  const runTestsCb = vi.fn(async () => suiteResult);
140
145
 
141
146
  const connectionPromise = bridge.nextConnection();
142
- const handle = await connect({ runTests: runTestsCb });
147
+ const handle = await connect({ runTests: runTestsCb, resetEnvironment: vi.fn() });
143
148
  handle.reportReady(device);
144
149
 
145
150
  const conn = await connectionPromise;
@@ -151,6 +156,39 @@ describe('bridge: createHarnessBridge + connectToHarness', () => {
151
156
  expect(result.tests[0].name).toBe('passes');
152
157
  handle.disconnect();
153
158
  });
159
+
160
+ it('rejects pending app calls when a newer client replaces the session', async () => {
161
+ const firstHandle = await connect({
162
+ runTests: async (): Promise<TestSuiteResult> =>
163
+ await new Promise<TestSuiteResult>(() => undefined),
164
+ resetEnvironment: vi.fn(),
165
+ });
166
+ firstHandle.reportReady(device);
167
+
168
+ const firstConnection = await bridge.nextConnection();
169
+ const pendingRun = firstConnection.runTests('example.ts', {
170
+ runner: '/runner.js',
171
+ });
172
+ const pendingRunAssertion = expect(pendingRun).rejects.toThrow(
173
+ 'The app bridge was replaced by a newer app connection.',
174
+ );
175
+
176
+ const secondHandle = await connect();
177
+ secondHandle.reportReady({
178
+ ...device,
179
+ model: 'iPhone 17 Pro Replacement',
180
+ });
181
+
182
+ await pendingRunAssertion;
183
+
184
+ await new Promise((r) => setTimeout(r, 20));
185
+
186
+ expect(bridge.connection?.device.model).toBe(
187
+ 'iPhone 17 Pro Replacement',
188
+ );
189
+ secondHandle.disconnect();
190
+ firstHandle.disconnect();
191
+ });
154
192
  });
155
193
 
156
194
  describe('bridge events', () => {
@@ -178,7 +216,9 @@ describe('bridge: createHarnessBridge + connectToHarness', () => {
178
216
  const pending = bridge.nextConnection();
179
217
  bridge.dispose();
180
218
 
181
- await expect(pending).rejects.toThrow('Bridge disposed');
219
+ await expect(pending).rejects.toThrow(
220
+ 'The app bridge was disposed before the test file finished running.',
221
+ );
182
222
  });
183
223
  });
184
224
  });
@@ -1,288 +1,323 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
+ import path from 'node:path';
2
3
  import type {
3
- AppCrashDetails,
4
- AppMonitor,
5
- AppMonitorEvent,
6
- AppMonitorListener,
7
- HarnessPlatformRunner,
4
+ AppSession,
5
+ AppSessionEvent,
6
+ AppSessionListener,
7
+ AppSessionLog,
8
+ AppSessionState,
8
9
  } from '@react-native-harness/platforms';
9
10
  import {
10
11
  createCrashMonitor,
11
12
  CrashWatchCancelledError,
12
13
  } from '../crash-monitor.js';
13
- import { NativeCrashError } from '../errors.js';
14
+ import { NativeCrashError, RuntimeDisconnectError } from '../errors.js';
14
15
 
15
16
  const noop = () => undefined;
16
- const resolveUndefined = async () => undefined;
17
-
18
- // ---------------------------------------------------------------------------
19
- // Test doubles
20
- // ---------------------------------------------------------------------------
21
-
22
- const createAppMonitorMock = () => {
23
- let registeredListener: AppMonitorListener | null = null;
24
-
25
- const monitor: AppMonitor = {
26
- start: vi.fn(resolveUndefined),
27
- stop: vi.fn(resolveUndefined),
28
- dispose: vi.fn(resolveUndefined),
29
- addListener: vi.fn((l: AppMonitorListener) => {
30
- registeredListener = l;
17
+ const waitForClassification = () =>
18
+ new Promise((resolve) => setTimeout(resolve, 1600));
19
+
20
+ const createAppSessionMock = (
21
+ initialState: AppSessionState = { status: 'running' },
22
+ logs: AppSessionLog[] = [],
23
+ getCrashDetails?: AppSession['getCrashDetails']
24
+ ) => {
25
+ let state = initialState;
26
+ let listener: AppSessionListener | null = null;
27
+
28
+ const session: AppSession = {
29
+ dispose: vi.fn(async () => undefined),
30
+ getState: vi.fn(async () => state),
31
+ getLogs: vi.fn(() => logs),
32
+ addListener: vi.fn((l: AppSessionListener) => {
33
+ listener = l;
31
34
  }),
32
- removeListener: vi.fn((l: AppMonitorListener) => {
33
- if (registeredListener === l) registeredListener = null;
35
+ removeListener: vi.fn((l: AppSessionListener) => {
36
+ if (listener === l) listener = null;
34
37
  }),
35
38
  };
36
39
 
40
+ if (getCrashDetails) {
41
+ session.getCrashDetails = getCrashDetails;
42
+ }
43
+
37
44
  return {
38
- monitor,
39
- emit: (event: AppMonitorEvent) => registeredListener?.(event),
45
+ session,
46
+ setState: (nextState: AppSessionState) => {
47
+ state = nextState;
48
+ },
49
+ emit: (event: AppSessionEvent) => listener?.(event),
40
50
  };
41
51
  };
42
52
 
43
- const createPlatformRunnerMock = (
44
- isRunning = false,
45
- crashDetails: AppCrashDetails | null = null,
46
- ) =>
47
- ({
48
- isAppRunning: vi.fn(async () => isRunning),
49
- getCrashDetails: vi.fn(async () => crashDetails),
50
- startApp: vi.fn(resolveUndefined),
51
- restartApp: vi.fn(resolveUndefined),
52
- stopApp: vi.fn(resolveUndefined),
53
- dispose: vi.fn(resolveUndefined),
54
- createAppMonitor: vi.fn(),
55
- }) as unknown as HarnessPlatformRunner;
56
-
57
- // ---------------------------------------------------------------------------
58
- // Tests
59
- // ---------------------------------------------------------------------------
60
-
61
53
  describe('createCrashMonitor', () => {
62
- describe('liveness', () => {
63
- it('starts not alive', () => {
64
- const { monitor } = createAppMonitorMock();
65
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
54
+ it('starts not alive and becomes alive when an app session is attached', () => {
55
+ const cm = createCrashMonitor();
56
+ const { session } = createAppSessionMock();
66
57
 
67
- expect(cm.isAlive()).toBe(false);
68
- });
58
+ expect(cm.isAlive()).toBe(false);
69
59
 
70
- it('becomes alive when the app starts', () => {
71
- const { monitor, emit } = createAppMonitorMock();
72
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
60
+ cm.setAppSession(session);
73
61
 
74
- emit({ type: 'app_started' });
62
+ expect(cm.isAlive()).toBe(true);
63
+ });
75
64
 
76
- expect(cm.isAlive()).toBe(true);
65
+ it('rejects a watch with NativeCrashError when the app session exits', async () => {
66
+ const { session, emit } = createAppSessionMock({
67
+ status: 'exited',
68
+ occurredAt: Date.now(),
69
+ reason: 'observed-exit',
77
70
  });
71
+ const cm = createCrashMonitor({ appSession: session });
72
+ const watch = cm.watch('/test/example.ts', 'execution');
73
+ watch.promise.catch(noop);
78
74
 
79
- it('becomes not alive after a confirmed crash', async () => {
80
- const { monitor, emit } = createAppMonitorMock();
81
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
82
-
83
- emit({ type: 'app_started' });
84
- const watch = cm.watch('test.ts', 'execution');
85
- watch.promise.catch(noop);
75
+ emit({ type: 'app_exited' });
86
76
 
87
- emit({ type: 'app_exited', isConfirmed: true });
88
- await watch.promise.catch(noop);
89
-
90
- expect(cm.isAlive()).toBe(false);
91
- });
77
+ const error = await watch.promise.catch((err: NativeCrashError) => err);
78
+ expect(error).toBeInstanceOf(NativeCrashError);
79
+ expect(error.testFilePath).toBe('/test/example.ts');
80
+ expect(error.details.phase).toBe('execution');
81
+ expect(cm.isAlive()).toBe(false);
92
82
  });
93
83
 
94
- describe('watch', () => {
95
- it('promise rejects with NativeCrashError on confirmed app_exited', async () => {
96
- const { monitor, emit } = createAppMonitorMock();
97
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
98
-
99
- const watch = cm.watch('/test/example.ts', 'execution');
100
- watch.promise.catch(noop);
101
- emit({ type: 'app_exited', isConfirmed: true });
102
-
103
- await expect(watch.promise).rejects.toBeInstanceOf(NativeCrashError);
84
+ it('classifies bridge disconnect plus exited app as NativeCrashError', async () => {
85
+ const { session, setState } = createAppSessionMock();
86
+ const cm = createCrashMonitor({ appSession: session });
87
+ const watch = cm.watch('test.ts', 'execution');
88
+ watch.promise.catch(noop);
89
+
90
+ cm.handleBridgeDisconnect();
91
+ setState({
92
+ status: 'exited',
93
+ occurredAt: Date.now(),
94
+ reason: 'process-gone',
104
95
  });
105
96
 
106
- it('attributes the crash to the file and phase passed to watch()', async () => {
107
- const { monitor, emit } = createAppMonitorMock();
108
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
97
+ await expect(watch.promise).rejects.toBeInstanceOf(NativeCrashError);
98
+ });
109
99
 
110
- const watch = cm.watch('/test/example.ts', 'startup');
111
- watch.promise.catch(noop);
112
- emit({ type: 'app_exited', isConfirmed: true });
100
+ it('classifies bridge disconnect plus running app as RuntimeDisconnectError', async () => {
101
+ const { session } = createAppSessionMock({ status: 'running' });
102
+ const cm = createCrashMonitor({ appSession: session });
103
+ const watch = cm.watch('test.ts', 'execution');
104
+ watch.promise.catch(noop);
113
105
 
114
- const error = await watch.promise.catch((e: NativeCrashError) => e);
115
- expect(error.testFilePath).toBe('/test/example.ts');
116
- expect(error.details.phase).toBe('startup');
117
- });
106
+ cm.handleBridgeDisconnect();
118
107
 
119
- it('settles the promise with CrashWatchCancelledError on cancel()', async () => {
120
- const { monitor } = createAppMonitorMock();
121
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
108
+ await expect(watch.promise).rejects.toBeInstanceOf(RuntimeDisconnectError);
109
+ });
122
110
 
123
- const watch = cm.watch('test.ts', 'execution');
124
- watch.cancel();
111
+ it('attaches matching session log evidence to native crash details', async () => {
112
+ const occurredAt = Date.now();
113
+ const logs = [
114
+ { line: 'ordinary app log', occurredAt },
115
+ { line: 'MyApp[123] fatal error: boom', occurredAt },
116
+ ];
117
+ const { session, setState } = createAppSessionMock(
118
+ { status: 'running' },
119
+ logs
120
+ );
121
+ const cm = createCrashMonitor({ appSession: session });
122
+ const watch = cm.watch('test.ts', 'execution');
123
+ watch.promise.catch(noop);
124
+
125
+ cm.handleBridgeDisconnect();
126
+ setState({ status: 'exited', occurredAt, reason: 'process-gone' });
127
+
128
+ const error = await watch.promise.catch((err: NativeCrashError) => err);
129
+ expect(error).toBeInstanceOf(NativeCrashError);
130
+ expect(error.details.rawLines).toEqual(['MyApp[123] fatal error: boom']);
131
+ });
125
132
 
126
- await expect(watch.promise).rejects.toBeInstanceOf(CrashWatchCancelledError);
133
+ it('asks the app session to extract native crash details for the current test', async () => {
134
+ const occurredAt = Date.now();
135
+ const getCrashDetails = vi.fn(async () => ({
136
+ artifactType: 'logcat' as const,
137
+ artifactPath: '/tmp/.harness/crash-reports/crash-logcat.txt',
138
+ processName: 'com.harnessplayground',
139
+ pid: 7777,
140
+ }));
141
+ const { session, setState } = createAppSessionMock(
142
+ { status: 'running' },
143
+ [],
144
+ getCrashDetails
145
+ );
146
+ const cm = createCrashMonitor({ appSession: session });
147
+ const watch = cm.watch('/test/example.ts', 'execution');
148
+ watch.promise.catch(noop);
149
+
150
+ cm.handleBridgeDisconnect();
151
+ setState({
152
+ status: 'exited',
153
+ occurredAt,
154
+ pid: 7777,
155
+ reason: 'process-gone',
127
156
  });
128
157
 
129
- it('subsequent cancel() after crash is a no-op', async () => {
130
- const { monitor, emit } = createAppMonitorMock();
131
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
158
+ const error = await watch.promise.catch((err: NativeCrashError) => err);
132
159
 
133
- const watch = cm.watch('test.ts', 'execution');
134
- watch.promise.catch(noop);
135
- emit({ type: 'app_exited', isConfirmed: true });
136
-
137
- await watch.promise.catch(noop);
138
- // Second cancel should not throw or cause issues.
139
- expect(() => watch.cancel()).not.toThrow();
160
+ expect(getCrashDetails).toHaveBeenCalledWith({
161
+ occurredAt: expect.any(Number),
162
+ pid: 7777,
163
+ processName: undefined,
164
+ testFilePath: '/test/example.ts',
140
165
  });
166
+ expect(error.details.artifactPath).toBe(
167
+ '/tmp/.harness/crash-reports/crash-logcat.txt'
168
+ );
169
+ expect(error.message).toContain(
170
+ `Harness extracted the crash log: ${path.relative(
171
+ process.cwd(),
172
+ '/tmp/.harness/crash-reports/crash-logcat.txt'
173
+ )}`
174
+ );
141
175
  });
142
176
 
143
- describe('unconfirmed events', () => {
144
- it('fires the crash if isAppRunning returns false', async () => {
145
- const { monitor, emit } = createAppMonitorMock();
146
- const runner = createPlatformRunnerMock(false /* not running */);
147
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: runner });
148
-
149
- const watch = cm.watch('test.ts', 'execution');
150
- watch.promise.catch(noop);
151
- emit({ type: 'app_exited', isConfirmed: false });
152
-
153
- await expect(watch.promise).rejects.toBeInstanceOf(NativeCrashError);
177
+ it('falls back to log details when native crash extraction fails', async () => {
178
+ const occurredAt = Date.now();
179
+ const getCrashDetails = vi.fn(async () => {
180
+ throw new Error('copy failed');
154
181
  });
182
+ const { session, setState } = createAppSessionMock(
183
+ { status: 'running' },
184
+ [{ line: 'MyApp[123] fatal error: boom', occurredAt }],
185
+ getCrashDetails
186
+ );
187
+ const cm = createCrashMonitor({ appSession: session });
188
+ const watch = cm.watch('/test/example.ts', 'execution');
189
+ watch.promise.catch(noop);
190
+
191
+ cm.handleBridgeDisconnect();
192
+ setState({ status: 'exited', occurredAt, reason: 'process-gone' });
193
+
194
+ const error = await watch.promise.catch((err: NativeCrashError) => err);
195
+
196
+ expect(error.details.rawLines).toEqual(['MyApp[123] fatal error: boom']);
197
+ expect(error.message).toContain("Harness couldn't extract the crash log.");
198
+ });
155
199
 
156
- it('does not fire if isAppRunning returns true', async () => {
157
- const { monitor, emit } = createAppMonitorMock();
158
- const runner = createPlatformRunnerMock(true /* still running */);
159
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: runner });
200
+ it('settles the promise with CrashWatchCancelledError on cancel()', async () => {
201
+ const cm = createCrashMonitor();
202
+ const watch = cm.watch('test.ts', 'execution');
160
203
 
161
- const watch = cm.watch('test.ts', 'execution');
162
- const settled = vi.fn();
163
- watch.promise.then(settled, settled);
204
+ watch.cancel();
164
205
 
165
- emit({ type: 'app_exited', isConfirmed: false });
166
- await new Promise((r) => setTimeout(r, 20));
206
+ await expect(watch.promise).rejects.toBeInstanceOf(
207
+ CrashWatchCancelledError
208
+ );
209
+ });
167
210
 
168
- expect(settled).not.toHaveBeenCalled();
169
- watch.cancel();
211
+ it('ignores session events while stopped', async () => {
212
+ const { session, emit } = createAppSessionMock({
213
+ status: 'exited',
214
+ occurredAt: Date.now(),
170
215
  });
216
+ const cm = createCrashMonitor({ appSession: session });
217
+ await cm.stop();
171
218
 
172
- it('fires on possible_crash when confirmed', async () => {
173
- const { monitor, emit } = createAppMonitorMock();
174
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
219
+ const watch = cm.watch('test.ts', 'execution');
220
+ const settled = vi.fn();
221
+ watch.promise.then(settled, settled);
175
222
 
176
- const watch = cm.watch('test.ts', 'execution');
177
- watch.promise.catch(noop);
178
- emit({ type: 'possible_crash', isConfirmed: true });
223
+ emit({ type: 'app_exited' });
224
+ await waitForClassification();
179
225
 
180
- await expect(watch.promise).rejects.toBeInstanceOf(NativeCrashError);
181
- });
226
+ expect(settled).not.toHaveBeenCalled();
227
+ watch.cancel();
182
228
  });
183
229
 
184
- describe('crash detail enrichment', () => {
185
- it('merges initial and enriched crash details', async () => {
186
- const { monitor, emit } = createAppMonitorMock();
187
- const runner = createPlatformRunnerMock(false, {
188
- processName: 'MyApp',
189
- signal: 'SIGSEGV',
190
- summary: 'Segmentation fault',
191
- });
192
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: runner });
230
+ it('removes the app session listener on dispose', async () => {
231
+ const { session } = createAppSessionMock();
232
+ const cm = createCrashMonitor({ appSession: session });
193
233
 
194
- const watch = cm.watch('test.ts', 'execution');
195
- watch.promise.catch(noop);
196
- emit({ type: 'app_exited', isConfirmed: true, crashDetails: { pid: 1234 } });
234
+ await cm.dispose();
197
235
 
198
- const error = await watch.promise.catch((e: NativeCrashError) => e);
199
- expect(error.details.processName).toBe('MyApp');
200
- expect(error.details.signal).toBe('SIGSEGV');
201
- expect(error.details.pid).toBe(1234);
202
- });
236
+ expect(session.removeListener).toHaveBeenCalledOnce();
237
+ expect(cm.isAlive()).toBe(false);
203
238
  });
204
239
 
205
- describe('stop / start', () => {
206
- it('ignores events while stopped', async () => {
207
- const { monitor, emit } = createAppMonitorMock();
208
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
240
+ describe('expectDisconnect', () => {
241
+ it('suppresses handleBridgeDisconnect while the window is open', async () => {
242
+ const { session } = createAppSessionMock({ status: 'running' });
243
+ const cm = createCrashMonitor({ appSession: session });
244
+ const watch = cm.watch('test.ts', 'execution');
245
+ watch.promise.catch(noop);
209
246
 
210
- await cm.stop();
247
+ const closeWindow = cm.expectDisconnect();
248
+ cm.handleBridgeDisconnect();
249
+ await waitForClassification();
211
250
 
212
- const watch = cm.watch('test.ts', 'execution');
213
251
  const settled = vi.fn();
214
252
  watch.promise.then(settled, settled);
215
-
216
- emit({ type: 'app_exited', isConfirmed: true });
217
- await new Promise((r) => setTimeout(r, 10));
218
-
253
+ await Promise.resolve();
219
254
  expect(settled).not.toHaveBeenCalled();
255
+
256
+ closeWindow();
220
257
  watch.cancel();
221
258
  });
222
259
 
223
- it('resumes monitoring after start()', async () => {
224
- const { monitor, emit } = createAppMonitorMock();
225
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
226
-
227
- await cm.stop();
228
- await cm.start();
229
-
260
+ it('resumes classifying disconnects once the window closes', async () => {
261
+ const { session } = createAppSessionMock({ status: 'running' });
262
+ const cm = createCrashMonitor({ appSession: session });
230
263
  const watch = cm.watch('test.ts', 'execution');
231
264
  watch.promise.catch(noop);
232
- emit({ type: 'app_exited', isConfirmed: true });
233
265
 
234
- await expect(watch.promise).rejects.toBeInstanceOf(NativeCrashError);
235
- });
236
- });
266
+ const closeWindow = cm.expectDisconnect();
267
+ closeWindow();
268
+
269
+ cm.handleBridgeDisconnect();
237
270
 
238
- describe('reset', () => {
239
- it('clears alive state and pending watchers', async () => {
240
- const { monitor, emit } = createAppMonitorMock();
241
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
271
+ await expect(watch.promise).rejects.toBeInstanceOf(RuntimeDisconnectError);
272
+ });
242
273
 
243
- emit({ type: 'app_started' });
274
+ it('keeps the window open until every opener has closed it (nested windows)', async () => {
275
+ const { session } = createAppSessionMock({ status: 'running' });
276
+ const cm = createCrashMonitor({ appSession: session });
244
277
  const watch = cm.watch('test.ts', 'execution');
245
278
  watch.promise.catch(noop);
246
279
 
247
- cm.reset();
280
+ const closeFirst = cm.expectDisconnect();
281
+ const closeSecond = cm.expectDisconnect();
282
+ closeFirst();
248
283
 
249
- expect(cm.isAlive()).toBe(false);
250
- // The watcher was cleared; a crash fired now should not reach the old watch.
251
- emit({ type: 'app_exited', isConfirmed: true });
252
- await new Promise((r) => setTimeout(r, 10));
284
+ cm.handleBridgeDisconnect();
285
+ await waitForClassification();
253
286
 
254
- // Old promise is still pending (we can verify by cancel resolving it).
287
+ const settled = vi.fn();
288
+ watch.promise.then(settled, settled);
289
+ await Promise.resolve();
290
+ expect(settled).not.toHaveBeenCalled();
291
+
292
+ closeSecond();
255
293
  watch.cancel();
256
- await expect(watch.promise).rejects.toBeInstanceOf(CrashWatchCancelledError);
257
294
  });
258
- });
259
-
260
- describe('dispose', () => {
261
- it('ignores events after dispose', async () => {
262
- const { monitor, emit } = createAppMonitorMock();
263
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
264
295
 
296
+ it('ignores a redundant close() call', async () => {
297
+ const { session } = createAppSessionMock({ status: 'running' });
298
+ const cm = createCrashMonitor({ appSession: session });
265
299
  const watch = cm.watch('test.ts', 'execution');
266
300
  watch.promise.catch(noop);
267
301
 
268
- await cm.dispose();
302
+ const closeWindow = cm.expectDisconnect();
303
+ closeWindow();
304
+ closeWindow();
269
305
 
270
- emit({ type: 'app_exited', isConfirmed: true });
271
- await new Promise((r) => setTimeout(r, 10));
306
+ cm.handleBridgeDisconnect();
272
307
 
273
- // After dispose watchers are cleared, so crash didn't propagate.
274
- // The promise is still pending - cancel to settle it.
275
- watch.cancel();
276
- await expect(watch.promise).rejects.toBeInstanceOf(CrashWatchCancelledError);
308
+ await expect(watch.promise).rejects.toBeInstanceOf(RuntimeDisconnectError);
277
309
  });
278
310
 
279
- it('calls appMonitor.dispose()', async () => {
280
- const { monitor } = createAppMonitorMock();
281
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
311
+ it('clears the expected-disconnect window on dispose', async () => {
312
+ const { session } = createAppSessionMock({ status: 'running' });
313
+ const cm = createCrashMonitor({ appSession: session });
314
+ cm.expectDisconnect();
282
315
 
283
316
  await cm.dispose();
284
317
 
285
- expect(monitor.dispose).toHaveBeenCalledOnce();
318
+ // A disconnect after dispose should not throw or hang; monitoring is
319
+ // fully torn down regardless of the window state.
320
+ expect(() => cm.handleBridgeDisconnect()).not.toThrow();
286
321
  });
287
322
  });
288
323
  });