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.
- package/cli/commands/agent.js +213 -36
- package/cli/commands/dashboard.js +25 -323
- package/core/step-tester.js +332 -0
- package/package.json +1 -1
package/cli/commands/agent.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
449
|
-
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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,""")+'" 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
|
|
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,
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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,'"')}" 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,'"')}" 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