skopix 2.0.22 → 2.0.24

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.
@@ -1007,6 +1007,8 @@ export async function dashboardCommand(options) {
1007
1007
  const testData = { name, type: 'recorded', url: url || '', steps: steps || [], playwrightJs: playwrightJs || '', playwrightTs: playwrightTs || '', tags: tags || [] };
1008
1008
  const result = await createTest(suitesDir, scope || 'saved', testData);
1009
1009
  if (teamMode && currentUser) { teamMode.db.logAudit({ userId: currentUser.id, action: 'test.created', targetType: 'test', targetId: result.id, metadata: { scope: scope || 'saved', type: 'recorded' } }); }
1010
+ // Auto-extract steps to library in background (non-blocking)
1011
+ extractStepsToLibrary(suitesDir, steps || [], name).catch(() => {});
1010
1012
  sendJSON(res, 200, result);
1011
1013
  } catch (err) { sendJSON(res, 400, { error: err.message }); }
1012
1014
  return;
@@ -1792,6 +1794,43 @@ export async function dashboardCommand(options) {
1792
1794
  return;
1793
1795
  }
1794
1796
 
1797
+ // ─── STEP LIBRARY ──────────────────────────────────────────────────
1798
+ if (pathname === '/api/step-library' && method === 'GET') {
1799
+ sendJSON(res, 200, await listLibrarySteps(suitesDir));
1800
+ return;
1801
+ }
1802
+ if (pathname === '/api/step-library' && method === 'POST') {
1803
+ const data = await readBody(req);
1804
+ const step = await addLibraryStep(suitesDir, data);
1805
+ sendJSON(res, 200, step);
1806
+ return;
1807
+ }
1808
+ if (pathname.match(/^\/api\/step-library\/[^/]+$/) && method === 'PUT') {
1809
+ const id = decodeURIComponent(pathname.split('/')[3]);
1810
+ const data = await readBody(req);
1811
+ const step = await updateLibraryStep(suitesDir, id, data);
1812
+ if (!step) sendJSON(res, 404, { error: 'Not found' });
1813
+ else sendJSON(res, 200, step);
1814
+ return;
1815
+ }
1816
+ if (pathname.match(/^\/api\/step-library\/[^/]+$/) && method === 'DELETE') {
1817
+ const id = decodeURIComponent(pathname.split('/')[3]);
1818
+ await deleteLibraryStep(suitesDir, id);
1819
+ sendJSON(res, 200, { deleted: true });
1820
+ return;
1821
+ }
1822
+ if (pathname === '/api/step-library/import' && method === 'POST') {
1823
+ const data = await readBody(req);
1824
+ const result = await importStepsFromTests(suitesDir);
1825
+ sendJSON(res, 200, result);
1826
+ return;
1827
+ }
1828
+ if (pathname === '/api/step-library/sync' && method === 'POST') {
1829
+ const result = await syncTestsToLibrary(suitesDir);
1830
+ sendJSON(res, 200, result);
1831
+ return;
1832
+ }
1833
+
1795
1834
  // ─── SUITE RUNS ────────────────────────────────────────────────────
