dsh-flakefinder 0.3.4 → 0.4.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/CHANGELOG.md +21 -0
- package/README.en.md +30 -41
- package/README.md +27 -95
- package/docs/USAGE.en.md +56 -0
- package/docs/USAGE.md +109 -0
- package/docs/VALIDATION.md +15 -0
- package/lib/config.d.ts +1 -0
- package/lib/config.js +2 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.js +3 -1
- package/lib/runner.d.ts +3 -1
- package/lib/runner.js +8 -4
- package/lib/store.js +85 -52
- package/lib/tools.js +18 -3
- package/lib/update-client.js +408 -0
- package/package.json +24 -7
package/lib/store.js
CHANGED
|
@@ -5,31 +5,67 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import fs from 'node:fs/promises';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import lockfile from 'proper-lockfile';
|
|
8
10
|
const HISTORY_FILE = 'history.json';
|
|
9
11
|
const HISTORY_LIMIT = 200;
|
|
10
12
|
export function createStore(dataDir, quarantineFile) {
|
|
11
13
|
const historyPath = path.join(dataDir, HISTORY_FILE);
|
|
14
|
+
async function readJson(file) {
|
|
15
|
+
try {
|
|
16
|
+
const info = await fs.lstat(file);
|
|
17
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > 8 * 1024 * 1024)
|
|
18
|
+
throw new Error('文件不是普通 JSON 文件或超过 8 MiB');
|
|
19
|
+
return JSON.parse(await fs.readFile(file, 'utf8'));
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error.code === 'ENOENT')
|
|
23
|
+
return undefined;
|
|
24
|
+
throw new Error('记录无法读取,原文件已保留:' + file + '。' + (error instanceof Error ? error.message : String(error)));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async function locked(file, update) {
|
|
28
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
29
|
+
const unlock = await lockfile.lock(file, { realpath: false, retries: { retries: 30, minTimeout: 25, maxTimeout: 100 } });
|
|
30
|
+
try {
|
|
31
|
+
return await update();
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
await unlock();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async function atomicWrite(file, value) {
|
|
38
|
+
const bytes = JSON.stringify(value, null, 2) + '\n';
|
|
39
|
+
if (Buffer.byteLength(bytes) > 8 * 1024 * 1024)
|
|
40
|
+
throw new Error('记录超过 8 MiB,本次没有保存。');
|
|
41
|
+
const temp = file + '.tmp-' + randomUUID();
|
|
42
|
+
try {
|
|
43
|
+
await fs.writeFile(temp, bytes, { flag: 'wx', mode: 0o600 });
|
|
44
|
+
await fs.rename(temp, file);
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
await fs.unlink(temp).catch(() => { });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
12
50
|
async function ensureDir() {
|
|
13
51
|
await fs.mkdir(dataDir, { recursive: true });
|
|
14
52
|
}
|
|
15
53
|
async function readHistory() {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const parsed = JSON.parse(text);
|
|
19
|
-
return Array.isArray(parsed) ? parsed : [];
|
|
20
|
-
}
|
|
21
|
-
catch {
|
|
54
|
+
const parsed = await readJson(historyPath);
|
|
55
|
+
if (parsed === undefined)
|
|
22
56
|
return [];
|
|
23
|
-
|
|
57
|
+
if (!Array.isArray(parsed) || parsed.some(entry => !entry || typeof entry.target !== 'string' || typeof entry.timestamp !== 'string' || !Array.isArray(entry.flakyTests)))
|
|
58
|
+
throw new Error('历史记录格式无效,原文件已保留:' + historyPath);
|
|
59
|
+
return parsed;
|
|
24
60
|
}
|
|
25
61
|
async function appendHistory(entry) {
|
|
26
62
|
await ensureDir();
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
63
|
+
return locked(historyPath, async () => {
|
|
64
|
+
const list = await readHistory();
|
|
65
|
+
list.unshift(entry);
|
|
66
|
+
const trimmed = list.slice(0, HISTORY_LIMIT);
|
|
67
|
+
await atomicWrite(historyPath, trimmed);
|
|
68
|
+
});
|
|
33
69
|
}
|
|
34
70
|
async function listHistory(target, limit) {
|
|
35
71
|
const list = await readHistory();
|
|
@@ -37,55 +73,52 @@ export function createStore(dataDir, quarantineFile) {
|
|
|
37
73
|
return filtered.slice(0, limit);
|
|
38
74
|
}
|
|
39
75
|
async function readQuarantine() {
|
|
40
|
-
|
|
41
|
-
|
|
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 {
|
|
76
|
+
const parsed = await readJson(quarantineFile);
|
|
77
|
+
if (parsed === undefined)
|
|
50
78
|
return { version: 1, quarantined: [] };
|
|
51
|
-
|
|
79
|
+
const obj = parsed;
|
|
80
|
+
if (!obj || obj.version !== 1 || !Array.isArray(obj.quarantined) || obj.quarantined.some(entry => !entry || typeof entry.file !== 'string' || !entry.file || entry.name !== null && typeof entry.name !== 'string' || typeof entry.reason !== 'string' || typeof entry.since !== 'string'))
|
|
81
|
+
throw new Error('隔离清单格式无效,原文件已保留:' + quarantineFile);
|
|
82
|
+
return obj;
|
|
52
83
|
}
|
|
53
84
|
async function writeQuarantine(doc) {
|
|
54
85
|
await fs.mkdir(path.dirname(quarantineFile), { recursive: true });
|
|
55
|
-
|
|
56
|
-
await fs.writeFile(tmp, JSON.stringify(doc, null, 2) + '\n', 'utf8');
|
|
57
|
-
await fs.rename(tmp, quarantineFile);
|
|
86
|
+
await atomicWrite(quarantineFile, doc);
|
|
58
87
|
}
|
|
59
88
|
async function addQuarantine(refs, reason) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
existing
|
|
67
|
-
|
|
68
|
-
|
|
89
|
+
return locked(quarantineFile, async () => {
|
|
90
|
+
const doc = await readQuarantine();
|
|
91
|
+
const now = new Date().toISOString();
|
|
92
|
+
const added = [];
|
|
93
|
+
for (const ref of refs) {
|
|
94
|
+
const existing = doc.quarantined.find(item => sameRef(item, ref));
|
|
95
|
+
if (existing !== undefined) {
|
|
96
|
+
existing.reason = reason;
|
|
97
|
+
existing.since = now;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const entry = { file: ref.file, name: ref.name, reason, since: now };
|
|
101
|
+
doc.quarantined.push(entry);
|
|
102
|
+
added.push(entry);
|
|
69
103
|
}
|
|
70
|
-
|
|
71
|
-
doc
|
|
72
|
-
added
|
|
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 };
|
|
104
|
+
doc.quarantined.sort((a, b) => a.file.localeCompare(b.file) || (a.name ?? '').localeCompare(b.name ?? ''));
|
|
105
|
+
await writeQuarantine(doc);
|
|
106
|
+
return { added, file: quarantineFile };
|
|
107
|
+
});
|
|
77
108
|
}
|
|
78
109
|
async function removeQuarantine(refs) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
110
|
+
return locked(quarantineFile, async () => {
|
|
111
|
+
const doc = await readQuarantine();
|
|
112
|
+
const removed = [];
|
|
113
|
+
doc.quarantined = doc.quarantined.filter(item => {
|
|
114
|
+
const hit = refs.some(ref => sameRef(item, ref));
|
|
115
|
+
if (hit)
|
|
116
|
+
removed.push(item);
|
|
117
|
+
return !hit;
|
|
118
|
+
});
|
|
119
|
+
await writeQuarantine(doc);
|
|
120
|
+
return { removed, file: quarantineFile };
|
|
86
121
|
});
|
|
87
|
-
await writeQuarantine(doc);
|
|
88
|
-
return { removed, file: quarantineFile };
|
|
89
122
|
}
|
|
90
123
|
return {
|
|
91
124
|
appendHistory,
|
package/lib/tools.js
CHANGED
|
@@ -8,7 +8,8 @@ import { buildPlan, readFramework } from './args.js';
|
|
|
8
8
|
import { aggregate, flakyTests } from './classify.js';
|
|
9
9
|
import { assertTarget, optionalInteger, optionalString, requiredString, resolveRuns, } from './config.js';
|
|
10
10
|
import { executePlan } from './runner.js';
|
|
11
|
-
import { formatRef, parseRef } from './store.js';
|
|
11
|
+
import { createStore, formatRef, parseRef } from './store.js';
|
|
12
|
+
import { resolve } from 'node:path';
|
|
12
13
|
function compileParameters(spec) {
|
|
13
14
|
const properties = {};
|
|
14
15
|
const required = [];
|
|
@@ -98,6 +99,18 @@ const detectSchema = {
|
|
|
98
99
|
};
|
|
99
100
|
/** 构建五个工具定义。 */
|
|
100
101
|
export function buildFlakeTools(cfg, runner, store) {
|
|
102
|
+
return buildWorkspaceTools(cfg, runner, store).map(tool => ({ ...tool, async execute(args, exec) {
|
|
103
|
+
const context = exec;
|
|
104
|
+
const signal = context?.signal;
|
|
105
|
+
signal?.throwIfAborted();
|
|
106
|
+
const cwd = context?.agent?.session?.header?.cwd || process.cwd();
|
|
107
|
+
const scopedStore = context?.agent?.session?.header?.cwd && cfg.quarantineFileRelative ? createStore(cfg.dataDir, resolve(cwd, cfg.quarantineFileRelative)) : store;
|
|
108
|
+
const scopedRunner = { run: (argv, options) => runner.run(argv, { ...options, cwd, signal }) };
|
|
109
|
+
const selected = buildWorkspaceTools(cfg, scopedRunner, scopedStore, cwd, signal).find(row => row.name === tool.name);
|
|
110
|
+
return selected.execute(args, exec);
|
|
111
|
+
} }));
|
|
112
|
+
}
|
|
113
|
+
function buildWorkspaceTools(cfg, runner, store, cwd = process.cwd(), signal) {
|
|
101
114
|
const detect = {
|
|
102
115
|
name: 'flaky_detect',
|
|
103
116
|
description: '重复运行测试多次并判定稳定性:stable-pass(全过)/ stable-fail(全挂)/ flaky(时好时坏)。支持 vitest / jest / pytest / node:test,自动探测本地框架。结果写入历史供 flaky_report 分析。',
|
|
@@ -119,11 +132,12 @@ export function buildFlakeTools(cfg, runner, store) {
|
|
|
119
132
|
const target = assertTarget(requiredString(args, 'target', '测试目标'));
|
|
120
133
|
const requested = readFramework(args.framework);
|
|
121
134
|
const runs = resolveRuns(args.runs, cfg);
|
|
122
|
-
const plans = Array.from({ length: runs }, (_, index) => buildPlan(
|
|
135
|
+
const plans = Array.from({ length: runs }, (_, index) => buildPlan(cwd, target, requested, index, cfg.pythonPath));
|
|
123
136
|
const framework = plans[0]?.framework ?? 'node';
|
|
124
137
|
const executed = [];
|
|
125
138
|
for (let index = 0; index < plans.length; index += 1) {
|
|
126
|
-
|
|
139
|
+
signal?.throwIfAborted();
|
|
140
|
+
executed.push(await executePlan(runner, plans[index], index + 1, cfg.timeoutMs, cwd, signal));
|
|
127
141
|
}
|
|
128
142
|
const result = aggregate(executed.map(item => ({ index: item.index, report: item.report, durationMs: item.durationMs, error: item.error })));
|
|
129
143
|
const flaky = flakyTests(result);
|
|
@@ -140,6 +154,7 @@ export function buildFlakeTools(cfg, runner, store) {
|
|
|
140
154
|
skippedCount: result.skippedCount,
|
|
141
155
|
flakyTests: flaky,
|
|
142
156
|
};
|
|
157
|
+
signal?.throwIfAborted();
|
|
143
158
|
await store.appendHistory(entry);
|
|
144
159
|
return {
|
|
145
160
|
target,
|