@react-native-harness/jest 1.2.0 → 1.4.0-rc.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 (54) hide show
  1. package/dist/__tests__/bridge.test.js +23 -2
  2. package/dist/__tests__/crash-monitor.test.js +146 -192
  3. package/dist/__tests__/errors.test.js +39 -0
  4. package/dist/__tests__/execute-run.test.js +190 -26
  5. package/dist/__tests__/fixtures/mock-platform-runner.d.ts +7 -0
  6. package/dist/__tests__/fixtures/mock-platform-runner.d.ts.map +1 -0
  7. package/dist/__tests__/fixtures/mock-platform-runner.js +6 -0
  8. package/dist/__tests__/harness-session.test.d.ts +2 -0
  9. package/dist/__tests__/harness-session.test.d.ts.map +1 -0
  10. package/dist/__tests__/harness-session.test.js +74 -0
  11. package/dist/__tests__/index.test.js +59 -10
  12. package/dist/__tests__/test-file-platform-filter.test.d.ts +2 -0
  13. package/dist/__tests__/test-file-platform-filter.test.d.ts.map +1 -0
  14. package/dist/__tests__/test-file-platform-filter.test.js +47 -0
  15. package/dist/crash-monitor.d.ts +5 -4
  16. package/dist/crash-monitor.d.ts.map +1 -1
  17. package/dist/crash-monitor.js +116 -102
  18. package/dist/errors.d.ts +10 -0
  19. package/dist/errors.d.ts.map +1 -1
  20. package/dist/errors.js +41 -2
  21. package/dist/execute-run.d.ts +4 -2
  22. package/dist/execute-run.d.ts.map +1 -1
  23. package/dist/execute-run.js +85 -7
  24. package/dist/harness-session.d.ts +21 -1
  25. package/dist/harness-session.d.ts.map +1 -1
  26. package/dist/harness-session.js +134 -39
  27. package/dist/index.d.ts +7 -3
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +24 -2
  30. package/dist/run.d.ts.map +1 -1
  31. package/dist/run.js +1 -0
  32. package/dist/test-file-platform-filter.d.ts +5 -0
  33. package/dist/test-file-platform-filter.d.ts.map +1 -0
  34. package/dist/test-file-platform-filter.js +40 -0
  35. package/dist/toTestResult.d.ts +1 -0
  36. package/dist/toTestResult.d.ts.map +1 -1
  37. package/dist/toTestResult.js +2 -2
  38. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  39. package/package.json +7 -7
  40. package/src/__tests__/bridge.test.ts +37 -2
  41. package/src/__tests__/crash-monitor.test.ts +182 -231
  42. package/src/__tests__/errors.test.ts +75 -0
  43. package/src/__tests__/execute-run.test.ts +235 -34
  44. package/src/__tests__/harness-session.test.ts +93 -0
  45. package/src/__tests__/index.test.ts +89 -11
  46. package/src/__tests__/test-file-platform-filter.test.ts +83 -0
  47. package/src/crash-monitor.ts +192 -131
  48. package/src/errors.ts +62 -1
  49. package/src/execute-run.ts +125 -11
  50. package/src/harness-session.ts +177 -42
  51. package/src/index.ts +40 -11
  52. package/src/run.ts +1 -0
  53. package/src/test-file-platform-filter.ts +57 -0
  54. package/src/toTestResult.ts +3 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@react-native-harness/jest",
3
- "version": "1.2.0",
3
+ "version": "1.4.0-rc.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -39,12 +39,12 @@
39
39
  "jest-util": "^30.2.0",
40
40
  "tslib": "^2.3.0",
41
41
  "yargs": "^17.7.2",
42
- "@react-native-harness/config": "1.2.0",
43
- "@react-native-harness/platforms": "1.2.0",
44
- "@react-native-harness/tools": "1.2.0",
45
- "@react-native-harness/bundler-metro": "1.2.0",
46
- "@react-native-harness/bridge": "1.2.0",
47
- "@react-native-harness/plugins": "1.2.0"
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"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@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: {
@@ -151,6 +152,38 @@ describe('bridge: createHarnessBridge + connectToHarness', () => {
151
152
  expect(result.tests[0].name).toBe('passes');
152
153
  handle.disconnect();
153
154
  });
