test: add registry consumer smoke coverage
This commit is contained in:
@@ -102,3 +102,6 @@ jobs:
|
||||
|
||||
- name: Validate registry metadata
|
||||
run: pnpm registry:check
|
||||
|
||||
- name: Run registry consumer smoke test
|
||||
run: pnpm test:registry:consumer
|
||||
|
||||
@@ -37,6 +37,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run registry consumer smoke test
|
||||
run: pnpm test:registry:consumer
|
||||
|
||||
- name: Open or update version PR
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
|
||||
@@ -61,6 +61,7 @@ Run tests:
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
pnpm test:registry:consumer
|
||||
pnpm test:e2e:smoke
|
||||
```
|
||||
|
||||
@@ -111,6 +112,8 @@ copy selected items into their own codebase.
|
||||
- Registry metadata lives in `registry/index.json` and is generated by `pnpm registry:build`.
|
||||
- The installer copies components into `src/cadence-ui`, adds missing package dependencies,
|
||||
and writes `src/cadence-ui/.install-manifest.json` so upgrades can reuse the same item set.
|
||||
- `pnpm test:registry:consumer` creates a temporary consumer app, runs the installer, and
|
||||
verifies the copied source typechecks and builds.
|
||||
- Install and upgrade instructions live in [docs/registry.md](/Users/xd/project/cadence-ui/docs/registry.md).
|
||||
|
||||
## Docs and QA
|
||||
|
||||
@@ -37,6 +37,15 @@ pnpm registry:check
|
||||
The pull request workflow now runs `pnpm registry:check`, and the release version PR
|
||||
workflow refreshes the registry automatically via `pnpm release:version`.
|
||||
|
||||
To validate the end-to-end consumer flow locally:
|
||||
|
||||
```bash
|
||||
pnpm test:registry:consumer
|
||||
```
|
||||
|
||||
That smoke test creates a temporary app, runs the registry installer, installs the
|
||||
required dependencies, and verifies that the copied source both typechecks and builds.
|
||||
|
||||
## Consumer install flow
|
||||
|
||||
### 1. Pin the Cadence UI source
|
||||
|
||||
@@ -78,6 +78,7 @@ Use the docs and smoke checks when the change touches behavior-heavy UI:
|
||||
|
||||
```bash
|
||||
pnpm build:docs
|
||||
pnpm test:registry:consumer
|
||||
pnpm test:e2e:smoke
|
||||
```
|
||||
|
||||
@@ -166,6 +167,15 @@ pnpm registry:check
|
||||
|
||||
This prevents registry metadata drift from merging unnoticed.
|
||||
|
||||
The repo also runs:
|
||||
|
||||
```bash
|
||||
pnpm test:registry:consumer
|
||||
```
|
||||
|
||||
This verifies that the committed registry snapshot can still install into a fresh
|
||||
consumer app, typecheck, and build.
|
||||
|
||||
### Version PR workflow
|
||||
|
||||
The `Release Version PR` workflow runs on pushes to `main` and on manual dispatch. It:
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"test": "pnpm --filter @ai-ui/ui test",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:smoke": "playwright test tests/e2e/storybook-smoke.spec.ts",
|
||||
"test:registry:consumer": "node ./tests/registry/consumer-smoke.mjs",
|
||||
"test:watch": "pnpm --filter @ai-ui/ui test:watch",
|
||||
"typecheck": "pnpm -r typecheck"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const registryInstallerPath = path.join(repoRoot, "scripts", "registry-install.mjs");
|
||||
const tscBinPath = path.join(repoRoot, "node_modules", "typescript", "bin", "tsc");
|
||||
const viteBinPath = path.join(repoRoot, "node_modules", "vite", "bin", "vite.js");
|
||||
const tempPrefix = path.join(os.tmpdir(), "cadence-ui-registry-consumer-");
|
||||
const keepArtifacts = process.env.CADENCE_KEEP_REGISTRY_SMOKE === "1";
|
||||
const pnpmCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
||||
|
||||
const registryItems = ["button", "dialog", "input", "form"];
|
||||
|
||||
const packageJson = {
|
||||
name: "cadence-ui-registry-consumer-smoke",
|
||||
private: true,
|
||||
type: "module",
|
||||
dependencies: {
|
||||
react: "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
devDependencies: {
|
||||
"@types/react": "^18.3.28",
|
||||
"@types/react-dom": "^18.3.7"
|
||||
}
|
||||
};
|
||||
|
||||
const tsconfig = {
|
||||
compilerOptions: {
|
||||
target: "ES2022",
|
||||
module: "ESNext",
|
||||
moduleResolution: "Bundler",
|
||||
jsx: "react-jsx",
|
||||
strict: true,
|
||||
esModuleInterop: true,
|
||||
skipLibCheck: true,
|
||||
noEmit: true,
|
||||
types: ["react", "react-dom"]
|
||||
},
|
||||
include: ["src"]
|
||||
};
|
||||
|
||||
const indexHtml = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Cadence UI Registry Smoke</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const mainSource = `import { useForm } from "react-hook-form";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import "./cadence-ui/tokens/styles.css";
|
||||
import { Button } from "./cadence-ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from "./cadence-ui/components/dialog";
|
||||
import { Form, FormControl, FormItem, FormLabel, FormMessage } from "./cadence-ui/components/form";
|
||||
import { Input } from "./cadence-ui/components/input";
|
||||
|
||||
type FormValues = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
function App() {
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
email: "team@cadence.dev"
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem", padding: "2rem" }}>
|
||||
<Form {...form}>
|
||||
<FormItem name="email">
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Email" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</Form>
|
||||
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button>Open registry smoke dialog</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogTitle>Registry smoke</DialogTitle>
|
||||
<DialogDescription>
|
||||
Verifies copied components typecheck and build in a consumer app.
|
||||
</DialogDescription>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
if (!root) {
|
||||
throw new Error("Missing root element.");
|
||||
}
|
||||
|
||||
createRoot(root).render(<App />);
|
||||
`;
|
||||
|
||||
function formatCommand(command, args) {
|
||||
return [command, ...args].join(" ");
|
||||
}
|
||||
|
||||
async function run(command, args, options = {}) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd ?? repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
...(options.env ?? {})
|
||||
},
|
||||
stdio: "inherit"
|
||||
});
|
||||
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(
|
||||
new Error(
|
||||
`${formatCommand(command, args)} failed with code ${code ?? "null"} and signal ${
|
||||
signal ?? "null"
|
||||
}.`
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function writeConsumerFixture(projectDir) {
|
||||
await fs.mkdir(path.join(projectDir, "src"), { recursive: true });
|
||||
await fs.writeFile(path.join(projectDir, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
await fs.writeFile(path.join(projectDir, "tsconfig.json"), `${JSON.stringify(tsconfig, null, 2)}\n`);
|
||||
await fs.writeFile(path.join(projectDir, "index.html"), indexHtml);
|
||||
await fs.writeFile(path.join(projectDir, "src", "global.d.ts"), 'declare module "*.css";\n');
|
||||
await fs.writeFile(path.join(projectDir, "src", "main.tsx"), mainSource);
|
||||
}
|
||||
|
||||
async function assertFileExists(projectDir, relativePath) {
|
||||
const absolutePath = path.join(projectDir, ...relativePath.split("/"));
|
||||
|
||||
try {
|
||||
await fs.stat(absolutePath);
|
||||
} catch {
|
||||
throw new Error(`Expected ${relativePath} to exist in the consumer fixture.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyManifest(projectDir) {
|
||||
const manifestPath = path.join(projectDir, "src", "cadence-ui", ".install-manifest.json");
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
|
||||
|
||||
for (const item of registryItems) {
|
||||
if (!manifest.items.includes(item)) {
|
||||
throw new Error(`Registry manifest is missing "${item}".`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!manifest.items.includes("tokens")) {
|
||||
throw new Error('Registry manifest is missing the required "tokens" item.');
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const projectDir = await fs.mkdtemp(tempPrefix);
|
||||
let succeeded = false;
|
||||
const cleanup = async () => {
|
||||
if (!succeeded || keepArtifacts) {
|
||||
console.log(`Keeping registry smoke fixture at ${projectDir}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.rm(projectDir, { force: true, recursive: true });
|
||||
};
|
||||
|
||||
try {
|
||||
console.log(`Creating registry consumer fixture in ${projectDir}`);
|
||||
await writeConsumerFixture(projectDir);
|
||||
|
||||
console.log("Installing registry items into the consumer fixture");
|
||||
await run(process.execPath, [registryInstallerPath, "--project", projectDir, ...registryItems]);
|
||||
await assertFileExists(projectDir, "src/cadence-ui/components/button.tsx");
|
||||
await assertFileExists(projectDir, "src/cadence-ui/components/dialog.tsx");
|
||||
await assertFileExists(projectDir, "src/cadence-ui/components/form.tsx");
|
||||
await assertFileExists(projectDir, "src/cadence-ui/tokens/styles.css");
|
||||
await verifyManifest(projectDir);
|
||||
|
||||
console.log("Installing consumer dependencies");
|
||||
await run(pnpmCommand, ["install", "--ignore-workspace"], {
|
||||
cwd: projectDir,
|
||||
env: {
|
||||
CI: "1"
|
||||
}
|
||||
});
|
||||
|
||||
console.log("Re-running registry install from the saved manifest");
|
||||
await run(process.execPath, [registryInstallerPath, "--project", projectDir, "--dry-run"]);
|
||||
|
||||
console.log("Typechecking the consumer fixture");
|
||||
await run(process.execPath, [tscBinPath, "-p", path.join(projectDir, "tsconfig.json"), "--noEmit"]);
|
||||
|
||||
console.log("Building the consumer fixture");
|
||||
await run(process.execPath, [viteBinPath, "build"], {
|
||||
cwd: projectDir
|
||||
});
|
||||
|
||||
succeeded = true;
|
||||
console.log("Registry consumer smoke test passed.");
|
||||
} catch (error) {
|
||||
console.error(`Registry consumer smoke test failed. Fixture preserved at ${projectDir}`);
|
||||
throw error;
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
Reference in New Issue
Block a user