Files
playkit/src/browser/aria.ts
T
ilia 8c2af03be3
CI / release (pull_request) Has been skipped
CI / skip-ci-check (pull_request) Successful in 35s
CI / secret-scan (pull_request) Successful in 34s
CI / build-and-test (pull_request) Successful in 55s
CI / selftest (pull_request) Successful in 1m9s
Add Outline-session browser helpers for hidden aria twins and uploads.
preferVisible (default on click), setFilesViaChooser, and cookiesToBearer
came out of driving notes.levkin.ca where Radix menus and attachment APIs
needed session reuse without re-auth.
2026-08-09 19:13:54 -04:00

111 lines
3.9 KiB
TypeScript

import type { Locator, Page } from '@playwright/test';
import { createLogger, type Logger } from '../logging/logger.js';
import { click as clickHelper, type ClickOptions } from './actions.js';
export interface ByAriaLabelOptions {
/** Element tags to scan (default: interactive-ish: button, a, [role=button]/[role=link]). */
selector?: string;
/**
* Prefer a visible match when several elements share a matching aria-label
* (common with Radix/headless UI twins that leave a hidden copy in the DOM).
* When true and no match is visible, falls back to the first regex match.
*/
preferVisible?: boolean;
logger?: Logger;
}
const DEFAULT_SELECTOR =
'button[aria-label], a[aria-label], [role="button"][aria-label], [role="link"][aria-label]';
/**
* Find the first element whose `aria-label` matches `pattern`, scoped to `root`
* (a Page or a Locator, e.g. a specific dialog) so unrelated matches elsewhere
* on the page don't win.
*
* Built for third-party UIs where you don't control markup and can't rely on
* stable test ids — aria-labels there are often compound and dynamic (e.g.
* "Edit Staff Automation Engineer at NiyaSoft"), so exact-string locators
* extracted from a single DOM dump tend to be brittle; a regex survives
* per-record text variation.
*
* Returns `null` (rather than throwing) when nothing matches, so callers can
* fall back to an alternate strategy (e.g. a nearby icon button) before
* giving up — see `withDialog` for retrying the whole lookup after a reopen.
*
* Pass `preferVisible: true` to skip hidden matches first (then fall back to
* the first match if none are visible).
*/
export async function byAriaLabel(
root: Page | Locator,
pattern: RegExp,
options?: ByAriaLabelOptions,
): Promise<Locator | null> {
const log = options?.logger ?? createLogger({ name: 'byAriaLabel' });
const selector = options?.selector ?? DEFAULT_SELECTOR;
const preferVisible = options?.preferVisible === true;
const candidates = root.locator(selector);
const count = await candidates.count();
let firstMatch: Locator | null = null;
let firstLabel: string | null = null;
for (let i = 0; i < count; i++) {
const el = candidates.nth(i);
const label = await el.getAttribute('aria-label');
if (!(label && pattern.test(label))) continue;
if (!firstMatch) {
firstMatch = el;
firstLabel = label;
if (!preferVisible) {
log.debug('byAriaLabel matched', { pattern: pattern.source, label });
return el;
}
}
if (preferVisible) {
const visible = await el.isVisible().catch(() => false);
if (visible) {
log.debug('byAriaLabel matched visible', { pattern: pattern.source, label });
return el;
}
}
}
if (preferVisible && firstMatch) {
log.debug('byAriaLabel visible none; falling back to first match', {
pattern: pattern.source,
label: firstLabel,
});
return firstMatch;
}
log.debug('byAriaLabel no match', { pattern: pattern.source, scanned: count });
return null;
}
/**
* `byAriaLabel` + click in one call. Throws with the pattern in the message
* (instead of a generic "locator not found") when nothing matches, since that's
* the single most useful piece of context when debugging a failed run later.
*
* Defaults `preferVisible: true` so hidden Radix/menu twins are skipped when a
* visible match exists. Pass `preferVisible: false` to restore first-match.
*/
export async function clickByAriaLabel(
root: Page | Locator,
pattern: RegExp,
options?: ByAriaLabelOptions & ClickOptions,
): Promise<string> {
const found = await byAriaLabel(root, pattern, {
...options,
preferVisible: options?.preferVisible ?? true,
});
if (!found) {
throw new Error(`clickByAriaLabel: no element with aria-label matching ${pattern} found`);
}
const label = (await found.getAttribute('aria-label')) ?? '';
await clickHelper(found, options);
return label;
}