ai-spec-dev 0.36.1 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/RELEASE_LOG.md CHANGED
@@ -5,6 +5,26 @@
5
5
 
6
6
  ---
7
7
 
8
+ ## v0.37.0 — 2026-04-02 — P1 测试覆盖:Mock Server / Types Generator / VCR
9
+
10
+ ### 新增测试(Phase 1 收尾)
11
+
12
+ **Test 7 — `mock-server-generator.test.ts`(28 tests)**
13
+
14
+ 覆盖 `generateMockAssets`(Express server.js 生成、README.md 端点表格、auth 中间件条件生成、DELETE 204 sendStatus、错误模拟注释、自定义端口、自定义输出目录、列表端点分页 fixture、MSW handlers/browser 生成、proxy 配置生成)、前端框架检测(Vite/Next.js/CRA/Webpack)、fixture 启发式(email/boolean/DateTime 字段类型)、`findLatestDslFile`(不存在目录、无匹配文件、最新文件选择、嵌套目录扫描)、`applyMockProxy`/`restoreMockProxy`(Vite 配置写入+dev:mock 脚本注入+还原、CRA proxy 字段注入+还原、Next.js 手动提示、无 lock 文件容错)。
15
+
16
+ **Test 8 — `types-generator.test.ts`(28 tests)**
17
+
18
+ 覆盖类型映射(String→string、Int/Float→number、Boolean→boolean、DateTime→string、Json→Record、数组类型、PascalCase 模型引用保留、未知类型回退 string、nullable 标记剥离)、模型接口渲染(export interface、必填/可选字段、模型描述 JSDoc、字段描述 JSDoc)、端点类型(请求体接口、查询参数可选接口、路径参数接口、includeEndpointTypes 开关、无 schema 端点跳过)、端点常量表(API_ENDPOINTS const、method/path/auth 字段、ApiEndpointKey 类型、includeEndpointMap 开关)、自定义 header、前端组件 Props 接口、`saveTypescriptTypes`(默认路径写入、自定义路径写入)。
19
+
20
+ **Test 9 — `vcr.test.ts`(22 tests)**
21
+
22
+ 覆盖 `VcrRecordingProvider`(透传 generate 调用、元数据记录含 callHash/promptPreview/duration/provider/model、providerName/modelName 代理、无 systemInstruction 时省略字段、promptPreview 截断 200 字符、保存至 .ai-spec-vcr 目录、双 recorder 合并按时间排序+重建索引、多 provider 记录)、`VcrReplayProvider`(按序回放、providerName=vcr-replay、modelName=runId、remaining/consumed 计数、exhausted 抛错、忽略 prompt 内容纯按索引回放)、`loadVcrRecording`(正常加载、不存在返回 null、损坏 JSON 返回 null)、`listVcrRecordings`(不存在目录返回空、逆序排列、跳过损坏文件、忽略非 JSON 文件、summary 字段正确性)。
23
+
24
+ **测试覆盖率提升:37.5% → 45%(15 → 18 个模块有测试,331 → 409 test cases)**
25
+
26
+ ---
27
+
8
28
  ## v0.36.1 — 2026-04-02 — P0 测试覆盖 + 质量硬门禁 + 错误体验优化
9
29
 
10
30
  ### 新增测试(Week 2)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-spec-dev",
3
- "version": "0.36.1",
3
+ "version": "0.37.0",
4
4
  "description": "AI-driven Development Orchestrator SDK & CLI",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -0,0 +1,404 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import * as path from "path";
