You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Turbo与Cypress Dashboard兼容问题:选择操作超时排查

问题描述

在使用Turbo的页面中实现了年份选择器,通过Stimulus控制器触发Turbo Frame更新结果列表。本地Cypress测试100%通过,但在Cypress Dashboard上仅1/4-1/3的运行成功,报错:

cy.then() timed out after waiting 6000ms.
Your callback function returned a promise that never resolved.

相关代码如下:

ERB模板代码

= turbo_frame_tag "widget-index", 'data-controller': 'turbo-frame-select' do
  .widget-card-header
    select 
     data-test-id="Widget-year-select" 
     data-action="turbo-frame-select#onChange"
     type="select"
     name="filter"
       - @widget_years.each do |year|
        option[selected=("selected" if (year == @search_year)) value="#{widget_index_path(company_id: @company.abbreviation, year: year)}"] #{year}

  #widgets_results data-test-id="widgets-results"
      = render WidgetCardComponent.with_collection(@widgets)

Stimulus控制器代码

// turbo_frame_select_controller.js

import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  onChange (event) {
    const frame = this.element;
    frame.src = event.target.value;
  )}
}

Cypress重写的select命令

Cypress.Commands.overwrite("select", (originalFn, subject, ...args) => {
  // Check if turbo is active for this select event
  const turboFrame = subject[0].closest('[data-controller="turbo-frame-select"]');
  const isSelect = subject[0].tagName === 'SELECT';

  if (isSelect && turboFrame) {
    // Wait for turbo to finish loading the page before proceeding with the next Cypress instructions.
    // First, get the document
    cy.document({ log: false }).then($document => {
      console.log('mutating observer last child');
      Cypress.log({
        $el: subject,
        name: 'select',
        displayName: 'select',
        message: 'select and wait for page to load',
        consoleProps: () => ({ subject: subject })
      });

      // Make Cypress wait for this promise which waits for the turbFrame to cause a mutation
      return new Cypress.Promise(resolve => {
        // Once we receive the mutuation,
        const onTurboFrameMutate = function(mutationList, observer) {
          for(const mutation of mutationList) {
            const isChild = mutation.type === 'childList';
            const lastChild = mutationList.length -1 !== mutationList.indexOf(mutation)

            if (isChild  && lastChild) {
              console.log('A child node has been added or removed.');
              turboFrameObserver.disconnect();

              // signal to Cypress that we're done
              resolve()
            }
          }
        }

        // createa new observer
        const turboFrameObserver = new MutationObserver(onTurboFrameMutate);

        // Options for the observer (which mutations to observe)
        const config = { attributes: true, childList: true, subtree: true };

        // Add our logic as observer
        turboFrameObserver.observe(turboFrame, config);

        // Finally, we are ready to perform the actual select operation
        originalFn(subject, ...args);
      })
    });
  } else {
    // Not a normal select on an <select> tag, turbo will not interfere here
    return originalFn(subject, ...args);
  }
});

测试代码片段

cy.visit(widgetIndexPath, { timeout: 10000 });

// Make sure header is loaded
cy.contains("h1", "Cash management");

// Current year selected
cy.get("@aYearSelector").find("option:selected").contains(currentYear);

// Select last year
   cy.get("@aYearSelector")
     .find("option")
     .its('length')
     .then((length) => {
       cy.get("@aYearSelector")
         .select(length - 1, {force: true})
         .then(()=> {
           // Triggers after year selector changes
           cy.wait('@previousYearRequest').then(({response}) => {
             expect(response.statusCode).to.eq(200);

             cy.get("[data-test-id='widget-year-select']").as('newYearSelector');
             cy.get("[data-test-id='widgets-results']").as('newResults');

             cy.get("@newYearSelector").find("option:selected").contains(previousYear);

             // Note: We have 13 months of data in the data layer from the factories.
             // The months count backwards from the current month which will be spread out
             // over 2 years.
             cy.get('@newResults')

可能的原因及解决办法

1. Mutation Observer判断逻辑存在致命漏洞

你的onTurboFrameMutate函数中,lastChild的判断逻辑是mutationList.length -1 !== mutationList.indexOf(mutation),这意味着只有非最后一个mutation才会触发resolve。但Turbo Frame更新可能只产生1个mutation,或者最后一个mutation才是更新完成的标志,直接导致Promise永远无法resolve,触发超时。

修复方法:
移除lastChild的错误判断,改为监听目标结果区域的更新:

const onTurboFrameMutate = function(mutationList, observer) {
  for(const mutation of mutationList) {
    if (mutation.type === 'childList') {
      // 检查是否是结果区域的更新
      const hasResultsUpdate = Array.from(mutation.addedNodes).some(node => 
        node.id === 'widgets_results' || node.querySelector('#widgets_results')
      );
      if (hasResultsUpdate) {
        observer.disconnect();
        resolve();
        break; // 避免多次触发
      }
    }
  }
}

2. 环境差异导致Turbo更新速度不一致

本地环境和Cypress Dashboard的网络、CPU性能有差异,Turbo Frame加载可能更慢,但你的代码没有设置超时兜底,导致Promise一直pending。

修复方法:
给Promise添加超时机制,超过10秒强制结束等待:

return new Cypress.Promise((resolve) => {
  // 超时兜底
  const timeoutId = setTimeout(() => {
    turboFrameObserver.disconnect();
    resolve();
  }, 10000);

  const onTurboFrameMutate = function(mutationList, observer) {
    for(const mutation of mutationList) {
      if (mutation.type === 'childList') {
        clearTimeout(timeoutId);
        observer.disconnect();
        resolve();
        break;
      }
    }
  }

  // ... 其他初始化代码
})

3. 手动监听Mutation不如利用Turbo原生事件可靠

Turbo在Frame加载完成时会触发turbo:frame-load事件,比手动监听Mutation Observer更精准,不会遗漏更新完成的信号。

修复方法:
改用监听Turbo原生事件来判断更新完成:

Cypress.Commands.overwrite("select", (originalFn, subject, ...args) => {
  const turboFrame = subject[0].closest('[data-controller="turbo-frame-select"]');
  const isSelect = subject[0].tagName === 'SELECT';

  if (isSelect && turboFrame) {
    return cy.document().then($document => {
      return new Cypress.Promise(resolve => {
        const onFrameLoad = () => {
          $document.removeEventListener('turbo:frame-load', onFrameLoad);
          resolve();
        };
        $document.addEventListener('turbo:frame-load', onFrameLoad);

        originalFn(subject, ...args);
      });
    });
  } else {
    return originalFn(subject, ...args);
  }
});

4. 测试中的请求别名依赖不稳定

如果@previousYearRequest的拦截规则不精准,或者网络延迟导致请求未在预期时间内完成,会阻塞后续逻辑,间接引发超时。

修复方法:
确保请求拦截的URL匹配正确,并给cy.wait()设置更长超时:

// 测试开头添加精准的请求拦截
cy.intercept('GET', `/widgets/${@company.abbreviation}*`).as('previousYearRequest');

// 等待请求时延长超时时间
cy.wait('@previousYearRequest', { timeout: 15000 }).then(({response}) => {
  expect(response.statusCode).to.eq(200);
});

内容的提问来源于stack exchange,提问作者Niles M

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.26 04:12:09