// ── 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 = `
hyprcrd test runner
`; 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 += `
`; html += `
${esc(suite.name)}
`; for (const t of suite.tests) { total++; totalTime += t.time; if (t.status === 'pass') { passed++; html += `
✓ ${esc(t.name)} ${t.time}ms
`; } else { failed++; html += `
✗ ${esc(t.name)} ${t.time}ms
`; html += `
${esc(t.error)}
`; } } html += `
`; } 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, '>'); } async function run() { const output = panel.querySelector('#test-output'); const summary = panel.querySelector('#test-summary'); // 1. Create backup output.innerHTML = '
creating backup...
'; summary.textContent = ''; await sleep(50); try { await createBackup(); } catch (e) { output.innerHTML = `
backup failed: ${esc(e.message)}
`; 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(); } });