Skip to content

Commit c09ce15

Browse files
committed
Encapsulate Metro startup orchestration
1 parent d5f1086 commit c09ce15

19 files changed

Lines changed: 1178 additions & 459 deletions

File tree

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import fs from 'node:fs';
2+
import os from 'node:os';
3+
import path from 'node:path';
4+
import { afterEach, describe, expect, it, vi } from 'vitest';
5+
import type { Config as HarnessConfig } from '@react-native-harness/config';
6+
import type { Reporter, ReportableEvent } from '../reporter.js';
7+
import { getBundleRequestObserverMiddleware } from '../middlewares/bundle-request-middleware.js';
8+
import { HARNESS_REQUEST_KIND_HEADER } from '../request-kind.js';
9+
10+
const createReporter = () => {
11+
const events: ReportableEvent[] = [];
12+
13+
const reporter: Reporter = {
14+
addListener: vi.fn(),
15+
removeListener: vi.fn(),
16+
clearAllListeners: vi.fn(),
17+
emit: (event) => {
18+
events.push(event);
19+
},
20+
};
21+
22+
return { events, reporter };
23+
};
24+
25+
const createProjectRoot = () => {
26+
const projectRoot = fs.mkdtempSync(
27+
path.join(os.tmpdir(), 'rn-harness-bundle-request-')
28+
);
29+
tempDirs.push(projectRoot);
30+
fs.writeFileSync(path.join(projectRoot, 'index.js'), 'module.exports = {};');
31+
return projectRoot;
32+
};
33+
34+
const tempDirs: string[] = [];
35+
36+
afterEach(() => {
37+
for (const tempDir of tempDirs.splice(0)) {
38+
fs.rmSync(tempDir, { recursive: true, force: true });
39+
}
40+
});
41+
42+
const createHarnessConfig = (): HarnessConfig =>
43+
({
44+
entryPoint: './index.js',
45+
}) as HarnessConfig;
46+
47+
describe('bundle request observer middleware', () => {
48+
it('emits app-originated entry bundle requests', () => {
49+
const { events, reporter } = createReporter();
50+
const middleware = getBundleRequestObserverMiddleware(
51+
createProjectRoot(),
52+
createHarnessConfig(),
53+
reporter
54+
);
55+
const next = vi.fn();
56+
57+
middleware(
58+
{
59+
headers: {},
60+
url: '/index.bundle?platform=ios&dev=true',
61+
} as never,
62+
{} as never,
63+
next
64+
);
65+
66+
expect(events).toEqual([
67+
expect.objectContaining({
68+
type: 'bundle_request_observed',
69+
platform: 'ios',
70+
requestKind: 'app',
71+
url: '/index.bundle?platform=ios&dev=true',
72+
}),
73+
]);
74+
expect(next).toHaveBeenCalledTimes(1);
75+
});
76+
77+
it('tags prewarm requests using the Harness header', () => {
78+
const { events, reporter } = createReporter();
79+
const middleware = getBundleRequestObserverMiddleware(
80+
createProjectRoot(),
81+
createHarnessConfig(),
82+
reporter
83+
);
84+
85+
middleware(
86+
{
87+
headers: {
88+
[HARNESS_REQUEST_KIND_HEADER]: 'prewarm',
89+
},
90+
url: '/index.bundle?platform=android',
91+
} as never,
92+
{} as never,
93+
vi.fn()
94+
);
95+
96+
expect(events).toEqual([
97+
expect.objectContaining({
98+
platform: 'android',
99+
requestKind: 'prewarm',
100+
}),
101+
]);
102+
});
103+
104+
it('ignores non-entry bundle requests', () => {
105+
const { events, reporter } = createReporter();
106+
const middleware = getBundleRequestObserverMiddleware(
107+
createProjectRoot(),
108+
createHarnessConfig(),
109+
reporter
110+
);
111+
112+
middleware(
113+
{
114+
headers: {},
115+
url: '/other.bundle?platform=ios',
116+
} as never,
117+
{} as never,
118+
vi.fn()
119+
);
120+
121+
expect(events).toEqual([]);
122+
});
123+
});
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
import { getEmitter } from '@react-native-harness/tools';
3+
import { waitForMetroBackedAppReady } from '../startup.js';
4+
import type { ReportableEvent } from '../reporter.js';
5+
import type { MetroInstance } from '../types.js';
6+
7+
const createAbortError = () =>
8+
new DOMException('The operation was aborted', 'AbortError');
9+
10+
const waitForAbort = (signal: AbortSignal): Promise<never> => {
11+
if (signal.aborted) {
12+
return Promise.reject(signal.reason ?? createAbortError());
13+
}
14+
15+
return new Promise((_, reject) => {
16+
signal.addEventListener(
17+
'abort',
18+
() => {
19+
reject(signal.reason ?? createAbortError());
20+
},
21+
{ once: true }
22+
);
23+
});
24+
};
25+
26+
const createMetroInstance = (
27+
overrides: Partial<MetroInstance> = {}
28+
): MetroInstance => ({
29+
events: getEmitter<ReportableEvent>(),
30+
waitUntilHealthy: vi.fn(async () => 'HTTP 200: packager-status:running'),
31+
prewarm: vi.fn(async () => false),
32+
dispose: vi.fn(async () => undefined),
33+
...overrides,
34+
});
35+
36+
const emitBundleRequestObserved = (
37+
metroInstance: MetroInstance,
38+
requestKind: 'app' | 'prewarm',
39+
platform = 'ios'
40+
) => {
41+
metroInstance.events.emit({
42+
type: 'bundle_request_observed',
43+
platform,
44+
requestKind,
45+
timestamp: new Date().toISOString(),
46+
url: `/index.bundle?platform=${platform}`,
47+
});
48+
};
49+
50+
afterEach(() => {
51+
vi.useRealTimers();
52+
});
53+
54+
describe('waitForMetroBackedAppReady', () => {
55+
it('fails when Metro never becomes healthy', async () => {
56+
const metroInstance = createMetroInstance({
57+
waitUntilHealthy: vi.fn(
58+
async () => 'HTTP 503: packager-status:starting'
59+
),
60+
});
61+
const startAttempt = vi.fn(async () => undefined);
62+
63+
await expect(
64+
waitForMetroBackedAppReady({
65+
metro: metroInstance,
66+
platformId: 'ios',
67+
bundleStartTimeout: 1_000,
68+
readyTimeout: 2_000,
69+
maxAppRestarts: 2,
70+
signal: new AbortController().signal,
71+
startAttempt,
72+
waitForReady: async () => undefined,
73+
waitForCrash: async (signal) => await waitForAbort(signal),
74+
})
75+
).rejects.toMatchObject({
76+
name: 'StartupStallError',
77+
code: 'metro_not_ready',
78+
});
79+
80+
expect(metroInstance.prewarm).not.toHaveBeenCalled();
81+
expect(startAttempt).not.toHaveBeenCalled();
82+
});
83+
84+
it('keeps prewarm as warm-up only and still retries until an app request appears', async () => {
85+
vi.useFakeTimers();
86+
87+
const metroInstance = createMetroInstance({
88+
prewarm: vi.fn(async () => true),
89+
});
90+
const startAttempt = vi.fn(async () => undefined);
91+
92+
const promise = waitForMetroBackedAppReady({
93+
metro: metroInstance,
94+
platformId: 'ios',
95+
bundleStartTimeout: 1_000,
96+
readyTimeout: 2_000,
97+
maxAppRestarts: 1,
98+
signal: new AbortController().signal,
99+
startAttempt,
100+
waitForReady: async (signal) => await waitForAbort(signal),
101+
waitForCrash: async (signal) => await waitForAbort(signal),
102+
});
103+
104+
await vi.advanceTimersByTimeAsync(2_000);
105+
106+
await expect(promise).rejects.toMatchObject({
107+
name: 'StartupStallError',
108+
code: 'bundle_request_not_observed',
109+
attempts: 2,
110+
sawPrewarmRequest: true,
111+
});
112+
expect(startAttempt).toHaveBeenCalledTimes(2);
113+
});
114+
115+
it('completes once the app requests its bundle and reports ready', async () => {
116+
const metroInstance = createMetroInstance();
117+
const startAttempt = vi.fn(async () => {
118+
emitBundleRequestObserved(metroInstance, 'app');
119+
});
120+
const waitForReady = vi.fn(async () => undefined);
121+
122+
await waitForMetroBackedAppReady({
123+
metro: metroInstance,
124+
platformId: 'ios',
125+
bundleStartTimeout: 1_000,
126+
readyTimeout: 2_000,
127+
maxAppRestarts: 2,
128+
signal: new AbortController().signal,
129+
startAttempt,
130+
waitForReady,
131+
waitForCrash: async (signal) => await waitForAbort(signal),
132+
});
133+
134+
expect(startAttempt).toHaveBeenCalledTimes(1);
135+
expect(waitForReady).toHaveBeenCalledTimes(1);
136+
});
137+
138+
it('fails when the app requests its bundle but never reports ready', async () => {
139+
vi.useFakeTimers();
140+
141+
const metroInstance = createMetroInstance();
142+
const startAttempt = vi.fn(async () => {
143+
emitBundleRequestObserved(metroInstance, 'app');
144+
});
145+
146+
const promise = waitForMetroBackedAppReady({
147+
metro: metroInstance,
148+
platformId: 'ios',
149+
bundleStartTimeout: 1_000,
150+
readyTimeout: 2_000,
151+
maxAppRestarts: 2,
152+
signal: new AbortController().signal,
153+
startAttempt,
154+
waitForReady: async (signal) => await waitForAbort(signal),
155+
waitForCrash: async (signal) => await waitForAbort(signal),
156+
});
157+
158+
await vi.advanceTimersByTimeAsync(2_000);
159+
160+
await expect(promise).rejects.toMatchObject({
161+
name: 'StartupStallError',
162+
code: 'ready_not_reported',
163+
attempts: 1,
164+
});
165+
expect(startAttempt).toHaveBeenCalledTimes(1);
166+
});
167+
168+
it('surfaces crash failures immediately instead of retrying', async () => {
169+
const metroInstance = createMetroInstance();
170+
const crashError = new Error('native crash');
171+
const startAttempt = vi.fn(async () => undefined);
172+
173+
await expect(
174+
waitForMetroBackedAppReady({
175+
metro: metroInstance,
176+
platformId: 'ios',
177+
bundleStartTimeout: 1_000,
178+
readyTimeout: 2_000,
179+
maxAppRestarts: 2,
180+
signal: new AbortController().signal,
181+
startAttempt,
182+
waitForReady: async (signal) => await waitForAbort(signal),
183+
waitForCrash: async () => {
184+
throw crashError;
185+
},
186+
})
187+
).rejects.toBe(crashError);
188+
189+
expect(startAttempt).toHaveBeenCalledTimes(1);
190+
});
191+
192+
it('stops after maxAppRestarts when no app request is ever observed', async () => {
193+
vi.useFakeTimers();
194+
195+
const metroInstance = createMetroInstance();
196+
const startAttempt = vi.fn(async () => undefined);
197+
198+
const promise = waitForMetroBackedAppReady({
199+
metro: metroInstance,
200+
platformId: 'ios',
201+
bundleStartTimeout: 1_000,
202+
readyTimeout: 2_000,
203+
maxAppRestarts: 2,
204+
signal: new AbortController().signal,
205+
startAttempt,
206+
waitForReady: async (signal) => await waitForAbort(signal),
207+
waitForCrash: async (signal) => await waitForAbort(signal),
208+
});
209+
210+
await vi.advanceTimersByTimeAsync(3_000);
211+
212+
await expect(promise).rejects.toMatchObject({
213+
name: 'StartupStallError',
214+
code: 'bundle_request_not_observed',
215+
attempts: 3,
216+
sawPrewarmRequest: false,
217+
});
218+
expect(startAttempt).toHaveBeenCalledTimes(3);
219+
});
220+
});

0 commit comments

Comments
 (0)