@zjex/git-workflow 0.2.22 → 0.2.24

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.
@@ -0,0 +1,470 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ generateAICommitMessage,
4
+ isAICommitAvailable,
5
+ getProviderInfo,
6
+ } from "../src/ai-service";
7
+ import { execOutput } from "../src/utils";
8
+ import type { GwConfig } from "../src/config";
9
+
10
+ // Mock dependencies
11
+ vi.mock("../src/utils");
12
+ global.fetch = vi.fn();
13
+
14
+ describe("AI Service 模块测试", () => {
15
+ beforeEach(() => {
16
+ vi.clearAllMocks();
17
+ });
18
+
19
+ afterEach(() => {
20
+ vi.restoreAllMocks();
21
+ });
22
+
23
+ describe("isAICommitAvailable 函数", () => {
24
+ it("默认应该返回 true", () => {
25
+ const config: GwConfig = {
26
+ featurePrefix: "feature",
27
+ hotfixPrefix: "hotfix",
28
+ requireId: false,
29
+ featureIdLabel: "Story ID",
30
+ hotfixIdLabel: "Issue ID",
31
+ };
32
+
33
+ expect(isAICommitAvailable(config)).toBe(true);
34
+ });
35
+
36
+ it("明确启用时应该返回 true", () => {
37
+ const config: GwConfig = {
38
+ featurePrefix: "feature",
39
+ hotfixPrefix: "hotfix",
40
+ requireId: false,
41
+ featureIdLabel: "Story ID",
42
+ hotfixIdLabel: "Issue ID",
43
+ aiCommit: {
44
+ enabled: true,
45
+ },
46
+ };
47
+
48
+ expect(isAICommitAvailable(config)).toBe(true);
49
+ });
50
+
51
+ it("明确禁用时应该返回 false", () => {
52
+ const config: GwConfig = {
53
+ featurePrefix: "feature",
54
+ hotfixPrefix: "hotfix",
55
+ requireId: false,
56
+ featureIdLabel: "Story ID",
57
+ hotfixIdLabel: "Issue ID",
58
+ aiCommit: {
59
+ enabled: false,
60
+ },
61
+ };
62
+
63
+ expect(isAICommitAvailable(config)).toBe(false);
64
+ });
65
+ });
66
+
67
+ describe("getProviderInfo 函数", () => {
68
+ it("应该返回 GitHub 提供商信息", () => {
69
+ const info = getProviderInfo("github");
70
+
71
+ expect(info).toBeDefined();
72
+ expect(info?.name).toBe("GitHub Models");
73
+ expect(info?.free).toBe(true);
74
+ expect(info?.needsKey).toBe(true);
75
+ expect(info?.defaultModel).toBe("gpt-4o-mini");
76
+ });
77
+
78
+ it("应该返回 OpenAI 提供商信息", () => {
79
+ const info = getProviderInfo("openai");
80
+
81
+ expect(info).toBeDefined();
82
+ expect(info?.name).toBe("OpenAI");
83
+ expect(info?.free).toBe(false);
84
+ expect(info?.needsKey).toBe(true);
85
+ });
86
+
87
+ it("应该返回 Claude 提供商信息", () => {
88
+ const info = getProviderInfo("claude");
89
+
90
+ expect(info).toBeDefined();
91
+ expect(info?.name).toBe("Claude");
92
+ expect(info?.free).toBe(false);
93
+ expect(info?.needsKey).toBe(true);
94
+ });
95
+
96
+ it("应该返回 Ollama 提供商信息", () => {
97
+ const info = getProviderInfo("ollama");
98
+
99
+ expect(info).toBeDefined();
100
+ expect(info?.name).toBe("Ollama");
101
+ expect(info?.free).toBe(true);
102
+ expect(info?.needsKey).toBe(false);
103
+ });
104
+
105
+ it("不支持的提供商应该返回 null", () => {
106
+ const info = getProviderInfo("unknown");
107
+
108
+ expect(info).toBeNull();
109
+ });
110
+ });
111
+
112
+ describe("generateAICommitMessage 函数", () => {
113
+ const mockConfig: GwConfig = {
114
+ featurePrefix: "feature",
115
+ hotfixPrefix: "hotfix",
116
+ requireId: false,
117
+ featureIdLabel: "Story ID",
118
+ hotfixIdLabel: "Issue ID",
119
+ aiCommit: {
120
+ enabled: true,
121
+ provider: "github",
122
+ apiKey: "test-key",
123
+ language: "zh-CN",
124
+ maxTokens: 200,
125
+ },
126
+ };
127
+
128
+ it("没有代码更改时应该抛出错误", async () => {
129
+ vi.mocked(execOutput).mockReturnValue("");
130
+
131
+ await expect(generateAICommitMessage(mockConfig)).rejects.toThrow(
132
+ "没有检测到代码更改"
133
+ );
134
+ });
135
+
136
+ it("应该调用 GitHub API", async () => {
137
+ vi.mocked(execOutput).mockReturnValue("diff --git a/file.ts b/file.ts");
138
+
139
+ const mockFetch = vi.mocked(fetch);
140
+ mockFetch.mockResolvedValue({
141
+ ok: true,
142
+ json: async () => ({
143
+ choices: [
144
+ {
145
+ message: {
146
+ content: "feat(test): 添加测试功能",
147
+ },
148
+ },
149
+ ],
150
+ }),
151
+ } as Response);
152
+
153
+ const result = await generateAICommitMessage(mockConfig);
154
+
155
+ expect(result).toBe("feat(test): 添加测试功能");
156
+ expect(mockFetch).toHaveBeenCalledWith(
157
+ expect.stringContaining("github.ai"),
158
+ expect.objectContaining({
159
+ method: "POST",
160
+ headers: expect.objectContaining({
161
+ Authorization: "Bearer test-key",
162
+ }),
163
+ })
164
+ );
165
+ });
166
+
167
+ it("应该调用 OpenAI API", async () => {
168
+ const openaiConfig = {
169
+ ...mockConfig,
170
+ aiCommit: {
171
+ ...mockConfig.aiCommit!,
172
+ provider: "openai" as const,
173
+ },
174
+ };
175
+
176
+ vi.mocked(execOutput).mockReturnValue("diff --git a/file.ts b/file.ts");
177
+
178
+ const mockFetch = vi.mocked(fetch);
179
+ mockFetch.mockResolvedValue({
180
+ ok: true,
181
+ json: async () => ({
182
+ choices: [
183
+ {
184
+ message: {
185
+ content: "feat(test): add test feature",
186
+ },
187
+ },
188
+ ],
189
+ }),
190
+ } as Response);
191
+
192
+ const result = await generateAICommitMessage(openaiConfig);
193
+
194
+ expect(result).toBe("feat(test): add test feature");
195
+ expect(mockFetch).toHaveBeenCalledWith(
196
+ expect.stringContaining("openai.com"),
197
+ expect.any(Object)
198
+ );
199
+ });
200
+
201
+ it("应该调用 Claude API", async () => {
202
+ const claudeConfig = {
203
+ ...mockConfig,
204
+ aiCommit: {
205
+ ...mockConfig.aiCommit!,
206
+ provider: "claude" as const,
207
+ },
208
+ };
209
+
210
+ vi.mocked(execOutput).mockReturnValue("diff --git a/file.ts b/file.ts");
211
+
212
+ const mockFetch = vi.mocked(fetch);
213
+ mockFetch.mockResolvedValue({
214
+ ok: true,
215
+ json: async () => ({
216
+ content: [
217
+ {
218
+ text: "feat(test): add test feature",
219
+ },
220
+ ],
221
+ }),
222
+ } as Response);
223
+
224
+ const result = await generateAICommitMessage(claudeConfig);
225
+
226
+ expect(result).toBe("feat(test): add test feature");
227
+ expect(mockFetch).toHaveBeenCalledWith(
228
+ expect.stringContaining("anthropic.com"),
229
+ expect.objectContaining({
230
+ headers: expect.objectContaining({
231
+ "x-api-key": "test-key",
232
+ }),
233
+ })
234
+ );
235
+ });
236
+
237
+ it("应该调用 Ollama API", async () => {
238
+ const ollamaConfig = {
239
+ ...mockConfig,
240
+ aiCommit: {
241
+ ...mockConfig.aiCommit!,
242
+ provider: "ollama" as const,
243
+ },
244
+ };
245
+
246
+ vi.mocked(execOutput).mockReturnValue("diff --git a/file.ts b/file.ts");
247
+
248
+ const mockFetch = vi.mocked(fetch);
249
+ mockFetch.mockResolvedValue({
250
+ ok: true,
251
+ json: async () => ({
252
+ response: "feat(test): 添加测试功能",
253
+ }),
254
+ } as Response);
255
+
256
+ const result = await generateAICommitMessage(ollamaConfig);
257
+
258
+ expect(result).toBe("feat(test): 添加测试功能");
259
+ expect(mockFetch).toHaveBeenCalledWith(
260
+ expect.stringContaining("localhost:11434"),
261
+ expect.any(Object)
262
+ );
263
+ });
264
+
265
+ it("API 失败时应该抛出错误", async () => {
266
+ vi.mocked(execOutput).mockReturnValue("diff --git a/file.ts b/file.ts");
267
+
268
+ const mockFetch = vi.mocked(fetch);
269
+ mockFetch.mockResolvedValue({
270
+ ok: false,
271
+ status: 401,
272
+ text: async () => "Unauthorized",
273
+ } as Response);
274
+
275
+ await expect(generateAICommitMessage(mockConfig)).rejects.toThrow(
276
+ "GitHub Models API 错误"
277
+ );
278
+ });
279
+
280
+ it("缺少 API key 时应该抛出错误", async () => {
281
+ const configWithoutKey = {
282
+ ...mockConfig,
283
+ aiCommit: {
284
+ ...mockConfig.aiCommit!,
285
+ apiKey: "",
286
+ },
287
+ };
288
+
289
+ vi.mocked(execOutput).mockReturnValue("diff --git a/file.ts b/file.ts");
290
+
291
+ await expect(generateAICommitMessage(configWithoutKey)).rejects.toThrow(
292
+ "需要 API key"
293
+ );
294
+ });
295
+
296
+ it("不支持的提供商应该抛出错误", async () => {
297
+ const invalidConfig = {
298
+ ...mockConfig,
299
+ aiCommit: {
300
+ ...mockConfig.aiCommit!,
301
+ provider: "invalid" as any,
302
+ },
303
+ };
304
+
305
+ vi.mocked(execOutput).mockReturnValue("diff --git a/file.ts b/file.ts");
306
+
307
+ await expect(generateAICommitMessage(invalidConfig)).rejects.toThrow(
308
+ "不支持的 AI 提供商"
309
+ );
310
+ });
311
+
312
+ it("应该截断过长的 diff", async () => {
313
+ const longDiff = "a".repeat(5000);
314
+ vi.mocked(execOutput).mockReturnValue(longDiff);
315
+
316
+ const mockFetch = vi.mocked(fetch);
317
+ mockFetch.mockResolvedValue({
318
+ ok: true,
319
+ json: async () => ({
320
+ choices: [
321
+ {
322
+ message: {
323
+ content: "feat: update",
324
+ },
325
+ },
326
+ ],
327
+ }),
328
+ } as Response);
329
+
330
+ await generateAICommitMessage(mockConfig);
331
+
332
+ const callArgs = mockFetch.mock.calls[0][1] as RequestInit;
333
+ const body = JSON.parse(callArgs.body as string);
334
+ const prompt = body.messages[0].content;
335
+
336
+ expect(prompt.length).toBeLessThan(longDiff.length + 1000);
337
+ expect(prompt).toContain("...");
338
+ });
339
+
340
+ it("应该使用配置的语言", async () => {
341
+ const enConfig = {
342
+ ...mockConfig,
343
+ aiCommit: {
344
+ ...mockConfig.aiCommit!,
345
+ language: "en-US" as const,
346
+ },
347
+ };
348
+
349
+ vi.mocked(execOutput).mockReturnValue("diff --git a/file.ts b/file.ts");
350
+
351
+ const mockFetch = vi.mocked(fetch);
352
+ mockFetch.mockResolvedValue({
353
+ ok: true,
354
+ json: async () => ({
355
+ choices: [
356
+ {
357
+ message: {
358
+ content: "feat: add feature",
359
+ },
360
+ },
361
+ ],
362
+ }),
363
+ } as Response);
364
+
365
+ await generateAICommitMessage(enConfig);
366
+
367
+ const callArgs = mockFetch.mock.calls[0][1] as RequestInit;
368
+ const body = JSON.parse(callArgs.body as string);
369
+ const prompt = body.messages[0].content;
370
+
371
+ expect(prompt).toContain("Generate a commit message");
372
+ expect(prompt).not.toContain("生成");
373
+ });
374
+
375
+ it("应该使用配置的模型", async () => {
376
+ const customModelConfig = {
377
+ ...mockConfig,
378
+ aiCommit: {
379
+ ...mockConfig.aiCommit!,
380
+ model: "gpt-4",
381
+ },
382
+ };
383
+
384
+ vi.mocked(execOutput).mockReturnValue("diff --git a/file.ts b/file.ts");
385
+
386
+ const mockFetch = vi.mocked(fetch);
387
+ mockFetch.mockResolvedValue({
388
+ ok: true,
389
+ json: async () => ({
390
+ choices: [
391
+ {
392
+ message: {
393
+ content: "feat: add feature",
394
+ },
395
+ },
396
+ ],
397
+ }),
398
+ } as Response);
399
+
400
+ await generateAICommitMessage(customModelConfig);
401
+
402
+ const callArgs = mockFetch.mock.calls[0][1] as RequestInit;
403
+ const body = JSON.parse(callArgs.body as string);
404
+
405
+ expect(body.model).toBe("gpt-4");
406
+ });
407
+
408
+ it("应该使用配置的 maxTokens", async () => {
409
+ const customTokensConfig = {
410
+ ...mockConfig,
411
+ aiCommit: {
412
+ ...mockConfig.aiCommit!,
413
+ maxTokens: 500,
414
+ },
415
+ };
416
+
417
+ vi.mocked(execOutput).mockReturnValue("diff --git a/file.ts b/file.ts");
418
+
419
+ const mockFetch = vi.mocked(fetch);
420
+ mockFetch.mockResolvedValue({
421
+ ok: true,
422
+ json: async () => ({
423
+ choices: [
424
+ {
425
+ message: {
426
+ content: "feat: add feature",
427
+ },
428
+ },
429
+ ],
430
+ }),
431
+ } as Response);
432
+
433
+ await generateAICommitMessage(customTokensConfig);
434
+
435
+ const callArgs = mockFetch.mock.calls[0][1] as RequestInit;
436
+ const body = JSON.parse(callArgs.body as string);
437
+
438
+ expect(body.max_tokens).toBe(500);
439
+ });
440
+ });
441
+
442
+ describe("Ollama 特殊情况", () => {
443
+ it("Ollama 连接失败时应该提供安装提示", async () => {
444
+ const ollamaConfig: GwConfig = {
445
+ featurePrefix: "feature",
446
+ hotfixPrefix: "hotfix",
447
+ requireId: false,
448
+ featureIdLabel: "Story ID",
449
+ hotfixIdLabel: "Issue ID",
450
+ aiCommit: {
451
+ enabled: true,
452
+ provider: "ollama",
453
+ model: "qwen2.5-coder:7b",
454
+ },
455
+ };
456
+
457
+ vi.mocked(execOutput).mockReturnValue("diff --git a/file.ts b/file.ts");
458
+
459
+ const mockFetch = vi.mocked(fetch);
460
+ mockFetch.mockRejectedValue(new Error("Connection refused"));
461
+
462
+ await expect(generateAICommitMessage(ollamaConfig)).rejects.toThrow(
463
+ "Ollama 连接失败"
464
+ );
465
+ await expect(generateAICommitMessage(ollamaConfig)).rejects.toThrow(
466
+ "ollama.com"
467
+ );
468
+ });
469
+ });
470
+ });