Compare commits

...

8 Commits
v0.4.0 ... main

Author SHA1 Message Date
c704df85bf Merge pull request 'Add resilient UI helpers for third-party / adversarial SPAs' (#8) from feat/resilient-ui-helpers into main
All checks were successful
CI / release (push) Has been skipped
CI / skip-ci-check (push) Successful in 4s
CI / build-and-test (push) Successful in 16s
CI / secret-scan (push) Successful in 4s
CI / selftest (push) Successful in 11s
2026-07-15 17:18:24 -05:00
8b30ab5a6d Add resilient UI helpers for third-party / adversarial SPAs.
All checks were successful
CI / release (pull_request) Has been skipped
CI / skip-ci-check (pull_request) Successful in 5s
CI / secret-scan (pull_request) Successful in 4s
CI / build-and-test (pull_request) Successful in 23s
CI / selftest (pull_request) Successful in 15s
Generalize patterns from LinkedIn automation: aria-label regex locators,
dialog reopen-retry, contenteditable typing, persistent flag-file sessions,
and a default noise exclude preset for NetworkErrorMonitor.
2026-07-15 18:17:02 -04:00
ee7b130db0 Merge pull request 'ops: require npm publish; close Outline/npm checklist' (#7) from docs/ops-hygiene-tokens into main
All checks were successful
CI / skip-ci-check (push) Successful in 7s
CI / release (push) Has been skipped
CI / build-and-test (push) Successful in 32s
CI / secret-scan (push) Successful in 3s
CI / selftest (push) Successful in 16s
2026-07-15 10:06:38 -05:00
84393be785 Close playkit ops gap: require npm publish, mark Outline/npm done.
All checks were successful
CI / release (pull_request) Has been skipped
CI / skip-ci-check (pull_request) Successful in 4s
CI / secret-scan (pull_request) Successful in 4s
CI / build-and-test (pull_request) Successful in 23s
CI / selftest (pull_request) Successful in 27s
Remove release soft-fail now that NPM_PUBLISH_TOKEN works; refresh OPS/OUTLINE/ROADMAP for the live Outline doc and published 0.4.0 registry package.
2026-07-15 11:06:20 -04:00
fdbc60506a Merge pull request 'docs: OPS checklist for npm publish + Outline scopes' (#6) from docs/ops-outstanding-checklist into main
All checks were successful
CI / skip-ci-check (push) Successful in 7s
CI / release (push) Has been skipped
CI / build-and-test (push) Successful in 38s
CI / secret-scan (push) Successful in 4s
CI / selftest (push) Successful in 21s
2026-07-15 08:43:15 -05:00
05f93d0330 docs: OPS checklist for npm token + Outline scopes
All checks were successful
CI / skip-ci-check (pull_request) Successful in 6s
CI / release (pull_request) Has been skipped
CI / build-and-test (pull_request) Successful in 46s
CI / secret-scan (pull_request) Successful in 4s
CI / selftest (pull_request) Successful in 17s
Record outstanding one-time UI steps after v0.4.0, add publish-gitea-npm.sh,
and align outline-sync body with the live Playkit page format.
2026-07-15 09:39:45 -04:00
029f192f55 Merge pull request 'fix(release): soft-fail npm publish until package write token' (#5) from fix/release-npm-publish-auth into main
All checks were successful
CI / release (push) Has been skipped
CI / skip-ci-check (push) Successful in 4s
CI / secret-scan (push) Successful in 3s
CI / build-and-test (push) Successful in 16s
CI / selftest (push) Successful in 11s
2026-07-15 08:17:32 -05:00
8489dd95d6 fix(release): soft-fail npm publish until package token is scoped
All checks were successful
CI / release (pull_request) Has been skipped
CI / skip-ci-check (pull_request) Successful in 5s
CI / secret-scan (pull_request) Successful in 4s
CI / build-and-test (pull_request) Successful in 16s
CI / selftest (pull_request) Successful in 11s
Gitea Release for v0.4.0 succeeded; npm registry returned E401 without
write:package. Keep release green and document the token follow-up.
2026-07-15 09:14:25 -04:00
21 changed files with 898 additions and 38 deletions

View File

@ -155,12 +155,13 @@ jobs:
-F "attachment=@${TARBALL}" \
"https://git.levkin.ca/api/v1/repos/ilia/playkit/releases/${RELEASE_ID}/assets"
- name: Publish to Gitea npm registry
# RELEASE_TOKEN (or optional NPM_PUBLISH_TOKEN) needs write:package — docs/NPM_REGISTRY.md
# Prefer NPM_PUBLISH_TOKEN (write:package); fall back to RELEASE_TOKEN if it has package scope.
# See docs/NPM_REGISTRY.md / docs/OPS.md.
run: |
TOKEN="${{ secrets.NPM_PUBLISH_TOKEN }}"
if [ -z "$TOKEN" ]; then TOKEN="${{ secrets.RELEASE_TOKEN }}"; fi
if [ -z "$TOKEN" ]; then
echo "::error::set RELEASE_TOKEN or NPM_PUBLISH_TOKEN with write:package"
echo "::error::set NPM_PUBLISH_TOKEN (or RELEASE_TOKEN) with write:package"
exit 1
fi
echo "@levkin:registry=https://git.levkin.ca/api/packages/ilia/npm/" > /tmp/playkit.npmrc

View File

@ -1,5 +1,15 @@
# Changelog
## 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`
## 0.4.0 — 2026-07-15
- **CLI**`playkit init` scaffolds `e2e/` + CI snippet; `playkit smoke` post-deploy public-host + health ping

View File

@ -85,9 +85,13 @@ 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 |
| `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 |
See also `docs/NETWORK.md`, `docs/SELFTEST.md`, `docs/NPM_REGISTRY.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`.
## Network (page traffic)
@ -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)
`createMailInbox()` picks the provider from `PLAYKIT_MAIL_PROVIDER` (default

View File

@ -14,7 +14,21 @@ Living plan for making `@levkin/playkit` more useful across Levkin repos.
- [ ] End adoption pause — migrate first extra consumer (`screening` candidate)
- [ ] Evaluate `playwright-exporter` for scheduled synthetics (may supersede bespoke cron wrappers around `playkit smoke`)
- [ ] Optional dedicated `NPM_PUBLISH_TOKEN` (least privilege vs reuse `RELEASE_TOKEN`)
## 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)
- [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] **Outline API key scopes**`documents.update` / `delete` working; living page synced via API (`docs/OUTLINE.md`)
## Adoption pause

View File

@ -27,16 +27,25 @@ npm i git+https://git.levkin.ca/ilia/playkit.git#v0.4.0
On `vX.Y.Z` tag, `.gitea/workflows/ci.yml` `release` job:
1. Creates the Gitea Release + attaches `npm pack` tarball (existing)
1. Creates the Gitea Release + attaches `npm pack` tarball
2. Runs `npm publish` to `https://git.levkin.ca/api/packages/ilia/npm/`
using `RELEASE_TOKEN` (needs **`write:package`** in addition to release scopes)
using `NPM_PUBLISH_TOKEN` if set, else `RELEASE_TOKEN`
(needs **`write:package`**)
One-time: edit the `RELEASE_TOKEN` personal access token / app token on Gitea
to include package write, or add a dedicated `NPM_PUBLISH_TOKEN` secret and
wire it in CI (preferred if you want least privilege).
Manual one-shot:
```bash
NPM_PUBLISH_TOKEN=… ./scripts/publish-gitea-npm.sh
```
## Verify
```bash
npm view @levkin/playkit versions --registry https://git.levkin.ca/api/packages/ilia/npm/
```
## Token setup
`@levkin/playkit@0.4.0` is on the registry. Tag/CI publish uses Actions secret
`NPM_PUBLISH_TOKEN` (`write:package`). Local ops: vault
`vault_playkit_npm_token` / `./scripts/publish-gitea-npm.sh`. See **`docs/OPS.md`**.

36
docs/OPS.md Normal file
View File

@ -0,0 +1,36 @@
# Ops checklist (playkit)
Living ops/status after releases. Update when an item closes.
## Done
| Item | Status |
|------|--------|
| Pushgateway + `live-playkit` Grafana board | Applied (`10.0.10.24:9091`) |
| Tag release workflow + `RELEASE_TOKEN` | Working (`v0.3.1`, `v0.4.0` Gitea releases) |
| Selftest CI | Green (Playwright image pinned to package version) |
| Outline **QA & Dev → Playkit** @ v0.4.0 | Synced via API 2026-07-15 → https://notes.levkin.ca/doc/playkit-CrPJq5x2qQ |
| Playkit sync probe cleanup | Deleted via API 2026-07-15 |
| Outline API key (`documents.update` / `delete`) | Vault + `.env` updated (`vault_outline_api_key`) |
| Gitea npm publish (`write:package`) | `@levkin/playkit@0.4.0` on registry; Actions secret `NPM_PUBLISH_TOKEN` set; vault `vault_playkit_npm_token` |
| v0.4 CLI / retry presets / registry wiring | Shipped on `main` |
## Outstanding
### Adoption soak
Keep **no new consumer repos** until punimtag e2e + kit CI + metrics stay green
for a few days. Then prefer `screening` as the next adopter.
## Recurring (after each release)
```bash
# Outline living page
cd /path/to/ansible && make vault-export-env
set -a && source .env && set +a
cd /path/to/playkit
python3 scripts/outline-sync-playkit.py
# Manual publish (CI also publishes on tag when NPM_PUBLISH_TOKEN is set)
./scripts/publish-gitea-npm.sh
```

View File

@ -2,14 +2,17 @@
Canonical prose stays in git (`README.md`, `docs/*`, `ROADMAP.md`).
Browsable front door: **Outline** → collection **QA & Dev** → doc **Playkit**
(`https://notes.levkin.ca`).
(`https://notes.levkin.ca/doc/playkit-CrPJq5x2qQ`).
Ops status for this page (scopes, last sync): **`docs/OPS.md`**.
## Sync script (preferred)
From this repo, with Outline credentials loaded:
```bash
# from ansible: make vault-export-env && set -a && source .env && set +a
# from ansible: make vault-export-env
# then export OUTLINE_URL / OUTLINE_API_KEY (safe parse — .env may contain shell-special chars)
python3 scripts/outline-sync-playkit.py
python3 scripts/outline-sync-playkit.py --dry-run
```
@ -19,8 +22,12 @@ version, install pin, whats-in-the-box digest, and links to repo docs.
**Required API scopes** (Outline → Settings → API & Access): at least
`collections.list`, `documents.list`, `documents.info`, `documents.create`,
`documents.update`. Without `documents.update` the script can create a first
doc but cannot refresh an existing Playkit page (HTTP 403).
`documents.update`, and preferably `documents.delete` / `documents.archive`.
Empty (unrestricted) scopes = full user access — fine for a personal kit-ops key.
Without `documents.update` the script can create a first doc but cannot refresh
an existing Playkit page (HTTP 403).
API sync to **v0.4.0** works as of 2026-07-15 (`vault_outline_api_key` has update/delete).
## When to update Outline
@ -33,19 +40,3 @@ changes consumer behavior:
3. Spot-check in Outline (search “Playkit” under QA & Dev)
4. Optional: `make outline-setup` from ansible only if collections are missing —
prefer the sync script for the living Playkit page
Paste template (if editing by hand — adjust version):
```markdown
# @levkin/playkit
Shared Playwright + API e2e kit. **Source of truth is the git repo.**
- Repo: https://git.levkin.ca/ilia/playkit
- Current: vX.Y.Z
- Consumers: punimtag (e2e/) — *pause further adoption until soak completes*
## Quick links
- README · CONSUMER.md · NETWORK.md · IDEAS.md · SELFTEST.md · ROADMAP · CHANGELOG
- Metrics: dash.levkin.ca → Live — Playkit e2e
```

View File

@ -32,14 +32,15 @@ def load_package_version() -> str:
def playkit_markdown(version: str) -> str:
return f"""# @levkin/playkit
Shared Playwright + API e2e kit for Levkin repos. **Source of truth is the git repo** this page is the browsable front door.
# Do not start with a second H1 — Outline already uses document title "Playkit".
return f"""Shared Playwright + API e2e kit for Levkin repos. **Source of truth is the git repo** — this page is the browsable front door.
- **Repo:** https://git.levkin.ca/ilia/playkit
- **Current:** v{version}
- **Consumers:** punimtag (`e2e/`) *pause further adoption until soak completes*
- **Install (git pin):** `npm i git+https://git.levkin.ca/ilia/playkit.git#v{version}`
- **Install (npm):** `npm i @levkin/playkit@{version}` see docs/NPM_REGISTRY.md
- **Fallback (git pin):** `npm i git+https://git.levkin.ca/ilia/playkit.git#v{version}`
- **CLI:** `npx @levkin/playkit init` · `playkit smoke`
## What's in the box
@ -49,6 +50,8 @@ Shared Playwright + API e2e kit for Levkin repos. **Source of truth is the git r
- Mail: `createMailInbox()` (Mailpit default, Mailtrap optional)
- Auth helpers: `saveStorageState` / `storageStateUse`, `playkitFailureArtifacts()`
- Metrics: `TimingCollector` Prometheus Pushgateway Grafana **Live Playkit e2e**
- Retry presets: `PLAYKIT_RETRY_PRESET=strictCi|flakyNetwork|default`
- Kit selftest CI against a fake site docs/SELFTEST.md
## Quick links (in-repo)
@ -57,10 +60,12 @@ Shared Playwright + API e2e kit for Levkin repos. **Source of truth is the git r
| `README.md` | Install, env vars, release steps |
| `docs/CONSUMER.md` | How to wire a consumer `e2e/` |
| `docs/NETWORK.md` | Page traffic spy / error monitor |
| `docs/NPM_REGISTRY.md` | Gitea npm install / publish |
| `docs/OPS.md` | Ops soak + outstanding UI one-timers |
| `docs/IDEAS.md` | OSS-borrowed backlog |
| `docs/OUTLINE.md` | When / how to re-sync this page |
| `docs/SELFTEST.md` | Kit CI fake-site self-test |
| `ROADMAP.md` | v0.4+ plan |
| `ROADMAP.md` | Living plan |
| `CHANGELOG.md` | Per-version notes |
## Metrics
@ -71,9 +76,10 @@ Shared Playwright + API e2e kit for Levkin repos. **Source of truth is the git r
## Release checklist (operators)
1. Tag `vX.Y.Z` (must match `package.json` + CHANGELOG section) Gitea `release` job uses `RELEASE_TOKEN`
2. Re-run this script: `python3 scripts/outline-sync-playkit.py`
3. Confirm Grafana board + Pushgateway scrape after `make deploy-observability` (ansible)
1. Tag `vX.Y.Z` (must match `package.json` + CHANGELOG section) Gitea `release` job
2. Ensure `NPM_PUBLISH_TOKEN` has `write:package` (docs/OPS.md) so registry publish succeeds
3. Re-run this script: `python3 scripts/outline-sync-playkit.py`
4. Confirm Grafana board + Pushgateway scrape (`make deploy-observability` in ansible)
_Last synced by `scripts/outline-sync-playkit.py` for v{version}._
"""

26
scripts/publish-gitea-npm.sh Executable file
View File

@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Publish @levkin/playkit to the Gitea npm registry.
# Usage:
# NPM_PUBLISH_TOKEN=… ./scripts/publish-gitea-npm.sh
# # or export from vault: vault_playkit_npm_token / RELEASE_TOKEN with write:package
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
TOKEN="${NPM_PUBLISH_TOKEN:-${PLAYKIT_NPM_TOKEN:-${RELEASE_TOKEN:-}}}"
if [[ -z "${TOKEN}" ]]; then
echo "Set NPM_PUBLISH_TOKEN (Gitea token with write:package). See docs/OPS.md" >&2
exit 1
fi
REG="https://git.levkin.ca/api/packages/ilia/npm/"
npm run build
TMP="$(mktemp)"
cat >"$TMP" <<EOF
@levkin:registry=${REG}
//git.levkin.ca/api/packages/ilia/npm/:_authToken=${TOKEN}
EOF
echo "Publishing $(node -p "require('./package.json').version") to ${REG}"
npm publish --userconfig "$TMP" --access restricted
rm -f "$TMP"
npm view @levkin/playkit version --registry "$REG" || true

67
src/browser/aria.test.ts Normal file
View 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
View 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;
}

View 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
View 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;
}

View File

@ -21,9 +21,23 @@ export {
dedupeNetworkErrors,
globToRegExp,
responseMatchesFilter,
COMMON_NOISE_PATTERNS,
type FulfillResponse,
type InterceptNetworkCallOptions,
type InterceptedNetworkCall,
type NetworkError,
type NetworkErrorMonitorOptions,
} 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';

View File

@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import type { Page, Request, Response, Route } from '@playwright/test';
import {
COMMON_NOISE_PATTERNS,
dedupeNetworkErrors,
globToRegExp,
interceptNetworkCall,
@ -180,6 +181,39 @@ describe('NetworkErrorMonitor', () => {
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', () => {
const page = createMockPage();
const net = startNetworkErrorMonitor(page);

View File

@ -162,9 +162,32 @@ export interface NetworkError {
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 {
/** Skip matching URLs (string substring or 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). */
minStatus?: number;
logger?: Logger;
@ -202,7 +225,9 @@ export class NetworkErrorMonitor {
) {
const log = options.logger ?? createLogger({ name: 'NetworkErrorMonitor' });
const minStatus = options.minStatus ?? 400;
const exclude = options.excludePatterns ?? [];
const exclude = options.useDefaultExcludes
? [...COMMON_NOISE_PATTERNS, ...(options.excludePatterns ?? [])]
: options.excludePatterns ?? [];
this.onResponse = (response: Response) => {
if (this.stopped) return;

View 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');
});
});

View 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);
}

View 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
View 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 });
}

View File

@ -35,6 +35,13 @@ export {
dedupeNetworkErrors,
globToRegExp,
responseMatchesFilter,
COMMON_NOISE_PATTERNS,
byAriaLabel,
clickByAriaLabel,
withDialog,
fillContentEditable,
runPersistentSession,
isBrowserCrashError,
type ClickOptions,
type FillOptions,
type GotoOptions,
@ -43,6 +50,12 @@ export {
type InterceptedNetworkCall,
type NetworkError,
type NetworkErrorMonitorOptions,
type ByAriaLabelOptions,
type WithDialogHandlers,
type WithDialogOptions,
type FillContentEditableOptions,
type PersistentSessionOptions,
type LaunchedSession,
} from './browser/index.js';
export {