diff --git a/cypress/e2e/files/files-download.cy.ts b/cypress/e2e/files/files-download.cy.ts deleted file mode 100644 index c811a4a4a8e..00000000000 --- a/cypress/e2e/files/files-download.cy.ts +++ /dev/null @@ -1,333 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { zipFileContains } from '../../support/utils/assertions.ts' -import { deleteDownloadsFolderBeforeEach } from '../../support/utils/deleteDownloadsFolder.ts' -import { randomString } from '../../support/utils/randomString.ts' -import { getRowForFile, navigateToFolder, triggerActionForFile, triggerSelectionAction } from './FilesUtils.ts' - -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(['']), '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', '') - }) - - it('can download folder', () => { - cy.mkdir(user, '/subfolder') - cy.uploadContent(user, new Blob(['']), '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(['']), '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', '') - }) - - /** - * 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(['']), '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', '') - }) -}) - -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(['']), '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', '') - }) - - /** - * Regression test of https://github.com/nextcloud/server/issues/44855 - */ - it('can download file with hash name', () => { - cy.uploadContent(user, new Blob(['']), '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', '') - }) - - /** - * 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(['']), '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', '') - }) -}) - -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(['']), '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 - triggerSelectionAction('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', - ])) - }) - - it('can download multiple selected files', () => { - cy.createRandomUser().then((user) => { - cy.uploadContent(user, new Blob(['']), 'text/plain', '/file.txt') - cy.uploadContent(user, new Blob(['']), '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 - triggerSelectionAction('download') - - // 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(['']), 'text/plain', '/1+1.txt') - cy.uploadContent(user, new Blob(['']), '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 - triggerSelectionAction('download') - - // 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(['']), 'text/plain', '/file.txt') - cy.uploadContent(user, new Blob(['']), '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 - triggerSelectionAction('download') - - // 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/tests/playwright/e2e/files/files-download.spec.ts b/tests/playwright/e2e/files/files-download.spec.ts new file mode 100644 index 00000000000..0847f516730 --- /dev/null +++ b/tests/playwright/e2e/files/files-download.spec.ts @@ -0,0 +1,248 @@ +/* + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Download, Page } from '@playwright/test' +import { readFile } from 'node:fs/promises' +import { addUser, runOcc } from '@nextcloud/e2e-test-server/docker' +import { login } from '@nextcloud/e2e-test-server/playwright' +import { User } from '@nextcloud/e2e-test-server' +import { test, expect } from '../../support/fixtures/files-page.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' +import { getZipEntries } from '../../support/utils/zip.ts' + +/** + * Register the download listener before running the trigger and return the + * resulting download. Playwright requires `waitForEvent('download')` to be + * pending before the action that starts the download (the Cypress original + * instead read a file off the downloads folder afterwards). + */ +async function triggerDownload(page: Page, action: () => Promise): Promise { + const downloadPromise = page.waitForEvent('download') + await action() + return downloadPromise +} + +/** + * Read a download's body as UTF-8 text. + * + * @param download The Playwright download event payload + */ +async function readDownloadText(download: Download): Promise { + const path = await download.path() + return readFile(path, 'utf-8') +} + +test.describe('Files: Download files using file actions', () => { + test('can download file', async ({ page, user, filesListPage }) => { + await uploadContent(page.request, user, '', 'text/plain', '/file.txt') + await filesListPage.open() + + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.triggerActionForFile('file.txt', 'download')) + + expect(download.suggestedFilename()).toBe('file.txt') + expect(await readDownloadText(download)).toBe('') + }) + + test('can download folder', async ({ page, user, filesListPage }) => { + await mkdir(page.request, user, '/subfolder') + await uploadContent(page.request, user, '', 'text/plain', '/subfolder/file.txt') + await filesListPage.open() + + await expect(filesListPage.getRowForFile('subfolder')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.triggerActionForFile('subfolder', 'download')) + + expect(download.suggestedFilename()).toBe('subfolder.zip') + expect(await getZipEntries(download)).toEqual([ + 'subfolder/', + 'subfolder/file.txt', + ]) + }) + + /** + * Regression test of https://github.com/nextcloud/server/issues/44855 + */ + test('can download file with hash name', async ({ page, user, filesListPage }) => { + await uploadContent(page.request, user, '', 'text/plain', '/#file.txt') + await filesListPage.open() + + await expect(filesListPage.getRowForFile('#file.txt')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.triggerActionForFile('#file.txt', 'download')) + + expect(download.suggestedFilename()).toBe('#file.txt') + expect(await readDownloadText(download)).toBe('') + }) + + /** + * Regression test of https://github.com/nextcloud/server/issues/44855 + */ + test('can download file from folder with hash name', async ({ page, user, filesListPage }) => { + await mkdir(page.request, user, '/#folder') + await uploadContent(page.request, user, '', 'text/plain', '/#folder/file.txt') + await filesListPage.open() + + await filesListPage.navigateToFolder('#folder') + // All are visible by default + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.triggerActionForFile('file.txt', 'download')) + + expect(download.suggestedFilename()).toBe('file.txt') + expect(await readDownloadText(download)).toBe('') + }) +}) + +test.describe('Files: Download files using default action', () => { + test('can download file', async ({ page, user, filesListPage }) => { + await uploadContent(page.request, user, '', 'text/plain', '/file.txt') + await filesListPage.open() + + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.getDownloadButtonForFile('file.txt').click()) + + expect(download.suggestedFilename()).toBe('file.txt') + expect(await readDownloadText(download)).toBe('') + }) + + /** + * Regression test of https://github.com/nextcloud/server/issues/44855 + */ + test('can download file with hash name', async ({ page, user, filesListPage }) => { + await uploadContent(page.request, user, '', 'text/plain', '/#file.txt') + await filesListPage.open() + + await expect(filesListPage.getRowForFile('#file.txt')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.getDownloadButtonForFile('#file.txt').click()) + + expect(download.suggestedFilename()).toBe('#file.txt') + expect(await readDownloadText(download)).toBe('') + }) + + /** + * Regression test of https://github.com/nextcloud/server/issues/44855 + */ + test('can download file from folder with hash name', async ({ page, user, filesListPage }) => { + await mkdir(page.request, user, '/#folder') + await uploadContent(page.request, user, '', 'text/plain', '/#folder/file.txt') + await filesListPage.open() + + await filesListPage.navigateToFolder('#folder') + // All are visible by default + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.getDownloadButtonForFile('file.txt').click()) + + expect(download.suggestedFilename()).toBe('file.txt') + expect(await readDownloadText(download)).toBe('') + }) +}) + +test.describe('Files: Download files using selection', () => { + test('can download selected files', async ({ page, user, filesListPage }) => { + await mkdir(page.request, user, '/subfolder') + await uploadContent(page.request, user, '', 'text/plain', '/subfolder/file.txt') + await filesListPage.open() + + await expect(filesListPage.getRowForFile('subfolder')).toBeVisible() + await filesListPage.selectRowForFile('subfolder') + + // see that one file is selected + await expect(page.getByText('1 selected')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.triggerSelectionAction('download')) + + expect(download.suggestedFilename()).toBe('subfolder.zip') + expect(await getZipEntries(download)).toEqual([ + 'subfolder/', + 'subfolder/file.txt', + ]) + }) + + test('can download multiple selected files', async ({ page, user, filesListPage }) => { + await uploadContent(page.request, user, '', 'text/plain', '/file.txt') + await uploadContent(page.request, user, '', 'text/plain', '/other file.txt') + await filesListPage.open() + + await filesListPage.selectRowForFile('file.txt') + await filesListPage.selectRowForFile('other file.txt') + + // see that two files are selected + await expect(page.getByText('2 selected')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.triggerSelectionAction('download')) + + expect(download.suggestedFilename()).toBe('download.zip') + expect(await getZipEntries(download)).toEqual([ + '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 + */ + test('can download selected files with special characters', async ({ page, user, filesListPage }) => { + await uploadContent(page.request, user, '', 'text/plain', '/1+1.txt') + await uploadContent(page.request, user, '', 'text/plain', '/some@other.txt') + await filesListPage.open() + + await filesListPage.selectRowForFile('some@other.txt') + await filesListPage.selectRowForFile('1+1.txt') + + // see that two files are selected + await expect(page.getByText('2 selected')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.triggerSelectionAction('download')) + + expect(download.suggestedFilename()).toBe('download.zip') + expect(await getZipEntries(download)).toEqual([ + '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 + * + * This test does not use the shared `user` fixture: it needs an email-like + * uid, which `createRandomUser()` cannot produce, so it provisions its own + * user via the docker helper and logs in at the API level. + */ + test('can download selected files with email uid', async ({ page, context, filesListPage }) => { + const randomString = (length: number) => Math.random().toString(36).slice(2, 2 + length) + const uid = `${randomString(5)}@${randomString(3)}` + const emailUser = new User(uid, uid, 'en') + + await addUser(emailUser) + await login(context.request, emailUser) + + try { + await uploadContent(page.request, emailUser, '', 'text/plain', '/file.txt') + await uploadContent(page.request, emailUser, '', 'text/plain', '/other file.txt') + await filesListPage.open() + + await filesListPage.selectRowForFile('file.txt') + await filesListPage.selectRowForFile('other file.txt') + + // see that two files are selected + await expect(page.getByText('2 selected')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.triggerSelectionAction('download')) + + expect(download.suggestedFilename()).toBe('download.zip') + expect(await getZipEntries(download)).toEqual([ + 'file.txt', + 'other file.txt', + ]) + } finally { + await runOcc(['user:delete', uid]) + } + }) +}) diff --git a/tests/playwright/support/sections/FilesListPage.ts b/tests/playwright/support/sections/FilesListPage.ts index 977b1519161..e41869b54c9 100644 --- a/tests/playwright/support/sections/FilesListPage.ts +++ b/tests/playwright/support/sections/FilesListPage.ts @@ -66,6 +66,13 @@ export class FilesListPage { return this.getRowForFile(filename).getByRole('img', { name: 'Favorite' }) } + /** + * The inline "Download" button rendered on a row for the default download action. + */ + getDownloadButtonForFile(filename: string): Locator { + return this.getRowForFile(filename).getByRole('button', { name: 'Download' }) + } + async selectAll(): Promise { await this.page.locator('[data-cy-files-list-selection-checkbox]') .getByRole('checkbox') diff --git a/tests/playwright/support/utils/zip.ts b/tests/playwright/support/utils/zip.ts new file mode 100644 index 00000000000..a6c2b2c16ea --- /dev/null +++ b/tests/playwright/support/utils/zip.ts @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Download } from '@playwright/test' +import { readFile } from 'node:fs/promises' +import { Uint8ArrayReader, ZipReader } from '@zip.js/zip.js' + +/** + * Read a downloaded zip and return its entry names, sorted. + * + * Ports the Cypress `zipFileContains` assertion onto the Playwright Download + * object: the download is saved to a temp path, read back, and parsed with the + * same @zip.js/zip.js reader the Cypress util used. + * + * @param download The Playwright download event payload + */ +export async function getZipEntries(download: Download): Promise { + const path = await download.path() + const buffer = await readFile(path) + const zip = new ZipReader(new Uint8ArrayReader(buffer)) + try { + const entries = await zip.getEntries() + return entries.map((entry) => entry.filename).sort() + } finally { + await zip.close() + } +}