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 += `