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 },