skopix 2.0.107 → 2.0.108

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.
Files changed (2) hide show
  1. package/cli/commands/agent.js +184 -35
  2. package/package.json +1 -1
@@ -387,7 +387,8 @@ export async function agentCommand(options) {
387
387
  const startUrl = (msg.startUrl && msg.startUrl.startsWith('http')) ? msg.startUrl : null;
388
388
  console.log(chalk.cyan(' ⏸ Debug replay: ') + chalk.white(replaySteps.length + ' steps') + (startUrl ? chalk.dim(' @ ' + startUrl) : ''));
389
389
 
390
- const sendRun = (data) => { try { ws.send(JSON.stringify({ type: 'runUpdate', runId, data })); } catch {} };
390
+ // IMPORTANT: dashboard listens for 'jobUpdate' (replay) and 'recordingUpdate' (recording)
391
+ const sendRun = (data) => { try { ws.send(JSON.stringify({ type: 'jobUpdate', runId, data })); } catch {} };
391
392
  const sendRec = (data) => { try { ws.send(JSON.stringify({ type: 'recordingUpdate', recordingId, data })); } catch {} };
392
393
  const steps = [];
393
394
  let chromiumBrowser = null;
@@ -404,10 +405,45 @@ export async function agentCommand(options) {
404
405
  ctx = await chromiumBrowser.newContext({ viewport: { width: 1280, height: 800 } });
405
406
  page = await ctx.newPage();
406
407
 
407
- // Wire up capture FIRST — must be on context before any page loads
408
+ if (startUrl) {
409
+ await page.goto(startUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
410
+ await page.waitForTimeout(1000);
411
+ }
412
+
413
+ sendRun({ type: 'stdout', text: ' Replaying ' + replaySteps.length + ' steps...' });
414
+
415
+ // Replay each step to the debug point (no toolbar yet — matches original solo flow)
416
+ let stepNum = 0;
417
+ let browserClosed = false;
418
+ for (const step of replaySteps) {
419
+ if (browserClosed) break;
420
+ stepNum++;
421
+ const sel = sanitiseSelector(step.stableSelector || step.selector);
422
+ const desc = step.description || (step.action + ' ' + (sel || ''));
423
+ sendRun({ type: 'stdout', text: ' [' + stepNum + '/' + replaySteps.length + '] ' + step.action.toUpperCase() + ' \u2014 ' + desc });
424
+ try {
425
+ await executeStep(step, sel, page, { url: startUrl || '' });
426
+ sendRun({ type: 'stdout', text: ' \u2713 Done' });
427
+ } catch (err) {
428
+ const emsg = err.message || '';
429
+ if (emsg.includes('closed') || emsg.includes('destroyed') || emsg.includes('detached')) {
430
+ browserClosed = true;
431
+ sendRun({ type: 'stdout', text: ' \u2716 Browser closed \u2014 stopping replay early' });
432
+ } else {
433
+ sendRun({ type: 'stdout', text: ' \u26a0 Step ' + stepNum + ' skipped: ' + emsg.slice(0, 100) });
434
+ }
435
+ }
436
+ }
437
+
438
+ sendRun({ type: 'stdout', text: '' });
439
+ sendRun({ type: 'stdout', text: '\u2501'.repeat(60) });
440
+ sendRun({ type: 'stdout', text: ' \u2714 Reached step ' + replaySteps.length + ' \u2014 browser is ready' });
441
+ sendRun({ type: 'stdout', text: ' Now recording. Use the browser, then click Stop.' });
442
+ sendRun({ type: 'stdout', text: '\u2501'.repeat(60) });
443
+
444
+ // Wire up capture on context — persists across navigations
408
445
  await ctx.exposeFunction('__skopixCapture', async (actionData) => {
409
446
  if (actionData.action === 'stop') {
410
- // Mirror stopDebugRecording: send done with steps, then stopped
411
447
  sendRec({ type: 'done', steps });
412
448
  sendRec({ type: 'stopped' });
413
449
  try { await ctx.close(); } catch {}
@@ -445,13 +481,8 @@ export async function agentCommand(options) {
445
481
  }, 400);
446
482
  });
447
483
 
448
- if (startUrl) {
449
- await page.goto(startUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
450
- await page.waitForTimeout(500);
451
- }
452
-
453
- // Inject toolbar into page BEFORE replay — visible throughout
454
- await page.evaluate(() => {
484
+ // addInitScript so toolbar re-injects on any future navigation during recording
485
+ await ctx.addInitScript(() => {
455
486
  if (window.__skopixRecording) return;
456
487
  window.__skopixRecording = true;
457
488
 
@@ -597,38 +628,156 @@ export async function agentCommand(options) {
597
628
  });
598
629
  });
599
630
 
631
+ // Inject toolbar into the CURRENT page immediately (addInitScript only fires on nav)
632
+ await page.evaluate(() => {
633
+ if (window.__skopixRecording) return;
634
+ window.__skopixRecording = true;
600
635
 
636
+ function getSelector(el) {
637
+ if (!el || el === document.body) return 'body';
638
+ const testAttrs = ['data-testid','data-test','pi-test-identifier','data-cy','data-qa'];
639
+ for (const attr of testAttrs) { const val = el.getAttribute(attr); if (val) return '['+attr+'="'+val+'"]'; }
640
+ if (el.id && !/^\d/.test(el.id)) return '#'+el.id;
641
+ const al = el.getAttribute('aria-label');
642
+ if (al && ['button','a','input'].includes(el.tagName.toLowerCase())) return el.tagName.toLowerCase()+'[aria-label="'+al+'"]';
643
+ if (el.name && el.tagName === 'INPUT') return 'input[name="'+el.name+'"]';
644
+ const parts = []; let cur = el, depth = 0;
645
+ while (cur && cur !== document.body && depth < 4) {
646
+ let seg = cur.tagName.toLowerCase();
647
+ if (cur.id && !/^\d/.test(cur.id)) { parts.unshift('#'+cur.id); break; }
648
+ const sib = Array.from(cur.parentElement ? cur.parentElement.children : []).filter(c => c.tagName === cur.tagName);
649
+ if (sib.length > 1) seg += ':nth-of-type('+(sib.indexOf(cur)+1)+')';
650
+ parts.unshift(seg); cur = cur.parentElement; depth++;
651
+ }
652
+ return parts.join(' > ');
653
+ }
654
+ function getElementInfo(el) {
655
+ return { tag: el.tagName.toLowerCase(), id: el.id||null, name: el.name||null, type: el.type||null,
656
+ text: (el.innerText||el.value||el.placeholder||el.getAttribute('aria-label')||'').trim().slice(0,80),
657
+ selector: getSelector(el), classes: el.className ? el.className.toString().trim().slice(0,100) : null,
658
+ piTestId: el.getAttribute('pi-test-identifier')||null, title: el.getAttribute('title')||null,
659
+ ariaLabel: el.getAttribute('aria-label')||null, dataTestId: el.getAttribute('data-testid')||null };
660
+ }
601
661
 
602
- // Replay steps to debug point
603
- let stepNum = 0;
604
- let browserClosed = false;
605
- for (const step of replaySteps) {
606
- if (browserClosed) break;
607
- stepNum++;
608
- const sel = sanitiseSelector(step.stableSelector || step.selector);
609
- const desc = step.description || (step.action + ' ' + (sel || ''));
610
- sendRun({ type: 'stdout', text: ' [' + stepNum + '/' + replaySteps.length + '] ' + step.action.toUpperCase() + ' \u2014 ' + desc });
611
- try {
612
- await executeStep(step, sel, page, { url: startUrl || '' });
613
- sendRun({ type: 'stdout', text: ' \u2713 Done' });
614
- } catch (err) {
615
- const emsg = err.message || '';
616
- if (emsg.includes('closed') || emsg.includes('destroyed') || emsg.includes('detached')) {
617
- browserClosed = true;
618
- sendRun({ type: 'stdout', text: ' \u2716 Browser closed' });
662
+ document.addEventListener('click', function(e) {
663
+ 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; }
664
+ if (window.__skopixPickMode) return;
665
+ const el = e.target;
666
+ if (!el || el === document.body || el === document.documentElement) return;
667
+ const rect = el.getBoundingClientRect();
668
+ const isCheckable = el.type === 'checkbox' || el.type === 'radio';
669
+ let checkTarget = null;
670
+ if (!isCheckable && el.tagName === 'LABEL' && el.htmlFor) checkTarget = document.getElementById(el.htmlFor);
671
+ if (!isCheckable && el.tagName === 'LABEL' && !el.htmlFor) checkTarget = el.querySelector('input[type="checkbox"],input[type="radio"]');
672
+ const actual = isCheckable ? el : checkTarget;
673
+ if (actual && (actual.type === 'checkbox' || actual.type === 'radio')) {
674
+ 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) });
619
675
  } else {
620
- sendRun({ type: 'stdout', text: ' \u26a0 Skipped: ' + emsg.slice(0, 100) });
676
+ 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) });
621
677
  }
622
- }
623
- }
678
+ }, true);
624
679
 
625
- sendRun({ type: 'stdout', text: '' });
626
- sendRun({ type: 'stdout', text: ' \u2714 Reached debug point \u2014 now recording' });
627
- sendRun({ type: 'stdout', text: ' Use the browser, then click Stop.' });
680
+ let typeTimer = null, lastInputEl = null;
681
+ document.addEventListener('input', function(e) {
682
+ const el = e.target;
683
+ if (!el || !['INPUT','TEXTAREA'].includes(el.tagName)) return;
684
+ if (el.type === 'checkbox' || el.type === 'radio') return;
685
+ if (el.closest && (el.closest('#__skopix_toolbar') || el.closest('#__skopix_popover'))) return;
686
+ lastInputEl = el; clearTimeout(typeTimer);
687
+ typeTimer = setTimeout(() => { if (!lastInputEl) return; window.__skopixCapture && window.__skopixCapture({ action:'type', element:getElementInfo(lastInputEl), value:lastInputEl.value, isPassword:lastInputEl.type==='password' }); lastInputEl = null; }, 600);
688
+ }, true);
689
+
690
+ document.addEventListener('change', function(e) {
691
+ const el = e.target; if (!el || el.tagName !== 'SELECT') return;
692
+ if (el.closest && (el.closest('#__skopix_toolbar') || el.closest('#__skopix_popover'))) return;
693
+ const sel2 = el.options[el.selectedIndex];
694
+ window.__skopixCapture && window.__skopixCapture({ action:'select', element:getElementInfo(el), value:el.value, label:sel2?sel2.text:el.value });
695
+ }, true);
696
+
697
+ // Toolbar
698
+ const tb = document.createElement('div');
699
+ tb.id = '__skopix_toolbar';
700
+ 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';
701
+ tb.innerHTML = '<span style="color:#f59e0b;font-size:14px;animation:skopix_pulse 1s infinite">\u25cf</span>'
702
+ + '<span style="color:#9ca3af">Debug recording</span>'
703
+ + '<span id="__skopix_count" style="color:#22d3ee;font-weight:700;min-width:20px;text-align:center">0</span>'
704
+ + '<span style="color:#4b5563">steps</span>'
705
+ + '<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>'
706
+ + '<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>';
707
+ const sty = document.createElement('style');
708
+ sty.textContent = '@keyframes skopix_pulse{0%,100%{opacity:1}50%{opacity:0.3}}';
709
+ document.head.appendChild(sty);
710
+ document.body.appendChild(tb);
711
+ tb.querySelector('#__skopix_stop_btn').addEventListener('click', e => { e.stopPropagation(); window.__skopixCapture && window.__skopixCapture({ action:'stop' }); });
712
+ window.__skopixUpdateCount = n => { const el = document.getElementById('__skopix_count'); if (el) el.textContent = n; };
713
+
714
+ // Assert picker
715
+ tb.querySelector('#__skopix_assert_btn').addEventListener('click', function(e) {
716
+ e.stopPropagation();
717
+ window.__skopixPickMode = true;
718
+ document.body.style.cursor = 'crosshair';
719
+ const hint = document.createElement('div'); hint.id = '__skopix_hint';
720
+ 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';
721
+ hint.textContent = 'Click any element to add an assertion \u2014 Esc to cancel';
722
+ document.body.appendChild(hint);
723
+ const overlay = document.createElement('div');
724
+ overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483646;pointer-events:auto;cursor:crosshair';
725
+ document.body.appendChild(overlay);
726
+ const hl = document.createElement('div');
727
+ 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';
728
+ document.body.appendChild(hl);
729
+ overlay.addEventListener('mousemove', function(e2) {
730
+ overlay.style.pointerEvents = 'none';
731
+ const el2 = document.elementFromPoint(e2.clientX, e2.clientY);
732
+ overlay.style.pointerEvents = 'auto';
733
+ if (!el2 || el2 === document.body) { hl.style.display = 'none'; return; }
734
+ const r = el2.getBoundingClientRect();
735
+ 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';
736
+ });
737
+ overlay.addEventListener('click', function(e2) {
738
+ e2.preventDefault(); e2.stopPropagation();
739
+ overlay.style.pointerEvents = 'none';
740
+ const el2 = document.elementFromPoint(e2.clientX, e2.clientY);
741
+ overlay.style.pointerEvents = 'auto';
742
+ window.__skopixPickMode = false; document.body.style.cursor = '';
743
+ overlay.remove(); const h2 = document.getElementById('__skopix_hint'); if (h2) h2.remove();
744
+ if (!el2 || el2 === document.body) { hl.style.display = 'none'; return; }
745
+ const sel2 = getSelector(el2);
746
+ const txt = (el2.innerText||el2.textContent||'').trim().slice(0,100);
747
+ const rect2 = el2.getBoundingClientRect();
748
+ hl.style.background = 'rgba(34,197,94,0.15)'; hl.style.borderColor = '#22c55e';
749
+ 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';
750
+ let sugType = 'visible', sugVal = '';
751
+ if (txt && txt.length > 0 && txt.length < 80) { sugType = 'text_contains'; sugVal = txt.replace(/\s+/g,' ').trim(); }
752
+ const popover = document.createElement('div'); popover.id = '__skopix_popover';
753
+ const topPos = rect2.bottom+8+280 > window.innerHeight ? Math.max(8,rect2.top-288) : rect2.bottom+8;
754
+ 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)';
755
+ popover.innerHTML = '<div style="color:#60a5fa;font-size:11px;letter-spacing:0.1em;margin-bottom:12px">ADD ASSERTION</div>'
756
+ + '<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>'
757
+ + '<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>'
758
+ + '<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>'
759
+ + '<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>';
760
+ document.body.appendChild(popover);
761
+ popover.querySelector('#__skopix_assert_type').addEventListener('change', function() {
762
+ const t = this.value; popover.querySelector('#__skopix_value_row').style.display = t==='visible'?'none':'';
763
+ });
764
+ popover.querySelector('#__skopix_cancel_btn').addEventListener('click', function(e3) { e3.stopPropagation(); hl.style.display='none'; popover.remove(); });
765
+ popover.querySelector('#__skopix_add_btn').addEventListener('click', function(e3) {
766
+ e3.stopPropagation();
767
+ const assertType = popover.querySelector('#__skopix_assert_type').value;
768
+ const value = popover.querySelector('#__skopix_assert_value').value.trim();
769
+ if (assertType !== 'visible' && assertType !== 'url_contains' && !value) { popover.querySelector('#__skopix_assert_value').style.borderColor='#dc2626'; return; }
770
+ 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} });
771
+ hl.style.display='none'; popover.remove();
772
+ });
773
+ });
774
+ 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); } });
775
+ });
776
+ });
628
777
 
629
- // Tell dashboard replay is done — frontend switches to recording SSE
778
+ // Tell dashboard replay is done — frontend closes replay SSE and opens recording SSE
630
779
  sendRun({ type: 'done', exitCode: 0, status: 'passed', recordingId });
631
- process.stderr.write('[debug] toolbar injected, waiting for user actions\n');
780
+ process.stderr.write('[debug] toolbar injected, recording live, awaiting user actions\n');
632
781
 
633
782
  } catch (err) {
634
783
  console.error(chalk.red(' \u2716 Debug error: ' + err.message));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skopix",
3
- "version": "2.0.107",
3
+ "version": "2.0.108",
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": {