Add @levkin/playkit v0.1 shared Playwright/API kit
All checks were successful
CI / skip-ci-check (push) Successful in 4s
CI / secret-scan (push) Successful in 3s
CI / build-and-test (push) Successful in 10s

Ship browser helpers with public-host guards, ApiClient, structured
logging, Prometheus timings, CI, examples, and ROADMAP for consumers.
This commit is contained in:
ilia 2026-07-14 16:04:23 -04:00
parent 6d13661660
commit af12a92eb7
28 changed files with 4089 additions and 2 deletions

9
.env.example Normal file
View File

@ -0,0 +1,9 @@
# Copy to .env for local examples (never commit real secrets)
PLAYKIT_BASE_URL=https://punimtagdev.levkin.ca
PLAYKIT_API_BASE_URL=https://punimtagdev.levkin.ca
PLAYKIT_PROJECT=punimtag
PLAYKIT_ENV=dev
PLAYKIT_LOG_LEVEL=info
PLAYKIT_FORBID_PRIVATE_HOSTS=true
# PLAYKIT_METRICS_ENABLED=true
# PLAYKIT_PUSHGATEWAY_URL=http://10.0.10.24:9091

58
.gitea/workflows/ci.yml Normal file
View File

@ -0,0 +1,58 @@
---
# Homelab CI for @levkin/playkit
name: CI
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
jobs:
skip-ci-check:
runs-on: [homelab, self-hosted, linux]
container:
image: node:20-bookworm
outputs:
should-skip: ${{ steps.check.outputs.skip }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- id: check
run: |
SKIP=0
BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}"
MSG="${GITHUB_EVENT_HEAD_COMMIT_MESSAGE:-$(git log -1 --pretty=%B 2>/dev/null || true)}"
echo "$BRANCH" "$MSG" | grep -qi '@skipci' && SKIP=1
echo "skip=$SKIP" >> $GITHUB_OUTPUT
build-and-test:
needs: skip-ci-check
if: needs.skip-ci-check.outputs.should-skip != '1'
runs-on: [homelab, self-hosted, linux]
container:
image: node:20-bookworm
steps:
- uses: actions/checkout@v4
- name: Install
run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Unit tests
run: npm test
- name: Build
run: npm run build
secret-scan:
needs: skip-ci-check
if: needs.skip-ci-check.outputs.should-skip != '1'
runs-on: [homelab, self-hosted, linux, heavy]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Gitleaks
run: |
docker run --rm -v "$PWD:/repo" ghcr.io/gitleaks/gitleaks:latest \
detect --source /repo --no-banner --redact

15
.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
node_modules/
dist/
coverage/
.DS_Store
*.log
.env
.env.*
!.env.example
playwright-report/
test-results/
blob-report/
.auth/
*.tsbuildinfo
.idea/
.vscode/

16
.gitleaks.toml Normal file
View File

@ -0,0 +1,16 @@
# Gitleaks config for playkit
# useDefault is required so detection rules load (see ansible .gitleaks.toml note).
title = "playkit gitleaks"
[extend]
useDefault = true
[allowlist]
description = "Known false positives"
paths = [
'''\.env\.example''',
]
regexes = [
'''\$\{[A-Za-z0-9_]+\}''',
]

12
CHANGELOG.md Normal file
View File

@ -0,0 +1,12 @@
# Changelog
## 0.1.0 — 2026-07-14
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

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Levkin / Ilia Dobkin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,3 +1,93 @@
# playkit
# @levkin/playkit
Shared Playwright + API test kit (@levkin/playkit) — UI helpers, API client, logging, performance, Grafana metrics
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
# git tag dependency (until a private npm registry is wired)
npm install git+https://git.levkin.ca/ilia/playkit.git#v0.1.0
# 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<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 } 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_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.24: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.
## Whats 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 |
## Develop this repo
```bash
npm ci
npm run typecheck
npm test
npm run build
```
## Release
1. Bump `version` in `package.json`
2. Update `CHANGELOG.md`
3. Tag `vX.Y.Z` and push — consumers pin the tag
## License
MIT

83
ROADMAP.md Normal file
View File

