Files
magpie/internal/gui/tests/settings-groups.test.cjs
T
yetone 641b01aa95 settings: the warm-ups and WorkBuddy's check-in sit under one row of tabs, Codex | Claude Code | WorkBuddy, the picked one's rows alone in the card (#124 Mrhe525: asked for the service groups as tabs in the heading's place instead of three stacked groups) — since v0.1.406 the Settings page ran on after Preferences with three groups under their own headings, CODEX (Warm up on reset, Daily warm-up), CLAUDE CODE (the same two) and WORKBUDDY (Daily check-in). Now one section follows Preferences whose heading row is the tabs, in the headings' small capitals, the picked one darker on a soft pill (a tablist labelled "Warm-up and check-in" · 预热与签到), and the card under it shows only that service's rows, unchanged: same rows, same settings keys and /api/settings posts, the same status lines (last warm-up, today's check-in) and tooltips. The WorkBuddy tab is there under the condition the group had (an account signed in or the check-in on). The tab is remembered per viewer (magpie.warmTab, localStorage in try/catch, like the panel's tab), Codex to begin with and whenever the remembered WorkBuddy tab is not there. A click on a tab moves nothing: the card is its own height, and a shorter one picked at the page's very end gets room kept at the view's foot by the page's "where the reader is" hold; Left/Right/Home/End move along the tabs (roving tabindex), the tab reached clicked so the key is held the same way. A pill drawn while its card was hidden measured nothing, so its thumb is put under the picked option, still, when the card is shown. No border stripes; one new string, en and zh together, none left unused. Tested with internal/gui/tests/settings-groups.test.cjs rewritten, in Chromium and WebKit, en and zh: the tabs in order where a heading would be, after Preferences and before Local network, Codex's rows alone to begin with and each tab its rows alone, the status on the lines, no left border; with the page scrolled, each control posts codexWarmup, codexWarmAt (On, a new time, Off), claudeWarmup, claudeWarmAt and workbuddyCheckin as before and a tab posts nothing, the page left where it was; the pills' thumbs under their picks on a card shown later (fails without the fix); the arrows, Home and End; at the page's very end, WorkBuddy's shorter card and Codex's taller one by click and key leaving the tabs within 1px; the tab remembered across a reload; no WorkBuddy tab with no account, Codex's shown when WorkBuddy's was remembered. Screenshots looked at in light and dark, en and zh, at 900 and the window's narrowest 560. go build -tags nogui (and GOOS=linux, GOOS=windows), go test -tags nogui ./... and the GUI suite (128) pass; click-scroll and usage-ledger failed once while go test ran beside them and pass alone. Not tried in the real Wails window.
2026-09-29 23:52:42 +08:00

307 lines
18 KiB
JavaScript

// Run with Node's test runner and Playwright on the module path; see README.md.
// The Settings page's warm-ups and check-in, one section with a tab per
// service (#124 Mrhe525: tabs rather than three stacked groups): after the
// Preferences list, where a heading would be, the tabs Codex, Claude Code
// and WorkBuddy, and under them one card with the picked service's rows —
// Codex's and Claude Code's Warm up on reset and Daily warm-up, WorkBuddy's
// Daily check-in — the lines under them short, with how the last warm-up and
// today's check-in went still on them. The WorkBuddy tab is there only with
// an account signed in; a daily warm-up's time field only while it is on.
// The tab is remembered across a reload (Codex when the remembered one is
// gone), the arrows move along the tabs, every control posts the same
// setting it did, and a click on a tab or a control with the page scrolled
// down moves nothing. 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 today = new Date(Date.now() + 8 * 3600e3).toISOString().slice(0, 10);
function settingsPayload(over) {
return {
theme: "light", lang: "en", tray: "panel", quotaLeft: false, currency: "usd",
dock: false, dockWindow: false, proxy: "", redact: false, redactPersonal: false, redactWords: [],
codexWarmup: "", claudeWarmup: "", codexWarmAt: "", claudeWarmAt: "", workbuddyCheckin: false, noStats: false,
trayUsage: "", trayUsageEvery: 3, vision: "", imageGen: "",
version: "0.1.400", dir: "~/.config/magpie", gateway: "http://127.0.0.1:3425",
proxyNow: "none", proxySource: "none", login: false,
visionModels: [], imageGenModels: [], lanURLs: [],
fx: { rate: 7.2, at: new Date().toISOString(), stale: false },
...over,
};
}
function server(lang, posts, over) {
let cur = settingsPayload({ lang, ...over });
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:"light",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: "light" }, fx: cur.fx });
if (url.pathname === "/api/settings") {
if (req.method() === "POST") {
const body = req.postDataJSON();
posts.push(body);
cur = { ...cur, ...body };
}
return json(cur);
}
if (url.pathname === "/api/usage/quotas") return json([]);
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: {
prefs: "Preferences", lan: "Local network", reset: "Warm up on reset", daily: "Daily warm-up", checkin: "Daily check-in",
on: "On", off: "Off", started: "last started", checked: "Ann checked in today +2, 3-day streak", tabs: "Warm-up and check-in",
},
zh: {
prefs: "偏好", lan: "局域网", reset: "窗口重置时预热", daily: "每日定时预热", checkin: "每日自动签到",
on: "开启", off: "关闭", started: "上次启动于", checked: "Ann 今日已签到 +2, 连续 3 天", tabs: "预热与签到",
},
};
const LISTS = { codex: "codexWarmList", claude: "claudeWarmList", wb: "wbList" };
const view = (page) => page.locator("#view-settings").evaluate((v) => v.scrollTop);
// the tab picked and the pane shown, both as the page says them
async function shown(page) {
return page.evaluate((lists) => {
const on = [...document.querySelectorAll("#warmTabs [role=tab]")].filter((b) => b.getAttribute("aria-selected") === "true").map((b) => b.dataset.warm);
const panes = Object.entries(lists).filter(([, id]) => {
const e = document.getElementById(id);
return getComputedStyle(e).visibility === "visible" && !e.inert && e.getBoundingClientRect().height > 0;
}).map(([k]) => k);
return { on, panes, focus: document.activeElement?.dataset?.warm || "" };
}, LISTS);
}
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
test(engine + ": warm-ups and check-in under a tab per service", async (t) => {
assert(["chromium", "webkit"].includes(engine), "BROWSER must be chromium or webkit");
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
const pages = [];
t.after(async () => {
if (process.env.ARTIFACT_DIR) {
await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
for (const [i, p] of pages.entries()) await p.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-settings-groups-${i}.png`) });
}
await browser.close();
});
const open = async (lang, posts, over, stored) => {
const errors = [];
const ctx = await browser.newContext({ viewport: { width: 900, height: 480 }, reducedMotion: "reduce" });
if (stored) await ctx.addInitScript((k) => { if (!sessionStorage.getItem("seeded")) { localStorage.setItem("magpie.warmTab", k); sessionStorage.setItem("seeded", "1"); } }, stored);
const page = await ctx.newPage();
pages.push(page);
page.setDefaultTimeout(5000);
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", server(lang, posts, over));
const go = async () => {
await page.locator("#prefs").click();
await page.locator("#warmSegs .opt").first().waitFor({ state: "attached" });
};
await page.goto("http://magpie.test/");
await go();
return { page, errors, reload: async () => { await page.reload(); await go(); } };
};
for (const lang of ["en", "zh"]) {
const w = L[lang];
await t.test(lang, async () => {
const posts = [];
const { page, errors, reload } = await open(lang, posts, {
codexWarmed: new Date(Date.now() - 3600e3).toISOString(),
workbuddy: true,
workbuddyCheckins: [{ user: "Ann", day: today, at: new Date().toISOString(), outcome: "claimed", credit: 2, streak: 3 }],
});
// the headings, in order: the tabs where a heading would be, after
// Preferences, before Local network
const heads = await page.evaluate(() => [...document.querySelectorAll("#view-settings > .row-head:not([hidden])")]
.map((h) => h.querySelector("#warmTabs") ? "tabs" : h.querySelector(".label")?.textContent));
const at = heads.indexOf(w.prefs);
assert(at >= 0, `Preferences heading: ${heads}`);
assert.deepEqual(heads.slice(at, at + 3), [w.prefs, "tabs", w.lan]);
assert.deepEqual(await page.locator("#warmTabs [role=tab]:visible").allTextContents(), ["Codex", "Claude Code", "WorkBuddy"]);
assert.equal(await page.locator("#warmTabs").getAttribute("role"), "tablist");
assert.equal(await page.locator("#warmTabs").getAttribute("aria-label"), w.tabs);
// no service rows left in Preferences
for (const id of ["#warmSegs", "#warmAtSegs", "#claudeWarmSegs", "#claudeWarmAtSegs", "#wbCheckinSegs"]) {
assert.equal(await page.locator("#view-settings .list.prefs").first().locator(id).count(), 0, id + " is out of the Preferences list");
}
// Codex to begin with, its rows alone
const visibleRows = () => page.locator(".warm-panes .row.pref:visible .who .name").allTextContents();
// each pill of the card shown has its thumb under the option picked,
// drawn while the card was hidden or not
const thumbsUnderPicks = async (tab) => assert.equal(await page.locator("#" + LISTS[tab]).evaluate((l) => [...l.querySelectorAll(".segs")].filter((s) => {
const th = s.querySelector(":scope > .thumb").getBoundingClientRect(), on = s.querySelector(":scope > .on").getBoundingClientRect();
return !(Math.abs(th.left - on.left) <= 1 && Math.abs(th.width - on.width) <= 1);
}).length), 0, tab + ": the pills' thumbs under their picks");
assert.deepEqual(await shown(page), { on: ["codex"], panes: ["codex"], focus: "" });
assert.deepEqual(await visibleRows(), [w.reset, w.daily]);
// short lines, the status still on them
const warmSub = await page.locator("#warmSub").textContent();
assert(warmSub.includes(w.started), `the last warm-up is shown: ${warmSub}`);
assert((await page.locator("#warmAtSub").textContent()).length < 60, "the daily line is short");
const wbSub = await page.locator("#wbCheckinSub").textContent();
assert(wbSub.includes(w.checked), `today's check-in is shown: ${wbSub}`);
// no coloured stripe down a card's, a row's or a tab's left side
const stripes = await page.evaluate(() => [...document.querySelectorAll(".warm-panes .list, .warm-panes .row, #warmTabs button")]
.filter((e) => { const c = getComputedStyle(e); return c.borderLeftWidth !== c.borderRightWidth || c.borderLeftColor !== c.borderRightColor; }).length);
assert.equal(stripes, 0, "no left-border accents");
// no time field while a daily warm-up is off
assert.equal(await page.locator("#warmAtSegs input.at").count(), 0);
assert.equal(await page.locator("#claudeWarmAtSegs input.at").count(), 0);
// scrolled down (a real wheel) till the tabs are near the top: each
// tab shows its rows alone, each control posts its setting, and
// nothing moves the page
await page.setViewportSize({ width: 900, height: 560 });
await page.mouse.move(450, 200);
const headTop = () => page.evaluate(() => document.querySelector("#warmTabs").getBoundingClientRect().top - document.querySelector("#view-settings").getBoundingClientRect().top);
for (let i = 0; i < 60 && (await headTop()) > 40; i++) { await page.mouse.wheel(0, 40); await page.waitForTimeout(15); }
await page.waitForTimeout(300);
const before = await view(page);
assert(before > 0, "the settings page must be long enough to scroll");
const still = async (what) => {
await page.waitForTimeout(250);
assert.equal(await view(page), before, what + ": the click must not scroll the page");
};
const last = async (key, want) => {
for (let i = 0; i < 50 && !(posts.length && JSON.stringify(posts.at(-1)[key]) === JSON.stringify(want)); i++) await page.waitForTimeout(40);
assert.deepEqual(posts.at(-1)[key], want, key);
await still(key);
};
const pick = (box, name) => page.locator(`${box} .opt`, { hasText: name }).first().click();
await page.locator("#warmSegs .opt").nth(1).click();
await last("codexWarmup", "week");
await pick("#warmAtSegs", w.on);
await last("codexWarmAt", "06:00");
const field = page.locator("#warmAtSegs input.at");
await field.waitFor();
assert.equal(await field.inputValue(), "06:00", "the time field comes with the daily warm-up on");
await field.fill("07:30");
await field.dispatchEvent("change");
await last("codexWarmAt", "07:30");
const posted = posts.length;
await page.locator("#warmTab-claude").click();
await still("the Claude Code tab");
assert.equal(posts.length, posted, "a tab posts nothing");
assert.deepEqual((await shown(page)).panes, ["claude"]);
assert.deepEqual((await shown(page)).on, ["claude"]);
assert.deepEqual(await visibleRows(), [w.reset, w.daily]);
await thumbsUnderPicks("claude");
assert(await page.locator("#claudeWarmSegs").isVisible() && !(await page.locator("#warmSegs").isVisible()));
await page.locator("#claudeWarmSegs .opt").nth(2).click();
await last("claudeWarmup", "all");
await pick("#claudeWarmAtSegs", w.on);
await last("claudeWarmAt", "06:00");
await page.locator("#claudeWarmAtSegs input.at").waitFor();
await page.locator("#warmTab-wb").click();
await still("the WorkBuddy tab");
assert.deepEqual((await shown(page)).panes, ["wb"]);
assert.deepEqual(await visibleRows(), [w.checkin]);
await thumbsUnderPicks("wb");
await pick("#wbCheckinSegs", w.on);
await last("workbuddyCheckin", true);
await page.locator("#warmTab-codex").click();
await still("the Codex tab");
assert.deepEqual((await shown(page)).panes, ["codex"]);
assert.equal(await field.inputValue(), "07:30", "Codex's rows as they were left");
await pick("#warmAtSegs", w.off);
await last("codexWarmAt", "");
await page.waitForFunction(() => !document.querySelector("#warmAtSegs input.at"));
// the others kept as they were set
assert.equal(posts.at(-1).codexWarmup, "week");
assert.equal(posts.at(-1).claudeWarmup, "all");
assert.equal(posts.at(-1).claudeWarmAt, "06:00");
assert.equal(posts.at(-1).workbuddyCheckin, true);
// the keyboard: the arrows, Home and End move along the tabs
await page.locator("#warmTab-codex").focus();
await page.keyboard.press("ArrowRight");
assert.deepEqual(await shown(page), { on: ["claude"], panes: ["claude"], focus: "claude" });
await page.keyboard.press("End");
assert.deepEqual(await shown(page), { on: ["wb"], panes: ["wb"], focus: "wb" });
await page.keyboard.press("ArrowRight");
assert.deepEqual(await shown(page), { on: ["codex"], panes: ["codex"], focus: "codex" });
await page.keyboard.press("ArrowLeft");
assert.deepEqual((await shown(page)).on, ["wb"]);
assert.equal(await page.locator("#warmTab-wb").getAttribute("tabindex"), "0");
assert.equal(await page.locator("#warmTab-codex").getAttribute("tabindex"), "-1");
await still("the keyboard");
// WorkBuddy's tab remembered across a reload
await reload();
assert.deepEqual((await shown(page)).on, ["wb"]);
assert.deepEqual((await shown(page)).panes, ["wb"]);
assert.deepEqual(errors, []);
});
await t.test(lang + ": at the page's end", async () => {
const { page, errors } = await open(lang, [], { workbuddy: true });
// the page scrolled to its very end with the tabs in sight: WorkBuddy's
// shorter card, by a click or a key, leaves the tabs where they were
// (room kept at the view's foot), and Codex's taller one too
const room = await page.evaluate(() => {
const v = document.querySelector("#view-settings"), tabs = document.querySelector("#warmTabs");
return v.scrollHeight - (tabs.getBoundingClientRect().top - v.getBoundingClientRect().top + v.scrollTop);
});
await page.setViewportSize({ width: 900, height: Math.ceil(room) + 160 });
await page.mouse.move(450, 200);
const atEnd = () => page.locator("#view-settings").evaluate((v) => v.scrollHeight - v.clientHeight - v.scrollTop);
for (let i = 0; i < 120 && (await atEnd()) > 0.5; i++) { await page.mouse.wheel(0, 60); await page.waitForTimeout(15); }
await page.waitForTimeout(300);
assert((await atEnd()) <= 0.5, "scrolled to the end");
assert(await page.locator("#warmTab-wb").isVisible() && (await page.locator("#warmTab-wb").boundingBox()).y > 60, "the tabs in sight");
const tabY = () => page.locator("#warmTab-wb").evaluate((b) => b.getBoundingClientRect().top);
const y0 = await tabY();
await page.locator("#warmTab-wb").click();
await page.waitForTimeout(250);
assert.deepEqual((await shown(page)).panes, ["wb"]);
assert(Math.abs((await tabY()) - y0) <= 1, "a shorter card at the page's end moves nothing");
await page.locator("#warmTab-codex").click();
await page.waitForTimeout(250);
assert.deepEqual((await shown(page)).panes, ["codex"]);
assert(Math.abs((await tabY()) - y0) <= 1, "back to the taller card moves nothing");
await page.locator("#warmTab-codex").focus();
await page.keyboard.press("End");
await page.waitForTimeout(250);
assert.deepEqual((await shown(page)).panes, ["wb"]);
assert(Math.abs((await tabY()) - y0) <= 1, "a key on a tab moves nothing either");
assert.deepEqual(errors, []);
});
await t.test(lang + ": no WorkBuddy signed in", async () => {
// WorkBuddy's tab remembered, but no account: no tab, Codex shown
const { page, errors } = await open(lang, [], {}, "wb");
assert.equal(await page.locator("#warmTab-wb").isVisible(), false, "no WorkBuddy tab");
assert.deepEqual(await page.locator("#warmTabs [role=tab]:visible").allTextContents(), ["Codex", "Claude Code"]);
assert.deepEqual(await shown(page), { on: ["codex"], panes: ["codex"], focus: "" });
assert.equal(await page.locator("#wbList").isVisible(), false, "no WorkBuddy rows");
await page.setViewportSize({ width: 900, height: 1000 });
await page.locator("#warmTab-claude").click();
assert.deepEqual((await shown(page)).panes, ["claude"]);
assert.deepEqual(errors, []);
});
}
});
}