@xera-ai/web 0.1.4 → 0.1.6

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.
@@ -2,4 +2,9 @@ export interface TypecheckResult {
2
2
  ok: boolean;
3
3
  errors: string[];
4
4
  }
5
+ /**
6
+ * Type-check the ticket's TypeScript files using the project's root tsconfig.
7
+ * Errors are filtered to those whose path contains the ticket directory, so the
8
+ * skill sees only the locally relevant ones.
9
+ */
5
10
  export declare function typecheckTicket(ticketDir: string): Promise<TypecheckResult>;
package/dist/index.js CHANGED
@@ -357,18 +357,45 @@ function validateGherkin(content) {
357
357
  }
358
358
  // src/generator/typecheck.ts
359
359
  import { spawnSync } from "child_process";
360
+ import { existsSync as existsSync2 } from "fs";
361
+ import { dirname, join as join5 } from "path";
362
+ function findNearestTsconfig(start) {
363
+ let dir = start;
364
+ while (true) {
365
+ const candidate = join5(dir, "tsconfig.json");
366
+ if (existsSync2(candidate))
367
+ return candidate;
368
+ const parent = dirname(dir);
369
+ if (parent === dir)
370
+ return null;
371
+ dir = parent;
372
+ }
373
+ }
360
374
  async function typecheckTicket(ticketDir) {
361
- const proc = spawnSync("npx", ["tsc", "--noEmit", "--project", ticketDir], { encoding: "utf8" });
375
+ const tsconfig = findNearestTsconfig(ticketDir);
376
+ if (!tsconfig) {
377
+ return {
378
+ ok: false,
379
+ errors: [
380
+ `No tsconfig.json found walking up from ${ticketDir}. Run \`xera init\` to scaffold one at the project root.`
381
+ ]
382
+ };
383
+ }
384
+ const proc = spawnSync("npx", ["tsc", "--noEmit", "-p", tsconfig], { encoding: "utf8" });
362
385
  if (proc.status === 0)
363
386
  return { ok: true, errors: [] };
364
- const out = (proc.stdout || "") + (proc.stderr || "");
365
- const errors = out.split(`
387
+ const out = `${proc.stdout ?? ""}${proc.stderr ?? ""}`;
388
+ const allErrors = out.split(`
366
389
  `).filter((line) => /error TS\d+/.test(line));
367
- return { ok: false, errors };
390
+ const ticketErrors = allErrors.filter((line) => line.includes(ticketDir));
391
+ return {
392
+ ok: false,
393
+ errors: ticketErrors.length > 0 ? ticketErrors : allErrors
394
+ };
368
395
  }
369
396
  // src/generator/lint.ts
370
- import { readFileSync as readFileSync3, existsSync as existsSync2, readdirSync } from "fs";
371
- import { join as join5 } from "path";
397
+ import { readFileSync as readFileSync3, existsSync as existsSync3, readdirSync } from "fs";
398
+ import { join as join6 } from "path";
372
399
 
373
400
  // src/generator/selector-rules.ts
374
401
  var AUTO_CLASS_RE = /\.(?:Mui|css|ant|chakra|MuiButton)[A-Za-z]*-[A-Za-z0-9_]*-[A-Za-z0-9_]{3,}/;
@@ -401,11 +428,11 @@ function lintSelectors(source) {
401
428
 
402
429
  // src/generator/lint.ts
403
430
  function listTsFiles(dir) {
404
- if (!existsSync2(dir))
431
+ if (!existsSync3(dir))
405
432
  return [];
406
433
  const out = [];
407
434
  for (const name of readdirSync(dir, { withFileTypes: true })) {
408
- const full = join5(dir, name.name);
435
+ const full = join6(dir, name.name);
409
436
  if (name.isDirectory())
410
437
  out.push(...listTsFiles(full));
411
438
  else if (name.name.endsWith(".ts"))
@@ -425,18 +452,18 @@ async function lintTicket(ticketDir) {
425
452
  return { ok: warnings.length === 0, warnings };
426
453
  }
427
454
  // src/generator/pom-scan.ts
428
- import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync as readFileSync4 } from "fs";
429
- import { join as join6 } from "path";
455
+ import { existsSync as existsSync4, readdirSync as readdirSync2, readFileSync as readFileSync4 } from "fs";
456
+ import { join as join7 } from "path";
430
457
  var CLASS_RE = /export\s+class\s+([A-Z][A-Za-z0-9_]*)/g;
431
458
  function scanSharedPoms(repoRoot) {
432
- const dir = join6(repoRoot, "shared", "page-objects");
433
- if (!existsSync3(dir))
459
+ const dir = join7(repoRoot, "shared", "page-objects");
460
+ if (!existsSync4(dir))
434
461
  return [];
435
462
  const found = [];
436
463
  for (const entry of readdirSync2(dir, { withFileTypes: true })) {
437
464
  if (!entry.isFile() || !entry.name.endsWith(".ts"))
438
465
  continue;
439
- const path = join6(dir, entry.name);
466
+ const path = join7(dir, entry.name);
440
467
  const src = readFileSync4(path, "utf8");
441
468
  for (const m of src.matchAll(CLASS_RE)) {
442
469
  found.push({ className: m[1], absolutePath: path });
@@ -445,23 +472,23 @@ function scanSharedPoms(repoRoot) {
445
472
  return found;
446
473
  }
447
474
  // src/generator/promote.ts
448
- import { existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync3, renameSync } from "fs";
449
- import { join as join7 } from "path";
475
+ import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync3, renameSync } from "fs";
476
+ import { join as join8 } from "path";
450
477
  async function promotePom(input) {
451
- const fromDir = join7(input.repoRoot, ".xera", input.ticket, "page-objects");
452
- const toDir = join7(input.repoRoot, "shared", "page-objects");
478
+ const fromDir = join8(input.repoRoot, ".xera", input.ticket, "page-objects");
479
+ const toDir = join8(input.repoRoot, "shared", "page-objects");
453
480
  const file = `${input.className}.ts`;
454
- const fromPath = join7(fromDir, file);
455
- const toPath = join7(toDir, file);
456
- if (!existsSync4(fromPath)) {
481
+ const fromPath = join8(fromDir, file);
482
+ const toPath = join8(toDir, file);
483
+ if (!existsSync5(fromPath)) {
457
484
  throw new Error(`POM ${file} not found at ${fromPath}`);
458
485
  }
459
- if (existsSync4(toPath)) {
486
+ if (existsSync5(toPath)) {
460
487
  throw new Error(`POM ${file} already exists at ${toPath}. Reconcile manually before promoting.`);
461
488
  }
462
489
  renameSync(fromPath, toPath);
463
- const specPath = join7(input.repoRoot, ".xera", input.ticket, "spec.ts");
464
- if (existsSync4(specPath)) {
490
+ const specPath = join8(input.repoRoot, ".xera", input.ticket, "spec.ts");
491
+ if (existsSync5(specPath)) {
465
492
  const src = readFileSync5(specPath, "utf8");
466
493
  const updated = src.replace(new RegExp(`from\\s+['"]\\./page-objects/${input.className}['"]`, "g"), `from '../../shared/page-objects/${input.className}'`);
467
494
  writeFileSync3(specPath, updated);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xera-ai/web",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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.4",
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,54 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ export interface TypecheckResult {
6
+ ok: boolean;
7
+ errors: string[];
8
+ }
9
+
10
+ /**
11
+ * Walks up from `start` looking for the first `tsconfig.json`. Returns null if
12
+ * none is found before reaching the filesystem root.
13
+ */
14
+ function findNearestTsconfig(start: string): string | null {
15
+ let dir = start;
16
+ // eslint-disable-next-line no-constant-condition
17
+ while (true) {
18
+ const candidate = join(dir, 'tsconfig.json');
19
+ if (existsSync(candidate)) return candidate;
20
+ const parent = dirname(dir);
21
+ if (parent === dir) return null;
22
+ dir = parent;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Type-check the ticket's TypeScript files using the project's root tsconfig.
28
+ * Errors are filtered to those whose path contains the ticket directory, so the
29
+ * skill sees only the locally relevant ones.
30
+ */
31
+ export async function typecheckTicket(ticketDir: string): Promise<TypecheckResult> {
32
+ const tsconfig = findNearestTsconfig(ticketDir);
33
+ if (!tsconfig) {
34
+ return {
35
+ ok: false,
36
+ errors: [
37
+ `No tsconfig.json found walking up from ${ticketDir}. Run \`xera init\` to scaffold one at the project root.`,
38
+ ],
39
+ };
40
+ }
41
+
42
+ const proc = spawnSync('npx', ['tsc', '--noEmit', '-p', tsconfig], { encoding: 'utf8' });
43
+ if (proc.status === 0) return { ok: true, errors: [] };
44
+
45
+ const out = `${proc.stdout ?? ''}${proc.stderr ?? ''}`;
46
+ const allErrors = out.split('\n').filter((line) => /error TS\d+/.test(line));
47
+ // Keep errors that originate inside the ticket dir; if none match, fall back
48
+ // to all errors so callers don't see "ok" for a broken root.
49
+ const ticketErrors = allErrors.filter((line) => line.includes(ticketDir));
50
+ return {
51
+ ok: false,
52
+ errors: ticketErrors.length > 0 ? ticketErrors : allErrors,
53
+ };
54
+ }
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
+ }