1796
1835
  if (pathname === '/api/suite-runs' && method === 'GET') {
1797
1836
  sendJSON(res, 200, await listSuiteRuns(suiteRunsDir, reportsDir));
@@ -3583,3 +3622,167 @@ async function syncIssuesStatus() {
3583
3622
  await saveIssueStore(store);
3584
3623
  return { updated, failed, total: store.issues.length };
3585
3624
  }
3625
+
3626
+ // ═══════════════════════════════════════════════════════════════
3627
+ // STEP LIBRARY — persistent store of reusable UI interactions
3628
+ // ═══════════════════════════════════════════════════════════════
3629
+
3630
+ const LIBRARY_FILE = () => path.join(os.homedir(), '.skopix', 'step-library.yaml');
3631
+
3632
+ async function listLibrarySteps(suitesDir) {
3633
+ const file = LIBRARY_FILE();
3634
+ if (!await fs.pathExists(file)) return [];
3635
+ try {
3636
+ const content = await fs.readFile(file, 'utf8');
3637
+ const data = yaml.parse(content);
3638
+ return Array.isArray(data) ? data : [];
3639
+ } catch { return []; }
3640
+ }
3641
+
3642
+ async function saveLibrarySteps(steps) {
3643
+ const file = LIBRARY_FILE();
3644
+ await fs.ensureDir(path.dirname(file));
3645
+ await fs.writeFile(file, yaml.stringify(steps));
3646
+ }
3647
+
3648
+ async function addLibraryStep(suitesDir, stepData) {
3649
+ const steps = await listLibrarySteps(suitesDir);
3650
+ const id = 'lib-' + Math.random().toString(36).slice(2, 10);
3651
+ const step = {
3652
+ id,
3653
+ name: stepData.name || stepData.description || 'Unnamed step',
3654
+ action: stepData.action,
3655
+ selector: stepData.stableSelector || stepData.selector || null,
3656
+ stableSelector: stepData.stableSelector || stepData.selector || null,
3657
+ value: stepData.value || null,
3658
+ assertType: stepData.assertType || null,
3659
+ attribute: stepData.attribute || null,
3660
+ description: stepData.description || null,
3661
+ tags: stepData.tags || [],
3662
+ usageCount: 0,
3663
+ createdAt: new Date().toISOString(),
3664
+ };
3665
+ steps.push(step);
3666
+ await saveLibrarySteps(steps);
3667
+ return step;
3668
+ }
3669
+
3670
+ async function updateLibraryStep(suitesDir, id, data) {
3671
+ const steps = await listLibrarySteps(suitesDir);
3672
+ const idx = steps.findIndex(s => s.id === id);
3673
+ if (idx === -1) return null;
3674
+ steps[idx] = { ...steps[idx], ...data, id };
3675
+ await saveLibrarySteps(steps);
3676
+ return steps[idx];
3677
+ }
3678
+
3679
+ async function deleteLibraryStep(suitesDir, id) {
3680
+ const steps = await listLibrarySteps(suitesDir);
3681
+ const filtered = steps.filter(s => s.id !== id);
3682
+ await saveLibrarySteps(filtered);
3683
+ }
3684
+
3685
+ // Similarity check — returns 0-1 score
3686
+ function stepSimilarity(a, b) {
3687
+ if (a.action !== b.action) return 0;
3688
+ const selA = (a.stableSelector || a.selector || '').toLowerCase();
3689
+ const selB = (b.stableSelector || b.selector || '').toLowerCase();
3690
+ if (selA && selB) {
3691
+ if (selA === selB) return 1;
3692
+ // Check if one contains the other
3693
+ if (selA.includes(selB) || selB.includes(selA)) return 0.85;
3694
+ // Check shared tokens
3695
+ const tokA = selA.split(/[\s>+~.,#\[\]()=]/).filter(Boolean);
3696
+ const tokB = selB.split(/[\s>+~.,#\[\]()=]/).filter(Boolean);
3697
+ const shared = tokA.filter(t => tokB.includes(t) && t.length > 2);
3698
+ if (shared.length > 0) return Math.min(0.8, shared.length / Math.max(tokA.length, tokB.length));
3699
+ }
3700
+ const descA = (a.description || '').toLowerCase();
3701
+ const descB = (b.description || '').toLowerCase();
3702
+ if (descA && descB && descA === descB) return 0.9;
3703
+ return 0;
3704
+ }
3705
+
3706
+ // Extract unique steps from a test and add to library (skip duplicates)
3707
+ async function extractStepsToLibrary(suitesDir, steps, testName) {
3708
+ if (!steps || steps.length === 0) return;
3709
+ const existing = await listLibrarySteps(suitesDir);
3710
+ const toAdd = [];
3711
+
3712
+ for (const step of steps) {
3713
+ if (!step.action || step.action === 'navigate') continue;
3714
+ // Skip steps with no selector (e.g. scroll)
3715
+ const sel = step.stableSelector || step.selector;
3716
+ if (!sel && step.action !== 'type') continue;
3717
+ // Check if similar step already exists
3718
+ const similar = existing.find(e => stepSimilarity(e, step) >= 0.9);
3719
+ if (similar) {
3720
+ // Increment usage count
3721
+ similar.usageCount = (similar.usageCount || 0) + 1;
3722
+ continue;
3723
+ }
3724
+ // New unique step
3725
+ toAdd.push({
3726
+ id: 'lib-' + Math.random().toString(36).slice(2, 10),
3727
+ name: step.description || (step.action + ' ' + (sel || '').slice(0, 40)),
3728
+ action: step.action,
3729
+ selector: sel || null,
3730
+ stableSelector: step.stableSelector || sel || null,
3731
+ value: step.value || null,
3732
+ assertType: step.assertType || null,
3733
+ attribute: step.attribute || null,
3734
+ description: step.description || null,
3735
+ tags: [],
3736
+ usageCount: 1,
3737
+ sourceTest: testName,
3738
+ createdAt: new Date().toISOString(),
3739
+ });
3740
+ }
3741
+
3742
+ if (toAdd.length > 0 || existing.some(e => e.usageCount !== undefined)) {
3743
+ await saveLibrarySteps([...existing, ...toAdd]);
3744
+ }
3745
+ }
3746
+
3747
+ // Import steps from all existing tests into library
3748
+ async function importStepsFromTests(suitesDir) {
3749
+ const allTests = await listAllTests(suitesDir);
3750
+ let imported = 0;
3751
+ let skipped = 0;
3752
+ for (const test of allTests) {
3753
+ if (!test.steps || test.steps.length === 0) continue;
3754
+ const before = (await listLibrarySteps(suitesDir)).length;
3755
+ await extractStepsToLibrary(suitesDir, test.steps, test.name);
3756
+ const after = (await listLibrarySteps(suitesDir)).length;
3757
+ imported += after - before;
3758
+ skipped += test.steps.length - (after - before);
3759
+ }
3760
+ return { imported, skipped, total: imported + skipped };
3761
+ }
3762
+
3763
+ // Sync existing tests — replace matching steps with library steps
3764
+ async function syncTestsToLibrary(suitesDir) {
3765
+ const allTests = await listAllTests(suitesDir);
3766
+ const library = await listLibrarySteps(suitesDir);
3767
+ let synced = 0;
3768
+ let updated = 0;
3769
+
3770
+ for (const test of allTests) {
3771
+ if (!test.steps || test.steps.length === 0) continue;
3772
+ let testUpdated = false;
3773
+ const newSteps = test.steps.map(step => {
3774
+ const match = library.find(l => stepSimilarity(l, step) >= 0.95);
3775
+ if (match && match.stableSelector && match.stableSelector !== step.stableSelector) {
3776
+ testUpdated = true;
3777
+ return { ...step, stableSelector: match.stableSelector, selector: match.selector || step.selector, libraryId: match.id };
3778
+ }
3779
+ return step;
3780
+ });
3781
+ if (testUpdated) {
3782
+ await updateTest(suitesDir, test.scope, test.id, { ...test, steps: newSteps });
3783
+ updated++;
3784
+ }
3785
+ synced++;
3786
+ }
3787
+ return { synced, updated };
3788
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skopix",
3
- "version": "2.0.22",
3
+ "version": "2.0.24",
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": {
@@ -1318,6 +1318,10 @@ body.viewer-mode .saved-test-row { cursor: default !important; }
1318
1318
  <span class="nav-icon"><svg viewBox="0 0 24 24"><polyline points="3 12 8 7 13 12 18 7 21 10"/><path d="M3 17l4-4 4 4 4-4 4 4"/></svg></span>
1319
1319
  Suite runs
1320
1320
  </a>
1321
+ <a class="nav-item" data-view="step-library">
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
+ Step library
1324
+ </a>
1321
1325
 
1322
1326
 
1323
1327
  <!-- Team section - shown only to admins in team mode -->
@@ -1554,6 +1558,94 @@ body.viewer-mode .saved-test-row { cursor: default !important; }
1554
1558
  </div>
1555
1559
  </div>
1556
1560
 
1561
+ <!-- STEP LIBRARY -->
1562
+ <div class="view" id="view-step-library">
1563
+ <div class="topbar">
1564
+ <div>
1565
+ <h1>Step library</h1>
1566
+ <div class="topbar-sub">Reusable UI interactions — built up from your recorded tests</div>
1567
+ </div>
1568
+ <div style="display:flex;gap:8px">
1569
+ <button class="btn btn-ghost" onclick="importStepsFromTests()" id="btn-import-steps">
1570
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
1571
+ Import from all tests
1572
+ </button>
1573
+ <button class="btn btn-ghost" onclick="syncTestsToLibrary()" id="btn-sync-tests">
1574
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg>
1575
+ Sync tests to library
1576
+ </button>
1577
+ <button class="btn btn-primary" onclick="openAddLibraryStep()">
1578
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
1579
+ Add step
1580
+ </button>
1581
+ </div>
1582
+ </div>
1583
+
1584
+ <!-- Search + filter -->
1585
+ <div style="display:flex;gap:10px;margin-bottom:16px">
1586
+ <input class="form-input" id="library-search" type="text" placeholder="Search steps..." oninput="filterLibrarySteps()" style="flex:1">
1587
+ <select class="form-select" id="library-filter-action" onchange="filterLibrarySteps()" style="width:140px">
1588
+ <option value="">All actions</option>
1589
+ <option value="click">click</option>
1590
+ <option value="type">type</option>
1591
+ <option value="check">check</option>
1592
+ <option value="assert">assert</option>
1593
+ <option value="select">select</option>
1594
+ <option value="scroll">scroll</option>
1595
+ </select>
1596
+ </div>
1597
+
1598
+ <div class="card">
1599
+ <div class="card-body" style="padding:0">
1600
+ <div id="library-steps-container"><!-- populated --></div>
1601
+ </div>
1602
+ </div>
1603
+ </div>
1604
+
1605
+ <!-- ADD/EDIT LIBRARY STEP MODAL -->
1606
+ <div class="modal-overlay" id="library-step-modal" style="display:none" onclick="if(event.target===this)closeLibraryStepModal()">
1607
+ <div class="modal" style="max-width:560px;width:100%">
1608
+ <div class="modal-header">
1609
+ <div class="modal-title" id="library-step-modal-title">Add step to library</div>
1610
+ <button class="modal-close" onclick="closeLibraryStepModal()">✕</button>
1611
+ </div>
1612
+ <div class="modal-body" style="display:flex;flex-direction:column;gap:14px">
1613
+ <input type="hidden" id="lib-step-id">
1614
+ <div class="form-field">
1615
+ <label class="form-label">Name</label>
1616
+ <input class="form-input" id="lib-step-name" type="text" placeholder="e.g. Click chart settings icon">
1617
+ </div>
1618
+ <div class="form-field">
1619
+ <label class="form-label">Action</label>
1620
+ <select class="form-select" id="lib-step-action">
1621
+ <option value="click">click</option>
1622
+ <option value="type">type</option>
1623
+ <option value="check">check</option>
1624
+ <option value="assert">assert</option>
1625
+ <option value="select">select</option>
1626
+ <option value="scroll">scroll</option>
1627
+ </select>
1628
+ </div>
1629
+ <div class="form-field">
1630
+ <label class="form-label">Selector</label>
1631
+ <input class="form-input" id="lib-step-selector" type="text" placeholder="CSS selector" style="font-family:var(--mono)">
1632
+ </div>
1633
+ <div class="form-field" id="lib-step-value-row">
1634
+ <label class="form-label">Value (for type/assert)</label>
1635
+ <input class="form-input" id="lib-step-value" type="text" placeholder="Value to type or assert">
1636
+ </div>
1637
+ <div class="form-field">
1638
+ <label class="form-label">Tags (comma separated)</label>
1639
+ <input class="form-input" id="lib-step-tags" type="text" placeholder="login, chart, navigation">
1640
+ </div>
1641
+ </div>
1642
+ <div class="modal-footer">
1643
+ <button class="btn btn-ghost" onclick="closeLibraryStepModal()">Cancel</button>
1644
+ <button class="btn btn-primary" onclick="saveLibraryStep()">Save</button>
1645
+ </div>
1646
+ </div>
1647
+ </div>
1648
+
1557
1649
  <!-- SUITES LIST -->
1558
1650
  <div class="view" id="view-suites">
1559
1651
  <div class="topbar">
@@ -4570,6 +4662,9 @@ switchView = function(name) {
4570
4662
  clearInterval(agentsPollInterval);
4571
4663
  agentsPollInterval = null;
4572
4664
  }
4665
+ if (name === 'step-library') {
4666
+ loadLibraryView();
4667
+ }
4573
4668
  if (name === 'users') {
4574
4669
  fetchUsersAndInvites();
4575
4670
  }
@@ -5294,6 +5389,290 @@ async function saveOllamaConfig() {
5294
5389
  }
5295
5390
  }
5296
5391
 
5392
+ // ── STEP LIBRARY ─────────────────────────────────────────────────────────────
5393
+ let libraryStepsCache = [];
5394
+
5395
+ async function fetchLibrarySteps() {
5396
+ try {
5397
+ const res = await fetch(API_BASE + '/api/step-library');
5398
+ if (!res.ok) return [];
5399
+ libraryStepsCache = await res.json();
5400
+ return libraryStepsCache;
5401
+ } catch { return []; }
5402
+ }
5403
+
5404
+ // Detect fragile/dynamic selectors
5405
+ function isSelectorFragile(sel) {
5406
+ if (!sel) return false;
5407
+ if (/nth-of-type|nth-child/.test(sel)) return true; // positional
5408
+ if (/#[a-z]+-\d+/.test(sel)) return true; // dynamic IDs like #cell-446
5409
+ if (/\[\w+=["'][^"']*\d{3,}[^"']*["']\]/.test(sel)) return true; // long numeric attr values
5410
+ return false;
5411
+ }
5412
+
5413
+ function getSelectorWarning(sel) {
5414
+ if (!sel) return null;
5415
+ if (/nth-of-type|nth-child/.test(sel)) return 'Positional selector — may break if page layout changes';
5416
+ if (/#[a-z]+-\d+/.test(sel)) return 'Dynamic ID — this number changes between runs';
5417
+ if (/\[\w+=["'][^"']*\d{3,}[^"']*["']\]/.test(sel)) return 'Contains a dynamic numeric value';
5418
+ return null;
5419
+ }
5420
+
5421
+ function renderLibrarySteps(steps) {
5422
+ const container = document.getElementById('library-steps-container');
5423
+ if (!container) return;
5424
+ if (!steps || steps.length === 0) {
5425
+ container.innerHTML = `<div class="empty" style="padding:60px 32px">
5426
+ <div class="empty-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><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></div>
5427
+ <div class="empty-title">No steps yet</div>
5428
+ <div class="empty-desc">Steps are added automatically when you save recorded tests, or click "Import from all tests" to populate from your existing tests.</div>
5429
+ <button class="btn btn-primary" onclick="importStepsFromTests()">Import from all tests</button>
5430
+ </div>`;
5431
+ return;
5432
+ }
5433
+
5434
+ const actionColors = { click:'#22d3ee', type:'#a78bfa', check:'#34d399', assert:'#fb923c', select:'#f472b6', scroll:'#6b7280' };
5435
+ const fragileCount = steps.filter(s => isSelectorFragile(s.stableSelector || s.selector)).length;
5436
+
5437
+ container.innerHTML = `
5438
+ <div style="display:flex;align-items:center;justify-content:space-between;padding:12px 20px;border-bottom:1px solid var(--border);background:var(--surface2)">
5439
+ <div style="display:flex;align-items:center;gap:12px">
5440
+ <label style="display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:12px;color:var(--muted);cursor:pointer">
5441
+ <input type="checkbox" id="lib-select-all" onchange="toggleSelectAllSteps(this.checked)" style="width:14px;height:14px">
5442
+ Select all
5443
+ </label>
5444
+ <button class="btn btn-ghost" id="btn-merge-steps" style="display:none;padding:4px 10px;font-size:11px" onclick="openMergeSteps()">
5445
+ Merge selected
5446
+ </button>
5447
+ <button class="btn btn-ghost" id="btn-delete-selected" style="display:none;padding:4px 10px;font-size:11px;color:var(--red)" onclick="deleteSelectedSteps()">
5448
+ Delete selected
5449
+ </button>
5450
+ </div>
5451
+ ${fragileCount > 0 ? `<div style="font-family:var(--mono);font-size:11px;color:#f59e0b">⚠ ${fragileCount} step${fragileCount>1?'s':''} with fragile selectors</div>` : '<div style="font-family:var(--mono);font-size:11px;color:#34d399">✓ All selectors look stable</div>'}
5452
+ </div>
5453
+ <table style="width:100%;border-collapse:collapse">
5454
+ <thead><tr style="font-family:var(--mono);font-size:10px;color:var(--muted);letter-spacing:0.1em">
5455
+ <th style="padding:10px 16px;width:32px;border-bottom:1px solid var(--border)"></th>
5456
+ <th style="padding:10px 20px;text-align:left;border-bottom:1px solid var(--border)">NAME</th>
5457
+ <th style="padding:10px 16px;text-align:left;border-bottom:1px solid var(--border)">ACTION</th>
5458
+ <th style="padding:10px 16px;text-align:left;border-bottom:1px solid var(--border)">SELECTOR</th>
5459
+ <th style="padding:10px 16px;text-align:left;border-bottom:1px solid var(--border)">USES</th>
5460
+ <th style="padding:10px 16px;text-align:right;border-bottom:1px solid var(--border)"></th>
5461
+ </tr></thead>
5462
+ <tbody>
5463
+ ${steps.map(s => {
5464
+ const sel = s.stableSelector || s.selector || '';
5465
+ const fragile = isSelectorFragile(sel);
5466
+ const warning = getSelectorWarning(sel);
5467
+ return `
5468
+ <tr data-step-id="${escapeAttr(s.id)}" style="border-bottom:1px solid var(--border);transition:background 0.15s${fragile?';background:rgba(245,158,11,0.04)':''}" onmouseover="this.style.background='var(--surface2)'" onmouseout="this.style.background='${fragile?'rgba(245,158,11,0.04)':''}'">
5469
+ <td style="padding:12px 16px">
5470
+ <input type="checkbox" class="lib-step-checkbox" data-id="${escapeAttr(s.id)}" onchange="onLibStepCheck()" style="width:14px;height:14px">
5471
+ </td>
5472
+ <td style="padding:12px 20px">
5473
+ <div style="font-size:13px;color:var(--text)">${escapeHtml(s.name || s.description || '')}</div>
5474
+ ${s.tags && s.tags.length ? `<div style="margin-top:4px;display:flex;gap:4px">${s.tags.map(t=>`<span style="font-family:var(--mono);font-size:10px;padding:2px 6px;background:var(--surface2);border-radius:4px;color:var(--muted)">${escapeHtml(t)}</span>`).join('')}</div>` : ''}
5475
+ </td>
5476
+ <td style="padding:12px 16px"><span style="font-family:var(--mono);font-size:11px;color:${actionColors[s.action]||'var(--muted)'}">${s.action||''}</span></td>
5477
+ <td style="padding:12px 16px;max-width:280px">
5478
+ <div style="display:flex;align-items:center;gap:6px">
5479
+ ${fragile ? `<span title="${escapeAttr(warning||'')}" style="color:#f59e0b;font-size:14px;cursor:help;flex-shrink:0">⚠</span>` : ''}
5480
+ <code style="font-size:11px;color:${fragile?'#f59e0b':'var(--muted)'};white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block">${escapeHtml(sel || '—')}</code>
5481
+ </div>
5482
+ </td>
5483
+ <td style="padding:12px 16px;font-family:var(--mono);font-size:12px;color:var(--muted)">${s.usageCount || 0}</td>
5484
+ <td style="padding:12px 16px;text-align:right">
5485
+ <button class="btn btn-ghost" style="padding:4px 10px;font-size:11px;margin-right:4px" onclick="editLibraryStep('${escapeAttr(s.id)}')">Edit</button>
5486
+ <button class="btn btn-ghost" style="padding:4px 10px;font-size:11px;color:var(--red)" onclick="deleteLibraryStepUI('${escapeAttr(s.id)}','${escapeAttr(s.name||'')}')">Delete</button>
5487
+ </td>
5488
+ </tr>`;
5489
+ }).join('')}
5490
+ </tbody>
5491
+ </table>`;
5492
+ }
5493
+
5494
+ function onLibStepCheck() {
5495
+ const checked = document.querySelectorAll('.lib-step-checkbox:checked');
5496
+ const mergeBtn = document.getElementById('btn-merge-steps');
5497
+ const deleteBtn = document.getElementById('btn-delete-selected');
5498
+ if (mergeBtn) mergeBtn.style.display = checked.length >= 2 ? '' : 'none';
5499
+ if (deleteBtn) deleteBtn.style.display = checked.length >= 1 ? '' : 'none';
5500
+ }
5501
+
5502
+ function toggleSelectAllSteps(checked) {
5503
+ document.querySelectorAll('.lib-step-checkbox').forEach(cb => cb.checked = checked);
5504
+ onLibStepCheck();
5505
+ }
5506
+
5507
+ function getSelectedStepIds() {
5508
+ return Array.from(document.querySelectorAll('.lib-step-checkbox:checked')).map(cb => cb.dataset.id);
5509
+ }
5510
+
5511
+ async function deleteSelectedSteps() {
5512
+ const ids = getSelectedStepIds();
5513
+ if (!ids.length) return;
5514
+ showConfirm('Delete steps?', `Delete ${ids.length} selected step${ids.length>1?'s':''}?`, async () => {
5515
+ for (const id of ids) {
5516
+ await fetch(API_BASE + '/api/step-library/' + encodeURIComponent(id), { method: 'DELETE' });
5517
+ }
5518
+ showToast(`Deleted ${ids.length} steps`);
5519
+ await loadLibraryView();
5520
+ });
5521
+ }
5522
+
5523
+ function openMergeSteps() {
5524
+ const ids = getSelectedStepIds();
5525
+ if (ids.length < 2) return;
5526
+ const selected = ids.map(id => libraryStepsCache.find(s => s.id === id)).filter(Boolean);
5527
+
5528
+ // Use the confirm overlay but inject HTML into desc
5529
+ const descEl = document.getElementById('confirm-desc');
5530
+ if (descEl) {
5531
+ descEl.innerHTML = `
5532
+ <div style="font-family:var(--mono);font-size:12px;color:var(--muted);margin-bottom:16px">
5533
+ Select which step to keep. The others will be deleted.
5534
+ </div>
5535
+ <div style="display:flex;flex-direction:column;gap:8px;text-align:left">
5536
+ ${selected.map((s, i) => `
5537
+ <label style="display:flex;align-items:flex-start;gap:10px;padding:10px 14px;border-radius:8px;border:1px solid var(--border);cursor:pointer">
5538
+ <input type="radio" name="merge-canonical" value="${escapeAttr(s.id)}" ${i===0?'checked':''} style="margin-top:3px;flex-shrink:0">
5539
+ <div>
5540
+ <div style="font-size:13px;color:var(--text);margin-bottom:4px">${escapeHtml(s.name||'')}</div>
5541
+ <code style="font-size:11px;color:var(--muted);word-break:break-all">${escapeHtml(s.stableSelector||s.selector||'')}</code>
5542
+ <span style="font-family:var(--mono);font-size:10px;color:var(--muted);margin-left:8px">${s.usageCount||0} uses</span>
5543
+ </div>
5544
+ </label>`).join('')}
5545
+ </div>`;
5546
+ }
5547
+ document.getElementById('confirm-title').textContent = `Merge ${selected.length} steps`;
5548
+
5549
+ // Override the confirm button
5550
+ const actions = document.querySelector('.confirm-actions');
5551
+ if (actions) {
5552
+ actions.innerHTML = `
5553
+ <button class="btn btn-ghost" onclick="closeConfirm()">Cancel</button>
5554
+ <button class="btn btn-primary" onclick="confirmMergeSteps(${JSON.stringify(ids)})">Merge</button>`;
5555
+ }
5556
+ document.getElementById('confirm-overlay').classList.add('open');
5557
+ }
5558
+
5559
+ async function confirmMergeSteps(ids) {
5560
+ const selected = ids.map(id => libraryStepsCache.find(s => s.id === id)).filter(Boolean);
5561
+ const canonicalId = document.querySelector('input[name="merge-canonical"]:checked')?.value;
5562
+ if (!canonicalId) return;
5563
+ const canonical = selected.find(s => s.id === canonicalId);
5564
+ const toDelete = selected.filter(s => s.id !== canonicalId);
5565
+ const totalUses = selected.reduce((sum, s) => sum + (s.usageCount||0), 0);
5566
+ closeConfirm();
5567
+ await fetch(API_BASE + '/api/step-library/' + encodeURIComponent(canonicalId), {
5568
+ method: 'PUT',
5569
+ headers: { 'Content-Type': 'application/json' },
5570
+ body: JSON.stringify({ ...canonical, usageCount: totalUses })
5571
+ });
5572
+ for (const s of toDelete) {
5573
+ await fetch(API_BASE + '/api/step-library/' + encodeURIComponent(s.id), { method: 'DELETE' });
5574
+ }
5575
+ showToast(`Merged ${selected.length} steps into "${canonical.name}"`);
5576
+ await loadLibraryView();
5577
+ }
5578
+
5579
+ function filterLibrarySteps() {
5580
+ const q = (document.getElementById('library-search')?.value || '').toLowerCase();
5581
+ const action = document.getElementById('library-filter-action')?.value || '';
5582
+ const filtered = libraryStepsCache.filter(s => {
5583
+ const matchQ = !q || (s.name||'').toLowerCase().includes(q) || (s.selector||'').toLowerCase().includes(q) || (s.description||'').toLowerCase().includes(q);
5584
+ const matchA = !action || s.action === action;
5585
+ return matchQ && matchA;
5586
+ });
5587
+ renderLibrarySteps(filtered);
5588
+ }
5589
+
5590
+ async function loadLibraryView() {
5591
+ const steps = await fetchLibrarySteps();
5592
+ renderLibrarySteps(steps);
5593
+ }
5594
+
5595
+ function openAddLibraryStep() {
5596
+ document.getElementById('lib-step-id').value = '';
5597
+ document.getElementById('lib-step-name').value = '';
5598
+ document.getElementById('lib-step-action').value = 'click';
5599
+ document.getElementById('lib-step-selector').value = '';
5600
+ document.getElementById('lib-step-value').value = '';
5601
+ document.getElementById('lib-step-tags').value = '';
5602
+ document.getElementById('library-step-modal-title').textContent = 'Add step to library';
5603
+ document.getElementById('library-step-modal').style.display = 'flex';
5604
+ }
5605
+
5606
+ function editLibraryStep(id) {
5607
+ const step = libraryStepsCache.find(s => s.id === id);
5608
+ if (!step) return;
5609
+ document.getElementById('lib-step-id').value = step.id;
5610
+ document.getElementById('lib-step-name').value = step.name || '';
5611
+ document.getElementById('lib-step-action').value = step.action || 'click';
5612
+ document.getElementById('lib-step-selector').value = step.stableSelector || step.selector || '';
5613
+ document.getElementById('lib-step-value').value = step.value || '';
5614
+ document.getElementById('lib-step-tags').value = (step.tags || []).join(', ');
5615
+ document.getElementById('library-step-modal-title').textContent = 'Edit step';
5616
+ document.getElementById('library-step-modal').style.display = 'flex';
5617
+ }
5618
+
5619
+ function closeLibraryStepModal() {
5620
+ document.getElementById('library-step-modal').style.display = 'none';
5621
+ }
5622
+
5623
+ async function saveLibraryStep() {
5624
+ const id = document.getElementById('lib-step-id').value;
5625
+ const name = document.getElementById('lib-step-name').value.trim();
5626
+ const action = document.getElementById('lib-step-action').value;
5627
+ const selector = document.getElementById('lib-step-selector').value.trim();
5628
+ const value = document.getElementById('lib-step-value').value.trim();
5629
+ const tags = (document.getElementById('lib-step-tags').value || '').split(',').map(t=>t.trim()).filter(Boolean);
5630
+
5631
+ if (!name) { showToast('Name is required'); return; }
5632
+
5633
+ try {
5634
+ const body = { name, action, selector, stableSelector: selector, value: value || null, tags };
5635
+ const url = id ? `${API_BASE}/api/step-library/${encodeURIComponent(id)}` : `${API_BASE}/api/step-library`;
5636
+ const method = id ? 'PUT' : 'POST';
5637
+ const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
5638
+ if (!res.ok) throw new Error(await res.text());
5639
+ closeLibraryStepModal();
5640
+ await loadLibraryView();
5641
+ showToast(id ? 'Step updated' : 'Step added to library');
5642
+ } catch (err) { showToast('Error: ' + err.message); }
5643
+ }
5644
+
5645
+ async function deleteLibraryStepUI(id, name) {
5646
+ showConfirm('Delete step?', `Remove "${name}" from the library?`, async () => {
5647
+ await fetch(API_BASE + '/api/step-library/' + encodeURIComponent(id), { method: 'DELETE' });
5648
+ await loadLibraryView();
5649
+ showToast('Step deleted');
5650
+ });
5651
+ }
5652
+
5653
+ async function importStepsFromTests() {
5654
+ const btn = document.getElementById('btn-import-steps');
5655
+ if (btn) { btn.disabled = true; btn.textContent = 'Importing...'; }
5656
+ try {
5657
+ const res = await fetch(API_BASE + '/api/step-library/import', { method: 'POST' });
5658
+ const data = await res.json();
5659
+ showToast(`Imported ${data.imported} new steps (${data.skipped} already existed)`);
5660
+ await loadLibraryView();
5661
+ } catch (err) { showToast('Error: ' + err.message); }
5662
+ finally { if (btn) { btn.disabled = false; btn.textContent = 'Import from all tests'; } }
5663
+ }
5664
+
5665
+ async function syncTestsToLibrary() {
5666
+ const btn = document.getElementById('btn-sync-tests');
5667
+ if (btn) { btn.disabled = true; btn.textContent = 'Syncing...'; }
5668
+ try {
5669
+ const res = await fetch(API_BASE + '/api/step-library/sync', { method: 'POST' });
5670
+ const data = await res.json();
5671
+ showToast(`Synced ${data.synced} tests — ${data.updated} updated with library selectors`);
5672
+ } catch (err) { showToast('Error: ' + err.message); }
5673
+ finally { if (btn) { btn.disabled = false; btn.textContent = 'Sync tests to library'; } }
5674
+ }
5675
+
5297
5676
  // ── SUITE TEST PICKER ────────────────────────────────────────────────────────
5298
5677
  function openSuiteTestPicker() {
5299
5678
  const list = document.getElementById('stp-test-list');