neiko on Discord installed a third-party plugin beside a plugin of their
own, added by its folder. Only the third-party one could be seen. Both
plugins' auth hooks name the same provider id. The host's auths() keeps
the last plugin in plugins.json for an id, as OpenCode does, so the
user's own plugin silently lost it:
- its row read "Signs in to nothing magpie can use";
- its provider was gone from the Add sheet;
- keepUnloaded could list the id a second time, from the
plugin-providers.json kept before.
A package-name clash and a display-name clash were tried as well. Both
plugins stay visible in those cases, because a folder plugin's name is
its path.
What changed:
- host.js:
- init gives each loaded plugin the ids it signs in to (provides) and,
for each id another plugin serves, that plugin's spec (servedBy).
- auths() honours plugins.json's new prefer map (id -> spec).
- Only the serving plugin's provider.models hook runs for the id.
- plugin:
- Clashes() turns provides and servedBy into {id, by, with} per spec.
- Prefer() records a pick and restarts the host.
- Remove drops the removed plugin's picks.
- Re-adding a plugin under a new spec carries its picks over.
- remove, off, options and prefer find a plugin by its exact spec
before its package name (indexOf), so acting on one plugin never
reaches another of the same package.
- keepUnloaded no longer re-adds a provider from before when another
plugin serves its id now.
- GUI:
- /api/plugins rows carry clashes, with the provider's listed name.
- POST /api/plugins/prefer {spec, provider}.
- The serving row says "{other} signs in to {name} too; this one
serves it".
- The other row says, in amber, "{name} is served by {other}, which
signs in to it too", with a "Use for {name}" button.
- New strings in zh, zh-TW, ja and de.
- CLI: `magpie plugin` prints the same lines, with the command that
fixes it, and `magpie plugin use <name> <provider>` makes the pick.
- Docs: docs/subsystems/provider-plugins.md, docs/reference.md and
site/public/docs/{,zh/,ja/}plugins.html.
What was run:
- Reproduced under a sandbox HOME with magpie web on 3562: a fake npm
plugin and a folder plugin, both on id "acme".
- Old binary: the local row had providers [] and /api/providers
listed acme twice.
- New binary: both rows carry the clash; prefer moves acme to the
folder plugin; remove clears the pick and leaves the other plugin
serving acme.
- New Go tests:
- TestTwoPluginsOneProvider, a real Bun host: Prefer, restart,
off/on and Remove.
- TestClashes, TestPluginNamedExactlyFirst and
TestKeepUnloadedLeavesAServedID.
- TestPluginsClashRows (gui) and TestPluginListSaysClash (CLI).
- Each fails when its piece is broken by hand: prefer ignored in
host.js, the keepUnloaded id check removed, name before exact spec,
Remove keeping picks, no provides, no clashes in the rows, no
prefer route.
- plugin-clash.test.cjs, in Chromium and WebKit, in en, zh, zh-TW, ja
and de, at 1100 and 560px: 22/22 pass. It times out on the old
plugins.js.
- gui-de, gui-ja, gui-zh-tw, i18n-keys and every plugin-*.test.cjs:
248 tests.
- 245 passed in the 4-wide run.
- plugin-moved-signin (webkit) failed once in that run, then passed
3/3 alone and 26/26 in a smaller 4-wide rerun.
- gofmt -l; go vet ./...; GOOS=linux and GOOS=windows go vet -tags
nogui ./...; go build ./...
- The full go test -tags nogui ./... under a temp HOME with the caches
pinned: 44 packages ok, none failed.
- After rebasing onto 05917100 (plugins.css conflict with a3a8e257's
chip rules, both kept): plugin, gui and the root plugin tests, vet on
darwin, linux and windows, and the 248 GUI tests above, all green
(248/248 this time).
Not tried: a real third-party plugin, neiko's own plugins, and Windows
beyond vet.
126 lines
8.8 KiB
JavaScript
126 lines
8.8 KiB
JavaScript
// Run with Node's test runner and Playwright on the module path; see README.md.
|
|
// Two installed plugins that sign in to one provider (neiko on Discord: a
|
|
// third-party plugin and their own folder plugin both name it). Both rows
|
|
// stay, each says which one serves the provider, and the other one offers
|
|
// "Use for …", which tells magpie to run the provider on it. Neither row
|
|
// reads "Signs in to nothing magpie can use". Chromium and WebKit, every
|
|
// language, wide and narrow; 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 MINE = "/Users/me/dev/my-acme";
|
|
const THEIRS = "opencode-acme-auth@latest";
|
|
|
|
function server(lang, asked) {
|
|
let by = THEIRS;
|
|
const clash = (spec) => [{ id: "acme", by, with: [spec === MINE ? THEIRS : MINE], name: "Acme" }];
|
|
const installed = () => [
|
|
{ spec: MINE, providers: by === MINE ? ["Acme"] : [], version: "", clashes: clash(MINE) },
|
|
{ spec: THEIRS, providers: by === THEIRS ? ["Acme"] : [], version: "1.0.0", clashes: clash(THEIRS) },
|
|
];
|
|
const state = () => ({ bun: true, bunVersion: "1.3.0", plugins: installed() });
|
|
return async (route) => {
|
|
const url = new URL(route.request().url());
|
|
const json = (data) => route.fulfill({ json: data });
|
|
const body = () => route.request().postDataJSON();
|
|
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: [{ id: "acme", pid: "acme", name: "Acme", spec: by, signedIn: false, models: [] }] });
|
|
if (url.pathname === "/api/groups") return json({ groups: [], pools: [] });
|
|
if (url.pathname === "/api/gateway/trace") return json({ routes: [] });
|
|
if (url.pathname === "/api/plugins/market" || url.pathname === "/api/plugins" || url.pathname === "/api/plugins/listings") return json(url.pathname === "/api/plugins" ? state() : url.pathname === "/api/plugins/listings" ? { listings: [] } : { listings: [], state: state() });
|
|
if (url.pathname === "/api/plugins/search") return json({ hits: [] });
|
|
if (url.pathname === "/api/plugins/npm") return json({});
|
|
if (url.pathname === "/api/plugins/prefer") {
|
|
const b = body();
|
|
asked.push(["prefer", b]);
|
|
by = b.spec;
|
|
return json({});
|
|
}
|
|
if (url.pathname.startsWith("/api/plugins/")) { asked.push([url.pathname, body()]); return json({}); }
|
|
if (url.pathname.startsWith("/api/")) return json({});
|
|
if (!/^\/[\w./-]*$/.test(url.pathname) || url.host !== "magpie.test") { asked.push(["fetched", url.href]); 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: "" }); }
|
|
};
|
|
}
|
|
|
|
// what each row says, in each language
|
|
const L = {
|
|
en: { installed: "Installed", nothing: "Signs in to nothing magpie can use", served: "Acme is served by {other}, which signs in to it too", serves: "{other} signs in to Acme too; this one serves it", use: "Use for Acme", done: "Acme now runs on my-acme" },
|
|
zh: { installed: "已安装", nothing: "没有 magpie 能用的登录", served: "Acme 由同样登录它的 {other} 提供", serves: "{other} 也登录 Acme;由这个插件提供", use: "用于 Acme", done: "Acme 现在由 my-acme 提供" },
|
|
"zh-TW": { installed: "已安裝", nothing: "沒有 magpie 能用的登入", served: "Acme 由同樣登入它的 {other} 提供", serves: "{other} 也登入 Acme;由這個外掛提供", use: "用於 Acme", done: "Acme 現在由 my-acme 提供" },
|
|
ja: { installed: "インストール済み", nothing: "magpie で使えるサインインはありません", served: "Acme は、同じくサインインする {other} が提供しています", serves: "{other} も Acme にサインインします。このプラグインが提供中です", use: "Acme に使う", done: "Acme は my-acme で動くようになりました" },
|
|
de: { installed: "Installiert", nothing: "Meldet bei nichts an, das magpie nutzen kann", served: "Acme wird von {other} bereitgestellt, die sich ebenfalls dort anmeldet", serves: "{other} meldet sich auch bei Acme an; diese Erweiterung stellt es bereit", use: "Für Acme nutzen", done: "Acme läuft jetzt über my-acme" },
|
|
};
|
|
const say = (s, other) => s.replace("{other}", other);
|
|
|
|
const shot = async (loc, name) => {
|
|
if (!process.env.ARTIFACT_DIR) return;
|
|
await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
|
|
await loc.screenshot({ path: path.join(process.env.ARTIFACT_DIR, name + ".png") });
|
|
};
|
|
|
|
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
|
test(engine + ": two plugins that sign in to one provider", async (t) => {
|
|
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || "chromium" }));
|
|
t.after(() => browser.close());
|
|
for (const lang of Object.keys(L)) {
|
|
for (const width of [1100, 560]) {
|
|
await t.test(`${lang} ${width}px`, async () => {
|
|
const w = L[lang];
|
|
const asked = [];
|
|
const ctx = await browser.newContext({ viewport: { width, height: 820 } });
|
|
const page = await ctx.newPage();
|
|
page.setDefaultTimeout(5000);
|
|
const errors = [];
|
|
page.on("pageerror", (e) => errors.push(e.message));
|
|
await page.route("**/*", server(lang, asked));
|
|
await page.goto("http://magpie.test/?view=plugins");
|
|
const view = page.locator("#view-plugins");
|
|
await view.waitFor({ state: "visible" });
|
|
await view.locator(".lib-tabs .opt", { hasText: w.installed }).click();
|
|
const row = (n) => view.locator(".pm-row", { has: page.locator(".name span", { hasText: new RegExp("^" + n + "$") }) });
|
|
const mine = row("my-acme"), theirs = row("opencode-acme-auth");
|
|
await mine.waitFor();
|
|
await theirs.waitFor();
|
|
|
|
// the third-party one serves it: both rows are there, and say so
|
|
assert.equal((await mine.locator(".pm-clash").innerText()).trim(), say(w.served, "opencode-acme-auth"));
|
|
assert.match(await mine.locator(".pm-clash").getAttribute("class"), /\bwarn\b/);
|
|
assert.equal((await theirs.locator(".pm-clash").innerText()).trim(), say(w.serves, "my-acme"));
|
|
assert.doesNotMatch(await mine.innerText(), new RegExp(w.nothing), "the row doesn't say it signs in to nothing");
|
|
assert.equal(await theirs.locator("button", { hasText: w.use }).count(), 0, "the one serving it has nothing to take over");
|
|
// the row fits: nothing wider than the page
|
|
const over = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
|
assert.ok(over <= 0, `the page scrolls sideways by ${over}px`);
|
|
await shot(view, `plugin-clash-${engine}-${lang}-${width}`);
|
|
|
|
// Use for Acme on the user's own one: magpie is told to run it there
|
|
const use = mine.locator("button", { hasText: w.use });
|
|
assert.equal(await use.count(), 1);
|
|
assert.ok((await use.getAttribute("title")).includes("opencode-acme-auth"), "its title names the one it takes over from");
|
|
await use.click();
|
|
await mine.locator(".pm-clash:not(.warn)").waitFor();
|
|
assert.deepEqual(asked.filter(([k]) => k === "prefer"), [["prefer", { spec: MINE, provider: "acme" }]]);
|
|
assert.equal((await mine.locator(".pm-clash").innerText()).trim(), say(w.serves, "opencode-acme-auth"));
|
|
assert.equal((await theirs.locator(".pm-clash").innerText()).trim(), say(w.served, "my-acme"));
|
|
assert.equal(await theirs.locator("button", { hasText: w.use }).count(), 1, "and the other one can take it back");
|
|
assert.equal(await mine.locator("button", { hasText: w.use }).count(), 0);
|
|
assert.equal(await page.locator("#status").textContent(), w.done);
|
|
await shot(view, `plugin-clash-used-${engine}-${lang}-${width}`);
|
|
assert.deepEqual(errors, []);
|
|
await ctx.close();
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|