dsh-flakefinder 0.1.0
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/README.en.md +50 -0
- package/README.md +101 -0
- package/cordis.patch.yml +19 -0
- package/lib/args.d.ts +21 -0
- package/lib/args.js +81 -0
- package/lib/classify.d.ts +48 -0
- package/lib/classify.js +103 -0
- package/lib/config.d.ts +44 -0
- package/lib/config.js +102 -0
- package/lib/index.d.ts +38 -0
- package/lib/index.js +56 -0
- package/lib/parse.d.ts +27 -0
- package/lib/parse.js +105 -0
- package/lib/runner.d.ts +66 -0
- package/lib/runner.js +98 -0
- package/lib/store.d.ts +58 -0
- package/lib/store.js +123 -0
- package/lib/tools.d.ts +31 -0
- package/lib/tools.js +332 -0
- package/package.json +60 -0
package/lib/parse.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 测试报告解析:vitest / jest 的 JSON reporter 与 node:test 的 TAP 输出统一成
|
|
3
|
+
* 一次运行的扁平用例列表。
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-flakefinder/parse
|
|
6
|
+
*/
|
|
7
|
+
export type TestStatus = 'passed' | 'failed' | 'skipped';
|
|
8
|
+
export interface NormalizedTest {
|
|
9
|
+
id: string;
|
|
10
|
+
file: string;
|
|
11
|
+
name: string;
|
|
12
|
+
status: TestStatus;
|
|
13
|
+
durationMs: number;
|
|
14
|
+
}
|
|
15
|
+
export interface NormalizedRun {
|
|
16
|
+
framework: string;
|
|
17
|
+
success: boolean;
|
|
18
|
+
total: number;
|
|
19
|
+
passed: number;
|
|
20
|
+
failed: number;
|
|
21
|
+
skipped: number;
|
|
22
|
+
tests: NormalizedTest[];
|
|
23
|
+
}
|
|
24
|
+
/** vitest 与 jest 的 JSON 结构高度接近,这里统一读取。 */
|
|
25
|
+
export declare function parseJsonReport(text: string, framework: string): NormalizedRun;
|
|
26
|
+
/** 解析 node:test 的 TAP 输出;子测试行忽略,只保留顶层用例。 */
|
|
27
|
+
export declare function parseTapReport(text: string): NormalizedRun;
|
package/lib/parse.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 测试报告解析:vitest / jest 的 JSON reporter 与 node:test 的 TAP 输出统一成
|
|
3
|
+
* 一次运行的扁平用例列表。
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-flakefinder/parse
|
|
6
|
+
*/
|
|
7
|
+
function asNumber(value) {
|
|
8
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
9
|
+
}
|
|
10
|
+
function normalizeStatus(value) {
|
|
11
|
+
if (value === 'passed')
|
|
12
|
+
return 'passed';
|
|
13
|
+
if (value === 'failed')
|
|
14
|
+
return 'failed';
|
|
15
|
+
if (value === 'pending' || value === 'skipped' || value === 'todo' || value === 'disabled')
|
|
16
|
+
return 'skipped';
|
|
17
|
+
return 'failed';
|
|
18
|
+
}
|
|
19
|
+
/** vitest 与 jest 的 JSON 结构高度接近,这里统一读取。 */
|
|
20
|
+
export function parseJsonReport(text, framework) {
|
|
21
|
+
let root;
|
|
22
|
+
try {
|
|
23
|
+
root = JSON.parse(text);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new Error(framework + ' 报告不是合法 JSON。请确认项目安装的是支持 --reporter=json 的版本。');
|
|
27
|
+
}
|
|
28
|
+
const obj = (root ?? {});
|
|
29
|
+
const suites = Array.isArray(obj.testResults) ? obj.testResults : [];
|
|
30
|
+
const tests = [];
|
|
31
|
+
for (const suite of suites) {
|
|
32
|
+
const file = typeof suite.name === 'string' && suite.name !== '' ? suite.name : '(unknown)';
|
|
33
|
+
const assertions = Array.isArray(suite.assertionResults) ? suite.assertionResults : [];
|
|
34
|
+
if (assertions.length > 0) {
|
|
35
|
+
for (const item of assertions) {
|
|
36
|
+
const fullName = typeof item.fullName === 'string' && item.fullName !== ''
|
|
37
|
+
? item.fullName
|
|
38
|
+
: (typeof item.title === 'string' ? item.title : '(unnamed)');
|
|
39
|
+
const status = normalizeStatus(item.status);
|
|
40
|
+
tests.push({
|
|
41
|
+
id: file + ' > ' + fullName,
|
|
42
|
+
file,
|
|
43
|
+
name: fullName,
|
|
44
|
+
status,
|
|
45
|
+
durationMs: Math.max(0, asNumber(item.duration) ?? 0),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
const status = normalizeStatus(suite.status);
|
|
51
|
+
tests.push({
|
|
52
|
+
id: file + ' > (suite)',
|
|
53
|
+
file,
|
|
54
|
+
name: '(suite)',
|
|
55
|
+
status,
|
|
56
|
+
durationMs: 0,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
framework,
|
|
62
|
+
success: obj.success === true,
|
|
63
|
+
total: reportedCount(obj.numTotalTests, tests.length),
|
|
64
|
+
passed: reportedCount(obj.numPassedTests, tests.filter(test => test.status === 'passed').length),
|
|
65
|
+
failed: reportedCount(obj.numFailedTests, tests.filter(test => test.status === 'failed').length),
|
|
66
|
+
skipped: reportedCount(obj.numPendingTests, tests.filter(test => test.status === 'skipped').length),
|
|
67
|
+
tests,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function reportedCount(reported, fallback) {
|
|
71
|
+
if (typeof reported === 'number' && Number.isFinite(reported))
|
|
72
|
+
return Math.round(reported);
|
|
73
|
+
return fallback;
|
|
74
|
+
}
|
|
75
|
+
/** 解析 node:test 的 TAP 输出;子测试行忽略,只保留顶层用例。 */
|
|
76
|
+
export function parseTapReport(text) {
|
|
77
|
+
const tests = [];
|
|
78
|
+
const lines = text.split(/\r?\n/);
|
|
79
|
+
let index = 0;
|
|
80
|
+
for (const line of lines) {
|
|
81
|
+
const match = /^(not ok|ok)\s+(\d+)\s+-\s*(.+?)\s*$/.exec(line.trim());
|
|
82
|
+
if (match === null)
|
|
83
|
+
continue;
|
|
84
|
+
const failed = match[1] === 'not ok';
|
|
85
|
+
index += 1;
|
|
86
|
+
const name = (match[3] ?? '(unnamed)').replace(/\s+#.*$/, '');
|
|
87
|
+
tests.push({
|
|
88
|
+
id: 'node:' + String(index) + ' > ' + name,
|
|
89
|
+
file: '(node:test)',
|
|
90
|
+
name,
|
|
91
|
+
status: failed ? 'failed' : 'passed',
|
|
92
|
+
durationMs: 0,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
const failed = tests.filter(item => item.status === 'failed').length;
|
|
96
|
+
return {
|
|
97
|
+
framework: 'node',
|
|
98
|
+
success: failed === 0 && tests.length > 0,
|
|
99
|
+
total: tests.length,
|
|
100
|
+
passed: tests.length - failed,
|
|
101
|
+
failed,
|
|
102
|
+
skipped: 0,
|
|
103
|
+
tests,
|
|
104
|
+
};
|
|
105
|
+
}
|
package/lib/runner.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 进程执行层:复用 dsh-docker 同款 subprocess 包装,并按计划读取 JSON 报告。
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-flakefinder/runner
|
|
5
|
+
*/
|
|
6
|
+
import type { TestPlan } from './args.js';
|
|
7
|
+
import { type NormalizedRun } from './parse.js';
|
|
8
|
+
export interface RunResult {
|
|
9
|
+
exitCode: number | null;
|
|
10
|
+
signal: string | null;
|
|
11
|
+
stdout: string;
|
|
12
|
+
stderr: string;
|
|
13
|
+
}
|
|
14
|
+
export interface ProcessRunner {
|
|
15
|
+
run(argv: readonly string[], options?: {
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
}): Promise<RunResult>;
|
|
18
|
+
}
|
|
19
|
+
export interface SubprocessHandleLike {
|
|
20
|
+
done: Promise<{
|
|
21
|
+
exitCode: number | null;
|
|
22
|
+
signal: string | null;
|
|
23
|
+
}>;
|
|
24
|
+
collected: {
|
|
25
|
+
stdout?: {
|
|
26
|
+
readFrom(offset: number): {
|
|
27
|
+
text: string;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
stderr?: {
|
|
31
|
+
readFrom(offset: number): {
|
|
32
|
+
text: string;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
terminate(): void;
|
|
37
|
+
}
|
|
38
|
+
export interface SubprocessSpawnLike {
|
|
39
|
+
(spec: {
|
|
40
|
+
argv: readonly string[];
|
|
41
|
+
cwd: string;
|
|
42
|
+
stdio: {
|
|
43
|
+
stdin: 'ignore';
|
|
44
|
+
stdout: {
|
|
45
|
+
maxBytes: number;
|
|
46
|
+
};
|
|
47
|
+
stderr: {
|
|
48
|
+
maxBytes: number;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
graceMs: number;
|
|
52
|
+
signal?: AbortSignal;
|
|
53
|
+
}): SubprocessHandleLike;
|
|
54
|
+
}
|
|
55
|
+
/** 用 DSH subprocess 服务构造 ProcessRunner。 */
|
|
56
|
+
export declare function createSubprocessRunner(spawn: SubprocessSpawnLike, graceMs: number, defaultTimeoutMs: number): ProcessRunner;
|
|
57
|
+
export interface FlakeRun {
|
|
58
|
+
index: number;
|
|
59
|
+
report: NormalizedRun | null;
|
|
60
|
+
durationMs: number;
|
|
61
|
+
exitCode: number | null;
|
|
62
|
+
stderr: string;
|
|
63
|
+
error?: string;
|
|
64
|
+
}
|
|
65
|
+
/** 执行一个测试计划并读取报告;进程失败但报告存在时仍返回报告。 */
|
|
66
|
+
export declare function executePlan(runner: ProcessRunner, plan: TestPlan, index: number, timeoutMs: number): Promise<FlakeRun>;
|
package/lib/runner.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 进程执行层:复用 dsh-docker 同款 subprocess 包装,并按计划读取 JSON 报告。
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-flakefinder/runner
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs/promises';
|
|
7
|
+
import { parseJsonReport, parseTapReport } from './parse.js';
|
|
8
|
+
const COLLECT_BYTES = 4 * 1024 * 1024;
|
|
9
|
+
/** 用 DSH subprocess 服务构造 ProcessRunner。 */
|
|
10
|
+
export function createSubprocessRunner(spawn, graceMs, defaultTimeoutMs) {
|
|
11
|
+
return {
|
|
12
|
+
async run(argv, options) {
|
|
13
|
+
const timeoutMs = options?.timeoutMs ?? defaultTimeoutMs;
|
|
14
|
+
const controller = new AbortController();
|
|
15
|
+
const timer = setTimeout(() => controller.abort(new Error('flakefinder test run timed out')), timeoutMs);
|
|
16
|
+
let handle;
|
|
17
|
+
try {
|
|
18
|
+
handle = spawn({
|
|
19
|
+
argv,
|
|
20
|
+
cwd: process.cwd(),
|
|
21
|
+
stdio: {
|
|
22
|
+
stdin: 'ignore',
|
|
23
|
+
stdout: { maxBytes: COLLECT_BYTES },
|
|
24
|
+
stderr: { maxBytes: COLLECT_BYTES },
|
|
25
|
+
},
|
|
26
|
+
graceMs,
|
|
27
|
+
signal: controller.signal,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
}
|
|
33
|
+
const outcome = await handle.done;
|
|
34
|
+
const stdout = handle.collected.stdout?.readFrom(0).text ?? '';
|
|
35
|
+
const stderr = handle.collected.stderr?.readFrom(0).text ?? '';
|
|
36
|
+
return { exitCode: outcome.exitCode, signal: outcome.signal, stdout, stderr };
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function stderrTail(stderr) {
|
|
41
|
+
const tail = stderr.trim().split(/\r?\n/).slice(-5).join(' | ');
|
|
42
|
+
return tail || '无错误输出';
|
|
43
|
+
}
|
|
44
|
+
/** 执行一个测试计划并读取报告;进程失败但报告存在时仍返回报告。 */
|
|
45
|
+
export async function executePlan(runner, plan, index, timeoutMs) {
|
|
46
|
+
const started = Date.now();
|
|
47
|
+
const result = await runner.run(plan.argv, { timeoutMs });
|
|
48
|
+
const durationMs = Date.now() - started;
|
|
49
|
+
if (plan.reportKind === 'tap') {
|
|
50
|
+
return {
|
|
51
|
+
index,
|
|
52
|
+
report: parseTapReport(result.stdout),
|
|
53
|
+
durationMs,
|
|
54
|
+
exitCode: result.exitCode,
|
|
55
|
+
stderr: result.stderr,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
let fileText = '';
|
|
59
|
+
let fileError = '';
|
|
60
|
+
try {
|
|
61
|
+
fileText = await fs.readFile(plan.reportPath, 'utf8');
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
fileError = error instanceof Error ? error.message : String(error);
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await fs.rm(plan.reportPath, { force: true }).catch(() => { });
|
|
68
|
+
}
|
|
69
|
+
if (fileText.trim() === '') {
|
|
70
|
+
return {
|
|
71
|
+
index,
|
|
72
|
+
report: null,
|
|
73
|
+
durationMs,
|
|
74
|
+
exitCode: result.exitCode,
|
|
75
|
+
stderr: result.stderr,
|
|
76
|
+
error: plan.framework + ' 没有写出 JSON 报告(' + fileError + ')。进程退出码:' + String(result.exitCode ?? 'null') + ';stderr:' + stderrTail(result.stderr),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
return {
|
|
81
|
+
index,
|
|
82
|
+
report: parseJsonReport(fileText, plan.framework),
|
|
83
|
+
durationMs,
|
|
84
|
+
exitCode: result.exitCode,
|
|
85
|
+
stderr: result.stderr,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
return {
|
|
90
|
+
index,
|
|
91
|
+
report: null,
|
|
92
|
+
durationMs,
|
|
93
|
+
exitCode: result.exitCode,
|
|
94
|
+
stderr: result.stderr,
|
|
95
|
+
error: error instanceof Error ? error.message : String(error),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
package/lib/store.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 历史与隔离清单存储:JSON 文件、原子写入、零运行时依赖。
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-flakefinder/store
|
|
5
|
+
*/
|
|
6
|
+
export interface HistoryEntry {
|
|
7
|
+
timestamp: string;
|
|
8
|
+
target: string;
|
|
9
|
+
framework: string;
|
|
10
|
+
runs: number;
|
|
11
|
+
durationMs: number;
|
|
12
|
+
verdict: string;
|
|
13
|
+
stablePassCount: number;
|
|
14
|
+
stableFailCount: number;
|
|
15
|
+
flakyCount: number;
|
|
16
|
+
skippedCount: number;
|
|
17
|
+
flakyTests: Array<{
|
|
18
|
+
file: string;
|
|
19
|
+
name: string;
|
|
20
|
+
failureRate: number;
|
|
21
|
+
}>;
|
|
22
|
+
}
|
|
23
|
+
export interface QuarantineEntry {
|
|
24
|
+
file: string;
|
|
25
|
+
name: string | null;
|
|
26
|
+
reason: string;
|
|
27
|
+
since: string;
|
|
28
|
+
}
|
|
29
|
+
export interface QuarantineDocument {
|
|
30
|
+
version: 1;
|
|
31
|
+
quarantined: QuarantineEntry[];
|
|
32
|
+
}
|
|
33
|
+
export interface Store {
|
|
34
|
+
appendHistory(entry: HistoryEntry): Promise<void>;
|
|
35
|
+
listHistory(target: string | undefined, limit: number): Promise<HistoryEntry[]>;
|
|
36
|
+
loadQuarantine(): Promise<QuarantineDocument>;
|
|
37
|
+
addQuarantine(refs: ParsedRef[], reason: string): Promise<{
|
|
38
|
+
added: QuarantineEntry[];
|
|
39
|
+
file: string;
|
|
40
|
+
}>;
|
|
41
|
+
removeQuarantine(refs: ParsedRef[]): Promise<{
|
|
42
|
+
removed: QuarantineEntry[];
|
|
43
|
+
file: string;
|
|
44
|
+
}>;
|
|
45
|
+
quarantinePath: string;
|
|
46
|
+
}
|
|
47
|
+
export interface ParsedRef {
|
|
48
|
+
file: string;
|
|
49
|
+
name: string | null;
|
|
50
|
+
}
|
|
51
|
+
export declare function createStore(dataDir: string, quarantineFile: string): Store;
|
|
52
|
+
/** 解析 "file.test.mjs" 或 "file.test.mjs > 用例名" 形式的引用。 */
|
|
53
|
+
export declare function parseRef(raw: string): ParsedRef;
|
|
54
|
+
/** 隔离清单条目的可读格式。 */
|
|
55
|
+
export declare function formatRef(entry: {
|
|
56
|
+
file: string;
|
|
57
|
+
name: string | null;
|
|
58
|
+
}): string;
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 历史与隔离清单存储:JSON 文件、原子写入、零运行时依赖。
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-flakefinder/store
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs/promises';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
const HISTORY_FILE = 'history.json';
|
|
9
|
+
const HISTORY_LIMIT = 200;
|
|
10
|
+
export function createStore(dataDir, quarantineFile) {
|
|
11
|
+
const historyPath = path.join(dataDir, HISTORY_FILE);
|
|
12
|
+
async function ensureDir() {
|
|
13
|
+
await fs.mkdir(dataDir, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
async function readHistory() {
|
|
16
|
+
try {
|
|
17
|
+
const text = await fs.readFile(historyPath, 'utf8');
|
|
18
|
+
const parsed = JSON.parse(text);
|
|
19
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function appendHistory(entry) {
|
|
26
|
+
await ensureDir();
|
|
27
|
+
const list = await readHistory();
|
|
28
|
+
list.unshift(entry);
|
|
29
|
+
const trimmed = list.slice(0, HISTORY_LIMIT);
|
|
30
|
+
const tmp = historyPath + '.tmp-' + process.pid;
|
|
31
|
+
await fs.writeFile(tmp, JSON.stringify(trimmed, null, 2), 'utf8');
|
|
32
|
+
await fs.rename(tmp, historyPath);
|
|
33
|
+
}
|
|
34
|
+
async function listHistory(target, limit) {
|
|
35
|
+
const list = await readHistory();
|
|
36
|
+
const filtered = target === undefined ? list : list.filter(entry => entry.target === target || entry.target.includes(target));
|
|
37
|
+
return filtered.slice(0, limit);
|
|
38
|
+
}
|
|
39
|
+
async function readQuarantine() {
|
|
40
|
+
try {
|
|
41
|
+
const text = await fs.readFile(quarantineFile, 'utf8');
|
|
42
|
+
const parsed = JSON.parse(text);
|
|
43
|
+
const obj = (parsed ?? {});
|
|
44
|
+
return {
|
|
45
|
+
version: 1,
|
|
46
|
+
quarantined: Array.isArray(obj.quarantined) ? obj.quarantined : [],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return { version: 1, quarantined: [] };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function writeQuarantine(doc) {
|
|
54
|
+
await fs.mkdir(path.dirname(quarantineFile), { recursive: true });
|
|
55
|
+
const tmp = quarantineFile + '.tmp-' + process.pid;
|
|
56
|
+
await fs.writeFile(tmp, JSON.stringify(doc, null, 2) + '\n', 'utf8');
|
|
57
|
+
await fs.rename(tmp, quarantineFile);
|
|
58
|
+
}
|
|
59
|
+
async function addQuarantine(refs, reason) {
|
|
60
|
+
const doc = await readQuarantine();
|
|
61
|
+
const now = new Date().toISOString();
|
|
62
|
+
const added = [];
|
|
63
|
+
for (const ref of refs) {
|
|
64
|
+
const existing = doc.quarantined.find(item => sameRef(item, ref));
|
|
65
|
+
if (existing !== undefined) {
|
|
66
|
+
existing.reason = reason;
|
|
67
|
+
existing.since = now;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const entry = { file: ref.file, name: ref.name, reason, since: now };
|
|
71
|
+
doc.quarantined.push(entry);
|
|
72
|
+
added.push(entry);
|
|
73
|
+
}
|
|
74
|
+
doc.quarantined.sort((a, b) => a.file.localeCompare(b.file) || (a.name ?? '').localeCompare(b.name ?? ''));
|
|
75
|
+
await writeQuarantine(doc);
|
|
76
|
+
return { added, file: quarantineFile };
|
|
77
|
+
}
|
|
78
|
+
async function removeQuarantine(refs) {
|
|
79
|
+
const doc = await readQuarantine();
|
|
80
|
+
const removed = [];
|
|
81
|
+
doc.quarantined = doc.quarantined.filter(item => {
|
|
82
|
+
const hit = refs.some(ref => sameRef(item, ref));
|
|
83
|
+
if (hit)
|
|
84
|
+
removed.push(item);
|
|
85
|
+
return !hit;
|
|
86
|
+
});
|
|
87
|
+
await writeQuarantine(doc);
|
|
88
|
+
return { removed, file: quarantineFile };
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
appendHistory,
|
|
92
|
+
listHistory,
|
|
93
|
+
loadQuarantine: readQuarantine,
|
|
94
|
+
addQuarantine,
|
|
95
|
+
removeQuarantine,
|
|
96
|
+
quarantinePath: quarantineFile,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function sameRef(item, ref) {
|
|
100
|
+
if (item.file !== ref.file)
|
|
101
|
+
return false;
|
|
102
|
+
if (ref.name === null)
|
|
103
|
+
return true;
|
|
104
|
+
return item.name === ref.name;
|
|
105
|
+
}
|
|
106
|
+
/** 解析 "file.test.mjs" 或 "file.test.mjs > 用例名" 形式的引用。 */
|
|
107
|
+
export function parseRef(raw) {
|
|
108
|
+
const trimmed = raw.trim();
|
|
109
|
+
if (trimmed === '')
|
|
110
|
+
throw new Error('测试引用不能为空。格式:文件路径,或 "文件路径 > 用例名"。');
|
|
111
|
+
const index = trimmed.indexOf(' > ');
|
|
112
|
+
if (index === -1)
|
|
113
|
+
return { file: trimmed, name: null };
|
|
114
|
+
const file = trimmed.slice(0, index).trim();
|
|
115
|
+
const name = trimmed.slice(index + 3).trim();
|
|
116
|
+
if (file === '')
|
|
117
|
+
throw new Error('测试引用缺少文件路径:' + raw);
|
|
118
|
+
return { file, name: name === '' ? null : name };
|
|
119
|
+
}
|
|
120
|
+
/** 隔离清单条目的可读格式。 */
|
|
121
|
+
export function formatRef(entry) {
|
|
122
|
+
return entry.name === null ? entry.file : entry.file + ' > ' + entry.name;
|
|
123
|
+
}
|
package/lib/tools.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 五个面向模型的测试稳定性工具:
|
|
3
|
+
* flaky_detect / flaky_history / flaky_quarantine / flaky_clear / flaky_report。
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-flakefinder/tools
|
|
6
|
+
*/
|
|
7
|
+
import { type ResolvedFlakeConfig } from './config.js';
|
|
8
|
+
import { type ProcessRunner } from './runner.js';
|
|
9
|
+
import { type Store } from './store.js';
|
|
10
|
+
export interface ContentBlock {
|
|
11
|
+
type: 'text';
|
|
12
|
+
text: string;
|
|
13
|
+
}
|
|
14
|
+
export interface FlakeToolDefinition {
|
|
15
|
+
name: string;
|
|
16
|
+
description: string;
|
|
17
|
+
parameters: {
|
|
18
|
+
type: 'object';
|
|
19
|
+
properties: Record<string, unknown>;
|
|
20
|
+
required?: string[];
|
|
21
|
+
};
|
|
22
|
+
output: {
|
|
23
|
+
schema: Record<string, unknown>;
|
|
24
|
+
render(args: unknown, value: unknown): ContentBlock[];
|
|
25
|
+
};
|
|
26
|
+
execute(args: unknown, exec: unknown): Promise<unknown>;
|
|
27
|
+
gate?(exec: unknown, next: () => Promise<unknown>): Promise<unknown>;
|
|
28
|
+
timeoutMs?: number;
|
|
29
|
+
}
|
|
30
|
+
/** 构建五个工具定义。 */
|
|
31
|
+
export declare function buildFlakeTools(cfg: ResolvedFlakeConfig, runner: ProcessRunner, store: Store): FlakeToolDefinition[];
|