skopix 2.0.103 → 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.
- package/core/recorder.js +472 -102
- 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
|
-
//
|
|
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
|
+
}
|
|
697
770
|
}, true);
|
|
698
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);
|
|
801
|
+
}, true);
|
|
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);
|
|
770
908
|
});
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
909
|
+
|
|
910
|
+
document.addEventListener('keydown', function escHandler(e) {
|
|
911
|
+
if (e.key === 'Escape') { stopPickMode(); document.removeEventListener('keydown', escHandler); }
|
|
774
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
|
+
}
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
popover.querySelector('#__skopix_assert_cancel').addEventListener('click', function(e) {
|
|
1064
|
+
e.stopPropagation();
|
|
1065
|
+
if (highlightEl) highlightEl.style.display = 'none';
|
|
1066
|
+
popover.remove();
|
|
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 = {
|
|
@@ -850,15 +1213,22 @@ export class RecordingSession {
|
|
|
850
1213
|
}
|
|
851
1214
|
}
|
|
852
1215
|
|
|
853
|
-
// ─── Entry point
|
|
854
|
-
const
|
|
855
|
-
|
|
1216
|
+
// ─── Entry point (only when run directly, not when imported as a module) ──────
|
|
1217
|
+
const isMain = process.argv[1] && (
|
|
1218
|
+
process.argv[1].endsWith('recorder.js') ||
|
|
1219
|
+
process.argv[1].includes('recorder')
|
|
1220
|
+
);
|
|
1221
|
+
|
|
1222
|
+
if (isMain && !process.argv[1].includes('node_modules/.bin')) {
|
|
1223
|
+
const [,, url, sessionId, screenshotDir] = process.argv;
|
|
1224
|
+
if (!url) { process.stderr.write('Usage: recorder.js <url> <sessionId> <screenshotDir>\n'); process.exit(1); }
|
|
856
1225
|
|
|
857
|
-
const session = new RecordingSession({ url, sessionId, screenshotDir });
|
|
858
|
-
session.launch().catch((err) => { session.emit({ type: 'error', message: err.message }); process.exit(1); });
|
|
1226
|
+
const session = new RecordingSession({ url, sessionId, screenshotDir });
|
|
1227
|
+
session.launch().catch((err) => { session.emit({ type: 'error', message: err.message }); process.exit(1); });
|
|
859
1228
|
|
|
860
|
-
process.stdin.setEncoding('utf8');
|
|
861
|
-
process.stdin.on('data', async (data) => {
|
|
862
|
-
|
|
863
|
-
});
|
|
864
|
-
process.on('SIGTERM', async () => { await session.stop(); process.exit(0); });
|
|
1229
|
+
process.stdin.setEncoding('utf8');
|
|
1230
|
+
process.stdin.on('data', async (data) => {
|
|
1231
|
+
if (data.trim() === 'stop') { await session.stop(); process.exit(0); }
|
|
1232
|
+
});
|
|
1233
|
+
process.on('SIGTERM', async () => { await session.stop(); process.exit(0); });
|
|
1234
|
+
}
|
package/package.json
CHANGED