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

465 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 installworkspace 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`);
});
}
});