@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.
- package/dist/generator/typecheck.d.ts +5 -0
- package/dist/index.js +50 -23
- package/package.json +3 -3
- package/src/adapter.ts +46 -0
- package/src/auth-setup/define.ts +25 -0
- package/src/auth-setup/playwright-state.ts +13 -0
- package/src/auth-setup/runner.ts +40 -0
- package/src/executor/index.ts +42 -0
- package/src/executor/playwright-args.ts +16 -0
- package/src/generator/gherkin-validate.ts +28 -0
- package/src/generator/lint.ts +30 -0
- package/src/generator/pom-scan.ts +24 -0
- package/src/generator/promote.ts +35 -0
- package/src/generator/selector-rules.ts +34 -0
- package/src/generator/typecheck.ts +54 -0
- package/src/index.ts +17 -0
- package/src/trace-normalizer/normalize.ts +62 -0
- package/src/trace-normalizer/parse.ts +41 -0
- package/src/trace-normalizer/scrub-rules.ts +60 -0
- package/src/trace-normalizer/scrub.ts +91 -0
- package/src/trace-normalizer/unzip.ts +20 -0
|
@@ -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
|
|
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 =
|
|
365
|
-
const
|
|
387
|
+
const out = `${proc.stdout ?? ""}${proc.stderr ?? ""}`;
|
|
388
|
+
const allErrors = out.split(`
|
|
366
389
|
`).filter((line) => /error TS\d+/.test(line));
|
|
367
|
-
|
|
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
|
|
371
|
-
import { join as
|
|
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 (!
|
|
431
|
+
if (!existsSync3(dir))
|
|
405
432
|
return [];
|
|
406
433
|
const out = [];
|
|
407
434
|
for (const name of readdirSync(dir, { withFileTypes: true })) {
|
|
408
|
-
const full =
|
|
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
|
|
429
|
-
import { join as
|
|
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 =
|
|
433
|
-
if (!
|
|
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 =
|
|
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
|
|
449
|
-
import { join as
|
|
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 =
|
|
452
|
-
const toDir =
|
|
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 =
|
|
455
|
-
const toPath =
|
|
456
|
-
if (!
|
|
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 (
|
|
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 =
|
|
464
|
-
if (
|
|
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.
|
|
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.
|
|
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
|
+
}
|