skopix 2.0.107 → 2.0.109

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.
@@ -191,6 +191,15 @@ export async function agentCommand(options) {
191
191
  if (msg.type === 'record') { await handleRecord(msg); return; }
192
192
  if (msg.type === 'replay') { await handleReplay(msg); return; }
193
193
  if (msg.type === 'debug-record') { await handleDebugRecord(msg); return; }
194
+ if (msg.type === 'step-tester-start') { await handleStepTester(msg); return; }
195
+ if (msg.type === 'step-tester-stop') {
196
+ try {
197
+ const { stopStepTester } = await import('../../core/step-tester.js');
198
+ await stopStepTester(msg.testerId);
199
+ } catch {}
200
+ ws.send(JSON.stringify({ type: 'jobDone', runId: msg.testerId }));
201
+ return;
202
+ }
194
203
  });
195
204
 
196
205
  ws.addEventListener('close', () => {
@@ -387,7 +396,8 @@ export async function agentCommand(options) {
387
396
  const startUrl = (msg.startUrl && msg.startUrl.startsWith('http')) ? msg.startUrl : null;
388
397
  console.log(chalk.cyan(' ⏸ Debug replay: ') + chalk.white(replaySteps.length + ' steps') + (startUrl ? chalk.dim(' @ ' + startUrl) : ''));
389
398
 
390
- const sendRun = (data) => { try { ws.send(JSON.stringify({ type: 'runUpdate', runId, data })); } catch {} };
399
+ // IMPORTANT: dashboard listens for 'jobUpdate' (replay) and 'recordingUpdate' (recording)
400
+ const sendRun = (data) => { try { ws.send(JSON.stringify({ type: 'jobUpdate', runId, data })); } catch {} };
391
401
  const sendRec = (data) => { try { ws.send(JSON.stringify({ type: 'recordingUpdate', recordingId, data })); } catch {} };
392
402
  const steps = [];
393
403
  let chromiumBrowser = null;
@@ -404,10 +414,45 @@ export async function agentCommand(options) {
404
414
  ctx = await chromiumBrowser.newContext({ viewport: { width: 1280, height: 800 } });
405
415
  page = await ctx.newPage();
406
416
 
407
- // Wire up capture FIRST — must be on context before any page loads
417
+ if (startUrl) {
418
+ await page.goto(startUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
419
+ await page.waitForTimeout(1000);
420
+ }
421
+
422
+ sendRun({ type: 'stdout', text: ' Replaying ' + replaySteps.length + ' steps...' });
423
+
424
+ // Replay each step to the debug point (no toolbar yet — matches original solo flow)
425
+ let stepNum = 0;
426
+ let browserClosed = false;
427
+ for (const step of replaySteps) {
428
+ if (browserClosed) break;
429
+ stepNum++;
430
+ const sel = sanitiseSelector(step.stableSelector || step.selector);
431
+ const desc = step.description || (step.action + ' ' + (sel || ''));
432
+ sendRun({ type: 'stdout', text: ' [' + stepNum + '/' + replaySteps.length + '] ' + step.action.toUpperCase() + ' \u2014 ' + desc });
433
+ try {
434
+ await executeStep(step, sel, page, { url: startUrl || '' });
435
+ sendRun({ type: 'stdout', text: ' \u2713 Done' });
436
+ } catch (err) {
437
+ const emsg = err.message || '';
438
+ if (emsg.includes('closed') || emsg.includes('destroyed') || emsg.includes('detached')) {
439
+ browserClosed = true;
440
+ sendRun({ type: 'stdout', text: ' \u2716 Browser closed \u2014 stopping replay early' });
441
+ } else {
442
+ sendRun({ type: 'stdout', text: ' \u26a0 Step ' + stepNum + ' skipped: ' + emsg.slice(0, 100) });
443
+ }
444
+ }
445
+ }
446
+
447
+ sendRun({ type: 'stdout', text: '' });
448
+ sendRun({ type: 'stdout', text: '\u2501'.repeat(60) });
449
+ sendRun({ type: 'stdout', text: ' \u2714 Reached step ' + replaySteps.length + ' \u2014 browser is ready' });
450
+ sendRun({ type: 'stdout', text: ' Now recording. Use the browser, then click Stop.' });
451
+ sendRun({ type: 'stdout', text: '\u2501'.repeat(60) });
452
+
453
+ // Wire up capture on context — persists across navigations
408
454
  await ctx.exposeFunction('__skopixCapture', async (actionData) => {
409
455
  if (actionData.action === 'stop') {
410
- // Mirror stopDebugRecording: send done with steps, then stopped
411
456
  sendRec({ type: 'done', steps });
412
457
  sendRec({ type: 'stopped' });
413
458
  try { await ctx.close(); } catch {}
@@ -445,13 +490,8 @@ export async function agentCommand(options) {
445
490
  }, 400);
446
491
  });
447
492
 
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(() => {
493
+ // addInitScript so toolbar re-injects on any future navigation during recording
494
+ await ctx.addInitScript(() => {
455
495
  if (window.__skopixRecording) return;
456
496
  window.__skopixRecording = true;
457
497
 
@@ -597,38 +637,156 @@ export async function agentCommand(options) {
597
637
  });
598
638
  });
599
639
 
640
+ // Inject toolbar into the CURRENT page immediately (addInitScript only fires on nav)
641
+ await page.evaluate(() => {
642
+ if (window.__skopixRecording) return;
643
+ window.__skopixRecording = true;
600
644
 
645
+ function getSelector(el) {
646
+ if (!el || el === document.body) return 'body';
647
+ const testAttrs = ['data-testid','data-test','pi-test-identifier','data-cy','data-qa'];
648
+ for (const attr of testAttrs) { const val = el.getAttribute(attr); if (val) return '['+attr+'="'+val+'"]'; }
649
+ if (el.id && !/^\d/.test(el.id)) return '#'+el.id;
650
+ const al = el.getAttribute('aria-label');
651
+ if (al && ['button','a','input'].includes(el.tagName.toLowerCase())) return el.tagName.toLowerCase()+'[aria-label="'+al+'"]';
652
+ if (el.name && el.tagName === 'INPUT') return 'input[name="'+el.name+'"]';
653
+ const parts = []; let cur = el, depth = 0;
654
+ while (cur && cur !== document.body && depth < 4) {
655
+ let seg = cur.tagName.toLowerCase();
656
+ if (cur.id && !/^\d/.test(cur.id)) { parts.unshift('#'+cur.id); break; }
657
+ const sib = Array.from(cur.parentElement ? cur.parentElement.children : []).filter(c => c.tagName === cur.tagName);
658
+ if (sib.length > 1) seg += ':nth-of-type('+(sib.indexOf(cur)+1)+')';
659
+ parts.unshift(seg); cur = cur.parentElement; depth++;
660
+ }
661
+ return parts.join(' > ');
662
+ }
663
+ function getElementInfo(el) {
664
+ return { tag: el.tagName.toLowerCase(), id: el.id||null, name: el.name||null, type: el.type||null,
665
+ text: (el.innerText||el.value||el.placeholder||el.getAttribute('aria-label')||'').trim().slice(0,80),
666
+ selector: getSelector(el), classes: el.className ? el.className.toString().trim().slice(0,100) : null,
667
+ piTestId: el.getAttribute('pi-test-identifier')||null, title: el.getAttribute('title')||null,
668
+ ariaLabel: el.getAttribute('aria-label')||null, dataTestId: el.getAttribute('data-testid')||null };
669
+ }
601
670
 
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' });
671
+ document.addEventListener('click', function(e) {
672
+ 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; }
673
+ if (window.__skopixPickMode) return;
674
+ const el = e.target;
675
+ if (!el || el === document.body || el === document.documentElement) return;
676
+ const rect = el.getBoundingClientRect();
677
+ const isCheckable = el.type === 'checkbox' || el.type === 'radio';
678
+ let checkTarget = null;
679
+ if (!isCheckable && el.tagName === 'LABEL' && el.htmlFor) checkTarget = document.getElementById(el.htmlFor);
680
+ if (!isCheckable && el.tagName === 'LABEL' && !el.htmlFor) checkTarget = el.querySelector('input[type="checkbox"],input[type="radio"]');
681
+ const actual = isCheckable ? el : checkTarget;
682
+ if (actual && (actual.type === 'checkbox' || actual.type === 'radio')) {
683
+ 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
684
  } else {
620
- sendRun({ type: 'stdout', text: ' \u26a0 Skipped: ' + emsg.slice(0, 100) });
685
+ 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
686
  }
622
- }
623
- }
687
+ }, true);
624
688
 
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.' });
689
+ let typeTimer = null, lastInputEl = null;
690
+ document.addEventListener('input', function(e) {
691
+ const el = e.target;
692
+ if (!el || !['INPUT','TEXTAREA'].includes(el.tagName)) return;
693
+ if (el.type === 'checkbox' || el.type === 'radio') return;
694
+ if (el.closest && (el.closest('#__skopix_toolbar') || el.closest('#__skopix_popover'))) return;
695
+ lastInputEl = el; clearTimeout(typeTimer);
696
+ typeTimer = setTimeout(() => { if (!lastInputEl) return; window.__skopixCapture && window.__skopixCapture({ action:'type', element:getElementInfo(lastInputEl), value:lastInputEl.value, isPassword:lastInputEl.type==='password' }); lastInputEl = null; }, 600);
697
+ }, true);
698
+
699
+ document.addEventListener('change', function(e) {
700
+ const el = e.target; if (!el || el.tagName !== 'SELECT') return;
701
+ if (el.closest && (el.closest('#__skopix_toolbar') || el.closest('#__skopix_popover'))) return;
702
+ const sel2 = el.options[el.selectedIndex];
703
+ window.__skopixCapture && window.__skopixCapture({ action:'select', element:getElementInfo(el), value:el.value, label:sel2?sel2.text:el.value });
704
+ }, true);
705
+
706
+ // Toolbar
707
+ const tb = document.createElement('div');
708
+ tb.id = '__skopix_toolbar';
709
+ 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';
710
+ tb.innerHTML = '<span style="color:#f59e0b;font-size:14px;animation:skopix_pulse 1s infinite">\u25cf</span>'
711
+ + '<span style="color:#9ca3af">Debug recording</span>'
712
+ + '<span id="__skopix_count" style="color:#22d3ee;font-weight:700;min-width:20px;text-align:center">0</span>'
713
+ + '<span style="color:#4b5563">steps</span>'
714
+ + '<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>'
715
+ + '<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>';
716
+ const sty = document.createElement('style');
717
+ sty.textContent = '@keyframes skopix_pulse{0%,100%{opacity:1}50%{opacity:0.3}}';
718
+ document.head.appendChild(sty);
719
+ document.body.appendChild(tb);
720
+ tb.querySelector('#__skopix_stop_btn').addEventListener('click', e => { e.stopPropagation(); window.__skopixCapture && window.__skopixCapture({ action:'stop' }); });
721
+ window.__skopixUpdateCount = n => { const el = document.getElementById('__skopix_count'); if (el) el.textContent = n; };
722
+
723
+ // Assert picker
724
+ tb.querySelector('#__skopix_assert_btn').addEventListener('click', function(e) {
725
+ e.stopPropagation();
726
+ window.__skopixPickMode = true;
727
+ document.body.style.cursor = 'crosshair';
728
+ const hint = document.createElement('div'); hint.id = '__skopix_hint';
729
+ 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';
730
+ hint.textContent = 'Click any element to add an assertion \u2014 Esc to cancel';
731
+ document.body.appendChild(hint);
732
+ const overlay = document.createElement('div');
733
+ overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483646;pointer-events:auto;cursor:crosshair';
734
+ document.body.appendChild(overlay);
735
+ const hl = document.createElement('div');
736
+ 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';
737
+ document.body.appendChild(hl);
738
+ overlay.addEventListener('mousemove', function(e2) {
739
+ overlay.style.pointerEvents = 'none';
740
+ const el2 = document.elementFromPoint(e2.clientX, e2.clientY);
741
+ overlay.style.pointerEvents = 'auto';
742
+ if (!el2 || el2 === document.body) { hl.style.display = 'none'; return; }
743
+ const r = el2.getBoundingClientRect();
744
+ 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';
745
+ });
746
+ overlay.addEventListener('click', function(e2) {
747
+ e2.preventDefault(); e2.stopPropagation();
748
+ overlay.style.pointerEvents = 'none';
749
+ const el2 = document.elementFromPoint(e2.clientX, e2.clientY);
750
+ overlay.style.pointerEvents = 'auto';
751
+ window.__skopixPickMode = false; document.body.style.cursor = '';
752
+ overlay.remove(); const h2 = document.getElementById('__skopix_hint'); if (h2) h2.remove();
753
+ if (!el2 || el2 === document.body) { hl.style.display = 'none'; return; }
754
+ const sel2 = getSelector(el2);
755
+ const txt = (el2.innerText||el2.textContent||'').trim().slice(0,100);
756
+ const rect2 = el2.getBoundingClientRect();
757
+ hl.style.background = 'rgba(34,197,94,0.15)'; hl.style.borderColor = '#22c55e';
758
+ 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';
759
+ let sugType = 'visible', sugVal = '';
760
+ if (txt && txt.length > 0 && txt.length < 80) { sugType = 'text_contains'; sugVal = txt.replace(/\s+/g,' ').trim(); }
761
+ const popover = document.createElement('div'); popover.id = '__skopix_popover';
762
+ const topPos = rect2.bottom+8+280 > window.innerHeight ? Math.max(8,rect2.top-288) : rect2.bottom+8;
763
+ 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)';
764
+ popover.innerHTML = '<div style="color:#60a5fa;font-size:11px;letter-spacing:0.1em;margin-bottom:12px">ADD ASSERTION</div>'
765
+ + '<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>'
766
+ + '<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>'
767
+ + '<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>'
768
+ + '<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>';
769
+ document.body.appendChild(popover);
770
+ popover.querySelector('#__skopix_assert_type').addEventListener('change', function() {
771
+ const t = this.value; popover.querySelector('#__skopix_value_row').style.display = t==='visible'?'none':'';
772
+ });
773
+ popover.querySelector('#__skopix_cancel_btn').addEventListener('click', function(e3) { e3.stopPropagation(); hl.style.display='none'; popover.remove(); });
774
+ popover.querySelector('#__skopix_add_btn').addEventListener('click', function(e3) {
775
+ e3.stopPropagation();
776
+ const assertType = popover.querySelector('#__skopix_assert_type').value;
777
+ const value = popover.querySelector('#__skopix_assert_value').value.trim();
778
+ if (assertType !== 'visible' && assertType !== 'url_contains' && !value) { popover.querySelector('#__skopix_assert_value').style.borderColor='#dc2626'; return; }
779
+ 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} });
780
+ hl.style.display='none'; popover.remove();
781
+ });
782
+ });
783
+ 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); } });
784
+ });
785
+ });
628
786
 
