@gavinc-cn/touchstone-dsh 0.2.0 → 0.3.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/LICENSE +0 -0
- package/README.md +23 -15
- package/README.zh.md +17 -12
- package/auth.py +0 -0
- package/backend/lib/access.js +44 -0
- package/backend/lib/agents.js +341 -0
- package/backend/lib/args.js +94 -0
- package/backend/lib/auth.js +195 -0
- package/backend/lib/board.js +6154 -0
- package/backend/lib/builtin-assets.js +1177 -0
- package/backend/lib/casescan.js +456 -0
- package/backend/lib/chat.js +2053 -0
- package/backend/lib/cookie.js +76 -0
- package/backend/lib/db.js +2260 -0
- package/backend/lib/dshdriver.js +1148 -0
- package/backend/lib/dshevents.js +851 -0
- package/backend/lib/export-cases.js +261 -0
- package/backend/lib/feishu.js +1738 -0
- package/backend/lib/feishu_cfg.js +228 -0
- package/backend/lib/feishu_conv.js +1484 -0
- package/backend/lib/feishu_device.js +428 -0
- package/backend/lib/feishu_inbound.js +1674 -0
- package/backend/lib/feishu_provision.js +855 -0
- package/backend/lib/feishu_rest.js +398 -0
- package/backend/lib/feishu_slash.js +568 -0
- package/backend/lib/feishu_ws.js +950 -0
- package/backend/lib/http.js +312 -0
- package/backend/lib/lib.js +973 -0
- package/backend/lib/loadcase.js +749 -0
- package/backend/lib/loadgen.js +1407 -0
- package/backend/lib/localbus.js +164 -0
- package/backend/lib/lock.js +405 -0
- package/backend/lib/proc.js +278 -0
- package/backend/lib/prompts.js +1021 -0
- package/backend/lib/pybase64.js +114 -0
- package/backend/lib/pyjson.js +214 -0
- package/backend/lib/query.js +125 -0
- package/backend/lib/rag.js +1310 -0
- package/backend/lib/runner.js +4520 -0
- package/backend/lib/servercaps.js +182 -0
- package/backend/lib/serverstate.js +237 -0
- package/backend/lib/sessparse.js +1176 -0
- package/backend/lib/sse.js +207 -0
- package/backend/lib/static.js +84 -0
- package/backend/lib/taskjson.js +302 -0
- package/backend/lib/waitq.js +2114 -0
- package/backend/lib/worktree.js +588 -0
- package/backend/lib/zstd-frames.js +218 -0
- package/backend/routes/admin.js +553 -0
- package/backend/routes/agents.js +149 -0
- package/backend/routes/assets.js +145 -0
- package/backend/routes/board.js +1220 -0
- package/backend/routes/feishu.js +269 -0
- package/backend/routes/feishu_app.js +215 -0
- package/backend/routes/feishu_hook.js +358 -0
- package/backend/routes/index.js +297 -0
- package/backend/routes/projects.js +1059 -0
- package/backend/routes/sessions.js +946 -0
- package/backend/routes/tasks.js +1530 -0
- package/backend/schema/fixtures/ancient.sql +189 -0
- package/backend/schema/fixtures/pre_alter.sql +177 -0
- package/backend/schema/migrations.json +154 -0
- package/backend/schema/schema.json +1451 -0
- package/backend/schema/schema.sql +216 -0
- package/backend/server.js +498 -0
- package/board.py +30 -1
- package/builtin_assets.py +0 -0
- package/builtin_prompts/free_style/flow.md +0 -0
- package/builtin_prompts/free_style/load_test.md +0 -0
- package/builtin_prompts/free_style/templates/bug_report_template.md +0 -0
- package/builtin_prompts/free_style/templates/case_template.md +0 -0
- package/builtin_prompts/free_style/templates/index_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_charts_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_index_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_plan_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_scenario_template.md +0 -0
- package/builtin_prompts/free_style/templates/status_template.md +0 -0
- package/db.py +294 -129
- package/dsh-plugin/cordis.patch.yml +0 -0
- package/dsh-plugin/install.sh +22 -8
- package/dsh-plugin/lib/client.bundle.js +0 -0
- package/dsh-plugin/lib/index.js +299 -29
- package/dsh-plugin/lib/pack-closure.mjs +291 -0
- package/dsh-plugin/scripts/build.mjs +0 -0
- package/dsh-plugin/scripts/prepack-check.mjs +30 -2
- package/dsh-plugin/scripts/select-bundle.mjs +0 -0
- package/dsh-plugin/src/client.js +0 -0
- package/dshevents.py +0 -0
- package/export_cases.py +0 -0
- package/extensions/dsh-autocommit/asset.json +0 -0
- package/extensions/dsh-autocommit/pkg/lib/index.mjs +0 -0
- package/extensions/dsh-autocommit/pkg/package.json +0 -0
- package/feishu_conv.py +0 -0
- package/lib.py +0 -0
- package/lifecycle.py +116 -13
- package/loadcase.py +0 -0
- package/loadgen.py +0 -0
- package/localbus.py +0 -0
- package/migrate_p7b.py +0 -0
- package/package.json +6 -1
- package/platcompat.py +0 -0
- package/prompts.py +0 -0
- package/rag.py +0 -0
- package/requirements.txt +0 -0
- package/touchstone.py +0 -0
- package/waitq.py +0 -0
- package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
- package/webui/dist-plugin/assets/{index-JLwyZZxC.js → index-UrMyeAXF.js} +110 -110
- package/webui/dist-plugin/index.html +2 -2
- package/worktree.py +0 -0
- package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +0 -1
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 案例库扫描聚合(`server.py` 顶部「扫描层文件缓存 → StateCache」那一段的 Node 对应物,M3 读路径)。
|
|
3
|
+
*
|
|
4
|
+
* 职责:把一个项目的**文件系统**(`<工作目录>/free_style` 案例树、`<工作目录>/bug_report` 报告、
|
|
5
|
+
* `<工作目录>/.live/live.json` 运行状态)聚合成 `/api/state`、`/api/stream`、
|
|
6
|
+
* `/api/projects/<id>/bugs` 三个端点要的形状。Python 侧这些函数写在 server.py(不在 lib.py),
|
|
7
|
+
* 本模块按同一分工照搬。
|
|
8
|
+
*
|
|
9
|
+
* ── 为什么要有按 mtime 的文件缓存 ────────────────────────────────────────────────
|
|
10
|
+
* `/api/stream` 每 0.5s 聚合一次全量状态;若每拍都重新读盘解析,一个几百用例的案例库就会把
|
|
11
|
+
* CPU 烧光。`cachedFile` 以 `(mtime_ns, size)` 为戳缓存 `loader(path)` 的结果:文件没被改写
|
|
12
|
+
* 就直接命中(runner 写入会改 mtime,缓存自动失效)。**删掉的文件不缓存**(stat 失败时顺手
|
|
13
|
+
* 把旧条目弹掉),避免残留条目把「文件已删」读成「文件还在」。
|
|
14
|
+
*
|
|
15
|
+
* ── StateCache 的两条不变量(照抄 Python,破坏任何一条都会让 SSE 语义变形) ─────────
|
|
16
|
+
* ① **TTL 1.0s 内返回同一个对象**(不是深拷贝):`_api_state`/`_stream` 会往返回的 state 上写
|
|
17
|
+
* `state["project"]`,Python 也是就地改缓存对象。拷贝一份会让每拍都当「内容变了」。
|
|
18
|
+
* ② **指纹在构建时算好、且剔除 `generated_at`**:SSE 每拍只做字符串比较,不再全量 dumps;
|
|
19
|
+
* `generated_at` 每秒都在变,留在指纹里会把「无变化」读成「一直在变」⇒ 每拍都推帧。
|
|
20
|
+
* `project` 段在构建之后才写入,故它**不在**缓存指纹里——调用方(`_stream`)自己把它
|
|
21
|
+
* 追加进去再比(见 `routes/projects.js`)。
|
|
22
|
+
*
|
|
23
|
+
* ── 已知有意保留的差异(不影响真实案例库) ───────────────────────────────────────
|
|
24
|
+
* - `task["payload"]` 坏 JSON:Python 只 catch ValueError(`json.loads` 失败),若 payload 是
|
|
25
|
+
* 「合法 JSON 但不是对象」(如 `[1]`),`.get("bug_dir")` 会 AttributeError 把端点打成 500。
|
|
26
|
+
* Node 侧**降级成「不匹配」继续扫**(一个坏行不该让整个 bug 列表 500)。正常库两者一致。
|
|
27
|
+
* - `\d` 按 ASCII(见 lib.js 文件头 ③)。
|
|
28
|
+
*
|
|
29
|
+
* 零 npm 依赖:只用 node:fs;JSON 序列化走 pyjson.js(与 Python 逐字节一致)。
|
|
30
|
+
*/
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
|
|
33
|
+
import { listTasks, nowStr } from './db.js';
|
|
34
|
+
import { pyJsonDumps } from './pyjson.js';
|
|
35
|
+
import {
|
|
36
|
+
CASE_DIR_RE, FS_ID_RE, PY_WS, isDir, isFile, parseFields, parseStatusMd, pyBasename, pyJoin,
|
|
37
|
+
pyMtimeFloat, pySorted, pySortedReverse, pyStrip, pyStrCompare, readText, runtimeDir,
|
|
38
|
+
} from './lib.js';
|
|
39
|
+
|
|
40
|
+
// --------------------------------------------------------------------------
|
|
41
|
+
// 正则(逐条对齐 server.py 顶部;口径见 lib.js 文件头)
|
|
42
|
+
// --------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/** INDEX.md 用例行:`| FS0001 | 名称 | 测试点 | 状态 | 关联 bug |`(`re.M` + finditer) */
|
|
45
|
+
const CASE_ROW_RE = new RegExp(
|
|
46
|
+
`^\\|${PY_WS}*(FS\\d{4})${PY_WS}*\\|([^|]+)\\|([^|]+)\\|([^|]+)\\|([^|]+)\\|`, 'gm');
|
|
47
|
+
|
|
48
|
+
/** INDEX.md 目录说明行:`> 一句话说明`(`DESC_RE.search`,只取第一处) */
|
|
49
|
+
const DESC_RE = new RegExp(`^>${PY_WS}*([^\\n]+)$`, 'm');
|
|
50
|
+
|
|
51
|
+
/** bug 报告目录名:`20260829_0635_FS_cli_config_load` → 标题取第三个下划线之后 */
|
|
52
|
+
const BUG_DIR_RE = /^\d{8}_\d{4}_([^\n]+)$/;
|
|
53
|
+
|
|
54
|
+
/** ROUNDS.md 角度覆盖表行:`| 第 1 轮 | 时间 | 阅读范围 | 角度 | FS0001~FS0015(15 条) | 2 |` */
|
|
55
|
+
const ROUND_ROW_RE = new RegExp(
|
|
56
|
+
`^\\|${PY_WS}*第${PY_WS}*(\\d+)${PY_WS}*轮${PY_WS}*\\|([^|]*)\\|([^|]*)\\|([^|]*)\\|([^|]*)\\|`
|
|
57
|
+
+ `${PY_WS}*(\\d+)${PY_WS}*\\|`, 'gm');
|
|
58
|
+
|
|
59
|
+
/** ROUNDS.md 轮次明细小节标题:`### 第 1 轮(2026-08-22)` */
|
|
60
|
+
const ROUND_SEC_RE = new RegExp(`^###${PY_WS}*第${PY_WS}*(\\d+)${PY_WS}*轮[^\\n]*$`, 'gm');
|
|
61
|
+
|
|
62
|
+
/** 明细小节执行统计:`通过 16 / 失败 0 / 跳过 0`(跳过段可缺) */
|
|
63
|
+
const EXEC_STAT_RE = new RegExp(
|
|
64
|
+
`通过${PY_WS}*(\\d+)${PY_WS}*/${PY_WS}*失败${PY_WS}*(\\d+)(?:${PY_WS}*/${PY_WS}*跳过${PY_WS}*(\\d+))?`);
|
|
65
|
+
|
|
66
|
+
/** 角度表「入库用例数」:`(15 条)` */
|
|
67
|
+
const CASES_COUNT_RE = new RegExp(`(${PY_WS}*(\\d+)${PY_WS}*条${PY_WS}*)`);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `re.finditer` 的等价迭代器:新建一个带 `g` 的副本再 matchAll。
|
|
71
|
+
*
|
|
72
|
+
* 为什么不直接用模块级正则 `exec`:`g` 正则的 `lastIndex` 会跨调用残留,第二次解析同一段
|
|
73
|
+
* 文本会从上次位置继续 —— 这种 bug 只在「同一进程里第二次调用」才显形,单测很容易漏。
|
|
74
|
+
* @param {RegExp} re
|
|
75
|
+
* @param {string} text
|
|
76
|
+
* @returns {IterableIterator<RegExpMatchArray>}
|
|
77
|
+
*/
|
|
78
|
+
function* iterMatches(re, text) {
|
|
79
|
+
const rx = new RegExp(re.source, re.flags.includes('g') ? re.flags : `${re.flags}g`);
|
|
80
|
+
yield* String(text).matchAll(rx);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --------------------------------------------------------------------------
|
|
84
|
+
// 扫描层文件缓存
|
|
85
|
+
// --------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
/** 文件缓存:`path -> [(mtime_ns, size), 解析结果]`(进程内,与 Python `_FILE_CACHE` 同) */
|
|
88
|
+
const FILE_CACHE = new Map();
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* `cached_file`:按 `(mtime_ns, size)` 缓存 `loader(path)` 的结果;stat 失败(文件不存在)
|
|
92
|
+
* 时不缓存并顺手弹掉旧条目。
|
|
93
|
+
*
|
|
94
|
+
* 用 `statSync(..., { bigint: true })` 取 `mtimeNs`(纳秒 BigInt)而不是 `mtimeMs`(浮点毫秒):
|
|
95
|
+
* Python 的戳是 `st_mtime_ns`,同一毫秒内的两次写入在 Python 侧能区分、在 `mtimeMs` 侧不能,
|
|
96
|
+
* 那会让「刚写过的文件读回旧内容」这种最难查的缓存 bug 只出现在 Node 版。
|
|
97
|
+
* @param {string} filePath
|
|
98
|
+
* @param {(p: string) => any} loader
|
|
99
|
+
* @returns {any}
|
|
100
|
+
*/
|
|
101
|
+
export function cachedFile(filePath, loader) {
|
|
102
|
+
const target = String(filePath);
|
|
103
|
+
let stamp;
|
|
104
|
+
try {
|
|
105
|
+
const st = fs.statSync(target, { bigint: true });
|
|
106
|
+
stamp = [st.mtimeNs, st.size];
|
|
107
|
+
} catch {
|
|
108
|
+
FILE_CACHE.delete(target);
|
|
109
|
+
return loader(target);
|
|
110
|
+
}
|
|
111
|
+
const hit = FILE_CACHE.get(target);
|
|
112
|
+
if (hit && hit[0][0] === stamp[0] && hit[0][1] === stamp[1]) return hit[1];
|
|
113
|
+
const value = loader(target);
|
|
114
|
+
FILE_CACHE.set(target, [stamp, value]);
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 当前缓存条目数(单测诊断用;Python 侧无此函数,不影响端点行为) */
|
|
119
|
+
export function fileCacheSize() {
|
|
120
|
+
return FILE_CACHE.size;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 清空文件缓存(单测隔离用;Python 侧无此函数,不影响端点行为) */
|
|
124
|
+
export function clearFileCache() {
|
|
125
|
+
FILE_CACHE.clear();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --------------------------------------------------------------------------
|
|
129
|
+
// 案例树
|
|
130
|
+
// --------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* `parse_index_cases`:从 INDEX.md 表格解析 `id -> [名称, 测试点]`。
|
|
134
|
+
* 索引里的名称/测试点比目录名更准确;名称为空或 `—` 的行整条忽略,测试点为 `—` 时置空串。
|
|
135
|
+
* @param {string} text
|
|
136
|
+
* @returns {Record<string, [string, string]>}
|
|
137
|
+
*/
|
|
138
|
+
export function parseIndexCases(text) {
|
|
139
|
+
const result = {};
|
|
140
|
+
for (const m of iterMatches(CASE_ROW_RE, text)) {
|
|
141
|
+
const name = pyStrip(m[2]);
|
|
142
|
+
const point = pyStrip(m[3]);
|
|
143
|
+
if (name && name !== '—') result[m[1]] = [name, point === '—' ? '' : point];
|
|
144
|
+
}
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* `scan_dir`:递归扫描案例库目录,生成嵌套树(分类目录 -> children / cases)。
|
|
150
|
+
* 跳过隐藏目录(如 `.live`);用例目录(`FS0004_名称` 且有 status.md)进 `cases`,
|
|
151
|
+
* 非用例目录递归成 `children`,`FS` 前缀但缺 status.md 的目录**两者都不进**(照抄 Python)。
|
|
152
|
+
* @param {string} dirPath 绝对目录
|
|
153
|
+
* @param {string} rel 相对路径(根为 `.`,逐层 `./子目录` 形态,与 Python `os.path.join` 同形)
|
|
154
|
+
* @returns {object}
|
|
155
|
+
*/
|
|
156
|
+
export function scanDir(dirPath, rel) {
|
|
157
|
+
const node = { name: pyBasename(dirPath), path: rel, desc: '', children: [], cases: [] };
|
|
158
|
+
let indexCases = {};
|
|
159
|
+
const indexText = cachedFile(pyJoin(dirPath, 'INDEX.md'), readText);
|
|
160
|
+
if (indexText) {
|
|
161
|
+
const m = DESC_RE.exec(indexText);
|
|
162
|
+
if (m) node.desc = pyStrip(m[1]);
|
|
163
|
+
indexCases = parseIndexCases(indexText);
|
|
164
|
+
}
|
|
165
|
+
let entries;
|
|
166
|
+
try {
|
|
167
|
+
entries = fs.readdirSync(dirPath);
|
|
168
|
+
} catch {
|
|
169
|
+
return node; // 目录不可读:返回已解析出的 desc,不抛
|
|
170
|
+
}
|
|
171
|
+
for (const entry of pySorted(entries)) {
|
|
172
|
+
if (entry.startsWith('.')) continue;
|
|
173
|
+
const full = pyJoin(dirPath, entry);
|
|
174
|
+
if (!isDir(full)) continue;
|
|
175
|
+
const m = CASE_DIR_RE.exec(entry);
|
|
176
|
+
if (m && isFile(pyJoin(full, 'status.md'))) {
|
|
177
|
+
const item = { id: m[1], name: m[2], point: '', relpath: pyJoin(rel, entry) };
|
|
178
|
+
Object.assign(item, cachedFile(pyJoin(full, 'status.md'), parseStatusMd));
|
|
179
|
+
if (Object.hasOwn(indexCases, m[1])) {
|
|
180
|
+
[item.name, item.point] = indexCases[m[1]];
|
|
181
|
+
}
|
|
182
|
+
node.cases.push(item);
|
|
183
|
+
} else if (!m) {
|
|
184
|
+
node.children.push(scanDir(full, pyJoin(rel, entry)));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return node;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* `collect_stats`:递归统计各状态用例数(就地累加并返回同一个 stats 对象)。
|
|
192
|
+
* 状态映射:通过/失败/需要复测/跳过 各归一键,其余(含未执行)计入 pending。
|
|
193
|
+
* @param {object} node
|
|
194
|
+
* @param {{total: number, passed: number, failed: number, retest: number,
|
|
195
|
+
* skipped: number, pending: number}} stats
|
|
196
|
+
* @returns {object} 同一个 stats
|
|
197
|
+
*/
|
|
198
|
+
export function collectStats(node, stats) {
|
|
199
|
+
for (const item of node.cases) {
|
|
200
|
+
stats.total += 1;
|
|
201
|
+
const key = { 通过: 'passed', 失败: 'failed', 需要复测: 'retest', 跳过: 'skipped' }[item.status];
|
|
202
|
+
stats[key || 'pending'] += 1;
|
|
203
|
+
}
|
|
204
|
+
for (const child of node.children) collectStats(child, stats);
|
|
205
|
+
return stats;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// --------------------------------------------------------------------------
|
|
209
|
+
// bug 报告
|
|
210
|
+
// --------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* `scan_bugs`:扫描 bug_report 目录下测试任务生成的报告(目录名含 `_FS_`),
|
|
214
|
+
* 按目录名倒序,最多 50 条。
|
|
215
|
+
* @param {string} bugDir
|
|
216
|
+
* @returns {Array<{dir: string, title: string, status: string, cases: string[]}>}
|
|
217
|
+
*/
|
|
218
|
+
export function scanBugs(bugDir) {
|
|
219
|
+
const bugs = [];
|
|
220
|
+
let entries;
|
|
221
|
+
try {
|
|
222
|
+
entries = pySortedReverse(fs.readdirSync(bugDir));
|
|
223
|
+
} catch {
|
|
224
|
+
return bugs; // 目录不存在/不可读:空列表(不抛)
|
|
225
|
+
}
|
|
226
|
+
for (const entry of entries) {
|
|
227
|
+
if (!entry.includes('_FS_')) continue;
|
|
228
|
+
const full = pyJoin(bugDir, entry);
|
|
229
|
+
if (!isDir(full)) continue;
|
|
230
|
+
const report = cachedFile(pyJoin(full, 'bug_report.md'), readText);
|
|
231
|
+
const fields = parseFields(report);
|
|
232
|
+
const m = BUG_DIR_RE.exec(entry);
|
|
233
|
+
bugs.push({
|
|
234
|
+
dir: entry,
|
|
235
|
+
title: m ? m[1] : entry,
|
|
236
|
+
status: fields['状态'] || '',
|
|
237
|
+
cases: pySorted(new Set(report.match(FS_ID_RE) || [])).slice(0, 20),
|
|
238
|
+
});
|
|
239
|
+
if (bugs.length >= 50) break;
|
|
240
|
+
}
|
|
241
|
+
return bugs;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* `bug_last_task`:在任务列表中找该 bug 最近的 fix/retest_bug/reject/script_retest 任务。
|
|
246
|
+
*
|
|
247
|
+
* `tasks` 已按创建时间倒序(`db.list_tasks` 的 `id DESC`),故**第一个命中即为最近**。
|
|
248
|
+
* payload 解析失败的行跳过(Python `except ValueError: continue`);payload 不是对象的行
|
|
249
|
+
* 同样跳过(Python 侧会 AttributeError,见文件头「已知有意保留的差异」)。
|
|
250
|
+
* @param {object[]} tasks
|
|
251
|
+
* @param {string} bugDir
|
|
252
|
+
* @returns {{id: number, task_type: string, name: string, status: string, created_at: string}|null}
|
|
253
|
+
*/
|
|
254
|
+
export function bugLastTask(tasks, bugDir) {
|
|
255
|
+
const wanted = new Set(['fix', 'retest_bug', 'script_retest', 'reject']);
|
|
256
|
+
for (const t of tasks) {
|
|
257
|
+
if (!wanted.has(t.task_type)) continue;
|
|
258
|
+
let payload;
|
|
259
|
+
try {
|
|
260
|
+
payload = JSON.parse(t.payload || '{}');
|
|
261
|
+
} catch {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) continue;
|
|
265
|
+
if (payload.bug_dir === bugDir) {
|
|
266
|
+
return { id: t.id, task_type: t.task_type, name: t.name, status: t.status, created_at: t.created_at };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* `list_project_bugs`:bug 列表 = 扫描结果 + 每个 bug 最近的修复/复测/修例任务信息。
|
|
274
|
+
*
|
|
275
|
+
* 复测通过(已闭环)与已拒绝(被用户否决)的报告沉到列表末尾作为历史报告,其余保持扫描出的
|
|
276
|
+
* 目录名倒序(排序稳定,组内相对顺序不变)。
|
|
277
|
+
* @param {object} project 项目行(需 `bug_dir`/`id`)
|
|
278
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options] 数据层读口选项
|
|
279
|
+
* @returns {object[]}
|
|
280
|
+
*/
|
|
281
|
+
export function listProjectBugs(project, options = {}) {
|
|
282
|
+
const bugs = scanBugs(project.bug_dir);
|
|
283
|
+
const tasks = listTasks(project.id, options);
|
|
284
|
+
for (const b of bugs) b.last_task = bugLastTask(tasks, b.dir);
|
|
285
|
+
bugs.sort((a, b) => closedRank(a) - closedRank(b));
|
|
286
|
+
return bugs;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** 历史报告判据:状态里出现「复测通过」或「已拒绝」(`re.search` ⇒ 任意位置命中) */
|
|
290
|
+
function closedRank(bug) {
|
|
291
|
+
return /复测通过|已拒绝/.test(bug.status || '') ? 1 : 0;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// --------------------------------------------------------------------------
|
|
295
|
+
// ROUNDS.md / live.json / 聚合
|
|
296
|
+
// --------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* `parse_rounds`:解析 ROUNDS.md —— 角度表取 轮次/时间/角度/入库数/新bug,
|
|
300
|
+
* 明细小节(`### 第 N 轮` 到下一小节之间)补 通过/失败/跳过。
|
|
301
|
+
* @param {string} root 案例库根
|
|
302
|
+
* @returns {object[]}
|
|
303
|
+
*/
|
|
304
|
+
export function parseRounds(root) {
|
|
305
|
+
const text = cachedFile(pyJoin(root, 'ROUNDS.md'), readText);
|
|
306
|
+
if (!text) return [];
|
|
307
|
+
const detail = new Map();
|
|
308
|
+
const secs = [...iterMatches(ROUND_SEC_RE, text)];
|
|
309
|
+
for (let i = 0; i < secs.length; i += 1) {
|
|
310
|
+
const m = secs[i];
|
|
311
|
+
const end = i + 1 < secs.length ? secs[i + 1].index : text.length;
|
|
312
|
+
const em = EXEC_STAT_RE.exec(text.slice(m.index + m[0].length, end));
|
|
313
|
+
if (em) {
|
|
314
|
+
detail.set(Number(m[1]), {
|
|
315
|
+
passed: Number(em[1]),
|
|
316
|
+
failed: Number(em[2]),
|
|
317
|
+
skipped: Number(em[3] || 0),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const rounds = [];
|
|
322
|
+
for (const m of iterMatches(ROUND_ROW_RE, text)) {
|
|
323
|
+
const no = Number(m[1]);
|
|
324
|
+
const cm = CASES_COUNT_RE.exec(m[5]);
|
|
325
|
+
const item = {
|
|
326
|
+
round: no,
|
|
327
|
+
time: pyStrip(m[2]),
|
|
328
|
+
angle: pyStrip(m[4]),
|
|
329
|
+
cases: cm ? Number(cm[1]) : 0,
|
|
330
|
+
new_bugs: Number(m[6]),
|
|
331
|
+
passed: 0,
|
|
332
|
+
failed: 0,
|
|
333
|
+
skipped: 0,
|
|
334
|
+
};
|
|
335
|
+
Object.assign(item, detail.get(no) || {});
|
|
336
|
+
rounds.push(item);
|
|
337
|
+
}
|
|
338
|
+
return rounds;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* `load_live`:读取运行状态文件 `<工作目录>/.live/live.json`。
|
|
343
|
+
* 不存在或解析失败返回 `{live: null, mtime: null}`(Python 返回 `(None, None)`)。
|
|
344
|
+
* @param {string} workDir
|
|
345
|
+
* @returns {{live: any, mtime: number|null}}
|
|
346
|
+
*/
|
|
347
|
+
export function loadLive(workDir) {
|
|
348
|
+
const file = pyJoin(runtimeDir(workDir, '.live'), 'live.json');
|
|
349
|
+
try {
|
|
350
|
+
const live = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
351
|
+
const st = fs.statSync(file, { bigint: true });
|
|
352
|
+
return { live, mtime: pyMtimeFloat(st.mtimeNs) };
|
|
353
|
+
} catch {
|
|
354
|
+
return { live: null, mtime: null };
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* `build_state`:聚合一次完整的 UI 状态。
|
|
360
|
+
* @param {string} root 案例库根(扫案例树/ROUNDS.md)
|
|
361
|
+
* @param {string} bugDir bug 报告目录
|
|
362
|
+
* @param {string} workDir 工作目录(读 `.live/live.json`)
|
|
363
|
+
* @returns {object}
|
|
364
|
+
*/
|
|
365
|
+
export function buildState(root, bugDir, workDir) {
|
|
366
|
+
const tree = scanDir(root, '.');
|
|
367
|
+
const stats = collectStats(tree, {
|
|
368
|
+
total: 0, passed: 0, failed: 0, retest: 0, skipped: 0, pending: 0,
|
|
369
|
+
});
|
|
370
|
+
const { live, mtime } = loadLive(workDir);
|
|
371
|
+
return {
|
|
372
|
+
generated_at: nowStr(),
|
|
373
|
+
live,
|
|
374
|
+
live_mtime: mtime,
|
|
375
|
+
library: { tree, stats },
|
|
376
|
+
bugs: scanBugs(bugDir),
|
|
377
|
+
rounds: parseRounds(root),
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* CPython `json.dumps(obj, ensure_ascii=False, sort_keys=True)` 的对应物
|
|
383
|
+
* (sort_keys 逐层递归排序 dict 键,键按码点序 —— 见 lib.js 的 pyStrCompare)。
|
|
384
|
+
* @param {any} value
|
|
385
|
+
* @returns {string}
|
|
386
|
+
*/
|
|
387
|
+
export function pyJsonDumpsSorted(value) {
|
|
388
|
+
return pyJsonDumps(sortKeysDeep(value));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* 递归把普通对象的键按码点序重排(数组保持原序)。
|
|
393
|
+
* @param {any} value
|
|
394
|
+
* @returns {any}
|
|
395
|
+
*/
|
|
396
|
+
export function sortKeysDeep(value) {
|
|
397
|
+
if (Array.isArray(value)) return value.map(sortKeysDeep);
|
|
398
|
+
if (value !== null && typeof value === 'object') {
|
|
399
|
+
const out = {};
|
|
400
|
+
for (const key of Object.keys(value).sort(pyStrCompare)) out[key] = sortKeysDeep(value[key]);
|
|
401
|
+
return out;
|
|
402
|
+
}
|
|
403
|
+
return value;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* `StateCache`:按项目缓存聚合状态(TTL 1s),SSE 0.5s 轮询时避免反复扫盘。
|
|
408
|
+
*
|
|
409
|
+
* 与 Python 的唯一实现差异:Python 用 `threading.Lock` 保护缓存,Node 侧 `get()` 里没有
|
|
410
|
+
* 任何 `await`(`buildState` 全同步)⇒ 单线程事件循环里不存在交错,锁是多余的。
|
|
411
|
+
* 构造参数 `now` 是**测试缝**(注入假时钟验证 TTL),生产路径不传(用墙钟秒)。
|
|
412
|
+
*/
|
|
413
|
+
export class StateCache {
|
|
414
|
+
/**
|
|
415
|
+
* @param {{ttl?: number, now?: () => number}} [opts] ttl 单位秒(对齐 Python `ttl=1.0`)
|
|
416
|
+
*/
|
|
417
|
+
constructor({ ttl = 1.0, now = () => Date.now() / 1000 } = {}) {
|
|
418
|
+
this.ttl = ttl;
|
|
419
|
+
this._now = now;
|
|
420
|
+
/** @type {Map<number, {ts: number, state: object, fingerprint: string}>} */
|
|
421
|
+
this._cache = new Map();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* 取该项目状态:TTL 内直接返回**同一对象**;否则重建并连同指纹一起缓存。
|
|
426
|
+
* @param {number} projectId
|
|
427
|
+
* @param {string} root 案例库根
|
|
428
|
+
* @param {string} bugDir bug 报告目录
|
|
429
|
+
* @param {string} workDir 工作目录
|
|
430
|
+
* @returns {object}
|
|
431
|
+
*/
|
|
432
|
+
get(projectId, root, bugDir, workDir) {
|
|
433
|
+
const hit = this._cache.get(projectId);
|
|
434
|
+
if (hit && this._now() - hit.ts < this.ttl) return hit.state;
|
|
435
|
+
const state = buildState(root, bugDir, workDir);
|
|
436
|
+
const fingerprint = pyJsonDumpsSorted(
|
|
437
|
+
Object.fromEntries(Object.entries(state).filter(([k]) => k !== 'generated_at')));
|
|
438
|
+
this._cache.set(projectId, { ts: this._now(), state, fingerprint });
|
|
439
|
+
return state;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* 该项目缓存状态的**构建时**指纹;紧随 `get()` 调用一定存在(无则 null)。
|
|
444
|
+
* @param {number} projectId
|
|
445
|
+
* @returns {string|null}
|
|
446
|
+
*/
|
|
447
|
+
fingerprint(projectId) {
|
|
448
|
+
const hit = this._cache.get(projectId);
|
|
449
|
+
return hit ? hit.fingerprint : null;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** 清空缓存(单测隔离用;Python 侧无此方法,不影响端点行为) */
|
|
453
|
+
clear() {
|
|
454
|
+
this._cache.clear();
|
|
455
|
+
}
|
|
456
|
+
}
|