Files
magpie/internal/plugin/plugin_test.go
T
yetone a31f97b30d plugin: OpenCode's provider plugins sign in to subscriptions in magpie — an npm package OpenCode users install for a plan (its auth hook: loader, methods, oauth authorize/callback, api keys, chat.headers) runs in a Bun host magpie downloads the first time a plugin needs it, speaking JSON-RPC over stdio; its sign-ins are kept in plugin-auth.json (0600). A provider a plugin signs in to is a provider like the others: its models the plugin lists, each spoken to on the API its AI SDK package speaks (anthropic → Messages, openai/azure → Responses, google → Gemini, Copilot's GPT-5+ → Responses, else chat completions), its requests carried by the plugin's fetch to the base URL its loader gives; an id magpie already has is <id>-plugin. CLI: magpie plugin [list|add|rm|update|on|off|login|logout]. App: Settings → Plugins adds, updates, switches off and removes them and says why one didn't load; Add provider → From plugins signs in with the plugin's methods, its questions (picks and checked text), the browser and a pasted code or a key, followed as the built-in sign-ins are; removing the account signs out. The built-in subscriptions stay. TestFakePlugin, TestPluginProvider (chat, Anthropic and Gemini models through the gateway) and TestPluginSignIn (a wrong code fails, the right one signs in, removing signs out, a key) run a fake plugin on Bun; plugin-signin.test.cjs (Chromium, WebKit, en/zh) covers the add sheet and Settings. signin-callback, add-sheet, zed-sub, account-proxy, own-signin-remove, routing-signin, click-scroll GUI tests pass; go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestUserPath, TestClaudeSignedOut, TestClaudeAccountNamedAsItsLogin, TestClaudeIdentityServedWhileAsked, known flakes under load, pass alone). Not yet tried with a real plugin and account.
2026-09-30 16:44:32 +08:00

169 lines
5.3 KiB
Go

