skopix 2.0.104 → 2.0.106

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.
@@ -385,89 +385,264 @@ export async function agentCommand(options) {
385
385
  async function handleDebugRecord(msg) {
386
386
  const { runId, recordingId, replaySteps } = msg;
387
387
  const startUrl = (msg.startUrl && msg.startUrl.startsWith('http')) ? msg.startUrl : null;
388
- console.log(chalk.cyan(' ⏸ Debug replay: ') + chalk.white(replaySteps.length + ' steps to debug point') + (startUrl ? chalk.dim(' @ ' + startUrl) : ''));
388
+ console.log(chalk.cyan(' ⏸ Debug replay: ') + chalk.white(replaySteps.length + ' steps') + (startUrl ? chalk.dim(' @ ' + startUrl) : ''));
389
389
 
390
390
  const sendRun = (data) => { try { ws.send(JSON.stringify({ type: 'runUpdate', runId, data })); } catch {} };
391
391
  const sendRec = (data) => { try { ws.send(JSON.stringify({ type: 'recordingUpdate', recordingId, data })); } catch {} };
392
+ const steps = [];
392
393
 
393
394
  sendRun({ type: 'stdout', text: '' });
394
395
  sendRun({ type: 'stdout', text: ' Replaying ' + replaySteps.length + ' steps to reach debug point...' });
395
- sendRun({ type: 'stdout', text: ''.repeat(60) });
396
+ sendRun({ type: 'stdout', text: '\u2501'.repeat(60) });
397
+
398
+ let chromiumBrowser = null;
399
+ let page = null;
400
+ let ctx = null;
396
401
 
397
402
  try {
398
403
  const { chromium } = await import('playwright');
399
- const browser = await chromium.launch({ headless: false, args: ['--no-sandbox', '--disable-blink-features=AutomationControlled', '--allow-insecure-localhost'] });
400
- const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
401
- const page = await ctx.newPage();
404
+ chromiumBrowser = await chromium.launch({ headless: false, args: ['--no-sandbox', '--disable-blink-features=AutomationControlled', '--allow-insecure-localhost'] });
405
+ ctx = await chromiumBrowser.newContext({ viewport: { width: 1280, height: 800 } });
406
+ page = await ctx.newPage();
402
407
 
403
408
  if (startUrl) {
404
409
  await page.goto(startUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
405
410
  await page.waitForTimeout(1000);
406
411
  }
407
412
 
408
- // Replay each step to reach debug point
413
+ // Replay steps to debug point
409
414
  let stepNum = 0;
415
+ let browserClosed = false;
410
416
  for (const step of replaySteps) {
417
+ if (browserClosed) break;
411
418
  stepNum++;
412
- sendRun({ type: 'stdout', text: ' [' + stepNum + '/' + replaySteps.length + '] ' + (step.action || '').toUpperCase() + ' — ' + (step.description || step.stableSelector || step.selector || '') });
419
+ const sel = sanitiseSelector(step.stableSelector || step.selector);
420
+ const desc = step.description || (step.action + ' ' + (sel || ''));
421
+ sendRun({ type: 'stdout', text: ' [' + stepNum + '/' + replaySteps.length + '] ' + step.action.toUpperCase() + ' \u2014 ' + desc });
413
422
  try {
414
- const sel = step.stableSelector || step.selector;
415
423
  await executeStep(step, sel, page, { url: startUrl || '' });
424
+ sendRun({ type: 'stdout', text: ' \u2713 Done' });
416
425
  } catch (err) {
417
- sendRun({ type: 'stdout', text: ' ✖ FAILED at step ' + stepNum + ': ' + err.message });
418
- sendRun({ type: 'done', exitCode: 1, status: 'failed' });
419
- sendRun({ type: 'stdout', text: '<span style="color:var(--red)">✗ Replay failed — could not reach debug point</span>' });
420
- await browser.close().catch(() => {});
421
- ws.send(JSON.stringify({ type: 'jobDone', runId }));
422
- return;
426
+ const msg2 = err.message || '';
427
+ if (msg2.includes('closed') || msg2.includes('destroyed') || msg2.includes('detached')) {
428
+ browserClosed = true;
429
+ sendRun({ type: 'stdout', text: ' \u2716 Browser closed' });
430
+ } else {
431
+ sendRun({ type: 'stdout', text: ' \u26a0 Skipped: ' + msg2.slice(0, 100) });
432
+ }
423
433
  }
424
434
  }
425
435
 
426
436
  sendRun({ type: 'stdout', text: '' });
427
- sendRun({ type: 'stdout', text: ' Reached debug point starting recorder' });
428
-
429
- // Attach RecordingSession to the existing browser — injects toolbar into current page
430
- const screenshotDir = path.join(os.homedir(), '.skopix', 'recordings', recordingId);
431
- await fs.ensureDir(screenshotDir);
432
-
433
- const { RecordingSession } = await import('../../core/recorder.js');
434
- const session = new RecordingSession({ url: startUrl || msg.startUrl || '', sessionId: recordingId, screenshotDir });
435
-
436
- // Override emit to forward over WebSocket
437
- session.emit = (obj) => {
438
- sendRec(obj);
439
- if (obj.type === 'step') process.stdout.write(chalk.cyan(' ⏺ ') + (obj.step?.action || '') + '\n');
440
- if (obj.type === 'done' || obj.type === 'stopped') {
441
- console.log(chalk.green(' ✔ Debug recording done — ' + (obj.steps?.length || 0) + ' new steps'));
442
- browser.close().catch(() => {});
437
+ sendRun({ type: 'stdout', text: ' \u2714 Reached debug point \u2014 recording started' });
438
+ sendRun({ type: 'stdout', text: ' Use the browser, then click Stop.' });
439
+
440
+ // Wire up capture exposeFunction on context persists across navigations
441
+ await ctx.exposeFunction('__skopixCapture', async (actionData) => {
442
+ if (actionData.action === 'stop') {
443
+ // Send done to close replay SSE, then send recording done
444
+ sendRun({ type: 'done', exitCode: 0, status: 'passed', recordingId });
445
+ // Save steps
446
+ const screenshotDir = path.join(os.homedir(), '.skopix', 'recordings', recordingId);
447
+ sendRec({ type: 'done', steps });
448
+ try { await chromiumBrowser.close(); } catch {}
443
449
  ws.send(JSON.stringify({ type: 'jobDone', runId }));
444
- console.log(chalk.cyan(' Waiting for jobs\n'));
450
+ console.log(chalk.green(' \u2714 Debug recording done \u2014 ' + steps.length + ' new steps'));
451
+ console.log(chalk.cyan(' \u25c6 Waiting for jobs\n'));
452
+ return;
445
453
  }
446
- };
447
-
448
- // Attach to existing browser/context/page injects toolbar without new browser
449
- await session.attachTo(browser, ctx, page);
454
+ const id = 'step-' + String(steps.length + 1).padStart(3, '0') + '-dbg';
455
+ const recStep = {
456
+ id, action: actionData.action, assertType: actionData.assertType || null,
457
+ attribute: actionData.attribute || null,
458
+ selector: actionData.selector || (actionData.element ? actionData.element.selector : null),
459
+ element: actionData.element || null, value: actionData.value || null,
460
+ isPassword: actionData.isPassword || false, label: actionData.label || null,
461
+ clickX: actionData.clickX || null, clickY: actionData.clickY || null,
462
+ elementX: actionData.elementX || null, elementY: actionData.elementY || null,
463
+ description: actionData.description || null,
464
+ url: page ? page.url() : '', timestamp: Date.now(), stableSelector: null,
465
+ };
466
+ steps.push(recStep);
467
+ sendRec({ type: 'step', step: recStep });
468
+ process.stdout.write(chalk.cyan(' \u23fa ') + (recStep.action || '') + '\n');
469
+ try { await page.evaluate(n => { if (window.__skopixUpdateCount) window.__skopixUpdateCount(n); }, steps.length); } catch {}
470
+ // Screenshot
471
+ setTimeout(async () => {
472
+ try {
473
+ const screenshotDir = path.join(os.homedir(), '.skopix', 'recordings', recordingId);
474
+ await fs.ensureDir(screenshotDir);
475
+ const sp = path.join(screenshotDir, id + '.png');
476
+ await page.screenshot({ path: sp, fullPage: false }).catch(() => {});
477
+ recStep.screenshotPath = sp;
478
+ sendRec({ type: 'screenshot', stepId: id, path: sp });
479
+ } catch {}
480
+ }, 400);
481
+ });
450
482
 
451
- // Listen for stop signal from server
452
- const stopHandler = (event) => {
453
- let m; try { m = JSON.parse(event.data); } catch { return; }
454
- if (m.type === 'stopRecord' && m.recordingId === recordingId) {
455
- session.stop().catch(() => {});
456
- ws.removeEventListener('message', stopHandler);
483
+ // Inject toolbar + all listeners directly into live page via evaluate
484
+ // (same approach as solo debug mode in dashboard.js — no reload needed)
485
+ await page.evaluate(() => {
486
+ if (window.__skopixRecording) return;
487
+ window.__skopixRecording = true;
488
+
489
+ function getSelector(el) {
490
+ if (!el || el === document.body) return 'body';
491
+ const testAttrs = ['data-testid','data-test','pi-test-identifier','data-cy','data-qa'];
492
+ for (const attr of testAttrs) { const val = el.getAttribute(attr); if (val) return '['+attr+'="'+val+'"]'; }
493
+ if (el.id && !/^\d/.test(el.id)) return '#'+el.id;
494
+ const al = el.getAttribute('aria-label');
495
+ if (al && ['button','a','input'].includes(el.tagName.toLowerCase())) return el.tagName.toLowerCase()+'[aria-label="'+al+'"]';
496
+ if (el.name && el.tagName === 'INPUT') return 'input[name="'+el.name+'"]';
497
+ const parts = []; let cur = el, depth = 0;
498
+ while (cur && cur !== document.body && depth < 4) {
499
+ let seg = cur.tagName.toLowerCase();
500
+ if (cur.id && !/^\d/.test(cur.id)) { parts.unshift('#'+cur.id); break; }
501
+ const sib = Array.from(cur.parentElement ? cur.parentElement.children : []).filter(c => c.tagName === cur.tagName);
502
+ if (sib.length > 1) seg += ':nth-of-type('+(sib.indexOf(cur)+1)+')';
503
+ parts.unshift(seg); cur = cur.parentElement; depth++;
504
+ }
505
+ return parts.join(' > ');
457
506
  }
458
- };
459
- ws.addEventListener('message', stopHandler);
507
+ function getElementInfo(el) {
508
+ return { tag: el.tagName.toLowerCase(), id: el.id||null, name: el.name||null, type: el.type||null,
509
+ text: (el.innerText||el.value||el.placeholder||el.getAttribute('aria-label')||'').trim().slice(0,80),
510
+ selector: getSelector(el), classes: el.className ? el.className.toString().trim().slice(0,100) : null,
511
+ piTestId: el.getAttribute('pi-test-identifier')||null, title: el.getAttribute('title')||null,
512
+ ariaLabel: el.getAttribute('aria-label')||null, dataTestId: el.getAttribute('data-testid')||null };
513
+ }
514
+
515
+ document.addEventListener('click', function(e) {
516
+ if (e.target && e.target.closest) { if (e.target.closest('#__skopix_toolbar')) return; if (e.target.closest('#__skopix_popover')) return; if (e.target.closest('#__skopix_hint')) return; }
517
+ if (window.__skopixPickMode) return;
518
+ const el = e.target;
519
+ if (!el || el === document.body || el === document.documentElement) return;
520
+ const rect = el.getBoundingClientRect();
521
+ const isCheckable = el.type === 'checkbox' || el.type === 'radio';
522
+ let checkTarget = null;
523
+ if (!isCheckable && el.tagName === 'LABEL' && el.htmlFor) checkTarget = document.getElementById(el.htmlFor);
524
+ if (!isCheckable && el.tagName === 'LABEL' && !el.htmlFor) checkTarget = el.querySelector('input[type="checkbox"],input[type="radio"]');
525
+ const actual = isCheckable ? el : checkTarget;
526
+ if (actual && (actual.type === 'checkbox' || actual.type === 'radio')) {
527
+ window.__skopixCapture && window.__skopixCapture({ action:'check', checked:actual.checked, element:getElementInfo(actual), clickX:Math.round(e.clientX), clickY:Math.round(e.clientY), elementX:Math.round(rect.left+rect.width/2), elementY:Math.round(rect.top+rect.height/2) });
528
+ } else {
529
+ window.__skopixCapture && window.__skopixCapture({ action:'click', element:getElementInfo(el), clickX:Math.round(e.clientX), clickY:Math.round(e.clientY), elementX:Math.round(rect.left+rect.width/2), elementY:Math.round(rect.top+rect.height/2) });
530
+ }
531
+ }, true);
532
+
533
+ let typeTimer = null, lastInputEl = null;
534
+ document.addEventListener('input', function(e) {
535
+ const el = e.target;
536
+ if (!el || !['INPUT','TEXTAREA'].includes(el.tagName)) return;
537
+ if (el.type === 'checkbox' || el.type === 'radio') return;
538
+ if (el.closest && (el.closest('#__skopix_toolbar') || el.closest('#__skopix_popover'))) return;
539
+ lastInputEl = el; clearTimeout(typeTimer);
540
+ typeTimer = setTimeout(() => { if (!lastInputEl) return; window.__skopixCapture && window.__skopixCapture({ action:'type', element:getElementInfo(lastInputEl), value:lastInputEl.value, isPassword:lastInputEl.type==='password' }); lastInputEl = null; }, 600);
541
+ }, true);
542
+
543
+ document.addEventListener('change', function(e) {
544
+ const el = e.target; if (!el || el.tagName !== 'SELECT') return;
545
+ if (el.closest && (el.closest('#__skopix_toolbar') || el.closest('#__skopix_popover'))) return;
546
+ const sel2 = el.options[el.selectedIndex];
547
+ window.__skopixCapture && window.__skopixCapture({ action:'select', element:getElementInfo(el), value:el.value, label:sel2?sel2.text:el.value });
548
+ }, true);
549
+
550
+ // Toolbar
551
+ const tb = document.createElement('div');
552
+ tb.id = '__skopix_toolbar';
553
+ tb.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:2147483647;background:#0f1117;border:2px solid #f59e0b;border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:10px;font-family:monospace;font-size:12px;color:#e5e7eb;box-shadow:0 4px 24px rgba(0,0,0,0.6);user-select:none';
554
+ tb.innerHTML = '<span style="color:#f59e0b;font-size:14px;animation:skopix_pulse 1s infinite">\u25cf</span>'
555
+ + '<span style="color:#9ca3af">Debug recording</span>'
556
+ + '<span id="__skopix_count" style="color:#22d3ee;font-weight:700;min-width:20px;text-align:center">0</span>'
557
+ + '<span style="color:#4b5563">steps</span>'
558
+ + '<button id="__skopix_assert_btn" style="background:#1e3a5f;border:1px solid #2563eb;color:#60a5fa;border-radius:6px;padding:4px 10px;cursor:pointer;font-size:11px;font-family:monospace">+ Assert</button>'
559
+ + '<button id="__skopix_stop_btn" style="background:#3f0d0d;border:1px solid #dc2626;color:#f87171;border-radius:6px;padding:4px 10px;cursor:pointer;font-size:11px;font-family:monospace">\u25a0 Stop</button>';
560
+ const sty = document.createElement('style');
561
+ sty.textContent = '@keyframes skopix_pulse{0%,100%{opacity:1}50%{opacity:0.3}}';
562
+ document.head.appendChild(sty);
563
+ document.body.appendChild(tb);
564
+ tb.querySelector('#__skopix_stop_btn').addEventListener('click', e => { e.stopPropagation(); window.__skopixCapture && window.__skopixCapture({ action:'stop' }); });
565
+ window.__skopixUpdateCount = n => { const el = document.getElementById('__skopix_count'); if (el) el.textContent = n; };
566
+
567
+ // Assert picker
568
+ tb.querySelector('#__skopix_assert_btn').addEventListener('click', function(e) {
569
+ e.stopPropagation();
570
+ window.__skopixPickMode = true;
571
+ document.body.style.cursor = 'crosshair';
572
+ const hint = document.createElement('div'); hint.id = '__skopix_hint';
573
+ 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';
574
+ hint.textContent = 'Click any element to add an assertion \u2014 Esc to cancel';
575
+ document.body.appendChild(hint);
576
+ const overlay = document.createElement('div');
577
+ overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483646;pointer-events:auto;cursor:crosshair';
578
+ document.body.appendChild(overlay);
579
+ const hl = document.createElement('div');
580
+ hl.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';
581
+ document.body.appendChild(hl);
582
+ overlay.addEventListener('mousemove', function(e2) {
583
+ overlay.style.pointerEvents = 'none';
584
+ const el2 = document.elementFromPoint(e2.clientX, e2.clientY);
585
+ overlay.style.pointerEvents = 'auto';
586
+ if (!el2 || el2 === document.body) { hl.style.display = 'none'; return; }
587
+ const r = el2.getBoundingClientRect();
588
+ hl.style.display = 'block'; hl.style.top = r.top+'px'; hl.style.left = r.left+'px'; hl.style.width = r.width+'px'; hl.style.height = r.height+'px';
589
+ });
590
+ overlay.addEventListener('click', function(e2) {
591
+ e2.preventDefault(); e2.stopPropagation();
592
+ overlay.style.pointerEvents = 'none';
593
+ const el2 = document.elementFromPoint(e2.clientX, e2.clientY);
594
+ overlay.style.pointerEvents = 'auto';
595
+ window.__skopixPickMode = false; document.body.style.cursor = '';
596
+ overlay.remove(); const h2 = document.getElementById('__skopix_hint'); if (h2) h2.remove();
597
+ if (!el2 || el2 === document.body) { hl.style.display = 'none'; return; }
598
+ const sel2 = getSelector(el2);
599
+ const txt = (el2.innerText||el2.textContent||'').trim().slice(0,100);
600
+ const rect2 = el2.getBoundingClientRect();
601
+ hl.style.background = 'rgba(34,197,94,0.15)'; hl.style.borderColor = '#22c55e';
602
+ hl.style.display = 'block'; hl.style.top = rect2.top+'px'; hl.style.left = rect2.left+'px'; hl.style.width = rect2.width+'px'; hl.style.height = rect2.height+'px';
603
+ let sugType = 'visible', sugVal = '';
604
+ if (txt && txt.length > 0 && txt.length < 80) { sugType = 'text_contains'; sugVal = txt.replace(/\s+/g,' ').trim(); }
605
+ const popover = document.createElement('div'); popover.id = '__skopix_popover';
606
+ const topPos = rect2.bottom+8+280 > window.innerHeight ? Math.max(8,rect2.top-288) : rect2.bottom+8;
607
+ popover.style.cssText = 'position:fixed;z-index:2147483647;top:'+topPos+'px;left:'+Math.min(rect2.left,window.innerWidth-360)+'px;width:350px;background:#0f1117;border:1px solid #2563eb;border-radius:10px;padding:16px;font-family:monospace;font-size:12px;color:#e5e7eb;box-shadow:0 8px 32px rgba(0,0,0,0.7)';
608
+ popover.innerHTML = '<div style="color:#60a5fa;font-size:11px;letter-spacing:0.1em;margin-bottom:12px">ADD ASSERTION</div>'
609
+ + '<div style="margin-bottom:10px"><div style="color:#9ca3af;font-size:10px;margin-bottom:4px">ELEMENT</div><div style="background:#1a1d2e;padding:6px 10px;border-radius:6px;color:#22d3ee;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+sel2+'</div>'+(txt?'<div style="color:#6b7280;font-size:10px;margin-top:3px">Text: "'+txt.slice(0,50)+'"</div>':'')+'</div>'
610
+ + '<div style="margin-bottom:10px"><div style="color:#9ca3af;font-size:10px;margin-bottom:4px">TYPE</div><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"><option value="visible"'+(sugType==="visible"?" selected":"")+'>Element is visible</option><option value="text_contains"'+(sugType==="text_contains"?" selected":"")+'>Text contains</option><option value="text_equals">Text equals</option><option value="url_contains">URL contains</option><option value="element_count">Element count</option></select></div>'
611
+ + '<div id="__skopix_value_row" style="margin-bottom:10px;'+(sugType==="visible"?"display:none":"")+'"><div style="color:#9ca3af;font-size:10px;margin-bottom:4px">EXPECTED VALUE</div><input id="__skopix_assert_value" type="text" value="'+sugVal.replace(/"/g,"&quot;")+'" 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"></div>'
612
+ + '<div style="display:flex;gap:8px;justify-content:flex-end"><button id="__skopix_cancel_btn" 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><button id="__skopix_add_btn" 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 \u2713</button></div>';
613
+ document.body.appendChild(popover);
614
+ popover.querySelector('#__skopix_assert_type').addEventListener('change', function() {
615
+ const t = this.value; popover.querySelector('#__skopix_value_row').style.display = t==='visible'?'none':'';
616
+ });
617
+ popover.querySelector('#__skopix_cancel_btn').addEventListener('click', function(e3) { e3.stopPropagation(); hl.style.display='none'; popover.remove(); });
618
+ popover.querySelector('#__skopix_add_btn').addEventListener('click', function(e3) {
619
+ e3.stopPropagation();
620
+ const assertType = popover.querySelector('#__skopix_assert_type').value;
621
+ const value = popover.querySelector('#__skopix_assert_value').value.trim();
622
+ if (assertType !== 'visible' && assertType !== 'url_contains' && !value) { popover.querySelector('#__skopix_assert_value').style.borderColor='#dc2626'; return; }
623
+ window.__skopixCapture && window.__skopixCapture({ action:'assert', assertType, selector:assertType==='url_contains'?null:sel2, value:value||null, element:assertType==='url_contains'?null:{tag:el2.tagName.toLowerCase(),text:txt} });
624
+ hl.style.display='none'; popover.remove();
625
+ });
626
+ });
627
+ document.addEventListener('keydown', function escH(e2) { if (e2.key==='Escape') { window.__skopixPickMode=false; document.body.style.cursor=''; overlay.remove(); const h2=document.getElementById('__skopix_hint');if(h2)h2.remove(); hl.style.display='none'; document.removeEventListener('keydown',escH); } });
628
+ });
629
+ });
630
+
631
+ // Tell dashboard recording is live — frontend switches to recording SSE
632
+ sendRun({ type: 'done', exitCode: 0, status: 'passed', recordingId });
633
+ process.stderr.write('[debug] toolbar injected, waiting for user actions\n');
460
634
 
461
635
  } catch (err) {
462
- console.error(chalk.red(' Debug error: ' + err.message));
463
- sendRun({ type: 'stdout', text: ' Debug replay error: ' + err.message });
636
+ console.error(chalk.red(' \u2716 Debug error: ' + err.message));
637
+ sendRun({ type: 'stdout', text: ' \u2716 Debug replay error: ' + err.message });
464
638
  sendRun({ type: 'done', exitCode: 1, status: 'failed' });
465
- sendRun({ type: 'stdout', text: '<span style="color:var(--red)">✗ Replay failed — could not reach debug point</span>' });
639
+ sendRec({ type: 'stopped' });
640
+ if (chromiumBrowser) try { await chromiumBrowser.close(); } catch {}
466
641
  ws.send(JSON.stringify({ type: 'jobDone', runId }));
467
642
  }
468
643
  }
469
644
 
470
- // ── HELPERS ────────────────────────────────────────────────────────────────
645
+ // ── HELPERS ────────────────────────────────────────────────────────────────
471
646
  function sanitiseSelector(sel) {
472
647
  if (!sel) return sel;
473
648
  return sel.replace(/\[([a-zA-Z_-]+)="([^"]+\.\d{5,})"\]/g, (_, attr, val) => '[' + attr + '*="' + val.replace(/\.\d{5,}$/, '') + '"]');
package/core/recorder.js CHANGED
@@ -597,28 +597,27 @@ export class RecordingSession {
597
597
  this.emit({ type: 'ready' });
598
598
  }
599
599
 
600
- // Attach to an existing browser/context/page (used for debug mode)
601
- // instead of launching a fresh browser via launch()
600
+ // Attach to an existing browser/context/page (used for debug mode).
601
+ // Runs the same context setup as launch() then reloads so addInitScript fires —
602
+ // toolbar and capture listeners are fully wired up, just like a fresh recording.
602
603
  async attachTo(browser, context, page) {
603
604
  this.browser = browser;
604
605
  this.context = context;
605
606
  this.page = page;
606
607
 
607
- // Expose capture function on the existing context
608
+ // Same exposeFunction as launch() persists across all navigations
608
609
  await this.context.exposeFunction('__skopixCapture', async (actionData) => {
609
610
  if (this._stopping) return;
610
- if (actionData.action === 'stop') {
611
- await this.stop();
612
- return;
613
- }
611
+ if (actionData.action === 'stop') { await this.stop(); return; }
614
612
  await this._captureStep(actionData);
615
- }).catch(() => {}); // may already be exposed
613
+ }).catch(() => {});
616
614
 
617
- // Inject the init script into the existing page
615
+ // Same addInitScript as launch() fires on every page load including the reload below
618
616
  await this.context.addInitScript(() => {
619
617
  if (window.__skopixRecording) return;
620
618
  window.__skopixRecording = true;
621
619
 
620
+ // ─── Selector builder ─────────────────────────────────────────────────
622
621
  function getSelector(el) {
623
622
  if (!el || el === document.body) return 'body';
624
623
  const testAttrs = ['data-testid', 'data-test', 'pi-test-identifier', 'data-cy', 'data-qa'];
@@ -632,13 +631,17 @@ export class RecordingSession {
632
631
  return el.tagName.toLowerCase() + '[aria-label="' + ariaLabel + '"]';
633
632
  }
634
633
  if (el.name && el.tagName === 'INPUT') return 'input[name="' + el.name + '"]';
635
- const parts = []; let cur = el; let depth = 0;
634
+ const parts = [];
635
+ let cur = el;
636
+ let depth = 0;
636
637
  while (cur && cur !== document.body && depth < 4) {
637
638
  let seg = cur.tagName.toLowerCase();
638
639
  if (cur.id && !/^\d/.test(cur.id)) { parts.unshift('#' + cur.id); break; }
639
640
  const sib = Array.from(cur.parentElement ? cur.parentElement.children : []).filter(c => c.tagName === cur.tagName);
640
641
  if (sib.length > 1) seg += ':nth-of-type(' + (sib.indexOf(cur) + 1) + ')';
641
- parts.unshift(seg); cur = cur.parentElement; depth++;
642
+ parts.unshift(seg);
643
+ cur = cur.parentElement;
644
+ depth++;
642
645
  }
643
646
  return parts.join(' > ');
644
647
  }
@@ -648,42 +651,104 @@ export class RecordingSession {
648
651
  const title = el.getAttribute('title') || null;
649
652
  const ariaLabel = el.getAttribute('aria-label') || null;
650
653
  const dataTestId = el.getAttribute('data-testid') || el.getAttribute('data-test-id') || null;
651
- const info = { tag: el.tagName.toLowerCase(), id: el.id || null, name: el.name || null, type: el.type || null, text: (el.innerText || el.value || el.placeholder || ariaLabel || title || '').trim().slice(0, 80), selector: getSelector(el), classes: el.className ? el.className.toString().trim().slice(0, 100) : null, title, ariaLabel, piTestId, dataTestId };
654
+ const info = {
655
+ tag: el.tagName.toLowerCase(),
656
+ id: el.id || null,
657
+ name: el.name || null,
658
+ type: el.type || null,
659
+ text: (el.innerText || el.value || el.placeholder || ariaLabel || title || '').trim().slice(0, 80),
660
+ selector: getSelector(el),
661
+ classes: el.className ? el.className.toString().trim().slice(0, 100) : null,
662
+ title,
663
+ ariaLabel,
664
+ piTestId,
665
+ dataTestId,
666
+ };
667
+ // For icon elements with no meaningful ID/text, capture parent context for better selector generation
652
668
  const isIcon = ['i', 'span', 'svg'].includes(info.tag) && !info.id && !info.text && !piTestId;
653
- if (isIcon && el.parentElement) { const p = el.parentElement; info.parentTag = p.tagName.toLowerCase(); info.parentClasses = p.className ? p.className.toString().trim().slice(0, 100) : null; info.parentSelector = getSelector(p); info.parentAriaLabel = p.getAttribute('aria-label') || null; info.parentTitle = p.getAttribute('title') || null; info.parentTestId = p.getAttribute('pi-test-identifier') || p.getAttribute('data-testid') || null; }
669
+ if (isIcon && el.parentElement) {
670
+ const p = el.parentElement;
671
+ info.parentTag = p.tagName.toLowerCase();
672
+ info.parentClasses = p.className ? p.className.toString().trim().slice(0, 100) : null;
673
+ info.parentSelector = getSelector(p);
674
+ info.parentAriaLabel = p.getAttribute('aria-label') || null;
675
+ info.parentTitle = p.getAttribute('title') || null;
676
+ info.parentTestId = p.getAttribute('pi-test-identifier') || p.getAttribute('data-testid') || null;
677
+ }
654
678
  return info;
655
679
  }
656
680
 
681
+ // ─── Action listeners ─────────────────────────────────────────────────
657
682
  document.addEventListener('click', function(e) {
658
- if (e.target && e.target.closest) { if (e.target.closest('#__skopix_toolbar')) return; if (e.target.closest('#__skopix_popover')) return; if (e.target.closest('#__skopix_hint')) return; }
659
- if (window.__skopixPickMode) return;
683
+ // Don't capture clicks on our own toolbar, popover, or hint overlay
684
+ if (e.target && e.target.closest) {
685
+ if (e.target.closest('#__skopix_toolbar')) return;
686
+ if (e.target.closest('#__skopix_popover')) return;
687
+ if (e.target.closest('#__skopix_hint')) return;
688
+ }
689
+ if (window.__skopixPickMode) return; // picker handles its own clicks
660
690
  const el = e.target;
661
691
  if (!el || el === document.body || el === document.documentElement) return;
662
692
  const rect = el.getBoundingClientRect();
663
693
  if (window.__skopixCapture) {
694
+ // Detect checkboxes and radio buttons - use 'check' action
695
+ // The checked state is what it WILL BE after this click (it toggles)
664
696
  const isCheckable = el.type === 'checkbox' || el.type === 'radio';
697
+ // Also check if we clicked a label that controls a checkbox
665
698
  let checkTarget = null;
666
- if (!isCheckable && el.tagName === 'LABEL' && el.htmlFor) checkTarget = document.getElementById(el.htmlFor);
667
- if (!isCheckable && el.tagName === 'LABEL' && !el.htmlFor) checkTarget = el.querySelector('input[type="checkbox"], input[type="radio"]');
699
+ if (!isCheckable && el.tagName === 'LABEL' && el.htmlFor) {
700
+ checkTarget = document.getElementById(el.htmlFor);
701
+ }
702
+ if (!isCheckable && el.tagName === 'LABEL' && !el.htmlFor) {
703
+ checkTarget = el.querySelector('input[type="checkbox"], input[type="radio"]');
704
+ }
668
705
  const actualCheckable = isCheckable ? el : checkTarget;
669
706
  if (actualCheckable && (actualCheckable.type === 'checkbox' || actualCheckable.type === 'radio')) {
670
- window.__skopixCapture({ action: 'check', checked: actualCheckable.checked, element: getElementInfo(actualCheckable), clickX: Math.round(e.clientX), clickY: Math.round(e.clientY), elementX: Math.round(rect.left + rect.width / 2), elementY: Math.round(rect.top + rect.height / 2) });
707
+ // By the time the click event fires, checkbox is already toggled
708
+ // so .checked gives us the new state directly
709
+ window.__skopixCapture({
710
+ action: 'check',
711
+ checked: actualCheckable.checked,
712
+ element: getElementInfo(actualCheckable),
713
+ clickX: Math.round(e.clientX),
714
+ clickY: Math.round(e.clientY),
715
+ elementX: Math.round(rect.left + rect.width / 2),
716
+ elementY: Math.round(rect.top + rect.height / 2),
717
+ });
671
718
  } else {
672
- window.__skopixCapture({ action: 'click', element: getElementInfo(el), clickX: Math.round(e.clientX), clickY: Math.round(e.clientY), elementX: Math.round(rect.left + rect.width / 2), elementY: Math.round(rect.top + rect.height / 2) });
719
+ window.__skopixCapture({
720
+ action: 'click',
721
+ element: getElementInfo(el),
722
+ clickX: Math.round(e.clientX),
723
+ clickY: Math.round(e.clientY),
724
+ elementX: Math.round(rect.left + rect.width / 2),
725
+ elementY: Math.round(rect.top + rect.height / 2),
726
+ });
673
727
  }
674
728
  }
675
729
  }, true);
676
730
 
677
- let typeTimer = null, lastInputEl = null;
731
+ let typeTimer = null;
732
+ let lastInputEl = null;
678
733
  document.addEventListener('input', function(e) {
679
734
  const el = e.target;
680
735
  if (!el || !['INPUT', 'TEXTAREA'].includes(el.tagName)) return;
736
+ // Checkboxes and radio buttons are handled by the click listener, not type
681
737
  if (el.type === 'checkbox' || el.type === 'radio') return;
682
- if (el.closest && (el.closest('#__skopix_toolbar') || el.closest('#__skopix_popover'))) return;
683
- lastInputEl = el; clearTimeout(typeTimer);
738
+ if (el.closest && el.closest('#__skopix_toolbar')) return;
739
+ if (el.closest && el.closest('#__skopix_popover')) return;
740
+ lastInputEl = el;
741
+ clearTimeout(typeTimer);
684
742
  typeTimer = setTimeout(function() {
685
743
  if (!lastInputEl) return;
686
- if (window.__skopixCapture) window.__skopixCapture({ action: 'type', element: getElementInfo(lastInputEl), value: lastInputEl.value, isPassword: lastInputEl.type === 'password' });
744
+ if (window.__skopixCapture) {
745
+ window.__skopixCapture({
746
+ action: 'type',
747
+ element: getElementInfo(lastInputEl),
748
+ value: lastInputEl.value,
749
+ isPassword: lastInputEl.type === 'password',
750
+ });
751
+ }
687
752
  lastInputEl = null;
688
753
  }, 600);
689
754
  }, true);
@@ -691,89 +756,385 @@ export class RecordingSession {
691
756
  document.addEventListener('change', function(e) {
692
757
  const el = e.target;
693
758
  if (!el || el.tagName !== 'SELECT') return;
694
- if (el.closest && (el.closest('#__skopix_toolbar') || el.closest('#__skopix_popover'))) return;
759
+ if (el.closest && el.closest('#__skopix_toolbar')) return;
760
+ if (el.closest && el.closest('#__skopix_popover')) return;
695
761
  const selected = el.options[el.selectedIndex];
696
- if (window.__skopixCapture) window.__skopixCapture({ action: 'select', element: getElementInfo(el), value: selected ? selected.value : '', label: selected ? selected.text : '' });
762
+ if (window.__skopixCapture) {
763
+ window.__skopixCapture({
764
+ action: 'select',
765
+ element: getElementInfo(el),
766
+ value: el.value,
767
+ label: selected ? selected.text : el.value,
768
+ });
769
+ }
770
+ }, true);
771
+
772
+ // Scroll listener - debounced, captures final scroll position
773
+ let scrollTimer = null;
774
+ document.addEventListener('scroll', function(e) {
775
+ const el = e.target;
776
+ // Ignore scrolls on our own UI elements
777
+ if (el && el.closest) {
778
+ if (el.closest('#__skopix_toolbar')) return;
779
+ if (el.closest('#__skopix_popover')) return;
780
+ }
781
+ clearTimeout(scrollTimer);
782
+ scrollTimer = setTimeout(function() {
783
+ // Determine what was scrolled - the element itself or the window
784
+ const isWindow = el === document || el === document.documentElement || el === document.body;
785
+ const scrollLeft = isWindow ? window.scrollX : el.scrollLeft;
786
+ const scrollTop = isWindow ? window.scrollY : el.scrollTop;
787
+ // Only capture meaningful scrolls (ignore tiny accidental scrolls)
788
+ if (Math.abs(scrollTop) < 50 && Math.abs(scrollLeft) < 50) return;
789
+ const selector = isWindow ? 'window' : getSelector(el);
790
+ if (window.__skopixCapture) {
791
+ window.__skopixCapture({
792
+ action: 'scroll',
793
+ selector,
794
+ scrollX: Math.round(scrollLeft),
795
+ scrollY: Math.round(scrollTop),
796
+ isWindow,
797
+ element: isWindow ? null : getElementInfo(el),
798
+ });
799
+ }
800
+ }, 400);
697
801
  }, true);
698
802
 
803
+ // ─── Floating toolbar ─────────────────────────────────────────────────
699
804
  function createToolbar() {
700
805
  if (document.getElementById('__skopix_toolbar')) return;
806
+
701
807
  const toolbar = document.createElement('div');
702
808
  toolbar.id = '__skopix_toolbar';
703
- toolbar.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:2147483647;display:flex;align-items:center;gap:8px;background:#0d0d1a;border:1px solid rgba(0,212,255,0.3);border-radius:10px;padding:10px 14px;font-family:monospace;font-size:12px;box-shadow:0 4px 24px rgba(0,0,0,0.5);user-select:none;';
704
- toolbar.innerHTML = '<span style="color:#00d4ff;font-weight:600;letter-spacing:0.08em">SKOPIX</span><span id="__skopix_step_count" style="color:#5a6180;font-size:11px">0 steps</span><button id="__skopix_assert_btn" style="background:#1a1d2e;border:1px solid rgba(0,212,255,0.2);color:#00d4ff;border-radius:6px;padding:5px 10px;cursor:pointer;font-family:monospace;font-size:11px">+ Assert</button><button id="__skopix_stop_btn" style="background:#1a1d2e;border:1px solid rgba(239,68,68,0.3);color:#ef4444;border-radius:6px;padding:5px 10px;cursor:pointer;font-family:monospace;font-size:11px">■ Stop</button>';
809
+ toolbar.style.cssText = [
810
+ 'position:fixed', 'bottom:20px', 'right:20px', 'z-index:2147483647',
811
+ 'background:#0f1117', 'border:1px solid #dc2626', 'border-radius:10px',
812
+ 'padding:10px 14px', 'display:flex', 'align-items:center', 'gap:10px',
813
+ 'font-family:monospace', 'font-size:12px', 'color:#e5e7eb',
814
+ 'box-shadow:0 4px 24px rgba(0,0,0,0.6)', 'user-select:none',
815
+ 'transition:opacity 0.2s',
816
+ ].join(';');
817
+
818
+ toolbar.innerHTML = `
819
+ <span style="color:#dc2626;font-size:14px;animation:skopix_pulse 1s infinite">●</span>
820
+ <span style="color:#9ca3af">Recording</span>
821
+ <span id="__skopix_count" style="color:#22d3ee;font-weight:700;min-width:20px;text-align:center">0</span>
822
+ <span style="color:#4b5563">steps</span>
823
+ <button id="__skopix_assert_btn" style="
824
+ background:#1e3a5f;border:1px solid #2563eb;color:#60a5fa;
825
+ border-radius:6px;padding:4px 10px;cursor:pointer;font-size:11px;font-family:monospace;
826
+ ">+ Assert</button>
827
+ <button id="__skopix_stop_btn" style="
828
+ background:#3f0d0d;border:1px solid #dc2626;color:#f87171;
829
+ border-radius:6px;padding:4px 10px;cursor:pointer;font-size:11px;font-family:monospace;
830
+ ">■ Stop</button>
831
+ `;
832
+
833
+ const style = document.createElement('style');
834
+ style.textContent = '@keyframes skopix_pulse{0%,100%{opacity:1}50%{opacity:0.3}}';
835
+ document.head.appendChild(style);
705
836
  document.body.appendChild(toolbar);
706
- document.getElementById('__skopix_stop_btn').addEventListener('click', function() { if (window.__skopixCapture) window.__skopixCapture({ action: 'stop' }); });
707
- document.getElementById('__skopix_assert_btn').addEventListener('click', function() { window.__skopixPickMode = true; document.body.style.cursor = 'crosshair'; });
708
- document.addEventListener('click', function assertPick(e) {
709
- if (!window.__skopixPickMode) return;
710
- if (e.target.closest && (e.target.closest('#__skopix_toolbar') || e.target.closest('#__skopix_popover'))) return;
711
- e.stopPropagation(); e.preventDefault();
712
- window.__skopixPickMode = false; document.body.style.cursor = '';
713
- const el = e.target; const sel = getSelector(el); const currentText = (el.innerText || el.value || '').trim().slice(0, 80);
714
- const suggestedType = el.tagName === 'IMG' ? 'visible' : currentText ? 'text_contains' : 'visible';
715
- const suggestedValue = currentText || '';
716
- let highlightEl = document.getElementById('__skopix_highlight');
717
- if (!highlightEl) { highlightEl = document.createElement('div'); highlightEl.id = '__skopix_highlight'; highlightEl.style.cssText = 'position:fixed;pointer-events:none;z-index:2147483646;border:2px solid #3b82f6;background:rgba(59,130,246,0.1);border-radius:4px;transition:all 0.1s;'; document.body.appendChild(highlightEl); }
718
- const rect = el.getBoundingClientRect(); highlightEl.style.cssText += 'left:' + rect.left + 'px;top:' + rect.top + 'px;width:' + rect.width + 'px;height:' + rect.height + 'px;display:block;';
719
- let popover = document.getElementById('__skopix_popover');
720
- if (popover) popover.remove();
721
- popover = document.createElement('div'); popover.id = '__skopix_popover';
722
- popover.style.cssText = 'position:fixed;z-index:2147483647;background:#0d0d1a;border:1px solid rgba(59,130,246,0.4);border-radius:10px;padding:16px;width:320px;box-shadow:0 8px 32px rgba(0,0,0,0.6);font-family:monospace;font-size:12px;color:#e5e7eb;';
723
- const vw = window.innerWidth, vh = window.innerHeight;
724
- const top = rect.bottom + 10 < vh - 200 ? rect.bottom + 10 : rect.top - 220;
725
- const left = Math.min(Math.max(rect.left, 10), vw - 340);
726
- popover.style.top = top + 'px'; popover.style.left = left + 'px';
727
- popover.innerHTML = '<div style="color:#60a5fa;font-size:11px;letter-spacing:0.1em;margin-bottom:12px">ADD ASSERTION</div><div style="margin-bottom:10px"><div style="color:#9ca3af;font-size:10px;margin-bottom:4px">SELECTED ELEMENT</div><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>' + (currentText ? '<div style="color:#6b7280;font-size:10px;margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">Current text: &quot;' + currentText.slice(0, 50) + (currentText.length > 50 ? '...' : '') + '&quot;</div>' : '') + '</div><div style="margin-bottom:10px"><div style="color:#9ca3af;font-size:10px;margin-bottom:4px">ASSERTION TYPE</div><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"><option value="visible"' + (suggestedType === "visible" ? " selected" : "") + '>Element is visible</option><option value="text_contains"' + (suggestedType === "text_contains" ? " selected" : "") + '>Text contains</option><option value="text_equals">Text equals</option><option value="url_contains">URL contains</option><option value="element_count">Element count</option><option value="attribute_contains">Attribute contains</option></select></div><div id="__skopix_value_row" style="margin-bottom:10px;' + (suggestedType === "visible" ? "display:none" : "") + '"><div style="color:#9ca3af;font-size:10px;margin-bottom:4px" id="__skopix_value_label">EXPECTED VALUE</div><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"></div><div style="display:flex;gap:8px;justify-content:flex-end"><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><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></div>';
728
- document.body.appendChild(popover);
729
- popover.querySelector('#__skopix_assert_cancel').addEventListener('click', function(e) { e.stopPropagation(); if (highlightEl) highlightEl.style.display = 'none'; popover.remove(); });
730
- popover.querySelector('#__skopix_assert_add').addEventListener('click', function(e) {
731
- e.stopPropagation();
732
- const assertType = popover.querySelector('#__skopix_assert_type').value;
733
- const value = popover.querySelector('#__skopix_assert_value') ? popover.querySelector('#__skopix_assert_value').value.trim() : '';
734
- if (assertType !== 'visible' && assertType !== 'url_contains' && !value) { popover.querySelector('#__skopix_assert_value').style.borderColor = '#dc2626'; return; }
735
- if (window.__skopixCapture) window.__skopixCapture({ action: 'assert', assertType, selector: assertType === 'url_contains' ? null : sel, value: value || null, element: assertType === 'url_contains' ? null : getElementInfo(el) });
736
- if (highlightEl) highlightEl.style.display = 'none'; popover.remove();
737
- });
738
- }, true);
837
+
838
+ document.getElementById('__skopix_stop_btn').addEventListener('click', function(e) {
839
+ e.stopPropagation();
840
+ if (window.__skopixCapture) window.__skopixCapture({ action: 'stop' });
841
+ });
842
+
843
+ document.getElementById('__skopix_assert_btn').addEventListener('click', function(e) {
844
+ e.stopPropagation();
845
+ startPickMode();
846
+ });
739
847
  }
740
848
 
741
- if (document.body) { createToolbar(); } else { document.addEventListener('DOMContentLoaded', createToolbar); }
742
- new MutationObserver(() => { if (!document.getElementById('__skopix_toolbar')) createToolbar(); }).observe(document.documentElement, { childList: true, subtree: false });
743
- window.__skopixUpdateCount = function(n) { const el = document.getElementById('__skopix_step_count'); if (el) el.textContent = n + ' step' + (n === 1 ? '' : 's'); };
744
- });
849
+ function updateStepCount(n) {
850
+ const el = document.getElementById('__skopix_count');
851
+ if (el) el.textContent = n;
852
+ }
745
853
 
746
- // Re-evaluate init script on the current page (since addInitScript only runs on navigation)
747
- // Use evaluate to inject directly into the live page without reloading
748
- try {
749
- await this.page.evaluate(() => {
750
- if (window.__skopixRecording) return;
751
- window.__skopixRecording = true;
752
- // Toolbar will be injected via addInitScript on next navigation,
753
- // but we need it now — dispatch a custom event to trigger creation
754
- const event = new CustomEvent('__skopix_attach');
755
- document.dispatchEvent(event);
756
- });
757
- } catch {}
854
+ // ─── Element picker mode ──────────────────────────────────────────────
855
+ let pickerOverlay = null;
856
+ let lastHovered = null;
758
857
 
759
- // Directly inject the toolbar into the current live page
760
- try {
761
- await this.page.evaluate(() => {
762
- if (document.getElementById('__skopix_toolbar')) return;
763
- const toolbar = document.createElement('div');
764
- toolbar.id = '__skopix_toolbar';
765
- toolbar.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:2147483647;display:flex;align-items:center;gap:8px;background:#0d0d1a;border:1px solid rgba(0,212,255,0.3);border-radius:10px;padding:10px 14px;font-family:monospace;font-size:12px;box-shadow:0 4px 24px rgba(0,0,0,0.5);user-select:none;';
766
- toolbar.innerHTML = '<span style="color:#00d4ff;font-weight:600;letter-spacing:0.08em">SKOPIX</span><span id="__skopix_step_count" style="color:#5a6180;font-size:11px">0 steps</span><button id="__skopix_assert_btn" style="background:#1a1d2e;border:1px solid rgba(0,212,255,0.2);color:#00d4ff;border-radius:6px;padding:5px 10px;cursor:pointer;font-family:monospace;font-size:11px">+ Assert</button><button id="__skopix_stop_btn" style="background:#1a1d2e;border:1px solid rgba(239,68,68,0.3);color:#ef4444;border-radius:6px;padding:5px 10px;cursor:pointer;font-family:monospace;font-size:11px">■ Stop</button>';
767
- document.body.appendChild(toolbar);
768
- document.getElementById('__skopix_stop_btn').addEventListener('click', function() {
769
- if (window.__skopixCapture) window.__skopixCapture({ action: 'stop' });
858
+ function startPickMode() {
859
+ window.__skopixPickMode = true;
860
+ document.body.style.cursor = 'crosshair';
861
+
862
+ // Dim the toolbar
863
+ const tb = document.getElementById('__skopix_toolbar');
864
+ if (tb) tb.style.opacity = '0.5';
865
+
866
+ // Show hint
867
+ const hint = document.createElement('div');
868
+ hint.id = '__skopix_hint';
869
+ 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';
870
+ hint.textContent = 'Click any element to add an assertion';
871
+ document.body.appendChild(hint);
872
+
873
+ pickerOverlay = document.createElement('div');
874
+ pickerOverlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483646;pointer-events:auto;cursor:crosshair';
875
+ document.body.appendChild(pickerOverlay);
876
+
877
+ let highlight = document.createElement('div');
878
+ 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';
879
+ document.body.appendChild(highlight);
880
+
881
+ pickerOverlay.addEventListener('mousemove', function(e) {
882
+ pickerOverlay.style.pointerEvents = 'none';
883
+ const el = document.elementFromPoint(e.clientX, e.clientY);
884
+ pickerOverlay.style.pointerEvents = 'auto';
885
+ if (!el || el === document.body || el.id === '__skopix_toolbar') {
886
+ highlight.style.display = 'none';
887
+ return;
888
+ }
889
+ lastHovered = el;
890
+ const r = el.getBoundingClientRect();
891
+ highlight.style.display = 'block';
892
+ highlight.style.top = r.top + 'px';
893
+ highlight.style.left = r.left + 'px';
894
+ highlight.style.width = r.width + 'px';
895
+ highlight.style.height = r.height + 'px';
896
+ });
897
+
898
+ pickerOverlay.addEventListener('click', function(e) {
899
+ e.preventDefault();
900
+ e.stopPropagation();
901
+ pickerOverlay.style.pointerEvents = 'none';
902
+ const el = document.elementFromPoint(e.clientX, e.clientY);
903
+ pickerOverlay.style.pointerEvents = 'auto';
904
+ if (!el || el === document.body) { stopPickMode(); return; }
905
+
906
+ stopPickMode();
907
+ showAssertionPopover(el, highlight);
908
+ });
909
+
910
+ document.addEventListener('keydown', function escHandler(e) {
911
+ if (e.key === 'Escape') { stopPickMode(); document.removeEventListener('keydown', escHandler); }
912
+ });
913
+ }
914
+
915
+ function stopPickMode() {
916
+ window.__skopixPickMode = false;
917
+ document.body.style.cursor = '';
918
+ if (pickerOverlay) { pickerOverlay.remove(); pickerOverlay = null; }
919
+ const hint = document.getElementById('__skopix_hint');
920
+ if (hint) hint.remove();
921
+ const tb = document.getElementById('__skopix_toolbar');
922
+ if (tb) tb.style.opacity = '1';
923
+ }
924
+
925
+ // ─── Assertion popover ────────────────────────────────────────────────
926
+ function showAssertionPopover(el, highlightEl) {
927
+ const existing = document.getElementById('__skopix_popover');
928
+ if (existing) existing.remove();
929
+
930
+ const sel = getSelector(el);
931
+ const currentText = (el.innerText || el.textContent || '').trim().slice(0, 100);
932
+ const tag = el.tagName.toLowerCase();
933
+ const rect = el.getBoundingClientRect();
934
+
935
+ // Smart defaults
936
+ let suggestedType = 'visible';
937
+ let suggestedValue = '';
938
+
939
+ // If element has a title/alt/aria-label, suggest attribute_contains
940
+ const titleAttr = el.getAttribute('title') || el.getAttribute('alt');
941
+ if (titleAttr && titleAttr.length > 0) {
942
+ suggestedType = 'attribute_contains';
943
+ suggestedValue = titleAttr.slice(0, 80);
944
+ } else if (currentText && currentText.length > 0 && currentText.length < 80) {
945
+ suggestedType = 'text_contains';
946
+ // For numbers, suggest exact value. For text, suggest contains.
947
+ suggestedValue = currentText.replace(/\s+/g, ' ').trim();
948
+ }
949
+ // Count suggestion for tables/lists
950
+ if (['table', 'tbody', 'ul', 'ol'].includes(tag) || el.querySelectorAll('tr, li').length > 1) {
951
+ const rows = el.querySelectorAll('tr:not(thead tr), li').length;
952
+ if (rows > 0) { suggestedType = 'element_count'; suggestedValue = String(rows); }
953
+ }
954
+
955
+ // Highlight selected element in green
956
+ if (highlightEl) {
957
+ highlightEl.style.background = 'rgba(34,197,94,0.15)';
958
+ highlightEl.style.borderColor = '#22c55e';
959
+ highlightEl.style.display = 'block';
960
+ highlightEl.style.top = rect.top + 'px';
961
+ highlightEl.style.left = rect.left + 'px';
962
+ highlightEl.style.width = rect.width + 'px';
963
+ highlightEl.style.height = rect.height + 'px';
964
+ }
965
+
966
+ const popover = document.createElement('div');
967
+ popover.id = '__skopix_popover';
968
+
969
+ // Position popover — prefer below element, fall back to above
970
+ const popHeight = 280;
971
+ const topPos = rect.bottom + 8 + popHeight > window.innerHeight
972
+ ? Math.max(8, rect.top - popHeight - 8)
973
+ : rect.bottom + 8;
974
+ const leftPos = Math.min(rect.left, window.innerWidth - 360);
975
+
976
+ popover.style.cssText = [
977
+ 'position:fixed', 'z-index:2147483647',
978
+ 'top:' + topPos + 'px', 'left:' + leftPos + 'px',
979
+ 'width:350px', 'background:#0f1117',
980
+ 'border:1px solid #2563eb', 'border-radius:10px',
981
+ 'padding:16px', 'font-family:monospace', 'font-size:12px', 'color:#e5e7eb',
982
+ 'box-shadow:0 8px 32px rgba(0,0,0,0.7)',
983
+ ].join(';');
984
+
985
+ popover.innerHTML = `
986
+ <div style="color:#60a5fa;font-size:11px;letter-spacing:0.1em;margin-bottom:12px">ADD ASSERTION</div>
987
+
988
+ <div style="margin-bottom:10px">
989
+ <div style="color:#9ca3af;font-size:10px;margin-bottom:4px">SELECTED ELEMENT</div>
990
+ <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>
991
+ ${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>' : ''}
992
+ </div>
993
+
994
+ <div style="margin-bottom:10px">
995
+ <div style="color:#9ca3af;font-size:10px;margin-bottom:4px">ASSERTION TYPE</div>
996
+ <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">
997
+ <option value="visible"${suggestedType==='visible'?' selected':''}>Element is visible</option>
998
+ <option value="text_contains"${suggestedType==='text_contains'?' selected':''}>Text contains</option>
999
+ <option value="text_equals"${suggestedType==='text_equals'?' selected':''}>Text equals</option>
1000
+ <option value="url_contains">URL contains</option>
1001
+ <option value="element_count"${suggestedType==='element_count'?' selected':''}>Element count</option>
1002
+ <option value="attribute_contains">Attribute contains (title, alt, etc.)</option>
1003
+ </select>
1004
+ </div>
1005
+
1006
+ <div id="__skopix_attr_row" style="margin-bottom:10px;display:none">
1007
+ <div style="color:#9ca3af;font-size:10px;margin-bottom:4px">ATTRIBUTE NAME</div>
1008
+ <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">
1009
+ </div>
1010
+
1011
+ <div id="__skopix_value_row" style="margin-bottom:10px;${suggestedType==='visible'?'display:none':''}">
1012
+ <div style="color:#9ca3af;font-size:10px;margin-bottom:4px" id="__skopix_value_label">EXPECTED VALUE</div>
1013
+ <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">
1014
+ </div>
1015
+
1016
+ <div style="margin-bottom:14px">
1017
+ <div style="color:#9ca3af;font-size:10px;margin-bottom:4px">DESCRIPTION (optional)</div>
1018
+ <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">
1019
+ </div>
1020
+
1021
+ <div style="display:flex;gap:8px;justify-content:flex-end">
1022
+ <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>
1023
+ <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>
1024
+ </div>
1025
+ `;
1026
+
1027
+ document.body.appendChild(popover);
1028
+
1029
+ // Show/hide value field based on type
1030
+ const typeSelect = popover.querySelector('#__skopix_assert_type');
1031
+ const valueRow = popover.querySelector('#__skopix_value_row');
1032
+ const valueLabel = popover.querySelector('#__skopix_value_label');
1033
+ const valueInput = popover.querySelector('#__skopix_assert_value');
1034
+ const attrRow = popover.querySelector('#__skopix_attr_row');
1035
+
1036
+ typeSelect.addEventListener('change', function() {
1037
+ const t = typeSelect.value;
1038
+ attrRow.style.display = t === 'attribute_contains' ? 'block' : 'none';
1039
+ if (t === 'visible') {
1040
+ valueRow.style.display = 'none';
1041
+ } else {
1042
+ valueRow.style.display = 'block';
1043
+ if (t === 'element_count') {
1044
+ valueLabel.textContent = 'EXPECTED COUNT (number)';
1045
+ valueInput.placeholder = 'e.g. 9';
1046
+ } else if (t === 'url_contains') {
1047
+ valueLabel.textContent = 'URL MUST CONTAIN';
1048
+ valueInput.placeholder = 'e.g. /dashboard';
1049
+ valueInput.value = '';
1050
+ } else if (t === 'attribute_contains') {
1051
+ valueLabel.textContent = 'ATTRIBUTE VALUE MUST CONTAIN';
1052
+ valueInput.placeholder = 'e.g. SVG equivalent';
1053
+ // Pre-fill with the title attribute value if it exists
1054
+ const titleVal = el.getAttribute('title') || el.getAttribute('alt') || el.getAttribute('aria-label') || '';
1055
+ if (titleVal && !valueInput.value) valueInput.value = titleVal.slice(0, 80);
1056
+ } else {
1057
+ valueLabel.textContent = 'EXPECTED VALUE';
1058
+ valueInput.placeholder = 'Expected text...';
1059
+ }
1060
+ }
770
1061
  });
771
- document.getElementById('__skopix_assert_btn').addEventListener('click', function() {
772
- window.__skopixPickMode = true;
773
- document.body.style.cursor = 'crosshair';
1062
+
1063
+ popover.querySelector('#__skopix_assert_cancel').addEventListener('click', function(e) {
1064
+ e.stopPropagation();
1065
+ if (highlightEl) highlightEl.style.display = 'none';
1066
+ popover.remove();
774
1067
  });
1068
+
1069
+ popover.querySelector('#__skopix_assert_add').addEventListener('click', function(e) {
1070
+ e.stopPropagation();
1071
+ const assertType = typeSelect.value;
1072
+ const value = popover.querySelector('#__skopix_assert_value').value.trim();
1073
+ const description = popover.querySelector('#__skopix_assert_desc').value.trim();
1074
+
1075
+ if (assertType !== 'visible' && assertType !== 'url_contains' && !value) {
1076
+ popover.querySelector('#__skopix_assert_value').style.borderColor = '#dc2626';
1077
+ return;
1078
+ }
1079
+
1080
+ if (window.__skopixCapture) {
1081
+ const attrInput = popover.querySelector('#__skopix_assert_attr');
1082
+ window.__skopixCapture({
1083
+ action: 'assert',
1084
+ assertType,
1085
+ attribute: assertType === 'attribute_contains' ? (attrInput ? attrInput.value.trim() || 'title' : 'title') : null,
1086
+ selector: assertType === 'url_contains' ? null : sel,
1087
+ value: value || null,
1088
+ description: description || null,
1089
+ element: assertType === 'url_contains' ? null : getElementInfo(el),
1090
+ });
1091
+ }
1092
+
1093
+ if (highlightEl) highlightEl.style.display = 'none';
1094
+ popover.remove();
1095
+
1096
+ // Flash the assert button green briefly to confirm
1097
+ const assertBtn = document.getElementById('__skopix_assert_btn');
1098
+ if (assertBtn) {
1099
+ const orig = assertBtn.style.cssText;
1100
+ assertBtn.textContent = '✓ Added';
1101
+ assertBtn.style.background = '#14532d';
1102
+ assertBtn.style.borderColor = '#22c55e';
1103
+ assertBtn.style.color = '#4ade80';
1104
+ setTimeout(() => { assertBtn.textContent = '+ Assert'; assertBtn.style.cssText = orig; }, 1500);
1105
+ }
1106
+ });
1107
+
1108
+ // Focus the value input if visible
1109
+ setTimeout(() => {
1110
+ if (valueRow.style.display !== 'none') valueInput.focus();
1111
+ }, 50);
1112
+ }
1113
+
1114
+ // Boot the toolbar once DOM is ready
1115
+ if (document.body) {
1116
+ createToolbar();
1117
+ } else {
1118
+ document.addEventListener('DOMContentLoaded', createToolbar);
1119
+ }
1120
+
1121
+ // Re-create toolbar after navigation if it got wiped
1122
+ new MutationObserver(() => {
1123
+ if (!document.getElementById('__skopix_toolbar')) createToolbar();
1124
+ }).observe(document.documentElement, { childList: true, subtree: false });
1125
+
1126
+ // Listen for step count updates from parent
1127
+ window.__skopixUpdateCount = function(n) { updateStepCount(n); };
1128
+ });
1129
+
1130
+ this.context.on('page', (newPage) => {
1131
+ newPage.on('framenavigated', async (frame) => {
1132
+ if (frame !== newPage.mainFrame()) return;
1133
+ const url = frame.url();
1134
+ if (url === 'about:blank') return;
1135
+ this.emit({ type: 'navigate', url });
775
1136
  });
776
- } catch {}
1137
+ });
777
1138
 
778
1139
  this.page.on('framenavigated', async (frame) => {
779
1140
  if (frame !== this.page.mainFrame()) return;
@@ -785,10 +1146,12 @@ export class RecordingSession {
785
1146
  }, 500);
786
1147
  });
787
1148
 
1149
+ // Reload so addInitScript fires — injects toolbar + all capture listeners into the page
1150
+ await this.page.reload({ waitUntil: 'domcontentloaded', timeout: 30000 });
788
1151
  this.emit({ type: 'ready' });
789
1152
  }
790
1153
 
791
- async _captureStep(actionData) {
1154
+ async _captureStep(actionData) {
792
1155
  const id = this.nextId();
793
1156
  const page = this.page;
794
1157
  const step = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skopix",
3
- "version": "2.0.104",
3
+ "version": "2.0.106",
4
4
  "description": "Browser-based QA tool — record tests by using your app, replay them deterministically, generate Playwright code automatically",
5
5
  "main": "cli/index.js",
6
6
  "bin": {