Plate 仓库 Yjs 协同编辑测试资产索引解读:从上游 CRDT 测试清单到本地协同收敛验证
2026/9/15 12:35:24 网站建设 项目流程

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-yjscore/test/index.test.ts(adapter suite)1 套L63
slate-yjscore/test/collaboration/{addMark,insertNode,insertText,mergeNode,moveNode,removeMark,removeNode,removeText,setNode,splitNode}/...共 54 个 fixture54 fixtureL9-L62
lexical-yjslexical/packages/lexical-yjsL66
y-prosemirrortests/delta.test.js12L76-L88
y-prosemirrortests/positions.test.js22L96-L118
y-prosemirrortests/suggestion-simulation.test.js4L120-L124
y-prosemirrortests/suggestions.test.js21L126-L147
y-prosemirrortests/tr.test.js⚠️ manual1L149-L150
y-prosemirrortests/undo.test.js33L152-L185
y-prosemirrortests/y-prosemirror.test.js⚠️ manual23L187-L210
y-prosemirrortests/{cohort,complexSchema,index,index.node}.js0(harness/支持文件)L70-L94
yjstests/IdMap.tests.js7L214-L221
yjstests/IdSet.tests.js7L223-L230
yjstests/attribution.tests.js7L232-L239
yjstests/compatibility.tests.js3L241-L244
yjstests/delta.tests.js5L246-L251
yjstests/doc.tests.js11L253-L264
yjstests/encoding.tests.js3L266-L269
yjstests/relativePositions.tests.js9L274-L283
yjstests/snapshot.tests.js12L285-L297
yjstests/undo-redo.tests.js25L302-L327
yjstests/updates.tests.js8L329-L337
yjstests/y-array.tests.js41L339-L380
yjstests/y-map.tests.js40L382-L422
yjstests/y-text.tests.js47L424-L471
yjstests/y-xml.tests.js12L473-L485
yjstests/{index,testHelper}.js0(runner/支持文件)L271-L299

需要说明:原文档中../slate-yjs/...../y-prosemirror/...../yjs/...等前缀是索引作者对上游仓库(当前仓库之外的兄弟目录)的相对引用,这些路径在本仓库内不存在;上表与下文的路径标识仅用于与原始索引一一对应。当前仓库内可验证的对应实现位于 packages/yjs,后文第 4 节详述。

分类体系:如何判断一份测试资产值不值得移植

inventory.md 为每个文件标注了Category字段,这是整个收割体系的决策核心,共五类:

类别含义典型例子
portable纯协同不变式,与具体编辑器视图解耦,可直接移植yjs 的relativePositionssnapshotupdatesundo-redo;slate-yjs 的全部 54 个操作 fixture
portable-mixed协同不变式有价值,但与编辑器视图/插件策略耦合,需甄别后移植y-prosemirror 的suggestionsundo;yjs 的y-arrayy-mapy-xmlattribution
harness无行为断言,仅是运行器或辅助设施y-prosemirror 的index.js/cohort.js/complexSchema.js;yjs 的index.js/testHelper.js;slate-yjs 的withTestingElements.ts
skip是 Yjs 内部存储/编码兼容性测试,对编辑器行为非直接目标IdMapIdSetcompatibilityencoding
manual有价值但需人工介入(如依赖浏览器视图)y-prosemirror 的tr.test.jsy-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):

操作族数量覆盖的关键场景
addMark5跨多 mark、与既有 mark 共存、文档首/尾、withOtherMarks
insertNode3文档首/尾、段落中间插入块节点
insertText11块首/块尾/文档首/文档尾、嵌套块中间、insideMarks、空字符串、HTML 实体、带 mark 文本、Unicode
mergeNode5删除后退格触发合并、同父节点、混合嵌套/混合类型节点、Unicode
moveNode8上/下移动 × 是否变成嵌套 × 是否保持嵌套,四种排列组合
removeMark3文本中间、先 add 再 remove、与其他 mark 共存
removeNode4文档首/尾、嵌套块、wrapper 块
removeText3文档首/尾、Unicode
setNode6文档首/尾、onDataChange、内联节点数据变更、onResetBlock、类型切换
splitNode6文档首/块尾/文档尾、非默认块、多子节点、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 个导出):testBasetestDeleteRangeOverPartialNodestestFormattingtestBaseInserttestReplaceAroundtestAttrSteptestWrappingtestFilledBlockquote等——验证 ProseMirror Step 与 Yjs delta 之间的双向映射不变式;
  • positions.test.js(22 个导出):testPositionsSingleParagraph一路覆盖到testPositionsDeeplyNestedtestPositionsHorizontalRuletestStoreMappingRoundTriptestStoreMappingBookmarkTextSelectiontestStoreMappingBookmarkNodeSelection——验证协同位置(RelativePosition)在各类文档结构下的映射与书签往返;
  • suggestion-simulation.test.js(4 个导出):testSimSetupConvergestestSimSingleSuggestionEditConvergestestRepeatGeneratingSuggestionEditstestSimLongRunningFuzz——用仿真/模糊手段验证建议(suggestion)模式下的收敛;
  • suggestions.test.js(21 个导出):testSuggestionSyncAndMarkstestSequentialTypingMarkstestBlockInsertionMarkstestImageInsertionMarkstestDeletionOfSuggestedContenttestTwoViewSuggestionsUsersDivergeOnSplittestCohortReplayConvergesAfterSplitDeleteInterleave等——覆盖建议内容与 mark、删除、回车/退格合并、双视图分歧、cohort 重放收敛;
  • undo.test.js(33 个导出):testBasicUndoRedotestAddToHistorytestCursorPositionAfterUndotestUndoDeleteRestoresContenttestUndoManagerSurvivesViewDestroytestMultipleEditorsOnSameYTypetestRemoteChangesNotUndoabletestRedoClearedByRemoteChangestestUndoManagerWithCustomCaptureTimeout——覆盖协同场景下撤销/重做与视图生命周期、远端变更的交互;
  • y-prosemirror.test.js(23 个导出):testPluginIntegritytestOverlappingMarkstestDocTransformationtestChangeOrigintestEmptyNotSynctestInsertDuplicationtestVersioningtestRepeatGenerateProsemirrorChanges{2,3,30,40,70,100,300}——前段是插件集成与版本化不变式,后段是"重复生成变更"的规模化压力测试。

