mirror of
https://github.com/nextcloud/server.git
synced 2026-06-11 09:42:09 -04:00
test(files): migrate more tests from Cypress to Playwright
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
parent
9fc19ac7f5
commit
a21c2fddd6
13 changed files with 766 additions and 551 deletions
|
|
@ -1,70 +0,0 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { User } from '@nextcloud/e2e-test-server/cypress'
|
||||
|
||||
import { getRowForFile, navigateToFolder, selectAllFiles, triggerActionForFile, triggerSelectionAction } from './FilesUtils.ts'
|
||||
|
||||
describe('files: Delete files using file actions', { testIsolation: true }, () => {
|
||||
let user: User
|
||||
|
||||
beforeEach(() => {
|
||||
cy.createRandomUser().then(($user) => {
|
||||
user = $user
|
||||
})
|
||||
})
|
||||
|
||||
it('can delete file', () => {
|
||||
cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt')
|
||||
cy.login(user)
|
||||
cy.visit('/apps/files')
|
||||
|
||||
// The file must exist and the preview loaded as it locks the file
|
||||
getRowForFile('file.txt')
|
||||
.should('be.visible')
|
||||
.find('.files-list__row-icon-preview--loaded')
|
||||
.should('exist')
|
||||
|
||||
cy.intercept('DELETE', '**/remote.php/dav/files/**').as('deleteFile')
|
||||
|
||||
triggerActionForFile('file.txt', 'delete')
|
||||
cy.wait('@deleteFile').its('response.statusCode').should('eq', 204)
|
||||
})
|
||||
|
||||
it('can delete multiple files', () => {
|
||||
cy.mkdir(user, '/root')
|
||||
for (let i = 0; i < 5; i++) {
|
||||
cy.uploadContent(user, new Blob([]), 'text/plain', `/root/file${i}.txt`)
|
||||
}
|
||||
cy.login(user)
|
||||
cy.visit('/apps/files')
|
||||
navigateToFolder('/root')
|
||||
|
||||
// The file must exist and the preview loaded as it locks the file
|
||||
cy.get('.files-list__row-icon-preview--loaded')
|
||||
.should('have.length', 5)
|
||||
|
||||
cy.intercept('DELETE', '**/remote.php/dav/files/**').as('deleteFile')
|
||||
|
||||
// select all
|
||||
selectAllFiles()
|
||||
triggerSelectionAction('delete')
|
||||
|
||||
// see dialog for confirmation
|
||||
cy.findByRole('dialog', { name: 'Confirm deletion' })
|
||||
.findByRole('button', { name: 'Delete files' })
|
||||
.click()
|
||||
|
||||
cy.wait('@deleteFile')
|
||||
cy.get('@deleteFile.all')
|
||||
.should('have.length', 5)
|
||||
|
||||
.should((all: any) => {
|
||||
for (const call of all) {
|
||||
expect(call.response.statusCode).to.equal(204)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { User } from '@nextcloud/e2e-test-server/cypress'
|
||||
|
||||
import { getRowForFile, navigateToFolder } from './FilesUtils.ts'
|
||||
|
||||
describe('files: Navigate through folders and observe behavior', () => {
|
||||
let user: User
|
||||
|
||||
before(() => {
|
||||
cy.createRandomUser().then(($user) => {
|
||||
user = $user
|
||||
cy.mkdir(user, '/foo')
|
||||
cy.mkdir(user, '/foo/bar')
|
||||
cy.mkdir(user, '/foo/bar/baz')
|
||||
})
|
||||
})
|
||||
|
||||
it('Shows root folder and we can navigate to the last folder', () => {
|
||||
cy.login(user)
|
||||
cy.visit('/apps/files/')
|
||||
|
||||
getRowForFile('foo').should('be.visible')
|
||||
navigateToFolder('/foo/bar/baz')
|
||||
|
||||
// Last folder is empty
|
||||
cy.get('[data-cy-files-list-row-fileid]').should('not.exist')
|
||||
})
|
||||
|
||||
it('Highlight the previous folder when navigating back', () => {
|
||||
cy.go('back')
|
||||
getRowForFile('baz').should('be.visible')
|
||||
.invoke('attr', 'class').should('contain', 'active')
|
||||
|
||||
cy.go('back')
|
||||
getRowForFile('bar').should('be.visible')
|
||||
.invoke('attr', 'class').should('contain', 'active')
|
||||
|
||||
cy.go('back')
|
||||
getRowForFile('foo').should('be.visible')
|
||||
.invoke('attr', 'class').should('contain', 'active')
|
||||
})
|
||||
|
||||
it('Can navigate forward again', () => {
|
||||
cy.go('forward')
|
||||
getRowForFile('bar').should('be.visible')
|
||||
.invoke('attr', 'class').should('contain', 'active')
|
||||
|
||||
cy.go('forward')
|
||||
getRowForFile('baz').should('be.visible')
|
||||
.invoke('attr', 'class').should('contain', 'active')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,288 +0,0 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { User } from '@nextcloud/e2e-test-server/cypress'
|
||||
|
||||
import { calculateViewportHeight, createFolder, getRowForFile, haveValidity, renameFile, triggerActionForFile } from './FilesUtils.ts'
|
||||
|
||||
describe('files: Rename nodes', { testIsolation: true }, () => {
|
||||
let user: User
|
||||
|
||||
beforeEach(() => {
|
||||
cy.createRandomUser().then(($user) => {
|
||||
user = $user
|
||||
|
||||
// remove welcome file
|
||||
cy.rm(user, '/welcome.txt')
|
||||
// create a file called "file.txt"
|
||||
cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt')
|
||||
|
||||
// login and visit files app
|
||||
cy.login(user)
|
||||
})
|
||||
cy.visit('/apps/files')
|
||||
})
|
||||
|
||||
it('can rename a file', () => {
|
||||
// All are visible by default
|
||||
getRowForFile('file.txt').should('be.visible')
|
||||
|
||||
triggerActionForFile('file.txt', 'rename')
|
||||
|
||||
getRowForFile('file.txt')
|
||||
.findByRole('textbox', { name: 'Filename' })
|
||||
.should('be.visible')
|
||||
.type('{selectAll}other.txt')
|
||||
.should(haveValidity(''))
|
||||
.type('{enter}')
|
||||
|
||||
// See it is renamed
|
||||
getRowForFile('other.txt').should('be.visible')
|
||||
})
|
||||
|
||||
/**
|
||||
* If this test gets flaky than we have a problem:
|
||||
* It means that the selection is not reliable set to the basename
|
||||
*/
|
||||
it('only selects basename of file', () => {
|
||||
// All are visible by default
|
||||
getRowForFile('file.txt').should('be.visible')
|
||||
|
||||
triggerActionForFile('file.txt', 'rename')
|
||||
|
||||
getRowForFile('file.txt')
|
||||
.findByRole('textbox', { name: 'Filename' })
|
||||
.should('be.visible')
|
||||
.should((el) => {
|
||||
const input = el.get(0) as HTMLInputElement
|
||||
expect(input.selectionStart).to.equal(0)
|
||||
expect(input.selectionEnd).to.equal('file'.length)
|
||||
})
|
||||
})
|
||||
|
||||
it('show validation error on file rename', () => {
|
||||
// All are visible by default
|
||||
getRowForFile('file.txt').should('be.visible')
|
||||
|
||||
triggerActionForFile('file.txt', 'rename')
|
||||
|
||||
getRowForFile('file.txt')
|
||||
.findByRole('textbox', { name: 'Filename' })
|
||||
.should('be.visible')
|
||||
.type('{selectAll}.htaccess')
|
||||
// See validity
|
||||
.should(haveValidity(/reserved name/i))
|
||||
})
|
||||
|
||||
it('shows accessible loading information', () => {
|
||||
const { resolve, promise } = Promise.withResolvers<void>()
|
||||
|
||||
getRowForFile('file.txt').should('be.visible')
|
||||
|
||||
// intercept the rename (MOVE)
|
||||
// the callback will wait until the promise resolve (so we have time to check the loading state)
|
||||
cy.intercept(
|
||||
'MOVE',
|
||||
/\/remote.php\/dav\/files\//,
|
||||
(request) => {
|
||||
// we need to wait in the onResponse handler as the intercept handler times out otherwise
|
||||
request.on('response', async () => {
|
||||
await promise
|
||||
})
|
||||
},
|
||||
).as('moveFile')
|
||||
|
||||
// Start the renaming
|
||||
triggerActionForFile('file.txt', 'rename')
|
||||
getRowForFile('file.txt')
|
||||
.findByRole('textbox', { name: 'Filename' })
|
||||
.should('be.visible')
|
||||
.type('{selectAll}new-name.txt{enter}')
|
||||
|
||||
// Loading state is visible
|
||||
getRowForFile('new-name.txt')
|
||||
.findByRole('img', { name: 'File is loading' })
|
||||
.should('be.visible')
|
||||
// checkbox is not visible
|
||||
getRowForFile('new-name.txt')
|
||||
.findByRole('checkbox', { name: /^Toggle selection/ })
|
||||
.should('not.exist')
|
||||
|
||||
cy.log('Resolve promise to preoceed with MOVE request')
|
||||
.then(() => resolve())
|
||||
|
||||
// Ensure the request is done (file renamed)
|
||||
cy.wait('@moveFile')
|
||||
|
||||
// checkbox visible again
|
||||
getRowForFile('new-name.txt')
|
||||
.findByRole('checkbox', { name: /^Toggle selection/ })
|
||||
.should('exist')
|
||||
// see the loading state is gone
|
||||
getRowForFile('new-name.txt')
|
||||
.findByRole('img', { name: 'File is loading' })
|
||||
.should('not.exist')
|
||||
})
|
||||
|
||||
it('cancel renaming on esc press', () => {
|
||||
// All are visible by default
|
||||
getRowForFile('file.txt').should('be.visible')
|
||||
|
||||
triggerActionForFile('file.txt', 'rename')
|
||||
|
||||
getRowForFile('file.txt')
|
||||
.findByRole('textbox', { name: 'Filename' })
|
||||
.should('be.visible')
|
||||
.type('{selectAll}other.txt')
|
||||
.should(haveValidity(''))
|
||||
.type('{esc}')
|
||||
|
||||
// See it is not renamed
|
||||
getRowForFile('other.txt').should('not.exist')
|
||||
getRowForFile('file.txt')
|
||||
.should('be.visible')
|
||||
.find('input[type="text"]')
|
||||
.should('not.exist')
|
||||
})
|
||||
|
||||
it('cancel on enter if no new name is entered', () => {
|
||||
// All are visible by default
|
||||
getRowForFile('file.txt').should('be.visible')
|
||||
|
||||
triggerActionForFile('file.txt', 'rename')
|
||||
|
||||
getRowForFile('file.txt')
|
||||
.findByRole('textbox', { name: 'Filename' })
|
||||
.should('be.visible')
|
||||
.type('{enter}')
|
||||
|
||||
// See it is not renamed
|
||||
getRowForFile('file.txt')
|
||||
.should('be.visible')
|
||||
.find('input[type="text"]')
|
||||
.should('not.exist')
|
||||
})
|
||||
|
||||
/**
|
||||
* This is a regression test of: https://github.com/nextcloud/server/issues/47438
|
||||
* The issue was that the renaming state was not reset when the new name moved the file out of the view of the current files list
|
||||
* due to virtual scrolling the renaming state was not changed then by the UI events (as the component was taken out of DOM before any event handling).
|
||||
*/
|
||||
it('correctly resets renaming state', () => {
|
||||
// Create 19 additional files
|
||||
for (let i = 1; i <= 19; i++) {
|
||||
cy.uploadContent(user, new Blob([]), 'text/plain', `/file${i}.txt`)
|
||||
}
|
||||
|
||||
// Calculate and setup a viewport where only the first 4 files are visible, causing 6 rows to be rendered
|
||||
cy.viewport(768, 500)
|
||||
cy.login(user)
|
||||
calculateViewportHeight(4)
|
||||
.then((height) => cy.viewport(768, height))
|
||||
|
||||
cy.visit('/apps/files')
|
||||
|
||||
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')
|
||||
// scroll file list to bottom
|
||||
cy.get('[data-cy-files-list]')
|
||||
.scrollTo('bottom')
|
||||
cy.screenshot()
|
||||
// The file is no longer in rename state
|
||||
getRowForFile('zzz.txt')
|
||||
.should('be.visible')
|
||||
.findByRole('textbox', { name: 'Filename' })
|
||||
.should('not.exist')
|
||||
})
|
||||
|
||||
it('shows warning on extension change - select new extension', () => {
|
||||
getRowForFile('file.txt').should('be.visible')
|
||||
|
||||
triggerActionForFile('file.txt', 'rename')
|
||||
getRowForFile('file.txt')
|
||||
.findByRole('textbox', { name: 'Filename' })
|
||||
.should('be.visible')
|
||||
.type('{selectAll}file.md')
|
||||
.type('{enter}')
|
||||
|
||||
// See warning dialog
|
||||
cy.findByRole('dialog', { name: 'Change file extension' })
|
||||
.should('be.visible')
|
||||
.findByRole('button', { name: 'Use .md' })
|
||||
.click()
|
||||
|
||||
// See it is renamed
|
||||
getRowForFile('file.md').should('be.visible')
|
||||
})
|
||||
|
||||
it('shows warning on extension change - select old extension', () => {
|
||||
getRowForFile('file.txt').should('be.visible')
|
||||
|
||||
triggerActionForFile('file.txt', 'rename')
|
||||
getRowForFile('file.txt')
|
||||
.findByRole('textbox', { name: 'Filename' })
|
||||
.should('be.visible')
|
||||
.type('{selectAll}document.md')
|
||||
.type('{enter}')
|
||||
|
||||
// See warning dialog
|
||||
cy.findByRole('dialog', { name: 'Change file extension' })
|
||||
.should('be.visible')
|
||||
.findByRole('button', { name: 'Keep .txt' })
|
||||
.click()
|
||||
|
||||
// See it is renamed
|
||||
getRowForFile('document.txt').should('be.visible')
|
||||
})
|
||||
|
||||
it('shows warning on extension removal', () => {
|
||||
getRowForFile('file.txt').should('be.visible')
|
||||
|
||||
triggerActionForFile('file.txt', 'rename')
|
||||
getRowForFile('file.txt')
|
||||
.findByRole('textbox', { name: 'Filename' })
|
||||
.should('be.visible')
|
||||
.type('{selectAll}file')
|
||||
.type('{enter}')
|
||||
|
||||
cy.findByRole('dialog', { name: 'Change file extension' })
|
||||
.should('be.visible')
|
||||
.findByRole('button', { name: 'Keep .txt' })
|
||||
.should('be.visible')
|
||||
cy.findByRole('dialog', { name: 'Change file extension' })
|
||||
.findByRole('button', { name: 'Remove extension' })
|
||||
.should('be.visible')
|
||||
.click()
|
||||
|
||||
// See it is renamed
|
||||
getRowForFile('file').should('be.visible')
|
||||
getRowForFile('file.txt').should('not.exist')
|
||||
})
|
||||
|
||||
it('does not show warning on folder renaming with a dot', () => {
|
||||
createFolder('folder.2024')
|
||||
|
||||
getRowForFile('folder.2024').should('be.visible')
|
||||
|
||||
triggerActionForFile('folder.2024', 'rename')
|
||||
getRowForFile('folder.2024')
|
||||
.findByRole('textbox', { name: 'Folder name' })
|
||||
.should('be.visible')
|
||||
.type('{selectAll}folder.2025')
|
||||
.should(haveValidity(''))
|
||||
.type('{enter}')
|
||||
|
||||
// See warning dialog
|
||||
cy.get('[role=dialog]').should('not.exist')
|
||||
|
||||
// See it is not renamed
|
||||
getRowForFile('folder.2025').should('be.visible')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { User } from '@nextcloud/e2e-test-server/cypress'
|
||||
|
||||
import { assertNotExistOrNotVisible } from '../settings/usersUtils.ts'
|
||||
import { getRowForFile, navigateToFolder, triggerActionForFile } from './FilesUtils.ts'
|
||||
|
||||
describe('Files: Sidebar', { testIsolation: true }, () => {
|
||||
let user: User
|
||||
let fileId: number = 0
|
||||
|
||||
beforeEach(() => cy.createRandomUser().then(($user) => {
|
||||
user = $user
|
||||
|
||||
cy.mkdir(user, '/folder')
|
||||
cy.uploadContent(user, new Blob([]), 'text/plain', '/file').then((response) => {
|
||||
fileId = Number.parseInt(response.headers['oc-fileid'] ?? '0')
|
||||
})
|
||||
cy.login(user)
|
||||
}))
|
||||
|
||||
it('opens the sidebar', () => {
|
||||
cy.visit('/apps/files')
|
||||
getRowForFile('file').should('be.visible')
|
||||
|
||||
triggerActionForFile('file', 'details')
|
||||
|
||||
cy.get('[data-cy-sidebar]')
|
||||
.should('be.visible')
|
||||
.findByRole('heading', { name: 'file' })
|
||||
.should('be.visible')
|
||||
})
|
||||
|
||||
it('changes the current fileid', () => {
|
||||
cy.visit('/apps/files')
|
||||
getRowForFile('file').should('be.visible')
|
||||
|
||||
triggerActionForFile('file', 'details')
|
||||
|
||||
cy.get('[data-cy-sidebar]').should('be.visible')
|
||||
cy.url().should('contain', `apps/files/files/${fileId}`)
|
||||
})
|
||||
|
||||
it('changes the sidebar content on other file', () => {
|
||||
cy.visit('/apps/files')
|
||||
getRowForFile('file').should('be.visible')
|
||||
|
||||
triggerActionForFile('file', 'details')
|
||||
|
||||
cy.get('[data-cy-sidebar]')
|
||||
.should('be.visible')
|
||||
.findByRole('heading', { name: 'file' })
|
||||
.should('be.visible')
|
||||
|
||||
// eslint-disable-next-line cypress/no-unnecessary-waiting
|
||||
cy.wait(600) // wait for a bit to avoid flakiness
|
||||
|
||||
triggerActionForFile('folder', 'details')
|
||||
cy.get('[data-cy-sidebar]')
|
||||
.should('be.visible')
|
||||
.findByRole('heading', { name: 'folder' })
|
||||
.should('be.visible')
|
||||
})
|
||||
|
||||
it('closes the sidebar on navigation', () => {
|
||||
cy.visit('/apps/files')
|
||||
|
||||
getRowForFile('file').should('be.visible')
|
||||
getRowForFile('folder').should('be.visible')
|
||||
|
||||
// open the sidebar
|
||||
triggerActionForFile('file', 'details')
|
||||
// validate it is open
|
||||
cy.get('[data-cy-sidebar]')
|
||||
.should('be.visible')
|
||||
|
||||
// if we navigate to the folder
|
||||
navigateToFolder('folder')
|
||||
// the sidebar should not be visible anymore
|
||||
cy.get('[data-cy-sidebar]')
|
||||
.should(assertNotExistOrNotVisible)
|
||||
})
|
||||
|
||||
it('closes the sidebar on delete', () => {
|
||||
cy.intercept('DELETE', `**/remote.php/dav/files/${user.userId}/file`).as('deleteFile')
|
||||
// visit the files app
|
||||
cy.visit('/apps/files')
|
||||
getRowForFile('file').should('be.visible')
|
||||
// open the sidebar
|
||||
triggerActionForFile('file', 'details')
|
||||
// validate it is open
|
||||
cy.get('[data-cy-sidebar]')
|
||||
.should('be.visible')
|
||||
// eslint-disable-next-line cypress/no-unnecessary-waiting
|
||||
cy.wait(600) // wait for a bit to avoid flakiness
|
||||
|
||||
// delete the file
|
||||
triggerActionForFile('file', 'delete')
|
||||
cy.wait('@deleteFile', { timeout: 10000 })
|
||||
// see the sidebar is closed
|
||||
cy.get('[data-cy-sidebar]')
|
||||
.should(assertNotExistOrNotVisible)
|
||||
})
|
||||
|
||||
it('changes the fileid on delete', () => {
|
||||
cy.intercept('DELETE', `**/remote.php/dav/files/${user.userId}/folder/other`).as('deleteFile')
|
||||
|
||||
cy.uploadContent(user, new Blob([]), 'text/plain', '/folder/other').then((response) => {
|
||||
const otherFileId = Number.parseInt(response.headers['oc-fileid'] ?? '0')
|
||||
cy.login(user)
|
||||
cy.visit('/apps/files')
|
||||
|
||||
getRowForFile('folder').should('be.visible')
|
||||
navigateToFolder('folder')
|
||||
getRowForFile('other').should('be.visible')
|
||||
|
||||
// open the sidebar
|
||||
triggerActionForFile('other', 'details')
|
||||
// validate it is open
|
||||
cy.get('[data-cy-sidebar]').should('be.visible')
|
||||
cy.url().should('contain', `apps/files/files/${otherFileId}`)
|
||||
|
||||
// eslint-disable-next-line cypress/no-unnecessary-waiting
|
||||
cy.wait(600) // wait for a bit to avoid flakiness
|
||||
|
||||
triggerActionForFile('other', 'delete')
|
||||
cy.wait('@deleteFile')
|
||||
|
||||
cy.get('[data-cy-sidebar]').should('not.be.visible')
|
||||
// Ensure the URL is changed
|
||||
cy.url().should('not.contain', `apps/files/files/${otherFileId}`)
|
||||
})
|
||||
})
|
||||
})
|
||||
60
tests/playwright/e2e/files/files-delete.spec.ts
Normal file
60
tests/playwright/e2e/files/files-delete.spec.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { test, expect } from '../../support/fixtures/files-page.ts'
|
||||
import { mkdir, uploadContent } from '../../support/utils/dav.ts'
|
||||
|
||||
test.describe('Files: Delete', () => {
|
||||
test('can delete a file', async ({ page, user, filesListPage }) => {
|
||||
await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file.txt')
|
||||
await filesListPage.open()
|
||||
|
||||
const row = filesListPage.getRowForFile('file.txt')
|
||||
await expect(row).toBeVisible()
|
||||
// Preview must finish loading before delete — a loading preview can lock the file
|
||||
await expect(row.locator('.files-list__row-icon-preview--loaded')).toBeVisible()
|
||||
|
||||
const deleteResponse = page.waitForResponse(
|
||||
(r) => r.url().includes('/remote.php/dav/files/') && r.request().method() === 'DELETE',
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
await filesListPage.triggerActionForFile('file.txt', 'delete')
|
||||
expect((await deleteResponse).status()).toBe(204)
|
||||
})
|
||||
|
||||
test('can delete multiple files', async ({ page, user, filesListPage }) => {
|
||||
await mkdir(page.request, user, '/root')
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', `/root/file${i}.txt`)
|
||||
}
|
||||
await filesListPage.open()
|
||||
await filesListPage.navigateToFolder('root')
|
||||
|
||||
// All 5 preview thumbnails must finish loading before we delete
|
||||
await expect(page.locator('.files-list__row-icon-preview--loaded')).toHaveCount(5)
|
||||
|
||||
// Set up listeners for all 5 DELETE responses before triggering the action
|
||||
const deleteResponses = Promise.all(
|
||||
Array.from({ length: 5 }, () =>
|
||||
page.waitForResponse(
|
||||
(r) => r.url().includes(`/remote.php/dav/files/${user.userId}/root/`) && r.request().method() === 'DELETE',
|
||||
{ timeout: 15000 },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
await filesListPage.selectAll()
|
||||
await filesListPage.triggerSelectionAction('delete')
|
||||
|
||||
await page.getByRole('dialog', { name: 'Confirm deletion' })
|
||||
.getByRole('button', { name: 'Delete files' })
|
||||
.click()
|
||||
|
||||
const responses = await deleteResponses
|
||||
for (const response of responses) {
|
||||
expect(response.status()).toBe(204)
|
||||
}
|
||||
})
|
||||
})
|
||||
51
tests/playwright/e2e/files/files-navigation.spec.ts
Normal file
51
tests/playwright/e2e/files/files-navigation.spec.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { test, expect } from '../../support/fixtures/files-page.ts'
|
||||
import { mkdir } from '../../support/utils/dav.ts'
|
||||
|
||||
test.describe('Files: Navigation', () => {
|
||||
test.beforeEach(async ({ page, user, filesListPage }) => {
|
||||
await mkdir(page.request, user, '/foo')
|
||||
await mkdir(page.request, user, '/foo/bar')
|
||||
await mkdir(page.request, user, '/foo/bar/baz')
|
||||
await filesListPage.open()
|
||||
})
|
||||
|
||||
test('shows root folder and can navigate to a deeply nested folder', async ({ page, filesListPage }) => {
|
||||
await expect(filesListPage.getRowForFile('foo')).toBeVisible()
|
||||
await filesListPage.navigateToFolder('foo/bar/baz')
|
||||
|
||||
// deepest folder is empty — no file rows rendered
|
||||
await expect(page.locator('[data-cy-files-list-row-fileid]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('highlights the previous folder when navigating back and forward', async ({ page, filesListPage }) => {
|
||||
await filesListPage.navigateToFolder('foo/bar/baz')
|
||||
await expect(page.locator('[data-cy-files-list-row-fileid]')).toHaveCount(0)
|
||||
|
||||
// Navigate back through each level — the folder we came from is highlighted
|
||||
await page.goBack()
|
||||
await expect(filesListPage.getRowForFile('baz')).toBeVisible()
|
||||
await expect(filesListPage.getRowForFile('baz')).toBeActiveRow()
|
||||
|
||||
await page.goBack()
|
||||
await expect(filesListPage.getRowForFile('bar')).toBeVisible()
|
||||
await expect(filesListPage.getRowForFile('bar')).toBeActiveRow()
|
||||
|
||||
await page.goBack()
|
||||
await expect(filesListPage.getRowForFile('foo')).toBeVisible()
|
||||
await expect(filesListPage.getRowForFile('foo')).toBeActiveRow()
|
||||
|
||||
// Navigate forward — the folder we re-entered is highlighted
|
||||
await page.goForward()
|
||||
await expect(filesListPage.getRowForFile('bar')).toBeVisible()
|
||||
await expect(filesListPage.getRowForFile('bar')).toBeActiveRow()
|
||||
|
||||
await page.goForward()
|
||||
await expect(filesListPage.getRowForFile('baz')).toBeVisible()
|
||||
await expect(filesListPage.getRowForFile('baz')).toBeActiveRow()
|
||||
})
|
||||
})
|
||||
242
tests/playwright/e2e/files/files-renaming.spec.ts
Normal file
242
tests/playwright/e2e/files/files-renaming.spec.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { test, expect } from '../../support/fixtures/files-page.ts'
|
||||
import { mkdir, rm, uploadContent } from '../../support/utils/dav.ts'
|
||||
|
||||
test.describe('Files: Rename nodes', () => {
|
||||
test.beforeEach(async ({ page, user, filesListPage }) => {
|
||||
// New users get welcome.txt — remove it so the list contains only our test files
|
||||
await rm(page.request, user, '/welcome.txt')
|
||||
await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file.txt')
|
||||
await filesListPage.open()
|
||||
})
|
||||
|
||||
test('can rename a file', async ({ filesListPage }) => {
|
||||
await expect(filesListPage.getRowForFile('file.txt')).toBeVisible()
|
||||
|
||||
await filesListPage.triggerActionForFile('file.txt', 'rename')
|
||||
|
||||
const input = filesListPage.getRenameInputForFile('file.txt')
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill('other.txt')
|
||||
await expect(input).toHaveValidationMessage('')
|
||||
await input.press('Enter')
|
||||
|
||||
await expect(filesListPage.getRowForFile('other.txt')).toBeVisible()
|
||||
})
|
||||
|
||||
/**
|
||||
* If this test gets flaky then the selection is not reliably set to the basename.
|
||||
* The selection should cover only the name part (without extension) when rename opens.
|
||||
*/
|
||||
test('only selects basename of file on rename open', async ({ filesListPage }) => {
|
||||
await expect(filesListPage.getRowForFile('file.txt')).toBeVisible()
|
||||
|
||||
await filesListPage.triggerActionForFile('file.txt', 'rename')
|
||||
|
||||
const input = filesListPage.getRenameInputForFile('file.txt')
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
const { selectionStart, selectionEnd } = await input.evaluate(
|
||||
(el) => ({ selectionStart: (el as HTMLInputElement).selectionStart, selectionEnd: (el as HTMLInputElement).selectionEnd }),
|
||||
)
|
||||
expect(selectionStart).toBe(0)
|
||||
expect(selectionEnd).toBe('file'.length)
|
||||
})
|
||||
|
||||
test('shows validation error on invalid filename', async ({ filesListPage }) => {
|
||||
await expect(filesListPage.getRowForFile('file.txt')).toBeVisible()
|
||||
|
||||
await filesListPage.triggerActionForFile('file.txt', 'rename')
|
||||
|
||||
const input = filesListPage.getRenameInputForFile('file.txt')
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill('.htaccess')
|
||||
|
||||
await expect(input).toHaveValidationMessage(/reserved name/i)
|
||||
})
|
||||
|
||||
test('shows accessible loading state while rename MOVE is in-flight', async ({ page, filesListPage }) => {
|
||||
await expect(filesListPage.getRowForFile('file.txt')).toBeVisible()
|
||||
|
||||
// Hold MOVE requests until we explicitly release them
|
||||
let resolveMove!: () => void
|
||||
const moveAllowed = new Promise<void>(resolve => { resolveMove = resolve })
|
||||
await page.route(/remote\.php\/dav\/files\//, async (route) => {
|
||||
if (route.request().method() === 'MOVE') {
|
||||
await moveAllowed
|
||||
}
|
||||
await route.continue()
|
||||
})
|
||||
|
||||
await filesListPage.triggerActionForFile('file.txt', 'rename')
|
||||
const input = filesListPage.getRenameInputForFile('file.txt')
|
||||
await input.fill('new-name.txt')
|
||||
await input.press('Enter')
|
||||
|
||||
// While MOVE is blocked: row shows loading icon, checkbox is hidden
|
||||
const loadingRow = filesListPage.getRowForFile('new-name.txt')
|
||||
await expect(loadingRow.getByRole('img', { name: 'File is loading' })).toBeVisible()
|
||||
await expect(loadingRow.getByRole('checkbox', { name: /Toggle selection/ })).not.toBeVisible()
|
||||
|
||||
// Release the MOVE and wait for it to complete
|
||||
const moveResponse = page.waitForResponse(
|
||||
r => r.url().includes('/remote.php/dav/files/') && r.request().method() === 'MOVE',
|
||||
)
|
||||
resolveMove()
|
||||
await moveResponse
|
||||
await page.unroute(/remote\.php\/dav\/files\//)
|
||||
|
||||
// Loading state clears: checkbox reappears, loading icon gone
|
||||
await expect(loadingRow.getByRole('checkbox', { name: /Toggle selection/ })).toBeVisible()
|
||||
await expect(loadingRow.getByRole('img', { name: 'File is loading' })).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('cancel renaming on Escape', async ({ filesListPage }) => {
|
||||
await expect(filesListPage.getRowForFile('file.txt')).toBeVisible()
|
||||
|
||||
await filesListPage.triggerActionForFile('file.txt', 'rename')
|
||||
|
||||
const input = filesListPage.getRenameInputForFile('file.txt')
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill('other.txt')
|
||||
await expect(input).toHaveValidationMessage('')
|
||||
await input.press('Escape')
|
||||
|
||||
// Original name kept, rename input removed
|
||||
await expect(filesListPage.getRowForFile('other.txt')).toHaveCount(0)
|
||||
await expect(filesListPage.getRowForFile('file.txt')).toBeVisible()
|
||||
await expect(filesListPage.getRowForFile('file.txt').locator('input[type="text"]')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('cancel renaming on Enter when name is unchanged', async ({ filesListPage }) => {
|
||||
await expect(filesListPage.getRowForFile('file.txt')).toBeVisible()
|
||||
|
||||
await filesListPage.triggerActionForFile('file.txt', 'rename')
|
||||
|
||||
const input = filesListPage.getRenameInputForFile('file.txt')
|
||||
await expect(input).toBeVisible()
|
||||
await input.press('Enter')
|
||||
|
||||
// No rename happened, input is gone
|
||||
await expect(filesListPage.getRowForFile('file.txt')).toBeVisible()
|
||||
await expect(filesListPage.getRowForFile('file.txt').locator('input[type="text"]')).not.toBeVisible()
|
||||
})
|
||||
|
||||
/**
|
||||
* Regression: https://github.com/nextcloud/server/issues/47438
|
||||
* Virtual scrolling removed the renaming component from DOM before state reset,
|
||||
* leaving the row permanently stuck in rename mode.
|
||||
*/
|
||||
test('correctly resets renaming state after virtual-scroll re-render', async ({ page, user, filesListPage }) => {
|
||||
// Create 19 more files so virtual scrolling kicks in with a small viewport
|
||||
for (let i = 1; i <= 19; i++) {
|
||||
await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', `/file${i}.txt`)
|
||||
}
|
||||
|
||||
// Start with a small viewport so only a few rows fit
|
||||
await page.setViewportSize({ width: 768, height: 500 })
|
||||
await filesListPage.open()
|
||||
|
||||
// Measure the DOM to calculate the exact height that shows only 4 rows
|
||||
const viewportHeight = await page.evaluate(() => {
|
||||
const filesList = document.querySelector('[data-cy-files-list]') as HTMLElement
|
||||
const outerHeight = window.innerHeight - filesList.clientHeight
|
||||
const beforeHeight = (document.querySelector('.files-list__before') as HTMLElement)?.offsetHeight ?? 0
|
||||
const filterHeight = (document.querySelector('.files-list__filters') as HTMLElement)?.offsetHeight ?? 0
|
||||
const theadHeight = (document.querySelector('[data-cy-files-list-thead]') as HTMLElement)?.offsetHeight ?? 0
|
||||
const rowHeight = (document.querySelector('[data-cy-files-list-tbody] tr') as HTMLElement)?.offsetHeight ?? 0
|
||||
return outerHeight + beforeHeight + filterHeight + theadHeight + 4 * rowHeight
|
||||
})
|
||||
await page.setViewportSize({ width: 768, height: viewportHeight })
|
||||
await filesListPage.open()
|
||||
|
||||
await expect(filesListPage.getRowForFile('file.txt')).toBeVisible()
|
||||
|
||||
// Rename to 'zzz.txt' — sorts last, scrolls out of the visible area
|
||||
await filesListPage.triggerActionForFile('file.txt', 'rename')
|
||||
const input = filesListPage.getRenameInputForFile('file.txt')
|
||||
const moveResponse = page.waitForResponse(
|
||||
r => r.url().includes('/remote.php/dav/files/') && r.request().method() === 'MOVE',
|
||||
)
|
||||
await input.fill('zzz.txt')
|
||||
await input.press('Enter')
|
||||
await moveResponse
|
||||
|
||||
// After rename zzz.txt is sorted to the end — no longer in the visible viewport
|
||||
await expect(filesListPage.getRowForFile('zzz.txt')).toHaveCount(0)
|
||||
|
||||
// Scroll to the bottom to bring zzz.txt into view
|
||||
await page.locator('[data-cy-files-list]').evaluate(el => el.scrollTo(0, el.scrollHeight))
|
||||
|
||||
// Row must be visible and NOT in rename state
|
||||
await expect(filesListPage.getRowForFile('zzz.txt')).toBeVisible()
|
||||
await expect(filesListPage.getRenameInputForFile('zzz.txt')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('shows extension-change warning — keep new extension', async ({ page, filesListPage }) => {
|
||||
await expect(filesListPage.getRowForFile('file.txt')).toBeVisible()
|
||||
|
||||
await filesListPage.triggerActionForFile('file.txt', 'rename')
|
||||
const input = filesListPage.getRenameInputForFile('file.txt')
|
||||
await input.fill('file.md')
|
||||
await input.press('Enter')
|
||||
|
||||
await page.getByRole('dialog', { name: 'Change file extension' })
|
||||
.getByRole('button', { name: 'Use .md' })
|
||||
.click()
|
||||
|
||||
await expect(filesListPage.getRowForFile('file.md')).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows extension-change warning — keep old extension', async ({ page, filesListPage }) => {
|
||||
await expect(filesListPage.getRowForFile('file.txt')).toBeVisible()
|
||||
|
||||
await filesListPage.triggerActionForFile('file.txt', 'rename')
|
||||
const input = filesListPage.getRenameInputForFile('file.txt')
|
||||
await input.fill('document.md')
|
||||
await input.press('Enter')
|
||||
|
||||
await page.getByRole('dialog', { name: 'Change file extension' })
|
||||
.getByRole('button', { name: 'Keep .txt' })
|
||||
.click()
|
||||
|
||||
await expect(filesListPage.getRowForFile('document.txt')).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows extension-removal warning', async ({ page, filesListPage }) => {
|
||||
await expect(filesListPage.getRowForFile('file.txt')).toBeVisible()
|
||||
|
||||
await filesListPage.triggerActionForFile('file.txt', 'rename')
|
||||
const input = filesListPage.getRenameInputForFile('file.txt')
|
||||
await input.fill('file')
|
||||
await input.press('Enter')
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'Change file extension' })
|
||||
await expect(dialog.getByRole('button', { name: 'Keep .txt' })).toBeVisible()
|
||||
await dialog.getByRole('button', { name: 'Remove extension' }).click()
|
||||
|
||||
await expect(filesListPage.getRowForFile('file')).toBeVisible()
|
||||
await expect(filesListPage.getRowForFile('file.txt')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('does not show extension warning when renaming a folder with a dot', async ({ page, user, filesListPage }) => {
|
||||
await mkdir(page.request, user, '/folder.2024')
|
||||
await filesListPage.open()
|
||||
|
||||
await expect(filesListPage.getRowForFile('folder.2024')).toBeVisible()
|
||||
|
||||
await filesListPage.triggerActionForFile('folder.2024', 'rename')
|
||||
const input = filesListPage.getRenameInputForFolder('folder.2024')
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill('folder.2025')
|
||||
await expect(input).toHaveValidationMessage('')
|
||||
await input.press('Enter')
|
||||
|
||||
await expect(page.locator('[role="dialog"]')).toHaveCount(0)
|
||||
await expect(filesListPage.getRowForFile('folder.2025')).toBeVisible()
|
||||
})
|
||||
})
|
||||
112
tests/playwright/e2e/files/files-sidebar.spec.ts
Normal file
112
tests/playwright/e2e/files/files-sidebar.spec.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { test, expect } from '../../support/fixtures/files-page.ts'
|
||||
import { mkdir, uploadContent } from '../../support/utils/dav.ts'
|
||||
|
||||
test.describe('Files: Sidebar', () => {
|
||||
let fileId: number
|
||||
|
||||
test.beforeEach(async ({ user, page, filesListPage }) => {
|
||||
await mkdir(page.request, user, '/folder')
|
||||
fileId = await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file')
|
||||
await filesListPage.open()
|
||||
})
|
||||
|
||||
test('opens the sidebar', async ({ filesListPage, filesSidebar }) => {
|
||||
await expect(filesListPage.getRowForFile('file')).toBeVisible()
|
||||
|
||||
await filesListPage.triggerActionForFile('file', 'details')
|
||||
|
||||
await expect(filesSidebar.sidebar()).toBeVisible()
|
||||
await expect(filesSidebar.heading('file')).toBeVisible()
|
||||
})
|
||||
|
||||
test('changes the current fileid', async ({ page, filesListPage, filesSidebar }) => {
|
||||
await expect(filesListPage.getRowForFile('file')).toBeVisible()
|
||||
|
||||
await filesListPage.triggerActionForFile('file', 'details')
|
||||
|
||||
await expect(filesSidebar.sidebar()).toBeVisible()
|
||||
await expect(page).toHaveURL(new RegExp(`apps/files/files/${fileId}`))
|
||||
})
|
||||
|
||||
test('changes the sidebar content on other file', async ({ filesListPage, filesSidebar }) => {
|
||||
await expect(filesListPage.getRowForFile('file')).toBeVisible()
|
||||
|
||||
await filesListPage.triggerActionForFile('file', 'details')
|
||||
|
||||
await expect(filesSidebar.sidebar()).toBeVisible()
|
||||
// Wait for the first file's heading to be stable before switching
|
||||
await expect(filesSidebar.heading('file')).toBeVisible()
|
||||
|
||||
await filesListPage.triggerActionForFile('folder', 'details')
|
||||
await expect(filesSidebar.sidebar()).toBeVisible()
|
||||
await expect(filesSidebar.heading('folder')).toBeVisible()
|
||||
})
|
||||
|
||||
test('closes the sidebar on navigation', async ({ filesListPage, filesSidebar }) => {
|
||||
await expect(filesListPage.getRowForFile('file')).toBeVisible()
|
||||
await expect(filesListPage.getRowForFile('folder')).toBeVisible()
|
||||
|
||||
// Open the sidebar
|
||||
await filesListPage.triggerActionForFile('file', 'details')
|
||||
await expect(filesSidebar.sidebar()).toBeVisible()
|
||||
|
||||
// Navigate into the folder — sidebar should close
|
||||
await filesListPage.navigateToFolder('folder')
|
||||
await expect(filesSidebar.sidebar()).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('closes the sidebar on delete', async ({ page, filesListPage, filesSidebar, user }) => {
|
||||
await expect(filesListPage.getRowForFile('file')).toBeVisible()
|
||||
|
||||
// Open the sidebar
|
||||
await filesListPage.triggerActionForFile('file', 'details')
|
||||
await expect(filesSidebar.sidebar()).toBeVisible()
|
||||
// Wait for the sidebar to be fully rendered before deleting
|
||||
await expect(filesSidebar.heading('file')).toBeVisible()
|
||||
|
||||
const deleteResponse = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`/remote.php/dav/files/${user.userId}/file`)
|
||||
&& response.request().method() === 'DELETE',
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
|
||||
await filesListPage.triggerActionForFile('file', 'delete')
|
||||
await deleteResponse
|
||||
|
||||
await expect(filesSidebar.sidebar()).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('changes the fileid on delete', async ({ page, filesListPage, filesSidebar, user }) => {
|
||||
const otherFileId = await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/folder/other')
|
||||
|
||||
await expect(filesListPage.getRowForFile('folder')).toBeVisible()
|
||||
await filesListPage.navigateToFolder('folder')
|
||||
await expect(filesListPage.getRowForFile('other')).toBeVisible()
|
||||
|
||||
// Open the sidebar for the inner file
|
||||
await filesListPage.triggerActionForFile('other', 'details')
|
||||
await expect(filesSidebar.sidebar()).toBeVisible()
|
||||
await expect(page).toHaveURL(new RegExp(`apps/files/files/${otherFileId}`))
|
||||
// Wait for the sidebar to be fully rendered before deleting
|
||||
await expect(filesSidebar.heading('other')).toBeVisible()
|
||||
|
||||
const deleteResponse = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`/remote.php/dav/files/${user.userId}/folder/other`)
|
||||
&& response.request().method() === 'DELETE',
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
|
||||
await filesListPage.triggerActionForFile('other', 'delete')
|
||||
await deleteResponse
|
||||
|
||||
await expect(filesSidebar.sidebar()).not.toBeVisible()
|
||||
await expect(page).not.toHaveURL(new RegExp(`apps/files/files/${otherFileId}`))
|
||||
})
|
||||
})
|
||||
42
tests/playwright/support/fixtures/files-page.ts
Normal file
42
tests/playwright/support/fixtures/files-page.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { runOcc } from '@nextcloud/e2e-test-server/docker'
|
||||
import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright'
|
||||
import { test as baseTest } from '@playwright/test'
|
||||
import type { User } from '@nextcloud/e2e-test-server'
|
||||
import { FilesListPage } from '../sections/FilesListPage.ts'
|
||||
import { FilesSidebarPage } from '../sections/FilesSidebarPage.ts'
|
||||
|
||||
type FilesFixtures = {
|
||||
user: User
|
||||
filesListPage: FilesListPage
|
||||
filesSidebar: FilesSidebarPage
|
||||
}
|
||||
|
||||
export const test = baseTest.extend<FilesFixtures>({
|
||||
user: async ({ context }, use) => {
|
||||
const user = await createRandomUser()
|
||||
try {
|
||||
await login(context.request, user)
|
||||
} catch {
|
||||
// Retry once on transient auth failure
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
await login(context.request, user)
|
||||
}
|
||||
await use(user)
|
||||
await runOcc(['user:delete', user.userId])
|
||||
},
|
||||
|
||||
filesListPage: async ({ page }, use) => {
|
||||
await use(new FilesListPage(page))
|
||||
},
|
||||
|
||||
filesSidebar: async ({ page }, use) => {
|
||||
await use(new FilesSidebarPage(page))
|
||||
},
|
||||
})
|
||||
|
||||
export { expect } from '../matchers.ts'
|
||||
65
tests/playwright/support/matchers.ts
Normal file
65
tests/playwright/support/matchers.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { expect as baseExpect, type Locator } from '@playwright/test'
|
||||
|
||||
export const expect = baseExpect.extend({
|
||||
/**
|
||||
* Asserts that a file-list row has the active highlight class.
|
||||
* A row becomes active when it was the last folder navigated into
|
||||
* (e.g. after a browser back/forward traversal).
|
||||
*/
|
||||
async toBeActiveRow(received: Locator, options?: { timeout?: number }) {
|
||||
let pass: boolean
|
||||
let failMessage: string | undefined
|
||||
try {
|
||||
await baseExpect(received).toHaveClass(/files-list__row--active/, options)
|
||||
pass = true
|
||||
} catch (e: unknown) {
|
||||
pass = false
|
||||
failMessage = (e as Error).message
|
||||
}
|
||||
return {
|
||||
message: () => pass
|
||||
? `Expected row not to have class 'files-list__row--active'`
|
||||
: failMessage ?? `Expected row to have class 'files-list__row--active'`,
|
||||
pass,
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Asserts that an input element has a specific HTML5 validation message.
|
||||
* An empty string means the input is valid (no validation error).
|
||||
* Retries until the message matches or the timeout expires.
|
||||
*/
|
||||
async toHaveValidationMessage(received: Locator, expected: string | RegExp, options?: { timeout?: number }) {
|
||||
let pass = false
|
||||
let actual = ''
|
||||
const getMsg = async () => received.evaluate((el) => (el as HTMLInputElement).validationMessage)
|
||||
try {
|
||||
if (typeof expected === 'string') {
|
||||
await baseExpect.poll(getMsg, { timeout: options?.timeout ?? 5000 }).toBe(expected)
|
||||
} else {
|
||||
await baseExpect.poll(getMsg, { timeout: options?.timeout ?? 5000 }).toMatch(expected)
|
||||
}
|
||||
pass = true
|
||||
} catch {
|
||||
actual = await getMsg().catch(() => '')
|
||||
}
|
||||
return {
|
||||
message: () => pass
|
||||
? `Expected validation message not to equal ${JSON.stringify(expected)}`
|
||||
: `Expected validation message ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||
pass,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
declare module '@playwright/test' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface Matchers<R, T> {
|
||||
toBeActiveRow(options?: { timeout?: number }): R
|
||||
toHaveValidationMessage(expected: string | RegExp, options?: { timeout?: number }): R
|
||||
}
|
||||
}
|
||||
78
tests/playwright/support/sections/FilesListPage.ts
Normal file
78
tests/playwright/support/sections/FilesListPage.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class FilesListPage {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async open(): Promise<void> {
|
||||
await this.page.goto('apps/files')
|
||||
await this.page.locator('[data-cy-files-list]').waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
getRowForFile(filename: string): Locator {
|
||||
return this.page.locator(`[data-cy-files-list-row-name="${filename}"]`)
|
||||
}
|
||||
|
||||
getRowForFileId(fileid: number): Locator {
|
||||
return this.page.locator(`[data-cy-files-list-row-fileid="${fileid}"]`)
|
||||
}
|
||||
|
||||
private getActionsButtonForFile(filename: string): Locator {
|
||||
return this.getRowForFile(filename)
|
||||
.getByRole('button', { name: 'Actions' })
|
||||
}
|
||||
|
||||
async triggerActionForFile(filename: string, actionId: string): Promise<void> {
|
||||
const row = this.getRowForFile(filename)
|
||||
await row.hover()
|
||||
|
||||
const actionsButton = this.getActionsButtonForFile(filename)
|
||||
await actionsButton.scrollIntoViewIfNeeded()
|
||||
// force: true to avoid issues with the sticky file list header
|
||||
await actionsButton.click({ force: true })
|
||||
|
||||
const menuId = await actionsButton.getAttribute('aria-controls')
|
||||
// The action button has role="menuitem", so use tag selector not getByRole
|
||||
const actionEntry = this.page
|
||||
.locator(`#${menuId} [data-cy-files-list-row-action="${actionId}"] button`)
|
||||
await actionEntry.waitFor({ state: 'visible' })
|
||||
await actionEntry.click()
|
||||
}
|
||||
|
||||
async selectAll(): Promise<void> {
|
||||
await this.page.locator('[data-cy-files-list-selection-checkbox]')
|
||||
.getByRole('checkbox')
|
||||
.click({ force: true })
|
||||
}
|
||||
|
||||
async triggerSelectionAction(actionId: string): Promise<void> {
|
||||
const actionsButton = this.page.locator('[data-cy-files-list-selection-actions]')
|
||||
.getByRole('button', { name: 'Actions' })
|
||||
await actionsButton.click({ force: true })
|
||||
// NcActionButton renders as <li data-cy-...><button role="menuitem">
|
||||
const actionButton = this.page.locator(`[data-cy-files-list-selection-action="${actionId}"] button`)
|
||||
await actionButton.waitFor({ state: 'visible' })
|
||||
await actionButton.click()
|
||||
}
|
||||
|
||||
getRenameInputForFile(filename: string): Locator {
|
||||
return this.getRowForFile(filename).getByRole('textbox', { name: 'Filename' })
|
||||
}
|
||||
|
||||
getRenameInputForFolder(foldername: string): Locator {
|
||||
return this.getRowForFile(foldername).getByRole('textbox', { name: 'Folder name' })
|
||||
}
|
||||
|
||||
async navigateToFolder(dirPath: string): Promise<void> {
|
||||
for (const directory of dirPath.split('/').filter(Boolean)) {
|
||||
await this.getRowForFile(directory)
|
||||
.getByRole('button')
|
||||
.filter({ hasText: directory })
|
||||
.click()
|
||||
}
|
||||
}
|
||||
}
|
||||
18
tests/playwright/support/sections/FilesSidebarPage.ts
Normal file
18
tests/playwright/support/sections/FilesSidebarPage.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class FilesSidebarPage {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
sidebar(): Locator {
|
||||
return this.page.locator('#app-sidebar-vue')
|
||||
}
|
||||
|
||||
heading(name: string): Locator {
|
||||
return this.sidebar().getByRole('heading', { name })
|
||||
}
|
||||
}
|
||||
98
tests/playwright/support/utils/dav.ts
Normal file
98
tests/playwright/support/utils/dav.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { APIRequestContext } from '@playwright/test'
|
||||
import type { User } from '@nextcloud/e2e-test-server'
|
||||
|
||||
/**
|
||||
* Make a MKCOL request to create a directory at the given path for the given user.
|
||||
*
|
||||
* @param request - The Playwright API request context
|
||||
* @param user - The user to create the directory for
|
||||
* @param path - The path of the directory to create (relative to user root)
|
||||
*/
|
||||
export async function mkdir(request: APIRequestContext, user: User, path: string): Promise<void> {
|
||||
const requesttoken = await getRequestToken(request)
|
||||
const response = await request.fetch(davUrl(user, path), {
|
||||
method: 'MKCOL',
|
||||
headers: { requesttoken },
|
||||
})
|
||||
if (!response.ok()) {
|
||||
throw new Error(`MKCOL ${path} failed with status ${response.status()}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload content to a DAV path and return the file ID from the response headers.
|
||||
*
|
||||
* @param request The Playwright API request context
|
||||
* @param user The user to upload as
|
||||
* @param content The content to upload
|
||||
* @param mimeType The MIME type of the content
|
||||
* @param path The path to upload to (relative to user root)
|
||||
* @return The file ID from the oc-fileid response header
|
||||
*/
|
||||
export async function uploadContent(
|
||||
request: APIRequestContext,
|
||||
user: User,
|
||||
content: Buffer | string,
|
||||
mimeType: string,
|
||||
path: string,
|
||||
): Promise<number> {
|
||||
const requesttoken = await getRequestToken(request)
|
||||
const response = await request.fetch(davUrl(user, path), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
requesttoken,
|
||||
},
|
||||
data: typeof content === 'string' ? content : content,
|
||||
})
|
||||
if (!response.ok()) {
|
||||
throw new Error(`PUT ${path} failed with status ${response.status()}`)
|
||||
}
|
||||
const fileId = response.headers()['oc-fileid']
|
||||
return fileId ? parseInt(fileId, 10) : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file or directory at the given path for the given user.
|
||||
*
|
||||
* @param request - The Playwright API request context
|
||||
* @param user - The user to delete as
|
||||
* @param path - The path to delete (relative to user root)
|
||||
*/
|
||||
export async function rm(request: APIRequestContext, user: User, path: string): Promise<void> {
|
||||
const requesttoken = await getRequestToken(request)
|
||||
const response = await request.fetch(davUrl(user, path), {
|
||||
method: 'DELETE',
|
||||
headers: { requesttoken },
|
||||
})
|
||||
if (!response.ok()) {
|
||||
throw new Error(`DELETE ${path} failed with status ${response.status()}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct the DAV URL for a given user and path.
|
||||
*
|
||||
* @param user - The user the path belongs to
|
||||
* @param path - The path relative to the user's root directory
|
||||
*/
|
||||
function davUrl(user: User, path: string): string {
|
||||
const cleanPath = ('/' + path).replace(/\/+/g, '/')
|
||||
const encodedPath = cleanPath.split('/').map((seg) => seg ? encodeURIComponent(seg) : '').join('/')
|
||||
return `/remote.php/dav/files/${encodeURIComponent(user.userId)}${encodedPath}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a CSRF request token using the Playwright API request context.
|
||||
*
|
||||
* @param request - The Playwright API request context
|
||||
*/
|
||||
async function getRequestToken(request: APIRequestContext): Promise<string> {
|
||||
const response = await request.get('/csrftoken', { failOnStatusCode: true })
|
||||
return (await response.json()).token
|
||||
}
|
||||
Loading…
Reference in a new issue