diff --git a/cypress/e2e/appstore/apps.cy.ts b/cypress/e2e/appstore/apps.cy.ts deleted file mode 100644 index 391ab163f9a..00000000000 --- a/cypress/e2e/appstore/apps.cy.ts +++ /dev/null @@ -1,279 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { User } from '@nextcloud/e2e-test-server/cypress' -import { handlePasswordConfirmation } from '../core-utils.ts' - -const admin = new User('admin', 'admin') - -describe('Settings: App management', { testIsolation: true }, () => { - beforeEach(() => { - // disable QA if already enabled - cy.runOccCommand('app:disable -n testing') - // enable notification if already disabled - cy.runOccCommand('app:enable -n updatenotification') - - // I am logged in as the admin - cy.login(admin) - - // Intercept the apps list request - cy.intercept('GET', '/ocs/v2.php/apps/appstore/api/v1/apps').as('fetchAppsList') - - // I open the Apps management - cy.visit('/settings/apps/installed') - - // Wait for the apps list to load - cy.wait('@fetchAppsList') - }) - - it('Can enable an installed app', () => { - cy.intercept('POST', '/ocs/v2.php/apps/appstore/api/v1/apps/enable').as('enableApp') - - cy.findByRole('table').should('exist') - // Wait for the app list to load - .contains('tr', 'QA testing', { timeout: 10000 }) - .should('exist') - .findByRole('button', { name: 'Enable' }) - // I enable the "QA testing" app - .click({ force: true }) - - handlePasswordConfirmation(admin.password) - - cy.wait('@enableApp') - - // Wait until we see the disable button for the app - cy.findByRole('table').should('exist') - .contains('tr', 'QA testing') - .should('exist') - // I see the disable button for the app - .findByRole('button', { name: 'Disable' }) - .should('be.visible') - - // Change to enabled apps view - cy.findByRole('navigation', { name: 'Appstore categories' }) - .within(() => { - cy.findByRole('link', { name: 'Active apps' }) - .should('be.visible') - .click({ force: true }) - }) - - cy.url().should('match', /settings\/apps\/enabled$/) - // I see that the "QA testing" app has been enabled - cy.findByRole('table') - .contains('tr', 'QA testing') - }) - - it('Can disable an installed app', () => { - cy.intercept('POST', '/ocs/v2.php/apps/appstore/api/v1/apps/disable').as('disableApp') - - cy.findByRole('table') - .should('exist') - // Wait for the app list to load - .contains('tr', 'Update notification', { timeout: 10000 }) - .should('exist') - // I disable the "Update notification" app - .findByRole('button', { name: 'Disable' }) - .click({ force: true }) - - handlePasswordConfirmation(admin.password) - cy.wait('@disableApp') - - // Wait until we see the disable button for the app - cy.findByRole('table').should('exist') - .contains('tr', 'Update notification') - .should('exist') - // I see the enable button for the app - .findByRole('button', { name: 'Enable' }) - .should('exist') - - // Change to disabled apps view - cy.findByRole('navigation', { name: 'Appstore categories' }) - .within(() => { - cy.findByRole('link', { name: 'Disabled apps' }).click({ force: true }) - }) - cy.url().should('match', /settings\/apps\/disabled$/) - - // I see that the "Update notification" app has been disabled - cy.findByRole('table') - .contains('tr', 'Update notification') - }) - - it('Browse enabled apps', () => { - // When I open the "Active apps" section - cy.findByRole('navigation', { name: 'Appstore categories' }) - .within(() => { - cy.findByRole('link', { name: 'Active apps' }) - .should('be.visible') - .click({ force: true }) - }) - - // Then I see that the current section is "Active apps" - cy.url().should('match', /settings\/apps\/enabled$/) - cy.findByRole('navigation', { name: 'Appstore categories' }) - .within(() => { - cy.findByRole('link', { name: 'Active apps', current: 'page' }) - .should('be.visible') - }) - - // I see that there are only enabled apps - cy.findByRole('table') - .should('exist') - .find('tr button') - .each(($action) => { - cy.wrap($action).should('not.contain', 'Enable') - }) - }) - - it('Browse disabled apps', () => { - // When I open the "Active Disabled" section - cy.findByRole('navigation', { name: 'Appstore categories' }) - .within(() => { - cy.findByRole('link', { name: 'Disabled apps' }) - .as('disabledAppsLink') - .should('be.visible') - .and('not.have.attr', 'aria-current') - cy.get('@disabledAppsLink') - .click({ force: true }) - }) - - // Then I see that the current section is "Disabled apps" - cy.url().should('match', /settings\/apps\/disabled$/) - cy.findByRole('navigation', { name: 'Appstore categories' }) - .within(() => { - cy.findByRole('link', { name: 'Disabled apps', current: 'page' }) - .should('be.visible') - }) - - // I see that there are only disabled apps - cy.findByRole('table') - .should('exist') - .find('tr button') - .each(($action) => { - cy.wrap($action).should('not.contain', 'Disable') - }) - }) - - it('Browse app bundles', () => { - // When I open the "App bundles" section - cy.findByRole('navigation', { name: 'Appstore categories' }) - .within(() => { - cy.findByRole('link', { name: 'App bundles' }) - .as('appBundlesLink') - .should('be.visible') - .and('not.have.attr', 'aria-current') - cy.get('@appBundlesLink') - .click({ force: true }) - }) - - // Then I see that the current section is "App bundles" - cy.url().should('match', /settings\/apps\/bundles$/) - cy.findByRole('navigation', { name: 'Appstore categories' }) - .within(() => { - cy.findByRole('link', { name: 'App bundles', current: 'page' }) - .should('be.visible') - }) - - // I see the app bundles - cy.findByRole('heading', { name: 'Enterprise bundle' }) - .should('be.visible') - cy.findByRole('heading', { name: 'Education bundle' }) - .should('be.visible') - }) - - it('View app details', () => { - // When I click on the "QA testing" app - cy.findByRole('table') - .contains('a', 'QA testing') - .click({ force: true }) - // I see that the app details are shown - cy.get('#app-sidebar-vue') - .should('be.visible') - .find('.app-sidebar-header__info') - .should('contain', 'QA testing') - cy.get('#app-sidebar-vue').contains('a', 'View in store').should('exist') - cy.get('#app-sidebar-vue') - .findByRole('button', { name: 'Enable' }) - .should('be.visible') - cy.get('#app-sidebar-vue') - .findByRole('button', { name: 'Remove' }) - .should('be.visible') - cy.get('#app-sidebar-vue').contains(/Version \d+\.\d+\.\d+/).should('be.visible') - }) - - it('Limit app usage to group', () => { - // When I open the "Active apps" section - cy.findByRole('navigation', { name: 'Appstore categories' }) - .within(() => { - cy.findByRole('link', { name: 'Active apps' }) - .should('be.visible') - .click({ force: true }) - }) - - // Then I see that the current section is "Active apps" - cy.url().should('match', /settings\/apps\/enabled$/) - - // Then I select the app - cy.findByRole('table') - .should('exist') - .contains('tr a', 'Dashboard', { timeout: 10000 }) - .click() - - // Then I enable "limit app to group" - cy.findByRole('button', { name: 'Limit to groups' }) - .click() - - // Then I select a group - cy.findByRole('dialog') - .should('be.visible') - .within(() => { - cy.get('input') - .should('be.focused') - .type('admin') - }) - cy.findByRole('option', { name: /admin/ }) - .click() - cy.findByRole('button', { name: 'Save' }) - .click() - - handlePasswordConfirmation(admin.password) - - cy.get('#app-sidebar-vue') - .findByRole('list', { name: 'Limited to groups' }) - .findByRole('listitem', { name: /admin/ }) - .should('be.visible') - - // Then I disable the group limitation - cy.get('#app-sidebar-vue') - .findByRole('button', { name: 'Limit to groups' }) - .click() - cy.findByRole('dialog') - .should('be.visible') - .within(() => { - cy.findByRole('button', { name: 'Deselect admin' }) - .should('be.visible') - .click() - cy.findByRole('button', { name: 'Save' }) - .click() - }) - - handlePasswordConfirmation(admin.password) - - cy.get('#app-sidebar-vue') - .findByRole('list', { name: 'Limited to groups' }) - .should('not.exist') - }) - - /* - * TODO: Improve testing with app store as external API - * The following scenarios require the files_antivirus and calendar app - * being present in the app store with support for the current server version - * Ideally we would have either a dummy app store endpoint with some test apps - * or even an app store instance running somewhere to properly test this. - * This is also a requirement to properly test updates of apps - */ - // TODO: View app details for app store apps - // TODO: Install an app from the app store - // TODO: Show section from app store -}) diff --git a/cypress/e2e/theming/a11y-color-contrast.cy.ts b/cypress/e2e/theming/a11y-color-contrast.cy.ts deleted file mode 100644 index 53796707b1c..00000000000 --- a/cypress/e2e/theming/a11y-color-contrast.cy.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -const themesToTest = ['light', 'dark', 'light-highcontrast', 'dark-highcontrast'] - -const testCases = { - 'Main text': { - foregroundColors: [ - 'color-main-text', - // 'color-text-light', deprecated - // 'color-text-lighter', deprecated - 'color-text-maxcontrast', - ], - backgroundColors: [ - 'color-main-background', - 'color-background-hover', - 'color-background-dark', - // 'color-background-darker', this should only be used for elements not for text - ], - }, - 'blurred background': { - foregroundColors: [ - 'color-main-text', - 'color-text-maxcontrast-blur', - ], - backgroundColors: [ - 'color-main-background-blur', - ], - }, - Primary: { - foregroundColors: [ - 'color-primary-text', - ], - backgroundColors: [ - // 'color-primary-default', this should only be used for elements not for text! - // 'color-primary-hover', this should only be used for elements and not for text! - 'color-primary', - ], - }, - 'Primary light': { - foregroundColors: [ - 'color-primary-light-text', - ], - backgroundColors: [ - 'color-primary-light', - 'color-primary-light-hover', - ], - }, - 'Primary element': { - foregroundColors: [ - 'color-primary-element-text', - 'color-primary-element-text-dark', - ], - backgroundColors: [ - 'color-primary-element', - 'color-primary-element-hover', - ], - }, - 'Primary element light': { - foregroundColors: [ - 'color-primary-element-light-text', - ], - backgroundColors: [ - 'color-primary-element-light', - 'color-primary-element-light-hover', - ], - }, - 'Severity information texts': { - foregroundColors: [ - 'color-error-text', - 'color-warning-text', - 'color-success-text', - 'color-info-text', - ], - backgroundColors: [ - 'color-main-background', - 'color-background-hover', - ], - }, - // only most important severity colors are supported on the blur - 'Severity information on blur': { - foregroundColors: [ - 'color-error-text', - 'color-success-text', - ], - backgroundColors: [ - 'color-main-background-blur', - ], - }, -} - -/** - * Create a wrapper element with color and background set - * - * @param foreground The foreground color (css variable without leading --) - * @param background The background color - */ -function createTestCase(foreground: string, background: string) { - const wrapper = document.createElement('div') - wrapper.style.padding = '14px' - wrapper.style.color = `var(--${foreground})` - wrapper.style.backgroundColor = `var(--${background})` - if (background.includes('blur')) { - wrapper.style.backdropFilter = 'var(--filter-background-blur)' - } - - const testCase = document.createElement('div') - testCase.innerText = `${foreground} ${background}` - testCase.setAttribute('data-cy-testcase', '') - - wrapper.appendChild(testCase) - return wrapper -} - -describe('Accessibility of Nextcloud theming colors', () => { - for (const theme of themesToTest) { - context(`Theme: ${theme}`, () => { - before(() => { - cy.createRandomUser().then(($user) => { - // set user theme - cy.runOccCommand(`user:setting -- '${$user.userId}' theming enabled-themes '[\\"${theme}\\"]'`) - cy.login($user) - cy.visit('/') - cy.injectAxe({ axeCorePath: 'node_modules/axe-core/axe.min.js' }) - }) - }) - - beforeEach(() => { - cy.document().then((doc) => { - // Unset background image and thus use background-color for testing blur background (images do not work with axe-core) - doc.body.style.backgroundImage = 'unset' - - const root = doc.querySelector('#content') - if (root === null) { - throw new Error('No test root found') - } - root.innerHTML = '' - }) - }) - - for (const [name, { backgroundColors, foregroundColors }] of Object.entries(testCases)) { - context(`Accessibility of CSS color variables for ${name}`, () => { - for (const foreground of foregroundColors) { - for (const background of backgroundColors) { - it(`color contrast of ${foreground} on ${background}`, () => { - cy.document().then((doc) => { - const element = createTestCase(foreground, background) - const root = doc.querySelector('#content') - - expect(root).not.to.be.undefined - - root!.appendChild(element) - - cy.checkA11y('[data-cy-testcase]', { - runOnly: ['color-contrast'], - }) - }) - }) - } - } - }) - } - }) - } -}) diff --git a/cypress/e2e/theming/admin-settings_background.cy.ts b/cypress/e2e/theming/admin-settings_background.cy.ts deleted file mode 100644 index 52c27aaed8a..00000000000 --- a/cypress/e2e/theming/admin-settings_background.cy.ts +++ /dev/null @@ -1,385 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { User } from '@nextcloud/e2e-test-server/cypress' -import { NavigationHeader } from '../../pages/NavigationHeader.ts' -import { - defaultBackground, - defaultPrimary, - pickColor, - validateBodyThemingCss, - validateUserThemingDefaultCss, -} from './themingUtils.ts' - -const admin = new User('admin', 'admin') - -describe('Remove the default background and restore it', { testIsolation: false }, function() { - before(function() { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - }) - - it('See the admin theming section', function() { - cy.visit('/settings/admin/theming') - cy.findByRole('heading', { name: 'Background and color' }) - .should('exist') - .scrollIntoView() - }) - - it('Remove the default background', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('removeBackground') - cy.intercept('*/apps/theming/theme/default.css?*').as('cssLoaded') - - cy.findByRole('checkbox', { name: /remove background image/i }) - .should('exist') - .should('not.be.checked') - .check({ force: true }) - - cy.wait('@removeBackground') - cy.wait('@cssLoaded') - - cy.window() - .should(() => validateBodyThemingCss(defaultPrimary, null)) - cy.waitUntil(() => cy.window().then((win) => { - const backgroundPlain = getComputedStyle(win.document.body).getPropertyValue('--image-background') - return backgroundPlain !== '' - })) - }) - - it('Screenshot the login page and validate login page', function() { - cy.logout() - cy.visit('/') - - cy.window() - .should(() => validateBodyThemingCss(defaultPrimary, null)) - cy.screenshot() - }) - - it('Undo theming settings and validate login page again', function() { - cy.resetAdminTheming() - cy.visit('/') - - cy.window() - .should(() => validateBodyThemingCss()) - cy.screenshot() - }) -}) - -describe('Remove the default background with a custom background color', function() { - let selectedColor = '' - - before(function() { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - }) - - it('See the admin theming section', function() { - cy.visit('/settings/admin/theming') - cy.findByRole('heading', { name: 'Background and color' }) - .should('exist') - .scrollIntoView() - }) - - it('Change the background color', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor') - cy.intercept('*/apps/theming/theme/default.css?*').as('cssLoaded') - - pickColor(cy.findByRole('button', { name: /Background color/ })) - .then((color) => { - selectedColor = color - }) - - cy.wait('@setColor') - cy.wait('@cssLoaded') - - cy.window() - .should(() => validateBodyThemingCss( - defaultPrimary, - defaultBackground, - selectedColor, - )) - }) - - it('Remove the default background', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('removeBackground') - - cy.findByRole('checkbox', { name: /remove background image/i }) - .should('exist') - .should('not.be.checked') - .check({ force: true }) - cy.wait('@removeBackground') - }) - - it('Screenshot the login page and validate login page', function() { - cy.logout() - cy.visit('/') - - cy.window() - .should(() => validateBodyThemingCss(defaultPrimary, null, selectedColor)) - cy.screenshot() - }) - - it('Undo theming settings and validate login page again', function() { - cy.resetAdminTheming() - cy.visit('/') - - cy.window() - .should(() => validateBodyThemingCss()) - cy.screenshot() - }) -}) - -describe('Remove the default background with a bright color', function() { - const navigationHeader = new NavigationHeader() - let selectedColor = '' - - before(function() { - // Just in case previous test failed - cy.resetAdminTheming() - cy.resetUserTheming(admin) - cy.login(admin) - }) - - it('See the admin theming section', function() { - cy.visit('/settings/admin/theming') - cy.findByRole('heading', { name: 'Background and color' }) - .should('exist') - .scrollIntoView() - }) - - it('Remove the default background', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('removeBackground') - cy.findByRole('checkbox', { name: /remove background image/i }) - .check({ force: true }) - cy.wait('@removeBackground') - }) - - it('Change the background color', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor') - cy.intercept('*/apps/theming/theme/default.css?*').as('cssLoaded') - - pickColor(cy.findByRole('button', { name: /Background color/ }), 4) - .then((color) => { - selectedColor = color - }) - - cy.wait('@setColor') - cy.wait('@cssLoaded') - - cy.window() - .should(() => validateBodyThemingCss(defaultPrimary, null, selectedColor)) - }) - - it('See the header being inverted', function() { - // Probe the Nextcloud logo: it carries the same - // `var(--background-image-invert-if-bright)` filter and is always - // present in the header. The waffle launcher's current-app icon only - // renders when an app is active, which isn't the case on settings, - // and the in-popover tiles use a fixed brightness/invert filter - // regardless of theme so they're not a valid inversion probe. - cy.waitUntil(() => navigationHeader - .logo() - .find('.logo') - .then((el) => { - let ret = true - el.each(function() { - ret = ret && window.getComputedStyle(this).filter === 'invert(1)' - }) - return ret - })) - }) -}) - -describe('Disable user theming and enable it back', function() { - before(function() { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - }) - - it('See the admin theming section', function() { - cy.visit('/settings/admin/theming') - cy.findByRole('heading', { name: 'Background and color' }) - .should('exist') - .scrollIntoView() - }) - - it('Disable user background theming', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('disableUserTheming') - - cy.findByRole('checkbox', { name: /Disable user theming/ }) - .should('exist') - .and('not.be.checked') - .check({ force: true }) - - cy.wait('@disableUserTheming') - }) - - it('Login as user', function() { - cy.logout() - cy.createRandomUser().then((user) => { - cy.login(user) - }) - }) - - it('User cannot not change background settings', function() { - cy.visit('/settings/user/theming') - cy.contains('Customization has been disabled by your administrator').should('exist') - }) -}) - -describe('The user default background settings reflect the admin theming settings', function() { - let selectedColor = '' - - before(function() { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - }) - - after(function() { - cy.resetAdminTheming() - }) - - it('See the admin theming section', function() { - cy.visit('/settings/admin/theming') - cy.findByRole('heading', { name: 'Background and color' }) - .should('exist') - .scrollIntoView() - }) - - it('Change the default background', function() { - cy.intercept('*/apps/theming/ajax/uploadImage').as('setBackground') - cy.intercept('*/apps/theming/theme/default.css?*').as('cssLoaded') - - cy.fixture('image.jpg', null).as('background') - cy.get('input[type="file"][name="background"]') - .should('exist') - .selectFile('@background', { force: true }) - - cy.wait('@setBackground') - cy.wait('@cssLoaded') - - cy.window() - .should(() => validateBodyThemingCss( - defaultPrimary, - '/apps/theming/image/background?v=', - null, - )) - }) - - it('Change the background color', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor') - cy.intercept('*/apps/theming/theme/default.css?*').as('cssLoaded') - - pickColor(cy.findByRole('button', { name: /Background color/ })) - .then((color) => { - selectedColor = color - }) - - cy.wait('@setColor') - cy.wait('@cssLoaded') - - cy.window() - .should(() => validateBodyThemingCss( - defaultPrimary, - '/apps/theming/image/background?v=', - selectedColor, - )) - }) - - it('Login page should match admin theming settings', function() { - cy.logout() - cy.visit('/') - - cy.window() - .should(() => validateBodyThemingCss( - defaultPrimary, - '/apps/theming/image/background?v=', - selectedColor, - )) - }) - - it('Login as user', function() { - cy.createRandomUser().then((user) => { - cy.login(user) - }) - }) - - it('See the user background settings', function() { - cy.visit('/settings/user/theming') - cy.findByRole('heading', { name: 'Background and color' }) - .scrollIntoView() - }) - - it('Default user background settings should match admin theming settings', function() { - cy.findByRole('button', { name: 'Default background' }) - .should('exist') - .and('have.attr', 'aria-pressed', 'true') - - cy.window() - .should(() => validateUserThemingDefaultCss( - selectedColor, - '/apps/theming/image/background?v=', - )) - }) -}) - -describe('The user default background settings reflect the admin theming settings with background removed', function() { - before(function() { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - }) - - after(function() { - cy.resetAdminTheming() - }) - - it('See the admin theming section', function() { - cy.visit('/settings/admin/theming') - cy.findByRole('heading', { name: 'Background and color' }) - .should('exist') - .scrollIntoView() - }) - - it('Remove the default background', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('removeBackground') - cy.findByRole('checkbox', { name: /remove background image/i }) - .check({ force: true }) - cy.wait('@removeBackground') - }) - - it('Login page should match admin theming settings', function() { - cy.logout() - cy.visit('/') - - cy.window() - .should(() => validateBodyThemingCss(defaultPrimary, null)) - }) - - it('Login as user', function() { - cy.createRandomUser().then((user) => { - cy.login(user) - }) - }) - - it('See the user background settings', function() { - cy.visit('/settings/user/theming') - cy.findByRole('heading', { name: 'Background and color' }) - .scrollIntoView() - }) - - it('Default user background settings should match admin theming settings', function() { - cy.findByRole('button', { name: 'Default background' }) - .should('exist') - .and('have.attr', 'aria-pressed', 'true') - - cy.window() - .should(() => validateUserThemingDefaultCss(defaultPrimary, null)) - }) -}) diff --git a/cypress/e2e/theming/admin-settings_branding.cy.ts b/cypress/e2e/theming/admin-settings_branding.cy.ts deleted file mode 100644 index 1f21045edf1..00000000000 --- a/cypress/e2e/theming/admin-settings_branding.cy.ts +++ /dev/null @@ -1,219 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { User } from '@nextcloud/e2e-test-server/cypress' - -const admin = new User('admin', 'admin') - -describe('Admin theming: Setting custom project URLs', function() { - this.beforeEach(() => { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - cy.visit('/settings/admin/theming') - cy.intercept('POST', '**/apps/theming/ajax/updateStylesheet').as('updateTheming') - }) - - it('Setting the web link', () => { - cy.findByRole('textbox', { name: /web link/i }) - .and('have.attr', 'type', 'url') - .as('input') - .scrollIntoView() - cy.get('@input') - .should('be.visible') - .type('{selectAll}http://example.com/path?query#fragment{enter}') - - cy.wait('@updateTheming') - - cy.logout() - - cy.visit('/') - cy.contains('a', 'Nextcloud') - .should('be.visible') - .and('have.attr', 'href', 'http://example.com/path?query#fragment') - }) - - it('Setting the legal notice link', () => { - cy.findByRole('textbox', { name: /legal notice link/i }) - .should('exist') - .and('have.attr', 'type', 'url') - .as('input') - .scrollIntoView() - cy.get('@input') - .type('http://example.com/path?query#fragment{enter}') - - cy.wait('@updateTheming') - - cy.logout() - - cy.visit('/') - cy.contains('a', /legal notice/i) - .should('be.visible') - .and('have.attr', 'href', 'http://example.com/path?query#fragment') - }) - - it('Setting the privacy policy link', () => { - cy.findByRole('textbox', { name: /privacy policy link/i }) - .should('exist') - .as('input') - .scrollIntoView() - cy.get('@input') - .should('have.attr', 'type', 'url') - .type('http://privacy.local/path?query#fragment{enter}') - - cy.wait('@updateTheming') - - cy.logout() - - cy.visit('/') - cy.contains('a', /privacy policy/i) - .should('be.visible') - .and('have.attr', 'href', 'http://privacy.local/path?query#fragment') - }) -}) - -describe('Admin theming: Web link corner cases', function() { - this.beforeEach(() => { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - cy.visit('/settings/admin/theming') - cy.intercept('POST', '**/apps/theming/ajax/updateStylesheet').as('updateTheming') - }) - - it('Already URL encoded', () => { - cy.findByRole('textbox', { name: /web link/i }) - .and('have.attr', 'type', 'url') - .as('input') - .scrollIntoView() - cy.get('@input') - .should('be.visible') - .type('{selectAll}http://example.com/%22path%20with%20space%22{enter}') - - cy.wait('@updateTheming') - - cy.logout() - - cy.visit('/') - cy.contains('a', 'Nextcloud') - .should('be.visible') - .and('have.attr', 'href', 'http://example.com/%22path%20with%20space%22') - }) - - it('URL with double quotes', () => { - cy.findByRole('textbox', { name: /web link/i }) - .and('have.attr', 'type', 'url') - .as('input') - .scrollIntoView() - cy.get('@input') - .should('be.visible') - .type('{selectAll}http://example.com/"path"{enter}') - - cy.wait('@updateTheming') - - cy.logout() - - cy.visit('/') - cy.contains('a', 'Nextcloud') - .should('be.visible') - .and('have.attr', 'href', 'http://example.com/%22path%22') - }) - - it('URL with double quotes and already encoded', () => { - cy.findByRole('textbox', { name: /web link/i }) - .and('have.attr', 'type', 'url') - .as('input') - .scrollIntoView() - cy.get('@input') - .should('be.visible') - .type('{selectAll}http://example.com/"the%20path"{enter}') - - cy.wait('@updateTheming') - - cy.logout() - - cy.visit('/') - cy.contains('a', 'Nextcloud') - .should('be.visible') - .and('have.attr', 'href', 'http://example.com/%22the%20path%22') - }) -}) - -describe('Admin theming: Change the login fields then reset them', function() { - const name = 'ABCdef123' - const url = 'https://example.com' - const slogan = 'Testing is fun' - - before(function() { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - }) - - it('See the admin theming section', function() { - cy.visit('/settings/admin/theming') - cy.findByRole('heading', { name: /^Theming/, level: 2 }) - .should('exist') - .scrollIntoView() - }) - - it('Change the name field', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('updateFields') - - // Name - cy.findByRole('textbox', { name: 'Name' }) - .should('be.visible') - .type(`{selectall}${name}{enter}`) - cy.wait('@updateFields') - - // Url - cy.findByRole('textbox', { name: 'Web link' }) - .should('be.visible') - .type(`{selectall}${url}{enter}`) - cy.wait('@updateFields') - - // Slogan - cy.findByRole('textbox', { name: 'Slogan' }) - .should('be.visible') - .type(`{selectall}${slogan}{enter}`) - cy.wait('@updateFields') - }) - - it('Ensure undo button presence', function() { - cy.findAllByRole('button', { name: /undo changes/i }) - .should('have.length', 3) - }) - - it('Validate login screen changes', function() { - cy.logout() - cy.visit('/') - - cy.get('[data-login-form-headline]').should('contain.text', name) - cy.get('footer p a').should('have.text', name) - cy.get('footer p a').should('have.attr', 'href', url) - cy.get('footer p').should('contain.text', `– ${slogan}`) - }) - - it('Undo theming settings', function() { - cy.login(admin) - cy.visit('/settings/admin/theming') - cy.findAllByRole('button', { name: /undo changes/i }) - .each((button) => { - cy.intercept('*/apps/theming/ajax/undoChanges').as('undoField') - cy.wrap(button).click() - cy.wait('@undoField') - }) - cy.logout() - }) - - it('Validate login screen changes again', function() { - cy.visit('/') - - cy.get('[data-login-form-headline]').should('not.contain.text', name) - cy.get('footer p a').should('not.have.text', name) - cy.get('footer p a').should('not.have.attr', 'href', url) - cy.get('footer p').should('not.contain.text', `– ${slogan}`) - }) -}) diff --git a/cypress/e2e/theming/admin-settings_colors.cy.ts b/cypress/e2e/theming/admin-settings_colors.cy.ts deleted file mode 100644 index 6651c3a4714..00000000000 --- a/cypress/e2e/theming/admin-settings_colors.cy.ts +++ /dev/null @@ -1,67 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { User } from '@nextcloud/e2e-test-server/cypress' -import { - defaultBackground, - defaultPrimary, - pickColor, - validateBodyThemingCss, -} from './themingUtils.ts' - -const admin = new User('admin', 'admin') - -describe('Change the primary color and reset it', function() { - let selectedColor = '' - - before(function() { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - }) - - it('See the admin theming section', function() { - cy.visit('/settings/admin/theming') - cy.findByRole('heading', { name: 'Background and color' }) - .should('exist') - .scrollIntoView() - }) - - it('Change the primary color', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor') - - pickColor(cy.findByRole('button', { name: /Primary color/ })) - .then((color) => { - selectedColor = color - }) - - cy.wait('@setColor') - cy.waitUntil(() => validateBodyThemingCss( - selectedColor, - defaultBackground, - defaultPrimary, - )) - }) - - it('Screenshot the login page and validate login page', function() { - cy.logout() - cy.visit('/') - - cy.waitUntil(() => validateBodyThemingCss( - selectedColor, - defaultBackground, - defaultPrimary, - )) - cy.screenshot() - }) - - it('Undo theming settings and validate login page again', function() { - cy.resetAdminTheming() - cy.visit('/') - - cy.waitUntil(validateBodyThemingCss) - cy.screenshot() - }) -}) diff --git a/cypress/e2e/theming/admin-settings_default-app.cy.ts b/cypress/e2e/theming/admin-settings_default-app.cy.ts deleted file mode 100644 index a11928d21a7..00000000000 --- a/cypress/e2e/theming/admin-settings_default-app.cy.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { User } from '@nextcloud/e2e-test-server/cypress' -import { NavigationHeader } from '../../pages/NavigationHeader.ts' - -const admin = new User('admin', 'admin') - -after(() => cy.resetAdminTheming()) - -describe('Admin theming set default apps', () => { - const navigationHeader = new NavigationHeader() - - before(function() { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - }) - - it('See the current default app is the dashboard', () => { - // check default route - cy.visit('/') - cy.url().should('match', /apps\/dashboard/) - - // Also check the top logo link - navigationHeader.logo().click() - cy.url().should('match', /apps\/dashboard/) - }) - - it('See the default app settings', () => { - cy.visit('/settings/admin/theming') - - cy.get('.settings-section').contains('Navigation bar settings').should('exist') - getDefaultAppSwitch().should('exist') - getDefaultAppSwitch().scrollIntoView() - }) - - it('Toggle the "use custom default app" switch', () => { - getDefaultAppSwitch().should('not.be.checked') - cy.findByRole('region', { name: 'Global default app' }) - .should('not.exist') - - getDefaultAppSwitch().check({ force: true }) - getDefaultAppSwitch().should('be.checked') - cy.findByRole('region', { name: 'Global default app' }) - .should('exist') - }) - - it('See the default app combobox', () => { - cy.findByRole('region', { name: 'Global default app' }) - .should('exist') - .findByRole('combobox') - .as('defaultAppSelect') - .scrollIntoView() - - cy.get('@defaultAppSelect') - .findByText('Dashboard') - .should('be.visible') - cy.get('@defaultAppSelect') - .findByText('Files') - .should('be.visible') - }) - - it('See the default app order selector', () => { - cy.findByRole('region', { name: 'Global default app' }) - .should('exist') - cy.findByRole('list', { name: 'Navigation bar app order' }) - .should('exist') - .findAllByRole('listitem') - .should('have.length', 2) - .then((elements) => { - const appIDs = elements.map((idx, el) => el.innerText.trim()).get() - expect(appIDs).to.deep.eq(['Dashboard', 'Files']) - }) - }) - - it('Change the default app', () => { - cy.findByRole('list', { name: 'Navigation bar app order' }) - .should('exist') - .as('appOrderSelector') - .scrollIntoView() - - cy.get('@appOrderSelector') - .findAllByRole('listitem') - .filter((_, e) => !!e.innerText.match(/Files/i)) - .findByRole('button', { name: 'Move up' }) - .as('moveFilesUpButton') - - cy.get('@moveFilesUpButton').should('be.visible') - cy.get('@moveFilesUpButton').click() - cy.get('@moveFilesUpButton').should('not.exist') - }) - - it('See the default app is changed', () => { - cy.findByRole('list', { name: 'Navigation bar app order' }) - .findAllByRole('listitem') - .then((elements) => { - const appIDs = elements.map((idx, el) => el.innerText.trim()).get() - expect(appIDs).to.deep.eq(['Files', 'Dashboard']) - }) - - // Check the redirect to the default app works - cy.request({ url: '/', followRedirect: false }).then((response) => { - expect(response.status).to.eq(302) - expect(response).to.have.property('headers') - expect(response.headers.location).to.contain('/apps/files') - }) - }) - - it('Toggle the "use custom default app" switch back to reset the default apps', () => { - cy.visit('/settings/admin/theming') - getDefaultAppSwitch().scrollIntoView() - - getDefaultAppSwitch().should('be.checked') - getDefaultAppSwitch().uncheck({ force: true }) - getDefaultAppSwitch().should('be.not.checked') - - // Check the redirect to the default app works - cy.request({ url: '/', followRedirect: false }).then((response) => { - expect(response.status).to.eq(302) - expect(response).to.have.property('headers') - expect(response.headers.location).to.contain('/apps/dashboard') - }) - }) -}) - -function getDefaultAppSwitch() { - return cy.findByRole('checkbox', { name: 'Use custom default app' }) -} diff --git a/cypress/e2e/theming/user-settings_app-order.cy.ts b/cypress/e2e/theming/user-settings_app-order.cy.ts deleted file mode 100644 index 5098b9af431..00000000000 --- a/cypress/e2e/theming/user-settings_app-order.cy.ts +++ /dev/null @@ -1,302 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { NavigationHeader } from '../../pages/NavigationHeader.ts' -import { SettingsAppOrderList } from '../../pages/SettingsAppOrderList.ts' -import { installTestApp, uninstallTestApp } from '../../support/commonUtils.ts' - -before(() => uninstallTestApp()) - -describe('User theming set app order', () => { - const navigationHeader = new NavigationHeader() - const appOrderList = new SettingsAppOrderList() - let user: User - - before(() => { - cy.resetAdminTheming() - // Create random user for this test - cy.createRandomUser().then(($user) => { - user = $user - cy.login($user) - }) - }) - - after(() => cy.deleteUser(user)) - - it('See the app order settings', () => { - visitAppOrderSettings() - }) - - it('See that the dashboard app is the first one', () => { - const appOrder = ['Dashboard', 'Files'] - appOrderList.assertAppOrder(appOrder) - - // Check the top app menu order. The launcher grid appends a synthetic - // "More apps" / "App store" tile to the user's apps, so iterate - // positionally only over the real-app prefix. - navigationHeader.getNavigationEntries().then(($entries) => { - appOrder.forEach((name, index) => { - expect($entries.eq(index)).to.contain.text(name) - }) - }) - }) - - it('Change the app order', () => { - appOrderList.interceptAppOrder() - appOrderList.getAppOrderList() - .scrollIntoView() - appOrderList.getUpButtonForApp('Files') - .should('be.visible') - .click() - appOrderList.waitForAppOrderUpdate() - - appOrderList.assertAppOrder(['Files', 'Dashboard']) - }) - - it('See the app menu order is changed', () => { - cy.reload() - const appOrder = ['Files', 'Dashboard'] - appOrderList.getAppOrderList() - .scrollIntoView() - appOrderList.assertAppOrder(appOrder) - - // Check the top app menu order. Idempotent open in the page object - // re-opens the popover after the reload above. The synthetic trailing - // tile is ignored by iterating only over the expected app names. - navigationHeader.getNavigationEntries().then(($entries) => { - appOrder.forEach((name, index) => { - expect($entries.eq(index)).to.contain.text(name) - }) - }) - }) -}) - -describe('User theming set app order with default app', () => { - const appOrderList = new SettingsAppOrderList() - const navigationHeader = new NavigationHeader() - let user: User - - before(() => { - cy.resetAdminTheming() - // install a third app - installTestApp() - // set files as default app - cy.runOccCommand('config:system:set --value \'files\' defaultapp') - - // Create random user for this test - cy.createRandomUser().then(($user) => { - user = $user - cy.login($user) - }) - }) - - after(() => { - cy.deleteUser(user) - uninstallTestApp() - }) - - it('See files is the default app', () => { - // Check the redirect to the default app works - cy.request({ url: '/', followRedirect: false }).then((response) => { - expect(response.status).to.eq(302) - expect(response).to.have.property('headers') - expect(response.headers.location).to.contain('/apps/files') - }) - }) - - it('See the app order settings: files is the first one', () => { - visitAppOrderSettings() - - const appOrder = ['Files', 'Dashboard', 'Test App 2', 'Test App'] - appOrderList.getAppOrderList() - .scrollIntoView() - appOrderList.assertAppOrder(appOrder) - }) - - it('Can not change the default app', () => { - appOrderList.getUpButtonForApp('Files').should('not.exist') - appOrderList.getDownButtonForApp('Files').should('not.exist') - appOrderList.getUpButtonForApp('Dashboard').should('not.exist') - // but can move down - appOrderList.getDownButtonForApp('Dashboard').should('be.visible') - }) - - it('Can see the correct buttons for other apps', () => { - appOrderList.getUpButtonForApp('Test App 2').should('be.visible') - appOrderList.getDownButtonForApp('Test App 2').should('be.visible') - appOrderList.getUpButtonForApp('Test App').should('be.visible') - appOrderList.getDownButtonForApp('Test App').should('not.exist') - }) - - it('Change the order of the other apps', () => { - appOrderList.interceptAppOrder() - appOrderList.getUpButtonForApp('Test App').click() - appOrderList.waitForAppOrderUpdate() - appOrderList.getUpButtonForApp('Test App').click() - appOrderList.waitForAppOrderUpdate() - - // Can't get up anymore, files is enforced as default app - appOrderList.getUpButtonForApp('Test App').should('not.exist') - - // Check the app order settings UI - appOrderList.assertAppOrder(['Files', 'Test App', 'Dashboard', 'Test App 2']) - }) - - it('See the app menu order is changed', () => { - cy.reload() - - const appOrder = ['Files', 'Test App', 'Dashboard', 'Test App 2'] - // Check the top app menu order. See note above: the launcher appends - // a synthetic tile that we skip by iterating positionally. - navigationHeader.getNavigationEntries().then(($entries) => { - appOrder.forEach((name, index) => { - expect($entries.eq(index)).to.contain.text(name) - }) - }) - }) -}) - -describe('User theming app order list accessibility', () => { - const appOrderList = new SettingsAppOrderList() - let user: User - - before(() => { - cy.resetAdminTheming() - installTestApp() - // Create random user for this test - cy.createRandomUser().then(($user) => { - user = $user - cy.login($user) - }) - }) - - after(() => { - uninstallTestApp() - cy.deleteUser(user) - }) - - it('click the first button', () => { - visitAppOrderSettings() - appOrderList.interceptAppOrder() - appOrderList.getDownButtonForApp('Dashboard') - .should('be.visible') - .scrollIntoView() - appOrderList.getDownButtonForApp('Dashboard') - .focus() - appOrderList.getDownButtonForApp('Dashboard') - .click() - appOrderList.waitForAppOrderUpdate() - }) - - it('see the same app kept the focus', () => { - appOrderList.getDownButtonForApp('Dashboard').should('have.focus') - }) - - it('click the last button', () => { - appOrderList.interceptAppOrder() - appOrderList.getUpButtonForApp('Dashboard') - .should('be.visible') - .focus() - appOrderList.getUpButtonForApp('Dashboard').click() - appOrderList.waitForAppOrderUpdate() - }) - - it('see the same app kept the focus', () => { - appOrderList.getUpButtonForApp('Dashboard').should('not.exist') - appOrderList.getDownButtonForApp('Dashboard').should('have.focus') - }) -}) - -describe('User theming reset app order', () => { - const appOrderList = new SettingsAppOrderList() - const navigationHeader = new NavigationHeader() - let user: User - - before(() => { - cy.resetAdminTheming() - // Create random user for this test - cy.createRandomUser().then(($user) => { - user = $user - cy.login($user) - }) - }) - - after(() => cy.deleteUser(user)) - - it('See that the dashboard app is the first one', () => { - visitAppOrderSettings() - - const appOrder = ['Dashboard', 'Files'] - appOrderList.assertAppOrder(appOrder) - - // Check the top app menu order. See note above on the synthetic tile. - navigationHeader.getNavigationEntries().then(($entries) => { - appOrder.forEach((name, index) => { - expect($entries.eq(index)).to.contain.text(name) - }) - }) - }) - - it('See the reset button is disabled', () => { - appOrderList.getResetButton() - .scrollIntoView() - appOrderList.getResetButton() - .should('be.disabled') - }) - - it('Change the app order', () => { - appOrderList.interceptAppOrder() - appOrderList.getUpButtonForApp('Files') - .should('be.visible') - .click() - appOrderList.waitForAppOrderUpdate() - - appOrderList.assertAppOrder(['Files', 'Dashboard']) - }) - - it('See the reset button is no longer disabled', () => { - appOrderList.getResetButton() - .scrollIntoView() - appOrderList.getResetButton() - .should('be.visible') - .and('be.enabled') - }) - - it('Reset the app order', () => { - cy.intercept('GET', '/ocs/v2.php/core/navigation/apps').as('loadApps') - appOrderList.interceptAppOrder() - appOrderList.getResetButton().click({ force: true }) - - cy.wait('@updateAppOrder') - .its('request.body') - .should('have.property', 'configValue', '[]') - cy.wait('@loadApps') - }) - - it('See the app order is restored', () => { - const appOrder = ['Dashboard', 'Files'] - appOrderList.assertAppOrder(appOrder) - // Check the top app menu order. See note above on the synthetic tile. - navigationHeader.getNavigationEntries().then(($entries) => { - appOrder.forEach((name, index) => { - expect($entries.eq(index)).to.contain.text(name) - }) - }) - }) - - it('See the reset button is disabled again', () => { - appOrderList.getResetButton() - .should('be.disabled') - }) -}) - -function visitAppOrderSettings() { - cy.visit('/settings/user/theming') - cy.findByRole('heading', { name: /Navigation bar settings/ }) - .should('exist') - .scrollIntoView() -} diff --git a/cypress/e2e/theming/user-settings_background.cy.ts b/cypress/e2e/theming/user-settings_background.cy.ts deleted file mode 100644 index 2ad00230472..00000000000 --- a/cypress/e2e/theming/user-settings_background.cy.ts +++ /dev/null @@ -1,270 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { User } from '@nextcloud/e2e-test-server/cypress' -import { NavigationHeader } from '../../pages/NavigationHeader.ts' -import { defaultPrimary, pickColor, validateBodyThemingCss } from './themingUtils.ts' - -const admin = new User('admin', 'admin') - -describe('User default background settings', function() { - before(function() { - cy.resetAdminTheming() - cy.resetUserTheming(admin) - cy.createRandomUser().then((user: User) => { - cy.login(user) - }) - }) - - it('See the user background settings', function() { - cy.visit('/settings/user/theming') - cy.findByRole('heading', { name: /Appearance and accessibility settings/ }) - .should('be.visible') - }) - - it('Default is selected on new users', function() { - cy.findByRole('button', { name: 'Default background', pressed: true }) - .should('exist') - .scrollIntoView() - }) -}) - -describe('User select shipped backgrounds and remove background', function() { - before(function() { - cy.createRandomUser().then((user: User) => { - cy.login(user) - }) - }) - - it('See the user background settings', function() { - cy.visit('/settings/user/theming') - cy.findByRole('heading', { name: /Background and color/ }) - .should('exist') - .scrollIntoView() - }) - - it('Select a shipped background', function() { - const background = 'anatoly-mikhaltsov-butterfly-wing-scale.jpg' - const backgroundName = 'Background picture of a red-ish butterfly wing under microscope' - cy.intercept('*/apps/theming/background/shipped').as('setBackground') - - // Select background - cy.findByRole('button', { name: backgroundName, pressed: false }) - .click() - cy.findByRole('button', { name: backgroundName, pressed: true }) - .should('be.visible') - - // Validate changed background and primary - cy.wait('@setBackground') - cy.waitUntil(() => validateBodyThemingCss('#a53c17', background, '#652e11')) - }) - - it('Select a bright shipped background', function() { - const background = 'bernie-cetonia-aurata-take-off-composition.jpg' - const backgroundName = 'Montage of a cetonia aurata bug that takes off with white background' - cy.intercept('*/apps/theming/background/shipped').as('setBackground') - - cy.findByRole('button', { name: backgroundName, pressed: false }) - .click() - cy.findByRole('button', { name: backgroundName, pressed: true }) - .should('be.visible') - - // Validate changed background and primary - cy.wait('@setBackground') - cy.waitUntil(() => validateBodyThemingCss('#56633d', background, '#dee0d3')) - }) -}) - -describe('User select a custom color', function() { - before(function() { - cy.createRandomUser().then((user: User) => { - cy.login(user) - }) - }) - - it('See the user background settings', function() { - cy.visit('/settings/user/theming') - cy.findByRole('heading', { name: /Background and color/ }) - .should('exist') - .scrollIntoView() - }) - - it('Select a custom color', function() { - cy.intercept('*/apps/theming/background/color').as('clearBackground') - - // Clear background - pickColor(cy.findByRole('button', { name: 'Plain background' }), 7) - - // Validate clear background - cy.wait('@clearBackground') - cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, null, '#3794ac')) - }) -}) - -describe('User select a bright custom color and remove background', function() { - const navigationHeader = new NavigationHeader() - - before(function() { - cy.createRandomUser().then((user: User) => { - cy.login(user) - }) - }) - - it('See the user background settings', function() { - cy.visit('/settings/user/theming') - cy.findByRole('heading', { name: /Background and color/ }) - .should('exist') - .scrollIntoView() - }) - - it('Remove background', function() { - cy.intercept('*/apps/theming/background/color').as('clearBackground') - - // Clear background - pickColor(cy.findByRole('button', { name: 'Plain background' }), 4) - - // Validate clear background - cy.wait('@clearBackground') - cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, null, '#ddcb55')) - }) - - it('See the header being inverted', function() { - // Probe the Nextcloud logo: it carries the same - // `var(--background-image-invert-if-bright)` filter and is always - // present in the header. The waffle launcher's current-app icon only - // renders when an app is active, which isn't the case on settings, - // and the in-popover tiles use a fixed brightness/invert filter - // regardless of theme so they're not a valid inversion probe. - cy.waitUntil(() => navigationHeader.logo().find('.logo').then((el) => { - let ret = true - el.each(function() { - ret = ret && window.getComputedStyle(this).filter === 'invert(1)' - }) - return ret - })) - }) - - it('Select another but non-bright shipped background', function() { - const background = 'anatoly-mikhaltsov-butterfly-wing-scale.jpg' - const backgroundName = 'Background picture of a red-ish butterfly wing under microscope' - cy.intercept('*/apps/theming/background/shipped').as('setBackground') - - // Select background - cy.findByRole('button', { name: backgroundName, pressed: false }) - .click() - cy.findByRole('button', { name: backgroundName, pressed: true }) - .should('be.visible') - - // Validate changed background and primary - cy.wait('@setBackground') - cy.waitUntil(() => validateBodyThemingCss('#a53c17', background, '#652e11')) - }) - - it('See the header NOT being inverted this time', function() { - // Probe the Nextcloud logo: see the inverted-header test above for - // why we don't probe the menu icons. - cy.waitUntil(() => navigationHeader.logo().find('.logo').then((el) => { - let ret = true - el.each(function() { - ret = ret && window.getComputedStyle(this).filter === 'none' - }) - return ret - })) - }) -}) - -describe('User select a custom background', function() { - const image = 'image.jpg' - before(function() { - cy.createRandomUser().then((user: User) => { - cy.uploadFile(user, image, 'image/jpeg') - cy.login(user) - }) - }) - - it('See the user background settings', function() { - cy.visit('/settings/user/theming') - cy.findByRole('heading', { name: /Background and color/ }) - .should('exist') - .scrollIntoView() - }) - - it('Select a custom background', function() { - cy.intercept('*/apps/theming/background/custom').as('setBackground') - - // Pick background - cy.findByRole('button', { name: 'Custom background' }).click() - cy.findByRole('dialog') - .should('be.visible') - .findAllByRole('row') - .contains(image) - .click() - cy.findByRole('button', { name: 'Select background' }).click() - - // Wait for background to be set - cy.wait('@setBackground') - cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, 'apps/theming/background?v=', '#2f2221')) - }) -}) - -describe('User changes settings and reload the page', function() { - const image = 'image.jpg' - - before(function() { - cy.createRandomUser().then((user: User) => { - cy.uploadFile(user, image, 'image/jpeg') - cy.login(user) - }) - }) - - it('See the user background settings', function() { - cy.visit('/settings/user/theming') - cy.findByRole('heading', { name: /Background and color/ }) - .should('exist') - .scrollIntoView() - }) - - it('Select a custom background', function() { - cy.intercept('*/apps/theming/background/custom').as('setBackground') - - // Pick background - cy.findByRole('button', { name: 'Custom background' }).click() - cy.findByRole('dialog') - .should('be.visible') - .findAllByRole('row') - .contains(image) - .click() - cy.findByRole('button', { name: 'Select background' }).click() - - // Wait for background to be set - cy.wait('@setBackground') - cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, 'apps/theming/background?v=', '#2f2221')) - }) - - it('Select a custom background color', function() { - cy.intercept('*/apps/theming/background/color').as('clearBackground') - - // Clear background - pickColor(cy.findByRole('button', { name: 'Plain background' }), 5) - - // Validate clear background - cy.wait('@clearBackground') - cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, null, '#a5b872')) - }) - - it('Select a custom primary color', function() { - cy.intercept('/ocs/v2.php/apps/provisioning_api/api/v1/config/users/theming/primary_color').as('setPrimaryColor') - - pickColor(cy.findByRole('button', { name: 'Primary color' }), 2) - - cy.wait('@setPrimaryColor') - cy.waitUntil(() => validateBodyThemingCss('#c98879', null, '#a5b872')) - }) - - it('Reload the page and validate persistent changes', function() { - cy.reload() - cy.waitUntil(() => validateBodyThemingCss('#c98879', null, '#a5b872')) - }) -}) diff --git a/tests/playwright/e2e/appstore/admin-settings-apps.spec.ts b/tests/playwright/e2e/appstore/admin-settings-apps.spec.ts new file mode 100644 index 00000000000..d318c3283af --- /dev/null +++ b/tests/playwright/e2e/appstore/admin-settings-apps.spec.ts @@ -0,0 +1,217 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect } from '@playwright/test' +import { test } from '../../support/fixtures/admin-appstore-page.ts' +import { handlePasswordConfirmation } from '../../support/utils/password-confirmation.ts' +import { runOcc } from '@nextcloud/e2e-test-server' + +test.describe('Settings: App management', () => { + test.beforeEach(async ({ page, appstorePage }) => { + // Disable QA testing app if already enabled + expect(await runOcc(['app:disable', 'testing'])) + .toMatch(/(No such app enabled|testing .+ disabled)/) + // Enable update notification app if disabled + expect(await runOcc(['app:enable', 'updatenotification'])) + .toMatch(/(updatenotification already enabled|updatenotification .+ enabled)/) + + // Open the installed apps page + await appstorePage.openInstalledApps() + + // Wait for the apps table to load + await appstorePage.appsTable().waitFor({ state: 'visible', timeout: 10000 }) + }) + + test('Can enable an installed app', async ({ page, appstorePage }) => { + // Intercept the enable app request + const enableRequest = page.waitForResponse( + (response) => response.url().includes('/ocs/v2.php/apps/appstore/api/v1/apps/enable'), + ) + + // Find and click the enable button for the QA testing app + await expect(appstorePage.appsTable()).toBeVisible() + const qaTestingRow = appstorePage.appRow('QA testing') + await expect(qaTestingRow).toBeVisible({ timeout: 10000 }) + + await appstorePage.enableButton('QA testing').click({ force: true }) + + // Handle password confirmation if needed + await handlePasswordConfirmation(page, 'admin') + + // Wait for the API request + await enableRequest + + // Wait until we see the disable button for the app + await expect(appstorePage.appsTable()).toBeVisible() + await expect(appstorePage.appRow('QA testing')).toBeVisible() + await expect(appstorePage.disableButton('QA testing')).toBeVisible() + + // Change to enabled apps view + await appstorePage.openEnabledApps() + + // Verify the app appears in the enabled list + await expect(appstorePage.appRow('QA testing')).toBeVisible() + }) + + test('Can disable an installed app', async ({ page, appstorePage }) => { + // Intercept the disable app request + const disableRequest = page.waitForResponse( + (response) => response.url().includes('/ocs/v2.php/apps/appstore/api/v1/apps/disable'), + ) + + // Find and click the disable button for the Update notification app + await expect(appstorePage.appsTable()).toBeVisible() + const updateRow = appstorePage.appRow('Update notification') + await expect(updateRow).toBeVisible({ timeout: 10000 }) + + await appstorePage.disableButton('Update notification').click({ force: true }) + + // Handle password confirmation if needed + await handlePasswordConfirmation(page, 'admin') + + // Wait for the API request + await disableRequest + + // Wait until we see the enable button for the app + await expect(appstorePage.appsTable()).toBeVisible() + await expect(appstorePage.appRow('Update notification')).toBeVisible() + await expect(appstorePage.enableButton('Update notification')).toBeVisible() + + // Change to disabled apps view + await appstorePage.openDisabledApps() + + // Verify the app appears in the disabled list + await expect(appstorePage.appRow('Update notification')).toBeVisible() + }) + + test('Browse enabled apps', async ({ appstorePage }) => { + // Open the "Active apps" section + await appstorePage.openEnabledApps() + + // Verify the URL is correct + await expect(appstorePage.navigationLink('Active apps')).toHaveAttribute('aria-current', 'page') + + // Verify that there are only enabled apps (all have "Disable" button, no "Enable" button) + await expect(appstorePage.appsTable()).toBeVisible() + + // Get all rows and verify each has a disable button and no enable button + const rows = appstorePage.appsTable().locator('tr') + const rowCount = await rows.count() + + for (let i = 1; i < rowCount; i++) { // Skip header row + const row = rows.nth(i) + const enableButton = row.getByRole('button', { name: 'Enable' }) + + // Enabled apps should not have an "Enable" button + await expect(enableButton).not.toBeVisible() + } + }) + + test('Browse disabled apps', async ({ appstorePage }) => { + // Open the "Disabled apps" section + await appstorePage.openDisabledApps() + + // Verify the current section is "Disabled apps" + await expect(appstorePage.navigationLink('Disabled apps')).toHaveAttribute('aria-current', 'page') + + // Verify that there are only disabled apps (all have "Enable" button, no "Disable" button) + await expect(appstorePage.appsTable()).toBeVisible() + + // Get all rows and verify each has an enable button and no disable button + const rows = appstorePage.appsTable().locator('tr') + const rowCount = await rows.count() + + for (let i = 1; i < rowCount; i++) { // Skip header row + const row = rows.nth(i) + const disableButton = row.getByRole('button', { name: 'Disable' }) + + // Disabled apps should not have a "Disable" button + await expect(disableButton).not.toBeVisible() + } + }) + + test('Browse app bundles', async ({ appstorePage }) => { + // Open the "App bundles" section + await appstorePage.openBundles() + + // Verify the current section is "App bundles" + await expect(appstorePage.navigationLink('App bundles')).toHaveAttribute('aria-current', 'page') + + // Verify we see the app bundles + await expect(appstorePage.enterpriseBundleHeading()).toBeVisible() + await expect(appstorePage.educationBundleHeading()).toBeVisible() + }) + + test('View app details', async ({ appstorePage }) => { + // Click on the "QA testing" app + await appstorePage.appLink('QA testing').click({ force: true }) + + // Verify the app details sidebar is shown + const sidebar = appstorePage.appSidebar() + await expect(sidebar).toBeVisible() + await expect(appstorePage.appSidebarHeader()).toContainText('QA testing') + + // Verify the sidebar contains expected elements + await expect(appstorePage.viewInStoreLink()).toBeVisible() + await expect(appstorePage.appSidebarEnableButton()).toBeVisible() + await expect(appstorePage.removeButton()).toBeVisible() + + // Verify version information is displayed + await expect(appstorePage.versionText()).toBeVisible() + }) + + test('Limit app usage to group', async ({ appstorePage, page }) => { + // Open the "Active apps" section + await appstorePage.openEnabledApps() + + // Select the updatenotification app + await appstorePage.appLink('Update Notification').scrollIntoViewIfNeeded() + await appstorePage.appLink('Update Notification').click() + + // Click the "Limit to groups" button + await appstorePage.limitToGroupsButton().click() + + // The dialog should be visible + const dialog = appstorePage.groupDialog() + await expect(dialog).toBeVisible() + + // Type "admin" in the search field + const searchInput = appstorePage.groupSearchInput() + await expect(searchInput).toBeFocused() + await searchInput.fill('admin') + + // Select the admin option from the dropdown + await appstorePage.groupOption('admin').click() + + // Click the Save button + await appstorePage.dialogSaveButton().click() + + // Handle password confirmation + await handlePasswordConfirmation(page, 'admin') + + // Verify the group is now in the "Limited to groups" list + const limitedList = appstorePage.limitedToGroupsList() + await expect(limitedList).toBeVisible() + await expect(limitedList.getByRole('listitem', { name: /admin/ })).toBeVisible() + + // Now disable the group limitation + await appstorePage.limitToGroupsButton().click() + + // The dialog should be visible again + await expect(dialog).toBeVisible() + + // Click the deselect button for the admin group + await appstorePage.deselectGroupButton('admin').click() + + // Click Save + await appstorePage.dialogSaveButton().click() + + // Handle password confirmation + await handlePasswordConfirmation(page, 'admin') + + // Verify the "Limited to groups" list is no longer visible + await expect(appstorePage.limitedToGroupsList()).toHaveCount(0) + }) +}) diff --git a/tests/playwright/e2e/theming/admin-settings-branding.spec.ts b/tests/playwright/e2e/theming/admin-settings-branding.spec.ts index d78409a26d0..fe6196bc832 100644 --- a/tests/playwright/e2e/theming/admin-settings-branding.spec.ts +++ b/tests/playwright/e2e/theming/admin-settings-branding.spec.ts @@ -10,8 +10,6 @@ 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() diff --git a/tests/playwright/e2e/theming/admin-settings-default-app.spec.ts b/tests/playwright/e2e/theming/admin-settings-default-app.spec.ts index b82a1fa5316..30807476704 100644 --- a/tests/playwright/e2e/theming/admin-settings-default-app.spec.ts +++ b/tests/playwright/e2e/theming/admin-settings-default-app.spec.ts @@ -44,8 +44,8 @@ test.describe('Admin theming set default apps', () => { 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.defaultAppSelectedValue('Dashboard')).toBeVisible() + await expect(adminThemingPage.defaultAppSelectedValue('Files')).toBeVisible() await expect(adminThemingPage.appOrderEntries()).toHaveCount(2) await expect(adminThemingPage.appOrderEntries().nth(0)).toContainText('Dashboard') diff --git a/tests/playwright/e2e/theming/user-settings-app-order.spec.ts b/tests/playwright/e2e/theming/user-settings-app-order.spec.ts index 305334635d6..8a5ac4a3c85 100644 --- a/tests/playwright/e2e/theming/user-settings-app-order.spec.ts +++ b/tests/playwright/e2e/theming/user-settings-app-order.spec.ts @@ -18,6 +18,7 @@ test('User can change personal app order', async ({ page }) => { await expect(userThemingPage.appOrderEntries().nth(0)).toContainText('Dashboard') await expect(userThemingPage.appOrderEntries().nth(1)).toContainText('Files') + await navigationHeader.openMenu() await expect(navigationHeader.navigationEntries().nth(0)).toContainText('Dashboard') await expect(navigationHeader.navigationEntries().nth(1)).toContainText('Files') @@ -39,6 +40,7 @@ test('User can change personal app order', async ({ page }) => { const reloadedOrder = (await userThemingPage.appOrderEntries().allInnerTexts()).map((entry) => entry.trim()) expect(reloadedOrder).toContain('Dashboard') expect(reloadedOrder).toContain('Files') + await navigationHeader.openMenu() await expect(navigationHeader.navigationEntries().nth(0)).toContainText(reloadedOrder[0]!) await expect(navigationHeader.navigationEntries().nth(1)).toContainText(reloadedOrder[1]!) }) diff --git a/tests/playwright/support/fixtures/admin-appstore-page.ts b/tests/playwright/support/fixtures/admin-appstore-page.ts new file mode 100644 index 00000000000..b4fea5fac0c --- /dev/null +++ b/tests/playwright/support/fixtures/admin-appstore-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 { AppstorePage } from '../sections/AppstorePage.ts' + +export const test = adminSessionTest.extend<{ appstorePage: AppstorePage }>({ + appstorePage: async ({ page }, use) => { + const appstorePage = new AppstorePage(page) + await use(appstorePage) + }, +}) diff --git a/tests/playwright/support/sections/AdminThemingPage.ts b/tests/playwright/support/sections/AdminThemingPage.ts index 44520c5a640..7e445f3fb77 100644 --- a/tests/playwright/support/sections/AdminThemingPage.ts +++ b/tests/playwright/support/sections/AdminThemingPage.ts @@ -24,7 +24,7 @@ export class AdminThemingPage { }) const requestToken = (await tokenResponse.json()).token - const response = await this.page.request.post('/apps/theming/ajax/undoAllChanges', { + const response = await this.page.request.post('./apps/theming/ajax/undoAllChanges', { headers: { requesttoken: requestToken, }, @@ -44,7 +44,15 @@ export class AdminThemingPage { } defaultAppSelect(): Locator { - return this.defaultAppRegion().getByRole('combobox') + // NcSelect appends the dropdown listbox to (appendToBody: true), so it cannot + // be reached via a scoped locator. Return the selected-options wrapper instead, which + // stays inline and contains the visible selected-value tag spans. + return this.defaultAppRegion().locator('.vs__selected-options') + } + + defaultAppSelectedValue(name: string): Locator { + // NcSelect renders each selected value as a tag with a "Deselect " button. + return this.defaultAppRegion().getByRole('button', { name: `Deselect ${name}` }) } appOrderList(): Locator { diff --git a/tests/playwright/support/sections/AppstorePage.ts b/tests/playwright/support/sections/AppstorePage.ts new file mode 100644 index 00000000000..053e7e0c043 --- /dev/null +++ b/tests/playwright/support/sections/AppstorePage.ts @@ -0,0 +1,199 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Locator, Page } from '@playwright/test' + +export class AppstorePage { + constructor(private readonly page: Page) {} + + /** + * Opens the main appstore page + */ + async openAppstore() { + await this.page.goto('settings/apps') + await this.appsTable().waitFor({ state: 'visible' }) + } + + /** + * Opens the installed apps page + */ + async openInstalledApps() { + await this.page.goto('settings/apps/installed') + await this.appsTable().waitFor({ state: 'visible' }) + } + + /** + * Opens the enabled apps page + */ + async openEnabledApps() { + await this.navigationLink('Active apps').click() + await this.page.waitForURL(/settings\/apps\/enabled$/) + } + + /** + * Opens the disabled apps page + */ + async openDisabledApps() { + await this.navigationLink('Disabled apps').click() + await this.page.waitForURL(/settings\/apps\/disabled$/) + } + + /** + * Opens the app bundles page + */ + async openBundles() { + await this.navigationLink('App bundles').click() + await this.page.waitForURL(/settings\/apps\/bundles$/) + } + + /** + * Gets the apps table element + */ + appsTable(): Locator { + return this.page.getByRole('table') + } + + /** + * Gets a specific app row by app name + */ + appRow(appName: string): Locator { + return this.appsTable().locator('tr').filter({ hasText: appName }).first() + } + + /** + * Gets the enable button for a specific app + */ + enableButton(appName: string): Locator { + return this.appRow(appName).getByRole('button', { name: 'Enable' }) + } + + /** + * Gets the disable button for a specific app + */ + disableButton(appName: string): Locator { + return this.appRow(appName).getByRole('button', { name: 'Disable' }) + } + + /** + * Gets the app link in the table + */ + appLink(appName: string): Locator { + return this.appsTable().getByRole('link', { name: appName }) + } + + /** + * Gets the navigation link in the appstore sidebar + */ + navigationLink(name: string): Locator { + return this.page.getByRole('navigation', { name: 'Appstore categories' }).getByRole('link', { name }) + } + + /** + * Gets the app sidebar + */ + appSidebar(): Locator { + return this.page.locator('#app-sidebar-vue') + } + + /** + * Gets the app sidebar header + */ + appSidebarHeader(): Locator { + return this.appSidebar().locator('.app-sidebar-header__info') + } + + /** + * Gets the "Enable" button in the app sidebar (not the table row). + * Use this when checking the sidebar after clicking an app link. + */ + appSidebarEnableButton(): Locator { + return this.appSidebar().getByRole('button', { name: 'Enable' }) + } + + /** + * Gets the "View in store" link in the sidebar + */ + viewInStoreLink(): Locator { + return this.appSidebar().getByRole('link', { name: 'View in store' }) + } + + /** + * Gets the "Remove" button in the sidebar + */ + removeButton(): Locator { + return this.appSidebar().getByRole('button', { name: 'Remove' }) + } + + /** + * Gets the "Limit to groups" button + */ + limitToGroupsButton(): Locator { + return this.appSidebar().getByRole('button', { name: 'Limit to groups' }) + } + + /** + * Gets the "Limited to groups" list + */ + limitedToGroupsList(): Locator { + return this.appSidebar().getByRole('list', { name: 'Limited to groups' }) + } + + /** + * Gets the group dialog + */ + groupDialog(): Locator { + return this.page.getByRole('dialog') + } + + /** + * Gets the save button in the dialog + */ + dialogSaveButton(): Locator { + return this.groupDialog().getByRole('button', { name: 'Save' }) + } + + /** + * Gets the deselect button for a group + */ + deselectGroupButton(groupName: string): Locator { + return this.groupDialog().getByRole('button', { name: `Deselect ${groupName}` }) + } + + /** + * Gets the group search input. + * NcSelectUsers uses role="combobox" on the search input, not role="textbox". + */ + groupSearchInput(): Locator { + return this.groupDialog().locator('input').first() + } + + /** + * Gets the enterprise bundle heading + */ + enterpriseBundleHeading(): Locator { + return this.page.getByRole('heading', { name: 'Enterprise bundle' }) + } + + /** + * Gets the education bundle heading + */ + educationBundleHeading(): Locator { + return this.page.getByRole('heading', { name: 'Education bundle' }) + } + + /** + * Gets the version text from sidebar + */ + versionText(): Locator { + return this.appSidebar().getByText(/Version \d+\.\d+\.\d+/) + } + + /** + * Gets a group option from the dropdown + */ + groupOption(groupName: string): Locator { + return this.page.getByRole('option', { name: new RegExp(groupName) }) + } +} diff --git a/tests/playwright/support/sections/NavigationHeaderPage.ts b/tests/playwright/support/sections/NavigationHeaderPage.ts index 543fccd8304..eee46402c3d 100644 --- a/tests/playwright/support/sections/NavigationHeaderPage.ts +++ b/tests/playwright/support/sections/NavigationHeaderPage.ts @@ -17,10 +17,34 @@ export class NavigationHeaderPage { } navigation(): Locator { - return this.header.getByRole('navigation', { name: 'Applications menu' }) + return this.header.getByRole('navigation', { name: 'Applications' }) } + private waffleButton(): Locator { + return this.navigation().locator('.app-menu__waffle') + } + + /** + * Open the waffle launcher popover. + * The app entries only exist in the DOM while the popover is open. + */ + async openMenu(): Promise { + const isOpen = await this.waffleButton().getAttribute('aria-expanded') === 'true' + if (!isOpen) { + await this.waffleButton().click() + } + await this.popover().waitFor({ state: 'visible' }) + } + + popover(): Locator { + return this.page.locator('[role="menu"][aria-label="Apps"]') + } + + /** + * Returns navigation entries from the waffle popover. + * Call {@link openMenu} first — entries are only in the DOM while the popover is open. + */ navigationEntries(): Locator { - return this.navigation().getByRole('listitem') + return this.popover().getByRole('menuitem') } } diff --git a/tests/playwright/support/utils/password-confirmation.ts b/tests/playwright/support/utils/password-confirmation.ts new file mode 100644 index 00000000000..75f26b85366 --- /dev/null +++ b/tests/playwright/support/utils/password-confirmation.ts @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Page } from '@playwright/test' + +/** + * Handle the password confirmation dialog if it appears + * + * @param page - The Playwright page object + * @param password - The password to enter (default: 'admin') + */ +export async function handlePasswordConfirmation(page: Page, password = 'admin') { + const dialog = page.locator('.modal-container:has-text("Authentication required")') + + try { + // Check if the dialog exists within a short timeout + const dialogVisible = await dialog.isVisible({ timeout: 500 }).catch(() => false) + + if (dialogVisible) { + // Fill the password field + await dialog.locator('input[type="password"]').fill(password) + + // Click the confirm button + await dialog.getByRole('button', { name: 'Confirm' }).click() + + // Wait for the dialog to disappear + await dialog.waitFor({ state: 'hidden' }) + } + } catch (error) { + // Dialog didn't appear, which is fine - some operations might not require confirmation + } +}