Files
yetone 9f5812f3cf agents: an update says each agent's version from and to (37FlowAI on X: "更新后 显示一次各 Agent 的版本变更列表: 例如 Pi 从 v1.0x 更新到 v1.10")
Update all's line, when the run is done, and its toast now list every
agent it updated with the versions it went from and to ("2 agents
updated: Codex 0.155.1 → 0.159.0, Crush 0.3.0 → 0.4.0"); with some
failed, the ones that went well are listed before the ones that didn't.
The line stays one line (cut short when narrow, so the list under it
doesn't move), and its tooltip has each change and error on its own
line. A row's own pill says "Codex updated: 0.155.1 → 0.159.0".
Three new strings, in zh, zh-TW, ja and de.

Run: agent-update-all and cli-update in Chromium and WebKit (14 pass in
each); the new checks fail on origin/main's app.js (5 fail). A new
case updates two agents in en, zh, ja and de, and checks the line at
420 wide keeps its height with no sideways scroll. gui-de, gui-ja,
i18n-keys, cli-behind, click-scroll and agent-cli-missing pass in both
engines (22 each). Not tried in the real window with real CLIs behind.
2026-10-09 11:01:11 +08:00

199 lines
12 KiB
JavaScript

// Run with Node's test runner and Playwright on the module path; see README.md.
// The agents' CLIs on the Agents page (#202): each row's version after its
// name, an "Update to x.y.z" pill where a newer one is out and magpie knows
// how the CLI was installed, the version alone where it doesn't; the rows
// keep their height; a click updates in place (busy, then the new version,
// the pill gone) with the view left where it was; a failed update says why
// and gives the pill back; the words in Chinese. No backend: the API is
// faked here, and nothing is ever installed.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const models = ["model-a", "model-b"].map((m) => ({ value: m, label: m }));
const agent = (id, used = true) => ({
id, name: id === "codex" ? "Codex" : id, path: "/test/" + id, fields: [
{ key: "model", label: "model", value: used ? "model-a" : "", options: models },
{ key: "effort", label: "effort", value: used ? "high" : "", options: ["low", "medium", "high"].map((v) => ({ value: v, label: v })) },
],
});
const state = {
agents: [...Array.from({ length: 12 }, (_, i) => agent("agent-" + i)), agent("codex"), agent("gemini"), agent("unset-1", false)],
profiles: [], settings: { lang: "en", theme: "light" },
};
const CLIS = {
codex: { version: "0.155.1", latest: "0.159.0", via: "self", command: "codex update", update: true },
gemini: { version: "0.60.0", latest: "0.61.0", via: "npm", command: "npm install -g --prefix /x @google/gemini-cli@latest", update: true },
"agent-0": { version: "1.2.3" }, // installed some way magpie can't tell
"agent-1": { version: "2.0.0", latest: "2.0.0", via: "brew", command: "brew upgrade agent-1" },
};
function server(lang, posts, web = true) {
return async (route) => {
const req = route.request(), url = new URL(req.url());
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:${web}};` });
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return route.fulfill({ json: { ...state, settings: { lang, theme: "light" } } });
if (url.pathname === "/api/agents/cli" && req.method() === "GET") return route.fulfill({ json: { agents: CLIS, pending: false } });
if (url.pathname.startsWith("/api/agents/cli/") && req.method() === "POST") {
const id = url.pathname.split("/").pop();
posts.push(id);
await new Promise((r) => setTimeout(r, 700)); // an update takes a while
if (id === "gemini") return route.fulfill({ status: 500, json: { error: "npm install -g @google/gemini-cli@latest: EACCES: permission denied" } });
return route.fulfill({ json: { ...CLIS[id], version: CLIS[id].latest, update: false } });
}
if (url.pathname === "/api/usage/quotas") return route.fulfill({ json: [] });
if (url.pathname === "/api/groups") return route.fulfill({ json: { groups: [] } });
if (url.pathname === "/api/providers") return route.fulfill({ json: { providers: [], gateway: { running: true } } });
if (url.pathname.startsWith("/api/")) return route.fulfill({ json: {} });
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
await route.fulfill({ body: await fs.readFile(file), contentType });
};
}
const row = (id) => `.row.agent[data-id="${id}"]`;
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
test(engine + ": the agents' CLI versions and updates", async (t) => {
assert(["chromium", "webkit"].includes(engine), "BROWSER must be chromium or webkit");
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
const errors = [];
t.after(async () => {
if (errors.length) console.log(errors);
await browser.close();
});
const open = async (lang, posts, web = true) => {
const page = await (await browser.newContext({ viewport: { width: 980, height: 520 } })).newPage();
page.setDefaultTimeout(5000);
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", server(lang, posts, web));
await page.goto("http://magpie.test/");
await page.locator(`${row("codex")} .ag-ver`).waitFor();
return page;
};
await t.test("versions, and a pill only where magpie can update", async () => {
const page = await open("en", []);
assert.equal(await page.locator(`${row("codex")} .ag-ver`).textContent(), "0.155.1");
assert.equal(await page.locator(`${row("codex")} .ag-up`).textContent(), "Update to 0.159.0");
assert.match(await page.locator(`${row("codex")} .ag-up`).getAttribute("title"), /codex update/);
// installed some way magpie can't tell: the version, and nothing to click
assert.equal(await page.locator(`${row("agent-0")} .ag-ver`).textContent(), "1.2.3");
assert.equal(await page.locator(`${row("agent-0")} .ag-up`).count(), 0);
assert.match(await page.locator(`${row("agent-0")} .ag-ver`).getAttribute("title"), /can't tell how it was installed/);
// up to date
assert.equal(await page.locator(`${row("agent-1")} .ag-up`).count(), 0);
// no CLI known: nothing at all
assert.equal(await page.locator(`${row("agent-2")} .ag-ver`).count(), 0);
// on the name's line: a row with a pill is as tall as one without
const h = await page.evaluate((s) => s.map((q) => document.querySelector(q).getBoundingClientRect().height), [row("codex"), row("agent-2")]);
assert(Math.abs(h[0] - h[1]) <= 1, `rows differ in height: ${h}`);
// the name, version and pill sit on one line
const tops = await page.evaluate((r) => [".name", ".ag-ver", ".ag-up"].map((s) => {
const b = document.querySelector(r + " " + s).getBoundingClientRect();
return b.top + b.height / 2;
}), row("codex"));
assert(Math.max(...tops) - Math.min(...tops) <= 2, `not on one line: ${tops}`);
if (process.env.ARTIFACT_DIR) {
await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-cli-update.png`) });
await page.setViewportSize({ width: 980, height: 1000 });
await page.waitForTimeout(300);
await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-cli-update-all.png`) });
}
});
await t.test("an update, the view left where it was", async () => {
const posts = [];
const page = await open("en", posts);
const view = page.locator("#view-agents");
// the reader scrolls down until a pill is in view, clear of the
// footer, so a scroll would show
const wheelTo = async (id) => {
const pill = page.locator(`${row(id)} .ag-up`);
await page.mouse.move(400, 300);
for (let i = 0; i < 30 && await pill.evaluate((b) => b.getBoundingClientRect().bottom > 460); i++) {
await page.mouse.wheel(0, 40);
await page.waitForTimeout(60);
}
await page.waitForTimeout(300);
};
await wheelTo("codex");
const top = await view.evaluate((v) => v.scrollTop);
assert(top > 0, "the list must be scrolled");
const was = await page.locator(`${row("codex")} .ag-up`).evaluate((b) => b.getBoundingClientRect().top);
await page.locator(`${row("codex")} .ag-up`).click();
await page.waitForTimeout(150);
assert.equal(await page.locator(`${row("codex")} .ag-up`).textContent(), "Updating…");
assert.equal(await page.locator(`${row("codex")} .ag-up.busy`).count(), 1);
// its ring turns inside the svg, round the viewBox's centre: a turned
// svg circles about a point off its centre in WebKit (owner, 10-09)
assert.deepEqual(await page.locator(`${row("codex")} .ag-up svg`).evaluate((s) => [getComputedStyle(s).animationName, getComputedStyle(s.firstElementChild).animationName, getComputedStyle(s.firstElementChild).transformBox]), ["none", "spin", "view-box"]);
// clicked again while it runs: nothing more is asked
await page.locator(`${row("codex")} .ag-up`).click({ force: true });
await page.waitForTimeout(900);
assert.deepEqual(posts, ["codex"]);
assert.equal(await page.locator(`${row("codex")} .ag-ver`).textContent(), "0.159.0");
assert.equal(await page.locator(`${row("codex")} .ag-up`).count(), 0);
// the version it went from too (37FlowAI on X)
assert.equal(await page.locator("#status").textContent(), "Codex updated: 0.155.1 → 0.159.0");
assert.equal(await view.evaluate((v) => v.scrollTop), top, "the update moved the page");
const now = await page.locator(`${row("codex")} .ag-ver`).evaluate((b) => b.getBoundingClientRect().top);
assert(Math.abs(now - was) <= 3, `the row moved ${now - was}px`);
// one that fails says why, and can be tried again
await wheelTo("gemini");
await page.locator(`${row("gemini")} .ag-up`).click();
await page.waitForTimeout(1000);
assert.match(await page.locator("#status").textContent(), /EACCES/);
assert.equal(await page.locator(`${row("gemini")} .ag-up`).textContent(), "Update to 0.61.0");
assert.equal(await page.locator(`${row("gemini")} .ag-up.busy`).count(), 0);
});
await t.test("in Chinese", async () => {
const page = await open("zh", []);
assert.equal(await page.locator(`${row("codex")} .ag-up`).textContent(), "更新到 0.159.0");
assert.match(await page.locator(`${row("codex")} .ag-up`).getAttribute("title"), /通过 codex update 更新/);
});
const lookAt = (page) => page.evaluate((r) => {
const q = (s) => document.querySelector(r + " " + s);
const shown = (e) => !!e && e.getBoundingClientRect().width > 0 && getComputedStyle(e).display !== "none";
const name = q(".name"), up = q(".ag-up"), words = q(".ag-up > span");
return { cut: name.scrollWidth > name.clientWidth + 1, up: shown(up), words: shown(words), ver: shown(q(".ag-ver")), label: up?.getAttribute("aria-label") };
}, row("codex"));
// the update pill gives way before the name: where the name's line is
// under 250px the pill keeps only its arrow (under 150px the version
// goes too). Since the 「接入」 rows (d0d098c6) a connected agent's fields
// fold away under the row, and magpie web 760px wide or less (#391) puts
// them under the name, so the line is no longer tight at 620px, the
// window's first width, as it was beside the fields; it is on a phone.
await t.test("a tight row cuts the pill before the name", async () => {
const all = { cut: false, up: true, words: true, ver: true, label: "更新到 0.159.0" };
const arrow = { ...all, words: false };
const at = async (page, width) => {
await page.setViewportSize({ width, height: 520 });
await page.waitForTimeout(100);
return lookAt(page);
};
// the app's window, wide and at its narrowest (560px): everything
const win = await open("zh", [], false);
assert.deepEqual(await lookAt(win), all);
assert.deepEqual(await at(win, 620), all, "620px window");
assert.deepEqual(await at(win, 560), all, "560px window");
// magpie web: everything down to about 500px, then the arrow alone,
// the name never cut, on to the narrowest phones
const web = await open("zh", []);
for (const width of [620, 540]) assert.deepEqual(await at(web, width), all, width + "px");
for (const width of [480, 430, 400, 375, 340, 320]) assert.deepEqual(await at(web, width), arrow, "the pill kept its words, or the name was cut, at " + width + "px");
});
assert.deepEqual(errors, []);
});
}