Skip to content

Commit d713593

Browse files
Merge remote-tracking branch 'origin/improve-manifest-e2e' into feat/exp-flag-alias-consumption
2 parents 8678110 + 09463c7 commit d713593

File tree

2 files changed

+340
-2
lines changed

2 files changed

+340
-2
lines changed

.github/workflows/e2e-manifest.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ jobs:
4646

4747
- name: E2E Test for Manifest Demo Development
4848
if: steps.check-ci.outcome == 'success'
49-
run: pnpm run app:manifest:dev & echo "done" && npx wait-on tcp:3009 && npx wait-on tcp:3012 && npx wait-on http://127.0.0.1:4001/ && npx nx run-many --target=e2e --projects=manifest-webpack-host --parallel=2 && npx kill-port 3013 3009 3010 3011 3012 4001
49+
run: node tools/scripts/run-manifest-e2e.mjs --mode=dev
5050

5151
- name: E2E Test for Manifest Demo Production
5252
if: steps.check-ci.outcome == 'success'
53-
run: pnpm run app:manifest:prod & echo "done" && npx wait-on tcp:3009 && npx wait-on tcp:3012 && npx wait-on http://127.0.0.1:4001/ && npx nx run-many --target=e2e --projects=manifest-webpack-host --parallel=1 && npx kill-port 3013 3009 3010 3011 3012 4001
53+
run: node tools/scripts/run-manifest-e2e.mjs --mode=prod

