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,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 };