@xera-ai/web 0.1.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/adapter.d.ts +2 -0
- package/dist/auth-setup/define.d.ts +15 -0
- package/dist/auth-setup/playwright-state.d.ts +1 -0
- package/dist/auth-setup/runner.d.ts +11 -0
- package/dist/executor/index.d.ts +17 -0
- package/dist/executor/playwright-args.d.ts +6 -0
- package/dist/generator/gherkin-validate.d.ts +8 -0
- package/dist/generator/lint.d.ts +8 -0
- package/dist/generator/pom-scan.d.ts +5 -0
- package/dist/generator/promote.d.ts +6 -0
- package/dist/generator/selector-rules.d.ts +9 -0
- package/dist/generator/typecheck.d.ts +5 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +494 -0
- package/dist/trace-normalizer/normalize.d.ts +6 -0
- package/dist/trace-normalizer/parse.d.ts +36 -0
- package/dist/trace-normalizer/scrub-rules.d.ts +7 -0
- package/dist/trace-normalizer/scrub.d.ts +28 -0
- package/dist/trace-normalizer/unzip.d.ts +5 -0
- package/package.json +26 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Page } from '@playwright/test';
|
|
2
|
+
export interface AuthRoleCreds {
|
|
3
|
+
email: string;
|
|
4
|
+
password: string;
|
|
5
|
+
}
|
|
6
|
+
export interface AuthSetupResult {
|
|
7
|
+
/** Optional explicit expiry hint, ms since epoch. */
|
|
8
|
+
expiresAt?: number;
|
|
9
|
+
}
|
|
10
|
+
export type AuthSetupFn = (page: Page, role: string, creds: AuthRoleCreds) => Promise<AuthSetupResult | void>;
|
|
11
|
+
/**
|
|
12
|
+
* Helper to type-narrow the user's auth setup function. Users import this in
|
|
13
|
+
* `shared/auth-setup.ts`.
|
|
14
|
+
*/
|
|
15
|
+
export declare function defineAuthSetup(fn: AuthSetupFn): AuthSetupFn;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function stagePlaywrightState(authDir: string, role: string): string;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Browser } from '@playwright/test';
|
|
2
|
+
import type { AuthRoleCreds } from './define';
|
|
3
|
+
export interface RunAuthSetupInput {
|
|
4
|
+
role: string;
|
|
5
|
+
creds: AuthRoleCreds;
|
|
6
|
+
setupScriptPath: string;
|
|
7
|
+
authDir: string;
|
|
8
|
+
browser: Browser;
|
|
9
|
+
now?: Date;
|
|
10
|
+
}
|
|
11
|
+
export declare function runAuthSetup(input: RunAuthSetupInput): Promise<void>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface SpawnResult {
|
|
2
|
+
exitCode: number;
|
|
3
|
+
}
|
|
4
|
+
export type SpawnFn = (cmd: string, args: string[], env: NodeJS.ProcessEnv) => Promise<SpawnResult>;
|
|
5
|
+
export interface RunPlaywrightInput {
|
|
6
|
+
specPath: string;
|
|
7
|
+
configPath: string;
|
|
8
|
+
outputDir: string;
|
|
9
|
+
env?: NodeJS.ProcessEnv;
|
|
10
|
+
spawn?: SpawnFn;
|
|
11
|
+
}
|
|
12
|
+
export interface RunPlaywrightResult {
|
|
13
|
+
outcome: 'PASS' | 'FAIL';
|
|
14
|
+
rawReportPath: string;
|
|
15
|
+
exitCode: number;
|
|
16
|
+
}
|
|
17
|
+
export declare function runPlaywright(input: RunPlaywrightInput): Promise<RunPlaywrightResult>;
|
package/dist/index.d.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';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
var __require = import.meta.require;
|
|
3
|
+
|
|
4
|
+
// src/executor/index.ts
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
|
|
7
|
+
// src/executor/playwright-args.ts
|
|
8
|
+
function buildPlaywrightArgs(input) {
|
|
9
|
+
return [
|
|
10
|
+
"test",
|
|
11
|
+
input.specPath,
|
|
12
|
+
`--config=${input.configPath}`,
|
|
13
|
+
"--reporter=json",
|
|
14
|
+
`--output=${input.outputDir}`,
|
|
15
|
+
"--trace=on"
|
|
16
|
+
];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// src/executor/index.ts
|
|
20
|
+
var defaultSpawn = async (cmd, args, env) => {
|
|
21
|
+
const proc = Bun.spawn([cmd, ...args], { env, stdout: "inherit", stderr: "inherit" });
|
|
22
|
+
const exitCode = await proc.exited;
|
|
23
|
+
return { exitCode };
|
|
24
|
+
};
|
|
25
|
+
async function runPlaywright(input) {
|
|
26
|
+
const args = buildPlaywrightArgs({
|
|
27
|
+
specPath: input.specPath,
|
|
28
|
+
configPath: input.configPath,
|
|
29
|
+
outputDir: input.outputDir
|
|
30
|
+
});
|
|
31
|
+
const spawn = input.spawn ?? defaultSpawn;
|
|
32
|
+
const { exitCode } = await spawn("npx", ["playwright", ...args], { ...process.env, ...input.env });
|
|
33
|
+
return {
|
|
34
|
+
outcome: exitCode === 0 ? "PASS" : "FAIL",
|
|
35
|
+
rawReportPath: join(input.outputDir, "report.json"),
|
|
36
|
+
exitCode
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// src/trace-normalizer/normalize.ts
|
|
41
|
+
import { readFileSync as readFileSync2, existsSync, writeFileSync } from "fs";
|
|
42
|
+
import { join as join2 } from "path";
|
|
43
|
+
|
|
44
|
+
// src/trace-normalizer/parse.ts
|
|
45
|
+
function* flatSpecs(suites) {
|
|
46
|
+
for (const s of suites) {
|
|
47
|
+
for (const sp of s.specs ?? [])
|
|
48
|
+
yield sp;
|
|
49
|
+
if (s.suites)
|
|
50
|
+
yield* flatSpecs(s.suites);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function parsePlaywrightReport(report, runId) {
|
|
54
|
+
const scenarios = [];
|
|
55
|
+
for (const spec of flatSpecs(report.suites)) {
|
|
56
|
+
const lastResult = spec.tests[0]?.results[0];
|
|
57
|
+
const outcome = !lastResult ? "SKIPPED" : lastResult.status === "passed" ? "PASS" : lastResult.status === "skipped" ? "SKIPPED" : "FAIL";
|
|
58
|
+
const sc = { name: spec.title, outcome };
|
|
59
|
+
if (outcome === "FAIL" && lastResult) {
|
|
60
|
+
const screenshot = lastResult.attachments?.find((a) => a.name === "screenshot")?.path;
|
|
61
|
+
const failure = {};
|
|
62
|
+
if (lastResult.error?.message !== undefined)
|
|
63
|
+
failure.errorMessage = lastResult.error.message;
|
|
64
|
+
if (screenshot !== undefined)
|
|
65
|
+
failure.screenshotPath = screenshot;
|
|
66
|
+
sc.failure = failure;
|
|
67
|
+
}
|
|
68
|
+
scenarios.push(sc);
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
runId,
|
|
72
|
+
outcome: report.stats.unexpected === 0 ? "PASS" : "FAIL",
|
|
73
|
+
scenarios,
|
|
74
|
+
scrubbed_fields_count: 0
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// src/trace-normalizer/scrub-rules.ts
|
|
79
|
+
var SENSITIVE_HEADERS = [
|
|
80
|
+
"authorization",
|
|
81
|
+
"cookie",
|
|
82
|
+
"set-cookie",
|
|
83
|
+
"x-api-key",
|
|
84
|
+
"x-auth-token",
|
|
85
|
+
"x-csrf-token",
|
|
86
|
+
"proxy-authorization"
|
|
87
|
+
];
|
|
88
|
+
var SENSITIVE_BODY_KEYS = [
|
|
89
|
+
/password/i,
|
|
90
|
+
/passwd/i,
|
|
91
|
+
/token/i,
|
|
92
|
+
/secret/i,
|
|
93
|
+
/api[-_]?key/i,
|
|
94
|
+
/access[-_]?key/i,
|
|
95
|
+
/private[-_]?key/i,
|
|
96
|
+
/authorization/i,
|
|
97
|
+
/credit[-_]?card/i,
|
|
98
|
+
/card[-_]?number/i,
|
|
99
|
+
/cvv/i
|
|
100
|
+
];
|
|
101
|
+
var JWT_RE = /\beyJ[A-Za-z0-9_-]{7,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{5,}\b/;
|
|
102
|
+
var CREDIT_CARD_RE = /\b(?:\d{4}[-\s]?){3}\d{4}\b/;
|
|
103
|
+
var JWT_RE_G = new RegExp(JWT_RE.source, "g");
|
|
104
|
+
var CREDIT_CARD_RE_G = new RegExp(CREDIT_CARD_RE.source, "g");
|
|
105
|
+
var REDACTED = "[REDACTED]";
|
|
106
|
+
function scrubHeaders(headers) {
|
|
107
|
+
const out = {};
|
|
108
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
109
|
+
out[k] = SENSITIVE_HEADERS.includes(k.toLowerCase()) ? REDACTED : v;
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
function scrubBodyJson(body) {
|
|
114
|
+
if (Array.isArray(body))
|
|
115
|
+
return body.map(scrubBodyJson);
|
|
116
|
+
if (body && typeof body === "object") {
|
|
117
|
+
const out = {};
|
|
118
|
+
for (const [k, v] of Object.entries(body)) {
|
|
119
|
+
if (SENSITIVE_BODY_KEYS.some((re) => re.test(k))) {
|
|
120
|
+
out[k] = REDACTED;
|
|
121
|
+
} else {
|
|
122
|
+
out[k] = scrubBodyJson(v);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
if (typeof body === "string")
|
|
128
|
+
return scrubFreeText(body);
|
|
129
|
+
return body;
|
|
130
|
+
}
|
|
131
|
+
function scrubFreeText(s) {
|
|
132
|
+
return s.replace(JWT_RE_G, REDACTED).replace(CREDIT_CARD_RE_G, REDACTED);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// src/trace-normalizer/scrub.ts
|
|
136
|
+
function countScrubbed(before, after) {
|
|
137
|
+
if (typeof before === "string" && typeof after === "string")
|
|
138
|
+
return before !== after ? 1 : 0;
|
|
139
|
+
if (Array.isArray(before) && Array.isArray(after)) {
|
|
140
|
+
return before.reduce((acc, b, i) => acc + countScrubbed(b, after[i]), 0);
|
|
141
|
+
}
|
|
142
|
+
if (before && after && typeof before === "object" && typeof after === "object") {
|
|
143
|
+
let n = 0;
|
|
144
|
+
for (const k of Object.keys(before)) {
|
|
145
|
+
n += countScrubbed(before[k], after[k]);
|
|
146
|
+
}
|
|
147
|
+
return n;
|
|
148
|
+
}
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
function scrub(run) {
|
|
152
|
+
const out = { ...run, scrubbed_fields_count: 0, scenarios: [] };
|
|
153
|
+
let totalScrubs = 0;
|
|
154
|
+
for (const sc of run.scenarios) {
|
|
155
|
+
const newSc = { ...sc };
|
|
156
|
+
if (sc.failure) {
|
|
157
|
+
const f = sc.failure;
|
|
158
|
+
const newF = { ...f };
|
|
159
|
+
if (f.errorMessage) {
|
|
160
|
+
newF.errorMessage = scrubFreeText(f.errorMessage);
|
|
161
|
+
totalScrubs += countScrubbed(f.errorMessage, newF.errorMessage);
|
|
162
|
+
}
|
|
163
|
+
if (f.consoleAtFailure) {
|
|
164
|
+
newF.consoleAtFailure = f.consoleAtFailure.map(scrubFreeText);
|
|
165
|
+
totalScrubs += f.consoleAtFailure.reduce((acc, b, i) => acc + countScrubbed(b, newF.consoleAtFailure[i]), 0);
|
|
166
|
+
}
|
|
167
|
+
if (f.networkAtFailure) {
|
|
168
|
+
newF.networkAtFailure = f.networkAtFailure.map((n) => {
|
|
169
|
+
const reqHeaders = n.requestHeaders ? scrubHeaders(n.requestHeaders) : undefined;
|
|
170
|
+
const resHeaders = n.responseHeaders ? scrubHeaders(n.responseHeaders) : undefined;
|
|
171
|
+
const reqBody = n.requestBody !== undefined ? scrubBodyJson(n.requestBody) : undefined;
|
|
172
|
+
const resBody = n.responseBody !== undefined ? scrubBodyJson(n.responseBody) : undefined;
|
|
173
|
+
totalScrubs += countScrubbed(n.requestHeaders ?? {}, reqHeaders ?? {});
|
|
174
|
+
totalScrubs += countScrubbed(n.responseHeaders ?? {}, resHeaders ?? {});
|
|
175
|
+
totalScrubs += countScrubbed(n.requestBody ?? {}, reqBody ?? {});
|
|
176
|
+
totalScrubs += countScrubbed(n.responseBody ?? {}, resBody ?? {});
|
|
177
|
+
const out2 = { method: n.method, url: n.url, status: n.status };
|
|
178
|
+
if (reqHeaders !== undefined)
|
|
179
|
+
out2.requestHeaders = reqHeaders;
|
|
180
|
+
if (resHeaders !== undefined)
|
|
181
|
+
out2.responseHeaders = resHeaders;
|
|
182
|
+
if (reqBody !== undefined)
|
|
183
|
+
out2.requestBody = reqBody;
|
|
184
|
+
if (resBody !== undefined)
|
|
185
|
+
out2.responseBody = resBody;
|
|
186
|
+
return out2;
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
newSc.failure = newF;
|
|
190
|
+
}
|
|
191
|
+
out.scenarios.push(newSc);
|
|
192
|
+
}
|
|
193
|
+
out.scrubbed_fields_count = totalScrubs;
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// src/trace-normalizer/unzip.ts
|
|
198
|
+
import { readFileSync } from "fs";
|
|
199
|
+
import { unzipSync } from "fflate";
|
|
200
|
+
function unzipTrace(tracePath) {
|
|
201
|
+
const buf = readFileSync(tracePath);
|
|
202
|
+
const entries = unzipSync(buf);
|
|
203
|
+
const files = {};
|
|
204
|
+
for (const [name, data] of Object.entries(entries)) {
|
|
205
|
+
if (name.endsWith("/"))
|
|
206
|
+
continue;
|
|
207
|
+
if (name.endsWith(".network") || name.endsWith(".trace") || name.endsWith(".txt") || name.endsWith(".json")) {
|
|
208
|
+
files[name] = new TextDecoder().decode(data);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return { files };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// src/trace-normalizer/normalize.ts
|
|
215
|
+
async function normalizeRun(input) {
|
|
216
|
+
const reportPath = join2(input.runDir, "report.json");
|
|
217
|
+
const report = JSON.parse(readFileSync2(reportPath, "utf8"));
|
|
218
|
+
let normalized = parsePlaywrightReport(report, input.runId);
|
|
219
|
+
const tracePath = join2(input.runDir, "trace.zip");
|
|
220
|
+
if (existsSync(tracePath)) {
|
|
221
|
+
const { files } = unzipTrace(tracePath);
|
|
222
|
+
const networkFile = Object.entries(files).find(([k]) => k.endsWith(".network"))?.[1];
|
|
223
|
+
const traceFile = Object.entries(files).find(([k]) => k.endsWith(".trace"))?.[1];
|
|
224
|
+
const network = networkFile ? networkFile.trim().split(`
|
|
225
|
+
`).filter(Boolean).map((l) => JSON.parse(l)).filter((e) => e.type === "request") : [];
|
|
226
|
+
const consoleEvents = traceFile ? traceFile.trim().split(`
|
|
227
|
+
`).filter(Boolean).map((l) => JSON.parse(l)).filter((e) => e.type === "console") : [];
|
|
228
|
+
for (const sc of normalized.scenarios) {
|
|
229
|
+
if (sc.outcome !== "FAIL")
|
|
230
|
+
continue;
|
|
231
|
+
sc.failure = sc.failure ?? {};
|
|
232
|
+
sc.failure.networkAtFailure = network.map((n) => {
|
|
233
|
+
const entry = { method: n.method, url: n.url, status: n.status };
|
|
234
|
+
if (n.requestHeaders !== undefined)
|
|
235
|
+
entry.requestHeaders = n.requestHeaders;
|
|
236
|
+
if (n.responseHeaders !== undefined)
|
|
237
|
+
entry.responseHeaders = n.responseHeaders;
|
|
238
|
+
if (n.requestBody !== undefined)
|
|
239
|
+
entry.requestBody = n.requestBody;
|
|
240
|
+
if (n.responseBody !== undefined)
|
|
241
|
+
entry.responseBody = n.responseBody;
|
|
242
|
+
return entry;
|
|
243
|
+
});
|
|
244
|
+
sc.failure.consoleAtFailure = consoleEvents.map((c) => c.text);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
normalized = scrub(normalized);
|
|
248
|
+
writeFileSync(join2(input.runDir, "normalized.json"), JSON.stringify(normalized, null, 2));
|
|
249
|
+
return normalized;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// src/adapter.ts
|
|
253
|
+
import { join as join3 } from "path";
|
|
254
|
+
var WebAdapter = {
|
|
255
|
+
id: "web",
|
|
256
|
+
async generate(_input) {
|
|
257
|
+
return { artifacts: [], warnings: [] };
|
|
258
|
+
},
|
|
259
|
+
async execute(input) {
|
|
260
|
+
const runDir = join3(input.ticketDir, "runs", input.runId);
|
|
261
|
+
const specPath = join3(input.ticketDir, "spec.ts");
|
|
262
|
+
const configPath = join3(input.ticketDir, "playwright.config.ts");
|
|
263
|
+
const pwResult = await runPlaywright({ specPath, configPath, outputDir: runDir });
|
|
264
|
+
const normalized = await normalizeRun({ runId: input.runId, runDir });
|
|
265
|
+
return {
|
|
266
|
+
runId: input.runId,
|
|
267
|
+
outcome: normalized.outcome,
|
|
268
|
+
scenarios: normalized.scenarios.map((s) => {
|
|
269
|
+
const out = { name: s.name, outcome: s.outcome };
|
|
270
|
+
if (s.failure !== undefined)
|
|
271
|
+
out.failure = s.failure;
|
|
272
|
+
return out;
|
|
273
|
+
}),
|
|
274
|
+
artifactsDir: runDir,
|
|
275
|
+
rawReportPath: pwResult.rawReportPath,
|
|
276
|
+
normalizedReportPath: join3(runDir, "normalized.json")
|
|
277
|
+
};
|
|
278
|
+
},
|
|
279
|
+
async doctor() {
|
|
280
|
+
const checks = [];
|
|
281
|
+
try {
|
|
282
|
+
await import("@playwright/test");
|
|
283
|
+
checks.push({ name: "@playwright/test installed", ok: true });
|
|
284
|
+
} catch {
|
|
285
|
+
checks.push({ name: "@playwright/test installed", ok: false, message: "Run `bun add -D @playwright/test`." });
|
|
286
|
+
}
|
|
287
|
+
return { ok: checks.every((c) => c.ok), checks };
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
// src/auth-setup/define.ts
|
|
291
|
+
function defineAuthSetup(fn) {
|
|
292
|
+
return fn;
|
|
293
|
+
}
|
|
294
|
+
// src/auth-setup/runner.ts
|
|
295
|
+
import { pathToFileURL } from "url";
|
|
296
|
+
import { writeAuthState } from "@xera-ai/core";
|
|
297
|
+
async function runAuthSetup(input) {
|
|
298
|
+
const mod = await import(pathToFileURL(input.setupScriptPath).href);
|
|
299
|
+
const fn = mod.default;
|
|
300
|
+
if (typeof fn !== "function") {
|
|
301
|
+
throw new Error(`Auth setup script at ${input.setupScriptPath} must default-export a function (see defineAuthSetup).`);
|
|
302
|
+
}
|
|
303
|
+
const context = await input.browser.newContext();
|
|
304
|
+
try {
|
|
305
|
+
const page = await context.newPage();
|
|
306
|
+
const result = await fn(page, input.role, input.creds) ?? {};
|
|
307
|
+
const storageState = await context.storageState();
|
|
308
|
+
const now = input.now ?? new Date;
|
|
309
|
+
const expiresAtMs = result.expiresAt ?? now.getTime() + 28800000;
|
|
310
|
+
writeAuthState(input.authDir, {
|
|
311
|
+
role: input.role,
|
|
312
|
+
strategy: "storageState",
|
|
313
|
+
created_at: now.toISOString(),
|
|
314
|
+
expires_at: new Date(expiresAtMs).toISOString(),
|
|
315
|
+
payload: storageState
|
|
316
|
+
});
|
|
317
|
+
} finally {
|
|
318
|
+
await context.close();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
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
|
+
// src/generator/gherkin-validate.ts
|
|
336
|
+
import { AstBuilder, GherkinClassicTokenMatcher, Parser } from "@cucumber/gherkin";
|
|
337
|
+
import { IdGenerator } from "@cucumber/messages";
|
|
338
|
+
function validateGherkin(content) {
|
|
339
|
+
if (!content.trim()) {
|
|
340
|
+
return { ok: false, errors: [{ line: 0, message: "Empty feature file" }] };
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
const parser = new Parser(new AstBuilder(IdGenerator.uuid()), new GherkinClassicTokenMatcher);
|
|
344
|
+
parser.parse(content);
|
|
345
|
+
return { ok: true, errors: [] };
|
|
346
|
+
} catch (e) {
|
|
347
|
+
const errors = [];
|
|
348
|
+
if (e?.errors && Array.isArray(e.errors)) {
|
|
349
|
+
for (const inner of e.errors) {
|
|
350
|
+
errors.push({ line: inner?.location?.line ?? 0, message: String(inner?.message ?? inner) });
|
|
351
|
+
}
|
|
352
|
+
} else {
|
|
353
|
+
errors.push({ line: 0, message: String(e?.message ?? e) });
|
|
354
|
+
}
|
|
355
|
+
return { ok: false, errors };
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// src/generator/typecheck.ts
|
|
359
|
+
import { spawnSync } from "child_process";
|
|
360
|
+
async function typecheckTicket(ticketDir) {
|
|
361
|
+
const proc = spawnSync("npx", ["tsc", "--noEmit", "--project", ticketDir], { encoding: "utf8" });
|
|
362
|
+
if (proc.status === 0)
|
|
363
|
+
return { ok: true, errors: [] };
|
|
364
|
+
const out = (proc.stdout || "") + (proc.stderr || "");
|
|
365
|
+
const errors = out.split(`
|
|
366
|
+
`).filter((line) => /error TS\d+/.test(line));
|
|
367
|
+
return { ok: false, errors };
|
|
368
|
+
}
|
|
369
|
+
// src/generator/lint.ts
|
|
370
|
+
import { readFileSync as readFileSync3, existsSync as existsSync2, readdirSync } from "fs";
|
|
371
|
+
import { join as join5 } from "path";
|
|
372
|
+
|
|
373
|
+
// src/generator/selector-rules.ts
|
|
374
|
+
var AUTO_CLASS_RE = /\.(?:Mui|css|ant|chakra|MuiButton)[A-Za-z]*-[A-Za-z0-9_]*-[A-Za-z0-9_]{3,}/;
|
|
375
|
+
var LOCATOR_CSS_RE = /\.locator\(\s*['"`]([^'"`]+)['"`]/;
|
|
376
|
+
var XPATH_RE = /\.locator\(\s*['"`](xpath=|\/\/)/;
|
|
377
|
+
var ALLOW_CSS_RE = /xera-allow-css:/;
|
|
378
|
+
function lintSelectors(source) {
|
|
379
|
+
const warnings = [];
|
|
380
|
+
const lines = source.split(`
|
|
381
|
+
`);
|
|
382
|
+
for (let i = 0;i < lines.length; i++) {
|
|
383
|
+
const text = lines[i];
|
|
384
|
+
const prev = lines[i - 1] ?? "";
|
|
385
|
+
if (XPATH_RE.test(text)) {
|
|
386
|
+
warnings.push({ rule: "no-xpath", line: i + 1, text, message: "XPath selectors are forbidden in v0.1." });
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
const cssMatch = LOCATOR_CSS_RE.exec(text);
|
|
390
|
+
if (cssMatch) {
|
|
391
|
+
const sel = cssMatch[1];
|
|
392
|
+
if (AUTO_CLASS_RE.test(sel)) {
|
|
393
|
+
warnings.push({ rule: "no-auto-classname", line: i + 1, text, message: `Auto-generated class name "${sel}" \u2014 refactor to role/label/test-id.` });
|
|
394
|
+
} else if (!ALLOW_CSS_RE.test(prev)) {
|
|
395
|
+
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.` });
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return { warnings };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// src/generator/lint.ts
|
|
403
|
+
function listTsFiles(dir) {
|
|
404
|
+
if (!existsSync2(dir))
|
|
405
|
+
return [];
|
|
406
|
+
const out = [];
|
|
407
|
+
for (const name of readdirSync(dir, { withFileTypes: true })) {
|
|
408
|
+
const full = join5(dir, name.name);
|
|
409
|
+
if (name.isDirectory())
|
|
410
|
+
out.push(...listTsFiles(full));
|
|
411
|
+
else if (name.name.endsWith(".ts"))
|
|
412
|
+
out.push(full);
|
|
413
|
+
}
|
|
414
|
+
return out;
|
|
415
|
+
}
|
|
416
|
+
async function lintTicket(ticketDir) {
|
|
417
|
+
const files = listTsFiles(ticketDir);
|
|
418
|
+
const warnings = [];
|
|
419
|
+
for (const f of files) {
|
|
420
|
+
const src = readFileSync3(f, "utf8");
|
|
421
|
+
const r = lintSelectors(src);
|
|
422
|
+
for (const w of r.warnings)
|
|
423
|
+
warnings.push({ ...w, file: f });
|
|
424
|
+
}
|
|
425
|
+
return { ok: warnings.length === 0, warnings };
|
|
426
|
+
}
|
|
427
|
+
// src/generator/pom-scan.ts
|
|
428
|
+
import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync as readFileSync4 } from "fs";
|
|
429
|
+
import { join as join6 } from "path";
|
|
430
|
+
var CLASS_RE = /export\s+class\s+([A-Z][A-Za-z0-9_]*)/g;
|
|
431
|
+
function scanSharedPoms(repoRoot) {
|
|
432
|
+
const dir = join6(repoRoot, "shared", "page-objects");
|
|
433
|
+
if (!existsSync3(dir))
|
|
434
|
+
return [];
|
|
435
|
+
const found = [];
|
|
436
|
+
for (const entry of readdirSync2(dir, { withFileTypes: true })) {
|
|
437
|
+
if (!entry.isFile() || !entry.name.endsWith(".ts"))
|
|
438
|
+
continue;
|
|
439
|
+
const path = join6(dir, entry.name);
|
|
440
|
+
const src = readFileSync4(path, "utf8");
|
|
441
|
+
for (const m of src.matchAll(CLASS_RE)) {
|
|
442
|
+
found.push({ className: m[1], absolutePath: path });
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return found;
|
|
446
|
+
}
|
|
447
|
+
// src/generator/promote.ts
|
|
448
|
+
import { existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync3, renameSync } from "fs";
|
|
449
|
+
import { join as join7 } from "path";
|
|
450
|
+
async function promotePom(input) {
|
|
451
|
+
const fromDir = join7(input.repoRoot, ".xera", input.ticket, "page-objects");
|
|
452
|
+
const toDir = join7(input.repoRoot, "shared", "page-objects");
|
|
453
|
+
const file = `${input.className}.ts`;
|
|
454
|
+
const fromPath = join7(fromDir, file);
|
|
455
|
+
const toPath = join7(toDir, file);
|
|
456
|
+
if (!existsSync4(fromPath)) {
|
|
457
|
+
throw new Error(`POM ${file} not found at ${fromPath}`);
|
|
458
|
+
}
|
|
459
|
+
if (existsSync4(toPath)) {
|
|
460
|
+
throw new Error(`POM ${file} already exists at ${toPath}. Reconcile manually before promoting.`);
|
|
461
|
+
}
|
|
462
|
+
renameSync(fromPath, toPath);
|
|
463
|
+
const specPath = join7(input.repoRoot, ".xera", input.ticket, "spec.ts");
|
|
464
|
+
if (existsSync4(specPath)) {
|
|
465
|
+
const src = readFileSync5(specPath, "utf8");
|
|
466
|
+
const updated = src.replace(new RegExp(`from\\s+['"]\\./page-objects/${input.className}['"]`, "g"), `from '../../shared/page-objects/${input.className}'`);
|
|
467
|
+
writeFileSync3(specPath, updated);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
export {
|
|
471
|
+
validateGherkin,
|
|
472
|
+
unzipTrace,
|
|
473
|
+
typecheckTicket,
|
|
474
|
+
stagePlaywrightState,
|
|
475
|
+
scrubHeaders,
|
|
476
|
+
scrubFreeText,
|
|
477
|
+
scrubBodyJson,
|
|
478
|
+
scrub,
|
|
479
|
+
scanSharedPoms,
|
|
480
|
+
runPlaywright,
|
|
481
|
+
runAuthSetup,
|
|
482
|
+
promotePom,
|
|
483
|
+
parsePlaywrightReport,
|
|
484
|
+
normalizeRun,
|
|
485
|
+
lintTicket,
|
|
486
|
+
lintSelectors,
|
|
487
|
+
defineAuthSetup,
|
|
488
|
+
buildPlaywrightArgs,
|
|
489
|
+
WebAdapter,
|
|
490
|
+
SENSITIVE_HEADERS,
|
|
491
|
+
SENSITIVE_BODY_KEYS,
|
|
492
|
+
JWT_RE,
|
|
493
|
+
CREDIT_CARD_RE
|
|
494
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { NormalizedRun } from './scrub';
|
|
2
|
+
interface PWAttachment {
|
|
3
|
+
name: string;
|
|
4
|
+
path?: string;
|
|
5
|
+
contentType?: string;
|
|
6
|
+
}
|
|
7
|
+
interface PWResult {
|
|
8
|
+
status: string;
|
|
9
|
+
duration: number;
|
|
10
|
+
error?: {
|
|
11
|
+
message?: string;
|
|
12
|
+
stack?: string;
|
|
13
|
+
};
|
|
14
|
+
attachments?: PWAttachment[];
|
|
15
|
+
}
|
|
16
|
+
interface PWTest {
|
|
17
|
+
results: PWResult[];
|
|
18
|
+
}
|
|
19
|
+
interface PWSpec {
|
|
20
|
+
title: string;
|
|
21
|
+
ok: boolean;
|
|
22
|
+
tests: PWTest[];
|
|
23
|
+
}
|
|
24
|
+
interface PWSuite {
|
|
25
|
+
title: string;
|
|
26
|
+
specs?: PWSpec[];
|
|
27
|
+
suites?: PWSuite[];
|
|
28
|
+
}
|
|
29
|
+
interface PWReport {
|
|
30
|
+
stats: {
|
|
31
|
+
unexpected: number;
|
|
32
|
+
};
|
|
33
|
+
suites: PWSuite[];
|
|
34
|
+
}
|
|
35
|
+
export declare function parsePlaywrightReport(report: PWReport, runId: string): NormalizedRun;
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const SENSITIVE_HEADERS: readonly string[];
|
|
2
|
+
export declare const SENSITIVE_BODY_KEYS: readonly RegExp[];
|
|
3
|
+
export declare const JWT_RE: RegExp;
|
|
4
|
+
export declare const CREDIT_CARD_RE: RegExp;
|
|
5
|
+
export declare function scrubHeaders(headers: Record<string, string>): Record<string, string>;
|
|
6
|
+
export declare function scrubBodyJson(body: unknown): unknown;
|
|
7
|
+
export declare function scrubFreeText(s: string): string;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface NormalizedNetworkEntry {
|
|
2
|
+
method: string;
|
|
3
|
+
url: string;
|
|
4
|
+
status: number;
|
|
5
|
+
requestHeaders?: Record<string, string>;
|
|
6
|
+
requestBody?: unknown;
|
|
7
|
+
responseHeaders?: Record<string, string>;
|
|
8
|
+
responseBody?: unknown;
|
|
9
|
+
}
|
|
10
|
+
export interface NormalizedScenario {
|
|
11
|
+
name: string;
|
|
12
|
+
outcome: 'PASS' | 'FAIL' | 'SKIPPED';
|
|
13
|
+
failure?: {
|
|
14
|
+
step?: string;
|
|
15
|
+
errorMessage?: string;
|
|
16
|
+
domSnapshotAtFailure?: string;
|
|
17
|
+
networkAtFailure?: NormalizedNetworkEntry[];
|
|
18
|
+
consoleAtFailure?: string[];
|
|
19
|
+
screenshotPath?: string;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export interface NormalizedRun {
|
|
23
|
+
runId: string;
|
|
24
|
+
outcome: 'PASS' | 'FAIL';
|
|
25
|
+
scenarios: NormalizedScenario[];
|
|
26
|
+
scrubbed_fields_count: number;
|
|
27
|
+
}
|
|
28
|
+
export declare function scrub(run: NormalizedRun): NormalizedRun;
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xera-ai/web",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"bun": "./src/index.ts",
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": ["dist"],
|
|
15
|
+
"scripts": {
|
|
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
|
+
"typecheck": "tsc --noEmit"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@cucumber/gherkin": "30.0.4",
|
|
21
|
+
"@cucumber/messages": "27.0.2",
|
|
22
|
+
"@playwright/test": "1.48.0",
|
|
23
|
+
"@xera-ai/core": "workspace:*",
|
|
24
|
+
"fflate": "0.8.2"
|
|
25
|
+
}
|
|
26
|
+
}
|