65dcc573(ggbdpq's patch 1) warmed the bun on PATH before the root package's one-minute plugin contexts. The same lookup of the bun on PATH is in 40 more places in internal/provider (28), internal/gateway (8), internal/plugin (3) and internal/gui (1), and in the root package's TestPluginListSaysClash. Many of them then open a one- or two-minute context (internal/provider's sign-in, fetch and move tests, internal/plugin's sandbox), and there macOS's first-exec check of a just-installed or upgraded bun landed inside the deadline. The rest are covered the same way, so no test has to remember which is which. testenv.Bun now does what the root package's pathBun did: it finds the bun on PATH (skipping the test when there is none) and runs `bun --version` before the test opens its context, once per bun per test binary. pathBun is gone; every `exec.LookPath("bun")` + skip block in the tests uses testenv.Bun instead. Three tests reach for the bun some other way and are left as they were: TestHostRunsAsMagpieBun (7ddea2c2covers its copy), the plugin_git test's realBun and netproxy's TestBunThroughBridge, none of which runs the plugin host under a deadline on the PATH bun. migrate_test.go needed two `err` declarations that the removed block used to provide. What was run (macOS arm64, bun 1.4.2), with a wrapper bun first on PATH whose first exec sleeps 61s (marker-gated, then execs the real bun): - Red, old code: - internal/provider TestPluginSignInTakesAPastedCallback: FAIL at 61.1s, "pluginsignin_paste_test.go:36: starting plugins: context deadline exceeded". - internal/plugin TestFakePlugin: FAIL at 62.0s, "plugin_test.go:66: Plugins = [], starting plugins: context deadline exceeded". - internal/gateway TestPluginFailover passed on the old code too (its calls have no deadline that short), so the gateway is not shown red. - Green, this change, same cold wrapper: the three tests passed (61.1s, 62.9s, 61.2s). - go test -tags nogui -count=1 for the root package and for internal/testenv, gateway, gui, plugin and provider, under a temp HOME with GOMODCACHE, GOCACHE and GOPATH pinned: all ok. - gofmt -l on the changed files: clean. go vet of those packages for darwin (-tags nogui), GOOS=linux and GOOS=windows (-tags nogui), and GOOS=windows without the tag: clean. Not run: a really cold bun on a loaded machine; the sleep stands in for the check. Not run on Linux or Windows, where the warm-up is one extra `bun --version` per test binary.
405 lines
14 KiB
Go
405 lines
14 KiB
Go
package provider
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/yetone/magpie/internal/plugin"
|
|
"github.com/yetone/magpie/internal/testenv"
|
|
)
|
|
|
|
// A plugin's accounts show their allowance as the built-ins' do: beside
|
|
// each account, on the usage page, and to the gateway, a window counting
|
|
// only some models holding back those alone.
|
|
func TestPluginUsage(t *testing.T) {
|
|
bun := testenv.Bun(t)
|
|
claudeHome(t)
|
|
t.Setenv("MAGPIE_BUN", bun)
|
|
t.Cleanup(plugin.Settle)
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
abs, _ := filepath.Abs("../plugin/testdata/fake/index.js")
|
|
if _, err := plugin.Add(ctx, abs); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := plugin.Providers(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, team := range []string{"a", "full"} {
|
|
st, err := StartPluginSignIn("fakeco", 1, map[string]string{"where": "work", "team": team})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := SubmitSignInCallback(st.ID, "good"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if _, err := PluginAPIKey(ctx, "fakeco", 0, nil, "k-123456"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var first []QuotaWindow // a@fake's, as first read
|
|
for _, agent := range []string{"fakeco", "plugin:fakeco"} {
|
|
u := LoginUsage(ctx, agent)
|
|
if len(u) != 3 {
|
|
t.Fatalf("%s: %d accounts' usage, want 3: %+v", agent, len(u), u)
|
|
}
|
|
q := u["a@fake"]
|
|
if q.Error != "" || q.Plan != "Fake Pro" || q.Provider != "fakeco" || q.Name != "FakeCo" || len(q.Windows) != 3 {
|
|
t.Fatalf("%s: a@fake = %+v", agent, q)
|
|
}
|
|
if q.Until == nil || q.Until.Year() != 2030 || q.Renew != "auto" {
|
|
t.Fatalf("%s: plan period %v %q", agent, q.Until, q.Renew)
|
|
}
|
|
w, week, extra := q.Windows[0], q.Windows[1], q.Windows[2]
|
|
if w.Name != "5 hours" || w.Used != 25 || w.Span != 5*time.Hour || w.ResetsAt == nil || time.Until(*w.ResetsAt) < 50*time.Minute {
|
|
t.Fatalf("%s: five hours = %+v", agent, w)
|
|
}
|
|
// a reset in seconds reads as one
|
|
if week.ResetsAt == nil || time.Until(*week.ResetsAt) < 23*time.Hour || time.Until(*week.ResetsAt) > 25*time.Hour {
|
|
t.Fatalf("%s: week = %+v", agent, week)
|
|
}
|
|
// its count, through the host (#659)
|
|
if week.Amount != 120 || week.Limit != 1200 || week.Unit != "credits" || w.Limit != 0 {
|
|
t.Fatalf("%s: week = %+v", agent, week)
|
|
}
|
|
if extra.Used != 250 || extra.Display != "$2.50" || !extra.Aside {
|
|
t.Fatalf("%s: extra = %+v", agent, extra)
|
|
}
|
|
if e := u["API key"].Error; e != "an API key has no plan" {
|
|
t.Fatalf("%s: the key's usage error = %q (%v)", agent, e, u)
|
|
}
|
|
if first == nil {
|
|
first = q.Windows
|
|
}
|
|
}
|
|
|
|
cards := 0
|
|
for _, q := range fetchSubscriptionUsage(context.Background()) {
|
|
if q.Provider == "fakeco" {
|
|
cards++
|
|
if q.Name != "FakeCo" || q.User == "" {
|
|
t.Fatalf("usage card %+v", q)
|
|
}
|
|
}
|
|
}
|
|
if cards != 3 {
|
|
t.Fatalf("%d usage cards for the plugin's accounts, want 3", cards)
|
|
}
|
|
// one account's card read again from its refresh button (#840): that
|
|
// account's alone is asked. Read a moment later (the five hours' renewal
|
|
// is told to the millisecond), its windows renew when they did, as a
|
|
// vendor's do: TestPluginFailover's routing weighs accounts read at
|
|
// different times by when they renew.
|
|
time.Sleep(2 * time.Millisecond)
|
|
again := context.WithValue(context.Background(), cardRefreshKey{}, cardRefresh{"fakeco", "A@fake"})
|
|
qs := fetchSubscriptionUsage(again)
|
|
if len(qs) != 1 || qs[0].User != "a@fake" || qs[0].Error != "" || len(qs[0].Windows) != len(first) {
|
|
t.Fatalf("a@fake read again: %+v", qs)
|
|
}
|
|
for i, w := range qs[0].Windows {
|
|
if a, b := w.ResetsAt, first[i].ResetsAt; (a == nil) != (b == nil) || a != nil && !a.Equal(*b) {
|
|
t.Errorf("a@fake's %s renews at %v read again, at %v first", w.Name, a, b)
|
|
}
|
|
}
|
|
|
|
// a model the plugin says the plan serves at no cost shows as free,
|
|
// as WorkBuddy's built-in marked its x0.00 models
|
|
free := map[string]bool{}
|
|
for _, pp := range plugin.Cached() {
|
|
if pp.ID == "fakeco" {
|
|
for _, m := range pluginCatalog(pp) {
|
|
free[m.ID] = m.Free
|
|
}
|
|
}
|
|
}
|
|
if !free["fake-1"] || free["fake-claude"] {
|
|
t.Fatalf("free models %v, want fake-1 alone", free)
|
|
}
|
|
|
|
// the gateway asks the allowance by the account's UsageAgent
|
|
var agent string
|
|
for _, p := range All() {
|
|
if p.IsPlugin() && p.ID == "fakeco" {
|
|
agent = p.Account.UsageAgent()
|
|
}
|
|
}
|
|
if agent != "plugin:fakeco" {
|
|
t.Fatalf("UsageAgent = %q", agent)
|
|
}
|
|
// the plan the usage told stays with the account, shown beside it
|
|
plan := false
|
|
for _, p := range All() {
|
|
if p.IsPlugin() && p.ID == "fakeco" && p.Account.User == "a@fake" {
|
|
plan = p.Account.Plan == "Fake Pro"
|
|
}
|
|
}
|
|
if !plan {
|
|
t.Fatal("a@fake's provider doesn't carry its plan")
|
|
}
|
|
if q := LoginUsage(ctx, agent)["full@fake"]; q.Resets == nil || !q.Resets.ByWindow || q.Resets.FiveHour != 2 || q.Resets.Weekly != 1 || q.Resets.Count != 3 || q.User != "Full@Fake.example" {
|
|
t.Fatalf("full@fake = %+v, resets %+v", q, q.Resets)
|
|
}
|
|
full := allowanceOf(LoginUsage(ctx, agent)["full@fake"].Windows, time.Now())
|
|
if used, _ := full.For("fake-claude", time.Now()); used != 100 {
|
|
t.Fatalf("fake-claude on full@fake: used %v, want 100", used)
|
|
}
|
|
if used, _ := full.For("fake-1", time.Now()); used != 10 {
|
|
t.Fatalf("fake-1 on full@fake: used %v, want 10 (the five hours don't count it)", used)
|
|
}
|
|
if full.Full("fake-claude", 100, time.Now()).IsZero() || !full.Full("fake-1", 100, time.Now()).IsZero() {
|
|
t.Fatal("full@fake is used up for fake-claude alone")
|
|
}
|
|
}
|
|
|
|
// A usage read that finds the sign-in refused marks the account, as the
|
|
// built-ins' did, and a clean one takes the mark off; one the network
|
|
// failed shows the last reading, as a built-in's did.
|
|
func TestPluginUsageLapse(t *testing.T) {
|
|
bun := testenv.Bun(t)
|
|
claudeHome(t)
|
|
t.Setenv("MAGPIE_BUN", bun)
|
|
t.Cleanup(plugin.Settle)
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
abs, _ := filepath.Abs("../plugin/testdata/fake/index.js")
|
|
if _, err := plugin.Add(ctx, abs); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
auth := func(user, refresh string) map[string]any {
|
|
return map[string]any{"type": "oauth", "refresh": refresh, "access": "a", "expires": 9e15, "accountId": user}
|
|
}
|
|
keys := map[string]string{}
|
|
var rows []savedLogin
|
|
for user, refresh := range map[string]string{"gone@fake": "r-gone", "ok@fake": "r-ok", "off@fake": "r-offline", "kept@fake": "r-kept", "renewed@fake": "r-renewed"} {
|
|
k, err := plugin.Import(ctx, "fakeco", auth(user, refresh))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
keys[user] = k
|
|
rows = append(rows, savedLogin{Agent: "plugin:fakeco", User: user, Home: k, On: true})
|
|
}
|
|
saveLogins(t, rows...)
|
|
if _, err := plugin.Providers(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pp := mustPlugin(t)
|
|
for _, u := range []string{"ok@fake", "renewed@fake"} {
|
|
notePluginLapse(pp, keys[u], http.StatusUnauthorized)
|
|
}
|
|
lapsed := func() map[string]bool {
|
|
out := map[string]bool{}
|
|
for _, l := range pluginLogins(mustPlugin(t)) {
|
|
out[l.User] = l.Lapsed != ""
|
|
}
|
|
return out
|
|
}
|
|
if l := lapsed(); !l["ok@fake"] || l["gone@fake"] {
|
|
t.Fatalf("before: %v", l)
|
|
}
|
|
for _, u := range []string{"gone@fake", "ok@fake", "kept@fake", "renewed@fake"} {
|
|
pluginLoginQuota(ctx, Login{Agent: "plugin:fakeco", User: u})
|
|
}
|
|
// a plugin that says what the read means is taken at its word: kept
|
|
// though its error says to sign in again, renewed though the read failed
|
|
if l := lapsed(); l["ok@fake"] || !l["gone@fake"] || l["kept@fake"] || l["renewed@fake"] {
|
|
t.Fatalf("after reading usage: %v", l)
|
|
}
|
|
if !passing.MatchString(pluginLoginQuota(ctx, Login{Agent: "plugin:fakeco", User: "off@fake"}).Error) {
|
|
t.Fatal("a failed fetch isn't taken for the network")
|
|
}
|
|
}
|
|
|
|
// A usage read begun before a request was answered doesn't overrule what
|
|
// the answer told of the sign-in: one that ends clean leaves the mark of a
|
|
// 401 that came meanwhile, and one that ends refused doesn't mark an
|
|
// account a request went through on meanwhile. A read begun afterwards
|
|
// marks or clears as before. TestPluginFailover's refused account lost
|
|
// its mark to a reading of its allowance begun just before the 401.
|
|
func TestPluginUsageReadOlderThanAnswer(t *testing.T) {
|
|
bun := testenv.Bun(t)
|
|
claudeHome(t)
|
|
t.Setenv("MAGPIE_BUN", bun)
|
|
t.Cleanup(plugin.Settle)
|
|
asked, answer := make(chan struct{}), make(chan int)
|
|
vendor := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
select {
|
|
case asked <- struct{}{}:
|
|
case <-r.Context().Done():
|
|
return
|
|
}
|
|
select {
|
|
case status := <-answer:
|
|
w.WriteHeader(status)
|
|
fmt.Fprint(w, "Fake Pro")
|
|
case <-r.Context().Done():
|
|
}
|
|
}))
|
|
defer vendor.Close()
|
|
t.Setenv("FAKE_USAGE", vendor.URL)
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
abs, _ := filepath.Abs("../plugin/testdata/fake/index.js")
|
|
if _, err := plugin.Add(ctx, abs); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
key, err := plugin.Import(ctx, "fakeco", map[string]any{"type": "oauth", "refresh": "r-ok", "access": "a", "expires": 9e15, "accountId": "ok@fake"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
saveLogins(t, savedLogin{Agent: "plugin:fakeco", User: "ok@fake", Home: key, On: true})
|
|
if _, err := plugin.Providers(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pp := mustPlugin(t)
|
|
lapsed := func() bool {
|
|
for _, l := range pluginLogins(mustPlugin(t)) {
|
|
return l.Lapsed != ""
|
|
}
|
|
t.Fatal("no account")
|
|
return false
|
|
}
|
|
// read reads the account's usage, the vendor answering it status
|
|
// once meanwhile has run
|
|
read := func(status int, meanwhile func()) {
|
|
t.Helper()
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
pluginLoginQuota(ctx, Login{Agent: "plugin:fakeco", User: "ok@fake"})
|
|
}()
|
|
select {
|
|
case <-asked:
|
|
case <-ctx.Done():
|
|
t.Fatal("the usage was never asked")
|
|
}
|
|
meanwhile()
|
|
answer <- status
|
|
<-done
|
|
}
|
|
// as the gateway notes a request's answer that says nothing of it
|
|
answered := func(status int) func() {
|
|
return func() { notePluginSaid(pp, key, "", status) }
|
|
}
|
|
nothing := func() {}
|
|
|
|
read(http.StatusOK, answered(http.StatusUnauthorized))
|
|
if !lapsed() {
|
|
t.Fatal("a usage read begun before the 401 took its mark off")
|
|
}
|
|
read(http.StatusOK, nothing)
|
|
if lapsed() {
|
|
t.Fatal("a clean usage read begun after the 401 left its mark")
|
|
}
|
|
read(http.StatusUnauthorized, answered(http.StatusOK))
|
|
if lapsed() {
|
|
t.Fatal("a refused usage read begun before a request went through marked the account")
|
|
}
|
|
read(http.StatusUnauthorized, nothing)
|
|
if !lapsed() {
|
|
t.Fatal("a refused usage read didn't mark the account")
|
|
}
|
|
}
|
|
|
|
// Each of a plugin provider's accounts has the models its own plan
|
|
// serves, as each of a built-in's accounts had: a key that has fewer
|
|
// doesn't take on the first account's.
|
|
func TestPluginAccountModels(t *testing.T) {
|
|
bun := testenv.Bun(t)
|
|
claudeHome(t)
|
|
t.Setenv("MAGPIE_BUN", bun)
|
|
t.Cleanup(plugin.Settle)
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
abs, _ := filepath.Abs("../plugin/testdata/fake/index.js")
|
|
if _, err := plugin.Add(ctx, abs); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := plugin.Providers(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st, err := StartPluginSignIn("fakeco", 1, map[string]string{"where": "work", "team": "a"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := SubmitSignInCallback(st.ID, "good"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := PluginAPIKey(ctx, "fakeco", 0, nil, "few"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := plugin.Providers(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := map[string][]string{}
|
|
pp := mustPlugin(t)
|
|
for _, l := range pluginLogins(pp) {
|
|
for _, m := range pluginProvider(pp, l).Available() {
|
|
got[l.User] = append(got[l.User], m.ID)
|
|
}
|
|
}
|
|
if len(got) != 2 || len(got["a@fake"]) < 2 || strings.Join(got["API key"], " ") != "fake-1" {
|
|
t.Fatalf("each account's models: %v", got)
|
|
}
|
|
}
|
|
|
|
// A built-in moved onto its plugin keeps the name, icon and site its
|
|
// provider had, whatever the plugin calls itself.
|
|
func TestMovedProviderName(t *testing.T) {
|
|
claudeHome(t)
|
|
for id, want := range map[string]string{CommandCodePlanID: "Command Code Plan", "grok": "Grok (SuperGrok)", "kiro": "Kiro"} {
|
|
if err := setMigration(id, func(m *Migration) { m.State = MovePlugin }); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p := pluginProvider(plugin.Provider{ID: id, Name: "Plugin's own " + id}, pluginLogin{})
|
|
if p.Name != want || p.Icon != movedCards[id].icon || p.Website != movedCards[id].site {
|
|
t.Fatalf("%s: %q %q %q, want %q", id, p.Name, p.Icon, p.Website, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A models hook that finds the sign-in refused and says so marks the
|
|
// account, as a built-in whose model list the vendor refused marked it.
|
|
func TestPluginModelsSayExpired(t *testing.T) {
|
|
bun := testenv.Bun(t)
|
|
claudeHome(t)
|
|
t.Setenv("MAGPIE_BUN", bun)
|
|
t.Cleanup(plugin.Settle)
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
abs, _ := filepath.Abs("../plugin/testdata/fake/index.js")
|
|
if _, err := plugin.Add(ctx, abs); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var rows []savedLogin
|
|
for user, refresh := range map[string]string{"ok@fake": "r-ok", "gone@fake": "r-models-gone", "dead@fake": "r-dead"} {
|
|
k, err := plugin.Import(ctx, "fakeco", map[string]any{"type": "oauth", "refresh": refresh, "access": "a", "expires": 9e15, "accountId": user})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rows = append(rows, savedLogin{Agent: "plugin:fakeco", User: user, Home: k, On: true})
|
|
}
|
|
saveLogins(t, rows...)
|
|
if _, err := plugin.Providers(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for deadline := time.Now().Add(10 * time.Second); ; time.Sleep(50 * time.Millisecond) {
|
|
lapsed := map[string]bool{}
|
|
for _, l := range pluginLogins(mustPlugin(t)) {
|
|
lapsed[l.User] = l.Lapsed != ""
|
|
}
|
|
if lapsed["gone@fake"] && !lapsed["ok@fake"] && !lapsed["dead@fake"] {
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("lapsed after reading the models: %v, want gone@fake alone (dead@fake's hook said nothing)", lapsed)
|
|
}
|
|
}
|
|
}
|