[P1][CR-T3/T4] 测试可移植性与稳定性:硬编码家目录、依赖本地 MPQ、墙钟性能断言偶发失败、全量耗时长 #536

Open
opened 2026-09-29 06:44:02 +00:00 by troytt · 0 comments
Owner

来源:#516|优先级 P1|评审编号 T3、T4|基线 e475c2e

问题描述

AGENTS.md §5 要求每次改动都满足"npx vitest run 全部通过(0 failed)"。但在基线 e475c2e 上直接实跑全量测试,暴露出四类工程问题:

  1. 硬编码开发者家目录:/usr/local/google/home/taodao/d2-data 直接写死在 src/、19 个测试文件和 20 个脚本里。换一台没有这个目录的机器(包括 Gitea CI runner),285 个调用 getSharedDataRegistry() / getSharedMountedArchives() 的测试会直接 ENOENT 崩溃,另有约 32 个带 skipIf 的测试文件被静默跳过。
  2. 墙钟与堆内存断言导致全量测试偶发红灯:基线实跑 545 个文件,唯一失败的一条就是墙钟耗时断言(590ms > 500ms)。
  3. 单元与重型压力测试混跑:全量一次墙钟 5m48s、累计 CPU 约 41 分钟;最慢的单个测试文件耗时 316s(其中单条用例 296s)。
  4. 测试内使用 Math.random 与文件句柄未关闭:5 个压力测试用无种子 Math.random;MPQ fileSource 从不关闭 FileHandle,触发 Node 的 Closing file descriptor ... on garbage collection 警告。

证据

T3:硬编码路径与本地 MPQ 依赖

  • 生产代码中的默认参数:
  • 测试与脚本:19 个 tests/**/*.test.ts 和 20 个 scripts/*.ts 硬编码了同一个家目录路径;285 个测试文件直接调用 getSharedDataRegistry() / getSharedMountedArchives() 且不传参,也没有 skipIf。
  • skipIf 现状:42 个测试文件含有 skipIf(共 404 处),其中 9 个机械转换文件写的是死代码 const isSkip = false && !fs.existsSync('samples/d2')(如 collision-orientation.test.ts:31),其余约 32 个文件在缺少本地 MPQ 或烘包产物时会静默跳过,不会在总结里醒目提示。
  • Gitea CI 现状:仓库虽然有 ci.yml:1-25,但遠端仓库目前 has_actions: false,最近 15 个 main 提交上的 commit status 数量均为 0——也就是说 CI 实际上没有在跑;一旦启用 runner,上面 285 个测试就会因找不到 /usr/local/google/home/taodao/d2-data 直接失败。

T4:不稳定断言、慢测试与资源泄漏

  • 墙钟时间与 GC 堆断言(受并发 worker 抢占和 GC 时机影响):

    位置 断言 基线实跑表现
    blizzard-challenger-stress.test.ts:497-504 expect(elapsedMs).toBeLessThan(500) 全量并发下跑出 590ms,直接失败;单跑约 85ms
    png-stress.test.ts:458-461 expect(elapsed).toBeLessThan(1000) 通过,但依赖机器负载
    skill-tree-stress.test.ts:882-884 expect(elapsed).toBeLessThan(2500)(注释里已写明是为 362 文件并发放宽的阈值) 文件增长到 545 个后会再次逼近阈值
    tier1-feature-coverage.test.ts:846-847 expect(duration).toBeLessThan(2000) 同上
    adv-cold-fire-boundaries.test.ts:840-845 expect(elapsed).toBeLessThan(100) 任何一次 GC 停顿都可能超过 100ms
    blizzard-stress.test.ts:615-623 expect(heapDiffMb).toBeLessThan(15) 依赖 V8 何时触发 major GC
  • 耗时最长的测试文件(来自基线全量实跑):

    • tests/skills/nec/necro-assets-adversarial-stress.test.ts:316s(其中单条用例遍历烘焙与校验耗时 296s)
    • tests/challenger-m1-stress.test.ts:181s
    • tests/full-spectrum-drop-parity.test.ts:125s
  • 测试内使用 Math.random:tests/blizzard-challenger-stress.test.ts、tests/challenger-cold-armors-stress.test.ts、tests/skills/nec/adv-pnb-adversarial-challenger.test.ts、tests/skills/pal/adv-pal-challenger-stress.test.ts、tests/skills/sor/adversarial-challenger-sor.test.ts 在生成随机输入时用了 Math.random,一旦偶发失败无法复现。

  • 文件描述符泄漏:fileSource(file-source.ts:16-37)open(path, 'r') 后返回的 MpqSource 没有 close() 方法,句柄一直活到进程结束或被 GC 回收,测试过程中打印 Warning: Closing file descriptor ... on garbage collection。

根因

  • 本地开发机上恰好有 /usr/local/google/home/taodao/d2-data,所有后续测试和脚本都顺手复制了这个绝对路径,从未在干净环境或启用了 runner 的 CI 上跑过。
  • 压力测试、性能基准与快速单元测试都放在同一个 tests/**/*.test.ts 入口里(vitest.config.ts:4-6),并用墙钟耗时代替复杂度/操作数断言。

