chore: isolate build artifacts and storybook smoke infra

This commit is contained in:
2026-03-20 00:19:43 +08:00
parent 7c87c7af37
commit 5045756525
10 changed files with 206 additions and 14 deletions
+171
View File
@@ -0,0 +1,171 @@
const { spawn } = require("node:child_process");
const fs = require("node:fs/promises");
const path = require("node:path");
const repoRoot = path.resolve(__dirname, "../../..");
const docsDir = path.resolve(repoRoot, "apps/docs");
const stateFile = path.resolve(repoRoot, ".artifacts/test-results/storybook-server.json");
const baseURL = "http://127.0.0.1:6006";
const startupTimeoutMs = 120_000;
const shutdownTimeoutMs = 10_000;
const pollIntervalMs = 250;
const storybookCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
const storybookArgs = ["run", "storybook"];
const reuseExistingServer = !process.env.CI;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function isServerResponsive() {
try {
const response = await fetch(baseURL, {
redirect: "manual",
signal: AbortSignal.timeout(1_000)
});
return response.status < 404 || [400, 401, 402, 403].includes(response.status);
} catch {
return false;
}
}
async function waitFor(condition, timeoutMs, failureMessage) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await condition()) {
return;
}
await sleep(pollIntervalMs);
}
throw new Error(failureMessage);
}
async function ensureStateDir() {
await fs.mkdir(path.dirname(stateFile), { recursive: true });
}
async function readServerState() {
try {
const raw = await fs.readFile(stateFile, "utf8");
return JSON.parse(raw);
} catch {
return null;
}
}
async function clearServerState() {
await fs.rm(stateFile, { force: true });
}
async function stopProcess(pid) {
try {
if (process.platform === "win32") {
process.kill(pid, "SIGTERM");
} else {
process.kill(-pid, "SIGTERM");
}
} catch (error) {
if (error && error.code === "ESRCH") {
return;
}
throw error;
}
try {
await waitFor(
async () => !(await isServerResponsive()),
shutdownTimeoutMs,
`Timed out waiting ${shutdownTimeoutMs}ms for Storybook to stop.`
);
} catch {
try {
if (process.platform === "win32") {
process.kill(pid, "SIGKILL");
} else {
process.kill(-pid, "SIGKILL");
}
} catch (error) {
if (!error || error.code !== "ESRCH") {
throw error;
}
}
}
}
async function startStorybookServer() {
if (await isServerResponsive()) {
if (reuseExistingServer) {
return;
}
throw new Error(`${baseURL} is already in use, and CI runs must start their own Storybook server.`);
}
const child = spawn(storybookCommand, storybookArgs, {
cwd: docsDir,
detached: process.platform !== "win32",
env: process.env,
shell: false,
stdio: "ignore"
});
const exitPromise = new Promise((_, reject) => {
child.once("error", (error) => {
reject(error);
});
child.once("exit", (code, signal) => {
reject(
new Error(
`Storybook exited before becoming ready (code: ${code ?? "null"}, signal: ${signal ?? "null"}).`
)
);
});
});
try {
await Promise.race([
waitFor(
async () => isServerResponsive(),
startupTimeoutMs,
`Timed out waiting ${startupTimeoutMs}ms for Storybook on ${baseURL}.`
),
exitPromise
]);
} catch (error) {
await stopProcess(child.pid);
throw error;
}
child.unref();
await ensureStateDir();
await fs.writeFile(stateFile, JSON.stringify({ pid: child.pid }, null, 2));
}
async function stopStorybookServer() {
const state = await readServerState();
if (!state) {
return;
}
try {
await stopProcess(state.pid);
} finally {
await clearServerState();
}
}
module.exports = {
clearServerState,
readServerState,
startStorybookServer,
stopStorybookServer
};