diff --git a/__tests__/mock-window.js b/__tests__/mock-window.js index dbb689a4f2c..e0c8607dc22 100644 --- a/__tests__/mock-window.js +++ b/__tests__/mock-window.js @@ -2,12 +2,8 @@ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { beforeEach } from 'vitest' - window.OC = { ...window.OC } window.OCA = { ...window.OCA } window.OCP = { ...window.OCP } -beforeEach(() => { - window.location = new URL('http://nextcloud.local') -}) +window._oc_webroot = '' diff --git a/apps/files/src/actions/editLocallyAction.spec.ts b/apps/files/src/actions/editLocallyAction.spec.ts index 4d07bb15189..9c7de1b78be 100644 --- a/apps/files/src/actions/editLocallyAction.spec.ts +++ b/apps/files/src/actions/editLocallyAction.spec.ts @@ -120,6 +120,7 @@ describe('Edit locally action execute tests', () => { data: { ocs: { data: { token: 'foobar' } } }, })) const showError = vi.spyOn(nextcloudDialogs, 'showError') + const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null) const file = new File({ id: 1, @@ -138,7 +139,7 @@ describe('Edit locally action execute tests', () => { expect(axios.post).toBeCalledTimes(1) expect(axios.post).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files/api/v1/openlocaleditor?format=json', { path: '/foobar.txt' }) expect(showError).toBeCalledTimes(0) - expect(window.location.href).toBe('nc://open/test@nextcloud.local/foobar.txt?token=foobar') + expect(windowOpenSpy).toBeCalledWith('nc://open/test@nextcloud.local/foobar.txt?token=foobar', '_self') }) test('Edit locally fails and shows error', async () => { diff --git a/apps/files/src/actions/editLocallyAction.ts b/apps/files/src/actions/editLocallyAction.ts index 2471eaf40a5..ae35b0ca409 100644 --- a/apps/files/src/actions/editLocallyAction.ts +++ b/apps/files/src/actions/editLocallyAction.ts @@ -73,7 +73,7 @@ const openLocalClient = async function(path: string) { let url = `nc://open/${uid}@` + window.location.host + encodePath(path) url += '?token=' + result.data.ocs.data.token - window.location.href = url + window.open(url, '_self') } catch (error) { showError(t('files', 'Failed to redirect to client')) } diff --git a/cypress.config.ts b/cypress.config.ts index e9b09f2012d..9f5c394b4c6 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -66,6 +66,19 @@ export default defineConfig({ on('task', { removeDirectory }) + // This allows to store global data (e.g. the name of a snapshot) + // because Cypress.env() and other options are local to the current spec file. + const data = {} + on('task', { + setVariable({ key, value }) { + data[key] = value + return null + }, + getVariable({ key }) { + return data[key] ?? null + }, + }) + // Disable spell checking to prevent rendering differences on('before:browser:launch', (browser, launchOptions) => { if (browser.family === 'chromium' && browser.name !== 'electron') { diff --git a/cypress/dockerNode.ts b/cypress/dockerNode.ts index a9e8c7c6c45..71644ae7399 100644 --- a/cypress/dockerNode.ts +++ b/cypress/dockerNode.ts @@ -147,6 +147,8 @@ export const configureNextcloud = async function() { // Saving DB state console.log('├─ Creating init DB snapshot...') await runExec(container, ['cp', '/var/www/html/data/owncloud.db', '/var/www/html/data/owncloud.db-init'], true) + console.log('├─ Creating init data backup...') + await runExec(container, ['tar', 'cf', 'data-init.tar', 'admin'], true, undefined, '/var/www/html/data') console.log('└─ Nextcloud is now ready to use 🎉') } @@ -277,9 +279,11 @@ const runExec = async function( command: string[], verbose = false, user = 'www-data', + workdir?: string, ): Promise { const exec = await container.exec({ Cmd: command, + WorkingDir: workdir, AttachStdout: true, AttachStderr: true, User: user, @@ -296,7 +300,7 @@ const runExec = async function( stream.on('data', str => { str = str.trim() // Remove non printable characters - .replace(/[^\x20-\x7E]+/g, '') + .replace(/[^\x0A\x0D\x20-\x7E]+/g, '') // Remove non alphanumeric leading characters .replace(/^[^a-z]/gi, '') output += str diff --git a/cypress/e2e/files/FilesUtils.ts b/cypress/e2e/files/FilesUtils.ts index 345b6402f1f..0f2b1154200 100644 --- a/cypress/e2e/files/FilesUtils.ts +++ b/cypress/e2e/files/FilesUtils.ts @@ -9,8 +9,8 @@ export const getRowForFile = (filename: string) => cy.get(`[data-cy-files-list-r 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 triggerActionForFileId = (fileid: number, actionId: string) => { getActionButtonForFileId(fileid).click() @@ -34,7 +34,7 @@ export const moveFile = (fileName: string, dirPath: string) => { 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 @@ -65,7 +65,7 @@ export const copyFile = (fileName: string, dirPath: string) => { 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 @@ -95,7 +95,7 @@ export const renameFile = (fileName: string, newFileName: string) => { 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}`) diff --git a/cypress/e2e/files_sharing/FilesSharingUtils.ts b/cypress/e2e/files_sharing/FilesSharingUtils.ts index ef8cf462a06..6e97a757a15 100644 --- a/cypress/e2e/files_sharing/FilesSharingUtils.ts +++ b/cypress/e2e/files_sharing/FilesSharingUtils.ts @@ -170,14 +170,3 @@ export const createFileRequest = (path: string, options: FileRequestOptions = {} // Close cy.get('[data-cy-file-request-dialog-controls="finish"]').click() } - -export const enterGuestName = (name: string) => { - cy.get('[data-cy-public-auth-prompt-dialog]').should('be.visible') - cy.get('[data-cy-public-auth-prompt-dialog-name]').should('be.visible') - cy.get('[data-cy-public-auth-prompt-dialog-submit]').should('be.visible') - - cy.get('[data-cy-public-auth-prompt-dialog-name]').type(`{selectall}${name}`) - cy.get('[data-cy-public-auth-prompt-dialog-submit]').click() - - cy.get('[data-cy-public-auth-prompt-dialog]').should('not.exist') -} diff --git a/cypress/e2e/files_sharing/file-request.cy.ts b/cypress/e2e/files_sharing/file-request.cy.ts index 7c33594e25c..d109c4c585d 100644 --- a/cypress/e2e/files_sharing/file-request.cy.ts +++ b/cypress/e2e/files_sharing/file-request.cy.ts @@ -5,12 +5,31 @@ import type { User } from '@nextcloud/cypress' import { createFolder, getRowForFile, navigateToFolder } from '../files/FilesUtils' -import { createFileRequest, enterGuestName } from './FilesSharingUtils' +import { createFileRequest } from './FilesSharingUtils' + +const enterGuestName = (name: string) => { + cy.findByRole('dialog', { name: /Upload files to/ }) + .should('be.visible') + .within(() => { + cy.findByRole('textbox', { name: 'Nickname' }) + .should('be.visible') + + cy.findByRole('textbox', { name: 'Nickname' }) + .type(`{selectall}${name}`) + + cy.findByRole('button', { name: 'Submit name' }) + .should('be.visible') + .click() + }) + + cy.findByRole('dialog', { name: /Upload files to/ }) + .should('not.exist') +} describe('Files', { testIsolation: true }, () => { + const folderName = 'test-folder' let user: User let url = '' - let folderName = 'test-folder' it('Login with a user and create a file request', () => { cy.createRandomUser().then((_user) => { @@ -33,19 +52,22 @@ describe('Files', { testIsolation: true }, () => { enterGuestName('Guest') // Check various elements on the page - cy.get('#public-upload .emptycontent').should('be.visible') - cy.get('#public-upload h2').contains(`Upload files to ${folderName}`) - cy.get('#public-upload input[type="file"]').as('fileInput').should('exist') + cy.contains(`Upload files to ${folderName}`) + .should('be.visible') + cy.findByRole('button', { name: 'Upload' }) + .should('be.visible') cy.intercept('PUT', '/public.php/dav/files/*/*').as('uploadFile') // Upload a file - cy.get('@fileInput').selectFile({ - contents: Cypress.Buffer.from('abcdef'), - fileName: 'file.txt', - mimeType: 'text/plain', - lastModified: Date.now(), - }, { force: true }) + cy.get('[data-cy-files-sharing-file-drop] input[type="file"]') + .should('exist') + .selectFile({ + contents: Cypress.Buffer.from('abcdef'), + fileName: 'file.txt', + mimeType: 'text/plain', + lastModified: Date.now(), + }, { force: true }) cy.wait('@uploadFile').its('response.statusCode').should('eq', 201) }) diff --git a/cypress/e2e/files_sharing/public-share/copy-move-files.cy.ts b/cypress/e2e/files_sharing/public-share/copy-move-files.cy.ts new file mode 100644 index 00000000000..078ecf747bf --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/copy-move-files.cy.ts @@ -0,0 +1,49 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { copyFile, getRowForFile, moveFile, navigateToFolder } from '../../files/FilesUtils.ts' +import { getShareUrl, setupPublicShare } from './setup-public-share.ts' + +describe('files_sharing: Public share - copy and move files', { testIsolation: true }, () => { + + beforeEach(() => { + setupPublicShare() + .then(() => cy.logout()) + .then(() => cy.visit(getShareUrl())) + }) + + it('Can copy a file to new folder', () => { + getRowForFile('foo.txt').should('be.visible') + getRowForFile('subfolder').should('be.visible') + + copyFile('foo.txt', 'subfolder') + + // still visible + getRowForFile('foo.txt').should('be.visible') + navigateToFolder('subfolder') + + cy.url().should('contain', 'dir=/subfolder') + getRowForFile('foo.txt').should('be.visible') + getRowForFile('bar.txt').should('be.visible') + getRowForFile('subfolder').should('not.exist') + }) + + it('Can move a file to new folder', () => { + getRowForFile('foo.txt').should('be.visible') + getRowForFile('subfolder').should('be.visible') + + moveFile('foo.txt', 'subfolder') + + // wait until visible again + getRowForFile('subfolder').should('be.visible') + + // file should be moved -> not exist anymore + getRowForFile('foo.txt').should('not.exist') + navigateToFolder('subfolder') + + cy.url().should('contain', 'dir=/subfolder') + getRowForFile('foo.txt').should('be.visible') + getRowForFile('subfolder').should('not.exist') + }) +}) diff --git a/cypress/e2e/files_sharing/public-share/download-files.cy.ts b/cypress/e2e/files_sharing/public-share/download-files.cy.ts new file mode 100644 index 00000000000..4e37d1b38ae --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/download-files.cy.ts @@ -0,0 +1,141 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +// @ts-expect-error The package is currently broken - but works... +import { deleteDownloadsFolderBeforeEach } from 'cypress-delete-downloads-folder' + +import { zipFileContains } from '../../../support/utils/assertions.ts' +import { getRowForFile, triggerActionForFile } from '../../files/FilesUtils.ts' +import { getShareUrl, setupPublicShare } from './setup-public-share.ts' + +describe('files_sharing: Public share - downloading files', { testIsolation: true }, () => { + + const shareName = 'shared' + + before(() => setupPublicShare()) + + deleteDownloadsFolderBeforeEach() + + beforeEach(() => { + cy.logout() + cy.visit(getShareUrl()) + }) + + it('Can download all files', () => { + getRowForFile('foo.txt').should('be.visible') + + cy.get('[data-cy-files-list]').within(() => { + cy.findByRole('checkbox', { name: /Toggle selection for all files/i }) + .should('exist') + .check({ force: true }) + + // see that two files are selected + cy.contains('2 selected').should('be.visible') + + // click download + cy.findByRole('button', { name: 'Download (selected)' }) + .should('be.visible') + .click() + + // check a file is downloaded + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/${shareName}.zip`, null, { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 30) + // Check all files are included + .and(zipFileContains([ + 'foo.txt', + 'subfolder/', + 'subfolder/bar.txt', + ])) + }) + }) + + it('Can download selected files', () => { + getRowForFile('subfolder') + .should('be.visible') + + cy.get('[data-cy-files-list]').within(() => { + getRowForFile('subfolder') + .findByRole('checkbox') + .check({ force: true }) + + // see that two files are selected + cy.contains('1 selected').should('be.visible') + + // click download + cy.findByRole('button', { 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/bar.txt', + ])) + }) + }) + + it('Can download folder by action', () => { + getRowForFile('subfolder') + .should('be.visible') + + cy.get('[data-cy-files-list]').within(() => { + 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/bar.txt', + ])) + }) + }) + + it('Can download file by action', () => { + getRowForFile('foo.txt') + .should('be.visible') + + cy.get('[data-cy-files-list]').within(() => { + triggerActionForFile('foo.txt', 'download') + + // check a file is downloaded + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/foo.txt`, 'utf-8', { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 5) + .and('contain', 'foo') + }) + }) + + it('Can download file by selection', () => { + getRowForFile('foo.txt') + .should('be.visible') + + cy.get('[data-cy-files-list]').within(() => { + getRowForFile('foo.txt') + .findByRole('checkbox') + .check({ force: true }) + + cy.findByRole('button', { name: 'Download (selected)' }) + .click() + + // check a file is downloaded + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/foo.txt`, 'utf-8', { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 5) + .and('contain', 'foo') + }) + }) +}) diff --git a/cypress/e2e/files_sharing/public-share-header-menu.cy.ts b/cypress/e2e/files_sharing/public-share/header-menu.cy.ts similarity index 61% rename from cypress/e2e/files_sharing/public-share-header-menu.cy.ts rename to cypress/e2e/files_sharing/public-share/header-menu.cy.ts index 020ea410dba..a89ee8eb90e 100644 --- a/cypress/e2e/files_sharing/public-share-header-menu.cy.ts +++ b/cypress/e2e/files_sharing/public-share/header-menu.cy.ts @@ -2,67 +2,39 @@ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { haveValidity, zipFileContains } from '../../support/utils/assertions.ts' -import { openSharingPanel } from './FilesSharingUtils.ts' -// @ts-expect-error The package is currently broken - but works... -import { deleteDownloadsFolderBeforeEach } from 'cypress-delete-downloads-folder' +import { haveValidity, zipFileContains } from '../../../support/utils/assertions.ts' +import { getShareUrl, setupPublicShare } from './setup-public-share.ts' +/** + * This tests ensures that on public shares the header actions menu correctly works + */ describe('files_sharing: Public share - header actions menu', { testIsolation: true }, () => { - let shareUrl: string - const shareName = 'to be shared' - - before(() => { - cy.createRandomUser().then(($user) => { - cy.mkdir($user, `/${shareName}`) - cy.mkdir($user, `/${shareName}/subfolder`) - cy.uploadContent($user, new Blob([]), 'text/plain', `/${shareName}/foo.txt`) - cy.uploadContent($user, new Blob([]), 'text/plain', `/${shareName}/subfolder/bar.txt`) - cy.login($user) - // open the files app - cy.visit('/apps/files') - // open the sidebar - openSharingPanel(shareName) - // create the share - cy.intercept('POST', '**/ocs/v2.php/apps/files_sharing/api/v1/shares').as('createShare') - cy.findByRole('button', { name: 'Create a new share link' }) - .click() - // extract the link - cy.wait('@createShare').should(({ response }) => { - const { ocs } = response?.body ?? {} - shareUrl = ocs?.data.url - expect(shareUrl).to.match(/^http:\/\//) - }) - }) - }) - - deleteDownloadsFolderBeforeEach() - + before(() => setupPublicShare()) beforeEach(() => { cy.logout() - cy.visit(shareUrl) + cy.visit(getShareUrl()) }) it('Can download all files', () => { - // Check the button cy.get('header') - .findByRole('button', { name: 'Download all files' }) + .findByRole('button', { name: 'Download' }) .should('be.visible') cy.get('header') - .findByRole('button', { name: 'Download all files' }) + .findByRole('button', { name: 'Download' }) .click() // check a file is downloaded const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/${shareName}.zip`, null, { timeout: 15000 }) + cy.readFile(`${downloadsFolder}/shared.zip`, null, { timeout: 15000 }) .should('exist') .and('have.length.gt', 30) // Check all files are included .and(zipFileContains([ - `${shareName}/`, - `${shareName}/foo.txt`, - `${shareName}/subfolder/`, - `${shareName}/subfolder/bar.txt`, + 'shared/', + 'shared/foo.txt', + 'shared/subfolder/', + 'shared/subfolder/bar.txt', ])) }) @@ -78,12 +50,12 @@ describe('files_sharing: Public share - header actions menu', { testIsolation: t cy.findByRole('menu', { name: /More action/i }) .should('be.visible') // see correct link in item - cy.findByRole('menuitem', { name: /Direct link/i }) + cy.findByRole('menuitem', { name: 'Direct link' }) .should('be.visible') .and('have.attr', 'href') .then((attribute) => expect(attribute).to.match(/^http:\/\/.+\/download$/)) // see menu closes on click - cy.findByRole('menuitem', { name: /Direct link/i }) + cy.findByRole('menuitem', { name: 'Direct link' }) .click() cy.findByRole('menu', { name: /More actions/i }) .should('not.exist') @@ -100,7 +72,7 @@ describe('files_sharing: Public share - header actions menu', { testIsolation: t // See the menu cy.findByRole('menu', { name: /More action/i }) .should('be.visible') - // see correct item + // see correct button cy.findByRole('menuitem', { name: /Add to your/i }) .should('be.visible') .click() @@ -125,6 +97,7 @@ describe('files_sharing: Public share - header actions menu', { testIsolation: t .findByRole('button', { name: /More actions/i }) .should('be.visible') .click() + // see correct button cy.findByRole('menuitem', { name: /Add to your/i }) .should('be.visible') .click() @@ -134,10 +107,11 @@ describe('files_sharing: Public share - header actions menu', { testIsolation: t .type('user@nextcloud.local') // intercept request, the request is continued when the promise is resolved const { promise, resolve } = Promise.withResolvers() - cy.intercept('POST', '**/apps/federatedfilesharing/createFederatedShare', async (req) => { - await promise - req.reply({ statusCode: 503 }) + cy.intercept('POST', '**/apps/federatedfilesharing/createFederatedShare', (request) => { + // we need to wait in the onResponse handler as the intercept handler times out otherwise + request.on('response', async (response) => { await promise; response.statusCode = 503 }) }).as('createFederatedShare') + // create the share cy.findByRole('button', { name: 'Create share' }) .click() @@ -161,7 +135,7 @@ describe('files_sharing: Public share - header actions menu', { testIsolation: t .findByRole('button', { name: /More actions/i }) .should('be.visible') .click() - // see correct item + // see correct button cy.findByRole('menuitem', { name: /Add to your/i }) .should('be.visible') .click() @@ -183,37 +157,43 @@ describe('files_sharing: Public share - header actions menu', { testIsolation: t it('See primary action is moved to menu on small screens', () => { cy.viewport(490, 490) // Check the button does not exist - cy.get('header') - .should('be.visible') - .findByRole('button', { name: 'Download all files' }) - .should('not.exist') - // Open the menu - cy.get('header') - .findByRole('button', { name: /More actions/i }) - .should('be.visible') - .click() - // See that the button is located in the menu - cy.findByRole('menuitem', { name: /Download all files/i }) - .should('be.visible') - // See all other items are also available + cy.get('header').within(() => { + cy.findByRole('button', { name: 'Direct link' }) + .should('not.exist') + cy.findByRole('button', { name: 'Download' }) + .should('not.exist') + cy.findByRole('button', { name: /Add to your/i }) + .should('not.exist') + // Open the menu + cy.findByRole('button', { name: /More actions/i }) + .should('be.visible') + .click() + }) + + // See correct number of menu item cy.findByRole('menu', { name: 'More actions' }) .findAllByRole('menuitem') .should('have.length', 3) - // Click the button to test the download - cy.findByRole('menuitem', { name: /Download all files/i }) - .click() + cy.findByRole('menu', { name: 'More actions' }) + .within(() => { + // See that download, federated share and direct link are moved to the menu + cy.findByRole('menuitem', { name: /^Download/ }) + .should('be.visible') + cy.findByRole('menuitem', { name: /Add to your/i }) + .should('be.visible') + cy.findByRole('menuitem', { name: 'Direct link' }) + .should('be.visible') - // check a file is downloaded - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/${shareName}.zip`, null, { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 30) - // Check all files are included - .and(zipFileContains([ - `${shareName}/`, - `${shareName}/foo.txt`, - `${shareName}/subfolder/`, - `${shareName}/subfolder/bar.txt`, - ])) + // See that direct link works + cy.findByRole('menuitem', { name: 'Direct link' }) + .should('be.visible') + .and('have.attr', 'href') + .then((attribute) => expect(attribute).to.match(/^http:\/\/.+\/download$/)) + // See remote share works + cy.findByRole('menuitem', { name: /Add to your/i }) + .should('be.visible') + .click() + }) + cy.findByRole('dialog', { name: /Add to your Nextcloud/i }).should('be.visible') }) }) diff --git a/cypress/e2e/files_sharing/public-share/rename-files.cy.ts b/cypress/e2e/files_sharing/public-share/rename-files.cy.ts new file mode 100644 index 00000000000..5f2fe00e650 --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/rename-files.cy.ts @@ -0,0 +1,32 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { getRowForFile, haveValidity, triggerActionForFile } from '../../files/FilesUtils.ts' +import { getShareUrl, setupPublicShare } from './setup-public-share.ts' + +describe('files_sharing: Public share - renaming files', { testIsolation: true }, () => { + + beforeEach(() => { + setupPublicShare() + .then(() => cy.logout()) + .then(() => cy.visit(getShareUrl())) + }) + + it('can rename a file', () => { + // All are visible by default + getRowForFile('foo.txt').should('be.visible') + + triggerActionForFile('foo.txt', 'rename') + + getRowForFile('foo.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') + }) +}) diff --git a/cypress/e2e/files_sharing/public-share/setup-public-share.ts b/cypress/e2e/files_sharing/public-share/setup-public-share.ts new file mode 100644 index 00000000000..9549552c200 --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/setup-public-share.ts @@ -0,0 +1,119 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { User } from '@nextcloud/cypress' +import { openSharingPanel } from '../FilesSharingUtils.ts' + +let user: User +let url: string + +/** + * URL of the share + */ +export function getShareUrl() { + if (url === undefined) { + throw new Error('You need to setup the share first!') + } + return url +} + +/** + * Setup the available data + * @param shareName The name of the shared folder + */ +function setupData(shareName: string) { + cy.mkdir(user, `/${shareName}`) + cy.mkdir(user, `/${shareName}/subfolder`) + cy.uploadContent(user, new Blob(['foo']), 'text/plain', `/${shareName}/foo.txt`) + cy.uploadContent(user, new Blob(['bar']), 'text/plain', `/${shareName}/subfolder/bar.txt`) +} + +/** + * Create a public link share + * @param shareName The name of the shared folder + */ +function createShare(shareName: string) { + cy.login(user) + // open the files app + cy.visit('/apps/files') + // open the sidebar + openSharingPanel(shareName) + // create the share + cy.intercept('POST', '**/ocs/v2.php/apps/files_sharing/api/v1/shares').as('createShare') + cy.findByRole('button', { name: 'Create a new share link' }) + .click() + + // extract the link + return cy.wait('@createShare') + .should(({ response }) => { + const { ocs } = response!.body + url = ocs?.data.url + expect(url).to.match(/^http:\/\//) + }) + .then(() => cy.wrap(url)) +} + +/** + * Adjust share permissions to be editable + */ +function adjustSharePermission() { + // Update the share to be a file drop + cy.findByRole('list', { name: 'Link shares' }) + .findAllByRole('listitem') + .first() + .findByRole('button', { name: /Actions/i }) + .click() + cy.findByRole('menuitem', { name: /Customize link/i }) + .should('be.visible') + .click() + + // Enable upload-edit + cy.get('[data-cy-files-sharing-share-permissions-bundle]') + .should('be.visible') + cy.get('[data-cy-files-sharing-share-permissions-bundle="upload-edit"]') + .click() + // save changes + cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('updateShare') + cy.findByRole('button', { name: 'Update share' }) + .click() + cy.wait('@updateShare') +} + +/** + * Setup a public share and backup the state. + * If the setup was already done in another run, the state will be restored. + * + * @return The URL of the share + */ +export function setupPublicShare(): Cypress.Chainable { + const shareName = 'shared' + + return cy.task('getVariable', { key: 'public-share-data' }) + .then((data) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { dataSnapshot, dbSnapshot, shareUrl } = data as any || {} + if (dataSnapshot && dbSnapshot) { + cy.restoreDB(dbSnapshot) + cy.restoreData(dataSnapshot) + url = shareUrl + return cy.wrap(shareUrl as string) + } else { + cy.restoreData() + cy.restoreDB() + + const shareData: Record = {} + return cy.createRandomUser() + .then(($user) => { user = $user }) + .then(() => setupData(shareName)) + .then(() => createShare(shareName)) + .then((value) => { shareData.shareUrl = value }) + .then(() => adjustSharePermission()) + .then(() => cy.backupDB().then((value) => { shareData.dbSnapshot = value })) + .then(() => cy.backupData([user.userId]).then((value) => { shareData.dataSnapshot = value })) + .then(() => cy.task('setVariable', { key: 'public-share-data', value: shareData })) + .then(() => cy.log(`Public share setup, URL: ${shareData.shareUrl}`)) + .then(() => cy.wrap(url)) + } + }) +} diff --git a/cypress/e2e/files_sharing/public-share/view_file-drop.cy.ts b/cypress/e2e/files_sharing/public-share/view_file-drop.cy.ts new file mode 100644 index 00000000000..8bc4b9b8e15 --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/view_file-drop.cy.ts @@ -0,0 +1,169 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { getRowForFile } from '../../files/FilesUtils.ts' +import { openSharingPanel } from '../FilesSharingUtils.ts' + +describe('files_sharing: Public share - File drop', { testIsolation: true }, () => { + + let shareUrl: string + let user: string + const shareName = 'shared' + + before(() => { + cy.createRandomUser().then(($user) => { + user = $user.userId + cy.mkdir($user, `/${shareName}`) + cy.uploadContent($user, new Blob(['content']), 'text/plain', `/${shareName}/foo.txt`) + cy.login($user) + // open the files app + cy.visit('/apps/files') + // open the sidebar + openSharingPanel(shareName) + // create the share + cy.intercept('POST', '**/ocs/v2.php/apps/files_sharing/api/v1/shares').as('createShare') + cy.findByRole('button', { name: 'Create a new share link' }) + .click() + // extract the link + cy.wait('@createShare').should(({ response }) => { + const { ocs } = response?.body ?? {} + shareUrl = ocs?.data.url + expect(shareUrl).to.match(/^http:\/\//) + }) + + // Update the share to be a file drop + cy.findByRole('list', { name: 'Link shares' }) + .findAllByRole('listitem') + .first() + .findByRole('button', { name: /Actions/i }) + .click() + cy.findByRole('menuitem', { name: /Customize link/i }) + .should('be.visible') + .click() + cy.get('[data-cy-files-sharing-share-permissions-bundle]') + .should('be.visible') + cy.get('[data-cy-files-sharing-share-permissions-bundle="file-drop"]') + .click() + + // save the update + cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('updateShare') + cy.findByRole('button', { name: 'Update share' }) + .click() + cy.wait('@updateShare') + }) + }) + + beforeEach(() => { + cy.logout() + cy.visit(shareUrl) + }) + + it('Cannot see share content', () => { + cy.contains(`Upload files to ${shareName}`) + .should('be.visible') + + // foo exists + cy.userFileExists(user, `${shareName}/foo.txt`).should('be.gt', 0) + // but is not visible + getRowForFile('foo.txt') + .should('not.exist') + }) + + it('Can only see upload files and upload folders menu entries', () => { + cy.contains(`Upload files to ${shareName}`) + .should('be.visible') + + cy.findByRole('button', { name: 'New' }) + .should('be.visible') + .click() + // See upload actions + cy.findByRole('menuitem', { name: 'Upload files' }) + .should('be.visible') + cy.findByRole('menuitem', { name: 'Upload folders' }) + .should('be.visible') + // But no other + cy.findByRole('menu') + .findAllByRole('menuitem') + .should('have.length', 2) + }) + + it('Can only see dedicated upload button', () => { + cy.contains(`Upload files to ${shareName}`) + .should('be.visible') + + cy.findByRole('button', { name: 'Upload' }) + .should('be.visible') + .click() + // See upload actions + cy.findByRole('menuitem', { name: 'Upload files' }) + .should('be.visible') + cy.findByRole('menuitem', { name: 'Upload folders' }) + .should('be.visible') + // But no other + cy.findByRole('menu') + .findAllByRole('menuitem') + .should('have.length', 2) + }) + + it('Can upload files', () => { + cy.contains(`Upload files to ${shareName}`) + .should('be.visible') + + const { promise, resolve } = Promise.withResolvers() + cy.intercept('PUT', '**/public.php/dav/files/**', (request) => { + if (request.url.includes('first.txt')) { + // just continue the first one + request.continue() + } else { + // We delay the second one until we checked that the progress bar is visible + request.on('response', async () => { await promise }) + } + }).as('uploadFile') + + cy.get('[data-cy-files-sharing-file-drop] input[type="file"]') + .should('exist') + .selectFile([ + { fileName: 'first.txt', contents: Buffer.from('8 bytes!') }, + { fileName: 'second.md', contents: Buffer.from('x'.repeat(128)) }, + ], { force: true }) + + cy.wait('@uploadFile') + + cy.findByRole('progressbar') + .should('be.visible') + .and((el) => { expect(Number.parseInt(el.attr('value') ?? '0')).be.gte(50) }) + // continue second request + .then(() => resolve(null)) + + cy.wait('@uploadFile') + + // Check files uploaded + cy.userFileExists(user, `${shareName}/first.txt`).should('eql', 8) + cy.userFileExists(user, `${shareName}/second.md`).should('eql', 128) + }) + + describe('Terms of service', { testIsolation: true }, () => { + before(() => cy.runOccCommand('config:app:set --value "TEST: Some disclaimer text" --type string core shareapi_public_link_disclaimertext')) + beforeEach(() => cy.visit(shareUrl)) + after(() => cy.runOccCommand('config:app:delete core shareapi_public_link_disclaimertext')) + + it('shows ToS on file-drop view', () => { + cy.contains(`Upload files to ${shareName}`) + .should('be.visible') + .should('contain.text', 'agree to the terms of service') + cy.findByRole('button', { name: /Terms of service/i }) + .should('be.visible') + .click() + + cy.findByRole('dialog', { name: 'Terms of service' }) + .should('contain.text', 'TEST: Some disclaimer text') + // close + .findByRole('button', { name: 'Close' }) + .click() + + cy.findByRole('dialog', { name: 'Terms of service' }) + .should('not.exist') + }) + }) +}) diff --git a/cypress/e2e/files_sharing/public-share/view_view-only-no-download.cy.ts b/cypress/e2e/files_sharing/public-share/view_view-only-no-download.cy.ts new file mode 100644 index 00000000000..abcb9ccae62 --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/view_view-only-no-download.cy.ts @@ -0,0 +1,104 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { getActionButtonForFile, getRowForFile, navigateToFolder } from '../../files/FilesUtils.ts' +import { openSharingPanel } from '../FilesSharingUtils.ts' + +describe('files_sharing: Public share - View only', { testIsolation: true }, () => { + + let shareUrl: string + const shareName = 'shared' + + before(() => { + cy.createRandomUser().then(($user) => { + cy.mkdir($user, `/${shareName}`) + cy.mkdir($user, `/${shareName}/subfolder`) + cy.uploadContent($user, new Blob([]), 'text/plain', `/${shareName}/foo.txt`) + cy.uploadContent($user, new Blob([]), 'text/plain', `/${shareName}/subfolder/bar.txt`) + cy.login($user) + // open the files app + cy.visit('/apps/files') + // open the sidebar + openSharingPanel(shareName) + // create the share + cy.intercept('POST', '**/ocs/v2.php/apps/files_sharing/api/v1/shares').as('createShare') + cy.findByRole('button', { name: 'Create a new share link' }) + .click() + // extract the link + cy.wait('@createShare').should(({ response }) => { + const { ocs } = response?.body ?? {} + shareUrl = ocs?.data.url + expect(shareUrl).to.match(/^http:\/\//) + }) + + // Update the share to be a view-only-no-download share + cy.findByRole('list', { name: 'Link shares' }) + .findAllByRole('listitem') + .first() + .findByRole('button', { name: /Actions/i }) + .click() + cy.findByRole('menuitem', { name: /Customize link/i }) + .should('be.visible') + .click() + cy.get('[data-cy-files-sharing-share-permissions-bundle]') + .should('be.visible') + cy.get('[data-cy-files-sharing-share-permissions-bundle="read-only"]') + .click() + cy.findByRole('checkbox', { name: 'Hide download' }) + .check({ force: true }) + // save the update + cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('updateShare') + cy.findByRole('button', { name: 'Update share' }) + .click() + cy.wait('@updateShare') + }) + }) + + beforeEach(() => { + cy.logout() + cy.visit(shareUrl) + }) + + it('Can see the files list', () => { + // foo exists + getRowForFile('foo.txt') + .should('be.visible') + }) + + it('But no actions available', () => { + // foo exists + getRowForFile('foo.txt') + .should('be.visible') + // but no actions + getActionButtonForFile('foo.txt') + .should('not.exist') + + // TODO: We really need Viewer in the server repo. + // So we could at least test viewing images + }) + + it('Can navigate to subfolder', () => { + getRowForFile('subfolder') + .should('be.visible') + + navigateToFolder('subfolder') + + getRowForFile('bar.txt') + .should('be.visible') + + // but also no actions + getActionButtonForFile('bar.txt') + .should('not.exist') + }) + + it('Cannot upload files', () => { + // wait for file list to be ready + getRowForFile('foo.txt') + .should('be.visible') + + cy.contains('button', 'New') + .should('be.visible') + .and('be.disabled') + }) +}) diff --git a/cypress/e2e/files_sharing/public-share/view_view-only.cy.ts b/cypress/e2e/files_sharing/public-share/view_view-only.cy.ts new file mode 100644 index 00000000000..4a8aa6b89a3 --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/view_view-only.cy.ts @@ -0,0 +1,107 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { getActionsForFile, getRowForFile, navigateToFolder } from '../../files/FilesUtils.ts' +import { openSharingPanel } from '../FilesSharingUtils.ts' + +describe('files_sharing: Public share - View only', { testIsolation: true }, () => { + + let shareUrl: string + const shareName = 'shared' + + before(() => { + cy.createRandomUser().then(($user) => { + cy.mkdir($user, `/${shareName}`) + cy.mkdir($user, `/${shareName}/subfolder`) + cy.uploadContent($user, new Blob(['content']), 'text/plain', `/${shareName}/foo.txt`) + cy.uploadContent($user, new Blob(['content']), 'text/plain', `/${shareName}/subfolder/bar.txt`) + cy.login($user) + // open the files app + cy.visit('/apps/files') + // open the sidebar + openSharingPanel(shareName) + // create the share + cy.intercept('POST', '**/ocs/v2.php/apps/files_sharing/api/v1/shares').as('createShare') + cy.findByRole('button', { name: 'Create a new share link' }) + .click() + // extract the link + cy.wait('@createShare').should(({ response }) => { + const { ocs } = response?.body ?? {} + shareUrl = ocs?.data.url + expect(shareUrl).to.match(/^http:\/\//) + }) + + // Update the share to be a view-only-no-download share + cy.findByRole('list', { name: 'Link shares' }) + .findAllByRole('listitem') + .first() + .findByRole('button', { name: /Actions/i }) + .click() + cy.findByRole('menuitem', { name: /Customize link/i }) + .should('be.visible') + .click() + cy.get('[data-cy-files-sharing-share-permissions-bundle]') + .should('be.visible') + cy.get('[data-cy-files-sharing-share-permissions-bundle="read-only"]') + .click() + // save the update + cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('updateShare') + cy.findByRole('button', { name: 'Update share' }) + .click() + cy.wait('@updateShare') + }) + }) + + beforeEach(() => { + cy.logout() + cy.visit(shareUrl) + }) + + it('Can see the files list', () => { + // foo exists + getRowForFile('foo.txt') + .should('be.visible') + }) + + it('Can navigate to subfolder', () => { + getRowForFile('subfolder') + .should('be.visible') + + navigateToFolder('subfolder') + + getRowForFile('bar.txt') + .should('be.visible') + }) + + it('Cannot upload files', () => { + // wait for file list to be ready + getRowForFile('foo.txt') + .should('be.visible') + + cy.contains('button', 'New') + .should('be.visible') + .and('be.disabled') + }) + + it('Only download action is actions available', () => { + getActionsForFile('foo.txt') + .should('be.visible') + .click() + + // Only the download action + cy.findByRole('menuitem', { name: 'Download' }) + .should('be.visible') + cy.findAllByRole('menuitem') + .should('have.length', 1) + + // Can download + cy.findByRole('menuitem', { name: 'Download' }).click() + // check a file is downloaded + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/foo.txt`, 'utf-8', { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 5) + .and('contain', 'content') + }) +}) diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 1574a03705f..23f93ea14d9 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -66,6 +66,8 @@ declare global { */ runOccCommand(command: string, options?: Partial): Cypress.Chainable, + userFileExists(user: string, path: string): Cypress.Chainable + /** * Create a snapshot of the current database */ @@ -75,7 +77,11 @@ declare global { * Restore a snapshot of the database * Default is the post-setup state */ - restoreDB(snapshot?: string): Cypress.Chainable, + restoreDB(snapshot?: string): Cypress.Chainable + + backupData(users?: string[]): Cypress.Chainable + + restoreData(snapshot?: string): Cypress.Chainable } } } @@ -85,7 +91,7 @@ Cypress.env('baseUrl', url) /** * Enable or disable a user - * TODO: standardise in @nextcloud/cypress + * TODO: standardize in @nextcloud/cypress * * @param {User} user the user to dis- / enable * @param {boolean} enable True if the user should be enable, false to disable @@ -112,7 +118,7 @@ Cypress.Commands.add('enableUser', (user: User, enable = true) => { /** * cy.uploadedFile - uploads a file from the fixtures folder - * TODO: standardise in @nextcloud/cypress + * TODO: standardize in @nextcloud/cypress * * @param {User} user the owner of the file, e.g. admin * @param {string} fixture the fixture file name, e.g. image1.jpg @@ -188,7 +194,7 @@ Cypress.Commands.add('mkdir', (user: User, target: string) => { /** * cy.uploadedContent - uploads a raw content - * TODO: standardise in @nextcloud/cypress + * TODO: standardize in @nextcloud/cypress * * @param {User} user the owner of the file, e.g. admin * @param {Blob} blob the content to upload @@ -288,6 +294,13 @@ Cypress.Commands.add('runOccCommand', (command: string, options?: Partial { + user.replaceAll('"', '\\"') + path.replaceAll('"', '\\"').replaceAll(/^\/+/gm, '') + return cy.exec(`docker exec --user www-data nextcloud-cypress-tests-server stat --printf="%s" "data/${user}/files/${path}"`, { failOnNonZeroExit: true }) + .then((exec) => Number.parseInt(exec.stdout || '0')) +}) + Cypress.Commands.add('backupDB', (): Cypress.Chainable => { const randomString = Math.random().toString(36).substring(7) cy.exec(`docker exec --user www-data nextcloud-cypress-tests-server cp /var/www/html/data/owncloud.db /var/www/html/data/owncloud.db-${randomString}`) @@ -299,3 +312,18 @@ Cypress.Commands.add('restoreDB', (snapshot: string = 'init') => { cy.exec(`docker exec --user www-data nextcloud-cypress-tests-server cp /var/www/html/data/owncloud.db-${snapshot} /var/www/html/data/owncloud.db`) cy.log(`Restored snapshot ${snapshot}`) }) + +Cypress.Commands.add('backupData', (users: string[] = ['admin']) => { + const snapshot = Math.random().toString(36).substring(7) + const toBackup = users.map((user) => `'${user.replaceAll('\\', '').replaceAll('\'', '\\\'')}'`).join(' ') + cy.exec(`docker exec --user www-data rm /var/www/html/data/data-${snapshot}.tar`, { failOnNonZeroExit: false }) + cy.exec(`docker exec --user www-data --workdir /var/www/html/data nextcloud-cypress-tests-server tar cf /var/www/html/data/data-${snapshot}.tar ${toBackup}`) + return cy.wrap(snapshot as string) +}) + +Cypress.Commands.add('restoreData', (snapshot?: string) => { + snapshot = snapshot ?? 'init' + snapshot.replaceAll('\\', '').replaceAll('"', '\\"') + cy.exec(`docker exec --user www-data --workdir /var/www/html/data nextcloud-cypress-tests-server rm -vfr $(tar --exclude='*/*' -tf '/var/www/html/data/data-${snapshot}.tar')`) + cy.exec(`docker exec --user www-data --workdir /var/www/html/data nextcloud-cypress-tests-server tar -xf '/var/www/html/data/data-${snapshot}.tar'`) +}) diff --git a/cypress/support/utils/assertions.ts b/cypress/support/utils/assertions.ts index 8e5acbd306a..08b93b32e86 100644 --- a/cypress/support/utils/assertions.ts +++ b/cypress/support/utils/assertions.ts @@ -17,9 +17,9 @@ export function zipFileContains(expectedFiles: string[]) { const blob = new Blob([buffer]) const zip = new ZipReader(blob.stream()) // check the real file names - const entries = (await zip.getEntries()).map((e) => e.filename) + const entries = (await zip.getEntries()).map((e) => e.filename).sort() console.info('Zip contains entries:', entries) - expect(entries).to.deep.equal(expectedFiles) + expect(entries).to.deep.equal(expectedFiles.sort()) } } diff --git a/package-lock.json b/package-lock.json index b186c8e27d6..affc85c6361 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ "@nextcloud/moment": "^1.3.1", "@nextcloud/password-confirmation": "^5.1.1", "@nextcloud/paths": "^2.2.1", - "@nextcloud/router": "^3.0.0", + "@nextcloud/router": "^3.0.1", "@nextcloud/sharing": "^0.2.3", "@nextcloud/upload": "^1.6.0", "@nextcloud/vue": "^8.17.1", diff --git a/package.json b/package.json index 40ac5765242..27f34132c8f 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "@nextcloud/moment": "^1.3.1", "@nextcloud/password-confirmation": "^5.1.1", "@nextcloud/paths": "^2.2.1", - "@nextcloud/router": "^3.0.0", + "@nextcloud/router": "^3.0.1", "@nextcloud/sharing": "^0.2.3", "@nextcloud/upload": "^1.6.0", "@nextcloud/vue": "^8.17.1", diff --git a/vitest.config.ts b/vitest.config.ts index e58902789b3..cdf322223bd 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,6 +10,11 @@ export default defineConfig({ test: { include: ['{apps,core}/**/*.{test,spec}.?(c|m)[jt]s?(x)'], environment: 'jsdom', + environmentOptions: { + jsdom: { + url: 'http://nextcloud.local', + }, + }, coverage: { include: ['apps/*/src/**', 'core/src/**'], exclude: ['**.spec.*', '**.test.*', '**.cy.*', 'core/src/tests/**'],