ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Etherpad 下游客户端兼容性测试 Phase 1:核心侧契约测试门禁与 downstream-smoke 工作流实战

Etherpad 下游客户端兼容性测试 Phase 1:核心侧契约测试门禁与 downstream-smoke 工作流实战 后端协同办公WebSocket前端富文本【免费下载链接】etherpadEtherpad: A modern really-real-time collaborative document editor.项目地址https://gitcode.com/gh_mirrors/et/etherpad点击查看免费下载Etherpad 是一个真正实时的协作文档编辑器其核心core仓库维护着 HTTP API、socket.io 握手/消息序列以及 changeset/attribpool 线缆wire格式。本篇文章围绕核心仓库中 Phase 1 实现计划实现计划与配套设计文档完整讲解如何在核心侧搭建一套兼容性门禁通过黄金向量golden vectors、socket 消息序列、HTTP API 形状三类契约测试Layer A以及能够真实启动服务器、按清单跑下游客户端的 smoke 工作流Layer B让任何针对develop的 PR 在合并前就发现会破坏独立下游客户端的变更。读完本文你将掌握vectors:gen夹具生成机制、三类契约测试的编写范式、clients.json清单与downstream-smoke.yml工作流的设计思路以及 Phase 1/Phase 2 的渐进式落地方法。背景下游客户端为何会被静默破坏Etherpad 的实时协作协议并非只有官方前端在使用。仓库设计文档明确指出当前有三个独立仓库的下游客户端它们不是把 core 作为库导入而是各自实现了或拷贝了线缆协议etherpad-cli-clientNode/TS 命令行客户端自带Changeset.tsAttributePool.ts的拷贝并自行与 socket.iomessage协议对话此前没有测试脚本etherpad-padRust 终端编辑器在其etherpad-clientcrate 中手写 engine.io v4 / socket.io v4 与 changeset 解码已有 CI 与mock-socket测试特性etherpad-desktopElectron 桌面 Capacitor 移动端包装/指向 core 服务器 URL已有 vitest Playwright e2e CI。问题在于这些客户端的 CI 永远不会针对新版 core 运行。当 core 的 PR 改动 HTTP API 响应形状、socket.io 握手或message序列、changeset/attribpool 线缆格式时破坏是静默的——core 自己测试全绿合并后下游客户端才报错。这正是该兼容性门禁要解决的痛点让 coredevelop及其它分支上的 PR 在合并前就能探测到下游破坏。整体架构两层兼容性门禁设计文档将门禁设计为两层跑在每一次 core PR 上core PR ──┬─► Layer A: Contract tests封闭、快速、无网络/客户端依赖 │ • golden-vector 断言changeset/attribpool 往返 │ • socket.io 消息序列测试CLIENT_VARS、USER_CHANGES → ACCEPT_COMMIT │ • HTTP API 形状快照 │ └─► Layer B: Downstream smoke启动真实服务器运行真实客户端 • 从 PR 构建并启动 Etherpad:9003已知 API key • 健康检查轮询直到就绪 • 按 manifest 展开矩阵克隆客户端 固定 ref搭建工具链 注入 core 新生成的 vectors运行客户端 test:vectors 再运行客户端 smoke连接 → 创建/打开 pad → 写入文本 → 经 HTTP API 读回 → 断言一致 • 按 PID 拆除服务器关键决策在设计阶段已锁定决策点选择检测策略混合式core 内快速契约测试每个 PR 下游 smoke每个 PRsmoke 节奏每个 PR带强防抖动措施CI 如何获取客户端在固定 ref 上 git clone记录在 core 的 manifest 中契约深度共享黄金向量——core 生成规范夹具各客户端用自研解码器解码同一夹具时序按层分阶段——Phase 1 全部在 core 侧Phase 2 逐个客户端仓库落地其中共享黄金向量是精髓core 用自己的Changeset/AttributePool引擎生成规范夹具每个客户端必须用自己的重写的解码器产出相同结果。任何一边的线缆实现漂移都会暴露。仓库中的实际落地状态需要说明的是本文对应的实现计划在仓库中已经落地。计划中规划的 7 个新文件在当前仓库中均可找到且个别文件与计划初稿存在有意的差异后文会逐一点出生成器模块已提交的黄金夹具夹具稳定性测试socket 消息序列测试HTTP API 形状测试客户端清单下游 smoke 工作流额外还有 Phase 2 才规划的 run-clients.sh 驱动脚本Task 1~3黄金向量生成器、夹具提交与稳定性门禁生成器模块线缆夹具的唯一事实来源计划的第一步是创建 generate-vectors.ts。它是一个纯模块导出generateVectors(): WireVector[]作为规范线缆夹具的单一事实来源同时可作为 CLI 运行用于重新写夹具文件。仓库中实际实现的完整代码use strict; import * as Changeset from ../../../../static/js/Changeset; import AttributePool from ../../../../static/js/AttributePool; export type WireVector { name: string; initialText: string; changeset: string; pool: ReturnTypeAttributePool[toJsonable]; resultText: string; }; const vector ( name: string, initialText: string, build: (pool: AttributePool) string, ): WireVector { const pool new AttributePool(); const changeset build(pool); Changeset.checkRep(changeset); return { name, initialText, changeset, pool: pool.toJsonable(), resultText: Changeset.applyToText(changeset, initialText), }; }; export const generateVectors (): WireVector[] [ vector(plain-insert, abc\n, () Changeset.makeSplice(abc\n, 3, 0, XYZ)), vector(plain-delete, abcdef\n, () Changeset.makeSplice(abcdef\n, 1, 3, )), vector(formatted-insert, abc\n, (pool) Changeset.makeSplice(abc\n, 3, 0, bold, [[bold, true]], pool)), vector(multiline-insert, abc\n, () Changeset.makeSplice(abc\n, 3, 0, one\ntwo\n)), vector(attrib-reuse, abc\n, (pool) { // Two formatted inserts sharing one pool entry exercises pool index reuse. const cs1 Changeset.makeSplice(abc\n, 0, 0, A, [[bold, true]], pool); const mid Changeset.applyToText(cs1, abc\n); const cs2 Changeset.makeSplice(mid, mid.length - 1, 0, B, [[bold, true]], pool); return Changeset.compose(cs1, cs2, pool); }), ]; // CLI entry: write the canonical fixture to disk. if (require.main module) { // eslint-disable-next-line typescript-eslint/no-var-requires const fs require(fs); // eslint-disable-next-line typescript-eslint/no-var-requires const path require(path); const out path.join(__dirname, ../../../fixtures/wire-vectors.json); fs.mkdirSync(path.dirname(out), {recursive: true}); fs.writeFileSync(out, ${JSON.stringify(generateVectors(), null, 2)}\n); // eslint-disable-next-line no-console console.log(wrote ${out}); }理解这段代码的关键点向量自包含每个向量给定initialText与pool应用changeset后得到resultText。下游客户端重新实现了 changeset/attribpool 解码器消费完全相同的 JSON必须复现resultText。用 core 自己的引擎生成Changeset.makeSplice构造变更集、Changeset.compose合并两个变更集attrib-reuse场景、Changeset.checkRep校验不变式、Changeset.applyToText计算结果。这保证了夹具永远与 core 当前序列化语义一致。覆盖五类操作纯插入、纯删除、带属性format/attrib的插入、多行插入char_bank以\n结尾、跨操作复用 attribpool 索引——正好覆盖客户端必须解码的全部操作类别。注册vectors:gen脚本在 src/package.json 的scripts中与既有test条目并列加入生成脚本。仓库中实际值计划初稿为tsx ...落地后为node --require tsx/cjs ...功能等价vectors:gen: node --require tsx/cjs tests/backend/specs/downstream/generate-vectors.ts,从src/目录运行cd src pnpm run vectors:gen预期输出wrote .../src/tests/fixtures/wire-vectors.json且文件包含 5 个向量逐个确认changeset非空、resultText与initialText不同。已提交的黄金夹具运行生成器后仓库中实际提交的 wire-vectors.json 长这样完整 5 条绝不手改[ { name: plain-insert, initialText: abc\n, changeset: Z:4333$XYZ, pool: { numToAttrib: {}, nextNum: 0 }, resultText: abcXYZ\n }, { name: plain-delete, initialText: abcdef\n, changeset: Z:731-3$, pool: { numToAttrib: {}, nextNum: 0 }, resultText: aef\n }, { name: formatted-insert, initialText: abc\n, changeset: Z:443*04$bold, pool: { numToAttrib: { 0: [bold, true] }, nextNum: 1 }, resultText: abcbold\n }, { name: multiline-insert, initialText: abc\n, changeset: Z:483|28$one\ntwo\n, pool: { numToAttrib: {}, nextNum: 0 }, resultText: abcone\ntwo\n\n }, { name: attrib-reuse, initialText: abc\n, changeset: Z:42*013*01$AB, pool: { numToAttrib: { 0: [bold, true] }, nextNum: 1 }, resultText: AabcB\n } ]眼检要点数组共 5 个对象键为name, initialText, changeset, pool, resultTextplain-insert/plain-delete/multiline-insert的pool.numToAttrib为空formatted-insert与attrib-reuse含bold,true条目。值得注意attrib-reuse的结果两个带bold属性的插入共享同一个池索引0生成AabcB\n这正是池索引复用语义的实证。夹具稳定性 自洽性测试wire-vectors.ts 守卫线缆格式契约包含两条断言use strict; const assert require(assert).strict; import fs from fs; import path from path; import * as Changeset from ../../../../static/js/Changeset; import {generateVectors} from ./generate-vectors; const fixturePath path.join(__dirname, ../../../fixtures/wire-vectors.json); describe(__filename, function () { it(committed fixture matches a fresh regeneration, function () { const committed JSON.parse(fs.readFileSync(fixturePath, utf8)); const fresh generateVectors(); assert.deepEqual(committed, fresh, wire-vectors.json is stale — run pnpm run vectors:gen and commit the result); }); it(every vector applies to its result under core Changeset, function () { for (const v of generateVectors()) { Changeset.checkRep(v.changeset); assert.equal(Changeset.applyToText(v.changeset, v.initialText), v.resultText, vector ${v.name} result mismatch); } }); });运行从src/cd src pnpm exec mocha --importtsx --timeout 120000 --extension ts tests/backend/specs/downstream/wire-vectors.ts预期 2 个用例通过。验证门禁确实会咬人手工改一条wire-vectors.json中的resultText再运行committed fixture matches a fresh regeneration 用例会失败——任何夹具漂移都必须是故意的线缆变更且必须在同一 PR 中重新生成并评审。测试通过后git checkout src/tests/fixtures/wire-vectors.json还原。这个稳定性测试的深层价值在于当 core 未来修改 changeset 序列化时vectors:gen会生成不同的夹具 → 稳定性用例失败 → 提醒开发者这是破坏下游的线缆变更必须与下游客户端协同升级。夹具漂移本身就是线缆发生了变化的信号。Task 4socket.io 消息序列测试wire-socket-sequence.ts 固定每个实时客户端都依赖的 socket.io 消息序列与形状握手handshake→CLIENT_VARS随后USER_CHANGES→ACCEPT_COMMIT。Rust 终端编辑器与 Node CLI 都是手写该序列的这里的任何改动都是会破坏下游的线缆协议变更。仓库中实际实现与计划初稿有一处重要差异初稿用common.waitForSocketEvent手工拼接属性池落地版本改用后端套件现成的common.waitForAcceptCommit与common.sendUserChanges辅助函数并在注释中明确了关键约束——插入操作必须携带会话作者属性*0N 对应 apool 条目否则服务端会以{disconnect:badChangeset}拒绝use strict; const assert require(assert).strict; const common require(../../common); const padManager require(../../../../node/db/PadManager); describe(__filename, function () { let agent: any; let socket: any; let pad: any; let padId: string; before(async function () { agent await common.init(); }); beforeEach(async function () { padId common.randomString(); pad await padManager.getPad(padId, dummy\n); await pad.setText(\n); // ensure the pad exists at a known empty state const res await agent.get(/p/${padId}).expect(200); socket await common.connect(res); }); afterEach(async function () { if (socket ! null) socket.close(); socket null; if (pad ! null) await pad.remove(); pad null; }); it(handshake returns CLIENT_VARS with the client-facing shape, async function () { const {type, data} await common.handshake(socket, padId); assert.equal(type, CLIENT_VARS); assert.ok(data.userId, CLIENT_VARS.userId missing); assert.ok(data.collab_client_vars, collab_client_vars missing); assert.equal(typeof data.collab_client_vars.rev, number); assert.ok(data.collab_client_vars.initialAttributedText, collab_client_vars.initialAttributedText missing); }); it(USER_CHANGES is acknowledged with ACCEPT_COMMIT and a bumped rev, async function () { const {data: clientVars} await common.handshake(socket, padId); const rev clientVars.collab_client_vars.rev; const authorId clientVars.userId; // Insert ops must carry the session author attribute (*0N matching // apool entry) or the server rejects with {disconnect:badChangeset}. const apool {numToAttrib: {0: [author, authorId]}, nextNum: 1}; await Promise.all([ common.waitForAcceptCommit(socket, rev 1), common.sendUserChanges(socket, {baseRev: rev, changeset: Z:15*05$hello, apool}), ]); assert.equal(pad.text(), hello\n); }); });测试要点复用src/tests/backend/common.ts中的common.init()、common.connect(res)、common.handshake(socket, padId)等既有后端测试辅助函数以及src/tests/backend/specs/messages.ts模式第一个用例锁定CLIENT_VARS形状必须有userId、collab_client_vars且rev是 number、initialAttributedText存在第二个用例做真实的双向确认发送USER_CHANGES变更集这里手写线缆字符串Z:15*05$hello含作者属性等待ACCEPT_COMMIT且newRev rev 1最后从 Pad 模型断言文本确实是hello\n。运行命令cd src pnpm exec mocha --importtsx --timeout 120000 --extension ts tests/backend/specs/downstream/wire-socket-sequence.ts预期 2 个用例通过。计划还给出防抖提示若waitForSocketEvent默认 1 秒超时对ACCEPT_COMMIT过紧可向其第 3 个参数传更大的timeoutMs例如common.waitForSocketEvent(socket, message, 5000)。Task 5HTTP API 形状快照wire-http-api.ts 快照下游客户端调用端点创建 pad、往返文本的响应形状键与类型而非易变的具体值。与计划初稿基于common.apiKey的 GETapikey 查询串不同落地版本改用与套件其余 api specs 一致的 JWT 认证common.generateJWTToken()参见 api/createDiffHTML.ts 一类的既有 specsetText用 POST JSON bodyuse strict; const assert require(assert).strict; const common require(../../common); describe(__filename, function () { let agent: any; let apiVersion 1; const padId wireHttp_${common.randomString()}; const endPoint (point: string) /api/${apiVersion}/${point}; before(async function () { agent await common.init(); const res await agent.get(/api/).expect(200).expect(Content-Type, /json/); apiVersion res.body.currentVersion; assert(apiVersion); }); it(createPad returns the standard {code,data,message} envelope, async function () { const res await agent.get(${endPoint(createPad)}?padID${padId}texthello%0A) .set(Authorization, await common.generateJWTToken()) .expect(200); assert.deepEqual(Object.keys(res.body).sort(), [code, data, message]); assert.equal(res.body.code, 0); }); it(setText getText round-trips text through the documented shape, async function () { await agent.post(endPoint(setText)) .set(Authorization, await common.generateJWTToken()) .send({padID: padId, text: world\n}) .expect(200); const res await agent.get(${endPoint(getText)}?padID${padId}) .set(Authorization, await common.generateJWTToken()) .expect(200); assert.equal(res.body.code, 0); assert.equal(typeof res.body.data.text, string); assert.equal(res.body.data.text, world\n); }); it(getRevisionsCount exposes a numeric revisions field, async function () { const res await agent.get(${endPoint(getRevisionsCount)}?padID${padId}) .set(Authorization, await common.generateJWTToken()) .expect(200); assert.equal(res.body.code, 0); assert.equal(typeof res.body.data.revisions, number); }); });三个用例分别锁定createPad的标准{code, data, message}信封键集合排序后完全一致、code 0setTextgetText的文本往返data.text为 string 且精确等于world\ngetRevisionsCount暴露数值型revisions字段。apiVersion不是硬编码而是启动时从/api/响应的currentVersion动态读取——这是对下游客户端跟随 API 版本演进行为的真实模拟。运行命令cd src pnpm exec mocha --importtsx --timeout 120000 --extension ts tests/backend/specs/downstream/wire-http-api.ts预期 3 个用例通过。Task 6客户端清单manifestclients.json 是下游客户端的注册清单纯数据。设计文档强调ref 固定到具体 commit SHA 而非main这样客户端自己推代码不会随机染红 core CI——提升某个 ref 必须是刻意的 PR。仓库中实际落地时三个客户端均已被点亮enabled: true说明 Phase 2 的对应 smoke 已经落地与计划初稿全部enabled:false的状态不同[ { name: etherpad-pad, repo: https://github.com/ether/pad.git, ref: 91620c67c49536bb77e90b39f298ea70ae93c4a0, kind: rust, enabled: true, vectorTest: cargo test --test vectors, smokeCmd: cargo test --test smoke -- --ignored }, { name: etherpad-cli-client, repo: https://github.com/ether/etherpad-cli-client.git, ref: ebc516ef1a4e7a0c97ccd7a3f2db65e99f8e177c, kind: node, enabled: true, vectorTest: pnpm run test:vectors, smokeCmd: pnpm run test:smoke }, { name: etherpad-desktop, repo: https://github.com/ether/etherpad-desktop.git, ref: ab83da645b8683afbbc203e4a3fa6f3622a55709, kind: desktop, enabled: true, vectorTest: pnpm run test:vectors, smokeCmd: pnpm run test:smoke } ]字段语义name/repo/ref客户端标识、仓库地址、固定 commit SHAkindrust|node|desktop决定工作流为其准备的工具链Rust 工具链 / nodepnpm / 桌面 headless 运行方式enabledPhase 2 中某客户端 smoke 落地后才翻转为truevectorTest/smokeCmd该客户端在注入夹具后要运行的向量测试与冒烟测试命令manifest 命令被视为仓库内可信白名单交由bash -euo pipefail -c执行而非eval。验证清单为合法 JSON 并打印概览node -e const crequire(./src/tests/downstream/clients.json); console.log(c.length, c.map(xx.name).join(,))Task 7downstream-smoke 工作流downstream-smoke.yml 是 Layer B 的骨架启动真实 Etherpad、健康检查、自检、拆除、并按清单展开客户端矩阵。触发条件为pull_request对doc/**、docs/**的纯文档改动忽略 每天 04:00 针对默认分支的schedule定时任务permissions: contents: read最小化权限。落地版工作流比计划初稿更成熟关键差异点端口与认证的配置改写计划初稿假设直接设PORT9003 pnpm run prod落地版发现settings.json.template自带字面量port: 9001与 sso 认证因此用sed将其改写为 9003 与 apikey并用grep校验改写确实生效防模板格式漂移导致 sed 静默空转随后把 API key 写入APIKEY.txt健康检查60 次 × 2 秒轮询http://localhost:9003/api/超时则输出服务日志并失败自检用 curl 完成一次带认证的createPadgetText往返并grep断言code:0与text:hi证明启动→健康→自检闭环可用向量生成cd src pnpm run vectors:gen确保注入给客户端的夹具来自当前 PR的序列化客户端矩阵通过bash src/tests/downstream/run-clients.sh执行环境变量SMOKE_URL/SMOKE_APIKEY传入按 PID 拆除if: always()保证无论成败都执行只kill记录的 PID绝不pkill -f那会误杀开发者机器上的其它服务器。仓库中实际的 Boot 步骤供对照- name: Boot Etherpad on :9003 (apikey auth) run: | # The template ships a literal port: 9001 and sso auth; rewrite both. # (PORT env is ignored once the settings file specifies a port.) sed -e s#port: 9001,#port: 9003,# \ -e s#${AUTHENTICATION_METHOD:sso}#apikey# \ settings.json.template settings.json grep -q port: 9003, settings.json \ || { echo ::error::port rewrite failed — settings.json.template format changed; exit 1; } grep -q authenticationMethod: apikey settings.json \ || { echo ::error::auth rewrite failed — settings.json.template format changed; exit 1; } printf %s $APIKEY APIKEY.txt pnpm run prod /tmp/ep.log 21 echo $! /tmp/ep.pid echo booted pid $(cat /tmp/ep.pid)Phase 2 的驱动脚本run-clients.sh虽然计划把按客户端展开矩阵放在 Phase 2仓库中 run-clients.sh 已经实现了完整逻辑是理解clients.json消费方式的最佳源码读取 manifestfilter(x x.enabled)得到待运行列表输出 TSV环境变量注入ETHERPAD_WIRE_VECTORS绝对路径指向 core 刚生成的夹具让客户端测试当前 core 的序列化而非其自带的旧快照、ETHERPAD_SMOKE_URL、ETHERPAD_SMOKE_APIKEY对每个客户端git clone→ 若固定 SHA 不可达则fetch origin sha→checkout ref按kind分派rust直接跑vectorTestsmokeCmdnode/desktop先pnpm install每个客户端在一个受保护的子 shell 中执行单客户端失败置fail1但循环继续命令经由bash -euo pipefail -c运行而非eval确保流水线阶段失败能浮出表面全部结束后以fail为退出码汇总。Task 8全量后端套件运行与 PR 提交流程新 specs 放在specs/downstream/子目录因此既有mocha ... --recursive tests/backend/specs的 glob 会零配置自动纳入。按总是运行后端测试的仓库规则以套件真实使用的 mocha 调用方式运行整个下游 spec 组cd src cross-env NODE_ENVproduction pnpm exec mocha --importtsx --timeout 120000 --extension ts --recursive tests/backend/specs/downstream预期tests/backend/specs/downstream/下全部通过3 个文件共 7 个用例。随后确认夹具守卫无回归cd src pnpm run vectors:gen git diff --exit-code src/tests/fixtures/wire-vectors.json预期退出码 0再生成结果与已提交夹具逐字节一致。最后推送分支并创建 PR示意请按仓库实际分支名调整git push -u origin feat/downstream-client-compat-tests gh pr create --base develop \ --title test: downstream client compatibility gate (Phase 1) \ --body Adds core-side contract tests (golden wire-vectors, socket-sequence, HTTP API shapes) and a downstream-smoke workflow scaffold... gh pr checks --watch防抖动设计smoke 每个 PR 都跑必须稳设计文档专门列出了防抖动措施工作流中均已体现健康检查轮询带超时任何客户端运行前先确认服务器就绪每个客户端 smoke 有界超时 1 次重试桌面端保持 headless-light重的 Electron e2e 留在 desktop 自己仓库不进 core 门禁若必须用 Electron 则置于xvfb-run下PID 式拆除绝不pkill -fmanifest ref 固定core CI 与客户端自身破坏隔离提升 ref 是刻意的 PR端口固定 :90039001 保留给本地临时使用避免端口冲突。分阶段规划与成功标准Phase 1core 侧先落地、立即可用向量生成器 wire-vectors.json 三个契约 spec downstream-smoke.ymlclients.json清单先用一个已有测试基础设施的参考客户端Rustetherpad-pad验证整套装置——当前仓库已处在此阶段完成、Phase 2 部分推进的状态Phase 2逐个客户端仓库推进按etherpad-pad→etherpad-cli-client→etherpad-desktop顺序每个 PR 为该客户端补上test:vectors smoke 并在 core manifest 中注册翻enabled为 true。明确不在范围内的内容不替换客户端既有的完整 e2e 套件留在各自仓库ep_kaput 按长期约定被排除不修改线缆协议本身——这套工作只观察它。成功标准来自设计文档改动 changeset 序列化、socket 消息序列或客户端可见 API 形状的 core PR在合并前必须触发 Layer A契约和/或 Layer Bsmoke失败Phase 1 在 coredevelop上全绿落地Rust 客户端接入 smoke 矩阵提升某客户端的固定 ref是客户端自身变更影响 core CI 的唯一途径。总结这套 Phase 1 兼容性门禁回答了 Etherpad 生态的一个结构性难题当协议核心与消费方分属不同仓库时如何低成本地持续守护线缆契约。核心思路有三层黄金向量把 changeset/attribpool 线缆格式固化为可提交、可对比、可注入的 JSON 夹具socket 序列与 HTTP 形状测试把实时与 REST 两条通路的关键形状钉死在每个 PR 上manifest smoke 工作流则保留了一条启动真实服务器、运行真实客户端的端到端退路并且通过固定 ref、PID 拆除、健康检查轮询等手段保证它不成为新的不稳定源。若你需要为其它协议核心 独立客户端形态的项目搭建同类门禁本文的 8 个任务、防抖动清单与enabled渐进开关可以直接作为模板复用。赞分享后端协同办公WebSocket前端富文本【免费下载链接】etherpadEtherpad: A modern really-real-time collaborative document editor.项目地址https://gitcode.com/gh_mirrors/et/etherpad点击查看免费下载相关推荐Etherpad 下游客户端兼容性测试设计为每个核心 PR 加装双层协议兼容闸门Etherpad 下游客户端兼容性测试设计为每个核心 PR 加装双层协议兼容闸门 Etherpad 的核心仓库 ether/etherpad 为独立仓库中后端协同办公WebSocket前端富文本NSwag代码生成契约测试确保客户端与API兼容性NSwag代码生成契约测试确保客户端与API兼容性 在现代API开发中前后端分离架构已成为主流。但这种架构下客户端与API服务端的接口契约一致性常常面临挑开发工具代码生成API设计claude-obsidian 贡献工程契约与开发工作流测试隔离、契约校验与验证门禁实战claude obsidian 贡献工程契约与开发工作流测试隔离、契约校验与验证门禁实战 CONTRIBUTING.md 是 claude obsidianAI 技能知识库知识管理RAG人工智能上一篇Gemma-4-E4B-it推理模式完全指南如何配置思维链获得最佳输出效果下一篇如何用PyTorch Serve快速部署Segment Anything Fast图像分割模型完整实践指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表