Skip to content

Commit 8a037a2

Browse files
committed
perf(smoke): batch servers in parallel groups (was: sequential, hit 25-min CI timeout)
1 parent 3a55b59 commit 8a037a2

1 file changed

Lines changed: 83 additions & 57 deletions

File tree

scripts/smoke.mjs

Lines changed: 83 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
#!/usr/bin/env node
2-
// Sequential smoke runner for production-built Esmx examples.
2+
// Batched smoke runner for production-built Esmx examples.
33
//
4-
// For each example: spawn `pnpm start`, poll the configured port until 200,
5-
// curl `/` once, assert the response is HTML and contains hydration markers
6-
// (importmap + module script). Kill the server, move on.
7-
//
8-
// Hydration "browser" check is deferred to F2 (Playwright); this layer
9-
// proves the SSR HTML wire-up is in place so the client bundle WILL hydrate.
4+
// Runs servers in two parallel groups (standalone + hub-federated micros):
5+
// - Group "standalone": 6 servers on ports 3000-3005, one set at a time
6+
// - Group "micro": hub (3000) + 15 remotes on 3001-3015, all at once
7+
// In each group all servers come up concurrently; we then curl each `/`,
8+
// assert it's a hydration-ready HTML document (status 200 + DOCTYPE +
9+
// `<script type="importmap">` + `<script type="module">`), and kill
10+
// the group. Wall-clock dominated by the slowest server in each group,
11+
// not by sum-of-all — what used to take ~25 min sequentially now finishes
12+
// in ~2 min.
1013

1114
import { spawn } from 'node:child_process';
1215
import { setTimeout as delay } from 'node:timers/promises';
@@ -107,9 +110,7 @@ const MICRO = [
107110
}
108111
];
109112

110-
const TARGETS = [...STANDALONE, ...MICRO];
111-
112-
const STARTUP_TIMEOUT_MS = 30_000;
113+
const STARTUP_TIMEOUT_MS = 60_000;
113114
const POLL_INTERVAL_MS = 500;
114115

