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/.env.example +6 -0
- package/README.md +126 -0
- package/bin/cli.js +36 -0
- package/dashboard/index.html +1222 -0
- package/dashboard/recorder.html +1359 -0
- package/package.json +23 -0
- package/recorder/cicd.js +760 -0
- package/recorder/converter.js +539 -0
- package/recorder/recorder.js +294 -0
- package/server.js +4616 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QA Deck — Playwright Recorder Engine (v3 - fully fixed)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { chromium } = require("playwright");
|
|
6
|
+
const { EventEmitter } = require("events");
|
|
7
|
+
|
|
8
|
+
class PlaywrightRecorder extends EventEmitter {
|
|
9
|
+
constructor(options = {}) {
|
|
10
|
+
super();
|
|
11
|
+
this.startUrl = options.startUrl || "about:blank";
|
|
12
|
+
this.pollMs = 500;
|
|
13
|
+
this.headless = options.headless || false;
|
|
14
|
+
this.sessionId = options.sessionId || Date.now().toString(36);
|
|
15
|
+
this.browser = null;
|
|
16
|
+
this.context = null;
|
|
17
|
+
this.page = null;
|
|
18
|
+
this.actions = []; // ALL captured actions (persists across navigations)
|
|
19
|
+
this.pollTimer = null;
|
|
20
|
+
this.isRecording = false;
|
|
21
|
+
this.startTime = null;
|
|
22
|
+
this._counter = 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
_id() { return `a${++this._counter}`; }
|
|
26
|
+
_t() { return this.startTime ? Date.now() - this.startTime : 0; }
|
|
27
|
+
|
|
28
|
+
// ── Injector script (runs inside browser) ─────────────────────────────────
|
|
29
|
+
// IMPORTANT: this is a plain function — no Node.js closures allowed.
|
|
30
|
+
static _injector() {
|
|
31
|
+
if (window.__QA_ACTIVE) return;
|
|
32
|
+
window.__QA_ACTIVE = true;
|
|
33
|
+
window.__QA_QUEUE = window.__QA_QUEUE || [];
|
|
34
|
+
|
|
35
|
+
function locator(el) {
|
|
36
|
+
if (!el) return null;
|
|
37
|
+
try {
|
|
38
|
+
// data-test / data-testid / data-cy
|
|
39
|
+
const tid = el.dataset && (el.dataset.testid || el.dataset.test || el.dataset.cy || el.dataset.qa);
|
|
40
|
+
if (tid) return '[data-testid="' + tid + '"]';
|
|
41
|
+
// unique id
|
|
42
|
+
if (el.id) {
|
|
43
|
+
const escaped = el.id.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
|
|
44
|
+
try { if (document.querySelectorAll('#' + escaped).length === 1) return '#' + el.id; } catch(e) {}
|
|
45
|
+
}
|
|
46
|
+
// aria-label
|
|
47
|
+
const aria = el.getAttribute('aria-label');
|
|
48
|
+
if (aria) return '[aria-label="' + aria.replace(/"/g, '\\"') + '"]';
|
|
49
|
+
// name attr
|
|
50
|
+
if (el.name) return '[name="' + el.name + '"]';
|
|
51
|
+
// button/link by text
|
|
52
|
+
const tag = (el.tagName || '').toLowerCase();
|
|
53
|
+
const text = (el.textContent || el.value || '').trim().replace(/\s+/g, ' ').slice(0, 40);
|
|
54
|
+
if ((tag === 'button' || tag === 'a') && text) {
|
|
55
|
+
try {
|
|
56
|
+
const matches = Array.from(document.querySelectorAll(tag))
|
|
57
|
+
.filter(function(e) { return (e.textContent || '').trim().replace(/\s+/g,' ').slice(0,40) === text; });
|
|
58
|
+
if (matches.length === 1) return tag + ':has-text("' + text.replace(/"/g, '\\"') + '")';
|
|
59
|
+
} catch(e) {}
|
|
60
|
+
}
|
|
61
|
+
return tag || 'element';
|
|
62
|
+
} catch(e) { return 'element'; }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getLabel(el) {
|
|
66
|
+
try {
|
|
67
|
+
if (el.getAttribute('aria-label')) return el.getAttribute('aria-label');
|
|
68
|
+
if (el.id) {
|
|
69
|
+
const lbl = document.querySelector('label[for="' + el.id + '"]');
|
|
70
|
+
if (lbl) return lbl.textContent.trim();
|
|
71
|
+
}
|
|
72
|
+
if (el.placeholder) return el.placeholder;
|
|
73
|
+
const prev = el.previousElementSibling;
|
|
74
|
+
if (prev && prev.tagName === 'LABEL') return prev.textContent.trim().slice(0, 60);
|
|
75
|
+
} catch(e) {}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function push(obj) {
|
|
80
|
+
obj.ts = Date.now();
|
|
81
|
+
window.__QA_QUEUE.push(obj);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Click ──
|
|
85
|
+
document.addEventListener('click', function(e) {
|
|
86
|
+
try {
|
|
87
|
+
const el = e.target.closest('button, a, [role="button"], input[type="checkbox"], input[type="radio"], label');
|
|
88
|
+
if (!el) return;
|
|
89
|
+
if (el.type === 'checkbox') {
|
|
90
|
+
push({ type: 'check', locator: locator(el), checked: el.checked, label: getLabel(el) });
|
|
91
|
+
} else if (el.type === 'radio') {
|
|
92
|
+
push({ type: 'radio', locator: locator(el), value: el.value, label: getLabel(el) });
|
|
93
|
+
} else {
|
|
94
|
+
const text = (el.textContent || el.value || '').trim().replace(/\s+/g,' ').slice(0, 80);
|
|
95
|
+
push({ type: 'click', locator: locator(el), text: text, tag: (el.tagName||'').toLowerCase() });
|
|
96
|
+
}
|
|
97
|
+
} catch(e) {}
|
|
98
|
+
}, true);
|
|
99
|
+
|
|
100
|
+
// ── Fill (on blur — always fires when user leaves a field) ──
|
|
101
|
+
document.addEventListener('blur', function(e) {
|
|
102
|
+
try {
|
|
103
|
+
const el = e.target;
|
|
104
|
+
if (!el || !['INPUT','TEXTAREA'].includes(el.tagName)) return;
|
|
105
|
+
if (['checkbox','radio','submit','button','reset'].includes(el.type)) return;
|
|
106
|
+
const val = el.value;
|
|
107
|
+
if (!val && val !== '0') return; // skip empty
|
|
108
|
+
// Avoid duplicate if change event already captured same value
|
|
109
|
+
const q = window.__QA_QUEUE;
|
|
110
|
+
const last = q[q.length - 1];
|
|
111
|
+
if (last && last.type === 'fill' && last.locator === locator(el) && last.value === val) return;
|
|
112
|
+
push({ type: 'fill', locator: locator(el), value: val, inputType: el.type || 'text', label: getLabel(el) });
|
|
113
|
+
} catch(e) {}
|
|
114
|
+
}, true);
|
|
115
|
+
|
|
116
|
+
// ── Select ──
|
|
117
|
+
document.addEventListener('change', function(e) {
|
|
118
|
+
try {
|
|
119
|
+
const el = e.target;
|
|
120
|
+
if (!el || el.tagName !== 'SELECT') return;
|
|
121
|
+
const opt = el.options[el.selectedIndex];
|
|
122
|
+
push({ type: 'select', locator: locator(el), value: el.value, optionText: opt ? opt.text : el.value, label: getLabel(el) });
|
|
123
|
+
} catch(e) {}
|
|
124
|
+
}, true);
|
|
125
|
+
|
|
126
|
+
// ── Keyboard (Enter/Escape) ──
|
|
127
|
+
document.addEventListener('keydown', function(e) {
|
|
128
|
+
try {
|
|
129
|
+
if (e.key === 'Enter') {
|
|
130
|
+
const el = e.target;
|
|
131
|
+
if (el && ['BUTTON','A'].includes(el.tagName)) return; // handled by click
|
|
132
|
+
push({ type: 'press', key: 'Enter', locator: locator(el) });
|
|
133
|
+
}
|
|
134
|
+
if (e.key === 'Escape') push({ type: 'press', key: 'Escape' });
|
|
135
|
+
} catch(e) {}
|
|
136
|
+
}, true);
|
|
137
|
+
|
|
138
|
+
// ── Form submit ──
|
|
139
|
+
document.addEventListener('submit', function(e) {
|
|
140
|
+
try { push({ type: 'submit', locator: locator(e.target) }); } catch(e) {}
|
|
141
|
+
}, true);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Start ─────────────────────────────────────────────────────────────────
|
|
145
|
+
async start() {
|
|
146
|
+
if (this.isRecording) throw new Error("Already recording");
|
|
147
|
+
|
|
148
|
+
this.browser = await chromium.launch({ headless: this.headless });
|
|
149
|
+
this.context = await this.browser.newContext({
|
|
150
|
+
viewport: { width: 1280, height: 800 },
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Inject before EVERY page/frame load — this is the most reliable way
|
|
154
|
+
await this.context.addInitScript(PlaywrightRecorder._injector);
|
|
155
|
+
|
|
156
|
+
// New popup/tab support
|
|
157
|
+
this.context.on("page", (pg) => this._attachListeners(pg));
|
|
158
|
+
|
|
159
|
+
this.page = await this.context.newPage();
|
|
160
|
+
this._attachListeners(this.page);
|
|
161
|
+
|
|
162
|
+
this.isRecording = true;
|
|
163
|
+
this.startTime = Date.now();
|
|
164
|
+
this._counter = 0;
|
|
165
|
+
this.actions = [];
|
|
166
|
+
|
|
167
|
+
if (this.startUrl !== "about:blank") {
|
|
168
|
+
await this.page.goto(this.startUrl, { waitUntil: "domcontentloaded", timeout: 30000 }).catch(() => {});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Seed with initial navigate
|
|
172
|
+
this.actions.push({ type: "navigate", url: this.startUrl, id: this._id(), sessionTime: 0, ts: Date.now() });
|
|
173
|
+
|
|
174
|
+
this._startPolling();
|
|
175
|
+
this.emit("started", { sessionId: this.sessionId });
|
|
176
|
+
return this.sessionId;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Stop ──────────────────────────────────────────────────────────────────
|
|
180
|
+
async stop() {
|
|
181
|
+
this._stopPolling();
|
|
182
|
+
await this._poll().catch(() => {}); // final drain
|
|
183
|
+
this.isRecording = false;
|
|
184
|
+
const actions = [...this.actions];
|
|
185
|
+
this.emit("stopped", { actions });
|
|
186
|
+
await this.browser?.close().catch(() => {});
|
|
187
|
+
this.browser = null; this.context = null; this.page = null;
|
|
188
|
+
return actions;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── Polling ───────────────────────────────────────────────────────────────
|
|
192
|
+
_startPolling() {
|
|
193
|
+
this.pollTimer = setInterval(() => this._poll().catch(() => {}), this.pollMs);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
_stopPolling() {
|
|
197
|
+
clearInterval(this.pollTimer);
|
|
198
|
+
this.pollTimer = null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async _poll() {
|
|
202
|
+
if (!this.page || this.page.isClosed()) return;
|
|
203
|
+
|
|
204
|
+
const raw = await this.page.evaluate(function() {
|
|
205
|
+
var q = window.__QA_QUEUE || [];
|
|
206
|
+
window.__QA_QUEUE = [];
|
|
207
|
+
return q;
|
|
208
|
+
}).catch(() => []);
|
|
209
|
+
|
|
210
|
+
if (!raw || !raw.length) return;
|
|
211
|
+
|
|
212
|
+
// Dedup: consecutive fills on same locator → keep last
|
|
213
|
+
const deduped = [];
|
|
214
|
+
for (let i = 0; i < raw.length; i++) {
|
|
215
|
+
const curr = raw[i], next = raw[i + 1];
|
|
216
|
+
if (curr.type === "fill" && next && next.type === "fill" && curr.locator === next.locator) continue;
|
|
217
|
+
deduped.push(curr);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
for (const a of deduped) {
|
|
221
|
+
const action = { ...a, id: this._id(), sessionTime: this._t() };
|
|
222
|
+
this.actions.push(action);
|
|
223
|
+
this.emit("action", action);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Page listeners ────────────────────────────────────────────────────────
|
|
228
|
+
_attachListeners(pg) {
|
|
229
|
+
// Capture navigations (these come from Playwright, not the injected script)
|
|
230
|
+
pg.on("framenavigated", (frame) => {
|
|
231
|
+
if (frame !== pg.mainFrame()) return;
|
|
232
|
+
const url = frame.url();
|
|
233
|
+
if (!url || url === "about:blank" || url === this.startUrl) return;
|
|
234
|
+
const action = { type: "navigate", url, id: this._id(), sessionTime: this._t(), ts: Date.now() };
|
|
235
|
+
this.actions.push(action);
|
|
236
|
+
this.emit("action", action);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// Dialogs
|
|
240
|
+
pg.on("dialog", async (dialog) => {
|
|
241
|
+
const action = { type: "dialog", dialogType: dialog.type(), message: dialog.message(), id: this._id(), sessionTime: this._t(), ts: Date.now() };
|
|
242
|
+
this.actions.push(action);
|
|
243
|
+
this.emit("action", action);
|
|
244
|
+
await dialog.accept().catch(() => {});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Re-inject on full page loads (multi-page apps, non-SPA)
|
|
248
|
+
pg.on("load", async () => {
|
|
249
|
+
try { await pg.evaluate(PlaywrightRecorder._injector); } catch (_) {}
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── Public API ────────────────────────────────────────────────────────────
|
|
254
|
+
getActions() { return [...this.actions]; }
|
|
255
|
+
getStatus() {
|
|
256
|
+
return {
|
|
257
|
+
isRecording: this.isRecording,
|
|
258
|
+
actionCount: this.actions.length,
|
|
259
|
+
duration: this._t(),
|
|
260
|
+
sessionId: this.sessionId,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Session manager ───────────────────────────────────────────────────────────
|
|
266
|
+
class RecorderSessionManager {
|
|
267
|
+
constructor() { this.sessions = new Map(); }
|
|
268
|
+
|
|
269
|
+
async createSession(options) {
|
|
270
|
+
const recorder = new PlaywrightRecorder(options);
|
|
271
|
+
const sessionId = await recorder.start();
|
|
272
|
+
this.sessions.set(sessionId, { recorder, startedAt: Date.now() });
|
|
273
|
+
setTimeout(() => this.destroySession(sessionId).catch(() => {}), 30 * 60 * 1000);
|
|
274
|
+
return { sessionId, recorder };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async destroySession(sessionId) {
|
|
278
|
+
const s = this.sessions.get(sessionId);
|
|
279
|
+
if (!s) return null;
|
|
280
|
+
const actions = await s.recorder.stop();
|
|
281
|
+
this.sessions.delete(sessionId);
|
|
282
|
+
return actions;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
getSession(sessionId) { return this.sessions.get(sessionId)?.recorder || null; }
|
|
286
|
+
|
|
287
|
+
listSessions() {
|
|
288
|
+
return Array.from(this.sessions.entries()).map(([id, s]) => ({
|
|
289
|
+
sessionId: id, ...s.recorder.getStatus(), startedAt: s.startedAt,
|
|
290
|
+
}));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
module.exports = { PlaywrightRecorder, RecorderSessionManager };
|