DOCUMENTATION · 测试体系

测试体系

本文介绍本项目如何写、如何运行测试。栈是 Vitest(单元测试)+ Playwright(端到端), 两者分别覆盖「引擎内核逻辑」与「真实浏览器里的整链路」。

同步自 docs/TESTING.md(243 行) · commit b2b4124 · 读源文件 ↗

权威配置:vitest.config 相关(无独立配置时走内置默认)、playwright.config.ts、package.json#scripts。 若与源码冲突,以源码与实际运行结果为准。


1. 总览

层工具目录目标
单元测试Vitesttests/*.test.ts引擎内核:Expression DSL、StoryEngine 节点流转、PluginManager 生命周期、WASM ABI 编解码
端到端Playwrighttests/e2e/*.spec.ts真实浏览器 + dev server:点击、DOM、事件、性能、WASM 集成
集成(无头)Vitesttests/kit.test.ts + tests/kit/index.ts真实 Runtime + 真实插件加载器(含 WASM)+ 假 UI:整套剧情与插件链路在 node 里跑完,不需要浏览器

命令:

npm test            # vitest run        —— 跑全部单测
npm run test:watch  # vitest            —— 监听模式
npx playwright test # 跑全部 E2E

E2E 首次需要浏览器依赖:

npx playwright install --with-deps chromium

2. 单元测试(Vitest)

2.1 文件分布

文件覆盖对象
tests/expression.test.ts表达式求值 + 函数注册(⑤):不许遮蔽内置 ✗ / 名字先到先得 ✓ / arity 检查 ✓ / 未知函数在求值期说清楚 ✓ / 宿主注册的函数被剧情条件用上(端到端)✓
tests/story.test.ts剧情引擎:节点类型流转、效果落地、状态变更、runToken 重载语义
tests/plugin-manager.test.ts插件管理器 + 契约总线 + 契约依赖 + 开销归因 + 来源追踪的插件侧(夹具插件写的状态能查到是它写的 ✓)
tests/wasm-abi.test.tsWASM ABI 消息/返回编解码、WASM_STATUS、长度前缀缓冲解析
tests/world-loop.test.ts世界模拟器:WorldLoop 生命周期与 world:tick 广播、规则错误隔离、规则按 owner 回收、observe 节点(条件/优先级/换剧情撤销订阅)、WorldLoop ↔ observe 端到端
tests/stories-shapes.test.ts三种形态各跑一遍(node 里确定性):文学向 / 涌现向 / 实时向;外加叙事插件:复盘(「你没走的路」+「因为你」+ 写进状态)已验 ✓,跨周目记忆 ✓ + 回归:两个插件同用一份剧情时,restart() 之后两个 start() 的效果都在(修"一次加载失败会卸掉所有在跑插件")✓
tests/mount.test.ts一行挂载:模板与 Renderer 的 id 契约(少一个 id 就会在运行时炸 → 用测试钉住)、契约清单自身、工具栏开关、标题转义、node 下调用的明确报错
tests/link.test.ts跨窗口链接:协议丢弃(不认识/版本过新/字段不全)、控制器端 pick 替玩家作出选择、intent 送进剧情、本地先点也成立、没有 BroadcastChannel 就报错
tests/host-typewriter.test.ts示例宿主的宿主侧实现:打字机(逐帧/加速/跳过/reduced-motion)+ 插件能力清单(排序、缺失兜底、真读仓库所有 manifest 检查权限是否有效)
tests/editor-text.test.ts文本剧本格式:逐种节拍的解析、诊断(带行号:未知场名 / 场名重复 / 条件不合法 / 选项没有「选择:」)、往返幂等契约(parse(stringify(doc)) === doc)与输出稳定性、文本→编译→端到端
tests/embed.test.ts嵌入:协议校验与命令白名单、父页的请求-答复配对与超时、事件分发、来源校验;端到端是"父页通过协议驱动子页里的真 Runtime跑完一段剧情"
tests/lib.test.ts库形态:公开入口的契约面(该导出的都在),以及只用公开 API 装配 Runtime 并跑完一段剧情(不碰任何内部路径)
tests/editor.test.ts编辑器的逻辑:文本优先写作、往返幂等、JSON 视图、结构体检(含无效选择检测("无条件且跳向同一场"的选项组;反例钉住保守规则 ✓)与剧情穷举压测(路线数 / 结局分布按场序 ✓ / 撞上限如实标注 / 死路 / 自环不爆栈))
tests/stories-valid.test.ts内置剧情 lint(npm run story:lint):用引擎自己的校验器把 public/stories 下每个剧情过一遍,并检查声明的插件是否存在、index.json 与磁盘是否一致
tests/capabilities.test.ts宿主能力探测:字段形状与自洽(threads = SAB ∧ 隔离)、SIMD 与原生 validate 一致、reportCapabilities 的日志与提示
tests/frame-loop.test.ts帧循环:累加器切步与 alpha 插值、一帧补不完时截断(starved)、订阅与自动停表、暂停/倍速、node 下无 rAF 仍可手动推进
tests/input-router.test.ts输入认领与仲裁:优先级 / 同优先级后认领者优先 / 作用域过滤 / isDown / 按 owner 回收 / 回调异常隔离
tests/plugin-timers.test.ts插件定时器:到点触发与取消、repeat、代数失效(防幽灵定时器)、按 owner 回收、ctx.schedule 的接线与卸载清理
tests/state-layer.test.ts状态层(批处理 / 快照 / 路径归一化 …)+ 状态来源追踪(②):每路径留最后一条 ✓ / 流水有上限 ✓ / explain() 说人话 ✓ / 剧情写入的来源 = 节点 ✓
tests/determinism.test.ts确定性(旗舰承诺):运行指纹(键顺序无关 ✓;NaN/Infinity/-0 有固定记法 ✓;中文/表情走码点 ✓;输入日志先投影掉 at ✓)+ 无头回放(同输入同指纹 ✓;换选择必不同 ✓;按日志重放同指纹 ✓;永远不结束的剧情如实标 truncated ✓)+ 存档导出/导入 ✓
tests/intents.test.ts意图通道:两道闸(空闲 + 有人在听)、choice 上不受理、权限与参数校验、面板→ctx.intents→剧情路由的端到端
tests/permission-scope.test.ts作用域限定权限(state:write:var.arena 只能写这一块)+ 运行期权限审计(环形上限、聚合计数、拒绝也留、ctx.state.set 留下"谁改的")
tests/runtime-semantics.test.ts运行时语义(observe / 派生状态 / 中文路径 / 空转保护)+ 后台限流判定(classifyWaitOverrun 纯函数 ✓)
tests/observability.test.ts可观测性 + 诊断包(含上限/截断/指纹/时间输入/漏网 ✓)+ 存档比较(同源取新 / 不同源 = diverge ✗ 不假装能自动合并)
tests/view-and-abi.test.ts受管画布:变换数学(纯函数)与无 DOM 时的明确报错;WASM ABI 版本协商
tests/assets.test.ts资产仓库:按 kind+url 去重、引用计数与 releaseOwner、失败与重试、进度、全局音量与静音、内置 loader 的环境差异
tests/scaffold.test.ts插件脚手架:在临时目录生成整套并真的加载运行;非法 id 拒绝、不覆盖已有文件、--no-story/--no-test
tests/docs-consistency.test.ts★ 文档一致性检查器:权限表与 ALL_PERMISSIONS、README 节点表、源码 emit 事件 ↔ EVENTS.md、npm run 脚本、文档路径、docs 索引、版本号、用例数三数口径、src 每个文件的文档落点
tests/setup.ts(不是测试文件,是全局兜底 ✓)每个用例之后一律 useRealTimers + restoreAllMocks + unstubAllGlobals ✓。别再靠"每个文件记得还原" ✗:漏一处就会让别的用例随机 30 秒超时(实测 6 次全量里红 2 次,且每次落在不同用例上)✓
tests/kit.test.ts无头测试套件自测(见 §2.4):minimal.json(纯内置节点)、demo.json(JS + WASM 插件、条件选项、自定义节点)、world.json(世界节拍驱动 observe),以及套件环境补丁的还原

当前共 440 个用例 = 417 个功能用例 + 23 个文档一致性检查器(明细:expression 12 / story 18 / plugin-manager 28 / wasm-abi 4 / world-loop 17 / kit 9 / plugin-timers 10 / capabilities 6 / determinism 30 / frame-loop 18 / input-router 13 / intents 10 / observability 10 / permission-scope 13 / runtime-semantics 19 / state-layer 22 / stories-valid 4 / view-and-abi 10 / assets 19 / editor 77 / scaffold 6 / editor-text 25 / embed 9 / host-typewriter 8 / lib 2 / link 5 / mount 5 / stories-shapes 8),npm test 全绿。

2.2 风格约定

2.3 新增单测的套路

import { describe, expect, it } from 'vitest'
import { StateStore } from '../src/runtime/StateStore'
import { StoryEngine, type StoryUi } from '../src/runtime/StoryEngine'

describe('某能力', () => {
  it('应该做正确的事', async () => {
    const state = new StateStore()
    // ...解构 engine,喂剧情,跑 run()
    expect(state.get('var.gold')).toBe(10)
  })
})

2.4 无头测试套件 @rne/testing

tests/kit/index.ts 把「假 UI + file:// 资源加载 + 微任务泵 + 驱动脚本」收敛成一份, 写一个跑通整条剧情链路的用例只要十几行:

import { createSandbox } from './kit/index'

it('世界事件把剧情拉到旁白节点', async () => {
  const sb = createSandbox()          // 装环境补丁 + 装配 Runtime + 记录轨迹
  try {
    await sb.load('world.json')       // 相对 public/ 的剧情路径
    await sb.begin()                  // 启动(安全版:绝不会 await 到剧情结束)
    await sb.driver.tickWorld(2)      // 手动推进世界节拍(确定性,不依赖真实定时器)
    expect(sb.nodes).toContain('show_extinction_message')
  } finally {
    sb.restore()                      // 还原 location / fetch / document / localStorage
  }
})

它专门替你处理四件麻烦事:

  1. loadStory({ autoStart: true }) 的 Promise 直到剧情结束才 resolve —— 直接 await 会永久挂起; sandbox.begin() 用「不 await + 泵宏任务」绕开,这是唯一安全的写法。
  2. file:// 下剧情里的 plugins: ['/plugins/x/manifest.json'] 会解析到文件系统根目录 —— 套件的 fetch 补丁会把 plugins[] 重写成资源根下的真实 URL(WASM 按字节读,不会被当文本读坏)。
  3. wait 节点会真等、say 会一直等点击 —— 假 UI 把 wait 立即结算;say 排进队列由 driver 控制(advance() / until() / runUntilEnd() / chooseByText())。
  4. 插件里读 document / localStorage 的代码在 node 下会抛 —— 套件默认装极小 DOM 桩与内存版 localStorage(假 UI 不会真正挂载组件,所以插件的渲染器不会被调用)。

驱动脚本不会静默卡死:一旦剧情既没等点击、也没等选项、也没结束,runUntilEnd() / until() 会抛错并说明当前节点,并提示你可能是世界驱动的节点、需要改用 tickWorld()。

假时钟的坑(我连吃三次 30 秒超时才搞清):如果某个测试文件里用了 vi.useFakeTimers(), 那么 vi.useRealTimers() 的恢复必须放在文件顶层 ✗ 不能塞进某个 describe 里 —— 否则"追加到文件顶层的新测试"会继承上一个测试的假时钟,setTimeout 永不触发, await new Promise((r) => setTimeout(r, 0)) 会永久挂起(表现为测试超时,看起来像引擎挂了 ✗)。 更稳的做法:等待用套件的 ui.waitFor(predicate, turns),而不是自己 await 定时器。

注意:套件补丁是全局的(location / fetch / document / localStorage), 每个用例应当 createSandbox() 后 finally { sb.restore() }。vitest 默认按文件隔离进程, 通常不会互相干扰。


2.5 门禁:让检查器真的会红

文档/一致性检查器的全部价值建立在会红上。它有两种接线方式:

方式内容状态
git 钩子(推荐,已验证).githooks/pre-push:推送前自动跑 npm run typecheck + npm test,并检查入库的库产物是否与源码同步(build:lib 后有差异就拒绝推送)。npm install 会通过 prepare 脚本自动把 core.hooksPath 指过去;手动的话两条命令任选:npm run prepare(同 npm install 那一步)或 npm run hooks:install✅ 已在本仓库验证会跑、会拦
CI.github/workflows/ci.yml(GitHub Actions 格式):npm ci → typecheck → test → story:lint → build⚠️ 格式未在 GitCode 上验证(编写时读不到它的流水线文档);不认就照这四步手配,关键是别漏 npm test

临时跳过门禁:SKIP_HOOKS=1 git push(会打印醒目警告 —— 逃生门要有,但不能悄悄用)。

3. 端到端测试(Playwright)

E2E 用 Playwright 驱动真实 Chromium,playwright.config.ts 会先起 dev server(http://localhost:5173)。

状态提示(2026-09):仓库已清理掉非示例组件(entropy-war 剧情、entropy-*/tycoon-*/ashforged/ironworld 插件、scripts/entropy-*、public/entropy-worker.js),完整原始内容归档于 archive/original 分支。 当前可运行的 E2E 是 tests/e2e/reload.spec.ts(剧情重载)与 tests/e2e/world.spec.ts(世界模拟器)。 下述 §3.1 的 entropy.spec.ts 仍保留全部代码, 但已通过 playwright.config.ts 的 testIgnore 停用,默认不运行、不参与 CI;如需复跑,先从 archive/original 分支取回对应剧情/插件放回 public/ 再执行。

