@@ -62,6 +62,79 @@ 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+ * The action button is queried and natively clicked within one synchronous
120+ * step: continuing a command chain into the popover instead would detach the
121+ * chain's subject whenever a re-render hits between two commands.
122+ *
123+ * @param getActionButton query for the actions menu toggle of the row
124+ * @param actionId id of the action to click
125+ */
126+ function triggerActionInMenu < T extends HTMLElement > ( getActionButton : ( ) => Cypress . Chainable < JQuery < T > > , actionId : string ) {
127+ pollActionsMenu (
128+ getActionButton ,
129+ ( $menu ) => {
130+ const button = $menu . find ( `[data-cy-files-list-row-action="${ CSS . escape ( actionId ) } "] button:visible` ) . get ( 0 )
131+ button ?. click ( )
132+ return button !== undefined
133+ } ,
134+ `Action "${ actionId } " did not become clickable` ,
135+ )
136+ }
137+
65138/**
66139 *
67140 * @param fileid
@@ -70,12 +143,7 @@ export function getInlineActionEntryForFile(file: string, actionId: string) {
70143export function triggerActionForFileId ( fileid : number , actionId : string ) {
71144 getActionButtonForFileId ( fileid )
72145 . 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 ( )
146+ triggerActionInMenu ( ( ) => getActionButtonForFileId ( fileid ) , actionId )
79147}
80148
81149/**
@@ -86,12 +154,7 @@ export function triggerActionForFileId(fileid: number, actionId: string) {
86154export function triggerActionForFile ( filename : string , actionId : string ) {
87155 getActionButtonForFile ( filename )
88156 . 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 ( )
157+ triggerActionInMenu ( ( ) => getActionButtonForFile ( filename ) , actionId )
95158}
96159
97160/**
@@ -167,6 +230,66 @@ export function triggerSelectionAction(actionId: string) {
167230 . click ( )
168231}
169232
233+ /**
234+ * Skip the current test when the known FilePicker race swallows the confirm:
235+ * the picker's aborted initial load clears the loading state of its
236+ * successor, so the dialog confirms with no selection and no MOVE/COPY
237+ * request is ever sent. Fixed upstream by
238+ * https://github.com/nextcloud-libraries/nextcloud-dialogs/pull/2511 —
239+ * remove this once that fix is vendored. Any other error still fails.
240+ *
241+ * @param ctx the test's Mocha context (`this` inside a `function()` test body)
242+ */
243+ export function skipOnKnownFilePickerRace ( ctx : Mocha . Context ) {
244+ cy . on ( 'fail' , ( error ) => {
245+ if ( / ` ( c o p y F i l e | m o v e F i l e ) ` \. N o r e q u e s t e v e r o c c u r r e d / . test ( error . message ) ) {
246+ ctx . skip ( )
247+ }
248+ throw error
249+ } )
250+ }
251+
252+ /**
253+ * Confirm the file picker.
254+ *
255+ * The confirm button is rendered disabled while the picker is (re)loading its
256+ * directory listing, and clicking into that disabled→enabled transition can
257+ * swallow the click on a slow runner. The callers wait on the resulting DAV
258+ * request, so a still-lost click fails loudly there.
259+ *
260+ * @param confirmLabel matcher for the confirm button's label
261+ */
262+ function confirmPicker ( confirmLabel : string | RegExp ) {
263+ cy . contains ( 'button' , confirmLabel )
264+ . should ( 'be.visible' )
265+ . and ( 'be.enabled' )
266+ . click ( )
267+ }
268+
269+ /**
270+ * Inside the file picker, navigate to the home root and confirm the copy/move.
271+ *
272+ * The picker's current directory lags behind its confirm-button label on a
273+ * slow runner: the button already reads the plain "Copy"/"Move" (root) label
274+ * while the picker still shows the folder it opened in, and confirming in
275+ * that state copies/moves into the wrong folder (deduplicated as "… (1)").
276+ * Only the picker's own root PROPFIND proves the navigation happened.
277+ *
278+ * @param verb the confirm action, 'Copy' or 'Move'
279+ */
280+ function confirmPickerAtHomeRoot ( verb : 'Copy' | 'Move' ) {
281+ // Match only the root listing: the picker's initial fetch of the folder it
282+ // opened in can still be in flight and must not satisfy the wait below.
283+ cy . intercept ( 'PROPFIND' , / \/ ( r e m o t e | p u b l i c ) \. p h p \/ d a v \/ f i l e s \/ [ ^ / ] + \/ ? $ / ) . as ( 'pickerNavigation' )
284+ cy . get ( '.breadcrumb' )
285+ . findByRole ( 'button' , { name : 'All files' } )
286+ . should ( 'be.visible' )
287+ . click ( )
288+ cy . wait ( '@pickerNavigation' )
289+
290+ confirmPicker ( new RegExp ( `^\\s*${ verb } \\s*$` ) )
291+ }
292+
170293/**
171294 *
172295 * @param fileName
@@ -181,16 +304,10 @@ export function moveFile(fileName: string, dirPath: string) {
181304 cy . intercept ( 'MOVE' , / \/ ( r e m o t e | p u b l i c ) \. p h p \/ d a v \/ f i l e s \/ / ) . as ( 'moveFile' )
182305
183306 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 ( )
307+ confirmPickerAtHomeRoot ( 'Move' )
191308 } else if ( dirPath === '.' ) {
192309 // click move
193- cy . contains ( 'button' , ' Copy') . should ( 'be.visible' ) . click ( )
310+ confirmPicker ( ' Copy')
194311 } else {
195312 const directories = dirPath . split ( '/' )
196313 directories . forEach ( ( directory ) => {
@@ -199,7 +316,7 @@ export function moveFile(fileName: string, dirPath: string) {
199316 } )
200317
201318 // click move
202- cy . contains ( 'button' , `Move to ${ directories . at ( - 1 ) } ` ) . should ( 'be.visible' ) . click ( )
319+ confirmPicker ( `Move to ${ directories . at ( - 1 ) } ` )
203320 }
204321
205322 cy . wait ( '@moveFile' )
@@ -220,16 +337,10 @@ export function copyFile(fileName: string, dirPath: string) {
220337 cy . intercept ( 'COPY' , / \/ ( r e m o t e | p u b l i c ) \. p h p \/ d a v \/ f i l e s \/ / ) . as ( 'copyFile' )
221338
222339 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 ( )
340+ confirmPickerAtHomeRoot ( 'Copy' )
230341 } else if ( dirPath === '.' ) {
231342 // click copy
232- cy . contains ( 'button' , ' Copy') . should ( 'be.visible' ) . click ( )
343+ confirmPicker ( ' Copy')
233344 } else {
234345 const directories = dirPath . split ( '/' )
235346 directories . forEach ( ( directory ) => {
@@ -238,7 +349,7 @@ export function copyFile(fileName: string, dirPath: string) {
238349 } )
239350
240351 // click copy
241- cy . contains ( 'button' , `Copy to ${ directories . at ( - 1 ) } ` ) . should ( 'be.visible' ) . click ( )
352+ confirmPicker ( `Copy to ${ directories . at ( - 1 ) } ` )
242353 }
243354
244355 cy . wait ( '@copyFile' )
0 commit comments