chrome-plugin-manga-translator

Round 13 audit — 五个 P0、四个 P1,和三道会撒谎的门禁

上一轮(10-23 的深入体检)没有改代码,只记了 5 个 P0 + 9 个 P1。 这一轮把它们全部关掉。

前三个 P0 是三件不同的事,但根子是同一个:

两个上下文在写同一份账,而规则只认得其中一个。

后面几组各自有自己的根因,每一节单独写。最后一组(P1-10/11/12)不是产品 缺陷,而是门禁本身在撒谎 —— 报告的东西和实际检查的东西不是一回事。


P0-1 — 账单会变少,而且少了的部分找不回来

现象

src/stores/usage-store.ts 里,每个月只有一份「本月累计」的累加器记录。 worker 记了一笔 120 token,一个更早 hydrate 过的上下文(比如设置页 打开着)再记一笔 10 token,它那份不含 worker 数据的快照就把 120 覆盖 成了 10:

after worker write:    acc-2026-10 byDay tokens=120
after stale ctx2 write: acc-2026-10 byDay tokens=10   <- 120 消失
FINAL summary: {"translations":1,"billableCalls":1,"tokens":10}

用户看到的账单比真实的少。明细记录还在,所以能看出少了一次调用; 但那 120 token 本身没了 —— 累加器是被替换掉的,不是被合并的。

根因

累加器的 id 只带月份:acc-2026-10。

两个上下文算出同一个 id,于是 unionRecords 认为「这是同一条记录的 两个版本」,按「时间戳新的赢」取一个。而这个规则只对同一个写者的同一 条血脉成立:worker 自己的多次写入,保留新的那份是对的。两个不同的 写者共用一个 id 时,它变成了一场「谁后写谁赢」——后写的那个把前一个的 数字整个替换掉。

修法

id 里带上写者身份:

const WRITER_ID =
  typeof window === 'undefined' ? 'worker' : `page-${CONTEXT_ID}`;

两个写者现在握着互不相交的总额,collectUsage 把它们加起来。写者 变了,id 就变了,冲突的前提就消失了。

这里用 typeof window === 'undefined' 区分 worker 和页面:service worker 没有 window,扩展页面有。这不是取巧,是这条边界上唯一一个能同时在 两侧成立的判据。

验证

两个测试,都做过变异验证(改坏实现 → 测试必须变红):

测试 变异后
「早 hydrate 的上下文不会让账单变小」 红:expected 120 to be 130
「一个月一份累计,不是每重启一份」 红:累加器变成 2 份

第二个测试用 vi.stubGlobal('window', undefined) 模拟 worker, vi.resetModules() + 动态 import() 造出第二个上下文 —— 两个进程 共用一个 chrome.storage.local 的真实形态。


P0-2 — 列举模型的超时被算成了花钱,真实的超时反而不算

现象

src/providers/openai-compatible-base.ts 两处 408 的判断,正好反了:

位置 请求 原来 应该
:346 GET /models(列举,模型没跑) 记了账单 不记
:412 chat completion(真跑了模型) 不记 记

更糟的是 allowBillableProbe: false(设置页每 15 分钟的健康轮询用的正是 这个参数)只挡住了 404 分支,挡不住这条。于是:网关一慢,用户的账上就 每 15 分钟多一次「计费调用」,而服务商根本没为他跑过任何模型。

根因

计费规则被写在错误的调用点上。规则的正确表述是「跑了模型的请求 才计费」,但代码把它写成了「返回了错误的请求才计费」——两个不同的问 题。/models 是个列举接口,它问网关「你有哪些模型」,从没跑过一个。

修法

规则跟着问题走:

src/providers/usage-recording.ts 的文档也同步改了 —— 之前那段注释 写着「只有超时算」,读的人会以为超时一律算。

验证

三个测试(列表超时、轮询超时、chat 探针超时),全部做过变异验证: 改回去之后分别红成 1 vs 0、1 vs 0、0 vs 1。


P0-3 — 第一道门在干净 checkout 上是空转的

现象

凭据边界有两道门(scripts/check-release-consistency.mjs):

  1. 源码门禁(checkCredentialBoundary):走 import 图,看内容脚本 有没有碰到凭据模块。
  2. 产物门禁(checkNoCredentialsInContentScript):扫 dist/content.js 的字节。

第二道是真的,第一道有洞。而且第一道跑的位置也不对。

根因(两处)

洞:判定 type-only import 的正则漏了三种形态。

形态 原来
import { X } from '...' CAUGHT
import X from '...' CAUGHT
import * as X from '...' CAUGHT
import X, { type Y } from '...' MISSED
缩进的 import MISSED

