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.
@@ -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', () => { send({ type: 'stopped' }); });
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
- // ── HELPERS ────────────────────────────────────────────────────────────────
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
- const stepTesterSessions = new Map(); // testerId -> { browser, ctx, page }
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,'&quot;')}" 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,'&quot;')}" 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skopix",
3
- "version": "2.0.108",
3
+ "version": "2.0.110",
4
4
  "description": "Browser-based QA tool — record tests by using your app, replay them deterministically, generate Playwright code automatically",
5
5
  "main": "cli/index.js",
6
6
  "bin": {