Skip to content

Commit cef7c69

Browse files
committed
feat(e2e): add baseline E2E test coverage for Vue 3 migration
- Add page objects: SubmitSection, ResultsSection, helpers - Add 6 spec files covering question editing, form submission, results view, required fields, sharing, and settings - Improve existing page objects with better selectors and API waits Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
1 parent 78a88fd commit cef7c69

11 files changed

Lines changed: 314 additions & 87 deletions
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { expect, mergeTests } from '@playwright/test'
7+
import { test as formTest } from '../support/fixtures/form'
8+
import { test as appNavigationTest } from '../support/fixtures/navigation'
9+
import { test as randomUserTest } from '../support/fixtures/random-user'
10+
import { test as submitTest } from '../support/fixtures/submit'
11+
import { test as topBarTest } from '../support/fixtures/topBar'
12+
import { waitForApiResponse } from '../support/helpers'
13+
import { QuestionType } from '../support/sections/QuestionType'
14+
import { FormsView } from '../support/sections/TopBarSection'
15+
16+
const test = mergeTests(
17+
randomUserTest,
18+
appNavigationTest,
19+
formTest,
20+
topBarTest,
21+
submitTest,
22+
)
23+
24+
test.describe('Form settings', () => {
25+
// Setup: create a form with one question, open the Settings sidebar tab
26+
test.beforeEach(async ({ page, appNavigation, form }) => {
27+
await page.goto('apps/forms')
28+
await page.waitForURL(/apps\/forms\/?$/)
29+
await appNavigation.clickNewForm()
30+
await form.fillTitle('Settings test form')
31+
32+
await form.addQuestion(QuestionType.ShortAnswer)
33+
const questions = await form.getQuestions()
34+
await questions[0].fillTitle('Your answer')
35+
36+
// Open sidebar and switch to Settings tab
37+
await page.getByRole('button', { name: /Share/ }).click()
38+
const settingsTab = page.getByRole('tab', { name: /Settings/ })
39+
await settingsTab.click()
40+
await expect(
41+
page.getByRole('checkbox', { name: /Close form/ }),
42+
).toBeVisible()
43+
})
44+
45+
test('Closing a form blocks submissions', async ({ page, topBar }) => {
46+
const saved = waitForApiResponse(page, 'PATCH')
47+
await page
48+
.getByRole('checkbox', { name: /Close form/ })
49+
.click({ force: true })
50+
await saved
51+
52+
await topBar.toggleView(FormsView.View)
53+
54+
// NcEmptyContent renders with role="note" — scope to main to avoid
55+
// matching the "Form closed" status text in the sidebar navigation.
56+
const main = page.getByRole('main')
57+
await expect(main.getByText('Form closed')).toBeVisible()
58+
await expect(
59+
main.getByText('This form was closed and is no longer taking responses'),
60+
).toBeVisible()
61+
})
62+
63+
test('Reopening a closed form allows submissions', async ({
64+
page,
65+
topBar,
66+
submitView,
67+
}) => {
68+
// Close the form
69+
const closed = waitForApiResponse(page, 'PATCH')
70+
await page
71+
.getByRole('checkbox', { name: /Close form/ })
72+
.click({ force: true })
73+
await closed
74+
75+
// Reopen the form
76+
const reopened = waitForApiResponse(page, 'PATCH')
77+
await page
78+
.getByRole('checkbox', { name: /Close form/ })
79+
.click({ force: true })
80+
await reopened
81+
82+
await topBar.toggleView(FormsView.View)
83+
84+
// Form should be accessible — questions visible and submit button present
85+
await expect(submitView.submitButton).toBeVisible()
86+
})
87+
88+
test('Anonymous mode shows anonymous message on submit view', async ({
89+
page,
90+
topBar,
91+
}) => {
92+
const saved = waitForApiResponse(page, 'PATCH')
93+
await page
94+
.getByRole('checkbox', { name: /Store responses anonymously/ })
95+
.click({ force: true })
96+
await saved
97+
98+
await topBar.toggleView(FormsView.View)
99+
100+
await expect(page.getByText('Responses are anonymous.')).toBeVisible()
101+
})
102+
103+
test('Non-anonymous mode shows account-connected message on edit view', async ({
104+
page,
105+
}) => {
106+
// The Create (edit) view always shows this message when anonymous
107+
// is off, because the editor is always in a logged-in context.
108+
// The Submit route doesn't receive isLoggedIn from the router, so
109+
// the message only appears on the edit view.
110+
await expect(
111+
page.getByText('Responses are connected to your account.'),
112+
).toBeVisible()
113+
})
114+
})
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { expect, mergeTests } from '@playwright/test'
7+
import { test as randomUserTest } from '../support/fixtures/random-user'
8+
import { test as appNavigationTest } from '../support/fixtures/navigation'
9+
import { test as formTest } from '../support/fixtures/form'
10+
import { QuestionType } from '../support/sections/QuestionType'
11+
import { waitForApiResponse } from '../support/helpers'
12+
13+
const test = mergeTests(
14+
randomUserTest,
15+
appNavigationTest,
16+
formTest,
17+
)
18+
19+
test.describe('Form sharing', () => {
20+
test.beforeEach(async ({ page, appNavigation, form }) => {
21+
await page.goto('apps/forms')
22+
await page.waitForURL(/apps\/forms\/?$/)
23+
await appNavigation.clickNewForm()
24+
await form.fillTitle('Sharing test form')
25+
26+
await form.addQuestion(QuestionType.ShortAnswer)
27+
const questions = await form.getQuestions()
28+
await questions[0].fillTitle('Test question')
29+
30+
// Open the sidebar via the Share button in the TopBar
31+
await page.getByRole('button', { name: /Share/ }).click()
32+
// Sidebar opens on the Sharing tab by default — wait for it
33+
await expect(
34+
page.getByRole('complementary').getByText('Share link'),
35+
).toBeVisible()
36+
})
37+
38+
test('Add a public share link', async ({ page }) => {
39+
// New forms start without a public link share.
40+
// NcActions with a single child renders it as an inline button.
41+
const shareLinkRow = page.locator('.share-div--link')
42+
const addLinkButton = shareLinkRow.getByRole('button', {
43+
name: /Add link/,
44+
})
45+
await expect(addLinkButton).toBeVisible()
46+
47+
const linkCreated = waitForApiResponse(page, 'POST')
48+
await addLinkButton.click()
49+
await linkCreated
50+
51+
// After adding, the share link entry renders NcActions with :inline="1",
52+
// so the first action ("Copy to clipboard") appears as an inline button.
53+
await expect(
54+
shareLinkRow.getByRole('button', { name: /Copy to clipboard/ }),
55+
).toBeVisible()
56+
})
57+
58+
test('Remove a public share link', async ({ page }) => {
59+
// First, add a link
60+
const shareLinkRow = page.locator('.share-div--link')
61+
62+
const linkCreated = waitForApiResponse(page, 'POST')
63+
await shareLinkRow.getByRole('button', { name: /Add link/ }).click()
64+
await linkCreated
65+
66+
// The inline "Copy to clipboard" action should now be visible
67+
await expect(
68+
shareLinkRow.getByRole('button', { name: /Copy to clipboard/ }),
69+
).toBeVisible()
70+
71+
// Open the overflow menu (the "Actions" toggle) to find "Remove link".
72+
// NcActions :inline="1" renders the first action inline and puts the
73+
// rest behind an overflow toggle button.
74+
await shareLinkRow.getByRole('button', { name: /Actions/ }).click()
75+
76+
const linkDeleted = waitForApiResponse(page, 'DELETE')
77+
await page.getByRole('menuitem', { name: /Remove link/ }).click()
78+
await linkDeleted
79+
80+
// After removal, the share link row reverts to the "no link" state.
81+
// NcActions collapses a single action to an inline button.
82+
await expect(
83+
shareLinkRow.getByRole('button', { name: /Add link/ }),
84+
).toBeVisible()
85+
})
86+
})

