@react-native-harness/jest 1.0.0 → 1.1.0-rc.2
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__/crash-supervisor.test.d.ts +2 -0
- package/dist/__tests__/crash-supervisor.test.d.ts.map +1 -0
- package/dist/__tests__/crash-supervisor.test.js +151 -0
- package/dist/__tests__/errors.test.d.ts +2 -0
- package/dist/__tests__/errors.test.d.ts.map +1 -0
- package/dist/__tests__/errors.test.js +54 -0
- package/dist/__tests__/harness-cache.test.d.ts +2 -0
- package/dist/__tests__/harness-cache.test.d.ts.map +1 -0
- package/dist/__tests__/harness-cache.test.js +51 -0
- package/dist/__tests__/harness.test.d.ts +2 -0
- package/dist/__tests__/harness.test.d.ts.map +1 -0
- package/dist/__tests__/harness.test.js +399 -0
- package/dist/__tests__/index.test.d.ts +2 -0
- package/dist/__tests__/index.test.d.ts.map +1 -0
- package/dist/__tests__/index.test.js +42 -0
- package/dist/cli-args.d.ts +1 -0
- package/dist/cli-args.d.ts.map +1 -1
- package/dist/cli-args.js +5 -0
- package/dist/crash-supervisor.d.ts +21 -0
- package/dist/crash-supervisor.d.ts.map +1 -0
- package/dist/crash-supervisor.js +210 -0
- package/dist/errors.d.ts +9 -4
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +43 -8
- package/dist/harness.d.ts +30 -8
- package/dist/harness.d.ts.map +1 -1
- package/dist/harness.js +440 -98
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +205 -57
- package/dist/logs.d.ts +1 -0
- package/dist/logs.d.ts.map +1 -1
- package/dist/logs.js +3 -0
- package/dist/run.d.ts +7 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +13 -8
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +18 -6
- package/dist/tsconfig.lib.tsbuildinfo +1 -1
- package/package.json +7 -6
- package/src/__tests__/crash-supervisor.test.ts +200 -0
- package/src/__tests__/errors.test.ts +70 -0
- package/src/__tests__/harness-cache.test.ts +74 -0
- package/src/__tests__/harness.test.ts +547 -0
- package/src/__tests__/index.test.ts +61 -0
- package/src/cli-args.ts +6 -0
- package/src/crash-supervisor.ts +321 -0
- package/src/env.d.ts +1 -1
- package/src/errors.ts +68 -8
- package/src/harness.ts +566 -120
- package/src/index.ts +276 -66
- package/src/logs.ts +4 -0
- package/src/run.ts +20 -10
- package/src/setup.ts +22 -6
- package/tsconfig.json +3 -3
- package/tsconfig.lib.json +6 -6
- package/dist/cli-args.test.d.ts +0 -2
- package/dist/cli-args.test.d.ts.map +0 -1
- package/dist/cli-args.test.js +0 -1
- package/dist/crash-monitor.d.ts +0 -16
- package/dist/crash-monitor.d.ts.map +0 -1
- package/dist/crash-monitor.js +0 -75
- package/dist/event-mapper.d.ts +0 -16
- package/dist/event-mapper.d.ts.map +0 -1
- package/dist/event-mapper.js +0 -120
- package/dist/global-setup.d.ts +0 -3
- package/dist/global-setup.d.ts.map +0 -1
- package/dist/global-setup.js +0 -60
- package/dist/global-teardown.d.ts +0 -3
- package/dist/global-teardown.d.ts.map +0 -1
- package/dist/global-teardown.js +0 -11
- package/dist/harness-reporter.d.ts +0 -8
- package/dist/harness-reporter.d.ts.map +0 -1
- package/dist/harness-reporter.js +0 -12
- package/dist/jest-options.d.ts +0 -2
- package/dist/jest-options.d.ts.map +0 -1
- package/dist/jest-options.js +0 -1
- package/dist/types.d.ts +0 -17
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -1
- package/src/crash-monitor.ts +0 -109
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"crash-supervisor.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/crash-supervisor.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createCrashSupervisor } from '../crash-supervisor.js';
|
|
3
|
+
const createMockAppMonitor = (options) => {
|
|
4
|
+
const listeners = new Set();
|
|
5
|
+
const noop = async () => undefined;
|
|
6
|
+
const appMonitor = {
|
|
7
|
+
start: noop,
|
|
8
|
+
stop: noop,
|
|
9
|
+
dispose: noop,
|
|
10
|
+
addListener: (listener) => {
|
|
11
|
+
listeners.add(listener);
|
|
12
|
+
},
|
|
13
|
+
removeListener: (listener) => {
|
|
14
|
+
listeners.delete(listener);
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
if (options?.getCrashDetails) {
|
|
18
|
+
appMonitor.getCrashDetails = options.getCrashDetails;
|
|
19
|
+
}
|
|
20
|
+
const emit = (event) => {
|
|
21
|
+
for (const listener of listeners) {
|
|
22
|
+
listener(event);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
return {
|
|
26
|
+
appMonitor,
|
|
27
|
+
emit,
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
const createPlatformRunner = ({ isAppRunning, getCrashDetails, }) => ({
|
|
31
|
+
startApp: async () => undefined,
|
|
32
|
+
restartApp: async () => undefined,
|
|
33
|
+
stopApp: async () => undefined,
|
|
34
|
+
dispose: async () => undefined,
|
|
35
|
+
isAppRunning,
|
|
36
|
+
createAppMonitor: () => {
|
|
37
|
+
throw new Error('Not used in unit tests');
|
|
38
|
+
},
|
|
39
|
+
getCrashDetails,
|
|
40
|
+
});
|
|
41
|
+
describe('crash-supervisor', () => {
|
|
42
|
+
it('rejects the active test when the app exits during launch', async () => {
|
|
43
|
+
const monitor = createMockAppMonitor();
|
|
44
|
+
const supervisor = createCrashSupervisor({
|
|
45
|
+
appMonitor: monitor.appMonitor,
|
|
46
|
+
platformRunner: createPlatformRunner({
|
|
47
|
+
isAppRunning: vi.fn().mockResolvedValue(false),
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
supervisor.beginLaunch('/tmp/startup.harness.ts');
|
|
51
|
+
const crashPromise = supervisor.waitForCrash('/tmp/startup.harness.ts');
|
|
52
|
+
const expectation = expect(crashPromise).rejects.toMatchObject({
|
|
53
|
+
name: 'NativeCrashError',
|
|
54
|
+
phase: 'startup',
|
|
55
|
+
details: {
|
|
56
|
+
signal: 'SIGSEGV',
|
|
57
|
+
processName: 'com.harnessplayground',
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
monitor.emit({
|
|
61
|
+
type: 'app_exited',
|
|
62
|
+
source: 'logs',
|
|
63
|
+
crashDetails: {
|
|
64
|
+
source: 'logs',
|
|
65
|
+
signal: 'SIGSEGV',
|
|
66
|
+
processName: 'com.harnessplayground',
|
|
67
|
+
rawLines: ['Fatal signal 11 (SIGSEGV)'],
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
await expectation;
|
|
71
|
+
await supervisor.dispose();
|
|
72
|
+
});
|
|
73
|
+
it('treats polling exit events as immediately confirmed crashes', async () => {
|
|
74
|
+
const monitor = createMockAppMonitor();
|
|
75
|
+
const isAppRunning = vi.fn().mockResolvedValue(true);
|
|
76
|
+
const supervisor = createCrashSupervisor({
|
|
77
|
+
appMonitor: monitor.appMonitor,
|
|
78
|
+
platformRunner: createPlatformRunner({
|
|
79
|
+
isAppRunning,
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
supervisor.beginLaunch('/tmp/polling-exit.harness.ts');
|
|
83
|
+
const crashPromise = supervisor.waitForCrash('/tmp/polling-exit.harness.ts');
|
|
84
|
+
monitor.emit({ type: 'app_exited', source: 'polling' });
|
|
85
|
+
await expect(crashPromise).rejects.toMatchObject({
|
|
86
|
+
name: 'NativeCrashError',
|
|
87
|
+
phase: 'startup',
|
|
88
|
+
});
|
|
89
|
+
expect(isAppRunning).not.toHaveBeenCalled();
|
|
90
|
+
await supervisor.dispose();
|
|
91
|
+
});
|
|
92
|
+
it('does not reject when monitoring is stopped', async () => {
|
|
93
|
+
const monitor = createMockAppMonitor();
|
|
94
|
+
const supervisor = createCrashSupervisor({
|
|
95
|
+
appMonitor: monitor.appMonitor,
|
|
96
|
+
platformRunner: createPlatformRunner({
|
|
97
|
+
isAppRunning: vi.fn().mockResolvedValue(false),
|
|
98
|
+
}),
|
|
99
|
+
});
|
|
100
|
+
supervisor.beginTestRun('/tmp/restart.harness.ts');
|
|
101
|
+
await supervisor.stop();
|
|
102
|
+
const reject = vi.fn();
|
|
103
|
+
void supervisor.waitForCrash('/tmp/restart.harness.ts').catch(reject);
|
|
104
|
+
monitor.emit({ type: 'app_exited', source: 'polling' });
|
|
105
|
+
await Promise.resolve();
|
|
106
|
+
expect(reject).not.toHaveBeenCalled();
|
|
107
|
+
await supervisor.dispose();
|
|
108
|
+
});
|
|
109
|
+
it('prefers crash details from the started app monitor over the runner', async () => {
|
|
110
|
+
const monitorGetCrashDetails = vi.fn().mockResolvedValue({
|
|
111
|
+
source: 'logs',
|
|
112
|
+
summary: 'full crash block',
|
|
113
|
+
rawLines: ['full crash block'],
|
|
114
|
+
});
|
|
115
|
+
const runnerGetCrashDetails = vi.fn().mockResolvedValue({
|
|
116
|
+
source: 'logs',
|
|
117
|
+
summary: 'runner details',
|
|
118
|
+
rawLines: ['runner details'],
|
|
119
|
+
});
|
|
120
|
+
const monitor = createMockAppMonitor({
|
|
121
|
+
getCrashDetails: monitorGetCrashDetails,
|
|
122
|
+
});
|
|
123
|
+
const supervisor = createCrashSupervisor({
|
|
124
|
+
appMonitor: monitor.appMonitor,
|
|
125
|
+
platformRunner: createPlatformRunner({
|
|
126
|
+
isAppRunning: vi.fn().mockResolvedValue(false),
|
|
127
|
+
getCrashDetails: runnerGetCrashDetails,
|
|
128
|
+
}),
|
|
129
|
+
});
|
|
130
|
+
supervisor.beginTestRun('/tmp/monitor-details.harness.ts');
|
|
131
|
+
const crashPromise = supervisor.waitForCrash('/tmp/monitor-details.harness.ts');
|
|
132
|
+
monitor.emit({
|
|
133
|
+
type: 'possible_crash',
|
|
134
|
+
source: 'logs',
|
|
135
|
+
crashDetails: {
|
|
136
|
+
source: 'logs',
|
|
137
|
+
processName: 'com.harnessplayground',
|
|
138
|
+
pid: 1234,
|
|
139
|
+
rawLines: ['partial line'],
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
await expect(crashPromise).rejects.toMatchObject({
|
|
143
|
+
details: {
|
|
144
|
+
summary: 'full crash block',
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
expect(monitorGetCrashDetails).toHaveBeenCalled();
|
|
148
|
+
expect(runnerGetCrashDetails).not.toHaveBeenCalled();
|
|
149
|
+
await supervisor.dispose();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/errors.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { NativeCrashError } from '../errors.js';
|
|
3
|
+
describe('NativeCrashError', () => {
|
|
4
|
+
it('formats the extracted stack trace in the error message', () => {
|
|
5
|
+
const error = new NativeCrashError('/tmp/crash.harness.ts', {
|
|
6
|
+
phase: 'execution',
|
|
7
|
+
processName: 'HarnessPlayground',
|
|
8
|
+
pid: 1234,
|
|
9
|
+
signal: 'SIGTRAP',
|
|
10
|
+
exceptionType: 'EXC_BREAKPOINT',
|
|
11
|
+
stackTrace: [
|
|
12
|
+
'0 AppDelegate.crashIfRequested() (AppDelegate.swift:31)',
|
|
13
|
+
'1 AppDelegate.application(_:didFinishLaunchingWithOptions:) (AppDelegate.swift:56)',
|
|
14
|
+
],
|
|
15
|
+
});
|
|
16
|
+
expect(error.message).toContain(' 0 AppDelegate.crashIfRequested() (AppDelegate.swift:31)');
|
|
17
|
+
expect(error.message).toContain(' 1 AppDelegate.application(_:didFinishLaunchingWithOptions:) (AppDelegate.swift:56)');
|
|
18
|
+
});
|
|
19
|
+
it('omits single-line iOS summaries from the rendered error message', () => {
|
|
20
|
+
const error = new NativeCrashError('/tmp/crash.harness.ts', {
|
|
21
|
+
phase: 'startup',
|
|
22
|
+
artifactType: 'ios-crash-report',
|
|
23
|
+
summary: '2026-03-12 13:46:18.154 Df HarnessPlayground[18007:65e716] [com.apple.dt.xctest:Default] notify_get_state check indicated test daemon not ready.',
|
|
24
|
+
processName: 'HarnessPlayground',
|
|
25
|
+
pid: 18007,
|
|
26
|
+
signal: 'SIGABRT',
|
|
27
|
+
exceptionType: 'EXC_CRASH',
|
|
28
|
+
});
|
|
29
|
+
expect(error.message).not.toContain('notify_get_state check indicated test daemon not ready');
|
|
30
|
+
expect(error.message).toContain('Signal: SIGABRT');
|
|
31
|
+
expect(error.message).toContain('Exception: EXC_CRASH');
|
|
32
|
+
expect(error.message).toContain('Process: HarnessPlayground (pid 18007)');
|
|
33
|
+
});
|
|
34
|
+
it('does not duplicate stack frames when the summary already contains a crash block', () => {
|
|
35
|
+
const frame = '03-13 07:59:44.943 20373 20373 E AndroidRuntime: \tat com.harnessplayground.MainActivity.onCreate(MainActivity.kt:38)';
|
|
36
|
+
const error = new NativeCrashError('/tmp/crash.harness.ts', {
|
|
37
|
+
phase: 'startup',
|
|
38
|
+
summary: [
|
|
39
|
+
'--------- beginning of crash',
|
|
40
|
+
'03-13 07:59:44.943 20373 20373 E AndroidRuntime: FATAL EXCEPTION: main',
|
|
41
|
+
frame,
|
|
42
|
+
].join('\n'),
|
|
43
|
+
stackTrace: [frame],
|
|
44
|
+
});
|
|
45
|
+
expect(error.message.match(/MainActivity\.onCreate\(MainActivity\.kt:38\)/g)).toHaveLength(1);
|
|
46
|
+
});
|
|
47
|
+
it('collapses the native crash stack header so jest does not reprint multiline messages', () => {
|
|
48
|
+
const error = new NativeCrashError('/tmp/crash.harness.ts', {
|
|
49
|
+
phase: 'startup',
|
|
50
|
+
summary: ['line one', 'line two'].join('\n'),
|
|
51
|
+
});
|
|
52
|
+
expect(error.stack).toBe('NativeCrashError: The native app crashed while preparing to run this test file.');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"harness-cache.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/harness-cache.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
const mocks = vi.hoisted(() => ({
|
|
3
|
+
isMetroCacheReusable: vi.fn(),
|
|
4
|
+
logMetroCacheReused: vi.fn(),
|
|
5
|
+
logMetroPrewarmCompleted: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
vi.mock('@react-native-harness/bundler-metro', () => ({
|
|
8
|
+
isMetroCacheReusable: mocks.isMetroCacheReusable,
|
|
9
|
+
}));
|
|
10
|
+
vi.mock('../logs.js', () => ({
|
|
11
|
+
logMetroCacheReused: mocks.logMetroCacheReused,
|
|
12
|
+
logMetroPrewarmCompleted: mocks.logMetroPrewarmCompleted,
|
|
13
|
+
}));
|
|
14
|
+
import { maybeLogMetroCacheReuse } from '../harness.js';
|
|
15
|
+
const platform = {
|
|
16
|
+
name: 'ios',
|
|
17
|
+
platformId: 'ios',
|
|
18
|
+
runner: '/virtual/platform-runner.js',
|
|
19
|
+
config: {},
|
|
20
|
+
};
|
|
21
|
+
const createHarnessConfig = (overrides = {}) => ({
|
|
22
|
+
appRegistryComponentName: 'App',
|
|
23
|
+
webSocketPort: 8081,
|
|
24
|
+
disableViewFlattening: false,
|
|
25
|
+
bridgeTimeout: 5000,
|
|
26
|
+
entryPoint: 'index.js',
|
|
27
|
+
unstable__enableMetroCache: true,
|
|
28
|
+
forwardClientLogs: false,
|
|
29
|
+
...overrides,
|
|
30
|
+
});
|
|
31
|
+
describe('maybeLogMetroCacheReuse', () => {
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
vi.clearAllMocks();
|
|
34
|
+
mocks.isMetroCacheReusable.mockReturnValue(false);
|
|
35
|
+
});
|
|
36
|
+
it('logs when an existing metro cache will be reused', () => {
|
|
37
|
+
mocks.isMetroCacheReusable.mockReturnValue(true);
|
|
38
|
+
maybeLogMetroCacheReuse(createHarnessConfig(), platform, '/tmp/project');
|
|
39
|
+
expect(mocks.isMetroCacheReusable).toHaveBeenCalledWith('/tmp/project');
|
|
40
|
+
expect(mocks.logMetroCacheReused).toHaveBeenCalledWith(platform);
|
|
41
|
+
});
|
|
42
|
+
it('does not log when metro cache reuse is disabled', () => {
|
|
43
|
+
maybeLogMetroCacheReuse(createHarnessConfig({ unstable__enableMetroCache: false }), platform, '/tmp/project');
|
|
44
|
+
expect(mocks.isMetroCacheReusable).not.toHaveBeenCalled();
|
|
45
|
+
expect(mocks.logMetroCacheReused).not.toHaveBeenCalled();
|
|
46
|
+
});
|
|
47
|
+
it('does not log when the metro cache is absent', () => {
|
|
48
|
+
maybeLogMetroCacheReuse(createHarnessConfig(), platform, '/tmp/project');
|
|
49
|
+
expect(mocks.logMetroCacheReused).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"harness.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/harness.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { definePlugin } from '@react-native-harness/plugins';
|
|
4
|
+
import { createCrashSupervisor } from '../crash-supervisor.js';
|
|
5
|
+
const mocks = vi.hoisted(() => ({
|
|
6
|
+
createCrashArtifactWriter: vi.fn(() => ({})),
|
|
7
|
+
getBridgeServer: vi.fn(),
|
|
8
|
+
getMetroInstance: vi.fn(),
|
|
9
|
+
isMetroCacheReusable: vi.fn(() => false),
|
|
10
|
+
logMetroCacheReused: vi.fn(),
|
|
11
|
+
waitForMetroBackedAppReady: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
vi.mock('@react-native-harness/bundler-metro', async () => {
|
|
14
|
+
const actual = await vi.importActual('@react-native-harness/bundler-metro');
|
|
15
|
+
return {
|
|
16
|
+
...actual,
|
|
17
|
+
getMetroInstance: mocks.getMetroInstance,
|
|
18
|
+
isMetroCacheReusable: mocks.isMetroCacheReusable,
|
|
19
|
+
waitForMetroBackedAppReady: mocks.waitForMetroBackedAppReady,
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
vi.mock('@react-native-harness/bridge/server', () => ({
|
|
23
|
+
getBridgeServer: mocks.getBridgeServer,
|
|
24
|
+
}));
|
|
25
|
+
vi.mock('../logs.js', () => ({
|
|
26
|
+
logMetroCacheReused: mocks.logMetroCacheReused,
|
|
27
|
+
}));
|
|
28
|
+
vi.mock('@react-native-harness/tools', async () => {
|
|
29
|
+
const actual = await vi.importActual('@react-native-harness/tools');
|
|
30
|
+
return {
|
|
31
|
+
...actual,
|
|
32
|
+
createCrashArtifactWriter: mocks.createCrashArtifactWriter,
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
import { getHarness, waitForAppReady } from '../harness.js';
|
|
36
|
+
import { StartupStallError } from '../errors.js';
|
|
37
|
+
const createBridgeServer = () => {
|
|
38
|
+
const emitter = new EventEmitter();
|
|
39
|
+
return {
|
|
40
|
+
serverBridge: {
|
|
41
|
+
rpc: {
|
|
42
|
+
clients: [],
|
|
43
|
+
},
|
|
44
|
+
on: emitter.on.bind(emitter),
|
|
45
|
+
once: emitter.once.bind(emitter),
|
|
46
|
+
off: emitter.off.bind(emitter),
|
|
47
|
+
dispose: vi.fn(),
|
|
48
|
+
},
|
|
49
|
+
emitReady: () => {
|
|
50
|
+
emitter.emit('ready', {
|
|
51
|
+
platform: 'ios',
|
|
52
|
+
manufacturer: 'Apple',
|
|
53
|
+
model: 'Simulator',
|
|
54
|
+
osVersion: '18.0',
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
emitEvent: (event) => {
|
|
58
|
+
emitter.emit('event', event);
|
|
59
|
+
},
|
|
60
|
+
emitDisconnect: () => {
|
|
61
|
+
emitter.emit('disconnect');
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
const createReporter = () => {
|
|
66
|
+
const listeners = new Set();
|
|
67
|
+
return {
|
|
68
|
+
addListener: (listener) => {
|
|
69
|
+
listeners.add(listener);
|
|
70
|
+
},
|
|
71
|
+
removeListener: (listener) => {
|
|
72
|
+
listeners.delete(listener);
|
|
73
|
+
},
|
|
74
|
+
emit: (event) => {
|
|
75
|
+
listeners.forEach((listener) => listener(event));
|
|
76
|
+
},
|
|
77
|
+
clearAllListeners: () => {
|
|
78
|
+
listeners.clear();
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
const createMetroInstance = (overrides = {}) => ({
|
|
83
|
+
events: createReporter(),
|
|
84
|
+
waitUntilHealthy: vi.fn(async () => 'HTTP 200: packager-status:running'),
|
|
85
|
+
prewarm: vi.fn(async () => false),
|
|
86
|
+
dispose: vi.fn(async () => undefined),
|
|
87
|
+
...overrides,
|
|
88
|
+
});
|
|
89
|
+
const createAppMonitor = () => {
|
|
90
|
+
const listeners = new Set();
|
|
91
|
+
return {
|
|
92
|
+
appMonitor: {
|
|
93
|
+
start: vi.fn(async () => undefined),
|
|
94
|
+
stop: vi.fn(async () => undefined),
|
|
95
|
+
dispose: vi.fn(async () => undefined),
|
|
96
|
+
addListener: (listener) => {
|
|
97
|
+
listeners.add(listener);
|
|
98
|
+
},
|
|
99
|
+
removeListener: (listener) => {
|
|
100
|
+
listeners.delete(listener);
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
emit: (event) => {
|
|
104
|
+
for (const listener of listeners) {
|
|
105
|
+
listener(event);
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
const createPlatformRunner = (overrides = {}) => ({
|
|
111
|
+
startApp: vi.fn(async () => undefined),
|
|
112
|
+
restartApp: vi.fn(async () => undefined),
|
|
113
|
+
stopApp: vi.fn(async () => undefined),
|
|
114
|
+
dispose: vi.fn(async () => undefined),
|
|
115
|
+
isAppRunning: vi.fn(async () => true),
|
|
116
|
+
createAppMonitor: () => createAppMonitor().appMonitor,
|
|
117
|
+
...overrides,
|
|
118
|
+
});
|
|
119
|
+
const createHarnessConfig = (overrides = {}) => ({
|
|
120
|
+
appRegistryComponentName: 'App',
|
|
121
|
+
bridgeTimeout: 60_000,
|
|
122
|
+
bundleStartTimeout: 60_000,
|
|
123
|
+
crashDetectionInterval: 500,
|
|
124
|
+
defaultRunner: 'ios',
|
|
125
|
+
detectNativeCrashes: true,
|
|
126
|
+
disableViewFlattening: false,
|
|
127
|
+
entryPoint: 'index.js',
|
|
128
|
+
forwardClientLogs: false,
|
|
129
|
+
maxAppRestarts: 2,
|
|
130
|
+
metroPort: 8081,
|
|
131
|
+
resetEnvironmentBetweenTestFiles: true,
|
|
132
|
+
runners: [],
|
|
133
|
+
unstable__enableMetroCache: false,
|
|
134
|
+
unstable__skipAlreadyIncludedModules: false,
|
|
135
|
+
webSocketPort: 3001,
|
|
136
|
+
...overrides,
|
|
137
|
+
});
|
|
138
|
+
beforeEach(() => {
|
|
139
|
+
vi.clearAllMocks();
|
|
140
|
+
});
|
|
141
|
+
afterEach(() => {
|
|
142
|
+
delete globalThis.__HARNESS_PLATFORM_RUNNER__;
|
|
143
|
+
});
|
|
144
|
+
describe('waitForAppReady', () => {
|
|
145
|
+
it('delegates startup orchestration to bundler-metro and resolves readiness from the bridge', async () => {
|
|
146
|
+
const { serverBridge, emitReady } = createBridgeServer();
|
|
147
|
+
const metroInstance = createMetroInstance();
|
|
148
|
+
const platformInstance = createPlatformRunner();
|
|
149
|
+
const { appMonitor } = createAppMonitor();
|
|
150
|
+
const crashSupervisor = createCrashSupervisor({
|
|
151
|
+
appMonitor,
|
|
152
|
+
platformRunner: platformInstance,
|
|
153
|
+
});
|
|
154
|
+
mocks.waitForMetroBackedAppReady.mockImplementationOnce(async (options) => {
|
|
155
|
+
options.onAttemptStart?.();
|
|
156
|
+
const readyPromise = options.waitForReady(new AbortController().signal);
|
|
157
|
+
emitReady();
|
|
158
|
+
await readyPromise;
|
|
159
|
+
options.onAttemptReset?.();
|
|
160
|
+
});
|
|
161
|
+
await waitForAppReady({
|
|
162
|
+
metroInstance,
|
|
163
|
+
serverBridge,
|
|
164
|
+
platformInstance,
|
|
165
|
+
platformId: 'ios',
|
|
166
|
+
bundleStartTimeout: 1_500,
|
|
167
|
+
readyTimeout: 2_500,
|
|
168
|
+
maxAppRestarts: 3,
|
|
169
|
+
testFilePath: '/tmp/test.harness.ts',
|
|
170
|
+
crashSupervisor,
|
|
171
|
+
});
|
|
172
|
+
expect(mocks.waitForMetroBackedAppReady).toHaveBeenCalledWith(expect.objectContaining({
|
|
173
|
+
metro: metroInstance,
|
|
174
|
+
platformId: 'ios',
|
|
175
|
+
bundleStartTimeout: 1_500,
|
|
176
|
+
readyTimeout: 2_500,
|
|
177
|
+
maxAppRestarts: 3,
|
|
178
|
+
startAttempt: expect.any(Function),
|
|
179
|
+
waitForReady: expect.any(Function),
|
|
180
|
+
waitForCrash: expect.any(Function),
|
|
181
|
+
}));
|
|
182
|
+
expect(crashSupervisor.isReady()).toBe(true);
|
|
183
|
+
await crashSupervisor.dispose();
|
|
184
|
+
});
|
|
185
|
+
it('passes launch options through the shared Metro startup helper', async () => {
|
|
186
|
+
const { serverBridge } = createBridgeServer();
|
|
187
|
+
const metroInstance = createMetroInstance();
|
|
188
|
+
const restartApp = vi.fn(async () => undefined);
|
|
189
|
+
const platformInstance = createPlatformRunner({ restartApp });
|
|
190
|
+
const { appMonitor } = createAppMonitor();
|
|
191
|
+
const crashSupervisor = createCrashSupervisor({
|
|
192
|
+
appMonitor,
|
|
193
|
+
platformRunner: platformInstance,
|
|
194
|
+
});
|
|
195
|
+
mocks.waitForMetroBackedAppReady.mockImplementationOnce(async (options) => {
|
|
196
|
+
await options.startAttempt();
|
|
197
|
+
});
|
|
198
|
+
await waitForAppReady({
|
|
199
|
+
metroInstance,
|
|
200
|
+
serverBridge,
|
|
201
|
+
platformInstance,
|
|
202
|
+
platformId: 'ios',
|
|
203
|
+
bundleStartTimeout: 1_500,
|
|
204
|
+
readyTimeout: 2_500,
|
|
205
|
+
maxAppRestarts: 3,
|
|
206
|
+
testFilePath: '/tmp/test.harness.ts',
|
|
207
|
+
crashSupervisor,
|
|
208
|
+
appLaunchOptions: {
|
|
209
|
+
extras: {
|
|
210
|
+
mode: 'startup',
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
expect(restartApp).toHaveBeenCalledWith({
|
|
215
|
+
extras: {
|
|
216
|
+
mode: 'startup',
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
await crashSupervisor.dispose();
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
describe('getHarness', () => {
|
|
223
|
+
it('routes ensureAppReady through the shared Metro startup helper', async () => {
|
|
224
|
+
const { serverBridge, emitReady } = createBridgeServer();
|
|
225
|
+
const appMonitor = createAppMonitor();
|
|
226
|
+
const restartApp = vi.fn(async () => undefined);
|
|
227
|
+
const platformInstance = createPlatformRunner({
|
|
228
|
+
restartApp,
|
|
229
|
+
createAppMonitor: () => appMonitor.appMonitor,
|
|
230
|
+
});
|
|
231
|
+
const metroInstance = createMetroInstance();
|
|
232
|
+
mocks.getBridgeServer.mockResolvedValue(serverBridge);
|
|
233
|
+
mocks.getMetroInstance.mockResolvedValue(metroInstance);
|
|
234
|
+
mocks.waitForMetroBackedAppReady.mockImplementationOnce(async (options) => {
|
|
235
|
+
await options.startAttempt();
|
|
236
|
+
const readyPromise = options.waitForReady(new AbortController().signal);
|
|
237
|
+
emitReady();
|
|
238
|
+
await readyPromise;
|
|
239
|
+
});
|
|
240
|
+
globalThis.__HARNESS_PLATFORM_RUNNER__ = vi.fn(async () => platformInstance);
|
|
241
|
+
const platform = {
|
|
242
|
+
config: {
|
|
243
|
+
appLaunchOptions: {
|
|
244
|
+
extras: {
|
|
245
|
+
source: 'ensure-ready',
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
name: 'ios',
|
|
250
|
+
platformId: 'ios',
|
|
251
|
+
runner: `data:text/javascript,${encodeURIComponent('export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);')}`,
|
|
252
|
+
};
|
|
253
|
+
const harness = await getHarness(createHarnessConfig(), platform, '/tmp/project');
|
|
254
|
+
await harness.ensureAppReady('/tmp/example.harness.ts');
|
|
255
|
+
expect(mocks.waitForMetroBackedAppReady).toHaveBeenCalledTimes(1);
|
|
256
|
+
expect(restartApp).toHaveBeenCalledWith({
|
|
257
|
+
extras: {
|
|
258
|
+
source: 'ensure-ready',
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
await harness.dispose();
|
|
262
|
+
});
|
|
263
|
+
it('routes restart(testFilePath) through the shared Metro startup helper', async () => {
|
|
264
|
+
const { serverBridge, emitReady } = createBridgeServer();
|
|
265
|
+
const appMonitor = createAppMonitor();
|
|
266
|
+
const restartApp = vi.fn(async () => undefined);
|
|
267
|
+
const stopApp = vi.fn(async () => undefined);
|
|
268
|
+
const platformInstance = createPlatformRunner({
|
|
269
|
+
restartApp,
|
|
270
|
+
stopApp,
|
|
271
|
+
isAppRunning: vi.fn(async () => false),
|
|
272
|
+
createAppMonitor: () => appMonitor.appMonitor,
|
|
273
|
+
});
|
|
274
|
+
const metroInstance = createMetroInstance();
|
|
275
|
+
mocks.getBridgeServer.mockResolvedValue(serverBridge);
|
|
276
|
+
mocks.getMetroInstance.mockResolvedValue(metroInstance);
|
|
277
|
+
mocks.waitForMetroBackedAppReady.mockImplementationOnce(async (options) => {
|
|
278
|
+
await options.startAttempt();
|
|
279
|
+
const readyPromise = options.waitForReady(new AbortController().signal);
|
|
280
|
+
emitReady();
|
|
281
|
+
await readyPromise;
|
|
282
|
+
});
|
|
283
|
+
globalThis.__HARNESS_PLATFORM_RUNNER__ = vi.fn(async () => platformInstance);
|
|
284
|
+
const platform = {
|
|
285
|
+
config: {
|
|
286
|
+
appLaunchOptions: {
|
|
287
|
+
extras: {
|
|
288
|
+
source: 'restart',
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
name: 'ios',
|
|
293
|
+
platformId: 'ios',
|
|
294
|
+
runner: `data:text/javascript,${encodeURIComponent('export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);')}`,
|
|
295
|
+
};
|
|
296
|
+
const harness = await getHarness(createHarnessConfig(), platform, '/tmp/project');
|
|
297
|
+
await harness.restart('/tmp/restart.harness.ts');
|
|
298
|
+
expect(stopApp).toHaveBeenCalledTimes(1);
|
|
299
|
+
expect(mocks.waitForMetroBackedAppReady).toHaveBeenCalledTimes(1);
|
|
300
|
+
expect(restartApp).toHaveBeenCalledWith({
|
|
301
|
+
extras: {
|
|
302
|
+
source: 'restart',
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
await harness.dispose();
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
describe('plugins', () => {
|
|
309
|
+
it('invokes lifecycle and runtime plugin handlers for configured plugins', async () => {
|
|
310
|
+
const { serverBridge, emitReady, emitEvent, emitDisconnect } = createBridgeServer();
|
|
311
|
+
const appMonitor = createAppMonitor();
|
|
312
|
+
const platformInstance = createPlatformRunner({
|
|
313
|
+
createAppMonitor: () => appMonitor.appMonitor,
|
|
314
|
+
});
|
|
315
|
+
const observedHooks = [];
|
|
316
|
+
mocks.getBridgeServer.mockResolvedValue(serverBridge);
|
|
317
|
+
const metroInstance = createMetroInstance();
|
|
318
|
+
mocks.getMetroInstance.mockResolvedValue(metroInstance);
|
|
319
|
+
globalThis.__HARNESS_PLATFORM_RUNNER__ = vi.fn(async () => platformInstance);
|
|
320
|
+
const plugin = definePlugin({
|
|
321
|
+
name: 'test-plugin',
|
|
322
|
+
createState: () => ({
|
|
323
|
+
creationCount: 0,
|
|
324
|
+
}),
|
|
325
|
+
hooks: {
|
|
326
|
+
harness: {
|
|
327
|
+
beforeCreation: (ctx) => {
|
|
328
|
+
ctx.state.creationCount += 1;
|
|
329
|
+
observedHooks.push(`beforeCreation:${ctx.platform.platformId}:${ctx.appLaunchOptions == null ? 'no-launch-options' : 'launch-options'}`);
|
|
330
|
+
},
|
|
331
|
+
beforeDispose: (ctx) => {
|
|
332
|
+
observedHooks.push(`beforeDispose:${ctx.state.creationCount}:${ctx.reason}`);
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
runtime: {
|
|
336
|
+
ready: (ctx) => {
|
|
337
|
+
observedHooks.push(`runtime.ready:${ctx.runId}:${ctx.device.platform}`);
|
|
338
|
+
},
|
|
339
|
+
disconnected: (ctx) => {
|
|
340
|
+
observedHooks.push(`runtime.disconnected:${ctx.reason}`);
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
collection: {
|
|
344
|
+
started: (ctx) => {
|
|
345
|
+
observedHooks.push(`collection.started:${ctx.file}`);
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
const platform = {
|
|
351
|
+
config: {
|
|
352
|
+
appLaunchOptions: {
|
|
353
|
+
environment: {
|
|
354
|
+
MODE: 'test',
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
name: 'ios',
|
|
359
|
+
platformId: 'ios',
|
|
360
|
+
runner: `data:text/javascript,${encodeURIComponent('export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);')}`,
|
|
361
|
+
};
|
|
362
|
+
const harness = await getHarness(createHarnessConfig({
|
|
363
|
+
plugins: [plugin],
|
|
364
|
+
}), platform, '/tmp/project');
|
|
365
|
+
harness.setRunState({
|
|
366
|
+
runId: 'run-1',
|
|
367
|
+
startTime: Date.now(),
|
|
368
|
+
testFiles: ['example.harness.ts'],
|
|
369
|
+
watchMode: false,
|
|
370
|
+
coverageEnabled: false,
|
|
371
|
+
summary: {
|
|
372
|
+
passed: 1,
|
|
373
|
+
failed: 0,
|
|
374
|
+
skipped: 0,
|
|
375
|
+
todo: 0,
|
|
376
|
+
},
|
|
377
|
+
status: 'passed',
|
|
378
|
+
});
|
|
379
|
+
emitReady();
|
|
380
|
+
emitEvent({
|
|
381
|
+
type: 'collection-started',
|
|
382
|
+
file: 'example.harness.ts',
|
|
383
|
+
});
|
|
384
|
+
emitDisconnect();
|
|
385
|
+
await harness.dispose();
|
|
386
|
+
expect(observedHooks).toEqual([
|
|
387
|
+
'beforeCreation:ios:launch-options',
|
|
388
|
+
'runtime.ready:run-1:ios',
|
|
389
|
+
'collection.started:example.harness.ts',
|
|
390
|
+
'runtime.disconnected:bridge-disconnected',
|
|
391
|
+
'beforeDispose:1:normal',
|
|
392
|
+
]);
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
describe('StartupStallError', () => {
|
|
396
|
+
it('includes the configured timeout and attempt count', () => {
|
|
397
|
+
expect(new StartupStallError(1_500, 4).message).toBe('The app did not request its Metro bundle after 4 launch attempts within 1500ms. Last Metro status: unknown.');
|
|
398
|
+
});
|
|
399
|
+
});
|