skopix 2.0.21 → 2.0.23

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.
@@ -254,6 +254,14 @@ export async function agentCommand(options) {
254
254
  deviceScaleFactor: 1,
255
255
  ...(wantReport ? { recordVideo: { dir: sessionDir, size: { width: 1280, height: 800 } } } : {}),
256
256
  });
257
+ // Set zoom via initScript so it persists across page loads and route changes
258
+ if (browserZoom !== 1) {
259
+ await ctx.addInitScript((zoom) => {
260
+ const applyZ = () => { if (document.documentElement) document.documentElement.style.zoom = zoom; };
261
+ applyZ();
262
+ document.addEventListener('DOMContentLoaded', applyZ);
263
+ }, String(browserZoom));
264
+ }
257
265
  const page = await ctx.newPage();
258
266
  const applyZoom = async () => {
259
267
  if (browserZoom !== 1) {
@@ -272,6 +280,9 @@ export async function agentCommand(options) {
272
280
  // Apply zoom now if no setup test (otherwise applied at setup→main transition)
273
281
  if (!setupTest) await applyZoom();
274
282
 
283
+ // Listen for future navigations and re-apply zoom
284
+ page.on('load', () => { applyZoom().catch(() => {}); });
285
+
275
286
  send({ type: 'stdout', text: '◆ Replaying ' + allSteps.length + ' steps on ' + os.hostname() });
276
287
 
277
288
  for (const step of allSteps) {
@@ -358,6 +369,7 @@ export async function agentCommand(options) {
358
369
  try { const ro = new URL(navUrl).origin; const to = new URL(test.url).origin; if (ro !== to) navUrl = navUrl.replace(ro, to); } catch {}
359
370
  }
360
371
  await page.goto(navUrl, { waitUntil: 'domcontentloaded', timeout: 15000 });
372
+ await applyZoom();
361
373
  await page.waitForTimeout(800);
362
374
 
363
375
  } else if (step.action === 'click') {
@@ -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.21",
3
+ "version": "2.0.23",
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,157 @@ 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
+ function renderLibrarySteps(steps) {
5405
+ const container = document.getElementById('library-steps-container');
5406
+ if (!container) return;
5407
+ if (!steps || steps.length === 0) {
5408
+ container.innerHTML = `<div class="empty" style="padding:60px 32px">
5409
+ <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>
5410
+ <div class="empty-title">No steps yet</div>
5411
+ <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>
5412
+ <button class="btn btn-primary" onclick="importStepsFromTests()">Import from all tests</button>
5413
+ </div>`;
5414
+ return;
5415
+ }
5416
+
5417
+ const actionColors = { click:'#22d3ee', type:'#a78bfa', check:'#34d399', assert:'#fb923c', select:'#f472b6', scroll:'#6b7280' };
5418
+ container.innerHTML = `
5419
+ <table style="width:100%;border-collapse:collapse">
5420
+ <thead><tr style="font-family:var(--mono);font-size:10px;color:var(--muted);letter-spacing:0.1em">
5421
+ <th style="padding:10px 20px;text-align:left;border-bottom:1px solid var(--border)">NAME</th>
5422
+ <th style="padding:10px 16px;text-align:left;border-bottom:1px solid var(--border)">ACTION</th>
5423
+ <th style="padding:10px 16px;text-align:left;border-bottom:1px solid var(--border)">SELECTOR</th>
5424
+ <th style="padding:10px 16px;text-align:left;border-bottom:1px solid var(--border)">USES</th>
5425
+ <th style="padding:10px 16px;text-align:right;border-bottom:1px solid var(--border)"></th>
5426
+ </tr></thead>
5427
+ <tbody>
5428
+ ${steps.map(s => `
5429
+ <tr style="border-bottom:1px solid var(--border);transition:background 0.15s" onmouseover="this.style.background='var(--surface2)'" onmouseout="this.style.background=''">
5430
+ <td style="padding:12px 20px">
5431
+ <div style="font-size:13px;color:var(--text)">${escapeHtml(s.name || s.description || '')}</div>
5432
+ ${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>` : ''}
5433
+ </td>
5434
+ <td style="padding:12px 16px"><span style="font-family:var(--mono);font-size:11px;color:${actionColors[s.action]||'var(--muted)'}">${s.action||''}</span></td>
5435
+ <td style="padding:12px 16px;max-width:280px"><code style="font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block">${escapeHtml(s.stableSelector || s.selector || '—')}</code></td>
5436
+ <td style="padding:12px 16px;font-family:var(--mono);font-size:12px;color:var(--muted)">${s.usageCount || 0}</td>
5437
+ <td style="padding:12px 16px;text-align:right">
5438
+ <button class="btn btn-ghost" style="padding:4px 10px;font-size:11px;margin-right:4px" onclick="editLibraryStep('${escapeAttr(s.id)}')">Edit</button>
5439
+ <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>
5440
+ </td>
5441
+ </tr>`).join('')}
5442
+ </tbody>
5443
+ </table>`;
5444
+ }
5445
+
5446
+ function filterLibrarySteps() {
5447
+ const q = (document.getElementById('library-search')?.value || '').toLowerCase();
5448
+ const action = document.getElementById('library-filter-action')?.value || '';
5449
+ const filtered = libraryStepsCache.filter(s => {
5450
+ const matchQ = !q || (s.name||'').toLowerCase().includes(q) || (s.selector||'').toLowerCase().includes(q) || (s.description||'').toLowerCase().includes(q);
5451
+ const matchA = !action || s.action === action;
5452
+ return matchQ && matchA;
5453
+ });
5454
+ renderLibrarySteps(filtered);
5455
+ }
5456
+
5457
+ async function loadLibraryView() {
5458
+ const steps = await fetchLibrarySteps();
5459
+ renderLibrarySteps(steps);
5460
+ }
5461
+
5462
+ function openAddLibraryStep() {
5463
+ document.getElementById('lib-step-id').value = '';
5464
+ document.getElementById('lib-step-name').value = '';
5465
+ document.getElementById('lib-step-action').value = 'click';
5466
+ document.getElementById('lib-step-selector').value = '';
5467
+ document.getElementById('lib-step-value').value = '';
5468
+ document.getElementById('lib-step-tags').value = '';
5469
+ document.getElementById('library-step-modal-title').textContent = 'Add step to library';
5470
+ document.getElementById('library-step-modal').style.display = 'flex';
5471
+ }
5472
+
5473
+ function editLibraryStep(id) {
5474
+ const step = libraryStepsCache.find(s => s.id === id);
5475
+ if (!step) return;
5476
+ document.getElementById('lib-step-id').value = step.id;
5477
+ document.getElementById('lib-step-name').value = step.name || '';
5478
+ document.getElementById('lib-step-action').value = step.action || 'click';
5479
+ document.getElementById('lib-step-selector').value = step.stableSelector || step.selector || '';
5480
+ document.getElementById('lib-step-value').value = step.value || '';
5481
+ document.getElementById('lib-step-tags').value = (step.tags || []).join(', ');
5482
+ document.getElementById('library-step-modal-title').textContent = 'Edit step';
5483
+ document.getElementById('library-step-modal').style.display = 'flex';
5484
+ }
5485
+
5486
+ function closeLibraryStepModal() {
5487
+ document.getElementById('library-step-modal').style.display = 'none';
5488
+ }
5489
+
5490
+ async function saveLibraryStep() {
5491
+ const id = document.getElementById('lib-step-id').value;
5492
+ const name = document.getElementById('lib-step-name').value.trim();
5493
+ const action = document.getElementById('lib-step-action').value;
5494
+ const selector = document.getElementById('lib-step-selector').value.trim();
5495
+ const value = document.getElementById('lib-step-value').value.trim();
5496
+ const tags = (document.getElementById('lib-step-tags').value || '').split(',').map(t=>t.trim()).filter(Boolean);
5497
+
5498
+ if (!name) { showToast('Name is required'); return; }
5499
+
5500
+ try {
5501
+ const body = { name, action, selector, stableSelector: selector, value: value || null, tags };
5502
+ const url = id ? `${API_BASE}/api/step-library/${encodeURIComponent(id)}` : `${API_BASE}/api/step-library`;
5503
+ const method = id ? 'PUT' : 'POST';
5504
+ const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
5505
+ if (!res.ok) throw new Error(await res.text());
5506
+ closeLibraryStepModal();
5507
+ await loadLibraryView();
5508
+ showToast(id ? 'Step updated' : 'Step added to library');
5509
+ } catch (err) { showToast('Error: ' + err.message); }
5510
+ }
5511
+
5512
+ async function deleteLibraryStepUI(id, name) {
5513
+ showConfirm('Delete step?', `Remove "${name}" from the library?`, async () => {
5514
+ await fetch(API_BASE + '/api/step-library/' + encodeURIComponent(id), { method: 'DELETE' });
5515
+ await loadLibraryView();
5516
+ showToast('Step deleted');
5517
+ });
5518
+ }
5519
+
5520
+ async function importStepsFromTests() {
5521
+ const btn = document.getElementById('btn-import-steps');
5522
+ if (btn) { btn.disabled = true; btn.textContent = 'Importing...'; }
5523
+ try {
5524
+ const res = await fetch(API_BASE + '/api/step-library/import', { method: 'POST' });
5525
+ const data = await res.json();
5526
+ showToast(`Imported ${data.imported} new steps (${data.skipped} already existed)`);
5527
+ await loadLibraryView();
5528
+ } catch (err) { showToast('Error: ' + err.message); }
5529
+ finally { if (btn) { btn.disabled = false; btn.textContent = 'Import from all tests'; } }
5530
+ }
5531
+
5532
+ async function syncTestsToLibrary() {
5533
+ const btn = document.getElementById('btn-sync-tests');
5534
+ if (btn) { btn.disabled = true; btn.textContent = 'Syncing...'; }
5535
+ try {
5536
+ const res = await fetch(API_BASE + '/api/step-library/sync', { method: 'POST' });
5537
+ const data = await res.json();
5538
+ showToast(`Synced ${data.synced} tests — ${data.updated} updated with library selectors`);
5539
+ } catch (err) { showToast('Error: ' + err.message); }
5540
+ finally { if (btn) { btn.disabled = false; btn.textContent = 'Sync tests to library'; } }
5541
+ }
5542
+
5297
5543
  // ── SUITE TEST PICKER ────────────────────────────────────────────────────────
5298
5544
  function openSuiteTestPicker() {
5299
5545
  const list = document.getElementById('stp-test-list');