plugins: Check for updates asks npm now for each installed plugin's newest version, and each row says what it found (the owner: 插件的检查更新按钮还是得做) — the community's plugins update by themselves a little after magpie starts and every 6 hours, and the page knew of anyone else's new version only from what npm said within the hour (Info's cache), so a reader who heard a plugin had a new version had no way to have magpie look. The foot of Installed (beside Update all) now has Check for updates, shown when a plugin is npm's (a folder's or a git one's has no versions there; a git one keeps its own Update, which fetches it again): it says Checking… with a spinner while POST /api/plugins/check asks npm, a few at a time, past the hour's cache (plugin.CheckNow, through npmAsk, which is npmInfo telling why npm said nothing), and nothing is installed. Each row then says it: "v0.0.9 out" titled "v0.0.7 installed, v0.0.9 on npm" with its Update (the existing upgrade path, Upgrade → Add), "Up to date", or an amber "Couldn't check" chip with a dot, titled why — npm not reached (check your connection or proxy), npm turning requests away (429, try again in a few minutes), npm answering with an error, or npm not having the package (fetchJSON's answers other than 200/404 are now a statusError, so a 429 is told apart). The status line says how many have updates, or that every plugin is up to date, and how many couldn't be checked and why; npm not reached at all is "Couldn't check for updates: npm couldn't be reached — check your connection or proxy". A community plugin's Update is titled that magpie would update it by itself within a few hours and Update does it now. What npm said is kept as Info keeps it, so the rows and the market read it, and an update found for the reader (someone else's, or pinned) waits as the background look's do, putting the dot on Plugins; one npm didn't answer for keeps what waited before. Every new string has its Chinese; the click never scrolls the page. TestCheckNow (an httptest npm registry: the community's and someone else's updates found past an hour-old cache saying otherwise, up to date, 404 missing, 429 limited, 500 registry, git and folder not asked, nothing installed, the cache and the waiting list updated) and TestCheckNowOffline (npm not reached: offline, what waited still waits) are new, as is TestPluginsCheckRoute; plugin-check-updates.test.cjs (WebKit and Chromium, en and zh: Checking… while npm is asked, the rows' out / up to date / couldn't check chips and titles, the status line, Update after the check, offline said plainly, the view's scroll unmoved) fails without the change and passes with it. plugin-updates, plugin-market, plugin-progressive, plugin-git, plugin-local and click-scroll pass in WebKit and Chromium. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass.
This commit is contained in:
1 parent
14b9928d33
commit
94bb3e2ca6
9 files changed
+601
-7
No files matched your search
@@ -2399,6 +2399,20 @@ const I18N = {
|
||||
"Update all ({n})": "全部更新({n})",
|
||||
"Plugins updated": "插件已更新",
|
||||
"v{v} out": "v{v} 可用",
|
||||
"Ask npm now for each plugin's newest version": "立即向 npm 查询每个插件的最新版本",
|
||||
"Last checked {time}": "上次检查:{time}",
|
||||
"1 plugin has an update": "1 个插件有更新",
|
||||
"{n} plugins have updates": "{n} 个插件有更新",
|
||||
"Every plugin is up to date": "所有插件都是最新的",
|
||||
"Couldn't check for updates: {error}": "无法检查更新:{error}",
|
||||
"Couldn't check": "无法检查",
|
||||
"npm couldn't be reached — check your connection or proxy": "无法连接 npm,请检查网络或代理",
|
||||
"npm is turning requests away for now (too many); try again in a few minutes": "npm 暂时拒绝请求(请求过多),请几分钟后再试",
|
||||
"npm doesn't have it": "npm 上没有这个包",
|
||||
"npm answered with an error: {error}": "npm 返回错误:{error}",
|
||||
"v{have} installed, v{v} on npm": "已安装 v{have},npm 上有 v{v}",
|
||||
"v{v} is the newest on npm": "v{v} 是 npm 上的最新版本",
|
||||
"magpie updates it by itself within a few hours; Update does it now": "magpie 会在几小时内自动更新它;点击“更新”立即更新",
|
||||
"Auto-updated": "已自动更新",
|
||||
"magpie updated it from v{from} to v{to} on {date}": "magpie 已于 {date} 自动将它从 v{from} 更新到 v{to}",
|
||||
"An update for {name} is out": "{name} 有新版本",
|
||||
|
||||
@@ -70,6 +70,10 @@
|
||||
.pm-chip.moved { gap: 4px; background: var(--accent-soft); color: var(--accent); }
|
||||
.pm-chip.moved .dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
|
||||
.pm-row .sub.ask { color: var(--fg); }
|
||||
.pm-chip.warn { gap: 4px; background: var(--amber-soft); color: var(--amber); }
|
||||
.pm-chip.warn .dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
|
||||
.pm-foot .pm-check { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.pm-foot .pm-check .spin { width: 9px; height: 9px; border-radius: 50%; border: 1.5px solid currentColor; border-right-color: transparent; animation: spin .7s linear infinite; }
|
||||
.pm-chip.soft { background: none; box-shadow: inset 0 0 0 1px var(--line); color: var(--muted); }
|
||||
|
||||
/* a plugin's one button */
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
let searchTimer = 0;
|
||||
const busy = new Map(); // package → "add" | "remove" | "upgrade" | "off"
|
||||
let asking = null; // { pkg, op }: a remove or switch-off waiting on the user's yes
|
||||
let checking = false; // Check for updates: npm being asked now
|
||||
let checked = null; // ...and what it said: { at, by: spec → check }
|
||||
let shown = "";
|
||||
|
||||
const SEARCH = "M7 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M10 10l3 3";
|
||||
@@ -170,6 +172,55 @@
|
||||
status(e.message, "err");
|
||||
}
|
||||
}
|
||||
// Check for updates: npm asked now, not what it said within the hour,
|
||||
// for each installed plugin's newest version. Each row then says so (an
|
||||
// update, up to date, or why it couldn't be checked), and its Update, or
|
||||
// Update all, installs it as before. Nothing is installed by the check.
|
||||
async function checkUpdates() {
|
||||
if (checking || busy.size) return;
|
||||
checking = true;
|
||||
draw();
|
||||
try {
|
||||
const r = await api("plugins/check", {});
|
||||
mine = r.state || mine;
|
||||
const by = {};
|
||||
for (const c of r.plugins || []) {
|
||||
by[c.spec] = c;
|
||||
// npm's answer now is the one the rows read
|
||||
if (c.latest) npm[c.package] = { ...(npm[c.package] || {}), version: c.latest };
|
||||
}
|
||||
checked = { at: r.at, by };
|
||||
merge();
|
||||
const asked = (r.plugins || []).filter((c) => c.status === "update" || c.status === "current" || c.status === "unknown");
|
||||
const ups = asked.filter((c) => c.status === "update" && !c.off).length;
|
||||
const unknown = asked.filter((c) => c.status === "unknown");
|
||||
if (asked.length && unknown.length === asked.length) {
|
||||
status(t("Couldn't check for updates: {error}", { error: whyText(unknown[0]) }), "err", 8000);
|
||||
} else {
|
||||
let msg = ups ? (ups === 1 ? t("1 plugin has an update") : t("{n} plugins have updates", { n: ups })) : t("Every plugin is up to date");
|
||||
if (unknown.length) {
|
||||
msg += " · " + t("{n} couldn't be checked: {error}", { n: unknown.length, error: whyText(unknown[0]) });
|
||||
status(msg, "warn", 8000);
|
||||
} else status(msg, "ok");
|
||||
}
|
||||
} catch (e) {
|
||||
status(t("Couldn't check for updates: {error}", { error: e.message }), "err", 8000);
|
||||
}
|
||||
checking = false;
|
||||
draw();
|
||||
window.renderPluginDot?.();
|
||||
}
|
||||
// why npm said nothing of a plugin, in the page's words
|
||||
function whyText(c) {
|
||||
switch (c.why) {
|
||||
case "offline": return t("npm couldn't be reached — check your connection or proxy");
|
||||
case "limited": return t("npm is turning requests away for now (too many); try again in a few minutes");
|
||||
case "missing": return t("npm doesn't have it");
|
||||
default: return t("npm answered with an error: {error}", { error: c.error || "" });
|
||||
}
|
||||
}
|
||||
const checkedAt = () => checked ? new Date(checked.at).toLocaleTimeString(document.documentElement.lang || undefined, { hour: "2-digit", minute: "2-digit" }) : "";
|
||||
|
||||
// the built-ins with accounts this plugin could run in their place
|
||||
const movableOf = (pkg) => (mine?.movable || []).filter((c) => c.package === pkg);
|
||||
// move: a built-in's accounts onto its plugin, installing it if need be,
|
||||
@@ -554,9 +605,19 @@
|
||||
const foot = el("div", "pm-foot");
|
||||
const outdated = es.filter((e) => e.latest && e.version && newer(e.latest, e.version));
|
||||
foot.append(el("span", "", mine.bun ? t("Plugins run on Bun {v}", { v: mine.bunVersion }) : ""), el("span", "grow"));
|
||||
// only npm has versions to ask for: a folder's or a git one's has none
|
||||
if (es.some((e) => onNPM(e.spec))) {
|
||||
const c = el("button", "text pm-check" + (checking ? " busy" : ""));
|
||||
if (checking) c.append(el("span", "spin"));
|
||||
c.append(el("span", "", checking ? t("Checking…") : t("Check for updates")));
|
||||
c.title = t("Ask npm now for each plugin's newest version") + (checked ? "\n" + t("Last checked {time}", { time: checkedAt() }) : "");
|
||||
c.disabled = checking || busy.size > 0;
|
||||
c.onclick = () => checkUpdates();
|
||||
foot.append(c);
|
||||
}
|
||||
if (outdated.length) {
|
||||
const up = el("button", "text", busy.has("*") ? t("Updating…") : t("Update all ({n})", { n: outdated.length }));
|
||||
up.disabled = busy.size > 0;
|
||||
up.disabled = busy.size > 0 || checking;
|
||||
up.onclick = () => act("*", "update", {}, () => status(t("Plugins updated"), "ok"));
|
||||
foot.append(up);
|
||||
}
|
||||
@@ -578,12 +639,26 @@
|
||||
const nm = el("div", "name");
|
||||
nm.append(el("span", "", shownName(e)));
|
||||
if (e.version) nm.append(el("span", "pm-ver", "v" + e.version));
|
||||
if (e.latest && e.version && newer(e.latest, e.version)) nm.append(el("span", "pm-chip up", t("v{v} out", { v: e.latest })));
|
||||
else if (e.autoUpdated && e.autoUpdated.to === e.version) {
|
||||
const ck = checked?.by[e.spec];
|
||||
if (e.latest && e.version && newer(e.latest, e.version)) {
|
||||
const c = el("span", "pm-chip up", t("v{v} out", { v: e.latest }));
|
||||
c.title = t("v{have} installed, v{v} on npm", { have: e.version, v: e.latest });
|
||||
nm.append(c);
|
||||
} else if (ck?.status === "unknown") {
|
||||
// asked just now, and npm didn't say: the row says why
|
||||
const c = el("span", "pm-chip warn");
|
||||
c.append(el("span", "dot"), el("span", "", t("Couldn't check")));
|
||||
c.title = whyText(ck);
|
||||
nm.append(c);
|
||||
} else if (e.autoUpdated && e.autoUpdated.to === e.version) {
|
||||
// magpie updated it by itself lately: the row says so, quietly
|
||||
const c = el("span", "pm-chip soft", t("Auto-updated"));
|
||||
c.title = t("magpie updated it from v{from} to v{to} on {date}", { from: e.autoUpdated.from, to: e.autoUpdated.to, date: new Date(e.autoUpdated.at).toLocaleDateString(document.documentElement.lang || undefined, { month: "short", day: "numeric" }) });
|
||||
nm.append(c);
|
||||
} else if ((ck?.status === "current" || ck?.status === "update") && e.version) {
|
||||
const c = el("span", "pm-chip soft", t("Up to date"));
|
||||
c.title = t("v{v} is the newest on npm", { v: e.version }) + "\n" + t("Last checked {time}", { time: checkedAt() });
|
||||
nm.append(c);
|
||||
}
|
||||
// the built-in subscriptions moved onto it: taking it away moves them
|
||||
// back, so the row says it carries them
|
||||
@@ -626,7 +701,9 @@
|
||||
const b = busy.get(pkg) || busy.get(e.spec);
|
||||
if (e.latest && e.version && newer(e.latest, e.version) && !e.off) {
|
||||
const up = el("button", "text", b === "upgrade" ? t("Updating…") : t("Update"));
|
||||
up.disabled = busy.size > 0;
|
||||
up.disabled = busy.size > 0 || checking;
|
||||
// the community's would come by itself: Update brings it now
|
||||
if (pkg.startsWith("@magpie-community/") && !/@(?!latest$)[^@/]+$/.test(e.spec.slice(pkg.length))) up.title = t("magpie updates it by itself within a few hours; Update does it now");
|
||||
up.onclick = () => act(pkg, "upgrade", { spec: e.spec }, () => status(t("{name} updated to v{v}", { name: l?.name || pkg, v: e.latest }), "ok"));
|
||||
val.append(up);
|
||||
} else if (isGit(e.spec) && !e.off) {
|
||||
|
||||
@@ -274,6 +274,14 @@ func pluginRoutes(mux *http.ServeMux, w Windows) {
|
||||
}
|
||||
writeJSON(rw, map[string]string{"dir": dir})
|
||||
})
|
||||
// Check for updates: npm asked now for each plugin's newest version,
|
||||
// with the list as it then stands; nothing installed
|
||||
mux.HandleFunc("POST /api/plugins/check", func(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), time.Minute)
|
||||
defer cancel()
|
||||
c := plugin.CheckNow(ctx)
|
||||
writeJSON(rw, map[string]any{"at": c.At, "plugins": c.Plugins, "state": pluginsState(ctx, w)})
|
||||
})
|
||||
// add, remove, update, turn on or off: each answers with the list
|
||||
mux.HandleFunc("POST /api/plugins/{op}", func(rw http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Check for updates is a route of its own, not one of the plugin ops: it
|
||||
// answers with what npm said of each plugin (none here, so npm is never
|
||||
// asked) and the list as it then stands.
|
||||
func TestPluginsCheckRoute(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("HOME", dir)
|
||||
t.Setenv("USERPROFILE", dir)
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
t.Setenv("MAGPIE_PLUGIN_MARKET", "off")
|
||||
mux := http.NewServeMux()
|
||||
pluginRoutes(mux, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest("POST", "/api/plugins/check", nil))
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("%d %s", rec.Code, rec.Body)
|
||||
}
|
||||
var got struct {
|
||||
At string
|
||||
Plugins []any
|
||||
State *pluginsJSON
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.At == "" || got.Plugins == nil || got.State == nil || got.State.Plugins == nil {
|
||||
t.Fatalf("answered %s", rec.Body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Run with Node's test runner and Playwright on the module path; see README.md.
|
||||
// Check for updates on Plugins (the owner: 插件的检查更新按钮还是得做): the
|
||||
// button asks npm now, says it is checking, and each row then says what it
|
||||
// found — an update with the version installed and the one on npm, up to
|
||||
// date, or why it couldn't be checked; Update installs the one found. npm
|
||||
// not reached says so. The click never scrolls the page. English and
|
||||
// Chinese; 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 COPILOT = "opencode-copilot-auth", ZED = "@magpie-community/opencode-zed-auth", KIRO = "@magpie-community/opencode-kiro-auth";
|
||||
|
||||
function server(lang, calls) {
|
||||
// what npm said within the hour: nothing newer than what's installed
|
||||
const installed = [
|
||||
{ spec: COPILOT, providers: ["GitHub Copilot"], version: "0.0.7", latest: "0.0.7", moved: [] },
|
||||
{ spec: ZED, providers: ["Zed"], version: "0.1.4", latest: "0.1.4", moved: [] },
|
||||
{ spec: KIRO, providers: ["Kiro"], version: "0.1.2", latest: "0.1.2", moved: [] },
|
||||
{ spec: "github:me/opencode-x-auth", package: "opencode-x-auth", providers: ["X"], version: "1.0.0", moved: [] },
|
||||
];
|
||||
for (let i = 0; i < 14; i++) installed.push({ spec: "opencode-filler-" + String(i).padStart(2, "0"), providers: ["F"], version: "1.0.0", latest: "1.0.0", moved: [] });
|
||||
let offline = false, gate = null;
|
||||
const state = () => ({ bun: true, bunVersion: "1.3.0", plugins: installed, movable: [] });
|
||||
const fn = 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:false};` });
|
||||
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
|
||||
if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme: "light" } });
|
||||
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true }, plugins: [] });
|
||||
if (url.pathname === "/api/groups") return json({ groups: [], pools: [] });
|
||||
if (url.pathname === "/api/gateway/trace") return json({ routes: [] });
|
||||
if (url.pathname === "/api/plugins/updates") return json({ checked: "2026-10-01T00:00:00Z", waiting: installed.filter((e) => e.spec === COPILOT && e.latest !== e.version).map((e) => ({ spec: e.spec, package: e.spec, version: e.version, latest: e.latest })), updated: [] });
|
||||
if (url.pathname === "/api/plugins/npm") return json({ npm: {} });
|
||||
if (url.pathname === "/api/plugins") return json(state());
|
||||
if (url.pathname === "/api/plugins/listings") return json({ listings: [] });
|
||||
if (url.pathname === "/api/plugins/check") {
|
||||
calls.push("check");
|
||||
if (gate) await gate;
|
||||
const at = "2026-10-02T09:30:00Z";
|
||||
if (offline) return json({ at, state: state(), plugins: installed.filter((e) => !e.spec.startsWith("github:")).map((e) => ({ spec: e.spec, package: e.spec, version: e.version, status: "unknown", why: "offline", error: "dial tcp: no route to host", auto: false, off: false })) });
|
||||
installed[0].latest = "0.0.9";
|
||||
const plugins = installed.map((e) => e.spec.startsWith("github:") ? { spec: e.spec, package: e.package, version: e.version, status: "git", auto: false, off: false }
|
||||
: e.spec === KIRO ? { spec: e.spec, package: e.spec, version: e.version, status: "unknown", why: "limited", error: "429 Too Many Requests", auto: true, off: false }
|
||||
: { spec: e.spec, package: e.spec, version: e.version, latest: e.latest, status: e.latest === e.version ? "current" : "update", auto: e.spec === ZED, off: false });
|
||||
return json({ at, state: state(), plugins });
|
||||
}
|
||||
if (url.pathname === "/api/plugins/upgrade") {
|
||||
const body = route.request().postDataJSON();
|
||||
calls.push("upgrade " + body.spec);
|
||||
installed[0].version = "0.0.9";
|
||||
return json(state());
|
||||
}
|
||||
if (url.pathname.startsWith("/api/")) return json({});
|
||||
if (!/^\/[\w./-]*$/.test(url.pathname) || url.host !== "magpie.test") return route.fulfill({ status: 404, body: "" });
|
||||
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: "" }); }
|
||||
};
|
||||
fn.offline = () => { offline = true; };
|
||||
fn.hold = () => { let open; gate = new Promise((r) => { open = r; }); return () => { gate = null; open(); }; };
|
||||
return fn;
|
||||
}
|
||||
|
||||
const L = {
|
||||
en: {
|
||||
installed: "Installed", check: "Check for updates", checking: "Checking…", update: "Update", out: "v0.0.9 out", outTitle: "v0.0.7 installed, v0.0.9 on npm",
|
||||
current: "Up to date", couldnt: "Couldn't check", limited: /too many/, found: /1 plugin has an update · 1 couldn't be checked: npm is turning requests away/,
|
||||
updated: "opencode-copilot-auth updated to v0.0.9", offline: /^Couldn't check for updates: npm couldn't be reached — check your connection or proxy$/,
|
||||
},
|
||||
zh: {
|
||||
installed: "已安装", check: "检查更新", checking: "检查中…", update: "更新", out: "v0.0.9 可用", outTitle: "已安装 v0.0.7,npm 上有 v0.0.9",
|
||||
current: "已是最新", couldnt: "无法检查", limited: /请求过多/, found: /1 个插件有更新 · 1 个无法检查:npm 暂时拒绝请求/,
|
||||
updated: /opencode-copilot-auth/, offline: /^无法检查更新:无法连接 npm,请检查网络或代理$/,
|
||||
},
|
||||
};
|
||||
|
||||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||||
test(engine + ": plugins check for updates", async (t) => {
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
t.after(() => browser.close());
|
||||
for (const lang of ["en", "zh"]) {
|
||||
await t.test(lang, async () => {
|
||||
const w = L[lang];
|
||||
const calls = [];
|
||||
const page = await (await browser.newContext({ viewport: { width: 980, height: 560 } })).newPage();
|
||||
page.setDefaultTimeout(5000);
|
||||
const errors = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
const srv = server(lang, calls);
|
||||
await page.route("**/*", srv);
|
||||
|
||||
await page.goto("http://magpie.test/?view=plugins");
|
||||
const view = page.locator("#view-plugins");
|
||||
await view.locator(".lib-tabs .opt", { hasText: w.installed }).click();
|
||||
const row = (spec) => view.locator(".pm-row").filter({ has: page.locator(".name span", { hasText: new RegExp("^" + spec.replace(/[/@-]/g, "\\$&") + "$") }) });
|
||||
await row(COPILOT).waitFor();
|
||||
// nothing newer known yet: no Update offered
|
||||
assert.equal(await row(COPILOT).getByRole("button", { name: w.update, exact: true }).count(), 0);
|
||||
|
||||
// the reader scrolls down to the button at the list's foot
|
||||
const check = view.locator(".pm-foot .pm-check");
|
||||
const box = await view.boundingBox();
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + 60);
|
||||
for (let i = 0; i < 80; i++) { await page.mouse.wheel(0, 60); await page.waitForTimeout(15); }
|
||||
await page.waitForTimeout(200);
|
||||
const top = await view.evaluate((v) => v.scrollTop);
|
||||
assert(top > 0, "the view must have scrolled");
|
||||
assert.equal((await check.textContent()).trim(), w.check);
|
||||
|
||||
const open = srv.hold();
|
||||
await check.click();
|
||||
await page.waitForFunction((s) => document.querySelector("#view-plugins .pm-foot .pm-check")?.textContent.trim() === s, w.checking);
|
||||
assert(await check.isDisabled());
|
||||
open();
|
||||
await page.waitForFunction((s) => document.querySelector("#view-plugins .pm-foot .pm-check")?.textContent.trim() === s, w.check);
|
||||
assert.match(await page.locator("#status").textContent(), w.found);
|
||||
assert.equal(await view.evaluate((v) => v.scrollTop), top, "the check must not scroll the page");
|
||||
|
||||
// each row says what npm said now
|
||||
const out = row(COPILOT).locator(".pm-chip.up");
|
||||
assert.equal(await out.textContent(), w.out);
|
||||
assert.equal(await out.getAttribute("title"), w.outTitle);
|
||||
assert.equal(await row(ZED).locator(".pm-chip.soft").textContent(), w.current);
|
||||
const warn = row(KIRO).locator(".pm-chip.warn");
|
||||
assert.equal(await warn.textContent(), w.couldnt);
|
||||
assert.match(await warn.getAttribute("title"), w.limited);
|
||||
assert.equal(await warn.locator(".dot").count(), 1);
|
||||
// a git one isn't npm's to check: nothing said of it
|
||||
assert.equal(await row("opencode-x-auth").locator(".pm-chip.soft, .pm-chip.warn").count(), 0);
|
||||
// the dot on Plugins: someone else's update waits
|
||||
await page.waitForFunction(() => document.querySelector('#nav button[data-view="plugins"]')?.classList.contains("has-dot"));
|
||||
|
||||
// Update installs the one found, as before: the reader scrolls back up to it
|
||||
for (let i = 0; i < 80; i++) { await page.mouse.wheel(0, -60); await page.waitForTimeout(15); }
|
||||
await page.waitForTimeout(200);
|
||||
const top2 = await view.evaluate((v) => v.scrollTop);
|
||||
await row(COPILOT).getByRole("button", { name: w.update, exact: true }).click();
|
||||
await row(COPILOT).locator(".pm-chip.soft", { hasText: w.current }).waitFor();
|
||||
assert.deepEqual(calls, ["check", "upgrade " + COPILOT]);
|
||||
assert.match(await page.locator("#status").textContent(), w.updated instanceof RegExp ? w.updated : new RegExp(w.updated.replace(/\./g, "\\.")));
|
||||
assert.equal(await view.evaluate((v) => v.scrollTop), top2, "updating must not scroll the page");
|
||||
|
||||
// npm not reached: said so, plainly
|
||||
for (let i = 0; i < 80; i++) { await page.mouse.wheel(0, 60); await page.waitForTimeout(15); }
|
||||
await page.waitForTimeout(200);
|
||||
const top3 = await view.evaluate((v) => v.scrollTop);
|
||||
srv.offline();
|
||||
await check.click();
|
||||
await page.waitForFunction(() => /npm/.test(document.querySelector("#status")?.textContent || ""));
|
||||
assert.match(await page.locator("#status").textContent(), w.offline);
|
||||
assert(await page.locator("#status").evaluate((s) => s.classList.contains("err")));
|
||||
assert.equal(await view.evaluate((v) => v.scrollTop), top3);
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -207,13 +207,22 @@ func fetchJSON(ctx context.Context, u string, limit int64) ([]byte, error) {
|
||||
return nil, errNotFound
|
||||
}
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s: %s", u, res.Status)
|
||||
return nil, &statusError{URL: u, Code: res.StatusCode, Status: res.Status}
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(res.Body, limit))
|
||||
}
|
||||
|
||||
var errNotFound = errors.New("not found")
|
||||
|
||||
// statusError is a server's answer other than 200 or 404.
|
||||
type statusError struct {
|
||||
URL string
|
||||
Code int
|
||||
Status string
|
||||
}
|
||||
|
||||
func (e *statusError) Error() string { return e.URL + ": " + e.Status }
|
||||
|
||||
// npmPath is a package's name as the registry's paths take it.
|
||||
func npmPath(name string) string { return strings.Replace(url.PathEscape(name), "%40", "@", 1) }
|
||||
|
||||
@@ -333,6 +342,16 @@ func repoURL(v any) string {
|
||||
}
|
||||
|
||||
func npmInfo(ctx context.Context, name string) (NPM, bool) {
|
||||
info, err := npmAsk(ctx, name)
|
||||
if err != nil {
|
||||
return NPM{}, errors.Is(err, errNotFound) // not on npm: known, and kept
|
||||
}
|
||||
return info, true
|
||||
}
|
||||
|
||||
// npmAsk is what npm says of the package now, or why it said nothing:
|
||||
// errNotFound when it has no such package.
|
||||
func npmAsk(ctx context.Context, name string) (NPM, error) {
|
||||
var info NPM
|
||||
var wg sync.WaitGroup
|
||||
var latestErr error
|
||||
@@ -372,9 +391,9 @@ func npmInfo(ctx context.Context, name string) (NPM, bool) {
|
||||
wg.Wait()
|
||||
info.Weekly = weekly
|
||||
if latestErr != nil {
|
||||
return NPM{}, errors.Is(latestErr, errNotFound) // not on npm: known, and kept
|
||||
return NPM{}, latestErr
|
||||
}
|
||||
return info, true
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// Hit is a package npm's search found.
|
||||
|
||||
@@ -11,7 +11,11 @@ package plugin
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -218,3 +222,136 @@ func KeepUpdated(ctx context.Context) {
|
||||
t.Reset(updateEvery)
|
||||
}
|
||||
}
|
||||
|
||||
// VersionCheck is what asking npm now found of one installed plugin.
|
||||
type VersionCheck struct {
|
||||
Spec string `json:"spec"`
|
||||
Package string `json:"package"`
|
||||
Version string `json:"version"` // installed
|
||||
Latest string `json:"latest,omitempty"` // npm's newest, when it answered
|
||||
// Status is "update" (npm has a newer version), "current", "unknown"
|
||||
// (npm didn't say: Why and Error say why), or "git" or "folder" for
|
||||
// one npm has no versions of
|
||||
Status string `json:"status"`
|
||||
// Why is why npm didn't say: "offline" (not reached), "limited" (too
|
||||
// many requests), "registry" (it answered with an error) or "missing"
|
||||
// (it has no such package)
|
||||
Why string `json:"why,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// Auto is whether magpie updates it by itself (the community's,
|
||||
// unpinned, switched on), so an update found would come anyway
|
||||
Auto bool `json:"auto"`
|
||||
Off bool `json:"off"`
|
||||
}
|
||||
|
||||
// VersionChecks is what a check asked now found.
|
||||
type VersionChecks struct {
|
||||
At time.Time `json:"at"`
|
||||
Plugins []VersionCheck `json:"plugins"`
|
||||
}
|
||||
|
||||
// checkEach is how long npm is waited for, for each package a check asks
|
||||
var checkEach = 15 * time.Second
|
||||
|
||||
// CheckNow asks npm now, not what it said within the hour, for the newest
|
||||
// version of each installed plugin, for the reader's Check for updates.
|
||||
// Nothing is installed: what it finds is updated as any update is, with
|
||||
// Upgrade. What npm says is kept, as Info keeps it, and the updates found
|
||||
// for the reader (someone else's, or pinned) wait for them as the
|
||||
// background look's do, putting the dot on Plugins.
|
||||
func CheckNow(ctx context.Context) VersionChecks {
|
||||
es := Load().Plugins
|
||||
out := make([]VersionCheck, len(es))
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, npmAtOnce)
|
||||
for i, e := range es {
|
||||
c := VersionCheck{Spec: e.Spec, Package: Name(e.Spec), Version: Installed(e.Spec), Off: e.Off}
|
||||
c.Auto = Official(c.Package) && !Pinned(e.Spec) && !e.Off
|
||||
switch {
|
||||
case IsPath(e.Spec):
|
||||
c.Status = "folder"
|
||||
case IsGit(e.Spec):
|
||||
c.Status = "git"
|
||||
}
|
||||
out[i] = c
|
||||
if c.Status != "" {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(c *VersionCheck) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
cctx, cancel := context.WithTimeout(ctx, checkEach)
|
||||
info, err := npmAsk(cctx, c.Package)
|
||||
cancel()
|
||||
if err == nil || errors.Is(err, errNotFound) {
|
||||
npmMu.Lock()
|
||||
npmCached()[c.Package] = npmEntry{info, time.Now()}
|
||||
saveNPM()
|
||||
npmMu.Unlock()
|
||||
}
|
||||
if err == nil && info.Version == "" {
|
||||
err = errors.New("npm named no version")
|
||||
}
|
||||
if err != nil {
|
||||
c.Status, c.Why, c.Error = "unknown", whyNot(err), err.Error()
|
||||
return
|
||||
}
|
||||
c.Latest, c.Status = info.Version, "current"
|
||||
if c.Version != "" && update.Newer(info.Version, c.Version) {
|
||||
c.Status = "update"
|
||||
}
|
||||
}(&out[i])
|
||||
}
|
||||
wg.Wait()
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
|
||||
// the updates found wait for the reader, as the background look's do;
|
||||
// one npm didn't answer for waits as it did
|
||||
updatesMu.Lock()
|
||||
u := readUpdates()
|
||||
asked := map[string]bool{}
|
||||
var waiting []Waiting
|
||||
for _, c := range out {
|
||||
if c.Status == "unknown" || c.Status == "git" || c.Status == "folder" {
|
||||
continue
|
||||
}
|
||||
asked[c.Package] = true
|
||||
if c.Status == "update" && !c.Off && !c.Auto {
|
||||
waiting = append(waiting, Waiting{Spec: c.Spec, Package: c.Package, Version: c.Version, Latest: c.Latest})
|
||||
}
|
||||
}
|
||||
for _, w := range u.Waiting {
|
||||
if !asked[w.Package] {
|
||||
waiting = append(waiting, w)
|
||||
}
|
||||
}
|
||||
u.Checked, u.Waiting = now, waiting
|
||||
if b, err := json.MarshalIndent(u, "", " "); err == nil {
|
||||
if err := os.MkdirAll(settings.Dir(), 0o700); err == nil {
|
||||
_ = writeWhole(updatesPath(), b)
|
||||
}
|
||||
}
|
||||
updatesMu.Unlock()
|
||||
return VersionChecks{At: now, Plugins: out}
|
||||
}
|
||||
|
||||
// whyNot is why npm said nothing of a package, in a word the page says in
|
||||
// its own: not reached, too many requests, an error, or no such package.
|
||||
func whyNot(err error) string {
|
||||
var se *statusError
|
||||
var ue *url.Error
|
||||
var ne net.Error
|
||||
switch {
|
||||
case errors.Is(err, errNotFound):
|
||||
return "missing"
|
||||
case errors.As(err, &se) && se.Code == http.StatusTooManyRequests:
|
||||
return "limited"
|
||||
case errors.As(err, &se):
|
||||
return "registry"
|
||||
case errors.As(err, &ue), errors.As(err, &ne), errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled):
|
||||
return "offline"
|
||||
}
|
||||
return "registry"
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Check for updates asks npm now, whatever it said within the hour: each
|
||||
// plugin says whether npm has a newer version, and why it couldn't be
|
||||
// checked when npm didn't say; someone else's update found waits for the
|
||||
// reader (the dot on Plugins), and nothing is installed.
|
||||
func TestCheckNow(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("HOME", dir)
|
||||
t.Setenv("USERPROFILE", dir)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(dir, "config"))
|
||||
const (
|
||||
ours = "@magpie-community/opencode-zed-auth"
|
||||
theirs = "opencode-gemini-auth"
|
||||
current = "@magpie-community/opencode-kiro-auth"
|
||||
gone = "opencode-gone-auth"
|
||||
limited = "opencode-busy-auth"
|
||||
broken = "opencode-broken-auth"
|
||||
)
|
||||
answers := map[string]string{ours: "0.1.4", theirs: "1.0.0", current: "0.2.0"}
|
||||
fakeNPM(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/downloads/") {
|
||||
w.Write([]byte(`{"downloads":1}`))
|
||||
return
|
||||
}
|
||||
name := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/"), "/latest")
|
||||
switch name {
|
||||
case limited:
|
||||
http.Error(w, "slow down", http.StatusTooManyRequests)
|
||||
case broken:
|
||||
http.Error(w, "oops", http.StatusInternalServerError)
|
||||
case gone:
|
||||
http.NotFound(w, r)
|
||||
default:
|
||||
w.Write([]byte(`{"version":"` + answers[name] + `"}`))
|
||||
}
|
||||
})
|
||||
folder := filepath.Join(dir, "myplugin")
|
||||
if err := save(List{Plugins: []Entry{{Spec: ours}, {Spec: theirs + "@latest"}, {Spec: current}, {Spec: gone}, {Spec: limited}, {Spec: broken}, {Spec: "github:me/opencode-x-auth"}, {Spec: folder}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range []string{ours, theirs, gone, limited, broken} {
|
||||
installedAt(t, p, "0.1.2")
|
||||
}
|
||||
installedAt(t, current, "0.2.0")
|
||||
// what npm said within the hour, before the new versions came out
|
||||
npmMu.Lock()
|
||||
for _, p := range []string{ours, theirs} {
|
||||
npmCached()[p] = npmEntry{NPM{Version: "0.1.2"}, time.Now().Add(-time.Minute)}
|
||||
}
|
||||
npmMu.Unlock()
|
||||
was := installLatest
|
||||
t.Cleanup(func() { installLatest = was })
|
||||
installLatest = func(_ context.Context, pkg string) error { t.Errorf("a check installed %s", pkg); return nil }
|
||||
|
||||
c := CheckNow(context.Background())
|
||||
got := map[string]string{}
|
||||
for _, p := range c.Plugins {
|
||||
got[p.Package] = p.Status + " " + p.Why + " " + p.Version + ">" + p.Latest
|
||||
}
|
||||
want := map[string]string{
|
||||
ours: "update 0.1.2>0.1.4",
|
||||
theirs: "update 0.1.2>1.0.0",
|
||||
current: "current 0.2.0>0.2.0",
|
||||
gone: "unknown missing 0.1.2>",
|
||||
limited: "unknown limited 0.1.2>",
|
||||
broken: "unknown registry 0.1.2>",
|
||||
"github:me/opencode-x-auth": "git >",
|
||||
folder: "folder >",
|
||||
}
|
||||
for k, v := range want {
|
||||
if got[k] != v {
|
||||
t.Errorf("%s: %q, want %q", k, got[k], v)
|
||||
}
|
||||
}
|
||||
if c.At.IsZero() || len(c.Plugins) != len(want) {
|
||||
t.Fatalf("checked %+v", c)
|
||||
}
|
||||
for _, p := range c.Plugins {
|
||||
if p.Auto != (p.Package == ours || p.Package == current) {
|
||||
t.Errorf("%s: Auto %v", p.Package, p.Auto)
|
||||
}
|
||||
}
|
||||
// what npm said now is what the page reads, not what it said before
|
||||
if v := InfoCached([]string{ours})[ours].Version; v != "0.1.4" {
|
||||
t.Errorf("kept %s, want 0.1.4", v)
|
||||
}
|
||||
// someone else's update waits for the reader; the community's comes
|
||||
// by itself
|
||||
if p := PendingUpdates(); len(p.Waiting) != 1 || p.Waiting[0].Package != theirs || p.Waiting[0].Latest != "1.0.0" || p.Checked.IsZero() {
|
||||
t.Fatalf("waiting %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// npm not reached: every plugin says it couldn't be checked, offline, and
|
||||
// what waited for the reader before still waits.
|
||||
func TestCheckNowOffline(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("HOME", dir)
|
||||
t.Setenv("USERPROFILE", dir)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(dir, "config"))
|
||||
fakeNPM(t, func(http.ResponseWriter, *http.Request) {})
|
||||
down := httptest.NewServer(http.NotFoundHandler())
|
||||
npmRegistry = down.URL
|
||||
down.Close()
|
||||
const theirs = "opencode-gemini-auth"
|
||||
if err := save(List{Plugins: []Entry{{Spec: theirs}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installedAt(t, theirs, "0.1.2")
|
||||
if err := os.MkdirAll(filepath.Dir(updatesPath()), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(updatesPath(), []byte(`{"waiting":[{"spec":"`+theirs+`","package":"`+theirs+`","version":"0.1.2","latest":"0.2.0"}]}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := CheckNow(context.Background())
|
||||
if len(c.Plugins) != 1 || c.Plugins[0].Status != "unknown" || c.Plugins[0].Why != "offline" || c.Plugins[0].Error == "" {
|
||||
t.Fatalf("offline: %+v", c.Plugins)
|
||||
}
|
||||
if p := PendingUpdates(); len(p.Waiting) != 1 || p.Waiting[0].Latest != "0.2.0" {
|
||||
t.Fatalf("waiting %+v", p.Waiting)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user