@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
@@ -1,7 +1,7 @@
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 { createHarnessBridge } from '@react-native-harness/bridge/server';
@@ -117,6 +117,27 @@ describe('bridge: createHarnessBridge + connectToHarness', () => {
117
117
  expect(result.tests[0].name).toBe('passes');
118
118
  handle.disconnect();
119
119
  });
120
+ it('rejects pending app calls when a newer client replaces the session', async () => {
121
+ const firstHandle = await connect({
122
+ runTests: async () => await new Promise(() => undefined),
123
+ });
124
+ firstHandle.reportReady(device);
125
+ const firstConnection = await bridge.nextConnection();
126
+ const pendingRun = firstConnection.runTests('example.ts', {
127
+ runner: '/runner.js',
128
+ });
129
+ const pendingRunAssertion = expect(pendingRun).rejects.toThrow('The app bridge was replaced by a newer app connection.');
130
+ const secondHandle = await connect();
131
+ secondHandle.reportReady({
132
+ ...device,
133
+ model: 'iPhone 17 Pro Replacement',
134
+ });
135
+ await pendingRunAssertion;
136
+ await new Promise((r) => setTimeout(r, 20));
137
+ expect(bridge.connection?.device.model).toBe('iPhone 17 Pro Replacement');
138
+ secondHandle.disconnect();
139
+ firstHandle.disconnect();
140
+ });
120
141
  });
