374 lines
14 KiB
JavaScript
374 lines
14 KiB
JavaScript
/**
|
|
* Frontend Builder Agent Test — SF-03
|
|
*/
|
|
|
|
import { describe, it, before } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { existsSync, rmSync, readFileSync } from "node:fs";
|
|
|
|
const __dirname = resolve(fileURLToPath(new URL(".", import.meta.url)));
|
|
const WORKSPACE = resolve(__dirname, "..");
|
|
|
|
let buildFrontend, loadJSON, writeFrontend;
|
|
|
|
before(async () => {
|
|
const mod = await import("../scripts/frontend-builder-agent.mjs");
|
|
buildFrontend = mod.buildFrontend;
|
|
loadJSON = mod.loadJSON;
|
|
writeFrontend = mod.writeFrontend;
|
|
});
|
|
|
|
function loadPRD(name) {
|
|
return JSON.parse(readFileSync(resolve(WORKSPACE, `test/fixtures/architecture-agent/${name}-prd.json`), "utf-8"));
|
|
}
|
|
function loadArch(name) {
|
|
return JSON.parse(readFileSync(resolve(WORKSPACE, `architecture-example.json`), "utf-8"));
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════
|
|
describe("1 — 宠物管理前端生成", () => {
|
|
it("生成完整项目结构", () => {
|
|
const prd = loadPRD("ecommerce");
|
|
const arch = loadArch("pet");
|
|
const result = buildFrontend(prd, arch);
|
|
|
|
assert.ok(!result.error);
|
|
assert.ok(result.stats.totalFiles >= 20, `Expected >= 20 files, got ${result.stats.totalFiles}`);
|
|
assert.ok(result.stats.pages >= 4);
|
|
assert.ok(result.stats.components >= 6);
|
|
assert.ok(result.stats.hooks >= 2);
|
|
assert.ok(result.stats.services >= 2);
|
|
});
|
|
|
|
it("所有页面文件非空", () => {
|
|
const prd = loadPRD("ecommerce");
|
|
const arch = loadArch("pet");
|
|
const result = buildFrontend(prd, arch);
|
|
|
|
for (const pf of result.stats.pageFiles) {
|
|
const content = result.files[pf];
|
|
assert.ok(content, `Missing file: ${pf}`);
|
|
assert.ok(content.length > 100, `${pf}: content too short (${content.length} chars)`);
|
|
assert.ok(content.includes("export default"), `${pf}: missing component export`);
|
|
}
|
|
});
|
|
|
|
it("页面包含有意义的 JSX", () => {
|
|
const prd = loadPRD("ecommerce");
|
|
const arch = loadArch("pet");
|
|
const result = buildFrontend(prd, arch);
|
|
|
|
// Each page must have at least a heading, a Button or real elements
|
|
for (const pf of result.stats.pageFiles) {
|
|
const c = result.files[pf];
|
|
// Should contain JSX elements, not just "TODO" or "stub"
|
|
const jsxCount = (c.match(/</g) || []).length;
|
|
const minJSX = pf.includes("order") || pf.includes("tag") || pf.includes("settings") ? 3 : 5;
|
|
assert.ok(jsxCount > minJSX, `${pf}: too few JSX elements (${jsxCount}, min ${minJSX})`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════
|
|
describe("2 — TypeScript 类型生成", () => {
|
|
it("生成类型文件", () => {
|
|
const prd = loadPRD("ecommerce");
|
|
const arch = loadArch("pet");
|
|
const result = buildFrontend(prd, arch);
|
|
|
|
assert.ok(result.files["types/index.ts"]);
|
|
const types = result.files["types/index.ts"];
|
|
assert.ok(types.includes("export interface"));
|
|
assert.ok(types.includes("User"));
|
|
});
|
|
|
|
it("电商域含 Product/Order 类型", () => {
|
|
const prd = loadPRD("ecommerce");
|
|
const arch = loadArch("pet");
|
|
const result = buildFrontend(prd, arch);
|
|
const types = result.files["types/index.ts"];
|
|
|
|
assert.ok(types.includes("Product"));
|
|
assert.ok(types.includes("Order"));
|
|
assert.ok(types.includes("Logistics"));
|
|
});
|
|
|
|
it("企业域含 Approval/Attendance 类型", () => {
|
|
const prd = loadPRD("enterprise");
|
|
const arch = loadArch("pet");
|
|
const result = buildFrontend(prd, arch);
|
|
const types = result.files["types/index.ts"];
|
|
|
|
assert.ok(types.includes("Approval"));
|
|
assert.ok(types.includes("Attendance"));
|
|
assert.ok(types.includes("Department"));
|
|
});
|
|
|
|
it("API 响应类型存在", () => {
|
|
const prd = loadPRD("note");
|
|
const arch = loadArch("pet");
|
|
const result = buildFrontend(prd, arch);
|
|
const types = result.files["types/index.ts"];
|
|
|
|
assert.ok(types.includes("ApiResponse"));
|
|
assert.ok(types.includes("PaginatedResponse"));
|
|
assert.ok(types.includes("ApiError"));
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════
|
|
describe("3 — 组件生成", () => {
|
|
it("生成所有共享 UI 组件", () => {
|
|
const prd = loadPRD("ecommerce");
|
|
const arch = loadArch("pet");
|
|
const result = buildFrontend(prd, arch);
|
|
|
|
const uiComponents = ["Button", "Card", "Input", "Modal", "EmptyState"];
|
|
for (const name of uiComponents) {
|
|
assert.ok(result.files[`components/ui/${name}.tsx`], `Missing UI component: ${name}`);
|
|
assert.ok(result.files[`components/ui/${name}.tsx`].includes("export function"), `${name}: missing export`);
|
|
}
|
|
});
|
|
|
|
it("域名匹配的领域组件被生成", () => {
|
|
// ecommerce PRD → ecommerce components
|
|
const prd = loadPRD("ecommerce");
|
|
const arch = loadArch("pet");
|
|
const result = buildFrontend(prd, arch);
|
|
assert.ok(result.files["components/ecommerce/ProductCard.tsx"]);
|
|
});
|
|
|
|
it("Layout 组件包含 Sidebar 和 BottomNav", () => {
|
|
const prd = loadPRD("note");
|
|
const arch = loadArch("pet");
|
|
const result = buildFrontend(prd, arch);
|
|
|
|
assert.ok(result.files["components/layout/Sidebar.tsx"]);
|
|
assert.ok(result.files["components/layout/BottomNav.tsx"]);
|
|
const sidebar = result.files["components/layout/Sidebar.tsx"];
|
|
assert.ok(sidebar.includes("usePathname"));
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════
|
|
describe("4 — API Services", () => {
|
|
it("生成 API 客户端", () => {
|
|
const prd = loadPRD("ecommerce");
|
|
const arch = loadArch("pet");
|
|
const result = buildFrontend(prd, arch);
|
|
|
|
assert.ok(result.files["services/api.ts"]);
|
|
const apiClient = result.files["services/api.ts"];
|
|
assert.ok(apiClient.includes("get:") || apiClient.includes("api.get"), "api client missing get method");
|
|
assert.ok(apiClient.includes("post:") || apiClient.includes("api.post"), "api client missing post method");
|
|
assert.ok(apiClient.includes("put:") || apiClient.includes("api.put"), "api client missing put method");
|
|
assert.ok(apiClient.includes("delete:") || apiClient.includes("api.delete"), "api client missing delete method");
|
|
});
|
|
|
|
it("按资源生成 service 文件", () => {
|
|
const prd = loadPRD("ecommerce");
|
|
const arch = loadArch("pet");
|
|
const result = buildFrontend(prd, arch);
|
|
|
|
// PetCare arch has: albums, daily-logs, hospitals, pets, schedules
|
|
assert.ok(result.files["services/pets.ts"]);
|
|
assert.ok(result.files["services/schedules.ts"]);
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════
|
|
describe("5 — Custom Hooks", () => {
|
|
it("生成通用 hooks", () => {
|
|
const prd = loadPRD("ecommerce");
|
|
const arch = loadArch("pet");
|
|
const result = buildFrontend(prd, arch);
|
|
|
|
assert.ok(result.files["hooks/useForm.ts"]);
|
|
assert.ok(result.files["hooks/useDebounce.ts"]);
|
|
});
|
|
|
|
it("按资源生成数据 hooks", () => {
|
|
const prd = loadPRD("ecommerce");
|
|
const arch = loadArch("pet");
|
|
const result = buildFrontend(prd, arch);
|
|
|
|
assert.ok(result.files["hooks/usePets.ts"]);
|
|
assert.ok(result.files["hooks/useSchedules.ts"]);
|
|
|
|
const usePets = result.files["hooks/usePets.ts"];
|
|
assert.ok(usePets.includes("useState"));
|
|
assert.ok(usePets.includes("useEffect"));
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════
|
|
describe("6 — 项目配置", () => {
|
|
it("生成 package.json", () => {
|
|
const prd = loadPRD("note");
|
|
const arch = loadArch("pet");
|
|
const result = buildFrontend(prd, arch);
|
|
|
|
assert.ok(result.files["package.json"]);
|
|
const pkg = JSON.parse(result.files["package.json"]);
|
|
assert.ok(pkg.dependencies.next);
|
|
assert.ok(pkg.dependencies.react);
|
|
assert.ok(pkg.devDependencies.typescript);
|
|
});
|
|
|
|
it("生成 tsconfig.json", () => {
|
|
const prd = loadPRD("note");
|
|
const arch = loadArch("pet");
|
|
const result = buildFrontend(prd, arch);
|
|
|
|
const tsconfig = JSON.parse(result.files["tsconfig.json"]);
|
|
assert.equal(tsconfig.compilerOptions.strict, true);
|
|
assert.ok(tsconfig.compilerOptions.paths["@/*"]);
|
|
});
|
|
|
|
it("生成 tailwind config", () => {
|
|
const prd = loadPRD("note");
|
|
const arch = loadArch("pet");
|
|
const result = buildFrontend(prd, arch);
|
|
|
|
assert.ok(result.files["tailwind.config.ts"]);
|
|
assert.ok(result.files["postcss.config.js"]);
|
|
assert.ok(result.files["app/globals.css"]);
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════
|
|
describe("7 — 布局和路由", () => {
|
|
it("Root layout 包含导航项", () => {
|
|
const prd = loadPRD("ecommerce");
|
|
const arch = loadArch("pet");
|
|
const result = buildFrontend(prd, arch);
|
|
|
|
const layout = result.files["app/layout.tsx"];
|
|
assert.ok(layout.includes("Sidebar"));
|
|
assert.ok(layout.includes("BottomNav"));
|
|
assert.ok(layout.includes("children"));
|
|
});
|
|
|
|
it("每个页面有独立 route 文件", () => {
|
|
const prd = loadPRD("ecommerce");
|
|
const arch = loadArch("pet");
|
|
const result = buildFrontend(prd, arch);
|
|
|
|
// Should have one page per route
|
|
const routes = result.stats.pageFiles.map(f => f.replace("app/", "/").replace("/page.tsx", ""));
|
|
assert.ok(routes.length >= 4);
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════
|
|
describe("8 — 错误处理", () => {
|
|
it("invalid PRD 返回错误", () => {
|
|
const result = buildFrontend({ error: "test" }, {});
|
|
assert.equal(result.error, "INVALID_PRD");
|
|
});
|
|
|
|
it("invalid Arch 返回错误", () => {
|
|
const prd = loadPRD("note");
|
|
const result = buildFrontend(prd, { error: "test" });
|
|
assert.equal(result.error, "INVALID_ARCH");
|
|
});
|
|
|
|
it("null 参数返回错误", () => {
|
|
const result = buildFrontend(null, {});
|
|
assert.equal(result.error, "INVALID_PRD");
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════
|
|
describe("9 — File I/O", () => {
|
|
it("writeFrontend 写入文件系统", () => {
|
|
const prd = loadPRD("note");
|
|
const arch = loadArch("pet");
|
|
const result = buildFrontend(prd, arch);
|
|
const tmpDir = resolve(WORKSPACE, ".tmp-frontend-test");
|
|
|
|
writeFrontend(result, tmpDir);
|
|
|
|
assert.ok(existsSync(resolve(tmpDir, "package.json")));
|
|
assert.ok(existsSync(resolve(tmpDir, "app/layout.tsx")));
|
|
assert.ok(existsSync(resolve(tmpDir, "types/index.ts")));
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("loadJSON 读取文件", () => {
|
|
const { data } = loadJSON(resolve(WORKSPACE, "prd-example.json"));
|
|
assert.ok(data.projectName);
|
|
});
|
|
|
|
it("loadJSON 不存在的文件返回 error", () => {
|
|
const { data, error } = loadJSON("/nonexistent.json");
|
|
assert.equal(data, null);
|
|
assert.ok(error);
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════
|
|
describe("10 — 全管道集成", () => {
|
|
it("--prd + --arch CLI 模式", async () => {
|
|
const { execSync } = await import("node:child_process");
|
|
const tmpDir = resolve(WORKSPACE, ".tmp-cli-frontend");
|
|
|
|
const result = execSync(
|
|
`node scripts/frontend-builder-agent.mjs --prd prd-example.json --arch architecture-example.json --output ${tmpDir}`,
|
|
{ cwd: WORKSPACE, encoding: "utf-8" }
|
|
);
|
|
const parsed = JSON.parse(result);
|
|
assert.equal(parsed.projectName, "PetCare");
|
|
assert.ok(parsed.stats.totalFiles > 20);
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("--input-text CLI 模式", async () => {
|
|
const { execSync } = await import("node:child_process");
|
|
const tmpDir = resolve(WORKSPACE, ".tmp-cli-frontend2");
|
|
|
|
const result = execSync(
|
|
`node scripts/frontend-builder-agent.mjs --input-text "做一个在线教育平台" --output ${tmpDir}`,
|
|
{ cwd: WORKSPACE, encoding: "utf-8" }
|
|
);
|
|
const parsed = JSON.parse(result);
|
|
assert.equal(parsed.projectName, "EduPlatform");
|
|
assert.ok(parsed.stats.totalFiles > 15);
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("--help 显示帮助", async () => {
|
|
const { execSync } = await import("node:child_process");
|
|
const result = execSync(`node scripts/frontend-builder-agent.mjs --help`, { cwd: WORKSPACE, encoding: "utf-8" });
|
|
assert.ok(result.includes("Frontend Builder"));
|
|
assert.ok(result.includes("Usage"));
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════
|
|
describe("11 — 多领域覆盖", () => {
|
|
const domains = ["ecommerce", "enterprise", "education", "note"];
|
|
|
|
for (const domain of domains) {
|
|
it(`${domain} 域生成有意义页面`, () => {
|
|
const prd = loadPRD(domain);
|
|
const arch = loadArch("pet"); // Any valid arch works
|
|
const result = buildFrontend(prd, arch);
|
|
|
|
assert.ok(!result.error);
|
|
assert.ok(result.stats.pages >= 3, `${domain}: expected >= 3 pages, got ${result.stats.pages}`);
|
|
|
|
// Verify at least one page has rich content
|
|
const pageContents = result.stats.pageFiles.map(pf => result.files[pf]);
|
|
const richPages = pageContents.filter(c => (c.match(/</g) || []).length > 8);
|
|
const minRich = domain === "note" ? 1 : 2;
|
|
assert.ok(richPages.length >= minRich, `${domain}: expected >= ${minRich} rich pages, got ${richPages.length}`);
|
|
});
|
|
}
|
|
});
|