3.0 可运行用例

tests/e2e/world.spec.ts —— 世界模拟器 E2E

验证「剧情不再主动推进,而是被动听世界说话」,并确认 JSON / JS / WASM 三路平权:

  1. 世界循环每拍驱动 WASM 插件把 var.species_count 递减到 0;
  2. 归零时 WASM 发布 ecology:species_extinct,被两处同时接收: a. 剧情 JSON 的 observe 节点 → 被动跳转到「世界」旁白; b. JS 插件的 ctx.world.subscribe → 在 #plugin-ui 弹出 .world-notice 警示;
  3. 通过 window.__storyRuntime.state 断言状态真的被 WASM 写进 StateStore。

该用例依赖 world.json 剧情(public/stories/)与示例插件,page.screenshot(...) 留证。

3.1 已停用用例(仅保留文件):tests/e2e/entropy.spec.ts

⚠️ 此用例依赖已移出的《熵减战争》压测资产,当前不会运行。以下为其历史验收说明,仅供恢复资产后复跑参考。

这是针对《熵减战争》压测剧情的硬核验收,四条用例对应四条硬指标与三路 WASM 集成:

  1. 跨域隔离 + 数据正确性:断言 crossOriginIsolated === true(SAB 前提)、mode === 'sab'、 三路 WASM 的 Float64Array 数据长度(nbody.raw_stats=32 / sample=96、cfd.sample=128、ga.sample=178)。
  2. pre 输出庞大 JSON + 刷新率:data-bytes > 5000、可被 JSON.parse、~1s/次 刷新(允许 0.5x~2x 抖动)。
  3. js-system_switch 启停:表格 3 行、tick 推进、停止后 tick 冻结、再次启动恢复。
  4. 主线程不被阻塞:重算下 rAF 2 秒内 ≥30 帧且最大间隔 <500ms,采样器 tick 仍推进。

