aboutsummaryrefslogtreecommitdiffstats
path: root/cypress
diff options
context:
space:
mode:
Diffstat (limited to 'cypress')
-rw-r--r--cypress/dockerNode.ts357
-rw-r--r--cypress/e2e/core-utils.ts90
-rw-r--r--cypress/e2e/core/404-error.cy.ts19
-rw-r--r--cypress/e2e/core/header_access-levels.cy.ts101
-rw-r--r--cypress/e2e/core/header_contacts-menu.cy.ts137
-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.ts324
-rw-r--r--cypress/e2e/files/LivePhotosUtils.ts104
-rw-r--r--cypress/e2e/files/drag-n-drop.cy.ts140
-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.ts177
-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-selection.cy.ts77
-rw-r--r--cypress/e2e/files/files-settings.cy.ts158
-rw-r--r--cypress/e2e/files/files-sidebar.cy.ts126
-rw-r--r--cypress/e2e/files/files-sorting.cy.ts330
-rw-r--r--cypress/e2e/files/files-xml-regression.cy.ts51
-rw-r--r--cypress/e2e/files/files.cy.ts58
-rw-r--r--cypress/e2e/files/live_photos.cy.ts172
-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/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.ts90
-rw-r--r--cypress/e2e/files_versions/version_creation.cy.ts47
-rw-r--r--cypress/e2e/files_versions/version_cross_share_move_and_copy.cy.ts102
-rw-r--r--cypress/e2e/files_versions/version_deletion.cy.ts98
-rw-r--r--cypress/e2e/files_versions/version_download.cy.ts94
-rw-r--r--cypress/e2e/files_versions/version_expiration.cy.ts56
-rw-r--r--cypress/e2e/files_versions/version_naming.cy.ts133
-rw-r--r--cypress/e2e/files_versions/version_restoration.cy.ts116
-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.ts152
-rw-r--r--cypress/e2e/login/webauth.cy.ts152
-rw-r--r--cypress/e2e/settings/access-levels.cy.ts65
-rw-r--r--cypress/e2e/settings/apps.cy.ts156
-rw-r--r--cypress/e2e/settings/personal-info.cy.ts448
-rw-r--r--cypress/e2e/settings/users-group-admin.cy.ts186
-rw-r--r--cypress/e2e/settings/users.cy.ts129
-rw-r--r--cypress/e2e/settings/usersUtils.ts90
-rw-r--r--cypress/e2e/settings/users_columns.cy.ts94
-rw-r--r--cypress/e2e/settings/users_disable.cy.ts79
-rw-r--r--cypress/e2e/settings/users_groups.cy.ts291
-rw-r--r--cypress/e2e/settings/users_manager.cy.ts121
-rw-r--r--cypress/e2e/settings/users_modify.cy.ts225
-rw-r--r--cypress/e2e/systemtags/admin-settings.cy.ts121
-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.ts157
-rw-r--r--cypress/e2e/theming/admin-settings.cy.ts595
-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.ts109
-rw-r--r--cypress/e2e/theming/user-settings_app-order.cy.ts292
-rw-r--r--cypress/e2e/theming/user-settings_background.cy.ts302
-rw-r--r--cypress/fixtures/image.jpgbin0 -> 1538878 bytes
-rw-r--r--cypress/fixtures/testapp/appinfo/info.xml31
-rw-r--r--cypress/fixtures/testapp/appinfo/routes.php12
-rw-r--r--cypress/fixtures/testapp/img/app.svg60
-rw-r--r--cypress/fixtures/testapp/lib/AppInfo/Application.php18
-rw-r--r--cypress/fixtures/testapp/lib/Controller/PageController.php27
-rw-r--r--cypress/fixtures/testapp/templates/main.php8
-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.ts248
-rw-r--r--cypress/support/commonUtils.ts80
-rw-r--r--cypress/support/component-index.html16
-rw-r--r--cypress/support/component.ts45
-rw-r--r--cypress/support/cypress-component.d.ts17
-rw-r--r--cypress/support/cypress-e2e.d.ts64
-rw-r--r--cypress/support/e2e.ts15
-rw-r--r--cypress/support/utils/assertions.ts40
-rw-r--r--cypress/tsconfig.json14
113 files changed, 14552 insertions, 0 deletions
diff --git a/cypress/dockerNode.ts b/cypress/dockerNode.ts
new file mode 100644
index 00000000000..6e21b33101c
--- /dev/null
+++ b/cypress/dockerNode.ts
@@ -0,0 +1,357 @@
+/**
+ * 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 */
+/* eslint-disable n/no-extraneous-import */
+
+import Docker from 'dockerode'
+import waitOn from 'wait-on'
+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_${basename(process.cwd()).replace(' ', '')}`
+const SERVER_IMAGE = 'ghcr.io/nextcloud/continuous-integration-shallow-server'
+
+/**
+ * Start the testing container
+ *
+ * @param {string} branch the branch of your current work
+ */
+export const startNextcloud = async function(branch: string = getCurrentGitBranch()): Promise<any> {
+
+ try {
+ try {
+ // Pulling images
+ console.log('\nPulling images... ⏳')
+ await new Promise((resolve, reject): any => docker.pull(SERVER_IMAGE, (err, stream) => {
+ if (err) {
+ reject(err)
+ }
+ if (stream === null) {
+ reject(new Error('Could not connect to docker, ensure docker is running.'))
+ return
+ }
+
+ // https://github.com/apocas/dockerode/issues/357
+ docker.modem.followProgress(stream, onFinished)
+
+ function onFinished(err) {
+ if (!err) {
+ resolve(true)
+ return
+ }
+ 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')
+ throw e
+ }
+
+ // Remove old container if exists
+ console.log('\nChecking running containers... 🔍')
+ try {
+ const oldContainer = docker.getContainer(CONTAINER_NAME)
+ const oldContainerData = await oldContainer.inspect()
+ if (oldContainerData) {
+ console.log('├─ Existing running container found')
+ console.log('├─ Removing... ⏳')
+ // Forcing any remnants to be removed just in case
+ await oldContainer.remove({ force: true })
+ console.log('└─ Done')
+ }
+ } catch (error) {
+ console.log('└─ None found!')
+ }
+
+ // Starting container
+ console.log('\nStarting Nextcloud container... 🚀')
+ console.log(`├─ Using branch '${branch}'`)
+ const container = await docker.createContainer({
+ Image: SERVER_IMAGE,
+ name: CONTAINER_NAME,
+ HostConfig: {
+ 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)
+
+ console.log(`├─ Nextcloud container's IP is ${ip} 🌏`)
+ return ip
+ } catch (err) {
+ console.log('└─ Unable to start the container 🛑')
+ console.log('\n', err, '\n')
+ stopNextcloud()
+ throw new Error('Unable to start the container')
+ }
+}
+
+/**
+ * Configure Nextcloud
+ */
+export const configureNextcloud = async function() {
+ console.log('\nConfiguring nextcloud...')
+ const container = docker.getContainer(CONTAINER_NAME)
+ await runExec(container, ['php', 'occ', '--version'], true)
+
+ // Be consistent for screenshots
+ await runExec(container, ['php', 'occ', 'config:system:set', 'default_language', '--value', 'en'], true)
+ await runExec(container, ['php', 'occ', 'config:system:set', 'force_language', '--value', 'en'], true)
+ 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 🎉')
+}
+
+/**
+ * Applying local changes to the container
+ * Only triggered if we're not in CI. Otherwise the
+ * continuous-integration-shallow-server image will
+ * already fetch the proper branch.
+ */
+export const applyChangesToNextcloud = async function() {
+ console.log('\nApply local changes to nextcloud...')
+
+ const htmlPath = '/var/www/html'
+ const folderPaths = [
+ './3rdparty',
+ './apps',
+ './core',
+ './dist',
+ './lib',
+ './ocs',
+ './ocs-provider',
+ './resources',
+ './tests',
+ './console.php',
+ './cron.php',
+ './index.php',
+ './occ',
+ './public.php',
+ './remote.php',
+ './status.php',
+ './version.php',
+ ].filter((folderPath) => {
+ const fullPath = path.resolve(__dirname, '..', folderPath)
+
+ 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 = createTar({ gzip: false }, folderPaths)
+ await container.putArchive(serverTar, {
+ path: htmlPath,
+ })
+
+ // Making sure we have the proper permissions
+ await runExec(container, ['chown', '-R', 'www-data:www-data', htmlPath], false, 'root')
+
+ console.log('└─ Changes applied successfully 🎉')
+}
+
+/**
+ * Force stop the testing container
+ */
+export const stopNextcloud = async function() {
+ try {
+ const container = docker.getContainer(CONTAINER_NAME)
+ console.log('Stopping Nextcloud container...')
+ container.remove({ force: true })
+ console.log('└─ Nextcloud container removed 🥀')
+ } catch (err) {
+ console.log(err)
+ }
+}
+
+/**
+ * Get the testing container's IP
+ *
+ * @param {Docker.Container} container the container to get the IP from
+ */
+export const getContainerIP = async function(
+ container = docker.getContainer(CONTAINER_NAME),
+): Promise<string> {
+ let ip = ''
+ let tries = 0
+ while (ip === '' && tries < 10) {
+ tries++
+
+ container.inspect(function(err, data) {
+ if (err) {
+ throw err
+ }
+
+ if (data?.HostConfig.PortBindings?.['80/tcp']?.[0]?.HostPort) {
+ ip = `localhost:${data.HostConfig.PortBindings['80/tcp'][0].HostPort}`
+ } else {
+ ip = data?.NetworkSettings?.IPAddress || ''
+ }
+ })
+
+ if (ip !== '') {
+ break
+ }
+
+ await sleep(1000 * tries)
+ }
+
+ return ip
+}
+
+// Would be simpler to start the container from cypress.config.ts,
+// but when checking out different branches, it can take a few seconds
+// Until we can properly configure the baseUrl retry intervals,
+// We need to make sure the server is already running before cypress
+// https://github.com/cypress-io/cypress/issues/22676
+export const waitOnNextcloud = async function(ip: string) {
+ console.log('├─ Waiting for Nextcloud to be ready... ⏳')
+ await waitOn({
+ resources: [`http://${ip}/index.php`],
+ // wait for nextcloud to be up and return any non error status
+ validateStatus: (status) => status >= 200 && status < 400,
+ // timout in ms
+ timeout: 5 * 60 * 1000,
+ // timeout for a single HTTP request
+ httpTimeout: 60 * 1000,
+ })
+ console.log('└─ Done')
+}
+
+const runExec = async function(
+ container: Docker.Container,
+ 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)
+ }
+ if (stream) {
+ stream.setEncoding('utf-8')
+ stream.on('data', str => {
+ 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(output))
+ }
+ })
+ })
+}
+
+const sleep = function(milliseconds: number) {
+ return new Promise((resolve) => setTimeout(resolve, milliseconds))
+}
+
+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
new file mode 100644
index 00000000000..4756836387a
--- /dev/null
+++ b/cypress/e2e/core-utils.ts
@@ -0,0 +1,90 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * Get the unified search modal (if open)
+ */
+export function getUnifiedSearchModal() {
+ return cy.get('#unified-search')
+}
+
+/**
+ * Open the unified search modal
+ */
+export function openUnifiedSearch() {
+ cy.get('button[aria-label="Unified search"]').click({ force: true })
+ // wait for it to be open
+ getUnifiedSearchModal().should('be.visible')
+}
+
+/**
+ * Close the unified search modal
+ */
+export function closeUnifiedSearch() {
+ getUnifiedSearchModal().find('button[aria-label="Close"]').click({ force: true })
+ getUnifiedSearchModal().should('not.be.visible')
+}
+
+/**
+ * Get the input field of the unified search
+ */
+export function getUnifiedSearchInput() {
+ return getUnifiedSearchModal().find('[data-cy-unified-search-input]')
+}
+
+export enum UnifiedSearchFilter {
+ FilterCurrentView = 'current-view',
+ Places = 'places',
+ People = 'people',
+ Date = 'date',
+}
+
+/**
+ * Get a filter action from the unified search
+ * @param filter The filter to get
+ */
+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
new file mode 100644
index 00000000000..b0e9ab8bac1
--- /dev/null
+++ b/cypress/e2e/core/header_access-levels.cy.ts
@@ -0,0 +1,101 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { User } from '@nextcloud/cypress'
+import { clearState, getNextcloudUserMenu, getNextcloudUserMenuToggle } from '../../support/commonUtils'
+
+const admin = new User('admin', 'admin')
+
+describe('Header: Ensure regular users do not have admin settings in the Settings menu', { testIsolation: true }, () => {
+ beforeEach(() => {
+ clearState()
+ })
+
+ it('Regular users can see basic items in the Settings menu', () => {
+ // Given I am logged in
+ cy.createRandomUser().then(($user) => {
+ cy.login($user)
+ cy.visit('/')
+ })
+ // I open the settings menu
+ getNextcloudUserMenuToggle().click()
+
+ getNextcloudUserMenu().find('ul').within(($el) => {
+ // I see the settings menu is open
+ cy.wrap($el).should('be.visible')
+
+ // I see that the Settings menu has only 6 items
+ cy.get('li').should('have.length', 6)
+ // I see that the "View profile" item in the Settings menu is shown
+ cy.contains('li', 'View profile').should('be.visible')
+ // I see that the "Set status" item in the Settings menu is shown
+ cy.contains('li', 'Set status').should('be.visible')
+ // I see that the "Appearance and accessibility" item in the Settings menu is shown
+ cy.contains('li', 'Appearance and accessibility').should('be.visible')
+ // I see that the "Settings" item in the Settings menu is shown
+ cy.contains('li', 'Settings').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
+ cy.contains('li', 'Log out').should('be.visible')
+ })
+ })
+
+ it('Regular users cannot see admin-level items in the Settings menu', () => {
+ // Given I am logged in
+ cy.createRandomUser().then(($user) => {
+ cy.login($user)
+ cy.visit('/')
+ })
+ // I open the settings menu
+ getNextcloudUserMenuToggle().click()
+
+ getNextcloudUserMenu().find('ul').within(($el) => {
+ // I see the settings menu is open
+ cy.wrap($el).should('be.visible')
+
+ // I see that the "Users" item in the Settings menu is NOT shown
+ cy.contains('li', 'Users').should('not.exist')
+ // I see that the "Administration settings" item in the Settings menu is NOT shown
+ cy.contains('li', 'Administration settings').should('not.exist')
+ cy.get('#admin_settings').should('not.exist')
+ })
+ })
+
+ it('Admin users can see admin-level items in the Settings menu', () => {
+ // Given I am logged in
+ cy.login(admin)
+ cy.visit('/')
+
+ // I open the settings menu
+ getNextcloudUserMenuToggle().click()
+
+ getNextcloudUserMenu().find('ul').within(($el) => {
+ // I see the settings menu is open
+ cy.wrap($el).should('be.visible')
+
+ // I see that the Settings menu has only 9 items
+ cy.get('li').should('have.length', 9)
+ // I see that the "Set status" item in the Settings menu is shown
+ cy.contains('li', 'View profile').should('be.visible')
+ // I see that the "Set status" item in the Settings menu is shown
+ cy.contains('li', 'Set status').should('be.visible')
+ // I see that the "Appearance and accessibility" item in the Settings menu is shown
+ cy.contains('li', 'Appearance and accessibility').should('be.visible')
+ // I see that the "Personal Settings" item in the Settings menu is shown
+ cy.contains('li', 'Personal settings').should('be.visible')
+ // I see that the "Administration settings" item in the Settings menu is shown
+ cy.contains('li', 'Administration settings').should('be.visible')
+ // 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', '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
+ cy.contains('li', 'Log out').should('be.visible')
+ })
+ })
+})
diff --git a/cypress/e2e/core/header_contacts-menu.cy.ts b/cypress/e2e/core/header_contacts-menu.cy.ts
new file mode 100644
index 00000000000..6279b72a78d
--- /dev/null
+++ b/cypress/e2e/core/header_contacts-menu.cy.ts
@@ -0,0 +1,137 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { User } from '@nextcloud/cypress'
+import { clearState, getNextcloudHeader } from '../../support/commonUtils'
+
+// eslint-disable-next-line n/no-extraneous-import
+import randomString from 'crypto-random-string'
+
+const admin = new User('admin', 'admin')
+
+const getContactsMenu = () => getNextcloudHeader().find('#header-menu-contactsmenu')
+const getContactsMenuToggle = () => getNextcloudHeader().find('#contactsmenu .header-menu__trigger')
+const getContactsSearch = () => getContactsMenu().find('#contactsmenu__menu__search')
+
+describe('Header: Contacts menu', { testIsolation: true }, () => {
+ let user: User
+
+ beforeEach(() => {
+ // clear user and group state
+ clearState()
+ // ensure the contacts menu is not restricted
+ cy.runOccCommand('config:app:set --value no core shareapi_restrict_user_enumeration_to_group')
+ // create a new user for testing the contacts
+ cy.createRandomUser().then(($user) => {
+ user = $user
+ })
+
+ // Given I am logged in as the admin
+ cy.login(admin)
+ cy.visit('/')
+ })
+
+ it('Other users are seen in the contacts menu', () => {
+ // When I open the Contacts menu
+ getContactsMenuToggle().click()
+ // I see that the Contacts menu is shown
+ getContactsMenu().should('exist')
+ // I see that the contact user in the Contacts menu is shown
+ getContactsMenu().contains('li.contact', user.userId).should('be.visible')
+ // I see that the contact "admin" in the Contacts menu is not shown
+ getContactsMenu().contains('li.contact', admin.userId).should('not.exist')
+ })
+
+ it('Just added users are seen in the contacts menu', () => {
+ // I create a new user
+ const newUserName = randomString(7)
+ // we can not use createRandomUser as it will invalidate the session
+ cy.runOccCommand(`user:add --password-from-env '${newUserName}'`, { env: { OC_PASS: '1234567' } })
+ // I open the Contacts menu
+ getContactsMenuToggle().click()
+ // I see that the Contacts menu is shown
+ getContactsMenu().should('exist')
+ // I see that the contact user in the Contacts menu is shown
+ getContactsMenu().contains('li.contact', user.userId).should('be.visible')
+ // I see that the contact of the new user in the Contacts menu is shown
+ getContactsMenu().contains('li.contact', newUserName).should('be.visible')
+ // I see that the contact "admin" in the Contacts menu is not shown
+ getContactsMenu().contains('li.contact', admin.userId).should('not.exist')
+ })
+
+ it('Search for other users in the contacts menu', () => {
+ cy.createRandomUser().then((otherUser) => {
+ // Given I am logged in as the admin
+ cy.login(admin)
+ cy.visit('/')
+
+ // I open the Contacts menu
+ getContactsMenuToggle().click()
+ // I see that the Contacts menu is shown
+ getContactsMenu().should('exist')
+ // I see that the contact user in the Contacts menu is shown
+ getContactsMenu().contains('li.contact', user.userId).should('be.visible')
+ // I see that the contact of the new user in the Contacts menu is shown
+ getContactsMenu().contains('li.contact', otherUser.userId).should('be.visible')
+
+ // I see that the Contacts menu search input is shown
+ getContactsSearch().should('exist')
+ // I search for the otherUser
+ getContactsSearch().type(otherUser.userId)
+ // I see that the contact otherUser in the Contacts menu is shown
+ getContactsMenu().contains('li.contact', otherUser.userId).should('be.visible')
+ // I see that the contact user in the Contacts menu is not shown
+ getContactsMenu().contains('li.contact', user.userId).should('not.exist')
+ // I see that the contact "admin" in the Contacts menu is not shown
+ getContactsMenu().contains('li.contact', admin.userId).should('not.exist')
+ })
+ })
+
+ it('Search for unknown users in the contacts menu', () => {
+ // I open the Contacts menu
+ getContactsMenuToggle().click()
+ // I see that the Contacts menu is shown
+ getContactsMenu().should('exist')
+ // I see that the contact user in the Contacts menu is shown
+ getContactsMenu().contains('li.contact', user.userId).should('be.visible')
+
+ // I see that the Contacts menu search input is shown
+ getContactsSearch().should('exist')
+ // I search for an unknown user
+ getContactsSearch().type('surely-unknown-user')
+ // I see that the no results message in the Contacts menu is shown
+ getContactsMenu().find('ul li').should('have.length', 0)
+ // I see that the contact user in the Contacts menu is not shown
+ getContactsMenu().contains('li.contact', user.userId).should('not.exist')
+ // I see that the contact "admin" in the Contacts menu is not shown
+ getContactsMenu().contains('li.contact', admin.userId).should('not.exist')
+ })
+
+ it('Users from other groups are not seen in the contacts menu when autocompletion is restricted within the same group', () => {
+ // I enable restricting username autocompletion to groups
+ cy.runOccCommand('config:app:set --value yes core shareapi_restrict_user_enumeration_to_group')
+ // I open the Contacts menu
+ getContactsMenuToggle().click()
+ // I see that the Contacts menu is shown
+ getContactsMenu().should('exist')
+ // I see that the contact user in the Contacts menu is not shown
+ getContactsMenu().contains('li.contact', user.userId).should('not.exist')
+ // I see that the contact "admin" in the Contacts menu is not shown
+ getContactsMenu().contains('li.contact', admin.userId).should('not.exist')
+
+ // I close the Contacts menu
+ getContactsMenuToggle().click()
+ // I disable restricting username autocompletion to groups
+ cy.runOccCommand('config:app:set --value no core shareapi_restrict_user_enumeration_to_group')
+ // I open the Contacts menu
+ getContactsMenuToggle().click()
+ // I see that the Contacts menu is shown
+ getContactsMenu().should('exist')
+ // I see that the contact user in the Contacts menu is shown
+ getContactsMenu().contains('li.contact', user.userId).should('be.visible')
+ // I see that the contact "admin" in the Contacts menu is not shown
+ getContactsMenu().contains('li.contact', admin.userId).should('not.exist')
+ })
+})
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
new file mode 100644
index 00000000000..71ea341a7bf
--- /dev/null
+++ b/cypress/e2e/files/FilesUtils.ts
@@ -0,0 +1,324 @@
+/**
+ * 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).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)
+ .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)
+ .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()
+}
+export const triggerInlineActionForFile = (filename: string, actionId: string) => {
+ 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, 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')
+
+ if (dirPath === '/') {
+ // select home folder
+ cy.get('button[title="Home"]').should('be.visible').click()
+ // click move
+ cy.contains('button', 'Move').should('be.visible').click()
+ } else if (dirPath === '.') {
+ // click move
+ cy.contains('button', 'Copy').should('be.visible').click()
+ } else {
+ 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('be.visible').click()
+ }
+
+ cy.wait('@moveFile')
+ })
+}
+
+export const copyFile = (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')
+
+ if (dirPath === '/') {
+ // select home folder
+ cy.get('button[title="Home"]').should('be.visible').click()
+ // click copy
+ cy.contains('button', 'Copy').should('be.visible').click()
+ } else if (dirPath === '.') {
+ // click copy
+ cy.contains('button', 'Copy').should('be.visible').click()
+ } else {
+ const directories = dirPath.split('/')
+ directories.forEach((directory) => {
+ // select the folder
+ cy.get(`[data-filename="${CSS.escape(directory)}"]`).should('be.visible').click()
+ })
+
+ // click copy
+ cy.contains('button', `Copy to ${directories.at(-1)}`).should('be.visible').click()
+ }
+
+ cy.wait('@copyFile')
+ })
+}
+
+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|public)\.php\/dav\/files\//).as('moveFile')
+
+ 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('/')
+ 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('[data-cy-sidebar] .app-sidebar__close').click({ force: true })
+}
+
+export const clickOnBreadcrumbs = (label: string) => {
+ cy.intercept('PROPFIND', /\/remote.php\/dav\//).as('propfind')
+ 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
new file mode 100644
index 00000000000..d8df1938694
--- /dev/null
+++ b/cypress/e2e/files/drag-n-drop.cy.ts
@@ -0,0 +1,140 @@
+/**
+ * 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 }, () => {
+ beforeEach(() => {
+ cy.createRandomUser().then((user) => {
+ cy.login(user)
+ })
+ cy.visit('/apps/files')
+ })
+
+ it('can drop a file', () => {
+ const dataTransfer = new DataTransfer()
+ dataTransfer.items.add(new File([], 'single-file.txt'))
+
+ cy.intercept('PUT', /\/remote.php\/dav\/files\//).as('uploadFile')
+
+ // Make sure the drop notice is not visible
+ cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible')
+
+ // Trigger the drop notice
+ cy.get('main.app-content').trigger('dragover', { dataTransfer })
+ cy.get('[data-cy-files-drag-drop-area]').should('be.visible')
+
+ // Upload drop a file
+ cy.get('[data-cy-files-drag-drop-area]').selectFile({
+ fileName: 'single-file.txt',
+ contents: ['hello '.repeat(1024)],
+ }, { action: 'drag-drop' })
+
+ cy.wait('@uploadFile')
+
+ // Make sure the upload is finished
+ cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible')
+ cy.get('[data-cy-upload-picker] progress').should('not.be.visible')
+ cy.get('@uploadFile.all').should('have.length', 1)
+
+ getRowForFile('single-file.txt').should('be.visible')
+ getRowForFile('single-file.txt').find('[data-cy-files-list-row-size]').should('contain', '6 KB')
+ })
+
+ it('can drop multiple files', () => {
+ const dataTransfer = new DataTransfer()
+ dataTransfer.items.add(new File([], 'first.txt'))
+ dataTransfer.items.add(new File([], 'second.txt'))
+
+ cy.intercept('PUT', /\/remote.php\/dav\/files\//).as('uploadFile')
+
+ // Make sure the drop notice is not visible
+ cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible')
+
+ // Trigger the drop notice
+ cy.get('main.app-content').trigger('dragover', { dataTransfer })
+ cy.get('[data-cy-files-drag-drop-area]').should('be.visible')
+
+ // Upload drop a file
+ cy.get('[data-cy-files-drag-drop-area]').selectFile([
+ {
+ fileName: 'first.txt',
+ contents: ['Hello'],
+ },
+ {
+ fileName: 'second.txt',
+ contents: ['World'],
+ },
+ ], { action: 'drag-drop' })
+
+ cy.wait('@uploadFile')
+
+ // Make sure the upload is finished
+ cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible')
+ cy.get('[data-cy-upload-picker] progress').should('not.be.visible')
+ cy.get('@uploadFile.all').should('have.length', 2)
+
+ getRowForFile('first.txt').should('be.visible')
+ getRowForFile('second.txt').should('be.visible')
+ })
+
+ it('will ignore legacy Folders', () => {
+ cy.window().then((win) => {
+ // Remove the Filesystem API to force the legacy File API
+ // See how cypress mocks the Filesystem API in https://github.com/cypress-io/cypress/blob/74109094a92df3bef073dda15f17194f31850d7d/packages/driver/src/cy/commands/actions/selectFile.ts#L24-L37
+ Object.defineProperty(win.DataTransferItem.prototype, 'getAsEntry', { get: undefined })
+ Object.defineProperty(win.DataTransferItem.prototype, 'webkitGetAsEntry', { get: undefined })
+ })
+
+ const dataTransfer = new DataTransfer()
+ dataTransfer.items.add(new File([], 'first.txt'))
+ dataTransfer.items.add(new File([], 'second.txt'))
+
+ // Legacy File API (not FileSystem API), will treat Folders as Files
+ // with empty type and empty content
+ dataTransfer.items.add(new File([], 'Foo', { type: 'httpd/unix-directory' }))
+ dataTransfer.items.add(new File([], 'Bar'))
+
+ cy.intercept('PUT', /\/remote.php\/dav\/files\//).as('uploadFile')
+
+ // Make sure the drop notice is not visible
+ cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible')
+
+ // Trigger the drop notice
+ cy.get('main.app-content').trigger('dragover', { dataTransfer })
+ cy.get('[data-cy-files-drag-drop-area]').should('be.visible')
+
+ // Upload drop a file
+ cy.get('[data-cy-files-drag-drop-area]').selectFile([
+ {
+ fileName: 'first.txt',
+ contents: ['Hello'],
+ },
+ {
+ fileName: 'second.txt',
+ contents: ['World'],
+ },
+ {
+ fileName: 'Foo',
+ contents: {},
+ },
+ {
+ fileName: 'Bar',
+ contents: { mimeType: 'httpd/unix-directory' },
+ },
+ ], { action: 'drag-drop' })
+
+ cy.wait('@uploadFile')
+
+ // Make sure the upload is finished
+ cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible')
+ cy.get('[data-cy-upload-picker] progress').should('not.be.visible')
+ cy.get('@uploadFile.all').should('have.length', 2)
+
+ getRowForFile('first.txt').should('be.visible')
+ getRowForFile('second.txt').should('be.visible')
+ getRowForFile('Foo').should('not.exist')
+ getRowForFile('Bar').should('not.exist')
+ })
+})
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
new file mode 100644
index 00000000000..086248eef3c
--- /dev/null
+++ b/cypress/e2e/files/files-copy-move.cy.ts
@@ -0,0 +1,177 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getRowForFile, moveFile, copyFile, navigateToFolder } from './FilesUtils.ts'
+
+describe('Files: Move or copy files', { testIsolation: true }, () => {
+ let currentUser
+ beforeEach(() => {
+ cy.createRandomUser().then((user) => {
+ currentUser = user
+ cy.login(user)
+ })
+ })
+ afterEach(() => {
+ // nice to have cleanup
+ cy.deleteUser(currentUser)
+ })
+
+
+ it('Can copy a file to new folder', () => {
+ // Prepare initial state
+ cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt')
+ .mkdir(currentUser, '/new-folder')
+ cy.login(currentUser)
+ cy.visit('/apps/files')
+
+ copyFile('original.txt', 'new-folder')
+
+ navigateToFolder('new-folder')
+
+ cy.url().should('contain', 'dir=/new-folder')
+ getRowForFile('original.txt').should('be.visible')
+ getRowForFile('new-folder').should('not.exist')
+ })
+
+ it('Can move a file to new folder', () => {
+ cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt')
+ .mkdir(currentUser, '/new-folder')
+ cy.login(currentUser)
+ cy.visit('/apps/files')
+
+ moveFile('original.txt', 'new-folder')
+
+ // wait until visible again
+ getRowForFile('new-folder').should('be.visible')
+
+ // original should be moved -> not exist anymore
+ getRowForFile('original.txt').should('not.exist')
+ navigateToFolder('new-folder')
+
+ cy.url().should('contain', 'dir=/new-folder')
+ getRowForFile('original.txt').should('be.visible')
+ getRowForFile('new-folder').should('not.exist')
+ })
+
+ /**
+ * Test for https://github.com/nextcloud/server/issues/41768
+ */
+ it('Can move a file to folder with similar name', () => {
+ cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original')
+ .mkdir(currentUser, '/original folder')
+ cy.login(currentUser)
+ cy.visit('/apps/files')
+
+ moveFile('original', 'original folder')
+
+ // wait until visible again
+ getRowForFile('original folder').should('be.visible')
+
+ // original should be moved -> not exist anymore
+ getRowForFile('original').should('not.exist')
+ navigateToFolder('original folder')
+
+ cy.url().should('contain', 'dir=/original%20folder')
+ getRowForFile('original').should('be.visible')
+ getRowForFile('original folder').should('not.exist')
+ })
+
+ it('Can move a file to its parent folder', () => {
+ cy.mkdir(currentUser, '/new-folder')
+ cy.uploadContent(currentUser, new Blob(), 'text/plain', '/new-folder/original.txt')
+ cy.login(currentUser)
+ cy.visit('/apps/files')
+
+ navigateToFolder('new-folder')
+ cy.url().should('contain', 'dir=/new-folder')
+
+ moveFile('original.txt', '/')
+
+ // wait until visible again
+ cy.get('main').contains('No files in here').should('be.visible')
+
+ // original should be moved -> not exist anymore
+ getRowForFile('original.txt').should('not.exist')
+
+ cy.visit('/apps/files')
+ getRowForFile('new-folder').should('be.visible')
+ getRowForFile('original.txt').should('be.visible')
+ })
+
+ it('Can copy a file to same folder', () => {
+ cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt')
+ cy.login(currentUser)
+ cy.visit('/apps/files')
+
+ copyFile('original.txt', '.')
+
+ getRowForFile('original.txt').should('be.visible')
+ getRowForFile('original (copy).txt').should('be.visible')
+ })
+
+ it('Can copy a file multiple times to same folder', () => {
+ cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt')
+ cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original (copy).txt')
+ cy.login(currentUser)
+ cy.visit('/apps/files')
+
+ copyFile('original.txt', '.')
+
+ getRowForFile('original.txt').should('be.visible')
+ getRowForFile('original (copy 2).txt').should('be.visible')
+ })
+
+ /**
+ * Test that a copied folder with a dot will be renamed correctly ('foo.bar' -> 'foo.bar (copy)')
+ * Test for: https://github.com/nextcloud/server/issues/43843
+ */
+ it('Can copy a folder to same folder', () => {
+ cy.mkdir(currentUser, '/foo.bar')
+ cy.login(currentUser)
+ cy.visit('/apps/files')
+
+ copyFile('foo.bar', '.')
+
+ getRowForFile('foo.bar').should('be.visible')
+ getRowForFile('foo.bar (copy)').should('be.visible')
+ })
+
+ /** Test for https://github.com/nextcloud/server/issues/43329 */
+ context('escaping file and folder names', () => {
+ it('Can handle files with special characters', () => {
+ cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt')
+ .mkdir(currentUser, '/can\'t say')
+ cy.login(currentUser)
+ cy.visit('/apps/files')
+
+ copyFile('original.txt', 'can\'t say')
+
+ navigateToFolder('can\'t say')
+
+ cy.url().should('contain', 'dir=/can%27t%20say')
+ getRowForFile('original.txt').should('be.visible')
+ getRowForFile('can\'t say').should('not.exist')
+ })
+
+ /**
+ * If escape is set to false (required for test above) then "<a>foo" would result in "<a>foo</a>" if sanitizing is not disabled
+ * We should disable it as vue already escapes the text when using v-text
+ */
+ it('does not incorrectly sanitize file names', () => {
+ cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt')
+ .mkdir(currentUser, '/<a href="#">foo')
+ cy.login(currentUser)
+ cy.visit('/apps/files')
+
+ copyFile('original.txt', '<a href="#">foo')
+
+ navigateToFolder('<a href="#">foo')
+
+ cy.url().should('contain', 'dir=/%3Ca%20href%3D%22%23%22%3Efoo')
+ getRowForFile('original.txt').should('be.visible')
+ getRowForFile('<a href="#">foo').should('not.exist')
+ })
+ })
+})
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-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
new file mode 100644
index 00000000000..b363e630b44
--- /dev/null
+++ b/cypress/e2e/files/files-settings.cy.ts
@@ -0,0 +1,158 @@
+/**
+ * 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.ts'
+
+describe('files: Set default view', { testIsolation: true }, () => {
+ beforeEach(() => {
+ cy.createRandomUser().then(($user) => {
+ cy.login($user)
+ })
+ })
+
+ 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
+
+ const setupFiles = () => cy.createRandomUser().then(($user) => {
+ user = $user
+
+ cy.uploadContent(user, new Blob([]), 'text/plain', '/.file')
+ cy.uploadContent(user, new Blob([]), 'text/plain', '/visible-file')
+ cy.mkdir(user, '/.folder')
+ cy.login(user)
+ })
+
+ context('view: All files', { testIsolation: false }, () => {
+ before(setupFiles)
+
+ it('hides dot-files by default', () => {
+ cy.visit('/apps/files')
+
+ getRowForFile('visible-file').should('be.visible')
+ getRowForFile('.file').should('not.exist')
+ getRowForFile('.folder').should('not.exist')
+ })
+
+ it('can show hidden files', () => {
+ showHiddenFiles()
+ // Now the files should be visible
+ getRowForFile('.file').should('be.visible')
+ getRowForFile('.folder').should('be.visible')
+ })
+ })
+
+ context('view: Personal files', { testIsolation: false }, () => {
+ before(setupFiles)
+
+ it('hides dot-files by default', () => {
+ cy.visit('/apps/files/personal')
+
+ getRowForFile('visible-file').should('be.visible')
+ getRowForFile('.file').should('not.exist')
+ getRowForFile('.folder').should('not.exist')
+ })
+
+ it('can show hidden files', () => {
+ showHiddenFiles()
+ // Now the files should be visible
+ getRowForFile('.file').should('be.visible')
+ getRowForFile('.folder').should('be.visible')
+ })
+ })
+
+ context('view: Recent files', { testIsolation: false }, () => {
+ before(() => {
+ setupFiles().then(() => {
+ // also add hidden file in hidden folder
+ cy.uploadContent(user, new Blob([]), 'text/plain', '/.folder/other-file')
+ cy.login(user)
+ })
+ })
+
+ it('hides dot-files by default', () => {
+ cy.visit('/apps/files/recent')
+
+ getRowForFile('visible-file').should('be.visible')
+ getRowForFile('.file').should('not.exist')
+ getRowForFile('.folder').should('not.exist')
+ getRowForFile('other-file').should('not.exist')
+ })
+
+ it('can show hidden files', () => {
+ showHiddenFiles()
+
+ getRowForFile('visible-file').should('be.visible')
+ // Now the files should be visible
+ getRowForFile('.file').should('be.visible')
+ getRowForFile('.folder').should('be.visible')
+ getRowForFile('other-file').should('be.visible')
+ })
+ })
+})
+
+/**
+ * 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
new file mode 100644
index 00000000000..9e726bf96e1
--- /dev/null
+++ b/cypress/e2e/files/files-sorting.cy.ts
@@ -0,0 +1,330 @@
+/**
+ * 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
+ beforeEach(() => {
+ cy.createRandomUser().then((user) => {
+ currentUser = user
+ cy.login(user)
+ })
+ })
+
+ it('Files are sorted by name ascending by default', () => {
+ cy.uploadContent(currentUser, new Blob(), 'text/plain', '/1 first.txt')
+ .uploadContent(currentUser, new Blob(), 'text/plain', '/z last.txt')
+ .uploadContent(currentUser, new Blob(), 'text/plain', '/A.txt')
+ .uploadContent(currentUser, new Blob(), 'text/plain', '/Ä.txt')
+ .mkdir(currentUser, '/m')
+ .mkdir(currentUser, '/4')
+ 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('4')
+ break
+ case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('m')
+ break
+ case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('1 first.txt')
+ break
+ case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('A.txt')
+ break
+ case 4: expect($row.attr('data-cy-files-list-row-name')).to.eq('Ä.txt')
+ break
+ case 5: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt')
+ break
+ case 6: expect($row.attr('data-cy-files-list-row-name')).to.eq('z last.txt')
+ break
+ }
+ })
+ })
+
+ /**
+ * 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')
+ .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
+ 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
+ }
+ })
+ })
+
+ it('Can sort by mtime', () => {
+ cy.uploadContent(currentUser, new Blob(), 'text/plain', '/1.txt', Date.now() / 1000 - 86400 - 1000)
+ .uploadContent(currentUser, new Blob(['a'.repeat(1024)]), 'text/plain', '/z.txt', Date.now() / 1000 - 86400)
+ .uploadContent(currentUser, new Blob(['a'.repeat(512)]), 'text/plain', '/a.txt', Date.now() / 1000 - 86400 - 500)
+ cy.login(currentUser)
+ cy.visit('/apps/files')
+
+ // click sort button
+ cy.get('th').contains('button', 'Modified').click()
+ // sorting is set
+ cy.contains('th', 'Modified').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('welcome.txt') // uploaded right now
+ break
+ case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt') // fake time of yesterday
+ break
+ case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt') // fake time of yesterday and few minutes
+ break
+ case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt') // fake time of yesterday and ~15 minutes ago
+ break
+ }
+ })
+
+ // reverse order
+ cy.get('th').contains('button', 'Modified').click()
+ cy.contains('th', 'Modified').should('have.attr', 'aria-sort', 'descending')
+ cy.get('[data-cy-files-list-row]').each(($row, index) => {
+ switch (index) {
+ case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') // uploaded right now
+ break
+ case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt') // fake time of yesterday
+ break
+ case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt') // fake time of yesterday and few minutes
+ break
+ case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt') // fake time of yesterday and ~15 minutes ago
+ break
+ }
+ })
+ })
+
+ it('Favorites are sorted first', () => {
+ cy.uploadContent(currentUser, new Blob(), 'text/plain', '/1.txt', Date.now() / 1000 - 86400 - 1000)
+ .uploadContent(currentUser, new Blob(['a'.repeat(1024)]), 'text/plain', '/z.txt', Date.now() / 1000 - 86400)
+ .uploadContent(currentUser, new Blob(['a'.repeat(512)]), 'text/plain', '/a.txt', Date.now() / 1000 - 86400 - 500)
+ .setFileAsFavorite(currentUser, '/a.txt')
+ cy.login(currentUser)
+ cy.visit('/apps/files')
+
+ cy.log('By name - ascending')
+ cy.contains('th', 'Name').should('have.attr', 'aria-sort', 'ascending')
+
+ 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('a.txt')
+ break
+ case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.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('z.txt')
+ break
+ }
+ })
+
+ cy.log('By name - descending')
+ cy.get('th').contains('button', 'Name').click()
+ cy.contains('th', 'Name').should('have.attr', 'aria-sort', 'descending')
+
+ 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('a.txt')
+ break
+ case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt')
+ break
+ case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt')
+ break
+ case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt')
+ break
+ }
+ })
+
+ cy.log('By size - ascending')
+ cy.get('th').contains('button', 'Size').click()
+ cy.contains('th', 'Size').should('have.attr', 'aria-sort', 'ascending')
+
+ 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('a.txt')
+ break
+ case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.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('z.txt')
+ break
+ }
+ })
+
+ cy.log('By size - descending')
+ cy.get('th').contains('button', 'Size').click()
+ cy.contains('th', 'Size').should('have.attr', 'aria-sort', 'descending')
+
+ 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('a.txt')
+ break
+ case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt')
+ break
+ case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt')
+ break
+ case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt')
+ break
+ }
+ })
+
+ cy.log('By mtime - ascending')
+ cy.get('th').contains('button', 'Modified').click()
+ cy.contains('th', 'Modified').should('have.attr', 'aria-sort', 'ascending')
+
+ 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('a.txt')
+ break
+ case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt')
+ break
+ case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt')
+ break
+ case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt')
+ break
+ }
+ })
+
+ cy.log('By mtime - descending')
+ cy.get('th').contains('button', 'Modified').click()
+ cy.contains('th', 'Modified').should('have.attr', 'aria-sort', 'descending')
+
+ 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('a.txt')
+ break
+ case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt')
+ break
+ case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt')
+ break
+ case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt')
+ break
+ }
+ })
+ })
+
+ 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
new file mode 100644
index 00000000000..a961b78e2f4
--- /dev/null
+++ b/cypress/e2e/files/files-xml-regression.cy.ts
@@ -0,0 +1,51 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getRowForFile, triggerActionForFile } from './FilesUtils.ts'
+
+/**
+ * This is a regression test for https://github.com/nextcloud/server/issues/43331
+ * Where files with XML entities in their names were wrongly displayed and could no longer be renamed / deleted etc.
+ */
+describe('Files: Can handle XML entities in file names', { testIsolation: false }, () => {
+ before(() => {
+ cy.createRandomUser().then((user) => {
+ cy.uploadContent(user, new Blob(), 'text/plain', '/and.txt')
+ cy.login(user)
+ cy.visit('/apps/files/')
+ })
+ })
+
+ it('Can reanme to a file name containing XML entities', () => {
+ cy.intercept('MOVE', /\/remote.php\/dav\/files\//).as('renameFile')
+ triggerActionForFile('and.txt', 'rename')
+ getRowForFile('and.txt')
+ .find('form[aria-label="Rename file"] input')
+ .type('{selectAll}&amp;.txt{enter}')
+
+ cy.wait('@renameFile')
+ getRowForFile('&amp;.txt').should('be.visible')
+ })
+
+ it('After a reload the filename is preserved', () => {
+ cy.reload()
+ getRowForFile('&amp;.txt').should('be.visible')
+ getRowForFile('&.txt').should('not.exist')
+ })
+
+ it('Can delete the file', () => {
+ cy.intercept('DELETE', /\/remote.php\/dav\/files\//).as('deleteFile')
+ triggerActionForFile('&amp;.txt', 'delete')
+ cy.wait('@deleteFile')
+
+ cy.contains('.toast-success', /Delete .* done/)
+ .should('be.visible')
+ getRowForFile('&amp;.txt').should('not.exist')
+
+ cy.reload()
+ getRowForFile('&amp;.txt').should('not.exist')
+ getRowForFile('&.txt').should('not.exist')
+ })
+})
diff --git a/cypress/e2e/files/files.cy.ts b/cypress/e2e/files/files.cy.ts
new file mode 100644
index 00000000000..efae1116d2d
--- /dev/null
+++ b/cypress/e2e/files/files.cy.ts
@@ -0,0 +1,58 @@
+import type { User } from "@nextcloud/cypress"
+
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+describe('Files', { testIsolation: true }, () => {
+ let currentUser: User
+
+ beforeEach(() => {
+ cy.createRandomUser().then((user) => {
+ 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
new file mode 100644
index 00000000000..8eb4efaaec0
--- /dev/null
+++ b/cypress/e2e/files/live_photos.cy.ts
@@ -0,0 +1,172 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from '@nextcloud/cypress'
+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 user: User
+ let randomFileName: string
+ let jpgFileId: number
+ let movFileId: number
+
+ beforeEach(() => {
+ setupLivePhotos()
+ .then((setupInfo) => {
+ user = setupInfo.user
+ randomFileName = setupInfo.fileName
+ jpgFileId = setupInfo.jpgFileId
+ movFileId = setupInfo.movFileId
+ })
+ })
+
+ it('Only renders the .jpg file', () => {
+ getRowForFileId(jpgFileId).should('have.length', 1)
+ getRowForFileId(movFileId).should('have.length', 0)
+ })
+
+ context("'Show hidden files' is enabled", () => {
+ beforeEach(() => {
+ setShowHiddenFiles(true)
+ })
+
+ it("Shows both files when 'Show hidden files' is enabled", () => {
+ getRowForFileId(jpgFileId).should('have.length', 1).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}.jpg`)
+ getRowForFileId(movFileId).should('have.length', 1).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}.mov`)
+ })
+
+ it('Copies both files when copying the .jpg', () => {
+ copyFile(`${randomFileName}.jpg`, '.')
+ clickOnBreadcrumbs('All files')
+
+ getRowForFile(`${randomFileName}.jpg`).should('have.length', 1)
+ getRowForFile(`${randomFileName}.mov`).should('have.length', 1)
+ getRowForFile(`${randomFileName} (copy).jpg`).should('have.length', 1)
+ getRowForFile(`${randomFileName} (copy).mov`).should('have.length', 1)
+ })
+
+ it('Copies both files when copying the .mov', () => {
+ copyFile(`${randomFileName}.mov`, '.')
+ clickOnBreadcrumbs('All files')
+
+ getRowForFile(`${randomFileName}.mov`).should('have.length', 1)
+ getRowForFile(`${randomFileName} (copy).jpg`).should('have.length', 1)
+ 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')
+
+ getRowForFileId(jpgFileId).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}_moved.jpg`)
+ getRowForFileId(movFileId).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}_moved.mov`)
+ })
+
+ it('Moves files when moving the .mov', () => {
+ renameFile(`${randomFileName}.mov`, `${randomFileName}_moved.mov`)
+ clickOnBreadcrumbs('All files')
+
+ getRowForFileId(jpgFileId).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}_moved.jpg`)
+ getRowForFileId(movFileId).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}_moved.mov`)
+ })
+
+ it('Deletes files when deleting the .jpg', () => {
+ triggerActionForFile(`${randomFileName}.jpg`, 'delete')
+ clickOnBreadcrumbs('All files')
+
+ getRowForFile(`${randomFileName}.jpg`).should('have.length', 0)
+ getRowForFile(`${randomFileName}.mov`).should('have.length', 0)
+
+ cy.visit('/apps/files/trashbin')
+
+ getRowForFileId(jpgFileId).invoke('attr', 'data-cy-files-list-row-name').should('to.match', new RegExp(`^${randomFileName}.jpg\\.d[0-9]+$`))
+ getRowForFileId(movFileId).invoke('attr', 'data-cy-files-list-row-name').should('to.match', new RegExp(`^${randomFileName}.mov\\.d[0-9]+$`))
+ })
+
+ it('Block deletion when deleting the .mov', () => {
+ triggerActionForFile(`${randomFileName}.mov`, 'delete')
+ clickOnBreadcrumbs('All files')
+
+ getRowForFile(`${randomFileName}.jpg`).should('have.length', 1)
+ getRowForFile(`${randomFileName}.mov`).should('have.length', 1)
+
+ cy.visit('/apps/files/trashbin')
+
+ getRowForFileId(jpgFileId).should('have.length', 0)
+ getRowForFileId(movFileId).should('have.length', 0)
+ })
+
+ it('Restores files when restoring the .jpg', () => {
+ triggerActionForFile(`${randomFileName}.jpg`, 'delete')
+ cy.visit('/apps/files/trashbin')
+ triggerInlineActionForFileId(jpgFileId, 'restore')
+ clickOnBreadcrumbs('Deleted files')
+
+ getRowForFile(`${randomFileName}.jpg`).should('have.length', 0)
+ getRowForFile(`${randomFileName}.mov`).should('have.length', 0)
+
+ cy.visit('/apps/files')
+
+ getRowForFile(`${randomFileName}.jpg`).should('have.length', 1)
+ getRowForFile(`${randomFileName}.mov`).should('have.length', 1)
+ })
+
+ it('Blocks restoration when restoring the .mov', () => {
+ triggerActionForFile(`${randomFileName}.jpg`, 'delete')
+ cy.visit('/apps/files/trashbin')
+ triggerInlineActionForFileId(movFileId, 'restore')
+ clickOnBreadcrumbs('Deleted files')
+
+ getRowForFileId(jpgFileId).should('have.length', 1)
+ getRowForFileId(movFileId).should('have.length', 1)
+
+ cy.visit('/apps/files')
+
+ getRowForFile(`${randomFileName}.jpg`).should('have.length', 0)
+ getRowForFile(`${randomFileName}.mov`).should('have.length', 0)
+ })
+ })
+})
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/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
new file mode 100644
index 00000000000..75c76b7e97c
--- /dev/null
+++ b/cypress/e2e/files_versions/filesVersionsUtils.ts
@@ -0,0 +1,90 @@
+/**
+ * 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 { 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
+ // within less than one second of each other.
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.uploadContent(user, new Blob(['v1'], { type: 'text/plain' }), 'text/plain', `/${fileName}`)
+ .wait(1100)
+ .uploadContent(user, new Blob(['v2'], { type: 'text/plain' }), 'text/plain', `/${fileName}`)
+ .wait(1100)
+ .uploadContent(user, new Blob(['v3'], { type: 'text/plain' }), 'text/plain', `/${fileName}`)
+ cy.login(user)
+}
+
+export function openVersionsPanel(fileName: string) {
+ // Detect the versions list fetch
+ cy.intercept('PROPFIND', '**/dav/versions/*/versions/**').as('getVersions')
+
+ // Open the versions tab
+ cy.window().then(win => {
+ win.OCA.Files.Sidebar.setActiveTab('version_vue')
+ win.OCA.Files.Sidebar.open(`/${fileName}`)
+ })
+
+ // Wait for the versions list to be fetched
+ cy.wait('@getVersions')
+ cy.get('#tab-version_vue').should('be.visible', { timeout: 10000 })
+}
+
+export function toggleVersionMenu(index: number) {
+ cy.get('#tab-version_vue [data-files-versions-version]')
+ .eq(index)
+ .find('button')
+ .click()
+}
+
+export function triggerVersionAction(index: number, actionName: string) {
+ toggleVersionMenu(index)
+ cy.get(`[data-cy-files-versions-version-action="${actionName}"]`).filter(':visible').click()
+}
+
+export function nameVersion(index: number, name: string) {
+ cy.intercept('PROPPATCH', '**/dav/versions/*/versions/**').as('labelVersion')
+ triggerVersionAction(index, 'label')
+ cy.get(':focused').type(`${name}{enter}`)
+ cy.wait('@labelVersion')
+}
+
+export function restoreVersion(index: number) {
+ cy.intercept('MOVE', '**/dav/versions/*/versions/**').as('restoreVersion')
+ triggerVersionAction(index, 'restore')
+ cy.wait('@restoreVersion')
+}
+
+export function deleteVersion(index: number) {
+ cy.intercept('DELETE', '**/dav/versions/*/versions/**').as('deleteVersion')
+ triggerVersionAction(index, 'delete')
+ cy.wait('@deleteVersion')
+}
+
+export function doesNotHaveAction(index: number, actionName: string) {
+ toggleVersionMenu(index)
+ cy.get(`[data-cy-files-versions-version-action="${actionName}"]`).should('not.exist')
+ toggleVersionMenu(index)
+}
+
+export function assertVersionContent(index: number, expectedContent: string) {
+ cy.intercept({ method: 'GET', times: 1, url: 'remote.php/**' }).as('downloadVersion')
+ triggerVersionAction(index, 'download')
+ cy.wait('@downloadVersion')
+ .then(({ response }) => expect(response?.body).to.equal(expectedContent))
+}
+
+export function setupTestSharedFileFromUser(owner: User, randomFileName: string, shareOptions: Partial<ShareSetting>) {
+ return cy.createRandomUser()
+ .then((recipient) => {
+ cy.login(owner)
+ cy.visit('/apps/files')
+ createShare(randomFileName, recipient.userId, shareOptions)
+ cy.login(recipient)
+ cy.visit('/apps/files')
+ return cy.wrap(recipient)
+ })
+}
diff --git a/cypress/e2e/files_versions/version_creation.cy.ts b/cypress/e2e/files_versions/version_creation.cy.ts
new file mode 100644
index 00000000000..a0441e96b29
--- /dev/null
+++ b/cypress/e2e/files_versions/version_creation.cy.ts
@@ -0,0 +1,47 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { openVersionsPanel, uploadThreeVersions } from './filesVersionsUtils'
+
+describe('Versions creation', () => {
+ let randomFileName = ''
+
+ before(() => {
+ randomFileName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + '.txt'
+
+ cy.createRandomUser()
+ .then((user) => {
+ uploadThreeVersions(user, randomFileName)
+ cy.login(user)
+ cy.visit('/apps/files')
+ openVersionsPanel(randomFileName)
+ })
+ })
+
+ it('Opens the versions panel and sees the versions', () => {
+ cy.visit('/apps/files')
+ openVersionsPanel(randomFileName)
+
+ cy.get('#tab-version_vue').within(() => {
+ cy.get('[data-files-versions-version]').should('have.length', 3)
+ cy.get('[data-files-versions-version]').eq(0).contains('Current version')
+ 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
new file mode 100644
index 00000000000..8c673b13d4c
--- /dev/null
+++ b/cypress/e2e/files_versions/version_cross_share_move_and_copy.cy.ts
@@ -0,0 +1,102 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { assertVersionContent, openVersionsPanel, setupTestSharedFileFromUser, uploadThreeVersions, nameVersion } from './filesVersionsUtils'
+import { clickOnBreadcrumbs, closeSidebar, copyFile, moveFile, navigateToFolder } from '../files/FilesUtils'
+import type { User } from '@nextcloud/cypress'
+
+/**
+ *
+ * @param filePath
+ */
+function assertVersionsContent(filePath: string) {
+ const path = filePath.split('/').slice(0, -1).join('/')
+
+ clickOnBreadcrumbs('All files')
+
+ if (path !== '') {
+ navigateToFolder(path)
+ }
+
+ openVersionsPanel(filePath)
+
+ cy.get('[data-files-versions-version]').should('have.length', 3)
+ assertVersionContent(0, 'v3')
+ assertVersionContent(1, 'v2')
+ assertVersionContent(2, 'v1')
+}
+
+describe('Versions cross share move and copy', () => {
+ let randomSharedFolderName = ''
+ let randomFileName = ''
+ let randomFilePath = ''
+ let alice: User
+ let bob: User
+
+ before(() => {
+ randomSharedFolderName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10)
+
+ cy.createRandomUser()
+ .then((user) => {
+ alice = user
+ cy.mkdir(alice, `/${randomSharedFolderName}`)
+ setupTestSharedFileFromUser(alice, randomSharedFolderName, {})
+ })
+ .then((user) => { bob = user })
+ })
+
+ beforeEach(() => {
+ randomFileName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + '.txt'
+ randomFilePath = `${randomSharedFolderName}/${randomFileName}`
+ uploadThreeVersions(alice, randomFilePath)
+
+ cy.login(bob)
+ cy.visit('/apps/files')
+ navigateToFolder(randomSharedFolderName)
+ openVersionsPanel(randomFilePath)
+ nameVersion(2, 'v1')
+ closeSidebar()
+ })
+
+ it('Also moves versions when bob moves the file out of a received share', () => {
+ moveFile(randomFileName, '/')
+ assertVersionsContent(randomFileName)
+ // TODO: move that in assertVersionsContent when copying files keeps the versions' metadata
+ cy.get('[data-files-versions-version]').eq(2).contains('v1')
+ })
+
+ it('Also copies versions when bob copies the file out of a received share', () => {
+ copyFile(randomFileName, '/')
+ assertVersionsContent(randomFileName)
+ })
+
+ context('When a file is in a subfolder', () => {
+ let randomSubFolderName
+ let randomSubSubFolderName
+
+ beforeEach(() => {
+ randomSubFolderName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10)
+ randomSubSubFolderName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10)
+ clickOnBreadcrumbs('All files')
+ cy.mkdir(bob, `/${randomSharedFolderName}/${randomSubFolderName}`)
+ cy.mkdir(bob, `/${randomSharedFolderName}/${randomSubFolderName}/${randomSubSubFolderName}`)
+ cy.login(bob)
+ navigateToFolder(randomSharedFolderName)
+ moveFile(randomFileName, `${randomSubFolderName}/${randomSubSubFolderName}`)
+ })
+
+ it('Also moves versions when bob moves the containing folder out of a received share', () => {
+ moveFile(randomSubFolderName, '/')
+ assertVersionsContent(`${randomSubFolderName}/${randomSubSubFolderName}/${randomFileName}`)
+ // TODO: move that in assertVersionsContent when copying files keeps the versions' metadata
+ cy.get('[data-files-versions-version]').eq(2).contains('v1')
+ })
+
+ it('Also copies versions when bob copies the containing folder out of a received share', () => {
+ copyFile(randomSubFolderName, '/')
+ assertVersionsContent(`${randomSubFolderName}/${randomSubSubFolderName}/${randomFileName}`)
+ })
+ })
+})
diff --git a/cypress/e2e/files_versions/version_deletion.cy.ts b/cypress/e2e/files_versions/version_deletion.cy.ts
new file mode 100644
index 00000000000..b49aa872639
--- /dev/null
+++ b/cypress/e2e/files_versions/version_deletion.cy.ts
@@ -0,0 +1,98 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from '@nextcloud/cypress'
+import { doesNotHaveAction, openVersionsPanel, setupTestSharedFileFromUser, uploadThreeVersions, deleteVersion } from './filesVersionsUtils'
+import { navigateToFolder, getRowForFile } from '../files/FilesUtils'
+
+describe('Versions restoration', () => {
+ const folderName = 'shared_folder'
+ const randomFileName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + '.txt'
+ const randomFilePath = `/${folderName}/${randomFileName}`
+ let user: User
+ let versionCount = 0
+
+ before(() => {
+ cy.createRandomUser()
+ .then((_user) => {
+ user = _user
+ cy.mkdir(user, `/${folderName}`)
+ uploadThreeVersions(user, randomFilePath)
+ uploadThreeVersions(user, randomFilePath)
+ versionCount = 6
+ cy.login(user)
+ cy.visit('/apps/files')
+ navigateToFolder(folderName)
+ openVersionsPanel(randomFilePath)
+ })
+ })
+
+ it('Delete initial version', () => {
+ cy.get('[data-files-versions-version]').should('have.length', versionCount)
+ deleteVersion(2)
+ versionCount--
+ cy.get('[data-files-versions-version]').should('have.length', versionCount)
+ })
+
+ context('Delete versions of shared file', () => {
+ it('Works with delete permission', () => {
+ setupTestSharedFileFromUser(user, folderName, { delete: true })
+ navigateToFolder(folderName)
+ openVersionsPanel(randomFilePath)
+
+ cy.get('[data-files-versions-version]').should('have.length', versionCount)
+ deleteVersion(2)
+ versionCount--
+ cy.get('[data-files-versions-version]').should('have.length', versionCount)
+ })
+
+ it('Does not work without delete permission', () => {
+ setupTestSharedFileFromUser(user, folderName, { delete: false })
+ navigateToFolder(folderName)
+ openVersionsPanel(randomFilePath)
+
+ doesNotHaveAction(0, 'delete')
+ doesNotHaveAction(1, 'delete')
+ doesNotHaveAction(2, 'delete')
+ })
+
+ it('Does not work without delete permission through direct API access', () => {
+ let fileId: string|undefined
+ let versionId: string|undefined
+
+ setupTestSharedFileFromUser(user, folderName, { delete: false })
+ .then(recipient => {
+ navigateToFolder(folderName)
+ openVersionsPanel(randomFilePath)
+
+ 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: 'DELETE',
+ url: `${base}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
+ auth: { user: recipient.userId, pass: recipient.password },
+ headers: {
+ cookie: '',
+ },
+ failOnStatusCode: false,
+ })
+ }).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
new file mode 100644
index 00000000000..548cb86a207
--- /dev/null
+++ b/cypress/e2e/files_versions/version_download.cy.ts
@@ -0,0 +1,94 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { assertVersionContent, doesNotHaveAction, openVersionsPanel, setupTestSharedFileFromUser, uploadThreeVersions } from './filesVersionsUtils'
+import type { User } from '@nextcloud/cypress'
+import { getRowForFile } from '../files/FilesUtils'
+
+describe('Versions download', () => {
+ let randomFileName = ''
+ let user: User
+
+ 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
+ uploadThreeVersions(user, randomFileName)
+ cy.login(user)
+ cy.visit('/apps/files')
+ openVersionsPanel(randomFileName)
+ })
+ })
+
+ 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')
+ assertVersionContent(2, 'v1')
+ })
+
+ context('Download versions of shared file', () => {
+ it('Works with download permission', () => {
+ setupTestSharedFileFromUser(user, randomFileName, { download: true })
+ openVersionsPanel(randomFileName)
+
+ assertVersionContent(0, 'v3')
+ assertVersionContent(1, 'v2')
+ assertVersionContent(2, 'v1')
+ })
+
+ it('Does not show action without download permission', () => {
+ setupTestSharedFileFromUser(user, randomFileName, { download: false })
+ openVersionsPanel(randomFileName)
+
+ 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="download"]').should('not.exist')
+
+ doesNotHaveAction(1, 'download')
+ doesNotHaveAction(2, 'download')
+ })
+
+ it('Does not work without download permission through direct API access', () => {
+ let fileId: string|undefined
+ let versionId: string|undefined
+
+ setupTestSharedFileFromUser(user, randomFileName, { download: false })
+ .then((recipient) => {
+ openVersionsPanel(randomFileName)
+
+ 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({
+ url: `${base}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
+ auth: { user: recipient.userId, pass: recipient.password },
+ headers: {
+ cookie: '',
+ },
+ failOnStatusCode: false,
+ })
+ }).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
new file mode 100644
index 00000000000..118ac01532f
--- /dev/null
+++ b/cypress/e2e/files_versions/version_expiration.cy.ts
@@ -0,0 +1,56 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { assertVersionContent, nameVersion, openVersionsPanel, uploadThreeVersions } from './filesVersionsUtils'
+
+describe('Versions expiration', () => {
+ let randomFileName = ''
+
+ beforeEach(() => {
+ randomFileName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + '.txt'
+
+ cy.createRandomUser()
+ .then((user) => {
+ uploadThreeVersions(user, randomFileName)
+ cy.login(user)
+ cy.visit('/apps/files')
+ openVersionsPanel(randomFileName)
+ })
+ })
+
+ it('Expire all versions', () => {
+ 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')
+ openVersionsPanel(randomFileName)
+
+ cy.get('#tab-version_vue').within(() => {
+ cy.get('[data-files-versions-version]').should('have.length', 1)
+ cy.get('[data-files-versions-version]').eq(0).contains('Current version')
+ })
+
+ assertVersionContent(0, 'v3')
+ })
+
+ it('Expire versions v2', () => {
+ nameVersion(2, 'v1')
+
+ 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')
+ openVersionsPanel(randomFileName)
+
+ cy.get('#tab-version_vue').within(() => {
+ cy.get('[data-files-versions-version]').should('have.length', 2)
+ cy.get('[data-files-versions-version]').eq(0).contains('Current version')
+ cy.get('[data-files-versions-version]').eq(1).contains('v1')
+ })
+
+ assertVersionContent(0, 'v3')
+ assertVersionContent(1, 'v1')
+ })
+})
diff --git a/cypress/e2e/files_versions/version_naming.cy.ts b/cypress/e2e/files_versions/version_naming.cy.ts
new file mode 100644
index 00000000000..ff299c53227
--- /dev/null
+++ b/cypress/e2e/files_versions/version_naming.cy.ts
@@ -0,0 +1,133 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from '@nextcloud/cypress'
+import { nameVersion, openVersionsPanel, uploadThreeVersions, doesNotHaveAction, setupTestSharedFileFromUser } from './filesVersionsUtils'
+import { getRowForFile } from '../files/FilesUtils'
+
+describe('Versions naming', () => {
+ let randomFileName = ''
+ let user: User
+
+ before(() => {
+ randomFileName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + '.txt'
+
+ cy.createRandomUser()
+ .then((_user) => {
+ user = _user
+ uploadThreeVersions(user, randomFileName)
+ cy.login(user)
+ cy.visit('/apps/files')
+ openVersionsPanel(randomFileName)
+ })
+ })
+
+ it('Names the versions', () => {
+ nameVersion(2, 'v1')
+ cy.get('#tab-version_vue').within(() => {
+ cy.get('[data-files-versions-version]').eq(2).contains('v1')
+ cy.get('[data-files-versions-version]').eq(2).contains('Initial version').should('not.exist')
+ })
+
+ nameVersion(1, 'v2')
+ cy.get('#tab-version_vue').within(() => {
+ cy.get('[data-files-versions-version]').eq(1).contains('v2')
+ })
+
+ nameVersion(0, 'v3')
+ cy.get('#tab-version_vue').within(() => {
+ cy.get('[data-files-versions-version]').eq(0).contains('v3 (Current version)')
+ })
+ })
+
+ context('Name versions of shared file', () => {
+ context('with edit permission', () => {
+ before(() => {
+ setupTestSharedFileFromUser(user, randomFileName, { update: true })
+ openVersionsPanel(randomFileName)
+ })
+
+ it('Names the versions', () => {
+ nameVersion(2, 'v1 - shared')
+ cy.get('#tab-version_vue').within(() => {
+ cy.get('[data-files-versions-version]').eq(2).contains('v1 - shared')
+ cy.get('[data-files-versions-version]').eq(2).contains('Initial version').should('not.exist')
+ })
+
+ nameVersion(1, 'v2 - shared')
+ cy.get('#tab-version_vue').within(() => {
+ cy.get('[data-files-versions-version]').eq(1).contains('v2 - shared')
+ })
+
+ nameVersion(0, 'v3 - shared')
+ cy.get('#tab-version_vue').within(() => {
+ cy.get('[data-files-versions-version]').eq(0).contains('v3 - shared (Current version)')
+ })
+ })
+ })
+
+ context('without edit permission', () => {
+ let recipient: User
+
+ beforeEach(() => {
+ setupTestSharedFileFromUser(user, randomFileName, { update: false })
+ .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')
+
+ doesNotHaveAction(1, 'label')
+ doesNotHaveAction(2, 'label')
+ })
+
+ it('Does not work without update permission through direct API access', () => {
+ let fileId: string|undefined
+ let versionId: string|undefined
+
+ 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
new file mode 100644
index 00000000000..34360808f61
--- /dev/null
+++ b/cypress/e2e/files_versions/version_restoration.cy.ts
@@ -0,0 +1,116 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from '@nextcloud/cypress'
+import { assertVersionContent, doesNotHaveAction, openVersionsPanel, setupTestSharedFileFromUser, restoreVersion, uploadThreeVersions } from './filesVersionsUtils'
+import { getRowForFile } from '../files/FilesUtils'
+
+describe('Versions restoration', () => {
+ let randomFileName = ''
+ let user: User
+
+ before(() => {
+ randomFileName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + '.txt'
+
+ cy.createRandomUser()
+ .then((_user) => {
+ user = _user
+ uploadThreeVersions(user, randomFileName)
+ cy.login(user)
+ cy.visit('/apps/files')
+ openVersionsPanel(randomFileName)
+ })
+ })
+
+ it('Current version does not have restore action', () => {
+ doesNotHaveAction(0, 'restore')
+ })
+
+ it('Restores initial version', () => {
+ restoreVersion(2)
+
+ cy.get('#tab-version_vue').within(() => {
+ cy.get('[data-files-versions-version]').should('have.length', 3)
+ cy.get('[data-files-versions-version]').eq(0).contains('Current version')
+ cy.get('[data-files-versions-version]').eq(2).contains('Initial version').should('not.exist')
+ })
+ })
+
+ it('Downloads versions and assert there content', () => {
+ assertVersionContent(0, 'v1')
+ assertVersionContent(1, 'v3')
+ assertVersionContent(2, 'v2')
+ })
+
+ context('Restore versions of shared file', () => {
+ it('Works with update permission', () => {
+ setupTestSharedFileFromUser(user, randomFileName, { update: true })
+ openVersionsPanel(randomFileName)
+
+ it('Restores initial version', () => {
+ restoreVersion(2)
+ cy.get('#tab-version_vue').within(() => {
+ cy.get('[data-files-versions-version]').should('have.length', 3)
+ cy.get('[data-files-versions-version]').eq(0).contains('Current version')
+ cy.get('[data-files-versions-version]').eq(2).contains('Initial version').should('not.exist')
+ })
+ })
+
+ it('Downloads versions and assert there content', () => {
+ assertVersionContent(0, 'v1')
+ assertVersionContent(1, 'v3')
+ assertVersionContent(2, 'v2')
+ })
+ })
+
+ it('Does not show action without delete permission', () => {
+ setupTestSharedFileFromUser(user, randomFileName, { update: false })
+ openVersionsPanel(randomFileName)
+
+ 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="restore"]').should('not.exist')
+
+ doesNotHaveAction(1, 'restore')
+ doesNotHaveAction(2, 'restore')
+ })
+
+ it('Does not work without update permission through direct API access', () => {
+ let fileId: string|undefined
+ let versionId: string|undefined
+
+ setupTestSharedFileFromUser(user, randomFileName, { update: false })
+ .then((recipient) => {
+ openVersionsPanel(randomFileName)
+
+ 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: 'MOVE',
+ url: `${base}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
+ auth: { user: recipient.userId, pass: recipient.password },
+ headers: {
+ cookie: '',
+ Destination: `${base}}/remote.php/dav/versions/${recipient.userId}/restore/target`,
+ },
+ failOnStatusCode: false,
+ })
+ }).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
new file mode 100644
index 00000000000..97e3b9a24bf
--- /dev/null
+++ b/cypress/e2e/login/login.cy.ts
@@ -0,0 +1,152 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from '@nextcloud/cypress'
+import { getNextcloudUserMenu, getNextcloudUserMenuToggle } from '../../support/commonUtils'
+
+describe('Login', () => {
+ let user: User
+ let disabledUser: User
+
+ after(() => cy.deleteUser(user))
+ before(() => {
+ // disable brute force protection
+ cy.runOccCommand('config:system:set auth.bruteforce.protection.enabled --value false --type bool')
+ cy.createRandomUser().then(($user) => {
+ user = $user
+ })
+ cy.createRandomUser().then(($user) => {
+ disabledUser = $user
+ cy.runOccCommand(`user:disable '${disabledUser.userId}'`)
+ })
+ })
+
+ beforeEach(() => {
+ cy.logout()
+ })
+
+ it('log in with valid account and password', () => {
+ // Given I visit the Home page
+ cy.visit('/')
+ // I see the login page
+ cy.get('form[name="login"]').should('be.visible')
+ // I log in with a valid account
+ 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 the login is done
+ cy.get('[data-login-form-submit]').if().should('not.contain', 'Logging in')
+
+ // Then I see that the current page is the Files app
+ cy.url().should('match', /apps\/dashboard(\/|$)/)
+ })
+
+ it('try to log in with valid account and invalid password', () => {
+ // Given I visit the Home page
+ cy.visit('/')
+ // I see the login page
+ cy.get('form[name="login"]').should('be.visible')
+ // I log in with a valid account but invalid password
+ cy.get('form[name="login"]').within(() => {
+ cy.get('input[name="user"]').type(user.userId)
+ cy.get('input[name="password"]').type(`${user.password}--wrong`)
+ cy.contains('button', 'Log in').click()
+ })
+
+ // see that the login is done
+ cy.get('[data-login-form-submit]').if().should('not.contain', 'Logging in')
+
+ // Then I see that the current page is the Login page
+ cy.url().should('match', /\/login/)
+ // And I see that a wrong password message is shown
+ cy.get('form[name="login"]').then(($el) => expect($el.text()).to.match(/Wrong.+password/i))
+ cy.get('input[name="password"]:invalid').should('exist')
+ })
+
+ it('try to log in with valid account and invalid password', () => {
+ // Given I visit the Home page
+ cy.visit('/')
+ // I see the login page
+ cy.get('form[name="login"]').should('be.visible')
+ // I log in with a valid account but invalid password
+ cy.get('form[name="login"]').within(() => {
+ cy.get('input[name="user"]').type(user.userId)
+ cy.get('input[name="password"]').type(`${user.password}--wrong`)
+ cy.contains('button', 'Log in').click()
+ })
+
+ // see that the login is done
+ cy.get('[data-login-form-submit]').if().should('not.contain', 'Logging in')
+
+ // Then I see that the current page is the Login page
+ cy.url().should('match', /\/login/)
+ // And I see that a wrong password message is shown
+ cy.get('form[name="login"]').then(($el) => expect($el.text()).to.match(/Wrong.+password/i).and.to.match(/Wrong.+login/))
+ cy.get('input[name="password"]:invalid').should('exist')
+ })
+
+ it('try to log in with invalid account', () => {
+ // Given I visit the Home page
+ cy.visit('/')
+ // I see the login page
+ cy.get('form[name="login"]').should('be.visible')
+ // I log in with an invalid user but valid password
+ cy.get('form[name="login"]').within(() => {
+ cy.get('input[name="user"]').type(`${user.userId}--wrong`)
+ cy.get('input[name="password"]').type(user.password)
+ cy.contains('button', 'Log in').click()
+ })
+
+ // see that the login is done
+ cy.get('[data-login-form-submit]').if().should('not.contain', 'Logging in')
+
+ // Then I see that the current page is the Login page
+ cy.url().should('match', /\/login/)
+ // And I see that a wrong password message is shown
+ cy.get('form[name="login"]').then(($el) => expect($el.text()).to.match(/Wrong.+password/i).and.to.match(/Wrong.+login/))
+ cy.get('input[name="password"]:invalid').should('exist')
+ })
+
+ it('try to log in as disabled account', () => {
+ // Given I visit the Home page
+ cy.visit('/')
+ // I see the login page
+ cy.get('form[name="login"]').should('be.visible')
+ // When I log in with user disabledUser and password
+ cy.get('form[name="login"]').within(() => {
+ cy.get('input[name="user"]').type(disabledUser.userId)
+ cy.get('input[name="password"]').type(disabledUser.password)
+ cy.contains('button', 'Log in').click()
+ })
+
+ // see that the login is done
+ cy.get('[data-login-form-submit]').if().should('not.contain', 'Logging in')
+
+ // Then I see that the current page is the Login page
+ cy.url().should('match', /\/login/)
+ // And I see that the disabled account message is shown
+ cy.get('form[name="login"]').then(($el) => expect($el.text()).to.match(/Account.+disabled/i))
+ cy.get('input[name="password"]:invalid').should('exist')
+ })
+
+ it('try to logout', () => {
+ cy.login(user)
+
+ // Given I visit the Home page
+ cy.visit('/')
+ // I see the dashboard
+ cy.url().should('match', /apps\/dashboard(\/|$)/)
+
+ // When click logout
+ getNextcloudUserMenuToggle().should('exist').click()
+ getNextcloudUserMenu().contains('a', 'Log out').click()
+
+ // Then I see that the current page is the Login page
+ cy.url().should('match', /\/login/)
+ })
+})
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
new file mode 100644
index 00000000000..4bf0cbc1832
--- /dev/null
+++ b/cypress/e2e/settings/access-levels.cy.ts
@@ -0,0 +1,65 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { User } from '@nextcloud/cypress'
+import { clearState, getNextcloudUserMenu, getNextcloudUserMenuToggle } from '../../support/commonUtils'
+
+const admin = new User('admin', 'admin')
+
+describe('Settings: Ensure only administrator can see the administration settings section', { testIsolation: true }, () => {
+ beforeEach(() => {
+ clearState()
+ })
+
+ it('Regular users cannot see admin-level items on the Settings page', () => {
+ // Given I am logged in
+ cy.createRandomUser().then(($user) => {
+ cy.login($user)
+ cy.visit('/')
+ })
+
+ // I open the settings menu
+ getNextcloudUserMenuToggle().click()
+ // I navigate to the settings panel
+ getNextcloudUserMenu()
+ .findByRole('link', { name: /settings/i })
+ .click()
+ cy.url().should('match', /\/settings\/user$/)
+
+ cy.get('#app-navigation').should('be.visible').within(() => {
+ // I see the personal section is NOT shown
+ cy.get('#app-navigation-caption-personal').should('not.exist')
+ // I see the admin section is NOT shown
+ cy.get('#app-navigation-caption-administration').should('not.exist')
+
+ // I see that the "Personal info" entry in the settings panel is shown
+ cy.get('[data-section-id="personal-info"]').should('exist').and('be.visible')
+ })
+ })
+
+ it('Admin users can see admin-level items on the Settings page', () => {
+ // Given I am logged in
+ cy.login(admin)
+ cy.visit('/')
+
+ // I open the settings menu
+ getNextcloudUserMenuToggle().click()
+ // I navigate to the settings panel
+ getNextcloudUserMenu()
+ .findByRole('link', { name: /Personal settings/i })
+ .click()
+ cy.url().should('match', /\/settings\/user$/)
+
+ cy.get('#app-navigation').should('be.visible').within(() => {
+ // I see the personal section is shown
+ cy.get('#app-navigation-caption-personal').should('be.visible')
+ // I see the admin section is shown
+ cy.get('#app-navigation-caption-administration').should('be.visible')
+
+ // I see that the "Personal info" entry in the settings panel is shown
+ cy.get('[data-section-id="personal-info"]').should('exist').and('be.visible')
+ })
+ })
+})
diff --git a/cypress/e2e/settings/apps.cy.ts b/cypress/e2e/settings/apps.cy.ts
new file mode 100644
index 00000000000..0df073271ef
--- /dev/null
+++ b/cypress/e2e/settings/apps.cy.ts
@@ -0,0 +1,156 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { User } from '@nextcloud/cypress'
+import { handlePasswordConfirmation } from './usersUtils'
+
+const admin = new User('admin', 'admin')
+
+describe('Settings: App management', { testIsolation: true }, () => {
+ beforeEach(() => {
+ // disable QA if already enabled
+ cy.runOccCommand('app:disable -n testing')
+ // enable notification if already disabled
+ cy.runOccCommand('app:enable -n updatenotification')
+
+ // 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', () => {
+ cy.get('#apps-list').should('exist')
+ // Wait for the app list to load
+ .contains('tr', 'QA testing', { timeout: 10000 })
+ .should('exist')
+ // I enable the "QA testing" app
+ .contains('button', 'Enable')
+ .click({ force: true })
+
+ handlePasswordConfirmation(admin.password)
+
+ // Wait until we see the disable button for the app
+ cy.get('#apps-list').should('exist')
+ .contains('tr', 'QA testing')
+ .should('exist')
+ // I see the disable button for the app
+ .contains('button', 'Disable', { timeout: 10000 })
+
+ // Change to enabled apps view
+ cy.get('#app-category-enabled a').click({ force: true })
+ cy.url().should('match', /settings\/apps\/enabled$/)
+ // I see that the "QA testing" app has been enabled
+ cy.get('#apps-list').contains('tr', 'QA testing')
+ })
+
+ it('Can disable an installed app', () => {
+ cy.get('#apps-list')
+ .should('exist')
+ // Wait for the app list to load
+ .contains('tr', 'Update notification', { timeout: 10000 })
+ .should('exist')
+ // I disable the "Update notification" app
+ .contains('button', 'Disable')
+ .click({ force: true })
+
+ handlePasswordConfirmation(admin.password)
+
+ // Wait until we see the disable button for the app
+ cy.get('#apps-list').should('exist')
+ .contains('tr', 'Update notification')
+ .should('exist')
+ // I see the enable button for the app
+ .contains('button', 'Enable', { timeout: 10000 })
+
+ // Change to disabled apps view
+ cy.get('#app-category-disabled a').click({ force: true })
+ cy.url().should('match', /settings\/apps\/disabled$/)
+ // I see that the "Update notification" app has been disabled
+ cy.get('#apps-list').contains('tr', 'Update notification')
+ })
+
+ it('Browse enabled apps', () => {
+ // When I open the "Active apps" section
+ cy.get('#app-category-enabled a')
+ .should('contain', 'Active apps')
+ .click({ force: true })
+ // Then I see that the current section is "Active apps"
+ cy.url().should('match', /settings\/apps\/enabled$/)
+ cy.get('#app-category-enabled').find('.active').should('exist')
+ // I see that there are only enabled apps
+ cy.get('#apps-list')
+ .should('exist')
+ .find('tr button')
+ .each(($action) => {
+ cy.wrap($action).should('not.contain', 'Enable')
+ })
+ })
+
+ it('Browse disabled apps', () => {
+ // When I open the "Active apps" section
+ cy.get('#app-category-disabled a')
+ .should('contain', 'Disabled apps')
+ .click({ force: true })
+ // Then I see that the current section is "Active apps"
+ cy.url().should('match', /settings\/apps\/disabled$/)
+ cy.get('#app-category-disabled').find('.active').should('exist')
+ // I see that there are only disabled apps
+ cy.get('#apps-list')
+ .should('exist')
+ .find('tr button')
+ .each(($action) => {
+ cy.wrap($action).should('not.contain', 'Disable')
+ })
+ })
+
+ it('Browse app bundles', () => {
+ // When I open the "App bundles" section
+ cy.get('#app-category-your-bundles a')
+ .should('contain', 'App bundles')
+ .click({ force: true })
+ // Then I see that the current section is "App bundles"
+ cy.url().should('match', /settings\/apps\/app-bundles$/)
+ 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 bundle')
+ // I see that the "Enterprise bundle" is disabled
+ cy.get('#apps-list').contains('tr', 'Enterprise bundle').contains('button', 'Download and enable all')
+ })
+
+ it('View app details', () => {
+ // When I click on the "QA testing" app
+ cy.get('#apps-list').contains('a', 'QA testing').click({ force: true })
+ // I see that the app details are shown
+ cy.get('#app-sidebar-vue')
+ .should('be.visible')
+ .find('.app-sidebar-header__info')
+ .should('contain', 'QA testing')
+ cy.get('#app-sidebar-vue').contains('a', 'View in store').should('exist')
+ cy.get('#app-sidebar-vue').find('input[type="button"][value="Enable"]').should('be.visible')
+ cy.get('#app-sidebar-vue').find('input[type="button"][value="Remove"]').should('be.visible')
+ cy.get('#app-sidebar-vue').contains(/Version \d+\.\d+\.\d+/).should('be.visible')
+ })
+
+ /*
+ * TODO: Improve testing with app store as external API
+ * The following scenarios require the files_antivirus and calendar app
+ * being present in the app store with support for the current server version
+ * Ideally we would have either a dummy app store endpoint with some test apps
+ * or even an app store instance running somewhere to properly test this.
+ * This is also a requirement to properly test updates of apps
+ */
+ // TODO: View app details for app store apps
+ // TODO: Install an app from the app store
+ // TODO: Show section from app store
+})
diff --git a/cypress/e2e/settings/personal-info.cy.ts b/cypress/e2e/settings/personal-info.cy.ts
new file mode 100644
index 00000000000..8d4b4bb606a
--- /dev/null
+++ b/cypress/e2e/settings/personal-info.cy.ts
@@ -0,0 +1,448 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from '@nextcloud/cypress'
+import { handlePasswordConfirmation } from './usersUtils.ts'
+
+let user: User
+
+enum Visibility {
+ Private = 'Private',
+ Local = 'Local',
+ Federated = 'Federated',
+ Public = 'Published'
+}
+
+const ALL_VISIBILITIES = [Visibility.Public, Visibility.Private, Visibility.Local, Visibility.Federated]
+
+/**
+ * Get the input connected to a specific label
+ * @param label The content of the label
+ */
+const inputForLabel = (label: string) => cy.contains('label', label).then((el) => cy.get(`#${el.attr('for')}`))
+
+/**
+ * Get the property visibility button
+ * @param property The property to which to look for the button
+ */
+const getVisibilityButton = (property: string) => cy.get(`button[aria-label*="Change scope level of ${property.toLowerCase()}"`)
+
+/**
+ * Validate a specifiy visibility is set for a property
+ * @param property The property
+ * @param active The active visibility
+ */
+const validateActiveVisibility = (property: string, active: Visibility) => {
+ getVisibilityButton(property)
+ .should('have.attr', 'aria-label')
+ .and('match', new RegExp(`current scope is ${active}`, 'i'))
+ getVisibilityButton(property)
+ .click()
+ cy.get('ul[role="menu"]')
+ .contains('button', active)
+ .should('have.attr', 'aria-checked', 'true')
+
+ // close menu
+ getVisibilityButton(property)
+ .click()
+}
+
+/**
+ * Set a specific visibility for a property
+ * @param property The property
+ * @param active The visibility to set
+ */
+const setActiveVisibility = (property: string, active: Visibility) => {
+ getVisibilityButton(property)
+ .click()
+ cy.get('ul[role="menu"]')
+ .contains('button', active)
+ .click({ force: true })
+ handlePasswordConfirmation(user.password)
+
+ cy.wait('@submitSetting')
+}
+
+/**
+ * Helper to check that setting all visibilities on a property is possible
+ * @param property The property to test
+ * @param defaultVisibility The default visibility of that property
+ * @param allowedVisibility Visibility that is allowed and need to be checked
+ */
+const checkSettingsVisibility = (property: string, defaultVisibility: Visibility = Visibility.Local, allowedVisibility: Visibility[] = ALL_VISIBILITIES) => {
+ getVisibilityButton(property)
+ .scrollIntoView()
+
+ validateActiveVisibility(property, defaultVisibility)
+
+ allowedVisibility.forEach((active) => {
+ setActiveVisibility(property, active)
+
+ cy.reload()
+ getVisibilityButton(property).scrollIntoView()
+
+ validateActiveVisibility(property, active)
+ })
+
+ // TODO: Fix this in vue library then enable this test again
+ /* // Test that not allowed options are disabled
+ ALL_VISIBILITIES.filter((v) => !allowedVisibility.includes(v)).forEach((disabled) => {
+ getVisibilityButton(property)
+ .click()
+ cy.get('ul[role="dialog"')
+ .contains('button', disabled)
+ .should('exist')
+ .and('have.attr', 'disabled', 'true')
+ }) */
+}
+
+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.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')
+
+ 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')
+
+ 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')
+ })
+
+ it('Can change language', () => {
+ cy.intercept('GET', /settings\/user/).as('reload')
+ inputForLabel('Language').scrollIntoView()
+ inputForLabel('Language').type('Ned')
+ cy.contains('li[role="option"]', 'Nederlands')
+ .click()
+ cy.wait('@reload')
+
+ // expect language changed
+ inputForLabel('Taal').scrollIntoView()
+ cy.contains('section', 'Help met vertalen')
+ })
+
+ it('Can change locale', () => {
+ cy.intercept('GET', /settings\/user/).as('reload')
+ cy.clock(new Date(2024, 0, 10))
+
+ // Default is US
+ cy.contains('section', '01/10/2024')
+
+ inputForLabel('Locale').scrollIntoView()
+ inputForLabel('Locale').type('German')
+ cy.contains('li[role="option"]', 'German (Germany')
+ .click()
+ cy.wait('@reload')
+
+ // expect locale changed
+ inputForLabel('Locale').scrollIntoView()
+ cy.contains('section', '10.01.2024')
+ })
+
+ it('Can set primary email and change its visibility', () => {
+ cy.contains('label', 'Email').scrollIntoView()
+ // Check invalid input
+ inputForLabel('Email').type('foo bar')
+ inputForLabel('Email').then(($el) => expect(($el.get(0) as HTMLInputElement).checkValidity()).to.be.false)
+ // handle valid input
+ inputForLabel('Email').type('{selectAll}hello@example.com')
+ handlePasswordConfirmation(user.password)
+
+ cy.wait('@submitSetting')
+ cy.reload()
+ inputForLabel('Email').should('have.value', 'hello@example.com')
+
+ checkSettingsVisibility(
+ 'Email',
+ Visibility.Federated,
+ // It is not possible to set it as private
+ ALL_VISIBILITIES.filter((v) => v !== Visibility.Private),
+ )
+
+ // check it is visible on the profile
+ cy.visit(`/u/${user.userId}`)
+ cy.contains('a', 'hello@example.com').should('be.visible').and('have.attr', 'href', 'mailto:hello@example.com')
+ })
+
+ it('Can delete primary email', () => {
+ cy.contains('label', 'Email').scrollIntoView()
+ inputForLabel('Email').type('{selectAll}hello@example.com')
+ handlePasswordConfirmation(user.password)
+ cy.wait('@submitSetting')
+
+ // check after reload
+ cy.reload()
+ inputForLabel('Email').should('have.value', 'hello@example.com')
+
+ // delete email
+ cy.get('button[aria-label="Remove primary email"]').click({ force: true })
+ cy.wait('@submitSetting')
+
+ // check after reload
+ cy.reload()
+ inputForLabel('Email').should('have.value', '')
+ })
+
+ it('Can set and delete additional emails', () => {
+ cy.get('button[aria-label="Add additional email"]').should('be.disabled')
+ // we need a primary email first
+ cy.contains('label', 'Email').scrollIntoView()
+ inputForLabel('Email').type('{selectAll}primary@example.com')
+ handlePasswordConfirmation(user.password)
+ cy.wait('@submitSetting')
+
+ // add new email
+ cy.get('button[aria-label="Add additional email"]')
+ .click()
+
+ // without any value we should not be able to add a second additional
+ cy.get('button[aria-label="Add additional email"]').should('be.disabled')
+
+ // fill the first additional
+ inputForLabel('Additional email address 1')
+ .type('1@example.com')
+ handlePasswordConfirmation(user.password)
+ cy.wait('@submitSetting')
+
+ // add second additional email
+ cy.get('button[aria-label="Add additional email"]')
+ .click()
+
+ // fill the second additional
+ inputForLabel('Additional email address 2')
+ .type('2@example.com')
+ handlePasswordConfirmation(user.password)
+ cy.wait('@submitSetting')
+
+ // check the content is saved
+ cy.reload()
+ inputForLabel('Additional email address 1')
+ .should('have.value', '1@example.com')
+ inputForLabel('Additional email address 2')
+ .should('have.value', '2@example.com')
+
+ // delete the first
+ cy.get('button[aria-label="Options for additional email address 1"]')
+ .click({ force: true })
+ cy.contains('button[role="menuitem"]', 'Delete email')
+ .click({ force: true })
+ handlePasswordConfirmation(user.password)
+
+ cy.reload()
+ inputForLabel('Additional email address 1')
+ .should('have.value', '2@example.com')
+ })
+
+ it('Can set Full name and change its visibility', () => {
+ cy.contains('label', 'Full name').scrollIntoView()
+ // handle valid input
+ inputForLabel('Full name').type('{selectAll}Jane Doe')
+ handlePasswordConfirmation(user.password)
+
+ cy.wait('@submitSetting')
+ cy.reload()
+ inputForLabel('Full name').should('have.value', 'Jane Doe')
+
+ checkSettingsVisibility(
+ 'Full name',
+ Visibility.Federated,
+ // It is not possible to set it as private
+ ALL_VISIBILITIES.filter((v) => v !== Visibility.Private),
+ )
+
+ // check it is visible on the profile
+ cy.visit(`/u/${user.userId}`)
+ cy.contains('h2', 'Jane Doe').should('be.visible')
+ })
+
+ it('Can set Phone number and its visibility', () => {
+ cy.contains('label', 'Phone number').scrollIntoView()
+ // Check invalid input
+ inputForLabel('Phone number').type('foo bar')
+ inputForLabel('Phone number').should('have.attr', 'class').and('contain', '--error')
+ // handle valid input
+ inputForLabel('Phone number').type('{selectAll}+49 89 721010 99701')
+ inputForLabel('Phone number').should('have.attr', 'class').and('not.contain', '--error')
+ handlePasswordConfirmation(user.password)
+
+ cy.wait('@submitSetting')
+ cy.reload()
+ inputForLabel('Phone number').should('have.value', '+498972101099701')
+
+ checkSettingsVisibility('Phone number')
+
+ // check it is visible on the profile
+ cy.visit(`/u/${user.userId}`)
+ 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
+ inputForLabel('Website').type('foo bar')
+ inputForLabel('Website').then(($el) => expect(($el.get(0) as HTMLInputElement).checkValidity()).to.be.false)
+ // handle valid input
+ inputForLabel('Website').type('{selectAll}http://example.com')
+ handlePasswordConfirmation(user.password)
+
+ cy.wait('@submitSetting')
+ cy.reload()
+ inputForLabel('Website').should('have.value', 'http://example.com')
+
+ checkSettingsVisibility('Website')
+
+ // check it is visible on the profile
+ cy.visit(`/u/${user.userId}`)
+ cy.contains('http://example.com').should('be.visible')
+ })
+
+ // Check generic properties that allow any visibility and any value
+ genericProperties.forEach(([property, value]) => {
+ it(`Can set ${property} and change its visibility`, () => {
+ cy.contains('label', property).scrollIntoView()
+ inputForLabel(property).type(value)
+ handlePasswordConfirmation(user.password)
+
+ cy.wait('@submitSetting')
+ cy.reload()
+ inputForLabel(property).should('have.value', value)
+
+ checkSettingsVisibility(property)
+
+ // check it is visible on the profile
+ cy.visit(`/u/${user.userId}`)
+ cy.contains(value).should('be.visible')
+ })
+ })
+
+ // Check non federated properties - those where we need special configuration and only support local visibility
+ nonfederatedProperties.forEach((property) => {
+ it(`Can set ${property} and change its visibility`, () => {
+ const uniqueValue = `${property.toUpperCase()} ${property.toLowerCase()}`
+ cy.contains('label', property).scrollIntoView()
+ inputForLabel(property).type(uniqueValue)
+ handlePasswordConfirmation(user.password)
+
+ cy.wait('@submitSetting')
+ cy.reload()
+ inputForLabel(property).should('have.value', uniqueValue)
+
+ checkSettingsVisibility(property, Visibility.Local, [Visibility.Private, Visibility.Local])
+
+ // check it is visible on the profile
+ cy.visit(`/u/${user.userId}`)
+ cy.contains(uniqueValue).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
new file mode 100644
index 00000000000..5b8726e92ca
--- /dev/null
+++ b/cypress/e2e/settings/users.cy.ts
@@ -0,0 +1,129 @@
+/**
+ * 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'
+import { getUserListRow, handlePasswordConfirmation } from './usersUtils'
+
+const admin = new User('admin', 'admin')
+const john = new User('john', '123456')
+
+describe('Settings: Create and delete accounts', function() {
+ beforeEach(function() {
+ cy.listUsers().then((users) => {
+ if ((users as string[]).includes(john.userId)) {
+ // ensure created user is deleted
+ cy.deleteUser(john)
+ }
+ })
+ cy.login(admin)
+ // open the User settings
+ cy.visit('/settings/users')
+ })
+
+ it('Can create a user', function() {
+ // open the New user modal
+ cy.get('button#new-user-button').click()
+
+ cy.get('form[data-test="form"]').within(() => {
+ // 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 user with additional field data', function() {
+ // open the New user modal
+ cy.get('button#new-user-button').click()
+
+ cy.get('form[data-test="form"]').within(() => {
+ // set the username
+ cy.get('input[data-test="username"]').should('exist').and('have.value', '')
+ cy.get('input[data-test="username"]').type(john.userId)
+ cy.get('input[data-test="username"]').should('have.value', john.userId)
+ // set the display name
+ cy.get('input[data-test="displayName"]').should('exist').and('have.value', '')
+ cy.get('input[data-test="displayName"]').type('John Smith')
+ cy.get('input[data-test="displayName"]').should('have.value', 'John Smith')
+ // set the email
+ cy.get('input[data-test="email"]').should('exist').and('have.value', '')
+ cy.get('input[data-test="email"]').type('john@example.org')
+ cy.get('input[data-test="email"]').should('have.value', 'john@example.org')
+ // set the password
+ 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 })
+ })
+
+ // 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 delete a user', function() {
+ let testUser
+ // create user
+ cy.createRandomUser()
+ .then(($user) => {
+ testUser = $user
+ })
+ cy.login(admin)
+ // ensure created user is present
+ cy.reload().then(() => {
+ // see that the user is in the list
+ getUserListRow(testUser.userId).within(() => {
+ // see that the list of users contains the user testUser
+ cy.contains(testUser.userId).should('exist')
+ // open the actions menu for the user
+ cy.get('[data-cy-user-list-cell-actions]')
+ .find('button.action-item__menutoggle')
+ .click({ force: true })
+ })
+
+ // 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 })
+
+ // 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
new file mode 100644
index 00000000000..7d8ea55d35b
--- /dev/null
+++ b/cypress/e2e/settings/usersUtils.ts
@@ -0,0 +1,90 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from '@nextcloud/cypress'
+
+/**
+ * Assert that `element` does not exist or is not visible
+ * Useful in cases such as when NcModal is opened/closed rapidly
+ * @param element Element that is inspected
+ */
+export function assertNotExistOrNotVisible(element: JQuery<HTMLElement>) {
+ const doesNotExist = element.length === 0
+ const isNotVisible = !element.is(':visible')
+
+ // eslint-disable-next-line no-unused-expressions
+ expect(doesNotExist || isNotVisible, 'does not exist or is not visible').to.be.true
+}
+
+/**
+ * Get the settings users list
+ * @return Cypress chainable object
+ */
+export function getUserList() {
+ return cy.get('[data-cy-user-list]')
+}
+
+/**
+ * Get the row entry for given userId within the settings users list
+ *
+ * @param userId the user to query
+ * @return Cypress chainable object
+ */
+export function getUserListRow(userId: string) {
+ return getUserList().find(`[data-cy-user-row="${userId}"]`)
+}
+
+export function waitLoading(selector: string) {
+ // We need to make sure the element is loading, otherwise the "done loading" will succeed even if we did not start loading.
+ // But Cypress might also be simply too slow to catch the loading phase. Thats why we need to wait in this case.
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.get(`${selector}[data-loading]`).if().should('exist').else().wait(1000)
+ // https://github.com/NoriSte/cypress-wait-until/issues/75#issuecomment-572685623
+ cy.waitUntil(() => Cypress.$(selector).length > 0 && !Cypress.$(selector).attr('data-loading')?.length, { timeout: 10000 })
+}
+
+/**
+ * Toggle the edit button of the user row
+ * @param user The user row to edit
+ * @param toEdit True if it should be switch to edit mode, false to switch to read-only
+ */
+export function toggleEditButton(user: User, toEdit = true) {
+ // see that the list of users contains the user
+ getUserListRow(user.userId).should('exist')
+ // toggle the edit mode for the user
+ .find('[data-cy-user-list-cell-actions]')
+ .find(`[data-cy-user-list-action-toggle-edit="${!toEdit}"]`)
+ .if()
+ .click({ force: true })
+ .else()
+ // otherwise ensure the button is already in edit mode
+ .then(() => getUserListRow(user.userId)
+ .find(`[data-cy-user-list-action-toggle-edit="${toEdit}"]`)
+ .should('exist'),
+ )
+}
+
+/**
+ * Handle the confirm password dialog (if needed)
+ * @param adminPassword The admin password for the dialog
+ */
+export function handlePasswordConfirmation(adminPassword = 'admin') {
+ const handleModal = (context: Cypress.Chainable) => {
+ return context.contains('.modal-container', 'Confirm your password')
+ .if()
+ .within(() => {
+ cy.get('input[type="password"]').type(adminPassword)
+ cy.get('button').contains('Confirm').click()
+ })
+ }
+
+ return cy.get('body')
+ .if()
+ .then(() => handleModal(cy.get('body')))
+ .else()
+ // Handle if inside a cy.within
+ .root().closest('body')
+ .then(($body) => handleModal(cy.wrap($body)))
+}
diff --git a/cypress/e2e/settings/users_columns.cy.ts b/cypress/e2e/settings/users_columns.cy.ts
new file mode 100644
index 00000000000..0afbf14e773
--- /dev/null
+++ b/cypress/e2e/settings/users_columns.cy.ts
@@ -0,0 +1,94 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { User } from '@nextcloud/cypress'
+import { assertNotExistOrNotVisible, getUserList } from './usersUtils.js'
+
+const admin = new User('admin', 'admin')
+
+describe('Settings: Show and hide columns', function() {
+ before(function() {
+ cy.login(admin)
+ // open the User settings
+ cy.visit('/settings/users')
+ })
+
+ beforeEach(function() {
+ // open the settings dialog
+ cy.contains('button', 'Account management settings').click()
+ // reset all visibility toggles
+ cy.get('.modal-container #settings-section_visibility-settings input[type="checkbox"]').uncheck({ force: true })
+
+ cy.contains('.modal-container', 'Account management settings').within(() => {
+ // enable the last login toggle
+ cy.get('[data-test="showLastLogin"] input[type="checkbox"]').check({ force: true })
+ // close the settings dialog
+ cy.get('button.modal-container__close').click()
+ })
+ cy.waitUntil(() => cy.get('.modal-container').should(el => assertNotExistOrNotVisible(el)))
+ })
+
+ it('Can show a column', function() {
+ // see that the language column is not in the header
+ cy.get('[data-cy-user-list-header-languages]').should('not.exist')
+
+ // see that the language column is not in all user rows
+ cy.get('tbody.user-list__body tr').each(($row) => {
+ cy.wrap($row).get('[data-test="language"]').should('not.exist')
+ })
+
+ // open the settings dialog
+ cy.contains('button', 'Account management settings').click()
+
+ cy.contains('.modal-container', 'Account management settings').within(() => {
+ // enable the language toggle
+ cy.get('[data-test="showLanguages"] input[type="checkbox"]').should('not.be.checked')
+ cy.get('[data-test="showLanguages"] input[type="checkbox"]').check({ force: true })
+ cy.get('[data-test="showLanguages"] input[type="checkbox"]').should('be.checked')
+ // close the settings dialog
+ cy.get('button.modal-container__close').click()
+ })
+ cy.waitUntil(() => cy.get('.modal-container').should(el => assertNotExistOrNotVisible(el)))
+
+ // see that the language column is in the header
+ cy.get('[data-cy-user-list-header-languages]').should('exist')
+
+ // see that the language column is in all user rows
+ getUserList().find('tbody tr').each(($row) => {
+ cy.wrap($row).get('[data-cy-user-list-cell-language]').should('exist')
+ })
+ })
+
+ it('Can hide a column', function() {
+ // see that the last login column is in the header
+ cy.get('[data-cy-user-list-header-last-login]').should('exist')
+
+ // see that the last login column is in all user rows
+ getUserList().find('tbody tr').each(($row) => {
+ cy.wrap($row).get('[data-cy-user-list-cell-last-login]').should('exist')
+ })
+
+ // open the settings dialog
+ cy.contains('button', 'Account management settings').click()
+
+ cy.contains('.modal-container', 'Account management settings').within(() => {
+ // disable the last login toggle
+ cy.get('[data-test="showLastLogin"] input[type="checkbox"]').should('be.checked')
+ cy.get('[data-test="showLastLogin"] input[type="checkbox"]').uncheck({ force: true })
+ cy.get('[data-test="showLastLogin"] input[type="checkbox"]').should('not.be.checked')
+ // close the settings dialog
+ cy.get('button.modal-container__close').click()
+ })
+ cy.waitUntil(() => cy.contains('.modal-container', 'Account management settings').should(el => assertNotExistOrNotVisible(el)))
+
+ // see that the last login column is not in the header
+ cy.get('[data-cy-user-list-header-last-login]').should('not.exist')
+
+ // see that the last login column is not in all user rows
+ getUserList().find('tbody tr').each(($row) => {
+ cy.wrap($row).get('[data-cy-user-list-cell-last-login]').should('not.exist')
+ })
+ })
+})
diff --git a/cypress/e2e/settings/users_disable.cy.ts b/cypress/e2e/settings/users_disable.cy.ts
new file mode 100644
index 00000000000..6195d43e211
--- /dev/null
+++ b/cypress/e2e/settings/users_disable.cy.ts
@@ -0,0 +1,79 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { User } from '@nextcloud/cypress'
+import { getUserListRow } from './usersUtils'
+import { clearState } from '../../support/commonUtils'
+
+const admin = new User('admin', 'admin')
+
+describe('Settings: Disable and enable users', function() {
+ let testUser: User
+
+ beforeEach(function() {
+ clearState()
+ cy.createRandomUser().then(($user) => {
+ testUser = $user
+ })
+ cy.login(admin)
+ // open the User settings
+ cy.visit('/settings/users')
+ })
+
+ // Not guranteed to run but would be nice to cleanup
+ after(() => {
+ cy.deleteUser(testUser)
+ })
+
+ it('Can disable the user', function() {
+ // ensure user is enabled
+ cy.enableUser(testUser)
+
+ // see that the user is in the list of active users
+ getUserListRow(testUser.userId).within(() => {
+ // see that the list of users contains the user testUser
+ cy.contains(testUser.userId).should('exist')
+ // open the actions menu for the user
+ cy.get('[data-cy-user-list-cell-actions] button.action-item__menutoggle').click({ scrollBehavior: 'center' })
+ })
+
+ // The "Disable account" action in the actions menu is shown and clicked
+ cy.get('.action-item__popper .action').contains('Disable account').should('exist').click()
+ // When clicked the section is not shown anymore
+ getUserListRow(testUser.userId).should('not.exist')
+ // But the disabled user section now exists
+ cy.get('#disabled').should('exist')
+ // Open disabled users section
+ cy.get('#disabled a').click()
+ cy.url().should('match', /\/disabled/)
+ // The list of disabled users should now contain the user
+ getUserListRow(testUser.userId).should('exist')
+ })
+
+ it('Can enable the user', function() {
+ // ensure user is disabled
+ cy.enableUser(testUser, false).reload()
+
+ // Open disabled users section
+ cy.get('#disabled a').click()
+ cy.url().should('match', /\/disabled/)
+
+ // see that the user is in the list of active users
+ getUserListRow(testUser.userId).within(() => {
+ // see that the list of disabled users contains the user testUser
+ cy.contains(testUser.userId).should('exist')
+ // open the actions menu for the user
+ cy.get('[data-cy-user-list-cell-actions] button.action-item__menutoggle').click({ scrollBehavior: 'center' })
+ })
+
+ // The "Enable account" action in the actions menu is shown and clicked
+ cy.get('.action-item__popper .action').contains('Enable account').should('exist').click()
+ // When clicked the section is not shown anymore
+ cy.get('#disabled').should('not.exist')
+ // Make sure it is still gone after the reload reload
+ cy.reload().login(admin)
+ cy.get('#disabled').should('not.exist')
+ })
+})
diff --git a/cypress/e2e/settings/users_groups.cy.ts b/cypress/e2e/settings/users_groups.cy.ts
new file mode 100644
index 00000000000..8d84ddc6bb4
--- /dev/null
+++ b/cypress/e2e/settings/users_groups.cy.ts
@@ -0,0 +1,291 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { User } from '@nextcloud/cypress'
+import { assertNotExistOrNotVisible, getUserListRow, handlePasswordConfirmation, toggleEditButton } from './usersUtils'
+
+// eslint-disable-next-line n/no-extraneous-import
+import randomString from 'crypto-random-string'
+
+const admin = new User('admin', 'admin')
+
+describe('Settings: Create groups', () => {
+ before(() => {
+ cy.login(admin)
+ cy.visit('/settings/users')
+ })
+
+ it('Can create a group', () => {
+ const groupName = randomString(7)
+ // open the Create group menu
+ cy.get('button[aria-label="Create group"]').click()
+
+ cy.get('li[data-cy-users-settings-new-group-name]').within(() => {
+ // see that the group name is ""
+ cy.get('input').should('exist').and('have.value', '')
+ // set the group name to foo
+ cy.get('input').type(groupName)
+ // see that the group name is foo
+ cy.get('input').should('have.value', groupName)
+ // submit the group name
+ cy.get('input ~ button').click()
+ })
+
+ // Make sure no confirmation modal is shown
+ handlePasswordConfirmation(admin.password)
+
+ // see that the created 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')
+ })
+ })
+})
+
+describe('Settings: Assign user to a group', { testIsolation: false }, () => {
+ const groupName = randomString(7)
+ let testUser: User
+
+ after(() => cy.deleteUser(testUser))
+ before(() => {
+ cy.createRandomUser().then((user) => {
+ testUser = user
+ })
+ 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"]').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', () => {
+ getUserListRow(testUser.userId)
+ .contains(testUser.userId)
+ .should('exist')
+ .scrollIntoView()
+ })
+
+ it('switch into user edit mode', () => {
+ toggleEditButton(testUser)
+ getUserListRow(testUser.userId)
+ .find('[data-cy-user-list-input-groups]')
+ .should('exist')
+ })
+
+ it('assign the group', () => {
+ // focus inside the input
+ getUserListRow(testUser.userId)
+ .find('[data-cy-user-list-input-groups] input')
+ .click({ force: true })
+ // enter the group name
+ getUserListRow(testUser.userId)
+ .find('[data-cy-user-list-input-groups] input')
+ .type(`${groupName.slice(0, 5)}`) // only type part as otherwise we would create a new one with the same name
+ cy.contains('li.vs__dropdown-option', groupName)
+ .click({ force: true })
+
+ handlePasswordConfirmation(admin.password)
+ })
+
+ it('leave the user edit mode', () => {
+ toggleEditButton(testUser, false)
+ })
+
+ it('see the group was successfully assigned', () => {
+ // see a new memeber
+ cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
+ .find('.counter-bubble__counter')
+ .should('contain', '1')
+ })
+
+ it('validate the user was added on backend', () => {
+ cy.runOccCommand(`user:info --output=json '${testUser.userId}'`).then((output) => {
+ cy.wrap(output.code).should('eq', 0)
+ cy.wrap(JSON.parse(output.stdout)?.groups).should('include', groupName)
+ })
+ })
+})
+
+describe('Settings: Delete an empty group', { testIsolation: false }, () => {
+ const groupName = randomString(7)
+
+ 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', () => {
+ // 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 "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 })
+
+ // Make sure no confirmation modal is shown
+ handlePasswordConfirmation(admin.password)
+ })
+
+ it('deleted group is not shown anymore', () => {
+ // 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))
+ expect(groups).to.not.include(groupName)
+ })
+ })
+})
+
+describe('Settings: Delete a non empty group', () => {
+ let testUser: User
+ const groupName = randomString(7)
+
+ before(() => {
+ cy.runOccCommand(`group:add '${groupName}'`)
+ cy.createRandomUser().then(($user) => {
+ testUser = $user
+ 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"]').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"]').find('li').contains(groupName)
+ .find('button.action-item__menutoggle')
+ .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 })
+
+ // Make sure no confirmation modal is shown
+ handlePasswordConfirmation(admin.password)
+ })
+
+ it('deleted group is not shown anymore', () => {
+ // 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))
+ expect(groups).to.not.include(groupName)
+ })
+ })
+})
+
+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}'`)
+ })
+ })
+
+ // Add two groups and add one user to group B
+ cy.runOccCommand('group:add A')
+ cy.runOccCommand('group:add B')
+ cy.createRandomUser().then((user) => {
+ cy.runOccCommand(`group:adduser B '${user.userId}'`)
+ })
+
+ // Visit the settings as admin
+ cy.login(admin)
+ cy.visit('/settings/users')
+ })
+
+ it('Can set sort by member count', () => {
+ // open the settings dialog
+ cy.contains('button', 'Account management settings').click()
+
+ cy.contains('.modal-container', 'Account management settings').within(() => {
+ cy.get('[data-test="sortGroupsByMemberCount"] input[type="radio"]').scrollIntoView()
+ cy.get('[data-test="sortGroupsByMemberCount"] input[type="radio"]').check({ force: true })
+ // close the settings dialog
+ cy.get('button.modal-container__close').click()
+ })
+ cy.waitUntil(() => cy.get('.modal-container').should(el => assertNotExistOrNotVisible(el)))
+ })
+
+ it('See that the groups are sorted by the member count', () => {
+ cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => {
+ cy.get('li').eq(0).should('contain', 'B') // 1 member
+ cy.get('li').eq(1).should('contain', 'A') // 0 members
+ })
+ })
+
+ it('See that the order is preserved after a reload', () => {
+ cy.reload()
+ cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => {
+ cy.get('li').eq(0).should('contain', 'B') // 1 member
+ cy.get('li').eq(1).should('contain', 'A') // 0 members
+ })
+ })
+
+ it('Can set sort by group name', () => {
+ // open the settings dialog
+ cy.contains('button', 'Account management settings').click()
+
+ cy.contains('.modal-container', 'Account management settings').within(() => {
+ cy.get('[data-test="sortGroupsByName"] input[type="radio"]').scrollIntoView()
+ cy.get('[data-test="sortGroupsByName"] input[type="radio"]').check({ force: true })
+ // close the settings dialog
+ cy.get('button.modal-container__close').click()
+ })
+ cy.waitUntil(() => cy.get('.modal-container').should(el => assertNotExistOrNotVisible(el)))
+ })
+
+ it('See that the groups are sorted by the user count', () => {
+ cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => {
+ cy.get('li').eq(0).should('contain', 'A')
+ cy.get('li').eq(1).should('contain', 'B')
+ })
+ })
+
+ it('See that the order is preserved after a reload', () => {
+ cy.reload()
+ cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => {
+ cy.get('li').eq(0).should('contain', 'A')
+ cy.get('li').eq(1).should('contain', 'B')
+ })
+ })
+})
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
new file mode 100644
index 00000000000..749bded2e94
--- /dev/null
+++ b/cypress/e2e/settings/users_modify.cy.ts
@@ -0,0 +1,225 @@
+/**
+ * SPDX-FileCopyrightText: 2023 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: Change user properties', function() {
+ let user: User
+
+ beforeEach(function() {
+ clearState()
+ cy.createRandomUser().then(($user) => { user = $user })
+ cy.login(admin)
+ })
+
+ it('Can change the display name', function() {
+ // open the User settings as admin
+ cy.visit('/settings/users')
+
+ // toggle edit button into edit mode
+ toggleEditButton(user, true)
+
+ getUserListRow(user.userId).within(() => {
+ // set the display name
+ cy.get('[data-cy-user-list-input-displayname]').should('exist').and('have.value', user.userId)
+ cy.get('[data-cy-user-list-input-displayname]').clear()
+ cy.get('[data-cy-user-list-input-displayname]').type('John Doe')
+ cy.get('[data-cy-user-list-input-displayname]').should('have.value', 'John Doe')
+ cy.get('[data-cy-user-list-input-displayname] ~ button').click()
+
+ // Make sure no confirmation modal is shown
+ handlePasswordConfirmation(admin.password)
+
+ // see that the display name cell is done loading
+ waitLoading('[data-cy-user-list-input-displayname]')
+ })
+
+ // Success message is shown
+ cy.get('.toastify.toast-success').contains(/Display.+name.+was.+successfully.+changed/i).should('exist')
+ })
+
+ it('Can change the password', function() {
+ // open the User settings as admin
+ cy.visit('/settings/users')
+
+ // toggle edit button into edit mode
+ toggleEditButton(user, true)
+
+ getUserListRow(user.userId).within(() => {
+ // see that the password of user is ""
+ cy.get('[data-cy-user-list-input-password]').should('exist').and('have.value', '')
+ // set the password for user to 123456
+ cy.get('[data-cy-user-list-input-password]').type('123456')
+ // When I set the password for user to 123456
+ cy.get('[data-cy-user-list-input-password]').should('have.value', '123456')
+ cy.get('[data-cy-user-list-input-password] ~ button').click()
+
+ // Make sure no confirmation modal is shown
+ handlePasswordConfirmation(admin.password)
+
+ // see that the password cell for user is done loading
+ waitLoading('[data-cy-user-list-input-password]')
+ // password input is emptied on change
+ cy.get('[data-cy-user-list-input-password]').should('have.value', '')
+ })
+
+ // Success message is shown
+ cy.get('.toastify.toast-success').contains(/Password.+successfully.+changed/i).should('exist')
+ })
+
+ it('Can change the email address', function() {
+ // open the User settings as admin
+ cy.visit('/settings/users')
+
+ // toggle edit button into edit mode
+ toggleEditButton(user, true)
+
+ getUserListRow(user.userId).find('[data-cy-user-list-cell-email]').within(() => {
+ // see that the email of user is ""
+ cy.get('input').should('exist').and('have.value', '')
+ // set the email for user to mymail@example.com
+ cy.get('input').type('mymail@example.com')
+ // When I set the password for user to mymail@example.com
+ cy.get('input').should('have.value', 'mymail@example.com')
+ cy.get('input ~ button').click()
+
+ // Make sure no confirmation modal is shown
+ handlePasswordConfirmation(admin.password)
+
+ // see that the password cell for user is done loading
+ waitLoading('[data-cy-user-list-input-email]')
+ })
+
+ // Success message is shown
+ cy.get('.toastify.toast-success').contains(/Email.+successfully.+changed/i).should('exist')
+ })
+
+ it('Can change the user quota to a predefined one', function() {
+ // open the User settings as admin
+ cy.visit('/settings/users')
+
+ // toggle edit button into edit mode
+ toggleEditButton(user, true)
+
+ getUserListRow(user.userId).find('[data-cy-user-list-cell-quota]').scrollIntoView()
+ getUserListRow(user.userId).find('[data-cy-user-list-cell-quota] [data-cy-user-list-input-quota]').within(() => {
+ // see that the quota of user is unlimited
+ cy.get('.vs__selected').should('exist').and('contain.text', 'Unlimited')
+ // Open the quota selector
+ cy.get('[role="combobox"]').click({ force: true })
+ // see that there are default options for the quota
+ cy.get('li').then(($options) => {
+ expect($options).to.have.length(5)
+ cy.wrap($options).contains('Default quota')
+ cy.wrap($options).contains('Unlimited')
+ cy.wrap($options).contains('1 GB')
+ cy.wrap($options).contains('10 GB')
+ // select 5 GB
+ cy.wrap($options).contains('5 GB').click({ force: true })
+
+ // Make sure no confirmation modal is shown
+ handlePasswordConfirmation(admin.password)
+ })
+ // see that the quota of user is 5 GB
+ cy.get('.vs__selected').should('exist').and('contain.text', '5 GB')
+ })
+
+ // see that the changes are loading
+ waitLoading('[data-cy-user-list-input-quota]')
+
+ // finish editing the user
+ toggleEditButton(user, false)
+
+ // I see that the quota was set on the backend
+ cy.runOccCommand(`user:info --output=json '${user.userId}'`).then(($result) => {
+ expect($result.code).to.equal(0)
+ const info = JSON.parse($result.stdout)
+ expect(info?.quota).to.equal('5 GB')
+ })
+ })
+
+ it('Can change the user quota to a custom value', function() {
+ // open the User settings as admin
+ cy.visit('/settings/users')
+
+ // toggle edit button into edit mode
+ toggleEditButton(user, true)
+
+ getUserListRow(user.userId).find('[data-cy-user-list-cell-quota]').scrollIntoView()
+ getUserListRow(user.userId).find('[data-cy-user-list-cell-quota]').within(() => {
+ // see that the quota of user is unlimited
+ cy.get('.vs__selected').should('exist').and('contain.text', 'Unlimited')
+ // set the quota to 4 MB
+ cy.get('[data-cy-user-list-input-quota] input').type('4 MB{enter}')
+
+ // Make sure no confirmation modal is shown
+ handlePasswordConfirmation(admin.password)
+
+ // see that the quota of user is 4 MB
+ // TODO: Enable this after the file size handling is fixed
+ // cy.get('.vs__selected').should('exist').and('contain.text', '4 MB')
+
+ // see that the changes are loading
+ waitLoading('[data-cy-user-list-input-quota]')
+ })
+
+ // finish editing the user
+ toggleEditButton(user, false)
+
+ // I see that the quota was set on the backend
+ cy.runOccCommand(`user:info --output=json '${user.userId}'`).then(($result) => {
+ expect($result.code).to.equal(0)
+ // TODO: Enable this after the file size handling is fixed!!!!!!
+ // const info = JSON.parse($result.stdout)
+ // expect(info?.quota).to.equal('4 MB')
+ })
+ })
+
+ it('Can make user a subadmin of a group', function() {
+ // create a group
+ const groupName = 'userstestgroup'
+ cy.runOccCommand(`group:add '${groupName}'`)
+
+ // open the User settings as admin
+ cy.visit('/settings/users')
+
+ // toggle edit button into edit mode
+ toggleEditButton(user, true)
+
+ getUserListRow(user.userId).find('[data-cy-user-list-cell-subadmins]').scrollIntoView()
+ getUserListRow(user.userId).find('[data-cy-user-list-cell-subadmins]').within(() => {
+ // see that the user is no subadmin
+ 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 })
+
+ // handle password confirmation on time out
+ handlePasswordConfirmation(admin.password)
+
+ // see that the user is subadmin of the group
+ cy.get('.vs__selected').should('exist').and('contain.text', groupName)
+ })
+
+ waitLoading('[data-cy-user-list-input-subadmins]')
+
+ // finish editing the user
+ toggleEditButton(user, false)
+
+ // I see that the quota was set on the backend
+ cy.getUserData(user).then(($response) => {
+ expect($response.status).to.equal(200)
+ const dom = (new DOMParser()).parseFromString($response.body, 'text/xml')
+ expect(dom.querySelector('subadmin element')?.textContent).to.contain(groupName)
+ })
+ })
+})
diff --git a/cypress/e2e/systemtags/admin-settings.cy.ts b/cypress/e2e/systemtags/admin-settings.cy.ts
new file mode 100644
index 00000000000..ac85cf34d65
--- /dev/null
+++ b/cypress/e2e/systemtags/admin-settings.cy.ts
@@ -0,0 +1,121 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { User } from '@nextcloud/cypress'
+
+const admin = new User('admin', 'admin')
+
+const tagName = 'foo'
+const updatedTagName = 'bar'
+
+describe('Create system tags', () => {
+ before(() => {
+ cy.login(admin)
+ cy.visit('/settings/admin')
+ })
+
+ it('Can create a tag', () => {
+ cy.get('input#system-tag-name').should('exist').and('have.value', '')
+ cy.get('input#system-tag-name').type(tagName)
+ cy.get('input#system-tag-name').should('have.value', tagName)
+ // submit the form
+ cy.get('input#system-tag-name').type('{enter}')
+
+ // see that the created tag is in the list
+ cy.get('input#system-tags-input').focus()
+ cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then(id => {
+ cy.get(`ul#${id}`).within(() => {
+ cy.contains('li', tagName).should('exist')
+ // ensure only one tag exists
+ cy.get('li').should('have.length', 1)
+ })
+ })
+ })
+})
+
+describe('Update system tags', { testIsolation: false }, () => {
+ before(() => {
+ cy.login(admin)
+ cy.visit('/settings/admin')
+ })
+
+ it('select the tag', () => {
+ // select the tag to edit
+ cy.get('input#system-tags-input').focus()
+ cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then(id => {
+ cy.get(`ul#${id}`).within(() => {
+ cy.contains('li', tagName).should('exist').click()
+ })
+ })
+ // see that the tag name matches the selected tag
+ cy.get('input#system-tag-name').should('exist').and('have.value', tagName)
+ // see that the tag level matches the selected tag
+ cy.get('input#system-tag-level').click()
+ cy.get('input#system-tag-level').siblings('.vs__selected').contains('Public').should('exist')
+ })
+
+ it('update the tag name and level', () => {
+ cy.get('input#system-tag-name').clear()
+ cy.get('input#system-tag-name').type(updatedTagName)
+ cy.get('input#system-tag-name').should('have.value', updatedTagName)
+ // select the new tag level
+ cy.get('input#system-tag-level').focus()
+ cy.get('input#system-tag-level').invoke('attr', 'aria-controls').then(id => {
+ cy.get(`ul#${id}`).within(() => {
+ cy.contains('li', 'Invisible').should('exist').click()
+ })
+ })
+ // submit the form
+ cy.get('input#system-tag-name').type('{enter}')
+ })
+
+ it('see the tag was successfully updated', () => {
+ cy.get('input#system-tags-input').focus()
+ cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then(id => {
+ cy.get(`ul#${id}`).within(() => {
+ cy.contains('li', `${updatedTagName} (invisible)`).should('exist')
+ // ensure only one tag exists
+ cy.get('li').should('have.length', 1)
+ })
+ })
+ })
+})
+
+describe('Delete system tags', { testIsolation: false }, () => {
+ before(() => {
+ cy.login(admin)
+ cy.visit('/settings/admin')
+ })
+
+ it('select the tag', () => {
+ // select the tag to edit
+ cy.get('input#system-tags-input').focus()
+ cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then(id => {
+ cy.get(`ul#${id}`).within(() => {
+ cy.contains('li', `${updatedTagName} (invisible)`).should('exist').click()
+ })
+ })
+ // see that the tag name matches the selected tag
+ cy.get('input#system-tag-name').should('exist').and('have.value', updatedTagName)
+ // see that the tag level matches the selected tag
+ cy.get('input#system-tag-level').focus()
+ cy.get('input#system-tag-level').siblings('.vs__selected').contains('Invisible').should('exist')
+ })
+
+ it('can delete the tag', () => {
+ cy.get('.system-tag-form__row').within(() => {
+ cy.contains('button', 'Delete').should('be.enabled').click()
+ })
+ })
+
+ it('see that the deleted tag is not present', () => {
+ cy.get('input#system-tags-input').focus()
+ cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then(id => {
+ cy.get(`ul#${id}`).within(() => {
+ cy.contains('li', updatedTagName).should('not.exist')
+ })
+ })
+ })
+})
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
new file mode 100644
index 00000000000..bff7df28e8e
--- /dev/null
+++ b/cypress/e2e/theming/a11y-color-contrast.cy.ts
@@ -0,0 +1,157 @@
+/**
+ * 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 = {
+ 'Main text': {
+ foregroundColors: [
+ 'color-main-text',
+ // 'color-text-light', deprecated
+ // 'color-text-lighter', deprecated
+ 'color-text-maxcontrast',
+ ],
+ backgroundColors: [
+ 'color-main-background',
+ 'color-background-hover',
+ 'color-background-dark',
+ // 'color-background-darker', this should only be used for elements not for text
+ ],
+ },
+ 'blurred background': {
+ foregroundColors: [
+ 'color-main-text',
+ 'color-text-maxcontrast-blur',
+ ],
+ backgroundColors: [
+ 'color-main-background-blur',
+ ],
+ },
+ Primary: {
+ foregroundColors: [
+ 'color-primary-text',
+ ],
+ backgroundColors: [
+ // 'color-primary-default', this should only be used for elements not for text!
+ // 'color-primary-hover', this should only be used for elements and not for text!
+ 'color-primary',
+ ],
+ },
+ 'Primary light': {
+ foregroundColors: [
+ 'color-primary-light-text',
+ ],
+ backgroundColors: [
+ 'color-primary-light',
+ 'color-primary-light-hover',
+ ],
+ },
+ 'Primary element': {
+ foregroundColors: [
+ 'color-primary-element-text',
+ 'color-primary-element-text-dark',
+ ],
+ backgroundColors: [
+ 'color-primary-element',
+ 'color-primary-element-hover',
+ ],
+ },
+ 'Primary element light': {
+ foregroundColors: [
+ 'color-primary-element-light-text',
+ ],
+ backgroundColors: [
+ 'color-primary-element-light',
+ 'color-primary-element-light-hover',
+ ],
+ },
+ 'Servity information texts': {
+ foregroundColors: [
+ 'color-error-text',
+ 'color-warning-text',
+ 'color-success-text',
+ 'color-info-text',
+ ],
+ backgroundColors: [
+ 'color-main-background',
+ 'color-background-hover',
+ 'color-main-background-blur',
+ ],
+ },
+}
+
+/**
+ * Create a wrapper element with color and background set
+ *
+ * @param foreground The foreground color (css variable without leading --)
+ * @param background The background color
+ */
+function createTestCase(foreground: string, background: string) {
+ const wrapper = document.createElement('div')
+ wrapper.style.padding = '14px'
+ wrapper.style.color = `var(--${foreground})`
+ wrapper.style.backgroundColor = `var(--${background})`
+ if (background.includes('blur')) {
+ wrapper.style.backdropFilter = 'var(--filter-background-blur)'
+ }
+
+ const testCase = document.createElement('div')
+ testCase.innerText = `${foreground} ${background}`
+ testCase.setAttribute('data-cy-testcase', '')
+
+ wrapper.appendChild(testCase)
+ return wrapper
+}
+
+describe('Accessibility of Nextcloud theming colors', () => {
+ for (const theme of themesToTest) {
+ context(`Theme: ${theme}`, () => {
+ before(() => {
+ cy.createRandomUser().then(($user) => {
+ // set user 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' })
+ })
+ })
+
+ beforeEach(() => {
+ cy.document().then(doc => {
+ // 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('#content')
+ if (root === null) {
+ throw new Error('No test root found')
+ }
+ root.innerHTML = ''
+ })
+ })
+
+ for (const [name, { backgroundColors, foregroundColors }] of Object.entries(testCases)) {
+ context(`Accessibility of CSS color variables for ${name}`, () => {
+ for (const foreground of foregroundColors) {
+ for (const background of backgroundColors) {
+ it(`color contrast of ${foreground} on ${background}`, () => {
+ cy.document().then(doc => {
+ const element = createTestCase(foreground, background)
+ 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
+ root!.appendChild(element)
+
+ cy.checkA11y('[data-cy-testcase]', {
+ runOnly: ['color-contrast'],
+ })
+ })
+ })
+ }
+ }
+ })
+ }
+ })
+ }
+})
diff --git a/cypress/e2e/theming/admin-settings.cy.ts b/cypress/e2e/theming/admin-settings.cy.ts
new file mode 100644
index 00000000000..4207b98f711
--- /dev/null
+++ b/cypress/e2e/theming/admin-settings.cy.ts
@@ -0,0 +1,595 @@
+/**
+ * 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 {
+ defaultPrimary,
+ defaultBackground,
+ pickRandomColor,
+ validateBodyThemingCss,
+ validateUserThemingDefaultCss,
+ expectBackgroundColor,
+} from './themingUtils'
+import { NavigationHeader } from '../../pages/NavigationHeader'
+
+const admin = new User('admin', 'admin')
+
+describe('Admin theming settings visibility check', function() {
+ before(function() {
+ // Just in case previous test failed
+ cy.resetAdminTheming()
+ cy.login(admin)
+ })
+
+ 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('be.visible')
+ })
+
+ it('See the default settings', function() {
+ 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('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))
+ })
+})
+
+describe('Change the primary color and reset it', function() {
+ let selectedColor = ''
+
+ before(function() {
+ // Just in case previous test failed
+ cy.resetAdminTheming()
+ cy.login(admin)
+ })
+
+ 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('be.visible')
+ })
+
+ it('Change the primary color', function() {
+ cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor')
+
+ pickRandomColor('[data-admin-theming-setting-primary-color]').then(
+ (color) => {
+ selectedColor = color
+ },
+ )
+
+ cy.wait('@setColor')
+ 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,
+ defaultPrimary,
+ ),
+ )
+ cy.screenshot()
+ })
+
+ it('Undo theming settings and validate login page again', function() {
+ cy.resetAdminTheming()
+ cy.visit('/')
+
+ cy.waitUntil(validateBodyThemingCss)
+ cy.screenshot()
+ })
+})
+
+describe('Remove the default background and restore it', function() {
+ before(function() {
+ // Just in case previous test failed
+ cy.resetAdminTheming()
+ cy.login(admin)
+ })
+
+ 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('be.visible')
+ })
+
+ it('Remove the default background', function() {
+ 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')
+ return backgroundPlain !== ''
+ }),
+ )
+ })
+
+ it('Screenshot the login page and validate login page', function() {
+ cy.logout()
+ cy.visit('/')
+
+ cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, null))
+ cy.screenshot()
+ })
+
+ it('Undo theming settings and validate login page again', function() {
+ cy.resetAdminTheming()
+ cy.visit('/')
+
+ cy.waitUntil(validateBodyThemingCss)
+ cy.screenshot()
+ })
+})
+
+describe('Remove the default background with a custom background color', function() {
+ let selectedColor = ''
+
+ before(function() {
+ // Just in case previous test failed
+ cy.resetAdminTheming()
+ cy.login(admin)
+ })
+
+ 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('be.visible')
+ })
+
+ 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,
+ defaultBackground,
+ selectedColor,
+ ),
+ )
+ })
+
+ it('Remove the default background', function() {
+ cy.intercept('*/apps/theming/ajax/updateStylesheet').as(
+ 'removeBackground',
+ )
+
+ cy.get('[data-admin-theming-setting-file-remove]').scrollIntoView()
+ cy.get('[data-admin-theming-setting-file-remove]').click({
+ force: true,
+ })
+
+ cy.wait('@removeBackground')
+ })
+
+ it('Screenshot the login page and validate login page', function() {
+ cy.logout()
+ cy.visit('/')
+
+ cy.waitUntil(() =>
+ validateBodyThemingCss(defaultPrimary, null, selectedColor),
+ )
+ cy.screenshot()
+ })
+
+ it('Undo theming settings and validate login page again', function() {
+ cy.resetAdminTheming()
+ cy.visit('/')
+
+ cy.waitUntil(validateBodyThemingCss)
+ cy.screenshot()
+ })
+})
+
+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()
+ cy.resetUserTheming(admin)
+ cy.login(admin)
+ })
+
+ 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('be.visible')
+ })
+
+ it('Remove the default background', function() {
+ cy.intercept('*/apps/theming/ajax/updateStylesheet').as(
+ 'removeBackground',
+ )
+
+ cy.get('[data-admin-theming-setting-file-remove]').click()
+
+ cy.wait('@removeBackground')
+ })
+
+ it('Change the background color', function() {
+ cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor')
+
+ // Pick one of the bright color preset
+ pickRandomColor(
+ '[data-admin-theming-setting-background-color]',
+ 4,
+ ).then((color) => {
+ selectedColor = color
+ })
+
+ cy.wait('@setColor')
+ cy.waitUntil(() =>
+ validateBodyThemingCss(defaultPrimary, null, selectedColor),
+ )
+ })
+
+ it('See the header being inverted', function() {
+ cy.waitUntil(() =>
+ navigationHeader
+ .getNavigationEntries()
+ .find('img')
+ .then((el) => {
+ let ret = true
+ el.each(function() {
+ ret = ret && window.getComputedStyle(this).filter === 'invert(1)'
+ })
+ return ret
+ })
+ )
+ })
+})
+
+describe('Change the login fields then reset them', function() {
+ const name = 'ABCdef123'
+ const url = 'https://example.com'
+ const slogan = 'Testing is fun'
+
+ before(function() {
+ // Just in case previous test failed
+ cy.resetAdminTheming()
+ cy.login(admin)
+ })
+
+ 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('be.visible')
+ })
+
+ it('Change the name field', 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.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.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.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')
+ })
+
+ it('Validate login screen changes', function() {
+ cy.logout()
+ cy.visit('/')
+
+ cy.get('[data-login-form-headline]').should('contain.text', name)
+ cy.get('footer p a').should('have.text', name)
+ cy.get('footer p a').should('have.attr', 'href', url)
+ cy.get('footer p').should('contain.text', `– ${slogan}`)
+ })
+
+ it('Undo theming settings', function() {
+ cy.resetAdminTheming()
+ })
+
+ it('Validate login screen changes again', function() {
+ cy.visit('/')
+
+ cy.get('[data-login-form-headline]').should('not.contain.text', name)
+ cy.get('footer p a').should('not.have.text', name)
+ cy.get('footer p a').should('not.have.attr', 'href', url)
+ cy.get('footer p').should('not.contain.text', `– ${slogan}`)
+ })
+})
+
+describe('Disable user theming and enable it back', function() {
+ before(function() {
+ // Just in case previous test failed
+ cy.resetAdminTheming()
+ cy.login(admin)
+ })
+
+ 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('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.wait('@disableUserTheming')
+ })
+
+ it('Login as user', function() {
+ cy.logout()
+ cy.createRandomUser().then((user) => {
+ cy.login(user)
+ })
+ })
+
+ it('User cannot not change background settings', function() {
+ cy.visit('/settings/user/theming')
+ cy.contains(
+ 'Customization has been disabled by your administrator',
+ ).should('exist')
+ })
+})
+
+describe('The user default background settings reflect the admin theming settings', function() {
+ let selectedColor = ''
+
+ before(function() {
+ // Just in case previous test failed
+ cy.resetAdminTheming()
+ cy.login(admin)
+ })
+
+ after(function() {
+ cy.resetAdminTheming()
+ })
+
+ 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('be.visible')
+ })
+
+ 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.wait('@setBackground')
+ 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(
+ defaultPrimary,
+ '/apps/theming/image/background?v=',
+ selectedColor,
+ ),
+ )
+ })
+
+ it('Login as user', function() {
+ cy.createRandomUser().then((user) => {
+ cy.login(user)
+ })
+ })
+
+ it('See the user background settings', function() {
+ cy.visit('/settings/user/theming')
+ cy.get('[data-user-theming-background-settings]').scrollIntoView()
+ cy.get('[data-user-theming-background-settings]').should('be.visible')
+ })
+
+ 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=',
+ ),
+ )
+ })
+})
+
+describe('The user default background settings reflect the admin theming settings with background removed', function() {
+ before(function() {
+ // Just in case previous test failed
+ cy.resetAdminTheming()
+ cy.login(admin)
+ })
+
+ after(function() {
+ cy.resetAdminTheming()
+ })
+
+ 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('be.visible')
+ })
+
+ it('Remove the default background', function() {
+ 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))
+ })
+
+ it('Login page should match admin theming settings', function() {
+ cy.logout()
+ cy.visit('/')
+
+ cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, null))
+ })
+
+ it('Login as user', function() {
+ cy.createRandomUser().then((user) => {
+ cy.login(user)
+ })
+ })
+
+ it('See the user background settings', function() {
+ cy.visit('/settings/user/theming')
+ cy.get('[data-user-theming-background-settings]').scrollIntoView()
+ cy.get('[data-user-theming-background-settings]').should('be.visible')
+ })
+
+ 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(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
new file mode 100644
index 00000000000..b4740beda1c
--- /dev/null
+++ b/cypress/e2e/theming/themingUtils.ts
@@ -0,0 +1,109 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { colord } from 'colord'
+
+export const defaultPrimary = '#00679e'
+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 primary color
+ * @param {string|null} expectedBackground the expected background
+ * @param {string|null} expectedBackgroundColor the expected background color (null to ignore)
+ */
+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 = expectedBackgroundColor === null || colord(guestBackgroundColor).isEqual(expectedBackgroundColor)
+ const isValidBackgroundImage = !expectedBackground
+ ? guestBackgroundImage === 'none'
+ : guestBackgroundImage.includes(expectedBackground)
+
+ console.debug({
+ isValidBackgroundColor,
+ isValidBackgroundImage,
+ guestBackgroundColor: colord(guestBackgroundColor).toHex(),
+ guestBackgroundImage,
+ })
+
+ 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())
+}
+
+/**
+ * Validate the user theming default select option css
+ *
+ * @param {string} expectedColor the expected color
+ * @param {string} expectedBackground the expected background
+ */
+export const validateUserThemingDefaultCss = function(expectedColor = defaultPrimary, expectedBackground: string|null = defaultBackground) {
+ const defaultSelectButton = Cypress.$('[data-user-theming-background-default]')
+ if (defaultSelectButton.length === 0) {
+ return false
+ }
+
+ const backgroundImage = defaultSelectButton.css('background-image')
+ const backgroundColor = defaultSelectButton.css('background-color')
+
+ const isValidBackgroundImage = !expectedBackground
+ ? (backgroundImage === 'none' || Cypress.$('body').css('background-image') === 'none')
+ : backgroundImage.includes(expectedBackground)
+
+ console.debug({
+ colorPickerOptionColor: colord(backgroundColor).toHex(),
+ expectedColor,
+ isValidBackgroundImage,
+ backgroundImage,
+ })
+
+ return isValidBackgroundImage && colord(backgroundColor).isEqual(expectedColor)
+}
+
+export const pickRandomColor = function(context: string, index?: number): Cypress.Chainable<string> {
+ // Pick one of the first 8 options
+ const randColour = index ?? Math.floor(Math.random() * 8)
+
+ const colorPreviewSelector = `${context} [data-admin-theming-setting-color]`
+
+ let oldColor = ''
+ cy.get(colorPreviewSelector).then(($el) => {
+ oldColor = $el.css('background-color')
+ })
+
+ // Open picker
+ 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()
+
+ // Wait for color change
+ cy.waitUntil(() => Cypress.$(colorPreviewSelector).css('background-color') !== oldColor)
+
+ // Get the selected color from the color preview block
+ return cy.get(colorPreviewSelector).then(($el) => $el.css('background-color'))
+}
diff --git a/cypress/e2e/theming/user-settings_app-order.cy.ts b/cypress/e2e/theming/user-settings_app-order.cy.ts
new file mode 100644
index 00000000000..11ef2f45382
--- /dev/null
+++ b/cypress/e2e/theming/user-settings_app-order.cy.ts
@@ -0,0 +1,292 @@
+/**
+ * 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'
+
+/**
+ * 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')
+}
+
+before(() => uninstallTestApp())
+
+describe('User theming set app order', () => {
+ const navigationHeader = new NavigationHeader()
+ let user: User
+
+ before(() => {
+ cy.resetAdminTheming()
+ // Create random user for this test
+ cy.createRandomUser().then(($user) => {
+ user = $user
+ cy.login($user)
+ })
+ })
+
+ after(() => cy.deleteUser(user))
+
+ it('See the app order settings', () => {
+ cy.visit('/settings/user/theming')
+
+ cy.get('.settings-section').contains('Navigation bar settings').should('exist')
+ cy.get('[data-cy-app-order]').scrollIntoView()
+ })
+
+ 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]')
+ .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('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')
+
+ 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()
+ 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(() => {
+ cy.resetAdminTheming()
+ // install a third app
+ installTestApp()
+ // set files as default app
+ cy.runOccCommand('config:system:set --value \'files\' defaultapp')
+
+ // Create random user for this test
+ cy.createRandomUser().then(($user) => {
+ user = $user
+ cy.login($user)
+ })
+ })
+
+ after(() => {
+ cy.deleteUser(user)
+ uninstallTestApp()
+ })
+
+ it('See files is the default app', () => {
+ // 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('See the app order settings: files is the first one', () => {
+ cy.visit('/settings/user/theming')
+ cy.get('[data-cy-app-order]').scrollIntoView()
+
+ 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', () => {
+ cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('not.be.visible')
+ cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="down"]').should('not.be.visible')
+
+ cy.get('[data-cy-app-order] [data-cy-app-order-element="dashboard"] [data-cy-app-order-button="up"]').should('not.be.visible')
+ cy.get('[data-cy-app-order] [data-cy-app-order-element="dashboard"] [data-cy-app-order-button="down"]').should('be.visible')
+
+ cy.get('[data-cy-app-order] [data-cy-app-order-element="testapp"] [data-cy-app-order-button="up"]').should('be.visible')
+ cy.get('[data-cy-app-order] [data-cy-app-order-element="testapp"] [data-cy-app-order-button="down"]').should('not.be.visible')
+ })
+
+ it('Change the order of the other apps', () => {
+ 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('@updateAppOrder')
+ cy.get('[data-cy-app-order] [data-cy-app-order-element="testapp"] [data-cy-app-order-button="up"]').click()
+ 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
+ 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()
+
+ 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]))
+ })
+})
+
+describe('User theming app order list accessibility', () => {
+ let user: User
+
+ before(() => {
+ cy.resetAdminTheming()
+ // Create random user for this test
+ cy.createRandomUser().then(($user) => {
+ user = $user
+ cy.login($user)
+ })
+ })
+
+ after(() => {
+ cy.deleteUser(user)
+ })
+
+ it('See the app order settings', () => {
+ cy.visit('/settings/user/theming')
+ cy.get('[data-cy-app-order]').scrollIntoView()
+ cy.get('[data-cy-app-order] [data-cy-app-order-element]').should('have.length', 2)
+ })
+
+ 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', () => {
+ cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="down"]').should('not.have.focus')
+ cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('not.have.focus')
+ cy.get('[data-cy-app-order] [data-cy-app-order-element="dashboard"] [data-cy-app-order-button="down"]').should('not.have.focus')
+ cy.get('[data-cy-app-order] [data-cy-app-order-element="dashboard"] [data-cy-app-order-button="up"]').should('have.focus')
+ })
+
+ 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', () => {
+ cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="down"]').should('not.have.focus')
+ cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('not.have.focus')
+ cy.get('[data-cy-app-order] [data-cy-app-order-element="dashboard"] [data-cy-app-order-button="up"]').should('not.have.focus')
+ cy.get('[data-cy-app-order] [data-cy-app-order-element="dashboard"] [data-cy-app-order-button="down"]').should('have.focus')
+ })
+})
+
+describe('User theming reset app order', () => {
+ const navigationHeader = new NavigationHeader()
+ let user: User
+
+ before(() => {
+ cy.resetAdminTheming()
+ // Create random user for this test
+ cy.createRandomUser().then(($user) => {
+ user = $user
+ cy.login($user)
+ })
+ })
+
+ after(() => cy.deleteUser(user))
+
+ it('See the app order settings', () => {
+ cy.visit('/settings/user/theming')
+
+ cy.get('.settings-section').contains('Navigation bar settings').should('exist')
+ cy.get('[data-cy-app-order]').scrollIntoView()
+ })
+
+ 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]')
+ .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', () => {
+ cy.get('[data-test-id="btn-apporder-reset"]').scrollIntoView()
+ cy.get('[data-test-id="btn-apporder-reset"]').should('be.visible').and('have.attr', 'disabled')
+ })
+
+ 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
+ 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', () => {
+ cy.get('[data-test-id="btn-apporder-reset"]').scrollIntoView()
+ cy.get('[data-test-id="btn-apporder-reset"]').should('be.visible').and('not.have.attr', 'disabled')
+ })
+
+ 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', () => {
+ 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', () => {
+ cy.get('[data-test-id="btn-apporder-reset"]').should('be.visible').and('have.attr', 'disabled')
+ })
+})
diff --git a/cypress/e2e/theming/user-settings_background.cy.ts b/cypress/e2e/theming/user-settings_background.cy.ts
new file mode 100644
index 00000000000..8abcb5bace1
--- /dev/null
+++ b/cypress/e2e/theming/user-settings_background.cy.ts
@@ -0,0 +1,302 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { User } from '@nextcloud/cypress'
+
+import { defaultPrimary, defaultBackground, validateBodyThemingCss } from './themingUtils'
+import { NavigationHeader } from '../../pages/NavigationHeader'
+
+const admin = new User('admin', 'admin')
+
+describe('User default background settings', function() {
+ before(function() {
+ cy.resetAdminTheming()
+ cy.resetUserTheming(admin)
+ cy.createRandomUser().then((user: User) => {
+ cy.login(user)
+ })
+ })
+
+ it('See the user background settings', function() {
+ cy.visit('/settings/user/theming')
+ cy.get('[data-user-theming-background-settings]').scrollIntoView()
+ cy.get('[data-user-theming-background-settings]').should('be.visible')
+ })
+
+ // Default cloud background is not rendered if admin theming background remains unchanged
+ it('Default cloud background is not rendered', function() {
+ cy.get(`[data-user-theming-background-shipped="${defaultBackground}"]`).should('not.exist')
+ })
+
+ it('Default is selected on new users', function() {
+ cy.get('[data-user-theming-background-default]').should('be.visible')
+ cy.get('[data-user-theming-background-default]').should('have.class', 'background--active')
+ })
+
+ it('Default background has accessibility attribute set', function() {
+ cy.get('[data-user-theming-background-default]').should('have.attr', 'aria-pressed', 'true')
+ })
+})
+
+describe('User select shipped backgrounds and remove background', function() {
+ before(function() {
+ cy.createRandomUser().then((user: User) => {
+ cy.login(user)
+ })
+ })
+
+ it('See the user background settings', function() {
+ cy.visit('/settings/user/theming')
+ cy.get('[data-user-theming-background-settings]').scrollIntoView()
+ cy.get('[data-user-theming-background-settings]').should('be.visible')
+ })
+
+ it('Select a shipped background', function() {
+ const background = 'anatoly-mikhaltsov-butterfly-wing-scale.jpg'
+ cy.intercept('*/apps/theming/background/shipped').as('setBackground')
+
+ // Select background
+ cy.get(`[data-user-theming-background-shipped="${background}"]`).click()
+
+ // Set the accessibility state
+ cy.get(`[data-user-theming-background-shipped="${background}"]`).should('have.attr', 'aria-pressed', 'true')
+
+ // Validate changed background and primary
+ cy.wait('@setBackground')
+ cy.waitUntil(() => validateBodyThemingCss('#a53c17', background, '#652e11'))
+ })
+
+ it('Select a bright shipped background', function() {
+ const background = 'bernie-cetonia-aurata-take-off-composition.jpg'
+ cy.intercept('*/apps/theming/background/shipped').as('setBackground')
+
+ // Select background
+ cy.get(`[data-user-theming-background-shipped="${background}"]`).click()
+
+ // Set the accessibility state
+ cy.get(`[data-user-theming-background-shipped="${background}"]`).should('have.attr', 'aria-pressed', 'true')
+
+ // Validate changed background and primary
+ cy.wait('@setBackground')
+ cy.waitUntil(() => validateBodyThemingCss('#56633d', background, '#dee0d3'))
+ })
+
+ it('Remove background', function() {
+ cy.intercept('*/apps/theming/background/color').as('clearBackground')
+
+ // Clear background
+ cy.get('[data-user-theming-background-color]').click()
+
+ // Set the accessibility state
+ cy.get('[data-user-theming-background-color]').should('have.attr', 'aria-pressed', 'true')
+
+ // Validate clear background
+ cy.wait('@clearBackground')
+ cy.waitUntil(() => validateBodyThemingCss('#56633d', null, '#dee0d3'))
+ })
+})
+
+describe('User select a custom color', function() {
+ before(function() {
+ cy.createRandomUser().then((user: User) => {
+ cy.login(user)
+ })
+ })
+
+ it('See the user background settings', function() {
+ cy.visit('/settings/user/theming')
+ cy.get('[data-user-theming-background-settings]').scrollIntoView()
+ cy.get('[data-user-theming-background-settings]').should('be.visible')
+ })
+
+ it('Select a custom color', function() {
+ cy.intercept('*/apps/theming/background/color').as('setColor')
+
+ 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(() => 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)
+ })
+ })
+
+ it('See the user background settings', function() {
+ cy.visit('/settings/user/theming')
+ cy.get('[data-user-theming-background-settings]').scrollIntoView()
+ cy.get('[data-user-theming-background-settings]').should('be.visible')
+ })
+
+ it('Remove background', function() {
+ cy.intercept('*/apps/theming/background/color').as('clearBackground')
+
+ // Clear background
+ cy.get('[data-user-theming-background-color]').click()
+ cy.get('[data-user-theming-background-color]').click()
+
+ // Validate clear background
+ cy.wait('@clearBackground')
+ cy.waitUntil(() => validateBodyThemingCss(undefined, null))
+ })
+
+ it('Select a custom color', function() {
+ cy.intercept('*/apps/theming/background/color').as('setColor')
+
+ // Pick one of the bright color preset
+ 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
+ cy.wait('@setColor')
+ })
+
+ it('See the header being inverted', function() {
+ cy.waitUntil(() => navigationHeader.getNavigationEntries().find('img').then((el) => {
+ let ret = true
+ el.each(function() {
+ ret = ret && window.getComputedStyle(this).filter === 'invert(1)'
+ })
+ return ret
+ }))
+ })
+
+ it('Select another but non-bright shipped background', function() {
+ const background = 'anatoly-mikhaltsov-butterfly-wing-scale.jpg'
+ cy.intercept('*/apps/theming/background/shipped').as('setBackground')
+
+ // Select background
+ cy.get(`[data-user-theming-background-shipped="${background}"]`).click()
+
+ // Validate changed background and primary
+ cy.wait('@setBackground')
+ cy.waitUntil(() => validateBodyThemingCss('#a53c17', background, '#652e11'))
+ })
+
+ it('See the header NOT being inverted this time', function() {
+ cy.waitUntil(() => navigationHeader.getNavigationEntries().find('img').then((el) => {
+ let ret = true
+ el.each(function() {
+ ret = ret && window.getComputedStyle(this).filter === 'none'
+ })
+ return ret
+ }))
+ })
+})
+
+describe('User select a custom background', function() {
+ const image = 'image.jpg'
+ before(function() {
+ cy.createRandomUser().then((user: User) => {
+ cy.uploadFile(user, image, 'image/jpeg')
+ cy.login(user)
+ })
+ })
+
+ it('See the user background settings', function() {
+ cy.visit('/settings/user/theming')
+ cy.get('[data-user-theming-background-settings]').scrollIntoView()
+ cy.get('[data-user-theming-background-settings]').should('be.visible')
+ })
+
+ it('Select a custom background', function() {
+ cy.intercept('*/apps/theming/background/custom').as('setBackground')
+
+ cy.on('uncaught:exception', (err) => {
+ // This can happen because of blink engine & skeleton animation, its not a bug just engine related.
+ if (err.message.includes('ResizeObserver loop limit exceeded')) {
+ return false
+ }
+ })
+
+ // Pick background
+ cy.get('[data-user-theming-background-custom]').click()
+ cy.get('.file-picker__files tr').contains(image).click()
+ cy.get('.dialog__actions .button-vue--vue-primary').click()
+
+ // Wait for background to be set
+ cy.wait('@setBackground')
+ cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, 'apps/theming/background?v=', '#2f2221'))
+ })
+})
+
+describe('User changes settings and reload the page', function() {
+ const image = 'image.jpg'
+ const colorFromImage = '#2f2221'
+
+ before(function() {
+ cy.createRandomUser().then((user: User) => {
+ cy.uploadFile(user, image, 'image/jpeg')
+ cy.login(user)
+ })
+ })
+
+ it('See the user background settings', function() {
+ cy.visit('/settings/user/theming')
+ cy.get('[data-user-theming-background-settings]').scrollIntoView()
+ cy.get('[data-user-theming-background-settings]').should('be.visible')
+ })
+
+ it('Select a custom background', function() {
+ cy.intercept('*/apps/theming/background/custom').as('setBackground')
+
+ cy.on('uncaught:exception', (err) => {
+ // This can happen because of blink engine & skeleton animation, its not a bug just engine related.
+ if (err.message.includes('ResizeObserver loop limit exceeded')) {
+ return false
+ }
+ })
+
+ // Pick background
+ cy.get('[data-user-theming-background-custom]').click()
+ cy.get('.file-picker__files tr').contains(image).click()
+ cy.get('.dialog__actions .button-vue--vue-primary').click()
+
+ // Wait for background to be set
+ cy.wait('@setBackground')
+ cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, 'apps/theming/background?v=', colorFromImage))
+ })
+
+ it('Select a custom color', function() {
+ cy.intercept('*/apps/theming/background/color').as('setColor')
+
+ 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(() => 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('#c98879', null, '#a5b872'))
+ })
+})
diff --git a/cypress/fixtures/image.jpg b/cypress/fixtures/image.jpg
new file mode 100644
index 00000000000..46dac8cc283
--- /dev/null
+++ b/cypress/fixtures/image.jpg
Binary files differ
diff --git a/cypress/fixtures/testapp/appinfo/info.xml b/cypress/fixtures/testapp/appinfo/info.xml
new file mode 100644
index 00000000000..a0deada5329
--- /dev/null
+++ b/cypress/fixtures/testapp/appinfo/info.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0"?>
+<info xmlns:xsi= "http://www.w3.org/2001/XMLSchema-instance"
+ xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
+ <!--
+ SPDX-FileCopyrightText: Ferdinand Thiessen <opensource@fthiessen.de>
+ SPDX-License-Identifier: CC0-1.0
+ -->
+ <id>testapp</id>
+ <name>Test App</name>
+ <summary>Test App</summary>
+ <description><![CDATA[A simple test app]]></description>
+ <version>0.0.1</version>
+ <licence>agpl</licence>
+ <author mail="opensource@fthiessen.de" >Ferdinand Thiessen</author>
+ <namespace>TestApp</namespace>
+ <category>games</category>
+ <bugs>https://github.com/nextcloud/server/issues</bugs>
+ <dependencies>
+ <nextcloud min-version="28" max-version="29"/>
+ </dependencies>
+ <navigations>
+ <navigation>
+ <name>Test App</name>
+ <route>testapp.page.index</route>
+ </navigation>
+ <navigation>
+ <name>Test App 2</name>
+ <route>testapp.page.index</route>
+ </navigation>
+ </navigations>
+</info>
diff --git a/cypress/fixtures/testapp/appinfo/routes.php b/cypress/fixtures/testapp/appinfo/routes.php
new file mode 100644
index 00000000000..b5471c5a0b2
--- /dev/null
+++ b/cypress/fixtures/testapp/appinfo/routes.php
@@ -0,0 +1,12 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * 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
new file mode 100644
index 00000000000..42b64b58d32
--- /dev/null
+++ b/cypress/fixtures/testapp/img/app.svg
@@ -0,0 +1,60 @@
+<?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#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ height="32"
+ width="32"
+ version="1"
+ viewBox="0 0 32 32"
+ id="svg4"
+ sodipodi:docname="app.svg"
+ inkscape:version="0.92.1 r">
+ <metadata
+ id="metadata10">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs8" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="789"
+ inkscape:window-height="480"
+ id="namedview6"
+ showgrid="false"
+ inkscape:zoom="7.375"
+ inkscape:cx="-8.3389831"
+ inkscape:cy="16"
+ inkscape:window-x="0"
+ inkscape:window-y="27"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg4" />
+ <path
+ d="M13.733 0a.915.915 0 0 0-.933.934V3.6c-1.182.304-2.243.794-3.267 1.4L7.6 3.068a.93.93 0 0 0-1.334 0l-3.2 3.2a.93.93 0 0 0 0 1.334L5 9.535c-.607 1.024-1.097 2.085-1.4 3.267H.933a.915.915 0 0 0-.933.934v4.533c0 .53.403.934.933.934H3.6c.303 1.182.793 2.243 1.4 3.267l-1.934 1.935a.93.93 0 0 0 0 1.333l3.2 3.2a.93.93 0 0 0 1.333 0L9.532 27c1.024.61 2.085 1.097 3.266 1.4v2.667c0 .53.402.933.932.933h4.534c.53 0 .933-.403.933-.935V28.4c1.18-.305 2.24-.795 3.265-1.4L24.4 28.93a.93.93 0 0 0 1.332 0l3.2-3.2a.93.93 0 0 0 0-1.333L27 22.465c.607-1.024 1.096-2.085 1.4-3.266h2.665a.915.915 0 0 0 .935-.933v-4.534a.915.915 0 0 0-.934-.933H28.4c-.304-1.182-.792-2.243-1.4-3.267L28.932 7.6a.93.93 0 0 0 0-1.334l-3.2-3.2a.93.93 0 0 0-1.333 0L22.465 5c-1.024-.607-2.084-1.097-3.266-1.4V.933A.915.915 0 0 0 18.267 0zM16 8.87A7.134 7.134 0 0 1 23.13 16 7.134 7.134 0 0 1 16 23.133c-3.936 0-7.13-3.196-7.13-7.132S12.063 8.87 16 8.87z"
+ display="block"
+ fill="#fff"
+ id="path2"
+ style="fill:#ffffff" />
+</svg>
diff --git a/cypress/fixtures/testapp/lib/AppInfo/Application.php b/cypress/fixtures/testapp/lib/AppInfo/Application.php
new file mode 100644
index 00000000000..8ca8f3ef527
--- /dev/null
+++ b/cypress/fixtures/testapp/lib/AppInfo/Application.php
@@ -0,0 +1,18 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\TestApp\AppInfo;
+
+use OCP\AppFramework\App;
+
+class Application extends App {
+ public const APP_ID = 'testapp';
+
+ public function __construct() {
+ parent::__construct(self::APP_ID);
+ }
+}
diff --git a/cypress/fixtures/testapp/lib/Controller/PageController.php b/cypress/fixtures/testapp/lib/Controller/PageController.php
new file mode 100644
index 00000000000..e7812fa1046
--- /dev/null
+++ b/cypress/fixtures/testapp/lib/Controller/PageController.php
@@ -0,0 +1,27 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * 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;
+
+class PageController extends Controller {
+ public function __construct(IRequest $request) {
+ parent::__construct(Application::APP_ID, $request);
+ }
+
+ #[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
new file mode 100644
index 00000000000..2fdc4ddb780
--- /dev/null
+++ b/cypress/fixtures/testapp/templates/main.php
@@ -0,0 +1,8 @@
+<?php
+declare(strict_types=1);
+/**
+ * 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
new file mode 100644
index 00000000000..ad486a8a8f7
--- /dev/null
+++ b/cypress/support/commands.ts
@@ -0,0 +1,248 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+// 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()
+
+const url = (Cypress.config('baseUrl') || '').replace(/\/index.php\/?$/g, '')
+Cypress.env('baseUrl', url)
+
+/**
+ * Enable or disable a user
+ * 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
+ */
+Cypress.Commands.add('enableUser', (user: User, enable = true) => {
+ const url = `${Cypress.config('baseUrl')}/ocs/v2.php/cloud/users/${user.userId}/${enable ? 'enable' : 'disable'}`.replace('index.php/', '')
+ return cy.request({
+ method: 'PUT',
+ url,
+ form: true,
+ auth: {
+ user: 'admin',
+ password: 'admin',
+ },
+ headers: {
+ 'OCS-ApiRequest': 'true',
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ }).then((response) => {
+ cy.log(`Enabled user ${user}`, response.status)
+ return cy.wrap(response)
+ })
+})
+
+/**
+ * cy.uploadedFile - uploads a file from the fixtures folder
+ * 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
+ * @param {string} mimeType e.g. image/png
+ * @param {string} [target] the target of the file relative to the user root
+ */
+Cypress.Commands.add('uploadFile', (user, fixture = 'image.jpg', mimeType = 'image/jpeg', target = `/${fixture}`) => {
+ // get fixture
+ 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) => {
+ // eslint-disable-next-line cypress/unsafe-to-chain-command
+ cy.clearAllCookies()
+ .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: 'PROPPATCH',
+ auth: {
+ username: user.userId,
+ password: user.password,
+ },
+ headers: {
+ 'Content-Type': 'application/xml',
+ },
+ data: `<?xml version="1.0"?>
+ <d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
+ <d:set>
+ <d:prop>
+ <oc:favorite>${favorite ? 1 : 0}</oc:favorite>
+ </d:prop>
+ </d:set>
+ </d:propertyupdate>`,
+ })
+ cy.log(`Created directory ${target}`, response)
+ } catch (error) {
+ cy.log('error', error)
+ throw new Error('Unable to process fixture')
+ }
+ })
+})
+
+Cypress.Commands.add('mkdir', (user: User, target: string) => {
+ // eslint-disable-next-line cypress/unsafe-to-chain-command
+ return 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: 'MKCOL',
+ auth: {
+ username: user.userId,
+ password: user.password,
+ },
+ })
+ cy.log(`Created directory ${target}`, response)
+ return response
+ } catch (error) {
+ cy.log('error', error)
+ throw new Error('Unable to create directory')
+ }
+ })
+})
+
+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: standardize in @nextcloud/cypress
+ *
+ * @param {User} user the owner of the file, e.g. admin
+ * @param {Blob} blob the content to upload
+ * @param {string} mimeType e.g. image/png
+ * @param {string} target the target of the file relative to the user root
+ */
+Cypress.Commands.add('uploadContent', (user: User, blob: Blob, mimeType: string, target: string, mtime?: number) => {
+ cy.clearCookies()
+ return cy.then(async () => {
+ const fileName = basename(target)
+
+ // Process paths
+ const rootPath = `${Cypress.env('baseUrl')}/remote.php/dav/files/${encodeURIComponent(user.userId)}`
+ const filePath = target.split('/').map(encodeURIComponent).join('/')
+ try {
+ const file = new File([blob], fileName, { type: mimeType })
+ const response = await axios({
+ url: `${rootPath}${filePath}`,
+ method: 'PUT',
+ data: file,
+ headers: {
+ 'Content-Type': mimeType,
+ 'X-OC-MTime': mtime ? `${mtime}` : undefined,
+ },
+ auth: {
+ username: user.userId,
+ password: user.password,
+ },
+ })
+ cy.log(`Uploaded content as ${fileName}`, response)
+ return response
+ } catch (error) {
+ cy.log('error', error)
+ throw new Error('Unable to process fixture')
+ }
+ })
+})
+
+/**
+ * Reset the admin theming entirely
+ */
+Cypress.Commands.add('resetAdminTheming', () => {
+ const admin = new User('admin', 'admin')
+
+ cy.clearCookies()
+ cy.login(admin)
+
+ // Clear all settings
+ cy.request('/csrftoken').then(({ body }) => {
+ const requestToken = body.token
+
+ axios({
+ method: 'POST',
+ url: '/index.php/apps/theming/ajax/undoAllChanges',
+ headers: {
+ requesttoken: requestToken,
+ },
+ })
+ })
+
+ // Clear admin session
+ cy.clearCookies()
+})
+
+/**
+ * Reset the current or provided user theming settings
+ * It does not reset the theme config as it is enforced in the
+ * server config for cypress testing.
+ */
+Cypress.Commands.add('resetUserTheming', (user?: User) => {
+ if (user) {
+ cy.clearCookies()
+ cy.login(user)
+ }
+
+ // Reset background config
+ cy.request('/csrftoken').then(({ body }) => {
+ const requestToken = body.token
+
+ cy.request({
+ method: 'POST',
+ url: '/apps/theming/background/default',
+ headers: {
+ requesttoken: requestToken,
+ },
+ })
+ })
+
+ if (user) {
+ // Clear current session
+ cy.clearCookies()
+ }
+})
+
+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
new file mode 100644
index 00000000000..8d02ace151b
--- /dev/null
+++ b/cypress/support/commonUtils.ts
@@ -0,0 +1,80 @@
+/**
+ * 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() {
+ return cy.get('#header')
+}
+
+/**
+ * Get user menu in the header navigation bar
+ */
+export function getNextcloudUserMenu() {
+ return getNextcloudHeader().find('#user-menu')
+}
+
+/**
+ * Get the user menu toggle in the header navigation bar
+ */
+export function getNextcloudUserMenuToggle() {
+ return getNextcloudUserMenu().find('.header-menu__trigger').should('have.length', 1)
+}
+
+/**
+ * Helper function ensure users and groups in this tests have a clean state
+ * Deletes all users (except admin) and groups
+ */
+export function clearState() {
+ // cleanup ignoring any failures
+ cy.runOccCommand('group:list --output=json').then(($result) => {
+ const groups = Object.keys(JSON.parse($result.stdout)).filter((name) => name !== 'admin')
+ groups.forEach((groupID) => cy.runOccCommand(`group:delete '${groupID}'`))
+ })
+
+ cy.runOccCommand('user:list --output=json').then(($result) => {
+ const users = Object.keys(JSON.parse($result.stdout)).filter((name) => name !== 'admin')
+ users.forEach((userID) => cy.runOccCommand(`user:delete '${userID}'`))
+ })
+}
+
+/**
+ * Install the test app
+ */
+export function installTestApp() {
+ const testAppPath = 'cypress/fixtures/testapp'
+ cy.runOccCommand('-V').then((output) => {
+ const version = output.stdout.match(/(\d\d+)\.\d+\.\d+/)?.[1]
+ cy.wrap(version).should('not.be.undefined')
+ 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')
+ })
+}
+
+/**
+ * Remove the test app
+ */
+export function uninstallTestApp() {
+ cy.runOccCommand('app:remove testapp', { failOnNonZeroExit: false })
+ 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
new file mode 100644
index 00000000000..e525b445373
--- /dev/null
+++ b/cypress/support/component-index.html
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<html>
+ <head>
+ <meta charset="utf-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="viewport" content="width=device-width,initial-scale=1.0">
+ <title>Components App</title>
+ </head>
+ <body>
+ <div data-cy-root></div>
+ </body>
+</html> \ No newline at end of file
diff --git a/cypress/support/component.ts b/cypress/support/component.ts
new file mode 100644
index 00000000000..853609bb4dd
--- /dev/null
+++ b/cypress/support/component.ts
@@ -0,0 +1,45 @@
+/**
+ * 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'
+
+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 || {}
+
+ return mount(component, options)
+})
+
+Cypress.Commands.add('mockInitialState', (app: string, key: string, value: unknown) => {
+ cy.document().then(($document) => {
+ const input = $document.createElement('input')
+ input.setAttribute('type', 'hidden')
+ input.setAttribute('id', `initial-state-${app}-${key}`)
+ input.setAttribute('value', btoa(JSON.stringify(value)))
+ $document.body.appendChild(input)
+ })
+})
+
+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))
+ })
+})
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
new file mode 100644
index 00000000000..65fb4b2a110
--- /dev/null
+++ b/cypress/support/e2e.ts
@@ -0,0 +1,15 @@
+/**
+ * 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'))
+Cypress.on('uncaught:exception', err => !err.message.includes('ResizeObserver loop completed with undelivered notifications'))
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
new file mode 100644
index 00000000000..510c64d633c
--- /dev/null
+++ b/cypress/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "../tsconfig.json",
+ "include": ["./**/*.ts", "../**/*.cy.ts", "./cypress-e2e.d.ts", "./cypress-component.d.ts"],
+ "exclude": [],
+ "compilerOptions": {
+ "types": [
+ "@testing-library/cypress",
+ "cypress",
+ "cypress-axe",
+ "cypress-wait-until",
+ "dockerode"
+ ],
+ }
+}