@ -0,0 +1,83 @@
# playkit roadmap
Living plan for making `@levkin/playkit` more useful across Levkin repos.
## Now (v0.1) — shipped in this repo
- [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
## Next (v0.2) — high value
- [ ] **Mailosaur / Mailpit adapter** — assert “email sent” without depending on JRCC Outlook or Spamhaus-blocked IPs
- [ ] **Auth storage state helpers** — save/load Playwright `storageState` for admin vs viewer roles
- [ ] **Trace-on-failure preset** — one-liner Playwright config merge (`trace: 'retain-on-failure'`, screenshot, video)
- [ ] **Schema assertions** — optional Zod (or AJV) helpers on `ApiClient` responses
- [ ] **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 (health + public host + login)
- [ ] **Retry policy presets** — flaky-network vs strict-CI profiles
## Later (v0.3+) — professional polish
- [ ] **Web Vitals** (LCP/CLS/INP) collection via Playwright CDP + metrics labels
- [ ] **A11y** — axe-core wrapper as optional peer
- [ ] **Visual regression** — screenshot baselines with per-project bucket (MinIO)
- [ ] **Contract testing** — OpenAPI-driven API suite generator
- [ ] **Multi-browser matrix helper** — chromium/firefox/webkit project factory
- [ ] **Flake quarantine** — annotate + quarantine flaky tests with Grafana panel
- [ ] **Hermes / Mattermost reporter** — post failed suite summary to `#eng`
- [ ] **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
## Ideas backlog (not scheduled)
| Idea | Why |
|------|-----|
| Shared Page Object for Authentik login | Many apps share SSO |
| Chaos toggles (throttle network, offline) | Catch brittle UIs |
| Seeded persona library (`admin`, `viewer`, `unverified`) | Consistent fixtures across apps |
| “Deploy smoke” CLI | Post-deploy one-command gate before paging humans |
| Recorded HAR attach on API failure | Faster debugging |
| Benchmark budgets in CI | Fail if p95 login > N ms |
| Docs site (VitePress) with cookbook recipes | Onboarding other repos faster |
| Email delivery probe (SMTP accept ≠ inbox) | Catch Spamhaus / bounce class of #56 |
| `NEXTAUTH_URL` / canonical URL checker | Static env lint before e2e |
| Tag-based suite filters (`@smoke`, `@auth`, `@mail`) | Fast PR vs nightly depth |
| Parallel shard helper for self-hosted runners | Keep CI under 5m as suite grows |
| Golden-path checklist generator | Per-app “must pass before claim fixed” |
| Kuma + playkit correlation IDs | Tie synthetic monitor blips to e2e runs |
| Diffable timing baselines in git | Spot regressions without Grafana |
## Punimtag as first consumer (follow-up, not this repo)
1. Add `e2e/` depending on `@levkin/playkit@v0.1.0`
2. Specs: public host after sign-out; health API; optional forgot-password with mail trap
3. CI job on PR + scheduled DEV smoke
4. Deploy rule: PR → green CI → merge → deploy script (no silent `pct exec` “done”)
## Observability
Pushgateway → Prometheus on LXC 240 (`observability` @ `10.0.10.24`) → Grafana dashboard `dashboards/playkit-overview.json`.
Metrics (v0.1):
- `playkit_action_duration_ms{project,env,action}`
- `playkit_action_ok{project,env,action}`
## Success criteria
A bug like “sign-out redirects to LAN IP” or “email accepted by SMTP but bounced by Outlook” cannot be marked fixed without:
1. Public-URL browser assertion (playkit host guards)
2. Delivery/inbox assertion (mail adapter — v0.2) or documented mail-trap substitute
3. Green consumer CI on the merge commit

View File

@ -0,0 +1,71 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": { "type": "prometheus", "uid": "prometheus" },
"enable": true,
"expr": "playkit_action_ok{project=~\"$project\"}",
"name": "Playkit action failed",
"type": "classic_conditions"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [],
"panels": [
{
"title": "Action duration (ms)",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [
{
"expr": "playkit_action_duration_ms{project=~\"$project\", env=~\"$env\"}",
"legendFormat": "{{action}}"
}
]
},
{
"title": "Action success (1=ok)",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [
{
"expr": "playkit_action_ok{project=~\"$project\", env=~\"$env\"}",
"legendFormat": "{{action}}"
}
]
}
],
"schemaVersion": 39,
"tags": ["playkit", "e2e"],
"templating": {
"list": [
{
"name": "project",
"type": "query",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"query": "label_values(playkit_action_duration_ms, project)",
"includeAll": true,
"current": { "text": "All", "value": "$__all" }
},
{
"name": "env",
"type": "query",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"query": "label_values(playkit_action_duration_ms, env)",
"includeAll": true,
"current": { "text": "All", "value": "$__all" }
}
]
},
"time": { "from": "now-6h", "to": "now" },
"title": "Playkit E2E overview",
"uid": "playkit-overview",
"version": 1
}

56
docs/CONSUMER.md Normal file
View File

