test(core): migrate end-to-end test to PlayWright

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2026-06-11 20:39:49 +02:00
parent b49ea3596e
commit 5fd406c784
No known key found for this signature in database
GPG key ID: 7E849AE05218500F
12 changed files with 414 additions and 350 deletions

View file

@ -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:

View file

@ -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 }}

View file

@ -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$/)
})
})

View file

@ -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')
})
})
})

View file

@ -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}(\\?.+|/?$)`))
})
})
}

View file

@ -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')
})
})

View file

@ -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$/)
})
})

View file

@ -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()
})
})

View file

@ -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<void> {
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}(\\?.+|/?$)`))
}
}

View file

@ -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<ContactsFixtures>({
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'])
}
})
})

View file

@ -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 <li> inside
* <ul class="account-menu__list">. The entry names ("View profile",
* "Log out", ) are visible text inside those items.
*/
export class AccountMenuPage {
constructor(private readonly page: Page) {}
private trigger(): Locator {
// NcHeaderMenu trigger button inside the #user-menu nav.
// The button's accessible name includes the dynamic avatar description
// ("Avatar of {displayName} — {status}"), so we scope by the stable
// container ID and the BEM class instead — the same approach used in
// NavigationHeaderPage for the waffle button.
return this.page.locator('header#header').locator('#user-menu .header-menu__trigger')
}
private panel(): Locator {
// NcHeaderMenu generates a content div with id="header-menu-{id}".
return this.page.locator('#header-menu-user-menu')
}
/** Open the settings menu and wait until the panel is visible. */
async open(): Promise<void> {
const isOpen = await this.trigger().getAttribute('aria-expanded') === 'true'
if (!isOpen) await this.trigger().click()
await this.panel().waitFor({ state: 'visible' })
}
/**
* All <li> entries currently shown in the panel.
* Use with toHaveCount() to assert the total number of menu items.
*/
entries(): Locator {
return this.panel().getByRole('listitem')
}
/**
* A single entry matched by visible text.
* Uses filter({ hasText }) so it works for both the primary name and
* the subname slot (e.g. the profile entry whose link label is the
* user's display name, while "View profile" appears as a subname).
*/
entry(name: string): Locator {
return this.panel().getByRole('listitem').filter({ hasText: name })
}
}

View file

@ -0,0 +1,70 @@
/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Locator, Page } from '@playwright/test'
/**
* The contacts / people menu in the Nextcloud header bar.
* Rendered by ContactsMenu.vue using NcHeaderMenu (id="contactsmenu").
*
* Contact entries are <li class="contact"> elements inside
* <ul aria-label="Contacts list">. When no contacts match the search,
* that list is absent from the DOM (v-else path) and NcEmptyContent is
* shown instead.
*/
export class ContactsMenuPage {
constructor(private readonly page: Page) {}
private trigger(): Locator {
// NcHeaderMenu passes its ariaLabel prop ("Search contacts") to the
// trigger NcButton, so the button is uniquely identifiable by role+name.
return this.page.locator('header#header').getByRole('button', { name: 'Search contacts' })
}
private panel(): Locator {
// NcHeaderMenu generates a content container with id="header-menu-{id}".
return this.page.locator('#header-menu-contactsmenu')
}
private contactsList(): Locator {
return this.page.getByRole('list', { name: 'Contacts list' })
}
/** Open the menu and wait for the initial contacts fetch to complete. */
async open(): Promise<void> {
// Register waitForResponse BEFORE clicking to avoid the race condition
// described in the migration context.
const loaded = this.page.waitForResponse(
(r) => r.url().includes('/contactsmenu/contacts') && r.request().method() === 'POST',
)
await this.trigger().click()
await loaded
}
/** Close the menu. */
async close(): Promise<void> {
const isOpen = await this.trigger().getAttribute('aria-expanded') === 'true'
if (isOpen) await this.trigger().click()
await this.panel().waitFor({ state: 'hidden' })
}
/**
* A contact entry matched by the user's userId / display name.
* Returns the <li> element containing that text. Returns an empty
* locator (count 0) when the contacts list is absent from the DOM.
*/
contact(userId: string): Locator {
return this.contactsList().getByRole('listitem').filter({ hasText: userId })
}
/**
* Fill the search input and wait for the server response.
* The input is debounced by 500 ms inside the component, so
* a network request always follows a fill().
*/
async search(query: string): Promise<void> {
await this.page.getByRole('searchbox', { name: 'Search contacts' }).fill(query)
}
}