package plugin
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
// sandbox gives the test its own magpie folders and the Bun on PATH; a
// machine without Bun skips it.
func sandbox(t *testing.T) {
t.Helper()
bun, err := exec.LookPath("bun")
if err != nil {
t.Skip("no bun on PATH")
}
dir := t.TempDir()
t.Setenv("HOME", dir)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(dir, "config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(dir, "cache"))
t.Setenv("MAGPIE_BUN", bun)
t.Cleanup(Settle)
}
func TestFakePlugin(t *testing.T) {
sandbox(t)
var seen []http.Header
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = append(seen, r.Header.Clone())
b, _ := io.ReadAll(r.Body)
w.Header().Set("Content-Type", "text/event-stream")
fl := w.(http.Flusher)
fmt.Fprintf(w, "data: %s %s\n\n", r.URL.Path, b)
fl.Flush()
time.Sleep(50 * time.Millisecond)
fmt.Fprint(w, "data: [DONE]\n\n")
}))
defer srv.Close()
t.Setenv("FAKE_BASE", srv.URL+"/v1")
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
abs, _ := filepath.Abs("testdata/fake/index.js")
if _, err := Add(ctx, abs); err != nil {
t.Fatal(err)
}
ls, err := Plugins(ctx)
if err != nil || len(ls) != 1 || ls[0].Error != "" {
t.Fatalf("Plugins = %+v, %v", ls, err)
}
ps, err := Providers(ctx)
if err != nil {
t.Fatal(err)
}
if len(ps) != 1 || ps[0].ID != "fakeco" || ps[0].Name != "FakeCo" || ps[0].SignedIn || len(ps[0].Methods) != 2 {
t.Fatalf("Providers = %+v", ps)
}
npm := map[string]string{}
for _, m := range ps[0].Models {
npm[m.ID] = m.NPM
}
if npm["fake-1"] != "@ai-sdk/openai-compatible" || npm["fake-claude"] != "@ai-sdk/anthropic" || npm["fake-gemini"] != "@ai-sdk/google" {
t.Fatalf("models = %+v", ps[0].Models)
}
// the browser method asks where, then a team only for work
p, err := NextPrompt(ctx, "fakeco", 1, map[string]string{})
if err != nil || p == nil || p.Key != "where" || len(p.Options) != 2 {
t.Fatalf("first prompt = %+v, %v", p, err)
}
if p, _ := NextPrompt(ctx, "fakeco", 1, map[string]string{"where": "home"}); p != nil {
t.Fatalf("home asked %+v", p)
}
if p, _ := NextPrompt(ctx, "fakeco", 1, map[string]string{"where": "work"}); p == nil || p.Key != "team" {
t.Fatalf("work asked %+v", p)
}
if e, _ := Validate(ctx, "fakeco", 1, "team", ""); e != "Required" {
t.Fatalf("Validate = %q", e)
}
in := map[string]string{"where": "work", "team": "blue"}
a, err := Authorize(ctx, "fakeco", 1, in)
if err != nil || a.Method != "code" || !strings.Contains(a.URL, "where=work") {
t.Fatalf("Authorize = %+v, %v", a, err)
}
if _, err := Finish(ctx, a.Session, "bad"); err != ErrFailed {
t.Fatalf("a bad code: %v", err)
}
a, _ = Authorize(ctx, "fakeco", 1, in)
if got, err := Finish(ctx, a.Session, "good"); err != nil || got != "fakeco" {
t.Fatalf("Finish = %q, %v", got, err)
}
var saved map[string]map[string]any
b, _ := os.ReadFile(AuthPath())
json.Unmarshal(b, &saved)
if saved["fakeco"]["type"] != "oauth" || saved["fakeco"]["refresh"] != "r-blue" || saved["fakeco"]["accountId"] != "me@fake" {
t.Fatalf("saved %v", saved)
}
if fi, _ := os.Stat(AuthPath()); fi.Mode().Perm() != 0o600 {
t.Fatalf("plugin-auth.json is %v", fi.Mode().Perm())
}
if !SignedIn("fakeco") {
t.Fatal("not signed in")
}
o, err := LoaderOptions(ctx, "fakeco")
if err != nil || o.BaseURL != srv.URL+"/v1" || !o.Fetch || o.APIKey != "dummy" {
t.Fatalf("LoaderOptions = %+v, %v", o, err)
}
res, err := Fetch(ctx, FetchRequest{
Provider: "fakeco", Model: "fake-1", NPM: "@ai-sdk/openai-compatible",
URL: o.BaseURL + "/chat/completions", Method: "POST",
Headers: map[string]string{"content-type": "application/json"},
Body: []byte(`{"model":"fake-1"}`),
})
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil || res.StatusCode != 200 || res.Header.Get("Content-Type") != "text/event-stream" {
t.Fatalf("Fetch = %d %v %v", res.StatusCode, res.Header, err)
}
if want := "data: /v1/chat/completions {\"model\":\"fake-1\"}\n\ndata: [DONE]\n\n"; string(body) != want {
t.Fatalf("body = %q", body)
}
h := seen[len(seen)-1]
// the plugin refreshed the stale token and saved it; the chat.headers
// hook added its header; the plugin saw the provider's models
if h.Get("Authorization") != "Bearer fresh" || h.Get("X-Plugin-Model") != "fake-1" || h.Get("X-Models") != "fake-1,fake-claude,fake-gemini" {
t.Fatalf("headers = %v", h)
}
b, _ = os.ReadFile(AuthPath())
json.Unmarshal(b, &saved)
if saved["fakeco"]["access"] != "fresh" {
t.Fatalf("refresh not saved: %v", saved)
}
// a request that is abandoned is aborted, and the host still answers
cctx, ccancel := context.WithCancel(ctx)
res, err = Fetch(cctx, FetchRequest{Provider: "fakeco", Model: "fake-1", URL: o.BaseURL + "/chat/completions", Method: "POST", Body: []byte(`{}`)})
if err != nil {
t.Fatal(err)
}
ccancel()
io.ReadAll(res.Body)
if _, err := Providers(ctx); err != nil {
t.Fatal(err)
}
if err := SignOut(ctx, "fakeco"); err != nil || SignedIn("fakeco") {
t.Fatalf("SignOut: %v", err)
}
if err := SetOff(abs, true); err != nil {
t.Fatal(err)
}
if ps := Cached(); len(ps) != 0 {
t.Fatalf("a plugin turned off still lists %+v", ps)
}
}