/** * 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(/ 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(/ 8); const minRich = domain === "note" ? 1 : 2; assert.ok(richPages.length >= minRich, `${domain}: expected >= ${minRich} rich pages, got ${richPages.length}`); }); } });