mirror of
https://github.com/nextcloud/server.git
synced 2026-06-12 02:00:51 -04:00
test: migrate some tests to playwright
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
parent
c45a5d4809
commit
ae8d311a33
16 changed files with 980 additions and 254 deletions
|
|
@ -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,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')
|
||||
})
|
||||
})
|
||||
})
|
||||
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])
|
||||
}
|
||||
})
|
||||
})
|
||||
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])
|
||||
}
|
||||
})
|
||||
})
|
||||
104
tests/playwright/e2e/theming/admin-settings-branding.spec.ts
Normal file
104
tests/playwright/e2e/theming/admin-settings-branding.spec.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
* 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.describe.configure({ mode: 'serial' })
|
||||
|
||||
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.defaultAppSelect().getByText('Dashboard')).toBeVisible()
|
||||
await expect(adminThemingPage.defaultAppSelect().getByText('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)
|
||||
})
|
||||
})
|
||||
44
tests/playwright/e2e/theming/user-settings-app-order.spec.ts
Normal file
44
tests/playwright/e2e/theming/user-settings-app-order.spec.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* 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 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 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')
|
||||
})
|
||||
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)
|
||||
},
|
||||
})
|
||||
101
tests/playwright/support/sections/AdminThemingPage.ts
Normal file
101
tests/playwright/support/sections/AdminThemingPage.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
* 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 {
|
||||
return this.defaultAppRegion().getByRole('combobox')
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
26
tests/playwright/support/sections/NavigationHeaderPage.ts
Normal file
26
tests/playwright/support/sections/NavigationHeaderPage.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* 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 menu' })
|
||||
}
|
||||
|
||||
navigationEntries(): Locator {
|
||||
return this.navigation().getByRole('listitem')
|
||||
}
|
||||
}
|
||||
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 })
|
||||
}
|
||||
}
|
||||
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