plugin infrastruktur
This commit is contained in:
1 parent
dd2a995f6c
commit
1545b90f39
43 files changed
No files matched your search
@@ -0,0 +1,358 @@
|
||||
// ── hyprcrd GUI Test Runner ──
|
||||
// Self-contained in-app test framework. No external dependencies.
|
||||
// Activate: ?test=1 in URL or Ctrl+Shift+T
|
||||
// Panel is hidden during test run so interactions are visible.
|
||||
// Backup is created before and auto-restored after every run.
|
||||
|
||||
import { getCsrf } from '../modules/util.js';
|
||||
import { stopBackgroundSync, startBackgroundSync } from '../modules/caldav.js';
|
||||
|
||||
const suites = [];
|
||||
let currentSuite = null;
|
||||
|
||||
// ── Public API ──
|
||||
|
||||
export function describe(name, fn) {
|
||||
const suite = { name, tests: [], beforeEach: null, afterEach: null };
|
||||
currentSuite = suite;
|
||||
suites.push(suite);
|
||||
fn();
|
||||
currentSuite = null;
|
||||
}
|
||||
|
||||
export function it(name, fn) {
|
||||
if (!currentSuite) throw new Error('it() must be inside describe()');
|
||||
currentSuite.tests.push({ name, fn });
|
||||
}
|
||||
|
||||
export function beforeEach(fn) {
|
||||
if (currentSuite) currentSuite.beforeEach = fn;
|
||||
}
|
||||
|
||||
export function afterEach(fn) {
|
||||
if (currentSuite) currentSuite.afterEach = fn;
|
||||
}
|
||||
|
||||
// ── Assertions ──
|
||||
|
||||
export function expect(actual) {
|
||||
return {
|
||||
toBe(expected) {
|
||||
if (actual !== expected) throw new AssertionError(`expected ${json(expected)}, got ${json(actual)}`);
|
||||
},
|
||||
toEqual(expected) {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new AssertionError(`expected ${json(expected)}, got ${json(actual)}`);
|
||||
},
|
||||
toBeTruthy() {
|
||||
if (!actual) throw new AssertionError(`expected truthy, got ${json(actual)}`);
|
||||
},
|
||||
toBeFalsy() {
|
||||
if (actual) throw new AssertionError(`expected falsy, got ${json(actual)}`);
|
||||
},
|
||||
toContain(item) {
|
||||
if (typeof actual === 'string') {
|
||||
if (!actual.includes(item)) throw new AssertionError(`expected "${trunc(actual)}" to contain "${item}"`);
|
||||
} else if (Array.isArray(actual)) {
|
||||
if (!actual.includes(item)) throw new AssertionError(`expected array to contain ${json(item)}`);
|
||||
} else {
|
||||
throw new AssertionError('toContain requires string or array');
|
||||
}
|
||||
},
|
||||
toBeGreaterThan(n) {
|
||||
if (!(actual > n)) throw new AssertionError(`expected ${actual} > ${n}`);
|
||||
},
|
||||
toBeLessThan(n) {
|
||||
if (!(actual < n)) throw new AssertionError(`expected ${actual} < ${n}`);
|
||||
},
|
||||
toMatch(regex) {
|
||||
if (!regex.test(actual)) throw new AssertionError(`expected "${trunc(actual)}" to match ${regex}`);
|
||||
},
|
||||
toExist() {
|
||||
if (actual === null || actual === undefined) throw new AssertionError('expected to exist, got ' + json(actual));
|
||||
},
|
||||
toBeVisible() {
|
||||
if (!actual) throw new AssertionError('element is null');
|
||||
const el = typeof actual === 'string' ? document.querySelector(actual) : actual;
|
||||
if (!el) throw new AssertionError('element not found');
|
||||
if (!isVisible(el)) throw new AssertionError('expected element to be visible');
|
||||
},
|
||||
toBeHidden() {
|
||||
if (!actual) return; // null = hidden
|
||||
const el = typeof actual === 'string' ? document.querySelector(actual) : actual;
|
||||
if (!el) return;
|
||||
if (isVisible(el)) throw new AssertionError('expected element to be hidden');
|
||||
},
|
||||
toHaveCount(n) {
|
||||
const list = typeof actual === 'string' ? document.querySelectorAll(actual) : actual;
|
||||
const len = list?.length ?? 0;
|
||||
if (len !== n) throw new AssertionError(`expected ${n} elements, found ${len}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class AssertionError extends Error {
|
||||
constructor(msg) { super(msg); this.name = 'AssertionError'; }
|
||||
}
|
||||
|
||||
function json(v) { try { return JSON.stringify(v); } catch { return String(v); } }
|
||||
function trunc(s) { return String(s).length > 80 ? String(s).substring(0, 80) + '...' : String(s); }
|
||||
|
||||
function isVisible(el) {
|
||||
if (!el) return false;
|
||||
if (el.classList.contains('overlay')) return el.classList.contains('open');
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0 && !el.classList.contains('hidden') && getComputedStyle(el).display !== 'none';
|
||||
}
|
||||
|
||||
// ── DOM Helpers ──
|
||||
|
||||
export function $(selector) {
|
||||
return document.querySelector(selector) || document.getElementById(selector);
|
||||
}
|
||||
|
||||
export function $$(selector) {
|
||||
return [...document.querySelectorAll(selector)];
|
||||
}
|
||||
|
||||
export function click(selectorOrEl) {
|
||||
const el = typeof selectorOrEl === 'string' ? $(selectorOrEl) : selectorOrEl;
|
||||
if (!el) throw new Error(`click: element not found: ${selectorOrEl}`);
|
||||
el.click();
|
||||
}
|
||||
|
||||
export function dblclick(selectorOrEl) {
|
||||
const el = typeof selectorOrEl === 'string' ? $(selectorOrEl) : selectorOrEl;
|
||||
if (!el) throw new Error(`dblclick: element not found: ${selectorOrEl}`);
|
||||
el.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
|
||||
}
|
||||
|
||||
export function type(selectorOrEl, text) {
|
||||
const el = typeof selectorOrEl === 'string' ? $(selectorOrEl) : selectorOrEl;
|
||||
if (!el) throw new Error(`type: element not found: ${selectorOrEl}`);
|
||||
el.focus();
|
||||
el.value = text;
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
export function pressKey(key, opts = {}) {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, ...opts }));
|
||||
}
|
||||
|
||||
export function rightClick(selectorOrEl, x, y) {
|
||||
const el = typeof selectorOrEl === 'string' ? $(selectorOrEl) : selectorOrEl;
|
||||
if (!el) throw new Error(`rightClick: element not found: ${selectorOrEl}`);
|
||||
const rect = el.getBoundingClientRect();
|
||||
el.dispatchEvent(new MouseEvent('contextmenu', {
|
||||
bubbles: true, clientX: x ?? rect.left + 10, clientY: y ?? rect.top + 10,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function waitFor(fn, timeout = 3000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
try { const r = fn(); if (r) return r; } catch {}
|
||||
await sleep(50);
|
||||
}
|
||||
throw new Error(`waitFor timed out after ${timeout}ms`);
|
||||
}
|
||||
|
||||
export function sleep(ms) {
|
||||
return new Promise(r => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
export function csrf() {
|
||||
return getCsrf();
|
||||
}
|
||||
|
||||
export async function closeAll() {
|
||||
for (const id of ['settings-overlay', 'help', 'search', 'calendar', 'gallery', 'detail', 'modal', 'tasks']) {
|
||||
const el = document.getElementById(id);
|
||||
if (el?.classList.contains('open')) el.classList.remove('open');
|
||||
}
|
||||
document.querySelector('#ctx-menu')?.classList.add('hidden');
|
||||
document.querySelector('#select-bar')?.classList.add('hidden');
|
||||
document.querySelector('.bucket-picker')?.remove();
|
||||
document.querySelector('.orphan-lightbox')?.remove();
|
||||
// Clear multi-select
|
||||
document.querySelectorAll('.card-selected').forEach(el => {
|
||||
el.classList.remove('card-selected');
|
||||
el.querySelector('.card-select-check')?.remove();
|
||||
});
|
||||
await sleep(50);
|
||||
}
|
||||
|
||||
// ── Backup & Restore ──
|
||||
|
||||
let backupBlob = null;
|
||||
|
||||
async function createBackup() {
|
||||
const res = await fetch('api/export');
|
||||
if (!res.ok) throw new Error('backup failed: ' + res.status);
|
||||
backupBlob = await res.blob();
|
||||
}
|
||||
|
||||
async function restoreBackup() {
|
||||
if (!backupBlob) return;
|
||||
const form = new FormData();
|
||||
form.append('file', backupBlob, 'backup.zip');
|
||||
await fetch('api/import', { method: 'POST', body: form, headers: { 'X-CSRF-Token': csrf() } });
|
||||
}
|
||||
|
||||
// ── Runner ──
|
||||
|
||||
async function runAll() {
|
||||
const results = [];
|
||||
for (const suite of suites) {
|
||||
const suiteResult = { name: suite.name, tests: [] };
|
||||
for (const test of suite.tests) {
|
||||
const entry = { name: test.name, status: 'pass', error: null, time: 0 };
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
if (suite.beforeEach) await suite.beforeEach();
|
||||
await test.fn();
|
||||
if (suite.afterEach) await suite.afterEach();
|
||||
} catch (err) {
|
||||
entry.status = 'fail';
|
||||
entry.error = err.message;
|
||||
// Always run afterEach on failure too
|
||||
try { if (suite.afterEach) await suite.afterEach(); } catch {}
|
||||
}
|
||||
entry.time = Math.round(performance.now() - t0);
|
||||
suiteResult.tests.push(entry);
|
||||
}
|
||||
results.push(suiteResult);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ── UI Panel ──
|
||||
|
||||
let panel = null;
|
||||
|
||||
function createPanel() {
|
||||
if (panel) { panel.remove(); panel = null; }
|
||||
panel = document.createElement('div');
|
||||
panel.id = 'test-panel';
|
||||
panel.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;z-index:9999;background:rgba(2,6,23,0.95);font-family:monospace;color:#cbd5e1;';
|
||||
panel.innerHTML = `
|
||||
<div id="test-header" style="position:absolute;top:0;left:0;right:0;height:44px;padding:8px 16px;border-bottom:1px solid #334155;display:flex;align-items:center;gap:12px;">
|
||||
<span style="font-weight:bold;color:#38bdf8;">hyprcrd test runner</span>
|
||||
<button id="test-run-btn" style="padding:4px 12px;background:#38bdf8;color:#0f172a;border:none;border-radius:3px;cursor:pointer;font-family:inherit;font-size:12px;">run all</button>
|
||||
<button id="test-copy-btn" style="padding:4px 12px;background:#334155;color:#94a3b8;border:none;border-radius:3px;cursor:pointer;font-family:inherit;font-size:12px;">copy</button>
|
||||
<span id="test-summary" style="font-size:12px;color:#64748b;"></span>
|
||||
<span style="flex:1"></span>
|
||||
<button id="test-close-btn" style="background:none;border:none;color:#64748b;cursor:pointer;font-size:16px;">✕</button>
|
||||
</div>
|
||||
<div id="test-output" style="position:absolute;top:44px;left:0;right:0;bottom:0;overflow-y:auto;padding:12px 16px;font-size:12px;line-height:1.8;"></div>
|
||||
`;
|
||||
document.body.appendChild(panel);
|
||||
panel.querySelector('#test-close-btn').addEventListener('click', close);
|
||||
panel.querySelector('#test-run-btn').addEventListener('click', run);
|
||||
panel.querySelector('#test-copy-btn').addEventListener('click', () => {
|
||||
const output = panel.querySelector('#test-output');
|
||||
const text = output.innerText;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const btn = panel.querySelector('#test-copy-btn');
|
||||
btn.textContent = 'copied';
|
||||
btn.style.color = '#4ade80';
|
||||
setTimeout(() => { btn.textContent = 'copy'; btn.style.color = '#94a3b8'; }, 1500);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderResults(results) {
|
||||
const output = panel.querySelector('#test-output');
|
||||
const summary = panel.querySelector('#test-summary');
|
||||
let total = 0, passed = 0, failed = 0, totalTime = 0;
|
||||
|
||||
let html = '';
|
||||
for (const suite of results) {
|
||||
html += `<div style="margin-bottom:16px;">`;
|
||||
html += `<div style="color:#94a3b8;font-weight:bold;margin-bottom:4px;">${esc(suite.name)}</div>`;
|
||||
for (const t of suite.tests) {
|
||||
total++;
|
||||
totalTime += t.time;
|
||||
if (t.status === 'pass') {
|
||||
passed++;
|
||||
html += `<div style="color:#4ade80;padding-left:16px;">✓ ${esc(t.name)} <span style="color:#475569;">${t.time}ms</span></div>`;
|
||||
} else {
|
||||
failed++;
|
||||
html += `<div style="color:#fb7185;padding-left:16px;">✗ ${esc(t.name)} <span style="color:#475569;">${t.time}ms</span></div>`;
|
||||
html += `<div style="color:#fb7185;padding-left:32px;font-size:11px;opacity:0.8;">${esc(t.error)}</div>`;
|
||||
}
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
output.innerHTML = html;
|
||||
summary.textContent = `${passed}/${total} passed, ${failed} failed — ${totalTime}ms`;
|
||||
summary.style.color = failed ? '#fb7185' : '#4ade80';
|
||||
}
|
||||
|
||||
function esc(s) { return String(s).replace(/</g, '<').replace(/>/g, '>'); }
|
||||
|
||||
async function run() {
|
||||
const output = panel.querySelector('#test-output');
|
||||
const summary = panel.querySelector('#test-summary');
|
||||
|
||||
// 1. Create backup
|
||||
output.innerHTML = '<div style="color:#64748b;">creating backup...</div>';
|
||||
summary.textContent = '';
|
||||
await sleep(50);
|
||||
try {
|
||||
await createBackup();
|
||||
} catch (e) {
|
||||
output.innerHTML = `<div style="color:#fb7185;">backup failed: ${esc(e.message)}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Stop background sync so tests don't create side effects
|
||||
stopBackgroundSync();
|
||||
window._testRunning = true;
|
||||
|
||||
// 3. Hide panel so tests run against visible UI
|
||||
panel.style.display = 'none';
|
||||
await sleep(100);
|
||||
|
||||
// 4. Run tests
|
||||
const results = await runAll();
|
||||
|
||||
// 5. Clean up any leftover UI state
|
||||
await closeAll();
|
||||
|
||||
// 6. Restore backup
|
||||
try {
|
||||
await restoreBackup();
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
} catch {}
|
||||
|
||||
// 7. Resume background sync (delay to avoid immediate re-sync creating duplicates)
|
||||
window._testRunning = false;
|
||||
setTimeout(() => startBackgroundSync(), 60000);
|
||||
|
||||
// 8. Show panel with results
|
||||
panel.style.display = '';
|
||||
renderResults(results);
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (panel) { panel.remove(); panel = null; }
|
||||
}
|
||||
|
||||
export function open() {
|
||||
createPanel();
|
||||
run();
|
||||
}
|
||||
|
||||
// ── Auto-activate ──
|
||||
|
||||
if (new URLSearchParams(location.search).has('test')) {
|
||||
setTimeout(open, 1000);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'T') {
|
||||
e.preventDefault();
|
||||
panel ? close() : open();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user