3
+ import * as fs from "fs-extra";
4
+ import * as os from "os";
5
+ import type { SpecDSL, ApiEndpoint } from "../core/dsl-types";
6
+
7
+ // ─── We need to import the module under test ────────────────────────────────
8
+ // Some functions are not exported — we test via the public API (generateMockAssets)
9
+ // and exported helpers (findLatestDslFile, applyMockProxy, restoreMockProxy).
10
+
11
+ import {
12
+ generateMockAssets,
13
+ findLatestDslFile,
14
+ applyMockProxy,
15
+ restoreMockProxy,
16
+ MockServerOptions,
17
+ } from "../core/mock-server-generator";
18
+
19
+ // ─── Fixtures ────────────────────────────────────────────────────────────────
20
+
21
+ function makeDsl(overrides: Partial<SpecDSL> = {}): SpecDSL {
22
+ return {
23
+ version: "1.0",
24
+ feature: { id: "user-crud", title: "User CRUD", description: "Basic user management" },
25
+ models: [
26
+ {
27
+ name: "User",
28
+ fields: [
29
+ { name: "id", type: "String", required: true, unique: true },
30
+ { name: "email", type: "String", required: true },
31
+ { name: "name", type: "String", required: true },
32
+ { name: "age", type: "Int", required: false },
33
+ { name: "isActive", type: "Boolean", required: true },
34
+ { name: "createdAt", type: "DateTime", required: true },
35
+ ],
36
+ },
37
+ ],
38
+ endpoints: [
39
+ {
40
+ id: "EP-001",
41
+ method: "GET",
42
+ path: "/api/users",
43
+ description: "List all users",
44
+ auth: true,
45
+ successStatus: 200,
46
+ successDescription: "Returns list of users",
47
+ },
48
+ {
49
+ id: "EP-002",
50
+ method: "POST",
51
+ path: "/api/users",
52
+ description: "Create a new user",
53
+ auth: true,
54
+ request: { body: { email: "String", name: "String" } },
55
+ successStatus: 201,
56
+ successDescription: "User created",
57
+ errors: [
58
+ { status: 400, code: "INVALID_INPUT", description: "Bad request" },
59
+ { status: 409, code: "DUPLICATE_EMAIL", description: "Email already exists" },
60
+ ],
61
+ },
62
+ {
63
+ id: "EP-003",
64
+ method: "GET",
65
+ path: "/api/users/:id",
66
+ description: "Get user by ID",
67
+ auth: true,
68
+ request: { params: { id: "String" } },
69
+ successStatus: 200,
70
+ successDescription: "Returns user",
71
+ },
72
+ {
73
+ id: "EP-004",
74
+ method: "DELETE",
75
+ path: "/api/users/:id",
76
+ description: "Delete a user",
77
+ auth: true,
78
+ successStatus: 204,
79
+ successDescription: "User deleted",
80
+ },
81
+ ],
82
+ behaviors: [],
83
+ ...overrides,
84
+ };
85
+ }
86
+
87
+ let tmpDir: string;
88
+
89
+ beforeEach(async () => {
90
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "mock-gen-test-"));
91
+ });
92
+
93
+ afterEach(async () => {
94
+ await fs.remove(tmpDir);
95
+ });
96
+
97
+ // ─── generateMockAssets ──────────────────────────────────────────────────────
98
+
99
+ describe("generateMockAssets", () => {
100
+ it("generates server.js and README.md by default", async () => {
101
+ const dsl = makeDsl();
102
+ const result = await generateMockAssets(dsl, tmpDir);
103
+
104
+ expect(result.files.length).toBe(2);
105
+ expect(result.files[0].path).toBe("mock/server.js");
106
+ expect(result.files[1].path).toBe("mock/README.md");
107
+
108
+ // server.js should exist on disk
109
+ const serverContent = await fs.readFile(path.join(tmpDir, "mock/server.js"), "utf-8");
110
+ expect(serverContent).toContain("express");
111
+ expect(serverContent).toContain("User CRUD");
112
+ expect(serverContent).toContain("/api/users");
113
+ });
114
+
115
+ it("server.js includes auth middleware when endpoints have auth", async () => {
116
+ const dsl = makeDsl();
117
+ const result = await generateMockAssets(dsl, tmpDir);
118
+ const serverContent = await fs.readFile(path.join(tmpDir, "mock/server.js"), "utf-8");
119
+
120
+ expect(serverContent).toContain("requireAuth");
121
+ expect(serverContent).toContain("Authorization");
122
+ });
123
+
124
+ it("server.js omits auth middleware when no endpoints need auth", async () => {
125
+ const dsl = makeDsl({
126
+ endpoints: [
127
+ {
128
+ id: "EP-001",
129
+ method: "GET",
130
+ path: "/api/health",
131
+ description: "Health check",
132
+ auth: false,
133
+ successStatus: 200,
134
+ successDescription: "OK",
135
+ },
136
+ ],
137
+ });
138
+ await generateMockAssets(dsl, tmpDir);
139
+ const serverContent = await fs.readFile(path.join(tmpDir, "mock/server.js"), "utf-8");
140
+ expect(serverContent).not.toContain("requireAuth");
141
+ });
142
+
143
+ it("generates DELETE 204 endpoints with sendStatus", async () => {
144
+ const dsl = makeDsl();
145
+ await generateMockAssets(dsl, tmpDir);
146
+ const serverContent = await fs.readFile(path.join(tmpDir, "mock/server.js"), "utf-8");
147
+ expect(serverContent).toContain("sendStatus(204)");
148
+ });
149
+
150
+ it("includes error simulation comment for endpoints with errors", async () => {
151
+ const dsl = makeDsl();
152
+ await generateMockAssets(dsl, tmpDir);
153
+ const serverContent = await fs.readFile(path.join(tmpDir, "mock/server.js"), "utf-8");
154
+ expect(serverContent).toContain("simulate_error=INVALID_INPUT");
155
+ });
156
+
157
+ it("uses custom port", async () => {
158
+ const dsl = makeDsl();
159
+ await generateMockAssets(dsl, tmpDir, { port: 4000 });
160
+ const serverContent = await fs.readFile(path.join(tmpDir, "mock/server.js"), "utf-8");
161
+ expect(serverContent).toContain("4000");
162
+ });
163
+
164
+ it("uses custom output directory", async () => {
165
+ const dsl = makeDsl();
166
+ await generateMockAssets(dsl, tmpDir, { outputDir: "mocks" });
167
+ expect(await fs.pathExists(path.join(tmpDir, "mocks/server.js"))).toBe(true);
168
+ });
169
+
170
+ it("generates list endpoint fixtures with data array for GET list endpoints", async () => {
171
+ const dsl = makeDsl();
172
+ await generateMockAssets(dsl, tmpDir);
173
+ const serverContent = await fs.readFile(path.join(tmpDir, "mock/server.js"), "utf-8");
174
+ // EP-001 is "List all users" — should produce paginated fixture
175
+ expect(serverContent).toContain('"total"');
176
+ expect(serverContent).toContain('"page"');
177
+ expect(serverContent).toContain('"pageSize"');
178
+ });
179
+
180
+ it("README.md contains endpoint table", async () => {
181
+ const dsl = makeDsl();
182
+ await generateMockAssets(dsl, tmpDir);
183
+ const readme = await fs.readFile(path.join(tmpDir, "mock/README.md"), "utf-8");
184
+ expect(readme).toContain("GET");
185
+ expect(readme).toContain("`/api/users`");
186
+ expect(readme).toContain("DELETE");
187
+ });
188
+
189
+ // ─── MSW option ─────────────────────────────────────────────────────────
190
+
191
+ it("generates MSW handlers when msw option is true", async () => {
192
+ const dsl = makeDsl();
193
+ const result = await generateMockAssets(dsl, tmpDir, { msw: true });
194
+
195
+ const handlerFile = result.files.find((f) => f.path.includes("handlers.ts"));
196
+ expect(handlerFile).toBeTruthy();
197
+
198
+ const handlersContent = await fs.readFile(
199
+ path.join(tmpDir, "src/mocks/handlers.ts"),
200
+ "utf-8"
201
+ );
202
+ expect(handlersContent).toContain("import { http, HttpResponse }");
203
+ expect(handlersContent).toContain("http.get");
204
+ expect(handlersContent).toContain("http.post");
205
+ expect(handlersContent).toContain("http.delete");
206
+ });
207
+
208
+ it("generates MSW browser setup file", async () => {
209
+ const dsl = makeDsl();
210
+ await generateMockAssets(dsl, tmpDir, { msw: true });
211
+ const browserContent = await fs.readFile(
212
+ path.join(tmpDir, "src/mocks/browser.ts"),
213
+ "utf-8"
214
+ );
215
+ expect(browserContent).toContain("setupWorker");
216
+ });
217
+
218
+ // ─── Proxy option ───────────────────────────────────────────────────────
219
+
220
+ it("generates proxy config when proxy option is true", async () => {
221
+ const dsl = makeDsl();
222
+ const result = await generateMockAssets(dsl, tmpDir, { proxy: true });
223
+ const proxyFile = result.files.find((f) => f.path.includes("proxy"));
224
+ expect(proxyFile).toBeTruthy();
225
+ });
226
+
227
+ it("detects Vite framework for proxy config", async () => {
228
+ // Create a vite.config.ts to trigger vite detection
229
+ await fs.writeFile(path.join(tmpDir, "vite.config.ts"), "export default {}", "utf-8");
230
+ const dsl = makeDsl();
231
+ const result = await generateMockAssets(dsl, tmpDir, { proxy: true });
232
+ const proxyFile = result.files.find((f) => f.path.includes("proxy"));
233
+ expect(proxyFile?.path).toContain("vite");
234
+ });
235
+
236
+ it("detects Next.js framework for proxy config", async () => {
237
+ await fs.writeFile(path.join(tmpDir, "next.config.js"), "module.exports = {}", "utf-8");
238
+ const dsl = makeDsl();
239
+ const result = await generateMockAssets(dsl, tmpDir, { proxy: true });
240
+ const proxyFile = result.files.find((f) => f.path.includes("proxy"));
241
+ expect(proxyFile?.path).toContain("next");
242
+ });
243
+
244
+ it("detects CRA framework via react-scripts", async () => {
245
+ await fs.writeJson(path.join(tmpDir, "package.json"), {
246
+ dependencies: { "react-scripts": "5.0.0" },
247
+ });
248
+ const dsl = makeDsl();
249
+ const result = await generateMockAssets(dsl, tmpDir, { proxy: true });
250
+ const proxyFile = result.files.find((f) => f.path.includes("proxy"));
251
+ expect(proxyFile?.path).toContain("cra");
252
+ });
253
+ });
254
+
255
+ // ─── Fixture value heuristics ────────────────────────────────────────────────
256
+ // We test these indirectly by checking generated server.js content
257
+
258
+ describe("fixture heuristics", () => {
259
+ it("generates email fixtures for email fields", async () => {
260
+ const dsl = makeDsl();
261
+ await generateMockAssets(dsl, tmpDir);
262
+ const content = await fs.readFile(path.join(tmpDir, "mock/server.js"), "utf-8");
263
+ expect(content).toContain("user@example.com");
264
+ });
265
+
266
+ it("generates boolean fixtures for boolean fields", async () => {
267
+ const dsl = makeDsl();
268
+ await generateMockAssets(dsl, tmpDir);
269
+ const content = await fs.readFile(path.join(tmpDir, "mock/server.js"), "utf-8");
270
+ // isActive: Boolean → true
271
+ expect(content).toContain('"isActive": true');
272
+ });
273
+
274
+ it("generates date fixtures for DateTime fields", async () => {
275
+ const dsl = makeDsl();
276
+ await generateMockAssets(dsl, tmpDir);
277
+ const content = await fs.readFile(path.join(tmpDir, "mock/server.js"), "utf-8");
278
+ expect(content).toContain("2024-01-15T10:30:00.000Z");
279
+ });
280
+ });
281
+
282
+ // ─── findLatestDslFile ───────────────────────────────────────────────────────
283
+
284
+ describe("findLatestDslFile", () => {
285
+ it("returns null when .ai-spec directory does not exist", async () => {
286
+ const result = await findLatestDslFile(tmpDir);
287
+ expect(result).toBeNull();
288
+ });
289
+
290
+ it("returns null when no .dsl.json files exist", async () => {
291
+ await fs.ensureDir(path.join(tmpDir, ".ai-spec"));
292
+ await fs.writeFile(path.join(tmpDir, ".ai-spec/readme.md"), "hi");
293
+ const result = await findLatestDslFile(tmpDir);
294
+ expect(result).toBeNull();
295
+ });
296
+
297
+ it("returns the most recently modified .dsl.json file", async () => {
298
+ const specDir = path.join(tmpDir, ".ai-spec");
299
+ await fs.ensureDir(specDir);
300
+
301
+ // Create two DSL files with different mtimes
302
+ const older = path.join(specDir, "old.dsl.json");
303
+ const newer = path.join(specDir, "new.dsl.json");
304
+ await fs.writeJson(older, { version: "1.0" });
305
+
306
+ // Small delay to ensure different mtime
307
+ await new Promise((r) => setTimeout(r, 50));
308
+ await fs.writeJson(newer, { version: "1.0" });
309
+
310
+ const result = await findLatestDslFile(tmpDir);
311
+ expect(result).toBe(newer);
312
+ });
313
+
314
+ it("scans nested directories", async () => {
315
+ const nestedDir = path.join(tmpDir, ".ai-spec", "v1");
316
+ await fs.ensureDir(nestedDir);
317
+ await fs.writeJson(path.join(nestedDir, "feature.dsl.json"), { version: "1.0" });
318
+
319
+ const result = await findLatestDslFile(tmpDir);
320
+ expect(result).toBe(path.join(nestedDir, "feature.dsl.json"));
321
+ });
322
+ });
323
+
324
+ // ─── applyMockProxy / restoreMockProxy ───────────────────────────────────────
325
+
326
+ describe("applyMockProxy / restoreMockProxy", () => {
327
+ it("applies Vite proxy: writes mock config + adds dev:mock script", async () => {
328
+ await fs.writeFile(path.join(tmpDir, "vite.config.ts"), "export default {}", "utf-8");
329
+ await fs.writeJson(path.join(tmpDir, "package.json"), { scripts: { dev: "vite" } });
330
+
331
+ const endpoints: ApiEndpoint[] = [
332
+ {
333
+ id: "EP-001", method: "GET", path: "/api/users", description: "List",
334
+ auth: false, successStatus: 200, successDescription: "OK",
335
+ },
336
+ ];
337
+ const result = await applyMockProxy(tmpDir, 3001, endpoints);
338
+
339
+ expect(result.framework).toBe("vite");
340
+ expect(result.applied).toBe(true);
341
+ expect(result.devCommand).toBe("npm run dev:mock");
342
+
343
+ // Check that vite mock config was created
344
+ expect(await fs.pathExists(path.join(tmpDir, "vite.config.ai-spec-mock.ts"))).toBe(true);
345
+
346
+ // Check that package.json has dev:mock script
347
+ const pkg = await fs.readJson(path.join(tmpDir, "package.json"));
348
+ expect(pkg.scripts["dev:mock"]).toContain("vite.config.ai-spec-mock.ts");
349
+ });
350
+
351
+ it("applies CRA proxy: patches package.json proxy field", async () => {
352
+ await fs.writeJson(path.join(tmpDir, "package.json"), {
353
+ dependencies: { "react-scripts": "5.0.0" },
354
+ });
355
+
356
+ const result = await applyMockProxy(tmpDir, 3001);
357
+
358
+ expect(result.framework).toBe("cra");
359
+ expect(result.applied).toBe(true);
360
+
361
+ const pkg = await fs.readJson(path.join(tmpDir, "package.json"));
362
+ expect(pkg.proxy).toBe("http://localhost:3001");
363
+ });
364
+
365
+ it("restoreMockProxy undoes Vite changes", async () => {
366
+ await fs.writeFile(path.join(tmpDir, "vite.config.ts"), "export default {}", "utf-8");
367
+ await fs.writeJson(path.join(tmpDir, "package.json"), { scripts: { dev: "vite" } });
368
+
369
+ await applyMockProxy(tmpDir, 3001);
370
+ const restoreResult = await restoreMockProxy(tmpDir);
371
+
372
+ expect(restoreResult.restored).toBe(true);
373
+ expect(await fs.pathExists(path.join(tmpDir, "vite.config.ai-spec-mock.ts"))).toBe(false);
374
+
375
+ const pkg = await fs.readJson(path.join(tmpDir, "package.json"));
376
+ expect(pkg.scripts["dev:mock"]).toBeUndefined();
377
+ });
378
+
379
+ it("restoreMockProxy undoes CRA proxy change", async () => {
380
+ await fs.writeJson(path.join(tmpDir, "package.json"), {
381
+ dependencies: { "react-scripts": "5.0.0" },
382
+ });
383
+
384
+ await applyMockProxy(tmpDir, 3001);
385
+ await restoreMockProxy(tmpDir);
386
+
387
+ const pkg = await fs.readJson(path.join(tmpDir, "package.json"));
388
+ expect(pkg.proxy).toBeUndefined();
389
+ });
390
+
391
+ it("restoreMockProxy returns restored:false when no lock file", async () => {
392
+ const result = await restoreMockProxy(tmpDir);
393
+ expect(result.restored).toBe(false);
394
+ expect(result.note).toContain("No lock file");
395
+ });
396
+
397
+ it("returns note for Next.js (no auto-patch)", async () => {
398
+ await fs.writeFile(path.join(tmpDir, "next.config.js"), "module.exports = {}", "utf-8");
399
+ const result = await applyMockProxy(tmpDir, 3001);
400
+ expect(result.framework).toBe("next");
401
+ expect(result.applied).toBe(false);
402
+ expect(result.note).toContain("next.config.js");
403
+ });
404
+ });