diff --git a/playwright/e2e/footer-policy-hierarchy-ui.spec.ts b/playwright/e2e/footer-policy-hierarchy-ui.spec.ts index 3aa8b76d8a..10aecb9f6f 100644 --- a/playwright/e2e/footer-policy-hierarchy-ui.spec.ts +++ b/playwright/e2e/footer-policy-hierarchy-ui.spec.ts @@ -20,6 +20,7 @@ import { setGroupPolicyEntry, setSystemPolicyEntry, } from '../support/policy-api' +import { clickVisibleOptionOrFallback } from '../support/select-option' const test = base.extend<{ adminRequestContext: APIRequestContext @@ -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 { @@ -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') diff --git a/playwright/support/select-option.ts b/playwright/support/select-option.ts new file mode 100644 index 0000000000..eb1ddffea0 --- /dev/null +++ b/playwright/support/select-option.ts @@ -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 + isVisible?: (options?: { timeout?: number }) => Promise +} + +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 { + 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) +} diff --git a/src/tests/helpers/selectOption.spec.ts b/src/tests/helpers/selectOption.spec.ts new file mode 100644 index 0000000000..9b88a00505 --- /dev/null +++ b/src/tests/helpers/selectOption.spec.ts @@ -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() + }) +})