Files
magpie/internal/gui/tests/usage-fold.test.cjs
T
yetone 2d5f9c748e gui: the Usage page shows every account again, each as compact as before the trend lines, and remembers which ones are open (ARNO on Discord: 以前每个账号所占的空间很小,基本能一页看到所有账号的情况,现在要一个个手动展开账号,展开后占的空间又特别大,余额走势的线条也分散在各个地方很乱。而且关了magpie后又得一个个展开)
With 17 subscriptions and 29 accounts, a card with several accounts showed one of them and hid the rest behind "Show N more accounts". Opening one showed every account at full size, with resets, credit days and its own trend line, so a page that once fit most accounts filled several screens. The open state was kept per subscription, and ARNO had to open the cards again after quitting.

Now every account of a card is on the page. The one in sight (the last served, else the first) is shown in full. The others are shown in brief: the account row and its windows' names, figures and bars, with the reset times only in the bar's tooltip, and no trend line, credit days, "read" line or resets row (the balance stays). A chevron at the end of each account row opens or folds that account alone. The card's button, now in its head beside the name, opens the rest in full or puts them back in brief. Each account's choice is kept in localStorage magpie.usageAccounts (provider + account), so it survives a restart and syncs with the tray panel. A subscription opened under the old magpie.usageOpen starts with all its accounts in full. A brief account's separator sits where a full one's does, so folding doesn't move the account clicked, and the page doesn't scroll (backToReader). The tray panel shows the accounts open in full (or the one in sight) with the same button. A jump to an account from the tray opens that account.

Tested: usage-fold.test.cjs checks the full/brief split, the per-account chevron (no scroll, the account stays put), the head button, that the choice survives a reload, Hide accounts, and the panel following the window. A new case with 17 subscriptions and 29 accounts at 1280x800 with Trends off has all 29 accounts' bars on the page and at least 15 fully on the first screen (16 now; 15 before the curves at 9ccabdfb^; 12 on v0.1.752, with 12 more only behind buttons). It fails on the old code. tray-cell-click now expects the opened account in magpie.usageAccounts. quota-curve, click-scroll, panel-usage, account-mask, mobile-web, tray-cell-click, quota-families and quota-pools pass in WebKit. Screenshots at 1280x800 in zh: Trends off, 19 of 29 accounts' bars fully on screen (scroll height 1360 vs 2005 with Cycle). go vet ./internal/... and go test -tags nogui -count=1 ./internal/gui/ under a temp HOME pass. Not tried in the built app with real accounts.
2026-10-03 19:45:33 +08:00

