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