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.
- package/README.md +93 -0
- package/bin/lexxit-automation-framework.js +427 -0
- package/dist/app.d.ts +2 -0
- package/dist/app.js +26 -0
- package/dist/app.js.map +1 -0
- package/dist/controllers/controller.d.ts +57 -0
- package/dist/controllers/controller.js +263 -0
- package/dist/controllers/controller.js.map +1 -0
- package/dist/core/BrowserManager.d.ts +46 -0
- package/dist/core/BrowserManager.js +377 -0
- package/dist/core/BrowserManager.js.map +1 -0
- package/dist/core/PlaywrightEngine.d.ts +16 -0
- package/dist/core/PlaywrightEngine.js +246 -0
- package/dist/core/PlaywrightEngine.js.map +1 -0
- package/dist/core/ScreenshotManager.d.ts +10 -0
- package/dist/core/ScreenshotManager.js +28 -0
- package/dist/core/ScreenshotManager.js.map +1 -0
- package/dist/core/TestData.d.ts +12 -0
- package/dist/core/TestData.js +29 -0
- package/dist/core/TestData.js.map +1 -0
- package/dist/core/TestExecutor.d.ts +16 -0
- package/dist/core/TestExecutor.js +355 -0
- package/dist/core/TestExecutor.js.map +1 -0
- package/dist/core/handlers/AllHandlers.d.ts +116 -0
- package/dist/core/handlers/AllHandlers.js +648 -0
- package/dist/core/handlers/AllHandlers.js.map +1 -0
- package/dist/core/handlers/BaseHandler.d.ts +16 -0
- package/dist/core/handlers/BaseHandler.js +27 -0
- package/dist/core/handlers/BaseHandler.js.map +1 -0
- package/dist/core/handlers/ClickHandler.d.ts +34 -0
- package/dist/core/handlers/ClickHandler.js +359 -0
- package/dist/core/handlers/ClickHandler.js.map +1 -0
- package/dist/core/handlers/CustomCodeHandler.d.ts +35 -0
- package/dist/core/handlers/CustomCodeHandler.js +102 -0
- package/dist/core/handlers/CustomCodeHandler.js.map +1 -0
- package/dist/core/handlers/DropdownHandler.d.ts +43 -0
- package/dist/core/handlers/DropdownHandler.js +304 -0
- package/dist/core/handlers/DropdownHandler.js.map +1 -0
- package/dist/core/handlers/InputHandler.d.ts +24 -0
- package/dist/core/handlers/InputHandler.js +197 -0
- package/dist/core/handlers/InputHandler.js.map +1 -0
- package/dist/core/registry/ActionRegistry.d.ts +8 -0
- package/dist/core/registry/ActionRegistry.js +35 -0
- package/dist/core/registry/ActionRegistry.js.map +1 -0
- package/dist/installer/frameworkLauncher.d.ts +31 -0
- package/dist/installer/frameworkLauncher.js +198 -0
- package/dist/installer/frameworkLauncher.js.map +1 -0
- package/dist/queue/ExecutionQueue.d.ts +52 -0
- package/dist/queue/ExecutionQueue.js +175 -0
- package/dist/queue/ExecutionQueue.js.map +1 -0
- package/dist/routes/api.routes.d.ts +2 -0
- package/dist/routes/api.routes.js +16 -0
- package/dist/routes/api.routes.js.map +1 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +30 -0
- package/dist/server.js.map +1 -0
- package/dist/types/types.d.ts +135 -0
- package/dist/types/types.js +4 -0
- package/dist/types/types.js.map +1 -0
- package/dist/utils/elementHighlight.d.ts +35 -0
- package/dist/utils/elementHighlight.js +136 -0
- package/dist/utils/elementHighlight.js.map +1 -0
- package/dist/utils/locatorHelper.d.ts +7 -0
- package/dist/utils/locatorHelper.js +53 -0
- package/dist/utils/locatorHelper.js.map +1 -0
- package/dist/utils/logger.d.ts +12 -0
- package/dist/utils/logger.js +35 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/response.d.ts +4 -0
- package/dist/utils/response.js +25 -0
- package/dist/utils/response.js.map +1 -0
- package/dist/utils/responseFormatter.d.ts +78 -0
- package/dist/utils/responseFormatter.js +123 -0
- package/dist/utils/responseFormatter.js.map +1 -0
- package/dist/utils/sseManager.d.ts +32 -0
- package/dist/utils/sseManager.js +122 -0
- package/dist/utils/sseManager.js.map +1 -0
- package/lexxit-automation-framework-2.0.0.tgz +0 -0
- package/npmignore +5 -0
- package/package.json +36 -0
- package/scripts/postinstall.js +52 -0
- package/src/app.ts +27 -0
- package/src/controllers/controller.ts +282 -0
- package/src/core/BrowserManager.ts +398 -0
- package/src/core/PlaywrightEngine.ts +371 -0
- package/src/core/ScreenshotManager.ts +25 -0
- package/src/core/TestData.ts +25 -0
- package/src/core/TestExecutor.ts +436 -0
- package/src/core/handlers/AllHandlers.ts +626 -0
- package/src/core/handlers/BaseHandler.ts +41 -0
- package/src/core/handlers/ClickHandler.ts +482 -0
- package/src/core/handlers/CustomCodeHandler.ts +123 -0
- package/src/core/handlers/DropdownHandler.ts +438 -0
- package/src/core/handlers/InputHandler.ts +192 -0
- package/src/core/registry/ActionRegistry.ts +31 -0
- package/src/installer/frameworkLauncher.ts +242 -0
- package/src/installer/install.sh +107 -0
- package/src/public/dashboard.html +540 -0
- package/src/public/queue-monitor.html +190 -0
- package/src/queue/ExecutionQueue.ts +200 -0
- package/src/routes/api.routes.ts +16 -0
- package/src/server.ts +29 -0
- package/src/types/types.ts +169 -0
- package/src/utils/elementHighlight.ts +174 -0
- package/src/utils/locatorHelper.ts +49 -0
- package/src/utils/logger.ts +40 -0
- package/src/utils/response.ts +27 -0
- package/src/utils/responseFormatter.ts +167 -0
- package/src/utils/sseManager.ts +127 -0
- package/tsconfig.json +18 -0
- 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
|
+
}
|