fix(cypress): use atomic cy.get() selectors to prevent detached DOM flakiness

All file-list helper functions now use a single cy.get() with a compound
CSS selector from the document root instead of chaining .find() /
.findByRole() on an intermediate subject.

When Vue re-renders the file list (e.g. while opening the sharing sidebar)
Cypress throws "subject no longer attached to the DOM" on any chained
.find() call — the engine cannot re-query through a chained command.
A single cy.get() with a compound selector is re-executed from scratch
on every assertion retry, making it immune to mid-render detachment.

Changes:
- getActionButtonForFile/Id: use button[aria-label="Actions"] atomic selector
  (NcActions default ariaLabel is t('Actions') per @nextcloud/vue source)
- getActionEntryForFile/Id: combine #menuId + descendant in one cy.get()
  instead of cy.get('#id').find(child)
- triggerActionForFile/Id: single trigger click + atomic
  [role="menu"] [data-cy-files-list-row-action="id"] > button query;
  removes duplicate getActionButtonForFile() call; adds force:true on
  menu item click for animation/overlay robustness
- triggerInlineActionForFile/Id: atomic row selector instead of
  getActionsFor*().find()
- selectRowForFile: atomic row+checkbox input selector
- renameFile: atomic row+name-cell+input selector
- navigateToFolder: atomic row+name-link selector

Fixes the recurring CI failures in:
  cypress/e2e/files/files-renaming.cy.ts
  cypress/e2e/files_sharing/note-to-recipient.cy.ts
  cypress/e2e/files/favorites.cy.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
skjnldsv 2026-05-12 12:02:46 +02:00 committed by John Molakvoæ
parent 15db84fee8
commit 9a43669736

View file

