From f3ba918491860976d501b7bf0881f6f564458e2d Mon Sep 17 00:00:00 2001 From: Arvak Date: Fri, 2 Oct 2026 10:15:42 +0800 Subject: [PATCH] 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 --- internal/gui/assets/app.css | 96 +++++++-- internal/gui/assets/app.js | 36 +++- internal/gui/assets/index.html | 2 +- internal/gui/assets/library.css | 9 + internal/gui/assets/routing.css | 28 ++- internal/gui/tests/README.md | 29 +++ internal/gui/tests/mobile-web.test.cjs | 286 +++++++++++++++++++++++++ 7 files changed, 466 insertions(+), 20 deletions(-) create mode 100644 internal/gui/tests/mobile-web.test.cjs diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 1640f5b7..86e4b9ed 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -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; } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 06b7ef38..45fe384d 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -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 diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html index b743c994..f8c4c9d7 100644 --- a/internal/gui/assets/index.html +++ b/internal/gui/assets/index.html @@ -2,7 +2,7 @@ - + magpie diff --git a/internal/gui/assets/library.css b/internal/gui/assets/library.css index 8aab4505..36fd3fd4 100644 --- a/internal/gui/assets/library.css +++ b/internal/gui/assets/library.css @@ -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; } diff --git a/internal/gui/assets/routing.css b/internal/gui/assets/routing.css index 30fdffa4..35439911 100644 --- a/internal/gui/assets/routing.css +++ b/internal/gui/assets/routing.css @@ -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; } diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index 29408698..ac95557b 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -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 +``` diff --git a/internal/gui/tests/mobile-web.test.cjs b/internal/gui/tests/mobile-web.test.cjs new file mode 100644 index 00000000..6ef5896b --- /dev/null +++ b/internal/gui/tests/mobile-web.test.cjs @@ -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(); } + }); + } + } + }); +}