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 +5 -3
- package/package.json +1 -4
- package/src/commands/auto-dev.js +186 -76
- package/src/commands/generate-code.js +28 -1
- package/src/commands/write-and-test.js +73 -25
- package/src/core/test-generator.js +57 -7
- package/src/core/test-runner.js +4 -1
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(預設
|
|
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(預設
|
|
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.
|
|
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
|
}
|
package/src/commands/auto-dev.js
CHANGED
|
@@ -4,14 +4,16 @@
|
|
|
4
4
|
* 支援全自動模式(--no-confirm)和分步互動模式
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { resolve
|
|
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: '
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
//
|
|
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
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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:
|
|
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
|
|
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(`測試類型:${
|
|
71
|
+
console.log(`測試類型:${requestedTestType}`);
|
|
39
72
|
console.log(`最大修復:${maxFixes} 次`);
|
|
40
73
|
console.log('');
|
|
41
74
|
|
|
42
75
|
// 1. 詢問是否繼續
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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(
|
|
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
|
-
|
|
77
|
-
|
|
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 {
|
|
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 =
|
|
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
|
-
*
|
|
165
|
+
* 將原始碼與測試檔案一併 commit 到 git
|
|
118
166
|
*/
|
|
119
|
-
async function
|
|
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('
|
|
125
|
-
} catch
|
|
126
|
-
logger.warning('無法自動 commit
|
|
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 - 測試類型(預設 '
|
|
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 = '
|
|
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
|
|
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.
|
|
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 {
|
|
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
|
-
- 第一行必須是
|
|
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
|
}
|
package/src/core/test-runner.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|