From 5fd406c784c4ea5426ce035b493d6f70a98b0d1e Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 11 Jun 2026 20:39:49 +0200 Subject: [PATCH 1/2] test(core): migrate end-to-end test to PlayWright Signed-off-by: Ferdinand Thiessen --- .github/workflows/cypress.yml | 4 +- .github/workflows/playwright.yml | 4 +- cypress/e2e/core/404-error.cy.ts | 19 --- cypress/e2e/core/header_access-levels.cy.ts | 101 ------------- cypress/e2e/core/header_app-menu.cy.ts | 91 ------------ cypress/e2e/core/header_contacts-menu.cy.ts | 135 ------------------ tests/playwright/e2e/core/404-error.spec.ts | 21 +++ .../e2e/core/header-access-levels.spec.ts | 60 ++++++++ .../e2e/core/header-app-menu.spec.ts | 80 +++++++++++ .../e2e/core/header-contacts-menu.spec.ts | 122 ++++++++++++++++ .../support/sections/AccountMenuPage.ts | 57 ++++++++ .../support/sections/ContactsMenuPage.ts | 70 +++++++++ 12 files changed, 414 insertions(+), 350 deletions(-) delete mode 100644 cypress/e2e/core/404-error.cy.ts delete mode 100644 cypress/e2e/core/header_access-levels.cy.ts delete mode 100644 cypress/e2e/core/header_app-menu.cy.ts delete mode 100644 cypress/e2e/core/header_contacts-menu.cy.ts create mode 100644 tests/playwright/e2e/core/404-error.spec.ts create mode 100644 tests/playwright/e2e/core/header-access-levels.spec.ts create mode 100644 tests/playwright/e2e/core/header-app-menu.spec.ts create mode 100644 tests/playwright/e2e/core/header-contacts-menu.spec.ts create mode 100644 tests/playwright/support/sections/AccountMenuPage.ts create mode 100644 tests/playwright/support/sections/ContactsMenuPage.ts diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml index f541f38b89e57..fd57e3a056125 100644 --- a/.github/workflows/cypress.yml +++ b/.github/workflows/cypress.yml @@ -141,10 +141,10 @@ jobs: matrix: # Run multiple copies of the current job in parallel # Please increase the number or runners as your tests suite grows (0 based index for e2e tests) - containers: ['setup', '0', '1', '2', '3', '4', '5', '6'] + containers: ['setup', '0', '1', '2', '3', '4', '5'] # Hack as strategy.job-total includes the "setup" and GitHub does not allow math expressions # Always align this number with the total of e2e runners (max. index + 1) - total-containers: [7] + total-containers: [6] services: mysql: diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 937e0feb1b218..dfb673edadb3f 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -82,8 +82,8 @@ jobs: strategy: fail-fast: false matrix: - shardIndex: [1, 2, 3] - shardTotal: [3] + shardIndex: [1, 2, 3, 4] + shardTotal: [4] outputs: node-version: ${{ steps.versions.outputs.node-version }} package-manager-version: ${{ steps.versions.outputs.package-manager-version }} diff --git a/cypress/e2e/core/404-error.cy.ts b/cypress/e2e/core/404-error.cy.ts deleted file mode 100644 index b24562933e8bb..0000000000000 --- a/cypress/e2e/core/404-error.cy.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -describe('404 error page', { testIsolation: true }, () => { - it('renders 404 page', () => { - cy.visit('/doesnotexist', { failOnStatusCode: false }) - - cy.findByRole('heading', { name: /Page not found/ }) - .should('be.visible') - cy.findByRole('link', { name: /Back to Nextcloud/ }) - .should('be.visible') - .click() - - cy.url() - .should('match', /(\/index.php)\/login$/) - }) -}) diff --git a/cypress/e2e/core/header_access-levels.cy.ts b/cypress/e2e/core/header_access-levels.cy.ts deleted file mode 100644 index e04109bfb3b26..0000000000000 --- a/cypress/e2e/core/header_access-levels.cy.ts +++ /dev/null @@ -1,101 +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 { clearState, getNextcloudUserMenu, getNextcloudUserMenuToggle } from '../../support/commonUtils.ts' - -const admin = new User('admin', 'admin') - -describe('Header: Ensure regular users do not have admin settings in the Settings menu', { testIsolation: true }, () => { - beforeEach(() => { - clearState() - }) - - it('Regular users can see basic items in the Settings menu', () => { - // Given I am logged in - cy.createRandomUser().then(($user) => { - cy.login($user) - cy.visit('/') - }) - // I open the settings menu - getNextcloudUserMenuToggle().click() - - getNextcloudUserMenu().find('ul').within(($el) => { - // I see the settings menu is open - cy.wrap($el).should('be.visible') - - // I see that the Settings menu has only 6 items - cy.get('li').should('have.length', 6) - // I see that the "View profile" item in the Settings menu is shown - cy.contains('li', 'View profile').should('be.visible') - // I see that the "Set status" item in the Settings menu is shown - cy.contains('li', 'Set status').should('be.visible') - // I see that the "Appearance and accessibility" item in the Settings menu is shown - cy.contains('li', 'Appearance and accessibility').should('be.visible') - // I see that the "Settings" item in the Settings menu is shown - cy.contains('li', 'Settings').should('be.visible') - // I see that the "Help" item in the Settings menu is shown - cy.contains('li', 'Help').should('be.visible') - // I see that the "Log out" item in the Settings menu is shown - cy.contains('li', 'Log out').should('be.visible') - }) - }) - - it('Regular users cannot see admin-level items in the Settings menu', () => { - // Given I am logged in - cy.createRandomUser().then(($user) => { - cy.login($user) - cy.visit('/') - }) - // I open the settings menu - getNextcloudUserMenuToggle().click() - - getNextcloudUserMenu().find('ul').within(($el) => { - // I see the settings menu is open - cy.wrap($el).should('be.visible') - - // I see that the "Users" item in the Settings menu is NOT shown - cy.contains('li', 'Users').should('not.exist') - // I see that the "Administration settings" item in the Settings menu is NOT shown - cy.contains('li', 'Administration settings').should('not.exist') - cy.get('#admin_settings').should('not.exist') - }) - }) - - it('Admin users can see admin-level items in the Settings menu', () => { - // Given I am logged in - cy.login(admin) - cy.visit('/') - - // I open the settings menu - getNextcloudUserMenuToggle().click() - - getNextcloudUserMenu().find('ul').within(($el) => { - // I see the settings menu is open - cy.wrap($el).should('be.visible') - - // I see that the Settings menu has only 9 items - cy.get('li').should('have.length', 9) - // I see that the "Set status" item in the Settings menu is shown - cy.contains('li', 'View profile').should('be.visible') - // I see that the "Set status" item in the Settings menu is shown - cy.contains('li', 'Set status').should('be.visible') - // I see that the "Appearance and accessibility" item in the Settings menu is shown - cy.contains('li', 'Appearance and accessibility').should('be.visible') - // I see that the "Personal Settings" item in the Settings menu is shown - cy.contains('li', 'Personal settings').should('be.visible') - // I see that the "Administration settings" item in the Settings menu is shown - cy.contains('li', 'Administration settings').should('be.visible') - // I see that the "Apps" item in the Settings menu is shown - cy.contains('li', 'Apps').should('be.visible') - // I see that the "Users" item in the Settings menu is shown - cy.contains('li', 'Accounts').should('be.visible') - // I see that the "Help" item in the Settings menu is shown - cy.contains('li', 'Help').should('be.visible') - // I see that the "Log out" item in the Settings menu is shown - cy.contains('li', 'Log out').should('be.visible') - }) - }) -}) diff --git a/cypress/e2e/core/header_app-menu.cy.ts b/cypress/e2e/core/header_app-menu.cy.ts deleted file mode 100644 index 2452e2a19486a..0000000000000 --- a/cypress/e2e/core/header_app-menu.cy.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { User } from '@nextcloud/e2e-test-server/cypress' -import { clearState, getNextcloudHeader } from '../../support/commonUtils.ts' - -const getAppMenu = () => getNextcloudHeader().find('.app-menu') -// Both triggers share aria-label="Open apps menu", so getByRole can't -// disambiguate them. BEM classes owned by the component under test are -// the next-best stable selectors. -const getWaffleTrigger = () => getAppMenu().find('.app-menu__waffle') - -before(clearState) - -describe('Header: App menu (waffle launcher)', { testIsolation: true }, () => { - describe('Normal user', () => { - beforeEach(() => { - cy.createRandomUser().then(($user) => { - cy.login($user) - cy.visit('/') - }) - }) - - it('Open and click opens the popover and navigates when a tile is clicked', () => { - getWaffleTrigger().click() - cy.get('.app-menu__popover').should('be.visible') - getWaffleTrigger().should('have.attr', 'aria-expanded', 'true') - - cy.findAllByRole('menuitem').first() - .should('be.visible') - .then(($tile) => { - const href = $tile.attr('href') - expect(href).to.match(/\/apps\//) - cy.wrap($tile).click() - cy.location('pathname').should('include', '/apps/') - }) - }) - - it('has all correct app navigation items', () => { - waffleMenuShouldContainApps([ - { name: 'Files', href: '/apps/files' }, - { name: 'Dashboard', href: '/apps/dashboard' }, - ]) - }) - }) - - describe('Admin', () => { - const admin = new User('admin', 'admin') - - beforeEach(() => { - cy.login(admin) - cy.visit('/') - }) - - it('shows the "More apps" tile for admins', () => { - getWaffleTrigger().click() - cy.get('.app-menu__popover').should('be.visible') - cy.findByRole('menuitem', { name: 'More apps' }).should('be.visible') - }) - - it('has all correct app navigation items', () => { - waffleMenuShouldContainApps([ - { name: 'Files', href: '/apps/files' }, - { name: 'Dashboard', href: '/apps/dashboard' }, - { name: 'Appstore', href: '/settings/apps' }, - ]) - }) - }) -}) - -/** - * Check that the waffle menu contains the given apps, by name and href. - * - * @param apps - The apps that should be present in the waffle menu, with their expected name and href. - */ -function waffleMenuShouldContainApps(apps: { name: string, href: string }[]) { - getWaffleTrigger().click() - getWaffleTrigger().should('have.attr', 'aria-expanded', 'true') - cy.findByRole('menu', { name: 'Apps' }).should('be.visible') - - cy.findAllByRole('menuitem') - .then((items) => { - apps.forEach((app) => { - const item = items.toArray().find((i) => i.textContent?.includes(app.name)) - expect(item, `App menu should contain ${app.name}`).to.exist - expect(item?.getAttribute('href')).to.match(new RegExp(`${app.href}(\\?.+|/?$)`)) - }) - }) -} diff --git a/cypress/e2e/core/header_contacts-menu.cy.ts b/cypress/e2e/core/header_contacts-menu.cy.ts deleted file mode 100644 index 657a50bc85c5d..0000000000000 --- a/cypress/e2e/core/header_contacts-menu.cy.ts +++ /dev/null @@ -1,135 +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 { clearState, getNextcloudHeader } from '../../support/commonUtils.ts' -import { randomString } from '../../support/utils/randomString.ts' - -const admin = new User('admin', 'admin') - -const getContactsMenu = () => getNextcloudHeader().find('#header-menu-contactsmenu') -const getContactsMenuToggle = () => getNextcloudHeader().find('#contactsmenu .header-menu__trigger') -const getContactsSearch = () => getContactsMenu().find('#contactsmenu__menu__search') - -describe('Header: Contacts menu', { testIsolation: true }, () => { - let user: User - - beforeEach(() => { - // clear user and group state - clearState() - // ensure the contacts menu is not restricted - cy.runOccCommand('config:app:set --value no core shareapi_restrict_user_enumeration_to_group') - // create a new user for testing the contacts - cy.createRandomUser().then(($user) => { - user = $user - }) - - // Given I am logged in as the admin - cy.login(admin) - cy.visit('/') - }) - - it('Other users are seen in the contacts menu', () => { - // When I open the Contacts menu - getContactsMenuToggle().click() - // I see that the Contacts menu is shown - getContactsMenu().should('exist') - // I see that the contact user in the Contacts menu is shown - getContactsMenu().contains('li.contact', user.userId).should('be.visible') - // I see that the contact "admin" in the Contacts menu is not shown - getContactsMenu().contains('li.contact', admin.userId).should('not.exist') - }) - - it('Just added users are seen in the contacts menu', () => { - // I create a new user - const newUserName = randomString(7) - // we can not use createRandomUser as it will invalidate the session - cy.runOccCommand(`user:add --password-from-env '${newUserName}'`, { env: { OC_PASS: '1234567' } }) - // I open the Contacts menu - getContactsMenuToggle().click() - // I see that the Contacts menu is shown - getContactsMenu().should('exist') - // I see that the contact user in the Contacts menu is shown - getContactsMenu().contains('li.contact', user.userId).should('be.visible') - // I see that the contact of the new user in the Contacts menu is shown - getContactsMenu().contains('li.contact', newUserName).should('be.visible') - // I see that the contact "admin" in the Contacts menu is not shown - getContactsMenu().contains('li.contact', admin.userId).should('not.exist') - }) - - it('Search for other users in the contacts menu', () => { - cy.createRandomUser().then((otherUser) => { - // Given I am logged in as the admin - cy.login(admin) - cy.visit('/') - - // I open the Contacts menu - getContactsMenuToggle().click() - // I see that the Contacts menu is shown - getContactsMenu().should('exist') - // I see that the contact user in the Contacts menu is shown - getContactsMenu().contains('li.contact', user.userId).should('be.visible') - // I see that the contact of the new user in the Contacts menu is shown - getContactsMenu().contains('li.contact', otherUser.userId).should('be.visible') - - // I see that the Contacts menu search input is shown - getContactsSearch().should('exist') - // I search for the otherUser - getContactsSearch().type(otherUser.userId) - // I see that the contact otherUser in the Contacts menu is shown - getContactsMenu().contains('li.contact', otherUser.userId).should('be.visible') - // I see that the contact user in the Contacts menu is not shown - getContactsMenu().contains('li.contact', user.userId).should('not.exist') - // I see that the contact "admin" in the Contacts menu is not shown - getContactsMenu().contains('li.contact', admin.userId).should('not.exist') - }) - }) - - it('Search for unknown users in the contacts menu', () => { - // I open the Contacts menu - getContactsMenuToggle().click() - // I see that the Contacts menu is shown - getContactsMenu().should('exist') - // I see that the contact user in the Contacts menu is shown - getContactsMenu().contains('li.contact', user.userId).should('be.visible') - - // I see that the Contacts menu search input is shown - getContactsSearch().should('exist') - // I search for an unknown user - getContactsSearch().type('surely-unknown-user') - // I see that the no results message in the Contacts menu is shown - getContactsMenu().find('ul li').should('have.length', 0) - // I see that the contact user in the Contacts menu is not shown - getContactsMenu().contains('li.contact', user.userId).should('not.exist') - // I see that the contact "admin" in the Contacts menu is not shown - getContactsMenu().contains('li.contact', admin.userId).should('not.exist') - }) - - it('Users from other groups are not seen in the contacts menu when autocompletion is restricted within the same group', () => { - // I enable restricting username autocompletion to groups - cy.runOccCommand('config:app:set --value yes core shareapi_restrict_user_enumeration_to_group') - // I open the Contacts menu - getContactsMenuToggle().click() - // I see that the Contacts menu is shown - getContactsMenu().should('exist') - // I see that the contact user in the Contacts menu is not shown - getContactsMenu().contains('li.contact', user.userId).should('not.exist') - // I see that the contact "admin" in the Contacts menu is not shown - getContactsMenu().contains('li.contact', admin.userId).should('not.exist') - - // I close the Contacts menu - getContactsMenuToggle().click() - // I disable restricting username autocompletion to groups - cy.runOccCommand('config:app:set --value no core shareapi_restrict_user_enumeration_to_group') - // I open the Contacts menu - getContactsMenuToggle().click() - // I see that the Contacts menu is shown - getContactsMenu().should('exist') - // I see that the contact user in the Contacts menu is shown - getContactsMenu().contains('li.contact', user.userId).should('be.visible') - // I see that the contact "admin" in the Contacts menu is not shown - getContactsMenu().contains('li.contact', admin.userId).should('not.exist') - }) -}) diff --git a/tests/playwright/e2e/core/404-error.spec.ts b/tests/playwright/e2e/core/404-error.spec.ts new file mode 100644 index 0000000000000..9d345e76c59cf --- /dev/null +++ b/tests/playwright/e2e/core/404-error.spec.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '@playwright/test' + +test.describe('404 error page', () => { + test('renders 404 page with a link back to login', async ({ page }) => { + // No authentication — the 404 page is shown to unauthenticated visitors. + await page.goto('/doesnotexist') + + await expect(page.getByRole('heading', { name: /Page not found/ })).toBeVisible() + + const backLink = page.getByRole('link', { name: /Back to Nextcloud/ }) + await expect(backLink).toBeVisible() + await backLink.click() + + await expect(page).toHaveURL(/\/login$/) + }) +}) diff --git a/tests/playwright/e2e/core/header-access-levels.spec.ts b/tests/playwright/e2e/core/header-access-levels.spec.ts new file mode 100644 index 0000000000000..daf03bda43f9f --- /dev/null +++ b/tests/playwright/e2e/core/header-access-levels.spec.ts @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect } from '@playwright/test' +import { test as userTest } from '../../support/fixtures/random-user-session.ts' +import { test as adminTest } from '../../support/fixtures/admin-session.ts' +import { AccountMenuPage } from '../../support/sections/AccountMenuPage.ts' + +// Regular user tests — the page fixture is logged in as a fresh random user. +userTest.describe('Header: Settings menu – regular user', () => { + userTest('can see the basic items', async ({ page }) => { + await page.goto('/') + const accountMenu = new AccountMenuPage(page) + await accountMenu.open() + + // A standard installation presents exactly 6 items for regular users. + await expect(accountMenu.entries()).toHaveCount(6) + await expect(accountMenu.entry('View profile')).toBeVisible() + await expect(accountMenu.entry('Set status')).toBeVisible() + await expect(accountMenu.entry('Appearance and accessibility')).toBeVisible() + // Regular users see "Settings" (personal settings shortcut), not the + // separate "Personal settings" / "Administration settings" split. + await expect(accountMenu.entry('Settings')).toBeVisible() + await expect(accountMenu.entry('Help')).toBeVisible() + await expect(accountMenu.entry('Log out')).toBeVisible() + }) + + userTest('cannot see admin-level items', async ({ page }) => { + await page.goto('/') + const accountMenu = new AccountMenuPage(page) + await accountMenu.open() + + await expect(accountMenu.entry('Users')).toHaveCount(0) + await expect(accountMenu.entry('Administration settings')).toHaveCount(0) + }) +}) + +// Admin tests — the page fixture is logged in as the built-in admin user. +adminTest.describe('Header: Settings menu – admin user', () => { + adminTest('can see the admin-level items', async ({ page }) => { + await page.goto('/') + const accountMenu = new AccountMenuPage(page) + await accountMenu.open() + + // A standard installation presents exactly 9 items for the admin. + await expect(accountMenu.entries()).toHaveCount(9) + await expect(accountMenu.entry('View profile')).toBeVisible() + await expect(accountMenu.entry('Set status')).toBeVisible() + await expect(accountMenu.entry('Appearance and accessibility')).toBeVisible() + // Admins see the explicit split between personal and admin sections. + await expect(accountMenu.entry('Personal settings')).toBeVisible() + await expect(accountMenu.entry('Administration settings')).toBeVisible() + await expect(accountMenu.entry('Apps')).toBeVisible() + await expect(accountMenu.entry('Accounts')).toBeVisible() + await expect(accountMenu.entry('Help')).toBeVisible() + await expect(accountMenu.entry('Log out')).toBeVisible() + }) +}) diff --git a/tests/playwright/e2e/core/header-app-menu.spec.ts b/tests/playwright/e2e/core/header-app-menu.spec.ts new file mode 100644 index 0000000000000..11902eb0e8917 --- /dev/null +++ b/tests/playwright/e2e/core/header-app-menu.spec.ts @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect } from '@playwright/test' +import { test as userTest } from '../../support/fixtures/random-user-session.ts' +import { test as adminTest } from '../../support/fixtures/admin-session.ts' +import { NavigationHeaderPage } from '../../support/sections/NavigationHeaderPage.ts' + +// Regular-user tests — logged in as a fresh random user. +userTest.describe('Header: App menu (waffle launcher) – regular user', () => { + userTest('opens the popover and navigates when a tile is clicked', async ({ page }) => { + await page.goto('/') + const navigationHeader = new NavigationHeaderPage(page) + + await navigationHeader.openMenu() + await expect(navigationHeader.popover()).toBeVisible() + + const firstEntry = navigationHeader.navigationEntries().first() + await expect(firstEntry).toBeVisible() + + const href = await firstEntry.getAttribute('href') + expect(href).toMatch(/\/apps\//) + + await firstEntry.click() + await expect(page).toHaveURL(/\/apps\//) + }) + + userTest('has the correct app navigation items', async ({ page }) => { + await page.goto('/') + const navigationHeader = new NavigationHeaderPage(page) + await expectWaffleMenuContainsApps(navigationHeader, [ + { name: 'Files', href: '/apps/files' }, + { name: 'Dashboard', href: '/apps/dashboard' }, + ]) + }) +}) + +// Admin tests — logged in as the built-in admin user. +adminTest.describe('Header: App menu (waffle launcher) – admin', () => { + adminTest('shows the "More apps" tile for admins', async ({ page }) => { + await page.goto('/') + const navigationHeader = new NavigationHeaderPage(page) + await navigationHeader.openMenu() + + await expect(navigationHeader.popover()).toBeVisible() + await expect(navigationHeader.popover().getByRole('menuitem', { name: 'More apps' })).toBeVisible() + }) + + adminTest('has the correct app navigation items', async ({ page }) => { + await page.goto('/') + const navigationHeader = new NavigationHeaderPage(page) + await expectWaffleMenuContainsApps(navigationHeader, [ + { name: 'Files', href: '/apps/files' }, + { name: 'Dashboard', href: '/apps/dashboard' }, + { name: 'Appstore', href: '/settings/apps' }, + ]) + }) +}) + +/** + * Open the waffle menu and assert that each expected app is present + * with a matching name and href. + */ +async function expectWaffleMenuContainsApps( + navigationHeader: NavigationHeaderPage, + apps: Array<{ name: string; href: string }>, +): Promise { + await navigationHeader.openMenu() + await expect(navigationHeader.popover()).toBeVisible() + + for (const app of apps) { + const entry = navigationHeader.navigationEntries().filter({ hasText: app.name }) + await expect(entry).toBeVisible() + const href = await entry.getAttribute('href') + // href may include a query string or a trailing slash + expect(href).toMatch(new RegExp(`${app.href}(\\?.+|/?$)`)) + } +} diff --git a/tests/playwright/e2e/core/header-contacts-menu.spec.ts b/tests/playwright/e2e/core/header-contacts-menu.spec.ts new file mode 100644 index 0000000000000..04eedcb9492eb --- /dev/null +++ b/tests/playwright/e2e/core/header-contacts-menu.spec.ts @@ -0,0 +1,122 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { createRandomUser } from '@nextcloud/e2e-test-server/playwright' +import { expect } from '@playwright/test' +import { test as adminTest } from '../../support/fixtures/admin-session.ts' +import { ContactsMenuPage } from '../../support/sections/ContactsMenuPage.ts' + +type ContactsFixtures = { contactUser: User } + +// Extend the admin session with a fresh random user available as `contactUser`. +// The user enumeration config is also reset to the permissive default here so +// that tests that modify it cannot bleed across runs. +const test = adminTest.extend({ + contactUser: async ({}, use) => { + await runOcc(['config:app:delete', 'core', 'shareapi_restrict_user_enumeration_to_group']) + const user = await createRandomUser() + await use(user) + await runOcc(['user:delete', user.userId]) + }, +}) + +// The restriction test toggles a global OCC config. Serial mode prevents +// parallel tests from racing on that setting. +test.describe.configure({ mode: 'serial' }) + +test.describe('Header: Contacts menu', () => { + test('other users are seen in the contacts menu', async ({ page, contactUser }) => { + await page.goto('/') + const contactsMenu = new ContactsMenuPage(page) + await contactsMenu.open() + + await expect(contactsMenu.contact(contactUser.userId)).toBeVisible() + // The logged-in admin must not appear in their own contacts list. + await expect(contactsMenu.contact('admin')).toHaveCount(0) + }) + + test('just-added users are seen in the contacts menu', async ({ page, contactUser }) => { + // Create a second user directly in the test body; clean up with try/finally. + const extraUser = await createRandomUser() + try { + await page.goto('/') + const contactsMenu = new ContactsMenuPage(page) + await contactsMenu.open() + + await expect(contactsMenu.contact(contactUser.userId)).toBeVisible() + await expect(contactsMenu.contact(extraUser.userId)).toBeVisible() + await expect(contactsMenu.contact('admin')).toHaveCount(0) + } finally { + await runOcc(['user:delete', extraUser.userId]) + } + }) + + test('search filters the contact list', async ({ page, contactUser }) => { + const otherUser = await createRandomUser() + try { + await page.goto('/') + const contactsMenu = new ContactsMenuPage(page) + await contactsMenu.open() + + // Both users visible before searching. + await expect(contactsMenu.contact(contactUser.userId)).toBeVisible() + await expect(contactsMenu.contact(otherUser.userId)).toBeVisible() + + // Searching for otherUser hides contactUser. + await contactsMenu.search(otherUser.userId) + await expect(contactsMenu.contact(otherUser.userId)).toBeVisible() + await expect(contactsMenu.contact(contactUser.userId)).toHaveCount(0) + await expect(contactsMenu.contact('admin')).toHaveCount(0) + } finally { + await runOcc(['user:delete', otherUser.userId]) + } + }) + + test('searching for an unknown user shows no results', async ({ page, contactUser }) => { + await page.goto('/') + const contactsMenu = new ContactsMenuPage(page) + await contactsMenu.open() + + await expect(contactsMenu.contact(contactUser.userId)).toBeVisible() + + await contactsMenu.search('surely-unknown-user') + + // NcEmptyContent renders the "name" prop as a heading. + await expect(page.getByText('No contacts found', { exact: true })).toBeVisible() + await expect(contactsMenu.contact(contactUser.userId)).toHaveCount(0) + await expect(contactsMenu.contact('admin')).toHaveCount(0) + }) + + test('users from other groups are not seen when user enumeration is restricted to the same group', async ({ page, contactUser }) => { + // Enable restriction first, then open the menu. + await runOcc(['config:app:set', '--value', 'yes', 'core', 'shareapi_restrict_user_enumeration_to_group']) + await new Promise((resolve) => globalThis.setTimeout(resolve, 3000)) // wait for app config cache to expire + try { + await page.goto('/') + const contactsMenu = new ContactsMenuPage(page) + await contactsMenu.open() + + // contactUser is in no group shared with admin → hidden. + await expect(contactsMenu.contact(contactUser.userId)).toHaveCount(0) + await expect(contactsMenu.contact('admin')).toHaveCount(0) + + // Close, lift the restriction, reopen — the contact should reappear. + await runOcc(['config:app:set', '--value', 'no', 'core', 'shareapi_restrict_user_enumeration_to_group']) + const waitForAppConfigCacheTTL = new Promise((resolve) => globalThis.setTimeout(resolve, 3000)) // wait for app config cache to expire + await contactsMenu.close() + await waitForAppConfigCacheTTL + + await page.reload() + await contactsMenu.open() + + await expect(contactsMenu.contact(contactUser.userId)).toBeVisible() + await expect(contactsMenu.contact('admin')).toHaveCount(0) + } finally { + await runOcc(['config:app:delete', 'core', 'shareapi_restrict_user_enumeration_to_group']) + } + }) +}) diff --git a/tests/playwright/support/sections/AccountMenuPage.ts b/tests/playwright/support/sections/AccountMenuPage.ts new file mode 100644 index 0000000000000..c3f255f0746cf --- /dev/null +++ b/tests/playwright/support/sections/AccountMenuPage.ts @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Locator, Page } from '@playwright/test' + +/** + * The "Settings menu" (account / user menu) in the Nextcloud header bar. + * Rendered by AccountMenu.vue using NcHeaderMenu (id="user-menu", is-nav). + * + * Each entry is a NcListItem rendered as an
  • inside + *