dsh-flakefinder 0.1.0 → 0.2.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 CHANGED
@@ -18,7 +18,7 @@ A DeepSeek Harness plugin for test stability: run tests repeatedly, classify fla
18
18
  | `flaky_quarantine` | Write flaky cases to `.flakefinder.json` | yes (approval) |
19
19
  | `flaky_clear` | Remove recovered cases from the manifest | yes (approval) |
20
20
 
21
- Frameworks: vitest / jest / node:test, auto-detected in that order.
21
+ Frameworks: vitest / jest / pytest / node:test, auto-detected in that order.
22
22
 
23
23
  ## Install
24
24
 
@@ -38,7 +38,7 @@ Agent:
38
38
 
39
39
  ## Config
40
40
 
41
- See `cordis.patch.yml`; defaults: `defaultRuns=5`, `maxRuns=20`, `timeoutMs=120000`, `writeApproval=true`.
41
+ See `cordis.patch.yml`; defaults: `defaultRuns=5`, `maxRuns=20`, `timeoutMs=120000`, `writeApproval=true`, `pythonPath=python` (`python3` on Linux/macOS; used for pytest runs).
42
42
 
43
43
  ## Development
44
44
 
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > 让 agent 分清「代码坏了」还是「测试在抽风」。
4
4
 
5
- DeepSeek Harness 测试稳定性插件:重复运行测试并识别 flaky 用例,历史留档、隔离清单、写操作审批门。零运行时依赖。
5
+ DeepSeek Harness 测试稳定性插件:支持 vitest / jest / pytest / node:test,重复运行测试并识别 flaky 用例,历史留档、隔离清单、写操作审批门。零运行时依赖。
6
6
 
