Outline-session browser helpers #17

Merged
ilia merged 1 commits from feat/outline-session-hardening into main 2026-08-09 18:16:54 -05:00
11 changed files with 251 additions and 12 deletions
+4
View File
@@ -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 <token>` 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()`
+13 -1
View File
@@ -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(
+3
View File
@@ -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
+28
View File
@@ -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']);
});
});
+28
View File
@@ -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<void>,
files: string | string[],
options?: SetFilesViaChooserOptions,
): Promise<void> {
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.
*/
+45 -5
View File
@@ -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(
+47 -4
View File
@@ -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<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)) {
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<string> {
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`);
}
+3 -1
View File
@@ -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,
+48
View File
@@ -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/,
);
});
});
+28 -1
View File
@@ -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 <token>`).
*
* 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}`;
}
+4
View File
@@ -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,