629
- // Tell dashboard replay is done — frontend switches to recording SSE
787
+ // Tell dashboard replay is done — frontend closes replay SSE and opens recording SSE
630
788
  sendRun({ type: 'done', exitCode: 0, status: 'passed', recordingId });
631
- process.stderr.write('[debug] toolbar injected, waiting for user actions\n');
789
+ process.stderr.write('[debug] toolbar injected, recording live, awaiting user actions\n');
632
790
 
633
791
  } catch (err) {
634
792
  console.error(chalk.red(' \u2716 Debug error: ' + err.message));
@@ -640,7 +798,26 @@ export async function agentCommand(options) {
640
798
  }
641
799
  }
642
800
 
643
- // ── HELPERS ────────────────────────────────────────────────────────────────
801
+ // ── STEP TESTER JOB ─────────────────────────────────────────────────────────
802
+ // Runs the full interactive step tester (headed browser + toolbar) on this
803
+ // machine. The toolbar's buttons call exposeFunction'd handlers directly,
804
+ // so the whole session is self-contained — no round-tripping needed.
805
+ async function handleStepTester(msg) {
806
+ const { testerId, url, selector, mode, steps } = msg;
807
+ console.log(chalk.cyan(' ⚡ Step tester: ') + chalk.white(mode === 'preview' ? (steps?.length || 0) + ' steps' : (selector || 'manual')) + (url ? chalk.dim(' @ ' + url) : ''));
808
+ try {
809
+ const { startStepTester } = await import('../../core/step-tester.js');
810
+ await startStepTester(testerId, url, selector, mode || 'test', steps);
811
+ console.log(chalk.green(' ✔ Step tester browser opened — use the toolbar, close when done'));
812
+ // The session runs independently; mark agent idle so it can take other jobs
813
+ ws.send(JSON.stringify({ type: 'jobDone', runId: testerId }));
814
+ } catch (err) {
815
+ console.error(chalk.red(' ✖ Step tester error: ' + err.message));
816
+ ws.send(JSON.stringify({ type: 'jobDone', runId: testerId }));
817
+ }
818
+ }
819
+
820
+ // ── HELPERS ────────────────────────────────────────────────────────────────
644
821
  function sanitiseSelector(sel) {
645
822
  if (!sel) return sel;
646
823
  return sel.replace(/\[([a-zA-Z_-]+)="([^"]+\.\d{5,})"\]/g, (_, attr, val) => '[' + attr + '*="' + val.replace(/\.\d{5,}$/, '') + '"]');
@@ -9,6 +9,7 @@ import open from 'open';
9
9
  import os from 'os';
10
10
  import crypto from 'crypto';
11
11
  import { loadIssueStore, saveIssueStore } from '../../core/tracker.js';
12
+ import { startStepTester, executeStepTesterAction, runStepTesterAction, stopStepTester, stepTesterSessions } from '../../core/step-tester.js';
12
13
 
13
14
  // Team mode imports - loaded lazily (only used when SKOPIX_TEAM_MODE=true)
14
15
  let teamMode = null; // populated below if team mode is active
@@ -2033,6 +2034,21 @@ export async function dashboardCommand(options) {
2033
2034
  const { url, selector, mode, steps } = JSON.parse(await readBody(req));
2034
2035
  try {
2035
2036
  const testerId = Math.random().toString(36).slice(2, 10);
2037
+ // In team mode, dispatch to a headed agent so the browser opens on the user's machine
2038
+ if (teamMode) {
2039
+ const headedAgent = getLeastBusyAgent(currentUser?.id, true);
2040
+ if (!headedAgent) {
2041
+ sendJSON(res, 503, { error: 'No agent with a display connected. Run "skopix agent" on your local machine to use the step tester.' });
2042
+ return;
2043
+ }
2044
+ stepTesterSessions.set(testerId, { remote: true, agentId: headedAgent.id });
2045
+ headedAgent.status = 'testing';
2046
+ headedAgent.currentJob = { type: 'step-tester', testerId };
2047
+ broadcastAgentList();
2048
+ sendToAgent(headedAgent, { type: 'step-tester-start', testerId, url, selector, mode: mode || 'test', steps });
2049
+ sendJSON(res, 200, { testerId, agent: { id: headedAgent.id, name: headedAgent.name } });
2050
+ return;
2051
+ }
2036
2052
  await startStepTester(testerId, url, selector, mode || 'test', steps);
2037
2053
  sendJSON(res, 200, { testerId });
2038
2054
  } catch (err) { sendJSON(res, 500, { error: err.message }); }
@@ -2066,6 +2082,14 @@ export async function dashboardCommand(options) {
2066
2082
  }
2067
2083
  if (pathname.match(/^\/api\/step-tester\/[^/]+\/stop$/) && method === 'POST') {
2068
2084
  const testerId = pathname.split('/')[3];
2085
+ const session = stepTesterSessions.get(testerId);
2086
+ if (session && session.remote) {
2087
+ const agent = agents.get(session.agentId);
2088
+ if (agent) sendToAgent(agent, { type: 'step-tester-stop', testerId });
2089
+ stepTesterSessions.delete(testerId);
2090
+ sendJSON(res, 200, { stopped: true });
2091
+ return;
2092
+ }
2069
2093
  await stopStepTester(testerId);
2070
2094
  sendJSON(res, 200, { stopped: true });
2071
2095
  return;
@@ -4536,327 +4560,5 @@ async function syncTestsToLibrary(suitesDir) {
4536
4560
  // STEP TESTER — live browser step testing
4537
4561
  // ═══════════════════════════════════════════════════════════════
4538
4562
 
4539
- const stepTesterSessions = new Map(); // testerId -> { browser, ctx, page }
4540
-
4541
- async function startStepTester(testerId, url, selector, mode, steps) {
4542
- const { chromium } = await import('playwright');
4543
- const browser = await chromium.launch({ headless: false, args: ['--no-sandbox'] });
4544
- const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
4545
- const page = await ctx.newPage();
4546
-
4547
- if (mode === 'preview' && steps && steps.length > 0) {
4548
- // Register expose functions FIRST before addInitScript so they're available on DOMContentLoaded
4549
- await ctx.exposeFunction('__skopixPreviewRun', async ({ index }) => {
4550
- const session = stepTesterSessions.get(testerId);
4551
- if (!session) return;
4552
- const s = steps[index];
4553
- if (!s) return;
4554
- await page.evaluate(({ i, status }) => { if(window.__skopixUpdatePreview) window.__skopixUpdatePreview(i, null, status); }, { i: index, status: `Running step ${index+1}/${steps.length}...` }).catch(()=>{});
4555
- const result = await executeStepTesterAction(page, { selector: s.stableSelector||s.selector, action: s.action, value: s.value||'', assertType: s.assertType });
4556
- const msg = result.passed ? (index+1 >= steps.length ? `✓ All ${steps.length} steps passed!` : `✓ Step ${index+1} passed`) : `✗ Step ${index+1} failed`;
4557
- if (result.passed) session.currentStep = index + 1;
4558
- if (!session.results) session.results = {};
4559
- session.results[index] = { passed: result.passed, error: result.error||null };
4560
- await page.evaluate(({ i, r, msg, currentStep }) => {
4561
- if(window.__skopixUpdatePreview) window.__skopixUpdatePreview(i, r, msg);
4562
- window.__skopixPreviewCurrentStep = currentStep;
4563
- }, { i: index, r: { passed: result.passed, error: result.error||null }, msg, currentStep: session.currentStep || 0 }).catch(()=>{});
4564
- });
4565
-
4566
- await ctx.exposeFunction('__skopixPreviewRunAll', async ({ fromIndex }) => {
4567
- const session = stepTesterSessions.get(testerId);
4568
- if (!session) return;
4569
- for (let i = fromIndex; i < steps.length; i++) {
4570
- const s = steps[i];
4571
- await page.evaluate(({ i, total }) => { if(window.__skopixUpdatePreview) window.__skopixUpdatePreview(i, null, `Running step ${i+1}/${total}...`); }, { i, total: steps.length }).catch(()=>{});
4572
- const result = await executeStepTesterAction(page, { selector: s.stableSelector||s.selector, action: s.action, value: s.value||'', assertType: s.assertType });
4573
- if (result.passed) session.currentStep = i + 1;
4574
- if (!session.results) session.results = {};
4575
- session.results[i] = { passed: result.passed, error: result.error||null };
4576
- const msg = result.passed ? (i+1 >= steps.length ? `✓ All ${steps.length} steps passed!` : `Running...`) : `✗ Step ${i+1} failed — fix and retry`;
4577
- await page.evaluate(({ i, r, msg, currentStep }) => {
4578
- if(window.__skopixUpdatePreview) window.__skopixUpdatePreview(i, r, msg);
4579
- window.__skopixPreviewCurrentStep = currentStep;
4580
- }, { i, r: { passed: result.passed, error: result.error||null }, msg, currentStep: session.currentStep || 0 }).catch(()=>{});
4581
- if (!result.passed) break;
4582
- await new Promise(r => setTimeout(r, 400));
4583
- }
4584
- });
4585
-
4586
- await ctx.exposeFunction('__skopixStopPreview', async () => {
4587
- try { await browser.close(); } catch {}
4588
- stepTesterSessions.delete(testerId);
4589
- });
4590
-
4591
- await ctx.exposeFunction('__skopixGetState', async () => {
4592
- const session = stepTesterSessions.get(testerId);
4593
- return { currentStep: session?.currentStep || 0, results: session?.results || {} };
4594
- });
4595
-
4596
- // THEN inject toolbar via addInitScript
4597
- // PREVIEW MODE — inject steps list toolbar
4598
- await ctx.addInitScript((stepsData) => {
4599
- window.__skopixPreviewSteps = stepsData;
4600
- window.__skopixPreviewResults = {};
4601
- document.addEventListener('DOMContentLoaded', () => {
4602
- if (document.getElementById('__skopix_preview')) return;
4603
- // Push page content left to avoid covering elements
4604
- document.body.style.marginRight = '320px';
4605
- document.body.style.boxSizing = 'border-box';
4606
- // Also fix any fixed/sticky headers
4607
- const fixedEls = Array.from(document.querySelectorAll('*')).filter(el => {
4608
- const s = window.getComputedStyle(el);
4609
- return (s.position === 'fixed' || s.position === 'sticky') && el.id !== '__skopix_preview';
4610
- });
4611
- fixedEls.forEach(el => {
4612
- const s = window.getComputedStyle(el);
4613
- const right = parseInt(s.right) || 0;
4614
- el.style.right = (right + 320) + 'px';
4615
- el.setAttribute('data-skopix-fixed', '1');
4616
- });
4617
- const tb = document.createElement('div');
4618
- tb.id = '__skopix_preview';
4619
- tb.style.cssText = [
4620
- 'position:fixed', 'top:0', 'right:0', 'bottom:0', 'width:320px',
4621
- 'z-index:2147483647', 'background:#0d0d1a', 'border-left:1px solid rgba(245,158,11,0.4)',
4622
- 'display:flex', 'flex-direction:column', 'font-family:monospace', 'font-size:12px',
4623
- 'box-shadow:-4px 0 24px rgba(0,0,0,0.5)',
4624
- ].join(';');
4625
- tb.innerHTML = `
4626
- <div style="padding:12px 16px;border-bottom:1px solid rgba(245,158,11,0.2);background:rgba(245,158,11,0.06);flex-shrink:0">
4627
- <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
4628
- <div style="display:flex;align-items:center;gap:8px">
4629
- <span style="color:#f59e0b">⚡</span>
4630
- <span style="color:#f59e0b;font-size:10px;letter-spacing:0.1em">PREVIEW</span>
4631
- </div>
4632
- <div style="display:flex;gap:6px">
4633
- <button id="__skopix_run_next" style="background:transparent;border:1px solid rgba(245,158,11,0.4);color:#f59e0b;
4634
- border-radius:6px;padding:4px 10px;cursor:pointer;font-size:11px;font-family:monospace">▶ Next</button>
4635
- <button id="__skopix_run_all" style="background:#f59e0b;border:none;color:#000;
4636
- border-radius:6px;padding:4px 10px;cursor:pointer;font-size:11px;font-family:monospace;font-weight:700">▶▶ All</button>
4637
- <button id="__skopix_stop_preview" style="background:transparent;border:1px solid #374151;color:#9ca3af;
4638
- border-radius:6px;padding:4px 10px;cursor:pointer;font-size:11px;font-family:monospace">✕ Stop</button>
4639
- </div>
4640
- </div>
4641
- <div id="__skopix_preview_status" style="color:#9ca3af;font-size:11px">Navigate to start, then click Run</div>
4642
- </div>
4643
- <div id="__skopix_steps" style="overflow-y:auto;flex:1;padding:0"></div>
4644
- <div id="__skopix_preview_summary" style="display:none;padding:12px 16px;border-top:1px solid rgba(255,255,255,0.08);font-size:12px"></div>
4645
- `;
4646
- document.body.appendChild(tb);
4647
-
4648
- function renderSteps(currentIdx, results) {
4649
- const actionColors = { click:'#22d3ee', type:'#a78bfa', check:'#34d399', assert:'#fb923c', select:'#f472b6', scroll:'#6b7280' };
4650
- document.getElementById('__skopix_steps').innerHTML = stepsData.map((s, i) => {
4651
- const r = results[i];
4652
- const isCurrent = i === currentIdx && !r;
4653
- const status = r ? (r.passed ? '✓' : '✗') : (isCurrent ? '▶' : '○');
4654
- const statusColor = r ? (r.passed ? '#34d399' : '#ef4444') : (isCurrent ? '#f59e0b' : '#374151');
4655
- const bg = isCurrent ? 'rgba(245,158,11,0.08)' : r && !r.passed ? 'rgba(239,68,68,0.06)' : '';
4656
- return `<div style="padding:8px 14px;border-bottom:1px solid rgba(255,255,255,0.05);display:flex;gap:10px;align-items:flex-start;background:${bg}">
4657
- <span style="color:${statusColor};font-size:13px;margin-top:1px;flex-shrink:0">${status}</span>
4658
- <div style="min-width:0">
4659
- <div style="display:flex;align-items:center;gap:6px;margin-bottom:2px">
4660
- <span style="color:${actionColors[s.action]||'#6b7280'};font-size:10px">${s.action}</span>
4661
- <span style="color:#e5e7eb;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px">${s.description||''}</span>
4662
- </div>
4663
- <code style="color:#4b5563;font-size:10px;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${s.selector||''}</code>
4664
- ${r && !r.passed ? `<div style="color:#ef4444;font-size:10px;margin-top:3px">${(r.error||'Failed').slice(0,80)}</div>` : ''}
4665
- </div>
4666
- </div>`;
4667
- }).join('');
4668
- }
4563
+ // stepTesterSessions imported from step-tester module (shared)
4669
4564
 
4670
- window.__skopixPreviewCurrentStep = 0;
4671
- window.__skopixPreviewResults = {};
4672
- renderSteps(0, {});
4673
-
4674
- document.getElementById('__skopix_run_next').addEventListener('click', (e) => {
4675
- e.stopPropagation();
4676
- if (window.__skopixPreviewRun) window.__skopixPreviewRun({ index: window.__skopixPreviewCurrentStep });
4677
- });
4678
-
4679
- document.getElementById('__skopix_run_all').addEventListener('click', (e) => {
4680
- e.stopPropagation();
4681
- if (window.__skopixPreviewRunAll) window.__skopixPreviewRunAll({ fromIndex: window.__skopixPreviewCurrentStep });
4682
- });
4683
-
4684
- document.getElementById('__skopix_stop_preview').addEventListener('click', (e) => {
4685
- e.stopPropagation();
4686
- if (window.__skopixStopPreview) window.__skopixStopPreview({});
4687
- });
4688
-
4689
- // Restore state from server after navigation
4690
- if (window.__skopixGetState) {
4691
- window.__skopixGetState({}).then(state => {
4692
- if (state && state.currentStep > 0) {
4693
- window.__skopixPreviewCurrentStep = state.currentStep;
4694
- window.__skopixPreviewResults = state.results || {};
4695
- renderSteps(state.currentStep, state.results || {});
4696
- }
4697
- }).catch(() => {});
4698
- }
4699
-
4700
- window.__skopixUpdatePreview = (index, result, status) => {
4701
- window.__skopixPreviewResults[index] = result;
4702
- if (result && result.passed) window.__skopixPreviewCurrentStep = index + 1;
4703
- document.getElementById('__skopix_preview_status').textContent = status || '';
4704
- renderSteps(window.__skopixPreviewCurrentStep, window.__skopixPreviewResults);
4705
- // Highlight element
4706
- if (result) {
4707
- try {
4708
- const target = document.querySelector(stepsData[index]?.selector || '');
4709
- if (target) {
4710
- const orig = target.style.outline;
4711
- target.style.outline = result.passed ? '3px solid #34d399' : '3px solid #ef4444';
4712
- setTimeout(() => { target.style.outline = orig; }, 1500);
4713
- }
4714
- } catch {}
4715
- }
4716
- // Update summary
4717
- const total = Object.keys(window.__skopixPreviewResults).length;
4718
- const passed = Object.values(window.__skopixPreviewResults).filter(r=>r&&r.passed).length;
4719
- const failed = total - passed;
4720
- if (total > 0) {
4721
- const summary = document.getElementById('__skopix_preview_summary');
4722
- summary.style.display = '';
4723
- summary.innerHTML = `<span style="color:#34d399">✓ ${passed}</span><span style="color:#4b5563"> / </span><span style="color:${failed>0?'#ef4444':'#4b5563'}">✗ ${failed}</span><span style="color:#4b5563"> of ${stepsData.length}</span>`;
4724
- }
4725
- };
4726
- });
4727
- }, steps.map(s => ({ action: s.action, selector: s.stableSelector||s.selector||'', description: s.description||s.action, value: s.value||'' })));
4728
-
4729
- } else {
4730
- // STEP TESTER MODE — register expose functions FIRST
4731
- await ctx.exposeFunction('__skopixTesterRun', async ({ sel, action, value }) => {
4732
- const result = await executeStepTesterAction(page, { selector: sel, action, value });
4733
- await page.evaluate(({ passed }) => {
4734
- const el = document.getElementById('__skopix_result');
4735
- if (el) { el.textContent = passed ? '✓' : '✗'; el.style.color = passed ? '#34d399' : '#ef4444'; }
4736
- }, { passed: result.passed }).catch(async () => {
4737
- await new Promise(r => setTimeout(r, 500));
4738
- await page.evaluate(({ passed }) => {
4739
- const el = document.getElementById('__skopix_result');
4740
- if (el) { el.textContent = passed ? '✓' : '✗'; el.style.color = passed ? '#34d399' : '#ef4444'; }
4741
- }, { passed: result.passed }).catch(() => {});
4742
- });
4743
- await page.evaluate(({ passed, sel }) => {
4744
- try {
4745
- const target = document.querySelector(sel);
4746
- if (target) {
4747
- const orig = target.style.outline;
4748
- target.style.outline = passed ? '3px solid #34d399' : '3px solid #ef4444';
4749
- setTimeout(() => { target.style.outline = orig; }, 1500);
4750
- }
4751
- } catch {}
4752
- }, { passed: result.passed, sel }).catch(() => {});
4753
- });
4754
-
4755
- await ctx.exposeFunction('__skopixTesterStop', async () => {
4756
- try { await browser.close(); } catch {}
4757
- stepTesterSessions.delete(testerId);
4758
- });
4759
-
4760
- // THEN inject toolbar
4761
- await ctx.addInitScript((sel) => {
4762
- window.__skopixTesterSelector = sel;
4763
- document.addEventListener('DOMContentLoaded', () => {
4764
- if (document.getElementById('__skopix_tester')) return;
4765
- const tb = document.createElement('div');
4766
- tb.id = '__skopix_tester';
4767
- tb.style.cssText = [
4768
- 'position:fixed', 'bottom:20px', 'left:50%', 'transform:translateX(-50%)',
4769
- 'z-index:2147483647', 'background:#0f1117', 'border:1px solid #f59e0b',
4770
- 'border-radius:12px', 'padding:12px 16px', 'display:flex', 'align-items:center',
4771
- 'gap:10px', 'font-family:monospace', 'font-size:12px', 'color:#e5e7eb',
4772
- 'box-shadow:0 4px 24px rgba(0,0,0,0.7)', 'min-width:400px',
4773
- ].join(';');
4774
- tb.innerHTML = `
4775
- <span style="color:#f59e0b;font-size:14px">⚡</span>
4776
- <span style="color:#9ca3af;font-size:11px">STEP TESTER</span>
4777
- <input id="__skopix_sel" value="${(sel||'').replace(/"/g,'&quot;')}" placeholder="selector..."
4778
- style="flex:1;background:#1a1d2e;border:1px solid #374151;border-radius:6px;padding:4px 8px;
4779
- color:#e5e7eb;font-family:monospace;font-size:11px;min-width:160px">
4780
- <select id="__skopix_action" style="background:#1a1d2e;border:1px solid #374151;border-radius:6px;
4781
- padding:4px 8px;color:#e5e7eb;font-family:monospace;font-size:11px">
4782
- <option value="click">click</option>
4783
- <option value="type">type</option>
4784
- <option value="check">check</option>
4785
- <option value="assert">assert (visible)</option>
4786
- <option value="assert_text">assert (text)</option>
4787
- </select>
4788
- <input id="__skopix_value" placeholder="value..." style="width:100px;background:#1a1d2e;
4789
- border:1px solid #374151;border-radius:6px;padding:4px 8px;color:#e5e7eb;
4790
- font-family:monospace;font-size:11px;display:none">
4791
- <button id="__skopix_run" style="background:#f59e0b;border:none;color:#000;
4792
- border-radius:6px;padding:5px 14px;cursor:pointer;font-size:11px;font-weight:700;
4793
- font-family:monospace">▶ Run</button>
4794
- <span id="__skopix_result" style="font-size:13px;min-width:20px"></span>
4795
- <button id="__skopix_stop" style="background:transparent;border:1px solid #374151;color:#9ca3af;
4796
- border-radius:6px;padding:5px 10px;cursor:pointer;font-size:11px;font-family:monospace">✕ Stop</button>
4797
- `;
4798
- document.body.appendChild(tb);
4799
- document.getElementById('__skopix_action').addEventListener('change', function() {
4800
- const needsValue = ['type','assert_text'].includes(this.value);
4801
- document.getElementById('__skopix_value').style.display = needsValue ? '' : 'none';
4802
- });
4803
- document.getElementById('__skopix_run').addEventListener('click', function(e) {
4804
- e.stopPropagation();
4805
- const sel = document.getElementById('__skopix_sel').value.trim();
4806
- const action = document.getElementById('__skopix_action').value;
4807
- const value = document.getElementById('__skopix_value').value.trim();
4808
- document.getElementById('__skopix_result').textContent = '⏳';
4809
- if (window.__skopixTesterRun) window.__skopixTesterRun({ sel, action, value });
4810
- });
4811
- document.getElementById('__skopix_stop').addEventListener('click', function(e) {
4812
- e.stopPropagation();
4813
- if (window.__skopixTesterStop) window.__skopixTesterStop({});
4814
- });
4815
- });
4816
- }, selector || '');
4817
- }
4818
-
4819
- if (url) await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {});
4820
- // Ensure toolbar is injected on first page (DOMContentLoaded may have already fired)
4821
- await page.evaluate(() => { if (window.__skopixTesterSelector !== undefined && !document.getElementById('__skopix_tester') && !document.getElementById('__skopix_preview')) { document.dispatchEvent(new Event('DOMContentLoaded')); } }).catch(() => {});
4822
- stepTesterSessions.set(testerId, { browser, ctx, page, currentStep: 0, results: {} });
4823
- }
4824
-
4825
- async function executeStepTesterAction(page, { selector, action, value }) {
4826
- try {
4827
- const { sanitiseSelector } = { sanitiseSelector: (s) => s }; // inline since not exported
4828
- const sel = selector;
4829
- if (action === 'click') {
4830
- await page.locator(sel).first().click({ timeout: 5000 });
4831
- } else if (action === 'type') {
4832
- await page.locator(sel).first().fill(value || '', { timeout: 5000 });
4833
- } else if (action === 'check') {
4834
- await page.locator(sel).first().click({ timeout: 5000 });
4835
- } else if (action === 'assert' || action === 'assert_text') {
4836
- if (action === 'assert_text') {
4837
- const text = await page.locator(sel).first().textContent({ timeout: 5000 });
4838
- if (!text.includes(value)) throw new Error(`Text "${text}" does not contain "${value}"`);
4839
- } else {
4840
- await page.locator(sel).first().waitFor({ state: 'attached', timeout: 5000 });
4841
- }
4842
- }
4843
- const screenshot = await page.screenshot({ type: 'jpeg', quality: 70 }).catch(() => null);
4844
- return { passed: true, screenshot: screenshot ? screenshot.toString('base64') : null };
4845
- } catch (err) {
4846
- const screenshot = await page.screenshot({ type: 'jpeg', quality: 70 }).catch(() => null);
4847
- return { passed: false, error: err.message, screenshot: screenshot ? screenshot.toString('base64') : null };
4848
- }
4849
- }
4850
-
4851
- async function runStepTesterAction(testerId, stepData) {
4852
- const session = stepTesterSessions.get(testerId);
4853
- if (!session) return { passed: false, error: 'Session not found' };
4854
- return await executeStepTesterAction(session.page, stepData);
4855
- }
4856
-
4857
- async function stopStepTester(testerId) {
4858
- const session = stepTesterSessions.get(testerId);
4859
- if (!session) return;
4860
- try { await session.browser.close(); } catch {}
4861
- stepTesterSessions.delete(testerId);
4862
- }
@@ -0,0 +1,332 @@
1
+ // Shared step tester logic — used by both the dashboard (solo mode) and
2
+ // the agent (team mode, runs on the user's machine with a display).
3
+ import { chromium } from 'playwright';
4
+
5
+ // Module-level session store (each process — dashboard or agent — has its own)
6
+ export const stepTesterSessions = new Map();
7
+
8
+ async function startStepTester(testerId, url, selector, mode, steps) {
9
+ // Detect display — on Linux servers without DISPLAY, must run headless
10
+ const hasDisplay = process.platform !== 'linux' || !!process.env.DISPLAY || !!process.env.WAYLAND_DISPLAY;
11
+ const browser = await chromium.launch({ headless: !hasDisplay, args: ['--no-sandbox'] });
12
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
13
+ const page = await ctx.newPage();
14
+
15
+ if (mode === 'preview' && steps && steps.length > 0) {
16
+ // Register expose functions FIRST before addInitScript so they're available on DOMContentLoaded
17
+ await ctx.exposeFunction('__skopixPreviewRun', async ({ index }) => {
18
+ const session = stepTesterSessions.get(testerId);
19
+ if (!session) return;
20
+ const s = steps[index];
21
+ if (!s) return;
22
+ await page.evaluate(({ i, status }) => { if(window.__skopixUpdatePreview) window.__skopixUpdatePreview(i, null, status); }, { i: index, status: `Running step ${index+1}/${steps.length}...` }).catch(()=>{});
23
+ const result = await executeStepTesterAction(page, { selector: s.stableSelector||s.selector, action: s.action, value: s.value||'', assertType: s.assertType });
24
+ const msg = result.passed ? (index+1 >= steps.length ? `✓ All ${steps.length} steps passed!` : `✓ Step ${index+1} passed`) : `✗ Step ${index+1} failed`;
25
+ if (result.passed) session.currentStep = index + 1;
26
+ if (!session.results) session.results = {};
27
+ session.results[index] = { passed: result.passed, error: result.error||null };
28
+ await page.evaluate(({ i, r, msg, currentStep }) => {
29
+ if(window.__skopixUpdatePreview) window.__skopixUpdatePreview(i, r, msg);
30
+ window.__skopixPreviewCurrentStep = currentStep;
31
+ }, { i: index, r: { passed: result.passed, error: result.error||null }, msg, currentStep: session.currentStep || 0 }).catch(()=>{});
32
+ });
33
+
34
+ await ctx.exposeFunction('__skopixPreviewRunAll', async ({ fromIndex }) => {
35
+ const session = stepTesterSessions.get(testerId);
36
+ if (!session) return;
37
+ for (let i = fromIndex; i < steps.length; i++) {
38
+ const s = steps[i];
39
+ await page.evaluate(({ i, total }) => { if(window.__skopixUpdatePreview) window.__skopixUpdatePreview(i, null, `Running step ${i+1}/${total}...`); }, { i, total: steps.length }).catch(()=>{});
40
+ const result = await executeStepTesterAction(page, { selector: s.stableSelector||s.selector, action: s.action, value: s.value||'', assertType: s.assertType });
41
+ if (result.passed) session.currentStep = i + 1;
42
+ if (!session.results) session.results = {};
43
+ session.results[i] = { passed: result.passed, error: result.error||null };
44
+ const msg = result.passed ? (i+1 >= steps.length ? `✓ All ${steps.length} steps passed!` : `Running...`) : `✗ Step ${i+1} failed — fix and retry`;
45
+ await page.evaluate(({ i, r, msg, currentStep }) => {
46
+ if(window.__skopixUpdatePreview) window.__skopixUpdatePreview(i, r, msg);
47
+ window.__skopixPreviewCurrentStep = currentStep;
48
+ }, { i, r: { passed: result.passed, error: result.error||null }, msg, currentStep: session.currentStep || 0 }).catch(()=>{});
49
+ if (!result.passed) break;
50
+ await new Promise(r => setTimeout(r, 400));
51
+ }
52
+ });
53
+
54
+ await ctx.exposeFunction('__skopixStopPreview', async () => {
55
+ try { await browser.close(); } catch {}
56
+ stepTesterSessions.delete(testerId);
57
+ });
58
+
59
+ await ctx.exposeFunction('__skopixGetState', async () => {
60
+ const session = stepTesterSessions.get(testerId);
61
+ return { currentStep: session?.currentStep || 0, results: session?.results || {} };
62
+ });
63
+
64
+ // THEN inject toolbar via addInitScript
65
+ // PREVIEW MODE — inject steps list toolbar
66
+ await ctx.addInitScript((stepsData) => {
67
+ window.__skopixPreviewSteps = stepsData;
68
+ window.__skopixPreviewResults = {};
69
+ document.addEventListener('DOMContentLoaded', () => {
70
+ if (document.getElementById('__skopix_preview')) return;
71
+ // Push page content left to avoid covering elements
72
+ document.body.style.marginRight = '320px';
73
+ document.body.style.boxSizing = 'border-box';
74
+ // Also fix any fixed/sticky headers
75
+ const fixedEls = Array.from(document.querySelectorAll('*')).filter(el => {
76
+ const s = window.getComputedStyle(el);
77
+ return (s.position === 'fixed' || s.position === 'sticky') && el.id !== '__skopix_preview';
78
+ });
79
+ fixedEls.forEach(el => {
80
+ const s = window.getComputedStyle(el);
81
+ const right = parseInt(s.right) || 0;
82
+ el.style.right = (right + 320) + 'px';
83
+ el.setAttribute('data-skopix-fixed', '1');
84
+ });
85
+ const tb = document.createElement('div');
86
+ tb.id = '__skopix_preview';
87
+ tb.style.cssText = [
88
+ 'position:fixed', 'top:0', 'right:0', 'bottom:0', 'width:320px',
89
+ 'z-index:2147483647', 'background:#0d0d1a', 'border-left:1px solid rgba(245,158,11,0.4)',
90
+ 'display:flex', 'flex-direction:column', 'font-family:monospace', 'font-size:12px',
91
+ 'box-shadow:-4px 0 24px rgba(0,0,0,0.5)',
92
+ ].join(';');
93
+ tb.innerHTML = `
94
+ <div style="padding:12px 16px;border-bottom:1px solid rgba(245,158,11,0.2);background:rgba(245,158,11,0.06);flex-shrink:0">
95
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
96
+ <div style="display:flex;align-items:center;gap:8px">
97
+ <span style="color:#f59e0b">⚡</span>
98
+ <span style="color:#f59e0b;font-size:10px;letter-spacing:0.1em">PREVIEW</span>
99
+ </div>
100
+ <div style="display:flex;gap:6px">
101
+ <button id="__skopix_run_next" style="background:transparent;border:1px solid rgba(245,158,11,0.4);color:#f59e0b;
102
+ border-radius:6px;padding:4px 10px;cursor:pointer;font-size:11px;font-family:monospace">▶ Next</button>
103
+ <button id="__skopix_run_all" style="background:#f59e0b;border:none;color:#000;
104
+ border-radius:6px;padding:4px 10px;cursor:pointer;font-size:11px;font-family:monospace;font-weight:700">▶▶ All</button>
105
+ <button id="__skopix_stop_preview" style="background:transparent;border:1px solid #374151;color:#9ca3af;
106
+ border-radius:6px;padding:4px 10px;cursor:pointer;font-size:11px;font-family:monospace">✕ Stop</button>
107
+ </div>
108
+ </div>
109
+ <div id="__skopix_preview_status" style="color:#9ca3af;font-size:11px">Navigate to start, then click Run</div>
110
+ </div>
111
+ <div id="__skopix_steps" style="overflow-y:auto;flex:1;padding:0"></div>
112
+ <div id="__skopix_preview_summary" style="display:none;padding:12px 16px;border-top:1px solid rgba(255,255,255,0.08);font-size:12px"></div>
113
+ `;
114
+ document.body.appendChild(tb);
115
+
116
+ function renderSteps(currentIdx, results) {
117
+ const actionColors = { click:'#22d3ee', type:'#a78bfa', check:'#34d399', assert:'#fb923c', select:'#f472b6', scroll:'#6b7280' };
118
+ document.getElementById('__skopix_steps').innerHTML = stepsData.map((s, i) => {
119
+ const r = results[i];
120
+ const isCurrent = i === currentIdx && !r;
121
+ const status = r ? (r.passed ? '✓' : '✗') : (isCurrent ? '▶' : '○');
122
+ const statusColor = r ? (r.passed ? '#34d399' : '#ef4444') : (isCurrent ? '#f59e0b' : '#374151');
123
+ const bg = isCurrent ? 'rgba(245,158,11,0.08)' : r && !r.passed ? 'rgba(239,68,68,0.06)' : '';
124
+ return `<div style="padding:8px 14px;border-bottom:1px solid rgba(255,255,255,0.05);display:flex;gap:10px;align-items:flex-start;background:${bg}">
125
+ <span style="color:${statusColor};font-size:13px;margin-top:1px;flex-shrink:0">${status}</span>
126
+ <div style="min-width:0">
127
+ <div style="display:flex;align-items:center;gap:6px;margin-bottom:2px">
128
+ <span style="color:${actionColors[s.action]||'#6b7280'};font-size:10px">${s.action}</span>
129
+ <span style="color:#e5e7eb;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px">${s.description||''}</span>
130
+ </div>
131
+ <code style="color:#4b5563;font-size:10px;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${s.selector||''}</code>
132
+ ${r && !r.passed ? `<div style="color:#ef4444;font-size:10px;margin-top:3px">${(r.error||'Failed').slice(0,80)}</div>` : ''}
133
+ </div>
134
+ </div>`;
135
+ }).join('');
136
+ }
137
+
138
+ window.__skopixPreviewCurrentStep = 0;
139
+ window.__skopixPreviewResults = {};
140
+ renderSteps(0, {});
141
+
142
+ document.getElementById('__skopix_run_next').addEventListener('click', (e) => {
143
+ e.stopPropagation();
144
+ if (window.__skopixPreviewRun) window.__skopixPreviewRun({ index: window.__skopixPreviewCurrentStep });
145
+ });
146
+
147
+ document.getElementById('__skopix_run_all').addEventListener('click', (e) => {
148
+ e.stopPropagation();
149
+ if (window.__skopixPreviewRunAll) window.__skopixPreviewRunAll({ fromIndex: window.__skopixPreviewCurrentStep });
150
+ });
151
+
152
+ document.getElementById('__skopix_stop_preview').addEventListener('click', (e) => {
153
+ e.stopPropagation();
154
+ if (window.__skopixStopPreview) window.__skopixStopPreview({});
155
+ });
156
+
157
+ // Restore state from server after navigation
158
+ if (window.__skopixGetState) {
159
+ window.__skopixGetState({}).then(state => {
160
+ if (state && state.currentStep > 0) {
161
+ window.__skopixPreviewCurrentStep = state.currentStep;
162
+ window.__skopixPreviewResults = state.results || {};
163
+ renderSteps(state.currentStep, state.results || {});
164
+ }
165
+ }).catch(() => {});
166
+ }
167
+
168
+ window.__skopixUpdatePreview = (index, result, status) => {
169
+ window.__skopixPreviewResults[index] = result;
170
+ if (result && result.passed) window.__skopixPreviewCurrentStep = index + 1;
171
+ document.getElementById('__skopix_preview_status').textContent = status || '';
172
+ renderSteps(window.__skopixPreviewCurrentStep, window.__skopixPreviewResults);
173
+ // Highlight element
174
+ if (result) {
175
+ try {
176
+ const target = document.querySelector(stepsData[index]?.selector || '');
177
+ if (target) {
178
+ const orig = target.style.outline;
179
+ target.style.outline = result.passed ? '3px solid #34d399' : '3px solid #ef4444';
180
+ setTimeout(() => { target.style.outline = orig; }, 1500);
181
+ }
182
+ } catch {}
183
+ }
184
+ // Update summary
185
+ const total = Object.keys(window.__skopixPreviewResults).length;
186
+ const passed = Object.values(window.__skopixPreviewResults).filter(r=>r&&r.passed).length;
187
+ const failed = total - passed;
188
+ if (total > 0) {
189
+ const summary = document.getElementById('__skopix_preview_summary');
190
+ summary.style.display = '';
191
+ summary.innerHTML = `<span style="color:#34d399">✓ ${passed}</span><span style="color:#4b5563"> / </span><span style="color:${failed>0?'#ef4444':'#4b5563'}">✗ ${failed}</span><span style="color:#4b5563"> of ${stepsData.length}</span>`;
192
+ }
193
+ };
194
+ });
195
+ }, steps.map(s => ({ action: s.action, selector: s.stableSelector||s.selector||'', description: s.description||s.action, value: s.value||'' })));
196
+
197
+ } else {
198
+ // STEP TESTER MODE — register expose functions FIRST
199
+ await ctx.exposeFunction('__skopixTesterRun', async ({ sel, action, value }) => {
200
+ const result = await executeStepTesterAction(page, { selector: sel, action, value });
201
+ await page.evaluate(({ passed }) => {
202
+ const el = document.getElementById('__skopix_result');
203
+ if (el) { el.textContent = passed ? '✓' : '✗'; el.style.color = passed ? '#34d399' : '#ef4444'; }
204
+ }, { passed: result.passed }).catch(async () => {
205
+ await new Promise(r => setTimeout(r, 500));
206
+ await page.evaluate(({ passed }) => {
207
+ const el = document.getElementById('__skopix_result');
208
+ if (el) { el.textContent = passed ? '✓' : '✗'; el.style.color = passed ? '#34d399' : '#ef4444'; }
209
+ }, { passed: result.passed }).catch(() => {});
210
+ });
211
+ await page.evaluate(({ passed, sel }) => {
212
+ try {
213
+ const target = document.querySelector(sel);
214
+ if (target) {
215
+ const orig = target.style.outline;
216
+ target.style.outline = passed ? '3px solid #34d399' : '3px solid #ef4444';
217
+ setTimeout(() => { target.style.outline = orig; }, 1500);
218
+ }
219
+ } catch {}
220
+ }, { passed: result.passed, sel }).catch(() => {});
221
+ });
222
+
223
+ await ctx.exposeFunction('__skopixTesterStop', async () => {
224
+ try { await browser.close(); } catch {}
225
+ stepTesterSessions.delete(testerId);
226
+ });
227
+
228
+ // THEN inject toolbar
229
+ await ctx.addInitScript((sel) => {
230
+ window.__skopixTesterSelector = sel;
231
+ document.addEventListener('DOMContentLoaded', () => {
232
+ if (document.getElementById('__skopix_tester')) return;
233
+ const tb = document.createElement('div');
234
+ tb.id = '__skopix_tester';
235
+ tb.style.cssText = [
236
+ 'position:fixed', 'bottom:20px', 'left:50%', 'transform:translateX(-50%)',
237
+ 'z-index:2147483647', 'background:#0f1117', 'border:1px solid #f59e0b',
238
+ 'border-radius:12px', 'padding:12px 16px', 'display:flex', 'align-items:center',
239
+ 'gap:10px', 'font-family:monospace', 'font-size:12px', 'color:#e5e7eb',
240
+ 'box-shadow:0 4px 24px rgba(0,0,0,0.7)', 'min-width:400px',
241
+ ].join(';');
242
+ tb.innerHTML = `
243
+ <span style="color:#f59e0b;font-size:14px">⚡</span>
244
+ <span style="color:#9ca3af;font-size:11px">STEP TESTER</span>
245
+ <input id="__skopix_sel" value="${(sel||'').replace(/"/g,'&quot;')}" placeholder="selector..."
246
+ style="flex:1;background:#1a1d2e;border:1px solid #374151;border-radius:6px;padding:4px 8px;
247
+ color:#e5e7eb;font-family:monospace;font-size:11px;min-width:160px">
248
+ <select id="__skopix_action" style="background:#1a1d2e;border:1px solid #374151;border-radius:6px;
249
+ padding:4px 8px;color:#e5e7eb;font-family:monospace;font-size:11px">
250
+ <option value="click">click</option>
251
+ <option value="type">type</option>
252
+ <option value="check">check</option>
253
+ <option value="assert">assert (visible)</option>
254
+ <option value="assert_text">assert (text)</option>
255
+ </select>
256
+ <input id="__skopix_value" placeholder="value..." style="width:100px;background:#1a1d2e;
257
+ border:1px solid #374151;border-radius:6px;padding:4px 8px;color:#e5e7eb;
258
+ font-family:monospace;font-size:11px;display:none">
259
+ <button id="__skopix_run" style="background:#f59e0b;border:none;color:#000;
260
+ border-radius:6px;padding:5px 14px;cursor:pointer;font-size:11px;font-weight:700;
261
+ font-family:monospace">▶ Run</button>
262
+ <span id="__skopix_result" style="font-size:13px;min-width:20px"></span>
263
+ <button id="__skopix_stop" style="background:transparent;border:1px solid #374151;color:#9ca3af;
264
+ border-radius:6px;padding:5px 10px;cursor:pointer;font-size:11px;font-family:monospace">✕ Stop</button>
265
+ `;
266
+ document.body.appendChild(tb);
267
+ document.getElementById('__skopix_action').addEventListener('change', function() {
268
+ const needsValue = ['type','assert_text'].includes(this.value);
269
+ document.getElementById('__skopix_value').style.display = needsValue ? '' : 'none';
270
+ });
271
+ document.getElementById('__skopix_run').addEventListener('click', function(e) {
272
+ e.stopPropagation();
273
+ const sel = document.getElementById('__skopix_sel').value.trim();
274
+ const action = document.getElementById('__skopix_action').value;
275
+ const value = document.getElementById('__skopix_value').value.trim();
276
+ document.getElementById('__skopix_result').textContent = '⏳';
277
+ if (window.__skopixTesterRun) window.__skopixTesterRun({ sel, action, value });
278
+ });
279
+ document.getElementById('__skopix_stop').addEventListener('click', function(e) {
280
+ e.stopPropagation();
281
+ if (window.__skopixTesterStop) window.__skopixTesterStop({});
282
+ });
283
+ });
284
+ }, selector || '');
285
+ }
286
+
287
+ if (url) await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {});
288
+ // Ensure toolbar is injected on first page (DOMContentLoaded may have already fired)
289
+ await page.evaluate(() => { if (window.__skopixTesterSelector !== undefined && !document.getElementById('__skopix_tester') && !document.getElementById('__skopix_preview')) { document.dispatchEvent(new Event('DOMContentLoaded')); } }).catch(() => {});
290
+ stepTesterSessions.set(testerId, { browser, ctx, page, currentStep: 0, results: {} });
291
+ }
292
+
293
+ async function executeStepTesterAction(page, { selector, action, value }) {
294
+ try {
295
+ const { sanitiseSelector } = { sanitiseSelector: (s) => s }; // inline since not exported
296
+ const sel = selector;
297
+ if (action === 'click') {
298
+ await page.locator(sel).first().click({ timeout: 5000 });
299
+ } else if (action === 'type') {
300
+ await page.locator(sel).first().fill(value || '', { timeout: 5000 });
301
+ } else if (action === 'check') {
302
+ await page.locator(sel).first().click({ timeout: 5000 });
303
+ } else if (action === 'assert' || action === 'assert_text') {
304
+ if (action === 'assert_text') {
305
+ const text = await page.locator(sel).first().textContent({ timeout: 5000 });
306
+ if (!text.includes(value)) throw new Error(`Text "${text}" does not contain "${value}"`);
307
+ } else {
308
+ await page.locator(sel).first().waitFor({ state: 'attached', timeout: 5000 });
309
+ }
310
+ }
311
+ const screenshot = await page.screenshot({ type: 'jpeg', quality: 70 }).catch(() => null);
312
+ return { passed: true, screenshot: screenshot ? screenshot.toString('base64') : null };
313
+ } catch (err) {
314
+ const screenshot = await page.screenshot({ type: 'jpeg', quality: 70 }).catch(() => null);
315
+ return { passed: false, error: err.message, screenshot: screenshot ? screenshot.toString('base64') : null };
316
+ }
317
+ }
318
+
319
+ async function runStepTesterAction(testerId, stepData) {
320
+ const session = stepTesterSessions.get(testerId);
321
+ if (!session) return { passed: false, error: 'Session not found' };
322
+ return await executeStepTesterAction(session.page, stepData);
323
+ }
324
+
325
+ async function stopStepTester(testerId) {
326
+ const session = stepTesterSessions.get(testerId);
327
+ if (!session) return;
328
+ try { await session.browser.close(); } catch {}
329
+ stepTesterSessions.delete(testerId);
330
+ }
331
+
332
+ export { startStepTester, executeStepTesterAction, runStepTesterAction, stopStepTester };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skopix",
3
- "version": "2.0.107",
3
+ "version": "2.0.109",
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": {