Add @levkin/playkit v0.1 shared Playwright/API kit
Ship browser helpers with public-host guards, ApiClient, structured logging, Prometheus timings, CI, examples, and ROADMAP for consumers.
This commit is contained in:
parent
6d13661660
commit
af12a92eb7
9
.env.example
Normal file
9
.env.example
Normal 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
58
.gitea/workflows/ci.yml
Normal 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
15
.gitignore
vendored
Normal 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
16
.gitleaks.toml
Normal 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
12
CHANGELOG.md
Normal 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
21
LICENSE
Normal 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.
|
||||
94
README.md
94
README.md
@ -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.
|
||||
|
||||
## 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 |
|
||||
|
||||
## 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
83
ROADMAP.md
Normal 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
|
||||
71
dashboards/playkit-overview.json
Normal file
71
dashboards/playkit-overview.json
Normal 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
56
docs/CONSUMER.md
Normal 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 human’s 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.
|
||||
35
examples/api/health.example.ts
Normal file
35
examples/api/health.example.ts
Normal 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);
|
||||
});
|
||||
20
examples/ui/public-host.example.spec.ts
Normal file
20
examples/ui/public-host.example.spec.ts
Normal 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
2673
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
75
package.json
Normal file
75
package.json
Normal 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
172
src/api/client.ts
Normal 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
1
src/api/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { ApiClient, type ApiClientOptions, type ApiRequestOptions, type ApiResponse } from './client.js';
|
||||
193
src/browser/actions.ts
Normal file
193
src/browser/actions.ts
Normal 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
13
src/browser/index.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export {
|
||||
BasePage,
|
||||
click,
|
||||
fill,
|
||||
safeGoto,
|
||||
waitForVisible,
|
||||
waitForHidden,
|
||||
waitForUrlHost,
|
||||
assertPublicHost,
|
||||
type ClickOptions,
|
||||
type FillOptions,
|
||||
type GotoOptions,
|
||||
} from './actions.js';
|
||||
61
src/config/loadConfig.test.ts
Normal file
61
src/config/loadConfig.test.ts
Normal 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
81
src/config/loadConfig.ts
Normal 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
38
src/fixtures/index.ts
Normal 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
45
src/index.ts
Normal 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
62
src/logging/logger.ts
Normal 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
33
src/logging/redact.ts
Normal 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
109
src/metrics/index.ts
Normal 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
21
tsconfig.json
Normal 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
16
tsup.config.ts
Normal 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
8
vitest.config.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user