@xera-ai/web 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xera-ai/web",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -11,7 +11,7 @@
11
11
  "types": "./dist/index.d.ts"
12
12
  }
13
13
  },
14
- "files": ["dist"],
14
+ "files": ["dist", "src"],
15
15
  "scripts": {
16
16
  "build": "bun build ./src/index.ts --outdir ./dist --target bun --external @playwright/test --external @xera-ai/core --external @cucumber/gherkin --external @cucumber/messages --external fflate",
17
17
  "typecheck": "tsc --noEmit"
@@ -20,7 +20,7 @@
20
20
  "@cucumber/gherkin": "30.0.4",
21
21
  "@cucumber/messages": "27.0.2",
22
22
  "@playwright/test": "1.48.0",
23
- "@xera-ai/core": "0.1.2",
23
+ "@xera-ai/core": "^0.1.5",
24
24
  "fflate": "0.8.2"
25
25
  }
26
26
  }
package/src/adapter.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { runPlaywright } from './executor';
2
+ import { normalizeRun } from './trace-normalizer/normalize';
3
+ import type { TestAdapter, GenerateInput, GenerateResult, ExecuteInput, RunResult, DoctorReport } from '@xera-ai/core/adapter';
4
+ import { join } from 'node:path';
5
+
6
+ export const WebAdapter: TestAdapter = {
7
+ id: 'web',
8
+
9
+ async generate(_input: GenerateInput): Promise<GenerateResult> {
10
+ // Generation itself is LLM-driven via skills + prompts; the adapter
11
+ // exposes helpers (validateGherkin, typecheckTicket, lintTicket) that
12
+ // the skills call via `bun run xera:*`. No direct artifact writing here.
13
+ return { artifacts: [], warnings: [] };
14
+ },
15
+
16
+ async execute(input: ExecuteInput): Promise<RunResult> {
17
+ const runDir = join(input.ticketDir, 'runs', input.runId);
18
+ const specPath = join(input.ticketDir, 'spec.ts');
19
+ const configPath = join(input.ticketDir, 'playwright.config.ts');
20
+ const pwResult = await runPlaywright({ specPath, configPath, outputDir: runDir });
21
+ const normalized = await normalizeRun({ runId: input.runId, runDir });
22
+ return {
23
+ runId: input.runId,
24
+ outcome: normalized.outcome,
25
+ scenarios: normalized.scenarios.map(s => {
26
+ const out: RunResult['scenarios'][number] = { name: s.name, outcome: s.outcome };
27
+ if (s.failure !== undefined) out.failure = s.failure;
28
+ return out;
29
+ }),
30
+ artifactsDir: runDir,
31
+ rawReportPath: pwResult.rawReportPath,
32
+ normalizedReportPath: join(runDir, 'normalized.json'),
33
+ };
34
+ },
35
+
36
+ async doctor(): Promise<DoctorReport> {
37
+ const checks: DoctorReport['checks'] = [];
38
+ try {
39
+ await import('@playwright/test');
40
+ checks.push({ name: '@playwright/test installed', ok: true });
41
+ } catch {
42
+ checks.push({ name: '@playwright/test installed', ok: false, message: 'Run `bun add -D @playwright/test`.' });
43
+ }
44
+ return { ok: checks.every(c => c.ok), checks };
45
+ },
46
+ };
@@ -0,0 +1,25 @@
1
+ import type { Page } from '@playwright/test';
2
+
3
+ export interface AuthRoleCreds {
4
+ email: string;
5
+ password: string;
6
+ }
7
+
8
+ export interface AuthSetupResult {
9
+ /** Optional explicit expiry hint, ms since epoch. */
10
+ expiresAt?: number;
11
+ }
12
+
13
+ export type AuthSetupFn = (
14
+ page: Page,
15
+ role: string,
16
+ creds: AuthRoleCreds,
17
+ ) => Promise<AuthSetupResult | void>;
18
+
19
+ /**
20
+ * Helper to type-narrow the user's auth setup function. Users import this in
21
+ * `shared/auth-setup.ts`.
22
+ */
23
+ export function defineAuthSetup(fn: AuthSetupFn): AuthSetupFn {
24
+ return fn;
25
+ }
@@ -0,0 +1,13 @@
1
+ import { writeFileSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { readAuthState } from '@xera-ai/core';
4
+
5
+ export function stagePlaywrightState(authDir: string, role: string): string {
6
+ const entry = readAuthState(authDir, role);
7
+ if (!entry) throw new Error(`No auth state for role "${role}" in ${authDir}`);
8
+ const cacheDir = join(authDir, '.cache');
9
+ mkdirSync(cacheDir, { recursive: true });
10
+ const stagedPath = join(cacheDir, `${role}.json`);
11
+ writeFileSync(stagedPath, JSON.stringify(entry.payload));
12
+ return stagedPath;
13
+ }
@@ -0,0 +1,40 @@
1
+ import type { Browser } from '@playwright/test';
2
+ import { pathToFileURL } from 'node:url';
3
+ import { writeAuthState } from '@xera-ai/core';
4
+ import type { AuthRoleCreds } from './define';
5
+
6
+ export interface RunAuthSetupInput {
7
+ role: string;
8
+ creds: AuthRoleCreds;
9
+ setupScriptPath: string;
10
+ authDir: string;
11
+ browser: Browser;
12
+ now?: Date;
13
+ }
14
+
15
+ export async function runAuthSetup(input: RunAuthSetupInput): Promise<void> {
16
+ const mod = await import(pathToFileURL(input.setupScriptPath).href);
17
+ const fn = mod.default;
18
+ if (typeof fn !== 'function') {
19
+ throw new Error(
20
+ `Auth setup script at ${input.setupScriptPath} must default-export a function (see defineAuthSetup).`,
21
+ );
22
+ }
23
+ const context = await input.browser.newContext();
24
+ try {
25
+ const page = await context.newPage();
26
+ const result = (await fn(page, input.role, input.creds)) ?? {};
27
+ const storageState = await context.storageState();
28
+ const now = input.now ?? new Date();
29
+ const expiresAtMs = result.expiresAt ?? now.getTime() + 8 * 3600 * 1000;
30
+ writeAuthState(input.authDir, {
31
+ role: input.role,
32
+ strategy: 'storageState',
33
+ created_at: now.toISOString(),
34
+ expires_at: new Date(expiresAtMs).toISOString(),
35
+ payload: storageState as unknown as Record<string, unknown>,
36
+ });
37
+ } finally {
38
+ await context.close();
39
+ }
40
+ }
@@ -0,0 +1,42 @@
1
+ import { join } from 'node:path';
2
+ import { buildPlaywrightArgs } from './playwright-args';
3
+
4
+ export interface SpawnResult {
5
+ exitCode: number;
6
+ }
7
+ export type SpawnFn = (cmd: string, args: string[], env: NodeJS.ProcessEnv) => Promise<SpawnResult>;
8
+
9
+ export interface RunPlaywrightInput {
10
+ specPath: string;
11
+ configPath: string;
12
+ outputDir: string;
13
+ env?: NodeJS.ProcessEnv;
14
+ spawn?: SpawnFn;
15
+ }
16
+
17
+ export interface RunPlaywrightResult {
18
+ outcome: 'PASS' | 'FAIL';
19
+ rawReportPath: string;
20
+ exitCode: number;
21
+ }
22
+
23
+ const defaultSpawn: SpawnFn = async (cmd, args, env) => {
24
+ const proc = Bun.spawn([cmd, ...args], { env, stdout: 'inherit', stderr: 'inherit' });
25
+ const exitCode = await proc.exited;
26
+ return { exitCode };
27
+ };
28
+
29
+ export async function runPlaywright(input: RunPlaywrightInput): Promise<RunPlaywrightResult> {
30
+ const args = buildPlaywrightArgs({
31
+ specPath: input.specPath,
32
+ configPath: input.configPath,
33
+ outputDir: input.outputDir,
34
+ });
35
+ const spawn = input.spawn ?? defaultSpawn;
36
+ const { exitCode } = await spawn('npx', ['playwright', ...args], { ...process.env, ...input.env });
37
+ return {
38
+ outcome: exitCode === 0 ? 'PASS' : 'FAIL',
39
+ rawReportPath: join(input.outputDir, 'report.json'),
40
+ exitCode,
41
+ };
42
+ }
@@ -0,0 +1,16 @@
1
+ export interface PlaywrightArgsInput {
2
+ specPath: string;
3
+ outputDir: string;
4
+ configPath: string;
5
+ }
6
+
7
+ export function buildPlaywrightArgs(input: PlaywrightArgsInput): string[] {
8
+ return [
9
+ 'test',
10
+ input.specPath,
11
+ `--config=${input.configPath}`,
12
+ '--reporter=json',
13
+ `--output=${input.outputDir}`,
14
+ '--trace=on',
15
+ ];
16
+ }
@@ -0,0 +1,28 @@
1
+ import { AstBuilder, GherkinClassicTokenMatcher, Parser } from '@cucumber/gherkin';
2
+ import { IdGenerator } from '@cucumber/messages';
3
+
4
+ export interface GherkinValidateResult {
5
+ ok: boolean;
6
+ errors: Array<{ line: number; message: string }>;
7
+ }
8
+
9
+ export function validateGherkin(content: string): GherkinValidateResult {
10
+ if (!content.trim()) {
11
+ return { ok: false, errors: [{ line: 0, message: 'Empty feature file' }] };
12
+ }
13
+ try {
14
+ const parser = new Parser(new AstBuilder(IdGenerator.uuid()), new GherkinClassicTokenMatcher());
15
+ parser.parse(content);
16
+ return { ok: true, errors: [] };
17
+ } catch (e: any) {
18
+ const errors: Array<{ line: number; message: string }> = [];
19
+ if (e?.errors && Array.isArray(e.errors)) {
20
+ for (const inner of e.errors) {
21
+ errors.push({ line: inner?.location?.line ?? 0, message: String(inner?.message ?? inner) });
22
+ }
23
+ } else {
24
+ errors.push({ line: 0, message: String(e?.message ?? e) });
25
+ }
26
+ return { ok: false, errors };
27
+ }
28
+ }
@@ -0,0 +1,30 @@
1
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { lintSelectors, type SelectorWarning } from './selector-rules';
4
+
5
+ export interface LintResult {
6
+ ok: boolean;
7
+ warnings: Array<SelectorWarning & { file: string }>;
8
+ }
9
+
10
+ function listTsFiles(dir: string): string[] {
11
+ if (!existsSync(dir)) return [];
12
+ const out: string[] = [];
13
+ for (const name of readdirSync(dir, { withFileTypes: true })) {
14
+ const full = join(dir, name.name);
15
+ if (name.isDirectory()) out.push(...listTsFiles(full));
16
+ else if (name.name.endsWith('.ts')) out.push(full);
17
+ }
18
+ return out;
19
+ }
20
+
21
+ export async function lintTicket(ticketDir: string): Promise<LintResult> {
22
+ const files = listTsFiles(ticketDir);
23
+ const warnings: LintResult['warnings'] = [];
24
+ for (const f of files) {
25
+ const src = readFileSync(f, 'utf8');
26
+ const r = lintSelectors(src);
27
+ for (const w of r.warnings) warnings.push({ ...w, file: f });
28
+ }
29
+ return { ok: warnings.length === 0, warnings };
30
+ }
@@ -0,0 +1,24 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ const CLASS_RE = /export\s+class\s+([A-Z][A-Za-z0-9_]*)/g;
5
+
6
+ export interface SharedPom {
7
+ className: string;
8
+ absolutePath: string;
9
+ }
10
+
11
+ export function scanSharedPoms(repoRoot: string): SharedPom[] {
12
+ const dir = join(repoRoot, 'shared', 'page-objects');
13
+ if (!existsSync(dir)) return [];
14
+ const found: SharedPom[] = [];
15
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
16
+ if (!entry.isFile() || !entry.name.endsWith('.ts')) continue;
17
+ const path = join(dir, entry.name);
18
+ const src = readFileSync(path, 'utf8');
19
+ for (const m of src.matchAll(CLASS_RE)) {
20
+ found.push({ className: m[1]!, absolutePath: path });
21
+ }
22
+ }
23
+ return found;
24
+ }
@@ -0,0 +1,35 @@
1
+ import { existsSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ export interface PromoteInput {
5
+ repoRoot: string;
6
+ ticket: string;
7
+ className: string;
8
+ }
9
+
10
+ export async function promotePom(input: PromoteInput): Promise<void> {
11
+ const fromDir = join(input.repoRoot, '.xera', input.ticket, 'page-objects');
12
+ const toDir = join(input.repoRoot, 'shared', 'page-objects');
13
+ const file = `${input.className}.ts`;
14
+ const fromPath = join(fromDir, file);
15
+ const toPath = join(toDir, file);
16
+
17
+ if (!existsSync(fromPath)) {
18
+ throw new Error(`POM ${file} not found at ${fromPath}`);
19
+ }
20
+ if (existsSync(toPath)) {
21
+ throw new Error(`POM ${file} already exists at ${toPath}. Reconcile manually before promoting.`);
22
+ }
23
+
24
+ renameSync(fromPath, toPath);
25
+
26
+ const specPath = join(input.repoRoot, '.xera', input.ticket, 'spec.ts');
27
+ if (existsSync(specPath)) {
28
+ const src = readFileSync(specPath, 'utf8');
29
+ const updated = src.replace(
30
+ new RegExp(`from\\s+['"]\\./page-objects/${input.className}['"]`, 'g'),
31
+ `from '../../shared/page-objects/${input.className}'`,
32
+ );
33
+ writeFileSync(specPath, updated);
34
+ }
35
+ }
@@ -0,0 +1,34 @@
1
+ export interface SelectorWarning {
2
+ rule: 'no-auto-classname' | 'prefer-role-over-css' | 'no-xpath';
3
+ line: number;
4
+ text: string;
5
+ message: string;
6
+ }
7
+
8
+ const AUTO_CLASS_RE = /\.(?:Mui|css|ant|chakra|MuiButton)[A-Za-z]*-[A-Za-z0-9_]*-[A-Za-z0-9_]{3,}/;
9
+ const LOCATOR_CSS_RE = /\.locator\(\s*['"`]([^'"`]+)['"`]/;
10
+ const XPATH_RE = /\.locator\(\s*['"`](xpath=|\/\/)/;
11
+ const ALLOW_CSS_RE = /xera-allow-css:/;
12
+
13
+ export function lintSelectors(source: string): { warnings: SelectorWarning[] } {
14
+ const warnings: SelectorWarning[] = [];
15
+ const lines = source.split('\n');
16
+ for (let i = 0; i < lines.length; i++) {
17
+ const text = lines[i]!;
18
+ const prev = lines[i - 1] ?? '';
19
+ if (XPATH_RE.test(text)) {
20
+ warnings.push({ rule: 'no-xpath', line: i + 1, text, message: 'XPath selectors are forbidden in v0.1.' });
21
+ continue;
22
+ }
23
+ const cssMatch = LOCATOR_CSS_RE.exec(text);
24
+ if (cssMatch) {
25
+ const sel = cssMatch[1]!;
26
+ if (AUTO_CLASS_RE.test(sel)) {
27
+ warnings.push({ rule: 'no-auto-classname', line: i + 1, text, message: `Auto-generated class name "${sel}" — refactor to role/label/test-id.` });
28
+ } else if (!ALLOW_CSS_RE.test(prev)) {
29
+ warnings.push({ rule: 'prefer-role-over-css', line: i + 1, text, message: `Prefer getByRole/getByLabel over CSS "${sel}". If unavoidable, add "// xera-allow-css: <reason>" on the previous line.` });
30
+ }
31
+ }
32
+ }
33
+ return { warnings };
34
+ }
@@ -0,0 +1,14 @@
1
+ import { spawnSync } from 'node:child_process';
2
+
3
+ export interface TypecheckResult {
4
+ ok: boolean;
5
+ errors: string[];
6
+ }
7
+
8
+ export async function typecheckTicket(ticketDir: string): Promise<TypecheckResult> {
9
+ const proc = spawnSync('npx', ['tsc', '--noEmit', '--project', ticketDir], { encoding: 'utf8' });
10
+ if (proc.status === 0) return { ok: true, errors: [] };
11
+ const out = (proc.stdout || '') + (proc.stderr || '');
12
+ const errors = out.split('\n').filter(line => /error TS\d+/.test(line));
13
+ return { ok: false, errors };
14
+ }
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ export * from './adapter';
2
+ export * from './auth-setup/define';
3
+ export * from './auth-setup/runner';
4
+ export * from './auth-setup/playwright-state';
5
+ export * from './executor';
6
+ export * from './executor/playwright-args';
7
+ export * from './trace-normalizer/normalize';
8
+ export * from './trace-normalizer/parse';
9
+ export * from './trace-normalizer/scrub';
10
+ export * from './trace-normalizer/scrub-rules';
11
+ export * from './trace-normalizer/unzip';
12
+ export * from './generator/gherkin-validate';
13
+ export * from './generator/typecheck';
14
+ export * from './generator/lint';
15
+ export * from './generator/selector-rules';
16
+ export * from './generator/pom-scan';
17
+ export * from './generator/promote';
@@ -0,0 +1,62 @@
1
+ import { readFileSync, existsSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { parsePlaywrightReport } from './parse';
4
+ import { scrub, type NormalizedNetworkEntry, type NormalizedRun } from './scrub';
5
+ import { unzipTrace } from './unzip';
6
+
7
+ export interface NormalizeRunInput {
8
+ runId: string;
9
+ runDir: string;
10
+ }
11
+
12
+ interface TraceNetworkEntry {
13
+ type: 'request';
14
+ method: string;
15
+ url: string;
16
+ status: number;
17
+ requestHeaders?: Record<string, string>;
18
+ responseHeaders?: Record<string, string>;
19
+ requestBody?: unknown;
20
+ responseBody?: unknown;
21
+ }
22
+
23
+ interface TraceConsoleEntry { type: 'console'; text: string; }
24
+
25
+ export async function normalizeRun(input: NormalizeRunInput): Promise<NormalizedRun> {
26
+ const reportPath = join(input.runDir, 'report.json');
27
+ const report = JSON.parse(readFileSync(reportPath, 'utf8'));
28
+ let normalized = parsePlaywrightReport(report, input.runId);
29
+
30
+ // Enrich with trace.zip if present
31
+ const tracePath = join(input.runDir, 'trace.zip');
32
+ if (existsSync(tracePath)) {
33
+ const { files } = unzipTrace(tracePath);
34
+ const networkFile = Object.entries(files).find(([k]) => k.endsWith('.network'))?.[1];
35
+ const traceFile = Object.entries(files).find(([k]) => k.endsWith('.trace'))?.[1];
36
+ const network: TraceNetworkEntry[] = networkFile
37
+ ? networkFile.trim().split('\n').filter(Boolean).map(l => JSON.parse(l)).filter((e: any) => e.type === 'request')
38
+ : [];
39
+ const consoleEvents: TraceConsoleEntry[] = traceFile
40
+ ? traceFile.trim().split('\n').filter(Boolean).map(l => JSON.parse(l)).filter((e: any) => e.type === 'console')
41
+ : [];
42
+
43
+ // Attach to each failing scenario (all entries — v0.1 doesn't yet correlate by step time)
44
+ for (const sc of normalized.scenarios) {
45
+ if (sc.outcome !== 'FAIL') continue;
46
+ sc.failure = sc.failure ?? {};
47
+ sc.failure.networkAtFailure = network.map(n => {
48
+ const entry: NormalizedNetworkEntry = { method: n.method, url: n.url, status: n.status };
49
+ if (n.requestHeaders !== undefined) entry.requestHeaders = n.requestHeaders;
50
+ if (n.responseHeaders !== undefined) entry.responseHeaders = n.responseHeaders;
51
+ if (n.requestBody !== undefined) entry.requestBody = n.requestBody;
52
+ if (n.responseBody !== undefined) entry.responseBody = n.responseBody;
53
+ return entry;
54
+ });
55
+ sc.failure.consoleAtFailure = consoleEvents.map(c => c.text);
56
+ }
57
+ }
58
+
59
+ normalized = scrub(normalized);
60
+ writeFileSync(join(input.runDir, 'normalized.json'), JSON.stringify(normalized, null, 2));
61
+ return normalized;
62
+ }
@@ -0,0 +1,41 @@
1
+ import type { NormalizedRun, NormalizedScenario } from './scrub';
2
+
3
+ interface PWAttachment { name: string; path?: string; contentType?: string; }
4
+ interface PWResult { status: string; duration: number; error?: { message?: string; stack?: string }; attachments?: PWAttachment[]; }
5
+ interface PWTest { results: PWResult[]; }
6
+ interface PWSpec { title: string; ok: boolean; tests: PWTest[]; }
7
+ interface PWSuite { title: string; specs?: PWSpec[]; suites?: PWSuite[]; }
8
+ interface PWReport { stats: { unexpected: number }; suites: PWSuite[]; }
9
+
10
+ function* flatSpecs(suites: PWSuite[]): Generator<PWSpec> {
11
+ for (const s of suites) {
12
+ for (const sp of s.specs ?? []) yield sp;
13
+ if (s.suites) yield* flatSpecs(s.suites);
14
+ }
15
+ }
16
+
17
+ export function parsePlaywrightReport(report: PWReport, runId: string): NormalizedRun {
18
+ const scenarios: NormalizedScenario[] = [];
19
+ for (const spec of flatSpecs(report.suites)) {
20
+ const lastResult = spec.tests[0]?.results[0];
21
+ const outcome: 'PASS' | 'FAIL' | 'SKIPPED' =
22
+ !lastResult ? 'SKIPPED' :
23
+ lastResult.status === 'passed' ? 'PASS' :
24
+ lastResult.status === 'skipped' ? 'SKIPPED' : 'FAIL';
25
+ const sc: NormalizedScenario = { name: spec.title, outcome };
26
+ if (outcome === 'FAIL' && lastResult) {
27
+ const screenshot = lastResult.attachments?.find(a => a.name === 'screenshot')?.path;
28
+ const failure: NormalizedScenario['failure'] = {};
29
+ if (lastResult.error?.message !== undefined) failure!.errorMessage = lastResult.error.message;
30
+ if (screenshot !== undefined) failure!.screenshotPath = screenshot;
31
+ sc.failure = failure;
32
+ }
33
+ scenarios.push(sc);
34
+ }
35
+ return {
36
+ runId,
37
+ outcome: report.stats.unexpected === 0 ? 'PASS' : 'FAIL',
38
+ scenarios,
39
+ scrubbed_fields_count: 0,
40
+ };
41
+ }
@@ -0,0 +1,60 @@
1
+ export const SENSITIVE_HEADERS: readonly string[] = [
2
+ 'authorization',
3
+ 'cookie',
4
+ 'set-cookie',
5
+ 'x-api-key',
6
+ 'x-auth-token',
7
+ 'x-csrf-token',
8
+ 'proxy-authorization',
9
+ ];
10
+
11
+ export const SENSITIVE_BODY_KEYS: readonly RegExp[] = [
12
+ /password/i,
13
+ /passwd/i,
14
+ /token/i,
15
+ /secret/i,
16
+ /api[-_]?key/i,
17
+ /access[-_]?key/i,
18
+ /private[-_]?key/i,
19
+ /authorization/i,
20
+ /credit[-_]?card/i,
21
+ /card[-_]?number/i,
22
+ /cvv/i,
23
+ ];
24
+
25
+ export const JWT_RE = /\beyJ[A-Za-z0-9_-]{7,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{5,}\b/;
26
+ export const CREDIT_CARD_RE = /\b(?:\d{4}[-\s]?){3}\d{4}\b/;
27
+
28
+ const JWT_RE_G = new RegExp(JWT_RE.source, 'g');
29
+ const CREDIT_CARD_RE_G = new RegExp(CREDIT_CARD_RE.source, 'g');
30
+
31
+ const REDACTED = '[REDACTED]';
32
+
33
+ export function scrubHeaders(headers: Record<string, string>): Record<string, string> {
34
+ const out: Record<string, string> = {};
35
+ for (const [k, v] of Object.entries(headers)) {
36
+ out[k] = SENSITIVE_HEADERS.includes(k.toLowerCase()) ? REDACTED : v;
37
+ }
38
+ return out;
39
+ }
40
+
41
+ export function scrubBodyJson(body: unknown): unknown {
42
+ if (Array.isArray(body)) return body.map(scrubBodyJson);
43
+ if (body && typeof body === 'object') {
44
+ const out: Record<string, unknown> = {};
45
+ for (const [k, v] of Object.entries(body)) {
46
+ if (SENSITIVE_BODY_KEYS.some(re => re.test(k))) {
47
+ out[k] = REDACTED;
48
+ } else {
49
+ out[k] = scrubBodyJson(v);
50
+ }
51
+ }
52
+ return out;
53
+ }
54
+ if (typeof body === 'string') return scrubFreeText(body);
55
+ return body;
56
+ }
57
+
58
+ export function scrubFreeText(s: string): string {
59
+ return s.replace(JWT_RE_G, REDACTED).replace(CREDIT_CARD_RE_G, REDACTED);
60
+ }
@@ -0,0 +1,91 @@
1
+ import { scrubHeaders, scrubBodyJson, scrubFreeText } from './scrub-rules';
2
+
3
+ export interface NormalizedNetworkEntry {
4
+ method: string;
5
+ url: string;
6
+ status: number;
7
+ requestHeaders?: Record<string, string>;
8
+ requestBody?: unknown;
9
+ responseHeaders?: Record<string, string>;
10
+ responseBody?: unknown;
11
+ }
12
+
13
+ export interface NormalizedScenario {
14
+ name: string;
15
+ outcome: 'PASS' | 'FAIL' | 'SKIPPED';
16
+ failure?: {
17
+ step?: string;
18
+ errorMessage?: string;
19
+ domSnapshotAtFailure?: string;
20
+ networkAtFailure?: NormalizedNetworkEntry[];
21
+ consoleAtFailure?: string[];
22
+ screenshotPath?: string;
23
+ };
24
+ }
25
+
26
+ export interface NormalizedRun {
27
+ runId: string;
28
+ outcome: 'PASS' | 'FAIL';
29
+ scenarios: NormalizedScenario[];
30
+ scrubbed_fields_count: number;
31
+ }
32
+
33
+ function countScrubbed(before: unknown, after: unknown): number {
34
+ if (typeof before === 'string' && typeof after === 'string') return before !== after ? 1 : 0;
35
+ if (Array.isArray(before) && Array.isArray(after)) {
36
+ return before.reduce((acc, b, i) => acc + countScrubbed(b, after[i]), 0);
37
+ }
38
+ if (before && after && typeof before === 'object' && typeof after === 'object') {
39
+ let n = 0;
40
+ for (const k of Object.keys(before as Record<string, unknown>)) {
41
+ n += countScrubbed((before as Record<string, unknown>)[k], (after as Record<string, unknown>)[k]);
42
+ }
43
+ return n;
44
+ }
45
+ return 0;
46
+ }
47
+
48
+ export function scrub(run: NormalizedRun): NormalizedRun {
49
+ const out: NormalizedRun = { ...run, scrubbed_fields_count: 0, scenarios: [] };
50
+ let totalScrubs = 0;
51
+ for (const sc of run.scenarios) {
52
+ const newSc: NormalizedScenario = { ...sc };
53
+ if (sc.failure) {
54
+ const f = sc.failure;
55
+ const newF = { ...f };
56
+ if (f.errorMessage) {
57
+ newF.errorMessage = scrubFreeText(f.errorMessage);
58
+ totalScrubs += countScrubbed(f.errorMessage, newF.errorMessage);
59
+ }
60
+ if (f.consoleAtFailure) {
61
+ newF.consoleAtFailure = f.consoleAtFailure.map(scrubFreeText);
62
+ totalScrubs += f.consoleAtFailure.reduce(
63
+ (acc, b, i) => acc + countScrubbed(b, newF.consoleAtFailure![i]),
64
+ 0,
65
+ );
66
+ }
67
+ if (f.networkAtFailure) {
68
+ newF.networkAtFailure = f.networkAtFailure.map(n => {
69
+ const reqHeaders = n.requestHeaders ? scrubHeaders(n.requestHeaders) : undefined;
70
+ const resHeaders = n.responseHeaders ? scrubHeaders(n.responseHeaders) : undefined;
71
+ const reqBody = n.requestBody !== undefined ? scrubBodyJson(n.requestBody) : undefined;
72
+ const resBody = n.responseBody !== undefined ? scrubBodyJson(n.responseBody) : undefined;
73
+ totalScrubs += countScrubbed(n.requestHeaders ?? {}, reqHeaders ?? {});
74
+ totalScrubs += countScrubbed(n.responseHeaders ?? {}, resHeaders ?? {});
75
+ totalScrubs += countScrubbed(n.requestBody ?? {}, reqBody ?? {});
76
+ totalScrubs += countScrubbed(n.responseBody ?? {}, resBody ?? {});
77
+ const out: NormalizedNetworkEntry = { method: n.method, url: n.url, status: n.status };
78
+ if (reqHeaders !== undefined) out.requestHeaders = reqHeaders;
79
+ if (resHeaders !== undefined) out.responseHeaders = resHeaders;
80
+ if (reqBody !== undefined) out.requestBody = reqBody;
81
+ if (resBody !== undefined) out.responseBody = resBody;
82
+ return out;
83
+ });
84
+ }
85
+ newSc.failure = newF;
86
+ }
87
+ out.scenarios.push(newSc);
88
+ }
89
+ out.scrubbed_fields_count = totalScrubs;
90
+ return out;
91
+ }
@@ -0,0 +1,20 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { unzipSync } from 'fflate';
3
+
4
+ export interface TraceEntries {
5
+ /** Filename → text contents */
6
+ files: Record<string, string>;
7
+ }
8
+
9
+ export function unzipTrace(tracePath: string): TraceEntries {
10
+ const buf = readFileSync(tracePath);
11
+ const entries = unzipSync(buf);
12
+ const files: Record<string, string> = {};
13
+ for (const [name, data] of Object.entries(entries)) {
14
+ if (name.endsWith('/')) continue;
15
+ if (name.endsWith('.network') || name.endsWith('.trace') || name.endsWith('.txt') || name.endsWith('.json')) {
16
+ files[name] = new TextDecoder().decode(data);
17
+ }
18
+ }
19
+ return { files };
20
+ }