155
+
156
+ it('rejects pending app calls when a newer client replaces the session', async () => {
157
+ const firstHandle = await connect({
158
+ runTests: async (): Promise<TestSuiteResult> =>
159
+ await new Promise<TestSuiteResult>(() => undefined),
160
+ });
161
+ firstHandle.reportReady(device);
162
+
163
+ const firstConnection = await bridge.nextConnection();
164
+ const pendingRun = firstConnection.runTests('example.ts', {
165
+ runner: '/runner.js',
166
+ });
167
+ const pendingRunAssertion = expect(pendingRun).rejects.toThrow(
168
+ 'The app bridge was replaced by a newer app connection.',
169
+ );
170
+
171
+ const secondHandle = await connect();
172
+ secondHandle.reportReady({
173
+ ...device,
174
+ model: 'iPhone 17 Pro Replacement',
175
+ });
176
+
177
+ await pendingRunAssertion;
178
+
179
+ await new Promise((r) => setTimeout(r, 20));
180
+
181
+ expect(bridge.connection?.device.model).toBe(
182
+ 'iPhone 17 Pro Replacement',
183
+ );
184
+ secondHandle.disconnect();
185
+ firstHandle.disconnect();
186
+ });
154
187
  });
155
188
 
156
189
  describe('bridge events', () => {
@@ -178,7 +211,9 @@ describe('bridge: createHarnessBridge + connectToHarness', () => {
178
211
  const pending = bridge.nextConnection();
179
212
  bridge.dispose();
180
213
 
181
- await expect(pending).rejects.toThrow('Bridge disposed');
214
+ await expect(pending).rejects.toThrow(
215
+ 'The app bridge was disposed before the test file finished running.',
216
+ );
182
217
  });
183
218
  });
184
219
  });
@@ -1,288 +1,239 @@
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() });
66
-
67
- expect(cm.isAlive()).toBe(false);
68
- });
69
-
70
- it('becomes alive when the app starts', () => {
71
- const { monitor, emit } = createAppMonitorMock();
72
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
73
-
74
- emit({ type: 'app_started' });
75
-
76
- expect(cm.isAlive()).toBe(true);
77
- });
78
-
79
- it('becomes not alive after a confirmed crash', async () => {
80
- const { monitor, emit } = createAppMonitorMock();
81
- 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();
82
57
 
83
- emit({ type: 'app_started' });
84
- const watch = cm.watch('test.ts', 'execution');
85
- watch.promise.catch(noop);
58
+ expect(cm.isAlive()).toBe(false);
86
59
 
87
- emit({ type: 'app_exited', isConfirmed: true });
88
- await watch.promise.catch(noop);
60
+ cm.setAppSession(session);
89
61
 
90
- expect(cm.isAlive()).toBe(false);
91
- });
62
+ expect(cm.isAlive()).toBe(true);
92
63
  });
93
64
 
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);
104
- });
105
-
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() });
109
-
110
- const watch = cm.watch('/test/example.ts', 'startup');
111
- watch.promise.catch(noop);
112
- emit({ type: 'app_exited', isConfirmed: true });
113
-
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
- });
118
-
119
- it('settles the promise with CrashWatchCancelledError on cancel()', async () => {
120
- const { monitor } = createAppMonitorMock();
121
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
122
-
123
- const watch = cm.watch('test.ts', 'execution');
124
- watch.cancel();
125
-
126
- await expect(watch.promise).rejects.toBeInstanceOf(CrashWatchCancelledError);
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',
127
70
  });
71
+ const cm = createCrashMonitor({ appSession: session });
72
+ const watch = cm.watch('/test/example.ts', 'execution');
73
+ watch.promise.catch(noop);
128
74
 
129
- it('subsequent cancel() after crash is a no-op', async () => {
130
- const { monitor, emit } = createAppMonitorMock();
131
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
132
-
133
- const watch = cm.watch('test.ts', 'execution');
134
- watch.promise.catch(noop);
135
- emit({ type: 'app_exited', isConfirmed: true });
75
+ emit({ type: 'app_exited' });
136
76
 
137
- await watch.promise.catch(noop);
138
- // Second cancel should not throw or cause issues.
139
- expect(() => watch.cancel()).not.toThrow();
140
- });
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);
141
82
  });
142
83
 
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 });
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);
152
89
 
153
- await expect(watch.promise).rejects.toBeInstanceOf(NativeCrashError);
90
+ cm.handleBridgeDisconnect();
91
+ setState({
92
+ status: 'exited',
93
+ occurredAt: Date.now(),
94
+ reason: 'process-gone',
154
95
  });
155
96
 
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 });
160
-
161
- const watch = cm.watch('test.ts', 'execution');
162
- const settled = vi.fn();
163
- watch.promise.then(settled, settled);
164
-
165
- emit({ type: 'app_exited', isConfirmed: false });
166
- await new Promise((r) => setTimeout(r, 20));
167
-
168
- expect(settled).not.toHaveBeenCalled();
169
- watch.cancel();
170
- });
97
+ await expect(watch.promise).rejects.toBeInstanceOf(NativeCrashError);
98
+ });
171
99
 