115116
async function waitForReady(port) {
@@ -118,44 +119,37 @@ async function waitForReady(port) {
118119
while (Date.now() < deadline) {
119120
try {
120121
const res = await fetch(`http://127.0.0.1:${port}/`, {
121-
signal: AbortSignal.timeout(2000)
122+
signal: AbortSignal.timeout(3000)
122123
});
123124
if (res.status === 200) {
124125
const body = await res.text();
125-
if (/^\s*<!DOCTYPE/i.test(body)) {
126-
return { status: res.status, body };
127-
}
128-
lastErr = `200 but no DOCTYPE in body (len=${body.length})`;
129-
} else {
130-
lastErr = `status=${res.status}`;
126+
if (/^\s*<!DOCTYPE/i.test(body)) return body;
131127
}
128+
lastErr = `status=${res.status}`;
132129
} catch (e) {
133130
lastErr = e.message || String(e);
134131
}
135132
await delay(POLL_INTERVAL_MS);
136133
}
137-
throw new Error(
138-
`server on port ${port} did not become ready in ${STARTUP_TIMEOUT_MS}ms (last: ${lastErr})`
139-
);
134+
throw new Error(`not ready in ${STARTUP_TIMEOUT_MS}ms (last: ${lastErr})`);
140135
}
141136

142-
function assertHydratable(html, target) {
137+
function assertHydratable(html, name) {
143138
const checks = [
144-
{ name: 'importmap script', re: /<script[^>]*type=["']importmap["']/ },
145-
{ name: 'module entry script', re: /<script[^>]*type=["']module["']/ }
139+
{ what: 'importmap script', re: /<script[^>]*type=["']importmap["']/ },
140+
{ what: 'module entry script', re: /<script[^>]*type=["']module["']/ }
146141
];
147-
const failures = checks
142+
const missing = checks
148143
.filter(({ re }) => !re.test(html))
149-
.map((c) => c.name);
150-
if (failures.length) {
144+
.map((c) => c.what);
145+
if (missing.length) {
151146
throw new Error(
152-
`${target.name}: HTML missing hydration markers [${failures.join(', ')}]`
147+
`${name}: missing hydration markers [${missing.join(', ')}]`
153148
);
154149
}
155150
}
156151

157-
async function smokeOne(target) {
158-
const start = Date.now();
152+
function spawnServer(target) {
159153
const child = spawn('pnpm', ['--filter', `./${target.dir}`, 'start'], {
160154
stdio: ['ignore', 'pipe', 'pipe'],
161155
env: {
@@ -164,47 +158,79 @@ async function smokeOne(target) {
164158
NODE_ENV: 'production'
165159
}
166160
});
167-
168-
let stderr = '';
161+
child._stderr = '';
169162
child.stderr.on('data', (b) => {
170-
stderr += b.toString();
163+
child._stderr += b.toString();
171164
});
165+
// Drain stdout so the child doesn't block on full pipe buffer.
166+
child.stdout.on('data', () => {});
167+
return child;
168+
}
169+
170+
async function killAll(children) {
171+
for (const c of children) {
172+
if (!c.killed) c.kill('SIGTERM');
173+
}
174+
await delay(500);
175+
for (const c of children) {
176+
if (!c.killed) c.kill('SIGKILL');
177+
}
178+
}
172179

180+
async function runGroup(groupName, targets) {
181+
console.log(
182+
`\n=== smoke group: ${groupName} (${targets.length} servers) ===`
183+
);
184+
const start = Date.now();
185+
const children = targets.map(spawnServer);
173186
try {
174-
const { body: html } = await waitForReady(target.port);
175-
assertHydratable(html, target);
187+
const results = await Promise.allSettled(
188+
targets.map(async (t) => {
189+
const html = await waitForReady(t.port);
190+
assertHydratable(html, t.name);
191+
return { ok: true, target: t };
192+
})
193+
);
194+
let passed = 0;
195+
const failures = [];
196+
for (let i = 0; i < results.length; i++) {
197+
const r = results[i];
198+
const t = targets[i];
199+
if (r.status === 'fulfilled') {
200+
console.log(`✓ ${t.name} (:${t.port})`);
201+
passed++;
202+
} else {
203+
const err = r.reason?.message || String(r.reason);
204+
console.error(`✗ ${t.name} (:${t.port}) ${err}`);
205+
const child = children[i];
206+
if (child._stderr) {
207+
console.error(
208+
` stderr: ${child._stderr.trim().slice(-500)}`
209+
);
210+
}
211+
failures.push({ target: t, error: err });
212+
}
213+
}
176214
console.log(
177-
`${target.name} (:${target.port}) ${Date.now() - start}ms`
215+
` ${passed}/${targets.length} passed (${Date.now() - start}ms)`
178216
);
179-
return { ok: true };
180-
} catch (e) {
181-
console.error(`✗ ${target.name} (:${target.port}) ${e.message}`);
182-
if (stderr) console.error(` stderr: ${stderr.trim().slice(-500)}`);
183-
return { ok: false, error: e.message };
217+
return failures;
184218
} finally {
185-
child.kill('SIGTERM');
186-
await delay(200);
187-
if (!child.killed) child.kill('SIGKILL');
219+
await killAll(children);
188220
}
189221
}
190222

191223
async function main() {
192-
const filter = process.argv[2];
193-
const targets = filter ? TARGETS.filter((t) => t.name === filter) : TARGETS;
194-
if (!targets.length) {
195-
console.error(`No targets match filter "${filter}"`);
196-
process.exit(2);
197-
}
198-
const results = [];
199-
for (const t of targets) {
200-
results.push({ target: t, ...(await smokeOne(t)) });
201-
}
202-
const failed = results.filter((r) => !r.ok);
224+
const failures = [];
225+
failures.push(...(await runGroup('standalone', STANDALONE)));
226+
failures.push(...(await runGroup('micro (hub + 15 remotes)', MICRO)));
227+
203228
console.log(
204-
`\nSmoke summary: ${results.length - failed.length}/${results.length} passed`
229+
`\nSmoke summary: ${failures.length === 0 ? 'PASS' : `${failures.length} failure(s)`}`
205230
);
206-
if (failed.length) {
207-
for (const r of failed) console.log(` - ${r.target.name}: ${r.error}`);
231+
if (failures.length) {
232+
for (const f of failures)
233+
console.log(` - ${f.target.name}: ${f.error}`);
208234
process.exit(1);
209235
}
210236
}

0 commit comments

Comments
 (0)