Plate 仓库测试双车道模型:以文件名后缀收敛 fast/slow 测试车道的基础设施清理实践
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
Plate 是一个基于 Slate 构建、面向 AI 场景与 shadcn/ui 的富文本编辑器 monorepo,其测试体系横跨apps/、packages/数十个包。本文以 docs/plans/2026-03-23-two-test-lanes-cleanup.md 这份清理计划为骨架,深入讲解 Plate 如何将测试模型收敛为「fast / slow 两条车道」的单一命名约定,并结合仓库中的运行器源码、配置常量与实际.slow.*测试分布,说明这套测试基础设施的运作原理与落地方式。读完本文,你将掌握 Plate 测试车道的完整心智模型:文件后缀如何决定测试归属、两条车道的运行器差异、性能门槛如何强制维护车道纪律,以及如何在自己的测试体系中复刻这套「命名即分类」的设计。
清理背景:为什么要把测试收敛为两条车道
大型 monorepo 的测试体系很容易在演进中积累隐式分类:除了显式的「慢测试」之外,工具链常常还会按照目录路径悄悄地把一部分测试划入慢速通道。Plate 的这次清理正是针对这一问题。
原计划文档明确指出当前状态的核心矛盾:
The current setup has a hidden path-bucketed slow lane in
test-fast.mjson top of the explicit*.slow.*lane. That makesdocxand package-integration tests behave like slow tests without actually being named or run like slow tests.
即当时的test-fast.mjs运行器中存在一套基于路径分桶(path-bucketed)的隐藏慢速车道,它与显式的*.slow.*命名车道叠加在一起。结果是:docx与 package-integration 相关测试在行为上像慢测试(会被排除出快速通道),但在命名上、运行方式上又不是慢测试。这种「行为与命名不一致」的状态会带来一系列问题:
- 可发现性差:开发者无法通过文件名判断一个测试属于快车道还是慢车道;
- 工具行为不透明:同一份测试代码,是否被运行、被哪个运行器运行,取决于它所在的目录,而不是它自身的命名;
- 重构风险高:移动文件位置可能导致测试在快慢车道之间意外漂移;
- 维护负担重:路径分桶清单需要随着新测试的加入持续手工维护。
清理的目标因此非常明确——把测试归属的判定收敛为唯一、可见、与位置无关的规则:文件后缀。
目标模型:两条车道,一种命名约定
计划文档定义了清理后的最终测试模型,恰好两条车道:
| 车道 | 命名约定 | 说明 |
|---|---|---|
| fast(快速车道) | *.spec.ts[x] | 常规单元/组件测试,要求快速稳定 |
| slow(慢速车道) | *.slow.ts[x] | 重量级、IO 密集、集成类测试 |
这一模型的核心设计哲学是**「命名即分类」(naming is the classification)**:测试属于哪条车道,完全由文件名后缀决定,与文件所在目录无关。任何开发者只要看到foo.slow.tsx,就知道它会进入慢速车道;看到bar.spec.tsx,就知道它属于快速车道。这份计划沉淀在 docs/plans/2026-03-23-two-test-lanes-cleanup.md 中,而它的落地证据遍布仓库的配置与脚本中。
现状剖析:test-suites.mjs 中的模式定义
理解这次清理,首先要看测试车道的「事实来源」——tooling/config/test-suites.mjs。该文件集中定义了所有测试文件的 glob 匹配模式,是test-fast.mjs、test-slow.mjs等运行器的唯一数据源:
export const TEST_FILE_PATTERNS = [ 'apps/**/*.spec.{ts,tsx}', 'packages/**/*.spec.{ts,tsx}', 'tooling/scripts/**/*.test.mjs', ]; export const TEST_SLOW_FILE_PATTERNS = [ 'apps/**/*.slow.{ts,tsx}', 'packages/**/*.slow.{ts,tsx}', ]; export const TEST_DEFERRED_FILE_PATTERNS = [ 'apps/**/__deferred__/**/*.deferred.spec.{ts,tsx}', ]; export const TEST_IGNORE_PATTERNS = [ '**/coverage/**', '**/dist/**', '**/node_modules/**', '.next/**', '**/__deferred__/**', ];逐项解读这份配置:
TEST_FILE_PATTERNS:快速车道收集模式,覆盖apps/**与packages/**下所有*.spec.{ts,tsx},外加tooling/scripts/**下的*.test.mjs(工具脚本自身的测试也进快车道);TEST_SLOW_FILE_PATTERNS:慢速车道收集模式,同样是apps/**与packages/**全域,但只看*.slow.{ts,tsx}后缀——注意这里没有任何路径分桶,慢车道完全由后缀驱动,这正是清理后想达到的状态;TEST_DEFERRED_FILE_PATTERNS:延期测试(deferred)模式,位于__deferred__目录下的.deferred.spec.*,由独立的test:deferred车道处理,与 fast/slow 双车道模型分离;TEST_IGNORE_PATTERNS:所有车道共同忽略的产物目录(coverage、dist、node_modules、.next)以及__deferred__目录本身,防止构建产物或延期测试被误收集。
从当前这份配置可以看出,路径分桶已经不存在——TEST_SLOW_FILE_PATTERNS只依赖文件名后缀,没有任何针对docx、docx-io或package-integration目录的排除/包含规则。这印证了清理计划第 1 步(Remove path-based slow buckets fromtooling/config/test-suites.mjs)与第 2 步(fast-lane 工具停止按路径排除)已经落地。
清理执行:五个工作项的落地路径
计划文档将清理工作拆为五个可执行步骤,逐一对照仓库现状可以还原其落地轨迹:
第 1 步:从 test-suites.mjs 移除基于路径的慢速分桶。如上文所示,tooling/config/test-suites.mjs 当前的慢车道模式只认*.slow.{ts,tsx}后缀,路径分桶规则已完全移除。
第 2 步:更新 fast-lane 工具,停止按路径排除文件。test-fast.mjs 中收集快车道测试的核心逻辑为:
const allFastFiles = globSync(TEST_FILE_PATTERNS, { cwd: process.cwd(), ignore: TEST_IGNORE_PATTERNS, onlyFiles: true, }).sort();它仅依据TEST_FILE_PATTERNS+TEST_IGNORE_PATTERNS做收集,文件路径只参与用户指定的过滤参数(pathFilters),不再内置任何「把某目录排除出快车道」的硬编码逻辑。
第 3 步:将当前被分桶的慢速 specs 重命名为*.slow.ts[x]。计划明确列出四类需要重命名的测试:
packages/docx/src/**packages/docx-io/src/**apps/www/src/__tests__/package-integration/**- 当时的
reactHeavyspecs
从当前仓库的慢车道文件分布(见下文第五节)可以看到,这四类测试如今全部以.slow.ts[x]后缀命名,例如packages/docx/src/lib/docx-cleaner/cleanDocx.slow.ts、packages/docx-io/src/lib/__tests__/tables.slow.tsx、apps/www/src/__tests__/package-integration/slate-contracts.slow.tsx。此外,全仓库搜索reactHeavy仅命中计划文档自身,说明该历史分类标记已无代码残留,重命名工作已完整收尾。
第 4 步:test:slow保持为唯一的慢速车道运行器。根目录 package.json 的脚本定义印证了这一点:test:slow指向bun tooling/scripts/test-slow.mjs,是慢速车道唯一的入口。
第 5 步:更新活跃测试指引,使「slow」仅指文件名后缀。这一语义已经固化在配置与脚本中:慢 = 文件名以.slow.ts[x]结尾,与目录位置、运行器内部逻辑无关。相关的同批次计划如 docs/plans/2026-03-23-docx-fast-lane-reclaim.md 也从 docx 一侧记录了快车道回收的配套工作。
运行器源码剖析:test-fast.mjs 与 test-slow.mjs
两条车道的运行器 test-fast.mjs 与 test-slow.mjs 共享同一套实现骨架,差异集中在测试收集模式与少量运行细节上。二者都基于 Bun 的bun test子进程驱动。
参数解析与路径过滤
两个脚本都定义了VALUE_FLAGS集合,把需要取值的 flag(如--bail、--reporter、--reporter-outfile、--max-concurrency、--seed、--timeout、-t等)与普通布尔 flag 区分开,剩余的非-前缀参数则进入pathFilters:
for (let i = 0; i < rawArgs.length; i++) { const arg = rawArgs[i]; if (arg === '--') continue; const matchedValueFlag = [...VALUE_FLAGS].find( (flag) => arg === flag || arg.startsWith(`${flag}=`) ); if (matchedValueFlag) { bunArgs.push(arg); if (arg === matchedValueFlag) { const nextArg = rawArgs[i + 1]; if (nextArg) { bunArgs.push(nextArg); i += 1; } } continue; } if (arg.startsWith('-')) { bunArgs.push(arg); continue; } pathFilters.push(arg); }pathFilters支持静态路径(目录或文件)与动态 glob(通过isDynamicPattern判断),最终从全量测试文件集合中筛选出选中文件。test-fast.mjs在未匹配到任何测试时会提示用户改用pnpm test:slow运行*.slow.*车道,形成两条车道的引导闭环:
if (selectedFiles.length === 0) { console.error( 'No fast-suite tests matched. Use `pnpm test:slow` for `*.slow.*` lanes.' ); process.exit(1); }mock.module 隔离机制
两个运行器都实现了递归的 mock 依赖检测:脚本扫描测试文件源码中是否出现mock.module(字样,若没有则递归解析其本地相对导入(通过LOCAL_IMPORT_PATTERN正则 +LOCAL_SOURCE_EXTENSIONS扩展名候选列表解析真实文件),确认是否存在间接使用 mock 的测试:
const MOCK_MODULE_PATTERN = 'mock.module('; const LOCAL_IMPORT_PATTERN = /\b(?:import|export)\b[^'"]*?from\s*'"['"]|\bimport\s*\(\s*'"['"]\s*\)|\brequire\s*\(\s*'"['"]\s*\)/g;凡检测到使用mock.module(的文件会被放入isolatedFiles单独逐个运行(避免模块 mock 污染同批次的共享执行环境),其余文件放入sharedFiles合并运行。这一隔离策略对两条车道一视同仁,保证了 mock 测试的确定性。
JUnit 报告合并
当以--reporter=junit --reporter-outfile=<file>运行时,脚本为每个批次生成独立的 JUnit XML(临时目录前缀分别为plate-fast-junit-与plate-slow-junit-),随后通过剥离 XML 声明与<testsuites>包裹层、再统一合并的方式生成最终报告:
const mergeJunitReports = (files, outfile) => { const suites = files .map((file) => readFileSync(file, 'utf8') .replace(XML_DECLARATION_RE, '') .replace(TESTSUITES_OPEN_RE, '') .replace(TESTSUITES_CLOSE_RE, '') .trim()) .filter(Boolean); writeFileSync(outfile, [ '<?xml version="1.0" encoding="UTF-8"?>', '<testsuites name="bun test">', ...suites, '</testsuites>', '', ].join('\n')); };两套运行器的差异
- 收集模式不同:
test-fast.mjs用TEST_FILE_PATTERNS(*.spec.{ts,tsx}+tooling/scripts/**/*.test.mjs),test-slow.mjs用TEST_SLOW_FILE_PATTERNS(*.slow.{ts,tsx}); - 文件路径显式化:
test-slow.mjs在最终运行前会为每个文件补./前缀(explicitPaths),确保 Bun 按显式路径执行慢速测试; - 空匹配提示不同:fast 车道提示转向
test:slow,slow 车道则直接提示No slow tests matched.。
性能门槛与车道纪律:test-slowest.mjs
双车道模型要真正成立,还必须防止快车道悄悄变慢。为此仓库在 tooling/config/test-suites.mjs 中定义了 CI 感知的性能阈值常量:
export const FAST_TEST_SLOW_CASE_THRESHOLD_MS = isCI ? 90 : 75; export const FAST_TEST_SLOW_FILE_THRESHOLD_MS = isCI ? 180 : 150; export const FAST_TEST_WARN_CASE_THRESHOLD_MS = isCI ? 75 : 60; export const FAST_TEST_WARN_FILE_THRESHOLD_MS = isCI ? 150 : 120;阈值的语义是:
- slow 阈值:单个用例超过 90ms(CI)/ 75ms(本地)、单个文件超过 180ms(CI)/ 150ms(本地),即判定为「应移入慢车道」的硬门槛;
- warn 阈值:低于 hard 线但已偏慢(用例 75/60ms,文件 150/120ms)的警告区,CI 中作为可见的漂移信号;
- CI 运行器噪声更大,因此 CI 侧阈值比本地略宽,本地保持更严格的纪律。
这套阈值由 test-slowest.mjs 执行:它内部调用test-fast.mjs并注入--reporter=junit,解析快车道每个用例/文件的耗时,与阈值比对后输出慢速报告。其配套参数包括:
--profile:非失败模式的分析运行(对应pnpm test:profile);--top N:只输出耗时 Top N 的文件。
test-slowest.mjs顶部的注释写明了车道纪律的执行规则:
When a fast-suite spec repeatedly crosses these thresholds, move the whole spec to
*.slow.ts[x].pnpm test:slowestandpnpm checkenforce these limits.
即:当快车道中的某个 spec 反复越过阈值,应把整个 spec 移到*.slow.ts[x]——不是只修单个用例,而是整体降级,这正是保持双车道纯净的机制。
仓库中实际的 slow 测试分布
清理完成后,慢车道测试在仓库中的分布完全遵循*.slow.{ts,tsx}后缀约定。当前仓库共有 77 个.slow.*测试文件,覆盖多个包与目录,代表性样本包括:
- docx 包:
packages/docx/src/lib/docx-cleaner/cleanDocx.slow.ts(docx 清理器的重量级测试); - docx-io 包:
packages/docx-io/src/lib/__tests__/tables.slow.tsx、packages/docx-io/src/lib/__tests__/block_quotes.slow.tsx、packages/docx-io/src/lib/internal/html-to-docx.slow.ts等; - package-integration(apps/www):
apps/www/src/__tests__/package-integration/slate-contracts.slow.tsx、apps/www/src/__tests__/package-integration/docx/align.slow.tsx、apps/www/src/__tests__/package-integration/core-static-html/serialize-html.roundtrip.slow.ts、apps/www/src/__tests__/package-integration/markdown-deserializer/deserializeMd.slow.tsx等; - AI 包:
packages/ai/src/react/ai-chat/hooks/useAIChatEditor.slow.tsx、packages/ai/src/react/ai-chat/utils/submitAIChat.slow.ts等; - core / 其他包:
packages/core/src/react/components/Plate.slow.tsx、packages/yjs/src/lib/__tests__/collaboration/index.slow.ts、packages/table/src/lib/merge/tableMergeBehavior.slow.tsx等。
这些文件不因所属目录而被特殊对待,慢车道身份完全来自文件名后缀——这正是「命名即分类」的直观体现。
相关 npm 脚本一览
双车道模型最终通过根目录 package.json 的脚本对外暴露,形成完整的使用入口:
| 脚本 | 命令 | 作用 |
|---|---|---|
test | bun tooling/scripts/test-fast.mjs | 运行 fast 车道(*.spec.*) |
test:slow | bun tooling/scripts/test-slow.mjs | 运行 slow 车道(*.slow.*),唯一的慢车道入口 |
test:all | pnpm test && pnpm test:slow | 两条车道全量运行 |
test:slowest | bun tooling/scripts/test-slowest.mjs | 执行快车道性能门槛检查(失败模式) |
test:profile | bun tooling/scripts/test-slowest.mjs --profile | 非失败的性能画像,输出慢速用例 |
test:deferred | bun tooling/scripts/test-deferred.mjs | 运行__deferred__目录下的延期测试 |
test:watch | bun tooling/scripts/test-fast.mjs --watch | fast 车道监听模式,开发期使用 |
check | pnpm lint && pnpm typecheck && pnpm test:all && pnpm test:slowest | 全量门禁,含性能门槛强制检查 |
日常开发反馈循环使用pnpm test(快车道)即可;提交前跑pnpm check会同时触发两条车道与test:slowest性能门槛,确保「快车道不偷偷变慢」这一纪律在 CI 与本地都得到强制执行。
清理的验收与启示
对照计划文档的五步工作项,当前仓库的最终状态是:
test-suites.mjs中已不存在基于路径的慢速分桶,慢车道模式仅由*.slow.{ts,tsx}后缀驱动;test-fast.mjs不再内置按路径排除的逻辑;- 原被分桶的 docx、docx-io、package-integration 及 reactHeavy 测试均已重命名为
.slow.ts[x],全仓库 77 个慢速测试文件遵循同一命名约定; test:slow是慢车道唯一运行入口;- 「slow = 文件名后缀」的语义已固化进配置、脚本与性能门槛机制中。
这套「双车道 + 命名即分类 + 性能门槛」的组合,为大型编辑器 monorepo 提供了可复制的测试分层范式:命名约定让测试归属一目了然,性能门槛用数据强制维护车道纪律,而统一配置文件(tooling/config/test-suites.mjs)则让所有运行器共享唯一的分类事实来源。对于正在构建多包仓库测试体系的团队,这是一份可以直接借鉴的工程实践。
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考