首页
/ ToolJet Cypress 测试实战:cypress-real-dnd 冷拖拽失效的根因分析与插件修复方案

ToolJet Cypress 测试实战:cypress-real-dnd 冷拖拽失效的根因分析与插件修复方案

2026-09-05 23:42:01作者:段琳惟

ToolJet 的可视化编辑器基于 react-dnd(HTML5 拖拽后端),要端到端验证"把组件从组件面板拖到画布"这一核心交互,标准 Cypress 的 cy.drag() 无法触发真实的 HTML5 拖拽事件流,只能依赖 cypress-real-dnd 这类走 Chrome DevTools Protocol(CDP)模拟真实输入序列的插件。本文以 CYPRESS_REAL_DND_FIX.md 为蓝本,完整还原 ToolJet 在 cypress-real-dnd(v0.1.2)中踩到的"冷首次拖拽抛错"缺陷:从症状表现、插件源码级的三层根因,到推荐的插件修复补丁、测试侧已落地的 cy.on('fail') 兜底实现,以及修复后的完整验证步骤。读完本文,你可以独立判断同类 CDP 拖拽插件缓存问题、看懂 ToolJet 的拖拽测试命令为何写成现在这样,并掌握插件修复后的清理路径。

背景:ToolJet 的拖拽测试链路是怎么接起来的

ToolJet 的 Cypress 测试工程位于 cypress-tests/ 目录,cypress-real-dnd^0.1.2 版本声明在 package.json 的 devDependencies 中,并在两处完成注册:

  • 插件端(Node 上下文)plugins/index.js 调用 require("cypress-real-dnd/plugin").realDragDropPlugin(on),注册 cdpRealDrag / cdpRealDragInit 两个 cy.task。拖拽本身在 Node 侧通过 CDP 直接对浏览器渲染进程派发 Input.dispatchMouseEvent 序列,这正是它能触发 HTML5 拖拽、而浏览器侧事件模拟做不到的原因。
  • 浏览器端(spec 上下文)support/e2e.js 引入 cypress-real-dnd/commands,提供 cy.realDragcy.realDragAndDropcy.realDragInit 等命令。

所有拖拽类 e2e 用例最终收敛到一个共享自定义命令 dragAndDropWidget,它定义在 commands.js。该命令的完整流程是:等待 react-dnd 在源节点上打上 draggable="true" 属性(源码注释明确指出,拖拽前该属性未就绪会使 cypress-real-dnd 抛出 "source may not be a real HTML5 draggable")→ 打开右侧组件面板并搜索 → 每次拖拽前调用 cy.realDragInit() 重新武装 CDP 拦截 → 执行 cy.realDragAndDrop(sourceSelector, canvas, { targetX, targetY }) → 轮询画布上已放置组件的数量确认落点生效。

理解这条链路是理解下文缺陷的前提:测试侧假设 cy.realDragInit() 每次调用都会重新武装拖拽拦截,而插件的实际行为并非如此

症状:冷首次拖拽抛出无法捕获的 task 拒绝

按文档记载,失败模式有非常精确的复现条件:spec 运行内的第一次拖拽,以及每一次 AUT(被测应用)导航之后的第一次拖拽——在 ToolJet 用例中,每次 beforeEach 里的 apiCreateApp + openApp 就构成一次完整导航。此时拖拽 task 被拒绝,报错为:

[cypress-real-dnd] No Input.dragIntercepted event after the mouse-move past threshold.
The source element may not be a real HTML5 draggable ...

这个错误的关键危害不在于报错文案,而在于它的传播方式:被拒绝的 cy.task 是命令队列级别的失败,普通的 .then() 回调、计数重试都拦不住它。因此它会直接杀死 beforeEach,进而杀死整个 spec——这不是"拖拽静默失效后重试一次就能恢复"的软性 flake,而是硬中断。ToolJet 的多份 spec 头部注释(如 buttonHappyPath.cy.js)也印证了这一点:它们被迫设置 testIsolation: false,因为 testIsolation 的每次重置测试都会让 AUT 重新导航,恰好把这个缺陷反复触发。

根因:插件源码里的三层缺陷

