Files
magpie/internal/gui/tests/usage-ledger-chart.test.cjs
T
c0f3fa012a usage: combine gateway and local session requests with model charts and account labels (#239)
* usage: Requests lists the agents' own session-file calls, put to the vendor and account their files prove, charts them by provider, agent or model, shows what was said, and the tray panel gets a Usage tab

The Usage page's Requests tab now covers requests an agent sends with its own sign-in (Claude Desktop's Code tab and Cowork, Claude Code, Codex), which never pass the gateway, and shows more of every request: why it failed, what was said in it, who it was of.

Requests tab: a strip of four totals (tokens, requests, cost, cache hit rate); the trend of one metric — tokens, cost or requests — as columns by the hour, day or week, told apart by provider, agent or model (both remembered), beside a ranking of the same that is its legend and a way in (a click lists only that provider's or agent's requests; a provider picker sits beside the agent's). The ranking is as tall as the chart and scrolls in itself, with no scrollbar: a slim rail in the card's margin shows how much there is and where it is (its thumb dragged, the rail clicked), and the edge with more beyond it fades; under the chart (860px and narrower) it shows whole. A row opens to its details: the vendor's error type and words, request id, endpoint, and what was said in it (input and output, read from the agent's session file when opened, the call that ended nearest the request — nothing stored). A refresh period (off / 5 s / 10 s / 30 s / 1 min) and refresh-now. The Overview says it counts gateway calls only; Requests and the panel say they count both. The tray panel gets a Usage tab (today / 7 / 30 days, provider picker, four totals, a small chart, top five, "Open Usage"), and is as tall as its tallest tab (#124).

Provider and account of a session-file call: never magpie's "claude" or "codex" subscription. Anthropic's when Claude Desktop's session metadata (not Claude-3p) names the account and organization, with that account's email; OpenAI's when Codex's rollout names a provider that is OpenAI's in config.toml — the built-in "openai", or a named one at OpenAI's address or signed in with the ChatGPT account and no base_url (requires_openai_auth) — with the creator account's email when the file records it. Anything else is "Local session", its recorded provider id and session account kept (CSV: session_provider, session_account). The config read is today's: a file names the provider id only.

Gateway: a failed call keeps the vendor's error text and type, request id and endpoint; a Claude subscription's failure uses the status Claude Code reports (api_error_status). A request turned away before any vendor was asked is logged, marked rejected, listed in Requests and left out of every total. The client's own session header is kept as native_session beside X-Magpie-Session, and a session-file call is matched to a gateway record by request id, then by that session, equal tokens and an end within 2 s, only when unique both ways.

Reading: session-file calls live in the session index main keeps on disk, parsed incrementally and outside its lock (Claude Code, Cowork in both Claude folders, Codex); the usage log is read on from where it was; a page builds the ledger once. Days are counted on calendar dates (DST). CSV gains request_id, endpoint, error_message, error_type, source, session_provider, session_account. i18n: one "Endpoint" (终结点), which a second key had turned for Azure's field.

Tested: go vet ./..., go build (also GOOS=linux/windows, -tags nogui) and go test -tags nogui ./... pass. The whole GUI suite in Chromium and WebKit, en and zh (Playwright 1.62.1): all 393 tests pass. On this computer's files (7 days): 3,330 Claude Desktop calls resolve to Anthropic with the account, 1,991 Codex calls on a named ChatGPT-signed-in provider to OpenAI (620 with the account; older rollouts don't record it), 9 Claude Code CLI calls stay Local session.

Co-authored-by: yetone <yetoneful@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* usage: a session-file call of one of magpie's own Codex or Claude accounts is told as that provider, beside the gateway's calls on it — "OpenAI" and "Codex" (or "Anthropic" and "Claude Code") read as two vendors when they were one subscription

Codex: an official route (above) whose creator account and user are one of magpie's Codex sign-ins (the CLI's own or a saved one) is magpie's "codex", with that account's email as the gateway's rows have it. Claude Desktop (not Claude-3p): an account and organization that are one of magpie's Claude sign-ins is magpie's "claude". Only while magpie has that provider signed in; another account, a rollout with no account recorded, or an ambiguous workspace stays OpenAI or Anthropic, and a relay stays Local session.

