@xera-ai/web 0.1.6 → 0.2.0
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/index.d.ts +7 -7
- package/dist/index.js +111 -79
- package/dist/trace-normalizer/scrub-rules.d.ts +4 -0
- package/package.json +10 -7
- package/src/adapter.ts +16 -5
- package/src/auth-setup/playwright-state.ts +1 -1
- package/src/auth-setup/runner.ts +1 -1
- package/src/executor/index.ts +4 -1
- package/src/generator/lint.ts +1 -1
- package/src/generator/promote.ts +4 -2
- package/src/generator/selector-rules.ts +18 -3
- package/src/index.ts +7 -7
- package/src/trace-normalizer/normalize.ts +20 -7
- package/src/trace-normalizer/parse.ts +36 -11
- package/src/trace-normalizer/scrub-rules.ts +11 -2
- package/src/trace-normalizer/scrub.ts +6 -3
- package/src/trace-normalizer/unzip.ts +6 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
export * from './adapter';
|
|
2
2
|
export * from './auth-setup/define';
|
|
3
|
-
export * from './auth-setup/runner';
|
|
4
3
|
export * from './auth-setup/playwright-state';
|
|
4
|
+
export * from './auth-setup/runner';
|
|
5
5
|
export * from './executor';
|
|
6
6
|
export * from './executor/playwright-args';
|
|
7
|
+
export * from './generator/gherkin-validate';
|
|
8
|
+
export * from './generator/lint';
|
|
9
|
+
export * from './generator/pom-scan';
|
|
10
|
+
export * from './generator/promote';
|
|
11
|
+
export * from './generator/selector-rules';
|
|
12
|
+
export * from './generator/typecheck';
|
|
7
13
|
export * from './trace-normalizer/normalize';
|
|
8
14
|
export * from './trace-normalizer/parse';
|
|
9
15
|
export * from './trace-normalizer/scrub';
|
|
10
16
|
export * from './trace-normalizer/scrub-rules';
|
|
11
17
|
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';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
var __require = import.meta.require;
|
|
3
3
|
|
|
4
|
+
// src/adapter.ts
|
|
5
|
+
import { join as join3 } from "path";
|
|
6
|
+
|
|
4
7
|
// src/executor/index.ts
|
|
5
8
|
import { join } from "path";
|
|
6
9
|
|
|
@@ -29,7 +32,10 @@ async function runPlaywright(input) {
|
|
|
29
32
|
outputDir: input.outputDir
|
|
30
33
|
});
|
|
31
34
|
const spawn = input.spawn ?? defaultSpawn;
|
|
32
|
-
const { exitCode } = await spawn("npx", ["playwright", ...args], {
|
|
35
|
+
const { exitCode } = await spawn("npx", ["playwright", ...args], {
|
|
36
|
+
...process.env,
|
|
37
|
+
...input.env
|
|
38
|
+
});
|
|
33
39
|
return {
|
|
34
40
|
outcome: exitCode === 0 ? "PASS" : "FAIL",
|
|
35
41
|
rawReportPath: join(input.outputDir, "report.json"),
|
|
@@ -38,7 +44,7 @@ async function runPlaywright(input) {
|
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
// src/trace-normalizer/normalize.ts
|
|
41
|
-
import { readFileSync as readFileSync2,
|
|
47
|
+
import { existsSync, readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
42
48
|
import { join as join2 } from "path";
|
|
43
49
|
|
|
44
50
|
// src/trace-normalizer/parse.ts
|
|
@@ -100,8 +106,12 @@ var SENSITIVE_BODY_KEYS = [
|
|
|
100
106
|
];
|
|
101
107
|
var JWT_RE = /\beyJ[A-Za-z0-9_-]{7,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{5,}\b/;
|
|
102
108
|
var CREDIT_CARD_RE = /\b(?:\d{4}[-\s]?){3}\d{4}\b/;
|
|
109
|
+
var EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
|
|
110
|
+
var PHONE_RE = /(?:\+?\d[\d\s().-]{6,}\d)/;
|
|
103
111
|
var JWT_RE_G = new RegExp(JWT_RE.source, "g");
|
|
104
112
|
var CREDIT_CARD_RE_G = new RegExp(CREDIT_CARD_RE.source, "g");
|
|
113
|
+
var EMAIL_RE_G = new RegExp(EMAIL_RE.source, "g");
|
|
114
|
+
var PHONE_RE_G = new RegExp(PHONE_RE.source, "g");
|
|
105
115
|
var REDACTED = "[REDACTED]";
|
|
106
116
|
function scrubHeaders(headers) {
|
|
107
117
|
const out = {};
|
|
@@ -129,7 +139,7 @@ function scrubBodyJson(body) {
|
|
|
129
139
|
return body;
|
|
130
140
|
}
|
|
131
141
|
function scrubFreeText(s) {
|
|
132
|
-
return s.replace(JWT_RE_G, REDACTED).replace(CREDIT_CARD_RE_G, REDACTED);
|
|
142
|
+
return s.replace(JWT_RE_G, REDACTED).replace(CREDIT_CARD_RE_G, REDACTED).replace(EMAIL_RE_G, REDACTED).replace(PHONE_RE_G, REDACTED);
|
|
133
143
|
}
|
|
134
144
|
|
|
135
145
|
// src/trace-normalizer/scrub.ts
|
|
@@ -250,7 +260,6 @@ async function normalizeRun(input) {
|
|
|
250
260
|
}
|
|
251
261
|
|
|
252
262
|
// src/adapter.ts
|
|
253
|
-
import { join as join3 } from "path";
|
|
254
263
|
var WebAdapter = {
|
|
255
264
|
id: "web",
|
|
256
265
|
async generate(_input) {
|
|
@@ -282,7 +291,11 @@ var WebAdapter = {
|
|
|
282
291
|
await import("@playwright/test");
|
|
283
292
|
checks.push({ name: "@playwright/test installed", ok: true });
|
|
284
293
|
} catch {
|
|
285
|
-
checks.push({
|
|
294
|
+
checks.push({
|
|
295
|
+
name: "@playwright/test installed",
|
|
296
|
+
ok: false,
|
|
297
|
+
message: "Run `bun add -D @playwright/test`."
|
|
298
|
+
});
|
|
286
299
|
}
|
|
287
300
|
return { ok: checks.every((c) => c.ok), checks };
|
|
288
301
|
}
|
|
@@ -291,6 +304,20 @@ var WebAdapter = {
|
|
|
291
304
|
function defineAuthSetup(fn) {
|
|
292
305
|
return fn;
|
|
293
306
|
}
|
|
307
|
+
// src/auth-setup/playwright-state.ts
|
|
308
|
+
import { mkdirSync, writeFileSync as writeFileSync2 } from "fs";
|
|
309
|
+
import { join as join4 } from "path";
|
|
310
|
+
import { readAuthState } from "@xera-ai/core";
|
|
311
|
+
function stagePlaywrightState(authDir, role) {
|
|
312
|
+
const entry = readAuthState(authDir, role);
|
|
313
|
+
if (!entry)
|
|
314
|
+
throw new Error(`No auth state for role "${role}" in ${authDir}`);
|
|
315
|
+
const cacheDir = join4(authDir, ".cache");
|
|
316
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
317
|
+
const stagedPath = join4(cacheDir, `${role}.json`);
|
|
318
|
+
writeFileSync2(stagedPath, JSON.stringify(entry.payload));
|
|
319
|
+
return stagedPath;
|
|
320
|
+
}
|
|
294
321
|
// src/auth-setup/runner.ts
|
|
295
322
|
import { pathToFileURL } from "url";
|
|
296
323
|
import { writeAuthState } from "@xera-ai/core";
|
|
@@ -318,20 +345,6 @@ async function runAuthSetup(input) {
|
|
|
318
345
|
await context.close();
|
|
319
346
|
}
|
|
320
347
|
}
|
|
321
|
-
// src/auth-setup/playwright-state.ts
|
|
322
|
-
import { writeFileSync as writeFileSync2, mkdirSync } from "fs";
|
|
323
|
-
import { join as join4 } from "path";
|
|
324
|
-
import { readAuthState } from "@xera-ai/core";
|
|
325
|
-
function stagePlaywrightState(authDir, role) {
|
|
326
|
-
const entry = readAuthState(authDir, role);
|
|
327
|
-
if (!entry)
|
|
328
|
-
throw new Error(`No auth state for role "${role}" in ${authDir}`);
|
|
329
|
-
const cacheDir = join4(authDir, ".cache");
|
|
330
|
-
mkdirSync(cacheDir, { recursive: true });
|
|
331
|
-
const stagedPath = join4(cacheDir, `${role}.json`);
|
|
332
|
-
writeFileSync2(stagedPath, JSON.stringify(entry.payload));
|
|
333
|
-
return stagedPath;
|
|
334
|
-
}
|
|
335
348
|
// src/generator/gherkin-validate.ts
|
|
336
349
|
import { AstBuilder, GherkinClassicTokenMatcher, Parser } from "@cucumber/gherkin";
|
|
337
350
|
import { IdGenerator } from "@cucumber/messages";
|
|
@@ -355,47 +368,9 @@ function validateGherkin(content) {
|
|
|
355
368
|
return { ok: false, errors };
|
|
356
369
|
}
|
|
357
370
|
}
|
|
358
|
-
// src/generator/typecheck.ts
|
|
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
|
-
}
|
|
374
|
-
async function typecheckTicket(ticketDir) {
|
|
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" });
|
|
385
|
-
if (proc.status === 0)
|
|
386
|
-
return { ok: true, errors: [] };
|
|
387
|
-
const out = `${proc.stdout ?? ""}${proc.stderr ?? ""}`;
|
|
388
|
-
const allErrors = out.split(`
|
|
389
|
-
`).filter((line) => /error TS\d+/.test(line));
|
|
390
|
-
const ticketErrors = allErrors.filter((line) => line.includes(ticketDir));
|
|
391
|
-
return {
|
|
392
|
-
ok: false,
|
|
393
|
-
errors: ticketErrors.length > 0 ? ticketErrors : allErrors
|
|
394
|
-
};
|
|
395
|
-
}
|
|
396
371
|
// src/generator/lint.ts
|
|
397
|
-
import {
|
|
398
|
-
import { join as
|
|
372
|
+
import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync3 } from "fs";
|
|
373
|
+
import { join as join5 } from "path";
|
|
399
374
|
|
|
400
375
|
// src/generator/selector-rules.ts
|
|
401
376
|
var AUTO_CLASS_RE = /\.(?:Mui|css|ant|chakra|MuiButton)[A-Za-z]*-[A-Za-z0-9_]*-[A-Za-z0-9_]{3,}/;
|
|
@@ -410,16 +385,31 @@ function lintSelectors(source) {
|
|
|
410
385
|
const text = lines[i];
|
|
411
386
|
const prev = lines[i - 1] ?? "";
|
|
412
387
|
if (XPATH_RE.test(text)) {
|
|
413
|
-
warnings.push({
|
|
388
|
+
warnings.push({
|
|
389
|
+
rule: "no-xpath",
|
|
390
|
+
line: i + 1,
|
|
391
|
+
text,
|
|
392
|
+
message: "XPath selectors are forbidden in v0.1."
|
|
393
|
+
});
|
|
414
394
|
continue;
|
|
415
395
|
}
|
|
416
396
|
const cssMatch = LOCATOR_CSS_RE.exec(text);
|
|
417
397
|
if (cssMatch) {
|
|
418
398
|
const sel = cssMatch[1];
|
|
419
399
|
if (AUTO_CLASS_RE.test(sel)) {
|
|
420
|
-
warnings.push({
|
|
400
|
+
warnings.push({
|
|
401
|
+
rule: "no-auto-classname",
|
|
402
|
+
line: i + 1,
|
|
403
|
+
text,
|
|
404
|
+
message: `Auto-generated class name "${sel}" \u2014 refactor to role/label/test-id.`
|
|
405
|
+
});
|
|
421
406
|
} else if (!ALLOW_CSS_RE.test(prev)) {
|
|
422
|
-
warnings.push({
|
|
407
|
+
warnings.push({
|
|
408
|
+
rule: "prefer-role-over-css",
|
|
409
|
+
line: i + 1,
|
|
410
|
+
text,
|
|
411
|
+
message: `Prefer getByRole/getByLabel over CSS "${sel}". If unavoidable, add "// xera-allow-css: <reason>" on the previous line.`
|
|
412
|
+
});
|
|
423
413
|
}
|
|
424
414
|
}
|
|
425
415
|
}
|
|
@@ -428,11 +418,11 @@ function lintSelectors(source) {
|
|
|
428
418
|
|
|
429
419
|
// src/generator/lint.ts
|
|
430
420
|
function listTsFiles(dir) {
|
|
431
|
-
if (!
|
|
421
|
+
if (!existsSync2(dir))
|
|
432
422
|
return [];
|
|
433
423
|
const out = [];
|
|
434
424
|
for (const name of readdirSync(dir, { withFileTypes: true })) {
|
|
435
|
-
const full =
|
|
425
|
+
const full = join5(dir, name.name);
|
|
436
426
|
if (name.isDirectory())
|
|
437
427
|
out.push(...listTsFiles(full));
|
|
438
428
|
else if (name.name.endsWith(".ts"))
|
|
@@ -452,18 +442,18 @@ async function lintTicket(ticketDir) {
|
|
|
452
442
|
return { ok: warnings.length === 0, warnings };
|
|
453
443
|
}
|
|
454
444
|
// src/generator/pom-scan.ts
|
|
455
|
-
import { existsSync as
|
|
456
|
-
import { join as
|
|
445
|
+
import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync as readFileSync4 } from "fs";
|
|
446
|
+
import { join as join6 } from "path";
|
|
457
447
|
var CLASS_RE = /export\s+class\s+([A-Z][A-Za-z0-9_]*)/g;
|
|
458
448
|
function scanSharedPoms(repoRoot) {
|
|
459
|
-
const dir =
|
|
460
|
-
if (!
|
|
449
|
+
const dir = join6(repoRoot, "shared", "page-objects");
|
|
450
|
+
if (!existsSync3(dir))
|
|
461
451
|
return [];
|
|
462
452
|
const found = [];
|
|
463
453
|
for (const entry of readdirSync2(dir, { withFileTypes: true })) {
|
|
464
454
|
if (!entry.isFile() || !entry.name.endsWith(".ts"))
|
|
465
455
|
continue;
|
|
466
|
-
const path =
|
|
456
|
+
const path = join6(dir, entry.name);
|
|
467
457
|
const src = readFileSync4(path, "utf8");
|
|
468
458
|
for (const m of src.matchAll(CLASS_RE)) {
|
|
469
459
|
found.push({ className: m[1], absolutePath: path });
|
|
@@ -472,28 +462,66 @@ function scanSharedPoms(repoRoot) {
|
|
|
472
462
|
return found;
|
|
473
463
|
}
|
|
474
464
|
// src/generator/promote.ts
|
|
475
|
-
import { existsSync as
|
|
476
|
-
import { join as
|
|
465
|
+
import { existsSync as existsSync4, readFileSync as readFileSync5, renameSync, writeFileSync as writeFileSync3 } from "fs";
|
|
466
|
+
import { join as join7 } from "path";
|
|
477
467
|
async function promotePom(input) {
|
|
478
|
-
const fromDir =
|
|
479
|
-
const toDir =
|
|
468
|
+
const fromDir = join7(input.repoRoot, ".xera", input.ticket, "page-objects");
|
|
469
|
+
const toDir = join7(input.repoRoot, "shared", "page-objects");
|
|
480
470
|
const file = `${input.className}.ts`;
|
|
481
|
-
const fromPath =
|
|
482
|
-
const toPath =
|
|
483
|
-
if (!
|
|
471
|
+
const fromPath = join7(fromDir, file);
|
|
472
|
+
const toPath = join7(toDir, file);
|
|
473
|
+
if (!existsSync4(fromPath)) {
|
|
484
474
|
throw new Error(`POM ${file} not found at ${fromPath}`);
|
|
485
475
|
}
|
|
486
|
-
if (
|
|
476
|
+
if (existsSync4(toPath)) {
|
|
487
477
|
throw new Error(`POM ${file} already exists at ${toPath}. Reconcile manually before promoting.`);
|
|
488
478
|
}
|
|
489
479
|
renameSync(fromPath, toPath);
|
|
490
|
-
const specPath =
|
|
491
|
-
if (
|
|
480
|
+
const specPath = join7(input.repoRoot, ".xera", input.ticket, "spec.ts");
|
|
481
|
+
if (existsSync4(specPath)) {
|
|
492
482
|
const src = readFileSync5(specPath, "utf8");
|
|
493
483
|
const updated = src.replace(new RegExp(`from\\s+['"]\\./page-objects/${input.className}['"]`, "g"), `from '../../shared/page-objects/${input.className}'`);
|
|
494
484
|
writeFileSync3(specPath, updated);
|
|
495
485
|
}
|
|
496
486
|
}
|
|
487
|
+
// src/generator/typecheck.ts
|
|
488
|
+
import { spawnSync } from "child_process";
|
|
489
|
+
import { existsSync as existsSync5 } from "fs";
|
|
490
|
+
import { dirname, join as join8 } from "path";
|
|
491
|
+
function findNearestTsconfig(start) {
|
|
492
|
+
let dir = start;
|
|
493
|
+
while (true) {
|
|
494
|
+
const candidate = join8(dir, "tsconfig.json");
|
|
495
|
+
if (existsSync5(candidate))
|
|
496
|
+
return candidate;
|
|
497
|
+
const parent = dirname(dir);
|
|
498
|
+
if (parent === dir)
|
|
499
|
+
return null;
|
|
500
|
+
dir = parent;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
async function typecheckTicket(ticketDir) {
|
|
504
|
+
const tsconfig = findNearestTsconfig(ticketDir);
|
|
505
|
+
if (!tsconfig) {
|
|
506
|
+
return {
|
|
507
|
+
ok: false,
|
|
508
|
+
errors: [
|
|
509
|
+
`No tsconfig.json found walking up from ${ticketDir}. Run \`xera init\` to scaffold one at the project root.`
|
|
510
|
+
]
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
const proc = spawnSync("npx", ["tsc", "--noEmit", "-p", tsconfig], { encoding: "utf8" });
|
|
514
|
+
if (proc.status === 0)
|
|
515
|
+
return { ok: true, errors: [] };
|
|
516
|
+
const out = `${proc.stdout ?? ""}${proc.stderr ?? ""}`;
|
|
517
|
+
const allErrors = out.split(`
|
|
518
|
+
`).filter((line) => /error TS\d+/.test(line));
|
|
519
|
+
const ticketErrors = allErrors.filter((line) => line.includes(ticketDir));
|
|
520
|
+
return {
|
|
521
|
+
ok: false,
|
|
522
|
+
errors: ticketErrors.length > 0 ? ticketErrors : allErrors
|
|
523
|
+
};
|
|
524
|
+
}
|
|
497
525
|
export {
|
|
498
526
|
validateGherkin,
|
|
499
527
|
unzipTrace,
|
|
@@ -516,6 +544,10 @@ export {
|
|
|
516
544
|
WebAdapter,
|
|
517
545
|
SENSITIVE_HEADERS,
|
|
518
546
|
SENSITIVE_BODY_KEYS,
|
|
547
|
+
PHONE_RE_G,
|
|
548
|
+
PHONE_RE,
|
|
519
549
|
JWT_RE,
|
|
550
|
+
EMAIL_RE_G,
|
|
551
|
+
EMAIL_RE,
|
|
520
552
|
CREDIT_CARD_RE
|
|
521
553
|
};
|
|
@@ -2,6 +2,10 @@ export declare const SENSITIVE_HEADERS: readonly string[];
|
|
|
2
2
|
export declare const SENSITIVE_BODY_KEYS: readonly RegExp[];
|
|
3
3
|
export declare const JWT_RE: RegExp;
|
|
4
4
|
export declare const CREDIT_CARD_RE: RegExp;
|
|
5
|
+
export declare const EMAIL_RE: RegExp;
|
|
6
|
+
export declare const PHONE_RE: RegExp;
|
|
7
|
+
export declare const EMAIL_RE_G: RegExp;
|
|
8
|
+
export declare const PHONE_RE_G: RegExp;
|
|
5
9
|
export declare function scrubHeaders(headers: Record<string, string>): Record<string, string>;
|
|
6
10
|
export declare function scrubBodyJson(body: unknown): unknown;
|
|
7
11
|
export declare function scrubFreeText(s: string): string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xera-ai/web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -11,16 +11,19 @@
|
|
|
11
11
|
"types": "./dist/index.d.ts"
|
|
12
12
|
}
|
|
13
13
|
},
|
|
14
|
-
"files": [
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
15
18
|
"scripts": {
|
|
16
19
|
"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
20
|
"typecheck": "tsc --noEmit"
|
|
18
21
|
},
|
|
19
22
|
"dependencies": {
|
|
20
|
-
"@cucumber/gherkin": "
|
|
21
|
-
"@cucumber/messages": "
|
|
22
|
-
"@playwright/test": "1.
|
|
23
|
-
"@xera-ai/core": "^0.
|
|
24
|
-
"fflate": "0.8.
|
|
23
|
+
"@cucumber/gherkin": "39.1.0",
|
|
24
|
+
"@cucumber/messages": "32.3.1",
|
|
25
|
+
"@playwright/test": "1.60.0",
|
|
26
|
+
"@xera-ai/core": "^0.4.0",
|
|
27
|
+
"fflate": "0.8.3"
|
|
25
28
|
}
|
|
26
29
|
}
|
package/src/adapter.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import type {
|
|
3
|
+
DoctorReport,
|
|
4
|
+
ExecuteInput,
|
|
5
|
+
GenerateInput,
|
|
6
|
+
GenerateResult,
|
|
7
|
+
RunResult,
|
|
8
|
+
TestAdapter,
|
|
9
|
+
} from '@xera-ai/core/adapter';
|
|
1
10
|
import { runPlaywright } from './executor';
|
|
2
11
|
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
12
|
|
|
6
13
|
export const WebAdapter: TestAdapter = {
|
|
7
14
|
id: 'web',
|
|
@@ -22,7 +29,7 @@ export const WebAdapter: TestAdapter = {
|
|
|
22
29
|
return {
|
|
23
30
|
runId: input.runId,
|
|
24
31
|
outcome: normalized.outcome,
|
|
25
|
-
scenarios: normalized.scenarios.map(s => {
|
|
32
|
+
scenarios: normalized.scenarios.map((s) => {
|
|
26
33
|
const out: RunResult['scenarios'][number] = { name: s.name, outcome: s.outcome };
|
|
27
34
|
if (s.failure !== undefined) out.failure = s.failure;
|
|
28
35
|
return out;
|
|
@@ -39,8 +46,12 @@ export const WebAdapter: TestAdapter = {
|
|
|
39
46
|
await import('@playwright/test');
|
|
40
47
|
checks.push({ name: '@playwright/test installed', ok: true });
|
|
41
48
|
} catch {
|
|
42
|
-
checks.push({
|
|
49
|
+
checks.push({
|
|
50
|
+
name: '@playwright/test installed',
|
|
51
|
+
ok: false,
|
|
52
|
+
message: 'Run `bun add -D @playwright/test`.',
|
|
53
|
+
});
|
|
43
54
|
}
|
|
44
|
-
return { ok: checks.every(c => c.ok), checks };
|
|
55
|
+
return { ok: checks.every((c) => c.ok), checks };
|
|
45
56
|
},
|
|
46
57
|
};
|
package/src/auth-setup/runner.ts
CHANGED
package/src/executor/index.ts
CHANGED
|
@@ -33,7 +33,10 @@ export async function runPlaywright(input: RunPlaywrightInput): Promise<RunPlayw
|
|
|
33
33
|
outputDir: input.outputDir,
|
|
34
34
|
});
|
|
35
35
|
const spawn = input.spawn ?? defaultSpawn;
|
|
36
|
-
const { exitCode } = await spawn('npx', ['playwright', ...args], {
|
|
36
|
+
const { exitCode } = await spawn('npx', ['playwright', ...args], {
|
|
37
|
+
...process.env,
|
|
38
|
+
...input.env,
|
|
39
|
+
});
|
|
37
40
|
return {
|
|
38
41
|
outcome: exitCode === 0 ? 'PASS' : 'FAIL',
|
|
39
42
|
rawReportPath: join(input.outputDir, 'report.json'),
|
package/src/generator/lint.ts
CHANGED
package/src/generator/promote.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync,
|
|
1
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
|
|
4
4
|
export interface PromoteInput {
|
|
@@ -18,7 +18,9 @@ export async function promotePom(input: PromoteInput): Promise<void> {
|
|
|
18
18
|
throw new Error(`POM ${file} not found at ${fromPath}`);
|
|
19
19
|
}
|
|
20
20
|
if (existsSync(toPath)) {
|
|
21
|
-
throw new Error(
|
|
21
|
+
throw new Error(
|
|
22
|
+
`POM ${file} already exists at ${toPath}. Reconcile manually before promoting.`,
|
|
23
|
+
);
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
renameSync(fromPath, toPath);
|
|
@@ -17,16 +17,31 @@ export function lintSelectors(source: string): { warnings: SelectorWarning[] } {
|
|
|
17
17
|
const text = lines[i]!;
|
|
18
18
|
const prev = lines[i - 1] ?? '';
|
|
19
19
|
if (XPATH_RE.test(text)) {
|
|
20
|
-
warnings.push({
|
|
20
|
+
warnings.push({
|
|
21
|
+
rule: 'no-xpath',
|
|
22
|
+
line: i + 1,
|
|
23
|
+
text,
|
|
24
|
+
message: 'XPath selectors are forbidden in v0.1.',
|
|
25
|
+
});
|
|
21
26
|
continue;
|
|
22
27
|
}
|
|
23
28
|
const cssMatch = LOCATOR_CSS_RE.exec(text);
|
|
24
29
|
if (cssMatch) {
|
|
25
30
|
const sel = cssMatch[1]!;
|
|
26
31
|
if (AUTO_CLASS_RE.test(sel)) {
|
|
27
|
-
warnings.push({
|
|
32
|
+
warnings.push({
|
|
33
|
+
rule: 'no-auto-classname',
|
|
34
|
+
line: i + 1,
|
|
35
|
+
text,
|
|
36
|
+
message: `Auto-generated class name "${sel}" — refactor to role/label/test-id.`,
|
|
37
|
+
});
|
|
28
38
|
} else if (!ALLOW_CSS_RE.test(prev)) {
|
|
29
|
-
warnings.push({
|
|
39
|
+
warnings.push({
|
|
40
|
+
rule: 'prefer-role-over-css',
|
|
41
|
+
line: i + 1,
|
|
42
|
+
text,
|
|
43
|
+
message: `Prefer getByRole/getByLabel over CSS "${sel}". If unavoidable, add "// xera-allow-css: <reason>" on the previous line.`,
|
|
44
|
+
});
|
|
30
45
|
}
|
|
31
46
|
}
|
|
32
47
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
export * from './adapter';
|
|
2
2
|
export * from './auth-setup/define';
|
|
3
|
-
export * from './auth-setup/runner';
|
|
4
3
|
export * from './auth-setup/playwright-state';
|
|
4
|
+
export * from './auth-setup/runner';
|
|
5
5
|
export * from './executor';
|
|
6
6
|
export * from './executor/playwright-args';
|
|
7
|
+
export * from './generator/gherkin-validate';
|
|
8
|
+
export * from './generator/lint';
|
|
9
|
+
export * from './generator/pom-scan';
|
|
10
|
+
export * from './generator/promote';
|
|
11
|
+
export * from './generator/selector-rules';
|
|
12
|
+
export * from './generator/typecheck';
|
|
7
13
|
export * from './trace-normalizer/normalize';
|
|
8
14
|
export * from './trace-normalizer/parse';
|
|
9
15
|
export * from './trace-normalizer/scrub';
|
|
10
16
|
export * from './trace-normalizer/scrub-rules';
|
|
11
17
|
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';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { parsePlaywrightReport } from './parse';
|
|
4
|
-
import {
|
|
4
|
+
import { type NormalizedNetworkEntry, type NormalizedRun, scrub } from './scrub';
|
|
5
5
|
import { unzipTrace } from './unzip';
|
|
6
6
|
|
|
7
7
|
export interface NormalizeRunInput {
|
|
@@ -20,7 +20,10 @@ interface TraceNetworkEntry {
|
|
|
20
20
|
responseBody?: unknown;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
interface TraceConsoleEntry {
|
|
23
|
+
interface TraceConsoleEntry {
|
|
24
|
+
type: 'console';
|
|
25
|
+
text: string;
|
|
26
|
+
}
|
|
24
27
|
|
|
25
28
|
export async function normalizeRun(input: NormalizeRunInput): Promise<NormalizedRun> {
|
|
26
29
|
const reportPath = join(input.runDir, 'report.json');
|
|
@@ -34,17 +37,27 @@ export async function normalizeRun(input: NormalizeRunInput): Promise<Normalized
|
|
|
34
37
|
const networkFile = Object.entries(files).find(([k]) => k.endsWith('.network'))?.[1];
|
|
35
38
|
const traceFile = Object.entries(files).find(([k]) => k.endsWith('.trace'))?.[1];
|
|
36
39
|
const network: TraceNetworkEntry[] = networkFile
|
|
37
|
-
? networkFile
|
|
40
|
+
? networkFile
|
|
41
|
+
.trim()
|
|
42
|
+
.split('\n')
|
|
43
|
+
.filter(Boolean)
|
|
44
|
+
.map((l) => JSON.parse(l))
|
|
45
|
+
.filter((e: any) => e.type === 'request')
|
|
38
46
|
: [];
|
|
39
47
|
const consoleEvents: TraceConsoleEntry[] = traceFile
|
|
40
|
-
? traceFile
|
|
48
|
+
? traceFile
|
|
49
|
+
.trim()
|
|
50
|
+
.split('\n')
|
|
51
|
+
.filter(Boolean)
|
|
52
|
+
.map((l) => JSON.parse(l))
|
|
53
|
+
.filter((e: any) => e.type === 'console')
|
|
41
54
|
: [];
|
|
42
55
|
|
|
43
56
|
// Attach to each failing scenario (all entries — v0.1 doesn't yet correlate by step time)
|
|
44
57
|
for (const sc of normalized.scenarios) {
|
|
45
58
|
if (sc.outcome !== 'FAIL') continue;
|
|
46
59
|
sc.failure = sc.failure ?? {};
|
|
47
|
-
sc.failure.networkAtFailure = network.map(n => {
|
|
60
|
+
sc.failure.networkAtFailure = network.map((n) => {
|
|
48
61
|
const entry: NormalizedNetworkEntry = { method: n.method, url: n.url, status: n.status };
|
|
49
62
|
if (n.requestHeaders !== undefined) entry.requestHeaders = n.requestHeaders;
|
|
50
63
|
if (n.responseHeaders !== undefined) entry.responseHeaders = n.responseHeaders;
|
|
@@ -52,7 +65,7 @@ export async function normalizeRun(input: NormalizeRunInput): Promise<Normalized
|
|
|
52
65
|
if (n.responseBody !== undefined) entry.responseBody = n.responseBody;
|
|
53
66
|
return entry;
|
|
54
67
|
});
|
|
55
|
-
sc.failure.consoleAtFailure = consoleEvents.map(c => c.text);
|
|
68
|
+
sc.failure.consoleAtFailure = consoleEvents.map((c) => c.text);
|
|
56
69
|
}
|
|
57
70
|
}
|
|
58
71
|
|
|
@@ -1,11 +1,33 @@
|
|
|
1
1
|
import type { NormalizedRun, NormalizedScenario } from './scrub';
|
|
2
2
|
|
|
3
|
-
interface PWAttachment {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
interface
|
|
3
|
+
interface PWAttachment {
|
|
4
|
+
name: string;
|
|
5
|
+
path?: string;
|
|
6
|
+
contentType?: string;
|
|
7
|
+
}
|
|
8
|
+
interface PWResult {
|
|
9
|
+
status: string;
|
|
10
|
+
duration: number;
|
|
11
|
+
error?: { message?: string; stack?: string };
|
|
12
|
+
attachments?: PWAttachment[];
|
|
13
|
+
}
|
|
14
|
+
interface PWTest {
|
|
15
|
+
results: PWResult[];
|
|
16
|
+
}
|
|
17
|
+
interface PWSpec {
|
|
18
|
+
title: string;
|
|
19
|
+
ok: boolean;
|
|
20
|
+
tests: PWTest[];
|
|
21
|
+
}
|
|
22
|
+
interface PWSuite {
|
|
23
|
+
title: string;
|
|
24
|
+
specs?: PWSpec[];
|
|
25
|
+
suites?: PWSuite[];
|
|
26
|
+
}
|
|
27
|
+
interface PWReport {
|
|
28
|
+
stats: { unexpected: number };
|
|
29
|
+
suites: PWSuite[];
|
|
30
|
+
}
|
|
9
31
|
|
|
10
32
|
function* flatSpecs(suites: PWSuite[]): Generator<PWSpec> {
|
|
11
33
|
for (const s of suites) {
|
|
@@ -18,13 +40,16 @@ export function parsePlaywrightReport(report: PWReport, runId: string): Normaliz
|
|
|
18
40
|
const scenarios: NormalizedScenario[] = [];
|
|
19
41
|
for (const spec of flatSpecs(report.suites)) {
|
|
20
42
|
const lastResult = spec.tests[0]?.results[0];
|
|
21
|
-
const outcome: 'PASS' | 'FAIL' | 'SKIPPED' =
|
|
22
|
-
|
|
23
|
-
lastResult.status === 'passed'
|
|
24
|
-
|
|
43
|
+
const outcome: 'PASS' | 'FAIL' | 'SKIPPED' = !lastResult
|
|
44
|
+
? 'SKIPPED'
|
|
45
|
+
: lastResult.status === 'passed'
|
|
46
|
+
? 'PASS'
|
|
47
|
+
: lastResult.status === 'skipped'
|
|
48
|
+
? 'SKIPPED'
|
|
49
|
+
: 'FAIL';
|
|
25
50
|
const sc: NormalizedScenario = { name: spec.title, outcome };
|
|
26
51
|
if (outcome === 'FAIL' && lastResult) {
|
|
27
|
-
const screenshot = lastResult.attachments?.find(a => a.name === 'screenshot')?.path;
|
|
52
|
+
const screenshot = lastResult.attachments?.find((a) => a.name === 'screenshot')?.path;
|
|
28
53
|
const failure: NormalizedScenario['failure'] = {};
|
|
29
54
|
if (lastResult.error?.message !== undefined) failure!.errorMessage = lastResult.error.message;
|
|
30
55
|
if (screenshot !== undefined) failure!.screenshotPath = screenshot;
|
|
@@ -24,9 +24,14 @@ export const SENSITIVE_BODY_KEYS: readonly RegExp[] = [
|
|
|
24
24
|
|
|
25
25
|
export const JWT_RE = /\beyJ[A-Za-z0-9_-]{7,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{5,}\b/;
|
|
26
26
|
export const CREDIT_CARD_RE = /\b(?:\d{4}[-\s]?){3}\d{4}\b/;
|
|
27
|
+
export const EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
|
|
28
|
+
// E.164-ish phone with optional + and separators. Conservative: require at least 7 digits.
|
|
29
|
+
export const PHONE_RE = /(?:\+?\d[\d\s().-]{6,}\d)/;
|
|
27
30
|
|
|
28
31
|
const JWT_RE_G = new RegExp(JWT_RE.source, 'g');
|
|
29
32
|
const CREDIT_CARD_RE_G = new RegExp(CREDIT_CARD_RE.source, 'g');
|
|
33
|
+
export const EMAIL_RE_G = new RegExp(EMAIL_RE.source, 'g');
|
|
34
|
+
export const PHONE_RE_G = new RegExp(PHONE_RE.source, 'g');
|
|
30
35
|
|
|
31
36
|
const REDACTED = '[REDACTED]';
|
|
32
37
|
|
|
@@ -43,7 +48,7 @@ export function scrubBodyJson(body: unknown): unknown {
|
|
|
43
48
|
if (body && typeof body === 'object') {
|
|
44
49
|
const out: Record<string, unknown> = {};
|
|
45
50
|
for (const [k, v] of Object.entries(body)) {
|
|
46
|
-
if (SENSITIVE_BODY_KEYS.some(re => re.test(k))) {
|
|
51
|
+
if (SENSITIVE_BODY_KEYS.some((re) => re.test(k))) {
|
|
47
52
|
out[k] = REDACTED;
|
|
48
53
|
} else {
|
|
49
54
|
out[k] = scrubBodyJson(v);
|
|
@@ -56,5 +61,9 @@ export function scrubBodyJson(body: unknown): unknown {
|
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
export function scrubFreeText(s: string): string {
|
|
59
|
-
return s
|
|
64
|
+
return s
|
|
65
|
+
.replace(JWT_RE_G, REDACTED)
|
|
66
|
+
.replace(CREDIT_CARD_RE_G, REDACTED)
|
|
67
|
+
.replace(EMAIL_RE_G, REDACTED)
|
|
68
|
+
.replace(PHONE_RE_G, REDACTED);
|
|
60
69
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { scrubBodyJson, scrubFreeText, scrubHeaders } from './scrub-rules';
|
|
2
2
|
|
|
3
3
|
export interface NormalizedNetworkEntry {
|
|
4
4
|
method: string;
|
|
@@ -38,7 +38,10 @@ function countScrubbed(before: unknown, after: unknown): number {
|
|
|
38
38
|
if (before && after && typeof before === 'object' && typeof after === 'object') {
|
|
39
39
|
let n = 0;
|
|
40
40
|
for (const k of Object.keys(before as Record<string, unknown>)) {
|
|
41
|
-
n += countScrubbed(
|
|
41
|
+
n += countScrubbed(
|
|
42
|
+
(before as Record<string, unknown>)[k],
|
|
43
|
+
(after as Record<string, unknown>)[k],
|
|
44
|
+
);
|
|
42
45
|
}
|
|
43
46
|
return n;
|
|
44
47
|
}
|
|
@@ -65,7 +68,7 @@ export function scrub(run: NormalizedRun): NormalizedRun {
|
|
|
65
68
|
);
|
|
66
69
|
}
|
|
67
70
|
if (f.networkAtFailure) {
|
|
68
|
-
newF.networkAtFailure = f.networkAtFailure.map(n => {
|
|
71
|
+
newF.networkAtFailure = f.networkAtFailure.map((n) => {
|
|
69
72
|
const reqHeaders = n.requestHeaders ? scrubHeaders(n.requestHeaders) : undefined;
|
|
70
73
|
const resHeaders = n.responseHeaders ? scrubHeaders(n.responseHeaders) : undefined;
|
|
71
74
|
const reqBody = n.requestBody !== undefined ? scrubBodyJson(n.requestBody) : undefined;
|
|
@@ -12,7 +12,12 @@ export function unzipTrace(tracePath: string): TraceEntries {
|
|
|
12
12
|
const files: Record<string, string> = {};
|
|
13
13
|
for (const [name, data] of Object.entries(entries)) {
|
|
14
14
|
if (name.endsWith('/')) continue;
|
|
15
|
-
if (
|
|
15
|
+
if (
|
|
16
|
+
name.endsWith('.network') ||
|
|
17
|
+
name.endsWith('.trace') ||
|
|
18
|
+
name.endsWith('.txt') ||
|
|
19
|
+
name.endsWith('.json')
|
|
20
|
+
) {
|
|
16
21
|
files[name] = new TextDecoder().decode(data);
|
|
17
22
|
}
|
|
18
23
|
}
|