Merge pull request 'feat: playkit v0.4 — npm registry, CLI, retry presets' (#4) from feat/v0.4-kit-polish into main
This commit is contained in:
commit
a086ca4bf4
@ -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
|
||||
|
||||
19
CHANGELOG.md
19
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
|
||||
|
||||
16
README.md
16
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)
|
||||
|
||||
|
||||
51
ROADMAP.md
51
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
|
||||
|
||||
@ -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`.
|
||||
|
||||
42
docs/NPM_REGISTRY.md
Normal file
42
docs/NPM_REGISTRY.md
Normal 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/
|
||||
```
|
||||
14
package.json
14
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"
|
||||
|
||||
229
src/cli.ts
Normal file
229
src/cli.ts
Normal 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);
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
20
src/config/retryPresets.test.ts
Normal file
20
src/config/retryPresets.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
49
src/config/retryPresets.ts
Normal file
49
src/config/retryPresets.ts
Normal 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];
|
||||
}
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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' },
|
||||
},
|
||||
]);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user