Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions playwright/e2e/footer-policy-hierarchy-ui.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
setGroupPolicyEntry,
setSystemPolicyEntry,
} from '../support/policy-api'
import { clickVisibleOptionOrFallback } from '../support/select-option'

const test = base.extend<{
adminRequestContext: APIRequestContext
Expand Down Expand Up @@ -212,13 +213,18 @@ async function selectTarget(dialogScope: Locator, kind: 'group' | 'user', target
if (await searchInput.isVisible({ timeout: 1000 }).catch(() => false)) {
for (let attempt = 0; attempt < 3; attempt += 1) {
await searchInput.fill(target)
const targetPattern = new RegExp(escapeRegExp(target), 'i')

const matchingOption = page.getByRole('option', { name: new RegExp(escapeRegExp(target), 'i') }).first()
const matchingOption = page.getByRole('option', { name: targetPattern }).first()
const matchingVisible = await matchingOption.waitFor({ state: 'visible', timeout: 3000 }).then(() => true).catch(() => false)
if (matchingVisible) {
await matchingOption.click()
const clicked = await clickVisibleOptionOrFallback(page, matchingOption, targetPattern)
if (!clicked) {
await searchInput.press('ArrowDown')
await searchInput.press('Enter')
}
} else {
const floatingOption = page.locator('ul[role="listbox"] li, .vs__dropdown-menu--floating li').filter({ hasText: new RegExp(escapeRegExp(target), 'i') }).first()
const floatingOption = page.locator('ul[role="listbox"] li, .vs__dropdown-menu--floating li').filter({ hasText: targetPattern }).first()
if (await floatingOption.isVisible({ timeout: 2000 }).catch(() => false)) {
await floatingOption.click()
} else {
Expand All @@ -236,9 +242,14 @@ async function selectTarget(dialogScope: Locator, kind: 'group' | 'user', target
await expect.poll(isSelectionConfirmed, { timeout: 8000 }).toBe(true)
} else {
await page.keyboard.type(target)
const matchingOption = page.getByRole('option', { name: new RegExp(escapeRegExp(target), 'i') }).first()
const targetPattern = new RegExp(escapeRegExp(target), 'i')
const matchingOption = page.getByRole('option', { name: targetPattern }).first()
if (await matchingOption.waitFor({ state: 'visible', timeout: 3000 }).then(() => true).catch(() => false)) {
await matchingOption.click()
const clicked = await clickVisibleOptionOrFallback(page, matchingOption, targetPattern)
if (!clicked) {
await page.keyboard.press('ArrowDown')
await page.keyboard.press('Enter')
}
} else {
await page.keyboard.press('ArrowDown')
await page.keyboard.press('Enter')
Expand Down
62 changes: 62 additions & 0 deletions playwright/support/select-option.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

type ClickableLocator = {
click: (options?: { timeout?: number }) => Promise<unknown>
isVisible?: (options?: { timeout?: number }) => Promise<boolean>
}

type LocatorFactory = {
locator: (selector: string) => {
filter: (options: { hasText: RegExp }) => {
first: () => ClickableLocator
}
}
}

/**
* Clicks a visible ARIA option with a bounded timeout, then falls back to vue-select's floating menu item.
*
* @param page The page-like object used to query floating dropdown options.
* @param matchingOption The ARIA option locator found by role.
* @param targetPattern Text pattern used to locate the fallback option.
* @param options Optional timeout overrides for the bounded click attempts.
* @param options.optionClickTimeout Timeout for each click attempt.
* @param options.fallbackVisibilityTimeout Timeout for waiting on the fallback option.
*/
export async function clickVisibleOptionOrFallback(
page: LocatorFactory,
matchingOption: ClickableLocator,
targetPattern: RegExp,
options?: {
optionClickTimeout?: number
fallbackVisibilityTimeout?: number
},
): Promise<boolean> {
const optionClickTimeout = options?.optionClickTimeout ?? 1500
const fallbackVisibilityTimeout = options?.fallbackVisibilityTimeout ?? 2000

const clickedOption = await matchingOption
.click({ timeout: optionClickTimeout })
.then(() => true)
.catch(() => false)
if (clickedOption) {
return true
}

const floatingOption = page
.locator('ul[role="listbox"] li, .vs__dropdown-menu--floating li')
.filter({ hasText: targetPattern })
.first()

if (!(await floatingOption.isVisible?.({ timeout: fallbackVisibilityTimeout }).catch(() => false))) {
return false
}

return floatingOption
.click({ timeout: optionClickTimeout })
.then(() => true)
.catch(() => false)
}
74 changes: 74 additions & 0 deletions src/tests/helpers/selectOption.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { describe, expect, it, vi } from 'vitest'

import { clickVisibleOptionOrFallback } from '../../../playwright/support/select-option'

describe('clickVisibleOptionOrFallback', () => {
it('falls back to the floating vue-select option when the visible ARIA option click times out', async () => {
const optionClick = vi.fn().mockRejectedValue(new Error('option click timed out'))
const fallbackClick = vi.fn().mockResolvedValue(undefined)
const fallbackIsVisible = vi.fn().mockResolvedValue(true)
const firstFallback = {
click: fallbackClick,
isVisible: fallbackIsVisible,
}
const fallbackLocator = {
filter: vi.fn(() => ({
first: vi.fn(() => firstFallback),
})),
}
const page = {
locator: vi.fn(() => fallbackLocator),
}
const matchingOption = {
click: optionClick,
}

const clicked = await clickVisibleOptionOrFallback(
page,
matchingOption,
/libresign-footer-ui-flow-group/i,
{ optionClickTimeout: 25, fallbackVisibilityTimeout: 25 },
)

expect(clicked).toBe(true)
expect(optionClick).toHaveBeenCalledWith({ timeout: 25 })
expect(page.locator).toHaveBeenCalledWith('ul[role="listbox"] li, .vs__dropdown-menu--floating li')
expect(fallbackLocator.filter).toHaveBeenCalledWith({ hasText: /libresign-footer-ui-flow-group/i })
expect(fallbackIsVisible).toHaveBeenCalledWith({ timeout: 25 })
expect(fallbackClick).toHaveBeenCalledWith({ timeout: 25 })
})

it('returns false when neither the ARIA option nor the floating fallback can be clicked', async () => {
const optionClick = vi.fn().mockRejectedValue(new Error('option click timed out'))
const fallbackClick = vi.fn()
const fallbackIsVisible = vi.fn().mockResolvedValue(false)
const page = {
locator: vi.fn(() => ({
filter: vi.fn(() => ({
first: vi.fn(() => ({
click: fallbackClick,
isVisible: fallbackIsVisible,
})),
})),
})),
}
const matchingOption = {
click: optionClick,
}

const clicked = await clickVisibleOptionOrFallback(
page,
matchingOption,
/libresign-footer-ui-flow-group/i,
{ optionClickTimeout: 25, fallbackVisibilityTimeout: 25 },
)

expect(clicked).toBe(false)
expect(fallbackClick).not.toHaveBeenCalled()
})
})
Loading