mirror of
https://github.com/nextcloud/server.git
synced 2026-06-14 19:20:35 -04:00
Merge pull request #60194 from nextcloud/ci/playwright
test: add playwright pipeline and start migrating Cypress to Playwright
This commit is contained in:
commit
426cbeb192
52 changed files with 2627 additions and 2626 deletions
4
.github/workflows/cypress.yml
vendored
4
.github/workflows/cypress.yml
vendored
|
|
@ -105,10 +105,10 @@ jobs:
|
|||
matrix:
|
||||
# Run multiple copies of the current job in parallel
|
||||
# Please increase the number or runners as your tests suite grows (0 based index for e2e tests)
|
||||
containers: ['setup', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
|
||||
containers: ['setup', '0', '1', '2', '3', '4', '5', '6', '7', '8']
|
||||
# Hack as strategy.job-total includes the "setup" and GitHub does not allow math expressions
|
||||
# Always align this number with the total of e2e runners (max. index + 1)
|
||||
total-containers: [10]
|
||||
total-containers: [9]
|
||||
|
||||
services:
|
||||
mysql:
|
||||
|
|
|
|||
130
.github/workflows/playwright.yml
vendored
Normal file
130
.github/workflows/playwright.yml
vendored
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Playwright Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
playwright-tests:
|
||||
timeout-minutes: 60
|
||||
name: Playwright tests ${{ matrix.shardIndex }} / ${{ matrix.shardTotal }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shardIndex: [1, 2, 3]
|
||||
shardTotal: [3]
|
||||
outputs:
|
||||
node-version: ${{ steps.versions.outputs.node-version }}
|
||||
package-manager-version: ${{ steps.versions.outputs.package-manager-version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: true # for 3rdparty
|
||||
|
||||
- name: Read package.json
|
||||
uses: nextcloud-libraries/parse-package-engines-action@122ae05d4257008180a514e1ddeb0c1b9d094bdd # v0.1.0
|
||||
id: versions
|
||||
|
||||
- name: Set up node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: ${{ steps.versions.outputs.node-version }}
|
||||
|
||||
- name: Set up npm
|
||||
run: npm i -g 'npm@${{ steps.versions.outputs.package-manager-version }}'
|
||||
|
||||
- name: Install dependencies and build
|
||||
run: |
|
||||
npm ci
|
||||
npm run build --if-present
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: npm run playwright -- --shard='${{ matrix.shardIndex }}/${{ matrix.shardTotal }}'
|
||||
|
||||
- name: Show logs
|
||||
if: failure()
|
||||
run: |
|
||||
for id in $(docker ps -aq); do
|
||||
docker container inspect "$id" --format '=== Logs for container {{.Name}} ==='
|
||||
docker logs "$id" >> nextcloud.log
|
||||
done
|
||||
echo '=== Nextcloud server logs ==='
|
||||
docker exec nextcloud-e2e-test-server_server cat data/nextcloud.log
|
||||
|
||||
- name: Upload blob report to GitHub Actions Artifacts
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: blob-report-${{ matrix.shardIndex }}
|
||||
path: blob-report
|
||||
retention-days: 1
|
||||
|
||||
merge-reports:
|
||||
# Merge reports after playwright-tests, even if some shards have failed
|
||||
if: ${{ !cancelled() }}
|
||||
needs: [playwright-tests]
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: ${{ needs.playwright-tests.outputs.node-version }}
|
||||
|
||||
- name: Set up npm
|
||||
run: npm i -g 'npm@${{ needs.playwright-tests.outputs.package-manager-version }}'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Download blob reports from GitHub Actions Artifacts
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
path: all-blob-reports
|
||||
pattern: blob-report-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Merge into HTML Report
|
||||
run: npx playwright merge-reports --config tests/playwright/merge.config.ts --reporter html,github ./all-blob-reports
|
||||
|
||||
- name: Upload HTML report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: html-report--attempt-${{ github.run_attempt }}
|
||||
path: playwright-report
|
||||
retention-days: 7
|
||||
|
||||
- name: Show the logs
|
||||
run: |
|
||||
echo 'To view the report:'
|
||||
echo ' 1. Extract the folder from the zip file'
|
||||
echo ' 2. run "npx playwright show-report name-of-my-extracted-playwright-report"'
|
||||
|
||||
summary:
|
||||
permissions:
|
||||
contents: none
|
||||
runs-on: ubuntu-latest-low
|
||||
needs: [playwright-tests]
|
||||
|
||||
if: always()
|
||||
|
||||
name: playwright-test-summary
|
||||
|
||||
steps:
|
||||
- name: Summary status
|
||||
run: if ${{ needs.playwright-tests.result != 'success' }}; then exit 1; fi
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -145,7 +145,9 @@ Vagrantfile
|
|||
|
||||
# Tests - auto-generated files
|
||||
/data-autotest
|
||||
/playwright-report
|
||||
/results.sarif
|
||||
/test-results
|
||||
/tests/.phpunit.cache
|
||||
/tests/.phpunit.result.cache
|
||||
/tests/coverage*
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ $expectedFiles = [
|
|||
'openapi.json',
|
||||
'package-lock.json',
|
||||
'package.json',
|
||||
'playwright.config.ts',
|
||||
'psalm-ncu.xml',
|
||||
'psalm-ocp.xml',
|
||||
'psalm-strict.xml',
|
||||
|
|
|
|||
|
|
@ -1,279 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023-2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { User } from '@nextcloud/e2e-test-server/cypress'
|
||||
import { handlePasswordConfirmation } from '../core-utils.ts'
|
||||
|
||||
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', '/ocs/v2.php/apps/appstore/api/v1/apps').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.intercept('POST', '/ocs/v2.php/apps/appstore/api/v1/apps/enable').as('enableApp')
|
||||
|
||||
cy.findByRole('table').should('exist')
|
||||
// Wait for the app list to load
|
||||
.contains('tr', 'QA testing', { timeout: 10000 })
|
||||
.should('exist')
|
||||
.findByRole('button', { name: 'Enable' })
|
||||
// I enable the "QA testing" app
|
||||
.click({ force: true })
|
||||
|
||||
handlePasswordConfirmation(admin.password)
|
||||
|
||||
cy.wait('@enableApp')
|
||||
|
||||
// Wait until we see the disable button for the app
|
||||
cy.findByRole('table').should('exist')
|
||||
.contains('tr', 'QA testing')
|
||||
.should('exist')
|
||||
// I see the disable button for the app
|
||||
.findByRole('button', { name: 'Disable' })
|
||||
.should('be.visible')
|
||||
|
||||
// Change to enabled apps view
|
||||
cy.findByRole('navigation', { name: 'Appstore categories' })
|
||||
.within(() => {
|
||||
cy.findByRole('link', { name: 'Active apps' })
|
||||
.should('be.visible')
|
||||
.click({ force: true })
|
||||
})
|
||||
|
||||
cy.url().should('match', /settings\/apps\/enabled$/)
|
||||
// I see that the "QA testing" app has been enabled
|
||||
cy.findByRole('table')
|
||||
.contains('tr', 'QA testing')
|
||||
})
|
||||
|
||||
it('Can disable an installed app', () => {
|
||||
cy.intercept('POST', '/ocs/v2.php/apps/appstore/api/v1/apps/disable').as('disableApp')
|
||||
|
||||
cy.findByRole('table')
|
||||
.should('exist')
|
||||
// Wait for the app list to load
|
||||
.contains('tr', 'Update notification', { timeout: 10000 })
|
||||
.should('exist')
|
||||
// I disable the "Update notification" app
|
||||
.findByRole('button', { name: 'Disable' })
|
||||
.click({ force: true })
|
||||
|
||||
handlePasswordConfirmation(admin.password)
|
||||
cy.wait('@disableApp')
|
||||
|
||||
// Wait until we see the disable button for the app
|
||||
cy.findByRole('table').should('exist')
|
||||
.contains('tr', 'Update notification')
|
||||
.should('exist')
|
||||
// I see the enable button for the app
|
||||
.findByRole('button', { name: 'Enable' })
|
||||
.should('exist')
|
||||
|
||||
// Change to disabled apps view
|
||||
cy.findByRole('navigation', { name: 'Appstore categories' })
|
||||
.within(() => {
|
||||
cy.findByRole('link', { name: 'Disabled apps' }).click({ force: true })
|
||||
})
|
||||
cy.url().should('match', /settings\/apps\/disabled$/)
|
||||
|
||||
// I see that the "Update notification" app has been disabled
|
||||
cy.findByRole('table')
|
||||
.contains('tr', 'Update notification')
|
||||
})
|
||||
|
||||
it('Browse enabled apps', () => {
|
||||
// When I open the "Active apps" section
|
||||
cy.findByRole('navigation', { name: 'Appstore categories' })
|
||||
.within(() => {
|
||||
cy.findByRole('link', { name: 'Active apps' })
|
||||
.should('be.visible')
|
||||
.click({ force: true })
|
||||
})
|
||||
|
||||
// Then I see that the current section is "Active apps"
|
||||
cy.url().should('match', /settings\/apps\/enabled$/)
|
||||
cy.findByRole('navigation', { name: 'Appstore categories' })
|
||||
.within(() => {
|
||||
cy.findByRole('link', { name: 'Active apps', current: 'page' })
|
||||
.should('be.visible')
|
||||
})
|
||||
|
||||
// I see that there are only enabled apps
|
||||
cy.findByRole('table')
|
||||
.should('exist')
|
||||
.find('tr button')
|
||||
.each(($action) => {
|
||||
cy.wrap($action).should('not.contain', 'Enable')
|
||||
})
|
||||
})
|
||||
|
||||
it('Browse disabled apps', () => {
|
||||
// When I open the "Active Disabled" section
|
||||
cy.findByRole('navigation', { name: 'Appstore categories' })
|
||||
.within(() => {
|
||||
cy.findByRole('link', { name: 'Disabled apps' })
|
||||
.as('disabledAppsLink')
|
||||
.should('be.visible')
|
||||
.and('not.have.attr', 'aria-current')
|
||||
cy.get('@disabledAppsLink')
|
||||
.click({ force: true })
|
||||
})
|
||||
|
||||
// Then I see that the current section is "Disabled apps"
|
||||
cy.url().should('match', /settings\/apps\/disabled$/)
|
||||
cy.findByRole('navigation', { name: 'Appstore categories' })
|
||||
.within(() => {
|
||||
cy.findByRole('link', { name: 'Disabled apps', current: 'page' })
|
||||
.should('be.visible')
|
||||
})
|
||||
|
||||
// I see that there are only disabled apps
|
||||
cy.findByRole('table')
|
||||
.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.findByRole('navigation', { name: 'Appstore categories' })
|
||||
.within(() => {
|
||||
cy.findByRole('link', { name: 'App bundles' })
|
||||
.as('appBundlesLink')
|
||||
.should('be.visible')
|
||||
.and('not.have.attr', 'aria-current')
|
||||
cy.get('@appBundlesLink')
|
||||
.click({ force: true })
|
||||
})
|
||||
|
||||
// Then I see that the current section is "App bundles"
|
||||
cy.url().should('match', /settings\/apps\/bundles$/)
|
||||
cy.findByRole('navigation', { name: 'Appstore categories' })
|
||||
.within(() => {
|
||||
cy.findByRole('link', { name: 'App bundles', current: 'page' })
|
||||
.should('be.visible')
|
||||
})
|
||||
|
||||
// I see the app bundles
|
||||
cy.findByRole('heading', { name: 'Enterprise bundle' })
|
||||
.should('be.visible')
|
||||
cy.findByRole('heading', { name: 'Education bundle' })
|
||||
.should('be.visible')
|
||||
})
|
||||
|
||||
it('View app details', () => {
|
||||
// When I click on the "QA testing" app
|
||||
cy.findByRole('table')
|
||||
.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')
|
||||
.findByRole('button', { name: 'Enable' })
|
||||
.should('be.visible')
|
||||
cy.get('#app-sidebar-vue')
|
||||
.findByRole('button', { name: 'Remove' })
|
||||
.should('be.visible')
|
||||
cy.get('#app-sidebar-vue').contains(/Version \d+\.\d+\.\d+/).should('be.visible')
|
||||
})
|
||||
|
||||
it('Limit app usage to group', () => {
|
||||
// When I open the "Active apps" section
|
||||
cy.findByRole('navigation', { name: 'Appstore categories' })
|
||||
.within(() => {
|
||||
cy.findByRole('link', { name: 'Active apps' })
|
||||
.should('be.visible')
|
||||
.click({ force: true })
|
||||
})
|
||||
|
||||
// Then I see that the current section is "Active apps"
|
||||
cy.url().should('match', /settings\/apps\/enabled$/)
|
||||
|
||||
// Then I select the app
|
||||
cy.findByRole('table')
|
||||
.should('exist')
|
||||
.contains('tr a', 'Dashboard', { timeout: 10000 })
|
||||
.click()
|
||||
|
||||
// Then I enable "limit app to group"
|
||||
cy.findByRole('button', { name: 'Limit to groups' })
|
||||
.click()
|
||||
|
||||
// Then I select a group
|
||||
cy.findByRole('dialog')
|
||||
.should('be.visible')
|
||||
.within(() => {
|
||||
cy.get('input')
|
||||
.should('be.focused')
|
||||
.type('admin')
|
||||
})
|
||||
cy.findByRole('option', { name: /admin/ })
|
||||
.click()
|
||||
cy.findByRole('button', { name: 'Save' })
|
||||
.click()
|
||||
|
||||
handlePasswordConfirmation(admin.password)
|
||||
|
||||
cy.get('#app-sidebar-vue')
|
||||
.findByRole('list', { name: 'Limited to groups' })
|
||||
.findByRole('listitem', { name: /admin/ })
|
||||
.should('be.visible')
|
||||
|
||||
// Then I disable the group limitation
|
||||
cy.get('#app-sidebar-vue')
|
||||
.findByRole('button', { name: 'Limit to groups' })
|
||||
.click()
|
||||
cy.findByRole('dialog')
|
||||
.should('be.visible')
|
||||
.within(() => {
|
||||
cy.findByRole('button', { name: 'Deselect admin' })
|
||||
.should('be.visible')
|
||||
.click()
|
||||
cy.findByRole('button', { name: 'Save' })
|
||||
.click()
|
||||
})
|
||||
|
||||
handlePasswordConfirmation(admin.password)
|
||||
|
||||
cy.get('#app-sidebar-vue')
|
||||
.findByRole('list', { name: 'Limited to groups' })
|
||||
.should('not.exist')
|
||||
})
|
||||
|
||||
/*
|
||||
* 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
|
||||
})
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { clearState } from '../../support/commonUtils.ts'
|
||||
|
||||
describe('Calendar: Availability', { testIsolation: true }, () => {
|
||||
before(() => {
|
||||
clearState()
|
||||
})
|
||||
|
||||
it('User can see the availability section in settings', () => {
|
||||
cy.createRandomUser().then(($user) => {
|
||||
cy.login($user)
|
||||
cy.visit('/settings/user')
|
||||
})
|
||||
|
||||
// can see the section
|
||||
cy.findAllByRole('link', { name: /Availability/ })
|
||||
.should('be.visible')
|
||||
.click()
|
||||
|
||||
cy.url().should('match', /settings\/user\/availability$/)
|
||||
cy.findByRole('heading', { name: /Availability/, level: 2 })
|
||||
.should('be.visible')
|
||||
})
|
||||
|
||||
it('Users can set their availability status', () => {
|
||||
cy.createRandomUser().then(($user) => {
|
||||
cy.login($user)
|
||||
cy.visit('/settings/user/availability')
|
||||
})
|
||||
|
||||
// can see the settings
|
||||
cy.findByRole('list', { name: 'Weekdays' })
|
||||
.should('be.visible')
|
||||
.within(() => {
|
||||
cy.contains('li', 'Friday')
|
||||
.should('be.visible')
|
||||
.should('contain.text', 'No working hours set')
|
||||
.as('fridayItem')
|
||||
.findByRole('button', { name: 'Add slot' })
|
||||
.click()
|
||||
})
|
||||
|
||||
cy.get('@fridayItem')
|
||||
.findByLabelText(/start time/i)
|
||||
.type('09:00')
|
||||
|
||||
cy.get('@fridayItem')
|
||||
.findByLabelText(/end time/i)
|
||||
.type('18:00')
|
||||
|
||||
cy.intercept('PROPPATCH', '**/remote.php/dav/calendars/*/inbox').as('saveAvailability')
|
||||
cy.get('#availability')
|
||||
.findByRole('button', { name: 'Save' })
|
||||
.click()
|
||||
cy.wait('@saveAvailability')
|
||||
|
||||
cy.reload()
|
||||
|
||||
cy.findByRole('list', { name: 'Weekdays' })
|
||||
.should('be.visible')
|
||||
.within(() => {
|
||||
cy.contains('li', 'Friday')
|
||||
.should('be.visible')
|
||||
.should('not.contain.text', 'No working hours set')
|
||||
})
|
||||
})
|
||||
|
||||
it('Users can set their absence', () => {
|
||||
cy.createUser({ language: 'en', password: 'password', userId: 'replacement-user' })
|
||||
cy.createRandomUser().then(($user) => {
|
||||
cy.login($user)
|
||||
cy.visit('/settings/user/availability')
|
||||
})
|
||||
|
||||
cy.findByRole('heading', { name: /absence/i }).scrollIntoView()
|
||||
|
||||
cy.findByLabelText(/First day/)
|
||||
.should('be.visible')
|
||||
.type('2024-12-24')
|
||||
|
||||
cy.findByLabelText(/Last day/)
|
||||
.should('be.visible')
|
||||
.type('2024-12-28')
|
||||
|
||||
cy.findByRole('textbox', { name: /Short absence/ })
|
||||
.should('be.visible')
|
||||
.type('Vacation')
|
||||
cy.findByRole('textbox', { name: /Long absence/ })
|
||||
.should('be.visible')
|
||||
.type('Happy holidays!')
|
||||
|
||||
cy.intercept('GET', '**/ocs/v2.php/apps/files_sharing/api/v1/sharees?*search=replacement*').as('userSearch')
|
||||
cy.findByRole('searchbox')
|
||||
.should('be.visible')
|
||||
.as('userSearchBox')
|
||||
.click()
|
||||
cy.get('@userSearchBox')
|
||||
.type('replacement')
|
||||
cy.wait('@userSearch')
|
||||
|
||||
cy.findByRole('option', { name: 'replacement-user' })
|
||||
.click()
|
||||
|
||||
cy.intercept('POST', '**/ocs/v2.php/apps/dav/api/v1/outOfOffice/*').as('saveAbsence')
|
||||
cy.get('#absence')
|
||||
.findByRole('button', { name: 'Save' })
|
||||
.click()
|
||||
cy.wait('@saveAbsence')
|
||||
|
||||
cy.reload()
|
||||
|
||||
// see its saved
|
||||
cy.findByLabelText(/First day/)
|
||||
.should('have.value', '2024-12-24')
|
||||
cy.findByLabelText(/Last day/)
|
||||
.should('have.value', '2024-12-28')
|
||||
cy.findByRole('textbox', { name: /Short absence/ })
|
||||
.should('have.value', 'Vacation')
|
||||
cy.findByRole('textbox', { name: /Long absence/ })
|
||||
.should('have.value', 'Happy holidays!')
|
||||
cy.findByRole('combobox')
|
||||
.should('contain.text', 'replacement-user')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { User } from '@nextcloud/e2e-test-server/cypress'
|
||||
|
||||
import { getRowForFile, navigateToFolder, selectAllFiles, triggerActionForFile, triggerSelectionAction } 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()
|
||||
triggerSelectionAction('delete')
|
||||
|
||||
// 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)
|
||||
|
||||
.should((all: any) => {
|
||||
for (const call of all) {
|
||||
expect(call.response.statusCode).to.equal(204)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { User } from '@nextcloud/e2e-test-server/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')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,288 +0,0 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { User } from '@nextcloud/e2e-test-server/cypress'
|
||||
|
||||
import { calculateViewportHeight, createFolder, getRowForFile, haveValidity, renameFile, triggerActionForFile } from './FilesUtils.ts'
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { User } from '@nextcloud/e2e-test-server/cypress'
|
||||
|
||||
import { assertNotExistOrNotVisible } from '../settings/usersUtils.ts'
|
||||
import { getRowForFile, navigateToFolder, triggerActionForFile } from './FilesUtils.ts'
|
||||
|
||||
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')
|
||||
|
||||
// eslint-disable-next-line cypress/no-unnecessary-waiting
|
||||
cy.wait(600) // wait for a bit to avoid flakiness
|
||||
|
||||
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')
|
||||
// eslint-disable-next-line cypress/no-unnecessary-waiting
|
||||
cy.wait(600) // wait for a bit to avoid flakiness
|
||||
|
||||
// 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}`)
|
||||
|
||||
// eslint-disable-next-line cypress/no-unnecessary-waiting
|
||||
cy.wait(600) // wait for a bit to avoid flakiness
|
||||
|
||||
triggerActionForFile('other', 'delete')
|
||||
cy.wait('@deleteFile')
|
||||
|
||||
cy.get('[data-cy-sidebar]').should('not.be.visible')
|
||||
// Ensure the URL is changed
|
||||
cy.url().should('not.contain', `apps/files/files/${otherFileId}`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { User } from '@nextcloud/e2e-test-server/cypress'
|
||||
|
||||
const admin = new User('admin', 'admin')
|
||||
|
||||
const tagName = 'foo'
|
||||
const updatedTagName = 'bar'
|
||||
|
||||
describe('Create system tags', () => {
|
||||
before(() => {
|
||||
// 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}`)
|
||||
})
|
||||
})
|
||||
|
||||
// login as admin and go to admin settings
|
||||
cy.login(admin)
|
||||
cy.visit('/settings/admin/server')
|
||||
})
|
||||
|
||||
it('Can create a tag', () => {
|
||||
cy.intercept('POST', '/remote.php/dav/systemtags').as('createTag')
|
||||
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}')
|
||||
|
||||
// wait for the tag to be created
|
||||
cy.wait('@createTag').its('response.statusCode').should('eq', 201)
|
||||
|
||||
// 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} li span[title="${tagName}"]`)
|
||||
.should('exist')
|
||||
.should('have.length', 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Update system tags', { testIsolation: false }, () => {
|
||||
before(() => {
|
||||
cy.login(admin)
|
||||
cy.visit('/settings/admin/server')
|
||||
})
|
||||
|
||||
it('select the tag', () => {
|
||||
cy.get('input#system-tags-input').focus()
|
||||
cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then((id) => {
|
||||
cy.get(`ul#${id} li span[title="${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.intercept('PROPPATCH', '/remote.php/dav/systemtags/*').as('updateTag')
|
||||
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} li span[title="Invisible"]`).should('exist').click()
|
||||
})
|
||||
// submit the form
|
||||
cy.get('input#system-tag-name').type('{enter}')
|
||||
// wait for the tag to be updated
|
||||
cy.wait('@updateTag').its('response.statusCode').should('eq', 207)
|
||||
})
|
||||
|
||||
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} li span[title="${updatedTagName} (invisible)"]`)
|
||||
.should('exist')
|
||||
.should('have.length', 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Delete system tags', { testIsolation: false }, () => {
|
||||
before(() => {
|
||||
cy.login(admin)
|
||||
cy.visit('/settings/admin/server')
|
||||
})
|
||||
|
||||
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} li span[title="${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.intercept('DELETE', '/remote.php/dav/systemtags/*').as('deleteTag')
|
||||
cy.get('.system-tag-form__row').within(() => {
|
||||
cy.contains('button', 'Delete').should('be.enabled').click()
|
||||
})
|
||||
// wait for the tag to be deleted
|
||||
cy.wait('@deleteTag').its('response.statusCode').should('eq', 204)
|
||||
})
|
||||
|
||||
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} li span[title="${updatedTagName}"]`).should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
/**
|
||||
* 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',
|
||||
],
|
||||
},
|
||||
'Severity information texts': {
|
||||
foregroundColors: [
|
||||
'color-error-text',
|
||||
'color-warning-text',
|
||||
'color-success-text',
|
||||
'color-info-text',
|
||||
],
|
||||
backgroundColors: [
|
||||
'color-main-background',
|
||||
'color-background-hover',
|
||||
],
|
||||
},
|
||||
// only most important severity colors are supported on the blur
|
||||
'Severity information on blur': {
|
||||
foregroundColors: [
|
||||
'color-error-text',
|
||||
'color-success-text',
|
||||
],
|
||||
backgroundColors: [
|
||||
'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')
|
||||
|
||||
expect(root).not.to.be.undefined
|
||||
|
||||
root!.appendChild(element)
|
||||
|
||||
cy.checkA11y('[data-cy-testcase]', {
|
||||
runOnly: ['color-contrast'],
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -1,385 +0,0 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { User } from '@nextcloud/e2e-test-server/cypress'
|
||||
import { NavigationHeader } from '../../pages/NavigationHeader.ts'
|
||||
import {
|
||||
defaultBackground,
|
||||
defaultPrimary,
|
||||
pickColor,
|
||||
validateBodyThemingCss,
|
||||
validateUserThemingDefaultCss,
|
||||
} from './themingUtils.ts'
|
||||
|
||||
const admin = new User('admin', 'admin')
|
||||
|
||||
describe('Remove the default background and restore it', { testIsolation: false }, 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.findByRole('heading', { name: 'Background and color' })
|
||||
.should('exist')
|
||||
.scrollIntoView()
|
||||
})
|
||||
|
||||
it('Remove the default background', function() {
|
||||
cy.intercept('*/apps/theming/ajax/updateStylesheet').as('removeBackground')
|
||||
cy.intercept('*/apps/theming/theme/default.css?*').as('cssLoaded')
|
||||
|
||||
cy.findByRole('checkbox', { name: /remove background image/i })
|
||||
.should('exist')
|
||||
.should('not.be.checked')
|
||||
.check({ force: true })
|
||||
|
||||
cy.wait('@removeBackground')
|
||||
cy.wait('@cssLoaded')
|
||||
|
||||
cy.window()
|
||||
.should(() => 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.window()
|
||||
.should(() => validateBodyThemingCss(defaultPrimary, null))
|
||||
cy.screenshot()
|
||||
})
|
||||
|
||||
it('Undo theming settings and validate login page again', function() {
|
||||
cy.resetAdminTheming()
|
||||
cy.visit('/')
|
||||
|
||||
cy.window()
|
||||
.should(() => 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.findByRole('heading', { name: 'Background and color' })
|
||||
.should('exist')
|
||||
.scrollIntoView()
|
||||
})
|
||||
|
||||
it('Change the background color', function() {
|
||||
cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor')
|
||||
cy.intercept('*/apps/theming/theme/default.css?*').as('cssLoaded')
|
||||
|
||||
pickColor(cy.findByRole('button', { name: /Background color/ }))
|
||||
.then((color) => {
|
||||
selectedColor = color
|
||||
})
|
||||
|
||||
cy.wait('@setColor')
|
||||
cy.wait('@cssLoaded')
|
||||
|
||||
cy.window()
|
||||
.should(() => validateBodyThemingCss(
|
||||
defaultPrimary,
|
||||
defaultBackground,
|
||||
selectedColor,
|
||||
))
|
||||
})
|
||||
|
||||
it('Remove the default background', function() {
|
||||
cy.intercept('*/apps/theming/ajax/updateStylesheet').as('removeBackground')
|
||||
|
||||
cy.findByRole('checkbox', { name: /remove background image/i })
|
||||
.should('exist')
|
||||
.should('not.be.checked')
|
||||
.check({ force: true })
|
||||
cy.wait('@removeBackground')
|
||||
})
|
||||
|
||||
it('Screenshot the login page and validate login page', function() {
|
||||
cy.logout()
|
||||
cy.visit('/')
|
||||
|
||||
cy.window()
|
||||
.should(() => validateBodyThemingCss(defaultPrimary, null, selectedColor))
|
||||
cy.screenshot()
|
||||
})
|
||||
|
||||
it('Undo theming settings and validate login page again', function() {
|
||||
cy.resetAdminTheming()
|
||||
cy.visit('/')
|
||||
|
||||
cy.window()
|
||||
.should(() => 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.findByRole('heading', { name: 'Background and color' })
|
||||
.should('exist')
|
||||
.scrollIntoView()
|
||||
})
|
||||
|
||||
it('Remove the default background', function() {
|
||||
cy.intercept('*/apps/theming/ajax/updateStylesheet').as('removeBackground')
|
||||
cy.findByRole('checkbox', { name: /remove background image/i })
|
||||
.check({ force: true })
|
||||
cy.wait('@removeBackground')
|
||||
})
|
||||
|
||||
it('Change the background color', function() {
|
||||
cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor')
|
||||
cy.intercept('*/apps/theming/theme/default.css?*').as('cssLoaded')
|
||||
|
||||
pickColor(cy.findByRole('button', { name: /Background color/ }), 4)
|
||||
.then((color) => {
|
||||
selectedColor = color
|
||||
})
|
||||
|
||||
cy.wait('@setColor')
|
||||
cy.wait('@cssLoaded')
|
||||
|
||||
cy.window()
|
||||
.should(() => validateBodyThemingCss(defaultPrimary, null, selectedColor))
|
||||
})
|
||||
|
||||
it('See the header being inverted', function() {
|
||||
// Probe the Nextcloud logo: it carries the same
|
||||
// `var(--background-image-invert-if-bright)` filter and is always
|
||||
// present in the header. The waffle launcher's current-app icon only
|
||||
// renders when an app is active, which isn't the case on settings,
|
||||
// and the in-popover tiles use a fixed brightness/invert filter
|
||||
// regardless of theme so they're not a valid inversion probe.
|
||||
cy.waitUntil(() => navigationHeader
|
||||
.logo()
|
||||
.find('.logo')
|
||||
.then((el) => {
|
||||
let ret = true
|
||||
el.each(function() {
|
||||
ret = ret && window.getComputedStyle(this).filter === 'invert(1)'
|
||||
})
|
||||
return ret
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
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.findByRole('heading', { name: 'Background and color' })
|
||||
.should('exist')
|
||||
.scrollIntoView()
|
||||
})
|
||||
|
||||
it('Disable user background theming', function() {
|
||||
cy.intercept('*/apps/theming/ajax/updateStylesheet').as('disableUserTheming')
|
||||
|
||||
cy.findByRole('checkbox', { name: /Disable user theming/ })
|
||||
.should('exist')
|
||||
.and('not.be.checked')
|
||||
.check({ force: true })
|
||||
|
||||
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.findByRole('heading', { name: 'Background and color' })
|
||||
.should('exist')
|
||||
.scrollIntoView()
|
||||
})
|
||||
|
||||
it('Change the default background', function() {
|
||||
cy.intercept('*/apps/theming/ajax/uploadImage').as('setBackground')
|
||||
cy.intercept('*/apps/theming/theme/default.css?*').as('cssLoaded')
|
||||
|
||||
cy.fixture('image.jpg', null).as('background')
|
||||
cy.get('input[type="file"][name="background"]')
|
||||
.should('exist')
|
||||
.selectFile('@background', { force: true })
|
||||
|
||||
cy.wait('@setBackground')
|
||||
cy.wait('@cssLoaded')
|
||||
|
||||
cy.window()
|
||||
.should(() => validateBodyThemingCss(
|
||||
defaultPrimary,
|
||||
'/apps/theming/image/background?v=',
|
||||
null,
|
||||
))
|
||||
})
|
||||
|
||||
it('Change the background color', function() {
|
||||
cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor')
|
||||
cy.intercept('*/apps/theming/theme/default.css?*').as('cssLoaded')
|
||||
|
||||
pickColor(cy.findByRole('button', { name: /Background color/ }))
|
||||
.then((color) => {
|
||||
selectedColor = color
|
||||
})
|
||||
|
||||
cy.wait('@setColor')
|
||||
cy.wait('@cssLoaded')
|
||||
|
||||
cy.window()
|
||||
.should(() => validateBodyThemingCss(
|
||||
defaultPrimary,
|
||||
'/apps/theming/image/background?v=',
|
||||
selectedColor,
|
||||
))
|
||||
})
|
||||
|
||||
it('Login page should match admin theming settings', function() {
|
||||
cy.logout()
|
||||
cy.visit('/')
|
||||
|
||||
cy.window()
|
||||
.should(() => 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.findByRole('heading', { name: 'Background and color' })
|
||||
.scrollIntoView()
|
||||
})
|
||||
|
||||
it('Default user background settings should match admin theming settings', function() {
|
||||
cy.findByRole('button', { name: 'Default background' })
|
||||
.should('exist')
|
||||
.and('have.attr', 'aria-pressed', 'true')
|
||||
|
||||
cy.window()
|
||||
.should(() => 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.findByRole('heading', { name: 'Background and color' })
|
||||
.should('exist')
|
||||
.scrollIntoView()
|
||||
})
|
||||
|
||||
it('Remove the default background', function() {
|
||||
cy.intercept('*/apps/theming/ajax/updateStylesheet').as('removeBackground')
|
||||
cy.findByRole('checkbox', { name: /remove background image/i })
|
||||
.check({ force: true })
|
||||
cy.wait('@removeBackground')
|
||||
})
|
||||
|
||||
it('Login page should match admin theming settings', function() {
|
||||
cy.logout()
|
||||
cy.visit('/')
|
||||
|
||||
cy.window()
|
||||
.should(() => 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.findByRole('heading', { name: 'Background and color' })
|
||||
.scrollIntoView()
|
||||
})
|
||||
|
||||
it('Default user background settings should match admin theming settings', function() {
|
||||
cy.findByRole('button', { name: 'Default background' })
|
||||
.should('exist')
|
||||
.and('have.attr', 'aria-pressed', 'true')
|
||||
|
||||
cy.window()
|
||||
.should(() => validateUserThemingDefaultCss(defaultPrimary, null))
|
||||
})
|
||||
})
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { User } from '@nextcloud/e2e-test-server/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')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Admin theming: 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.findByRole('heading', { name: /^Theming/, level: 2 })
|
||||
.should('exist')
|
||||
.scrollIntoView()
|
||||
})
|
||||
|
||||
it('Change the name field', function() {
|
||||
cy.intercept('*/apps/theming/ajax/updateStylesheet').as('updateFields')
|
||||
|
||||
// Name
|
||||
cy.findByRole('textbox', { name: 'Name' })
|
||||
.should('be.visible')
|
||||
.type(`{selectall}${name}{enter}`)
|
||||
cy.wait('@updateFields')
|
||||
|
||||
// Url
|
||||
cy.findByRole('textbox', { name: 'Web link' })
|
||||
.should('be.visible')
|
||||
.type(`{selectall}${url}{enter}`)
|
||||
cy.wait('@updateFields')
|
||||
|
||||
// Slogan
|
||||
cy.findByRole('textbox', { name: 'Slogan' })
|
||||
.should('be.visible')
|
||||
.type(`{selectall}${slogan}{enter}`)
|
||||
cy.wait('@updateFields')
|
||||
})
|
||||
|
||||
it('Ensure undo button presence', function() {
|
||||
cy.findAllByRole('button', { name: /undo changes/i })
|
||||
.should('have.length', 3)
|
||||
})
|
||||
|
||||
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.login(admin)
|
||||
cy.visit('/settings/admin/theming')
|
||||
cy.findAllByRole('button', { name: /undo changes/i })
|
||||
.each((button) => {
|
||||
cy.intercept('*/apps/theming/ajax/undoChanges').as('undoField')
|
||||
cy.wrap(button).click()
|
||||
cy.wait('@undoField')
|
||||
})
|
||||
cy.logout()
|
||||
})
|
||||
|
||||
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}`)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { User } from '@nextcloud/e2e-test-server/cypress'
|
||||
import {
|
||||
defaultBackground,
|
||||
defaultPrimary,
|
||||
pickColor,
|
||||
validateBodyThemingCss,
|
||||
} from './themingUtils.ts'
|
||||
|
||||
const admin = new User('admin', 'admin')
|
||||
|
||||
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.findByRole('heading', { name: 'Background and color' })
|
||||
.should('exist')
|
||||
.scrollIntoView()
|
||||
})
|
||||
|
||||
it('Change the primary color', function() {
|
||||
cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor')
|
||||
|
||||
pickColor(cy.findByRole('button', { name: /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()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { User } from '@nextcloud/e2e-test-server/cypress'
|
||||
import { NavigationHeader } from '../../pages/NavigationHeader.ts'
|
||||
|
||||
const admin = new User('admin', 'admin')
|
||||
|
||||
after(() => cy.resetAdminTheming())
|
||||
|
||||
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')
|
||||
getDefaultAppSwitch().should('exist')
|
||||
getDefaultAppSwitch().scrollIntoView()
|
||||
})
|
||||
|
||||
it('Toggle the "use custom default app" switch', () => {
|
||||
getDefaultAppSwitch().should('not.be.checked')
|
||||
cy.findByRole('region', { name: 'Global default app' })
|
||||
.should('not.exist')
|
||||
|
||||
getDefaultAppSwitch().check({ force: true })
|
||||
getDefaultAppSwitch().should('be.checked')
|
||||
cy.findByRole('region', { name: 'Global default app' })
|
||||
.should('exist')
|
||||
})
|
||||
|
||||
it('See the default app combobox', () => {
|
||||
cy.findByRole('region', { name: 'Global default app' })
|
||||
.should('exist')
|
||||
.findByRole('combobox')
|
||||
.as('defaultAppSelect')
|
||||
.scrollIntoView()
|
||||
|
||||
cy.get('@defaultAppSelect')
|
||||
.findByText('Dashboard')
|
||||
.should('be.visible')
|
||||
cy.get('@defaultAppSelect')
|
||||
.findByText('Files')
|
||||
.should('be.visible')
|
||||
})
|
||||
|
||||
it('See the default app order selector', () => {
|
||||
cy.findByRole('region', { name: 'Global default app' })
|
||||
.should('exist')
|
||||
cy.findByRole('list', { name: 'Navigation bar app order' })
|
||||
.should('exist')
|
||||
.findAllByRole('listitem')
|
||||
.should('have.length', 2)
|
||||
.then((elements) => {
|
||||
const appIDs = elements.map((idx, el) => el.innerText.trim()).get()
|
||||
expect(appIDs).to.deep.eq(['Dashboard', 'Files'])
|
||||
})
|
||||
})
|
||||
|
||||
it('Change the default app', () => {
|
||||
cy.findByRole('list', { name: 'Navigation bar app order' })
|
||||
.should('exist')
|
||||
.as('appOrderSelector')
|
||||
.scrollIntoView()
|
||||
|
||||
cy.get('@appOrderSelector')
|
||||
.findAllByRole('listitem')
|
||||
.filter((_, e) => !!e.innerText.match(/Files/i))
|
||||
.findByRole('button', { name: 'Move up' })
|
||||
.as('moveFilesUpButton')
|
||||
|
||||
cy.get('@moveFilesUpButton').should('be.visible')
|
||||
cy.get('@moveFilesUpButton').click()
|
||||
cy.get('@moveFilesUpButton').should('not.exist')
|
||||
})
|
||||
|
||||
it('See the default app is changed', () => {
|
||||
cy.findByRole('list', { name: 'Navigation bar app order' })
|
||||
.findAllByRole('listitem')
|
||||
.then((elements) => {
|
||||
const appIDs = elements.map((idx, el) => el.innerText.trim()).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')
|
||||
getDefaultAppSwitch().scrollIntoView()
|
||||
|
||||
getDefaultAppSwitch().should('be.checked')
|
||||
getDefaultAppSwitch().uncheck({ force: true })
|
||||
getDefaultAppSwitch().should('be.not.checked')
|
||||
|
||||
// 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function getDefaultAppSwitch() {
|
||||
return cy.findByRole('checkbox', { name: 'Use custom default app' })
|
||||
}
|
||||
|
|
@ -1,302 +0,0 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { User } from '@nextcloud/e2e-test-server/cypress'
|
||||
|
||||
import { NavigationHeader } from '../../pages/NavigationHeader.ts'
|
||||
import { SettingsAppOrderList } from '../../pages/SettingsAppOrderList.ts'
|
||||
import { installTestApp, uninstallTestApp } from '../../support/commonUtils.ts'
|
||||
|
||||
before(() => uninstallTestApp())
|
||||
|
||||
describe('User theming set app order', () => {
|
||||
const navigationHeader = new NavigationHeader()
|
||||
const appOrderList = new SettingsAppOrderList()
|
||||
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', () => {
|
||||
visitAppOrderSettings()
|
||||
})
|
||||
|
||||
it('See that the dashboard app is the first one', () => {
|
||||
const appOrder = ['Dashboard', 'Files']
|
||||
appOrderList.assertAppOrder(appOrder)
|
||||
|
||||
// Check the top app menu order. The launcher grid appends a synthetic
|
||||
// "More apps" / "App store" tile to the user's apps, so iterate
|
||||
// positionally only over the real-app prefix.
|
||||
navigationHeader.getNavigationEntries().then(($entries) => {
|
||||
appOrder.forEach((name, index) => {
|
||||
expect($entries.eq(index)).to.contain.text(name)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('Change the app order', () => {
|
||||
appOrderList.interceptAppOrder()
|
||||
appOrderList.getAppOrderList()
|
||||
.scrollIntoView()
|
||||
appOrderList.getUpButtonForApp('Files')
|
||||
.should('be.visible')
|
||||
.click()
|
||||
appOrderList.waitForAppOrderUpdate()
|
||||
|
||||
appOrderList.assertAppOrder(['Files', 'Dashboard'])
|
||||
})
|
||||
|
||||
it('See the app menu order is changed', () => {
|
||||
cy.reload()
|
||||
const appOrder = ['Files', 'Dashboard']
|
||||
appOrderList.getAppOrderList()
|
||||
.scrollIntoView()
|
||||
appOrderList.assertAppOrder(appOrder)
|
||||
|
||||
// Check the top app menu order. Idempotent open in the page object
|
||||
// re-opens the popover after the reload above. The synthetic trailing
|
||||
// tile is ignored by iterating only over the expected app names.
|
||||
navigationHeader.getNavigationEntries().then(($entries) => {
|
||||
appOrder.forEach((name, index) => {
|
||||
expect($entries.eq(index)).to.contain.text(name)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('User theming set app order with default app', () => {
|
||||
const appOrderList = new SettingsAppOrderList()
|
||||
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', () => {
|
||||
visitAppOrderSettings()
|
||||
|
||||
const appOrder = ['Files', 'Dashboard', 'Test App 2', 'Test App']
|
||||
appOrderList.getAppOrderList()
|
||||
.scrollIntoView()
|
||||
appOrderList.assertAppOrder(appOrder)
|
||||
})
|
||||
|
||||
it('Can not change the default app', () => {
|
||||
appOrderList.getUpButtonForApp('Files').should('not.exist')
|
||||
appOrderList.getDownButtonForApp('Files').should('not.exist')
|
||||
appOrderList.getUpButtonForApp('Dashboard').should('not.exist')
|
||||
// but can move down
|
||||
appOrderList.getDownButtonForApp('Dashboard').should('be.visible')
|
||||
})
|
||||
|
||||
it('Can see the correct buttons for other apps', () => {
|
||||
appOrderList.getUpButtonForApp('Test App 2').should('be.visible')
|
||||
appOrderList.getDownButtonForApp('Test App 2').should('be.visible')
|
||||
appOrderList.getUpButtonForApp('Test App').should('be.visible')
|
||||
appOrderList.getDownButtonForApp('Test App').should('not.exist')
|
||||
})
|
||||
|
||||
it('Change the order of the other apps', () => {
|
||||
appOrderList.interceptAppOrder()
|
||||
appOrderList.getUpButtonForApp('Test App').click()
|
||||
appOrderList.waitForAppOrderUpdate()
|
||||
appOrderList.getUpButtonForApp('Test App').click()
|
||||
appOrderList.waitForAppOrderUpdate()
|
||||
|
||||
// Can't get up anymore, files is enforced as default app
|
||||
appOrderList.getUpButtonForApp('Test App').should('not.exist')
|
||||
|
||||
// Check the app order settings UI
|
||||
appOrderList.assertAppOrder(['Files', 'Test App', 'Dashboard', 'Test App 2'])
|
||||
})
|
||||
|
||||
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. See note above: the launcher appends
|
||||
// a synthetic tile that we skip by iterating positionally.
|
||||
navigationHeader.getNavigationEntries().then(($entries) => {
|
||||
appOrder.forEach((name, index) => {
|
||||
expect($entries.eq(index)).to.contain.text(name)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('User theming app order list accessibility', () => {
|
||||
const appOrderList = new SettingsAppOrderList()
|
||||
let user: User
|
||||
|
||||
before(() => {
|
||||
cy.resetAdminTheming()
|
||||
installTestApp()
|
||||
// Create random user for this test
|
||||
cy.createRandomUser().then(($user) => {
|
||||
user = $user
|
||||
cy.login($user)
|
||||
})
|
||||
})
|
||||
|
||||
after(() => {
|
||||
uninstallTestApp()
|
||||
cy.deleteUser(user)
|
||||
})
|
||||
|
||||
it('click the first button', () => {
|
||||
visitAppOrderSettings()
|
||||
appOrderList.interceptAppOrder()
|
||||
appOrderList.getDownButtonForApp('Dashboard')
|
||||
.should('be.visible')
|
||||
.scrollIntoView()
|
||||
appOrderList.getDownButtonForApp('Dashboard')
|
||||
.focus()
|
||||
appOrderList.getDownButtonForApp('Dashboard')
|
||||
.click()
|
||||
appOrderList.waitForAppOrderUpdate()
|
||||
})
|
||||
|
||||
it('see the same app kept the focus', () => {
|
||||
appOrderList.getDownButtonForApp('Dashboard').should('have.focus')
|
||||
})
|
||||
|
||||
it('click the last button', () => {
|
||||
appOrderList.interceptAppOrder()
|
||||
appOrderList.getUpButtonForApp('Dashboard')
|
||||
.should('be.visible')
|
||||
.focus()
|
||||
appOrderList.getUpButtonForApp('Dashboard').click()
|
||||
appOrderList.waitForAppOrderUpdate()
|
||||
})
|
||||
|
||||
it('see the same app kept the focus', () => {
|
||||
appOrderList.getUpButtonForApp('Dashboard').should('not.exist')
|
||||
appOrderList.getDownButtonForApp('Dashboard').should('have.focus')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User theming reset app order', () => {
|
||||
const appOrderList = new SettingsAppOrderList()
|
||||
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 that the dashboard app is the first one', () => {
|
||||
visitAppOrderSettings()
|
||||
|
||||
const appOrder = ['Dashboard', 'Files']
|
||||
appOrderList.assertAppOrder(appOrder)
|
||||
|
||||
// Check the top app menu order. See note above on the synthetic tile.
|
||||
navigationHeader.getNavigationEntries().then(($entries) => {
|
||||
appOrder.forEach((name, index) => {
|
||||
expect($entries.eq(index)).to.contain.text(name)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('See the reset button is disabled', () => {
|
||||
appOrderList.getResetButton()
|
||||
.scrollIntoView()
|
||||
appOrderList.getResetButton()
|
||||
.should('be.disabled')
|
||||
})
|
||||
|
||||
it('Change the app order', () => {
|
||||
appOrderList.interceptAppOrder()
|
||||
appOrderList.getUpButtonForApp('Files')
|
||||
.should('be.visible')
|
||||
.click()
|
||||
appOrderList.waitForAppOrderUpdate()
|
||||
|
||||
appOrderList.assertAppOrder(['Files', 'Dashboard'])
|
||||
})
|
||||
|
||||
it('See the reset button is no longer disabled', () => {
|
||||
appOrderList.getResetButton()
|
||||
.scrollIntoView()
|
||||
appOrderList.getResetButton()
|
||||
.should('be.visible')
|
||||
.and('be.enabled')
|
||||
})
|
||||
|
||||
it('Reset the app order', () => {
|
||||
cy.intercept('GET', '/ocs/v2.php/core/navigation/apps').as('loadApps')
|
||||
appOrderList.interceptAppOrder()
|
||||
appOrderList.getResetButton().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']
|
||||
appOrderList.assertAppOrder(appOrder)
|
||||
// Check the top app menu order. See note above on the synthetic tile.
|
||||
navigationHeader.getNavigationEntries().then(($entries) => {
|
||||
appOrder.forEach((name, index) => {
|
||||
expect($entries.eq(index)).to.contain.text(name)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('See the reset button is disabled again', () => {
|
||||
appOrderList.getResetButton()
|
||||
.should('be.disabled')
|
||||
})
|
||||
})
|
||||
|
||||
function visitAppOrderSettings() {
|
||||
cy.visit('/settings/user/theming')
|
||||
cy.findByRole('heading', { name: /Navigation bar settings/ })
|
||||
.should('exist')
|
||||
.scrollIntoView()
|
||||
}
|
||||
|
|
@ -1,270 +0,0 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { User } from '@nextcloud/e2e-test-server/cypress'
|
||||
import { NavigationHeader } from '../../pages/NavigationHeader.ts'
|
||||
import { defaultPrimary, pickColor, validateBodyThemingCss } from './themingUtils.ts'
|
||||
|
||||
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.findByRole('heading', { name: /Appearance and accessibility settings/ })
|
||||
.should('be.visible')
|
||||
})
|
||||
|
||||
it('Default is selected on new users', function() {
|
||||
cy.findByRole('button', { name: 'Default background', pressed: true })
|
||||
.should('exist')
|
||||
.scrollIntoView()
|
||||
})
|
||||
})
|
||||
|
||||
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.findByRole('heading', { name: /Background and color/ })
|
||||
.should('exist')
|
||||
.scrollIntoView()
|
||||
})
|
||||
|
||||
it('Select a shipped background', function() {
|
||||
const background = 'anatoly-mikhaltsov-butterfly-wing-scale.jpg'
|
||||
const backgroundName = 'Background picture of a red-ish butterfly wing under microscope'
|
||||
cy.intercept('*/apps/theming/background/shipped').as('setBackground')
|
||||
|
||||
// Select background
|
||||
cy.findByRole('button', { name: backgroundName, pressed: false })
|
||||
.click()
|
||||
cy.findByRole('button', { name: backgroundName, pressed: true })
|
||||
.should('be.visible')
|
||||
|
||||
// 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'
|
||||
const backgroundName = 'Montage of a cetonia aurata bug that takes off with white background'
|
||||
cy.intercept('*/apps/theming/background/shipped').as('setBackground')
|
||||
|
||||
cy.findByRole('button', { name: backgroundName, pressed: false })
|
||||
.click()
|
||||
cy.findByRole('button', { name: backgroundName, pressed: true })
|
||||
.should('be.visible')
|
||||
|
||||
// Validate changed background and primary
|
||||
cy.wait('@setBackground')
|
||||
cy.waitUntil(() => validateBodyThemingCss('#56633d', background, '#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.findByRole('heading', { name: /Background and color/ })
|
||||
.should('exist')
|
||||
.scrollIntoView()
|
||||
})
|
||||
|
||||
it('Select a custom color', function() {
|
||||
cy.intercept('*/apps/theming/background/color').as('clearBackground')
|
||||
|
||||
// Clear background
|
||||
pickColor(cy.findByRole('button', { name: 'Plain background' }), 7)
|
||||
|
||||
// Validate clear background
|
||||
cy.wait('@clearBackground')
|
||||
cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, null, '#3794ac'))
|
||||
})
|
||||
})
|
||||
|
||||
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.findByRole('heading', { name: /Background and color/ })
|
||||
.should('exist')
|
||||
.scrollIntoView()
|
||||
})
|
||||
|
||||
it('Remove background', function() {
|
||||
cy.intercept('*/apps/theming/background/color').as('clearBackground')
|
||||
|
||||
// Clear background
|
||||
pickColor(cy.findByRole('button', { name: 'Plain background' }), 4)
|
||||
|
||||
// Validate clear background
|
||||
cy.wait('@clearBackground')
|
||||
cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, null, '#ddcb55'))
|
||||
})
|
||||
|
||||
it('See the header being inverted', function() {
|
||||
// Probe the Nextcloud logo: it carries the same
|
||||
// `var(--background-image-invert-if-bright)` filter and is always
|
||||
// present in the header. The waffle launcher's current-app icon only
|
||||
// renders when an app is active, which isn't the case on settings,
|
||||
// and the in-popover tiles use a fixed brightness/invert filter
|
||||
// regardless of theme so they're not a valid inversion probe.
|
||||
cy.waitUntil(() => navigationHeader.logo().find('.logo').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'
|
||||
const backgroundName = 'Background picture of a red-ish butterfly wing under microscope'
|
||||
cy.intercept('*/apps/theming/background/shipped').as('setBackground')
|
||||
|
||||
// Select background
|
||||
cy.findByRole('button', { name: backgroundName, pressed: false })
|
||||
.click()
|
||||
cy.findByRole('button', { name: backgroundName, pressed: true })
|
||||
.should('be.visible')
|
||||
|
||||
// Validate changed background and primary
|
||||
cy.wait('@setBackground')
|
||||
cy.waitUntil(() => validateBodyThemingCss('#a53c17', background, '#652e11'))
|
||||
})
|
||||
|
||||
it('See the header NOT being inverted this time', function() {
|
||||
// Probe the Nextcloud logo: see the inverted-header test above for
|
||||
// why we don't probe the menu icons.
|
||||
cy.waitUntil(() => navigationHeader.logo().find('.logo').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.findByRole('heading', { name: /Background and color/ })
|
||||
.should('exist')
|
||||
.scrollIntoView()
|
||||
})
|
||||
|
||||
it('Select a custom background', function() {
|
||||
cy.intercept('*/apps/theming/background/custom').as('setBackground')
|
||||
|
||||
// Pick background
|
||||
cy.findByRole('button', { name: 'Custom background' }).click()
|
||||
cy.findByRole('dialog')
|
||||
.should('be.visible')
|
||||
.findAllByRole('row')
|
||||
.contains(image)
|
||||
.click()
|
||||
cy.findByRole('button', { name: 'Select background' }).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'
|
||||
|
||||
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.findByRole('heading', { name: /Background and color/ })
|
||||
.should('exist')
|
||||
.scrollIntoView()
|
||||
})
|
||||
|
||||
it('Select a custom background', function() {
|
||||
cy.intercept('*/apps/theming/background/custom').as('setBackground')
|
||||
|
||||
// Pick background
|
||||
cy.findByRole('button', { name: 'Custom background' }).click()
|
||||
cy.findByRole('dialog')
|
||||
.should('be.visible')
|
||||
.findAllByRole('row')
|
||||
.contains(image)
|
||||
.click()
|
||||
cy.findByRole('button', { name: 'Select background' }).click()
|
||||
|
||||
// Wait for background to be set
|
||||
cy.wait('@setBackground')
|
||||
cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, 'apps/theming/background?v=', '#2f2221'))
|
||||
})
|
||||
|
||||
it('Select a custom background color', function() {
|
||||
cy.intercept('*/apps/theming/background/color').as('clearBackground')
|
||||
|
||||
// Clear background
|
||||
pickColor(cy.findByRole('button', { name: 'Plain background' }), 5)
|
||||
|
||||
// Validate clear background
|
||||
cy.wait('@clearBackground')
|
||||
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')
|
||||
|
||||
pickColor(cy.findByRole('button', { name: 'Primary color' }), 2)
|
||||
|
||||
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'))
|
||||
})
|
||||
})
|
||||
64
package-lock.json
generated
64
package-lock.json
generated
|
|
@ -47,6 +47,7 @@
|
|||
"@nextcloud/stylelint-config": "^3.2.2",
|
||||
"@nextcloud/typings": "^1.10.0",
|
||||
"@nextcloud/vite-config": "^2.5.2",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@testing-library/cypress": "^10.1.3",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/vue": "^8.1.0",
|
||||
|
|
@ -3032,6 +3033,22 @@
|
|||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobufjs/aspromise": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||
|
|
@ -13409,6 +13426,53 @@
|
|||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pluralize": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@
|
|||
"lint": "eslint --suppressions-location build/eslint-baseline.json --no-error-on-unmatched-pattern ./cypress",
|
||||
"postlint": "build/demi.sh lint",
|
||||
"lint:fix": "build/demi.sh lint:fix",
|
||||
"playwright": "playwright test",
|
||||
"playwright:install": "playwright install chromium-headless-shell",
|
||||
"sass": "sass --style compressed --load-path core/css core/css/ $(for cssdir in $(find apps -mindepth 2 -maxdepth 2 -name \"css\"); do if ! $(git check-ignore -q $cssdir); then printf \"$cssdir \"; fi; done)",
|
||||
"sass:icons": "node build/icons.mjs",
|
||||
"sass:watch": "sass --watch --load-path core/css core/css/ $(for cssdir in $(find apps -mindepth 2 -maxdepth 2 -name \"css\"); do if ! $(git check-ignore -q $cssdir); then printf \"$cssdir \"; fi; done)",
|
||||
|
|
@ -76,6 +78,7 @@
|
|||
"@nextcloud/stylelint-config": "^3.2.2",
|
||||
"@nextcloud/typings": "^1.10.0",
|
||||
"@nextcloud/vite-config": "^2.5.2",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@testing-library/cypress": "^10.1.3",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/vue": "^8.1.0",
|
||||
|
|
|
|||
55
playwright.config.ts
Normal file
55
playwright.config.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/playwright/e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: process.env.CI ? [['blob'], ['dot'], ['github']] : 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:8042/index.php/',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'admin-settings',
|
||||
fullyParallel: false,
|
||||
workers: 1, // only one admin setting test can run at a time due to shared state
|
||||
testMatch: '**/admin-settings*.spec.ts',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'chrome',
|
||||
testMatch: /\/(?!admin-settings)[^/]*\.spec\.ts$/,
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
},
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'node tests/playwright/start-nextcloud-server.js',
|
||||
env: {
|
||||
NEXTCLOUD_PORT: '8042',
|
||||
},
|
||||
stderr: 'pipe',
|
||||
stdout: 'pipe',
|
||||
gracefulShutdown: {
|
||||
signal: 'SIGTERM',
|
||||
timeout: 10000,
|
||||
},
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 5 * 60 * 1000,
|
||||
wait: {
|
||||
stdout: /Nextcloud container ready to run Playwright tests/,
|
||||
},
|
||||
},
|
||||
})
|
||||
217
tests/playwright/e2e/appstore/admin-settings-apps.spec.ts
Normal file
217
tests/playwright/e2e/appstore/admin-settings-apps.spec.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
import { test } from '../../support/fixtures/admin-appstore-page.ts'
|
||||
import { handlePasswordConfirmation } from '../../support/utils/password-confirmation.ts'
|
||||
import { runOcc } from '@nextcloud/e2e-test-server'
|
||||
|
||||
test.describe('Settings: App management', () => {
|
||||
test.beforeEach(async ({ page, appstorePage }) => {
|
||||
// Disable QA testing app if already enabled
|
||||
expect(await runOcc(['app:disable', 'testing']))
|
||||
.toMatch(/(No such app enabled|testing .+ disabled)/)
|
||||
// Enable update notification app if disabled
|
||||
expect(await runOcc(['app:enable', 'updatenotification']))
|
||||
.toMatch(/(updatenotification already enabled|updatenotification .+ enabled)/)
|
||||
|
||||
// Open the installed apps page
|
||||
await appstorePage.openInstalledApps()
|
||||
|
||||
// Wait for the apps table to load
|
||||
await appstorePage.appsTable().waitFor({ state: 'visible', timeout: 10000 })
|
||||
})
|
||||
|
||||
test('Can enable an installed app', async ({ page, appstorePage }) => {
|
||||
// Intercept the enable app request
|
||||
const enableRequest = page.waitForResponse(
|
||||
(response) => response.url().includes('/ocs/v2.php/apps/appstore/api/v1/apps/enable'),
|
||||
)
|
||||
|
||||
// Find and click the enable button for the QA testing app
|
||||
await expect(appstorePage.appsTable()).toBeVisible()
|
||||
const qaTestingRow = appstorePage.appRow('QA testing')
|
||||
await expect(qaTestingRow).toBeVisible({ timeout: 10000 })
|
||||
|
||||
await appstorePage.enableButton('QA testing').click({ force: true })
|
||||
|
||||
// Handle password confirmation if needed
|
||||
await handlePasswordConfirmation(page, 'admin')
|
||||
|
||||
// Wait for the API request
|
||||
await enableRequest
|
||||
|
||||
// Wait until we see the disable button for the app
|
||||
await expect(appstorePage.appsTable()).toBeVisible()
|
||||
await expect(appstorePage.appRow('QA testing')).toBeVisible()
|
||||
await expect(appstorePage.disableButton('QA testing')).toBeVisible()
|
||||
|
||||
// Change to enabled apps view
|
||||
await appstorePage.openEnabledApps()
|
||||
|
||||
// Verify the app appears in the enabled list
|
||||
await expect(appstorePage.appRow('QA testing')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Can disable an installed app', async ({ page, appstorePage }) => {
|
||||
// Intercept the disable app request
|
||||
const disableRequest = page.waitForResponse(
|
||||
(response) => response.url().includes('/ocs/v2.php/apps/appstore/api/v1/apps/disable'),
|
||||
)
|
||||
|
||||
// Find and click the disable button for the Update notification app
|
||||
await expect(appstorePage.appsTable()).toBeVisible()
|
||||
const updateRow = appstorePage.appRow('Update notification')
|
||||
await expect(updateRow).toBeVisible({ timeout: 10000 })
|
||||
|
||||
await appstorePage.disableButton('Update notification').click({ force: true })
|
||||
|
||||
// Handle password confirmation if needed
|
||||
await handlePasswordConfirmation(page, 'admin')
|
||||
|
||||
// Wait for the API request
|
||||
await disableRequest
|
||||
|
||||
// Wait until we see the enable button for the app
|
||||
await expect(appstorePage.appsTable()).toBeVisible()
|
||||
await expect(appstorePage.appRow('Update notification')).toBeVisible()
|
||||
await expect(appstorePage.enableButton('Update notification')).toBeVisible()
|
||||
|
||||
// Change to disabled apps view
|
||||
await appstorePage.openDisabledApps()
|
||||
|
||||
// Verify the app appears in the disabled list
|
||||
await expect(appstorePage.appRow('Update notification')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Browse enabled apps', async ({ appstorePage }) => {
|
||||
// Open the "Active apps" section
|
||||
await appstorePage.openEnabledApps()
|
||||
|
||||
// Verify the URL is correct
|
||||
await expect(appstorePage.navigationLink('Active apps')).toHaveAttribute('aria-current', 'page')
|
||||
|
||||
// Verify that there are only enabled apps (all have "Disable" button, no "Enable" button)
|
||||
await expect(appstorePage.appsTable()).toBeVisible()
|
||||
|
||||
// Get all rows and verify each has a disable button and no enable button
|
||||
const rows = appstorePage.appsTable().locator('tr')
|
||||
const rowCount = await rows.count()
|
||||
|
||||
for (let i = 1; i < rowCount; i++) { // Skip header row
|
||||
const row = rows.nth(i)
|
||||
const enableButton = row.getByRole('button', { name: 'Enable' })
|
||||
|
||||
// Enabled apps should not have an "Enable" button
|
||||
await expect(enableButton).not.toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('Browse disabled apps', async ({ appstorePage }) => {
|
||||
// Open the "Disabled apps" section
|
||||
await appstorePage.openDisabledApps()
|
||||
|
||||
// Verify the current section is "Disabled apps"
|
||||
await expect(appstorePage.navigationLink('Disabled apps')).toHaveAttribute('aria-current', 'page')
|
||||
|
||||
// Verify that there are only disabled apps (all have "Enable" button, no "Disable" button)
|
||||
await expect(appstorePage.appsTable()).toBeVisible()
|
||||
|
||||
// Get all rows and verify each has an enable button and no disable button
|
||||
const rows = appstorePage.appsTable().locator('tr')
|
||||
const rowCount = await rows.count()
|
||||
|
||||
for (let i = 1; i < rowCount; i++) { // Skip header row
|
||||
const row = rows.nth(i)
|
||||
const disableButton = row.getByRole('button', { name: 'Disable' })
|
||||
|
||||
// Disabled apps should not have a "Disable" button
|
||||
await expect(disableButton).not.toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('Browse app bundles', async ({ appstorePage }) => {
|
||||
// Open the "App bundles" section
|
||||
await appstorePage.openBundles()
|
||||
|
||||
// Verify the current section is "App bundles"
|
||||
await expect(appstorePage.navigationLink('App bundles')).toHaveAttribute('aria-current', 'page')
|
||||
|
||||
// Verify we see the app bundles
|
||||
await expect(appstorePage.enterpriseBundleHeading()).toBeVisible()
|
||||
await expect(appstorePage.educationBundleHeading()).toBeVisible()
|
||||
})
|
||||
|
||||
test('View app details', async ({ appstorePage }) => {
|
||||
// Click on the "QA testing" app
|
||||
await appstorePage.appLink('QA testing').click({ force: true })
|
||||
|
||||
// Verify the app details sidebar is shown
|
||||
const sidebar = appstorePage.appSidebar()
|
||||
await expect(sidebar).toBeVisible()
|
||||
await expect(appstorePage.appSidebarHeader()).toContainText('QA testing')
|
||||
|
||||
// Verify the sidebar contains expected elements
|
||||
await expect(appstorePage.viewInStoreLink()).toBeVisible()
|
||||
await expect(appstorePage.appSidebarEnableButton()).toBeVisible()
|
||||
await expect(appstorePage.removeButton()).toBeVisible()
|
||||
|
||||
// Verify version information is displayed
|
||||
await expect(appstorePage.versionText()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Limit app usage to group', async ({ appstorePage, page }) => {
|
||||
// Open the "Active apps" section
|
||||
await appstorePage.openEnabledApps()
|
||||
|
||||
// Select the updatenotification app
|
||||
await appstorePage.appLink('Update Notification').scrollIntoViewIfNeeded()
|
||||
await appstorePage.appLink('Update Notification').click()
|
||||
|
||||
// Click the "Limit to groups" button
|
||||
await appstorePage.limitToGroupsButton().click()
|
||||
|
||||
// The dialog should be visible
|
||||
const dialog = appstorePage.groupDialog()
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
// Type "admin" in the search field
|
||||
const searchInput = appstorePage.groupSearchInput()
|
||||
await expect(searchInput).toBeFocused()
|
||||
await searchInput.fill('admin')
|
||||
|
||||
// Select the admin option from the dropdown
|
||||
await appstorePage.groupOption('admin').click()
|
||||
|
||||
// Click the Save button
|
||||
await appstorePage.dialogSaveButton().click()
|
||||
|
||||
// Handle password confirmation
|
||||
await handlePasswordConfirmation(page, 'admin')
|
||||
|
||||
// Verify the group is now in the "Limited to groups" list
|
||||
const limitedList = appstorePage.limitedToGroupsList()
|
||||
await expect(limitedList).toBeVisible()
|
||||
await expect(limitedList.getByRole('listitem', { name: /admin/ })).toBeVisible()
|
||||
|
||||
// Now disable the group limitation
|
||||
await appstorePage.limitToGroupsButton().click()
|
||||
|
||||
// The dialog should be visible again
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
// Click the deselect button for the admin group
|
||||
await appstorePage.deselectGroupButton('admin').click()
|
||||
|
||||
// Click Save
|
||||
await appstorePage.dialogSaveButton().click()
|
||||
|
||||
// Handle password confirmation
|
||||
await handlePasswordConfirmation(page, 'admin')
|
||||
|
||||
// Verify the "Limited to groups" list is no longer visible
|
||||
await expect(appstorePage.limitedToGroupsList()).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
102
tests/playwright/e2e/dav/availability.spec.ts
Normal file
102
tests/playwright/e2e/dav/availability.spec.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
import { User } from '@nextcloud/e2e-test-server'
|
||||
import { addUser, runOcc } from '@nextcloud/e2e-test-server/docker'
|
||||
import { test } from '../../support/fixtures/random-user-session.ts'
|
||||
|
||||
test.describe('Calendar: Availability', () => {
|
||||
test('User can see the availability section in settings', async ({ page }) => {
|
||||
await page.goto('settings/user')
|
||||
|
||||
// The settings sidebar lists an "Availability" navigation link
|
||||
await page.getByRole('link', { name: /Availability/i }).first().click()
|
||||
|
||||
await expect(page).toHaveURL(/settings\/user\/availability$/)
|
||||
await expect(page.getByRole('heading', { name: /Availability/i, level: 2 })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Users can set their availability status', async ({ page }) => {
|
||||
await page.goto('settings/user/availability')
|
||||
|
||||
// CalendarAvailability renders listitems without an accessible name; filter by text content
|
||||
const fridayItem = page.locator('#availability').getByRole('listitem').filter({ hasText: 'Friday' })
|
||||
await expect(fridayItem).toBeVisible()
|
||||
await expect(fridayItem).toContainText('No working hours set')
|
||||
|
||||
// Add a time slot for Friday
|
||||
await fridayItem.getByRole('button', { name: 'Add slot' }).click()
|
||||
|
||||
// Fill start and end times — labels are visually hidden but accessible
|
||||
await fridayItem.getByLabel('Pick a start time for Friday').fill('09:00')
|
||||
await fridayItem.getByLabel('Pick a end time for Friday').fill('18:00')
|
||||
|
||||
// Wait for the PROPPATCH save request before clicking
|
||||
const saveResponse = page.waitForResponse(
|
||||
(r) => r.url().includes('/remote.php/dav/calendars/') && r.url().includes('/inbox') && r.request().method() === 'PROPPATCH',
|
||||
)
|
||||
await page.locator('#availability').getByRole('button', { name: 'Save' }).click()
|
||||
await saveResponse
|
||||
|
||||
await page.reload()
|
||||
|
||||
// After reload Friday should have a slot (no longer shows "No working hours set")
|
||||
await expect(page.locator('#availability').getByRole('listitem').filter({ hasText: 'Friday' })).not.toContainText('No working hours set')
|
||||
})
|
||||
|
||||
test('Users can set their absence', async ({ page }) => {
|
||||
// Create a specific replacement user
|
||||
const replacementUser = new User('replacement-user', 'password')
|
||||
await runOcc(['user:delete', replacementUser.userId]).catch(() => {})
|
||||
await addUser(replacementUser)
|
||||
|
||||
try {
|
||||
await page.goto('settings/user/availability')
|
||||
|
||||
await page.getByRole('heading', { name: /absence/i }).scrollIntoViewIfNeeded()
|
||||
|
||||
const absenceSection = page.locator('#absence')
|
||||
|
||||
// Fill date fields (NcDateTimePickerNative with type="date")
|
||||
await absenceSection.getByLabel('First day').fill('2024-12-24')
|
||||
await absenceSection.getByLabel(/Last day/i).fill('2024-12-28')
|
||||
|
||||
// Fill text fields
|
||||
await absenceSection.getByRole('textbox', { name: /Short absence/i }).fill('Vacation')
|
||||
await absenceSection.getByRole('textbox', { name: /Long absence/i }).fill('Happy holidays!')
|
||||
|
||||
// Search for the replacement user via NcSelectUsers
|
||||
const userSearchInput = absenceSection.getByLabel('Out of office replacement (optional)')
|
||||
const searchResponse = page.waitForResponse(
|
||||
(r) => r.url().includes('/apps/files_sharing/api/v1/sharees') && r.url().includes('search=replacement'),
|
||||
)
|
||||
await userSearchInput.click()
|
||||
await userSearchInput.fill('replacement')
|
||||
await searchResponse
|
||||
|
||||
await page.getByRole('option', { name: 'replacement-user' }).click()
|
||||
|
||||
// Save and wait for the OCS POST
|
||||
const saveResponse = page.waitForResponse(
|
||||
(r) => r.url().includes('/apps/dav/api/v1/outOfOffice/') && r.request().method() === 'POST',
|
||||
)
|
||||
await absenceSection.getByRole('button', { name: 'Save' }).click()
|
||||
await saveResponse
|
||||
|
||||
await page.reload()
|
||||
|
||||
// Verify all fields are persisted after reload
|
||||
await expect(absenceSection.getByLabel('First day')).toHaveValue('2024-12-24')
|
||||
await expect(absenceSection.getByLabel(/Last day/i)).toHaveValue('2024-12-28')
|
||||
await expect(absenceSection.getByRole('textbox', { name: /Short absence/i })).toHaveValue('Vacation')
|
||||
await expect(absenceSection.getByRole('textbox', { name: /Long absence/i })).toHaveValue('Happy holidays!')
|
||||
// NcSelectUsers (single-select) shows the selected user in .vs__selected and a "Clear selected" button
|
||||
await expect(absenceSection.locator('.vs__selected')).toContainText('replacement-user')
|
||||
} finally {
|
||||
await runOcc(['user:delete', replacementUser.userId])
|
||||
}
|
||||
})
|
||||
})
|
||||
60
tests/playwright/e2e/files/files-delete.spec.ts
Normal file
60
tests/playwright/e2e/files/files-delete.spec.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { test, expect } from '../../support/fixtures/files-page.ts'
|
||||
import { mkdir, uploadContent } from '../../support/utils/dav.ts'
|
||||
|
||||
test.describe('Files: Delete', () => {
|
||||
test('can delete a file', async ({ page, user, filesListPage }) => {
|
||||
await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file.txt')
|
||||
await filesListPage.open()
|
||||
|
||||
const row = filesListPage.getRowForFile('file.txt')
|
||||
await expect(row).toBeVisible()
|
||||
// Preview must finish loading before delete — a loading preview can lock the file
|
||||
await expect(row.locator('.files-list__row-icon-preview--loaded')).toBeVisible()
|
||||
|
||||
const deleteResponse = page.waitForResponse(
|
||||
(r) => r.url().includes('/remote.php/dav/files/') && r.request().method() === 'DELETE',
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
await filesListPage.triggerActionForFile('file.txt', 'delete')
|
||||
expect((await deleteResponse).status()).toBe(204)
|
||||
})
|
||||
|
||||
test('can delete multiple files', async ({ page, user, filesListPage }) => {
|
||||
await mkdir(page.request, user, '/root')
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', `/root/file${i}.txt`)
|
||||
}
|
||||
await filesListPage.open()
|
||||
await filesListPage.navigateToFolder('root')
|
||||
|
||||
// All 5 preview thumbnails must finish loading before we delete
|
||||
await expect(page.locator('.files-list__row-icon-preview--loaded')).toHaveCount(5)
|
||||
|
||||
// Set up listeners for all 5 DELETE responses before triggering the action
|
||||
const deleteResponses = Promise.all(
|
||||
Array.from({ length: 5 }, () =>
|
||||
page.waitForResponse(
|
||||
(r) => r.url().includes(`/remote.php/dav/files/${user.userId}/root/`) && r.request().method() === 'DELETE',
|
||||
{ timeout: 15000 },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
await filesListPage.selectAll()
|
||||
await filesListPage.triggerSelectionAction('delete')
|
||||
|
||||
await page.getByRole('dialog', { name: 'Confirm deletion' })
|
||||
.getByRole('button', { name: 'Delete files' })
|
||||
.click()
|
||||
|
||||
const responses = await deleteResponses
|
||||
for (const response of responses) {
|
||||
expect(response.status()).toBe(204)
|
||||
}
|
||||
})
|
||||
})
|
||||
51
tests/playwright/e2e/files/files-navigation.spec.ts
Normal file
51
tests/playwright/e2e/files/files-navigation.spec.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { test, expect } from '../../support/fixtures/files-page.ts'
|
||||
import { mkdir } from '../../support/utils/dav.ts'
|
||||
|
||||
test.describe('Files: Navigation', () => {
|
||||
test.beforeEach(async ({ page, user, filesListPage }) => {
|
||||
await mkdir(page.request, user, '/foo')
|
||||
await mkdir(page.request, user, '/foo/bar')
|
||||
await mkdir(page.request, user, '/foo/bar/baz')
|
||||
await filesListPage.open()
|
||||
})
|
||||
|
||||
test('shows root folder and can navigate to a deeply nested folder', async ({ page, filesListPage }) => {
|
||||
await expect(filesListPage.getRowForFile('foo')).toBeVisible()
|
||||
await filesListPage.navigateToFolder('foo/bar/baz')
|
||||
|
||||
// deepest folder is empty — no file rows rendered
|
||||
await expect(page.locator('[data-cy-files-list-row-fileid]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('highlights the previous folder when navigating back and forward', async ({ page, filesListPage }) => {
|
||||
await filesListPage.navigateToFolder('foo/bar/baz')
|
||||
await expect(page.locator('[data-cy-files-list-row-fileid]')).toHaveCount(0)
|
||||
|
||||
// Navigate back through each level — the folder we came from is highlighted
|
||||
await page.goBack()
|
||||
await expect(filesListPage.getRowForFile('baz')).toBeVisible()
|
||||
await expect(filesListPage.getRowForFile('baz')).toBeActiveRow()
|
||||
|
||||
await page.goBack()
|
||||
await expect(filesListPage.getRowForFile('bar')).toBeVisible()
|
||||
await expect(filesListPage.getRowForFile('bar')).toBeActiveRow()
|
||||
|
||||
await page.goBack()
|
||||
await expect(filesListPage.getRowForFile('foo')).toBeVisible()
|
||||
await expect(filesListPage.getRowForFile('foo')).toBeActiveRow()
|
||||
|
||||
// Navigate forward — the folder we re-entered is highlighted
|
||||
await page.goForward()
|
||||
await expect(filesListPage.getRowForFile('bar')).toBeVisible()
|
||||
await expect(filesListPage.getRowForFile('bar')).toBeActiveRow()
|
||||
|
||||
await page.goForward()
|
||||
await expect(filesListPage.getRowForFile('baz')).toBeVisible()
|
||||
await expect(filesListPage.getRowForFile('baz')).toBeActiveRow()
|
||||
})
|
||||
})
|
||||
242
tests/playwright/e2e/files/files-renaming.spec.ts
Normal file
242
tests/playwright/e2e/files/files-renaming.spec.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { test, expect } from '../../support/fixtures/files-page.ts'
|
||||
import { mkdir, rm, uploadContent } from '../../support/utils/dav.ts'
|
||||
|
||||
test.describe('Files: Rename nodes', () => {
|
||||
test.beforeEach(async ({ page, user, filesListPage }) => {
|
||||
// New users get welcome.txt — remove it so the list contains only our test files
|
||||
await rm(page.request, user, '/welcome.txt')
|
||||
await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file.txt')
|
||||
await filesListPage.open()
|
||||
})
|
||||
|
||||
test('can rename a file', async ({ filesListPage }) => {
|
||||
await expect(filesListPage.getRowForFile('file.txt')).toBeVisible()
|
||||
|
||||
await filesListPage.triggerActionForFile('file.txt', 'rename')
|
||||
|
||||
const input = filesListPage.getRenameInputForFile('file.txt')
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill('other.txt')
|
||||
await expect(input).toHaveValidationMessage('')
|
||||
await input.press('Enter')
|
||||
|
||||
await expect(filesListPage.getRowForFile('other.txt')).toBeVisible()
|
||||
})
|
||||
|
||||
/**
|
||||
* If this test gets flaky then the selection is not reliably set to the basename.
|
||||
* The selection should cover only the name part (without extension) when rename opens.
|
||||
*/
|
||||
test('only selects basename of file on rename open', async ({ filesListPage }) => {
|
||||
await expect(filesListPage.getRowForFile('file.txt')).toBeVisible()
|
||||
|
||||
await filesListPage.triggerActionForFile('file.txt', 'rename')
|
||||
|
||||
const input = filesListPage.getRenameInputForFile('file.txt')
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
const { selectionStart, selectionEnd } = await input.evaluate(
|
||||
(el) => ({ selectionStart: (el as HTMLInputElement).selectionStart, selectionEnd: (el as HTMLInputElement).selectionEnd }),
|
||||
)
|
||||
expect(selectionStart).toBe(0)
|
||||
expect(selectionEnd).toBe('file'.length)
|
||||
})
|
||||
|
||||
test('shows validation error on invalid filename', async ({ filesListPage }) => {
|
||||
await expect(filesListPage.getRowForFile('file.txt')).toBeVisible()
|
||||
|
||||
await filesListPage.triggerActionForFile('file.txt', 'rename')
|
||||
|
||||
const input = filesListPage.getRenameInputForFile('file.txt')
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill('.htaccess')
|
||||
|
||||
await expect(input).toHaveValidationMessage(/reserved name/i)
|
||||
})
|
||||
|
||||
test('shows accessible loading state while rename MOVE is in-flight', async ({ page, filesListPage }) => {
|
||||
await expect(filesListPage.getRowForFile('file.txt')).toBeVisible()
|
||||
|
||||
// Hold MOVE requests until we explicitly release them
|
||||
let resolveMove!: () => void
|
||||
const moveAllowed = new Promise<void>(resolve => { resolveMove = resolve })
|
||||
await page.route(/remote\.php\/dav\/files\//, async (route) => {
|
||||
if (route.request().method() === 'MOVE') {
|
||||
await moveAllowed
|
||||
}
|
||||
await route.continue()
|
||||
})
|
||||
|
||||
await filesListPage.triggerActionForFile('file.txt', 'rename')
|
||||
const input = filesListPage.getRenameInputForFile('file.txt')
|
||||
await input.fill('new-name.txt')
|
||||
await input.press('Enter')
|
||||
|
||||
// While MOVE is blocked: row shows loading icon, checkbox is hidden
|
||||
const loadingRow = filesListPage.getRowForFile('new-name.txt')
|
||||
await expect(loadingRow.getByRole('img', { name: 'File is loading' })).toBeVisible()
|
||||
await expect(loadingRow.getByRole('checkbox', { name: /Toggle selection/ })).not.toBeVisible()
|
||||
|
||||
// Release the MOVE and wait for it to complete
|
||||
const moveResponse = page.waitForResponse(
|
||||
r => r.url().includes('/remote.php/dav/files/') && r.request().method() === 'MOVE',
|
||||
)
|
||||
resolveMove()
|
||||
await moveResponse
|
||||
await page.unroute(/remote\.php\/dav\/files\//)
|
||||
|
||||
// Loading state clears: checkbox reappears, loading icon gone
|
||||
await expect(loadingRow.getByRole('checkbox', { name: /Toggle selection/ })).toBeVisible()
|
||||
await expect(loadingRow.getByRole('img', { name: 'File is loading' })).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('cancel renaming on Escape', async ({ filesListPage }) => {
|
||||
await expect(filesListPage.getRowForFile('file.txt')).toBeVisible()
|
||||
|
||||
await filesListPage.triggerActionForFile('file.txt', 'rename')
|
||||
|
||||
const input = filesListPage.getRenameInputForFile('file.txt')
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill('other.txt')
|
||||
await expect(input).toHaveValidationMessage('')
|
||||
await input.press('Escape')
|
||||
|
||||
// Original name kept, rename input removed
|
||||
await expect(filesListPage.getRowForFile('other.txt')).toHaveCount(0)
|
||||
await expect(filesListPage.getRowForFile('file.txt')).toBeVisible()
|
||||
await expect(filesListPage.getRowForFile('file.txt').locator('input[type="text"]')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('cancel renaming on Enter when name is unchanged', async ({ filesListPage }) => {
|
||||
await expect(filesListPage.getRowForFile('file.txt')).toBeVisible()
|
||||
|
||||
await filesListPage.triggerActionForFile('file.txt', 'rename')
|
||||
|
||||
const input = filesListPage.getRenameInputForFile('file.txt')
|
||||
await expect(input).toBeVisible()
|
||||
await input.press('Enter')
|
||||
|
||||
// No rename happened, input is gone
|
||||
await expect(filesListPage.getRowForFile('file.txt')).toBeVisible()
|
||||
await expect(filesListPage.getRowForFile('file.txt').locator('input[type="text"]')).not.toBeVisible()
|
||||
})
|
||||
|
||||
/**
|
||||
* Regression: https://github.com/nextcloud/server/issues/47438
|
||||
* Virtual scrolling removed the renaming component from DOM before state reset,
|
||||
* leaving the row permanently stuck in rename mode.
|
||||
*/
|
||||
test('correctly resets renaming state after virtual-scroll re-render', async ({ page, user, filesListPage }) => {
|
||||
// Create 19 more files so virtual scrolling kicks in with a small viewport
|
||||
for (let i = 1; i <= 19; i++) {
|
||||
await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', `/file${i}.txt`)
|
||||
}
|
||||
|
||||
// Start with a small viewport so only a few rows fit
|
||||
await page.setViewportSize({ width: 768, height: 500 })
|
||||
await filesListPage.open()
|
||||
|
||||
// Measure the DOM to calculate the exact height that shows only 4 rows
|
||||
const viewportHeight = await page.evaluate(() => {
|
||||
const filesList = document.querySelector('[data-cy-files-list]') as HTMLElement
|
||||
const outerHeight = window.innerHeight - filesList.clientHeight
|
||||
const beforeHeight = (document.querySelector('.files-list__before') as HTMLElement)?.offsetHeight ?? 0
|
||||
const filterHeight = (document.querySelector('.files-list__filters') as HTMLElement)?.offsetHeight ?? 0
|
||||
const theadHeight = (document.querySelector('[data-cy-files-list-thead]') as HTMLElement)?.offsetHeight ?? 0
|
||||
const rowHeight = (document.querySelector('[data-cy-files-list-tbody] tr') as HTMLElement)?.offsetHeight ?? 0
|
||||
return outerHeight + beforeHeight + filterHeight + theadHeight + 4 * rowHeight
|
||||
})
|
||||
await page.setViewportSize({ width: 768, height: viewportHeight })
|
||||
await filesListPage.open()
|
||||
|
||||
await expect(filesListPage.getRowForFile('file.txt')).toBeVisible()
|
||||
|
||||
// Rename to 'zzz.txt' — sorts last, scrolls out of the visible area
|
||||
await filesListPage.triggerActionForFile('file.txt', 'rename')
|
||||
const input = filesListPage.getRenameInputForFile('file.txt')
|
||||
const moveResponse = page.waitForResponse(
|
||||
r => r.url().includes('/remote.php/dav/files/') && r.request().method() === 'MOVE',
|
||||
)
|
||||
await input.fill('zzz.txt')
|
||||
await input.press('Enter')
|
||||
await moveResponse
|
||||
|
||||
// After rename zzz.txt is sorted to the end — no longer in the visible viewport
|
||||
await expect(filesListPage.getRowForFile('zzz.txt')).toHaveCount(0)
|
||||
|
||||
// Scroll to the bottom to bring zzz.txt into view
|
||||
await page.locator('[data-cy-files-list]').evaluate(el => el.scrollTo(0, el.scrollHeight))
|
||||
|
||||
// Row must be visible and NOT in rename state
|
||||
await expect(filesListPage.getRowForFile('zzz.txt')).toBeVisible()
|
||||
await expect(filesListPage.getRenameInputForFile('zzz.txt')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('shows extension-change warning — keep new extension', async ({ page, filesListPage }) => {
|
||||
await expect(filesListPage.getRowForFile('file.txt')).toBeVisible()
|
||||
|
||||
await filesListPage.triggerActionForFile('file.txt', 'rename')
|
||||
const input = filesListPage.getRenameInputForFile('file.txt')
|
||||
await input.fill('file.md')
|
||||
await input.press('Enter')
|
||||
|
||||
await page.getByRole('dialog', { name: 'Change file extension' })
|
||||
.getByRole('button', { name: 'Use .md' })
|
||||
.click()
|
||||
|
||||
await expect(filesListPage.getRowForFile('file.md')).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows extension-change warning — keep old extension', async ({ page, filesListPage }) => {
|
||||
await expect(filesListPage.getRowForFile('file.txt')).toBeVisible()
|
||||
|
||||
await filesListPage.triggerActionForFile('file.txt', 'rename')
|
||||
const input = filesListPage.getRenameInputForFile('file.txt')
|
||||
await input.fill('document.md')
|
||||
await input.press('Enter')
|
||||
|
||||
await page.getByRole('dialog', { name: 'Change file extension' })
|
||||
.getByRole('button', { name: 'Keep .txt' })
|
||||
.click()
|
||||
|
||||
await expect(filesListPage.getRowForFile('document.txt')).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows extension-removal warning', async ({ page, filesListPage }) => {
|
||||
await expect(filesListPage.getRowForFile('file.txt')).toBeVisible()
|
||||
|
||||
await filesListPage.triggerActionForFile('file.txt', 'rename')
|
||||
const input = filesListPage.getRenameInputForFile('file.txt')
|
||||
await input.fill('file')
|
||||
await input.press('Enter')
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'Change file extension' })
|
||||
await expect(dialog.getByRole('button', { name: 'Keep .txt' })).toBeVisible()
|
||||
await dialog.getByRole('button', { name: 'Remove extension' }).click()
|
||||
|
||||
await expect(filesListPage.getRowForFile('file')).toBeVisible()
|
||||
await expect(filesListPage.getRowForFile('file.txt')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('does not show extension warning when renaming a folder with a dot', async ({ page, user, filesListPage }) => {
|
||||
await mkdir(page.request, user, '/folder.2024')
|
||||
await filesListPage.open()
|
||||
|
||||
await expect(filesListPage.getRowForFile('folder.2024')).toBeVisible()
|
||||
|
||||
await filesListPage.triggerActionForFile('folder.2024', 'rename')
|
||||
const input = filesListPage.getRenameInputForFolder('folder.2024')
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill('folder.2025')
|
||||
await expect(input).toHaveValidationMessage('')
|
||||
await input.press('Enter')
|
||||
|
||||
await expect(page.locator('[role="dialog"]')).toHaveCount(0)
|
||||
await expect(filesListPage.getRowForFile('folder.2025')).toBeVisible()
|
||||
})
|
||||
})
|
||||
112
tests/playwright/e2e/files/files-sidebar.spec.ts
Normal file
112
tests/playwright/e2e/files/files-sidebar.spec.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { test, expect } from '../../support/fixtures/files-page.ts'
|
||||
import { mkdir, uploadContent } from '../../support/utils/dav.ts'
|
||||
|
||||
test.describe('Files: Sidebar', () => {
|
||||
let fileId: number
|
||||
|
||||
test.beforeEach(async ({ user, page, filesListPage }) => {
|
||||
await mkdir(page.request, user, '/folder')
|
||||
fileId = await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file')
|
||||
await filesListPage.open()
|
||||
})
|
||||
|
||||
test('opens the sidebar', async ({ filesListPage, filesSidebar }) => {
|
||||
await expect(filesListPage.getRowForFile('file')).toBeVisible()
|
||||
|
||||
await filesListPage.triggerActionForFile('file', 'details')
|
||||
|
||||
await expect(filesSidebar.sidebar()).toBeVisible()
|
||||
await expect(filesSidebar.heading('file')).toBeVisible()
|
||||
})
|
||||
|
||||
test('changes the current fileid', async ({ page, filesListPage, filesSidebar }) => {
|
||||
await expect(filesListPage.getRowForFile('file')).toBeVisible()
|
||||
|
||||
await filesListPage.triggerActionForFile('file', 'details')
|
||||
|
||||
await expect(filesSidebar.sidebar()).toBeVisible()
|
||||
await expect(page).toHaveURL(new RegExp(`apps/files/files/${fileId}`))
|
||||
})
|
||||
|
||||
test('changes the sidebar content on other file', async ({ filesListPage, filesSidebar }) => {
|
||||
await expect(filesListPage.getRowForFile('file')).toBeVisible()
|
||||
|
||||
await filesListPage.triggerActionForFile('file', 'details')
|
||||
|
||||
await expect(filesSidebar.sidebar()).toBeVisible()
|
||||
// Wait for the first file's heading to be stable before switching
|
||||
await expect(filesSidebar.heading('file')).toBeVisible()
|
||||
|
||||
await filesListPage.triggerActionForFile('folder', 'details')
|
||||
await expect(filesSidebar.sidebar()).toBeVisible()
|
||||
await expect(filesSidebar.heading('folder')).toBeVisible()
|
||||
})
|
||||
|
||||
test('closes the sidebar on navigation', async ({ filesListPage, filesSidebar }) => {
|
||||
await expect(filesListPage.getRowForFile('file')).toBeVisible()
|
||||
await expect(filesListPage.getRowForFile('folder')).toBeVisible()
|
||||
|
||||
// Open the sidebar
|
||||
await filesListPage.triggerActionForFile('file', 'details')
|
||||
await expect(filesSidebar.sidebar()).toBeVisible()
|
||||
|
||||
// Navigate into the folder — sidebar should close
|
||||
await filesListPage.navigateToFolder('folder')
|
||||
await expect(filesSidebar.sidebar()).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('closes the sidebar on delete', async ({ page, filesListPage, filesSidebar, user }) => {
|
||||
await expect(filesListPage.getRowForFile('file')).toBeVisible()
|
||||
|
||||
// Open the sidebar
|
||||
await filesListPage.triggerActionForFile('file', 'details')
|
||||
await expect(filesSidebar.sidebar()).toBeVisible()
|
||||
// Wait for the sidebar to be fully rendered before deleting
|
||||
await expect(filesSidebar.heading('file')).toBeVisible()
|
||||
|
||||
const deleteResponse = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`/remote.php/dav/files/${user.userId}/file`)
|
||||
&& response.request().method() === 'DELETE',
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
|
||||
await filesListPage.triggerActionForFile('file', 'delete')
|
||||
await deleteResponse
|
||||
|
||||
await expect(filesSidebar.sidebar()).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('changes the fileid on delete', async ({ page, filesListPage, filesSidebar, user }) => {
|
||||
const otherFileId = await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/folder/other')
|
||||
|
||||
await expect(filesListPage.getRowForFile('folder')).toBeVisible()
|
||||
await filesListPage.navigateToFolder('folder')
|
||||
await expect(filesListPage.getRowForFile('other')).toBeVisible()
|
||||
|
||||
// Open the sidebar for the inner file
|
||||
await filesListPage.triggerActionForFile('other', 'details')
|
||||
await expect(filesSidebar.sidebar()).toBeVisible()
|
||||
await expect(page).toHaveURL(new RegExp(`apps/files/files/${otherFileId}`))
|
||||
// Wait for the sidebar to be fully rendered before deleting
|
||||
await expect(filesSidebar.heading('other')).toBeVisible()
|
||||
|
||||
const deleteResponse = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`/remote.php/dav/files/${user.userId}/folder/other`)
|
||||
&& response.request().method() === 'DELETE',
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
|
||||
await filesListPage.triggerActionForFile('other', 'delete')
|
||||
await deleteResponse
|
||||
|
||||
await expect(filesSidebar.sidebar()).not.toBeVisible()
|
||||
await expect(page).not.toHaveURL(new RegExp(`apps/files/files/${otherFileId}`))
|
||||
})
|
||||
})
|
||||
101
tests/playwright/e2e/systemtags/admin-settings.spec.ts
Normal file
101
tests/playwright/e2e/systemtags/admin-settings.spec.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
import { runOcc } from '@nextcloud/e2e-test-server/docker'
|
||||
import { test } from '../../support/fixtures/admin-session.ts'
|
||||
|
||||
const tagName = 'foo'
|
||||
const updatedTagName = 'bar'
|
||||
|
||||
test.describe('System tags admin settings', () => {
|
||||
// Tests are sequential: update depends on create, delete depends on update
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async () => {
|
||||
// Delete all existing tags so each test run starts from a clean state
|
||||
const output = await runOcc(['tag:list', '--output=json'])
|
||||
const tags = JSON.parse(output) as Record<string, unknown>
|
||||
await Promise.all(Object.keys(tags).map((id) => runOcc(['tag:delete', id]).catch(() => {})))
|
||||
})
|
||||
|
||||
test('Can create a tag', async ({ page }) => {
|
||||
await page.goto('settings/admin/server')
|
||||
|
||||
// Scroll the collaborative tags section into view — the admin settings page is long
|
||||
await page.getByRole('heading', { name: 'Collaborative tags' }).scrollIntoViewIfNeeded()
|
||||
|
||||
const tagNameInput = page.getByLabel('Tag name')
|
||||
await expect(tagNameInput).toHaveValue('')
|
||||
|
||||
// Create the tag and intercept the DAV POST
|
||||
const createResponse = page.waitForResponse(
|
||||
(r) => r.url().includes('/remote.php/dav/systemtags') && r.request().method() === 'POST',
|
||||
)
|
||||
await tagNameInput.fill(tagName)
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
expect((await createResponse).status()).toBe(201)
|
||||
|
||||
// The form resets after creation — verify the tag now appears in the selection dropdown
|
||||
await page.getByRole('combobox', { name: 'Search for a tag to edit' }).click()
|
||||
await expect(page.getByRole('option', { name: tagName })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Can update a tag', async ({ page }) => {
|
||||
await page.goto('settings/admin/server')
|
||||
await page.getByRole('heading', { name: 'Collaborative tags' }).scrollIntoViewIfNeeded()
|
||||
|
||||
// Select the tag to edit
|
||||
await page.getByRole('combobox', { name: 'Search for a tag to edit' }).click()
|
||||
await page.getByRole('option', { name: tagName }).click()
|
||||
|
||||
// Verify the form reflects the selected tag
|
||||
await expect(page.getByLabel('Tag name')).toHaveValue(tagName)
|
||||
// NcSelect single-select: selected level appears inline in .vs__selected
|
||||
await expect(page.locator('.system-tag-form__group:has(#system-tag-level) .vs__selected')).toContainText('Public')
|
||||
|
||||
// Update the name
|
||||
await page.getByLabel('Tag name').fill(updatedTagName)
|
||||
|
||||
// Change the level — click opens the teleported VueSelect dropdown
|
||||
await page.locator('#system-tag-level').click()
|
||||
await page.getByRole('option', { name: 'Invisible' }).click()
|
||||
|
||||
const updateResponse = page.waitForResponse(
|
||||
(r) => r.url().includes('/remote.php/dav/systemtags/') && r.request().method() === 'PROPPATCH',
|
||||
)
|
||||
await page.getByRole('button', { name: 'Update' }).click()
|
||||
expect((await updateResponse).status()).toBe(207)
|
||||
|
||||
// NcEllipsisedOption splits names ≥ 10 chars across two spans, breaking the accessible name.
|
||||
// "bar (invisible)" (15 chars) splits at position 8 → accessible name "bar (inv isible)".
|
||||
// Use filter({ hasText }) to match on text content instead of the exact accessible name.
|
||||
await page.getByRole('combobox', { name: 'Search for a tag to edit' }).click()
|
||||
await expect(page.getByRole('option').filter({ hasText: updatedTagName })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Can delete a tag', async ({ page }) => {
|
||||
await page.goto('settings/admin/server')
|
||||
await page.getByRole('heading', { name: 'Collaborative tags' }).scrollIntoViewIfNeeded()
|
||||
|
||||
// Select the invisible tag to delete
|
||||
await page.getByRole('combobox', { name: 'Search for a tag to edit' }).click()
|
||||
await page.getByRole('option').filter({ hasText: updatedTagName }).click()
|
||||
|
||||
// Verify the form reflects the selected tag
|
||||
await expect(page.getByLabel('Tag name')).toHaveValue(updatedTagName)
|
||||
await expect(page.locator('.system-tag-form__group:has(#system-tag-level) .vs__selected')).toContainText('Invisible')
|
||||
|
||||
const deleteResponse = page.waitForResponse(
|
||||
(r) => r.url().includes('/remote.php/dav/systemtags/') && r.request().method() === 'DELETE',
|
||||
)
|
||||
await page.locator('.system-tag-form__row').getByRole('button', { name: 'Delete' }).click()
|
||||
expect((await deleteResponse).status()).toBe(204)
|
||||
|
||||
// Verify the tag is gone from the dropdown
|
||||
await page.getByRole('combobox', { name: 'Search for a tag to edit' }).click()
|
||||
await expect(page.getByRole('option').filter({ hasText: updatedTagName })).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
117
tests/playwright/e2e/theming/a11y-color-contrast.spec.ts
Normal file
117
tests/playwright/e2e/theming/a11y-color-contrast.spec.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path'
|
||||
import { runOcc } from '@nextcloud/e2e-test-server/docker'
|
||||
import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright'
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
const themesToTest = ['light', 'dark', 'light-highcontrast', 'dark-highcontrast']
|
||||
|
||||
const testCases = {
|
||||
'Main text': {
|
||||
foregroundColors: ['color-main-text', 'color-text-maxcontrast'],
|
||||
backgroundColors: ['color-main-background', 'color-background-hover', 'color-background-dark'],
|
||||
},
|
||||
'blurred background': {
|
||||
foregroundColors: ['color-main-text', 'color-text-maxcontrast-blur'],
|
||||
backgroundColors: ['color-main-background-blur'],
|
||||
},
|
||||
Primary: {
|
||||
foregroundColors: ['color-primary-text'],
|
||||
backgroundColors: ['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'],
|
||||
},
|
||||
'Severity information texts': {
|
||||
foregroundColors: ['color-error-text', 'color-warning-text', 'color-success-text', 'color-info-text'],
|
||||
backgroundColors: ['color-main-background', 'color-background-hover'],
|
||||
},
|
||||
'Severity information on blur': {
|
||||
foregroundColors: ['color-error-text', 'color-success-text'],
|
||||
backgroundColors: ['color-main-background-blur'],
|
||||
},
|
||||
}
|
||||
|
||||
for (const theme of themesToTest) {
|
||||
test(`Accessibility of Nextcloud theming colors: ${theme}`, async ({ page, context }) => {
|
||||
const user = await createRandomUser()
|
||||
const failures: string[] = []
|
||||
|
||||
try {
|
||||
await runOcc(['user:setting', '--', user.userId, 'theming', 'enabled-themes', `["${theme}"]`])
|
||||
await login(context.request, user)
|
||||
await page.goto('')
|
||||
|
||||
await page.addScriptTag({ path: resolve(process.cwd(), 'node_modules/axe-core/axe.min.js') })
|
||||
|
||||
for (const [groupName, { foregroundColors, backgroundColors }] of Object.entries(testCases)) {
|
||||
for (const foreground of foregroundColors) {
|
||||
for (const background of backgroundColors) {
|
||||
await page.evaluate(({ foregroundValue, backgroundValue }) => {
|
||||
document.body.style.backgroundImage = 'unset'
|
||||
const root = document.querySelector('#content')
|
||||
if (!root) {
|
||||
throw new Error('No test root found')
|
||||
}
|
||||
|
||||
root.innerHTML = ''
|
||||
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.style.padding = '14px'
|
||||
wrapper.style.color = `var(--${foregroundValue})`
|
||||
wrapper.style.backgroundColor = `var(--${backgroundValue})`
|
||||
if (backgroundValue.includes('blur')) {
|
||||
wrapper.style.backdropFilter = 'var(--filter-background-blur)'
|
||||
}
|
||||
|
||||
const testCase = document.createElement('div')
|
||||
testCase.innerText = `${foregroundValue} ${backgroundValue}`
|
||||
testCase.setAttribute('data-cy-testcase', '')
|
||||
|
||||
wrapper.append(testCase)
|
||||
root.append(wrapper)
|
||||
}, {
|
||||
foregroundValue: foreground,
|
||||
backgroundValue: background,
|
||||
})
|
||||
|
||||
const axeResult = await page.evaluate(async () => {
|
||||
const axe = (window as any).axe
|
||||
if (!axe) {
|
||||
throw new Error('axe is not loaded')
|
||||
}
|
||||
|
||||
return axe.run('[data-cy-testcase]', {
|
||||
runOnly: {
|
||||
type: 'rule',
|
||||
values: ['color-contrast'],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
if (axeResult.violations.length > 0) {
|
||||
failures.push(`${groupName}: ${foreground} on ${background}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await runOcc(['user:delete', user.userId])
|
||||
}
|
||||
|
||||
expect(failures).toEqual([])
|
||||
})
|
||||
}
|
||||
123
tests/playwright/e2e/theming/admin-settings-background.spec.ts
Normal file
123
tests/playwright/e2e/theming/admin-settings-background.spec.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright'
|
||||
import { runOcc } from '@nextcloud/e2e-test-server/docker'
|
||||
import { expect } from '@playwright/test'
|
||||
import { test } from '../../support/fixtures/admin-theming-page.ts'
|
||||
import { resolve } from 'node:path'
|
||||
import { getBodyThemingSnapshot, pickColor } from '../../support/utils/theming.ts'
|
||||
|
||||
test.describe('Admin theming background settings', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeEach(async ({ adminThemingPage, page }) => {
|
||||
await adminThemingPage.reset()
|
||||
await adminThemingPage.open()
|
||||
if (await adminThemingPage.disableUserThemingCheckbox().isChecked()) {
|
||||
await Promise.all([
|
||||
page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'),
|
||||
adminThemingPage.disableUserThemingCheckbox().uncheck({ force: true }),
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
test('Remove default background and restore it', async ({ adminThemingPage, page }) => {
|
||||
await expect(adminThemingPage.backgroundAndColorHeading()).toBeVisible()
|
||||
if (await adminThemingPage.removeBackgroundImageCheckbox().isChecked()) {
|
||||
await Promise.all([
|
||||
page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'),
|
||||
adminThemingPage.removeBackgroundImageCheckbox().uncheck({ force: true }),
|
||||
])
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'),
|
||||
adminThemingPage.removeBackgroundImageCheckbox().check({ force: true }),
|
||||
])
|
||||
|
||||
await page.goto('/index.php/logout')
|
||||
await page.goto('/index.php/login')
|
||||
await expect.poll(async () => (await getBodyThemingSnapshot(page)).backgroundImage).toBe('none')
|
||||
|
||||
await adminThemingPage.reset()
|
||||
await page.goto('settings/admin/theming')
|
||||
await expect(adminThemingPage.backgroundAndColorHeading()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Disable user theming', async ({ adminThemingPage, page, context }) => {
|
||||
await expect(adminThemingPage.disableUserThemingCheckbox()).not.toBeChecked()
|
||||
await Promise.all([
|
||||
page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'),
|
||||
adminThemingPage.disableUserThemingCheckbox().check({ force: true }),
|
||||
])
|
||||
|
||||
const user = await createRandomUser()
|
||||
try {
|
||||
await login(context.request, user)
|
||||
await page.goto('settings/user/theming')
|
||||
await expect(page.getByText('Customization has been disabled by your administrator')).toBeVisible()
|
||||
} finally {
|
||||
await runOcc(['user:delete', user.userId])
|
||||
}
|
||||
})
|
||||
|
||||
test('Remove default background with custom color', async ({ adminThemingPage, page, context }) => {
|
||||
await expect(adminThemingPage.backgroundAndColorHeading()).toBeVisible()
|
||||
const backgroundColorButton = page.getByRole('button', { name: /Background color/ })
|
||||
const selectedColor = await pickColor(page, backgroundColorButton, 2)
|
||||
expect(selectedColor).toBeTruthy()
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'),
|
||||
adminThemingPage.removeBackgroundImageCheckbox().check({ force: true }),
|
||||
])
|
||||
|
||||
await page.goto('/index.php/logout')
|
||||
await page.goto('/index.php/login')
|
||||
await expect.poll(async () => (await getBodyThemingSnapshot(page)).backgroundImage).toBe('none')
|
||||
})
|
||||
|
||||
test('User default background reflects admin custom background and color', async ({ adminThemingPage, page, context }) => {
|
||||
const imagePath = resolve(process.cwd(), 'cypress/fixtures/image.jpg')
|
||||
|
||||
await page.locator('input[type="file"][name="background"]').setInputFiles(imagePath)
|
||||
await page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/uploadImage') && response.request().method() === 'POST')
|
||||
|
||||
const backgroundColorButton = page.getByRole('button', { name: /Background color/ })
|
||||
await pickColor(page, backgroundColorButton, 1)
|
||||
await page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST')
|
||||
|
||||
await page.goto('/index.php/logout')
|
||||
const user = await createRandomUser()
|
||||
try {
|
||||
await login(context.request, user)
|
||||
await page.goto('settings/user/theming')
|
||||
await expect(page.getByRole('button', { name: 'Default background' })).toHaveAttribute('aria-pressed', 'true')
|
||||
const snapshot = await getBodyThemingSnapshot(page)
|
||||
expect(snapshot.backgroundImage).toContain('/apps/theming/image/background?v=')
|
||||
} finally {
|
||||
await runOcc(['user:delete', user.userId])
|
||||
}
|
||||
})
|
||||
|
||||
test('User default background reflects admin removed background', async ({ adminThemingPage, page, context }) => {
|
||||
await Promise.all([
|
||||
page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'),
|
||||
adminThemingPage.removeBackgroundImageCheckbox().check({ force: true }),
|
||||
])
|
||||
|
||||
await page.goto('/index.php/logout')
|
||||
const user = await createRandomUser()
|
||||
try {
|
||||
await login(context.request, user)
|
||||
await page.goto('settings/user/theming')
|
||||
await expect(page.getByRole('button', { name: 'Default background' })).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect.poll(async () => (await getBodyThemingSnapshot(page)).backgroundImage).toBe('none')
|
||||
} finally {
|
||||
await runOcc(['user:delete', user.userId])
|
||||
}
|
||||
})
|
||||
})
|
||||
102
tests/playwright/e2e/theming/admin-settings-branding.spec.ts
Normal file
102
tests/playwright/e2e/theming/admin-settings-branding.spec.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { User } from '@nextcloud/e2e-test-server'
|
||||
import { expect } from '@playwright/test'
|
||||
import { test } from '../../support/fixtures/admin-theming-page.ts'
|
||||
|
||||
const admin = new User('admin', 'admin')
|
||||
|
||||
test.describe('Admin theming branding settings', () => {
|
||||
test.beforeEach(async ({ adminThemingPage }) => {
|
||||
await adminThemingPage.reset()
|
||||
await adminThemingPage.open()
|
||||
})
|
||||
|
||||
test('Set project links and verify persisted values', async ({ adminThemingPage, page }) => {
|
||||
await expect(adminThemingPage.webLinkInput()).toHaveAttribute('type', 'url')
|
||||
await expect(adminThemingPage.legalNoticeLinkInput()).toHaveAttribute('type', 'url')
|
||||
await expect(adminThemingPage.privacyPolicyLinkInput()).toHaveAttribute('type', 'url')
|
||||
|
||||
await adminThemingPage.webLinkInput().fill('http://example.com/path?query#fragment')
|
||||
await Promise.all([
|
||||
page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'),
|
||||
adminThemingPage.webLinkInput().press('Enter'),
|
||||
])
|
||||
|
||||
await adminThemingPage.legalNoticeLinkInput().fill('http://example.com/legal?query#fragment')
|
||||
await Promise.all([
|
||||
page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'),
|
||||
adminThemingPage.legalNoticeLinkInput().press('Enter'),
|
||||
])
|
||||
|
||||
await adminThemingPage.privacyPolicyLinkInput().fill('http://privacy.local/path?query#fragment')
|
||||
await Promise.all([
|
||||
page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'),
|
||||
adminThemingPage.privacyPolicyLinkInput().press('Enter'),
|
||||
])
|
||||
|
||||
await page.reload()
|
||||
await expect(adminThemingPage.webLinkInput()).toHaveValue('http://example.com/path?query#fragment')
|
||||
await expect(adminThemingPage.legalNoticeLinkInput()).toHaveValue('http://example.com/legal?query#fragment')
|
||||
await expect(adminThemingPage.privacyPolicyLinkInput()).toHaveValue('http://privacy.local/path?query#fragment')
|
||||
})
|
||||
|
||||
test('Set and undo login fields', async ({ adminThemingPage, page }) => {
|
||||
const name = 'ABCdef123'
|
||||
const url = 'https://example.com'
|
||||
const slogan = 'Testing is fun'
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'),
|
||||
adminThemingPage.nameInput().fill(name),
|
||||
])
|
||||
await adminThemingPage.nameInput().press('Enter')
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'),
|
||||
adminThemingPage.webLinkInput().fill(url),
|
||||
])
|
||||
await adminThemingPage.webLinkInput().press('Enter')
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'),
|
||||
adminThemingPage.sloganInput().fill(slogan),
|
||||
])
|
||||
await adminThemingPage.sloganInput().press('Enter')
|
||||
|
||||
await expect(adminThemingPage.undoChangesButtons()).toHaveCount(3)
|
||||
|
||||
for (let index = 0; index < 3; index++) {
|
||||
await Promise.all([
|
||||
page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/undoChanges') && response.request().method() === 'POST'),
|
||||
adminThemingPage.undoChangesButtons().first().click(),
|
||||
])
|
||||
}
|
||||
await expect(adminThemingPage.undoChangesButtons()).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Web link corner cases', async ({ adminThemingPage, page }) => {
|
||||
await setUrlFieldAndWait(page, adminThemingPage.webLinkInput(), 'http://example.com/%22path%20with%20space%22')
|
||||
await page.reload()
|
||||
await expect(adminThemingPage.webLinkInput()).toHaveValue('http://example.com/%22path%20with%20space%22')
|
||||
|
||||
await setUrlFieldAndWait(page, adminThemingPage.webLinkInput(), 'http://example.com/"path"')
|
||||
await page.reload()
|
||||
await expect(adminThemingPage.webLinkInput()).toHaveValue('http://example.com/%22path%22')
|
||||
|
||||
await setUrlFieldAndWait(page, adminThemingPage.webLinkInput(), 'http://example.com/"the%20path"')
|
||||
await page.reload()
|
||||
await expect(adminThemingPage.webLinkInput()).toHaveValue('http://example.com/%22the%20path%22')
|
||||
})
|
||||
})
|
||||
|
||||
async function setUrlFieldAndWait(page: import('@playwright/test').Page, locator: import('@playwright/test').Locator, value: string) {
|
||||
await locator.fill(value)
|
||||
await Promise.all([
|
||||
page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'),
|
||||
locator.press('Enter'),
|
||||
])
|
||||
}
|
||||
30
tests/playwright/e2e/theming/admin-settings-colors.spec.ts
Normal file
30
tests/playwright/e2e/theming/admin-settings-colors.spec.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
import { test } from '../../support/fixtures/admin-theming-page.ts'
|
||||
import { pickColor } from '../../support/utils/theming.ts'
|
||||
|
||||
test.beforeEach(async ({ adminThemingPage }) => {
|
||||
await adminThemingPage.reset()
|
||||
await adminThemingPage.open()
|
||||
})
|
||||
|
||||
test('Change the primary color and reset it', async ({ adminThemingPage, page }) => {
|
||||
await page.getByRole('heading', { name: 'Background and color' }).scrollIntoViewIfNeeded()
|
||||
|
||||
const primaryColorButton = page.getByRole('button', { name: /Primary color/ })
|
||||
const updateStylesheetResponse = page.waitForResponse((response) => {
|
||||
return response.url().includes('/apps/theming/ajax/updateStylesheet')
|
||||
&& response.request().method() === 'POST'
|
||||
})
|
||||
await pickColor(page, primaryColorButton, 3)
|
||||
expect(await updateStylesheetResponse).toBeTruthy()
|
||||
|
||||
await page.goto('settings/admin/theming')
|
||||
await adminThemingPage.reset()
|
||||
await page.goto('settings/admin/theming')
|
||||
await expect(page.getByRole('heading', { name: 'Background and color' })).toBeVisible()
|
||||
})
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
import { runOcc } from '@nextcloud/e2e-test-server/docker'
|
||||
import { test } from '../../support/fixtures/admin-theming-page.ts'
|
||||
import { NavigationHeaderPage } from '../../support/sections/NavigationHeaderPage.ts'
|
||||
|
||||
test.describe('Admin theming set default apps', () => {
|
||||
// we need serial mode to reset the default app setting after each test
|
||||
// and to restore the default app to dashboard at the end of the tests.
|
||||
// Otherwise, the tests would influence each other and lead to random failures (race condition when run in parallel).
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeEach(async ({ adminThemingPage, page, context }) => {
|
||||
await runOcc(['config:system:set', 'defaultapp', '--value', 'dashboard'])
|
||||
await adminThemingPage.reset()
|
||||
await page.goto('')
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await runOcc(['config:system:set', 'defaultapp', '--value', 'dashboard'])
|
||||
})
|
||||
|
||||
test('See the current default app is the dashboard', async ({ page }) => {
|
||||
const navigationHeader = new NavigationHeaderPage(page)
|
||||
|
||||
await expect(page).toHaveURL(/apps\/dashboard/)
|
||||
await navigationHeader.logo().click()
|
||||
await expect(page).toHaveURL(/apps\/dashboard/)
|
||||
})
|
||||
|
||||
test('Can configure and switch the default app to files', async ({ adminThemingPage }) => {
|
||||
await adminThemingPage.open()
|
||||
await expect(adminThemingPage.defaultAppSwitch()).toBeVisible()
|
||||
if (await adminThemingPage.defaultAppSwitch().isChecked()) {
|
||||
await adminThemingPage.defaultAppSwitch().uncheck({ force: true })
|
||||
}
|
||||
await expect(adminThemingPage.defaultAppSwitch()).not.toBeChecked()
|
||||
|
||||
await adminThemingPage.defaultAppSwitch().check({ force: true })
|
||||
await expect(adminThemingPage.defaultAppSwitch()).toBeChecked()
|
||||
await expect(adminThemingPage.defaultAppRegion()).toBeVisible()
|
||||
|
||||
await expect(adminThemingPage.defaultAppSelectedValue('Dashboard')).toBeVisible()
|
||||
await expect(adminThemingPage.defaultAppSelectedValue('Files')).toBeVisible()
|
||||
|
||||
await expect(adminThemingPage.appOrderEntries()).toHaveCount(2)
|
||||
await expect(adminThemingPage.appOrderEntries().nth(0)).toContainText('Dashboard')
|
||||
await expect(adminThemingPage.appOrderEntries().nth(1)).toContainText('Files')
|
||||
|
||||
await adminThemingPage.moveUpButton('Files').click()
|
||||
await expect(adminThemingPage.moveUpButton('Files')).toHaveCount(0)
|
||||
await expect(adminThemingPage.appOrderEntries().nth(0)).toContainText('Files')
|
||||
await expect(adminThemingPage.appOrderEntries().nth(1)).toContainText('Dashboard')
|
||||
|
||||
await adminThemingPage.defaultAppSwitch().uncheck({ force: true })
|
||||
await expect(adminThemingPage.defaultAppSwitch()).not.toBeChecked()
|
||||
await expect(adminThemingPage.defaultAppRegion()).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
46
tests/playwright/e2e/theming/user-settings-app-order.spec.ts
Normal file
46
tests/playwright/e2e/theming/user-settings-app-order.spec.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
import { test } from '../../support/fixtures/random-user-session.ts'
|
||||
import { NavigationHeaderPage } from '../../support/sections/NavigationHeaderPage.ts'
|
||||
import { UserThemingPage } from '../../support/sections/UserThemingPage.ts'
|
||||
|
||||
test('User can change personal app order', async ({ page }) => {
|
||||
const userThemingPage = new UserThemingPage(page)
|
||||
const navigationHeader = new NavigationHeaderPage(page)
|
||||
|
||||
await userThemingPage.open()
|
||||
|
||||
await expect(userThemingPage.appOrderEntries()).toHaveCount(2)
|
||||
await expect(userThemingPage.appOrderEntries().nth(0)).toContainText('Dashboard')
|
||||
await expect(userThemingPage.appOrderEntries().nth(1)).toContainText('Files')
|
||||
|
||||
await navigationHeader.openMenu()
|
||||
await expect(navigationHeader.navigationEntries().nth(0)).toContainText('Dashboard')
|
||||
await expect(navigationHeader.navigationEntries().nth(1)).toContainText('Files')
|
||||
|
||||
const initialFirstEntry = await userThemingPage.appOrderEntries().nth(0).innerText()
|
||||
if (/Dashboard/i.test(initialFirstEntry)) {
|
||||
const moveUpButton = userThemingPage.appEntry('Files').locator('button[aria-label="Move up"]').first()
|
||||
if (await moveUpButton.count() > 0) {
|
||||
await moveUpButton.evaluate((element) => {
|
||||
(element as HTMLButtonElement).click()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const currentOrder = (await userThemingPage.appOrderEntries().allInnerTexts()).map((entry) => entry.trim())
|
||||
expect(currentOrder).toContain('Dashboard')
|
||||
expect(currentOrder).toContain('Files')
|
||||
|
||||
await page.reload()
|
||||
const reloadedOrder = (await userThemingPage.appOrderEntries().allInnerTexts()).map((entry) => entry.trim())
|
||||
expect(reloadedOrder).toContain('Dashboard')
|
||||
expect(reloadedOrder).toContain('Files')
|
||||
await navigationHeader.openMenu()
|
||||
await expect(navigationHeader.navigationEntries().nth(0)).toContainText(reloadedOrder[0]!)
|
||||
await expect(navigationHeader.navigationEntries().nth(1)).toContainText(reloadedOrder[1]!)
|
||||
})
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
import { test } from '../../support/fixtures/random-user-session.ts'
|
||||
import { getBodyThemingSnapshot, pickColor } from '../../support/utils/theming.ts'
|
||||
|
||||
test('User can configure background and plain color', async ({ page }) => {
|
||||
await page.goto('settings/user/theming')
|
||||
await page.getByRole('heading', { name: 'Background and color' }).waitFor({ state: 'visible' })
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Default background', pressed: true })).toBeVisible()
|
||||
|
||||
const darkBackground = 'anatoly-mikhaltsov-butterfly-wing-scale.jpg'
|
||||
const darkBackgroundName = 'Background picture of a red-ish butterfly wing under microscope'
|
||||
await page.getByRole('button', { name: darkBackgroundName, pressed: false }).click()
|
||||
await expect(page.getByRole('button', { name: darkBackgroundName, pressed: true })).toBeVisible()
|
||||
await expect.poll(async () => (await getBodyThemingSnapshot(page)).backgroundImage).toContain(darkBackground)
|
||||
|
||||
const brightBackground = 'bernie-cetonia-aurata-take-off-composition.jpg'
|
||||
const brightBackgroundName = 'Montage of a cetonia aurata bug that takes off with white background'
|
||||
await page.getByRole('button', { name: brightBackgroundName, pressed: false }).click()
|
||||
await expect(page.getByRole('button', { name: brightBackgroundName, pressed: true })).toBeVisible()
|
||||
await expect.poll(async () => (await getBodyThemingSnapshot(page)).backgroundImage).toContain(brightBackground)
|
||||
|
||||
const plainBackgroundButton = page.getByRole('button', { name: 'Plain background' })
|
||||
await pickColor(page, plainBackgroundButton, 7)
|
||||
await expect.poll(async () => (await getBodyThemingSnapshot(page)).backgroundImage).toBe('none')
|
||||
|
||||
await page.reload()
|
||||
await expect.poll(async () => (await getBodyThemingSnapshot(page)).backgroundImage).toBe('none')
|
||||
})
|
||||
11
tests/playwright/merge.config.ts
Normal file
11
tests/playwright/merge.config.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
// Needed to merge multiple Playwright reports
|
||||
// when they are ran on self-hosted and github runners (different test directories are used)
|
||||
export default {
|
||||
testDir: 'tests/playwright/e2e',
|
||||
reporter: [['html', { open: 'never' }]],
|
||||
}
|
||||
75
tests/playwright/start-nextcloud-server.js
Normal file
75
tests/playwright/start-nextcloud-server.js
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { configureNextcloud, runExec, runOcc, startNextcloud, stopNextcloud, waitOnNextcloud } from '@nextcloud/e2e-test-server/docker'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
|
||||
|
||||
function getMounts() {
|
||||
const mounts = {
|
||||
'3rdparty': resolve(rootDir, '3rdparty'),
|
||||
apps: resolve(rootDir, 'apps'),
|
||||
core: resolve(rootDir, 'core'),
|
||||
dist: resolve(rootDir, 'dist'),
|
||||
lib: resolve(rootDir, 'lib'),
|
||||
ocs: resolve(rootDir, 'ocs'),
|
||||
'ocs-provider': resolve(rootDir, 'ocs-provider'),
|
||||
resources: resolve(rootDir, 'resources'),
|
||||
tests: resolve(rootDir, 'tests'),
|
||||
'console.php': resolve(rootDir, 'console.php'),
|
||||
'cron.php': resolve(rootDir, 'cron.php'),
|
||||
'index.php': resolve(rootDir, 'index.php'),
|
||||
occ: resolve(rootDir, 'occ'),
|
||||
'public.php': resolve(rootDir, 'public.php'),
|
||||
'remote.php': resolve(rootDir, 'remote.php'),
|
||||
'status.php': resolve(rootDir, 'status.php'),
|
||||
'version.php': resolve(rootDir, 'version.php'),
|
||||
}
|
||||
|
||||
return Object.fromEntries(Object.entries(mounts).filter(([, path]) => existsSync(path)))
|
||||
}
|
||||
|
||||
async function start() {
|
||||
const port = Number.parseInt(process.env.NEXTCLOUD_PORT ?? '8042', 10)
|
||||
const ip = await startNextcloud(process.env.BRANCH, false, {
|
||||
mounts: getMounts(),
|
||||
exposePort: port,
|
||||
forceRecreate: true,
|
||||
})
|
||||
|
||||
await runExec(['mkdir', '-p', 'apps-cypress'])
|
||||
await runExec(['cp', 'cypress/fixtures/app.config.php', 'config'])
|
||||
|
||||
await waitOnNextcloud(ip)
|
||||
await configureNextcloud()
|
||||
|
||||
process.stdout.write('\nApply custom configuration for Playwright tests\n')
|
||||
await runOcc(['config:system:set', 'appstoreenabled', '--value', 'false', '--type', 'boolean'])
|
||||
process.stdout.write('├─ Disabled app store\n')
|
||||
await runExec(['php', '-r', '$db = new SQLite3("data/owncloud.db");$db->busyTimeout(5000);$db->exec("PRAGMA journal_mode = wal;");'])
|
||||
process.stdout.write('├─ Enabled SQLite WAL mode for better performance\n')
|
||||
process.stdout.write('├─ Initialize cron job...\n')
|
||||
await runExec(['php', 'cron.php'])
|
||||
process.stdout.write('│ └─ OK !\n')
|
||||
process.stdout.write('└─ Nextcloud container ready to run Playwright tests\n')
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
process.stderr.write('Stopping Nextcloud server…\n')
|
||||
await stopNextcloud()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on('SIGTERM', stop)
|
||||
process.on('SIGINT', stop)
|
||||
|
||||
await start()
|
||||
|
||||
while (true) {
|
||||
await new Promise((resolvePromise) => setTimeout(resolvePromise, 5000))
|
||||
}
|
||||
14
tests/playwright/support/fixtures/admin-appstore-page.ts
Normal file
14
tests/playwright/support/fixtures/admin-appstore-page.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { test as adminSessionTest } from './admin-session.ts'
|
||||
import { AppstorePage } from '../sections/AppstorePage.ts'
|
||||
|
||||
export const test = adminSessionTest.extend<{ appstorePage: AppstorePage }>({
|
||||
appstorePage: async ({ page }, use) => {
|
||||
const appstorePage = new AppstorePage(page)
|
||||
await use(appstorePage)
|
||||
},
|
||||
})
|
||||
23
tests/playwright/support/fixtures/admin-session.ts
Normal file
23
tests/playwright/support/fixtures/admin-session.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { User } from '@nextcloud/e2e-test-server'
|
||||
import { login } from '@nextcloud/e2e-test-server/playwright'
|
||||
import { test as baseTest } from '@playwright/test'
|
||||
|
||||
const admin = new User('admin', 'admin')
|
||||
|
||||
export const test = baseTest.extend({
|
||||
page: async ({ page, context }, use) => {
|
||||
try {
|
||||
await login(context.request, admin)
|
||||
} catch (error) {
|
||||
console.info('Failed to authenticate as admin, retrying', error)
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
await login(context.request, admin)
|
||||
}
|
||||
await use(page)
|
||||
},
|
||||
})
|
||||
14
tests/playwright/support/fixtures/admin-theming-page.ts
Normal file
14
tests/playwright/support/fixtures/admin-theming-page.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { test as adminSessionTest } from './admin-session.ts'
|
||||
import { AdminThemingPage } from '../sections/AdminThemingPage.ts'
|
||||
|
||||
export const test = adminSessionTest.extend<{ adminThemingPage: AdminThemingPage }>({
|
||||
adminThemingPage: async ({ page }, use) => {
|
||||
const adminThemingPage = new AdminThemingPage(page)
|
||||
await use(adminThemingPage)
|
||||
},
|
||||
})
|
||||
42
tests/playwright/support/fixtures/files-page.ts
Normal file
42
tests/playwright/support/fixtures/files-page.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { runOcc } from '@nextcloud/e2e-test-server/docker'
|
||||
import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright'
|
||||
import { test as baseTest } from '@playwright/test'
|
||||
import type { User } from '@nextcloud/e2e-test-server'
|
||||
import { FilesListPage } from '../sections/FilesListPage.ts'
|
||||
import { FilesSidebarPage } from '../sections/FilesSidebarPage.ts'
|
||||
|
||||
type FilesFixtures = {
|
||||
user: User
|
||||
filesListPage: FilesListPage
|
||||
filesSidebar: FilesSidebarPage
|
||||
}
|
||||
|
||||
export const test = baseTest.extend<FilesFixtures>({
|
||||
user: async ({ context }, use) => {
|
||||
const user = await createRandomUser()
|
||||
try {
|
||||
await login(context.request, user)
|
||||
} catch {
|
||||
// Retry once on transient auth failure
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
await login(context.request, user)
|
||||
}
|
||||
await use(user)
|
||||
await runOcc(['user:delete', user.userId])
|
||||
},
|
||||
|
||||
filesListPage: async ({ page }, use) => {
|
||||
await use(new FilesListPage(page))
|
||||
},
|
||||
|
||||
filesSidebar: async ({ page }, use) => {
|
||||
await use(new FilesSidebarPage(page))
|
||||
},
|
||||
})
|
||||
|
||||
export { expect } from '../matchers.ts'
|
||||
19
tests/playwright/support/fixtures/random-user-session.ts
Normal file
19
tests/playwright/support/fixtures/random-user-session.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { runOcc } from '@nextcloud/e2e-test-server/docker'
|
||||
import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright'
|
||||
import { test as baseTest } from '@playwright/test'
|
||||
|
||||
export const test = baseTest.extend({
|
||||
page: async ({ page, context }, use) => {
|
||||
const user = await createRandomUser()
|
||||
await login(context.request, user)
|
||||
|
||||
await use(page)
|
||||
|
||||
await runOcc(['user:delete', user.userId])
|
||||
},
|
||||
})
|
||||
65
tests/playwright/support/matchers.ts
Normal file
65
tests/playwright/support/matchers.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { expect as baseExpect, type Locator } from '@playwright/test'
|
||||
|
||||
export const expect = baseExpect.extend({
|
||||
/**
|
||||
* Asserts that a file-list row has the active highlight class.
|
||||
* A row becomes active when it was the last folder navigated into
|
||||
* (e.g. after a browser back/forward traversal).
|
||||
*/
|
||||
async toBeActiveRow(received: Locator, options?: { timeout?: number }) {
|
||||
let pass: boolean
|
||||
let failMessage: string | undefined
|
||||
try {
|
||||
await baseExpect(received).toHaveClass(/files-list__row--active/, options)
|
||||
pass = true
|
||||
} catch (e: unknown) {
|
||||
pass = false
|
||||
failMessage = (e as Error).message
|
||||
}
|
||||
return {
|
||||
message: () => pass
|
||||
? `Expected row not to have class 'files-list__row--active'`
|
||||
: failMessage ?? `Expected row to have class 'files-list__row--active'`,
|
||||
pass,
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Asserts that an input element has a specific HTML5 validation message.
|
||||
* An empty string means the input is valid (no validation error).
|
||||
* Retries until the message matches or the timeout expires.
|
||||
*/
|
||||
async toHaveValidationMessage(received: Locator, expected: string | RegExp, options?: { timeout?: number }) {
|
||||
let pass = false
|
||||
let actual = ''
|
||||
const getMsg = async () => received.evaluate((el) => (el as HTMLInputElement).validationMessage)
|
||||
try {
|
||||
if (typeof expected === 'string') {
|
||||
await baseExpect.poll(getMsg, { timeout: options?.timeout ?? 5000 }).toBe(expected)
|
||||
} else {
|
||||
await baseExpect.poll(getMsg, { timeout: options?.timeout ?? 5000 }).toMatch(expected)
|
||||
}
|
||||
pass = true
|
||||
} catch {
|
||||
actual = await getMsg().catch(() => '')
|
||||
}
|
||||
return {
|
||||
message: () => pass
|
||||
? `Expected validation message not to equal ${JSON.stringify(expected)}`
|
||||
: `Expected validation message ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||
pass,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
declare module '@playwright/test' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface Matchers<R, T> {
|
||||
toBeActiveRow(options?: { timeout?: number }): R
|
||||
toHaveValidationMessage(expected: string | RegExp, options?: { timeout?: number }): R
|
||||
}
|
||||
}
|
||||
109
tests/playwright/support/sections/AdminThemingPage.ts
Normal file
109
tests/playwright/support/sections/AdminThemingPage.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class AdminThemingPage {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async open() {
|
||||
await this.page.goto('settings/admin/theming')
|
||||
await this.page.getByText('Navigation bar settings').waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the admin theming settings to default using HTTP request.
|
||||
*
|
||||
* @param request - The APIRequestContext to perform the request with admin credentials.
|
||||
*/
|
||||
async reset() {
|
||||
const tokenResponse = await this.page.request.get('/csrftoken', {
|
||||
failOnStatusCode: true,
|
||||
})
|
||||
const requestToken = (await tokenResponse.json()).token
|
||||
|
||||
const response = await this.page.request.post('./apps/theming/ajax/undoAllChanges', {
|
||||
headers: {
|
||||
requesttoken: requestToken,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to reset theming settings (${response.status})`)
|
||||
}
|
||||
}
|
||||
|
||||
defaultAppSwitch(): Locator {
|
||||
return this.page.getByRole('checkbox', { name: 'Use custom default app' })
|
||||
}
|
||||
|
||||
defaultAppRegion(): Locator {
|
||||
return this.page.getByRole('region', { name: 'Global default app' })
|
||||
}
|
||||
|
||||
defaultAppSelect(): Locator {
|
||||
// NcSelect appends the dropdown listbox to <body> (appendToBody: true), so it cannot
|
||||
// be reached via a scoped locator. Return the selected-options wrapper instead, which
|
||||
// stays inline and contains the visible selected-value tag spans.
|
||||
return this.defaultAppRegion().locator('.vs__selected-options')
|
||||
}
|
||||
|
||||
defaultAppSelectedValue(name: string): Locator {
|
||||
// NcSelect renders each selected value as a tag with a "Deselect <name>" button.
|
||||
return this.defaultAppRegion().getByRole('button', { name: `Deselect ${name}` })
|
||||
}
|
||||
|
||||
appOrderList(): Locator {
|
||||
return this.page.getByRole('list', { name: 'Navigation bar app order' })
|
||||
}
|
||||
|
||||
appOrderEntries(): Locator {
|
||||
return this.appOrderList().getByRole('listitem')
|
||||
}
|
||||
|
||||
appEntry(name: string): Locator {
|
||||
return this.appOrderEntries().filter({ hasText: name })
|
||||
}
|
||||
|
||||
moveUpButton(appName: string): Locator {
|
||||
return this.appEntry(appName).getByRole('button', { name: 'Move up' })
|
||||
}
|
||||
|
||||
backgroundAndColorHeading(): Locator {
|
||||
return this.page.getByRole('heading', { name: 'Background and color' })
|
||||
}
|
||||
|
||||
webLinkInput(): Locator {
|
||||
return this.page.getByRole('textbox', { name: /web link/i })
|
||||
}
|
||||
|
||||
legalNoticeLinkInput(): Locator {
|
||||
return this.page.getByRole('textbox', { name: /legal notice link/i })
|
||||
}
|
||||
|
||||
privacyPolicyLinkInput(): Locator {
|
||||
return this.page.getByRole('textbox', { name: /privacy policy link/i })
|
||||
}
|
||||
|
||||
nameInput(): Locator {
|
||||
return this.page.getByRole('textbox', { name: 'Name' })
|
||||
}
|
||||
|
||||
sloganInput(): Locator {
|
||||
return this.page.getByRole('textbox', { name: 'Slogan' })
|
||||
}
|
||||
|
||||
undoChangesButtons(): Locator {
|
||||
return this.page.getByRole('button', { name: /undo changes/i })
|
||||
}
|
||||
|
||||
removeBackgroundImageCheckbox(): Locator {
|
||||
return this.page.getByRole('checkbox', { name: /remove background image/i })
|
||||
}
|
||||
|
||||
disableUserThemingCheckbox(): Locator {
|
||||
return this.page.getByRole('checkbox', { name: /disable user theming/i })
|
||||
}
|
||||
}
|
||||
199
tests/playwright/support/sections/AppstorePage.ts
Normal file
199
tests/playwright/support/sections/AppstorePage.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class AppstorePage {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
/**
|
||||
* Opens the main appstore page
|
||||
*/
|
||||
async openAppstore() {
|
||||
await this.page.goto('settings/apps')
|
||||
await this.appsTable().waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the installed apps page
|
||||
*/
|
||||
async openInstalledApps() {
|
||||
await this.page.goto('settings/apps/installed')
|
||||
await this.appsTable().waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the enabled apps page
|
||||
*/
|
||||
async openEnabledApps() {
|
||||
await this.navigationLink('Active apps').click()
|
||||
await this.page.waitForURL(/settings\/apps\/enabled$/)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the disabled apps page
|
||||
*/
|
||||
async openDisabledApps() {
|
||||
await this.navigationLink('Disabled apps').click()
|
||||
await this.page.waitForURL(/settings\/apps\/disabled$/)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the app bundles page
|
||||
*/
|
||||
async openBundles() {
|
||||
await this.navigationLink('App bundles').click()
|
||||
await this.page.waitForURL(/settings\/apps\/bundles$/)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the apps table element
|
||||
*/
|
||||
appsTable(): Locator {
|
||||
return this.page.getByRole('table')
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a specific app row by app name
|
||||
*/
|
||||
appRow(appName: string): Locator {
|
||||
return this.appsTable().locator('tr').filter({ hasText: appName }).first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the enable button for a specific app
|
||||
*/
|
||||
enableButton(appName: string): Locator {
|
||||
return this.appRow(appName).getByRole('button', { name: 'Enable' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the disable button for a specific app
|
||||
*/
|
||||
disableButton(appName: string): Locator {
|
||||
return this.appRow(appName).getByRole('button', { name: 'Disable' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the app link in the table
|
||||
*/
|
||||
appLink(appName: string): Locator {
|
||||
return this.appsTable().getByRole('link', { name: appName })
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the navigation link in the appstore sidebar
|
||||
*/
|
||||
navigationLink(name: string): Locator {
|
||||
return this.page.getByRole('navigation', { name: 'Appstore categories' }).getByRole('link', { name })
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the app sidebar
|
||||
*/
|
||||
appSidebar(): Locator {
|
||||
return this.page.locator('#app-sidebar-vue')
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the app sidebar header
|
||||
*/
|
||||
appSidebarHeader(): Locator {
|
||||
return this.appSidebar().locator('.app-sidebar-header__info')
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the "Enable" button in the app sidebar (not the table row).
|
||||
* Use this when checking the sidebar after clicking an app link.
|
||||
*/
|
||||
appSidebarEnableButton(): Locator {
|
||||
return this.appSidebar().getByRole('button', { name: 'Enable' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the "View in store" link in the sidebar
|
||||
*/
|
||||
viewInStoreLink(): Locator {
|
||||
return this.appSidebar().getByRole('link', { name: 'View in store' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the "Remove" button in the sidebar
|
||||
*/
|
||||
removeButton(): Locator {
|
||||
return this.appSidebar().getByRole('button', { name: 'Remove' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the "Limit to groups" button
|
||||
*/
|
||||
limitToGroupsButton(): Locator {
|
||||
return this.appSidebar().getByRole('button', { name: 'Limit to groups' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the "Limited to groups" list
|
||||
*/
|
||||
limitedToGroupsList(): Locator {
|
||||
return this.appSidebar().getByRole('list', { name: 'Limited to groups' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the group dialog
|
||||
*/
|
||||
groupDialog(): Locator {
|
||||
return this.page.getByRole('dialog')
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the save button in the dialog
|
||||
*/
|
||||
dialogSaveButton(): Locator {
|
||||
return this.groupDialog().getByRole('button', { name: 'Save' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the deselect button for a group
|
||||
*/
|
||||
deselectGroupButton(groupName: string): Locator {
|
||||
return this.groupDialog().getByRole('button', { name: `Deselect ${groupName}` })
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the group search input.
|
||||
* NcSelectUsers uses role="combobox" on the search input, not role="textbox".
|
||||
*/
|
||||
groupSearchInput(): Locator {
|
||||
return this.groupDialog().locator('input').first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the enterprise bundle heading
|
||||
*/
|
||||
enterpriseBundleHeading(): Locator {
|
||||
return this.page.getByRole('heading', { name: 'Enterprise bundle' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the education bundle heading
|
||||
*/
|
||||
educationBundleHeading(): Locator {
|
||||
return this.page.getByRole('heading', { name: 'Education bundle' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the version text from sidebar
|
||||
*/
|
||||
versionText(): Locator {
|
||||
return this.appSidebar().getByText(/Version \d+\.\d+\.\d+/)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a group option from the dropdown
|
||||
*/
|
||||
groupOption(groupName: string): Locator {
|
||||
return this.page.getByRole('option', { name: new RegExp(groupName) })
|
||||
}
|
||||
}
|
||||
78
tests/playwright/support/sections/FilesListPage.ts
Normal file
78
tests/playwright/support/sections/FilesListPage.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class FilesListPage {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async open(): Promise<void> {
|
||||
await this.page.goto('apps/files')
|
||||
await this.page.locator('[data-cy-files-list]').waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
getRowForFile(filename: string): Locator {
|
||||
return this.page.locator(`[data-cy-files-list-row-name="${filename}"]`)
|
||||
}
|
||||
|
||||
getRowForFileId(fileid: number): Locator {
|
||||
return this.page.locator(`[data-cy-files-list-row-fileid="${fileid}"]`)
|
||||
}
|
||||
|
||||
private getActionsButtonForFile(filename: string): Locator {
|
||||
return this.getRowForFile(filename)
|
||||
.getByRole('button', { name: 'Actions' })
|
||||
}
|
||||
|
||||
async triggerActionForFile(filename: string, actionId: string): Promise<void> {
|
||||
const row = this.getRowForFile(filename)
|
||||
await row.hover()
|
||||
|
||||
const actionsButton = this.getActionsButtonForFile(filename)
|
||||
await actionsButton.scrollIntoViewIfNeeded()
|
||||
// force: true to avoid issues with the sticky file list header
|
||||
await actionsButton.click({ force: true })
|
||||
|
||||
const menuId = await actionsButton.getAttribute('aria-controls')
|
||||
// The action button has role="menuitem", so use tag selector not getByRole
|
||||
const actionEntry = this.page
|
||||
.locator(`#${menuId} [data-cy-files-list-row-action="${actionId}"] button`)
|
||||
await actionEntry.waitFor({ state: 'visible' })
|
||||
await actionEntry.click()
|
||||
}
|
||||
|
||||
async selectAll(): Promise<void> {
|
||||
await this.page.locator('[data-cy-files-list-selection-checkbox]')
|
||||
.getByRole('checkbox')
|
||||
.click({ force: true })
|
||||
}
|
||||
|
||||
async triggerSelectionAction(actionId: string): Promise<void> {
|
||||
const actionsButton = this.page.locator('[data-cy-files-list-selection-actions]')
|
||||
.getByRole('button', { name: 'Actions' })
|
||||
await actionsButton.click({ force: true })
|
||||
// NcActionButton renders as <li data-cy-...><button role="menuitem">
|
||||
const actionButton = this.page.locator(`[data-cy-files-list-selection-action="${actionId}"] button`)
|
||||
await actionButton.waitFor({ state: 'visible' })
|
||||
await actionButton.click()
|
||||
}
|
||||
|
||||
getRenameInputForFile(filename: string): Locator {
|
||||
return this.getRowForFile(filename).getByRole('textbox', { name: 'Filename' })
|
||||
}
|
||||
|
||||
getRenameInputForFolder(foldername: string): Locator {
|
||||
return this.getRowForFile(foldername).getByRole('textbox', { name: 'Folder name' })
|
||||
}
|
||||
|
||||
async navigateToFolder(dirPath: string): Promise<void> {
|
||||
for (const directory of dirPath.split('/').filter(Boolean)) {
|
||||
await this.getRowForFile(directory)
|
||||
.getByRole('button')
|
||||
.filter({ hasText: directory })
|
||||
.click()
|
||||
}
|
||||
}
|
||||
}
|
||||
18
tests/playwright/support/sections/FilesSidebarPage.ts
Normal file
18
tests/playwright/support/sections/FilesSidebarPage.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class FilesSidebarPage {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
sidebar(): Locator {
|
||||
return this.page.locator('#app-sidebar-vue')
|
||||
}
|
||||
|
||||
heading(name: string): Locator {
|
||||
return this.sidebar().getByRole('heading', { name })
|
||||
}
|
||||
}
|
||||
50
tests/playwright/support/sections/NavigationHeaderPage.ts
Normal file
50
tests/playwright/support/sections/NavigationHeaderPage.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class NavigationHeaderPage {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
private get header(): Locator {
|
||||
return this.page.locator('header#header')
|
||||
}
|
||||
|
||||
logo(): Locator {
|
||||
return this.header.locator('#nextcloud')
|
||||
}
|
||||
|
||||
navigation(): Locator {
|
||||
return this.header.getByRole('navigation', { name: 'Applications' })
|
||||
}
|
||||
|
||||
private waffleButton(): Locator {
|
||||
return this.navigation().locator('.app-menu__waffle')
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the waffle launcher popover.
|
||||
* The app entries only exist in the DOM while the popover is open.
|
||||
*/
|
||||
async openMenu(): Promise<void> {
|
||||
const isOpen = await this.waffleButton().getAttribute('aria-expanded') === 'true'
|
||||
if (!isOpen) {
|
||||
await this.waffleButton().click()
|
||||
}
|
||||
await this.popover().waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
popover(): Locator {
|
||||
return this.page.locator('[role="menu"][aria-label="Apps"]')
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns navigation entries from the waffle popover.
|
||||
* Call {@link openMenu} first — entries are only in the DOM while the popover is open.
|
||||
*/
|
||||
navigationEntries(): Locator {
|
||||
return this.popover().getByRole('menuitem')
|
||||
}
|
||||
}
|
||||
31
tests/playwright/support/sections/UserThemingPage.ts
Normal file
31
tests/playwright/support/sections/UserThemingPage.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class UserThemingPage {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async open() {
|
||||
await this.page.goto('settings/user/theming')
|
||||
await this.page.getByRole('heading', { name: /Navigation bar settings/ }).waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
appOrderList(): Locator {
|
||||
return this.page.getByRole('list', { name: 'Navigation bar app order' })
|
||||
}
|
||||
|
||||
appOrderEntries(): Locator {
|
||||
return this.appOrderList().getByRole('listitem')
|
||||
}
|
||||
|
||||
appEntry(name: string): Locator {
|
||||
return this.appOrderEntries().filter({ hasText: name })
|
||||
}
|
||||
|
||||
moveUpButton(appName: string): Locator {
|
||||
return this.appEntry(appName).getByRole('button', { name: 'Move up', includeHidden: true })
|
||||
}
|
||||
}
|
||||
98
tests/playwright/support/utils/dav.ts
Normal file
98
tests/playwright/support/utils/dav.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { APIRequestContext } from '@playwright/test'
|
||||
import type { User } from '@nextcloud/e2e-test-server'
|
||||
|
||||
/**
|
||||
* Make a MKCOL request to create a directory at the given path for the given user.
|
||||
*
|
||||
* @param request - The Playwright API request context
|
||||
* @param user - The user to create the directory for
|
||||
* @param path - The path of the directory to create (relative to user root)
|
||||
*/
|
||||
export async function mkdir(request: APIRequestContext, user: User, path: string): Promise<void> {
|
||||
const requesttoken = await getRequestToken(request)
|
||||
const response = await request.fetch(davUrl(user, path), {
|
||||
method: 'MKCOL',
|
||||
headers: { requesttoken },
|
||||
})
|
||||
if (!response.ok()) {
|
||||
throw new Error(`MKCOL ${path} failed with status ${response.status()}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload content to a DAV path and return the file ID from the response headers.
|
||||
*
|
||||
* @param request The Playwright API request context
|
||||
* @param user The user to upload as
|
||||
* @param content The content to upload
|
||||
* @param mimeType The MIME type of the content
|
||||
* @param path The path to upload to (relative to user root)
|
||||
* @return The file ID from the oc-fileid response header
|
||||
*/
|
||||
export async function uploadContent(
|
||||
request: APIRequestContext,
|
||||
user: User,
|
||||
content: Buffer | string,
|
||||
mimeType: string,
|
||||
path: string,
|
||||
): Promise<number> {
|
||||
const requesttoken = await getRequestToken(request)
|
||||
const response = await request.fetch(davUrl(user, path), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
requesttoken,
|
||||
},
|
||||
data: typeof content === 'string' ? content : content,
|
||||
})
|
||||
if (!response.ok()) {
|
||||
throw new Error(`PUT ${path} failed with status ${response.status()}`)
|
||||
}
|
||||
const fileId = response.headers()['oc-fileid']
|
||||
return fileId ? parseInt(fileId, 10) : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file or directory at the given path for the given user.
|
||||
*
|
||||
* @param request - The Playwright API request context
|
||||
* @param user - The user to delete as
|
||||
* @param path - The path to delete (relative to user root)
|
||||
*/
|
||||
export async function rm(request: APIRequestContext, user: User, path: string): Promise<void> {
|
||||
const requesttoken = await getRequestToken(request)
|
||||
const response = await request.fetch(davUrl(user, path), {
|
||||
method: 'DELETE',
|
||||
headers: { requesttoken },
|
||||
})
|
||||
if (!response.ok()) {
|
||||
throw new Error(`DELETE ${path} failed with status ${response.status()}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct the DAV URL for a given user and path.
|
||||
*
|
||||
* @param user - The user the path belongs to
|
||||
* @param path - The path relative to the user's root directory
|
||||
*/
|
||||
function davUrl(user: User, path: string): string {
|
||||
const cleanPath = ('/' + path).replace(/\/+/g, '/')
|
||||
const encodedPath = cleanPath.split('/').map((seg) => seg ? encodeURIComponent(seg) : '').join('/')
|
||||
return `/remote.php/dav/files/${encodeURIComponent(user.userId)}${encodedPath}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a CSRF request token using the Playwright API request context.
|
||||
*
|
||||
* @param request - The Playwright API request context
|
||||
*/
|
||||
async function getRequestToken(request: APIRequestContext): Promise<string> {
|
||||
const response = await request.get('/csrftoken', { failOnStatusCode: true })
|
||||
return (await response.json()).token
|
||||
}
|
||||
34
tests/playwright/support/utils/password-confirmation.ts
Normal file
34
tests/playwright/support/utils/password-confirmation.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Handle the password confirmation dialog if it appears
|
||||
*
|
||||
* @param page - The Playwright page object
|
||||
* @param password - The password to enter (default: 'admin')
|
||||
*/
|
||||
export async function handlePasswordConfirmation(page: Page, password = 'admin') {
|
||||
const dialog = page.locator('.modal-container:has-text("Authentication required")')
|
||||
|
||||
try {
|
||||
// Check if the dialog exists within a short timeout
|
||||
const dialogVisible = await dialog.isVisible({ timeout: 500 }).catch(() => false)
|
||||
|
||||
if (dialogVisible) {
|
||||
// Fill the password field
|
||||
await dialog.locator('input[type="password"]').fill(password)
|
||||
|
||||
// Click the confirm button
|
||||
await dialog.getByRole('button', { name: 'Confirm' }).click()
|
||||
|
||||
// Wait for the dialog to disappear
|
||||
await dialog.waitFor({ state: 'hidden' })
|
||||
}
|
||||
} catch (error) {
|
||||
// Dialog didn't appear, which is fine - some operations might not require confirmation
|
||||
}
|
||||
}
|
||||
90
tests/playwright/support/utils/theming.ts
Normal file
90
tests/playwright/support/utils/theming.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
export const defaultPrimary = '#00679e'
|
||||
export const defaultBackground = 'jo-myoung-hee-fluid.webp'
|
||||
|
||||
export async function getBodyThemingSnapshot(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const styles = getComputedStyle(document.body)
|
||||
return {
|
||||
primary: styles.getPropertyValue('--color-primary').trim(),
|
||||
backgroundColor: styles.backgroundColor,
|
||||
backgroundImage: styles.backgroundImage,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function expectBodyThemingCss(page: Page, expected: {
|
||||
primary?: string
|
||||
background?: string | null
|
||||
backgroundColor?: string | null
|
||||
}) {
|
||||
await expect.poll(async () => {
|
||||
const snapshot = await getBodyThemingSnapshot(page)
|
||||
const expectedPrimary = expected.primary ?? defaultPrimary
|
||||
const normalizedPrimary = await normalizeColor(page, snapshot.primary)
|
||||
const normalizedExpectedPrimary = await normalizeColor(page, expectedPrimary)
|
||||
|
||||
const expectedBackgroundColor = expected.backgroundColor ?? defaultPrimary
|
||||
const normalizedBackground = expectedBackgroundColor === null
|
||||
? null
|
||||
: await normalizeColor(page, expectedBackgroundColor)
|
||||
|
||||
const expectedBackground = expected.background === undefined ? defaultBackground : expected.background
|
||||
|
||||
const validPrimary = normalizedPrimary === normalizedExpectedPrimary
|
||||
const validBackgroundColor = normalizedBackground === null || snapshot.backgroundColor === normalizedBackground
|
||||
const validBackgroundImage = expectedBackground === null
|
||||
? snapshot.backgroundImage === 'none'
|
||||
: snapshot.backgroundImage.includes(expectedBackground)
|
||||
|
||||
return validPrimary && validBackgroundColor && validBackgroundImage
|
||||
}, {
|
||||
timeout: 10000,
|
||||
message: 'Expected body theming CSS to match expected values',
|
||||
}).toBeTruthy()
|
||||
}
|
||||
|
||||
export async function expectPrimaryColor(page: Page, expectedColor: string) {
|
||||
const normalizedExpectedPrimary = await normalizeColor(page, expectedColor)
|
||||
|
||||
await expect.poll(async () => {
|
||||
const snapshot = await getBodyThemingSnapshot(page)
|
||||
return normalizeColor(page, snapshot.primary)
|
||||
}, {
|
||||
timeout: 10000,
|
||||
message: 'Expected primary color CSS variable to match',
|
||||
}).toBe(normalizedExpectedPrimary)
|
||||
}
|
||||
|
||||
export async function pickColor(page: Page, trigger: Locator, index: number) {
|
||||
const oldColor = await trigger.evaluate((element) => getComputedStyle(element as HTMLElement).backgroundColor)
|
||||
|
||||
await trigger.click({ force: true })
|
||||
await page.locator('.color-picker__simple-color-circle').nth(index).click()
|
||||
await page.getByRole('button', { name: /Choose/i }).click()
|
||||
|
||||
await expect.poll(async () => {
|
||||
return trigger.evaluate((element) => getComputedStyle(element as HTMLElement).backgroundColor)
|
||||
}).not.toBe(oldColor)
|
||||
|
||||
return trigger.evaluate((element) => getComputedStyle(element as HTMLElement).backgroundColor)
|
||||
}
|
||||
|
||||
async function normalizeColor(page: Page, color: string) {
|
||||
return page.evaluate((value) => {
|
||||
const element = document.createElement('div')
|
||||
element.style.color = value
|
||||
document.body.append(element)
|
||||
const normalized = getComputedStyle(element).color
|
||||
element.remove()
|
||||
return normalized
|
||||
}, color)
|
||||
}
|
||||
Loading…
Reference in a new issue