222 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Run with Node's test runner and Playwright on the module path; see README.md.
// A subscription with several accounts shows one of them in full on the
// Usage page — the one that answered last, else the first — and the others
// in brief, their bars without when they reset (whqtian on Discord: 只显示
// 一个账号即可,其他的可以点击展开; ARNO: 以前每个账号所占的空间很小,基本
// 能一页看到所有账号… 关了magpie后又得一个个展开); the tray panel leaves the
// ones in brief out, behind "Show N more accounts". Each account opens or
// folds on its own, and stays so across a reload, by provider and account;
// the card's button opens the rest; a provider with one account has no
// button; the clicks don't move the page, and Hide accounts still hides
// every one. English and Chinese; no backend, the API is faked here.
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 ago = (m) => new Date(Date.now() - m * 60e3).toISOString();
const later = new Date(Date.now() + 5 * 3600e3).toISOString();
const wb = (user, used, served) => ({ provider: "workbuddy", name: "WorkBuddy", kind: "subscription", icon: "workbuddy-color", plan: "Pro", user, windows: [{ name: "Credits", used, display: `${used * 56} / 5600`, resetsAt: later }], ...(served ? { lastServedAt: served } : {}) });
const quotas = () => [
wb("alpha@example.com", 10),
wb("bravo@example.com", 20, ago(30)),
wb("charlie@example.com", 30, ago(2)), // answered last: the one in sight
wb("delta@example.com", 40),
{ provider: "zcode", name: "ZCode", kind: "subscription", icon: "zcode", plan: "Start", user: "solo@example.com", windows: [{ name: "5 hours", used: 10 }] },
{ provider: "codex", name: "Codex", kind: "subscription", icon: "openai", plan: "Plus", user: "one@example.com", windows: [{ name: "5 hours", used: 20 }] },
{ provider: "codex", name: "Codex", kind: "subscription", icon: "openai", plan: "Plus", user: "two@example.com", windows: [{ name: "5 hours", used: 50 }] },
];
function serve(lang, panel, list = quotas) {
const settings = { theme: "light", lang, tray: "panel", quotaLeft: false, currency: "usd" };
return async (route) => {
const url = new URL(route.request().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:"light",web:${!panel}};` });
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 });
if (url.pathname === "/api/settings") return json(settings);
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true } });
if (url.pathname === "/api/usage/quotas") return json(list());
if (url.pathname === "/api/groups") return json({ groups: [], models: [] });
if (url.pathname === "/api/plugins") return json({ plugins: [] });
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)];
try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); }
};
}
const words = {
en: { more3: "Show 3 more accounts", more1: "Show 1 more account", fewer: "Show fewer accounts", full3: "Show 3 more accounts in full", full1: "Show 1 more account in full", brief: "Show the other accounts in brief", hide: "Hide accounts" },
zh: { more3: "展开其余 3 个账号", more1: "展开其余 1 个账号", fewer: "收起其余账号", full3: "展开其余 3 个账号", full1: "展开其余 1 个账号", brief: "收起其余账号", hide: "账号打码" },
};
// what each card shows: its name, the accounts in full and in brief (in
// sight both), the meters and the reset lines in sight, its button
const cards = (page) => page.evaluate(() => [...document.querySelectorAll("#subscriptionUsage > .subscription-card")].map((c) => {
const shown = [...c.querySelectorAll(".subscription-account")].filter((a) => a.offsetParent);
return {
name: c.querySelector(".subscription-head b").textContent,
full: shown.filter((a) => !a.classList.contains("brief")).map((a) => a.querySelector(".user").title),
brief: shown.filter((a) => a.classList.contains("brief")).map((a) => a.querySelector(".user").title),
meters: [...c.querySelectorAll(".quota-windows")].filter((a) => a.offsetParent).length,
resets: [...c.querySelectorAll(".quota-reset")].filter((a) => a.offsetParent).length,
more: c.querySelector(".quota-more")?.textContent ?? null,
};
}));
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh"]) {
const w = words[lang];
test(`${engine} ${lang}: several accounts: one in full, the others in brief, each remembered`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
t.after(() => browser.close());
const context = await browser.newContext({ viewport: { width: 900, height: 300 }, reducedMotion: "reduce" });
const errors = [];
const open = async (url) => {
const page = await context.newPage();
page.setDefaultTimeout(5000);
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", serve(lang, url.includes("mode=panel")));
await page.goto(url);
return page;
};
const page = await open("http://magpie.test/?view=usage");
await page.evaluate(() => { localStorage.removeItem("magpie.usageOpen"); localStorage.removeItem("magpie.usageAccounts"); localStorage.removeItem("magpie.maskEmails"); });
await page.reload();
await page.waitForSelector(".subscription-account .user", { state: "attached" });
let got = await cards(page);
const by = (name) => got.find((c) => c.name === name);
// every account in sight: the one that answered last in full, the
// others' bars alone
assert.deepEqual(by("WorkBuddy"), { name: "WorkBuddy", full: ["charlie@example.com"], brief: ["alpha@example.com", "bravo@example.com", "delta@example.com"], meters: 4, resets: 1, more: w.full3 });
assert.deepEqual(by("Codex"), { name: "Codex", full: ["one@example.com"], brief: ["two@example.com"], meters: 2, resets: 0, more: w.full1 });
assert.equal(by("ZCode").more, null, "one account: no button");
assert.deepEqual([by("ZCode").full, by("ZCode").brief], [["solo@example.com"], []]);
assert.equal(await page.locator("#subscriptionUsage > [data-key=zcode] .quota-acct-fold").count(), 0, "one account: no fold");
// opening it doesn't move the page
const view = page.locator("#view-usage");
await page.mouse.move(450, 200);
await page.mouse.wheel(0, 60);
await page.waitForFunction(() => document.querySelector("#view-usage").scrollTop > 0);
await page.waitForTimeout(300);
const head = page.locator("#subscriptionUsage > [data-key=workbuddy] .subscription-head");
const before = await view.evaluate((v) => v.scrollTop);
assert.ok(before > 0, "the view was scrolled");
const headAt = await head.evaluate((e) => e.getBoundingClientRect().top);
await page.locator("#subscriptionUsage > [data-key=workbuddy] .quota-more").click();
await page.waitForTimeout(700);
assert.equal(await view.evaluate((v) => v.scrollTop), before, "the view didn't scroll");
assert.equal(await head.evaluate((e) => e.getBoundingClientRect().top), headAt, "the card stays where it was");
got = await cards(page);
assert.deepEqual(by("WorkBuddy"), { name: "WorkBuddy", full: ["alpha@example.com", "bravo@example.com", "charlie@example.com", "delta@example.com"], brief: [], meters: 4, resets: 4, more: w.brief });
assert.equal(by("Codex").more, w.full1, "another provider stays as it was");
// an account opens and folds on its own, where it is
const acct = (key, user) => page.locator(`#subscriptionUsage > [data-key=${key}] .subscription-account`, { hasText: user });
const two = acct("codex", "two@example.com");
const twoAt = await two.evaluate((e) => e.getBoundingClientRect().top);
const scrolled = await view.evaluate((v) => v.scrollTop);
assert.equal(await two.locator(".quota-acct-fold").getAttribute("aria-expanded"), "false");
await two.locator(".quota-acct-fold").click();
await page.waitForTimeout(400);
assert.equal(await view.evaluate((v) => v.scrollTop), scrolled, "the view didn't scroll");
assert.equal(await two.evaluate((e) => e.getBoundingClientRect().top), twoAt, "the account stays where it was");
got = await cards(page);
assert.deepEqual([by("Codex").full, by("Codex").brief, by("Codex").more], [["one@example.com", "two@example.com"], [], w.brief]);
await acct("workbuddy", "alpha@example.com").locator(".quota-acct-fold").click();
got = await cards(page);
assert.deepEqual(by("WorkBuddy").brief, ["alpha@example.com"]);
assert.equal(by("WorkBuddy").more, w.full1);
// remembered across a reload (a restart), by provider and account
await page.reload();
await page.waitForSelector(".subscription-account .user", { state: "attached" });
got = await cards(page);
assert.deepEqual([by("WorkBuddy").full, by("WorkBuddy").brief], [["bravo@example.com", "charlie@example.com", "delta@example.com"], ["alpha@example.com"]]);
assert.deepEqual([by("Codex").full, by("Codex").brief], [["one@example.com", "two@example.com"], []]);
// the card's button opens the rest
await page.locator("#subscriptionUsage > [data-key=workbuddy] .quota-more").click();
got = await cards(page);
assert.equal(by("WorkBuddy").full.length, 4);
// Hide accounts still hides every account, the folded ones too
await page.locator("#usageMask").click();
await page.waitForFunction(() => document.querySelectorAll("#subscriptionUsage .subscription-account .user .pii").length === 7);
assert.ok(!/example\.com/.test(await page.locator("#subscriptionUsage").innerText()));
await page.locator("#usageMask").click();
// the tray panel folds the same way, and follows what was opened
const panel = await open("http://magpie.test/?mode=panel");
await panel.setViewportSize({ width: 380, height: 900 });
await panel.evaluate(() => setPanelTab("usage"));
await panel.waitForSelector("#panelQuota .pq-card .pq-user");
const pq = () => panel.evaluate(() => Object.fromEntries([...document.querySelectorAll("#panelQuota .pq-group")].map((g) => [g.querySelector(".pq-gn").textContent, { users: [...g.querySelectorAll(".pq-user")].map((u) => u.textContent), more: g.querySelector(".pq-more")?.textContent ?? null }])));
let p = await pq();
assert.deepEqual(p.WorkBuddy, { users: ["alpha@example.com", "bravo@example.com", "charlie@example.com", "delta@example.com"], more: w.fewer });
assert.deepEqual(p.Codex, { users: ["one@example.com", "two@example.com"], more: w.fewer });
assert.equal(p.ZCode.more, null);
await panel.locator("#panelQuota .pq-group", { hasText: "WorkBuddy" }).locator(".pq-more").click();
p = await pq();
assert.deepEqual(p.WorkBuddy, { users: ["charlie@example.com"], more: w.more3 });
// and the window follows the panel
await page.waitForFunction(() => document.querySelector("#subscriptionUsage > [data-key=workbuddy] .quota-more")?.getAttribute("aria-expanded") === "false");
got = await cards(page);
assert.deepEqual([by("WorkBuddy").full, by("WorkBuddy").brief], [["charlie@example.com"], ["alpha@example.com", "bravo@example.com", "delta@example.com"]]);
assert.deepEqual(errors, []);
});
}
// ARNO's Usage page: 17 subscriptions, 29 accounts. With Trends off, at
// 1280×800, every account is on the page and most of their bars are on
// the first screen without opening anything, as many as before the curves.
test(`${engine}: 17 subscriptions and 29 accounts, most on one screen`, async (t) => {
const later = (h) => new Date(Date.now() + h * 3600e3).toISOString();
const two = (a, b) => [{ name: "5 hours", used: a, resetsAt: later(2) }, { name: "Weekly", used: b, resetsAt: later(72) }];
const one = (name, used, h = 288) => [{ name, used, resetsAt: later(h) }];
const sub = (provider, name, users, windows) => users.map((user, i) => ({ provider, name, kind: "subscription", icon: "generic", plan: "Pro", ...(user ? { user } : {}), windows: windows(i) }));
const ag = (i) => ["Gemini 3.8 Flash", "Gemini 3.7 Pro", "Claude Sonnet 4.6", "Claude Opus 4.6 Thinking", "GPT-OSS 120B"].map((name, j) => ({ name, used: 10 * (i + j), resetsAt: later(j < 2 ? 2 : 72) }));
const many = () => [
...sub("claude", "Claude", ["a@c.test", "b@c.test", "c@c.test", "d@c.test"], (i) => i ? two(5 * i, 20) : [...two(62, 41), ...two(77, 12).map((w) => ({ ...w, name: w.name + " · Opus" }))]),
...sub("codex", "Codex", ["a@x.test", "b@x.test", "c@x.test", "d@x.test"], (i) => two(10 * i, 30)),
...sub("antigravity", "Antigravity", ["g1@g.test", "g2@g.test"], ag),
...sub("gemini", "Gemini CLI", ["h1@g.test", "h2@g.test"], () => one("Daily", 14, 2)),
...sub("kiro", "Kiro", ["k1@k.test", "k2@k.test"], () => one("Monthly", 44)),
...sub("workbuddy", "WorkBuddy", ["w1@w.test", "w2@w.test", "w3@w.test"], () => one("Credits", 20)),
...sub("qoder", "Qoder", ["q1@q.test", "q2@q.test"], () => one("Credits", 25)),
...[["cursor", "Cursor"], ["zcode", "ZCode"], ["copilot", "GitHub Copilot"], ["devin", "Devin"], ["factory", "Factory"], ["grok", "Grok"], ["mimo-app", "Xiaomi MiMo"]]
.flatMap(([id, name]) => sub(id, name, [`${id}@solo.test`], () => one("Monthly", 30))),
...sub("glm", "GLM Coding", [""], () => two(22, 3)),
...sub("kimi", "Kimi", [""], () => two(10, 30)),
...sub("minimax", "MiniMax", [""], () => one("5 hours", 5, 2)),
];
assert.equal(many().length, 29);
assert.equal(new Set(many().map((q) => q.provider)).size, 17);
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
t.after(() => browser.close());
const page = await (await browser.newContext({ viewport: { width: 1280, height: 800 }, reducedMotion: "reduce" })).newPage();
page.setDefaultTimeout(5000);
await page.route("**/*", serve("zh", false, many));
await page.addInitScript(() => { try { if (!sessionStorage.getItem("set")) { localStorage.clear(); localStorage.setItem("magpie.quotaRange", "off"); sessionStorage.setItem("set", "1"); } } catch {} });
await page.goto("http://magpie.test/?view=usage");
await page.waitForSelector(".subscription-account .user");
const seen = () => page.evaluate(() => {
const box = document.querySelector("#subscriptionUsage");
// an account's bars: what follows its row; a card without accounts: its bars
const bars = [...box.querySelectorAll(".subscription-account")].map((a) => a.nextElementSibling);
for (const c of box.children) if (!c.querySelector(".subscription-account")) bars.push(c.querySelector(".quota-windows"));
return { all: bars.length, shown: bars.filter((b) => b.offsetParent).length, onScreen: bars.filter((b) => b.offsetParent && b.getBoundingClientRect().bottom <= innerHeight).length };
});
const s = await seen();
assert.equal(s.all, 29);
assert.equal(s.shown, 29, "every account's bars are on the page, none behind a button");
// before the curves (9ccabdfb^) 15 were; v0.1.752 showed 12, and 12 more only behind buttons
assert.ok(s.onScreen >= 15, `as many accounts on the first screen as before the curves: ${s.onScreen} of 29`);
});
}