skopix 2.0.0
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/.dockerignore +65 -0
- package/.github/workflows/docker.yml +78 -0
- package/cli/commands/agent.js +378 -0
- package/cli/commands/config.js +67 -0
- package/cli/commands/dashboard.js +3524 -0
- package/cli/commands/init.js +190 -0
- package/cli/commands/report.js +41 -0
- package/cli/commands/run.js +350 -0
- package/cli/index.js +85 -0
- package/cli/ui.js +126 -0
- package/core/auth.js +148 -0
- package/core/browser.js +1049 -0
- package/core/credentials.js +47 -0
- package/core/db.js +503 -0
- package/core/llm.js +641 -0
- package/core/recorder.js +653 -0
- package/core/reporter.js +282 -0
- package/core/tracker.js +768 -0
- package/package.json +54 -0
- package/web/app/index.html +5937 -0
- package/web/index.html +644 -0
- package/web/invite.html +244 -0
- package/web/login.html +271 -0
- package/web/reset.html +222 -0
- package/web/setup.html +300 -0
package/core/recorder.js
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
import { chromium } from 'playwright';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
export class RecordingSession {
|
|
6
|
+
constructor({ url, sessionId, screenshotDir }) {
|
|
7
|
+
this.startUrl = url;
|
|
8
|
+
this.sessionId = sessionId;
|
|
9
|
+
this.screenshotDir = screenshotDir;
|
|
10
|
+
this.steps = [];
|
|
11
|
+
this.stepCounter = 0;
|
|
12
|
+
this.browser = null;
|
|
13
|
+
this.context = null;
|
|
14
|
+
this.page = null;
|
|
15
|
+
this._stopping = false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
emit(obj) {
|
|
19
|
+
process.stdout.write(JSON.stringify(obj) + '\n');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
nextId() {
|
|
23
|
+
return 'step-' + String(++this.stepCounter).padStart(3, '0');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async launch() {
|
|
27
|
+
this.browser = await chromium.launch({
|
|
28
|
+
headless: false,
|
|
29
|
+
args: [
|
|
30
|
+
'--no-sandbox',
|
|
31
|
+
'--disable-blink-features=AutomationControlled',
|
|
32
|
+
'--disable-infobars',
|
|
33
|
+
'--allow-insecure-localhost',
|
|
34
|
+
'--disable-features=IsolateOrigins,site-per-process',
|
|
35
|
+
],
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
this.context = await this.browser.newContext({
|
|
39
|
+
viewport: { width: 1280, height: 800 },
|
|
40
|
+
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
41
|
+
locale: 'en-GB',
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Expose capture function ONCE at context level — persists across all navigations
|
|
45
|
+
await this.context.exposeFunction('__skopixCapture', async (actionData) => {
|
|
46
|
+
if (this._stopping) return;
|
|
47
|
+
if (actionData.action === 'stop') {
|
|
48
|
+
await this.stop();
|
|
49
|
+
process.exit(0);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
await this._captureStep(actionData);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// The big init script — injected on every page load automatically
|
|
56
|
+
await this.context.addInitScript(() => {
|
|
57
|
+
if (window.__skopixRecording) return;
|
|
58
|
+
window.__skopixRecording = true;
|
|
59
|
+
|
|
60
|
+
// ─── Selector builder ─────────────────────────────────────────────────
|
|
61
|
+
function getSelector(el) {
|
|
62
|
+
if (!el || el === document.body) return 'body';
|
|
63
|
+
const testAttrs = ['data-testid', 'data-test', 'pi-test-identifier', 'data-cy', 'data-qa'];
|
|
64
|
+
for (const attr of testAttrs) {
|
|
65
|
+
const val = el.getAttribute(attr);
|
|
66
|
+
if (val) return '[' + attr + '="' + val + '"]';
|
|
67
|
+
}
|
|
68
|
+
if (el.id && !/^\d/.test(el.id)) return '#' + el.id;
|
|
69
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
70
|
+
if (ariaLabel && ['button', 'a', 'input'].includes(el.tagName.toLowerCase())) {
|
|
71
|
+
return el.tagName.toLowerCase() + '[aria-label="' + ariaLabel + '"]';
|
|
72
|
+
}
|
|
73
|
+
if (el.name && el.tagName === 'INPUT') return 'input[name="' + el.name + '"]';
|
|
74
|
+
const parts = [];
|
|
75
|
+
let cur = el;
|
|
76
|
+
let depth = 0;
|
|
77
|
+
while (cur && cur !== document.body && depth < 4) {
|
|
78
|
+
let seg = cur.tagName.toLowerCase();
|
|
79
|
+
if (cur.id && !/^\d/.test(cur.id)) { parts.unshift('#' + cur.id); break; }
|
|
80
|
+
const sib = Array.from(cur.parentElement ? cur.parentElement.children : []).filter(c => c.tagName === cur.tagName);
|
|
81
|
+
if (sib.length > 1) seg += ':nth-of-type(' + (sib.indexOf(cur) + 1) + ')';
|
|
82
|
+
parts.unshift(seg);
|
|
83
|
+
cur = cur.parentElement;
|
|
84
|
+
depth++;
|
|
85
|
+
}
|
|
86
|
+
return parts.join(' > ');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getElementInfo(el) {
|
|
90
|
+
return {
|
|
91
|
+
tag: el.tagName.toLowerCase(),
|
|
92
|
+
id: el.id || null,
|
|
93
|
+
name: el.name || null,
|
|
94
|
+
type: el.type || null,
|
|
95
|
+
text: (el.innerText || el.value || el.placeholder || el.getAttribute('aria-label') || '').trim().slice(0, 80),
|
|
96
|
+
selector: getSelector(el),
|
|
97
|
+
classes: el.className ? el.className.toString().trim().slice(0, 100) : null,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── Action listeners ─────────────────────────────────────────────────
|
|
102
|
+
document.addEventListener('click', function(e) {
|
|
103
|
+
// Don't capture clicks on our own toolbar, popover, or hint overlay
|
|
104
|
+
if (e.target && e.target.closest) {
|
|
105
|
+
if (e.target.closest('#__skopix_toolbar')) return;
|
|
106
|
+
if (e.target.closest('#__skopix_popover')) return;
|
|
107
|
+
if (e.target.closest('#__skopix_hint')) return;
|
|
108
|
+
}
|
|
109
|
+
if (window.__skopixPickMode) return; // picker handles its own clicks
|
|
110
|
+
const el = e.target;
|
|
111
|
+
if (!el || el === document.body || el === document.documentElement) return;
|
|
112
|
+
const rect = el.getBoundingClientRect();
|
|
113
|
+
if (window.__skopixCapture) {
|
|
114
|
+
// Detect checkboxes and radio buttons - use 'check' action
|
|
115
|
+
// The checked state is what it WILL BE after this click (it toggles)
|
|
116
|
+
const isCheckable = el.type === 'checkbox' || el.type === 'radio';
|
|
117
|
+
// Also check if we clicked a label that controls a checkbox
|
|
118
|
+
let checkTarget = null;
|
|
119
|
+
if (!isCheckable && el.tagName === 'LABEL' && el.htmlFor) {
|
|
120
|
+
checkTarget = document.getElementById(el.htmlFor);
|
|
121
|
+
}
|
|
122
|
+
if (!isCheckable && el.tagName === 'LABEL' && !el.htmlFor) {
|
|
123
|
+
checkTarget = el.querySelector('input[type="checkbox"], input[type="radio"]');
|
|
124
|
+
}
|
|
125
|
+
const actualCheckable = isCheckable ? el : checkTarget;
|
|
126
|
+
if (actualCheckable && (actualCheckable.type === 'checkbox' || actualCheckable.type === 'radio')) {
|
|
127
|
+
// By the time the click event fires, checkbox is already toggled
|
|
128
|
+
// so .checked gives us the new state directly
|
|
129
|
+
window.__skopixCapture({
|
|
130
|
+
action: 'check',
|
|
131
|
+
checked: actualCheckable.checked,
|
|
132
|
+
element: getElementInfo(actualCheckable),
|
|
133
|
+
clickX: Math.round(e.clientX),
|
|
134
|
+
clickY: Math.round(e.clientY),
|
|
135
|
+
elementX: Math.round(rect.left + rect.width / 2),
|
|
136
|
+
elementY: Math.round(rect.top + rect.height / 2),
|
|
137
|
+
});
|
|
138
|
+
} else {
|
|
139
|
+
window.__skopixCapture({
|
|
140
|
+
action: 'click',
|
|
141
|
+
element: getElementInfo(el),
|
|
142
|
+
clickX: Math.round(e.clientX),
|
|
143
|
+
clickY: Math.round(e.clientY),
|
|
144
|
+
elementX: Math.round(rect.left + rect.width / 2),
|
|
145
|
+
elementY: Math.round(rect.top + rect.height / 2),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}, true);
|
|
150
|
+
|
|
151
|
+
let typeTimer = null;
|
|
152
|
+
let lastInputEl = null;
|
|
153
|
+
document.addEventListener('input', function(e) {
|
|
154
|
+
const el = e.target;
|
|
155
|
+
if (!el || !['INPUT', 'TEXTAREA'].includes(el.tagName)) return;
|
|
156
|
+
// Checkboxes and radio buttons are handled by the click listener, not type
|
|
157
|
+
if (el.type === 'checkbox' || el.type === 'radio') return;
|
|
158
|
+
if (el.closest && el.closest('#__skopix_toolbar')) return;
|
|
159
|
+
if (el.closest && el.closest('#__skopix_popover')) return;
|
|
160
|
+
lastInputEl = el;
|
|
161
|
+
clearTimeout(typeTimer);
|
|
162
|
+
typeTimer = setTimeout(function() {
|
|
163
|
+
if (!lastInputEl) return;
|
|
164
|
+
if (window.__skopixCapture) {
|
|
165
|
+
window.__skopixCapture({
|
|
166
|
+
action: 'type',
|
|
167
|
+
element: getElementInfo(lastInputEl),
|
|
168
|
+
value: lastInputEl.value,
|
|
169
|
+
isPassword: lastInputEl.type === 'password',
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
lastInputEl = null;
|
|
173
|
+
}, 600);
|
|
174
|
+
}, true);
|
|
175
|
+
|
|
176
|
+
document.addEventListener('change', function(e) {
|
|
177
|
+
const el = e.target;
|
|
178
|
+
if (!el || el.tagName !== 'SELECT') return;
|
|
179
|
+
if (el.closest && el.closest('#__skopix_toolbar')) return;
|
|
180
|
+
if (el.closest && el.closest('#__skopix_popover')) return;
|
|
181
|
+
const selected = el.options[el.selectedIndex];
|
|
182
|
+
if (window.__skopixCapture) {
|
|
183
|
+
window.__skopixCapture({
|
|
184
|
+
action: 'select',
|
|
185
|
+
element: getElementInfo(el),
|
|
186
|
+
value: el.value,
|
|
187
|
+
label: selected ? selected.text : el.value,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}, true);
|
|
191
|
+
|
|
192
|
+
// Scroll listener - debounced, captures final scroll position
|
|
193
|
+
let scrollTimer = null;
|
|
194
|
+
document.addEventListener('scroll', function(e) {
|
|
195
|
+
const el = e.target;
|
|
196
|
+
// Ignore scrolls on our own UI elements
|
|
197
|
+
if (el && el.closest) {
|
|
198
|
+
if (el.closest('#__skopix_toolbar')) return;
|
|
199
|
+
if (el.closest('#__skopix_popover')) return;
|
|
200
|
+
}
|
|
201
|
+
clearTimeout(scrollTimer);
|
|
202
|
+
scrollTimer = setTimeout(function() {
|
|
203
|
+
// Determine what was scrolled - the element itself or the window
|
|
204
|
+
const isWindow = el === document || el === document.documentElement || el === document.body;
|
|
205
|
+
const scrollLeft = isWindow ? window.scrollX : el.scrollLeft;
|
|
206
|
+
const scrollTop = isWindow ? window.scrollY : el.scrollTop;
|
|
207
|
+
// Only capture meaningful scrolls (ignore tiny accidental scrolls)
|
|
208
|
+
if (Math.abs(scrollTop) < 50 && Math.abs(scrollLeft) < 50) return;
|
|
209
|
+
const selector = isWindow ? 'window' : getSelector(el);
|
|
210
|
+
if (window.__skopixCapture) {
|
|
211
|
+
window.__skopixCapture({
|
|
212
|
+
action: 'scroll',
|
|
213
|
+
selector,
|
|
214
|
+
scrollX: Math.round(scrollLeft),
|
|
215
|
+
scrollY: Math.round(scrollTop),
|
|
216
|
+
isWindow,
|
|
217
|
+
element: isWindow ? null : getElementInfo(el),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}, 400);
|
|
221
|
+
}, true);
|
|
222
|
+
|
|
223
|
+
// ─── Floating toolbar ─────────────────────────────────────────────────
|
|
224
|
+
function createToolbar() {
|
|
225
|
+
if (document.getElementById('__skopix_toolbar')) return;
|
|
226
|
+
|
|
227
|
+
const toolbar = document.createElement('div');
|
|
228
|
+
toolbar.id = '__skopix_toolbar';
|
|
229
|
+
toolbar.style.cssText = [
|
|
230
|
+
'position:fixed', 'bottom:20px', 'right:20px', 'z-index:2147483647',
|
|
231
|
+
'background:#0f1117', 'border:1px solid #dc2626', 'border-radius:10px',
|
|
232
|
+
'padding:10px 14px', 'display:flex', 'align-items:center', 'gap:10px',
|
|
233
|
+
'font-family:monospace', 'font-size:12px', 'color:#e5e7eb',
|
|
234
|
+
'box-shadow:0 4px 24px rgba(0,0,0,0.6)', 'user-select:none',
|
|
235
|
+
'transition:opacity 0.2s',
|
|
236
|
+
].join(';');
|
|
237
|
+
|
|
238
|
+
toolbar.innerHTML = `
|
|
239
|
+
<span style="color:#dc2626;font-size:14px;animation:skopix_pulse 1s infinite">●</span>
|
|
240
|
+
<span style="color:#9ca3af">Recording</span>
|
|
241
|
+
<span id="__skopix_count" style="color:#22d3ee;font-weight:700;min-width:20px;text-align:center">0</span>
|
|
242
|
+
<span style="color:#4b5563">steps</span>
|
|
243
|
+
<button id="__skopix_assert_btn" style="
|
|
244
|
+
background:#1e3a5f;border:1px solid #2563eb;color:#60a5fa;
|
|
245
|
+
border-radius:6px;padding:4px 10px;cursor:pointer;font-size:11px;font-family:monospace;
|
|
246
|
+
">+ Assert</button>
|
|
247
|
+
<button id="__skopix_stop_btn" style="
|
|
248
|
+
background:#3f0d0d;border:1px solid #dc2626;color:#f87171;
|
|
249
|
+
border-radius:6px;padding:4px 10px;cursor:pointer;font-size:11px;font-family:monospace;
|
|
250
|
+
">■ Stop</button>
|
|
251
|
+
`;
|
|
252
|
+
|
|
253
|
+
const style = document.createElement('style');
|
|
254
|
+
style.textContent = '@keyframes skopix_pulse{0%,100%{opacity:1}50%{opacity:0.3}}';
|
|
255
|
+
document.head.appendChild(style);
|
|
256
|
+
document.body.appendChild(toolbar);
|
|
257
|
+
|
|
258
|
+
document.getElementById('__skopix_stop_btn').addEventListener('click', function(e) {
|
|
259
|
+
e.stopPropagation();
|
|
260
|
+
if (window.__skopixCapture) window.__skopixCapture({ action: 'stop' });
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
document.getElementById('__skopix_assert_btn').addEventListener('click', function(e) {
|
|
264
|
+
e.stopPropagation();
|
|
265
|
+
startPickMode();
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function updateStepCount(n) {
|
|
270
|
+
const el = document.getElementById('__skopix_count');
|
|
271
|
+
if (el) el.textContent = n;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ─── Element picker mode ──────────────────────────────────────────────
|
|
275
|
+
let pickerOverlay = null;
|
|
276
|
+
let lastHovered = null;
|
|
277
|
+
|
|
278
|
+
function startPickMode() {
|
|
279
|
+
window.__skopixPickMode = true;
|
|
280
|
+
document.body.style.cursor = 'crosshair';
|
|
281
|
+
|
|
282
|
+
// Dim the toolbar
|
|
283
|
+
const tb = document.getElementById('__skopix_toolbar');
|
|
284
|
+
if (tb) tb.style.opacity = '0.5';
|
|
285
|
+
|
|
286
|
+
// Show hint
|
|
287
|
+
const hint = document.createElement('div');
|
|
288
|
+
hint.id = '__skopix_hint';
|
|
289
|
+
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';
|
|
290
|
+
hint.textContent = 'Click any element to add an assertion';
|
|
291
|
+
document.body.appendChild(hint);
|
|
292
|
+
|
|
293
|
+
pickerOverlay = document.createElement('div');
|
|
294
|
+
pickerOverlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483646;pointer-events:auto;cursor:crosshair';
|
|
295
|
+
document.body.appendChild(pickerOverlay);
|
|
296
|
+
|
|
297
|
+
let highlight = document.createElement('div');
|
|
298
|
+
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';
|
|
299
|
+
document.body.appendChild(highlight);
|
|
300
|
+
|
|
301
|
+
pickerOverlay.addEventListener('mousemove', function(e) {
|
|
302
|
+
pickerOverlay.style.pointerEvents = 'none';
|
|
303
|
+
const el = document.elementFromPoint(e.clientX, e.clientY);
|
|
304
|
+
pickerOverlay.style.pointerEvents = 'auto';
|
|
305
|
+
if (!el || el === document.body || el.id === '__skopix_toolbar') {
|
|
306
|
+
highlight.style.display = 'none';
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
lastHovered = el;
|
|
310
|
+
const r = el.getBoundingClientRect();
|
|
311
|
+
highlight.style.display = 'block';
|
|
312
|
+
highlight.style.top = r.top + 'px';
|
|
313
|
+
highlight.style.left = r.left + 'px';
|
|
314
|
+
highlight.style.width = r.width + 'px';
|
|
315
|
+
highlight.style.height = r.height + 'px';
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
pickerOverlay.addEventListener('click', function(e) {
|
|
319
|
+
e.preventDefault();
|
|
320
|
+
e.stopPropagation();
|
|
321
|
+
pickerOverlay.style.pointerEvents = 'none';
|
|
322
|
+
const el = document.elementFromPoint(e.clientX, e.clientY);
|
|
323
|
+
pickerOverlay.style.pointerEvents = 'auto';
|
|
324
|
+
if (!el || el === document.body) { stopPickMode(); return; }
|
|
325
|
+
|
|
326
|
+
stopPickMode();
|
|
327
|
+
showAssertionPopover(el, highlight);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
document.addEventListener('keydown', function escHandler(e) {
|
|
331
|
+
if (e.key === 'Escape') { stopPickMode(); document.removeEventListener('keydown', escHandler); }
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function stopPickMode() {
|
|
336
|
+
window.__skopixPickMode = false;
|
|
337
|
+
document.body.style.cursor = '';
|
|
338
|
+
if (pickerOverlay) { pickerOverlay.remove(); pickerOverlay = null; }
|
|
339
|
+
const hint = document.getElementById('__skopix_hint');
|
|
340
|
+
if (hint) hint.remove();
|
|
341
|
+
const tb = document.getElementById('__skopix_toolbar');
|
|
342
|
+
if (tb) tb.style.opacity = '1';
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ─── Assertion popover ────────────────────────────────────────────────
|
|
346
|
+
function showAssertionPopover(el, highlightEl) {
|
|
347
|
+
const existing = document.getElementById('__skopix_popover');
|
|
348
|
+
if (existing) existing.remove();
|
|
349
|
+
|
|
350
|
+
const sel = getSelector(el);
|
|
351
|
+
const currentText = (el.innerText || el.textContent || '').trim().slice(0, 100);
|
|
352
|
+
const tag = el.tagName.toLowerCase();
|
|
353
|
+
const rect = el.getBoundingClientRect();
|
|
354
|
+
|
|
355
|
+
// Smart defaults
|
|
356
|
+
let suggestedType = 'visible';
|
|
357
|
+
let suggestedValue = '';
|
|
358
|
+
|
|
359
|
+
// If element has a title/alt/aria-label, suggest attribute_contains
|
|
360
|
+
const titleAttr = el.getAttribute('title') || el.getAttribute('alt');
|
|
361
|
+
if (titleAttr && titleAttr.length > 0) {
|
|
362
|
+
suggestedType = 'attribute_contains';
|
|
363
|
+
suggestedValue = titleAttr.slice(0, 80);
|
|
364
|
+
} else if (currentText && currentText.length > 0 && currentText.length < 80) {
|
|
365
|
+
suggestedType = 'text_contains';
|
|
366
|
+
// For numbers, suggest exact value. For text, suggest contains.
|
|
367
|
+
suggestedValue = currentText.replace(/\s+/g, ' ').trim();
|
|
368
|
+
}
|
|
369
|
+
// Count suggestion for tables/lists
|
|
370
|
+
if (['table', 'tbody', 'ul', 'ol'].includes(tag) || el.querySelectorAll('tr, li').length > 1) {
|
|
371
|
+
const rows = el.querySelectorAll('tr:not(thead tr), li').length;
|
|
372
|
+
if (rows > 0) { suggestedType = 'element_count'; suggestedValue = String(rows); }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Highlight selected element in green
|
|
376
|
+
if (highlightEl) {
|
|
377
|
+
highlightEl.style.background = 'rgba(34,197,94,0.15)';
|
|
378
|
+
highlightEl.style.borderColor = '#22c55e';
|
|
379
|
+
highlightEl.style.display = 'block';
|
|
380
|
+
highlightEl.style.top = rect.top + 'px';
|
|
381
|
+
highlightEl.style.left = rect.left + 'px';
|
|
382
|
+
highlightEl.style.width = rect.width + 'px';
|
|
383
|
+
highlightEl.style.height = rect.height + 'px';
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const popover = document.createElement('div');
|
|
387
|
+
popover.id = '__skopix_popover';
|
|
388
|
+
|
|
389
|
+
// Position popover — prefer below element, fall back to above
|
|
390
|
+
const popHeight = 280;
|
|
391
|
+
const topPos = rect.bottom + 8 + popHeight > window.innerHeight
|
|
392
|
+
? Math.max(8, rect.top - popHeight - 8)
|
|
393
|
+
: rect.bottom + 8;
|
|
394
|
+
const leftPos = Math.min(rect.left, window.innerWidth - 360);
|
|
395
|
+
|
|
396
|
+
popover.style.cssText = [
|
|
397
|
+
'position:fixed', 'z-index:2147483647',
|
|
398
|
+
'top:' + topPos + 'px', 'left:' + leftPos + 'px',
|
|
399
|
+
'width:350px', 'background:#0f1117',
|
|
400
|
+
'border:1px solid #2563eb', 'border-radius:10px',
|
|
401
|
+
'padding:16px', 'font-family:monospace', 'font-size:12px', 'color:#e5e7eb',
|
|
402
|
+
'box-shadow:0 8px 32px rgba(0,0,0,0.7)',
|
|
403
|
+
].join(';');
|
|
404
|
+
|
|
405
|
+
popover.innerHTML = `
|
|
406
|
+
<div style="color:#60a5fa;font-size:11px;letter-spacing:0.1em;margin-bottom:12px">ADD ASSERTION</div>
|
|
407
|
+
|
|
408
|
+
<div style="margin-bottom:10px">
|
|
409
|
+
<div style="color:#9ca3af;font-size:10px;margin-bottom:4px">SELECTED ELEMENT</div>
|
|
410
|
+
<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>
|
|
411
|
+
${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>' : ''}
|
|
412
|
+
</div>
|
|
413
|
+
|
|
414
|
+
<div style="margin-bottom:10px">
|
|
415
|
+
<div style="color:#9ca3af;font-size:10px;margin-bottom:4px">ASSERTION TYPE</div>
|
|
416
|
+
<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">
|
|
417
|
+
<option value="visible"${suggestedType==='visible'?' selected':''}>Element is visible</option>
|
|
418
|
+
<option value="text_contains"${suggestedType==='text_contains'?' selected':''}>Text contains</option>
|
|
419
|
+
<option value="text_equals"${suggestedType==='text_equals'?' selected':''}>Text equals</option>
|
|
420
|
+
<option value="url_contains">URL contains</option>
|
|
421
|
+
<option value="element_count"${suggestedType==='element_count'?' selected':''}>Element count</option>
|
|
422
|
+
<option value="attribute_contains">Attribute contains (title, alt, etc.)</option>
|
|
423
|
+
</select>
|
|
424
|
+
</div>
|
|
425
|
+
|
|
426
|
+
<div id="__skopix_attr_row" style="margin-bottom:10px;display:none">
|
|
427
|
+
<div style="color:#9ca3af;font-size:10px;margin-bottom:4px">ATTRIBUTE NAME</div>
|
|
428
|
+
<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">
|
|
429
|
+
</div>
|
|
430
|
+
|
|
431
|
+
<div id="__skopix_value_row" style="margin-bottom:10px;${suggestedType==='visible'?'display:none':''}">
|
|
432
|
+
<div style="color:#9ca3af;font-size:10px;margin-bottom:4px" id="__skopix_value_label">EXPECTED VALUE</div>
|
|
433
|
+
<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">
|
|
434
|
+
</div>
|
|
435
|
+
|
|
436
|
+
<div style="margin-bottom:14px">
|
|
437
|
+
<div style="color:#9ca3af;font-size:10px;margin-bottom:4px">DESCRIPTION (optional)</div>
|
|
438
|
+
<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">
|
|
439
|
+
</div>
|
|
440
|
+
|
|
441
|
+
<div style="display:flex;gap:8px;justify-content:flex-end">
|
|
442
|
+
<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>
|
|
443
|
+
<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>
|
|
444
|
+
</div>
|
|
445
|
+
`;
|
|
446
|
+
|
|
447
|
+
document.body.appendChild(popover);
|
|
448
|
+
|
|
449
|
+
// Show/hide value field based on type
|
|
450
|
+
const typeSelect = popover.querySelector('#__skopix_assert_type');
|
|
451
|
+
const valueRow = popover.querySelector('#__skopix_value_row');
|
|
452
|
+
const valueLabel = popover.querySelector('#__skopix_value_label');
|
|
453
|
+
const valueInput = popover.querySelector('#__skopix_assert_value');
|
|
454
|
+
const attrRow = popover.querySelector('#__skopix_attr_row');
|
|
455
|
+
|
|
456
|
+
typeSelect.addEventListener('change', function() {
|
|
457
|
+
const t = typeSelect.value;
|
|
458
|
+
attrRow.style.display = t === 'attribute_contains' ? 'block' : 'none';
|
|
459
|
+
if (t === 'visible') {
|
|
460
|
+
valueRow.style.display = 'none';
|
|
461
|
+
} else {
|
|
462
|
+
valueRow.style.display = 'block';
|
|
463
|
+
if (t === 'element_count') {
|
|
464
|
+
valueLabel.textContent = 'EXPECTED COUNT (number)';
|
|
465
|
+
valueInput.placeholder = 'e.g. 9';
|
|
466
|
+
} else if (t === 'url_contains') {
|
|
467
|
+
valueLabel.textContent = 'URL MUST CONTAIN';
|
|
468
|
+
valueInput.placeholder = 'e.g. /dashboard';
|
|
469
|
+
valueInput.value = '';
|
|
470
|
+
} else if (t === 'attribute_contains') {
|
|
471
|
+
valueLabel.textContent = 'ATTRIBUTE VALUE MUST CONTAIN';
|
|
472
|
+
valueInput.placeholder = 'e.g. SVG equivalent';
|
|
473
|
+
// Pre-fill with the title attribute value if it exists
|
|
474
|
+
const titleVal = el.getAttribute('title') || el.getAttribute('alt') || el.getAttribute('aria-label') || '';
|
|
475
|
+
if (titleVal && !valueInput.value) valueInput.value = titleVal.slice(0, 80);
|
|
476
|
+
} else {
|
|
477
|
+
valueLabel.textContent = 'EXPECTED VALUE';
|
|
478
|
+
valueInput.placeholder = 'Expected text...';
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
popover.querySelector('#__skopix_assert_cancel').addEventListener('click', function(e) {
|
|
484
|
+
e.stopPropagation();
|
|
485
|
+
if (highlightEl) highlightEl.style.display = 'none';
|
|
486
|
+
popover.remove();
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
popover.querySelector('#__skopix_assert_add').addEventListener('click', function(e) {
|
|
490
|
+
e.stopPropagation();
|
|
491
|
+
const assertType = typeSelect.value;
|
|
492
|
+
const value = popover.querySelector('#__skopix_assert_value').value.trim();
|
|
493
|
+
const description = popover.querySelector('#__skopix_assert_desc').value.trim();
|
|
494
|
+
|
|
495
|
+
if (assertType !== 'visible' && assertType !== 'url_contains' && !value) {
|
|
496
|
+
popover.querySelector('#__skopix_assert_value').style.borderColor = '#dc2626';
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (window.__skopixCapture) {
|
|
501
|
+
const attrInput = popover.querySelector('#__skopix_assert_attr');
|
|
502
|
+
window.__skopixCapture({
|
|
503
|
+
action: 'assert',
|
|
504
|
+
assertType,
|
|
505
|
+
attribute: assertType === 'attribute_contains' ? (attrInput ? attrInput.value.trim() || 'title' : 'title') : null,
|
|
506
|
+
selector: assertType === 'url_contains' ? null : sel,
|
|
507
|
+
value: value || null,
|
|
508
|
+
description: description || null,
|
|
509
|
+
element: assertType === 'url_contains' ? null : getElementInfo(el),
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (highlightEl) highlightEl.style.display = 'none';
|
|
514
|
+
popover.remove();
|
|
515
|
+
|
|
516
|
+
// Flash the assert button green briefly to confirm
|
|
517
|
+
const assertBtn = document.getElementById('__skopix_assert_btn');
|
|
518
|
+
if (assertBtn) {
|
|
519
|
+
const orig = assertBtn.style.cssText;
|
|
520
|
+
assertBtn.textContent = '✓ Added';
|
|
521
|
+
assertBtn.style.background = '#14532d';
|
|
522
|
+
assertBtn.style.borderColor = '#22c55e';
|
|
523
|
+
assertBtn.style.color = '#4ade80';
|
|
524
|
+
setTimeout(() => { assertBtn.textContent = '+ Assert'; assertBtn.style.cssText = orig; }, 1500);
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
// Focus the value input if visible
|
|
529
|
+
setTimeout(() => {
|
|
530
|
+
if (valueRow.style.display !== 'none') valueInput.focus();
|
|
531
|
+
}, 50);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Boot the toolbar once DOM is ready
|
|
535
|
+
if (document.body) {
|
|
536
|
+
createToolbar();
|
|
537
|
+
} else {
|
|
538
|
+
document.addEventListener('DOMContentLoaded', createToolbar);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Re-create toolbar after navigation if it got wiped
|
|
542
|
+
new MutationObserver(() => {
|
|
543
|
+
if (!document.getElementById('__skopix_toolbar')) createToolbar();
|
|
544
|
+
}).observe(document.documentElement, { childList: true, subtree: false });
|
|
545
|
+
|
|
546
|
+
// Listen for step count updates from parent
|
|
547
|
+
window.__skopixUpdateCount = function(n) { updateStepCount(n); };
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
this.context.on('page', (newPage) => {
|
|
551
|
+
newPage.on('framenavigated', async (frame) => {
|
|
552
|
+
if (frame !== newPage.mainFrame()) return;
|
|
553
|
+
const url = frame.url();
|
|
554
|
+
if (url === 'about:blank' || url === this.startUrl) return;
|
|
555
|
+
this.emit({ type: 'navigate', url });
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
this.page = await this.context.newPage();
|
|
560
|
+
|
|
561
|
+
this.page.on('framenavigated', async (frame) => {
|
|
562
|
+
if (frame !== this.page.mainFrame()) return;
|
|
563
|
+
const url = frame.url();
|
|
564
|
+
if (url === 'about:blank') return;
|
|
565
|
+
this.emit({ type: 'navigate', url });
|
|
566
|
+
// Update step count in toolbar after navigation
|
|
567
|
+
setTimeout(async () => {
|
|
568
|
+
try {
|
|
569
|
+
await this.page.evaluate((n) => {
|
|
570
|
+
if (window.__skopixUpdateCount) window.__skopixUpdateCount(n);
|
|
571
|
+
}, this.steps.length);
|
|
572
|
+
} catch {}
|
|
573
|
+
}, 500);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
await this.page.goto(this.startUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
577
|
+
this.emit({ type: 'ready' });
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async _captureStep(actionData) {
|
|
581
|
+
const id = this.nextId();
|
|
582
|
+
const page = this.page;
|
|
583
|
+
const step = {
|
|
584
|
+
id,
|
|
585
|
+
action: actionData.action,
|
|
586
|
+
assertType: actionData.assertType || null,
|
|
587
|
+
selector: actionData.selector || (actionData.element ? actionData.element.selector : null),
|
|
588
|
+
element: actionData.element || null,
|
|
589
|
+
value: actionData.value || null,
|
|
590
|
+
isPassword: actionData.isPassword || false,
|
|
591
|
+
label: actionData.label || null,
|
|
592
|
+
clickX: actionData.clickX || null,
|
|
593
|
+
clickY: actionData.clickY || null,
|
|
594
|
+
elementX: actionData.elementX || null,
|
|
595
|
+
elementY: actionData.elementY || null,
|
|
596
|
+
description: actionData.description || null,
|
|
597
|
+
url: page ? page.url() : '',
|
|
598
|
+
timestamp: Date.now(),
|
|
599
|
+
stableSelector: null,
|
|
600
|
+
screenshotPath: null,
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
// Update toolbar step count
|
|
604
|
+
setTimeout(async () => {
|
|
605
|
+
try {
|
|
606
|
+
await page.evaluate((n) => {
|
|
607
|
+
if (window.__skopixUpdateCount) window.__skopixUpdateCount(n);
|
|
608
|
+
}, this.steps.length + 1);
|
|
609
|
+
} catch {}
|
|
610
|
+
}, 100);
|
|
611
|
+
|
|
612
|
+
// Screenshot after short delay
|
|
613
|
+
setTimeout(async () => {
|
|
614
|
+
try {
|
|
615
|
+
if (this.screenshotDir && page) {
|
|
616
|
+
await fs.ensureDir(this.screenshotDir);
|
|
617
|
+
const screenshotPath = path.join(this.screenshotDir, id + '.png');
|
|
618
|
+
await page.screenshot({ path: screenshotPath, fullPage: false }).catch(() => {});
|
|
619
|
+
step.screenshotPath = screenshotPath;
|
|
620
|
+
this.emit({ type: 'screenshot', stepId: id, path: screenshotPath });
|
|
621
|
+
}
|
|
622
|
+
} catch {}
|
|
623
|
+
}, 400);
|
|
624
|
+
|
|
625
|
+
this.steps.push(step);
|
|
626
|
+
this.emit({ type: 'step', step });
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async stop() {
|
|
630
|
+
this._stopping = true;
|
|
631
|
+
try {
|
|
632
|
+
if (this.screenshotDir && this.page) {
|
|
633
|
+
await fs.ensureDir(this.screenshotDir);
|
|
634
|
+
await this.page.screenshot({ path: path.join(this.screenshotDir, 'final.png'), fullPage: false }).catch(() => {});
|
|
635
|
+
}
|
|
636
|
+
} catch {}
|
|
637
|
+
try { await this.browser.close(); } catch {}
|
|
638
|
+
this.emit({ type: 'done', steps: this.steps });
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ─── Entry point ──────────────────────────────────────────────────────────────
|
|
643
|
+
const [,, url, sessionId, screenshotDir] = process.argv;
|
|
644
|
+
if (!url) { process.stderr.write('Usage: recorder.js <url> <sessionId> <screenshotDir>\n'); process.exit(1); }
|
|
645
|
+
|
|
646
|
+
const session = new RecordingSession({ url, sessionId, screenshotDir });
|
|
647
|
+
session.launch().catch((err) => { session.emit({ type: 'error', message: err.message }); process.exit(1); });
|
|
648
|
+
|
|
649
|
+
process.stdin.setEncoding('utf8');
|
|
650
|
+
process.stdin.on('data', async (data) => {
|
|
651
|
+
if (data.trim() === 'stop') { await session.stop(); process.exit(0); }
|
|
652
|
+
});
|
|
653
|
+
process.on('SIGTERM', async () => { await session.stop(); process.exit(0); });
|