Files
16gagent/test/frontend-builder-agent.test.mjs
2026-06-06 10:40:48 +08:00

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}`);
});
}
});