172
- it('fires on possible_crash when confirmed', async () => {
173
- const { monitor, emit } = createAppMonitorMock();
174
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
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);
175
105
 
176
- const watch = cm.watch('test.ts', 'execution');
177
- watch.promise.catch(noop);
178
- emit({ type: 'possible_crash', isConfirmed: true });
106
+ cm.handleBridgeDisconnect();
179
107
 
180
- await expect(watch.promise).rejects.toBeInstanceOf(NativeCrashError);
181
- });
108
+ await expect(watch.promise).rejects.toBeInstanceOf(RuntimeDisconnectError);
182
109
  });
183
110
 
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 });
193
-
194
- const watch = cm.watch('test.ts', 'execution');
195
- watch.promise.catch(noop);
196
- emit({ type: 'app_exited', isConfirmed: true, crashDetails: { pid: 1234 } });
197
-
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
- });
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']);
203
131
  });
204
132
 
205
- describe('stop / start', () => {
206
- it('ignores events while stopped', async () => {
207
- const { monitor, emit } = createAppMonitorMock();
208
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
209
-
210
- await cm.stop();
211
-
212
- const watch = cm.watch('test.ts', 'execution');
213
- const settled = vi.fn();
214
- watch.promise.then(settled, settled);
215
-
216
- emit({ type: 'app_exited', isConfirmed: true });
217
- await new Promise((r) => setTimeout(r, 10));
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',
156
+ });
157
+
158
+ const error = await watch.promise.catch((err: NativeCrashError) => err);
159
+
160
+ expect(getCrashDetails).toHaveBeenCalledWith({
161
+ occurredAt: expect.any(Number),
162
+ pid: 7777,
163
+ processName: undefined,
164
+ testFilePath: '/test/example.ts',
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
+ );
175
+ });
218
176
 
219
- expect(settled).not.toHaveBeenCalled();
220
- watch.cancel();
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');
221
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);
222
190
 
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();
191
+ cm.handleBridgeDisconnect();
192
+ setState({ status: 'exited', occurredAt, reason: 'process-gone' });
229
193
 
230
- const watch = cm.watch('test.ts', 'execution');
231
- watch.promise.catch(noop);
232
- emit({ type: 'app_exited', isConfirmed: true });
194
+ const error = await watch.promise.catch((err: NativeCrashError) => err);
233
195
 
234
- await expect(watch.promise).rejects.toBeInstanceOf(NativeCrashError);
235
- });
196
+ expect(error.details.rawLines).toEqual(['MyApp[123] fatal error: boom']);
197
+ expect(error.message).toContain("Harness couldn't extract the crash log.");
236
198
  });
237
199
 
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() });
200
+ it('settles the promise with CrashWatchCancelledError on cancel()', async () => {
201
+ const cm = createCrashMonitor();
202
+ const watch = cm.watch('test.ts', 'execution');
242
203
 
243
- emit({ type: 'app_started' });
244
- const watch = cm.watch('test.ts', 'execution');
245
- watch.promise.catch(noop);
204
+ watch.cancel();
246
205
 
247
- cm.reset();
248
-
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));
253
-
254
- // Old promise is still pending (we can verify by cancel resolving it).
255
- watch.cancel();
256
- await expect(watch.promise).rejects.toBeInstanceOf(CrashWatchCancelledError);
257
- });
206
+ await expect(watch.promise).rejects.toBeInstanceOf(
207
+ CrashWatchCancelledError
208
+ );
258
209
  });
259
210
 
