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,