关键辅助函数 waitForAllSystems()(勿删或跳过):三个 Worker 各自实例化 WASM 并分配线性内存,初始化节奏不同: N 体(26MB)与 GA(<1MB)先出结果,流体(20MB,1024² 网格)会晚几个采样周期。 它用 expect.poll 等到三路都产出非零物理量 + 都以步进≥1 再继续断言, 否则会把「流体还没上线」误判成「流体数据为 0」。新增数据断言时,务必同样等待「目标子系统已上线」,不要只等某一路上线就断言。

3.2 访问 var.sim 的调试句柄

E2E 通过引擎暴露的 window.__storyRuntime.state.get('var.sim') 直接读插件写入的状态,避免依赖 DOM 文本。

async function readSim(page) {
  return page.evaluate(() => {
    const rt = window.__storyRuntime
    return rt?.state.get('var.sim') ?? null
  })
}

3.3 新增 E2E 的套路

import { expect, test } from '@playwright/test'

test('我的场景', async ({ page }) => {
  await page.goto('/')
  // 选剧情、等节点/组件挂载
  // 交互、断言 data-* 属性(尽量少依赖脆弱的选择器)
})

建议给关键 DOM 打 data-* 属性做稳定锚点(如 data-role="launch"、data-refresh-count), 别依赖文本/位置这类脆弱定位。


4. 常见问题


相关链接