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/lib/tools.js ADDED
@@ -0,0 +1,332 @@
1
+ /**
2
+ * 五个面向模型的测试稳定性工具:
3
+ * flaky_detect / flaky_history / flaky_quarantine / flaky_clear / flaky_report。
4
+ *
5
+ * @module dsh-flakefinder/tools
6
+ */
7
+ import { buildPlan, readFramework } from './args.js';
8
+ import { aggregate, flakyTests } from './classify.js';
9
+ import { assertTarget, optionalInteger, optionalString, requiredString, resolveRuns, } from './config.js';
10
+ import { executePlan } from './runner.js';
11
+ import { formatRef, parseRef } from './store.js';
12
+ function compileParameters(spec) {
13
+ const properties = {};
14
+ const required = [];
15
+ for (const [key, prop] of Object.entries(spec)) {
16
+ if (prop?.required === true)
17
+ required.push(key);
18
+ const node = {};
19
+ if (typeof prop?.type === 'string')
20
+ node.type = prop.type;
21
+ if (typeof prop?.description === 'string')
22
+ node.description = prop.description;
23
+ if (typeof prop?.items === 'string')
24
+ node.items = { type: prop.items };
25
+ if (prop?.minItems !== undefined)
26
+ node.minItems = prop.minItems;
27
+ if (prop?.maxItems !== undefined)
28
+ node.maxItems = prop.maxItems;
29
+ properties[key] = node;
30
+ }
31
+ return { type: 'object', properties, ...(required.length > 0 ? { required } : {}) };
32
+ }
33
+ function asRecord(value) {
34
+ return typeof value === 'object' && value !== null ? value : {};
35
+ }
36
+ function parseRefList(args) {
37
+ const raw = args.tests;
38
+ if (!Array.isArray(raw) || raw.length === 0)
39
+ throw new Error('tests 为必填数组,至少包含一个测试引用。');
40
+ if (raw.length > 100)
41
+ throw new Error('tests 一次最多 100 条,请分批处理。');
42
+ return raw.map(item => {
43
+ if (typeof item !== 'string')
44
+ throw new Error('tests 数组里的每一项必须是字符串,格式:文件路径,或 "文件路径 > 用例名"。');
45
+ return parseRef(item);
46
+ });
47
+ }
48
+ function verdictLabel(verdict) {
49
+ switch (verdict) {
50
+ case 'stable-pass': return '稳定通过';
51
+ case 'stable-fail': return '稳定失败';
52
+ case 'flaky': return '不稳定(flaky)';
53
+ case 'skipped': return '全部跳过';
54
+ case 'empty': return '空结果';
55
+ default: return verdict;
56
+ }
57
+ }
58
+ function resultSummary(result) {
59
+ const lines = [
60
+ '判定:' + verdictLabel(result.verdict),
61
+ '重复运行:' + String(result.runs.length) + ' 次;总耗时:' + String(Math.round(result.durationMs / 100) / 10) + ' 秒',
62
+ '稳定通过:' + String(result.stablePassCount) + ';稳定失败:' + String(result.stableFailCount) + ';flaky:' + String(result.flakyCount) + ';跳过:' + String(result.skippedCount),
63
+ ];
64
+ if (result.flakyCount > 0) {
65
+ lines.push('flaky 用例:');
66
+ for (const test of result.tests.filter(item => item.verdict === 'flaky')) {
67
+ lines.push('- ' + formatRef(test) + '(失败率 ' + String(test.failureRate) + '%)');
68
+ }
69
+ }
70
+ return lines.join('\n');
71
+ }
72
+ const testSchema = {
73
+ type: 'object',
74
+ properties: {
75
+ id: { type: 'string' },
76
+ file: { type: 'string' },
77
+ name: { type: 'string' },
78
+ passes: { type: 'integer' },
79
+ failures: { type: 'integer' },
80
+ failureRate: { type: 'number' },
81
+ verdict: { type: 'string' },
82
+ },
83
+ additionalProperties: true,
84
+ };
85
+ const detectSchema = {
86
+ type: 'object',
87
+ properties: {
88
+ target: { type: 'string' },
89
+ framework: { type: 'string' },
90
+ runs: { type: 'integer' },
91
+ verdict: { type: 'string' },
92
+ stablePassCount: { type: 'integer' },
93
+ stableFailCount: { type: 'integer' },
94
+ flakyCount: { type: 'integer' },
95
+ skippedCount: { type: 'integer' },
96
+ durationMs: { type: 'integer' },
97
+ runResults: { type: 'array', items: { type: 'object', additionalProperties: true } },
98
+ tests: { type: 'array', items: testSchema },
99
+ flakyTests: { type: 'array', items: { type: 'object', additionalProperties: true } },
100
+ },
101
+ additionalProperties: true,
102
+ };
103
+ /** 构建五个工具定义。 */
104
+ export function buildFlakeTools(cfg, runner, store) {
105
+ const detect = {
106
+ name: 'flaky_detect',
107
+ description: '重复运行测试多次并判定稳定性:stable-pass(全过)/ stable-fail(全挂)/ flaky(时好时坏)。支持 vitest / jest / node:test,自动探测本地框架。结果写入历史供 flaky_report 分析。',
108
+ parameters: compileParameters({
109
+ target: { type: 'string', required: true, description: '测试文件、目录或 glob(相对当前工作区)。不能以 - 开头。' },
110
+ framework: { type: 'string', description: 'auto(默认,自动探测 vitest→jest→node:test)/ vitest / jest / node。' },
111
+ runs: { type: 'integer', description: '重复次数,默认取配置 defaultRuns。' },
112
+ }),
113
+ output: {
114
+ schema: detectSchema,
115
+ render: (_args, value) => {
116
+ const rec = asRecord(value);
117
+ const summary = typeof rec.summary === 'string' ? rec.summary : '';
118
+ return [{ type: 'text', text: summary === '' ? '检测完成。' : summary }];
119
+ },
120
+ },
121
+ async execute(rawArgs) {
122
+ const args = asRecord(rawArgs);
123
+ const target = assertTarget(requiredString(args, 'target', '测试目标'));
124
+ const requested = readFramework(args.framework);
125
+ const runs = resolveRuns(args.runs, cfg);
126
+ const plans = Array.from({ length: runs }, (_, index) => buildPlan(process.cwd(), target, requested, index));
127
+ const framework = plans[0]?.framework ?? 'node';
128
+ const executed = [];
129
+ for (let index = 0; index < plans.length; index += 1) {
130
+ executed.push(await executePlan(runner, plans[index], index + 1, cfg.timeoutMs));
131
+ }
132
+ const result = aggregate(executed.map(item => ({ index: item.index, report: item.report, durationMs: item.durationMs, error: item.error })));
133
+ const flaky = flakyTests(result);
134
+ const entry = {
135
+ timestamp: new Date().toISOString(),
136
+ target,
137
+ framework,
138
+ runs: result.runs.length,
139
+ durationMs: result.durationMs,
140
+ verdict: result.verdict,
141
+ stablePassCount: result.stablePassCount,
142
+ stableFailCount: result.stableFailCount,
143
+ flakyCount: result.flakyCount,
144
+ skippedCount: result.skippedCount,
145
+ flakyTests: flaky,
146
+ };
147
+ await store.appendHistory(entry);
148
+ return {
149
+ target,
150
+ framework,
151
+ runs: result.runs.length,
152
+ verdict: result.verdict,
153
+ stablePassCount: result.stablePassCount,
154
+ stableFailCount: result.stableFailCount,
155
+ flakyCount: result.flakyCount,
156
+ skippedCount: result.skippedCount,
157
+ durationMs: result.durationMs,
158
+ runResults: result.runs,
159
+ tests: result.tests,
160
+ flakyTests: flaky,
161
+ summary: resultSummary(result),
162
+ };
163
+ },
164
+ timeoutMs: runsTimeout(cfg),
165
+ };
166
+ const history = {
167
+ name: 'flaky_history',
168
+ description: '查询 flaky_detect 的历史记录,可按测试目标过滤,用于判断某个测试是长期稳定失败还是偶发问题。',
169
+ parameters: compileParameters({
170
+ target: { type: 'string', description: '按测试目标过滤(可选,子串匹配)。' },
171
+ limit: { type: 'integer', description: '返回条数 1-100(默认 20)。' },
172
+ }),
173
+ output: {
174
+ schema: {
175
+ type: 'object',
176
+ properties: { count: { type: 'integer' }, entries: { type: 'array', items: { type: 'object', additionalProperties: true } } },
177
+ additionalProperties: true,
178
+ },
179
+ render: (_args, value) => {
180
+ const rec = asRecord(value);
181
+ const entries = Array.isArray(rec.entries) ? rec.entries : [];
182
+ const lines = ['历史记录 ' + String(entries.length) + ' 条:'];
183
+ for (const item of entries) {
184
+ const row = asRecord(item);
185
+ lines.push('- ' + row.target + '(' + row.framework + ',' + verdictLabel(String(row.verdict ?? '')) + ',' + String(row.runs ?? '?') + ' 次,' + row.timestamp + ')');
186
+ }
187
+ return [{ type: 'text', text: lines.join('\n') }];
188
+ },
189
+ },
190
+ async execute(rawArgs) {
191
+ const args = asRecord(rawArgs);
192
+ const target = optionalString(args, 'target');
193
+ const limit = optionalInteger(args, 'limit', '返回条数', 1, 100, 20);
194
+ const entries = await store.listHistory(target, limit);
195
+ return { count: entries.length, entries };
196
+ },
197
+ timeoutMs: 10000,
198
+ };
199
+ const quarantine = {
200
+ name: 'flaky_quarantine',
201
+ description: '把确认的 flaky 用例写入项目根 .flakefinder.json 隔离清单(不修改测试源码)。需要 flaky_detect 判定为 flaky 后再使用。',
202
+ parameters: compileParameters({
203
+ tests: { type: 'array', items: 'string', minItems: 1, maxItems: 100, required: true, description: '测试引用数组:文件路径,或 "文件路径 > 用例名"。' },
204
+ reason: { type: 'string', required: true, description: '隔离原因(会写进清单,例如:定时器竞态,见 issue #12)。' },
205
+ }),
206
+ output: {
207
+ schema: {
208
+ type: 'object',
209
+ properties: { file: { type: 'string' }, addedCount: { type: 'integer' }, added: { type: 'array', items: { type: 'object', additionalProperties: true } } },
210
+ additionalProperties: true,
211
+ },
212
+ render: (_args, value) => {
213
+ const rec = asRecord(value);
214
+ return [{ type: 'text', text: '已写入隔离清单 ' + rec.file + ',新增 ' + String(rec.addedCount ?? 0) + ' 条。' }];
215
+ },
216
+ },
217
+ async execute(rawArgs) {
218
+ const args = asRecord(rawArgs);
219
+ const refs = parseRefList(args);
220
+ const reason = requiredString(args, 'reason', '隔离原因');
221
+ const outcome = await store.addQuarantine(refs, reason);
222
+ return { file: outcome.file, addedCount: outcome.added.length, added: outcome.added };
223
+ },
224
+ timeoutMs: 10000,
225
+ };
226
+ const clear = {
227
+ name: 'flaky_clear',
228
+ description: '从隔离清单移除已经恢复稳定的用例。建议先 flaky_detect 验证多轮全部通过后再清除。',
229
+ parameters: compileParameters({
230
+ tests: { type: 'array', items: 'string', minItems: 1, maxItems: 100, required: true, description: '测试引用数组:文件路径,或 "文件路径 > 用例名"。' },
231
+ }),
232
+ output: {
233
+ schema: {
234
+ type: 'object',
235
+ properties: { file: { type: 'string' }, removedCount: { type: 'integer' }, removed: { type: 'array', items: { type: 'object', additionalProperties: true } } },
236
+ additionalProperties: true,
237
+ },
238
+ render: (_args, value) => {
239
+ const rec = asRecord(value);
240
+ return [{ type: 'text', text: '已从隔离清单移除 ' + String(rec.removedCount ?? 0) + ' 条。' }];
241
+ },
242
+ },
243
+ async execute(rawArgs) {
244
+ const args = asRecord(rawArgs);
245
+ const refs = parseRefList(args);
246
+ const outcome = await store.removeQuarantine(refs);
247
+ return { file: outcome.file, removedCount: outcome.removed.length, removed: outcome.removed };
248
+ },
249
+ timeoutMs: 10000,
250
+ };
251
+ const report = {
252
+ name: 'flaky_report',
253
+ description: '汇总隔离清单与检测历史,输出项目测试稳定性报告(只读,不运行测试)。',
254
+ parameters: compileParameters({
255
+ target: { type: 'string', description: '按测试目标过滤历史(可选)。' },
256
+ historyLimit: { type: 'integer', description: '历史条数 1-100(默认 20)。' },
257
+ }),
258
+ output: {
259
+ schema: {
260
+ type: 'object',
261
+ properties: {
262
+ quarantineFile: { type: 'string' },
263
+ quarantinedCount: { type: 'integer' },
264
+ quarantined: { type: 'array', items: { type: 'object', additionalProperties: true } },
265
+ historyCount: { type: 'integer' },
266
+ history: { type: 'array', items: { type: 'object', additionalProperties: true } },
267
+ },
268
+ additionalProperties: true,
269
+ },
270
+ render: (_args, value) => {
271
+ const rec = asRecord(value);
272
+ const quarantined = Array.isArray(rec.quarantined) ? rec.quarantined : [];
273
+ const lines = [
274
+ '测试稳定性报告',
275
+ '隔离清单:' + String(rec.quarantineFile ?? '') + '(' + String(rec.quarantinedCount ?? 0) + ' 条)',
276
+ ];
277
+ for (const item of quarantined) {
278
+ const row = asRecord(item);
279
+ lines.push('- ' + formatRef({ file: String(row.file ?? ''), name: typeof row.name === 'string' ? row.name : null }) + ':' + String(row.reason ?? ''));
280
+ }
281
+ return [{ type: 'text', text: lines.join('\n') }];
282
+ },
283
+ },
284
+ async execute(rawArgs) {
285
+ const args = asRecord(rawArgs);
286
+ const target = optionalString(args, 'target');
287
+ const historyLimit = optionalInteger(args, 'historyLimit', '历史条数', 1, 100, 20);
288
+ const doc = await store.loadQuarantine();
289
+ const historyEntries = await store.listHistory(target, historyLimit);
290
+ return {
291
+ quarantineFile: store.quarantinePath,
292
+ quarantinedCount: doc.quarantined.length,
293
+ quarantined: doc.quarantined,
294
+ historyCount: historyEntries.length,
295
+ history: historyEntries,
296
+ };
297
+ },
298
+ timeoutMs: 10000,
299
+ };
300
+ if (cfg.writeApproval) {
301
+ quarantine.gate = approvalGate('flaky_quarantine', '把测试用例写入 .flakefinder.json 隔离清单');
302
+ clear.gate = approvalGate('flaky_clear', '从 .flakefinder.json 隔离清单移除测试用例');
303
+ }
304
+ return [detect, history, quarantine, clear, report];
305
+ }
306
+ function runsTimeout(cfg) {
307
+ return Math.min(10 * 60 * 1000, cfg.maxRuns * cfg.timeoutMs + 15000);
308
+ }
309
+ /** 写操作审批门:复用 dsh-docker 的中文拒绝语义。 */
310
+ function approvalGate(toolName, action) {
311
+ return async (exec, next) => {
312
+ const record = (typeof exec === 'object' && exec !== null ? exec : {});
313
+ const approval = record.approval;
314
+ if (approval === undefined) {
315
+ return { kind: 'deny', reason: toolName + ' 需要确认,但当前环境没有审批通道(如 headless)。如确定安全,可在配置中设置 writeApproval: false。' };
316
+ }
317
+ const outcome = await approval.request({
318
+ agent: record.agent,
319
+ toolName: record.toolName,
320
+ callId: record.callId,
321
+ reason: action,
322
+ signal: record.signal,
323
+ });
324
+ if (outcome === 'allowed-once')
325
+ return next();
326
+ if (outcome === 'cancelled')
327
+ return { kind: 'deny', reason: action + ' 被取消,未执行。' };
328
+ if (outcome === 'unavailable')
329
+ return { kind: 'deny', reason: action + ' 不可用(没有可用的审批界面),未执行。' };
330
+ return { kind: 'deny', reason: action + ' 未获批准:要么你拒绝了,要么当前会话处于 Full Access。若确需直接写入,可设置 writeApproval: false(自行承担风险)。' };
331
+ };
332
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "dsh-flakefinder",
3
+ "version": "0.1.0",
4
+ "description": "DSH 测试稳定性插件:flaky_detect/history/quarantine/clear/report 五工具,重复运行测试并识别不稳定用例,SQLite-free JSON 历史与隔离清单,零运行时依赖。",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ },
13
+ "./cordis.patch.yml": "./cordis.patch.yml",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "lib",
18
+ "cordis.patch.yml",
19
+ "README.md",
20
+ "README.en.md"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.json",
24
+ "prepare": "tsc -p tsconfig.json",
25
+ "typecheck": "tsc -p tsconfig.json --noEmit",
26
+ "test": "pnpm run build && node --test \"test/*.test.mjs\"",
27
+ "prepublishOnly": "pnpm run build"
28
+ },
29
+ "dsh": {
30
+ "bundle": {
31
+ "patch": "./cordis.patch.yml"
32
+ }
33
+ },
34
+ "keywords": [
35
+ "dsh",
36
+ "deepseek-harness",
37
+ "plugin",
38
+ "testing",
39
+ "flaky-test",
40
+ "quality"
41
+ ],
42
+ "license": "MIT",
43
+ "engines": {
44
+ "node": ">=22.13"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^24.0.0",
48
+ "typescript": "^5.6.0"
49
+ },
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "git+https://github.com/STARDUSTLC666/dsh-flakefinder.git"
53
+ },
54
+ "bugs": {
55
+ "url": "https://github.com/STARDUSTLC666/dsh-flakefinder/issues"
56
+ },
57
+ "homepage": "https://github.com/STARDUSTLC666/dsh-flakefinder#readme",
58
+ "author": "stardustlc",
59
+ "packageManager": "pnpm@11.7.0"
60
+ }