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
+1
View File
@@ -1,4 +1,5 @@
node_modules
/.artifacts
dist
storybook-static
coverage
+10
View File
@@ -1,7 +1,16 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import tailwindcss from "@tailwindcss/vite";
import type { StorybookConfig } from "@storybook/react-vite";
import { mergeConfig } from "vite";
const storybookConfigDir = path.dirname(fileURLToPath(import.meta.url));
const storybookCacheDir = path.resolve(
storybookConfigDir,
"../../../.artifacts/cache/storybook/vite"
);
const config: StorybookConfig = {
stories: ["../src/**/*.stories.@(ts|tsx)"],
addons: [
@@ -15,6 +24,7 @@ const config: StorybookConfig = {
},
async viteFinal(config) {
return mergeConfig(config, {
cacheDir: storybookCacheDir,
plugins: [tailwindcss()]
});
}
+3 -3
View File
@@ -3,9 +3,9 @@
"private": true,
"type": "module",
"scripts": {
"build-storybook": "storybook build",
"storybook": "storybook dev -p 6006 --ci",
"storybook:smoke": "storybook dev -p 6006 --ci --smoke-test",
"build-storybook": "storybook build --disable-telemetry --output-dir ../../.artifacts/storybook-static",
"storybook": "storybook dev -p 6006 --ci --disable-telemetry",
"storybook:smoke": "storybook dev -p 6006 --ci --smoke-test --disable-telemetry",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
+2 -2
View File
@@ -8,10 +8,10 @@
},
"scripts": {
"build": "pnpm --filter @ai-ui/tokens build && pnpm --filter @ai-ui/ui build",
"build:docs": "pnpm --filter @ai-ui/docs build-storybook",
"build:docs": "pnpm --dir apps/docs run build-storybook",
"changeset": "changeset",
"changeset:status": "changeset status --verbose",
"dev:docs": "pnpm --filter @ai-ui/docs storybook",
"dev:docs": "pnpm --dir apps/docs run storybook",
"lint": "eslint .",
"test": "pnpm --filter @ai-ui/ui test",
"test:e2e": "playwright test",
+1 -2
View File
@@ -18,8 +18,7 @@
"src"
],
"scripts": {
"build": "tsup src/index.ts --clean --dts --format esm",
"build": "tsup src/index.ts --clean --dts --format esm --out-dir dist",
"typecheck": "tsc --noEmit -p tsconfig.json"
}
}
+1 -1
View File
@@ -11,7 +11,7 @@
"src"
],
"scripts": {
"build": "tsup src/index.ts --clean --dts --format esm,cjs",
"build": "tsup src/index.ts --clean --dts --format esm,cjs --out-dir dist",
"test": "vitest run --config ../../vitest.config.ts",
"test:watch": "vitest --config ../../vitest.config.ts",
"typecheck": "tsc --noEmit -p tsconfig.json"
+3 -6
View File
@@ -1,16 +1,13 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
globalSetup: "./tests/e2e/support/global-setup.cjs",
globalTeardown: "./tests/e2e/support/global-teardown.cjs",
outputDir: "./.artifacts/test-results",
testDir: "./tests/e2e",
timeout: 30_000,
use: {
baseURL: "http://127.0.0.1:6006",
trace: "retain-on-failure"
},
webServer: {
command: "pnpm --filter @ai-ui/docs storybook",
port: 6006,
reuseExistingServer: !process.env.CI,
timeout: 120_000
}
});
+7
View File
@@ -0,0 +1,7 @@
const { startStorybookServer } = require("./storybook-server.cjs");
async function globalSetup() {
await startStorybookServer();
}
module.exports = globalSetup;
+7
View File
@@ -0,0 +1,7 @@
const { stopStorybookServer } = require("./storybook-server.cjs");
async function globalTeardown() {
await stopStorybookServer();
}
module.exports = globalTeardown;
+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
};