Skip to content

Commit 20af9c5

Browse files
chore(router-host-2000): add router e2e runner script
1 parent 86be5ab commit 20af9c5

File tree

2 files changed

+385
-1
lines changed

2 files changed

+385
-1
lines changed

.github/workflows/e2e-router.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,4 @@ jobs:
6262

6363
- name: E2E Test for Runtime Demo
6464
if: steps.check-ci.outcome == 'success'
65-
run: npx kill-port --port 2000,2001,2002,2003,2004,2005,2006,2200,2100 && pnpm run app:router:dev & echo "done" && sleep 30 && npx nx run-many --target=test:e2e --projects=router-host-2000 --parallel=1 && lsof -ti tcp:2000,2001,2002,2003,2004,2005,2006,2200,2100 | xargs kill
65+
run: node tools/scripts/run-router-e2e.mjs --mode=dev

tools/scripts/run-router-e2e.mjs

Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
1+
#!/usr/bin/env node
2+
import { spawn } from 'node:child_process';
3+
4+
const ROUTER_WAIT_TARGETS = [
5+
'tcp:2000',
6+
'tcp:2001',
7+
'tcp:2002',
8+
'tcp:2003',
9+
'tcp:2004',
10+
'tcp:2005',
11+
'tcp:2006',
12+
'tcp:2100',
13+
'tcp:2200',
14+
];
15+
16+
const KILL_PORT_ARGS = [
17+
'npx',
18+
'kill-port',
19+
'2000',
20+
'2001',
21+
'2002',
22+
'2003',
23+
'2004',
24+
'2005',
25+
'2006',
26+
'2100',
27+
'2200',
28+
];
29+
30+
const DEFAULT_CI_WAIT_MS = 30_000;
31+
32+
// Marks child processes that run in their own process group so we can safely signal the group.
33+
const DETACHED_PROCESS_GROUP = Symbol('detachedProcessGroup');
34+
35+
const SCENARIOS = {
36+
dev: {
37+
label: 'router development',
38+
serveCmd: ['pnpm', 'run', 'app:router:dev'],
39+
e2eCmd: [
40+
'npx',
41+
'nx',
42+
'run-many',
43+
'--target=test:e2e',
44+
'--projects=router-host-2000',
45+
'--parallel=1',
46+
],
47+
waitTargets: ROUTER_WAIT_TARGETS,
48+
ciWaitMs: DEFAULT_CI_WAIT_MS,
49+
},
50+
};
51+
52+
const VALID_MODES = new Set(['dev', 'all']);
53+
54+
async function main() {
55+
const modeArg = process.argv.find((arg) => arg.startsWith('--mode='));
56+
const mode = modeArg ? modeArg.split('=')[1] : 'all';
57+
58+
if (!VALID_MODES.has(mode)) {
59+
console.error(
60+
`Unknown mode "${mode}". Expected one of ${Array.from(VALID_MODES).join(', ')}`,
61+
);
62+
process.exitCode = 1;
63+
return;
64+
}
65+
66+
const targets = mode === 'all' ? ['dev'] : [mode];
67+
68+
for (const target of targets) {
69+
await runScenario(target);
70+
}
71+
}
72+
73+
async function runScenario(name) {
74+
const scenario = SCENARIOS[name];
75+
if (!scenario) {
76+
throw new Error(`Unknown scenario: ${name}`);
77+
}
78+
79+
console.log(`\n[router-e2e] Starting ${scenario.label}`);
80+
81+
await runKillPort();
82+
83+
const serve = spawn(scenario.serveCmd[0], scenario.serveCmd.slice(1), {
84+
stdio: 'inherit',
85+
detached: true,
86+
});
87+
serve[DETACHED_PROCESS_GROUP] = true;
88+
89+
let serveExitInfo;
90+
let shutdownRequested = false;
91+
92+
const serveExitPromise = new Promise((resolve, reject) => {
93+
serve.on('exit', (code, signal) => {
94+
serveExitInfo = { code, signal };
95+
resolve(serveExitInfo);
96+
});
97+
serve.on('error', reject);
98+
});
99+
100+
try {
101+
const { factory: waitFactory, note: waitFactoryNote } =
102+
getWaitFactory(scenario);
103+
if (waitFactoryNote) {
104+
console.log(waitFactoryNote);
105+
}
106+
107+
await runGuardedCommand(
108+
'waiting for router demo ports',
109+
serveExitPromise,
110+
waitFactory,
111+
() => shutdownRequested,
112+
);
113+
114+
await runGuardedCommand(
115+
'running router e2e tests',
116+
serveExitPromise,
117+
() => spawnWithPromise(scenario.e2eCmd[0], scenario.e2eCmd.slice(1)),
118+
() => shutdownRequested,
119+
);
120+
} finally {
121+
shutdownRequested = true;
122+
123+
let serveExitError = null;
124+
try {
125+
await shutdownServe(serve, serveExitPromise);
126+
} catch (error) {
127+
console.error('[router-e2e] Serve command emitted error:', error);
128+
serveExitError = error;
129+
}
130+
131+
await runKillPort();
132+
133+
if (serveExitError) {
134+
throw serveExitError;
135+
}
136+
}
137+
138+
if (!isExpectedServeExit(serveExitInfo)) {
139+
throw new Error(
140+
`Serve command for ${scenario.label} exited unexpectedly with ${formatExit(serveExitInfo)}`,
141+
);
142+
}
143+
144+
console.log(`[router-e2e] Finished ${scenario.label}`);
145+
}
146+
147+
async function runKillPort() {
148+
const { promise } = spawnWithPromise(
149+
KILL_PORT_ARGS[0],
150+
KILL_PORT_ARGS.slice(1),
151+
);
152+
try {
153+
await promise;
154+
} catch (error) {
155+
console.warn('[router-e2e] kill-port command failed:', error.message);
156+
}
157+
}
158+
159+
function spawnWithPromise(cmd, args, options = {}) {
160+
const child = spawn(cmd, args, {
161+
stdio: 'inherit',
162+
...options,
163+
});
164+
if (options.detached) {
165+
child[DETACHED_PROCESS_GROUP] = true;
166+
}
167+
168+
const promise = new Promise((resolve, reject) => {
169+
child.on('exit', (code, signal) => {
170+
if (code === 0) {
171+
resolve({ code, signal });
172+
} else {
173+
reject(
174+
new Error(
175+
`${cmd} ${args.join(' ')} exited with ${formatExit({ code, signal })}`,
176+
),
177+
);
178+
}
179+
});
180+
child.on('error', reject);
181+
});
182+
183+
return { child, promise };
184+
}
185+
186+
function getWaitFactory(scenario) {
187+
const waitTargets = scenario.waitTargets ?? [];
188+
if (!waitTargets.length) {
189+
return {
190+
factory: () =>
191+
spawnWithPromise(process.execPath, ['-e', 'process.exit(0)']),
192+
};
193+
}
194+
195+
if (process.env.CI) {
196+
const waitMs = getCiWaitMs(scenario);
197+
return {
198+
factory: () =>
199+
spawnWithPromise(process.execPath, [
200+
'-e',
201+
`setTimeout(() => process.exit(0), ${waitMs});`,
202+
]),
203+
note: `[router-e2e] CI detected; sleeping for ${waitMs}ms before running router e2e tests`,
204+
};
205+
}
206+
207+
return {
208+
factory: () => spawnWithPromise('npx', ['wait-on', ...waitTargets]),
209+
};
210+
}
211+
212+
function getCiWaitMs(scenario) {
213+
const userOverride = Number.parseInt(
214+
process.env.ROUTER_E2E_CI_WAIT_MS ?? '',
215+
10,
216+
);
217+
if (!Number.isNaN(userOverride) && userOverride >= 0) {
218+
return userOverride;
219+
}
220+
if (typeof scenario.ciWaitMs === 'number' && scenario.ciWaitMs >= 0) {
221+
return scenario.ciWaitMs;
222+
}
223+
return DEFAULT_CI_WAIT_MS;
224+
}
225+
226+
async function shutdownServe(proc, exitPromise) {
227+
if (proc.exitCode !== null || proc.signalCode !== null) {
228+
return exitPromise;
229+
}
230+
231+
const sequence = [
232+
{ signal: 'SIGINT', timeoutMs: 8000 },
233+
{ signal: 'SIGTERM', timeoutMs: 5000 },
234+
{ signal: 'SIGKILL', timeoutMs: 3000 },
235+
];
236+
237+
for (const { signal, timeoutMs } of sequence) {
238+
if (proc.exitCode !== null || proc.signalCode !== null) {
239+
break;
240+
}
241+
242+
sendSignal(proc, signal);
243+
244+
try {
245+
await waitWithTimeout(exitPromise, timeoutMs);
246+
break;
247+
} catch (error) {
248+
if (error.name !== 'TimeoutError') {
249+
throw error;
250+
}
251+
}
252+
}
253+
254+
return exitPromise;
255+
}
256+
257+
function sendSignal(proc, signal) {
258+
if (proc.exitCode !== null || proc.signalCode !== null) {
259+
return;
260+
}
261+
262+
if (proc[DETACHED_PROCESS_GROUP]) {
263+
try {
264+
process.kill(-proc.pid, signal);
265+
return;
266+
} catch (error) {
267+
if (error.code !== 'ESRCH' && error.code !== 'EPERM') {
268+
throw error;
269+
}
270+
}
271+
}
272+
273+
try {
274+
proc.kill(signal);
275+
} catch (error) {
276+
if (error.code !== 'ESRCH') {
277+
throw error;
278+
}
279+
}
280+
}
281+
282+
function waitWithTimeout(promise, timeoutMs) {
283+
return new Promise((resolve, reject) => {
284+
let settled = false;
285+
286+
const timer = setTimeout(() => {
287+
if (settled) {
288+
return;
289+
}
290+
settled = true;
291+
const timeoutError = new Error(`Timed out after ${timeoutMs}ms`);
292+
timeoutError.name = 'TimeoutError';
293+
reject(timeoutError);
294+
}, timeoutMs);
295+
296+
promise.then(
297+
(value) => {
298+
if (settled) {
299+
return;
300+
}
301+
settled = true;
302+
clearTimeout(timer);
303+
resolve(value);
304+
},
305+
(error) => {
306+
if (settled) {
307+
return;
308+
}
309+
settled = true;
310+
clearTimeout(timer);
311+
reject(error);
312+
},
313+
);
314+
});
315+
}
316+
317+
function isExpectedServeExit(info) {
318+
if (!info) {
319+
return false;
320+
}
321+
322+
const { code, signal } = info;
323+
324+
if (code === 0) {
325+
return true;
326+
}
327+
328+
if (code === 130 || code === 137 || code === 143) {
329+
return true;
330+
}
331+
332+
if (code == null && ['SIGINT', 'SIGTERM', 'SIGKILL'].includes(signal)) {
333+
return true;
334+
}
335+
336+
return false;
337+
}
338+
339+
function formatExit({ code, signal }) {
340+
const parts = [];
341+
if (code !== null && code !== undefined) {
342+
parts.push(`code ${code}`);
343+
}
344+
if (signal) {
345+
parts.push(`signal ${signal}`);
346+
}
347+
return parts.length > 0 ? parts.join(', ') : 'unknown status';
348+
}
349+
350+
main().catch((error) => {
351+
console.error('[router-e2e] Error:', error);
352+
process.exitCode = 1;
353+
});
354+
355+
async function runGuardedCommand(
356+
description,
357+
serveExitPromise,
358+
factory,
359+
isShutdownRequested = () => false,
360+
) {
361+
const { child, promise } = factory();
362+
363+
const serveWatcher = serveExitPromise.then((info) => {
364+
if (isShutdownRequested()) {
365+
return info;
366+
}
367+
if (child.exitCode === null && child.signalCode === null) {
368+
sendSignal(child, 'SIGINT');
369+
}
370+
throw new Error(
371+
`Serve process exited while ${description}: ${formatExit(info)}`,
372+
);
373+
});
374+
375+
try {
376+
return await Promise.race([promise, serveWatcher]);
377+
} finally {
378+
serveWatcher.catch(() => {});
379+
if (child.exitCode === null && child.signalCode === null) {
380+
// ensure processes do not linger if the command resolved first
381+
sendSignal(child, 'SIGINT');
382+
}
383+
}
384+
}

0 commit comments

Comments
 (0)