位置:--source-only 跑在 inject-env-config.mjs 之前。而源码门禁 要看的正是 src/shared/env-credentials.generated.ts —— 那个文件是注入 脚本生成的。干净 checkout 里它还不存在,门禁于是无图可走,给出一个 虚假的绿灯。

(所以这一条的严重级别不是「密钥会泄漏」—— 产物门禁拦得住。真实的 严重级别是「CI 的第一道门空转」,以及它让人以为源码层已经被守住了。)

修法

验证

三个测试:带 default 绑定的 type-only 混写、缩进的 import、以及一条 读 package.json 断言注入顺序的测试。全部做过变异验证。


P0-4/5/6/7/8 — 取消属于一轮,不属于页面

这五条共用一个根因:translationCancelled 是模块级布尔量。

任何一处取消都会把它置上,而只有”整页翻译”会在开始时把它清回 false。 于是它同时坏在两头:

修法

每一轮翻译有自己的身份。

interface RunToken {
  readonly epoch: number;   // 这一轮开始时页面的纪元
  cancelled: boolean;       // 用户按了取消
  reason: AbortReason;      // 被什么中止的
}

isRunDead(token) = 被取消了 或 已经过时。请求发出前、结果回来后、 以及这一轮收尾写状态之前,都是这一个判据。

