chore: isolate build artifacts and storybook smoke infra
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
/.artifacts
|
||||||
dist
|
dist
|
||||||
storybook-static
|
storybook-static
|
||||||
coverage
|
coverage
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import type { StorybookConfig } from "@storybook/react-vite";
|
import type { StorybookConfig } from "@storybook/react-vite";
|
||||||
import { mergeConfig } from "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 = {
|
const config: StorybookConfig = {
|
||||||
stories: ["../src/**/*.stories.@(ts|tsx)"],
|
stories: ["../src/**/*.stories.@(ts|tsx)"],
|
||||||
addons: [
|
addons: [
|
||||||
@@ -15,6 +24,7 @@ const config: StorybookConfig = {
|
|||||||
},
|
},
|
||||||
async viteFinal(config) {
|
async viteFinal(config) {
|
||||||
return mergeConfig(config, {
|
return mergeConfig(config, {
|
||||||
|
cacheDir: storybookCacheDir,
|
||||||
plugins: [tailwindcss()]
|
plugins: [tailwindcss()]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build-storybook": "storybook build",
|
"build-storybook": "storybook build --disable-telemetry --output-dir ../../.artifacts/storybook-static",
|
||||||
"storybook": "storybook dev -p 6006 --ci",
|
"storybook": "storybook dev -p 6006 --ci --disable-telemetry",
|
||||||
"storybook:smoke": "storybook dev -p 6006 --ci --smoke-test",
|
"storybook:smoke": "storybook dev -p 6006 --ci --smoke-test --disable-telemetry",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+2
-2
@@ -8,10 +8,10 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "pnpm --filter @ai-ui/tokens build && pnpm --filter @ai-ui/ui build",
|
"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": "changeset",
|
||||||
"changeset:status": "changeset status --verbose",
|
"changeset:status": "changeset status --verbose",
|
||||||
"dev:docs": "pnpm --filter @ai-ui/docs storybook",
|
"dev:docs": "pnpm --dir apps/docs run storybook",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"test": "pnpm --filter @ai-ui/ui test",
|
"test": "pnpm --filter @ai-ui/ui test",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
|
|||||||
@@ -18,8 +18,7 @@
|
|||||||
"src"
|
"src"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"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"
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"src"
|
"src"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"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": "vitest run --config ../../vitest.config.ts",
|
||||||
"test:watch": "vitest --config ../../vitest.config.ts",
|
"test:watch": "vitest --config ../../vitest.config.ts",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import { defineConfig } from "@playwright/test";
|
import { defineConfig } from "@playwright/test";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
globalSetup: "./tests/e2e/support/global-setup.cjs",
|
||||||
|
globalTeardown: "./tests/e2e/support/global-teardown.cjs",
|
||||||
|
outputDir: "./.artifacts/test-results",
|
||||||
testDir: "./tests/e2e",
|
testDir: "./tests/e2e",
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
use: {
|
use: {
|
||||||
baseURL: "http://127.0.0.1:6006",
|
baseURL: "http://127.0.0.1:6006",
|
||||||
trace: "retain-on-failure"
|
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