skopix 2.0.37 → 2.0.39

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.
@@ -1811,6 +1811,25 @@ export async function dashboardCommand(options) {
1811
1811
  sendJSON(res, 200, result);
1812
1812
  return;
1813
1813
  }
1814
+ if (pathname.match(/^\/api\/step-tester\/[^/]+\/run-sequence$/) && method === 'POST') {
1815
+ const testerId = pathname.split('/')[3];
1816
+ const { steps } = JSON.parse(await readBody(req));
1817
+ const session = stepTesterSessions.get(testerId);
1818
+ if (!session) { sendJSON(res, 404, { error: 'Session not found' }); return; }
1819
+ // Run steps one at a time, streaming results via SSE
1820
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*' });
1821
+ for (let i = 0; i < steps.length; i++) {
1822
+ const step = steps[i];
1823
+ res.write(`data: ${JSON.stringify({ type: 'step-start', index: i })}\n\n`);
1824
+ const result = await executeStepTesterAction(session.page, step);
1825
+ res.write(`data: ${JSON.stringify({ type: 'step-result', index: i, ...result })}\n\n`);
1826
+ if (!result.passed) break; // stop on first failure
1827
+ await new Promise(r => setTimeout(r, 300)); // small gap between steps
1828
+ }
1829
+ res.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`);
1830
+ res.end();
1831
+ return;
1832
+ }
1814
1833
  if (pathname.match(/^\/api\/step-tester\/[^/]+\/stop$/) && method === 'POST') {
1815
1834
  const testerId = pathname.split('/')[3];
1816
1835
  await stopStepTester(testerId);
@@ -1823,6 +1842,32 @@ export async function dashboardCommand(options) {
1823
1842
  sendJSON(res, 200, await listLibrarySteps(suitesDir));
1824
1843
  return;
1825
1844
  }
1845
+ if (pathname === '/api/step-library/pending' && method === 'GET') {
1846
+ sendJSON(res, 200, await listPendingSteps());
1847
+ return;
1848
+ }
1849
+ if (pathname.match(/^\/api\/step-library\/pending\/[^/]+\/approve$/) && method === 'POST') {
1850
+ const id = decodeURIComponent(pathname.split('/')[4]);
1851
+ const step = await approvePendingStep(suitesDir, id);
1852
+ sendJSON(res, 200, step || { error: 'Not found' });
1853
+ return;
1854
+ }
1855
+ if (pathname.match(/^\/api\/step-library\/pending\/[^/]+\/dismiss$/) && method === 'POST') {
1856
+ const id = decodeURIComponent(pathname.split('/')[4]);
1857
+ await dismissPendingStep(id);
1858
+ sendJSON(res, 200, { dismissed: true });
1859
+ return;
1860
+ }
1861
+ if (pathname === '/api/step-library/pending/approve-all' && method === 'POST') {
1862
+ const count = await approveAllPending(suitesDir);
1863
+ sendJSON(res, 200, { approved: count });
1864
+ return;
1865
+ }
1866
+ if (pathname === '/api/step-library/pending/dismiss-all' && method === 'POST') {
1867
+ await dismissAllPending();
1868
+ sendJSON(res, 200, { dismissed: true });
1869
+ return;
1870
+ }
1826
1871
  if (pathname === '/api/step-library' && method === 'POST') {
1827
1872
  const data = JSON.parse(await readBody(req));
1828
1873
  const step = await addLibraryStep(suitesDir, data);
@@ -3652,6 +3697,66 @@ async function syncIssuesStatus() {
3652
3697
  // ═══════════════════════════════════════════════════════════════
3653
3698
 
3654
3699
  const LIBRARY_FILE = () => path.join(os.homedir(), '.skopix', 'step-library.yaml');
3700
+ const LIBRARY_PENDING_FILE = () => path.join(os.homedir(), '.skopix', 'step-library-pending.yaml');
3701
+
3702
+ async function listPendingSteps() {
3703
+ const file = LIBRARY_PENDING_FILE();
3704
+ if (!await fs.pathExists(file)) return [];
3705
+ try {
3706
+ const content = await fs.readFile(file, 'utf8');
3707
+ const data = yaml.parse(content);
3708
+ return Array.isArray(data) ? data : [];
3709
+ } catch { return []; }
3710
+ }
3711
+
3712
+ async function savePendingSteps(steps) {
3713
+ const file = LIBRARY_PENDING_FILE();
3714
+ await fs.ensureDir(path.dirname(file));
3715
+ await fs.writeFile(file, yaml.stringify(steps));
3716
+ }
3717
+
3718
+ async function addToPending(suitesDir, pendingStep) {
3719
+ const pending = await listPendingSteps();
3720
+ const existing = await listLibrarySteps(suitesDir);
3721
+ // Check not already in library or pending
3722
+ const allSels = [...existing, ...pending].map(e => (e.stableSelector||e.selector||'').toLowerCase());
3723
+ const selLower = (pendingStep.stableSelector||pendingStep.selector||'').toLowerCase();
3724
+ if (allSels.includes(selLower)) return; // already exists
3725
+ // Check similarity
3726
+ const similar = [...existing, ...pending].find(e => stepSimilarity(e, pendingStep) >= 0.85);
3727
+ if (similar) return;
3728
+ pending.push({ ...pendingStep, id: 'pending-' + Math.random().toString(36).slice(2, 10), addedAt: new Date().toISOString() });
3729
+ await savePendingSteps(pending);
3730
+ }
3731
+
3732
+ async function approvePendingStep(suitesDir, id) {
3733
+ const pending = await listPendingSteps();
3734
+ const step = pending.find(s => s.id === id);
3735
+ if (!step) return null;
3736
+ const { id: _id, addedAt: _a, ...stepData } = step;
3737
+ const added = await addLibraryStep(suitesDir, stepData);
3738
+ await savePendingSteps(pending.filter(s => s.id !== id));
3739
+ return added;
3740
+ }
3741
+
3742
+ async function dismissPendingStep(id) {
3743
+ const pending = await listPendingSteps();
3744
+ await savePendingSteps(pending.filter(s => s.id !== id));
3745
+ }
3746
+
3747
+ async function approveAllPending(suitesDir) {
3748
+ const pending = await listPendingSteps();
3749
+ for (const step of pending) {
3750
+ const { id: _id, addedAt: _a, ...stepData } = step;
3751
+ await addLibraryStep(suitesDir, stepData);
3752
+ }
3753
+ await savePendingSteps([]);
3754
+ return pending.length;
3755
+ }
3756
+
3757
+ async function dismissAllPending() {
3758
+ await savePendingSteps([]);
3759
+ }
3655
3760
 
3656
3761
  async function listLibrarySteps(suitesDir) {
3657
3762
  const file = LIBRARY_FILE();
@@ -3733,19 +3838,41 @@ async function extractStepsToLibrary(suitesDir, steps, testName) {
3733
3838
  const sel = step.stableSelector || step.selector;
3734
3839
  if (!sel) continue;
3735
3840
  const selLower = sel.toLowerCase();
3736
- // Skip click steps if we already have this selector from a type step (deduplicate click/type pairs)
3841
+
3842
+ // Check exact match first
3737
3843
  if (seenSelectors.has(selLower)) {
3738
- const existing2 = existing.find(e => (e.stableSelector||e.selector||'').toLowerCase() === selLower) ||
3739
- toAdd.find(e => (e.stableSelector||e.selector||'').toLowerCase() === selLower);
3740
- if (existing2) { existing2.usageCount = (existing2.usageCount || 0) + 1; }
3844
+ const match = existing.find(e => (e.stableSelector||e.selector||'').toLowerCase() === selLower) ||
3845
+ toAdd.find(e => (e.stableSelector||e.selector||'').toLowerCase() === selLower);
3846
+ if (match) match.usageCount = (match.usageCount || 0) + 1;
3847
+ continue;
3848
+ }
3849
+
3850
+ // Check similarity against existing library entries (catches slight variations)
3851
+ const similarExisting = existing.find(e => stepSimilarity(e, step) >= 0.85);
3852
+ if (similarExisting) {
3853
+ similarExisting.usageCount = (similarExisting.usageCount || 0) + 1;
3854
+ // If new selector is more stable (has pi-test-identifier), upgrade it
3855
+ if (sel.includes('pi-test-identifier') && !(similarExisting.stableSelector||'').includes('pi-test-identifier')) {
3856
+ similarExisting.stableSelector = sel;
3857
+ similarExisting.selector = sel;
3858
+ }
3859
+ seenSelectors.add(selLower);
3741
3860
  continue;
3742
3861
  }
3862
+
3863
+ // Check similarity against steps we're about to add
3864
+ const similarNew = toAdd.find(e => stepSimilarity(e, step) >= 0.85);
3865
+ if (similarNew) {
3866
+ similarNew.usageCount = (similarNew.usageCount || 0) + 1;
3867
+ seenSelectors.add(selLower);
3868
+ continue;
3869
+ }
3870
+
3743
3871
  seenSelectors.add(selLower);
3744
3872
  // Generate clean name — strip action prefix
3745
3873
  let name = step.description || '';
3746
3874
  name = name.replace(/^(click|type|check|assert|select)\s+/i, '').trim() || sel.slice(0, 50);
3747
3875
  toAdd.push({
3748
- id: 'lib-' + Math.random().toString(36).slice(2, 10),
3749
3876
  name,
3750
3877
  selector: sel,
3751
3878
  stableSelector: step.stableSelector || sel,
@@ -3757,29 +3884,32 @@ async function extractStepsToLibrary(suitesDir, steps, testName) {
3757
3884
  tags: [],
3758
3885
  usageCount: 1,
3759
3886
  sourceTest: testName,
3760
- createdAt: new Date().toISOString(),
3761
3887
  });
3762
3888
  }
3763
3889
 
3764
- if (toAdd.length > 0 || existing.some(e => e.usageCount !== undefined)) {
3765
- await saveLibrarySteps([...existing, ...toAdd]);
3890
+ // Add new elements to pending review queue (not directly to library)
3891
+ for (const step of toAdd) {
3892
+ await addToPending(suitesDir, step);
3893
+ }
3894
+
3895
+ // Save updated usage counts for existing entries
3896
+ if (existing.some(e => e.usageCount !== undefined)) {
3897
+ await saveLibrarySteps(existing);
3766
3898
  }
3767
3899
  }
3768
3900
 
3769
3901
  // Import steps from all existing tests into library
3770
3902
  async function importStepsFromTests(suitesDir) {
3771
3903
  const allTests = await listAllTests(suitesDir);
3772
- let imported = 0;
3773
- let skipped = 0;
3904
+ let queued = 0;
3774
3905
  for (const test of allTests) {
3775
3906
  if (!test.steps || test.steps.length === 0) continue;
3776
- const before = (await listLibrarySteps(suitesDir)).length;
3907
+ const before = (await listPendingSteps()).length;
3777
3908
  await extractStepsToLibrary(suitesDir, test.steps, test.name);
3778
- const after = (await listLibrarySteps(suitesDir)).length;
3779
- imported += after - before;
3780
- skipped += test.steps.length - (after - before);
3909
+ const after = (await listPendingSteps()).length;
3910
+ queued += after - before;
3781
3911
  }
3782
- return { imported, skipped, total: imported + skipped };
3912
+ return { queued, message: `${queued} elements added to review queue` };
3783
3913
  }
3784
3914
 
3785
3915
  // Sync existing tests — replace matching steps with library steps
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skopix",
3
- "version": "2.0.37",
3
+ "version": "2.0.39",
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": {
@@ -1321,6 +1321,7 @@ body.viewer-mode .saved-test-row { cursor: default !important; }
1321
1321
  <a class="nav-item" data-view="step-library">
1322
1322
  <span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="4" rx="1"/><rect x="3" y="10" width="18" height="4" rx="1"/><rect x="3" y="17" width="18" height="4" rx="1"/></svg></span>
1323
1323
  Step library
1324
+ <span id="lib-pending-badge" style="display:none;background:#f59e0b;color:#000;font-size:9px;font-weight:700;padding:1px 5px;border-radius:8px;margin-left:4px;font-family:var(--mono)"></span>
1324
1325
  </a>
1325
1326
 
1326
1327
 
@@ -1589,6 +1590,23 @@ body.viewer-mode .saved-test-row { cursor: default !important; }
1589
1590
  </div>
1590
1591
  </div>
1591
1592
 
1593
+ <!-- Pending review section -->
1594
+ <div id="library-pending-section" style="display:none;margin-bottom:16px">
1595
+ <div class="card" style="border-color:rgba(245,158,11,0.3);background:rgba(245,158,11,0.04)">
1596
+ <div class="card-header" style="border-color:rgba(245,158,11,0.2)">
1597
+ <div class="card-title" style="color:#f59e0b">
1598
+ ⏳ Pending review
1599
+ <span id="pending-count-label" style="font-family:var(--mono);font-size:11px;color:var(--muted);font-weight:400;margin-left:8px"></span>
1600
+ </div>
1601
+ <div style="display:flex;gap:8px">
1602
+ <button class="btn btn-ghost" style="font-size:11px;padding:4px 10px" onclick="dismissAllPendingUI()">Dismiss all</button>
1603
+ <button class="btn btn-primary" style="font-size:11px;padding:4px 10px;background:#f59e0b;border-color:#f59e0b;color:#000" onclick="approveAllPendingUI()">Add all to library</button>
1604
+ </div>
1605
+ </div>
1606
+ <div id="pending-steps-list" style="padding:0"></div>
1607
+ </div>
1608
+ </div>
1609
+
1592
1610
  <!-- Search + filter -->
1593
1611
  <div style="display:flex;gap:10px;margin-bottom:16px">
1594
1612
  <input class="form-input" id="library-search" type="text" placeholder="Search elements..." oninput="filterLibrarySteps()" style="flex:1">
@@ -1663,6 +1681,10 @@ body.viewer-mode .saved-test-row { cursor: default !important; }
1663
1681
  </div>
1664
1682
  <div style="display:flex;gap:8px;align-items:center">
1665
1683
  <input class="form-input" id="builder-test-name" type="text" placeholder="Test name..." style="width:220px">
1684
+ <button class="btn btn-ghost" id="btn-preview-test" onclick="startBuilderPreview()" style="color:#f59e0b;border-color:rgba(245,158,11,0.3)">
1685
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
1686
+ Preview
1687
+ </button>
1666
1688
  <button class="btn btn-primary" onclick="saveBuiltTest()">Save test</button>
1667
1689
  </div>
1668
1690
  </div>
@@ -1686,7 +1708,7 @@ body.viewer-mode .saved-test-row { cursor: default !important; }
1686
1708
  </div>
1687
1709
 
1688
1710
  <!-- RIGHT: Test steps being built -->
1689
- <div style="display:flex;flex-direction:column;overflow:hidden">
1711
+ <div style="display:flex;flex-direction:column;overflow:hidden;position:relative">
1690
1712
  <div style="padding:14px 20px;border-bottom:1px solid var(--border);flex-shrink:0;display:flex;align-items:center;justify-content:space-between">
1691
1713
  <div>
1692
1714
  <div style="font-family:var(--mono);font-size:10px;color:var(--muted);letter-spacing:0.1em">TEST STEPS</div>
@@ -1702,6 +1724,36 @@ body.viewer-mode .saved-test-row { cursor: default !important; }
1702
1724
  ← Click steps from the library to add them here
1703
1725
  </div>
1704
1726
  </div>
1727
+
1728
+ <!-- PREVIEW OVERLAY -->
1729
+ <div id="builder-preview-overlay" style="display:none;position:absolute;inset:0;background:var(--bg);flex-direction:column;z-index:10">
1730
+ <div style="padding:14px 20px;border-bottom:1px solid var(--border);flex-shrink:0;display:flex;align-items:center;justify-content:space-between;background:rgba(245,158,11,0.06)">
1731
+ <div style="display:flex;align-items:center;gap:10px">
1732
+ <span style="color:#f59e0b;font-size:14px" id="preview-status-dot">⏸</span>
1733
+ <div>
1734
+ <div style="font-family:var(--mono);font-size:10px;color:#f59e0b;letter-spacing:0.1em">PREVIEW MODE</div>
1735
+ <div style="font-family:var(--mono);font-size:11px;color:var(--muted);margin-top:2px" id="preview-status-text">Browser open — ready to run</div>
1736
+ </div>
1737
+ </div>
1738
+ <div style="display:flex;gap:8px">
1739
+ <button class="btn btn-ghost" id="btn-preview-run-all" onclick="previewRunAll()" style="font-size:11px;padding:4px 12px;color:#f59e0b;border-color:rgba(245,158,11,0.3)">
1740
+ ▶▶ Run all
1741
+ </button>
1742
+ <button class="btn btn-ghost" id="btn-preview-run-next" onclick="previewRunNext()" style="font-size:11px;padding:4px 12px;color:#f59e0b;border-color:rgba(245,158,11,0.3)">
1743
+ ▶ Run next
1744
+ </button>
1745
+ <button class="btn btn-ghost" onclick="stopBuilderPreview()" style="font-size:11px;padding:4px 10px">
1746
+ ✕ Stop preview
1747
+ </button>
1748
+ </div>
1749
+ </div>
1750
+ <div id="preview-steps-list" style="overflow-y:auto;flex:1;padding:8px 0"></div>
1751
+ <div style="padding:12px 20px;border-top:1px solid var(--border);flex-shrink:0;display:flex;align-items:center;gap:10px" id="preview-summary" style="display:none">
1752
+ <span id="preview-passed" style="font-family:var(--mono);font-size:12px;color:#34d399"></span>
1753
+ <span id="preview-failed" style="font-family:var(--mono);font-size:12px;color:#ef4444"></span>
1754
+ </div>
1755
+ </div>
1756
+
1705
1757
  </div>
1706
1758
 
1707
1759
  </div>
@@ -2791,6 +2843,7 @@ async function refreshAll() {
2791
2843
  renderConfig();
2792
2844
  renderSuites();
2793
2845
  populateSuiteSelect();
2846
+ checkPendingCount();
2794
2847
  }
2795
2848
 
2796
2849
  function renderStats() {
@@ -5549,8 +5602,122 @@ async function saveOllamaConfig() {
5549
5602
  }
5550
5603
  }
5551
5604
 
5552
- // ── STEP LIBRARY ─────────────────────────────────────────────────────────────
5553
- let libraryStepsCache = [];
5605
+ // ── STEP LIBRARY PENDING REVIEW ──────────────────────────────────────────────
5606
+ let pendingStepsCache = [];
5607
+
5608
+ async function fetchPendingSteps() {
5609
+ try {
5610
+ const res = await fetch(API_BASE + '/api/step-library/pending');
5611
+ if (!res.ok) return [];
5612
+ pendingStepsCache = await res.json();
5613
+ return pendingStepsCache;
5614
+ } catch { return []; }
5615
+ }
5616
+
5617
+ function updatePendingBadge(count) {
5618
+ const badge = document.getElementById('lib-pending-badge');
5619
+ if (!badge) return;
5620
+ if (count > 0) { badge.textContent = count; badge.style.display = ''; }
5621
+ else { badge.style.display = 'none'; }
5622
+ }
5623
+
5624
+ function renderPendingSteps(steps) {
5625
+ const section = document.getElementById('library-pending-section');
5626
+ const list = document.getElementById('pending-steps-list');
5627
+ const countLabel = document.getElementById('pending-count-label');
5628
+ if (!section || !list) return;
5629
+
5630
+ if (!steps || steps.length === 0) {
5631
+ section.style.display = 'none';
5632
+ updatePendingBadge(0);
5633
+ return;
5634
+ }
5635
+
5636
+ section.style.display = '';
5637
+ updatePendingBadge(steps.length);
5638
+ if (countLabel) countLabel.textContent = `${steps.length} element${steps.length > 1 ? 's' : ''} waiting`;
5639
+
5640
+ list.innerHTML = steps.map(s => {
5641
+ const sel = s.stableSelector || s.selector || '';
5642
+ const fragile = isSelectorFragile(sel);
5643
+ return `
5644
+ <div style="display:flex;align-items:center;gap:12px;padding:12px 20px;border-bottom:1px solid rgba(245,158,11,0.1)" data-pending-id="${escapeAttr(s.id)}">
5645
+ <div style="flex:1;min-width:0">
5646
+ <div style="font-size:13px;color:var(--text);margin-bottom:3px">${escapeHtml(s.name||'')}</div>
5647
+ <div style="display:flex;align-items:center;gap:6px">
5648
+ ${fragile ? '<span style="color:#f59e0b;font-size:12px" title="Fragile selector">⚠</span>' : ''}
5649
+ <code style="font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block;max-width:400px">${escapeHtml(sel)}</code>
5650
+ </div>
5651
+ ${s.sourceTest ? `<div style="font-family:var(--mono);font-size:10px;color:var(--muted2);margin-top:2px">from: ${escapeHtml(s.sourceTest)}</div>` : ''}
5652
+ </div>
5653
+ <div style="display:flex;gap:6px;flex-shrink:0">
5654
+ <button class="btn btn-ghost pending-edit-btn" style="padding:4px 10px;font-size:11px" data-id="${escapeAttr(s.id)}">Edit</button>
5655
+ <button class="btn btn-ghost pending-dismiss-btn" style="padding:4px 10px;font-size:11px;color:var(--red)" data-id="${escapeAttr(s.id)}">Dismiss</button>
5656
+ <button class="btn btn-primary pending-approve-btn" style="padding:4px 10px;font-size:11px;background:#f59e0b;border-color:#f59e0b;color:#000" data-id="${escapeAttr(s.id)}">Add</button>
5657
+ </div>
5658
+ </div>`;
5659
+ }).join('');
5660
+ }
5661
+
5662
+ async function loadLibraryView() {
5663
+ const [steps, pending] = await Promise.all([fetchLibrarySteps(), fetchPendingSteps()]);
5664
+ populateLibraryTagFilter();
5665
+ renderPendingSteps(pending);
5666
+ renderLibrarySteps(steps);
5667
+ }
5668
+
5669
+ async function approvePendingUI(id) {
5670
+ await fetch(API_BASE + '/api/step-library/pending/' + encodeURIComponent(id) + '/approve', { method: 'POST' });
5671
+ await loadLibraryView();
5672
+ }
5673
+
5674
+ async function dismissPendingUI(id) {
5675
+ await fetch(API_BASE + '/api/step-library/pending/' + encodeURIComponent(id) + '/dismiss', { method: 'POST' });
5676
+ const pending = await fetchPendingSteps();
5677
+ renderPendingSteps(pending);
5678
+ }
5679
+
5680
+ async function approveAllPendingUI() {
5681
+ const res = await fetch(API_BASE + '/api/step-library/pending/approve-all', { method: 'POST' });
5682
+ const data = await res.json();
5683
+ showToast(`Added ${data.approved} elements to library`);
5684
+ await loadLibraryView();
5685
+ }
5686
+
5687
+ async function dismissAllPendingUI() {
5688
+ showConfirm('Dismiss all?', 'Dismiss all pending elements without adding them to the library?', async () => {
5689
+ await fetch(API_BASE + '/api/step-library/pending/dismiss-all', { method: 'POST' });
5690
+ await loadLibraryView();
5691
+ });
5692
+ }
5693
+
5694
+ // Also check pending count on page load and periodically
5695
+ async function checkPendingCount() {
5696
+ const pending = await fetchPendingSteps();
5697
+ updatePendingBadge(pending.length);
5698
+ }
5699
+
5700
+ // Event delegation for pending buttons
5701
+ document.addEventListener('click', (e) => {
5702
+ const approveBtn = e.target.closest('.pending-approve-btn');
5703
+ const dismissBtn = e.target.closest('.pending-dismiss-btn');
5704
+ const editBtn = e.target.closest('.pending-edit-btn');
5705
+ if (approveBtn) approvePendingUI(approveBtn.dataset.id);
5706
+ if (dismissBtn) dismissPendingUI(dismissBtn.dataset.id);
5707
+ if (editBtn) {
5708
+ // Open edit modal pre-filled with pending step data
5709
+ const step = pendingStepsCache.find(s => s.id === editBtn.dataset.id);
5710
+ if (step) {
5711
+ document.getElementById('lib-step-id').value = 'pending:' + step.id;
5712
+ document.getElementById('lib-step-name').value = step.name || '';
5713
+ document.getElementById('lib-step-selector').value = step.stableSelector || step.selector || '';
5714
+ document.getElementById('lib-step-action').value = step.defaultAction || '';
5715
+ document.getElementById('lib-step-tags').value = (step.tags || []).join(', ');
5716
+ document.getElementById('library-step-modal-title').textContent = 'Edit before adding';
5717
+ document.getElementById('library-step-modal').style.display = 'flex';
5718
+ }
5719
+ }
5720
+ });
5554
5721
 
5555
5722
  async function fetchLibrarySteps() {
5556
5723
  try {
@@ -5861,7 +6028,9 @@ function closeLibraryStepModal() {
5861
6028
  }
5862
6029
 
5863
6030
  async function saveLibraryStep() {
5864
- const id = document.getElementById('lib-step-id').value;
6031
+ const rawId = document.getElementById('lib-step-id').value;
6032
+ const isPending = rawId.startsWith('pending:');
6033
+ const id = isPending ? rawId.slice(8) : rawId;
5865
6034
  const name = document.getElementById('lib-step-name').value.trim();
5866
6035
  const selector = document.getElementById('lib-step-selector').value.trim();
5867
6036
  const defaultAction = document.getElementById('lib-step-action').value;
@@ -5871,14 +6040,23 @@ async function saveLibraryStep() {
5871
6040
  if (!selector) { showToast('Selector is required'); return; }
5872
6041
 
5873
6042
  try {
5874
- const body = { name, selector, stableSelector: selector, defaultAction: defaultAction || null, tags };
5875
- const url = id ? `${API_BASE}/api/step-library/${encodeURIComponent(id)}` : `${API_BASE}/api/step-library`;
5876
- const method = id ? 'PUT' : 'POST';
5877
- const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
5878
- if (!res.ok) throw new Error(await res.text());
6043
+ if (isPending) {
6044
+ // Dismiss pending and add directly to library with edits
6045
+ await fetch(API_BASE + '/api/step-library/pending/' + encodeURIComponent(id) + '/dismiss', { method: 'POST' });
6046
+ const body = { name, selector, stableSelector: selector, defaultAction: defaultAction || null, tags };
6047
+ await fetch(API_BASE + '/api/step-library', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
6048
+ } else if (id) {
6049
+ const body = { name, selector, stableSelector: selector, defaultAction: defaultAction || null, tags };
6050
+ const res = await fetch(API_BASE + '/api/step-library/' + encodeURIComponent(id), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
6051
+ if (!res.ok) throw new Error(await res.text());
6052
+ } else {
6053
+ const body = { name, selector, stableSelector: selector, defaultAction: defaultAction || null, tags };
6054
+ const res = await fetch(API_BASE + '/api/step-library', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
6055
+ if (!res.ok) throw new Error(await res.text());
6056
+ }
5879
6057
  closeLibraryStepModal();
5880
6058
  await loadLibraryView();
5881
- showToast(id ? 'Element updated' : 'Element added to library');
6059
+ showToast(isPending ? 'Element added to library' : id ? 'Element updated' : 'Element added to library');
5882
6060
  } catch (err) { showToast('Error: ' + err.message); }
5883
6061
  }
5884
6062
 
@@ -5896,7 +6074,7 @@ async function importStepsFromTests() {
5896
6074
  try {
5897
6075
  const res = await fetch(API_BASE + '/api/step-library/import', { method: 'POST' });
5898
6076
  const data = await res.json();
5899
- showToast(`Imported ${data.imported} new steps (${data.skipped} already existed)`);
6077
+ showToast(data.queued > 0 ? `${data.queued} elements added to review queue` : 'No new elements found');
5900
6078
  await loadLibraryView();
5901
6079
  } catch (err) { showToast('Error: ' + err.message); }
5902
6080
  finally { if (btn) { btn.disabled = false; btn.textContent = 'Import from all tests'; } }
@@ -5939,9 +6117,14 @@ function openTestBuilder() {
5939
6117
  }
5940
6118
 
5941
6119
  function closeTestBuilder() {
6120
+ // Stop preview if active
6121
+ if (previewState.testerId) {
6122
+ fetch(API_BASE + '/api/step-tester/' + previewState.testerId + '/stop', { method: 'POST' }).catch(() => {});
6123
+ previewState.testerId = null;
6124
+ }
6125
+ document.getElementById('builder-preview-overlay').style.display = 'none';
5942
6126
  const builder = document.getElementById('view-test-builder');
5943
6127
  if (builder) builder.style.display = 'none';
5944
- // Restore all views to their default display state before switching
5945
6128
  document.querySelectorAll('.view').forEach(v => {
5946
6129
  if (v.id !== 'view-test-builder') v.style.display = '';
5947
6130
  });
@@ -6238,6 +6421,204 @@ function harvestAnother() {
6238
6421
  document.getElementById('harvester-status').textContent = 'Navigate to the next element, then click Capture.';
6239
6422
  }
6240
6423
 
6424
+ // ── TEST BUILDER PREVIEW ─────────────────────────────────────────────────────
6425
+ let previewState = { testerId: null, currentStep: 0, results: [], running: false };
6426
+
6427
+ function renderPreviewSteps() {
6428
+ const container = document.getElementById('preview-steps-list');
6429
+ if (!container) return;
6430
+ const actionColors = { click:'#22d3ee', type:'#a78bfa', check:'#34d399', assert:'#fb923c', select:'#f472b6', scroll:'#6b7280' };
6431
+
6432
+ container.innerHTML = builderSteps.map((s, i) => {
6433
+ const result = previewState.results[i];
6434
+ const isCurrent = i === previewState.currentStep && !result;
6435
+ const status = result ? (result.passed ? '✓' : '✗') : (isCurrent ? '▶' : '○');
6436
+ const statusColor = result ? (result.passed ? '#34d399' : '#ef4444') : (isCurrent ? '#f59e0b' : 'var(--muted2)');
6437
+ const bg = isCurrent ? 'rgba(245,158,11,0.06)' : result && !result.passed ? 'rgba(239,68,68,0.04)' : '';
6438
+ return `<div style="display:flex;align-items:flex-start;gap:12px;padding:10px 20px;border-bottom:1px solid var(--border);background:${bg}">
6439
+ <span style="font-family:var(--mono);font-size:13px;color:${statusColor};min-width:20px;margin-top:1px">${status}</span>
6440
+ <div style="flex:1;min-width:0">
6441
+ <div style="display:flex;align-items:center;gap:8px">
6442
+ <span style="font-family:var(--mono);font-size:10px;color:${actionColors[s.action]||'var(--muted)'}">${s.action}</span>
6443
+ <span style="font-size:12px;color:var(--text)">${escapeHtml(s.description||'')}</span>
6444
+ </div>
6445
+ <code style="font-size:10px;color:var(--muted2)">${escapeHtml(s.stableSelector||s.selector||'')}</code>
6446
+ ${result && !result.passed ? `<div style="font-family:var(--mono);font-size:11px;color:#ef4444;margin-top:4px">✗ ${escapeHtml(result.error||'Failed')}</div>` : ''}
6447
+ ${result && result.screenshot ? `<img src="data:image/jpeg;base64,${result.screenshot}" style="width:100%;max-width:300px;border-radius:6px;margin-top:8px;border:1px solid var(--border)" loading="lazy">` : ''}
6448
+ </div>
6449
+ </div>`;
6450
+ }).join('');
6451
+
6452
+ // Scroll current step into view
6453
+ const items = container.querySelectorAll('div[style*="border-bottom"]');
6454
+ if (items[previewState.currentStep]) items[previewState.currentStep].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
6455
+ }
6456
+
6457
+ function updatePreviewSummary() {
6458
+ const passed = previewState.results.filter(r => r && r.passed).length;
6459
+ const failed = previewState.results.filter(r => r && !r.passed).length;
6460
+ const total = previewState.results.filter(r => r).length;
6461
+ if (total === 0) return;
6462
+ document.getElementById('preview-passed').textContent = `✓ ${passed} passed`;
6463
+ document.getElementById('preview-failed').textContent = failed > 0 ? `✗ ${failed} failed` : '';
6464
+ document.getElementById('preview-summary').style.display = 'flex';
6465
+ }
6466
+
6467
+ async function startBuilderPreview() {
6468
+ const url = document.getElementById('builder-url')?.value?.trim();
6469
+ if (builderSteps.length === 0) { showToast('Add steps first'); return; }
6470
+ if (!url) { showToast('Set a start URL first'); document.getElementById('builder-url').focus(); return; }
6471
+
6472
+ const btn = document.getElementById('btn-preview-test');
6473
+ btn.disabled = true;
6474
+ btn.textContent = 'Opening...';
6475
+
6476
+ try {
6477
+ const res = await fetch(API_BASE + '/api/step-tester/start', {
6478
+ method: 'POST',
6479
+ headers: { 'Content-Type': 'application/json' },
6480
+ body: JSON.stringify({ url, selector: builderSteps[0]?.stableSelector || '' }),
6481
+ });
6482
+ const data = await res.json();
6483
+ if (!res.ok) throw new Error(data.error || 'Failed');
6484
+
6485
+ previewState = { testerId: data.testerId, currentStep: 0, results: [], running: false };
6486
+
6487
+ // Show overlay
6488
+ document.getElementById('builder-preview-overlay').style.display = 'flex';
6489
+ document.getElementById('preview-status-text').textContent = 'Browser open — navigate to start, then click Run next or Run all';
6490
+ document.getElementById('preview-summary').style.display = 'none';
6491
+ renderPreviewSteps();
6492
+ btn.textContent = '⏸ Previewing';
6493
+ } catch (err) {
6494
+ showToast('Error: ' + err.message);
6495
+ btn.disabled = false;
6496
+ btn.textContent = 'Preview';
6497
+ }
6498
+ }
6499
+
6500
+ async function stopBuilderPreview() {
6501
+ if (previewState.testerId) {
6502
+ await fetch(API_BASE + '/api/step-tester/' + previewState.testerId + '/stop', { method: 'POST' }).catch(() => {});
6503
+ previewState.testerId = null;
6504
+ }
6505
+ document.getElementById('builder-preview-overlay').style.display = 'none';
6506
+ const btn = document.getElementById('btn-preview-test');
6507
+ btn.disabled = false;
6508
+ btn.textContent = 'Preview';
6509
+ }
6510
+
6511
+ async function previewRunNext() {
6512
+ if (!previewState.testerId || previewState.running) return;
6513
+ if (previewState.currentStep >= builderSteps.length) return;
6514
+
6515
+ previewState.running = true;
6516
+ const step = builderSteps[previewState.currentStep];
6517
+ document.getElementById('preview-status-text').textContent = `Running step ${previewState.currentStep + 1}/${builderSteps.length}...`;
6518
+ document.getElementById('btn-preview-run-next').disabled = true;
6519
+ document.getElementById('btn-preview-run-all').disabled = true;
6520
+ renderPreviewSteps();
6521
+
6522
+ try {
6523
+ const res = await fetch(API_BASE + '/api/step-tester/' + previewState.testerId + '/run', {
6524
+ method: 'POST',
6525
+ headers: { 'Content-Type': 'application/json' },
6526
+ body: JSON.stringify({ selector: step.stableSelector || step.selector, action: step.action, value: step.value || '', assertType: step.assertType }),
6527
+ });
6528
+ const result = await res.json();
6529
+ previewState.results[previewState.currentStep] = result;
6530
+
6531
+ if (result.passed) {
6532
+ previewState.currentStep++;
6533
+ document.getElementById('preview-status-text').textContent =
6534
+ previewState.currentStep >= builderSteps.length
6535
+ ? `✓ All ${builderSteps.length} steps passed!`
6536
+ : `✓ Step ${previewState.currentStep} passed — ready for next`;
6537
+ document.getElementById('preview-status-dot').textContent = previewState.currentStep >= builderSteps.length ? '✓' : '⏸';
6538
+ document.getElementById('preview-status-dot').style.color = previewState.currentStep >= builderSteps.length ? '#34d399' : '#f59e0b';
6539
+ } else {
6540
+ document.getElementById('preview-status-text').textContent = `✗ Step ${previewState.currentStep + 1} failed — fix the step and try again`;
6541
+ document.getElementById('preview-status-dot').textContent = '✗';
6542
+ document.getElementById('preview-status-dot').style.color = '#ef4444';
6543
+ }
6544
+ } catch (err) {
6545
+ previewState.results[previewState.currentStep] = { passed: false, error: err.message };
6546
+ }
6547
+
6548
+ previewState.running = false;
6549
+ document.getElementById('btn-preview-run-next').disabled = previewState.currentStep >= builderSteps.length;
6550
+ document.getElementById('btn-preview-run-all').disabled = false;
6551
+ renderPreviewSteps();
6552
+ updatePreviewSummary();
6553
+ }
6554
+
6555
+ async function previewRunAll() {
6556
+ if (!previewState.testerId || previewState.running) return;
6557
+ previewState.running = true;
6558
+ previewState.results = [];
6559
+ previewState.currentStep = 0;
6560
+ document.getElementById('btn-preview-run-all').disabled = true;
6561
+ document.getElementById('btn-preview-run-next').disabled = true;
6562
+ document.getElementById('preview-summary').style.display = 'none';
6563
+
6564
+ const steps = builderSteps.map(s => ({
6565
+ selector: s.stableSelector || s.selector,
6566
+ action: s.action,
6567
+ value: s.value || '',
6568
+ assertType: s.assertType,
6569
+ }));
6570
+
6571
+ try {
6572
+ const es = new EventSource(API_BASE + '/api/step-tester/' + previewState.testerId + '/run-sequence?' + new URLSearchParams({ steps: JSON.stringify(steps) }));
6573
+
6574
+ // Use POST with EventSource workaround — send steps via a separate POST then listen
6575
+ const res = await fetch(API_BASE + '/api/step-tester/' + previewState.testerId + '/run-sequence', {
6576
+ method: 'POST',
6577
+ headers: { 'Content-Type': 'application/json' },
6578
+ body: JSON.stringify({ steps }),
6579
+ });
6580
+
6581
+ const reader = res.body.getReader();
6582
+ const decoder = new TextDecoder();
6583
+ let buffer = '';
6584
+
6585
+ while (true) {
6586
+ const { done, value } = await reader.read();
6587
+ if (done) break;
6588
+ buffer += decoder.decode(value, { stream: true });
6589
+ const lines = buffer.split('\n');
6590
+ buffer = lines.pop();
6591
+ for (const line of lines) {
6592
+ if (!line.startsWith('data: ')) continue;
6593
+ try {
6594
+ const msg = JSON.parse(line.slice(6));
6595
+ if (msg.type === 'step-start') {
6596
+ previewState.currentStep = msg.index;
6597
+ document.getElementById('preview-status-text').textContent = `Running step ${msg.index + 1}/${builderSteps.length}...`;
6598
+ renderPreviewSteps();
6599
+ } else if (msg.type === 'step-result') {
6600
+ previewState.results[msg.index] = { passed: msg.passed, error: msg.error, screenshot: msg.screenshot };
6601
+ previewState.currentStep = msg.passed ? msg.index + 1 : msg.index;
6602
+ renderPreviewSteps();
6603
+ updatePreviewSummary();
6604
+ } else if (msg.type === 'done') {
6605
+ const allPassed = previewState.results.every(r => r && r.passed);
6606
+ document.getElementById('preview-status-text').textContent = allPassed ? `✓ All ${builderSteps.length} steps passed!` : `✗ Preview complete — some steps failed`;
6607
+ document.getElementById('preview-status-dot').textContent = allPassed ? '✓' : '✗';
6608
+ document.getElementById('preview-status-dot').style.color = allPassed ? '#34d399' : '#ef4444';
6609
+ }
6610
+ } catch {}
6611
+ }
6612
+ }
6613
+ } catch (err) {
6614
+ document.getElementById('preview-status-text').textContent = 'Error: ' + err.message;
6615
+ }
6616
+
6617
+ previewState.running = false;
6618
+ document.getElementById('btn-preview-run-all').disabled = false;
6619
+ document.getElementById('btn-preview-run-next').disabled = previewState.currentStep >= builderSteps.length;
6620
+ }
6621
+
6241
6622
  // ── SUITE TEST PICKER ────────────────────────────────────────────────────────
6242
6623
  function openSuiteTestPicker() {
6243
6624
  const list = document.getElementById('stp-test-list');