From 6d88818fae5a1292321aa8576746fdd569563d9a Mon Sep 17 00:00:00 2001 From: ilia Date: Wed, 15 Jul 2026 09:06:23 -0400 Subject: [PATCH] Release 0.4.0: npm registry publish, playkit CLI, retry presets Add Gitea npm publish on tag release, `playkit init`/`smoke` CLI, and PLAYKIT_RETRY_PRESET profiles. Document install + consumer scaffold. --- .gitea/workflows/ci.yml | 13 ++ CHANGELOG.md | 19 +-- README.md | 16 ++- ROADMAP.md | 51 +++---- docs/CONSUMER.md | 47 +++++-- docs/NPM_REGISTRY.md | 42 ++++++ package.json | 14 +- src/cli.ts | 229 ++++++++++++++++++++++++++++++++ src/config/loadConfig.test.ts | 18 +++ src/config/loadConfig.ts | 17 ++- src/config/retryPresets.test.ts | 20 +++ src/config/retryPresets.ts | 49 +++++++ src/index.ts | 6 + tsup.config.ts | 34 +++-- 14 files changed, 502 insertions(+), 73 deletions(-) create mode 100644 docs/NPM_REGISTRY.md create mode 100644 src/cli.ts create mode 100644 src/config/retryPresets.test.ts create mode 100644 src/config/retryPresets.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index cab844f..7d1df83 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d0b4a7..13d79cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 7d8cf6f..396d006 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,14 @@ Use it as a library from any consumer repo (punimtag, MirrorMatch, …). App-spe ## Install (consumer) ```bash -# git tag dependency (until a private npm registry is wired) -npm install git+https://git.levkin.ca/ilia/playkit.git#v0.3.1 +# Preferred — Gitea npm registry (docs/NPM_REGISTRY.md) +npm install @levkin/playkit@0.4.0 + +# Fallback — git tag +npm install git+https://git.levkin.ca/ilia/playkit.git#v0.4.0 + +# Scaffold e2e/ +npx @levkin/playkit init # peer npm install -D @playwright/test @@ -54,6 +60,8 @@ test('sign-out stays on public host', async ({ page, playkitConfig, timings }) = | `PLAYKIT_API_BASE_URL` | no | Defaults to base URL | | `PLAYKIT_EXPECTED_HOST` | no | Defaults to host of base URL | | `PLAYKIT_FORBID_PRIVATE_HOSTS` | no | Default `true` — refuse `10.x` / localhost as expected host | +| `PLAYKIT_RETRY_PRESET` | no | `default` \| `strictCi` \| `flakyNetwork` (timeouts/retries) | +| `PLAYKIT_ACTION_RETRIES` / `PLAYKIT_TIMEOUT_MS` | no | Override preset values | | `PLAYKIT_PROJECT` | no | Metric / log label | | `PLAYKIT_ENV` | no | Metric / log label (`dev`/`qa`/`prod`) | | `PLAYKIT_METRICS_ENABLED` | no | Push timings to Pushgateway | @@ -77,9 +85,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) diff --git a/ROADMAP.md b/ROADMAP.md index 958c1b7..8574f24 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/docs/CONSUMER.md b/docs/CONSUMER.md index 1e84250..67a4d5c 100644 --- a/docs/CONSUMER.md +++ b/docs/CONSUMER.md @@ -2,12 +2,29 @@ ## 1. Depend on a release +**Preferred (Gitea npm registry):** + ```bash -npm install git+https://git.levkin.ca/ilia/playkit.git#v0.3.1 +# .npmrc — see docs/NPM_REGISTRY.md +# @levkin:registry=https://git.levkin.ca/api/packages/ilia/npm/ +npm install @levkin/playkit@0.4.0 npm install -D @playwright/test npx playwright install chromium ``` +**Fallback (git pin):** + +```bash +npm install git+https://git.levkin.ca/ilia/playkit.git#v0.4.0 +``` + +**Scaffold:** + +```bash +npx --yes @levkin/playkit init +# or: playkit init +``` + ## 2. Layout ``` @@ -25,12 +42,22 @@ Store in Infisical `LevkinOps` / `Development` (path e.g. `/playkit/punimtag`): - `PLAYKIT_BASE_URL=https://punimtagdev.levkin.ca` - `E2E_ADMIN_EMAIL` / `E2E_ADMIN_PASSWORD` (dedicated test user — not a human’s password) -- optional `PLAYKIT_PUSHGATEWAY_URL=http://10.0.10.24:9091` (Pushgateway on the observability LXC — config lives in ansible `deploy/observability/`; scrape job + `live-playkit` Grafana board are wired, but confirm `make deploy-observability` has actually been run before turning `PLAYKIT_METRICS_ENABLED=true` on in CI) -- for mail specs: `PLAYKIT_MAIL_PROVIDER=mailpit` (default) + `MAILPIT_BASE_URL` / `MAILPIT_USER` / `MAILPIT_PASSWORD`, or `MAILTRAP_*` for SaaS +- optional `PLAYKIT_PUSHGATEWAY_URL=http://10.0.10.24:9091` +- optional `PLAYKIT_RETRY_PRESET=strictCi|flakyNetwork|default` +- for mail specs: `PLAYKIT_MAIL_PROVIDER=mailpit` (default) + `MAILPIT_*`, or `MAILTRAP_*` Sync into Gitea Actions secrets for the consumer repo. -## 4. CI job sketch +## 4. Post-deploy smoke + +```bash +PLAYKIT_BASE_URL=https://punimtagdev.levkin.ca playkit smoke +# or: playkit smoke --path /api/health +``` + +## 5. CI job sketch + +See `e2e/ci-snippet.yml` from `playkit init`, or: ```yaml e2e: @@ -42,19 +69,15 @@ e2e: - run: npx playwright test env: PLAYKIT_BASE_URL: ${{ secrets.PLAYKIT_BASE_URL }} + PLAYKIT_RETRY_PRESET: strictCi E2E_ADMIN_EMAIL: ${{ secrets.E2E_ADMIN_EMAIL }} E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }} - - uses: actions/upload-artifact@v4 - if: failure() - with: - name: playwright-report - path: playwright-report/ ``` -## 5. Deploy rule +## 6. Deploy rule PR → CI green (unit + e2e when secrets present) → merge → documented deploy script. Do not claim “fixed” from a bare `pct exec` hotfix without a follow-up PR. -**Adoption pause:** do not add playkit to other app repos until punimtag + kit CI -have soaked for a few days. See `docs/IDEAS.md`. +**Adoption pause:** soak punimtag + kit CI a few more days before migrating +`screening` / `slack-sieve` / `portfolio`. See `docs/IDEAS.md`. diff --git a/docs/NPM_REGISTRY.md b/docs/NPM_REGISTRY.md new file mode 100644 index 0000000..eb3a01a --- /dev/null +++ b/docs/NPM_REGISTRY.md @@ -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/ +``` diff --git a/package.json b/package.json index c02dc01..2f24575 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,15 @@ { "name": "@levkin/playkit", - "version": "0.3.1", + "version": "0.4.0", "description": "Shared Playwright + API test kit — browser helpers, API client, logging, performance, Grafana/Prometheus metrics", "license": "MIT", "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", + "bin": { + "playkit": "./dist/cli.js" + }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -33,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" diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..24694f2 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,229 @@ +/** + * playkit CLI — `npx @levkin/playkit ` / `playkit ` + * + * 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 { + 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({ + 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; + devDependencies?: Record; + }; + 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 { + 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 { + 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); +}); diff --git a/src/config/loadConfig.test.ts b/src/config/loadConfig.test.ts index 28d6f84..13ada7b 100644 --- a/src/config/loadConfig.test.ts +++ b/src/config/loadConfig.test.ts @@ -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); }); }); diff --git a/src/config/loadConfig.ts b/src/config/loadConfig.ts index 72f956c..67f87ae 100644 --- a/src/config/loadConfig.ts +++ b/src/config/loadConfig.ts @@ -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, diff --git a/src/config/retryPresets.test.ts b/src/config/retryPresets.test.ts new file mode 100644 index 0000000..47a5a54 --- /dev/null +++ b/src/config/retryPresets.test.ts @@ -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/); + }); +}); diff --git a/src/config/retryPresets.ts b/src/config/retryPresets.ts new file mode 100644 index 0000000..123e408 --- /dev/null +++ b/src/config/retryPresets.ts @@ -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 = { + 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]; +} diff --git a/src/index.ts b/src/index.ts index 4786a11..7d43680 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,12 @@ export { isPrivateHost, type PlaykitConfig, } from './config/loadConfig.js'; +export { + RETRY_PRESETS, + resolveRetryPreset, + type RetryPreset, + type RetryPresetName, +} from './config/retryPresets.js'; export { createLogger, type Logger, type LogLevel } from './logging/logger.js'; export { redactSecrets } from './logging/redact.js'; diff --git a/tsup.config.ts b/tsup.config.ts index 1175073..0bb5e66 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -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' }, + }, +]); -- 2.49.1