文档将根因定位在插件源码(安装副本位于 cypress-tests/node_modules/cypress-real-dnd/src/plugin.jsnode_modules 不进仓库,故行号以文档标注为参考):

  1. getClient() 把一切都缓存在 cdpPromise 里(约 plugin.js:106

    if (cdpPromise) return cdpPromise;
    

    CDP 连接建立、鼠标循环热身(warmup)、以及 Input.setInterceptDrags({ enabled: true }) 的武装动作,全部内联在这个被缓存的 promise 内部(约 plugin.js:116–183)。结果是这三件事在整个 spec 运行内只执行一次——第一次 await getClient() 时——此后再也不会重跑。

  2. realDragInit() 对已温热的 client 是空操作(约 plugin.js:~340

    async function realDragInit() {
      await getClient();   // 返回缓存的 promise → 不会重新武装,也不会热身
      await sleep(1500);
    }
    

    ToolJet 在每次拖拽前都调用 cy.realDragInit(),期望它重新武装拦截;但在 client 已缓存的情况下,它只是睡了一觉。

  3. realDrag 内部自动重试预算太小(约 plugin.js:~301–316:只重试一次、等待时间约 300 ms,不足以覆盖刚完成导航、CDP 流量仍在沉降的 AUT。

三者叠加的净效应是:导航之后,渲染进程里的 drag 拦截被 Cypress 自身的自动化/快照 CDP 流量"无声地"解除,而 realDragInit() 无力把它重新装回去,于是下一次拖拽必然抛错。这也解释了为什么失败总精确发生在"第一次拖拽"——不是元素问题,而是拦截器状态问题。

推荐修复:把"连接一次"与"武装+热身"解耦

文档给出的首选方案是重构 getClient():缓存的应该只是连接本身,而"武装拦截 + 消耗冷窗口"应拆成一个可随时重跑的函数,realDragInit() 每次都对它调用:

// attach the CDP client once (cache the CONNECTION only)
async function getClient() {
  if (cdpPromise) return cdpPromise;
  cdpPromise = (async () => {
    const { Input, Runtime /* ... */ } = await attachToAutTarget();
    Input.dragIntercepted(({ data }) => { lastDragData = data; });
    return { Input, Runtime /* ... */ };
  })();
  return cdpPromise;
}

// re-runnable: arm interceptDrags + burn the cold window. Safe to call any time.
async function armAndWarmup() {
  const { Input } = await getClient();
  await Input.setInterceptDrags({ enabled: true });
  await sleep(300);
  // off-canvas no-op mouse cycle (the existing warmup body), then re-arm:
  await Input.dispatchMouseEvent({ type: "mousePressed",  x: 1, y: 1, button: "left", clickCount: 1 });
  await Input.dispatchMouseEvent({ type: "mouseMoved",    x: 6, y: 6, button: "left" });
  await Input.dispatchMouseEvent({ type: "mouseReleased", x: 6, y: 6, button: "left" });
  await Input.setInterceptDrags({ enabled: true });
  await sleep(200);
  return { ok: true };
}

async function realDragInit() {   // now genuinely re-arms per call
  await armAndWarmup();
  return { ok: true };
}

// realDrag should also armAndWarmup() on its catch/retry path instead of just
// re-calling setInterceptDrags once.

这个设计保留了 CDP 连接的单例性(连接复用本身是合理的),只把"武装"从连接生命周期里剥离出来。armAndWarmup() 的语义是幂等且随时可调用的:先武装拦截,睡 300 ms 消耗冷窗口,做一次画布外的空鼠标循环(沿用原热身主体),再重新武装一次、再睡 200 ms。任务注册方式保持不变(cdpRealDragInit: realDragInit),没有任何 API 变化——ToolJet 现有的 cy.realDragInit() 调用从此开始真正兑现测试侧一直假设的语义。

轻量替代方案(不想重构插件时)

文档还给出两个侵入更小的选项:

  • 调大 realDrag 的内部重试预算(约 plugin.js:301–316):重试 2–3 次,每次等待拉长到约 800 ms,并在每次重试前调用热身。
  • 新增专用重热身任务:如 cdpRealDragRewarmarmAndWarmup(),测试侧把每次拖拽前的 cy.realDragInit() 换成 cy.realDragRewarm()

测试侧的临时兜底:dragAndDropWidget 里的 cy.on('fail') 陷阱

在插件修好之前,ToolJet 选择把兜底逻辑落在可提交的测试代码里。node_modules 的改动无法入库且会在 npm ci 时丢失,所以文档明确说"干净的修复归属在插件侧",而 commands.js 中的 fail-trap 是可提交的过渡方案。结合源码,该兜底的实现要点是:

  • 捕获与甄别installFailTrap(throwTriesLeft) 注册一个一次性 cy.on('fail') 处理器(commands.js#L199-L218)。处理器用 /dragIntercepted|cdpRealDrag/i 正则甄别错误是否属于"冷拦截抛错"——只有这类错误且仍有重试预算时才吞掉(return false),其他任何错误一律原样 re-throw,确保不掩盖真实失败。
  • 恢复动作:陷阱触发后依次执行 cy.realDragInit()(借插件热身序列重新武装)→ cy.wait(900) 沉降 → 重新驱动完整的拖拽尝试(开面板→搜索→拖拽→计数轮询)。
  • 防重入与预算:用模块级 currentTrap 持有当前处理器,安装新陷阱前先摘除未触发的旧陷阱(否则多个 once 陷阱会堆积后一起触发);attempt(4) 给出最多 4 轮"重新武装+重驱"的抛错重试预算。
  • 双路恢复分工:抛错路径(intercept 被解除,task 拒绝)由 fail-trap 负责;静默失效路径(拦截已武装但 react-dnd 没生成组件)由 confirmDropOrRetry 的计数轮询负责(commands.js#L155-L169),二者互不替代。

值得注意的一个工程细节:代码注释解释了为什么用模块作用域标志位而不是闭包返回值——Cypress 在出错时同步调用 fail 处理器,处理器只能入队恢复命令并返回 false,无法用常规的异步恢复手段。

插件修复后的验证步骤

文档给出了五步验证清单,核心是"去掉兜底、跑绿三个代表性 spec":

  1. 在插件仓库中:升版本(如 0.1.3),npm publish(或 npm pack 后本地安装)。

  2. cypress-tests 中:升级 package.json 里的 cypress-real-dnd 版本,npm install

  3. commands.jsREUSE-AFTER-PLUGIN-FIX 注释块里的简化版 dragAndDropWidget 替换现有实现——彻底删掉 cy.on('fail') 陷阱,只保留每次导航前的重新武装和静默失效计数轮询。简化版命令形态为:

    const attempt = (triesLeft) => {
      countWidgets().then((before) => {
        openPanelAndSearch();
        cy.get(sourceSelector, { timeout: 15000 }).should("exist");
        cy.realDragInit();   // post-fix: genuinely re-arms+re-warms per nav
        cy.wait(300);
        cy.get("body").then(($body) => {
          cy.realDragAndDrop(sourceSelector, resolveCanvas($body), {
            targetX: positionX,
            targetY: positionY,
          });
          confirmDropOrRetry(before, 16, triesLeft); // silent-miss safety net
        });
      });
    };
    cy.realDragInit();
    cy.wait(500);
    attempt(3);
    cy.waitForAutoSave();
    
  4. 运行验证——在无 fail-trap、无任何 throw 的前提下全部保持绿色:

    cd cypress-tests
    npx cypress run --browser chrome --headless \
      --spec "cypress/e2e/happyPath/appbuilder/commonTestcases/components/buttonHappyPath.cy.js" \
      --config baseUrl=http://localhost:8082
    # repeat for datePickerHappyPath.cy.js and newSuits/componentsBasics/button.cy.js
    

    其中 buttonHappyPath.cy.jsdatePickerHappyPath.cy.js 正是当前已依赖 fail-trap 恢复冷拖拽抛错的代表性 spec。

  5. 加分项:整个套件里被隔离(quarantine)的 CSA(Control Component)拖拽测试——分散在 number/password/text/modal/csa 各 spec 中、统一标注为 "post-popover drag No dragIntercepted"——被同一根因阻塞。插件修复后应重新验证并取消 skip。仓库中 csa.skip.js 头部注释即记录了这一隔离原因:每个测试的"多次拖拽 + 打开 Radix popover + 第二次拖拽"流程都会在 popover 之后的第二次拖拽上命中 "No dragIntercepted"。

为什么先交付了 band-aid 而不是改插件

文档最后一节解释了取舍:node_modules 里的改动无法提交、且会在 npm ci 时丢失,因此只有测试侧的 cy.on('fail') 陷阱是可提交、可复现的止血方案。它能正确恢复抛错(重新武装 + 重驱,并 re-throw 一切非冷拦截错误),但"干净的修复归属在插件"。这个决策本身对依赖第三方 Cypress 插件的项目有普遍参考价值:当缺陷在依赖包内部且依赖无法 fork 入库时,可提交的测试侧兜底 + 一份精确的修复文档(含补丁代码与验证步骤)是维持套件绿色的标准做法——CYPRESS_REAL_DND_FIX.mdREUSE-AFTER-PLUGIN-FIX 注释块成对出现,正是为了让"插件修好后一键移除兜底"成为低成本操作。

小结

  • 症状边界:失败精确发生在 spec 首次拖拽、以及每次 AUT 导航后的首次拖拽,且以不可捕获的 cy.task 拒绝形式杀死整个 spec;
  • 根因链cdpPromise 缓存吞掉了武装动作 → realDragInit() 对温热 client 退化为纯 sleep → 内部重试预算过小,三层缺陷叠加使"导航后拦截丢失"不可自愈;
  • 修复方向:把"CDP 连接(一次)"与"武装+热身(可重跑)"解耦,realDragInit() 每次调用真实重武装,API 保持不变;
  • 过渡方案dragAndDropWidget 中的单发 cy.on('fail') 陷阱做冷拦截甄别、重新武装与重驱,非目标错误一律 re-throw;
  • 验证闭环:升插件版本 → 换用简化版命令 → 三个代表性 spec 全绿 → 解除 CSA 相关隔离用例。
登录后查看全文
热门项目推荐
相关项目推荐