@react-native-harness/jest 1.2.0-rc.1 → 1.2.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.
- package/dist/__tests__/bridge.test.d.ts +2 -0
- package/dist/__tests__/bridge.test.d.ts.map +1 -0
- package/dist/__tests__/bridge.test.js +142 -0
- package/dist/__tests__/client-log-handler.test.js +116 -72
- package/dist/__tests__/crash-monitor.test.d.ts +2 -0
- package/dist/__tests__/crash-monitor.test.d.ts.map +1 -0
- package/dist/__tests__/crash-monitor.test.js +212 -0
- package/dist/__tests__/execute-run.test.d.ts +2 -0
- package/dist/__tests__/execute-run.test.d.ts.map +1 -0
- package/dist/__tests__/execute-run.test.js +229 -0
- package/dist/__tests__/hook-queue.test.d.ts +2 -0
- package/dist/__tests__/hook-queue.test.d.ts.map +1 -0
- package/dist/__tests__/hook-queue.test.js +56 -0
- package/dist/__tests__/index.test.js +94 -37
- package/dist/__tests__/resource-lock.test.js +24 -24
- package/dist/client-log-handler.d.ts +10 -11
- package/dist/client-log-handler.d.ts.map +1 -1
- package/dist/client-log-handler.js +36 -58
- package/dist/crash-monitor.d.ts +17 -10
- package/dist/crash-monitor.d.ts.map +1 -1
- package/dist/crash-monitor.js +164 -63
- package/dist/execute-run.d.ts +4 -0
- package/dist/execute-run.d.ts.map +1 -0
- package/dist/execute-run.js +151 -0
- package/dist/harness-session.d.ts +34 -0
- package/dist/harness-session.d.ts.map +1 -0
- package/dist/harness-session.js +523 -0
- package/dist/hook-queue.d.ts +6 -0
- package/dist/hook-queue.d.ts.map +1 -0
- package/dist/hook-queue.js +18 -0
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -230
- package/dist/logs.d.ts +1 -0
- package/dist/logs.d.ts.map +1 -1
- package/dist/logs.js +3 -0
- package/dist/resource-lock.d.ts +0 -1
- package/dist/resource-lock.d.ts.map +1 -1
- package/dist/resource-lock.js +1 -1
- package/dist/run.d.ts +2 -2
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +3 -3
- package/dist/tsconfig.lib.tsbuildinfo +1 -1
- package/package.json +7 -8
- package/src/__tests__/bridge.test.ts +184 -0
- package/src/__tests__/client-log-handler.test.ts +117 -81
- package/src/__tests__/crash-monitor.test.ts +288 -0
- package/src/__tests__/execute-run.test.ts +325 -0
- package/src/__tests__/hook-queue.test.ts +77 -0
- package/src/__tests__/index.test.ts +135 -55
- package/src/__tests__/resource-lock.test.ts +29 -42
- package/src/client-log-handler.ts +48 -64
- package/src/crash-monitor.ts +248 -0
- package/src/execute-run.ts +199 -0
- package/src/harness-session.ts +734 -0
- package/src/hook-queue.ts +26 -0
- package/src/index.ts +17 -330
- package/src/logs.ts +4 -0
- package/src/resource-lock.ts +1 -1
- package/src/run.ts +5 -5
- package/tsconfig.tsbuildinfo +1 -0
- package/src/__tests__/crash-supervisor.test.ts +0 -200
- package/src/__tests__/harness-cache.test.ts +0 -74
- package/src/__tests__/harness.test.ts +0 -923
- package/src/crash-supervisor.ts +0 -321
- package/src/env.d.ts +0 -9
- package/src/harness.ts +0 -870
- package/src/setup.ts +0 -127
- package/src/teardown.ts +0 -14
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type {
|
|
3
|
+
AppCrashDetails,
|
|
4
|
+
AppMonitor,
|
|
5
|
+
AppMonitorEvent,
|
|
6
|
+
AppMonitorListener,
|
|
7
|
+
HarnessPlatformRunner,
|
|
8
|
+
} from '@react-native-harness/platforms';
|
|
9
|
+
import {
|
|
10
|
+
createCrashMonitor,
|
|
11
|
+
CrashWatchCancelledError,
|
|
12
|
+
} from '../crash-monitor.js';
|
|
13
|
+
import { NativeCrashError } from '../errors.js';
|
|
14
|
+
|
|
15
|
+
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;
|
|
31
|
+
}),
|
|
32
|
+
removeListener: vi.fn((l: AppMonitorListener) => {
|
|
33
|
+
if (registeredListener === l) registeredListener = null;
|
|
34
|
+
}),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
monitor,
|
|
39
|
+
emit: (event: AppMonitorEvent) => registeredListener?.(event),
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
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
|
+
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() });
|
|
82
|
+
|
|
83
|
+
emit({ type: 'app_started' });
|
|
84
|
+
const watch = cm.watch('test.ts', 'execution');
|
|
85
|
+
watch.promise.catch(noop);
|
|
86
|
+
|
|
87
|
+
emit({ type: 'app_exited', isConfirmed: true });
|
|
88
|
+
await watch.promise.catch(noop);
|
|
89
|
+
|
|
90
|
+
expect(cm.isAlive()).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
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);
|
|
127
|
+
});
|
|
128
|
+
|
|
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 });
|
|
136
|
+
|
|
137
|
+
await watch.promise.catch(noop);
|
|
138
|
+
// Second cancel should not throw or cause issues.
|
|
139
|
+
expect(() => watch.cancel()).not.toThrow();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
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);
|
|
154
|
+
});
|
|
155
|
+
|
|
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
|
+
});
|
|
171
|
+
|
|
172
|
+
it('fires on possible_crash when confirmed', async () => {
|
|
173
|
+
const { monitor, emit } = createAppMonitorMock();
|
|
174
|
+
const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
|
|
175
|
+
|
|
176
|
+
const watch = cm.watch('test.ts', 'execution');
|
|
177
|
+
watch.promise.catch(noop);
|
|
178
|
+
emit({ type: 'possible_crash', isConfirmed: true });
|
|
179
|
+
|
|
180
|
+
await expect(watch.promise).rejects.toBeInstanceOf(NativeCrashError);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
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
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
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));
|
|
218
|
+
|
|
219
|
+
expect(settled).not.toHaveBeenCalled();
|
|
220
|
+
watch.cancel();
|
|
221
|
+
});
|
|
222
|
+
|
|
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
|
+
|
|
230
|
+
const watch = cm.watch('test.ts', 'execution');
|
|
231
|
+
watch.promise.catch(noop);
|
|
232
|
+
emit({ type: 'app_exited', isConfirmed: true });
|
|
233
|
+
|
|
234
|
+
await expect(watch.promise).rejects.toBeInstanceOf(NativeCrashError);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
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() });
|
|
242
|
+
|
|
243
|
+
emit({ type: 'app_started' });
|
|
244
|
+
const watch = cm.watch('test.ts', 'execution');
|
|
245
|
+
watch.promise.catch(noop);
|
|
246
|
+
|
|
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
|
+
});
|
|
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
|
+
|
|
265
|
+
const watch = cm.watch('test.ts', 'execution');
|
|
266
|
+
watch.promise.catch(noop);
|
|
267
|
+
|
|
268
|
+
await cm.dispose();
|
|
269
|
+
|
|
270
|
+
emit({ type: 'app_exited', isConfirmed: true });
|
|
271
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
272
|
+
|
|
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
|
+
});
|
|
278
|
+
|
|
279
|
+
it('calls appMonitor.dispose()', async () => {
|
|
280
|
+
const { monitor } = createAppMonitorMock();
|
|
281
|
+
const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() });
|
|
282
|
+
|
|
283
|
+
await cm.dispose();
|
|
284
|
+
|
|
285
|
+
expect(monitor.dispose).toHaveBeenCalledOnce();
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
});
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
2
|
+
import type { Config, Test, TestWatcher } from 'jest-runner';
|
|
3
|
+
import type { TestResult as JestTestResult } from '@jest/test-result';
|
|
4
|
+
import type { TestSuiteResult } from '@react-native-harness/bridge';
|
|
5
|
+
import { NativeCrashError, StartupStallError } from '../errors.js';
|
|
6
|
+
import { DeviceNotRespondingError } from '@react-native-harness/bridge/server';
|
|
7
|
+
import type { HarnessSession } from '../harness-session.js';
|
|
8
|
+
import { executeRun } from '../execute-run.js';
|
|
9
|
+
|
|
10
|
+
const resolveUndefined = async () => undefined;
|
|
11
|
+
|
|
12
|
+
// Mock the file-runner so we control what jestResult/harnessResult each test returns
|
|
13
|
+
// without needing a real Jest config or Metro bundler.
|
|
14
|
+
const mockRunHarnessTestFile = vi.hoisted(() => vi.fn());
|
|
15
|
+
vi.mock('../run.js', () => ({
|
|
16
|
+
runHarnessTestFile: mockRunHarnessTestFile,
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Helpers
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
const makeJestResult = (
|
|
24
|
+
overrides: Partial<JestTestResult> = {},
|
|
25
|
+
): JestTestResult =>
|
|
26
|
+
({
|
|
27
|
+
testFilePath: '/test/example.ts',
|
|
28
|
+
testResults: [],
|
|
29
|
+
failureMessage: null,
|
|
30
|
+
numFailingTests: 0,
|
|
31
|
+
numPassingTests: 1,
|
|
32
|
+
numPendingTests: 0,
|
|
33
|
+
numTodoTests: 0,
|
|
34
|
+
leaks: false,
|
|
35
|
+
openHandles: [],
|
|
36
|
+
skipped: false,
|
|
37
|
+
snapshot: {
|
|
38
|
+
added: 0, fileDeleted: false, matched: 0, unchecked: 0,
|
|
39
|
+
uncheckedKeys: [], unmatched: 0, updated: 0,
|
|
40
|
+
},
|
|
41
|
+
perfStats: { start: 0, end: 1, runtime: 1, slow: false,
|
|
42
|
+
loadTestEnvironmentStart: 0, loadTestEnvironmentEnd: 0,
|
|
43
|
+
setupAfterEnvStart: 0, setupAfterEnvEnd: 0,
|
|
44
|
+
setupFilesStart: 0, setupFilesEnd: 0 },
|
|
45
|
+
coverage: undefined,
|
|
46
|
+
...overrides,
|
|
47
|
+
} as JestTestResult);
|
|
48
|
+
|
|
49
|
+
const makeHarnessResult = (status: 'passed' | 'failed' = 'passed'): TestSuiteResult => ({
|
|
50
|
+
name: 'suite',
|
|
51
|
+
tests: [],
|
|
52
|
+
suites: [],
|
|
53
|
+
status,
|
|
54
|
+
duration: 50,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const makeFileRunResult = (overrides: Partial<ReturnType<typeof mockRunHarnessTestFile>> = {}) => ({
|
|
58
|
+
jestResult: makeJestResult(),
|
|
59
|
+
harnessResult: makeHarnessResult(),
|
|
60
|
+
relativeTestPath: 'example.ts',
|
|
61
|
+
duration: 50,
|
|
62
|
+
...overrides,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const makeTest = (filePath = '/test/example.ts'): Test => ({
|
|
66
|
+
path: filePath,
|
|
67
|
+
context: { config: {} as Config.ProjectConfig },
|
|
68
|
+
} as Test);
|
|
69
|
+
|
|
70
|
+
const makeWatcher = (interrupted = false): TestWatcher =>
|
|
71
|
+
({ isInterrupted: () => interrupted }) as TestWatcher;
|
|
72
|
+
|
|
73
|
+
const makeGlobalConfig = (overrides: Partial<Config.GlobalConfig> = {}): Config.GlobalConfig =>
|
|
74
|
+
({
|
|
75
|
+
rootDir: '/project',
|
|
76
|
+
watch: false,
|
|
77
|
+
watchAll: false,
|
|
78
|
+
collectCoverage: false,
|
|
79
|
+
...overrides,
|
|
80
|
+
} as Config.GlobalConfig);
|
|
81
|
+
|
|
82
|
+
const makeSession = (overrides: Partial<HarnessSession> = {}): HarnessSession => ({
|
|
83
|
+
config: {
|
|
84
|
+
metroPort: 8081,
|
|
85
|
+
host: undefined,
|
|
86
|
+
resetEnvironmentBetweenTestFiles: false,
|
|
87
|
+
detectNativeCrashes: true,
|
|
88
|
+
} as HarnessSession['config'],
|
|
89
|
+
context: {} as HarnessSession['context'],
|
|
90
|
+
ensureAppReady: vi.fn(resolveUndefined),
|
|
91
|
+
runTestFile: vi.fn(async () => makeHarnessResult()),
|
|
92
|
+
restartApp: vi.fn(resolveUndefined),
|
|
93
|
+
resetCrashState: vi.fn(),
|
|
94
|
+
flushClientLogs: vi.fn(() => []),
|
|
95
|
+
callHook: vi.fn(resolveUndefined),
|
|
96
|
+
setRunState: vi.fn(),
|
|
97
|
+
dispose: vi.fn(resolveUndefined),
|
|
98
|
+
...overrides,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Tests
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
beforeEach(() => {
|
|
106
|
+
vi.clearAllMocks();
|
|
107
|
+
mockRunHarnessTestFile.mockResolvedValue(makeFileRunResult());
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('executeRun', () => {
|
|
111
|
+
describe('lifecycle hooks', () => {
|
|
112
|
+
it('emits run:started then run:finished in order', async () => {
|
|
113
|
+
const hookNames: string[] = [];
|
|
114
|
+
const session = makeSession({
|
|
115
|
+
callHook: vi.fn(async (name) => { hookNames.push(name as string); }),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
await executeRun(session, [makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeGlobalConfig());
|
|
119
|
+
|
|
120
|
+
expect(hookNames[0]).toBe('run:started');
|
|
121
|
+
expect(hookNames[hookNames.length - 1]).toBe('run:finished');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('emits test-file:started and test-file:finished around each test', async () => {
|
|
125
|
+
const hookNames: string[] = [];
|
|
126
|
+
const session = makeSession({
|
|
127
|
+
callHook: vi.fn(async (name) => { hookNames.push(name as string); }),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await executeRun(session, [makeTest('/a.ts'), makeTest('/b.ts')], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeGlobalConfig());
|
|
131
|
+
|
|
132
|
+
const fileHooks = hookNames.filter((n) =>
|
|
133
|
+
n === 'test-file:started' || n === 'test-file:finished',
|
|
134
|
+
);
|
|
135
|
+
expect(fileHooks).toEqual([
|
|
136
|
+
'test-file:started', 'test-file:finished',
|
|
137
|
+
'test-file:started', 'test-file:finished',
|
|
138
|
+
]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('always emits run:finished even when a test throws', async () => {
|
|
142
|
+
const hookNames: string[] = [];
|
|
143
|
+
const session = makeSession({
|
|
144
|
+
callHook: vi.fn(async (name) => { hookNames.push(name as string); }),
|
|
145
|
+
ensureAppReady: vi.fn(async () => { throw new Error('unexpected'); }),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await executeRun(session, [makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeGlobalConfig());
|
|
149
|
+
|
|
150
|
+
expect(hookNames).toContain('run:finished');
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('happy path', () => {
|
|
155
|
+
it('calls onStart, ensureAppReady, runTestFile, onResult in order', async () => {
|
|
156
|
+
const order: string[] = [];
|
|
157
|
+
const session = makeSession({
|
|
158
|
+
ensureAppReady: vi.fn(async () => { order.push('ensureAppReady'); }),
|
|
159
|
+
});
|
|
160
|
+
const onStart = vi.fn(async () => { order.push('onStart'); });
|
|
161
|
+
const onResult = vi.fn(async () => { order.push('onResult'); });
|
|
162
|
+
|
|
163
|
+
mockRunHarnessTestFile.mockImplementation(async () => {
|
|
164
|
+
order.push('runTestFile');
|
|
165
|
+
return makeFileRunResult();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await executeRun(session, [makeTest()], makeWatcher(), onStart, onResult, vi.fn(), makeGlobalConfig());
|
|
169
|
+
|
|
170
|
+
expect(order).toEqual(['onStart', 'ensureAppReady', 'runTestFile', 'onResult']);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('accumulates passing test counts in run:finished summary', async () => {
|
|
174
|
+
let finishedPayload: unknown;
|
|
175
|
+
const session = makeSession({
|
|
176
|
+
callHook: vi.fn(async (name, payload) => {
|
|
177
|
+
if (name === 'run:finished') finishedPayload = payload;
|
|
178
|
+
}),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
mockRunHarnessTestFile.mockResolvedValue(
|
|
182
|
+
makeFileRunResult({ jestResult: makeJestResult({ numPassingTests: 3 }) }),
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
await executeRun(session, [makeTest(), makeTest('/b.ts')], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeGlobalConfig());
|
|
186
|
+
|
|
187
|
+
const payload = finishedPayload as { summary: { passed: number }; status: string };
|
|
188
|
+
expect(payload.summary.passed).toBe(6);
|
|
189
|
+
expect(payload.status).toBe('passed');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('attaches buffered client logs to the Jest result', async () => {
|
|
193
|
+
const clientLogs = [{ message: 'Loaded screen', origin: '', type: 'warn' }] satisfies NonNullable<JestTestResult['console']>;
|
|
194
|
+
const session = makeSession({
|
|
195
|
+
flushClientLogs: vi.fn()
|
|
196
|
+
.mockReturnValueOnce([])
|
|
197
|
+
.mockReturnValueOnce(clientLogs),
|
|
198
|
+
});
|
|
199
|
+
const onResult = vi.fn();
|
|
200
|
+
|
|
201
|
+
await executeRun(
|
|
202
|
+
session,
|
|
203
|
+
[makeTest()],
|
|
204
|
+
makeWatcher(),
|
|
205
|
+
vi.fn(),
|
|
206
|
+
onResult,
|
|
207
|
+
vi.fn(),
|
|
208
|
+
makeGlobalConfig(),
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
expect(session.flushClientLogs).toHaveBeenCalledTimes(2);
|
|
212
|
+
expect(onResult).toHaveBeenCalledWith(
|
|
213
|
+
expect.anything(),
|
|
214
|
+
expect.objectContaining({ console: clientLogs }),
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('runtime failures', () => {
|
|
220
|
+
it('passes StartupStallError to onFailure with an empty stack', async () => {
|
|
221
|
+
const onFailure = vi.fn();
|
|
222
|
+
const session = makeSession({
|
|
223
|
+
ensureAppReady: vi.fn().mockRejectedValue(new StartupStallError(1500, 3)),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
await executeRun(session, [makeTest()], makeWatcher(), vi.fn(), vi.fn(), onFailure, makeGlobalConfig());
|
|
227
|
+
|
|
228
|
+
expect(onFailure).toHaveBeenCalledWith(
|
|
229
|
+
expect.objectContaining({ path: '/test/example.ts' }),
|
|
230
|
+
expect.objectContaining({ message: expect.stringContaining('1500'), stack: '' }),
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('passes DeviceNotRespondingError to onFailure with an empty stack', async () => {
|
|
235
|
+
const onFailure = vi.fn();
|
|
236
|
+
const session = makeSession({
|
|
237
|
+
ensureAppReady: vi.fn().mockRejectedValue(
|
|
238
|
+
new DeviceNotRespondingError('runTests', []),
|
|
239
|
+
),
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
await executeRun(session, [makeTest()], makeWatcher(), vi.fn(), vi.fn(), onFailure, makeGlobalConfig());
|
|
243
|
+
|
|
244
|
+
expect(onFailure).toHaveBeenCalledWith(
|
|
245
|
+
expect.anything(),
|
|
246
|
+
expect.objectContaining({ stack: '' }),
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('calls resetCrashState after a NativeCrashError', async () => {
|
|
251
|
+
const session = makeSession({
|
|
252
|
+
ensureAppReady: vi.fn().mockRejectedValue(
|
|
253
|
+
new NativeCrashError('example.ts', { phase: 'execution', source: 'polling' }),
|
|
254
|
+
),
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
await executeRun(session, [makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeGlobalConfig());
|
|
258
|
+
|
|
259
|
+
expect(session.resetCrashState).toHaveBeenCalled();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('counts runtime failures in the summary', async () => {
|
|
263
|
+
let finishedPayload: unknown;
|
|
264
|
+
const session = makeSession({
|
|
265
|
+
callHook: vi.fn(async (name, payload) => {
|
|
266
|
+
if (name === 'run:finished') finishedPayload = payload;
|
|
267
|
+
}),
|
|
268
|
+
ensureAppReady: vi.fn().mockRejectedValue(new StartupStallError(1000, 2)),
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
await executeRun(session, [makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeGlobalConfig());
|
|
272
|
+
|
|
273
|
+
const payload = finishedPayload as { summary: { failed: number }; status: string };
|
|
274
|
+
expect(payload.summary.failed).toBe(1);
|
|
275
|
+
expect(payload.status).toBe('failed');
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe('watcher interrupt', () => {
|
|
280
|
+
it('stops processing tests and still emits run:finished', async () => {
|
|
281
|
+
const hookNames: string[] = [];
|
|
282
|
+
const session = makeSession({
|
|
283
|
+
callHook: vi.fn(async (name) => { hookNames.push(name as string); }),
|
|
284
|
+
});
|
|
285
|
+
const onStart = vi.fn();
|
|
286
|
+
|
|
287
|
+
await executeRun(
|
|
288
|
+
session,
|
|
289
|
+
[makeTest('/a.ts'), makeTest('/b.ts')],
|
|
290
|
+
makeWatcher(true /* interrupted */),
|
|
291
|
+
onStart,
|
|
292
|
+
vi.fn(),
|
|
293
|
+
vi.fn(),
|
|
294
|
+
makeGlobalConfig(),
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
// No test was started; watcher was already interrupted.
|
|
298
|
+
expect(onStart).not.toHaveBeenCalled();
|
|
299
|
+
expect(hookNames).toContain('run:finished');
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe('resetEnvironmentBetweenTestFiles', () => {
|
|
304
|
+
it('calls restartApp before the second test but not the first', async () => {
|
|
305
|
+
const session = makeSession({
|
|
306
|
+
config: {
|
|
307
|
+
metroPort: 8081,
|
|
308
|
+
resetEnvironmentBetweenTestFiles: true,
|
|
309
|
+
detectNativeCrashes: false,
|
|
310
|
+
} as HarnessSession['config'],
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
await executeRun(
|
|
314
|
+
session,
|
|
315
|
+
[makeTest('/a.ts'), makeTest('/b.ts'), makeTest('/c.ts')],
|
|
316
|
+
makeWatcher(),
|
|
317
|
+
vi.fn(), vi.fn(), vi.fn(),
|
|
318
|
+
makeGlobalConfig(),
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
// restartApp should be called for tests 2 and 3, not test 1.
|
|
322
|
+
expect(session.restartApp).toHaveBeenCalledTimes(2);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createHookQueue } from '../hook-queue.js';
|
|
3
|
+
|
|
4
|
+
describe('createHookQueue', () => {
|
|
5
|
+
it('executes scheduled work in FIFO order', async () => {
|
|
6
|
+
const queue = createHookQueue();
|
|
7
|
+
const order: number[] = [];
|
|
8
|
+
|
|
9
|
+
queue.schedule(async () => { order.push(1); });
|
|
10
|
+
queue.schedule(async () => { order.push(2); });
|
|
11
|
+
queue.schedule(async () => { order.push(3); });
|
|
12
|
+
|
|
13
|
+
await queue.drain();
|
|
14
|
+
|
|
15
|
+
expect(order).toEqual([1, 2, 3]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('drain awaits async work before resolving', async () => {
|
|
19
|
+
const queue = createHookQueue();
|
|
20
|
+
let resolved = false;
|
|
21
|
+
|
|
22
|
+
queue.schedule(async () => {
|
|
23
|
+
await new Promise<void>((r) => setTimeout(r, 20));
|
|
24
|
+
resolved = true;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(resolved).toBe(false);
|
|
28
|
+
await queue.drain();
|
|
29
|
+
expect(resolved).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('drain resolves immediately when nothing is scheduled', async () => {
|
|
33
|
+
const queue = createHookQueue();
|
|
34
|
+
await expect(queue.drain()).resolves.toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('captures the first error and re-throws it on drain', async () => {
|
|
38
|
+
const queue = createHookQueue();
|
|
39
|
+
const err = new Error('hook failed');
|
|
40
|
+
|
|
41
|
+
queue.schedule(async () => { throw err; });
|
|
42
|
+
|
|
43
|
+
await expect(queue.drain()).rejects.toBe(err);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('continues executing subsequent hooks after one throws', async () => {
|
|
47
|
+
const queue = createHookQueue();
|
|
48
|
+
const ran: string[] = [];
|
|
49
|
+
|
|
50
|
+
queue.schedule(async () => { throw new Error('first'); });
|
|
51
|
+
queue.schedule(async () => { ran.push('second'); });
|
|
52
|
+
queue.schedule(async () => { ran.push('third'); });
|
|
53
|
+
|
|
54
|
+
await expect(queue.drain()).rejects.toThrow('first');
|
|
55
|
+
|
|
56
|
+
expect(ran).toEqual(['second', 'third']);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('error is cleared after drain re-throws it', async () => {
|
|
60
|
+
const queue = createHookQueue();
|
|
61
|
+
|
|
62
|
+
queue.schedule(async () => { throw new Error('once'); });
|
|
63
|
+
await expect(queue.drain()).rejects.toThrow('once');
|
|
64
|
+
|
|
65
|
+
// Second drain should see no accumulated error.
|
|
66
|
+
await expect(queue.drain()).resolves.toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('surfaces only the first error when multiple hooks throw', async () => {
|
|
70
|
+
const queue = createHookQueue();
|
|
71
|
+
|
|
72
|
+
queue.schedule(async () => { throw new Error('first'); });
|
|
73
|
+
queue.schedule(async () => { throw new Error('second'); });
|
|
74
|
+
|
|
75
|
+
await expect(queue.drain()).rejects.toThrow('first');
|
|
76
|
+
});
|
|
77
|
+
});
|