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.
@@ -0,0 +1,539 @@
1
+ /**
2
+ * QA Deck — Action Converter
3
+ *
4
+ * Converts raw recorded actions into:
5
+ * 1. Human-readable test steps (for the test case editor)
6
+ * 2. Selenium Python code
7
+ * 3. Selenium Java code
8
+ * 4. Playwright Python code
9
+ * 5. Playwright TypeScript code
10
+ */
11
+
12
+ // ─── Human-readable steps ─────────────────────────────────────────────────────
13
+
14
+ function actionsToSteps(actions) {
15
+ const steps = [];
16
+ let stepNum = 1;
17
+
18
+ for (const action of actions) {
19
+ const step = actionToStep(action, stepNum);
20
+ if (step) {
21
+ steps.push({ num: stepNum++, text: step, action });
22
+ }
23
+ }
24
+
25
+ return steps;
26
+ }
27
+
28
+ function actionToStep(action, num) {
29
+ switch (action.type) {
30
+ case "navigate":
31
+ return `Navigate to ${action.url}`;
32
+ case "click":
33
+ return `Click ${describeElement(action)}`;
34
+ case "fill":
35
+ return `Enter "${action.value}" in ${describeField(action, "field")}`;
36
+ case "select":
37
+ return `Select "${action.optionText || action.value}" from ${describeField(action, "dropdown")}`;
38
+ case "check":
39
+ return `${action.checked ? "Check" : "Uncheck"} ${describeField(action, "checkbox")}`;
40
+ case "radio":
41
+ return `Select "${action.label || action.value}" radio option`;
42
+ case "press":
43
+ return `Press ${action.key} key${action.context ? ` (${action.context})` : ""}`;
44
+ case "hover":
45
+ return `Hover over ${describeElement(action)}`;
46
+ case "upload":
47
+ return `Upload a file using ${describeField(action, "upload input")}`;
48
+ case "submit":
49
+ return getActionLocator(action) ? `Submit ${describeField(action, "form")}` : `Submit the form`;
50
+ case "dialog":
51
+ return `Handle ${action.dialogType} dialog: "${action.message?.slice(0, 60) || ""}"`;
52
+ default:
53
+ return null;
54
+ }
55
+ }
56
+
57
+ function describeElement(action) {
58
+ if (action.text) return `"${action.text}"`;
59
+ if (action.label) return `the ${action.label} ${action.tag || "element"}`;
60
+ if (action.name) return `the ${action.name} ${action.tag || "element"}`;
61
+ const locator = getActionLocator(action);
62
+ return locator ? `element (${locator})` : (action.tag || "element");
63
+ }
64
+
65
+ function describeField(action, kind) {
66
+ if (action.label) return `the ${action.label} ${kind}`;
67
+ if (action.name) return `the ${action.name} ${kind}`;
68
+ if (action.placeholder) return `the "${action.placeholder}" ${kind}`;
69
+ const locator = getActionLocator(action);
70
+ return locator ? `the ${kind} (${locator})` : `the ${kind}`;
71
+ }
72
+
73
+ function getActionLocator(action) {
74
+ return action?.locator || action?.selector || "";
75
+ }
76
+
77
+ // ─── Code generators ──────────────────────────────────────────────────────────
78
+
79
+ function actionsToCode(actions, framework, pageClassName = "RecordedPage") {
80
+ const generators = {
81
+ "selenium-python": seleniumPython,
82
+ "selenium-java": seleniumJava,
83
+ "playwright-python": playwrightPython,
84
+ "playwright-typescript": playwrightTypeScript,
85
+ };
86
+ const gen = generators[framework] || generators["selenium-python"];
87
+ return gen(actions, pageClassName);
88
+ }
89
+
90
+ // ── Selenium Python ───────────────────────────────────────────────────────────
91
+
92
+ function seleniumPython(actions, className) {
93
+ const pageType = inferPageType(actions);
94
+ const lines = [];
95
+
96
+ lines.push(`from selenium.webdriver.common.by import By`);
97
+ lines.push(`from selenium.webdriver.support.ui import WebDriverWait, Select`);
98
+ lines.push(`from selenium.webdriver.support import expected_conditions as EC`);
99
+ lines.push(`from selenium.webdriver.common.keys import Keys`);
100
+ lines.push(``);
101
+ lines.push(`# ── Page Object ──────────────────────────────────────────────`);
102
+ lines.push(`class ${className}:`);
103
+ lines.push(``);
104
+
105
+ // Collect unique locators
106
+ const locators = extractLocators(actions);
107
+ locators.forEach(({ name, strategy, value }) => {
108
+ lines.push(` ${name.toUpperCase()} = (By.${strategy}, "${value}")`);
109
+ });
110
+ lines.push(``);
111
+ lines.push(` def __init__(self, driver):`);
112
+ lines.push(` self.driver = driver`);
113
+ lines.push(` self.wait = WebDriverWait(driver, 10)`);
114
+ lines.push(``);
115
+
116
+ // Action methods
117
+ const methods = buildSeleniumPythonMethods(actions, locators);
118
+ methods.forEach(m => lines.push(...m.split("\n").map(l => " " + l)));
119
+ lines.push(``);
120
+
121
+ // Test method
122
+ lines.push(`# ── Recorded test ────────────────────────────────────────────`);
123
+ lines.push(`def test_recorded_flow(driver):`);
124
+ lines.push(` """Auto-generated from recorded session"""`)
125
+ lines.push(` page = ${className}(driver)`);
126
+ lines.push(``);
127
+
128
+ actions.forEach((action, i) => {
129
+ const code = actionToSeleniumPython(action, locators);
130
+ if (code) {
131
+ lines.push(` # Step ${i + 1}: ${actionToStep(action, i + 1) || action.type}`);
132
+ lines.push(` ${code}`);
133
+ }
134
+ });
135
+
136
+ return lines.join("\n");
137
+ }
138
+
139
+ function buildSeleniumPythonMethods(actions, locators) {
140
+ const methods = [];
141
+ const fills = actions.filter(a => a.type === "fill");
142
+ const clicks = actions.filter(a => a.type === "click");
143
+
144
+ if (fills.length > 0 || clicks.some(c => c.locator?.includes("submit") || c.text?.toLowerCase().includes("submit") || c.text?.toLowerCase().includes("login") || c.text?.toLowerCase().includes("save"))) {
145
+ const params = fills.map(f => {
146
+ const name = locatorToVarName(getActionLocator(f));
147
+ return `${name}="${f.value}"`;
148
+ }).join(", ");
149
+
150
+ methods.push(`def perform_actions(self, ${params || "**kwargs"}):\n """Execute the recorded user flow"""\n${fills.map(f => {
151
+ const name = locatorToVarName(getActionLocator(f));
152
+ const upper = name.toUpperCase();
153
+ return ` el = self.wait.until(EC.element_to_be_clickable(self.${upper}))\n el.clear()\n el.send_keys(${name})`;
154
+ }).join("\n")}`);
155
+ }
156
+
157
+ return methods;
158
+ }
159
+
160
+ function actionToSeleniumPython(action, locators) {
161
+ const locator = getActionLocator(action);
162
+ const locInfo = findLocatorInfo(locator, locators);
163
+ const locRef = locInfo ? `self.${locInfo.name.toUpperCase()}` : `(By.CSS_SELECTOR, "${locator}")`;
164
+
165
+ switch (action.type) {
166
+ case "navigate":
167
+ return `driver.get("${action.url}")`;
168
+ case "click":
169
+ return `page.wait.until(EC.element_to_be_clickable(${locRef})).click()`;
170
+ case "fill":
171
+ return `el = page.wait.until(EC.presence_of_element_located(${locRef}))\nel.clear()\nel.send_keys("${escStr(action.value)}")`;
172
+ case "select":
173
+ return `Select(driver.find_element(*${locRef})).select_by_visible_text("${escStr(action.optionText || action.value)}")`;
174
+ case "check":
175
+ return `cb = driver.find_element(*${locRef})\nif cb.is_selected() != ${action.checked}: cb.click()`;
176
+ case "press":
177
+ return `driver.switch_to.active_element.send_keys(Keys.${action.key.toUpperCase()})`;
178
+ case "submit":
179
+ return `driver.find_element(*${locRef}).submit()`;
180
+ case "hover":
181
+ return `ActionChains(driver).move_to_element(driver.find_element(*${locRef})).perform()`;
182
+ default:
183
+ return null;
184
+ }
185
+ }
186
+
187
+ // ── Selenium Java ─────────────────────────────────────────────────────────────
188
+
189
+ function seleniumJava(actions, className) {
190
+ const lines = [];
191
+ lines.push(`import org.openqa.selenium.*;`);
192
+ lines.push(`import org.openqa.selenium.support.ui.*;`);
193
+ lines.push(`import org.openqa.selenium.interactions.Actions;`);
194
+ lines.push(`import java.time.Duration;`);
195
+ lines.push(``);
196
+ lines.push(`public class ${className} {`);
197
+ lines.push(``);
198
+
199
+ const locators = extractLocators(actions);
200
+ locators.forEach(({ name, strategy, value }) => {
201
+ lines.push(` private final By ${camelCase(name)} = By.${javaStrategy(strategy)}("${value}");`);
202
+ });
203
+
204
+ lines.push(``);
205
+ lines.push(` private final WebDriver driver;`);
206
+ lines.push(` private final WebDriverWait wait;`);
207
+ lines.push(``);
208
+ lines.push(` public ${className}(WebDriver driver) {`);
209
+ lines.push(` this.driver = driver;`);
210
+ lines.push(` this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));`);
211
+ lines.push(` }`);
212
+ lines.push(``);
213
+ lines.push(` /** Auto-generated from recorded session */`);
214
+ lines.push(` public void performRecordedFlow() {`);
215
+
216
+ actions.forEach((action, i) => {
217
+ const code = actionToSeleniumJava(action, locators);
218
+ if (code) {
219
+ lines.push(` // Step ${i + 1}: ${actionToStep(action, i + 1) || action.type}`);
220
+ lines.push(` ${code}`);
221
+ }
222
+ });
223
+
224
+ lines.push(` }`);
225
+ lines.push(`}`);
226
+
227
+ return lines.join("\n");
228
+ }
229
+
230
+ function actionToSeleniumJava(action, locators) {
231
+ const locator = getActionLocator(action);
232
+ const locInfo = findLocatorInfo(locator, locators);
233
+ const locRef = locInfo ? camelCase(locInfo.name) : `By.cssSelector("${locator}")`;
234
+
235
+ switch (action.type) {
236
+ case "navigate": return `driver.get("${action.url}");`;
237
+ case "click": return `wait.until(ExpectedConditions.elementToBeClickable(${locRef})).click();`;
238
+ case "fill": return `WebElement el${sanitize(locator)} = wait.until(ExpectedConditions.presenceOfElementLocated(${locRef}));\nel${sanitize(locator)}.clear();\nel${sanitize(locator)}.sendKeys("${escStr(action.value)}");`;
239
+ case "select": return `new Select(driver.findElement(${locRef})).selectByVisibleText("${escStr(action.optionText || action.value)}");`;
240
+ case "press": return `driver.switchTo().activeElement().sendKeys(Keys.${action.key.toUpperCase()});`;
241
+ case "submit": return `driver.findElement(${locRef}).submit();`;
242
+ default: return null;
243
+ }
244
+ }
245
+
246
+ // ── Playwright Python ─────────────────────────────────────────────────────────
247
+
248
+ function playwrightPython(actions, className) {
249
+ const lines = [];
250
+ lines.push(`from playwright.sync_api import Page, expect`);
251
+ lines.push(``);
252
+ lines.push(`class ${className}:`);
253
+ lines.push(` def __init__(self, page: Page):`);
254
+ lines.push(` self.page = page`);
255
+ lines.push(``);
256
+
257
+ const locators = extractLocators(actions);
258
+ locators.forEach(({ name, locator }) => {
259
+ lines.push(` @property`);
260
+ lines.push(` def ${name}(self):`);
261
+ lines.push(` return self.page.locator('${locator}')`);
262
+ lines.push(``);
263
+ });
264
+
265
+ lines.push(` def perform_recorded_flow(self):`);
266
+ lines.push(` """Auto-generated from recorded session"""`);
267
+
268
+ actions.forEach((action, i) => {
269
+ const code = actionToPlaywrightPython(action);
270
+ if (code) {
271
+ lines.push(` # Step ${i + 1}: ${actionToStep(action, i + 1) || action.type}`);
272
+ code.split("\n").forEach(l => lines.push(` ${l}`));
273
+ }
274
+ });
275
+
276
+ lines.push(``);
277
+ lines.push(`# ── Test ──────────────────────────────────────────────────────`);
278
+ lines.push(`def test_recorded_flow(page: Page):`);
279
+ lines.push(` """Auto-generated test from recording"""`)
280
+ lines.push(` po = ${className}(page)`);
281
+ lines.push(` po.perform_recorded_flow()`);
282
+
283
+ return lines.join("\n");
284
+ }
285
+
286
+ function actionToPlaywrightPython(action) {
287
+ const loc = pwLocator(getActionLocator(action));
288
+ switch (action.type) {
289
+ case "navigate": return `self.page.goto("${action.url}")`;
290
+ case "click": return `self.page.${loc}.click()`;
291
+ case "fill": return `self.page.${loc}.fill("${escStr(action.value)}")`;
292
+ case "select": return `self.page.${loc}.select_option("${escStr(action.value)}")`;
293
+ case "check": return `self.page.${loc}.${action.checked ? "check" : "uncheck"}()`;
294
+ case "press": return `self.page.keyboard.press("${action.key}")`;
295
+ case "hover": return `self.page.${loc}.hover()`;
296
+ case "upload": return `self.page.${loc}.set_input_files("path/to/file") # TODO: specify file`;
297
+ case "submit": return `self.page.${loc}.press("Enter")`;
298
+ default: return null;
299
+ }
300
+ }
301
+
302
+ // ── Playwright TypeScript ─────────────────────────────────────────────────────
303
+
304
+ function playwrightTypeScript(actions, className) {
305
+ const lines = [];
306
+ lines.push(`import { Page, Locator, expect } from '@playwright/test';`);
307
+ lines.push(``);
308
+ lines.push(`export class ${className} {`);
309
+ lines.push(` private page: Page;`);
310
+ lines.push(``);
311
+
312
+ const locators = extractLocators(actions);
313
+ locators.forEach(({ name, locator }) => {
314
+ lines.push(` readonly ${camelCase(name)}: Locator;`);
315
+ });
316
+
317
+ lines.push(``);
318
+ lines.push(` constructor(page: Page) {`);
319
+ lines.push(` this.page = page;`);
320
+ locators.forEach(({ name, locator }) => {
321
+ lines.push(` this.${camelCase(name)} = page.locator('${locator}');`);
322
+ });
323
+ lines.push(` }`);
324
+ lines.push(``);
325
+ lines.push(` /** Auto-generated from recorded session */`);
326
+ lines.push(` async performRecordedFlow(): Promise<void> {`);
327
+
328
+ actions.forEach((action, i) => {
329
+ const code = actionToPlaywrightTS(action, locators);
330
+ if (code) {
331
+ lines.push(` // Step ${i + 1}: ${actionToStep(action, i + 1) || action.type}`);
332
+ lines.push(` ${code}`);
333
+ }
334
+ });
335
+
336
+ lines.push(` }`);
337
+ lines.push(`}`);
338
+ lines.push(``);
339
+ lines.push(`// ── Test ──────────────────────────────────────────────────────`);
340
+ lines.push(`import { test } from '@playwright/test';`);
341
+ lines.push(``);
342
+ lines.push(`test('recorded flow', async ({ page }) => {`);
343
+ lines.push(` const po = new ${className}(page);`);
344
+ lines.push(` await po.performRecordedFlow();`);
345
+ lines.push(`});`);
346
+
347
+ return lines.join("\n");
348
+ }
349
+
350
+ function actionToPlaywrightTS(action, locators) {
351
+ const locator = getActionLocator(action);
352
+ const locInfo = findLocatorInfo(locator, locators);
353
+ const locRef = locInfo ? `this.${camelCase(locInfo.name)}` : `this.page.locator('${locator}')`;
354
+
355
+ switch (action.type) {
356
+ case "navigate": return `await this.page.goto('${action.url}');`;
357
+ case "click": return `await ${locRef}.click();`;
358
+ case "fill": return `await ${locRef}.fill('${escStr(action.value)}');`;
359
+ case "select": return `await ${locRef}.selectOption('${escStr(action.value)}');`;
360
+ case "check": return `await ${locRef}.${action.checked ? "check" : "uncheck"}();`;
361
+ case "press": return `await this.page.keyboard.press('${action.key}');`;
362
+ case "hover": return `await ${locRef}.hover();`;
363
+ case "upload": return `await ${locRef}.setInputFiles('path/to/file'); // TODO: specify file`;
364
+ case "submit": return `await ${locRef}.press('Enter');`;
365
+ default: return null;
366
+ }
367
+ }
368
+
369
+ // ─── Locator helpers ──────────────────────────────────────────────────────────
370
+
371
+ function extractLocators(actions) {
372
+ const seen = new Map();
373
+ const locators = [];
374
+
375
+ for (const action of actions) {
376
+ const locator = getActionLocator(action);
377
+ if (!locator || action.type === "navigate" || action.type === "press" || action.type === "dialog") continue;
378
+ if (seen.has(locator)) continue;
379
+ seen.set(locator, true);
380
+
381
+ const name = locatorToVarName(locator);
382
+ const { strategy, value } = parseLocatorForSelenium(locator);
383
+
384
+ locators.push({ name, locator, strategy, value });
385
+ }
386
+
387
+ return locators;
388
+ }
389
+
390
+ function locatorToVarName(locator) {
391
+ if (!locator) return "element";
392
+ return locator
393
+ .replace(/[#\[\]"'=.*:()>+~@-]/g, "_")
394
+ .replace(/^_+|_+$/g, "")
395
+ .replace(/_+/g, "_")
396
+ .replace(/^(\d)/, "_$1")
397
+ .toLowerCase()
398
+ .slice(0, 40) || "element";
399
+ }
400
+
401
+ function parseLocatorForSelenium(locator) {
402
+ if (!locator) return { strategy: "CSS_SELECTOR", value: "*" };
403
+ if (locator.startsWith("#")) return { strategy: "ID", value: locator.slice(1) };
404
+ if (locator.startsWith("[name=")) {
405
+ const m = locator.match(/\[name="?([^"\]]+)"?\]/);
406
+ return { strategy: "NAME", value: m?.[1] || locator };
407
+ }
408
+ if (locator.startsWith("[data-testid=")) {
409
+ const m = locator.match(/\[data-testid="?([^"\]]+)"?\]/);
410
+ return { strategy: "CSS_SELECTOR", value: `[data-testid="${m?.[1] || ""}"]` };
411
+ }
412
+ if (locator.includes(":has-text(")) {
413
+ const m = locator.match(/:has-text\("([^"]+)"\)/);
414
+ if (m) return { strategy: "XPATH", value: `//*[contains(text(), '${m[1]}')]` };
415
+ }
416
+ if (locator.startsWith("[aria-label=")) {
417
+ return { strategy: "XPATH", value: `//*[@aria-label="${locator.match(/\[aria-label="([^"]+)"/)?.[1] || ""}"]` };
418
+ }
419
+ return { strategy: "CSS_SELECTOR", value: locator };
420
+ }
421
+
422
+ function pwLocator(locator) {
423
+ if (!locator) return `locator("*")`;
424
+ if (locator.startsWith("#")) return `locator("${locator}")`;
425
+ if (locator.includes(":has-text(")) return `locator("${locator}")`;
426
+ if (locator.startsWith("[aria-label=")) {
427
+ const m = locator.match(/\[aria-label="([^"]+)"/);
428
+ return m ? `get_by_label("${m[1]}")` : `locator("${locator}")`;
429
+ }
430
+ return `locator("${locator}")`;
431
+ }
432
+
433
+ function findLocatorInfo(locator, locators) {
434
+ return locators.find(l => l.locator === locator) || null;
435
+ }
436
+
437
+ function javaStrategy(strategy) {
438
+ const map = { ID: "id", NAME: "name", CSS_SELECTOR: "cssSelector", XPATH: "xpath" };
439
+ return map[strategy] || "cssSelector";
440
+ }
441
+
442
+ function inferPageType(actions) {
443
+ const urls = actions.filter(a => a.type === "navigate").map(a => a.url || "");
444
+ const combined = urls.join(" ").toLowerCase();
445
+ if (/login|signin/.test(combined)) return "login";
446
+ if (/checkout|payment/.test(combined)) return "checkout";
447
+ if (/register|signup/.test(combined)) return "registration";
448
+ if (/search|results/.test(combined)) return "search";
449
+ if (/dashboard/.test(combined)) return "dashboard";
450
+ if (/refund|return/.test(combined)) return "refund";
451
+ if (/admin|manage/.test(combined)) return "admin";
452
+ return "page";
453
+ }
454
+
455
+ function actionsToJourneySegments(actions = []) {
456
+ const segments = [];
457
+ let current = null;
458
+
459
+ for (const action of actions || []) {
460
+ if (action.type === "navigate") {
461
+ if (current?.actions?.length) segments.push(finalizeJourneySegment(current, segments.length));
462
+ current = {
463
+ url: action.url || "about:blank",
464
+ actions: [action],
465
+ };
466
+ continue;
467
+ }
468
+
469
+ if (!current) {
470
+ current = {
471
+ url: action.url || "about:blank",
472
+ actions: [],
473
+ };
474
+ }
475
+
476
+ current.actions.push(action);
477
+ }
478
+
479
+ if (current?.actions?.length) segments.push(finalizeJourneySegment(current, segments.length));
480
+ return segments;
481
+ }
482
+
483
+ function finalizeJourneySegment(segment, index) {
484
+ const url = segment.url || segment.actions.find((action) => action.type === "navigate")?.url || "about:blank";
485
+ const path = getPathFromUrl(url);
486
+ const recordedSteps = actionsToSteps(segment.actions).map((step) => step.text);
487
+ const pageType = inferPageType([{ type: "navigate", url }, ...segment.actions]);
488
+
489
+ return {
490
+ id: `segment_${index + 1}`,
491
+ order: index + 1,
492
+ title: buildJourneySegmentTitle(url, path, index + 1),
493
+ url,
494
+ path,
495
+ pageType,
496
+ source: "recording",
497
+ actions: segment.actions,
498
+ recordedSteps,
499
+ transitionStatus: index === 0 ? "start" : "recorded",
500
+ };
501
+ }
502
+
503
+ function buildJourneySegmentTitle(url, path, order) {
504
+ const cleanPath = (path || "").replace(/\/+$/, "");
505
+ const lastPart = cleanPath.split("/").filter(Boolean).pop();
506
+ if (lastPart) {
507
+ return lastPart
508
+ .replace(/[-_]+/g, " ")
509
+ .replace(/\b\w/g, (char) => char.toUpperCase());
510
+ }
511
+
512
+ try {
513
+ return new URL(url).hostname;
514
+ } catch (_) {
515
+ return `Step ${order}`;
516
+ }
517
+ }
518
+
519
+ function getPathFromUrl(url) {
520
+ try {
521
+ return new URL(url).pathname || "/";
522
+ } catch (_) {
523
+ return "/";
524
+ }
525
+ }
526
+
527
+ function camelCase(str) {
528
+ return str.replace(/_([a-z])/g, (_, l) => l.toUpperCase());
529
+ }
530
+
531
+ function sanitize(str) {
532
+ return str.replace(/[^a-zA-Z0-9]/g, "").slice(0, 12) || "El";
533
+ }
534
+
535
+ function escStr(s) {
536
+ return (s || "").replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/'/g, "\\'");
537
+ }
538
+
539
+ module.exports = { actionsToSteps, actionsToCode, actionToStep, actionsToJourneySegments };