Tested: TestSessionCallsOfMagpiesAccountsAreItsProviders (the account matched, another account, none recorded, magpie's account through a relay, Desktop in magpie's and another organization, Claude-3p, magpie signed out) and TestLedgerTellsCodexSessionCallsAsMagpiesCodex (a gateway Codex call and a session call of its account one provider of two calls, another account's OpenAI). On this computer (7 days): 3,439 Claude Desktop calls are magpie's Claude, 626 Codex calls its Codex, 1,322 older Codex rollouts with no account stay OpenAI. go test -tags nogui ./... and the usage GUI tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: 修复请求统计缓存膨胀与会话账号误归属 #239

调用明细改为按会话文件分片保存并限制内存缓存,汇总索引仅保留统计数据;网关日志按需读取。

严格匹配订阅账号与用户 ID,排除自定义渠道、非官方端点覆盖、外部 CODEX_HOME 及关闭的供应商。补齐零 token 失败去重、Codex 原始会话 ID、拒绝标记导出和面板显示修复。

通过全量 Go 测试、静态检查、受影响模块的并发检查、52 项浏览器测试及 macOS/Linux/Windows 构建;使用 35 万条合成调用验证索引体积与内存改善。

* fix: 降低请求统计刷新与写入开销并明确本地账号展示 #239

请求分片改为追加帧和字符串去重,分页复用未变化的紧凑快照,采用 LRU 并移出全局锁内的磁盘读取。缓存 Cowork 目录,补齐同前缀改写检测及崩溃临时文件清理。

本地记录仅显示匹配账号或本地会话,明确官方登录标记与请求路由的区别。默认今日与模型维度,Auto Review 复用共享目录定价并单独标注估价参考。

完整 Go 测试、vet、相关 race 和 52 项浏览器回归通过;提供 2000 文件及四个大会话连续追加的可复现性能对照。

* fix: 优化用量徽标与耗时分级并保护分页边界 #239

官方登录、API、订阅和本地会话采用独立颜色与柔和底色,兼容明暗主题。耗时按 10 秒及 30 秒阈值着色,保留估算标识与原始毫秒提示;无有效耗时显示短横线。

限制分页选取范围,避免超大偏移整数溢出及越界空页的无效堆构造。补充阈值、徽标对比度和极端分页参数回归。

* fix: 将账号与来源徽标分为两行展示 #239

* fix: 提高用量表格模型文字对比度 #239

发送模型和已知实际模型使用主题正文次级色,表头与时间同步加深。保留缺失值弱化、转发提示和本地日志来源说明。Chromium 与 WebKit 的中英文、浅深色主题 10 项界面检查通过。

---------

Co-authored-by: yetone <yetoneful@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 17:42:10 +08:00

397 lines
29 KiB
JavaScript

// Run with Node's test runner and Playwright on the module path; see README.md.
// The Requests tab opens on what its requests add up to: a strip of four totals
// (tokens, requests, cost, the cache hit rate), then the trend of one metric —
// tokens, cost or requests — as columns by the hour, day or week, each told
// apart by provider, agent or model, beside a ranking of the same that is the
// chart's legend and a way in: a click on a provider or an agent lists only its
// requests, the provider picker beside the agent's does too, and the ranking
// keeps the others in sight to switch to. The pointer over a column shows what
// each had of it; over a ranked one, the others in the chart fade. The metric
// and what it is split by are remembered; a click moves nothing; the chart fits
// at every width and follows the window; with no request listed there is
// nothing. English and Chinese, light and dark; no backend, the API is faked.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const midnight = new Date();
midnight.setHours(0, 0, 0, 0);
// three providers over a day by the hour: who called them, and on what model
const WHO = [
{ id: "relay", name: "Relay", icon: "generic", agent: "codex", model: "gpt-6-sol", hours: [7, 16], per: { calls: 3, tokens: 400000, cost: 0.4 } },
{ id: "anthropic", name: "Claude", icon: "claudecode-color", agent: "claude", model: "claude-sonnet-5", hours: [13, 20], per: { calls: 2, tokens: 900000, cost: 0.9 } },
{ id: "codex", name: "Codex", icon: "codex-color", agent: "codex", model: "gpt-6-luna", hours: [19, 23], per: { calls: 4, tokens: 200000, cost: 0.1 } },
];
const AGENTS = { codex: { name: "Codex", icon: "codex-color" }, claude: { name: "Claude Code", icon: "claudecode-color" } };
const inHour = (w, h) => (h >= w.hours[0] && h <= w.hours[1] ? 1 : 0);
const SERIES = Array.from({ length: 24 }, (_, h) => {
const by = { provider: {}, agent: {}, model: {} };
let calls = 0, tokens = 0, cost = 0;
for (const w of WHO) {
if (!inHour(w, h)) continue;
const part = { calls: w.per.calls, tokens: w.per.tokens, cost: w.per.cost };
for (const [dim, key] of [["provider", w.id], ["agent", w.agent], ["model", w.model]]) {
const o = by[dim][key] || { calls: 0, tokens: 0, cost: 0 };
by[dim][key] = { calls: o.calls + part.calls, tokens: o.tokens + part.tokens, cost: o.cost + part.cost };
}
calls += part.calls; tokens += part.tokens; cost += part.cost;
}
return { label: String(h).padStart(2, "0"), time: new Date(midnight.getTime() + h * 3600e3).toISOString(), calls, errors: 0, input: tokens * 0.01, output: tokens * 0.005, cache_write: tokens * 0.05, cache_read: tokens * 0.935, cost, by };
});
const share = (id, name, icon, calls, tokens, cost, errors = 0) => ({ id, name, icon, calls, errors, input: tokens * 0.01, output: tokens * 0.005, cache_write: tokens * 0.05, cache_read: tokens * 0.935, cost });
const sumOf = (pick) => SERIES.reduce((a, p) => { for (const [k, v] of Object.entries(pick(p))) { a[k] = a[k] || { calls: 0, tokens: 0, cost: 0 }; a[k].calls += v.calls; a[k].tokens += v.tokens; a[k].cost += v.cost; } return a; }, {});
const BY = {
provider: Object.entries(sumOf((p) => p.by.provider)).map(([id, x]) => share(id, WHO.find((w) => w.id === id).name, WHO.find((w) => w.id === id).icon, x.calls, x.tokens, x.cost, id === "relay" ? 3 : 0)),
agent: Object.entries(sumOf((p) => p.by.agent)).map(([id, x]) => share(id, AGENTS[id].name, AGENTS[id].icon, x.calls, x.tokens, x.cost)),
model: Object.entries(sumOf((p) => p.by.model)).map(([id, x]) => share(id, id, "", x.calls, x.tokens, x.cost)),
};
for (const k in BY) BY[k].sort((a, b) => b.input + b.output + b.cache_read + b.cache_write - (a.input + a.output + a.cache_read + a.cache_write));
const sum = (k) => SERIES.reduce((a, p) => a + p[k], 0);
const TOTALS = { calls: sum("calls"), errors: 3, input: sum("input"), output: sum("output"), cache_write: sum("cache_write"), cache_read: sum("cache_read"), reasoning: 0, cost: sum("cost"), unpriced: 0 };
const allTokens = TOTALS.input + TOTALS.output + TOTALS.cache_write + TOTALS.cache_read;
const ROWS = [{ t: new Date().toISOString(), agent: "claude", agentName: "Claude Code", icon: "claudecode-color", provider: "anthropic", providerName: "Claude", model: "claude-sonnet-5", req: "sonnet", in: 2, out: 600, cache_write: 900, cache_read: 390000, ms: 2380, status: 200, cost: 0.087, priced: true }];
function page(q, variant) {
const none = variant === "none";
const noPrice = variant === "unpriced";
// more providers than the chart's height holds in its ranking
const many = variant === "many" ? Array.from({ length: 9 }, (_, i) => share("p" + i, "Provider " + i, "generic", 90 - i, 9e6 - i * 8e5, 9 - i, i % 3 ? 0 : 2)) : null;
const series = SERIES.map((p) => (noPrice ? { ...p, cost: 0, by: { ...p.by, provider: Object.fromEntries(Object.entries(p.by.provider).map(([k, v]) => [k, { ...v, cost: 0 }])) } } : p));
return {
period: "today", rows: none ? [] : ROWS, offset: 0, total: none ? 0 : ROWS.length, ...TOTALS, ...(none ? { calls: 0, errors: 0, input: 0, output: 0, cache_write: 0, cache_read: 0, cost: 0 } : {}),
...(noPrice ? { cost: 0, unpriced: 40 } : {}),
bucket: "hour", series: none ? [] : series, by: none ? { provider: [], agent: [], model: [] } : many ? { ...BY, provider: many } : BY,
agents: Object.entries(AGENTS).map(([id, a]) => ({ id, ...a })), providers: (many || WHO).map((w) => ({ id: w.id, name: w.name, icon: w.icon })),
};
}
function server(lang, theme, variant, asked) {
return async (route) => {
const req = route.request(), url = new URL(req.url());
const json = (data) => route.fulfill({ json: data });
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"${theme}",web:false};` });
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme }, fx: { rate: 7.2, at: new Date().toISOString() } });
if (url.pathname === "/api/usage/requests") { asked.push(url.searchParams); return json(page(url.searchParams, variant)); }
if (url.pathname === "/api/usage/quotas") return json([]);
if (url.pathname === "/api/usage") return json({ calls: 1, errors: 0, input: 1, output: 1, cache_read: 0, cache_write: 0, reasoning: 0, unpriced: 0, cost: 1, bucket: "day", series: [], agents: [], models: [], path: "~/.config/magpie/usage.jsonl" });
if (url.pathname === "/api/sessions") return json({ sessions: [], dirs: [] });
if (url.pathname === "/api/sessions/stats") return json({ from: "", to: "", days: [], agents: {} });
if (url.pathname === "/api/groups") return json({ groups: [], models: [] });
if (url.pathname.startsWith("/api/")) return json({});
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
await route.fulfill({ body: await fs.readFile(file), contentType });
};
}
const L = {
en: { strip: ["Tokens", "Requests", "Cost", "Cache hit rate"], metric: ["Tokens", "Cost", "Requests"], split: ["Model", "Provider", "Agent"], all: "All providers", none: "No known price for these requests" },
zh: { strip: ["Token", "请求", "费用", "缓存命中率"], metric: ["Token", "费用", "请求"], split: ["模型", "供应商", "Agent"], all: "全部供应商", none: "这些请求没有已知价格" },
};
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
test(engine + ": the Requests tab's totals and trend", async (t) => {
assert(["chromium", "webkit"].includes(engine), "BROWSER must be chromium or webkit");
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
t.after(() => browser.close());
const shots = process.env.ARTIFACT_DIR;
if (shots) await fs.mkdir(shots, { recursive: true });
const open = async (lang, theme, { width = 1180, variant = "", ctx } = {}) => {
const errors = [], asked = [];
const context = ctx || (await browser.newContext({ viewport: { width, height: 760 }, reducedMotion: "reduce" }));
const p = await context.newPage();
p.setDefaultTimeout(5000);
p.on("pageerror", (e) => errors.push(e.message));
await p.route("**/*", server(lang, theme, variant, asked));
await p.goto("http://magpie.test/");
await p.locator('[data-view="usage"]').first().click();
await p.locator("#usageTab .opt").nth(1).click();
if (variant !== "none") await p.locator("#ledRank .rk").first().waitFor();
if (["many", "unpriced"].includes(variant)) await p.locator("#ledSplit .opt").nth(1).click();
return { p, errors, asked, context };
};
const lastAsked = async (asked, want) => {
for (let i = 0; i < 60 && !(asked.length && want(asked.at(-1))); i++) await new Promise((r) => setTimeout(r, 40));
assert(asked.length && want(asked.at(-1)), "asked: " + (asked.at(-1) || ""));
};
const names = (p) => p.locator("#ledRank .rk .rk-nm").allTextContents();
for (const lang of ["en", "zh"]) {
const w = L[lang];
await t.test(lang, async () => {
const { p, errors, asked } = await open(lang, "light");
// the strip of four totals
assert.deepEqual(await p.locator("#ledKpi .k").allTextContents(), w.strip);
assert.equal(await p.locator("#ledKpi .blk").nth(0).getAttribute("title"), Math.round(allTokens).toLocaleString(lang === "zh" ? "zh-CN" : "en"));
const rate = TOTALS.cache_read / (TOTALS.input + TOTALS.cache_write + TOTALS.cache_read);
assert.equal(await p.locator("#ledKpi .blk").nth(3).locator(".v").textContent(), (100 * rate).toFixed(1) + "%");
assert.equal(parseFloat(await p.locator("#ledKpi .blk").nth(3).locator(".meter i").evaluate((i) => i.style.width)), parseFloat((100 * rate).toFixed(1)));
assert.equal(await p.locator("#ledKpi .blk").nth(1).locator(".v").textContent(), String(TOTALS.calls));
// the two switches, and the ranking by the most tokens: Claude, Relay, Codex
assert.deepEqual(await p.locator("#ledMetric .opt").allTextContents(), w.metric);
assert.deepEqual(await p.locator("#ledSplit .opt").allTextContents(), w.split);
assert.equal(await p.locator("#ledScope").textContent(), lang === "zh" ? "统计网关调用与会话日志调用;本地拒绝的请求不计入汇总。" : "Gateway and session-log calls; local rejections excluded from totals.");
assert.equal(await p.locator("#ledMetric .opt.on").textContent(), w.metric[0]);
assert.equal(await p.locator("#ledSplit .opt.on").textContent(), w.split[0]);
assert.equal(await p.locator("#period .opt.on").textContent(), lang === "zh" ? "今天" : "Today");
assert.deepEqual(await names(p), ["claude-sonnet-5", "gpt-6-sol", "gpt-6-luna"]);
await p.locator("#ledSplit .opt").nth(1).click(); // exercise provider ranking below
assert.deepEqual(await names(p), ["Claude", "Relay", "Codex"]);
assert.deepEqual(await p.locator("#ledRank .rk-sw").evaluateAll((s) => s.map((x) => x.style.background)), ["var(--c1)", "var(--c2)", "var(--c3)"]);
assert(/\d+%/.test(await p.locator("#ledRank .rk-b").first().textContent()), "a share");
assert((await p.locator("#ledRank .rk").nth(1).locator(".rk-b .rk-bad").textContent()).includes("3"), "Relay's failures");
// the columns: a segment of each provider where it had calls
const keys = await p.locator("#ledChart rect.col").evaluateAll((r) => [...new Set(r.map((x) => x.dataset.k))].sort());
assert.deepEqual(keys, ["anthropic", "codex", "relay"]);
const svg = await p.locator("#ledChart svg").boundingBox(), box = await p.locator("#ledChart").boundingBox();
// and where they are: on the bottom line, inside the plot, each in the slot of its hour
const pos = await p.locator("#ledChart rect.col").evaluateAll((rs, want) => {
const s = document.querySelector("#ledChart svg").getBoundingClientRect();
const boxes = rs.map((r) => ({ k: r.dataset.k, ...r.getBoundingClientRect().toJSON() }));
const bottom = Math.max(...boxes.map((b) => b.bottom));
const grid = [...document.querySelectorAll("#ledChart svg .grid")].map((g) => g.getBoundingClientRect().bottom);
return { inside: boxes.every((b) => b.top >= s.top - 1 && b.bottom <= s.bottom + 1 && b.left >= s.left - 1 && b.right <= s.right + 1), onGrid: Math.abs(bottom - Math.max(...grid)) < 2,
relay: Math.min(...boxes.filter((b) => b.k === "relay").map((b) => b.left)), claude: Math.min(...boxes.filter((b) => b.k === "anthropic").map((b) => b.left)), left: s.left, width: s.width };
});
assert(pos.inside, "the columns are inside the chart");
assert(pos.onGrid, "the columns stand on the bottom line");
const hourAt = (x) => ((x - pos.left) / pos.width) * 24;
assert(hourAt(pos.relay) > 6.5 && hourAt(pos.relay) < 8.6, "Relay's first column is in hour 7: " + hourAt(pos.relay));
assert(hourAt(pos.claude) > 12.5 && hourAt(pos.claude) < 14.6, "Claude's first column is in hour 13: " + hourAt(pos.claude));
assert(svg.x >= box.x - 1 && svg.x + svg.width <= box.x + box.width + 1, "the chart is within its box");
const axis = await p.locator("#ledChart svg .axis").allTextContents();
assert(axis.includes("00:00") && axis.some((s) => /M$|万|亿/.test(s)), "hours below, tokens at the side: " + axis);
// the pointer over a column: the hour and what each had of it
const before = await p.locator("#view-usage").evaluate((v) => v.scrollTop);
await p.mouse.move(svg.x + svg.width * (20.5 / 24), svg.y + svg.height * 0.5); // hour 20: Claude and Codex
assert(await p.locator("#ledChart .tip").isVisible());
const tip = await p.locator("#ledChart .tip").innerText();
assert(tip.includes("Claude") && tip.includes("Codex") && !tip.includes("Relay"), "who had hour 20: " + tip);
assert(tip.includes(w.strip[0] === "Tokens" ? "Total" : "合计"), "and the total: " + tip);
await p.mouse.move(svg.x + svg.width * (8.5 / 24), svg.y + svg.height * 0.5); // hour 8: Relay alone
const tip8 = await p.locator("#ledChart .tip").innerText();
assert(tip8.includes("Relay") && !tip8.includes("Claude"), tip8);
await p.mouse.move(svg.x + svg.width * 0.5, box.y - 30);
assert(!(await p.locator("#ledChart .tip").isVisible()), "the tip goes with the pointer");
// a ranked one stands out, and the others fade
await p.locator("#ledRank .rk").nth(2).hover();
const faded = await p.locator("#ledChart rect.col").evaluateAll((r) => r.filter((x) => x.style.opacity === "0.22").length);
const kept = await p.locator("#ledChart rect.col").evaluateAll((r) => r.filter((x) => x.dataset.k === "codex" && x.style.opacity === "").length);
assert(faded > 0 && kept > 0, `the others fade: ${faded} faded, ${kept} of Codex's kept`);
await p.mouse.move(5, 5);
// the metric: cost has its own axis and ranking (Claude cost most, then Relay, Codex)
await p.locator("#ledMetric .opt").nth(1).click();
assert((await p.locator("#ledChart svg .axis").allTextContents()).some((s) => /^[$¥]/.test(s)));
assert.deepEqual(await names(p), ["Claude", "Relay", "Codex"]);
await p.locator("#ledMetric .opt").nth(2).click(); // requests: Relay 30, Codex 20, Claude 16
assert.deepEqual(await names(p), ["Relay", "Codex", "Claude"]);
assert.equal(await p.locator("#view-usage").evaluate((v) => v.scrollTop), before, "a click moved the page");
// and what it is split by
await p.locator("#ledSplit .opt").nth(2).click();
assert.deepEqual(await names(p), ["Codex", "Claude Code"]);
await p.locator("#ledSplit .opt").nth(0).click();
assert.deepEqual(await names(p), ["gpt-6-sol", "gpt-6-luna", "claude-sonnet-5"]);
await p.locator("#ledRank .rk").first().click();
await lastAsked(asked, (q) => q.get("model") === "gpt-6-sol" && !q.has("q"));
await p.locator("#ledRank .rk").first().click();
await lastAsked(asked, (q) => !q.has("model"));
await p.locator("#ledSplit .opt").nth(1).click();
await p.locator("#ledMetric .opt").nth(0).click();
// no rule of another part of the page reaches the ranking: nothing in it but the
// swatches, the bars and a hovered or picked row has a background of its own
// (the Sessions heatmap's .l1/.l2 once painted its lines in the accent)
await p.mouse.move(5, 5);
const painted = await p.locator("#ledRank *").evaluateAll((els) => els
.filter((e) => !e.closest(".rk-sw, .rk-bar") && getComputedStyle(e).backgroundColor !== "rgba(0, 0, 0, 0)")
.map((e) => e.className || e.tagName));
assert.deepEqual(painted, [], "painted in the ranking");
// and none of the new parts' short class names is one a bare rule elsewhere styles
const bare = await p.evaluate(() => {
const rules = [];
for (const sheet of document.styleSheets) { try { for (const r of sheet.cssRules) if (r.selectorText) rules.push(r.selectorText); } catch {} }
const global = new Set();
for (const sel of rules) for (const part of sel.split(",")) { const m = part.trim().match(/^\.([\w-]+)$/); if (m) global.add(m[1]); }
const used = new Set();
for (const e of document.querySelectorAll("#ledDash *, #ledWrap .led-detail *")) for (const c of e.classList) used.add(c);
const ok = new Set(["segs", "opt", "grow", "text", "sess-pick", "skeleton", "thumb", "on", "ic"]); // the page's shared parts, used as meant
// the new parts' own, prefixed names are theirs; a short bare one may be anyone's
const own = /^(led|pu|cx|rk|usage)-/;
return [...used].filter((c) => global.has(c) && !ok.has(c) && !own.test(c));
});
assert.deepEqual(bare, [], "a class a global rule also styles");
// a click on a provider lists only its requests, on it again all of them
const n = asked.length;
await p.locator("#ledRank .rk").nth(1).click();
await lastAsked(asked, (q) => q.get("provider") === "relay");
assert(asked.length > n);
await p.locator("#ledProvider", { hasText: "Relay" }).waitFor();
assert(await p.locator("#ledRank .rk.on").count() === 1 && (await p.locator("#ledRank .rk.on .rk-nm").textContent()) === "Relay");
await p.locator("#ledRank .rk").nth(1).click();
await lastAsked(asked, (q) => !q.get("provider"));
await p.locator("#ledProvider", { hasText: w.all }).waitFor();
// the picker beside the agent's does the same
await p.locator("#ledProvider").click();
await p.locator(".sess-menu button, .sess-menu [role=option], .sess-menu .opt").filter({ hasText: "Codex" }).first().click();
await lastAsked(asked, (q) => q.get("provider") === "codex");
assert.equal(await p.locator("#view-usage").evaluate((v) => v.scrollTop), before, "a click moved the page");
if (shots) await p.screenshot({ path: path.join(shots, `chart-${engine}-${lang}.png`) });
assert.deepEqual(errors, []);
});
}
await t.test("the metric is remembered, while today and model are defaults", async () => {
const first = await open("en", "light");
await first.p.locator("#ledMetric .opt").nth(1).click();
await first.p.locator("#ledSplit .opt").nth(1).click();
await first.p.evaluate(() => localStorage.setItem("magpie.ledSplit", "provider"));
const second = await open("en", "light", { ctx: first.context });
assert.equal(await second.p.locator("#ledMetric .opt.on").textContent(), "Cost");
assert.equal(await second.p.locator("#ledSplit .opt.on").textContent(), "Model");
assert.deepEqual(await names(second.p), ["claude-sonnet-5", "gpt-6-sol", "gpt-6-luna"]);
});
await t.test("dark, and narrow", async () => {
const { p, errors } = await open("en", "dark", { width: 560 });
const fits = await p.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1 && document.getElementById("view-usage").scrollWidth <= document.getElementById("view-usage").clientWidth + 1);
assert(fits, "the page doesn't scroll sideways");
const chart = await p.locator("#ledChart").boundingBox(), rank = await p.locator("#ledRank").boundingBox(), svg = await p.locator("#ledChart svg").boundingBox();
assert(svg.width > 300 && svg.x + svg.width <= chart.x + chart.width + 1, "the chart fits at 560");
assert(rank.y >= chart.y + chart.height - 1, "the ranking goes under the chart when there is no room beside it");
assert.equal(await p.locator("#ledKpi").evaluate((k) => getComputedStyle(k).gridTemplateColumns.split(" ").length), 2, "two totals to a row");
// the chart follows the window
await p.setViewportSize({ width: 1100, height: 760 });
await p.waitForTimeout(300);
const wider = await p.locator("#ledChart svg").boundingBox();
assert(wider.width > 500 && (await p.locator("#ledRank").boundingBox()).x > wider.x + wider.width - 5, `beside it again: ${svg.width} → ${wider.width}`);
if (process.env.ARTIFACT_DIR) await p.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `chart-${engine}-dark.png`) });
assert.deepEqual(errors, []);
});
await t.test("a long ranking is as tall as the chart and scrolls in itself", async () => {
const { p, errors, asked } = await open("en", "light", { variant: "many" });
await p.locator("#ledRank .rk").nth(7).waitFor();
const box = async () => {
const chart = await p.locator("#ledChart").boundingBox(), rank = await p.locator("#ledRank").boundingBox();
const [top, height, client] = await p.locator("#ledRank").evaluate((r) => [r.scrollTop, r.scrollHeight, r.clientHeight]);
return { chart, rank, top, height, client };
};
let b = await box();
const plot = await p.locator("#ledChart .plot").boundingBox();
assert(Math.abs(b.rank.height - plot.height) <= 1 && Math.abs(b.chart.height - plot.height) <= 1, `the ranking is ${b.rank.height}px beside a ${plot.height}px chart`);
assert(b.height > b.client + 40, "the ranking holds more than it shows");
// no scrollbar: the edge with more beyond it fades
const look = () => p.locator("#ledRank").evaluate((r) => ({ bar: r.offsetWidth - r.clientWidth, above: r.classList.contains("more-above"), below: r.classList.contains("more-below"), mask: getComputedStyle(r).webkitMaskImage || getComputedStyle(r).maskImage }));
let seen = await look();
assert.equal(seen.bar, 0, "no scrollbar");
assert(!seen.above && seen.below && /gradient/.test(seen.mask), "at the top, only the foot fades");
// but a rail says there is more, and how much: beside the ranking, as
// tall as it, its thumb at the top and as long as the part in sight
const rail = async () => {
const r = await p.locator("#ledRail").boundingBox(), th = await p.locator("#ledRail i").boundingBox();
return { r, th };
};
let g = await rail();
assert(g.r && Math.abs(g.r.y - b.rank.y) <= 1 && Math.abs(g.r.height - b.rank.height) <= 1 && g.r.x >= b.rank.x + b.rank.width - 1, "the rail beside the ranking, as tall");
assert(Math.abs(g.th.y - g.r.y) <= 1 && Math.abs(g.th.height - Math.max(24, (b.client * b.client) / b.height)) <= 1, `the thumb at the top, ${g.th.height}px for ${b.client} of ${b.height}`);
// the wheel over it scrolls the ranking, not the page
const view = () => p.locator("#view-usage").evaluate((v) => v.scrollTop);
const before = await view();
await p.mouse.move(b.rank.x + b.rank.width / 2, b.rank.y + b.rank.height / 2);
await p.mouse.wheel(0, 120);
for (let i = 0; i < 40 && (await box()).top === 0; i++) await p.waitForTimeout(25);
b = await box();
assert(b.top > 0, "the ranking scrolled");
seen = await look();
assert(seen.above && seen.below === b.top < b.height - b.client - 1, "scrolled, the head fades too");
g = await rail();
assert(Math.abs(g.th.y - g.r.y - ((g.r.height - g.th.height) * b.top) / (b.height - b.client)) <= 1.5, "the thumb follows the scroll");
assert.equal(await view(), before, "the page stayed where it was");
// a pick redraws the ranking where it was scrolled to (clicked where it
// is, as a reader does, not scrolled to first as a test's click would)
const rows = p.locator("#ledRank .rk:not(.plain)");
let hit = -1, at;
for (let i = (await rows.count()) - 1; i >= 0 && hit < 0; i--) {
at = await rows.nth(i).boundingBox();
if (at.y >= b.rank.y && at.y + at.height <= b.rank.y + b.rank.height) hit = i;
}
assert(hit > 0, "a row in sight");
await p.mouse.click(at.x + at.width / 2, at.y + at.height / 2);
await lastAsked(asked, (q) => q.get("provider") === "p" + hit);
await p.locator("#ledRank .rk.on").waitFor();
const after = await box();
assert.equal(after.top, b.top, "the pick kept the ranking's scroll");
assert.equal(await view(), before, "a click moved the page");
// the thumb dragged to the rail's foot takes the ranking to its end; a
// click at the rail's head, back to its start
g = await rail();
await p.mouse.move(g.th.x + g.th.width / 2, g.th.y + g.th.height / 2);
await p.mouse.down();
await p.mouse.move(g.th.x + g.th.width / 2, g.r.y + g.r.height + 40, { steps: 6 });
await p.mouse.up();
b = await box();
assert(b.top >= b.height - b.client - 1, "dragged to its end");
g = await rail();
assert(Math.abs(g.th.y + g.th.height - (g.r.y + g.r.height)) <= 1.5, "the thumb at the rail's foot");
await p.mouse.click(g.r.x + g.r.width / 2, g.r.y + 3);
assert.equal((await box()).top, 0, "a click at the rail's head, back to the start");
assert.equal(await view(), before, "the rail moved the page");
if (shots) await p.locator(".led-trend").screenshot({ path: path.join(shots, `chart-${engine}-long-ranking.png`) });
// narrow, the ranking goes under the chart at its own height
await p.setViewportSize({ width: 560, height: 760 });
await p.waitForTimeout(300);
const n = await box();
assert(n.rank.y >= n.chart.y + n.chart.height - 1 && n.height <= n.client + 1, "under the chart, the whole ranking shows");
seen = await look();
assert(!seen.above && !seen.below, "nothing fades when it all shows");
assert(await p.locator("#ledRail").isHidden(), "no rail when it all shows");
// wide again, and scrolled to its end: only the head fades
await p.setViewportSize({ width: 1180, height: 760 });
await p.waitForTimeout(300);
await p.locator("#ledRank").evaluate((r) => { r.scrollTop = r.scrollHeight; });
for (let i = 0; i < 40 && (await look()).below; i++) await p.waitForTimeout(25);
seen = await look();
assert(seen.above && !seen.below, "at the end, only the head fades");
assert(await p.locator("#ledRail").isVisible(), "wide again, the rail is back");
assert.deepEqual(errors, []);
// headless hides scrollbars: Chromium drawing the classic one, as macOS
// does with a mouse plugged in, has none on the ranking either
if (engine === "chromium") {
const classic = await chromium.launch({ channel: "chromium", ignoreDefaultArgs: ["--hide-scrollbars"] });
try {
const c = await open("en", "light", { variant: "many", ctx: await classic.newContext({ viewport: { width: 1180, height: 760 }, reducedMotion: "reduce" }) });
await c.p.addStyleTag({ content: "*::-webkit-scrollbar { width: 14px; height: 14px; } *::-webkit-scrollbar-thumb { background: #888; }" });
assert(await c.p.locator("#view-usage").evaluate(() => { const d = document.createElement("div"); d.style.cssText = "width:50px;height:50px;overflow:scroll"; document.body.append(d); const w = d.offsetWidth - d.clientWidth; d.remove(); return w > 0; }), "this Chromium draws scrollbars");
assert.equal(await c.p.locator("#ledRank").evaluate((r) => r.offsetWidth - r.clientWidth), 0, "no scrollbar on the ranking where the system draws one");
assert.deepEqual(c.errors, []);
} finally {
await classic.close();
}
}
});
await t.test("no price, no requests", async () => {
const a = await open("en", "light", { variant: "unpriced" });
await a.p.locator("#ledMetric .opt").nth(1).click();
assert.equal((await a.p.locator("#ledChart .none").textContent()).trim(), "No known price for these requests");
const b = await open("en", "light", { variant: "none" });
await b.p.waitForTimeout(300);
assert(await b.p.locator("#ledDash").isHidden(), "nothing to total or chart");
assert.deepEqual([...a.errors, ...b.errors], []);
});
});
}