From 2da8fbe4886847189e8e2acb4b6ff556d34bfff6 Mon Sep 17 00:00:00 2001 From: yetone Date: Sat, 10 Oct 2026 10:22:56 +0800 Subject: [PATCH] tests: every test that runs plugins on the bun on PATH runs it once first, through testenv.Bun, so a bun that has never run here doesn't time out internal/provider's and internal/plugin's plugin tests (#1306) 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 (7ddea2c2 covers 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. --- accounts_plugin_cli_test.go | 3 ++- internal/gateway/plugin_failover_test.go | 12 +++------ internal/gateway/plugin_grok_parity_test.go | 7 ++--- internal/gateway/plugin_listing_proxy_test.go | 7 ++--- internal/gateway/plugin_parity_test.go | 7 ++--- internal/gateway/plugin_plan_lacks_test.go | 7 ++--- internal/gateway/plugin_proxy_down_test.go | 7 ++--- internal/gateway/plugin_test.go | 7 ++--- internal/gui/plugins_clash_test.go | 6 +---- internal/plugin/bunupdate_test.go | 11 ++------ internal/plugin/plugin_test.go | 7 ++--- .../provider/account_order_plugin_test.go | 7 ++--- internal/provider/docker_signin_test.go | 5 +--- internal/provider/forget_account_test.go | 7 ++--- internal/provider/migrate_lapse_test.go | 16 +++-------- internal/provider/migrate_test.go | 25 +++++------------ internal/provider/migrate_ux_test.go | 7 ++--- internal/provider/plugin_checkin_test.go | 12 +++------ internal/provider/plugin_fetch_lapse_test.go | 7 ++--- internal/provider/plugin_fetchnew_test.go | 12 +++------ internal/provider/plugin_id_custom_test.go | 7 ++--- internal/provider/plugin_id_reserved_test.go | 7 ++--- internal/provider/plugin_moved_test.go | 6 +---- internal/provider/plugin_usage_test.go | 27 +++++-------------- internal/provider/pluginsignin_paste_test.go | 7 ++--- internal/provider/pluginsignin_test.go | 12 +++------ internal/provider/signin_removed_test.go | 7 ++--- internal/testenv/bun.go | 27 +++++++++++++++++++ plugin_clash_cli_test.go | 7 ++--- plugin_login_cli_test.go | 19 ++----------- 30 files changed, 97 insertions(+), 208 deletions(-) create mode 100644 internal/testenv/bun.go diff --git a/accounts_plugin_cli_test.go b/accounts_plugin_cli_test.go index 1dae1f3d..71cb0fb8 100644 --- a/accounts_plugin_cli_test.go +++ b/accounts_plugin_cli_test.go @@ -10,12 +10,13 @@ import ( "github.com/yetone/magpie/internal/plugin" "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/testenv" ) // magpie accounts lists, switches and forgets a plugin's accounts by its // provider's id, as a built-in subscription's. func TestAccountsOfAPlugin(t *testing.T) { - bun := pathBun(t) + bun := testenv.Bun(t) home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) diff --git a/internal/gateway/plugin_failover_test.go b/internal/gateway/plugin_failover_test.go index b37e1059..e45bc71a 100644 --- a/internal/gateway/plugin_failover_test.go +++ b/internal/gateway/plugin_failover_test.go @@ -6,7 +6,6 @@ import ( "io" "net/http" "net/http/httptest" - "os/exec" "path/filepath" "strings" "sync" @@ -15,6 +14,7 @@ import ( "github.com/yetone/magpie/internal/plugin" "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/testenv" ) // A plugin's provider with several accounts fails over as a built-in's @@ -24,10 +24,7 @@ import ( // and a routing group goes on to its next member when every account of // the plugin is out. func TestPluginFailover(t *testing.T) { - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) fresh(t) t.Setenv("MAGPIE_BUN", bun) t.Cleanup(plugin.Settle) @@ -255,10 +252,7 @@ func contains(xs []string, x string) bool { // lapsed, as the built-in's did; a 401 the built-in didn't take for a // refused sign-in leaves it be. The agent never sees the header. func TestPluginSaysSignIn(t *testing.T) { - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) fresh(t) t.Setenv("MAGPIE_BUN", bun) t.Cleanup(plugin.Settle) diff --git a/internal/gateway/plugin_grok_parity_test.go b/internal/gateway/plugin_grok_parity_test.go index c7aaf54c..5851ac40 100644 --- a/internal/gateway/plugin_grok_parity_test.go +++ b/internal/gateway/plugin_grok_parity_test.go @@ -7,7 +7,6 @@ import ( "io" "net/http" "net/http/httptest" - "os/exec" "path/filepath" "strings" "sync" @@ -16,6 +15,7 @@ import ( "github.com/yetone/magpie/internal/plugin" "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/testenv" ) // besideFake installs the fake plugin as the plugin of the built-in id, @@ -23,10 +23,7 @@ import ( // key, its requests going to up; it gives the provider's id. func besideFake(t *testing.T, id string, up http.Handler) string { t.Helper() - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) fresh(t) t.Setenv("MAGPIE_BUN", bun) t.Setenv("FAKE_ID", id) diff --git a/internal/gateway/plugin_listing_proxy_test.go b/internal/gateway/plugin_listing_proxy_test.go index 99665ca8..e3c11ff3 100644 --- a/internal/gateway/plugin_listing_proxy_test.go +++ b/internal/gateway/plugin_listing_proxy_test.go @@ -5,7 +5,6 @@ import ( "fmt" "net/http" "net/http/httptest" - "os/exec" "path/filepath" "slices" "testing" @@ -13,16 +12,14 @@ import ( "github.com/yetone/magpie/internal/plugin" "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/testenv" ) // A plugin account's model list is asked through the account's own proxy, // as a built-in account's is fetched through its: each of two accounts // behind its own proxy is told the list its proxy carried. func TestPluginModelListThroughAccountProxy(t *testing.T) { - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) fresh(t) for _, k := range []string{"HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy"} { t.Setenv(k, "") diff --git a/internal/gateway/plugin_parity_test.go b/internal/gateway/plugin_parity_test.go index 24d21d21..d658af4c 100644 --- a/internal/gateway/plugin_parity_test.go +++ b/internal/gateway/plugin_parity_test.go @@ -6,7 +6,6 @@ import ( "net/http" "net/http/httptest" "os" - "os/exec" "path/filepath" "strings" "sync" @@ -15,16 +14,14 @@ import ( "github.com/yetone/magpie/internal/plugin" "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/testenv" ) // movedFake is the fake plugin signed in as the built-in id it was moved // onto, its requests going to up. func movedFake(t *testing.T, id string, up http.Handler) { t.Helper() - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) fresh(t) t.Setenv("MAGPIE_BUN", bun) t.Setenv("FAKE_ID", id) diff --git a/internal/gateway/plugin_plan_lacks_test.go b/internal/gateway/plugin_plan_lacks_test.go index a133549b..be950e88 100644 --- a/internal/gateway/plugin_plan_lacks_test.go +++ b/internal/gateway/plugin_plan_lacks_test.go @@ -5,7 +5,6 @@ import ( "io" "net/http" "net/http/httptest" - "os/exec" "path/filepath" "strings" "sync" @@ -14,16 +13,14 @@ import ( "github.com/yetone/magpie/internal/plugin" "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/testenv" ) // A plugin's account whose plan lacks the model is tried after the ones // that list it, as a Codex account's is (a Free one behind a Plus): the // plugin's first account, told only fake-1, isn't sent fake-resp. func TestPluginAccountLackingTheModelGoesLast(t *testing.T) { - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) fresh(t) t.Setenv("MAGPIE_BUN", bun) t.Setenv("FAKE_RESPONSES", "1") diff --git a/internal/gateway/plugin_proxy_down_test.go b/internal/gateway/plugin_proxy_down_test.go index 8ff05133..3a7add57 100644 --- a/internal/gateway/plugin_proxy_down_test.go +++ b/internal/gateway/plugin_proxy_down_test.go @@ -7,7 +7,6 @@ import ( "net" "net/http" "net/http/httptest" - "os/exec" "path/filepath" "strings" "testing" @@ -15,16 +14,14 @@ import ( "github.com/yetone/magpie/internal/plugin" "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/testenv" ) // A plugin account whose proxy is down, HTTP or SOCKS5, never reached its // vendor: as for a built-in's (TestProxyDownDoesNotRest), the next account // answers and the first isn't set aside. func TestPluginProxyDownDoesNotRest(t *testing.T) { - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) for _, scheme := range []string{"http", "socks5"} { t.Run(scheme, func(t *testing.T) { fresh(t) diff --git a/internal/gateway/plugin_test.go b/internal/gateway/plugin_test.go index 44208a05..a02c1974 100644 --- a/internal/gateway/plugin_test.go +++ b/internal/gateway/plugin_test.go @@ -6,7 +6,6 @@ import ( "io" "net/http" "net/http/httptest" - "os/exec" "path/filepath" "slices" "strings" @@ -16,6 +15,7 @@ import ( "github.com/yetone/magpie/internal/plugin" "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/testenv" ) // A plugin's provider signed in to is a provider like the others: each of @@ -23,10 +23,7 @@ import ( // base URL the plugin's loader gives, and the plugin's fetch carries the // request with the sign-in it keeps. func TestPluginProvider(t *testing.T) { - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) fresh(t) t.Setenv("MAGPIE_BUN", bun) t.Cleanup(plugin.Settle) diff --git a/internal/gui/plugins_clash_test.go b/internal/gui/plugins_clash_test.go index 72cecd72..dd0790e7 100644 --- a/internal/gui/plugins_clash_test.go +++ b/internal/gui/plugins_clash_test.go @@ -6,7 +6,6 @@ import ( "net/http" "net/http/httptest" "os" - "os/exec" "path/filepath" "testing" @@ -19,10 +18,7 @@ import ( // /api/plugins carries the clash, naming the plugin that serves it and // the provider as it is listed, and prefer hands it to the other one. func TestPluginsClashRows(t *testing.T) { - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) dir := t.TempDir() testenv.SetHome(t, dir) t.Setenv("XDG_CONFIG_HOME", filepath.Join(dir, "config")) diff --git a/internal/plugin/bunupdate_test.go b/internal/plugin/bunupdate_test.go index 87eed266..b436dc6e 100644 --- a/internal/plugin/bunupdate_test.go +++ b/internal/plugin/bunupdate_test.go @@ -11,7 +11,6 @@ import ( "net/http" "net/http/httptest" "os" - "os/exec" "path/filepath" "runtime" "strings" @@ -211,10 +210,7 @@ func TestCheckBunSetsAsideOneThatFailsItsTry(t *testing.T) { // The real try: the Bun on PATH passes it, as its own version. func TestTryBunOnRealBun(t *testing.T) { - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) v := bunReported(bun) if err := tryBun(context.Background(), bun, v); err != nil { t.Fatalf("bun %s: %v", v, err) @@ -230,10 +226,7 @@ func TestHostFallsBackWhenTheNewBunDies(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("the dying bun is a shell script") } - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) bunHome(t, "9.9.9", time.Now().Add(-72*time.Hour)) t.Cleanup(Settle) if err := os.MkdirAll(bunDirOf(BunVersion), 0o755); err != nil { diff --git a/internal/plugin/plugin_test.go b/internal/plugin/plugin_test.go index 63c5189e..33b579b4 100644 --- a/internal/plugin/plugin_test.go +++ b/internal/plugin/plugin_test.go @@ -9,7 +9,6 @@ import ( "net/http" "net/http/httptest" "os" - "os/exec" "path/filepath" "reflect" "runtime" @@ -20,16 +19,14 @@ import ( "time" "github.com/yetone/magpie/internal/netproxy" + "github.com/yetone/magpie/internal/testenv" ) // 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") - } + bun := testenv.Bun(t) dir := t.TempDir() t.Setenv("HOME", dir) t.Setenv("USERPROFILE", dir) diff --git a/internal/provider/account_order_plugin_test.go b/internal/provider/account_order_plugin_test.go index 8227ab6b..07337a2e 100644 --- a/internal/provider/account_order_plugin_test.go +++ b/internal/provider/account_order_plugin_test.go @@ -2,23 +2,20 @@ package provider import ( "context" - "os/exec" "path/filepath" "reflect" "testing" "time" "github.com/yetone/magpie/internal/plugin" + "github.com/yetone/magpie/internal/testenv" ) // A plugin provider's accounts are arranged like a built-in's: the page // names them by the provider's id, magpie keeps them as plugin:, and // the gateway tries them in the order dragged. func TestAccountOrderPluginAccounts(t *testing.T) { - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) claudeHome(t) t.Setenv("MAGPIE_BUN", bun) t.Cleanup(plugin.Settle) diff --git a/internal/provider/docker_signin_test.go b/internal/provider/docker_signin_test.go index 299ec167..b6c44f7a 100644 --- a/internal/provider/docker_signin_test.go +++ b/internal/provider/docker_signin_test.go @@ -208,10 +208,7 @@ func TestCommandCodeSignInPastedKey(t *testing.T) { // bash" in Docker, where there's no shell to run it: the CLI is installed // first, as it is for a moved one. func TestGrokPluginSignInInstallsCLI(t *testing.T) { - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) home := claudeHome(t) t.Setenv("MAGPIE_BUN", bun) t.Cleanup(plugin.Settle) diff --git a/internal/provider/forget_account_test.go b/internal/provider/forget_account_test.go index 5950f478..7b5136cd 100644 --- a/internal/provider/forget_account_test.go +++ b/internal/provider/forget_account_test.go @@ -2,13 +2,13 @@ package provider import ( "context" - "os/exec" "path/filepath" "slices" "testing" "time" "github.com/yetone/magpie/internal/plugin" + "github.com/yetone/magpie/internal/testenv" ) // #694: a plugin's subscription removed from magpie was listed among the @@ -17,10 +17,7 @@ import ( // ForgetAccount signs its accounts out of plugin-auth.json and logins.json: // it leaves the removed list, and a sign-in later starts afresh. func TestForgetRemovedPluginAccount(t *testing.T) { - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) claudeHome(t) t.Setenv("MAGPIE_BUN", bun) t.Cleanup(plugin.Settle) diff --git a/internal/provider/migrate_lapse_test.go b/internal/provider/migrate_lapse_test.go index 24640943..b4a9e32b 100644 --- a/internal/provider/migrate_lapse_test.go +++ b/internal/provider/migrate_lapse_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/yetone/magpie/internal/plugin" + "github.com/yetone/magpie/internal/testenv" ) // An account the vendor refused while it ran on the plugin goes back to @@ -18,10 +19,7 @@ import ( // (every real mover clears the mark there): the built-in had marked it // the same way, and it would show signed in until its next refusal. func TestMoveBackKeepsLapse(t *testing.T) { - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) claudeHome(t) t.Setenv("MAGPIE_BUN", bun) t.Cleanup(plugin.Settle) @@ -76,10 +74,7 @@ func TestMoveBackKeepsLapse(t *testing.T) { // on a refused model token) moves along marked, as the built-in would // have marked it on its own model read, and doesn't stop the move. func TestMoveRefusedWhileListing(t *testing.T) { - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) claudeHome(t) t.Setenv("MAGPIE_BUN", bun) t.Cleanup(plugin.Settle) @@ -123,10 +118,7 @@ func TestMoveRefusedWhileListing(t *testing.T) { // the whole move. A plugin failing an account for any other reason still // does (TestMove's r-dead). func TestMoveExpiredInWords(t *testing.T) { - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) claudeHome(t) t.Setenv("MAGPIE_BUN", bun) t.Cleanup(plugin.Settle) diff --git a/internal/provider/migrate_test.go b/internal/provider/migrate_test.go index 8b6f174d..266f79db 100644 --- a/internal/provider/migrate_test.go +++ b/internal/provider/migrate_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "os" - "os/exec" "path/filepath" "slices" "strings" @@ -12,6 +11,7 @@ import ( "time" "github.com/yetone/magpie/internal/plugin" + "github.com/yetone/magpie/internal/testenv" "github.com/yetone/magpie/internal/zed" ) @@ -77,10 +77,7 @@ func fakeSaved(t *testing.T) map[string]savedLogin { // Moved, the plugin has the accounts in the user's order; moved back, the // built-in has them again, fresh tokens and order included. func TestMoveToPlugin(t *testing.T) { - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) claudeHome(t) t.Setenv("MAGPIE_BUN", bun) t.Cleanup(plugin.Settle) @@ -128,7 +125,7 @@ func TestMoveToPlugin(t *testing.T) { // an account the vendor refuses: nothing moves, and the other's token, // spent by the plugin, comes back renewed reset(fakeLogin("a@fake", "rot-a", false, true), fakeLogin("c@fake", "r-dead", false, true)) - err = Move(ctx, "fakeco") + err := Move(ctx, "fakeco") if err == nil || !strings.Contains(err.Error(), "c@fake") { t.Fatalf("Move with a refused account = %v", err) } @@ -282,10 +279,7 @@ func TestMoveToPlugin(t *testing.T) { // A move cut short (magpie quit mid-way) is put back before it is tried // again, and a failed one waits before magpie tries it by itself again. func TestMoveCutShort(t *testing.T) { - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) claudeHome(t) t.Setenv("MAGPIE_BUN", bun) t.Cleanup(plugin.Settle) @@ -361,10 +355,7 @@ func mustPlugin(t *testing.T) plugin.Provider { // when it renewed one, the built-in's when that renewed it meanwhile (a // request went to it), none when neither did. func TestPutBackKeepsNewer(t *testing.T) { - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) claudeHome(t) t.Setenv("MAGPIE_BUN", bun) t.Cleanup(plugin.Settle) @@ -470,10 +461,7 @@ func TestKeepMovedCurrent(t *testing.T) { // the built-in back first: its accounts go back to it rather than out of // sight with the plugin. func TestReleasePlugin(t *testing.T) { - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) for _, op := range []string{"off", "remove"} { t.Run(op, func(t *testing.T) { claudeHome(t) @@ -502,6 +490,7 @@ func TestReleasePlugin(t *testing.T) { if got := MovedOnto(abs); len(got) != 1 || got[0] != "fakeco" { t.Fatalf("moved onto the plugin: %v", got) } + var err error if op == "off" { err = SetPluginOff(ctx, abs, true) } else { diff --git a/internal/provider/migrate_ux_test.go b/internal/provider/migrate_ux_test.go index eb1b1786..c577f38d 100644 --- a/internal/provider/migrate_ux_test.go +++ b/internal/provider/migrate_ux_test.go @@ -4,13 +4,13 @@ import ( "context" "encoding/json" "os" - "os/exec" "path/filepath" "strings" "testing" "time" "github.com/yetone/magpie/internal/plugin" + "github.com/yetone/magpie/internal/testenv" "github.com/yetone/magpie/internal/zed" ) @@ -18,10 +18,7 @@ import ( // the plugin not yet installed: the move's install adds it. func fakePlugin(t *testing.T, inUse *[]string) (context.Context, string, *int) { t.Helper() - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) claudeHome(t) t.Setenv("MAGPIE_BUN", bun) t.Cleanup(plugin.Settle) diff --git a/internal/provider/plugin_checkin_test.go b/internal/provider/plugin_checkin_test.go index dfb8997d..804e8179 100644 --- a/internal/provider/plugin_checkin_test.go +++ b/internal/provider/plugin_checkin_test.go @@ -3,7 +3,6 @@ package provider import ( "context" "os" - "os/exec" "path/filepath" "slices" "strings" @@ -12,16 +11,14 @@ import ( "github.com/yetone/magpie/internal/plugin" "github.com/yetone/magpie/internal/settings" + "github.com/yetone/magpie/internal/testenv" ) // pluginCheckinSandbox is the fake plugin with its own check-in, signed in // with each key; it says the file each press is written to. func pluginCheckinSandbox(t *testing.T, keys ...string) string { t.Helper() - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) home := claudeHome(t) t.Setenv("MAGPIE_BUN", bun) presses := filepath.Join(home, "presses") @@ -175,10 +172,7 @@ func TestPluginCheckinRunsAsTheBuiltinsDo(t *testing.T) { // through its fetch, so an account isn't checked in twice; and its switch // follows the vendor's, which it took over, until set on its own. func TestPluginCheckinTakesOverTheVendors(t *testing.T) { - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) claudeHome(t) t.Setenv("MAGPIE_BUN", bun) t.Setenv("FAKE_ID", "qoder") diff --git a/internal/provider/plugin_fetch_lapse_test.go b/internal/provider/plugin_fetch_lapse_test.go index a4460a8d..108e1fc1 100644 --- a/internal/provider/plugin_fetch_lapse_test.go +++ b/internal/provider/plugin_fetch_lapse_test.go @@ -3,23 +3,20 @@ package provider import ( "context" "net/http" - "os/exec" "path/filepath" "strings" "testing" "time" "github.com/yetone/magpie/internal/plugin" + "github.com/yetone/magpie/internal/testenv" ) // A plugin's fetch hook that throws on its sign-in (the vendor turned the // refresh away) marks the account lapsed, as a built-in's refused refresh // marked it; the other account is left alone. func TestPluginFetchSignInExpiredLapses(t *testing.T) { - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) claudeHome(t) t.Setenv("MAGPIE_BUN", bun) t.Setenv("FAKE_BASE", "http://127.0.0.1:9/v1") diff --git a/internal/provider/plugin_fetchnew_test.go b/internal/provider/plugin_fetchnew_test.go index ba65eca3..9c236992 100644 --- a/internal/provider/plugin_fetchnew_test.go +++ b/internal/provider/plugin_fetchnew_test.go @@ -6,13 +6,13 @@ import ( "net/http" "net/http/httptest" "os" - "os/exec" "path/filepath" "sync/atomic" "testing" "time" "github.com/yetone/magpie/internal/plugin" + "github.com/yetone/magpie/internal/testenv" ) // A plugin's account listed with the plugin's providers is not asked for @@ -20,10 +20,7 @@ import ( // the Providers page built its state through FetchNew, and every ten // minutes asked every plugin's vendors for their lists again. func TestFetchNewLeavesListedPlugins(t *testing.T) { - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) claudeHome(t) t.Setenv("MAGPIE_BUN", bun) t.Cleanup(plugin.Settle) @@ -64,10 +61,7 @@ func TestFetchNewLeavesListedPlugins(t *testing.T) { // plugin: (routing) is read from the vendor once, as a built-in's, // asked for by one name, is. func TestPluginUsageReadOnceWhicheverName(t *testing.T) { - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) claudeHome(t) t.Setenv("MAGPIE_BUN", bun) t.Cleanup(plugin.Settle) diff --git a/internal/provider/plugin_id_custom_test.go b/internal/provider/plugin_id_custom_test.go index 8e976ded..e9196aa3 100644 --- a/internal/provider/plugin_id_custom_test.go +++ b/internal/provider/plugin_id_custom_test.go @@ -2,12 +2,12 @@ package provider import ( "context" - "os/exec" "path/filepath" "testing" "time" "github.com/yetone/magpie/internal/plugin" + "github.com/yetone/magpie/internal/testenv" ) // #867: a provider of the user's own saved before a plugin with its id was @@ -15,10 +15,7 @@ import ( // subscription once signed in — "signed in, but magpie can't list it". The // plugin's is id-plugin then, and the user's own keeps its id. func TestPluginIDBesideCustomProvider(t *testing.T) { - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) claudeHome(t) t.Setenv("MAGPIE_BUN", bun) t.Cleanup(plugin.Settle) diff --git a/internal/provider/plugin_id_reserved_test.go b/internal/provider/plugin_id_reserved_test.go index 72ff51d1..f499cafd 100644 --- a/internal/provider/plugin_id_reserved_test.go +++ b/internal/provider/plugin_id_reserved_test.go @@ -2,22 +2,19 @@ package provider import ( "context" - "os/exec" "path/filepath" "testing" "time" "github.com/yetone/magpie/internal/plugin" + "github.com/yetone/magpie/internal/testenv" ) // An installed plugin's provider id is kept for it signed in or not, as a // built-in subscription's is: a provider of the user's own saved on it, or // renamed onto it, would hide the plugin's subscription once signed in. func TestPluginIDReservedSignedOut(t *testing.T) { - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) claudeHome(t) t.Setenv("MAGPIE_BUN", bun) t.Cleanup(plugin.Settle) diff --git a/internal/provider/plugin_moved_test.go b/internal/provider/plugin_moved_test.go index 04a61143..64a85bdb 100644 --- a/internal/provider/plugin_moved_test.go +++ b/internal/provider/plugin_moved_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "os" - "os/exec" "path/filepath" "strings" "testing" @@ -170,10 +169,7 @@ func TestAllLoginsMoved(t *testing.T) { // installed first when it isn't there, the code the page asks about shown // to copy, and the account it signs in to named as the one in use. func TestMovedSignInAsBuiltIn(t *testing.T) { - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) home := claudeHome(t) t.Setenv("MAGPIE_BUN", bun) t.Cleanup(plugin.Settle) diff --git a/internal/provider/plugin_usage_test.go b/internal/provider/plugin_usage_test.go index abcc45ff..4380ffff 100644 --- a/internal/provider/plugin_usage_test.go +++ b/internal/provider/plugin_usage_test.go @@ -5,23 +5,20 @@ import ( "fmt" "net/http" "net/http/httptest" - "os/exec" "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, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) claudeHome(t) t.Setenv("MAGPIE_BUN", bun) t.Cleanup(plugin.Settle) @@ -165,10 +162,7 @@ func TestPluginUsage(t *testing.T) { // 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, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) claudeHome(t) t.Setenv("MAGPIE_BUN", bun) t.Cleanup(plugin.Settle) @@ -229,10 +223,7 @@ func TestPluginUsageLapse(t *testing.T) { // 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, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) claudeHome(t) t.Setenv("MAGPIE_BUN", bun) t.Cleanup(plugin.Settle) @@ -320,10 +311,7 @@ func TestPluginUsageReadOlderThanAnswer(t *testing.T) { // 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, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) claudeHome(t) t.Setenv("MAGPIE_BUN", bun) t.Cleanup(plugin.Settle) @@ -379,10 +367,7 @@ func TestMovedProviderName(t *testing.T) { // 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, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) claudeHome(t) t.Setenv("MAGPIE_BUN", bun) t.Cleanup(plugin.Settle) diff --git a/internal/provider/pluginsignin_paste_test.go b/internal/provider/pluginsignin_paste_test.go index 4fc1b32f..4f1e9581 100644 --- a/internal/provider/pluginsignin_paste_test.go +++ b/internal/provider/pluginsignin_paste_test.go @@ -3,13 +3,13 @@ package provider import ( "context" "net/url" - "os/exec" "path/filepath" "strings" "testing" "time" "github.com/yetone/magpie/internal/plugin" + "github.com/yetone/magpie/internal/testenv" ) // A plugin's browser sign-in that comes back to a port of the plugin's on @@ -18,10 +18,7 @@ import ( // only this sign-in's port is taken, a page that sends the browser on is // the next page to open, a wrong code fails, the right one signs in. func TestPluginSignInTakesAPastedCallback(t *testing.T) { - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) claudeHome(t) t.Setenv("MAGPIE_BUN", bun) t.Setenv("FAKE_LOOPBACK", "1") diff --git a/internal/provider/pluginsignin_test.go b/internal/provider/pluginsignin_test.go index dd0d253b..11fecc3c 100644 --- a/internal/provider/pluginsignin_test.go +++ b/internal/provider/pluginsignin_test.go @@ -2,7 +2,6 @@ package provider import ( "context" - "os/exec" "path/filepath" "slices" "strings" @@ -10,6 +9,7 @@ import ( "time" "github.com/yetone/magpie/internal/plugin" + "github.com/yetone/magpie/internal/testenv" ) // A plugin's sign-in is followed like a built-in one: the page, then the @@ -17,10 +17,7 @@ import ( // done, its provider is there, and removing it signs out. A key signs in // at once. func TestPluginSignIn(t *testing.T) { - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) claudeHome(t) t.Setenv("MAGPIE_BUN", bun) t.Cleanup(plugin.Settle) @@ -102,10 +99,7 @@ func TestPluginSignIn(t *testing.T) { // subscription does: each signed in beside the others, one first and the // rest on behind it or off, any put first, and removing one signs it out. func TestPluginAccounts(t *testing.T) { - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) claudeHome(t) t.Setenv("MAGPIE_BUN", bun) t.Cleanup(plugin.Settle) diff --git a/internal/provider/signin_removed_test.go b/internal/provider/signin_removed_test.go index a3710a68..4673d7b8 100644 --- a/internal/provider/signin_removed_test.go +++ b/internal/provider/signin_removed_test.go @@ -6,7 +6,6 @@ import ( "net" "net/http" "net/http/httptest" - "os/exec" "path/filepath" "sync" "testing" @@ -14,6 +13,7 @@ import ( "github.com/yetone/magpie/internal/catalog" "github.com/yetone/magpie/internal/plugin" + "github.com/yetone/magpie/internal/testenv" ) // Signing in to an account removed from magpie shows it again before the @@ -62,10 +62,7 @@ func TestSignInDoneOnceTheAccountIsBack(t *testing.T) { }, { agent: "fakeco", setUp: func(t *testing.T) { - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) claudeHome(t) t.Setenv("MAGPIE_BUN", bun) t.Cleanup(plugin.Settle) diff --git a/internal/testenv/bun.go b/internal/testenv/bun.go new file mode 100644 index 00000000..0fd254c9 --- /dev/null +++ b/internal/testenv/bun.go @@ -0,0 +1,27 @@ +package testenv + +import ( + "os/exec" + "sync" + "testing" +) + +// bunsRun holds a *sync.Once for each bun Bun has run. +var bunsRun sync.Map + +// Bun is the bun on PATH for a test that runs plugins with it (as +// MAGPIE_BUN), run once before the test opens its deadline; a machine +// without one skips the test. A bun just installed or upgraded hasn't run +// on this machine yet, and macOS checks a program before its first exec, +// which can take tens of seconds on a busy machine: that check must not eat +// the minute a test gives its plugin calls (ggbdpq, #1306). +func Bun(t testing.TB) string { + t.Helper() + bun, err := exec.LookPath("bun") + if err != nil { + t.Skip("no bun on PATH") + } + once, _ := bunsRun.LoadOrStore(bun, new(sync.Once)) + once.(*sync.Once).Do(func() { exec.Command(bun, "--version").Run() }) + return bun +} diff --git a/plugin_clash_cli_test.go b/plugin_clash_cli_test.go index 3c3699f1..8a34805c 100644 --- a/plugin_clash_cli_test.go +++ b/plugin_clash_cli_test.go @@ -3,22 +3,19 @@ package main import ( "context" "os" - "os/exec" "path/filepath" "strings" "testing" "github.com/yetone/magpie/internal/plugin" + "github.com/yetone/magpie/internal/testenv" ) // magpie plugin lists both plugins that sign in to one provider (neiko on // Discord: a third-party plugin and their own), says which one serves it // and how to have the other one serve it; `magpie plugin use` does that. func TestPluginListSaysClash(t *testing.T) { - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } + bun := testenv.Bun(t) groupsHome(t) t.Setenv("MAGPIE_BUN", bun) t.Setenv("MAGPIE_PLUGIN_MARKET", "off") diff --git a/plugin_login_cli_test.go b/plugin_login_cli_test.go index ae279d55..8926d9ab 100644 --- a/plugin_login_cli_test.go +++ b/plugin_login_cli_test.go @@ -5,7 +5,6 @@ import ( "fmt" "net/http" "net/http/httptest" - "os/exec" "path/filepath" "sync/atomic" "testing" @@ -13,6 +12,7 @@ import ( "github.com/yetone/magpie/internal/plugin" "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/testenv" ) // magpie accounts add, signing in to a plugin's provider removed from @@ -101,7 +101,7 @@ func TestPluginLoginReadsUsageAfresh(t *testing.T) { // plugin (FakeCo) added, as TestAccountsOfAPlugin sets one up. func fakeCoHome(t *testing.T) context.Context { t.Helper() - bun := pathBun(t) + bun := testenv.Bun(t) home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) @@ -122,21 +122,6 @@ func fakeCoHome(t *testing.T) context.Context { return ctx } -// pathBun is the bun on PATH, run once before the caller opens its -// one-minute context. A bun just installed or upgraded hasn't run on this -// machine yet, and macOS checks a program before its first exec, which can -// take tens of seconds on a busy machine: that check must not eat the -// minute the plugin calls have (ggbdpq, #1306; 7ddea2c2 for the copied bun). -func pathBun(t *testing.T) string { - t.Helper() - bun, err := exec.LookPath("bun") - if err != nil { - t.Skip("no bun on PATH") - } - exec.Command(bun, "--version").Run() - return bun -} - // noBrowser has the sign-in pages magpie opens go nowhere. It swaps the // opener rather than putting a do-nothing open first on PATH: a script // written now is a program macOS checks on its first run, which took 25 to