# @levkin/playkit Shared **Playwright + API** test kit for Levkin / homelab apps. Use it as a library from any consumer repo (punimtag, MirrorMatch, …). App-specific Page Objects and specs stay in the consumer; reusable helpers, logging, API client, performance timings, and Grafana/Prometheus metrics live here. ## Install (consumer) ```bash # 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 npx playwright install chromium ``` ```ts // e2e/fixtures.ts import { test as base } from '@playwright/test'; import { createPlaykitRuntime, type PlaykitFixtures } from '@levkin/playkit'; const runtime = createPlaykitRuntime(); export const test = base.extend({ 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 } from '@playwright/test'; ``` ```ts // e2e/auth.signout.spec.ts — catches NEXTAUTH_URL → 10.x redirect (punimtag #57) import { assertPublicHost, waitForUrlHost } from '@levkin/playkit'; import { test } from './fixtures'; test('sign-out stays on public host', async ({ page, playkitConfig, timings }) => { assertPublicHost(playkitConfig.baseUrl); await timings.measure('open', () => page.goto(playkitConfig.baseUrl)); // … login … // … sign out … await waitForUrlHost(page, playkitConfig.expectedHost); }); ``` ## Environment | Variable | Required | Purpose | |----------|----------|---------| | `PLAYKIT_BASE_URL` / `BASE_URL` | yes | Public UI URL (e.g. `https://punimtagdev.levkin.ca`) | | `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 | | `PLAYKIT_PUSHGATEWAY_URL` | if metrics | e.g. `http://10.0.10.x:9091` | | `PLAYKIT_LOG_LEVEL` | no | `debug` \| `info` \| `warn` \| `error` | **Secrets:** put test credentials and pushgateway tokens in Infisical (`LevkinOps`) and sync into Gitea Actions — see ansible `docs/hardening/SECRETS.md`. Never commit passwords. ## What’s in the box | Module | Role | |--------|------| | `BasePage`, `click` / `fill` / `safeGoto` | Retried browser actions + Page Object base | | `waitForUrlHost` / `assertPublicHost` | Guard against LAN redirect bugs | | `ApiClient` | Typed HTTP client with status asserts + redacted logs | | `createLogger` / `redactSecrets` | Structured JSON logs | | `TimingCollector` / `pushPrometheusMetrics` | Action timings → Prometheus Pushgateway → Grafana | | `createPlaykitRuntime` | One-shot config + logger + API + timings | | `createMailInbox` / `MailpitClient` / `MailtrapClient` | Assert password-reset / verify emails (Mailpit homelab trap or Mailtrap SaaS) | | `assertSchema` | Zod schema asserts (standalone or via `ApiClient` `schema` option) | | `saveStorageState` / `storageStateUse` | Auth once, reuse across specs | | `playkitFailureArtifacts` | Trace/video/screenshot only on failure | | `interceptNetworkCall` | Spy or mock the next matching page network call | | `byAriaLabel` / `clickByAriaLabel` | Find/click by a regex over `aria-label`, scoped to a page or a narrower locator | | `withDialog` | Retry an action against a modal that might have silently closed, reopening it first | | `fillContentEditable` | Type into `contenteditable` rich-text fields with real paragraph breaks | | `runPersistentSession` | Keep one browser session open across runs via flag files instead of relaunching/re-authenticating | | `playkit` CLI | `init` scaffold + `smoke` post-deploy gate | See also `docs/NETWORK.md`, `docs/SELFTEST.md`, `docs/NPM_REGISTRY.md`, `docs/OPS.md`, `docs/IDEAS.md`, `docs/OUTLINE.md`. ## Network (page traffic) ```ts import { interceptNetworkCall, startNetworkErrorMonitor } from '@levkin/playkit'; const users = interceptNetworkCall({ page, url: '**/api/users' }); const net = startNetworkErrorMonitor(page, { excludePatterns: ['sentry.io'] }); try { await page.goto('/users'); const { status } = await users; expect(status).toBe(200); } finally { net.assertNoErrors(); } ``` ## 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 `