Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c704df85bf | |||
| 8b30ab5a6d | |||
| ee7b130db0 | |||
| 84393be785 | |||
| fdbc60506a | |||
| 05f93d0330 | |||
| 029f192f55 | |||
| 8489dd95d6 | |||
| a086ca4bf4 | |||
| 6d88818fae | |||
| d53d1ad2c7 | |||
| 68b04bd826 | |||
| afaa987218 |
@ -45,6 +45,25 @@ jobs:
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
# Real browser+HTTP self-test (not mocks). Catches BasePage/ApiClient/network
|
||||
# regressions before consumers pin a broken tag. See docs/SELFTEST.md.
|
||||
selftest:
|
||||
needs: [skip-ci-check, build-and-test]
|
||||
if: needs.skip-ci-check.outputs.should-skip != '1'
|
||||
runs-on: [homelab, self-hosted, linux]
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install
|
||||
run: npm ci
|
||||
- name: Self-test against fake site
|
||||
run: npm run selftest
|
||||
env:
|
||||
CI: 'true'
|
||||
PLAYKIT_SELFTEST_PORT: '4173'
|
||||
PLAYKIT_BASE_URL: 'http://127.0.0.1:4173'
|
||||
|
||||
secret-scan:
|
||||
needs: skip-ci-check
|
||||
if: needs.skip-ci-check.outputs.should-skip != '1'
|
||||
@ -135,3 +154,17 @@ jobs:
|
||||
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \
|
||||
-F "attachment=@${TARBALL}" \
|
||||
"https://git.levkin.ca/api/v1/repos/ilia/playkit/releases/${RELEASE_ID}/assets"
|
||||
- name: Publish to Gitea npm registry
|
||||
# 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 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
|
||||
echo "//git.levkin.ca/api/packages/ilia/npm/:_authToken=${TOKEN}" >> /tmp/playkit.npmrc
|
||||
npm publish --userconfig /tmp/playkit.npmrc --access restricted
|
||||
rm -f /tmp/playkit.npmrc
|
||||
|
||||
27
CHANGELOG.md
27
CHANGELOG.md
@ -2,15 +2,32 @@
|
||||
|
||||
## 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
|
||||
- **Retry presets** — `PLAYKIT_RETRY_PRESET=strictCi|flakyNetwork|default` wired through `loadConfig` (`RETRY_PRESETS` export)
|
||||
- **Gitea npm registry** — `publishConfig` + release-job `npm publish`; consumer docs in `docs/NPM_REGISTRY.md`
|
||||
- Docs: CONSUMER/ROADMAP updated for v0.4; selftest + Outline sync remain as of 0.3.x line
|
||||
|
||||
## 0.3.1 — 2026-07-14
|
||||
|
||||
- **Network interception / error monitor** — `interceptNetworkCall()`, `startNetworkErrorMonitor()` (see `docs/NETWORK.md`)
|
||||
- Tests: expand coverage for network helpers — `NetworkErrorMonitor` (record/dedupe/exclude/minStatus/assert), `interceptNetworkCall` (spy/fulfill/handler/method continue/non-JSON), plus `globToRegExp` / `responseMatchesFilter`
|
||||
- Docs: `docs/OUTLINE.md` checklist (update Outline QA & Dev Playkit page on each release); `docs/IDEAS.md` for OSS-borrowed backlog; adoption pause (no new consumer repos until soak)
|
||||
- CI: add tag-triggered `release` job (`.gitea/workflows/ci.yml`) — re-runs build/test, verifies tag matches `package.json` version and `CHANGELOG.md` documents it, creates a Gitea release with an `npm pack` tarball attached. Requires a one-time `GITEA_TOKEN` Actions secret.
|
||||
- CI: add tag-triggered `release` job (`.gitea/workflows/ci.yml`) — re-runs build/test, verifies tag matches `package.json` version and `CHANGELOG.md` documents it, creates a Gitea release with an `npm pack` tarball attached. Requires a one-time `RELEASE_TOKEN` Actions secret.
|
||||
- Docs: bump install pin examples from `v0.1.0` to `v0.3.0` (README, CONSUMER.md)
|
||||
- Docs: lead with Mailpit (homelab default) instead of Mailtrap in README email section; use `createMailInbox()` + `readMailHtml()` in the example instead of a provider-specific client
|
||||
- Ops: Pushgateway + `live-playkit` Grafana board now provisioned via ansible `deploy/observability/` (pending `make deploy-observability`); removed the standalone `dashboards/playkit-overview.json` (superseded) and the `dashboards` entry from `package.json` `files`
|
||||
- Ops: Pushgateway + `live-playkit` Grafana board now provisioned via ansible `deploy/observability/`
|
||||
- **Self-test suite** — `selftest/` fake site + Playwright CI job (`docs/SELFTEST.md`)
|
||||
- **Outline sync** — `scripts/outline-sync-playkit.py`
|
||||
|
||||
## 0.3.0 — 2026-07-14
|
||||
|
||||
@ -38,7 +55,5 @@ Initial release.
|
||||
|
||||
- Browser: `BasePage`, `click`, `fill`, `safeGoto`, visibility waits, `waitForUrlHost`, `assertPublicHost`
|
||||
- API: `ApiClient` with status expectations and secret redaction
|
||||
- Logging: structured JSON logger
|
||||
- Metrics: `TimingCollector` + Prometheus Pushgateway push
|
||||
- Config: `loadConfig()` with private-host guard
|
||||
- Docs: README, ROADMAP, Grafana dashboard stub, consumer examples
|
||||
- Metrics: `TimingCollector` + Pushgateway helper
|
||||
- Config/logging helpers + consumer docs
|
||||
|
||||
87
README.md
87
README.md
@ -7,8 +7,14 @@ Use it as a library from any consumer repo (punimtag, MirrorMatch, …). App-spe
|
||||
## Install (consumer)
|
||||
|
||||
```bash
|
||||
# git tag dependency (until a private npm registry is wired)
|
||||
npm install git+https://git.levkin.ca/ilia/playkit.git#v0.3.1
|
||||
# Preferred — Gitea npm registry (docs/NPM_REGISTRY.md)
|
||||
npm install @levkin/playkit@0.4.0
|
||||
|
||||
# Fallback — git tag
|
||||
npm install git+https://git.levkin.ca/ilia/playkit.git#v0.4.0
|
||||
|
||||
# Scaffold e2e/
|
||||
npx @levkin/playkit init
|
||||
|
||||
# peer
|
||||
npm install -D @playwright/test
|
||||
@ -54,6 +60,8 @@ test('sign-out stays on public host', async ({ page, playkitConfig, timings }) =
|
||||
| `PLAYKIT_API_BASE_URL` | no | Defaults to base URL |
|
||||
| `PLAYKIT_EXPECTED_HOST` | no | Defaults to host of base URL |
|
||||
| `PLAYKIT_FORBID_PRIVATE_HOSTS` | no | Default `true` — refuse `10.x` / localhost as expected host |
|
||||
| `PLAYKIT_RETRY_PRESET` | no | `default` \| `strictCi` \| `flakyNetwork` (timeouts/retries) |
|
||||
| `PLAYKIT_ACTION_RETRIES` / `PLAYKIT_TIMEOUT_MS` | no | Override preset values |
|
||||
| `PLAYKIT_PROJECT` | no | Metric / log label |
|
||||
| `PLAYKIT_ENV` | no | Metric / log label (`dev`/`qa`/`prod`) |
|
||||
| `PLAYKIT_METRICS_ENABLED` | no | Push timings to Pushgateway |
|
||||
@ -77,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 |
|
||||
| `startNetworkErrorMonitor` | Fail tests that silently collect HTTP 4xx/5xx |
|
||||
| `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/IDEAS.md` (OSS-borrowed backlog), `docs/OUTLINE.md` (live docs sync).
|
||||
See also `docs/NETWORK.md`, `docs/SELFTEST.md`, `docs/NPM_REGISTRY.md`, `docs/OPS.md`, `docs/IDEAS.md`, `docs/OUTLINE.md`.
|
||||
|
||||
## Network (page traffic)
|
||||
|
||||
@ -97,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
|
||||
@ -130,9 +195,21 @@ a real address will not appear in either. See ansible `docs/hardening/SECRETS.md
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm test
|
||||
npm run selftest # fake site + Chromium (docs/SELFTEST.md)
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Outline live docs
|
||||
|
||||
After each release (or when consumer-facing docs change):
|
||||
|
||||
```bash
|
||||
# needs OUTLINE_URL + OUTLINE_API_KEY (ansible: make vault-export-env)
|
||||
python3 scripts/outline-sync-playkit.py
|
||||
```
|
||||
|
||||
See `docs/OUTLINE.md`.
|
||||
|
||||
## Release
|
||||
|
||||
1. Bump `version` in `package.json`
|
||||
@ -141,7 +218,7 @@ npm run build
|
||||
|
||||
Pushing the tag triggers `.gitea/workflows/ci.yml`'s `release` job: it re-runs typecheck/test/build, verifies the tag matches `package.json`'s `version` and that `CHANGELOG.md` documents it, then creates a Gitea release (with the `npm pack` tarball attached) via the API using a repo-scoped `RELEASE_TOKEN` Actions secret. If any check fails, no release is created — fix and re-tag. Consumers still pin the git tag (`#vX.Y.Z`); the Gitea release is for visibility/changelog, not an npm registry publish (see ROADMAP "private Gitea npm registry").
|
||||
|
||||
4. **Update Outline** — after the release is green, sync the Playkit page under Outline **QA & Dev** (`notes.levkin.ca`). Checklist: `docs/OUTLINE.md`.
|
||||
4. **Update Outline** — `python3 scripts/outline-sync-playkit.py` (checklist: `docs/OUTLINE.md`).
|
||||
|
||||
**One-time setup:** add a `RELEASE_TOKEN` secret (repo `Settings → Actions → Secrets` on `ilia/playkit`) scoped to create releases on this repo — separate from the `PLAYKIT_GIT_TOKEN` consumers use to clone it. (Named `RELEASE_TOKEN`, not `GITEA_TOKEN` — Gitea's Actions secrets API rejects that literal name as reserved, same reason `PLAYKIT_GIT_TOKEN` isn't called `GITEA_TOKEN` either.)
|
||||
|
||||
|
||||
65
ROADMAP.md
65
ROADMAP.md
@ -2,38 +2,37 @@
|
||||
|
||||
Living plan for making `@levkin/playkit` more useful across Levkin repos.
|
||||
|
||||
## Now (v0.3.1) — shipped
|
||||
## Now (v0.4.0) — this release
|
||||
|
||||
- [x] Browser helpers with retries (`click`, `fill`, `safeGoto`, visibility waits)
|
||||
- [x] `BasePage` for Page Objects
|
||||
- [x] Public-host guards (`assertPublicHost`, `waitForUrlHost`) — Kolby #57 class
|
||||
- [x] `ApiClient` with redacted structured logging
|
||||
- [x] `TimingCollector` + Prometheus Pushgateway export
|
||||
- [x] Config from env (Infisical/CI-friendly)
|
||||
- [x] `createPlaykitRuntime()` for one-shot wiring
|
||||
- [x] Unit tests (vitest) + Gitea Actions CI
|
||||
- [x] Starter Grafana dashboard JSON
|
||||
- [x] Consumer docs + API/UI examples
|
||||
- [x] **Mailtrap / Mailpit** — `createMailInbox()`, `waitForEmail`, link extraction
|
||||
- [x] **Auth storage state helpers** — `saveStorageState` / `storageStateUse`
|
||||
- [x] **Trace-on-failure preset** — `playkitFailureArtifacts()`
|
||||
- [x] **Schema assertions** — Zod `assertSchema` + `ApiClient` `schema` option
|
||||
- [x] Everything from v0.3.1 (browser/API/mail/network/selftest/Outline/Pushgateway)
|
||||
- [x] **Private Gitea npm registry publish** — release job `npm publish` to `git.levkin.ca` (`docs/NPM_REGISTRY.md`)
|
||||
- [x] **Consumer template** — `playkit init` / `npx @levkin/playkit init`
|
||||
- [x] **Deploy-smoke CLI** — `playkit smoke` (public-host + health GET)
|
||||
- [x] **Retry policy presets** — `PLAYKIT_RETRY_PRESET=strictCi|flakyNetwork|default`
|
||||
|
||||
## Next (v0.4) — high value
|
||||
## Next after soak
|
||||
|
||||
- [ ] **Private Gitea npm registry publish** — `npm install @levkin/playkit` without git URLs
|
||||
- [ ] **Consumer template** — `npx @levkin/playkit init` scaffolding `e2e/` + CI snippet
|
||||
- [ ] **Deploy-smoke CLI** — `playkit smoke --project punimtag` post-deploy gate
|
||||
- [ ] **Retry policy presets** — flaky-network vs strict-CI profiles
|
||||
- [ ] **Self-test against a real fake site** — today's unit tests only mock HTTP/mail; `examples/` specs are explicitly *not run* in kit CI. Stand up a tiny demo app (or point at an existing DEV LXC) and run the browser/API/mail helpers against it in CI, so a regression in `BasePage`/`ApiClient`/`assertPublicHost` is caught before a consumer pins a broken tag.
|
||||
- [x] **Network interception / network error monitor** — `interceptNetworkCall()`, `startNetworkErrorMonitor()` / `assertNoErrors()` (see `docs/NETWORK.md`)
|
||||
- [x] **Tag-triggered release workflow** — `.gitea/workflows/ci.yml` `release` job now runs on `vX.Y.Z` tag push: re-verifies build/test, checks tag == `package.json` version, checks `CHANGELOG.md` has that version's section, then creates a Gitea release (npm-pack tarball attached) via the API. Needs a one-time `GITEA_TOKEN` Actions secret on this repo (see README "Release"). Still open: `build-and-test`/`secret-scan` also re-run on the tag push (same `on.push` trigger) — harmless redundancy, not wired to skip.
|
||||
- [ ] **Live docs** — Outline page under **QA & Dev** (`notes.levkin.ca`). Sync checklist in `docs/OUTLINE.md` — update Outline whenever playkit is released.
|
||||
- [x] **Pushgateway + dashboard wired in ansible** — `pushgateway` service, Prometheus scrape job, and a generated `live-playkit` Grafana board now live in ansible `deploy/observability/` (superseding the old standalone `dashboards/playkit-overview.json`, which is removed). Ops still needs to run `make deploy-observability` against the LXC before `PLAYKIT_METRICS_ENABLED=true` does anything in CI — check with the ansible repo owner before flipping that on.
|
||||
- [ ] End adoption pause — migrate first extra consumer (`screening` candidate)
|
||||
- [ ] 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)
|
||||
|
||||
- [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
|
||||
|
||||
**No new consumer repos until we soak.** Keep playing through punimtag e2e + kit CI for a few days (release job, Pushgateway/dashboard apply, network helpers) before migrating `screening` / `slack-sieve` / `portfolio`. See `docs/IDEAS.md`.
|
||||
**No new consumer repos until soak finishes.** Keep punimtag e2e + kit CI + metrics path healthy for a few days. See `docs/IDEAS.md`.
|
||||
|
||||
## Later (v0.5+) — professional polish
|
||||
|
||||
@ -47,14 +46,14 @@ Living plan for making `@levkin/playkit` more useful across Levkin repos.
|
||||
- [ ] **Infisical SDK helper** — `loadSecretsFromInfisical()` for local runs (machine identity)
|
||||
- [ ] **JUnit + HTML report merge** — single artifact for Gitea PR checks
|
||||
- [ ] **Network assert helpers** — fail if request hits `10.x` / wrong host after navigation
|
||||
- [ ] **Test burn-in (flake detection)** — rerun a spec N times
|
||||
|
||||
## Ideas pulled from similar OSS tools (2026-07 research)
|
||||
|
||||
See `docs/IDEAS.md` for expanded notes. Summary:
|
||||
See `docs/IDEAS.md`. Summary:
|
||||
|
||||
- [x] **Network interception / network error monitor** — shipped (above)
|
||||
- [ ] **Test burn-in (flake detection)** — rerun a spec N times; mechanism for flake quarantine
|
||||
- [ ] **Scheduled synthetic monitoring** — evaluate `playwright-exporter` before bespoke smoke CLI
|
||||
- [ ] **Functional-core-for-everything audit** — optional function wrappers alongside class APIs
|
||||
- ~~Remote-write vs Pushgateway~~ — moot while Pushgateway is the path
|
||||
- Confirmed sane (no action): OpenAPI / axe / visual reg already under v0.5+
|
||||
- [x] Network interception / error monitor
|
||||
- [ ] Test burn-in / flake quarantine
|
||||
- [ ] Scheduled synthetic monitoring (`playwright-exporter`)
|
||||
- [ ] Functional-core / fixture-shell audit
|
||||
- ~~Remote-write vs Pushgateway~~ — moot
|
||||
|
||||
@ -2,12 +2,29 @@
|
||||
|
||||
## 1. Depend on a release
|
||||
|
||||
**Preferred (Gitea npm registry):**
|
||||
|
||||
```bash
|
||||
npm install git+https://git.levkin.ca/ilia/playkit.git#v0.3.1
|
||||
# .npmrc — see docs/NPM_REGISTRY.md
|
||||
# @levkin:registry=https://git.levkin.ca/api/packages/ilia/npm/
|
||||
npm install @levkin/playkit@0.4.0
|
||||
npm install -D @playwright/test
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
**Fallback (git pin):**
|
||||
|
||||
```bash
|
||||
npm install git+https://git.levkin.ca/ilia/playkit.git#v0.4.0
|
||||
```
|
||||
|
||||
**Scaffold:**
|
||||
|
||||
```bash
|
||||
npx --yes @levkin/playkit init
|
||||
# or: playkit init
|
||||
```
|
||||
|
||||
## 2. Layout
|
||||
|
||||
```
|
||||
@ -25,12 +42,22 @@ Store in Infisical `LevkinOps` / `Development` (path e.g. `/playkit/punimtag`):
|
||||
|
||||
- `PLAYKIT_BASE_URL=https://punimtagdev.levkin.ca`
|
||||
- `E2E_ADMIN_EMAIL` / `E2E_ADMIN_PASSWORD` (dedicated test user — not a human’s password)
|
||||
- optional `PLAYKIT_PUSHGATEWAY_URL=http://10.0.10.24:9091` (Pushgateway on the observability LXC — config lives in ansible `deploy/observability/`; scrape job + `live-playkit` Grafana board are wired, but confirm `make deploy-observability` has actually been run before turning `PLAYKIT_METRICS_ENABLED=true` on in CI)
|
||||
- for mail specs: `PLAYKIT_MAIL_PROVIDER=mailpit` (default) + `MAILPIT_BASE_URL` / `MAILPIT_USER` / `MAILPIT_PASSWORD`, or `MAILTRAP_*` for SaaS
|
||||
- optional `PLAYKIT_PUSHGATEWAY_URL=http://10.0.10.24:9091`
|
||||
- optional `PLAYKIT_RETRY_PRESET=strictCi|flakyNetwork|default`
|
||||
- for mail specs: `PLAYKIT_MAIL_PROVIDER=mailpit` (default) + `MAILPIT_*`, or `MAILTRAP_*`
|
||||
|
||||
Sync into Gitea Actions secrets for the consumer repo.
|
||||
|
||||
## 4. CI job sketch
|
||||
## 4. Post-deploy smoke
|
||||
|
||||
```bash
|
||||
PLAYKIT_BASE_URL=https://punimtagdev.levkin.ca playkit smoke
|
||||
# or: playkit smoke --path /api/health
|
||||
```
|
||||
|
||||
## 5. CI job sketch
|
||||
|
||||
See `e2e/ci-snippet.yml` from `playkit init`, or:
|
||||
|
||||
```yaml
|
||||
e2e:
|
||||
@ -42,19 +69,15 @@ e2e:
|
||||
- run: npx playwright test
|
||||
env:
|
||||
PLAYKIT_BASE_URL: ${{ secrets.PLAYKIT_BASE_URL }}
|
||||
PLAYKIT_RETRY_PRESET: strictCi
|
||||
E2E_ADMIN_EMAIL: ${{ secrets.E2E_ADMIN_EMAIL }}
|
||||
E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
```
|
||||
|
||||
## 5. Deploy rule
|
||||
## 6. Deploy rule
|
||||
|
||||
PR → CI green (unit + e2e when secrets present) → merge → documented deploy script.
|
||||
Do not claim “fixed” from a bare `pct exec` hotfix without a follow-up PR.
|
||||
|
||||
**Adoption pause:** do not add playkit to other app repos until punimtag + kit CI
|
||||
have soaked for a few days. See `docs/IDEAS.md`.
|
||||
**Adoption pause:** soak punimtag + kit CI a few more days before migrating
|
||||
`screening` / `slack-sieve` / `portfolio`. See `docs/IDEAS.md`.
|
||||
|
||||
51
docs/NPM_REGISTRY.md
Normal file
51
docs/NPM_REGISTRY.md
Normal file
@ -0,0 +1,51 @@
|
||||
# Gitea npm registry (@levkin scope)
|
||||
|
||||
Gitea Package Registry is enabled on `git.levkin.ca`. Playkit releases can be
|
||||
installed without a git URL once published.
|
||||
|
||||
## Consumer install
|
||||
|
||||
`.npmrc` (or CI step):
|
||||
|
||||
```ini
|
||||
@levkin:registry=https://git.levkin.ca/api/packages/ilia/npm/
|
||||
//git.levkin.ca/api/packages/ilia/npm/:_authToken=${PLAYKIT_GIT_TOKEN}
|
||||
```
|
||||
|
||||
```bash
|
||||
npm i @levkin/playkit
|
||||
# or pin: npm i @levkin/playkit@0.4.0
|
||||
```
|
||||
|
||||
Git pin remains supported as a fallback:
|
||||
|
||||
```bash
|
||||
npm i git+https://git.levkin.ca/ilia/playkit.git#v0.4.0
|
||||
```
|
||||
|
||||
## Publish (release job)
|
||||
|
||||
On `vX.Y.Z` tag, `.gitea/workflows/ci.yml` `release` job:
|
||||
|
||||
1. Creates the Gitea Release + attaches `npm pack` tarball
|
||||
2. Runs `npm publish` to `https://git.levkin.ca/api/packages/ilia/npm/`
|
||||
using `NPM_PUBLISH_TOKEN` if set, else `RELEASE_TOKEN`
|
||||
(needs **`write:package`**)
|
||||
|
||||
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
36
docs/OPS.md
Normal 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
|
||||
```
|
||||
@ -2,7 +2,32 @@
|
||||
|
||||
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
|
||||
# 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
|
||||
```
|
||||
|
||||
Creates or updates **QA & Dev → Playkit** with the current `package.json`
|
||||
version, install pin, what’s-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`, 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
|
||||
|
||||
@ -11,24 +36,7 @@ tag / Gitea release is green), or whenever you merge a docs-only change that
|
||||
changes consumer behavior:
|
||||
|
||||
1. Tag / release finished (or main docs PR merged)
|
||||
2. Open Outline → QA & Dev → Playkit
|
||||
3. Sync at least: current version pin, “what’s in the box”, install snippet,
|
||||
link to CHANGELOG / ROADMAP / NETWORK.md
|
||||
4. Optional: `make outline-setup` from ansible only if you maintain seed notes there —
|
||||
prefer editing the living page by hand so it stays readable
|
||||
|
||||
Paste template (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 · ROADMAP · CHANGELOG
|
||||
- Metrics: dash.levkin.ca → Live — Playkit e2e
|
||||
```
|
||||
2. Run `python3 scripts/outline-sync-playkit.py`
|
||||
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
|
||||
|
||||
44
docs/SELFTEST.md
Normal file
44
docs/SELFTEST.md
Normal file
@ -0,0 +1,44 @@
|
||||
# Kit CI self-test (fake site)
|
||||
|
||||
Unit tests in `src/**/*.test.ts` mock HTTP/mail. They do **not** prove that
|
||||
`BasePage`, `ApiClient`, or network helpers work against a real browser + HTTP
|
||||
server. The **selftest** suite fills that gap without depending on punimtag DEV.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
selftest/
|
||||
demo-site/server.mjs # tiny Node HTTP app (:4173)
|
||||
playwright.config.ts # starts webServer, runs Chromium
|
||||
tests/
|
||||
browser.spec.ts # BasePage, host guards, intercept, error monitor
|
||||
api.spec.ts # ApiClient + Zod schema
|
||||
```
|
||||
|
||||
Mail stays unit-tested only (Mailpit/Mailtrap need live SMTP traps). Selftest
|
||||
does not start Mailpit.
|
||||
|
||||
## Local
|
||||
|
||||
```bash
|
||||
npx playwright install chromium # once
|
||||
npm run selftest
|
||||
```
|
||||
|
||||
Env (optional):
|
||||
|
||||
| Var | Default |
|
||||
|-----|---------|
|
||||
| `PLAYKIT_SELFTEST_PORT` | `4173` |
|
||||
| `PLAYKIT_BASE_URL` | `http://127.0.0.1:4173` |
|
||||
|
||||
Selftest sets `assertPublicHost(..., false)` when touching `127.0.0.1` — consumer
|
||||
public e2e must keep the default forbid-private behavior.
|
||||
|
||||
## CI
|
||||
|
||||
`.gitea/workflows/ci.yml` job `selftest` runs in
|
||||
`mcr.microsoft.com/playwright:v1.61.1-jammy` (must match the pinned
|
||||
`@playwright/test` version) and executes `npm run selftest` after unit tests.
|
||||
Failures there mean a regression in kit browser/API helpers *before* a consumer
|
||||
pins a broken tag.
|
||||
17
package.json
17
package.json
@ -1,12 +1,15 @@
|
||||
{
|
||||
"name": "@levkin/playkit",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0",
|
||||
"description": "Shared Playwright + API test kit — browser helpers, API client, logging, performance, Grafana/Prometheus metrics",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"bin": {
|
||||
"playkit": "./dist/cli.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
@ -33,15 +36,18 @@
|
||||
"dist",
|
||||
"README.md",
|
||||
"ROADMAP.md",
|
||||
"LICENSE"
|
||||
"LICENSE",
|
||||
"docs/NPM_REGISTRY.md",
|
||||
"docs/CONSUMER.md"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"build": "tsup && chmod +x dist/cli.js",
|
||||
"prepare": "npm run build",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"selftest": "playwright test -c selftest/playwright.config.ts",
|
||||
"lint": "tsc --noEmit",
|
||||
"prepublishOnly": "npm run build",
|
||||
"example:api": "tsx examples/api/health.example.ts"
|
||||
@ -58,7 +64,7 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@types/node": "^22.15.0",
|
||||
"tsup": "^8.4.0",
|
||||
"tsx": "^4.19.0",
|
||||
@ -68,6 +74,9 @@
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.levkin.ca/api/packages/ilia/npm/"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.levkin.ca/ilia/playkit.git"
|
||||
|
||||
240
scripts/outline-sync-playkit.py
Executable file
240
scripts/outline-sync-playkit.py
Executable file
@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create or update Outline → QA & Dev → Playkit (living front door).
|
||||
|
||||
Canonical source stays in git; this pushes a digest + links to notes.levkin.ca.
|
||||
|
||||
Credentials (required):
|
||||
OUTLINE_URL
|
||||
OUTLINE_TOKEN or OUTLINE_API_KEY
|
||||
|
||||
Usage (from ansible vault env, or any shell with those vars):
|
||||
python3 scripts/outline-sync-playkit.py
|
||||
python3 scripts/outline-sync-playkit.py --dry-run
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
COLLECTION_NAME = "QA & Dev"
|
||||
DOC_TITLE = "Playkit"
|
||||
|
||||
|
||||
def load_package_version() -> str:
|
||||
pkg = json.loads((REPO_ROOT / "package.json").read_text(encoding="utf-8"))
|
||||
return str(pkg["version"])
|
||||
|
||||
|
||||
def playkit_markdown(version: str) -> str:
|
||||
# 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 (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
|
||||
|
||||
- Browser: `BasePage`, retries (`click`/`fill`/`safeGoto`), public-host guards (`assertPublicHost`, `waitForUrlHost`)
|
||||
- Network: `interceptNetworkCall`, `startNetworkErrorMonitor` / `assertNoErrors` — see NETWORK.md
|
||||
- API: `ApiClient` + Zod `schema` / `assertSchema`
|
||||
- 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)
|
||||
|
||||
| Doc | Purpose |
|
||||
|-----|---------|
|
||||
| `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` | Living plan |
|
||||
| `CHANGELOG.md` | Per-version notes |
|
||||
|
||||
## Metrics
|
||||
|
||||
- Pushgateway: `http://10.0.10.24:9091` (LAN)
|
||||
- Grafana: `dash.levkin.ca` → **Live — Playkit e2e** (`live-playkit`)
|
||||
- Enable in CI with `PLAYKIT_METRICS_ENABLED=true` + `PLAYKIT_PUSHGATEWAY_URL`
|
||||
|
||||
## Release checklist (operators)
|
||||
|
||||
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}._
|
||||
"""
|
||||
|
||||
|
||||
class OutlineError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class OutlineClient:
|
||||
def __init__(self, base_url: str, token: str, *, dry_run: bool = False) -> None:
|
||||
self.api_base = f"{base_url.rstrip('/')}/api"
|
||||
self.token = token
|
||||
self.dry_run = dry_run
|
||||
|
||||
def call(self, method: str, body: dict | None = None) -> dict:
|
||||
payload = json.dumps(body or {}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{self.api_base}/{method}",
|
||||
data=payload,
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
if self.dry_run:
|
||||
print(f"DRY-RUN POST {method}: {json.dumps(body or {}, indent=2)[:800]}")
|
||||
return {"ok": True, "data": {"id": "dry-run"}}
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
raw = resp.read().decode()
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode(errors="replace")
|
||||
raise OutlineError(f"{method} failed (HTTP {exc.code}): {detail[:500]}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise OutlineError(f"{method} request failed: {exc}") from exc
|
||||
|
||||
data = json.loads(raw)
|
||||
if not data.get("ok"):
|
||||
raise OutlineError(f"{method} returned ok=false: {raw[:500]}")
|
||||
return data
|
||||
|
||||
def list_collections(self) -> list[dict]:
|
||||
out: list[dict] = []
|
||||
offset = 0
|
||||
while True:
|
||||
batch = self.call("collections.list", {"limit": 100, "offset": offset}).get("data") or []
|
||||
out.extend(batch)
|
||||
if len(batch) < 100:
|
||||
break
|
||||
offset += 100
|
||||
return out
|
||||
|
||||
def list_documents(self, collection_id: str) -> list[dict]:
|
||||
out: list[dict] = []
|
||||
offset = 0
|
||||
while True:
|
||||
batch = (
|
||||
self.call(
|
||||
"documents.list",
|
||||
{"limit": 100, "offset": offset, "collectionId": collection_id},
|
||||
).get("data")
|
||||
or []
|
||||
)
|
||||
out.extend(batch)
|
||||
if len(batch) < 100:
|
||||
break
|
||||
offset += 100
|
||||
return out
|
||||
|
||||
|
||||
def require_env() -> tuple[str, str]:
|
||||
# Allow ansible `.env` next to sibling repo without overwriting set keys
|
||||
ansible_env = REPO_ROOT.parent / "ansible" / ".env"
|
||||
if ansible_env.is_file():
|
||||
for line in ansible_env.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value.strip().strip('"').strip("'")
|
||||
|
||||
url = os.environ.get("OUTLINE_URL", "").strip()
|
||||
token = (
|
||||
os.environ.get("OUTLINE_TOKEN", "").strip()
|
||||
or os.environ.get("OUTLINE_API_KEY", "").strip()
|
||||
)
|
||||
missing = [n for n, v in (("OUTLINE_URL", url), ("OUTLINE_TOKEN|OUTLINE_API_KEY", token)) if not v]
|
||||
if missing:
|
||||
print(
|
||||
"ERROR: missing "
|
||||
+ ", ".join(missing)
|
||||
+ "\nLoad: cd ../ansible && make vault-export-env && set -a && source .env && set +a",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
return url, token
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
version = load_package_version()
|
||||
text = playkit_markdown(version)
|
||||
url, token = require_env()
|
||||
client = OutlineClient(url, token, dry_run=args.dry_run)
|
||||
|
||||
collections = {c["name"]: c["id"] for c in client.list_collections()}
|
||||
if COLLECTION_NAME not in collections:
|
||||
raise OutlineError(
|
||||
f'collection "{COLLECTION_NAME}" not found — run ansible scripts/outline-setup.py first'
|
||||
)
|
||||
coll_id = collections[COLLECTION_NAME]
|
||||
docs = {d["title"]: d["id"] for d in client.list_documents(coll_id)}
|
||||
|
||||
try:
|
||||
if DOC_TITLE in docs:
|
||||
client.call(
|
||||
"documents.update",
|
||||
{"id": docs[DOC_TITLE], "title": DOC_TITLE, "text": text, "publish": True},
|
||||
)
|
||||
print(f"updated: {COLLECTION_NAME} / {DOC_TITLE} (v{version})")
|
||||
else:
|
||||
client.call(
|
||||
"documents.create",
|
||||
{
|
||||
"title": DOC_TITLE,
|
||||
"text": text,
|
||||
"collectionId": coll_id,
|
||||
"publish": True,
|
||||
},
|
||||
)
|
||||
print(f"created: {COLLECTION_NAME} / {DOC_TITLE} (v{version})")
|
||||
except OutlineError as exc:
|
||||
if "403" in str(exc) or "authorization_error" in str(exc):
|
||||
print(
|
||||
"\nERROR: Outline API key can list/create but not update documents.\n"
|
||||
"In Outline → Settings → API & Access, edit the key and add scopes:\n"
|
||||
" documents.update documents.delete documents.archive\n"
|
||||
"(full recommended set is in ansible docs/guides/authentik-apps.md Outline section).\n"
|
||||
"Then re-run: python3 scripts/outline-sync-playkit.py\n"
|
||||
"Also delete any leftover 'Playkit sync probe' doc under QA & Dev if present.\n",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise
|
||||
|
||||
print(f"Outline: {url.rstrip('/')}/ → search '{DOC_TITLE}' under {COLLECTION_NAME}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
26
scripts/publish-gitea-npm.sh
Executable file
26
scripts/publish-gitea-npm.sh
Executable 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
|
||||
87
selftest/demo-site/server.mjs
Normal file
87
selftest/demo-site/server.mjs
Normal file
@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Tiny fake app for playkit CI self-tests.
|
||||
* Endpoints: GET /, GET /form, GET /api/health, GET /api/items, GET /api/boom (500)
|
||||
*/
|
||||
import http from 'node:http';
|
||||
import { URL } from 'node:url';
|
||||
|
||||
const PORT = Number(process.env.PLAYKIT_SELFTEST_PORT || 4173);
|
||||
const HOST = process.env.PLAYKIT_SELFTEST_HOST || '127.0.0.1';
|
||||
|
||||
const HOME = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8"><title>Playkit selftest</title></head>
|
||||
<body>
|
||||
<h1>Playkit selftest home</h1>
|
||||
<p><a href="/form">Open form</a></p>
|
||||
<button id="load-items" type="button">Load items</button>
|
||||
<ul id="items"></ul>
|
||||
<script>
|
||||
document.getElementById('load-items').addEventListener('click', async () => {
|
||||
const res = await fetch('/api/items');
|
||||
const data = await res.json();
|
||||
const ul = document.getElementById('items');
|
||||
ul.innerHTML = '';
|
||||
for (const item of data.items) {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = item.name;
|
||||
ul.appendChild(li);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const FORM = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8"><title>Form — Playkit selftest</title></head>
|
||||
<body>
|
||||
<h1>Demo form</h1>
|
||||
<label>Name <input id="name" name="name" /></label>
|
||||
<button id="submit" type="button">Submit</button>
|
||||
<p id="greeting" hidden></p>
|
||||
<script>
|
||||
document.getElementById('submit').addEventListener('click', () => {
|
||||
const name = document.getElementById('name').value;
|
||||
const g = document.getElementById('greeting');
|
||||
g.hidden = false;
|
||||
g.textContent = 'Hello, ' + name;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
function json(res, status, body) {
|
||||
const payload = JSON.stringify(body);
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload),
|
||||
});
|
||||
res.end(payload);
|
||||
}
|
||||
|
||||
function html(res, status, body) {
|
||||
res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const u = new URL(req.url || '/', `http://${HOST}:${PORT}`);
|
||||
if (req.method === 'GET' && u.pathname === '/') return html(res, 200, HOME);
|
||||
if (req.method === 'GET' && u.pathname === '/form') return html(res, 200, FORM);
|
||||
if (req.method === 'GET' && u.pathname === '/api/health') {
|
||||
return json(res, 200, { status: 'ok', service: 'playkit-selftest' });
|
||||
}
|
||||
if (req.method === 'GET' && u.pathname === '/api/items') {
|
||||
return json(res, 200, { items: [{ id: 1, name: 'alpha' }, { id: 2, name: 'beta' }] });
|
||||
}
|
||||
if (req.method === 'GET' && u.pathname === '/api/boom') {
|
||||
return json(res, 500, { error: 'intentional' });
|
||||
}
|
||||
json(res, 404, { error: 'not found' });
|
||||
});
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`playkit selftest listening on http://${HOST}:${PORT}`);
|
||||
});
|
||||
29
selftest/playwright.config.ts
Normal file
29
selftest/playwright.config.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const port = Number(process.env.PLAYKIT_SELFTEST_PORT || 4173);
|
||||
const baseURL = process.env.PLAYKIT_BASE_URL || `http://127.0.0.1:${port}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: process.env.CI ? 2 : undefined,
|
||||
reporter: process.env.CI ? 'list' : [['list'], ['html', { open: 'never' }]],
|
||||
use: {
|
||||
baseURL,
|
||||
...devices['Desktop Chrome'],
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
webServer: {
|
||||
command: `node demo-site/server.mjs`,
|
||||
url: baseURL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 30_000,
|
||||
env: {
|
||||
...process.env,
|
||||
PLAYKIT_SELFTEST_PORT: String(port),
|
||||
PLAYKIT_SELFTEST_HOST: '127.0.0.1',
|
||||
},
|
||||
},
|
||||
});
|
||||
43
selftest/tests/api.spec.ts
Normal file
43
selftest/tests/api.spec.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { z } from 'zod';
|
||||
import { ApiClient, TimingCollector, assertSchema } from '../../src/index.js';
|
||||
|
||||
const baseUrl = process.env.PLAYKIT_BASE_URL || 'http://127.0.0.1:4173';
|
||||
|
||||
const HealthSchema = z.object({
|
||||
status: z.literal('ok'),
|
||||
service: z.string(),
|
||||
});
|
||||
|
||||
const ItemsSchema = z.object({
|
||||
items: z.array(z.object({ id: z.number(), name: z.string() })),
|
||||
});
|
||||
|
||||
test.describe('playkit selftest — ApiClient', () => {
|
||||
test('GET /api/health with Zod schema', async () => {
|
||||
const timings = new TimingCollector();
|
||||
const api = new ApiClient({ baseUrl });
|
||||
|
||||
const res = await timings.measure('api_health', () =>
|
||||
api.get<{ status: string; service: string }>('/api/health', {
|
||||
expectedStatus: 200,
|
||||
schema: HealthSchema,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.status).toBe('ok');
|
||||
// signature: assertSchema(data, schema)
|
||||
expect(assertSchema(res.data, HealthSchema).service).toBe('playkit-selftest');
|
||||
});
|
||||
|
||||
test('GET /api/items + expectedStatus on boom', async () => {
|
||||
const api = new ApiClient({ baseUrl });
|
||||
const items = await api.get('/api/items', { schema: ItemsSchema });
|
||||
expect(items.data.items[0]?.name).toBe('alpha');
|
||||
expect(items.data.items).toHaveLength(2);
|
||||
|
||||
const boom = await api.get('/api/boom', { expectedStatus: 500 });
|
||||
expect(boom.status).toBe(500);
|
||||
});
|
||||
});
|
||||
59
selftest/tests/browser.spec.ts
Normal file
59
selftest/tests/browser.spec.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
BasePage,
|
||||
assertPublicHost,
|
||||
waitForUrlHost,
|
||||
TimingCollector,
|
||||
interceptNetworkCall,
|
||||
startNetworkErrorMonitor,
|
||||
} from '../../src/index.js';
|
||||
|
||||
const baseUrl = process.env.PLAYKIT_BASE_URL || 'http://127.0.0.1:4173';
|
||||
|
||||
test.describe('playkit selftest — browser helpers', () => {
|
||||
test('BasePage click/fill + public-host relax for localhost', async ({ page }) => {
|
||||
// Intentional LAN/selftest: forbidPrivateHosts must be off
|
||||
assertPublicHost(baseUrl, false);
|
||||
expect(() => assertPublicHost(baseUrl, true)).toThrow(/private host/i);
|
||||
|
||||
const timings = new TimingCollector();
|
||||
const home = new BasePage(page, baseUrl);
|
||||
|
||||
await timings.measure('open_home', () => home.open('/'));
|
||||
await waitForUrlHost(page, '127.0.0.1');
|
||||
await expect(page.getByRole('heading', { name: 'Playkit selftest home' })).toBeVisible();
|
||||
|
||||
await timings.measure('open_form', () => home.open('/form'));
|
||||
await home.fill(page.locator('#name'), 'Kolby');
|
||||
await home.click(page.locator('#submit'));
|
||||
await expect(page.getByText('Hello, Kolby')).toBeVisible();
|
||||
|
||||
expect(timings.getSamples().length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('network intercept + error monitor', async ({ page }) => {
|
||||
const home = new BasePage(page, baseUrl);
|
||||
await home.open('/');
|
||||
|
||||
const items = interceptNetworkCall({
|
||||
page,
|
||||
method: 'GET',
|
||||
url: '**/api/items',
|
||||
});
|
||||
await page.getByRole('button', { name: 'Load items' }).click();
|
||||
const { status, responseJson } = await items;
|
||||
expect(status).toBe(200);
|
||||
expect(responseJson).toMatchObject({ items: expect.any(Array) });
|
||||
await expect(page.getByText('alpha')).toBeVisible();
|
||||
|
||||
const net = startNetworkErrorMonitor(page, {
|
||||
excludePatterns: [/\/api\/health/],
|
||||
});
|
||||
try {
|
||||
await page.goto(`${baseUrl}/api/boom`);
|
||||
// stay on JSON page — monitor should have recorded 500
|
||||
} finally {
|
||||
expect(() => net.assertNoErrors()).toThrow(/network errors/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
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,
|
||||
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';
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
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 });
|
||||
}
|
||||
229
src/cli.ts
Normal file
229
src/cli.ts
Normal file
@ -0,0 +1,229 @@
|
||||
/**
|
||||
* playkit CLI — `npx @levkin/playkit <command>` / `playkit <command>`
|
||||
*
|
||||
* init Scaffold e2e/ + CI snippet
|
||||
* smoke Post-deploy public-host + health ping
|
||||
*/
|
||||
import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
|
||||
const GITEA_NPM = 'https://git.levkin.ca/api/packages/ilia/npm/';
|
||||
|
||||
function usage(code = 0): never {
|
||||
console.log(`Usage:
|
||||
playkit init [dir] [--force]
|
||||
playkit smoke [--path /api/health]
|
||||
|
||||
Env for smoke:
|
||||
PLAYKIT_BASE_URL (required)
|
||||
PLAYKIT_API_BASE_URL (optional)
|
||||
PLAYKIT_SMOKE_PATH (default /api/health)
|
||||
PLAYKIT_RETRY_PRESET (optional: default|strictCi|flakyNetwork)
|
||||
`);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
function writeIfMissing(path: string, contents: string, force: boolean): void {
|
||||
if (existsSync(path) && !force) {
|
||||
console.log(`skip (exists): ${path}`);
|
||||
return;
|
||||
}
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, contents, 'utf8');
|
||||
console.log(`wrote: ${path}`);
|
||||
}
|
||||
|
||||
async function cmdInit(args: string[]): Promise<void> {
|
||||
const force = args.includes('--force');
|
||||
const dirArg = args.find((a) => !a.startsWith('-'));
|
||||
const root = resolve(dirArg || '.');
|
||||
const e2e = join(root, 'e2e');
|
||||
|
||||
writeIfMissing(
|
||||
join(e2e, 'playwright.config.ts'),
|
||||
`import { defineConfig, devices } from '@playwright/test';
|
||||
import { playkitFailureArtifacts } from '@levkin/playkit';
|
||||
|
||||
const baseURL = process.env.PLAYKIT_BASE_URL;
|
||||
if (!baseURL) throw new Error('set PLAYKIT_BASE_URL');
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: process.env.CI ? 'list' : 'html',
|
||||
use: {
|
||||
baseURL,
|
||||
...devices['Desktop Chrome'],
|
||||
...playkitFailureArtifacts(),
|
||||
},
|
||||
});
|
||||
`,
|
||||
force,
|
||||
);
|
||||
|
||||
writeIfMissing(
|
||||
join(e2e, 'fixtures.ts'),
|
||||
`import { test as base, expect } from '@playwright/test';
|
||||
import { createPlaykitRuntime, type PlaykitFixtures } from '@levkin/playkit';
|
||||
|
||||
const runtime = createPlaykitRuntime();
|
||||
|
||||
export const test = base.extend<PlaykitFixtures>({
|
||||
playkitConfig: async ({}, use) => use(runtime.playkitConfig),
|
||||
playkitLog: async ({}, use) => use(runtime.playkitLog),
|
||||
api: async ({}, use) => use(runtime.api),
|
||||
timings: async ({}, use) => use(runtime.timings),
|
||||
});
|
||||
|
||||
export { expect };
|
||||
`,
|
||||
force,
|
||||
);
|
||||
|
||||
writeIfMissing(
|
||||
join(e2e, 'tests', 'health.smoke.spec.ts'),
|
||||
`import { test, expect } from '../fixtures';
|
||||
import { assertPublicHost } from '@levkin/playkit';
|
||||
|
||||
test('public host + API health', async ({ playkitConfig, api }) => {
|
||||
assertPublicHost(playkitConfig.baseUrl, playkitConfig.forbidPrivateHosts);
|
||||
const res = await api.get('/api/health', { expectedStatus: 200 });
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
`,
|
||||
force,
|
||||
);
|
||||
|
||||
writeIfMissing(
|
||||
join(e2e, 'env-defaults.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
DEFAULT_BASE_URL: 'https://example.levkin.ca',
|
||||
DEFAULT_API_BASE_URL: 'https://example.levkin.ca',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
force,
|
||||
);
|
||||
|
||||
writeIfMissing(
|
||||
join(e2e, 'ci-snippet.yml'),
|
||||
`# Drop into .gitea/workflows/ci.yml (adjust secrets / paths)
|
||||
e2e:
|
||||
runs-on: [homelab, self-hosted, linux]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: e2e
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: npm auth for @levkin
|
||||
working-directory: .
|
||||
run: |
|
||||
echo "@levkin:registry=${GITEA_NPM}" >> ~/.npmrc
|
||||
echo "//git.levkin.ca/api/packages/ilia/npm/:_authToken=\${{ secrets.PLAYKIT_GIT_TOKEN }}" >> ~/.npmrc
|
||||
npm ci
|
||||
- run: npx playwright install --with-deps chromium
|
||||
- run: npx playwright test
|
||||
env:
|
||||
PLAYKIT_BASE_URL: \${{ secrets.PLAYKIT_BASE_URL }}
|
||||
PLAYKIT_RETRY_PRESET: strictCi
|
||||
CI: 'true'
|
||||
`.replaceAll('${GITEA_NPM}', GITEA_NPM),
|
||||
force,
|
||||
);
|
||||
|
||||
writeIfMissing(
|
||||
join(root, '.npmrc.playkit.example'),
|
||||
`@levkin:registry=${GITEA_NPM}
|
||||
//git.levkin.ca/api/packages/ilia/npm/:_authToken=\${PLAYKIT_GIT_TOKEN}
|
||||
`,
|
||||
force,
|
||||
);
|
||||
|
||||
const pkgPath = join(root, 'package.json');
|
||||
if (existsSync(pkgPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
};
|
||||
const dep =
|
||||
pkg.dependencies?.['@levkin/playkit'] ||
|
||||
pkg.devDependencies?.['@levkin/playkit'];
|
||||
if (!dep) {
|
||||
console.log(
|
||||
`\nNext: npm i @levkin/playkit --registry ${GITEA_NPM}\n` +
|
||||
` (or copy .npmrc.playkit.example → .npmrc)\n` +
|
||||
`Fallback: npm i git+https://git.levkin.ca/ilia/playkit.git#v0.4.0`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nScaffolded under ${e2e}`);
|
||||
}
|
||||
|
||||
async function cmdSmoke(args: string[]): Promise<void> {
|
||||
const pathIdx = args.indexOf('--path');
|
||||
const smokePath =
|
||||
(pathIdx >= 0 ? args[pathIdx + 1] : undefined) ||
|
||||
process.env.PLAYKIT_SMOKE_PATH ||
|
||||
'/api/health';
|
||||
|
||||
const mod = await import('./index.js');
|
||||
const config = mod.loadConfig();
|
||||
mod.assertPublicHost(config.baseUrl, config.forbidPrivateHosts);
|
||||
|
||||
const api = new mod.ApiClient({ baseUrl: config.apiBaseUrl });
|
||||
const timings = new mod.TimingCollector();
|
||||
const res = await timings.measure('smoke', () =>
|
||||
api.get(smokePath, { expectedStatus: 200 }),
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
project: config.project,
|
||||
env: config.env,
|
||||
host: config.expectedHost,
|
||||
path: smokePath,
|
||||
status: res.status,
|
||||
durationMs: res.durationMs,
|
||||
retryPreset: config.retryPreset,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
if (config.metrics.enabled && config.metrics.pushgatewayUrl) {
|
||||
await mod.pushPrometheusMetrics(timings, {
|
||||
pushgatewayUrl: config.metrics.pushgatewayUrl,
|
||||
job: config.metrics.job,
|
||||
grouping: { project: config.project, env: config.env, suite: 'smoke' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const [, , cmd, ...rest] = process.argv;
|
||||
if (!cmd || cmd === '-h' || cmd === '--help') usage(0);
|
||||
if (cmd === 'init') return cmdInit(rest);
|
||||
if (cmd === 'smoke') return cmdSmoke(rest);
|
||||
console.error(`Unknown command: ${cmd}`);
|
||||
usage(1);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -33,6 +33,24 @@ describe('loadConfig', () => {
|
||||
});
|
||||
expect(cfg.expectedHost).toBe('punimtagdev.levkin.ca');
|
||||
expect(cfg.project).toBe('punimtag');
|
||||
expect(cfg.retryPreset).toBe('default');
|
||||
});
|
||||
|
||||
it('applies PLAYKIT_RETRY_PRESET and allows env overrides', () => {
|
||||
const cfg = loadConfig({
|
||||
PLAYKIT_BASE_URL: 'https://punimtagdev.levkin.ca',
|
||||
PLAYKIT_RETRY_PRESET: 'strictCi',
|
||||
});
|
||||
expect(cfg.retryPreset).toBe('strictCi');
|
||||
expect(cfg.actionRetries).toBe(0);
|
||||
expect(cfg.defaultTimeoutMs).toBe(15_000);
|
||||
|
||||
const overriden = loadConfig({
|
||||
PLAYKIT_BASE_URL: 'https://punimtagdev.levkin.ca',
|
||||
PLAYKIT_RETRY_PRESET: 'strictCi',
|
||||
PLAYKIT_ACTION_RETRIES: '9',
|
||||
});
|
||||
expect(overriden.actionRetries).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { resolveRetryPreset, type RetryPresetName } from './retryPresets.js';
|
||||
|
||||
export interface PlaykitConfig {
|
||||
/** Public UI base URL (must be what users open in the browser). */
|
||||
baseUrl: string;
|
||||
@ -11,6 +13,8 @@ export interface PlaykitConfig {
|
||||
env: string;
|
||||
defaultTimeoutMs: number;
|
||||
actionRetries: number;
|
||||
/** Active retry preset name (from PLAYKIT_RETRY_PRESET). */
|
||||
retryPreset: RetryPresetName;
|
||||
metrics: {
|
||||
enabled: boolean;
|
||||
pushgatewayUrl?: string;
|
||||
@ -55,7 +59,15 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): PlaykitConfig
|
||||
|
||||
const apiBaseUrl = (env.PLAYKIT_API_BASE_URL || env.API_BASE_URL || baseUrl).replace(/\/$/, '');
|
||||
const expectedHost = env.PLAYKIT_EXPECTED_HOST || hostFromUrl(baseUrl);
|
||||
const preset = resolveRetryPreset(env.PLAYKIT_RETRY_PRESET);
|
||||
// Explicit env overrides win over preset values.
|
||||
const forbidPrivateHosts = parseBool(env.PLAYKIT_FORBID_PRIVATE_HOSTS, true);
|
||||
const defaultTimeoutMs = Number(env.PLAYKIT_TIMEOUT_MS || preset.defaultTimeoutMs);
|
||||
const actionRetries = Number(
|
||||
env.PLAYKIT_ACTION_RETRIES !== undefined && env.PLAYKIT_ACTION_RETRIES !== ''
|
||||
? env.PLAYKIT_ACTION_RETRIES
|
||||
: preset.actionRetries,
|
||||
);
|
||||
|
||||
if (forbidPrivateHosts && isPrivateHost(expectedHost)) {
|
||||
throw new Error(
|
||||
@ -70,8 +82,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): PlaykitConfig
|
||||
forbidPrivateHosts,
|
||||
project: env.PLAYKIT_PROJECT || env.CI_PROJECT || 'unknown',
|
||||
env: env.PLAYKIT_ENV || env.APP_ENV || 'dev',
|
||||
defaultTimeoutMs: Number(env.PLAYKIT_TIMEOUT_MS || 30_000),
|
||||
actionRetries: Number(env.PLAYKIT_ACTION_RETRIES || 2),
|
||||
defaultTimeoutMs,
|
||||
actionRetries,
|
||||
retryPreset: preset.name,
|
||||
metrics: {
|
||||
enabled: parseBool(env.PLAYKIT_METRICS_ENABLED, false),
|
||||
pushgatewayUrl: env.PLAYKIT_PUSHGATEWAY_URL || env.PUSHGATEWAY_URL,
|
||||
|
||||
20
src/config/retryPresets.test.ts
Normal file
20
src/config/retryPresets.test.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveRetryPreset, RETRY_PRESETS } from './retryPresets.js';
|
||||
|
||||
describe('retry presets', () => {
|
||||
it('defaults when unset', () => {
|
||||
expect(resolveRetryPreset(undefined).name).toBe('default');
|
||||
expect(resolveRetryPreset('').name).toBe('default');
|
||||
});
|
||||
|
||||
it('resolves known presets', () => {
|
||||
expect(resolveRetryPreset('strictCi').actionRetries).toBe(0);
|
||||
expect(resolveRetryPreset('flakyNetwork').actionRetries).toBeGreaterThan(
|
||||
RETRY_PRESETS.default.actionRetries,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unknown names', () => {
|
||||
expect(() => resolveRetryPreset('nope')).toThrow(/Unknown PLAYKIT_RETRY_PRESET/);
|
||||
});
|
||||
});
|
||||
49
src/config/retryPresets.ts
Normal file
49
src/config/retryPresets.ts
Normal file
@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Named retry / timeout profiles for consumers and `loadConfig`.
|
||||
*
|
||||
* Use via `PLAYKIT_RETRY_PRESET=strictCi|flakyNetwork` or
|
||||
* `applyRetryPreset(config, 'flakyNetwork')`.
|
||||
*/
|
||||
export type RetryPresetName = 'strictCi' | 'flakyNetwork' | 'default';
|
||||
|
||||
export interface RetryPreset {
|
||||
name: RetryPresetName;
|
||||
/** Passed to click/fill/safeGoto retries (0 = fail fast). */
|
||||
actionRetries: number;
|
||||
defaultTimeoutMs: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const RETRY_PRESETS: Record<RetryPresetName, RetryPreset> = {
|
||||
default: {
|
||||
name: 'default',
|
||||
actionRetries: 2,
|
||||
defaultTimeoutMs: 30_000,
|
||||
description: 'Balanced defaults (dev + typical CI)',
|
||||
},
|
||||
strictCi: {
|
||||
name: 'strictCi',
|
||||
actionRetries: 0,
|
||||
defaultTimeoutMs: 15_000,
|
||||
description: 'Fail fast — expose flakes instead of masking them',
|
||||
},
|
||||
flakyNetwork: {
|
||||
name: 'flakyNetwork',
|
||||
actionRetries: 4,
|
||||
defaultTimeoutMs: 45_000,
|
||||
description: 'Extra retries/timeouts for congested or remote CI',
|
||||
},
|
||||
};
|
||||
|
||||
export function resolveRetryPreset(
|
||||
name: string | undefined,
|
||||
): RetryPreset {
|
||||
if (!name) return RETRY_PRESETS.default;
|
||||
const key = name as RetryPresetName;
|
||||
if (!(key in RETRY_PRESETS)) {
|
||||
throw new Error(
|
||||
`Unknown PLAYKIT_RETRY_PRESET "${name}". Use: ${Object.keys(RETRY_PRESETS).join(', ')}`,
|
||||
);
|
||||
}
|
||||
return RETRY_PRESETS[key];
|
||||
}
|
||||
19
src/index.ts
19
src/index.ts
@ -7,6 +7,12 @@ export {
|
||||
isPrivateHost,
|
||||
type PlaykitConfig,
|
||||
} from './config/loadConfig.js';
|
||||
export {
|
||||
RETRY_PRESETS,
|
||||
resolveRetryPreset,
|
||||
type RetryPreset,
|
||||
type RetryPresetName,
|
||||
} from './config/retryPresets.js';
|
||||
export { createLogger, type Logger, type LogLevel } from './logging/logger.js';
|
||||
export { redactSecrets } from './logging/redact.js';
|
||||
|
||||
@ -29,6 +35,13 @@ export {
|
||||
dedupeNetworkErrors,
|
||||
globToRegExp,
|
||||
responseMatchesFilter,
|
||||
COMMON_NOISE_PATTERNS,
|
||||
byAriaLabel,
|
||||
clickByAriaLabel,
|
||||
withDialog,
|
||||
fillContentEditable,
|
||||
runPersistentSession,
|
||||
isBrowserCrashError,
|
||||
type ClickOptions,
|
||||
type FillOptions,
|
||||
type GotoOptions,
|
||||
@ -37,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 {
|
||||
|
||||
@ -1,17 +1,31 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: {
|
||||
index: 'src/index.ts',
|
||||
'browser/index': 'src/browser/index.ts',
|
||||
'api/index': 'src/api/index.ts',
|
||||
'mail/index': 'src/mail/index.ts',
|
||||
},
|
||||
format: ['esm', 'cjs'],
|
||||
const shared = {
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
splitting: false,
|
||||
treeshake: true,
|
||||
external: ['@playwright/test'],
|
||||
});
|
||||
} as const;
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
...shared,
|
||||
entry: {
|
||||
index: 'src/index.ts',
|
||||
'browser/index': 'src/browser/index.ts',
|
||||
'api/index': 'src/api/index.ts',
|
||||
'mail/index': 'src/mail/index.ts',
|
||||
},
|
||||
format: ['esm', 'cjs'],
|
||||
clean: true,
|
||||
},
|
||||
{
|
||||
...shared,
|
||||
entry: { cli: 'src/cli.ts' },
|
||||
format: ['esm'],
|
||||
dts: false,
|
||||
clean: false,
|
||||
banner: { js: '#!/usr/bin/env node' },
|
||||
},
|
||||
]);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user