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:
1 parent
1eb2640d7e
commit
f3ba918491
7 files changed
+466
-20
No files matched your search
+82
-14
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user