ai-git-tools 2.0.38 → 2.0.40
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 +21 -0
- package/package.json +1 -1
- package/src/commands/autodev.js +51 -0
- package/src/commands/commit-all.js +79 -0
- package/src/commands/pr.js +63 -0
- package/src/core/config-loader.js +43 -0
- package/src/dev-modules/core/autodev-workflow.js +232 -0
- package/src/dev-modules/core/issue-parser.js +144 -0
- package/src/dev-modules/test/executor-base.js +74 -0
- package/src/dev-modules/test/executor-factory.js +29 -0
- package/src/dev-modules/test/jest-executor.js +86 -0
- package/src/dev-modules/test/mocha-executor.js +95 -0
- package/src/dev-modules/test/result-formatter.js +90 -0
- package/src/dev-modules/test/test-detector.js +170 -0
- package/src/dev-modules/test/vitest-executor.js +90 -0
- package/src/pr-modules/core/git-operations.js +34 -6
- package/src/pr-modules/core/github-api.js +84 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 測試結果格式化模組
|
|
3
|
+
* 將 TestResults 轉換為 GitHub Markdown 格式,供發佈到 Issue
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** 唯一識別標記,用於在多次執行時更新同一則評論 */
|
|
7
|
+
export const COMMENT_MARKER = '<!-- ai-autodev-test-results -->';
|
|
8
|
+
|
|
9
|
+
export class ResultFormatter {
|
|
10
|
+
/**
|
|
11
|
+
* 將 TestResults 格式化為 Markdown 字串
|
|
12
|
+
* @param {import('./executor-base.js').TestResults} results
|
|
13
|
+
* @param {import('../core/issue-parser.js').IssueData} issueData
|
|
14
|
+
* @param {Object} [meta] - 額外的 meta 資訊
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
formatAsMarkdown(results, issueData, meta = {}) {
|
|
18
|
+
const now = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
|
19
|
+
const passRate = results.total > 0
|
|
20
|
+
? ((results.passed / results.total) * 100).toFixed(1)
|
|
21
|
+
: '0.0';
|
|
22
|
+
const allPassed = results.failed === 0 && results.total > 0;
|
|
23
|
+
const statusEmoji = allPassed ? '✅' : '❌';
|
|
24
|
+
const durationSec = (results.duration / 1000).toFixed(2);
|
|
25
|
+
|
|
26
|
+
const lines = [
|
|
27
|
+
COMMENT_MARKER,
|
|
28
|
+
'',
|
|
29
|
+
`## ${statusEmoji} 自動測試報告`,
|
|
30
|
+
'',
|
|
31
|
+
`> 由 \`ai autodev\` 自動執行 · ${now} UTC`,
|
|
32
|
+
'',
|
|
33
|
+
'### 摘要',
|
|
34
|
+
'',
|
|
35
|
+
'| 項目 | 結果 |',
|
|
36
|
+
'|------|------|',
|
|
37
|
+
`| 框架 | \`${results.framework}\` |`,
|
|
38
|
+
`| 總測試數 | ${results.total} |`,
|
|
39
|
+
`| ✅ 通過 | ${results.passed} |`,
|
|
40
|
+
`| ❌ 失敗 | ${results.failed} |`,
|
|
41
|
+
`| ⏭️ 跳過 | ${results.skipped} |`,
|
|
42
|
+
`| 通過率 | **${passRate}%** |`,
|
|
43
|
+
`| 執行時間 | ${durationSec}s |`,
|
|
44
|
+
'',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
// 失敗詳情
|
|
48
|
+
const failedTests = results.tests.filter((t) => !t.passed);
|
|
49
|
+
if (failedTests.length > 0) {
|
|
50
|
+
lines.push('### ❌ 失敗測試');
|
|
51
|
+
lines.push('');
|
|
52
|
+
for (const t of failedTests.slice(0, 20)) {
|
|
53
|
+
lines.push(`#### \`${t.name}\``);
|
|
54
|
+
if (t.suite) lines.push(`- **Suite**: ${t.suite}`);
|
|
55
|
+
if (t.error) {
|
|
56
|
+
lines.push('```');
|
|
57
|
+
lines.push(t.error.slice(0, 500));
|
|
58
|
+
lines.push('```');
|
|
59
|
+
}
|
|
60
|
+
lines.push('');
|
|
61
|
+
}
|
|
62
|
+
if (failedTests.length > 20) {
|
|
63
|
+
lines.push(`> ... 還有 ${failedTests.length - 20} 個失敗測試未顯示`);
|
|
64
|
+
lines.push('');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 後續步驟
|
|
69
|
+
lines.push('### 下一步');
|
|
70
|
+
lines.push('');
|
|
71
|
+
if (allPassed) {
|
|
72
|
+
lines.push('- 🎉 所有測試通過!');
|
|
73
|
+
if (meta.commitHash) {
|
|
74
|
+
lines.push(`- 📦 已自動提交:\`${meta.commitHash}\``);
|
|
75
|
+
}
|
|
76
|
+
if (meta.prUrl) {
|
|
77
|
+
lines.push(`- 🔗 已自動建立 PR:${meta.prUrl}`);
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
lines.push('- 請修復上方列出的失敗測試');
|
|
81
|
+
lines.push('- 修復後重新執行 `ai autodev <issue>`');
|
|
82
|
+
if (results.failed > 0) {
|
|
83
|
+
lines.push(`- 或執行 \`npx ${results.framework} --watch\` 進行偵錯`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
lines.push('');
|
|
88
|
+
return lines.join('\n');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 測試框架檢測模組
|
|
3
|
+
* 自動偵測專案使用的測試框架(Jest / Vitest / Mocha)
|
|
4
|
+
* 並發現所有相關測試檔案
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readdirSync, statSync, readFileSync } from 'fs';
|
|
8
|
+
import { resolve, join } from 'path';
|
|
9
|
+
|
|
10
|
+
/** 支援的框架識別字 */
|
|
11
|
+
export const FRAMEWORKS = {
|
|
12
|
+
JEST: 'jest',
|
|
13
|
+
VITEST: 'vitest',
|
|
14
|
+
MOCHA: 'mocha',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export class TestDetector {
|
|
18
|
+
/**
|
|
19
|
+
* @param {string} projectRoot - 專案根目錄(預設 process.cwd())
|
|
20
|
+
*/
|
|
21
|
+
constructor(projectRoot = process.cwd()) {
|
|
22
|
+
this.projectRoot = projectRoot;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 偵測測試框架
|
|
27
|
+
* 優先順序:設定檔 > package.json scripts > package.json devDependencies
|
|
28
|
+
* @returns {string|null} 框架名稱或 null
|
|
29
|
+
*/
|
|
30
|
+
detectFramework() {
|
|
31
|
+
const root = this.projectRoot;
|
|
32
|
+
|
|
33
|
+
// 1. 專用設定檔
|
|
34
|
+
if (
|
|
35
|
+
existsSync(join(root, 'vitest.config.ts')) ||
|
|
36
|
+
existsSync(join(root, 'vitest.config.js')) ||
|
|
37
|
+
existsSync(join(root, 'vitest.config.mjs'))
|
|
38
|
+
) {
|
|
39
|
+
return FRAMEWORKS.VITEST;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (
|
|
43
|
+
existsSync(join(root, 'jest.config.js')) ||
|
|
44
|
+
existsSync(join(root, 'jest.config.ts')) ||
|
|
45
|
+
existsSync(join(root, 'jest.config.mjs')) ||
|
|
46
|
+
existsSync(join(root, 'jest.config.json'))
|
|
47
|
+
) {
|
|
48
|
+
return FRAMEWORKS.JEST;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (
|
|
52
|
+
existsSync(join(root, '.mocharc.js')) ||
|
|
53
|
+
existsSync(join(root, '.mocharc.cjs')) ||
|
|
54
|
+
existsSync(join(root, '.mocharc.json')) ||
|
|
55
|
+
existsSync(join(root, '.mocharc.yml')) ||
|
|
56
|
+
existsSync(join(root, '.mocharc.yaml'))
|
|
57
|
+
) {
|
|
58
|
+
return FRAMEWORKS.MOCHA;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 2. package.json
|
|
62
|
+
const pkgPath = join(root, 'package.json');
|
|
63
|
+
if (existsSync(pkgPath)) {
|
|
64
|
+
try {
|
|
65
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
66
|
+
|
|
67
|
+
// test script
|
|
68
|
+
const testScript = pkg.scripts?.test || '';
|
|
69
|
+
if (/\bvitest\b/.test(testScript)) return FRAMEWORKS.VITEST;
|
|
70
|
+
if (/\bjest\b/.test(testScript)) return FRAMEWORKS.JEST;
|
|
71
|
+
if (/\bmocha\b/.test(testScript)) return FRAMEWORKS.MOCHA;
|
|
72
|
+
|
|
73
|
+
// devDependencies / dependencies
|
|
74
|
+
const allDeps = {
|
|
75
|
+
...pkg.devDependencies,
|
|
76
|
+
...pkg.dependencies,
|
|
77
|
+
};
|
|
78
|
+
if (allDeps.vitest) return FRAMEWORKS.VITEST;
|
|
79
|
+
if (allDeps.jest || allDeps['@jest/core']) return FRAMEWORKS.JEST;
|
|
80
|
+
if (allDeps.mocha) return FRAMEWORKS.MOCHA;
|
|
81
|
+
} catch (_) {
|
|
82
|
+
// 解析失敗,繼續往下
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 遞迴搜尋符合 patterns 的檔案
|
|
91
|
+
* @param {string} dir
|
|
92
|
+
* @param {RegExp[]} patterns
|
|
93
|
+
* @param {string[]} excludes - 排除目錄名稱
|
|
94
|
+
* @returns {string[]}
|
|
95
|
+
*/
|
|
96
|
+
_glob(dir, patterns, excludes = ['node_modules', 'dist', '.next', 'coverage', '.git']) {
|
|
97
|
+
const results = [];
|
|
98
|
+
|
|
99
|
+
let entries;
|
|
100
|
+
try {
|
|
101
|
+
entries = readdirSync(dir);
|
|
102
|
+
} catch (_) {
|
|
103
|
+
return results;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
if (excludes.includes(entry)) continue;
|
|
108
|
+
const fullPath = join(dir, entry);
|
|
109
|
+
let stat;
|
|
110
|
+
try {
|
|
111
|
+
stat = statSync(fullPath);
|
|
112
|
+
} catch (_) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (stat.isDirectory()) {
|
|
117
|
+
results.push(...this._glob(fullPath, patterns, excludes));
|
|
118
|
+
} else if (patterns.some((p) => p.test(entry))) {
|
|
119
|
+
results.push(fullPath);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return results;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 依框架發現測試檔案
|
|
128
|
+
* @param {string} framework
|
|
129
|
+
* @param {Object} [config] - 可選:{ testPaths?: string[] }
|
|
130
|
+
* @returns {string[]}
|
|
131
|
+
*/
|
|
132
|
+
discoverTests(framework, config = {}) {
|
|
133
|
+
// 若使用者明確指定路徑,直接回傳(轉為絕對路徑)
|
|
134
|
+
if (config.testPaths && config.testPaths.length > 0) {
|
|
135
|
+
return config.testPaths.map((p) => resolve(this.projectRoot, p));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const commonPatterns = [
|
|
139
|
+
/\.test\.(js|ts|jsx|tsx|mjs|cjs)$/,
|
|
140
|
+
/\.spec\.(js|ts|jsx|tsx|mjs|cjs)$/,
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
switch (framework) {
|
|
144
|
+
case FRAMEWORKS.JEST:
|
|
145
|
+
case FRAMEWORKS.VITEST:
|
|
146
|
+
return this._glob(this.projectRoot, commonPatterns);
|
|
147
|
+
|
|
148
|
+
case FRAMEWORKS.MOCHA: {
|
|
149
|
+
// Mocha 慣例:test/ 或 tests/ 目錄
|
|
150
|
+
const mochaPatterns = [/\.(js|ts|mjs|cjs)$/];
|
|
151
|
+
const dirs = ['test', 'tests', 'spec', 'specs'].map((d) =>
|
|
152
|
+
join(this.projectRoot, d)
|
|
153
|
+
);
|
|
154
|
+
const results = [];
|
|
155
|
+
for (const d of dirs) {
|
|
156
|
+
if (existsSync(d)) {
|
|
157
|
+
results.push(...this._glob(d, mochaPatterns));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// 也搜尋根目錄的 .test. / .spec. 檔案
|
|
161
|
+
results.push(...this._glob(this.projectRoot, commonPatterns));
|
|
162
|
+
return [...new Set(results)];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
default:
|
|
166
|
+
// 未知框架,使用通用 patterns
|
|
167
|
+
return this._glob(this.projectRoot, commonPatterns);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vitest 測試執行器
|
|
3
|
+
* 使用 --reporter=json 取得結構化輸出
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execSync } from 'child_process';
|
|
7
|
+
import { ExecutorBase } from './executor-base.js';
|
|
8
|
+
|
|
9
|
+
export class VitestExecutor extends ExecutorBase {
|
|
10
|
+
constructor(options = {}) {
|
|
11
|
+
super(options);
|
|
12
|
+
this.framework = 'vitest';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 執行 Vitest,回傳標準化 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 = testPaths.length > 0 ? testPaths.join(' ') : '';
|
|
23
|
+
const cmd = `npx vitest run --reporter=json ${pathArgs}`;
|
|
24
|
+
|
|
25
|
+
if (options.verbose) {
|
|
26
|
+
console.log(` 執行命令:${cmd}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let output = '';
|
|
30
|
+
try {
|
|
31
|
+
output = execSync(cmd, {
|
|
32
|
+
encoding: 'utf-8',
|
|
33
|
+
timeout: this.timeout,
|
|
34
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
35
|
+
cwd: options.projectRoot || process.cwd(),
|
|
36
|
+
});
|
|
37
|
+
} catch (error) {
|
|
38
|
+
output = error.stdout || error.output?.[1] || '';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return this.parseResults(output);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 將 Vitest --reporter=json 輸出解析為標準 TestResults
|
|
46
|
+
* @param {string} output
|
|
47
|
+
* @returns {import('./executor-base.js').TestResults}
|
|
48
|
+
*/
|
|
49
|
+
parseResults(output) {
|
|
50
|
+
const result = this.emptyResults(this.framework);
|
|
51
|
+
result.rawOutput = output;
|
|
52
|
+
|
|
53
|
+
let json;
|
|
54
|
+
try {
|
|
55
|
+
const start = output.indexOf('{');
|
|
56
|
+
if (start === -1) return result;
|
|
57
|
+
json = JSON.parse(output.slice(start));
|
|
58
|
+
} catch (_) {
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Vitest JSON 結構
|
|
63
|
+
const sum = (json.numPassedTests ?? 0) + (json.numFailedTests ?? 0) + (json.numPendingTests ?? 0);
|
|
64
|
+
const numTotalTests = json.numTotalTests ?? sum;
|
|
65
|
+
result.total = numTotalTests;
|
|
66
|
+
result.passed = json.numPassedTests ?? 0;
|
|
67
|
+
result.failed = json.numFailedTests ?? 0;
|
|
68
|
+
result.skipped = json.numPendingTests ?? 0;
|
|
69
|
+
result.duration = json.testResults?.reduce(
|
|
70
|
+
(acc, s) => acc + (s.perfStats?.runtime ?? s.duration ?? 0),
|
|
71
|
+
0
|
|
72
|
+
) ?? 0;
|
|
73
|
+
|
|
74
|
+
for (const suite of json.testResults ?? []) {
|
|
75
|
+
const suiteName =
|
|
76
|
+
suite.name?.replace(process.cwd(), '').replace(/^\//, '') ?? '';
|
|
77
|
+
for (const t of suite.assertionResults ?? []) {
|
|
78
|
+
result.tests.push({
|
|
79
|
+
name: t.fullName ?? t.title,
|
|
80
|
+
suite: suiteName,
|
|
81
|
+
passed: t.status === 'passed',
|
|
82
|
+
duration: t.duration ?? 0,
|
|
83
|
+
error: t.failureMessages?.join('\n') || null,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -11,9 +11,13 @@ export class GitOperations {
|
|
|
11
11
|
*/
|
|
12
12
|
detectReleaseBranches() {
|
|
13
13
|
try {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
// 嘗試同步遠端,失敗就用本機已知的遠端資訊
|
|
15
|
+
try {
|
|
16
|
+
execSync('git fetch origin', { stdio: 'ignore', timeout: 15000 });
|
|
17
|
+
} catch (_) {
|
|
18
|
+
// fetch 失敗,繼續使用已經 cache 的遠端分支
|
|
19
|
+
}
|
|
20
|
+
const branches = execSync('git branch -r', { encoding: 'utf-8' })
|
|
17
21
|
.split('\n')
|
|
18
22
|
.map((b) => b.trim())
|
|
19
23
|
.filter((b) => b.startsWith('origin/release-'))
|
|
@@ -31,12 +35,18 @@ export class GitOperations {
|
|
|
31
35
|
const branches = this.detectReleaseBranches();
|
|
32
36
|
if (branches.length === 0) return null;
|
|
33
37
|
|
|
38
|
+
// 優先選月分支(-m),其次週分支(-w),最後 fallback 到全部 release 分支
|
|
34
39
|
const monthlyBranches = branches.filter((b) => b.includes('-m'));
|
|
35
40
|
const weeklyBranches = branches.filter((b) => b.includes('-w'));
|
|
36
|
-
const priorityBranches =
|
|
41
|
+
const priorityBranches =
|
|
42
|
+
monthlyBranches.length > 0
|
|
43
|
+
? monthlyBranches
|
|
44
|
+
: weeklyBranches.length > 0
|
|
45
|
+
? weeklyBranches
|
|
46
|
+
: branches;
|
|
37
47
|
|
|
38
48
|
priorityBranches.sort().reverse();
|
|
39
|
-
return priorityBranches[0];
|
|
49
|
+
return priorityBranches[0] || null;
|
|
40
50
|
}
|
|
41
51
|
|
|
42
52
|
/**
|
|
@@ -135,9 +145,27 @@ export class GitOperations {
|
|
|
135
145
|
*/
|
|
136
146
|
async push(branch) {
|
|
137
147
|
try {
|
|
138
|
-
execSync(`git push -u origin ${branch}`, {
|
|
148
|
+
execSync(`git push -u origin ${branch}`, {
|
|
149
|
+
stdio: ['ignore', 'inherit', 'pipe'],
|
|
150
|
+
encoding: 'utf-8',
|
|
151
|
+
});
|
|
139
152
|
return true;
|
|
140
153
|
} catch (error) {
|
|
154
|
+
const errMsg = (error.stderr || error.message || '').toString();
|
|
155
|
+
const is403 = errMsg.includes('403') || errMsg.includes('Write access') || errMsg.includes('write access');
|
|
156
|
+
if (is403) {
|
|
157
|
+
throw new PRError(
|
|
158
|
+
'\u63a8送失敗:git 沒有寫入權限',
|
|
159
|
+
'GIT_PUSH_FORBIDDEN',
|
|
160
|
+
[
|
|
161
|
+
'建議執行以下指令讓 git 使用 gh 的認證:',
|
|
162
|
+
' gh auth setup-git',
|
|
163
|
+
'或者改用 SSH 權限:',
|
|
164
|
+
' git remote set-url origin git@github.com:<org>/<repo>.git',
|
|
165
|
+
],
|
|
166
|
+
`git push -u origin ${branch}`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
141
169
|
throw new PRError(
|
|
142
170
|
'推送失敗',
|
|
143
171
|
'GIT_PUSH_FAILED',
|