DOCUMENTATION · 测试体系
测试体系
本文介绍本项目如何写、如何运行测试。栈是 Vitest(单元测试)+ Playwright(端到端), 两者分别覆盖「引擎内核逻辑」与「真实浏览器里的整链路」。
权威配置:
vitest.config 相关(无独立配置时走内置默认)、playwright.config.ts、package.json#scripts。 若与源码冲突,以源码与实际运行结果为准。
1. 总览
| 层 | 工具 | 目录 | 目标 |
|---|---|---|---|
| 单元测试 | Vitest | tests/*.test.ts | 引擎内核:Expression DSL、StoryEngine 节点流转、PluginManager 生命周期、WASM ABI 编解码 |
| 端到端 | Playwright | tests/e2e/*.spec.ts | 真实浏览器 + dev server:点击、DOM、事件、性能、WASM 集成 |
| 集成(无头) | Vitest | tests/kit.test.ts + tests/kit/index.ts | 真实 Runtime + 真实插件加载器(含 WASM)+ 假 UI:整套剧情与插件链路在 node 里跑完,不需要浏览器 |
命令:
npm test # vitest run —— 跑全部单测
npm run test:watch # vitest —— 监听模式
npx playwright test # 跑全部 E2EE2E 首次需要浏览器依赖:
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.ts | WASM 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 个文档一致性检查器(明细:
expression12 /story18 /plugin-manager28 /wasm-abi4 /world-loop17 /kit9 /plugin-timers10 /capabilities6 /determinism30 /frame-loop18 /input-router13 /intents10 /observability10 /permission-scope13 /runtime-semantics19 /state-layer22 /stories-valid4 /view-and-abi10 /assets19 /editor77 /scaffold6 /editor-text25 /embed9 /host-typewriter8 /lib2 /link5 /mount5 /stories-shapes8),npm test全绿。
2.2 风格约定
- 纯逻辑、无浏览器 API:引擎内核不依赖 DOM,单测直接在 Node 环境跑。
若被测代码触到
localStorage/window,用传入假依赖或getStorage()-style 的守卫,别引真实的浏览器对象。 - 用真实实例,少 mock:
StateStore、EventBus、StoryEngine、PluginManager都是可new的真类, 构造时注入假的 UI(如假RuntimeUi/ 假renderChoice)即可,尽量不 mock 内部。 - 断言状态而非实现细节:尽量断言
state.get(...)的返回值、engine.currentId、事件 payload,而不是某函数被调用了几次。
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
}
})它专门替你处理四件麻烦事:
loadStory({ autoStart: true })的 Promise 直到剧情结束才 resolve —— 直接await会永久挂起;sandbox.begin()用「不 await + 泵宏任务」绕开,这是唯一安全的写法。- file:// 下剧情里的
plugins: ['/plugins/x/manifest.json']会解析到文件系统根目录 —— 套件的 fetch 补丁会把plugins[]重写成资源根下的真实 URL(WASM 按字节读,不会被当文本读坏)。 wait节点会真等、say会一直等点击 —— 假 UI 把wait立即结算;say排进队列由driver控制(advance()/until()/runUntilEnd()/chooseByText())。- 插件里读
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 三路平权:
- 世界循环每拍驱动 WASM 插件把
var.species_count递减到 0; - 归零时 WASM 发布
ecology:species_extinct,被两处同时接收: a. 剧情 JSON 的observe节点 → 被动跳转到「世界」旁白; b. JS 插件的ctx.world.subscribe→ 在#plugin-ui弹出.world-notice警示; - 通过
window.__storyRuntime.state断言状态真的被 WASM 写进 StateStore。
该用例依赖
world.json剧情(public/stories/)与示例插件,page.screenshot(...)留证。
3.1 已停用用例(仅保留文件):tests/e2e/entropy.spec.ts
⚠️ 此用例依赖已移出的《熵减战争》压测资产,当前不会运行。以下为其历史验收说明,仅供恢复资产后复跑参考。
这是针对《熵减战争》压测剧情的硬核验收,四条用例对应四条硬指标与三路 WASM 集成:
- 跨域隔离 + 数据正确性:断言
crossOriginIsolated === true(SAB 前提)、mode === 'sab'、 三路 WASM 的 Float64Array 数据长度(nbody.raw_stats=32 / sample=96、cfd.sample=128、ga.sample=178)。 - pre 输出庞大 JSON + 刷新率:
data-bytes > 5000、可被JSON.parse、~1s/次刷新(允许 0.5x~2x 抖动)。 js-system_switch启停:表格 3 行、tick 推进、停止后 tick 冻结、再次启动恢复。- 主线程不被阻塞:重算下 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. 常见问题
- E2E 报
libatk...so.0等浏览器依赖缺失:执行npx playwright install --with-deps chromium。 SharedArrayBuffer在 E2E 里不可用:本项目默认关闭跨域隔离(见vite.config.ts)。 需要 SAB 的用例要用CROSS_ORIGIN_ISOLATION=1 npx playwright test启动(webServer 会继承该环境变量); 另注意reuseExistingServer: true—— 若本机已有未开隔离的 dev server 在跑,会被复用导致隔离失效, 先停掉它。详见 CROSS_ORIGIN.md §3.1。- 单测用了浏览器 API:把依赖作为构造参数传入,或改用假实现。
- E2E 时有时无(flaky):多半是三路子系统上线节奏不同,核对是否走了
waitForAllSystems。
相关链接
- ARCHITECTURE.md:引擎内部结构,便于写针对性测试。
- STORY_FORMAT.md:剧情文件格式,E2E 剧情、单测构造剧情都看它。
- EVENTS.md:事件契约,单测里断言事件 payload 时参考。
- CROSS_ORIGIN.md:跨域隔离开关(
CROSS_ORIGIN_ISOLATION)—— 用到 SAB 的 E2E 前提。