@ -0,0 +1,56 @@
# How to adopt @levkin/playkit in an app repo
## 1. Depend on a release
```bash
npm install git+https://git.levkin.ca/ilia/playkit.git#v0.1.0
npm install -D @playwright/test
npx playwright install chromium
```
## 2. Layout
```
e2e/
playwright.config.ts
fixtures.ts
pages/LoginPage.ts
tests/auth.signout.spec.ts
api/health.spec.ts
```
## 3. Config (Infisical → CI secrets)
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 humans password)
- optional `PLAYKIT_PUSHGATEWAY_URL`
Sync into Gitea Actions secrets for the consumer repo.
## 4. CI job sketch
```yaml
e2e:
runs-on: [homelab, self-hosted, linux]
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test
env:
PLAYKIT_BASE_URL: ${{ secrets.PLAYKIT_BASE_URL }}
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
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.

View File

@ -0,0 +1,35 @@
/**
* Example: API health check pattern consumers copy into their repos.
* Run: PLAYKIT_BASE_URL=https://punimtagdev.levkin.ca npm run example:api
*/
import { ApiClient, TimingCollector, loadConfig, createLogger, pushPrometheusMetrics } from '../src/index.js';
async function main() {
const config = loadConfig();
const log = createLogger({ name: 'example-api', bindings: { project: config.project } });
const timings = new TimingCollector(log);
const api = new ApiClient({
baseUrl: config.apiBaseUrl,
logger: log,
});
const res = await timings.measure('health', () =>
api.get('/api/health', { expectedStatus: 200 }),
);
log.info('health ok', { status: res.status, durationMs: res.durationMs, data: res.data });
if (config.metrics.enabled && config.metrics.pushgatewayUrl) {
await pushPrometheusMetrics(timings, {
pushgatewayUrl: config.metrics.pushgatewayUrl,
job: config.metrics.job,
grouping: { project: config.project, env: config.env },
logger: log,
});
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@ -0,0 +1,20 @@
/**
* Example UI pattern (not executed in kit CI consumers own Playwright projects).
*
* Critical assert: after sign-out, URL host must remain the public host.
* This is the failure mode from punimtag #57 (redirect to 10.0.10.121:3001).
*/
import { test, expect } from '@playwright/test';
import { loadConfig, waitForUrlHost, assertPublicHost, TimingCollector } from '../../src/index.js';
test.describe('public host smoke (pattern)', () => {
test('base URL is public and reachable', async ({ page }) => {
const config = loadConfig();
const timings = new TimingCollector();
assertPublicHost(config.baseUrl, config.forbidPrivateHosts);
await timings.measure('goto_home', () => page.goto(config.baseUrl));
await waitForUrlHost(page, config.expectedHost);
expect(page.url()).toContain(config.expectedHost);
});
});

2673
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

75
package.json Normal file
View File

@ -0,0 +1,75 @@
{
"name": "@levkin/playkit",
"version": "0.1.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",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./browser": {
"types": "./dist/browser/index.d.ts",
"import": "./dist/browser/index.js",
"require": "./dist/browser/index.cjs"
},
"./api": {
"types": "./dist/api/index.d.ts",
"import": "./dist/api/index.js",
"require": "./dist/api/index.cjs"
}
},
"files": [
"dist",
"dashboards",
"README.md",
"ROADMAP.md",
"LICENSE"
],
"scripts": {
"build": "tsup",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"lint": "tsc --noEmit",
"prepublishOnly": "npm run build",
"example:api": "tsx examples/api/health.example.ts"
},
"peerDependencies": {
"@playwright/test": ">=1.40.0"
},
"peerDependenciesMeta": {
"@playwright/test": {
"optional": true
}
},
"devDependencies": {
"@playwright/test": "^1.52.0",
"@types/node": "^22.15.0",
"tsup": "^8.4.0",
"tsx": "^4.19.0",
"typescript": "^5.8.0",
"vitest": "^3.1.0"
},
"engines": {
"node": ">=20"
},
"repository": {
"type": "git",
"url": "https://git.levkin.ca/ilia/playkit.git"
},
"keywords": [
"playwright",
"e2e",
"api-testing",
"observability",
"grafana",
"prometheus"
]
}

172
src/api/client.ts Normal file
View File

@ -0,0 +1,172 @@
import { createLogger, type Logger } from '../logging/logger.js';
import { redactSecrets } from '../logging/redact.js';
export interface ApiClientOptions {
baseUrl: string;
defaultHeaders?: Record<string, string>;
timeoutMs?: number;
logger?: Logger;
/** Extra strings to treat as secrets in logs (tokens, passwords). */
redactValues?: string[];
}
export interface ApiRequestOptions {
headers?: Record<string, string>;
query?: Record<string, string | number | boolean | undefined>;
body?: unknown;
timeoutMs?: number;
expectedStatus?: number | number[];
}
export interface ApiResponse<T = unknown> {
status: number;
ok: boolean;
headers: Headers;
data: T;
text: string;
durationMs: number;
url: string;
}
function buildUrl(base: string, path: string, query?: ApiRequestOptions['query']): string {
const u = new URL(path.replace(/^\//, ''), base.endsWith('/') ? base : `${base}/`);
if (query) {
for (const [k, v] of Object.entries(query)) {
if (v === undefined) continue;
u.searchParams.set(k, String(v));
}
}
return u.toString();
}
function statusOk(status: number, expected?: number | number[]): boolean {
if (expected == null) return status >= 200 && status < 300;
const list = Array.isArray(expected) ? expected : [expected];
return list.includes(status);
}
export class ApiClient {
private readonly log: Logger;
private readonly timeoutMs: number;
private readonly headers: Record<string, string>;
private readonly redactValues: string[];
constructor(private readonly options: ApiClientOptions) {
this.log = options.logger ?? createLogger({ name: 'ApiClient' });
this.timeoutMs = options.timeoutMs ?? 30_000;
this.headers = { Accept: 'application/json', ...(options.defaultHeaders ?? {}) };
this.redactValues = options.redactValues ?? [];
}
withAuthBearer(token: string): ApiClient {
return new ApiClient({
...this.options,
defaultHeaders: {
...this.headers,
Authorization: `Bearer ${token}`,
},
redactValues: [...this.redactValues, token],
logger: this.log,
});
}
async request<T = unknown>(
method: string,
path: string,
opts: ApiRequestOptions = {},
): Promise<ApiResponse<T>> {
const url = buildUrl(this.options.baseUrl, path, opts.query);
const headers: Record<string, string> = { ...this.headers, ...(opts.headers ?? {}) };
let body: string | undefined;
if (opts.body !== undefined) {
if (!headers['Content-Type'] && !headers['content-type']) {
headers['Content-Type'] = 'application/json';
}
body = typeof opts.body === 'string' ? opts.body : JSON.stringify(opts.body);
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? this.timeoutMs);
const started = Date.now();
this.log.info('api.request', {
method,
url: this.safeUrl(url),
headers: redactSecrets(headers),
});
try {
const res = await fetch(url, {
method,
headers,
body,
signal: controller.signal,
});
const text = await res.text();
const durationMs = Date.now() - started;
let data: T;
try {
data = text ? (JSON.parse(text) as T) : (undefined as T);
} catch {
data = text as unknown as T;
}
const response: ApiResponse<T> = {
status: res.status,
ok: res.ok,
headers: res.headers,
data,
text,
durationMs,
url,
};
this.log.info('api.response', {
method,
url: this.safeUrl(url),
status: res.status,
durationMs,
body: redactSecrets(typeof data === 'object' ? data : { text: text.slice(0, 500) }),
});
if (!statusOk(res.status, opts.expectedStatus)) {
const want = opts.expectedStatus ?? '2xx';
throw new Error(
`API ${method} ${url} expected status ${want} but got ${res.status}: ${text.slice(0, 300)}`,
);
}
return response;
} finally {
clearTimeout(timer);
}
}
get<T = unknown>(path: string, opts?: ApiRequestOptions): Promise<ApiResponse<T>> {
return this.request<T>('GET', path, opts);
}
post<T = unknown>(path: string, opts?: ApiRequestOptions): Promise<ApiResponse<T>> {
return this.request<T>('POST', path, opts);
}
put<T = unknown>(path: string, opts?: ApiRequestOptions): Promise<ApiResponse<T>> {
return this.request<T>('PUT', path, opts);
}
patch<T = unknown>(path: string, opts?: ApiRequestOptions): Promise<ApiResponse<T>> {
return this.request<T>('PATCH', path, opts);
}
delete<T = unknown>(path: string, opts?: ApiRequestOptions): Promise<ApiResponse<T>> {
return this.request<T>('DELETE', path, opts);
}
private safeUrl(url: string): string {
let out = url;
for (const secret of this.redactValues) {
if (secret) out = out.split(secret).join('[REDACTED]');
}
return out;
}
}

1
src/api/index.ts Normal file
View File

@ -0,0 +1 @@
export { ApiClient, type ApiClientOptions, type ApiRequestOptions, type ApiResponse } from './client.js';

193
src/browser/actions.ts Normal file
View File

@ -0,0 +1,193 @@
import type { Locator, Page } from '@playwright/test';
import { createLogger, type Logger } from '../logging/logger.js';
import { isPrivateHost } from '../config/loadConfig.js';
export interface ClickOptions {
timeout?: number;
retries?: number;
force?: boolean;
trial?: boolean;
}
export interface FillOptions {
timeout?: number;
retries?: number;
clear?: boolean;
}
export interface GotoOptions {
timeout?: number;
waitUntil?: 'load' | 'domcontentloaded' | 'networkidle' | 'commit';
retries?: number;
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
async function withRetries<T>(
label: string,
retries: number,
log: Logger,
fn: () => Promise<T>,
): Promise<T> {
let last: unknown;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const started = Date.now();
const result = await fn();
log.debug(label, { attempt, ms: Date.now() - started });
return result;
} catch (err) {
last = err;
log.warn(`${label} failed`, {
attempt,
error: err instanceof Error ? err.message : String(err),
});
if (attempt < retries) await sleep(250 * (attempt + 1));
}
}
throw last;
}
export async function waitForVisible(
locator: Locator,
options?: { timeout?: number; logger?: Logger },
): Promise<void> {
const log = options?.logger ?? createLogger({ name: 'waitForVisible' });
log.debug('waitForVisible');
await locator.waitFor({ state: 'visible', timeout: options?.timeout ?? 30_000 });
}
export async function waitForHidden(
locator: Locator,
options?: { timeout?: number; logger?: Logger },
): Promise<void> {
const log = options?.logger ?? createLogger({ name: 'waitForHidden' });
log.debug('waitForHidden');
await locator.waitFor({ state: 'hidden', timeout: options?.timeout ?? 30_000 });
}
export async function click(
locator: Locator,
options?: ClickOptions & { logger?: Logger },
): Promise<void> {
const log = options?.logger ?? createLogger({ name: 'click' });
const retries = options?.retries ?? 2;
await withRetries('click', retries, log, async () => {
await locator.waitFor({ state: 'visible', timeout: options?.timeout ?? 30_000 });
await locator.click({
timeout: options?.timeout ?? 30_000,
force: options?.force,
trial: options?.trial,
});
});
}
export async function fill(
locator: Locator,
value: string,
options?: FillOptions & { logger?: Logger },
): Promise<void> {
const log = options?.logger ?? createLogger({ name: 'fill' });
const retries = options?.retries ?? 2;
await withRetries('fill', retries, log, async () => {
await locator.waitFor({ state: 'visible', timeout: options?.timeout ?? 30_000 });
if (options?.clear !== false) {
await locator.fill('');
}
await locator.fill(value, { timeout: options?.timeout ?? 30_000 });
});
}
export async function safeGoto(
page: Page,
url: string,
options?: GotoOptions & { logger?: Logger },
): Promise<void> {
const log = options?.logger ?? createLogger({ name: 'safeGoto' });
const retries = options?.retries ?? 2;
await withRetries(`goto ${url}`, retries, log, async () => {
await page.goto(url, {
timeout: options?.timeout ?? 60_000,
waitUntil: options?.waitUntil ?? 'domcontentloaded',
});
});
}
/**
* Assert the page URL host matches the public expected host.
* Catches Auth.js / NEXTAUTH_URL misconfig that redirects to LAN IPs (punimtag #57).
*/
export async function waitForUrlHost(
page: Page,
expectedHost: string,
options?: { timeout?: number; logger?: Logger },
): Promise<void> {
const log = options?.logger ?? createLogger({ name: 'waitForUrlHost' });
const timeout = options?.timeout ?? 30_000;
const started = Date.now();
while (Date.now() - started < timeout) {
const host = new URL(page.url()).hostname;
if (host === expectedHost) {
log.info('url host ok', { host });
return;
}
await sleep(100);
}
const actual = new URL(page.url()).hostname;
throw new Error(
`Expected URL host "${expectedHost}" but got "${actual}" (${page.url()}). ` +
`This often means NEXTAUTH_URL / AUTH_URL points at a LAN address.`,
);
}
export function assertPublicHost(urlOrHost: string, forbidPrivate = true): void {
let host = urlOrHost;
try {
host = new URL(urlOrHost).hostname;
} catch {
// already a hostname
}
if (forbidPrivate && isPrivateHost(host)) {
throw new Error(`Refusing private host "${host}" for public e2e`);
}
}
/**
* Thin Page Object base prefer getByRole / getByTestId in subclasses.
*/
export class BasePage {
protected readonly log: Logger;
constructor(
protected readonly page: Page,
protected readonly baseUrl: string,
logger?: Logger,
) {
this.log = logger ?? createLogger({ name: this.constructor.name });
}
async open(path = '/'): Promise<void> {
const url = path.startsWith('http') ? path : `${this.baseUrl.replace(/\/$/, '')}${path.startsWith('/') ? path : `/${path}`}`;
this.log.info('open', { url });
await safeGoto(this.page, url, { logger: this.log });
}
async click(locator: Locator, options?: ClickOptions): Promise<void> {
await click(locator, { ...options, logger: this.log });
}
async fill(locator: Locator, value: string, options?: FillOptions): Promise<void> {
await fill(locator, value, { ...options, logger: this.log });
}
async expectHost(expectedHost: string, timeout?: number): Promise<void> {
await waitForUrlHost(this.page, expectedHost, { timeout, logger: this.log });
}
async screenshot(name: string): Promise<Buffer> {
this.log.info('screenshot', { name });
return this.page.screenshot({ fullPage: true, type: 'png' });
}
}

13
src/browser/index.ts Normal file
View File

@ -0,0 +1,13 @@
export {
BasePage,
click,
fill,
safeGoto,
waitForVisible,
waitForHidden,
waitForUrlHost,
assertPublicHost,
type ClickOptions,
type FillOptions,
type GotoOptions,
} from './actions.js';

View File

@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { isPrivateHost, loadConfig } from './loadConfig.js';
import { redactSecrets } from '../logging/redact.js';
import { TimingCollector } from '../metrics/index.js';
describe('isPrivateHost', () => {
it('detects LAN and localhost', () => {
expect(isPrivateHost('10.0.10.121')).toBe(true);
expect(isPrivateHost('192.168.1.1')).toBe(true);
expect(isPrivateHost('172.16.0.2')).toBe(true);
expect(isPrivateHost('localhost')).toBe(true);
expect(isPrivateHost('punimtagdev.levkin.ca')).toBe(false);
});
});
describe('loadConfig', () => {
it('requires base url', () => {
expect(() => loadConfig({})).toThrow(/PLAYKIT_BASE_URL/);
});
it('rejects private expected host by default', () => {
expect(() =>
loadConfig({
PLAYKIT_BASE_URL: 'http://10.0.10.121:3001',
}),
).toThrow(/private/);
});
it('accepts public https host', () => {
const cfg = loadConfig({
PLAYKIT_BASE_URL: 'https://punimtagdev.levkin.ca',
PLAYKIT_PROJECT: 'punimtag',
});
expect(cfg.expectedHost).toBe('punimtagdev.levkin.ca');
expect(cfg.project).toBe('punimtag');
});
});
describe('redactSecrets', () => {
it('redacts password and authorization', () => {
const out = redactSecrets({
password: '123456',
Authorization: 'Bearer abc.def',
email: 'a@b.c',
});
expect(out.password).toBe('[REDACTED]');
expect(out.Authorization).toBe('[REDACTED]');
expect(out.email).toBe('a@b.c');
});
});
describe('TimingCollector', () => {
it('records timings and renders prometheus text', async () => {
const t = new TimingCollector();
await t.measure('goto', async () => 1);
const text = t.toPrometheusText({ project: 'demo' });
expect(text).toContain('playkit_action_duration_ms');
expect(text).toContain('project="demo"');
expect(text).toContain('action="goto"');
});
});

81
src/config/loadConfig.ts Normal file
View File

@ -0,0 +1,81 @@
export interface PlaykitConfig {
/** Public UI base URL (must be what users open in the browser). */
baseUrl: string;
/** API base URL (defaults to baseUrl). */
apiBaseUrl: string;
/** Expected hostname — sign-out / redirects must stay on this host. */
expectedHost: string;
/** Reject private LAN hosts (10/8, 172.16/12, 192.168/16, localhost). */
forbidPrivateHosts: boolean;
project: string;
env: string;
defaultTimeoutMs: number;
actionRetries: number;
metrics: {
enabled: boolean;
pushgatewayUrl?: string;
job: string;
};
}
function parseBool(v: string | undefined, fallback: boolean): boolean {
if (v == null || v === '') return fallback;
return ['1', 'true', 'yes', 'on'].includes(v.toLowerCase());
}
function hostFromUrl(url: string): string {
try {
return new URL(url).hostname;
} catch {
throw new Error(`Invalid URL in playkit config: ${url}`);
}
}
export function isPrivateHost(hostname: string): boolean {
const h = hostname.toLowerCase();
if (h === 'localhost' || h === '127.0.0.1' || h === '::1') return true;
if (/^10\.\d+\.\d+\.\d+$/.test(h)) return true;
if (/^192\.168\.\d+\.\d+$/.test(h)) return true;
if (/^172\.(1[6-9]|2\d|3[0-1])\.\d+\.\d+$/.test(h)) return true;
return false;
}
/**
* Load kit config from environment (CI injects from Infisical / Actions secrets).
*
* Required: PLAYKIT_BASE_URL (or BASE_URL)
*/
export function loadConfig(env: NodeJS.ProcessEnv = process.env): PlaykitConfig {
const baseUrl = (env.PLAYKIT_BASE_URL || env.BASE_URL || '').replace(/\/$/, '');
if (!baseUrl) {
throw new Error(
'playkit: set PLAYKIT_BASE_URL (or BASE_URL) to the public app URL, e.g. https://punimtagdev.levkin.ca',
);
}
const apiBaseUrl = (env.PLAYKIT_API_BASE_URL || env.API_BASE_URL || baseUrl).replace(/\/$/, '');
const expectedHost = env.PLAYKIT_EXPECTED_HOST || hostFromUrl(baseUrl);
const forbidPrivateHosts = parseBool(env.PLAYKIT_FORBID_PRIVATE_HOSTS, true);
if (forbidPrivateHosts && isPrivateHost(expectedHost)) {
throw new Error(
`playkit: expected host "${expectedHost}" looks private. Public e2e must use a public hostname (Kolby #57 class bug). Set PLAYKIT_FORBID_PRIVATE_HOSTS=false only for intentional LAN runs.`,
);
}
return {
baseUrl,
apiBaseUrl,
expectedHost,
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),
metrics: {
enabled: parseBool(env.PLAYKIT_METRICS_ENABLED, false),
pushgatewayUrl: env.PLAYKIT_PUSHGATEWAY_URL || env.PUSHGATEWAY_URL,
job: env.PLAYKIT_METRICS_JOB || 'playkit',
},
};
}

