skopix 2.0.104 → 2.0.105

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/core/recorder.js +455 -92
  2. package/package.json +1 -1
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
- // instead of launching a fresh browser via launch()
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
- // Expose capture function on the existing context
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(() => {}); // may already be exposed
613
+ }).catch(() => {});
616
614
 
617
- // Inject the init script into the existing page
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 = []; let cur = el; let depth = 0;
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); cur = cur.parentElement; depth++;
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 = { tag: el.tagName.toLowerCase(), id: el.id || null, name: el.name || null, type: el.type || null, text: (el.innerText || el.value || el.placeholder || ariaLabel || title || '').trim().slice(0, 80), selector: getSelector(el), classes: el.className ? el.className.toString().trim().slice(0, 100) : null, title, ariaLabel, piTestId, dataTestId };
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) { const p = el.parentElement; info.parentTag = p.tagName.toLowerCase(); info.parentClasses = p.className ? p.className.toString().trim().slice(0, 100) : null; info.parentSelector = getSelector(p); info.parentAriaLabel = p.getAttribute('aria-label') || null; info.parentTitle = p.getAttribute('title') || null; info.parentTestId = p.getAttribute('pi-test-identifier') || p.getAttribute('data-testid') || null; }
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
- 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; }
659
- if (window.__skopixPickMode) return;
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) checkTarget = document.getElementById(el.htmlFor);
667
- if (!isCheckable && el.tagName === 'LABEL' && !el.htmlFor) checkTarget = el.querySelector('input[type="checkbox"], input[type="radio"]');
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
- window.__skopixCapture({ action: 'check', checked: actualCheckable.checked, element: getElementInfo(actualCheckable), 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) });
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({ 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) });
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, lastInputEl = 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 && (el.closest('#__skopix_toolbar') || el.closest('#__skopix_popover'))) return;
683
- lastInputEl = el; clearTimeout(typeTimer);
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) window.__skopixCapture({ action: 'type', element: getElementInfo(lastInputEl), value: lastInputEl.value, isPassword: lastInputEl.type === 'password' });
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 && (el.closest('#__skopix_toolbar') || el.closest('#__skopix_popover'))) return;
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) window.__skopixCapture({ action: 'select', element: getElementInfo(el), value: selected ? selected.value : '', label: selected ? selected.text : '' });
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 = 'position:fixed;bottom:20px;right:20px;z-index:2147483647;display:flex;align-items:center;gap:8px;background:#0d0d1a;border:1px solid rgba(0,212,255,0.3);border-radius:10px;padding:10px 14px;font-family:monospace;font-size:12px;box-shadow:0 4px 24px rgba(0,0,0,0.5);user-select:none;';
704
- toolbar.innerHTML = '<span style="color:#00d4ff;font-weight:600;letter-spacing:0.08em">SKOPIX</span><span id="__skopix_step_count" style="color:#5a6180;font-size:11px">0 steps</span><button id="__skopix_assert_btn" style="background:#1a1d2e;border:1px solid rgba(0,212,255,0.2);color:#00d4ff;border-radius:6px;padding:5px 10px;cursor:pointer;font-family:monospace;font-size:11px">+ Assert</button><button id="__skopix_stop_btn" style="background:#1a1d2e;border:1px solid rgba(239,68,68,0.3);color:#ef4444;border-radius:6px;padding:5px 10px;cursor:pointer;font-family:monospace;font-size:11px">■ Stop</button>';
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
- document.getElementById('__skopix_stop_btn').addEventListener('click', function() { if (window.__skopixCapture) window.__skopixCapture({ action: 'stop' }); });
707
- document.getElementById('__skopix_assert_btn').addEventListener('click', function() { window.__skopixPickMode = true; document.body.style.cursor = 'crosshair'; });
708
- document.addEventListener('click', function assertPick(e) {
709
- if (!window.__skopixPickMode) return;
710
- if (e.target.closest && (e.target.closest('#__skopix_toolbar') || e.target.closest('#__skopix_popover'))) return;
711
- e.stopPropagation(); e.preventDefault();
712
- window.__skopixPickMode = false; document.body.style.cursor = '';
713
- const el = e.target; const sel = getSelector(el); const currentText = (el.innerText || el.value || '').trim().slice(0, 80);
714
- const suggestedType = el.tagName === 'IMG' ? 'visible' : currentText ? 'text_contains' : 'visible';
715
- const suggestedValue = currentText || '';
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: &quot;' + currentText.slice(0, 50) + (currentText.length > 50 ? '...' : '') + '&quot;</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, "&quot;") + '" 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
- if (document.body) { createToolbar(); } else { document.addEventListener('DOMContentLoaded', createToolbar); }
742
- new MutationObserver(() => { if (!document.getElementById('__skopix_toolbar')) createToolbar(); }).observe(document.documentElement, { childList: true, subtree: false });
743
- window.__skopixUpdateCount = function(n) { const el = document.getElementById('__skopix_step_count'); if (el) el.textContent = n + ' step' + (n === 1 ? '' : 's'); };
744
- });
849
+ function updateStepCount(n) {
850
+ const el = document.getElementById('__skopix_count');
851
+ if (el) el.textContent = n;
852
+ }
745
853
 
746
- // Re-evaluate init script on the current page (since addInitScript only runs on navigation)
747
- // Use evaluate to inject directly into the live page without reloading
748
- try {
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
- // Directly inject the toolbar into the current live page
760
- try {
761
- await this.page.evaluate(() => {
762
- if (document.getElementById('__skopix_toolbar')) return;
763
- const toolbar = document.createElement('div');
764
- toolbar.id = '__skopix_toolbar';
765
- toolbar.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:2147483647;display:flex;align-items:center;gap:8px;background:#0d0d1a;border:1px solid rgba(0,212,255,0.3);border-radius:10px;padding:10px 14px;font-family:monospace;font-size:12px;box-shadow:0 4px 24px rgba(0,0,0,0.5);user-select:none;';
766
- toolbar.innerHTML = '<span style="color:#00d4ff;font-weight:600;letter-spacing:0.08em">SKOPIX</span><span id="__skopix_step_count" style="color:#5a6180;font-size:11px">0 steps</span><button id="__skopix_assert_btn" style="background:#1a1d2e;border:1px solid rgba(0,212,255,0.2);color:#00d4ff;border-radius:6px;padding:5px 10px;cursor:pointer;font-family:monospace;font-size:11px">+ Assert</button><button id="__skopix_stop_btn" style="background:#1a1d2e;border:1px solid rgba(239,68,68,0.3);color:#ef4444;border-radius:6px;padding:5px 10px;cursor:pointer;font-family:monospace;font-size:11px">■ Stop</button>';
767
- document.body.appendChild(toolbar);
768
- document.getElementById('__skopix_stop_btn').addEventListener('click', function() {
769
- if (window.__skopixCapture) window.__skopixCapture({ action: 'stop' });
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, '&quot;')}" 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
- document.getElementById('__skopix_assert_btn').addEventListener('click', function() {
772
- window.__skopixPickMode = true;
773
- document.body.style.cursor = 'crosshair';
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
- } catch {}
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
- async _captureStep(actionData) {
1154
+ async _captureStep(actionData) {
792
1155
  const id = this.nextId();
793
1156
  const page = this.page;
794
1157
  const step = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skopix",
3
- "version": "2.0.104",
3
+ "version": "2.0.105",
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": {