其中tr.test.jsy-prosemirror.test.js被 inventory 标记为manual(需人工运行),其余四个为可直接运行的portable/portable-mixed

yjs:CRDT 基座测试,协同正确性的"最底层证据"

yjs 域直接收割 Yjs 核心仓库自身的测试,它们是上面所有适配器测试的地基。按其主题可归为几组:

  • 存储与编码兼容skip类):IdMapIdSet(随机合并/差分/删除/交集)、compatibility(V1 数组/Map/文本解码兼容)、encoding(状态向量差分)——这些验证 Yjs 内部数据结构,对编辑器行为是间接的;
  • 文本与共享类型portable/portable-mixed类):y-text(47 个导出,含testDeltaBugtestSplitSurrogateCharactertestLargeFragmentedDocumenttestAttributedContenttestIncrementalUpdatesPerformanceOnLargeFragmentedDocument、批量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 个:testUndoTexttestGlobalScopetestDoubleUndotestUndoInEmbedtestConsecutiveRedoBugtestIgnoreRemoteMapChangesProperty等)、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.tsCollaborationConnector的设计值得细读,它复刻了上游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服务端既有内容优先于本地 drafty-prosemirrortestVersioning
string_value_deserializes_onceHTML 字符串只在首个播种者处反序列化一次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_convergereverse顺序投递下仍收敛且顺序正确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):

  1. 先建 provider,后连编辑器init()先遍历providers配置/实例,用createProvider实例化并注入onConnect/onDisconnect/onError/onSyncChange回调(L233-L294);
  2. 首同步等待 + 5 秒超时autoConnect为 true 时连接全部 provider,并通过syncPromisePromise.race等待首次同步,超时SYNC_TIMEOUT = 5000ms(L299-L322)——这正是timeout_then_late_sync_does_not_reseedfixture 所验证的路径;
  3. 仅空文档才播种hasSharedRootState()通过检查共享Y.XmlText长度与内部_start字段判断是否已有历史(L30-L35),配合hasPendingLocalPersistence()(等待本地持久化完成),决定是否把value写入——string_value_deserializes_onceserver_content_wins_over_local_value即围绕该分支设计;
  4. 播种后再连接编辑器YjsEditor.connect(editor)editor.tf.init(...)editor.api.onChange()依次执行(L361-L371),保证编辑器看到的初始状态就是收敛后的共享状态。

值得一提的是hasSharedRootState中对_start的利用:Y.XmlText 在内容被删空后仍保留 tombstone 历史,_start非空即可区分"从未播种的空文档"与"已持久化的空文档",从而避免重复播种——这是 fixture 语义(seed_oncedoes_not_reseed)在源码层的直接映射。

如何基于索引扩充 Plate 的协同测试

综合test-index.mdinventory.md给出的信息,后续扩充协同测试可遵循以下路径(以仓库内现有资产为起点):

  1. 基座回归:把 yjs 域的portable族(relativePositionssnapshotupdatesundo-redodeltadoc)作为 CRDT 基座的冒烟集,任何协同失败先跑这批;
  2. 操作矩阵移植:以 slate-yjs 的 54 个 fixture 为模板(边界位置 × 结构场景 × Unicode/实体),对照 packages/yjs/src/lib/tests/collaboration/fixtures.ts 现有的 10 个 fixture,把缺失的moveNode/splitNode/mergeNode嵌套场景补充为同类 fixture;
  3. 建议与撤销场景:从 y-prosemirror 的portable-mixed族(suggestionsundo)中剥离 ProseMirror 视图层,保留其不变式(如"远端变更不可撤销""撤销后光标归位"),映射到 Plate 的 withTYHistory.ts 等本地实现;
  4. 规模化压测:借鉴 yjs 域y-text/y-array/y-maptestRepeatGenerating*系列的做法,在 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询