skopix 2.0.106 → 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 +169 -22
  2. package/package.json +1 -1
@@ -387,18 +387,18 @@ 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 = [];
394
+ let chromiumBrowser = null;
395
+ let page = null;
396
+ let ctx = null;
393
397
 
394
398
  sendRun({ type: 'stdout', text: '' });
395
399
  sendRun({ type: 'stdout', text: ' Replaying ' + replaySteps.length + ' steps to reach debug point...' });
396
400
  sendRun({ type: 'stdout', text: '\u2501'.repeat(60) });
397
401
 
398
- let chromiumBrowser = null;
399
- let page = null;
400
- let ctx = null;
401
-
402
402
  try {
403
403
  const { chromium } = await import('playwright');
404
404
  chromiumBrowser = await chromium.launch({ headless: false, args: ['--no-sandbox', '--disable-blink-features=AutomationControlled', '--allow-insecure-localhost'] });
@@ -410,7 +410,9 @@ export async function agentCommand(options) {
410
410
  await page.waitForTimeout(1000);
411
411
  }
412
412
 
413
- // Replay steps to debug point
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)
414
416
  let stepNum = 0;
415
417
  let browserClosed = false;
416
418
  for (const step of replaySteps) {
@@ -423,28 +425,28 @@ export async function agentCommand(options) {
423
425
  await executeStep(step, sel, page, { url: startUrl || '' });
424
426
  sendRun({ type: 'stdout', text: ' \u2713 Done' });
425
427
  } catch (err) {
426
- const msg2 = err.message || '';
427
- if (msg2.includes('closed') || msg2.includes('destroyed') || msg2.includes('detached')) {
428
+ const emsg = err.message || '';
429
+ if (emsg.includes('closed') || emsg.includes('destroyed') || emsg.includes('detached')) {
428
430
  browserClosed = true;
429
- sendRun({ type: 'stdout', text: ' \u2716 Browser closed' });
431
+ sendRun({ type: 'stdout', text: ' \u2716 Browser closed \u2014 stopping replay early' });
430
432
  } else {
431
- sendRun({ type: 'stdout', text: ' \u26a0 Skipped: ' + msg2.slice(0, 100) });
433
+ sendRun({ type: 'stdout', text: ' \u26a0 Step ' + stepNum + ' skipped: ' + emsg.slice(0, 100) });
432
434
  }
433
435
  }
434
436
  }
435
437
 
436
438
  sendRun({ type: 'stdout', text: '' });
437
- sendRun({ type: 'stdout', text: ' \u2714 Reached debug point \u2014 recording started' });
438
- sendRun({ type: 'stdout', text: ' Use the browser, then click Stop.' });
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) });
439
443
 
440
- // Wire up capture — exposeFunction on context persists across navigations
444
+ // Wire up capture on context persists across navigations
441
445
  await ctx.exposeFunction('__skopixCapture', async (actionData) => {
442
446
  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
447
  sendRec({ type: 'done', steps });
448
+ sendRec({ type: 'stopped' });
449
+ try { await ctx.close(); } catch {}
448
450
  try { await chromiumBrowser.close(); } catch {}
449
451
  ws.send(JSON.stringify({ type: 'jobDone', runId }));
450
452
  console.log(chalk.green(' \u2714 Debug recording done \u2014 ' + steps.length + ' new steps'));
@@ -467,7 +469,6 @@ export async function agentCommand(options) {
467
469
  sendRec({ type: 'step', step: recStep });
468
470
  process.stdout.write(chalk.cyan(' \u23fa ') + (recStep.action || '') + '\n');
469
471
  try { await page.evaluate(n => { if (window.__skopixUpdateCount) window.__skopixUpdateCount(n); }, steps.length); } catch {}
470
- // Screenshot
471
472
  setTimeout(async () => {
472
473
  try {
473
474
  const screenshotDir = path.join(os.homedir(), '.skopix', 'recordings', recordingId);
@@ -480,8 +481,154 @@ export async function agentCommand(options) {
480
481
  }, 400);
481
482
  });
482
483
 
483
- // Inject toolbar + all listeners directly into live page via evaluate
484
- // (same approach as solo debug mode in dashboard.js — no reload needed)
484
+ // addInitScript so toolbar re-injects on any future navigation during recording
485
+ await ctx.addInitScript(() => {
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(' > ');
506
+ }
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
+ // Inject toolbar into the CURRENT page immediately (addInitScript only fires on nav)
485
632
  await page.evaluate(() => {
486
633
  if (window.__skopixRecording) return;
487
634
  window.__skopixRecording = true;
@@ -628,9 +775,9 @@ export async function agentCommand(options) {
628
775
  });
629
776
  });
630
777
 
631
- // Tell dashboard recording is live — frontend switches to recording SSE
778
+ // Tell dashboard replay is done — frontend closes replay SSE and opens recording SSE
632
779
  sendRun({ type: 'done', exitCode: 0, status: 'passed', recordingId });
633
- process.stderr.write('[debug] toolbar injected, waiting for user actions\n');
780
+ process.stderr.write('[debug] toolbar injected, recording live, awaiting user actions\n');
634
781
 
635
782
  } catch (err) {
636
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.106",
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": {