// ── hyprcrd GUI Test Runner ── // Self-contained in-app test framework. No external dependencies. // Activate: ?test=1 in URL or Ctrl+Shift+Alt+T // Panel is hidden during test run so interactions are visible. // Backup is created before and auto-restored after every run. import { getCsrf } from '../../js/modules/util.js'; import { stopBackgroundSync, startBackgroundSync } from '../../js/modules/caldav.js'; // Suites live on window so two runner.js instances (one cached and loaded // by test files via bare imports, one fresh and loaded by loader.js with // ?v=) can share registration. The cached runner provides describe/it/ // expect at module-load time; the fresh runner runs `runAll` against the // shared array. Without this we'd see "0/0 passed" whenever the cached // runner is older than the loader. const suites = (window._testSuites = window._testSuites || []); 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() { document.getElementById('hterm')?.classList.add('hidden'); for (const id of ['settings-overlay', 'help', 'shortcuts', 'search', 'calendar', 'gallery', 'detail', 'modal', 'tasks', 'dialog']) { 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(); document.getElementById('bucket-super-popover')?.remove(); document.getElementById('asset-picker-flyout')?.remove(); document.getElementById('gitea-overlay')?.classList.remove('open'); // 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(); } // Purge leftover test artifacts from previous failed runs. // Tests use `__prefix__` naming; anything matching gets deleted before // the baseline backup so it doesn't get restored after the run either. async function purgeTestLeftovers() { const token = () => document.cookie.match(/csrf=([^;]+)/)?.[1] || getCsrf(); let purged = { cards: 0, buckets: 0 }; try { const cards = await fetch('api/cards').then(r => r.json()); const testCards = cards.filter(c => /^__.*__/.test(c.title || '')); for (const c of testCards) { await fetch(`api/cards/${c.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': token() } }); purged.cards++; } } catch {} try { const buckets = await fetch('api/buckets').then(r => r.json()); const testBuckets = buckets.filter(b => /^__.*__/.test(b.name || '')); for (const b of testBuckets) { await fetch(`api/buckets/${b.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': token() } }); purged.buckets++; } } catch {} // Also clear any leftover editor drafts so the restore prompt doesn't fire try { for (const k of Object.keys(localStorage)) { if (k.startsWith('hyprcrd-editor-draft')) localStorage.removeItem(k); } } catch {} return purged; } 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 ── // Fixed-top progress strip shown while tests are running. The main test // panel is hidden during the run so the suite can interact with the UI; // this strip gives the user a way to see how far along the run is. // // loader.js creates the DOM node when the user presses the shortcut so // it appears immediately (loader.js is cache-busted per page load via // the plugin manager's ?v=time()). The runner — which lives at a stable // URL so suite registration is consistent — only updates the existing // node. If runner.js is cached and stale, the placeholder still shows // up so the user can tell something happened. function getProgressBar() { return document.getElementById('test-progress'); } function updateProgress(current, total, name, failed) { const bar = getProgressBar(); if (!bar) return; const label = bar.querySelector('#test-progress-label'); const nameEl = bar.querySelector('#test-progress-name'); const fill = bar.querySelector('#test-progress-fill'); if (label) label.textContent = `test ${current}/${total}:`; if (nameEl) nameEl.textContent = name; if (fill) { fill.style.width = (current / total * 100) + '%'; if (failed) fill.style.background = '#fb7185'; } } function hideProgress() { getProgressBar()?.remove(); document.getElementById('test-shield')?.remove(); document.body.style.marginTop = ''; document.body.style.height = ''; } /** * Run tests. With `only=null` runs everything; with `only` set to a * Set of `${suite.name}::${test.name}` keys, runs just those. * The progress bar denominator reflects the filtered count so the * fill bar makes sense for both full runs and retries. */ async function runAll(only = null) { const allTests = []; for (const suite of suites) { for (const test of suite.tests) { if (!only || only.has(`${suite.name}::${test.name}`)) { allTests.push({ suite, test }); } } } const total = allTests.length; updateProgress(0, total, only ? 'retrying…' : 'starting…', false); let done = 0; let anyFailed = false; const suiteMap = new Map(); for (const { suite, test } of allTests) { done++; updateProgress(done, total, `${suite.name} — ${test.name}`, anyFailed); 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; anyFailed = true; // Always run afterEach on failure too try { if (suite.afterEach) await suite.afterEach(); } catch {} } entry.time = Math.round(performance.now() - t0); if (!suiteMap.has(suite.name)) suiteMap.set(suite.name, { name: suite.name, tests: [] }); suiteMap.get(suite.name).tests.push(entry); } // Don't hide the progress bar here — run() does it after the retry // pass (if any) so the bar stays visible during the whole sequence. return [...suiteMap.values()]; } // ── UI Panel ── let panel = null; let lastResults = 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); // Copy: when failures exist, only copy the failed + flaky tests so // the user can paste the relevant part without wading through 200+ // lines of green. When everything passed, copy the full output. panel.querySelector('#test-copy-btn').addEventListener('click', () => { let text; if (lastResults) { const hasFailures = lastResults.some(s => s.tests.some(t => t.status === 'fail' || t.retried)); if (hasFailures) { const lines = []; for (const s of lastResults) { const relevant = s.tests.filter(t => t.status === 'fail' || t.retried); if (!relevant.length) continue; lines.push(s.name); for (const t of relevant) { const icon = t.status === 'fail' ? '✗' : '⚠'; const note = t.retried ? (t.status === 'pass' ? ' (flaky — passed on retry)' : ' (failed twice)') : ''; lines.push(`${icon} ${t.name} ${t.time}ms${note}`); if (t.error) lines.push(` ${t.error}`); if (t.originalError) lines.push(` first run: ${t.originalError}`); } lines.push(''); } text = lines.join('\n').trim(); } else { text = panel.querySelector('#test-output').innerText; } } else { text = panel.querySelector('#test-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) { lastResults = results; const output = panel.querySelector('#test-output'); const summary = panel.querySelector('#test-summary'); let total = 0, passed = 0, failed = 0, flaky = 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' && t.retried) { // Failed first time, passed on auto-retry → flaky. // Counts as passed in the summary but coloured amber + // shows the original error so the user can decide whether // to investigate or move on. passed++; flaky++; html += `
⚠ ${esc(t.name)} ${t.time}ms (flaky — passed on retry)
`; if (t.originalError) { html += `
first run: ${esc(t.originalError)}
`; } } else if (t.status === 'pass') { passed++; html += `
✓ ${esc(t.name)} ${t.time}ms
`; } else { failed++; const retryNote = t.retried ? ' (failed twice)' : ''; html += `
✗ ${esc(t.name)} ${t.time}ms${retryNote}
`; html += `
${esc(t.error)}
`; } } html += `
`; } output.innerHTML = html; const flakyNote = flaky ? `, ${flaky} flaky` : ''; summary.textContent = `${passed}/${total} passed, ${failed} failed${flakyNote} — ${totalTime}ms`; summary.style.color = failed ? '#fb7185' : (flaky ? '#fbbf24' : '#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. Purge leftover test artifacts from any previous failed run output.innerHTML = '
cleaning up previous test artifacts...
'; summary.textContent = ''; await sleep(50); const purged = await purgeTestLeftovers(); if (purged.cards || purged.buckets) { output.innerHTML += `
purged ${purged.cards} leftover card${purged.cards !== 1 ? 's' : ''}, ${purged.buckets} bucket${purged.buckets !== 1 ? 's' : ''}
`; } // 2. Create backup (clean baseline) output.innerHTML += '
creating backup...
'; 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(); // 4b. Auto-retry: collect every failed (suite, test) pair, restore the // backup so the retry runs against the same baseline state the first // pass started from, then re-run *only* those tests. Pass-on-retry is // marked so the user can tell flaky from genuinely broken at a glance. const failedKeys = new Set(); for (const sr of results) { for (const t of sr.tests) { if (t.status === 'fail') failedKeys.add(`${sr.name}::${t.name}`); } } if (failedKeys.size) { await closeAll(); try { await restoreBackup(); await window._reloadAll?.(); await sleep(200); } catch {} const retryResults = await runAll(failedKeys); const retryMap = new Map(); for (const sr of retryResults) { for (const t of sr.tests) { retryMap.set(`${sr.name}::${t.name}`, t); } } for (const sr of results) { for (let i = 0; i < sr.tests.length; i++) { const key = `${sr.name}::${sr.tests[i].name}`; const retried = retryMap.get(key); if (retried) { const originalError = sr.tests[i].error; sr.tests[i] = { ...retried, retried: true, originalError }; } } } } // 5. Hide progress bar now that both passes (first + retry) are done hideProgress(); // 6. 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.altKey && (e.key === 'T' || e.key === 't')) { e.preventDefault(); panel ? close() : open(); } if (e.key === 'Escape' && panel && panel.style.display !== 'none') { e.preventDefault(); e.stopPropagation(); close(); } });