修复指南

  1. 统一通过环境变量 / 配置文件解析数据目录:
    • 新建 src/config/data-dir.ts(或放在 Node 侧 helper),唯一解析顺序:process.env.D2_DATA_DIR → 仓库根下的 samples/d2(若存在且含完整 MPQ);找不到时抛出带清晰指引的错误(指明需要设置 D2_DATA_DIR=/path/to/mpq);
    • 删除全仓所有 /usr/local/google/home/taodao/... 字面量,以及 data-registry.ts 里 baseDir.startsWith('/usr/local') 的���判;
    • 在 AGENTS.md §5 与 docs/LOCAL_DATA.md 中写明 D2_DATA_DIR 约定。
  2. 拆分测试分层(test:unit vs test:stress / test:mpq):
    • 在 vitest.config.ts 中使用 projects(或拆成 vitest.unit.config.ts 与 vitest.stress.config.ts):
      • npm run test:unit:不依赖完整商业 MPQ(只用仓库自带的最小文本表导出或合成 fixture)、不含 *-stress.test.ts,目标在 30–60 秒内跑完,任何机器检出即可运行;
      • npm run test:stress:包含 *-stress.test.ts、full-spectrum-*、necro-assets-adversarial-stress 等重型遍历测试;
      • npm test:在配置了 D2_DATA_DIR 的开发机或自托管 CI runner 上跑完整套件;
    • 删除 const isSkip = false && ... 这类假 skipIf;对真正依赖外部工具(如 ORACLE_BIN)的测试,在 CI 日志末尾显式汇总跳过数量,避免"全跳过也算绿灯"。
  3. 移除默认套件中的墙钟与堆大小硬断言:
    • 功能与压力测试里只断言确定性不变量(如生成的碎片数 2500、爆炸数 4400、活跃投射物上限 <= 25、视线步数上限),把 expect(elapsedMs).toBeLessThan(...) 和 expect(heapDiffMb).toBeLessThan(...) 移到单独的 npm run bench(vitest bench)脚本中,不在并发 worker 下做硬门禁。
  4. 测试随机源固定种子 + 关闭文件句柄:
    • 上述 5 个测试文件里的 Math.random 全部替换为固定种子的 D2Rng(例如 new D2Rng(0x113c_beef));
    • 给 MpqSource / MpqArchive / MountedArchives 增加可选的 close(): Promise<void>,底层调用 handle.close();在 afterAll 或进程退出钩子中关闭共享归档,消除 fd GC 警告。

验收标准

  • 全仓(src/、tests/、scripts/)搜索 /usr/local/google/home/taodao 结果为 0;通过 D2_DATA_DIR 指定数据目录时全量测试正常通过。
  • 默认测试套件中不再有 toBeLessThan 墙钟耗时或 heapUsed 断言;连续在满载并发下运行 npx vitest run 3 次,均为 0 failed。
  • npm run test:unit 在未设置 D2_DATA_DIR 的干净环境下可独立运行并在 60 秒内全绿通过;重型压力测试归入 npm run test:stress。
  • tests/ 中 Math.random 出现次数为 0;运行测试不再出现 Closing file descriptor ... on garbage collection 警告。
  • npm run typecheck 0 error;npx vitest run 全部通过。

相关

  • #535:测试有效性与空断言清理。
  • #532:打包脚本中的硬编码路径。
  • #537:用 lint 规则禁止测试与模拟代码中的 Math.random。