7
7
  ![license](https://img.shields.io/npm/l/dsh-flakefinder) ![stars](https://img.shields.io/github/stars/STARDUSTLC666/dsh-flakefinder?style=social)
8
8
 
@@ -22,8 +22,9 @@ DeepSeek Harness 测试稳定性插件:重复运行测试并识别 flaky 用
22
22
 
23
23
  - vitest:读取 `node_modules/vitest/vitest.mjs`,使用 JSON reporter
24
24
  - jest:读取 `node_modules/jest/bin/jest.js`,使用 `--json --outputFile`
25
+ - pytest:`python -m pytest --junitxml`,解析 JUnit XML(内置能力,无需 pytest 插件)
25
26
  - node:test:`node --test --test-reporter=tap`,解析 TAP
26
- - `framework: auto` 按 vitest → jest → node:test 自动探测
27
+ - `framework: auto` 按 vitest → jest → pytest → node:test 自动探测
27
28
 
28
29
  ## 安装
29
30
 
@@ -62,6 +63,7 @@ Agent:
62
63
  | `writeApproval` | `true` | 隔离清单写操作是否走审批门 |
63
64
  | `dataDir` | `DSH_HOME/.dsh-flakefinder` | 历史存储目录 |
64
65
  | `quarantineFile` | `<cwd>/.flakefinder.json` | 隔离清单路径 |
66
+ | `pythonPath` | `DSH_FLAKEFINDER_PYTHON` 或 `python`/`python3` | pytest 使用的 Python 解释器 |
65
67
 
66
68
  ## 隔离清单格式
67
69
 
@@ -85,7 +87,7 @@ Agent:
85
87
 
86
88
  - Node >= 22.13,TypeScript,零运行时依赖
87
89
  - 测试进程走 DSH 官方 subprocess 服务,argv 数组、无 shell
88
- - 全量单测 30+:解析、判定、存储、审批门、注册与 manifest
90
+ - 全量单测 35+:解析、判定、存储、审批门、pytest 计划、注册与 manifest
89
91
  - `pnpm test`:构建 + `node --test`
90
92
 
91
93
  ## 发布门禁
package/cordis.patch.yml CHANGED
@@ -10,6 +10,7 @@
10
10
  # writeApproval: true # quarantine/clear 是否走审批门(默认 true)
11
11
  # dataDir: '' # 历史存储目录(默认 DSH_HOME/.dsh-flakefinder)
12
12
  # quarantineFile: '' # 隔离清单(默认项目根 .flakefinder.json)
13
+ pythonPath: '' # pytest 使用的 Python 解释器(默认 python/python3)
13
14
  #
14
15
  - insert:
15
16
  - id: flakefinder
package/lib/args.d.ts CHANGED
@@ -4,8 +4,8 @@
4
4
  *
5
5
  * @module dsh-flakefinder/args
6
6
  */
7
- export type Framework = 'auto' | 'vitest' | 'jest' | 'node';
8
- export type ReportKind = 'json' | 'tap';
7
+ export type Framework = 'auto' | 'vitest' | 'jest' | 'pytest' | 'node';
8
+ export type ReportKind = 'json' | 'tap' | 'junit';
9
9
  export interface TestPlan {
10
10
  framework: Exclude<Framework, 'auto'>;
11
11
  reportKind: ReportKind;
@@ -18,4 +18,4 @@ export declare function readFramework(raw: unknown): Framework;
18
18
  /** 在 cwd 里探测本地框架 CLI 入口;显式指定时缺失会给出中文指引。 */
19
19
  export declare function detectFramework(cwd: string, requested: Framework): Exclude<Framework, 'auto'>;
20
20
  /** 构建一次测试运行的执行计划。runIndex 仅用于给临时报告文件命名。 */
21
- export declare function buildPlan(cwd: string, target: string, requested: Framework, runIndex: number): TestPlan;
21
+ export declare function buildPlan(cwd: string, target: string, requested: Framework, runIndex: number, pythonPath?: string): TestPlan;
package/lib/args.js CHANGED
@@ -13,14 +13,27 @@ import { assertTarget } from './config.js';
13
13
  export function readFramework(raw) {
14
14
  if (raw === undefined || raw === null)
15
15
  return 'auto';
16
- if (raw === 'auto' || raw === 'vitest' || raw === 'jest' || raw === 'node')
16
+ if (raw === 'auto' || raw === 'vitest' || raw === 'jest' || raw === 'pytest' || raw === 'node')
17
17
  return raw;
18
- throw new Error('framework 只支持 auto / vitest / jest / node,收到:' + String(raw));
18
+ throw new Error('framework 只支持 auto / vitest / jest / pytest / node,收到:' + String(raw));
19
19
  }
20
20
  function localEntry(cwd, ...parts) {
21
21
  const full = path.join(cwd, ...parts);
22
22
  return fs.existsSync(full) ? full : null;
23
23
  }
24
+ function hasPytestConfig(cwd) {
25
+ if (fs.existsSync(path.join(cwd, 'pytest.ini')) || fs.existsSync(path.join(cwd, 'tox.ini')))
26
+ return true;
27
+ const pyproject = path.join(cwd, 'pyproject.toml');
28
+ if (!fs.existsSync(pyproject))
29
+ return false;
30
+ try {
31
+ return /\[tool\.pytest(\.ini_options)?\]/.test(fs.readFileSync(pyproject, 'utf8'));
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
24
37
  /** 在 cwd 里探测本地框架 CLI 入口;显式指定时缺失会给出中文指引。 */
25
38
  export function detectFramework(cwd, requested) {
26
39
  const vitest = localEntry(cwd, 'node_modules', 'vitest', 'vitest.mjs');
@@ -38,10 +51,12 @@ export function detectFramework(cwd, requested) {
38
51
  return 'vitest';
39
52
  if (jest !== null)
40
53
  return 'jest';
54
+ if (hasPytestConfig(cwd))
55
+ return 'pytest';
41
56
  return 'node';
42
57
  }
43
58
  /** 构建一次测试运行的执行计划。runIndex 仅用于给临时报告文件命名。 */
44
- export function buildPlan(cwd, target, requested, runIndex) {
59
+ export function buildPlan(cwd, target, requested, runIndex, pythonPath = 'python') {
45
60
  const safeTarget = assertTarget(target);
46
61
  const framework = detectFramework(cwd, requested);
47
62
  const token = crypto.randomBytes(6).toString('hex');
@@ -71,6 +86,16 @@ export function buildPlan(cwd, target, requested, runIndex) {
71
86
  label: 'jest run ' + safeTarget,
72
87
  };
73
88
  }
89
+ if (framework === 'pytest') {
90
+ const reportPath = path.join(os.tmpdir(), 'dsh-flakefinder-pytest-' + process.pid + '-' + String(runIndex) + '-' + token + '.xml');
91
+ return {
92
+ framework,
93
+ reportKind: 'junit',
94
+ reportPath,
95
+ argv: [pythonPath, '-m', 'pytest', safeTarget, '-q', '--junitxml=' + reportPath],
96
+ label: 'pytest ' + safeTarget,
97
+ };
98
+ }
74
99
  return {
75
100
  framework: 'node',
76
101
  reportKind: 'tap',
package/lib/config.d.ts CHANGED
@@ -19,6 +19,8 @@ export interface FlakeConfig {
19
19
  dataDir?: string;
20
20
  /** 项目隔离清单路径(默认 cwd/.flakefinder.json)。 */
21
21
  quarantineFile?: string;
22
+ /** Python 解释器命令(pytest 框架使用,默认 DSH_FLAKEFINDER_PYTHON 或 python/python3)。 */
23
+ pythonPath?: string;
22
24
  }
23
25
  /** 解析后的配置。 */
24
26
  export interface ResolvedFlakeConfig {
@@ -29,6 +31,7 @@ export interface ResolvedFlakeConfig {
29
31
  writeApproval: boolean;
30
32
  dataDir: string;
31
33
  quarantineFile: string;
34
+ pythonPath: string;
32
35
  }
33
36
  /** 解析并校验配置,非法值抛出中文错误。 */
34
37
  export declare function resolveConfig(config: FlakeConfig | undefined | null, cwd?: string): ResolvedFlakeConfig;
package/lib/config.js CHANGED
@@ -46,7 +46,10 @@ export function resolveConfig(config, cwd = process.cwd()) {
46
46
  const quarantineFile = typeof cfg.quarantineFile === 'string' && cfg.quarantineFile.trim() !== ''
47
47
  ? path.resolve(cwd, cfg.quarantineFile.trim())
48
48
  : path.join(cwd, '.flakefinder.json');
49
- return { defaultRuns, maxRuns, timeoutMs, graceMs, writeApproval, dataDir, quarantineFile };
49
+ const pythonPath = typeof cfg.pythonPath === 'string' && cfg.pythonPath.trim() !== ''
50
+ ? cfg.pythonPath.trim()
51
+ : (process.env.DSH_FLAKEFINDER_PYTHON?.trim() || (process.platform === 'win32' ? 'python' : 'python3'));
52
+ return { defaultRuns, maxRuns, timeoutMs, graceMs, writeApproval, dataDir, quarantineFile, pythonPath };
50
53
  }
51
54
  function readRuns(label, value, lo, hi) {
52
55
  if (typeof value !== 'number' || !Number.isFinite(value))
package/lib/parse.d.ts CHANGED
@@ -23,5 +23,7 @@ export interface NormalizedRun {
23
23
  }
24
24
  /** vitest 与 jest 的 JSON 结构高度接近,这里统一读取。 */
25
25
  export declare function parseJsonReport(text: string, framework: string): NormalizedRun;
26
+ /** 解析 pytest 的 JUnit XML 报告。 */
27
+ export declare function parseJunitReport(text: string): NormalizedRun;
26
28
  /** 解析 node:test 的 TAP 输出;子测试行忽略,只保留顶层用例。 */
27
29
  export declare function parseTapReport(text: string): NormalizedRun;
package/lib/parse.js CHANGED
@@ -72,6 +72,33 @@ function reportedCount(reported, fallback) {
72
72
  return Math.round(reported);
73
73
  return fallback;
74
74
  }
75
+ function attrOf(tag, name) {
76
+ const m = new RegExp(name + '="([^"]*)"').exec(tag);
77
+ return m === null ? '' : m[1];
78
+ }
79
+ /** 解析 pytest 的 JUnit XML 报告。 */
80
+ export function parseJunitReport(text) {
81
+ const tests = [];
82
+ const block = /<testcase\b([^>]*)>([\s\S]*?)<\/testcase\s*>/gi;
83
+ const single = /<testcase\b([^>]*)\/>/gi;
84
+ for (const m of text.matchAll(block)) {
85
+ const tag = m[1];
86
+ const body = m[2];
87
+ const classname = attrOf(tag, 'classname') || '(pytest)';
88
+ const name = attrOf(tag, 'name') || '(unnamed)';
89
+ const status = /<(?:failure|error)\b/.test(body) ? 'failed' : /<skipped\b/.test(body) ? 'skipped' : 'passed';
90
+ tests.push({ id: classname + ' > ' + name, file: classname, name, status, durationMs: Math.max(0, Number(attrOf(tag, 'time')) || 0) });
91
+ }
92
+ for (const m of text.matchAll(single)) {
93
+ const tag = m[1];
94
+ const classname = attrOf(tag, 'classname') || '(pytest)';
95
+ const name = attrOf(tag, 'name') || '(unnamed)';
96
+ tests.push({ id: classname + ' > ' + name, file: classname, name, status: 'passed', durationMs: Math.max(0, Number(attrOf(tag, 'time')) || 0) });
97
+ }
98
+ const failed = tests.filter(item => item.status === 'failed').length;
99
+ const skipped = tests.filter(item => item.status === 'skipped').length;
100
+ return { framework: 'pytest', success: failed === 0, total: tests.length, passed: tests.length - failed - skipped, failed, skipped, tests };
101
+ }
75
102
  /** 解析 node:test 的 TAP 输出;子测试行忽略,只保留顶层用例。 */
76
103
  export function parseTapReport(text) {
77
104
  const tests = [];
package/lib/runner.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * @module dsh-flakefinder/runner
5
5
  */
6
6
  import fs from 'node:fs/promises';
7
- import { parseJsonReport, parseTapReport } from './parse.js';
7
+ import { parseJsonReport, parseJunitReport, parseTapReport } from './parse.js';
8
8
  const COLLECT_BYTES = 4 * 1024 * 1024;
9
9
  /** 用 DSH subprocess 服务构造 ProcessRunner。 */
10
10
  export function createSubprocessRunner(spawn, graceMs, defaultTimeoutMs) {
@@ -73,13 +73,13 @@ export async function executePlan(runner, plan, index, timeoutMs) {
73
73
  durationMs,
74
74
  exitCode: result.exitCode,
75
75
  stderr: result.stderr,
76
- error: plan.framework + ' 没有写出 JSON 报告(' + fileError + ')。进程退出码:' + String(result.exitCode ?? 'null') + ';stderr:' + stderrTail(result.stderr),
76
+ error: plan.framework + ' 没有写出报告文件(' + fileError + ')。进程退出码:' + String(result.exitCode ?? 'null') + ';stderr:' + stderrTail(result.stderr),
77
77
  };
78
78
  }
79
79
  try {
80
80
  return {
81
81
  index,
82
- report: parseJsonReport(fileText, plan.framework),
82
+ report: plan.reportKind === 'junit' ? parseJunitReport(fileText) : parseJsonReport(fileText, plan.framework),
83
83
  durationMs,
84
84
  exitCode: result.exitCode,
85
85
  stderr: result.stderr,
package/lib/tools.js CHANGED
@@ -104,10 +104,10 @@ const detectSchema = {
104
104
  export function buildFlakeTools(cfg, runner, store) {
105
105
  const detect = {
106
106
  name: 'flaky_detect',
107
- description: '重复运行测试多次并判定稳定性:stable-pass(全过)/ stable-fail(全挂)/ flaky(时好时坏)。支持 vitest / jest / node:test,自动探测本地框架。结果写入历史供 flaky_report 分析。',
107
+ description: '重复运行测试多次并判定稳定性:stable-pass(全过)/ stable-fail(全挂)/ flaky(时好时坏)。支持 vitest / jest / pytest / node:test,自动探测本地框架。结果写入历史供 flaky_report 分析。',
108
108
  parameters: compileParameters({
109
109
  target: { type: 'string', required: true, description: '测试文件、目录或 glob(相对当前工作区)。不能以 - 开头。' },
110
- framework: { type: 'string', description: 'auto(默认,自动探测 vitest→jest→node:test)/ vitest / jest / node。' },
110
+ framework: { type: 'string', description: 'auto(默认,自动探测 vitest→jest→pytest→node:test)/ vitest / jest / pytest / node。' },
111
111
  runs: { type: 'integer', description: '重复次数,默认取配置 defaultRuns。' },
112
112
  }),
113
113
  output: {
@@ -123,7 +123,7 @@ export function buildFlakeTools(cfg, runner, store) {
123
123
  const target = assertTarget(requiredString(args, 'target', '测试目标'));
124
124
  const requested = readFramework(args.framework);
125
125
  const runs = resolveRuns(args.runs, cfg);
126
- const plans = Array.from({ length: runs }, (_, index) => buildPlan(process.cwd(), target, requested, index));
126
+ const plans = Array.from({ length: runs }, (_, index) => buildPlan(process.cwd(), target, requested, index, cfg.pythonPath));
127
127
  const framework = plans[0]?.framework ?? 'node';
128
128
  const executed = [];
129
129
  for (let index = 0; index < plans.length; index += 1) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-flakefinder",
3
- "version": "0.1.0",
4
- "description": "DSH 测试稳定性插件:flaky_detect/history/quarantine/clear/report 五工具,重复运行测试并识别不稳定用例,SQLite-free JSON 历史与隔离清单,零运行时依赖。",
3
+ "version": "0.2.0",
4
+ "description": "DSH 测试稳定性插件:flaky_detect/history/quarantine/clear/report 五工具,支持 vitest/jest/pytest/node:test,重复运行测试并识别不稳定用例,JSON 历史与隔离清单,零运行时依赖。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
@@ -37,7 +37,8 @@
37
37
  "plugin",
38
38
  "testing",
39
39
  "flaky-test",
40
- "quality"
40
+ "quality",
41
+ "dsh-plugin"
41
42
  ],
42
43
  "license": "MIT",
43
44
  "engines": {