From 7473d3d467a5a2a4484ae7844ec2f4a3d1924f70 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 4 Aug 2025 13:41:31 +0200 Subject: [PATCH 1/2] test(cypress): split helpers for files actions to make tests less flaky Signed-off-by: Ferdinand Thiessen --- cypress/e2e/files/FilesUtils.ts | 91 ++++++++++++------- cypress/e2e/files/files-actions.cy.ts | 14 +-- cypress/e2e/files/files-renaming.cy.ts | 9 +- .../files-user-credentials.cy.ts | 11 ++- .../e2e/files_sharing/FilesSharingUtils.ts | 4 +- .../e2e/files_sharing/note-to-recipient.cy.ts | 2 +- .../files_sharing/share-status-action.cy.ts | 15 ++- 7 files changed, 84 insertions(+), 62 deletions(-) diff --git a/cypress/e2e/files/FilesUtils.ts b/cypress/e2e/files/FilesUtils.ts index d8dd8ee55882f..71ea341a7bfb4 100644 --- a/cypress/e2e/files/FilesUtils.ts +++ b/cypress/e2e/files/FilesUtils.ts @@ -4,7 +4,7 @@ */ import type { User } from '@nextcloud/cypress' -import { ACTION_COPY_MOVE } from "../../../apps/files/src/actions/moveOrCopyAction" +import { ACTION_COPY_MOVE } from '../../../apps/files/src/actions/moveOrCopyAction.ts' export const getRowForFileId = (fileid: number) => cy.get(`[data-cy-files-list-row-fileid="${fileid}"]`) export const getRowForFile = (filename: string) => cy.get(`[data-cy-files-list-row-name="${CSS.escape(filename)}"]`) @@ -15,55 +15,63 @@ export const getActionsForFile = (filename: string) => getRowForFile(filename).f export const getActionButtonForFileId = (fileid: number) => getActionsForFileId(fileid).findByRole('button', { name: 'Actions' }) export const getActionButtonForFile = (filename: string) => getActionsForFile(filename).findByRole('button', { name: 'Actions' }) -const searchForActionInRow = (row: JQuery, actionId: string): Cypress.Chainable> => { - const action = row.find(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`) - if (action.length > 0) { - cy.log('Found action in row') - return cy.wrap(action) - } - - // Else look in the action menu - const menuButtonId = row.find('button[aria-controls]').attr('aria-controls') - if (menuButtonId === undefined) { - return cy.wrap(Cypress.$()) - } +export const getActionEntryForFileId = (fileid: number, actionId: string) => { + return getActionButtonForFileId(fileid) + .should('have.attr', 'aria-controls') + .then((menuId) => cy.get(`#${menuId}`).find(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`)) +} - // eslint-disable-next-line no-unused-expressions - expect(menuButtonId).not.to.be.undefined - return cy.get(`#${menuButtonId} [data-cy-files-list-row-action="${CSS.escape(actionId)}"]`) +export const getActionEntryForFile = (file: string, actionId: string) => { + return getActionButtonForFile(file) + .should('have.attr', 'aria-controls') + .then((menuId) => cy.get(`#${menuId}`).find(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`)) } -export const getActionEntryForFileId = (fileid: number, actionId: string): Cypress.Chainable> => { - // If we cannot find the action in the row, it might be in the action menu - return getRowForFileId(fileid).should('be.visible') - .then(row => searchForActionInRow(row, actionId)) +export const getInlineActionEntryForFileId = (fileid: number, actionId: string) => { + return getActionsForFileId(fileid) + .find(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`) } -export const getActionEntryForFile = (filename: string, actionId: string): Cypress.Chainable> => { - // If we cannot find the action in the row, it might be in the action menu - return getRowForFile(filename).should('be.visible') - .then(row => searchForActionInRow(row, actionId)) + +export const getInlineActionEntryForFile = (file: string, actionId: string) => { + return getActionsForFile(file) + .find(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`) } export const triggerActionForFileId = (fileid: number, actionId: string) => { - // Even if it's inline, we open the action menu to get all actions visible - getActionButtonForFileId(fileid).click({ force: true }) + getActionButtonForFileId(fileid) + .as('actionButton') + .scrollIntoView() + cy.get('@actionButton') + .click({ force: true }) // force to avoid issues with overlaying file list header getActionEntryForFileId(fileid, actionId) - .find('button').last() - .should('exist').click({ force: true }) + .find('button') + .should('be.visible') + .click() } + export const triggerActionForFile = (filename: string, actionId: string) => { - // Even if it's inline, we open the action menu to get all actions visible - getActionButtonForFile(filename).click({ force: true }) + getActionButtonForFile(filename) + .as('actionButton') + .scrollIntoView() + cy.get('@actionButton') + .click({ force: true }) // force to avoid issues with overlaying file list header getActionEntryForFile(filename, actionId) - .find('button').last() - .should('exist').click({ force: true }) + .find('button') + .should('be.visible') + .click() } export const triggerInlineActionForFileId = (fileid: number, actionId: string) => { - getActionsForFileId(fileid).find(`button[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`).should('exist').click() + getActionsForFileId(fileid) + .find(`button[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`) + .should('exist') + .click() } export const triggerInlineActionForFile = (filename: string, actionId: string) => { - getActionsForFile(filename).find(`button[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`).should('exist').click() + getActionsForFile(filename) + .find(`button[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`) + .should('exist') + .click() } export const selectAllFiles = () => { @@ -166,12 +174,17 @@ export const copyFile = (fileName: string, dirPath: string) => { export const renameFile = (fileName: string, newFileName: string) => { getRowForFile(fileName) + .should('exist') + .scrollIntoView() + triggerActionForFile(fileName, 'rename') // 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').type(`{selectAll}${newFileName}{enter}`) + getRowForFile(fileName) + .find('[data-cy-files-list-row-name] input') + .type(`{selectAll}${newFileName}{enter}`) cy.wait('@moveFile') } @@ -182,8 +195,10 @@ export const navigateToFolder = (dirPath: string) => { if (directory === '') { continue } + getRowForFile(directory).should('be.visible').find('[data-cy-files-list-row-name-link]').click() } + } export const closeSidebar = () => { @@ -281,6 +296,12 @@ export function enableGridMode() { export function calculateViewportHeight(rows: number): Cypress.Chainable { cy.visit('/apps/files') + cy.get('[data-cy-files-list]') + .should('be.visible') + + cy.get('[data-cy-files-list-tbody] tr', { timeout: 5000 }) + .and('be.visible') + return cy.get('[data-cy-files-list]') .should('be.visible') .then((filesList) => { diff --git a/cypress/e2e/files/files-actions.cy.ts b/cypress/e2e/files/files-actions.cy.ts index e7b5548cd6f58..1f5d9f072258c 100644 --- a/cypress/e2e/files/files-actions.cy.ts +++ b/cypress/e2e/files/files-actions.cy.ts @@ -10,7 +10,6 @@ import { getActionButtonForFileId, getActionEntryForFileId, getRowForFile, getSe import { ACTION_COPY_MOVE } from '../../../apps/files/src/actions/moveOrCopyAction' import { ACTION_DELETE } from '../../../apps/files/src/actions/deleteAction' import { ACTION_DETAILS } from '../../../apps/files/src/actions/sidebarAction' -import { ACTION_SHARING_STATUS } from '../../../apps/files_sharing/src/files_actions/sharingStatusAction' declare global { interface Window { @@ -24,7 +23,6 @@ const expectedDefaultActionsIDs = [ ACTION_COPY_MOVE, ACTION_DELETE, ACTION_DETAILS, - ACTION_SHARING_STATUS, ] const expectedDefaultSelectionActionsIDs = [ ACTION_COPY_MOVE, @@ -94,7 +92,9 @@ describe('Files: Actions', { testIsolation: true }, () => { }) // Open the menu - getActionButtonForFileId(fileId).click({ force: true }) + getActionButtonForFileId(fileId) + .scrollIntoView() + .click({ force: true }) // Check we have the parent action but not the children getActionEntryForFileId(fileId, 'nested-action').should('be.visible') @@ -104,8 +104,8 @@ describe('Files: Actions', { testIsolation: true }, () => { // Click on the parent action getActionEntryForFileId(fileId, 'nested-action') - .find('button').last() - .should('exist').click({ force: true }) + .should('be.visible') + .click() // Check we have the children and the back button but not the parent getActionEntryForFileId(fileId, 'nested-action').should('not.exist') @@ -115,8 +115,8 @@ describe('Files: Actions', { testIsolation: true }, () => { // Click on the back button getActionEntryForFileId(fileId, 'menu-back') - .find('button').last() - .should('exist').click({ force: true }) + .should('be.visible') + .click() // Check we have the parent action but not the children getActionEntryForFileId(fileId, 'nested-action').should('be.visible') diff --git a/cypress/e2e/files/files-renaming.cy.ts b/cypress/e2e/files/files-renaming.cy.ts index 269f0bd252b45..3839519b2196c 100644 --- a/cypress/e2e/files/files-renaming.cy.ts +++ b/cypress/e2e/files/files-renaming.cy.ts @@ -179,13 +179,16 @@ describe('files: Rename nodes', { testIsolation: true }, () => { cy.visit('/apps/files') - getRowForFile('file.txt').should('be.visible') + getRowForFile('file.txt') + .should('be.visible') // Z so it is shown last renameFile('file.txt', 'zzz.txt') // not visible any longer - getRowForFile('zzz.txt').should('not.exist') + getRowForFile('zzz.txt') + .should('not.exist') // scroll file list to bottom - cy.get('[data-cy-files-list]').scrollTo('bottom') + cy.get('[data-cy-files-list]') + .scrollTo('bottom') cy.screenshot() // The file is no longer in rename state getRowForFile('zzz.txt') diff --git a/cypress/e2e/files_external/files-user-credentials.cy.ts b/cypress/e2e/files_external/files-user-credentials.cy.ts index 0bcb356c864aa..8f4602496bb7d 100644 --- a/cypress/e2e/files_external/files-user-credentials.cy.ts +++ b/cypress/e2e/files_external/files-user-credentials.cy.ts @@ -5,7 +5,7 @@ import { User } from '@nextcloud/cypress' import { AuthBackend, createStorageWithConfig, StorageBackend } from './StorageUtils' -import { getActionEntryForFile, getRowForFile, navigateToFolder, triggerInlineActionForFile } from '../files/FilesUtils' +import { getInlineActionEntryForFile, getRowForFile, navigateToFolder, triggerInlineActionForFile } from '../files/FilesUtils' import { ACTION_CREDENTIALS_EXTERNAL_STORAGE } from '../../../apps/files_external/src/actions/enterCredentialsAction' import { handlePasswordConfirmation } from '../settings/usersUtils' @@ -76,7 +76,8 @@ describe('Files user credentials', { testIsolation: true }, () => { // Auth dialog should be closed and the set credentials button should be gone authDialog.should('not.exist', { timeout: 2000 }) - getActionEntryForFile(storageUser.userId, ACTION_CREDENTIALS_EXTERNAL_STORAGE).should('not.exist') + getInlineActionEntryForFile(storageUser.userId, ACTION_CREDENTIALS_EXTERNAL_STORAGE) + .should('not.exist') // Finally, the storage should be accessible cy.visit('/apps/files') @@ -125,7 +126,7 @@ describe('Files user credentials', { testIsolation: true }, () => { // Auth dialog should be closed and the set credentials button should be gone cy.get('@authDialog').should('not.exist', { timeout: 2000 }) - getActionEntryForFile('storage1', ACTION_CREDENTIALS_EXTERNAL_STORAGE).should('not.exist') + getInlineActionEntryForFile('storage1', ACTION_CREDENTIALS_EXTERNAL_STORAGE).should('not.exist') // Finally, the storage should be accessible cy.visit('/apps/files') @@ -144,8 +145,8 @@ describe('Files user credentials', { testIsolation: true }, () => { getRowForFile('storage2').should('be.visible') // Since we already have set the credentials, the action should not be present - getActionEntryForFile('storage1', ACTION_CREDENTIALS_EXTERNAL_STORAGE).should('not.exist') - getActionEntryForFile('storage2', ACTION_CREDENTIALS_EXTERNAL_STORAGE).should('not.exist') + getInlineActionEntryForFile('storage1', ACTION_CREDENTIALS_EXTERNAL_STORAGE).should('not.exist') + getInlineActionEntryForFile('storage2', ACTION_CREDENTIALS_EXTERNAL_STORAGE).should('not.exist') // Finally, the storage should be accessible cy.visit('/apps/files') diff --git a/cypress/e2e/files_sharing/FilesSharingUtils.ts b/cypress/e2e/files_sharing/FilesSharingUtils.ts index a8e89888cc038..b8d24864470c7 100644 --- a/cypress/e2e/files_sharing/FilesSharingUtils.ts +++ b/cypress/e2e/files_sharing/FilesSharingUtils.ts @@ -124,8 +124,8 @@ export function updateShare(fileName: string, index: number, shareSettings: Part export function openSharingPanel(fileName: string) { triggerActionForFile(fileName, 'details') - cy.get('#app-sidebar-vue') - .get('[aria-controls="tab-sharing"]') + cy.get('[data-cy-sidebar]') + .find('[aria-controls="tab-sharing"]') .click() } diff --git a/cypress/e2e/files_sharing/note-to-recipient.cy.ts b/cypress/e2e/files_sharing/note-to-recipient.cy.ts index 60f02b9023df3..a666892d55e44 100644 --- a/cypress/e2e/files_sharing/note-to-recipient.cy.ts +++ b/cypress/e2e/files_sharing/note-to-recipient.cy.ts @@ -72,7 +72,7 @@ describe('files_sharing: Note to recipient', { testIsolation: true }, () => { createShare('folder', sharee.userId, { read: true, download: true, note: 'Hello, this is the note.' }) // reload just to be sure - cy.reload() + cy.visit('/apps/files') // open the sharing tab openSharingPanel('folder') diff --git a/cypress/e2e/files_sharing/share-status-action.cy.ts b/cypress/e2e/files_sharing/share-status-action.cy.ts index 1b88810047a44..c88f117ddc8e7 100644 --- a/cypress/e2e/files_sharing/share-status-action.cy.ts +++ b/cypress/e2e/files_sharing/share-status-action.cy.ts @@ -4,7 +4,7 @@ */ import type { User } from '@nextcloud/cypress' import { createShare } from './FilesSharingUtils.ts' -import { closeSidebar, enableGridMode, getActionButtonForFile, getRowForFile } from '../files/FilesUtils.ts' +import { closeSidebar, enableGridMode, getActionButtonForFile, getInlineActionEntryForFile, getRowForFile } from '../files/FilesUtils.ts' describe('files_sharing: Sharing status action', { testIsolation: true }, () => { /** @@ -78,10 +78,9 @@ describe('files_sharing: Sharing status action', { testIsolation: true }, () => cy.login(user) cy.visit('/apps/files') - getRowForFile('folder') - .should('be.visible') - .find('[data-cy-files-list-row-actions]') - .findByRole('button', { name: /^Shared with/i }) + getInlineActionEntryForFile('folder', 'sharing-status') + .should('have.attr', 'aria-label', `Shared with ${sharee.userId}`) + .should('have.attr', 'title', `Shared with ${sharee.userId}`) .should('be.visible') }) @@ -103,10 +102,8 @@ describe('files_sharing: Sharing status action', { testIsolation: true }, () => cy.login(sharee) cy.visit('/apps/files') - getRowForFile('folder') - .should('be.visible') - .find('[data-cy-files-list-row-actions]') - .findByRole('button', { name: `Shared by ${user.userId}` }) + getInlineActionEntryForFile('folder', 'sharing-status') + .should('have.attr', 'aria-label', `Shared by ${user.userId}`) .should('be.visible') }) From bdc17d143ab5b5dbe3961b2880cce0b89181055b Mon Sep 17 00:00:00 2001 From: Peter Ringelmann Date: Mon, 11 May 2026 13:15:18 +0200 Subject: [PATCH 2/2] chore(ci): Make row action helpers atomic to avoid mid-render detachment Signed-off-by: Peter Ringelmann Signed-off-by: Salvatore Martire <4652631+salmart-dev@users.noreply.github.com> --- cypress/e2e/files/FilesUtils.ts | 12 ++++++------ .../e2e/files_sharing/share-status-action.cy.ts | 14 ++++++-------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/cypress/e2e/files/FilesUtils.ts b/cypress/e2e/files/FilesUtils.ts index 71ea341a7bfb4..7e790da379a5f 100644 --- a/cypress/e2e/files/FilesUtils.ts +++ b/cypress/e2e/files/FilesUtils.ts @@ -9,8 +9,10 @@ import { ACTION_COPY_MOVE } from '../../../apps/files/src/actions/moveOrCopyActi export const getRowForFileId = (fileid: number) => cy.get(`[data-cy-files-list-row-fileid="${fileid}"]`) export const getRowForFile = (filename: string) => cy.get(`[data-cy-files-list-row-name="${CSS.escape(filename)}"]`) -export const getActionsForFileId = (fileid: number) => getRowForFileId(fileid).find('[data-cy-files-list-row-actions]') -export const getActionsForFile = (filename: string) => getRowForFile(filename).find('[data-cy-files-list-row-actions]') +// Atomic query so the lookup is retried as a whole when rows re-render +// (chained .find() can fail with "subject no longer attached" mid-render). +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' }) @@ -28,13 +30,11 @@ export const getActionEntryForFile = (file: string, actionId: string) => { } export const getInlineActionEntryForFileId = (fileid: number, actionId: string) => { - return getActionsForFileId(fileid) - .find(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`) + return cy.get(`[data-cy-files-list-row-fileid="${fileid}"] [data-cy-files-list-row-action="${CSS.escape(actionId)}"]`) } export const getInlineActionEntryForFile = (file: string, actionId: string) => { - return getActionsForFile(file) - .find(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`) + return cy.get(`[data-cy-files-list-row-name="${CSS.escape(file)}"] [data-cy-files-list-row-action="${CSS.escape(actionId)}"]`) } export const triggerActionForFileId = (fileid: number, actionId: string) => { diff --git a/cypress/e2e/files_sharing/share-status-action.cy.ts b/cypress/e2e/files_sharing/share-status-action.cy.ts index c88f117ddc8e7..15971e31f3c65 100644 --- a/cypress/e2e/files_sharing/share-status-action.cy.ts +++ b/cypress/e2e/files_sharing/share-status-action.cy.ts @@ -4,7 +4,7 @@ */ import type { User } from '@nextcloud/cypress' import { createShare } from './FilesSharingUtils.ts' -import { closeSidebar, enableGridMode, getActionButtonForFile, getInlineActionEntryForFile, getRowForFile } from '../files/FilesUtils.ts' +import { closeSidebar, enableGridMode, getActionButtonForFile, getInlineActionEntryForFile, getRowForFile, getActionsForFile } from '../files/FilesUtils.ts' describe('files_sharing: Sharing status action', { testIsolation: true }, () => { /** @@ -22,9 +22,8 @@ describe('files_sharing: Sharing status action', { testIsolation: true }, () => cy.visit('/apps/files') - getRowForFile('folder') - .should('be.visible') - .find('[data-cy-files-list-row-actions]') + getRowForFile('folder').should('be.visible') + getActionsForFile('folder') .findByRole('button', { name: 'Shared' }) .should('not.exist') }) @@ -37,12 +36,11 @@ describe('files_sharing: Sharing status action', { testIsolation: true }, () => cy.visit('/apps/files') }) - getRowForFile('folder') - .should('be.visible') - .find('[data-cy-files-list-row-actions]') + getRowForFile('folder').should('be.visible') + getActionsForFile('folder') .findByRole('button', { name: /Show sharing options/ }) .should('be.visible') - .click() + .click({ force: true }) // check the click opened the sidebar cy.get('[data-cy-sidebar]')