pw-automation-framework 2.0.1

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.
Files changed (111) hide show
  1. package/README.md +93 -0
  2. package/bin/lexxit-automation-framework.js +427 -0
  3. package/dist/app.d.ts +2 -0
  4. package/dist/app.js +26 -0
  5. package/dist/app.js.map +1 -0
  6. package/dist/controllers/controller.d.ts +57 -0
  7. package/dist/controllers/controller.js +263 -0
  8. package/dist/controllers/controller.js.map +1 -0
  9. package/dist/core/BrowserManager.d.ts +46 -0
  10. package/dist/core/BrowserManager.js +377 -0
  11. package/dist/core/BrowserManager.js.map +1 -0
  12. package/dist/core/PlaywrightEngine.d.ts +16 -0
  13. package/dist/core/PlaywrightEngine.js +246 -0
  14. package/dist/core/PlaywrightEngine.js.map +1 -0
  15. package/dist/core/ScreenshotManager.d.ts +10 -0
  16. package/dist/core/ScreenshotManager.js +28 -0
  17. package/dist/core/ScreenshotManager.js.map +1 -0
  18. package/dist/core/TestData.d.ts +12 -0
  19. package/dist/core/TestData.js +29 -0
  20. package/dist/core/TestData.js.map +1 -0
  21. package/dist/core/TestExecutor.d.ts +16 -0
  22. package/dist/core/TestExecutor.js +355 -0
  23. package/dist/core/TestExecutor.js.map +1 -0
  24. package/dist/core/handlers/AllHandlers.d.ts +116 -0
  25. package/dist/core/handlers/AllHandlers.js +648 -0
  26. package/dist/core/handlers/AllHandlers.js.map +1 -0
  27. package/dist/core/handlers/BaseHandler.d.ts +16 -0
  28. package/dist/core/handlers/BaseHandler.js +27 -0
  29. package/dist/core/handlers/BaseHandler.js.map +1 -0
  30. package/dist/core/handlers/ClickHandler.d.ts +34 -0
  31. package/dist/core/handlers/ClickHandler.js +359 -0
  32. package/dist/core/handlers/ClickHandler.js.map +1 -0
  33. package/dist/core/handlers/CustomCodeHandler.d.ts +35 -0
  34. package/dist/core/handlers/CustomCodeHandler.js +102 -0
  35. package/dist/core/handlers/CustomCodeHandler.js.map +1 -0
  36. package/dist/core/handlers/DropdownHandler.d.ts +43 -0
  37. package/dist/core/handlers/DropdownHandler.js +304 -0
  38. package/dist/core/handlers/DropdownHandler.js.map +1 -0
  39. package/dist/core/handlers/InputHandler.d.ts +24 -0
  40. package/dist/core/handlers/InputHandler.js +197 -0
  41. package/dist/core/handlers/InputHandler.js.map +1 -0
  42. package/dist/core/registry/ActionRegistry.d.ts +8 -0
  43. package/dist/core/registry/ActionRegistry.js +35 -0
  44. package/dist/core/registry/ActionRegistry.js.map +1 -0
  45. package/dist/installer/frameworkLauncher.d.ts +31 -0
  46. package/dist/installer/frameworkLauncher.js +198 -0
  47. package/dist/installer/frameworkLauncher.js.map +1 -0
  48. package/dist/queue/ExecutionQueue.d.ts +52 -0
  49. package/dist/queue/ExecutionQueue.js +175 -0
  50. package/dist/queue/ExecutionQueue.js.map +1 -0
  51. package/dist/routes/api.routes.d.ts +2 -0
  52. package/dist/routes/api.routes.js +16 -0
  53. package/dist/routes/api.routes.js.map +1 -0
  54. package/dist/server.d.ts +1 -0
  55. package/dist/server.js +30 -0
  56. package/dist/server.js.map +1 -0
  57. package/dist/types/types.d.ts +135 -0
  58. package/dist/types/types.js +4 -0
  59. package/dist/types/types.js.map +1 -0
  60. package/dist/utils/elementHighlight.d.ts +35 -0
  61. package/dist/utils/elementHighlight.js +136 -0
  62. package/dist/utils/elementHighlight.js.map +1 -0
  63. package/dist/utils/locatorHelper.d.ts +7 -0
  64. package/dist/utils/locatorHelper.js +53 -0
  65. package/dist/utils/locatorHelper.js.map +1 -0
  66. package/dist/utils/logger.d.ts +12 -0
  67. package/dist/utils/logger.js +35 -0
  68. package/dist/utils/logger.js.map +1 -0
  69. package/dist/utils/response.d.ts +4 -0
  70. package/dist/utils/response.js +25 -0
  71. package/dist/utils/response.js.map +1 -0
  72. package/dist/utils/responseFormatter.d.ts +78 -0
  73. package/dist/utils/responseFormatter.js +123 -0
  74. package/dist/utils/responseFormatter.js.map +1 -0
  75. package/dist/utils/sseManager.d.ts +32 -0
  76. package/dist/utils/sseManager.js +122 -0
  77. package/dist/utils/sseManager.js.map +1 -0
  78. package/lexxit-automation-framework-2.0.0.tgz +0 -0
  79. package/npmignore +5 -0
  80. package/package.json +36 -0
  81. package/scripts/postinstall.js +52 -0
  82. package/src/app.ts +27 -0
  83. package/src/controllers/controller.ts +282 -0
  84. package/src/core/BrowserManager.ts +398 -0
  85. package/src/core/PlaywrightEngine.ts +371 -0
  86. package/src/core/ScreenshotManager.ts +25 -0
  87. package/src/core/TestData.ts +25 -0
  88. package/src/core/TestExecutor.ts +436 -0
  89. package/src/core/handlers/AllHandlers.ts +626 -0
  90. package/src/core/handlers/BaseHandler.ts +41 -0
  91. package/src/core/handlers/ClickHandler.ts +482 -0
  92. package/src/core/handlers/CustomCodeHandler.ts +123 -0
  93. package/src/core/handlers/DropdownHandler.ts +438 -0
  94. package/src/core/handlers/InputHandler.ts +192 -0
  95. package/src/core/registry/ActionRegistry.ts +31 -0
  96. package/src/installer/frameworkLauncher.ts +242 -0
  97. package/src/installer/install.sh +107 -0
  98. package/src/public/dashboard.html +540 -0
  99. package/src/public/queue-monitor.html +190 -0
  100. package/src/queue/ExecutionQueue.ts +200 -0
  101. package/src/routes/api.routes.ts +16 -0
  102. package/src/server.ts +29 -0
  103. package/src/types/types.ts +169 -0
  104. package/src/utils/elementHighlight.ts +174 -0
  105. package/src/utils/locatorHelper.ts +49 -0
  106. package/src/utils/logger.ts +40 -0
  107. package/src/utils/response.ts +27 -0
  108. package/src/utils/responseFormatter.ts +167 -0
  109. package/src/utils/sseManager.ts +127 -0
  110. package/tsconfig.json +18 -0
  111. package/videos/fb1b94b6-6639-4c9a-82bb-63572606f403/page@5bd5c6c8b62baa700e9810cdd64f5c49.webm +0 -0
