@xera-ai/web 0.1.5 → 0.1.7
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/core/src/adapter/types.d.ts +69 -0
- package/dist/core/src/artifact/hash.d.ts +3 -0
- package/dist/core/src/artifact/meta.d.ts +45 -0
- package/dist/core/src/artifact/paths.d.ts +24 -0
- package/dist/core/src/artifact/status.d.ts +95 -0
- package/dist/core/src/auth/encrypt.d.ts +3 -0
- package/dist/core/src/auth/key.d.ts +2 -0
- package/dist/core/src/auth/refresh.d.ts +8 -0
- package/dist/core/src/auth/state.d.ts +23 -0
- package/dist/core/src/config/define.d.ts +2 -0
- package/dist/core/src/config/load.d.ts +2 -0
- package/dist/core/src/config/schema.d.ts +325 -0
- package/dist/core/src/index.d.ts +19 -0
- package/dist/core/src/jira/client.d.ts +10 -0
- package/dist/core/src/jira/fields.d.ts +6 -0
- package/dist/core/src/jira/mcp-backend.d.ts +2 -0
- package/dist/core/src/jira/rest-backend.d.ts +7 -0
- package/dist/core/src/jira/retry.d.ts +7 -0
- package/dist/core/src/jira/types.d.ts +28 -0
- package/dist/core/src/lock/file-lock.d.ts +11 -0
- package/dist/core/src/logging/ndjson-logger.d.ts +10 -0
- package/dist/index.js +91 -32
- package/dist/web/src/generator/typecheck.d.ts +10 -0
- package/dist/{trace-normalizer → web/src/trace-normalizer}/scrub-rules.d.ts +4 -0
- package/package.json +1 -1
- 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 +2 -2
- package/src/generator/pom-scan.ts +1 -1
- package/src/generator/promote.ts +4 -2
- package/src/generator/selector-rules.ts +18 -3
- package/src/generator/typecheck.ts +44 -4
- 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/generator/typecheck.d.ts +0 -5
- /package/dist/{adapter.d.ts → web/src/adapter.d.ts} +0 -0
- /package/dist/{auth-setup → web/src/auth-setup}/define.d.ts +0 -0
- /package/dist/{auth-setup → web/src/auth-setup}/playwright-state.d.ts +0 -0
- /package/dist/{auth-setup → web/src/auth-setup}/runner.d.ts +0 -0
- /package/dist/{executor → web/src/executor}/index.d.ts +0 -0
- /package/dist/{executor → web/src/executor}/playwright-args.d.ts +0 -0
- /package/dist/{generator → web/src/generator}/gherkin-validate.d.ts +0 -0
- /package/dist/{generator → web/src/generator}/lint.d.ts +0 -0
- /package/dist/{generator → web/src/generator}/pom-scan.d.ts +0 -0
- /package/dist/{generator → web/src/generator}/promote.d.ts +0 -0
- /package/dist/{generator → web/src/generator}/selector-rules.d.ts +0 -0
- /package/dist/{index.d.ts → web/src/index.d.ts} +0 -0
- /package/dist/{trace-normalizer → web/src/trace-normalizer}/normalize.d.ts +0 -0
- /package/dist/{trace-normalizer → web/src/trace-normalizer}/parse.d.ts +0 -0
- /package/dist/{trace-normalizer → web/src/trace-normalizer}/scrub.d.ts +0 -0
- /package/dist/{trace-normalizer → web/src/trace-normalizer}/unzip.d.ts +0 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface JiraTicket {
|
|
2
|
+
key: string;
|
|
3
|
+
summary: string;
|
|
4
|
+
story: string;
|
|
5
|
+
acceptanceCriteria?: string;
|
|
6
|
+
attachments: Array<{
|
|
7
|
+
filename: string;
|
|
8
|
+
url: string;
|
|
9
|
+
}>;
|
|
10
|
+
raw: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
export interface JiraFieldMap {
|
|
13
|
+
story: string;
|
|
14
|
+
acceptanceCriteria?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface JiraClient {
|
|
17
|
+
readonly backend: 'mcp' | 'rest';
|
|
18
|
+
fetchTicket(key: string, fields: JiraFieldMap): Promise<JiraTicket>;
|
|
19
|
+
postComment(key: string, body: string): Promise<{
|
|
20
|
+
id: string;
|
|
21
|
+
}>;
|
|
22
|
+
transitionStatus(key: string, statusName: string): Promise<void>;
|
|
23
|
+
listFields(sampleKey: string): Promise<Array<{
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
hasContent: boolean;
|
|
27
|
+
}>>;
|
|
28
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface LockData {
|
|
2
|
+
pid: number;
|
|
3
|
+
hostname: string;
|
|
4
|
+
started_at: string;
|
|
5
|
+
run_id: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function acquireLock(path: string, runId: string): boolean;
|
|
8
|
+
export declare function releaseLock(path: string): void;
|
|
9
|
+
export declare function readLock(path: string): LockData | null;
|
|
10
|
+
export declare function isLockStale(path: string): boolean;
|
|
11
|
+
export declare function forceUnlock(path: string): void;
|
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
|
}
|
|
@@ -319,7 +332,7 @@ async function runAuthSetup(input) {
|
|
|
319
332
|
}
|
|
320
333
|
}
|
|
321
334
|
// src/auth-setup/playwright-state.ts
|
|
322
|
-
import { writeFileSync as writeFileSync2
|
|
335
|
+
import { mkdirSync, writeFileSync as writeFileSync2 } from "fs";
|
|
323
336
|
import { join as join4 } from "path";
|
|
324
337
|
import { readAuthState } from "@xera-ai/core";
|
|
325
338
|
function stagePlaywrightState(authDir, role) {
|
|
@@ -357,18 +370,45 @@ function validateGherkin(content) {
|
|
|
357
370
|
}
|
|
358
371
|
// src/generator/typecheck.ts
|
|
359
372
|
import { spawnSync } from "child_process";
|
|
373
|
+
import { existsSync as existsSync2 } from "fs";
|
|
374
|
+
import { dirname, join as join5 } from "path";
|
|
375
|
+
function findNearestTsconfig(start) {
|
|
376
|
+
let dir = start;
|
|
377
|
+
while (true) {
|
|
378
|
+
const candidate = join5(dir, "tsconfig.json");
|
|
379
|
+
if (existsSync2(candidate))
|
|
380
|
+
return candidate;
|
|
381
|
+
const parent = dirname(dir);
|
|
382
|
+
if (parent === dir)
|
|
383
|
+
return null;
|
|
384
|
+
dir = parent;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
360
387
|
async function typecheckTicket(ticketDir) {
|
|
361
|
-
const
|
|
388
|
+
const tsconfig = findNearestTsconfig(ticketDir);
|
|
389
|
+
if (!tsconfig) {
|
|
390
|
+
return {
|
|
391
|
+
ok: false,
|
|
392
|
+
errors: [
|
|
393
|
+
`No tsconfig.json found walking up from ${ticketDir}. Run \`xera init\` to scaffold one at the project root.`
|
|
394
|
+
]
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
const proc = spawnSync("npx", ["tsc", "--noEmit", "-p", tsconfig], { encoding: "utf8" });
|
|
362
398
|
if (proc.status === 0)
|
|
363
399
|
return { ok: true, errors: [] };
|
|
364
|
-
const out =
|
|
365
|
-
const
|
|
400
|
+
const out = `${proc.stdout ?? ""}${proc.stderr ?? ""}`;
|
|
401
|
+
const allErrors = out.split(`
|
|
366
402
|
`).filter((line) => /error TS\d+/.test(line));
|
|
367
|
-
|
|
403
|
+
const ticketErrors = allErrors.filter((line) => line.includes(ticketDir));
|
|
404
|
+
return {
|
|
405
|
+
ok: false,
|
|
406
|
+
errors: ticketErrors.length > 0 ? ticketErrors : allErrors
|
|
407
|
+
};
|
|
368
408
|
}
|
|
369
409
|
// src/generator/lint.ts
|
|
370
|
-
import {
|
|
371
|
-
import { join as
|
|
410
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3, readdirSync } from "fs";
|
|
411
|
+
import { join as join6 } from "path";
|
|
372
412
|
|
|
373
413
|
// src/generator/selector-rules.ts
|
|
374
414
|
var AUTO_CLASS_RE = /\.(?:Mui|css|ant|chakra|MuiButton)[A-Za-z]*-[A-Za-z0-9_]*-[A-Za-z0-9_]{3,}/;
|
|
@@ -383,16 +423,31 @@ function lintSelectors(source) {
|
|
|
383
423
|
const text = lines[i];
|
|
384
424
|
const prev = lines[i - 1] ?? "";
|
|
385
425
|
if (XPATH_RE.test(text)) {
|
|
386
|
-
warnings.push({
|
|
426
|
+
warnings.push({
|
|
427
|
+
rule: "no-xpath",
|
|
428
|
+
line: i + 1,
|
|
429
|
+
text,
|
|
430
|
+
message: "XPath selectors are forbidden in v0.1."
|
|
431
|
+
});
|
|
387
432
|
continue;
|
|
388
433
|
}
|
|
389
434
|
const cssMatch = LOCATOR_CSS_RE.exec(text);
|
|
390
435
|
if (cssMatch) {
|
|
391
436
|
const sel = cssMatch[1];
|
|
392
437
|
if (AUTO_CLASS_RE.test(sel)) {
|
|
393
|
-
warnings.push({
|
|
438
|
+
warnings.push({
|
|
439
|
+
rule: "no-auto-classname",
|
|
440
|
+
line: i + 1,
|
|
441
|
+
text,
|
|
442
|
+
message: `Auto-generated class name "${sel}" \u2014 refactor to role/label/test-id.`
|
|
443
|
+
});
|
|
394
444
|
} else if (!ALLOW_CSS_RE.test(prev)) {
|
|
395
|
-
warnings.push({
|
|
445
|
+
warnings.push({
|
|
446
|
+
rule: "prefer-role-over-css",
|
|
447
|
+
line: i + 1,
|
|
448
|
+
text,
|
|
449
|
+
message: `Prefer getByRole/getByLabel over CSS "${sel}". If unavoidable, add "// xera-allow-css: <reason>" on the previous line.`
|
|
450
|
+
});
|
|
396
451
|
}
|
|
397
452
|
}
|
|
398
453
|
}
|
|
@@ -401,11 +456,11 @@ function lintSelectors(source) {
|
|
|
401
456
|
|
|
402
457
|
// src/generator/lint.ts
|
|
403
458
|
function listTsFiles(dir) {
|
|
404
|
-
if (!
|
|
459
|
+
if (!existsSync3(dir))
|
|
405
460
|
return [];
|
|
406
461
|
const out = [];
|
|
407
462
|
for (const name of readdirSync(dir, { withFileTypes: true })) {
|
|
408
|
-
const full =
|
|
463
|
+
const full = join6(dir, name.name);
|
|
409
464
|
if (name.isDirectory())
|
|
410
465
|
out.push(...listTsFiles(full));
|
|
411
466
|
else if (name.name.endsWith(".ts"))
|
|
@@ -425,18 +480,18 @@ async function lintTicket(ticketDir) {
|
|
|
425
480
|
return { ok: warnings.length === 0, warnings };
|
|
426
481
|
}
|
|
427
482
|
// src/generator/pom-scan.ts
|
|
428
|
-
import { existsSync as
|
|
429
|
-
import { join as
|
|
483
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4, readdirSync as readdirSync2 } from "fs";
|
|
484
|
+
import { join as join7 } from "path";
|
|
430
485
|
var CLASS_RE = /export\s+class\s+([A-Z][A-Za-z0-9_]*)/g;
|
|
431
486
|
function scanSharedPoms(repoRoot) {
|
|
432
|
-
const dir =
|
|
433
|
-
if (!
|
|
487
|
+
const dir = join7(repoRoot, "shared", "page-objects");
|
|
488
|
+
if (!existsSync4(dir))
|
|
434
489
|
return [];
|
|
435
490
|
const found = [];
|
|
436
491
|
for (const entry of readdirSync2(dir, { withFileTypes: true })) {
|
|
437
492
|
if (!entry.isFile() || !entry.name.endsWith(".ts"))
|
|
438
493
|
continue;
|
|
439
|
-
const path =
|
|
494
|
+
const path = join7(dir, entry.name);
|
|
440
495
|
const src = readFileSync4(path, "utf8");
|
|
441
496
|
for (const m of src.matchAll(CLASS_RE)) {
|
|
442
497
|
found.push({ className: m[1], absolutePath: path });
|
|
@@ -445,23 +500,23 @@ function scanSharedPoms(repoRoot) {
|
|
|
445
500
|
return found;
|
|
446
501
|
}
|
|
447
502
|
// src/generator/promote.ts
|
|
448
|
-
import { existsSync as
|
|
449
|
-
import { join as
|
|
503
|
+
import { existsSync as existsSync5, readFileSync as readFileSync5, renameSync, writeFileSync as writeFileSync3 } from "fs";
|
|
504
|
+
import { join as join8 } from "path";
|
|
450
505
|
async function promotePom(input) {
|
|
451
|
-
const fromDir =
|
|
452
|
-
const toDir =
|
|
506
|
+
const fromDir = join8(input.repoRoot, ".xera", input.ticket, "page-objects");
|
|
507
|
+
const toDir = join8(input.repoRoot, "shared", "page-objects");
|
|
453
508
|
const file = `${input.className}.ts`;
|
|
454
|
-
const fromPath =
|
|
455
|
-
const toPath =
|
|
456
|
-
if (!
|
|
509
|
+
const fromPath = join8(fromDir, file);
|
|
510
|
+
const toPath = join8(toDir, file);
|
|
511
|
+
if (!existsSync5(fromPath)) {
|
|
457
512
|
throw new Error(`POM ${file} not found at ${fromPath}`);
|
|
458
513
|
}
|
|
459
|
-
if (
|
|
514
|
+
if (existsSync5(toPath)) {
|
|
460
515
|
throw new Error(`POM ${file} already exists at ${toPath}. Reconcile manually before promoting.`);
|
|
461
516
|
}
|
|
462
517
|
renameSync(fromPath, toPath);
|
|
463
|
-
const specPath =
|
|
464
|
-
if (
|
|
518
|
+
const specPath = join8(input.repoRoot, ".xera", input.ticket, "spec.ts");
|
|
519
|
+
if (existsSync5(specPath)) {
|
|
465
520
|
const src = readFileSync5(specPath, "utf8");
|
|
466
521
|
const updated = src.replace(new RegExp(`from\\s+['"]\\./page-objects/${input.className}['"]`, "g"), `from '../../shared/page-objects/${input.className}'`);
|
|
467
522
|
writeFileSync3(specPath, updated);
|
|
@@ -489,6 +544,10 @@ export {
|
|
|
489
544
|
WebAdapter,
|
|
490
545
|
SENSITIVE_HEADERS,
|
|
491
546
|
SENSITIVE_BODY_KEYS,
|
|
547
|
+
PHONE_RE_G,
|
|
548
|
+
PHONE_RE,
|
|
492
549
|
JWT_RE,
|
|
550
|
+
EMAIL_RE_G,
|
|
551
|
+
EMAIL_RE,
|
|
493
552
|
CREDIT_CARD_RE
|
|
494
553
|
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface TypecheckResult {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
errors: string[];
|
|
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
|
+
*/
|
|
10
|
+
export declare function typecheckTicket(ticketDir: string): Promise<TypecheckResult>;
|
|
@@ -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
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
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { type SelectorWarning, lintSelectors } from './selector-rules';
|
|
4
4
|
|
|
5
5
|
export interface LintResult {
|
|
6
6
|
ok: boolean;
|
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
|
}
|
|
@@ -1,14 +1,54 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
2
4
|
|
|
3
5
|
export interface TypecheckResult {
|
|
4
6
|
ok: boolean;
|
|
5
7
|
errors: string[];
|
|
6
8
|
}
|
|
7
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
|
+
*/
|
|
8
31
|
export async function typecheckTicket(ticketDir: string): Promise<TypecheckResult> {
|
|
9
|
-
const
|
|
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' });
|
|
10
43
|
if (proc.status === 0) return { ok: true, errors: [] };
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
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
|
+
};
|
|
14
54
|
}
|
|
@@ -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;
|