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.
- package/cli/commands/agent.js +12 -0
- package/cli/commands/dashboard.js +203 -0
- package/package.json +1 -1
- package/web/app/index.html +246 -0
package/cli/commands/agent.js
CHANGED
|
@@ -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
package/web/app/index.html
CHANGED
|
@@ -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');
|