换图(#5)

请求飞着的时候页面换了画面,回来的是旧画面的译文。修法是在发请求 前记住这张图的地址,回来时比一次:不一样就丢掉这份结果,并且不动那张图 的”已翻译”标记——它还得留在候选里,等下一次请求。

判据和”画面换了就撤掉旧译文”用的是同一条(isSameArtwork:换掉查询串 之后还是同一个文件就是同一张画面),所以两处不会各说各话。

取消之后再点一次(#7)

被取消的那一轮还在收尾(在路上的请求撤不回来),但它不会再发新的请求 了。以前这一次的点击被静默丢掉——用户看着页面毫无反应。

现在:互斥锁被一个已被取消的轮次占着的时候,这一次点击会等它收完, 然后接上去。真的有一轮在跑的时候,点击仍然被挡下(用户没有要求跑两遍)。

验证

五个测试,全部做过变异验证:

测试 变异 结果
旧轮的答案不会画到新一轮的页面上 isRunDead 恒为 false 红
换过画面的图不会被画上旧译文 去掉画面比对 红
旧章的结果不会把新章标成已翻译 isRunDead 恒为 false 红
取消之后再点一次会真的开始 去掉”等上一轮收完” 红
右键单图翻译不会被上一轮的取消连累 取消退回页面级布尔量 红(其余四个也一起红)

最后一条那个变异就是旧代码本身:取消重新变成页面的事实,五个测试全部 变红。


P1-9 — 墨迹把模型框包住时,那道闸门是哑的

现象

像素精修把模型给的框吸附到页面上真的墨迹上。它有一道闸门:找到的字如果 和模型要的框不是同一段文字,就不精修(否则译文面板会盖住邻居的原文)。

闸门比的是「包含度」:两个框里小的那个有多大比例落在大的里面。

问题是第四种:墨迹 ⊇ 模型框(墨迹把模型框整个包住)。这时交集就是模型框 本身,比例恒等于 1 —— 闸门根本不响。

而这一种里藏着两件完全不同的事:

  1. 模型少框了一行(墨迹是同一段文字,大一点)→ 该放行;
  2. 模型指的是别处一个小东西(画在字上的拟声词),那团墨迹是整段文字 → 该拦住:撑到那么大,只会让这一小张的译文盖住整段原文。

包含度分不开它们,因为它们在几何上是同一件事。

修法

换一个问题问:模型框能解释这团墨迹的多少?

MIN_INK_COVERAGE = 0.2。这个数不是拍的:合法情形是 0.328(两行里框住 一行),越界情形是 0.029(一个小框落在一整段文字里)。

验证

一个测试,两次变异验证:

变异 结果
去掉新闸门 红:「does not let a small box swallow a whole block of text」
阈值改成 0.5(太严) 红:「snaps a short box onto both lines of text」

第二次变异同时证明这个阈值是有边的:再严一点,模型少框一行这种最常见的 情况也会被拦掉,精修就白做了。


P1-10/11/12 — 三道会撒谎的门禁

这三条不是产品缺陷,是门禁本身在撒谎。它们的共同点是:门禁报告的东西 和它实际检查的东西不是一回事。

#10 — smoke 测试把「内容脚本完全失聪」判为通过

smoke-test-public-build.mjs 打开一个 file:// 页面,给内容脚本发一条 GET_STATE。file:// 页面只有在扩展开了「允许访问文件网址」时才能收到 消息,所以「没收到」有两种原因,而原代码把它们合成了一种:

实际情况 原判定 应该
内容脚本答了 通过 通过
浏览器拦了 file://,内容脚本没被送达 通过 未验证(失败)
浏览器没拦,内容脚本仍然没答 通过 失败

第二行那个「通过」是致命的:一个内容脚本完全没注入的构建,只要跑测试的 机器没开文件访问权限,就能过 smoke。门禁把「没验证」写成了「已验证」。

修法:向 worker 问 chrome.extension.isAllowedFileSchemeAccess()。有权限 还拒收 = 内容脚本真的死了 = 失败;没权限 = 这次没验证成 = 失败,并在 detail 里写明是哪种。

判定逻辑抽到 scripts/lib/smoke-verdicts.mjs,因为它不需要浏览器就能测。

#11 — expect(true).toBe(true)

keepalive 和并发度热更新这两条路径,原来的测试最后一行是 expect(true).toBe(true) —— 中间所有调用都只是「跑一遍没抛异常」。删掉 startKeepalive() 调用、删掉 syncQueueLimit() 调用,测试一样绿。

现在的断言落在可观察的东西上:

最后一条在唯一能说真话的地方观察:同时在飞的 provider 调用数。

#12 — 承诺锚点只验字符串存在性

docs/store-claims.md 把每条对外承诺绑到一个代码锚点上, check-store-claims.mjs 验证锚点还在。但它是在原始文件里搜字符串: 删掉 OverlayRenderer 这个类、留一行提到它的注释,锚点照样「存在」。

锚点说的是「代码在做这件事」,注释不是代码。

修法:先把注释剥掉再验。扫描器同时跟踪字符串字面量(否则 URL 里的 // 会被当成注释,把同一行后面的真代码一起吞掉 —— 那是误报,和漏报一样有害)。

这一改立刻抓出两条现存的坏锚点:

承诺 原锚点 问题 新锚点
OCR 语言包从 jsdelivr 下载 text-detector.ts:jsdelivr 只在注释里 text-detector.ts:!langPath
出站请求只有三类 同上 同上 text-detector.ts:!langPath
没有统计、遥测、崩溃上报 usage-store.ts:按天聚合 只在文件头注释里 usage-store.ts:accumulator

!符号名 是新加的锚点形式,表达「这条承诺靠的是这个符号没有被赋值」。 jsdelivr 那条正是这样:tesseract.js 在 langPath 未设置时才用 jsdelivr, 所以承诺的事实就是「我们不设 langPath」。这种「靠缺席成立」的承诺没有别的 锚法,不引入这个形式,它就只能继续锚在注释上。

验证

测试 变异 结果
拒收 = 失败 / 未验证 拒收算通过(旧行为) 红 2 条
拦了 vs 死了要分开 合并成一句 红 1 条
keepalive 建立/清除 不调 startKeepalive 红
无关写入不动闹钟 任何本地写入都重设闹钟 红
并发度热更新 监听器不同步并发度 红(超时)
并发度热更新 updateMaxConcurrent 不 drain 红(超时)
注释里的符号不算 检查原始文件(旧行为) 红 2 条
字符串里的 // 不算注释 不跟踪字符串 红
!symbol 形式 不支持 红 2 条
标识符边界 退回子串搜索 红

写测试时有一条一开始没通过变异验证:最初的「注释里的撇号」用例在去掉 字符串跟踪后依然绿,因为那个撇号本来就在行注释里。换成「字符串里含 URL」 才真正压住这个分支。变异验证的价值就在这里 —— 它把「看起来在测」和 「真的在测」分开了。


还开着的

门禁脚本自己没有被变异验证

项目已经确立了「新测试必须做变异验证」,但这套规矩只覆盖了产品代码 的测试,没覆盖门禁脚本。#10 和 #12 就是这么来的:一个把「内容脚本 完全失聪」当通过,一个把「符号删了、注释还在」当通过。

这一轮补上了这三道门禁的变异验证,但没有系统性地给 scripts/ 下每一个 门禁做同样的事。那仍然是一项待办。

上架仍然是 Jasper 一个人的事

自动化已经就位(pnpm release:publish 会打包、上传、发布),但 Chrome Web Store 的 dashboard item、PUBLISHER_ID / ITEM_ID、Google Cloud 的 API 凭据、支持页 URL —— 这些在仓库里一份都没有,只有 Jasper 能建。