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