playwright/e2e/question-editing.spec.ts

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const test = mergeTests(randomUserTest, appNavigationTest, formTest)
1414
test.describe('Question editing lifecycle', () => {
1515
test.beforeEach(async ({ page, appNavigation, form }) => {
1616
await page.goto('apps/forms')
17-
await page.waitForURL(/apps\/forms$/)
17+
await page.waitForURL(/apps\/forms\/?$/)
1818
await appNavigation.clickNewForm()
1919
await form.fillTitle('Editing test form')
2020
})
@@ -77,20 +77,17 @@ test.describe('Question editing lifecycle', () => {
7777
await questions[0].fillTitle('First question')
7878
await questions[1].fillTitle('Second question')
7979

80-
// Open the actions menu on the first question and delete it.
81-
// NcActions renders as a button inside the question section.
82-
// Question.vue uses force-menu so there's always a trigger button.
83-
const firstSection = questions[0].section
84-
await firstSection.getByRole('button', { name: 'Actions' }).click()
85-
await page.getByRole('menuitem', { name: 'Delete question' }).click()
80+
await questions[0].delete()
8681

87-
// Wait for the DELETE response
82+
// Wait for Vue to re-render after deletion before taking a snapshot
83+
await expect(
84+
page.getByRole('listitem', { name: /Question number \d+/i }),
85+
).toHaveCount(1)
8886
questions = await form.getQuestions()
89-
expect(questions).toHaveLength(1)
9087
await expect(questions[0].titleInput).toHaveValue('Second question')
9188
})
9289

93-
test('Clone a question', async ({ page, form }) => {
90+
test('Clone a question', async ({ form }) => {
9491
await form.addQuestion(QuestionType.Checkboxes)
9592

9693
const questions = await form.getQuestions()
@@ -99,11 +96,8 @@ test.describe('Question editing lifecycle', () => {
9996
await question.addAnswer('Red')
10097
await question.addAnswer('Blue')
10198

102-
// Clone via the actions menu
103-
await question.section.getByRole('button', { name: 'Actions' }).click()
104-
await page.getByRole('menuitem', { name: 'Copy question' }).click()
99+
await question.clone()
105100

106-
// Wait for the clone to appear
107101
const updatedQuestions = await form.getQuestions()
108102
expect(updatedQuestions).toHaveLength(2)
109103

playwright/e2e/required-fields.spec.ts

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44
*/
55

66
import { expect, mergeTests } from '@playwright/test'
7-
import { test as randomUserTest } from '../support/fixtures/random-user'
8-
import { test as appNavigationTest } from '../support/fixtures/navigation'
97
import { test as formTest } from '../support/fixtures/form'
10-
import { test as topBarTest } from '../support/fixtures/topBar'
8+
import { test as appNavigationTest } from '../support/fixtures/navigation'
9+
import { test as randomUserTest } from '../support/fixtures/random-user'
1110
import { test as submitTest } from '../support/fixtures/submit'
11+
import { test as topBarTest } from '../support/fixtures/topBar'
1212
import { QuestionType } from '../support/sections/QuestionType'
1313
import { FormsView } from '../support/sections/TopBarSection'
1414

@@ -24,7 +24,7 @@ test.describe('Required field validation', () => {
2424
// Setup: create form with 2 questions, mark the first as required
2525
test.beforeEach(async ({ page, appNavigation, form }) => {
2626
await page.goto('apps/forms')
27-
await page.waitForURL(/apps\/forms$/)
27+
await page.waitForURL(/apps\/forms\/?$/)
2828
await appNavigation.clickNewForm()
2929
await form.fillTitle('Required fields test')
3030

@@ -33,15 +33,7 @@ test.describe('Required field validation', () => {
3333
const questions = await form.getQuestions()
3434
await questions[0].fillTitle('Required field')
3535

36-
// Toggle required via the actions menu.
37-
// Question.vue has an NcActionCheckbox with label "Required"
38-
// inside the NcActions menu.
39-
await questions[0].section
40-
.getByRole('button', { name: 'Actions' })
41-
.click()
42-
await page.getByRole('menuitemcheckbox', { name: 'Required' }).click()
43-
// Close the menu by pressing Escape
44-
await page.keyboard.press('Escape')
36+
await questions[0].toggleRequired()
4537

4638
// Add a non-required question
4739
await form.addQuestion(QuestionType.ShortAnswer)

playwright/e2e/results-view.spec.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@
44
*/
55

66
import { expect, mergeTests } from '@playwright/test'
7-
import { test as randomUserTest } from '../support/fixtures/random-user'
8-
import { test as appNavigationTest } from '../support/fixtures/navigation'
97
import { test as formTest } from '../support/fixtures/form'
10-
import { test as topBarTest } from '../support/fixtures/topBar'
11-
import { test as submitTest } from '../support/fixtures/submit'
8+
import { test as appNavigationTest } from '../support/fixtures/navigation'
9+
import { test as randomUserTest } from '../support/fixtures/random-user'
1210
import { test as resultsTest } from '../support/fixtures/results'
11+
import { test as submitTest } from '../support/fixtures/submit'
12+
import { test as topBarTest } from '../support/fixtures/topBar'
1313
import { QuestionType } from '../support/sections/QuestionType'
1414
import { FormsView } from '../support/sections/TopBarSection'
1515

@@ -26,7 +26,7 @@ test.describe('Results view', () => {
2626
// Setup: create form, add questions, submit a response, go to results
2727
test.beforeEach(async ({ page, appNavigation, form, topBar, submitView }) => {
2828
await page.goto('apps/forms')
29-
await page.waitForURL(/apps\/forms$/)
29+
await page.waitForURL(/apps\/forms\/?$/)
3030
await appNavigation.clickNewForm()
3131
await form.fillTitle('Results test form')
3232

@@ -51,8 +51,11 @@ test.describe('Results view', () => {
5151
await submitView.submit()
5252
await expect(submitView.successMessage).toBeVisible()
5353

54-
// Navigate to Results view
55-
await topBar.toggleView(FormsView.Results)
54+
// Navigate to Results view via URL — the SPA route transition
55+
// from submit → results after submission causes a brief redirect loop,
56+
// so we use direct navigation instead of clicking the TopBar.
57+
await page.goto(page.url().replace(/\/submit.*$/, '/results'))
58+
await page.waitForURL(/\/results$/)
5659
})
5760

5861
test('Summary tab shows submitted data', async ({ resultsView }) => {
@@ -71,19 +74,15 @@ test.describe('Results view', () => {
7174
await expect(colorSummary).toBeVisible()
7275
})
7376

74-
test('Responses tab shows individual submission', async ({
75-
resultsView,
76-
}) => {
77+
test('Responses tab shows individual submission', async ({ resultsView }) => {
7778
await resultsView.switchToResponses()
7879

7980
// Should show the individual submission with the answers
8081
await expect(resultsView.responsesTab).toBeChecked()
8182
await expect(resultsView.responseCount).toBeVisible()
8283
})
8384

84-
test('Tab switching between Summary and Responses', async ({
85-
resultsView,
86-
}) => {
85+
test('Tab switching between Summary and Responses', async ({ resultsView }) => {
8786
// Start on Summary
8887
await expect(resultsView.summaryTab).toBeChecked()
8988

playwright/e2e/submit-form.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ test.describe('Form submission', () => {
2424
// Setup: create a form with 4 question types
2525
test.beforeEach(async ({ page, appNavigation, form }) => {
2626
await page.goto('apps/forms')
27-
await page.waitForURL(/apps\/forms$/)
27+
await page.waitForURL(/apps\/forms\/?$/)
2828
await appNavigation.clickNewForm()
2929
await form.fillTitle('Submission test form')
3030

playwright/support/helpers.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type { Page, Response } from '@playwright/test'
7+
8+
const FORMS_API_PATH = '/api/v3/forms/'
9+
10+
/**
11+
* Wait for a Forms API response matching the given HTTP method.
12+
* Must be called BEFORE the action that triggers the request.
13+
*/
14+
export function waitForApiResponse(
15+
page: Page,
16+
method: string,
17+
): Promise<Response> {
18+
return page.waitForResponse(
19+
(response) =>
20+
response.request().method() === method
21+
&& response.request().url().includes(FORMS_API_PATH),
22+
)
23+
}

0 commit comments

Comments
 (0)