38
src/fixtures/index.ts Normal file
View File

@ -0,0 +1,38 @@
import { loadConfig, type PlaykitConfig } from '../config/loadConfig.js';
import { createLogger, type Logger } from '../logging/logger.js';
import { ApiClient } from '../api/client.js';
import { TimingCollector } from '../metrics/index.js';
export interface PlaykitFixtures {
playkitConfig: PlaykitConfig;
playkitLog: Logger;
api: ApiClient;
timings: TimingCollector;
}
/**
* Build the standard playkit objects for a test run.
* Prefer this over inventing per-file wiring.
*
* For Playwright `test.extend`, see docs/CONSUMER.md consumers usually
* wrap these in their own fixtures file.
*/
export function createPlaykitRuntime(env: NodeJS.ProcessEnv = process.env): PlaykitFixtures {
const playkitConfig = loadConfig(env);
const playkitLog = createLogger({
name: 'e2e',
bindings: { project: playkitConfig.project, env: playkitConfig.env },
});
const api = new ApiClient({
baseUrl: playkitConfig.apiBaseUrl,
timeoutMs: playkitConfig.defaultTimeoutMs,
logger: playkitLog.child({ component: 'api' }),
});
const timings = new TimingCollector(playkitLog.child({ component: 'timings' }));
return { playkitConfig, playkitLog, api, timings };
}
/** @deprecated alias — use createPlaykitRuntime */
export function createPlaykitFixtures(): PlaykitFixtures {
return createPlaykitRuntime();
}