> 来源:#516|优先级 P1|评审编号 T3、T4|基线 `e475c2e` ## 问题描述 `AGENTS.md` §5 要求每次改动都满足"`npx vitest run` 全部通过(0 failed)"。但在基线 `e475c2e` 上直接实跑全量测试,暴露出四类工程问题: 1. **硬编码开发者家目录**:`/usr/local/google/home/taodao/d2-data` 直接写死在 `src/`、19 个测试文件和 20 个脚本里。换一台没有这个目录的机器(包括 Gitea CI runner),285 个调用 `getSharedDataRegistry()` / `getSharedMountedArchives()` 的测试会直接 `ENOENT` 崩溃,另有约 32 个带 `skipIf` 的测试文件被静默跳过。 2. **墙钟与堆内存断言导致全量测试偶发红灯**:基线实跑 545 个文件,唯一失败的一条就是墙钟耗时断言(590ms > 500ms)。 3. **单元与重型压力测试混跑**:全量一次墙钟 5m48s、累计 CPU 约 41 分钟;最慢的单个测试文件耗时 316s(其中单条用例 296s)。 4. **测试内使用 `Math.random` 与文件句柄未关闭**:5 个压力测试用无种子 `Math.random`;MPQ `fileSource` 从不关闭 `FileHandle`,触发 Node 的 `Closing file descriptor ... on garbage collection` 警告。 ## 证据 ### T3:硬编码路径与本地 MPQ 依赖 - **生产代码中的默认参数**: - `getSharedMountedArchives(baseDir = '/usr/local/google/home/taodao/d2-data')`([data-registry.ts:1541](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/engine/data-registry.ts#L1541)) - `getSharedDataRegistry(baseDir = '/usr/local/google/home/taodao/d2-data')`([data-registry.ts:1569](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/engine/data-registry.ts#L1569)) - 浏览器分支里甚至专门写了 `baseDir.startsWith('/usr/local') ? 'samples/d2' : baseDir`([data-registry.ts:1548](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/engine/data-registry.ts#L1548)、[data-registry.ts:1574](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/engine/data-registry.ts#L1574))来绕过这个默认值。 - **测试与脚本**:19 个 `tests/**/*.test.ts` 和 20 个 `scripts/*.ts` 硬编码了同一个家目录路径;285 个测试文件直接调用 `getSharedDataRegistry()` / `getSharedMountedArchives()` 且不传参,也没有 `skipIf`。 - **`skipIf` 现状**:42 个测试文件含有 `skipIf`(共 404 处),其中 9 个机械转换文件写的是死代码 `const isSkip = false && !fs.existsSync('samples/d2')`(如 [collision-orientation.test.ts:31](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/tests/collision-orientation.test.ts#L31)),其余约 32 个文件在缺少本地 MPQ 或烘包产物时会静默跳过,不会在总结里醒目提示。 - **Gitea CI 现状**:仓库虽然有 [ci.yml:1-25](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/.gitea/workflows/ci.yml#L1-L25),但遠端仓库目前 `has_actions: false`,最近 15 个 `main` 提交上的 commit status 数量均为 0——也就是说 CI 实际上没有在跑;一旦启用 runner,上面 285 个测试就会因找不到 `/usr/local/google/home/taodao/d2-data` 直接失败。 ### T4:不稳定断言、慢测试与资源泄漏 - **墙钟时间与 GC 堆断言**(受并发 worker 抢占和 GC 时机影响): | 位置 | 断言 | 基线实跑表现 | |---|---|---| | [blizzard-challenger-stress.test.ts:497-504](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/tests/blizzard-challenger-stress.test.ts#L497-L504) | `expect(elapsedMs).toBeLessThan(500)` | 全量并发下跑出 **590ms**,直接失败;单跑约 85ms | | [png-stress.test.ts:458-461](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/tests/png-stress.test.ts#L458-L461) | `expect(elapsed).toBeLessThan(1000)` | 通过,但依赖机器负载 | | [skill-tree-stress.test.ts:882-884](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/tests/skill-tree-stress.test.ts#L882-L884) | `expect(elapsed).toBeLessThan(2500)`(注释里已写明是为 362 文件并发放宽的阈值) | 文件增长到 545 个后会再次逼近阈值 | | [tier1-feature-coverage.test.ts:846-847](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/tests/e2e-drop-parity/tier1-feature-coverage.test.ts#L846-L847) | `expect(duration).toBeLessThan(2000)` | 同上 | | [adv-cold-fire-boundaries.test.ts:840-845](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/tests/skills/sor/adv-cold-fire-boundaries.test.ts#L840-L845) | `expect(elapsed).toBeLessThan(100)` | 任何一次 GC 停顿都可能超过 100ms | | [blizzard-stress.test.ts:615-623](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/tests/blizzard-stress.test.ts#L615-L623) | `expect(heapDiffMb).toBeLessThan(15)` | 依赖 V8 何时触发 major GC | - **耗时最长的测试文件**(来自基线全量实跑): - `tests/skills/nec/necro-assets-adversarial-stress.test.ts`:**316s**(其中单条用例遍历烘焙与校验耗时 296s) - `tests/challenger-m1-stress.test.ts`:**181s** - `tests/full-spectrum-drop-parity.test.ts`:**125s** - **测试内使用 `Math.random`**:`tests/blizzard-challenger-stress.test.ts`、`tests/challenger-cold-armors-stress.test.ts`、`tests/skills/nec/adv-pnb-adversarial-challenger.test.ts`、`tests/skills/pal/adv-pal-challenger-stress.test.ts`、`tests/skills/sor/adversarial-challenger-sor.test.ts` 在生成随机输入时用了 `Math.random`,一旦偶发失败无法复现。 - **文件描述符泄漏**:`fileSource`([file-source.ts:16-37](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/mpq/file-source.ts#L16-L37))`open(path, 'r')` 后返回的 `MpqSource` 没有 `close()` 方法,句柄一直活到进程结束或被 GC 回收,测试过程中打印 `Warning: Closing file descriptor ... on garbage collection`。 ## 根因 - 本地开发机上恰好有 `/usr/local/google/home/taodao/d2-data`,所有后续测试和脚本都顺手复制了这个绝对路径,从未在干净环境或启用了 runner 的 CI 上跑过。 - 压力测试、性能基准与快速单元测试都放在同一个 `tests/**/*.test.ts` 入口里([vitest.config.ts:4-6](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/vitest.config.ts#L4-L6)),并用墙钟耗时代替复杂度/操作数断言。 ## 修复指南 1. **统一通过环境变量 / 配置文件解析数据目录**: - 新建 `src/config/data-dir.ts`(或放在 Node 侧 helper),唯一解析顺序:`process.env.D2_DATA_DIR` → 仓库根下的 `samples/d2`(若存在且含完整 MPQ);找不到时抛出带清晰指引的错误(指明需要设置 `D2_DATA_DIR=/path/to/mpq`); - 删除全仓所有 `/usr/local/google/home/taodao/...` 字面量,以及 `data-registry.ts` 里 `baseDir.startsWith('/usr/local')` 的���判; - 在 `AGENTS.md` §5 与 `docs/LOCAL_DATA.md` 中写明 `D2_DATA_DIR` 约定。 2. **拆分测试分层(`test:unit` vs `test:stress` / `test:mpq`)**: - 在 `vitest.config.ts` 中使用 `projects`(或拆成 `vitest.unit.config.ts` 与 `vitest.stress.config.ts`): - `npm run test:unit`:不依赖完整商业 MPQ(只用仓库自带的最小文本表导出或合成 fixture)、不含 `*-stress.test.ts`,目标在 30–60 秒内跑完,任何机器检出即可运行; - `npm run test:stress`:包含 `*-stress.test.ts`、`full-spectrum-*`、`necro-assets-adversarial-stress` 等重型遍历测试; - `npm test`:在配置了 `D2_DATA_DIR` 的开发机或自托管 CI runner 上跑完整套件; - 删除 `const isSkip = false && ...` 这类假 `skipIf`;对真正依赖外部工具(如 `ORACLE_BIN`)的测试,在 CI 日志末尾显式汇总跳过数量,避免"全跳过也算绿灯"。 3. **移除默认套件中的墙钟与堆大小硬断言**: - 功能与压力测试里只断言**确定性不变量**(如生成的碎片数 `2500`、爆炸数 `4400`、活跃投射物上限 `<= 25`、视线步数上限),把 `expect(elapsedMs).toBeLessThan(...)` 和 `expect(heapDiffMb).toBeLessThan(...)` 移到单独的 `npm run bench`(`vitest bench`)脚本中,不在并发 worker 下做硬门禁。 4. **测试随机源固定种子 + 关闭文件句柄**: - 上述 5 个测试文件里的 `Math.random` 全部替换为固定种子的 `D2Rng`(例如 `new D2Rng(0x113c_beef)`); - 给 `MpqSource` / `MpqArchive` / `MountedArchives` 增加可选的 `close(): Promise<void>`,底层调用 `handle.close()`;在 `afterAll` 或进程退出钩子中关闭共享归档,消除 fd GC 警告。 ## 验收标准 - [ ] 全仓(`src/`、`tests/`、`scripts/`)搜索 `/usr/local/google/home/taodao` 结果为 0;通过 `D2_DATA_DIR` 指定数据目录时全量测试正常通过。 - [ ] 默认测试套件中不再有 `toBeLessThan` 墙钟耗时或 `heapUsed` 断言;连续在满载并发下运行 `npx vitest run` 3 次,均为 **0 failed**。 - [ ] `npm run test:unit` 在未设置 `D2_DATA_DIR` 的干净环境下可独立运行并在 60 秒内全绿通过;重型压力测试归入 `npm run test:stress`。 - [ ] `tests/` 中 `Math.random` 出现次数为 0;运行测试不再出现 `Closing file descriptor ... on garbage collection` 警告。 - [ ] `npm run typecheck` 0 error;`npx vitest run` 全部通过。 ## 相关 - #535:测试有效性与空断言清理。 - #532:打包脚本中的硬编码路径。 - #537:用 lint 规则禁止测试与模拟代码中的 `Math.random`。
Sign in to join this conversation.
No Label
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: troytt/diablo2-web#536
No description provided.