qa-deck-backend 1.0.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/server.js ADDED
@@ -0,0 +1,4616 @@
1
+ /**
2
+ * QA Deck — Backend API Server
3
+ * Pure Node.js, no external dependencies.
4
+ *
5
+ * Endpoints:
6
+ * POST /api/generate-tests → Claude generates test cases from page data
7
+ * POST /api/generate-script → Claude generates automation scripts
8
+ * POST /api/save-project → Save project to file system
9
+ * GET /api/projects → List saved projects
10
+ * GET /api/health → Health check
11
+ */
12
+
13
+ const http = require("http");
14
+ const https = require("https");
15
+ const fs = require("fs");
16
+ const path = require("path");
17
+ const crypto = require("crypto");
18
+ const { spawn } = require("child_process");
19
+ const os = require("os");
20
+ const { RecorderSessionManager } = require("./recorder/recorder.js");
21
+ const { actionsToSteps, actionsToCode, actionsToJourneySegments } = require("./recorder/converter.js");
22
+ const { generateCICD } = require("./recorder/cicd.js");
23
+
24
+ const recorderManager = new RecorderSessionManager();
25
+
26
+ // ─── Config ───────────────────────────────────────────────────────────────────
27
+
28
+ const PORT = process.env.PORT || 3747;
29
+ const PROJECTS_DIR = path.join(__dirname, "projects");
30
+ const ALLOWED_ORIGINS = [
31
+ "chrome-extension://", // any chrome extension
32
+ "http://localhost",
33
+ "http://127.0.0.1",
34
+ "https://qadeck.com", // production website
35
+ "https://www.qadeck.com",
36
+ process.env.WEBSITE_ORIGIN, // custom origin via env (e.g. Vercel preview URLs)
37
+ ].filter(Boolean);
38
+
39
+ if (!fs.existsSync(PROJECTS_DIR)) fs.mkdirSync(PROJECTS_DIR, { recursive: true });
40
+
41
+ // Guard: warn if running from unexpected location (e.g. stale process from renamed folder)
42
+ const expectedDirName = "qa-autopilot-backend";
43
+ if (path.basename(__dirname) !== expectedDirName) {
44
+ console.warn(`⚠️ WARNING: server.js is running from an unexpected directory: ${__dirname}`);
45
+ console.warn(` Expected directory name: "${expectedDirName}"`);
46
+ console.warn(` If you renamed the project folder, restart this server from the correct path.\n`);
47
+ }
48
+
49
+ // Sanitize AI JSON output — strips markdown fences and escapes literal control
50
+ // characters that Claude sometimes emits inside string values (e.g. raw newlines
51
+ // in generated code blocks), which cause JSON.parse to throw "Bad control character".
52
+ function sanitizeAiJson(text) {
53
+ const stripped = text.replace(/^```json\s*/m, "").replace(/\s*```$/m, "").trim();
54
+ return stripped.replace(/"(?:[^"\\]|\\.)*"/gs, (match) =>
55
+ match
56
+ .replace(/\n/g, "\\n")
57
+ .replace(/\r/g, "\\r")
58
+ .replace(/\t/g, "\\t")
59
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, (c) =>
60
+ `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`)
61
+ );
62
+ }
63
+
64
+ const TESTCASE_PACK_ORDER = ["smoke", "regression", "e2e"];
65
+ const TESTCASE_CATEGORIES = new Set([
66
+ "functional",
67
+ "negative",
68
+ "boundary",
69
+ "navigation",
70
+ "ui",
71
+ "accessibility",
72
+ "e2e",
73
+ "performance",
74
+ "security",
75
+ ]);
76
+
77
+ function deriveLegacySuite(caseKind, packs) {
78
+ if (packs.includes("smoke")) return "smoke";
79
+ if (packs.includes("regression")) return "regression";
80
+ if (packs.includes("e2e") || caseKind === "flow") return "e2e";
81
+ return "page";
82
+ }
83
+
84
+ function normalizePackMembership(caseKind, packs) {
85
+ const normalized = Array.from(new Set((packs || []).filter((pack) => TESTCASE_PACK_ORDER.includes(pack))));
86
+ return TESTCASE_PACK_ORDER.filter((pack) => normalized.includes(pack) && (caseKind === "flow" || pack !== "e2e"));
87
+ }
88
+
89
+ function normalizeGeneratedCaseKind(raw, fallback = "page") {
90
+ const explicit = String(raw?.caseKind || "").toLowerCase().trim();
91
+ if (["page", "flow", "step"].includes(explicit)) return explicit;
92
+
93
+ const scope = String(raw?.scope || "").toLowerCase().trim();
94
+ if (scope === "journey") return "flow";
95
+ if (scope === "step") return "step";
96
+
97
+ const suite = String(raw?.suite || "").toLowerCase().trim();
98
+ if (suite === "e2e") return fallback === "step" ? "step" : "flow";
99
+
100
+ const category = String(raw?.category || "").toLowerCase().trim();
101
+ if (category === "e2e") return fallback === "step" ? "step" : "flow";
102
+
103
+ return fallback;
104
+ }
105
+
106
+ function normalizeGeneratedPacks(raw, caseKind) {
107
+ const explicit = Array.isArray(raw?.packs)
108
+ ? raw.packs.map((pack) => String(pack || "").toLowerCase().trim())
109
+ : [];
110
+ if (explicit.length) return normalizePackMembership(caseKind, explicit);
111
+
112
+ const tags = Array.isArray(raw?.tags) ? raw.tags.map((tag) => String(tag || "").toLowerCase()) : [];
113
+ const suite = String(raw?.suite || "").toLowerCase().trim();
114
+ const next = [];
115
+
116
+ if (suite === "smoke" || tags.includes("smoke")) next.push("smoke");
117
+ if (suite === "regression" || tags.includes("regression")) next.push("regression");
118
+ if (suite === "e2e" || tags.includes("e2e") || tags.includes("flow")) next.push("e2e");
119
+
120
+ return normalizePackMembership(caseKind, next);
121
+ }
122
+
123
+ function normalizeGeneratedCategory(rawCategory, caseKind, packs) {
124
+ const category = String(rawCategory || "").toLowerCase().trim();
125
+ if (TESTCASE_CATEGORIES.has(category) && !["smoke", "regression", "page"].includes(category)) {
126
+ return category;
127
+ }
128
+ if (caseKind === "flow" || packs.includes("e2e")) return "e2e";
129
+ return "functional";
130
+ }
131
+
132
+ // Rate limiter — localhost is unlimited (recorder polls every 600ms)
133
+ const rateLimiter = new Map();
134
+ const LOCAL_IPS = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
135
+
136
+ function checkRateLimit(ip) {
137
+ if (LOCAL_IPS.has(ip)) return true; // no limit for localhost
138
+ const now = Date.now();
139
+ const hits = (rateLimiter.get(ip) || []).filter(t => now - t < 60_000);
140
+ hits.push(now);
141
+ rateLimiter.set(ip, hits);
142
+ return hits.length <= 300;
143
+ }
144
+
145
+ function normalizeRuntimePath(value) {
146
+ return String(value || "")
147
+ .replace(/\\/g, "/")
148
+ .replace(/^\/+/, "")
149
+ .replace(/\.\.(\/|\\)/g, "");
150
+ }
151
+
152
+ function resolveRuntimeScriptTargets(file, framework) {
153
+ const filename = normalizeRuntimePath(file?.filename || "script.txt");
154
+ const key = String(file?.key || "");
155
+ const hasExplicitPath = filename.includes("/");
156
+ if (hasExplicitPath) return [filename];
157
+
158
+ const isPython = framework === "selenium-python" || framework === "playwright-python";
159
+ if (!isPython) return [filename];
160
+
161
+ if (key === "pageObject") return [`pages/${filename}`, `page_objects/${filename}`];
162
+ if (["tests", "accessibility", "perfTest", "visualTest"].includes(key) || /^test_/i.test(filename)) {
163
+ return [`tests/${filename}`];
164
+ }
165
+
166
+ return [filename];
167
+ }
168
+
169
+ function buildRuntimePythonConftest() {
170
+ return `import os
171
+ import sys
172
+
173
+ ROOT = os.path.dirname(__file__)
174
+ for relative in ("pages", "page_objects"):
175
+ candidate = os.path.join(ROOT, relative)
176
+ if os.path.isdir(candidate) and candidate not in sys.path:
177
+ sys.path.insert(0, candidate)
178
+ `;
179
+ }
180
+
181
+ // ─── HTTP Server ──────────────────────────────────────────────────────────────
182
+
183
+ const server = http.createServer(async (req, res) => {
184
+ // CORS — allow chrome extensions and localhost
185
+ const origin = req.headers.origin || "";
186
+ const allowed = ALLOWED_ORIGINS.some(o => origin.startsWith(o));
187
+
188
+ res.setHeader("Access-Control-Allow-Origin", allowed ? origin : "null");
189
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
190
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-API-Key");
191
+ res.setHeader("X-Content-Type-Options", "nosniff");
192
+
193
+ if (req.method === "OPTIONS") {
194
+ res.writeHead(204);
195
+ res.end();
196
+ return;
197
+ }
198
+
199
+ const ip = req.socket.remoteAddress || "unknown";
200
+ if (!checkRateLimit(ip)) {
201
+ return jsonResponse(res, 429, { error: "Rate limit exceeded. Max 20 requests/minute." });
202
+ }
203
+
204
+ const url = new URL(req.url, `http://localhost:${PORT}`);
205
+ const route = `${req.method} ${url.pathname}`;
206
+
207
+ try {
208
+ if (route === "GET /api/health" || route === "HEAD /api/health") return handleHealth(req, res);
209
+ if (route === "POST /api/generate-tests") return await handleGenerateTests(req, res);
210
+ if (route === "POST /api/generate-script") return await handleGenerateScript(req, res);
211
+ if (route === "POST /api/generate-journey-tests") return await handleGenerateJourneyTests(req, res);
212
+ if (route === "POST /api/generate-journey-script") return await handleGenerateJourneyScript(req, res);
213
+ if (route === "POST /api/run-tests") return await handleRunTests(req, res);
214
+ if (route === "POST /api/save-project") return await handleSaveProject(req, res);
215
+ if (route === "GET /api/projects") return handleListProjects(req, res);
216
+ if (url.pathname.startsWith("/api/projects/")) {
217
+ if (req.method === "GET") return handleGetProject(req, res, url);
218
+ if (req.method === "DELETE") return handleDeleteProject(req, res, url);
219
+ }
220
+
221
+ // CI/CD config generation
222
+ if (route === "POST /api/generate-cicd") return await handleGenerateCICD(req, res);
223
+
224
+ // Recorder routes
225
+ if (url.pathname === "/api/record/start" && req.method === "POST") return await handleRecordStart(req, res);
226
+ if (url.pathname === "/api/record/sessions" && req.method === "GET") return handleRecordSessions(req, res);
227
+ if (url.pathname.startsWith("/api/record/")) {
228
+ const parts = url.pathname.split("/");
229
+ const sessionId = parts[3];
230
+ const action = parts[4];
231
+ if (action === "actions" && req.method === "GET") return handleRecordActions(req, res, sessionId);
232
+ if (action === "stop" && req.method === "POST") return await handleRecordStop(req, res, sessionId);
233
+ if (action === "convert" && req.method === "POST") return await handleRecordConvert(req, res, sessionId);
234
+ if (action === "testcases" && req.method === "POST") return await handleRecordTestCases(req, res, sessionId);
235
+ }
236
+
237
+ // Page proxy — strips X-Frame-Options, injects capture script
238
+ if (req.method === "GET" && url.pathname === "/api/proxy") return await handleProxy(req, res, url);
239
+ // Proxy asset passthrough — serves CSS/JS/images for proxied pages
240
+ if (req.method === "GET" && (url.pathname === "/api/proxy-asset" || url.pathname.startsWith("/api/proxy-asset/"))) {
241
+ return await handleProxyAsset(req, res, url);
242
+ }
243
+
244
+ // Serve dashboard static files
245
+ if (req.method === "GET") return serveStatic(req, res, url);
246
+
247
+ jsonResponse(res, 404, { error: "Not found" });
248
+ } catch (err) {
249
+ console.error("[Server Error]", err);
250
+ jsonResponse(res, 500, { error: "Internal server error", detail: err.message });
251
+ }
252
+ });
253
+
254
+ server.listen(PORT, () => {
255
+ console.log(`\n🚀 QA Deck Backend running`);
256
+ console.log(` Dashboard: http://localhost:${PORT}`);
257
+ console.log(` API Health: http://localhost:${PORT}/api/health\n`);
258
+ });
259
+
260
+ // ─── Route handlers ───────────────────────────────────────────────────────────
261
+
262
+ function handleHealth(req, res) {
263
+ jsonResponse(res, 200, {
264
+ status: "ok",
265
+ version: "1.0.0",
266
+ timestamp: new Date().toISOString(),
267
+ projects: fs.readdirSync(PROJECTS_DIR).filter(f => f.endsWith(".json")).length,
268
+ });
269
+ }
270
+
271
+ function escapeRegExp(value) {
272
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
273
+ }
274
+
275
+ // ─── Run Tests ────────────────────────────────────────────────────────────────
276
+
277
+ async function handleRunTests(req, res) {
278
+ const body = await readBody(req);
279
+ const { scripts, framework, headless = true } = body;
280
+
281
+ if (!scripts?.length) return jsonResponse(res, 400, { error: "scripts array is required" });
282
+ if (!framework) return jsonResponse(res, 400, { error: "framework is required" });
283
+
284
+ // SSE headers — no Content-Length so we can stream
285
+ const origin = req.headers.origin || "";
286
+ const allowed = ["chrome-extension://", "http://localhost", "http://127.0.0.1"].some(o => origin.startsWith(o));
287
+ res.writeHead(200, {
288
+ "Content-Type": "text/event-stream",
289
+ "Cache-Control": "no-cache",
290
+ "Connection": "keep-alive",
291
+ "Access-Control-Allow-Origin": allowed ? origin : "null",
292
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
293
+ "Access-Control-Allow-Headers": "Content-Type, X-API-Key",
294
+ });
295
+
296
+ function sseEvent(payload) {
297
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
298
+ }
299
+
300
+ // ── Dependency check ──────────────────────────────────────────────────────
301
+
302
+ const depChecks = {
303
+ "selenium-python": { cmds: ["python3 -m pytest --version", "python3 -c \"import selenium\""],
304
+ install: "pip install pytest selenium webdriver-manager" },
305
+ "playwright-python": { cmds: ["python3 -m pytest --version", "python3 -c \"from playwright.sync_api import sync_playwright\""],
306
+ install: "pip install pytest pytest-playwright && playwright install chromium" },
307
+ "playwright-typescript": { cmds: ["node -e \"require('@playwright/test')\""],
308
+ install: "npm install && npx playwright install chromium" },
309
+ "selenium-java": { cmds: ["mvn --version"],
310
+ install: "macOS: brew install maven | Windows/Linux: https://maven.apache.org/install.html" },
311
+ };
312
+
313
+ const check = depChecks[framework];
314
+ if (check) {
315
+ for (const cmd of check.cmds) {
316
+ const [bin, ...args] = cmd.split(" ");
317
+ const ok = await new Promise(resolve => {
318
+ const c = spawn(bin, args, { shell: true });
319
+ c.on("close", code => resolve(code === 0));
320
+ c.on("error", () => resolve(false));
321
+ });
322
+ if (!ok) {
323
+ sseEvent({
324
+ type: "missing-deps",
325
+ message: `Missing dependency for ${framework}: ${cmd.split(" ").slice(0, 3).join(" ")} not found.`,
326
+ installCmd: check.install,
327
+ });
328
+ res.end();
329
+ return;
330
+ }
331
+ }
332
+ }
333
+
334
+ // ── Write script files to temp dir ───────────────────────────────────────
335
+
336
+ let tmpDir;
337
+ try {
338
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "qadeck-run-"));
339
+ for (const f of scripts) {
340
+ const targets = resolveRuntimeScriptTargets(f, framework);
341
+ for (const target of targets) {
342
+ const filePath = path.join(tmpDir, target);
343
+ const dir = path.dirname(filePath);
344
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
345
+ fs.writeFileSync(filePath, patchHeadless(f.content, target, framework, headless));
346
+ }
347
+ }
348
+
349
+ if (framework === "selenium-python" || framework === "playwright-python") {
350
+ const runtimeConftest = buildRuntimePythonConftest();
351
+ const rootConftest = path.join(tmpDir, "conftest.py");
352
+ const testsConftest = path.join(tmpDir, "tests", "conftest.py");
353
+ const pagesInit = path.join(tmpDir, "pages", "__init__.py");
354
+ const pageObjectsInit = path.join(tmpDir, "page_objects", "__init__.py");
355
+
356
+ if (!fs.existsSync(path.dirname(testsConftest))) fs.mkdirSync(path.dirname(testsConftest), { recursive: true });
357
+ if (!fs.existsSync(path.dirname(pagesInit))) fs.mkdirSync(path.dirname(pagesInit), { recursive: true });
358
+ if (!fs.existsSync(path.dirname(pageObjectsInit))) fs.mkdirSync(path.dirname(pageObjectsInit), { recursive: true });
359
+
360
+ fs.writeFileSync(rootConftest, runtimeConftest);
361
+ fs.writeFileSync(testsConftest, runtimeConftest);
362
+ fs.writeFileSync(pagesInit, "");
363
+ fs.writeFileSync(pageObjectsInit, "");
364
+ }
365
+ } catch (err) {
366
+ sseEvent({ type: "error", message: "Failed to write script files: " + err.message });
367
+ res.end();
368
+ return;
369
+ }
370
+
371
+ // ── Spawn test runner ─────────────────────────────────────────────────────
372
+
373
+ const runConfigs = {
374
+ "selenium-python": { bin: "python3", args: ["-m", "pytest", "-v", "--tb=short"] },
375
+ "playwright-python": { bin: "python3", args: ["-m", "pytest", "-v", "--tb=short"] },
376
+ "playwright-typescript": { bin: "node_modules/.bin/playwright", args: ["test", "--reporter=list"] },
377
+ "selenium-java": { bin: "mvn", args: ["test", "-Dsurefire.useFile=false"] },
378
+ };
379
+
380
+ const runCfg = runConfigs[framework] || runConfigs["selenium-python"];
381
+ sseEvent({
382
+ type: "start",
383
+ message: `Running: ${runCfg.bin} ${runCfg.args.join(" ")} (headless=${headless ? "true" : "false"})`,
384
+ });
385
+
386
+ const child = spawn(runCfg.bin, runCfg.args, { cwd: tmpDir, shell: process.platform === "win32" });
387
+ let stdout = "";
388
+ const liveResults = [];
389
+ const parseLiveStdout = createLiveResultParser(framework);
390
+ let runFinished = false;
391
+ let stopRequested = false;
392
+
393
+ function cleanupRunArtifacts() {
394
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
395
+ }
396
+
397
+ function terminateRun(reason = "Run stopped by client") {
398
+ if (runFinished) return;
399
+ stopRequested = true;
400
+ runFinished = true;
401
+ if (!child.killed) {
402
+ try { child.kill("SIGTERM"); } catch (_) {}
403
+ setTimeout(() => {
404
+ if (!child.killed) {
405
+ try { child.kill("SIGKILL"); } catch (_) {}
406
+ }
407
+ }, 1500);
408
+ }
409
+ cleanupRunArtifacts();
410
+ console.log(`[Run Tests] ${reason}`);
411
+ }
412
+
413
+ res.on("close", () => {
414
+ if (!runFinished) terminateRun();
415
+ });
416
+
417
+ child.stdout.on("data", (chunk) => {
418
+ const text = chunk.toString();
419
+ stdout += text;
420
+ sseEvent({ type: "stdout", text });
421
+ const updates = parseLiveStdout(text);
422
+ for (const result of updates) {
423
+ const existingIndex = liveResults.findIndex((entry) => entry.name === result.name);
424
+ if (existingIndex >= 0) liveResults[existingIndex] = result;
425
+ else liveResults.push(result);
426
+ sseEvent({ type: "test-result", result });
427
+ }
428
+ });
429
+
430
+ child.stderr.on("data", (chunk) => {
431
+ sseEvent({ type: "stderr", text: chunk.toString() });
432
+ });
433
+
434
+ child.on("error", (err) => {
435
+ runFinished = true;
436
+ sseEvent({ type: "error", message: err.message });
437
+ res.end();
438
+ cleanupRunArtifacts();
439
+ });
440
+
441
+ child.on("close", (code) => {
442
+ if (stopRequested) {
443
+ cleanupRunArtifacts();
444
+ return;
445
+ }
446
+ runFinished = true;
447
+ const results = parseTestResults(stdout, framework);
448
+ sseEvent({ type: "done", exitCode: code, results });
449
+ res.end();
450
+ cleanupRunArtifacts();
451
+ });
452
+ }
453
+
454
+ // Patch template base files to apply the requested headless setting.
455
+ // Templates default to non-headless (visible browser); we inject headless args when headless=true.
456
+ // Uses regex matching so minor whitespace/formatting differences in stored content don't break it.
457
+ function patchHeadless(content, filename, framework, headless) {
458
+ const base = path.basename(filename);
459
+
460
+ // ── selenium-python: patch any saved python file with webdriver.Chrome(...) ──
461
+ if (framework === "selenium-python" && filename.endsWith(".py")) {
462
+ let patched = content
463
+ .replace(/^[ \t]*__qadeck_headless_options\s*=\s*webdriver\.ChromeOptions\(\)\s*\n?/gm, "")
464
+ .replace(/^[ \t]*__qadeck_headless_options\.add_argument\(\s*["']--headless(?:=new)?["']\s*\)\s*\n?/gm, "")
465
+ .replace(/webdriver\.Chrome\(\s*options\s*=\s*__qadeck_headless_options\s*,\s*/g, "webdriver.Chrome(")
466
+ .replace(/webdriver\.Chrome\(\s*options\s*=\s*__qadeck_headless_options\s*\)/g, "webdriver.Chrome()");
467
+
468
+ if (!headless) {
469
+ return patched.replace(
470
+ /^[ \t]*[A-Za-z_][A-Za-z0-9_]*\.add_argument\(\s*["']--headless(?:=new)?["']\s*\)\s*\n?/gm,
471
+ ""
472
+ );
473
+ }
474
+
475
+ const optionVars = new Set();
476
+ patched.replace(/webdriver\.Chrome\((.*)\)/g, (_match, args) => {
477
+ const optionsMatch = args.match(/(?:^|,)\s*options\s*=\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?:,|$)/);
478
+ if (optionsMatch?.[1]) optionVars.add(optionsMatch[1]);
479
+ return _match;
480
+ });
481
+
482
+ for (const optionVar of optionVars) {
483
+ const headlessArgRe = new RegExp(
484
+ `^[ \\t]*${escapeRegExp(optionVar)}\\.add_argument\\(\\s*["']--headless(?:=new)?["']\\s*\\)\\s*$`,
485
+ "m"
486
+ );
487
+ if (headlessArgRe.test(patched)) continue;
488
+
489
+ const optionsDefRe = new RegExp(
490
+ `^([ \\t]*)${escapeRegExp(optionVar)}\\s*=\\s*(?:Options|webdriver\\.ChromeOptions)\\(\\)\\s*$`,
491
+ "m"
492
+ );
493
+ patched = patched.replace(optionsDefRe, (line, indent) =>
494
+ `${line}\n${indent}${optionVar}.add_argument("--headless=new")`
495
+ );
496
+ }
497
+
498
+ return patched.replace(
499
+ /^([ \t]*)([\w.]+\s*=\s*)?webdriver\.Chrome\((?!.*options\s*=)(.*)\)\s*$/gm,
500
+ (_match, indent, assignment = "", args = "") => {
501
+ const trimmedArgs = args.trim();
502
+ const chromeArgs = trimmedArgs
503
+ ? `options=__qadeck_headless_options, ${trimmedArgs}`
504
+ : "options=__qadeck_headless_options";
505
+ return [
506
+ `${indent}__qadeck_headless_options = webdriver.ChromeOptions()`,
507
+ `${indent}__qadeck_headless_options.add_argument("--headless=new")`,
508
+ `${indent}${assignment}webdriver.Chrome(${chromeArgs})`,
509
+ ].join("\n");
510
+ }
511
+ );
512
+ }
513
+
514
+ // ── playwright-python: patch any saved python file with launch(...) ──────
515
+ if (framework === "playwright-python" && filename.endsWith(".py")) {
516
+ if (headless) {
517
+ if (/headless\s*=/.test(content)) {
518
+ return content.replace(/headless\s*=\s*\w+/g, "headless=True");
519
+ }
520
+ return content.replace(/(\.launch\s*\()/, "$1headless=True, ");
521
+ } else {
522
+ if (/headless\s*=/.test(content)) {
523
+ return content.replace(/headless\s*=\s*\w+/g, "headless=False");
524
+ }
525
+ return content;
526
+ }
527
+ }
528
+
529
+ // ── playwright-typescript: playwright.config.ts ──────────────────────────
530
+ if (framework === "playwright-typescript" && base === "playwright.config.ts") {
531
+ if (headless) {
532
+ if (/headless\s*:/.test(content)) {
533
+ return content.replace(/headless\s*:\s*(true|false)/gi, "headless: true");
534
+ }
535
+ // inject into use: {} block
536
+ return content.replace(/(use\s*:\s*\{)/, "$1\n headless: true,");
537
+ } else {
538
+ if (/headless\s*:/.test(content)) {
539
+ return content.replace(/headless\s*:\s*(true|false)/gi, "headless: false");
540
+ }
541
+ return content;
542
+ }
543
+ }
544
+
545
+ // ── selenium-java: BaseTest.java ─────────────────────────────────────────
546
+ if (framework === "selenium-java" && base === "BaseTest.java") {
547
+ if (headless) {
548
+ if (/--headless/.test(content)) return content;
549
+ // options.addArguments("--start-maximized") → add headless alongside it
550
+ if (/options\.addArguments\(/.test(content)) {
551
+ return content.replace(
552
+ /(options\.addArguments\([^)]*)\)/,
553
+ '$1, "--headless=new")'
554
+ );
555
+ }
556
+ // No addArguments call — inject before driver creation
557
+ return content.replace(
558
+ /(ChromeOptions\s+options\s*=\s*new\s+ChromeOptions\(\);)/,
559
+ '$1\n options.addArguments("--headless=new");'
560
+ );
561
+ } else {
562
+ return content.replace(/,?\s*"--headless=new"/g, "");
563
+ }
564
+ }
565
+
566
+ return content;
567
+ }
568
+
569
+ function parseTestResults(stdout, framework) {
570
+ const results = [];
571
+ const lines = stdout.split("\n");
572
+
573
+ for (const line of lines) {
574
+ const parsed = parseTestResultLine(line, framework);
575
+ if (parsed) results.push(parsed);
576
+ }
577
+
578
+ return results;
579
+ }
580
+
581
+ function createLiveResultParser(framework) {
582
+ let buffer = "";
583
+
584
+ return (chunk) => {
585
+ buffer += chunk;
586
+ const lines = buffer.split(/\r?\n/);
587
+ buffer = lines.pop() ?? "";
588
+
589
+ return lines
590
+ .map((line) => parseTestResultLine(line, framework))
591
+ .filter(Boolean);
592
+ };
593
+ }
594
+
595
+ function parseTestResultLine(line, framework) {
596
+ if (framework === "selenium-python" || framework === "playwright-python") {
597
+ // pytest -v output: "tests/test_form.py::TestClass::test_name PASSED [ 50%]"
598
+ const match = line.match(/^([\w/\\.\-]+::[\w\[\]-]+)\s+(PASSED|FAILED|ERROR|SKIPPED)(?:\s+\[.*?\])?\s*(?:\((.+?)\))?/);
599
+ if (!match) return null;
600
+ return {
601
+ name: match[1].split("::").pop(),
602
+ status: match[2].toLowerCase(),
603
+ duration: match[3] || null,
604
+ };
605
+ }
606
+
607
+ if (framework === "playwright-typescript") {
608
+ // Playwright list reporter: " ✓ test name (123ms)" or " × test name (456ms)"
609
+ const match = line.match(/^\s*([✓✗×])\s+(.+?)(?:\s+\((\d+ms)\))?$/);
610
+ if (!match) return null;
611
+ return {
612
+ name: match[2].trim(),
613
+ status: match[1] === "✓" ? "passed" : "failed",
614
+ duration: match[3] || null,
615
+ };
616
+ }
617
+
618
+ if (framework === "selenium-java") {
619
+ const match = line.match(/^\s*(PASS|FAIL|ERROR|SKIP)\s+(.+)/i);
620
+ if (!match) return null;
621
+ return {
622
+ name: match[2].trim(),
623
+ status: match[1].toLowerCase(),
624
+ duration: null,
625
+ };
626
+ }
627
+
628
+ return null;
629
+ }
630
+
631
+ async function handleGenerateTests(req, res) {
632
+ const body = await readBody(req);
633
+ const { pageData, apiKey, exploratoryMode } = body;
634
+
635
+ if (!pageData) return jsonResponse(res, 400, { error: "pageData is required" });
636
+ if (!apiKey) return jsonResponse(res, 400, { error: "apiKey is required" });
637
+
638
+ console.log(`[Generate Tests] Page: ${pageData.meta?.url} | Type: ${pageData.meta?.pageType} | Exploratory: ${!!exploratoryMode}`);
639
+
640
+ const prompt = buildTestCasePrompt(pageData, exploratoryMode);
641
+ const result = await callAI(apiKey, prompt, 4096,
642
+ "You are an expert QA automation engineer. Generate precise, actionable test cases from web page analysis data. Respond with valid JSON only — no markdown, no explanation.");
643
+
644
+ if (!result.success) return jsonResponse(res, 502, { error: result.error });
645
+
646
+ let testCases;
647
+ try {
648
+ const clean = sanitizeAiJson(result.text);
649
+ const parsed = JSON.parse(clean);
650
+ testCases = parsed.testCases || parsed;
651
+ if (!Array.isArray(testCases)) throw new Error("Expected array of test cases");
652
+ } catch (err) {
653
+ console.error("[Parse Error]", err.message, "\nRaw:", result.text.slice(0, 200));
654
+ return jsonResponse(res, 502, { error: "Failed to parse AI response", detail: err.message });
655
+ }
656
+
657
+ // Normalise and validate each test case
658
+ testCases = testCases.map((tc, i) => {
659
+ const caseKind = normalizeGeneratedCaseKind(tc, "page");
660
+ const packs = normalizeGeneratedPacks(tc, caseKind);
661
+ return {
662
+ id: tc.id || `TC${String(i + 1).padStart(3, "0")}`,
663
+ title: tc.title || "Untitled test case",
664
+ category: normalizeGeneratedCategory(tc.category, caseKind, packs),
665
+ priority: tc.priority || "medium",
666
+ preconditions: tc.preconditions || "",
667
+ steps: Array.isArray(tc.steps) ? tc.steps : [],
668
+ expectedResult: tc.expectedResult || tc.expected_result || "",
669
+ locators: tc.locators || {},
670
+ testData: tc.testData || tc.test_data || {},
671
+ tags: tc.tags || [],
672
+ approved: tc.approved !== false,
673
+ caseKind,
674
+ packs,
675
+ suite: deriveLegacySuite(caseKind, packs),
676
+ scope: "page",
677
+ source: tc.source || "page",
678
+ };
679
+ });
680
+
681
+ console.log(`[Generate Tests] ✓ ${testCases.length} test cases generated`);
682
+ jsonResponse(res, 200, { success: true, testCases, count: testCases.length });
683
+ }
684
+
685
+ // ─── Template-driven files (never AI-generated) ──────────────────────────────
686
+ // base_test, config, and pytest.ini are always correct — no AI hallucination risk
687
+ function injectTemplateFiles(scripts, framework, pageData) {
688
+ const url = pageData?.meta?.url || "https://example.com";
689
+
690
+ if (framework === "selenium-python") {
691
+ scripts.base = {
692
+ filename: "base_test.py",
693
+ content: `from selenium import webdriver
694
+ from selenium.webdriver.chrome.options import Options
695
+ from selenium.webdriver.common.by import By
696
+ from selenium.webdriver.support import expected_conditions as EC
697
+ from selenium.webdriver.support.ui import WebDriverWait
698
+
699
+
700
+ _ACTIVE_DRIVER = None
701
+
702
+
703
+ def set_active_driver(driver):
704
+ global _ACTIVE_DRIVER
705
+ _ACTIVE_DRIVER = driver
706
+ return driver
707
+
708
+
709
+ def get_active_driver():
710
+ return _ACTIVE_DRIVER
711
+
712
+
713
+ def resolve_driver(driver=None):
714
+ return driver or _ACTIVE_DRIVER
715
+
716
+
717
+ class DeferredDriverProxy:
718
+ def _resolve(self):
719
+ driver = resolve_driver()
720
+ if driver is None:
721
+ raise RuntimeError("QA Deck could not resolve an active Selenium driver for this generated file.")
722
+ return driver
723
+
724
+ def __getattr__(self, name):
725
+ return getattr(self._resolve(), name)
726
+
727
+
728
+ class LazyElement:
729
+ def __init__(self, driver_ref, by, value):
730
+ self._driver_ref = driver_ref
731
+ self._by = by
732
+ self._value = value
733
+
734
+ def _resolve(self):
735
+ driver = resolve_driver(self._driver_ref() if callable(self._driver_ref) else self._driver_ref)
736
+ if driver is None:
737
+ raise RuntimeError("QA Deck could not resolve an active Selenium driver for this page object.")
738
+ return WebDriverWait(driver, 10).until(EC.presence_of_element_located((self._by, self._value)))
739
+
740
+ def __getattr__(self, name):
741
+ return getattr(self._resolve(), name)
742
+
743
+
744
+ class LazyElements:
745
+ def __init__(self, driver_ref, by, value):
746
+ self._driver_ref = driver_ref
747
+ self._by = by
748
+ self._value = value
749
+
750
+ def _resolve(self):
751
+ driver = resolve_driver(self._driver_ref() if callable(self._driver_ref) else self._driver_ref)
752
+ if driver is None:
753
+ raise RuntimeError("QA Deck could not resolve an active Selenium driver for this page object.")
754
+ return WebDriverWait(driver, 10).until(lambda d: d.find_elements(self._by, self._value))
755
+
756
+ def __iter__(self):
757
+ return iter(self._resolve())
758
+
759
+ def __getitem__(self, item):
760
+ return self._resolve()[item]
761
+
762
+ def __len__(self):
763
+ return len(self._resolve())
764
+
765
+
766
+ class BaseTest:
767
+ """Base test class — browser setup and teardown."""
768
+
769
+ def __init__(self, driver=None):
770
+ self.driver = resolve_driver(driver)
771
+ self.wait = WebDriverWait(self.driver, 10) if self.driver else None
772
+
773
+ def setup_method(self):
774
+ """Launch Chrome before each test."""
775
+ options = Options()
776
+ options.add_argument("--no-sandbox")
777
+ options.add_argument("--disable-dev-shm-usage")
778
+ # Selenium 4.18+ includes Selenium Manager — no chromedriver download needed
779
+ self.driver = set_active_driver(webdriver.Chrome(options=options))
780
+ self.driver.maximize_window()
781
+ self.wait = WebDriverWait(self.driver, 10)
782
+ self.driver.get("${url}")
783
+
784
+ def teardown_method(self):
785
+ """Quit Chrome after each test."""
786
+ if hasattr(self, "driver") and self.driver:
787
+ self.driver.quit()
788
+ set_active_driver(None)
789
+ `.replace("${url}", url),
790
+ };
791
+
792
+ scripts.config = {
793
+ filename: "pytest.ini",
794
+ content: `[pytest]
795
+ addopts = -v --tb=short --junit-xml=report.xml
796
+ testpaths = tests
797
+ `,
798
+ };
799
+ }
800
+
801
+ if (framework === "playwright-python") {
802
+ scripts.base = {
803
+ filename: "conftest.py",
804
+ content: `import pytest
805
+ from playwright.sync_api import sync_playwright
806
+
807
+
808
+ @pytest.fixture(scope="function")
809
+ def page():
810
+ """Playwright page fixture — launches browser per test."""
811
+ with sync_playwright() as p:
812
+ browser = p.chromium.launch(headless=False)
813
+ ctx = browser.new_context()
814
+ pg = ctx.new_page()
815
+ pg.goto("${url}")
816
+ yield pg
817
+ ctx.close()
818
+ browser.close()
819
+ `.replace("${url}", url),
820
+ };
821
+
822
+ scripts.config = {
823
+ filename: "pytest.ini",
824
+ content: `[pytest]
825
+ addopts = -v --tb=short --junit-xml=report.xml
826
+ testpaths = tests
827
+ `,
828
+ };
829
+ }
830
+
831
+ if (framework === "playwright-typescript") {
832
+ scripts.base = {
833
+ filename: "playwright.config.ts",
834
+ content: `import { defineConfig } from '@playwright/test';
835
+
836
+ export default defineConfig({
837
+ testDir: './tests',
838
+ timeout: 30000,
839
+ retries: 1,
840
+ use: {
841
+ baseURL: '${url}',
842
+ headless: false,
843
+ screenshot: 'only-on-failure',
844
+ video: 'retain-on-failure',
845
+ },
846
+ reporter: [['html'], ['junit', { outputFile: 'report.xml' }]],
847
+ });
848
+ `.replace("${url}", url),
849
+ };
850
+ }
851
+
852
+ if (framework === "selenium-java") {
853
+ scripts.base = {
854
+ filename: "BaseTest.java",
855
+ content: `package tests;
856
+
857
+ import org.openqa.selenium.WebDriver;
858
+ import org.openqa.selenium.chrome.ChromeDriver;
859
+ import org.openqa.selenium.chrome.ChromeOptions;
860
+ import org.openqa.selenium.support.ui.WebDriverWait;
861
+ import org.testng.annotations.AfterMethod;
862
+ import org.testng.annotations.BeforeMethod;
863
+ import java.time.Duration;
864
+
865
+ public class BaseTest {
866
+ protected WebDriver driver;
867
+ protected WebDriverWait wait;
868
+
869
+ @BeforeMethod
870
+ public void setUp() {
871
+ ChromeOptions options = new ChromeOptions();
872
+ options.addArguments("--no-sandbox", "--disable-dev-shm-usage");
873
+ driver = new ChromeDriver(options); // Selenium Manager handles chromedriver
874
+ driver.manage().window().maximize();
875
+ wait = new WebDriverWait(driver, Duration.ofSeconds(10));
876
+ driver.get("${url}");
877
+ }
878
+
879
+ @AfterMethod
880
+ public void tearDown() {
881
+ if (driver != null) driver.quit();
882
+ }
883
+ }
884
+ `.replace("${url}", url),
885
+ };
886
+ }
887
+
888
+ return scripts;
889
+ }
890
+
891
+ // ─── Post-processor: inject missing imports the AI forgot ────────────────────
892
+ function fixGeneratedScripts(scripts, framework) {
893
+ if (framework === "selenium-python" || framework === "playwright-python") {
894
+ // Required imports for every Python test file
895
+ const seleniumImports = [
896
+ "from selenium.webdriver.support.ui import WebDriverWait",
897
+ "from selenium.webdriver.support import expected_conditions as EC",
898
+ "from selenium.webdriver.common.by import By",
899
+ "from selenium.common.exceptions import TimeoutException, NoSuchElementException",
900
+ ];
901
+ const playwrightImports = [
902
+ "import pytest",
903
+ "from playwright.sync_api import expect",
904
+ ];
905
+ const requiredImports = framework === "selenium-python" ? seleniumImports : playwrightImports;
906
+
907
+ // Fix each Python file that uses these names but doesn't import them
908
+ for (const key of ["tests", "base", "pageObject"]) {
909
+ const file = scripts[key];
910
+ if (!file?.content) continue;
911
+
912
+ let content = file.content;
913
+ const missing = requiredImports.filter(imp => {
914
+ // Check if the symbol is used but not imported
915
+ const symbol = imp.split(" ").pop(); // last word = the imported name
916
+ const isUsed = content.includes(symbol.split(" as ").pop()); // handle "as EC"
917
+ const alreadyImported = content.includes(imp);
918
+ return isUsed && !alreadyImported;
919
+ });
920
+
921
+ if (missing.length > 0) {
922
+ // Find first import line to insert after the existing imports block
923
+ const lines = content.split("\n");
924
+ let lastImportLine = 0;
925
+ for (let i = 0; i < lines.length; i++) {
926
+ if (lines[i].startsWith("import ") || lines[i].startsWith("from ")) {
927
+ lastImportLine = i;
928
+ }
929
+ }
930
+ lines.splice(lastImportLine + 1, 0, ...missing);
931
+ scripts[key] = { ...file, content: lines.join("\n") };
932
+ console.log(`[Fix Imports] Added ${missing.length} missing imports to ${file.filename}`);
933
+ }
934
+ }
935
+ }
936
+
937
+ if (framework === "playwright-typescript") {
938
+ // Ensure test file imports from @playwright/test
939
+ const file = scripts.tests;
940
+ if (file?.content && !file.content.includes("@playwright/test")) {
941
+ scripts.tests = {
942
+ ...file,
943
+ content: `import { test, expect, Page } from '@playwright/test';\n` + file.content,
944
+ };
945
+ }
946
+ }
947
+
948
+ return scripts;
949
+ }
950
+
951
+ async function handleGenerateScript(req, res) {
952
+ const body = await readBody(req);
953
+ const { testCases, pageData, framework, format, customAssertions, networkCalls, visualTesting, perfAssertions, environments, datasetsMap, apiKey } = body;
954
+
955
+ if (!testCases?.length) return jsonResponse(res, 400, { error: "testCases are required" });
956
+ if (!framework) return jsonResponse(res, 400, { error: "framework is required" });
957
+ if (!apiKey) return jsonResponse(res, 400, { error: "apiKey is required" });
958
+
959
+ // Route to BDD generator if format=bdd
960
+ if (format === "bdd") {
961
+ return handleGenerateBDDScript(testCases, pageData, framework, apiKey, res);
962
+ }
963
+
964
+ console.log(`[Generate Script] Framework: ${framework} | Test cases: ${testCases.length}${customAssertions?.length ? ` | Custom assertions: ${customAssertions.length}` : ""}${networkCalls?.length ? ` | Network assertions: ${networkCalls.length}` : ""}`);
965
+
966
+ // Step 1: Build page object + test data deterministically — no AI, no hallucinated locators
967
+ const pageObject = generatePageObject(pageData, framework);
968
+ const testData = generateTestData(testCases, pageData, framework);
969
+ console.log(`[Generate Script] ✓ Page object generated deterministically (${pageObject.filename}, ${pageObject.content.length} chars)`);
970
+
971
+ // Step 2: Ask AI only for test methods, giving it the page object API it must use
972
+ const pageObjectApi = _extractApiSummary(pageObject.content, framework);
973
+ const prompt = buildTestsOnlyPrompt(testCases, pageData, framework, pageObject.filename, pageObjectApi, networkCalls, customAssertions, datasetsMap);
974
+ const result = await callAI(apiKey, prompt, 4096,
975
+ "You are a senior QA automation engineer. Write test methods using the provided page object. Respond ONLY with valid JSON — no markdown fences, no extra text.");
976
+
977
+ if (!result.success) return jsonResponse(res, 502, { error: result.error });
978
+
979
+ let scripts;
980
+ try {
981
+ const clean = sanitizeAiJson(result.text);
982
+ const aiPart = JSON.parse(clean);
983
+ if (!aiPart.tests?.filename || !aiPart.tests?.content) throw new Error("Missing tests file in AI response");
984
+ scripts = { pageObject, testData, tests: aiPart.tests };
985
+ } catch (err) {
986
+ console.error("[Script Parse Error]", err.message);
987
+ return jsonResponse(res, 502, { error: "Failed to parse script response", detail: err.message });
988
+ }
989
+
990
+ // Inject template-driven files — these never come from AI to avoid import/setup bugs
991
+ scripts = injectTemplateFiles(scripts, framework, pageData);
992
+
993
+ // Post-process: fix any remaining missing imports the AI forgot
994
+ scripts = fixGeneratedScripts(scripts, framework);
995
+
996
+ // Generate 6th file — accessibility tests (template-based, no AI call)
997
+ if (pageData?.accessibility) {
998
+ scripts.accessibility = buildAccessibilityScript(pageData, framework);
999
+ console.log(`[Generate Script] ✓ Accessibility test file added (${scripts.accessibility.filename})`);
1000
+ }
1001
+
1002
+ // Generate performance assertion tests (opt-in)
1003
+ if (perfAssertions && pageData?.performance) {
1004
+ scripts.perfTest = buildPerformanceScript(pageData, framework);
1005
+ console.log(`[Generate Script] ✓ Performance test file added (${scripts.perfTest.filename})`);
1006
+ }
1007
+
1008
+ // Generate 7th file — visual regression tests (opt-in)
1009
+ if (visualTesting) {
1010
+ scripts.visualTest = buildVisualRegressionScript(pageData, framework);
1011
+ console.log(`[Generate Script] ✓ Visual regression test file added (${scripts.visualTest.filename})`);
1012
+ }
1013
+
1014
+ // Generate environment config files (opt-in)
1015
+ if (environments?.length > 0) {
1016
+ scripts.envConfigs = buildEnvironmentConfigs(environments, pageData, framework);
1017
+ console.log(`[Generate Script] ✓ ${environments.length} environment config files added`);
1018
+ }
1019
+
1020
+ console.log(`[Generate Script] ✓ ${Object.keys(scripts).length} files generated`);
1021
+ jsonResponse(res, 200, { success: true, scripts });
1022
+ }
1023
+
1024
+ async function handleGenerateJourneyTests(req, res) {
1025
+ const body = await readBody(req);
1026
+ const { journey, apiKey } = body;
1027
+
1028
+ if (!journey?.steps?.length) return jsonResponse(res, 400, { error: "journey with steps is required" });
1029
+ if (!apiKey) return jsonResponse(res, 400, { error: "apiKey is required" });
1030
+
1031
+ const prompt = buildJourneyTestPrompt(journey);
1032
+ const result = await callAI(
1033
+ apiKey,
1034
+ prompt,
1035
+ 6144,
1036
+ "You are a senior QA automation engineer. Generate journey-level and step-level web test cases. Respond ONLY with valid JSON."
1037
+ );
1038
+
1039
+ if (!result.success) return jsonResponse(res, 502, { error: result.error });
1040
+
1041
+ let testCases;
1042
+ try {
1043
+ const clean = sanitizeAiJson(result.text);
1044
+ const parsed = JSON.parse(clean);
1045
+ testCases = Array.isArray(parsed) ? parsed : parsed.testCases;
1046
+ if (!Array.isArray(testCases)) throw new Error("Expected testCases array");
1047
+ } catch (err) {
1048
+ console.error("[Journey Test Parse Error]", err.message);
1049
+ return jsonResponse(res, 502, { error: "Failed to parse journey test response", detail: err.message });
1050
+ }
1051
+
1052
+ const normalized = normalizeJourneyTestCases(testCases, journey);
1053
+ jsonResponse(res, 200, {
1054
+ success: true,
1055
+ testCases: normalized,
1056
+ summary: buildJourneyGenerationSummary(journey),
1057
+ });
1058
+ }
1059
+
1060
+ async function handleGenerateJourneyScript(req, res) {
1061
+ const body = await readBody(req);
1062
+ const { journey, testCases, framework, apiKey } = body;
1063
+
1064
+ if (!journey?.steps?.length) return jsonResponse(res, 400, { error: "journey with steps is required" });
1065
+ if (!testCases?.length) return jsonResponse(res, 400, { error: "testCases are required" });
1066
+ if (!framework) return jsonResponse(res, 400, { error: "framework is required" });
1067
+ if (!apiKey) return jsonResponse(res, 400, { error: "apiKey is required" });
1068
+
1069
+ const approvedCases = testCases.filter((tc) => tc.approved !== false);
1070
+ const summary = buildJourneyGenerationSummary(journey);
1071
+ const prompt = buildJourneyScriptPrompt(journey, approvedCases, framework, summary);
1072
+ const result = await callAI(
1073
+ apiKey,
1074
+ prompt,
1075
+ 8192,
1076
+ "You are a senior QA automation engineer. Generate a multi-page automation bundle. Respond ONLY with valid JSON."
1077
+ );
1078
+
1079
+ if (!result.success) return jsonResponse(res, 502, { error: result.error });
1080
+
1081
+ let bundle;
1082
+ try {
1083
+ const clean = sanitizeAiJson(result.text);
1084
+ bundle = JSON.parse(clean);
1085
+ } catch (err) {
1086
+ console.error("[Journey Script Parse Error]", err.message);
1087
+ return jsonResponse(res, 502, { error: "Failed to parse journey script response", detail: err.message });
1088
+ }
1089
+
1090
+ const normalized = normalizeJourneyScriptBundle(bundle, framework, journey, approvedCases, summary);
1091
+ jsonResponse(res, 200, { success: true, bundle: normalized });
1092
+ }
1093
+
1094
+ async function handleSaveProject(req, res) {
1095
+ const body = await readBody(req);
1096
+ const { project } = body;
1097
+ if (!project) return jsonResponse(res, 400, { error: "project is required" });
1098
+
1099
+ const id = project.id || crypto.randomUUID();
1100
+ const filename = path.join(PROJECTS_DIR, `${id}.json`);
1101
+ const data = { ...project, id, savedAt: new Date().toISOString() };
1102
+
1103
+ fs.writeFileSync(filename, JSON.stringify(data, null, 2));
1104
+ console.log(`[Save Project] Saved: ${id}`);
1105
+ jsonResponse(res, 200, { success: true, id });
1106
+ }
1107
+
1108
+ function countGeneratedScriptFiles(scripts) {
1109
+ if (!scripts) return 0;
1110
+ if (Array.isArray(scripts.files)) return scripts.files.length;
1111
+ return Object.values(scripts).filter((file) => file && typeof file === "object" && file.filename && file.content).length;
1112
+ }
1113
+
1114
+ function buildLocalArtifactCounts(project, mode, latestArtifacts) {
1115
+ if (project.artifactCounts && typeof project.artifactCounts === "object") {
1116
+ return {
1117
+ scans: Number(project.artifactCounts.scans || 0),
1118
+ journeys: Number(project.artifactCounts.journeys || 0),
1119
+ testCases: Number(project.artifactCounts.testCases || 0),
1120
+ scriptFiles: Number(project.artifactCounts.scriptFiles || 0),
1121
+ cicdFiles: Number(project.artifactCounts.cicdFiles || 0),
1122
+ notes: Number(project.artifactCounts.notes || 0),
1123
+ };
1124
+ }
1125
+
1126
+ if (latestArtifacts && typeof latestArtifacts === "object") {
1127
+ const journeySteps = Array.isArray(latestArtifacts.journey?.steps) ? latestArtifacts.journey.steps : [];
1128
+ return {
1129
+ scans: mode === "journey"
1130
+ ? journeySteps.filter((step) => !!step?.pageData).length
1131
+ : (latestArtifacts.scan ? 1 : 0),
1132
+ journeys: mode === "journey" && latestArtifacts.journey ? 1 : 0,
1133
+ testCases: Array.isArray(latestArtifacts.testcases) ? latestArtifacts.testcases.length : 0,
1134
+ scriptFiles: Array.isArray(latestArtifacts.scriptFiles) ? latestArtifacts.scriptFiles.length : 0,
1135
+ cicdFiles: latestArtifacts.cicd && typeof latestArtifacts.cicd === "object" ? Object.keys(latestArtifacts.cicd).length : 0,
1136
+ notes: Array.isArray(latestArtifacts.notes?.stepNotes) ? latestArtifacts.notes.stepNotes.length : 0,
1137
+ };
1138
+ }
1139
+
1140
+ const page = Array.isArray(project.pages) ? project.pages[0] || {} : {};
1141
+ return {
1142
+ scans: mode === "journey"
1143
+ ? (Array.isArray(project.steps) ? project.steps.filter((step) => !!step?.pageData).length : 0)
1144
+ : (project.currentPageData ? 1 : 0),
1145
+ journeys: mode === "journey" ? 1 : 0,
1146
+ testCases: Array.isArray(project.generated?.testCases)
1147
+ ? project.generated.testCases.length
1148
+ : Array.isArray(page.testCases)
1149
+ ? page.testCases.length
1150
+ : 0,
1151
+ scriptFiles: countGeneratedScriptFiles(project.generated?.scripts || page.scripts || project.scripts),
1152
+ cicdFiles: project.cicdGeneratedConfigs && typeof project.cicdGeneratedConfigs === "object"
1153
+ ? Object.keys(project.cicdGeneratedConfigs).length
1154
+ : project.cicd && typeof project.cicd === "object"
1155
+ ? Object.keys(project.cicd).length
1156
+ : 0,
1157
+ notes: Array.isArray(project.notes?.stepNotes) ? project.notes.stepNotes.length : 0,
1158
+ };
1159
+ }
1160
+
1161
+ function handleListProjects(req, res) {
1162
+ const files = fs.readdirSync(PROJECTS_DIR).filter(f => f.endsWith(".json"));
1163
+ const projects = files.map(f => {
1164
+ try {
1165
+ const data = JSON.parse(fs.readFileSync(path.join(PROJECTS_DIR, f), "utf8"));
1166
+ const mode = data.mode || (Array.isArray(data.steps) ? "journey" : "page");
1167
+ const versions = Array.isArray(data.versions)
1168
+ ? [...data.versions].sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0))
1169
+ : [];
1170
+ const latestVersionId = data.latestVersionId || versions[0]?.id || "";
1171
+ const latestArtifacts = latestVersionId && data.artifactsByVersion
1172
+ ? data.artifactsByVersion[latestVersionId] || null
1173
+ : null;
1174
+ const artifactCounts = buildLocalArtifactCounts(data, mode, latestArtifacts);
1175
+ const pageCount = mode === "journey"
1176
+ ? (latestArtifacts?.journey?.steps?.length || data.steps?.length || 0)
1177
+ : (data.pages?.length || 1);
1178
+ const sourceUrl = data.sourceUrl || data.url || data.currentPageData?.meta?.url || data.steps?.[0]?.url || data.pages?.[0]?.url || "";
1179
+
1180
+ return {
1181
+ id: data.id,
1182
+ name: data.name,
1183
+ url: sourceUrl,
1184
+ sourceUrl,
1185
+ savedAt: data.savedAt,
1186
+ createdAt: data.createdAt || data.savedAt,
1187
+ updatedAt: data.updatedAt || data.savedAt,
1188
+ lastOpenedAt: data.lastOpenedAt || null,
1189
+ mode,
1190
+ status: data.status || "draft",
1191
+ tags: Array.isArray(data.tags) ? data.tags : [],
1192
+ syncState: data.syncState || "local",
1193
+ activeFramework: data.activeFramework || data.generated?.activeFramework || "selenium-python",
1194
+ latestVersionId,
1195
+ artifactCounts,
1196
+ testCaseCount: artifactCounts.testCases,
1197
+ pageCount,
1198
+ };
1199
+ } catch { return null; }
1200
+ }).filter(Boolean)
1201
+ .sort((a, b) => new Date(b.updatedAt || b.savedAt || 0) - new Date(a.updatedAt || a.savedAt || 0));
1202
+ jsonResponse(res, 200, { success: true, projects });
1203
+ }
1204
+
1205
+ function handleGetProject(req, res, url) {
1206
+ const id = url.pathname.split("/").pop();
1207
+ const filename = path.join(PROJECTS_DIR, `${id}.json`);
1208
+ if (!fs.existsSync(filename)) return jsonResponse(res, 404, { error: "Project not found" });
1209
+ try {
1210
+ const data = JSON.parse(fs.readFileSync(filename, "utf8"));
1211
+ jsonResponse(res, 200, { success: true, project: data });
1212
+ } catch {
1213
+ jsonResponse(res, 500, { error: "Failed to read project" });
1214
+ }
1215
+ }
1216
+
1217
+ // ─── Claude API caller ────────────────────────────────────────────────────────
1218
+
1219
+ // ─── Universal AI caller — detects provider from key format ──────────────────
1220
+
1221
+ function callAI(apiKey, prompt, maxTokens, system) {
1222
+ if (!apiKey) return Promise.resolve({ success: false, error: "No API key provided" });
1223
+
1224
+ // Detect provider from key prefix
1225
+ if (apiKey.startsWith("sk-ant-") || apiKey.startsWith("sk-ant")) {
1226
+ return callClaude(apiKey, { system, prompt, maxTokens: maxTokens || 3000 });
1227
+ }
1228
+ if (apiKey.startsWith("AIza")) {
1229
+ return callGemini(apiKey, prompt, system, maxTokens || 3000);
1230
+ }
1231
+ if (apiKey.startsWith("xai-")) {
1232
+ return callGrok(apiKey, prompt, system, maxTokens || 3000);
1233
+ }
1234
+ if (apiKey.startsWith("gsk_")) {
1235
+ return callGroq(apiKey, prompt, system, maxTokens || 3000);
1236
+ }
1237
+ if (apiKey.startsWith("LA-")) {
1238
+ return callMetaLlama(apiKey, prompt, system, maxTokens || 3000);
1239
+ }
1240
+ if (apiKey.startsWith("sk-") || apiKey.startsWith("sk-proj-")) {
1241
+ return callOpenAI(apiKey, prompt, system, maxTokens || 3000);
1242
+ }
1243
+ // Unknown format — try Claude anyway
1244
+ return callClaude(apiKey, { system, prompt, maxTokens: maxTokens || 3000 });
1245
+ }
1246
+
1247
+ // ── Claude (Anthropic) ────────────────────────────────────────────────────────
1248
+ function callClaude(apiKey, { system, prompt, maxTokens = 3000 }) {
1249
+ return new Promise((resolve) => {
1250
+ const body = JSON.stringify({
1251
+ model: "claude-sonnet-4-6",
1252
+ max_tokens: maxTokens,
1253
+ system,
1254
+ messages: [{ role: "user", content: prompt }],
1255
+ });
1256
+
1257
+ const req = https.request({
1258
+ hostname: "api.anthropic.com",
1259
+ path: "/v1/messages",
1260
+ method: "POST",
1261
+ headers: {
1262
+ "Content-Type": "application/json",
1263
+ "Content-Length": Buffer.byteLength(body),
1264
+ "x-api-key": apiKey,
1265
+ "anthropic-version": "2023-06-01",
1266
+ },
1267
+ }, (res) => {
1268
+ let data = "";
1269
+ res.on("data", c => data += c);
1270
+ res.on("end", () => {
1271
+ try {
1272
+ const p = JSON.parse(data);
1273
+ if (p.error) return resolve({ success: false, error: p.error.message || "Claude API error" });
1274
+ resolve({ success: true, text: p.content?.[0]?.text || "" });
1275
+ } catch { resolve({ success: false, error: "Failed to parse Claude response" }); }
1276
+ });
1277
+ });
1278
+ req.on("error", e => resolve({ success: false, error: e.message }));
1279
+ req.setTimeout(60000, () => { req.destroy(); resolve({ success: false, error: "Claude API timeout" }); });
1280
+ req.write(body);
1281
+ req.end();
1282
+ });
1283
+ }
1284
+
1285
+ // ── Gemini (Google) ───────────────────────────────────────────────────────────
1286
+ function callGemini(apiKey, prompt, system, maxTokens) {
1287
+ return new Promise((resolve) => {
1288
+ const fullPrompt = system ? `${system}\n\n${prompt}` : prompt;
1289
+ const body = JSON.stringify({
1290
+ contents: [{ parts: [{ text: fullPrompt }] }],
1291
+ generationConfig: { maxOutputTokens: maxTokens, temperature: 0.3 },
1292
+ });
1293
+
1294
+ const path = `/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`;
1295
+ const req = https.request({
1296
+ hostname: "generativelanguage.googleapis.com",
1297
+ path,
1298
+ method: "POST",
1299
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
1300
+ }, (res) => {
1301
+ let data = "";
1302
+ res.on("data", c => data += c);
1303
+ res.on("end", () => {
1304
+ try {
1305
+ const p = JSON.parse(data);
1306
+ if (p.error) return resolve({ success: false, error: p.error.message || "Gemini error" });
1307
+ const text = p.candidates?.[0]?.content?.parts?.[0]?.text || "";
1308
+ resolve({ success: true, text });
1309
+ } catch { resolve({ success: false, error: "Failed to parse Gemini response" }); }
1310
+ });
1311
+ });
1312
+ req.on("error", e => resolve({ success: false, error: e.message }));
1313
+ req.setTimeout(60000, () => { req.destroy(); resolve({ success: false, error: "Gemini API timeout" }); });
1314
+ req.write(body);
1315
+ req.end();
1316
+ });
1317
+ }
1318
+
1319
+ // ── OpenAI (GPT-4o-mini) ──────────────────────────────────────────────────────
1320
+ function callOpenAI(apiKey, prompt, system, maxTokens) {
1321
+ return new Promise((resolve) => {
1322
+ const messages = [];
1323
+ if (system) messages.push({ role: "system", content: system });
1324
+ messages.push({ role: "user", content: prompt });
1325
+
1326
+ const body = JSON.stringify({
1327
+ model: "gpt-4o-mini",
1328
+ max_tokens: maxTokens,
1329
+ messages,
1330
+ temperature: 0.3,
1331
+ });
1332
+
1333
+ const req = https.request({
1334
+ hostname: "api.openai.com",
1335
+ path: "/v1/chat/completions",
1336
+ method: "POST",
1337
+ headers: {
1338
+ "Content-Type": "application/json",
1339
+ "Content-Length": Buffer.byteLength(body),
1340
+ "Authorization": `Bearer ${apiKey}`,
1341
+ },
1342
+ }, (res) => {
1343
+ let data = "";
1344
+ res.on("data", c => data += c);
1345
+ res.on("end", () => {
1346
+ try {
1347
+ const p = JSON.parse(data);
1348
+ if (p.error) return resolve({ success: false, error: p.error.message || "OpenAI error" });
1349
+ const text = p.choices?.[0]?.message?.content || "";
1350
+ resolve({ success: true, text });
1351
+ } catch { resolve({ success: false, error: "Failed to parse OpenAI response" }); }
1352
+ });
1353
+ });
1354
+ req.on("error", e => resolve({ success: false, error: e.message }));
1355
+ req.setTimeout(60000, () => { req.destroy(); resolve({ success: false, error: "OpenAI API timeout" }); });
1356
+ req.write(body);
1357
+ req.end();
1358
+ });
1359
+ }
1360
+
1361
+
1362
+ // ── Grok (xAI) ───────────────────────────────────────────────────────────────
1363
+ function callGrok(apiKey, prompt, system, maxTokens) {
1364
+ return new Promise((resolve) => {
1365
+ const messages = [];
1366
+ if (system) messages.push({ role: "system", content: system });
1367
+ messages.push({ role: "user", content: prompt });
1368
+
1369
+ const body = JSON.stringify({
1370
+ model: "grok-beta",
1371
+ max_tokens: maxTokens,
1372
+ messages,
1373
+ temperature: 0.3,
1374
+ });
1375
+
1376
+ const req = https.request({
1377
+ hostname: "api.x.ai",
1378
+ path: "/v1/chat/completions",
1379
+ method: "POST",
1380
+ headers: {
1381
+ "Content-Type": "application/json",
1382
+ "Content-Length": Buffer.byteLength(body),
1383
+ "Authorization": `Bearer ${apiKey}`,
1384
+ },
1385
+ }, (res) => {
1386
+ let data = "";
1387
+ res.on("data", c => data += c);
1388
+ res.on("end", () => {
1389
+ try {
1390
+ const p = JSON.parse(data);
1391
+ if (p.error) return resolve({ success: false, error: p.error.message || "Grok error" });
1392
+ const text = p.choices?.[0]?.message?.content || "";
1393
+ resolve({ success: true, text });
1394
+ } catch { resolve({ success: false, error: "Failed to parse Grok response" }); }
1395
+ });
1396
+ });
1397
+ req.on("error", e => resolve({ success: false, error: e.message }));
1398
+ req.setTimeout(60000, () => { req.destroy(); resolve({ success: false, error: "Grok API timeout" }); });
1399
+ req.write(body);
1400
+ req.end();
1401
+ });
1402
+ }
1403
+
1404
+ // ── Llama via Groq ────────────────────────────────────────────────────────────
1405
+ function callGroq(apiKey, prompt, system, maxTokens) {
1406
+ return new Promise((resolve) => {
1407
+ const messages = [];
1408
+ if (system) messages.push({ role: "system", content: system });
1409
+ messages.push({ role: "user", content: prompt });
1410
+
1411
+ const body = JSON.stringify({ model: "llama-3.3-70b-versatile", max_tokens: maxTokens, messages, temperature: 0.3 });
1412
+
1413
+ const req = https.request({
1414
+ hostname: "api.groq.com",
1415
+ path: "/openai/v1/chat/completions",
1416
+ method: "POST",
1417
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body), "Authorization": `Bearer ${apiKey}` },
1418
+ }, (res) => {
1419
+ let data = "";
1420
+ res.on("data", c => data += c);
1421
+ res.on("end", () => {
1422
+ try {
1423
+ const p = JSON.parse(data);
1424
+ if (p.error) return resolve({ success: false, error: p.error.message || "Groq error" });
1425
+ resolve({ success: true, text: p.choices?.[0]?.message?.content || "" });
1426
+ } catch { resolve({ success: false, error: "Failed to parse Groq response" }); }
1427
+ });
1428
+ });
1429
+ req.on("error", e => resolve({ success: false, error: e.message }));
1430
+ req.setTimeout(60000, () => { req.destroy(); resolve({ success: false, error: "Groq API timeout" }); });
1431
+ req.write(body);
1432
+ req.end();
1433
+ });
1434
+ }
1435
+
1436
+ // ── Llama via Meta API ────────────────────────────────────────────────────────
1437
+ function callMetaLlama(apiKey, prompt, system, maxTokens) {
1438
+ return new Promise((resolve) => {
1439
+ const messages = [];
1440
+ if (system) messages.push({ role: "system", content: system });
1441
+ messages.push({ role: "user", content: prompt });
1442
+
1443
+ const body = JSON.stringify({ model: "Llama-4-Scout-17B-16E-Instruct", max_tokens: maxTokens, messages, temperature: 0.3 });
1444
+
1445
+ const req = https.request({
1446
+ hostname: "api.llama.com",
1447
+ path: "/compat/v1/chat/completions",
1448
+ method: "POST",
1449
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body), "Authorization": `Bearer ${apiKey}` },
1450
+ }, (res) => {
1451
+ let data = "";
1452
+ res.on("data", c => data += c);
1453
+ res.on("end", () => {
1454
+ try {
1455
+ const p = JSON.parse(data);
1456
+ if (p.error) return resolve({ success: false, error: p.error.message || "Meta Llama error" });
1457
+ resolve({ success: true, text: p.choices?.[0]?.message?.content || "" });
1458
+ } catch { resolve({ success: false, error: "Failed to parse Meta Llama response" }); }
1459
+ });
1460
+ });
1461
+ req.on("error", e => resolve({ success: false, error: e.message }));
1462
+ req.setTimeout(60000, () => { req.destroy(); resolve({ success: false, error: "Meta Llama API timeout" }); });
1463
+ req.write(body);
1464
+ req.end();
1465
+ });
1466
+ }
1467
+
1468
+ // ─── Prompt builders ──────────────────────────────────────────────────────────
1469
+
1470
+ function buildTestCasePrompt(pageData, exploratoryMode = false) {
1471
+ const { meta, forms, buttons, links, tables, pageStructure, navigation, modals, alerts } = pageData;
1472
+
1473
+ // Slim down for token efficiency
1474
+ const slim = {
1475
+ url: meta.url,
1476
+ title: meta.title,
1477
+ pageType: meta.pageType,
1478
+ framework: meta.framework,
1479
+ forms: (forms || []).map(f => ({
1480
+ purpose: f.purpose,
1481
+ fields: (f.fields || []).map(fi => ({
1482
+ label: fi.label,
1483
+ type: fi.type,
1484
+ required: fi.required,
1485
+ locator: fi.locator,
1486
+ placeholder: fi.placeholder,
1487
+ })),
1488
+ validationRules: f.validationRules,
1489
+ submitButton: f.submitButton?.text,
1490
+ })),
1491
+ buttons: (buttons || []).slice(0, 20).map(b => ({
1492
+ text: b.text,
1493
+ action: b.action,
1494
+ disabled: b.disabled,
1495
+ locator: b.locator,
1496
+ })),
1497
+ internalLinks: (links || []).filter(l => !l.isExternal).slice(0, 12).map(l => ({
1498
+ text: l.text,
1499
+ path: l.path,
1500
+ locator: l.locator,
1501
+ })),
1502
+ tables: (tables || []).map(t => ({
1503
+ purpose: t.purpose,
1504
+ headers: t.headers,
1505
+ hasActions: t.hasActions,
1506
+ hasSearch: t.hasSearch,
1507
+ hasSorting: t.hasSorting,
1508
+ hasPagination: t.hasPagination,
1509
+ })),
1510
+ modals: (modals || []).length,
1511
+ alerts: (alerts || []).map(a => ({ type: a.type, text: a.text.slice(0, 80) })),
1512
+ pageFeatures: pageStructure,
1513
+ hasNavigation: (navigation || []).length > 0,
1514
+ };
1515
+
1516
+ const modeBlock = exploratoryMode
1517
+ ? `Generate 10-15 test cases focusing EXCLUSIVELY on:
1518
+ 1. Boundary values (min/max length, zero, negative numbers, empty vs whitespace-only)
1519
+ 2. Negative cases (invalid formats, wrong data types, SQL injection patterns, script tags in inputs)
1520
+ 3. Race conditions and double-submit scenarios (clicking submit twice rapidly)
1521
+ 4. Session edge cases (expired session, concurrent logins, back-button after logout)
1522
+ 5. Unusual user behaviour (rapid clicks, tab-key navigation, copy-paste, very long strings >1000 chars, Unicode and RTL characters)
1523
+ 6. State transition errors (submitting partially-filled forms, interrupting multi-step flows)
1524
+
1525
+ Do NOT generate happy-path or positive tests. Every test case must target a potential defect.`
1526
+ : `Generate 10-15 test cases covering ALL of:
1527
+ 1. Happy path (positive flows with valid data)
1528
+ 2. Negative cases (empty fields, invalid format, wrong credentials, boundary values)
1529
+ 3. UI state validation (error messages appear, loading states, disabled buttons)
1530
+ 4. Navigation flows (links go to correct pages)
1531
+ 5. Form validation (required fields, format rules, max length)
1532
+ 6. Edge cases specific to this page type`;
1533
+
1534
+ return `Analyse this web page and generate comprehensive QA test cases.
1535
+
1536
+ PAGE DATA:
1537
+ ${JSON.stringify(slim, null, 2)}
1538
+
1539
+ ${modeBlock}
1540
+
1541
+ Use the EXACT locators from the page data above in the "locators" field.
1542
+
1543
+ Respond with ONLY this JSON (no markdown, no explanation):
1544
+ {
1545
+ "testCases": [
1546
+ {
1547
+ "id": "TC001",
1548
+ "title": "Concise one-line description of what is verified",
1549
+ "category": "functional|negative|boundary|ui|navigation|accessibility|performance|security",
1550
+ "caseKind": "page",
1551
+ "packs": ["smoke", "regression"],
1552
+ "priority": "high|medium|low",
1553
+ "preconditions": "State required before this test (e.g. 'User is not logged in')",
1554
+ "steps": [
1555
+ "1. Navigate to <url>",
1556
+ "2. Perform <action> on <element>",
1557
+ "3. Observe <result>"
1558
+ ],
1559
+ "expectedResult": "Specific, measurable expected outcome",
1560
+ "locators": {
1561
+ "descriptive_name": "actual_css_or_xpath_from_page_data"
1562
+ },
1563
+ "testData": {
1564
+ "key": "value"
1565
+ },
1566
+ "tags": ["smoke", "auth"]
1567
+ }
1568
+ ]
1569
+ }`;
1570
+ }
1571
+
1572
+ // ─── Extract real page signals from scanned DOM ───────────────────────────────
1573
+ // This prevents the AI from guessing selectors like .error-message or title_contains()
1574
+ function extractPageSignals(pageData) {
1575
+ const elements = pageData?.elements || [];
1576
+ const url = pageData?.meta?.url || "";
1577
+ const urlPath = (() => { try { return new URL(url).pathname; } catch { return "/"; } })();
1578
+
1579
+ const errorEls = [];
1580
+ const successEls = [];
1581
+ const formEls = [];
1582
+ const alertEls = [];
1583
+
1584
+ for (const el of elements) {
1585
+ const tag = (el.tag || "").toLowerCase();
1586
+ const id = (el.id || "").toLowerCase();
1587
+ const cls = (el.className || el.class || "").toLowerCase();
1588
+ const dataTest = el.dataTest || el["data-test"] || "";
1589
+ const role = (el.role || "").toLowerCase();
1590
+ const text = (el.text || el.innerText || "").slice(0, 80);
1591
+ const ariaLabel = (el.ariaLabel || el["aria-label"] || "").toLowerCase();
1592
+
1593
+ // Best available selector (priority: data-test > id > css)
1594
+ const sel = dataTest ? `[data-test="${dataTest}"]`
1595
+ : el.id ? `#${el.id}`
1596
+ : el.css || "";
1597
+
1598
+ if (!sel) continue;
1599
+
1600
+ const isError = id.includes("error") || cls.includes("error") || dataTest.toLowerCase().includes("error") || role === "alert" || ariaLabel.includes("error");
1601
+ const isSuccess = id.includes("success") || cls.includes("success") || dataTest.toLowerCase().includes("success") || id.includes("welcome");
1602
+ const isAlert = cls.includes("alert") || role === "alert" || cls.includes("notification") || cls.includes("toast");
1603
+ const isForm = ["input","select","textarea","button"].includes(tag);
1604
+
1605
+ if (isError) errorEls.push({ tag, sel, text });
1606
+ else if (isAlert) alertEls.push({ tag, sel, text });
1607
+ if (isSuccess) successEls.push({ tag, sel, text });
1608
+ if (isForm) formEls.push({ tag, type: el.type || "", sel, name: el.name || "", placeholder: el.placeholder || "" });
1609
+ }
1610
+
1611
+ return {
1612
+ urlPath,
1613
+ errorEls: errorEls.slice(0, 5),
1614
+ alertEls: alertEls.slice(0, 5),
1615
+ successEls: successEls.slice(0, 5),
1616
+ formEls: formEls.slice(0, 20),
1617
+ allSelectors: elements
1618
+ .filter(e => e.dataTest || e.id)
1619
+ .slice(0, 30)
1620
+ .map(e => ({
1621
+ tag: e.tag,
1622
+ sel: e.dataTest ? `[data-test="${e.dataTest}"]` : `#${e.id}`,
1623
+ text: (e.text || "").slice(0, 40),
1624
+ type: e.type || ""
1625
+ }))
1626
+ };
1627
+ }
1628
+
1629
+ // ═══════════════════════════════════════════════════════════════════════════════
1630
+ // DETERMINISTIC SCRIPT GENERATOR
1631
+ // Page objects and test data are built directly from the scan — no AI required.
1632
+ // AI is called only for the test method bodies (action sequences + assertions).
1633
+ // ═══════════════════════════════════════════════════════════════════════════════
1634
+
1635
+ function _toSnake(str) {
1636
+ return String(str || "element").trim().toLowerCase()
1637
+ .replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "")
1638
+ .replace(/^(\d)/, "_$1") || "element";
1639
+ }
1640
+ function _toCamel(str) { return _toSnake(str).replace(/_([a-z])/g, (_, c) => c.toUpperCase()); }
1641
+ function _toPascalEl(str) { const c = _toCamel(str); return c.charAt(0).toUpperCase() + c.slice(1); }
1642
+ function _toScreaming(str) { return _toSnake(str).toUpperCase(); }
1643
+ function _elLabel(el) { return el.label || el.ariaLabel || el.text || el.name || el.testId || el.tag || "element"; }
1644
+
1645
+ function _actionType(el) {
1646
+ const tag = (el.tag || "").toLowerCase(), type = (el.type || "").toLowerCase();
1647
+ if (tag === "select") return "select";
1648
+ if (tag === "textarea") return "fill";
1649
+ if (tag === "input") {
1650
+ if (["text","email","password","search","tel","url","number","date","time"].includes(type)) return "fill";
1651
+ if (type === "checkbox") return "checkbox";
1652
+ if (type === "radio") return "radio";
1653
+ if (type === "file") return "upload";
1654
+ return "fill";
1655
+ }
1656
+ return "click";
1657
+ }
1658
+
1659
+ function _seleniumByTuple(el) {
1660
+ const s = el.locatorStrategy || "css", loc = el.locator || "";
1661
+ const q = (value) => JSON.stringify(String(value || ""));
1662
+ const m = {
1663
+ "test-id": `By.CSS_SELECTOR, ${q(loc)}`,
1664
+ "id": `By.ID, ${q(loc.replace(/^#/, ""))}`,
1665
+ "name": `By.NAME, ${q(loc)}`,
1666
+ "xpath": `By.XPATH, ${q(loc)}`,
1667
+ "css": `By.CSS_SELECTOR, ${q(loc)}`,
1668
+ "aria-label": `By.CSS_SELECTOR, ${q(loc)}`,
1669
+ };
1670
+ return `(${m[s] || `By.CSS_SELECTOR, ${q(loc)}`})`;
1671
+ }
1672
+ function _seleniumByJava(el) {
1673
+ const s = el.locatorStrategy || "css", loc = el.locator || "";
1674
+ const q = (value) => JSON.stringify(String(value || ""));
1675
+ const m = {
1676
+ "test-id": `By.cssSelector(${q(loc)})`,
1677
+ "id": `By.id(${q(loc.replace(/^#/, ""))})`,
1678
+ "name": `By.name(${q(loc)})`,
1679
+ "xpath": `By.xpath(${q(loc)})`,
1680
+ "css": `By.cssSelector(${q(loc)})`,
1681
+ "aria-label": `By.cssSelector(${q(loc)})`,
1682
+ };
1683
+ return m[s] || `By.cssSelector(${q(loc)})`;
1684
+ }
1685
+ function _pwLocPy(el) {
1686
+ const s = el.locatorStrategy || "css", loc = el.locator || "";
1687
+ const q = (value) => JSON.stringify(String(value || ""));
1688
+ if (s === "test-id" && el.testId) return `page.get_by_test_id(${q(el.testId)})`;
1689
+ if (s === "aria-label" && el.ariaLabel) return `page.get_by_label(${q(el.ariaLabel)})`;
1690
+ if (s === "xpath") return `page.locator(${q(`xpath=${loc}`)})`;
1691
+ return `page.locator(${q(loc)})`;
1692
+ }
1693
+ function _pwLocTs(el) {
1694
+ const s = el.locatorStrategy || "css", loc = el.locator || "";
1695
+ const q = (value) => JSON.stringify(String(value || ""));
1696
+ if (s === "test-id" && el.testId) return `page.getByTestId(${q(el.testId)})`;
1697
+ if (s === "aria-label" && el.ariaLabel) return `page.getByLabel(${q(el.ariaLabel)})`;
1698
+ if (s === "xpath") return `page.locator(${q(`xpath=${loc}`)})`;
1699
+ return `page.locator(${q(loc)})`;
1700
+ }
1701
+
1702
+ function _collectElements(pageData) {
1703
+ const seen = new Set(), all = [];
1704
+ const add = (el) => { if (!el?.locator || seen.has(el.locator)) return; seen.add(el.locator); all.push(el); };
1705
+ for (const form of (pageData?.forms || [])) for (const field of (form.fields || [])) add(field);
1706
+ for (const el of (pageData?.inputs || [])) add(el);
1707
+ for (const btn of (pageData?.buttons || [])) add(btn);
1708
+ return all;
1709
+ }
1710
+
1711
+ function _genSelPy(elements, className, url) {
1712
+ const locs = elements.map(el => ` ${_toScreaming(_elLabel(el))} = ${_seleniumByTuple(el)}`).join("\n");
1713
+ const methods = elements.map(el => {
1714
+ const C = _toScreaming(_elLabel(el)), at = _actionType(el), n = _toSnake(_elLabel(el)), lbl = _elLabel(el);
1715
+ if (at === "fill") return `\n def enter_${n}(self, value: str):\n """Enter text in ${lbl}."""\n el = self.wait.until(EC.element_to_be_clickable(self.${C}))\n el.clear()\n el.send_keys(value)`;
1716
+ if (at === "click") return `\n def click_${n}(self):\n """Click ${lbl}."""\n self.wait.until(EC.element_to_be_clickable(self.${C})).click()`;
1717
+ if (at === "select") return `\n def select_${n}(self, option: str):\n from selenium.webdriver.support.ui import Select\n Select(self.wait.until(EC.visibility_of_element_located(self.${C}))).select_by_visible_text(option)`;
1718
+ if (at === "checkbox") return `\n def check_${n}(self):\n el = self.wait.until(EC.element_to_be_clickable(self.${C}))\n if not el.is_selected(): el.click()\n\n def uncheck_${n}(self):\n el = self.wait.until(EC.element_to_be_clickable(self.${C}))\n if el.is_selected(): el.click()`;
1719
+ return `\n def click_${n}(self):\n self.wait.until(EC.element_to_be_clickable(self.${C})).click()`;
1720
+ }).join("\n");
1721
+ return `from selenium.webdriver.common.by import By\nfrom selenium.webdriver.support import expected_conditions as EC\nfrom selenium.webdriver.support.ui import WebDriverWait\nfrom base_test import BaseTest, resolve_driver\n\n\nclass ${className}(BaseTest):\n """Page Object for ${url}"""\n\n # ── Locators ──────────────────────────────────────────────────────────────\n${locs}\n\n # ── Actions ───────────────────────────────────────────────────────────────${methods}\n`;
1722
+ }
1723
+
1724
+ function _genPwPy(elements, className, url) {
1725
+ const inits = elements.map(el => ` self.${_toSnake(_elLabel(el))} = ${_pwLocPy(el)}`).join("\n");
1726
+ const methods = elements.map(el => {
1727
+ const n = _toSnake(_elLabel(el)), at = _actionType(el), lbl = _elLabel(el);
1728
+ if (at === "fill") return `\n def enter_${n}(self, value: str):\n self.${n}.fill(value)`;
1729
+ if (at === "click") return `\n def click_${n}(self):\n self.${n}.click()`;
1730
+ if (at === "select") return `\n def select_${n}(self, option: str):\n self.${n}.select_option(option)`;
1731
+ if (at === "checkbox") return `\n def check_${n}(self):\n self.${n}.check()\n\n def uncheck_${n}(self):\n self.${n}.uncheck()`;
1732
+ return `\n def click_${n}(self):\n self.${n}.click()`;
1733
+ }).join("\n");
1734
+ return `from playwright.sync_api import Page, expect\n\n\nclass ${className}:\n """Page Object for ${url}"""\n\n def __init__(self, page: Page):\n self.page = page\n # ── Locators ──────────────────────────────────────────────────────────\n${inits}\n\n # ── Actions ───────────────────────────────────────────────────────────────${methods}\n`;
1735
+ }
1736
+
1737
+ function _genPwTs(elements, className, url) {
1738
+ const props = elements.map(el => ` readonly ${_toCamel(_elLabel(el))}: Locator;`).join("\n");
1739
+ const inits = elements.map(el => ` this.${_toCamel(_elLabel(el))} = ${_pwLocTs(el)};`).join("\n");
1740
+ const methods = elements.map(el => {
1741
+ const n = _toCamel(_elLabel(el)), p = _toPascalEl(_elLabel(el)), at = _actionType(el);
1742
+ if (at === "fill") return `\n async enter${p}(value: string): Promise<void> {\n await this.${n}.fill(value);\n }`;
1743
+ if (at === "click") return `\n async click${p}(): Promise<void> {\n await this.${n}.click();\n }`;
1744
+ if (at === "select") return `\n async select${p}(option: string): Promise<void> {\n await this.${n}.selectOption(option);\n }`;
1745
+ if (at === "checkbox") return `\n async check${p}(): Promise<void> {\n await this.${n}.check();\n }\n\n async uncheck${p}(): Promise<void> {\n await this.${n}.uncheck();\n }`;
1746
+ return `\n async click${p}(): Promise<void> {\n await this.${n}.click();\n }`;
1747
+ }).join("\n");
1748
+ return `import { Page, Locator } from '@playwright/test';\n\nexport class ${className} {\n${props}\n\n constructor(readonly page: Page) {\n${inits}\n }\n${methods}\n}\n`;
1749
+ }
1750
+
1751
+ function _genSelJava(elements, className, url) {
1752
+ const locs = elements.map(el => ` private static final By ${_toScreaming(_elLabel(el))} = ${_seleniumByJava(el)};`).join("\n");
1753
+ const methods = elements.map(el => {
1754
+ const C = _toScreaming(_elLabel(el)), p = _toPascalEl(_elLabel(el)), at = _actionType(el);
1755
+ if (at === "fill") return `\n public void enter${p}(String value) {\n wait.until(ExpectedConditions.elementToBeClickable(${C})).sendKeys(value);\n }`;
1756
+ if (at === "click") return `\n public void click${p}() {\n wait.until(ExpectedConditions.elementToBeClickable(${C})).click();\n }`;
1757
+ if (at === "select") return `\n public void select${p}(String option) {\n new Select(wait.until(ExpectedConditions.visibilityOfElementLocated(${C}))).selectByVisibleText(option);\n }`;
1758
+ if (at === "checkbox") return `\n public void check${p}() {\n WebElement el = wait.until(ExpectedConditions.elementToBeClickable(${C}));\n if (!el.isSelected()) el.click();\n }`;
1759
+ return `\n public void click${p}() {\n wait.until(ExpectedConditions.elementToBeClickable(${C})).click();\n }`;
1760
+ }).join("\n");
1761
+ return `import org.openqa.selenium.By;\nimport org.openqa.selenium.WebDriver;\nimport org.openqa.selenium.WebElement;\nimport org.openqa.selenium.support.ui.ExpectedConditions;\nimport org.openqa.selenium.support.ui.Select;\nimport org.openqa.selenium.support.ui.WebDriverWait;\n\n/**\n * Page Object for ${url}\n */\npublic class ${className} extends BaseTest {\n\n // ── Locators ──────────────────────────────────────────────────────────────\n${locs}\n\n public ${className}(WebDriver driver, WebDriverWait wait) {\n super(driver, wait);\n }\n\n // ── Actions ───────────────────────────────────────────────────────────────${methods}\n}\n`;
1762
+ }
1763
+
1764
+ function generatePageObject(pageData, framework) {
1765
+ const elements = _collectElements(pageData);
1766
+ const pageType = pageData?.meta?.pageType || "page";
1767
+ const url = pageData?.meta?.url || "";
1768
+ const className = toPascalCase(pageType) + "Page";
1769
+ const isJava = framework === "selenium-java", isTs = framework === "playwright-typescript";
1770
+ const filename = isTs ? `${_toSnake(pageType)}_page.ts` : isJava ? `${className}.java` : `${_toSnake(pageType)}_page.py`;
1771
+ let content = "";
1772
+ if (framework === "selenium-python") content = _genSelPy(elements, className, url);
1773
+ else if (framework === "playwright-python") content = _genPwPy(elements, className, url);
1774
+ else if (framework === "playwright-typescript") content = _genPwTs(elements, className, url);
1775
+ else if (framework === "selenium-java") content = _genSelJava(elements, className, url);
1776
+ return { filename, content };
1777
+ }
1778
+
1779
+ function generateTestData(testCases, pageData, framework) {
1780
+ const isTs = framework === "playwright-typescript", isJava = framework === "selenium-java";
1781
+ const filename = isTs ? "test_data.ts" : isJava ? "test_data.json" : "test_data.py";
1782
+ const merged = {};
1783
+ for (const tc of (testCases || [])) {
1784
+ if (tc.testData && typeof tc.testData === "object") Object.assign(merged, tc.testData);
1785
+ }
1786
+ for (const el of _collectElements(pageData)) {
1787
+ const t = (el.type || "").toLowerCase(), k = `valid_${_toSnake(_elLabel(el))}`;
1788
+ if (merged[k]) continue;
1789
+ if (t === "email") merged[k] = "test@example.com";
1790
+ else if (t === "password") merged[k] = "TestPassword123!";
1791
+ else if (t === "tel") merged[k] = "+1-555-000-0000";
1792
+ else if (t === "url") merged[k] = "https://example.com";
1793
+ else if (t === "number") merged[k] = 42;
1794
+ else if (t === "date") merged[k] = "2024-01-15";
1795
+ }
1796
+ if (isJava) return { filename, content: JSON.stringify(merged, null, 2) };
1797
+ if (isTs) return { filename, content: `export const testData = ${JSON.stringify(merged, null, 2)};\n` };
1798
+ const py = JSON.stringify(merged, null, 2).replace(/\btrue\b/g, "True").replace(/\bfalse\b/g, "False").replace(/\bnull\b/g, "None");
1799
+ return { filename, content: `# Auto-generated test data\nTEST_DATA = ${py}\n` };
1800
+ }
1801
+
1802
+ function _extractApiSummary(pageObjectContent, framework) {
1803
+ return pageObjectContent.split("\n").filter(line => {
1804
+ if (framework === "playwright-typescript") return /^\s+async\s+\w+\(/.test(line);
1805
+ if (framework === "selenium-java") return /^\s+public\s+\w+\s+\w+\(/.test(line);
1806
+ return /^\s+def\s+\w+\(self/.test(line);
1807
+ }).map(l => l.trim()).join("\n");
1808
+ }
1809
+
1810
+ function buildTestsOnlyPrompt(testCases, pageData, framework, pageObjectFilename, pageObjectApi, networkCalls, customAssertions, datasetsMap) {
1811
+ const pageType = pageData?.meta?.pageType || "page";
1812
+ const isTs = framework === "playwright-typescript", isJava = framework === "selenium-java";
1813
+ const ext = isJava ? "java" : isTs ? "ts" : "py";
1814
+ const baseUrl = pageData?.meta?.url || "https://example.com";
1815
+ const filename = `test_${_toSnake(pageType)}.${ext}`;
1816
+ const slim = (testCases || []).slice(0, 15).map(tc => ({ id: tc.id, title: tc.title, steps: tc.steps, expectedResult: tc.expectedResult, testData: tc.testData || {}, customAssertions: tc.customAssertions || [] }));
1817
+
1818
+ const netSection = networkCalls?.length
1819
+ ? `\nNETWORK CALLS TO ASSERT:\n${networkCalls.map((n, i) => { try { return `${i+1}. ${n.method} ${new URL(n.url).pathname} → ${n.statusCode}`; } catch { return `${i+1}. ${n.method} ${n.url} → ${n.statusCode}`; } }).join("\n")}\n`
1820
+ : "";
1821
+
1822
+ const customSection = customAssertions?.length
1823
+ ? `\nGLOBAL CUSTOM ASSERTIONS (inject in the most relevant test method):\n${customAssertions.map((a, i) => `${i+1}. ${a.type} — locator: ${a.locator}${a.value ? ` — expected: "${a.value}"` : ""}`).join("\n")}\n`
1824
+ : "";
1825
+
1826
+ const datasetSection = datasetsMap && Object.keys(datasetsMap).length
1827
+ ? `\nDATA-DRIVEN DATASETS (parametrize these test methods):\n${Object.entries(datasetsMap).map(([id, rows]) => `${id}: ${rows.length} rows — keys: ${Object.keys(rows[0] || {}).join(", ")}\n Data: ${JSON.stringify(rows)}`).join("\n")}\n`
1828
+ : "";
1829
+
1830
+ return `Generate ONLY the test class file for ${framework}.
1831
+
1832
+ PAGE OBJECT: ${pageObjectFilename} (already generated — import and use it)
1833
+ BASE URL: ${baseUrl}
1834
+
1835
+ AVAILABLE PAGE OBJECT METHODS (use ONLY these — never call driver.find_element or page.locator directly):
1836
+ ${pageObjectApi}
1837
+ ${netSection}${customSection}${datasetSection}
1838
+ TEST CASES (${slim.length}):
1839
+ ${JSON.stringify(slim, null, 2)}
1840
+
1841
+ Rules:
1842
+ - One test method per test case named test_{id}_{snake_title}
1843
+ - Use ONLY the page object methods listed above for all interactions
1844
+ - Assertions: WebDriverWait + EC (Selenium) or expect() (Playwright) — no time.sleep
1845
+ - Test data from TEST_DATA/testData imports — no hardcoded credentials
1846
+ - Each test is fully independent (setup in setup_method / beforeEach)
1847
+ - If customAssertions are present in a test case, inject them at the end of that test method
1848
+
1849
+ Respond with ONLY this JSON (no markdown fences):
1850
+ {"tests":{"filename":"${filename}","content":"<full test class with all imports>"}}`;
1851
+ }
1852
+
1853
+ // ─────────────────────────────────────────────────────────────────────────────
1854
+
1855
+ function buildScriptPrompt(testCases, pageData, framework, customAssertions, networkCalls, datasetsMap) {
1856
+ const pageType = pageData?.meta?.pageType || "page";
1857
+ const pageUrl = pageData?.meta?.url || "https://example.com";
1858
+ const className = toPascalCase(pageType);
1859
+ const signals = extractPageSignals(pageData);
1860
+
1861
+ // Collect flaky elements from scan data
1862
+ const flakyElements = [
1863
+ ...(pageData?.buttons || []).filter(b => b.flaky),
1864
+ ...(pageData?.inputs || []).filter(i => i.flaky),
1865
+ ...((pageData?.forms || []).flatMap(f => (f.fields || []).filter(fi => fi.flaky))),
1866
+ ];
1867
+
1868
+ const fwConfig = {
1869
+ "selenium-python": {
1870
+ lang: "Python", runner: "pytest", ext: "py",
1871
+ configFile: "pytest.ini", baseClass: "BaseTest",
1872
+ imports: "from selenium import webdriver\nfrom selenium.webdriver.common.by import By\nfrom selenium.webdriver.support.ui import WebDriverWait\nfrom selenium.webdriver.support import expected_conditions as EC",
1873
+ },
1874
+ "selenium-java": {
1875
+ lang: "Java", runner: "TestNG", ext: "java",
1876
+ configFile: "testng.xml", baseClass: "BaseTest",
1877
+ imports: "import org.openqa.selenium.*;\nimport org.openqa.selenium.chrome.ChromeDriver;\nimport org.openqa.selenium.support.ui.*;\nimport org.testng.annotations.*;",
1878
+ },
1879
+ "playwright-python": {
1880
+ lang: "Python", runner: "pytest-playwright", ext: "py",
1881
+ configFile: "pytest.ini", baseClass: "BaseTest",
1882
+ imports: "import pytest\nfrom playwright.sync_api import Page, expect",
1883
+ },
1884
+ "playwright-typescript": {
1885
+ lang: "TypeScript", runner: "Playwright Test", ext: "ts",
1886
+ configFile: "playwright.config.ts", baseClass: "",
1887
+ imports: "import { test, expect, Page } from '@playwright/test';",
1888
+ },
1889
+ };
1890
+
1891
+ const cfg = fwConfig[framework] || fwConfig["selenium-python"];
1892
+ const ext = cfg.ext;
1893
+
1894
+ // Only send approved test cases, slimmed down.
1895
+ // Embed any assertions the user pinned to a specific TC directly on that TC object
1896
+ // so the AI has unambiguous per-method injection instructions.
1897
+ const slimCases = testCases.map(tc => {
1898
+ const pinned = (customAssertions || []).filter(a => a.tcId === tc.id);
1899
+ const entry = {
1900
+ id: tc.id,
1901
+ title: tc.title,
1902
+ priority: tc.priority,
1903
+ preconditions: tc.preconditions,
1904
+ steps: tc.steps,
1905
+ expectedResult: tc.expectedResult,
1906
+ locators: tc.locators,
1907
+ testData: tc.testData,
1908
+ };
1909
+ if (pinned.length > 0) {
1910
+ entry.customAssertions = pinned.map(a => ({
1911
+ type: a.type, locator: a.locator, value: a.value, attrName: a.attrName,
1912
+ }));
1913
+ }
1914
+ return entry;
1915
+ });
1916
+
1917
+ // Assertions not pinned to a TC — AI still tries to match by locator (legacy behaviour)
1918
+ const globalAssertions = (customAssertions || []).filter(a => !a.tcId);
1919
+
1920
+ // Build page signals section for the prompt
1921
+ const signalsSection = `
1922
+ REAL PAGE DOM SIGNALS (extracted from live scan — use ONLY these selectors, never guess):
1923
+ - Current page URL path: ${signals.urlPath}
1924
+ ${signals.errorEls.length ? `- Error/validation elements found: ${JSON.stringify(signals.errorEls)}` : "- No error elements found in scan (use URL change or element disappearance to detect errors)"}
1925
+ ${signals.alertEls.length ? `- Alert/notification elements: ${JSON.stringify(signals.alertEls)}` : ""}
1926
+ ${signals.successEls.length ? `- Success indicator elements: ${JSON.stringify(signals.successEls)}` : ""}
1927
+ - All confirmed selectors on page (data-test / id): ${JSON.stringify(signals.allSelectors)}
1928
+
1929
+ ASSERTION RULES based on real DOM:
1930
+ - SUCCESS after form submit: use EC.url_contains("${signals.urlPath.split("/").filter(Boolean).pop() || ""}") OR EC.visibility_of_element_located() for a known post-action element
1931
+ - ERROR after form submit: use ${signals.errorEls[0]?.sel || signals.alertEls[0]?.sel ? `EC.visibility_of_element_located((By.CSS_SELECTOR, "${signals.errorEls[0]?.sel || signals.alertEls[0]?.sel}"))` : "EC.url_contains() to confirm page did not change, or presence of form still on page"}
1932
+ - NEVER use EC.title_contains() unless the page title is confirmed above
1933
+ - ONLY use selectors from the "confirmed selectors" list above
1934
+ `;
1935
+
1936
+ return `Generate a complete, production-ready ${framework} automation test suite.
1937
+
1938
+ FRAMEWORK: ${framework}
1939
+ LANGUAGE: ${cfg.lang}
1940
+ TEST RUNNER: ${cfg.runner}
1941
+ PAGE TYPE: ${pageType}
1942
+ BASE URL: ${pageUrl}
1943
+ PAGE CLASS NAME: ${className}Page
1944
+ ${signalsSection}
1945
+ TEST CASES (${slimCases.length} total):
1946
+ ${JSON.stringify(slimCases, null, 2)}
1947
+ ${globalAssertions.length ? `
1948
+ CUSTOM ASSERTIONS (user-defined, no specific TC pinned — inject in the most relevant test method by matching locator):
1949
+ ${globalAssertions.map((a, i) => {
1950
+ const typeMap = { text_equals: "assert element text equals", is_visible: "assert element is visible", not_exists: "assert element does NOT exist", attr_equals: `assert attribute '${a.attrName}' equals`, url_contains: "assert page URL contains", count_equals: "assert element count equals" };
1951
+ return `${i+1}. ${typeMap[a.type] || a.type} — locator: ${a.locator}${a.value ? ` — expected: "${a.value}"` : ""}`;
1952
+ }).join("\n")}
1953
+ ` : ""}
1954
+ - Any test case with a "customAssertions" array in the TEST CASES JSON above MUST inject those assertions at the end of that specific test method — these are user-verified and override AI-guessed assertions for that step
1955
+ ${networkCalls?.length ? `
1956
+ NETWORK / API ASSERTIONS (captured from real browser interactions — MUST assert these in tests):
1957
+ ${networkCalls.map((n, i) => {
1958
+ const urlObj = (() => { try { return new URL(n.url); } catch { return null; } })();
1959
+ const path = urlObj ? urlObj.pathname : n.url;
1960
+ return `${i+1}. ${n.method} ${path} → expected status ${n.statusCode}`;
1961
+ }).join("\n")}
1962
+ Framework-specific assertion patterns to use:
1963
+ ${framework === "playwright-typescript" ? `- const resp = await page.waitForResponse(r => r.url().includes('PATH') && r.request().method() === 'METHOD'); expect(resp.status()).toBe(STATUS);` : ""}
1964
+ ${framework === "playwright-python" ? `- with page.expect_response(lambda r: 'PATH' in r.url and r.request.method == 'METHOD') as resp_info:\n # trigger action\nassert resp_info.value.status == STATUS` : ""}
1965
+ ${framework === "selenium-python" ? `# REST Assured-style API assertion (add as comment + requests snippet):\n# import requests\n# response = requests.METHOD('BASE_URL/PATH')\n# assert response.status_code == STATUS` : ""}
1966
+ ${framework === "selenium-java" ? `// REST Assured assertion:\n// given().when().METHOD("BASE_URL/PATH").then().statusCode(STATUS);` : ""}
1967
+ - Add network assertions in the test method that triggers the relevant API call
1968
+ ` : ""}
1969
+ ${datasetsMap && Object.keys(datasetsMap).length > 0 ? `
1970
+ DATA-DRIVEN DATASETS (MUST parametrize these test methods):
1971
+ ${Object.entries(datasetsMap).map(([tcId, rows]) => {
1972
+ const keys = Object.keys(rows[0] || {});
1973
+ const paramName = framework.includes("java") ? "@DataProvider" : framework.includes("typescript") ? "test.each" : "@pytest.mark.parametrize";
1974
+ return `Test ${tcId}: ${rows.length} dataset rows | Keys: ${keys.join(", ")}\n Datasets: ${JSON.stringify(rows)}\n Use ${paramName} to run this test ${rows.length} times, once per row`;
1975
+ }).join("\n")}
1976
+ - Wrap ONLY the listed test methods with the parametrize decorator/annotation
1977
+ - Use the dataset keys as method parameters and substitute them in place of hardcoded values
1978
+ ` : ""}
1979
+ ${pageData?.iframes?.filter(f => f.elements?.length > 0).length > 0 ? `
1980
+ IFRAME ELEMENTS (must use frame-switching locators):
1981
+ ${pageData.iframes.filter(f => f.elements?.length > 0).map(f =>
1982
+ `iFrame: ${f.locator} (${f.elements.length} elements)\n ${framework.includes("playwright") ? `Playwright: page.frameLocator('${f.locator}').locator(...)` : `Selenium: driver.switch_to.frame("${f.name || f.locator}") → find element → driver.switch_to.default_content()`}`
1983
+ ).join("\n")}
1984
+ ` : ""}
1985
+ ${pageData?.shadowElements?.filter(s => s.elements?.length > 0).length > 0 ? `
1986
+ SHADOW DOM ELEMENTS (use piercing selectors):
1987
+ ${pageData.shadowElements.filter(s => s.elements?.length > 0).map(s =>
1988
+ `Host: ${s.host} → elements: ${s.elements.map(e => e.locator).join(", ")}\n ${framework.includes("playwright") ? `Playwright: page.locator('${s.host} >> ${s.elements[0]?.locator || "input"}')` : `Selenium: driver.execute_script("return document.querySelector('${s.host}').shadowRoot.querySelector('...')")`}`
1989
+ ).join("\n")}
1990
+ ` : ""}
1991
+ IMPORTANT — DO NOT generate base_test.py or config files. They are handled by templates.
1992
+ Generate ONLY these 3 files. Use proper ${cfg.lang} syntax throughout.
1993
+
1994
+ Requirements:
1995
+ - Follow Page Object Model (POM) strictly — no locators in test files
1996
+ - Use explicit waits (WebDriverWait / page.wait_for / expect) — NO time.sleep
1997
+ - Add docstrings/comments for every method and test
1998
+ - Include ALL locators from test cases in the Page Object as class-level constants
1999
+ - Parameterise test data — no hardcoded values in tests
2000
+ - Each test must be independent (no shared state)
2001
+ - ONLY use selectors confirmed in the DOM signals above — never guess
2002
+ - For success: use EC.url_contains() or EC.visibility_of_element_located()
2003
+ - For errors: use EC.visibility_of_element_located() with the real error selector from DOM signals
2004
+ ${flakyElements.length > 0 ? `- FLAKINESS WARNING: ${flakyElements.length} element(s) have unstable locators. For each flaky locator used, add a comment: # FLAKY: consider replacing with data-testid or stable attribute\n Flaky locators: ${flakyElements.map(e => e.locator).join(", ")}` : ""}
2005
+ ${framework === "selenium-python" ? `- test_*.py imports are provided by template — DO NOT repeat them, just write the class body` : ""}
2006
+
2007
+ Respond ONLY with this JSON (no markdown fences, no extra text):
2008
+ {
2009
+ "pageObject": {
2010
+ "filename": "${pageType.toLowerCase()}_page.${ext}",
2011
+ "content": "Page Object class with ALL locators as class constants and action methods"
2012
+ },
2013
+ "testData": {
2014
+ "filename": "test_data.${ext}",
2015
+ "content": "All test data including valid inputs, invalid inputs, boundary values"
2016
+ },
2017
+ "tests": {
2018
+ "filename": "test_${pageType.toLowerCase()}.${ext}",
2019
+ "content": "Full test class with one test method per test case, using POM — include all imports at top"
2020
+ }
2021
+ }`;
2022
+ }
2023
+
2024
+ // ─── Performance Assertion Script (template-based) ────────────────────────────
2025
+
2026
+ function buildPerformanceScript(pageData, framework) {
2027
+ const pageType = (pageData?.meta?.pageType || "page").toLowerCase();
2028
+ const url = pageData?.meta?.url || "https://example.com";
2029
+ const perf = pageData.performance || {};
2030
+ const loadThreshold = perf.suggestedThresholds?.loadTime || 3000;
2031
+ const fcpThreshold = perf.suggestedThresholds?.fcp || 2500;
2032
+ const ttfbThreshold = perf.suggestedThresholds?.ttfb || 800;
2033
+
2034
+ if (framework === "playwright-typescript") {
2035
+ return {
2036
+ filename: `test-performance-${pageType}.spec.ts`,
2037
+ content: `import { test, expect } from '@playwright/test';
2038
+
2039
+ /**
2040
+ * Performance Assertion Tests — ${pageType}
2041
+ * Measured baseline: Load=${perf.loadTime || "N/A"}ms, FCP=${perf.fcp || "N/A"}ms, TTFB=${perf.ttfb || "N/A"}ms
2042
+ * Thresholds: Load<${loadThreshold}ms, FCP<${fcpThreshold}ms, TTFB<${ttfbThreshold}ms
2043
+ */
2044
+
2045
+ test.describe('Performance SLA — ${pageType}', () => {
2046
+ test('page load time is within SLA', async ({ page }) => {
2047
+ const start = Date.now();
2048
+ await page.goto('${url}');
2049
+ await page.waitForLoadState('load');
2050
+ const loadTime = Date.now() - start;
2051
+ expect(loadTime).toBeLessThan(${loadThreshold}); // SLA: <${loadThreshold}ms
2052
+ });
2053
+
2054
+ test('first contentful paint is within SLA', async ({ page }) => {
2055
+ await page.goto('${url}');
2056
+ const fcp = await page.evaluate(() => {
2057
+ const entry = performance.getEntriesByName('first-contentful-paint')[0];
2058
+ return entry ? Math.round(entry.startTime) : null;
2059
+ });
2060
+ if (fcp !== null) expect(fcp).toBeLessThan(${fcpThreshold}); // SLA: <${fcpThreshold}ms
2061
+ });
2062
+
2063
+ test('time to first byte is within SLA', async ({ page }) => {
2064
+ await page.goto('${url}');
2065
+ const ttfb = await page.evaluate(() => {
2066
+ const nav = performance.getEntriesByType('navigation')[0];
2067
+ return nav ? Math.round(nav.responseStart - nav.startTime) : null;
2068
+ });
2069
+ if (ttfb !== null) expect(ttfb).toBeLessThan(${ttfbThreshold}); // SLA: <${ttfbThreshold}ms
2070
+ });
2071
+ });`,
2072
+ };
2073
+ }
2074
+
2075
+ if (framework === "playwright-python") {
2076
+ return {
2077
+ filename: `test_performance_${pageType}.py`,
2078
+ content: `"""
2079
+ Performance Assertion Tests — ${pageType}
2080
+ Measured baseline: Load=${perf.loadTime || "N/A"}ms, FCP=${perf.fcp || "N/A"}ms, TTFB=${perf.ttfb || "N/A"}ms
2081
+ Thresholds: Load<${loadThreshold}ms, FCP<${fcpThreshold}ms, TTFB<${ttfbThreshold}ms
2082
+ """
2083
+ import time
2084
+ import pytest
2085
+ from playwright.sync_api import Page
2086
+
2087
+ LOAD_THRESHOLD_MS = ${loadThreshold}
2088
+ FCP_THRESHOLD_MS = ${fcpThreshold}
2089
+ TTFB_THRESHOLD_MS = ${ttfbThreshold}
2090
+ PAGE_URL = '${url}'
2091
+
2092
+
2093
+ def test_page_load_time(page: Page):
2094
+ """Page load time must be within SLA."""
2095
+ start = time.time()
2096
+ page.goto(PAGE_URL)
2097
+ page.wait_for_load_state('load')
2098
+ load_ms = (time.time() - start) * 1000
2099
+ assert load_ms < LOAD_THRESHOLD_MS, f"Load time {load_ms:.0f}ms exceeds SLA of {LOAD_THRESHOLD_MS}ms"
2100
+
2101
+
2102
+ def test_first_contentful_paint(page: Page):
2103
+ """FCP must be within SLA."""
2104
+ page.goto(PAGE_URL)
2105
+ fcp = page.evaluate("""() => {
2106
+ const entry = performance.getEntriesByName('first-contentful-paint')[0];
2107
+ return entry ? Math.round(entry.startTime) : null;
2108
+ }""")
2109
+ if fcp is not None:
2110
+ assert fcp < FCP_THRESHOLD_MS, f"FCP {fcp}ms exceeds SLA of {FCP_THRESHOLD_MS}ms"
2111
+
2112
+
2113
+ def test_time_to_first_byte(page: Page):
2114
+ """TTFB must be within SLA."""
2115
+ page.goto(PAGE_URL)
2116
+ ttfb = page.evaluate("""() => {
2117
+ const nav = performance.getEntriesByType('navigation')[0];
2118
+ return nav ? Math.round(nav.responseStart - nav.startTime) : null;
2119
+ }""")
2120
+ if ttfb is not None:
2121
+ assert ttfb < TTFB_THRESHOLD_MS, f"TTFB {ttfb}ms exceeds SLA of {TTFB_THRESHOLD_MS}ms"`,
2122
+ };
2123
+ }
2124
+
2125
+ // Selenium fallback (Python or Java — uses JS executor)
2126
+ if (framework === "selenium-java") {
2127
+ return {
2128
+ filename: `PerformanceTest${toPascalCase(pageType)}.java`,
2129
+ content: `/**
2130
+ * Performance Assertion Tests — ${pageType}
2131
+ * Measured baseline: Load=${perf.loadTime || "N/A"}ms, FCP=${perf.fcp || "N/A"}ms, TTFB=${perf.ttfb || "N/A"}ms
2132
+ */
2133
+ import org.openqa.selenium.*;
2134
+ import org.openqa.selenium.chrome.ChromeDriver;
2135
+ import org.testng.Assert;
2136
+ import org.testng.annotations.*;
2137
+
2138
+ public class PerformanceTest${toPascalCase(pageType)} {
2139
+ private WebDriver driver;
2140
+ private static final long LOAD_THRESHOLD_MS = ${loadThreshold};
2141
+ private static final long TTFB_THRESHOLD_MS = ${ttfbThreshold};
2142
+
2143
+ @BeforeMethod
2144
+ public void setUp() { driver = new ChromeDriver(); }
2145
+
2146
+ @Test
2147
+ public void testPageLoadTime() {
2148
+ long start = System.currentTimeMillis();
2149
+ driver.get("${url}");
2150
+ long loadTime = System.currentTimeMillis() - start;
2151
+ Assert.assertTrue(loadTime < LOAD_THRESHOLD_MS,
2152
+ "Load time " + loadTime + "ms exceeds SLA of " + LOAD_THRESHOLD_MS + "ms");
2153
+ }
2154
+
2155
+ @Test
2156
+ public void testTimeToFirstByte() {
2157
+ driver.get("${url}");
2158
+ Long ttfb = (Long) ((JavascriptExecutor) driver).executeScript(
2159
+ "const nav = performance.getEntriesByType('navigation')[0]; " +
2160
+ "return nav ? Math.round(nav.responseStart - nav.startTime) : null;");
2161
+ if (ttfb != null) {
2162
+ Assert.assertTrue(ttfb < TTFB_THRESHOLD_MS,
2163
+ "TTFB " + ttfb + "ms exceeds SLA of " + TTFB_THRESHOLD_MS + "ms");
2164
+ }
2165
+ }
2166
+
2167
+ @AfterMethod
2168
+ public void tearDown() { if (driver != null) driver.quit(); }
2169
+ }`,
2170
+ };
2171
+ }
2172
+
2173
+ // selenium-python
2174
+ return {
2175
+ filename: `test_performance_${pageType}.py`,
2176
+ content: `"""
2177
+ Performance Assertion Tests — ${pageType}
2178
+ Measured baseline: Load=${perf.loadTime || "N/A"}ms, FCP=${perf.fcp || "N/A"}ms, TTFB=${perf.ttfb || "N/A"}ms
2179
+ Thresholds: Load<${loadThreshold}ms, TTFB<${ttfbThreshold}ms
2180
+ """
2181
+ import time
2182
+ import pytest
2183
+ from selenium import webdriver
2184
+
2185
+ LOAD_THRESHOLD_MS = ${loadThreshold}
2186
+ TTFB_THRESHOLD_MS = ${ttfbThreshold}
2187
+ PAGE_URL = '${url}'
2188
+
2189
+
2190
+ @pytest.fixture
2191
+ def driver():
2192
+ d = webdriver.Chrome()
2193
+ yield d
2194
+ d.quit()
2195
+
2196
+
2197
+ def test_page_load_time(driver):
2198
+ """Page load time must be within SLA."""
2199
+ start = time.time()
2200
+ driver.get(PAGE_URL)
2201
+ load_ms = (time.time() - start) * 1000
2202
+ assert load_ms < LOAD_THRESHOLD_MS, f"Load time {load_ms:.0f}ms exceeds SLA of {LOAD_THRESHOLD_MS}ms"
2203
+
2204
+
2205
+ def test_ttfb(driver):
2206
+ """TTFB must be within SLA."""
2207
+ driver.get(PAGE_URL)
2208
+ ttfb = driver.execute_script(
2209
+ "const nav = performance.getEntriesByType('navigation')[0]; "
2210
+ "return nav ? Math.round(nav.responseStart - nav.startTime) : null;"
2211
+ )
2212
+ if ttfb is not None:
2213
+ assert ttfb < TTFB_THRESHOLD_MS, f"TTFB {ttfb}ms exceeds SLA of {TTFB_THRESHOLD_MS}ms"`,
2214
+ };
2215
+ }
2216
+
2217
+ // ─── Visual Regression Script (template-based, no AI call) ───────────────────
2218
+
2219
+ function buildVisualRegressionScript(pageData, framework) {
2220
+ const pageType = (pageData?.meta?.pageType || "page").toLowerCase();
2221
+ const url = pageData?.meta?.url || "https://example.com";
2222
+
2223
+ if (framework === "playwright-typescript") {
2224
+ return {
2225
+ filename: `test-visual-${pageType}.spec.ts`,
2226
+ content: `import { test, expect } from '@playwright/test';
2227
+
2228
+ /**
2229
+ * Visual Regression Tests — ${pageType}
2230
+ * Run once with --update-snapshots to create baselines.
2231
+ * Usage: npx playwright test test-visual-${pageType}.spec.ts
2232
+ * Update baselines: npx playwright test test-visual-${pageType}.spec.ts --update-snapshots
2233
+ */
2234
+
2235
+ test.describe('Visual Regression — ${pageType}', () => {
2236
+ test.beforeEach(async ({ page }) => {
2237
+ await page.goto('${url}');
2238
+ });
2239
+
2240
+ test('full page visual snapshot', async ({ page }) => {
2241
+ await page.waitForLoadState('networkidle');
2242
+ await expect(page).toHaveScreenshot('${pageType}-full.png', {
2243
+ fullPage: true,
2244
+ threshold: 0.1,
2245
+ maxDiffPixelRatio: 0.02,
2246
+ });
2247
+ });
2248
+
2249
+ test('above-the-fold visual snapshot', async ({ page }) => {
2250
+ await expect(page).toHaveScreenshot('${pageType}-viewport.png', {
2251
+ threshold: 0.05,
2252
+ });
2253
+ });
2254
+ });`,
2255
+ };
2256
+ }
2257
+
2258
+ if (framework === "playwright-python") {
2259
+ return {
2260
+ filename: `test_visual_${pageType}.py`,
2261
+ content: `"""
2262
+ Visual Regression Tests — ${pageType}
2263
+ Run once with --snapshot-update to create baselines.
2264
+ Usage: pytest test_visual_${pageType}.py
2265
+ Update baselines: pytest test_visual_${pageType}.py --snapshot-update
2266
+ Requires: pytest-playwright, syrupy
2267
+ """
2268
+ import pytest
2269
+ from playwright.sync_api import Page
2270
+
2271
+
2272
+ @pytest.fixture
2273
+ def page_loaded(page: Page):
2274
+ page.goto('${url}')
2275
+ page.wait_for_load_state('networkidle')
2276
+ return page
2277
+
2278
+
2279
+ def test_full_page_snapshot(page_loaded, assert_snapshot):
2280
+ """Full page visual regression baseline."""
2281
+ assert_snapshot(page_loaded.screenshot(full_page=True), name='${pageType}-full.png')
2282
+
2283
+
2284
+ def test_viewport_snapshot(page_loaded, assert_snapshot):
2285
+ """Above-the-fold visual regression baseline."""
2286
+ assert_snapshot(page_loaded.screenshot(), name='${pageType}-viewport.png')`,
2287
+ };
2288
+ }
2289
+
2290
+ if (framework === "selenium-java") {
2291
+ return {
2292
+ filename: `VisualTest${toPascalCase(pageType)}.java`,
2293
+ content: `/**
2294
+ * Visual Regression Tests — ${pageType}
2295
+ * Uses Percy for visual diff. Requires: io.percy:percy-java-selenium
2296
+ * Run: mvn test -Dtest=VisualTest${toPascalCase(pageType)}
2297
+ * First run creates baselines in Percy dashboard.
2298
+ */
2299
+ import io.percy.selenium.Percy;
2300
+ import org.openqa.selenium.WebDriver;
2301
+ import org.openqa.selenium.chrome.ChromeDriver;
2302
+ import org.testng.annotations.*;
2303
+
2304
+ public class VisualTest${toPascalCase(pageType)} {
2305
+ private WebDriver driver;
2306
+ private Percy percy;
2307
+
2308
+ @BeforeMethod
2309
+ public void setUp() {
2310
+ driver = new ChromeDriver();
2311
+ percy = new Percy(driver);
2312
+ driver.get("${url}");
2313
+ }
2314
+
2315
+ @Test
2316
+ public void testFullPageVisual() {
2317
+ percy.snapshot("${pageType} - Full Page");
2318
+ }
2319
+
2320
+ @Test
2321
+ public void testViewportVisual() {
2322
+ // Scroll to top first
2323
+ ((org.openqa.selenium.JavascriptExecutor) driver).executeScript("window.scrollTo(0, 0)");
2324
+ percy.snapshot("${pageType} - Viewport");
2325
+ }
2326
+
2327
+ @AfterMethod
2328
+ public void tearDown() {
2329
+ if (driver != null) driver.quit();
2330
+ }
2331
+ }`,
2332
+ };
2333
+ }
2334
+
2335
+ // selenium-python
2336
+ return {
2337
+ filename: `test_visual_${pageType}.py`,
2338
+ content: `"""
2339
+ Visual Regression Tests — ${pageType}
2340
+ Uses pytest-image-snapshot for local diff comparison.
2341
+ Install: pip install pytest-image-snapshot pillow
2342
+ Usage: pytest test_visual_${pageType}.py
2343
+ Update baselines: pytest test_visual_${pageType}.py --snapshot-update
2344
+ """
2345
+ import pytest
2346
+ from selenium import webdriver
2347
+ from selenium.webdriver.chrome.options import Options
2348
+
2349
+
2350
+ @pytest.fixture
2351
+ def driver():
2352
+ opts = Options()
2353
+ d = webdriver.Chrome(options=opts)
2354
+ d.set_window_size(1280, 900)
2355
+ d.get('${url}')
2356
+ yield d
2357
+ d.quit()
2358
+
2359
+
2360
+ def test_full_page_visual(driver, assert_image_snapshot):
2361
+ """Full page visual regression baseline."""
2362
+ screenshot = driver.get_screenshot_as_png()
2363
+ assert_image_snapshot(screenshot, '${pageType}_full.png', threshold=0.02)
2364
+
2365
+
2366
+ def test_viewport_visual(driver, assert_image_snapshot):
2367
+ """Viewport visual regression baseline."""
2368
+ from selenium.webdriver.common.action_chains import ActionChains
2369
+ ActionChains(driver).scroll_by_amount(0, 0).perform()
2370
+ screenshot = driver.get_screenshot_as_png()
2371
+ assert_image_snapshot(screenshot, '${pageType}_viewport.png', threshold=0.01)`,
2372
+ };
2373
+ }
2374
+
2375
+ // ─── Multi-Environment Config Generator ──────────────────────────────────────
2376
+
2377
+ function buildEnvironmentConfigs(environments, pageData, framework) {
2378
+ const ext = framework.includes("java") ? "json" : framework.includes("typescript") ? "json" : "json";
2379
+ const configs = environments.map(env => ({
2380
+ name: env.name,
2381
+ filename: `env_${env.name.toLowerCase().replace(/\s+/g, "_")}.${ext}`,
2382
+ content: JSON.stringify({
2383
+ name: env.name,
2384
+ baseUrl: env.baseUrl || pageData?.meta?.url || "https://example.com",
2385
+ ...(env.vars || {}),
2386
+ }, null, 2),
2387
+ }));
2388
+
2389
+ const readme = `# Multi-Environment Configuration
2390
+
2391
+ ## Environments
2392
+ ${environments.map(e => `- **${e.name}**: ${e.baseUrl || pageData?.meta?.url}`).join("\n")}
2393
+
2394
+ ## Usage
2395
+ ${framework === "selenium-python" ? `\`\`\`bash\nENV=staging pytest\nENV=production pytest\n\`\`\`` : ""}
2396
+ ${framework === "playwright-typescript" ? `\`\`\`bash\nENV=staging npx playwright test\nENV=production npx playwright test\n\`\`\`` : ""}
2397
+ ${framework === "selenium-java" ? `\`\`\`bash\nmvn test -DENV=staging\nmvn test -DENV=production\n\`\`\`` : ""}
2398
+ ${framework === "playwright-python" ? `\`\`\`bash\nENV=staging pytest\nENV=production pytest\n\`\`\`` : ""}
2399
+
2400
+ The base class reads the \`ENV\` environment variable and loads the matching \`config/env_<name>.json\` file automatically.`;
2401
+
2402
+ return { configs, readme };
2403
+ }
2404
+
2405
+ function buildJourneyTestPrompt(journey) {
2406
+ const steps = slimJourneySteps(journey);
2407
+
2408
+ return `Generate test cases for this ordered multi-page QA journey.
2409
+
2410
+ JOURNEY NAME: ${journey.name || "Untitled Journey"}
2411
+ TOTAL STEPS: ${steps.length}
2412
+ ORDERED STEPS:
2413
+ ${JSON.stringify(steps, null, 2)}
2414
+
2415
+ Requirements:
2416
+ - Generate both journey-level and step-level test cases
2417
+ - Journey-level cases must validate complete end-to-end outcomes across multiple pages
2418
+ - Step-level cases must validate the local page or recorded segment for that step
2419
+ - Use scope="journey" for end-to-end cases and scope="step" for step cases
2420
+ - For step cases, include the correct stepId and stepOrder from the provided steps
2421
+ - Keep output actionable for enterprise-style QA flows such as login, search, cart, checkout, approvals, refunds, or admin actions
2422
+ - Prefer precise assertions and realistic negative cases
2423
+ - Use exact step metadata and recorded actions where available
2424
+
2425
+ Respond ONLY with valid JSON:
2426
+ {
2427
+ "testCases": [
2428
+ {
2429
+ "id": "optional",
2430
+ "title": "string",
2431
+ "category": "functional|negative|e2e|accessibility",
2432
+ "caseKind": "flow|step",
2433
+ "packs": ["smoke", "regression", "e2e"],
2434
+ "priority": "high|medium|low",
2435
+ "preconditions": "string",
2436
+ "steps": ["step"],
2437
+ "expectedResult": "string",
2438
+ "locators": {},
2439
+ "testData": {},
2440
+ "tags": [],
2441
+ "scope": "journey|step",
2442
+ "stepId": "required for step scope",
2443
+ "stepOrder": 1,
2444
+ "groupLabel": "optional"
2445
+ }
2446
+ ]
2447
+ }`;
2448
+ }
2449
+
2450
+ function buildJourneyScriptPrompt(journey, approvedCases, framework, summary) {
2451
+ const ext = framework.includes("java") ? "java" : framework.includes("typescript") ? "ts" : "py";
2452
+ const grouped = {
2453
+ journeyCases: approvedCases.filter((tc) => tc.scope === "journey").map(slimJourneyCaseForPrompt),
2454
+ stepCases: approvedCases.filter((tc) => tc.scope === "step").map(slimJourneyCaseForPrompt),
2455
+ };
2456
+
2457
+ return `Generate a multi-page automation bundle for this journey.
2458
+
2459
+ FRAMEWORK: ${framework}
2460
+ JOURNEY NAME: ${journey.name || "Untitled Journey"}
2461
+ FULL JOURNEY EXECUTABLE: ${summary.journeyExecutable ? "yes" : "no"}
2462
+ MISSING TRANSITIONS: ${JSON.stringify(summary.missingTransitions)}
2463
+ ORDERED STEPS:
2464
+ ${JSON.stringify(slimJourneySteps(journey), null, 2)}
2465
+ APPROVED TEST CASES:
2466
+ ${JSON.stringify(grouped, null, 2)}
2467
+
2468
+ Requirements:
2469
+ - Return ONLY page objects, journey tests, and step tests in the files array
2470
+ - Shared base/config/test-data files are injected separately, do not generate them
2471
+ - Add one page object file per unique page context when possible
2472
+ - Add separate step test files for the provided step cases
2473
+ - ${summary.journeyExecutable ? "Add one end-to-end journey test file that stitches all recorded transitions in order" : "Do NOT add a journey group file because recorded transitions are missing; generate step-level files only"}
2474
+ - Preserve step order in generated tests
2475
+ - Use recorded transitions as the source of truth for executable navigation
2476
+ - Keep filenames deterministic and implementation-ready
2477
+ - For selenium-python:
2478
+ - Use Selenium 4 APIs only, never use find_element_by_* or find_elements_by_*
2479
+ - Do not locate elements inside page object __init__; store locators and resolve them in methods
2480
+ - Tests must either inherit BaseTest or use the shared driver fixture from conftest.py
2481
+ - Never depend on a manually preset global driver variable
2482
+
2483
+ Respond ONLY with valid JSON:
2484
+ {
2485
+ "files": [
2486
+ {
2487
+ "filename": "pages/example_page.${ext}",
2488
+ "content": "file content",
2489
+ "group": "page|journey|step",
2490
+ "stepId": "optional"
2491
+ }
2492
+ ],
2493
+ "summary": {
2494
+ "journeyExecutable": ${summary.journeyExecutable ? "true" : "false"},
2495
+ "notes": ["optional note"]
2496
+ }
2497
+ }`;
2498
+ }
2499
+
2500
+ function slimJourneySteps(journey) {
2501
+ return (journey.steps || []).map((step, index) => ({
2502
+ id: step.id,
2503
+ order: step.order || index + 1,
2504
+ title: step.title,
2505
+ url: step.url,
2506
+ path: step.path,
2507
+ pageType: step.pageType,
2508
+ source: step.source,
2509
+ transitionStatus: step.transitionStatus,
2510
+ notes: step.notes || "",
2511
+ keyForms: slimStepForms(step.pageData),
2512
+ keyButtons: slimStepButtons(step.pageData),
2513
+ recordedSteps: (step.recordedSteps || []).slice(0, 10),
2514
+ }));
2515
+ }
2516
+
2517
+ function slimStepForms(pageData) {
2518
+ return (pageData?.forms || []).slice(0, 3).map((form) => ({
2519
+ purpose: form.purpose,
2520
+ fields: (form.fields || []).slice(0, 6).map((field) => ({
2521
+ label: field.label,
2522
+ type: field.type,
2523
+ required: field.required,
2524
+ locator: field.locator,
2525
+ })),
2526
+ submitButton: form.submitButton?.text || null,
2527
+ }));
2528
+ }
2529
+
2530
+ function slimStepButtons(pageData) {
2531
+ return (pageData?.buttons || []).slice(0, 8).map((button) => ({
2532
+ text: button.text,
2533
+ action: button.action,
2534
+ locator: button.locator,
2535
+ }));
2536
+ }
2537
+
2538
+ function slimJourneyCaseForPrompt(testCase) {
2539
+ return {
2540
+ id: testCase.id,
2541
+ title: testCase.title,
2542
+ scope: testCase.scope,
2543
+ stepId: testCase.stepId || null,
2544
+ stepOrder: testCase.stepOrder || null,
2545
+ preconditions: testCase.preconditions || "",
2546
+ steps: testCase.steps || [],
2547
+ expectedResult: testCase.expectedResult || "",
2548
+ tags: testCase.tags || [],
2549
+ };
2550
+ }
2551
+
2552
+ function normalizeJourneyTestCases(testCases, journey) {
2553
+ const stepMap = new Map((journey.steps || []).map((step, index) => [
2554
+ step.id,
2555
+ {
2556
+ ...step,
2557
+ order: step.order || index + 1,
2558
+ },
2559
+ ]));
2560
+ const seen = new Set();
2561
+ let journeyCount = 0;
2562
+ let stepCount = 0;
2563
+
2564
+ return testCases.reduce((acc, raw, index) => {
2565
+ const scope = raw.scope === "step" ? "step" : "journey";
2566
+ const step =
2567
+ scope === "step"
2568
+ ? stepMap.get(raw.stepId) ||
2569
+ [...stepMap.values()].find((candidate) => candidate.order === Number(raw.stepOrder)) ||
2570
+ [...stepMap.values()][index % Math.max(stepMap.size, 1)]
2571
+ : null;
2572
+
2573
+ if (scope === "journey") journeyCount += 1;
2574
+ if (scope === "step") stepCount += 1;
2575
+
2576
+ const fallbackCaseKind = scope === "journey" ? "flow" : "step";
2577
+ const caseKind = normalizeGeneratedCaseKind(raw, fallbackCaseKind);
2578
+ let packs = normalizeGeneratedPacks(raw, caseKind);
2579
+ if (scope === "journey" && !packs.length) packs = normalizePackMembership(caseKind, ["e2e"]);
2580
+ const normalized = {
2581
+ id:
2582
+ raw.id ||
2583
+ (scope === "journey"
2584
+ ? `JY${String(journeyCount).padStart(3, "0")}`
2585
+ : `ST${String(stepCount).padStart(3, "0")}`),
2586
+ title:
2587
+ raw.title ||
2588
+ (scope === "journey"
2589
+ ? `Journey validation ${journeyCount}`
2590
+ : `Step ${step?.order || 1} validation ${stepCount}`),
2591
+ category: normalizeGeneratedCategory(raw.category, caseKind, packs),
2592
+ priority: raw.priority || "medium",
2593
+ preconditions: raw.preconditions || "",
2594
+ steps: Array.isArray(raw.steps) ? raw.steps : [],
2595
+ expectedResult: raw.expectedResult || raw.expected_result || "",
2596
+ locators: raw.locators || {},
2597
+ testData: raw.testData || raw.test_data || {},
2598
+ tags: Array.isArray(raw.tags) ? raw.tags : [],
2599
+ approved: raw.approved !== false,
2600
+ caseKind,
2601
+ packs,
2602
+ suite: deriveLegacySuite(caseKind, packs),
2603
+ scope,
2604
+ stepId: scope === "step" ? step?.id || raw.stepId || null : null,
2605
+ stepOrder: scope === "step" ? step?.order || Number(raw.stepOrder) || null : null,
2606
+ source: raw.source || (scope === "journey" || scope === "step" ? "recording" : "page"),
2607
+ groupLabel:
2608
+ scope === "journey"
2609
+ ? "Journey Cases"
2610
+ : raw.groupLabel || `Step ${step?.order || raw.stepOrder || 1} — ${step?.title || "Recorded Step"}`,
2611
+ };
2612
+
2613
+ const key = `${normalized.scope}|${normalized.stepId || "journey"}|${normalized.title.toLowerCase()}`;
2614
+ if (seen.has(key)) return acc;
2615
+ seen.add(key);
2616
+ acc.push(normalized);
2617
+ return acc;
2618
+ }, []);
2619
+ }
2620
+
2621
+ function buildJourneyGenerationSummary(journey) {
2622
+ const missingTransitions = (journey.steps || [])
2623
+ .filter((step, index) => index > 0 && step.transitionStatus !== "recorded")
2624
+ .map((step, index) => ({
2625
+ stepId: step.id,
2626
+ order: step.order || index + 2,
2627
+ title: step.title,
2628
+ url: step.url,
2629
+ }));
2630
+
2631
+ return {
2632
+ journeyExecutable: missingTransitions.length === 0,
2633
+ missingTransitions,
2634
+ totalSteps: journey.steps?.length || 0,
2635
+ };
2636
+ }
2637
+
2638
+ function normalizeJourneyScriptBundle(bundle, framework, journey, approvedCases, summary) {
2639
+ const rawFiles = Array.isArray(bundle?.files) ? bundle.files : [];
2640
+ let files = rawFiles
2641
+ .filter((file) => file?.filename && file?.content)
2642
+ .map((file) => ({
2643
+ filename: String(file.filename).replace(/^\/+/, ""),
2644
+ content: String(file.content),
2645
+ group: ["page", "journey", "step", "shared"].includes(file.group) ? file.group : inferJourneyFileGroup(file.filename),
2646
+ stepId: file.stepId || null,
2647
+ }))
2648
+ .filter((file) => summary.journeyExecutable || file.group !== "journey");
2649
+
2650
+ if (framework === "selenium-python") {
2651
+ files = files.map((file) => stabilizeSeleniumPythonJourneyFile(file));
2652
+ }
2653
+
2654
+ const deduped = [];
2655
+ const seen = new Set();
2656
+ for (const file of files) {
2657
+ if (seen.has(file.filename)) continue;
2658
+ seen.add(file.filename);
2659
+ deduped.push(file);
2660
+ }
2661
+
2662
+ const withShared = ensureJourneySharedFiles(deduped, framework, journey, approvedCases);
2663
+ return {
2664
+ framework,
2665
+ files: withShared.sort(sortJourneyFiles),
2666
+ summary: {
2667
+ journeyExecutable: summary.journeyExecutable,
2668
+ missingTransitions: summary.missingTransitions,
2669
+ notes: Array.isArray(bundle?.summary?.notes) ? bundle.summary.notes : [],
2670
+ },
2671
+ };
2672
+ }
2673
+
2674
+ function inferJourneyFileGroup(filename = "") {
2675
+ const lower = filename.toLowerCase();
2676
+ if (lower.includes("page")) return "page";
2677
+ if (lower.includes("journey")) return "journey";
2678
+ if (lower.includes("step")) return "step";
2679
+ return "step";
2680
+ }
2681
+
2682
+ function sortJourneyFiles(a, b) {
2683
+ const order = { shared: 0, page: 1, journey: 2, step: 3 };
2684
+ return (order[a.group] ?? 9) - (order[b.group] ?? 9) || a.filename.localeCompare(b.filename);
2685
+ }
2686
+
2687
+ function ensureJourneySharedFiles(files, framework, journey, approvedCases) {
2688
+ const result = [...files];
2689
+ const existing = new Set(result.map((file) => file.filename));
2690
+
2691
+ buildJourneySharedFiles(framework, journey, approvedCases).forEach((file) => {
2692
+ if (existing.has(file.filename)) return;
2693
+ result.push(file);
2694
+ existing.add(file.filename);
2695
+ });
2696
+
2697
+ return result;
2698
+ }
2699
+
2700
+ function stabilizeSeleniumPythonJourneyFile(file) {
2701
+ if (!file?.filename?.endsWith(".py")) return file;
2702
+
2703
+ let content = normalizeLegacySeleniumPythonCalls(file.content);
2704
+ content = file.group === "page"
2705
+ ? hardenSeleniumPythonPageObject(content)
2706
+ : hardenSeleniumPythonTestFile(content);
2707
+
2708
+ return { ...file, content };
2709
+ }
2710
+
2711
+ function normalizeLegacySeleniumPythonCalls(content) {
2712
+ const replacements = [
2713
+ [/(\b[\w.]+)\.find_elements_by_css_selector\((.+?)\)/g, "$1.find_elements(By.CSS_SELECTOR, $2)"],
2714
+ [/(\b[\w.]+)\.find_element_by_css_selector\((.+?)\)/g, "$1.find_element(By.CSS_SELECTOR, $2)"],
2715
+ [/(\b[\w.]+)\.find_elements_by_xpath\((.+?)\)/g, "$1.find_elements(By.XPATH, $2)"],
2716
+ [/(\b[\w.]+)\.find_element_by_xpath\((.+?)\)/g, "$1.find_element(By.XPATH, $2)"],
2717
+ [/(\b[\w.]+)\.find_elements_by_id\((.+?)\)/g, "$1.find_elements(By.ID, $2)"],
2718
+ [/(\b[\w.]+)\.find_element_by_id\((.+?)\)/g, "$1.find_element(By.ID, $2)"],
2719
+ [/(\b[\w.]+)\.find_elements_by_name\((.+?)\)/g, "$1.find_elements(By.NAME, $2)"],
2720
+ [/(\b[\w.]+)\.find_element_by_name\((.+?)\)/g, "$1.find_element(By.NAME, $2)"],
2721
+ [/(\b[\w.]+)\.find_elements_by_class_name\((.+?)\)/g, "$1.find_elements(By.CLASS_NAME, $2)"],
2722
+ [/(\b[\w.]+)\.find_element_by_class_name\((.+?)\)/g, "$1.find_element(By.CLASS_NAME, $2)"],
2723
+ [/(\b[\w.]+)\.find_elements_by_link_text\((.+?)\)/g, "$1.find_elements(By.LINK_TEXT, $2)"],
2724
+ [/(\b[\w.]+)\.find_element_by_link_text\((.+?)\)/g, "$1.find_element(By.LINK_TEXT, $2)"],
2725
+ [/(\b[\w.]+)\.find_elements_by_partial_link_text\((.+?)\)/g, "$1.find_elements(By.PARTIAL_LINK_TEXT, $2)"],
2726
+ [/(\b[\w.]+)\.find_element_by_partial_link_text\((.+?)\)/g, "$1.find_element(By.PARTIAL_LINK_TEXT, $2)"],
2727
+ [/(\b[\w.]+)\.find_elements_by_tag_name\((.+?)\)/g, "$1.find_elements(By.TAG_NAME, $2)"],
2728
+ [/(\b[\w.]+)\.find_element_by_tag_name\((.+?)\)/g, "$1.find_element(By.TAG_NAME, $2)"],
2729
+ ];
2730
+
2731
+ return replacements.reduce((next, [pattern, replacement]) => next.replace(pattern, replacement), content);
2732
+ }
2733
+
2734
+ function hardenSeleniumPythonPageObject(content) {
2735
+ let next = content;
2736
+ if (
2737
+ next.includes("self.driver.find_element(") ||
2738
+ next.includes("self.driver.find_elements(") ||
2739
+ next.includes("resolve_driver(")
2740
+ ) {
2741
+ next = ensurePythonImport(next, "from selenium.webdriver.common.by import By");
2742
+ next = ensurePythonImport(next, "from base_test import LazyElement, LazyElements, resolve_driver");
2743
+ }
2744
+
2745
+ next = next.replace(/super\(\)\.__init__\(\s*driver\s*\)/g, "super().__init__(resolve_driver(driver))");
2746
+ next = next.replace(/^(\s*)self\.driver\s*=\s*driver\s*$/gm, "$1self.driver = resolve_driver(driver)");
2747
+ next = next.replace(
2748
+ /^(\s*)self\.(\w+)\s*=\s*self\.driver\.find_elements\(\s*By\.([A-Z_]+)\s*,\s*(.+)\)\s*$/gm,
2749
+ "$1self.$2 = LazyElements(lambda: self.driver, By.$3, $4)"
2750
+ );
2751
+ next = next.replace(
2752
+ /^(\s*)self\.(\w+)\s*=\s*self\.driver\.find_element\(\s*By\.([A-Z_]+)\s*,\s*(.+)\)\s*$/gm,
2753
+ "$1self.$2 = LazyElement(lambda: self.driver, By.$3, $4)"
2754
+ );
2755
+
2756
+ return next;
2757
+ }
2758
+
2759
+ function hardenSeleniumPythonTestFile(content) {
2760
+ let next = content;
2761
+ next = ensurePythonImport(next, "from selenium.webdriver.common.by import By");
2762
+ next = ensurePythonImport(next, "from base_test import DeferredDriverProxy");
2763
+
2764
+ if (!next.includes("driver = DeferredDriverProxy()")) {
2765
+ next = insertPythonAfterImports(next, "driver = DeferredDriverProxy()");
2766
+ }
2767
+
2768
+ return next;
2769
+ }
2770
+
2771
+ function ensurePythonImport(content, statement) {
2772
+ if (content.includes(statement)) return content;
2773
+ return `${statement}\n${content}`;
2774
+ }
2775
+
2776
+ function insertPythonAfterImports(content, line) {
2777
+ const importBlock = content.match(/^(?:(?:from\s+\S+\s+import\s+.+|import\s+.+)\n)+/);
2778
+ if (!importBlock) return `${line}\n${content}`;
2779
+ return `${importBlock[0]}${line}\n${content.slice(importBlock[0].length)}`;
2780
+ }
2781
+
2782
+ function buildJourneySharedFiles(framework, journey, approvedCases) {
2783
+ const baseUrl = journey.steps?.[0]?.url || "https://example.com";
2784
+ const safeJourneyName = toPascalCase(journey.name || "Journey");
2785
+ const files = [];
2786
+
2787
+ if (framework === "selenium-python") {
2788
+ files.push({
2789
+ filename: "base_test.py",
2790
+ group: "shared",
2791
+ content: `from selenium import webdriver
2792
+ from selenium.webdriver.chrome.options import Options
2793
+ from selenium.webdriver.common.by import By
2794
+ from selenium.webdriver.support import expected_conditions as EC
2795
+ from selenium.webdriver.support.ui import WebDriverWait
2796
+
2797
+
2798
+ _ACTIVE_DRIVER = None
2799
+
2800
+
2801
+ def set_active_driver(driver):
2802
+ global _ACTIVE_DRIVER
2803
+ _ACTIVE_DRIVER = driver
2804
+ return driver
2805
+
2806
+
2807
+ def get_active_driver():
2808
+ return _ACTIVE_DRIVER
2809
+
2810
+
2811
+ def resolve_driver(driver=None):
2812
+ return driver or _ACTIVE_DRIVER
2813
+
2814
+
2815
+ class DeferredDriverProxy:
2816
+ def _resolve(self):
2817
+ driver = resolve_driver()
2818
+ if driver is None:
2819
+ raise RuntimeError("QA Deck could not resolve an active Selenium driver for this generated file.")
2820
+ return driver
2821
+
2822
+ def __getattr__(self, name):
2823
+ return getattr(self._resolve(), name)
2824
+
2825
+
2826
+ class LazyElement:
2827
+ def __init__(self, driver_ref, by, value):
2828
+ self._driver_ref = driver_ref
2829
+ self._by = by
2830
+ self._value = value
2831
+
2832
+ def _resolve(self):
2833
+ driver = resolve_driver(self._driver_ref() if callable(self._driver_ref) else self._driver_ref)
2834
+ if driver is None:
2835
+ raise RuntimeError("QA Deck could not resolve an active Selenium driver for this page object.")
2836
+ return WebDriverWait(driver, 10).until(EC.presence_of_element_located((self._by, self._value)))
2837
+
2838
+ def __getattr__(self, name):
2839
+ return getattr(self._resolve(), name)
2840
+
2841
+
2842
+ class LazyElements:
2843
+ def __init__(self, driver_ref, by, value):
2844
+ self._driver_ref = driver_ref
2845
+ self._by = by
2846
+ self._value = value
2847
+
2848
+ def _resolve(self):
2849
+ driver = resolve_driver(self._driver_ref() if callable(self._driver_ref) else self._driver_ref)
2850
+ if driver is None:
2851
+ raise RuntimeError("QA Deck could not resolve an active Selenium driver for this page object.")
2852
+ return WebDriverWait(driver, 10).until(lambda d: d.find_elements(self._by, self._value))
2853
+
2854
+ def __iter__(self):
2855
+ return iter(self._resolve())
2856
+
2857
+ def __getitem__(self, item):
2858
+ return self._resolve()[item]
2859
+
2860
+ def __len__(self):
2861
+ return len(self._resolve())
2862
+
2863
+
2864
+ class BaseTest:
2865
+ """Shared Selenium setup for QA Deck journeys."""
2866
+
2867
+ def __init__(self, driver=None):
2868
+ self.driver = resolve_driver(driver)
2869
+ self.wait = WebDriverWait(self.driver, 10) if self.driver else None
2870
+
2871
+ def setup_method(self):
2872
+ options = Options()
2873
+ options.add_argument("--no-sandbox")
2874
+ options.add_argument("--disable-dev-shm-usage")
2875
+ self.driver = set_active_driver(webdriver.Chrome(options=options))
2876
+ self.driver.maximize_window()
2877
+ self.wait = WebDriverWait(self.driver, 10)
2878
+ self.driver.get("${baseUrl}")
2879
+
2880
+ def teardown_method(self):
2881
+ if getattr(self, "driver", None):
2882
+ self.driver.quit()
2883
+ set_active_driver(None)
2884
+ `,
2885
+ });
2886
+ files.push({
2887
+ filename: "conftest.py",
2888
+ group: "shared",
2889
+ content: `import pytest
2890
+ from selenium import webdriver
2891
+ from selenium.webdriver.chrome.options import Options
2892
+
2893
+ from base_test import set_active_driver
2894
+
2895
+
2896
+ @pytest.fixture(scope="function")
2897
+ def driver():
2898
+ options = Options()
2899
+ options.add_argument("--no-sandbox")
2900
+ options.add_argument("--disable-dev-shm-usage")
2901
+ driver = set_active_driver(webdriver.Chrome(options=options))
2902
+ driver.maximize_window()
2903
+ driver.get("${baseUrl}")
2904
+ yield driver
2905
+ driver.quit()
2906
+ set_active_driver(None)
2907
+ `,
2908
+ });
2909
+ files.push({
2910
+ filename: "pytest.ini",
2911
+ group: "shared",
2912
+ content: `[pytest]
2913
+ addopts = -v --tb=short --junit-xml=report.xml
2914
+ testpaths = tests
2915
+ `,
2916
+ });
2917
+ files.push({
2918
+ filename: "test_data.py",
2919
+ group: "shared",
2920
+ content: buildJourneyTestDataContent(framework, journey, approvedCases),
2921
+ });
2922
+ } else if (framework === "playwright-python") {
2923
+ files.push({
2924
+ filename: "conftest.py",
2925
+ group: "shared",
2926
+ content: `import pytest
2927
+ from playwright.sync_api import sync_playwright
2928
+
2929
+
2930
+ @pytest.fixture(scope="function")
2931
+ def page():
2932
+ with sync_playwright() as p:
2933
+ browser = p.chromium.launch(headless=False)
2934
+ context = browser.new_context()
2935
+ page = context.new_page()
2936
+ page.goto("${baseUrl}")
2937
+ yield page
2938
+ context.close()
2939
+ browser.close()
2940
+ `,
2941
+ });
2942
+ files.push({
2943
+ filename: "pytest.ini",
2944
+ group: "shared",
2945
+ content: `[pytest]
2946
+ addopts = -v --tb=short --junit-xml=report.xml
2947
+ testpaths = tests
2948
+ `,
2949
+ });
2950
+ files.push({
2951
+ filename: "test_data.py",
2952
+ group: "shared",
2953
+ content: buildJourneyTestDataContent(framework, journey, approvedCases),
2954
+ });
2955
+ } else if (framework === "playwright-typescript") {
2956
+ files.push({
2957
+ filename: "journey_base.ts",
2958
+ group: "shared",
2959
+ content: `import { Page, expect } from '@playwright/test';
2960
+
2961
+ export class JourneyBase {
2962
+ constructor(public page: Page) {}
2963
+
2964
+ async open(path = '/') {
2965
+ await this.page.goto(path);
2966
+ }
2967
+ }
2968
+ `,
2969
+ });
2970
+ files.push({
2971
+ filename: "playwright.config.ts",
2972
+ group: "shared",
2973
+ content: `import { defineConfig } from '@playwright/test';
2974
+
2975
+ export default defineConfig({
2976
+ testDir: './tests',
2977
+ timeout: 30000,
2978
+ retries: 1,
2979
+ use: {
2980
+ baseURL: '${baseUrl}',
2981
+ headless: false,
2982
+ screenshot: 'only-on-failure',
2983
+ video: 'retain-on-failure',
2984
+ },
2985
+ reporter: [['html'], ['junit', { outputFile: 'report.xml' }]],
2986
+ });
2987
+ `,
2988
+ });
2989
+ files.push({
2990
+ filename: "test_data.ts",
2991
+ group: "shared",
2992
+ content: buildJourneyTestDataContent(framework, journey, approvedCases),
2993
+ });
2994
+ } else if (framework === "selenium-java") {
2995
+ files.push({
2996
+ filename: "BaseTest.java",
2997
+ group: "shared",
2998
+ content: `import org.openqa.selenium.WebDriver;
2999
+ import org.openqa.selenium.chrome.ChromeDriver;
3000
+ import org.openqa.selenium.chrome.ChromeOptions;
3001
+ import org.openqa.selenium.support.ui.WebDriverWait;
3002
+ import org.testng.annotations.AfterMethod;
3003
+ import org.testng.annotations.BeforeMethod;
3004
+
3005
+ import java.time.Duration;
3006
+
3007
+ public class BaseTest {
3008
+ protected WebDriver driver;
3009
+ protected WebDriverWait wait;
3010
+
3011
+ @BeforeMethod
3012
+ public void setUp() {
3013
+ ChromeOptions options = new ChromeOptions();
3014
+ options.addArguments("--no-sandbox", "--disable-dev-shm-usage");
3015
+ driver = new ChromeDriver(options);
3016
+ driver.manage().window().maximize();
3017
+ wait = new WebDriverWait(driver, Duration.ofSeconds(10));
3018
+ driver.get("${baseUrl}");
3019
+ }
3020
+
3021
+ @AfterMethod
3022
+ public void tearDown() {
3023
+ if (driver != null) driver.quit();
3024
+ }
3025
+ }
3026
+ `,
3027
+ });
3028
+ files.push({
3029
+ filename: "testng.xml",
3030
+ group: "shared",
3031
+ content: `<!DOCTYPE suite SYSTEM "https://testng.org/testng-1.0.dtd" >
3032
+ <suite name="${safeJourneyName}Suite">
3033
+ <test name="${safeJourneyName}Journey">
3034
+ <classes>
3035
+ <class name="Test${safeJourneyName}Journey" />
3036
+ </classes>
3037
+ </test>
3038
+ </suite>
3039
+ `,
3040
+ });
3041
+ files.push({
3042
+ filename: "test_data.json",
3043
+ group: "shared",
3044
+ content: buildJourneyTestDataContent(framework, journey, approvedCases),
3045
+ });
3046
+ }
3047
+
3048
+ return files;
3049
+ }
3050
+
3051
+ function buildJourneyTestDataContent(framework, journey, approvedCases) {
3052
+ const payload = {
3053
+ journeyName: journey.name || "Untitled Journey",
3054
+ baseUrl: journey.steps?.[0]?.url || "https://example.com",
3055
+ steps: (journey.steps || []).map((step) => ({
3056
+ order: step.order,
3057
+ title: step.title,
3058
+ url: step.url,
3059
+ pageType: step.pageType,
3060
+ notes: step.notes || "",
3061
+ })),
3062
+ approvedCases: approvedCases.map((testCase) => ({
3063
+ id: testCase.id,
3064
+ title: testCase.title,
3065
+ scope: testCase.scope,
3066
+ stepId: testCase.stepId || null,
3067
+ })),
3068
+ };
3069
+
3070
+ if (framework === "playwright-typescript") {
3071
+ return `export const journeyData = ${JSON.stringify(payload, null, 2)};\n`;
3072
+ }
3073
+
3074
+ if (framework === "selenium-java") {
3075
+ return JSON.stringify(payload, null, 2);
3076
+ }
3077
+
3078
+ return `journey_data = ${toPythonLiteral(payload)}\n`;
3079
+ }
3080
+
3081
+ function toPythonLiteral(value) {
3082
+ return JSON.stringify(value, null, 2)
3083
+ .replace(/\btrue\b/g, "True")
3084
+ .replace(/\bfalse\b/g, "False")
3085
+ .replace(/\bnull\b/g, "None");
3086
+ }
3087
+
3088
+ // ─── BDD / Gherkin Generator ──────────────────────────────────────────────────
3089
+
3090
+ async function handleGenerateBDDScript(testCases, pageData, framework, apiKey, res) {
3091
+ console.log(`[Generate BDD] Framework: ${framework} | Test cases: ${testCases.length}`);
3092
+
3093
+ const prompt = buildBDDPrompt(testCases, pageData, framework);
3094
+ const result = await callAI(apiKey, prompt, 6144,
3095
+ "You are a senior QA automation engineer expert in BDD testing. Generate production-ready Gherkin feature files and step definitions. Respond ONLY with valid JSON — no markdown fences, no extra text.");
3096
+
3097
+ if (!result.success) return jsonResponse(res, 502, { error: result.error });
3098
+
3099
+ let scripts;
3100
+ try {
3101
+ const clean = sanitizeAiJson(result.text);
3102
+ scripts = JSON.parse(clean);
3103
+ if (!scripts.feature?.content || !scripts.steps?.content) {
3104
+ throw new Error("Missing required BDD files: feature and/or steps");
3105
+ }
3106
+ } catch (err) {
3107
+ console.error("[BDD Parse Error]", err.message);
3108
+ return jsonResponse(res, 502, { error: "Failed to parse BDD response", detail: err.message });
3109
+ }
3110
+
3111
+ // Inject template-driven hooks/environment file (never AI-generated)
3112
+ scripts.hooks = buildBDDHooks(framework, pageData?.meta?.url || "https://example.com");
3113
+
3114
+ console.log(`[Generate BDD] ✓ feature + steps + hooks generated`);
3115
+ jsonResponse(res, 200, { success: true, scripts });
3116
+ }
3117
+
3118
+ function buildBDDPrompt(testCases, pageData, framework) {
3119
+ const pageType = pageData?.meta?.pageType || "page";
3120
+ const pageUrl = pageData?.meta?.url || "https://example.com";
3121
+ const className = toPascalCase(pageType);
3122
+ const signals = extractPageSignals(pageData);
3123
+
3124
+ const bddConfig = {
3125
+ "selenium-python": { runner: "Behave", stepImport: "from behave import given, when, then, step", stepExt: "py", driverSetup: "context.driver" },
3126
+ "selenium-java": { runner: "Cucumber-JVM (TestNG)",stepImport: "import io.cucumber.java.en.*;", stepExt: "java", driverSetup: "SharedContext.driver" },
3127
+ "playwright-python": { runner: "pytest-bdd", stepImport: "from pytest_bdd import scenarios, given, when, then\nimport pytest", stepExt: "py", driverSetup: "page" },
3128
+ "playwright-typescript": { runner: "@cucumber/cucumber",stepImport: "import { Given, When, Then } from '@cucumber/cucumber';", stepExt: "ts", driverSetup: "this.page" },
3129
+ };
3130
+
3131
+ const cfg = bddConfig[framework] || bddConfig["selenium-python"];
3132
+ const ext = cfg.stepExt;
3133
+
3134
+ const slimCases = testCases.map(tc => ({
3135
+ id: tc.id,
3136
+ title: tc.title,
3137
+ priority: tc.priority,
3138
+ steps: tc.steps,
3139
+ expectedResult: tc.expectedResult,
3140
+ locators: tc.locators || {},
3141
+ testData: tc.testData || {},
3142
+ }));
3143
+
3144
+ return `Generate a complete BDD test suite in Gherkin format.
3145
+
3146
+ FRAMEWORK: ${framework}
3147
+ BDD RUNNER: ${cfg.runner}
3148
+ PAGE TYPE: ${pageType}
3149
+ BASE URL: ${pageUrl}
3150
+ PAGE CLASS: ${className}Page
3151
+
3152
+ REAL PAGE DOM SIGNALS (use ONLY these selectors — never guess):
3153
+ - Confirmed selectors: ${JSON.stringify(signals.allSelectors)}
3154
+ - Error elements: ${JSON.stringify(signals.errorEls)}
3155
+ - Success elements: ${JSON.stringify(signals.successEls)}
3156
+
3157
+ TEST CASES TO CONVERT TO BDD (${slimCases.length} total):
3158
+ ${JSON.stringify(slimCases, null, 2)}
3159
+
3160
+ REQUIREMENTS:
3161
+ feature file:
3162
+ - Use "Feature: ${className}" at the top with a short description
3163
+ - One "Scenario:" per test case (use the test case title as scenario name)
3164
+ - Use Given/When/Then/And keywords — plain English, no code
3165
+ - For data-driven cases: use "Scenario Outline:" with "<param>" and "Examples:" table
3166
+
3167
+ steps file:
3168
+ - Start with: ${cfg.stepImport}
3169
+ - Implement every step from the feature file
3170
+ - Use ${cfg.driverSetup} for browser interactions
3171
+ - Use ONLY confirmed DOM selectors from the signals above
3172
+ - Use explicit waits — NO sleep() or time.sleep()
3173
+ - Each step function must match its Gherkin text exactly (regex or exact string)
3174
+
3175
+ testData file:
3176
+ - Constants file with all test values referenced in the feature file
3177
+
3178
+ Respond ONLY with this JSON (no markdown, no extra text):
3179
+ {
3180
+ "feature": { "filename": "${pageType.toLowerCase()}.feature", "content": "Feature: ..." },
3181
+ "steps": { "filename": "${pageType.toLowerCase()}_steps.${ext}", "content": "step definitions..." },
3182
+ "testData": { "filename": "test_data.${ext}", "content": "test data constants..." }
3183
+ }`;
3184
+ }
3185
+
3186
+ function buildBDDHooks(framework, baseUrl) {
3187
+ if (framework === "selenium-python") {
3188
+ return {
3189
+ filename: "environment.py",
3190
+ content: `# Behave hooks — setup and teardown
3191
+ from selenium import webdriver
3192
+ from selenium.webdriver.chrome.options import Options
3193
+ from selenium.webdriver.chrome.service import Service
3194
+ from webdriver_manager.chrome import ChromeDriverManager
3195
+
3196
+ BASE_URL = "${baseUrl}"
3197
+
3198
+ def before_scenario(context, scenario):
3199
+ opts = Options()
3200
+ opts.add_argument("--no-sandbox")
3201
+ opts.add_argument("--disable-dev-shm-usage")
3202
+ context.driver = webdriver.Chrome(
3203
+ service=Service(ChromeDriverManager().install()), options=opts
3204
+ )
3205
+ context.driver.implicitly_wait(10)
3206
+ context.base_url = BASE_URL
3207
+
3208
+ def after_scenario(context, scenario):
3209
+ context.driver.quit()
3210
+ `,
3211
+ };
3212
+ }
3213
+
3214
+ if (framework === "selenium-java") {
3215
+ return {
3216
+ filename: "Hooks.java",
3217
+ content: `package hooks;
3218
+
3219
+ import io.cucumber.java.After;
3220
+ import io.cucumber.java.Before;
3221
+ import org.openqa.selenium.WebDriver;
3222
+ import org.openqa.selenium.chrome.ChromeDriver;
3223
+ import org.openqa.selenium.chrome.ChromeOptions;
3224
+ import steps.SharedContext;
3225
+
3226
+ public class Hooks {
3227
+
3228
+ @Before
3229
+ public void setUp() {
3230
+ ChromeOptions options = new ChromeOptions();
3231
+ options.addArguments("--no-sandbox", "--disable-dev-shm-usage");
3232
+ SharedContext.driver = new ChromeDriver(options);
3233
+ SharedContext.driver.manage().window().maximize();
3234
+ SharedContext.baseUrl = "${baseUrl}";
3235
+ }
3236
+
3237
+ @After
3238
+ public void tearDown() {
3239
+ if (SharedContext.driver != null) {
3240
+ SharedContext.driver.quit();
3241
+ }
3242
+ }
3243
+ }
3244
+ `,
3245
+ };
3246
+ }
3247
+
3248
+ if (framework === "playwright-python") {
3249
+ return {
3250
+ filename: "conftest.py",
3251
+ content: `# pytest-bdd conftest — Playwright fixtures
3252
+ import pytest
3253
+ from playwright.sync_api import sync_playwright
3254
+
3255
+ BASE_URL = "${baseUrl}"
3256
+
3257
+ @pytest.fixture(scope="session")
3258
+ def browser_instance():
3259
+ with sync_playwright() as p:
3260
+ browser = p.chromium.launch(headless=True)
3261
+ yield browser
3262
+ browser.close()
3263
+
3264
+ @pytest.fixture
3265
+ def page(browser_instance):
3266
+ context = browser_instance.new_context(base_url=BASE_URL)
3267
+ pg = context.new_page()
3268
+ yield pg
3269
+ context.close()
3270
+ `,
3271
+ };
3272
+ }
3273
+
3274
+ // playwright-typescript — @cucumber/cucumber
3275
+ return {
3276
+ filename: "hooks.ts",
3277
+ content: `import { Before, After, setDefaultTimeout, setWorldConstructor } from '@cucumber/cucumber';
3278
+ import { chromium, Browser, BrowserContext, Page } from 'playwright';
3279
+
3280
+ const BASE_URL = '${baseUrl}';
3281
+
3282
+ setDefaultTimeout(30 * 1000);
3283
+
3284
+ setWorldConstructor(function () {
3285
+ this.baseUrl = BASE_URL;
3286
+ });
3287
+
3288
+ Before(async function () {
3289
+ const browser: Browser = await chromium.launch({ headless: true });
3290
+ const context: BrowserContext = await browser.newContext({ baseURL: BASE_URL });
3291
+ this.page = (await context.newPage()) as Page;
3292
+ this.browser = browser;
3293
+ });
3294
+
3295
+ After(async function () {
3296
+ await this.browser?.close();
3297
+ });
3298
+ `,
3299
+ };
3300
+ }
3301
+
3302
+ // ─── Accessibility Script Generator ──────────────────────────────────────────
3303
+
3304
+ function buildAccessibilityScript(pageData, framework) {
3305
+ const acc = pageData.accessibility || {};
3306
+ const issues = acc.issues || [];
3307
+ const url = pageData.meta?.url || "https://example.com";
3308
+ const pageType = (pageData.meta?.pageType || "page").toLowerCase();
3309
+
3310
+ if (framework === "playwright-typescript") {
3311
+ const issueChecks = issues.slice(0, 10).map(issue =>
3312
+ ` // Verify: ${issue.type} on <${issue.element}>\n` +
3313
+ ` expect(violations.find(v => v.id === '${issue.type}')).toBeUndefined();`
3314
+ ).join("\n");
3315
+
3316
+ return {
3317
+ filename: `accessibility_${pageType}.spec.ts`,
3318
+ content: `import { test, expect } from '@playwright/test';
3319
+ import AxeBuilder from '@axe-core/playwright';
3320
+
3321
+ /**
3322
+ * Accessibility tests for ${pageType}
3323
+ * Generated by QA Deck — uses @axe-core/playwright
3324
+ * Install: npm install --save-dev @axe-core/playwright
3325
+ */
3326
+ test.describe('Accessibility — ${pageType}', () => {
3327
+
3328
+ test('no critical or serious violations', async ({ page }) => {
3329
+ await page.goto('${url}');
3330
+ const results = await new AxeBuilder({ page })
3331
+ .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
3332
+ .analyze();
3333
+ const blocking = results.violations.filter(
3334
+ v => v.impact === 'critical' || v.impact === 'serious'
3335
+ );
3336
+ expect(blocking, \`Critical/serious violations: \${blocking.map(v => v.id).join(', ')}\`).toHaveLength(0);
3337
+ });
3338
+
3339
+ test('no moderate violations', async ({ page }) => {
3340
+ await page.goto('${url}');
3341
+ const results = await new AxeBuilder({ page }).analyze();
3342
+ const moderate = results.violations.filter(v => v.impact === 'moderate');
3343
+ expect(moderate.length, \`Moderate violations: \${moderate.map(v => v.id).join(', ')}\`).toBe(0);
3344
+ });
3345
+ ${issueChecks ? `
3346
+ test('specific issues from scan are resolved', async ({ page }) => {
3347
+ await page.goto('${url}');
3348
+ const results = await new AxeBuilder({ page }).analyze();
3349
+ const violations = results.violations;
3350
+ ${issueChecks}
3351
+ });` : ""}
3352
+ ${acc.inputsWithoutLabels > 0 ? `
3353
+ test('all inputs have accessible labels', async ({ page }) => {
3354
+ await page.goto('${url}');
3355
+ const results = await new AxeBuilder({ page }).withRules(['label']).analyze();
3356
+ expect(results.violations, 'Inputs without labels found').toHaveLength(0);
3357
+ });` : ""}
3358
+
3359
+ });
3360
+ `,
3361
+ };
3362
+ }
3363
+
3364
+ if (framework === "playwright-python") {
3365
+ const issueChecks = issues.slice(0, 10).map(issue =>
3366
+ ` # Check: ${issue.type} on <${issue.element}>\n` +
3367
+ ` assert not any(v["id"] == "${issue.type}" for v in violations), f"Issue not resolved: ${issue.type}"`
3368
+ ).join("\n");
3369
+
3370
+ return {
3371
+ filename: `test_accessibility_${pageType}.py`,
3372
+ content: `"""
3373
+ Accessibility tests for ${pageType}
3374
+ Generated by QA Deck — uses axe-playwright-python
3375
+ Install: pip install axe-playwright-python
3376
+ """
3377
+ import pytest
3378
+ from axe_playwright_python import Axe
3379
+
3380
+
3381
+ @pytest.fixture(scope="module")
3382
+ def axe_results(page):
3383
+ page.goto("${url}")
3384
+ axe = Axe()
3385
+ return axe.run(page)
3386
+
3387
+
3388
+ def test_no_critical_violations(axe_results):
3389
+ """Assert axe-core reports zero critical violations."""
3390
+ critical = [v for v in axe_results["violations"] if v["impact"] == "critical"]
3391
+ assert len(critical) == 0, f"Critical violations: {[v['id'] for v in critical]}"
3392
+
3393
+
3394
+ def test_no_serious_violations(axe_results):
3395
+ """Assert axe-core reports zero serious violations."""
3396
+ serious = [v for v in axe_results["violations"] if v["impact"] == "serious"]
3397
+ assert len(serious) == 0, f"Serious violations: {[v['id'] for v in serious]}"
3398
+ ${issueChecks ? `
3399
+
3400
+ def test_known_issues_resolved(page):
3401
+ """Assert specific issues found during DOM scan are resolved."""
3402
+ axe = Axe()
3403
+ results = axe.run(page)
3404
+ violations = results["violations"]
3405
+ ${issueChecks}` : ""}
3406
+ ${acc.inputsWithoutLabels > 0 ? `
3407
+
3408
+ def test_all_inputs_have_labels(axe_results):
3409
+ """Assert no inputs are missing accessible labels."""
3410
+ label_violations = [v for v in axe_results["violations"] if v["id"] == "label"]
3411
+ assert len(label_violations) == 0, f"Inputs without labels: {len(label_violations)} violation(s)"` : ""}
3412
+ `,
3413
+ };
3414
+ }
3415
+
3416
+ if (framework === "selenium-java") {
3417
+ return {
3418
+ filename: `AccessibilityTest.java`,
3419
+ content: `package tests;
3420
+
3421
+ import com.deque.html.axecore.selenium.AxeBuilder;
3422
+ import com.deque.html.axecore.results.Results;
3423
+ import com.deque.html.axecore.results.Rule;
3424
+ import org.openqa.selenium.WebDriver;
3425
+ import org.openqa.selenium.chrome.ChromeDriver;
3426
+ import org.testng.Assert;
3427
+ import org.testng.annotations.*;
3428
+ import java.util.List;
3429
+ import java.util.stream.Collectors;
3430
+
3431
+ /**
3432
+ * Accessibility tests for ${pageType}
3433
+ * Generated by QA Deck — uses axe-core-maven-html
3434
+ * Add to pom.xml: com.deque.html.axe-core:axe-core-maven-html
3435
+ */
3436
+ public class AccessibilityTest {
3437
+
3438
+ private WebDriver driver;
3439
+
3440
+ @BeforeClass
3441
+ public void setUp() {
3442
+ driver = new ChromeDriver();
3443
+ driver.get("${url}");
3444
+ }
3445
+
3446
+ @Test
3447
+ public void noCriticalViolations() {
3448
+ Results results = new AxeBuilder().analyze(driver);
3449
+ List<Rule> critical = results.getViolations().stream()
3450
+ .filter(r -> "critical".equals(r.getImpact()))
3451
+ .collect(Collectors.toList());
3452
+ Assert.assertEquals(critical.size(), 0,
3453
+ "Critical a11y violations: " + critical.stream().map(Rule::getId).collect(Collectors.joining(", ")));
3454
+ }
3455
+
3456
+ @Test
3457
+ public void noSeriousViolations() {
3458
+ Results results = new AxeBuilder().analyze(driver);
3459
+ List<Rule> serious = results.getViolations().stream()
3460
+ .filter(r -> "serious".equals(r.getImpact()))
3461
+ .collect(Collectors.toList());
3462
+ Assert.assertEquals(serious.size(), 0,
3463
+ "Serious a11y violations: " + serious.stream().map(Rule::getId).collect(Collectors.joining(", ")));
3464
+ }
3465
+ ${acc.inputsWithoutLabels > 0 ? `
3466
+ @Test
3467
+ public void allInputsHaveLabels() {
3468
+ Results results = new AxeBuilder().withRules(java.util.Arrays.asList("label")).analyze(driver);
3469
+ Assert.assertEquals(results.getViolations().size(), 0, "Inputs without accessible labels found");
3470
+ }` : ""}
3471
+
3472
+ @AfterClass
3473
+ public void tearDown() {
3474
+ if (driver != null) driver.quit();
3475
+ }
3476
+ }
3477
+ `,
3478
+ };
3479
+ }
3480
+
3481
+ // Default: selenium-python
3482
+ const issueChecks = issues.slice(0, 10).map(issue =>
3483
+ ` # Check: ${issue.type} on <${issue.element}>\n` +
3484
+ ` assert not any(v["id"] == "${issue.type}" for v in violations), f"Issue not resolved: ${issue.type}"`
3485
+ ).join("\n");
3486
+
3487
+ return {
3488
+ filename: `test_accessibility_${pageType}.py`,
3489
+ content: `"""
3490
+ Accessibility tests for ${pageType}
3491
+ Generated by QA Deck — uses axe-selenium-python
3492
+ Install: pip install axe-selenium-python
3493
+ """
3494
+ import pytest
3495
+ from axe_selenium_python import Axe
3496
+
3497
+
3498
+ @pytest.fixture
3499
+ def axe(driver):
3500
+ return Axe(driver)
3501
+
3502
+
3503
+ def test_no_critical_violations(driver, axe):
3504
+ """Assert zero critical violations."""
3505
+ driver.get("${url}")
3506
+ axe.inject()
3507
+ results = axe.run()
3508
+ critical = [v for v in results["violations"] if v["impact"] == "critical"]
3509
+ assert len(critical) == 0, f"Critical violations: {[v['id'] for v in critical]}"
3510
+
3511
+
3512
+ def test_no_serious_violations(driver, axe):
3513
+ """Assert zero serious violations."""
3514
+ driver.get("${url}")
3515
+ axe.inject()
3516
+ results = axe.run()
3517
+ serious = [v for v in results["violations"] if v["impact"] == "serious"]
3518
+ assert len(serious) == 0, f"Serious violations: {[v['id'] for v in serious]}"
3519
+ ${issueChecks ? `
3520
+
3521
+ def test_known_issues_resolved(driver, axe):
3522
+ """Assert specific issues found during DOM scan are resolved."""
3523
+ driver.get("${url}")
3524
+ axe.inject()
3525
+ results = axe.run()
3526
+ violations = results["violations"]
3527
+ ${issueChecks}` : ""}
3528
+ ${acc.inputsWithoutLabels > 0 ? `
3529
+
3530
+ def test_all_inputs_have_labels(driver, axe):
3531
+ """Assert no inputs are missing accessible labels."""
3532
+ driver.get("${url}")
3533
+ axe.inject()
3534
+ results = axe.run()
3535
+ label_violations = [v for v in results["violations"] if v["id"] == "label"]
3536
+ assert len(label_violations) == 0, f"Inputs without labels: {len(label_violations)} violation(s)"` : ""}
3537
+ `,
3538
+ };
3539
+ }
3540
+
3541
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
3542
+
3543
+ function readBody(req) {
3544
+ return new Promise((resolve, reject) => {
3545
+ let data = "";
3546
+ req.on("data", chunk => {
3547
+ data += chunk;
3548
+ if (data.length > 2_000_000) reject(new Error("Request too large")); // 2MB limit
3549
+ });
3550
+ req.on("end", () => {
3551
+ try { resolve(JSON.parse(data || "{}")); }
3552
+ catch { reject(new Error("Invalid JSON body")); }
3553
+ });
3554
+ req.on("error", reject);
3555
+ });
3556
+ }
3557
+
3558
+ function jsonResponse(res, status, data) {
3559
+ const body = JSON.stringify(data);
3560
+ res.writeHead(status, {
3561
+ "Content-Type": "application/json",
3562
+ "Content-Length": Buffer.byteLength(body),
3563
+ });
3564
+ res.end(body);
3565
+ }
3566
+
3567
+ function toPascalCase(str) {
3568
+ return (str || "page").split(/[-_\s]/).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join("");
3569
+ }
3570
+
3571
+ // ─── Static file server (dashboard) ──────────────────────────────────────────
3572
+
3573
+ const MIME_TYPES = {
3574
+ ".html": "text/html; charset=utf-8",
3575
+ ".css": "text/css; charset=utf-8",
3576
+ ".js": "application/javascript; charset=utf-8",
3577
+ ".json": "application/json",
3578
+ ".png": "image/png",
3579
+ ".svg": "image/svg+xml",
3580
+ ".ico": "image/x-icon",
3581
+ ".woff2":"font/woff2",
3582
+ };
3583
+
3584
+ function serveStatic(req, res, url) {
3585
+ // Root → serve dashboard
3586
+ let filePath = url.pathname === "/" || url.pathname === ""
3587
+ ? path.join(__dirname, "dashboard", "index.html")
3588
+ : path.join(__dirname, "dashboard", url.pathname);
3589
+
3590
+ // Security: prevent path traversal
3591
+ const resolved = path.resolve(filePath);
3592
+ const dashboardDir = path.resolve(__dirname, "dashboard");
3593
+ if (!resolved.startsWith(dashboardDir)) {
3594
+ return jsonResponse(res, 403, { error: "Forbidden" });
3595
+ }
3596
+
3597
+ if (!fs.existsSync(resolved)) {
3598
+ // SPA fallback
3599
+ filePath = path.join(__dirname, "dashboard", "index.html");
3600
+ }
3601
+
3602
+ const ext = path.extname(filePath).toLowerCase();
3603
+ const mime = MIME_TYPES[ext] || "application/octet-stream";
3604
+
3605
+ try {
3606
+ const content = fs.readFileSync(filePath);
3607
+ res.writeHead(200, { "Content-Type": mime, "Content-Length": content.length });
3608
+ res.end(content);
3609
+ } catch {
3610
+ jsonResponse(res, 500, { error: "Failed to serve file" });
3611
+ }
3612
+ }
3613
+
3614
+ // ─── Delete project ───────────────────────────────────────────────────────────
3615
+
3616
+ function handleDeleteProject(req, res, url) {
3617
+ const id = url.pathname.split("/").pop();
3618
+ if (!id || id === "projects") return jsonResponse(res, 400, { error: "Project ID required" });
3619
+
3620
+ const filename = path.join(PROJECTS_DIR, `${id}.json`);
3621
+ if (!fs.existsSync(filename)) return jsonResponse(res, 404, { error: "Project not found" });
3622
+
3623
+ try {
3624
+ fs.unlinkSync(filename);
3625
+ console.log(`[Delete Project] Deleted: ${id}`);
3626
+ jsonResponse(res, 200, { success: true, id });
3627
+ } catch (err) {
3628
+ jsonResponse(res, 500, { error: "Failed to delete project", detail: err.message });
3629
+ }
3630
+ }
3631
+
3632
+ // ─── Recorder route handlers ──────────────────────────────────────────────────
3633
+
3634
+ async function handleRecordStart(req, res) {
3635
+ const body = await readBody(req);
3636
+ const { startUrl, sessionId: requestedId } = body;
3637
+
3638
+ if (!startUrl) return jsonResponse(res, 400, { error: "startUrl is required" });
3639
+
3640
+ try {
3641
+ const { sessionId, recorder } = await recorderManager.createSession({
3642
+ startUrl,
3643
+ sessionId: requestedId,
3644
+ headless: false,
3645
+ });
3646
+
3647
+ console.log(`[Recorder] Session started: ${sessionId} → ${startUrl}`);
3648
+ jsonResponse(res, 200, { success: true, sessionId, startUrl });
3649
+ } catch (err) {
3650
+ console.error("[Recorder] Start error:", err.message);
3651
+ jsonResponse(res, 500, { error: "Failed to start recording: " + err.message });
3652
+ }
3653
+ }
3654
+
3655
+ function handleRecordSessions(req, res) {
3656
+ const sessions = recorderManager.listSessions();
3657
+ jsonResponse(res, 200, { success: true, sessions });
3658
+ }
3659
+
3660
+ function handleRecordActions(req, res, sessionId) {
3661
+ const recorder = recorderManager.getSession(sessionId);
3662
+ if (!recorder) return jsonResponse(res, 404, { error: "Session not found" });
3663
+
3664
+ const status = recorder.getStatus();
3665
+ const actions = recorder.getActions();
3666
+ jsonResponse(res, 200, { success: true, actions, ...status });
3667
+ }
3668
+
3669
+ async function handleRecordStop(req, res, sessionId) {
3670
+ try {
3671
+ const actions = await recorderManager.destroySession(sessionId);
3672
+ if (!actions) return jsonResponse(res, 404, { error: "Session not found" });
3673
+
3674
+ console.log(`[Recorder] Session stopped: ${sessionId} — ${actions.length} actions`);
3675
+ const steps = actionsToSteps(actions);
3676
+ const journeySegments = actionsToJourneySegments(actions);
3677
+ jsonResponse(res, 200, { success: true, actions, steps, journeySegments, actionCount: actions.length });
3678
+ } catch (err) {
3679
+ jsonResponse(res, 500, { error: "Failed to stop recording: " + err.message });
3680
+ }
3681
+ }
3682
+
3683
+ async function handleRecordConvert(req, res, sessionId) {
3684
+ const body = await readBody(req);
3685
+ const { framework, className, apiKey } = body;
3686
+
3687
+ // Get actions — either from live session or from body
3688
+ let actions = body.actions;
3689
+ if (!actions && sessionId !== "offline") {
3690
+ const recorder = recorderManager.getSession(sessionId);
3691
+ if (!recorder) return jsonResponse(res, 404, { error: "Session not found" });
3692
+ actions = recorder.getActions();
3693
+ }
3694
+
3695
+ if (!actions?.length) return jsonResponse(res, 400, { error: "No actions to convert" });
3696
+
3697
+ const fw = framework || "playwright-python";
3698
+ const cls = className || "RecordedPage";
3699
+
3700
+ // Generate code from converter
3701
+ const code = actionsToCode(actions, fw, cls);
3702
+ const steps = actionsToSteps(actions);
3703
+
3704
+ // Generate test case: Claude if API key provided, otherwise auto-build from actions
3705
+ let testCase = null;
3706
+ if (apiKey) {
3707
+ const claudeResult = await callAI(apiKey, buildRecordingPrompt(steps, actions, fw), 3000,
3708
+ "You are a QA automation expert. Convert recorded user actions into structured test cases. Respond ONLY with valid JSON.");
3709
+ if (claudeResult.success) {
3710
+ try {
3711
+ testCase = JSON.parse(sanitizeAiJson(claudeResult.text));
3712
+ } catch (_) {}
3713
+ }
3714
+ }
3715
+ // Always fall back to auto-generated test case if Claude didn't produce one
3716
+ if (!testCase) {
3717
+ testCase = autoGenerateTestCase(steps, actions, cls);
3718
+ }
3719
+
3720
+ jsonResponse(res, 200, {
3721
+ success: true,
3722
+ code,
3723
+ steps,
3724
+ testCase,
3725
+ framework: fw,
3726
+ actionCount: actions.length,
3727
+ });
3728
+ }
3729
+
3730
+ async function handleRecordTestCases(req, res, sessionId) {
3731
+ const body = await readBody(req);
3732
+ let { pageContext, actions, steps, scenarioTypes, suiteTypes, apiKey, sourceMode } = body;
3733
+
3734
+ if (!actions && sessionId !== "offline") {
3735
+ const recorder = recorderManager.getSession(sessionId);
3736
+ if (!recorder) return jsonResponse(res, 404, { error: "Session not found" });
3737
+ actions = recorder.getActions();
3738
+ }
3739
+
3740
+ actions = Array.isArray(actions) ? actions : [];
3741
+ const stepTexts = Array.isArray(steps)
3742
+ ? steps.map((step) => typeof step === "string" ? step : step?.text).filter(Boolean)
3743
+ : actionsToSteps(actions).map((step) => step.text);
3744
+ const normalizedTypes = normalizeScenarioTypes(scenarioTypes);
3745
+ const normalizedSuites = normalizeSuiteTypes(suiteTypes);
3746
+
3747
+ if (!pageContext && !actions.length) {
3748
+ return jsonResponse(res, 400, { error: "pageContext or actions are required" });
3749
+ }
3750
+
3751
+ let testCases = [];
3752
+ if (apiKey) {
3753
+ const aiResult = await callAI(
3754
+ apiKey,
3755
+ buildScenarioGenerationPrompt(pageContext, actions, stepTexts, normalizedTypes, normalizedSuites, sourceMode || "hybrid"),
3756
+ 5000,
3757
+ "You are a senior QA engineer. Generate comprehensive web QA test cases. Return valid JSON only with a testCases array."
3758
+ );
3759
+ if (aiResult.success) {
3760
+ try {
3761
+ const parsed = JSON.parse(sanitizeAiJson(aiResult.text));
3762
+ testCases = Array.isArray(parsed) ? parsed : (parsed.testCases || []);
3763
+ } catch (_) {}
3764
+ }
3765
+ }
3766
+
3767
+ if (!Array.isArray(testCases) || !testCases.length) {
3768
+ testCases = autoGenerateRecorderTestCases(pageContext, actions, stepTexts, normalizedTypes, normalizedSuites, sourceMode || "hybrid");
3769
+ }
3770
+
3771
+ const normalized = dedupeTestCases(
3772
+ testCases.map((tc, index) => normalizeRecorderTestCase(tc, index, pageContext, actions, sourceMode || "hybrid"))
3773
+ );
3774
+
3775
+ jsonResponse(res, 200, {
3776
+ success: true,
3777
+ testCases: normalized,
3778
+ count: normalized.length,
3779
+ suiteTypes: normalizedSuites,
3780
+ sourceMode: sourceMode || "hybrid",
3781
+ });
3782
+ }
3783
+
3784
+ function buildRecordingPrompt(steps, actions, framework) {
3785
+ const stepTexts = steps.map((s, i) => `${i + 1}. ${s.text}`).join("\n");
3786
+ const locators = actions
3787
+ .filter(a => a.locator)
3788
+ .reduce((m, a) => { m[a.locator] = a.type; return m; }, {});
3789
+
3790
+ return `Convert these recorded browser actions into a structured test case.
3791
+
3792
+ RECORDED STEPS:
3793
+ ${stepTexts}
3794
+
3795
+ LOCATORS USED:
3796
+ ${JSON.stringify(locators, null, 2)}
3797
+
3798
+ FRAMEWORK: ${framework}
3799
+
3800
+ Respond ONLY with this JSON:
3801
+ {
3802
+ "id": "TC001",
3803
+ "title": "One-line description of what this test verifies",
3804
+ "category": "functional|negative|boundary|navigation|ui|accessibility|e2e",
3805
+ "caseKind": "flow",
3806
+ "packs": ["smoke", "regression", "e2e"],
3807
+ "priority": "high|medium|low",
3808
+ "preconditions": "What must be true before running this test",
3809
+ "steps": ["1. Step text", "2. Step text"],
3810
+ "expectedResult": "Specific, measurable expected outcome",
3811
+ "locators": { "descriptive_name": "locator_value" },
3812
+ "testData": { "key": "value for any data used" },
3813
+ "tags": ["recorded", "e2e"]
3814
+ }`;
3815
+ }
3816
+
3817
+ // ─── Auto test case builder (no API key needed) ──────────────────────────────
3818
+
3819
+ function autoGenerateTestCase(steps, actions, className) {
3820
+ const stepTexts = steps.map(s => s.text);
3821
+ const navigates = actions.filter(a => a.type === "navigate").map(a => a.url);
3822
+ const fills = actions.filter(a => a.type === "fill");
3823
+ const clicks = actions.filter(a => a.type === "click");
3824
+ const url = navigates[0] || "";
3825
+
3826
+ // Infer page type from URL and actions
3827
+ const urlLower = url.toLowerCase();
3828
+ const isLogin = /login|signin/.test(urlLower) || fills.some(f => /pass/i.test(f.label || f.locator || ""));
3829
+ const isRegister = /register|signup/.test(urlLower);
3830
+ const isCheckout = /checkout|payment|cart/.test(urlLower);
3831
+ const isSearch = /search/.test(urlLower) || clicks.some(c => /search/i.test(c.text || ""));
3832
+
3833
+ // Infer title
3834
+ let title = "Recorded user flow";
3835
+ if (isLogin) title = "User can log in with valid credentials";
3836
+ else if (isRegister) title = "User can complete registration";
3837
+ else if (isCheckout) title = "User can complete checkout flow";
3838
+ else if (isSearch) title = "User can search and view results";
3839
+ else if (clicks.length) title = "User can " + (clicks[0].text || "interact with page").toLowerCase();
3840
+
3841
+ // Infer preconditions
3842
+ let preconditions = "Browser is open and application is accessible";
3843
+ if (isLogin) preconditions = "User has a valid registered account";
3844
+ if (isRegister) preconditions = "User does not have an existing account";
3845
+
3846
+ // Build expected result
3847
+ let expectedResult = "Flow completes without errors";
3848
+ if (isLogin) expectedResult = "User is redirected to the dashboard/home page after successful login";
3849
+ if (isRegister) expectedResult = "Account is created and user receives confirmation";
3850
+ if (isCheckout) expectedResult = "Order is placed and confirmation page is shown";
3851
+
3852
+ // Collect unique locators used
3853
+ const locators = {};
3854
+ actions.filter(a => a.locator).forEach(a => {
3855
+ const name = (a.label || a.locator)
3856
+ .replace(/[^a-zA-Z0-9 ]/g, " ").trim()
3857
+ .replace(/\s+/g, "_").toLowerCase().slice(0, 30) || "element";
3858
+ locators[name] = a.locator;
3859
+ });
3860
+
3861
+ // Collect test data (fill values)
3862
+ const testData = {};
3863
+ fills.forEach(f => {
3864
+ const key = (f.label || f.inputType || "field")
3865
+ .replace(/[^a-zA-Z0-9 ]/g, "").trim()
3866
+ .replace(/\s+/g, "_").toLowerCase().slice(0, 30);
3867
+ if (f.inputType === "password") testData[key] = "***";
3868
+ else testData[key] = f.value || "";
3869
+ });
3870
+
3871
+ // Tags
3872
+ const tags = ["recorded", "e2e"];
3873
+ if (isLogin) tags.push("authentication");
3874
+ if (isCheckout) tags.push("checkout");
3875
+ if (isSearch) tags.push("search");
3876
+
3877
+ return {
3878
+ id: "TC001",
3879
+ title,
3880
+ category: "e2e",
3881
+ caseKind: "flow",
3882
+ packs: ["e2e"],
3883
+ priority: isLogin || isCheckout ? "high" : "medium",
3884
+ preconditions,
3885
+ steps: stepTexts,
3886
+ expectedResult,
3887
+ locators,
3888
+ testData,
3889
+ tags,
3890
+ suite: "e2e",
3891
+ approved: true,
3892
+ };
3893
+ }
3894
+
3895
+ function normalizeScenarioTypes(types) {
3896
+ const allowed = ["positive", "negative", "boundary", "navigation", "ui", "accessibility", "all_possible"];
3897
+ const picked = Array.isArray(types) ? types.filter((type) => allowed.includes(type)) : [];
3898
+ if (!picked.length) return ["positive"];
3899
+ if (picked.includes("all_possible")) return allowed;
3900
+ return Array.from(new Set(picked));
3901
+ }
3902
+
3903
+ function normalizeSuiteTypes(types) {
3904
+ const allowed = ["page", "e2e", "regression", "smoke"];
3905
+ const picked = Array.isArray(types) ? types.filter((type) => allowed.includes(type)) : [];
3906
+ return picked.length ? Array.from(new Set(picked)) : ["page"];
3907
+ }
3908
+
3909
+ function inferRecorderSource(pageContext, actions, sourceMode) {
3910
+ if (sourceMode === "hybrid" && pageContext && actions?.length) return "hybrid";
3911
+ if (actions?.length) return "recording";
3912
+ if (pageContext) return "page";
3913
+ return "hybrid";
3914
+ }
3915
+
3916
+ function ensureVerifyTitle(title, expectedResult) {
3917
+ const cleaned = String(title || "").replace(/\s+/g, " ").trim();
3918
+ if (cleaned && /^verify\b/i.test(cleaned)) {
3919
+ return cleaned.endsWith(".") ? cleaned : `${cleaned}.`;
3920
+ }
3921
+ const subject = cleaned || "the selected scenario behaves correctly";
3922
+ const expected = String(expectedResult || "the expected result is displayed").replace(/\s+/g, " ").trim().replace(/\.$/, "");
3923
+ return `Verify ${subject.replace(/^verify\s+/i, "")} and expect ${expected}.`;
3924
+ }
3925
+
3926
+ function normalizeRecorderTestCase(tc, index, pageContext, actions, sourceMode) {
3927
+ const expectedResult = String(tc.expectedResult || tc.expected_result || "").replace(/\s+/g, " ").trim();
3928
+ const tags = Array.isArray(tc.tags) ? tc.tags.map((tag) => String(tag).toLowerCase()) : [];
3929
+ const caseKind = normalizeGeneratedCaseKind(tc, String(tc.scope || "").toLowerCase() === "journey" ? "flow" : "page");
3930
+ let packs = normalizeGeneratedPacks(tc, caseKind);
3931
+ if (caseKind === "flow" && !packs.length) packs = normalizePackMembership(caseKind, ["e2e"]);
3932
+ return {
3933
+ id: String(tc.id || `TC${String(index + 1).padStart(3, "0")}`),
3934
+ title: ensureVerifyTitle(tc.title, expectedResult || "the expected result is displayed"),
3935
+ category: normalizeGeneratedCategory(tc.category, caseKind, packs),
3936
+ priority: String(tc.priority || "medium").toLowerCase(),
3937
+ preconditions: String(tc.preconditions || "").trim(),
3938
+ steps: Array.isArray(tc.steps) ? tc.steps.map((step) => String(step).trim()).filter(Boolean) : [],
3939
+ expectedResult,
3940
+ locators: tc.locators && typeof tc.locators === "object" ? tc.locators : {},
3941
+ testData: tc.testData && typeof tc.testData === "object" ? tc.testData : {},
3942
+ tags,
3943
+ caseKind,
3944
+ packs,
3945
+ suite: deriveLegacySuite(caseKind, packs),
3946
+ scope: tc.scope || (caseKind === "flow" ? "journey" : caseKind === "step" ? "step" : "page"),
3947
+ source: tc.source || inferRecorderSource(pageContext, actions, sourceMode),
3948
+ };
3949
+ }
3950
+
3951
+ function dedupeTestCases(testCases) {
3952
+ const seen = new Set();
3953
+ return testCases.filter((tc) => {
3954
+ const key = String(tc.title || "").toLowerCase().trim();
3955
+ if (!key || seen.has(key)) return false;
3956
+ seen.add(key);
3957
+ return true;
3958
+ });
3959
+ }
3960
+
3961
+ function buildScenarioGenerationPrompt(pageContext, actions, stepTexts, scenarioTypes, suiteTypes, sourceMode) {
3962
+ const slimPage = pageContext ? {
3963
+ url: pageContext.url,
3964
+ title: pageContext.title,
3965
+ headings: (pageContext.headings || []).slice(0, 8),
3966
+ forms: (pageContext.forms || []).slice(0, 6),
3967
+ inputs: (pageContext.inputs || []).slice(0, 12),
3968
+ buttons: (pageContext.buttons || []).slice(0, 12),
3969
+ links: (pageContext.links || []).slice(0, 12),
3970
+ tables: (pageContext.tables || []).slice(0, 6),
3971
+ alerts: (pageContext.alerts || []).slice(0, 6),
3972
+ } : null;
3973
+
3974
+ return `Generate QA test cases for this recorder session.
3975
+
3976
+ SOURCE MODE: ${sourceMode}
3977
+ TEST SUITES: ${suiteTypes.join(", ")}
3978
+ SCENARIO TYPES: ${scenarioTypes.join(", ")}
3979
+
3980
+ PAGE CONTEXT:
3981
+ ${JSON.stringify(slimPage, null, 2)}
3982
+
3983
+ RECORDED STEPS:
3984
+ ${JSON.stringify(stepTexts, null, 2)}
3985
+
3986
+ RECORDED ACTIONS:
3987
+ ${JSON.stringify(actions.slice(0, 25), null, 2)}
3988
+
3989
+ Requirements:
3990
+ - Return multiple test cases in a "testCases" array
3991
+ - Every title must be one line and start with "Verify"
3992
+ - Each title must say what is tested and what is expected
3993
+ - Include detailed preconditions, explicit steps, and measurable expectedResult
3994
+ - Use "caseKind" to describe whether a case is a page, flow, or step case
3995
+ - Use "packs" to describe whether a case belongs to smoke, regression, or e2e reusable packs
3996
+ - "page" coverage means local page validation, "e2e" means journey coverage, "regression" means broad retestable coverage, and "smoke" means critical happy-path checks
3997
+ - Cover requested scenario types only; if "all_possible" is present, include a broad mix
3998
+ - If the page has forms, include at least one positive and one negative test case
3999
+ - Use source as page, recording, or hybrid
4000
+
4001
+ Respond ONLY with JSON:
4002
+ {
4003
+ "testCases": [
4004
+ {
4005
+ "id": "TC001",
4006
+ "title": "Verify ... and expect ...",
4007
+ "category": "functional|negative|boundary|navigation|ui|accessibility|e2e",
4008
+ "priority": "high|medium|low",
4009
+ "caseKind": "page|flow|step",
4010
+ "packs": ["smoke", "regression", "e2e"],
4011
+ "preconditions": "What must be true before running this test",
4012
+ "steps": ["1. ...", "2. ..."],
4013
+ "expectedResult": "Specific measurable outcome",
4014
+ "locators": { "name": "locator" },
4015
+ "testData": { "key": "value" },
4016
+ "tags": ["tag"],
4017
+ "source": "page|recording|hybrid"
4018
+ }
4019
+ ]
4020
+ }`;
4021
+ }
4022
+
4023
+ function autoGenerateRecorderTestCases(pageContext, actions, stepTexts, scenarioTypes, suiteTypes, sourceMode) {
4024
+ const inputs = pageContext?.inputs || [];
4025
+ const buttons = pageContext?.buttons || [];
4026
+ const links = pageContext?.links || [];
4027
+ const tables = pageContext?.tables || [];
4028
+ const alerts = pageContext?.alerts || [];
4029
+ const forms = pageContext?.forms || [];
4030
+ const url = String(pageContext?.url || actions.find((action) => action.type === "navigate")?.url || "").toLowerCase();
4031
+ const source = inferRecorderSource(pageContext, actions, sourceMode);
4032
+ const testCases = [];
4033
+ const seen = new Set();
4034
+
4035
+ const requiredInputs = inputs.filter((input) => input.required);
4036
+ const hasPassword = inputs.some((input) => /password/i.test(input.type || input.label || ""));
4037
+ const hasUserField = inputs.some((input) => /user|email|login/i.test(input.label || input.name || input.locator || ""));
4038
+ const isLogin = /login|signin|sign-in/.test(url) || (hasPassword && hasUserField);
4039
+ const isCheckout = /checkout|payment|cart|billing/.test(url);
4040
+ const formLocators = Object.fromEntries(requiredInputs.slice(0, 6).map((input) => [sanitizeKey(input.label || input.locator || "field"), input.locator || ""]));
4041
+ const buttonLocators = Object.fromEntries(buttons.slice(0, 6).map((button) => [sanitizeKey(button.text || button.locator || "button"), button.locator || ""]));
4042
+ const linkLocators = Object.fromEntries(links.slice(0, 6).map((link) => [sanitizeKey(link.text || link.href || "link"), link.locator || link.href || ""]));
4043
+ const wantsPage = suiteTypes.includes("page") || suiteTypes.includes("regression") || suiteTypes.includes("smoke");
4044
+ const wantsE2E = suiteTypes.includes("e2e") || suiteTypes.includes("regression") || suiteTypes.includes("smoke");
4045
+ const wantsRegression = suiteTypes.includes("regression");
4046
+ const wantsSmoke = suiteTypes.includes("smoke");
4047
+
4048
+ const pushCase = (definition) => {
4049
+ const normalized = normalizeRecorderTestCase({
4050
+ ...definition,
4051
+ source: definition.source || source,
4052
+ }, testCases.length, pageContext, actions, sourceMode);
4053
+ const key = normalized.title.toLowerCase().trim();
4054
+ if (!seen.has(key)) {
4055
+ seen.add(key);
4056
+ testCases.push(normalized);
4057
+ }
4058
+ };
4059
+
4060
+ if (actions.length && wantsE2E) {
4061
+ pushCase({
4062
+ title: isLogin
4063
+ ? "Verify the recorded login flow completes successfully and expect the post-login page to open"
4064
+ : "Verify the recorded user flow completes successfully and expect the final screen to load",
4065
+ category: "e2e",
4066
+ suite: wantsSmoke ? "smoke" : wantsRegression ? "regression" : "e2e",
4067
+ priority: "high",
4068
+ preconditions: "The application is loaded and ready for interaction.",
4069
+ steps: stepTexts.length ? stepTexts : actionsToSteps(actions).map((step) => step.text),
4070
+ expectedResult: isLogin
4071
+ ? "The user is logged in successfully and the next page is displayed."
4072
+ : "The recorded flow completes without errors and the final state is shown.",
4073
+ locators: { ...formLocators, ...buttonLocators },
4074
+ testData: buildTestDataFromActions(actions),
4075
+ tags: ["recorded", "flow", wantsRegression ? "regression" : "e2e", wantsSmoke ? "smoke" : ""].filter(Boolean),
4076
+ source: actions.length && pageContext ? "hybrid" : "recording",
4077
+ });
4078
+ }
4079
+
4080
+ if (scenarioTypes.includes("positive") && wantsPage) {
4081
+ if (isLogin) {
4082
+ pushCase({
4083
+ title: "Verify login succeeds with valid credentials and expect the inventory page to open",
4084
+ category: "functional",
4085
+ suite: wantsSmoke ? "smoke" : wantsRegression ? "regression" : "page",
4086
+ priority: "high",
4087
+ preconditions: "The user has valid login credentials.",
4088
+ steps: [
4089
+ `Navigate to ${pageContext?.url || "the login page"}.`,
4090
+ "Enter a valid username in the username field.",
4091
+ "Enter a valid password in the password field.",
4092
+ "Submit the login form.",
4093
+ ],
4094
+ expectedResult: "The inventory page opens and the user is authenticated.",
4095
+ locators: formLocators,
4096
+ testData: { username: "valid_user", password: "***" },
4097
+ tags: ["positive", "authentication", wantsRegression ? "regression" : "page", wantsSmoke ? "smoke" : ""].filter(Boolean),
4098
+ });
4099
+ } else if (forms.length) {
4100
+ pushCase({
4101
+ title: "Verify the main form accepts valid input and expect successful submission",
4102
+ category: "functional",
4103
+ suite: wantsSmoke ? "smoke" : wantsRegression ? "regression" : "page",
4104
+ priority: "high",
4105
+ preconditions: "The page is loaded and valid test data is available.",
4106
+ steps: [
4107
+ "Open the page under test.",
4108
+ "Enter valid values in the required form fields.",
4109
+ "Submit the form using the primary action.",
4110
+ ],
4111
+ expectedResult: "The form is submitted successfully and the success state is displayed.",
4112
+ locators: { ...formLocators, ...buttonLocators },
4113
+ testData: buildPlaceholderData(inputs, "valid"),
4114
+ tags: ["positive", "form", wantsRegression ? "regression" : "page", wantsSmoke ? "smoke" : ""].filter(Boolean),
4115
+ });
4116
+ } else if (buttons.length) {
4117
+ pushCase({
4118
+ title: "Verify the primary page action works and expect the next state to appear",
4119
+ category: "functional",
4120
+ suite: wantsSmoke ? "smoke" : wantsRegression ? "regression" : "page",
4121
+ priority: "medium",
4122
+ preconditions: "The page is loaded and ready for interaction.",
4123
+ steps: [
4124
+ "Open the target page.",
4125
+ `Click ${buttons[0].text || "the primary action button"}.`,
4126
+ ],
4127
+ expectedResult: "The intended next state or response is displayed.",
4128
+ locators: buttonLocators,
4129
+ testData: {},
4130
+ tags: ["positive", wantsRegression ? "regression" : "page", wantsSmoke ? "smoke" : ""].filter(Boolean),
4131
+ });
4132
+ }
4133
+ }
4134
+
4135
+ if (scenarioTypes.includes("negative") && (wantsPage || wantsRegression)) {
4136
+ if (isLogin) {
4137
+ pushCase({
4138
+ title: "Verify login fails with invalid credentials and expect an error message",
4139
+ category: "negative",
4140
+ suite: wantsRegression ? "regression" : "page",
4141
+ priority: "high",
4142
+ preconditions: "The user is on the login page.",
4143
+ steps: [
4144
+ `Navigate to ${pageContext?.url || "the login page"}.`,
4145
+ "Enter an invalid username and password.",
4146
+ "Submit the login form.",
4147
+ ],
4148
+ expectedResult: "Authentication is rejected and an error message is displayed.",
4149
+ locators: { ...formLocators, ...buttonLocators },
4150
+ testData: { username: "invalid_user", password: "***" },
4151
+ tags: ["negative", "authentication", wantsRegression ? "regression" : "page"],
4152
+ });
4153
+ }
4154
+ if (requiredInputs.length) {
4155
+ pushCase({
4156
+ title: "Verify required fields block submission when left empty and expect validation feedback",
4157
+ category: "negative",
4158
+ suite: wantsRegression ? "regression" : "page",
4159
+ priority: "high",
4160
+ preconditions: "The page is loaded with the main form visible.",
4161
+ steps: [
4162
+ "Open the page with the form.",
4163
+ "Leave the required fields empty.",
4164
+ "Attempt to submit the form.",
4165
+ ],
4166
+ expectedResult: "Submission is blocked and validation messages or error states are shown.",
4167
+ locators: { ...formLocators, ...buttonLocators },
4168
+ testData: {},
4169
+ tags: ["negative", "validation", wantsRegression ? "regression" : "page"],
4170
+ });
4171
+ }
4172
+ }
4173
+
4174
+ if (scenarioTypes.includes("boundary") && inputs.length && (wantsPage || wantsRegression)) {
4175
+ pushCase({
4176
+ title: "Verify boundary input values are handled correctly and expect stable validation behavior",
4177
+ category: "boundary",
4178
+ suite: wantsRegression ? "regression" : "page",
4179
+ priority: "medium",
4180
+ preconditions: "The form fields are available for input.",
4181
+ steps: [
4182
+ "Open the page containing editable input fields.",
4183
+ "Enter boundary-length values into the relevant fields.",
4184
+ "Submit or leave the fields to trigger validation.",
4185
+ ],
4186
+ expectedResult: "Boundary values are either accepted correctly or rejected with clear validation feedback.",
4187
+ locators: formLocators,
4188
+ testData: buildPlaceholderData(inputs, "boundary"),
4189
+ tags: ["boundary", wantsRegression ? "regression" : "page"],
4190
+ });
4191
+ }
4192
+
4193
+ if (scenarioTypes.includes("navigation") && links.length && wantsPage) {
4194
+ pushCase({
4195
+ title: "Verify navigation links route to the expected destination and expect the target page to load",
4196
+ category: "navigation",
4197
+ suite: wantsSmoke ? "smoke" : wantsRegression ? "regression" : "page",
4198
+ priority: "medium",
4199
+ preconditions: "The page is loaded with visible navigation links.",
4200
+ steps: [
4201
+ "Open the current page.",
4202
+ `Click ${links[0].text || "the first visible navigation link"}.`,
4203
+ ],
4204
+ expectedResult: "The linked destination loads without broken navigation or unexpected errors.",
4205
+ locators: linkLocators,
4206
+ testData: {},
4207
+ tags: ["navigation", wantsRegression ? "regression" : "page", wantsSmoke ? "smoke" : ""].filter(Boolean),
4208
+ });
4209
+ }
4210
+
4211
+ if (scenarioTypes.includes("ui") && wantsPage) {
4212
+ if (buttons.length) {
4213
+ pushCase({
4214
+ title: "Verify critical page controls are visible and usable and expect the UI state to remain stable",
4215
+ category: "ui",
4216
+ suite: wantsSmoke ? "smoke" : wantsRegression ? "regression" : "page",
4217
+ priority: "medium",
4218
+ preconditions: "The page has loaded completely.",
4219
+ steps: [
4220
+ "Open the current page.",
4221
+ "Inspect the key buttons and interactive controls.",
4222
+ "Confirm the primary controls are visible and actionable.",
4223
+ ],
4224
+ expectedResult: "Important controls are visible, enabled when appropriate, and rendered without layout issues.",
4225
+ locators: buttonLocators,
4226
+ testData: {},
4227
+ tags: ["ui", wantsRegression ? "regression" : "page", wantsSmoke ? "smoke" : ""].filter(Boolean),
4228
+ });
4229
+ }
4230
+ if (tables.length) {
4231
+ pushCase({
4232
+ title: "Verify table data and headers render correctly and expect the grid layout to remain readable",
4233
+ category: "ui",
4234
+ suite: wantsRegression ? "regression" : "page",
4235
+ priority: "medium",
4236
+ preconditions: "The page contains a data table.",
4237
+ steps: [
4238
+ "Open the page that contains table content.",
4239
+ "Review the visible table headers and rows.",
4240
+ ],
4241
+ expectedResult: "Table headers and rows render correctly with no broken layout or missing data labels.",
4242
+ locators: { table: tables[0].locator || "table" },
4243
+ testData: {},
4244
+ tags: ["ui", "table", wantsRegression ? "regression" : "page"],
4245
+ });
4246
+ }
4247
+ }
4248
+
4249
+ if (scenarioTypes.includes("accessibility") && wantsPage) {
4250
+ pushCase({
4251
+ title: "Verify the main interactive elements are accessible and expect labels and keyboard behavior to be clear",
4252
+ category: "accessibility",
4253
+ suite: wantsRegression ? "regression" : "page",
4254
+ priority: "medium",
4255
+ preconditions: "The page is loaded and interactive elements are visible.",
4256
+ steps: [
4257
+ "Open the current page.",
4258
+ "Review visible input labels, placeholders, or accessible names.",
4259
+ "Navigate the primary controls with keyboard interaction.",
4260
+ ],
4261
+ expectedResult: "Core fields and controls expose clear names or labels and remain usable through keyboard navigation.",
4262
+ locators: { ...formLocators, ...buttonLocators, ...linkLocators },
4263
+ testData: {},
4264
+ tags: ["accessibility", wantsRegression ? "regression" : "page"],
4265
+ });
4266
+ }
4267
+
4268
+ if (alerts.length && wantsPage) {
4269
+ pushCase({
4270
+ title: "Verify page feedback messages appear clearly and expect users to understand the outcome",
4271
+ category: "ui",
4272
+ suite: wantsRegression ? "regression" : "page",
4273
+ priority: "low",
4274
+ preconditions: "The page includes alerts, errors, or feedback messages.",
4275
+ steps: [
4276
+ "Trigger a page action that displays a feedback message.",
4277
+ "Observe the rendered alert or feedback state.",
4278
+ ],
4279
+ expectedResult: "Feedback messages are visible, readable, and clearly communicate the result of the action.",
4280
+ locators: Object.fromEntries(alerts.slice(0, 4).map((alert, index) => [`alert_${index + 1}`, alert.locator || alert.text])),
4281
+ testData: {},
4282
+ tags: ["ui", "feedback", wantsRegression ? "regression" : "page"],
4283
+ });
4284
+ }
4285
+
4286
+ return testCases;
4287
+ }
4288
+
4289
+ function sanitizeKey(value) {
4290
+ return String(value || "item").replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "").toLowerCase().slice(0, 32) || "item";
4291
+ }
4292
+
4293
+ function buildPlaceholderData(inputs, mode) {
4294
+ const data = {};
4295
+ (inputs || []).slice(0, 6).forEach((input, index) => {
4296
+ const key = sanitizeKey(input.label || input.locator || `field_${index + 1}`);
4297
+ if (mode === "boundary") data[key] = "boundary_value";
4298
+ else if (/password/i.test(input.type || key)) data[key] = "***";
4299
+ else data[key] = "valid_value";
4300
+ });
4301
+ return data;
4302
+ }
4303
+
4304
+ function buildTestDataFromActions(actions) {
4305
+ const data = {};
4306
+ (actions || []).filter((action) => action.type === "fill").forEach((action, index) => {
4307
+ const key = sanitizeKey(action.label || action.locator || `field_${index + 1}`);
4308
+ data[key] = /password/i.test(action.inputType || key) ? "***" : action.value || "";
4309
+ });
4310
+ return data;
4311
+ }
4312
+
4313
+ // ─── CI/CD config handler ─────────────────────────────────────────────────────
4314
+
4315
+ async function handleGenerateCICD(req, res) {
4316
+ const body = await readBody(req);
4317
+ const {
4318
+ framework, projectName, pageType, baseUrl,
4319
+ browsers, parallel, reporters, slackWebhook,
4320
+ emailNotify, branches, prTrigger, testCaseCount,
4321
+ useAllure, nodeVersion, pythonVersion, javaVersion,
4322
+ } = body;
4323
+
4324
+ if (!framework) return jsonResponse(res, 400, { error: "framework is required" });
4325
+
4326
+ console.log(`[CI/CD] Generating configs for ${framework} — project: ${projectName}`);
4327
+
4328
+ try {
4329
+ const configs = generateCICD({
4330
+ framework,
4331
+ projectName: projectName || "qa-tests",
4332
+ pageType: pageType || "page",
4333
+ baseUrl: baseUrl || "https://staging.example.com",
4334
+ browsers: browsers || ["chromium"],
4335
+ parallel: parallel ?? false,
4336
+ reporters: reporters || ["html", "junit"],
4337
+ slackWebhook: slackWebhook ?? false,
4338
+ emailNotify: emailNotify ?? false,
4339
+ branches: branches || ["main", "develop"],
4340
+ prTrigger: prTrigger ?? true,
4341
+ testCaseCount: testCaseCount || 0,
4342
+ useAllure: useAllure ?? false,
4343
+ nodeVersion: nodeVersion || "20",
4344
+ pythonVersion: pythonVersion || "3.11",
4345
+ javaVersion: javaVersion || "17",
4346
+ });
4347
+
4348
+ console.log(`[CI/CD] ✓ Generated: ${Object.keys(configs).join(", ")}`);
4349
+ jsonResponse(res, 200, { success: true, configs });
4350
+ } catch (err) {
4351
+ console.error("[CI/CD] Error:", err.message);
4352
+ jsonResponse(res, 500, { error: "CI/CD generation failed: " + err.message });
4353
+ }
4354
+ }
4355
+
4356
+ // ─── Page proxy (strips X-Frame-Options, injects capture script) ──────────────
4357
+
4358
+ async function handleProxy(req, res, url) {
4359
+ const target = url.searchParams.get("url");
4360
+ if (!target) return jsonResponse(res, 400, { error: "url param required" });
4361
+ if (!/^https?:\/\//i.test(target)) return jsonResponse(res, 400, { error: "Invalid URL" });
4362
+
4363
+ try {
4364
+ const targetUrl = new URL(target);
4365
+ const origin = targetUrl.origin;
4366
+
4367
+ const html = await proxyFetchHTML(target);
4368
+
4369
+ // Inject our capture script + fix relative URLs
4370
+ const baseHref = `http://localhost:${PORT}/api/proxy-asset/${targetUrl.protocol.replace(":", "")}/${targetUrl.host}${targetUrl.pathname}${targetUrl.search}`;
4371
+ const baseTag = `<base href="${baseHref}"/>`;
4372
+ const captureScript = `<script>
4373
+ (function(){
4374
+ if(window.__QA_ACTIVE)return;
4375
+ window.__QA_ACTIVE=true;window.__QA_Q=[];
4376
+
4377
+ function _proxyAsset(absUrl){
4378
+ try {
4379
+ var u = new URL(absUrl);
4380
+ return 'http://localhost:${PORT}/api/proxy-asset/' + u.protocol.replace(':','') + '/' + u.host + u.pathname + u.search;
4381
+ } catch(e) {
4382
+ return absUrl;
4383
+ }
4384
+ }
4385
+ var _PROXY_PAGE = 'http://localhost:${PORT}/api/proxy?url=';
4386
+ var _ORIGIN = '${origin}';
4387
+ var _TARGET_PATH = ${JSON.stringify(targetUrl.pathname + targetUrl.search + targetUrl.hash || "/")};
4388
+
4389
+ try {
4390
+ if (window.location.pathname !== _TARGET_PATH) {
4391
+ history.replaceState({}, '', _TARGET_PATH);
4392
+ }
4393
+ } catch(e) {}
4394
+
4395
+ try {
4396
+ if (navigator.serviceWorker && typeof navigator.serviceWorker.register === 'function') {
4397
+ navigator.serviceWorker.register = function() {
4398
+ return Promise.resolve({
4399
+ installing: null,
4400
+ waiting: null,
4401
+ active: null,
4402
+ scope: window.location.origin + _TARGET_PATH,
4403
+ onupdatefound: null,
4404
+ unregister: function() { return Promise.resolve(true); },
4405
+ update: function() { return Promise.resolve(); }
4406
+ });
4407
+ };
4408
+ }
4409
+ } catch(e) {}
4410
+
4411
+ // ── Intercept fetch so React dynamic imports work ──
4412
+ var _origFetch = window.fetch;
4413
+ window.fetch = function(input, init) {
4414
+ var url = (typeof input === 'string') ? input : (input && input.url) || '';
4415
+ // Rewrite same-origin or relative URLs through asset proxy
4416
+ if (url && !url.startsWith('http://localhost') && !url.startsWith('data:')) {
4417
+ try {
4418
+ var abs = url.startsWith('http') ? url : new URL(url, _ORIGIN).toString();
4419
+ if (abs.startsWith(_ORIGIN) || abs.startsWith('https://') || abs.startsWith('http://')) {
4420
+ var proxied = _proxyAsset(abs);
4421
+ if (typeof input === 'string') input = proxied;
4422
+ else input = new Request(proxied, input);
4423
+ }
4424
+ } catch(e) {}
4425
+ }
4426
+ return _origFetch.call(this, input, init);
4427
+ };
4428
+
4429
+ // ── Intercept XHR for older-style apps ──
4430
+ var _origXHROpen = XMLHttpRequest.prototype.open;
4431
+ XMLHttpRequest.prototype.open = function(method, url) {
4432
+ if (url && typeof url === 'string' && !url.startsWith('http://localhost') && !url.startsWith('data:')) {
4433
+ try {
4434
+ var abs = url.startsWith('http') ? url : new URL(url, _ORIGIN).toString();
4435
+ url = _proxyAsset(abs);
4436
+ } catch(e) {}
4437
+ }
4438
+ return _origXHROpen.apply(this, [method, url].concat(Array.prototype.slice.call(arguments, 2)));
4439
+ };
4440
+
4441
+ // ── Event capture ──
4442
+ function _loc(e){if(!e)return null;var t=e.dataset&&(e.dataset.testid||e.dataset.test||e.dataset.cy);if(t)return'[data-testid="'+t+'"]';if(e.id)return'#'+e.id;var a=e.getAttribute('aria-label');if(a)return'[aria-label="'+a+'"]';if(e.name)return'[name="'+e.name+'"]';var g=(e.tagName||'').toLowerCase(),x=(e.textContent||e.value||'').trim().slice(0,40);return x?(g+':has-text("'+x+'")'):g;}
4443
+ function _lbl(e){if(e.getAttribute('aria-label'))return e.getAttribute('aria-label');if(e.id){var l=document.querySelector('label[for="'+e.id+'"]');if(l)return l.textContent.trim();}return e.placeholder||null;}
4444
+ function _push(o){o.ts=Date.now();window.__QA_Q.push(o);}
4445
+ document.addEventListener('click',function(e){var el=e.target.closest('button,a,[role="button"],input[type="checkbox"],input[type="radio"]');if(!el)return;if(el.type==='checkbox')_push({type:'check',locator:_loc(el),checked:el.checked,label:_lbl(el)});else if(el.type==='radio')_push({type:'radio',locator:_loc(el),value:el.value,label:_lbl(el)});else _push({type:'click',locator:_loc(el),text:(el.textContent||el.value||'').trim().slice(0,60),tag:(el.tagName||'').toLowerCase()});},true);
4446
+ document.addEventListener('blur',function(e){var el=e.target;if(!el||!['INPUT','TEXTAREA'].includes(el.tagName))return;if(['checkbox','radio','submit','button'].includes(el.type))return;if(!el.value)return;var last=window.__QA_Q[window.__QA_Q.length-1];if(last&&last.type==='fill'&&last.locator===_loc(el)&&last.value===el.value)return;_push({type:'fill',locator:_loc(el),value:el.value,inputType:el.type||'text',label:_lbl(el)});},true);
4447
+ document.addEventListener('change',function(e){var el=e.target;if(!el||el.tagName!=='SELECT')return;var o=el.options[el.selectedIndex];_push({type:'select',locator:_loc(el),value:el.value,optionText:o?o.text:el.value,label:_lbl(el)});},true);
4448
+ document.addEventListener('submit',function(e){_push({type:'submit',locator:_loc(e.target)});},true);
4449
+ document.addEventListener('keydown',function(e){if(e.key==='Enter'&&!['BUTTON','A'].includes(e.target.tagName))_push({type:'press',key:'Enter',locator:_loc(e.target)});if(e.key==='Escape')_push({type:'press',key:'Escape'});},true);
4450
+ console.log('[QA Deck] Proxy capture active on ${origin}');
4451
+ })();
4452
+ </script>`;
4453
+
4454
+ const proxyAssetUrl = (rawUrl) => {
4455
+ try {
4456
+ const asset = new URL(rawUrl);
4457
+ return `http://localhost:${PORT}/api/proxy-asset/${asset.protocol.replace(":", "")}/${asset.host}${asset.pathname}${asset.search}`;
4458
+ } catch {
4459
+ return rawUrl;
4460
+ }
4461
+ };
4462
+
4463
+ let patched = html;
4464
+
4465
+ // Strip meta CSP/refresh tags that break proxied SPAs.
4466
+ patched = patched.replace(
4467
+ /<meta[^>]+http-equiv=["'](?:content-security-policy|refresh|x-frame-options)["'][^>]*>/gi,
4468
+ ""
4469
+ );
4470
+
4471
+ // 1. Inject base URL + capture script at top of <head>
4472
+ if (/<head[^>]*>/i.test(patched)) {
4473
+ patched = patched.replace(/(<head[^>]*>)/i, `$1${baseTag}${captureScript}`);
4474
+ } else {
4475
+ patched = baseTag + captureScript + patched;
4476
+ }
4477
+
4478
+ // 2. Rewrite absolute URLs (https://...) → proxy-asset
4479
+ patched = patched
4480
+ .replace(/(src|href)="(https?:\/\/[^"#?][^"]*)"/gi, (m, attr, u) =>
4481
+ `${attr}="${proxyAssetUrl(u)}"`)
4482
+ .replace(/(src|href)='(https?:\/\/[^'#?][^']*)'/gi, (m, attr, u) =>
4483
+ `${attr}='${proxyAssetUrl(u)}'`);
4484
+
4485
+ // 3. Rewrite root-relative URLs (/path/...) → proxy-asset with origin prepended
4486
+ patched = patched
4487
+ .replace(/(src|href)="(\/(?!\/)[^"]*)"/gi, (m, attr, path) =>
4488
+ `${attr}="${proxyAssetUrl(origin + path)}"`)
4489
+ .replace(/(src|href)='(\/(?!\/)[^']*)'/gi, (m, attr, path) =>
4490
+ `${attr}='${proxyAssetUrl(origin + path)}'`);
4491
+
4492
+ res.writeHead(200, {
4493
+ "Content-Type": "text/html; charset=utf-8",
4494
+ "X-Frame-Options": "ALLOWALL",
4495
+ "Access-Control-Allow-Origin": "*",
4496
+ });
4497
+ res.end(patched);
4498
+
4499
+ } catch (err) {
4500
+ jsonResponse(res, 502, { error: "Proxy fetch failed: " + err.message });
4501
+ }
4502
+ }
4503
+
4504
+ // ─── Proxy HTML fetcher ───────────────────────────────────────────────────────
4505
+ function proxyFetchHTML(target, depth) {
4506
+ depth = depth || 0;
4507
+ if (depth > 3) return Promise.reject(new Error("Too many redirects"));
4508
+ return new Promise((resolve, reject) => {
4509
+ const targetUrl = new URL(target);
4510
+ const lib = targetUrl.protocol === "https:" ? require("https") : require("http");
4511
+ const request = lib.request({
4512
+ hostname: targetUrl.hostname,
4513
+ path: targetUrl.pathname + targetUrl.search,
4514
+ method: "GET",
4515
+ headers: {
4516
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120 Safari/537.36",
4517
+ "Accept": "text/html,application/xhtml+xml,*/*;q=0.9",
4518
+ "Accept-Language": "en-US,en;q=0.9",
4519
+ },
4520
+ }, (proxyRes) => {
4521
+ if ([301,302,303,307,308].includes(proxyRes.statusCode) && proxyRes.headers.location) {
4522
+ const next = new URL(proxyRes.headers.location, target).toString();
4523
+ resolve(proxyFetchHTML(next, depth + 1));
4524
+ return;
4525
+ }
4526
+ let data = "";
4527
+ proxyRes.on("data", c => data += c);
4528
+ proxyRes.on("end", () => resolve(data));
4529
+ });
4530
+ request.on("error", reject);
4531
+ request.setTimeout(10000, () => { request.destroy(); reject(new Error("Timeout")); });
4532
+ request.end();
4533
+ });
4534
+ }
4535
+
4536
+ // Helper for redirect following
4537
+ function handleProxyFetch(url, depth) {
4538
+ if (depth > 3) return Promise.reject(new Error("Too many redirects"));
4539
+ return new Promise((resolve, reject) => {
4540
+ const targetUrl = new URL(url);
4541
+ const lib = targetUrl.protocol === "https:" ? require("https") : require("http");
4542
+ const request = lib.request({
4543
+ hostname: targetUrl.hostname,
4544
+ path: targetUrl.pathname + targetUrl.search,
4545
+ method: "GET",
4546
+ headers: { "User-Agent": "Mozilla/5.0 Chrome/120" },
4547
+ }, (res) => {
4548
+ if ([301,302,303,307,308].includes(res.statusCode) && res.headers.location) {
4549
+ resolve(handleProxyFetch(new URL(res.headers.location, url).toString(), depth+1));
4550
+ return;
4551
+ }
4552
+ let data = "";
4553
+ res.on("data", c => data += c);
4554
+ res.on("end", () => resolve(data));
4555
+ });
4556
+ request.on("error", reject);
4557
+ request.setTimeout(8000, () => { request.destroy(); reject(new Error("Timeout")); });
4558
+ request.end();
4559
+ });
4560
+ }
4561
+
4562
+ // ─── Asset proxy (fetches CSS/JS/images for proxied pages) ───────────────────
4563
+ async function handleProxyAsset(req, res, url) {
4564
+ let assetUrl = url.searchParams.get("url");
4565
+ if (!assetUrl && url.pathname.startsWith("/api/proxy-asset/")) {
4566
+ const suffix = url.pathname.slice("/api/proxy-asset/".length);
4567
+ const parts = suffix.split("/").filter(Boolean);
4568
+ const protocol = parts.shift();
4569
+ const host = parts.shift();
4570
+ const pathName = "/" + parts.join("/");
4571
+ if (protocol && host) {
4572
+ assetUrl = `${protocol}://${host}${pathName}${url.search || ""}`;
4573
+ }
4574
+ }
4575
+
4576
+ if (!assetUrl || !/^https?:\/\//i.test(assetUrl)) {
4577
+ res.writeHead(400); res.end(); return;
4578
+ }
4579
+ try {
4580
+ const targetUrl = new URL(assetUrl);
4581
+ const lib = targetUrl.protocol === "https:" ? require("https") : require("http");
4582
+ await new Promise((resolve, reject) => {
4583
+ const request = lib.request({
4584
+ hostname: targetUrl.hostname,
4585
+ path: targetUrl.pathname + targetUrl.search,
4586
+ method: "GET",
4587
+ headers: {
4588
+ "User-Agent": "Mozilla/5.0 Chrome/120",
4589
+ "Referer": targetUrl.origin + "/",
4590
+ "Accept": "*/*",
4591
+ },
4592
+ }, (proxyRes) => {
4593
+ // Follow one redirect
4594
+ if ([301,302,303,307,308].includes(proxyRes.statusCode) && proxyRes.headers.location) {
4595
+ const loc = new URL(proxyRes.headers.location, assetUrl).toString();
4596
+ handleProxyAsset(req, { writeHead: res.writeHead.bind(res), end: res.end.bind(res), write: res.write.bind(res) }, new URL(`http://x/api/proxy-asset?url=${encodeURIComponent(loc)}`));
4597
+ resolve(); return;
4598
+ }
4599
+ const ct = proxyRes.headers["content-type"] || "application/octet-stream";
4600
+ res.writeHead(200, {
4601
+ "Content-Type": ct,
4602
+ "Access-Control-Allow-Origin": "*",
4603
+ "Cache-Control": "public, max-age=3600",
4604
+ });
4605
+ proxyRes.pipe(res);
4606
+ proxyRes.on("end", resolve);
4607
+ proxyRes.on("error", reject);
4608
+ });
4609
+ request.on("error", (e) => { console.error("[proxy-asset]", e.message); res.writeHead(502); res.end(); reject(e); });
4610
+ request.setTimeout(8000, () => { request.destroy(); res.writeHead(504); res.end(); reject(new Error("timeout")); });
4611
+ request.end();
4612
+ });
4613
+ } catch (err) {
4614
+ if (!res.headersSent) { res.writeHead(502); res.end(); }
4615
+ }
4616
+ }