skopix 2.0.23 → 2.0.25

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skopix",
3
- "version": "2.0.23",
3
+ "version": "2.0.25",
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": {
@@ -5401,6 +5401,23 @@ async function fetchLibrarySteps() {
5401
5401
  } catch { return []; }
5402
5402
  }
5403
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
+
5404
5421
  function renderLibrarySteps(steps) {
5405
5422
  const container = document.getElementById('library-steps-container');
5406
5423
  if (!container) return;
@@ -5415,9 +5432,27 @@ function renderLibrarySteps(steps) {
5415
5432
  }
5416
5433
 
5417
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
+
5418
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>
5419
5453
  <table style="width:100%;border-collapse:collapse">
5420
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>
5421
5456
  <th style="padding:10px 20px;text-align:left;border-bottom:1px solid var(--border)">NAME</th>
5422
5457
  <th style="padding:10px 16px;text-align:left;border-bottom:1px solid var(--border)">ACTION</th>
5423
5458
  <th style="padding:10px 16px;text-align:left;border-bottom:1px solid var(--border)">SELECTOR</th>
@@ -5425,24 +5460,122 @@ function renderLibrarySteps(steps) {
5425
5460
  <th style="padding:10px 16px;text-align:right;border-bottom:1px solid var(--border)"></th>
5426
5461
  </tr></thead>
5427
5462
  <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=''">
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>
5430
5472
  <td style="padding:12px 20px">
5431
5473
  <div style="font-size:13px;color:var(--text)">${escapeHtml(s.name || s.description || '')}</div>
5432
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>` : ''}
5433
5475
  </td>
5434
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>
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>
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>
5436
5483
  <td style="padding:12px 16px;font-family:var(--mono);font-size:12px;color:var(--muted)">${s.usageCount || 0}</td>
5437
5484
  <td style="padding:12px 16px;text-align:right">
5438
5485
  <button class="btn btn-ghost" style="padding:4px 10px;font-size:11px;margin-right:4px" onclick="editLibraryStep('${escapeAttr(s.id)}')">Edit</button>
5439
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>
5440
5487
  </td>
5441
- </tr>`).join('')}
5488
+ </tr>`;
5489
+ }).join('')}
5442
5490
  </tbody>
5443
5491
  </table>`;
5444
5492
  }
5445
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
+
5446
5579
  function filterLibrarySteps() {
5447
5580
  const q = (document.getElementById('library-search')?.value || '').toLowerCase();
5448
5581
  const action = document.getElementById('library-filter-action')?.value || '';