diff --git a/config.php b/config.php index eb0e1be..33042c8 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ { else if (search.classList.contains('open')) closeSearch(); else if (tasks?.classList.contains('open')) closeTasks(); else if (document.getElementById('gallery')?.classList.contains('open')) closeGallery(); + else if (document.getElementById('calendar')?.classList.contains('open')) closeCalendar(); return; } if (e.key === 's' && (e.ctrlKey || e.metaKey) && modal.classList.contains('open')) { e.preventDefault(); e.shiftKey ? saveCard() : saveOnly(); return; } if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && modal.classList.contains('open')) { e.preventDefault(); saveCard(); return; } if (e.key === 'z' && (e.ctrlKey || e.metaKey) && !modal.classList.contains('open') && canUndo()) { e.preventDefault(); undo(); return; } if (e.key === '/' && ![modal, detail, search, tasks].some(el => el?.classList.contains('open')) && document.activeElement === document.body) { e.preventDefault(); openSearch(); } + // Bare "c" toggles the calendar from the board. Same guards as "/": + // no modifier, focus on (so it doesn't intercept text typing), + // and no full-screen overlay already in front. + if (e.key === 'c' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && document.activeElement === document.body) { + const calendar = document.getElementById('calendar'); + const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay')]; + if (blockers.some(el => el?.classList.contains('open'))) return; + e.preventDefault(); + if (calendar?.classList.contains('open')) closeCalendar(); + else openCalendar(); + return; + } + // Bare "t" opens the tag quick-pick on the board. Same focus + + // overlay guards as "c". Picking a tag sets state.activeTags = [tag]. + if (e.key === 't' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && document.activeElement === document.body) { + const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay'), document.getElementById('calendar')]; + if (blockers.some(el => el?.classList.contains('open'))) return; + e.preventDefault(); + openTagQuickPick(); + return; + } + // Bare "1"–"9" picks the Nth user bucket in the bar — fixed filters + // (all/current/favorites/journal/none) are skipped, super-buckets + // count as one slot and activate via activateBucket() so the + // dropdown popover is NOT triggered. + if (/^[1-9]$/.test(e.key) && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && document.activeElement === document.body) { + const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay'), document.getElementById('calendar')]; + if (blockers.some(el => el?.classList.contains('open'))) return; + const SPECIAL = new Set(['all', 'current', 'favorites', 'journal', 'none']); + const tiles = [...document.querySelectorAll('#bucket-bar .bucket-tab[data-bucket], #bucket-bar .bucket-tab[data-bucket-super]')] + .filter(t => !SPECIAL.has(t.dataset.bucket)); + const idx = parseInt(e.key, 10) - 1; + const tile = tiles[idx]; + if (tile) { + e.preventDefault(); + // Super-bucket: activate the currently shown member (parent or + // active child) directly instead of clicking, which would open + // the dropdown popover. Regular bucket: just take its data-bucket. + const target = tile.dataset.bucketSuper ? (tile.dataset.superShown || tile.dataset.bucketSuper) : tile.dataset.bucket; + activateBucket(target); + } + return; + } // Arrow keys navigate group in detail view if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && detail.classList.contains('open') && state.viewingCard) { @@ -288,6 +332,96 @@ document.getElementById('quick-capture-input')?.addEventListener('keydown', asyn setTimeout(() => { n.style.transition = 'opacity .3s'; n.style.opacity = '0'; setTimeout(() => n.remove(), 350); }, 2500); }); +// ── Tag quick-pick (bare "t" hotkey on the board) ── + +/** Show a small popover listing every tag found on any card. Typing + * filters; arrow keys + enter pick; clicking outside or ESC closes. + * Picking a tag sets state.activeTags = [tag] and re-renders the board. */ +function openTagQuickPick() { + const existing = document.getElementById('tag-quickpick'); + if (existing) { existing.remove(); return; } + + const tagSet = new Set(); + for (const c of state.cards) { + for (const t of (c.tags || '').split(',').filter(Boolean)) tagSet.add(t); + } + const allTags = [...tagSet].sort(); + if (!allTags.length) return; + + const overlay = document.createElement('div'); + overlay.id = 'tag-quickpick'; + overlay.style.cssText = 'position:fixed;inset:0;z-index:120;background:var(--t-overlay,rgba(0,0,0,0.55));display:flex;align-items:flex-start;justify-content:center;padding:80px 16px 16px;backdrop-filter:blur(var(--ui-blur-overlay,0));'; + const box = document.createElement('div'); + box.style.cssText = 'background:var(--t-surface);border:1px solid var(--t-border);border-radius:var(--ui-radius-modal);box-shadow:var(--ui-shadow-modal);width:100%;max-width:380px;max-height:60vh;display:flex;flex-direction:column;overflow:hidden;'; + box.innerHTML = ` +
+ + +
+
+ `; + overlay.appendChild(box); + document.body.appendChild(overlay); + + const input = box.querySelector('#tag-quickpick-input'); + const list = box.querySelector('#tag-quickpick-list'); + let filtered = allTags; + let active = 0; + + function escHtml(s) { return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } + + function renderList() { + if (!filtered.length) { + list.innerHTML = '
no tags match
'; + return; + } + list.innerHTML = filtered.map((t, i) => ` + + `).join(''); + list.querySelectorAll('[data-tag]').forEach(btn => { + btn.addEventListener('mouseenter', () => { active = parseInt(btn.dataset.idx, 10); renderList(); }); + btn.addEventListener('click', () => pick(btn.dataset.tag)); + }); + const activeEl = list.querySelector(`[data-idx="${active}"]`); + activeEl?.scrollIntoView({ block: 'nearest' }); + } + + function pick(tag) { + state.activeTags = [tag]; + close(); + window._renderTagBar?.(); + window._renderBoard?.(); + } + function close() { + overlay.remove(); + document.removeEventListener('keydown', onKey, true); + } + function onKey(e) { + if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); } + else if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(active + 1, filtered.length - 1); renderList(); } + else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(active - 1, 0); renderList(); } + else if (e.key === 'Enter') { e.preventDefault(); if (filtered[active]) pick(filtered[active]); } + } + + input.addEventListener('input', () => { + const f = input.value.toLowerCase().replace(/^#/, ''); + filtered = f ? allTags.filter(t => t.toLowerCase().includes(f)) : allTags; + active = 0; + renderList(); + }); + overlay.addEventListener('click', e => { if (e.target === overlay) close(); }); + // Capture-phase keydown so ESC etc. don't bubble back to the rest of + // the page handlers (search, board) while the picker is open. + document.addEventListener('keydown', onKey, true); + + renderList(); + setTimeout(() => input.focus(), 0); +} + // ── Boot ── checkAuth(); diff --git a/public/js/modules/board.js b/public/js/modules/board.js index d5cf790..d246da1 100644 --- a/public/js/modules/board.js +++ b/public/js/modules/board.js @@ -312,8 +312,11 @@ function closeBoardPreview() { boardPreview?.classList.add('hidden'); state.boardSplitOpen = false; localStorage.setItem('hyprcrd-board-split', '0'); + // The toolbar's active state lives on the `.active` class — the + // previous `text-t-accent` removal was a no-op since that class + // isn't what toggleBoardSplit uses. const btn = $('board-split-toggle'); - if (btn) btn.classList.remove('text-t-accent'); + if (btn) btn.classList.remove('active'); board.querySelectorAll('.board-card').forEach(el => el.classList.remove('preview-active')); state.boardPreviewCardId = null; resetBoardPreview(); diff --git a/public/js/modules/buckets.js b/public/js/modules/buckets.js index 99f0468..d36e13b 100644 --- a/public/js/modules/buckets.js +++ b/public/js/modules/buckets.js @@ -186,6 +186,18 @@ const inpBucket = $('inp-bucket'); const searchFilter = $('search-bucket-filter'); const tasksFilter = $('tasks-bucket-filter'); +/** Switch the active bucket without going through the click path — + * used by the number-key hotkeys (and any other code that wants to + * activate a bucket programmatically without triggering the super- + * bucket dropdown). Persists via setActiveBucket and re-renders the + * bar + board so the active highlight tracks. */ +export function activateBucket(idOrSpecial) { + setActiveBucket(idOrSpecial); + state.activeGroup = null; + renderBar(); + window._renderBoard?.(); +} + export async function loadBuckets() { state.buckets = await api('api/buckets'); // The persisted activeBucket may point to a bucket that no longer diff --git a/public/js/modules/context-menu.js b/public/js/modules/context-menu.js index 23423b7..a6a90fc 100644 --- a/public/js/modules/context-menu.js +++ b/public/js/modules/context-menu.js @@ -74,7 +74,21 @@ export async function joinSelectedCards() { const ordered = [winner, ...losers]; const titleList = ordered.map(c => `## ${c.title || ''}`).join('\n'); const bodyChunks = ordered.map(c => (c.body || '').trim()).filter(Boolean); - const mergedBody = titleList + '\n\n---\n\n' + bodyChunks.join('\n\n---\n\n'); + let mergedBody = titleList + '\n\n---\n\n' + bodyChunks.join('\n\n---\n\n'); + // Dedupe hashtag mentions across the merged body — first occurrence + // wins, subsequent ones get stripped (case-insensitive). Without + // this the merged card has every shared tag listed once per source + // card, which clutters the body and makes the rendered tag chips + // look duplicated to a casual reader (the server's extractTags also + // dedupes the tags column, but the body text itself is what the + // user actually edits). + const seenTags = new Set(); + mergedBody = mergedBody.replace(/(^|\s)#([a-zA-Z0-9_/-]+)/g, (match, lead, tag) => { + const key = tag.toLowerCase(); + if (seenTags.has(key)) return lead; // strip the dupe, keep the leading whitespace + seenTags.add(key); + return match; + }).replace(/[ \t]{2,}/g, ' ').replace(/\n{3,}/g, '\n\n'); // Snapshot for undo BEFORE we mutate anything const winnerPrev = { title: winner.title, body: winner.body || '' }; @@ -123,6 +137,7 @@ export function showContextMenu(x, y, card) { + ${typeof window._giteaMakeIssueFromCard === 'function' ? `` : ''}
@@ -306,6 +321,12 @@ export function showContextMenu(x, y, card) { await window._reloadAll(); } else if (a === 'save-md') { hideContextMenu(); window.location.href = `api/export-md?ids=${card.id}`; } + else if (a === 'make-ticket') { + hideContextMenu(); + // Gitea plugin pre-fills the create-issue form from this + // card and leaves it open — the user reviews and submits. + await window._giteaMakeIssueFromCard?.(card); + } else if (a === 'copy-text') { hideContextMenu(); let body = card.body || ''; diff --git a/public/plugins/collab-buckets/backend.php b/public/plugins/collab-buckets/backend.php index b3467b6..cd71d43 100644 --- a/public/plugins/collab-buckets/backend.php +++ b/public/plugins/collab-buckets/backend.php @@ -506,6 +506,13 @@ function collabSyncCards(PDO $db): array { // the delete back to the relay we snapshot the highest tombstone id // before apply and purge everything > that marker afterwards. $tombstoneMark = (int)$db->query('SELECT COALESCE(MAX(id), 0) FROM collab_tombstones')->fetchColumn(); + // Buckets that received changes from a foreign author. Used at the end + // of the apply phase to bump collab_buckets.updated_at, which the + // frontend's has_unread calculation reads — that's how a different + // bucket-bar tab gets the unread dot + 3-pulse when a peer pushes a + // card while the user is looking at something else. + $selfUser = collabRelaySettings()['user']; + $unreadBuckets = []; try { foreach ($incoming as $c) { // Resolve local bucket id @@ -514,6 +521,8 @@ function collabSyncCards(PDO $db): array { $localBucketId = $bid->fetchColumn(); if (!$localBucketId) continue; // bucket mirror missing — shouldn't happen + $foreignAuthor = ($c['author_username'] ?? '') !== $selfUser; + // Find local card by relay_uid — grab all fields so we can // stash a conflict copy if needed before overwriting $find = $db->prepare('SELECT id, title, body, color, tags, position, due_date, updated_at FROM cards WHERE relay_uid = :u'); @@ -525,6 +534,7 @@ function collabSyncCards(PDO $db): array { $db->prepare('DELETE FROM cards WHERE id = :id')->execute(['id' => $local['id']]); $db->prepare('DELETE FROM collab_card_state WHERE card_id = :id')->execute(['id' => $local['id']]); $stats['deleted_local']++; + if ($foreignAuthor) $unreadBuckets[$c['bucket_uid']] = true; } continue; } @@ -609,8 +619,21 @@ function collabSyncCards(PDO $db): array { 'r' => $c['updated_at'], ]); $stats['pulled']++; + if ($foreignAuthor) $unreadBuckets[$c['bucket_uid']] = true; } } finally { + // Bump `collab_buckets.updated_at` for every bucket that received + // changes from another user. The frontend's has_unread is + // `updated_at > last_seen_at`, so this is what triggers the unread + // dot + 3-pulse on the bucket-bar tab when the user is looking at + // something else. Skipped for the user's own pushes since the + // relay echoes those back too. + if ($unreadBuckets) { + $bumpStmt = $db->prepare("UPDATE collab_buckets SET updated_at = datetime('now') WHERE uid = :u"); + foreach (array_keys($unreadBuckets) as $u) { + $bumpStmt->execute(['u' => $u]); + } + } // Purge any tombstones the apply phase itself created. Tombstones // queued before apply (by actual user deletes) survive and get // drained on the next poll iteration. diff --git a/public/plugins/email/email.js b/public/plugins/email/email.js index 1378d6b..2ff040c 100644 --- a/public/plugins/email/email.js +++ b/public/plugins/email/email.js @@ -877,8 +877,42 @@ function init() { $('email-overlay').addEventListener('click', e => { if (e.target === $('email-overlay')) closeEmail(); }); - $('email-overlay').addEventListener('keydown', e => { - if (e.key === 'Escape') { closeEmail(); e.stopPropagation(); } + // Document-level Escape: works regardless of which element is focused + // (the previous overlay-level keydown only fired when focus was inside + // the overlay, which is rarely the case after open). + document.addEventListener('keydown', e => { + if (e.key !== 'Escape') return; + if (!$('email-overlay')?.classList.contains('open')) return; + // Don't steal Escape from a sub-modal that lives over the overlay + const dialog = document.getElementById('dialog'); + if (dialog?.classList.contains('open')) return; + e.preventDefault(); + e.stopPropagation(); + closeEmail(); + }); + // Document-level "m" toggles the mail overlay. Suppressed when the + // user is typing into an input / textarea / contenteditable so it + // doesn't intercept normal text entry. Also suppressed if any modal + // overlay is open (editor, search, settings, …) so we don't fight + // those for the hotkey. + document.addEventListener('keydown', e => { + if (e.key !== 'm' && e.key !== 'M') return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + const ae = document.activeElement; + if (ae) { + if (ae.isContentEditable) return; + const tag = ae.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + } + // Don't toggle while another full-screen overlay is in front + const blockers = ['modal', 'detail', 'search', 'settings-overlay', 'gallery', 'help', 'calendar', 'tasks', 'dialog']; + for (const id of blockers) { + if (document.getElementById(id)?.classList.contains('open')) return; + } + e.preventDefault(); + const overlay = $('email-overlay'); + if (overlay?.classList.contains('open')) closeEmail(); + else openEmail(); }); $('email-account-select').addEventListener('change', e => { diff --git a/public/plugins/gitea/gitea.js b/public/plugins/gitea/gitea.js index 1dce55b..95d4e4a 100644 --- a/public/plugins/gitea/gitea.js +++ b/public/plugins/gitea/gitea.js @@ -1593,6 +1593,35 @@ if (document.readyState === 'loading') { init(); } +/** + * Open the gitea overlay, switch to the create-issue form and pre-fill + * title + body from a hyprcrd card. Does NOT submit — the user reviews + * and clicks Submit themselves. Called from the card right-click menu's + * "Make ticket" action (see public/js/modules/context-menu.js). + */ +window._giteaMakeIssueFromCard = async function (card) { + if (!card) return; + // Body might not be loaded in state.cards (lazy field) — fetch it. + let body = card.body; + if (body == null) { + try { + const all = await fetch(`api/cards?ids=${card.id}`).then(r => r.json()); + const fresh = all.find(c => c.id == card.id); + body = fresh?.body || ''; + } catch { body = ''; } + } + await openGitea(); + if (accounts.length === 0) return; // welcome view — user must configure first + await openCreateForm(); + const titleInput = $('gitea-create-title'); + const bodyInput = $('gitea-create-body'); + if (titleInput) titleInput.value = card.title || ''; + if (bodyInput) bodyInput.value = body || ''; + // Cursor on the repo field so the user can pick the destination + // first. Title + body are already filled. + $('gitea-create-repo')?.focus(); +}; + hyprcrd.registerPlugin({ name: 'gitea', version: '0.1.0', diff --git a/public/plugins/tests/context-menu.test.js b/public/plugins/tests/context-menu.test.js index 19b98db..f0e2d5f 100644 --- a/public/plugins/tests/context-menu.test.js +++ b/public/plugins/tests/context-menu.test.js @@ -130,3 +130,86 @@ describe('Context Menu', () => { } }); }); + +describe('Card: Make ticket (gitea)', () => { + afterEach(async () => { + // Always close the gitea overlay so subsequent tests aren't + // running on top of it + document.getElementById('gitea-overlay')?.classList.remove('open'); + await sleep(50); + }); + + it('action is exposed when the gitea plugin is loaded', () => { + // The make-ticket entry only renders when the plugin exposes + // _giteaMakeIssueFromCard. If the plugin isn't loaded in this + // build, treat as a soft skip — nothing to test. + if (typeof window._giteaMakeIssueFromCard !== 'function') return; + const card = $('#board [data-id]'); + if (!card) return; + rightClick(card); + // Use sleep here, the menu render is synchronous after the dispatch + const actions = $$('#ctx-items [data-action]').map(b => b.dataset.action); + expect(actions).toContain('make-ticket'); + }); + + it('clicking make-ticket pre-fills the gitea create form (no submit)', async () => { + if (typeof window._giteaMakeIssueFromCard !== 'function') return; + + const title = '__test_make_ticket_title__'; + const body = 'a body that should land in the gitea form'; + const res = await fetch('api/cards', { + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, + body: JSON.stringify({ title, body, color: '#38bdf8' }), + }).then(r => r.json()); + await window._reloadAll?.(); + await sleep(200); + + const cardEl = document.querySelector(`[data-id="${res.id}"]`); + if (!cardEl) return; + rightClick(cardEl); + await sleep(150); + const btn = $$('#ctx-items [data-action="make-ticket"]')[0]; + expect(btn).toExist(); + click(btn); + + // openGitea + openCreateForm involve account fetch + repo fetch. + // Wait until the gitea overlay is open OR a reasonable timeout + // passes. Without a configured gitea account the welcome view + // shows instead — that's a soft skip since we can't test the + // pre-fill in that case. + let overlay = null; + for (let i = 0; i < 60; i++) { + overlay = document.getElementById('gitea-overlay'); + if (overlay?.classList.contains('open')) break; + await sleep(50); + } + if (!overlay?.classList.contains('open')) return; + + const welcome = document.getElementById('gitea-view-welcome'); + if (welcome && !welcome.classList.contains('hidden')) { + // No gitea accounts configured — function returns early, + // form never renders, can't test the pre-fill path. + return; + } + + // Wait until openCreateForm has finished its async repo load + // and surfaced the form (it's hidden by default). + let form = null; + for (let i = 0; i < 60; i++) { + form = document.getElementById('gitea-create-form'); + if (form && !form.classList.contains('hidden')) break; + await sleep(50); + } + expect(form && !form.classList.contains('hidden')).toBe(true); + + // The form must NOT auto-submit — the title and body should be + // populated and the form still visible afterwards. + const titleInput = document.getElementById('gitea-create-title'); + const bodyInput = document.getElementById('gitea-create-body'); + expect(titleInput?.value).toBe(title); + expect(bodyInput?.value).toBe(body); + // Issues list / detail view should NOT be the foreground + const detail = document.getElementById('gitea-detail'); + expect(detail?.classList.contains('hidden')).toBe(true); + }); +}); diff --git a/public/plugins/tests/runner.js b/public/plugins/tests/runner.js index 0ae934e..503141f 100644 --- a/public/plugins/tests/runner.js +++ b/public/plugins/tests/runner.js @@ -241,38 +241,48 @@ function hideProgress() { document.body.style.height = ''; } -async function runAll() { - const results = []; - const total = suites.reduce((n, s) => n + s.tests.length, 0); - // Initial label before any test has started - updateProgress(0, total, 'starting…', false); +/** + * 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; - for (const suite of suites) { - const suiteResult = { name: suite.name, tests: [] }; - for (const test of suite.tests) { - 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); - suiteResult.tests.push(entry); + 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 {} } - results.push(suiteResult); + 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); } hideProgress(); - return results; + return [...suiteMap.values()]; } // ── UI Panel ── @@ -313,7 +323,7 @@ function createPanel() { 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 total = 0, passed = 0, failed = 0, flaky = 0, totalTime = 0; let html = ''; for (const suite of results) { @@ -322,20 +332,33 @@ function renderResults(results) { for (const t of suite.tests) { total++; totalTime += t.time; - if (t.status === 'pass') { + 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++; - html += `
✗ ${esc(t.name)} ${t.time}ms
`; + const retryNote = t.retried ? ' (failed twice)' : ''; + html += `
✗ ${esc(t.name)} ${t.time}ms${retryNote}
`; html += `
${esc(t.error)}
`; } } html += ``; } output.innerHTML = html; - summary.textContent = `${passed}/${total} passed, ${failed} failed — ${totalTime}ms`; - summary.style.color = failed ? '#fb7185' : '#4ade80'; + 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, '>'); } @@ -366,6 +389,42 @@ async function run() { // 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. Clean up any leftover UI state await closeAll();