Files
magpie/internal/gui/tests/mobile-web.test.cjs
T
yetone d0d098c69f agents: the 「接入」 boards filled in — the opened row as designed, real accounts tried
- The opened row follows the boards: what to do in the agent, running copies with the old list, Claude Code's tiers (each with its own effort beside its model again, #536), the model list by whose models they are with what's hidden, the model new sessions start on, what connecting costs (Gemini's Google sign-in), and the files put back on disconnect, shown line by line.
- Disconnecting asks first and shows the lines it puts back. Secrets are masked, and a config of tens of thousands of lines diffs in a fraction of a second.
- Connecting prefers the agent's own subscription when magpie serves it (Claude, Codex).
- The dry run goes through proc.
- Every model hidden still keeps the row connected, with the way back to the list (#356).
- Routing groups are named in Chinese.
- Phone widths: the pickers and squares wrap inside the opened row, and the name stays beside its logo.

GUI tests moved to the new rows: connected fixtures carry refs and open the row from its link. tier-effort and mobile-web each found a real gap and now guard the fix.

Tried in a sandbox HOME with the owner's real accounts:
- Claude Code: connected, a request answered, and disconnect restored the settings exactly.
- OpenCode: a request answered.
- Gemini CLI: connected.
- Codex: connected.
- Model list picks were saved.

go vet, builds (darwin, GOOS=windows, GOOS=linux -tags nogui) and go test -tags nogui ./... under a temp HOME pass. The agent GUI tests pass except cli-update's tight-row case, menu-scroll and old-webkit, which fail on main too.
2026-10-04 02:05:10 +08:00

296 lines
17 KiB
JavaScript

// Run with Node's test runner and Playwright on the module path; see README.md.
// Phone navigation and content stay usable in both languages (#391). The
// desktop screenshots match BASE_REF within a small rendering tolerance
// (origin/main by default), but for the Agents page while BASE_REF hasn't its
// 「接入」 rows yet; only API boundaries are faked, never the page's layout or
// scrolling helpers.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const { PNG } = require("playwright-core/lib/utilsBundle");
const assets = path.resolve(__dirname, "../assets");
const base = process.env.BASE_REF || "origin/main";
const baseline = new Map();
const at = "2026-09-30T12:00:00.000Z";
const groups = [{ name: "Claude Code", icon: "claudecode-color", calls: 123, errors: 2,
input: 123456, output: 67890, cache_read: 543210, cache_write: 1234, reasoning: 12345, cost: 12.34, unpriced: 0 }];
const usage = { ...groups[0], path: "/test/usage.jsonl", bucket: "day", agents: groups,
models: [{ ...groups[0], name: "a-model-with-a-long-name" }],
series: Array.from({ length: 30 }, (_, i) => ({ ...groups[0], label: "09-" + String(i + 1).padStart(2, "0") })) };
// Populated rows are essential: an empty Agents list cannot expose clipped
// model/effort controls or make the desktop comparison cover those rows.
// Connected, so each opens on its pickers.
const modelOptions = [{ value: "claude-sonnet-4.5", label: "Claude Sonnet 4.5", icon: "claude-color", ref: "claude/claude-sonnet-4.5" },
{ value: "gpt-6.1-sol", label: "GPT-6.1-Sol", icon: "openai", ref: "openai/gpt-6.1-sol" }];
const modelField = (key, label, value = "") => ({ key, label, value, options: modelOptions });
const agentRows = [
{ id: "claude", name: "Claude Code", path: "/test/claude.json", icon: "claudecode-color", wired: true, fields: [
modelField("model", "model", "claude-sonnet-4.5"),
{ key: "effort", label: "thinking", value: "high", options: [{ value: "high", label: "High" }] },
] },
{ id: "codex", name: "Codex", path: "/test/codex.toml", icon: "codex-color", wired: true, fields: [
modelField("model", "model", "gpt-6.1-sol"),
{ key: "effort", label: "effort", value: "high", options: [{ value: "high", label: "High" }] },
modelField("subagent", "subagents"),
{ key: "signin", label: "sign-in", value: "magpie", options: [{ value: "magpie", label: "magpie" }, { value: "chatgpt", label: "ChatGPT" }] },
] },
{ id: "omp", name: "omp", path: "/test/omp.json", icon: "omp", wired: true, fields: [
modelField("model", "model", "gpt-6.1-sol"), modelField("subagent", "subagents"),
modelField("small", "smol"), modelField("slow", "slow"),
] },
];
const views = ["agents", "providers", "gateway", "routing", "usage", "library", "plugins", "settings"];
function baselineFile(file) {
if (!baseline.has(file)) baseline.set(file, execFileSync("git", ["show", `${base}:internal/gui/assets/${file}`], { cwd: assets, maxBuffer: 4 * 1024 * 1024 }));
return baseline.get(file);
}
function server(lang, original = false, opened = []) {
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:true};` });
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json({ agents: agentRows, profiles: [], settings: { lang, theme: "light" } });
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true, calls: [], groups: [] } });
if (url.pathname === "/api/groups") return json({ groups: [], pools: [], models: [] });
if (url.pathname === "/api/usage/quotas") {
// main's usage render can reveal the empty quota heading again if it
// finishes last. Deliver the empty allowances after the totals paint,
// so both screenshot pages compare the same completed API state.
await route.request().frame().page().locator("#usageAgents .row.stat").first().waitFor({ state: "attached", timeout: 30000 });
return json([]);
}
if (url.pathname === "/api/usage") return json(usage);
if (url.pathname === "/api/gateway/trace") return json({ mine: true, now: at, seq: 0, routes: [], totals: { requests: 0, rerouted: 0, errors: 0 } });
if (url.pathname === "/api/gateway/history") return json({ cut: false, days: [], routes: [] });
if (url.pathname === "/api/library") return json({ dir: "/test/library", home: "/test", agents: [], instructions: { agents: [], sets: [] }, servers: [], skills: [], projects: [], foundServers: [], foundSkills: [] });
if (url.pathname === "/api/plugins") return json({ bun: true, plugins: [], movable: [] });
if (url.pathname === "/api/plugins/listings") return json({ listings: [] });
if (url.pathname === "/api/plugins/npm") return json({ npm: {} });
if (url.pathname === "/api/plugins/market") return json({ listings: [], state: { bun: true, plugins: [] } });
if (url.pathname === "/api/library/reveal") { opened.push(route.request().postDataJSON()); return json({}); }
if (url.pathname.startsWith("/api/")) return json({});
const file = url.pathname === "/" ? "index.html" : url.pathname.slice(1);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
try {
let body;
if (original) body = baselineFile(file);
else body = await fs.readFile(path.join(assets, file));
await route.fulfill({ body, contentType });
} catch { await route.fulfill({ status: 404 }); }
};
}
async function open(browser, lang, width, { original = false, touch = true, opened = [] } = {}) {
const page = await browser.newPage({ viewport: { width, height: 900 }, hasTouch: touch, reducedMotion: "reduce", colorScheme: "light" });
page.setDefaultTimeout(5000);
await page.addInitScript(at => {
const RealDate = Date;
window.Date = class extends RealDate {
constructor(...args) { super(...(args.length ? args : [at])); }
static now() { return new RealDate(at).getTime(); }
};
}, at);
await page.route("http://magpie.test/**", server(lang, original, opened));
await page.goto("http://magpie.test/");
await page.locator("#nav button").first().waitFor();
return page;
}
async function go(page, view) {
// Playwright scrolls each real navigation button into sight before clicking;
// this exercises the phone's horizontal navigation strip too.
await page.locator(view === "settings" ? "#prefs" : `#nav [data-view="${view}"]`).click();
await page.locator(`#view-${view}`).waitFor();
if (view === "library") await page.locator("#view-library .lib-more:not(:disabled)").waitFor();
if (view === "plugins") await page.locator("#view-plugins .pm-find").waitFor();
if (view === "usage") {
await page.locator("#usageAgents .row.stat").waitFor();
await page.locator("#quotaHead").waitFor({ state: "hidden" });
}
await page.evaluate(() => document.fonts.ready);
await page.waitForTimeout(800); // let the tab's spring and content entrance settle
}
async function save(page, name) {
if (!process.env.ARTIFACT_DIR) return;
await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, name + ".png"), animations: "disabled" });
}
async function fits(page, view) {
const dimensions = await page.locator(`#view-${view}`).evaluate(v => ({
view: [v.scrollWidth, v.clientWidth], body: [document.body.scrollWidth, innerWidth],
document: [document.documentElement.scrollWidth, innerWidth],
}));
for (const [name, [actual, available]] of Object.entries(dimensions)) assert(actual <= available + 1, `${view}: ${name} scrolls sideways (${actual} > ${available})`);
}
async function agentsFit(page) {
for (const agent of agentRows) {
const row = page.locator(`.row.agent[data-id="${agent.id}"]`);
await row.waitFor();
// opened, its pickers in the row
await row.locator(".ag-link").click();
await row.locator(".ag-exp").waitFor();
const bad = await row.evaluate(row => {
const box = row.getBoundingClientRect(), bad = [];
for (const field of row.querySelectorAll(".field")) {
const b = field.getBoundingClientRect();
if (b.left < box.left - 1 || b.right > box.right + 1 || b.top < box.top - 1 || b.bottom > box.bottom + 1) bad.push("field cropped: " + field.dataset.key);
}
const model = row.querySelector('.field[data-key="model"] .v');
if (!model?.textContent || model.scrollWidth > model.clientWidth + 1) bad.push("model name cropped");
return bad;
});
assert.deepEqual(bad, [], agent.name + ": every control and model name must be visible");
for (const field of agent.fields) {
await row.locator(`.field[data-key="${field.key}"]`).click();
await page.locator("#pop").waitFor();
assert.equal(await page.locator("#pop").isVisible(), true, agent.name + ": " + field.key + " opens");
await page.locator('#nav [data-view="agents"]').click();
await page.locator("#pop").waitFor({ state: "hidden" });
}
}
}
async function usageFits(page) {
const bad = await page.evaluate(() => {
const bad = [];
for (const r of document.querySelectorAll(".row.stat")) {
const box = r.getBoundingClientRect();
for (const e of r.querySelectorAll(".num, .cost")) {
const b = e.getBoundingClientRect();
if (b.left < box.left || b.right > box.right + 1 || e.scrollWidth > e.clientWidth + 1) bad.push("tokens or cost cropped: " + e.textContent);
}
}
for (const e of document.querySelectorAll("#stats small")) if (e.scrollWidth > e.clientWidth + 1) bad.push("stat explanation cropped: " + e.textContent);
let last;
const dates = [];
for (const e of document.querySelectorAll("#chart .labels span")) {
if (!e.textContent || getComputedStyle(e).visibility === "hidden") continue;
dates.push(e.textContent);
const text = document.createRange(); text.selectNodeContents(e);
const b = text.getBoundingClientRect();
if (last && b.left < last.right + 2) bad.push("date labels overlap");
const box = e.parentElement.getBoundingClientRect();
if (b.left < box.left - 1 || b.right > box.right + 1) bad.push("date label outside chart");
last = b;
}
if (dates.length < 3 || dates[0] !== "09-01" || dates[dates.length - 1] !== "09-30") bad.push("chart must retain its endpoints and intermediate dates");
return bad;
});
assert.deepEqual(bad, []);
for (const selector of ["#usageAgents .cost", "#usageModels .cost"]) assert.match(await page.locator(selector).first().textContent(), /≈\$12\.34/);
}
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
test(engine + ": magpie web on phones", 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());
for (const lang of ["en", "zh"]) {
for (const width of [360, 390, 430, 820]) {
await t.test(`${lang} ${width}: every tab and page fits`, async () => {
const opened = [], errors = [], failures = [], page = await open(browser, lang, width, { opened });
page.on("pageerror", e => errors.push(e.message));
try {
for (const view of views) {
try {
await go(page, view);
await save(page, `${engine}-${lang}-${width}-${view}`);
await fits(page, view);
assert.equal(await page.locator(`#view-${view}`).isVisible(), true);
if (view === "agents") await agentsFit(page);
if (view === "usage") await usageFits(page);
if (view === "library") {
await Promise.all([
page.waitForResponse(r => new URL(r.url()).pathname === "/api/library/reveal"),
page.getByRole("button", { name: lang === "en" ? "Library folder" : "资源库文件夹", exact: true }).click(),
]);
assert.deepEqual(opened, [{ path: "/test/library" }], "Library folder must be reachable");
}
} catch (e) { failures.push(view + ": " + e.message); }
}
assert.deepEqual(errors, []);
assert.deepEqual(failures, []);
} finally { await page.close(); }
});
}
await t.test(lang + ": idle scripts cannot scroll, touch momentum can, then protection returns", async () => {
const page = await open(browser, lang, 390);
try {
// Settings now has separate parts. Its Usage part in a short phone
// viewport has real content to scroll; General fits without scrolling.
await page.setViewportSize({ width: 390, height: 600 });
await page.goto("http://magpie.test/?view=settings&tab=usage");
await go(page, "settings");
await page.waitForTimeout(500);
const view = page.locator("#view-settings");
assert(await view.evaluate(v => v.scrollHeight > v.clientHeight + 400));
await view.evaluate(v => { v.scrollTop = 300; });
await page.waitForTimeout(100);
assert.equal(await view.evaluate(v => v.scrollTop), 0, "idle code cannot move a phone page");
await view.evaluate(async v => {
for (const name of ["touchstart", "touchmove", "touchend"]) v.dispatchEvent(new Event(name, { bubbles: true }));
// OS momentum outlasts the original 250ms input window; the scroll
// events arrive 60ms apart, as they do while a finger's fling slows.
for (let i = 1; i <= 10; i++) {
await new Promise(r => setTimeout(r, 60));
v.scrollTop = i * 30;
}
});
await page.waitForTimeout(100);
assert.equal(await view.evaluate(v => v.scrollTop), 300, "continuous momentum must not be pulled back");
await page.waitForTimeout(350);
await view.evaluate(v => { v.scrollTop = 450; });
await page.waitForTimeout(100);
assert.equal(await view.evaluate(v => v.scrollTop), 300, "protection returns after momentum stops");
} finally { await page.close(); }
});
for (const width of [900, 1280]) {
await t.test(`${lang} ${width}: desktop matches ${base}`, async () => {
const current = await open(browser, lang, width, { touch: false });
const original = await open(browser, lang, width, { touch: false, original: true });
try {
for (const view of views) {
if (view === "agents" && !baselineFile("app.js").includes("function connectPanel")) continue;
await go(current, view); await go(original, view);
await current.mouse.move(0, 0); await original.mouse.move(0, 0);
// Removing hover can repaint the tab's shadow in a later frame.
await current.waitForTimeout(350); await original.waitForTimeout(350);
const before = PNG.sync.read(await original.screenshot({ animations: "disabled" }));
const after = PNG.sync.read(await current.screenshot({ animations: "disabled" }));
const sameSize = before.width === after.width && before.height === after.height;
let changedPixels = 0;
// Chromium can repaint antialiased edges differently even when
// comparing HEAD with itself. Count a pixel once only when one
// of its channels changes by more than 48; size changes always fail.
if (sameSize) for (let i = 0; i < before.data.length; i += 4) {
for (let channel = 0; channel < 4; channel++) {
if (Math.abs(before.data[i + channel] - after.data[i + channel]) > 48) {
changedPixels++;
break;
}
}
}
const same = sameSize && changedPixels <= 300;
if (!same) {
await save(original, `${engine}-${lang}-${width}-${view}-desktop-before`);
await save(current, `${engine}-${lang}-${width}-${view}-desktop-after`);
}
assert(same, `${view}: desktop screenshot changed (${sameSize ? changedPixels + " pixels differ by more than 48; limit 300" : "dimensions differ"})`);
}
} finally { await current.close(); await original.close(); }
});
}
}
});
}