Skip to content

Commit 97f7108

Browse files
authored
Merge pull request #63097 from nextcloud/fix/fix-cypress-stability
[stable33] test(cypress): Improve stability of cypress test suite by fixing several issues
2 parents 18c5290 + ea264cc commit 97f7108

16 files changed

Lines changed: 498 additions & 165 deletions

‎cypress.config.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* SPDX-License-Identifier: AGPL-3.0-or-later
55
*/
66

7-
import { configureNextcloud, docker, getContainer, getContainerName, runExec, runOcc, startNextcloud, stopNextcloud, waitOnNextcloud } from '@nextcloud/e2e-test-server'
7+
import { configureNextcloud, docker, getContainer, getContainerName, runOcc, startNextcloud, stopNextcloud, waitOnNextcloud } from '@nextcloud/e2e-test-server'
88
import { defineConfig } from 'cypress'
99
import cypressSplit from 'cypress-split'
1010
import vitePreprocessor from 'cypress-vite'
@@ -58,6 +58,11 @@ export default defineConfig({
5858
// Disable session isolation
5959
testIsolation: false,
6060

61+
// The default 4s regularly expires on plain rendering latency on slow
62+
// CI runners. Prefer explicit waits where a request or state exists to
63+
// wait on; this only buys headroom for rendering, which has neither.
64+
defaultCommandTimeout: 10000,
65+
6166
requestTimeout: 30000,
6267

6368
// We've imported your old cypress plugins here.
@@ -165,9 +170,6 @@ export default defineConfig({
165170
config.baseUrl = `http://localhost:${port}/index.php`
166171
// if needed for the setup tests, connect to the actions network
167172
await connectToActionsNetwork()
168-
// make sure not to write into apps but use a local apps folder
169-
runExec(['mkdir', 'apps-cypress'])
170-
runExec(['cp', 'cypress/fixtures/app.config.php', 'config'])
171173
// now wait until Nextcloud is ready and configure it
172174
await waitOnNextcloud(ip)
173175
await configureNextcloud()

‎cypress/e2e/files/FilesUtils.ts‎

Lines changed: 159 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,83 @@ export function getInlineActionEntryForFile(file: string, actionId: string) {
6262
return cy.get(`[data-cy-files-list-row-name="${CSS.escape(file)}"] [data-cy-files-list-row-action="${CSS.escape(actionId)}"]`)
6363
}
6464

65+
/**
66+
* Poll a row's actions menu until `tryFinish` succeeds against its popover.
67+
*
68+
* On slow (CI) runners a single interaction with the menu is not reliable:
69+
* - The opening click is lost while the row's handler is not attached yet
70+
* (toggle stays aria-expanded="false") — must click again.
71+
* - The menu is opening but the popover still positions itself over several
72+
* frames (aria-expanded="true", not yet visible) — clicking now would
73+
* toggle it closed and wedge the show/hide transitions; must only wait.
74+
* - A concurrent list re-render (e.g. a preview finishing) can replace the
75+
* popover at any moment — `tryFinish` gets a freshly queried popover per
76+
* attempt and must do all its work against it synchronously.
77+
*
78+
* @param getActionButton query for the actions menu toggle of the row
79+
* @param tryFinish called with the freshly queried popover, reports completion
80+
* @param failureMessage error message when the time budget is exhausted
81+
*/
82+
function pollActionsMenu<T extends HTMLElement>(
83+
getActionButton: () => Cypress.Chainable<JQuery<T>>,
84+
tryFinish: ($menu: JQuery<HTMLElement>) => boolean,
85+
failureMessage: string,
86+
) {
87+
const poll = (elapsed: number) => {
88+
getActionButton().then(($toggle) => {
89+
const menuId = $toggle.attr('aria-controls')
90+
if (menuId && tryFinish(Cypress.$(`#${CSS.escape(menuId)}`))) {
91+
return
92+
}
93+
if (elapsed >= 20000) {
94+
throw new Error(`${failureMessage} (aria-expanded=${$toggle.attr('aria-expanded')})`)
95+
}
96+
if ($toggle.attr('aria-expanded') !== 'true') {
97+
cy.wrap($toggle).click({ force: true }) // force to avoid issues with overlaying file list header
98+
}
99+
// eslint-disable-next-line cypress/no-unnecessary-waiting -- give the popover a moment to open/position before re-checking
100+
cy.wait(250)
101+
poll(elapsed + 250)
102+
})
103+
}
104+
poll(0)
105+
}
106+
107+
/**
108+
* Open the actions menu of a file row and wait until it is displayed.
109+
*
110+
* @param getActionButton query for the actions menu toggle of the row
111+
*/
112+
export function openActionsMenu<T extends HTMLElement>(getActionButton: () => Cypress.Chainable<JQuery<T>>) {
113+
pollActionsMenu(getActionButton, ($menu) => $menu.is(':visible'), 'Actions menu did not open')
114+
}
115+
116+
/**
117+
* Open the actions menu of a file row and click the given action in it.
118+
*
119+
* Queried and natively clicked in one synchronous step: a command chain into
120+
* the popover would detach its subject whenever a re-render hits in between.
121+
*
122+
* @param getActionButton query for the actions menu toggle of the row
123+
* @param actionId id of the action to click
124+
*/
125+
function triggerActionInMenu<T extends HTMLElement>(getActionButton: () => Cypress.Chainable<JQuery<T>>, actionId: string) {
126+
pollActionsMenu(
127+
getActionButton,
128+
($menu) => {
129+
const button = $menu.find(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"] button:visible`).get(0)
130+
// A disabled button would swallow the click silently, so keep
131+
// polling instead of reporting the action as triggered.
132+
if (!button || (button as HTMLButtonElement).disabled) {
133+
return false
134+
}
135+
button.click()
136+
return true
137+
},
138+
`Action "${actionId}" did not become clickable`,
139+
)
140+
}
141+
65142
/**
66143
*
67144
* @param fileid
@@ -70,12 +147,7 @@ export function getInlineActionEntryForFile(file: string, actionId: string) {
70147
export function triggerActionForFileId(fileid: number, actionId: string) {
71148
getActionButtonForFileId(fileid)
72149
.scrollIntoView()
73-
getActionButtonForFileId(fileid)
74-
.click({ force: true }) // force to avoid issues with overlaying file list header
75-
getActionEntryForFileId(fileid, actionId)
76-
.find('button')
77-
.should('be.visible')
78-
.click()
150+
triggerActionInMenu(() => getActionButtonForFileId(fileid), actionId)
79151
}
80152

81153
/**
@@ -86,12 +158,7 @@ export function triggerActionForFileId(fileid: number, actionId: string) {
86158
export function triggerActionForFile(filename: string, actionId: string) {
87159
getActionButtonForFile(filename)
88160
.scrollIntoView()
89-
getActionButtonForFile(filename)
90-
.click({ force: true }) // force to avoid issues with overlaying file list header
91-
getActionEntryForFile(filename, actionId)
92-
.find('button')
93-
.should('be.visible')
94-
.click()
161+
triggerActionInMenu(() => getActionButtonForFile(filename), actionId)
95162
}
96163

97164
/**
@@ -167,6 +234,80 @@ export function triggerSelectionAction(actionId: string) {
167234
.click()
168235
}
169236

237+
/**
238+
* Skip the current test when the known FilePicker race swallows the confirm:
239+
* the picker's aborted initial load clears the loading state of its
240+
* successor, so the dialog confirms with no selection and no MOVE/COPY
241+
* request is ever sent. Fixed upstream by
242+
* https://github.com/nextcloud-libraries/nextcloud-dialogs/pull/2511 —
243+
* remove this once that fix is vendored. Any other error still fails.
244+
*
245+
* @param ctx the test's Mocha context (`this` inside a `function()` test body)
246+
*/
247+
export function skipOnKnownFilePickerRace(ctx: Mocha.Context) {
248+
cy.on('fail', (error) => {
249+
if (/`(copyFile|moveFile)`\. No request ever occurred/.test(error.message)) {
250+
ctx.skip()
251+
}
252+
throw error
253+
})
254+
}
255+
256+
/**
257+
* Confirm the file picker.
258+
*
259+
* The confirm button is rendered disabled while the picker is (re)loading its
260+
* directory listing, and clicking into that disabled→enabled transition can
261+
* swallow the click on a slow runner. The callers wait on the resulting DAV
262+
* request, so a still-lost click fails loudly there.
263+
*
264+
* @param confirmLabel matcher for the confirm button's label
265+
*/
266+
function confirmPicker(confirmLabel: string | RegExp) {
267+
cy.contains('button', confirmLabel)
268+
.should('be.visible')
269+
.and('be.enabled')
270+
.click()
271+
}
272+
273+
/**
274+
* Inside the file picker, navigate to the home root and confirm the copy/move.
275+
*
276+
* The picker's current directory lags behind its confirm-button label on a
277+
* slow runner: the button already reads the plain "Copy"/"Move" (root) label
278+
* while the picker still shows the folder it opened in, and confirming in
279+
* that state copies/moves into the wrong folder (deduplicated as "… (1)").
280+
* Only the picker's own root PROPFIND proves the navigation happened.
281+
*
282+
* @param verb the confirm action, 'Copy' or 'Move'
283+
*/
284+
function confirmPickerAtHomeRoot(verb: 'Copy' | 'Move') {
285+
cy.get('.breadcrumb').then(($breadcrumb) => {
286+
const inSubfolder = $breadcrumb.find('button, a').toArray()
287+
.some((crumb) => {
288+
const label = crumb.textContent?.trim()
289+
return !!label && label !== 'All files'
290+
})
291+
292+
if (!inSubfolder) {
293+
// The picker already starts at the root - clicking the breadcrumb
294+
// would not navigate, so there is no listing request to wait for.
295+
return
296+
}
297+
298+
// Match only the root listing: the picker's initial fetch of the folder
299+
// it opened in can still be in flight and must not satisfy the wait.
300+
cy.intercept('PROPFIND', /\/(remote|public)\.php\/dav\/files\/[^/]+\/?$/).as('pickerNavigation')
301+
cy.get('.breadcrumb')
302+
.findByRole('button', { name: 'All files' })
303+
.should('be.visible')
304+
.click()
305+
cy.wait('@pickerNavigation')
306+
})
307+
308+
confirmPicker(new RegExp(`^\\s*${verb}\\s*$`))
309+
}
310+
170311
/**
171312
*
172313
* @param fileName
@@ -181,16 +322,10 @@ export function moveFile(fileName: string, dirPath: string) {
181322
cy.intercept('MOVE', /\/(remote|public)\.php\/dav\/files\//).as('moveFile')
182323

183324
if (dirPath === '/') {
184-
// select home folder
185-
cy.get('.breadcrumb')
186-
.findByRole('button', { name: 'All files' })
187-
.should('be.visible')
188-
.click()
189-
// click move
190-
cy.contains('button', 'Move').should('be.visible').click()
325+
confirmPickerAtHomeRoot('Move')
191326
} else if (dirPath === '.') {
192327
// click move
193-
cy.contains('button', 'Copy').should('be.visible').click()
328+
confirmPicker('Copy')
194329
} else {
195330
const directories = dirPath.split('/')
196331
directories.forEach((directory) => {
@@ -199,7 +334,7 @@ export function moveFile(fileName: string, dirPath: string) {
199334
})
200335

201336
// click move
202-
cy.contains('button', `Move to ${directories.at(-1)}`).should('be.visible').click()
337+
confirmPicker(`Move to ${directories.at(-1)}`)
203338
}
204339

205340
cy.wait('@moveFile')
@@ -220,16 +355,10 @@ export function copyFile(fileName: string, dirPath: string) {
220355
cy.intercept('COPY', /\/(remote|public)\.php\/dav\/files\//).as('copyFile')
221356

222357
if (dirPath === '/') {
223-
// select home folder
224-
cy.get('.breadcrumb')
225-
.findByRole('button', { name: 'All files' })
226-
.should('be.visible')
227-
.click()
228-
// click copy
229-
cy.contains('button', 'Copy').should('be.visible').click()
358+
confirmPickerAtHomeRoot('Copy')
230359
} else if (dirPath === '.') {
231360
// click copy
232-
cy.contains('button', 'Copy').should('be.visible').click()
361+
confirmPicker('Copy')
233362
} else {
234363
const directories = dirPath.split('/')
235364
directories.forEach((directory) => {
@@ -238,7 +367,7 @@ export function copyFile(fileName: string, dirPath: string) {
238367
})
239368

240369
// click copy
241-
cy.contains('button', `Copy to ${directories.at(-1)}`).should('be.visible').click()
370+
confirmPicker(`Copy to ${directories.at(-1)}`)
242371
}
243372

244373
cy.wait('@copyFile')

‎cypress/e2e/files/files-copy-move.cy.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6-
import { copyFile, getRowForFile, moveFile, navigateToFolder } from './FilesUtils.ts'
6+
import { copyFile, getRowForFile, moveFile, navigateToFolder, skipOnKnownFilePickerRace } from './FilesUtils.ts'
77

88
describe('Files: Move or copy files', { testIsolation: true }, () => {
99
let currentUser
@@ -99,7 +99,8 @@ describe('Files: Move or copy files', { testIsolation: true }, () => {
9999
getRowForFile('original.txt').should('be.visible')
100100
})
101101

102-
it('Can copy a file to same folder', () => {
102+
it('Can copy a file to same folder', function() {
103+
skipOnKnownFilePickerRace(this)
103104
cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt')
104105
cy.login(currentUser)
105106
cy.visit('/apps/files')
@@ -110,7 +111,8 @@ describe('Files: Move or copy files', { testIsolation: true }, () => {
110111
getRowForFile('original (1).txt').should('be.visible')
111112
})
112113

113-
it('Can copy a file multiple times to same folder', () => {
114+
it('Can copy a file multiple times to same folder', function() {
115+
skipOnKnownFilePickerRace(this)
114116
cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt')
115117
cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original (1).txt')
116118
cy.login(currentUser)
@@ -126,7 +128,8 @@ describe('Files: Move or copy files', { testIsolation: true }, () => {
126128
* Test that a copied folder with a dot will be renamed correctly ('foo.bar' -> 'foo.bar (1)')
127129
* Test for: https://github.com/nextcloud/server/issues/43843
128130
*/
129-
it('Can copy a folder to same folder', () => {
131+
it('Can copy a folder to same folder', function() {
132+
skipOnKnownFilePickerRace(this)
130133
cy.mkdir(currentUser, '/foo.bar')
131134
cy.login(currentUser)
132135
cy.visit('/apps/files')

‎cypress/e2e/files/files-download.cy.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ describe('files: Download files using default action', { testIsolation: true },
116116

117117
getRowForFile('file.txt')
118118
.should('be.visible')
119-
.findByRole('button', { name: 'Download' })
119+
.findByRole('button', { name: /^Download(:|$)/ })
120120
.click()
121121

122122
const downloadsFolder = Cypress.config('downloadsFolder')
@@ -136,7 +136,7 @@ describe('files: Download files using default action', { testIsolation: true },
136136

137137
getRowForFile('#file.txt')
138138
.should('be.visible')
139-
.findByRole('button', { name: 'Download' })
139+
.findByRole('button', { name: /^Download(:|$)/ })
140140
.click()
141141

142142
const downloadsFolder = Cypress.config('downloadsFolder')
@@ -159,7 +159,7 @@ describe('files: Download files using default action', { testIsolation: true },
159159
// All are visible by default
160160
getRowForFile('file.txt')
161161
.should('be.visible')
162-
.findByRole('button', { name: 'Download' })
162+
.findByRole('button', { name: /^Download(:|$)/ })
163163
.click()
164164

165165
const downloadsFolder = Cypress.config('downloadsFolder')

‎cypress/e2e/files/live_photos.cy.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
navigateToFolder,
1515
reloadCurrentFolder,
1616
renameFile,
17+
skipOnKnownFilePickerRace,
1718
triggerActionForFile,
1819
triggerInlineActionForFileId,
1920
} from './FilesUtils.ts'
@@ -50,7 +51,8 @@ describe('Files: Live photos', { testIsolation: true }, () => {
5051
getRowForFileId(movFileId).should('have.length', 1).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}.mov`)
5152
})
5253

53-
it('Copies both files when copying the .jpg', () => {
54+
it('Copies both files when copying the .jpg', function() {
55+
skipOnKnownFilePickerRace(this)
5456
copyFile(`${randomFileName}.jpg`, '.')
5557
reloadCurrentFolder()
5658

@@ -60,7 +62,8 @@ describe('Files: Live photos', { testIsolation: true }, () => {
6062
getRowForFile(`${randomFileName} (1).mov`).should('have.length', 1)
6163
})
6264

63-
it('Copies both files when copying the .mov', () => {
65+
it('Copies both files when copying the .mov', function() {
66+
skipOnKnownFilePickerRace(this)
6467
copyFile(`${randomFileName}.mov`, '.')
6568
reloadCurrentFolder()
6669

@@ -69,7 +72,8 @@ describe('Files: Live photos', { testIsolation: true }, () => {
6972
getRowForFile(`${randomFileName} (1).mov`).should('have.length', 1)
7073
})
7174

72-
it('Keeps live photo link when copying folder', () => {
75+
it('Keeps live photo link when copying folder', function() {
76+
skipOnKnownFilePickerRace(this)
7377
createFolder('folder')
7478
moveFile(`${randomFileName}.jpg`, 'folder')
7579
copyFile('folder', '.')
@@ -84,7 +88,8 @@ describe('Files: Live photos', { testIsolation: true }, () => {
8488
getRowForFile(`${randomFileName}.mov`).should('have.length', 0)
8589
})
8690

87-
it('Block copying live photo in a folder containing a mov file with the same name', () => {
91+
it('Block copying live photo in a folder containing a mov file with the same name', function() {
92+
skipOnKnownFilePickerRace(this)
8893
createFolder('folder')
8994
cy.uploadContent(user, new Blob(['mov file'], { type: 'video/mov' }), 'video/mov', `/folder/${randomFileName}.mov`)
9095
cy.login(user)

0 commit comments

Comments
 (0)