mirror of
https://github.com/nextcloud/server.git
synced 2026-06-13 18:50:47 -04:00
test(core): migrate end-to-end test to PlayWright
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
parent
b49ea3596e
commit
5fd406c784
12 changed files with 414 additions and 350 deletions
4
.github/workflows/cypress.yml
vendored
4
.github/workflows/cypress.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
4
.github/workflows/playwright.yml
vendored
4
.github/workflows/playwright.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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$/)
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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}(\\?.+|/?$)`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
21
tests/playwright/e2e/core/404-error.spec.ts
Normal file
21
tests/playwright/e2e/core/404-error.spec.ts
Normal 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$/)
|
||||
})
|
||||
})
|
||||
60
tests/playwright/e2e/core/header-access-levels.spec.ts
Normal file
60
tests/playwright/e2e/core/header-access-levels.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
80
tests/playwright/e2e/core/header-app-menu.spec.ts
Normal file
80
tests/playwright/e2e/core/header-app-menu.spec.ts
Normal 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}(\\?.+|/?$)`))
|
||||
}
|
||||
}
|
||||
122
tests/playwright/e2e/core/header-contacts-menu.spec.ts
Normal file
122
tests/playwright/e2e/core/header-contacts-menu.spec.ts
Normal 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'])
|
||||
}
|
||||
})
|
||||
})
|
||||
57
tests/playwright/support/sections/AccountMenuPage.ts
Normal file
57
tests/playwright/support/sections/AccountMenuPage.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
70
tests/playwright/support/sections/ContactsMenuPage.ts
Normal file
70
tests/playwright/support/sections/ContactsMenuPage.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue