Release 0.4.0: npm registry publish, playkit CLI, retry presets
All checks were successful
CI / skip-ci-check (pull_request) Successful in 5s
CI / release (pull_request) Has been skipped
CI / build-and-test (pull_request) Successful in 24s
CI / selftest (pull_request) Successful in 23s
CI / secret-scan (pull_request) Successful in 4s

Add Gitea npm publish on tag release, `playkit init`/`smoke` CLI,
and PLAYKIT_RETRY_PRESET profiles. Document install + consumer scaffold.
This commit is contained in:
ilia 2026-07-15 09:06:23 -04:00
parent d53d1ad2c7
commit 6d88818fae
14 changed files with 502 additions and 73 deletions

View File

@ -154,3 +154,16 @@ jobs:
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \
-F "attachment=@${TARBALL}" \
"https://git.levkin.ca/api/v1/repos/ilia/playkit/releases/${RELEASE_ID}/assets"
- name: Publish to Gitea npm registry
# RELEASE_TOKEN (or optional NPM_PUBLISH_TOKEN) needs write:package — docs/NPM_REGISTRY.md
run: |
TOKEN="${{ secrets.NPM_PUBLISH_TOKEN }}"
if [ -z "$TOKEN" ]; then TOKEN="${{ secrets.RELEASE_TOKEN }}"; fi
if [ -z "$TOKEN" ]; then
echo "::error::set RELEASE_TOKEN or NPM_PUBLISH_TOKEN with write:package"
exit 1
fi
echo "@levkin:registry=https://git.levkin.ca/api/packages/ilia/npm/" > /tmp/playkit.npmrc
echo "//git.levkin.ca/api/packages/ilia/npm/:_authToken=${TOKEN}" >> /tmp/playkit.npmrc
npm publish --userconfig /tmp/playkit.npmrc --access restricted
rm -f /tmp/playkit.npmrc

View File

@ -1,10 +1,11 @@
# Changelog
## Unreleased
## 0.4.0 — 2026-07-15
- **Self-test suite**`selftest/` fake site + Playwright CI job (`docs/SELFTEST.md`, `npm run selftest`)
- **Outline sync**`scripts/outline-sync-playkit.py` creates/updates QA & Dev → Playkit (needs `documents.update` on the Outline API key)
- Ops soak: Pushgateway (`:9091`) + Grafana `live-playkit` board **applied** on observability LXC (`make deploy-observability`); Prometheus scrape job `pushgateway` healthy; `RELEASE_TOKEN` present and Gitea release `v0.3.1` exists
- **CLI**`playkit init` scaffolds `e2e/` + CI snippet; `playkit smoke` post-deploy public-host + health ping
- **Retry presets**`PLAYKIT_RETRY_PRESET=strictCi|flakyNetwork|default` wired through `loadConfig` (`RETRY_PRESETS` export)
- **Gitea npm registry**`publishConfig` + release-job `npm publish`; consumer docs in `docs/NPM_REGISTRY.md`
- Docs: CONSUMER/ROADMAP updated for v0.4; selftest + Outline sync remain as of 0.3.x line
## 0.3.1 — 2026-07-14
@ -14,7 +15,9 @@
- CI: add tag-triggered `release` job (`.gitea/workflows/ci.yml`) — re-runs build/test, verifies tag matches `package.json` version and `CHANGELOG.md` documents it, creates a Gitea release with an `npm pack` tarball attached. Requires a one-time `RELEASE_TOKEN` Actions secret.
- Docs: bump install pin examples from `v0.1.0` to `v0.3.0` (README, CONSUMER.md)
- Docs: lead with Mailpit (homelab default) instead of Mailtrap in README email section; use `createMailInbox()` + `readMailHtml()` in the example instead of a provider-specific client
- Ops: Pushgateway + `live-playkit` Grafana board now provisioned via ansible `deploy/observability/` (pending `make deploy-observability`); removed the standalone `dashboards/playkit-overview.json` (superseded) and the `dashboards` entry from `package.json` `files`
- Ops: Pushgateway + `live-playkit` Grafana board now provisioned via ansible `deploy/observability/`
- **Self-test suite**`selftest/` fake site + Playwright CI job (`docs/SELFTEST.md`)
- **Outline sync**`scripts/outline-sync-playkit.py`
## 0.3.0 — 2026-07-14
@ -42,7 +45,5 @@ Initial release.
- Browser: `BasePage`, `click`, `fill`, `safeGoto`, visibility waits, `waitForUrlHost`, `assertPublicHost`
- API: `ApiClient` with status expectations and secret redaction
- Logging: structured JSON logger
- Metrics: `TimingCollector` + Prometheus Pushgateway push
- Config: `loadConfig()` with private-host guard
- Docs: README, ROADMAP, Grafana dashboard stub, consumer examples
- Metrics: `TimingCollector` + Pushgateway helper
- Config/logging helpers + consumer docs

View File

@ -7,8 +7,14 @@ Use it as a library from any consumer repo (punimtag, MirrorMatch, …). App-spe
## Install (consumer)
```bash
# git tag dependency (until a private npm registry is wired)
npm install git+https://git.levkin.ca/ilia/playkit.git#v0.3.1
# Preferred — Gitea npm registry (docs/NPM_REGISTRY.md)
npm install @levkin/playkit@0.4.0
# Fallback — git tag
npm install git+https://git.levkin.ca/ilia/playkit.git#v0.4.0
# Scaffold e2e/
npx @levkin/playkit init
# peer
npm install -D @playwright/test
@ -54,6 +60,8 @@ test('sign-out stays on public host', async ({ page, playkitConfig, timings }) =
| `PLAYKIT_API_BASE_URL` | no | Defaults to base URL |
| `PLAYKIT_EXPECTED_HOST` | no | Defaults to host of base URL |
| `PLAYKIT_FORBID_PRIVATE_HOSTS` | no | Default `true` — refuse `10.x` / localhost as expected host |
| `PLAYKIT_RETRY_PRESET` | no | `default` \| `strictCi` \| `flakyNetwork` (timeouts/retries) |
| `PLAYKIT_ACTION_RETRIES` / `PLAYKIT_TIMEOUT_MS` | no | Override preset values |
| `PLAYKIT_PROJECT` | no | Metric / log label |
| `PLAYKIT_ENV` | no | Metric / log label (`dev`/`qa`/`prod`) |
| `PLAYKIT_METRICS_ENABLED` | no | Push timings to Pushgateway |
@ -77,9 +85,9 @@ test('sign-out stays on public host', async ({ page, playkitConfig, timings }) =
| `saveStorageState` / `storageStateUse` | Auth once, reuse across specs |
| `playkitFailureArtifacts` | Trace/video/screenshot only on failure |
| `interceptNetworkCall` | Spy or mock the next matching page network call |
| `startNetworkErrorMonitor` | Fail tests that silently collect HTTP 4xx/5xx |
| `playkit` CLI | `init` scaffold + `smoke` post-deploy gate |
See also `docs/NETWORK.md`, `docs/SELFTEST.md` (kit CI fake site), `docs/IDEAS.md` (OSS-borrowed backlog), `docs/OUTLINE.md` (live docs sync).
See also `docs/NETWORK.md`, `docs/SELFTEST.md`, `docs/NPM_REGISTRY.md`, `docs/IDEAS.md`, `docs/OUTLINE.md`.
## Network (page traffic)

View File

@ -2,38 +2,23 @@
Living plan for making `@levkin/playkit` more useful across Levkin repos.
## Now (v0.3.1) — shipped
## Now (v0.4.0) — this release
- [x] Browser helpers with retries (`click`, `fill`, `safeGoto`, visibility waits)
- [x] `BasePage` for Page Objects
- [x] Public-host guards (`assertPublicHost`, `waitForUrlHost`) — Kolby #57 class
- [x] `ApiClient` with redacted structured logging
- [x] `TimingCollector` + Prometheus Pushgateway export
- [x] Config from env (Infisical/CI-friendly)
- [x] `createPlaykitRuntime()` for one-shot wiring
- [x] Unit tests (vitest) + Gitea Actions CI
- [x] Starter Grafana dashboard JSON
- [x] Consumer docs + API/UI examples
- [x] **Mailtrap / Mailpit**`createMailInbox()`, `waitForEmail`, link extraction
- [x] **Auth storage state helpers**`saveStorageState` / `storageStateUse`
- [x] **Trace-on-failure preset**`playkitFailureArtifacts()`
- [x] **Schema assertions** — Zod `assertSchema` + `ApiClient` `schema` option
- [x] Everything from v0.3.1 (browser/API/mail/network/selftest/Outline/Pushgateway)
- [x] **Private Gitea npm registry publish** — release job `npm publish` to `git.levkin.ca` (`docs/NPM_REGISTRY.md`)
- [x] **Consumer template**`playkit init` / `npx @levkin/playkit init`
- [x] **Deploy-smoke CLI**`playkit smoke` (public-host + health GET)
- [x] **Retry policy presets**`PLAYKIT_RETRY_PRESET=strictCi|flakyNetwork|default`
## Next (v0.4) — high value
## Next after soak
- [ ] **Private Gitea npm registry publish**`npm install @levkin/playkit` without git URLs
- [ ] **Consumer template**`npx @levkin/playkit init` scaffolding `e2e/` + CI snippet
- [ ] **Deploy-smoke CLI**`playkit smoke --project punimtag` post-deploy gate
- [ ] **Retry policy presets** — flaky-network vs strict-CI profiles
- [x] **Self-test against a real fake site**`selftest/` tiny Node app + Playwright suite in CI (`docs/SELFTEST.md`)
- [x] **Network interception / network error monitor**`interceptNetworkCall()`, `startNetworkErrorMonitor()` / `assertNoErrors()` (see `docs/NETWORK.md`)
- [x] **Tag-triggered release workflow**`.gitea/workflows/ci.yml` `release` job now runs on `vX.Y.Z` tag push: re-verifies build/test, checks tag == `package.json` version, checks `CHANGELOG.md` has that version's section, then creates a Gitea release (npm-pack tarball attached) via the API. Needs a one-time `RELEASE_TOKEN` Actions secret on this repo (see README "Release"). Still open: `build-and-test`/`secret-scan` also re-run on the tag push (same `on.push` trigger) — harmless redundancy, not wired to skip.
- [x] **Live docs** — Outline page under **QA & Dev** (`notes.levkin.ca`). Sync with `python3 scripts/outline-sync-playkit.py` (checklist in `docs/OUTLINE.md`).
- [x] **Pushgateway + dashboard wired in ansible** — applied via `make deploy-observability` (LXC `10.0.10.24`): `pushgateway` container, Prometheus scrape job, Grafana `live-playkit` board. Flip consumer CI metrics with `PLAYKIT_METRICS_ENABLED=true` + `PLAYKIT_PUSHGATEWAY_URL=http://10.0.10.24:9091`.
- [ ] End adoption pause — migrate first extra consumer (`screening` candidate)
- [ ] Evaluate `playwright-exporter` for scheduled synthetics (may supersede bespoke cron wrappers around `playkit smoke`)
- [ ] Optional dedicated `NPM_PUBLISH_TOKEN` (least privilege vs reuse `RELEASE_TOKEN`)
## Adoption pause
**No new consumer repos until we soak.** Keep playing through punimtag e2e + kit CI for a few days (release job, Pushgateway/dashboard apply, network helpers) before migrating `screening` / `slack-sieve` / `portfolio`. See `docs/IDEAS.md`.
**No new consumer repos until soak finishes.** Keep punimtag e2e + kit CI + metrics path healthy for a few days. See `docs/IDEAS.md`.
## Later (v0.5+) — professional polish
@ -47,14 +32,14 @@ Living plan for making `@levkin/playkit` more useful across Levkin repos.
- [ ] **Infisical SDK helper**`loadSecretsFromInfisical()` for local runs (machine identity)
- [ ] **JUnit + HTML report merge** — single artifact for Gitea PR checks
- [ ] **Network assert helpers** — fail if request hits `10.x` / wrong host after navigation
- [ ] **Test burn-in (flake detection)** — rerun a spec N times
## Ideas pulled from similar OSS tools (2026-07 research)
See `docs/IDEAS.md` for expanded notes. Summary:
See `docs/IDEAS.md`. Summary:
- [x] **Network interception / network error monitor** — shipped (above)
- [ ] **Test burn-in (flake detection)** — rerun a spec N times; mechanism for flake quarantine
- [ ] **Scheduled synthetic monitoring** — evaluate `playwright-exporter` before bespoke smoke CLI
- [ ] **Functional-core-for-everything audit** — optional function wrappers alongside class APIs
- ~~Remote-write vs Pushgateway~~ — moot while Pushgateway is the path
- Confirmed sane (no action): OpenAPI / axe / visual reg already under v0.5+
- [x] Network interception / error monitor
- [ ] Test burn-in / flake quarantine
- [ ] Scheduled synthetic monitoring (`playwright-exporter`)
- [ ] Functional-core / fixture-shell audit
- ~~Remote-write vs Pushgateway~~ — moot

View File

@ -2,12 +2,29 @@
## 1. Depend on a release
**Preferred (Gitea npm registry):**
```bash
npm install git+https://git.levkin.ca/ilia/playkit.git#v0.3.1
# .npmrc — see docs/NPM_REGISTRY.md
# @levkin:registry=https://git.levkin.ca/api/packages/ilia/npm/
npm install @levkin/playkit@0.4.0
npm install -D @playwright/test
npx playwright install chromium
```
**Fallback (git pin):**
```bash
npm install git+https://git.levkin.ca/ilia/playkit.git#v0.4.0
```
**Scaffold:**
```bash
npx --yes @levkin/playkit init
# or: playkit init
```
## 2. Layout
```
@ -25,12 +42,22 @@ Store in Infisical `LevkinOps` / `Development` (path e.g. `/playkit/punimtag`):
- `PLAYKIT_BASE_URL=https://punimtagdev.levkin.ca`
- `E2E_ADMIN_EMAIL` / `E2E_ADMIN_PASSWORD` (dedicated test user — not a humans password)
- optional `PLAYKIT_PUSHGATEWAY_URL=http://10.0.10.24:9091` (Pushgateway on the observability LXC — config lives in ansible `deploy/observability/`; scrape job + `live-playkit` Grafana board are wired, but confirm `make deploy-observability` has actually been run before turning `PLAYKIT_METRICS_ENABLED=true` on in CI)
- for mail specs: `PLAYKIT_MAIL_PROVIDER=mailpit` (default) + `MAILPIT_BASE_URL` / `MAILPIT_USER` / `MAILPIT_PASSWORD`, or `MAILTRAP_*` for SaaS
- optional `PLAYKIT_PUSHGATEWAY_URL=http://10.0.10.24:9091`
- optional `PLAYKIT_RETRY_PRESET=strictCi|flakyNetwork|default`
- for mail specs: `PLAYKIT_MAIL_PROVIDER=mailpit` (default) + `MAILPIT_*`, or `MAILTRAP_*`
Sync into Gitea Actions secrets for the consumer repo.
## 4. CI job sketch
## 4. Post-deploy smoke
```bash
PLAYKIT_BASE_URL=https://punimtagdev.levkin.ca playkit smoke
# or: playkit smoke --path /api/health
```
## 5. CI job sketch
See `e2e/ci-snippet.yml` from `playkit init`, or:
```yaml
e2e:
@ -42,19 +69,15 @@ e2e:
- run: npx playwright test
env:
PLAYKIT_BASE_URL: ${{ secrets.PLAYKIT_BASE_URL }}
PLAYKIT_RETRY_PRESET: strictCi
E2E_ADMIN_EMAIL: ${{ secrets.E2E_ADMIN_EMAIL }}
E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }}
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
```
## 5. Deploy rule
## 6. Deploy rule
PR → CI green (unit + e2e when secrets present) → merge → documented deploy script.
Do not claim “fixed” from a bare `pct exec` hotfix without a follow-up PR.
**Adoption pause:** do not add playkit to other app repos until punimtag + kit CI
have soaked for a few days. See `docs/IDEAS.md`.
**Adoption pause:** soak punimtag + kit CI a few more days before migrating
`screening` / `slack-sieve` / `portfolio`. See `docs/IDEAS.md`.

42
docs/NPM_REGISTRY.md Normal file
View File

@ -0,0 +1,42 @@
# Gitea npm registry (@levkin scope)
Gitea Package Registry is enabled on `git.levkin.ca`. Playkit releases can be
installed without a git URL once published.
## Consumer install
`.npmrc` (or CI step):
```ini
@levkin:registry=https://git.levkin.ca/api/packages/ilia/npm/
//git.levkin.ca/api/packages/ilia/npm/:_authToken=${PLAYKIT_GIT_TOKEN}
```
```bash
npm i @levkin/playkit
# or pin: npm i @levkin/playkit@0.4.0
```
Git pin remains supported as a fallback:
```bash
npm i git+https://git.levkin.ca/ilia/playkit.git#v0.4.0
```
## Publish (release job)
On `vX.Y.Z` tag, `.gitea/workflows/ci.yml` `release` job:
1. Creates the Gitea Release + attaches `npm pack` tarball (existing)
2. Runs `npm publish` to `https://git.levkin.ca/api/packages/ilia/npm/`
using `RELEASE_TOKEN` (needs **`write:package`** in addition to release scopes)
One-time: edit the `RELEASE_TOKEN` personal access token / app token on Gitea
to include package write, or add a dedicated `NPM_PUBLISH_TOKEN` secret and
wire it in CI (preferred if you want least privilege).
## Verify
```bash
npm view @levkin/playkit versions --registry https://git.levkin.ca/api/packages/ilia/npm/
```

View File

@ -1,12 +1,15 @@
{
"name": "@levkin/playkit",
"version": "0.3.1",
"version": "0.4.0",
"description": "Shared Playwright + API test kit — browser helpers, API client, logging, performance, Grafana/Prometheus metrics",
"license": "MIT",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"playkit": "./dist/cli.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
@ -33,10 +36,12 @@
"dist",
"README.md",
"ROADMAP.md",
"LICENSE"
"LICENSE",
"docs/NPM_REGISTRY.md",
"docs/CONSUMER.md"
],
"scripts": {
"build": "tsup",
"build": "tsup && chmod +x dist/cli.js",
"prepare": "npm run build",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit",
@ -69,6 +74,9 @@
"engines": {
"node": ">=20"
},
"publishConfig": {
"registry": "https://git.levkin.ca/api/packages/ilia/npm/"
},
"repository": {
"type": "git",
"url": "https://git.levkin.ca/ilia/playkit.git"

229
src/cli.ts Normal file
View File

@ -0,0 +1,229 @@
/**
* playkit CLI `npx @levkin/playkit <command>` / `playkit <command>`
*
* init Scaffold e2e/ + CI snippet
* smoke Post-deploy public-host + health ping
*/
import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
const GITEA_NPM = 'https://git.levkin.ca/api/packages/ilia/npm/';
function usage(code = 0): never {
console.log(`Usage:
playkit init [dir] [--force]
playkit smoke [--path /api/health]
Env for smoke:
PLAYKIT_BASE_URL (required)
PLAYKIT_API_BASE_URL (optional)
PLAYKIT_SMOKE_PATH (default /api/health)
PLAYKIT_RETRY_PRESET (optional: default|strictCi|flakyNetwork)
`);
process.exit(code);
}
function writeIfMissing(path: string, contents: string, force: boolean): void {
if (existsSync(path) && !force) {
console.log(`skip (exists): ${path}`);
return;
}
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, contents, 'utf8');
console.log(`wrote: ${path}`);
}
async function cmdInit(args: string[]): Promise<void> {
const force = args.includes('--force');
const dirArg = args.find((a) => !a.startsWith('-'));
const root = resolve(dirArg || '.');
const e2e = join(root, 'e2e');
writeIfMissing(
join(e2e, 'playwright.config.ts'),
`import { defineConfig, devices } from '@playwright/test';
import { playkitFailureArtifacts } from '@levkin/playkit';
const baseURL = process.env.PLAYKIT_BASE_URL;
if (!baseURL) throw new Error('set PLAYKIT_BASE_URL');
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? 'list' : 'html',
use: {
baseURL,
...devices['Desktop Chrome'],
...playkitFailureArtifacts(),
},
});
`,
force,
);
writeIfMissing(
join(e2e, 'fixtures.ts'),
`import { test as base, expect } from '@playwright/test';
import { createPlaykitRuntime, type PlaykitFixtures } from '@levkin/playkit';
const runtime = createPlaykitRuntime();
export const test = base.extend<PlaykitFixtures>({
playkitConfig: async ({}, use) => use(runtime.playkitConfig),
playkitLog: async ({}, use) => use(runtime.playkitLog),
api: async ({}, use) => use(runtime.api),
timings: async ({}, use) => use(runtime.timings),
});
export { expect };
`,
force,
);
writeIfMissing(
join(e2e, 'tests', 'health.smoke.spec.ts'),
`import { test, expect } from '../fixtures';
import { assertPublicHost } from '@levkin/playkit';
test('public host + API health', async ({ playkitConfig, api }) => {
assertPublicHost(playkitConfig.baseUrl, playkitConfig.forbidPrivateHosts);
const res = await api.get('/api/health', { expectedStatus: 200 });
expect(res.ok).toBe(true);
});
`,
force,
);
writeIfMissing(
join(e2e, 'env-defaults.json'),
`${JSON.stringify(
{
DEFAULT_BASE_URL: 'https://example.levkin.ca',
DEFAULT_API_BASE_URL: 'https://example.levkin.ca',
},
null,
2,
)}\n`,
force,
);
writeIfMissing(
join(e2e, 'ci-snippet.yml'),
`# Drop into .gitea/workflows/ci.yml (adjust secrets / paths)
e2e:
runs-on: [homelab, self-hosted, linux]
defaults:
run:
working-directory: e2e
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: npm auth for @levkin
working-directory: .
run: |
echo "@levkin:registry=${GITEA_NPM}" >> ~/.npmrc
echo "//git.levkin.ca/api/packages/ilia/npm/:_authToken=\${{ secrets.PLAYKIT_GIT_TOKEN }}" >> ~/.npmrc
npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test
env:
PLAYKIT_BASE_URL: \${{ secrets.PLAYKIT_BASE_URL }}
PLAYKIT_RETRY_PRESET: strictCi
CI: 'true'
`.replaceAll('${GITEA_NPM}', GITEA_NPM),
force,
);
writeIfMissing(
join(root, '.npmrc.playkit.example'),
`@levkin:registry=${GITEA_NPM}
//git.levkin.ca/api/packages/ilia/npm/:_authToken=\${PLAYKIT_GIT_TOKEN}
`,
force,
);
const pkgPath = join(root, 'package.json');
if (existsSync(pkgPath)) {
try {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
const dep =
pkg.dependencies?.['@levkin/playkit'] ||
pkg.devDependencies?.['@levkin/playkit'];
if (!dep) {
console.log(
`\nNext: npm i @levkin/playkit --registry ${GITEA_NPM}\n` +
` (or copy .npmrc.playkit.example → .npmrc)\n` +
`Fallback: npm i git+https://git.levkin.ca/ilia/playkit.git#v0.4.0`,
);
}
} catch {
/* ignore */
}
}
console.log(`\nScaffolded under ${e2e}`);
}
async function cmdSmoke(args: string[]): Promise<void> {
const pathIdx = args.indexOf('--path');
const smokePath =
(pathIdx >= 0 ? args[pathIdx + 1] : undefined) ||
process.env.PLAYKIT_SMOKE_PATH ||
'/api/health';
const mod = await import('./index.js');
const config = mod.loadConfig();
mod.assertPublicHost(config.baseUrl, config.forbidPrivateHosts);
const api = new mod.ApiClient({ baseUrl: config.apiBaseUrl });
const timings = new mod.TimingCollector();
const res = await timings.measure('smoke', () =>
api.get(smokePath, { expectedStatus: 200 }),
);
console.log(
JSON.stringify(
{
ok: true,
project: config.project,
env: config.env,
host: config.expectedHost,
path: smokePath,
status: res.status,
durationMs: res.durationMs,
retryPreset: config.retryPreset,
},
null,
2,
),
);
if (config.metrics.enabled && config.metrics.pushgatewayUrl) {
await mod.pushPrometheusMetrics(timings, {
pushgatewayUrl: config.metrics.pushgatewayUrl,
job: config.metrics.job,
grouping: { project: config.project, env: config.env, suite: 'smoke' },
});
}
}
async function main(): Promise<void> {
const [, , cmd, ...rest] = process.argv;
if (!cmd || cmd === '-h' || cmd === '--help') usage(0);
if (cmd === 'init') return cmdInit(rest);
if (cmd === 'smoke') return cmdSmoke(rest);
console.error(`Unknown command: ${cmd}`);
usage(1);
}
main().catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
});

View File

@ -33,6 +33,24 @@ describe('loadConfig', () => {
});
expect(cfg.expectedHost).toBe('punimtagdev.levkin.ca');
expect(cfg.project).toBe('punimtag');
expect(cfg.retryPreset).toBe('default');
});
it('applies PLAYKIT_RETRY_PRESET and allows env overrides', () => {
const cfg = loadConfig({
PLAYKIT_BASE_URL: 'https://punimtagdev.levkin.ca',
PLAYKIT_RETRY_PRESET: 'strictCi',
});
expect(cfg.retryPreset).toBe('strictCi');
expect(cfg.actionRetries).toBe(0);
expect(cfg.defaultTimeoutMs).toBe(15_000);
const overriden = loadConfig({
PLAYKIT_BASE_URL: 'https://punimtagdev.levkin.ca',
PLAYKIT_RETRY_PRESET: 'strictCi',
PLAYKIT_ACTION_RETRIES: '9',
});
expect(overriden.actionRetries).toBe(9);
});
});

View File

@ -1,3 +1,5 @@
import { resolveRetryPreset, type RetryPresetName } from './retryPresets.js';
export interface PlaykitConfig {
/** Public UI base URL (must be what users open in the browser). */
baseUrl: string;
@ -11,6 +13,8 @@ export interface PlaykitConfig {
env: string;
defaultTimeoutMs: number;
actionRetries: number;
/** Active retry preset name (from PLAYKIT_RETRY_PRESET). */
retryPreset: RetryPresetName;
metrics: {
enabled: boolean;
pushgatewayUrl?: string;
@ -55,7 +59,15 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): PlaykitConfig
const apiBaseUrl = (env.PLAYKIT_API_BASE_URL || env.API_BASE_URL || baseUrl).replace(/\/$/, '');
const expectedHost = env.PLAYKIT_EXPECTED_HOST || hostFromUrl(baseUrl);
const preset = resolveRetryPreset(env.PLAYKIT_RETRY_PRESET);
// Explicit env overrides win over preset values.
const forbidPrivateHosts = parseBool(env.PLAYKIT_FORBID_PRIVATE_HOSTS, true);
const defaultTimeoutMs = Number(env.PLAYKIT_TIMEOUT_MS || preset.defaultTimeoutMs);
const actionRetries = Number(
env.PLAYKIT_ACTION_RETRIES !== undefined && env.PLAYKIT_ACTION_RETRIES !== ''
? env.PLAYKIT_ACTION_RETRIES
: preset.actionRetries,
);
if (forbidPrivateHosts && isPrivateHost(expectedHost)) {
throw new Error(
@ -70,8 +82,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): PlaykitConfig
forbidPrivateHosts,
project: env.PLAYKIT_PROJECT || env.CI_PROJECT || 'unknown',
env: env.PLAYKIT_ENV || env.APP_ENV || 'dev',
defaultTimeoutMs: Number(env.PLAYKIT_TIMEOUT_MS || 30_000),
actionRetries: Number(env.PLAYKIT_ACTION_RETRIES || 2),
defaultTimeoutMs,
actionRetries,
retryPreset: preset.name,
metrics: {
enabled: parseBool(env.PLAYKIT_METRICS_ENABLED, false),
pushgatewayUrl: env.PLAYKIT_PUSHGATEWAY_URL || env.PUSHGATEWAY_URL,

View File

@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { resolveRetryPreset, RETRY_PRESETS } from './retryPresets.js';
describe('retry presets', () => {
it('defaults when unset', () => {
expect(resolveRetryPreset(undefined).name).toBe('default');
expect(resolveRetryPreset('').name).toBe('default');
});
it('resolves known presets', () => {
expect(resolveRetryPreset('strictCi').actionRetries).toBe(0);
expect(resolveRetryPreset('flakyNetwork').actionRetries).toBeGreaterThan(
RETRY_PRESETS.default.actionRetries,
);
});
it('rejects unknown names', () => {
expect(() => resolveRetryPreset('nope')).toThrow(/Unknown PLAYKIT_RETRY_PRESET/);
});
});

View File

@ -0,0 +1,49 @@
/**
* Named retry / timeout profiles for consumers and `loadConfig`.
*
* Use via `PLAYKIT_RETRY_PRESET=strictCi|flakyNetwork` or
* `applyRetryPreset(config, 'flakyNetwork')`.
*/
export type RetryPresetName = 'strictCi' | 'flakyNetwork' | 'default';
export interface RetryPreset {
name: RetryPresetName;
/** Passed to click/fill/safeGoto retries (0 = fail fast). */
actionRetries: number;
defaultTimeoutMs: number;
description: string;
}
export const RETRY_PRESETS: Record<RetryPresetName, RetryPreset> = {
default: {
name: 'default',
actionRetries: 2,
defaultTimeoutMs: 30_000,
description: 'Balanced defaults (dev + typical CI)',
},
strictCi: {
name: 'strictCi',
actionRetries: 0,
defaultTimeoutMs: 15_000,
description: 'Fail fast — expose flakes instead of masking them',
},
flakyNetwork: {
name: 'flakyNetwork',
actionRetries: 4,
defaultTimeoutMs: 45_000,
description: 'Extra retries/timeouts for congested or remote CI',
},
};
export function resolveRetryPreset(
name: string | undefined,
): RetryPreset {
if (!name) return RETRY_PRESETS.default;
const key = name as RetryPresetName;
if (!(key in RETRY_PRESETS)) {
throw new Error(
`Unknown PLAYKIT_RETRY_PRESET "${name}". Use: ${Object.keys(RETRY_PRESETS).join(', ')}`,
);
}
return RETRY_PRESETS[key];
}

View File

@ -7,6 +7,12 @@ export {
isPrivateHost,
type PlaykitConfig,
} from './config/loadConfig.js';
export {
RETRY_PRESETS,
resolveRetryPreset,
type RetryPreset,
type RetryPresetName,
} from './config/retryPresets.js';
export { createLogger, type Logger, type LogLevel } from './logging/logger.js';
export { redactSecrets } from './logging/redact.js';

View File

@ -1,17 +1,31 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: {
index: 'src/index.ts',
'browser/index': 'src/browser/index.ts',
'api/index': 'src/api/index.ts',
'mail/index': 'src/mail/index.ts',
},
format: ['esm', 'cjs'],
const shared = {
dts: true,
sourcemap: true,
clean: true,
splitting: false,
treeshake: true,
external: ['@playwright/test'],
});
} as const;
export default defineConfig([
{
...shared,
entry: {
index: 'src/index.ts',
'browser/index': 'src/browser/index.ts',
'api/index': 'src/api/index.ts',
'mail/index': 'src/mail/index.ts',
},
format: ['esm', 'cjs'],
clean: true,
},
{
...shared,
entry: { cli: 'src/cli.ts' },
format: ['esm'],
dts: false,
clean: false,
banner: { js: '#!/usr/bin/env node' },
},
]);