skopix 2.0.108 → 2.0.110
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/commands/agent.js +33 -2
- package/cli/commands/dashboard.js +33 -323
- package/core/step-tester.js +332 -0
- package/package.json +1 -1
package/cli/commands/agent.js
CHANGED
|
@@ -191,6 +191,15 @@ export async function agentCommand(options) {
|
|
|
191
191
|
if (msg.type === 'record') { await handleRecord(msg); return; }
|
|
192
192
|
if (msg.type === 'replay') { await handleReplay(msg); return; }
|
|
193
193
|
if (msg.type === 'debug-record') { await handleDebugRecord(msg); return; }
|
|
194
|
+
if (msg.type === 'step-tester-start') { await handleStepTester(msg); return; }
|
|
195
|
+
if (msg.type === 'step-tester-stop') {
|
|
196
|
+
try {
|
|
197
|
+
const { stopStepTester } = await import('../../core/step-tester.js');
|
|
198
|
+
await stopStepTester(msg.testerId);
|
|
199
|
+
} catch {}
|
|
200
|
+
ws.send(JSON.stringify({ type: 'jobDone', runId: msg.testerId }));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
194
203
|
});
|
|
195
204
|
|
|
196
205
|
ws.addEventListener('close', () => {
|
|
@@ -236,7 +245,10 @@ export async function agentCommand(options) {
|
|
|
236
245
|
send({ type: 'error', message: chunk.toString().trim().slice(0, 200) });
|
|
237
246
|
});
|
|
238
247
|
|
|
239
|
-
child.on('close', () => {
|
|
248
|
+
child.on('close', () => {
|
|
249
|
+
send({ type: 'stopped' });
|
|
250
|
+
try { ws.send(JSON.stringify({ type: 'jobDone', recordingId })); } catch {}
|
|
251
|
+
});
|
|
240
252
|
|
|
241
253
|
// Listen for stop signal from server
|
|
242
254
|
const stopHandler = (event) => {
|
|
@@ -789,7 +801,26 @@ export async function agentCommand(options) {
|
|
|
789
801
|
}
|
|
790
802
|
}
|
|
791
803
|
|
|
792
|
-
// ──
|
|
804
|
+
// ── STEP TESTER JOB ─────────────────────────────────────────────────────────
|
|
805
|
+
// Runs the full interactive step tester (headed browser + toolbar) on this
|
|
806
|
+
// machine. The toolbar's buttons call exposeFunction'd handlers directly,
|
|
807
|
+
// so the whole session is self-contained — no round-tripping needed.
|
|
808
|
+
async function handleStepTester(msg) {
|
|
809
|
+
const { testerId, url, selector, mode, steps } = msg;
|
|
810
|
+
console.log(chalk.cyan(' ⚡ Step tester: ') + chalk.white(mode === 'preview' ? (steps?.length || 0) + ' steps' : (selector || 'manual')) + (url ? chalk.dim(' @ ' + url) : ''));
|
|
811
|
+
try {
|
|
812
|
+
const { startStepTester } = await import('../../core/step-tester.js');
|
|
813
|
+
await startStepTester(testerId, url, selector, mode || 'test', steps);
|
|
814
|
+
console.log(chalk.green(' ✔ Step tester browser opened — use the toolbar, close when done'));
|
|
815
|
+
// The session runs independently; mark agent idle so it can take other jobs
|
|
816
|
+
ws.send(JSON.stringify({ type: 'jobDone', runId: testerId }));
|
|
817
|
+
} catch (err) {
|
|
818
|
+
console.error(chalk.red(' ✖ Step tester error: ' + err.message));
|
|
819
|
+
ws.send(JSON.stringify({ type: 'jobDone', runId: testerId }));
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// ── HELPERS ────────────────────────────────────────────────────────────────
|
|
793
824
|
function sanitiseSelector(sel) {
|
|
794
825
|
if (!sel) return sel;
|
|
795
826
|
return sel.replace(/\[([a-zA-Z_-]+)="([^"]+\.\d{5,})"\]/g, (_, attr, val) => '[' + attr + '*="' + val.replace(/\.\d{5,}$/, '') + '"]');
|
|
@@ -9,6 +9,7 @@ import open from 'open';
|
|
|
9
9
|
import os from 'os';
|
|
10
10
|
import crypto from 'crypto';
|
|
11
11
|
import { loadIssueStore, saveIssueStore } from '../../core/tracker.js';
|
|
12
|
+
import { startStepTester, executeStepTesterAction, runStepTesterAction, stopStepTester, stepTesterSessions } from '../../core/step-tester.js';
|
|
12
13
|
|
|
13
14
|
// Team mode imports - loaded lazily (only used when SKOPIX_TEAM_MODE=true)
|
|
14
15
|
let teamMode = null; // populated below if team mode is active
|
|
@@ -2033,6 +2034,21 @@ export async function dashboardCommand(options) {
|
|
|
2033
2034
|
const { url, selector, mode, steps } = JSON.parse(await readBody(req));
|
|
2034
2035
|
try {
|
|
2035
2036
|
const testerId = Math.random().toString(36).slice(2, 10);
|
|
2037
|
+
// In team mode, dispatch to a headed agent so the browser opens on the user's machine
|
|
2038
|
+
if (teamMode) {
|
|
2039
|
+
const headedAgent = getLeastBusyAgent(currentUser?.id, true);
|
|
2040
|
+
if (!headedAgent) {
|
|
2041
|
+
sendJSON(res, 503, { error: 'No agent with a display connected. Run "skopix agent" on your local machine to use the step tester.' });
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
2044
|
+
stepTesterSessions.set(testerId, { remote: true, agentId: headedAgent.id });
|
|
2045
|
+
headedAgent.status = 'testing';
|
|
2046
|
+
headedAgent.currentJob = { type: 'step-tester', testerId };
|
|
2047
|
+
broadcastAgentList();
|
|
2048
|
+
sendToAgent(headedAgent, { type: 'step-tester-start', testerId, url, selector, mode: mode || 'test', steps });
|
|
2049
|
+
sendJSON(res, 200, { testerId, agent: { id: headedAgent.id, name: headedAgent.name } });
|
|
2050
|
+
return;
|
|
2051
|
+
}
|
|
2036
2052
|
await startStepTester(testerId, url, selector, mode || 'test', steps);
|
|
2037
2053
|
sendJSON(res, 200, { testerId });
|
|
2038
2054
|
} catch (err) { sendJSON(res, 500, { error: err.message }); }
|
|
@@ -2066,6 +2082,14 @@ export async function dashboardCommand(options) {
|
|
|
2066
2082
|
}
|
|
2067
2083
|
if (pathname.match(/^\/api\/step-tester\/[^/]+\/stop$/) && method === 'POST') {
|
|
2068
2084
|
const testerId = pathname.split('/')[3];
|
|
2085
|
+
const session = stepTesterSessions.get(testerId);
|
|
2086
|
+
if (session && session.remote) {
|
|
2087
|
+
const agent = agents.get(session.agentId);
|
|
2088
|
+
if (agent) sendToAgent(agent, { type: 'step-tester-stop', testerId });
|
|
2089
|
+
stepTesterSessions.delete(testerId);
|
|
2090
|
+
sendJSON(res, 200, { stopped: true });
|
|
2091
|
+
return;
|
|
2092
|
+
}
|
|
2069
2093
|
await stopStepTester(testerId);
|
|
2070
2094
|
sendJSON(res, 200, { stopped: true });
|
|
2071
2095
|
return;
|
|
@@ -2388,6 +2412,14 @@ export async function dashboardCommand(options) {
|
|
|
2388
2412
|
const agent = ws.agentId ? agents.get(ws.agentId) : null;
|
|
2389
2413
|
if (!agent) return;
|
|
2390
2414
|
|
|
2415
|
+
// Explicit job completion — always resets agent to idle regardless of job type
|
|
2416
|
+
if (msg.type === 'jobDone') {
|
|
2417
|
+
agent.status = 'idle';
|
|
2418
|
+
agent.currentJob = null;
|
|
2419
|
+
broadcastAgentList();
|
|
2420
|
+
return;
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2391
2423
|
if (msg.type === 'jobUpdate') {
|
|
2392
2424
|
// Agent streaming step output back to server
|
|
2393
2425
|
const run = msg.runId ? activeRuns.get(msg.runId) : null;
|
|
@@ -4536,327 +4568,5 @@ async function syncTestsToLibrary(suitesDir) {
|
|
|
4536
4568
|
// STEP TESTER — live browser step testing
|
|
4537
4569
|
// ═══════════════════════════════════════════════════════════════
|
|
4538
4570
|
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
async function startStepTester(testerId, url, selector, mode, steps) {
|
|
4542
|
-
const { chromium } = await import('playwright');
|
|
4543
|
-
const browser = await chromium.launch({ headless: false, args: ['--no-sandbox'] });
|
|
4544
|
-
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
4545
|
-
const page = await ctx.newPage();
|
|
4546
|
-
|
|
4547
|
-
if (mode === 'preview' && steps && steps.length > 0) {
|
|
4548
|
-
// Register expose functions FIRST before addInitScript so they're available on DOMContentLoaded
|
|
4549
|
-
await ctx.exposeFunction('__skopixPreviewRun', async ({ index }) => {
|
|
4550
|
-
const session = stepTesterSessions.get(testerId);
|
|
4551
|
-
if (!session) return;
|
|
4552
|
-
const s = steps[index];
|
|
4553
|
-
if (!s) return;
|
|
4554
|
-
await page.evaluate(({ i, status }) => { if(window.__skopixUpdatePreview) window.__skopixUpdatePreview(i, null, status); }, { i: index, status: `Running step ${index+1}/${steps.length}...` }).catch(()=>{});
|
|
4555
|
-
const result = await executeStepTesterAction(page, { selector: s.stableSelector||s.selector, action: s.action, value: s.value||'', assertType: s.assertType });
|
|
4556
|
-
const msg = result.passed ? (index+1 >= steps.length ? `✓ All ${steps.length} steps passed!` : `✓ Step ${index+1} passed`) : `✗ Step ${index+1} failed`;
|
|
4557
|
-
if (result.passed) session.currentStep = index + 1;
|
|
4558
|
-
if (!session.results) session.results = {};
|
|
4559
|
-
session.results[index] = { passed: result.passed, error: result.error||null };
|
|
4560
|
-
await page.evaluate(({ i, r, msg, currentStep }) => {
|
|
4561
|
-
if(window.__skopixUpdatePreview) window.__skopixUpdatePreview(i, r, msg);
|
|
4562
|
-
window.__skopixPreviewCurrentStep = currentStep;
|
|
4563
|
-
}, { i: index, r: { passed: result.passed, error: result.error||null }, msg, currentStep: session.currentStep || 0 }).catch(()=>{});
|
|
4564
|
-
});
|
|
4565
|
-
|
|
4566
|
-
await ctx.exposeFunction('__skopixPreviewRunAll', async ({ fromIndex }) => {
|
|
4567
|
-
const session = stepTesterSessions.get(testerId);
|
|
4568
|
-
if (!session) return;
|
|
4569
|
-
for (let i = fromIndex; i < steps.length; i++) {
|
|
4570
|
-
const s = steps[i];
|
|
4571
|
-
await page.evaluate(({ i, total }) => { if(window.__skopixUpdatePreview) window.__skopixUpdatePreview(i, null, `Running step ${i+1}/${total}...`); }, { i, total: steps.length }).catch(()=>{});
|
|
4572
|
-
const result = await executeStepTesterAction(page, { selector: s.stableSelector||s.selector, action: s.action, value: s.value||'', assertType: s.assertType });
|
|
4573
|
-
if (result.passed) session.currentStep = i + 1;
|
|
4574
|
-
if (!session.results) session.results = {};
|
|
4575
|
-
session.results[i] = { passed: result.passed, error: result.error||null };
|
|
4576
|
-
const msg = result.passed ? (i+1 >= steps.length ? `✓ All ${steps.length} steps passed!` : `Running...`) : `✗ Step ${i+1} failed — fix and retry`;
|
|
4577
|
-
await page.evaluate(({ i, r, msg, currentStep }) => {
|
|
4578
|
-
if(window.__skopixUpdatePreview) window.__skopixUpdatePreview(i, r, msg);
|
|
4579
|
-
window.__skopixPreviewCurrentStep = currentStep;
|
|
4580
|
-
}, { i, r: { passed: result.passed, error: result.error||null }, msg, currentStep: session.currentStep || 0 }).catch(()=>{});
|
|
4581
|
-
if (!result.passed) break;
|
|
4582
|
-
await new Promise(r => setTimeout(r, 400));
|
|
4583
|
-
}
|
|
4584
|
-
});
|
|
4585
|
-
|
|
4586
|
-
await ctx.exposeFunction('__skopixStopPreview', async () => {
|
|
4587
|
-
try { await browser.close(); } catch {}
|
|
4588
|
-
stepTesterSessions.delete(testerId);
|
|
4589
|
-
});
|
|
4590
|
-
|
|
4591
|
-
await ctx.exposeFunction('__skopixGetState', async () => {
|
|
4592
|
-
const session = stepTesterSessions.get(testerId);
|
|
4593
|
-
return { currentStep: session?.currentStep || 0, results: session?.results || {} };
|
|
4594
|
-
});
|
|
4595
|
-
|
|
4596
|
-
// THEN inject toolbar via addInitScript
|
|
4597
|
-
// PREVIEW MODE — inject steps list toolbar
|
|
4598
|
-
await ctx.addInitScript((stepsData) => {
|
|
4599
|
-
window.__skopixPreviewSteps = stepsData;
|
|
4600
|
-
window.__skopixPreviewResults = {};
|
|
4601
|
-
document.addEventListener('DOMContentLoaded', () => {
|
|
4602
|
-
if (document.getElementById('__skopix_preview')) return;
|
|
4603
|
-
// Push page content left to avoid covering elements
|
|
4604
|
-
document.body.style.marginRight = '320px';
|
|
4605
|
-
document.body.style.boxSizing = 'border-box';
|
|
4606
|
-
// Also fix any fixed/sticky headers
|
|
4607
|
-
const fixedEls = Array.from(document.querySelectorAll('*')).filter(el => {
|
|
4608
|
-
const s = window.getComputedStyle(el);
|
|
4609
|
-
return (s.position === 'fixed' || s.position === 'sticky') && el.id !== '__skopix_preview';
|
|
4610
|
-
});
|
|
4611
|
-
fixedEls.forEach(el => {
|
|
4612
|
-
const s = window.getComputedStyle(el);
|
|
4613
|
-
const right = parseInt(s.right) || 0;
|
|
4614
|
-
el.style.right = (right + 320) + 'px';
|
|
4615
|
-
el.setAttribute('data-skopix-fixed', '1');
|
|
4616
|
-
});
|
|
4617
|
-
const tb = document.createElement('div');
|
|
4618
|
-
tb.id = '__skopix_preview';
|
|
4619
|
-
tb.style.cssText = [
|
|
4620
|
-
'position:fixed', 'top:0', 'right:0', 'bottom:0', 'width:320px',
|
|
4621
|
-
'z-index:2147483647', 'background:#0d0d1a', 'border-left:1px solid rgba(245,158,11,0.4)',
|
|
4622
|
-
'display:flex', 'flex-direction:column', 'font-family:monospace', 'font-size:12px',
|
|
4623
|
-
'box-shadow:-4px 0 24px rgba(0,0,0,0.5)',
|
|
4624
|
-
].join(';');
|
|
4625
|
-
tb.innerHTML = `
|
|
4626
|
-
<div style="padding:12px 16px;border-bottom:1px solid rgba(245,158,11,0.2);background:rgba(245,158,11,0.06);flex-shrink:0">
|
|
4627
|
-
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
|
|
4628
|
-
<div style="display:flex;align-items:center;gap:8px">
|
|
4629
|
-
<span style="color:#f59e0b">⚡</span>
|
|
4630
|
-
<span style="color:#f59e0b;font-size:10px;letter-spacing:0.1em">PREVIEW</span>
|
|
4631
|
-
</div>
|
|
4632
|
-
<div style="display:flex;gap:6px">
|
|
4633
|
-
<button id="__skopix_run_next" style="background:transparent;border:1px solid rgba(245,158,11,0.4);color:#f59e0b;
|
|
4634
|
-
border-radius:6px;padding:4px 10px;cursor:pointer;font-size:11px;font-family:monospace">▶ Next</button>
|
|
4635
|
-
<button id="__skopix_run_all" style="background:#f59e0b;border:none;color:#000;
|
|
4636
|
-
border-radius:6px;padding:4px 10px;cursor:pointer;font-size:11px;font-family:monospace;font-weight:700">▶▶ All</button>
|
|
4637
|
-
<button id="__skopix_stop_preview" style="background:transparent;border:1px solid #374151;color:#9ca3af;
|
|
4638
|
-
border-radius:6px;padding:4px 10px;cursor:pointer;font-size:11px;font-family:monospace">✕ Stop</button>
|
|
4639
|
-
</div>
|
|
4640
|
-
</div>
|
|
4641
|
-
<div id="__skopix_preview_status" style="color:#9ca3af;font-size:11px">Navigate to start, then click Run</div>
|
|
4642
|
-
</div>
|
|
4643
|
-
<div id="__skopix_steps" style="overflow-y:auto;flex:1;padding:0"></div>
|
|
4644
|
-
<div id="__skopix_preview_summary" style="display:none;padding:12px 16px;border-top:1px solid rgba(255,255,255,0.08);font-size:12px"></div>
|
|
4645
|
-
`;
|
|
4646
|
-
document.body.appendChild(tb);
|
|
4647
|
-
|
|
4648
|
-
function renderSteps(currentIdx, results) {
|
|
4649
|
-
const actionColors = { click:'#22d3ee', type:'#a78bfa', check:'#34d399', assert:'#fb923c', select:'#f472b6', scroll:'#6b7280' };
|
|
4650
|
-
document.getElementById('__skopix_steps').innerHTML = stepsData.map((s, i) => {
|
|
4651
|
-
const r = results[i];
|
|
4652
|
-
const isCurrent = i === currentIdx && !r;
|
|
4653
|
-
const status = r ? (r.passed ? '✓' : '✗') : (isCurrent ? '▶' : '○');
|
|
4654
|
-
const statusColor = r ? (r.passed ? '#34d399' : '#ef4444') : (isCurrent ? '#f59e0b' : '#374151');
|
|
4655
|
-
const bg = isCurrent ? 'rgba(245,158,11,0.08)' : r && !r.passed ? 'rgba(239,68,68,0.06)' : '';
|
|
4656
|
-
return `<div style="padding:8px 14px;border-bottom:1px solid rgba(255,255,255,0.05);display:flex;gap:10px;align-items:flex-start;background:${bg}">
|
|
4657
|
-
<span style="color:${statusColor};font-size:13px;margin-top:1px;flex-shrink:0">${status}</span>
|
|
4658
|
-
<div style="min-width:0">
|
|
4659
|
-
<div style="display:flex;align-items:center;gap:6px;margin-bottom:2px">
|
|
4660
|
-
<span style="color:${actionColors[s.action]||'#6b7280'};font-size:10px">${s.action}</span>
|
|
4661
|
-
<span style="color:#e5e7eb;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px">${s.description||''}</span>
|
|
4662
|
-
</div>
|
|
4663
|
-
<code style="color:#4b5563;font-size:10px;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${s.selector||''}</code>
|
|
4664
|
-
${r && !r.passed ? `<div style="color:#ef4444;font-size:10px;margin-top:3px">${(r.error||'Failed').slice(0,80)}</div>` : ''}
|
|
4665
|
-
</div>
|
|
4666
|
-
</div>`;
|
|
4667
|
-
}).join('');
|
|
4668
|
-
}
|
|
4669
|
-
|
|
4670
|
-
window.__skopixPreviewCurrentStep = 0;
|
|
4671
|
-
window.__skopixPreviewResults = {};
|
|
4672
|
-
renderSteps(0, {});
|
|
4673
|
-
|
|
4674
|
-
document.getElementById('__skopix_run_next').addEventListener('click', (e) => {
|
|
4675
|
-
e.stopPropagation();
|
|
4676
|
-
if (window.__skopixPreviewRun) window.__skopixPreviewRun({ index: window.__skopixPreviewCurrentStep });
|
|
4677
|
-
});
|
|
4678
|
-
|
|
4679
|
-
document.getElementById('__skopix_run_all').addEventListener('click', (e) => {
|
|
4680
|
-
e.stopPropagation();
|
|
4681
|
-
if (window.__skopixPreviewRunAll) window.__skopixPreviewRunAll({ fromIndex: window.__skopixPreviewCurrentStep });
|
|
4682
|
-
});
|
|
4571
|
+
// stepTesterSessions imported from step-tester module (shared)
|
|
4683
4572
|
|
|
4684
|
-
document.getElementById('__skopix_stop_preview').addEventListener('click', (e) => {
|
|
4685
|
-
e.stopPropagation();
|
|
4686
|
-
if (window.__skopixStopPreview) window.__skopixStopPreview({});
|
|
4687
|
-
});
|
|
4688
|
-
|
|
4689
|
-
// Restore state from server after navigation
|
|
4690
|
-
if (window.__skopixGetState) {
|
|
4691
|
-
window.__skopixGetState({}).then(state => {
|
|
4692
|
-
if (state && state.currentStep > 0) {
|
|
4693
|
-
window.__skopixPreviewCurrentStep = state.currentStep;
|
|
4694
|
-
window.__skopixPreviewResults = state.results || {};
|
|
4695
|
-
renderSteps(state.currentStep, state.results || {});
|
|
4696
|
-
}
|
|
4697
|
-
}).catch(() => {});
|
|
4698
|
-
}
|
|
4699
|
-
|
|
4700
|
-
window.__skopixUpdatePreview = (index, result, status) => {
|
|
4701
|
-
window.__skopixPreviewResults[index] = result;
|
|
4702
|
-
if (result && result.passed) window.__skopixPreviewCurrentStep = index + 1;
|
|
4703
|
-
document.getElementById('__skopix_preview_status').textContent = status || '';
|
|
4704
|
-
renderSteps(window.__skopixPreviewCurrentStep, window.__skopixPreviewResults);
|
|
4705
|
-
// Highlight element
|
|
4706
|
-
if (result) {
|
|
4707
|
-
try {
|
|
4708
|
-
const target = document.querySelector(stepsData[index]?.selector || '');
|
|
4709
|
-
if (target) {
|
|
4710
|
-
const orig = target.style.outline;
|
|
4711
|
-
target.style.outline = result.passed ? '3px solid #34d399' : '3px solid #ef4444';
|
|
4712
|
-
setTimeout(() => { target.style.outline = orig; }, 1500);
|
|
4713
|
-
}
|
|
4714
|
-
} catch {}
|
|
4715
|
-
}
|
|
4716
|
-
// Update summary
|
|
4717
|
-
const total = Object.keys(window.__skopixPreviewResults).length;
|
|
4718
|
-
const passed = Object.values(window.__skopixPreviewResults).filter(r=>r&&r.passed).length;
|
|
4719
|
-
const failed = total - passed;
|
|
4720
|
-
if (total > 0) {
|
|
4721
|
-
const summary = document.getElementById('__skopix_preview_summary');
|
|
4722
|
-
summary.style.display = '';
|
|
4723
|
-
summary.innerHTML = `<span style="color:#34d399">✓ ${passed}</span><span style="color:#4b5563"> / </span><span style="color:${failed>0?'#ef4444':'#4b5563'}">✗ ${failed}</span><span style="color:#4b5563"> of ${stepsData.length}</span>`;
|
|
4724
|
-
}
|
|
4725
|
-
};
|
|
4726
|
-
});
|
|
4727
|
-
}, steps.map(s => ({ action: s.action, selector: s.stableSelector||s.selector||'', description: s.description||s.action, value: s.value||'' })));
|
|
4728
|
-
|
|
4729
|
-
} else {
|
|
4730
|
-
// STEP TESTER MODE — register expose functions FIRST
|
|
4731
|
-
await ctx.exposeFunction('__skopixTesterRun', async ({ sel, action, value }) => {
|
|
4732
|
-
const result = await executeStepTesterAction(page, { selector: sel, action, value });
|
|
4733
|
-
await page.evaluate(({ passed }) => {
|
|
4734
|
-
const el = document.getElementById('__skopix_result');
|
|
4735
|
-
if (el) { el.textContent = passed ? '✓' : '✗'; el.style.color = passed ? '#34d399' : '#ef4444'; }
|
|
4736
|
-
}, { passed: result.passed }).catch(async () => {
|
|
4737
|
-
await new Promise(r => setTimeout(r, 500));
|
|
4738
|
-
await page.evaluate(({ passed }) => {
|
|
4739
|
-
const el = document.getElementById('__skopix_result');
|
|
4740
|
-
if (el) { el.textContent = passed ? '✓' : '✗'; el.style.color = passed ? '#34d399' : '#ef4444'; }
|
|
4741
|
-
}, { passed: result.passed }).catch(() => {});
|
|
4742
|
-
});
|
|
4743
|
-
await page.evaluate(({ passed, sel }) => {
|
|
4744
|
-
try {
|
|
4745
|
-
const target = document.querySelector(sel);
|
|
4746
|
-
if (target) {
|
|
4747
|
-
const orig = target.style.outline;
|
|
4748
|
-
target.style.outline = passed ? '3px solid #34d399' : '3px solid #ef4444';
|
|
4749
|
-
setTimeout(() => { target.style.outline = orig; }, 1500);
|
|
4750
|
-
}
|
|
4751
|
-
} catch {}
|
|
4752
|
-
}, { passed: result.passed, sel }).catch(() => {});
|
|
4753
|
-
});
|
|
4754
|
-
|
|
4755
|
-
await ctx.exposeFunction('__skopixTesterStop', async () => {
|
|
4756
|
-
try { await browser.close(); } catch {}
|
|
4757
|
-
stepTesterSessions.delete(testerId);
|
|
4758
|
-
});
|
|
4759
|
-
|
|
4760
|
-
// THEN inject toolbar
|
|
4761
|
-
await ctx.addInitScript((sel) => {
|
|
4762
|
-
window.__skopixTesterSelector = sel;
|
|
4763
|
-
document.addEventListener('DOMContentLoaded', () => {
|
|
4764
|
-
if (document.getElementById('__skopix_tester')) return;
|
|
4765
|
-
const tb = document.createElement('div');
|
|
4766
|
-
tb.id = '__skopix_tester';
|
|
4767
|
-
tb.style.cssText = [
|
|
4768
|
-
'position:fixed', 'bottom:20px', 'left:50%', 'transform:translateX(-50%)',
|
|
4769
|
-
'z-index:2147483647', 'background:#0f1117', 'border:1px solid #f59e0b',
|
|
4770
|
-
'border-radius:12px', 'padding:12px 16px', 'display:flex', 'align-items:center',
|
|
4771
|
-
'gap:10px', 'font-family:monospace', 'font-size:12px', 'color:#e5e7eb',
|
|
4772
|
-
'box-shadow:0 4px 24px rgba(0,0,0,0.7)', 'min-width:400px',
|
|
4773
|
-
].join(';');
|
|
4774
|
-
tb.innerHTML = `
|
|
4775
|
-
<span style="color:#f59e0b;font-size:14px">⚡</span>
|
|
4776
|
-
<span style="color:#9ca3af;font-size:11px">STEP TESTER</span>
|
|
4777
|
-
<input id="__skopix_sel" value="${(sel||'').replace(/"/g,'"')}" placeholder="selector..."
|
|
4778
|
-
style="flex:1;background:#1a1d2e;border:1px solid #374151;border-radius:6px;padding:4px 8px;
|
|
4779
|
-
color:#e5e7eb;font-family:monospace;font-size:11px;min-width:160px">
|
|
4780
|
-
<select id="__skopix_action" style="background:#1a1d2e;border:1px solid #374151;border-radius:6px;
|
|
4781
|
-
padding:4px 8px;color:#e5e7eb;font-family:monospace;font-size:11px">
|
|
4782
|
-
<option value="click">click</option>
|
|
4783
|
-
<option value="type">type</option>
|
|
4784
|
-
<option value="check">check</option>
|
|
4785
|
-
<option value="assert">assert (visible)</option>
|
|
4786
|
-
<option value="assert_text">assert (text)</option>
|
|
4787
|
-
</select>
|
|
4788
|
-
<input id="__skopix_value" placeholder="value..." style="width:100px;background:#1a1d2e;
|
|
4789
|
-
border:1px solid #374151;border-radius:6px;padding:4px 8px;color:#e5e7eb;
|
|
4790
|
-
font-family:monospace;font-size:11px;display:none">
|
|
4791
|
-
<button id="__skopix_run" style="background:#f59e0b;border:none;color:#000;
|
|
4792
|
-
border-radius:6px;padding:5px 14px;cursor:pointer;font-size:11px;font-weight:700;
|
|
4793
|
-
font-family:monospace">▶ Run</button>
|
|
4794
|
-
<span id="__skopix_result" style="font-size:13px;min-width:20px"></span>
|
|
4795
|
-
<button id="__skopix_stop" style="background:transparent;border:1px solid #374151;color:#9ca3af;
|
|
4796
|
-
border-radius:6px;padding:5px 10px;cursor:pointer;font-size:11px;font-family:monospace">✕ Stop</button>
|
|
4797
|
-
`;
|
|
4798
|
-
document.body.appendChild(tb);
|
|
4799
|
-
document.getElementById('__skopix_action').addEventListener('change', function() {
|
|
4800
|
-
const needsValue = ['type','assert_text'].includes(this.value);
|
|
4801
|
-
document.getElementById('__skopix_value').style.display = needsValue ? '' : 'none';
|
|
4802
|
-
});
|
|
4803
|
-
document.getElementById('__skopix_run').addEventListener('click', function(e) {
|
|
4804
|
-
e.stopPropagation();
|
|
4805
|
-
const sel = document.getElementById('__skopix_sel').value.trim();
|
|
4806
|
-
const action = document.getElementById('__skopix_action').value;
|
|
4807
|
-
const value = document.getElementById('__skopix_value').value.trim();
|
|
4808
|
-
document.getElementById('__skopix_result').textContent = '⏳';
|
|
4809
|
-
if (window.__skopixTesterRun) window.__skopixTesterRun({ sel, action, value });
|
|
4810
|
-
});
|
|
4811
|
-
document.getElementById('__skopix_stop').addEventListener('click', function(e) {
|
|
4812
|
-
e.stopPropagation();
|
|
4813
|
-
if (window.__skopixTesterStop) window.__skopixTesterStop({});
|
|
4814
|
-
});
|
|
4815
|
-
});
|
|
4816
|
-
}, selector || '');
|
|
4817
|
-
}
|
|
4818
|
-
|
|
4819
|
-
if (url) await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {});
|
|
4820
|
-
// Ensure toolbar is injected on first page (DOMContentLoaded may have already fired)
|
|
4821
|
-
await page.evaluate(() => { if (window.__skopixTesterSelector !== undefined && !document.getElementById('__skopix_tester') && !document.getElementById('__skopix_preview')) { document.dispatchEvent(new Event('DOMContentLoaded')); } }).catch(() => {});
|
|
4822
|
-
stepTesterSessions.set(testerId, { browser, ctx, page, currentStep: 0, results: {} });
|
|
4823
|
-
}
|
|
4824
|
-
|
|
4825
|
-
async function executeStepTesterAction(page, { selector, action, value }) {
|
|
4826
|
-
try {
|
|
4827
|
-
const { sanitiseSelector } = { sanitiseSelector: (s) => s }; // inline since not exported
|
|
4828
|
-
const sel = selector;
|
|
4829
|
-
if (action === 'click') {
|
|
4830
|
-
await page.locator(sel).first().click({ timeout: 5000 });
|
|
4831
|
-
} else if (action === 'type') {
|
|
4832
|
-
await page.locator(sel).first().fill(value || '', { timeout: 5000 });
|
|
4833
|
-
} else if (action === 'check') {
|
|
4834
|
-
await page.locator(sel).first().click({ timeout: 5000 });
|
|
4835
|
-
} else if (action === 'assert' || action === 'assert_text') {
|
|
4836
|
-
if (action === 'assert_text') {
|
|
4837
|
-
const text = await page.locator(sel).first().textContent({ timeout: 5000 });
|
|
4838
|
-
if (!text.includes(value)) throw new Error(`Text "${text}" does not contain "${value}"`);
|
|
4839
|
-
} else {
|
|
4840
|
-
await page.locator(sel).first().waitFor({ state: 'attached', timeout: 5000 });
|
|
4841
|
-
}
|
|
4842
|
-
}
|
|
4843
|
-
const screenshot = await page.screenshot({ type: 'jpeg', quality: 70 }).catch(() => null);
|
|
4844
|
-
return { passed: true, screenshot: screenshot ? screenshot.toString('base64') : null };
|
|
4845
|
-
} catch (err) {
|
|
4846
|
-
const screenshot = await page.screenshot({ type: 'jpeg', quality: 70 }).catch(() => null);
|
|
4847
|
-
return { passed: false, error: err.message, screenshot: screenshot ? screenshot.toString('base64') : null };
|
|
4848
|
-
}
|
|
4849
|
-
}
|
|
4850
|
-
|
|
4851
|
-
async function runStepTesterAction(testerId, stepData) {
|
|
4852
|
-
const session = stepTesterSessions.get(testerId);
|
|
4853
|
-
if (!session) return { passed: false, error: 'Session not found' };
|
|
4854
|
-
return await executeStepTesterAction(session.page, stepData);
|
|
4855
|
-
}
|
|
4856
|
-
|
|
4857
|
-
async function stopStepTester(testerId) {
|
|
4858
|
-
const session = stepTesterSessions.get(testerId);
|
|
4859
|
-
if (!session) return;
|
|
4860
|
-
try { await session.browser.close(); } catch {}
|
|
4861
|
-
stepTesterSessions.delete(testerId);
|
|
4862
|
-
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// Shared step tester logic — used by both the dashboard (solo mode) and
|
|
2
|
+
// the agent (team mode, runs on the user's machine with a display).
|
|
3
|
+
import { chromium } from 'playwright';
|
|
4
|
+
|
|
5
|
+
// Module-level session store (each process — dashboard or agent — has its own)
|
|
6
|
+
export const stepTesterSessions = new Map();
|
|
7
|
+
|
|
8
|
+
async function startStepTester(testerId, url, selector, mode, steps) {
|
|
9
|
+
// Detect display — on Linux servers without DISPLAY, must run headless
|
|
10
|
+
const hasDisplay = process.platform !== 'linux' || !!process.env.DISPLAY || !!process.env.WAYLAND_DISPLAY;
|
|
11
|
+
const browser = await chromium.launch({ headless: !hasDisplay, args: ['--no-sandbox'] });
|
|
12
|
+
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
13
|
+
const page = await ctx.newPage();
|
|
14
|
+
|
|
15
|
+
if (mode === 'preview' && steps && steps.length > 0) {
|
|
16
|
+
// Register expose functions FIRST before addInitScript so they're available on DOMContentLoaded
|
|
17
|
+
await ctx.exposeFunction('__skopixPreviewRun', async ({ index }) => {
|
|
18
|
+
const session = stepTesterSessions.get(testerId);
|
|
19
|
+
if (!session) return;
|
|
20
|
+
const s = steps[index];
|
|
21
|
+
if (!s) return;
|
|
22
|
+
await page.evaluate(({ i, status }) => { if(window.__skopixUpdatePreview) window.__skopixUpdatePreview(i, null, status); }, { i: index, status: `Running step ${index+1}/${steps.length}...` }).catch(()=>{});
|
|
23
|
+
const result = await executeStepTesterAction(page, { selector: s.stableSelector||s.selector, action: s.action, value: s.value||'', assertType: s.assertType });
|
|
24
|
+
const msg = result.passed ? (index+1 >= steps.length ? `✓ All ${steps.length} steps passed!` : `✓ Step ${index+1} passed`) : `✗ Step ${index+1} failed`;
|
|
25
|
+
if (result.passed) session.currentStep = index + 1;
|
|
26
|
+
if (!session.results) session.results = {};
|
|
27
|
+
session.results[index] = { passed: result.passed, error: result.error||null };
|
|
28
|
+
await page.evaluate(({ i, r, msg, currentStep }) => {
|
|
29
|
+
if(window.__skopixUpdatePreview) window.__skopixUpdatePreview(i, r, msg);
|
|
30
|
+
window.__skopixPreviewCurrentStep = currentStep;
|
|
31
|
+
}, { i: index, r: { passed: result.passed, error: result.error||null }, msg, currentStep: session.currentStep || 0 }).catch(()=>{});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
await ctx.exposeFunction('__skopixPreviewRunAll', async ({ fromIndex }) => {
|
|
35
|
+
const session = stepTesterSessions.get(testerId);
|
|
36
|
+
if (!session) return;
|
|
37
|
+
for (let i = fromIndex; i < steps.length; i++) {
|
|
38
|
+
const s = steps[i];
|
|
39
|
+
await page.evaluate(({ i, total }) => { if(window.__skopixUpdatePreview) window.__skopixUpdatePreview(i, null, `Running step ${i+1}/${total}...`); }, { i, total: steps.length }).catch(()=>{});
|
|
40
|
+
const result = await executeStepTesterAction(page, { selector: s.stableSelector||s.selector, action: s.action, value: s.value||'', assertType: s.assertType });
|
|
41
|
+
if (result.passed) session.currentStep = i + 1;
|
|
42
|
+
if (!session.results) session.results = {};
|
|
43
|
+
session.results[i] = { passed: result.passed, error: result.error||null };
|
|
44
|
+
const msg = result.passed ? (i+1 >= steps.length ? `✓ All ${steps.length} steps passed!` : `Running...`) : `✗ Step ${i+1} failed — fix and retry`;
|
|
45
|
+
await page.evaluate(({ i, r, msg, currentStep }) => {
|
|
46
|
+
if(window.__skopixUpdatePreview) window.__skopixUpdatePreview(i, r, msg);
|
|
47
|
+
window.__skopixPreviewCurrentStep = currentStep;
|
|
48
|
+
}, { i, r: { passed: result.passed, error: result.error||null }, msg, currentStep: session.currentStep || 0 }).catch(()=>{});
|
|
49
|
+
if (!result.passed) break;
|
|
50
|
+
await new Promise(r => setTimeout(r, 400));
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
await ctx.exposeFunction('__skopixStopPreview', async () => {
|
|
55
|
+
try { await browser.close(); } catch {}
|
|
56
|
+
stepTesterSessions.delete(testerId);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await ctx.exposeFunction('__skopixGetState', async () => {
|
|
60
|
+
const session = stepTesterSessions.get(testerId);
|
|
61
|
+
return { currentStep: session?.currentStep || 0, results: session?.results || {} };
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// THEN inject toolbar via addInitScript
|
|
65
|
+
// PREVIEW MODE — inject steps list toolbar
|
|
66
|
+
await ctx.addInitScript((stepsData) => {
|
|
67
|
+
window.__skopixPreviewSteps = stepsData;
|
|
68
|
+
window.__skopixPreviewResults = {};
|
|
69
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
70
|
+
if (document.getElementById('__skopix_preview')) return;
|
|
71
|
+
// Push page content left to avoid covering elements
|
|
72
|
+
document.body.style.marginRight = '320px';
|
|
73
|
+
document.body.style.boxSizing = 'border-box';
|
|
74
|
+
// Also fix any fixed/sticky headers
|
|
75
|
+
const fixedEls = Array.from(document.querySelectorAll('*')).filter(el => {
|
|
76
|
+
const s = window.getComputedStyle(el);
|
|
77
|
+
return (s.position === 'fixed' || s.position === 'sticky') && el.id !== '__skopix_preview';
|
|
78
|
+
});
|
|
79
|
+
fixedEls.forEach(el => {
|
|
80
|
+
const s = window.getComputedStyle(el);
|
|
81
|
+
const right = parseInt(s.right) || 0;
|
|
82
|
+
el.style.right = (right + 320) + 'px';
|
|
83
|
+
el.setAttribute('data-skopix-fixed', '1');
|
|
84
|
+
});
|
|
85
|
+
const tb = document.createElement('div');
|
|
86
|
+
tb.id = '__skopix_preview';
|
|
87
|
+
tb.style.cssText = [
|
|
88
|
+
'position:fixed', 'top:0', 'right:0', 'bottom:0', 'width:320px',
|
|
89
|
+
'z-index:2147483647', 'background:#0d0d1a', 'border-left:1px solid rgba(245,158,11,0.4)',
|
|
90
|
+
'display:flex', 'flex-direction:column', 'font-family:monospace', 'font-size:12px',
|
|
91
|
+
'box-shadow:-4px 0 24px rgba(0,0,0,0.5)',
|
|
92
|
+
].join(';');
|
|
93
|
+
tb.innerHTML = `
|
|
94
|
+
<div style="padding:12px 16px;border-bottom:1px solid rgba(245,158,11,0.2);background:rgba(245,158,11,0.06);flex-shrink:0">
|
|
95
|
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
|
|
96
|
+
<div style="display:flex;align-items:center;gap:8px">
|
|
97
|
+
<span style="color:#f59e0b">⚡</span>
|
|
98
|
+
<span style="color:#f59e0b;font-size:10px;letter-spacing:0.1em">PREVIEW</span>
|
|
99
|
+
</div>
|
|
100
|
+
<div style="display:flex;gap:6px">
|
|
101
|
+
<button id="__skopix_run_next" style="background:transparent;border:1px solid rgba(245,158,11,0.4);color:#f59e0b;
|
|
102
|
+
border-radius:6px;padding:4px 10px;cursor:pointer;font-size:11px;font-family:monospace">▶ Next</button>
|
|
103
|
+
<button id="__skopix_run_all" style="background:#f59e0b;border:none;color:#000;
|
|
104
|
+
border-radius:6px;padding:4px 10px;cursor:pointer;font-size:11px;font-family:monospace;font-weight:700">▶▶ All</button>
|
|
105
|
+
<button id="__skopix_stop_preview" style="background:transparent;border:1px solid #374151;color:#9ca3af;
|
|
106
|
+
border-radius:6px;padding:4px 10px;cursor:pointer;font-size:11px;font-family:monospace">✕ Stop</button>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
<div id="__skopix_preview_status" style="color:#9ca3af;font-size:11px">Navigate to start, then click Run</div>
|
|
110
|
+
</div>
|
|
111
|
+
<div id="__skopix_steps" style="overflow-y:auto;flex:1;padding:0"></div>
|
|
112
|
+
<div id="__skopix_preview_summary" style="display:none;padding:12px 16px;border-top:1px solid rgba(255,255,255,0.08);font-size:12px"></div>
|
|
113
|
+
`;
|
|
114
|
+
document.body.appendChild(tb);
|
|
115
|
+
|
|
116
|
+
function renderSteps(currentIdx, results) {
|
|
117
|
+
const actionColors = { click:'#22d3ee', type:'#a78bfa', check:'#34d399', assert:'#fb923c', select:'#f472b6', scroll:'#6b7280' };
|
|
118
|
+
document.getElementById('__skopix_steps').innerHTML = stepsData.map((s, i) => {
|
|
119
|
+
const r = results[i];
|
|
120
|
+
const isCurrent = i === currentIdx && !r;
|
|
121
|
+
const status = r ? (r.passed ? '✓' : '✗') : (isCurrent ? '▶' : '○');
|
|
122
|
+
const statusColor = r ? (r.passed ? '#34d399' : '#ef4444') : (isCurrent ? '#f59e0b' : '#374151');
|
|
123
|
+
const bg = isCurrent ? 'rgba(245,158,11,0.08)' : r && !r.passed ? 'rgba(239,68,68,0.06)' : '';
|
|
124
|
+
return `<div style="padding:8px 14px;border-bottom:1px solid rgba(255,255,255,0.05);display:flex;gap:10px;align-items:flex-start;background:${bg}">
|
|
125
|
+
<span style="color:${statusColor};font-size:13px;margin-top:1px;flex-shrink:0">${status}</span>
|
|
126
|
+
<div style="min-width:0">
|
|
127
|
+
<div style="display:flex;align-items:center;gap:6px;margin-bottom:2px">
|
|
128
|
+
<span style="color:${actionColors[s.action]||'#6b7280'};font-size:10px">${s.action}</span>
|
|
129
|
+
<span style="color:#e5e7eb;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px">${s.description||''}</span>
|
|
130
|
+
</div>
|
|
131
|
+
<code style="color:#4b5563;font-size:10px;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${s.selector||''}</code>
|
|
132
|
+
${r && !r.passed ? `<div style="color:#ef4444;font-size:10px;margin-top:3px">${(r.error||'Failed').slice(0,80)}</div>` : ''}
|
|
133
|
+
</div>
|
|
134
|
+
</div>`;
|
|
135
|
+
}).join('');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
window.__skopixPreviewCurrentStep = 0;
|
|
139
|
+
window.__skopixPreviewResults = {};
|
|
140
|
+
renderSteps(0, {});
|
|
141
|
+
|
|
142
|
+
document.getElementById('__skopix_run_next').addEventListener('click', (e) => {
|
|
143
|
+
e.stopPropagation();
|
|
144
|
+
if (window.__skopixPreviewRun) window.__skopixPreviewRun({ index: window.__skopixPreviewCurrentStep });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
document.getElementById('__skopix_run_all').addEventListener('click', (e) => {
|
|
148
|
+
e.stopPropagation();
|
|
149
|
+
if (window.__skopixPreviewRunAll) window.__skopixPreviewRunAll({ fromIndex: window.__skopixPreviewCurrentStep });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
document.getElementById('__skopix_stop_preview').addEventListener('click', (e) => {
|
|
153
|
+
e.stopPropagation();
|
|
154
|
+
if (window.__skopixStopPreview) window.__skopixStopPreview({});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Restore state from server after navigation
|
|
158
|
+
if (window.__skopixGetState) {
|
|
159
|
+
window.__skopixGetState({}).then(state => {
|
|
160
|
+
if (state && state.currentStep > 0) {
|
|
161
|
+
window.__skopixPreviewCurrentStep = state.currentStep;
|
|
162
|
+
window.__skopixPreviewResults = state.results || {};
|
|
163
|
+
renderSteps(state.currentStep, state.results || {});
|
|
164
|
+
}
|
|
165
|
+
}).catch(() => {});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
window.__skopixUpdatePreview = (index, result, status) => {
|
|
169
|
+
window.__skopixPreviewResults[index] = result;
|
|
170
|
+
if (result && result.passed) window.__skopixPreviewCurrentStep = index + 1;
|
|
171
|
+
document.getElementById('__skopix_preview_status').textContent = status || '';
|
|
172
|
+
renderSteps(window.__skopixPreviewCurrentStep, window.__skopixPreviewResults);
|
|
173
|
+
// Highlight element
|
|
174
|
+
if (result) {
|
|
175
|
+
try {
|
|
176
|
+
const target = document.querySelector(stepsData[index]?.selector || '');
|
|
177
|
+
if (target) {
|
|
178
|
+
const orig = target.style.outline;
|
|
179
|
+
target.style.outline = result.passed ? '3px solid #34d399' : '3px solid #ef4444';
|
|
180
|
+
setTimeout(() => { target.style.outline = orig; }, 1500);
|
|
181
|
+
}
|
|
182
|
+
} catch {}
|
|
183
|
+
}
|
|
184
|
+
// Update summary
|
|
185
|
+
const total = Object.keys(window.__skopixPreviewResults).length;
|
|
186
|
+
const passed = Object.values(window.__skopixPreviewResults).filter(r=>r&&r.passed).length;
|
|
187
|
+
const failed = total - passed;
|
|
188
|
+
if (total > 0) {
|
|
189
|
+
const summary = document.getElementById('__skopix_preview_summary');
|
|
190
|
+
summary.style.display = '';
|
|
191
|
+
summary.innerHTML = `<span style="color:#34d399">✓ ${passed}</span><span style="color:#4b5563"> / </span><span style="color:${failed>0?'#ef4444':'#4b5563'}">✗ ${failed}</span><span style="color:#4b5563"> of ${stepsData.length}</span>`;
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
}, steps.map(s => ({ action: s.action, selector: s.stableSelector||s.selector||'', description: s.description||s.action, value: s.value||'' })));
|
|
196
|
+
|
|
197
|
+
} else {
|
|
198
|
+
// STEP TESTER MODE — register expose functions FIRST
|
|
199
|
+
await ctx.exposeFunction('__skopixTesterRun', async ({ sel, action, value }) => {
|
|
200
|
+
const result = await executeStepTesterAction(page, { selector: sel, action, value });
|
|
201
|
+
await page.evaluate(({ passed }) => {
|
|
202
|
+
const el = document.getElementById('__skopix_result');
|
|
203
|
+
if (el) { el.textContent = passed ? '✓' : '✗'; el.style.color = passed ? '#34d399' : '#ef4444'; }
|
|
204
|
+
}, { passed: result.passed }).catch(async () => {
|
|
205
|
+
await new Promise(r => setTimeout(r, 500));
|
|
206
|
+
await page.evaluate(({ passed }) => {
|
|
207
|
+
const el = document.getElementById('__skopix_result');
|
|
208
|
+
if (el) { el.textContent = passed ? '✓' : '✗'; el.style.color = passed ? '#34d399' : '#ef4444'; }
|
|
209
|
+
}, { passed: result.passed }).catch(() => {});
|
|
210
|
+
});
|
|
211
|
+
await page.evaluate(({ passed, sel }) => {
|
|
212
|
+
try {
|
|
213
|
+
const target = document.querySelector(sel);
|
|
214
|
+
if (target) {
|
|
215
|
+
const orig = target.style.outline;
|
|
216
|
+
target.style.outline = passed ? '3px solid #34d399' : '3px solid #ef4444';
|
|
217
|
+
setTimeout(() => { target.style.outline = orig; }, 1500);
|
|
218
|
+
}
|
|
219
|
+
} catch {}
|
|
220
|
+
}, { passed: result.passed, sel }).catch(() => {});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
await ctx.exposeFunction('__skopixTesterStop', async () => {
|
|
224
|
+
try { await browser.close(); } catch {}
|
|
225
|
+
stepTesterSessions.delete(testerId);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// THEN inject toolbar
|
|
229
|
+
await ctx.addInitScript((sel) => {
|
|
230
|
+
window.__skopixTesterSelector = sel;
|
|
231
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
232
|
+
if (document.getElementById('__skopix_tester')) return;
|
|
233
|
+
const tb = document.createElement('div');
|
|
234
|
+
tb.id = '__skopix_tester';
|
|
235
|
+
tb.style.cssText = [
|
|
236
|
+
'position:fixed', 'bottom:20px', 'left:50%', 'transform:translateX(-50%)',
|
|
237
|
+
'z-index:2147483647', 'background:#0f1117', 'border:1px solid #f59e0b',
|
|
238
|
+
'border-radius:12px', 'padding:12px 16px', 'display:flex', 'align-items:center',
|
|
239
|
+
'gap:10px', 'font-family:monospace', 'font-size:12px', 'color:#e5e7eb',
|
|
240
|
+
'box-shadow:0 4px 24px rgba(0,0,0,0.7)', 'min-width:400px',
|
|
241
|
+
].join(';');
|
|
242
|
+
tb.innerHTML = `
|
|
243
|
+
<span style="color:#f59e0b;font-size:14px">⚡</span>
|
|
244
|
+
<span style="color:#9ca3af;font-size:11px">STEP TESTER</span>
|
|
245
|
+
<input id="__skopix_sel" value="${(sel||'').replace(/"/g,'"')}" placeholder="selector..."
|
|
246
|
+
style="flex:1;background:#1a1d2e;border:1px solid #374151;border-radius:6px;padding:4px 8px;
|
|
247
|
+
color:#e5e7eb;font-family:monospace;font-size:11px;min-width:160px">
|
|
248
|
+
<select id="__skopix_action" style="background:#1a1d2e;border:1px solid #374151;border-radius:6px;
|
|
249
|
+
padding:4px 8px;color:#e5e7eb;font-family:monospace;font-size:11px">
|
|
250
|
+
<option value="click">click</option>
|
|
251
|
+
<option value="type">type</option>
|
|
252
|
+
<option value="check">check</option>
|
|
253
|
+
<option value="assert">assert (visible)</option>
|
|
254
|
+
<option value="assert_text">assert (text)</option>
|
|
255
|
+
</select>
|
|
256
|
+
<input id="__skopix_value" placeholder="value..." style="width:100px;background:#1a1d2e;
|
|
257
|
+
border:1px solid #374151;border-radius:6px;padding:4px 8px;color:#e5e7eb;
|
|
258
|
+
font-family:monospace;font-size:11px;display:none">
|
|
259
|
+
<button id="__skopix_run" style="background:#f59e0b;border:none;color:#000;
|
|
260
|
+
border-radius:6px;padding:5px 14px;cursor:pointer;font-size:11px;font-weight:700;
|
|
261
|
+
font-family:monospace">▶ Run</button>
|
|
262
|
+
<span id="__skopix_result" style="font-size:13px;min-width:20px"></span>
|
|
263
|
+
<button id="__skopix_stop" style="background:transparent;border:1px solid #374151;color:#9ca3af;
|
|
264
|
+
border-radius:6px;padding:5px 10px;cursor:pointer;font-size:11px;font-family:monospace">✕ Stop</button>
|
|
265
|
+
`;
|
|
266
|
+
document.body.appendChild(tb);
|
|
267
|
+
document.getElementById('__skopix_action').addEventListener('change', function() {
|
|
268
|
+
const needsValue = ['type','assert_text'].includes(this.value);
|
|
269
|
+
document.getElementById('__skopix_value').style.display = needsValue ? '' : 'none';
|
|
270
|
+
});
|
|
271
|
+
document.getElementById('__skopix_run').addEventListener('click', function(e) {
|
|
272
|
+
e.stopPropagation();
|
|
273
|
+
const sel = document.getElementById('__skopix_sel').value.trim();
|
|
274
|
+
const action = document.getElementById('__skopix_action').value;
|
|
275
|
+
const value = document.getElementById('__skopix_value').value.trim();
|
|
276
|
+
document.getElementById('__skopix_result').textContent = '⏳';
|
|
277
|
+
if (window.__skopixTesterRun) window.__skopixTesterRun({ sel, action, value });
|
|
278
|
+
});
|
|
279
|
+
document.getElementById('__skopix_stop').addEventListener('click', function(e) {
|
|
280
|
+
e.stopPropagation();
|
|
281
|
+
if (window.__skopixTesterStop) window.__skopixTesterStop({});
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
}, selector || '');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (url) await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {});
|
|
288
|
+
// Ensure toolbar is injected on first page (DOMContentLoaded may have already fired)
|
|
289
|
+
await page.evaluate(() => { if (window.__skopixTesterSelector !== undefined && !document.getElementById('__skopix_tester') && !document.getElementById('__skopix_preview')) { document.dispatchEvent(new Event('DOMContentLoaded')); } }).catch(() => {});
|
|
290
|
+
stepTesterSessions.set(testerId, { browser, ctx, page, currentStep: 0, results: {} });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function executeStepTesterAction(page, { selector, action, value }) {
|
|
294
|
+
try {
|
|
295
|
+
const { sanitiseSelector } = { sanitiseSelector: (s) => s }; // inline since not exported
|
|
296
|
+
const sel = selector;
|
|
297
|
+
if (action === 'click') {
|
|
298
|
+
await page.locator(sel).first().click({ timeout: 5000 });
|
|
299
|
+
} else if (action === 'type') {
|
|
300
|
+
await page.locator(sel).first().fill(value || '', { timeout: 5000 });
|
|
301
|
+
} else if (action === 'check') {
|
|
302
|
+
await page.locator(sel).first().click({ timeout: 5000 });
|
|
303
|
+
} else if (action === 'assert' || action === 'assert_text') {
|
|
304
|
+
if (action === 'assert_text') {
|
|
305
|
+
const text = await page.locator(sel).first().textContent({ timeout: 5000 });
|
|
306
|
+
if (!text.includes(value)) throw new Error(`Text "${text}" does not contain "${value}"`);
|
|
307
|
+
} else {
|
|
308
|
+
await page.locator(sel).first().waitFor({ state: 'attached', timeout: 5000 });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const screenshot = await page.screenshot({ type: 'jpeg', quality: 70 }).catch(() => null);
|
|
312
|
+
return { passed: true, screenshot: screenshot ? screenshot.toString('base64') : null };
|
|
313
|
+
} catch (err) {
|
|
314
|
+
const screenshot = await page.screenshot({ type: 'jpeg', quality: 70 }).catch(() => null);
|
|
315
|
+
return { passed: false, error: err.message, screenshot: screenshot ? screenshot.toString('base64') : null };
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function runStepTesterAction(testerId, stepData) {
|
|
320
|
+
const session = stepTesterSessions.get(testerId);
|
|
321
|
+
if (!session) return { passed: false, error: 'Session not found' };
|
|
322
|
+
return await executeStepTesterAction(session.page, stepData);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function stopStepTester(testerId) {
|
|
326
|
+
const session = stepTesterSessions.get(testerId);
|
|
327
|
+
if (!session) return;
|
|
328
|
+
try { await session.browser.close(); } catch {}
|
|
329
|
+
stepTesterSessions.delete(testerId);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export { startStepTester, executeStepTesterAction, runStepTesterAction, stopStepTester };
|
package/package.json
CHANGED