Files
magpie/internal/plugin/plugin_clash_test.go
T
yetone 7d0e415e62 plugins: two plugins that sign in to one provider are both listed, each row says which serves it, and Use for picks the other (neiko on Discord)
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.
2026-10-10 00:55:38 +08:00

190 lines
5.7 KiB
Go

package plugin
import (
"context"
"os"
"path/filepath"
"reflect"
"testing"
"time"
)
// Two plugins that sign in to one provider id (neiko on Discord: a
// third-party plugin and their own one, added by its folder). The host
// runs one of them for it; each says it signs in to the id, the other
// one says who serves it, the provider is listed once, and Prefer hands
// it to the other. Removing one leaves the other's untouched.
func TestTwoPluginsOneProvider(t *testing.T) {
sandbox(t)
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
src, err := os.ReadFile("testdata/fake/index.js")
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
mine, theirs := filepath.Join(dir, "mine", "index.js"), filepath.Join(dir, "theirs", "index.js")
for _, f := range []string{mine, theirs} {
if err := os.MkdirAll(filepath.Dir(f), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(f, src, 0o644); err != nil {
t.Fatal(err)
}
}
served := func(want string) {
t.Helper()
ps, err := Providers(ctx)
if err != nil {
t.Fatal(err)
}
var specs []string
for _, p := range ps {
if p.ID == "fakeco" {
specs = append(specs, p.Spec)
}
}
if !reflect.DeepEqual(specs, []string{want}) {
t.Fatalf("fakeco is served by %v, want just %s", specs, want)
}
}
// the user's own first: it serves fakeco
if _, err := Add(ctx, mine); err != nil {
t.Fatal(err)
}
served(mine)
// a third party's added after it serves it now, as in OpenCode, and
// fakeco is listed once, not again as mine's from the list kept before
if _, err := Add(ctx, theirs); err != nil {
t.Fatal(err)
}
served(theirs)
ls, err := Plugins(ctx)
if err != nil || len(ls) != 2 {
t.Fatalf("Plugins = %+v, %v", ls, err)
}
by := map[string]Loaded{}
for _, l := range ls {
by[l.Spec] = l
}
if l := by[mine]; !reflect.DeepEqual(l.Provides, []string{"fakeco"}) || !reflect.DeepEqual(l.ServedBy, map[string]string{"fakeco": theirs}) {
t.Fatalf("mine loaded as %+v: it signs in to fakeco, which theirs serves", l)
}
if l := by[theirs]; !reflect.DeepEqual(l.Provides, []string{"fakeco"}) || l.ServedBy != nil {
t.Fatalf("theirs loaded as %+v: it signs in to fakeco and serves it", l)
}
want := map[string][]Clash{
mine: {{ID: "fakeco", By: theirs, With: []string{theirs}}},
theirs: {{ID: "fakeco", By: theirs, With: []string{mine}}},
}
if c := Clashes(ls); !reflect.DeepEqual(c, want) {
t.Fatalf("Clashes = %+v, want %+v", c, want)
}
// picked for it, mine serves it again; theirs stays installed
if err := Prefer(mine, "fakeco"); err != nil {
t.Fatal(err)
}
served(mine)
if ls, err = Plugins(ctx); err != nil {
t.Fatal(err)
}
if c := Clashes(ls); len(c[mine]) != 1 || c[mine][0].By != mine || c[theirs][0].By != mine {
t.Fatalf("after Prefer, Clashes = %+v", c)
}
// ...and stays picked after a restart
Restart()
served(mine)
// switched off, it leaves fakeco to theirs; on again, it has it back
if err := SetOff(mine, true); err != nil {
t.Fatal(err)
}
served(theirs)
if err := SetOff(mine, false); err != nil {
t.Fatal(err)
}
served(mine)
// removed, its pick goes with it, and theirs serves fakeco
if err := Remove(ctx, mine); err != nil {
t.Fatal(err)
}
if p := Load().Prefer; len(p) != 0 {
t.Fatalf("after removing mine, Prefer = %v", p)
}
served(theirs)
}
func TestClashes(t *testing.T) {
ls := []Loaded{
{Spec: "a", Provides: []string{"x", "y"}, ServedBy: map[string]string{"x": "c"}},
{Spec: "b", Error: "broken"},
{Spec: "c", Provides: []string{"x"}},
{Spec: "d", Provides: []string{"z"}},
}
want := map[string][]Clash{
"a": {{ID: "x", By: "c", With: []string{"c"}}},
"c": {{ID: "x", By: "c", With: []string{"a"}}},
}
if c := Clashes(ls); !reflect.DeepEqual(c, want) {
t.Fatalf("Clashes = %+v, want %+v", c, want)
}
if c := Clashes(nil); len(c) != 0 {
t.Fatalf("Clashes(nil) = %+v", c)
}
}
// A name is the plugin added as it exactly before one whose package has
// that name: switching one off, setting its options or picking it never
// reaches another.
func TestPluginNamedExactlyFirst(t *testing.T) {
storeSandbox(t)
writeList(t, `{"plugins":[{"spec":"acme@latest"},{"spec":"acme"}]}`)
if err := SetOff("acme", true); err != nil {
t.Fatal(err)
}
if ps := Load().Plugins; ps[0].Off || !ps[1].Off {
t.Fatalf("SetOff(acme) = %+v, want acme off, not acme@latest", ps)
}
if err := SetOptions("acme", map[string]any{"k": 1.0}); err != nil {
t.Fatal(err)
}
if ps := Load().Plugins; ps[0].Options != nil || ps[1].Options == nil {
t.Fatalf("SetOptions(acme) = %+v", ps)
}
if err := Prefer("acme", "acme"); err != nil {
t.Fatal(err)
}
if p := Load().Prefer; !reflect.DeepEqual(p, map[string]string{"acme": "acme"}) {
t.Fatalf("Prefer = %v", p)
}
if err := Prefer("acme@latest", "acme"); err != nil {
t.Fatal(err)
}
if p := Load().Prefer; !reflect.DeepEqual(p, map[string]string{"acme": "acme@latest"}) {
t.Fatalf("Prefer = %v", p)
}
if err := Prefer("nosuch", "acme"); err == nil {
t.Fatal("Prefer took a plugin that isn't installed")
}
if err := Prefer("acme", ""); err == nil {
t.Fatal("Prefer took no provider")
}
}
// A provider kept from before for a plugin that told none this time isn't
// kept when another plugin serves its id now: it would be listed twice.
func TestKeepUnloadedLeavesAServedID(t *testing.T) {
storeSandbox(t)
writeList(t, `{"plugins":[{"spec":"/me/acme"},{"spec":"acme-auth"},{"spec":"/me/broken"}]}`)
now := []Provider{{ID: "acme", Spec: "acme-auth"}}
last := []Provider{{ID: "acme", Spec: "/me/acme"}, {ID: "other", Spec: "/me/broken"}}
got := keepUnloaded(now, last)
want := []Provider{{ID: "acme", Spec: "acme-auth"}, {ID: "other", Spec: "/me/broken"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("keepUnloaded = %+v, want %+v", got, want)
}
}