skopix 2.0.105 → 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.
Files changed (2) hide show
  1. package/cli/commands/agent.js +222 -47
  2. package/package.json +1 -1
@@ -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 to debug point') + (startUrl ? chalk.dim(' @ ' + startUrl) : ''));
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: ''.repeat(60) });
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
- const browser = await chromium.launch({ headless: false, args: ['--no-sandbox', '--disable-blink-features=AutomationControlled', '--allow-insecure-localhost'] });
400
- const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
401
- const page = await ctx.newPage();
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 each step to reach debug point
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
- sendRun({ type: 'stdout', text: ' [' + stepNum + '/' + replaySteps.length + '] ' + (step.action || '').toUpperCase() + ' — ' + (step.description || step.stableSelector || step.selector || '') });
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
- sendRun({ type: 'stdout', text: ' ✖ FAILED at step ' + stepNum + ': ' + err.message });
418
- sendRun({ type: 'done', exitCode: 1, status: 'failed' });
419
- sendRun({ type: 'stdout', text: '<span style="color:var(--red)">✗ Replay failed — could not reach debug point</span>' });
420
- await browser.close().catch(() => {});
421
- ws.send(JSON.stringify({ type: 'jobDone', runId }));
422
- return;
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: ' Reached debug point starting recorder' });
428
-
429
- // Attach RecordingSession to the existing browser — injects toolbar into current page
430
- const screenshotDir = path.join(os.homedir(), '.skopix', 'recordings', recordingId);
431
- await fs.ensureDir(screenshotDir);
432
-
433
- const { RecordingSession } = await import('../../core/recorder.js');
434
- const session = new RecordingSession({ url: startUrl || msg.startUrl || '', sessionId: recordingId, screenshotDir });
435
-
436
- // Override emit to forward over WebSocket
437
- session.emit = (obj) => {
438
- sendRec(obj);
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.cyan(' Waiting for jobs\n'));
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
- // Attach to existing browser/context/page injects toolbar without new browser
449
- await session.attachTo(browser, ctx, page);
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
- // Listen for stop signal from server
452
- const stopHandler = (event) => {
453
- let m; try { m = JSON.parse(event.data); } catch { return; }
454
- if (m.type === 'stopRecord' && m.recordingId === recordingId) {
455
- session.stop().catch(() => {});
456
- ws.removeEventListener('message', stopHandler);
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
- ws.addEventListener('message', stopHandler);
507
+ function getElementInfo(el) {
508
+ return { tag: el.tagName.toLowerCase(), id: el.id||null, name: el.name||null, type: el.type||null,
509
+ text: (el.innerText||el.value||el.placeholder||el.getAttribute('aria-label')||'').trim().slice(0,80),
510
+ selector: getSelector(el), classes: el.className ? el.className.toString().trim().slice(0,100) : null,
511
+ piTestId: el.getAttribute('pi-test-identifier')||null, title: el.getAttribute('title')||null,
512
+ ariaLabel: el.getAttribute('aria-label')||null, dataTestId: el.getAttribute('data-testid')||null };
513
+ }
514
+
515
+ document.addEventListener('click', function(e) {
516
+ if (e.target && e.target.closest) { if (e.target.closest('#__skopix_toolbar')) return; if (e.target.closest('#__skopix_popover')) return; if (e.target.closest('#__skopix_hint')) return; }
517
+ if (window.__skopixPickMode) return;
518
+ const el = e.target;
519
+ if (!el || el === document.body || el === document.documentElement) return;
520
+ const rect = el.getBoundingClientRect();
521
+ const isCheckable = el.type === 'checkbox' || el.type === 'radio';
522
+ let checkTarget = null;
523
+ if (!isCheckable && el.tagName === 'LABEL' && el.htmlFor) checkTarget = document.getElementById(el.htmlFor);
524
+ if (!isCheckable && el.tagName === 'LABEL' && !el.htmlFor) checkTarget = el.querySelector('input[type="checkbox"],input[type="radio"]');
525
+ const actual = isCheckable ? el : checkTarget;
526
+ if (actual && (actual.type === 'checkbox' || actual.type === 'radio')) {
527
+ window.__skopixCapture && window.__skopixCapture({ action:'check', checked:actual.checked, element:getElementInfo(actual), clickX:Math.round(e.clientX), clickY:Math.round(e.clientY), elementX:Math.round(rect.left+rect.width/2), elementY:Math.round(rect.top+rect.height/2) });
528
+ } else {
529
+ window.__skopixCapture && window.__skopixCapture({ action:'click', element:getElementInfo(el), clickX:Math.round(e.clientX), clickY:Math.round(e.clientY), elementX:Math.round(rect.left+rect.width/2), elementY:Math.round(rect.top+rect.height/2) });
530
+ }
531
+ }, true);
532
+
533
+ let typeTimer = null, lastInputEl = null;
534
+ document.addEventListener('input', function(e) {
535
+ const el = e.target;
536
+ if (!el || !['INPUT','TEXTAREA'].includes(el.tagName)) return;
537
+ if (el.type === 'checkbox' || el.type === 'radio') return;
538
+ if (el.closest && (el.closest('#__skopix_toolbar') || el.closest('#__skopix_popover'))) return;
539
+ lastInputEl = el; clearTimeout(typeTimer);
540
+ typeTimer = setTimeout(() => { if (!lastInputEl) return; window.__skopixCapture && window.__skopixCapture({ action:'type', element:getElementInfo(lastInputEl), value:lastInputEl.value, isPassword:lastInputEl.type==='password' }); lastInputEl = null; }, 600);
541
+ }, true);
542
+
543
+ document.addEventListener('change', function(e) {
544
+ const el = e.target; if (!el || el.tagName !== 'SELECT') return;
545
+ if (el.closest && (el.closest('#__skopix_toolbar') || el.closest('#__skopix_popover'))) return;
546
+ const sel2 = el.options[el.selectedIndex];
547
+ window.__skopixCapture && window.__skopixCapture({ action:'select', element:getElementInfo(el), value:el.value, label:sel2?sel2.text:el.value });
548
+ }, true);
549
+
550
+ // Toolbar
551
+ const tb = document.createElement('div');
552
+ tb.id = '__skopix_toolbar';
553
+ tb.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:2147483647;background:#0f1117;border:2px solid #f59e0b;border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:10px;font-family:monospace;font-size:12px;color:#e5e7eb;box-shadow:0 4px 24px rgba(0,0,0,0.6);user-select:none';
554
+ tb.innerHTML = '<span style="color:#f59e0b;font-size:14px;animation:skopix_pulse 1s infinite">\u25cf</span>'
555
+ + '<span style="color:#9ca3af">Debug recording</span>'
556
+ + '<span id="__skopix_count" style="color:#22d3ee;font-weight:700;min-width:20px;text-align:center">0</span>'
557
+ + '<span style="color:#4b5563">steps</span>'
558
+ + '<button id="__skopix_assert_btn" style="background:#1e3a5f;border:1px solid #2563eb;color:#60a5fa;border-radius:6px;padding:4px 10px;cursor:pointer;font-size:11px;font-family:monospace">+ Assert</button>'
559
+ + '<button id="__skopix_stop_btn" style="background:#3f0d0d;border:1px solid #dc2626;color:#f87171;border-radius:6px;padding:4px 10px;cursor:pointer;font-size:11px;font-family:monospace">\u25a0 Stop</button>';
560
+ const sty = document.createElement('style');
561
+ sty.textContent = '@keyframes skopix_pulse{0%,100%{opacity:1}50%{opacity:0.3}}';
562
+ document.head.appendChild(sty);
563
+ document.body.appendChild(tb);
564
+ tb.querySelector('#__skopix_stop_btn').addEventListener('click', e => { e.stopPropagation(); window.__skopixCapture && window.__skopixCapture({ action:'stop' }); });
565
+ window.__skopixUpdateCount = n => { const el = document.getElementById('__skopix_count'); if (el) el.textContent = n; };
566
+
567
+ // Assert picker
568
+ tb.querySelector('#__skopix_assert_btn').addEventListener('click', function(e) {
569
+ e.stopPropagation();
570
+ window.__skopixPickMode = true;
571
+ document.body.style.cursor = 'crosshair';
572
+ const hint = document.createElement('div'); hint.id = '__skopix_hint';
573
+ hint.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:2147483647;background:#1e3a5f;border:1px solid #2563eb;color:#60a5fa;padding:8px 18px;border-radius:8px;font-family:monospace;font-size:13px;pointer-events:none';
574
+ hint.textContent = 'Click any element to add an assertion \u2014 Esc to cancel';
575
+ document.body.appendChild(hint);
576
+ const overlay = document.createElement('div');
577
+ overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483646;pointer-events:auto;cursor:crosshair';
578
+ document.body.appendChild(overlay);
579
+ const hl = document.createElement('div');
580
+ hl.style.cssText = 'position:fixed;pointer-events:none;z-index:2147483645;background:rgba(37,99,235,0.15);border:2px solid #2563eb;border-radius:3px;transition:all 0.1s;display:none';
581
+ document.body.appendChild(hl);
582
+ overlay.addEventListener('mousemove', function(e2) {
583
+ overlay.style.pointerEvents = 'none';
584
+ const el2 = document.elementFromPoint(e2.clientX, e2.clientY);
585
+ overlay.style.pointerEvents = 'auto';
586
+ if (!el2 || el2 === document.body) { hl.style.display = 'none'; return; }
587
+ const r = el2.getBoundingClientRect();
588
+ hl.style.display = 'block'; hl.style.top = r.top+'px'; hl.style.left = r.left+'px'; hl.style.width = r.width+'px'; hl.style.height = r.height+'px';
589
+ });
590
+ overlay.addEventListener('click', function(e2) {
591
+ e2.preventDefault(); e2.stopPropagation();
592
+ overlay.style.pointerEvents = 'none';
593
+ const el2 = document.elementFromPoint(e2.clientX, e2.clientY);
594
+ overlay.style.pointerEvents = 'auto';
595
+ window.__skopixPickMode = false; document.body.style.cursor = '';
596
+ overlay.remove(); const h2 = document.getElementById('__skopix_hint'); if (h2) h2.remove();
597
+ if (!el2 || el2 === document.body) { hl.style.display = 'none'; return; }
598
+ const sel2 = getSelector(el2);
599
+ const txt = (el2.innerText||el2.textContent||'').trim().slice(0,100);
600
+ const rect2 = el2.getBoundingClientRect();
601
+ hl.style.background = 'rgba(34,197,94,0.15)'; hl.style.borderColor = '#22c55e';
602
+ hl.style.display = 'block'; hl.style.top = rect2.top+'px'; hl.style.left = rect2.left+'px'; hl.style.width = rect2.width+'px'; hl.style.height = rect2.height+'px';
603
+ let sugType = 'visible', sugVal = '';
604
+ if (txt && txt.length > 0 && txt.length < 80) { sugType = 'text_contains'; sugVal = txt.replace(/\s+/g,' ').trim(); }
605
+ const popover = document.createElement('div'); popover.id = '__skopix_popover';
606
+ const topPos = rect2.bottom+8+280 > window.innerHeight ? Math.max(8,rect2.top-288) : rect2.bottom+8;
607
+ popover.style.cssText = 'position:fixed;z-index:2147483647;top:'+topPos+'px;left:'+Math.min(rect2.left,window.innerWidth-360)+'px;width:350px;background:#0f1117;border:1px solid #2563eb;border-radius:10px;padding:16px;font-family:monospace;font-size:12px;color:#e5e7eb;box-shadow:0 8px 32px rgba(0,0,0,0.7)';
608
+ popover.innerHTML = '<div style="color:#60a5fa;font-size:11px;letter-spacing:0.1em;margin-bottom:12px">ADD ASSERTION</div>'
609
+ + '<div style="margin-bottom:10px"><div style="color:#9ca3af;font-size:10px;margin-bottom:4px">ELEMENT</div><div style="background:#1a1d2e;padding:6px 10px;border-radius:6px;color:#22d3ee;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+sel2+'</div>'+(txt?'<div style="color:#6b7280;font-size:10px;margin-top:3px">Text: "'+txt.slice(0,50)+'"</div>':'')+'</div>'
610
+ + '<div style="margin-bottom:10px"><div style="color:#9ca3af;font-size:10px;margin-bottom:4px">TYPE</div><select id="__skopix_assert_type" style="width:100%;background:#1a1d2e;border:1px solid #374151;color:#e5e7eb;padding:6px 8px;border-radius:6px;font-family:monospace;font-size:12px"><option value="visible"'+(sugType==="visible"?" selected":"")+'>Element is visible</option><option value="text_contains"'+(sugType==="text_contains"?" selected":"")+'>Text contains</option><option value="text_equals">Text equals</option><option value="url_contains">URL contains</option><option value="element_count">Element count</option></select></div>'
611
+ + '<div id="__skopix_value_row" style="margin-bottom:10px;'+(sugType==="visible"?"display:none":"")+'"><div style="color:#9ca3af;font-size:10px;margin-bottom:4px">EXPECTED VALUE</div><input id="__skopix_assert_value" type="text" value="'+sugVal.replace(/"/g,"&quot;")+'" style="width:100%;box-sizing:border-box;background:#1a1d2e;border:1px solid #374151;color:#e5e7eb;padding:6px 8px;border-radius:6px;font-family:monospace;font-size:12px"></div>'
612
+ + '<div style="display:flex;gap:8px;justify-content:flex-end"><button id="__skopix_cancel_btn" style="background:transparent;border:1px solid #374151;color:#9ca3af;border-radius:6px;padding:6px 14px;cursor:pointer;font-family:monospace;font-size:12px">Cancel</button><button id="__skopix_add_btn" style="background:#1e3a5f;border:1px solid #2563eb;color:#60a5fa;border-radius:6px;padding:6px 14px;cursor:pointer;font-family:monospace;font-size:12px;font-weight:700">Add \u2713</button></div>';
613
+ document.body.appendChild(popover);
614
+ popover.querySelector('#__skopix_assert_type').addEventListener('change', function() {
615
+ const t = this.value; popover.querySelector('#__skopix_value_row').style.display = t==='visible'?'none':'';
616
+ });
617
+ popover.querySelector('#__skopix_cancel_btn').addEventListener('click', function(e3) { e3.stopPropagation(); hl.style.display='none'; popover.remove(); });
618
+ popover.querySelector('#__skopix_add_btn').addEventListener('click', function(e3) {
619
+ e3.stopPropagation();
620
+ const assertType = popover.querySelector('#__skopix_assert_type').value;
621
+ const value = popover.querySelector('#__skopix_assert_value').value.trim();
622
+ if (assertType !== 'visible' && assertType !== 'url_contains' && !value) { popover.querySelector('#__skopix_assert_value').style.borderColor='#dc2626'; return; }
623
+ window.__skopixCapture && window.__skopixCapture({ action:'assert', assertType, selector:assertType==='url_contains'?null:sel2, value:value||null, element:assertType==='url_contains'?null:{tag:el2.tagName.toLowerCase(),text:txt} });
624
+ hl.style.display='none'; popover.remove();
625
+ });
626
+ });
627
+ document.addEventListener('keydown', function escH(e2) { if (e2.key==='Escape') { window.__skopixPickMode=false; document.body.style.cursor=''; overlay.remove(); const h2=document.getElementById('__skopix_hint');if(h2)h2.remove(); hl.style.display='none'; document.removeEventListener('keydown',escH); } });
628
+ });
629
+ });
630
+
631
+ // 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(' Debug error: ' + err.message));
463
- sendRun({ type: 'stdout', text: ' Debug replay error: ' + err.message });
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
- sendRun({ type: 'stdout', text: '<span style="color:var(--red)">✗ Replay failed — could not reach debug point</span>' });
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
- // ── HELPERS ────────────────────────────────────────────────────────────────
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skopix",
3
- "version": "2.0.105",
3
+ "version": "2.0.106",
4
4
  "description": "Browser-based QA tool — record tests by using your app, replay them deterministically, generate Playwright code automatically",
5
5
  "main": "cli/index.js",
6
6
  "bin": {