Skip to content

Commit 829d2b5

Browse files
committed
test: migrate theming tests from Cypress to Playwright
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
1 parent 861790d commit 829d2b5

12 files changed

Lines changed: 777 additions & 0 deletions
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { resolve } from 'node:path'
7+
import { runOcc } from '@nextcloud/e2e-test-server/docker'
8+
import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright'
9+
import { expect, test } from '@playwright/test'
10+
11+
const themesToTest = ['light', 'dark', 'light-highcontrast', 'dark-highcontrast']
12+
13+
const testCases = {
14+
'Main text': {
15+
foregroundColors: ['color-main-text', 'color-text-maxcontrast'],
16+
backgroundColors: ['color-main-background', 'color-background-hover', 'color-background-dark'],
17+
},
18+
'blurred background': {
19+
foregroundColors: ['color-main-text', 'color-text-maxcontrast-blur'],
20+
backgroundColors: ['color-main-background-blur'],
21+
},
22+
Primary: {
23+
foregroundColors: ['color-primary-text'],
24+
backgroundColors: ['color-primary'],
25+
},
26+
'Primary light': {
27+
foregroundColors: ['color-primary-light-text'],
28+
backgroundColors: ['color-primary-light', 'color-primary-light-hover'],
29+
},
30+
'Primary element': {
31+
foregroundColors: ['color-primary-element-text', 'color-primary-element-text-dark'],
32+
backgroundColors: ['color-primary-element', 'color-primary-element-hover'],
33+
},
34+
'Primary element light': {
35+
foregroundColors: ['color-primary-element-light-text'],
36+
backgroundColors: ['color-primary-element-light', 'color-primary-element-light-hover'],
37+
},
38+
'Severity information texts': {
39+
foregroundColors: ['color-error-text', 'color-warning-text', 'color-success-text', 'color-info-text'],
40+
backgroundColors: ['color-main-background', 'color-background-hover'],
41+
},
42+
'Severity information on blur': {
43+
foregroundColors: ['color-error-text', 'color-success-text'],
44+
backgroundColors: ['color-main-background-blur'],
45+
},
46+
}
47+
48+
for (const theme of themesToTest) {
49+
test(`Accessibility of Nextcloud theming colors: ${theme}`, async ({ page, context }) => {
50+
const user = await createRandomUser()
51+
const failures: string[] = []
52+
53+
try {
54+
await runOcc(['user:setting', '--', user.userId, 'theming', 'enabled-themes', `["${theme}"]`])
55+
await login(context.request, user)
56+
await page.goto('')
57+
58+
await page.addScriptTag({ path: resolve(process.cwd(), 'node_modules/axe-core/axe.min.js') })
59+
60+
for (const [groupName, { foregroundColors, backgroundColors }] of Object.entries(testCases)) {
61+
for (const foreground of foregroundColors) {
62+
for (const background of backgroundColors) {
63+
await page.evaluate(({ foregroundValue, backgroundValue }) => {
64+
document.body.style.backgroundImage = 'unset'
65+
const root = document.querySelector('#content')
66+
if (!root) {
67+
throw new Error('No test root found')
68+
}
69+
70+
root.innerHTML = ''
71+
72+
const wrapper = document.createElement('div')
73+
wrapper.style.padding = '14px'
74+
wrapper.style.color = `var(--${foregroundValue})`
75+
wrapper.style.backgroundColor = `var(--${backgroundValue})`
76+
if (backgroundValue.includes('blur')) {
77+
wrapper.style.backdropFilter = 'var(--filter-background-blur)'
78+
}
79+
80+
const testCase = document.createElement('div')
81+
testCase.innerText = `${foregroundValue} ${backgroundValue}`
82+
testCase.setAttribute('data-cy-testcase', '')
83+
84+
wrapper.append(testCase)
85+
root.append(wrapper)
86+
}, {
87+
foregroundValue: foreground,
88+
backgroundValue: background,
89+
})
90+
91+
const axeResult = await page.evaluate(async () => {
92+
const axe = (window as any).axe
93+
if (!axe) {
94+
throw new Error('axe is not loaded')
95+
}
96+
97+
return axe.run('[data-cy-testcase]', {
98+
runOnly: {
99+
type: 'rule',
100+
values: ['color-contrast'],
101+
},
102+
})
103+
})
104+
105+
if (axeResult.violations.length > 0) {
106+
failures.push(`${groupName}: ${foreground} on ${background}`)
107+
}
108+
}
109+
}
110+
}
111+
} finally {
112+
await runOcc(['user:delete', user.userId])
113+
}
114+
115+
expect(failures).toEqual([])
116+
})
117+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright'
7+
import { runOcc } from '@nextcloud/e2e-test-server/docker'
8+
import { expect } from '@playwright/test'
9+
import { test } from '../../support/fixtures/admin-theming-page.ts'
10+
import { resolve } from 'node:path'
11+
import { defaultBackground, defaultPrimary, getBodyThemingSnapshot, pickColor } from '../../support/utils/theming.ts'
12+
13+
test.describe('Admin theming background settings', () => {
14+
test.describe.configure({ mode: 'serial' })
15+
16+
test.beforeEach(async ({ adminThemingPage, page }) => {
17+
await adminThemingPage.reset()
18+
await adminThemingPage.open()
19+
if (await adminThemingPage.disableUserThemingCheckbox().isChecked()) {
20+
await Promise.all([
21+
page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'),
22+
adminThemingPage.disableUserThemingCheckbox().uncheck({ force: true }),
23+
])
24+
}
25+
})
26+
27+
test('Remove default background and restore it', async ({ adminThemingPage, page, context }) => {
28+
await expect(adminThemingPage.backgroundAndColorHeading()).toBeVisible()
29+
if (await adminThemingPage.removeBackgroundImageCheckbox().isChecked()) {
30+
await Promise.all([
31+
page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'),
32+
adminThemingPage.removeBackgroundImageCheckbox().uncheck({ force: true }),
33+
])
34+
}
35+
36+
await Promise.all([
37+
page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'),
38+
adminThemingPage.removeBackgroundImageCheckbox().check({ force: true }),
39+
])
40+
41+
await page.goto('/index.php/logout')
42+
await page.goto('/index.php/login')
43+
await expect.poll(async () => (await getBodyThemingSnapshot(page)).backgroundImage).toBe('none')
44+
45+
await adminThemingPage.reset()
46+
await page.goto('settings/admin/theming')
47+
await expect(adminThemingPage.backgroundAndColorHeading()).toBeVisible()
48+
})
49+
50+
test('Disable user theming', async ({ adminThemingPage, page, context }) => {
51+
await expect(adminThemingPage.disableUserThemingCheckbox()).not.toBeChecked()
52+
await Promise.all([
53+
page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'),
54+
adminThemingPage.disableUserThemingCheckbox().check({ force: true }),
55+
])
56+
57+
const user = await createRandomUser()
58+
try {
59+
await login(context.request, user)
60+
await page.goto('settings/user/theming')
61+
await expect(page.getByText('Customization has been disabled by your administrator')).toBeVisible()
62+
} finally {
63+
await runOcc(['user:delete', user.userId])
64+
}
65+
})
66+
67+
test('Remove default background with custom color', async ({ adminThemingPage, page, context }) => {
68+
await expect(adminThemingPage.backgroundAndColorHeading()).toBeVisible()
69+
const backgroundColorButton = page.getByRole('button', { name: /Background color/ })
70+
const selectedColor = await pickColor(page, backgroundColorButton, 2)
71+
expect(selectedColor).toBeTruthy()
72+
73+
await Promise.all([
74+
page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'),
75+
adminThemingPage.removeBackgroundImageCheckbox().check({ force: true }),
76+
])
77+
78+
await page.goto('/index.php/logout')
79+
await page.goto('/index.php/login')
80+
await expect.poll(async () => (await getBodyThemingSnapshot(page)).backgroundImage).toBe('none')
81+
})
82+
83+
test('User default background reflects admin custom background and color', async ({ adminThemingPage, page, context }) => {
84+
const imagePath = resolve(process.cwd(), 'cypress/fixtures/image.jpg')
85+
86+
await page.locator('input[type="file"][name="background"]').setInputFiles(imagePath)
87+
await page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/uploadImage') && response.request().method() === 'POST')
88+
89+
const backgroundColorButton = page.getByRole('button', { name: /Background color/ })
90+
await pickColor(page, backgroundColorButton, 1)
91+
await page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST')
92+
93+
await page.goto('/index.php/logout')
94+
const user = await createRandomUser()
95+
try {
96+
await login(context.request, user)
97+
await page.goto('settings/user/theming')
98+
await expect(page.getByRole('button', { name: 'Default background' })).toHaveAttribute('aria-pressed', 'true')
99+
const snapshot = await getBodyThemingSnapshot(page)
100+
expect(snapshot.backgroundImage).toContain('/apps/theming/image/background?v=')
101+
} finally {
102+
await runOcc(['user:delete', user.userId])
103+
}
104+
})
105+
106+
test('User default background reflects admin removed background', async ({ adminThemingPage, page, context }) => {
107+
await Promise.all([
108+
page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'),
109+
adminThemingPage.removeBackgroundImageCheckbox().check({ force: true }),
110+
])
111+
112+
await page.goto('/index.php/logout')
113+
const user = await createRandomUser()
114+
try {
115+
await login(context.request, user)
116+
await page.goto('settings/user/theming')
117+
await expect(page.getByRole('button', { name: 'Default background' })).toHaveAttribute('aria-pressed', 'true')
118+
await expect.poll(async () => (await getBodyThemingSnapshot(page)).backgroundImage).toBe('none')
119+
} finally {
120+
await runOcc(['user:delete', user.userId])
121+
}
122+
})
123+
})
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { User } from '@nextcloud/e2e-test-server'
7+
import { expect } from '@playwright/test'
8+
import { test } from '../../support/fixtures/admin-theming-page.ts'
9+
10+
const admin = new User('admin', 'admin')
11+
12+
test.describe('Admin theming branding settings', () => {
13+
test.describe.configure({ mode: 'serial' })
14+
15+
test.beforeEach(async ({ adminThemingPage }) => {
16+
await adminThemingPage.reset()
17+
await adminThemingPage.open()
18+
})
19+
20+
test('Set project links and verify persisted values', async ({ adminThemingPage, page }) => {
21+
await expect(adminThemingPage.webLinkInput()).toHaveAttribute('type', 'url')
22+
await expect(adminThemingPage.legalNoticeLinkInput()).toHaveAttribute('type', 'url')
23+
await expect(adminThemingPage.privacyPolicyLinkInput()).toHaveAttribute('type', 'url')
24+
25+
await adminThemingPage.webLinkInput().fill('http://example.com/path?query#fragment')
26+
await Promise.all([
27+
page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'),
28+
adminThemingPage.webLinkInput().press('Enter'),
29+
])
30+
31+
await adminThemingPage.legalNoticeLinkInput().fill('http://example.com/legal?query#fragment')
32+
await Promise.all([
33+
page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'),
34+
adminThemingPage.legalNoticeLinkInput().press('Enter'),
35+
])
36+
37+
await adminThemingPage.privacyPolicyLinkInput().fill('http://privacy.local/path?query#fragment')
38+
await Promise.all([
39+
page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'),
40+
adminThemingPage.privacyPolicyLinkInput().press('Enter'),
41+
])
42+
43+
await page.reload()
44+
await expect(adminThemingPage.webLinkInput()).toHaveValue('http://example.com/path?query#fragment')
45+
await expect(adminThemingPage.legalNoticeLinkInput()).toHaveValue('http://example.com/legal?query#fragment')
46+
await expect(adminThemingPage.privacyPolicyLinkInput()).toHaveValue('http://privacy.local/path?query#fragment')
47+
})
48+
49+
test('Set and undo login fields', async ({ adminThemingPage, page }) => {
50+
const name = 'ABCdef123'
51+
const url = 'https://example.com'
52+
const slogan = 'Testing is fun'
53+
54+
await Promise.all([
55+
page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'),
56+
adminThemingPage.nameInput().fill(name),
57+
])
58+
await adminThemingPage.nameInput().press('Enter')
59+
60+
await Promise.all([
61+
page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'),
62+
adminThemingPage.webLinkInput().fill(url),
63+
])
64+
await adminThemingPage.webLinkInput().press('Enter')
65+
66+
await Promise.all([
67+
page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'),
68+
adminThemingPage.sloganInput().fill(slogan),
69+
])
70+
await adminThemingPage.sloganInput().press('Enter')
71+
72+
await expect(adminThemingPage.undoChangesButtons()).toHaveCount(3)
73+
74+
for (let index = 0; index < 3; index++) {
75+
await Promise.all([
76+
page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/undoChanges') && response.request().method() === 'POST'),
77+
adminThemingPage.undoChangesButtons().first().click(),
78+
])
79+
}
80+
await expect(adminThemingPage.undoChangesButtons()).toHaveCount(0)
81+
})
82+
83+
test('Web link corner cases', async ({ adminThemingPage, page }) => {
84+
await setUrlFieldAndWait(page, adminThemingPage.webLinkInput(), 'http://example.com/%22path%20with%20space%22')
85+
await page.reload()
86+
await expect(adminThemingPage.webLinkInput()).toHaveValue('http://example.com/%22path%20with%20space%22')
87+
88+
await setUrlFieldAndWait(page, adminThemingPage.webLinkInput(), 'http://example.com/"path"')
89+
await page.reload()
90+
await expect(adminThemingPage.webLinkInput()).toHaveValue('http://example.com/%22path%22')
91+
92+
await setUrlFieldAndWait(page, adminThemingPage.webLinkInput(), 'http://example.com/"the%20path"')
93+
await page.reload()
94+
await expect(adminThemingPage.webLinkInput()).toHaveValue('http://example.com/%22the%20path%22')
95+
})
96+
})
97+
98+
async function setUrlFieldAndWait(page: import('@playwright/test').Page, locator: import('@playwright/test').Locator, value: string) {
99+
await locator.fill(value)
100+
await Promise.all([
101+
page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'),
102+
locator.press('Enter'),
103+
])
104+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { expect } from '@playwright/test'
7+
import { test } from '../../support/fixtures/admin-theming-page.ts'
8+
import { pickColor } from '../../support/utils/theming.ts'
9+
10+
test.beforeEach(async ({ adminThemingPage }) => {
11+
await adminThemingPage.reset()
12+
await adminThemingPage.open()
13+
})
14+
15+
test('Change the primary color and reset it', async ({ adminThemingPage, page }) => {
16+
await page.getByRole('heading', { name: 'Background and color' }).scrollIntoViewIfNeeded()
17+
18+
const primaryColorButton = page.getByRole('button', { name: /Primary color/ })
19+
const updateStylesheetResponse = page.waitForResponse((response) => {
20+
return response.url().includes('/apps/theming/ajax/updateStylesheet')
21+
&& response.request().method() === 'POST'
22+
})
23+
await pickColor(page, primaryColorButton, 3)
24+
expect(await updateStylesheetResponse).toBeTruthy()
25+
26+
await page.goto('settings/admin/theming')
27+
await adminThemingPage.reset()
28+
await page.goto('settings/admin/theming')
29+
await expect(page.getByRole('heading', { name: 'Background and color' })).toBeVisible()
30+
})

0 commit comments

Comments
 (0)