diff options
Diffstat (limited to 'cypress/e2e/files')
24 files changed, 2983 insertions, 193 deletions
diff --git a/cypress/e2e/files/FilesUtils.ts b/cypress/e2e/files/FilesUtils.ts index ed2a9ac3389..71ea341a7bf 100644 --- a/cypress/e2e/files/FilesUtils.ts +++ b/cypress/e2e/files/FilesUtils.ts @@ -3,38 +3,120 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import type { User } from '@nextcloud/cypress' +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)}"]`) 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]') -export const getActionButtonForFileId = (fileid: number) => getActionsForFileId(fileid).find('button[aria-label="Actions"]') -export const getActionButtonForFile = (filename: string) => getActionsForFile(filename).find('button[aria-label="Actions"]') +export const getActionButtonForFileId = (fileid: number) => getActionsForFileId(fileid).findByRole('button', { name: 'Actions' }) +export const getActionButtonForFile = (filename: string) => getActionsForFile(filename).findByRole('button', { name: 'Actions' }) + +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)}"]`)) +} + +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 getInlineActionEntryForFileId = (fileid: number, actionId: string) => { + return getActionsForFileId(fileid) + .find(`[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)}"]`) +} export const triggerActionForFileId = (fileid: number, actionId: string) => { - getActionButtonForFileId(fileid).click() - cy.get(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"] > button`).should('exist').click() + 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') + .should('be.visible') + .click() } + export const triggerActionForFile = (filename: string, actionId: string) => { - getActionButtonForFile(filename).click() - cy.get(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"] > button`).should('exist').click() + 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') + .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).get(`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 = () => { + cy.get('[data-cy-files-list-selection-checkbox]') + .findByRole('checkbox', { checked: false }) + .click({ force: true }) +} +export const deselectAllFiles = () => { + cy.get('[data-cy-files-list-selection-checkbox]') + .findByRole('checkbox', { checked: true }) + .click({ force: true }) +} + +export const selectRowForFile = (filename: string, options: Partial<Cypress.ClickOptions> = {}) => { + getRowForFile(filename) + .find('[data-cy-files-list-row-checkbox]') + .findByRole('checkbox') + // don't use click to avoid triggering side effects events + .trigger('change', { ...options, force: true }) + .should('be.checked') + cy.get('[data-cy-files-list-selection-checkbox]').findByRole('checkbox').should('satisfy', (elements) => { + return elements.length === 1 && (elements[0].checked === true || elements[0].indeterminate === true) + }) + +} + +export const getSelectionActionButton = () => cy.get('[data-cy-files-list-selection-actions]').findByRole('button', { name: 'Actions' }) +export const getSelectionActionEntry = (actionId: string) => cy.get(`[data-cy-files-list-selection-action="${CSS.escape(actionId)}"]`) +export const triggerSelectionAction = (actionId: string) => { + // Even if it's inline, we open the action menu to get all actions visible + getSelectionActionButton().click({ force: true }) + // the entry might already be a button or a button might its child + getSelectionActionEntry(actionId) + .then($el => $el.is('button') ? cy.wrap($el) : cy.wrap($el).findByRole('button').last()) + .should('exist') + .click() } export const moveFile = (fileName: string, dirPath: string) => { getRowForFile(fileName).should('be.visible') - triggerActionForFile(fileName, 'move-copy') + triggerActionForFile(fileName, ACTION_COPY_MOVE) cy.get('.file-picker').within(() => { // intercept the copy so we can wait for it - cy.intercept('MOVE', /\/remote.php\/dav\/files\//).as('moveFile') + cy.intercept('MOVE', /\/(remote|public)\.php\/dav\/files\//).as('moveFile') if (dirPath === '/') { // select home folder @@ -61,11 +143,11 @@ export const moveFile = (fileName: string, dirPath: string) => { export const copyFile = (fileName: string, dirPath: string) => { getRowForFile(fileName).should('be.visible') - triggerActionForFile(fileName, 'move-copy') + triggerActionForFile(fileName, ACTION_COPY_MOVE) cy.get('.file-picker').within(() => { // intercept the copy so we can wait for it - cy.intercept('COPY', /\/remote.php\/dav\/files\//).as('copyFile') + cy.intercept('COPY', /\/(remote|public)\.php\/dav\/files\//).as('copyFile') if (dirPath === '/') { // select home folder @@ -92,22 +174,30 @@ 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.php\/dav\/files\//).as('moveFile') + cy.intercept('MOVE', /\/(remote|public)\.php\/dav\/files\//).as('moveFile') - getRowForFile(fileName).find('[data-cy-files-list-row-name] input').clear() - getRowForFile(fileName).find('[data-cy-files-list-row-name] input').type(`${newFileName}{enter}`) + getRowForFile(fileName) + .find('[data-cy-files-list-row-name] input') + .type(`{selectAll}${newFileName}{enter}`) cy.wait('@moveFile') } export const navigateToFolder = (dirPath: string) => { const directories = dirPath.split('/') - directories.forEach((directory) => { + for (const directory of directories) { + if (directory === '') { + continue + } + getRowForFile(directory).should('be.visible').find('[data-cy-files-list-row-name-link]').click() - }) + } } @@ -121,3 +211,114 @@ export const clickOnBreadcrumbs = (label: string) => { cy.get('[data-cy-files-content-breadcrumbs]').contains(label).click() cy.wait('@propfind') } + +export const createFolder = (folderName: string) => { + cy.intercept('MKCOL', /\/remote.php\/dav\/files\//).as('createFolder') + + // TODO: replace by proper data-cy selectors + cy.get('[data-cy-upload-picker] .action-item__menutoggle').first().click() + cy.get('[data-cy-upload-picker-menu-entry="newFolder"] button').click() + cy.get('[data-cy-files-new-node-dialog]').should('be.visible') + cy.get('[data-cy-files-new-node-dialog-input]').type(`{selectall}${folderName}`) + cy.get('[data-cy-files-new-node-dialog-submit]').click() + + cy.wait('@createFolder') + + getRowForFile(folderName).should('be.visible') +} + +/** + * Check validity of an input element + * @param validity The expected validity message (empty string means it is valid) + * @example + * ```js + * cy.findByRole('textbox') + * .should(haveValidity(/must not be empty/i)) + * ``` + */ +export const haveValidity = (validity: string | RegExp) => { + if (typeof validity === 'string') { + return (el: JQuery<HTMLElement>) => expect((el.get(0) as HTMLInputElement).validationMessage).to.equal(validity) + } + return (el: JQuery<HTMLElement>) => expect((el.get(0) as HTMLInputElement).validationMessage).to.match(validity) +} + +export const deleteFileWithRequest = (user: User, path: string) => { + // Ensure path starts with a slash and has no double slashes + path = `/${path}`.replace(/\/+/g, '/') + + cy.request('/csrftoken').then(({ body }) => { + const requestToken = body.token + cy.request({ + method: 'DELETE', + url: `${Cypress.env('baseUrl')}/remote.php/dav/files/${user.userId}${path}`, + auth: { + user: user.userId, + password: user.password, + }, + headers: { + requestToken, + }, + retryOnStatusCodeFailure: true, + }) + }) +} + +export const triggerFileListAction = (actionId: string) => { + cy.get(`button[data-cy-files-list-action="${CSS.escape(actionId)}"]`).last() + .should('exist').click({ force: true }) +} + +export const reloadCurrentFolder = () => { + cy.intercept('PROPFIND', /\/remote.php\/dav\//).as('propfind') + cy.get('[data-cy-files-content-breadcrumbs]').findByRole('button', { description: 'Reload current directory' }).click() + cy.wait('@propfind') +} + +/** + * Enable the grid mode for the files list. + * Will fail if already enabled! + */ +export function enableGridMode() { + cy.intercept('**/apps/files/api/v1/config/grid_view').as('setGridMode') + cy.findByRole('button', { name: 'Switch to grid view' }) + .should('be.visible') + .click() + cy.wait('@setGridMode') +} + +/** + * Calculate the needed viewport height to limit the visible rows of the file list. + * Requires a logged in user. + * + * @param rows The number of rows that should be displayed at the same time + */ +export function calculateViewportHeight(rows: number): Cypress.Chainable<number> { + 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) => { + const windowHeight = Cypress.$('body').outerHeight()! + // Size of other page elements + const outerHeight = Math.ceil(windowHeight - filesList.outerHeight()!) + // Size of before and filters + const beforeHeight = Math.ceil(Cypress.$('.files-list__before').outerHeight()!) + const filterHeight = Math.ceil(Cypress.$('.files-list__filters').outerHeight()!) + // Size of the table header + const tableHeaderHeight = Math.ceil(Cypress.$('[data-cy-files-list-thead]').outerHeight()!) + // table row height + const rowHeight = Math.ceil(Cypress.$('[data-cy-files-list-tbody] tr').outerHeight()!) + + // sum it up + const viewportHeight = outerHeight + beforeHeight + filterHeight + tableHeaderHeight + rows * rowHeight + cy.log(`Calculated viewport height: ${viewportHeight} (${outerHeight} + ${beforeHeight} + ${filterHeight} + ${tableHeaderHeight} + ${rows} * ${rowHeight})`) + return cy.wrap(viewportHeight) + }) +} diff --git a/cypress/e2e/files/LivePhotosUtils.ts b/cypress/e2e/files/LivePhotosUtils.ts new file mode 100644 index 00000000000..34e6a1d934e --- /dev/null +++ b/cypress/e2e/files/LivePhotosUtils.ts @@ -0,0 +1,104 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' + +type SetupInfo = { + snapshot: string + jpgFileId: number + movFileId: number + fileName: string + user: User +} + +/** + */ +function setMetadata(user: User, fileName: string, requesttoken: string, metadata: object) { + const base = Cypress.config('baseUrl')!.replace(/\/index\.php\/?/, '') + cy.request({ + method: 'PROPPATCH', + url: `${base}/remote.php/dav/files/${user.userId}/${fileName}`, + auth: { user: user.userId, pass: user.password }, + headers: { + requesttoken, + }, + body: `<?xml version="1.0"?> + <d:propertyupdate xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns"> + <d:set> + <d:prop> + ${Object.entries(metadata).map(([key, value]) => `<${key}>${value}</${key}>`).join('\n')} + </d:prop> + </d:set> + </d:propertyupdate>`, + }) +} + +/** + * + * @param enable + */ +export function setShowHiddenFiles(enable: boolean) { + cy.request('/csrftoken').then(({ body }) => { + const requestToken = body.token + const url = `${Cypress.config('baseUrl')}/apps/files/api/v1/config/show_hidden` + cy.request({ + method: 'PUT', + url, + headers: { + 'Content-Type': 'application/json', + requesttoken: requestToken, + }, + body: { value: enable }, + }) + }) + cy.reload() +} + +/** + * + */ +export function setupLivePhotos(): Cypress.Chainable<SetupInfo> { + return cy.task('getVariable', { key: 'live-photos-data' }) + .then((_setupInfo) => { + const setupInfo = _setupInfo as SetupInfo || {} + if (setupInfo.snapshot) { + cy.restoreState(setupInfo.snapshot) + } else { + let requesttoken: string + + setupInfo.fileName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + + cy.createRandomUser().then(_user => { setupInfo.user = _user }) + + cy.then(() => { + cy.uploadContent(setupInfo.user, new Blob(['jpg file'], { type: 'image/jpg' }), 'image/jpg', `/${setupInfo.fileName}.jpg`) + .then(response => { setupInfo.jpgFileId = parseInt(response.headers['oc-fileid']) }) + cy.uploadContent(setupInfo.user, new Blob(['mov file'], { type: 'video/mov' }), 'video/mov', `/${setupInfo.fileName}.mov`) + .then(response => { setupInfo.movFileId = parseInt(response.headers['oc-fileid']) }) + + cy.login(setupInfo.user) + }) + + cy.visit('/apps/files') + + cy.get('head').invoke('attr', 'data-requesttoken').then(_requesttoken => { requesttoken = _requesttoken as string }) + + cy.then(() => { + setMetadata(setupInfo.user, `${setupInfo.fileName}.jpg`, requesttoken, { 'nc:metadata-files-live-photo': setupInfo.movFileId }) + setMetadata(setupInfo.user, `${setupInfo.fileName}.mov`, requesttoken, { 'nc:metadata-files-live-photo': setupInfo.jpgFileId }) + }) + + cy.then(() => { + cy.saveState().then((value) => { setupInfo.snapshot = value }) + cy.task('setVariable', { key: 'live-photos-data', value: setupInfo }) + }) + } + return cy.then(() => { + cy.login(setupInfo.user) + cy.visit('/apps/files') + return cy.wrap(setupInfo) + }) + }) +} diff --git a/cypress/e2e/files/duplicated-node-regression.cy.ts b/cypress/e2e/files/duplicated-node-regression.cy.ts new file mode 100644 index 00000000000..14355a62b9d --- /dev/null +++ b/cypress/e2e/files/duplicated-node-regression.cy.ts @@ -0,0 +1,33 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { createFolder, getRowForFile, triggerActionForFile } from './FilesUtils.ts' + +before(() => { + cy.createRandomUser() + .then((user) => { + cy.mkdir(user, '/only once') + cy.login(user) + cy.visit('/apps/files') + }) +}) + +/** + * Regression test for https://github.com/nextcloud/server/issues/47904 + */ +it('Ensure nodes are not duplicated in the file list', () => { + // See the folder + getRowForFile('only once').should('be.visible') + // Delete the folder + cy.intercept('DELETE', '**/remote.php/dav/**').as('deleteFolder') + triggerActionForFile('only once', 'delete') + cy.wait('@deleteFolder') + getRowForFile('only once').should('not.exist') + // Create the folder again + createFolder('only once') + // See folder exists only once + getRowForFile('only once') + .should('have.length', 1) +}) diff --git a/cypress/e2e/files/favorites.cy.ts b/cypress/e2e/files/favorites.cy.ts new file mode 100644 index 00000000000..96812f116e1 --- /dev/null +++ b/cypress/e2e/files/favorites.cy.ts @@ -0,0 +1,137 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { getActionButtonForFile, getRowForFile, triggerActionForFile } from './FilesUtils' + +describe('files: Favorites', { testIsolation: true }, () => { + let user: User + + beforeEach(() => { + cy.createRandomUser().then(($user) => { + user = $user + cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt') + cy.mkdir(user, '/new folder') + cy.login(user) + cy.visit('/apps/files') + }) + }) + + it('Mark file as favorite', () => { + // See file exists + getRowForFile('file.txt') + .should('exist') + + cy.intercept('POST', '**/apps/files/api/v1/files/file.txt').as('addToFavorites') + // Click actions + getActionButtonForFile('file.txt').click({ force: true }) + // See action is called 'Add to favorites' + cy.get('[data-cy-files-list-row-action="favorite"] > button').last() + .should('exist') + .and('contain.text', 'Add to favorites') + .click({ force: true }) + cy.wait('@addToFavorites') + // See favorites star + getRowForFile('file.txt') + .findByRole('img', { name: 'Favorite' }) + .should('exist') + }) + + it('Un-mark file as favorite', () => { + // See file exists + getRowForFile('file.txt') + .should('exist') + + cy.intercept('POST', '**/apps/files/api/v1/files/file.txt').as('addToFavorites') + // toggle favorite + triggerActionForFile('file.txt', 'favorite') + cy.wait('@addToFavorites') + + // See favorites star + getRowForFile('file.txt') + .findByRole('img', { name: 'Favorite' }) + .should('be.visible') + + // Remove favorite + // click action button + getActionButtonForFile('file.txt').click({ force: true }) + // See action is called 'Remove from favorites' + cy.get('[data-cy-files-list-row-action="favorite"] > button').last() + .should('exist') + .and('have.text', 'Remove from favorites') + .click({ force: true }) + cy.wait('@addToFavorites') + // See no favorites star anymore + getRowForFile('file.txt') + .findByRole('img', { name: 'Favorite' }) + .should('not.exist') + }) + + it('See favorite folders in navigation', () => { + cy.intercept('POST', '**/apps/files/api/v1/files/new%20folder').as('addToFavorites') + + // see navigation has no entry + cy.get('[data-cy-files-navigation-item="favorites"]') + .should('be.visible') + .contains('new folder') + .should('not.exist') + + // toggle favorite + triggerActionForFile('new folder', 'favorite') + cy.wait('@addToFavorites') + + // See in navigation + cy.get('[data-cy-files-navigation-item="favorites"]') + .should('be.visible') + .contains('new folder') + .should('exist') + + // toggle favorite + triggerActionForFile('new folder', 'favorite') + cy.wait('@addToFavorites') + + // See no longer in navigation + cy.get('[data-cy-files-navigation-item="favorites"]') + .should('be.visible') + .contains('new folder') + .should('not.exist') + }) + + it('Mark file as favorite using the sidebar', () => { + // See file exists + getRowForFile('new folder') + .should('exist') + // see navigation has no entry + cy.get('[data-cy-files-navigation-item="favorites"]') + .should('be.visible') + .contains('new folder') + .should('not.exist') + + cy.intercept('PROPPATCH', '**/remote.php/dav/files/*/new%20folder').as('addToFavorites') + // open sidebar + triggerActionForFile('new folder', 'details') + // open actions + cy.get('[data-cy-sidebar]') + .findByRole('button', { name: 'Actions' }) + .click() + // trigger menu button + cy.findAllByRole('menu') + .findByRole('menuitem', { name: 'Add to favorites' }) + .should('be.visible') + .click() + cy.wait('@addToFavorites') + + // See favorites star + getRowForFile('new folder') + .findByRole('img', { name: 'Favorite' }) + .should('be.visible') + + // See folder in navigation + cy.get('[data-cy-files-navigation-item="favorites"]') + .should('be.visible') + .contains('new folder') + .should('exist') + }) +}) diff --git a/cypress/e2e/files/files-actions.cy.ts b/cypress/e2e/files/files-actions.cy.ts new file mode 100644 index 00000000000..dbcf810e2a2 --- /dev/null +++ b/cypress/e2e/files/files-actions.cy.ts @@ -0,0 +1,216 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { FileAction } from '@nextcloud/files' + +import { getActionButtonForFileId, getActionEntryForFileId, getRowForFile, getSelectionActionButton, getSelectionActionEntry, selectRowForFile } from './FilesUtils' +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' + +declare global { + interface Window { + _nc_fileactions: FileAction[] + } +} + +// Those two arrays doesn't represent the full list of actions +// the goal is to test a few, we're not trying to match the full feature set +const expectedDefaultActionsIDs = [ + ACTION_COPY_MOVE, + ACTION_DELETE, + ACTION_DETAILS, +] +const expectedDefaultSelectionActionsIDs = [ + ACTION_COPY_MOVE, + ACTION_DELETE, +] + +describe('Files: Actions', { testIsolation: true }, () => { + let user: User + let fileId: number = 0 + + beforeEach(() => cy.createRandomUser().then(($user) => { + user = $user + + cy.uploadContent(user, new Blob([]), 'image/jpeg', '/image.jpg').then((response) => { + fileId = Number.parseInt(response.headers['oc-fileid'] ?? '0') + }) + cy.login(user) + })) + + it('Show some standard actions', () => { + cy.visit('/apps/files') + getRowForFile('image.jpg').should('be.visible') + + expectedDefaultActionsIDs.forEach((actionId) => { + // Open the menu + getActionButtonForFileId(fileId).click({ force: true }) + // Check the action is visible + getActionEntryForFileId(fileId, actionId).should('be.visible') + // Close the menu + cy.get('body').click({ force: true }) + }) + }) + + it('Show some nested actions', () => { + const parent = new FileAction({ + id: 'nested-action', + displayName: () => 'Nested Action', + exec: cy.spy(), + iconSvgInline: () => '<svg></svg>', + }) + + const child1 = new FileAction({ + id: 'nested-child-1', + displayName: () => 'Nested Child 1', + exec: cy.spy(), + iconSvgInline: () => '<svg></svg>', + parent: 'nested-action', + }) + + const child2 = new FileAction({ + id: 'nested-child-2', + displayName: () => 'Nested Child 2', + exec: cy.spy(), + iconSvgInline: () => '<svg></svg>', + parent: 'nested-action', + }) + + cy.visit('/apps/files', { + // Cannot use registerFileAction here + onBeforeLoad: (win) => { + if (!win._nc_fileactions) win._nc_fileactions = [] + // Cannot use registerFileAction here + win._nc_fileactions.push(parent) + win._nc_fileactions.push(child1) + win._nc_fileactions.push(child2) + }, + }) + + // Open the menu + getActionButtonForFileId(fileId) + .scrollIntoView() + .click({ force: true }) + + // Check we have the parent action but not the children + getActionEntryForFileId(fileId, 'nested-action').should('be.visible') + getActionEntryForFileId(fileId, 'menu-back').should('not.exist') + getActionEntryForFileId(fileId, 'nested-child-1').should('not.exist') + getActionEntryForFileId(fileId, 'nested-child-2').should('not.exist') + + // Click on the parent action + getActionEntryForFileId(fileId, 'nested-action') + .should('be.visible') + .click() + + // Check we have the children and the back button but not the parent + getActionEntryForFileId(fileId, 'nested-action').should('not.exist') + getActionEntryForFileId(fileId, 'menu-back').should('be.visible') + getActionEntryForFileId(fileId, 'nested-child-1').should('be.visible') + getActionEntryForFileId(fileId, 'nested-child-2').should('be.visible') + + // Click on the back button + getActionEntryForFileId(fileId, 'menu-back') + .should('be.visible') + .click() + + // Check we have the parent action but not the children + getActionEntryForFileId(fileId, 'nested-action').should('be.visible') + getActionEntryForFileId(fileId, 'menu-back').should('not.exist') + getActionEntryForFileId(fileId, 'nested-child-1').should('not.exist') + getActionEntryForFileId(fileId, 'nested-child-2').should('not.exist') + }) + + it('Show some actions for a selection', () => { + cy.visit('/apps/files') + getRowForFile('image.jpg').should('be.visible') + + selectRowForFile('image.jpg') + + cy.get('[data-cy-files-list-selection-actions]').should('be.visible') + getSelectionActionButton().should('be.visible') + + // Open the menu + getSelectionActionButton().click({ force: true }) + + // Check the action is visible + expectedDefaultSelectionActionsIDs.forEach((actionId) => { + getSelectionActionEntry(actionId).should('be.visible') + }) + }) + + it('Show some nested actions for a selection', () => { + const parent = new FileAction({ + id: 'nested-action', + displayName: () => 'Nested Action', + exec: cy.spy(), + iconSvgInline: () => '<svg></svg>', + }) + + const child1 = new FileAction({ + id: 'nested-child-1', + displayName: () => 'Nested Child 1', + exec: cy.spy(), + execBatch: cy.spy(), + iconSvgInline: () => '<svg></svg>', + parent: 'nested-action', + }) + + const child2 = new FileAction({ + id: 'nested-child-2', + displayName: () => 'Nested Child 2', + exec: cy.spy(), + execBatch: cy.spy(), + iconSvgInline: () => '<svg></svg>', + parent: 'nested-action', + }) + + cy.visit('/apps/files', { + // Cannot use registerFileAction here + onBeforeLoad: (win) => { + if (!win._nc_fileactions) win._nc_fileactions = [] + // Cannot use registerFileAction here + win._nc_fileactions.push(parent) + win._nc_fileactions.push(child1) + win._nc_fileactions.push(child2) + }, + }) + + selectRowForFile('image.jpg') + + // Open the menu + getSelectionActionButton().click({ force: true }) + + // Check we have the parent action but not the children + getSelectionActionEntry('nested-action').should('be.visible') + getSelectionActionEntry('menu-back').should('not.exist') + getSelectionActionEntry('nested-child-1').should('not.exist') + getSelectionActionEntry('nested-child-2').should('not.exist') + + // Click on the parent action + getSelectionActionEntry('nested-action') + .find('button').last() + .should('exist').click({ force: true }) + + // Check we have the children and the back button but not the parent + getSelectionActionEntry('nested-action').should('not.exist') + getSelectionActionEntry('menu-back').should('be.visible') + getSelectionActionEntry('nested-child-1').should('be.visible') + getSelectionActionEntry('nested-child-2').should('be.visible') + + // Click on the back button + getSelectionActionEntry('menu-back') + .find('button').last() + .should('exist').click({ force: true }) + + // Check we have the parent action but not the children + getSelectionActionEntry('nested-action').should('be.visible') + getSelectionActionEntry('menu-back').should('not.exist') + getSelectionActionEntry('nested-child-1').should('not.exist') + getSelectionActionEntry('nested-child-2').should('not.exist') + }) +}) diff --git a/cypress/e2e/files/files_copy-move.cy.ts b/cypress/e2e/files/files-copy-move.cy.ts index 086248eef3c..086248eef3c 100644 --- a/cypress/e2e/files/files_copy-move.cy.ts +++ b/cypress/e2e/files/files-copy-move.cy.ts diff --git a/cypress/e2e/files/files-delete.cy.ts b/cypress/e2e/files/files-delete.cy.ts new file mode 100644 index 00000000000..edb88519c59 --- /dev/null +++ b/cypress/e2e/files/files-delete.cy.ts @@ -0,0 +1,74 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { getRowForFile, navigateToFolder, selectAllFiles, triggerActionForFile } 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() + cy.get('[data-cy-files-list-selection-actions]') + .findByRole('button', { name: 'Actions' }) + .click() + cy.get('[data-cy-files-list-selection-action="delete"]') + .findByRole('menuitem', { name: /^Delete files/ }) + .click() + + // 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) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .should((all: any) => { + for (const call of all) { + expect(call.response.statusCode).to.equal(204) + } + }) + }) +}) diff --git a/cypress/e2e/files/files-download.cy.ts b/cypress/e2e/files/files-download.cy.ts new file mode 100644 index 00000000000..06eb62094b8 --- /dev/null +++ b/cypress/e2e/files/files-download.cy.ts @@ -0,0 +1,351 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { getRowForFile, navigateToFolder, triggerActionForFile } from './FilesUtils' +import { deleteDownloadsFolderBeforeEach } from 'cypress-delete-downloads-folder' +import { zipFileContains } from '../../support/utils/assertions.ts' + +import randomString from 'crypto-random-string' + +describe('files: Download files using file actions', { testIsolation: true }, () => { + let user: User + + deleteDownloadsFolderBeforeEach() + + beforeEach(() => { + cy.createRandomUser().then(($user) => { + user = $user + }) + }) + + it('can download file', () => { + cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/file.txt') + cy.login(user) + cy.visit('/apps/files') + + getRowForFile('file.txt').should('be.visible') + + triggerActionForFile('file.txt', 'download') + + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/file.txt`, { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 8) + .and('equal', '<content>') + }) + + it('can download folder', () => { + cy.mkdir(user, '/subfolder') + cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/subfolder/file.txt') + + cy.login(user) + cy.visit('/apps/files') + getRowForFile('subfolder') + .should('be.visible') + + triggerActionForFile('subfolder', 'download') + + // check a file is downloaded + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/subfolder.zip`, null, { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 30) + // Check all files are included + .and(zipFileContains([ + 'subfolder/', + 'subfolder/file.txt', + ])) + }) + + /** + * Regression test of https://github.com/nextcloud/server/issues/44855 + */ + it('can download file with hash name', () => { + cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/#file.txt') + cy.login(user) + cy.visit('/apps/files') + + triggerActionForFile('#file.txt', 'download') + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/#file.txt`, { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 8) + .and('equal', '<content>') + }) + + /** + * Regression test of https://github.com/nextcloud/server/issues/44855 + */ + it('can download file from folder with hash name', () => { + cy.mkdir(user, '/#folder') + .uploadContent(user, new Blob(['<content>']), 'text/plain', '/#folder/file.txt') + cy.login(user) + cy.visit('/apps/files') + + navigateToFolder('#folder') + // All are visible by default + getRowForFile('file.txt').should('be.visible') + + triggerActionForFile('file.txt', 'download') + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/file.txt`, { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 8) + .and('equal', '<content>') + }) +}) + +describe('files: Download files using default action', { testIsolation: true }, () => { + let user: User + + deleteDownloadsFolderBeforeEach() + + beforeEach(() => { + cy.createRandomUser().then(($user) => { + user = $user + }) + }) + + it('can download file', () => { + cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/file.txt') + cy.login(user) + cy.visit('/apps/files') + + getRowForFile('file.txt') + .should('be.visible') + .findByRole('button', { name: 'Download' }) + .click() + + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/file.txt`, { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 8) + .and('equal', '<content>') + }) + + /** + * Regression test of https://github.com/nextcloud/server/issues/44855 + */ + it('can download file with hash name', () => { + cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/#file.txt') + cy.login(user) + cy.visit('/apps/files') + + getRowForFile('#file.txt') + .should('be.visible') + .findByRole('button', { name: 'Download' }) + .click() + + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/#file.txt`, { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 8) + .and('equal', '<content>') + }) + + /** + * Regression test of https://github.com/nextcloud/server/issues/44855 + */ + it('can download file from folder with hash name', () => { + cy.mkdir(user, '/#folder') + .uploadContent(user, new Blob(['<content>']), 'text/plain', '/#folder/file.txt') + cy.login(user) + cy.visit('/apps/files') + + navigateToFolder('#folder') + // All are visible by default + getRowForFile('file.txt') + .should('be.visible') + .findByRole('button', { name: 'Download' }) + .click() + + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/file.txt`, { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 8) + .and('equal', '<content>') + }) +}) + +describe('files: Download files using selection', () => { + + deleteDownloadsFolderBeforeEach() + + it('can download selected files', () => { + cy.createRandomUser().then((user) => { + cy.mkdir(user, '/subfolder') + cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/subfolder/file.txt') + cy.login(user) + cy.visit('/apps/files') + }) + + getRowForFile('subfolder') + .should('be.visible') + + getRowForFile('subfolder') + .findByRole('checkbox') + .check({ force: true }) + + // see that two files are selected + cy.get('[data-cy-files-list]').within(() => { + cy.contains('1 selected').should('be.visible') + }) + + // click download + cy.get('[data-cy-files-list-selection-actions]') + .findByRole('button', { name: 'Actions' }) + .click() + cy.findByRole('menuitem', { name: 'Download (selected)' }) + .should('be.visible') + .click() + + // check a file is downloaded + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/subfolder.zip`, null, { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 30) + // Check all files are included + .and(zipFileContains([ + 'subfolder/', + 'subfolder/file.txt', + ])) + }) + + it('can download multiple selected files', () => { + cy.createRandomUser().then((user) => { + cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/file.txt') + cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/other file.txt') + cy.login(user) + cy.visit('/apps/files') + }) + + getRowForFile('file.txt') + .should('be.visible') + .findByRole('checkbox') + .check({ force: true }) + + getRowForFile('other file.txt') + .should('be.visible') + .findByRole('checkbox') + .check({ force: true }) + + cy.get('[data-cy-files-list]').within(() => { + // see that two files are selected + cy.contains('2 selected').should('be.visible') + }) + + // click download + cy.get('[data-cy-files-list-selection-actions]') + .findByRole('button', { name: 'Actions' }) + .click() + cy.findByRole('menuitem', { name: 'Download (selected)' }) + .click() + + // check a file is downloaded + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/download.zip`, null, { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 30) + // Check all files are included + .and(zipFileContains([ + 'file.txt', + 'other file.txt', + ])) + }) + + /** + * Regression test of https://help.nextcloud.com/t/unable-to-download-files-on-nextcloud-when-multiple-files-selected/221327/5 + */ + it('can download selected files with special characters', () => { + cy.createRandomUser().then((user) => { + cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/1+1.txt') + cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/some@other.txt') + cy.login(user) + cy.visit('/apps/files') + }) + + getRowForFile('some@other.txt') + .should('be.visible') + .findByRole('checkbox') + .check({ force: true }) + + getRowForFile('1+1.txt') + .should('be.visible') + .findByRole('checkbox') + .check({ force: true }) + + cy.get('[data-cy-files-list]').within(() => { + // see that two files are selected + cy.contains('2 selected').should('be.visible') + }) + + // click download + cy.get('[data-cy-files-list-selection-actions]') + .findByRole('button', { name: 'Actions' }) + .click() + cy.findByRole('menuitem', { name: 'Download (selected)' }) + .click() + + // check a file is downloaded + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/download.zip`, null, { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 30) + // Check all files are included + .and(zipFileContains([ + '1+1.txt', + 'some@other.txt', + ])) + }) + + /** + * Regression test of https://help.nextcloud.com/t/unable-to-download-files-on-nextcloud-when-multiple-files-selected/221327/5 + */ + it('can download selected files with email uid', () => { + const name = `${randomString(5)}@${randomString(3)}` + const user: User = { userId: name, password: name, language: 'en' } + + cy.createUser(user).then(() => { + cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/file.txt') + cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/other file.txt') + cy.login(user) + cy.visit('/apps/files') + }) + + getRowForFile('file.txt') + .should('be.visible') + .findByRole('checkbox') + .check({ force: true }) + + getRowForFile('other file.txt') + .should('be.visible') + .findByRole('checkbox') + .check({ force: true }) + + cy.get('[data-cy-files-list]').within(() => { + // see that two files are selected + cy.contains('2 selected').should('be.visible') + }) + + // click download + cy.get('[data-cy-files-list-selection-actions]') + .findByRole('button', { name: 'Actions' }) + .click() + cy.findByRole('menuitem', { name: 'Download (selected)' }) + .click() + + // check a file is downloaded + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/download.zip`, null, { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 30) + // Check all files are included + .and(zipFileContains([ + 'file.txt', + 'other file.txt', + ])) + }) +}) diff --git a/cypress/e2e/files/files-filtering.cy.ts b/cypress/e2e/files/files-filtering.cy.ts new file mode 100644 index 00000000000..9499d9ff49c --- /dev/null +++ b/cypress/e2e/files/files-filtering.cy.ts @@ -0,0 +1,280 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { getRowForFile, navigateToFolder } from './FilesUtils' +import { FilesNavigationPage } from '../../pages/FilesNavigation' +import { FilesFilterPage } from '../../pages/FilesFilters' + +describe('files: Filter in files list', { testIsolation: true }, () => { + const appNavigation = new FilesNavigationPage() + const filesFilters = new FilesFilterPage() + let user: User + + beforeEach(() => cy.createRandomUser().then(($user) => { + user = $user + + cy.mkdir(user, '/folder') + cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt') + cy.uploadContent(user, new Blob([]), 'text/csv', '/spreadsheet.csv') + cy.uploadContent(user, new Blob([]), 'text/plain', '/folder/text.txt') + cy.login(user) + cy.visit('/apps/files') + })) + + it('filters current view by name', () => { + // All are visible by default + getRowForFile('folder').should('be.visible') + getRowForFile('file.txt').should('be.visible') + + // Set up a search query + appNavigation.searchInput() + .type('folder') + + // See that only the folder is visible + getRowForFile('folder').should('be.visible') + getRowForFile('file.txt').should('not.exist') + getRowForFile('spreadsheet.csv').should('not.exist') + }) + + it('can reset name filter', () => { + // All are visible by default + getRowForFile('folder').should('be.visible') + getRowForFile('file.txt').should('be.visible') + + // Set up a search query + appNavigation.searchInput() + .type('folder') + + // See that only the folder is visible + getRowForFile('folder').should('be.visible') + getRowForFile('file.txt').should('not.exist') + + // reset the filter + appNavigation.searchInput().should('have.value', 'folder') + appNavigation.searchClearButton().should('exist').click() + appNavigation.searchInput().should('have.value', '') + + // All are visible again + getRowForFile('folder').should('be.visible') + getRowForFile('file.txt').should('be.visible') + }) + + it('filters current view by type', () => { + // All are visible by default + getRowForFile('folder').should('be.visible') + getRowForFile('file.txt').should('be.visible') + getRowForFile('spreadsheet.csv').should('be.visible') + + filesFilters.filterContainter() + .findByRole('button', { name: 'Type' }) + .should('be.visible') + .click() + cy.findByRole('menuitemcheckbox', { name: 'Spreadsheets' }) + .should('be.visible') + .click() + filesFilters.filterContainter() + .findByRole('button', { name: 'Type' }) + .should('be.visible') + .click() + + // See that only the spreadsheet is visible + getRowForFile('spreadsheet.csv').should('be.visible') + getRowForFile('file.txt').should('not.exist') + getRowForFile('folder').should('not.exist') + }) + + it('can reset filter by type', () => { + // All are visible by default + getRowForFile('folder').should('be.visible') + + filesFilters.filterContainter() + .findByRole('button', { name: 'Type' }) + .should('be.visible') + .click() + cy.findByRole('menuitemcheckbox', { name: 'Spreadsheets' }) + .should('be.visible') + .click() + filesFilters.filterContainter() + .findByRole('button', { name: 'Type' }) + .should('be.visible') + .click() + + // See folder is not visible + getRowForFile('folder').should('not.exist') + + // clear filter + filesFilters.filterContainter() + .findByRole('button', { name: 'Type' }) + .should('be.visible') + .click() + cy.findByRole('menuitem', { name: /clear filter/i }) + .should('be.visible') + .click() + filesFilters.filterContainter() + .findByRole('button', { name: 'Type' }) + .should('be.visible') + .click() + + // See folder is visible again + getRowForFile('folder').should('be.visible') + }) + + it('can reset filter by clicking chip button', () => { + // All are visible by default + getRowForFile('folder').should('be.visible') + + filesFilters.filterContainter() + .findByRole('button', { name: 'Type' }) + .should('be.visible') + .click() + cy.findByRole('menuitemcheckbox', { name: 'Spreadsheets' }) + .should('be.visible') + .click() + filesFilters.filterContainter() + .findByRole('button', { name: 'Type' }) + .should('be.visible') + .click() + + // See folder is not visible + getRowForFile('folder').should('not.exist') + + // clear filter + filesFilters.removeFilter('Spreadsheets') + + // See folder is visible again + getRowForFile('folder').should('be.visible') + }) + + it('keeps name filter when changing the directory', () => { + // All are visible by default + getRowForFile('folder').should('be.visible') + getRowForFile('file.txt').should('be.visible') + + // Set up a search query + appNavigation.searchInput() + .type('folder') + + // See that only the folder is visible + getRowForFile('folder').should('be.visible') + getRowForFile('file.txt').should('not.exist') + + // go to that folder + navigateToFolder('folder') + + // see that the folder is also filtered + getRowForFile('text.txt').should('not.exist') + }) + + it('keeps type filter when changing the directory', () => { + // All are visible by default + getRowForFile('folder').should('be.visible') + getRowForFile('file.txt').should('be.visible') + + filesFilters.filterContainter() + .findByRole('button', { name: 'Type' }) + .should('be.visible') + .click() + cy.findByRole('menuitemcheckbox', { name: 'Folders' }) + .should('be.visible') + .click() + filesFilters.filterContainter() + .findByRole('button', { name: 'Type' }) + .click() + + // See that only the folder is visible + getRowForFile('folder').should('be.visible') + getRowForFile('file.txt').should('not.exist') + + // see filter is active + filesFilters.activeFilters().contains(/Folder/).should('be.visible') + + // go to that folder + navigateToFolder('folder') + + // see filter is still active + filesFilters.activeFilters().contains(/Folder/).should('be.visible') + + // see that the folder is filtered + getRowForFile('text.txt').should('not.exist') + }) + + /** Regression test of https://github.com/nextcloud/server/issues/47251 */ + it('keeps filter state when changing the directory', () => { + // files are visible + getRowForFile('folder').should('be.visible') + getRowForFile('file.txt').should('be.visible') + + // enable type filter for folders + filesFilters.filterContainter() + .findByRole('button', { name: 'Type' }) + .should('be.visible') + .click() + cy.findByRole('menuitemcheckbox', { name: 'Folders' }) + .should('be.visible') + .click() + // assert the button is checked + cy.findByRole('menuitemcheckbox', { name: 'Folders' }) + .should('have.attr', 'aria-checked', 'true') + // close the menu + filesFilters.filterContainter() + .findByRole('button', { name: 'Type' }) + .click() + + // See the chips are active + filesFilters.activeFilters() + .should('have.length', 1) + .contains(/Folder/).should('be.visible') + + // See that folder is visible but file not + getRowForFile('folder').should('be.visible') + getRowForFile('file.txt').should('not.exist') + + // Change the directory + navigateToFolder('folder') + getRowForFile('folder').should('not.exist') + + // See that the chip is still active + filesFilters.activeFilters() + .should('have.length', 1) + .contains(/Folder/).should('be.visible') + // And also the button should be active + filesFilters.filterContainter() + .findByRole('button', { name: 'Type' }) + .should('be.visible') + .click() + cy.findByRole('menuitemcheckbox', { name: 'Folders' }) + .should('be.visible') + .and('have.attr', 'aria-checked', 'true') + }) + + it('resets filter when changing the view', () => { + // All are visible by default + getRowForFile('folder').should('be.visible') + getRowForFile('file.txt').should('be.visible') + + // Set up a search query + appNavigation.searchInput() + .type('folder') + + // See that only the folder is visible + getRowForFile('folder').should('be.visible') + getRowForFile('file.txt').should('not.exist') + + // go to other view + appNavigation.views() + .findByRole('link', { name: /personal files/i }) + .click() + // wait for view changed + cy.url().should('match', /apps\/files\/personal/) + + // see that the folder is not filtered + getRowForFile('folder').should('be.visible') + getRowForFile('file.txt').should('be.visible') + + // see the filter bar is gone + appNavigation.searchInput().should('have.value', '') + }) +}) diff --git a/cypress/e2e/files/files-navigation.cy.ts b/cypress/e2e/files/files-navigation.cy.ts new file mode 100644 index 00000000000..4cc56990caf --- /dev/null +++ b/cypress/e2e/files/files-navigation.cy.ts @@ -0,0 +1,55 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/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') + }) +}) diff --git a/cypress/e2e/files/files-renaming.cy.ts b/cypress/e2e/files/files-renaming.cy.ts new file mode 100644 index 00000000000..ac1edb1e104 --- /dev/null +++ b/cypress/e2e/files/files-renaming.cy.ts @@ -0,0 +1,285 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { calculateViewportHeight, createFolder, getRowForFile, haveValidity, renameFile, triggerActionForFile } from './FilesUtils' + +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') + }) +}) diff --git a/cypress/e2e/files/files-searching.cy.ts b/cypress/e2e/files/files-searching.cy.ts deleted file mode 100644 index 10ca1b44e2f..00000000000 --- a/cypress/e2e/files/files-searching.cy.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/cypress' -import { getRowForFile, navigateToFolder } from './FilesUtils' -import { UnifiedSearchPage } from '../../pages/UnifiedSearch.ts' - -describe('files: Search and filter in files list', { testIsolation: true }, () => { - const unifiedSearch = new UnifiedSearchPage() - let user: User - - beforeEach(() => cy.createRandomUser().then(($user) => { - user = $user - - cy.mkdir(user, '/a folder') - cy.uploadContent(user, new Blob([]), 'text/plain', '/b file') - cy.uploadContent(user, new Blob([]), 'text/plain', '/a folder/c file') - cy.login(user) - cy.visit('/apps/files') - })) - - it('files app supports local search', () => { - unifiedSearch.openLocalSearch() - unifiedSearch.localSearchInput() - .should('not.have.css', 'display', 'none') - .and('not.be.disabled') - }) - - it('filters current view', () => { - // All are visible by default - getRowForFile('a folder').should('be.visible') - getRowForFile('b file').should('be.visible') - - // Set up a search query - unifiedSearch.openLocalSearch() - unifiedSearch.typeLocalSearch('a folder') - - // See that only the folder is visible - getRowForFile('a folder').should('be.visible') - getRowForFile('b file').should('not.exist') - }) - - it('resets filter when changeing the directory', () => { - // All are visible by default - getRowForFile('a folder').should('be.visible') - getRowForFile('b file').should('be.visible') - - // Set up a search query - unifiedSearch.openLocalSearch() - unifiedSearch.typeLocalSearch('a folder') - - // See that only the folder is visible - getRowForFile('a folder').should('be.visible') - getRowForFile('b file').should('not.exist') - - // go to that folder - navigateToFolder('a folder') - - // see that the folder is not filtered - getRowForFile('c file').should('be.visible') - }) - - it('resets filter when changeing the view', () => { - // All are visible by default - getRowForFile('a folder').should('be.visible') - getRowForFile('b file').should('be.visible') - - // Set up a search query - unifiedSearch.openLocalSearch() - unifiedSearch.typeLocalSearch('a folder') - - // See that only the folder is visible - getRowForFile('a folder').should('be.visible') - getRowForFile('b file').should('not.exist') - - // go to other view - cy.get('[data-cy-files-navigation-item="personal"] a').click({ force: true }) - // wait for view changed - cy.url().should('match', /apps\/files\/personal/) - - // see that the folder is not filtered - getRowForFile('a folder').should('be.visible') - getRowForFile('b file').should('be.visible') - - // see the filter bar is gone - unifiedSearch.localSearchInput().should('not.exist') - }) -}) diff --git a/cypress/e2e/files/files-selection.cy.ts b/cypress/e2e/files/files-selection.cy.ts new file mode 100644 index 00000000000..c50543a8c7c --- /dev/null +++ b/cypress/e2e/files/files-selection.cy.ts @@ -0,0 +1,77 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { deselectAllFiles, selectAllFiles, selectRowForFile } from './FilesUtils' + +const files = { + 'image.jpg': 'image/jpeg', + 'document.pdf': 'application/pdf', + 'archive.zip': 'application/zip', + 'audio.mp3': 'audio/mpeg', + 'video.mp4': 'video/mp4', + 'readme.md': 'text/markdown', + 'welcome.txt': 'text/plain', +} +const filesCount = Object.keys(files).length + +describe('files: Select all files', { testIsolation: true }, () => { + let user: User + + before(() => { + cy.createRandomUser().then(($user) => { + user = $user + Object.keys(files).forEach((file) => { + cy.uploadContent(user, new Blob(), files[file], '/' + file) + }) + }) + }) + + beforeEach(() => { + cy.login(user) + cy.visit('/apps/files') + }) + + it('Can select and unselect all files', () => { + cy.get('[data-cy-files-list-row-fileid]').should('have.length', filesCount) + cy.get('[data-cy-files-list-row-checkbox]').should('have.length', filesCount) + + selectAllFiles() + + cy.get('.files-list__selected').should('contain.text', '7 selected') + cy.get('[data-cy-files-list-row-checkbox]').findByRole('checkbox').should('be.checked') + + deselectAllFiles() + + cy.get('.files-list__selected').should('not.exist') + cy.get('[data-cy-files-list-row-checkbox]').findByRole('checkbox').should('not.be.checked') + }) + + it('Can select some files randomly', () => { + const randomFiles = Object.keys(files).reduce((acc, file) => { + if (Math.random() > 0.1) { + acc.push(file) + } + return acc + }, [] as string[]) + + randomFiles.forEach(name => selectRowForFile(name)) + + cy.get('.files-list__selected').should('contain.text', `${randomFiles.length} selected`) + cy.get('[data-cy-files-list-row-checkbox] input[type="checkbox"]:checked').should('have.length', randomFiles.length) + }) + + it('Can select range of files with shift key', () => { + cy.get('[data-cy-files-list-row-checkbox]').should('have.length', filesCount) + selectRowForFile('audio.mp3') + cy.window().trigger('keydown', { key: 'ShiftLeft', shiftKey: true }) + selectRowForFile('readme.md') + cy.window().trigger('keyup', { key: 'ShiftLeft', shiftKey: true }) + + cy.get('.files-list__selected').should('contain.text', '4 selected') + cy.get('[data-cy-files-list-row-checkbox] input[type="checkbox"]:checked').should('have.length', 4) + + }) +}) diff --git a/cypress/e2e/files/files-settings.cy.ts b/cypress/e2e/files/files-settings.cy.ts index 7f02cdf7f1b..b363e630b44 100644 --- a/cypress/e2e/files/files-settings.cy.ts +++ b/cypress/e2e/files/files-settings.cy.ts @@ -4,19 +4,63 @@ */ import type { User } from '@nextcloud/cypress' -import { getRowForFile } from './FilesUtils' -const showHiddenFiles = () => { - // Open the files settings - cy.get('[data-cy-files-navigation-settings-button] a').click({ force: true }) - // Toggle the hidden files setting - cy.get('[data-cy-files-settings-setting="show_hidden"]').within(() => { - cy.get('input').should('not.be.checked') - cy.get('input').check({ force: true }) +import { getRowForFile } from './FilesUtils.ts' + +describe('files: Set default view', { testIsolation: true }, () => { + beforeEach(() => { + cy.createRandomUser().then(($user) => { + cy.login($user) + }) }) - // Close the dialog - cy.get('[data-cy-files-navigation-settings] button[aria-label="Close"]').click() -} + + it('Defaults to the "files" view', () => { + cy.visit('/apps/files') + + // See URL and current view + cy.url().should('match', /\/apps\/files\/files/) + cy.get('[data-cy-files-content-breadcrumbs]') + .findByRole('button', { + name: 'All files', + description: 'Reload current directory', + }) + + // See the option is also selected + // Open the files settings + cy.findByRole('link', { name: 'Files settings' }).click({ force: true }) + // Toggle the setting + cy.findByRole('dialog', { name: 'Files settings' }) + .should('be.visible') + .within(() => { + cy.findByRole('group', { name: 'Default view' }) + .findByRole('radio', { name: 'All files' }) + .should('be.checked') + }) + }) + + it('Can set it to personal files', () => { + cy.visit('/apps/files') + + // Open the files settings + cy.findByRole('link', { name: 'Files settings' }).click({ force: true }) + // Toggle the setting + cy.findByRole('dialog', { name: 'Files settings' }) + .should('be.visible') + .within(() => { + cy.findByRole('group', { name: 'Default view' }) + .findByRole('radio', { name: 'Personal files' }) + .check({ force: true }) + }) + + cy.visit('/apps/files') + cy.url().should('match', /\/apps\/files\/personal/) + cy.get('[data-cy-files-content-breadcrumbs]') + .findByRole('button', { + name: 'Personal files', + description: 'Reload current directory', + }) + }) +}) describe('files: Hide or show hidden files', { testIsolation: true }, () => { let user: User @@ -97,3 +141,18 @@ describe('files: Hide or show hidden files', { testIsolation: true }, () => { }) }) }) + +/** + * Helper to toggle the hidden files settings + */ +function showHiddenFiles() { + // Open the files settings + cy.get('[data-cy-files-navigation-settings-button] a').click({ force: true }) + // Toggle the hidden files setting + cy.get('[data-cy-files-settings-setting="show_hidden"]').within(() => { + cy.get('input').should('not.be.checked') + cy.get('input').check({ force: true }) + }) + // Close the dialog + cy.get('[data-cy-files-navigation-settings] button[aria-label="Close"]').click() +} diff --git a/cypress/e2e/files/files-sidebar.cy.ts b/cypress/e2e/files/files-sidebar.cy.ts index a5dd9399c50..f5c4205c462 100644 --- a/cypress/e2e/files/files-sidebar.cy.ts +++ b/cypress/e2e/files/files-sidebar.cy.ts @@ -5,6 +5,7 @@ import type { User } from '@nextcloud/cypress' import { getRowForFile, navigateToFolder, triggerActionForFile } from './FilesUtils' +import { assertNotExistOrNotVisible } from '../settings/usersUtils' describe('Files: Sidebar', { testIsolation: true }, () => { let user: User @@ -26,7 +27,10 @@ describe('Files: Sidebar', { testIsolation: true }, () => { triggerActionForFile('file', 'details') - cy.get('[data-cy-sidebar]').should('be.visible') + cy.get('[data-cy-sidebar]') + .should('be.visible') + .findByRole('heading', { name: 'file' }) + .should('be.visible') }) it('changes the current fileid', () => { @@ -39,20 +43,63 @@ describe('Files: Sidebar', { testIsolation: true }, () => { cy.url().should('contain', `apps/files/files/${fileId}`) }) - it('closes the sidebar on delete', () => { + 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') + + 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') + 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') + // delete the file triggerActionForFile('file', 'delete') - cy.get('[data-cy-sidebar]').should('not.exist') + 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) @@ -69,6 +116,8 @@ describe('Files: Sidebar', { testIsolation: true }, () => { cy.url().should('contain', `apps/files/files/${otherFileId}`) triggerActionForFile('other', 'delete') + cy.wait('@deleteFile') + cy.get('[data-cy-sidebar]').should('not.exist') // Ensure the URL is changed cy.url().should('not.contain', `apps/files/files/${otherFileId}`) diff --git a/cypress/e2e/files/files_sorting.cy.ts b/cypress/e2e/files/files-sorting.cy.ts index 925f2f7f590..9e726bf96e1 100644 --- a/cypress/e2e/files/files_sorting.cy.ts +++ b/cypress/e2e/files/files-sorting.cy.ts @@ -41,6 +41,31 @@ describe('Files: Sorting the file list', { testIsolation: true }, () => { }) }) + /** + * Regression test of https://github.com/nextcloud/server/issues/45829 + */ + it('Filesnames with numbers are sorted by name ascending by default', () => { + cy.uploadContent(currentUser, new Blob(), 'text/plain', '/name.txt') + .uploadContent(currentUser, new Blob(), 'text/plain', '/name_03.txt') + .uploadContent(currentUser, new Blob(), 'text/plain', '/name_02.txt') + .uploadContent(currentUser, new Blob(), 'text/plain', '/name_01.txt') + cy.login(currentUser) + cy.visit('/apps/files') + + cy.get('[data-cy-files-list-row]').each(($row, index) => { + switch (index) { + case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('name.txt') + break + case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('name_01.txt') + break + case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('name_02.txt') + break + case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('name_03.txt') + break + } + }) + }) + it('Can sort by size', () => { cy.uploadContent(currentUser, new Blob(), 'text/plain', '/1 tiny.txt') .uploadContent(currentUser, new Blob(['a'.repeat(1024)]), 'text/plain', '/z big.txt') @@ -141,7 +166,6 @@ describe('Files: Sorting the file list', { testIsolation: true }, () => { cy.visit('/apps/files') cy.log('By name - ascending') - cy.get('th').contains('button', 'Name').click() cy.contains('th', 'Name').should('have.attr', 'aria-sort', 'ascending') cy.get('[data-cy-files-list-row]').each(($row, index) => { @@ -242,4 +266,65 @@ describe('Files: Sorting the file list', { testIsolation: true }, () => { } }) }) + + it('Sorting works after switching view twice', () => { + cy.uploadContent(currentUser, new Blob(), 'text/plain', '/1 tiny.txt') + .uploadContent(currentUser, new Blob(['a'.repeat(1024)]), 'text/plain', '/z big.txt') + .uploadContent(currentUser, new Blob(['a'.repeat(512)]), 'text/plain', '/a medium.txt') + .mkdir(currentUser, '/folder') + cy.login(currentUser) + cy.visit('/apps/files') + + // click sort button twice + cy.get('th').contains('button', 'Size').click() + cy.get('th').contains('button', 'Size').click() + + // switch to personal and click sort button twice again + cy.get('[data-cy-files-navigation-item="personal"]').click() + cy.get('th').contains('button', 'Size').click() + cy.get('th').contains('button', 'Size').click() + + // switch back to files view and do actual assertions + cy.get('[data-cy-files-navigation-item="files"]').click() + + // click sort button + cy.get('th').contains('button', 'Size').click() + // sorting is set + cy.contains('th', 'Size').should('have.attr', 'aria-sort', 'ascending') + // Files are sorted + cy.get('[data-cy-files-list-row]').each(($row, index) => { + switch (index) { + case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('folder') + break + case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('1 tiny.txt') + break + case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') + break + case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('a medium.txt') + break + case 4: expect($row.attr('data-cy-files-list-row-name')).to.eq('z big.txt') + break + } + }) + + // click sort button + cy.get('th').contains('button', 'Size').click() + // sorting is set + cy.contains('th', 'Size').should('have.attr', 'aria-sort', 'descending') + // Files are sorted + cy.get('[data-cy-files-list-row]').each(($row, index) => { + switch (index) { + case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('folder') + break + case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('z big.txt') + break + case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('a medium.txt') + break + case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') + break + case 4: expect($row.attr('data-cy-files-list-row-name')).to.eq('1 tiny.txt') + break + } + }) + }) }) diff --git a/cypress/e2e/files/files-xml-regression.cy.ts b/cypress/e2e/files/files-xml-regression.cy.ts index 15e7b61f6bf..a961b78e2f4 100644 --- a/cypress/e2e/files/files-xml-regression.cy.ts +++ b/cypress/e2e/files/files-xml-regression.cy.ts @@ -40,7 +40,7 @@ describe('Files: Can handle XML entities in file names', { testIsolation: false triggerActionForFile('&.txt', 'delete') cy.wait('@deleteFile') - cy.contains('.toast-success', /Delete .* successfull/) + cy.contains('.toast-success', /Delete .* done/) .should('be.visible') getRowForFile('&.txt').should('not.exist') diff --git a/cypress/e2e/files/files.cy.ts b/cypress/e2e/files/files.cy.ts index 900be849d9b..efae1116d2d 100644 --- a/cypress/e2e/files/files.cy.ts +++ b/cypress/e2e/files/files.cy.ts @@ -1,16 +1,58 @@ +import type { User } from "@nextcloud/cypress" + /** * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ describe('Files', { testIsolation: true }, () => { + let currentUser: User + beforeEach(() => { cy.createRandomUser().then((user) => { - cy.login(user) + currentUser = user }) }) it('Login with a user and open the files app', () => { + cy.login(currentUser) cy.visit('/apps/files') cy.get('[data-cy-files-list] [data-cy-files-list-row-name="welcome.txt"]').should('be.visible') }) + + it('Opens a valid file shows it as active', () => { + cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt').then((response) => { + const fileId = Number.parseInt(response.headers['oc-fileid'] ?? '0') + + cy.login(currentUser) + cy.visit('/apps/files/files/' + fileId) + + cy.get(`[data-cy-files-list-row-fileid=${fileId}]`) + .should('be.visible') + cy.get(`[data-cy-files-list-row-fileid=${fileId}]`) + .invoke('attr', 'data-cy-files-list-row-name').should('eq', 'original.txt') + cy.get(`[data-cy-files-list-row-fileid=${fileId}]`) + .invoke('attr', 'class').should('contain', 'active') + cy.contains('The file could not be found').should('not.exist') + }) + }) + + it('Opens a valid folder shows its content', () => { + cy.mkdir(currentUser, '/folder').then(() => { + cy.login(currentUser) + cy.visit('/apps/files/files?dir=/folder') + + cy.get('[data-cy-files-content-breadcrumbs]').contains('folder').should('be.visible') + cy.contains('The file could not be found').should('not.exist') + }) + }) + + it('Opens an unknown file show an error', () => { + cy.intercept('PROPFIND', /\/remote.php\/dav\//).as('propfind') + cy.login(currentUser) + cy.visit('/apps/files/files/123456') + + cy.wait('@propfind') + // The toast should be visible + cy.contains('The file could not be found', { timeout: 5000 }).should('be.visible') + }) }) diff --git a/cypress/e2e/files/live_photos.cy.ts b/cypress/e2e/files/live_photos.cy.ts index 659cdc544ed..8eb4efaaec0 100644 --- a/cypress/e2e/files/live_photos.cy.ts +++ b/cypress/e2e/files/live_photos.cy.ts @@ -4,75 +4,34 @@ */ import type { User } from '@nextcloud/cypress' -import { clickOnBreadcrumbs, closeSidebar, copyFile, getRowForFile, getRowForFileId, renameFile, triggerActionForFile, triggerInlineActionForFileId } from './FilesUtils' - -/** - * - * @param user - * @param fileName - * @param domain - * @param requesttoken - * @param metadata - */ -function setMetadata(user: User, fileName: string, domain: string, requesttoken: string, metadata: object) { - cy.request({ - method: 'PROPPATCH', - url: `http://${domain}/remote.php/dav/files/${user.userId}/${fileName}`, - auth: { user: user.userId, pass: user.password }, - headers: { - requesttoken, - }, - body: `<?xml version="1.0"?> - <d:propertyupdate xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns"> - <d:set> - <d:prop> - ${Object.entries(metadata).map(([key, value]) => `<${key}>${value}</${key}>`).join('\n')} - </d:prop> - </d:set> - </d:propertyupdate>`, - }) -} +import { + clickOnBreadcrumbs, + copyFile, + createFolder, + getRowForFile, + getRowForFileId, + moveFile, + navigateToFolder, + renameFile, + triggerActionForFile, + triggerInlineActionForFileId, +} from './FilesUtils' +import { setShowHiddenFiles, setupLivePhotos } from './LivePhotosUtils' describe('Files: Live photos', { testIsolation: true }, () => { - let currentUser: User + let user: User let randomFileName: string let jpgFileId: number let movFileId: number - let hostname: string - let requesttoken: string - - before(() => { - cy.createRandomUser().then((user) => { - currentUser = user - cy.login(currentUser) - cy.visit('/apps/files') - }) - - cy.url().then(url => { hostname = new URL(url).hostname }) - }) beforeEach(() => { - randomFileName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) - - cy.uploadContent(currentUser, new Blob(['jpg file'], { type: 'image/jpg' }), 'image/jpg', `/${randomFileName}.jpg`) - .then(response => { jpgFileId = parseInt(response.headers['oc-fileid']) }) - cy.uploadContent(currentUser, new Blob(['mov file'], { type: 'video/mov' }), 'video/mov', `/${randomFileName}.mov`) - .then(response => { movFileId = parseInt(response.headers['oc-fileid']) }) - - cy.login(currentUser) - cy.visit('/apps/files') - - cy.get('head').invoke('attr', 'data-requesttoken').then(_requesttoken => { requesttoken = _requesttoken as string }) - - cy.then(() => { - setMetadata(currentUser, `${randomFileName}.jpg`, hostname, requesttoken, { 'nc:metadata-files-live-photo': movFileId }) - setMetadata(currentUser, `${randomFileName}.mov`, hostname, requesttoken, { 'nc:metadata-files-live-photo': jpgFileId }) - }) - - cy.then(() => { - cy.visit(`/apps/files/files/${jpgFileId}`) // Refresh and scroll to the .jpg file. - closeSidebar() - }) + setupLivePhotos() + .then((setupInfo) => { + user = setupInfo.user + randomFileName = setupInfo.fileName + jpgFileId = setupInfo.jpgFileId + movFileId = setupInfo.movFileId + }) }) it('Only renders the .jpg file', () => { @@ -81,12 +40,8 @@ describe('Files: Live photos', { testIsolation: true }, () => { }) context("'Show hidden files' is enabled", () => { - before(() => { - cy.login(currentUser) - cy.visit('/apps/files') - cy.get('[data-cy-files-navigation-settings-button]').click() - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('[data-cy-files-settings-setting="show_hidden"] input').check({ force: true }) + beforeEach(() => { + setShowHiddenFiles(true) }) it("Shows both files when 'Show hidden files' is enabled", () => { @@ -113,6 +68,35 @@ describe('Files: Live photos', { testIsolation: true }, () => { getRowForFile(`${randomFileName} (copy).mov`).should('have.length', 1) }) + it('Keeps live photo link when copying folder', () => { + createFolder('folder') + moveFile(`${randomFileName}.jpg`, 'folder') + copyFile('folder', '.') + navigateToFolder('folder (copy)') + + getRowForFile(`${randomFileName}.jpg`).should('have.length', 1) + getRowForFile(`${randomFileName}.mov`).should('have.length', 1) + + setShowHiddenFiles(false) + + getRowForFile(`${randomFileName}.jpg`).should('have.length', 1) + getRowForFile(`${randomFileName}.mov`).should('have.length', 0) + }) + + it('Block copying live photo in a folder containing a mov file with the same name', () => { + createFolder('folder') + cy.uploadContent(user, new Blob(['mov file'], { type: 'video/mov' }), 'video/mov', `/folder/${randomFileName}.mov`) + cy.login(user) + cy.visit('/apps/files') + copyFile(`${randomFileName}.jpg`, 'folder') + navigateToFolder('folder') + + cy.get('[data-cy-files-list-row-fileid]').should('have.length', 1) + getRowForFile(`${randomFileName}.mov`).should('have.length', 1) + getRowForFile(`${randomFileName}.jpg`).should('have.length', 0) + getRowForFile(`${randomFileName} (copy).jpg`).should('have.length', 0) + }) + it('Moves files when moving the .jpg', () => { renameFile(`${randomFileName}.jpg`, `${randomFileName}_moved.jpg`) clickOnBreadcrumbs('All files') diff --git a/cypress/e2e/files/new-menu.cy.ts b/cypress/e2e/files/new-menu.cy.ts new file mode 100644 index 00000000000..dfe586fa073 --- /dev/null +++ b/cypress/e2e/files/new-menu.cy.ts @@ -0,0 +1,123 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { createFolder, getRowForFile, haveValidity, navigateToFolder } from './FilesUtils' + +describe('"New"-menu', { testIsolation: true }, () => { + + beforeEach(() => { + cy.createRandomUser().then(($user) => { + cy.login($user) + cy.visit('/apps/files') + }) + }) + + it('Create new folder', () => { + // Click the "new" button + cy.get('[data-cy-upload-picker]') + .findByRole('button', { name: 'New' }) + .should('be.visible') + .click() + // Click the "new folder" menu entry + cy.findByRole('menuitem', { name: 'New folder' }) + .should('be.visible') + .click() + // Create a folder + cy.intercept('MKCOL', '**/remote.php/dav/files/**').as('mkdir') + cy.findByRole('dialog', { name: /create new folder/i }) + .findByRole('textbox', { name: 'Folder name' }) + .type('A new folder{enter}') + cy.wait('@mkdir') + // See the folder is visible + getRowForFile('A new folder') + .should('be.visible') + }) + + it('Does not allow creating forbidden folder names', () => { + // Click the "new" button + cy.get('[data-cy-upload-picker]') + .findByRole('button', { name: 'New' }) + .should('be.visible') + .click() + // Click the "new folder" menu entry + cy.findByRole('menuitem', { name: 'New folder' }) + .should('be.visible') + .click() + // enter folder name + cy.findByRole('dialog', { name: /create new folder/i }) + .findByRole('textbox', { name: 'Folder name' }) + .type('.htaccess') + // See that input has invalid state set + cy.findByRole('dialog', { name: /create new folder/i }) + .findByRole('textbox', { name: 'Folder name' }) + .should(haveValidity(/reserved name/i)) + // See that it can not create + cy.findByRole('dialog', { name: /create new folder/i }) + .findByRole('button', { name: 'Create' }) + .should('be.disabled') + }) + + it('Does not allow creating folders with already existing names', () => { + createFolder('already exists') + // Click the "new" button + cy.get('[data-cy-upload-picker]') + .findByRole('button', { name: 'New' }) + .should('be.visible') + .click() + // Click the "new folder" menu entry + cy.findByRole('menuitem', { name: 'New folder' }) + .should('be.visible') + .click() + // enter folder name + cy.findByRole('dialog', { name: /create new folder/i }) + .findByRole('textbox', { name: 'Folder name' }) + .type('already exists') + // See that input has invalid state set + cy.findByRole('dialog', { name: /create new folder/i }) + .findByRole('textbox', { name: 'Folder name' }) + .should(haveValidity(/already in use/i)) + // See that it can not create + cy.findByRole('dialog', { name: /create new folder/i }) + .findByRole('button', { name: 'Create' }) + .should('be.disabled') + }) + + /** + * Regression test of https://github.com/nextcloud/server/issues/47530 + */ + it('Create same folder in child folder', () => { + // setup other folders + createFolder('folder') + createFolder('other folder') + navigateToFolder('folder') + + // Click the "new" button + cy.get('[data-cy-upload-picker]') + .findByRole('button', { name: 'New' }) + .should('be.visible') + .click() + // Click the "new folder" menu entry + cy.findByRole('menuitem', { name: 'New folder' }) + .should('be.visible') + .click() + // enter folder name + cy.findByRole('dialog', { name: /create new folder/i }) + .findByRole('textbox', { name: 'Folder name' }) + .type('other folder') + // See that creating is allowed + cy.findByRole('dialog', { name: /create new folder/i }) + .findByRole('textbox', { name: 'Folder name' }) + .should(haveValidity('')) + // can create + cy.intercept('MKCOL', '**/remote.php/dav/files/**').as('mkdir') + cy.findByRole('dialog', { name: /create new folder/i }) + .findByRole('button', { name: 'Create' }) + .click() + cy.wait('@mkdir') + // see it is created + getRowForFile('other folder') + .should('be.visible') + }) +}) diff --git a/cypress/e2e/files/recent-view.cy.ts b/cypress/e2e/files/recent-view.cy.ts new file mode 100644 index 00000000000..64eeca9a085 --- /dev/null +++ b/cypress/e2e/files/recent-view.cy.ts @@ -0,0 +1,44 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { getRowForFile, triggerActionForFile } from './FilesUtils' + +describe('files: Recent view', { testIsolation: true }, () => { + let user: User + + beforeEach(() => cy.createRandomUser().then(($user) => { + user = $user + + cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt') + cy.login(user) + })) + + it('see the recently created file in the recent view', () => { + cy.visit('/apps/files/recent') + // All are visible by default + getRowForFile('file.txt').should('be.visible') + }) + + /** + * Regression test: There was a bug that the files were correctly loaded but with invalid source + * so the delete action failed. + */ + it('can delete a file in the recent view', () => { + cy.intercept('DELETE', '**/remote.php/dav/files/**').as('deleteFile') + + cy.visit('/apps/files/recent') + // See the row + getRowForFile('file.txt').should('be.visible') + // delete the file + triggerActionForFile('file.txt', 'delete') + cy.wait('@deleteFile') + // See it is not visible anymore + getRowForFile('file.txt').should('not.exist') + // also not existing in default view after reload + cy.visit('/apps/files') + getRowForFile('file.txt').should('not.exist') + }) +}) diff --git a/cypress/e2e/files/router-query.cy.ts b/cypress/e2e/files/router-query.cy.ts new file mode 100644 index 00000000000..9c6564c8ecf --- /dev/null +++ b/cypress/e2e/files/router-query.cy.ts @@ -0,0 +1,180 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { join } from 'path' +import { getRowForFileId } from './FilesUtils.ts' + +/** + * Check that the sidebar is opened for a specific file + * @param name The name of the file + */ +function sidebarIsOpen(name: string): void { + cy.get('[data-cy-sidebar]') + .should('be.visible') + .findByRole('heading', { name }) + .should('be.visible') +} + +/** + * Skip a test without viewer installed + */ +function skipIfViewerDisabled(this: Mocha.Context): void { + cy.runOccCommand('app:list --enabled --output json') + .then((exec) => exec.stdout) + .then((output) => JSON.parse(output)) + .then((obj) => 'viewer' in obj.enabled) + .then((enabled) => { + if (!enabled) { + this.skip() + } + }) +} + +/** + * Check a file was not downloaded + * @param filename The expected filename + */ +function fileNotDownloaded(filename: string): void { + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(join(downloadsFolder, filename)).should('not.exist') +} + +describe('Check router query flags:', function() { + let user: User + let imageId: number + let archiveId: number + let folderId: number + + before(() => { + cy.createRandomUser().then(($user) => { + user = $user + cy.uploadFile(user, 'image.jpg') + .then((response) => { imageId = Number.parseInt(response.headers['oc-fileid']) }) + cy.mkdir(user, '/folder') + .then((response) => { folderId = Number.parseInt(response.headers['oc-fileid']) }) + cy.uploadContent(user, new Blob([]), 'application/zstd', '/archive.zst') + .then((response) => { archiveId = Number.parseInt(response.headers['oc-fileid']) }) + cy.login(user) + }) + }) + + describe('"opendetails"', () => { + it('open details for known file type', () => { + cy.visit(`/apps/files/files/${imageId}?opendetails`) + + // see sidebar + sidebarIsOpen('image.jpg') + + // but no viewer + cy.findByRole('dialog', { name: 'image.jpg' }) + .should('not.exist') + + // and no download + fileNotDownloaded('image.jpg') + }) + + it('open details for unknown file type', () => { + cy.visit(`/apps/files/files/${archiveId}?opendetails`) + + // see sidebar + sidebarIsOpen('archive.zst') + + // but no viewer + cy.findByRole('dialog', { name: 'archive.zst' }) + .should('not.exist') + + // and no download + fileNotDownloaded('archive.zst') + }) + + it('open details for folder', () => { + cy.visit(`/apps/files/files/${folderId}?opendetails`) + + // see sidebar + sidebarIsOpen('folder') + + // but no viewer + cy.findByRole('dialog', { name: 'folder' }) + .should('not.exist') + + // and no download + fileNotDownloaded('folder') + }) + }) + + describe('"openfile"', function() { + /** Check the viewer is open and shows the image */ + function viewerShowsImage(): void { + cy.findByRole('dialog', { name: 'image.jpg' }) + .should('be.visible') + .find(`img[src*="fileId=${imageId}"]`) + .should('be.visible') + } + + it('opens files with default action', function() { + skipIfViewerDisabled.call(this) + + cy.visit(`/apps/files/files/${imageId}?openfile`) + viewerShowsImage() + }) + + it('opens files with default action using explicit query state', function() { + skipIfViewerDisabled.call(this) + + cy.visit(`/apps/files/files/${imageId}?openfile=true`) + viewerShowsImage() + }) + + it('does not open files with default action when using explicitly query value `false`', function() { + skipIfViewerDisabled.call(this) + + cy.visit(`/apps/files/files/${imageId}?openfile=false`) + getRowForFileId(imageId) + .should('be.visible') + .and('have.class', 'files-list__row--active') + + cy.findByRole('dialog', { name: 'image.jpg' }) + .should('not.exist') + }) + + it('does not open folders but shows details', () => { + cy.visit(`/apps/files/files/${folderId}?openfile`) + + // See the URL was replaced + cy.url() + .should('match', /[?&]opendetails(&|=|$)/) + .and('not.match', /openfile/) + + // See the sidebar is correctly opened + cy.get('[data-cy-sidebar]') + .should('be.visible') + .findByRole('heading', { name: 'folder' }) + .should('be.visible') + + // see the folder was not changed + getRowForFileId(imageId).should('exist') + }) + + it('does not open unknown file types but shows details', () => { + cy.visit(`/apps/files/files/${archiveId}?openfile`) + + // See the URL was replaced + cy.url() + .should('match', /[?&]opendetails(&|=|$)/) + .and('not.match', /openfile/) + + // See the sidebar is correctly opened + cy.get('[data-cy-sidebar]') + .should('be.visible') + .findByRole('heading', { name: 'archive.zst' }) + .should('be.visible') + + // See no file was downloaded + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(join(downloadsFolder, 'archive.zst')).should('not.exist') + }) + }) +}) diff --git a/cypress/e2e/files/scrolling.cy.ts b/cypress/e2e/files/scrolling.cy.ts new file mode 100644 index 00000000000..f7a4ef683f5 --- /dev/null +++ b/cypress/e2e/files/scrolling.cy.ts @@ -0,0 +1,284 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { calculateViewportHeight, enableGridMode, getRowForFile } from './FilesUtils.ts' +import { beFullyInViewport, notBeFullyInViewport } from '../core-utils.ts' + +describe('files: Scrolling to selected file in file list', () => { + const fileIds = new Map<number, string>() + let viewportHeight: number + + before(() => { + initFilesAndViewport(fileIds) + .then((_viewportHeight) => { + cy.log(`Saving viewport height to ${_viewportHeight}px`) + viewportHeight = _viewportHeight + }) + }) + + beforeEach(() => { + cy.viewport(1200, viewportHeight) + }) + + it('Can see first file in list', () => { + cy.visit(`/apps/files/files/${fileIds.get(1)}`) + + // See file is visible + getRowForFile('1.txt') + .should('be.visible') + + // we expect also element 6 to be visible + getRowForFile('6.txt') + .should('be.visible') + // but not element 7 - though it should exist (be buffered) + getRowForFile('7.txt') + .should('exist') + .and('not.be.visible') + }) + + // Same kind of tests for partially visible top and bottom + for (let i = 2; i <= 5; i++) { + it(`correctly scrolls to row ${i}`, () => { + cy.visit(`/apps/files/files/${fileIds.get(i)}`) + + // See file is visible + getRowForFile(`${i}.txt`) + .should('be.visible') + .and(notBeOverlappedByTableHeader) + + // we expect also element +4 to be visible + // (6 visible rows -> 5 without our scrolled row -> so we only have 4 fully visible others + two 1/2 hidden rows) + getRowForFile(`${i + 4}.txt`) + .should('be.visible') + // but not element -1 or +5 - though it should exist (be buffered) + getRowForFile(`${i - 1}.txt`) + .should('exist') + .and(beOverlappedByTableHeader) + getRowForFile(`${i + 5}.txt`) + .should('exist') + .and(notBeFullyInViewport) + }) + } + + // this will have half of the footer visible and half of the previous element + it('correctly scrolls to row 6', () => { + cy.visit(`/apps/files/files/${fileIds.get(6)}`) + + // See file is visible + getRowForFile('6.txt') + .should('be.visible') + .and(notBeOverlappedByTableHeader) + + // we expect also element 7,8,9,10 visible + getRowForFile('10.txt') + .should('be.visible') + // but not row 5 + getRowForFile('5.txt') + .should('exist') + .and(beOverlappedByTableHeader) + // see footer is only shown partly + cy.get('tfoot') + .should('exist') + .and(notBeFullyInViewport) + .contains('10 files') + .should('be.visible') + }) + + // For the last "page" of entries we can not scroll further + // so we show all of the last 4 entries + for (let i = 7; i <= 10; i++) { + it(`correctly scrolls to row ${i}`, () => { + cy.visit(`/apps/files/files/${fileIds.get(i)}`) + + // See file is visible + getRowForFile(`${i}.txt`) + .should('be.visible') + .and(notBeOverlappedByTableHeader) + + // there are only max. 4 rows left so also row 6+ should be visible + getRowForFile('6.txt') + .should('be.visible') + getRowForFile('10.txt') + .should('be.visible') + // Also the footer is visible + cy.get('tfoot') + .contains('10 files') + .should(beFullyInViewport) + }) + } +}) + +describe('files: Scrolling to selected file in file list (GRID MODE)', () => { + const fileIds = new Map<number, string>() + let viewportHeight: number + + before(() => { + initFilesAndViewport(fileIds, true) + .then((_viewportHeight) => { viewportHeight = _viewportHeight }) + }) + + beforeEach(() => { + cy.viewport(768, viewportHeight) + }) + + // First row + for (let i = 1; i <= 3; i++) { + it(`Can see files in first row (file ${i})`, () => { + cy.visit(`/apps/files/files/${fileIds.get(i)}`) + + for (let j = 1; j <= 3; j++) { + // See all files of that row are visible + getRowForFile(`${j}.txt`) + .should('be.visible') + // we expect also the second row to be visible + getRowForFile(`${j + 3}.txt`) + .should('be.visible') + // Because there is no half row on top we also see the third row + getRowForFile(`${j + 6}.txt`) + .should('be.visible') + // But not the forth row + getRowForFile(`${j + 9}.txt`) + .should('exist') + .and(notBeFullyInViewport) + } + }) + } + + // Second row + // Same kind of tests for partially visible top and bottom + for (let i = 4; i <= 6; i++) { + it(`correctly scrolls to second row (file ${i})`, () => { + cy.visit(`/apps/files/files/${fileIds.get(i)}`) + + // See all three files of that row are visible + for (let j = 4; j <= 6; j++) { + getRowForFile(`${j}.txt`) + .should('be.visible') + .and(notBeOverlappedByTableHeader) + // we expect also the next row to be visible + getRowForFile(`${j + 3}.txt`) + .should('be.visible') + // but not the row below (should be half cut) + getRowForFile(`${j + 6}.txt`) + .should('exist') + .and(notBeFullyInViewport) + // Same for the row above + getRowForFile(`${j - 3}.txt`) + .should('exist') + .and(beOverlappedByTableHeader) + } + }) + } + + // Third row + // this will have half of the footer visible and half of the previous row + for (let i = 7; i <= 9; i++) { + it(`correctly scrolls to third row (file ${i})`, () => { + cy.visit(`/apps/files/files/${fileIds.get(i)}`) + + // See all three files of that row are visible + for (let j = 7; j <= 9; j++) { + getRowForFile(`${j}.txt`) + .should('be.visible') + // we expect also the next row to be visible + getRowForFile(`${j + 3}.txt`) + .should('be.visible') + // but not the row above + getRowForFile(`${j - 3}.txt`) + .should('exist') + .and(beOverlappedByTableHeader) + } + + cy.get('tfoot') + .contains('span', '12 files') + .should('be.visible') + }) + } + + // Forth row which only has row 4 and 3 visible and the full footer + for (let i = 10; i <= 12; i++) { + it(`correctly scrolls to forth row (file ${i})`, () => { + cy.visit(`/apps/files/files/${fileIds.get(i)}`) + + // See all three files of that row are visible + for (let j = 10; j <= 12; j++) { + getRowForFile(`${j}.txt`) + .should('be.visible') + .and(notBeOverlappedByTableHeader) + // we expect also the row above to be visible + getRowForFile(`${j - 3}.txt`) + .should('be.visible') + } + + // see footer is shown + cy.get('tfoot') + .contains('.files-list__row-name', '12 files') + .should(beFullyInViewport) + }) + } +}) + +/// Some helpers + +/** + * Assert that an element is overlapped by the table header + * @param $el The element + * @param expected if it should be overlapped or NOT + */ +function beOverlappedByTableHeader($el: JQuery<HTMLElement>, expected = true) { + const headerRect = Cypress.$('thead').get(0)!.getBoundingClientRect() + const elementRect = $el.get(0)!.getBoundingClientRect() + const overlap = !(headerRect.right < elementRect.left + || headerRect.left > elementRect.right + || headerRect.bottom < elementRect.top + || headerRect.top > elementRect.bottom) + + if (expected) { + // eslint-disable-next-line no-unused-expressions + expect(overlap, 'Overlapped by table header').to.be.true + } else { + // eslint-disable-next-line no-unused-expressions + expect(overlap, 'Not overlapped by table header').to.be.false + } +} + +/** + * Assert that an element is not overlapped by the table header + * @param $el The element + */ +function notBeOverlappedByTableHeader($el: JQuery<HTMLElement>) { + return beOverlappedByTableHeader($el, false) +} + +function initFilesAndViewport(fileIds: Map<number, string>, gridMode = false): Cypress.Chainable<number> { + return cy.createRandomUser().then((user) => { + cy.rm(user, '/welcome.txt') + + // Create files with names 1.txt, 2.txt, ..., 10.txt + const count = gridMode ? 12 : 10 + for (let i = 1; i <= count; i++) { + cy.uploadContent(user, new Blob([]), 'text/plain', `/${i}.txt`) + .then((response) => fileIds.set(i, Number.parseInt(response.headers['oc-fileid']).toString())) + } + + cy.login(user) + cy.viewport(1200, 800) + + cy.visit('/apps/files') + + // If grid mode is requested, enable it + if (gridMode) { + enableGridMode() + } + + // Calculate height to ensure that those 10 elements can not be rendered in one list (only 6 will fit the screen, 3 in grid mode) + return calculateViewportHeight(gridMode ? 3 : 6) + .then((height) => { + // Set viewport height to the calculated height + cy.log(`Setting viewport height to ${height}px`) + cy.wrap(height) + }) + }) +} diff --git a/cypress/e2e/files/search.cy.ts b/cypress/e2e/files/search.cy.ts new file mode 100644 index 00000000000..3b5d455fd6c --- /dev/null +++ b/cypress/e2e/files/search.cy.ts @@ -0,0 +1,217 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { FilesNavigationPage } from '../../pages/FilesNavigation' +import { getRowForFile, navigateToFolder } from './FilesUtils' + +describe('files: search', () => { + + let user: User + + const navigation = new FilesNavigationPage() + + before(() => { + cy.createRandomUser().then(($user) => { + user = $user + cy.mkdir(user, '/some folder') + cy.mkdir(user, '/some folder/nested folder') + cy.mkdir(user, '/other folder') + cy.mkdir(user, '/12345') + cy.uploadContent(user, new Blob(['content']), 'text/plain', '/file.txt') + cy.uploadContent(user, new Blob(['content']), 'text/plain', '/some folder/a file.txt') + cy.uploadContent(user, new Blob(['content']), 'text/plain', '/some folder/a second file.txt') + cy.uploadContent(user, new Blob(['content']), 'text/plain', '/some folder/nested folder/deep file.txt') + cy.uploadContent(user, new Blob(['content']), 'text/plain', '/other folder/another file.txt') + cy.login(user) + }) + }) + + beforeEach(() => { + cy.visit('/apps/files') + }) + + it('updates the query on the URL', () => { + navigation.searchScopeTrigger().click() + navigation.searchScopeMenu() + .should('be.visible') + .findByRole('menuitem', { name: /search everywhere/i }) + .should('be.visible') + .click() + + navigation.searchInput().type('file') + cy.url().should('match', /query=file($|&)/) + }) + + it('can search globally', () => { + navigation.searchScopeTrigger().click() + navigation.searchScopeMenu() + .should('be.visible') + .findByRole('menuitem', { name: /search everywhere/i }) + .should('be.visible') + .click() + navigation.searchInput().type('file') + + getRowForFile('file.txt').should('be.visible') + getRowForFile('a file.txt').should('be.visible') + getRowForFile('a second file.txt').should('be.visible') + getRowForFile('another file.txt').should('be.visible') + }) + + it('filter does also search locally', () => { + navigateToFolder('some folder') + getRowForFile('a file.txt').should('be.visible') + + navigation.searchInput().type('file') + + getRowForFile('a file.txt').should('be.visible') + getRowForFile('a second file.txt').should('be.visible') + getRowForFile('deep file.txt').should('be.visible') + cy.get('[data-cy-files-list-row-fileid]').should('have.length', 3) + }) + + it('See "search everywhere" button', () => { + // Not visible initially + cy.get('[data-cy-files-filters]') + .findByRole('button', { name: /Search everywhere/i }) + .should('not.to.exist') + + // add a filter + navigation.searchInput().type('file') + + // see its visible + cy.get('[data-cy-files-filters]') + .findByRole('button', { name: /Search everywhere/i }) + .should('be.visible') + + // clear the filter + navigation.searchClearButton().click() + + // see its not visible again + cy.get('[data-cy-files-filters]') + .findByRole('button', { name: /Search everywhere/i }) + .should('not.to.exist') + }) + + it('can make local search a global search', () => { + navigateToFolder('some folder') + getRowForFile('a file.txt').should('be.visible') + + navigation.searchInput().type('file') + + // see local results + getRowForFile('a file.txt').should('be.visible') + getRowForFile('a second file.txt').should('be.visible') + getRowForFile('deep file.txt').should('be.visible') + cy.get('[data-cy-files-list-row-fileid]').should('have.length', 3) + + // toggle global search + cy.get('[data-cy-files-filters]') + .findByRole('button', { name: /Search everywhere/i }) + .should('be.visible') + .click() + + // see global results + getRowForFile('file.txt').should('be.visible') + getRowForFile('a file.txt').should('be.visible') + getRowForFile('deep file.txt').should('be.visible') + getRowForFile('a second file.txt').should('be.visible') + getRowForFile('another file.txt').should('be.visible') + }) + + it('shows empty content when there are no results', () => { + navigateToFolder('some folder') + getRowForFile('a file.txt').should('be.visible') + + navigation.searchScopeTrigger().click() + navigation.searchScopeMenu() + .should('be.visible') + .findByRole('menuitem', { name: /search everywhere/i }) + .should('be.visible') + .click() + navigation.searchInput().type('xyz') + + // see the empty content message + cy.contains('[role="note"]', /No search results for .xyz./) + .should('be.visible') + .within(() => { + // see within there is a search box with the same value + cy.findByRole('searchbox', { name: /search for files/i }) + .should('be.visible') + .and('have.value', 'xyz') + }) + }) + + it('can alter search', () => { + navigation.searchScopeTrigger().click() + navigation.searchScopeMenu() + .should('be.visible') + .findByRole('menuitem', { name: /search everywhere/i }) + .should('be.visible') + .click() + navigation.searchInput().type('other') + + getRowForFile('another file.txt').should('be.visible') + getRowForFile('other folder').should('be.visible') + cy.get('[data-cy-files-list-row-fileid]').should('have.length', 2) + + navigation.searchInput().type(' file') + navigation.searchInput().should('have.value', 'other file') + getRowForFile('another file.txt').should('be.visible') + cy.get('[data-cy-files-list-row-fileid]').should('have.length', 1) + }) + + it('returns to file list if search is cleared', () => { + navigation.searchScopeTrigger().click() + navigation.searchScopeMenu() + .should('be.visible') + .findByRole('menuitem', { name: /search everywhere/i }) + .should('be.visible') + .click() + navigation.searchInput().type('other') + + getRowForFile('another file.txt').should('be.visible') + getRowForFile('other folder').should('be.visible') + cy.get('[data-cy-files-list-row-fileid]').should('have.length', 2) + + navigation.searchClearButton().click() + navigation.searchInput().should('have.value', '') + getRowForFile('file.txt').should('be.visible') + cy.get('[data-cy-files-list-row-fileid]').should('have.length', 5) + }) + + /** + * Problem: + * 1. Being on the search view + * 2. Press the refresh button (name of the current view) + * 3. See that the router link does not preserve the query + * + * We fix this with a navigation guard and need to verify that it works + */ + it('keeps the query in the URL', () => { + navigation.searchScopeTrigger().click() + navigation.searchScopeMenu() + .should('be.visible') + .findByRole('menuitem', { name: /search everywhere/i }) + .should('be.visible') + .click() + navigation.searchInput().type('file') + + // see that the search view is loaded + getRowForFile('a file.txt').should('be.visible') + // see the correct url + cy.url().should('match', /query=file($|&)/) + + cy.intercept('SEARCH', '**/remote.php/dav/').as('search') + // refresh the view + cy.findByRole('button', { description: /reload current directory/i }).click() + // wait for the request + cy.wait('@search') + // see that the search view is reloaded + getRowForFile('a file.txt').should('be.visible') + // see the correct url + cy.url().should('match', /query=file($|&)/) + }) +}) |