ai-git-tools 2.0.59 → 2.0.61

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/bin/cli.js CHANGED
@@ -142,8 +142,9 @@ program
142
142
  .command('write-and-test')
143
143
  .description('AI 為指定檔案生成 Jest 測試,並自動執行與修復(最多 2 次)')
144
144
  .requiredOption('--file <path>', '原始碼路徑')
145
- .option('--test-type <type>', '測試類型:unit 或 component(預設 unit)', 'unit')
145
+ .option('--test-type <type>', '測試類型:auto、unit 或 component(預設 auto)', 'auto')
146
146
  .option('--max-fixes <number>', '最大自動修復次數(預設 2)', '2')
147
+ .option('--no-confirm', '跳過確認直接生成並執行測試')
147
148
  .option('--model <model>', '指定 AI 模型')
148
149
  .action(async (options) => {
149
150
  try {
@@ -159,10 +160,11 @@ program
159
160
  .command('auto-dev')
160
161
  .description('一鍵自動化:從 GitHub Issue 到代碼生成、測試與提交')
161
162
  .requiredOption('--issue <number>', 'GitHub Issue 編號')
162
- .option('--file <path>', '目標檔案路徑(若無則提示)')
163
+ .option('--file <path>', '目標檔案路徑(若無則自動推斷)')
163
164
  .option('--context <description>', '額外說明或補充需求')
164
- .option('--test-type <type>', '測試類型:unit 或 component(預設 unit)', 'unit')
165
+ .option('--test-type <type>', '測試類型:auto、unit 或 component(預設 auto)', 'auto')
165
166
  .option('--max-lines <number>', '最大行數限制(預設 500)', '500')
167
+ .option('--max-fixes <number>', '最大自動修復次數(預設 2)', '2')
166
168
  .option('--no-confirm', '全自動模式,跳過所有確認')
167
169
  .option('--model <model>', '指定 AI 模型')
168
170
  .action(async (options) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-git-tools",
3
- "version": "2.0.59",
3
+ "version": "2.0.61",
4
4
  "description": "AI-powered Git automation tools for commit messages and PR generation",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -63,9 +63,6 @@
63
63
  "jest": {
64
64
  "testEnvironment": "node",
65
65
  "transform": {},
66
- "extensionsToTreatAsEsm": [
67
- ".js"
68
- ],
69
66
  "moduleNameMapper": {
70
67
  "^(\\.{1,2}/.+)\\.js$": "$1"
71
68
  }
@@ -4,14 +4,16 @@
4
4
  * 支援全自動模式(--no-confirm)和分步互動模式
5
5
  */
6
6
 
7
- import { resolve, dirname, basename, join } from 'path';
7
+ import { resolve } from 'path';
8
+ import { existsSync, readFileSync } from 'fs';
9
+ import { execSync } from 'child_process';
8
10
  import inquirer from 'inquirer';
9
11
  import { IssueReader } from '../core/issue-reader.js';
10
12
  import { CodeGenerator } from '../core/code-generator.js';
11
- import { TestGenerator } from '../core/test-generator.js';
12
- import { TestRunner } from '../core/test-runner.js';
13
13
  import { AIClient } from '../core/ai-client.js';
14
14
  import { GitOperations } from '../core/git-operations.js';
15
+ import { TestGenerator } from '../core/test-generator.js';
16
+ import { writeAndTestCommand, TEST_TYPE_CHOICES, normalizeTestType } from './write-and-test.js';
15
17
  import { Logger } from '../utils/logger.js';
16
18
 
17
19
  export async function autoDevCommand(options = {}) {
@@ -71,13 +73,25 @@ export async function autoDevCommand(options = {}) {
71
73
  }
72
74
 
73
75
  // ============================================================
74
- // 第四步:詢問目標檔案路徑(若未指定)
76
+ // 第四步:決定目標檔案路徑(優先自動推斷)
75
77
  // ============================================================
76
78
  if (!filePath) {
79
+ filePath = await inferTargetFilePath(issue, plan, options.model);
80
+ if (filePath) {
81
+ logger.info(`已自動推斷目標檔案:${filePath}`);
82
+ }
83
+ }
84
+
85
+ if (!filePath) {
86
+ if (noConfirm) {
87
+ logger.error('無法自動推斷目標檔案路徑,請改用 --file 明確指定。');
88
+ throw new Error('缺少可推斷的目標檔案路徑');
89
+ }
90
+
77
91
  const { file } = await inquirer.prompt([{
78
92
  type: 'input',
79
93
  name: 'file',
80
- message: '請輸入要生成的檔案路徑(例如:src/core/new-feature.js):',
94
+ message: '無法自動推斷,請輸入要生成的檔案路徑(例如:src/core/new-feature.js):',
81
95
  validate: (input) => input.trim().length > 0 ? true : '路徑不能為空',
82
96
  }]);
83
97
  filePath = file;
@@ -94,7 +108,7 @@ export async function autoDevCommand(options = {}) {
94
108
  try {
95
109
  codeResult = await CodeGenerator.generateFile(issue, absoluteFilePath, {
96
110
  maxLines: parseInt(options.maxLines || '500', 10),
97
- language: filePath.endsWith('.ts') ? 'ts' : 'js',
111
+ language: getLanguageFromFilePath(filePath),
98
112
  extraContext: options.context || '',
99
113
  model: options.model,
100
114
  });
@@ -106,80 +120,42 @@ export async function autoDevCommand(options = {}) {
106
120
 
107
121
  console.log('');
108
122
 
109
- // ============================================================
110
- // 第六步:詢問是否執行測試(若非全自動)
111
- // ============================================================
112
- if (!noConfirm) {
113
- const { runTest } = await inquirer.prompt([{
114
- type: 'confirm',
115
- name: 'runTest',
116
- message: '代碼已生成,是否為該檔案生成並執行測試?',
117
- default: true,
118
- }]);
123
+ const selectedTestType = await resolveTestTypeForAutoDev({
124
+ noConfirm,
125
+ requestedTestType: options.testType,
126
+ });
119
127
 
120
- if (!runTest) {
121
- logger.success('完成!代碼已生成。可稍後手動運行:');
122
- console.log(` ai-git-tools write-and-test --file ${filePath}`);
123
- return;
124
- }
125
- }
128
+ const resolvedRuntimeTestType = resolveRuntimeTestType(absoluteFilePath, selectedTestType);
126
129
 
127
- // ============================================================
128
- // 第七步:生成與執行測試(自動修復最多 2 次)
129
- // ============================================================
130
- const testFilePath = deriveTestFilePath(absoluteFilePath);
131
- const maxFixes = 2;
132
-
133
- logger.step('正在生成測試...');
134
- await TestGenerator.generateTests(
135
- absoluteFilePath,
136
- testFilePath,
137
- options.testType || 'unit',
138
- options.model
139
- );
140
- logger.success(`測試已生成:${testFilePath}`);
130
+ logger.step('正在驗證專案可用的測試腳本...');
131
+ ensureJestAvailable(resolvedRuntimeTestType);
132
+ logger.success('已確認可執行 Jest 測試。');
141
133
  console.log('');
142
134
 
143
- let lastErrors = [];
144
- for (let attempt = 0; attempt <= maxFixes; attempt++) {
145
- if (attempt > 0) {
146
- logger.step(`進行第 ${attempt}/${maxFixes} 次自動修復...`);
147
- await TestGenerator.generateFix(absoluteFilePath, lastErrors, attempt, options.model);
148
- logger.success('修復代碼已更新。');
149
- }
150
-
151
- logger.step(`執行測試${attempt > 0 ? `(第 ${attempt} 次修復後)` : ''}...`);
152
- const result = await TestRunner.runTests(testFilePath);
153
-
154
- if (result.success) {
155
- logger.success('所有測試通過! 🎉');
156
- console.log('');
157
- break;
158
- }
135
+ // ============================================================
136
+ // 第六步:強制執行測試流程
137
+ // ============================================================
138
+ const testResult = await writeAndTestCommand({
139
+ file: filePath,
140
+ testType: selectedTestType,
141
+ maxFixes: options.maxFixes || '2',
142
+ model: options.model,
143
+ noConfirm: true,
144
+ skipCommit: true,
145
+ });
159
146
 
160
- lastErrors = result.errors;
161
- if (attempt < maxFixes) {
162
- logger.warning(`測試失敗(${result.errors.length} 個錯誤),嘗試自動修復...`);
163
- } else {
164
- logger.warning(`測試在第 ${maxFixes} 次修復後仍未通過`);
165
- console.log('');
166
- lastErrors.slice(0, 2).forEach((e, i) => {
167
- console.log(`\n[錯誤 ${i + 1}]`);
168
- console.log(e.split('\n').slice(0, 8).join('\n'));
169
- });
170
- console.log('');
171
- logger.error('自動修復失敗,需要手動介入。');
172
- return;
173
- }
147
+ if (!testResult?.success) {
148
+ logger.error('測試流程未完成,自動化工作流中止。');
149
+ return;
174
150
  }
175
151
 
176
152
  // ============================================================
177
- // 第八步:自動 Commit
153
+ // 第七步:自動 Commit
178
154
  // ============================================================
179
155
  logger.step('正在自動提交...');
180
156
  try {
181
- GitOperations.exec(`git add "${absoluteFilePath}" "${testFilePath}"`, { silent: true });
182
- const commitMsg = `feat(auto-dev): Issue #${issue.number} - ${issue.title}\n\n生成檔案:\n- ${filePath}\n- ${testFilePath.replace(process.cwd(), '.')}`;
157
+ GitOperations.exec(`git add "${absoluteFilePath}" "${testResult.testFilePath}"`, { silent: true });
158
+ const commitMsg = `feat(auto-dev): Issue #${issue.number} - ${issue.title}\n\n生成檔案:\n- ${filePath}\n- ${testResult.testFilePath.replace(process.cwd(), '.')}`;
183
159
  GitOperations.exec(`git commit -m "${commitMsg.split('\n')[0]}"`, { silent: true });
184
160
  logger.success('已自動提交');
185
161
  } catch {
@@ -190,7 +166,8 @@ export async function autoDevCommand(options = {}) {
190
166
  logger.section('✅ 自動化工作流完成');
191
167
  console.log(`Issue #${issue.number}:${issue.title}`);
192
168
  console.log(`生成檔案:${filePath}`);
193
- console.log(`測試檔案:${testFilePath.replace(process.cwd(), '.')}`);
169
+ console.log(`測試檔案:${testResult.testFilePath.replace(process.cwd(), '.')}`);
170
+ console.log(`測試類型:${testResult.testType}`);
194
171
  console.log(`合計行數:${codeResult.linesCount + ' (程式碼)'}`);
195
172
  }
196
173
 
@@ -220,11 +197,144 @@ ${issue.labels.length ? `標籤:${issue.labels.join(', ')}` : ''}
220
197
  }
221
198
 
222
199
  /**
223
- * 依據原始檔案路徑推導測試檔案路徑
200
+ * 優先以規則與計畫內容推斷最可能的目標檔案
224
201
  */
225
- function deriveTestFilePath(sourceFilePath) {
226
- const dir = dirname(sourceFilePath);
227
- const name = basename(sourceFilePath).replace(/\.(js|ts|mjs|cjs)$/, '');
228
- const ext = sourceFilePath.endsWith('.ts') ? '.ts' : '.js';
229
- return join(dir, '__tests__', `${name}.test${ext}`);
202
+ async function inferTargetFilePath(issue, plan, model) {
203
+ const planCandidates = extractFileCandidates(plan);
204
+ if (planCandidates.length === 1) {
205
+ return planCandidates[0];
206
+ }
207
+
208
+ if (planCandidates.length > 1) {
209
+ const preferredCandidate = planCandidates.find((candidate) => candidate.startsWith('src/'));
210
+ if (preferredCandidate) {
211
+ return preferredCandidate;
212
+ }
213
+ return planCandidates[0];
214
+ }
215
+
216
+ const prompt = `請根據以下 GitHub Issue 與實作計畫,推斷「最主要的實作檔案路徑」。
217
+
218
+ ## 規則
219
+ - 只回傳單一檔案路徑
220
+ - 優先回傳 src/ 底下的實作檔,不要回傳測試檔、文件檔、設定檔
221
+ - 副檔名限制為 .js、.ts、.jsx、.tsx、.mjs、.cjs
222
+ - 不要包含程式碼區塊、不要加說明
223
+
224
+ ## Issue #${issue.number}
225
+ 標題:${issue.title}
226
+ 描述:
227
+ ${issue.body || '(無描述)'}
228
+
229
+ ## 實作計畫
230
+ ${plan}`.trim();
231
+
232
+ const inferred = (await AIClient.sendAndWait(prompt, model)).trim().split(/\s+/)[0];
233
+ return isValidTargetPath(inferred) ? inferred : null;
234
+ }
235
+
236
+ function extractFileCandidates(plan) {
237
+ const matches = plan.match(/([A-Za-z0-9_./-]+\.(?:js|ts|jsx|tsx|mjs|cjs))/g) || [];
238
+ const uniqueMatches = [...new Set(matches.filter(isValidTargetPath))];
239
+
240
+ return uniqueMatches.filter((candidate) => {
241
+ const normalized = candidate.toLowerCase();
242
+ return !normalized.includes('__tests__/')
243
+ && !normalized.includes('.test.')
244
+ && !normalized.includes('.spec.')
245
+ && !normalized.endsWith('.config.js');
246
+ });
247
+ }
248
+
249
+ function isValidTargetPath(value) {
250
+ return typeof value === 'string'
251
+ && /^[A-Za-z0-9_./-]+\.(js|ts|jsx|tsx|mjs|cjs)$/.test(value)
252
+ && !value.startsWith('/');
253
+ }
254
+
255
+ function getLanguageFromFilePath(filePath) {
256
+ return /\.(ts|tsx)$/.test(filePath) ? 'ts' : 'js';
257
+ }
258
+
259
+ async function resolveTestTypeForAutoDev({ noConfirm, requestedTestType }) {
260
+ if (noConfirm) {
261
+ return normalizeTestType(requestedTestType || 'auto');
262
+ }
263
+
264
+ const { testType } = await inquirer.prompt([{
265
+ type: 'list',
266
+ name: 'testType',
267
+ message: '請選擇這次要執行的測試方式:',
268
+ choices: TEST_TYPE_CHOICES,
269
+ default: normalizeTestType(requestedTestType || 'auto'),
270
+ }]);
271
+
272
+ return normalizeTestType(testType);
273
+ }
274
+
275
+ function resolveRuntimeTestType(sourceFilePath, requestedTestType) {
276
+ if (requestedTestType !== 'auto') {
277
+ return requestedTestType;
278
+ }
279
+
280
+ const sourceCode = readFileSync(sourceFilePath, 'utf-8');
281
+ return TestGenerator.resolveTestType(sourceFilePath, sourceCode, 'auto');
282
+ }
283
+
284
+ function ensureJestAvailable(runtimeTestType = 'unit') {
285
+ const packageJson = readProjectPackageJson();
286
+
287
+ try {
288
+ execSync('npx jest --version', {
289
+ encoding: 'utf-8',
290
+ stdio: 'pipe',
291
+ cwd: process.cwd(),
292
+ });
293
+ ensureComponentTestDependencies(packageJson, runtimeTestType);
294
+ return;
295
+ } catch {
296
+ const hasJestDependency = Boolean(
297
+ packageJson.dependencies?.jest || packageJson.devDependencies?.jest
298
+ );
299
+
300
+ if (!hasJestDependency) {
301
+ throw new Error('目前專案未安裝 Jest,請先安裝後再執行 auto-dev。');
302
+ }
303
+
304
+ ensureComponentTestDependencies(packageJson, runtimeTestType);
305
+
306
+ throw new Error('偵測到 Jest 依賴,但無法執行 npx jest,請檢查安裝或 lockfile 狀態。');
307
+ }
308
+ }
309
+
310
+ function readProjectPackageJson() {
311
+ const packageJsonPath = resolve(process.cwd(), 'package.json');
312
+ if (!existsSync(packageJsonPath)) {
313
+ throw new Error('目前目錄沒有 package.json,無法執行 Jest 測試。');
314
+ }
315
+
316
+ try {
317
+ return JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
318
+ } catch {
319
+ throw new Error('無法讀取目前專案的 package.json,請確認 Jest 已安裝。');
320
+ }
321
+ }
322
+
323
+ function ensureComponentTestDependencies(packageJson, runtimeTestType) {
324
+ if (runtimeTestType !== 'component') {
325
+ return;
326
+ }
327
+
328
+ const hasJsdomDependency = Boolean(
329
+ packageJson.devDependencies?.['jest-environment-jsdom']
330
+ || packageJson.dependencies?.['jest-environment-jsdom']
331
+ );
332
+ const hasTestingLibraryDependency = Boolean(
333
+ packageJson.devDependencies?.['@testing-library/react']
334
+ || packageJson.dependencies?.['@testing-library/react']
335
+ );
336
+
337
+ if (!hasJsdomDependency || !hasTestingLibraryDependency) {
338
+ throw new Error('元件測試需要 jest-environment-jsdom 與 @testing-library/react,請先安裝後再執行 auto-dev。');
339
+ }
230
340
  }
@@ -8,6 +8,7 @@ import { resolve } from 'path';
8
8
  import inquirer from 'inquirer';
9
9
  import { IssueReader } from '../core/issue-reader.js';
10
10
  import { CodeGenerator } from '../core/code-generator.js';
11
+ import { writeAndTestCommand, TEST_TYPE_CHOICES } from './write-and-test.js';
11
12
  import { Logger } from '../utils/logger.js';
12
13
 
13
14
  export async function generateCodeCommand(options = {}) {
@@ -61,7 +62,7 @@ export async function generateCodeCommand(options = {}) {
61
62
 
62
63
  const result = await CodeGenerator.generateFile(issue, absolutePath, {
63
64
  maxLines,
64
- language: absolutePath.endsWith('.ts') ? 'ts' : 'js',
65
+ language: /\.(ts|tsx)$/.test(absolutePath) ? 'ts' : 'js',
65
66
  extraContext,
66
67
  model: options.model,
67
68
  });
@@ -84,5 +85,31 @@ export async function generateCodeCommand(options = {}) {
84
85
  console.log('\n💡 下一步:執行 write-and-test 為此檔案生成並執行測試');
85
86
  console.log(` ai-git-tools write-and-test --file ${filePath}`);
86
87
 
88
+ if (!options.noConfirm) {
89
+ const { nextAction } = await inquirer.prompt([{
90
+ type: 'list',
91
+ name: 'nextAction',
92
+ message: '代碼已生成,接下來要如何處理測試?',
93
+ choices: [
94
+ ...TEST_TYPE_CHOICES,
95
+ {
96
+ name: '先不執行測試',
97
+ value: 'skip',
98
+ },
99
+ ],
100
+ default: 'auto',
101
+ }]);
102
+
103
+ if (nextAction !== 'skip') {
104
+ await writeAndTestCommand({
105
+ file: filePath,
106
+ testType: nextAction,
107
+ maxFixes: options.maxFixes || '2',
108
+ model: options.model,
109
+ noConfirm: true,
110
+ });
111
+ }
112
+ }
113
+
87
114
  return result;
88
115
  }
@@ -11,11 +11,44 @@ import { TestGenerator } from '../core/test-generator.js';
11
11
  import { TestRunner } from '../core/test-runner.js';
12
12
  import { Logger } from '../utils/logger.js';
13
13
 
14
+ export const TEST_TYPE_CHOICES = [
15
+ {
16
+ name: '寫測試(自動判斷 Jest 單元測試 / 元件測試)',
17
+ value: 'auto',
18
+ },
19
+ {
20
+ name: '寫 Jest 單元測試',
21
+ value: 'unit',
22
+ },
23
+ {
24
+ name: '寫元件測試',
25
+ value: 'component',
26
+ },
27
+ ];
28
+
29
+ export function normalizeTestType(testType = 'auto') {
30
+ const normalized = String(testType).trim().toLowerCase();
31
+
32
+ if (normalized === 'auto' || normalized === 'unit' || normalized === 'component') {
33
+ return normalized;
34
+ }
35
+
36
+ if (normalized === 'jest' || normalized === 'jest-unit' || normalized === 'unit-test') {
37
+ return 'unit';
38
+ }
39
+
40
+ if (normalized === 'component-test' || normalized === 'react-component') {
41
+ return 'component';
42
+ }
43
+
44
+ throw new Error(`不支援的測試類型:${testType}`);
45
+ }
46
+
14
47
  export async function writeAndTestCommand(options = {}) {
15
48
  const logger = new Logger();
16
49
 
17
50
  const filePath = options.file;
18
- const testType = options.testType || 'unit';
51
+ const requestedTestType = normalizeTestType(options.testType || 'auto');
19
52
  const maxFixes = parseInt(options.maxFixes || '2', 10);
20
53
 
21
54
  if (!filePath) {
@@ -35,27 +68,35 @@ export async function writeAndTestCommand(options = {}) {
35
68
  logger.header('write-and-test');
36
69
  console.log(`原始碼 :${absoluteSourcePath}`);
37
70
  console.log(`測試檔案:${testFilePath}`);
38
- console.log(`測試類型:${testType}`);
71
+ console.log(`測試類型:${requestedTestType}`);
39
72
  console.log(`最大修復:${maxFixes} 次`);
40
73
  console.log('');
41
74
 
42
75
  // 1. 詢問是否繼續
43
- const { confirmed } = await inquirer.prompt([{
44
- type: 'confirm',
45
- name: 'confirmed',
46
- message: `確認為 ${filePath} 生成 ${testType} 測試並執行?`,
47
- default: true,
48
- }]);
49
-
50
- if (!confirmed) {
51
- logger.info('已取消。');
52
- return;
76
+ if (!options.noConfirm) {
77
+ const { confirmed } = await inquirer.prompt([{
78
+ type: 'confirm',
79
+ name: 'confirmed',
80
+ message: `確認為 ${filePath} 生成 ${requestedTestType} 測試並執行?`,
81
+ default: true,
82
+ }]);
83
+
84
+ if (!confirmed) {
85
+ logger.info('已取消。');
86
+ return;
87
+ }
53
88
  }
54
89
 
55
90
  // 2. 生成測試
56
91
  logger.step('正在生成測試代碼...');
57
- await TestGenerator.generateTests(absoluteSourcePath, testFilePath, testType, options.model);
92
+ const generatedTest = await TestGenerator.generateTests(
93
+ absoluteSourcePath,
94
+ testFilePath,
95
+ requestedTestType,
96
+ options.model
97
+ );
58
98
  logger.success(`測試已寫入:${testFilePath}`);
99
+ logger.info(`實際測試類型:${generatedTest.testType}`);
59
100
 
60
101
  // 3. 執行測試 + 自動修復循環
61
102
  let lastErrors = [];
@@ -73,8 +114,10 @@ export async function writeAndTestCommand(options = {}) {
73
114
  logger.success('所有測試通過! 🎉');
74
115
 
75
116
  // 4. 自動 commit 測試檔案
76
- await commitTestFile(testFilePath, absoluteSourcePath, logger);
77
- return { success: true, testFilePath };
117
+ if (!options.skipCommit) {
118
+ await commitGeneratedFiles(testFilePath, absoluteSourcePath, logger);
119
+ }
120
+ return { success: true, testFilePath, testType: generatedTest.testType };
78
121
  }
79
122
 
80
123
  lastErrors = result.errors;
@@ -97,7 +140,12 @@ export async function writeAndTestCommand(options = {}) {
97
140
  console.log('');
98
141
  console.log(`原始碼路徑:${absoluteSourcePath}`);
99
142
  console.log(`測試路徑 :${testFilePath}`);
100
- return { success: false, testFilePath, errors: lastErrors };
143
+ return {
144
+ success: false,
145
+ testFilePath,
146
+ errors: lastErrors,
147
+ testType: generatedTest.testType,
148
+ };
101
149
  }
102
150
  }
103
151
  }
@@ -106,23 +154,23 @@ export async function writeAndTestCommand(options = {}) {
106
154
  * 依據原始檔案路徑推導測試檔案路徑
107
155
  * e.g. src/core/foo.js → src/core/__tests__/foo.test.js
108
156
  */
109
- function deriveTestFilePath(sourceFilePath) {
157
+ export function deriveTestFilePath(sourceFilePath) {
110
158
  const dir = dirname(sourceFilePath);
111
- const name = basename(sourceFilePath).replace(/\.(js|ts|mjs|cjs)$/, '');
112
- const ext = sourceFilePath.endsWith('.ts') ? '.ts' : '.js';
159
+ const name = basename(sourceFilePath).replace(/\.(js|jsx|ts|tsx|mjs|cjs)$/, '');
160
+ const ext = /\.(ts|tsx)$/.test(sourceFilePath) ? '.ts' : '.js';
113
161
  return join(dir, '__tests__', `${name}.test${ext}`);
114
162
  }
115
163
 
116
164
  /**
117
- * 將測試檔案 commit 到 git
165
+ * 將原始碼與測試檔案一併 commit 到 git
118
166
  */
119
- async function commitTestFile(testFilePath, sourceFilePath, logger) {
167
+ async function commitGeneratedFiles(testFilePath, sourceFilePath, logger) {
120
168
  try {
121
- execSync(`git add "${testFilePath}"`, { stdio: 'pipe' });
169
+ execSync(`git add "${sourceFilePath}" "${testFilePath}"`, { stdio: 'pipe' });
122
170
  const name = basename(sourceFilePath);
123
171
  execSync(`git commit -m "test: 為 ${name} 新增自動生成的測試"`, { stdio: 'pipe' });
124
- logger.success('測試檔案已自動 commit。');
125
- } catch (error) {
126
- logger.warning('無法自動 commit 測試檔案(可能沒有 staged 變更或不在 git 倉庫中)。');
172
+ logger.success('原始碼與測試檔案已自動 commit。');
173
+ } catch {
174
+ logger.warning('無法自動 commit 產生的檔案(可能沒有 staged 變更或不在 git 倉庫中)。');
127
175
  }
128
176
  }
@@ -12,11 +12,11 @@ export class TestGenerator {
12
12
  * 為原始檔案生成測試並寫入測試檔案
13
13
  * @param {string} sourceFilePath - 原始碼路徑(絕對路徑)
14
14
  * @param {string} testFilePath - 測試檔案輸出路徑(絕對路徑)
15
- * @param {'unit'|'component'} testType - 測試類型(預設 'unit')
15
+ * @param {'auto'|'unit'|'component'} testType - 測試類型(預設 'auto')
16
16
  * @param {string} [model] - AI 模型(可選)
17
- * @returns {Promise<{ testFilePath, linesCount }>}
17
+ * @returns {Promise<{ testFilePath, linesCount, testType: 'unit'|'component' }>}
18
18
  */
19
- static async generateTests(sourceFilePath, testFilePath, testType = 'unit', model) {
19
+ static async generateTests(sourceFilePath, testFilePath, testType = 'auto', model) {
20
20
  let sourceCode;
21
21
  try {
22
22
  sourceCode = readFileSync(sourceFilePath, 'utf-8');
@@ -24,15 +24,47 @@ export class TestGenerator {
24
24
  throw new Error(`無法讀取原始碼:${sourceFilePath}`);
25
25
  }
26
26
 
27
- const prompt = TestGenerator._buildPrompt(sourceCode, sourceFilePath, testType);
27
+ const resolvedTestType = TestGenerator.resolveTestType(sourceFilePath, sourceCode, testType);
28
+ const prompt = TestGenerator._buildPrompt(sourceCode, sourceFilePath, resolvedTestType);
28
29
  const rawTests = await AIClient.sendAndWait(prompt, model);
29
- const testCode = TestGenerator._cleanCode(rawTests);
30
+ const testCode = TestGenerator._finalizeTestCode(
31
+ TestGenerator._cleanCode(rawTests),
32
+ resolvedTestType
33
+ );
30
34
 
31
35
  // 確保目錄存在
32
36
  mkdirSync(dirname(testFilePath), { recursive: true });
33
37
  writeFileSync(testFilePath, testCode, 'utf-8');
34
38
 
35
- return { testFilePath, linesCount: testCode.split('\n').length };
39
+ return {
40
+ testFilePath,
41
+ linesCount: testCode.split('\n').length,
42
+ testType: resolvedTestType,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * 解析最終測試類型
48
+ * @param {string} sourceFilePath
49
+ * @param {string} sourceCode
50
+ * @param {'auto'|'unit'|'component'} requestedTestType
51
+ * @returns {'unit'|'component'}
52
+ */
53
+ static resolveTestType(sourceFilePath, sourceCode, requestedTestType = 'auto') {
54
+ if (requestedTestType === 'unit' || requestedTestType === 'component') {
55
+ return requestedTestType;
56
+ }
57
+
58
+ const normalizedPath = sourceFilePath.toLowerCase();
59
+ const looksLikeComponentPath = /\.(jsx|tsx)$/.test(normalizedPath)
60
+ || /component|page|view|screen/.test(normalizedPath);
61
+ const hasReactImport = /from\s+['"]react['"]|from\s+['"]react-dom['"]/.test(sourceCode);
62
+ const hasTestingLibraryHint = /use(State|Effect|Memo|Callback|Reducer|Ref|Context)|React\./.test(sourceCode);
63
+ const hasJsx = /<([A-Z][\w]*|[a-z][\w-]*)(\s[^>]*)?>/.test(sourceCode);
64
+
65
+ return looksLikeComponentPath || hasReactImport || hasTestingLibraryHint || hasJsx
66
+ ? 'component'
67
+ : 'unit';
36
68
  }
37
69
 
38
70
  /**
@@ -75,11 +107,14 @@ ${sourceCode}
75
107
  3. Mock 所有外部依賴(檔案系統、網路、子程序等)
76
108
  4. 使用 beforeEach / afterEach 管理測試狀態
77
109
  5. 測試名稱使用繁體中文描述
110
+ ${testType === 'component'
111
+ ? '6. 使用 @testing-library/react 撰寫互動與渲染測試\n7. 檔案最前面必須加上 /** @jest-environment jsdom */ 註解'
112
+ : '6. 以純邏輯與副作用隔離為主,避免使用 DOM API'}
78
113
 
79
114
  ## 輸出規則
80
115
  - 只輸出可直接執行的測試程式碼
81
116
  - 不要 markdown 區塊(\`\`\`)、不要任何說明文字
82
- - 第一行必須是 import 或 require 陳述式`.trim();
117
+ - 第一行必須是 /** @jest-environment jsdom */ import / require 陳述式`.trim();
83
118
  }
84
119
 
85
120
  /**
@@ -112,4 +147,19 @@ ${testErrors.join('\n')}
112
147
  .replace(/\n?```$/gm, '')
113
148
  .trim();
114
149
  }
150
+
151
+ /**
152
+ * 針對元件測試補上必要的 jsdom 設定
153
+ */
154
+ static _finalizeTestCode(testCode, testType) {
155
+ if (testType !== 'component') {
156
+ return testCode;
157
+ }
158
+
159
+ if (testCode.startsWith('/** @jest-environment jsdom */')) {
160
+ return testCode;
161
+ }
162
+
163
+ return `/** @jest-environment jsdom */\n${testCode}`;
164
+ }
115
165
  }
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { execSync } from 'child_process';
7
7
  import { existsSync } from 'fs';
8
+ import { resolve } from 'path';
8
9
 
9
10
  export class TestRunner {
10
11
  /**
@@ -18,10 +19,12 @@ export class TestRunner {
18
19
  }
19
20
 
20
21
  try {
21
- execSync(`npx jest "${testFilePath}" --no-coverage --forceExit`, {
22
+ const absoluteTestFilePath = resolve(testFilePath);
23
+ execSync(`npx jest --runInBand --runTestsByPath "${absoluteTestFilePath}" --no-coverage`, {
22
24
  encoding: 'utf-8',
23
25
  stdio: 'pipe',
24
26
  timeout: 120000, // 2 分鐘超時
27
+ cwd: process.cwd(),
25
28
  });
26
29
  return { success: true, errors: [] };
27
30
  } catch (error) {