@@ -0,0 +1,174 @@
1
+ import { Page } from "playwright";
2
+
3
+ /**
4
+ * elementHighlight.ts
5
+ * ===================
6
+ * Injects a visual highlight into the browser window BEFORE the framework
7
+ * interacts with an element. This lets the user watching the automation
8
+ * clearly see which field is being targeted.
9
+ *
10
+ * Visual effects:
11
+ * • Glowing border (blue for input/click, orange for verify/assertion)
12
+ * • Ripple pulse animation
13
+ * • Small floating label showing the step action + value
14
+ * • Auto-removes after the interaction completes (or on timeout)
15
+ *
16
+ * Usage (in handlers):
17
+ * await highlightElement(this.page, locatorString, 'input', 'Ravi');
18
+ * // ... do the actual interaction ...
19
+ * await removeHighlight(this.page);
20
+ */
21
+
22
+ type HighlightType = "input" | "click" | "verify" | "select" | "checkbox";
23
+
24
+ const COLORS: Record<HighlightType, { border: string; bg: string; label: string }> = {
25
+ input: { border: "#3b82f6", bg: "rgba(59,130,246,0.08)", label: "#3b82f6" },
26
+ click: { border: "#22c55e", bg: "rgba(34,197,94,0.08)", label: "#22c55e" },
27
+ verify: { border: "#a855f7", bg: "rgba(168,85,247,0.08)", label: "#a855f7" },
28
+ select: { border: "#f59e0b", bg: "rgba(245,158,11,0.08)", label: "#f59e0b" },
29
+ checkbox: { border: "#ec4899", bg: "rgba(236,72,153,0.08)", label: "#ec4899" },
30
+ };
31
+
32
+ /**
33
+ * Highlights an element in the browser before interacting with it.
34
+ *
35
+ * @param page Playwright Page
36
+ * @param locator CSS/XPath locator string (already built)
37
+ * @param type Visual type: input | click | verify | select | checkbox
38
+ * @param labelText Short text shown in the floating label (e.g. the value being entered)
39
+ */
40
+ export async function highlightElement(
41
+ page: Page,
42
+ locator: string,
43
+ type: HighlightType = "input",
44
+ labelText: string = "",
45
+ ): Promise<void> {
46
+ const c = COLORS[type];
47
+
48
+ try {
49
+ await page.evaluate(
50
+ ({ locator, border, bg, labelColor, labelText }) => {
51
+ // ── Find element ──────────────────────────────────────────────────────
52
+ let el: Element | null = null;
53
+
54
+ if (locator.startsWith("//") || locator.startsWith("(//") || locator.startsWith("./")) {
55
+ // XPath
56
+ const result = document.evaluate(locator, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
57
+ el = result.singleNodeValue as Element | null;
58
+ } else {
59
+ // CSS
60
+ try { el = document.querySelector(locator); } catch { /* bad selector */ }
61
+ }
62
+
63
+ if (!el || !(el instanceof HTMLElement)) return;
64
+
65
+ // ── Remove any previous highlight ─────────────────────────────────────
66
+ document.querySelectorAll("[data-pw-highlight]").forEach(n => {
67
+ const orig = (n as HTMLElement).dataset.pwOrigStyle ?? "";
68
+ (n as HTMLElement).style.cssText = orig;
69
+ delete (n as HTMLElement).dataset.pwHighlight;
70
+ delete (n as HTMLElement).dataset.pwOrigStyle;
71
+ });
72
+ document.querySelectorAll("#pw-overlay,#pw-label,#pw-ripple-style").forEach(n => n.remove());
73
+
74
+ // ── Store original style ───────────────────────────────────────────────
75
+ el.dataset.pwHighlight = "1";
76
+ el.dataset.pwOrigStyle = el.style.cssText;
77
+
78
+ // ── Apply highlight styles ─────────────────────────────────────────────
79
+ el.style.outline = `2px solid ${border}`;
80
+ el.style.outlineOffset = "2px";
81
+ el.style.boxShadow = `0 0 0 4px ${bg}, 0 0 16px ${border}55`;
82
+ el.style.transition = "outline .15s, box-shadow .15s";
83
+ el.style.zIndex = "9999";
84
+
85
+ // ── Scroll element into view (smooth) ─────────────────────────────────
86
+ el.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
87
+
88
+ // ── Ripple pulse animation ─────────────────────────────────────────────
89
+ const style = document.createElement("style");
90
+ style.id = "pw-ripple-style";
91
+ style.textContent = `
92
+ @keyframes pwRipple {
93
+ 0% { box-shadow: 0 0 0 0 ${border}66, 0 0 0 0 ${border}33; }
94
+ 50% { box-shadow: 0 0 0 6px ${border}33, 0 0 0 12px ${border}11; }
95
+ 100% { box-shadow: 0 0 0 0 ${border}00, 0 0 0 0 ${border}00; }
96
+ }
97
+ `;
98
+ document.head.appendChild(style);
99
+ el.style.animation = "pwRipple .7s ease-out";
100
+
101
+ // ── Floating label ─────────────────────────────────────────────────────
102
+ if (labelText) {
103
+ const rect = el.getBoundingClientRect();
104
+ const label = document.createElement("div");
105
+ label.id = "pw-label";
106
+
107
+ const scrollX = window.scrollX || document.documentElement.scrollLeft;
108
+ const scrollY = window.scrollY || document.documentElement.scrollTop;
109
+
110
+ // Position above the element; clamp to viewport
111
+ const top = Math.max(scrollY + rect.top - 36, scrollY + 4);
112
+ const left = Math.min(
113
+ Math.max(scrollX + rect.left, scrollX + 4),
114
+ scrollX + document.documentElement.clientWidth - 240,
115
+ );
116
+
117
+ Object.assign(label.style, {
118
+ position: "absolute",
119
+ top: `${top}px`,
120
+ left: `${left}px`,
121
+ zIndex: "2147483647",
122
+ background: "#0a0d14",
123
+ color: labelColor,
124
+ border: `1px solid ${border}`,
125
+ borderRadius: "6px",
126
+ padding: "3px 10px",
127
+ fontSize: "12px",
128
+ fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif",
129
+ fontWeight: "600",
130
+ pointerEvents:"none",
131
+ whiteSpace: "nowrap",
132
+ maxWidth: "260px",
133
+ overflow: "hidden",
134
+ textOverflow: "ellipsis",
135
+ boxShadow: "0 4px 14px rgba(0,0,0,.5)",
136
+ animation: "pwFadeIn .15s ease",
137
+ });
138
+
139
+ const styleTag = document.createElement("style");
140
+ styleTag.textContent = "@keyframes pwFadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}";
141
+ document.head.appendChild(styleTag);
142
+
143
+ label.textContent = `⚡ ${labelText.length > 40 ? labelText.slice(0, 37) + "…" : labelText}`;
144
+ document.body.appendChild(label);
145
+ }
146
+ },
147
+ { locator, border: c.border, bg: c.bg, labelColor: c.label, labelText },
148
+ );
149
+ } catch {
150
+ // Highlight is purely cosmetic — never throw
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Removes the visual highlight from the page.
156
+ * Called after the interaction completes (or on failure).
157
+ */
158
+ export async function removeHighlight(page: Page): Promise<void> {
159
+ try {
160
+ await page.evaluate(() => {
161
+ document.querySelectorAll("[data-pw-highlight]").forEach(n => {
162
+ const el = n as HTMLElement;
163
+ el.style.cssText = el.dataset.pwOrigStyle ?? "";
164
+ el.style.animation = "";
165
+ delete el.dataset.pwHighlight;
166
+ delete el.dataset.pwOrigStyle;
167
+ });
168
+ document.querySelectorAll("#pw-overlay,#pw-label,#pw-ripple-style").forEach(n => n.remove());
169
+ document.querySelectorAll("style[textContent*='pwFadeIn'],style[textContent*='pwRipple']").forEach(n => n.remove());
170
+ });
171
+ } catch {
172
+ // Cosmetic — never throw
173
+ }
174
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * buildLocatorString
3
+ * ==================
4
+ * Converts strategy + value → Playwright selector string.
5
+ * Supports all standard and Playwright-native strategies.
6
+ */
7
+ export function buildLocatorString(strategy: string, value: string): string {
8
+ switch (strategy.toLowerCase().replace(/[_\s-]/g, "")) {
9
+ case "xpath":
10
+ return `xpath=${value}`;
11
+ case "css":
12
+ case "cssselector":
13
+ return value;
14
+ case "id":
15
+ return `#${value}`;
16
+ case "name":
17
+ return `[name="${value}"]`;
18
+ case "placeholder":
19
+ return `[placeholder="${value}"]`;
20
+ case "text":
21
+ case "linktext":
22
+ return `text=${value}`;
23
+ case "partiallinktext":
24
+ case "partialtext":
25
+ return `text=${value}`;
26
+ case "classname":
27
+ case "class":
28
+ return `.${value}`;
29
+ case "tagname":
30
+ case "tag":
31
+ return value;
32
+ case "testid":
33
+ case "datatestid":
34
+ return `[data-testid="${value}"]`;
35
+ case "label":
36
+ return `label=${value}`;
37
+ case "arialabel":
38
+ return `[aria-label="${value}"]`;
39
+ case "role":
40
+ return `[role="${value}"]`;
41
+ case "title":
42
+ return `[title="${value}"]`;
43
+ case "value":
44
+ return `[value="${value}"]`;
45
+ default:
46
+ console.warn(`[WARN] Unknown strategy "${strategy}", treating as CSS`);
47
+ return value;
48
+ }
49
+ }
@@ -0,0 +1,40 @@
1
+ import { LogLevel, LogEntry } from "../types/types";
2
+
3
+ const COLORS: Record<LogLevel, string> = {
4
+ DEBUG: "\x1b[36m", // cyan
5
+ INFO: "\x1b[32m", // green
6
+ WARN: "\x1b[33m", // yellow
7
+ ERROR: "\x1b[31m", // red
8
+ };
9
+ const RESET = "\x1b[0m";
10
+
11
+ export class Logger {
12
+ private executionId: string;
13
+ private context: string;
14
+
15
+ constructor(context: string, executionId: string = "global") {
16
+ this.context = context;
17
+ this.executionId = executionId;
18
+ }
19
+
20
+ private log(level: LogLevel, message: string, extra?: Record<string, any>): void {
21
+ const ts = new Date().toISOString();
22
+ const color = COLORS[level];
23
+ const tag = `[${level}][${this.context}]`;
24
+ const extra_str = extra ? ` ${JSON.stringify(extra)}` : "";
25
+ console.log(`${color}${ts} ${tag}${RESET} ${message}${extra_str}`);
26
+ }
27
+
28
+ debug(msg: string, ctx?: Record<string, any>) { this.log("DEBUG", msg, ctx); }
29
+ info (msg: string, ctx?: Record<string, any>) { this.log("INFO", msg, ctx); }
30
+ warn (msg: string, ctx?: Record<string, any>) { this.log("WARN", msg, ctx); }
31
+ error(msg: string, ctx?: Record<string, any>) { this.log("ERROR", msg, ctx); }
32
+
33
+ child(context: string): Logger {
34
+ return new Logger(`${this.context}:${context}`, this.executionId);
35
+ }
36
+
37
+ static create(context: string, executionId?: string): Logger {
38
+ return new Logger(context, executionId);
39
+ }
40
+ }
@@ -0,0 +1,27 @@
1
+ import { ActionResponse } from "../types/types";
2
+
3
+ export function pass(comments: string, screenshot?: string, data?: any): ActionResponse {
4
+ return { status: "Pass", comments, screenshot, data };
5
+ }
6
+
7
+ export function fail(comments: string, errorDetail?: string, screenshot?: string, data?: any): ActionResponse {
8
+ const message = errorDetail ? `${comments} — ${errorDetail}` : comments;
9
+ return { status: "Fail", comments: message, screenshot, data };
10
+ }
11
+ export function failElementNotFound(
12
+ displayLabel: string,
13
+ screenshot?: string,
14
+ extraData?: Record<string, any>,
15
+ ): ActionResponse {
16
+ return {
17
+ status: "Fail",
18
+ comments: `"${displayLabel}" not found`,
19
+ screenshot,
20
+ data: {
21
+ expected_result: `"${displayLabel}" found and interacted with`,
22
+ failure_type: "ELEMENT_NOT_FOUND",
23
+ ...extraData,
24
+ },
25
+ };
26
+ }
27
+
@@ -0,0 +1,167 @@
1
+ /**
2
+ * responseFormatter.ts
3
+ * ====================
4
+ * Converts internal framework types → the standard API response format
5
+ * that matches what the main application expects.
6
+ *
7
+ * Standard format example:
8
+ * {
9
+ * "status": "fail",
10
+ * "results": [
11
+ * {
12
+ * "test_script_uid": "...",
13
+ * "app_id": "...",
14
+ * "status": "fail",
15
+ * "results": [
16
+ * {
17
+ * "uid": null,
18
+ * "obj_uid": "...",
19
+ * "step_name": "...",
20
+ * "page_uid": null,
21
+ * "comments": "...",
22
+ * "screenshot": "base64...",
23
+ * "duration": "8 seconds",
24
+ * "start_time": "2026-04-04 20:25:34",
25
+ * "expected_result": "...",
26
+ * "status": "pass"
27
+ * }
28
+ * ],
29
+ * "duration": "42 seconds",
30
+ * "script_start_time": "2026-04-04 20:25:34"
31
+ * }
32
+ * ]
33
+ * }
34
+ */
35
+
36
+ import { ExecutionResult, ScriptResult, StepResult } from "../types/types";
37
+ // import { ActionResponse } from "../types/types";
38
+
39
+ // ─── Formatted types (what the main app expects) ──────────────────────────────
40
+
41
+ export interface FormattedStepResult {
42
+ uid: string | null;
43
+ obj_uid: string | null;
44
+ step_name: string;
45
+ step_script?: string;
46
+ page_uid: string | null;
47
+ comments: string;
48
+ screenshot?: string;
49
+ duration: string; // e.g. "8 seconds"
50
+ start_time: string; // e.g. "2026-04-04 20:25:34"
51
+ expected_result: string;
52
+ status: "pass" | "fail" | "skip";
53
+ comparison_code?: any;
54
+ }
55
+
56
+ export interface FormattedScriptResult {
57
+ test_script_uid: string;
58
+ app_id: string | undefined;
59
+ status: "pass" | "fail";
60
+ results: FormattedStepResult[];
61
+ duration: string; // e.g. "42 seconds"
62
+ script_start_time: string; // e.g. "2026-04-04 20:25:34"
63
+ comparison_code?: any;
64
+ }
65
+
66
+ export interface FormattedExecutionResponse {
67
+ status: "pass" | "fail";
68
+ executionId: string;
69
+ results: FormattedScriptResult[];
70
+ // Extra metadata (can be ignored by main app)
71
+ total_scripts: number;
72
+ passed_scripts: number;
73
+ failed_scripts: number;
74
+ total_duration: string;
75
+ }
76
+
77
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
78
+
79
+ /**
80
+ * Format milliseconds → "X seconds" or "less than 1 second"
81
+ */
82
+ export function formatDuration(ms: number): string {
83
+ if (ms < 1000) return "less than 1 second";
84
+ const secs = Math.round(ms / 1000);
85
+ return `${secs} second${secs !== 1 ? "s" : ""}`;
86
+ }
87
+
88
+ /**
89
+ * Format ISO timestamp → "YYYY-MM-DD HH:MM:SS"
90
+ */
91
+ export function formatDateTime(isoString: string): string {
92
+ try {
93
+ const d = new Date(isoString);
94
+ const pad = (n: number) => String(n).padStart(2, "0");
95
+ return (
96
+ `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
97
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
98
+ );
99
+ } catch {
100
+ return isoString;
101
+ }
102
+ }
103
+
104
+
105
+ /**
106
+ * Map internal StepStatus to lowercase API status
107
+ */
108
+ function mapStepStatus(status: string): "pass" | "fail" | "skip" {
109
+ switch (status?.toUpperCase()) {
110
+ case "PASS": return "pass";
111
+ case "FAIL": return "fail";
112
+ case "SKIP":
113
+ case "RUNNING":
114
+ case "PENDING": return "skip";
115
+ default: return "skip";
116
+ }
117
+ }
118
+
119
+ // ─── Formatters ──────────────────────────────────────────────────────────────
120
+
121
+ export function formatStepResult(step: StepResult): FormattedStepResult {
122
+ return {
123
+ uid: step.uid ?? null,
124
+ obj_uid: step.obj_uid ?? null,
125
+ step_name: step.step_name,
126
+ step_script: step.step_script,
127
+ page_uid: step.page_uid ?? null,
128
+ comments: step.skip_reason
129
+ ? `Skipped — ${step.skip_reason}`
130
+ : (step.comments || ""),
131
+ screenshot: step.screenshot,
132
+ duration: formatDuration(step.duration_ms),
133
+ start_time: formatDateTime(step.start_time),
134
+ expected_result: step.expected_result || "",
135
+ status: mapStepStatus(step.status),
136
+ // comparison_code: step.comparison_code,
137
+ };
138
+ }
139
+
140
+ export function formatScriptResult(script: ScriptResult): FormattedScriptResult {
141
+ const failedStep = script.step_results.find(s => s.comparison_code); // ← this line
142
+ return {
143
+ test_script_uid: script.test_script_uid,
144
+ app_id: script.app_id,
145
+ status: script.overall_status === "PASS" ? "pass" : "fail",
146
+ results: script.step_results.map(formatStepResult),
147
+ duration: formatDuration(script.duration_ms),
148
+ script_start_time: formatDateTime(script.start_time),
149
+ comparison_code: failedStep?.comparison_code,
150
+
151
+ };
152
+ }
153
+
154
+ export function formatExecutionResult(result: ExecutionResult): FormattedExecutionResponse {
155
+ const formattedResults = result.results.map(formatScriptResult);
156
+ const overallPass = formattedResults.every(r => r.status === "pass");
157
+
158
+ return {
159
+ status: overallPass ? "pass" : "fail",
160
+ executionId: result.executionId,
161
+ results: formattedResults,
162
+ total_scripts: result.total_scripts,
163
+ passed_scripts: result.passed,
164
+ failed_scripts: result.failed,
165
+ total_duration: formatDuration(result.duration_ms),
166
+ };
167
+ }
@@ -0,0 +1,127 @@
1
+ import { Response } from "express";
2
+ import { SSEEvent, SSEEventType } from "../types/types";
3
+ import { Logger } from "./logger";
4
+
5
+ const log = Logger.create("SSEManager");
6
+
7
+ /**
8
+ * SSEManager — v2 with Event Replay Buffer
9
+ * =========================================
10
+ * ROOT CAUSE FIX:
11
+ * The dashboard opens AFTER POST /api/run-test returns. Since execution is
12
+ * now non-blocking, it often completes before the browser even renders the
13
+ * dashboard page and the SSE connection is established. This means the
14
+ * dashboard misses all events and shows "Waiting for execution to begin..."
15
+ * even though the run is already done.
16
+ *
17
+ * FIX: Store every emitted event in a per-execution replay buffer.
18
+ * When a new SSE client connects, immediately replay all past events.
19
+ * This way late-connecting dashboards see the full execution history.
20
+ */
21
+ export class SSEManager {
22
+ private clients: Map<string, Set<Response>> = new Map();
23
+ // ✅ Replay buffer — stores all events per execution
24
+ private eventBuffer: Map<string, SSEEvent[]> = new Map();
25
+ // Max events to buffer per execution (prevents memory leak)
26
+ private readonly MAX_BUFFER = 500;
27
+ // How long to keep buffer after execution completes (ms)
28
+ private readonly BUFFER_TTL = 2 * 60 * 60 * 1000; // 2 hours
29
+
30
+ addClient(executionId: string, res: Response): void {
31
+ res.setHeader("Content-Type", "text/event-stream");
32
+ res.setHeader("Cache-Control", "no-cache");
33
+ res.setHeader("Connection", "keep-alive");
34
+ res.setHeader("X-Accel-Buffering", "no");
35
+ res.flushHeaders();
36
+
37
+ if (!this.clients.has(executionId)) this.clients.set(executionId, new Set());
38
+ this.clients.get(executionId)!.add(res);
39
+
40
+ log.info(`SSE client connected: ${executionId}`);
41
+
42
+ // ✅ REPLAY: Send all past events immediately to this late-joining client
43
+ const pastEvents = this.eventBuffer.get(executionId) ?? [];
44
+ if (pastEvents.length > 0) {
45
+ log.info(`Replaying ${pastEvents.length} past events to late client: ${executionId}`);
46
+ pastEvents.forEach((ev) => this.sendToOne(res, ev));
47
+ } else {
48
+ // Only send connect message if no replay (otherwise connect msg mid-replay is confusing)
49
+ this.sendToOne(res, {
50
+ type: "log", executionId,
51
+ timestamp: new Date().toISOString(),
52
+ data: { message: "Connected to execution stream" },
53
+ });
54
+ }
55
+
56
+ // Heartbeat
57
+ const hb = setInterval(() => {
58
+ if (!res.writableEnded) res.write(": heartbeat\n\n");
59
+ else clearInterval(hb);
60
+ }, 20000);
61
+
62
+ res.on("close", () => {
63
+ clearInterval(hb);
64
+ this.removeClient(executionId, res);
65
+ });
66
+ }
67
+
68
+ emit(executionId: string, type: SSEEventType, data: any): void {
69
+ const event: SSEEvent = {
70
+ type, executionId,
71
+ timestamp: new Date().toISOString(),
72
+ data,
73
+ };
74
+
75
+ // ✅ Buffer event for replay
76
+ if (!this.eventBuffer.has(executionId)) {
77
+ this.eventBuffer.set(executionId, []);
78
+ }
79
+ const buf = this.eventBuffer.get(executionId)!;
80
+ if (buf.length < this.MAX_BUFFER) buf.push(event);
81
+
82
+ // Broadcast to all currently connected clients
83
+ const clients = this.clients.get(executionId);
84
+ if (clients && clients.size > 0) {
85
+ clients.forEach((res) => this.sendToOne(res, event));
86
+ }
87
+
88
+ if (type === "execution_complete") {
89
+ // Clean up connections, keep buffer for replay
90
+ setTimeout(() => this.clients.delete(executionId), 5000);
91
+ // Clean up buffer after TTL
92
+ setTimeout(() => this.eventBuffer.delete(executionId), this.BUFFER_TTL);
93
+ }
94
+ }
95
+
96
+ /** Broadcast to ALL connections (e.g. queue updates, global health) */
97
+ broadcast(type: SSEEventType, data: any): void {
98
+ const event: SSEEvent = {
99
+ type, executionId: "global",
100
+ timestamp: new Date().toISOString(),
101
+ data,
102
+ };
103
+ this.clients.forEach((clients) => clients.forEach((res) => this.sendToOne(res, event)));
104
+ }
105
+
106
+ /** Replay buffer for a given executionId (used by status endpoint) */
107
+ getBuffer(executionId: string): SSEEvent[] {
108
+ return this.eventBuffer.get(executionId) ?? [];
109
+ }
110
+
111
+ hasClients(executionId: string): boolean {
112
+ return (this.clients.get(executionId)?.size ?? 0) > 0;
113
+ }
114
+
115
+ private sendToOne(res: Response, event: SSEEvent): void {
116
+ try {
117
+ if (!res.writableEnded) res.write(`data: ${JSON.stringify(event)}\n\n`);
118
+ } catch { /* client gone */ }
119
+ }
120
+
121
+ private removeClient(executionId: string, res: Response): void {
122
+ this.clients.get(executionId)?.delete(res);
123
+ if (this.clients.get(executionId)?.size === 0) this.clients.delete(executionId);
124
+ }
125
+ }
126
+
127
+ export const sseManager = new SSEManager();
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "commonjs",
5
+ "lib": ["ES2021", "DOM"],
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "resolveJsonModule": true,
13
+ "declaration": true,
14
+ "sourceMap": true
15
+ },
16
+ "include": ["src/**/*"],
17
+ "exclude": ["node_modules", "dist"]
18
+ }