From 8c2af03be3d75382ee4f064391592a6e802e442e Mon Sep 17 00:00:00 2001 From: ilia Date: Sun, 9 Aug 2026 19:13:54 -0400 Subject: [PATCH] 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. --- CHANGELOG.md | 4 +++ README.md | 14 ++++++++- ROADMAP.md | 3 ++ src/browser/actions.test.ts | 28 ++++++++++++++++++ src/browser/actions.ts | 28 ++++++++++++++++++ src/browser/aria.test.ts | 50 +++++++++++++++++++++++++++---- src/browser/aria.ts | 51 +++++++++++++++++++++++++++++--- src/browser/index.ts | 4 ++- src/browser/storageState.test.ts | 48 ++++++++++++++++++++++++++++++ src/browser/storageState.ts | 29 +++++++++++++++++- src/index.ts | 4 +++ 11 files changed, 251 insertions(+), 12 deletions(-) create mode 100644 src/browser/storageState.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d61f19..962d145 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- **Outline-session hardening** (aria twins, uploads, cookie→API): + - `byAriaLabel` / `clickByAriaLabel`: `preferVisible` option — skip hidden Radix/menu twins; **`clickByAriaLabel` defaults `preferVisible: true`**, falling back to the first match if none are visible + - `setFilesViaChooser(page, trigger, files)` — wait for `filechooser` then `setFiles` (slash-menu Image / Upload flows) + - `cookiesToBearer(storageState | path, cookieName)` — `Bearer ` from Playwright storage-state cookies (e.g. Outline `accessToken`) - **Resilient UI automation helpers** — for driving third-party/adversarial SPAs (no stable test ids, occasional bot walls) rather than your own instrumented app: - `byAriaLabel()` / `clickByAriaLabel()` — find/click by a regex over `aria-label`, scoped to a `Page` or a narrower `Locator` (e.g. one dialog) - `withDialog()` — retry an action against a modal that might have silently closed, reopening it first via a caller-supplied `reopen()` diff --git a/README.md b/README.md index 6b7c2b1..ad07da0 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,9 @@ test('sign-out stays on public host', async ({ page, playkitConfig, timings }) = | `saveStorageState` / `storageStateUse` | Auth once, reuse across specs | | `playkitFailureArtifacts` | Trace/video/screenshot only on failure | | `interceptNetworkCall` | Spy or mock the next matching page network call | -| `byAriaLabel` / `clickByAriaLabel` | Find/click by a regex over `aria-label`, scoped to a page or a narrower locator | +| `byAriaLabel` / `clickByAriaLabel` | Find/click by a regex over `aria-label`; `clickByAriaLabel` prefers visible matches (`preferVisible`) | +| `setFilesViaChooser` | Wait for native filechooser then `setFiles` (Image / Upload menus) | +| `cookiesToBearer` | `Bearer …` from Playwright storage-state cookies for follow-up API calls | | `withDialog` | Retry an action against a modal that might have silently closed, reopening it first | | `fillContentEditable` | Type into `contenteditable` rich-text fields with real paragraph breaks | | `runPersistentSession` | Keep one browser session open across runs via flag files instead of relaunching/re-authenticating | @@ -130,12 +132,22 @@ import { withDialog, fillContentEditable, runPersistentSession, + setFilesViaChooser, + cookiesToBearer, } from '@levkin/playkit'; // aria-label is often compound + record-specific ("Edit Staff Automation // Engineer at NiyaSoft") — a regex survives per-record text variation better // than an exact string copied from one DOM dump. await clickByAriaLabel(page, /Edit.*at NiyaSoft/i); +// Defaults preferVisible:true — skips hidden Radix/menu twins with the same label. + +await setFilesViaChooser(page, async () => { + await page.getByText(/^Image$/i).click(); +}, '/tmp/shot.jpg'); + +const auth = cookiesToBearer('.session/state.json', 'accessToken'); +// Authorization: Bearer … // Re-open the dialog and retry if it closed underneath you mid-flow. await withDialog( diff --git a/ROADMAP.md b/ROADMAP.md index f33c0f3..70c2e6f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,6 +20,9 @@ Living plan for making `@levkin/playkit` more useful across Levkin repos. Pulled from real friction driving LinkedIn (third-party, bot-walled, no test ids) from a Cursor/Camoufox automation — see the resume repo's `scripts/linkedin-polish-all.mjs` for the original hand-rolled versions these replace. - [x] `byAriaLabel` / `clickByAriaLabel` — regex-over-`aria-label` locator, scoped to page or a narrower locator +- [x] `preferVisible` on aria helpers (`clickByAriaLabel` defaults on) — skip hidden Radix twins +- [x] `setFilesViaChooser` — filechooser + setFiles for upload/slash-menu flows +- [x] `cookiesToBearer` — storage-state cookie → `Authorization: Bearer …` - [x] `withDialog` — reopen-and-retry wrapper for modals that can silently close mid-flow - [x] `fillContentEditable` — real-keyboard typing into `contenteditable` rich text with paragraph breaks - [x] `runPersistentSession` — flag-file-driven long-lived browser session (RUN/READY/CLOSE), auto-relaunch on crash diff --git a/src/browser/actions.test.ts b/src/browser/actions.test.ts index 0303810..d790daa 100644 --- a/src/browser/actions.test.ts +++ b/src/browser/actions.test.ts @@ -7,6 +7,7 @@ import { click, fill, safeGoto, + setFilesViaChooser, waitForUrlHost, } from './actions.js'; @@ -164,3 +165,30 @@ describe('BasePage', () => { expect(locator.fill).toHaveBeenCalledWith('value', { timeout: 30_000 }); }); }); + +describe('setFilesViaChooser', () => { + it('waits for filechooser, runs trigger, then setFiles', async () => { + const setFiles = vi.fn(async () => undefined); + const waitForEvent = vi.fn(async () => ({ setFiles })); + const page = { waitForEvent } as unknown as Page; + const trigger = vi.fn(async () => undefined); + + await setFilesViaChooser(page, trigger, '/tmp/a.jpg', { logger: silentLogger, timeout: 5_000 }); + + expect(waitForEvent).toHaveBeenCalledWith('filechooser', { timeout: 5_000 }); + expect(trigger).toHaveBeenCalledTimes(1); + expect(setFiles).toHaveBeenCalledWith(['/tmp/a.jpg']); + }); + + it('accepts multiple file paths', async () => { + const setFiles = vi.fn(async () => undefined); + const page = { + waitForEvent: vi.fn(async () => ({ setFiles })), + } as unknown as Page; + + await setFilesViaChooser(page, async () => undefined, ['/a.png', '/b.png'], { + logger: silentLogger, + }); + expect(setFiles).toHaveBeenCalledWith(['/a.png', '/b.png']); + }); +}); diff --git a/src/browser/actions.ts b/src/browser/actions.ts index 1eac310..9db7807 100644 --- a/src/browser/actions.ts +++ b/src/browser/actions.ts @@ -154,6 +154,34 @@ export function assertPublicHost(urlOrHost: string, forbidPrivate = true): void } } +export interface SetFilesViaChooserOptions { + timeout?: number; + logger?: Logger; +} + +/** + * Open a native file chooser (slash-menu Image, Upload button, etc.), then + * set the chosen files. Starts waiting for `filechooser` before running + * `trigger` so the event is never missed. + */ +export async function setFilesViaChooser( + page: Page, + trigger: () => Promise, + files: string | string[], + options?: SetFilesViaChooserOptions, +): Promise { + const log = options?.logger ?? createLogger({ name: 'setFilesViaChooser' }); + const timeout = options?.timeout ?? 30_000; + const paths = Array.isArray(files) ? files : [files]; + log.debug('setFilesViaChooser', { files: paths.length, timeout }); + + const [chooser] = await Promise.all([ + page.waitForEvent('filechooser', { timeout }), + trigger(), + ]); + await chooser.setFiles(paths); +} + /** * Thin Page Object base — prefer getByRole / getByTestId in subclasses. */ diff --git a/src/browser/aria.test.ts b/src/browser/aria.test.ts index e33f3fd..42d6a03 100644 --- a/src/browser/aria.test.ts +++ b/src/browser/aria.test.ts @@ -2,11 +2,12 @@ import { describe, expect, it, vi } from 'vitest'; import type { Locator, Page } from '@playwright/test'; import { byAriaLabel, clickByAriaLabel } from './aria.js'; -function mockLocatorList(labels: string[]) { +function mockLocatorList(labels: string[], visibility: boolean[] = []) { const clicked: number[] = []; const nth = (i: number) => ({ getAttribute: vi.fn(async (name: string) => (name === 'aria-label' ? labels[i] ?? null : null)), + isVisible: vi.fn(async () => visibility[i] ?? true), click: vi.fn(async () => { clicked.push(i); }), @@ -21,12 +22,12 @@ function mockLocatorList(labels: string[]) { return { list, clicked }; } -function mockRoot(labels: string[]) { - const { list, clicked } = mockLocatorList(labels); +function mockRoot(labels: string[], visibility?: boolean[]) { + const { list, clicked } = mockLocatorList(labels, visibility); const root = { locator: vi.fn(() => list), } as unknown as Page; - return { root, clicked }; + return { root, clicked, list }; } describe('byAriaLabel', () => { @@ -48,16 +49,55 @@ describe('byAriaLabel', () => { const found = await byAriaLabel(dialogRoot, /Save/); expect(found).not.toBeNull(); }); + + it('with preferVisible skips a hidden match and returns the visible twin', async () => { + const { root, list } = mockRoot( + ['Document options', 'Document options'], + [false, true], + ); + const found = await byAriaLabel(root, /^Document options$/i, { preferVisible: true }); + expect(found).not.toBeNull(); + // second candidate (index 1) + expect(list.nth).toHaveBeenCalled(); + expect(await found!.isVisible()).toBe(true); + expect(await found!.getAttribute('aria-label')).toBe('Document options'); + }); + + it('with preferVisible falls back to the first match when all are hidden', async () => { + const { root } = mockRoot(['Document options', 'Document options'], [false, false]); + const found = await byAriaLabel(root, /^Document options$/i, { preferVisible: true }); + expect(found).not.toBeNull(); + expect(await found!.isVisible()).toBe(false); + }); }); describe('clickByAriaLabel', () => { it('finds and clicks the matching element, returning its label', async () => { - const { root, clicked } = mockRoot(['Home', 'Edit AI Engineer at Levkin Inc.']); + const { root, clicked } = mockRoot(['Home', 'Edit AI Engineer at Levkin Inc.'], [true, true]); const label = await clickByAriaLabel(root, /Edit.*at Levkin/i); expect(label).toBe('Edit AI Engineer at Levkin Inc.'); expect(clicked).toEqual([1]); }); + it('defaults preferVisible and clicks the visible twin', async () => { + const { root, clicked } = mockRoot( + ['Document options', 'Document options'], + [false, true], + ); + const label = await clickByAriaLabel(root, /^Document options$/i); + expect(label).toBe('Document options'); + expect(clicked).toEqual([1]); + }); + + it('with preferVisible: false clicks the first match even if hidden', async () => { + const { root, clicked } = mockRoot( + ['Document options', 'Document options'], + [false, true], + ); + await clickByAriaLabel(root, /^Document options$/i, { preferVisible: false, force: true }); + expect(clicked).toEqual([0]); + }); + it('throws a descriptive error when nothing matches', async () => { const { root } = mockRoot(['Home']); await expect(clickByAriaLabel(root, /Edit.*Nonexistent/i)).rejects.toThrow( diff --git a/src/browser/aria.ts b/src/browser/aria.ts index 7c6768d..5dcb07d 100644 --- a/src/browser/aria.ts +++ b/src/browser/aria.ts @@ -5,6 +5,12 @@ 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; } @@ -25,6 +31,9 @@ const DEFAULT_SELECTOR = * 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, @@ -33,16 +42,44 @@ export async function byAriaLabel( ): Promise { 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)) { - log.debug('byAriaLabel matched', { pattern: pattern.source, label }); - return el; + 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; } @@ -51,13 +88,19 @@ export async function byAriaLabel( * `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 { - const found = await byAriaLabel(root, pattern, options); + 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`); } diff --git a/src/browser/index.ts b/src/browser/index.ts index 97807b1..b862507 100644 --- a/src/browser/index.ts +++ b/src/browser/index.ts @@ -7,11 +7,13 @@ export { waitForHidden, waitForUrlHost, assertPublicHost, + setFilesViaChooser, type ClickOptions, type FillOptions, type GotoOptions, + type SetFilesViaChooserOptions, } from './actions.js'; -export { saveStorageState, storageStateUse } from './storageState.js'; +export { saveStorageState, storageStateUse, cookiesToBearer, type StorageStateLike } from './storageState.js'; export { playkitFailureArtifacts } from './playwrightPreset.js'; export { interceptNetworkCall, diff --git a/src/browser/storageState.test.ts b/src/browser/storageState.test.ts new file mode 100644 index 0000000..a72056e --- /dev/null +++ b/src/browser/storageState.test.ts @@ -0,0 +1,48 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { cookiesToBearer, storageStateUse } from './storageState.js'; + +describe('storageStateUse', () => { + it('returns a Playwright use() blob', () => { + expect(storageStateUse('/tmp/state.json')).toEqual({ storageState: '/tmp/state.json' }); + }); +}); + +describe('cookiesToBearer', () => { + it('formats Bearer from an in-memory storage state', () => { + const auth = cookiesToBearer( + { + cookies: [ + { name: 'csrfToken', value: 'x' }, + { name: 'accessToken', value: 'tok-123' }, + ], + }, + 'accessToken', + ); + expect(auth).toBe('Bearer tok-123'); + }); + + it('reads storage state from a JSON file path', () => { + const dir = mkdtempSync(join(tmpdir(), 'playkit-state-')); + const path = join(dir, 'state.json'); + writeFileSync( + path, + JSON.stringify({ cookies: [{ name: 'accessToken', value: 'from-file' }] }), + ); + expect(cookiesToBearer(path, 'accessToken')).toBe('Bearer from-file'); + }); + + it('throws when the cookie is missing', () => { + expect(() => cookiesToBearer({ cookies: [] }, 'accessToken')).toThrow( + /cookie "accessToken" not found/, + ); + }); + + it('throws when cookies array is missing', () => { + expect(() => cookiesToBearer({} as { cookies: [] }, 'accessToken')).toThrow( + /no cookies array/, + ); + }); +}); diff --git a/src/browser/storageState.ts b/src/browser/storageState.ts index 89167d4..fedbcea 100644 --- a/src/browser/storageState.ts +++ b/src/browser/storageState.ts @@ -1,4 +1,4 @@ -import { mkdirSync } from 'node:fs'; +import { mkdirSync, readFileSync } from 'node:fs'; import { dirname } from 'node:path'; import type { BrowserContext, Page } from '@playwright/test'; @@ -20,3 +20,30 @@ export async function saveStorageState( export function storageStateUse(filePath: string): { storageState: string } { return { storageState: filePath }; } + +export type StorageStateLike = { + cookies: Array<{ name: string; value: string }>; +}; + +/** + * Pull a named cookie from a Playwright storage-state object or JSON file path + * and return an `Authorization` header value (`Bearer `). + * + * Useful when a headed login left cookies on disk and a follow-up script needs + * the same session against a JSON API (e.g. Outline `accessToken`). + */ +export function cookiesToBearer(state: StorageStateLike | string, cookieName: string): string { + const parsed: StorageStateLike = + typeof state === 'string' + ? (JSON.parse(readFileSync(state, 'utf8')) as StorageStateLike) + : state; + const cookies = parsed?.cookies; + if (!Array.isArray(cookies)) { + throw new Error('cookiesToBearer: storage state has no cookies array'); + } + const hit = cookies.find((c) => c.name === cookieName); + if (!hit?.value) { + throw new Error(`cookiesToBearer: cookie "${cookieName}" not found`); + } + return `Bearer ${hit.value}`; +} diff --git a/src/index.ts b/src/index.ts index ca8b227..8acc6d4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ export { assertPublicHost, saveStorageState, storageStateUse, + cookiesToBearer, playkitFailureArtifacts, interceptNetworkCall, startNetworkErrorMonitor, @@ -42,9 +43,12 @@ export { fillContentEditable, runPersistentSession, isBrowserCrashError, + setFilesViaChooser, type ClickOptions, type FillOptions, type GotoOptions, + type SetFilesViaChooserOptions, + type StorageStateLike, type FulfillResponse, type InterceptNetworkCallOptions, type InterceptedNetworkCall,