45
src/index.ts Normal file
View File

@ -0,0 +1,45 @@
/**
* @levkin/playkit shared Playwright + API test kit
*/
export {
loadConfig,
isPrivateHost,
type PlaykitConfig,
} from './config/loadConfig.js';
export { createLogger, type Logger, type LogLevel } from './logging/logger.js';
export { redactSecrets } from './logging/redact.js';
export {
BasePage,
click,
fill,
safeGoto,
waitForVisible,
waitForHidden,
waitForUrlHost,
assertPublicHost,
type ClickOptions,
type FillOptions,
type GotoOptions,
} from './browser/index.js';
export {
ApiClient,
type ApiClientOptions,
type ApiRequestOptions,
type ApiResponse,
} from './api/index.js';
export {
TimingCollector,
pushPrometheusMetrics,
type TimingSample,
type MetricsPushOptions,
} from './metrics/index.js';
export {
createPlaykitRuntime,
createPlaykitFixtures,
type PlaykitFixtures,
} from './fixtures/index.js';

62
src/logging/logger.ts Normal file
View File

@ -0,0 +1,62 @@
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export interface Logger {
debug: (msg: string, meta?: Record<string, unknown>) => void;
info: (msg: string, meta?: Record<string, unknown>) => void;
warn: (msg: string, meta?: Record<string, unknown>) => void;
error: (msg: string, meta?: Record<string, unknown>) => void;
child: (bindings: Record<string, unknown>) => Logger;
}
const LEVEL_ORDER: Record<LogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40,
};
export function createLogger(options?: {
level?: LogLevel;
name?: string;
bindings?: Record<string, unknown>;
}): Logger {
const min = LEVEL_ORDER[options?.level ?? (process.env.PLAYKIT_LOG_LEVEL as LogLevel) ?? 'info'];
const base = {
kit: 'playkit',
name: options?.name ?? 'playkit',
...(options?.bindings ?? {}),
};
const write = (level: LogLevel, msg: string, meta?: Record<string, unknown>) => {
if (LEVEL_ORDER[level] < min) return;
const line = JSON.stringify({
ts: new Date().toISOString(),
level,
msg,
...base,
...(meta ?? {}),
});
if (level === 'error') {
console.error(line);
} else if (level === 'warn') {
console.warn(line);
} else {
console.log(line);
}
};
const logger: Logger = {
debug: (msg, meta) => write('debug', msg, meta),
info: (msg, meta) => write('info', msg, meta),
warn: (msg, meta) => write('warn', msg, meta),
error: (msg, meta) => write('error', msg, meta),
child: (bindings) =>
createLogger({
level: options?.level,
name: options?.name,
bindings: { ...base, ...bindings },
}),
};
return logger;
}

