Outline-session browser helpers #17
@@ -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()`
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user