121
142
  describe('bridge events', () => {
122
143
  it('emitEvent on app side fires the event listener on bridge', async () => {
@@ -136,7 +157,7 @@ describe('bridge: createHarnessBridge + connectToHarness', () => {
136
157
  it('rejects pending nextConnection() waiters', async () => {
137
158
  const pending = bridge.nextConnection();
138
159
  bridge.dispose();
139
- await expect(pending).rejects.toThrow('Bridge disposed');
160
+ await expect(pending).rejects.toThrow('The app bridge was disposed before the test file finished running.');
140
161
  });
141
162
  });
142
163
  });
@@ -1,212 +1,166 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
+ import path from 'node:path';
2
3
  import { createCrashMonitor, CrashWatchCancelledError, } from '../crash-monitor.js';
3
- import { NativeCrashError } from '../errors.js';
4
+ import { NativeCrashError, RuntimeDisconnectError } from '../errors.js';
4
5
  const noop = () => undefined;
5
- const resolveUndefined = async () => undefined;
6
- // ---------------------------------------------------------------------------
7
- // Test doubles
8
- // ---------------------------------------------------------------------------
9
- const createAppMonitorMock = () => {
10
- let registeredListener = null;
11
- const monitor = {
12
- start: vi.fn(resolveUndefined),
13
- stop: vi.fn(resolveUndefined),
14
- dispose: vi.fn(resolveUndefined),
6
+ const waitForClassification = () => new Promise((resolve) => setTimeout(resolve, 1600));
7
+ const createAppSessionMock = (initialState = { status: 'running' }, logs = [], getCrashDetails) => {
8
+ let state = initialState;
9
+ let listener = null;
10
+ const session = {
11
+ dispose: vi.fn(async () => undefined),
12
+ getState: vi.fn(async () => state),
13
+ getLogs: vi.fn(() => logs),
15
14
  addListener: vi.fn((l) => {
16
- registeredListener = l;
15
+ listener = l;
17
16
  }),
18
17
  removeListener: vi.fn((l) => {
19
- if (registeredListener === l)
20
- registeredListener = null;
18
+ if (listener === l)
19
+ listener = null;
21
20
  }),
22
21
  };
22
+ if (getCrashDetails) {
23
+ session.getCrashDetails = getCrashDetails;
24
+ }
23
25
  return {
24
- monitor,
25
- emit: (event) => registeredListener?.(event),
26
+ session,
27
+ setState: (nextState) => {
28
+ state = nextState;
29
+ },
30
+ emit: (event) => listener?.(event),
26
31
  };
27
32
  };
28
- const createPlatformRunnerMock = (isRunning = false, crashDetails = null) => ({
29
- isAppRunning: vi.fn(async () => isRunning),
30
- getCrashDetails: vi.fn(async () => crashDetails),
31
- startApp: vi.fn(resolveUndefined),
32
- restartApp: vi.fn(resolveUndefined),
33
- stopApp: vi.fn(resolveUndefined),
34
- dispose: vi.fn(resolveUndefined),
35
- createAppMonitor: vi.fn(),
36
- });
37
- // ---------------------------------------------------------------------------
38
- // Tests
39
- // ---------------------------------------------------------------------------
40
33
  describe('createCrashMonitor', () => {
41
- describe('liveness', () => {
42
- it('starts not alive', () => {
43
- const { monitor } = createAppMonitorMock();
44
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
45
- expect(cm.isAlive()).toBe(false);
46
- });
47
- it('becomes alive when the app starts', () => {
48
- const { monitor, emit } = createAppMonitorMock();
49
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
50
- emit({ type: 'app_started' });
51
- expect(cm.isAlive()).toBe(true);
52
- });
53
- it('becomes not alive after a confirmed crash', async () => {
54
- const { monitor, emit } = createAppMonitorMock();
55
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
56
- emit({ type: 'app_started' });
57
- const watch = cm.watch('test.ts', 'execution');
58
- watch.promise.catch(noop);
59
- emit({ type: 'app_exited', isConfirmed: true });
60
- await watch.promise.catch(noop);
61
- expect(cm.isAlive()).toBe(false);
62
- });
34
+ it('starts not alive and becomes alive when an app session is attached', () => {
35
+ const cm = createCrashMonitor();
36
+ const { session } = createAppSessionMock();
37
+ expect(cm.isAlive()).toBe(false);
38
+ cm.setAppSession(session);
39
+ expect(cm.isAlive()).toBe(true);
63
40
  });
64
- describe('watch', () => {
65
- it('promise rejects with NativeCrashError on confirmed app_exited', async () => {
66
- const { monitor, emit } = createAppMonitorMock();
67
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
68
- const watch = cm.watch('/test/example.ts', 'execution');
69
- watch.promise.catch(noop);
70
- emit({ type: 'app_exited', isConfirmed: true });
71
- await expect(watch.promise).rejects.toBeInstanceOf(NativeCrashError);
72
- });
73
- it('attributes the crash to the file and phase passed to watch()', async () => {
74
- const { monitor, emit } = createAppMonitorMock();
75
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
76
- const watch = cm.watch('/test/example.ts', 'startup');
77
- watch.promise.catch(noop);
78
- emit({ type: 'app_exited', isConfirmed: true });
79
- const error = await watch.promise.catch((e) => e);
80
- expect(error.testFilePath).toBe('/test/example.ts');
81
- expect(error.details.phase).toBe('startup');
82
- });
83
- it('settles the promise with CrashWatchCancelledError on cancel()', async () => {
84
- const { monitor } = createAppMonitorMock();
85
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
86
- const watch = cm.watch('test.ts', 'execution');
87
- watch.cancel();
88
- await expect(watch.promise).rejects.toBeInstanceOf(CrashWatchCancelledError);
89
- });
90
- it('subsequent cancel() after crash is a no-op', async () => {
91
- const { monitor, emit } = createAppMonitorMock();
92
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
93
- const watch = cm.watch('test.ts', 'execution');
94
- watch.promise.catch(noop);
95
- emit({ type: 'app_exited', isConfirmed: true });
96
- await watch.promise.catch(noop);
97
- // Second cancel should not throw or cause issues.
98
- expect(() => watch.cancel()).not.toThrow();
99
- });
41
+ it('rejects a watch with NativeCrashError when the app session exits', async () => {
42
+ const { session, emit } = createAppSessionMock({
43
+ status: 'exited',
44
+ occurredAt: Date.now(),
45
+ reason: 'observed-exit',
46
+ });
47
+ const cm = createCrashMonitor({ appSession: session });
48
+ const watch = cm.watch('/test/example.ts', 'execution');
49
+ watch.promise.catch(noop);
50
+ emit({ type: 'app_exited' });
51
+ const error = await watch.promise.catch((err) => err);
52
+ expect(error).toBeInstanceOf(NativeCrashError);
53
+ expect(error.testFilePath).toBe('/test/example.ts');
54
+ expect(error.details.phase).toBe('execution');
55
+ expect(cm.isAlive()).toBe(false);
100
56
  });
101
- describe('unconfirmed events', () => {
102
- it('fires the crash if isAppRunning returns false', async () => {
103
- const { monitor, emit } = createAppMonitorMock();
104
- const runner = createPlatformRunnerMock(false /* not running */);
105
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: runner });
106
- const watch = cm.watch('test.ts', 'execution');
107
- watch.promise.catch(noop);
108
- emit({ type: 'app_exited', isConfirmed: false });
109
- await expect(watch.promise).rejects.toBeInstanceOf(NativeCrashError);
110
- });
111
- it('does not fire if isAppRunning returns true', async () => {
112
- const { monitor, emit } = createAppMonitorMock();
113
- const runner = createPlatformRunnerMock(true /* still running */);
114
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: runner });
115
- const watch = cm.watch('test.ts', 'execution');
116
- const settled = vi.fn();
117
- watch.promise.then(settled, settled);
118
- emit({ type: 'app_exited', isConfirmed: false });
119
- await new Promise((r) => setTimeout(r, 20));
120
- expect(settled).not.toHaveBeenCalled();
121
- watch.cancel();
122
- });
123
- it('fires on possible_crash when confirmed', async () => {
124
- const { monitor, emit } = createAppMonitorMock();
125
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
126
- const watch = cm.watch('test.ts', 'execution');
127
- watch.promise.catch(noop);
128
- emit({ type: 'possible_crash', isConfirmed: true });
129
- await expect(watch.promise).rejects.toBeInstanceOf(NativeCrashError);
130
- });
57
+ it('classifies bridge disconnect plus exited app as NativeCrashError', async () => {
58
+ const { session, setState } = createAppSessionMock();
59
+ const cm = createCrashMonitor({ appSession: session });
60
+ const watch = cm.watch('test.ts', 'execution');
61
+ watch.promise.catch(noop);
62
+ cm.handleBridgeDisconnect();
63
+ setState({
64
+ status: 'exited',
65
+ occurredAt: Date.now(),
66
+ reason: 'process-gone',
67
+ });
68
+ await expect(watch.promise).rejects.toBeInstanceOf(NativeCrashError);
131
69
  });
132
- describe('crash detail enrichment', () => {
133
- it('merges initial and enriched crash details', async () => {
134
- const { monitor, emit } = createAppMonitorMock();
135
- const runner = createPlatformRunnerMock(false, {
136
- processName: 'MyApp',
137
- signal: 'SIGSEGV',
138
- summary: 'Segmentation fault',
139
- });
140
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: runner });
141
- const watch = cm.watch('test.ts', 'execution');
142
- watch.promise.catch(noop);
143
- emit({ type: 'app_exited', isConfirmed: true, crashDetails: { pid: 1234 } });
144
- const error = await watch.promise.catch((e) => e);
145
- expect(error.details.processName).toBe('MyApp');
146
- expect(error.details.signal).toBe('SIGSEGV');
147
- expect(error.details.pid).toBe(1234);
148
- });
70
+ it('classifies bridge disconnect plus running app as RuntimeDisconnectError', async () => {
71
+ const { session } = createAppSessionMock({ status: 'running' });
72
+ const cm = createCrashMonitor({ appSession: session });
73
+ const watch = cm.watch('test.ts', 'execution');
74
+ watch.promise.catch(noop);
75
+ cm.handleBridgeDisconnect();
76
+ await expect(watch.promise).rejects.toBeInstanceOf(RuntimeDisconnectError);
149
77
  });
150
- describe('stop / start', () => {
151
- it('ignores events while stopped', async () => {
152
- const { monitor, emit } = createAppMonitorMock();
153
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
154
- await cm.stop();
155
- const watch = cm.watch('test.ts', 'execution');
156
- const settled = vi.fn();
157
- watch.promise.then(settled, settled);
158
- emit({ type: 'app_exited', isConfirmed: true });
159
- await new Promise((r) => setTimeout(r, 10));
160
- expect(settled).not.toHaveBeenCalled();
161
- watch.cancel();
162
- });
163
- it('resumes monitoring after start()', async () => {
164
- const { monitor, emit } = createAppMonitorMock();
165
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
166
- await cm.stop();
167
- await cm.start();
168
- const watch = cm.watch('test.ts', 'execution');
169
- watch.promise.catch(noop);
170
- emit({ type: 'app_exited', isConfirmed: true });
171
- await expect(watch.promise).rejects.toBeInstanceOf(NativeCrashError);
172
- });
78
+ it('attaches matching session log evidence to native crash details', async () => {
79
+ const occurredAt = Date.now();
80
+ const logs = [
81
+ { line: 'ordinary app log', occurredAt },
82
+ { line: 'MyApp[123] fatal error: boom', occurredAt },
83
+ ];
84
+ const { session, setState } = createAppSessionMock({ status: 'running' }, logs);
85
+ const cm = createCrashMonitor({ appSession: session });
86
+ const watch = cm.watch('test.ts', 'execution');
87
+ watch.promise.catch(noop);
88
+ cm.handleBridgeDisconnect();
89
+ setState({ status: 'exited', occurredAt, reason: 'process-gone' });
90
+ const error = await watch.promise.catch((err) => err);
91
+ expect(error).toBeInstanceOf(NativeCrashError);
92
+ expect(error.details.rawLines).toEqual(['MyApp[123] fatal error: boom']);
173
93
  });
174
- describe('reset', () => {
175
- it('clears alive state and pending watchers', async () => {
176
- const { monitor, emit } = createAppMonitorMock();
177
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
178
- emit({ type: 'app_started' });
179
- const watch = cm.watch('test.ts', 'execution');
180
- watch.promise.catch(noop);
181
- cm.reset();
182
- expect(cm.isAlive()).toBe(false);
183
- // The watcher was cleared; a crash fired now should not reach the old watch.
184
- emit({ type: 'app_exited', isConfirmed: true });
185
- await new Promise((r) => setTimeout(r, 10));
186
- // Old promise is still pending (we can verify by cancel resolving it).
187
- watch.cancel();
188
- await expect(watch.promise).rejects.toBeInstanceOf(CrashWatchCancelledError);
189
- });
94
+ it('asks the app session to extract native crash details for the current test', async () => {
95
+ const occurredAt = Date.now();
96
+ const getCrashDetails = vi.fn(async () => ({
97
+ artifactType: 'logcat',
98
+ artifactPath: '/tmp/.harness/crash-reports/crash-logcat.txt',
99
+ processName: 'com.harnessplayground',
100
+ pid: 7777,
101
+ }));
102
+ const { session, setState } = createAppSessionMock({ status: 'running' }, [], getCrashDetails);
103
+ const cm = createCrashMonitor({ appSession: session });
104
+ const watch = cm.watch('/test/example.ts', 'execution');
105
+ watch.promise.catch(noop);
106
+ cm.handleBridgeDisconnect();
107
+ setState({
108
+ status: 'exited',
109
+ occurredAt,
110
+ pid: 7777,
111
+ reason: 'process-gone',
112
+ });
113
+ const error = await watch.promise.catch((err) => err);
114
+ expect(getCrashDetails).toHaveBeenCalledWith({
115
+ occurredAt: expect.any(Number),
116
+ pid: 7777,
117
+ processName: undefined,
118
+ testFilePath: '/test/example.ts',
119
+ });
120
+ expect(error.details.artifactPath).toBe('/tmp/.harness/crash-reports/crash-logcat.txt');
121
+ expect(error.message).toContain(`Harness extracted the crash log: ${path.relative(process.cwd(), '/tmp/.harness/crash-reports/crash-logcat.txt')}`);
190
122
  });
191
- describe('dispose', () => {
192
- it('ignores events after dispose', async () => {
193
- const { monitor, emit } = createAppMonitorMock();
194
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
195
- const watch = cm.watch('test.ts', 'execution');
196
- watch.promise.catch(noop);
197
- await cm.dispose();
198
- emit({ type: 'app_exited', isConfirmed: true });
199
- await new Promise((r) => setTimeout(r, 10));
200
- // After dispose watchers are cleared, so crash didn't propagate.
201
- // The promise is still pending - cancel to settle it.
202
- watch.cancel();
203
- await expect(watch.promise).rejects.toBeInstanceOf(CrashWatchCancelledError);
204
- });
205
- it('calls appMonitor.dispose()', async () => {
206
- const { monitor } = createAppMonitorMock();
207
- const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
208
- await cm.dispose();
209
- expect(monitor.dispose).toHaveBeenCalledOnce();
210
- });
123
+ it('falls back to log details when native crash extraction fails', async () => {
124
+ const occurredAt = Date.now();
125
+ const getCrashDetails = vi.fn(async () => {
126
+ throw new Error('copy failed');
127
+ });
128
+ const { session, setState } = createAppSessionMock({ status: 'running' }, [{ line: 'MyApp[123] fatal error: boom', occurredAt }], getCrashDetails);
129
+ const cm = createCrashMonitor({ appSession: session });
130
+ const watch = cm.watch('/test/example.ts', 'execution');
131
+ watch.promise.catch(noop);
132
+ cm.handleBridgeDisconnect();
133
+ setState({ status: 'exited', occurredAt, reason: 'process-gone' });
134
+ const error = await watch.promise.catch((err) => err);
135
+ expect(error.details.rawLines).toEqual(['MyApp[123] fatal error: boom']);
136
+ expect(error.message).toContain("Harness couldn't extract the crash log.");
137
+ });
138
+ it('settles the promise with CrashWatchCancelledError on cancel()', async () => {
139
+ const cm = createCrashMonitor();
140
+ const watch = cm.watch('test.ts', 'execution');
141
+ watch.cancel();
142
+ await expect(watch.promise).rejects.toBeInstanceOf(CrashWatchCancelledError);
143
+ });
144
+ it('ignores session events while stopped', async () => {
145
+ const { session, emit } = createAppSessionMock({
146
+ status: 'exited',
147
+ occurredAt: Date.now(),
148
+ });
149
+ const cm = createCrashMonitor({ appSession: session });
150
+ await cm.stop();
151
+ const watch = cm.watch('test.ts', 'execution');
152
+ const settled = vi.fn();
153
+ watch.promise.then(settled, settled);
154
+ emit({ type: 'app_exited' });
155
+ await waitForClassification();
156
+ expect(settled).not.toHaveBeenCalled();
157
+ watch.cancel();
158
+ });
159
+ it('removes the app session listener on dispose', async () => {
160
+ const { session } = createAppSessionMock();
161
+ const cm = createCrashMonitor({ appSession: session });
162
+ await cm.dispose();
163
+ expect(session.removeListener).toHaveBeenCalledOnce();
164
+ expect(cm.isAlive()).toBe(false);
211
165
  });
212
166
  });
@@ -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
  describe('PlatformReadyTimeoutError', () => {
4
6
  it('includes the configured timeout and config hint', () => {
@@ -6,6 +8,33 @@ describe('PlatformReadyTimeoutError', () => {
6
8
  });
7
9
  });
8
10
  describe('NativeCrashError', () => {
11
+ it('reports the extracted crash log path when available', () => {
12
+ const error = new NativeCrashError('/tmp/crash.harness.ts', {
13
+ phase: 'execution',
14
+ artifactPath: path.join(process.cwd(), '.harness', 'crash-reports', 'crash.ips'),
15
+ });
16
+ expect(error.message).toContain(`Harness extracted the crash log: ${path.join('.harness', 'crash-reports', 'crash.ips')}`);
17
+ });
18
+ it('lists enrichment artifact paths when available', () => {
19
+ const error = new NativeCrashError('/tmp/crash.harness.ts', {
20
+ phase: 'execution',
21
+ artifactPath: path.join(process.cwd(), '.harness', 'crash-reports', 'logcat.txt'),
22
+ enrichmentArtifacts: [
23
+ {
24
+ artifactType: 'dropbox-native-crash',
25
+ artifactPath: path.join(process.cwd(), '.harness', 'crash-reports', 'dropbox-native-crash.txt'),
26
+ },
27
+ ],
28
+ });
29
+ expect(error.message).toContain('Additional crash artifacts:');
30
+ expect(error.message).toContain(path.join('.harness', 'crash-reports', 'dropbox-native-crash.txt'));
31
+ });
32
+ it('reports crash log extraction failure when no artifact was pulled', () => {
33
+ const error = new NativeCrashError('/tmp/crash.harness.ts', {
34
+ phase: 'execution',
35
+ });
36
+ expect(error.message).toContain("Harness couldn't extract the crash log.");
37
+ });
9
38
  it('formats the extracted stack trace in the error message', () => {
10
39
  const error = new NativeCrashError('/tmp/crash.harness.ts', {
11
40
  phase: 'execution',
@@ -57,3 +86,13 @@ describe('NativeCrashError', () => {
57
86
  expect(error.stack).toBe('NativeCrashError: The native app crashed while preparing to run this test file.');
58
87
  });
59
88
  });
89
+ describe('AppBridgeDisconnectedError', () => {
90
+ it('explains likely causes without exposing an internal stack', () => {
91
+ const error = new AppBridgeDisconnectedError('app-disconnected');
92
+ expect(error.message).toBe('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.');
93
+ expect(error.stack).toBe(`AppBridgeDisconnectedError: ${error.message}`);
94
+ });
95
+ it('uses a reconnection-specific message when a newer app connection replaces the old one', () => {
96
+ expect(new AppBridgeDisconnectedError('app-replaced').message).toBe('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.');
97
+ });
98
+ });