chore: isolate build artifacts and storybook smoke infra
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
node_modules
|
||||
/.artifacts
|
||||
dist
|
||||
storybook-static
|
||||
coverage
|
||||
|
||||
@@ -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,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
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
const { startStorybookServer } = require("./storybook-server.cjs");
|
||||
|
||||
async function globalSetup() {
|
||||
await startStorybookServer();
|
||||
}
|
||||
|
||||
module.exports = globalSetup;
|
||||
@@ -0,0 +1,7 @@
|
||||
const { stopStorybookServer } = require("./storybook-server.cjs");
|
||||
|
||||
async function globalTeardown() {
|
||||
await stopStorybookServer();
|
||||
}
|
||||
|
||||
module.exports = globalTeardown;
|
||||
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user