diff --git a/playwright/e2e/form-settings.spec.ts b/playwright/e2e/form-settings.spec.ts new file mode 100644 index 000000000..3600dece5 --- /dev/null +++ b/playwright/e2e/form-settings.spec.ts @@ -0,0 +1,114 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, mergeTests } from '@playwright/test' +import { test as formTest } from '../support/fixtures/form' +import { test as appNavigationTest } from '../support/fixtures/navigation' +import { test as randomUserTest } from '../support/fixtures/random-user' +import { test as submitTest } from '../support/fixtures/submit' +import { test as topBarTest } from '../support/fixtures/topBar' +import { waitForApiResponse } from '../support/helpers' +import { QuestionType } from '../support/sections/QuestionType' +import { FormsView } from '../support/sections/TopBarSection' + +const test = mergeTests( + randomUserTest, + appNavigationTest, + formTest, + topBarTest, + submitTest, +) + +test.describe('Form settings', () => { + // Setup: create a form with one question, open the Settings sidebar tab + test.beforeEach(async ({ page, appNavigation, form }) => { + await page.goto('apps/forms') + await page.waitForURL(/apps\/forms\/?$/) + await appNavigation.clickNewForm() + await form.fillTitle('Settings test form') + + await form.addQuestion(QuestionType.ShortAnswer) + const questions = await form.getQuestions() + await questions[0].fillTitle('Your answer') + + // Open sidebar and switch to Settings tab + await page.getByRole('button', { name: /Share/ }).click() + const settingsTab = page.getByRole('tab', { name: /Settings/ }) + await settingsTab.click() + await expect( + page.getByRole('checkbox', { name: /Close form/ }), + ).toBeVisible() + }) + + test('Closing a form blocks submissions', async ({ page, topBar }) => { + const saved = waitForApiResponse(page, 'PATCH') + await page + .getByRole('checkbox', { name: /Close form/ }) + .click({ force: true }) + await saved + + await topBar.toggleView(FormsView.View) + + // NcEmptyContent renders with role="note" — scope to main to avoid + // matching the "Form closed" status text in the sidebar navigation. + const main = page.getByRole('main') + await expect(main.getByText('Form closed')).toBeVisible() + await expect( + main.getByText('This form was closed and is no longer taking responses'), + ).toBeVisible() + }) + + test('Reopening a closed form allows submissions', async ({ + page, + topBar, + submitView, + }) => { + // Close the form + const closed = waitForApiResponse(page, 'PATCH') + await page + .getByRole('checkbox', { name: /Close form/ }) + .click({ force: true }) + await closed + + // Reopen the form + const reopened = waitForApiResponse(page, 'PATCH') + await page + .getByRole('checkbox', { name: /Close form/ }) + .click({ force: true }) + await reopened + + await topBar.toggleView(FormsView.View) + + // Form should be accessible — questions visible and submit button present + await expect(submitView.submitButton).toBeVisible() + }) + + test('Anonymous mode shows anonymous message on submit view', async ({ + page, + topBar, + }) => { + const saved = waitForApiResponse(page, 'PATCH') + await page + .getByRole('checkbox', { name: /Store responses anonymously/ }) + .click({ force: true }) + await saved + + await topBar.toggleView(FormsView.View) + + await expect(page.getByText('Responses are anonymous.')).toBeVisible() + }) + + test('Non-anonymous mode shows account-connected message on edit view', async ({ + page, + }) => { + // The Create (edit) view always shows this message when anonymous + // is off, because the editor is always in a logged-in context. + // The Submit route doesn't receive isLoggedIn from the router, so + // the message only appears on the edit view. + await expect( + page.getByText('Responses are connected to your account.'), + ).toBeVisible() + }) +}) diff --git a/playwright/e2e/form-sharing.spec.ts b/playwright/e2e/form-sharing.spec.ts new file mode 100644 index 000000000..28bedfb1c --- /dev/null +++ b/playwright/e2e/form-sharing.spec.ts @@ -0,0 +1,86 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, mergeTests } from '@playwright/test' +import { test as randomUserTest } from '../support/fixtures/random-user' +import { test as appNavigationTest } from '../support/fixtures/navigation' +import { test as formTest } from '../support/fixtures/form' +import { QuestionType } from '../support/sections/QuestionType' +import { waitForApiResponse } from '../support/helpers' + +const test = mergeTests( + randomUserTest, + appNavigationTest, + formTest, +) + +test.describe('Form sharing', () => { + test.beforeEach(async ({ page, appNavigation, form }) => { + await page.goto('apps/forms') + await page.waitForURL(/apps\/forms\/?$/) + await appNavigation.clickNewForm() + await form.fillTitle('Sharing test form') + + await form.addQuestion(QuestionType.ShortAnswer) + const questions = await form.getQuestions() + await questions[0].fillTitle('Test question') + + // Open the sidebar via the Share button in the TopBar + await page.getByRole('button', { name: /Share/ }).click() + // Sidebar opens on the Sharing tab by default — wait for it + await expect( + page.getByRole('complementary').getByText('Share link'), + ).toBeVisible() + }) + + test('Add a public share link', async ({ page }) => { + // New forms start without a public link share. + // NcActions with a single child renders it as an inline button. + const shareLinkRow = page.locator('.share-div--link') + const addLinkButton = shareLinkRow.getByRole('button', { + name: /Add link/, + }) + await expect(addLinkButton).toBeVisible() + + const linkCreated = waitForApiResponse(page, 'POST') + await addLinkButton.click() + await linkCreated + + // After adding, the share link entry renders NcActions with :inline="1", + // so the first action ("Copy to clipboard") appears as an inline button. + await expect( + shareLinkRow.getByRole('link', { name: /Copy to clipboard/ }), + ).toBeVisible() + }) + + test('Remove a public share link', async ({ page }) => { + // First, add a link + const shareLinkRow = page.locator('.share-div--link') + + const linkCreated = waitForApiResponse(page, 'POST') + await shareLinkRow.getByRole('button', { name: /Add link/ }).click() + await linkCreated + + // The inline "Copy to clipboard" action should now be visible + await expect( + shareLinkRow.getByRole('link', { name: /Copy to clipboard/ }), + ).toBeVisible() + + // Open the overflow menu (the "Actions" toggle) to find "Remove link". + // NcActions :inline="1" renders the first action inline and puts the + // rest behind an overflow toggle button. + await shareLinkRow.getByRole('button', { name: /Actions/ }).click() + + const linkDeleted = waitForApiResponse(page, 'DELETE') + await page.getByRole('menuitem', { name: /Remove link/ }).click() + await linkDeleted + + // After removal, the share link row reverts to the "no link" state. + // NcActions collapses a single action to an inline button. + await expect( + shareLinkRow.getByRole('button', { name: /Add link/ }), + ).toBeVisible() + }) +}) diff --git a/playwright/e2e/question-editing.spec.ts b/playwright/e2e/question-editing.spec.ts new file mode 100644 index 000000000..a0c7b27c9 --- /dev/null +++ b/playwright/e2e/question-editing.spec.ts @@ -0,0 +1,109 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, mergeTests } from '@playwright/test' +import { test as randomUserTest } from '../support/fixtures/random-user' +import { test as appNavigationTest } from '../support/fixtures/navigation' +import { test as formTest } from '../support/fixtures/form' +import { QuestionType } from '../support/sections/QuestionType' + +const test = mergeTests(randomUserTest, appNavigationTest, formTest) + +test.describe('Question editing lifecycle', () => { + test.beforeEach(async ({ page, appNavigation, form }) => { + await page.goto('apps/forms') + await page.waitForURL(/apps\/forms\/?$/) + await appNavigation.clickNewForm() + await form.fillTitle('Editing test form') + }) + + const questionTypes = [ + QuestionType.ShortAnswer, + QuestionType.LongAnswer, + QuestionType.Checkboxes, + QuestionType.RadioButtons, + QuestionType.Dropdown, + QuestionType.Date, + QuestionType.LinearScale, + QuestionType.Color, + ] + + for (const type of questionTypes) { + test(`Add a ${type} question`, async ({ form }) => { + await form.addQuestion(type) + + const questions = await form.getQuestions() + expect(questions).toHaveLength(1) + await expect(questions[0].titleInput).toBeVisible() + }) + } + + test('Edit question title and description', async ({ form }) => { + await form.addQuestion(QuestionType.ShortAnswer) + + const questions = await form.getQuestions() + const question = questions[0] + + await question.fillTitle('What is your name?') + await expect(question.titleInput).toHaveValue('What is your name?') + + await question.fillDescription('Please enter your full name') + await expect(question.descriptionInput).toHaveValue( + 'Please enter your full name', + ) + }) + + test('Add answer options to a checkbox question', async ({ form }) => { + await form.addQuestion(QuestionType.Checkboxes) + + const questions = await form.getQuestions() + const question = questions[0] + + await question.addAnswer('Option A') + await question.addAnswer('Option B') + await question.addAnswer('Option C') + + await expect(question.answerInputs).toHaveCount(3) + }) + + test('Delete a question', async ({ page, form }) => { + await form.addQuestion(QuestionType.ShortAnswer) + await form.addQuestion(QuestionType.LongAnswer) + + let questions = await form.getQuestions() + expect(questions).toHaveLength(2) + await questions[0].fillTitle('First question') + await questions[1].fillTitle('Second question') + + await questions[0].delete() + + // Wait for Vue to re-render after deletion before taking a snapshot + await expect( + page.getByRole('listitem', { name: /Question number \d+/i }), + ).toHaveCount(1) + questions = await form.getQuestions() + await expect(questions[0].titleInput).toHaveValue('Second question') + }) + + test('Clone a question', async ({ form }) => { + await form.addQuestion(QuestionType.Checkboxes) + + const questions = await form.getQuestions() + const question = questions[0] + await question.fillTitle('Favorite colors') + await question.addAnswer('Red') + await question.addAnswer('Blue') + + await question.clone() + + const updatedQuestions = await form.getQuestions() + expect(updatedQuestions).toHaveLength(2) + + // The clone should have the same title and options + const clone = updatedQuestions[1] + await expect(clone.titleInput).toHaveValue('Favorite colors') + await expect(clone.answerInputs).toHaveCount(2) + }) +}) diff --git a/playwright/e2e/required-fields.spec.ts b/playwright/e2e/required-fields.spec.ts new file mode 100644 index 000000000..1da97dd65 --- /dev/null +++ b/playwright/e2e/required-fields.spec.ts @@ -0,0 +1,70 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, mergeTests } from '@playwright/test' +import { test as formTest } from '../support/fixtures/form' +import { test as appNavigationTest } from '../support/fixtures/navigation' +import { test as randomUserTest } from '../support/fixtures/random-user' +import { test as submitTest } from '../support/fixtures/submit' +import { test as topBarTest } from '../support/fixtures/topBar' +import { QuestionType } from '../support/sections/QuestionType' +import { FormsView } from '../support/sections/TopBarSection' + +const test = mergeTests( + randomUserTest, + appNavigationTest, + formTest, + topBarTest, + submitTest, +) + +test.describe('Required field validation', () => { + // Setup: create form with 2 questions, mark the first as required + test.beforeEach(async ({ page, appNavigation, form }) => { + await page.goto('apps/forms') + await page.waitForURL(/apps\/forms\/?$/) + await appNavigation.clickNewForm() + await form.fillTitle('Required fields test') + + // Add a required short answer + await form.addQuestion(QuestionType.ShortAnswer) + const questions = await form.getQuestions() + await questions[0].fillTitle('Required field') + + await questions[0].toggleRequired() + + // Add a non-required question + await form.addQuestion(QuestionType.ShortAnswer) + const questions2 = await form.getQuestions() + await questions2[1].fillTitle('Optional field') + }) + + test('Submit with empty required field shows validation error', async ({ + topBar, + submitView, + }) => { + await topBar.toggleView(FormsView.View) + + // Fill only the optional field + await submitView.fillText('Optional field', 'some text') + + // Try to submit — should fail due to required field + await submitView.submitButton.click() + + // The form should NOT show success message (submission blocked by HTML5 validation) + await expect(submitView.successMessage).not.toBeVisible() + }) + + test('Submit succeeds after filling required field', async ({ + topBar, + submitView, + }) => { + await topBar.toggleView(FormsView.View) + + await submitView.fillText('Required field', 'my answer') + await submitView.submit() + await expect(submitView.successMessage).toBeVisible() + }) +}) diff --git a/playwright/e2e/results-view.spec.ts b/playwright/e2e/results-view.spec.ts new file mode 100644 index 000000000..2254762a6 --- /dev/null +++ b/playwright/e2e/results-view.spec.ts @@ -0,0 +1,99 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, mergeTests } from '@playwright/test' +import { test as formTest } from '../support/fixtures/form' +import { test as appNavigationTest } from '../support/fixtures/navigation' +import { test as randomUserTest } from '../support/fixtures/random-user' +import { test as resultsTest } from '../support/fixtures/results' +import { test as submitTest } from '../support/fixtures/submit' +import { test as topBarTest } from '../support/fixtures/topBar' +import { QuestionType } from '../support/sections/QuestionType' +import { FormsView } from '../support/sections/TopBarSection' + +const test = mergeTests( + randomUserTest, + appNavigationTest, + formTest, + topBarTest, + submitTest, + resultsTest, +) + +test.describe('Results view', () => { + // Setup: create form, add questions, submit a response, go to results + test.beforeEach(async ({ page, appNavigation, form, topBar, submitView }) => { + await page.goto('apps/forms') + await page.waitForURL(/apps\/forms\/?$/) + await appNavigation.clickNewForm() + await form.fillTitle('Results test form') + + // Add a short answer question + await form.addQuestion(QuestionType.ShortAnswer) + const questions1 = await form.getQuestions() + await questions1[0].fillTitle('Your name') + + // Add a checkboxes question + await form.addQuestion(QuestionType.Checkboxes) + const questions2 = await form.getQuestions() + await questions2[1].fillTitle('Pick colors') + await questions2[1].addAnswer('Red') + await questions2[1].addAnswer('Green') + await questions2[1].addAnswer('Blue') + + // Switch to View mode and submit a response + await topBar.toggleView(FormsView.View) + await submitView.fillText('Your name', 'Alice') + await submitView.checkOption('Pick colors', 'Red') + await submitView.checkOption('Pick colors', 'Blue') + await submitView.submit() + await expect(submitView.successMessage).toBeVisible() + + // Navigate to Results view via URL — the SPA route transition + // from submit → results after submission causes a brief redirect loop, + // so we use direct navigation instead of clicking the TopBar. + await page.goto(page.url().replace(/\/submit.*$/, '/results')) + await page.waitForURL(/\/results$/) + }) + + test('Summary tab shows submitted data', async ({ resultsView }) => { + // Summary is the default active tab + await expect(resultsView.summaryTab).toBeChecked() + + // Verify the response count shows 1 + await expect(resultsView.responseCount).toBeVisible() + + // The summary for each question should be visible + const nameSummary = resultsView.getSummaryForQuestion('Your name') + await expect(nameSummary).toBeVisible() + await expect(nameSummary).toContainText('Alice') + + const colorSummary = resultsView.getSummaryForQuestion('Pick colors') + await expect(colorSummary).toBeVisible() + }) + + test('Responses tab shows individual submission', async ({ resultsView }) => { + await resultsView.switchToResponses() + + // Should show the individual submission with the answers + await expect(resultsView.responsesTab).toBeChecked() + await expect(resultsView.responseCount).toBeVisible() + }) + + test('Tab switching between Summary and Responses', async ({ resultsView }) => { + // Start on Summary + await expect(resultsView.summaryTab).toBeChecked() + + // Switch to Responses + await resultsView.switchToResponses() + await expect(resultsView.responsesTab).toBeChecked() + await expect(resultsView.summaryTab).not.toBeChecked() + + // Switch back to Summary + await resultsView.switchToSummary() + await expect(resultsView.summaryTab).toBeChecked() + await expect(resultsView.responsesTab).not.toBeChecked() + }) +}) diff --git a/playwright/e2e/submit-form.spec.ts b/playwright/e2e/submit-form.spec.ts new file mode 100644 index 000000000..336aafca9 --- /dev/null +++ b/playwright/e2e/submit-form.spec.ts @@ -0,0 +1,82 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, mergeTests } from '@playwright/test' +import { test as randomUserTest } from '../support/fixtures/random-user' +import { test as appNavigationTest } from '../support/fixtures/navigation' +import { test as formTest } from '../support/fixtures/form' +import { test as topBarTest } from '../support/fixtures/topBar' +import { test as submitTest } from '../support/fixtures/submit' +import { QuestionType } from '../support/sections/QuestionType' +import { FormsView } from '../support/sections/TopBarSection' + +const test = mergeTests( + randomUserTest, + appNavigationTest, + formTest, + topBarTest, + submitTest, +) + +test.describe('Form submission', () => { + // Setup: create a form with 4 question types + test.beforeEach(async ({ page, appNavigation, form }) => { + await page.goto('apps/forms') + await page.waitForURL(/apps\/forms\/?$/) + await appNavigation.clickNewForm() + await form.fillTitle('Submission test form') + + // Add a short answer question + await form.addQuestion(QuestionType.ShortAnswer) + const questions1 = await form.getQuestions() + await questions1[0].fillTitle('Your name') + + // Add a checkboxes question with options + await form.addQuestion(QuestionType.Checkboxes) + const questions2 = await form.getQuestions() + await questions2[1].fillTitle('Favorite fruits') + await questions2[1].addAnswer('Apple') + await questions2[1].addAnswer('Banana') + await questions2[1].addAnswer('Cherry') + + // Add a dropdown question with options + await form.addQuestion(QuestionType.Dropdown) + const questions3 = await form.getQuestions() + await questions3[2].fillTitle('Your country') + await questions3[2].addAnswer('Germany') + await questions3[2].addAnswer('France') + await questions3[2].addAnswer('Spain') + + // Add a date question + await form.addQuestion(QuestionType.Date) + const questions4 = await form.getQuestions() + await questions4[3].fillTitle('Birth date') + }) + + test('Fill and submit a form', async ({ topBar, submitView }) => { + await topBar.toggleView(FormsView.View) + + await submitView.fillText('Your name', 'Alice') + await submitView.checkOption('Favorite fruits', 'Apple') + await submitView.checkOption('Favorite fruits', 'Cherry') + await submitView.selectDropdown('Your country', 'Germany') + + await submitView.submit() + await expect(submitView.successMessage).toBeVisible() + }) + + test('Partial submission succeeds when no fields are required', async ({ + topBar, + submitView, + }) => { + await topBar.toggleView(FormsView.View) + + // Only fill the short answer, leave everything else empty + await submitView.fillText('Your name', 'Bob') + + await submitView.submit() + await expect(submitView.successMessage).toBeVisible() + }) +}) diff --git a/playwright/support/fixtures/results.ts b/playwright/support/fixtures/results.ts new file mode 100644 index 000000000..8f05841c9 --- /dev/null +++ b/playwright/support/fixtures/results.ts @@ -0,0 +1,18 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test as baseTest } from '@playwright/test' +import { ResultsSection } from '../sections/ResultsSection' + +interface ResultsFixture { + resultsView: ResultsSection +} + +export const test = baseTest.extend({ + resultsView: async ({ page }, use) => { + const resultsView = new ResultsSection(page) + await use(resultsView) + }, +}) diff --git a/playwright/support/fixtures/submit.ts b/playwright/support/fixtures/submit.ts new file mode 100644 index 000000000..b3c2c3fea --- /dev/null +++ b/playwright/support/fixtures/submit.ts @@ -0,0 +1,18 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test as baseTest } from '@playwright/test' +import { SubmitSection } from '../sections/SubmitSection' + +interface SubmitFixture { + submitView: SubmitSection +} + +export const test = baseTest.extend({ + submitView: async ({ page }, use) => { + const submitView = new SubmitSection(page) + await use(submitView) + }, +}) diff --git a/playwright/support/helpers.ts b/playwright/support/helpers.ts new file mode 100644 index 000000000..f12ffa7af --- /dev/null +++ b/playwright/support/helpers.ts @@ -0,0 +1,23 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Page, Response } from '@playwright/test' + +const FORMS_API_PATH = '/api/v3/forms/' + +/** + * Wait for a Forms API response matching the given HTTP method. + * Must be called BEFORE the action that triggers the request. + */ +export function waitForApiResponse( + page: Page, + method: string, +): Promise { + return page.waitForResponse( + (response) => + response.request().method() === method + && response.request().url().includes(FORMS_API_PATH), + ) +} diff --git a/playwright/support/sections/FormSection.ts b/playwright/support/sections/FormSection.ts index 9a98181cc..6dea1c2eb 100644 --- a/playwright/support/sections/FormSection.ts +++ b/playwright/support/sections/FormSection.ts @@ -3,9 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Locator, Page, Response } from '@playwright/test' +import type { Locator, Page } from '@playwright/test' import type { QuestionType } from './QuestionType.ts' +import { waitForApiResponse } from '../helpers.ts' import { QuestionSection } from './QuestionSection.ts' export class FormSection { @@ -40,27 +41,22 @@ export class FormSection { } public async addQuestion(type: QuestionType): Promise { + const created = waitForApiResponse(this.page, 'POST') await this.newQuestionButton.click() await this.page.getByRole('menuitem', { name: type }).click() + await created } public async getQuestions(): Promise { return this.page - .locator('main section') + .getByRole('listitem', { name: /Question number \d+/i }) .all() - .then((sections) => - sections.map((section) => new QuestionSection(this.page, section)), + .then((items) => + items.map((item) => new QuestionSection(this.page, item)), ) } - private getFormUpdatedPromise(): Promise { - return this.page.waitForResponse( - (response) => - response.request().method() === 'PATCH' - && response - .request() - .url() - .includes('/ocs/v2.php/apps/forms/api/v3/forms/'), - ) + private getFormUpdatedPromise() { + return waitForApiResponse(this.page, 'PATCH') } } diff --git a/playwright/support/sections/QuestionSection.ts b/playwright/support/sections/QuestionSection.ts index 2b33f2c83..9bc9e85e3 100644 --- a/playwright/support/sections/QuestionSection.ts +++ b/playwright/support/sections/QuestionSection.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Locator, Page, Response } from '@playwright/test' +import type { Locator, Page } from '@playwright/test' +import { waitForApiResponse } from '../helpers' export class QuestionSection { public readonly titleInput: Locator @@ -30,33 +31,53 @@ export class QuestionSection { } async fillTitle(title: string): Promise { - const saved = this.getQuestionUpdatedPromise() + const saved = waitForApiResponse(this.page, 'PATCH') await this.titleInput.fill(title) await saved } async fillDescription(description: string): Promise { - const saved = this.getQuestionUpdatedPromise() + const saved = waitForApiResponse(this.page, 'PATCH') await this.descriptionInput.fill(description) await saved } async addAnswer(text: string): Promise { - const saved = this.page.waitForResponse( - (response) => - response.request().method() === 'POST' - && response.request().url().includes('/api/v3/forms/'), - ) + const saved = waitForApiResponse(this.page, 'POST') await this.newAnswerInput.fill(text) await this.newAnswerInput.press('Enter') await saved } - private getQuestionUpdatedPromise(): Promise { - return this.page.waitForResponse( - (response) => - response.request().method() === 'PATCH' - && response.request().url().includes('/api/v3/forms/'), - ) + async openActionsMenu(): Promise { + await this.section + .getByRole('button', { name: 'Actions', exact: true }) + .click() + } + + async delete(): Promise { + await this.openActionsMenu() + const deleted = waitForApiResponse(this.page, 'DELETE') + await this.page.getByRole('button', { name: 'Delete question' }).click() + await deleted + } + + async clone(): Promise { + await this.openActionsMenu() + const cloned = waitForApiResponse(this.page, 'POST') + await this.page.getByRole('button', { name: 'Copy question' }).click() + await cloned + } + + async toggleRequired(): Promise { + await this.openActionsMenu() + // Wait for the debounced PATCH so it doesn't get caught + // by a later waitForResponse from fillTitle or similar. + const saved = waitForApiResponse(this.page, 'PATCH') + await this.page + .getByRole('checkbox', { name: 'Required' }) + .click({ force: true }) + await saved + await this.page.keyboard.press('Escape') } } diff --git a/playwright/support/sections/QuestionType.ts b/playwright/support/sections/QuestionType.ts index da502e0d9..208b981cf 100644 --- a/playwright/support/sections/QuestionType.ts +++ b/playwright/support/sections/QuestionType.ts @@ -11,5 +11,6 @@ export enum QuestionType { File = 'File', LinearScale = 'Linear scale', LongAnswer = 'Long text', + RadioButtons = 'Radio buttons', ShortAnswer = 'Short answer', } diff --git a/playwright/support/sections/ResultsSection.ts b/playwright/support/sections/ResultsSection.ts new file mode 100644 index 000000000..b9039a06a --- /dev/null +++ b/playwright/support/sections/ResultsSection.ts @@ -0,0 +1,43 @@ +/** + * 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 ResultsSection { + public readonly summaryTab: Locator + public readonly responsesTab: Locator + public readonly noResponsesMessage: Locator + public readonly responseCount: Locator + + constructor(public readonly page: Page) { + const main = this.page.getByRole('main') + this.summaryTab = main.getByRole('radio', { name: 'Summary' }) + // "Responses" exists in both the TopBar (value="results") and the Results PillMenu + // (value="responses"). Use .and() with the value attribute to disambiguate. + this.responsesTab = main + .getByRole('radio', { name: 'Responses' }) + .and(this.page.locator('[value="responses"]')) + this.noResponsesMessage = this.page.getByText('No responses yet') + this.responseCount = this.page.getByText(/\d+ responses?/) + } + + public async switchToSummary(): Promise { + // NcCheckboxRadioSwitch renders a hidden with + // v-on="{ change: onToggle }". Dispatch the change event directly. + await this.summaryTab.dispatchEvent('change') + } + + public async switchToResponses(): Promise { + await this.responsesTab.dispatchEvent('change') + } + + /** Get the summary section for a specific question by its title. */ + public getSummaryForQuestion(name: string | RegExp): Locator { + return this.page + .getByRole('main') + .getByRole('heading', { name }) + .locator('..') + } +} diff --git a/playwright/support/sections/SubmitSection.ts b/playwright/support/sections/SubmitSection.ts new file mode 100644 index 000000000..467b0aed6 --- /dev/null +++ b/playwright/support/sections/SubmitSection.ts @@ -0,0 +1,96 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Locator, Page, Response } from '@playwright/test' + +export class SubmitSection { + public readonly submitButton: Locator + public readonly successMessage: Locator + + constructor(public readonly page: Page) { + this.submitButton = this.page.getByRole('button', { name: 'Submit' }) + this.successMessage = this.page.getByText( + 'Thank you for completing the form!', + ) + } + + /** + * Get a question's list item by its title text. + * Questions render as
  • , + * and each contains an

    with the title text. + */ + public getQuestion(name: string | RegExp): Locator { + return this.page + .getByRole('listitem') + .filter({ has: this.page.getByRole('heading', { name }) }) + } + + /** + * Fill a short/long text question by its title. + * QuestionShort renders , + * QuestionLong renders