tools/scripts/run-manifest-e2e.mjs

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
#!/usr/bin/env node
2+
import { spawn } from 'node:child_process';
3+
4+
const MANIFEST_WAIT_TARGETS = [
5+
'tcp:3009',
6+
'tcp:3012',
7+
'http://127.0.0.1:4001/',
8+
];
9+
10+
const KILL_PORT_ARGS = [
11+
'npx',
12+
'kill-port',
13+
'3013',
14+
'3009',
15+
'3010',
16+
'3011',
17+
'3012',
18+
'4001',
19+
];
20+
21+
const SCENARIOS = {
22+
dev: {
23+
label: 'manifest development',
24+
serveCmd: ['pnpm', 'run', 'app:manifest:dev'],
25+
e2eCmd: [
26+
'npx',
27+
'nx',
28+
'run-many',
29+
'--target=e2e',
30+
'--projects=manifest-webpack-host',
31+
'--parallel=2',
32+
],
33+
waitTargets: MANIFEST_WAIT_TARGETS,
34+
},
35+
prod: {
36+
label: 'manifest production',
37+
serveCmd: ['pnpm', 'run', 'app:manifest:prod'],
38+
e2eCmd: [
39+
'npx',
40+
'nx',
41+
'run-many',
42+
'--target=e2e',
43+
'--projects=manifest-webpack-host',
44+
'--parallel=1',
45+
],
46+
waitTargets: MANIFEST_WAIT_TARGETS,
47+
},
48+
};
49+
50+
const VALID_MODES = new Set(['dev', 'prod', 'all']);
51+
52+
async function main() {
53+
const modeArg = process.argv.find((arg) => arg.startsWith('--mode='));
54+
const mode = modeArg ? modeArg.split('=')[1] : 'all';
55+
56+
if (!VALID_MODES.has(mode)) {
57+
console.error(
58+
`Unknown mode "${mode}". Expected one of ${Array.from(VALID_MODES).join(', ')}`,
59+
);
60+
process.exitCode = 1;
61+
return;
62+
}
63+
64+
const targets = mode === 'all' ? ['dev', 'prod'] : [mode];
65+
66+
for (const target of targets) {
67+
await runScenario(target);
68+
}
69+
}
70+
71+
async function runScenario(name) {
72+
const scenario = SCENARIOS[name];
73+
if (!scenario) {
74+
throw new Error(`Unknown scenario: ${name}`);
75+
}
76+
77+
console.log(`\n[manifest-e2e] Starting ${scenario.label}`);
78+
79+
const serve = spawn(scenario.serveCmd[0], scenario.serveCmd.slice(1), {
80+
stdio: 'inherit',
81+
detached: true,
82+
});
83+
84+
let serveExitInfo;
85+
let shutdownRequested = false;
86+
87+
const serveExitPromise = new Promise((resolve, reject) => {
88+
serve.on('exit', (code, signal) => {
89+
serveExitInfo = { code, signal };
90+
resolve(serveExitInfo);
91+
});
92+
serve.on('error', reject);
93+
});
94+
95+
const guard = (commandDescription, factory) => {
96+
const controller = new AbortController();
97+
const { signal } = controller;
98+
const { child, promise } = factory(signal);
99+
100+
const watchingPromise = serveExitPromise.then((info) => {
101+
if (!shutdownRequested) {
102+
if (child.exitCode === null && child.signalCode === null) {
103+
controller.abort();
104+
}
105+
throw new Error(
106+
`Serve process exited while ${commandDescription}: ${formatExit(info)}`,
107+
);
108+
}
109+
return info;
110+
});
111+
112+
return Promise.race([promise, watchingPromise]).finally(() => {
113+
if (child.exitCode === null && child.signalCode === null) {
114+
controller.abort();
115+
}
116+
});
117+
};
118+
119+
const runCommand = (cmd, args, signal) => {
120+
const child = spawn(cmd, args, {
121+
stdio: 'inherit',
122+
signal,
123+
});
124+
125+
const promise = new Promise((resolve, reject) => {
126+
child.on('exit', (code, childSignal) => {
127+
if (code === 0) {
128+
resolve({ code, signal: childSignal });
129+
} else {
130+
reject(
131+
new Error(
132+
`${cmd} ${args.join(' ')} exited with ${formatExit({ code, signal: childSignal })}`,
133+
),
134+
);
135+
}
136+
});
137+
child.on('error', reject);
138+
});
139+
140+
return { child, promise };
141+
};
142+
143+
try {
144+
await guard('waiting for manifest services', (signal) =>
145+
runCommand('npx', ['wait-on', ...scenario.waitTargets], signal),
146+
);
147+
148+
await guard('running manifest e2e tests', (signal) =>
149+
runCommand(scenario.e2eCmd[0], scenario.e2eCmd.slice(1), signal),
150+
);
151+
} finally {
152+
shutdownRequested = true;
153+
154+
let serveExitError = null;
155+
try {
156+
await shutdownServe(serve, serveExitPromise);
157+
} catch (error) {
158+
console.error('[manifest-e2e] Serve command emitted error:', error);
159+
serveExitError = error;
160+
}
161+
162+
await runKillPort();
163+
164+
if (serveExitError) {
165+
throw serveExitError;
166+
}
167+
}
168+
169+
if (!isExpectedServeExit(serveExitInfo)) {
170+
throw new Error(
171+
`Serve command for ${scenario.label} exited unexpectedly with ${formatExit(serveExitInfo)}`,
172+
);
173+
}
174+
175+
console.log(`[manifest-e2e] Finished ${scenario.label}`);
176+
}
177+
178+
async function runKillPort() {
179+
const { promise } = spawnWithPromise(
180+
KILL_PORT_ARGS[0],
181+
KILL_PORT_ARGS.slice(1),
182+
);
183+
try {
184+
await promise;
185+
} catch (error) {
186+
console.warn('[manifest-e2e] kill-port command failed:', error.message);
187+
}
188+
}
189+
190+
function spawnWithPromise(cmd, args, options = {}) {
191+
const child = spawn(cmd, args, {
192+
stdio: 'inherit',
193+
...options,
194+
});
195+
196+
const promise = new Promise((resolve, reject) => {
197+
child.on('exit', (code, signal) => {
198+
if (code === 0) {
199+
resolve({ code, signal });
200+
} else {
201+
reject(
202+
new Error(
203+
`${cmd} ${args.join(' ')} exited with ${formatExit({ code, signal })}`,
204+
),
205+
);
206+
}
207+
});
208+
child.on('error', reject);
209+
});
210+
211+
return { child, promise };
212+
}
213+
214+
async function shutdownServe(proc, exitPromise) {
215+
if (proc.exitCode !== null || proc.signalCode !== null) {
216+
return exitPromise;
217+
}
218+
219+
const sequence = [
220+
{ signal: 'SIGINT', timeoutMs: 8000 },
221+
{ signal: 'SIGTERM', timeoutMs: 5000 },
222+
{ signal: 'SIGKILL', timeoutMs: 3000 },
223+
];
224+
225+
for (const { signal, timeoutMs } of sequence) {
226+
if (proc.exitCode !== null || proc.signalCode !== null) {
227+
break;
228+
}
229+
230+
sendSignal(proc, signal);
231+
232+
try {
233+
await waitWithTimeout(exitPromise, timeoutMs);
234+
break;
235+
} catch (error) {
236+
if (error?.name !== 'TimeoutError') {
237+
throw error;
238+
}
239+
// escalate to next signal on timeout
240+
}
241+
}
242+
243+
return exitPromise;
244+
}
245+
246+
function sendSignal(proc, signal) {
247+
if (proc.exitCode !== null || proc.signalCode !== null) {
248+
return;
249+
}
250+
251+
try {
252+
process.kill(-proc.pid, signal);
253+
} catch (error) {
254+
if (error.code !== 'ESRCH' && error.code !== 'EPERM') {
255+
throw error;
256+
}
257+
try {
258+
proc.kill(signal);
259+
} catch (innerError) {
260+
if (innerError.code !== 'ESRCH') {
261+
throw innerError;
262+
}
263+
}
264+
}
265+
}
266+
267+
function waitWithTimeout(promise, timeoutMs) {
268+
return new Promise((resolve, reject) => {
269+
let settled = false;
270+
271+
const timer = setTimeout(() => {
272+
if (settled) {
273+
return;
274+
}
275+
settled = true;
276+
const timeoutError = new Error(`Timed out after ${timeoutMs}ms`);
277+
timeoutError.name = 'TimeoutError';
278+
reject(timeoutError);
279+
}, timeoutMs);
280+
281+
promise.then(
282+
(value) => {
283+
if (settled) {
284+
return;
285+
}
286+
settled = true;
287+
clearTimeout(timer);
288+
resolve(value);
289+
},
290+
(error) => {
291+
if (settled) {
292+
return;
293+
}
294+
settled = true;
295+
clearTimeout(timer);
296+
reject(error);
297+
},
298+
);
299+
});
300+
}
301+
302+
function isExpectedServeExit(info) {
303+
if (!info) {
304+
return false;
305+
}
306+
307+
const { code, signal } = info;
308+
309+
if (code === 0) {
310+
return true;
311+
}
312+
313+
if (code === 130 || code === 137 || code === 143) {
314+
return true;
315+
}
316+
317+
if (code == null && ['SIGINT', 'SIGTERM', 'SIGKILL'].includes(signal)) {
318+
return true;
319+
}
320+
321+
return false;
322+
}
323+
324+
function formatExit({ code, signal }) {
325+
const parts = [];
326+
if (code !== null && code !== undefined) {
327+
parts.push(`code ${code}`);
328+
}
329+
if (signal) {
330+
parts.push(`signal ${signal}`);
331+
}
332+
return parts.length > 0 ? parts.join(', ') : 'unknown status';
333+
}
334+
335+
main().catch((error) => {
336+
console.error('[manifest-e2e] Error:', error);
337+
process.exitCode = 1;
338+
});

0 commit comments

Comments
 (0)