gui: make magpie web usable on phone screens (#391)

* gui: make magpie web usable on phone screens

* gui: keep phone content readable and retain idle scroll protection

* gui: tolerate small desktop screenshot rendering differences

* tests: synchronize Zhipu team quota request records

* gui: keep every agent field visible on phones

* gui: exercise touch scrolling in the settings usage part

* gui: serve plugin parts in the phone test fixture
This commit is contained in:
Arvak authored and GitHub committed 2026-10-02 10:15:42 +08:00
1 parent 1eb2640d7e
commit f3ba918491
7 files changed
+466 -20

No files matched your search

+82 -14
View File
@@ -269,15 +269,20 @@ body.window .top.inrow .actions { margin-left: 0; }
the icons in a row, the tabs at their size in a row of their own under
them, sliding sideways when they don't fit (they were squeezed into the
room between, too small to read or tap) */
@media (max-width: 600px) {
body.web .top { flex-wrap: wrap; height: auto; row-gap: 4px; padding: 6px 10px 8px 14px; }
body.web .top .brand, body.web .top .actions { height: 30px; align-items: center; }
body.web .top .seg {
position: relative; left: auto; top: auto; transform: none; order: 3; flex: 1 0 100%; max-width: 100%;
overflow-x: auto; overflow-y: hidden; scrollbar-width: none; overscroll-behavior-x: contain;
@media (max-width: 760px), (pointer: coarse) and (max-width: 1024px) {
body.web .top {
height: auto; min-height: 48px; flex-wrap: wrap; gap: 4px 8px;
padding: calc(6px + env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) 8px max(12px, env(safe-area-inset-left));
}
body.web .top .seg::-webkit-scrollbar { display: none; }
body.web .top .seg button { flex: 1 0 auto; height: 30px; padding: 0 12px; font-size: 13px; }
body.web .top .brand, body.web .top .actions { min-height: 40px; align-items: center; }
body.web .top #nav {
position: relative; left: auto; top: auto; transform: none;
order: 3; flex: 0 0 100%; min-width: 0; max-width: 100%;
overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: none;
}
body.web .top #nav::-webkit-scrollbar { display: none; }
body.web .top #nav button { flex: none; height: 40px; padding: 0 14px; font-size: 14px; }
body.web .top .actions { gap: 4px; }
}
@keyframes update-in { from { opacity: 0; transform: translateY(-3px) scale(.94); } }
@keyframes spin { to { transform: rotate(360deg); } }
@@ -2013,12 +2018,6 @@ input.words.search-key, input.words.search-url { flex: 1 1 120px; width: auto; m
.row.pref.lan-container .sub { white-space: normal; }
.pop.proto-menu.lan-address-menu { width: min(320px, calc(100vw - 16px)); max-height: min(360px, 70vh); overflow-y: auto; }
.lan-address-menu .pm-name { font: 12px var(--code); }
/* a phone: a control too wide for the row beside its name goes under it
rather than cut the name to a few letters */
@media (max-width: 600px) {
body.web .row.pref:not(.lan-address-row) { flex-wrap: wrap; row-gap: 8px; }
body.web .row.pref:not(.lan-address-row) .who { flex: 1 1 150px; }
}
@media (max-width: 600px) {
.row.pref.lan-address-row { height: 76px; }
.lan-address-controls .segs { right: 12px; }
@@ -2133,6 +2132,75 @@ body.panel .modal { padding: 16px 12px; }
.skeleton { animation: none !important; }
}
/* A browser on a phone: keep the desktop and tray's layout independent.
Tabs scroll on their own row, content keeps the available height, and
form controls are large enough to use without zooming the page. */
@media (max-width: 760px), (pointer: coarse) and (max-width: 1024px) {
body.web { height: 100%; font-size: 14px; }
body.web .icon { width: 44px; height: 44px; }
body.web .update { min-height: 36px; }
body.web .view {
--view-left: max(12px, env(safe-area-inset-left));
--view-right: max(12px, env(safe-area-inset-right));
padding: 2px var(--view-right) 12px var(--view-left);
}
body.web .foot { padding-bottom: max(8px, env(safe-area-inset-bottom)); min-width: 0; }
body.web .foot .status { min-width: 0; overflow-wrap: anywhere; }
body.web .row-head { height: auto; min-height: 40px; flex-wrap: wrap; gap: 4px; }
body.web .text, body.web .field, body.web .segs .opt, body.web .copy { min-height: 44px; }
body.web .text { padding: 8px 10px; }
body.web .copy { min-width: 44px; }
body.web .fields { width: 100%; grid-template-columns: minmax(0, 1fr) minmax(0, 124px); }
body.web .row.agent { flex-wrap: wrap; }
body.web .row.agent > .fields { flex-basis: 100%; }
/* Extra controls must not keep the desktop's fixed grid width. The model
gets the full row; effort and squares wrap below it without clipping. */
body.web #agents .row.agent > .fields,
body.web #agents.extras .row.agent > .fields {
display: flex; flex-wrap: wrap; width: 100%; min-width: 0; margin-left: 0;
}
body.web #agents .fields > .field.main,
body.web #agents .fields > .field.solo { flex: 1 0 100%; }
body.web #agents .fields > .field.side { flex: 1 0 124px; }
body.web #agents .extras-cell { flex-wrap: wrap; max-width: 100%; }
body.web #agents .field.extra { width: 44px; min-width: 44px; }
body.web .row.pref { flex-wrap: wrap; gap: 8px; padding: 12px; }
body.web .row.pref > .who { flex-basis: 100%; }
body.web .row.pref .sub { white-space: normal; }
body.web .row.pref .segs { margin-left: 0; max-width: 100%; flex-wrap: wrap; }
body.web .proxy-segs { margin-left: 0; flex-wrap: wrap; justify-content: flex-start; }
body.web .usage-head { height: auto; min-height: 44px; flex-wrap: wrap; gap: 8px; }
body.web .usage-head > .segs { max-width: 100%; flex-wrap: wrap; }
body.web .usage-head > .grow, body.web .sess-tools > .grow { display: none; }
body.web .sess-filter { order: 1; flex: 1 1 100%; width: 100%; }
body.web .sess-pick { min-height: 44px; max-width: 100%; font-size: 14px; }
body.web .stats, body.web .stats.six { grid-template-columns: repeat(2, minmax(0, 1fr)); }
body.web .kpi small { white-space: normal; overflow: visible; overflow-wrap: anywhere; }
body.web .row.stat { display: grid; grid-template-columns: 20px minmax(0, 1fr) auto; gap: 6px 8px; }
body.web .row.stat .who { grid-column: 2; grid-row: 1; }
body.web .row.stat .who .sub { white-space: normal; }
body.web .row.stat .cost { grid-column: 3; grid-row: 1; }
body.web .row.stat .num { grid-column: 2 / 4; grid-row: 2; min-width: 0; align-items: flex-start; }
body.web .row.stat .num small { max-width: 100%; white-space: normal; }
body.web .row.stat .share { grid-column: 2 / 4; grid-row: 3; margin: 0; }
body.web .connect { grid-template-columns: minmax(0, 1fr); gap: 6px; }
body.web .connect label { text-align: left; margin-top: 6px; }
body.web .connect .segs { flex-wrap: wrap; }
body.web .connect .val { height: auto; min-height: 44px; }
body.web input:not([type="range"]):not([type="checkbox"]):not([type="radio"]),
body.web textarea, body.web select { font-size: 16px; min-height: 44px; }
body.web .modal { padding: max(8px, env(safe-area-inset-top)) max(8px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left)); }
body.web .modal .editor.framed > .ebody, body.web .editor { grid-template-columns: minmax(0, 1fr); gap: 6px; padding: 12px; }
body.web .editor label { text-align: left; padding-top: 8px; }
body.web .editor .segs { max-width: 100%; flex-wrap: wrap; }
body.web .editor .proxy-row { flex-wrap: wrap; }
body.web .editor .proxy-row input.proxy-url { flex-basis: 100%; }
body.web .editor .bar { flex-wrap: wrap; }
body.web .pop { max-width: calc(100vw - 16px); max-height: calc(100% - 16px); }
body.web .pop .picker-body { min-height: 0; }
body.web .pop #list li { min-height: 44px; }
}
/* Import from other apps: a checklist per app */
.editor.importapps { grid-template-columns: minmax(0, 1fr); }
.editor.importapps .appnote { font-size: 12px; color: var(--muted); line-height: 1.5; }
+32 -4
View File
@@ -11,7 +11,7 @@ if (web) document.body.classList.add("web");
// iOS zooms the page into a field it focuses whose text is under 16px, and
// leaves it zoomed; at most 1 stops that, and Safari still lets a pinch zoom
if (web && (/iP(hone|ad|od)/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1))) {
document.querySelector('meta[name="viewport"]')?.setAttribute("content", "width=device-width, initial-scale=1, maximum-scale=1");
document.querySelector('meta[name="viewport"]')?.setAttribute("content", "width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover");
}
// The Mac window draws its title bar inside the page (the traffic lights);
// on Linux the page's header is the whole title bar (plainTitlebar), so it
@@ -1841,6 +1841,8 @@ function score(q, o) {
function placePop(anchor, w, h) {
const pop = $("#pop");
const r = anchor.getBoundingClientRect(), pad = 8;
w = Math.min(w, innerWidth - pad * 2);
h = Math.min(h, innerHeight - pad * 2);
pop.style.width = w + "px";
let x = Math.max(pad, Math.min(r.left, innerWidth - w - pad));
let y = r.bottom + 5;
@@ -1930,7 +1932,9 @@ function openPicker(agent, field, anchor, ev, only) {
q.value = "";
q.placeholder = modelPicker && extra(field) ? t("{field} — filter, or type any model id…", { field: t(field.label) }) : modelPicker ? t("Filter, or type any model id…") : t("Filter {field}…", { field: t(field.label) });
filter();
q.focus();
// On touch screens leave the keyboard closed until the filter is tapped,
// so opening a model list leaves room to browse its choices.
if (!web || !matchMedia("(pointer: coarse)").matches) q.focus();
}
function effortName(option) {
@@ -8448,6 +8452,28 @@ function quotaWindows(sub) {
return windows;
}
// Keep the last date and as many earlier dates as fit, without clipping their
// text. Visibility keeps each label aligned to its bar; resizing restores them.
function fitChartLabels() {
const narrow = web && matchMedia("(max-width: 760px), (pointer: coarse) and (max-width: 1024px)").matches;
for (const labels of document.querySelectorAll(".chart .labels")) {
const spans = [...labels.children];
for (const span of spans) span.style.visibility = "";
if (!narrow || !labels.offsetWidth) continue;
const box = labels.getBoundingClientRect();
let right = box.right + 8;
for (const span of spans.reverse()) {
if (!span.textContent) continue;
const range = document.createRange();
range.selectNodeContents(span);
const r = range.getBoundingClientRect();
if (r.right + 8 > right + 1 || r.left < box.left - 1) span.style.visibility = "hidden";
else right = r.left;
}
}
}
new ResizeObserver(fitChartLabels).observe($("#view-usage"));
function renderUsage() {
const u = usage;
const view = $("#view-usage");
@@ -8518,6 +8544,7 @@ function renderUsage() {
labels.append(el("span", "", i % every === 0 || last ? p.label : ""));
});
chart.append(el("div", "peak", fmtN(peak)), bars, labels);
fitChartLabels();
const total = Math.max(1, tokensOf(u));
const list = (id, groups) => {
@@ -10152,6 +10179,7 @@ function renderSessChart(chart, st, used, ov, acts) {
labels.append(el("span", "", i % every === 0 || end ? label : ""));
});
chart.append(bars, labels);
fitChartLabels();
function drawHead() {
const head = el("div", "sess-chart-head");
@@ -12347,7 +12375,7 @@ setTimeout(wag, 250);
// magpie web on a phone, or a browser as narrow: the header is two rows,
// the magpie and the icons over the tabs, which scroll sideways when they
// don't fit, the one open kept in sight
function phoneWeb() { return document.body.classList.contains("web") && matchMedia("(max-width: 600px)").matches; }
function phoneWeb() { return document.body.classList.contains("web") && matchMedia("(max-width: 760px), (pointer: coarse) and (max-width: 1024px)").matches; }
function navInSight() {
const nav = $("#nav"), on = nav?.querySelector("button.on");
if (!phoneWeb() || !on || nav.scrollWidth <= nav.clientWidth) return;
@@ -12355,7 +12383,7 @@ function navInSight() {
if (nav.scrollLeft > l) nav.scrollLeft = l;
else if (nav.scrollLeft < r) nav.scrollLeft = r;
}
matchMedia("(max-width: 600px)").addEventListener?.("change", () => { fitTop(); navInSight(); });
matchMedia("(max-width: 760px), (pointer: coarse) and (max-width: 1024px)").addEventListener?.("change", () => { fitTop(); navInSight(); });
// A narrow window has no room for the whole header: the name goes, leaving
// the magpie, and Update becomes its arrow; narrower still, the tabs stop
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>magpie</title>
<link rel="icon" href="icons/favicon.svg" type="image/svg+xml">
<script src="boot.js"></script>
+9
View File
@@ -12,6 +12,15 @@
}
/* the window's view is padded 2px at the top: the strip reaches over it */
body.window .lib-head { top: -2px; margin: -2px -16px 0; padding: 8px 16px 4px; }
@media (max-width: 760px), (pointer: coarse) and (max-width: 1024px) {
body.web .lib-head {
flex-wrap: wrap;
margin: -2px calc(-1 * var(--view-right)) 0 calc(-1 * var(--view-left));
padding: 8px var(--view-right) 4px var(--view-left);
}
body.web .lib-tabs { max-width: 100%; flex-wrap: wrap; }
body.web .lib-more { flex: none; }
}
.lib-head.stuck { box-shadow: 0 1px 0 var(--line-2); }
.lib-tabs .opt { height: 24px; padding: 0 12px; font-size: 12.5px; }
.lib-more { display: inline-flex; align-items: center; gap: 5px; }
+27 -1
View File
@@ -299,7 +299,33 @@ button.rt-session { cursor: pointer; }
.rt-pool .ctl { display: grid; grid-template-columns: auto auto; align-items: center; justify-items: start; gap: 6px 8px; }
.rt-pool .lab { display: contents; }
.rt-pool .lab small { font-size: 11px; color: var(--muted); }
@media (max-width: 560px) { .rt-group .tags { display: none; } }
@media (max-width: 560px) { body:not(.web) .rt-group .tags { display: none; } }
@media (max-width: 760px), (pointer: coarse) and (max-width: 1024px) {
body.web .rt-group {
display: grid; grid-template-columns: 20px minmax(0, 1fr) auto;
gap: 6px 8px; padding: 12px;
}
body.web .rt-group .tags { display: flex; flex-wrap: wrap; grid-column: 2 / -1; grid-row: 2; }
body.web .rt-group > .text { grid-column: 3; grid-row: 1; }
body.web .rt-group .nm { flex-wrap: wrap; }
body.web .rt-group .mem { white-space: normal; overflow-wrap: anywhere; }
body.web .rt-pick { height: auto; min-height: 44px; padding: 8px; font-size: 12.5px; }
body.web .rt-pick { flex-wrap: wrap; }
body.web .rt-gedit { grid-template-columns: minmax(0, 1fr); gap: 6px; padding: 12px; }
body.web .rt-gedit label { text-align: left; padding-top: 8px; }
body.web .rt-gedit .segs { flex-wrap: wrap; }
body.web .rt-gedit .fbrow { flex-wrap: wrap; }
body.web .rt-gedit .fbrow .n { flex: 1 1 calc(100% - 60px); white-space: normal; overflow-wrap: anywhere; }
body.web .rt-gedit .fbrow > .grow { display: none; }
body.web .rt-gedit .fbrow .text { min-width: 44px; }
body.web .rt-cond { height: auto; min-height: 44px; }
body.web .rt-gadd { min-height: 44px; }
body.web .rt-pool .ctl { grid-template-columns: minmax(0, 1fr); }
body.web .rt-pool .lab small { padding-top: 6px; }
body.web .rt-pool .segs { flex-wrap: wrap; }
body.web .rt-stats { flex-wrap: wrap; gap: 10px 16px; }
body.web #view-routing > .row-head .note { flex: 1 1 100%; order: 1; margin: 0; }
}
.rt-gsec .segs .opt { white-space: nowrap; }
@container (max-width: 520px) {
.rt-gsec .row-head .note { display: none; }
+29
View File
@@ -1351,3 +1351,32 @@ Install and the installed plugin's "v… out". It runs in Chromium and
WebKit, in English and Chinese:
node --test internal/gui/tests/plugin-progressive.test.cjs
```
## Mobile Web
`mobile-web.test.cjs` checks all eight web pages and their navigation in
Chromium and WebKit, English and Chinese, at touch widths 360/390/430/820.
Agent fixtures include model/effort controls, Codex's subagent/sign-in squares
and omp's three role squares. Every phone field must stay inside its row, model
names must be readable, and each picker must open; desktop comparisons use
those same populated rows.
It checks that Library's folder button is reachable, Usage tokens, costs and
stat explanations are readable, and chart dates do not overlap. A released
touch's continuous scroll can outlast the input window, while scripts cannot
move an idle page, including after momentum stops. This uses Settings → Allowances
at 390×600, where the real settings rows leave room to scroll. The API is faked;
no user configuration is read or changed.
At desktop widths 900/1280, screenshots are compared with the same pages from
`origin/main`. Dimensions must match; a pixel counts as different only when
any RGBA channel differs by more than 48, and more than 300 such pixels fails.
This tolerates Chromium's small antialiasing differences while still detecting
layout changes. Fetch that branch before running; set `BASE_REF` to another
local Git ref (`HEAD` for a self-comparison) to select a particular baseline.
PNG decoding uses the copy bundled with Playwright, without another dependency.
The empty allowance fixture completes after usage totals render, so both pages
compare the same completed API state rather than a loading-order difference.
```sh
node --test internal/gui/tests/mobile-web.test.cjs internal/gui/tests/old-webkit.test.cjs
```
+286
View File
@@ -0,0 +1,286 @@
// 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); 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.
const modelOptions = [{ value: "claude-sonnet-4.5", label: "Claude Sonnet 4.5", icon: "claude-color" },
{ value: "gpt-6.1-sol", label: "GPT-6.1-Sol", icon: "openai" }];
const modelField = (key, label, value = "") => ({ key, label, value, options: modelOptions });
const agentRows = [
{ id: "claude", name: "Claude Code", path: "/test/claude.json", icon: "claudecode-color", 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", 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", 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 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) {
if (!baseline.has(file)) baseline.set(file, execFileSync("git", ["show", `${base}:internal/gui/assets/${file}`], { cwd: assets, maxBuffer: 4 * 1024 * 1024 }));
body = baseline.get(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();
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) {
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(); }
});
}
}
});
}