aboutsummaryrefslogtreecommitdiffstats
path: root/cypress
diff options
context:
space:
mode:
Diffstat (limited to 'cypress')
-rw-r--r--cypress/dockerNode.ts144
-rw-r--r--cypress/e2e/core-utils.ts61
-rw-r--r--cypress/e2e/core/404-error.cy.ts19
-rw-r--r--cypress/e2e/core/header_access-levels.cy.ts23
-rw-r--r--cypress/e2e/core/header_contacts-menu.cy.ts21
-rw-r--r--cypress/e2e/core/setup.ts145
-rw-r--r--cypress/e2e/dashboard/widget-performance.cy.ts41
-rw-r--r--cypress/e2e/files/FilesUtils.ts258
-rw-r--r--cypress/e2e/files/LivePhotosUtils.ts104
-rw-r--r--cypress/e2e/files/drag-n-drop.cy.ts4
-rw-r--r--cypress/e2e/files/duplicated-node-regression.cy.ts33
-rw-r--r--cypress/e2e/files/favorites.cy.ts137
-rw-r--r--cypress/e2e/files/files-actions.cy.ts216
-rw-r--r--cypress/e2e/files/files-copy-move.cy.ts (renamed from cypress/e2e/files/files_copy-move.cy.ts)21
-rw-r--r--cypress/e2e/files/files-delete.cy.ts74
-rw-r--r--cypress/e2e/files/files-download.cy.ts351
-rw-r--r--cypress/e2e/files/files-filtering.cy.ts280
-rw-r--r--cypress/e2e/files/files-navigation.cy.ts55
-rw-r--r--cypress/e2e/files/files-renaming.cy.ts285
-rw-r--r--cypress/e2e/files/files-searching.cy.ts105
-rw-r--r--cypress/e2e/files/files-selection.cy.ts77
-rw-r--r--cypress/e2e/files/files-settings.cy.ts102
-rw-r--r--cypress/e2e/files/files-sidebar.cy.ts126
-rw-r--r--cypress/e2e/files/files-sorting.cy.ts (renamed from cypress/e2e/files/files_sorting.cy.ts)108
-rw-r--r--cypress/e2e/files/files-xml-regression.cy.ts23
-rw-r--r--cypress/e2e/files/files.cy.ts65
-rw-r--r--cypress/e2e/files/live_photos.cy.ts141
-rw-r--r--cypress/e2e/files/new-menu.cy.ts123
-rw-r--r--cypress/e2e/files/recent-view.cy.ts44
-rw-r--r--cypress/e2e/files/router-query.cy.ts180
-rw-r--r--cypress/e2e/files/scrolling.cy.ts284
-rw-r--r--cypress/e2e/files/search.cy.ts217
-rw-r--r--cypress/e2e/files_external/StorageUtils.ts38
-rw-r--r--cypress/e2e/files_external/files-external-failed.cy.ts75
-rw-r--r--cypress/e2e/files_external/files-user-credentials.cy.ts143
-rw-r--r--cypress/e2e/files_external/settings.cy.ts130
-rw-r--r--cypress/e2e/files_sharing/FilesSharingUtils.ts199
-rw-r--r--cypress/e2e/files_sharing/ShareOptionsType.ts18
-rw-r--r--cypress/e2e/files_sharing/expiry-date.cy.ts128
-rw-r--r--cypress/e2e/files_sharing/file-request.cy.ts83
-rw-r--r--cypress/e2e/files_sharing/files-copy-move.cy.ts150
-rw-r--r--cypress/e2e/files_sharing/files-download.cy.ts102
-rw-r--r--cypress/e2e/files_sharing/files-shares-view.cy.ts59
-rw-r--r--cypress/e2e/files_sharing/filesSharingUtils.ts112
-rw-r--r--cypress/e2e/files_sharing/limit_to_same_group.cy.ts107
-rw-r--r--cypress/e2e/files_sharing/note-to-recipient.cy.ts92
-rw-r--r--cypress/e2e/files_sharing/public-share/PublicShareUtils.ts191
-rw-r--r--cypress/e2e/files_sharing/public-share/copy-move-files.cy.ts49
-rw-r--r--cypress/e2e/files_sharing/public-share/default-view.cy.ts102
-rw-r--r--cypress/e2e/files_sharing/public-share/download.cy.ts266
-rw-r--r--cypress/e2e/files_sharing/public-share/header-avatar.cy.ts193
-rw-r--r--cypress/e2e/files_sharing/public-share/header-menu.cy.ts199
-rw-r--r--cypress/e2e/files_sharing/public-share/rename-files.cy.ts32
-rw-r--r--cypress/e2e/files_sharing/public-share/required-before-create.cy.ts192
-rw-r--r--cypress/e2e/files_sharing/public-share/sidebar-tab.cy.ts45
-rw-r--r--cypress/e2e/files_sharing/public-share/view_file-drop.cy.ts172
-rw-r--r--cypress/e2e/files_sharing/public-share/view_view-only-no-download.cy.ts100
-rw-r--r--cypress/e2e/files_sharing/public-share/view_view-only.cy.ts103
-rw-r--r--cypress/e2e/files_sharing/share-status-action.cy.ts125
-rw-r--r--cypress/e2e/files_trashbin/files-trash-action.cy.ts69
-rw-r--r--cypress/e2e/files_trashbin/files.cy.ts70
-rw-r--r--cypress/e2e/files_versions/filesVersionsUtils.ts27
-rw-r--r--cypress/e2e/files_versions/version_creation.cy.ts35
-rw-r--r--cypress/e2e/files_versions/version_cross_share_move_and_copy.cy.ts21
-rw-r--r--cypress/e2e/files_versions/version_deletion.cy.ts46
-rw-r--r--cypress/e2e/files_versions/version_download.cy.ts53
-rw-r--r--cypress/e2e/files_versions/version_expiration.cy.ts25
-rw-r--r--cypress/e2e/files_versions/version_naming.cy.ts102
-rw-r--r--cypress/e2e/files_versions/version_restoration.cy.ts50
-rw-r--r--cypress/e2e/files_versions/version_sharing.cy.ts46
-rw-r--r--cypress/e2e/login/login-redirect.cy.ts62
-rw-r--r--cypress/e2e/login/login.cy.ts21
-rw-r--r--cypress/e2e/login/webauth.cy.ts152
-rw-r--r--cypress/e2e/settings/access-levels.cy.ts29
-rw-r--r--cypress/e2e/settings/apps.cy.ts30
-rw-r--r--cypress/e2e/settings/personal-info.cy.ts121
-rw-r--r--cypress/e2e/settings/users-group-admin.cy.ts186
-rw-r--r--cypress/e2e/settings/users.cy.ts32
-rw-r--r--cypress/e2e/settings/usersUtils.ts21
-rw-r--r--cypress/e2e/settings/users_columns.cy.ts21
-rw-r--r--cypress/e2e/settings/users_disable.cy.ts21
-rw-r--r--cypress/e2e/settings/users_groups.cy.ts91
-rw-r--r--cypress/e2e/settings/users_manager.cy.ts121
-rw-r--r--cypress/e2e/settings/users_modify.cy.ts64
-rw-r--r--cypress/e2e/systemtags/admin-settings.cy.ts21
-rw-r--r--cypress/e2e/systemtags/files-bulk-action.cy.ts468
-rw-r--r--cypress/e2e/systemtags/files-inline-action.cy.ts172
-rw-r--r--cypress/e2e/systemtags/files-sidebar.cy.ts44
-rw-r--r--cypress/e2e/theming/a11y-color-contrast.cy.ts10
-rw-r--r--cypress/e2e/theming/admin-settings.cy.ts360
-rw-r--r--cypress/e2e/theming/admin-settings_default-app.cy.ts91
-rw-r--r--cypress/e2e/theming/admin-settings_urls.cy.ts143
-rw-r--r--cypress/e2e/theming/themingUtils.ts93
-rw-r--r--cypress/e2e/theming/user-settings_app-order.cy.ts (renamed from cypress/e2e/theming/navigation-bar-settings.cy.ts)228
-rw-r--r--cypress/e2e/theming/user-settings_background.cy.ts (renamed from cypress/e2e/theming/user-background.cy.ts)117
-rw-r--r--cypress/fixtures/testapp/appinfo/routes.php7
-rw-r--r--cypress/fixtures/testapp/img/app.svg4
-rw-r--r--cypress/fixtures/testapp/lib/AppInfo/Application.php7
-rw-r--r--cypress/fixtures/testapp/lib/Controller/PageController.php15
-rw-r--r--cypress/fixtures/testapp/templates/main.php6
-rw-r--r--cypress/pages/FilesFilters.ts34
-rw-r--r--cypress/pages/FilesNavigation.ts46
-rw-r--r--cypress/pages/NavigationHeader.ts58
-rw-r--r--cypress/pages/UnifiedSearch.ts75
-rw-r--r--cypress/support/commands.ts132
-rw-r--r--cypress/support/commonUtils.ts27
-rw-r--r--cypress/support/component-index.html4
-rw-r--r--cypress/support/component.ts61
-rw-r--r--cypress/support/cypress-component.d.ts17
-rw-r--r--cypress/support/cypress-e2e.d.ts64
-rw-r--r--cypress/support/e2e.ts25
-rw-r--r--cypress/support/utils/assertions.ts40
-rw-r--r--cypress/tsconfig.json11
113 files changed, 9353 insertions, 1615 deletions
diff --git a/cypress/dockerNode.ts b/cypress/dockerNode.ts
index de724e237bd..6e21b33101c 100644
--- a/cypress/dockerNode.ts
+++ b/cypress/dockerNode.ts
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
/* eslint-disable no-console */
/* eslint-disable n/no-unpublished-import */
@@ -25,12 +8,14 @@
import Docker from 'dockerode'
import waitOn from 'wait-on'
-import tar from 'tar'
+import { c as createTar } from 'tar'
+import path, { basename } from 'path'
import { execSync } from 'child_process'
+import { existsSync } from 'fs'
export const docker = new Docker()
-const CONTAINER_NAME = 'nextcloud-cypress-tests-server'
+const CONTAINER_NAME = `nextcloud-cypress-tests_${basename(process.cwd()).replace(' ', '')}`
const SERVER_IMAGE = 'ghcr.io/nextcloud/continuous-integration-shallow-server'
/**
@@ -56,10 +41,6 @@ export const startNextcloud = async function(branch: string = getCurrentGitBranc
// https://github.com/apocas/dockerode/issues/357
docker.modem.followProgress(stream, onFinished)
- /**
- *
- * @param err
- */
function onFinished(err) {
if (!err) {
resolve(true)
@@ -68,6 +49,10 @@ export const startNextcloud = async function(branch: string = getCurrentGitBranc
reject(err)
}
}))
+
+ const digest = await (await docker.getImage(SERVER_IMAGE).inspect()).RepoDigests.at(0)
+ const sha = digest?.split('@').at(1)
+ console.log('├─ Using image ' + sha)
console.log('└─ Done')
} catch (e) {
console.log('└─ Failed to pull images')
@@ -97,14 +82,33 @@ export const startNextcloud = async function(branch: string = getCurrentGitBranc
Image: SERVER_IMAGE,
name: CONTAINER_NAME,
HostConfig: {
- Binds: [],
+ Mounts: [{
+ Target: '/var/www/html/data',
+ Source: '',
+ Type: 'tmpfs',
+ ReadOnly: false,
+ }],
+ PortBindings: {
+ '80/tcp': [{
+ HostIP: '0.0.0.0',
+ HostPort: '8083',
+ }],
+ },
+ // If running the setup tests, let's bind to host
+ // to communicate with the github actions DB services
+ NetworkMode: process.env.SETUP_TESTING === 'true' ? await getGithubNetwork() : undefined,
},
Env: [
`BRANCH=${branch}`,
+ 'APCU=1',
],
})
await container.start()
+ // Set proper permissions for the data folder
+ await runExec(container, ['chown', '-R', 'www-data:www-data', '/var/www/html/data'], false, 'root')
+ await runExec(container, ['chmod', '0770', '/var/www/html/data'], false, 'root')
+
// Get container's IP
const ip = await getContainerIP(container)
@@ -132,9 +136,30 @@ export const configureNextcloud = async function() {
await runExec(container, ['php', 'occ', 'config:system:set', 'default_locale', '--value', 'en_US'], true)
await runExec(container, ['php', 'occ', 'config:system:set', 'force_locale', '--value', 'en_US'], true)
await runExec(container, ['php', 'occ', 'config:system:set', 'enforce_theme', '--value', 'light'], true)
+
// Speed up test and make them less flaky. If a cron execution is needed, it can be triggered manually.
await runExec(container, ['php', 'occ', 'background:cron'], true)
+ // Checking apcu
+ const distributed = await runExec(container, ['php', 'occ', 'config:system:get', 'memcache.distributed'])
+ const local = await runExec(container, ['php', 'occ', 'config:system:get', 'memcache.local'])
+ const hashing = await runExec(container, ['php', 'occ', 'config:system:get', 'hashing_default_password'])
+
+ console.log('├─ Checking APCu configuration... 👀')
+ if (!distributed.trim().includes('Memcache\\APCu')
+ || !local.trim().includes('Memcache\\APCu')
+ || !hashing.trim().includes('true')) {
+ console.log('└─ APCu is not properly configured 🛑')
+ throw new Error('APCu is not properly configured')
+ }
+ console.log('│ └─ OK !')
+
+ // 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 🎉')
}
@@ -146,7 +171,6 @@ export const configureNextcloud = async function() {
*/
export const applyChangesToNextcloud = async function() {
console.log('\nApply local changes to nextcloud...')
- const container = docker.getContainer(CONTAINER_NAME)
const htmlPath = '/var/www/html'
const folderPaths = [
@@ -158,6 +182,7 @@ export const applyChangesToNextcloud = async function() {
'./ocs',
'./ocs-provider',
'./resources',
+ './tests',
'./console.php',
'./cron.php',
'./index.php',
@@ -166,14 +191,27 @@ export const applyChangesToNextcloud = async function() {
'./remote.php',
'./status.php',
'./version.php',
- ]
+ ].filter((folderPath) => {
+ const fullPath = path.resolve(__dirname, '..', folderPath)
- folderPaths.forEach((path) => {
- console.log(`├─ Copying ${path}`)
+ if (existsSync(fullPath)) {
+ console.log(`├─ Copying ${folderPath}`)
+ return true
+ }
+ return false
})
+ // Don't try to apply changes, when there are none. Otherwise we
+ // still execute the 'chown' command, which is not needed.
+ if (folderPaths.length === 0) {
+ console.log('└─ No local changes found to apply')
+ return
+ }
+
+ const container = docker.getContainer(CONTAINER_NAME)
+
// Tar-streaming the above folders into the container
- const serverTar = tar.c({ gzip: false }, folderPaths)
+ const serverTar = createTar({ gzip: false }, folderPaths)
await container.putArchive(serverTar, {
path: htmlPath,
})
@@ -211,11 +249,16 @@ export const getContainerIP = async function(
while (ip === '' && tries < 10) {
tries++
- await container.inspect(function(err, data) {
+ container.inspect(function(err, data) {
if (err) {
throw err
}
- ip = data?.NetworkSettings?.IPAddress || ''
+
+ if (data?.HostConfig.PortBindings?.['80/tcp']?.[0]?.HostPort) {
+ ip = `localhost:${data.HostConfig.PortBindings['80/tcp'][0].HostPort}`
+ } else {
+ ip = data?.NetworkSettings?.IPAddress || ''
+ }
})
if (ip !== '') {
@@ -252,15 +295,18 @@ const runExec = async function(
command: string[],
verbose = false,
user = 'www-data',
-) {
+ workdir?: string,
+): Promise<string> {
const exec = await container.exec({
Cmd: command,
+ WorkingDir: workdir,
AttachStdout: true,
AttachStderr: true,
User: user,
})
return new Promise((resolve, reject) => {
+ let output = ''
exec.start({}, (err, stream) => {
if (err) {
reject(err)
@@ -268,11 +314,17 @@ const runExec = async function(
if (stream) {
stream.setEncoding('utf-8')
stream.on('data', str => {
- if (verbose && str.trim() !== '') {
- console.log(`├─ ${str.trim().replace(/\n/gi, '\n├─ ')}`)
+ str = str.trim()
+ // Remove non printable characters
+ .replace(/[^\x0A\x0D\x20-\x7E]+/g, '')
+ // Remove non alphanumeric leading characters
+ .replace(/^[^a-z]/gi, '')
+ output += str
+ if (verbose && str !== '') {
+ console.log(`├─ ${str.replace(/\n/gi, '\n├─ ')}`)
}
})
- stream.on('end', resolve)
+ stream.on('end', () => resolve(output))
}
})
})
@@ -285,3 +337,21 @@ const sleep = function(milliseconds: number) {
const getCurrentGitBranch = function() {
return execSync('git rev-parse --abbrev-ref HEAD').toString().trim() || 'master'
}
+
+/**
+ * Get the network name of the github actions network
+ * This is used to connect to the database services
+ * started by github actions
+ */
+const getGithubNetwork = async function(): Promise<string|undefined> {
+ console.log('├─ Looking for github actions network... 🔍')
+ const networks = await docker.listNetworks()
+ const network = networks.find((network) => network.Name.startsWith('github_network'))
+ if (network) {
+ console.log('│ └─ Found github actions network: ' + network.Name)
+ return network.Name
+ }
+
+ console.log('│ └─ No github actions network found')
+ return undefined
+}
diff --git a/cypress/e2e/core-utils.ts b/cypress/e2e/core-utils.ts
index d2209da460a..4756836387a 100644
--- a/cypress/e2e/core-utils.ts
+++ b/cypress/e2e/core-utils.ts
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @author Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
/**
@@ -65,3 +48,43 @@ export enum UnifiedSearchFilter {
export function getUnifiedSearchFilter(filter: UnifiedSearchFilter) {
return getUnifiedSearchModal().find(`[data-cy-unified-search-filters] [data-cy-unified-search-filter="${CSS.escape(filter)}"]`)
}
+
+/**
+ * Assertion that an element is fully within the current viewport.
+ * @param $el The element
+ * @param expected If the element is expected to be fully in viewport or not fully
+ * @example
+ * ```js
+ * cy.get('#my-element')
+ * .should(beFullyInViewport)
+ * ```
+ */
+export function beFullyInViewport($el: JQuery<HTMLElement>, expected = true) {
+ const { top, left, bottom, right } = $el.get(0)!.getBoundingClientRect()
+ const innerHeight = Cypress.$('body').innerHeight()!
+ const innerWidth = Cypress.$('body').innerWidth()!
+ const fullyVisible = top >= 0 && left >= 0 && bottom <= innerHeight && right <= innerWidth
+
+ console.debug(`fullyVisible: ${fullyVisible}, top: ${top >= 0}, left: ${left >= 0}, bottom: ${bottom <= innerHeight}, right: ${right <= innerWidth}`)
+
+ if (expected) {
+ // eslint-disable-next-line no-unused-expressions
+ expect(fullyVisible, 'Fully within viewport').to.be.true
+ } else {
+ // eslint-disable-next-line no-unused-expressions
+ expect(fullyVisible, 'Not fully within viewport').to.be.false
+ }
+}
+
+/**
+ * Opposite of `beFullyInViewport` - resolves when element is not or only partially in viewport.
+ * @param $el The element
+ * @example
+ * ```js
+ * cy.get('#my-element')
+ * .should(notBeFullyInViewport)
+ * ```
+ */
+export function notBeFullyInViewport($el: JQuery<HTMLElement>) {
+ return beFullyInViewport($el, false)
+}
diff --git a/cypress/e2e/core/404-error.cy.ts b/cypress/e2e/core/404-error.cy.ts
new file mode 100644
index 00000000000..b24562933e8
--- /dev/null
+++ b/cypress/e2e/core/404-error.cy.ts
@@ -0,0 +1,19 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+describe('404 error page', { testIsolation: true }, () => {
+ it('renders 404 page', () => {
+ cy.visit('/doesnotexist', { failOnStatusCode: false })
+
+ cy.findByRole('heading', { name: /Page not found/ })
+ .should('be.visible')
+ cy.findByRole('link', { name: /Back to Nextcloud/ })
+ .should('be.visible')
+ .click()
+
+ cy.url()
+ .should('match', /(\/index.php)\/login$/)
+ })
+})
diff --git a/cypress/e2e/core/header_access-levels.cy.ts b/cypress/e2e/core/header_access-levels.cy.ts
index d1529376cf9..b0e9ab8bac1 100644
--- a/cypress/e2e/core/header_access-levels.cy.ts
+++ b/cypress/e2e/core/header_access-levels.cy.ts
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @author Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { User } from '@nextcloud/cypress'
@@ -108,7 +91,7 @@ describe('Header: Ensure regular users do not have admin settings in the Setting
// I see that the "Apps" item in the Settings menu is shown
cy.contains('li', 'Apps').should('be.visible')
// I see that the "Users" item in the Settings menu is shown
- cy.contains('li', 'Users').should('be.visible')
+ cy.contains('li', 'Accounts').should('be.visible')
// I see that the "Help" item in the Settings menu is shown
cy.contains('li', 'Help').should('be.visible')
// I see that the "Log out" item in the Settings menu is shown
diff --git a/cypress/e2e/core/header_contacts-menu.cy.ts b/cypress/e2e/core/header_contacts-menu.cy.ts
index d4c8ffe7b1b..6279b72a78d 100644
--- a/cypress/e2e/core/header_contacts-menu.cy.ts
+++ b/cypress/e2e/core/header_contacts-menu.cy.ts
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @author Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { User } from '@nextcloud/cypress'
diff --git a/cypress/e2e/core/setup.ts b/cypress/e2e/core/setup.ts
new file mode 100644
index 00000000000..a9174a3ebe7
--- /dev/null
+++ b/cypress/e2e/core/setup.ts
@@ -0,0 +1,145 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * DO NOT RENAME THIS FILE to .cy.ts ⚠️
+ * This is not following the pattern of the other files in this folder
+ * because it is manually added to the tests by the cypress config.
+ */
+describe('Can install Nextcloud', { testIsolation: true, retries: 0 }, () => {
+ beforeEach(() => {
+ // Move the config file and data folder
+ cy.runCommand('rm /var/www/html/config/config.php', { failOnNonZeroExit: false })
+ cy.runCommand('rm /var/www/html/data/owncloud.db', { failOnNonZeroExit: false })
+ })
+
+ it('Sqlite', () => {
+ cy.visit('/')
+ cy.get('[data-cy-setup-form]').should('be.visible')
+ cy.get('[data-cy-setup-form-field="adminlogin"]').should('be.visible')
+ cy.get('[data-cy-setup-form-field="adminpass"]').should('be.visible')
+ cy.get('[data-cy-setup-form-field="directory"]').should('have.value', '/var/www/html/data')
+
+ // Select the SQLite database
+ cy.get('[data-cy-setup-form-field="dbtype-sqlite"] input').check({ force: true })
+
+ sharedSetup()
+ })
+
+ it('MySQL', () => {
+ cy.visit('/')
+ cy.get('[data-cy-setup-form]').should('be.visible')
+ cy.get('[data-cy-setup-form-field="adminlogin"]').should('be.visible')
+ cy.get('[data-cy-setup-form-field="adminpass"]').should('be.visible')
+ cy.get('[data-cy-setup-form-field="directory"]').should('have.value', '/var/www/html/data')
+
+ // Select the SQLite database
+ cy.get('[data-cy-setup-form-field="dbtype-mysql"] input').check({ force: true })
+
+ // Fill in the DB form
+ cy.get('[data-cy-setup-form-field="dbuser"]').type('{selectAll}oc_autotest')
+ cy.get('[data-cy-setup-form-field="dbpass"]').type('{selectAll}nextcloud')
+ cy.get('[data-cy-setup-form-field="dbname"]').type('{selectAll}oc_autotest')
+ cy.get('[data-cy-setup-form-field="dbhost"]').type('{selectAll}mysql:3306')
+
+ sharedSetup()
+ })
+
+ it('MariaDB', () => {
+ cy.visit('/')
+ cy.get('[data-cy-setup-form]').should('be.visible')
+ cy.get('[data-cy-setup-form-field="adminlogin"]').should('be.visible')
+ cy.get('[data-cy-setup-form-field="adminpass"]').should('be.visible')
+ cy.get('[data-cy-setup-form-field="directory"]').should('have.value', '/var/www/html/data')
+
+ // Select the SQLite database
+ cy.get('[data-cy-setup-form-field="dbtype-mysql"] input').check({ force: true })
+
+ // Fill in the DB form
+ cy.get('[data-cy-setup-form-field="dbuser"]').type('{selectAll}oc_autotest')
+ cy.get('[data-cy-setup-form-field="dbpass"]').type('{selectAll}nextcloud')
+ cy.get('[data-cy-setup-form-field="dbname"]').type('{selectAll}oc_autotest')
+ cy.get('[data-cy-setup-form-field="dbhost"]').type('{selectAll}mariadb:3306')
+
+ sharedSetup()
+ })
+
+ it('PostgreSQL', () => {
+ cy.visit('/')
+ cy.get('[data-cy-setup-form]').should('be.visible')
+ cy.get('[data-cy-setup-form-field="adminlogin"]').should('be.visible')
+ cy.get('[data-cy-setup-form-field="adminpass"]').should('be.visible')
+ cy.get('[data-cy-setup-form-field="directory"]').should('have.value', '/var/www/html/data')
+
+ // Select the SQLite database
+ cy.get('[data-cy-setup-form-field="dbtype-pgsql"] input').check({ force: true })
+
+ // Fill in the DB form
+ cy.get('[data-cy-setup-form-field="dbuser"]').type('{selectAll}root')
+ cy.get('[data-cy-setup-form-field="dbpass"]').type('{selectAll}rootpassword')
+ cy.get('[data-cy-setup-form-field="dbname"]').type('{selectAll}nextcloud')
+ cy.get('[data-cy-setup-form-field="dbhost"]').type('{selectAll}postgres:5432')
+
+ sharedSetup()
+ })
+
+ it('Oracle', () => {
+ cy.runCommand('cp /var/www/html/tests/databases-all-config.php /var/www/html/config/config.php')
+ cy.visit('/')
+ cy.get('[data-cy-setup-form]').should('be.visible')
+ cy.get('[data-cy-setup-form-field="adminlogin"]').should('be.visible')
+ cy.get('[data-cy-setup-form-field="adminpass"]').should('be.visible')
+ cy.get('[data-cy-setup-form-field="directory"]').should('have.value', '/var/www/html/data')
+
+ // Select the SQLite database
+ cy.get('[data-cy-setup-form-field="dbtype-oci"] input').check({ force: true })
+
+ // Fill in the DB form
+ cy.get('[data-cy-setup-form-field="dbuser"]').type('{selectAll}system')
+ cy.get('[data-cy-setup-form-field="dbpass"]').type('{selectAll}oracle')
+ cy.get('[data-cy-setup-form-field="dbname"]').type('{selectAll}FREE')
+ cy.get('[data-cy-setup-form-field="dbhost"]').type('{selectAll}oracle:1521')
+
+ sharedSetup()
+ })
+
+})
+
+/**
+ * Shared admin setup function for the Nextcloud setup
+ */
+function sharedSetup() {
+ const randAdmin = 'admin-' + Math.random().toString(36).substring(2, 15)
+
+ // Fill in the form
+ cy.get('[data-cy-setup-form-field="adminlogin"]').type(randAdmin)
+ cy.get('[data-cy-setup-form-field="adminpass"]').type(randAdmin)
+
+ // Nothing more to do on sqlite, let's continue
+ cy.get('[data-cy-setup-form-submit]').click()
+
+ // Wait for the setup to finish
+ cy.location('pathname', { timeout: 10000 })
+ .should('include', '/core/apps/recommended')
+
+ // See the apps setup
+ cy.get('[data-cy-setup-recommended-apps]')
+ .should('be.visible')
+ .within(() => {
+ cy.findByRole('heading', { name: 'Recommended apps' })
+ .should('be.visible')
+ cy.findByRole('button', { name: 'Skip' })
+ .should('be.visible')
+ cy.findByRole('button', { name: 'Install recommended apps' })
+ .should('be.visible')
+ })
+
+ // Skip the setup apps
+ cy.get('[data-cy-setup-recommended-apps-skip]').click()
+
+ // Go to files
+ cy.visit('/apps/files/')
+ cy.get('[data-cy-files-content]').should('be.visible')
+}
diff --git a/cypress/e2e/dashboard/widget-performance.cy.ts b/cypress/e2e/dashboard/widget-performance.cy.ts
new file mode 100644
index 00000000000..99e46d7b0ae
--- /dev/null
+++ b/cypress/e2e/dashboard/widget-performance.cy.ts
@@ -0,0 +1,41 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * Regression test of https://github.com/nextcloud/server/issues/48403
+ * Ensure that only visible widget data is loaded
+ */
+describe('dashboard: performance', () => {
+ before(() => {
+ cy.createRandomUser().then((user) => {
+ // Enable one widget
+ cy.runOccCommand(`user:setting -- '${user.userId}' dashboard layout files-favorites`)
+ cy.login(user)
+ })
+ })
+
+ it('Only load needed widgets', () => {
+ cy.intercept('**/dashboard/api/v2/widget-items?widgets*').as('loadedWidgets')
+
+ const now = new Date(2025, 0, 14, 15)
+ cy.clock(now)
+
+ // The dashboard is loaded
+ cy.visit('/apps/dashboard')
+ cy.get('#app-dashboard')
+ .should('be.visible')
+ .contains('Good afternoon')
+ .should('be.visible')
+
+ // Wait that one data is loaded (ensure the API works), this should be the favorite files.
+ cy.wait('@loadedWidgets')
+ // Wait and check no requests are made (ensure that the user statuses data is NOT loaded)
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.wait(4000, { timeout: 8000 })
+ cy.get('@loadedWidgets.all').then((interceptions) => {
+ expect(interceptions).to.have.length(1)
+ })
+ })
+})
diff --git a/cypress/e2e/files/FilesUtils.ts b/cypress/e2e/files/FilesUtils.ts
index b23d9997390..71ea341a7bf 100644
--- a/cypress/e2e/files/FilesUtils.ts
+++ b/cypress/e2e/files/FilesUtils.ts
@@ -1,57 +1,122 @@
/**
- * @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @author Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * 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
@@ -78,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
@@ -109,28 +174,36 @@ 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()
- })
+ }
}
export const closeSidebar = () => {
// {force: true} as it might be hidden behind toasts
- cy.get('[cy-data-sidebar] .app-sidebar__close').click({ force: true })
+ cy.get('[data-cy-sidebar] .app-sidebar__close').click({ force: true })
}
export const clickOnBreadcrumbs = (label: string) => {
@@ -138,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/drag-n-drop.cy.ts b/cypress/e2e/files/drag-n-drop.cy.ts
index 86a3bcfb571..d8df1938694 100644
--- a/cypress/e2e/files/drag-n-drop.cy.ts
+++ b/cypress/e2e/files/drag-n-drop.cy.ts
@@ -1,3 +1,7 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
import { getRowForFile } from './FilesUtils.ts'
describe('files: Drag and Drop', { testIsolation: true }, () => {
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 7c80cb12ead..086248eef3c 100644
--- a/cypress/e2e/files/files_copy-move.cy.ts
+++ b/cypress/e2e/files/files-copy-move.cy.ts
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @author Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getRowForFile, moveFile, copyFile, navigateToFolder } from './FilesUtils.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 293d267bbb0..00000000000
--- a/cypress/e2e/files/files-searching.cy.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-/**
- * @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @author Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-import type { User } from '@nextcloud/cypress'
-import { getRowForFile, navigateToFolder } from './FilesUtils'
-import { UnifiedSearchFilter, getUnifiedSearchFilter, getUnifiedSearchInput, getUnifiedSearchModal, openUnifiedSearch } from '../core-utils.ts'
-
-describe('files: Search and filter in files list', { testIsolation: true }, () => {
- 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('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
- openUnifiedSearch()
- getUnifiedSearchInput().type('a folder')
- getUnifiedSearchFilter(UnifiedSearchFilter.FilterCurrentView).click({ force: true })
- // Wait for modal to close
- getUnifiedSearchModal().should('not.be.visible')
-
- // 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
- openUnifiedSearch()
- getUnifiedSearchInput().type('a folder')
- getUnifiedSearchFilter(UnifiedSearchFilter.FilterCurrentView).click({ force: true })
- // Wait for modal to close
- getUnifiedSearchModal().should('not.be.visible')
-
- // 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
- openUnifiedSearch()
- getUnifiedSearchInput().type('a folder')
- getUnifiedSearchFilter(UnifiedSearchFilter.FilterCurrentView).click({ force: true })
- // Wait for modal to close
- getUnifiedSearchModal().should('not.be.visible')
-
- // 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')
- })
-})
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 497f7c8782e..b363e630b44 100644
--- a/cypress/e2e/files/files-settings.cy.ts
+++ b/cypress/e2e/files/files-settings.cy.ts
@@ -1,39 +1,66 @@
/**
- * @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @author Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
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
@@ -114,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
new file mode 100644
index 00000000000..f5c4205c462
--- /dev/null
+++ b/cypress/e2e/files/files-sidebar.cy.ts
@@ -0,0 +1,126 @@
+/**
+ * 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 { assertNotExistOrNotVisible } from '../settings/usersUtils'
+
+describe('Files: Sidebar', { testIsolation: true }, () => {
+ let user: User
+ let fileId: number = 0
+
+ beforeEach(() => cy.createRandomUser().then(($user) => {
+ user = $user
+
+ cy.mkdir(user, '/folder')
+ cy.uploadContent(user, new Blob([]), 'text/plain', '/file').then((response) => {
+ fileId = Number.parseInt(response.headers['oc-fileid'] ?? '0')
+ })
+ cy.login(user)
+ }))
+
+ it('opens the sidebar', () => {
+ cy.visit('/apps/files')
+ getRowForFile('file').should('be.visible')
+
+ triggerActionForFile('file', 'details')
+
+ cy.get('[data-cy-sidebar]')
+ .should('be.visible')
+ .findByRole('heading', { name: 'file' })
+ .should('be.visible')
+ })
+
+ it('changes the current fileid', () => {
+ cy.visit('/apps/files')
+ getRowForFile('file').should('be.visible')
+
+ triggerActionForFile('file', 'details')
+
+ cy.get('[data-cy-sidebar]').should('be.visible')
+ cy.url().should('contain', `apps/files/files/${fileId}`)
+ })
+
+ it('changes the sidebar content on other file', () => {
+ cy.visit('/apps/files')
+ getRowForFile('file').should('be.visible')
+
+ triggerActionForFile('file', 'details')
+
+ cy.get('[data-cy-sidebar]')
+ .should('be.visible')
+ .findByRole('heading', { name: 'file' })
+ .should('be.visible')
+
+ triggerActionForFile('folder', 'details')
+ cy.get('[data-cy-sidebar]')
+ .should('be.visible')
+ .findByRole('heading', { name: 'folder' })
+ .should('be.visible')
+ })
+
+ it('closes the sidebar on navigation', () => {
+ cy.visit('/apps/files')
+
+ getRowForFile('file').should('be.visible')
+ getRowForFile('folder').should('be.visible')
+
+ // open the sidebar
+ triggerActionForFile('file', 'details')
+ // validate it is open
+ cy.get('[data-cy-sidebar]')
+ .should('be.visible')
+
+ // if we navigate to the folder
+ navigateToFolder('folder')
+ // the sidebar should not be visible anymore
+ cy.get('[data-cy-sidebar]')
+ .should(assertNotExistOrNotVisible)
+ })
+
+ it('closes the sidebar on delete', () => {
+ cy.intercept('DELETE', `**/remote.php/dav/files/${user.userId}/file`).as('deleteFile')
+ // visit the files app
+ cy.visit('/apps/files')
+ getRowForFile('file').should('be.visible')
+ // open the sidebar
+ triggerActionForFile('file', 'details')
+ // validate it is open
+ cy.get('[data-cy-sidebar]').should('be.visible')
+ // delete the file
+ triggerActionForFile('file', 'delete')
+ cy.wait('@deleteFile', { timeout: 10000 })
+ // see the sidebar is closed
+ cy.get('[data-cy-sidebar]')
+ .should(assertNotExistOrNotVisible)
+ })
+
+ it('changes the fileid on delete', () => {
+ cy.intercept('DELETE', `**/remote.php/dav/files/${user.userId}/folder/other`).as('deleteFile')
+
+ cy.uploadContent(user, new Blob([]), 'text/plain', '/folder/other').then((response) => {
+ const otherFileId = Number.parseInt(response.headers['oc-fileid'] ?? '0')
+ cy.login(user)
+ cy.visit('/apps/files')
+
+ getRowForFile('folder').should('be.visible')
+ navigateToFolder('folder')
+ getRowForFile('other').should('be.visible')
+
+ // open the sidebar
+ triggerActionForFile('other', 'details')
+ // validate it is open
+ cy.get('[data-cy-sidebar]').should('be.visible')
+ cy.url().should('contain', `apps/files/files/${otherFileId}`)
+
+ 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 3e46d868c1e..9e726bf96e1 100644
--- a/cypress/e2e/files/files_sorting.cy.ts
+++ b/cypress/e2e/files/files-sorting.cy.ts
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
describe('Files: Sorting the file list', { testIsolation: true }, () => {
let currentUser
@@ -58,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')
@@ -158,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) => {
@@ -259,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 5e26418d442..a961b78e2f4 100644
--- a/cypress/e2e/files/files-xml-regression.cy.ts
+++ b/cypress/e2e/files/files-xml-regression.cy.ts
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2024 Ferdinand Thiessen
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getRowForFile, triggerActionForFile } from './FilesUtils.ts'
@@ -57,7 +40,7 @@ describe('Files: Can handle XML entities in file names', { testIsolation: false
triggerActionForFile('&amp;.txt', 'delete')
cy.wait('@deleteFile')
- cy.contains('.toast-success', /Delete .* successfull/)
+ cy.contains('.toast-success', /Delete .* done/)
.should('be.visible')
getRowForFile('&amp;.txt').should('not.exist')
diff --git a/cypress/e2e/files/files.cy.ts b/cypress/e2e/files/files.cy.ts
index 33261be417e..efae1116d2d 100644
--- a/cypress/e2e/files/files.cy.ts
+++ b/cypress/e2e/files/files.cy.ts
@@ -1,33 +1,58 @@
+import type { User } from "@nextcloud/cypress"
+
/**
- * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * 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 aa2224c787b..8eb4efaaec0 100644
--- a/cypress/e2e/files/live_photos.cy.ts
+++ b/cypress/e2e/files/live_photos.cy.ts
@@ -1,95 +1,37 @@
/**
- * @copyright Copyright (c) 2024 Louis Chmn <louis@chmn.me>
- *
- * @author Louis Chmn <louis@chmn.me>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
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', () => {
@@ -98,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", () => {
@@ -130,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($|&)/)
+ })
+})
diff --git a/cypress/e2e/files_external/StorageUtils.ts b/cypress/e2e/files_external/StorageUtils.ts
new file mode 100644
index 00000000000..0f7fec65edf
--- /dev/null
+++ b/cypress/e2e/files_external/StorageUtils.ts
@@ -0,0 +1,38 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from "@nextcloud/cypress"
+
+export type StorageConfig = {
+ [key: string]: string
+}
+
+export enum StorageBackend {
+ DAV = 'dav',
+ SMB = 'smb',
+ SFTP = 'sftp',
+}
+
+export enum AuthBackend {
+ GlobalAuth = 'password::global',
+ LoginCredentials = 'password::logincredentials',
+ Password = 'password::password',
+ SessionCredentials = 'password::sessioncredentials',
+ UserGlobalAuth = 'password::global::user',
+ UserProvided = 'password::userprovided',
+}
+
+/**
+ * Create a storage via occ
+ */
+export function createStorageWithConfig(mountPoint: string, storageBackend: StorageBackend, authBackend: AuthBackend, configs: StorageConfig, user?: User): Cypress.Chainable {
+ const configsFlag = Object.keys(configs).map(key => `--config "${key}=${configs[key]}"`).join(' ')
+ const userFlag = user ? `--user ${user.userId}` : ''
+
+ const command = `files_external:create "${mountPoint}" "${storageBackend}" "${authBackend}" ${configsFlag} ${userFlag}`
+
+ cy.log(`Creating storage with command: ${command}`)
+ return cy.runOccCommand(command)
+}
diff --git a/cypress/e2e/files_external/files-external-failed.cy.ts b/cypress/e2e/files_external/files-external-failed.cy.ts
new file mode 100644
index 00000000000..29e5454dd60
--- /dev/null
+++ b/cypress/e2e/files_external/files-external-failed.cy.ts
@@ -0,0 +1,75 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { User } from '@nextcloud/cypress'
+import { AuthBackend, createStorageWithConfig, StorageBackend } from './StorageUtils'
+import { getRowForFile } from '../files/FilesUtils'
+
+describe('Files user credentials', { testIsolation: true }, () => {
+ let currentUser: User
+
+ beforeEach(() => {
+ })
+
+ before(() => {
+ cy.runOccCommand('app:enable files_external')
+ cy.createRandomUser().then((user) => { currentUser = user })
+ })
+
+ afterEach(() => {
+ // Cleanup global storages
+ cy.runOccCommand('files_external:list --output=json').then(({ stdout }) => {
+ const list = JSON.parse(stdout)
+ list.forEach((storage) => cy.runOccCommand(`files_external:delete --yes ${storage.mount_id}`), { failOnNonZeroExit: false })
+ })
+ })
+
+ after(() => {
+ cy.runOccCommand('app:disable files_external')
+ })
+
+ it('Create a failed user storage with invalid url', () => {
+ const url = 'http://cloud.domain.com/remote.php/dav/files/abcdef123456'
+ createStorageWithConfig('Storage1', StorageBackend.DAV, AuthBackend.LoginCredentials, { host: url.replace('index.php/', ''), secure: 'false' })
+
+ cy.login(currentUser)
+ cy.visit('/apps/files')
+
+ // Ensure the row is visible and marked as unavailable
+ getRowForFile('Storage1').as('row').should('be.visible')
+ cy.get('@row').find('[data-cy-files-list-row-name-link]')
+ .should('have.attr', 'title', 'This node is unavailable')
+
+ // Ensure clicking on the location does not open the folder
+ cy.location().then((loc) => {
+ cy.get('@row').find('[data-cy-files-list-row-name-link]').click()
+ cy.location('href').should('eq', loc.href)
+ })
+ })
+
+ it('Create a failed user storage with invalid login credentials', () => {
+ const url = 'http://cloud.domain.com/remote.php/dav/files/abcdef123456'
+ createStorageWithConfig('Storage2', StorageBackend.DAV, AuthBackend.Password, {
+ host: url.replace('index.php/', ''),
+ user: 'invaliduser',
+ password: 'invalidpassword',
+ secure: 'false',
+ })
+
+ cy.login(currentUser)
+ cy.visit('/apps/files')
+
+ // Ensure the row is visible and marked as unavailable
+ getRowForFile('Storage2').as('row').should('be.visible')
+ cy.get('@row').find('[data-cy-files-list-row-name-link]')
+ .should('have.attr', 'title', 'This node is unavailable')
+
+ // Ensure clicking on the location does not open the folder
+ cy.location().then((loc) => {
+ cy.get('@row').find('[data-cy-files-list-row-name-link]').click()
+ cy.location('href').should('eq', loc.href)
+ })
+ })
+})
diff --git a/cypress/e2e/files_external/files-user-credentials.cy.ts b/cypress/e2e/files_external/files-user-credentials.cy.ts
new file mode 100644
index 00000000000..b20b06b69ba
--- /dev/null
+++ b/cypress/e2e/files_external/files-user-credentials.cy.ts
@@ -0,0 +1,143 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { User } from '@nextcloud/cypress'
+import { AuthBackend, createStorageWithConfig, StorageBackend } from './StorageUtils'
+import { getInlineActionEntryForFile, getRowForFile, navigateToFolder, triggerInlineActionForFile } from '../files/FilesUtils'
+
+import { ACTION_CREDENTIALS_EXTERNAL_STORAGE } from '../../../apps/files_external/src/actions/enterCredentialsAction'
+import { handlePasswordConfirmation } from '../settings/usersUtils'
+
+describe('Files user credentials', { testIsolation: true }, () => {
+ let user1: User
+ let user2: User
+ let storageUser: User
+
+ before(() => {
+ cy.runOccCommand('app:enable files_external')
+
+ // Create some users
+ cy.createRandomUser().then((user) => { user1 = user })
+ cy.createRandomUser().then((user) => { user2 = user })
+
+ // This user will hold the webdav storage
+ cy.createRandomUser().then((user) => {
+ storageUser = user
+ cy.uploadFile(user, 'image.jpg')
+ })
+ })
+
+ after(() => {
+ // Cleanup global storages
+ cy.runOccCommand('files_external:list --output=json').then(({ stdout }) => {
+ const list = JSON.parse(stdout)
+ list.forEach((storage) => cy.runOccCommand(`files_external:delete --yes ${storage.mount_id}`), { failOnNonZeroExit: false })
+ })
+
+ cy.runOccCommand('app:disable files_external')
+ })
+
+ it('Create a user storage with user credentials', () => {
+ // Its not the public server address but the address so the server itself can connect to it
+ const base = 'http://localhost'
+ const host = `${base}/remote.php/dav/files/${storageUser.userId}`
+ createStorageWithConfig(storageUser.userId, StorageBackend.DAV, AuthBackend.UserProvided, { host, secure: 'false' })
+
+ cy.login(user1)
+ cy.visit('/apps/files/extstoragemounts')
+ getRowForFile(storageUser.userId).should('be.visible')
+
+ cy.intercept('PUT', '**/apps/files_external/userglobalstorages/*').as('setCredentials')
+
+ triggerInlineActionForFile(storageUser.userId, ACTION_CREDENTIALS_EXTERNAL_STORAGE)
+
+ // See credentials dialog
+ cy.findByRole('dialog', { name: 'Storage credentials' }).as('storageDialog')
+ cy.get('@storageDialog').should('be.visible')
+ cy.get('@storageDialog').findByRole('textbox', { name: 'Login' }).type(storageUser.userId)
+ cy.get('@storageDialog').get('input[type="password"]').type(storageUser.password)
+ cy.get('@storageDialog').get('button').contains('Confirm').click()
+ cy.get('@storageDialog').should('not.exist')
+
+ // Storage dialog now closed, the user auth dialog should be visible
+ cy.findByRole('dialog', { name: 'Confirm your password' }).as('authDialog')
+ cy.get('@authDialog').should('be.visible')
+ handlePasswordConfirmation(user1.password)
+
+ // Wait for the credentials to be set
+ cy.wait('@setCredentials')
+
+ // Auth dialog should be closed and the set credentials button should be gone
+ cy.get('@authDialog').should('not.exist', { timeout: 2000 })
+
+ getInlineActionEntryForFile(storageUser.userId, ACTION_CREDENTIALS_EXTERNAL_STORAGE)
+ .should('not.exist')
+
+ // Finally, the storage should be accessible
+ cy.visit('/apps/files')
+ navigateToFolder(storageUser.userId)
+ getRowForFile('image.jpg').should('be.visible')
+ })
+
+ it('Create a user storage with GLOBAL user credentials', () => {
+ // Its not the public server address but the address so the server itself can connect to it
+ const base = 'http://localhost'
+ const host = `${base}/remote.php/dav/files/${storageUser.userId}`
+ createStorageWithConfig('storage1', StorageBackend.DAV, AuthBackend.UserGlobalAuth, { host, secure: 'false' })
+
+ cy.login(user2)
+ cy.visit('/apps/files/extstoragemounts')
+ getRowForFile('storage1').should('be.visible')
+
+ cy.intercept('PUT', '**/apps/files_external/userglobalstorages/*').as('setCredentials')
+
+ triggerInlineActionForFile('storage1', ACTION_CREDENTIALS_EXTERNAL_STORAGE)
+
+ // See credentials dialog
+ cy.findByRole('dialog', { name: 'Storage credentials' }).as('storageDialog')
+ cy.get('@storageDialog').should('be.visible')
+ cy.get('@storageDialog').findByRole('textbox', { name: 'Login' }).type(storageUser.userId)
+ cy.get('@storageDialog').get('input[type="password"]').type(storageUser.password)
+ cy.get('@storageDialog').get('button').contains('Confirm').click()
+ cy.get('@storageDialog').should('not.exist')
+
+ // Storage dialog now closed, the user auth dialog should be visible
+ cy.findByRole('dialog', { name: 'Confirm your password' }).as('authDialog')
+ cy.get('@authDialog').should('be.visible')
+ handlePasswordConfirmation(user2.password)
+
+ // Wait for the credentials to be set
+ cy.wait('@setCredentials')
+
+ // Auth dialog should be closed and the set credentials button should be gone
+ cy.get('@authDialog').should('not.exist', { timeout: 2000 })
+ getInlineActionEntryForFile('storage1', ACTION_CREDENTIALS_EXTERNAL_STORAGE).should('not.exist')
+
+ // Finally, the storage should be accessible
+ cy.visit('/apps/files')
+ navigateToFolder('storage1')
+ getRowForFile('image.jpg').should('be.visible')
+ })
+
+ it('Create another user storage while reusing GLOBAL user credentials', () => {
+ // Its not the public server address but the address so the server itself can connect to it
+ const base = 'http://localhost'
+ const host = `${base}/remote.php/dav/files/${storageUser.userId}`
+ createStorageWithConfig('storage2', StorageBackend.DAV, AuthBackend.UserGlobalAuth, { host, secure: 'false' })
+
+ cy.login(user2)
+ cy.visit('/apps/files/extstoragemounts')
+ getRowForFile('storage2').should('be.visible')
+
+ // Since we already have set the credentials, the action should not be present
+ getInlineActionEntryForFile('storage1', ACTION_CREDENTIALS_EXTERNAL_STORAGE).should('not.exist')
+ getInlineActionEntryForFile('storage2', ACTION_CREDENTIALS_EXTERNAL_STORAGE).should('not.exist')
+
+ // Finally, the storage should be accessible
+ cy.visit('/apps/files')
+ navigateToFolder('storage2')
+ getRowForFile('image.jpg').should('be.visible')
+ })
+})
diff --git a/cypress/e2e/files_external/settings.cy.ts b/cypress/e2e/files_external/settings.cy.ts
new file mode 100644
index 00000000000..9f017bbf951
--- /dev/null
+++ b/cypress/e2e/files_external/settings.cy.ts
@@ -0,0 +1,130 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+describe('files_external settings', () => {
+ before(() => {
+ cy.runOccCommand('app:enable files_external')
+ cy.login({ language: 'en', password: 'admin', userId: 'admin' })
+ })
+
+ beforeEach(() => {
+ cy.runOccCommand('files_external:list --output json')
+ .then((exec) => {
+ const list = JSON.parse(exec.stdout)
+ for (const entry of list) {
+ cy.runOccCommand('files_external:delete ' + entry)
+ }
+ })
+ cy.visit('/settings/admin/externalstorages')
+ })
+
+ it('can see the settings section', () => {
+ cy.findByRole('heading', { name: 'External storage' })
+ .should('be.visible')
+ cy.get('table#externalStorage')
+ .should('be.visible')
+ })
+
+ it('populates the row and creates a new empty one', () => {
+ selectBackend('local')
+
+ // See cell now contains the backend
+ getTable()
+ .findAllByRole('row')
+ .first()
+ .find('.backend')
+ .should('contain.text', 'Local')
+
+ // and the backend select is available but clear
+ getBackendSelect()
+ .should('have.value', null)
+
+ // the suggested mount point name is set to the backend
+ getTable()
+ .findAllByRole('row')
+ .first()
+ .find('input[name="mountPoint"]')
+ .should('have.value', 'Local')
+ })
+
+ it('does not save the storage with missing configuration', function() {
+ selectBackend('local')
+
+ getTable()
+ .findAllByRole('row').first()
+ .should('be.visible')
+ .within(() => {
+ cy.findByRole('checkbox', { name: 'All people' })
+ .check()
+ cy.get('button[title="Save"]')
+ .click()
+ })
+
+ cy.findByRole('dialog', { name: 'Confirm your password' })
+ .should('not.exist')
+ })
+
+ it('does not save the storage with applicable configuration', function() {
+ selectBackend('local')
+
+ getTable()
+ .findAllByRole('row').first()
+ .should('be.visible')
+ .within(() => {
+ cy.get('input[placeholder="Location"]')
+ .type('/tmp')
+ cy.get('button[title="Save"]')
+ .click()
+ })
+
+ cy.findByRole('dialog', { name: 'Confirm your password' })
+ .should('not.exist')
+ })
+
+ it('does save the storage with needed configuration', function() {
+ selectBackend('local')
+
+ getTable()
+ .findAllByRole('row').first()
+ .should('be.visible')
+ .within(() => {
+ cy.findByRole('checkbox', { name: 'All people' })
+ .check()
+ cy.get('input[placeholder="Location"]')
+ .type('/tmp')
+ cy.get('button[title="Save"]')
+ .click()
+ })
+
+ cy.findByRole('dialog', { name: 'Confirm your password' })
+ .should('be.visible')
+ })
+})
+
+/**
+ * Get the external storages table
+ */
+function getTable() {
+ return cy.get('table#externalStorage')
+ .find('tbody')
+}
+
+/**
+ * Get the backend select element
+ */
+function getBackendSelect() {
+ return getTable()
+ .findAllByRole('row')
+ .last()
+ .findByRole('combobox')
+}
+
+/**
+ * @param backend - Backend to select
+ */
+function selectBackend(backend: string): void {
+ getBackendSelect()
+ .select(backend)
+}
diff --git a/cypress/e2e/files_sharing/FilesSharingUtils.ts b/cypress/e2e/files_sharing/FilesSharingUtils.ts
new file mode 100644
index 00000000000..c9b30bd576c
--- /dev/null
+++ b/cypress/e2e/files_sharing/FilesSharingUtils.ts
@@ -0,0 +1,199 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+/* eslint-disable jsdoc/require-jsdoc */
+import { triggerActionForFile } from '../files/FilesUtils'
+
+export interface ShareSetting {
+ read: boolean
+ update: boolean
+ delete: boolean
+ create: boolean
+ share: boolean
+ download: boolean
+ note: string
+ expiryDate: Date
+}
+
+export function createShare(fileName: string, username: string, shareSettings: Partial<ShareSetting> = {}) {
+ openSharingPanel(fileName)
+
+ cy.get('#app-sidebar-vue').within(() => {
+ cy.intercept({ times: 1, method: 'GET', url: '**/apps/files_sharing/api/v1/sharees?*' }).as('userSearch')
+ cy.findByRole('combobox', { name: /Search for internal recipients/i })
+ .type(`{selectAll}${username}`)
+ cy.wait('@userSearch')
+ })
+
+ cy.get(`[user="${username}"]`).click()
+
+ // HACK: Save the share and then update it, as permissions changes are currently not saved for new share.
+ cy.get('[data-cy-files-sharing-share-editor-action="save"]').click({ scrollBehavior: 'nearest' })
+ updateShare(fileName, 0, shareSettings)
+}
+
+export function openSharingDetails(index: number) {
+ cy.get('#app-sidebar-vue').within(() => {
+ cy.get('[data-cy-files-sharing-share-actions]').eq(index).click({ force: true })
+ cy.get('[data-cy-files-sharing-share-permissions-bundle="custom"]').click()
+ })
+}
+
+export function updateShare(fileName: string, index: number, shareSettings: Partial<ShareSetting> = {}) {
+ openSharingPanel(fileName)
+ openSharingDetails(index)
+
+ cy.intercept({ times: 1, method: 'PUT', url: '**/apps/files_sharing/api/v1/shares/*' }).as('updateShare')
+
+ cy.get('#app-sidebar-vue').within(() => {
+ if (shareSettings.download !== undefined) {
+ cy.get('[data-cy-files-sharing-share-permissions-checkbox="download"]').find('input').as('downloadCheckbox')
+ if (shareSettings.download) {
+ // Force:true because the checkbox is hidden by the pretty UI.
+ cy.get('@downloadCheckbox').check({ force: true, scrollBehavior: 'nearest' })
+ } else {
+ // Force:true because the checkbox is hidden by the pretty UI.
+ cy.get('@downloadCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' })
+ }
+ }
+
+ if (shareSettings.read !== undefined) {
+ cy.get('[data-cy-files-sharing-share-permissions-checkbox="read"]').find('input').as('readCheckbox')
+ if (shareSettings.read) {
+ // Force:true because the checkbox is hidden by the pretty UI.
+ cy.get('@readCheckbox').check({ force: true, scrollBehavior: 'nearest' })
+ } else {
+ // Force:true because the checkbox is hidden by the pretty UI.
+ cy.get('@readCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' })
+ }
+ }
+
+ if (shareSettings.update !== undefined) {
+ cy.get('[data-cy-files-sharing-share-permissions-checkbox="update"]').find('input').as('updateCheckbox')
+ if (shareSettings.update) {
+ // Force:true because the checkbox is hidden by the pretty UI.
+ cy.get('@updateCheckbox').check({ force: true, scrollBehavior: 'nearest' })
+ } else {
+ // Force:true because the checkbox is hidden by the pretty UI.
+ cy.get('@updateCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' })
+ }
+ }
+
+ if (shareSettings.create !== undefined) {
+ cy.get('[data-cy-files-sharing-share-permissions-checkbox="create"]').find('input').as('createCheckbox')
+ if (shareSettings.create) {
+ // Force:true because the checkbox is hidden by the pretty UI.
+ cy.get('@createCheckbox').check({ force: true, scrollBehavior: 'nearest' })
+ } else {
+ // Force:true because the checkbox is hidden by the pretty UI.
+ cy.get('@createCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' })
+ }
+ }
+
+ if (shareSettings.delete !== undefined) {
+ cy.get('[data-cy-files-sharing-share-permissions-checkbox="delete"]').find('input').as('deleteCheckbox')
+ if (shareSettings.delete) {
+ // Force:true because the checkbox is hidden by the pretty UI.
+ cy.get('@deleteCheckbox').check({ force: true, scrollBehavior: 'nearest' })
+ } else {
+ // Force:true because the checkbox is hidden by the pretty UI.
+ cy.get('@deleteCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' })
+ }
+ }
+
+ if (shareSettings.note !== undefined) {
+ cy.findByRole('checkbox', { name: /note to recipient/i }).check({ force: true, scrollBehavior: 'nearest' })
+ cy.findByRole('textbox', { name: /note to recipient/i }).type(shareSettings.note)
+ }
+
+ if (shareSettings.expiryDate !== undefined) {
+ cy.findByRole('checkbox', { name: /expiration date/i })
+ .check({ force: true, scrollBehavior: 'nearest' })
+ cy.get('#share-date-picker')
+ .type(`${shareSettings.expiryDate.getFullYear()}-${String(shareSettings.expiryDate.getMonth() + 1).padStart(2, '0')}-${String(shareSettings.expiryDate.getDate()).padStart(2, '0')}`)
+ }
+
+ cy.get('[data-cy-files-sharing-share-editor-action="save"]').click({ scrollBehavior: 'nearest' })
+
+ cy.wait('@updateShare')
+ })
+ // close all toasts
+ cy.get('.toast-success').findAllByRole('button').click({ force: true, multiple: true })
+}
+
+export function openSharingPanel(fileName: string) {
+ triggerActionForFile(fileName, 'details')
+
+ cy.get('[data-cy-sidebar]')
+ .find('[aria-controls="tab-sharing"]')
+ .click()
+}
+
+type FileRequestOptions = {
+ label?: string
+ note?: string
+ password?: string
+ /* YYYY-MM-DD format */
+ expiration?: string
+}
+
+/**
+ * Create a file request for a folder
+ * @param path The path of the folder, leading slash is required
+ * @param options The options for the file request
+ */
+export const createFileRequest = (path: string, options: FileRequestOptions = {}) => {
+ if (!path.startsWith('/')) {
+ throw new Error('Path must start with a slash')
+ }
+
+ // Navigate to the folder
+ cy.visit('/apps/files/files?dir=' + path)
+
+ // Open the file request dialog
+ cy.get('[data-cy-upload-picker] .action-item__menutoggle').first().click()
+ cy.contains('.upload-picker__menu-entry button', 'Create file request').click()
+ cy.get('[data-cy-file-request-dialog]').should('be.visible')
+
+ // Check and fill the first page options
+ cy.get('[data-cy-file-request-dialog-fieldset="label"]').should('be.visible')
+ cy.get('[data-cy-file-request-dialog-fieldset="destination"]').should('be.visible')
+ cy.get('[data-cy-file-request-dialog-fieldset="note"]').should('be.visible')
+
+ cy.get('[data-cy-file-request-dialog-fieldset="destination"] input').should('contain.value', path)
+ if (options.label) {
+ cy.get('[data-cy-file-request-dialog-fieldset="label"] input').type(`{selectall}${options.label}`)
+ }
+ if (options.note) {
+ cy.get('[data-cy-file-request-dialog-fieldset="note"] textarea').type(`{selectall}${options.note}`)
+ }
+
+ // Go to the next page
+ cy.get('[data-cy-file-request-dialog-controls="next"]').click()
+ cy.get('[data-cy-file-request-dialog-fieldset="expiration"] input[type="checkbox"]').should('exist')
+ cy.get('[data-cy-file-request-dialog-fieldset="expiration"] input[type="date"]').should('not.exist')
+ cy.get('[data-cy-file-request-dialog-fieldset="password"] input[type="checkbox"]').should('exist')
+ cy.get('[data-cy-file-request-dialog-fieldset="password"] input[type="password"]').should('not.exist')
+ if (options.expiration) {
+ cy.get('[data-cy-file-request-dialog-fieldset="expiration"] input[type="checkbox"]').check({ force: true })
+ cy.get('[data-cy-file-request-dialog-fieldset="expiration"] input[type="date"]').type(`{selectall}${options.expiration}`)
+ }
+ if (options.password) {
+ cy.get('[data-cy-file-request-dialog-fieldset="password"] input[type="checkbox"]').check({ force: true })
+ cy.get('[data-cy-file-request-dialog-fieldset="password"] input[type="password"]').type(`{selectall}${options.password}`)
+ }
+
+ // Create the file request
+ cy.get('[data-cy-file-request-dialog-controls="next"]').click()
+
+ // Get the file request URL
+ cy.get('[data-cy-file-request-dialog-fieldset="link"]').then(($link) => {
+ const url = $link.val()
+ cy.log(`File request URL: ${url}`)
+ cy.wrap(url).as('fileRequestUrl')
+ })
+
+ // Close
+ cy.get('[data-cy-file-request-dialog-controls="finish"]').click()
+}
diff --git a/cypress/e2e/files_sharing/ShareOptionsType.ts b/cypress/e2e/files_sharing/ShareOptionsType.ts
new file mode 100644
index 00000000000..a6ce6922299
--- /dev/null
+++ b/cypress/e2e/files_sharing/ShareOptionsType.ts
@@ -0,0 +1,18 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+export type ShareOptions = {
+ enforcePassword?: boolean
+ enforceExpirationDate?: boolean
+ alwaysAskForPassword?: boolean
+ defaultExpirationDateSet?: boolean
+}
+
+export const defaultShareOptions: ShareOptions = {
+ enforcePassword: false,
+ enforceExpirationDate: false,
+ alwaysAskForPassword: false,
+ defaultExpirationDateSet: false,
+}
diff --git a/cypress/e2e/files_sharing/expiry-date.cy.ts b/cypress/e2e/files_sharing/expiry-date.cy.ts
new file mode 100644
index 00000000000..f39a47309e2
--- /dev/null
+++ b/cypress/e2e/files_sharing/expiry-date.cy.ts
@@ -0,0 +1,128 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { User } from '@nextcloud/cypress'
+import { closeSidebar } from '../files/FilesUtils.ts'
+import { createShare, openSharingDetails, openSharingPanel, updateShare } from './FilesSharingUtils.ts'
+
+describe('files_sharing: Expiry date', () => {
+ const expectedDefaultDate = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000)
+ const expectedDefaultDateString = `${expectedDefaultDate.getFullYear()}-${String(expectedDefaultDate.getMonth() + 1).padStart(2, '0')}-${String(expectedDefaultDate.getDate()).padStart(2, '0')}`
+ const fortnight = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000)
+ const fortnightString = `${fortnight.getFullYear()}-${String(fortnight.getMonth() + 1).padStart(2, '0')}-${String(fortnight.getDate()).padStart(2, '0')}`
+
+ let alice: User
+ let bob: User
+
+ before(() => {
+ // Ensure we have the admin setting setup for default dates with 2 days in the future
+ cy.runOccCommand('config:app:set --value yes core shareapi_default_internal_expire_date')
+ cy.runOccCommand('config:app:set --value 2 core shareapi_internal_expire_after_n_days')
+
+ cy.createRandomUser().then((user) => {
+ alice = user
+ cy.login(alice)
+ })
+ cy.createRandomUser().then((user) => {
+ bob = user
+ })
+ })
+
+ after(() => {
+ cy.runOccCommand('config:app:delete core shareapi_default_internal_expire_date')
+ cy.runOccCommand('config:app:delete core shareapi_enforce_internal_expire_date')
+ cy.runOccCommand('config:app:delete core shareapi_internal_expire_after_n_days')
+ })
+
+ beforeEach(() => {
+ cy.runOccCommand('config:app:delete core shareapi_enforce_internal_expire_date')
+ })
+
+ it('See default expiry date is set and enforced', () => {
+ // Enforce the date
+ cy.runOccCommand('config:app:set --value yes core shareapi_enforce_internal_expire_date')
+ const dir = 'defaultExpiryDateEnforced'
+ prepareDirectory(dir)
+
+ validateExpiryDate(dir, expectedDefaultDateString)
+ cy.findByRole('checkbox', { name: /expiration date/i })
+ .should('be.checked')
+ .and('be.disabled')
+ })
+
+ it('See default expiry date is set also if not enforced', () => {
+ const dir = 'defaultExpiryDate'
+ prepareDirectory(dir)
+
+ validateExpiryDate(dir, expectedDefaultDateString)
+ cy.findByRole('checkbox', { name: /expiration date/i })
+ .should('be.checked')
+ .and('not.be.disabled')
+ .check({ force: true, scrollBehavior: 'nearest' })
+ })
+
+ it('Can set custom expiry date', () => {
+ const dir = 'customExpiryDate'
+ prepareDirectory(dir)
+ updateShare(dir, 0, { expiryDate: fortnight })
+ validateExpiryDate(dir, fortnightString)
+ })
+
+ it('Custom expiry date survives reload', () => {
+ const dir = 'customExpiryDateReload'
+ prepareDirectory(dir)
+ updateShare(dir, 0, { expiryDate: fortnight })
+ validateExpiryDate(dir, fortnightString)
+
+ cy.visit('/apps/files')
+ validateExpiryDate(dir, fortnightString)
+ })
+
+ /**
+ * Regression test for https://github.com/nextcloud/server/pull/50192
+ * Ensure that admin default settings do not always override the user set value.
+ */
+ it('Custom expiry date survives unrelated update', () => {
+ const dir = 'customExpiryUnrelatedChanges'
+ prepareDirectory(dir)
+ updateShare(dir, 0, { expiryDate: fortnight })
+ validateExpiryDate(dir, fortnightString)
+
+ closeSidebar()
+ updateShare(dir, 0, { note: 'Only note changed' })
+ validateExpiryDate(dir, fortnightString)
+
+ cy.visit('/apps/files')
+ validateExpiryDate(dir, fortnightString)
+ })
+
+ /**
+ * Prepare directory, login and share to bob
+ *
+ * @param name The directory name
+ */
+ function prepareDirectory(name: string) {
+ cy.mkdir(alice, `/${name}`)
+ cy.login(alice)
+ cy.visit('/apps/files')
+ createShare(name, bob.userId)
+ }
+
+ /**
+ * Validate expiry date on a share
+ *
+ * @param filename The filename to validate
+ * @param expectedDate The expected date in YYYY-MM-dd
+ */
+ function validateExpiryDate(filename: string, expectedDate: string) {
+ openSharingPanel(filename)
+ openSharingDetails(0)
+
+ cy.get('#share-date-picker')
+ .should('exist')
+ .and('have.value', expectedDate)
+ }
+
+})
diff --git a/cypress/e2e/files_sharing/file-request.cy.ts b/cypress/e2e/files_sharing/file-request.cy.ts
new file mode 100644
index 00000000000..578f72fa0b5
--- /dev/null
+++ b/cypress/e2e/files_sharing/file-request.cy.ts
@@ -0,0 +1,83 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from '@nextcloud/cypress'
+import { createFolder, getRowForFile, navigateToFolder } from '../files/FilesUtils'
+import { createFileRequest } from './FilesSharingUtils'
+
+const enterGuestName = (name: string) => {
+ cy.findByRole('dialog', { name: /Upload files to/ })
+ .should('be.visible')
+ .within(() => {
+ cy.findByRole('textbox', { name: 'Name' })
+ .should('be.visible')
+
+ cy.findByRole('textbox', { name: 'Name' })
+ .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 = ''
+
+ it('Login with a user and create a file request', () => {
+ cy.createRandomUser().then((_user) => {
+ user = _user
+ cy.login(user)
+ })
+
+ cy.visit('/apps/files')
+ createFolder(folderName)
+
+ createFileRequest(`/${folderName}`)
+ cy.get('@fileRequestUrl').should('contain', '/s/').then((_url: string) => {
+ cy.logout()
+ url = _url
+ })
+ })
+
+ it('Open the file request as a guest', () => {
+ cy.visit(url)
+ enterGuestName('Guest')
+
+ // Check various elements on the page
+ 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('[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)
+ })
+
+ it('Check the uploaded file', () => {
+ cy.login(user)
+ cy.visit(`/apps/files/files?dir=/${folderName}`)
+ getRowForFile('Guest')
+ .should('be.visible')
+ navigateToFolder('Guest')
+ getRowForFile('file.txt').should('be.visible')
+ })
+})
diff --git a/cypress/e2e/files_sharing/files-copy-move.cy.ts b/cypress/e2e/files_sharing/files-copy-move.cy.ts
new file mode 100644
index 00000000000..6ad01cb2219
--- /dev/null
+++ b/cypress/e2e/files_sharing/files-copy-move.cy.ts
@@ -0,0 +1,150 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { User } from '@nextcloud/cypress'
+import { createShare } from './FilesSharingUtils.ts'
+import {
+ getRowForFile,
+ copyFile,
+ navigateToFolder,
+ triggerActionForFile,
+} from '../files/FilesUtils.ts'
+import { ACTION_COPY_MOVE } from '../../../apps/files/src/actions/moveOrCopyAction.ts'
+
+export const copyFileForbidden = (fileName: string, dirPath: string) => {
+ getRowForFile(fileName).should('be.visible')
+ triggerActionForFile(fileName, ACTION_COPY_MOVE)
+
+ cy.get('.file-picker').within(() => {
+ // intercept the copy so we can wait for it
+ cy.intercept('COPY', /\/(remote|public)\.php\/dav\/files\//).as('copyFile')
+
+ const directories = dirPath.split('/')
+ directories.forEach((directory) => {
+ // select the folder
+ cy.get(`[data-filename="${CSS.escape(directory)}"]`).should('be.visible').click()
+ })
+
+ // check copy button
+ cy.contains('button', `Copy to ${directories.at(-1)}`).should('be.disabled')
+ })
+}
+
+export const moveFileForbidden = (fileName: string, dirPath: string) => {
+ getRowForFile(fileName).should('be.visible')
+ triggerActionForFile(fileName, ACTION_COPY_MOVE)
+
+ cy.get('.file-picker').within(() => {
+ // intercept the copy so we can wait for it
+ cy.intercept('MOVE', /\/(remote|public)\.php\/dav\/files\//).as('moveFile')
+
+ // select home folder
+ cy.get('button[title="Home"]').should('be.visible').click()
+
+ const directories = dirPath.split('/')
+ directories.forEach((directory) => {
+ // select the folder
+ cy.get(`[data-filename="${directory}"]`).should('be.visible').click()
+ })
+
+ // click move
+ cy.contains('button', `Move to ${directories.at(-1)}`).should('not.exist')
+ })
+}
+
+describe('files_sharing: Move or copy files', { testIsolation: true }, () => {
+ let user: User
+ let sharee: User
+
+ beforeEach(() => {
+ cy.createRandomUser().then(($user) => {
+ user = $user
+ })
+ cy.createRandomUser().then(($user) => {
+ sharee = $user
+ })
+ })
+
+ it('can create a file in a shared folder', () => {
+ // share the folder
+ cy.mkdir(user, '/folder')
+ cy.login(user)
+ cy.visit('/apps/files')
+ createShare('folder', sharee.userId, { read: true, download: true })
+ cy.logout()
+
+ // Now for the sharee
+ cy.uploadContent(sharee, new Blob([]), 'text/plain', '/folder/file.txt')
+ cy.login(sharee)
+ // visit shared files view
+ cy.visit('/apps/files')
+ // see the shared folder
+ getRowForFile('folder').should('be.visible')
+ navigateToFolder('folder')
+ // Content of the shared folder
+ getRowForFile('file.txt').should('be.visible')
+ })
+
+ it('can copy a file to a shared folder', () => {
+ // share the folder
+ cy.mkdir(user, '/folder')
+ cy.login(user)
+ cy.visit('/apps/files')
+ createShare('folder', sharee.userId, { read: true, download: true })
+ cy.logout()
+
+ // Now for the sharee
+ cy.uploadContent(sharee, new Blob([]), 'text/plain', '/file.txt')
+ cy.login(sharee)
+ // visit shared files view
+ cy.visit('/apps/files')
+ // see the shared folder
+ getRowForFile('folder').should('be.visible')
+ // copy file to a shared folder
+ copyFile('file.txt', 'folder')
+ // click on the folder should open it in files
+ navigateToFolder('folder')
+ // Content of the shared folder
+ getRowForFile('file.txt').should('be.visible')
+ })
+
+ it('can not copy a file to a shared folder with no create permissions', () => {
+ // share the folder
+ cy.mkdir(user, '/folder')
+ cy.login(user)
+ cy.visit('/apps/files')
+ createShare('folder', sharee.userId, { read: true, download: true, create: false })
+ cy.logout()
+
+ // Now for the sharee
+ cy.uploadContent(sharee, new Blob([]), 'text/plain', '/file.txt')
+ cy.login(sharee)
+ // visit shared files view
+ cy.visit('/apps/files')
+ // see the shared folder
+ getRowForFile('folder').should('be.visible')
+ copyFileForbidden('file.txt', 'folder')
+ })
+
+ it('can not move a file from a shared folder with no delete permissions', () => {
+ // share the folder
+ cy.mkdir(user, '/folder')
+ cy.uploadContent(user, new Blob([]), 'text/plain', '/folder/file.txt')
+ cy.login(user)
+ cy.visit('/apps/files')
+ createShare('folder', sharee.userId, { read: true, download: true, delete: false })
+ cy.logout()
+
+ // Now for the sharee
+ cy.mkdir(sharee, '/folder-own')
+ cy.login(sharee)
+ // visit shared files view
+ cy.visit('/apps/files')
+ // see the shared folder
+ getRowForFile('folder').should('be.visible')
+ navigateToFolder('folder')
+ getRowForFile('file.txt').should('be.visible')
+ moveFileForbidden('file.txt', 'folder-own')
+ })
+})
diff --git a/cypress/e2e/files_sharing/files-download.cy.ts b/cypress/e2e/files_sharing/files-download.cy.ts
new file mode 100644
index 00000000000..97ea91b7647
--- /dev/null
+++ b/cypress/e2e/files_sharing/files-download.cy.ts
@@ -0,0 +1,102 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { User } from '@nextcloud/cypress'
+import { createShare } from './FilesSharingUtils.ts'
+import {
+ getActionButtonForFile,
+ getActionEntryForFile,
+ getRowForFile,
+} from '../files/FilesUtils.ts'
+
+describe('files_sharing: Download forbidden', { testIsolation: true }, () => {
+ let user: User
+ let sharee: User
+
+ beforeEach(() => {
+ cy.runOccCommand('config:app:set --value yes core shareapi_allow_view_without_download')
+ cy.createRandomUser().then(($user) => {
+ user = $user
+ })
+ cy.createRandomUser().then(($user) => {
+ sharee = $user
+ })
+ })
+
+ after(() => {
+ cy.runOccCommand('config:app:delete core shareapi_allow_view_without_download')
+ })
+
+ it('cannot download a folder if disabled', () => {
+ // share the folder
+ cy.mkdir(user, '/folder')
+ cy.login(user)
+ cy.visit('/apps/files')
+ createShare('folder', sharee.userId, { read: true, download: false })
+ cy.logout()
+
+ // Now for the sharee
+ cy.login(sharee)
+
+ // visit shared files view
+ cy.visit('/apps/files')
+ // see the shared folder
+ getActionButtonForFile('folder')
+ .should('be.visible')
+ // open the action menu
+ .click({ force: true })
+ // see no download action
+ getActionEntryForFile('folder', 'download')
+ .should('not.exist')
+
+ // Disable view without download option
+ cy.runOccCommand('config:app:set --value no core shareapi_allow_view_without_download')
+
+ // visit shared files view
+ cy.visit('/apps/files')
+ // see the shared folder
+ getRowForFile('folder').should('be.visible')
+ getActionButtonForFile('folder')
+ .should('be.visible')
+ // open the action menu
+ .click({ force: true })
+ getActionEntryForFile('folder', 'download').should('not.exist')
+ })
+
+ it('cannot download a file if disabled', () => {
+ // share the folder
+ cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt')
+ cy.login(user)
+ cy.visit('/apps/files')
+ createShare('file.txt', sharee.userId, { read: true, download: false })
+ cy.logout()
+
+ // Now for the sharee
+ cy.login(sharee)
+
+ // visit shared files view
+ cy.visit('/apps/files')
+ // see the shared folder
+ getActionButtonForFile('file.txt')
+ .should('be.visible')
+ // open the action menu
+ .click({ force: true })
+ // see no download action
+ getActionEntryForFile('file.txt', 'download')
+ .should('not.exist')
+
+ // Disable view without download option
+ cy.runOccCommand('config:app:set --value no core shareapi_allow_view_without_download')
+
+ // visit shared files view
+ cy.visit('/apps/files')
+ // see the shared folder
+ getRowForFile('file.txt').should('be.visible')
+ getActionButtonForFile('file.txt')
+ .should('be.visible')
+ // open the action menu
+ .click({ force: true })
+ getActionEntryForFile('file.txt', 'download').should('not.exist')
+ })
+})
diff --git a/cypress/e2e/files_sharing/files-shares-view.cy.ts b/cypress/e2e/files_sharing/files-shares-view.cy.ts
new file mode 100644
index 00000000000..12a67d9ee0f
--- /dev/null
+++ b/cypress/e2e/files_sharing/files-shares-view.cy.ts
@@ -0,0 +1,59 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { User } from '@nextcloud/cypress'
+import { createShare } from './FilesSharingUtils.ts'
+import { getRowForFile } from '../files/FilesUtils.ts'
+
+describe('files_sharing: Files view', { testIsolation: true }, () => {
+ let user: User
+ let sharee: User
+
+ beforeEach(() => {
+ cy.createRandomUser().then(($user) => {
+ user = $user
+ })
+ cy.createRandomUser().then(($user) => {
+ sharee = $user
+ })
+ })
+
+ /**
+ * Regression test of https://github.com/nextcloud/server/issues/46108
+ */
+ it('opens a shared folder when clicking on it', () => {
+ cy.mkdir(user, '/folder')
+ cy.uploadContent(user, new Blob([]), 'text/plain', '/folder/file')
+ cy.login(user)
+ cy.visit('/apps/files')
+
+ // share the folder
+ createShare('folder', sharee.userId, { read: true, download: true })
+ // visit the own shares
+ cy.visit('/apps/files/sharingout')
+ // see the shared folder
+ getRowForFile('folder').should('be.visible')
+ // click on the folder should open it in files
+ getRowForFile('folder').findByRole('button', { name: /open in files/i }).click()
+ // See the URL has changed
+ cy.url().should('match', /apps\/files\/files\/.+dir=\/folder/)
+ // Content of the shared folder
+ getRowForFile('file').should('be.visible')
+
+ cy.logout()
+ // Now for the sharee
+ cy.login(sharee)
+
+ // visit shared files view
+ cy.visit('/apps/files/sharingin')
+ // see the shared folder
+ getRowForFile('folder').should('be.visible')
+ // click on the folder should open it in files
+ getRowForFile('folder').findByRole('button', { name: /open in files/i }).click()
+ // See the URL has changed
+ cy.url().should('match', /apps\/files\/files\/.+dir=\/folder/)
+ // Content of the shared folder
+ getRowForFile('file').should('be.visible')
+ })
+})
diff --git a/cypress/e2e/files_sharing/filesSharingUtils.ts b/cypress/e2e/files_sharing/filesSharingUtils.ts
deleted file mode 100644
index cb407153380..00000000000
--- a/cypress/e2e/files_sharing/filesSharingUtils.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-/* eslint-disable jsdoc/require-jsdoc */
-/**
- * @copyright Copyright (c) 2024 Louis Chemineau <louis@chmn.me>
- *
- * @author Louis Chemineau <louis@chmn.me>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-import { triggerActionForFile } from '../files/FilesUtils'
-
-export interface ShareSetting {
- read: boolean
- update: boolean
- delete: boolean
- share: boolean
- download: boolean
-}
-
-export function createShare(fileName: string, username: string, shareSettings: Partial<ShareSetting> = {}) {
- openSharingPanel(fileName)
-
- cy.get('#app-sidebar-vue').within(() => {
- cy.get('#sharing-search-input').clear()
- cy.intercept({ times: 1, method: 'GET', url: '**/apps/files_sharing/api/v1/sharees?*' }).as('userSearch')
- cy.get('#sharing-search-input').type(username)
- cy.wait('@userSearch')
- })
-
- cy.get(`[user="${username}"]`).click()
-
- // HACK: Save the share and then update it, as permissions changes are currently not saved for new share.
- cy.get('[data-cy-files-sharing-share-editor-action="save"]').click({ scrollBehavior: 'nearest' })
- updateShare(fileName, 0, shareSettings)
-}
-
-export function updateShare(fileName: string, index: number, shareSettings: Partial<ShareSetting> = {}) {
- openSharingPanel(fileName)
-
- cy.get('#app-sidebar-vue').within(() => {
- cy.get('[data-cy-files-sharing-share-actions]').eq(index).click()
- cy.get('[data-cy-files-sharing-share-permissions-bundle="custom"]').click()
-
- if (shareSettings.download !== undefined) {
- cy.get('[data-cy-files-sharing-share-permissions-checkbox="download"]').find('input').as('downloadCheckbox')
- if (shareSettings.download) {
- // Force:true because the checkbox is hidden by the pretty UI.
- cy.get('@downloadCheckbox').check({ force: true, scrollBehavior: 'nearest' })
- } else {
- // Force:true because the checkbox is hidden by the pretty UI.
- cy.get('@downloadCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' })
- }
- }
-
- if (shareSettings.read !== undefined) {
- cy.get('[data-cy-files-sharing-share-permissions-checkbox="read"]').find('input').as('readCheckbox')
- if (shareSettings.read) {
- // Force:true because the checkbox is hidden by the pretty UI.
- cy.get('@readCheckbox').check({ force: true, scrollBehavior: 'nearest' })
- } else {
- // Force:true because the checkbox is hidden by the pretty UI.
- cy.get('@readCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' })
- }
- }
-
- if (shareSettings.update !== undefined) {
- cy.get('[data-cy-files-sharing-share-permissions-checkbox="update"]').find('input').as('updateCheckbox')
- if (shareSettings.update) {
- // Force:true because the checkbox is hidden by the pretty UI.
- cy.get('@updateCheckbox').check({ force: true, scrollBehavior: 'nearest' })
- } else {
- // Force:true because the checkbox is hidden by the pretty UI.
- cy.get('@updateCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' })
- }
- }
-
- if (shareSettings.delete !== undefined) {
- cy.get('[data-cy-files-sharing-share-permissions-checkbox="delete"]').find('input').as('deleteCheckbox')
- if (shareSettings.delete) {
- // Force:true because the checkbox is hidden by the pretty UI.
- cy.get('@deleteCheckbox').check({ force: true, scrollBehavior: 'nearest' })
- } else {
- // Force:true because the checkbox is hidden by the pretty UI.
- cy.get('@deleteCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' })
- }
- }
-
- cy.get('[data-cy-files-sharing-share-editor-action="save"]').click({ scrollBehavior: 'nearest' })
- })
-}
-
-export function openSharingPanel(fileName: string) {
- triggerActionForFile(fileName, 'details')
-
- cy.get('#app-sidebar-vue')
- .get('[aria-controls="tab-sharing"]')
- .click()
-}
diff --git a/cypress/e2e/files_sharing/limit_to_same_group.cy.ts b/cypress/e2e/files_sharing/limit_to_same_group.cy.ts
new file mode 100644
index 00000000000..c95efa089ff
--- /dev/null
+++ b/cypress/e2e/files_sharing/limit_to_same_group.cy.ts
@@ -0,0 +1,107 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { User } from "@nextcloud/cypress"
+import { createShare } from "./FilesSharingUtils.ts"
+
+describe('Limit to sharing to people in the same group', () => {
+ let alice: User
+ let bob: User
+ let randomFileName1 = ''
+ let randomFileName2 = ''
+ let randomGroupName = ''
+ let randomGroupName2 = ''
+ let randomGroupName3 = ''
+
+ before(() => {
+ randomFileName1 = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + '.txt'
+ randomFileName2 = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + '.txt'
+ randomGroupName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10)
+ randomGroupName2 = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10)
+ randomGroupName3 = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10)
+
+ cy.runOccCommand('config:app:set core shareapi_only_share_with_group_members --value yes')
+
+ cy.createRandomUser()
+ .then(user => {
+ alice = user
+ cy.createRandomUser()
+ })
+ .then(user => {
+ bob = user
+
+ cy.runOccCommand(`group:add ${randomGroupName}`)
+ cy.runOccCommand(`group:add ${randomGroupName2}`)
+ cy.runOccCommand(`group:add ${randomGroupName3}`)
+ cy.runOccCommand(`group:adduser ${randomGroupName} ${alice.userId}`)
+ cy.runOccCommand(`group:adduser ${randomGroupName} ${bob.userId}`)
+ cy.runOccCommand(`group:adduser ${randomGroupName2} ${alice.userId}`)
+ cy.runOccCommand(`group:adduser ${randomGroupName2} ${bob.userId}`)
+ cy.runOccCommand(`group:adduser ${randomGroupName3} ${bob.userId}`)
+
+ cy.uploadContent(alice, new Blob(['share to bob'], { type: 'text/plain' }), 'text/plain', `/${randomFileName1}`)
+ cy.uploadContent(bob, new Blob(['share by bob'], { type: 'text/plain' }), 'text/plain', `/${randomFileName2}`)
+
+ cy.login(alice)
+ cy.visit('/apps/files')
+ createShare(randomFileName1, bob.userId)
+ cy.login(bob)
+ cy.visit('/apps/files')
+ createShare(randomFileName2, alice.userId)
+ })
+ })
+
+ after(() => {
+ cy.runOccCommand('config:app:set core shareapi_only_share_with_group_members --value no')
+ })
+
+ it('Alice can see the shared file', () => {
+ cy.login(alice)
+ cy.visit('/apps/files')
+ cy.get(`[data-cy-files-list] [data-cy-files-list-row-name="${randomFileName2}"]`).should('exist')
+ })
+
+ it('Bob can see the shared file', () => {
+ cy.login(alice)
+ cy.visit('/apps/files')
+ cy.get(`[data-cy-files-list] [data-cy-files-list-row-name="${randomFileName1}"]`).should('exist')
+ })
+
+ context('Bob is removed from the first group', () => {
+ before(() => {
+ cy.runOccCommand(`group:removeuser ${randomGroupName} ${bob.userId}`)
+ })
+
+ it('Alice can see the shared file', () => {
+ cy.login(alice)
+ cy.visit('/apps/files')
+ cy.get(`[data-cy-files-list] [data-cy-files-list-row-name="${randomFileName2}"]`).should('exist')
+ })
+
+ it('Bob can see the shared file', () => {
+ cy.login(alice)
+ cy.visit('/apps/files')
+ cy.get(`[data-cy-files-list] [data-cy-files-list-row-name="${randomFileName1}"]`).should('exist')
+ })
+ })
+
+ context('Bob is removed from the second group', () => {
+ before(() => {
+ cy.runOccCommand(`group:removeuser ${randomGroupName2} ${bob.userId}`)
+ })
+
+ it('Alice cannot see the shared file', () => {
+ cy.login(alice)
+ cy.visit('/apps/files')
+ cy.get(`[data-cy-files-list] [data-cy-files-list-row-name="${randomFileName2}"]`).should('not.exist')
+ })
+
+ it('Bob cannot see the shared file', () => {
+ cy.login(alice)
+ cy.visit('/apps/files')
+ cy.get(`[data-cy-files-list] [data-cy-files-list-row-name="${randomFileName1}"]`).should('not.exist')
+ })
+ })
+})
diff --git a/cypress/e2e/files_sharing/note-to-recipient.cy.ts b/cypress/e2e/files_sharing/note-to-recipient.cy.ts
new file mode 100644
index 00000000000..08fee587d9a
--- /dev/null
+++ b/cypress/e2e/files_sharing/note-to-recipient.cy.ts
@@ -0,0 +1,92 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { User } from '@nextcloud/cypress'
+import { createShare, openSharingPanel } from './FilesSharingUtils.ts'
+import { navigateToFolder } from '../files/FilesUtils.ts'
+
+describe('files_sharing: Note to recipient', { testIsolation: true }, () => {
+ let user: User
+ let sharee: User
+
+ beforeEach(() => {
+ cy.createRandomUser().then(($user) => {
+ user = $user
+ })
+ cy.createRandomUser().then(($user) => {
+ sharee = $user
+ })
+ })
+
+ it('displays the note to the sharee', () => {
+ cy.mkdir(user, '/folder')
+ cy.uploadContent(user, new Blob([]), 'text/plain', '/folder/file')
+ cy.login(user)
+ cy.visit('/apps/files')
+
+ // share the folder
+ createShare('folder', sharee.userId, { read: true, download: true, note: 'Hello, this is the note.' })
+
+ cy.logout()
+ // Now for the sharee
+ cy.login(sharee)
+
+ // visit shared files view
+ cy.visit('/apps/files')
+ navigateToFolder('folder')
+ cy.get('.note-to-recipient')
+ .should('be.visible')
+ .and('contain.text', 'Hello, this is the note.')
+ })
+
+ it('displays the note to the sharee even if the file list is empty', () => {
+ cy.mkdir(user, '/folder')
+ cy.login(user)
+ cy.visit('/apps/files')
+
+ // share the folder
+ createShare('folder', sharee.userId, { read: true, download: true, note: 'Hello, this is the note.' })
+
+ cy.logout()
+ // Now for the sharee
+ cy.login(sharee)
+
+ // visit shared files view
+ cy.visit('/apps/files')
+ navigateToFolder('folder')
+ cy.get('.note-to-recipient')
+ .should('be.visible')
+ .and('contain.text', 'Hello, this is the note.')
+ })
+
+ /**
+ * Regression test for https://github.com/nextcloud/server/issues/46188
+ */
+ it('shows an existing note when editing a share', () => {
+ cy.mkdir(user, '/folder')
+ cy.login(user)
+ cy.visit('/apps/files')
+
+ // share the folder
+ createShare('folder', sharee.userId, { read: true, download: true, note: 'Hello, this is the note.' })
+
+ // reload just to be sure
+ cy.visit('/apps/files')
+
+ // open the sharing tab
+ openSharingPanel('folder')
+
+ cy.get('[data-cy-sidebar]').within(() => {
+ // Open the share
+ cy.get('[data-cy-files-sharing-share-actions]').first().click({ force: true })
+
+ cy.findByRole('checkbox', { name: /note to recipient/i })
+ .and('be.checked')
+ cy.findByRole('textbox', { name: /note to recipient/i })
+ .should('be.visible')
+ .and('have.value', 'Hello, this is the note.')
+ })
+ })
+
+})
diff --git a/cypress/e2e/files_sharing/public-share/PublicShareUtils.ts b/cypress/e2e/files_sharing/public-share/PublicShareUtils.ts
new file mode 100644
index 00000000000..e0cbd06a4c7
--- /dev/null
+++ b/cypress/e2e/files_sharing/public-share/PublicShareUtils.ts
@@ -0,0 +1,191 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { User } from '@nextcloud/cypress'
+import type { ShareOptions } from '../ShareOptionsType.ts'
+import { openSharingPanel } from '../FilesSharingUtils.ts'
+
+export interface ShareContext {
+ user: User
+ url?: string
+}
+
+const defaultShareContext: ShareContext = {
+ user: {} as User,
+ url: undefined,
+}
+
+/**
+ * Retrieves the URL of the share.
+ * Throws an error if the share context is not initialized properly.
+ *
+ * @param context The current share context (defaults to `defaultShareContext` if not provided).
+ * @return The share URL.
+ * @throws Error if the share context has no URL.
+ */
+export function getShareUrl(context: ShareContext = defaultShareContext): string {
+ if (!context.url) {
+ throw new Error('You need to setup the share first!')
+ }
+ return context.url
+}
+
+/**
+ * Setup the available data
+ * @param user The current share context
+ * @param shareName The name of the shared folder
+ */
+export function setupData(user: User, shareName: string): void {
+ cy.mkdir(user, `/${shareName}`)
+ cy.mkdir(user, `/${shareName}/subfolder`)
+ cy.uploadContent(user, new Blob(['<content>foo</content>']), 'text/plain', `/${shareName}/foo.txt`)
+ cy.uploadContent(user, new Blob(['<content>bar</content>']), 'text/plain', `/${shareName}/subfolder/bar.txt`)
+}
+
+/**
+ * Check the password state based on enforcement and default presence.
+ *
+ * @param enforced Whether the password is enforced.
+ * @param alwaysAskForPassword Wether the password should always be asked for.
+ */
+function checkPasswordState(enforced: boolean, alwaysAskForPassword: boolean) {
+ if (enforced) {
+ cy.contains('Password protection (enforced)').should('exist')
+ } else if (alwaysAskForPassword) {
+ cy.contains('Password protection').should('exist')
+ }
+ cy.contains('Enter a password')
+ .should('exist')
+ .and('not.be.disabled')
+}
+
+/**
+ * Check the expiration date state based on enforcement and default presence.
+ *
+ * @param enforced Whether the expiration date is enforced.
+ * @param hasDefault Whether a default expiration date is set.
+ */
+function checkExpirationDateState(enforced: boolean, hasDefault: boolean) {
+ if (enforced) {
+ cy.contains('Enable link expiration (enforced)').should('exist')
+ } else if (hasDefault) {
+ cy.contains('Enable link expiration').should('exist')
+ }
+ cy.contains('Enter expiration date')
+ .should('exist')
+ .and('not.be.disabled')
+ cy.get('input[data-cy-files-sharing-expiration-date-input]').should('exist')
+ cy.get('input[data-cy-files-sharing-expiration-date-input]')
+ .invoke('val')
+ .then((val) => {
+ // eslint-disable-next-line no-unused-expressions
+ expect(val).to.not.be.undefined
+
+ const inputDate = new Date(typeof val === 'number' ? val : String(val))
+ const expectedDate = new Date()
+ expectedDate.setDate(expectedDate.getDate() + 2)
+ expect(inputDate.toDateString()).to.eq(expectedDate.toDateString())
+ })
+
+}
+
+/**
+ * Create a public link share
+ * @param context The current share context
+ * @param shareName The name of the shared folder
+ * @param options The share options
+ */
+export function createLinkShare(context: ShareContext, shareName: string, options: ShareOptions | null = null): Cypress.Chainable<string> {
+ cy.login(context.user)
+ cy.visit('/apps/files')
+ openSharingPanel(shareName)
+
+ cy.intercept('POST', '**/ocs/v2.php/apps/files_sharing/api/v1/shares').as('createLinkShare')
+ cy.findByRole('button', { name: 'Create a new share link' }).click()
+ // Conduct optional checks based on the provided options
+ if (options) {
+ cy.get('.sharing-entry__actions').should('be.visible') // Wait for the dialog to open
+ checkPasswordState(options.enforcePassword ?? false, options.alwaysAskForPassword ?? false)
+ checkExpirationDateState(options.enforceExpirationDate ?? false, options.defaultExpirationDateSet ?? false)
+ cy.findByRole('button', { name: 'Create share' }).click()
+ }
+
+ return cy.wait('@createLinkShare')
+ .should(({ response }) => {
+ expect(response?.statusCode).to.eq(200)
+ const url = response?.body?.ocs?.data?.url
+ expect(url).to.match(/^https?:\/\//)
+ context.url = url
+ })
+ .then(() => cy.wrap(context.url as string))
+}
+
+/**
+ * open link share details for specific index
+ *
+ * @param index
+ */
+export function openLinkShareDetails(index: number) {
+ cy.findByRole('list', { name: 'Link shares' })
+ .findAllByRole('listitem')
+ .eq(index)
+ .findByRole('button', { name: /Actions/i })
+ .click()
+ cy.findByRole('menuitem', { name: /Customize link/i }).click()
+}
+
+/**
+ * Adjust share permissions to be editable
+ */
+function adjustSharePermission(): void {
+ openLinkShareDetails(0)
+
+ cy.get('[data-cy-files-sharing-share-permissions-bundle]').should('be.visible')
+ cy.get('[data-cy-files-sharing-share-permissions-bundle="upload-edit"]').click()
+
+ cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('updateShare')
+ cy.findByRole('button', { name: 'Update share' }).click()
+ cy.wait('@updateShare').its('response.statusCode').should('eq', 200)
+}
+
+/**
+ * Setup a public share and backup the state.
+ * If the setup was already done in another run, the state will be restored.
+ *
+ * @param shareName The name of the shared folder
+ * @return The URL of the share
+ */
+export function setupPublicShare(shareName = 'shared'): Cypress.Chainable<string> {
+
+ return cy.task('getVariable', { key: 'public-share-data' })
+ .then((data) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const { dataSnapshot, shareUrl } = data as any || {}
+ if (dataSnapshot) {
+ cy.restoreState(dataSnapshot)
+ defaultShareContext.url = shareUrl
+ return cy.wrap(shareUrl as string)
+ } else {
+ const shareData: Record<string, unknown> = {}
+ return cy.createRandomUser()
+ .then((user) => {
+ defaultShareContext.user = user
+ })
+ .then(() => setupData(defaultShareContext.user, shareName))
+ .then(() => createLinkShare(defaultShareContext, shareName))
+ .then((url) => {
+ shareData.shareUrl = url
+ })
+ .then(() => adjustSharePermission())
+ .then(() =>
+ cy.saveState().then((snapshot) => {
+ shareData.dataSnapshot = snapshot
+ }),
+ )
+ .then(() => cy.task('setVariable', { key: 'public-share-data', value: shareData }))
+ .then(() => cy.log(`Public share setup, URL: ${shareData.shareUrl}`))
+ .then(() => cy.wrap(defaultShareContext.url))
+ }
+ })
+}
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..87f16b01387
--- /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 './PublicShareUtils.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/default-view.cy.ts b/cypress/e2e/files_sharing/public-share/default-view.cy.ts
new file mode 100644
index 00000000000..33e0a57da11
--- /dev/null
+++ b/cypress/e2e/files_sharing/public-share/default-view.cy.ts
@@ -0,0 +1,102 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { User } from '@nextcloud/cypress'
+import { getRowForFile } from '../../files/FilesUtils.ts'
+import { createLinkShare, setupData } from './PublicShareUtils.ts'
+
+describe('files_sharing: Public share - setting the default view mode', () => {
+
+ let user: User
+
+ beforeEach(() => {
+ cy.createRandomUser()
+ .then(($user) => (user = $user))
+ .then(() => setupData(user, 'shared'))
+ })
+
+ it('is by default in list view', () => {
+ const context = { user }
+ createLinkShare(context, 'shared')
+ .then((url) => {
+ cy.logout()
+ cy.visit(url!)
+
+ // See file is visible
+ getRowForFile('foo.txt').should('be.visible')
+ // See we are in list view
+ cy.findByRole('button', { name: 'Switch to grid view' })
+ .should('be.visible')
+ .and('not.be.disabled')
+ })
+ })
+
+ it('can be toggled by user', () => {
+ const context = { user }
+ createLinkShare(context, 'shared')
+ .then((url) => {
+ cy.logout()
+ cy.visit(url!)
+
+ // See file is visible
+ getRowForFile('foo.txt')
+ .should('be.visible')
+ // See we are in list view
+ .find('.files-list__row-icon')
+ .should(($el) => expect($el.outerWidth()).to.be.lessThan(99))
+
+ // See the grid view toggle
+ cy.findByRole('button', { name: 'Switch to grid view' })
+ .should('be.visible')
+ .and('not.be.disabled')
+ // And can change to grid view
+ .click()
+
+ // See we are in grid view
+ getRowForFile('foo.txt')
+ .find('.files-list__row-icon')
+ .should(($el) => expect($el.outerWidth()).to.be.greaterThan(99))
+
+ // See the grid view toggle is now the list view toggle
+ cy.findByRole('button', { name: 'Switch to list view' })
+ .should('be.visible')
+ .and('not.be.disabled')
+ })
+ })
+
+ it('can be changed to default grid view', () => {
+ const context = { user }
+ createLinkShare(context, 'shared')
+ .then((url) => {
+ // Can set the "grid" view checkbox
+ cy.findByRole('list', { name: 'Link shares' })
+ .findAllByRole('listitem')
+ .first()
+ .findByRole('button', { name: /Actions/i })
+ .click()
+ cy.findByRole('menuitem', { name: /Customize link/i }).click()
+ cy.findByRole('checkbox', { name: /Show files in grid view/i })
+ .scrollIntoView()
+ cy.findByRole('checkbox', { name: /Show files in grid view/i })
+ .should('not.be.checked')
+ .check({ force: true })
+
+ // Wait for the share 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').its('response.statusCode').should('eq', 200)
+
+ // Logout and visit the share
+ cy.logout()
+ cy.visit(url!)
+
+ // See file is visible
+ getRowForFile('foo.txt').should('be.visible')
+ // See we are in list view
+ cy.findByRole('button', { name: 'Switch to list view' })
+ .should('be.visible')
+ .and('not.be.disabled')
+ })
+ })
+})
diff --git a/cypress/e2e/files_sharing/public-share/download.cy.ts b/cypress/e2e/files_sharing/public-share/download.cy.ts
new file mode 100644
index 00000000000..372f553a8a0
--- /dev/null
+++ b/cypress/e2e/files_sharing/public-share/download.cy.ts
@@ -0,0 +1,266 @@
+/*!
+ * 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 { createLinkShare, getShareUrl, openLinkShareDetails, setupPublicShare, type ShareContext } from './PublicShareUtils.ts'
+import { getRowForFile, getRowForFileId, triggerActionForFile, triggerActionForFileId } from '../../files/FilesUtils.ts'
+import { zipFileContains } from '../../../support/utils/assertions.ts'
+import type { User } from '@nextcloud/cypress'
+
+describe('files_sharing: Public share - downloading files', { testIsolation: true }, () => {
+
+ // in general there is no difference except downloading
+ // as file shares have the source of the share token but a different displayname
+ describe('file share', () => {
+ let fileId: number
+
+ before(() => {
+ cy.createRandomUser().then((user) => {
+ const context: ShareContext = { user }
+ cy.uploadContent(user, new Blob(['<content>foo</content>']), 'text/plain', '/file.txt')
+ .then(({ headers }) => { fileId = Number.parseInt(headers['oc-fileid']) })
+ cy.login(user)
+ createLinkShare(context, 'file.txt')
+ .then(() => cy.logout())
+ .then(() => cy.visit(context.url!))
+ })
+ })
+
+ it('can download the file', () => {
+ getRowForFileId(fileId)
+ .should('be.visible')
+ getRowForFileId(fileId)
+ .find('[data-cy-files-list-row-name]')
+ .should((el) => expect(el.text()).to.match(/file\s*\.txt/)) // extension is sparated so there might be a space between
+ triggerActionForFileId(fileId, 'download')
+ // check a file is downloaded with the correct name
+ const downloadsFolder = Cypress.config('downloadsFolder')
+ cy.readFile(`${downloadsFolder}/file.txt`, 'utf-8', { timeout: 15000 })
+ .should('exist')
+ .and('have.length.gt', 5)
+ .and('contain', '<content>foo</content>')
+ })
+ })
+
+ describe('folder share', () => {
+ 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}/download.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', '<content>foo</content>')
+ })
+ })
+
+ 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', '<content>foo</content>')
+ })
+ })
+ })
+
+ describe('download permission - link share', () => {
+ let context: ShareContext
+ beforeEach(() => {
+ cy.createRandomUser().then((user) => {
+ cy.mkdir(user, '/test')
+
+ context = { user }
+ createLinkShare(context, 'test')
+ cy.login(context.user)
+ cy.visit('/apps/files')
+ })
+ })
+
+ deleteDownloadsFolderBeforeEach()
+
+ it('download permission is retained', () => {
+ getRowForFile('test').should('be.visible')
+ triggerActionForFile('test', 'details')
+
+ openLinkShareDetails(0)
+
+ cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('update')
+
+ cy.findByRole('checkbox', { name: /hide download/i })
+ .should('exist')
+ .and('not.be.checked')
+ .check({ force: true })
+ cy.findByRole('checkbox', { name: /hide download/i })
+ .should('be.checked')
+ cy.findByRole('button', { name: /update share/i })
+ .click()
+
+ cy.wait('@update')
+
+ openLinkShareDetails(0)
+ cy.findByRole('checkbox', { name: /hide download/i })
+ .should('be.checked')
+
+ cy.reload()
+
+ openLinkShareDetails(0)
+ cy.findByRole('checkbox', { name: /hide download/i })
+ .should('be.checked')
+ })
+ })
+
+ describe('download permission - mail share', () => {
+ let user: User
+
+ beforeEach(() => {
+ cy.createRandomUser().then(($user) => {
+ user = $user
+ cy.mkdir(user, '/test')
+ cy.login(user)
+ cy.visit('/apps/files')
+ })
+ })
+
+ it('download permission is retained', () => {
+ getRowForFile('test').should('be.visible')
+ triggerActionForFile('test', 'details')
+
+ cy.findByRole('combobox', { name: /Enter external recipients/i })
+ .type('test@example.com')
+
+ cy.get('.option[sharetype="4"][user="test@example.com"]')
+ .parent('li')
+ .click()
+ cy.findByRole('button', { name: /advanced settings/i })
+ .should('be.visible')
+ .click()
+
+ cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('update')
+
+ cy.findByRole('checkbox', { name: /hide download/i })
+ .should('exist')
+ .and('not.be.checked')
+ .check({ force: true })
+ cy.findByRole('button', { name: /save share/i })
+ .click()
+
+ cy.wait('@update')
+
+ openLinkShareDetails(0)
+ cy.findByRole('button', { name: /advanced settings/i })
+ .click()
+ cy.findByRole('checkbox', { name: /hide download/i })
+ .should('exist')
+ .and('be.checked')
+ })
+ })
+})
diff --git a/cypress/e2e/files_sharing/public-share/header-avatar.cy.ts b/cypress/e2e/files_sharing/public-share/header-avatar.cy.ts
new file mode 100644
index 00000000000..c7227062293
--- /dev/null
+++ b/cypress/e2e/files_sharing/public-share/header-avatar.cy.ts
@@ -0,0 +1,193 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { ShareContext } from './PublicShareUtils.ts'
+import { createLinkShare, setupData } from './PublicShareUtils.ts'
+
+/**
+ * This tests ensures that on public shares the header avatar menu correctly works
+ */
+describe('files_sharing: Public share - header avatar menu', { testIsolation: true }, () => {
+ let context: ShareContext
+ let firstPublicShareUrl = ''
+ let secondPublicShareUrl = ''
+
+ before(() => {
+ cy.createRandomUser()
+ .then((user) => {
+ context = {
+ user,
+ url: undefined,
+ }
+ setupData(context.user, 'public1')
+ setupData(context.user, 'public2')
+ createLinkShare(context, 'public1').then((shareUrl) => {
+ firstPublicShareUrl = shareUrl
+ cy.log(`Created first share with URL: ${shareUrl}`)
+ })
+ createLinkShare(context, 'public2').then((shareUrl) => {
+ secondPublicShareUrl = shareUrl
+ cy.log(`Created second share with URL: ${shareUrl}`)
+ })
+ })
+ })
+
+ beforeEach(() => {
+ cy.logout()
+ cy.visit(firstPublicShareUrl)
+ })
+
+ it('See the undefined avatar menu', () => {
+ cy.get('header')
+ .findByRole('navigation', { name: /User menu/i })
+ .should('be.visible')
+ .findByRole('button', { name: /User menu/i })
+ .should('be.visible')
+ .click()
+ cy.get('#header-menu-public-page-user-menu')
+ .as('headerMenu')
+
+ // Note that current guest user is not identified
+ cy.get('@headerMenu')
+ .should('be.visible')
+ .findByRole('note')
+ .should('be.visible')
+ .should('contain', 'not identified')
+
+ // Button to set guest name
+ cy.get('@headerMenu')
+ .findByRole('link', { name: /Set public name/i })
+ .should('be.visible')
+ })
+
+ it('Can set public name', () => {
+ cy.get('header')
+ .findByRole('navigation', { name: /User menu/i })
+ .should('be.visible')
+ .findByRole('button', { name: /User menu/i })
+ .should('be.visible')
+ .as('userMenuButton')
+
+ // Open the user menu
+ cy.get('@userMenuButton').click()
+ cy.get('#header-menu-public-page-user-menu')
+ .as('headerMenu')
+
+ cy.get('@headerMenu')
+ .findByRole('link', { name: /Set public name/i })
+ .should('be.visible')
+ .click()
+
+ // Check the dialog is visible
+ cy.findByRole('dialog', { name: /Guest identification/i })
+ .should('be.visible')
+ .as('guestIdentificationDialog')
+
+ // Check the note is visible
+ cy.get('@guestIdentificationDialog')
+ .findByRole('note')
+ .should('contain', 'not identified')
+
+ // Check the input is visible
+ cy.get('@guestIdentificationDialog')
+ .findByRole('textbox', { name: /Name/i })
+ .should('be.visible')
+ .type('{selectAll}John Doe{enter}')
+
+ // Check that the dialog is closed
+ cy.get('@guestIdentificationDialog')
+ .should('not.exist')
+
+ // Check that the avatar changed
+ cy.get('@userMenuButton')
+ .find('img')
+ .invoke('attr', 'src')
+ .should('include', 'avatar/guest/John%20Doe')
+ })
+
+ it('Guest name us persistent and can be changed', () => {
+ cy.get('header')
+ .findByRole('navigation', { name: /User menu/i })
+ .should('be.visible')
+ .findByRole('button', { name: /User menu/i })
+ .should('be.visible')
+ .as('userMenuButton')
+
+ // Open the user menu
+ cy.get('@userMenuButton').click()
+ cy.get('#header-menu-public-page-user-menu')
+ .as('headerMenu')
+
+ cy.get('@headerMenu')
+ .findByRole('link', { name: /Set public name/i })
+ .should('be.visible')
+ .click()
+
+ // Check the dialog is visible
+ cy.findByRole('dialog', { name: /Guest identification/i })
+ .should('be.visible')
+ .as('guestIdentificationDialog')
+
+ // Set the name
+ cy.get('@guestIdentificationDialog')
+ .findByRole('textbox', { name: /Name/i })
+ .should('be.visible')
+ .type('{selectAll}Jane Doe{enter}')
+
+ // Check that the dialog is closed
+ cy.get('@guestIdentificationDialog')
+ .should('not.exist')
+
+ // Create another share
+ cy.visit(secondPublicShareUrl)
+
+ cy.get('header')
+ .findByRole('navigation', { name: /User menu/i })
+ .should('be.visible')
+ .findByRole('button', { name: /User menu/i })
+ .should('be.visible')
+ .as('userMenuButton')
+
+ // Open the user menu
+ cy.get('@userMenuButton').click()
+ cy.get('#header-menu-public-page-user-menu')
+ .as('headerMenu')
+
+ // See the note with the current name
+ cy.get('@headerMenu')
+ .findByRole('note')
+ .should('contain', 'You will be identified as Jane Doe')
+
+ cy.get('@headerMenu')
+ .findByRole('link', { name: /Change public name/i })
+ .should('be.visible')
+ .click()
+
+ // Check the dialog is visible
+ cy.findByRole('dialog', { name: /Guest identification/i })
+ .should('be.visible')
+ .as('guestIdentificationDialog')
+
+ // Check that the note states the current name
+ // cy.get('@guestIdentificationDialog')
+ // .findByRole('note')
+ // .should('contain', 'are currently identified as Jane Doe')
+
+ // Change the name
+ cy.get('@guestIdentificationDialog')
+ .findByRole('textbox', { name: /Name/i })
+ .should('be.visible')
+ .type('{selectAll}Foo Bar{enter}')
+
+ // Check that the dialog is closed
+ cy.get('@guestIdentificationDialog')
+ .should('not.exist')
+
+ // Check that the avatar changed with the second name
+ cy.get('@userMenuButton')
+ .find('img')
+ .invoke('attr', 'src')
+ .should('include', 'avatar/guest/Foo%20Bar')
+ })
+})
diff --git a/cypress/e2e/files_sharing/public-share/header-menu.cy.ts b/cypress/e2e/files_sharing/public-share/header-menu.cy.ts
new file mode 100644
index 00000000000..1dd0de13477
--- /dev/null
+++ b/cypress/e2e/files_sharing/public-share/header-menu.cy.ts
@@ -0,0 +1,199 @@
+/*!
+ * 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 { getShareUrl, setupPublicShare } from './PublicShareUtils.ts'
+
+/**
+ * This tests ensures that on public shares the header actions menu correctly works
+ */
+describe('files_sharing: Public share - header actions menu', { testIsolation: true }, () => {
+
+ before(() => setupPublicShare())
+ beforeEach(() => {
+ cy.logout()
+ cy.visit(getShareUrl())
+ })
+
+ it('Can download all files', () => {
+ cy.get('header')
+ .findByRole('button', { name: 'Download' })
+ .should('be.visible')
+ cy.get('header')
+ .findByRole('button', { name: 'Download' })
+ .click()
+
+ // check a file is downloaded
+ const downloadsFolder = Cypress.config('downloadsFolder')
+ cy.readFile(`${downloadsFolder}/shared.zip`, null, { timeout: 15000 })
+ .should('exist')
+ .and('have.length.gt', 30)
+ // Check all files are included
+ .and(zipFileContains([
+ 'shared/',
+ 'shared/foo.txt',
+ 'shared/subfolder/',
+ 'shared/subfolder/bar.txt',
+ ]))
+ })
+
+ it('Can copy direct link', () => {
+ // Check the button
+ cy.get('header')
+ .findByRole('button', { name: /More actions/i })
+ .should('be.visible')
+ cy.get('header')
+ .findByRole('button', { name: /More actions/i })
+ .click()
+ // See the menu
+ cy.findByRole('menu', { name: /More action/i })
+ .should('be.visible')
+ // see correct link in item
+ cy.findByRole('menuitem', { name: 'Direct link' })
+ .should('be.visible')
+ .and('have.attr', 'href')
+ .then((attribute) => expect(attribute).to.match(new RegExp(`^${Cypress.env('baseUrl')}/public.php/dav/files/.+/?accept=zip$`)))
+ // see menu closes on click
+ cy.findByRole('menuitem', { name: 'Direct link' })
+ .click()
+ cy.findByRole('menu', { name: /More actions/i })
+ .should('not.exist')
+ })
+
+ it('Can create federated share', () => {
+ // Check the button
+ cy.get('header')
+ .findByRole('button', { name: /More actions/i })
+ .should('be.visible')
+ cy.get('header')
+ .findByRole('button', { name: /More actions/i })
+ .click()
+ // See the menu
+ cy.findByRole('menu', { name: /More action/i })
+ .should('be.visible')
+ // see correct button
+ cy.findByRole('menuitem', { name: /Add to your/i })
+ .should('be.visible')
+ .click()
+ // see the dialog
+ cy.findByRole('dialog', { name: /Add to your Nextcloud/i })
+ .should('be.visible')
+ cy.findByRole('dialog', { name: /Add to your Nextcloud/i }).within(() => {
+ cy.findByRole('textbox')
+ .type('user@nextcloud.local')
+ // create share
+ cy.intercept('POST', '**/apps/federatedfilesharing/createFederatedShare')
+ .as('createFederatedShare')
+ cy.findByRole('button', { name: 'Create share' })
+ .click()
+ cy.wait('@createFederatedShare')
+ })
+ })
+
+ it('Has user feedback while creating federated share', () => {
+ // Check the button
+ cy.get('header')
+ .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()
+ // see the dialog
+ cy.findByRole('dialog', { name: /Add to your Nextcloud/i }).should('be.visible').within(() => {
+ cy.findByRole('textbox')
+ .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', (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()
+ // see that while the share is created the button is disabled
+ cy.findByRole('button', { name: 'Create share' })
+ .should('be.disabled')
+ .then(() => {
+ // continue the request
+ resolve(null)
+ })
+ cy.wait('@createFederatedShare')
+ // see that the button is no longer disabled
+ cy.findByRole('button', { name: 'Create share' })
+ .should('not.be.disabled')
+ })
+ })
+
+ it('Has input validation for federated share', () => {
+ // Check the button
+ cy.get('header')
+ .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()
+ // see the dialog
+ cy.findByRole('dialog', { name: /Add to your Nextcloud/i }).should('be.visible').within(() => {
+ // Check domain only
+ cy.findByRole('textbox')
+ .type('nextcloud.local')
+ cy.findByRole('textbox')
+ .should(haveValidity(/user/i))
+ // Check no valid domain
+ cy.findByRole('textbox')
+ .type('{selectAll}user@invalid')
+ cy.findByRole('textbox')
+ .should(haveValidity(/invalid.+url/i))
+ })
+ })
+
+ it('See primary action is moved to menu on small screens', () => {
+ cy.viewport(490, 490)
+ // Check the button does not exist
+ 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)
+ 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')
+
+ // See that direct link works
+ cy.findByRole('menuitem', { name: 'Direct link' })
+ .should('be.visible')
+ .and('have.attr', 'href')
+ .then((attribute) => expect(attribute).to.match(new RegExp(`^${Cypress.env('baseUrl')}/public.php/dav/files/.+/?accept=zip$`)))
+ // 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..adeb6e52504
--- /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 './PublicShareUtils.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/required-before-create.cy.ts b/cypress/e2e/files_sharing/public-share/required-before-create.cy.ts
new file mode 100644
index 00000000000..772b7fa8380
--- /dev/null
+++ b/cypress/e2e/files_sharing/public-share/required-before-create.cy.ts
@@ -0,0 +1,192 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { ShareContext } from './PublicShareUtils.ts'
+import type { ShareOptions } from '../ShareOptionsType.ts'
+import { defaultShareOptions } from '../ShareOptionsType.ts'
+import { setupData, createLinkShare } from './PublicShareUtils.ts'
+
+describe('files_sharing: Before create checks', () => {
+
+ let shareContext: ShareContext
+
+ before(() => {
+ // Setup data for the shared folder once before all tests
+ cy.createRandomUser().then((randomUser) => {
+ shareContext = {
+ user: randomUser,
+ }
+ })
+ })
+
+ afterEach(() => {
+ cy.runOccCommand('config:app:delete core shareapi_enable_link_password_by_default')
+ cy.runOccCommand('config:app:delete core shareapi_enforce_links_password')
+ cy.runOccCommand('config:app:delete core shareapi_default_expire_date')
+ cy.runOccCommand('config:app:delete core shareapi_enforce_expire_date')
+ cy.runOccCommand('config:app:delete core shareapi_expire_after_n_days')
+ })
+
+ const applyShareOptions = (options: ShareOptions = defaultShareOptions): void => {
+ cy.runOccCommand(`config:app:set --value ${options.alwaysAskForPassword ? 'yes' : 'no'} core shareapi_enable_link_password_by_default`)
+ cy.runOccCommand(`config:app:set --value ${options.enforcePassword ? 'yes' : 'no'} core shareapi_enforce_links_password`)
+ cy.runOccCommand(`config:app:set --value ${options.enforceExpirationDate ? 'yes' : 'no'} core shareapi_enforce_expire_date`)
+ cy.runOccCommand(`config:app:set --value ${options.defaultExpirationDateSet ? 'yes' : 'no'} core shareapi_default_expire_date`)
+ if (options.defaultExpirationDateSet) {
+ cy.runOccCommand('config:app:set --value 2 core shareapi_expire_after_n_days')
+ }
+ }
+
+ it('Checks if user can create share when both password and expiration date are enforced', () => {
+ const shareOptions : ShareOptions = {
+ alwaysAskForPassword: true,
+ enforcePassword: true,
+ enforceExpirationDate: true,
+ defaultExpirationDateSet: true,
+ }
+ applyShareOptions(shareOptions)
+ const shareName = 'passwordAndExpireEnforced'
+ setupData(shareContext.user, shareName)
+ createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => {
+ shareContext.url = shareUrl
+ cy.log(`Created share with URL: ${shareUrl}`)
+ })
+ })
+
+ it('Checks if user can create share when password is enforced and expiration date has a default set', () => {
+ const shareOptions : ShareOptions = {
+ alwaysAskForPassword: true,
+ enforcePassword: true,
+ defaultExpirationDateSet: true,
+ }
+ applyShareOptions(shareOptions)
+ const shareName = 'passwordEnforcedDefaultExpire'
+ setupData(shareContext.user, shareName)
+ createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => {
+ shareContext.url = shareUrl
+ cy.log(`Created share with URL: ${shareUrl}`)
+ })
+ })
+
+ it('Checks if user can create share when password is optionally requested and expiration date is enforced', () => {
+ const shareOptions : ShareOptions = {
+ alwaysAskForPassword: true,
+ defaultExpirationDateSet: true,
+ enforceExpirationDate: true,
+ }
+ applyShareOptions(shareOptions)
+ const shareName = 'defaultPasswordExpireEnforced'
+ setupData(shareContext.user, shareName)
+ createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => {
+ shareContext.url = shareUrl
+ cy.log(`Created share with URL: ${shareUrl}`)
+ })
+ })
+
+ it('Checks if user can create share when password is optionally requested and expiration date have defaults set', () => {
+ const shareOptions : ShareOptions = {
+ alwaysAskForPassword: true,
+ defaultExpirationDateSet: true,
+ }
+ applyShareOptions(shareOptions)
+ const shareName = 'defaultPasswordAndExpire'
+ setupData(shareContext.user, shareName)
+ createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => {
+ shareContext.url = shareUrl
+ cy.log(`Created share with URL: ${shareUrl}`)
+ })
+ })
+
+ it('Checks if user can create share with password enforced and expiration date set but not enforced', () => {
+ const shareOptions : ShareOptions = {
+ alwaysAskForPassword: true,
+ enforcePassword: true,
+ defaultExpirationDateSet: true,
+ enforceExpirationDate: false,
+ }
+ applyShareOptions(shareOptions)
+ const shareName = 'passwordEnforcedExpireSetNotEnforced'
+ setupData(shareContext.user, shareName)
+ createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => {
+ shareContext.url = shareUrl
+ cy.log(`Created share with URL: ${shareUrl}`)
+ })
+ })
+
+ it('Checks if user can create a share when both password and expiration date have default values but are both not enforced', () => {
+ const shareOptions : ShareOptions = {
+ alwaysAskForPassword: true,
+ enforcePassword: false,
+ defaultExpirationDateSet: true,
+ enforceExpirationDate: false,
+ }
+ applyShareOptions(shareOptions)
+ const shareName = 'defaultPasswordAndExpirationNotEnforced'
+ setupData(shareContext.user, shareName)
+ createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => {
+ shareContext.url = shareUrl
+ cy.log(`Created share with URL: ${shareUrl}`)
+ })
+ })
+
+ it('Checks if user can create share with password not enforced but expiration date enforced', () => {
+ const shareOptions : ShareOptions = {
+ alwaysAskForPassword: true,
+ enforcePassword: false,
+ defaultExpirationDateSet: true,
+ enforceExpirationDate: true,
+ }
+ applyShareOptions(shareOptions)
+ const shareName = 'noPasswordExpireEnforced'
+ setupData(shareContext.user, shareName)
+ createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => {
+ shareContext.url = shareUrl
+ cy.log(`Created share with URL: ${shareUrl}`)
+ })
+ })
+
+ it('Checks if user can create share with password not enforced and expiration date has a default set', () => {
+ const shareOptions : ShareOptions = {
+ alwaysAskForPassword: true,
+ enforcePassword: false,
+ defaultExpirationDateSet: true,
+ enforceExpirationDate: false,
+ }
+ applyShareOptions(shareOptions)
+ const shareName = 'defaultExpireNoPasswordEnforced'
+ setupData(shareContext.user, shareName)
+ createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => {
+ shareContext.url = shareUrl
+ cy.log(`Created share with URL: ${shareUrl}`)
+ })
+ })
+
+ it('Checks if user can create share with expiration date set and password not enforced', () => {
+ const shareOptions : ShareOptions = {
+ alwaysAskForPassword: true,
+ enforcePassword: false,
+ defaultExpirationDateSet: true,
+ }
+ applyShareOptions(shareOptions)
+
+ const shareName = 'noPasswordExpireDefault'
+ setupData(shareContext.user, shareName)
+ createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => {
+ shareContext.url = shareUrl
+ cy.log(`Created share with URL: ${shareUrl}`)
+ })
+ })
+
+ it('Checks if user can create share with password not enforced, expiration date not enforced, and no defaults set', () => {
+ applyShareOptions()
+ const shareName = 'noPasswordNoExpireNoDefaults'
+ setupData(shareContext.user, shareName)
+ createLinkShare(shareContext, shareName, null).then((shareUrl) => {
+ shareContext.url = shareUrl
+ cy.log(`Created share with URL: ${shareUrl}`)
+ })
+ })
+
+})
diff --git a/cypress/e2e/files_sharing/public-share/sidebar-tab.cy.ts b/cypress/e2e/files_sharing/public-share/sidebar-tab.cy.ts
new file mode 100644
index 00000000000..6b026717fd8
--- /dev/null
+++ b/cypress/e2e/files_sharing/public-share/sidebar-tab.cy.ts
@@ -0,0 +1,45 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from "@nextcloud/cypress"
+import { createShare } from "./FilesSharingUtils"
+import { createLinkShare, openLinkShareDetails } from "./PublicShareUtils"
+
+describe('files_sharing: sidebar tab', () => {
+ let alice: User
+
+ beforeEach(() => {
+ cy.createRandomUser()
+ .then((user) => {
+ alice = user
+ cy.mkdir(user, '/test')
+ cy.login(user)
+ cy.visit('/apps/files')
+ })
+ })
+
+ /**
+ * Regression tests of https://github.com/nextcloud/server/issues/53566
+ * Where the ' char was shown as &#39;
+ */
+ it('correctly lists shares by label with special characters', () => {
+ createLinkShare({ user: alice }, 'test')
+ openLinkShareDetails(0)
+ cy.findByRole('textbox', { name: /share label/i })
+ .should('be.visible')
+ .type('Alice\' share')
+
+ cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('PUT')
+ cy.findByRole('button', { name: /update share/i }).click()
+ cy.wait('@PUT')
+
+ // see the label is shown correctly
+ cy.findByRole('list', { name: /link shares/i })
+ .findAllByRole('listitem')
+ .should('have.length', 1)
+ .first()
+ .should('contain.text', 'Share link (Alice\' share)')
+ })
+})
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..f95115ee591
--- /dev/null
+++ b/cypress/e2e/files_sharing/public-share/view_file-drop.cy.ts
@@ -0,0 +1,172 @@
+/*!
+ * 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.get('[data-cy-files-sharing-file-drop]')
+ .contains(`Upload files to ${shareName}`)
+ .should('be.visible')
+ cy.get('[data-cy-files-sharing-file-drop]')
+ .contains('agree to the terms of service')
+ .should('be.visible')
+ 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..0e2d2edab6c
--- /dev/null
+++ b/cypress/e2e/files_sharing/public-share/view_view-only-no-download.cy.ts
@@ -0,0 +1,100 @@
+/*!
+ * 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')
+ })
+})
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..511a1caeb09
--- /dev/null
+++ b/cypress/e2e/files_sharing/public-share/view_view-only.cy.ts
@@ -0,0 +1,103 @@
+/*!
+ * 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(['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')
+ })
+
+ it('Only download action is actions available', () => {
+ getActionButtonForFile('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/e2e/files_sharing/share-status-action.cy.ts b/cypress/e2e/files_sharing/share-status-action.cy.ts
new file mode 100644
index 00000000000..f02ec676573
--- /dev/null
+++ b/cypress/e2e/files_sharing/share-status-action.cy.ts
@@ -0,0 +1,125 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { User } from '@nextcloud/cypress'
+import { createShare } from './FilesSharingUtils.ts'
+import { closeSidebar, enableGridMode, getActionButtonForFile, getInlineActionEntryForFile, getRowForFile } from '../files/FilesUtils.ts'
+
+describe('files_sharing: Sharing status action', { testIsolation: true }, () => {
+ /**
+ * Regression test of https://github.com/nextcloud/server/issues/45723
+ */
+ it('No "shared" tag when user ID is purely numerical but there are no shares', () => {
+ const user = {
+ language: 'en',
+ password: 'test1234',
+ userId: String(Math.floor(Math.random() * 1000)),
+ } as User
+ cy.createUser(user)
+ cy.mkdir(user, '/folder')
+ cy.login(user)
+
+ cy.visit('/apps/files')
+
+ getRowForFile('folder')
+ .should('be.visible')
+ .find('[data-cy-files-list-row-actions]')
+ .findByRole('button', { name: 'Shared' })
+ .should('not.exist')
+ })
+
+ it('Render quick option for sharing', () => {
+ cy.createRandomUser().then((user) => {
+ cy.mkdir(user, '/folder')
+ cy.login(user)
+
+ cy.visit('/apps/files')
+ })
+
+ getRowForFile('folder')
+ .should('be.visible')
+ .find('[data-cy-files-list-row-actions]')
+ .findByRole('button', { name: /Sharing options/ })
+ .should('be.visible')
+ .click()
+
+ // check the click opened the sidebar
+ cy.get('[data-cy-sidebar]')
+ .should('be.visible')
+ // and ensure the sharing tab is selected
+ .findByRole('tab', { name: 'Sharing', selected: true })
+ .should('exist')
+ })
+
+ describe('Sharing inline status action handling', () => {
+ let user: User
+ let sharee: User
+
+ before(() => {
+ cy.createRandomUser().then(($user) => {
+ sharee = $user
+ })
+ cy.createRandomUser().then(($user) => {
+ user = $user
+ cy.mkdir(user, '/folder')
+ cy.login(user)
+
+ cy.visit('/apps/files')
+ getRowForFile('folder').should('be.visible')
+
+ createShare('folder', sharee.userId)
+ closeSidebar()
+ })
+ cy.logout()
+ })
+
+ it('Render inline status action for sharer', () => {
+ cy.login(user)
+ cy.visit('/apps/files')
+
+ getInlineActionEntryForFile('folder', 'sharing-status')
+ .should('have.attr', 'aria-label', `Shared with ${sharee.userId}`)
+ .should('have.attr', 'title', `Shared with ${sharee.userId}`)
+ .should('be.visible')
+ })
+
+ it('Render status action in gridview for sharer', () => {
+ cy.login(user)
+ cy.visit('/apps/files')
+ enableGridMode()
+
+ getRowForFile('folder')
+ .should('be.visible')
+ getActionButtonForFile('folder')
+ .click()
+ cy.findByRole('menu')
+ .findByRole('menuitem', { name: /shared with/i })
+ .should('be.visible')
+ })
+
+ it('Render inline status action for sharee', () => {
+ cy.login(sharee)
+ cy.visit('/apps/files')
+
+ getInlineActionEntryForFile('folder', 'sharing-status')
+ .should('have.attr', 'aria-label', `Shared by ${user.userId}`)
+ .should('be.visible')
+ })
+
+ it('Render status action in grid view for sharee', () => {
+ cy.login(sharee)
+ cy.visit('/apps/files')
+
+ enableGridMode()
+
+ getRowForFile('folder')
+ .should('be.visible')
+ getActionButtonForFile('folder')
+ .click()
+ cy.findByRole('menu')
+ .findByRole('menuitem', { name: `Shared by ${user.userId}` })
+ .should('be.visible')
+ })
+ })
+})
diff --git a/cypress/e2e/files_trashbin/files-trash-action.cy.ts b/cypress/e2e/files_trashbin/files-trash-action.cy.ts
new file mode 100644
index 00000000000..090a7ed8d5d
--- /dev/null
+++ b/cypress/e2e/files_trashbin/files-trash-action.cy.ts
@@ -0,0 +1,69 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { User } from '@nextcloud/cypress'
+import { deleteFileWithRequest, triggerFileListAction } from '../files/FilesUtils.ts'
+
+const FILE_COUNT = 5
+describe('files_trashbin: Empty trashbin action', { testIsolation: true }, () => {
+ let user: User
+
+ beforeEach(() => {
+ cy.createRandomUser().then(($user) => {
+ user = $user
+ // create 5 fake files and move them to trash
+ for (let index = 0; index < FILE_COUNT; index++) {
+ cy.uploadContent(user, new Blob(['<content>']), 'text/plain', `/file${index}.txt`)
+ deleteFileWithRequest(user, `/file${index}.txt`)
+ }
+ // login
+ cy.login(user)
+ })
+ })
+
+ it('Can empty trashbin', () => {
+ cy.visit('/apps/files')
+ // Home have no files (or the default welcome file)
+ cy.get('[data-cy-files-list-row-fileid]').should('have.length', 1)
+ cy.get('[data-cy-files-list-action="empty-trash"]').should('not.exist')
+
+ // Go to trashbin, and see our deleted files
+ cy.visit('/apps/files/trashbin')
+ cy.get('[data-cy-files-list-row-fileid]').should('have.length', FILE_COUNT)
+
+ // Empty trashbin
+ cy.intercept('DELETE', '**/remote.php/dav/trashbin/**').as('emptyTrash')
+ triggerFileListAction('empty-trash')
+
+ // Confirm dialog
+ cy.get('[role=dialog]').should('be.visible')
+ .findByRole('button', { name: 'Empty deleted files' }).click()
+
+ // Wait for the request to finish
+ cy.wait('@emptyTrash').its('response.statusCode').should('eq', 204)
+ cy.get('@emptyTrash.all').should('have.length', 1)
+
+ // Trashbin should be empty
+ cy.get('[data-cy-files-list-row-fileid]').should('not.exist')
+ })
+
+ it('Cancelling empty trashbin action does not delete anything', () => {
+ // Go to trashbin, and see our deleted files
+ cy.visit('/apps/files/trashbin')
+ cy.get('[data-cy-files-list-row-fileid]').should('have.length', FILE_COUNT)
+
+ // Empty trashbin
+ cy.intercept('DELETE', '**/remote.php/dav/trashbin/**').as('emptyTrash')
+ triggerFileListAction('empty-trash')
+
+ // Cancel dialog
+ cy.get('[role=dialog]').should('be.visible')
+ .findByRole('button', { name: 'Cancel' }).click()
+
+ // request was never sent
+ cy.get('@emptyTrash').should('not.exist')
+ cy.get('[data-cy-files-list-row-fileid]').should('have.length', FILE_COUNT)
+ })
+
+})
diff --git a/cypress/e2e/files_trashbin/files.cy.ts b/cypress/e2e/files_trashbin/files.cy.ts
new file mode 100644
index 00000000000..4c2bce7df7a
--- /dev/null
+++ b/cypress/e2e/files_trashbin/files.cy.ts
@@ -0,0 +1,70 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from '@nextcloud/cypress'
+
+// @ts-expect-error package has wrong typings
+import { deleteDownloadsFolderBeforeEach } from 'cypress-delete-downloads-folder'
+import { deleteFileWithRequest, getRowForFileId, selectAllFiles, triggerActionForFileId } from '../files/FilesUtils.ts'
+
+describe('files_trashbin: download files', { testIsolation: true }, () => {
+ let user: User
+ const fileids: number[] = []
+
+ deleteDownloadsFolderBeforeEach()
+
+ before(() => {
+ cy.createRandomUser().then(($user) => {
+ user = $user
+
+ cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/file.txt')
+ .then(({ headers }) => fileids.push(Number.parseInt(headers['oc-fileid'])))
+ .then(() => deleteFileWithRequest(user, '/file.txt'))
+ cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/other-file.txt')
+ .then(({ headers }) => fileids.push(Number.parseInt(headers['oc-fileid'])))
+ .then(() => deleteFileWithRequest(user, '/other-file.txt'))
+ })
+ })
+
+ beforeEach(() => {
+ cy.login(user)
+ cy.visit('/apps/files/trashbin')
+ })
+
+ it('can download file', () => {
+ getRowForFileId(fileids[0]).should('be.visible')
+ getRowForFileId(fileids[1]).should('be.visible')
+
+ triggerActionForFileId(fileids[0], '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 a file using default action', () => {
+ getRowForFileId(fileids[0])
+ .should('be.visible')
+ .findByRole('button', { name: 'Download' })
+ .click({ force: true })
+
+ const downloadsFolder = Cypress.config('downloadsFolder')
+ cy.readFile(`${downloadsFolder}/file.txt`, { timeout: 15000 })
+ .should('exist')
+ .and('have.length.gt', 8)
+ .and('equal', '<content>')
+ })
+
+ // TODO: Fix this as this dependens on the webdav zip folder plugin not working for trashbin (and never worked with old NC legacy download ajax as well)
+ it('does not offer bulk download', () => {
+ cy.get('[data-cy-files-list-row-checkbox]').should('have.length', 2)
+ selectAllFiles()
+ cy.get('.files-list__selected').should('contain.text', '2 selected')
+ cy.get('[data-cy-files-list-selection-action="restore"]').should('be.visible')
+ cy.get('[data-cy-files-list-selection-action="download"]').should('not.exist')
+ })
+})
diff --git a/cypress/e2e/files_versions/filesVersionsUtils.ts b/cypress/e2e/files_versions/filesVersionsUtils.ts
index 4ea47162f0b..75c76b7e97c 100644
--- a/cypress/e2e/files_versions/filesVersionsUtils.ts
+++ b/cypress/e2e/files_versions/filesVersionsUtils.ts
@@ -1,29 +1,10 @@
-/* eslint-disable jsdoc/require-jsdoc */
/**
- * @copyright Copyright (c) 2022 Louis Chemineau <louis@chmn.me>
- *
- * @author Louis Chemineau <louis@chmn.me>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-
+/* eslint-disable jsdoc/require-jsdoc */
import type { User } from '@nextcloud/cypress'
-import path from 'path'
-import { createShare, type ShareSetting } from '../files_sharing/filesSharingUtils'
+import { createShare, type ShareSetting } from '../files_sharing/FilesSharingUtils'
export const uploadThreeVersions = (user: User, fileName: string) => {
// A new version will not be created if the changes occur
diff --git a/cypress/e2e/files_versions/version_creation.cy.ts b/cypress/e2e/files_versions/version_creation.cy.ts
index d3650f63939..a0441e96b29 100644
--- a/cypress/e2e/files_versions/version_creation.cy.ts
+++ b/cypress/e2e/files_versions/version_creation.cy.ts
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me>
- *
- * @author Louis Chmn <louis@chmn.me>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { openVersionsPanel, uploadThreeVersions } from './filesVersionsUtils'
@@ -47,4 +30,18 @@ describe('Versions creation', () => {
cy.get('[data-files-versions-version]').eq(2).contains('Initial version')
})
})
+
+ it('See yourself as version author', () => {
+ cy.visit('/apps/files')
+ openVersionsPanel(randomFileName)
+
+ cy.findByRole('tabpanel', { name: 'Versions' })
+ .findByRole('list', { name: 'File versions' })
+ .findAllByRole('listitem')
+ .should('have.length', 3)
+ .first()
+ .find('[data-cy-files-version-author-name]')
+ .should('exist')
+ .and('contain.text', 'You')
+ })
})
diff --git a/cypress/e2e/files_versions/version_cross_share_move_and_copy.cy.ts b/cypress/e2e/files_versions/version_cross_share_move_and_copy.cy.ts
index 9750c2bb20c..8c673b13d4c 100644
--- a/cypress/e2e/files_versions/version_cross_share_move_and_copy.cy.ts
+++ b/cypress/e2e/files_versions/version_cross_share_move_and_copy.cy.ts
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2024 Louis Chmn <louis@chmn.me>
- *
- * @author Louis Chmn <louis@chmn.me>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { assertVersionContent, openVersionsPanel, setupTestSharedFileFromUser, uploadThreeVersions, nameVersion } from './filesVersionsUtils'
diff --git a/cypress/e2e/files_versions/version_deletion.cy.ts b/cypress/e2e/files_versions/version_deletion.cy.ts
index 1e90c79fafa..b49aa872639 100644
--- a/cypress/e2e/files_versions/version_deletion.cy.ts
+++ b/cypress/e2e/files_versions/version_deletion.cy.ts
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2024 Louis Chmn <louis@chmn.me>
- *
- * @author Louis Chmn <louis@chmn.me>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { User } from '@nextcloud/cypress'
@@ -76,7 +59,6 @@ describe('Versions restoration', () => {
})
it('Does not work without delete permission through direct API access', () => {
- let hostname: string
let fileId: string|undefined
let versionId: string|undefined
@@ -85,24 +67,30 @@ describe('Versions restoration', () => {
navigateToFolder(folderName)
openVersionsPanel(randomFilePath)
- cy.url().then(url => { hostname = new URL(url).hostname })
- getRowForFile(randomFileName).invoke('attr', 'data-cy-files-list-row-fileid').then(_fileId => { fileId = _fileId })
- cy.get('[data-files-versions-version]').eq(1).invoke('attr', 'data-files-versions-version').then(_versionId => { versionId = _versionId })
+ getRowForFile(randomFileName)
+ .should('be.visible')
+ .invoke('attr', 'data-cy-files-list-row-fileid')
+ .then(($fileId) => { fileId = $fileId })
+ cy.get('[data-files-versions-version]')
+ .eq(1)
+ .invoke('attr', 'data-files-versions-version')
+ .then(($versionId) => { versionId = $versionId })
+
+ cy.logout()
cy.then(() => {
- cy.logout()
- cy.request({
+ const base = Cypress.config('baseUrl')!.replace(/\/index\.php\/?$/, '')
+ return cy.request({
method: 'DELETE',
+ url: `${base}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
auth: { user: recipient.userId, pass: recipient.password },
headers: {
cookie: '',
},
- url: `http://${hostname}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
failOnStatusCode: false,
})
- .then(({ status }) => {
- expect(status).to.equal(403)
- })
+ }).then(({ status }) => {
+ expect(status).to.equal(403)
})
})
})
diff --git a/cypress/e2e/files_versions/version_download.cy.ts b/cypress/e2e/files_versions/version_download.cy.ts
index 0e4301654f0..548cb86a207 100644
--- a/cypress/e2e/files_versions/version_download.cy.ts
+++ b/cypress/e2e/files_versions/version_download.cy.ts
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me>
- *
- * @author Louis Chmn <louis@chmn.me>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { assertVersionContent, doesNotHaveAction, openVersionsPanel, setupTestSharedFileFromUser, uploadThreeVersions } from './filesVersionsUtils'
@@ -31,6 +14,7 @@ describe('Versions download', () => {
before(() => {
randomFileName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + '.txt'
+ cy.runOccCommand('config:app:set --value no core shareapi_allow_view_without_download')
cy.createRandomUser()
.then((_user) => {
user = _user
@@ -41,6 +25,10 @@ describe('Versions download', () => {
})
})
+ after(() => {
+ cy.runOccCommand('config:app:delete core shareapi_allow_view_without_download')
+ })
+
it('Download versions and assert their content', () => {
assertVersionContent(0, 'v3')
assertVersionContent(1, 'v2')
@@ -69,31 +57,36 @@ describe('Versions download', () => {
})
it('Does not work without download permission through direct API access', () => {
- let hostname: string
let fileId: string|undefined
let versionId: string|undefined
setupTestSharedFileFromUser(user, randomFileName, { download: false })
- .then(recipient => {
+ .then((recipient) => {
openVersionsPanel(randomFileName)
- cy.url().then(url => { hostname = new URL(url).hostname })
- getRowForFile(randomFileName).invoke('attr', 'data-cy-files-list-row-fileid').then(_fileId => { fileId = _fileId })
- cy.get('[data-files-versions-version]').eq(1).invoke('attr', 'data-files-versions-version').then(_versionId => { versionId = _versionId })
+ getRowForFile(randomFileName)
+ .should('be.visible')
+ .invoke('attr', 'data-cy-files-list-row-fileid')
+ .then(($fileId) => { fileId = $fileId })
+
+ cy.get('[data-files-versions-version]')
+ .eq(1)
+ .invoke('attr', 'data-files-versions-version')
+ .then(($versionId) => { versionId = $versionId })
+ cy.logout()
cy.then(() => {
- cy.logout()
- cy.request({
+ const base = Cypress.config('baseUrl')!.replace(/\/index\.php\/?$/, '')
+ return cy.request({
+ url: `${base}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
auth: { user: recipient.userId, pass: recipient.password },
headers: {
cookie: '',
},
- url: `http://${hostname}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
failOnStatusCode: false,
})
- .then(({ status }) => {
- expect(status).to.equal(403)
- })
+ }).then(({ status }) => {
+ expect(status).to.equal(403)
})
})
})
diff --git a/cypress/e2e/files_versions/version_expiration.cy.ts b/cypress/e2e/files_versions/version_expiration.cy.ts
index 1c1c6fc70ae..118ac01532f 100644
--- a/cypress/e2e/files_versions/version_expiration.cy.ts
+++ b/cypress/e2e/files_versions/version_expiration.cy.ts
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me>
- *
- * @author Louis Chmn <louis@chmn.me>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { assertVersionContent, nameVersion, openVersionsPanel, uploadThreeVersions } from './filesVersionsUtils'
@@ -38,7 +21,7 @@ describe('Versions expiration', () => {
})
it('Expire all versions', () => {
- cy.runOccCommand('config:system:set versions_retention_obligation --value "0, 0"')
+ cy.runOccCommand('config:system:set versions_retention_obligation --value \'0, 0\'')
cy.runOccCommand('versions:expire')
cy.runOccCommand('config:system:set versions_retention_obligation --value auto')
cy.visit('/apps/files')
@@ -55,7 +38,7 @@ describe('Versions expiration', () => {
it('Expire versions v2', () => {
nameVersion(2, 'v1')
- cy.runOccCommand('config:system:set versions_retention_obligation --value "0, 0"')
+ cy.runOccCommand('config:system:set versions_retention_obligation --value \'0, 0\'')
cy.runOccCommand('versions:expire')
cy.runOccCommand('config:system:set versions_retention_obligation --value auto')
cy.visit('/apps/files')
diff --git a/cypress/e2e/files_versions/version_naming.cy.ts b/cypress/e2e/files_versions/version_naming.cy.ts
index a2f0514dfa0..ff299c53227 100644
--- a/cypress/e2e/files_versions/version_naming.cy.ts
+++ b/cypress/e2e/files_versions/version_naming.cy.ts
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me>
- *
- * @author Louis Chmn <louis@chmn.me>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { User } from '@nextcloud/cypress'
@@ -86,10 +69,17 @@ describe('Versions naming', () => {
})
context('without edit permission', () => {
- it('Does not show action', () => {
+ let recipient: User
+
+ beforeEach(() => {
setupTestSharedFileFromUser(user, randomFileName, { update: false })
- openVersionsPanel(randomFileName)
+ .then(($recipient) => {
+ recipient = $recipient
+ openVersionsPanel(randomFileName)
+ })
+ })
+ it('Does not show action', () => {
cy.get('[data-files-versions-version]').eq(0).find('.action-item__menutoggle').should('not.exist')
cy.get('[data-files-versions-version]').eq(0).get('[data-cy-version-action="label"]').should('not.exist')
@@ -98,45 +88,45 @@ describe('Versions naming', () => {
})
it('Does not work without update permission through direct API access', () => {
- let hostname: string
let fileId: string|undefined
let versionId: string|undefined
- setupTestSharedFileFromUser(user, randomFileName, { update: false })
- .then(recipient => {
- openVersionsPanel(randomFileName)
-
- cy.url().then(url => { hostname = new URL(url).hostname })
- getRowForFile(randomFileName).invoke('attr', 'data-cy-files-list-row-fileid').then(_fileId => { fileId = _fileId })
- cy.get('[data-files-versions-version]').eq(1).invoke('attr', 'data-files-versions-version').then(_versionId => { versionId = _versionId })
-
- cy.then(() => {
- cy.logout()
- cy.request({
- method: 'PROPPATCH',
- auth: { user: recipient.userId, pass: recipient.password },
- headers: {
- cookie: '',
- },
- body: `<?xml version="1.0"?>
- <d:propertyupdate xmlns:d="DAV:"
- xmlns:oc="http://owncloud.org/ns"
- xmlns:nc="http://nextcloud.org/ns"
- xmlns:ocs="http://open-collaboration-services.org/ns">
- <d:set>
- <d:prop>
- <nc:version-label>not authorized labeling</nc:version-label>
- </d:prop>
- </d:set>
- </d:propertyupdate>`,
- url: `http://${hostname}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
- failOnStatusCode: false,
- })
- .then(({ status }) => {
- expect(status).to.equal(403)
- })
- })
+ getRowForFile(randomFileName)
+ .should('be.visible')
+ .invoke('attr', 'data-cy-files-list-row-fileid')
+ .then(($fileId) => { fileId = $fileId })
+
+ cy.get('[data-files-versions-version]')
+ .eq(1)
+ .invoke('attr', 'data-files-versions-version')
+ .then(($versionId) => { versionId = $versionId })
+
+ cy.logout()
+ cy.then(() => {
+ const base = Cypress.config('baseUrl')!.replace(/index\.php\/?/, '')
+ return cy.request({
+ method: 'PROPPATCH',
+ url: `${base}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
+ auth: { user: recipient.userId, pass: recipient.password },
+ headers: {
+ cookie: '',
+ },
+ body: `<?xml version="1.0"?>
+ <d:propertyupdate xmlns:d="DAV:"
+ xmlns:oc="http://owncloud.org/ns"
+ xmlns:nc="http://nextcloud.org/ns"
+ xmlns:ocs="http://open-collaboration-services.org/ns">
+ <d:set>
+ <d:prop>
+ <nc:version-label>not authorized labeling</nc:version-label>
+ </d:prop>
+ </d:set>
+ </d:propertyupdate>`,
+ failOnStatusCode: false,
})
+ }).then(({ status }) => {
+ expect(status).to.equal(403)
+ })
})
})
})
diff --git a/cypress/e2e/files_versions/version_restoration.cy.ts b/cypress/e2e/files_versions/version_restoration.cy.ts
index 72295003bba..34360808f61 100644
--- a/cypress/e2e/files_versions/version_restoration.cy.ts
+++ b/cypress/e2e/files_versions/version_restoration.cy.ts
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me>
- *
- * @author Louis Chmn <louis@chmn.me>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { User } from '@nextcloud/cypress'
@@ -94,33 +77,38 @@ describe('Versions restoration', () => {
})
it('Does not work without update permission through direct API access', () => {
- let hostname: string
let fileId: string|undefined
let versionId: string|undefined
setupTestSharedFileFromUser(user, randomFileName, { update: false })
- .then(recipient => {
+ .then((recipient) => {
openVersionsPanel(randomFileName)
- cy.url().then(url => { hostname = new URL(url).hostname })
- getRowForFile(randomFileName).invoke('attr', 'data-cy-files-list-row-fileid').then(_fileId => { fileId = _fileId })
- cy.get('[data-files-versions-version]').eq(1).invoke('attr', 'data-files-versions-version').then(_versionId => { versionId = _versionId })
+ getRowForFile(randomFileName)
+ .should('be.visible')
+ .invoke('attr', 'data-cy-files-list-row-fileid')
+ .then(($fileId) => { fileId = $fileId })
+ cy.get('[data-files-versions-version]')
+ .eq(1)
+ .invoke('attr', 'data-files-versions-version')
+ .then(($versionId) => { versionId = $versionId })
+
+ cy.logout()
cy.then(() => {
- cy.logout()
- cy.request({
+ const base = Cypress.config('baseUrl')!.replace(/\/index\.php\/?$/, '')
+ return cy.request({
method: 'MOVE',
+ url: `${base}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
auth: { user: recipient.userId, pass: recipient.password },
headers: {
cookie: '',
- Destination: `http://${hostname}/remote.php/dav/versions/${recipient.userId}/restore/target`,
+ Destination: `${base}}/remote.php/dav/versions/${recipient.userId}/restore/target`,
},
- url: `http://${hostname}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
failOnStatusCode: false,
})
- .then(({ status }) => {
- expect(status).to.equal(403)
- })
+ }).then(({ status }) => {
+ expect(status).to.equal(403)
})
})
})
diff --git a/cypress/e2e/files_versions/version_sharing.cy.ts b/cypress/e2e/files_versions/version_sharing.cy.ts
new file mode 100644
index 00000000000..e978cb42fd9
--- /dev/null
+++ b/cypress/e2e/files_versions/version_sharing.cy.ts
@@ -0,0 +1,46 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { User } from '@nextcloud/cypress'
+import { openVersionsPanel, setupTestSharedFileFromUser, uploadThreeVersions } from './filesVersionsUtils.ts'
+import { navigateToFolder, triggerActionForFile } from '../files/FilesUtils.ts'
+
+describe('Versions on shares', () => {
+ const randomSharedFolderName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10)
+ const randomFileName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + '.txt'
+ const randomFilePath = `${randomSharedFolderName}/${randomFileName}`
+ let alice: User
+ let bob: User
+
+ before(() => {
+ cy.createRandomUser()
+ .then((user) => {
+ alice = user
+ })
+ .then(() => {
+ cy.mkdir(alice, `/${randomSharedFolderName}`)
+ return setupTestSharedFileFromUser(alice, randomSharedFolderName, {})
+ })
+ .then((user) => { bob = user })
+ .then(() => uploadThreeVersions(alice, randomFilePath))
+ })
+
+ it('See sharees display name as author', () => {
+ cy.login(bob)
+ cy.visit('/apps/files')
+
+ navigateToFolder(randomSharedFolderName)
+
+ triggerActionForFile(randomFileName, 'details')
+ cy.findByRole('tab', { name: 'Versions' }).click()
+
+ cy.findByRole('tabpanel', { name: 'Versions' })
+ .findByRole('list', { name: 'File versions' })
+ .findAllByRole('listitem')
+ .first()
+ .find('[data-cy-files-version-author-name]')
+ .should('be.visible')
+ .and('contain.text', alice.userId)
+ })
+})
diff --git a/cypress/e2e/login/login-redirect.cy.ts b/cypress/e2e/login/login-redirect.cy.ts
new file mode 100644
index 00000000000..eb0710dcbcc
--- /dev/null
+++ b/cypress/e2e/login/login-redirect.cy.ts
@@ -0,0 +1,62 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * Test that when a session expires / the user logged out in another tab,
+ * the user gets redirected to the login on the next request.
+ */
+describe('Logout redirect ', { testIsolation: true }, () => {
+
+ let user
+
+ before(() => {
+ cy.createRandomUser()
+ .then(($user) => {
+ user = $user
+ })
+ })
+
+ it('Redirects to login if session timed out', () => {
+ // Login and see settings
+ cy.login(user)
+ cy.visit('/settings/user#profile')
+ cy.findByRole('checkbox', { name: /Enable profile/i })
+ .should('exist')
+
+ // clear session
+ cy.clearAllCookies()
+
+ // trigger an request
+ cy.findByRole('checkbox', { name: /Enable profile/i })
+ .click({ force: true })
+
+ // See that we are redirected
+ cy.url()
+ .should('match', /\/login/i)
+ .and('include', `?redirect_url=${encodeURIComponent('/index.php/settings/user#profile')}`)
+
+ cy.get('form[name="login"]').should('be.visible')
+ })
+
+ it('Redirect from login works', () => {
+ cy.logout()
+ // visit the login
+ cy.visit(`/login?redirect_url=${encodeURIComponent('/index.php/settings/user#profile')}`)
+
+ // see login
+ cy.get('form[name="login"]').should('be.visible')
+ cy.get('form[name="login"]').within(() => {
+ cy.get('input[name="user"]').type(user.userId)
+ cy.get('input[name="password"]').type(user.password)
+ cy.contains('button[data-login-form-submit]', 'Log in').click()
+ })
+
+ // see that we are correctly redirected
+ cy.url().should('include', '/index.php/settings/user#profile')
+ cy.findByRole('checkbox', { name: /Enable profile/i })
+ .should('exist')
+ })
+
+})
diff --git a/cypress/e2e/login/login.cy.ts b/cypress/e2e/login/login.cy.ts
index 39c9e213039..97e3b9a24bf 100644
--- a/cypress/e2e/login/login.cy.ts
+++ b/cypress/e2e/login/login.cy.ts
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @author Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { User } from '@nextcloud/cypress'
diff --git a/cypress/e2e/login/webauth.cy.ts b/cypress/e2e/login/webauth.cy.ts
new file mode 100644
index 00000000000..fb67ed7f21c
--- /dev/null
+++ b/cypress/e2e/login/webauth.cy.ts
@@ -0,0 +1,152 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from '@nextcloud/cypress'
+
+interface IChromeVirtualAuthenticator {
+ authenticatorId: string
+}
+
+/**
+ * Create a virtual authenticator using chrome debug protocol
+ */
+async function createAuthenticator(): Promise<IChromeVirtualAuthenticator> {
+ await Cypress.automation('remote:debugger:protocol', {
+ command: 'WebAuthn.enable',
+ })
+ const authenticator = await Cypress.automation('remote:debugger:protocol', {
+ command: 'WebAuthn.addVirtualAuthenticator',
+ params: {
+ options: {
+ protocol: 'ctap2',
+ ctap2Version: 'ctap2_1',
+ hasUserVerification: true,
+ transport: 'usb',
+ automaticPresenceSimulation: true,
+ isUserVerified: true,
+ },
+ },
+ })
+ return authenticator
+}
+
+/**
+ * Delete a virtual authenticator using chrome devbug protocol
+ *
+ * @param authenticator the authenticator object
+ */
+async function deleteAuthenticator(authenticator: IChromeVirtualAuthenticator) {
+ await Cypress.automation('remote:debugger:protocol', {
+ command: 'WebAuthn.removeVirtualAuthenticator',
+ params: {
+ ...authenticator,
+ },
+ })
+}
+
+describe('Login using WebAuthn', () => {
+ let authenticator: IChromeVirtualAuthenticator
+ let user: User
+
+ afterEach(() => {
+ cy.deleteUser(user)
+ .then(() => deleteAuthenticator(authenticator))
+ })
+
+ beforeEach(() => {
+ cy.createRandomUser()
+ .then(($user) => {
+ user = $user
+ cy.login(user)
+ })
+ .then(() => createAuthenticator())
+ .then(($authenticator) => {
+ authenticator = $authenticator
+ cy.log('Created virtual authenticator')
+ })
+ })
+
+ it('add and delete WebAuthn', () => {
+ cy.intercept('**/settings/api/personal/webauthn/registration').as('webauthn')
+ cy.visit('/settings/user/security')
+
+ cy.contains('[role="note"]', /No devices configured/i).should('be.visible')
+
+ cy.findByRole('button', { name: /Add WebAuthn device/i })
+ .should('be.visible')
+ .click()
+
+ cy.wait('@webauthn')
+
+ cy.findByRole('textbox', { name: /Device name/i })
+ .should('be.visible')
+ .type('test device{enter}')
+
+ cy.wait('@webauthn')
+
+ cy.contains('[role="note"]', /No devices configured/i).should('not.exist')
+
+ cy.findByRole('list', { name: /following devices are configured for your account/i })
+ .should('be.visible')
+ .contains('li', 'test device')
+ .should('be.visible')
+ .findByRole('button', { name: /Actions/i })
+ .click()
+
+ cy.findByRole('menuitem', { name: /Delete/i })
+ .should('be.visible')
+ .click()
+
+ cy.contains('[role="note"]', /No devices configured/i).should('be.visible')
+ cy.findByRole('list', { name: /following devices are configured for your account/i })
+ .should('not.exist')
+
+ cy.reload()
+ cy.contains('[role="note"]', /No devices configured/i).should('be.visible')
+ })
+
+ it('add WebAuthn and login', () => {
+ cy.intercept('GET', '**/settings/api/personal/webauthn/registration').as('webauthnSetupInit')
+ cy.intercept('POST', '**/settings/api/personal/webauthn/registration').as('webauthnSetupDone')
+ cy.intercept('POST', '**/login/webauthn/start').as('webauthnLogin')
+
+ cy.visit('/settings/user/security')
+
+ cy.findByRole('button', { name: /Add WebAuthn device/i })
+ .should('be.visible')
+ .click()
+ cy.wait('@webauthnSetupInit')
+
+ cy.findByRole('textbox', { name: /Device name/i })
+ .should('be.visible')
+ .type('test device{enter}')
+ cy.wait('@webauthnSetupDone')
+
+ cy.findByRole('list', { name: /following devices are configured for your account/i })
+ .should('be.visible')
+ .findByText('test device')
+ .should('be.visible')
+
+ cy.logout()
+ cy.visit('/login')
+
+ cy.findByRole('button', { name: /Log in with a device/i })
+ .should('be.visible')
+ .click()
+
+ cy.findByRole('form', { name: /Log in with a device/i })
+ .should('be.visible')
+ .findByRole('textbox', { name: /Login or email/i })
+ .should('be.visible')
+ .type(`{selectAll}${user.userId}`)
+
+ cy.findByRole('button', { name: /Log in/i })
+ .click()
+ cy.wait('@webauthnLogin')
+
+ // Then I see that the current page is the Files app
+ cy.url().should('match', /apps\/dashboard(\/|$)/)
+ })
+})
diff --git a/cypress/e2e/settings/access-levels.cy.ts b/cypress/e2e/settings/access-levels.cy.ts
index ac02d607de2..4bf0cbc1832 100644
--- a/cypress/e2e/settings/access-levels.cy.ts
+++ b/cypress/e2e/settings/access-levels.cy.ts
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @author Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { User } from '@nextcloud/cypress'
@@ -40,7 +23,9 @@ describe('Settings: Ensure only administrator can see the administration setting
// I open the settings menu
getNextcloudUserMenuToggle().click()
// I navigate to the settings panel
- getNextcloudUserMenu().find('#settings a').click()
+ getNextcloudUserMenu()
+ .findByRole('link', { name: /settings/i })
+ .click()
cy.url().should('match', /\/settings\/user$/)
cy.get('#app-navigation').should('be.visible').within(() => {
@@ -62,7 +47,9 @@ describe('Settings: Ensure only administrator can see the administration setting
// I open the settings menu
getNextcloudUserMenuToggle().click()
// I navigate to the settings panel
- getNextcloudUserMenu().find('#settings a').click()
+ getNextcloudUserMenu()
+ .findByRole('link', { name: /Personal settings/i })
+ .click()
cy.url().should('match', /\/settings\/user$/)
cy.get('#app-navigation').should('be.visible').within(() => {
diff --git a/cypress/e2e/settings/apps.cy.ts b/cypress/e2e/settings/apps.cy.ts
index c1ef24951a9..0df073271ef 100644
--- a/cypress/e2e/settings/apps.cy.ts
+++ b/cypress/e2e/settings/apps.cy.ts
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @author Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { User } from '@nextcloud/cypress'
@@ -34,8 +17,15 @@ describe('Settings: App management', { testIsolation: true }, () => {
// I am logged in as the admin
cy.login(admin)
+
+ // Intercept the apps list request
+ cy.intercept('GET', '*/settings/apps/list').as('fetchAppsList')
+
// I open the Apps management
cy.visit('/settings/apps/installed')
+
+ // Wait for the apps list to load
+ cy.wait('@fetchAppsList')
})
it('Can enable an installed app', () => {
@@ -133,7 +123,7 @@ describe('Settings: App management', { testIsolation: true }, () => {
cy.get('#app-category-your-bundles').find('.active').should('exist')
// I see the app bundles
cy.get('#apps-list').contains('tr', 'Enterprise bundle')
- cy.get('#apps-list').contains('tr', 'Education Edition')
+ cy.get('#apps-list').contains('tr', 'Education bundle')
// I see that the "Enterprise bundle" is disabled
cy.get('#apps-list').contains('tr', 'Enterprise bundle').contains('button', 'Download and enable all')
})
diff --git a/cypress/e2e/settings/personal-info.cy.ts b/cypress/e2e/settings/personal-info.cy.ts
index a7564d5125e..8d4b4bb606a 100644
--- a/cypress/e2e/settings/personal-info.cy.ts
+++ b/cypress/e2e/settings/personal-info.cy.ts
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @author Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { User } from '@nextcloud/cypress'
@@ -115,33 +98,55 @@ const checkSettingsVisibility = (property: string, defaultVisibility: Visibility
}) */
}
-const genericProperties = ['Location', 'X (formerly Twitter)', 'Fediverse']
+const genericProperties = [
+ ['Location', 'Berlin'],
+ ['X (formerly Twitter)', 'nextclouders'],
+ ['Fediverse', 'nextcloud@mastodon.xyz'],
+]
const nonfederatedProperties = ['Organisation', 'Role', 'Headline', 'About']
describe('Settings: Change personal information', { testIsolation: true }, () => {
+ let snapshot: string = ''
before(() => {
+ // make sure the fediverse check does not do http requests
+ cy.runOccCommand('config:system:set has_internet_connection --type bool --value false')
// ensure we can set locale and language
cy.runOccCommand('config:system:delete force_language')
cy.runOccCommand('config:system:delete force_locale')
+ cy.createRandomUser().then(($user) => {
+ user = $user
+ cy.modifyUser(user, 'language', 'en')
+ cy.modifyUser(user, 'locale', 'en_US')
+
+ // Make sure the user is logged in at least once
+ // before the snapshot is taken to speed up the tests
+ cy.login(user)
+ cy.visit('/settings/user')
+
+ cy.saveState().then(($snapshot) => {
+ snapshot = $snapshot
+ })
+ })
})
after(() => {
+ cy.runOccCommand('config:system:delete has_internet_connection')
+
cy.runOccCommand('config:system:set force_language --value en')
cy.runOccCommand('config:system:set force_locale --value en_US')
})
beforeEach(() => {
- cy.createRandomUser().then(($user) => {
- user = $user
- cy.modifyUser(user, 'language', 'en')
- cy.modifyUser(user, 'locale', 'en_US')
- cy.login($user)
- cy.visit('/settings/user')
- })
+ cy.login(user)
+ cy.visit('/settings/user')
cy.intercept('PUT', /ocs\/v2.php\/cloud\/users\//).as('submitSetting')
})
+ afterEach(() => {
+ cy.restoreState(snapshot)
+ })
+
it('Can dis- and enable the profile', () => {
cy.visit(`/u/${user.userId}`)
cy.contains('h2', user.userId).should('be.visible')
@@ -149,6 +154,7 @@ describe('Settings: Change personal information', { testIsolation: true }, () =>
cy.visit('/settings/user')
cy.contains('Enable profile').click()
handlePasswordConfirmation(user.password)
+ cy.wait('@submitSetting')
cy.visit(`/u/${user.userId}`, { failOnStatusCode: false })
cy.contains('h2', 'Profile not found').should('be.visible')
@@ -156,6 +162,7 @@ describe('Settings: Change personal information', { testIsolation: true }, () =>
cy.visit('/settings/user')
cy.contains('Enable profile').click()
handlePasswordConfirmation(user.password)
+ cy.wait('@submitSetting')
cy.visit(`/u/${user.userId}`, { failOnStatusCode: false })
cy.contains('h2', user.userId).should('be.visible')
@@ -329,6 +336,57 @@ describe('Settings: Change personal information', { testIsolation: true }, () =>
cy.get('a[href="tel:+498972101099701"]').should('be.visible')
})
+ it('Can set phone number with phone region', () => {
+ cy.contains('label', 'Phone number').scrollIntoView()
+ inputForLabel('Phone number').type('{selectAll}0 40 428990')
+ inputForLabel('Phone number').should('have.attr', 'class').and('contain', '--error')
+
+ cy.runOccCommand('config:system:set default_phone_region --value DE')
+ cy.reload()
+
+ cy.contains('label', 'Phone number').scrollIntoView()
+ inputForLabel('Phone number').type('{selectAll}0 40 428990')
+ handlePasswordConfirmation(user.password)
+
+ cy.wait('@submitSetting')
+ cy.reload()
+ inputForLabel('Phone number').should('have.value', '+4940428990')
+ })
+
+ it('Can reset phone number', () => {
+ cy.contains('label', 'Phone number').scrollIntoView()
+ inputForLabel('Phone number').type('{selectAll}+49 40 428990')
+ handlePasswordConfirmation(user.password)
+
+ cy.wait('@submitSetting')
+ cy.reload()
+ inputForLabel('Phone number').should('have.value', '+4940428990')
+
+ inputForLabel('Phone number').clear()
+ handlePasswordConfirmation(user.password)
+
+ cy.wait('@submitSetting')
+ cy.reload()
+ inputForLabel('Phone number').should('have.value', '')
+ })
+
+ it('Can reset social media property', () => {
+ cy.contains('label', 'Fediverse').scrollIntoView()
+ inputForLabel('Fediverse').type('{selectAll}@nextcloud@mastodon.social')
+ handlePasswordConfirmation(user.password)
+
+ cy.wait('@submitSetting')
+ cy.reload()
+ inputForLabel('Fediverse').should('have.value', 'nextcloud@mastodon.social')
+
+ inputForLabel('Fediverse').clear()
+ handlePasswordConfirmation(user.password)
+
+ cy.wait('@submitSetting')
+ cy.reload()
+ inputForLabel('Fediverse').should('have.value', '')
+ })
+
it('Can set Website and change its visibility', () => {
cy.contains('label', 'Website').scrollIntoView()
// Check invalid input
@@ -350,22 +408,21 @@ describe('Settings: Change personal information', { testIsolation: true }, () =>
})
// Check generic properties that allow any visibility and any value
- genericProperties.forEach((property) => {
+ genericProperties.forEach(([property, value]) => {
it(`Can set ${property} and change its visibility`, () => {
- const uniqueValue = `${property.toUpperCase()} ${property.toLowerCase()}`
cy.contains('label', property).scrollIntoView()
- inputForLabel(property).type(uniqueValue)
+ inputForLabel(property).type(value)
handlePasswordConfirmation(user.password)
cy.wait('@submitSetting')
cy.reload()
- inputForLabel(property).should('have.value', uniqueValue)
+ inputForLabel(property).should('have.value', value)
checkSettingsVisibility(property)
// check it is visible on the profile
cy.visit(`/u/${user.userId}`)
- cy.contains(uniqueValue).should('be.visible')
+ cy.contains(value).should('be.visible')
})
})
diff --git a/cypress/e2e/settings/users-group-admin.cy.ts b/cypress/e2e/settings/users-group-admin.cy.ts
new file mode 100644
index 00000000000..5b5dcfd33a8
--- /dev/null
+++ b/cypress/e2e/settings/users-group-admin.cy.ts
@@ -0,0 +1,186 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+/// <reference types="cypress-if" />
+import { User } from '@nextcloud/cypress'
+import { getUserListRow, handlePasswordConfirmation } from './usersUtils'
+// eslint-disable-next-line n/no-extraneous-import
+import randomString from 'crypto-random-string'
+
+const admin = new User('admin', 'admin')
+const john = new User('john', '123456')
+
+/**
+ * Make a user subadmin of a group.
+ *
+ * @param user - The user to make subadmin
+ * @param group - The group the user should be subadmin of
+ */
+function makeSubAdmin(user: User, group: string): void {
+ cy.request({
+ url: `${Cypress.config('baseUrl')!.replace('/index.php', '')}/ocs/v2.php/cloud/users/${user.userId}/subadmins`,
+ method: 'POST',
+ auth: {
+ user: admin.userId,
+ password: admin.userId,
+ },
+ headers: {
+ 'OCS-ApiRequest': 'true',
+ },
+ body: {
+ groupid: group,
+ },
+ })
+}
+
+describe('Settings: Create accounts as a group admin', function() {
+
+ let subadmin: User
+ let group: string
+
+ beforeEach(() => {
+ group = randomString(7)
+ cy.deleteUser(john)
+ cy.createRandomUser().then((user) => {
+ subadmin = user
+ cy.runOccCommand(`group:add '${group}'`)
+ cy.runOccCommand(`group:adduser '${group}' '${subadmin.userId}'`)
+ makeSubAdmin(subadmin, group)
+ })
+ })
+
+ it('Can create a user with prefilled single group', () => {
+ cy.login(subadmin)
+ // open the User settings
+ cy.visit('/settings/users')
+
+ // open the New user modal
+ cy.get('button#new-user-button').click()
+
+ cy.get('form[data-test="form"]').within(() => {
+ // see that the correct group is preselected
+ cy.contains('[data-test="groups"] .vs__selected', group).should('be.visible')
+ // see that the username is ""
+ cy.get('input[data-test="username"]').should('exist').and('have.value', '')
+ // set the username to john
+ cy.get('input[data-test="username"]').type(john.userId)
+ // see that the username is john
+ cy.get('input[data-test="username"]').should('have.value', john.userId)
+ // see that the password is ""
+ cy.get('input[type="password"]').should('exist').and('have.value', '')
+ // set the password to 123456
+ cy.get('input[type="password"]').type(john.password)
+ // see that the password is 123456
+ cy.get('input[type="password"]').should('have.value', john.password)
+ })
+
+ cy.get('form[data-test="form"]').parents('[role="dialog"]').within(() => {
+ // submit the new user form
+ cy.get('button[type="submit"]').click({ force: true })
+ })
+
+ // Make sure no confirmation modal is shown
+ handlePasswordConfirmation(admin.password)
+
+ // see that the created user is in the list
+ getUserListRow(john.userId)
+ // see that the list of users contains the user john
+ .contains(john.userId).should('exist')
+ })
+
+ it('Can create a new user when member of multiple groups', () => {
+ const group2 = randomString(7)
+ cy.runOccCommand(`group:add '${group2}'`)
+ cy.runOccCommand(`group:adduser '${group2}' '${subadmin.userId}'`)
+ makeSubAdmin(subadmin, group2)
+
+ cy.login(subadmin)
+ // open the User settings
+ cy.visit('/settings/users')
+
+ // open the New user modal
+ cy.get('button#new-user-button').click()
+
+ cy.get('form[data-test="form"]').within(() => {
+ // see that no group is pre-selected
+ cy.get('[data-test="groups"] .vs__selected').should('not.exist')
+ // see both groups are available
+ cy.findByRole('combobox', { name: /member of the following groups/i })
+ .should('be.visible')
+ .click()
+ // can select both groups
+ cy.document().its('body')
+ .findByRole('listbox', { name: 'Options' })
+ .should('be.visible')
+ .as('options')
+ .findAllByRole('option')
+ .should('have.length', 2)
+ .get('@options')
+ .findByRole('option', { name: group })
+ .should('be.visible')
+ .get('@options')
+ .findByRole('option', { name: group2 })
+ .should('be.visible')
+ .click()
+ // see group is selected
+ cy.contains('[data-test="groups"] .vs__selected', group2).should('be.visible')
+
+ // see that the username is ""
+ cy.get('input[data-test="username"]').should('exist').and('have.value', '')
+ // set the username to john
+ cy.get('input[data-test="username"]').type(john.userId)
+ // see that the username is john
+ cy.get('input[data-test="username"]').should('have.value', john.userId)
+ // see that the password is ""
+ cy.get('input[type="password"]').should('exist').and('have.value', '')
+ // set the password to 123456
+ cy.get('input[type="password"]').type(john.password)
+ // see that the password is 123456
+ cy.get('input[type="password"]').should('have.value', john.password)
+ })
+
+ cy.get('form[data-test="form"]').parents('[role="dialog"]').within(() => {
+ // submit the new user form
+ cy.get('button[type="submit"]').click({ force: true })
+ })
+
+ // Make sure no confirmation modal is shown
+ handlePasswordConfirmation(admin.password)
+
+ // see that the created user is in the list
+ getUserListRow(john.userId)
+ // see that the list of users contains the user john
+ .contains(john.userId).should('exist')
+ })
+
+ it('Only sees groups they are subadmin of', () => {
+ const group2 = randomString(7)
+ cy.runOccCommand(`group:add '${group2}'`)
+ cy.runOccCommand(`group:adduser '${group2}' '${subadmin.userId}'`)
+ // not a subadmin!
+
+ cy.login(subadmin)
+ // open the User settings
+ cy.visit('/settings/users')
+
+ // open the New user modal
+ cy.get('button#new-user-button').click()
+
+ cy.get('form[data-test="form"]').within(() => {
+ // see that the subadmin group is pre-selected
+ cy.contains('[data-test="groups"] .vs__selected', group).should('be.visible')
+ // see only the subadmin group is available
+ cy.findByRole('combobox', { name: /member of the following groups/i })
+ .should('be.visible')
+ .click()
+ // can select both groups
+ cy.document().its('body')
+ .findByRole('listbox', { name: 'Options' })
+ .should('be.visible')
+ .as('options')
+ .findAllByRole('option')
+ .should('have.length', 1)
+ })
+ })
+})
diff --git a/cypress/e2e/settings/users.cy.ts b/cypress/e2e/settings/users.cy.ts
index c90afc8866e..5b8726e92ca 100644
--- a/cypress/e2e/settings/users.cy.ts
+++ b/cypress/e2e/settings/users.cy.ts
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @author Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
/// <reference types="cypress-if" />
import { User } from '@nextcloud/cypress'
@@ -56,6 +39,9 @@ describe('Settings: Create and delete accounts', function() {
cy.get('input[type="password"]').type(john.password)
// see that the password is 123456
cy.get('input[type="password"]').should('have.value', john.password)
+ })
+
+ cy.get('form[data-test="form"]').parents('[role="dialog"]').within(() => {
// submit the new user form
cy.get('button[type="submit"]').click({ force: true })
})
@@ -90,6 +76,9 @@ describe('Settings: Create and delete accounts', function() {
cy.get('input[type="password"]').should('exist').and('have.value', '')
cy.get('input[type="password"]').type(john.password)
cy.get('input[type="password"]').should('have.value', john.password)
+ })
+
+ cy.get('form[data-test="form"]').parents('[role="dialog"]').within(() => {
// submit the new user form
cy.get('button[type="submit"]').click({ force: true })
})
@@ -126,12 +115,13 @@ describe('Settings: Create and delete accounts', function() {
// The "Delete account" action in the actions menu is shown and clicked
cy.get('.action-item__popper .action').contains('Delete account').should('exist').click({ force: true })
- // And confirmation dialog accepted
- cy.get('.nc-generic-dialog button').contains(`Delete ${testUser.userId}`).click({ force: true })
// Make sure no confirmation modal is shown
handlePasswordConfirmation(admin.password)
+ // And confirmation dialog accepted
+ cy.get('.nc-generic-dialog button').contains(`Delete ${testUser.userId}`).click({ force: true })
+
// deleted clicked the user is not shown anymore
getUserListRow(testUser.userId).should('not.exist')
})
diff --git a/cypress/e2e/settings/usersUtils.ts b/cypress/e2e/settings/usersUtils.ts
index 56eff5e7d7d..7d8ea55d35b 100644
--- a/cypress/e2e/settings/usersUtils.ts
+++ b/cypress/e2e/settings/usersUtils.ts
@@ -1,23 +1,6 @@
/**
- * @copyright 2023 Christopher Ng <chrng8@gmail.com>
- *
- * @author Christopher Ng <chrng8@gmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { User } from '@nextcloud/cypress'
diff --git a/cypress/e2e/settings/users_columns.cy.ts b/cypress/e2e/settings/users_columns.cy.ts
index 5f2a293b824..0afbf14e773 100644
--- a/cypress/e2e/settings/users_columns.cy.ts
+++ b/cypress/e2e/settings/users_columns.cy.ts
@@ -1,23 +1,6 @@
/**
- * @copyright 2023 Christopher Ng <chrng8@gmail.com>
- *
- * @author Christopher Ng <chrng8@gmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { User } from '@nextcloud/cypress'
diff --git a/cypress/e2e/settings/users_disable.cy.ts b/cypress/e2e/settings/users_disable.cy.ts
index dd555c64f91..6195d43e211 100644
--- a/cypress/e2e/settings/users_disable.cy.ts
+++ b/cypress/e2e/settings/users_disable.cy.ts
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @author Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { User } from '@nextcloud/cypress'
diff --git a/cypress/e2e/settings/users_groups.cy.ts b/cypress/e2e/settings/users_groups.cy.ts
index fd56c558b4f..8d84ddc6bb4 100644
--- a/cypress/e2e/settings/users_groups.cy.ts
+++ b/cypress/e2e/settings/users_groups.cy.ts
@@ -1,23 +1,6 @@
/**
- * @copyright 2023 Christopher Ng <chrng8@gmail.com>
- *
- * @author Christopher Ng <chrng8@gmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { User } from '@nextcloud/cypress'
@@ -72,15 +55,17 @@ describe('Settings: Assign user to a group', { testIsolation: false }, () => {
})
cy.runOccCommand(`group:add '${groupName}'`)
cy.login(admin)
+ cy.intercept('GET', '**/ocs/v2.php/cloud/groups/details?search=&offset=*&limit=*').as('loadGroups')
cy.visit('/settings/users')
+ cy.wait('@loadGroups')
})
it('see that the group is in the list', () => {
- cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').contains('li', groupName).should('exist')
- cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').contains('li', groupName).within(() => {
- cy.get('.counter-bubble__counter')
- .should('not.exist') // is hidden when 0
- })
+ cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
+ .should('exist')
+ cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
+ .find('.counter-bubble__counter')
+ .should('not.exist') // is hidden when 0
})
it('see that the user is in the list', () => {
@@ -118,8 +103,7 @@ describe('Settings: Assign user to a group', { testIsolation: false }, () => {
it('see the group was successfully assigned', () => {
// see a new memeber
- cy.get('ul[data-cy-users-settings-navigation-groups="custom"]')
- .contains('li', groupName)
+ cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
.find('.counter-bubble__counter')
.should('contain', '1')
})
@@ -138,23 +122,25 @@ describe('Settings: Delete an empty group', { testIsolation: false }, () => {
before(() => {
cy.runOccCommand(`group:add '${groupName}'`)
cy.login(admin)
+ cy.intercept('GET', '**/ocs/v2.php/cloud/groups/details?search=&offset=*&limit=*').as('loadGroups')
cy.visit('/settings/users')
+ cy.wait('@loadGroups')
})
it('see that the group is in the list', () => {
- cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => {
- // see that the list of groups contains the group foo
- cy.contains(groupName).should('exist').scrollIntoView()
- // open the actions menu for the group
- cy.contains('li', groupName).within(() => {
- cy.get('button.action-item__menutoggle').click({ force: true })
- })
- })
+ // see that the list of groups contains the group foo
+ cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
+ .should('exist')
+ .scrollIntoView()
+ // open the actions menu for the group
+ cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
+ .find('button.action-item__menutoggle')
+ .click({ force: true })
})
it('can delete the group', () => {
- // The "Remove group" action in the actions menu is shown and clicked
- cy.get('.action-item__popper button').contains('Remove group').should('exist').click({ force: true })
+ // The "Delete group" action in the actions menu is shown and clicked
+ cy.get('.action-item__popper button').contains('Delete group').should('exist').click({ force: true })
// And confirmation dialog accepted
cy.get('.modal-container button').contains('Confirm').click({ force: true })
@@ -163,10 +149,9 @@ describe('Settings: Delete an empty group', { testIsolation: false }, () => {
})
it('deleted group is not shown anymore', () => {
- cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => {
- // see that the list of groups does not contain the group
- cy.contains(groupName).should('not.exist')
- })
+ // see that the list of groups does not contain the group
+ cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
+ .should('not.exist')
// and also not in database
cy.runOccCommand('group:list --output=json').then(($response) => {
const groups: string[] = Object.keys(JSON.parse($response.stdout))
@@ -186,24 +171,27 @@ describe('Settings: Delete a non empty group', () => {
cy.runOccCommand(`group:addUser '${groupName}' '${$user.userId}'`)
})
cy.login(admin)
+ cy.intercept('GET', '**/ocs/v2.php/cloud/groups/details?search=&offset=*&limit=*').as('loadGroups')
cy.visit('/settings/users')
+ cy.wait('@loadGroups')
})
after(() => cy.deleteUser(testUser))
it('see that the group is in the list', () => {
// see that the list of groups contains the group
- cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').contains('li', groupName).should('exist').scrollIntoView()
+ cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
+ .should('exist')
+ .scrollIntoView()
})
it('can delete the group', () => {
// open the menu
- cy.get('ul[data-cy-users-settings-navigation-groups="custom"]')
- .contains('li', groupName)
+ cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
.find('button.action-item__menutoggle')
.click({ force: true })
- // The "Remove group" action in the actions menu is shown and clicked
- cy.get('.action-item__popper button').contains('Remove group').should('exist').click({ force: true })
+ // The "Delete group" action in the actions menu is shown and clicked
+ cy.get('.action-item__popper button').contains('Delete group').should('exist').click({ force: true })
// And confirmation dialog accepted
cy.get('.modal-container button').contains('Confirm').click({ force: true })
@@ -212,10 +200,9 @@ describe('Settings: Delete a non empty group', () => {
})
it('deleted group is not shown anymore', () => {
- cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => {
- // see that the list of groups does not contain the group foo
- cy.contains(groupName).should('not.exist')
- })
+ // see that the list of groups does not contain the group foo
+ cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
+ .should('not.exist')
// and also not in database
cy.runOccCommand('group:list --output=json').then(($response) => {
const groups: string[] = Object.keys(JSON.parse($response.stdout))
@@ -224,13 +211,13 @@ describe('Settings: Delete a non empty group', () => {
})
})
-describe.only('Settings: Sort groups in the UI', () => {
+describe('Settings: Sort groups in the UI', () => {
before(() => {
// Clear state
cy.runOccCommand('group:list --output json').then((output) => {
const groups = Object.keys(JSON.parse(output.stdout)).filter((group) => group !== 'admin')
groups.forEach((group) => {
- cy.runOccCommand(`group:delete "${group}"`)
+ cy.runOccCommand(`group:delete '${group}'`)
})
})
@@ -238,7 +225,7 @@ describe.only('Settings: Sort groups in the UI', () => {
cy.runOccCommand('group:add A')
cy.runOccCommand('group:add B')
cy.createRandomUser().then((user) => {
- cy.runOccCommand(`group:adduser B "${user.userId}"`)
+ cy.runOccCommand(`group:adduser B '${user.userId}'`)
})
// Visit the settings as admin
diff --git a/cypress/e2e/settings/users_manager.cy.ts b/cypress/e2e/settings/users_manager.cy.ts
new file mode 100644
index 00000000000..b7596ddf0ce
--- /dev/null
+++ b/cypress/e2e/settings/users_manager.cy.ts
@@ -0,0 +1,121 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { User } from '@nextcloud/cypress'
+import { getUserListRow, handlePasswordConfirmation, toggleEditButton, waitLoading } from './usersUtils'
+import { clearState } from '../../support/commonUtils'
+
+const admin = new User('admin', 'admin')
+
+describe('Settings: User Manager Management', function() {
+ let user: User
+ let manager: User
+
+ beforeEach(function() {
+ clearState()
+ cy.createRandomUser().then(($user) => {
+ manager = $user
+ return cy.createRandomUser()
+ }).then(($user) => {
+ user = $user
+ cy.login(admin)
+ cy.intercept('PUT', `/ocs/v2.php/cloud/users/${user.userId}*`).as('updateUser')
+ })
+ })
+
+ it('Can assign and remove a manager through the UI', function() {
+ cy.visit('/settings/users')
+
+ toggleEditButton(user, true)
+
+ // Scroll to manager cell and wait for it to be visible
+ getUserListRow(user.userId)
+ .find('[data-cy-user-list-cell-manager]')
+ .scrollIntoView()
+ .should('be.visible')
+
+ // Assign a manager
+ getUserListRow(user.userId).find('[data-cy-user-list-cell-manager]').within(() => {
+ // Verify no manager is set initially
+ cy.get('.vs__selected').should('not.exist')
+
+ // Open the dropdown menu
+ cy.get('[role="combobox"]').click({ force: true })
+
+ // Wait for the dropdown to be visible and initialized
+ waitLoading('[data-cy-user-list-input-manager]')
+
+ // Type the manager's username to search
+ cy.get('input[type="search"]').type(manager.userId, { force: true })
+
+ // Wait for the search results to load
+ waitLoading('[data-cy-user-list-input-manager]')
+ })
+
+ // Now select the manager from the filtered results
+ // Since the dropdown is floating, we need to search globally
+ cy.get('.vs__dropdown-menu').find('li').contains('span', manager.userId).should('be.visible').click({ force: true })
+
+ // Handle password confirmation if needed
+ handlePasswordConfirmation(admin.password)
+
+ // Verify the manager is selected in the UI
+ cy.get('.vs__selected').should('exist').and('contain.text', manager.userId)
+
+ // Verify the PUT request was made to set the manager
+ cy.wait('@updateUser').then((interception) => {
+ // Verify the request URL and body
+ expect(interception.request.url).to.match(/\/cloud\/users\/.+/)
+ expect(interception.request.body).to.deep.equal({
+ key: 'manager',
+ value: manager.userId
+ })
+ expect(interception.response?.statusCode).to.equal(200)
+ })
+
+ // Wait for the save to complete
+ waitLoading('[data-cy-user-list-input-manager]')
+
+ // Verify the manager is set in the backend
+ cy.getUserData(user).then(($result) => {
+ expect($result.body).to.contain(`<manager>${manager.userId}</manager>`)
+ })
+
+ // Now remove the manager
+ getUserListRow(user.userId).find('[data-cy-user-list-cell-manager]').within(() => {
+ // Clear the manager selection
+ cy.get('.vs__clear').click({ force: true })
+
+ // Verify the manager is cleared in the UI
+ cy.get('.vs__selected').should('not.exist')
+
+ // Handle password confirmation if needed
+ handlePasswordConfirmation(admin.password)
+ })
+
+ // Verify the PUT request was made to clear the manager
+ cy.wait('@updateUser').then((interception) => {
+ // Verify the request URL and body
+ expect(interception.request.url).to.match(/\/cloud\/users\/.+/)
+ expect(interception.request.body).to.deep.equal({
+ key: 'manager',
+ value: '',
+ })
+ expect(interception.response?.statusCode).to.equal(200)
+ })
+
+ // Wait for the save to complete
+ waitLoading('[data-cy-user-list-input-manager]')
+
+ // Verify the manager is cleared in the backend
+ cy.getUserData(user).then(($result) => {
+ expect($result.body).to.not.contain(`<manager>${manager.userId}</manager>`)
+ expect($result.body).to.contain('<manager></manager>')
+ })
+
+ // Finish editing the user
+ toggleEditButton(user, false)
+ })
+})
diff --git a/cypress/e2e/settings/users_modify.cy.ts b/cypress/e2e/settings/users_modify.cy.ts
index b230fb998a5..749bded2e94 100644
--- a/cypress/e2e/settings/users_modify.cy.ts
+++ b/cypress/e2e/settings/users_modify.cy.ts
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @author Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { User } from '@nextcloud/cypress'
@@ -198,47 +181,6 @@ describe('Settings: Change user properties', function() {
})
})
- it('Can set manager of a user', function() {
- // create the manager
- let manager: User
- cy.createRandomUser().then(($user) => { manager = $user })
-
- // open the User settings as admin
- cy.login(admin)
- cy.visit('/settings/users')
-
- // toggle edit button into edit mode
- toggleEditButton(user, true)
-
- getUserListRow(user.userId)
- .find('[data-cy-user-list-cell-manager]')
- .scrollIntoView()
-
- getUserListRow(user.userId).find('[data-cy-user-list-cell-manager]').within(() => {
- // see that the user has no manager
- cy.get('.vs__selected').should('not.exist')
- // Open the dropdown menu
- cy.get('[role="combobox"]').click({ force: true })
- // select the manager
- cy.contains('li', manager.userId).click({ force: true })
-
- // Handle password confirmation on time out
- handlePasswordConfirmation(admin.password)
-
- // see that the user has a manager set
- cy.get('.vs__selected').should('exist').and('contain.text', manager.userId)
- })
-
- // see that the changes are loading
- waitLoading('[data-cy-user-list-input-manager]')
-
- // finish editing the user
- toggleEditButton(user, false)
-
- // validate the manager is set
- cy.getUserData(user).then(($result) => expect($result.body).to.contain(`<manager>${manager.userId}</manager>`))
- })
-
it('Can make user a subadmin of a group', function() {
// create a group
const groupName = 'userstestgroup'
@@ -256,6 +198,8 @@ describe('Settings: Change user properties', function() {
cy.get('.vs__selected').should('not.exist')
// Open the dropdown menu
cy.get('[role="combobox"]').click({ force: true })
+ // Search for the group
+ cy.get('[role="combobox"]').type('userstestgroup')
// select the group
cy.contains('li', groupName).click({ force: true })
diff --git a/cypress/e2e/systemtags/admin-settings.cy.ts b/cypress/e2e/systemtags/admin-settings.cy.ts
index 3c9a8b25cf4..ac85cf34d65 100644
--- a/cypress/e2e/systemtags/admin-settings.cy.ts
+++ b/cypress/e2e/systemtags/admin-settings.cy.ts
@@ -1,23 +1,6 @@
/**
- * @copyright 2023 Christopher Ng <chrng8@gmail.com>
- *
- * @author Christopher Ng <chrng8@gmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { User } from '@nextcloud/cypress'
diff --git a/cypress/e2e/systemtags/files-bulk-action.cy.ts b/cypress/e2e/systemtags/files-bulk-action.cy.ts
new file mode 100644
index 00000000000..7ed9ad7fa7b
--- /dev/null
+++ b/cypress/e2e/systemtags/files-bulk-action.cy.ts
@@ -0,0 +1,468 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from '@nextcloud/cypress'
+import { randomBytes } from 'crypto'
+import { getRowForFile, selectAllFiles, selectRowForFile, triggerSelectionAction } from '../files/FilesUtils'
+import { createShare } from '../files_sharing/FilesSharingUtils'
+
+let tags = {} as Record<string, number>
+const files = [
+ 'file1.txt',
+ 'file2.txt',
+ 'file3.txt',
+ 'file4.txt',
+ 'file5.txt',
+]
+
+function resetTags() {
+ tags = {}
+ for (const tag in [0, 1, 2, 3, 4]) {
+ tags[randomBytes(8).toString('base64').slice(0, 6)] = 0
+ }
+
+ // delete any existing tags
+ cy.runOccCommand('tag:list --output=json').then((output) => {
+ Object.keys(JSON.parse(output.stdout)).forEach((id) => {
+ cy.runOccCommand(`tag:delete ${id}`)
+ })
+ })
+
+ // create tags
+ Object.keys(tags).forEach((tag) => {
+ cy.runOccCommand(`tag:add ${tag} public --output=json`).then((output) => {
+ tags[tag] = JSON.parse(output.stdout).id as number
+ })
+ })
+ cy.log('Using tags', tags)
+}
+
+function expectInlineTagForFile(file: string, tags: string[]) {
+ getRowForFile(file)
+ .find('[data-systemtags-fileid]')
+ .findAllByRole('listitem')
+ .should('have.length', tags.length)
+ .each(tag => {
+ expect(tag.text()).to.be.oneOf(tags)
+ })
+}
+
+function triggerTagManagementDialogAction() {
+ cy.intercept('PROPFIND', '/remote.php/dav/systemtags/').as('getTagsList')
+ triggerSelectionAction('systemtags:bulk')
+ cy.wait('@getTagsList')
+ cy.get('[data-cy-systemtags-picker]').should('be.visible')
+}
+
+describe('Systemtags: Files bulk action', { testIsolation: false }, () => {
+ let user1: User
+ let user2: User
+
+ before(() => {
+ cy.createRandomUser().then((_user1) => {
+ user1 = _user1
+ cy.createRandomUser().then((_user2) => {
+ user2 = _user2
+ })
+
+ files.forEach((file) => {
+ cy.uploadContent(user1, new Blob([]), 'text/plain', '/' + file)
+ })
+ })
+
+ resetTags()
+ })
+
+ after(() => {
+ resetTags()
+ cy.runOccCommand('config:app:set systemtags restrict_creation_to_admin --value 0')
+ })
+
+ it('Can assign tag to selection', () => {
+ cy.login(user1)
+ cy.visit('/apps/files')
+
+ files.forEach((file) => {
+ getRowForFile(file).should('be.visible')
+ })
+ selectRowForFile('file2.txt')
+ selectRowForFile('file4.txt')
+
+ triggerTagManagementDialogAction()
+ cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5)
+ cy.get('[data-cy-systemtags-picker-tag-color]').should('have.length', 5)
+
+ cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData')
+ cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData')
+
+ const tag = Object.keys(tags)[3]
+ cy.get(`[data-cy-systemtags-picker-tag=${tags[tag]}]`).should('be.visible')
+ .findByRole('checkbox').click({ force: true })
+ cy.get('[data-cy-systemtags-picker-button-submit]').click()
+
+ cy.wait('@getTagData')
+ cy.wait('@assignTagData')
+ cy.get('[data-cy-systemtags-picker]').should('not.exist')
+
+ expectInlineTagForFile('file2.txt', [tag])
+ expectInlineTagForFile('file4.txt', [tag])
+ })
+
+ it('Can assign multiple tags to selection', () => {
+ cy.login(user1)
+ cy.visit('/apps/files')
+
+ files.forEach((file) => {
+ getRowForFile(file).should('be.visible')
+ })
+ selectAllFiles()
+
+ triggerTagManagementDialogAction()
+ cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5)
+ cy.get('[data-cy-systemtags-picker-tag-color]').should('have.length', 5)
+
+ cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData')
+ cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData')
+
+ const prevTag = Object.keys(tags)[3]
+ const tag1 = Object.keys(tags)[1]
+ const tag2 = Object.keys(tags)[2]
+ cy.get(`[data-cy-systemtags-picker-tag=${tags[tag1]}]`).should('be.visible')
+ .findByRole('checkbox').click({ force: true })
+ cy.get(`[data-cy-systemtags-picker-tag=${tags[tag2]}]`).should('be.visible')
+ .findByRole('checkbox').click({ force: true })
+ cy.get('[data-cy-systemtags-picker-button-submit]').click()
+
+ cy.wait('@getTagData')
+ cy.wait('@assignTagData')
+ cy.get('@getTagData.all').should('have.length', 2)
+ cy.get('@assignTagData.all').should('have.length', 2)
+ cy.get('[data-cy-systemtags-picker]').should('not.exist')
+
+ expectInlineTagForFile('file1.txt', [tag1, tag2])
+ expectInlineTagForFile('file2.txt', [prevTag, tag1, tag2])
+ expectInlineTagForFile('file3.txt', [tag1, tag2])
+ expectInlineTagForFile('file4.txt', [prevTag, tag1, tag2])
+ expectInlineTagForFile('file5.txt', [tag1, tag2])
+ })
+
+ it('Can remove tag from selection', () => {
+ cy.login(user1)
+ cy.visit('/apps/files')
+
+ files.forEach((file) => {
+ getRowForFile(file).should('be.visible')
+ })
+ selectRowForFile('file1.txt')
+ selectRowForFile('file3.txt')
+ selectRowForFile('file4.txt')
+
+ triggerTagManagementDialogAction()
+ cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5)
+
+ cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData')
+ cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData')
+
+ const firstTag = Object.keys(tags)[3]
+ const tag1 = Object.keys(tags)[1]
+ const tag2 = Object.keys(tags)[2]
+ cy.get(`[data-cy-systemtags-picker-tag=${tags[tag2]}]`).should('be.visible')
+ .findByRole('checkbox').click({ force: true })
+ cy.get('[data-cy-systemtags-picker-button-submit]').click()
+
+ cy.wait('@getTagData')
+ cy.wait('@assignTagData')
+ cy.get('[data-cy-systemtags-picker]').should('not.exist')
+
+ expectInlineTagForFile('file1.txt', [tag1])
+ expectInlineTagForFile('file2.txt', [firstTag, tag1, tag2])
+ expectInlineTagForFile('file3.txt', [tag1])
+ expectInlineTagForFile('file4.txt', [firstTag, tag1])
+ expectInlineTagForFile('file5.txt', [tag1, tag2])
+
+ })
+
+ it('Can remove multiple tags from selection', () => {
+ cy.login(user1)
+ cy.visit('/apps/files')
+
+ files.forEach((file) => {
+ getRowForFile(file).should('be.visible')
+ })
+ selectAllFiles()
+
+ triggerTagManagementDialogAction()
+ cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5)
+
+ cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData')
+ cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData')
+
+ cy.get('[data-cy-systemtags-picker-tag] input:indeterminate').should('exist')
+ .click({ force: true, multiple: true })
+ // indeterminate became checked
+ cy.get('[data-cy-systemtags-picker-tag] input:checked').should('exist')
+ .click({ force: true, multiple: true })
+ // now all are unchecked
+ cy.get('[data-cy-systemtags-picker-button-submit]').click()
+
+ cy.wait('@getTagData')
+ cy.wait('@assignTagData')
+ cy.get('@getTagData.all').should('have.length', 3)
+ cy.get('@assignTagData.all').should('have.length', 3)
+ cy.get('[data-cy-systemtags-picker]').should('not.exist')
+
+ expectInlineTagForFile('file1.txt', [])
+ expectInlineTagForFile('file2.txt', [])
+ expectInlineTagForFile('file3.txt', [])
+ expectInlineTagForFile('file4.txt', [])
+ expectInlineTagForFile('file5.txt', [])
+ })
+
+ it('Can assign and remove multiple tags as a secondary user', () => {
+ // Create new users
+ cy.createRandomUser().then((_user1) => {
+ user1 = _user1
+ cy.createRandomUser().then((_user2) => {
+ user2 = _user2
+ })
+
+ files.forEach((file) => {
+ cy.uploadContent(user1, new Blob([]), 'text/plain', '/' + file)
+ })
+ })
+
+ cy.login(user1)
+ cy.visit('/apps/files')
+
+ files.forEach((file) => {
+ getRowForFile(file).should('be.visible')
+ })
+ selectAllFiles()
+
+ triggerTagManagementDialogAction()
+ cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5)
+
+ cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData1')
+ cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData1')
+
+ const tag1 = Object.keys(tags)[0]
+ const tag2 = Object.keys(tags)[3]
+ cy.get(`[data-cy-systemtags-picker-tag=${tags[tag1]}]`).should('be.visible')
+ .findByRole('checkbox').click({ force: true })
+ cy.get(`[data-cy-systemtags-picker-tag=${tags[tag2]}]`).should('be.visible')
+ .findByRole('checkbox').click({ force: true })
+ cy.get('[data-cy-systemtags-picker-button-submit]').click()
+
+ cy.wait('@getTagData1')
+ cy.wait('@assignTagData1')
+ cy.get('@getTagData1.all').should('have.length', 2)
+ cy.get('@assignTagData1.all').should('have.length', 2)
+ cy.get('[data-cy-systemtags-picker]').should('not.exist')
+
+ expectInlineTagForFile('file1.txt', [tag1, tag2])
+ expectInlineTagForFile('file2.txt', [tag1, tag2])
+ expectInlineTagForFile('file3.txt', [tag1, tag2])
+ expectInlineTagForFile('file4.txt', [tag1, tag2])
+ expectInlineTagForFile('file5.txt', [tag1, tag2])
+
+ createShare('file1.txt', user2.userId)
+ createShare('file3.txt', user2.userId)
+
+ cy.login(user2)
+ cy.visit('/apps/files')
+
+ getRowForFile('file1.txt').should('be.visible')
+ getRowForFile('file3.txt').should('be.visible')
+
+ expectInlineTagForFile('file1.txt', [tag1, tag2])
+ expectInlineTagForFile('file3.txt', [tag1, tag2])
+
+ selectRowForFile('file1.txt')
+ selectRowForFile('file3.txt')
+ triggerTagManagementDialogAction()
+ cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5)
+
+ cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData2')
+ cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData2')
+
+ cy.get(`[data-cy-systemtags-picker-tag=${tags[tag1]}]`).should('be.visible')
+ .findByRole('checkbox').click({ force: true })
+ cy.get(`[data-cy-systemtags-picker-tag=${tags[tag2]}]`).should('be.visible')
+ .findByRole('checkbox').click({ force: true })
+ cy.get('[data-cy-systemtags-picker-button-submit]').click()
+
+ cy.wait('@getTagData2')
+ cy.wait('@assignTagData2')
+ cy.get('@getTagData2.all').should('have.length', 2)
+ cy.get('@assignTagData2.all').should('have.length', 2)
+ cy.get('[data-cy-systemtags-picker]').should('not.exist')
+
+ expectInlineTagForFile('file1.txt', [])
+ expectInlineTagForFile('file3.txt', [])
+
+ cy.login(user1)
+ cy.visit('/apps/files')
+
+ expectInlineTagForFile('file1.txt', [])
+ expectInlineTagForFile('file3.txt', [])
+ })
+
+ it('Can create tag and assign files to it', () => {
+ cy.createRandomUser().then((user1) => {
+ files.forEach((file) => {
+ cy.uploadContent(user1, new Blob([]), 'text/plain', '/' + file)
+ })
+
+ cy.login(user1)
+ cy.visit('/apps/files')
+
+ files.forEach((file) => {
+ getRowForFile(file).should('be.visible')
+ })
+ selectAllFiles()
+
+ triggerTagManagementDialogAction()
+ cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5)
+
+ cy.intercept('POST', '/remote.php/dav/systemtags').as('createTag')
+ cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData')
+ cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData')
+
+ const newTag = randomBytes(8).toString('base64').slice(0, 6)
+ cy.get('[data-cy-systemtags-picker-input]').type(newTag)
+
+ cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 0)
+ cy.get('[data-cy-systemtags-picker-button-create]').should('be.visible')
+ cy.get('[data-cy-systemtags-picker-button-create]').click()
+
+ cy.wait('@createTag')
+ cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 6)
+ // Verify the new tag is selected by default
+ cy.get('[data-cy-systemtags-picker-tag]').contains(newTag)
+ .parents('[data-cy-systemtags-picker-tag]')
+ .findByRole('checkbox', { hidden: true }).should('be.checked')
+
+ // Apply changes
+ cy.get('[data-cy-systemtags-picker-button-submit]').click()
+
+ cy.wait('@getTagData')
+ cy.wait('@assignTagData')
+ cy.get('@getTagData.all').should('have.length', 1)
+ cy.get('@assignTagData.all').should('have.length', 1)
+ cy.get('[data-cy-systemtags-picker]').should('not.exist')
+
+ expectInlineTagForFile('file1.txt', [newTag])
+ expectInlineTagForFile('file2.txt', [newTag])
+ expectInlineTagForFile('file3.txt', [newTag])
+ expectInlineTagForFile('file4.txt', [newTag])
+ expectInlineTagForFile('file5.txt', [newTag])
+ })
+ })
+
+ it('Cannot create tag if restriction is in place', () => {
+ let tagId: string
+
+ cy.runOccCommand('config:app:set systemtags restrict_creation_to_admin --value 1')
+ cy.runOccCommand('tag:add testTag public --output json').then(({ stdout }) => {
+ const tag = JSON.parse(stdout)
+ tagId = tag.id
+ })
+
+ cy.createRandomUser().then((user1) => {
+ files.forEach((file) => {
+ cy.uploadContent(user1, new Blob([]), 'text/plain', '/' + file)
+ })
+
+ cy.login(user1)
+ cy.visit('/apps/files')
+
+ files.forEach((file) => {
+ getRowForFile(file).should('be.visible')
+ })
+ selectAllFiles()
+
+ triggerTagManagementDialogAction()
+
+ cy.findByRole('textbox', { name: 'Search or create tag' }).should('not.exist')
+ cy.findByRole('textbox', { name: 'Search tag' }).should('be.visible')
+
+ cy.get('[data-cy-systemtags-picker-input]').type('testTag')
+
+ cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 1)
+ cy.get('[data-cy-systemtags-picker-button-create]').should('not.exist')
+ cy.get('[data-cy-systemtags-picker-tag-color]').should('not.exist')
+
+ // Assign the tag
+ cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData')
+ cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData')
+
+ cy.get(`[data-cy-systemtags-picker-tag="${tagId}"]`).should('be.visible')
+ .findByRole('checkbox').click({ force: true })
+ cy.get('[data-cy-systemtags-picker-button-submit]').click()
+
+ cy.wait('@getTagData')
+ cy.wait('@assignTagData')
+
+ cy.get('[data-cy-systemtags-picker]').should('not.exist')
+
+ // Finally, reset the restriction
+ cy.runOccCommand('config:app:set systemtags restrict_creation_to_admin --value 0')
+ })
+ })
+
+ it('Can search for tags with insensitive case', () => {
+ let tagId: string
+ resetTags()
+
+ cy.runOccCommand('tag:add TESTTAG public --output json').then(({ stdout }) => {
+ const tag = JSON.parse(stdout)
+ tagId = tag.id
+ })
+
+ cy.createRandomUser().then((user1) => {
+ files.forEach((file) => {
+ cy.uploadContent(user1, new Blob([]), 'text/plain', '/' + file)
+ })
+
+ cy.login(user1)
+ cy.visit('/apps/files')
+
+ files.forEach((file) => {
+ getRowForFile(file).should('be.visible')
+ })
+ selectAllFiles()
+
+ triggerTagManagementDialogAction()
+
+ cy.findByRole('textbox', { name: 'Search or create tag' }).should('be.visible')
+ cy.findByRole('textbox', { name: 'Search tag' }).should('not.exist')
+
+ cy.get('[data-cy-systemtags-picker-input]').type('testtag')
+
+ cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 1)
+ cy.get(`[data-cy-systemtags-picker-tag="${tagId}"]`).should('be.visible')
+ .findByRole('checkbox').should('not.be.checked')
+
+ // Assign the tag
+ cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData')
+ cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData')
+
+ cy.get(`[data-cy-systemtags-picker-tag="${tagId}"]`).should('be.visible')
+ .findByRole('checkbox').click({ force: true })
+ cy.get('[data-cy-systemtags-picker-button-submit]').click()
+
+ cy.wait('@getTagData')
+ cy.wait('@assignTagData')
+
+ expectInlineTagForFile('file1.txt', ['TESTTAG'])
+ expectInlineTagForFile('file2.txt', ['TESTTAG'])
+ expectInlineTagForFile('file3.txt', ['TESTTAG'])
+ expectInlineTagForFile('file4.txt', ['TESTTAG'])
+ expectInlineTagForFile('file5.txt', ['TESTTAG'])
+
+ cy.get('[data-cy-systemtags-picker]').should('not.exist')
+ })
+ })
+})
diff --git a/cypress/e2e/systemtags/files-inline-action.cy.ts b/cypress/e2e/systemtags/files-inline-action.cy.ts
new file mode 100644
index 00000000000..e1199972a5d
--- /dev/null
+++ b/cypress/e2e/systemtags/files-inline-action.cy.ts
@@ -0,0 +1,172 @@
+/* eslint-disable no-unused-expressions */
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { User } from '@nextcloud/cypress'
+import { randomBytes } from 'crypto'
+import { closeSidebar, getRowForFile, triggerActionForFile } from '../files/FilesUtils.ts'
+
+describe('Systemtags: Files integration', { testIsolation: true }, () => {
+ let user: User
+
+ beforeEach(() => cy.createRandomUser().then(($user) => {
+ user = $user
+
+ cy.mkdir(user, '/folder')
+ cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt')
+ cy.login(user)
+ cy.visit('/apps/files')
+ }))
+
+ it('See first assigned tag in the file list', () => {
+ const tag = randomBytes(8).toString('base64')
+
+ cy.intercept('PROPFIND', `**/remote.php/dav/files/${user.userId}/file.txt`).as('getNode')
+ getRowForFile('file.txt').should('be.visible')
+ triggerActionForFile('file.txt', 'details')
+ cy.wait('@getNode')
+
+ cy.get('[data-cy-sidebar]')
+ .should('be.visible')
+ .findByRole('button', { name: 'Actions' })
+ .should('be.visible')
+ .click()
+
+ cy.findByRole('menuitem', { name: 'Tags' })
+ .should('be.visible')
+ .click()
+
+ cy.intercept('PUT', '**/remote.php/dav/systemtags-relations/files/**').as('assignTag')
+
+ getCollaborativeTagsInput()
+ .type(`{selectAll}${tag}{enter}`)
+ cy.wait('@assignTag')
+ cy.wait('@getNode')
+
+ // Close the sidebar and reload to check the file list
+ closeSidebar()
+ cy.reload()
+
+ getRowForFile('file.txt')
+ .findByRole('list', { name: /collaborative tags/i })
+ .findByRole('listitem')
+ .should('be.visible')
+ .and('contain.text', tag)
+ })
+
+ it('See two assigned tags are also shown in the file list', () => {
+ const tag1 = randomBytes(5).toString('base64')
+ const tag2 = randomBytes(5).toString('base64')
+
+ cy.intercept('PROPFIND', `**/remote.php/dav/files/${user.userId}/file.txt`).as('getNode')
+ getRowForFile('file.txt').should('be.visible')
+ triggerActionForFile('file.txt', 'details')
+ cy.wait('@getNode')
+
+ cy.get('[data-cy-sidebar]')
+ .should('be.visible')
+ .findByRole('button', { name: 'Actions' })
+ .should('be.visible')
+ .click()
+
+ cy.findByRole('menuitem', { name: 'Tags' })
+ .should('be.visible')
+ .click()
+
+ cy.intercept('PUT', '**/remote.php/dav/systemtags-relations/files/**').as('assignTag')
+
+ // Assign first tag
+ getCollaborativeTagsInput()
+ .type(`{selectAll}${tag1}{enter}`)
+ cy.wait('@assignTag')
+ cy.wait('@getNode')
+
+ // Assign second tag
+ getCollaborativeTagsInput()
+ .type(`{selectAll}${tag2}{enter}`)
+ cy.wait('@assignTag')
+ cy.wait('@getNode')
+
+ // Close the sidebar and reload to check the file list
+ closeSidebar()
+ cy.reload()
+
+ getRowForFile('file.txt')
+ .findByRole('list', { name: /collaborative tags/i })
+ .children()
+ .should('have.length', 2)
+ .should('contain.text', tag1)
+ .should('contain.text', tag2)
+ })
+
+ it('See three assigned tags result in overflow entry', () => {
+ const tag1 = randomBytes(4).toString('base64')
+ const tag2 = randomBytes(4).toString('base64')
+ const tag3 = randomBytes(4).toString('base64')
+
+ cy.intercept('PROPFIND', `**/remote.php/dav/files/${user.userId}/file.txt`).as('getNode')
+ getRowForFile('file.txt').should('be.visible')
+ triggerActionForFile('file.txt', 'details')
+ cy.wait('@getNode')
+
+ cy.get('[data-cy-sidebar]')
+ .should('be.visible')
+ .findByRole('button', { name: 'Actions' })
+ .should('be.visible')
+ .click()
+
+ cy.findByRole('menuitem', { name: 'Tags' })
+ .should('be.visible')
+ .click()
+
+ cy.intercept('PUT', '**/remote.php/dav/systemtags-relations/files/**').as('assignTag')
+
+ // Assign first tag
+ getCollaborativeTagsInput()
+ .type(`{selectAll}${tag1}{enter}`)
+ cy.wait('@assignTag')
+ cy.wait('@getNode')
+
+ // Assign second tag
+ getCollaborativeTagsInput()
+ .type(`{selectAll}${tag2}{enter}`)
+ cy.wait('@assignTag')
+ cy.wait('@getNode')
+
+ // Assign third tag
+ getCollaborativeTagsInput()
+ .type(`{selectAll}${tag3}{enter}`)
+ cy.wait('@assignTag')
+ cy.wait('@getNode')
+
+ // Close the sidebar and reload to check the file list
+ closeSidebar()
+ cy.reload()
+
+ getRowForFile('file.txt')
+ .findByRole('list', { name: /collaborative tags/i })
+ .children()
+ .then(($children) => {
+ expect($children.length).to.eq(4)
+ expect($children.get(0)).be.visible
+ expect($children.get(1)).be.visible
+ // not visible - just for accessibility
+ expect($children.get(2)).not.be.visible
+ expect($children.get(3)).not.be.visible
+ // Text content
+ expect($children.get(1)).contain.text('+2')
+ // Remove the '+x' element
+ const elements = [$children.get(0), ...$children.get().slice(2)]
+ .map((el) => el.innerText.trim())
+ expect(elements).to.have.members([tag1, tag2, tag3])
+ })
+ })
+})
+
+function getCollaborativeTagsInput(): Cypress.Chainable<JQuery<HTMLElement>> {
+ return cy.get('[data-cy-sidebar]')
+ .findByRole('combobox', { name: /collaborative tags/i })
+ .should('be.visible')
+ .should('not.have.attr', 'disabled', { timeout: 5000 })
+}
diff --git a/cypress/e2e/systemtags/files-sidebar.cy.ts b/cypress/e2e/systemtags/files-sidebar.cy.ts
new file mode 100644
index 00000000000..c6e6fda50d4
--- /dev/null
+++ b/cypress/e2e/systemtags/files-sidebar.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 { randomBytes } from 'crypto'
+import { getRowForFile, triggerActionForFile } from '../files/FilesUtils.ts'
+
+describe('Systemtags: Files sidebar integration', { testIsolation: true }, () => {
+ let user: User
+
+ beforeEach(() => cy.createRandomUser().then(($user) => {
+ user = $user
+
+ cy.mkdir(user, '/folder')
+ cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt')
+ cy.login(user)
+ }))
+
+ it('Can assign tags using the sidebar', () => {
+ const tag = randomBytes(8).toString('base64')
+ cy.visit('/apps/files')
+
+ getRowForFile('file.txt').should('be.visible')
+ triggerActionForFile('file.txt', 'details')
+
+ cy.get('[data-cy-sidebar]')
+ .should('be.visible')
+ .findByRole('button', { name: 'Actions' })
+ .should('be.visible')
+ .click()
+
+ cy.findByRole('menuitem', { name: 'Tags' })
+ .click()
+
+ cy.intercept('PUT', '**/remote.php/dav/systemtags-relations/files/**').as('assignTag')
+ cy.get('[data-cy-sidebar]')
+ .findByRole('combobox', { name: /collaborative tags/i })
+ .should('be.visible')
+ .type(`${tag}{enter}`)
+ cy.wait('@assignTag')
+ })
+})
diff --git a/cypress/e2e/theming/a11y-color-contrast.cy.ts b/cypress/e2e/theming/a11y-color-contrast.cy.ts
index 03a6814ea1f..bff7df28e8e 100644
--- a/cypress/e2e/theming/a11y-color-contrast.cy.ts
+++ b/cypress/e2e/theming/a11y-color-contrast.cy.ts
@@ -1,3 +1,7 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
const themesToTest = ['light', 'dark', 'light-highcontrast', 'dark-highcontrast']
const testCases = {
@@ -106,7 +110,7 @@ describe('Accessibility of Nextcloud theming colors', () => {
before(() => {
cy.createRandomUser().then(($user) => {
// set user theme
- cy.runOccCommand(`user:setting -- '${$user.userId}' theming enabled-themes '["${theme}"]'`)
+ cy.runOccCommand(`user:setting -- '${$user.userId}' theming enabled-themes '[\\"${theme}\\"]'`)
cy.login($user)
cy.visit('/')
cy.injectAxe({ axeCorePath: 'node_modules/axe-core/axe.min.js' })
@@ -118,7 +122,7 @@ describe('Accessibility of Nextcloud theming colors', () => {
// Unset background image and thus use background-color for testing blur background (images do not work with axe-core)
doc.body.style.backgroundImage = 'unset'
- const root = doc.querySelector('main')
+ const root = doc.querySelector('#content')
if (root === null) {
throw new Error('No test root found')
}
@@ -133,7 +137,7 @@ describe('Accessibility of Nextcloud theming colors', () => {
it(`color contrast of ${foreground} on ${background}`, () => {
cy.document().then(doc => {
const element = createTestCase(foreground, background)
- const root = doc.querySelector('main')
+ const root = doc.querySelector('#content')
// eslint-disable-next-line no-unused-expressions
expect(root).not.to.be.undefined
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
diff --git a/cypress/e2e/theming/admin-settings.cy.ts b/cypress/e2e/theming/admin-settings.cy.ts
index 1c4e3458aae..4207b98f711 100644
--- a/cypress/e2e/theming/admin-settings.cy.ts
+++ b/cypress/e2e/theming/admin-settings.cy.ts
@@ -1,29 +1,19 @@
/**
- * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
/* eslint-disable n/no-unpublished-import */
import { User } from '@nextcloud/cypress'
-import { colord } from 'colord'
-import { defaultPrimary, defaultBackground, pickRandomColor, validateBodyThemingCss, validateUserThemingDefaultCss } from './themingUtils'
+import {
+ defaultPrimary,
+ defaultBackground,
+ pickRandomColor,
+ validateBodyThemingCss,
+ validateUserThemingDefaultCss,
+ expectBackgroundColor,
+} from './themingUtils'
+import { NavigationHeader } from '../../pages/NavigationHeader'
const admin = new User('admin', 'admin')
@@ -36,15 +26,24 @@ describe('Admin theming settings visibility check', function() {
it('See the admin theming section', function() {
cy.visit('/settings/admin/theming')
- cy.get('[data-admin-theming-settings]').should('exist').scrollIntoView()
+ cy.get('[data-admin-theming-settings]')
+ .should('exist')
+ .scrollIntoView()
cy.get('[data-admin-theming-settings]').should('be.visible')
})
it('See the default settings', function() {
- cy.get('[data-admin-theming-setting-primary-color-picker]').should('exist')
- cy.get('[data-admin-theming-setting-primary-color-reset]').should('not.exist')
+ cy.get('[data-admin-theming-setting-color-picker]').should('exist')
cy.get('[data-admin-theming-setting-file-reset]').should('not.exist')
- cy.get('[data-admin-theming-setting-file-remove]').should('be.visible')
+ cy.get('[data-admin-theming-setting-file-remove]').should('exist')
+
+ cy.get(
+ '[data-admin-theming-setting-primary-color] [data-admin-theming-setting-color]',
+ ).then(($el) => expectBackgroundColor($el, defaultPrimary))
+
+ cy.get(
+ '[data-admin-theming-setting-background-color] [data-admin-theming-setting-color]',
+ ).then(($el) => expectBackgroundColor($el, defaultPrimary))
})
})
@@ -59,24 +58,42 @@ describe('Change the primary color and reset it', function() {
it('See the admin theming section', function() {
cy.visit('/settings/admin/theming')
- cy.get('[data-admin-theming-settings]').should('exist').scrollIntoView()
+ cy.get('[data-admin-theming-settings]')
+ .should('exist')
+ .scrollIntoView()
cy.get('[data-admin-theming-settings]').should('be.visible')
})
it('Change the primary color', function() {
cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor')
- pickRandomColor().then(color => { selectedColor = color })
+ pickRandomColor('[data-admin-theming-setting-primary-color]').then(
+ (color) => {
+ selectedColor = color
+ },
+ )
cy.wait('@setColor')
- cy.waitUntil(() => validateBodyThemingCss(selectedColor, defaultBackground))
+ cy.waitUntil(() =>
+ validateBodyThemingCss(
+ selectedColor,
+ defaultBackground,
+ defaultPrimary,
+ ),
+ )
})
it('Screenshot the login page and validate login page', function() {
cy.logout()
cy.visit('/')
- cy.waitUntil(() => validateBodyThemingCss(selectedColor, defaultBackground))
+ cy.waitUntil(() =>
+ validateBodyThemingCss(
+ selectedColor,
+ defaultBackground,
+ defaultPrimary,
+ ),
+ )
cy.screenshot()
})
@@ -98,21 +115,29 @@ describe('Remove the default background and restore it', function() {
it('See the admin theming section', function() {
cy.visit('/settings/admin/theming')
- cy.get('[data-admin-theming-settings]').should('exist').scrollIntoView()
+ cy.get('[data-admin-theming-settings]')
+ .should('exist')
+ .scrollIntoView()
cy.get('[data-admin-theming-settings]').should('be.visible')
})
it('Remove the default background', function() {
- cy.intercept('*/apps/theming/ajax/updateStylesheet').as('removeBackground')
+ cy.intercept('*/apps/theming/ajax/updateStylesheet').as(
+ 'removeBackground',
+ )
cy.get('[data-admin-theming-setting-file-remove]').click()
cy.wait('@removeBackground')
cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, null))
- cy.waitUntil(() => cy.window().then((win) => {
- const backgroundPlain = getComputedStyle(win.document.body).getPropertyValue('--image-background-plain')
- return backgroundPlain !== ''
- }))
+ cy.waitUntil(() =>
+ cy.window().then((win) => {
+ const backgroundPlain = getComputedStyle(
+ win.document.body,
+ ).getPropertyValue('--image-background')
+ return backgroundPlain !== ''
+ }),
+ )
})
it('Screenshot the login page and validate login page', function() {
@@ -132,7 +157,7 @@ describe('Remove the default background and restore it', function() {
})
})
-describe('Remove the default background with a custom primary color', function() {
+describe('Remove the default background with a custom background color', function() {
let selectedColor = ''
before(function() {
@@ -143,23 +168,40 @@ describe('Remove the default background with a custom primary color', function()
it('See the admin theming section', function() {
cy.visit('/settings/admin/theming')
- cy.get('[data-admin-theming-settings]').should('exist').scrollIntoView()
+ cy.get('[data-admin-theming-settings]')
+ .should('exist')
+ .scrollIntoView()
cy.get('[data-admin-theming-settings]').should('be.visible')
})
- it('Change the primary color', function() {
+ it('Change the background color', function() {
cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor')
- pickRandomColor().then(color => { selectedColor = color })
+ pickRandomColor('[data-admin-theming-setting-background-color]').then(
+ (color) => {
+ selectedColor = color
+ },
+ )
cy.wait('@setColor')
- cy.waitUntil(() => validateBodyThemingCss(selectedColor, defaultBackground))
+ cy.waitUntil(() =>
+ validateBodyThemingCss(
+ defaultPrimary,
+ defaultBackground,
+ selectedColor,
+ ),
+ )
})
it('Remove the default background', function() {
- cy.intercept('*/apps/theming/ajax/updateStylesheet').as('removeBackground')
+ cy.intercept('*/apps/theming/ajax/updateStylesheet').as(
+ 'removeBackground',
+ )
- cy.get('[data-admin-theming-setting-file-remove]').click()
+ cy.get('[data-admin-theming-setting-file-remove]').scrollIntoView()
+ cy.get('[data-admin-theming-setting-file-remove]').click({
+ force: true,
+ })
cy.wait('@removeBackground')
})
@@ -168,7 +210,9 @@ describe('Remove the default background with a custom primary color', function()
cy.logout()
cy.visit('/')
- cy.waitUntil(() => validateBodyThemingCss(selectedColor, null))
+ cy.waitUntil(() =>
+ validateBodyThemingCss(defaultPrimary, null, selectedColor),
+ )
cy.screenshot()
})
@@ -182,6 +226,9 @@ describe('Remove the default background with a custom primary color', function()
})
describe('Remove the default background with a bright color', function() {
+ const navigationHeader = new NavigationHeader()
+ let selectedColor = ''
+
before(function() {
// Just in case previous test failed
cy.resetAdminTheming()
@@ -191,37 +238,52 @@ describe('Remove the default background with a bright color', function() {
it('See the admin theming section', function() {
cy.visit('/settings/admin/theming')
- cy.get('[data-admin-theming-settings]').should('exist').scrollIntoView()
+ cy.get('[data-admin-theming-settings]')
+ .should('exist')
+ .scrollIntoView()
cy.get('[data-admin-theming-settings]').should('be.visible')
})
it('Remove the default background', function() {
- cy.intercept('*/apps/theming/ajax/updateStylesheet').as('removeBackground')
+ cy.intercept('*/apps/theming/ajax/updateStylesheet').as(
+ 'removeBackground',
+ )
cy.get('[data-admin-theming-setting-file-remove]').click()
cy.wait('@removeBackground')
})
- it('Change the primary color', function() {
+ it('Change the background color', function() {
cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor')
// Pick one of the bright color preset
- cy.get('[data-admin-theming-setting-primary-color-picker]').click()
- cy.get('.color-picker__simple-color-circle:eq(4)').click()
+ pickRandomColor(
+ '[data-admin-theming-setting-background-color]',
+ 4,
+ ).then((color) => {
+ selectedColor = color
+ })
cy.wait('@setColor')
- cy.waitUntil(() => validateBodyThemingCss('#ddcb55', null))
+ cy.waitUntil(() =>
+ validateBodyThemingCss(defaultPrimary, null, selectedColor),
+ )
})
it('See the header being inverted', function() {
- cy.waitUntil(() => cy.window().then((win) => {
- const firstEntry = win.document.querySelector('.app-menu-main li img')
- if (!firstEntry) {
- return false
- }
- return getComputedStyle(firstEntry).filter === 'invert(1)'
- }))
+ cy.waitUntil(() =>
+ navigationHeader
+ .getNavigationEntries()
+ .find('img')
+ .then((el) => {
+ let ret = true
+ el.each(function() {
+ ret = ret && window.getComputedStyle(this).filter === 'invert(1)'
+ })
+ return ret
+ })
+ )
})
})
@@ -238,7 +300,9 @@ describe('Change the login fields then reset them', function() {
it('See the admin theming section', function() {
cy.visit('/settings/admin/theming')
- cy.get('[data-admin-theming-settings]').should('exist').scrollIntoView()
+ cy.get('[data-admin-theming-settings]')
+ .should('exist')
+ .scrollIntoView()
cy.get('[data-admin-theming-settings]').should('be.visible')
})
@@ -246,42 +310,54 @@ describe('Change the login fields then reset them', function() {
cy.intercept('*/apps/theming/ajax/updateStylesheet').as('updateFields')
// Name
- cy.get('[data-admin-theming-setting-field="name"] input[type="text"]')
- .scrollIntoView()
- cy.get('[data-admin-theming-setting-field="name"] input[type="text"]')
- .type(`{selectall}${name}{enter}`)
+ cy.get(
+ '[data-admin-theming-setting-field="name"] input[type="text"]',
+ ).scrollIntoView()
+ cy.get(
+ '[data-admin-theming-setting-field="name"] input[type="text"]',
+ ).type(`{selectall}${name}{enter}`)
cy.wait('@updateFields')
// Url
- cy.get('[data-admin-theming-setting-field="url"] input[type="url"]')
- .scrollIntoView()
- cy.get('[data-admin-theming-setting-field="url"] input[type="url"]')
- .type(`{selectall}${url}{enter}`)
+ cy.get(
+ '[data-admin-theming-setting-field="url"] input[type="url"]',
+ ).scrollIntoView()
+ cy.get(
+ '[data-admin-theming-setting-field="url"] input[type="url"]',
+ ).type(`{selectall}${url}{enter}`)
cy.wait('@updateFields')
// Slogan
- cy.get('[data-admin-theming-setting-field="slogan"] input[type="text"]')
- .scrollIntoView()
- cy.get('[data-admin-theming-setting-field="slogan"] input[type="text"]')
- .type(`{selectall}${slogan}{enter}`)
+ cy.get(
+ '[data-admin-theming-setting-field="slogan"] input[type="text"]',
+ ).scrollIntoView()
+ cy.get(
+ '[data-admin-theming-setting-field="slogan"] input[type="text"]',
+ ).type(`{selectall}${slogan}{enter}`)
cy.wait('@updateFields')
})
it('Ensure undo button presence', function() {
- cy.get('[data-admin-theming-setting-field="name"] .input-field__trailing-button')
- .scrollIntoView()
- cy.get('[data-admin-theming-setting-field="name"] .input-field__trailing-button')
- .should('be.visible')
-
- cy.get('[data-admin-theming-setting-field="url"] .input-field__trailing-button')
- .scrollIntoView()
- cy.get('[data-admin-theming-setting-field="url"] .input-field__trailing-button')
- .should('be.visible')
-
- cy.get('[data-admin-theming-setting-field="slogan"] .input-field__trailing-button')
- .scrollIntoView()
- cy.get('[data-admin-theming-setting-field="slogan"] .input-field__trailing-button')
- .should('be.visible')
+ cy.get(
+ '[data-admin-theming-setting-field="name"] .input-field__trailing-button',
+ ).scrollIntoView()
+ cy.get(
+ '[data-admin-theming-setting-field="name"] .input-field__trailing-button',
+ ).should('be.visible')
+
+ cy.get(
+ '[data-admin-theming-setting-field="url"] .input-field__trailing-button',
+ ).scrollIntoView()
+ cy.get(
+ '[data-admin-theming-setting-field="url"] .input-field__trailing-button',
+ ).should('be.visible')
+
+ cy.get(
+ '[data-admin-theming-setting-field="slogan"] .input-field__trailing-button',
+ ).scrollIntoView()
+ cy.get(
+ '[data-admin-theming-setting-field="slogan"] .input-field__trailing-button',
+ ).should('be.visible')
})
it('Validate login screen changes', function() {
@@ -317,19 +393,29 @@ describe('Disable user theming and enable it back', function() {
it('See the admin theming section', function() {
cy.visit('/settings/admin/theming')
- cy.get('[data-admin-theming-settings]').should('exist').scrollIntoView()
+ cy.get('[data-admin-theming-settings]')
+ .should('exist')
+ .scrollIntoView()
cy.get('[data-admin-theming-settings]').should('be.visible')
})
it('Disable user background theming', function() {
- cy.intercept('*/apps/theming/ajax/updateStylesheet').as('disableUserTheming')
-
- cy.get('[data-admin-theming-setting-disable-user-theming]')
- .scrollIntoView()
- cy.get('[data-admin-theming-setting-disable-user-theming]')
- .should('be.visible')
- cy.get('[data-admin-theming-setting-disable-user-theming] input[type="checkbox"]').check({ force: true })
- cy.get('[data-admin-theming-setting-disable-user-theming] input[type="checkbox"]').should('be.checked')
+ cy.intercept('*/apps/theming/ajax/updateStylesheet').as(
+ 'disableUserTheming',
+ )
+
+ cy.get(
+ '[data-admin-theming-setting-disable-user-theming]',
+ ).scrollIntoView()
+ cy.get('[data-admin-theming-setting-disable-user-theming]').should(
+ 'be.visible',
+ )
+ cy.get(
+ '[data-admin-theming-setting-disable-user-theming] input[type="checkbox"]',
+ ).check({ force: true })
+ cy.get(
+ '[data-admin-theming-setting-disable-user-theming] input[type="checkbox"]',
+ ).should('be.checked')
cy.wait('@disableUserTheming')
})
@@ -343,8 +429,9 @@ describe('Disable user theming and enable it back', function() {
it('User cannot not change background settings', function() {
cy.visit('/settings/user/theming')
- cy.get('[data-user-theming-background-disabled]').scrollIntoView()
- cy.get('[data-user-theming-background-disabled]').should('be.visible')
+ cy.contains(
+ 'Customization has been disabled by your administrator',
+ ).should('exist')
})
})
@@ -363,40 +450,60 @@ describe('The user default background settings reflect the admin theming setting
it('See the admin theming section', function() {
cy.visit('/settings/admin/theming')
- cy.get('[data-admin-theming-settings]').should('exist').scrollIntoView()
+ cy.get('[data-admin-theming-settings]')
+ .should('exist')
+ .scrollIntoView()
cy.get('[data-admin-theming-settings]').should('be.visible')
})
- it('Change the primary color', function() {
- cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor')
-
- pickRandomColor().then(color => { selectedColor = color })
-
- cy.wait('@setColor')
- cy.waitUntil(() => cy.window().then(($window) => {
- const primary = $window.getComputedStyle($window.document.body).getPropertyValue('--color-primary-default')
- return colord(primary).isEqual(selectedColor)
- }))
- })
-
it('Change the default background', function() {
cy.intercept('*/apps/theming/ajax/uploadImage').as('setBackground')
cy.fixture('image.jpg', null).as('background')
- cy.get('[data-admin-theming-setting-file="background"] input[type="file"]').selectFile('@background', { force: true })
+ cy.get(
+ '[data-admin-theming-setting-file="background"] input[type="file"]',
+ ).selectFile('@background', { force: true })
cy.wait('@setBackground')
- cy.waitUntil(() => cy.window().then((win) => {
- const currentBackgroundDefault = getComputedStyle(win.document.body).getPropertyValue('--image-background-default')
- return currentBackgroundDefault.includes('/apps/theming/image/background?v=')
- }))
+ cy.waitUntil(() =>
+ validateBodyThemingCss(
+ defaultPrimary,
+ '/apps/theming/image/background?v=',
+ null,
+ ),
+ )
+ })
+
+ it('Change the background color', function() {
+ cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor')
+
+ pickRandomColor('[data-admin-theming-setting-background-color]').then(
+ (color) => {
+ selectedColor = color
+ },
+ )
+
+ cy.wait('@setColor')
+ cy.waitUntil(() =>
+ validateBodyThemingCss(
+ defaultPrimary,
+ '/apps/theming/image/background?v=',
+ selectedColor,
+ ),
+ )
})
it('Login page should match admin theming settings', function() {
cy.logout()
cy.visit('/')
- cy.waitUntil(() => validateBodyThemingCss(selectedColor, '/apps/theming/image/background?v='))
+ cy.waitUntil(() =>
+ validateBodyThemingCss(
+ defaultPrimary,
+ '/apps/theming/image/background?v=',
+ selectedColor,
+ ),
+ )
})
it('Login as user', function() {
@@ -413,9 +520,17 @@ describe('The user default background settings reflect the admin theming setting
it('Default user background settings should match admin theming settings', function() {
cy.get('[data-user-theming-background-default]').should('be.visible')
- cy.get('[data-user-theming-background-default]').should('have.class', 'background--active')
-
- cy.waitUntil(() => validateUserThemingDefaultCss(selectedColor, '/apps/theming/image/background?v='))
+ cy.get('[data-user-theming-background-default]').should(
+ 'have.class',
+ 'background--active',
+ )
+
+ cy.waitUntil(() =>
+ validateUserThemingDefaultCss(
+ selectedColor,
+ '/apps/theming/image/background?v=',
+ ),
+ )
})
})
@@ -432,12 +547,16 @@ describe('The user default background settings reflect the admin theming setting
it('See the admin theming section', function() {
cy.visit('/settings/admin/theming')
- cy.get('[data-admin-theming-settings]').should('exist').scrollIntoView()
+ cy.get('[data-admin-theming-settings]')
+ .should('exist')
+ .scrollIntoView()
cy.get('[data-admin-theming-settings]').should('be.visible')
})
it('Remove the default background', function() {
- cy.intercept('*/apps/theming/ajax/updateStylesheet').as('removeBackground')
+ cy.intercept('*/apps/theming/ajax/updateStylesheet').as(
+ 'removeBackground',
+ )
cy.get('[data-admin-theming-setting-file-remove]').click()
@@ -466,7 +585,10 @@ describe('The user default background settings reflect the admin theming setting
it('Default user background settings should match admin theming settings', function() {
cy.get('[data-user-theming-background-default]').should('be.visible')
- cy.get('[data-user-theming-background-default]').should('have.class', 'background--active')
+ cy.get('[data-user-theming-background-default]').should(
+ 'have.class',
+ 'background--active',
+ )
cy.waitUntil(() => validateUserThemingDefaultCss(defaultPrimary, null))
})
diff --git a/cypress/e2e/theming/admin-settings_default-app.cy.ts b/cypress/e2e/theming/admin-settings_default-app.cy.ts
new file mode 100644
index 00000000000..702f737bc15
--- /dev/null
+++ b/cypress/e2e/theming/admin-settings_default-app.cy.ts
@@ -0,0 +1,91 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { User } from '@nextcloud/cypress'
+import { NavigationHeader } from '../../pages/NavigationHeader'
+
+const admin = new User('admin', 'admin')
+
+describe('Admin theming set default apps', () => {
+ const navigationHeader = new NavigationHeader()
+
+ before(function() {
+ // Just in case previous test failed
+ cy.resetAdminTheming()
+ cy.login(admin)
+ })
+
+ it('See the current default app is the dashboard', () => {
+ // check default route
+ cy.visit('/')
+ cy.url().should('match', /apps\/dashboard/)
+
+ // Also check the top logo link
+ navigationHeader.logo().click()
+ cy.url().should('match', /apps\/dashboard/)
+ })
+
+ it('See the default app settings', () => {
+ cy.visit('/settings/admin/theming')
+
+ cy.get('.settings-section').contains('Navigation bar settings').should('exist')
+ cy.get('[data-cy-switch-default-app]').should('exist')
+ cy.get('[data-cy-switch-default-app]').scrollIntoView()
+ })
+
+ it('Toggle the "use custom default app" switch', () => {
+ cy.get('[data-cy-switch-default-app] input').should('not.be.checked')
+ cy.get('[data-cy-switch-default-app] .checkbox-content').click()
+ cy.get('[data-cy-switch-default-app] input').should('be.checked')
+ })
+
+ it('See the default app order selector', () => {
+ cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => {
+ const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get()
+ expect(appIDs).to.deep.eq(['dashboard', 'files'])
+ })
+ })
+
+ it('Change the default app', () => {
+ cy.get('[data-cy-app-order] [data-cy-app-order-element="files"]').scrollIntoView()
+
+ cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('be.visible')
+ cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').click()
+ cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('not.be.visible')
+
+ })
+
+ it('See the default app is changed', () => {
+ cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => {
+ const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get()
+ expect(appIDs).to.deep.eq(['files', 'dashboard'])
+ })
+
+ // Check the redirect to the default app works
+ cy.request({ url: '/', followRedirect: false }).then((response) => {
+ expect(response.status).to.eq(302)
+ expect(response).to.have.property('headers')
+ expect(response.headers.location).to.contain('/apps/files')
+ })
+ })
+
+ it('Toggle the "use custom default app" switch back to reset the default apps', () => {
+ cy.visit('/settings/admin/theming')
+ cy.get('[data-cy-switch-default-app]').scrollIntoView()
+
+ cy.get('[data-cy-switch-default-app] input').should('be.checked')
+ cy.get('[data-cy-switch-default-app] .checkbox-content').click()
+ cy.get('[data-cy-switch-default-app] input').should('be.not.checked')
+ })
+
+ it('See the default app is changed back to default', () => {
+ // Check the redirect to the default app works
+ cy.request({ url: '/', followRedirect: false }).then((response) => {
+ expect(response.status).to.eq(302)
+ expect(response).to.have.property('headers')
+ expect(response.headers.location).to.contain('/apps/dashboard')
+ })
+ })
+})
diff --git a/cypress/e2e/theming/admin-settings_urls.cy.ts b/cypress/e2e/theming/admin-settings_urls.cy.ts
new file mode 100644
index 00000000000..46bae7901c4
--- /dev/null
+++ b/cypress/e2e/theming/admin-settings_urls.cy.ts
@@ -0,0 +1,143 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { User } from '@nextcloud/cypress'
+
+const admin = new User('admin', 'admin')
+
+describe('Admin theming: Setting custom project URLs', function() {
+ this.beforeEach(() => {
+ // Just in case previous test failed
+ cy.resetAdminTheming()
+ cy.login(admin)
+ cy.visit('/settings/admin/theming')
+ cy.intercept('POST', '**/apps/theming/ajax/updateStylesheet').as('updateTheming')
+ })
+
+ it('Setting the web link', () => {
+ cy.findByRole('textbox', { name: /web link/i })
+ .and('have.attr', 'type', 'url')
+ .as('input')
+ .scrollIntoView()
+ cy.get('@input')
+ .should('be.visible')
+ .type('{selectAll}http://example.com/path?query#fragment{enter}')
+
+ cy.wait('@updateTheming')
+
+ cy.logout()
+
+ cy.visit('/')
+ cy.contains('a', 'Nextcloud')
+ .should('be.visible')
+ .and('have.attr', 'href', 'http://example.com/path?query#fragment')
+ })
+
+ it('Setting the legal notice link', () => {
+ cy.findByRole('textbox', { name: /legal notice link/i })
+ .should('exist')
+ .and('have.attr', 'type', 'url')
+ .as('input')
+ .scrollIntoView()
+ cy.get('@input')
+ .type('http://example.com/path?query#fragment{enter}')
+
+ cy.wait('@updateTheming')
+
+ cy.logout()
+
+ cy.visit('/')
+ cy.contains('a', /legal notice/i)
+ .should('be.visible')
+ .and('have.attr', 'href', 'http://example.com/path?query#fragment')
+ })
+
+ it('Setting the privacy policy link', () => {
+ cy.findByRole('textbox', { name: /privacy policy link/i })
+ .should('exist')
+ .as('input')
+ .scrollIntoView()
+ cy.get('@input')
+ .should('have.attr', 'type', 'url')
+ .type('http://privacy.local/path?query#fragment{enter}')
+
+ cy.wait('@updateTheming')
+
+ cy.logout()
+
+ cy.visit('/')
+ cy.contains('a', /privacy policy/i)
+ .should('be.visible')
+ .and('have.attr', 'href', 'http://privacy.local/path?query#fragment')
+ })
+
+})
+
+describe('Admin theming: Web link corner cases', function() {
+ this.beforeEach(() => {
+ // Just in case previous test failed
+ cy.resetAdminTheming()
+ cy.login(admin)
+ cy.visit('/settings/admin/theming')
+ cy.intercept('POST', '**/apps/theming/ajax/updateStylesheet').as('updateTheming')
+ })
+
+ it('Already URL encoded', () => {
+ cy.findByRole('textbox', { name: /web link/i })
+ .and('have.attr', 'type', 'url')
+ .as('input')
+ .scrollIntoView()
+ cy.get('@input')
+ .should('be.visible')
+ .type('{selectAll}http://example.com/%22path%20with%20space%22{enter}')
+
+ cy.wait('@updateTheming')
+
+ cy.logout()
+
+ cy.visit('/')
+ cy.contains('a', 'Nextcloud')
+ .should('be.visible')
+ .and('have.attr', 'href', 'http://example.com/%22path%20with%20space%22')
+ })
+
+ it('URL with double quotes', () => {
+ cy.findByRole('textbox', { name: /web link/i })
+ .and('have.attr', 'type', 'url')
+ .as('input')
+ .scrollIntoView()
+ cy.get('@input')
+ .should('be.visible')
+ .type('{selectAll}http://example.com/"path"{enter}')
+
+ cy.wait('@updateTheming')
+
+ cy.logout()
+
+ cy.visit('/')
+ cy.contains('a', 'Nextcloud')
+ .should('be.visible')
+ .and('have.attr', 'href', 'http://example.com/%22path%22')
+ })
+
+ it('URL with double quotes and already encoded', () => {
+ cy.findByRole('textbox', { name: /web link/i })
+ .and('have.attr', 'type', 'url')
+ .as('input')
+ .scrollIntoView()
+ cy.get('@input')
+ .should('be.visible')
+ .type('{selectAll}http://example.com/"the%20path"{enter}')
+
+ cy.wait('@updateTheming')
+
+ cy.logout()
+
+ cy.visit('/')
+ cy.contains('a', 'Nextcloud')
+ .should('be.visible')
+ .and('have.attr', 'href', 'http://example.com/%22the%20path%22')
+ })
+
+})
diff --git a/cypress/e2e/theming/themingUtils.ts b/cypress/e2e/theming/themingUtils.ts
index 2965886c656..b4740beda1c 100644
--- a/cypress/e2e/theming/themingUtils.ts
+++ b/cypress/e2e/theming/themingUtils.ts
@@ -1,49 +1,57 @@
/**
- * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { colord } from 'colord'
-const defaultNextcloudBlue = '#0082c9'
export const defaultPrimary = '#00679e'
-export const defaultBackground = 'kamil-porembinski-clouds.jpg'
+export const defaultBackground = 'jenna-kim-the-globe.webp'
+
+/**
+ * Check if a CSS variable is set to a specific color
+ * @param variable Variable to check
+ * @param expectedColor Color that is expected
+ */
+export function validateCSSVariable(variable: string, expectedColor: string) {
+ const value = window.getComputedStyle(Cypress.$('body').get(0)).getPropertyValue(variable)
+ console.debug(`${variable}, is: ${colord(value).toHex()} expected: ${expectedColor}`)
+ return colord(value).isEqual(expectedColor)
+}
/**
* Validate the current page body css variables
*
- * @param {string} expectedColor the expected color
+ * @param {string} expectedColor the expected primary color
* @param {string|null} expectedBackground the expected background
+ * @param {string|null} expectedBackgroundColor the expected background color (null to ignore)
*/
-export const validateBodyThemingCss = function(expectedColor = defaultPrimary, expectedBackground: string|null = defaultBackground) {
+export function validateBodyThemingCss(expectedColor = defaultPrimary, expectedBackground: string|null = defaultBackground, expectedBackgroundColor: string|null = defaultPrimary) {
// We must use `Cypress.$` here as any assertions (get is an assertion) is not allowed in wait-until's check function, see documentation
const guestBackgroundColor = Cypress.$('body').css('background-color')
const guestBackgroundImage = Cypress.$('body').css('background-image')
- const isValidBackgroundColor = colord(guestBackgroundColor).isEqual(expectedColor)
+ const isValidBackgroundColor = expectedBackgroundColor === null || colord(guestBackgroundColor).isEqual(expectedBackgroundColor)
const isValidBackgroundImage = !expectedBackground
? guestBackgroundImage === 'none'
: guestBackgroundImage.includes(expectedBackground)
- console.debug({ guestBackgroundColor: colord(guestBackgroundColor).toHex(), guestBackgroundImage, expectedColor, expectedBackground, isValidBackgroundColor, isValidBackgroundImage })
+ console.debug({
+ isValidBackgroundColor,
+ isValidBackgroundImage,
+ guestBackgroundColor: colord(guestBackgroundColor).toHex(),
+ guestBackgroundImage,
+ })
- return isValidBackgroundColor && isValidBackgroundImage
+ return isValidBackgroundColor && isValidBackgroundImage && validateCSSVariable('--color-primary', expectedColor)
+}
+
+/**
+ * Check background color of element
+ * @param element JQuery element to check
+ * @param color expected color
+ */
+export function expectBackgroundColor(element: JQuery<HTMLElement>, color: string) {
+ expect(colord(element.css('background-color')).toHex()).equal(colord(color).toHex())
}
/**
@@ -58,28 +66,28 @@ export const validateUserThemingDefaultCss = function(expectedColor = defaultPri
return false
}
- const defaultOptionBackground = defaultSelectButton.css('background-image')
- const colorPickerOptionColor = defaultSelectButton.css('background-color')
- const isNextcloudBlue = colord(colorPickerOptionColor).isEqual('#0082c9')
+ const backgroundImage = defaultSelectButton.css('background-image')
+ const backgroundColor = defaultSelectButton.css('background-color')
const isValidBackgroundImage = !expectedBackground
- ? defaultOptionBackground === 'none'
- : defaultOptionBackground.includes(expectedBackground)
-
- console.debug({ colorPickerOptionColor: colord(colorPickerOptionColor).toHex(), expectedColor, isValidBackgroundImage, isNextcloudBlue })
+ ? (backgroundImage === 'none' || Cypress.$('body').css('background-image') === 'none')
+ : backgroundImage.includes(expectedBackground)
+
+ console.debug({
+ colorPickerOptionColor: colord(backgroundColor).toHex(),
+ expectedColor,
+ isValidBackgroundImage,
+ backgroundImage,
+ })
- return isValidBackgroundImage && (
- colord(colorPickerOptionColor).isEqual(expectedColor)
- // we replace nextcloud blue with the the default rpimary (apps/theming/lib/Themes/DefaultTheme.php line 76)
- || (isNextcloudBlue && colord(expectedColor).isEqual(defaultPrimary))
- )
+ return isValidBackgroundImage && colord(backgroundColor).isEqual(expectedColor)
}
-export const pickRandomColor = function(): Cypress.Chainable<string> {
+export const pickRandomColor = function(context: string, index?: number): Cypress.Chainable<string> {
// Pick one of the first 8 options
- const randColour = Math.floor(Math.random() * 8)
+ const randColour = index ?? Math.floor(Math.random() * 8)
- const colorPreviewSelector = '[data-user-theming-background-color],[data-admin-theming-setting-primary-color]'
+ const colorPreviewSelector = `${context} [data-admin-theming-setting-color]`
let oldColor = ''
cy.get(colorPreviewSelector).then(($el) => {
@@ -87,7 +95,8 @@ export const pickRandomColor = function(): Cypress.Chainable<string> {
})
// Open picker
- cy.contains('button', 'Change color').click()
+ cy.get(`${context} [data-admin-theming-setting-color-picker]`).scrollIntoView()
+ cy.get(`${context} [data-admin-theming-setting-color-picker]`).click({ force: true })
// Click on random color
cy.get('.color-picker__simple-color-circle').eq(randColour).click()
diff --git a/cypress/e2e/theming/navigation-bar-settings.cy.ts b/cypress/e2e/theming/user-settings_app-order.cy.ts
index 4bea6225f76..11ef2f45382 100644
--- a/cypress/e2e/theming/navigation-bar-settings.cy.ts
+++ b/cypress/e2e/theming/user-settings_app-order.cy.ts
@@ -1,110 +1,23 @@
/**
- * @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @author Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { User } from '@nextcloud/cypress'
import { installTestApp, uninstallTestApp } from '../../support/commonUtils'
+import { NavigationHeader } from '../../pages/NavigationHeader'
-const admin = new User('admin', 'admin')
-
-describe('Admin theming set default apps', () => {
- before(function() {
- // Just in case previous test failed
- cy.resetAdminTheming()
- cy.login(admin)
- })
-
- it('See the current default app is the dashboard', () => {
- cy.visit('/')
- cy.url().should('match', /apps\/dashboard/)
-
- // Also check the top logo link
- cy.get('#nextcloud').click()
- cy.url().should('match', /apps\/dashboard/)
- })
-
- it('See the default app settings', () => {
- cy.visit('/settings/admin/theming')
-
- cy.get('.settings-section').contains('Navigation bar settings').should('exist')
- cy.get('[data-cy-switch-default-app]').should('exist')
- cy.get('[data-cy-switch-default-app]').scrollIntoView()
- })
-
- it('Toggle the "use custom default app" switch', () => {
- cy.get('[data-cy-switch-default-app] input').should('not.be.checked')
- cy.get('[data-cy-switch-default-app] .checkbox-content').click()
- cy.get('[data-cy-switch-default-app] input').should('be.checked')
- })
-
- it('See the default app order selector', () => {
- cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => {
- const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get()
- expect(appIDs).to.deep.eq(['dashboard', 'files'])
- })
- })
-
- it('Change the default app', () => {
- cy.get('[data-cy-app-order] [data-cy-app-order-element="files"]').scrollIntoView()
-
- cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('be.visible')
- cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').click()
- cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('not.be.visible')
-
- })
-
- it('See the default app is changed', () => {
- cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => {
- const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get()
- expect(appIDs).to.deep.eq(['files', 'dashboard'])
- })
-
- // Check the redirect to the default app works
- cy.request({ url: '/', followRedirect: false }).then((response) => {
- expect(response.status).to.eq(302)
- expect(response).to.have.property('headers')
- expect(response.headers.location).to.contain('/apps/files')
- })
- })
-
- it('Toggle the "use custom default app" switch back to reset the default apps', () => {
- cy.visit('/settings/admin/theming')
- cy.get('[data-cy-switch-default-app]').scrollIntoView()
-
- cy.get('[data-cy-switch-default-app] input').should('be.checked')
- cy.get('[data-cy-switch-default-app] .checkbox-content').click()
- cy.get('[data-cy-switch-default-app] input').should('be.not.checked')
- })
+/**
+ * Intercept setting the app order as `updateAppOrder`
+ */
+function interceptAppOrder() {
+ cy.intercept('POST', '/ocs/v2.php/apps/provisioning_api/api/v1/config/users/core/apporder').as('updateAppOrder')
+}
- it('See the default app is changed back to default', () => {
- // Check the redirect to the default app works
- cy.request({ url: '/', followRedirect: false }).then((response) => {
- expect(response.status).to.eq(302)
- expect(response).to.have.property('headers')
- expect(response.headers.location).to.contain('/apps/dashboard')
- })
- })
-})
+before(() => uninstallTestApp())
describe('User theming set app order', () => {
+ const navigationHeader = new NavigationHeader()
let user: User
before(() => {
@@ -126,40 +39,43 @@ describe('User theming set app order', () => {
})
it('See that the dashboard app is the first one', () => {
+ const appOrder = ['Dashboard', 'Files']
// Check the app order settings UI
- cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => {
- const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get()
- expect(appIDs).to.deep.eq(['dashboard', 'files'])
- })
+ cy.get('[data-cy-app-order] [data-cy-app-order-element]')
+ .each((element, index) => expect(element).to.contain.text(appOrder[index]))
// Check the top app menu order
- cy.get('.app-menu-main .app-menu-entry').then(elements => {
- const appIDs = elements.map((idx, el) => el.getAttribute('data-app-id')).get()
- expect(appIDs).to.deep.eq(['dashboard', 'files'])
- })
+ navigationHeader.getNavigationEntries()
+ .each((entry, index) => expect(entry).contain.text(appOrder[index]))
})
it('Change the app order', () => {
+ interceptAppOrder()
cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('be.visible')
cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').click()
cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('not.be.visible')
+ cy.wait('@updateAppOrder')
- cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => {
- const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get()
- expect(appIDs).to.deep.eq(['files', 'dashboard'])
- })
+ const appOrder = ['Files', 'Dashboard']
+ cy.get('[data-cy-app-order] [data-cy-app-order-element]')
+ .each((element, index) => expect(element).to.contain.text(appOrder[index]))
})
it('See the app menu order is changed', () => {
cy.reload()
- cy.get('.app-menu-main .app-menu-entry').then(elements => {
- const appIDs = elements.map((idx, el) => el.getAttribute('data-app-id')).get()
- expect(appIDs).to.deep.eq(['files', 'dashboard'])
- })
+ const appOrder = ['Files', 'Dashboard']
+ // Check the app order settings UI
+ cy.get('[data-cy-app-order] [data-cy-app-order-element]')
+ .each((element, index) => expect(element).to.contain.text(appOrder[index]))
+
+ // Check the top app menu order
+ navigationHeader.getNavigationEntries()
+ .each((entry, index) => expect(entry).contain.text(appOrder[index]))
})
})
describe('User theming set app order with default app', () => {
+ const navigationHeader = new NavigationHeader()
let user: User
before(() => {
@@ -167,7 +83,7 @@ describe('User theming set app order with default app', () => {
// install a third app
installTestApp()
// set files as default app
- cy.runOccCommand('config:system:set --value "files" defaultapp')
+ cy.runOccCommand('config:system:set --value \'files\' defaultapp')
// Create random user for this test
cy.createRandomUser().then(($user) => {
@@ -193,11 +109,11 @@ describe('User theming set app order with default app', () => {
it('See the app order settings: files is the first one', () => {
cy.visit('/settings/user/theming')
cy.get('[data-cy-app-order]').scrollIntoView()
- cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => {
- expect(elements).to.have.length(4)
- const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get()
- expect(appIDs).to.deep.eq(['files', 'dashboard', 'testapp1', 'testapp'])
- })
+
+ const appOrder = ['Files', 'Dashboard', 'Test App 2', 'Test App']
+ // Check the app order settings UI
+ cy.get('[data-cy-app-order] [data-cy-app-order-element]')
+ .each((element, index) => expect(element).to.contain.text(appOrder[index]))
})
it('Can not change the default app', () => {
@@ -212,32 +128,31 @@ describe('User theming set app order with default app', () => {
})
it('Change the order of the other apps', () => {
- cy.intercept('POST', '**/apps/provisioning_api/api/v1/config/users/core/apporder').as('setAppOrder')
+ interceptAppOrder()
// Move the testapp up twice, it should be the first one after files
cy.get('[data-cy-app-order] [data-cy-app-order-element="testapp"] [data-cy-app-order-button="up"]').click()
- cy.wait('@setAppOrder')
+ cy.wait('@updateAppOrder')
cy.get('[data-cy-app-order] [data-cy-app-order-element="testapp"] [data-cy-app-order-button="up"]').click()
- cy.wait('@setAppOrder')
+ cy.wait('@updateAppOrder')
// Can't get up anymore, files is enforced as default app
cy.get('[data-cy-app-order] [data-cy-app-order-element="testapp"] [data-cy-app-order-button="up"]').should('not.be.visible')
// Check the final list order
- cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => {
- expect(elements).to.have.length(4)
- const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get()
- expect(appIDs).to.deep.eq(['files', 'testapp', 'dashboard', 'testapp1'])
- })
+ const appOrder = ['Files', 'Test App', 'Dashboard', 'Test App 2']
+ // Check the app order settings UI
+ cy.get('[data-cy-app-order] [data-cy-app-order-element]')
+ .each((element, index) => expect(element).to.contain.text(appOrder[index]))
})
it('See the app menu order is changed', () => {
cy.reload()
- cy.get('.app-menu-main .app-menu-entry').then(elements => {
- expect(elements).to.have.length(4)
- const appIDs = elements.map((idx, el) => el.getAttribute('data-app-id')).get()
- expect(appIDs).to.deep.eq(['files', 'testapp', 'dashboard', 'testapp1'])
- })
+
+ const appOrder = ['Files', 'Test App', 'Dashboard', 'Test App 2']
+ // Check the top app menu order
+ navigationHeader.getNavigationEntries()
+ .each((entry, index) => expect(entry).contain.text(appOrder[index]))
})
})
@@ -264,8 +179,10 @@ describe('User theming app order list accessibility', () => {
})
it('click the first button', () => {
+ interceptAppOrder()
cy.get('[data-cy-app-order] [data-cy-app-order-element="dashboard"] [data-cy-app-order-button="down"]').should('be.visible').focus()
cy.get('[data-cy-app-order] [data-cy-app-order-element="dashboard"] [data-cy-app-order-button="down"]').click()
+ cy.wait('@updateAppOrder')
})
it('see the same app kept the focus', () => {
@@ -276,8 +193,10 @@ describe('User theming app order list accessibility', () => {
})
it('click the last button', () => {
+ interceptAppOrder()
cy.get('[data-cy-app-order] [data-cy-app-order-element="dashboard"] [data-cy-app-order-button="up"]').should('be.visible').focus()
cy.get('[data-cy-app-order] [data-cy-app-order-element="dashboard"] [data-cy-app-order-button="up"]').click()
+ cy.wait('@updateAppOrder')
})
it('see the same app kept the focus', () => {
@@ -289,6 +208,7 @@ describe('User theming app order list accessibility', () => {
})
describe('User theming reset app order', () => {
+ const navigationHeader = new NavigationHeader()
let user: User
before(() => {
@@ -310,17 +230,14 @@ describe('User theming reset app order', () => {
})
it('See that the dashboard app is the first one', () => {
+ const appOrder = ['Dashboard', 'Files']
// Check the app order settings UI
- cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => {
- const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get()
- expect(appIDs).to.deep.eq(['dashboard', 'files'])
- })
+ cy.get('[data-cy-app-order] [data-cy-app-order-element]')
+ .each((element, index) => expect(element).to.contain.text(appOrder[index]))
// Check the top app menu order
- cy.get('.app-menu-main .app-menu-entry').then(elements => {
- const appIDs = elements.map((idx, el) => el.getAttribute('data-app-id')).get()
- expect(appIDs).to.deep.eq(['dashboard', 'files'])
- })
+ navigationHeader.getNavigationEntries()
+ .each((entry, index) => expect(entry).contain.text(appOrder[index]))
})
it('See the reset button is disabled', () => {
@@ -329,15 +246,17 @@ describe('User theming reset app order', () => {
})
it('Change the app order', () => {
+ interceptAppOrder()
cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('be.visible')
cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').click()
cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('not.be.visible')
+ cy.wait('@updateAppOrder')
// Check the app order settings UI
- cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => {
- const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get()
- expect(appIDs).to.deep.eq(['files', 'dashboard'])
- })
+ const appOrder = ['Files', 'Dashboard']
+ // Check the app order settings UI
+ cy.get('[data-cy-app-order] [data-cy-app-order-element]')
+ .each((element, index) => expect(element).to.contain.text(appOrder[index]))
})
it('See the reset button is no longer disabled', () => {
@@ -346,14 +265,25 @@ describe('User theming reset app order', () => {
})
it('Reset the app order', () => {
+ cy.intercept('GET', '/ocs/v2.php/core/navigation/apps').as('loadApps')
+ interceptAppOrder()
cy.get('[data-test-id="btn-apporder-reset"]').click({ force: true })
+
+ cy.wait('@updateAppOrder')
+ .its('request.body')
+ .should('have.property', 'configValue', '[]')
+ cy.wait('@loadApps')
})
it('See the app order is restored', () => {
- cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => {
- const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get()
- expect(appIDs).to.deep.eq(['dashboard', 'files'])
- })
+ const appOrder = ['Dashboard', 'Files']
+ // Check the app order settings UI
+ cy.get('[data-cy-app-order] [data-cy-app-order-element]')
+ .each((element, index) => expect(element).to.contain.text(appOrder[index]))
+
+ // Check the top app menu order
+ navigationHeader.getNavigationEntries()
+ .each((entry, index) => expect(entry).contain.text(appOrder[index]))
})
it('See the reset button is disabled again', () => {
diff --git a/cypress/e2e/theming/user-background.cy.ts b/cypress/e2e/theming/user-settings_background.cy.ts
index cdf3ef59f4d..8abcb5bace1 100644
--- a/cypress/e2e/theming/user-background.cy.ts
+++ b/cypress/e2e/theming/user-settings_background.cy.ts
@@ -1,27 +1,11 @@
/**
- * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { User } from '@nextcloud/cypress'
-import { defaultPrimary, defaultBackground, pickRandomColor, validateBodyThemingCss } from './themingUtils'
+import { defaultPrimary, defaultBackground, validateBodyThemingCss } from './themingUtils'
+import { NavigationHeader } from '../../pages/NavigationHeader'
const admin = new User('admin', 'admin')
@@ -80,7 +64,7 @@ describe('User select shipped backgrounds and remove background', function() {
// Validate changed background and primary
cy.wait('@setBackground')
- cy.waitUntil(() => validateBodyThemingCss('#a53c17', background))
+ cy.waitUntil(() => validateBodyThemingCss('#a53c17', background, '#652e11'))
})
it('Select a bright shipped background', function() {
@@ -95,21 +79,21 @@ describe('User select shipped backgrounds and remove background', function() {
// Validate changed background and primary
cy.wait('@setBackground')
- cy.waitUntil(() => validateBodyThemingCss('#869171', background))
+ cy.waitUntil(() => validateBodyThemingCss('#56633d', background, '#dee0d3'))
})
it('Remove background', function() {
- cy.intercept('*/apps/theming/background/custom').as('clearBackground')
+ cy.intercept('*/apps/theming/background/color').as('clearBackground')
// Clear background
- cy.get('[data-user-theming-background-clear]').click()
+ cy.get('[data-user-theming-background-color]').click()
// Set the accessibility state
- cy.get('[data-user-theming-background-clear]').should('have.attr', 'aria-pressed', 'true')
+ cy.get('[data-user-theming-background-color]').should('have.attr', 'aria-pressed', 'true')
// Validate clear background
cy.wait('@clearBackground')
- cy.waitUntil(() => validateBodyThemingCss('#869171', null))
+ cy.waitUntil(() => validateBodyThemingCss('#56633d', null, '#dee0d3'))
})
})
@@ -129,18 +113,18 @@ describe('User select a custom color', function() {
it('Select a custom color', function() {
cy.intercept('*/apps/theming/background/color').as('setColor')
- pickRandomColor()
+ cy.get('[data-user-theming-background-color]').click()
+ cy.get('.color-picker__simple-color-circle').eq(5).click()
// Validate custom colour change
cy.wait('@setColor')
- cy.waitUntil(() => cy.window().then((win) => {
- const primary = getComputedStyle(win.document.body).getPropertyValue('--color-primary')
- return primary !== defaultPrimary && primary !== defaultPrimary
- }))
+ cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, null, '#a5b872'))
})
})
describe('User select a bright custom color and remove background', function() {
+ const navigationHeader = new NavigationHeader()
+
before(function() {
cy.createRandomUser().then((user: User) => {
cy.login(user)
@@ -154,10 +138,11 @@ describe('User select a bright custom color and remove background', function() {
})
it('Remove background', function() {
- cy.intercept('*/apps/theming/background/custom').as('clearBackground')
+ cy.intercept('*/apps/theming/background/color').as('clearBackground')
// Clear background
- cy.get('[data-user-theming-background-clear]').click()
+ cy.get('[data-user-theming-background-color]').click()
+ cy.get('[data-user-theming-background-color]').click()
// Validate clear background
cy.wait('@clearBackground')
@@ -168,7 +153,8 @@ describe('User select a bright custom color and remove background', function() {
cy.intercept('*/apps/theming/background/color').as('setColor')
// Pick one of the bright color preset
- cy.contains('button', 'Change color').click()
+ cy.get('[data-user-theming-background-color]').scrollIntoView()
+ cy.get('[data-user-theming-background-color]').click()
cy.get('.color-picker__simple-color-circle:eq(4)').click()
// Validate custom colour change
@@ -176,12 +162,12 @@ describe('User select a bright custom color and remove background', function() {
})
it('See the header being inverted', function() {
- cy.waitUntil(() => cy.window().then((win) => {
- const firstEntry = win.document.querySelector('.app-menu-main li img')
- if (!firstEntry) {
- return false
- }
- return getComputedStyle(firstEntry).filter === 'invert(1)'
+ cy.waitUntil(() => navigationHeader.getNavigationEntries().find('img').then((el) => {
+ let ret = true
+ el.each(function() {
+ ret = ret && window.getComputedStyle(this).filter === 'invert(1)'
+ })
+ return ret
}))
})
@@ -194,16 +180,16 @@ describe('User select a bright custom color and remove background', function() {
// Validate changed background and primary
cy.wait('@setBackground')
- cy.waitUntil(() => validateBodyThemingCss('#a53c17', background))
+ cy.waitUntil(() => validateBodyThemingCss('#a53c17', background, '#652e11'))
})
it('See the header NOT being inverted this time', function() {
- cy.waitUntil(() => cy.window().then((win) => {
- const firstEntry = win.document.querySelector('.app-menu-main li')
- if (!firstEntry) {
- return false
- }
- return getComputedStyle(firstEntry).filter === 'none'
+ cy.waitUntil(() => navigationHeader.getNavigationEntries().find('img').then((el) => {
+ let ret = true
+ el.each(function() {
+ ret = ret && window.getComputedStyle(this).filter === 'none'
+ })
+ return ret
}))
})
})
@@ -240,15 +226,13 @@ describe('User select a custom background', function() {
// Wait for background to be set
cy.wait('@setBackground')
- cy.waitUntil(() => validateBodyThemingCss('#4c0c04', 'apps/theming/background?v='))
+ cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, 'apps/theming/background?v=', '#2f2221'))
})
})
describe('User changes settings and reload the page', function() {
const image = 'image.jpg'
- const primaryFromImage = '#4c0c04'
-
- let selectedColor = ''
+ const colorFromImage = '#2f2221'
before(function() {
cy.createRandomUser().then((user: User) => {
@@ -280,28 +264,39 @@ describe('User changes settings and reload the page', function() {
// Wait for background to be set
cy.wait('@setBackground')
- cy.waitUntil(() => validateBodyThemingCss(primaryFromImage, 'apps/theming/background?v='))
+ cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, 'apps/theming/background?v=', colorFromImage))
})
it('Select a custom color', function() {
cy.intercept('*/apps/theming/background/color').as('setColor')
- cy.contains('button', 'Change color').click()
+ cy.get('[data-user-theming-background-color]').click()
cy.get('.color-picker__simple-color-circle:eq(5)').click()
+ cy.get('[data-user-theming-background-color]').click()
// Validate clear background
cy.wait('@setColor')
- cy.waitUntil(() => cy.window().then((win) => {
- selectedColor = getComputedStyle(win.document.body).getPropertyValue('--color-primary')
- return selectedColor !== primaryFromImage
- }))
+ cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, null, '#a5b872'))
+ })
+
+ it('Select a custom primary color', function() {
+ cy.intercept('/ocs/v2.php/apps/provisioning_api/api/v1/config/users/theming/primary_color').as('setPrimaryColor')
+
+ cy.get('[data-user-theming-primary-color-trigger]').scrollIntoView()
+ cy.get('[data-user-theming-primary-color-trigger]').click()
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.wait(500)
+ cy.get('.color-picker__simple-color-circle').should('be.visible')
+ cy.get('.color-picker__simple-color-circle:eq(2)').click()
+ cy.get('[data-user-theming-primary-color-trigger]').click()
+
+ // Validate clear background
+ cy.wait('@setPrimaryColor')
+ cy.waitUntil(() => validateBodyThemingCss('#c98879', null, '#a5b872'))
})
it('Reload the page and validate persistent changes', function() {
cy.reload()
- cy.waitUntil(() => validateBodyThemingCss(selectedColor, 'apps/theming/background?v='))
-
- // validate accessibility state
- cy.get('[data-user-theming-background-custom]').should('have.attr', 'aria-pressed', 'true')
+ cy.waitUntil(() => validateBodyThemingCss('#c98879', null, '#a5b872'))
})
})
diff --git a/cypress/fixtures/testapp/appinfo/routes.php b/cypress/fixtures/testapp/appinfo/routes.php
index cd54acdc083..b5471c5a0b2 100644
--- a/cypress/fixtures/testapp/appinfo/routes.php
+++ b/cypress/fixtures/testapp/appinfo/routes.php
@@ -1,9 +1,10 @@
<?php
declare(strict_types=1);
-// SPDX-FileCopyrightText: Ferdinand Thiessen <opensource@fthiessen.de>
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
return [
'routes' => [
['name' => 'page#index', 'url' => '/', 'verb' => 'GET'],
diff --git a/cypress/fixtures/testapp/img/app.svg b/cypress/fixtures/testapp/img/app.svg
index fe370f80daf..42b64b58d32 100644
--- a/cypress/fixtures/testapp/img/app.svg
+++ b/cypress/fixtures/testapp/img/app.svg
@@ -1,4 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
diff --git a/cypress/fixtures/testapp/lib/AppInfo/Application.php b/cypress/fixtures/testapp/lib/AppInfo/Application.php
index 7647889f784..8ca8f3ef527 100644
--- a/cypress/fixtures/testapp/lib/AppInfo/Application.php
+++ b/cypress/fixtures/testapp/lib/AppInfo/Application.php
@@ -1,9 +1,10 @@
<?php
declare(strict_types=1);
-// SPDX-FileCopyrightText: Ferdinand Thiessen <opensource@fthiessen.de>
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
namespace OCA\TestApp\AppInfo;
use OCP\AppFramework\App;
diff --git a/cypress/fixtures/testapp/lib/Controller/PageController.php b/cypress/fixtures/testapp/lib/Controller/PageController.php
index 2a9dec885a7..e7812fa1046 100644
--- a/cypress/fixtures/testapp/lib/Controller/PageController.php
+++ b/cypress/fixtures/testapp/lib/Controller/PageController.php
@@ -1,13 +1,16 @@
<?php
declare(strict_types=1);
-// SPDX-FileCopyrightText: Ferdinand Thiessen <opensource@fthiessen.de>
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
namespace OCA\TestApp\Controller;
use OCA\TestApp\AppInfo\Application;
use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\IRequest;
@@ -16,10 +19,8 @@ class PageController extends Controller {
parent::__construct(Application::APP_ID, $request);
}
- /**
- * @NoAdminRequired
- * @NoCSRFRequired
- */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
public function index(): TemplateResponse {
return new TemplateResponse(Application::APP_ID, 'main');
}
diff --git a/cypress/fixtures/testapp/templates/main.php b/cypress/fixtures/testapp/templates/main.php
index abe4bf041de..2fdc4ddb780 100644
--- a/cypress/fixtures/testapp/templates/main.php
+++ b/cypress/fixtures/testapp/templates/main.php
@@ -1,6 +1,8 @@
<?php
declare(strict_types=1);
-// SPDX-FileCopyrightText: Ferdinand Thiessen <opensource@fthiessen.de>
-// SPDX-License-Identifier: AGPL-3.0-or-later
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
?>
<div id="content"></div>
diff --git a/cypress/pages/FilesFilters.ts b/cypress/pages/FilesFilters.ts
new file mode 100644
index 00000000000..036530f1e8a
--- /dev/null
+++ b/cypress/pages/FilesFilters.ts
@@ -0,0 +1,34 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * Page object model for the files filters
+ */
+export class FilesFilterPage {
+
+ filterContainter() {
+ return cy.get('[data-cy-files-filters]')
+ }
+
+ activeFiltersList() {
+ return cy.findByRole('list', { name: 'Active filters' })
+ }
+
+ activeFilters() {
+ return this.activeFiltersList().findAllByRole('listitem')
+ }
+
+ removeFilter(name: string | RegExp) {
+ const el = typeof name === 'string'
+ ? this.activeFilters().should('contain.text', name)
+ : this.activeFilters().should('match', name)
+ el.should('exist')
+ // click the button
+ el.findByRole('button', { name: 'Remove filter' })
+ .should('exist')
+ .click({ force: true })
+ }
+
+}
diff --git a/cypress/pages/FilesNavigation.ts b/cypress/pages/FilesNavigation.ts
new file mode 100644
index 00000000000..1be11231bad
--- /dev/null
+++ b/cypress/pages/FilesNavigation.ts
@@ -0,0 +1,46 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * Page object model for the files app navigation
+ */
+export class FilesNavigationPage {
+
+ navigation() {
+ return cy.findByRole('navigation', { name: 'Files' })
+ }
+
+ searchInput() {
+ return this.navigation().findByRole('searchbox')
+ }
+
+ searchScopeTrigger() {
+ return this.navigation().findByRole('button', { name: /search scope options/i })
+ }
+
+ /**
+ * Only available after clicking on the search scope trigger
+ */
+ searchScopeMenu() {
+ return cy.findByRole('menu', { name: /search scope options/i })
+ }
+
+ searchClearButton() {
+ return this.navigation().findByRole('button', { name: /clear search/i })
+ }
+
+ settingsToggle() {
+ return this.navigation().findByRole('link', { name: 'Files settings' })
+ }
+
+ views() {
+ return this.navigation().findByRole('list', { name: 'Views' })
+ }
+
+ quota() {
+ return this.navigation().find('[data-cy-files-navigation-settings-quota]')
+ }
+
+}
diff --git a/cypress/pages/NavigationHeader.ts b/cypress/pages/NavigationHeader.ts
new file mode 100644
index 00000000000..5441b75de88
--- /dev/null
+++ b/cypress/pages/NavigationHeader.ts
@@ -0,0 +1,58 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * Page object model for the Nextcloud navigation header
+ */
+export class NavigationHeader {
+
+ /**
+ * Locator of the header bar wrapper
+ */
+ header() {
+ return cy.get('header#header')
+ }
+
+ /**
+ * Locator for the logo navigation entry (entry redirects to default app)
+ */
+ logo() {
+ return this.header()
+ .find('#nextcloud')
+ }
+
+ /**
+ * Locator of the app navigation bar
+ */
+ navigation() {
+ return this.header()
+ .findByRole('navigation', { name: 'Applications menu' })
+ }
+
+ /**
+ * The toggle for the navigation overflow menu
+ */
+ overflowNavigationToggle() {
+ return this.navigation()
+ }
+
+ /**
+ * Get all navigation entries
+ */
+ getNavigationEntries() {
+ return this.navigation()
+ .findAllByRole('listitem')
+ }
+
+ /**
+ * Get the navigation entry for a given app
+ * @param name The app name
+ */
+ getNavigationEntry(name: string) {
+ return this.navigation()
+ .findByRole('listitem', { name })
+ }
+
+}
diff --git a/cypress/pages/UnifiedSearch.ts b/cypress/pages/UnifiedSearch.ts
new file mode 100644
index 00000000000..f6e0dd2e7a7
--- /dev/null
+++ b/cypress/pages/UnifiedSearch.ts
@@ -0,0 +1,75 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * Page object model for the UnifiedSearch
+ */
+export class UnifiedSearchPage {
+
+ toggleButton() {
+ return cy.findByRole('button', { name: 'Unified search' })
+ }
+
+ globalSearchButton() {
+ return cy.findByRole('button', { name: 'Search everywhere' })
+ }
+
+ localSearchInput() {
+ return cy.findByRole('textbox', { name: 'Search in current app' })
+ }
+
+ globalSearchInput() {
+ return cy.findByRole('textbox', { name: /Search apps, files/ })
+ }
+
+ globalSearchModal() {
+ // TODO: Broken in library
+ // return cy.findByRole('dialog', { name: 'Unified search' })
+ return cy.get('#unified-search')
+ }
+
+ // functions
+
+ openLocalSearch() {
+ this.toggleButton()
+ .if('visible')
+ .click()
+
+ this.localSearchInput().should('exist').and('not.have.css', 'display', 'none')
+ }
+
+ /**
+ * Type in the local search (must be open before)
+ * Helper because the input field is overlayed by the global-search button -> cypress thinks the input is not visible
+ *
+ * @param text The text to type
+ * @param options Options as for `cy.type()`
+ */
+ typeLocalSearch(text: string, options?: Partial<Omit<Cypress.TypeOptions, 'force'>>) {
+ return this.localSearchInput()
+ .type(text, { ...options, force: true })
+ }
+
+ openGlobalSearch() {
+ this.toggleButton()
+ .if('visible').click()
+
+ this.globalSearchButton()
+ .if('visible').click()
+ }
+
+ closeGlobalSearch() {
+ this.globalSearchModal()
+ .findByRole('button', { name: 'Close' })
+ .click()
+ }
+
+ getResults(category: string | RegExp) {
+ return this.globalSearchModal()
+ .findByRole('list', { name: category })
+ .findAllByRole('listitem')
+ }
+
+}
diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts
index 8d78c3db166..ad486a8a8f7 100644
--- a/cypress/support/commands.ts
+++ b/cypress/support/commands.ts
@@ -1,94 +1,24 @@
/**
- * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-/* eslint-disable n/no-unpublished-import */
-import axios from '@nextcloud/axios'
+// eslint-disable-next-line n/no-extraneous-import
+import axios from 'axios'
import { addCommands, User } from '@nextcloud/cypress'
import { basename } from 'path'
// Add custom commands
+import '@testing-library/cypress/add-commands'
import 'cypress-if'
import 'cypress-wait-until'
addCommands()
-// Register this file's custom commands types
-declare global {
- // eslint-disable-next-line @typescript-eslint/no-namespace
- namespace Cypress {
- interface Chainable<Subject = any> {
- /**
- * Enable or disable a given user
- */
- enableUser(user: User, enable?: boolean): Cypress.Chainable<Cypress.Response<any>>,
-
- /**
- * Upload a file from the fixtures folder to a given user storage.
- * **Warning**: Using this function will reset the previous session
- */
- uploadFile(user: User, fixture?: string, mimeType?: string, target?: string): Cypress.Chainable<void>,
-
- /**
- * Upload a raw content to a given user storage.
- * **Warning**: Using this function will reset the previous session
- */
- uploadContent(user: User, content: Blob, mimeType: string, target: string, mtime?: number): Cypress.Chainable<AxiosResponse>,
-
- /**
- * Create a new directory
- * **Warning**: Using this function will reset the previous session
- */
- mkdir(user: User, target: string): Cypress.Chainable<void>,
-
- /**
- * Set a file as favorite (or remove from favorite)
- */
- setFileAsFavorite(user: User, target: string, favorite?: boolean): Cypress.Chainable<void>,
-
- /**
- * Reset the admin theming entirely.
- * **Warning**: Using this function will reset the previous session
- */
- resetAdminTheming(): Cypress.Chainable<void>,
-
- /**
- * Reset the user theming settings.
- * If provided, will clear session and login as the given user.
- * **Warning**: Providing a user will reset the previous session.
- */
- resetUserTheming(user?: User): Cypress.Chainable<void>,
-
- /**
- * Run an occ command in the docker container.
- */
- runOccCommand(command: string, options?: Partial<Cypress.ExecOptions>): Cypress.Chainable<Cypress.Exec>,
- }
- }
-}
-
const url = (Cypress.config('baseUrl') || '').replace(/\/index.php\/?$/g, '')
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
@@ -115,7 +45,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
@@ -124,12 +54,12 @@ Cypress.Commands.add('enableUser', (user: User, enable = true) => {
*/
Cypress.Commands.add('uploadFile', (user, fixture = 'image.jpg', mimeType = 'image/jpeg', target = `/${fixture}`) => {
// get fixture
- return cy.fixture(fixture, 'base64').then(async file => {
- // convert the base64 string to a blob
- const blob = Cypress.Blob.base64StringToBlob(file, mimeType)
-
- cy.uploadContent(user, blob, mimeType, target)
- })
+ return cy.fixture(fixture, 'base64')
+ .then((file) => (
+ // convert the base64 string to a blob
+ Cypress.Blob.base64StringToBlob(file, mimeType)
+ ))
+ .then((blob) => cy.uploadContent(user, blob, mimeType, target))
})
Cypress.Commands.add('setFileAsFavorite', (user: User, target: string, favorite = true) => {
@@ -168,7 +98,7 @@ Cypress.Commands.add('setFileAsFavorite', (user: User, target: string, favorite
Cypress.Commands.add('mkdir', (user: User, target: string) => {
// eslint-disable-next-line cypress/unsafe-to-chain-command
- cy.clearCookies()
+ return cy.clearCookies()
.then(async () => {
try {
const rootPath = `${Cypress.env('baseUrl')}/remote.php/dav/files/${encodeURIComponent(user.userId)}`
@@ -182,6 +112,7 @@ Cypress.Commands.add('mkdir', (user: User, target: string) => {
},
})
cy.log(`Created directory ${target}`, response)
+ return response
} catch (error) {
cy.log('error', error)
throw new Error('Unable to create directory')
@@ -189,9 +120,32 @@ Cypress.Commands.add('mkdir', (user: User, target: string) => {
})
})
+Cypress.Commands.add('rm', (user: User, target: string) => {
+ // eslint-disable-next-line cypress/unsafe-to-chain-command
+ cy.clearCookies()
+ .then(async () => {
+ try {
+ const rootPath = `${Cypress.env('baseUrl')}/remote.php/dav/files/${encodeURIComponent(user.userId)}`
+ const filePath = target.split('/').map(encodeURIComponent).join('/')
+ const response = await axios({
+ url: `${rootPath}${filePath}`,
+ method: 'DELETE',
+ auth: {
+ username: user.userId,
+ password: user.password,
+ },
+ })
+ cy.log(`delete file or directory ${target}`, response)
+ } catch (error) {
+ cy.log('error', error)
+ throw new Error('Unable to delete file or directory')
+ }
+ })
+})
+
/**
* 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
@@ -286,7 +240,9 @@ Cypress.Commands.add('resetUserTheming', (user?: User) => {
}
})
-Cypress.Commands.add('runOccCommand', (command: string, options?: Partial<Cypress.ExecOptions>) => {
- const env = Object.entries(options?.env ?? {}).map(([name, value]) => `-e '${name}=${value}'`).join(' ')
- return cy.exec(`docker exec --user www-data ${env} nextcloud-cypress-tests-server php ./occ ${command}`, options)
+Cypress.Commands.add('userFileExists', (user: string, path: string) => {
+ user.replaceAll('"', '\\"')
+ path.replaceAll('"', '\\"').replaceAll(/^\/+/gm, '')
+ return cy.runCommand(`stat --printf="%s" "data/${user}/files/${path}"`, { failOnNonZeroExit: true })
+ .then((exec) => Number.parseInt(exec.stdout || '0'))
})
diff --git a/cypress/support/commonUtils.ts b/cypress/support/commonUtils.ts
index ce4556a9349..8d02ace151b 100644
--- a/cypress/support/commonUtils.ts
+++ b/cypress/support/commonUtils.ts
@@ -1,4 +1,11 @@
/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { basename } from 'path'
+
+/**
* Get the header navigation bar
*/
export function getNextcloudHeader() {
@@ -44,8 +51,12 @@ export function installTestApp() {
cy.runOccCommand('-V').then((output) => {
const version = output.stdout.match(/(\d\d+)\.\d+\.\d+/)?.[1]
cy.wrap(version).should('not.be.undefined')
- cy.exec(`docker cp '${testAppPath}' nextcloud-cypress-tests-server:/var/www/html/apps`, { log: true })
- cy.exec(`docker exec nextcloud-cypress-tests-server sed -i -e 's|-version="[0-9]\\+|-version="${version}|g' apps/testapp/appinfo/info.xml`)
+ getContainerName()
+ .then(containerName => {
+ cy.exec(`docker cp '${testAppPath}' ${containerName}:/var/www/html/apps`, { log: true })
+ cy.exec(`docker exec --workdir /var/www/html ${containerName} chown -R www-data:www-data /var/www/html/apps/testapp`)
+ })
+ cy.runCommand(`sed -i -e 's|-version=\\"[0-9]\\+|-version=\\"${version}|g' apps/testapp/appinfo/info.xml`)
cy.runOccCommand('app:enable --force testapp')
})
}
@@ -55,5 +66,15 @@ export function installTestApp() {
*/
export function uninstallTestApp() {
cy.runOccCommand('app:remove testapp', { failOnNonZeroExit: false })
- cy.exec('docker exec nextcloud-cypress-tests-server rm -fr apps/testapp/appinfo/info.xml')
+ cy.runCommand('rm -fr apps/testapp/appinfo/info.xml')
+}
+
+/**
+ *
+ */
+export function getContainerName(): Cypress.Chainable<string> {
+ return cy.exec('pwd')
+ .then(({ stdout }) => {
+ return cy.wrap(`nextcloud-cypress-tests_${basename(stdout).replace(' ', '')}`)
+ })
}
diff --git a/cypress/support/component-index.html b/cypress/support/component-index.html
index ac6e79fd83d..e525b445373 100644
--- a/cypress/support/component-index.html
+++ b/cypress/support/component-index.html
@@ -1,4 +1,8 @@
<!DOCTYPE html>
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<html>
<head>
<meta charset="utf-8">
diff --git a/cypress/support/component.ts b/cypress/support/component.ts
index ea370ae6d06..853609bb4dd 100644
--- a/cypress/support/component.ts
+++ b/cypress/support/component.ts
@@ -1,52 +1,28 @@
/**
- * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
+
+import '@testing-library/cypress/add-commands'
import 'cypress-axe'
+// styles
+import '../../apps/theming/css/default.css'
+import '../../core/css/server.css'
+
/* eslint-disable */
import { mount } from '@cypress/vue2'
-// Example use:
-// cy.mount(MyComponent)
-Cypress.Commands.add('mount', (component, optionsOrProps) => {
- let instance = null
- const oldMounted = component?.mounted || false
+Cypress.Commands.add('mount', (component, options = {}) => {
+ // Setup options object
+ options.extensions = options.extensions || {}
+ options.extensions.plugins = options.extensions.plugins || []
+ options.extensions.components = options.extensions.components || {}
- // Override the mounted method to expose
- // the component instance to cypress
- component.mounted = function() {
- // eslint-disable-next-line
- instance = this
- if (oldMounted) {
- oldMounted.call(instance)
- }
- }
-
- // Expose the component with cy.get('@component')
- return mount(component, optionsOrProps).then(() => {
- return cy.wrap(instance).as('component')
- })
+ return mount(component, options)
})
-Cypress.Commands.add('mockInitialState', (app: string, key: string, value: any) => {
+Cypress.Commands.add('mockInitialState', (app: string, key: string, value: unknown) => {
cy.document().then(($document) => {
const input = $document.createElement('input')
input.setAttribute('type', 'hidden')
@@ -57,8 +33,13 @@ Cypress.Commands.add('mockInitialState', (app: string, key: string, value: any)
})
Cypress.Commands.add('unmockInitialState', (app?: string, key?: string) => {
+ cy.window().then(($window) => {
+ // @ts-expect-error internal value
+ delete $window._nc_initial_state
+ })
+
cy.document().then(($document) => {
$document.querySelectorAll('body > input[type="hidden"]' + (app ? `[id="initial-state-${app}-${key}"]` : ''))
.forEach((node) => $document.body.removeChild(node))
})
-}) \ No newline at end of file
+})
diff --git a/cypress/support/cypress-component.d.ts b/cypress/support/cypress-component.d.ts
new file mode 100644
index 00000000000..735db871e35
--- /dev/null
+++ b/cypress/support/cypress-component.d.ts
@@ -0,0 +1,17 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { mount } from '@cypress/vue2'
+
+declare global {
+ // eslint-disable-next-line @typescript-eslint/no-namespace
+ namespace Cypress {
+ interface Chainable {
+ mount: typeof mount
+ mockInitialState: (app: string, key: string, value: unknown) => Cypress.Chainable<void>
+ unmockInitialState: (app?: string, key?: string) => Cypress.Chainable<void>
+ }
+ }
+}
diff --git a/cypress/support/cypress-e2e.d.ts b/cypress/support/cypress-e2e.d.ts
new file mode 100644
index 00000000000..97385ac070b
--- /dev/null
+++ b/cypress/support/cypress-e2e.d.ts
@@ -0,0 +1,64 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+// eslint-disable-next-line n/no-extraneous-import
+import type { AxiosResponse } from 'axios'
+
+declare global {
+ // eslint-disable-next-line @typescript-eslint/no-namespace
+ namespace Cypress {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars
+ interface Chainable<Subject = any> {
+ /**
+ * Enable or disable a given user
+ */
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ enableUser(user: User, enable?: boolean): Cypress.Chainable<Cypress.Response<any>>,
+
+ /**
+ * Upload a file from the fixtures folder to a given user storage.
+ * **Warning**: Using this function will reset the previous session
+ */
+ uploadFile(user: User, fixture?: string, mimeType?: string, target?: string): Cypress.Chainable<AxiosResponse>,
+
+ /**
+ * Upload a raw content to a given user storage.
+ * **Warning**: Using this function will reset the previous session
+ */
+ uploadContent(user: User, content: Blob, mimeType: string, target: string, mtime?: number): Cypress.Chainable<AxiosResponse>,
+
+ /**
+ * Delete a file or directory
+ */
+ rm(user: User, target: string): Cypress.Chainable<AxiosResponse>,
+
+ /**
+ * Create a new directory
+ * **Warning**: Using this function will reset the previous session
+ */
+ mkdir(user: User, target: string): Cypress.Chainable<AxiosResponse>,
+
+ /**
+ * Set a file as favorite (or remove from favorite)
+ */
+ setFileAsFavorite(user: User, target: string, favorite?: boolean): Cypress.Chainable<void>,
+
+ /**
+ * Reset the admin theming entirely.
+ * **Warning**: Using this function will reset the previous session
+ */
+ resetAdminTheming(): Cypress.Chainable<void>,
+
+ /**
+ * Reset the user theming settings.
+ * If provided, will clear session and login as the given user.
+ * **Warning**: Providing a user will reset the previous session.
+ */
+ resetUserTheming(user?: User): Cypress.Chainable<void>,
+
+ userFileExists(user: string, path: string): Cypress.Chainable<number>
+ }
+ }
+}
diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts
index ff888fe7d5a..65fb4b2a110 100644
--- a/cypress/support/e2e.ts
+++ b/cypress/support/e2e.ts
@@ -1,27 +1,14 @@
/**
- * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import 'cypress-axe'
import './commands.ts'
+// Remove with Node 22
+// Ensure that we can use `Promise.withResolvers` - works in browser but on Node we need Node 22+
+import 'core-js/actual/promise/with-resolvers.js'
+
// Fix ResizeObserver loop limit exceeded happening in Cypress only
// @see https://github.com/cypress-io/cypress/issues/20341
Cypress.on('uncaught:exception', err => !err.message.includes('ResizeObserver loop limit exceeded'))
diff --git a/cypress/support/utils/assertions.ts b/cypress/support/utils/assertions.ts
new file mode 100644
index 00000000000..08b93b32e86
--- /dev/null
+++ b/cypress/support/utils/assertions.ts
@@ -0,0 +1,40 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { ZipReader } from '@zip.js/zip.js'
+/**
+ * Assert that a file contains a list of expected files
+ * @param expectedFiles List of expected filenames
+ * @example
+ * ```js
+ * cy.readFile('file', null, { ... })
+ * .should(zipFileContains(['file.txt']))
+ * ```
+ */
+export function zipFileContains(expectedFiles: string[]) {
+ return async (buffer: Buffer) => {
+ 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).sort()
+ console.info('Zip contains entries:', entries)
+ expect(entries).to.deep.equal(expectedFiles.sort())
+ }
+}
+
+/**
+ * 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)
+}
diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json
index c5dcff95d70..510c64d633c 100644
--- a/cypress/tsconfig.json
+++ b/cypress/tsconfig.json
@@ -1,7 +1,14 @@
{
"extends": "../tsconfig.json",
- "include": ["./**/*.ts"],
+ "include": ["./**/*.ts", "../**/*.cy.ts", "./cypress-e2e.d.ts", "./cypress-component.d.ts"],
+ "exclude": [],
"compilerOptions": {
- "types": ["cypress", "cypress-axe", "cypress-wait-until", "dockerode"],
+ "types": [
+ "@testing-library/cypress",
+ "cypress",
+ "cypress-axe",
+ "cypress-wait-until",
+ "dockerode"
+ ],
}
}