* update: say so when the `magpie` command is a copy the app left behind install.sh links the terminal's magpie into ~/.local/bin to the app's own binary, so it follows every GUI update for free. A command that arrived as a copied file — an older installer, a hand cp — keeps running its own build once magpie.app moves on, and a command behind the app is a stale build: one that predates the WebDAV short-write check (#531) truncated a remote backup on `magpie webdav now`, wedging the sync on every tick after. The gap is macOS-only: on Linux the app and the command are one file, and its update replaces it in place. magpie doesn't rewrite the user's PATH itself — that's the installer's job, and a background update has no business reaching there. What the command *is* says whether it can follow the app, so update.StaleCLI is a plain Lstat of the installer's own convention (MAGPIE_BIN_DIR, else ~/.local/bin): a regular file is behind, a link isn't, and nothing runs the old binary to ask its version. When it's behind, magpie says so where the user can see it: /api/state carries cliBehind and the window's status line says it once per run (the panel stays quiet, too small for advice), and `magpie update` adds the line after updating the app it found through the same copied command. TestStaleCLI pins the three file states over a temp bin dir; cli-behind.test.cjs (WebKit and Chromium, en and zh) fakes a state with cliBehind and checks the window says it, the panel doesn't, and a following command is never mentioned. go vet, and go test -tags nogui ./internal/update ./internal/gui under a temp HOME pass. * update: only say the command is behind when it is, and how to fix it Two things the review caught about the notice. A copy that isn't actually behind the app shouldn't nag. The standalone magpie-cli build is a legitimate regular file in ~/.local/bin beside the app, and the first cut warned about it on every window load however current it was. StaleCLI now takes the app's version and stays quiet unless the copy is a release that trails it — a copy at or ahead of the app, a build from source, and one that won't run are all left be. Reading the version runs the old binary, whose start-up migrates things, but that's the only way to tell a stale release from a current one, and it's reached only for a file already known by Lstat to be a copy, once per run. The advice named a step that didn't fix it. `magpie update` run from a copied command has no bundle to update (the copy isn't inside a .app), so it replaces the copy with another copy and the warning comes back. Both the window's line and `magpie update`'s now point at what actually works: re-run the installer, or link the command by hand — `ln -sf <app>/Contents/MacOS/magpie <cli>`, the link install.sh makes. TestStaleCLI covers the version decision (behind → say, current, ahead, source, unreadable → quiet) over a temp bin dir; the browser test's wording follows. Verified by mutation: dropping the version guard turns the stale case red, and Stat for Lstat turns the link case red. go vet, and go test -tags nogui ./internal/update ./internal/gui under a temp HOME pass. * gui: the window's copied-command advice is an Lstat with a per-version dismiss (yetone's review on #623) What: the window no longer runs the old binary's `version` to tell a stale copy from a current one. update.CopiedCLI is a bare Lstat, reached on every launch; the version read stays in update.StaleCLI, which only `magpie update` calls. The advice moved from a 9s status line to a small dialog with "Hide until the next version", kept in settings' cli-behind-quiet file as the version it was dismissed at, so the next app update asks once more. POST /api/cli-behind/quiet writes it. Why: `magpie version` isn't read-only — run() migrates settings before the command answers, so an old copy rewrote the user's real config with the old build's migrations (old MoveOffAccountIDs could drop fields), and the read on the first /api/state cost up to 2s on a cold start. An Lstat can't tell whether the copy trails, so what the window says is advice, not a warning that knows — dismissable, and worded as a copy rather than a stale one. Tests: TestCLIBehindIsLstatOnlyAndDismissible (a non-executable file is reported — nothing ran; dismiss quiets it and the file holds this version; the installer's symlink is quiet). cli-behind.test.cjs covers the dialog, its dismiss POST and the quiet panel, chromium+webkit, en+zh, 4/4. go vet, go build -tags nogui (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass (netproxy's TestBunThroughBridge fails the same way on the branch without this change). Co-Authored-By: Claude Code <noreply@anthropic.com> * gui: the dismiss survives a restart, and cliBehindVal is behind a lock (yetone's review on #623) What: the POST handler wrote cliBehindVal = "" while state() read it on other goroutines with no lock — a page dismissing while another view polled /api/state raced. cliBehindVal now sits behind cliBehindMu on both sides. The test also proves the promise the handler's one-line clear couldn't: after a simulated restart (fresh Once, empty value) the dismissed advice stays quiet, and the next version asks once more. Why: the restart case was untested — short-circuiting cliQuiet() left the suite green, because the handler's in-memory clear alone satisfied it. The quiet file is what actually carries the dismiss across runs. Tests: TestCLIBehindIsLstatOnlyAndDismissible extended with the restart and next-version legs; `if false && cliQuiet()` now fails it (mutation-checked). go vet, go test -tags nogui -race ./internal/gui ./internal/update pass; TestGatewayTakenOver fails the same way with -race on the unmodified branch (pre-existing). Co-Authored-By: Claude Code <noreply@anthropic.com> --------- Co-authored-by: Claude Code <noreply@anthropic.com>
92 lines
5.5 KiB
JavaScript
92 lines
5.5 KiB
JavaScript
// Run with Node's test runner and Playwright on the module path; see README.md.
|
|
// A machine whose `magpie` command is a copied file (an older installer, a
|
|
// hand cp) keeps running that build after a GUI update moves the app on —
|
|
// the stale command that wedged a WebDAV sync before #531's check. The
|
|
// window can't tell a stale copy from a current one without running the old
|
|
// binary (whose start-up migrates settings), so /api/state reports the copy
|
|
// and the window shows advice with a "Hide until the next version" that
|
|
// POSTs /api/cli-behind/quiet. The panel is too small for a dialog, so it
|
|
// stays out of it. English and Chinese; no backend, the API is faked here.
|
|
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 HEAD = {
|
|
en: "The `magpie` command is a copy",
|
|
zh: "终端里的 `magpie` 命令是一份拷贝",
|
|
};
|
|
|
|
function server(lang, behind, quiet) {
|
|
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") {
|
|
const s = { agents: [], profiles: [], settings: { lang, theme: "light" } };
|
|
if (behind) s.cliBehind = "~/.local/bin/magpie";
|
|
return json(s);
|
|
}
|
|
if (url.pathname === "/api/cli-behind/quiet") { quiet.calls++; return route.fulfill({ status: 204 }); }
|
|
if (url.pathname === "/api/usage/quotas") return json([]);
|
|
if (url.pathname === "/api/groups") return json({ groups: [] });
|
|
if (url.pathname === "/api/update" || url.pathname === "/api/drift") return json({});
|
|
if (url.pathname.startsWith("/api/")) return 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)];
|
|
try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); }
|
|
};
|
|
}
|
|
|
|
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
|
for (const lang of ["en", "zh"]) {
|
|
test(`${engine} ${lang}: a copied command gets advice, once, dismissable`, async (t) => {
|
|
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
|
t.after(async () => { await browser.close(); });
|
|
const quiet = { calls: 0 };
|
|
const page = await (await browser.newContext({ viewport: { width: 900, height: 700 }, reducedMotion: "reduce" })).newPage();
|
|
page.setDefaultTimeout(5000);
|
|
const errors = [];
|
|
page.on("pageerror", (e) => errors.push(e.message));
|
|
await page.route("**/*", server(lang, true, quiet));
|
|
await page.goto("http://magpie.test/");
|
|
const dialog = page.locator("#modal:not([hidden])");
|
|
await dialog.waitFor();
|
|
await assert.match(await dialog.textContent(), new RegExp(HEAD[lang].replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), "the dialog names the copy");
|
|
assert.match(await dialog.textContent(), /~\/\.local\/bin\/magpie/, "the dialog names the path");
|
|
// Hide until the next version posts the dismiss and closes
|
|
await dialog.getByRole("button", { name: lang === "zh" ? "隐藏,直到下一个版本" : "Hide until the next version" }).click();
|
|
await page.waitForFunction(() => document.querySelector("#modal").hidden);
|
|
assert.equal(quiet.calls, 1, "the dismiss reached the API");
|
|
// the panel, too small for a dialog, says nothing
|
|
const panel = await (await browser.newContext({ viewport: { width: 380, height: 600 }, reducedMotion: "reduce" })).newPage();
|
|
panel.setDefaultTimeout(5000);
|
|
panel.on("pageerror", (e) => errors.push(e.message));
|
|
await panel.route("**/*", server(lang, true, quiet));
|
|
await panel.goto("http://magpie.test/?mode=panel");
|
|
await panel.locator("#view-agents, #nav, body").first().waitFor();
|
|
await panel.waitForTimeout(500);
|
|
assert.ok(await panel.locator("#modal").evaluate((m) => m.hidden), "the panel stays quiet");
|
|
// and no advice when the command is the link (no cliBehind)
|
|
const ok = await (await browser.newContext({ viewport: { width: 900, height: 700 }, reducedMotion: "reduce" })).newPage();
|
|
ok.setDefaultTimeout(5000);
|
|
ok.on("pageerror", (e) => errors.push(e.message));
|
|
await ok.route("**/*", server(lang, false, quiet));
|
|
await ok.goto("http://magpie.test/");
|
|
await ok.waitForTimeout(500);
|
|
assert.ok(await ok.locator("#modal").evaluate((m) => m.hidden), "a following command is not mentioned");
|
|
const keys = [
|
|
"The `magpie` command is a copy",
|
|
"The `magpie` command at {path} is a copied file, not the installer's link to the app: it won't follow the app's updates, and an old copy can break what a new one fixed. Re-run the installer, or link it by hand.",
|
|
];
|
|
const missing = await ok.evaluate((ks) => ks.filter((x) => !I18N.zh[x]), keys);
|
|
assert.deepEqual(missing, [], "the advice has its Chinese");
|
|
assert.deepEqual(errors, []);
|
|
});
|
|
}
|
|
}
|