From 1d7419fd521d3d8300f35652a4c0ea84df2f3726 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 12 Jun 2026 01:08:10 +0200 Subject: [PATCH] test(login): migrate end-to-end tests to PlayWright Signed-off-by: Ferdinand Thiessen --- cypress/e2e/login/login-redirect.cy.ts | 60 ------- cypress/e2e/login/login.cy.ts | 153 ------------------ cypress/e2e/login/webauth.cy.ts | 152 ----------------- .../e2e/login/login-redirect.spec.ts | 52 ++++++ tests/playwright/e2e/login/login.spec.ts | 87 ++++++++++ tests/playwright/e2e/login/webauth.spec.ts | 130 +++++++++++++++ .../support/sections/AccountMenuPage.ts | 20 +-- .../playwright/support/sections/LoginPage.ts | 33 ++++ 8 files changed, 306 insertions(+), 381 deletions(-) delete mode 100644 cypress/e2e/login/login-redirect.cy.ts delete mode 100644 cypress/e2e/login/login.cy.ts delete mode 100644 cypress/e2e/login/webauth.cy.ts create mode 100644 tests/playwright/e2e/login/login-redirect.spec.ts create mode 100644 tests/playwright/e2e/login/login.spec.ts create mode 100644 tests/playwright/e2e/login/webauth.spec.ts create mode 100644 tests/playwright/support/sections/LoginPage.ts diff --git a/cypress/e2e/login/login-redirect.cy.ts b/cypress/e2e/login/login-redirect.cy.ts deleted file mode 100644 index 0c7db13ad6b..00000000000 --- a/cypress/e2e/login/login-redirect.cy.ts +++ /dev/null @@ -1,60 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -/** - * Test that when a session expires / the user logged out in another tab, - * the user gets redirected to the login on the next request. - */ -describe('Logout redirect ', { testIsolation: true }, () => { - let user - - before(() => { - cy.createRandomUser() - .then(($user) => { - user = $user - }) - }) - - it('Redirects to login if session timed out', () => { - // Login and see settings - cy.login(user) - cy.visit('/settings/user#profile') - cy.findByRole('checkbox', { name: /Enable profile/i }) - .should('exist') - - // clear session - cy.clearAllCookies() - - // trigger an request - cy.findByRole('checkbox', { name: /Enable profile/i }) - .click({ force: true }) - - // See that we are redirected - cy.url() - .should('match', /\/login/i) - .and('include', `?redirect_url=${encodeURIComponent('/index.php/settings/user#profile')}`) - - cy.get('form[name="login"]').should('be.visible') - }) - - it('Redirect from login works', () => { - cy.logout() - // visit the login - cy.visit(`/login?redirect_url=${encodeURIComponent('/index.php/settings/user#profile')}`) - - // see login - cy.get('form[name="login"]').should('be.visible') - cy.get('form[name="login"]').within(() => { - cy.get('input[name="user"]').type(user.userId) - cy.get('input[name="password"]').type(user.password) - cy.contains('button[data-login-form-submit]', 'Log in').click() - }) - - // see that we are correctly redirected - cy.url().should('include', '/index.php/settings/user#profile') - cy.findByRole('checkbox', { name: /Enable profile/i }) - .should('exist') - }) -}) diff --git a/cypress/e2e/login/login.cy.ts b/cypress/e2e/login/login.cy.ts deleted file mode 100644 index 09b871792e4..00000000000 --- a/cypress/e2e/login/login.cy.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { getNextcloudUserMenu, getNextcloudUserMenuToggle } from '../../support/commonUtils.ts' - -describe('Login', () => { - let user: User - let disabledUser: User - - after(() => cy.deleteUser(user)) - before(() => { - // disable brute force protection - cy.runOccCommand('config:system:set auth.bruteforce.protection.enabled --value false --type bool') - cy.createRandomUser().then(($user) => { - user = $user - }) - cy.createRandomUser().then(($user) => { - disabledUser = $user - cy.runOccCommand(`user:disable '${disabledUser.userId}'`) - }) - }) - - beforeEach(() => { - cy.logout() - }) - - it('log in with valid account and password', () => { - // Given I visit the Home page - cy.visit('/') - // I see the login page - cy.get('form[name="login"]').should('be.visible') - // I log in with a valid account - cy.get('form[name="login"]').within(() => { - cy.get('input[name="user"]').type(user.userId) - cy.get('input[name="password"]').type(user.password) - cy.contains('button[data-login-form-submit]', 'Log in').click() - }) - - // see that the login is done - cy.get('[data-login-form-submit]').if().should('not.contain', 'Logging in') - - // Then I see that the current page is the Files app - cy.url().should('match', /apps\/dashboard(\/|$)/) - }) - - it('try to log in with valid account and invalid password', () => { - // Given I visit the Home page - cy.visit('/') - // I see the login page - cy.get('form[name="login"]').should('be.visible') - // I log in with a valid account but invalid password - cy.get('form[name="login"]').within(() => { - cy.get('input[name="user"]').type(user.userId) - cy.get('input[name="password"]').type(`${user.password}--wrong`) - cy.contains('button', 'Log in').click() - }) - - // see that the login is done - cy.get('[data-login-form-submit]').if().should('not.contain', 'Logging in') - - // Then I see that the current page is the Login page - cy.url().should('match', /\/login/) - // And I see that a wrong password message is shown - cy.get('form[name="login"]').then(($el) => expect($el.text()).to.match(/Wrong.+password/i)) - cy.get('input[name="password"]:invalid').should('exist') - }) - - it('try to log in with valid account and invalid password', () => { - // Given I visit the Home page - cy.visit('/') - // I see the login page - cy.get('form[name="login"]').should('be.visible') - // I log in with a valid account but invalid password - cy.get('form[name="login"]').within(() => { - cy.get('input[name="user"]').type(user.userId) - cy.get('input[name="password"]').type(`${user.password}--wrong`) - cy.contains('button', 'Log in').click() - }) - - // see that the login is done - cy.get('[data-login-form-submit]').if().should('not.contain', 'Logging in') - - // Then I see that the current page is the Login page - cy.url().should('match', /\/login/) - // And I see that a wrong password message is shown - cy.get('form[name="login"]').then(($el) => expect($el.text()).to.match(/Wrong.+password/i).and.to.match(/Wrong.+login/)) - cy.get('input[name="password"]:invalid').should('exist') - }) - - it('try to log in with invalid account', () => { - // Given I visit the Home page - cy.visit('/') - // I see the login page - cy.get('form[name="login"]').should('be.visible') - // I log in with an invalid user but valid password - cy.get('form[name="login"]').within(() => { - cy.get('input[name="user"]').type(`${user.userId}--wrong`) - cy.get('input[name="password"]').type(user.password) - cy.contains('button', 'Log in').click() - }) - - // see that the login is done - cy.get('[data-login-form-submit]').if().should('not.contain', 'Logging in') - - // Then I see that the current page is the Login page - cy.url().should('match', /\/login/) - // And I see that a wrong password message is shown - cy.get('form[name="login"]').then(($el) => expect($el.text()).to.match(/Wrong.+password/i).and.to.match(/Wrong.+login/)) - cy.get('input[name="password"]:invalid').should('exist') - }) - - it('try to log in as disabled account', () => { - // Given I visit the Home page - cy.visit('/') - // I see the login page - cy.get('form[name="login"]').should('be.visible') - // When I log in with user disabledUser and password - cy.get('form[name="login"]').within(() => { - cy.get('input[name="user"]').type(disabledUser.userId) - cy.get('input[name="password"]').type(disabledUser.password) - cy.contains('button', 'Log in').click() - }) - - // see that the login is done - cy.get('[data-login-form-submit]').if().should('not.contain', 'Logging in') - - // Then I see that the current page is the Login page - cy.url().should('match', /\/login/) - // And I see that the disabled account message is shown - cy.get('form[name="login"]').then(($el) => expect($el.text()).to.match(/Account.+disabled/i)) - cy.get('input[name="password"]:invalid').should('exist') - }) - - it('try to logout', () => { - cy.login(user) - - // Given I visit the Home page - cy.visit('/') - // I see the dashboard - cy.url().should('match', /apps\/dashboard(\/|$)/) - - // When click logout - getNextcloudUserMenuToggle().should('exist').click() - getNextcloudUserMenu().contains('a', 'Log out').click() - - // Then I see that the current page is the Login page - cy.url().should('match', /\/login/) - }) -}) diff --git a/cypress/e2e/login/webauth.cy.ts b/cypress/e2e/login/webauth.cy.ts deleted file mode 100644 index 4d8d3acc20a..00000000000 --- a/cypress/e2e/login/webauth.cy.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -interface IChromeVirtualAuthenticator { - authenticatorId: string -} - -/** - * Create a virtual authenticator using chrome debug protocol - */ -async function createAuthenticator(): Promise { - await Cypress.automation('remote:debugger:protocol', { - command: 'WebAuthn.enable', - }) - const authenticator = await Cypress.automation('remote:debugger:protocol', { - command: 'WebAuthn.addVirtualAuthenticator', - params: { - options: { - protocol: 'ctap2', - ctap2Version: 'ctap2_1', - hasUserVerification: true, - transport: 'usb', - automaticPresenceSimulation: true, - isUserVerified: true, - }, - }, - }) - return authenticator -} - -/** - * Delete a virtual authenticator using chrome devbug protocol - * - * @param authenticator the authenticator object - */ -async function deleteAuthenticator(authenticator: IChromeVirtualAuthenticator) { - await Cypress.automation('remote:debugger:protocol', { - command: 'WebAuthn.removeVirtualAuthenticator', - params: { - ...authenticator, - }, - }) -} - -describe('Login using WebAuthn', () => { - let authenticator: IChromeVirtualAuthenticator - let user: User - - afterEach(() => { - cy.deleteUser(user) - .then(() => deleteAuthenticator(authenticator)) - }) - - beforeEach(() => { - cy.createRandomUser() - .then(($user) => { - user = $user - cy.login(user) - }) - .then(() => createAuthenticator()) - .then(($authenticator) => { - authenticator = $authenticator - cy.log('Created virtual authenticator') - }) - }) - - it('add and delete WebAuthn', () => { - cy.intercept('**/settings/api/personal/webauthn/registration').as('webauthn') - cy.visit('/settings/user/security') - - cy.contains('[role="note"]', /No devices configured/i).should('be.visible') - - cy.findByRole('button', { name: /Add WebAuthn device/i }) - .should('be.visible') - .click() - - cy.wait('@webauthn') - - cy.findByRole('textbox', { name: /Device name/i }) - .should('be.visible') - .type('test device{enter}') - - cy.wait('@webauthn') - - cy.contains('[role="note"]', /No devices configured/i).should('not.exist') - - cy.findByRole('list', { name: /following devices are configured for your account/i }) - .should('be.visible') - .contains('li', 'test device') - .should('be.visible') - .findByRole('button', { name: /Actions/i }) - .click() - - cy.findByRole('menuitem', { name: /Delete/i }) - .should('be.visible') - .click() - - cy.contains('[role="note"]', /No devices configured/i).should('be.visible') - cy.findByRole('list', { name: /following devices are configured for your account/i }) - .should('not.exist') - - cy.reload() - cy.contains('[role="note"]', /No devices configured/i).should('be.visible') - }) - - it('add WebAuthn and login', () => { - cy.intercept('GET', '**/settings/api/personal/webauthn/registration').as('webauthnSetupInit') - cy.intercept('POST', '**/settings/api/personal/webauthn/registration').as('webauthnSetupDone') - cy.intercept('POST', '**/login/webauthn/start').as('webauthnLogin') - - cy.visit('/settings/user/security') - - cy.findByRole('button', { name: /Add WebAuthn device/i }) - .should('be.visible') - .click() - cy.wait('@webauthnSetupInit') - - cy.findByRole('textbox', { name: /Device name/i }) - .should('be.visible') - .type('test device{enter}') - cy.wait('@webauthnSetupDone') - - cy.findByRole('list', { name: /following devices are configured for your account/i }) - .should('be.visible') - .findByText('test device') - .should('be.visible') - - cy.logout() - cy.visit('/login') - - cy.findByRole('button', { name: /Log in with a device/i }) - .should('be.visible') - .click() - - cy.findByRole('form', { name: /Log in with a device/i }) - .should('be.visible') - .findByRole('textbox', { name: /Login or email/i }) - .should('be.visible') - .type(`{selectAll}${user.userId}`) - - cy.findByRole('button', { name: /Log in/i }) - .click() - cy.wait('@webauthnLogin') - - // Then I see that the current page is the Files app - cy.url().should('match', /apps\/dashboard(\/|$)/) - }) -}) diff --git a/tests/playwright/e2e/login/login-redirect.spec.ts b/tests/playwright/e2e/login/login-redirect.spec.ts new file mode 100644 index 00000000000..eb493a70c44 --- /dev/null +++ b/tests/playwright/e2e/login/login-redirect.spec.ts @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '@playwright/test' +import { User } from '@nextcloud/e2e-test-server' +import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright' +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { LoginPage } from '../../support/sections/LoginPage.ts' + +test.describe('Login: Redirect', () => { + let user: User + + test.beforeAll(async () => { + user = await createRandomUser() + }) + + test.afterAll(async () => { + await runOcc(['user:delete', user.userId]) + }) + + test('redirects to login with redirect_url when session expires', async ({ page, context }) => { + await login(context.request, user) + await page.goto('/settings/user#profile') + + // Wait for the profile settings checkbox to confirm the page has loaded + await expect(page.getByRole('checkbox', { name: /Enable profile/i })).toBeVisible() + + // Simulate session expiry by clearing all cookies + await context.clearCookies() + + // Clicking the checkbox triggers an authenticated request that returns 302 to login + await page.getByRole('checkbox', { name: /Enable profile/i }).click({ force: true }) + + await expect(page).toHaveURL(/\/login/i) + await expect(page).toHaveURL(/redirect_url=/) + }) + + test('redirect_url parameter redirects to the original page after login', async ({ page }) => { + const redirectTarget = 'settings/user#profile' + await page.goto(redirectTarget) + await expect(page).toHaveURL(new RegExp(`/login\\?redirect_url=(\/index.php\/)?${redirectTarget}`)) + + const loginPage = new LoginPage(page) + await expect(loginPage.usernameInput()).toBeVisible() + await loginPage.login(user.userId, user.password) + + await expect(page).toHaveURL(/\/settings\/user/) + await expect(page.getByRole('checkbox', { name: /Enable profile/i })).toBeVisible() + }) +}) diff --git a/tests/playwright/e2e/login/login.spec.ts b/tests/playwright/e2e/login/login.spec.ts new file mode 100644 index 00000000000..ed95febabc5 --- /dev/null +++ b/tests/playwright/e2e/login/login.spec.ts @@ -0,0 +1,87 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test as baseTest } from '@playwright/test' +import { User } from '@nextcloud/e2e-test-server' +import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright' +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { AccountMenuPage } from '../../support/sections/AccountMenuPage.ts' +import { LoginPage } from '../../support/sections/LoginPage.ts' + +const test = baseTest.extend<{ + user: User + disabledUser: User +}>({ + user: async ({}, use) => { + const user = await createRandomUser() + await use(user) + await runOcc(['user:delete', user.userId]) + }, + disabledUser: async ({}, use) => { + const user = await createRandomUser() + await runOcc(['user:disable', user.userId]) + await use(user) + await runOcc(['user:delete', user.userId]) + }, +}) + +test.describe('Login', () => { + test.beforeAll(async () => { + await runOcc(['config:system:set', 'auth.bruteforce.protection.enabled', '--value', 'false', '--type', 'bool']) + }) + + test.afterAll(async () => { + await runOcc(['config:system:delete', 'auth.bruteforce.protection.enabled']) + }) + + test('successful login lands on the dashboard', async ({ page, user }) => { + const loginPage = new LoginPage(page) + await loginPage.goto() + await loginPage.login(user.userId, user.password) + + await expect(page).toHaveURL(/apps\/dashboard(\/|$)/) + }) + + test('wrong password shows error and marks password field invalid', async ({ page, user }) => { + const loginPage = new LoginPage(page) + await loginPage.goto() + await loginPage.login(user.userId, `${user.password}--wrong`) + + await expect(page).toHaveURL(/\/login/) + await expect(page.getByText(/Wrong login or password/i)).toBeVisible() + await expect(loginPage.passwordInput().and(page.locator(':invalid'))).toHaveCount(1) + }) + + test('wrong account name shows error and marks password field invalid', async ({ page, user }) => { + const loginPage = new LoginPage(page) + await loginPage.goto() + await loginPage.login(`${user.userId}--wrong`, user.password) + + await expect(page).toHaveURL(/\/login/) + await expect(page.getByText(/Wrong login or password/i)).toBeVisible() + await expect(loginPage.passwordInput().and(page.locator(':invalid'))).toHaveCount(1) + }) + + test('disabled account shows disabled error', async ({ page, disabledUser }) => { + const loginPage = new LoginPage(page) + await loginPage.goto() + await loginPage.login(disabledUser.userId, disabledUser.password) + + await expect(page).toHaveURL(/\/login/) + await expect(page.getByText(/Account.*disabled/i)).toBeVisible() + await expect(loginPage.passwordInput().and(page.locator(':invalid'))).toHaveCount(1) + }) + + test('logout redirects to the login page', async ({ page, context, user }) => { + await login(context.request, user) + await page.goto('/') + + const accountMenu = new AccountMenuPage(page) + await accountMenu.open() + await accountMenu.entry('Log out').getByRole('link').click() + + await expect(page).toHaveURL(/\/login($|\?)/) + }) +}) diff --git a/tests/playwright/e2e/login/webauth.spec.ts b/tests/playwright/e2e/login/webauth.spec.ts new file mode 100644 index 00000000000..ff16027bc44 --- /dev/null +++ b/tests/playwright/e2e/login/webauth.spec.ts @@ -0,0 +1,130 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test, type BrowserContext } from '@playwright/test' +import { User } from '@nextcloud/e2e-test-server' +import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright' +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { handlePasswordConfirmation } from '../../support/utils/password-confirmation.ts' + +test.describe('Login: WebAuthn', () => { + test.skip(({ browserName }) => browserName !== 'chromium', 'WebAuthn emulator only is supported in Chromium-based browsers') + + let user: User + let cdpSession: Awaited> + let authenticatorId: string + + test.beforeEach(async ({ page, context }) => { + user = await createRandomUser() + await login(context.request, user) + + cdpSession = await page.context().newCDPSession(page) + await cdpSession.send('WebAuthn.enable', { enableUI: false }) + const result = await cdpSession.send('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: 'ctap2', + ctap2Version: 'ctap2_1', + hasUserVerification: true, + transport: 'usb', + automaticPresenceSimulation: true, + isUserVerified: true, + }, + }) + authenticatorId = result.authenticatorId + }) + + test.afterEach(async () => { + await cdpSession.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId }) + await runOcc(['user:delete', user.userId]) + }) + + test('add and delete a WebAuthn device', async ({ page }) => { + const registrationChallenge = page.waitForResponse( + (r) => r.url().includes('/settings/api/personal/webauthn/registration'), + ) + await page.goto('/settings/user/security') + + const securitySection = page.locator('#security-webauthn') + await expect( + securitySection.getByRole('note').filter({ hasText: /No devices configured/i }), + ).toBeVisible() + + await page.getByRole('button', { name: /Add WebAuthn device/i }).click() + await handlePasswordConfirmation(page, user.password) + await registrationChallenge + + const deviceNameInput = page.getByLabel('Device name') + await expect(deviceNameInput).toBeVisible() + + const registrationComplete = page.waitForResponse( + (r) => r.url().includes('/settings/api/personal/webauthn/registration'), + ) + await deviceNameInput.fill('test device') + await deviceNameInput.press('Enter') + await registrationComplete + + const deviceList = page.getByRole('list', { name: /following devices/i }) + await expect(deviceList).toBeVisible() + const deviceItem = deviceList.getByRole('listitem').filter({ hasText: 'test device' }) + await expect(deviceItem).toBeVisible() + + await deviceItem.getByRole('button', { name: 'Actions' }).click() + await handlePasswordConfirmation(page, user.password) + await page.getByRole('menuitem', { name: 'Delete' }).click() + await handlePasswordConfirmation(page, user.password) + + await expect( + securitySection.getByRole('note').filter({ hasText: /No devices configured/i }), + ).toBeVisible() + await expect(deviceList).toHaveCount(0) + + await page.reload() + await expect( + securitySection.getByRole('note').filter({ hasText: /No devices configured/i }), + ).toBeVisible() + }) + + test('add a WebAuthn device and use it to log in', async ({ page, context }) => { + const registrationChallenge = page.waitForResponse( + (r) => r.url().includes('/settings/api/personal/webauthn/registration') && r.request().method() === 'GET', + ) + await page.goto('/settings/user/security') + + await page.getByRole('button', { name: /Add WebAuthn device/i }).click() + await handlePasswordConfirmation(page, user.password) + await registrationChallenge + + const registrationComplete = page.waitForResponse( + (r) => r.url().includes('/settings/api/personal/webauthn/registration') && r.request().method() === 'POST', + ) + const deviceNameInput = page.getByLabel('Device name') + await deviceNameInput.fill('test device') + await deviceNameInput.press('Enter') + await registrationComplete + + const deviceList = page.getByRole('list', { name: /following devices/i }) + await expect(deviceList.getByRole('listitem').filter({ hasText: 'test device' })).toBeVisible() + + // Log out and return to the login page + await context.clearCookies() + await page.goto('/login') + + // Switch to passwordless login form + await page.getByRole('button', { name: /Log in with a device/i }).click() + + const passwordlessForm = page.getByRole('form', { name: /Log in with a device/i }) + await expect(passwordlessForm).toBeVisible() + + await passwordlessForm.getByLabel('Login or email').fill(user.userId) + + const webauthnLogin = page.waitForResponse( + (r) => r.url().includes('/login/webauthn/start') && r.request().method() === 'POST', + ) + await page.getByRole('button', { name: 'Log in' }).click() + await webauthnLogin + + await expect(page).toHaveURL(/apps\/dashboard(\/|$)/) + }) +}) diff --git a/tests/playwright/support/sections/AccountMenuPage.ts b/tests/playwright/support/sections/AccountMenuPage.ts index c3f255f0746..b6e532a2e47 100644 --- a/tests/playwright/support/sections/AccountMenuPage.ts +++ b/tests/playwright/support/sections/AccountMenuPage.ts @@ -17,39 +17,27 @@ 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') + return this.page.getByRole('button', { name: 'Settings menu' }) } 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 { const isOpen = await this.trigger().getAttribute('aria-expanded') === 'true' - if (!isOpen) await this.trigger().click() + if (!isOpen) { + await this.trigger().click() + } await this.panel().waitFor({ state: 'visible' }) } - /** - * All
  • 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 }) diff --git a/tests/playwright/support/sections/LoginPage.ts b/tests/playwright/support/sections/LoginPage.ts new file mode 100644 index 00000000000..c4333afb152 --- /dev/null +++ b/tests/playwright/support/sections/LoginPage.ts @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Locator, Page } from '@playwright/test' + +export class LoginPage { + constructor(private readonly page: Page) {} + + usernameInput(): Locator { + // Label is "Account name" or "Account name or email" depending on config + return this.page.getByLabel(/Account name/) + } + + passwordInput(): Locator { + return this.page.getByLabel('Password').and(this.page.locator('input')) + } + + submitButton(): Locator { + return this.page.getByRole('button', { name: 'Log in', exact: true }) + } + + async goto(): Promise { + await this.page.goto('/login') + } + + async login(userId: string, password: string): Promise { + await this.usernameInput().fill(userId) + await this.passwordInput().fill(password) + await this.submitButton().click() + } +}