Release playkit v0.3 with Zod asserts and auth helpers
Add assertSchema/ApiClient schema option, storageState helpers, and playkitFailureArtifacts preset for consumer e2e suites.
This commit is contained in:
parent
a52fe89372
commit
7369b1c5e5
@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## 0.3.0 — 2026-07-14
|
||||
|
||||
- **Zod schema asserts** — `assertSchema()` + optional `schema` on `ApiClient` requests
|
||||
- **storageState helpers** — `saveStorageState()`, `storageStateUse()`
|
||||
- **Trace-on-failure preset** — `playkitFailureArtifacts()` for Playwright `use`
|
||||
- Mailpit adapter marked shipped (was in 0.2.1)
|
||||
|
||||
## 0.2.1 — 2026-07-14
|
||||
|
||||
- **Mailpit** client + `createMailInbox()` (prefer homelab Mailpit, else Mailtrap SaaS)
|
||||
|
||||
60
ROADMAP.md
60
ROADMAP.md
@ -2,7 +2,7 @@
|
||||
|
||||
Living plan for making `@levkin/playkit` more useful across Levkin repos.
|
||||
|
||||
## Now (v0.2) — Mailtrap + prior kit
|
||||
## Now (v0.3) — shipped
|
||||
|
||||
- [x] Browser helpers with retries (`click`, `fill`, `safeGoto`, visibility waits)
|
||||
- [x] `BasePage` for Page Objects
|
||||
@ -14,20 +14,19 @@ Living plan for making `@levkin/playkit` more useful across Levkin repos.
|
||||
- [x] Unit tests (vitest) + Gitea Actions CI
|
||||
- [x] Starter Grafana dashboard JSON
|
||||
- [x] Consumer docs + API/UI examples
|
||||
- [x] **Mailtrap / Email Testing adapter** — `waitForEmail`, link extraction (Kolby #56 class)
|
||||
- [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
|
||||
|
||||
## Next (v0.3) — high value
|
||||
## Next (v0.4) — high value
|
||||
|
||||
- [ ] **Mailpit adapter** — same `waitForEmail` interface for local/homelab catch-all (no SaaS)
|
||||
- [ ] **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)
|
||||
- [ ] **Deploy-smoke CLI** — `playkit smoke --project punimtag` post-deploy gate
|
||||
- [ ] **Retry policy presets** — flaky-network vs strict-CI profiles
|
||||
|
||||
## Later (v0.4+) — professional polish
|
||||
## Later (v0.5+) — professional polish
|
||||
|
||||
- [ ] **Web Vitals** (LCP/CLS/INP) collection via Playwright CDP + metrics labels
|
||||
- [ ] **A11y** — axe-core wrapper as optional peer
|
||||
@ -39,46 +38,3 @@ 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
|
||||
|
||||
## 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
|
||||
|
||||
1. [x] `e2e/` depending on `@levkin/playkit`
|
||||
2. [x] Specs: public host after sign-out; health API; forgot-password via Mailtrap
|
||||
3. [ ] CI secrets populated + 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
|
||||
|
||||
16
package-lock.json
generated
16
package-lock.json
generated
@ -1,13 +1,16 @@
|
||||
{
|
||||
"name": "@levkin/playkit",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@levkin/playkit",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@types/node": "^22.15.0",
|
||||
@ -2668,6 +2671,15 @@
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@levkin/playkit",
|
||||
"version": "0.2.1",
|
||||
"version": "0.3.0",
|
||||
"description": "Shared Playwright + API test kit — browser helpers, API client, logging, performance, Grafana/Prometheus metrics",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@ -47,6 +47,9 @@
|
||||
"prepublishOnly": "npm run build",
|
||||
"example:api": "tsx examples/api/health.example.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@playwright/test": ">=1.40.0"
|
||||
},
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { createLogger, type Logger } from '../logging/logger.js';
|
||||
import { redactSecrets } from '../logging/redact.js';
|
||||
import { assertSchema } from './schema.js';
|
||||
import type { z } from 'zod';
|
||||
|
||||
export interface ApiClientOptions {
|
||||
baseUrl: string;
|
||||
@ -16,6 +18,8 @@ export interface ApiRequestOptions {
|
||||
body?: unknown;
|
||||
timeoutMs?: number;
|
||||
expectedStatus?: number | number[];
|
||||
/** Optional Zod schema — response `data` is parsed/asserted when set. */
|
||||
schema?: z.ZodType<unknown>;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T = unknown> {
|
||||
@ -111,6 +115,10 @@ export class ApiClient {
|
||||
data = text as unknown as T;
|
||||
}
|
||||
|
||||
if (opts.schema) {
|
||||
data = assertSchema(data, opts.schema as z.ZodType<T>, `${method} ${path}`);
|
||||
}
|
||||
|
||||
const response: ApiResponse<T> = {
|
||||
status: res.status,
|
||||
ok: res.ok,
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export { ApiClient, type ApiClientOptions, type ApiRequestOptions, type ApiResponse } from './client.js';
|
||||
export { assertSchema, SchemaAssertionError } from './schema.js';
|
||||
|
||||
17
src/api/schema.test.ts
Normal file
17
src/api/schema.test.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import { assertSchema, SchemaAssertionError } from './schema.js';
|
||||
|
||||
describe('assertSchema', () => {
|
||||
const Health = z.object({ status: z.literal('ok') });
|
||||
|
||||
it('returns parsed data when valid', () => {
|
||||
expect(assertSchema({ status: 'ok' }, Health)).toEqual({ status: 'ok' });
|
||||
});
|
||||
|
||||
it('throws SchemaAssertionError when invalid', () => {
|
||||
expect(() => assertSchema({ status: 'nope' }, Health, 'health')).toThrow(
|
||||
SchemaAssertionError,
|
||||
);
|
||||
});
|
||||
});
|
||||
34
src/api/schema.ts
Normal file
34
src/api/schema.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import type { z } from 'zod';
|
||||
|
||||
export class SchemaAssertionError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly issues: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'SchemaAssertionError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert `data` matches a Zod schema. Throws SchemaAssertionError on failure.
|
||||
* Prefer this over `as T` casts at API boundaries.
|
||||
*/
|
||||
export function assertSchema<T>(
|
||||
data: unknown,
|
||||
schema: z.ZodType<T>,
|
||||
label = 'response',
|
||||
): T {
|
||||
const result = schema.safeParse(data);
|
||||
if (!result.success) {
|
||||
const summary = result.error.issues
|
||||
.slice(0, 8)
|
||||
.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
|
||||
.join('; ');
|
||||
throw new SchemaAssertionError(
|
||||
`Schema assertion failed for ${label}: ${summary}`,
|
||||
result.error.issues,
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
@ -11,3 +11,5 @@ export {
|
||||
type FillOptions,
|
||||
type GotoOptions,
|
||||
} from './actions.js';
|
||||
export { saveStorageState, storageStateUse } from './storageState.js';
|
||||
export { playkitFailureArtifacts } from './playwrightPreset.js';
|
||||
|
||||
18
src/browser/playwrightPreset.ts
Normal file
18
src/browser/playwrightPreset.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import type { PlaywrightTestConfig } from '@playwright/test';
|
||||
|
||||
type UseOptions = NonNullable<PlaywrightTestConfig['use']>;
|
||||
|
||||
/**
|
||||
* Artifact defaults for CI / flaky debugging: retain trace/screenshot/video on failure.
|
||||
* Merge into `defineConfig({ use: { ...playkitFailureArtifacts() } })`.
|
||||
*/
|
||||
export function playkitFailureArtifacts(
|
||||
overrides: Partial<UseOptions> = {},
|
||||
): Partial<UseOptions> {
|
||||
return {
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
22
src/browser/storageState.ts
Normal file
22
src/browser/storageState.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import type { BrowserContext, Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Save Playwright storage state (cookies + localStorage) for reuse across suites.
|
||||
* Typical use: one-time admin login → save → other specs load via `test.use({ storageState })`.
|
||||
*/
|
||||
export async function saveStorageState(
|
||||
source: Page | BrowserContext,
|
||||
filePath: string,
|
||||
): Promise<string> {
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
const context = 'context' in source ? source.context() : source;
|
||||
await context.storageState({ path: filePath });
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/** Options blob for `test.use({ storageState: path })` / project config. */
|
||||
export function storageStateUse(filePath: string): { storageState: string } {
|
||||
return { storageState: filePath };
|
||||
}
|
||||
@ -19,6 +19,9 @@ export {
|
||||
waitForHidden,
|
||||
waitForUrlHost,
|
||||
assertPublicHost,
|
||||
saveStorageState,
|
||||
storageStateUse,
|
||||
playkitFailureArtifacts,
|
||||
type ClickOptions,
|
||||
type FillOptions,
|
||||
type GotoOptions,
|
||||
@ -26,6 +29,8 @@ export {
|
||||
|
||||
export {
|
||||
ApiClient,
|
||||
assertSchema,
|
||||
SchemaAssertionError,
|
||||
type ApiClientOptions,
|
||||
type ApiRequestOptions,
|
||||
type ApiResponse,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user