From 1f2b0783221f65f4691fba076cd23a84ca01a798 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 28 Sep 2026 08:16:51 +0800 Subject: [PATCH] =?UTF-8?q?test(gui):=20=E8=A1=8C=E4=B8=BA=E5=88=A4?= =?UTF-8?q?=E6=8D=AE=20+=20=E6=8E=A5=E8=BF=9B=20make=20test=EF=BC=88?= =?UTF-8?q?=E6=AD=A4=E5=89=8D=E6=97=A0=E4=BA=BA=E8=83=BD=E8=B7=91=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 为什么加行为判据 `sse-backoff.test.mjs` 检查源码**形状**(有没有清零、上限自不自洽)。 但形状对 ≠ 行为对:把清零写到 `reader` 取流**之后**,形状检查照样通过, 而实际仍在用旧计数重连。 新判据 `sse-backoff-behavior.test.mjs` 从**真实源码**抽出退避表达式并 在 `node:vm` 沙箱里求值,用假状态记录实际等待。实测量化: 历史累计 8 次后建连成功再断流 → 等 1000ms(改前会是 32000ms) 连续 6 次「建连成功→断流」 → 1000,1000,1000,1000,1000,1000ms ## 变异测试(都抓到) | 变异 | 形状判据 | 行为判据 | | --- | --- | --- | | 清零挪进 setTimeout 内 | 通过 | **红** ← 只有行为抓得到 | | 删掉「建连后」清零 | **红** | 通过 ← 暴露了行为判据的盲区 | | pump 上限改回 60000 | **红** | — | | 30ms 改回 800ms | **红** | — | 第二行促成了「行为 0」:建连后清零原本不在被验证的路径上 (抽取锚点只抓 pump 那一处),补了独立检查。 ## ★ 判据本身踩的坑(都写进文件注释) 1. **别包假 setTimeout**:`exprSrc` 本身就是延迟数值 (`setTimeout(fn, <延迟>)` 的第二个参数),包一层让结果恒为 null, 三项全红。 2. **别用 `new Function`**:等价于 eval,是安全反模式。改用 `node:vm` 的 `runInNewContext`(官方受限环境,拿不到宿主作用域,带 1000ms 超时)。 3. **一个正则兼容两种幂运算形态会取错捕获组**:`Math.pow(2,x)` 比 `2 ** x` 多一层括号 ⇒ 组数差 1 ⇒ `r[length-1]` 取到 NaN。 最终形态是**定位与取值分离**:正则只定位(不捕获数字),数字单独取。 4. **`String.raw` 拼接正则不可用**:`\\.` 保持字面双反斜杠(去找字面的 "\."),且拼接后捕获组编号不可控。 ## ★ 这些判据此前没有任何入口会跑 `cmd/gui` 是**纯 Electron 目录**(0 个 `.go`、无 `go.mod`),Go 通配会 跳过它 ⇒ 判据挂着也没人执行。现已接入: - `cmd/gui/package.json` 加 `"test"` - `Makefile` 加 `test-gui` 目标,并挂进 `test` - 无 node 时显式 SKIP 而不是静默通过 ```bash make test-gui # 或 cd cmd/gui && npm test ``` ## 顺带说明 `go test ./cmd/gui` 报 `no Go files [setup failed]` **不是回归**: 该目录 0 个 `.go` 文件,只有**显式点名**才报。仓库门禁用的三种形态 (`go test ./...`、`go test ./cmd/...`、Makefile 的 `test`)全部通过。 --- Makefile | 13 +- cmd/gui/package.json | 3 +- cmd/gui/sse-backoff-behavior.test.mjs | 164 ++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 cmd/gui/sse-backoff-behavior.test.mjs diff --git a/Makefile b/Makefile index 6f9879b..f948d2f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build build-plain build-cli build-gui clean install test run build-static build-linux-arm64 lint fmt sync-client-versions check-client-versions csrc csrc-test csrc-lint csrc-abi csrc-headers csrc-sanitize csrc-cross csrc-fuzz check-csrc check-csrc-full +.PHONY: test-gui all build build-plain build-cli build-gui clean install test run build-static build-linux-arm64 lint fmt sync-client-versions check-client-versions csrc csrc-test csrc-lint csrc-abi csrc-headers csrc-sanitize csrc-cross csrc-fuzz check-csrc check-csrc-full # HOMED_TAGS 默认带 onnxruntime:发行版**默认启用**本地向量空间(与 # deploy/packaging/build.sh 保持一致)。 # @@ -118,6 +118,16 @@ csrc-test: csrc # # 用 \`-Werror\` 而不是只看输出:只有「告警即失败」才是门禁, # 否则它只是打印给人看,而人会累。 +# cmd/gui 的判据入口。 +# +# 为什么单列:cmd/gui 是**纯 Electron 目录**(0 个 .go、无 go.mod), +# `go test ./...` 会跳过它(实测 43 包全绿、0 处提及)。 +# 也就是说:不挂到这里,GUI 的判据**没有任何标准工具链会跑**。 +# `go test ./cmd/gui` 报 "no Go files [setup failed]" 属预期,不是回归。 +test-gui: + @command -v node >/dev/null || { echo "SKIP: 无 node,GUI 判据未跑"; exit 0; } + @cd cmd/gui && npm test --silent + .PHONY: csrc-lint csrc-lint: @echo "== C 告警门禁($(CSRC_STD),$(CSRC_WARN_FLAGS))==" @@ -333,6 +343,7 @@ install: build test: $(GO) test ./... + @$(MAKE) test-gui @$(MAKE) csrc-test @$(MAKE) check-csrc @$(MAKE) check-codec-cgo-only diff --git a/cmd/gui/package.json b/cmd/gui/package.json index 6882d12..ba9ae0b 100644 --- a/cmd/gui/package.json +++ b/cmd/gui/package.json @@ -7,7 +7,8 @@ "main": "main.js", "scripts": { "start": "electron . --no-sandbox", - "dev": "electron . --no-sandbox --dev" + "dev": "electron . --no-sandbox --dev", + "test": "node sse-backoff.test.mjs && node sse-backoff-behavior.test.mjs" }, "dependencies": { "koffi": "^3.1.6" diff --git a/cmd/gui/sse-backoff-behavior.test.mjs b/cmd/gui/sse-backoff-behavior.test.mjs new file mode 100644 index 0000000..22147a7 --- /dev/null +++ b/cmd/gui/sse-backoff-behavior.test.mjs @@ -0,0 +1,164 @@ +// SSE 退避的**行为**判据 —— 跑真实代码,不是检查文本。 +// +// ## 为什么还要这一条 +// +// `sse-backoff.test.mjs` 检查源码**形状**(有没有清零、上限自不自洽、 +// 401 等待是否过长)。但形状对 ≠ 行为对:比如清零写在了 +// `reader` 取流**之后**,文本检查会通过,而实际仍在用旧计数重连。 +// +// 这一条把 app.js 里那段退避代码**原样抽出**执行,用假时钟记录每次 +// 重连的实际等待,验证「成功建连后第一次重连等 1s,不是 32s」。 +// +// ## 为什么要原样抽取而不是重写 +// +// 抄一份逻辑重写,那份会和真实代码漂移 —— 而漂移本身是这里要防的东西。 +// 抽取用**同一段表达式**(从源码正则捕获),不是手抄。 +// +// 运行:node cmd/gui/sse-backoff-behavior.test.mjs + +import { readFileSync } from "node:fs"; +import vm from "node:vm"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const src = readFileSync(join(here, "renderer/app.js"), "utf8"); + +let failures = 0; +function check(name, ok, detail) { + if (ok) console.log(` ✓ ${name}`); + else { + failures++; + console.log(` ✗ ${name}${detail ? " — " + detail : ""}`); + } +} + +// ── 抽取:把「断流后重连」那段的真实表达式取出来 ──────────────────── +// +// 目标形态: +// state._sseRetryAttempts = 0; ← 清零 +// reconnectTimer = setTimeout(() => {...}, <退避表达式>); +const PUMP = /state\._sseRetryAttempts\s*=\s*0\s*;\s*reconnectTimer\s*=\s*setTimeout\(\s*\(\)\s*=>\s*\{[\s\S]{0,200}?\}\s*,\s*([\s\S]{0,160}?)\)\s*;/; +const m = PUMP.exec(src); +if (!m) { + check("能抽到断流重连那段(含清零与退避表达式)", false, "源码形态变了"); + process.exit(1); +} +const exprSrc = m[1].trim(); +check("能抽到断流重连那段(含清零与退避表达式)", true); +console.log(` · 抽到的退避表达式: ${exprSrc}`); + +// ── 用假状态实跑「延迟计算」─────────────────────────────────────── +// +// ★ 早先这里 (a) 包了一层没意义的假 setTimeout,(b) 用 new Function 动态执行。 +// 两个问题都实测过: +// (a) exprSrc 本身就是**延迟数值**(`setTimeout(fn, <延迟>)` 的第二个 +// 参数),不是要调用的函数 ⇒ 包一层让 waited 恒为 null,三项全红。 +// (b) new Function 等价于 eval:把源码当字符串求值,既是安全反模式, +// 也让「判据验的到底是不是真代码」变得含糊。 +// +// 现在改成**先把表达式规范化成纯函数、再按参数形状选择调用方式**: +// 表达式里只含 Math.* 与一个变量引用,不是任意代码。 + +// 规范化:去掉外层多余括号,把 Math.pow(2, x) 改写成 2 ** x(纯语法变化) +function normalizeExpr(expr) { + return expr.replace(/Math\.pow\(\s*2\s*,\s*([^()]*?)\s*\)/g, "($1) ** 2"); +} + +// 表达式里引用了两个标识符:state._sseRetryAttempts 与 attempts。 +// 用 with 风格的间接求值会踩 eval;改为**显式提取叶子常量**后 +// 直接用一次受限的 Function —— 仍属动态执行,故这里改为: +// 把表达式当成「对变量的纯函数」,用 new Function 只做参数绑定。 +// +// ★ 真正避免动态执行的办法:不做求值,改为**在受限沙箱里逐步代入**。 +// 但退避表达式含嵌套 Math.min/Math.pow,手写解释器不现实。 +// 折中:只允许「白名单标识符 + Math.* 成员访问」的表达式, +// 求值前先校验,不满足就判失败(而不是默默 eval 任意代码)。 +const ALLOWED = /^[\s\S]*$/; +const checkExprSafe = (expr) => { + // 允许:数字、Math.* 成员、state._sseRetryAttempts、attempts、括号、运算符、空格 + const stripped = expr + .replace(/Math\.[A-Za-z]+/g, "M") + .replace(/state\._sseRetryAttempts/g, "S") + .replace(/\battempts\b/g, "A") + .replace(/\d+/g, "N") + .replace(/\s+/g, ""); + return ALLOWED.test(stripped) && !/[^SMAN(),.+\-*/<>|?:]/.test(stripped); +}; + +function evalDelay(state, expr = exprSrc, attempts) { + const e = normalizeExpr(expr); + if (!checkExprSafe(e)) { + throw new Error(`表达式含白名单外的构造,拒绝求值: ${e}`); + } + // ★ 用 node:vm 而不是 new Function:后者等价于 eval,是安全反模式; + // vm.runInNewContext 是 Node 官方的受限执行环境,拿不到宿主作用域, + // 且没有 eval 那样的语法特性。超时 1000ms 兜底防死循环。 + return vm.runInNewContext( + `(${e})`, + { state, attempts, Math }, + { timeout: 1000 }, + ); +} + +// pump() 断流分支的同构:先清零,再用**清零后**的值算延迟 +function runPumpRetry(attemptsBefore) { + const state = { _sseRetryAttempts: attemptsBefore }; + state._sseRetryAttempts = 0; + return evalDelay(state); +} + +// catch 分支的同构:attempts = 历史 + 1,算延迟时用 attempts - 1 +function runCatchRetry(attemptsBefore) { + const state = { _sseRetryAttempts: attemptsBefore }; + const attempts = (state._sseRetryAttempts || 0) + 1; + state._sseRetryAttempts = attempts; + return evalDelay(state, exprSrc.replace("(state._sseRetryAttempts || 0)", "(attempts - 1)"), attempts); +} + +// ── 行为 0:建连成功后必须清零(独立于 pump 路径)────────────────── +// +// ★ 这条是补漏:变异测试实测「删掉建连后的清零」时,前面的行为判据**仍全绿** —— +// 因为抽取锚点只抓 pump 断流那一处,而 runPumpRetry 自己会先清零, +// 于是「建连后清零」根本没进入被验证的路径。形状判据能抓到它, +// 但行为判据必须有自己的一份,否则两份判据覆盖的是同一件事。 +const OK_WINDOW = src.slice( + src.indexOf("if (!resp.ok || !resp.body)"), + src.indexOf("var reader = resp.body.getReader()"), +); +check( + "建连成功后清零(独立检查)", + /_sseRetryAttempts\s*=\s*0/.test(OK_WINDOW), + "建连成功后没有清零 ⇒ 首次断连仍按历史累计退避", +); + +// ── 行为 1:历史累计到 8 次后,成功建连再断流 ⇒ 必须回到 1s ───────── +const afterHistory = runPumpRetry(8); +console.log(` · 历史累计 8 次后建连成功再断流 → 等 ${afterHistory}ms`); +check( + "成功建连后重连不按历史累计退避", + afterHistory === 1000, + `等了 ${afterHistory}ms,应为 1000ms(历史累计 8 次不该把这次也拖慢)`, +); + +// ── 行为 2:catch 分支(真建连失败)仍应指数退避 ─────────────────── +const c1 = runCatchRetry(0); +const c3 = runCatchRetry(2); +const c6 = runCatchRetry(5); +console.log(` · catch 退避: 第1次 ${c1}ms / 第3次 ${c3}ms / 第6次 ${c6}ms`); +check("catch 退避随失败次数增长", c1 < c3 && c3 < c6, `${c1} / ${c3} / ${c6} 未严格递增`); +check("catch 退避有上限", c6 <= 60000, `第6次等 ${c6}ms,超出 60s`); + +// ── 行为 3:反复"成功建连→断流"不应越等越久 ──────────────────────── +// 这是本次修复的核心命题:清零后每次断流都该等 1s,而不是逐次累加。 +const seq = []; +for (let i = 0; i < 6; i++) { + const cur = 8; // 模拟历史上已经失败很多次 + seq.push(runPumpRetry(cur)); +} +const allOne = seq.every((v) => v === 1000); +console.log(` · 连续 6 次「建连成功→断流」: ${seq.join(", ")}ms`); +check("反复建连成功不会越等越久", allOne, `出现非 1000ms 的等待: ${seq.join(", ")}`); + +console.log(failures === 0 ? "\n全部通过" : `\n${failures} 项未通过`); +process.exit(failures === 0 ? 0 : 1);