465 lines
18 KiB
JavaScript
465 lines
18 KiB
JavaScript
/**
|
||
* 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`);
|
||
});
|
||
}
|
||
});
|