ai-git-tools 2.0.59 → 2.0.60
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 +123 -76
- package/src/commands/write-and-test.js +37 -22
- 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.60",
|
|
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,15 @@
|
|
|
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 { writeAndTestCommand } from './write-and-test.js';
|
|
15
16
|
import { Logger } from '../utils/logger.js';
|
|
16
17
|
|
|
17
18
|
export async function autoDevCommand(options = {}) {
|
|
@@ -71,13 +72,25 @@ export async function autoDevCommand(options = {}) {
|
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
// ============================================================
|
|
74
|
-
//
|
|
75
|
+
// 第四步:決定目標檔案路徑(優先自動推斷)
|
|
75
76
|
// ============================================================
|
|
76
77
|
if (!filePath) {
|
|
78
|
+
filePath = await inferTargetFilePath(issue, plan, options.model);
|
|
79
|
+
if (filePath) {
|
|
80
|
+
logger.info(`已自動推斷目標檔案:${filePath}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!filePath) {
|
|
85
|
+
if (noConfirm) {
|
|
86
|
+
logger.error('無法自動推斷目標檔案路徑,請改用 --file 明確指定。');
|
|
87
|
+
throw new Error('缺少可推斷的目標檔案路徑');
|
|
88
|
+
}
|
|
89
|
+
|
|
77
90
|
const { file } = await inquirer.prompt([{
|
|
78
91
|
type: 'input',
|
|
79
92
|
name: 'file',
|
|
80
|
-
message: '
|
|
93
|
+
message: '無法自動推斷,請輸入要生成的檔案路徑(例如:src/core/new-feature.js):',
|
|
81
94
|
validate: (input) => input.trim().length > 0 ? true : '路徑不能為空',
|
|
82
95
|
}]);
|
|
83
96
|
filePath = file;
|
|
@@ -106,80 +119,35 @@ export async function autoDevCommand(options = {}) {
|
|
|
106
119
|
|
|
107
120
|
console.log('');
|
|
108
121
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const { runTest } = await inquirer.prompt([{
|
|
114
|
-
type: 'confirm',
|
|
115
|
-
name: 'runTest',
|
|
116
|
-
message: '代碼已生成,是否為該檔案生成並執行測試?',
|
|
117
|
-
default: true,
|
|
118
|
-
}]);
|
|
119
|
-
|
|
120
|
-
if (!runTest) {
|
|
121
|
-
logger.success('完成!代碼已生成。可稍後手動運行:');
|
|
122
|
-
console.log(` ai-git-tools write-and-test --file ${filePath}`);
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
122
|
+
logger.step('正在驗證專案可用的測試腳本...');
|
|
123
|
+
ensureJestAvailable();
|
|
124
|
+
logger.success('已確認可執行 Jest 測試。');
|
|
125
|
+
console.log('');
|
|
126
126
|
|
|
127
127
|
// ============================================================
|
|
128
|
-
//
|
|
128
|
+
// 第六步:強制執行測試流程
|
|
129
129
|
// ============================================================
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
options.model
|
|
139
|
-
);
|
|
140
|
-
logger.success(`測試已生成:${testFilePath}`);
|
|
141
|
-
console.log('');
|
|
142
|
-
|
|
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);
|
|
130
|
+
const testResult = await writeAndTestCommand({
|
|
131
|
+
file: filePath,
|
|
132
|
+
testType: options.testType || 'auto',
|
|
133
|
+
maxFixes: options.maxFixes || '2',
|
|
134
|
+
model: options.model,
|
|
135
|
+
noConfirm: true,
|
|
136
|
+
skipCommit: true,
|
|
137
|
+
});
|
|
153
138
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
break;
|
|
158
|
-
}
|
|
159
|
-
|
|
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
|
-
}
|
|
139
|
+
if (!testResult?.success) {
|
|
140
|
+
logger.error('測試流程未完成,自動化工作流中止。');
|
|
141
|
+
return;
|
|
174
142
|
}
|
|
175
143
|
|
|
176
144
|
// ============================================================
|
|
177
|
-
//
|
|
145
|
+
// 第七步:自動 Commit
|
|
178
146
|
// ============================================================
|
|
179
147
|
logger.step('正在自動提交...');
|
|
180
148
|
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(), '.')}`;
|
|
149
|
+
GitOperations.exec(`git add "${absoluteFilePath}" "${testResult.testFilePath}"`, { silent: true });
|
|
150
|
+
const commitMsg = `feat(auto-dev): Issue #${issue.number} - ${issue.title}\n\n生成檔案:\n- ${filePath}\n- ${testResult.testFilePath.replace(process.cwd(), '.')}`;
|
|
183
151
|
GitOperations.exec(`git commit -m "${commitMsg.split('\n')[0]}"`, { silent: true });
|
|
184
152
|
logger.success('已自動提交');
|
|
185
153
|
} catch {
|
|
@@ -190,7 +158,8 @@ export async function autoDevCommand(options = {}) {
|
|
|
190
158
|
logger.section('✅ 自動化工作流完成');
|
|
191
159
|
console.log(`Issue #${issue.number}:${issue.title}`);
|
|
192
160
|
console.log(`生成檔案:${filePath}`);
|
|
193
|
-
console.log(`測試檔案:${testFilePath.replace(process.cwd(), '.')}`);
|
|
161
|
+
console.log(`測試檔案:${testResult.testFilePath.replace(process.cwd(), '.')}`);
|
|
162
|
+
console.log(`測試類型:${testResult.testType}`);
|
|
194
163
|
console.log(`合計行數:${codeResult.linesCount + ' (程式碼)'}`);
|
|
195
164
|
}
|
|
196
165
|
|
|
@@ -220,11 +189,89 @@ ${issue.labels.length ? `標籤:${issue.labels.join(', ')}` : ''}
|
|
|
220
189
|
}
|
|
221
190
|
|
|
222
191
|
/**
|
|
223
|
-
*
|
|
192
|
+
* 優先以規則與計畫內容推斷最可能的目標檔案
|
|
224
193
|
*/
|
|
225
|
-
function
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
194
|
+
async function inferTargetFilePath(issue, plan, model) {
|
|
195
|
+
const planCandidates = extractFileCandidates(plan);
|
|
196
|
+
if (planCandidates.length === 1) {
|
|
197
|
+
return planCandidates[0];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (planCandidates.length > 1) {
|
|
201
|
+
const preferredCandidate = planCandidates.find((candidate) => candidate.startsWith('src/'));
|
|
202
|
+
if (preferredCandidate) {
|
|
203
|
+
return preferredCandidate;
|
|
204
|
+
}
|
|
205
|
+
return planCandidates[0];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const prompt = `請根據以下 GitHub Issue 與實作計畫,推斷「最主要的實作檔案路徑」。
|
|
209
|
+
|
|
210
|
+
## 規則
|
|
211
|
+
- 只回傳單一檔案路徑
|
|
212
|
+
- 優先回傳 src/ 底下的實作檔,不要回傳測試檔、文件檔、設定檔
|
|
213
|
+
- 副檔名限制為 .js、.ts、.jsx、.tsx、.mjs、.cjs
|
|
214
|
+
- 不要包含程式碼區塊、不要加說明
|
|
215
|
+
|
|
216
|
+
## Issue #${issue.number}
|
|
217
|
+
標題:${issue.title}
|
|
218
|
+
描述:
|
|
219
|
+
${issue.body || '(無描述)'}
|
|
220
|
+
|
|
221
|
+
## 實作計畫
|
|
222
|
+
${plan}`.trim();
|
|
223
|
+
|
|
224
|
+
const inferred = (await AIClient.sendAndWait(prompt, model)).trim().split(/\s+/)[0];
|
|
225
|
+
return isValidTargetPath(inferred) ? inferred : null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function extractFileCandidates(plan) {
|
|
229
|
+
const matches = plan.match(/([A-Za-z0-9_./-]+\.(?:js|ts|jsx|tsx|mjs|cjs))/g) || [];
|
|
230
|
+
const uniqueMatches = [...new Set(matches.filter(isValidTargetPath))];
|
|
231
|
+
|
|
232
|
+
return uniqueMatches.filter((candidate) => {
|
|
233
|
+
const normalized = candidate.toLowerCase();
|
|
234
|
+
return !normalized.includes('__tests__/')
|
|
235
|
+
&& !normalized.includes('.test.')
|
|
236
|
+
&& !normalized.includes('.spec.')
|
|
237
|
+
&& !normalized.endsWith('.config.js');
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function isValidTargetPath(value) {
|
|
242
|
+
return typeof value === 'string'
|
|
243
|
+
&& /^[A-Za-z0-9_./-]+\.(js|ts|jsx|tsx|mjs|cjs)$/.test(value)
|
|
244
|
+
&& !value.startsWith('/');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function ensureJestAvailable() {
|
|
248
|
+
try {
|
|
249
|
+
execSync('npx jest --version', {
|
|
250
|
+
encoding: 'utf-8',
|
|
251
|
+
stdio: 'pipe',
|
|
252
|
+
cwd: process.cwd(),
|
|
253
|
+
});
|
|
254
|
+
} catch {
|
|
255
|
+
const packageJsonPath = resolve(process.cwd(), 'package.json');
|
|
256
|
+
if (!existsSync(packageJsonPath)) {
|
|
257
|
+
throw new Error('目前目錄沒有 package.json,無法執行 Jest 測試。');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
let packageJson;
|
|
261
|
+
try {
|
|
262
|
+
packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
|
263
|
+
} catch {
|
|
264
|
+
throw new Error('無法讀取目前專案的 package.json,請確認 Jest 已安裝。');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const hasJestDependency = Boolean(
|
|
268
|
+
packageJson.dependencies?.jest || packageJson.devDependencies?.jest
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
if (!hasJestDependency) {
|
|
272
|
+
throw new Error('目前專案未安裝 Jest,請先安裝後再執行 auto-dev。');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
throw new Error('偵測到 Jest 依賴,但無法執行 npx jest,請檢查安裝或 lockfile 狀態。');
|
|
276
|
+
}
|
|
230
277
|
}
|
|
@@ -15,7 +15,7 @@ export async function writeAndTestCommand(options = {}) {
|
|
|
15
15
|
const logger = new Logger();
|
|
16
16
|
|
|
17
17
|
const filePath = options.file;
|
|
18
|
-
const
|
|
18
|
+
const requestedTestType = options.testType || 'auto';
|
|
19
19
|
const maxFixes = parseInt(options.maxFixes || '2', 10);
|
|
20
20
|
|
|
21
21
|
if (!filePath) {
|
|
@@ -35,27 +35,35 @@ export async function writeAndTestCommand(options = {}) {
|
|
|
35
35
|
logger.header('write-and-test');
|
|
36
36
|
console.log(`原始碼 :${absoluteSourcePath}`);
|
|
37
37
|
console.log(`測試檔案:${testFilePath}`);
|
|
38
|
-
console.log(`測試類型:${
|
|
38
|
+
console.log(`測試類型:${requestedTestType}`);
|
|
39
39
|
console.log(`最大修復:${maxFixes} 次`);
|
|
40
40
|
console.log('');
|
|
41
41
|
|
|
42
42
|
// 1. 詢問是否繼續
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
43
|
+
if (!options.noConfirm) {
|
|
44
|
+
const { confirmed } = await inquirer.prompt([{
|
|
45
|
+
type: 'confirm',
|
|
46
|
+
name: 'confirmed',
|
|
47
|
+
message: `確認為 ${filePath} 生成 ${requestedTestType} 測試並執行?`,
|
|
48
|
+
default: true,
|
|
49
|
+
}]);
|
|
50
|
+
|
|
51
|
+
if (!confirmed) {
|
|
52
|
+
logger.info('已取消。');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
// 2. 生成測試
|
|
56
58
|
logger.step('正在生成測試代碼...');
|
|
57
|
-
await TestGenerator.generateTests(
|
|
59
|
+
const generatedTest = await TestGenerator.generateTests(
|
|
60
|
+
absoluteSourcePath,
|
|
61
|
+
testFilePath,
|
|
62
|
+
requestedTestType,
|
|
63
|
+
options.model
|
|
64
|
+
);
|
|
58
65
|
logger.success(`測試已寫入:${testFilePath}`);
|
|
66
|
+
logger.info(`實際測試類型:${generatedTest.testType}`);
|
|
59
67
|
|
|
60
68
|
// 3. 執行測試 + 自動修復循環
|
|
61
69
|
let lastErrors = [];
|
|
@@ -73,8 +81,10 @@ export async function writeAndTestCommand(options = {}) {
|
|
|
73
81
|
logger.success('所有測試通過! 🎉');
|
|
74
82
|
|
|
75
83
|
// 4. 自動 commit 測試檔案
|
|
76
|
-
|
|
77
|
-
|
|
84
|
+
if (!options.skipCommit) {
|
|
85
|
+
await commitGeneratedFiles(testFilePath, absoluteSourcePath, logger);
|
|
86
|
+
}
|
|
87
|
+
return { success: true, testFilePath, testType: generatedTest.testType };
|
|
78
88
|
}
|
|
79
89
|
|
|
80
90
|
lastErrors = result.errors;
|
|
@@ -97,7 +107,12 @@ export async function writeAndTestCommand(options = {}) {
|
|
|
97
107
|
console.log('');
|
|
98
108
|
console.log(`原始碼路徑:${absoluteSourcePath}`);
|
|
99
109
|
console.log(`測試路徑 :${testFilePath}`);
|
|
100
|
-
return {
|
|
110
|
+
return {
|
|
111
|
+
success: false,
|
|
112
|
+
testFilePath,
|
|
113
|
+
errors: lastErrors,
|
|
114
|
+
testType: generatedTest.testType,
|
|
115
|
+
};
|
|
101
116
|
}
|
|
102
117
|
}
|
|
103
118
|
}
|
|
@@ -114,15 +129,15 @@ function deriveTestFilePath(sourceFilePath) {
|
|
|
114
129
|
}
|
|
115
130
|
|
|
116
131
|
/**
|
|
117
|
-
*
|
|
132
|
+
* 將原始碼與測試檔案一併 commit 到 git
|
|
118
133
|
*/
|
|
119
|
-
async function
|
|
134
|
+
async function commitGeneratedFiles(testFilePath, sourceFilePath, logger) {
|
|
120
135
|
try {
|
|
121
|
-
execSync(`git add "${testFilePath}"`, { stdio: 'pipe' });
|
|
136
|
+
execSync(`git add "${sourceFilePath}" "${testFilePath}"`, { stdio: 'pipe' });
|
|
122
137
|
const name = basename(sourceFilePath);
|
|
123
138
|
execSync(`git commit -m "test: 為 ${name} 新增自動生成的測試"`, { stdio: 'pipe' });
|
|
124
|
-
logger.success('
|
|
125
|
-
} catch
|
|
126
|
-
logger.warning('無法自動 commit
|
|
139
|
+
logger.success('原始碼與測試檔案已自動 commit。');
|
|
140
|
+
} catch {
|
|
141
|
+
logger.warning('無法自動 commit 產生的檔案(可能沒有 staged 變更或不在 git 倉庫中)。');
|
|
127
142
|
}
|
|
128
143
|
}
|
|
@@ -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) {
|