Files
magpie/internal/gui/tests/cli-update.test.cjs
T
yetone 97b5a386a1 tests: cli-update's tight agent row is looked for where a row is tight now — the subtest made the window 620px and 604px and expected the update pill to keep only its arrow and then the version to go, as they did while an agent's fields stood beside its name. Bisected: it first failed at f3ba9184 (#391, magpie web on phones), where magpie web 760px wide or less puts the fields under the name; the fixture boots as magpie web (bootPrefs.web), so the name's line spanned the row at 620px (~378px of room) and the pill kept its words. Booted as the app's window it would have failed from d0d098c6 (the 「接入」 rows): a connected agent's fields fold away under the row, so the name's line is 378px at 620px (164px before it) and 318px at the window's narrowest, 560px. No behaviour regressed: the container rules (pill to its arrow under 250px, version gone under 150px, name last) are as before, and in magpie web the pill keeps only its arrow from about 490px down, the name never cut, to 320px; the name's line never gets under 150px now, so the version stays. The fixture can boot as the window (open(lang, posts, web)); the subtest checks the window wide, at 620px and at 560px shows everything, and magpie web shows everything at 620px and 540px and the arrow alone, the name whole, at 480, 430, 400, 375, 340 and 320px. It fails with the pill's container rule taken out. Webkit cli-update passes (5/5); agent-update-all, agent-connect, agent-layout, agent-grip, cli-behind, click-scroll and agent-connected-unfolded pass (click-scroll on its own; run in one batch with the others it failed once under load). No app code changed.
2026-10-04 08:59:11 +08:00

195 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);
// 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);
assert.match(await page.locator("#status").textContent(), /Codex updated to 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, []);
});
}