/** * Fullstack Composer Agent Test — SF-05 */ import { describe, it, before, after } 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 composeFullstack, loadJSON, writeFullstack; before(async () => { const mod = await import("../scripts/fullstack-composer-agent.mjs"); composeFullstack = mod.composeFullstack; loadJSON = mod.loadJSON; writeFullstack = mod.writeFullstack; }); function loadPRD(name) { return JSON.parse(readFileSync(resolve(WORKSPACE, `test/fixtures/architecture-agent/${name}-prd.json`), "utf-8")); } function loadArch() { return JSON.parse(readFileSync(resolve(WORKSPACE, "architecture-example.json"), "utf-8")); } // Demo RequirementPackage const DEMO_PRD = { projectName: "PetCare", chineseName: "宠物管理", domain: "pet", summary: "一款宠物健康管理应用", features: [ { name: "宠物档案", priority: "P0" }, { name: "健康日程", priority: "P0" }, ], userStories: [{ id: "US-001", as: "宠物主人", want: "记录宠物", priority: "P0" }], personas: [{ name: "宠物主人" }], pages: [ { name: "首页", route: "/home" }, { name: "宠物详情", route: "/pet/:id" }, ], }; const DEMO_ARCH = { projectName: "PetCare", databaseSchema: [ { table: "pets", description: "宠物档案", fields: [ { name: "id", type: "TEXT", constraints: "PK" }, { name: "owner_id", type: "TEXT", constraints: "NOT NULL, FK → users.id" }, { name: "name", type: "TEXT", constraints: "NOT NULL" }, { name: "species", type: "TEXT" }, { name: "created_at", type: "TEXT", constraints: "DEFAULT (datetime('now'))" }, ], }, { table: "schedules", description: "健康日程", fields: [ { name: "id", type: "TEXT", constraints: "PK" }, { name: "pet_id", type: "TEXT", constraints: "NOT NULL, FK → pets.id" }, { name: "title", type: "TEXT", constraints: "NOT NULL" }, ], }, ], apiDesign: [ { resource: "pets", basePath: "/api/pets", endpoints: [ { method: "GET", path: "/api/pets" }, { method: "POST", path: "/api/pets" }, { method: "GET", path: "/api/pets/:id" }, { method: "PUT", path: "/api/pets/:id" }, { method: "DELETE", path: "/api/pets/:id" }, ], }, { resource: "schedules", basePath: "/api/schedules", endpoints: [ { method: "GET", path: "/api/schedules" }, { method: "POST", path: "/api/schedules" }, ], }, ], }; // ═══════════════════════════════════════════════════════ describe("1 — 全栈项目目录结构", () => { let result; before(async () => { result = await composeFullstack(DEMO_PRD, DEMO_ARCH); }); it("生成完整项目(无错误)", () => { assert.ok(!result.error, `Unexpected error: ${result.error}`); assert.ok(result.stats.totalFiles >= 30, `Expected >= 30 files, got ${result.stats.totalFiles}`); }); it("apps/web 存在", () => { assert.ok(result.stats.webFiles >= 10, `Expected >= 10 web files, got ${result.stats.webFiles}`); assert.ok(result.files["apps/web/package.json"], "Missing web package.json"); assert.ok(result.files["apps/web/tsconfig.json"], "Missing web tsconfig.json"); }); it("apps/api 存在", () => { assert.ok(result.stats.apiFiles >= 10, `Expected >= 10 api files, got ${result.stats.apiFiles}`); assert.ok(result.files["apps/api/package.json"], "Missing api package.json"); assert.ok(result.files["apps/api/tsconfig.json"], "Missing api tsconfig.json"); assert.ok(result.files["apps/api/src/index.ts"], "Missing api entry point"); assert.ok(result.files["apps/api/src/routes/auth.ts"], "Missing auth routes"); }); it("packages/shared-types 存在", () => { assert.ok(result.files["packages/shared-types/package.json"], "Missing shared-types package.json"); assert.ok(result.files["packages/shared-types/src/index.ts"], "Missing shared-types index"); assert.ok(result.files["packages/shared-types/tsconfig.json"], "Missing shared-types tsconfig"); }); it("packages/shared-config 存在", () => { assert.ok(result.files["packages/shared-config/package.json"], "Missing shared-config package.json"); assert.ok(result.files["packages/shared-config/src/index.ts"], "Missing shared-config index"); }); }); // ═══════════════════════════════════════════════════════ describe("2 — 根目录文件", () => { let result; before(async () => { result = await composeFullstack(DEMO_PRD, DEMO_ARCH); }); it("根 package.json 是 workspace 配置", () => { const pkg = JSON.parse(result.files["package.json"]); assert.ok(pkg.workspaces, "Missing workspaces field"); assert.ok(pkg.workspaces.includes("apps/*"), "Missing apps/* workspace"); assert.ok(pkg.workspaces.includes("packages/*"), "Missing packages/* workspace"); assert.ok(pkg.scripts.dev, "Missing dev script"); assert.ok(pkg.scripts.build, "Missing build script"); assert.ok(pkg.scripts["dev:web"], "Missing dev:web script"); assert.ok(pkg.scripts["dev:api"], "Missing dev:api script"); }); it("根 README.md 存在", () => { assert.ok(result.files["README.md"]); const readme = result.files["README.md"]; assert.ok(readme.includes("Quick Start"), "Missing Quick Start section"); assert.ok(readme.includes("npm install"), "Missing install instructions"); assert.ok(readme.includes("npm run dev"), "Missing dev instructions"); assert.ok(readme.includes("web"), "Missing web reference"); assert.ok(readme.includes("api"), "Missing api reference"); assert.ok(readme.includes("Architecture"), "Missing Architecture section"); }); it(".env.example 存在", () => { assert.ok(result.files[".env.example"]); const env = result.files[".env.example"]; assert.ok(env.includes("JWT_SECRET")); assert.ok(env.includes("DATABASE_URL")); assert.ok(env.includes("NEXT_PUBLIC_API_URL")); assert.ok(env.includes("API_PORT")); }); it(".gitignore 存在", () => { assert.ok(result.files[".gitignore"]); assert.ok(result.files[".gitignore"].includes("node_modules")); }); }); // ═══════════════════════════════════════════════════════ describe("3 — scripts/", () => { let result; before(async () => { result = await composeFullstack(DEMO_PRD, DEMO_ARCH); }); it("dev.mjs 存在", () => { assert.ok(result.files["scripts/dev.mjs"]); const dev = result.files["scripts/dev.mjs"]; assert.ok(dev.includes("spawn"), "Missing spawn call"); assert.ok(dev.includes("apps/api"), "Missing api reference"); assert.ok(dev.includes("apps/web"), "Missing web reference"); assert.ok(dev.includes("SIGINT"), "Missing signal handling"); }); it("build.mjs 存在", () => { assert.ok(result.files["scripts/build.mjs"]); const build = result.files["scripts/build.mjs"]; assert.ok(build.includes("apps/api"), "Missing api reference"); assert.ok(build.includes("apps/web"), "Missing web reference"); assert.ok(build.includes("execSync"), "Missing execSync call"); }); }); // ═══════════════════════════════════════════════════════ describe("4 — shared-types 包", () => { let result; before(async () => { result = await composeFullstack(DEMO_PRD, DEMO_ARCH); }); it("包含实体类型", () => { const types = result.files["packages/shared-types/src/index.ts"]; assert.ok(types.includes("interface User"), "Missing User type"); assert.ok(types.includes("interface Pet"), "Missing Pet type"); assert.ok(types.includes("interface Schedule"), "Missing Schedule type"); }); it("包含 API 响应包装类型", () => { const types = result.files["packages/shared-types/src/index.ts"]; assert.ok(types.includes("ApiResponse"), "Missing ApiResponse"); assert.ok(types.includes("PaginatedResponse"), "Missing PaginatedResponse"); assert.ok(types.includes("ErrorResponse"), "Missing ErrorResponse"); }); it("package.json 正确命名", () => { const pkg = JSON.parse(result.files["packages/shared-types/package.json"]); assert.equal(pkg.name, "@shared/types"); }); }); // ═══════════════════════════════════════════════════════ describe("5 — shared-config 包", () => { let result; before(async () => { result = await composeFullstack(DEMO_PRD, DEMO_ARCH); }); it("包含 API 配置", () => { const config = result.files["packages/shared-config/src/index.ts"]; assert.ok(config.includes("API_BASE_URL"), "Missing API_BASE_URL"); assert.ok(config.includes("API_PREFIX"), "Missing API_PREFIX"); assert.ok(config.includes("AUTH_TOKEN_KEY"), "Missing AUTH_TOKEN_KEY"); }); it("package.json 正确命名", () => { const pkg = JSON.parse(result.files["packages/shared-config/package.json"]); assert.equal(pkg.name, "@shared/config"); }); }); // ═══════════════════════════════════════════════════════ describe("6 — 前端适配(monorepo 后处理)", () => { let result; before(async () => { result = await composeFullstack(DEMO_PRD, DEMO_ARCH); }); it("前端 package.json 依赖 @shared/types 和 @shared/config", () => { const pkg = JSON.parse(result.files["apps/web/package.json"]); assert.ok(pkg.dependencies["@shared/types"], "Missing @shared/types dep"); assert.ok(pkg.dependencies["@shared/config"], "Missing @shared/config dep"); }); it("前端 API 服务使用 @shared/config", () => { const apiFile = result.files["apps/web/src/services/api.ts"]; assert.ok(apiFile, "apps/web/src/services/api.ts not found in generated files"); assert.ok(apiFile.includes("@shared/config"), "Missing @shared/config import"); assert.ok(apiFile.includes("API_BASE_URL"), "Missing API_BASE_URL usage"); assert.ok(apiFile.includes("API_PREFIX"), "Missing API_PREFIX usage"); }); it("前端类型文件 re-exports shared types", () => { const types = result.files["apps/web/src/types/index.ts"]; assert.ok(types.includes("@shared/types"), "Missing @shared/types import"); }); }); // ═══════════════════════════════════════════════════════ describe("7 — 后端适配(monorepo 后处理)", () => { let result; before(async () => { result = await composeFullstack(DEMO_PRD, DEMO_ARCH); }); it("后端 package.json 依赖 @shared/types 和 @shared/config", () => { const pkg = JSON.parse(result.files["apps/api/package.json"]); assert.ok(pkg.dependencies["@shared/types"], "Missing @shared/types dep"); assert.ok(pkg.dependencies["@shared/config"], "Missing @shared/config dep"); }); it("后端类型文件 re-exports shared types", () => { const types = result.files["apps/api/src/types/index.ts"]; assert.ok(types.includes("@shared/types"), "Missing @shared/types import"); }); }); // ═══════════════════════════════════════════════════════ describe("8 — 错误处理", () => { it("invalid PRD 返回错误", async () => { const result = await composeFullstack({ error: "test" }, {}); assert.equal(result.error, "INVALID_PRD"); }); it("invalid Architecture 返回错误", async () => { const result = await composeFullstack(DEMO_PRD, { error: "test" }); assert.equal(result.error, "INVALID_ARCH"); }); it("null PRD 返回错误", async () => { const result = await composeFullstack(null, {}); assert.equal(result.error, "INVALID_PRD"); }); it("空的 databaseSchema 仍然生成基础项目", async () => { const result = await composeFullstack( { projectName: "Empty", summary: "test", features: [], pages: [] }, { databaseSchema: [], apiDesign: [] } ); assert.ok(!result.error); assert.ok(result.files["apps/web/package.json"]); assert.ok(result.files["apps/api/package.json"]); assert.ok(result.files["packages/shared-types/package.json"]); }); }); // ═══════════════════════════════════════════════════════ describe("9 — 文件写入 I/O", () => { it("writeFullstack 写入完整目录树", async () => { const result = await composeFullstack(DEMO_PRD, DEMO_ARCH); const tmpDir = resolve(WORKSPACE, ".tmp-fullstack-test"); writeFullstack(result, tmpDir); // Root assert.ok(existsSync(resolve(tmpDir, "package.json"))); assert.ok(existsSync(resolve(tmpDir, "README.md"))); assert.ok(existsSync(resolve(tmpDir, ".env.example"))); assert.ok(existsSync(resolve(tmpDir, "scripts/dev.mjs"))); assert.ok(existsSync(resolve(tmpDir, "scripts/build.mjs"))); // apps/web assert.ok(existsSync(resolve(tmpDir, "apps/web/package.json"))); assert.ok(existsSync(resolve(tmpDir, "apps/web/src/app/layout.tsx"))); assert.ok(existsSync(resolve(tmpDir, "apps/web/src/types/index.ts"))); // apps/api assert.ok(existsSync(resolve(tmpDir, "apps/api/package.json"))); assert.ok(existsSync(resolve(tmpDir, "apps/api/src/index.ts"))); assert.ok(existsSync(resolve(tmpDir, "apps/api/src/routes/auth.ts"))); assert.ok(existsSync(resolve(tmpDir, "apps/api/src/db/schema.ts"))); // packages assert.ok(existsSync(resolve(tmpDir, "packages/shared-types/package.json"))); assert.ok(existsSync(resolve(tmpDir, "packages/shared-types/src/index.ts"))); assert.ok(existsSync(resolve(tmpDir, "packages/shared-config/package.json"))); assert.ok(existsSync(resolve(tmpDir, "packages/shared-config/src/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 — npm install & build", () => { let tmpDir; before(async () => { tmpDir = resolve(WORKSPACE, ".tmp-fullstack-build"); rmSync(tmpDir, { recursive: true, force: true }); const result = await composeFullstack(DEMO_PRD, DEMO_ARCH); writeFullstack(result, tmpDir); }); after(() => { rmSync(tmpDir, { recursive: true, force: true }); }); it("npm install(workspace root 安装,自动链接)", async () => { const { execSync } = await import("node:child_process"); try { // Root-level install sets up workspace symlinks for @shared/types, @shared/config execSync("npm install --no-audit --no-fund", { cwd: tmpDir, encoding: "utf-8", timeout: 300_000, stdio: "pipe", }); // Verify workspace links exist (hoisted to root node_modules) const linked = existsSync(resolve(tmpDir, "node_modules/@shared/types")) || existsSync(resolve(tmpDir, "apps/api/node_modules/@shared/types")); assert.ok(linked, "@shared/types not linked"); } catch (e) { assert.fail(`npm install failed: ${(e.stderr || e.message)?.slice(0, 300)}`); } }); it("api build 通过(tsc --noEmit)", async () => { const { execSync } = await import("node:child_process"); try { execSync("npx tsc --noEmit", { cwd: resolve(tmpDir, "apps/api"), encoding: "utf-8", timeout: 60_000, stdio: "pipe", }); } catch (e) { assert.fail(`api build failed: ${(e.stdout || e.stderr)?.slice(0, 500)}`); } }); it("web 结构有效(目录文件齐全)", () => { assert.ok(existsSync(resolve(tmpDir, "apps/web/package.json"))); assert.ok(existsSync(resolve(tmpDir, "apps/web/src/app/layout.tsx"))); assert.ok(existsSync(resolve(tmpDir, "apps/web/src/types/index.ts"))); }); }); // ═══════════════════════════════════════════════════════ describe("11 — 生成的文件非空且有意义", () => { let result; before(async () => { result = await composeFullstack(DEMO_PRD, DEMO_ARCH); }); it("所有文件内容非空(>30 字符)", () => { for (const [path, content] of Object.entries(result.files)) { assert.ok(content.length > 30, `${path}: too short (${content.length} chars)`); } }); it("所有 package.json 可解析", () => { const pkgFiles = Object.keys(result.files).filter(f => f.endsWith("package.json")); for (const pf of pkgFiles) { const pkg = JSON.parse(result.files[pf]); assert.ok(pkg.name, `${pf}: missing name`); } }); }); // ═══════════════════════════════════════════════════════ describe("12 — 多领域覆盖", () => { const domains = ["ecommerce", "enterprise", "education", "note"]; for (const domain of domains) { it(`${domain} 域生成成功`, async () => { const prd = loadPRD(domain); const result = await composeFullstack(prd, loadArch()); assert.ok(!result.error, `${domain}: ${result.message}`); assert.ok(result.stats.webFiles >= 10, `${domain}: web files too few`); assert.ok(result.stats.apiFiles >= 10, `${domain}: api files too few`); assert.ok(result.files["packages/shared-types/src/index.ts"], `${domain}: shared-types missing`); }); } });