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/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
- try {
17
- const text = await fs.readFile(historyPath, 'utf8');
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
- 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);
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
- 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 {
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
- const tmp = quarantineFile + '.tmp-' + process.pid;
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
- 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;
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
- 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 };
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
- 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;
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(process.cwd(), target, requested, index, cfg.pythonPath));
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
- executed.push(await executePlan(runner, plans[index], index + 1, cfg.timeoutMs));
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,