33
src/logging/redact.ts Normal file
View File

@ -0,0 +1,33 @@
const SENSITIVE_KEY =
/(pass(word)?|secret|token|authorization|api[_-]?key|cookie|set-cookie)/i;
/**
* Deep-redact common secret fields for safe logs.
*/
export function redactSecrets<T>(value: T, depth = 0): T {
if (depth > 8) return value;
if (value == null) return value;
if (Array.isArray(value)) {
return value.map((v) => redactSecrets(v, depth + 1)) as T;
}
if (typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (SENSITIVE_KEY.test(k)) {
out[k] = typeof v === 'string' && v.length > 0 ? '[REDACTED]' : v;
} else {
out[k] = redactSecrets(v, depth + 1);
}
}
return out as T;
}
if (typeof value === 'string') {
// Bearer tokens in free text
return value.replace(/(Bearer\s+)[A-Za-z0-9._\-+=/]+/gi, '$1[REDACTED]') as T;
}
return value;
}

109
src/metrics/index.ts Normal file
View File

@ -0,0 +1,109 @@
import { createLogger, type Logger } from '../logging/logger.js';
export interface TimingSample {
name: string;
durationMs: number;
ok: boolean;
labels?: Record<string, string>;
}
/**
* Collect action/navigation timings for Prometheus histograms / summaries.
*/
export class TimingCollector {
private readonly samples: TimingSample[] = [];
private readonly log: Logger;
constructor(logger?: Logger) {
this.log = logger ?? createLogger({ name: 'TimingCollector' });
}
async measure<T>(name: string, fn: () => Promise<T>, labels?: Record<string, string>): Promise<T> {
const started = Date.now();
let ok = true;
try {
return await fn();
} catch (err) {
ok = false;
throw err;
} finally {
const durationMs = Date.now() - started;
this.samples.push({ name, durationMs, ok, labels });
this.log.info('timing', { name, durationMs, ok, ...labels });
}
}
getSamples(): TimingSample[] {
return [...this.samples];
}
clear(): void {
this.samples.length = 0;
}
/**
* Render Prometheus text exposition (suitable for Pushgateway POST body).
*/
toPrometheusText(extraLabels: Record<string, string> = {}): string {
const lines: string[] = [
'# HELP playkit_action_duration_ms Duration of playkit-measured actions in milliseconds',
'# TYPE playkit_action_duration_ms gauge',
'# HELP playkit_action_ok 1 if last sample for this action succeeded',
'# TYPE playkit_action_ok gauge',
];
for (const s of this.samples) {
const labels = { ...extraLabels, ...(s.labels ?? {}), action: s.name };
const labelStr = Object.entries(labels)
.map(([k, v]) => `${k}="${escapeLabel(String(v))}"`)
.join(',');
lines.push(`playkit_action_duration_ms{${labelStr}} ${s.durationMs}`);
lines.push(`playkit_action_ok{${labelStr}} ${s.ok ? 1 : 0}`);
}
return lines.join('\n') + '\n';
}
}
function escapeLabel(v: string): string {
return v.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/"/g, '\\"');
}
export interface MetricsPushOptions {
pushgatewayUrl: string;
job: string;
grouping?: Record<string, string>;
logger?: Logger;
}
/**
* Push metrics to a Prometheus Pushgateway.
* Example URL: http://10.0.10.24:9091
*/
export async function pushPrometheusMetrics(
collector: TimingCollector,
options: MetricsPushOptions,
): Promise<void> {
const log = options.logger ?? createLogger({ name: 'metrics' });
const base = options.pushgatewayUrl.replace(/\/$/, '');
const grouping = options.grouping ?? {};
const pathParts = [`job/${encodeURIComponent(options.job)}`];
for (const [k, v] of Object.entries(grouping)) {
pathParts.push(`${encodeURIComponent(k)}/${encodeURIComponent(v)}`);
}
const url = `${base}/metrics/${pathParts.join('/')}`;
const body = collector.toPrometheusText(grouping);
log.info('pushing metrics', { url, bytes: body.length, samples: collector.getSamples().length });
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'text/plain; version=0.0.4' },
body,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Pushgateway returned ${res.status}: ${text.slice(0, 300)}`);
}
}

21
tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "examples", "**/*.test.ts"]
}

16
tsup.config.ts Normal file
View File

@ -0,0 +1,16 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: {
index: 'src/index.ts',
'browser/index': 'src/browser/index.ts',
'api/index': 'src/api/index.ts',
},
format: ['esm', 'cjs'],
dts: true,
sourcemap: true,
clean: true,
splitting: false,
treeshake: true,
external: ['@playwright/test'],
});

8
vitest.config.ts Normal file
View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
environment: 'node',
},
});