import { expect, test } from "@playwright/test"; import { readFileSync, existsSync } from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import vm from "node:vm"; import { randomUUID, webcrypto } from "node:crypto"; import ts from "typescript"; const root = path.resolve(__dirname, "../.."); const id = randomUUID(); const owner = randomUUID(); function loadRuntime(mocks: Record = {}, globals: Record = {}) { const modules = new Map(); const context = vm.createContext({ console, Buffer, URL, Response, Request, Headers, DOMException, AbortController, AbortSignal, crypto: webcrypto, setTimeout, clearTimeout, process: { env: {} }, fetch: () => { throw new Error("Unexpected network request"); }, ...globals, }); const load = (relative: string): any => { let filename = path.resolve(root, relative); if (!existsSync(filename)) filename = path.join(filename.replace(/\.ts$/, ""), "index.ts"); if (modules.has(filename)) return modules.get(filename).exports; const module = { exports: {} }; modules.set(filename, module); const nativeRequire = createRequire(filename); const requireMock = (name: string) => { if (name === "server-only") return {}; if (Object.hasOwn(mocks, name)) return mocks[name]; if (name.startsWith("@/")) return load(`${name.slice(2)}.ts`); if (name.startsWith(".")) return load(path.relative(root, path.resolve(path.dirname(filename), `${name}.ts`))); return nativeRequire(name); }; const source = ts.transpileModule(readFileSync(filename, "utf8"), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true }, }).outputText; context.module = module; context.exports = module.exports; context.require = requireMock; const fn = new vm.Script(`(function(module,exports,require){${source}\n})`, { filename }).runInContext(context); fn(module, module.exports, requireMock); return module.exports; }; return load; } function memoryStorage() { const values = new Map(); return { getItem: (key: string) => values.get(key) || null, setItem: (key: string, value: string) => values.set(key, value), removeItem: (key: string) => values.delete(key) }; } function projectRoute() { const projects = new Map(); let userId: string | null = owner; const db = { insert: () => ({ values: (value: any) => ({ onConflictDoNothing: () => ({ returning: async () => { const row = { ...value, id: value.id || randomUUID(), createdAt: new Date(), updatedAt: new Date() }; if (projects.has(row.id)) return []; projects.set(row.id, row); return [row]; } }) }) }), select: () => ({ from: () => ({ where: (conditions: any[]) => ({ limit: async () => [...projects.values()].filter((row) => conditions.every(([key, value]) => row[key] === value)) }) }) }), }; const load = loadRuntime({ "@/lib/db": { db }, "@/lib/db/schema": { canvasProject: { id: "id", userId: "userId", workspaceType: "workspaceType" } }, "@/app/(auth)/auth": { auth: async () => userId ? { user: { id: userId } } : null }, "drizzle-orm": { eq: (key: string, value: unknown) => [key, value], and: (...parts: unknown[]) => parts, desc: (value: unknown) => value }, }); return { route: load("app/api/agent-projects/route.ts"), projects, setUser: (value: string | null) => { userId = value; } }; } test("duplicate POSTs return one project without overwriting saved state", async () => { const { route, projects } = projectRoute(); const post = (title: string) => route.POST(new Request("http://localhost/api/agent-projects", { method: "POST", body: JSON.stringify({ id, title }) })); const responses = await Promise.all([post("原项目"), post("重试标题")]); expect(projects.size).toBe(1); for (const response of responses) { expect(response.status).toBe(200); expect((await response.json()).data).toMatchObject({ id, title: "原项目", userId: owner }); } projects.get(id).title = "已编辑项目"; expect((await (await post("迟到的重试")).json()).data.title).toBe("已编辑项目"); }); test("creation retains account ownership and accepts existing clients", async () => { const { route, projects, setUser } = projectRoute(); const post = (body: unknown) => route.POST(new Request("http://localhost/api/agent-projects", { method: "POST", body: JSON.stringify(body) })); expect((await post({ id })).status).toBe(200); setUser(randomUUID()); const conflict = await post({ id }); expect(conflict.status).toBe(409); expect((await conflict.json()).data).toBeUndefined(); expect(projects.size).toBe(1); expect((await post({ title: "旧客户端" })).status).toBe(200); expect((await post({ id: "invalid" })).status).toBe(400); setUser(null); expect((await post({ id: randomUUID() })).status).toBe(401); }); test("a lost create response recovers the inserted project", async () => { let project: any; let posts = 0; const fetchMock = async (_url: string, init?: RequestInit) => { if (init?.method === "POST") { posts++; project = JSON.parse(String(init.body)); throw new DOMException("Aborted", "AbortError"); } return Response.json({ success: true, data: project }); }; const { agentProjectService } = loadRuntime({}, { fetch: fetchMock, sessionStorage: memoryStorage() })("services/agent-projects/index.ts"); const result = await agentProjectService.createProject({ title: "新项目" }, { creationKey: "blank:test" }); expect(result.success).toBe(true); expect(result.data.id).toBe(project.id); expect(posts).toBe(1); }); test("unconfirmed creation survives reload and retries with the same project ID", async () => { const storage = memoryStorage(); const ids: string[] = []; let offline = true; const fetchMock = async (_url: string, init?: RequestInit) => { if (init?.method === "POST") { const body = JSON.parse(String(init.body)); ids.push(body.id); if (!offline) return Response.json({ success: true, data: body }); throw new TypeError("Connection closed"); } return Response.json({ success: false }, { status: 404 }); }; const service = () => loadRuntime({}, { fetch: fetchMock, sessionStorage: storage })("services/agent-projects/index.ts").agentProjectService; const first = await service().createProject({ title: "09:18" }, { creationKey: "blank:test" }); expect(first.error).toContain("尚未确认"); offline = false; const reloaded = service(); expect((await reloaded.createProject({ title: "09:21" }, { creationKey: "blank:test" })).success).toBe(true); expect(ids[1]).toBe(ids[0]); await reloaded.createProject({ title: "新的独立项目" }, { creationKey: "blank:test" }); expect(ids[2]).not.toBe(ids[0]); }); test("simultaneous clicks share one create request", async () => { let posts = 0; let finish!: () => void; const wait = new Promise((resolve) => { finish = resolve; }); const service = loadRuntime({}, { fetch: async (_url: string, init: RequestInit) => { posts++; await wait; return Response.json({ success: true, data: JSON.parse(String(init.body)) }); } })("services/agent-projects/index.ts").agentProjectService; const first = service.createProject({}, { creationKey: "blank:double" }); const second = service.createProject({}, { creationKey: "blank:double" }); finish(); const results = await Promise.all([first, second]); expect(posts).toBe(1); expect(results[0].data.id).toBe(results[1].data.id); }); test("conditional settings requests reuse the full config after 304", async () => { const headers: Headers[] = []; const config = { steps: [{ key: "style", promptFragment: "保留提示词" }] }; const { fetchCanvasSiteSettings } = loadRuntime({}, { fetch: async (_url: string, init: RequestInit) => { headers.push(new Headers(init.headers)); return headers.length === 1 ? Response.json(config, { headers: { ETag: 'W/"revision-1"' } }) : new Response(null, { status: 304 }); } })("lib/client/canvas-site-settings.ts"); const first = await fetchCanvasSiteSettings("/api/site-settings?scope=agent-canvas"); const second = await fetchCanvasSiteSettings("/api/site-settings?scope=agent-canvas"); expect(second).toBe(first); expect(second).toEqual(config); expect(headers[1].get("If-None-Match")).toBe('W/"revision-1"'); }); test("settings timeout also aborts a stalled response body", async () => { const { fetchCanvasSiteSettings } = loadRuntime({}, { setTimeout: (callback: () => void) => setTimeout(callback, 5), fetch: async (_url: string, init: RequestInit) => ({ ok: true, status: 200, json: () => new Promise((_, reject) => { init.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError"))); }) }), })("lib/client/canvas-site-settings.ts"); await expect(fetchCanvasSiteSettings("/api/site-settings?scope=agent-canvas")).rejects.toHaveProperty("name", "AbortError"); }); test("entry config stays small and unchanged config returns 304 before loading values", async () => { let revision = new Date("2026-11-04T00:00:00Z"); let fullReads = 0; const tables = { siteSettings: { id: "siteId", updatedAt: "siteUpdatedAt" }, systemSettings: { key: "key", updatedAt: "updatedAt" }, aiProvider: { id: "providerId", updatedAt: "providerUpdatedAt", isEnabled: "enabled", type: "type" } }; const ui = { steps: [{ key: "style", title: "风格", enabled: true, sortOrder: 0, options: [{ key: "test", buttonName: "风格一", promptFragment: "生成提示词".repeat(3000), enabled: true, sortOrder: 0 }] }] }; const db = { select: (columns: unknown) => ({ from: (table: unknown) => { const result = () => { if (table === tables.aiProvider) return []; if (table === tables.siteSettings) return [{ id: "site", siteName: "由壹点AI", updatedAt: revision }]; if (!columns) fullReads++; return [{ key: "agent_workflow_ui_config", value: ui, updatedAt: revision }]; }; const query: any = Object.assign(Promise.resolve(result()), { where: () => query, limit: () => query, }); return query; } }) }; const route = loadRuntime({ "@/lib/db": { db }, "@/lib/db/schema": tables, "@/app/(auth)/auth": { auth: async () => null }, })("app/api/site-settings/route.ts"); const request = (scope: string, etag?: string) => { const req = new Request(`http://localhost/api/site-settings?scope=${scope}`, { headers: etag ? { "If-None-Match": etag } : {} }); return Object.assign(req, { nextUrl: new URL(req.url) }); }; const entry = await route.GET(request("agent-entry")); expect(entry.status).toBe(200); const entryData = await entry.json(); expect(entryData.agentWorkflowUiConfig.steps).toEqual([]); expect(JSON.stringify(entryData).length).toBeLessThan(20_000); const full = await route.GET(request("agent-canvas")); expect((await full.json()).agentWorkflowUiConfig.steps[0].options[0].promptFragment).toBe("生成提示词".repeat(3000).slice(0, 20_000)); const etag = full.headers.get("ETag"); const reads = fullReads; const unchanged = await route.GET(request("agent-canvas", etag)); expect(unchanged.status).toBe(304); expect(await unchanged.text()).toBe(""); expect(fullReads).toBe(reads); revision = new Date("2026-11-04T00:01:00Z"); const changed = await route.GET(request("agent-canvas", etag)); expect(changed.status).toBe(200); expect(changed.headers.get("ETag")).not.toBe(etag); });