260
- describe('dispose', () => {
261
- it('ignores events after dispose', async () => {
262
- const { monitor, emit } = createAppMonitorMock();
263
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
264
-
265
- const watch = cm.watch('test.ts', 'execution');
266
- watch.promise.catch(noop);
211
+ it('ignores session events while stopped', async () => {
212
+ const { session, emit } = createAppSessionMock({
213
+ status: 'exited',
214
+ occurredAt: Date.now(),
215
+ });
216
+ const cm = createCrashMonitor({ appSession: session });
217
+ await cm.stop();
267
218
 
268
- await cm.dispose();
219
+ const watch = cm.watch('test.ts', 'execution');
220
+ const settled = vi.fn();
221
+ watch.promise.then(settled, settled);
269
222
 
270
- emit({ type: 'app_exited', isConfirmed: true });
271
- await new Promise((r) => setTimeout(r, 10));
223
+ emit({ type: 'app_exited' });
224
+ await waitForClassification();
272
225
 
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);
277
- });
226
+ expect(settled).not.toHaveBeenCalled();
227
+ watch.cancel();
228
+ });
278
229
 
279
- it('calls appMonitor.dispose()', async () => {
280
- const { monitor } = createAppMonitorMock();
281
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
230
+ it('removes the app session listener on dispose', async () => {
231
+ const { session } = createAppSessionMock();
232
+ const cm = createCrashMonitor({ appSession: session });
282
233
 
283
- await cm.dispose();
234
+ await cm.dispose();
284
235
 
285
- expect(monitor.dispose).toHaveBeenCalledOnce();
286
- });
236
+ expect(session.removeListener).toHaveBeenCalledOnce();
237
+ expect(cm.isAlive()).toBe(false);
287
238
  });
288
239
  });
@@ -1,4 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
+ import path from 'node:path';
3
+ import { AppBridgeDisconnectedError } from '@react-native-harness/bridge/server';
2
4
  import { NativeCrashError, PlatformReadyTimeoutError } from '../errors.js';
3
5
 
4
6
  describe('PlatformReadyTimeoutError', () => {
@@ -10,6 +12,62 @@ describe('PlatformReadyTimeoutError', () => {
10
12
  });
11
13
 
12
14
  describe('NativeCrashError', () => {
15
+ it('reports the extracted crash log path when available', () => {
16
+ const error = new NativeCrashError('/tmp/crash.harness.ts', {
17
+ phase: 'execution',
18
+ artifactPath: path.join(
19
+ process.cwd(),
20
+ '.harness',
21
+ 'crash-reports',
22
+ 'crash.ips'
23
+ ),
24
+ });
25
+
26
+ expect(error.message).toContain(
27
+ `Harness extracted the crash log: ${path.join(
28
+ '.harness',
29
+ 'crash-reports',
30
+ 'crash.ips'
31
+ )}`
32
+ );
33
+ });
34
+
35
+ it('lists enrichment artifact paths when available', () => {
36
+ const error = new NativeCrashError('/tmp/crash.harness.ts', {
37
+ phase: 'execution',
38
+ artifactPath: path.join(
39
+ process.cwd(),
40
+ '.harness',
41
+ 'crash-reports',
42
+ 'logcat.txt'
43
+ ),
44
+ enrichmentArtifacts: [
45
+ {
46
+ artifactType: 'dropbox-native-crash',
47
+ artifactPath: path.join(
48
+ process.cwd(),
49
+ '.harness',
50
+ 'crash-reports',
51
+ 'dropbox-native-crash.txt'
52
+ ),
53
+ },
54
+ ],
55
+ });
56
+
57
+ expect(error.message).toContain('Additional crash artifacts:');
58
+ expect(error.message).toContain(
59
+ path.join('.harness', 'crash-reports', 'dropbox-native-crash.txt')
60
+ );
61
+ });
62
+
63
+ it('reports crash log extraction failure when no artifact was pulled', () => {
64
+ const error = new NativeCrashError('/tmp/crash.harness.ts', {
65
+ phase: 'execution',
66
+ });
67
+
68
+ expect(error.message).toContain("Harness couldn't extract the crash log.");
69
+ });
70
+
13
71
  it('formats the extracted stack trace in the error message', () => {
14
72
  const error = new NativeCrashError('/tmp/crash.harness.ts', {
15
73
  phase: 'execution',
@@ -80,3 +138,20 @@ describe('NativeCrashError', () => {
80
138
  );
81
139
  });
82
140
  });
141
+
142
+ describe('AppBridgeDisconnectedError', () => {
143
+ it('explains likely causes without exposing an internal stack', () => {
144
+ const error = new AppBridgeDisconnectedError('app-disconnected');
145
+
146
+ expect(error.message).toBe(
147
+ 'The app bridge disconnected during test execution. This can happen if the app was killed, crashed, reloaded, or restarted while the test file was running.'
148
+ );
149
+ expect(error.stack).toBe(`AppBridgeDisconnectedError: ${error.message}`);
150
+ });
151
+
152
+ it('uses a reconnection-specific message when a newer app connection replaces the old one', () => {
153
+ expect(new AppBridgeDisconnectedError('app-replaced').message).toBe(
154
+ 'The app bridge was replaced by a newer app connection. This can happen when the app reloads, restarts, or reconnects while a test file is still running.'
155
+ );
156
+ });
157
+ });