@ -15,8 +15,13 @@ export const getRowForFile = (filename: string) => cy.get(`[data-cy-files-list-r
export const getActionsForFileId = (fileid: number) => cy.get(`[data-cy-files-list-row-fileid="${fileid}"] [data-cy-files-list-row-actions]`)
export const getActionsForFile = (filename: string) => cy.get(`[data-cy-files-list-row-name="${CSS.escape(filename)}"] [data-cy-files-list-row-actions]`)
export const getActionButtonForFileId = (fileid: number) => getActionsForFileId(fileid).findByRole('button', { name: 'Actions' })
export const getActionButtonForFile = (filename: string) => getActionsForFile(filename).findByRole('button', { name: 'Actions' })
// Fully atomic selectors — a single cy.get() from root prevents "subject no longer attached"
// errors when the file list re-renders (e.g. due to Vue reactivity while opening the sidebar).
// NcActions renders its trigger button with aria-label="Actions" by default (see @nextcloud/vue NcActions ariaLabel prop default).
export const getActionButtonForFileId = (fileid: number) =>
cy.get(`[data-cy-files-list-row-fileid="${fileid}"] [data-cy-files-list-row-actions] button[aria-label="Actions"]`)
export const getActionButtonForFile = (filename: string) =>
cy.get(`[data-cy-files-list-row-name="${CSS.escape(filename)}"] [data-cy-files-list-row-actions] button[aria-label="Actions"]`)
/**
*
@ -24,11 +29,11 @@ export const getActionButtonForFile = (filename: string) => getActionsForFile(fi
* @param actionId
*/
export function getActionEntryForFileId(fileid: number, actionId: string) {
// Use a combined selector inside .then() to avoid chaining .find() on a potentially
// stale menu subject — a single cy.get() with descendant combinator is re-queried atomically.
return getActionButtonForFileId(fileid)
.should('have.attr', 'aria-controls')
.then((menuId) => cy.get(`#${menuId}`)
.should('exist')
.find(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`))
.then((menuId) => cy.get(`#${menuId} [data-cy-files-list-row-action="${CSS.escape(actionId)}"]`))
}
/**
@ -37,11 +42,11 @@ export function getActionEntryForFileId(fileid: number, actionId: string) {
* @param actionId
*/
export function getActionEntryForFile(file: string, actionId: string) {
// Use a combined selector inside .then() to avoid chaining .find() on a potentially
// stale menu subject — a single cy.get() with descendant combinator is re-queried atomically.
return getActionButtonForFile(file)
.should('have.attr', 'aria-controls')
.then((menuId) => cy.get(`#${menuId}`)
.should('exist')
.find(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`))
.then((menuId) => cy.get(`#${menuId} [data-cy-files-list-row-action="${CSS.escape(actionId)}"]`))
}
/**
@ -69,13 +74,15 @@ export function getInlineActionEntryForFile(file: string, actionId: string) {
*/
export function triggerActionForFileId(fileid: number, actionId: string) {
getActionButtonForFileId(fileid)
.scrollIntoView()
getActionButtonForFileId(fileid)
.click({ force: true }) // force to avoid issues with overlaying file list header
getActionEntryForFileId(fileid, actionId)
.find('button')
.should('be.visible')
.click()
.scrollIntoView()
.click({ force: true }) // force to avoid issues with overlaying file list header
// Single atomic cy.get() from root — avoids "subject no longer attached" when the
// file list re-renders (Vue reactivity) while the dropdown is open.
// force: true to handle brief animation/overlay states during menu transitions.
cy.get(`[role="menu"] [data-cy-files-list-row-action="${CSS.escape(actionId)}"] > button`)
.should('be.visible')
.click({ force: true })
}
/**
@ -85,13 +92,15 @@ export function triggerActionForFileId(fileid: number, actionId: string) {
*/
export function triggerActionForFile(filename: string, actionId: string) {
getActionButtonForFile(filename)
.scrollIntoView()
getActionButtonForFile(filename)
.click({ force: true }) // force to avoid issues with overlaying file list header
getActionEntryForFile(filename, actionId)
.find('button')
.should('be.visible')
.click()
.scrollIntoView()
.click({ force: true }) // force to avoid issues with overlaying file list header
// Single atomic cy.get() from root — avoids "subject no longer attached" when the
// file list re-renders (Vue reactivity) while the dropdown is open.
// force: true to handle brief animation/overlay states during menu transitions.
cy.get(`[role="menu"] [data-cy-files-list-row-action="${CSS.escape(actionId)}"] > button`)
.should('be.visible')
.click({ force: true })
}
/**
@ -100,8 +109,9 @@ export function triggerActionForFile(filename: string, actionId: string) {
* @param actionId
*/
export function triggerInlineActionForFileId(fileid: number, actionId: string) {
getActionsForFileId(fileid)
.find(`button[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`)
// Atomic selector — inline NcActionButton renders the button as the root element,
// so button[data-cy-files-list-row-action] is correct for inline (non-menu) actions.
cy.get(`[data-cy-files-list-row-fileid="${fileid}"] button[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`)
.should('exist')
.click()
}
@ -111,8 +121,9 @@ export function triggerInlineActionForFileId(fileid: number, actionId: string) {
* @param actionId
*/
export function triggerInlineActionForFile(filename: string, actionId: string) {
getActionsForFile(filename)
.find(`button[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`)
// Atomic selector — inline NcActionButton renders the button as the root element,
// so button[data-cy-files-list-row-action] is correct for inline (non-menu) actions.
cy.get(`[data-cy-files-list-row-name="${CSS.escape(filename)}"] button[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`)
.should('exist')
.click()
}
@ -140,9 +151,8 @@ export function deselectAllFiles() {
* @param options
*/
export function selectRowForFile(filename: string, options: Partial<Cypress.ClickOptions> = {}) {
getRowForFile(filename)
.find('[data-cy-files-list-row-checkbox]')
.findByRole('checkbox')
// Atomic selector — avoids chained .find() on a potentially stale row subject.
cy.get(`[data-cy-files-list-row-name="${CSS.escape(filename)}"] [data-cy-files-list-row-checkbox] input[type="checkbox"]`)
// don't use click to avoid triggering side effects events
.trigger('change', { ...options, force: true })
.should('be.checked')
@ -260,8 +270,9 @@ export function renameFile(fileName: string, newFileName: string) {
// intercept the move so we can wait for it
cy.intercept('MOVE', /\/(remote|public)\.php\/dav\/files\//).as('moveFile')
getRowForFile(fileName)
.find('[data-cy-files-list-row-name] input')
// Atomic selector — avoids chained .find() on a row subject that may have re-rendered
// when entering rename mode (the link element is replaced by an input).
cy.get(`[data-cy-files-list-row-name="${CSS.escape(fileName)}"] [data-cy-files-list-row-name] input`)
.type(`{selectAll}${newFileName}{enter}`)
cy.wait('@moveFile')
@ -278,7 +289,10 @@ export function navigateToFolder(dirPath: string) {
continue
}
getRowForFile(directory).should('be.visible').find('[data-cy-files-list-row-name-link]').click()
// Atomic selector — avoids chained .find() on a potentially stale row subject.
cy.get(`[data-cy-files-list-row-name="${CSS.escape(directory)}"] [data-cy-files-list-row-name-link]`)
.should('be.visible')
.click()
}
}