场景测试:基于 JSON-RPC 的端到端验证
场景测试(scenario tests)是 SpinningMomo 测试体系中的端到端(end-to-end)验证层:它在一个真实的后端进程生命周期内,通过 JSON-RPC 接口驱动完整业务场景,验证从请求入口到数据落盘的整条链路。本文覆盖 tests/scenarios/ 目录下的运行器(run.ts)、场景套件(gallery_core.ts、gallery_recovery.ts、capture.ts)以及各场景阶段(phase)的组织方式。
Purpose and Scope
本页面说明:
- 场景测试的总体架构:套件编排器 → 子进程场景套件 → 多个有序阶段;
- 运行器
tests/scenarios/run.ts的进程编排、CLI 筛选与退出码传播逻辑; gallery_core.ts套件如何将 9 个相互独立的阶段串在同一进程内执行;- 场景目录的文件布局与各阶段文件对应验证的业务能力;
- 失败模式与运行时注意事项。
不在本页范围内(属于兄弟页面):
- 单元/特性级测试(
tests/features/,如tests/features/gallery/ignore/matcher_test.cpp)——这些是对 C++ 内部组件的直接单元验证; - JSON-RPC 协议本身的定义与后端服务实现细节。
Overview
与单元测试不同,场景测试回答的问题是:"当真实后端进程跑起来之后,一连串用户操作是否仍能产生一致的结果?" 为此,它具备以下特征:
- 真实进程:每个场景套件由
run.ts通过child_process.spawn以独立的 Node 子进程启动(见runScenarioProcess),并非在同一进程内 mock 调用。 - 有序阶段:一个套件内部包含多个按顺序执行的场景阶段(phase),这些阶段共享同一后端进程与沙箱生命周期,但彼此使用互不冲突的文件路径与带前缀限定的断言(例如
query_filters阶段的断言以qf-前缀限定),因此可以在同一进程内顺序执行而互不干扰。 - 套件级隔离:不同套件之间通过"一个套件 = 一个子进程"的方式天然隔离;套件失败会中止后续套件执行,避免错误级联。
- 按子集筛选:支持在命令行传入非
-开头的位置参数作为套件名过滤器,便于本地只跑相关套件。
目录结构概览
1tests/scenarios/
2├── run.ts # 套件编排器(入口)
3├── gallery_core.ts # 图库核心功能场景套件(9 个阶段)
4├── gallery_recovery.ts # 图库恢复类场景套件
5├── capture.ts # 采集(截图/录制)场景套件
6├── gallery/ # gallery_core 的各阶段实现
7│ ├── folder_tree.ts、scanner_metadata.ts、hash_inheritance.ts
8│ ├── move_consistency.ts、missing_restore.ts、watcher_consistency.ts
9│ ├── tag_crud.ts、query_filters.ts、purge_missing.ts
10│ └── expired_missing_purge.ts、unreachable_root.ts、path_encoding.ts
11├── capture/ # capture 套件的阶段实现
12│ ├── recording.ts、screenshot.ts
13├── fixtures/ # 测试用静态素材(solid_blue.png、solid_green.png 等)
14└── support/ # 公共支撑代码
15 ├── runtime.ts # 提供 REPOSITORY_ROOT 等运行时路径常量
16 └── support.ts # 提供 runScenarioPhases 等阶段编排原语Architecture
图示要点:
run.ts是唯一入口,它把每个套件文件当作一个独立 Node 子进程启动(cwd固定为REPOSITORY_ROOT,stdio: "inherit"直接透传输出)。gallery_core.ts不自己启动进程,而是把 9 个阶段交给runScenarioPhases(来自support/support.ts)在同一进程内顺序执行。- 阶段(如
folder_tree.ts)通过 JSON-RPC 与真实后端交互并断言结果;fixtures 目录提供确定性素材(纯色 PNG),保证 hash 断言稳定。
主内容:编排器与套件的实现
1. 编排器 run.ts 的进程模型
run.ts 的核心是 runScenarioProcess:以 process.execPath(即当前 Node 可执行文件)spawn 一个子进程运行指定套件脚本,并把 CLI 参数原样透传。其返回值是一个 Promise,仅在子进程 exit(或 error)后 resolve,settled 标志位保证 error 与 exit 两个一次性事件之间只有一个会生效,避免双重 settle:
1function runScenarioProcess(fileName: string, arguments_: string[]): Promise<number> {
2 const child = spawn(process.execPath, [join(scenarioDirectory, fileName), ...arguments_], {
3 cwd: REPOSITORY_ROOT,
4 stdio: "inherit",
5 windowsHide: false,
6 });
7
8 return new Promise<number>((resolve, reject) => {
9 let settled = false;
10 child.once("error", (error) => {
11 if (settled) {
12 return;
13 }
14 settled = true;
15 reject(error);
16 });
17 child.once("exit", (code) => {
18 if (settled) {
19 return;
20 }
21 settled = true;
22 resolve(code ?? 1);
23 });
24 });
25}Source: run.ts
设计意图:
- 为什么用子进程而不是直接 import:每个套件需要一次全新的后端进程与沙箱生命周期;子进程天然提供干净的进程状态与明确的退出码边界。
- 为什么
stdio: "inherit":套件内的日志、JSON-RPC 报错直接打到同一终端,便于在 CI 中定位失败阶段,无需额外日志聚合。 resolve(code ?? 1):若子进程被信号杀死(code为null),按失败(退出码 1)处理,防止异常终止被误判为通过。
2. 套件清单与顺序执行
套件列表硬编码在编排器中,注释明确了"同一真实进程与沙箱生命周期内按顺序执行多个相互独立的场景阶段"这一约束:
1// 每个套件在同一个真实进程与沙箱生命周期内按顺序执行多个相互独立的场景阶段。
2const SCENARIO_FILES = [
3 "gallery_core.ts",
4 "gallery_recovery.ts",
5 "capture.ts",
6];Source: run.ts
主循环对每个目标套件依次 await 执行,一旦退出码非 0 就打印 场景套件在 <file> 处停止 并 break,把最后一次的退出码写回 process.exitCode:
1for (const scenarioFile of targetScenarioFiles) {
2 console.log(`\n===== 场景:${scenarioFile} =====`);
3 try {
4 exitCode = await runScenarioProcess(scenarioFile, process.argv.slice(2));
5 } catch (error) {
6 console.error(`无法启动场景进程:${String(error)}`);
7 exitCode = 1;
8 }
9
10 if (exitCode !== 0) {
11 console.error(`场景套件在 ${scenarioFile} 处停止`);
12 break;
13 }
14}
15
16process.exitCode = exitCode;Source: run.ts
失败即停(fail-fast)的意义:后续套件往往依赖前序套件建立的共享状态或素材;继续执行只会产生误导性的连锁失败,因此尽早中止并保留现场。
3. CLI 参数解析与套件筛选
编排器对 process.argv.slice(2) 做轻量解析:跳过 --exe= / --target-exe=(及其带空格的形式,这表明套件脚本自身会消费一个"目标可执行文件"参数,用于指定被测后端二进制),其余非 - 开头的参数作为套件名过滤器(统一转为小写、en-US locale):
1const cliArguments = process.argv.slice(2);
2const suiteFilters: string[] = [];
3for (let i = 0; i < cliArguments.length; i++) {
4 const argument = cliArguments[i];
5 if (argument.startsWith("--exe=") || argument.startsWith("--target-exe=")) {
6 continue;
7 }
8 if (argument === "--exe" || argument === "--target-exe") {
9 i++;
10 continue;
11 }
12 if (!argument.startsWith("-")) {
13 suiteFilters.push(argument.toLocaleLowerCase("en-US"));
14 }
15}
16
17const targetScenarioFiles =
18 suiteFilters.length > 0
19 ? SCENARIO_FILES.filter((file) =>
20 suiteFilters.some((filter) => file.toLocaleLowerCase("en-US").includes(filter)),
21 )
22 : SCENARIO_FILES;Source: run.ts
筛选语义是子串匹配:传入 capture 会命中 capture.ts;传入 gallery 会同时命中 gallery_core.ts 与 gallery_recovery.ts。当没有任何套件匹配时,编排器显式列出可用套件并以退出码 1 结束,给使用者明确的纠错提示:
1if (targetScenarioFiles.length === 0) {
2 console.error(
3 `未找到匹配的场景套件。传入筛选:${suiteFilters.join(", ")}\n可选套件列表:${SCENARIO_FILES.join(", ")}`,
4 );
5 process.exit(1);
6}Source: run.ts
4. gallery_core 套件:同一进程内的 9 个有序阶段
gallery_core.ts 的全部职责就是声明阶段顺序并交给 runScenarioPhases 执行。文件头部的中文注释是理解该套件可组合性的关键——它解释了为什么这些阶段能安全地共享一次进程生命周期:
1import folderTreePhase from "./gallery/folder_tree.ts";
2import scannerMetadataPhase from "./gallery/scanner_metadata.ts";
3import hashInheritancePhase from "./gallery/hash_inheritance.ts";
4import moveConsistencyPhase from "./gallery/move_consistency.ts";
5import missingRestorePhase from "./gallery/missing_restore.ts";
6import watcherConsistencyPhase from "./gallery/watcher_consistency.ts";
7import tagCrudPhase from "./gallery/tag_crud.ts";
8import queryFiltersPhase from "./gallery/query_filters.ts";
9import purgeMissingPhase from "./gallery/purge_missing.ts";
10
11import { runScenarioPhases } from "./support/support.ts";
12
13// 这些阶段都从同一空图库出发、使用互不冲突的文件路径、断言均按路径限定,
14// 因此可以在同一次真实进程生命周期内顺序执行。
15// hash_inheritance 依赖“最早的同 hash 资产”语义,使用独立 blue fixture;
16// watcher_consistency 会留下 missing 资产;query_filters 断言以 qf- 前缀限定;
17// purge_missing 依赖其余阶段留下的共享 logo 缩略图,放在最后。
18await runScenarioPhases("gallery_core", [
19 folderTreePhase,
20 scannerMetadataPhase,
21 hashInheritancePhase,
22 moveConsistencyPhase,
23 missingRestorePhase,
24 watcherConsistencyPhase,
25 tagCrudPhase,
26 queryFiltersPhase,
27 purgeMissingPhase,
28]);Source: gallery_core.ts
从中可以提炼出四条阶段共存契约(新增阶段时必须遵守):
- 同一空图库起点:所有阶段都假设套件启动时图库为空,不做阶段内的全局清理。
- 路径互不冲突:每个阶段使用独占的文件路径(fixtures 目录中的
solid_blue.png、solid_green.png等提供了确定性素材)。 - 断言按路径限定:如
query_filters的断言仅匹配qf-前缀的资产,避免被其他阶段的数据污染。 - 顺序敏感的阶段放两端:
purge_missing依赖"其余阶段留下的共享 logo 缩略图",因此被固定在最后;hash_inheritance依赖"最早的同 hash 资产"语义并使用独立的 blue fixture。
5. 阶段文件与验证的业务能力
tests/scenarios/gallery/ 下的阶段文件按名称即可对应到被验证的业务能力:
| 阶段文件 | 验证目标 |
|---|---|
folder_tree.ts | 目录树扫描与层级结构 |
scanner_metadata.ts | 扫描器产出的资产元数据 |
hash_inheritance.ts | 同 hash 资产的继承语义(最早者为准) |
move_consistency.ts | 资产移动后的一致性 |
missing_restore.ts | 缺失资产的恢复 |
watcher_consistency.ts | 文件系统 watcher 驱动的一致性(会留下 missing 资产) |
tag_crud.ts | 标签的增删改查 |
query_filters.ts | 查询过滤条件(断言以 qf- 前缀限定) |
purge_missing.ts | 缺失资产的清除(依赖共享 logo 缩略图) |
expired_missing_purge.ts | 过期缺失资产的清除(属 gallery_recovery 等套件) |
unreachable_root.ts | 不可达根目录的处理 |
path_encoding.ts | 路径编码(特殊字符路径) |
capture/ 目录下的 recording.ts 与 screenshot.ts 则覆盖录制与截图两条采集链路。这些阶段通过 JSON-RPC 与后端交互;runScenarioPhases 与 REPOSITORY_ROOT 分别由 tests/scenarios/support/support.ts 与 tests/scenarios/support/runtime.ts 提供(其内部实现细节未在本次收集范围内展开,此处仅依据 run.ts 与 gallery_core.ts 的 import 关系确认其导出)。
Core Flow:一次完整场景运行
顺序设计的原因:先解析筛选再 spawn,可以保证在启动任何昂贵的真实进程之前就发现"筛选无匹配"这类使用错误;而套件间 fail-fast 则避免在已经失败的状态下继续累积不可信结果。
Usage Examples
运行全部场景套件
node tests/scenarios/run.ts编排器会按 gallery_core.ts → gallery_recovery.ts → capture.ts 的固定顺序依次 spawn 子进程,任何一个失败即停止。
只运行与 "capture" 相关的套件
node tests/scenarios/capture.ts # 直接运行单套件(仍需相应 --exe 参数)
node tests/scenarios/run.ts capture # 经编排器筛选运行,便于与其他套件统一管理筛选为子串匹配:gallery 会同时命中 gallery_core.ts 与 gallery_recovery.ts;无匹配时编排器会列出全部可选套件并以退出码 1 退出。
新增一个 gallery_core 阶段(骨架)
依据 gallery_core.ts 的既有模式,新增阶段即为:在 tests/scenarios/gallery/ 下实现默认导出的阶段对象,然后插入 runScenarioPhases 的数组(注意上文"阶段共存契约"):
1import folderTreePhase from "./gallery/folder_tree.ts";
2// ...其余阶段 import
3import { runScenarioPhases } from "./support/support.ts";
4
5await runScenarioPhases("gallery_core", [
6 folderTreePhase,
7 // ...按依赖顺序插入新阶段
8]);Source: gallery_core.ts
Configuration Options
| 选项 | 形式 | 默认行为 | 说明 |
|---|---|---|---|
| 套件筛选 | 位置参数(非 - 开头) | 运行 SCENARIO_FILES 全部套件 | 对套件文件名做 en-US 小写子串匹配;可传多个 |
--exe / --target-exe | --exe=<path> 或 --exe <path> | 由套件脚本自行处理 | 指定被测后端可执行文件;编排器只负责剥离并透传,不消费其值 |
其他 - 开头参数 | 任意 | 原样透传给子进程 | 由各套件/阶段自行解析 |
API Reference
runScenarioProcess(fileName: string, arguments_: string[]): Promise<number>
启动一个场景套件子进程并等待其退出。
Parameters:
fileName(string): 套件文件名(如"gallery_core.ts"),相对于scenarioDirectory(即tests/scenarios/)。arguments_(string[]): 透传给子进程的 CLI 参数(编排器传入process.argv.slice(2),已被剥离--exe类选项之前的原始参数)。
Returns: Promise<number> — 子进程退出码;error 事件触发时 reject(如无法 spawn)。被信号终止(code 为 null)时按 1 resolve。
Throws / Rejects:
- spawn 失败(如 Node 可执行文件异常)时 reject,主循环捕获后置
exitCode = 1。
Source: run.ts
runScenarioPhases(suiteName: string, phases: Phase[]): Promise<void>
在当前进程内按序执行一组场景阶段。由 tests/scenarios/support/support.ts 导出,gallery_core.ts 以 await runScenarioPhases("gallery_core", [...9 个阶段]) 方式调用。阶段对象的具体结构未在本次收集的源码范围内展开。
Source: gallery_core.ts、gallery_core.ts
Failure Modes, Edge Cases & Concurrency
- 子进程被信号杀死:
exit事件的code为null,runScenarioProcess以1resolve,等同失败;settled标志防止error+exit双重 settle。 - 无法启动子进程:
spawn的error事件触发 reject,主循环catch后打印无法启动场景进程:...并置退出码 1(run.tsL77-L81)。 - 筛选无匹配:打印"未找到匹配的场景套件"并列出可选套件,
process.exit(1)(run.tsL67-L72)——在 spawn 任何进程之前完成校验。 - 套件失败级联中止:非 0 退出码触发
break,后续套件不再执行(run.tsL83-L86)。 - 阶段间共享状态:
gallery_core的 9 个阶段共享同一进程与沙箱;这不是并发,而是有序共享——契约(空图库起点、路径互斥、断言按前缀限定)保证了可组合性。watcher_consistency会留下 missing 资产、purge_missing依赖共享 logo 缩略图,因此阶段顺序是套件正确性的一部分,不可随意重排。 - 跨平台:
windowsHide: false保持 Windows 下子进程控制台窗口可见,配合stdio: "inherit"保证各平台日志可见性。
Performance & Operational Notes
- 进程开销:每套件一个子进程、每套件一个后端生命周期,换来的是最强的隔离性;代价是套件数量增多时总启动成本线性增长。这也是
gallery_core把 9 个可共存阶段合并进一个进程的原因——在不破坏隔离契约的前提下摊薄进程/沙箱启动成本。 - 本地迭代:利用子串筛选(如
run.ts capture)只跑相关套件,可显著缩短反馈回路。 - CI 友好:
stdio: "inherit"+ 明确的退出码传播(process.exitCode = exitCode)使 CI 无需解析输出即可判断成败。 - 扩展点:新增套件时,把它加入
SCENARIO_FILES(run.tsL8-L12)并实现为可被spawn的独立入口脚本;新增gallery_core阶段时遵守"阶段共存契约"并谨慎选择插入位置(顺序敏感阶段放最后)。