Plate 仓库 Yjs 协同编辑测试资产索引解读:从上游 CRDT 测试清单到本地协同收敛验证
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
本篇技术指南以 docs/editor-test-harvester/yjs-collaboration/test-index.md 为核心骨架,解读该仓库为Yjs 协同编辑建立的测试资产索引(Harvest Test Index):它盘点并归类了 slate-yjs、lexical-yjs、y-prosemirror、yjs 四个上游代码库中与协同编辑相关的全部可运行测试,同时结合本仓库 packages/yjs 内实际落地的协同编辑测试(多端内存连接器 + 收敛性 fixture)进行交叉印证。读完本文,你将理解:这张索引的字段含义、四大测试域各自覆盖的协同不变式、portable/portable-mixed/harness/skip等分类如何指导测试资产复用,以及如何在 Plate 的 Yjs 插件测试中复现"本地编辑 + 远端镜像收敛"这一核心验证模式。
文档定位:测试收割体系的协同编辑分册
在进入具体条目之前,先厘清这份文档在仓库中的位置。docs/editor-test-harvester/目录是仓库为编辑器行为测试收割(test harvester)建立的专题档案区,按编辑器/协议分为多个分册:
- docs/editor-test-harvester/lexical
- docs/editor-test-harvester/prosemirror
- docs/editor-test-harvester/tiptap
- docs/editor-test-harvester/portabletext
- docs/editor-test-harvester/yjs-collaboration
yjs-collaboration分册下有两份配套文档,本次主题是其中之一的test-index.md,另一份 inventory.md 则是它的"总账"(给出每个文件的类别、理由与收割命令)。两者配合使用:inventory 回答"这些测试是什么、是否可运行",test-index 回答"具体有哪些测试函数、在哪个文件哪一行"。
test-index.md的元信息非常简洁:
status: done license_mode: permissive这两行说明:该分册的索引工作已完成(done),且所索引的上游测试全部采用宽松许可(permissive),因此可以合法地作为测试资产被参考或移植。
四大测试域总览
test-index.md将协同编辑相关测试资产划分为四个上游域,下表为完整清单(文件、可运行性、测试导出数量均以文档逐行统计为准):
| 上游域 | 测试文件 | 可运行 | 测试导出数 | 索引行号范围 |
|---|---|---|---|---|
| slate-yjs | core/test/index.test.ts(adapter suite) | ✅ | 1 套 | L63 |
| slate-yjs | core/test/collaboration/{addMark,insertNode,insertText,mergeNode,moveNode,removeMark,removeNode,removeText,setNode,splitNode}/...共 54 个 fixture | ✅ | 54 fixture | L9-L62 |
| lexical-yjs | lexical/packages/lexical-yjs | ❌ | 无 | L66 |
| y-prosemirror | tests/delta.test.js | ✅ | 12 | L76-L88 |
| y-prosemirror | tests/positions.test.js | ✅ | 22 | L96-L118 |
| y-prosemirror | tests/suggestion-simulation.test.js | ✅ | 4 | L120-L124 |
| y-prosemirror | tests/suggestions.test.js | ✅ | 21 | L126-L147 |
| y-prosemirror | tests/tr.test.js | ⚠️ manual | 1 | L149-L150 |
| y-prosemirror | tests/undo.test.js | ✅ | 33 | L152-L185 |
| y-prosemirror | tests/y-prosemirror.test.js | ⚠️ manual | 23 | L187-L210 |
| y-prosemirror | tests/{cohort,complexSchema,index,index.node}.js | ❌ | 0(harness/支持文件) | L70-L94 |
| yjs | tests/IdMap.tests.js | ✅ | 7 | L214-L221 |
| yjs | tests/IdSet.tests.js | ✅ | 7 | L223-L230 |
| yjs | tests/attribution.tests.js | ✅ | 7 | L232-L239 |
| yjs | tests/compatibility.tests.js | ✅ | 3 | L241-L244 |
| yjs | tests/delta.tests.js | ✅ | 5 | L246-L251 |
| yjs | tests/doc.tests.js | ✅ | 11 | L253-L264 |
| yjs | tests/encoding.tests.js | ✅ | 3 | L266-L269 |
| yjs | tests/relativePositions.tests.js | ✅ | 9 | L274-L283 |
| yjs | tests/snapshot.tests.js | ✅ | 12 | L285-L297 |
| yjs | tests/undo-redo.tests.js | ✅ | 25 | L302-L327 |
| yjs | tests/updates.tests.js | ✅ | 8 | L329-L337 |
| yjs | tests/y-array.tests.js | ✅ | 41 | L339-L380 |
| yjs | tests/y-map.tests.js | ✅ | 40 | L382-L422 |
| yjs | tests/y-text.tests.js | ✅ | 47 | L424-L471 |
| yjs | tests/y-xml.tests.js | ✅ | 12 | L473-L485 |
| yjs | tests/{index,testHelper}.js | ❌ | 0(runner/支持文件) | L271-L299 |
需要说明:原文档中../slate-yjs/...、../y-prosemirror/...、../yjs/...等前缀是索引作者对上游仓库(当前仓库之外的兄弟目录)的相对引用,这些路径在本仓库内不存在;上表与下文的路径标识仅用于与原始索引一一对应。当前仓库内可验证的对应实现位于 packages/yjs,后文第 4 节详述。
分类体系:如何判断一份测试资产值不值得移植
inventory.md 为每个文件标注了Category字段,这是整个收割体系的决策核心,共五类:
| 类别 | 含义 | 典型例子 |
|---|---|---|
portable | 纯协同不变式,与具体编辑器视图解耦,可直接移植 | yjs 的relativePositions、snapshot、updates、undo-redo;slate-yjs 的全部 54 个操作 fixture |
portable-mixed | 协同不变式有价值,但与编辑器视图/插件策略耦合,需甄别后移植 | y-prosemirror 的suggestions、undo;yjs 的y-array、y-map、y-xml、attribution |
harness | 无行为断言,仅是运行器或辅助设施 | y-prosemirror 的index.js/cohort.js/complexSchema.js;yjs 的index.js/testHelper.js;slate-yjs 的withTestingElements.ts |
skip | 是 Yjs 内部存储/编码兼容性测试,对编辑器行为非直接目标 | IdMap、IdSet、compatibility、encoding |
manual | 有价值但需人工介入(如依赖浏览器视图) | y-prosemirror 的tr.test.js、y-prosemirror.test.js |
这一分类直接回答了"该不该把这份上游测试搬进 Plate 的测试体系":
- 优先收割
portable:它们验证的是 CRDT 层面最基础的收敛不变式,与框架无关; - 谨慎处理
portable-mixed:其断言逻辑可复用,但其中混入的 ProseMirror 视图/插件代码需要剥离; harness文件本身不产出行为断言,但其中的技术手法(如 testHelper.js 对应的随机多端连接器与比较器)值得借鉴——这正是本仓库 packages/yjs/src/lib/tests/collaboration/harness.ts 所采用的做法(见第 4 节)。
分域详解
slate-yjs:54 个"操作级回放"fixture
slate-yjs 域贡献了索引中结构化程度最高的一块:packages/core/test/collaboration/下按 Slate 原生操作命名的 54 个 fixture,外加一个入口 suiteindex.test.ts:63。
每个 fixture 的验证模式(摘自 inventory 的 Reason 列)是:
Slate operation fixture is replayed through slate-yjs and checked against a remote Y.Doc mirror.
即:用本地 Slate 编辑器重放一个操作 → 观察该操作如何被 slate-yjs 转录到共享 Y.XmlText → 在远端 Y.Doc 镜像上核对最终文档状态是否一致。这正是协同适配器测试的黄金路径:不关心具体 UI,只关心"操作 → CRDT 变更"的映射正确性。
54 个 fixture 按操作族分布(行号区间见 test-index.md):
| 操作族 | 数量 | 覆盖的关键场景 |
|---|---|---|
addMark | 5 | 跨多 mark、与既有 mark 共存、文档首/尾、withOtherMarks |
insertNode | 3 | 文档首/尾、段落中间插入块节点 |
insertText | 11 | 块首/块尾/文档首/文档尾、嵌套块中间、insideMarks、空字符串、HTML 实体、带 mark 文本、Unicode |
mergeNode | 5 | 删除后退格触发合并、同父节点、混合嵌套/混合类型节点、Unicode |
moveNode | 8 | 上/下移动 × 是否变成嵌套 × 是否保持嵌套,四种排列组合 |
removeMark | 3 | 文本中间、先 add 再 remove、与其他 mark 共存 |
removeNode | 4 | 文档首/尾、嵌套块、wrapper 块 |
removeText | 3 | 文档首/尾、Unicode |
setNode | 6 | 文档首/尾、onDataChange、内联节点数据变更、onResetBlock、类型切换 |
splitNode | 6 | 文档首/块尾/文档尾、非默认块、多子节点、Unicode |
从命名可以提炼出 slate-yjs 测试的两个设计原则:边界全覆盖("atBeginningOfDocument"、"atEndOfBlock"、"inTheMiddle")与不变式覆盖(Unicode、实体、空字符串、嵌套结构),这些正是协同适配器最容易出偏差的地方——尤其是 Unicode 拆分(涉及代理对)与嵌套块移动后的结构一致性。
lexical-yjs:source-only 扫描,无运行资产
索引对 lexical-yjs 域只记录了一行结论(L66):
No runnable test files found in
../lexical/packages/lexical-yjs; source-only scan recorded in the report.
也就是说,上游 Lexical 的lexical-yjs包在索引采集时不存在符合可运行模式(__tests__/test/tests/spec/e2e等)的测试文件,索引仅对其源码做过协同概念扫描,未产出可收割条目。这个"空目标"本身就是收割工作的有效产出:避免后续重复扫描该路径。inventory 的 Empty Target Notes 一节给出了相同结论。
y-prosemirror:覆盖 delta 映射、位置映射、建议模式与撤销
y-prosemirror 域是功能面最宽的一块,六个可运行测试文件各自盯住协同适配器的一个子系统:
delta.test.js(12 个导出):testBase、testDeleteRangeOverPartialNodes、testFormatting、testBaseInsert、testReplaceAround、testAttrStep、testWrapping、testFilledBlockquote等——验证 ProseMirror Step 与 Yjs delta 之间的双向映射不变式;positions.test.js(22 个导出):testPositionsSingleParagraph一路覆盖到testPositionsDeeplyNested、testPositionsHorizontalRule、testStoreMappingRoundTrip、testStoreMappingBookmarkTextSelection、testStoreMappingBookmarkNodeSelection——验证协同位置(RelativePosition)在各类文档结构下的映射与书签往返;suggestion-simulation.test.js(4 个导出):testSimSetupConverges、testSimSingleSuggestionEditConverges、testRepeatGeneratingSuggestionEdits、testSimLongRunningFuzz——用仿真/模糊手段验证建议(suggestion)模式下的收敛;suggestions.test.js(21 个导出):testSuggestionSyncAndMarks、testSequentialTypingMarks、testBlockInsertionMarks、testImageInsertionMarks、testDeletionOfSuggestedContent、testTwoViewSuggestionsUsersDivergeOnSplit、testCohortReplayConvergesAfterSplitDeleteInterleave等——覆盖建议内容与 mark、删除、回车/退格合并、双视图分歧、cohort 重放收敛;undo.test.js(33 个导出):testBasicUndoRedo、testAddToHistory、testCursorPositionAfterUndo、testUndoDeleteRestoresContent、testUndoManagerSurvivesViewDestroy、testMultipleEditorsOnSameYType、testRemoteChangesNotUndoable、testRedoClearedByRemoteChanges、testUndoManagerWithCustomCaptureTimeout——覆盖协同场景下撤销/重做与视图生命周期、远端变更的交互;y-prosemirror.test.js(23 个导出):testPluginIntegrity、testOverlappingMarks、testDocTransformation、testChangeOrigin、testEmptyNotSync、testInsertDuplication、testVersioning、testRepeatGenerateProsemirrorChanges{2,3,30,40,70,100,300}——前段是插件集成与版本化不变式,后段是"重复生成变更"的规模化压力测试。
其中tr.test.js与y-prosemirror.test.js被 inventory 标记为manual(需人工运行),其余四个为可直接运行的portable/portable-mixed。
yjs:CRDT 基座测试,协同正确性的"最底层证据"
yjs 域直接收割 Yjs 核心仓库自身的测试,它们是上面所有适配器测试的地基。按其主题可归为几组:
- 存储与编码兼容(
skip类):IdMap、IdSet(随机合并/差分/删除/交集)、compatibility(V1 数组/Map/文本解码兼容)、encoding(状态向量差分)——这些验证 Yjs 内部数据结构,对编辑器行为是间接的; - 文本与共享类型(
portable/portable-mixed类):y-text(47 个导出,含testDeltaBug、testSplitSurrogateCharacter、testLargeFragmentedDocument、testAttributedContent、testIncrementalUpdatesPerformanceOnLargeFragmentedDocument、批量testRepeatGenerateTextChanges*)、y-array(41 个,含并发插入冲突、迟到同步、观察者事件、GC、testRepeatGeneratingYarrayTests*直至 30000 次)、y-map(40 个,含嵌套事件、三端冲突、十万次规模压测)、y-xml(12 个,含属性/兄弟/克隆/attributed content); - 协同语义(
portable类):relativePositions(9 个:7 个位置用例 + 关联差异 + undo 交互)、snapshot(12 个:恢复快照、删除项恢复、依赖变更、testContainsUpdate)、updates(8 个:更新合并、键编码、混淆、testIntersectDoc)、undo-redo(25 个:testUndoText、testGlobalScope、testDoubleUndo、testUndoInEmbed、testConsecutiveRedoBug、testIgnoreRemoteMapChangesProperty等)、doc(11 个:事务递归、子文档、客户端 ID 冲突、testToJSON)、delta(5 个)、attribution(7 个:相对位置、attributed events、session)。
这批测试对 Plate 的意义在于:Plate 的 Yjs 插件建立在@slate-yjs/core之上,而后者又建立在 Yjs 之上——当协同测试失败时,先回到 yjs 域的portable测试确认基座是否健康,可以快速区分"是适配器问题还是 CRDT 基座问题"。
本地落地:packages/yjs 中的协同收敛测试
索引文档本身是"资产清单",而本仓库的 packages/yjs/src/lib/tests/collaboration 则是这套思路在 Plate 内的直接落地,包含三个文件:
- harness.ts:多端内存协同连接器;
- fixtures.ts:10 个收敛性 fixture;
- index.slow.ts:慢速测试入口,逐个执行 fixture。
多端内存连接器:模拟网络但保留 CRDT 语义
harness.ts中CollaborationConnector的设计值得细读,它复刻了上游testHelper.js(yjs 域,harness 类)的"多端随机连接"手法,但针对编辑器协同做了精简:
- 每个 peer 是一个
TestCollaborationProvider,通过registerProviderType(PROVIDER_TYPE, ...)注册为插件可识别的自定义 provider 类型(对应 providers/registry 的扩展点); connect(peer)建立连接时,把已在线 peer 的完整状态Y.encodeStateAsUpdate(...)排入队列(L124-L132),模拟"新用户加入房间先拉全量快照";- 本地
document.on('update')捕获的增量更新会广播给所有在线 peer(L190-L198),flushAll({ order: 'fifo' | 'reverse' })决定投递顺序——reverse模式用于模拟乱序到达(对应上游 y-prosemirror 的乱序/迟到场景); - 远端更新统一以
REMOTE_ORIGIN(Symbol)为 origin 应用(L91),这样测试可以区分"本地编辑"与"远端回放"。
10 个 fixture:收敛性不变式清单
fixtures.ts中的collaborationFixtures数组在 index.slow.ts 中被逐条执行,覆盖的协同不变式与上游索引形成清晰对照:
| fixture 名 | 验证的协同不变式 | 对应上游资产 |
|---|---|---|
seed_once_from_empty_doc | 首个进房者负责播种,后进者不重复播种 | slate-yjs 播种语义 / y-prosemirrortestEmptyNotSync |
server_content_wins_over_local_value | 服务端既有内容优先于本地 draft | y-prosemirrortestVersioning |
string_value_deserializes_once | HTML 字符串只在首个播种者处反序列化一次 | slate-yjs fixture 回放 |
async_value_waits_then_converges | 异步 value 就绪前阻塞播种,随后收敛 | y-prosemirror 异步变更组 |
custom_shared_type_nested_doc | 自定义sharedType(嵌套Y.XmlText)下多端收敛,且顶层content不被污染 | y-prosemirrortestDocTransformation/ yjsdoc子文档 |
reconnect_eventually_converges | 断线重连后拉取期间缺失的更新 | yjsupdates合并 / y-arraytestDeletionsInLateSync |
concurrent_local_edits_while_disconnected_eventually_converge | 双端离线各自编辑,重连后合并无丢失 | yjs 并发冲突族(y-array/y-map三端冲突) |
out_of_order_updates_eventually_converge | reverse顺序投递下仍收敛且顺序正确 | yjs 状态向量去重/乱序恢复 |
timeout_then_late_sync_does_not_reseed | 同步超时后不重复播种(幂等播种) | y-prosemirrortestEmptyNotSync |
mixed_provider_inputs | 同文档挂多个 provider 时每个都连接一次 | y-prosemirrortestMultipleEditorsOnSameYType |
其中out_of_order_updates_eventually_converge直接使用了connector.flushAll({ order: 'reverse' })(fixtures.ts L441),把上游 yjs 的乱序收敛能力变成 Plate 插件层可验证的契约。
与 BaseYjsPlugin 的对应关系
这些 fixture 断言的其实是 BaseYjsPlugin.ts 的初始化契约,关键逻辑在init()(L176-L375):
- 先建 provider,后连编辑器:
init()先遍历providers配置/实例,用createProvider实例化并注入onConnect/onDisconnect/onError/onSyncChange回调(L233-L294); - 首同步等待 + 5 秒超时:
autoConnect为 true 时连接全部 provider,并通过syncPromise与Promise.race等待首次同步,超时SYNC_TIMEOUT = 5000ms(L299-L322)——这正是timeout_then_late_sync_does_not_reseedfixture 所验证的路径; - 仅空文档才播种:
hasSharedRootState()通过检查共享Y.XmlText长度与内部_start字段判断是否已有历史(L30-L35),配合hasPendingLocalPersistence()(等待本地持久化完成),决定是否把value写入——string_value_deserializes_once、server_content_wins_over_local_value即围绕该分支设计; - 播种后再连接编辑器:
YjsEditor.connect(editor)、editor.tf.init(...)、editor.api.onChange()依次执行(L361-L371),保证编辑器看到的初始状态就是收敛后的共享状态。
值得一提的是hasSharedRootState中对_start的利用:Y.XmlText 在内容被删空后仍保留 tombstone 历史,_start非空即可区分"从未播种的空文档"与"已持久化的空文档",从而避免重复播种——这是 fixture 语义(seed_once、does_not_reseed)在源码层的直接映射。
如何基于索引扩充 Plate 的协同测试
综合test-index.md与inventory.md给出的信息,后续扩充协同测试可遵循以下路径(以仓库内现有资产为起点):
- 基座回归:把 yjs 域的
portable族(relativePositions、snapshot、updates、undo-redo、delta、doc)作为 CRDT 基座的冒烟集,任何协同失败先跑这批; - 操作矩阵移植:以 slate-yjs 的 54 个 fixture 为模板(边界位置 × 结构场景 × Unicode/实体),对照 packages/yjs/src/lib/tests/collaboration/fixtures.ts 现有的 10 个 fixture,把缺失的
moveNode/splitNode/mergeNode嵌套场景补充为同类 fixture; - 建议与撤销场景:从 y-prosemirror 的
portable-mixed族(suggestions、undo)中剥离 ProseMirror 视图层,保留其不变式(如"远端变更不可撤销""撤销后光标归位"),映射到 Plate 的 withTYHistory.ts 等本地实现; - 规模化压测:借鉴 yjs 域
y-text/y-array/y-map中testRepeatGenerating*系列的做法,在 index.slow.ts 的慢速通道中加入随机模糊生成的收敛断言。
小结
test-index.md不是一份普通的测试清单,而是一张可执行的协同测试资产地图:它以行号为锚点精确指向上游 300 余个测试函数,以portable/portable-mixed/harness/skip/manual五级分类标明每份资产的复用价值,并给出"空目标"(lexical-yjs)等反面记录。配合本仓库 packages/yjs 的本地实现——CollaborationConnector多端内存连接器、10 个收敛性 fixture、BaseYjsPlugin.init的播种与首同步契约——读者既能追溯每个协同不变式的上游出处,也能看到它们在 Plate 插件层如何被验证,从而为自己的协同编辑功能建立同样的"操作回放 + 远端镜像收敛"测试闭环。
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考