From 0f10c7f755db0981b058de20b50558cee4a48e4a Mon Sep 17 00:00:00 2001 From: Mickael Couzinet Date: Tue, 22 Sep 2026 19:02:14 +0200 Subject: [PATCH] claude-usage: the footer and the histogram render again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `render()` escaped the footer with `esc()`, but the widget defines no such function: the helper lives on the bridge, as `Fremkit.esc`. So the reference threw on every render — once a second, plus once per channel answer — and everything the line stands in front of never reached the tile: today's tokens, the sessions, the cost, and the hourly histogram under them. The gauges above it drew, which is why the widget looked merely quiet rather than broken. The widget is now evaluated for real in a test, against the stubs the host provides, so a render that throws fails the suite rather than leaving half a tile empty. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 3 + server/test/claude-usage-widget.test.ts | 97 +++++++++++++++++++++++++ widgets/claude-usage/index.html | 2 +- widgets/claude-usage/manifest.json | 2 +- 4 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 server/test/claude-usage-widget.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 53e746a..23261cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,9 @@ Anything that changes what the dashboard, the admin or a widget can do gets a li sessions after 30 minutes (on by default), and group the board by application. ### Fixed +- The Claude limits widget shows today's tokens and its hourly histogram again. It reached for the + bridge's escaping helper by the wrong name, so every render threw where the footer begins and + everything under the gauges stayed empty (@mcouzinet, #5). - Spotify's progress bar moves: on a Mac whose number format uses a decimal comma the position read as nothing, and the bar sat at zero. It now glides across each second instead of stepping, lands at once on a seek, a new track or a pause, and empties when Spotify closes (@mcouzinet, #4). diff --git a/server/test/claude-usage-widget.test.ts b/server/test/claude-usage-widget.test.ts new file mode 100644 index 0000000..27e5a56 --- /dev/null +++ b/server/test/claude-usage-widget.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' + +/** + * The Claude limits widget drawn for real. + * + * The widget is a plain browser script in an HTML file, so it is evaluated here against stubs and + * then handed one answer per channel, exactly as the host delivers them. A render that throws + * leaves the nodes it had not reached yet empty, which is what this watches: the footer and the + * histogram sit at the end of `render()`, after everything a reader actually notices. + */ +interface StubNode { innerHTML: string; textContent: string; hidden: boolean } + +interface Loaded { + /** The elements the widget asked for by id, so a test can read what it put in them. */ + nodes: Map + /** Hands the widget one answer from a channel, as the host delivers it. */ + publish(channel: string, data: Record): void +} + +async function loadWidget(): Promise { + const html = await readFile(fileURLToPath(new URL('../../widgets/claude-usage/index.html', import.meta.url)), 'utf8') + const script = [...html.matchAll(/]*>([\s\S]*?)<\/script>/g)].map((m) => m[1]).join('\n') + + const subscribers = new Map void)[]>() + const node = (): StubNode => ({ + innerHTML: '', textContent: '', hidden: false, + classList: { add: () => {}, remove: () => {}, toggle: () => {} }, + style: { setProperty: () => {} }, + } as StubNode) + // By id, and the same object every time: a fresh stub per call would forget every write. + const nodes = new Map() + const byId = (id: string): StubNode => { + if (!nodes.has(id)) nodes.set(id, node()) + return nodes.get(id)! + } + const document = { + documentElement: { lang: '', style: { setProperty: () => {} } }, + getElementById: byId, + } + const Fremkit = { + locale: 'fr', + compact: false, + size: { h: 4, px: { width: 640, height: 160 } }, + whenReady: (cb: () => void) => cb(), + subscribe: (channel: string, cb: (data: unknown) => void) => { + subscribers.set(channel, [...(subscribers.get(channel) ?? []), cb]) + return () => {} + }, + onLocale: () => {}, + onResize: () => {}, + t: (dict: Record, params?: Record) => + Object.entries(params ?? {}).reduce((text, [k, v]) => text.replace('{' + k + '}', String(v)), dict.fr), + // The bridge's own helper, which is what the widget must reach it through: it is passed by + // reference to `map`, so it may not lean on `this`. + esc: (value: unknown) => String(value ?? '').replace(/[&<>"']/g, (c) => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c), + } + // The widget redraws itself every second for the countdowns; a test wants the renders it asks for. + new Function('document', 'Fremkit', 'setInterval', script)(document, Fremkit, () => 0) + return { + nodes, + publish: (channel, data) => (subscribers.get(channel) ?? []).forEach((cb) => cb(data)), + } +} + +const today = { + messages: 12, input: 1000, output: 2000, cacheRead: 500, cacheWrite: 500, + sessions: 2, costUsd: 1.25, hourly: new Array(24).fill(0), +} + +describe('the claude-usage widget', () => { + it('puts the day in its footer', async () => { + const loaded = await loadWidget() + loaded.publish('claude-usage', { fiveHour: { pct: 10, resetsAt: 0 }, sevenDay: { pct: 20, resetsAt: 0 }, today, updatedAt: Date.now() }) + expect(loaded.nodes.get('foot')?.innerHTML).toContain('aujourd’hui') + expect(loaded.nodes.get('foot')?.innerHTML).toContain('4 k tokens') + }) + + it('escapes what the account names, since a limit name is not ours', async () => { + const loaded = await loadWidget() + loaded.publish('claude-account', { + available: true, stale: false, + limits: [{ name: 'Opus', percent: 40, resetsAt: 0 }], + breakdown: [{ name: '', percent: 30 }], + }) + expect(loaded.nodes.get('foot')?.innerHTML).toContain('<img src=x>') + }) + + it('draws the histogram that follows the footer', async () => { + const loaded = await loadWidget() + loaded.publish('claude-usage', { fiveHour: null, sevenDay: null, today: { ...today, hourly: new Array(24).fill(1) }, updatedAt: Date.now() }) + expect(loaded.nodes.get('histrow')?.hidden).toBe(false) + expect(loaded.nodes.get('hist')?.innerHTML).toContain('height:100%') + }) +}) diff --git a/widgets/claude-usage/index.html b/widgets/claude-usage/index.html index 3bd36f6..ccff4f5 100644 --- a/widgets/claude-usage/index.html +++ b/widgets/claude-usage/index.html @@ -130,7 +130,7 @@ parts.push(Fremkit.t(L.today, { tokens: tokens(total), sessions: sessions }) + (t.costUsd ? Fremkit.t(L.cost, { amount: dec(t.costUsd, 2) }) : '')) } - foot.innerHTML = parts.map(esc).join('
') + foot.innerHTML = parts.map(Fremkit.esc).join('
') var histrow = document.getElementById('histrow'), hist = document.getElementById('hist'), histlbl = document.getElementById('histlbl') if (data && data.today && data.today.hourly && Fremkit.size.h >= 4) { histrow.hidden = false diff --git a/widgets/claude-usage/manifest.json b/widgets/claude-usage/manifest.json index 8d780c2..3ef440d 100644 --- a/widgets/claude-usage/manifest.json +++ b/widgets/claude-usage/manifest.json @@ -1,6 +1,6 @@ { "id": "claude-usage", "name": { "fr": "Quotas Claude", "en": "Claude limits" }, - "version": "1.1.0", + "version": "1.1.1", "description": { "fr": "Quotas 5 h / 7 jours et tokens du jour", "en": "5 h / 7 day limits and today’s tokens" }, "category": "ai", "icon": "gauge", "minSize": [16, 4], "defaultSize": [16, 4], "compact": { "width": 9 },