Tests assume a normal board — no super-view, no bucket filter, manual sort, no hides. When the runner fired with the app left in super-view or with filters on, selectors like `.card[data-id]` missed the `.superview-item` DOM and multi-select / due-date assertions read the wrong initial values. Snapshot the relevant bits of window.state before the run, force a clean baseline, then restore the user's view afterwards so opening the runner doesn't silently wipe their context. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
630 lines
25 KiB
JavaScript
630 lines
25 KiB
JavaScript
// ── 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/sync.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();
|
|
}
|
|
|
|
// Snapshot the view-affecting bits of window.state so we can force a
|
|
// neutral baseline (normal board, no filter, manual sort, no hides)
|
|
// for the test run and restore the user's view afterwards. Tests were
|
|
// flaky when the runner fired while the board was in super-view: DOM
|
|
// selectors like `.card[data-id]` don't match `.superview-item`, so
|
|
// multi-select and due-date tests read wrong initial values.
|
|
export function snapshotViewState() {
|
|
const s = window.state;
|
|
if (!s) return null;
|
|
return {
|
|
superView: s.superView,
|
|
activeBucket: s.activeBucket,
|
|
activeTags: [...(s.activeTags || [])],
|
|
activeGroup: s.activeGroup,
|
|
sortMode: s.sortMode,
|
|
hideTermine: s.hideTermine,
|
|
hideDone: s.hideDone,
|
|
hideTagged: s.hideTagged,
|
|
boardSplitOpen: s.boardSplitOpen,
|
|
};
|
|
}
|
|
|
|
export function resetViewState() {
|
|
const s = window.state;
|
|
if (!s) return;
|
|
s.superView = false;
|
|
s.activeBucket = 'all';
|
|
s.activeTags = [];
|
|
s.activeGroup = null;
|
|
s.sortMode = 'manual';
|
|
s.hideTermine = false;
|
|
s.hideDone = false;
|
|
s.hideTagged = false;
|
|
s.boardSplitOpen = false;
|
|
s.selectedIds?.clear?.();
|
|
// Sync super-view toggle visuals with the reset state so tests that
|
|
// inspect the button see the correct pose.
|
|
const btn = document.getElementById('superview-toggle');
|
|
const icon = btn?.querySelector('i');
|
|
if (icon) icon.className = 'ph ph-list text-sm';
|
|
btn?.classList.remove('active');
|
|
window._renderBoard?.();
|
|
}
|
|
|
|
export function restoreViewState(snap) {
|
|
const s = window.state;
|
|
if (!s || !snap) return;
|
|
Object.assign(s, {
|
|
superView: snap.superView,
|
|
activeBucket: snap.activeBucket,
|
|
activeTags: snap.activeTags,
|
|
activeGroup: snap.activeGroup,
|
|
sortMode: snap.sortMode,
|
|
hideTermine: snap.hideTermine,
|
|
hideDone: snap.hideDone,
|
|
hideTagged: snap.hideTagged,
|
|
boardSplitOpen: snap.boardSplitOpen,
|
|
});
|
|
const btn = document.getElementById('superview-toggle');
|
|
const icon = btn?.querySelector('i');
|
|
if (icon) icon.className = `ph ${snap.superView ? 'ph-squares-four' : 'ph-list'} text-sm`;
|
|
btn?.classList.toggle('active', !!snap.superView);
|
|
window._renderBoard?.();
|
|
}
|
|
|
|
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<string> 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 = `
|
|
<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);
|
|
// 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 += `<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' && 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 += `<div style="color:#fbbf24;padding-left:16px;">⚠ ${esc(t.name)} <span style="color:#475569;">${t.time}ms</span> <span style="color:#fbbf24;font-style:italic;">(flaky — passed on retry)</span></div>`;
|
|
if (t.originalError) {
|
|
html += `<div style="color:#fbbf24;padding-left:32px;font-size:11px;opacity:0.7;">first run: ${esc(t.originalError)}</div>`;
|
|
}
|
|
} else 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++;
|
|
const retryNote = t.retried ? ' <span style="color:#fb7185;font-style:italic;">(failed twice)</span>' : '';
|
|
html += `<div style="color:#fb7185;padding-left:16px;">✗ ${esc(t.name)} <span style="color:#475569;">${t.time}ms</span>${retryNote}</div>`;
|
|
html += `<div style="color:#fb7185;padding-left:32px;font-size:11px;opacity:0.8;">${esc(t.error)}</div>`;
|
|
}
|
|
}
|
|
html += `</div>`;
|
|
}
|
|
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, '<').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 = '<div style="color:#64748b;">cleaning up previous test artifacts...</div>';
|
|
summary.textContent = '';
|
|
await sleep(50);
|
|
const purged = await purgeTestLeftovers();
|
|
if (purged.cards || purged.buckets) {
|
|
output.innerHTML += `<div style="color:#fbbf24;">purged ${purged.cards} leftover card${purged.cards !== 1 ? 's' : ''}, ${purged.buckets} bucket${purged.buckets !== 1 ? 's' : ''}</div>`;
|
|
}
|
|
|
|
// 2. Create backup (clean baseline)
|
|
output.innerHTML += '<div style="color:#64748b;">creating backup...</div>';
|
|
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;
|
|
|
|
// 2b. Snapshot + reset view state. Tests assume a normal board with
|
|
// no filters / super-view off / manual sort — if the user left the
|
|
// app in super-view or with a bucket filter active, assertions on
|
|
// .card[data-id], card count, etc. read wrong initial values.
|
|
const viewSnap = snapshotViewState();
|
|
await closeAll();
|
|
resetViewState();
|
|
await sleep(50);
|
|
|
|
// 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 {}
|
|
// Re-reset the view in case a failing test left it in super-view,
|
|
// a bucket filter, or a sorted mode — otherwise the retry runs
|
|
// against the same wrong baseline that flaked it the first time.
|
|
resetViewState();
|
|
await sleep(50);
|
|
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 {}
|
|
|
|
// 6b. Put the view back the way the user left it (super-view, sort,
|
|
// filters, …) so launching the test runner doesn't silently wipe
|
|
// their working context.
|
|
restoreViewState(viewSnap);
|
|
|
|
// 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();
|
|
}
|
|
});
|