Skip to content

Commit 28a7502

Browse files
authored
Merge pull request #62688 from nextcloud/chore/stableize-tests
test: resolve potential flaky Playwright tests
2 parents b9e303f + 576d78c commit 28a7502

5 files changed

Lines changed: 106 additions & 44 deletions

File tree

playwright.config.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export default defineConfig({
1111
forbidOnly: !!process.env.CI,
1212
retries: process.env.CI ? 1 : 0,
1313
workers: process.env.CI ? 1 : undefined,
14+
timeout: process.env.CI ? 45_000 : undefined, // on CI allow 1.5x the default timeout to compensate for shared server resources
1415
reporter: process.env.CI ? [['blob'], ['dot'], ['github']] : 'html',
1516
use: {
1617
baseURL: 'http://localhost:8042/index.php/',
@@ -57,10 +58,10 @@ export default defineConfig({
5758
stdout: 'pipe',
5859
gracefulShutdown: {
5960
signal: 'SIGTERM',
60-
timeout: 10000,
61+
timeout: 10_000,
6162
},
6263
reuseExistingServer: !process.env.CI,
63-
timeout: 5 * 60 * 1000,
64+
timeout: 300_000,
6465
wait: {
6566
stdout: /Nextcloud container ready to run Playwright tests/,
6667
},

tests/playwright/e2e/core/header-unified-search.spec.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ test.describe('Header: unified search keyboard navigation', () => {
4848
test('typing auto-selects the first result', async ({ page }) => {
4949
const search = new UnifiedSearchPage(page)
5050
await search.input().fill(TOKEN)
51+
// A row rendering is not enough: a provider settling later re-renders the
52+
// list and re-establishes the selection, so read it only once it is final.
53+
await search.waitForSettledResults()
5154

5255
const firstOption = search.options().first()
5356
await expect(firstOption).toBeVisible()
@@ -60,37 +63,43 @@ test.describe('Header: unified search keyboard navigation', () => {
6063
test('arrow keys move the selection while focus stays in the input', async ({ page }) => {
6164
const search = new UnifiedSearchPage(page)
6265
await search.input().fill(TOKEN)
66+
await search.waitForSettledResults()
6367
// Need at least two rows to have somewhere to move to.
6468
await expect(search.options().nth(1)).toBeVisible()
65-
await expect(search.input()).toHaveAttribute('aria-activedescendant', /\w/)
6669

67-
const firstId = await search.input().getAttribute('aria-activedescendant')
70+
const firstId = await search.activeOptionId()
6871

6972
await page.keyboard.press('ArrowDown')
7073
// Selection advanced to another row and the input never lost focus.
71-
await expect(search.input()).not.toHaveAttribute('aria-activedescendant', firstId!)
74+
await expect(search.input()).not.toHaveAttribute('aria-activedescendant', firstId)
7275
await expect(search.input()).toBeFocused()
7376

7477
await page.keyboard.press('ArrowUp')
75-
await expect(search.input()).toHaveAttribute('aria-activedescendant', firstId!)
78+
await expect(search.input()).toHaveAttribute('aria-activedescendant', firstId)
7679
await expect(search.input()).toBeFocused()
7780
})
7881

7982
test('Enter opens the selected result', async ({ page }) => {
8083
const search = new UnifiedSearchPage(page)
8184
await search.input().fill(TOKEN)
82-
await expect(search.options().first()).toBeVisible()
85+
// Enter activates whatever is selected *at that moment* and does nothing at
86+
// all when the list is mid-flight (no selection), so the search has to have
87+
// settled before the key is pressed.
88+
await search.waitForSettledResults()
8389

84-
const activeId = await search.input().getAttribute('aria-activedescendant')
90+
const activeId = await search.activeOptionId()
8591
// The row is an NcListItem link to the file's short URL (/f/<id>), which the
8692
// server redirects into the Files viewer. Grab the id and assert we land on it.
87-
const href = await search.option(activeId!).getByRole('link').getAttribute('href')
93+
const href = await search.option(activeId).getByRole('link').getAttribute('href')
8894
const fileId = href?.match(/\/f\/(\d+)/)?.[1]
8995
expect(fileId).toBeTruthy()
9096

97+
// Arm the wait before the key press: the URL is only reached after a
98+
// server-side redirect, which outlives the default assertion timeout on a
99+
// loaded CI machine.
100+
const opened = page.waitForURL(new RegExp(`/files/${fileId}(?:[/?]|$)`), { timeout: 15_000 })
91101
await page.keyboard.press('Enter')
92-
93-
await expect(page).toHaveURL(new RegExp(`/files/${fileId}(?:[/?]|$)`))
102+
await opened
94103
})
95104

96105
test('Escape closes the open results popover', async ({ page }) => {

tests/playwright/e2e/files_sharing/admin-settings-limit-to-same-group.spec.ts

Lines changed: 34 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ test.describe('files_sharing: Sharing limited to members of the same group', ()
2525

2626
test.beforeAll(async () => {
2727
await runOcc(['config:app:set', '--value', 'yes', 'core', 'shareapi_only_share_with_group_members'])
28+
// The groups are shared by both tests, so create them once here rather than
29+
// spending two container round-trips inside every test's own timeout.
30+
for (const group of groups) {
31+
await runOcc(['group:add', group], { failOnError: false })
32+
}
2833
})
2934

3035
test.afterAll(async () => {
@@ -34,36 +39,6 @@ test.describe('files_sharing: Sharing limited to members of the same group', ()
3439
}
3540
})
3641

37-
/**
38-
* Put both accounts in two shared groups, then share a file each way.
39-
*
40-
* @returns the file names, `fromUser` shared by `user`, `fromRecipient` by `recipient`
41-
*/
42-
async function seedMutualShares(
43-
user: User,
44-
recipient: User,
45-
userRequest: Parameters<typeof createShare>[0],
46-
recipientRequest: Parameters<typeof createShare>[0],
47-
): Promise<{ fromUser: string, fromRecipient: string }> {
48-
for (const group of groups) {
49-
await runOcc(['group:add', group], { failOnError: false })
50-
await runOcc(['group:adduser', group, user.userId])
51-
await runOcc(['group:adduser', group, recipient.userId])
52-
}
53-
54-
const fromUser = 'shared-by-user.txt'
55-
const fromRecipient = 'shared-by-recipient.txt'
56-
await uploadContent(userRequest, user, 'share to recipient', 'text/plain', `/${fromUser}`)
57-
await uploadContent(recipientRequest, recipient, 'share to user', 'text/plain', `/${fromRecipient}`)
58-
await createShare(userRequest, `/${fromUser}`, recipient.userId)
59-
await createShare(recipientRequest, `/${fromRecipient}`, user.userId)
60-
61-
await waitForShare(recipientRequest, recipient, '', fromUser)
62-
await waitForShare(userRequest, user, '', fromRecipient)
63-
64-
return { fromUser, fromRecipient }
65-
}
66-
6742
test('keeps the shares while one common group is left', async ({ page, user, recipient, recipientRequest, filesListPage }) => {
6843
const { fromUser, fromRecipient } = await seedMutualShares(user, recipient, page.request, recipientRequest)
6944

@@ -92,4 +67,33 @@ test.describe('files_sharing: Sharing limited to members of the same group', ()
9267
await filesListPage.open()
9368
await expect(filesListPage.getRowForFile(fromUser)).toHaveCount(0)
9469
})
70+
71+
/**
72+
* Put both accounts in two shared groups, then share a file each way.
73+
*
74+
* @returns the file names, `fromUser` shared by `user`, `fromRecipient` by `recipient`
75+
*/
76+
async function seedMutualShares(
77+
user: User,
78+
recipient: User,
79+
userRequest: Parameters<typeof createShare>[0],
80+
recipientRequest: Parameters<typeof createShare>[0],
81+
): Promise<{ fromUser: string, fromRecipient: string }> {
82+
for (const group of groups) {
83+
await runOcc(['group:adduser', group, user.userId])
84+
await runOcc(['group:adduser', group, recipient.userId])
85+
}
86+
87+
const fromUser = 'shared-by-user.txt'
88+
const fromRecipient = 'shared-by-recipient.txt'
89+
await uploadContent(userRequest, user, 'share to recipient', 'text/plain', `/${fromUser}`)
90+
await uploadContent(recipientRequest, recipient, 'share to user', 'text/plain', `/${fromRecipient}`)
91+
await createShare(userRequest, `/${fromUser}`, recipient.userId)
92+
await createShare(recipientRequest, `/${fromRecipient}`, user.userId)
93+
94+
await waitForShare(recipientRequest, recipient, '', fromUser)
95+
await waitForShare(userRequest, user, '', fromRecipient)
96+
97+
return { fromUser, fromRecipient }
98+
}
9599
})

tests/playwright/support/sections/FilesListPage.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@ export class FilesListPage {
2525
return this.page.locator('[data-cy-files-list]')
2626
}
2727

28-
/** Wait for the file list container to be rendered (e.g. after a direct goto). */
28+
/**
29+
* Wait for the file list container to be rendered (e.g. after a direct goto).
30+
*/
2931
async waitForList(): Promise<void> {
30-
await this.getFilesList().waitFor({ state: 'visible' })
32+
await this.getFilesList().waitFor({ state: 'visible', timeout: 20_000 })
3133
}
3234

3335
/**
@@ -176,6 +178,8 @@ export class FilesListPage {
176178
* row Locator so it serves both name- and fileid-addressed rows.
177179
*/
178180
private async openActionsMenuForRow(row: Locator): Promise<Locator> {
181+
await expect(row).toBeVisible({ timeout: 15_000 })
182+
179183
await row.hover()
180184

181185
const actionsButton = row.getByRole('button', { name: 'Actions' })
@@ -431,7 +435,9 @@ export class FilesListPage {
431435
// the row's buttons by the folder name is ambiguous for shared folders,
432436
// whose row also carries a "Shared by …" action button that can contain
433437
// the same text.
434-
await this.getRowNameLinkForFile(directory).click()
438+
const link = this.getRowNameLinkForFile(directory)
439+
await expect(link).toBeVisible()
440+
await link.click()
435441

436442
// Assert the deepest segment of the `dir` query param matches the folder
437443
// we just opened. Comparing the decoded value (URLSearchParams decodes

tests/playwright/support/sections/UnifiedSearchPage.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
import type { Locator, Page } from '@playwright/test'
77

8+
import { expect } from '@playwright/test'
9+
810
/**
911
* The unified search in the top header: the combobox input and the results
1012
* popover it controls. Desktop layout only (the mobile header collapses to a
@@ -63,6 +65,46 @@ export class UnifiedSearchPage {
6365
return this.panel().getByRole('option')
6466
}
6567

68+
/**
69+
* The popover's polite status region (WCAG 4.1.3). It reports the search
70+
* progress: "Searching …" while any provider is still in flight, then the
71+
* settled outcome — "No matching results" or the result count.
72+
*/
73+
status(): Locator {
74+
return this.panel().getByRole('status')
75+
}
76+
77+
/**
78+
* Wait for the result set to stop changing, i.e. for every provider to have
79+
* answered.
80+
*
81+
* Results stream in per provider and the rows re-render on every batch, which
82+
* clears and re-establishes the selection in between — so a visible first row
83+
* does not mean the selection is settled. Anything that reads
84+
* `aria-activedescendant`, or activates the selected row with Enter, has to
85+
* wait for this first: on a mid-flight list the id is momentarily absent and
86+
* Enter is a no-op.
87+
*
88+
* The status region is the app's own settled signal, so this needs no
89+
* arbitrary delay.
90+
*/
91+
async waitForSettledResults(): Promise<void> {
92+
// The count, not just "results": "No matching results" is a settled state too,
93+
// but one no selection assertion can work with. Anchored (with the template's
94+
// surrounding whitespace, which a regex match does not normalize away) so a
95+
// still-running search cannot slip through.
96+
await expect(this.status()).toHaveText(/^\s*\d+ results?\s*$/)
97+
}
98+
99+
/**
100+
* The id of the currently selected row, once the input names one. Waits for the
101+
* attribute so a still-settling list cannot yield `null`.
102+
*/
103+
async activeOptionId(): Promise<string> {
104+
await expect(this.input()).toHaveAttribute('aria-activedescendant', /\S/)
105+
return (await this.input().getAttribute('aria-activedescendant'))!
106+
}
107+
66108
/**
67109
* A single result row addressed by its DOM id (the value the input carries in
68110
* aria-activedescendant). Attribute selector so provider ids with dots or colons

0 commit comments

Comments
 (0)