ai-git-tools 2.0.39 → 2.0.41

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,273 @@
1
+ /**
2
+ * AutoDev 工作流程編排
3
+ * 整合 Issue 解析 → 測試執行 → 結果發佈 → commit-all → PR
4
+ */
5
+
6
+ import { IssueParser } from './issue-parser.js';
7
+ import { TestDetector } from '../test/test-detector.js';
8
+ import { createExecutor } from '../test/executor-factory.js';
9
+ import { ResultFormatter, COMMENT_MARKER } from '../test/result-formatter.js';
10
+ import { CodeGenerator } from '../ai/code-generator.js';
11
+ import { GitHubAPI } from '../../pr-modules/core/github-api.js';
12
+ import { commitAllProgrammatic } from '../../commands/commit-all.js';
13
+ import { prProgrammatic } from '../../commands/pr.js';
14
+
15
+ /**
16
+ * @typedef {Object} AutodevOptions
17
+ * @property {boolean} [dryRun] - 乾運行:只顯示計劃,不執行
18
+ * @property {boolean} [verbose] - 詳細輸出
19
+ * @property {string} [framework] - 強制指定框架(覆蓋自動偵測)
20
+ * @property {boolean} [skipCommit] - 跳過 commit-all
21
+ * @property {boolean} [skipPr] - 跳過 PR 建立
22
+ * @property {boolean} [skipAll] - 只測試 + 發佈評論,跳過 commit 和 PR
23
+ * @property {boolean} [commitOnly] - 測試 + commit,不建立 PR
24
+ */
25
+
26
+ export class AutodevWorkflow {
27
+ /**
28
+ * @param {Object} config - .ai-git-config.js 的 autodev 區塊
29
+ */
30
+ constructor(config = {}) {
31
+ this.config = config;
32
+ this.issueParser = new IssueParser();
33
+ this.testDetector = new TestDetector(config.projectRoot || process.cwd());
34
+ this.formatter = new ResultFormatter();
35
+ this.codeGenerator = new CodeGenerator({
36
+ aiModel: config.aiModel,
37
+ maxRetries: config.maxRetries,
38
+ });
39
+ this.github = new GitHubAPI();
40
+ }
41
+
42
+ /**
43
+ * 主執行流程
44
+ * @param {string|number} issueInput
45
+ * @param {AutodevOptions} options
46
+ */
47
+ async execute(issueInput, options = {}) {
48
+ const step = (n, total, msg) =>
49
+ console.log(`\n[${n}/${total}] ${msg}`);
50
+
51
+ const TOTAL = this._calcTotalSteps(options);
52
+ let stepIdx = 0;
53
+
54
+ // ── 1. 解析 Issue ────────────────────────────────────────
55
+ step(++stepIdx, TOTAL, '解析 Issue...');
56
+ const issueData = await this._parseIssue(issueInput, options);
57
+
58
+ // ── 2. AI 自動開發(生成代碼框架)────────────────────────
59
+ step(++stepIdx, TOTAL, '生成代碼框架...');
60
+ const generatedFiles = await this._generateCode(issueData, options);
61
+
62
+ // ── 3. 偵測框架 ──────────────────────────────────────────
63
+ step(++stepIdx, TOTAL, '偵測測試框架...');
64
+ const framework = this._detectFramework(options);
65
+
66
+ // ── 4. 發現測試檔案 ──────────────────────────────────────
67
+ step(++stepIdx, TOTAL, '搜尋測試檔案...');
68
+ const testFiles = this.testDetector.discoverTests(framework, {
69
+ testPaths: this.config.testPaths,
70
+ });
71
+ console.log(` 找到 ${testFiles.length} 個測試檔案`);
72
+
73
+ if (options.dryRun) {
74
+ console.log('\n🔍 乾運行模式:以下是執行計劃\n');
75
+ console.log(` Issue : #${issueData.number} - ${issueData.title}`);
76
+ console.log(` 代碼檔案 : ${generatedFiles.length} 個將被建立`);
77
+ console.log(` 框架 : ${framework}`);
78
+ console.log(` 測試數量 : ${testFiles.length} 個檔案`);
79
+ console.log(` Commit : ${this._willCommit(options) ? '✅ 會執行' : '⏭️ 跳過'}`);
80
+ console.log(` PR : ${this._willCreatePR(options) ? '✅ 會執行' : '⏭️ 跳過'}`);
81
+ return;
82
+ }
83
+
84
+ if (testFiles.length === 0) {
85
+ console.warn(' ⚠️ 未找到任何測試檔案,跳過測試步驟');
86
+ }
87
+
88
+ // ── 5. 執行測試 ──────────────────────────────────────────
89
+ step(++stepIdx, TOTAL, `執行 ${framework} 測試...`);
90
+ const executor = createExecutor(framework, {
91
+ timeout: this.config.testTimeout,
92
+ });
93
+ const testResults = await executor.run(testFiles, {
94
+ verbose: options.verbose,
95
+ projectRoot: this.config.projectRoot || process.cwd(),
96
+ });
97
+ const allPassed = testResults.failed === 0 && testResults.total > 0;
98
+ const passRate = testResults.total > 0
99
+ ? ((testResults.passed / testResults.total) * 100).toFixed(1)
100
+ : '-';
101
+ console.log(
102
+ ` ${allPassed ? '✅' : '❌'} ${testResults.passed}/${testResults.total} 通過(${passRate}%)· ${(testResults.duration / 1000).toFixed(1)}s`
103
+ );
104
+
105
+ // ── 6. 格式化結果 ─────────────────────────────────────────
106
+ step(++stepIdx, TOTAL, '格式化測試結果...');
107
+ const meta = {
108
+ generatedFiles: generatedFiles.length,
109
+ };
110
+ let markdown = this.formatter.formatAsMarkdown(testResults, issueData, meta);
111
+
112
+ // ── 7. 發佈到 Issue 評論(初稿,無 commit/PR 資訊)─────────
113
+ step(++stepIdx, TOTAL, `發佈結果到 Issue #${issueData.number}...`);
114
+ const commentResult = await this._postOrUpdateComment(
115
+ issueData,
116
+ markdown,
117
+ options
118
+ );
119
+ if (commentResult) {
120
+ console.log(` 📝 評論已發佈:${commentResult.url}`);
121
+ }
122
+
123
+ // ── 8. 若測試失敗,停止 ───────────────────────────────────
124
+ if (!allPassed && testResults.total > 0) {
125
+ console.log('\n❌ 測試未全部通過,已停止自動 commit / PR');
126
+ console.log(' 修復失敗的測試後,重新執行 `ai autodev <issue>`');
127
+ return;
128
+ }
129
+
130
+ // 若沒有測試,給警告但繼續
131
+ if (testResults.total === 0) {
132
+ console.warn(' ⚠️ 無測試可執行,繼續後續流程');
133
+ }
134
+
135
+ // ── 9. 自動 commit-all ───────────────────────────────────
136
+ if (this._willCommit(options)) {
137
+ step(++stepIdx, TOTAL, '自動提交(ai commit-all)...');
138
+ const commitResult = await commitAllProgrammatic({ verbose: options.verbose });
139
+ if (!commitResult.success) {
140
+ console.error(` ❌ 提交失敗:${commitResult.message}`);
141
+ console.log(' 請手動執行 `ai commit-all`,然後再次執行 `ai pr`');
142
+ return;
143
+ }
144
+ meta.commitHash = commitResult.commitHash;
145
+ console.log(` ✅ ${commitResult.message}(${commitResult.commitHash})`);
146
+ }
147
+
148
+ // ── 10. 自動建立 PR ───────────────────────────────────────
149
+ if (this._willCreatePR(options)) {
150
+ step(++stepIdx, TOTAL, '自動建立 PR(ai pr)...');
151
+ const prResult = await prProgrammatic({ verbose: options.verbose });
152
+ if (!prResult.success) {
153
+ console.error(` ❌ PR 建立失敗:${prResult.message}`);
154
+ return;
155
+ }
156
+ meta.prUrl = prResult.prUrl;
157
+ console.log(` ✅ PR 已建立:${prResult.prUrl}`);
158
+ }
159
+
160
+ // ── 更新 Issue 評論(加入 commit/PR 資訊)───────────────
161
+ if (commentResult && (meta.commitHash || meta.prUrl)) {
162
+ markdown = this.formatter.formatAsMarkdown(testResults, issueData, meta);
163
+ await this._postOrUpdateComment(issueData, markdown, options, commentResult.id);
164
+ }
165
+
166
+ // ── 完成摘要 ──────────────────────────────────────────────
167
+ console.log('\n' + '═'.repeat(60));
168
+ console.log('🎉 ai autodev 完成!');
169
+ console.log(` Issue : #${issueData.number} - ${issueData.title}`);
170
+ if (generatedFiles.length > 0) console.log(` 代碼檔案 : ${generatedFiles.length} 個已建立`);
171
+ console.log(` 測試 : ${testResults.passed}/${testResults.total} 通過`);
172
+ if (meta.commitHash) console.log(` Commit : ${meta.commitHash}`);
173
+ if (meta.prUrl) console.log(` PR : ${meta.prUrl}`);
174
+ if (commentResult) console.log(` 評論 : ${commentResult.url}`);
175
+ console.log('═'.repeat(60));
176
+ }
177
+
178
+ // ── 私有輔助方法 ─────────────────────────────────────────────
179
+
180
+ async _parseIssue(issueInput, options) {
181
+ const issueData = await this.issueParser.parse(issueInput);
182
+ if (options.verbose) {
183
+ console.log(` #${issueData.number}: ${issueData.title}`);
184
+ console.log(` 倉庫: ${issueData.owner}/${issueData.repo}`);
185
+ }
186
+ return issueData;
187
+ }
188
+
189
+ async _generateCode(issueData, options) {
190
+ try {
191
+ const projectRoot = this.config.projectRoot || process.cwd();
192
+ const files = await this.codeGenerator.generateCodeFiles(issueData, projectRoot);
193
+
194
+ if (files.length === 0) {
195
+ console.log(' ℹ️ 沒有需要生成的代碼檔案');
196
+ return [];
197
+ }
198
+
199
+ // 寫入檔案
200
+ const { written, skipped } = await this.codeGenerator.writeFiles(files, projectRoot);
201
+ console.log(` ✅ 已寫入 ${written.length} 個檔案`);
202
+ if (skipped.length > 0) {
203
+ console.warn(` ⚠️ 跳過 ${skipped.length} 個檔案`);
204
+ }
205
+
206
+ if (options.verbose) {
207
+ written.forEach((f) => console.log(` 新建: ${f}`));
208
+ }
209
+
210
+ return written;
211
+ } catch (error) {
212
+ console.error(` ❌ 代碼生成失敗:${error.message}`);
213
+ return [];
214
+ }
215
+ }
216
+
217
+ _detectFramework(options) {
218
+ const forced = options.framework || this.config.framework;
219
+ if (forced) {
220
+ console.log(` 使用指定框架:${forced}`);
221
+ return forced;
222
+ }
223
+ const detected = this.testDetector.detectFramework();
224
+ if (!detected) {
225
+ throw new Error(
226
+ '無法自動偵測測試框架\n請在 .ai-git-config.js 的 autodev.framework 指定:jest / vitest / mocha'
227
+ );
228
+ }
229
+ console.log(` 偵測到框架:${detected}`);
230
+ return detected;
231
+ }
232
+
233
+ async _postOrUpdateComment(issueData, markdown, options, existingCommentId = null) {
234
+ if (options.dryRun) return null;
235
+
236
+ try {
237
+ const { owner, repo, number } = issueData;
238
+
239
+ // 若有已知 ID,直接更新
240
+ if (existingCommentId) {
241
+ return this.github.editIssueComment(owner, repo, existingCommentId, markdown);
242
+ }
243
+
244
+ // 搜尋相同 marker 的舊評論
245
+ const existing = this.github.findCommentWithMarker(owner, repo, number, COMMENT_MARKER);
246
+ if (existing) {
247
+ return this.github.editIssueComment(owner, repo, existing.id, markdown);
248
+ }
249
+
250
+ return this.github.postCommentOnIssue(owner, repo, number, markdown);
251
+ } catch (error) {
252
+ console.error(` ⚠️ 發佈評論失敗:${error.message}`);
253
+ return null;
254
+ }
255
+ }
256
+
257
+ _willCommit(options) {
258
+ return !options.skipAll && !options.skipCommit && !options.dryRun;
259
+ }
260
+
261
+ _willCreatePR(options) {
262
+ if (options.commitOnly) return false;
263
+ return !options.skipAll && !options.skipPr && !options.dryRun;
264
+ }
265
+
266
+ _calcTotalSteps(options) {
267
+ // 基本步驟:Issue解析 + 代碼生成 + 框架偵測 + 測試發現 + 執行 + 格式化 + 發佈
268
+ let count = 7;
269
+ if (this._willCommit(options)) count++;
270
+ if (this._willCreatePR(options)) count++;
271
+ return count;
272
+ }
273
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Issue 解析模組
3
+ * 負責解析 Issue 編號/URL,並從 GitHub API 擷取 Issue 資料
4
+ */
5
+
6
+ import { execSync } from 'child_process';
7
+
8
+ /**
9
+ * 標準化 IssueData 結構
10
+ * @typedef {Object} IssueData
11
+ * @property {number} number - Issue 編號
12
+ * @property {string} title - Issue 標題
13
+ * @property {string} body - Issue 說明
14
+ * @property {string[]} labels - 標籤列表
15
+ * @property {string} owner - 倉庫擁有者
16
+ * @property {string} repo - 倉庫名稱
17
+ * @property {string} url - Issue URL
18
+ */
19
+
20
+ export class IssueParser {
21
+ /**
22
+ * 解析輸入(數字字串或完整 URL)為 Issue 編號
23
+ * @param {string|number} input - Issue 編號或 GitHub Issue URL
24
+ * @returns {number} Issue 編號
25
+ */
26
+ parseIssueNumber(input) {
27
+ const str = String(input).trim();
28
+
29
+ // 純數字
30
+ if (/^\d+$/.test(str)) {
31
+ return parseInt(str, 10);
32
+ }
33
+
34
+ // GitHub URL 格式:https://github.com/owner/repo/issues/123
35
+ const urlMatch = str.match(/\/issues\/(\d+)/);
36
+ if (urlMatch) {
37
+ return parseInt(urlMatch[1], 10);
38
+ }
39
+
40
+ throw new Error(
41
+ `無效的 Issue 輸入:「${input}」\n請提供 Issue 編號(如 123)或完整 URL(如 https://github.com/owner/repo/issues/123)`
42
+ );
43
+ }
44
+
45
+ /**
46
+ * 從 GitHub URL 解析倉庫資訊
47
+ * @param {string} url
48
+ * @returns {{ owner: string, repo: string, issueNumber: number }}
49
+ */
50
+ parseIssueURL(url) {
51
+ const match = url.match(/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/);
52
+ if (!match) {
53
+ throw new Error(`無法解析 GitHub Issue URL:${url}`);
54
+ }
55
+ return {
56
+ owner: match[1],
57
+ repo: match[2].replace(/\.git$/, ''),
58
+ issueNumber: parseInt(match[3], 10),
59
+ };
60
+ }
61
+
62
+ /**
63
+ * 從 git remote 自動偵測 owner/repo
64
+ * @returns {{ owner: string, repo: string }}
65
+ */
66
+ detectRepoFromRemote() {
67
+ try {
68
+ const remoteUrl = execSync('git remote get-url origin', {
69
+ encoding: 'utf-8',
70
+ stdio: ['pipe', 'pipe', 'pipe'],
71
+ }).trim();
72
+
73
+ // HTTPS: https://github.com/owner/repo.git
74
+ const httpsMatch = remoteUrl.match(/github\.com\/([^/]+)\/([^/\s]+?)(?:\.git)?$/);
75
+ if (httpsMatch) {
76
+ return { owner: httpsMatch[1], repo: httpsMatch[2] };
77
+ }
78
+
79
+ // SSH: git@github.com:owner/repo.git
80
+ const sshMatch = remoteUrl.match(/github\.com:([^/]+)\/([^/\s]+?)(?:\.git)?$/);
81
+ if (sshMatch) {
82
+ return { owner: sshMatch[1], repo: sshMatch[2] };
83
+ }
84
+
85
+ throw new Error('無法從 git remote 解析 owner/repo');
86
+ } catch (error) {
87
+ throw new Error(`偵測 git remote 失敗:${error.message}`);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * 透過 GitHub CLI 擷取 Issue 資料
93
+ * @param {string} owner
94
+ * @param {string} repo
95
+ * @param {number} issueNumber
96
+ * @returns {IssueData}
97
+ */
98
+ async fetchIssue(owner, repo, issueNumber) {
99
+ try {
100
+ const json = execSync(
101
+ `gh api repos/${owner}/${repo}/issues/${issueNumber}`,
102
+ { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
103
+ );
104
+ const data = JSON.parse(json);
105
+
106
+ return {
107
+ number: data.number,
108
+ title: data.title,
109
+ body: data.body || '',
110
+ labels: (data.labels || []).map((l) => l.name),
111
+ owner,
112
+ repo,
113
+ url: data.html_url,
114
+ };
115
+ } catch (error) {
116
+ if (error.message.includes('404')) {
117
+ throw new Error(
118
+ `Issue #${issueNumber} 不存在於 ${owner}/${repo}\n請確認 Issue 編號正確,且你有存取該倉庫的權限`
119
+ );
120
+ }
121
+ throw new Error(`擷取 Issue 失敗:${error.message}`);
122
+ }
123
+ }
124
+
125
+ /**
126
+ * 解析輸入並完整擷取 Issue 資料(主要入口)
127
+ * @param {string|number} input - Issue 編號或 URL
128
+ * @returns {Promise<IssueData>}
129
+ */
130
+ async parse(input) {
131
+ const str = String(input).trim();
132
+
133
+ // 如果是完整 URL,直接解析 owner/repo
134
+ if (str.startsWith('http')) {
135
+ const { owner, repo, issueNumber } = this.parseIssueURL(str);
136
+ return this.fetchIssue(owner, repo, issueNumber);
137
+ }
138
+
139
+ // 否則從 git remote 偵測
140
+ const issueNumber = this.parseIssueNumber(str);
141
+ const { owner, repo } = this.detectRepoFromRemote();
142
+ return this.fetchIssue(owner, repo, issueNumber);
143
+ }
144
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * 測試執行器基礎類別
3
+ * 定義標準介面與 TestResults 結構
4
+ */
5
+
6
+ /**
7
+ * @typedef {Object} TestCase
8
+ * @property {string} name - 測試名稱
9
+ * @property {string} suite - 所屬 suite(describe 區塊)
10
+ * @property {boolean} passed
11
+ * @property {number} duration - 毫秒
12
+ * @property {string|null} error - 失敗時的錯誤訊息
13
+ */
14
+
15
+ /**
16
+ * @typedef {Object} TestResults
17
+ * @property {string} framework
18
+ * @property {number} total
19
+ * @property {number} passed
20
+ * @property {number} failed
21
+ * @property {number} skipped
22
+ * @property {number} duration - 總毫秒數
23
+ * @property {TestCase[]} tests
24
+ * @property {string|null} rawOutput
25
+ */
26
+
27
+ export class ExecutorBase {
28
+ /**
29
+ * @param {Object} options
30
+ * @param {number} [options.timeout=300000] - 5 分鐘
31
+ */
32
+ constructor(options = {}) {
33
+ this.timeout = options.timeout ?? 300_000;
34
+ }
35
+
36
+ /**
37
+ * 執行測試(子類別實作)
38
+ * @param {string[]} testPaths
39
+ * @param {Object} options
40
+ * @returns {Promise<TestResults>}
41
+ */
42
+ // eslint-disable-next-line no-unused-vars
43
+ async run(testPaths, options = {}) {
44
+ throw new Error('子類別必須實作 run() 方法');
45
+ }
46
+
47
+ /**
48
+ * 解析 JSON 輸出(子類別實作)
49
+ * @param {string} output
50
+ * @returns {TestResults}
51
+ */
52
+ // eslint-disable-next-line no-unused-vars
53
+ parseResults(output) {
54
+ throw new Error('子類別必須實作 parseResults() 方法');
55
+ }
56
+
57
+ /**
58
+ * 建立空的 TestResults 骨架
59
+ * @param {string} framework
60
+ * @returns {TestResults}
61
+ */
62
+ emptyResults(framework) {
63
+ return {
64
+ framework,
65
+ total: 0,
66
+ passed: 0,
67
+ failed: 0,
68
+ skipped: 0,
69
+ duration: 0,
70
+ tests: [],
71
+ rawOutput: null,
72
+ };
73
+ }
74
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * 測試執行器工廠
3
+ * 依框架名稱回傳對應的執行器實例
4
+ */
5
+
6
+ import { JestExecutor } from './jest-executor.js';
7
+ import { VitestExecutor } from './vitest-executor.js';
8
+ import { MochaExecutor } from './mocha-executor.js';
9
+ import { FRAMEWORKS } from './test-detector.js';
10
+
11
+ /**
12
+ * @param {string} framework - 'jest' | 'vitest' | 'mocha'
13
+ * @param {Object} options - 傳遞給執行器的選項(如 timeout)
14
+ * @returns {import('./executor-base.js').ExecutorBase}
15
+ */
16
+ export function createExecutor(framework, options = {}) {
17
+ switch (framework) {
18
+ case FRAMEWORKS.JEST:
19
+ return new JestExecutor(options);
20
+ case FRAMEWORKS.VITEST:
21
+ return new VitestExecutor(options);
22
+ case FRAMEWORKS.MOCHA:
23
+ return new MochaExecutor(options);
24
+ default:
25
+ throw new Error(
26
+ `不支援的測試框架:「${framework}」\n目前支援:jest、vitest、mocha\n請在 .ai-git-config.js 的 autodev.framework 指定框架`
27
+ );
28
+ }
29
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Jest 測試執行器
3
+ * 使用 --json 取得結構化輸出
4
+ */
5
+
6
+ import { execSync } from 'child_process';
7
+ import { ExecutorBase } from './executor-base.js';
8
+
9
+ export class JestExecutor extends ExecutorBase {
10
+ constructor(options = {}) {
11
+ super(options);
12
+ this.framework = 'jest';
13
+ }
14
+
15
+ /**
16
+ * 執行 Jest,回傳標準化 TestResults
17
+ * @param {string[]} testPaths - 若為空則執行全部
18
+ * @param {Object} options
19
+ * @param {boolean} [options.verbose]
20
+ * @returns {Promise<import('./executor-base.js').TestResults>}
21
+ */
22
+ async run(testPaths = [], options = {}) {
23
+ const pathArgs = testPaths.length > 0 ? testPaths.join(' ') : '';
24
+ const cmd = `npx jest --json --forceExit --passWithNoTests ${pathArgs}`;
25
+
26
+ if (options.verbose) {
27
+ console.log(` 執行命令:${cmd}`);
28
+ }
29
+
30
+ let output = '';
31
+ try {
32
+ output = execSync(cmd, {
33
+ encoding: 'utf-8',
34
+ timeout: this.timeout,
35
+ stdio: ['pipe', 'pipe', 'pipe'],
36
+ cwd: options.projectRoot || process.cwd(),
37
+ });
38
+ } catch (error) {
39
+ // Jest 測試失敗時 exit code 非 0,但 stdout 仍有 JSON
40
+ output = error.stdout || error.output?.[1] || '';
41
+ }
42
+
43
+ return this.parseResults(output);
44
+ }
45
+
46
+ /**
47
+ * 將 Jest --json 輸出解析為標準 TestResults
48
+ * @param {string} output
49
+ * @returns {import('./executor-base.js').TestResults}
50
+ */
51
+ parseResults(output) {
52
+ const result = this.emptyResults(this.framework);
53
+ result.rawOutput = output;
54
+
55
+ let json;
56
+ try {
57
+ // Jest --json 可能混有非 JSON 前綴,找第一個 { 開始
58
+ const start = output.indexOf('{');
59
+ if (start === -1) return result;
60
+ json = JSON.parse(output.slice(start));
61
+ } catch (_) {
62
+ return result;
63
+ }
64
+
65
+ result.total = json.numTotalTests ?? 0;
66
+ result.passed = json.numPassedTests ?? 0;
67
+ result.failed = json.numFailedTests ?? 0;
68
+ result.skipped = (json.numPendingTests ?? 0) + (json.numTodoTests ?? 0);
69
+ result.duration = json.testResults?.reduce((acc, s) => acc + (s.perfStats?.runtime ?? 0), 0) ?? 0;
70
+
71
+ for (const suite of json.testResults ?? []) {
72
+ const suiteName = suite.testFilePath?.replace(process.cwd(), '').replace(/^\//, '') ?? '';
73
+ for (const t of suite.testResults ?? []) {
74
+ result.tests.push({
75
+ name: t.fullName ?? t.title,
76
+ suite: suiteName,
77
+ passed: t.status === 'passed',
78
+ duration: t.duration ?? 0,
79
+ error: t.failureMessages?.join('\n') || null,
80
+ });
81
+ }
82
+ }
83
+
84
+ return result;
85
+ }
86
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Mocha 測試執行器
3
+ * 使用 --reporter json 取得結構化輸出
4
+ */
5
+
6
+ import { execSync } from 'child_process';
7
+ import { ExecutorBase } from './executor-base.js';
8
+
9
+ export class MochaExecutor extends ExecutorBase {
10
+ constructor(options = {}) {
11
+ super(options);
12
+ this.framework = 'mocha';
13
+ }
14
+
15
+ /**
16
+ * 執行 Mocha,回傳標準化 TestResults
17
+ * @param {string[]} testPaths
18
+ * @param {Object} options
19
+ * @returns {Promise<import('./executor-base.js').TestResults>}
20
+ */
21
+ async run(testPaths = [], options = {}) {
22
+ const pathArgs =
23
+ testPaths.length > 0 ? testPaths.map((p) => `"${p}"`).join(' ') : '"test/**/*.js" "tests/**/*.js"';
24
+ const cmd = `npx mocha --reporter json ${pathArgs}`;
25
+
26
+ if (options.verbose) {
27
+ console.log(` 執行命令:${cmd}`);
28
+ }
29
+
30
+ let output = '';
31
+ try {
32
+ output = execSync(cmd, {
33
+ encoding: 'utf-8',
34
+ timeout: this.timeout,
35
+ stdio: ['pipe', 'pipe', 'pipe'],
36
+ cwd: options.projectRoot || process.cwd(),
37
+ });
38
+ } catch (error) {
39
+ // Mocha 失敗時 exit code 非 0,stdout 仍有 JSON
40
+ output = error.stdout || error.output?.[1] || '';
41
+ }
42
+
43
+ return this.parseResults(output);
44
+ }
45
+
46
+ /**
47
+ * 將 Mocha --reporter json 輸出解析為標準 TestResults
48
+ * @param {string} output
49
+ * @returns {import('./executor-base.js').TestResults}
50
+ */
51
+ parseResults(output) {
52
+ const result = this.emptyResults(this.framework);
53
+ result.rawOutput = output;
54
+
55
+ let json;
56
+ try {
57
+ const start = output.indexOf('{');
58
+ if (start === -1) return result;
59
+ json = JSON.parse(output.slice(start));
60
+ } catch (_) {
61
+ return result;
62
+ }
63
+
64
+ const stats = json.stats || {};
65
+ result.total = stats.tests ?? 0;
66
+ result.passed = stats.passes ?? 0;
67
+ result.failed = stats.failures ?? 0;
68
+ result.skipped = stats.pending ?? 0;
69
+ result.duration = stats.duration ?? 0;
70
+
71
+ // passes
72
+ for (const t of json.passes ?? []) {
73
+ result.tests.push({
74
+ name: t.fullTitle ?? t.title,
75
+ suite: t.fullTitle?.replace(t.title, '').trim() ?? '',
76
+ passed: true,
77
+ duration: t.duration ?? 0,
78
+ error: null,
79
+ });
80
+ }
81
+
82
+ // failures
83
+ for (const t of json.failures ?? []) {
84
+ result.tests.push({
85
+ name: t.fullTitle ?? t.title,
86
+ suite: t.fullTitle?.replace(t.title, '').trim() ?? '',
87
+ passed: false,
88
+ duration: t.duration ?? 0,
89
+ error: t.err?.message || t.err?.stack || null,
90
+ });
91
+ }
92
+
93
+ return result;
94
+ }
95
+ }