// ── 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'; 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 = `