Add resilient UI helpers for third-party / adversarial SPAs #8
@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
- **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()`
|
||||||
|
- `fillContentEditable()` — type into `contenteditable` rich-text fields (not just `<textarea>`), splitting on blank lines so multi-paragraph/bulleted text renders as separate blocks instead of collapsing
|
||||||
|
- `runPersistentSession()` — keep one browser session open across many runs via `RUN`/`READY`/`CLOSE` flag files instead of relaunching (and re-authenticating) every time; auto-relaunches on an unexpected crash
|
||||||
|
- `NetworkErrorMonitor`: `useDefaultExcludes` + exported `COMMON_NOISE_PATTERNS` (ad/telemetry pixel hosts) so consumers stop hand-rolling the same exclude list per project
|
||||||
- Ops: npm publish is hard-required in the tag release job (removed soft-fail); `NPM_PUBLISH_TOKEN` + Outline update scopes documented as done in `docs/OPS.md` / `ROADMAP.md`
|
- Ops: npm publish is hard-required in the tag release job (removed soft-fail); `NPM_PUBLISH_TOKEN` + Outline update scopes documented as done in `docs/OPS.md` / `ROADMAP.md`
|
||||||
|
|
||||||
## 0.4.0 — 2026-07-15
|
## 0.4.0 — 2026-07-15
|
||||||
|
|||||||
57
README.md
57
README.md
@ -85,6 +85,10 @@ test('sign-out stays on public host', async ({ page, playkitConfig, timings }) =
|
|||||||
| `saveStorageState` / `storageStateUse` | Auth once, reuse across specs |
|
| `saveStorageState` / `storageStateUse` | Auth once, reuse across specs |
|
||||||
| `playkitFailureArtifacts` | Trace/video/screenshot only on failure |
|
| `playkitFailureArtifacts` | Trace/video/screenshot only on failure |
|
||||||
| `interceptNetworkCall` | Spy or mock the next matching page network call |
|
| `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 |
|
||||||
|
| `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 |
|
||||||
| `playkit` CLI | `init` scaffold + `smoke` post-deploy gate |
|
| `playkit` CLI | `init` scaffold + `smoke` post-deploy gate |
|
||||||
|
|
||||||
See also `docs/NETWORK.md`, `docs/SELFTEST.md`, `docs/NPM_REGISTRY.md`, `docs/OPS.md`, `docs/IDEAS.md`, `docs/OUTLINE.md`.
|
See also `docs/NETWORK.md`, `docs/SELFTEST.md`, `docs/NPM_REGISTRY.md`, `docs/OPS.md`, `docs/IDEAS.md`, `docs/OUTLINE.md`.
|
||||||
@ -105,6 +109,59 @@ try {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Resilient UI automation (third-party / adversarial SPAs)
|
||||||
|
|
||||||
|
`click`/`fill`/`waitForVisible` above assume you already have a correct
|
||||||
|
`Locator` — great when you control the markup and have stable test ids. These
|
||||||
|
helpers are for the opposite case: driving a third-party site you don't
|
||||||
|
control, where selectors are dynamic/compound (aria-labels that embed
|
||||||
|
record-specific text), rich-text fields aren't real `<textarea>`s, modals
|
||||||
|
close out from under you, and the site has its own bot-detection you'd rather
|
||||||
|
not retrigger by relaunching the browser on every run.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
byAriaLabel,
|
||||||
|
clickByAriaLabel,
|
||||||
|
withDialog,
|
||||||
|
fillContentEditable,
|
||||||
|
runPersistentSession,
|
||||||
|
} 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);
|
||||||
|
|
||||||
|
// Re-open the dialog and retry if it closed underneath you mid-flow.
|
||||||
|
await withDialog(
|
||||||
|
{
|
||||||
|
isOpen: () => page.getByRole('button', { name: 'Save' }).isVisible(),
|
||||||
|
reopen: () => clickByAriaLabel(page, /Edit.*at NiyaSoft/i),
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
await fillContentEditable(page.locator('[contenteditable="true"]').first(), longBioText);
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep ONE browser open across many runs — touch RUN/READY/CLOSE flag files
|
||||||
|
// instead of relaunching (and re-triggering captchas) each time.
|
||||||
|
await runPersistentSession({
|
||||||
|
dir: '.session',
|
||||||
|
storageStatePath: '.session/state.json',
|
||||||
|
launch: async () => {
|
||||||
|
const browser = await chromium.launch({ headless: false });
|
||||||
|
const context = await browser.newContext();
|
||||||
|
const page = await context.newPage();
|
||||||
|
return { browser, context, page };
|
||||||
|
},
|
||||||
|
onRun: async ({ page }) => {
|
||||||
|
/* your flow against the live page */
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
## Email testing (Mailpit default, Mailtrap optional)
|
## Email testing (Mailpit default, Mailtrap optional)
|
||||||
|
|
||||||
`createMailInbox()` picks the provider from `PLAYKIT_MAIL_PROVIDER` (default
|
`createMailInbox()` picks the provider from `PLAYKIT_MAIL_PROVIDER` (default
|
||||||
|
|||||||
10
ROADMAP.md
10
ROADMAP.md
@ -15,6 +15,16 @@ Living plan for making `@levkin/playkit` more useful across Levkin repos.
|
|||||||
- [ ] End adoption pause — migrate first extra consumer (`screening` candidate)
|
- [ ] End adoption pause — migrate first extra consumer (`screening` candidate)
|
||||||
- [ ] Evaluate `playwright-exporter` for scheduled synthetics (may supersede bespoke cron wrappers around `playkit smoke`)
|
- [ ] Evaluate `playwright-exporter` for scheduled synthetics (may supersede bespoke cron wrappers around `playkit smoke`)
|
||||||
|
|
||||||
|
## Done since v0.4.0 (resilient UI automation)
|
||||||
|
|
||||||
|
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] `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
|
||||||
|
- [x] `NetworkErrorMonitor` `useDefaultExcludes` + `COMMON_NOISE_PATTERNS` (ad/telemetry noise preset)
|
||||||
|
|
||||||
## Done since v0.4.0 (ops)
|
## Done since v0.4.0 (ops)
|
||||||
|
|
||||||
- [x] **Wire `NPM_PUBLISH_TOKEN` / `write:package`** — `@levkin/playkit@0.4.0` published; Actions secret + `vault_playkit_npm_token`; release job no longer soft-fails (`docs/OPS.md`)
|
- [x] **Wire `NPM_PUBLISH_TOKEN` / `write:package`** — `@levkin/playkit@0.4.0` published; Actions secret + `vault_playkit_npm_token`; release job no longer soft-fails (`docs/OPS.md`)
|
||||||
|
|||||||
67
src/browser/aria.test.ts
Normal file
67
src/browser/aria.test.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { Locator, Page } from '@playwright/test';
|
||||||
|
import { byAriaLabel, clickByAriaLabel } from './aria.js';
|
||||||
|
|
||||||
|
function mockLocatorList(labels: string[]) {
|
||||||
|
const clicked: number[] = [];
|
||||||
|
const nth = (i: number) =>
|
||||||
|
({
|
||||||
|
getAttribute: vi.fn(async (name: string) => (name === 'aria-label' ? labels[i] ?? null : null)),
|
||||||
|
click: vi.fn(async () => {
|
||||||
|
clicked.push(i);
|
||||||
|
}),
|
||||||
|
waitFor: vi.fn(async () => undefined),
|
||||||
|
}) as unknown as Locator;
|
||||||
|
|
||||||
|
const list = {
|
||||||
|
count: vi.fn(async () => labels.length),
|
||||||
|
nth: vi.fn((i: number) => nth(i)),
|
||||||
|
} as unknown as Locator;
|
||||||
|
|
||||||
|
return { list, clicked };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockRoot(labels: string[]) {
|
||||||
|
const { list, clicked } = mockLocatorList(labels);
|
||||||
|
const root = {
|
||||||
|
locator: vi.fn(() => list),
|
||||||
|
} as unknown as Page;
|
||||||
|
return { root, clicked };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('byAriaLabel', () => {
|
||||||
|
it('returns the first locator whose aria-label matches the pattern', async () => {
|
||||||
|
const { root } = mockRoot(['Home', 'Edit AI Engineer at Levkin Inc.', 'Edit About']);
|
||||||
|
const found = await byAriaLabel(root, /Edit.*at Levkin/i);
|
||||||
|
expect(found).not.toBeNull();
|
||||||
|
expect(await found!.getAttribute('aria-label')).toBe('Edit AI Engineer at Levkin Inc.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when nothing matches', async () => {
|
||||||
|
const { root } = mockRoot(['Home', 'Notifications']);
|
||||||
|
const found = await byAriaLabel(root, /Edit.*Nonexistent/i);
|
||||||
|
expect(found).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is scoped to whatever root is passed (page or a narrower locator)', async () => {
|
||||||
|
const { root: dialogRoot } = mockRoot(['Save', 'Cancel']);
|
||||||
|
const found = await byAriaLabel(dialogRoot, /Save/);
|
||||||
|
expect(found).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 label = await clickByAriaLabel(root, /Edit.*at Levkin/i);
|
||||||
|
expect(label).toBe('Edit AI Engineer at Levkin Inc.');
|
||||||
|
expect(clicked).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws a descriptive error when nothing matches', async () => {
|
||||||
|
const { root } = mockRoot(['Home']);
|
||||||
|
await expect(clickByAriaLabel(root, /Edit.*Nonexistent/i)).rejects.toThrow(
|
||||||
|
/no element with aria-label matching/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
67
src/browser/aria.ts
Normal file
67
src/browser/aria.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
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;
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
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 candidates = root.locator(selector);
|
||||||
|
const count = await candidates.count();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
export async function clickByAriaLabel(
|
||||||
|
root: Page | Locator,
|
||||||
|
pattern: RegExp,
|
||||||
|
options?: ByAriaLabelOptions & ClickOptions,
|
||||||
|
): Promise<string> {
|
||||||
|
const found = await byAriaLabel(root, pattern, options);
|
||||||
|
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;
|
||||||
|
}
|
||||||
56
src/browser/dialog.test.ts
Normal file
56
src/browser/dialog.test.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { withDialog } from './dialog.js';
|
||||||
|
|
||||||
|
describe('withDialog', () => {
|
||||||
|
it('runs fn directly when the dialog is already open', async () => {
|
||||||
|
const isOpen = vi.fn(async () => true);
|
||||||
|
const reopen = vi.fn(async () => undefined);
|
||||||
|
const fn = vi.fn(async () => 'ok');
|
||||||
|
|
||||||
|
const result = await withDialog({ isOpen, reopen }, fn);
|
||||||
|
|
||||||
|
expect(result).toBe('ok');
|
||||||
|
expect(reopen).not.toHaveBeenCalled();
|
||||||
|
expect(fn).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reopens the dialog first when it is not open', async () => {
|
||||||
|
const isOpen = vi.fn(async () => false);
|
||||||
|
const reopen = vi.fn(async () => undefined);
|
||||||
|
const fn = vi.fn(async () => 'ok');
|
||||||
|
|
||||||
|
const result = await withDialog({ isOpen, reopen }, fn);
|
||||||
|
|
||||||
|
expect(result).toBe('ok');
|
||||||
|
expect(reopen).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries with a reopen after fn throws, then succeeds', async () => {
|
||||||
|
const isOpen = vi.fn(async () => true);
|
||||||
|
const reopen = vi.fn(async () => undefined);
|
||||||
|
let calls = 0;
|
||||||
|
const fn = vi.fn(async () => {
|
||||||
|
calls += 1;
|
||||||
|
if (calls === 1) throw new Error('modal lost');
|
||||||
|
return 'recovered';
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await withDialog({ isOpen, reopen }, fn, { retries: 1 });
|
||||||
|
|
||||||
|
expect(result).toBe('recovered');
|
||||||
|
expect(fn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws the last error once retries are exhausted', async () => {
|
||||||
|
const isOpen = vi.fn(async () => true);
|
||||||
|
const reopen = vi.fn(async () => undefined);
|
||||||
|
const fn = vi.fn(async () => {
|
||||||
|
throw new Error('still broken');
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(withDialog({ isOpen, reopen }, fn, { retries: 1 })).rejects.toThrow(
|
||||||
|
'still broken',
|
||||||
|
);
|
||||||
|
expect(fn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
50
src/browser/dialog.ts
Normal file
50
src/browser/dialog.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { createLogger, type Logger } from '../logging/logger.js';
|
||||||
|
|
||||||
|
export interface WithDialogHandlers {
|
||||||
|
/** True when the dialog/modal is currently open and usable. */
|
||||||
|
isOpen: () => Promise<boolean>;
|
||||||
|
/** Re-open the dialog (e.g. re-click the trigger, re-navigate). */
|
||||||
|
reopen: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WithDialogOptions {
|
||||||
|
/** How many times to reopen-and-retry after a failure (default 1 = two attempts total). */
|
||||||
|
retries?: number;
|
||||||
|
logger?: Logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `fn` against a dialog/modal that a third-party SPA might silently close
|
||||||
|
* out from under you (re-render, toast, navigation) between when you opened
|
||||||
|
* it and when you finish interacting with it.
|
||||||
|
*
|
||||||
|
* Checks `isOpen()` before each attempt and calls `reopen()` if it's not —
|
||||||
|
* this is the "modal lost — reopen" retry pattern, generalized instead of
|
||||||
|
* hand-rolled per call site.
|
||||||
|
*/
|
||||||
|
export async function withDialog<T>(
|
||||||
|
handlers: WithDialogHandlers,
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
options?: WithDialogOptions,
|
||||||
|
): Promise<T> {
|
||||||
|
const log = options?.logger ?? createLogger({ name: 'withDialog' });
|
||||||
|
const retries = options?.retries ?? 1;
|
||||||
|
let lastErr: unknown;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||||
|
if (!(await handlers.isOpen())) {
|
||||||
|
log.warn('dialog not open — reopening', { attempt });
|
||||||
|
await handlers.reopen();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err;
|
||||||
|
log.warn('withDialog attempt failed', {
|
||||||
|
attempt,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr;
|
||||||
|
}
|
||||||
@ -21,9 +21,23 @@ export {
|
|||||||
dedupeNetworkErrors,
|
dedupeNetworkErrors,
|
||||||
globToRegExp,
|
globToRegExp,
|
||||||
responseMatchesFilter,
|
responseMatchesFilter,
|
||||||
|
COMMON_NOISE_PATTERNS,
|
||||||
type FulfillResponse,
|
type FulfillResponse,
|
||||||
type InterceptNetworkCallOptions,
|
type InterceptNetworkCallOptions,
|
||||||
type InterceptedNetworkCall,
|
type InterceptedNetworkCall,
|
||||||
type NetworkError,
|
type NetworkError,
|
||||||
type NetworkErrorMonitorOptions,
|
type NetworkErrorMonitorOptions,
|
||||||
} from './network.js';
|
} from './network.js';
|
||||||
|
export {
|
||||||
|
byAriaLabel,
|
||||||
|
clickByAriaLabel,
|
||||||
|
type ByAriaLabelOptions,
|
||||||
|
} from './aria.js';
|
||||||
|
export { withDialog, type WithDialogHandlers, type WithDialogOptions } from './dialog.js';
|
||||||
|
export { fillContentEditable, type FillContentEditableOptions } from './richText.js';
|
||||||
|
export {
|
||||||
|
runPersistentSession,
|
||||||
|
isBrowserCrashError,
|
||||||
|
type PersistentSessionOptions,
|
||||||
|
type LaunchedSession,
|
||||||
|
} from './persistentSession.js';
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import type { Page, Request, Response, Route } from '@playwright/test';
|
import type { Page, Request, Response, Route } from '@playwright/test';
|
||||||
import {
|
import {
|
||||||
|
COMMON_NOISE_PATTERNS,
|
||||||
dedupeNetworkErrors,
|
dedupeNetworkErrors,
|
||||||
globToRegExp,
|
globToRegExp,
|
||||||
interceptNetworkCall,
|
interceptNetworkCall,
|
||||||
@ -180,6 +181,39 @@ describe('NetworkErrorMonitor', () => {
|
|||||||
net.stop();
|
net.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('leaves default noise (ad/telemetry pixels) untouched unless useDefaultExcludes is set', () => {
|
||||||
|
const page = createMockPage();
|
||||||
|
const net = startNetworkErrorMonitor(page);
|
||||||
|
page.emitResponse(mockResponse({ status: 502, url: 'https://www.googletagmanager.com/gtm.js' }));
|
||||||
|
expect(net.getErrors()).toHaveLength(1);
|
||||||
|
net.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('useDefaultExcludes filters COMMON_NOISE_PATTERNS in addition to custom excludePatterns', () => {
|
||||||
|
const page = createMockPage();
|
||||||
|
const net = startNetworkErrorMonitor(page, {
|
||||||
|
useDefaultExcludes: true,
|
||||||
|
excludePatterns: ['my-own-cdn.example.com'],
|
||||||
|
});
|
||||||
|
|
||||||
|
page.emitResponse(mockResponse({ status: 502, url: 'https://www.googletagmanager.com/gtm.js' }));
|
||||||
|
page.emitResponse(mockResponse({ status: 500, url: 'https://my-own-cdn.example.com/x.js' }));
|
||||||
|
page.emitResponse(mockResponse({ status: 503, url: 'https://app.example.com/real-endpoint' }));
|
||||||
|
|
||||||
|
expect(net.getErrors()).toEqual([
|
||||||
|
expect.objectContaining({ url: 'https://app.example.com/real-endpoint' }),
|
||||||
|
]);
|
||||||
|
net.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('COMMON_NOISE_PATTERNS matches the ad/telemetry hosts it documents', () => {
|
||||||
|
expect(matchesExcludePattern('https://www.doubleclick.net/x', COMMON_NOISE_PATTERNS)).toBe(true);
|
||||||
|
expect(matchesExcludePattern('https://connect.facebook.net/x', COMMON_NOISE_PATTERNS)).toBe(true);
|
||||||
|
expect(matchesExcludePattern('https://app.example.com/api/users', COMMON_NOISE_PATTERNS)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('stop() ignores further responses', () => {
|
it('stop() ignores further responses', () => {
|
||||||
const page = createMockPage();
|
const page = createMockPage();
|
||||||
const net = startNetworkErrorMonitor(page);
|
const net = startNetworkErrorMonitor(page);
|
||||||
|
|||||||
@ -162,9 +162,32 @@ export interface NetworkError {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ad/telemetry/tracking-pixel hosts that routinely 4xx/5xx in the background
|
||||||
|
* on third-party sites and are almost never what you're debugging. Opt in via
|
||||||
|
* `useDefaultExcludes: true` — off by default so existing consumers asserting
|
||||||
|
* against their own app's traffic see no behavior change.
|
||||||
|
*/
|
||||||
|
export const COMMON_NOISE_PATTERNS: Array<string | RegExp> = [
|
||||||
|
'doubleclick.net',
|
||||||
|
'googlesyndication.com',
|
||||||
|
'google-analytics.com',
|
||||||
|
'googletagmanager.com',
|
||||||
|
'facebook.com/tr',
|
||||||
|
'connect.facebook.net',
|
||||||
|
'segment.io',
|
||||||
|
'segment.com',
|
||||||
|
/\bads?\b/i,
|
||||||
|
/\/pixel(\/|\?|$)/i,
|
||||||
|
/\btelemetry\b/i,
|
||||||
|
/\btrack(ing)?\b/i,
|
||||||
|
];
|
||||||
|
|
||||||
export interface NetworkErrorMonitorOptions {
|
export interface NetworkErrorMonitorOptions {
|
||||||
/** Skip matching URLs (string substring or RegExp). */
|
/** Skip matching URLs (string substring or RegExp). */
|
||||||
excludePatterns?: Array<string | RegExp>;
|
excludePatterns?: Array<string | RegExp>;
|
||||||
|
/** Also skip anything matching `COMMON_NOISE_PATTERNS` (default false). */
|
||||||
|
useDefaultExcludes?: boolean;
|
||||||
/** Minimum status to treat as an error (default 400). */
|
/** Minimum status to treat as an error (default 400). */
|
||||||
minStatus?: number;
|
minStatus?: number;
|
||||||
logger?: Logger;
|
logger?: Logger;
|
||||||
@ -202,7 +225,9 @@ export class NetworkErrorMonitor {
|
|||||||
) {
|
) {
|
||||||
const log = options.logger ?? createLogger({ name: 'NetworkErrorMonitor' });
|
const log = options.logger ?? createLogger({ name: 'NetworkErrorMonitor' });
|
||||||
const minStatus = options.minStatus ?? 400;
|
const minStatus = options.minStatus ?? 400;
|
||||||
const exclude = options.excludePatterns ?? [];
|
const exclude = options.useDefaultExcludes
|
||||||
|
? [...COMMON_NOISE_PATTERNS, ...(options.excludePatterns ?? [])]
|
||||||
|
: options.excludePatterns ?? [];
|
||||||
|
|
||||||
this.onResponse = (response: Response) => {
|
this.onResponse = (response: Response) => {
|
||||||
if (this.stopped) return;
|
if (this.stopped) return;
|
||||||
|
|||||||
137
src/browser/persistentSession.test.ts
Normal file
137
src/browser/persistentSession.test.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { Browser, BrowserContext, Page } from '@playwright/test';
|
||||||
|
import { isBrowserCrashError, runPersistentSession } from './persistentSession.js';
|
||||||
|
|
||||||
|
function mockSession() {
|
||||||
|
const context = {
|
||||||
|
storageState: vi.fn(async () => undefined),
|
||||||
|
} as unknown as BrowserContext;
|
||||||
|
const page = {
|
||||||
|
url: vi.fn(() => 'https://example.com'),
|
||||||
|
} as unknown as Page;
|
||||||
|
const browser = {
|
||||||
|
isConnected: vi.fn(() => true),
|
||||||
|
close: vi.fn(async () => undefined),
|
||||||
|
} as unknown as Browser;
|
||||||
|
return { browser, context, page };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('isBrowserCrashError', () => {
|
||||||
|
it('recognizes common Playwright crash messages', () => {
|
||||||
|
expect(isBrowserCrashError(new Error('Target page, context or browser has been closed'))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(isBrowserCrashError(new Error('Target closed'))).toBe(true);
|
||||||
|
expect(isBrowserCrashError(new Error('something else'))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runPersistentSession', () => {
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (dir) rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs onRun once at start, then exits once onRun touches CLOSE', async () => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'playkit-session-'));
|
||||||
|
const session = mockSession();
|
||||||
|
const launch = vi.fn(async () => session);
|
||||||
|
const onRun = vi.fn(async () => {
|
||||||
|
writeFileSync(join(dir, 'CLOSE'), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
await runPersistentSession({ dir, launch, onRun, pollMs: 5 });
|
||||||
|
|
||||||
|
expect(launch).toHaveBeenCalledOnce();
|
||||||
|
expect(onRun).toHaveBeenCalledOnce();
|
||||||
|
expect(session.browser.close).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-runs onRun when RUN is touched, saving storage state on each success', async () => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'playkit-session-'));
|
||||||
|
const storageStatePath = join(dir, 'state.json');
|
||||||
|
const session = mockSession();
|
||||||
|
const launch = vi.fn(async () => session);
|
||||||
|
let calls = 0;
|
||||||
|
const onRun = vi.fn(async () => {
|
||||||
|
calls += 1;
|
||||||
|
if (calls === 1) {
|
||||||
|
setTimeout(() => writeFileSync(join(dir, 'RUN'), ''), 20);
|
||||||
|
} else {
|
||||||
|
writeFileSync(join(dir, 'CLOSE'), '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await runPersistentSession({ dir, launch, onRun, storageStatePath, pollMs: 5 });
|
||||||
|
|
||||||
|
expect(onRun).toHaveBeenCalledTimes(2);
|
||||||
|
// Once after each successful run, plus once more on the final CLOSE shutdown.
|
||||||
|
expect(session.context.storageState).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('relaunches automatically when the page/browser looks dead', async () => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'playkit-session-'));
|
||||||
|
const freshSession = mockSession();
|
||||||
|
const deadPage = {
|
||||||
|
url: vi.fn(() => {
|
||||||
|
throw new Error('closed');
|
||||||
|
}),
|
||||||
|
} as unknown as Page;
|
||||||
|
const deadBrowser = {
|
||||||
|
isConnected: vi.fn(() => false),
|
||||||
|
close: vi.fn(async () => undefined),
|
||||||
|
} as unknown as Browser;
|
||||||
|
|
||||||
|
let launchCount = 0;
|
||||||
|
const launch = vi.fn(async () => {
|
||||||
|
launchCount += 1;
|
||||||
|
if (launchCount === 1) {
|
||||||
|
return { browser: deadBrowser, context: freshSession.context, page: deadPage };
|
||||||
|
}
|
||||||
|
return freshSession;
|
||||||
|
});
|
||||||
|
const onRun = vi.fn(async () => {
|
||||||
|
writeFileSync(join(dir, 'CLOSE'), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
await runPersistentSession({ dir, launch, onRun, pollMs: 5 });
|
||||||
|
|
||||||
|
expect(launch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(onRun).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the session open when onError returns true, and stops once CLOSE appears', async () => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'playkit-session-'));
|
||||||
|
const session = mockSession();
|
||||||
|
const launch = vi.fn(async () => session);
|
||||||
|
let calls = 0;
|
||||||
|
const onRun = vi.fn(async () => {
|
||||||
|
calls += 1;
|
||||||
|
throw new Error('boom');
|
||||||
|
});
|
||||||
|
const onError = vi.fn(async () => {
|
||||||
|
writeFileSync(join(dir, 'CLOSE'), '');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await runPersistentSession({ dir, launch, onRun, onError, pollMs: 5 });
|
||||||
|
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
expect(onError).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rethrows when onError is not provided', async () => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'playkit-session-'));
|
||||||
|
const session = mockSession();
|
||||||
|
const launch = vi.fn(async () => session);
|
||||||
|
const onRun = vi.fn(async () => {
|
||||||
|
throw new Error('fatal');
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(runPersistentSession({ dir, launch, onRun, pollMs: 5 })).rejects.toThrow('fatal');
|
||||||
|
});
|
||||||
|
});
|
||||||
131
src/browser/persistentSession.ts
Normal file
131
src/browser/persistentSession.ts
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { Browser, BrowserContext, Page } from '@playwright/test';
|
||||||
|
import { createLogger, type Logger } from '../logging/logger.js';
|
||||||
|
import { saveStorageState } from './storageState.js';
|
||||||
|
|
||||||
|
export interface LaunchedSession {
|
||||||
|
browser: Browser;
|
||||||
|
context: BrowserContext;
|
||||||
|
page: Page;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersistentSessionOptions {
|
||||||
|
/** Directory for control flag files (and, by default, storage state). */
|
||||||
|
dir: string;
|
||||||
|
/** (Re)launch the browser/context/page. Called on start and after any crash. */
|
||||||
|
launch: () => Promise<LaunchedSession>;
|
||||||
|
/** Runs once at start, and again every time RUN or READY is touched. */
|
||||||
|
onRun: (session: LaunchedSession) => Promise<void>;
|
||||||
|
/**
|
||||||
|
* Called when `onRun` throws (after a browser-crash relaunch is already
|
||||||
|
* handled internally). Return `true` to keep the session open and wait for
|
||||||
|
* a manual RUN (e.g. after a human clears a captcha); return `false`, or
|
||||||
|
* don't provide this, to rethrow and stop the loop.
|
||||||
|
*/
|
||||||
|
onError?: (error: unknown, session: LaunchedSession) => Promise<boolean> | boolean;
|
||||||
|
/** Path to persist storage state (cookies + localStorage) after each successful run. */
|
||||||
|
storageStatePath?: string;
|
||||||
|
/** Flag filenames, relative to `dir` (defaults: RUN / READY / CLOSE). */
|
||||||
|
flags?: { run?: string; ready?: string; close?: string };
|
||||||
|
/** Poll interval in ms while idle (default 2000). */
|
||||||
|
pollMs?: number;
|
||||||
|
logger?: Logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CRASH_PATTERN = /context or browser has been closed|Target (page|closed)|Target page/i;
|
||||||
|
|
||||||
|
/** Exported for tests; true when `err` looks like the page/browser died mid-run. */
|
||||||
|
export function isBrowserCrashError(err: unknown): boolean {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return CRASH_PATTERN.test(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFlag(path: string): void {
|
||||||
|
try {
|
||||||
|
unlinkSync(path);
|
||||||
|
} catch {
|
||||||
|
/* already gone */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep ONE browser session open across many automation runs, controlled by
|
||||||
|
* flag files instead of process restarts/relogins.
|
||||||
|
*
|
||||||
|
* Built for sites with bot-detection or occasional captchas, where closing
|
||||||
|
* and reopening the browser on every run is slow and can itself trip
|
||||||
|
* anti-automation heuristics. Instead:
|
||||||
|
*
|
||||||
|
* - touch `<dir>/RUN` — re-run `onRun` against the *same* page
|
||||||
|
* - touch `<dir>/READY` — same as RUN (semantic alias: "I cleared the captcha")
|
||||||
|
* - touch `<dir>/CLOSE` — save storage state (if configured) and shut down
|
||||||
|
*
|
||||||
|
* If the browser process dies unexpectedly (crash, manual close), it's
|
||||||
|
* relaunched automatically via `launch()` and `onRun` is re-armed to fire on
|
||||||
|
* the next iteration.
|
||||||
|
*/
|
||||||
|
export async function runPersistentSession(options: PersistentSessionOptions): Promise<void> {
|
||||||
|
const log = options.logger ?? createLogger({ name: 'persistentSession' });
|
||||||
|
mkdirSync(options.dir, { recursive: true });
|
||||||
|
const runFlag = join(options.dir, options.flags?.run ?? 'RUN');
|
||||||
|
const readyFlag = join(options.dir, options.flags?.ready ?? 'READY');
|
||||||
|
const closeFlag = join(options.dir, options.flags?.close ?? 'CLOSE');
|
||||||
|
const pollMs = options.pollMs ?? 2000;
|
||||||
|
|
||||||
|
clearFlag(runFlag);
|
||||||
|
clearFlag(readyFlag);
|
||||||
|
clearFlag(closeFlag);
|
||||||
|
|
||||||
|
let session = await options.launch();
|
||||||
|
let doneOnce = false;
|
||||||
|
|
||||||
|
while (!existsSync(closeFlag)) {
|
||||||
|
let dead = false;
|
||||||
|
try {
|
||||||
|
void session.page.url();
|
||||||
|
} catch {
|
||||||
|
dead = true;
|
||||||
|
}
|
||||||
|
if (dead || !session.browser.isConnected()) {
|
||||||
|
log.warn('browser closed unexpectedly — relaunching');
|
||||||
|
session = await options.launch();
|
||||||
|
doneOnce = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const forced = existsSync(runFlag) || existsSync(readyFlag);
|
||||||
|
if (forced) {
|
||||||
|
clearFlag(runFlag);
|
||||||
|
clearFlag(readyFlag);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!doneOnce || forced) {
|
||||||
|
try {
|
||||||
|
await options.onRun(session);
|
||||||
|
doneOnce = true;
|
||||||
|
if (options.storageStatePath) {
|
||||||
|
await saveStorageState(session.context, options.storageStatePath);
|
||||||
|
}
|
||||||
|
log.info('run complete — session stays open (touch RUN to retry, CLOSE to quit)');
|
||||||
|
} catch (err) {
|
||||||
|
if (isBrowserCrashError(err)) {
|
||||||
|
log.warn('browser closed mid-run — will relaunch');
|
||||||
|
doneOnce = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
log.error('run failed', { error: err instanceof Error ? err.message : String(err) });
|
||||||
|
const keepOpen = options.onError ? await options.onError(err, session) : false;
|
||||||
|
doneOnce = true;
|
||||||
|
if (!keepOpen) throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, pollMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('CLOSE flag — shutting down');
|
||||||
|
if (options.storageStatePath) {
|
||||||
|
await saveStorageState(session.context, options.storageStatePath).catch(() => undefined);
|
||||||
|
}
|
||||||
|
await session.browser.close().catch(() => undefined);
|
||||||
|
}
|
||||||
59
src/browser/richText.test.ts
Normal file
59
src/browser/richText.test.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { Locator, Page } from '@playwright/test';
|
||||||
|
import { fillContentEditable } from './richText.js';
|
||||||
|
|
||||||
|
function mockLocator() {
|
||||||
|
const pressed: string[] = [];
|
||||||
|
const typed: string[] = [];
|
||||||
|
const page = {
|
||||||
|
keyboard: {
|
||||||
|
press: vi.fn(async (key: string) => {
|
||||||
|
pressed.push(key);
|
||||||
|
}),
|
||||||
|
type: vi.fn(async (text: string) => {
|
||||||
|
typed.push(text);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
} as unknown as Page;
|
||||||
|
|
||||||
|
const locator = {
|
||||||
|
waitFor: vi.fn(async () => undefined),
|
||||||
|
click: vi.fn(async () => undefined),
|
||||||
|
page: vi.fn(() => page),
|
||||||
|
} as unknown as Locator;
|
||||||
|
|
||||||
|
return { locator, page, pressed, typed };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('fillContentEditable', () => {
|
||||||
|
it('clears the field and types each paragraph with a double-Enter break', async () => {
|
||||||
|
const { locator, typed, pressed } = mockLocator();
|
||||||
|
|
||||||
|
await fillContentEditable(locator, 'First bullet\n\nSecond bullet\n\nThird bullet');
|
||||||
|
|
||||||
|
expect(typed).toEqual(['First bullet', 'Second bullet', 'Third bullet']);
|
||||||
|
// Select-all + delete before typing.
|
||||||
|
expect(pressed[0]).toMatch(/Meta\+A|Control\+A/);
|
||||||
|
expect(pressed[1]).toBe('Backspace');
|
||||||
|
// Two Enters between paragraphs, none trailing after the last one.
|
||||||
|
const enters = pressed.filter((k) => k === 'Enter');
|
||||||
|
expect(enters).toHaveLength(4); // 2 breaks * 2 Enters each
|
||||||
|
});
|
||||||
|
|
||||||
|
it('types a single Enter per break when paragraphBreak is "enter"', async () => {
|
||||||
|
const { locator, pressed } = mockLocator();
|
||||||
|
|
||||||
|
await fillContentEditable(locator, 'A\n\nB', { paragraphBreak: 'enter' });
|
||||||
|
|
||||||
|
const enters = pressed.filter((k) => k === 'Enter');
|
||||||
|
expect(enters).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores blank/whitespace-only paragraphs', async () => {
|
||||||
|
const { locator, typed } = mockLocator();
|
||||||
|
|
||||||
|
await fillContentEditable(locator, 'A\n\n\n\nB\n\n \n\nC');
|
||||||
|
|
||||||
|
expect(typed).toEqual(['A', 'B', 'C']);
|
||||||
|
});
|
||||||
|
});
|
||||||
57
src/browser/richText.ts
Normal file
57
src/browser/richText.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import type { Locator } from '@playwright/test';
|
||||||
|
import { createLogger, type Logger } from '../logging/logger.js';
|
||||||
|
|
||||||
|
export interface FillContentEditableOptions {
|
||||||
|
/**
|
||||||
|
* How to render a paragraph break between `value`'s blank-line-separated
|
||||||
|
* paragraphs: 'double-enter' (default) types Enter twice, which is what most
|
||||||
|
* rich-text widgets (incl. LinkedIn's) need to render an actual blank line
|
||||||
|
* instead of collapsing consecutive lines into inline text; 'enter' types it
|
||||||
|
* once for widgets that already treat a single Enter as a new block.
|
||||||
|
*/
|
||||||
|
paragraphBreak?: 'double-enter' | 'enter';
|
||||||
|
/** Per-keystroke delay in ms passed to `page.keyboard.type` (default 4). */
|
||||||
|
typeDelay?: number;
|
||||||
|
timeout?: number;
|
||||||
|
logger?: Logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type `value` into a `contenteditable` rich-text field via real keyboard
|
||||||
|
* events, splitting on blank lines (`\n\n`) so multi-paragraph/bulleted text
|
||||||
|
* renders as separate blocks instead of collapsing into one run.
|
||||||
|
*
|
||||||
|
* `Locator.fill()` only works on `<input>`/`<textarea>` — it's a no-op or
|
||||||
|
* throws on `contenteditable` divs, which is what many CMS/social-profile
|
||||||
|
* rich-text fields actually use (LinkedIn's About and Description fields,
|
||||||
|
* for example, look like a `<textarea>` visually but aren't one).
|
||||||
|
*/
|
||||||
|
export async function fillContentEditable(
|
||||||
|
locator: Locator,
|
||||||
|
value: string,
|
||||||
|
options?: FillContentEditableOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
const log = options?.logger ?? createLogger({ name: 'fillContentEditable' });
|
||||||
|
await locator.waitFor({ state: 'visible', timeout: options?.timeout ?? 20_000 });
|
||||||
|
const page = locator.page();
|
||||||
|
await locator.click();
|
||||||
|
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
|
||||||
|
await page.keyboard.press('Backspace');
|
||||||
|
|
||||||
|
const paragraphs = value
|
||||||
|
.split(/\n\n+/)
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const delay = options?.typeDelay ?? 4;
|
||||||
|
|
||||||
|
for (let i = 0; i < paragraphs.length; i++) {
|
||||||
|
await page.keyboard.type(paragraphs[i]!, { delay });
|
||||||
|
if (i < paragraphs.length - 1) {
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
if (options?.paragraphBreak !== 'enter') {
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.debug('fillContentEditable done', { paragraphs: paragraphs.length });
|
||||||
|
}
|
||||||
13
src/index.ts
13
src/index.ts
@ -35,6 +35,13 @@ export {
|
|||||||
dedupeNetworkErrors,
|
dedupeNetworkErrors,
|
||||||
globToRegExp,
|
globToRegExp,
|
||||||
responseMatchesFilter,
|
responseMatchesFilter,
|
||||||
|
COMMON_NOISE_PATTERNS,
|
||||||
|
byAriaLabel,
|
||||||
|
clickByAriaLabel,
|
||||||
|
withDialog,
|
||||||
|
fillContentEditable,
|
||||||
|
runPersistentSession,
|
||||||
|
isBrowserCrashError,
|
||||||
type ClickOptions,
|
type ClickOptions,
|
||||||
type FillOptions,
|
type FillOptions,
|
||||||
type GotoOptions,
|
type GotoOptions,
|
||||||
@ -43,6 +50,12 @@ export {
|
|||||||
type InterceptedNetworkCall,
|
type InterceptedNetworkCall,
|
||||||
type NetworkError,
|
type NetworkError,
|
||||||
type NetworkErrorMonitorOptions,
|
type NetworkErrorMonitorOptions,
|
||||||
|
type ByAriaLabelOptions,
|
||||||
|
type WithDialogHandlers,
|
||||||
|
type WithDialogOptions,
|
||||||
|
type FillContentEditableOptions,
|
||||||
|
type PersistentSessionOptions,
|
||||||
|
type LaunchedSession,
|
||||||
} from './browser/index.js';
|
} from './browser/index.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user