skopix 2.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,653 @@
1
+ import { chromium } from 'playwright';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+
5
+ export class RecordingSession {
6
+ constructor({ url, sessionId, screenshotDir }) {
7
+ this.startUrl = url;
8
+ this.sessionId = sessionId;
9
+ this.screenshotDir = screenshotDir;
10
+ this.steps = [];
11
+ this.stepCounter = 0;
12
+ this.browser = null;
13
+ this.context = null;
14
+ this.page = null;
15
+ this._stopping = false;
16
+ }
17
+
18
+ emit(obj) {
19
+ process.stdout.write(JSON.stringify(obj) + '\n');
20
+ }
21
+
22
+ nextId() {
23
+ return 'step-' + String(++this.stepCounter).padStart(3, '0');
24
+ }
25
+
26
+ async launch() {
27
+ this.browser = await chromium.launch({
28
+ headless: false,
29
+ args: [
30
+ '--no-sandbox',
31
+ '--disable-blink-features=AutomationControlled',
32
+ '--disable-infobars',
33
+ '--allow-insecure-localhost',
34
+ '--disable-features=IsolateOrigins,site-per-process',
35
+ ],
36
+ });
37
+
38
+ this.context = await this.browser.newContext({
39
+ viewport: { width: 1280, height: 800 },
40
+ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
41
+ locale: 'en-GB',
42
+ });
43
+
44
+ // Expose capture function ONCE at context level — persists across all navigations
45
+ await this.context.exposeFunction('__skopixCapture', async (actionData) => {
46
+ if (this._stopping) return;
47
+ if (actionData.action === 'stop') {
48
+ await this.stop();
49
+ process.exit(0);
50
+ return;
51
+ }
52
+ await this._captureStep(actionData);
53
+ });
54
+
55
+ // The big init script — injected on every page load automatically
56
+ await this.context.addInitScript(() => {
57
+ if (window.__skopixRecording) return;
58
+ window.__skopixRecording = true;
59
+
60
+ // ─── Selector builder ─────────────────────────────────────────────────
61
+ function getSelector(el) {
62
+ if (!el || el === document.body) return 'body';
63
+ const testAttrs = ['data-testid', 'data-test', 'pi-test-identifier', 'data-cy', 'data-qa'];
64
+ for (const attr of testAttrs) {
65
+ const val = el.getAttribute(attr);
66
+ if (val) return '[' + attr + '="' + val + '"]';
67
+ }
68
+ if (el.id && !/^\d/.test(el.id)) return '#' + el.id;
69
+ const ariaLabel = el.getAttribute('aria-label');
70
+ if (ariaLabel && ['button', 'a', 'input'].includes(el.tagName.toLowerCase())) {
71
+ return el.tagName.toLowerCase() + '[aria-label="' + ariaLabel + '"]';
72
+ }
73
+ if (el.name && el.tagName === 'INPUT') return 'input[name="' + el.name + '"]';
74
+ const parts = [];
75
+ let cur = el;
76
+ let depth = 0;
77
+ while (cur && cur !== document.body && depth < 4) {
78
+ let seg = cur.tagName.toLowerCase();
79
+ if (cur.id && !/^\d/.test(cur.id)) { parts.unshift('#' + cur.id); break; }
80
+ const sib = Array.from(cur.parentElement ? cur.parentElement.children : []).filter(c => c.tagName === cur.tagName);
81
+ if (sib.length > 1) seg += ':nth-of-type(' + (sib.indexOf(cur) + 1) + ')';
82
+ parts.unshift(seg);
83
+ cur = cur.parentElement;
84
+ depth++;
85
+ }
86
+ return parts.join(' > ');
87
+ }
88
+
89
+ function getElementInfo(el) {
90
+ return {
91
+ tag: el.tagName.toLowerCase(),
92
+ id: el.id || null,
93
+ name: el.name || null,
94
+ type: el.type || null,
95
+ text: (el.innerText || el.value || el.placeholder || el.getAttribute('aria-label') || '').trim().slice(0, 80),
96
+ selector: getSelector(el),
97
+ classes: el.className ? el.className.toString().trim().slice(0, 100) : null,
98
+ };
99
+ }
100
+
101
+ // ─── Action listeners ─────────────────────────────────────────────────
102
+ document.addEventListener('click', function(e) {
103
+ // Don't capture clicks on our own toolbar, popover, or hint overlay
104
+ if (e.target && e.target.closest) {
105
+ if (e.target.closest('#__skopix_toolbar')) return;
106
+ if (e.target.closest('#__skopix_popover')) return;
107
+ if (e.target.closest('#__skopix_hint')) return;
108
+ }
109
+ if (window.__skopixPickMode) return; // picker handles its own clicks
110
+ const el = e.target;
111
+ if (!el || el === document.body || el === document.documentElement) return;
112
+ const rect = el.getBoundingClientRect();
113
+ if (window.__skopixCapture) {
114
+ // Detect checkboxes and radio buttons - use 'check' action
115
+ // The checked state is what it WILL BE after this click (it toggles)
116
+ const isCheckable = el.type === 'checkbox' || el.type === 'radio';
117
+ // Also check if we clicked a label that controls a checkbox
118
+ let checkTarget = null;
119
+ if (!isCheckable && el.tagName === 'LABEL' && el.htmlFor) {
120
+ checkTarget = document.getElementById(el.htmlFor);
121
+ }
122
+ if (!isCheckable && el.tagName === 'LABEL' && !el.htmlFor) {
123
+ checkTarget = el.querySelector('input[type="checkbox"], input[type="radio"]');
124
+ }
125
+ const actualCheckable = isCheckable ? el : checkTarget;
126
+ if (actualCheckable && (actualCheckable.type === 'checkbox' || actualCheckable.type === 'radio')) {
127
+ // By the time the click event fires, checkbox is already toggled
128
+ // so .checked gives us the new state directly
129
+ window.__skopixCapture({
130
+ action: 'check',
131
+ checked: actualCheckable.checked,
132
+ element: getElementInfo(actualCheckable),
133
+ clickX: Math.round(e.clientX),
134
+ clickY: Math.round(e.clientY),
135
+ elementX: Math.round(rect.left + rect.width / 2),
136
+ elementY: Math.round(rect.top + rect.height / 2),
137
+ });
138
+ } else {
139
+ window.__skopixCapture({
140
+ action: 'click',
141
+ element: getElementInfo(el),
142
+ clickX: Math.round(e.clientX),
143
+ clickY: Math.round(e.clientY),
144
+ elementX: Math.round(rect.left + rect.width / 2),
145
+ elementY: Math.round(rect.top + rect.height / 2),
146
+ });
147
+ }
148
+ }
149
+ }, true);
150
+
151
+ let typeTimer = null;
152
+ let lastInputEl = null;
153
+ document.addEventListener('input', function(e) {
154
+ const el = e.target;
155
+ if (!el || !['INPUT', 'TEXTAREA'].includes(el.tagName)) return;
156
+ // Checkboxes and radio buttons are handled by the click listener, not type
157
+ if (el.type === 'checkbox' || el.type === 'radio') return;
158
+ if (el.closest && el.closest('#__skopix_toolbar')) return;
159
+ if (el.closest && el.closest('#__skopix_popover')) return;
160
+ lastInputEl = el;
161
+ clearTimeout(typeTimer);
162
+ typeTimer = setTimeout(function() {
163
+ if (!lastInputEl) return;
164
+ if (window.__skopixCapture) {
165
+ window.__skopixCapture({
166
+ action: 'type',
167
+ element: getElementInfo(lastInputEl),
168
+ value: lastInputEl.value,
169
+ isPassword: lastInputEl.type === 'password',
170
+ });
171
+ }
172
+ lastInputEl = null;
173
+ }, 600);
174
+ }, true);
175
+
176
+ document.addEventListener('change', function(e) {
177
+ const el = e.target;
178
+ if (!el || el.tagName !== 'SELECT') return;
179
+ if (el.closest && el.closest('#__skopix_toolbar')) return;
180
+ if (el.closest && el.closest('#__skopix_popover')) return;
181
+ const selected = el.options[el.selectedIndex];
182
+ if (window.__skopixCapture) {
183
+ window.__skopixCapture({
184
+ action: 'select',
185
+ element: getElementInfo(el),
186
+ value: el.value,
187
+ label: selected ? selected.text : el.value,
188
+ });
189
+ }
190
+ }, true);
191
+
192
+ // Scroll listener - debounced, captures final scroll position
193
+ let scrollTimer = null;
194
+ document.addEventListener('scroll', function(e) {
195
+ const el = e.target;
196
+ // Ignore scrolls on our own UI elements
197
+ if (el && el.closest) {
198
+ if (el.closest('#__skopix_toolbar')) return;
199
+ if (el.closest('#__skopix_popover')) return;
200
+ }
201
+ clearTimeout(scrollTimer);
202
+ scrollTimer = setTimeout(function() {
203
+ // Determine what was scrolled - the element itself or the window
204
+ const isWindow = el === document || el === document.documentElement || el === document.body;
205
+ const scrollLeft = isWindow ? window.scrollX : el.scrollLeft;
206
+ const scrollTop = isWindow ? window.scrollY : el.scrollTop;
207
+ // Only capture meaningful scrolls (ignore tiny accidental scrolls)
208
+ if (Math.abs(scrollTop) < 50 && Math.abs(scrollLeft) < 50) return;
209
+ const selector = isWindow ? 'window' : getSelector(el);
210
+ if (window.__skopixCapture) {
211
+ window.__skopixCapture({
212
+ action: 'scroll',
213
+ selector,
214
+ scrollX: Math.round(scrollLeft),
215
+ scrollY: Math.round(scrollTop),
216
+ isWindow,
217
+ element: isWindow ? null : getElementInfo(el),
218
+ });
219
+ }
220
+ }, 400);
221
+ }, true);
222
+
223
+ // ─── Floating toolbar ─────────────────────────────────────────────────
224
+ function createToolbar() {
225
+ if (document.getElementById('__skopix_toolbar')) return;
226
+
227
+ const toolbar = document.createElement('div');
228
+ toolbar.id = '__skopix_toolbar';
229
+ toolbar.style.cssText = [
230
+ 'position:fixed', 'bottom:20px', 'right:20px', 'z-index:2147483647',
231
+ 'background:#0f1117', 'border:1px solid #dc2626', 'border-radius:10px',
232
+ 'padding:10px 14px', 'display:flex', 'align-items:center', 'gap:10px',
233
+ 'font-family:monospace', 'font-size:12px', 'color:#e5e7eb',
234
+ 'box-shadow:0 4px 24px rgba(0,0,0,0.6)', 'user-select:none',
235
+ 'transition:opacity 0.2s',
236
+ ].join(';');
237
+
238
+ toolbar.innerHTML = `
239
+ <span style="color:#dc2626;font-size:14px;animation:skopix_pulse 1s infinite">●</span>
240
+ <span style="color:#9ca3af">Recording</span>
241
+ <span id="__skopix_count" style="color:#22d3ee;font-weight:700;min-width:20px;text-align:center">0</span>
242
+ <span style="color:#4b5563">steps</span>
243
+ <button id="__skopix_assert_btn" style="
244
+ background:#1e3a5f;border:1px solid #2563eb;color:#60a5fa;
245
+ border-radius:6px;padding:4px 10px;cursor:pointer;font-size:11px;font-family:monospace;
246
+ ">+ Assert</button>
247
+ <button id="__skopix_stop_btn" style="
248
+ background:#3f0d0d;border:1px solid #dc2626;color:#f87171;
249
+ border-radius:6px;padding:4px 10px;cursor:pointer;font-size:11px;font-family:monospace;
250
+ ">■ Stop</button>
251
+ `;
252
+
253
+ const style = document.createElement('style');
254
+ style.textContent = '@keyframes skopix_pulse{0%,100%{opacity:1}50%{opacity:0.3}}';
255
+ document.head.appendChild(style);
256
+ document.body.appendChild(toolbar);
257
+
258
+ document.getElementById('__skopix_stop_btn').addEventListener('click', function(e) {
259
+ e.stopPropagation();
260
+ if (window.__skopixCapture) window.__skopixCapture({ action: 'stop' });
261
+ });
262
+
263
+ document.getElementById('__skopix_assert_btn').addEventListener('click', function(e) {
264
+ e.stopPropagation();
265
+ startPickMode();
266
+ });
267
+ }
268
+
269
+ function updateStepCount(n) {
270
+ const el = document.getElementById('__skopix_count');
271
+ if (el) el.textContent = n;
272
+ }
273
+
274
+ // ─── Element picker mode ──────────────────────────────────────────────
275
+ let pickerOverlay = null;
276
+ let lastHovered = null;
277
+
278
+ function startPickMode() {
279
+ window.__skopixPickMode = true;
280
+ document.body.style.cursor = 'crosshair';
281
+
282
+ // Dim the toolbar
283
+ const tb = document.getElementById('__skopix_toolbar');
284
+ if (tb) tb.style.opacity = '0.5';
285
+
286
+ // Show hint
287
+ const hint = document.createElement('div');
288
+ hint.id = '__skopix_hint';
289
+ hint.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:2147483647;background:#1e3a5f;border:1px solid #2563eb;color:#60a5fa;padding:8px 18px;border-radius:8px;font-family:monospace;font-size:13px;pointer-events:none';
290
+ hint.textContent = 'Click any element to add an assertion';
291
+ document.body.appendChild(hint);
292
+
293
+ pickerOverlay = document.createElement('div');
294
+ pickerOverlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483646;pointer-events:auto;cursor:crosshair';
295
+ document.body.appendChild(pickerOverlay);
296
+
297
+ let highlight = document.createElement('div');
298
+ highlight.style.cssText = 'position:fixed;pointer-events:none;z-index:2147483645;background:rgba(37,99,235,0.15);border:2px solid #2563eb;border-radius:3px;transition:all 0.1s;display:none';
299
+ document.body.appendChild(highlight);
300
+
301
+ pickerOverlay.addEventListener('mousemove', function(e) {
302
+ pickerOverlay.style.pointerEvents = 'none';
303
+ const el = document.elementFromPoint(e.clientX, e.clientY);
304
+ pickerOverlay.style.pointerEvents = 'auto';
305
+ if (!el || el === document.body || el.id === '__skopix_toolbar') {
306
+ highlight.style.display = 'none';
307
+ return;
308
+ }
309
+ lastHovered = el;
310
+ const r = el.getBoundingClientRect();
311
+ highlight.style.display = 'block';
312
+ highlight.style.top = r.top + 'px';
313
+ highlight.style.left = r.left + 'px';
314
+ highlight.style.width = r.width + 'px';
315
+ highlight.style.height = r.height + 'px';
316
+ });
317
+
318
+ pickerOverlay.addEventListener('click', function(e) {
319
+ e.preventDefault();
320
+ e.stopPropagation();
321
+ pickerOverlay.style.pointerEvents = 'none';
322
+ const el = document.elementFromPoint(e.clientX, e.clientY);
323
+ pickerOverlay.style.pointerEvents = 'auto';
324
+ if (!el || el === document.body) { stopPickMode(); return; }
325
+
326
+ stopPickMode();
327
+ showAssertionPopover(el, highlight);
328
+ });
329
+
330
+ document.addEventListener('keydown', function escHandler(e) {
331
+ if (e.key === 'Escape') { stopPickMode(); document.removeEventListener('keydown', escHandler); }
332
+ });
333
+ }
334
+
335
+ function stopPickMode() {
336
+ window.__skopixPickMode = false;
337
+ document.body.style.cursor = '';
338
+ if (pickerOverlay) { pickerOverlay.remove(); pickerOverlay = null; }
339
+ const hint = document.getElementById('__skopix_hint');
340
+ if (hint) hint.remove();
341
+ const tb = document.getElementById('__skopix_toolbar');
342
+ if (tb) tb.style.opacity = '1';
343
+ }
344
+
345
+ // ─── Assertion popover ────────────────────────────────────────────────
346
+ function showAssertionPopover(el, highlightEl) {
347
+ const existing = document.getElementById('__skopix_popover');
348
+ if (existing) existing.remove();
349
+
350
+ const sel = getSelector(el);
351
+ const currentText = (el.innerText || el.textContent || '').trim().slice(0, 100);
352
+ const tag = el.tagName.toLowerCase();
353
+ const rect = el.getBoundingClientRect();
354
+
355
+ // Smart defaults
356
+ let suggestedType = 'visible';
357
+ let suggestedValue = '';
358
+
359
+ // If element has a title/alt/aria-label, suggest attribute_contains
360
+ const titleAttr = el.getAttribute('title') || el.getAttribute('alt');
361
+ if (titleAttr && titleAttr.length > 0) {
362
+ suggestedType = 'attribute_contains';
363
+ suggestedValue = titleAttr.slice(0, 80);
364
+ } else if (currentText && currentText.length > 0 && currentText.length < 80) {
365
+ suggestedType = 'text_contains';
366
+ // For numbers, suggest exact value. For text, suggest contains.
367
+ suggestedValue = currentText.replace(/\s+/g, ' ').trim();
368
+ }
369
+ // Count suggestion for tables/lists
370
+ if (['table', 'tbody', 'ul', 'ol'].includes(tag) || el.querySelectorAll('tr, li').length > 1) {
371
+ const rows = el.querySelectorAll('tr:not(thead tr), li').length;
372
+ if (rows > 0) { suggestedType = 'element_count'; suggestedValue = String(rows); }
373
+ }
374
+
375
+ // Highlight selected element in green
376
+ if (highlightEl) {
377
+ highlightEl.style.background = 'rgba(34,197,94,0.15)';
378
+ highlightEl.style.borderColor = '#22c55e';
379
+ highlightEl.style.display = 'block';
380
+ highlightEl.style.top = rect.top + 'px';
381
+ highlightEl.style.left = rect.left + 'px';
382
+ highlightEl.style.width = rect.width + 'px';
383
+ highlightEl.style.height = rect.height + 'px';
384
+ }
385
+
386
+ const popover = document.createElement('div');
387
+ popover.id = '__skopix_popover';
388
+
389
+ // Position popover — prefer below element, fall back to above
390
+ const popHeight = 280;
391
+ const topPos = rect.bottom + 8 + popHeight > window.innerHeight
392
+ ? Math.max(8, rect.top - popHeight - 8)
393
+ : rect.bottom + 8;
394
+ const leftPos = Math.min(rect.left, window.innerWidth - 360);
395
+
396
+ popover.style.cssText = [
397
+ 'position:fixed', 'z-index:2147483647',
398
+ 'top:' + topPos + 'px', 'left:' + leftPos + 'px',
399
+ 'width:350px', 'background:#0f1117',
400
+ 'border:1px solid #2563eb', 'border-radius:10px',
401
+ 'padding:16px', 'font-family:monospace', 'font-size:12px', 'color:#e5e7eb',
402
+ 'box-shadow:0 8px 32px rgba(0,0,0,0.7)',
403
+ ].join(';');
404
+
405
+ popover.innerHTML = `
406
+ <div style="color:#60a5fa;font-size:11px;letter-spacing:0.1em;margin-bottom:12px">ADD ASSERTION</div>
407
+
408
+ <div style="margin-bottom:10px">
409
+ <div style="color:#9ca3af;font-size:10px;margin-bottom:4px">SELECTED ELEMENT</div>
410
+ <div style="background:#1a1d2e;padding:6px 10px;border-radius:6px;color:#22d3ee;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${sel}">${sel}</div>
411
+ ${currentText ? '<div style="color:#6b7280;font-size:10px;margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">Current text: "' + currentText.slice(0,50) + (currentText.length > 50 ? '...' : '') + '"</div>' : ''}
412
+ </div>
413
+
414
+ <div style="margin-bottom:10px">
415
+ <div style="color:#9ca3af;font-size:10px;margin-bottom:4px">ASSERTION TYPE</div>
416
+ <select id="__skopix_assert_type" style="width:100%;background:#1a1d2e;border:1px solid #374151;color:#e5e7eb;padding:6px 8px;border-radius:6px;font-family:monospace;font-size:12px">
417
+ <option value="visible"${suggestedType==='visible'?' selected':''}>Element is visible</option>
418
+ <option value="text_contains"${suggestedType==='text_contains'?' selected':''}>Text contains</option>
419
+ <option value="text_equals"${suggestedType==='text_equals'?' selected':''}>Text equals</option>
420
+ <option value="url_contains">URL contains</option>
421
+ <option value="element_count"${suggestedType==='element_count'?' selected':''}>Element count</option>
422
+ <option value="attribute_contains">Attribute contains (title, alt, etc.)</option>
423
+ </select>
424
+ </div>
425
+
426
+ <div id="__skopix_attr_row" style="margin-bottom:10px;display:none">
427
+ <div style="color:#9ca3af;font-size:10px;margin-bottom:4px">ATTRIBUTE NAME</div>
428
+ <input id="__skopix_assert_attr" type="text" value="title" placeholder="e.g. title, alt, aria-label" style="width:100%;box-sizing:border-box;background:#1a1d2e;border:1px solid #374151;color:#e5e7eb;padding:6px 8px;border-radius:6px;font-family:monospace;font-size:12px">
429
+ </div>
430
+
431
+ <div id="__skopix_value_row" style="margin-bottom:10px;${suggestedType==='visible'?'display:none':''}">
432
+ <div style="color:#9ca3af;font-size:10px;margin-bottom:4px" id="__skopix_value_label">EXPECTED VALUE</div>
433
+ <input id="__skopix_assert_value" type="text" value="${suggestedValue.replace(/"/g, '&quot;')}" placeholder="Expected value..." style="width:100%;box-sizing:border-box;background:#1a1d2e;border:1px solid #374151;color:#e5e7eb;padding:6px 8px;border-radius:6px;font-family:monospace;font-size:12px">
434
+ </div>
435
+
436
+ <div style="margin-bottom:14px">
437
+ <div style="color:#9ca3af;font-size:10px;margin-bottom:4px">DESCRIPTION (optional)</div>
438
+ <input id="__skopix_assert_desc" type="text" placeholder="e.g. First row should be sales" style="width:100%;box-sizing:border-box;background:#1a1d2e;border:1px solid #374151;color:#e5e7eb;padding:6px 8px;border-radius:6px;font-family:monospace;font-size:12px">
439
+ </div>
440
+
441
+ <div style="display:flex;gap:8px;justify-content:flex-end">
442
+ <button id="__skopix_assert_cancel" style="background:transparent;border:1px solid #374151;color:#9ca3af;border-radius:6px;padding:6px 14px;cursor:pointer;font-family:monospace;font-size:12px">Cancel</button>
443
+ <button id="__skopix_assert_add" style="background:#1e3a5f;border:1px solid #2563eb;color:#60a5fa;border-radius:6px;padding:6px 14px;cursor:pointer;font-family:monospace;font-size:12px;font-weight:700">Add ✓</button>
444
+ </div>
445
+ `;
446
+
447
+ document.body.appendChild(popover);
448
+
449
+ // Show/hide value field based on type
450
+ const typeSelect = popover.querySelector('#__skopix_assert_type');
451
+ const valueRow = popover.querySelector('#__skopix_value_row');
452
+ const valueLabel = popover.querySelector('#__skopix_value_label');
453
+ const valueInput = popover.querySelector('#__skopix_assert_value');
454
+ const attrRow = popover.querySelector('#__skopix_attr_row');
455
+
456
+ typeSelect.addEventListener('change', function() {
457
+ const t = typeSelect.value;
458
+ attrRow.style.display = t === 'attribute_contains' ? 'block' : 'none';
459
+ if (t === 'visible') {
460
+ valueRow.style.display = 'none';
461
+ } else {
462
+ valueRow.style.display = 'block';
463
+ if (t === 'element_count') {
464
+ valueLabel.textContent = 'EXPECTED COUNT (number)';
465
+ valueInput.placeholder = 'e.g. 9';
466
+ } else if (t === 'url_contains') {
467
+ valueLabel.textContent = 'URL MUST CONTAIN';
468
+ valueInput.placeholder = 'e.g. /dashboard';
469
+ valueInput.value = '';
470
+ } else if (t === 'attribute_contains') {
471
+ valueLabel.textContent = 'ATTRIBUTE VALUE MUST CONTAIN';
472
+ valueInput.placeholder = 'e.g. SVG equivalent';
473
+ // Pre-fill with the title attribute value if it exists
474
+ const titleVal = el.getAttribute('title') || el.getAttribute('alt') || el.getAttribute('aria-label') || '';
475
+ if (titleVal && !valueInput.value) valueInput.value = titleVal.slice(0, 80);
476
+ } else {
477
+ valueLabel.textContent = 'EXPECTED VALUE';
478
+ valueInput.placeholder = 'Expected text...';
479
+ }
480
+ }
481
+ });
482
+
483
+ popover.querySelector('#__skopix_assert_cancel').addEventListener('click', function(e) {
484
+ e.stopPropagation();
485
+ if (highlightEl) highlightEl.style.display = 'none';
486
+ popover.remove();
487
+ });
488
+
489
+ popover.querySelector('#__skopix_assert_add').addEventListener('click', function(e) {
490
+ e.stopPropagation();
491
+ const assertType = typeSelect.value;
492
+ const value = popover.querySelector('#__skopix_assert_value').value.trim();
493
+ const description = popover.querySelector('#__skopix_assert_desc').value.trim();
494
+
495
+ if (assertType !== 'visible' && assertType !== 'url_contains' && !value) {
496
+ popover.querySelector('#__skopix_assert_value').style.borderColor = '#dc2626';
497
+ return;
498
+ }
499
+
500
+ if (window.__skopixCapture) {
501
+ const attrInput = popover.querySelector('#__skopix_assert_attr');
502
+ window.__skopixCapture({
503
+ action: 'assert',
504
+ assertType,
505
+ attribute: assertType === 'attribute_contains' ? (attrInput ? attrInput.value.trim() || 'title' : 'title') : null,
506
+ selector: assertType === 'url_contains' ? null : sel,
507
+ value: value || null,
508
+ description: description || null,
509
+ element: assertType === 'url_contains' ? null : getElementInfo(el),
510
+ });
511
+ }
512
+
513
+ if (highlightEl) highlightEl.style.display = 'none';
514
+ popover.remove();
515
+
516
+ // Flash the assert button green briefly to confirm
517
+ const assertBtn = document.getElementById('__skopix_assert_btn');
518
+ if (assertBtn) {
519
+ const orig = assertBtn.style.cssText;
520
+ assertBtn.textContent = '✓ Added';
521
+ assertBtn.style.background = '#14532d';
522
+ assertBtn.style.borderColor = '#22c55e';
523
+ assertBtn.style.color = '#4ade80';
524
+ setTimeout(() => { assertBtn.textContent = '+ Assert'; assertBtn.style.cssText = orig; }, 1500);
525
+ }
526
+ });
527
+
528
+ // Focus the value input if visible
529
+ setTimeout(() => {
530
+ if (valueRow.style.display !== 'none') valueInput.focus();
531
+ }, 50);
532
+ }
533
+
534
+ // Boot the toolbar once DOM is ready
535
+ if (document.body) {
536
+ createToolbar();
537
+ } else {
538
+ document.addEventListener('DOMContentLoaded', createToolbar);
539
+ }
540
+
541
+ // Re-create toolbar after navigation if it got wiped
542
+ new MutationObserver(() => {
543
+ if (!document.getElementById('__skopix_toolbar')) createToolbar();
544
+ }).observe(document.documentElement, { childList: true, subtree: false });
545
+
546
+ // Listen for step count updates from parent
547
+ window.__skopixUpdateCount = function(n) { updateStepCount(n); };
548
+ });
549
+
550
+ this.context.on('page', (newPage) => {
551
+ newPage.on('framenavigated', async (frame) => {
552
+ if (frame !== newPage.mainFrame()) return;
553
+ const url = frame.url();
554
+ if (url === 'about:blank' || url === this.startUrl) return;
555
+ this.emit({ type: 'navigate', url });
556
+ });
557
+ });
558
+
559
+ this.page = await this.context.newPage();
560
+
561
+ this.page.on('framenavigated', async (frame) => {
562
+ if (frame !== this.page.mainFrame()) return;
563
+ const url = frame.url();
564
+ if (url === 'about:blank') return;
565
+ this.emit({ type: 'navigate', url });
566
+ // Update step count in toolbar after navigation
567
+ setTimeout(async () => {
568
+ try {
569
+ await this.page.evaluate((n) => {
570
+ if (window.__skopixUpdateCount) window.__skopixUpdateCount(n);
571
+ }, this.steps.length);
572
+ } catch {}
573
+ }, 500);
574
+ });
575
+
576
+ await this.page.goto(this.startUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
577
+ this.emit({ type: 'ready' });
578
+ }
579
+
580
+ async _captureStep(actionData) {
581
+ const id = this.nextId();
582
+ const page = this.page;
583
+ const step = {
584
+ id,
585
+ action: actionData.action,
586
+ assertType: actionData.assertType || null,
587
+ selector: actionData.selector || (actionData.element ? actionData.element.selector : null),
588
+ element: actionData.element || null,
589
+ value: actionData.value || null,
590
+ isPassword: actionData.isPassword || false,
591
+ label: actionData.label || null,
592
+ clickX: actionData.clickX || null,
593
+ clickY: actionData.clickY || null,
594
+ elementX: actionData.elementX || null,
595
+ elementY: actionData.elementY || null,
596
+ description: actionData.description || null,
597
+ url: page ? page.url() : '',
598
+ timestamp: Date.now(),
599
+ stableSelector: null,
600
+ screenshotPath: null,
601
+ };
602
+
603
+ // Update toolbar step count
604
+ setTimeout(async () => {
605
+ try {
606
+ await page.evaluate((n) => {
607
+ if (window.__skopixUpdateCount) window.__skopixUpdateCount(n);
608
+ }, this.steps.length + 1);
609
+ } catch {}
610
+ }, 100);
611
+
612
+ // Screenshot after short delay
613
+ setTimeout(async () => {
614
+ try {
615
+ if (this.screenshotDir && page) {
616
+ await fs.ensureDir(this.screenshotDir);
617
+ const screenshotPath = path.join(this.screenshotDir, id + '.png');
618
+ await page.screenshot({ path: screenshotPath, fullPage: false }).catch(() => {});
619
+ step.screenshotPath = screenshotPath;
620
+ this.emit({ type: 'screenshot', stepId: id, path: screenshotPath });
621
+ }
622
+ } catch {}
623
+ }, 400);
624
+
625
+ this.steps.push(step);
626
+ this.emit({ type: 'step', step });
627
+ }
628
+
629
+ async stop() {
630
+ this._stopping = true;
631
+ try {
632
+ if (this.screenshotDir && this.page) {
633
+ await fs.ensureDir(this.screenshotDir);
634
+ await this.page.screenshot({ path: path.join(this.screenshotDir, 'final.png'), fullPage: false }).catch(() => {});
635
+ }
636
+ } catch {}
637
+ try { await this.browser.close(); } catch {}
638
+ this.emit({ type: 'done', steps: this.steps });
639
+ }
640
+ }
641
+
642
+ // ─── Entry point ──────────────────────────────────────────────────────────────
643
+ const [,, url, sessionId, screenshotDir] = process.argv;
644
+ if (!url) { process.stderr.write('Usage: recorder.js <url> <sessionId> <screenshotDir>\n'); process.exit(1); }
645
+
646
+ const session = new RecordingSession({ url, sessionId, screenshotDir });
647
+ session.launch().catch((err) => { session.emit({ type: 'error', message: err.message }); process.exit(1); });
648
+
649
+ process.stdin.setEncoding('utf8');
650
+ process.stdin.on('data', async (data) => {
651
+ if (data.trim() === 'stop') { await session.stop(); process.exit(0); }
652
+ });
653
+ process.on('SIGTERM', async () => { await session.stop(); process.exit(0); });