diff --git a/main.js b/main.js
index 0a0fa002..26d79232 100644
--- a/main.js
+++ b/main.js
@@ -8,6 +8,7 @@ const log = require('electron-log');
// getFolderIndexMtimeMs moved to session-cache.js
const { startMcpServer, shutdownMcpServer, shutdownAll: shutdownAllMcp, resolvePendingDiff, rekeyMcpServer, cleanStaleLockFiles } = require('./mcp-bridge');
const { fetchAndTransformUsage } = require('./claude-auth');
+const { runStatsCommand, refreshStatsCache } = require('./stats-refresh');
const { readHead } = require('./jsonl-scan');
const codexAuth = require('./codex-auth');
@@ -553,118 +554,37 @@ ipcMain.handle('get-stats', () => {
}
});
-// --- IPC: refresh-stats (run /stats + /usage via PTY) ---
+// --- IPC: refresh-stats ---
+let statsRefreshInFlight = null;
ipcMain.handle('refresh-stats', async () => {
- // For stats, use the configured shell profile
- const globalSettings = getSetting('global') || {};
- const statsProfileId = globalSettings.shellProfile || SETTING_DEFAULTS.shellProfile;
- const statsShellProfile = resolveShell(statsProfileId);
- const statsShell = statsShellProfile.path;
- const statsShellExtraArgs = statsShellProfile.args || [];
- const ptyEnv = {
- ...cleanPtyEnv,
- TERM: 'xterm-256color',
- COLORTERM: 'truecolor',
- TERM_PROGRAM: 'iTerm.app',
- TERM_PROGRAM_VERSION: '3.6.6',
- FORCE_COLOR: '3',
- ITERM_SESSION_ID: '1',
- };
-
- // Helper: spawn claude with args, collect output, auto-accept trust, kill when idle
- // waitFor: optional regex tested against stripped output — finish only when matched
- function runClaude(args, { timeoutMs = 15000, waitFor = null } = {}) {
- return new Promise((resolve) => {
- let output = '';
- let settled = false;
- let trustAccepted = false;
- // Track idle: ✳ in OSC title means Claude is idle and waiting for input
- let sawActivity = false;
-
- const finish = () => {
- if (settled) return;
- settled = true;
- try { p.kill(); } catch {}
- resolve(output);
- };
-
- const claudeCmd = `claude ${args}`;
- const p = pty.spawn(statsShell, shellArgs(statsShell, claudeCmd, statsShellExtraArgs), {
- name: 'xterm-256color',
- cols: 120,
- rows: 40,
- cwd: os.homedir(),
- env: ptyEnv,
- });
-
- const strip = (s) => s
- .replace(/\x1b\[[^@-~]*[@-~]/g, '')
- .replace(/\x1b\][^\x07]*\x07/g, '')
- .replace(/\x1b[^[\]].?/g, '');
-
- p.onData((data) => {
- output += data;
-
- // Auto-accept trust directory prompt (Enter selects "1. Yes")
- if (!trustAccepted) {
- if (/trust\s*this\s*folder/i.test(strip(output))) {
- trustAccepted = true;
- try { p.write('\r'); } catch {}
- return;
- }
- }
-
- // If waitFor is set, finish when that pattern appears in stripped output
- if (waitFor) {
- if (waitFor.test(strip(output))) {
- finish();
- }
- return;
- }
+ if (!harnessEnabled(DEFAULT_HARNESS)) return { stats: null, usage: {} };
+ if (statsRefreshInFlight) return statsRefreshInFlight;
- // Default: detect busy→idle transition via OSC title containing ✳
- if (!sawActivity) {
- const oscTitle = data.match(/\x1b\]0;([^\x07\x1b]*)/);
- if (oscTitle) {
- const first = oscTitle[1].charAt(0);
- if (first.charCodeAt(0) >= 0x2800 && first.charCodeAt(0) <= 0x28FF) {
- sawActivity = true;
- }
- }
- } else if (data.includes('\u2733')) {
- finish();
- }
+ statsRefreshInFlight = (async () => {
+ const usagePromise = fetchAndTransformUsage().catch(() => ({}));
+ const result = await refreshStatsCache(STATS_CACHE_PATH, () => {
+ const globalSettings = getSetting('global') || {};
+ const profile = resolveShell(globalSettings.shellProfile || SETTING_DEFAULTS.shellProfile);
+ return runStatsCommand({
+ spawn: (...args) => pty.spawn(...args),
+ shell: profile.path,
+ args: shellArgs(profile.path, 'claude "/stats"', profile.args || []),
+ options: {
+ name: 'xterm-256color', cols: 120, rows: 40, cwd: os.homedir(),
+ env: {
+ ...cleanPtyEnv,
+ TERM: 'xterm-256color', COLORTERM: 'truecolor',
+ TERM_PROGRAM: 'iTerm.app', TERM_PROGRAM_VERSION: '3.6.6',
+ FORCE_COLOR: '3', ITERM_SESSION_ID: '1',
+ },
+ },
});
-
- p.onExit(() => finish());
- setTimeout(finish, timeoutMs);
});
- }
-
- // A switched-off CLI is not spawned and not queried — the PTY run below is
- // the most expensive thing in the app to do for a CLI the user has hidden.
- if (!harnessEnabled(DEFAULT_HARNESS)) return { stats: null, usage: {} };
-
- try {
- // Run /stats via PTY (for heatmap/chart data) and fetch usage via API in parallel
- const [, usage] = await Promise.all([
- runClaude('"/stats"', { waitFor: /streak/i, timeoutMs: 10000 }),
- fetchAndTransformUsage().catch(() => ({})),
- ]);
-
- // Read refreshed stats cache
- let stats = null;
- try {
- if (fs.existsSync(STATS_CACHE_PATH)) {
- stats = JSON.parse(fs.readFileSync(STATS_CACHE_PATH, 'utf8'));
- }
- } catch {}
-
- return { stats, usage: usage || {} };
- } catch (err) {
- log.error('Error refreshing stats:', err);
- return { stats: null, usage: {} };
- }
+ if (result.statsError) log.warn('Error refreshing stats:', result.statsError);
+ return { ...result, usage: await usagePromise || {} };
+ })();
+ try { return await statsRefreshInFlight; }
+ finally { statsRefreshInFlight = null; }
});
// --- IPC: get-usage (lightweight, API-only, no PTY) ---
diff --git a/public/stats-view.js b/public/stats-view.js
index f8ebf377..941338fa 100644
--- a/public/stats-view.js
+++ b/public/stats-view.js
@@ -13,8 +13,8 @@ async function loadStats() {
spinner.innerHTML = `
Updating stats\u2026`;
statsViewerBody.appendChild(spinner);
- // Refresh stats cache via PTY (/stats + /usage)
- let stats, usage;
+ // Refresh the stats cache via PTY and fetch rate limits via API.
+ let stats, usage, statsError;
// Codex usage is a plain API read, so it runs alongside the Claude refresh
// (which spawns a PTY) rather than after it.
const codexPromise = (window.api.getCodexUsage?.() ?? Promise.resolve({})).catch(() => ({}));
@@ -22,12 +22,14 @@ async function loadStats() {
try {
const result = await window.api.refreshStats();
stats = result?.stats;
+ statsError = result?.statsError;
usage = result?.usage || {};
cachedUsage = usage;
} catch {
// Fallback to cached stats
- stats = await window.api.getStats();
+ stats = await window.api.getStats().catch(() => null);
usage = cachedUsage || {};
+ statsError = 'Could not refresh stats.';
}
let codexUsage = {};
@@ -36,10 +38,18 @@ async function loadStats() {
statsViewerBody.innerHTML = '';
+ if (statsError) {
+ const notice = document.createElement('div');
+ notice.className = 'stats-refresh-error';
+ notice.setAttribute('role', 'status');
+ notice.textContent = `Stats refresh failed. ${statsError}${stats ? ' Showing cached data.' : ''}`;
+ statsViewerBody.appendChild(notice);
+ }
+
// Codex counts here too — a Codex-only user has no Claude stats cache, and
// bailing on that alone would hide their rate limits entirely.
if (!stats && !Object.keys(usage).length && !Object.keys(codexUsage || {}).length) {
- statsViewerBody.innerHTML = 'No stats data found. Run some sessions first.
';
+ if (!statsError) statsViewerBody.innerHTML = 'No stats data found. Run some sessions first.
';
return;
}
diff --git a/public/style.css b/public/style.css
index c2a9e5df..c0e25f62 100644
--- a/public/style.css
+++ b/public/style.css
@@ -2038,6 +2038,16 @@ body { display: flex; flex-direction: column; }
font-size: 11px;
}
+.stats-refresh-error {
+ font-size: 12px;
+ color: #d6ad59;
+ background: rgba(255,200,50,0.06);
+ border: 1px solid rgba(255,200,50,0.18);
+ border-radius: 8px;
+ padding: 10px 14px;
+ margin-bottom: 20px;
+}
+
/* Stats spinner */
.stats-spinner {
display: flex;
diff --git a/stats-refresh.js b/stats-refresh.js
new file mode 100644
index 00000000..29d4563c
--- /dev/null
+++ b/stats-refresh.js
@@ -0,0 +1,118 @@
+const fs = require('node:fs');
+
+function stripTerminal(output) {
+ return output
+ .replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '')
+ // Ink positions words using absolute columns instead of literal spaces.
+ .replace(/\x1b\[[0-?]*[ -/]*([@-~])/g, (_, command) => /[GHf]/.test(command) ? ' ' : '')
+ .replace(/\x1b[^[\]].?/g, '');
+}
+
+function selectedTrustOption(output) {
+ const matches = [...output.matchAll(/[❯›>]\s*(?:\d+\.\s*)?(Yes|No)\b/gi)];
+ return matches.at(-1)?.[1].toLowerCase();
+}
+
+// Keep navigation and confirmation in separate writes. Wait for Claude to
+// render the selected Yes before pressing Enter, including on older CLIs that
+// already select Yes. Never confirm an unknown selection.
+function runStatsCommand({ spawn, shell, args, options, timeoutMs = 10000 }) {
+ return new Promise((resolve) => {
+ let p, timeout, inputTimer;
+ let output = '';
+ let settled = false;
+ let trustPrompt = false;
+ let moved = false;
+ let confirmed = false;
+ const subscriptions = [];
+
+ function finish(error = null) {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timeout);
+ clearTimeout(inputTimer);
+ for (const subscription of subscriptions) subscription?.dispose();
+ try { p?.kill(); } catch {}
+ resolve({ error });
+ }
+
+ function sendAfterRender(key, delay) {
+ inputTimer = setTimeout(() => {
+ inputTimer = null;
+ if (settled) return;
+ // Recheck in case another render changed the selection while waiting.
+ const selected = selectedTrustOption(stripTerminal(output));
+ if (key === '\r' ? selected !== 'yes' : selected !== 'no') return;
+ if (key === '\r') confirmed = true;
+ else moved = true;
+ output = '';
+ try { p.write(key); }
+ catch { finish('Could not confirm the folder trust prompt.'); }
+ }, delay);
+ }
+
+ try {
+ p = spawn(shell, args, options);
+ timeout = setTimeout(() => finish('Stats refresh timed out.'), timeoutMs);
+ subscriptions.push(p.onData((data) => {
+ if (settled) return;
+ output = (output + data).slice(-65536);
+ const text = stripTerminal(output);
+ if (!confirmed) {
+ if (/trust[^\r\n]*folder/i.test(text)) trustPrompt = true;
+ if (trustPrompt) {
+ const selected = selectedTrustOption(text);
+ if (!inputTimer) {
+ if (selected === 'no' && !moved) sendAfterRender('\x1b[B', 1000);
+ else if (selected === 'yes') sendAfterRender('\r', moved ? 100 : 1000);
+ }
+ return;
+ }
+ }
+ if (/streak/i.test(text)) finish();
+ }));
+ subscriptions.push(p.onExit(() => finish('Claude exited before stats finished.')));
+ } catch {
+ finish('Could not start Claude to refresh stats.');
+ }
+ });
+}
+
+function readStatsCache(cachePath) {
+ try {
+ const stats = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
+ if (!stats || typeof stats !== 'object' || Array.isArray(stats)) return null;
+ return { stats, mtimeMs: fs.statSync(cachePath).mtimeMs };
+ } catch {
+ return null;
+ }
+}
+
+async function refreshStatsCache(cachePath, run, now = () => new Date()) {
+ const before = readStatsCache(cachePath);
+ let error;
+ try { ({ error } = await run()); }
+ catch { error = 'Could not refresh stats.'; }
+ const after = readStatsCache(cachePath);
+
+ if (!error) {
+ if (!after) {
+ error = 'Stats refresh finished without a readable cache.';
+ } else {
+ // Claude caches completed UTC days; today's activity is computed in its
+ // UI. An unchanged cache through yesterday is normal on repeat refreshes.
+ const yesterday = new Date(now());
+ yesterday.setUTCDate(yesterday.getUTCDate() - 1);
+ const lastDate = after.stats.lastComputedDate;
+ const current = typeof lastDate === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(lastDate)
+ && lastDate >= yesterday.toISOString().slice(0, 10);
+ const advanced = !before || after.mtimeMs > before.mtimeMs
+ || (typeof lastDate === 'string' && lastDate > before.stats.lastComputedDate);
+ if (!current && !advanced) error = 'Stats cache did not advance after refreshing.';
+ }
+ }
+
+ return { stats: after?.stats || before?.stats || null, statsError: error || null };
+}
+
+module.exports = { runStatsCommand, refreshStatsCache };
diff --git a/test/stats-refresh.test.js b/test/stats-refresh.test.js
new file mode 100644
index 00000000..bf292880
--- /dev/null
+++ b/test/stats-refresh.test.js
@@ -0,0 +1,180 @@
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { runStatsCommand, refreshStatsCache } = require('../stats-refresh');
+
+function ptyFixture(t, { writeError = false, timeoutMs = 10000 } = {}) {
+ t.mock.timers.enable({ apis: ['setTimeout'] });
+ let onData, onExit, killed = 0;
+ const writes = [];
+ const result = runStatsCommand({
+ spawn: () => ({
+ write(value) {
+ if (writeError) throw new Error('closed');
+ writes.push(value);
+ },
+ kill() { killed++; },
+ onData(fn) { onData = fn; return { dispose() {} }; },
+ onExit(fn) { onExit = fn; return { dispose() {} }; },
+ }),
+ shell: '/bin/sh', args: ['-c', 'claude "/stats"'], options: {}, timeoutMs,
+ });
+ return { result, writes, data: value => onData(value), exit: code => onExit({ exitCode: code }), kills: () => killed };
+}
+
+// Includes the absolute-column escape sequences emitted by Claude 2.1.263.
+const noSelected = '\x1b[2GAccessing workspace:\r\n/fake-home\r\n'
+ + '\x1b[2G❯\x1b[4GNo,\x1b[8Gexit\r\n'
+ + '\x1b[4GYes,\x1b[9GI\x1b[11Gtrust\x1b[17Gthis\x1b[22Gfolder\r\n';
+const yesSelected = '\x1b[1D\x1b[4B\r\x1b[1C\x1b[4A No, exit\r'
+ + '\x1b[1C\x1b[1B\x1b[38;2;153;204;255m❯\x1b[4GYes, I trust this folder\x1b[39m\r\n';
+
+test('moves Down from No and only confirms after a fragmented Yes redraw', async t => {
+ const p = ptyFixture(t);
+ for (const byte of noSelected) p.data(byte);
+ t.mock.timers.tick(1000);
+ assert.deepEqual(p.writes, ['\x1b[B']);
+ // Old accumulated output or unrelated chunks must not trigger Enter.
+ p.data('\x1b[?2026h');
+ t.mock.timers.tick(1000);
+ assert.deepEqual(p.writes, ['\x1b[B']);
+ for (const byte of yesSelected) p.data(byte);
+ t.mock.timers.tick(100);
+ assert.deepEqual(p.writes, ['\x1b[B', '\r']);
+ p.data('Current\x1b[43Gstreak: 2 days');
+ assert.deepEqual(await p.result, { error: null });
+ t.mock.timers.tick(20000);
+ assert.equal(p.kills(), 1);
+ assert.equal(p.writes.length, 2);
+});
+
+test('preserves older numbered prompts that already highlight Yes', async t => {
+ const p = ptyFixture(t);
+ p.data('Do you trust this folder?\n❯ 1. Yes, proceed\n 2. No, exit\n');
+ t.mock.timers.tick(1000);
+ assert.deepEqual(p.writes, ['\r']);
+ p.data('Longest streak: 5 days');
+ assert.equal((await p.result).error, null);
+});
+
+test('an already trusted directory requires no keyboard input', async t => {
+ const p = ptyFixture(t);
+ p.data('Loading stats...\nCurrent streak: 2 days');
+ assert.equal((await p.result).error, null);
+ assert.deepEqual(p.writes, []);
+});
+
+test('does not accept a trust prompt whose selected option is unknown', async t => {
+ const p = ptyFixture(t);
+ p.data('Do you trust this folder?\nNo, exit\nYes, I trust this folder\n');
+ t.mock.timers.tick(10000);
+ assert.match((await p.result).error, /timed out/);
+ assert.deepEqual(p.writes, []);
+});
+
+test('times out instead of confirming when Down never produces a Yes selection', async t => {
+ const p = ptyFixture(t);
+ p.data(noSelected);
+ t.mock.timers.tick(1000);
+ p.data(noSelected);
+ t.mock.timers.tick(9000);
+ assert.match((await p.result).error, /timed out/);
+ assert.deepEqual(p.writes, ['\x1b[B']);
+});
+
+for (const exitCode of [0, 1, 127]) {
+ test(`exit ${exitCode} before stats output fails and cancels pending trust input`, async t => {
+ const p = ptyFixture(t);
+ p.data(noSelected);
+ p.exit(exitCode);
+ assert.match((await p.result).error, /exited/);
+ t.mock.timers.tick(20000);
+ p.data(yesSelected);
+ assert.deepEqual(p.writes, []);
+ assert.equal(p.kills(), 1);
+ });
+}
+
+test('reports a failed PTY write and stops the process', async t => {
+ const p = ptyFixture(t, { writeError: true });
+ p.data(noSelected);
+ t.mock.timers.tick(1000);
+ assert.match((await p.result).error, /trust prompt/);
+ assert.equal(p.kills(), 1);
+});
+
+test('reports a failed spawn', async () => {
+ const result = await runStatsCommand({ spawn() { throw new Error('ENOENT'); } });
+ assert.match(result.error, /Could not start/);
+});
+
+function cacheFixture(t, date = '2026-08-20') {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-stats-'));
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
+ const file = path.join(dir, 'stats-cache.json');
+ function write(lastComputedDate) {
+ fs.writeFileSync(file, JSON.stringify({ lastComputedDate, totalSessions: 1 }));
+ }
+ if (date) write(date);
+ return { file, write, now: () => new Date('2026-09-07T04:00:00Z') };
+}
+
+test('reports a completed command that leaves an old cache unchanged', async t => {
+ const c = cacheFixture(t);
+ const result = await refreshStatsCache(c.file, async () => ({ error: null }), c.now);
+ assert.match(result.statsError, /did not advance/);
+ assert.equal(result.stats.lastComputedDate, '2026-08-20');
+});
+
+test('accepts a cache date advancing even when the filesystem timestamp is unchanged', async t => {
+ const c = cacheFixture(t);
+ fs.utimesSync(c.file, 1000, 1000);
+ const result = await refreshStatsCache(c.file, async () => {
+ c.write('2026-09-06');
+ fs.utimesSync(c.file, 1000, 1000);
+ return { error: null };
+ }, c.now);
+ assert.equal(result.statsError, null);
+ assert.equal(result.stats.lastComputedDate, '2026-09-06');
+});
+
+test('an unchanged cache through yesterday is normal on a repeat refresh', async t => {
+ const c = cacheFixture(t, '2026-09-06');
+ const result = await refreshStatsCache(c.file, async () => ({ error: null }), c.now);
+ assert.equal(result.statsError, null);
+});
+
+test('a current cache does not hide a failed command', async t => {
+ const c = cacheFixture(t, '2026-09-06');
+ const result = await refreshStatsCache(c.file, async () => ({ error: 'Stats refresh timed out.' }), c.now);
+ assert.match(result.statsError, /timed out/);
+ assert.equal(result.stats.lastComputedDate, '2026-09-06');
+});
+
+test('keeps the last readable cache when the refreshed file is invalid', async t => {
+ const c = cacheFixture(t);
+ const result = await refreshStatsCache(c.file, async () => {
+ fs.writeFileSync(c.file, '{');
+ return { error: null };
+ }, c.now);
+ assert.match(result.statsError, /readable cache/);
+ assert.equal(result.stats.lastComputedDate, '2026-08-20');
+});
+
+test('reports a missing cache for a first-time user without discarding the error', async t => {
+ const c = cacheFixture(t, null);
+ const result = await refreshStatsCache(c.file, async () => ({ error: 'Claude exited before stats finished.' }), c.now);
+ assert.match(result.statsError, /exited/);
+ assert.equal(result.stats, null);
+});
+
+test('a first successful cache creation succeeds', async t => {
+ const c = cacheFixture(t, null);
+ const result = await refreshStatsCache(c.file, async () => {
+ c.write('2026-09-06');
+ return { error: null };
+ }, c.now);
+ assert.equal(result.statsError, null);
+});
diff --git a/test/stats-view.test.js b/test/stats-view.test.js
new file mode 100644
index 00000000..def5fdde
--- /dev/null
+++ b/test/stats-view.test.js
@@ -0,0 +1,77 @@
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const vm = require('node:vm');
+
+const source = fs.readFileSync(path.join(__dirname, '../public/stats-view.js'), 'utf8');
+
+async function render({ stats = null, statsError = null, usage = {}, codexUsage = {}, reject = false, rejectCache = false } = {}) {
+ const body = {
+ children: [], html: '',
+ set innerHTML(value) { this.html = value; this.children = []; },
+ get innerHTML() { return this.html; },
+ appendChild(child) { this.children.push(child); },
+ };
+ const rendered = { charts: [], usage: [] };
+ const context = {
+ document: { createElement: () => ({ setAttribute(name, value) { this[name] = value; } }) },
+ statsViewerBody: body,
+ escapeHtml: value => value,
+ window: { api: {
+ refreshStats: async () => {
+ if (reject) throw new Error('IPC unavailable');
+ return { stats, statsError, usage };
+ },
+ getStats: async () => {
+ if (rejectCache) throw new Error('cache unavailable');
+ return stats;
+ },
+ getCodexUsage: async () => codexUsage,
+ } },
+ };
+ vm.runInNewContext(source, context);
+ context.buildHeatmap = () => {};
+ context.buildDailyBarChart = value => rendered.charts.push(value);
+ context.buildStatsSummary = () => {};
+ context.buildUsageSection = (value, { runtime }) => rendered.usage.push({ runtime, value });
+ await context.loadStats();
+ return { body, rendered, error: body.children.find(child => child.className === 'stats-refresh-error') };
+}
+
+test('failed refresh keeps cached charts and both independent usage panels visible', async () => {
+ const stats = { lastComputedDate: '2026-08-20', dailyActivity: [] };
+ const { rendered, error } = await render({
+ stats, statsError: 'Stats cache did not advance after refreshing.',
+ usage: { session: 12 }, codexUsage: { limits: [{ percent: 20 }] },
+ });
+ assert.equal(rendered.charts[0], stats);
+ assert.deepEqual(rendered.usage.map(panel => panel.runtime), ['claude', 'codex']);
+ assert.match(error.textContent, /Stats refresh failed.*did not advance.*Showing cached data/);
+ assert.equal(error.role, 'status');
+});
+
+test('first-time refresh failure displays its error instead of the no-sessions empty state', async () => {
+ const { body, error } = await render({ statsError: 'Claude exited before stats finished.' });
+ assert.match(error.textContent, /Claude exited/);
+ assert.doesNotMatch(error.textContent, /Showing cached/);
+ assert.doesNotMatch(body.innerHTML, /Run some sessions first/);
+});
+
+test('successful repeat refresh displays no error', async () => {
+ const { error, rendered } = await render({ stats: { lastComputedDate: '2026-09-06' } });
+ assert.equal(error, undefined);
+ assert.equal(rendered.charts.length, 1);
+});
+
+test('IPC rejection displays a failure while falling back to cached charts', async () => {
+ const { error, rendered } = await render({ stats: { lastComputedDate: '2026-08-20' }, reject: true });
+ assert.match(error.textContent, /Could not refresh stats.*Showing cached data/);
+ assert.equal(rendered.charts.length, 1);
+});
+
+test('a failed cache fallback still clears the spinner and displays the error', async () => {
+ const { body, error } = await render({ reject: true, rejectCache: true });
+ assert.match(error.textContent, /Could not refresh stats/);
+ assert.equal(body.children.some(child => child.className === 'stats-spinner'), false);
+});