diff --git a/cypress/e2e/dav/availability.cy.ts b/cypress/e2e/dav/availability.cy.ts deleted file mode 100644 index e1cc91d37d5..00000000000 --- a/cypress/e2e/dav/availability.cy.ts +++ /dev/null @@ -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') - }) -}) diff --git a/cypress/e2e/systemtags/admin-settings.cy.ts b/cypress/e2e/systemtags/admin-settings.cy.ts deleted file mode 100644 index c8b74335a5a..00000000000 --- a/cypress/e2e/systemtags/admin-settings.cy.ts +++ /dev/null @@ -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') - }) - }) -}) diff --git a/tests/playwright/e2e/dav/availability.spec.ts b/tests/playwright/e2e/dav/availability.spec.ts new file mode 100644 index 00000000000..8d44e609c06 --- /dev/null +++ b/tests/playwright/e2e/dav/availability.spec.ts @@ -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]) + } + }) +}) diff --git a/tests/playwright/e2e/systemtags/admin-settings.spec.ts b/tests/playwright/e2e/systemtags/admin-settings.spec.ts new file mode 100644 index 00000000000..a79322d5eed --- /dev/null +++ b/tests/playwright/e2e/systemtags/admin-settings.spec.ts @@ -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 + 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() + }) +}) diff --git a/tests/playwright/e2e/theming/a11y-color-contrast.spec.ts b/tests/playwright/e2e/theming/a11y-color-contrast.spec.ts new file mode 100644 index 00000000000..576ba6a96fb --- /dev/null +++ b/tests/playwright/e2e/theming/a11y-color-contrast.spec.ts @@ -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([]) + }) +} diff --git a/tests/playwright/e2e/theming/admin-settings-background.spec.ts b/tests/playwright/e2e/theming/admin-settings-background.spec.ts new file mode 100644 index 00000000000..535946b6a0a --- /dev/null +++ b/tests/playwright/e2e/theming/admin-settings-background.spec.ts @@ -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]) + } + }) +}) diff --git a/tests/playwright/e2e/theming/admin-settings-branding.spec.ts b/tests/playwright/e2e/theming/admin-settings-branding.spec.ts new file mode 100644 index 00000000000..d78409a26d0 --- /dev/null +++ b/tests/playwright/e2e/theming/admin-settings-branding.spec.ts @@ -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'), + ]) +} diff --git a/tests/playwright/e2e/theming/admin-settings-colors.spec.ts b/tests/playwright/e2e/theming/admin-settings-colors.spec.ts new file mode 100644 index 00000000000..9becf0aef03 --- /dev/null +++ b/tests/playwright/e2e/theming/admin-settings-colors.spec.ts @@ -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() +}) diff --git a/tests/playwright/e2e/theming/admin-settings-default-app.spec.ts b/tests/playwright/e2e/theming/admin-settings-default-app.spec.ts new file mode 100644 index 00000000000..b82a1fa5316 --- /dev/null +++ b/tests/playwright/e2e/theming/admin-settings-default-app.spec.ts @@ -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) + }) +}) diff --git a/tests/playwright/e2e/theming/user-settings-app-order.spec.ts b/tests/playwright/e2e/theming/user-settings-app-order.spec.ts new file mode 100644 index 00000000000..305334635d6 --- /dev/null +++ b/tests/playwright/e2e/theming/user-settings-app-order.spec.ts @@ -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]!) +}) diff --git a/tests/playwright/e2e/theming/user-settings-background.spec.ts b/tests/playwright/e2e/theming/user-settings-background.spec.ts new file mode 100644 index 00000000000..eec6c53b8e5 --- /dev/null +++ b/tests/playwright/e2e/theming/user-settings-background.spec.ts @@ -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') +}) diff --git a/tests/playwright/support/fixtures/admin-theming-page.ts b/tests/playwright/support/fixtures/admin-theming-page.ts new file mode 100644 index 00000000000..f2d232915e7 --- /dev/null +++ b/tests/playwright/support/fixtures/admin-theming-page.ts @@ -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) + }, +}) diff --git a/tests/playwright/support/sections/AdminThemingPage.ts b/tests/playwright/support/sections/AdminThemingPage.ts new file mode 100644 index 00000000000..44520c5a640 --- /dev/null +++ b/tests/playwright/support/sections/AdminThemingPage.ts @@ -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 }) + } +} diff --git a/tests/playwright/support/sections/NavigationHeaderPage.ts b/tests/playwright/support/sections/NavigationHeaderPage.ts new file mode 100644 index 00000000000..543fccd8304 --- /dev/null +++ b/tests/playwright/support/sections/NavigationHeaderPage.ts @@ -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') + } +} diff --git a/tests/playwright/support/sections/UserThemingPage.ts b/tests/playwright/support/sections/UserThemingPage.ts new file mode 100644 index 00000000000..a67b98ec9ed --- /dev/null +++ b/tests/playwright/support/sections/UserThemingPage.ts @@ -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 }) + } +} diff --git a/tests/playwright/support/utils/theming.ts b/tests/playwright/support/utils/theming.ts new file mode 100644 index 00000000000..300e19e8851 --- /dev/null +++ b/tests/playwright/support/utils/theming.ts @@ -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) +}