From 50457565259b2e80e7e3bfce6d53388e6e7a4303 Mon Sep 17 00:00:00 2001 From: kurihada Date: Fri, 20 Mar 2026 00:19:43 +0800 Subject: [PATCH] chore: isolate build artifacts and storybook smoke infra --- .gitignore | 1 + apps/docs/.storybook/main.ts | 10 ++ apps/docs/package.json | 6 +- package.json | 4 +- packages/tokens/package.json | 3 +- packages/ui/package.json | 2 +- playwright.config.ts | 9 +- tests/e2e/support/global-setup.cjs | 7 + tests/e2e/support/global-teardown.cjs | 7 + tests/e2e/support/storybook-server.cjs | 171 +++++++++++++++++++++++++ 10 files changed, 206 insertions(+), 14 deletions(-) create mode 100644 tests/e2e/support/global-setup.cjs create mode 100644 tests/e2e/support/global-teardown.cjs create mode 100644 tests/e2e/support/storybook-server.cjs diff --git a/.gitignore b/.gitignore index d4927f4..ce1aaaf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules +/.artifacts dist storybook-static coverage diff --git a/apps/docs/.storybook/main.ts b/apps/docs/.storybook/main.ts index 4ac8b01..ea1c658 100644 --- a/apps/docs/.storybook/main.ts +++ b/apps/docs/.storybook/main.ts @@ -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()] }); } diff --git a/apps/docs/package.json b/apps/docs/package.json index fbf0d5f..c068971 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -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": { diff --git a/package.json b/package.json index 3e88a30..99d2cdd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/tokens/package.json b/packages/tokens/package.json index 85cadc2..a66df01 100644 --- a/packages/tokens/package.json +++ b/packages/tokens/package.json @@ -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" } } - diff --git a/packages/ui/package.json b/packages/ui/package.json index 0cfe66b..f7861a5 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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" diff --git a/playwright.config.ts b/playwright.config.ts index 4851815..fd6620e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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 } }); diff --git a/tests/e2e/support/global-setup.cjs b/tests/e2e/support/global-setup.cjs new file mode 100644 index 0000000..b5abc18 --- /dev/null +++ b/tests/e2e/support/global-setup.cjs @@ -0,0 +1,7 @@ +const { startStorybookServer } = require("./storybook-server.cjs"); + +async function globalSetup() { + await startStorybookServer(); +} + +module.exports = globalSetup; diff --git a/tests/e2e/support/global-teardown.cjs b/tests/e2e/support/global-teardown.cjs new file mode 100644 index 0000000..125954f --- /dev/null +++ b/tests/e2e/support/global-teardown.cjs @@ -0,0 +1,7 @@ +const { stopStorybookServer } = require("./storybook-server.cjs"); + +async function globalTeardown() { + await stopStorybookServer(); +} + +module.exports = globalTeardown; diff --git a/tests/e2e/support/storybook-server.cjs b/tests/e2e/support/storybook-server.cjs new file mode 100644 index 0000000..29e4ca3 --- /dev/null +++ b/tests/e2e/support/storybook-server.cjs @@ -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 +};