skopix 2.0.37 → 2.0.38

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.
@@ -1823,6 +1823,32 @@ export async function dashboardCommand(options) {
1823
1823
  sendJSON(res, 200, await listLibrarySteps(suitesDir));
1824
1824
  return;
1825
1825
  }
1826
+ if (pathname === '/api/step-library/pending' && method === 'GET') {
1827
+ sendJSON(res, 200, await listPendingSteps());
1828
+ return;
1829
+ }
1830
+ if (pathname.match(/^\/api\/step-library\/pending\/[^/]+\/approve$/) && method === 'POST') {
1831
+ const id = decodeURIComponent(pathname.split('/')[4]);
1832
+ const step = await approvePendingStep(suitesDir, id);
1833
+ sendJSON(res, 200, step || { error: 'Not found' });
1834
+ return;
1835
+ }
1836
+ if (pathname.match(/^\/api\/step-library\/pending\/[^/]+\/dismiss$/) && method === 'POST') {
1837
+ const id = decodeURIComponent(pathname.split('/')[4]);
1838
+ await dismissPendingStep(id);
1839
+ sendJSON(res, 200, { dismissed: true });
1840
+ return;
1841
+ }
1842
+ if (pathname === '/api/step-library/pending/approve-all' && method === 'POST') {
1843
+ const count = await approveAllPending(suitesDir);
1844
+ sendJSON(res, 200, { approved: count });
1845
+ return;
1846
+ }
1847
+ if (pathname === '/api/step-library/pending/dismiss-all' && method === 'POST') {
1848
+ await dismissAllPending();
1849
+ sendJSON(res, 200, { dismissed: true });
1850
+ return;
1851
+ }
1826
1852
  if (pathname === '/api/step-library' && method === 'POST') {
1827
1853
  const data = JSON.parse(await readBody(req));
1828
1854
  const step = await addLibraryStep(suitesDir, data);
@@ -3652,6 +3678,66 @@ async function syncIssuesStatus() {
3652
3678
  // ═══════════════════════════════════════════════════════════════
3653
3679
 
3654
3680
  const LIBRARY_FILE = () => path.join(os.homedir(), '.skopix', 'step-library.yaml');
3681
+ const LIBRARY_PENDING_FILE = () => path.join(os.homedir(), '.skopix', 'step-library-pending.yaml');
3682
+
3683
+ async function listPendingSteps() {
3684
+ const file = LIBRARY_PENDING_FILE();
3685
+ if (!await fs.pathExists(file)) return [];
3686
+ try {
3687
+ const content = await fs.readFile(file, 'utf8');
3688
+ const data = yaml.parse(content);
3689
+ return Array.isArray(data) ? data : [];
3690
+ } catch { return []; }
3691
+ }
3692
+
3693
+ async function savePendingSteps(steps) {
3694
+ const file = LIBRARY_PENDING_FILE();
3695
+ await fs.ensureDir(path.dirname(file));
3696
+ await fs.writeFile(file, yaml.stringify(steps));
3697
+ }
3698
+
3699
+ async function addToPending(suitesDir, pendingStep) {
3700
+ const pending = await listPendingSteps();
3701
+ const existing = await listLibrarySteps(suitesDir);
3702
+ // Check not already in library or pending
3703
+ const allSels = [...existing, ...pending].map(e => (e.stableSelector||e.selector||'').toLowerCase());
3704
+ const selLower = (pendingStep.stableSelector||pendingStep.selector||'').toLowerCase();
3705
+ if (allSels.includes(selLower)) return; // already exists
3706
+ // Check similarity
3707
+ const similar = [...existing, ...pending].find(e => stepSimilarity(e, pendingStep) >= 0.85);
3708
+ if (similar) return;
3709
+ pending.push({ ...pendingStep, id: 'pending-' + Math.random().toString(36).slice(2, 10), addedAt: new Date().toISOString() });
3710
+ await savePendingSteps(pending);
3711
+ }
3712
+
3713
+ async function approvePendingStep(suitesDir, id) {
3714
+ const pending = await listPendingSteps();
3715
+ const step = pending.find(s => s.id === id);
3716
+ if (!step) return null;
3717
+ const { id: _id, addedAt: _a, ...stepData } = step;
3718
+ const added = await addLibraryStep(suitesDir, stepData);
3719
+ await savePendingSteps(pending.filter(s => s.id !== id));
3720
+ return added;
3721
+ }
3722
+
3723
+ async function dismissPendingStep(id) {
3724
+ const pending = await listPendingSteps();
3725
+ await savePendingSteps(pending.filter(s => s.id !== id));
3726
+ }
3727
+
3728
+ async function approveAllPending(suitesDir) {
3729
+ const pending = await listPendingSteps();
3730
+ for (const step of pending) {
3731
+ const { id: _id, addedAt: _a, ...stepData } = step;
3732
+ await addLibraryStep(suitesDir, stepData);
3733
+ }
3734
+ await savePendingSteps([]);
3735
+ return pending.length;
3736
+ }
3737
+
3738
+ async function dismissAllPending() {
3739
+ await savePendingSteps([]);
3740
+ }
3655
3741
 
3656
3742
  async function listLibrarySteps(suitesDir) {
3657
3743
  const file = LIBRARY_FILE();
@@ -3733,19 +3819,41 @@ async function extractStepsToLibrary(suitesDir, steps, testName) {
3733
3819
  const sel = step.stableSelector || step.selector;
3734
3820
  if (!sel) continue;
3735
3821
  const selLower = sel.toLowerCase();
3736
- // Skip click steps if we already have this selector from a type step (deduplicate click/type pairs)
3822
+
3823
+ // Check exact match first
3737
3824
  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; }
3825
+ const match = existing.find(e => (e.stableSelector||e.selector||'').toLowerCase() === selLower) ||
3826
+ toAdd.find(e => (e.stableSelector||e.selector||'').toLowerCase() === selLower);
3827
+ if (match) match.usageCount = (match.usageCount || 0) + 1;
3741
3828
  continue;
3742
3829
  }
3830
+
3831
+ // Check similarity against existing library entries (catches slight variations)
3832
+ const similarExisting = existing.find(e => stepSimilarity(e, step) >= 0.85);
3833
+ if (similarExisting) {
3834
+ similarExisting.usageCount = (similarExisting.usageCount || 0) + 1;
3835
+ // If new selector is more stable (has pi-test-identifier), upgrade it
3836
+ if (sel.includes('pi-test-identifier') && !(similarExisting.stableSelector||'').includes('pi-test-identifier')) {
3837
+ similarExisting.stableSelector = sel;
3838
+ similarExisting.selector = sel;
3839
+ }
3840
+ seenSelectors.add(selLower);
3841
+ continue;
3842
+ }
3843
+
3844
+ // Check similarity against steps we're about to add
3845
+ const similarNew = toAdd.find(e => stepSimilarity(e, step) >= 0.85);
3846
+ if (similarNew) {
3847
+ similarNew.usageCount = (similarNew.usageCount || 0) + 1;
3848
+ seenSelectors.add(selLower);
3849
+ continue;
3850
+ }
3851
+
3743
3852
  seenSelectors.add(selLower);
3744
3853
  // Generate clean name — strip action prefix
3745
3854
  let name = step.description || '';
3746
3855
  name = name.replace(/^(click|type|check|assert|select)\s+/i, '').trim() || sel.slice(0, 50);
3747
3856
  toAdd.push({
3748
- id: 'lib-' + Math.random().toString(36).slice(2, 10),
3749
3857
  name,
3750
3858
  selector: sel,
3751
3859
  stableSelector: step.stableSelector || sel,
@@ -3757,29 +3865,32 @@ async function extractStepsToLibrary(suitesDir, steps, testName) {
3757
3865
  tags: [],
3758
3866
  usageCount: 1,
3759
3867
  sourceTest: testName,
3760
- createdAt: new Date().toISOString(),
3761
3868
  });
3762
3869
  }
3763
3870
 
3764
- if (toAdd.length > 0 || existing.some(e => e.usageCount !== undefined)) {
3765
- await saveLibrarySteps([...existing, ...toAdd]);
3871
+ // Add new elements to pending review queue (not directly to library)
3872
+ for (const step of toAdd) {
3873
+ await addToPending(suitesDir, step);
3874
+ }
3875
+
3876
+ // Save updated usage counts for existing entries
3877
+ if (existing.some(e => e.usageCount !== undefined)) {
3878
+ await saveLibrarySteps(existing);
3766
3879
  }
3767
3880
  }
3768
3881
 
3769
3882
  // Import steps from all existing tests into library
3770
3883
  async function importStepsFromTests(suitesDir) {
3771
3884
  const allTests = await listAllTests(suitesDir);
3772
- let imported = 0;
3773
- let skipped = 0;
3885
+ let queued = 0;
3774
3886
  for (const test of allTests) {
3775
3887
  if (!test.steps || test.steps.length === 0) continue;
3776
- const before = (await listLibrarySteps(suitesDir)).length;
3888
+ const before = (await listPendingSteps()).length;
3777
3889
  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);
3890
+ const after = (await listPendingSteps()).length;
3891
+ queued += after - before;
3781
3892
  }
3782
- return { imported, skipped, total: imported + skipped };
3893
+ return { queued, message: `${queued} elements added to review queue` };
3783
3894
  }
3784
3895
 
3785
3896
  // 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.38",
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">
@@ -2791,6 +2809,7 @@ async function refreshAll() {
2791
2809
  renderConfig();
2792
2810
  renderSuites();
2793
2811
  populateSuiteSelect();
2812
+ checkPendingCount();
2794
2813
  }
2795
2814
 
2796
2815
  function renderStats() {
@@ -5549,8 +5568,122 @@ async function saveOllamaConfig() {
5549
5568
  }
5550
5569
  }
5551
5570
 
5552
- // ── STEP LIBRARY ─────────────────────────────────────────────────────────────
5553
- let libraryStepsCache = [];
5571
+ // ── STEP LIBRARY PENDING REVIEW ──────────────────────────────────────────────
5572
+ let pendingStepsCache = [];
5573
+
5574
+ async function fetchPendingSteps() {
5575
+ try {
5576
+ const res = await fetch(API_BASE + '/api/step-library/pending');
5577
+ if (!res.ok) return [];
5578
+ pendingStepsCache = await res.json();
5579
+ return pendingStepsCache;
5580
+ } catch { return []; }
5581
+ }
5582
+
5583
+ function updatePendingBadge(count) {
5584
+ const badge = document.getElementById('lib-pending-badge');
5585
+ if (!badge) return;
5586
+ if (count > 0) { badge.textContent = count; badge.style.display = ''; }
5587
+ else { badge.style.display = 'none'; }
5588
+ }
5589
+
5590
+ function renderPendingSteps(steps) {
5591
+ const section = document.getElementById('library-pending-section');
5592
+ const list = document.getElementById('pending-steps-list');
5593
+ const countLabel = document.getElementById('pending-count-label');
5594
+ if (!section || !list) return;
5595
+
5596
+ if (!steps || steps.length === 0) {
5597
+ section.style.display = 'none';
5598
+ updatePendingBadge(0);
5599
+ return;
5600
+ }
5601
+
5602
+ section.style.display = '';
5603
+ updatePendingBadge(steps.length);
5604
+ if (countLabel) countLabel.textContent = `${steps.length} element${steps.length > 1 ? 's' : ''} waiting`;
5605
+
5606
+ list.innerHTML = steps.map(s => {
5607
+ const sel = s.stableSelector || s.selector || '';
5608
+ const fragile = isSelectorFragile(sel);
5609
+ return `
5610
+ <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)}">
5611
+ <div style="flex:1;min-width:0">
5612
+ <div style="font-size:13px;color:var(--text);margin-bottom:3px">${escapeHtml(s.name||'')}</div>
5613
+ <div style="display:flex;align-items:center;gap:6px">
5614
+ ${fragile ? '<span style="color:#f59e0b;font-size:12px" title="Fragile selector">⚠</span>' : ''}
5615
+ <code style="font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block;max-width:400px">${escapeHtml(sel)}</code>
5616
+ </div>
5617
+ ${s.sourceTest ? `<div style="font-family:var(--mono);font-size:10px;color:var(--muted2);margin-top:2px">from: ${escapeHtml(s.sourceTest)}</div>` : ''}
5618
+ </div>
5619
+ <div style="display:flex;gap:6px;flex-shrink:0">
5620
+ <button class="btn btn-ghost pending-edit-btn" style="padding:4px 10px;font-size:11px" data-id="${escapeAttr(s.id)}">Edit</button>
5621
+ <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>
5622
+ <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>
5623
+ </div>
5624
+ </div>`;
5625
+ }).join('');
5626
+ }
5627
+
5628
+ async function loadLibraryView() {
5629
+ const [steps, pending] = await Promise.all([fetchLibrarySteps(), fetchPendingSteps()]);
5630
+ populateLibraryTagFilter();
5631
+ renderPendingSteps(pending);
5632
+ renderLibrarySteps(steps);
5633
+ }
5634
+
5635
+ async function approvePendingUI(id) {
5636
+ await fetch(API_BASE + '/api/step-library/pending/' + encodeURIComponent(id) + '/approve', { method: 'POST' });
5637
+ await loadLibraryView();
5638
+ }
5639
+
5640
+ async function dismissPendingUI(id) {
5641
+ await fetch(API_BASE + '/api/step-library/pending/' + encodeURIComponent(id) + '/dismiss', { method: 'POST' });
5642
+ const pending = await fetchPendingSteps();
5643
+ renderPendingSteps(pending);
5644
+ }
5645
+
5646
+ async function approveAllPendingUI() {
5647
+ const res = await fetch(API_BASE + '/api/step-library/pending/approve-all', { method: 'POST' });
5648
+ const data = await res.json();
5649
+ showToast(`Added ${data.approved} elements to library`);
5650
+ await loadLibraryView();
5651
+ }
5652
+
5653
+ async function dismissAllPendingUI() {
5654
+ showConfirm('Dismiss all?', 'Dismiss all pending elements without adding them to the library?', async () => {
5655
+ await fetch(API_BASE + '/api/step-library/pending/dismiss-all', { method: 'POST' });
5656
+ await loadLibraryView();
5657
+ });
5658
+ }
5659
+
5660
+ // Also check pending count on page load and periodically
5661
+ async function checkPendingCount() {
5662
+ const pending = await fetchPendingSteps();
5663
+ updatePendingBadge(pending.length);
5664
+ }
5665
+
5666
+ // Event delegation for pending buttons
5667
+ document.addEventListener('click', (e) => {
5668
+ const approveBtn = e.target.closest('.pending-approve-btn');
5669
+ const dismissBtn = e.target.closest('.pending-dismiss-btn');
5670
+ const editBtn = e.target.closest('.pending-edit-btn');
5671
+ if (approveBtn) approvePendingUI(approveBtn.dataset.id);
5672
+ if (dismissBtn) dismissPendingUI(dismissBtn.dataset.id);
5673
+ if (editBtn) {
5674
+ // Open edit modal pre-filled with pending step data
5675
+ const step = pendingStepsCache.find(s => s.id === editBtn.dataset.id);
5676
+ if (step) {
5677
+ document.getElementById('lib-step-id').value = 'pending:' + step.id;
5678
+ document.getElementById('lib-step-name').value = step.name || '';
5679
+ document.getElementById('lib-step-selector').value = step.stableSelector || step.selector || '';
5680
+ document.getElementById('lib-step-action').value = step.defaultAction || '';
5681
+ document.getElementById('lib-step-tags').value = (step.tags || []).join(', ');
5682
+ document.getElementById('library-step-modal-title').textContent = 'Edit before adding';
5683
+ document.getElementById('library-step-modal').style.display = 'flex';
5684
+ }
5685
+ }
5686
+ });
5554
5687
 
5555
5688
  async function fetchLibrarySteps() {
5556
5689
  try {
@@ -5861,7 +5994,9 @@ function closeLibraryStepModal() {
5861
5994
  }
5862
5995
 
5863
5996
  async function saveLibraryStep() {
5864
- const id = document.getElementById('lib-step-id').value;
5997
+ const rawId = document.getElementById('lib-step-id').value;
5998
+ const isPending = rawId.startsWith('pending:');
5999
+ const id = isPending ? rawId.slice(8) : rawId;
5865
6000
  const name = document.getElementById('lib-step-name').value.trim();
5866
6001
  const selector = document.getElementById('lib-step-selector').value.trim();
5867
6002
  const defaultAction = document.getElementById('lib-step-action').value;
@@ -5871,14 +6006,23 @@ async function saveLibraryStep() {
5871
6006
  if (!selector) { showToast('Selector is required'); return; }
5872
6007
 
5873
6008
  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());
6009
+ if (isPending) {
6010
+ // Dismiss pending and add directly to library with edits
6011
+ await fetch(API_BASE + '/api/step-library/pending/' + encodeURIComponent(id) + '/dismiss', { method: 'POST' });
6012
+ const body = { name, selector, stableSelector: selector, defaultAction: defaultAction || null, tags };
6013
+ await fetch(API_BASE + '/api/step-library', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
6014
+ } else if (id) {
6015
+ const body = { name, selector, stableSelector: selector, defaultAction: defaultAction || null, tags };
6016
+ const res = await fetch(API_BASE + '/api/step-library/' + encodeURIComponent(id), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
6017
+ if (!res.ok) throw new Error(await res.text());
6018
+ } else {
6019
+ const body = { name, selector, stableSelector: selector, defaultAction: defaultAction || null, tags };
6020
+ const res = await fetch(API_BASE + '/api/step-library', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
6021
+ if (!res.ok) throw new Error(await res.text());
6022
+ }
5879
6023
  closeLibraryStepModal();
5880
6024
  await loadLibraryView();
5881
- showToast(id ? 'Element updated' : 'Element added to library');
6025
+ showToast(isPending ? 'Element added to library' : id ? 'Element updated' : 'Element added to library');
5882
6026
  } catch (err) { showToast('Error: ' + err.message); }
5883
6027
  }
5884
6028
 
@@ -5896,7 +6040,7 @@ async function importStepsFromTests() {
5896
6040
  try {
5897
6041
  const res = await fetch(API_BASE + '/api/step-library/import', { method: 'POST' });
5898
6042
  const data = await res.json();
5899
- showToast(`Imported ${data.imported} new steps (${data.skipped} already existed)`);
6043
+ showToast(data.queued > 0 ? `${data.queued} elements added to review queue` : 'No new elements found');
5900
6044
  await loadLibraryView();
5901
6045
  } catch (err) { showToast('Error: ' + err.message); }
5902
6046
  finally { if (btn) { btn.disabled = false; btn.textContent = 'Import from all tests'; } }