provider: a providers.json that can't be read is said where the providers are listed, never backed up or synced as none, and is replaced through a rename (PR #415's review) — #415 made edits refuse a file they couldn't read or decode, but every read for the user still took it as empty: magpie providers said "no providers yet", magpie models "no models yet", the Providers page opened the Add sheet as for a first use, and backup.Collect, so an export and a WebDAV/S3 sync, carried it as no providers, which another computer's Mirror would then take as every provider removed. magpie providers and magpie models now list what is left (the signed-in accounts) and end in the error, the TUI says it over its list, and the Providers page shows a "Your providers file can't be read" box (无法读取供应商配置文件) over the list with magpie's reason and no Add sheet; start-up, the gateway and the catalog still go on with it as empty. Stored and StoredGroups return the read error, so a backup and a sync stop on it with nothing pushed. The error, for reads and refused writes alike, names the file, says magpie left it unchanged and to fix it or move it aside (errors.Is ErrUnreadable). store writes through writePrivate, a 0600 temp file renamed over it (steady.Rename waits out a Windows reader holding it, a symlink keeps its target), instead of os.WriteFile's truncate-then-write, and the read goes through steady.ReadFile. TestFileErrorSaysUnreadable, TestWriteErrorsSayLeftUnchanged, TestStoredRefusesUnreadable, TestStoreReplacesAtomically, TestSyncStopsOnUnreadableProviders, TestProvidersCmdSaysUnreadable and provider-file-error.test.cjs fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; provider-file-error, click-scroll, old-webkit, providers-off-fold, add-sheet pass.
This commit is contained in:
19 files changed
+506
-25
No files matched your search
@@ -80,7 +80,17 @@ var ErrPassphrase = errors.New("wrong passphrase, or the file was changed")
|
||||
// variable or header that looks like one (their names stay).
|
||||
func Collect(keys bool, app string) (Bundle, error) {
|
||||
b := Bundle{Version: 1, Created: time.Now().UTC(), App: app, Keys: keys}
|
||||
for _, p := range provider.Stored() {
|
||||
// a providers.json that can't be read stops the backup: carried as no
|
||||
// providers, it would take them all away where it is put back, or where
|
||||
// sync mirrors it
|
||||
stored, err := provider.Stored()
|
||||
if err != nil {
|
||||
return b, err
|
||||
}
|
||||
if b.Groups, err = provider.StoredGroups(); err != nil {
|
||||
return b, err
|
||||
}
|
||||
for _, p := range stored {
|
||||
if !keys {
|
||||
p = withoutKeys(p)
|
||||
}
|
||||
@@ -96,7 +106,6 @@ func Collect(keys bool, app string) (Bundle, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
b.Groups = provider.StoredGroups()
|
||||
searches := []provider.SearchAPI{}
|
||||
for _, a := range provider.StoredSearchAPIs() {
|
||||
if !keys {
|
||||
@@ -266,7 +275,11 @@ func Restore(b Bundle, parts Parts) (Result, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, p := range provider.Stored() {
|
||||
stored, err := provider.Stored()
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
for _, p := range stored {
|
||||
if !p.Ready() && slices.ContainsFunc(b.Providers, func(q provider.Provider) bool { return q.ID == p.ID }) {
|
||||
r.NeedKey = append(r.NeedKey, p.Name)
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ func TestSyncBalanceToken(t *testing.T) {
|
||||
if serverKeys {
|
||||
localKey, localToken = "sk-a", "balance-a"
|
||||
}
|
||||
ps := provider.Stored()
|
||||
ps, _ := provider.Stored()
|
||||
if len(ps) != 1 || ps[0].Key != localKey || ps[0].BalanceToken != localToken {
|
||||
t.Fatalf("b after download: %+v", ps)
|
||||
}
|
||||
@@ -130,12 +130,12 @@ func TestSyncBalanceToken(t *testing.T) {
|
||||
if got := remote(); got.Keys != serverKeys || len(got.Providers) != 1 || got.Providers[0].Name != "Renamed" || got.Providers[0].Key != wantKey || got.Providers[0].BalanceToken != wantToken {
|
||||
t.Fatalf("after b's keyless upload: %+v", got)
|
||||
}
|
||||
if ps := provider.Stored(); len(ps) != 1 || ps[0].BalanceToken != "balance-b-new" {
|
||||
if ps, _ := provider.Stored(); len(ps) != 1 || ps[0].BalanceToken != "balance-b-new" {
|
||||
t.Fatalf("upload changed b's token: %+v", ps)
|
||||
}
|
||||
use(a)
|
||||
now()
|
||||
if ps := provider.Stored(); len(ps) != 1 || ps[0].Name != "Renamed" || ps[0].Key != "sk-a" || ps[0].BalanceToken != "balance-a" {
|
||||
if ps, _ := provider.Stored(); len(ps) != 1 || ps[0].Name != "Renamed" || ps[0].Key != "sk-a" || ps[0].BalanceToken != "balance-a" {
|
||||
t.Fatalf("a after download: %+v", ps)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -101,7 +101,8 @@ func (c computer) use(t *testing.T) {
|
||||
|
||||
func ids() []string {
|
||||
var out []string
|
||||
for _, p := range provider.Stored() {
|
||||
ps, _ := provider.Stored()
|
||||
for _, p := range ps {
|
||||
out = append(out, p.ID+"="+p.Key)
|
||||
}
|
||||
slices.Sort(out)
|
||||
@@ -252,7 +253,7 @@ func TestSync(t *testing.T) {
|
||||
now(t)
|
||||
a.use(t)
|
||||
now(t)
|
||||
if ps := provider.Stored(); len(ps) != 1 || ps[0].Name != "Kimi 2" || ps[0].Key != "k2" {
|
||||
if ps, _ := provider.Stored(); len(ps) != 1 || ps[0].Name != "Kimi 2" || ps[0].Key != "k2" {
|
||||
t.Fatalf("a after c renamed: %+v", ps)
|
||||
}
|
||||
remote, _ := backup.Open(fake.files["/dav/magpie/magpie.magpie-backup"], "correct horse")
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package davsync
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/yetone/magpie/internal/backup"
|
||||
"github.com/yetone/magpie/internal/provider"
|
||||
)
|
||||
|
||||
// A providers.json that can't be read is never pushed as no providers: a
|
||||
// sync stops on it, the server keeps the providers, and another computer
|
||||
// mirroring the server keeps them too.
|
||||
func TestSyncStopsOnUnreadableProviders(t *testing.T) {
|
||||
fake := &fakeDAV{files: map[string][]byte{}, etags: map[string]string{}, dirs: map[string]bool{"/dav": true}}
|
||||
srv := httptest.NewServer(fake)
|
||||
defer srv.Close()
|
||||
ctx := context.Background()
|
||||
cfg := Config{URL: srv.URL + "/dav/", User: "me", Password: "pw", Passphrase: "correct horse", Keys: true}
|
||||
|
||||
a, b := newComputer(t), newComputer(t)
|
||||
b.use(t)
|
||||
if err := Configure(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.use(t)
|
||||
if err := provider.Save(provider.Provider{ID: "deepseek", Name: "DeepSeek", Chat: "https://api.deepseek.com/v1", Key: "k1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Configure(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Now(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.use(t)
|
||||
if err := Now(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ids(); !slices.Equal(got, []string{"deepseek=k1"}) {
|
||||
t.Fatalf("b's providers: %v", got)
|
||||
}
|
||||
|
||||
// a's file is cut short
|
||||
a.use(t)
|
||||
whole, err := os.ReadFile(provider.Path())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
broken := whole[:len(whole)/2]
|
||||
if err := os.WriteFile(provider.Path(), broken, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
puts := fake.puts
|
||||
if err := Now(ctx); !errors.Is(err, provider.ErrUnreadable) {
|
||||
t.Fatalf("a synced a broken providers.json: %v", err)
|
||||
}
|
||||
if fake.puts != puts {
|
||||
t.Fatal("a pushed a broken providers.json")
|
||||
}
|
||||
if after, _ := os.ReadFile(provider.Path()); !bytes.Equal(after, broken) {
|
||||
t.Fatal("a's broken providers.json was written over")
|
||||
}
|
||||
if _, err := backup.Collect(true, "test"); !errors.Is(err, provider.ErrUnreadable) {
|
||||
t.Fatalf("a backup of a broken providers.json: %v", err)
|
||||
}
|
||||
remote, err := backup.Open(fake.files["/dav/magpie/magpie.magpie-backup"], "correct horse")
|
||||
if err != nil || len(remote.Providers) != 1 {
|
||||
t.Fatalf("on the server: %v %+v", err, remote.Providers)
|
||||
}
|
||||
b.use(t)
|
||||
if err := Now(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ids(); !slices.Equal(got, []string{"deepseek=k1"}) {
|
||||
t.Fatalf("b lost its providers to a's broken file: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -1572,6 +1572,9 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
|
||||
#view-providers .after-list { position: sticky; bottom: -8px; z-index: 2; margin: 0 -12px -8px; padding: 14px 16px 12px; background: linear-gradient(to bottom, transparent, var(--bg) 12px); transition: opacity .18s ease, visibility .18s; }
|
||||
#view-providers .after-list.away { opacity: 0; visibility: hidden; }
|
||||
#movable { margin: 0 0 8px; }
|
||||
#fileError { margin: 0 0 10px; }
|
||||
#fileError .signing { border: 1px solid var(--line); border-radius: 10px; }
|
||||
#fileError .signing .mark { background: var(--red); }
|
||||
.movable { display: flex; align-items: baseline; flex-wrap: wrap; gap: 0 4px; padding: 2px 14px; font-size: 11.5px; line-height: 1.6; color: var(--muted); }
|
||||
.movable .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); flex: none; margin-right: 4px; align-self: center; }
|
||||
.movable .link { font-size: 11.5px; padding: 0 2px; }
|
||||
|
||||
@@ -2225,7 +2225,8 @@ async function loadProviders() {
|
||||
providers = await api("providers");
|
||||
// a reload's provider may be gone since
|
||||
if (typeof editing === "string" && !providers.providers.some((p) => p.id === editing)) editing = null;
|
||||
if (!providers.providers.length && editing === null) adding = true;
|
||||
// a providers.json that can't be read is not a first use: no Add sheet
|
||||
if (!providers.providers.length && editing === null && !providers.fileError) adding = true;
|
||||
if (view === "gateway") {
|
||||
// the gateway page is looked at and left open: coming back to the window
|
||||
// redraws it only when something on it changed, and when only the calls
|
||||
@@ -2350,6 +2351,7 @@ function renderProviders() {
|
||||
if (open) dialog = renderEditor(p);
|
||||
}
|
||||
renderExcluded();
|
||||
renderFileError();
|
||||
renderMovable();
|
||||
dialog = renderAdd() || dialog;
|
||||
if (importing) dialog = renderImport(importing);
|
||||
@@ -2390,6 +2392,26 @@ async function switchProvider(p, on, s) {
|
||||
}
|
||||
}
|
||||
|
||||
// renderFileError: over the list, that providers.json is there but can't
|
||||
// be read, so the list is the signed-in accounts alone: magpie left the
|
||||
// file as it is, and nothing is saved over it until it is fixed or moved.
|
||||
function renderFileError() {
|
||||
const box = $("#fileError");
|
||||
if (!box) return;
|
||||
box.replaceChildren();
|
||||
box.hidden = !providers.fileError;
|
||||
if (box.hidden) return;
|
||||
const r = el("div", "signing file-error");
|
||||
r.setAttribute("role", "alert");
|
||||
r.append(el("span", "mark", "!"));
|
||||
const tt = el("span", "tt");
|
||||
tt.append(el("span", "n", t("Your providers file can't be read")),
|
||||
el("span", "s", t("magpie left it unchanged and lists no providers from it. Fix the file or move it aside, then reopen this page; until then, changes to providers are refused.")),
|
||||
el("span", "s", providers.fileError));
|
||||
r.append(tt);
|
||||
box.append(r);
|
||||
}
|
||||
|
||||
// renderMovable: one quiet line over the list naming the built-in
|
||||
// subscriptions a community plugin can run, and Review, which opens the
|
||||
// first one's editor at its Runs on. Hidden, it stays hidden until another
|
||||
|
||||
@@ -321,6 +321,8 @@ const I18N = {
|
||||
"magpie forgets its copy of {users}. {agent}'s own files and sign-in, and the account itself, are left as they are.": "magpie 会移除它保存的 {users}。{agent} 自己的文件和登录,以及账号本身,都保持原样。",
|
||||
"signed in": "已登录",
|
||||
"{p} exposes no models yet — pick some below": "{p} 尚未暴露模型 — 在下方选几个",
|
||||
"Your providers file can't be read": "无法读取供应商配置文件",
|
||||
"magpie left it unchanged and lists no providers from it. Fix the file or move it aside, then reopen this page; until then, changes to providers are refused.": "magpie 没有改动它,也不会列出其中的供应商。请修复该文件或将其移走,然后重新打开此页;在此之前,对供应商的修改都会被拒绝。",
|
||||
"+ Add provider": "+ 添加供应商",
|
||||
"Add a provider": "添加供应商",
|
||||
"Add your first provider": "添加第一个供应商",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
</main>
|
||||
|
||||
<main class="view" id="view-providers" hidden>
|
||||
<div id="fileError" hidden></div>
|
||||
<div id="movable" hidden></div>
|
||||
<div class="list" id="providers"></div>
|
||||
<div class="row-head off-head" id="offHead" hidden><button class="fold" id="foldOff" aria-expanded="false" aria-controls="offProviders"><span class="label" data-t>Turned off</span><span class="note" id="offCount"></span></button></div>
|
||||
|
||||
@@ -192,6 +192,10 @@ type providersJSON struct {
|
||||
// Moved is the agents the change moved off models it stopped serving
|
||||
// (agent.Reseat), for the page to say so.
|
||||
Moved []agent.Move `json:"moved,omitempty"`
|
||||
// FileError is why providers.json can't be read (provider.FileError):
|
||||
// the page says so over what is listed, which is then the signed-in
|
||||
// accounts alone, never "add your first provider".
|
||||
FileError string `json:"fileError,omitempty"`
|
||||
}
|
||||
|
||||
// agentModel is the model an agent is on, as magpie's catalog names it.
|
||||
@@ -398,6 +402,9 @@ func providersState() providersJSON {
|
||||
agents := agent.Detected()
|
||||
s := providersJSON{Providers: []providerJSON{}, Presets: []presetJSON{}, Excluded: []excludedJSON{}}
|
||||
s.OnPlugins = provider.OnPlugins()
|
||||
if err := provider.FileError(); err != nil {
|
||||
s.FileError = err.Error()
|
||||
}
|
||||
for _, x := range provider.Excluded() {
|
||||
e := excludedJSON{Exclusion: x, Name: x.Agent, Icon: "generic"}
|
||||
if a, err := agent.Find(x.Agent); err == nil {
|
||||
|
||||
@@ -858,10 +858,18 @@ and what was typed kept, and Remove posts `{vendor, remove: true}`. No click
|
||||
moves the page. English and Chinese, Chromium and WebKit, with `/api/plugins`
|
||||
faked.
|
||||
|
||||
`provider-file-error.test.cjs` checks a providers.json that is there but
|
||||
can't be read (#415's review): the Providers page says over the list that the
|
||||
file can't be read (无法读取供应商配置文件), was left unchanged and is to be
|
||||
fixed or moved aside, with magpie's reason; the Add sheet doesn't open as for
|
||||
a first use, a signed-in account still listed stays under it, and a file that
|
||||
reads shows none of it. English and Chinese, Chromium and WebKit, with
|
||||
`/api/plugins` faked.
|
||||
|
||||
With Node.js and Playwright available:
|
||||
|
||||
```sh
|
||||
node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/rollup-short.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs internal/gui/tests/library-loading.test.cjs internal/gui/tests/provider-typed.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs internal/gui/tests/plugin-signin.test.cjs internal/gui/tests/plugin-market.test.cjs internal/gui/tests/s3-sync.test.cjs internal/gui/tests/routing-wb-refused.test.cjs internal/gui/tests/routing-blocked.test.cjs internal/gui/tests/routing-shape.test.cjs internal/gui/tests/market-have.test.cjs internal/gui/tests/sessions-today.test.cjs internal/gui/tests/routing-side-calls.test.cjs internal/gui/tests/import-all-skills.test.cjs internal/gui/tests/routing-flood.test.cjs internal/gui/tests/routing-busy.test.cjs internal/gui/tests/own-skill.test.cjs internal/gui/tests/routing-manual.test.cjs internal/gui/tests/tray-usages.test.cjs internal/gui/tests/provider-remove.test.cjs internal/gui/tests/signin-link.test.cjs internal/gui/tests/tray-inuse.test.cjs internal/gui/tests/codex-auto-reset.test.cjs internal/gui/tests/routing-auto-reset.test.cjs internal/gui/tests/claude-usage-asked.test.cjs internal/gui/tests/claude-risk.test.cjs internal/gui/tests/library-toggle-status.test.cjs internal/gui/tests/library-all.test.cjs internal/gui/tests/routing-reroute-title.test.cjs internal/gui/tests/plain-names.test.cjs internal/gui/tests/panel-effort.test.cjs internal/gui/tests/agent-models-all-hidden.test.cjs internal/gui/tests/omp-roles.test.cjs internal/gui/tests/claude-effort.test.cjs internal/gui/tests/remote-magpie.test.cjs internal/gui/tests/qoder-cn-tile.test.cjs internal/gui/tests/provider-levels-scroll.test.cjs internal/gui/tests/signin-paste-codex.test.cjs internal/gui/tests/usage-alert.test.cjs internal/gui/tests/quota-families.test.cjs internal/gui/tests/provider-search.test.cjs internal/gui/tests/webdav-settings-refresh.test.cjs internal/gui/tests/lan-docker.test.cjs internal/gui/tests/plugin-key-hint.test.cjs internal/gui/tests/update-hide.test.cjs internal/gui/tests/tray-logos.test.cjs internal/gui/tests/routing-proxy.test.cjs internal/gui/tests/sse-body.test.cjs internal/gui/tests/remote-group-served.test.cjs internal/gui/tests/plugin-move-overflow.test.cjs internal/gui/tests/routing-time.test.cjs internal/gui/tests/unlisted-models.test.cjs internal/gui/tests/plugin-updates.test.cjs internal/gui/tests/request-archive.test.cjs internal/gui/tests/group-member-fast.test.cjs internal/gui/tests/providers-off-fold.test.cjs internal/gui/tests/account-return.test.cjs internal/gui/tests/usage-ledger-detail.test.cjs internal/gui/tests/usage-ledger-chart.test.cjs internal/gui/tests/panel-usage.test.cjs internal/gui/tests/usage-refresh.test.cjs internal/gui/tests/usage-ledger-content.test.cjs internal/gui/tests/claude-direct.test.cjs internal/gui/tests/signin-again.test.cjs internal/gui/tests/sessions-calendar-fill.test.cjs internal/gui/tests/editor-short-window.test.cjs internal/gui/tests/group-from-model-add.test.cjs internal/gui/tests/sync-other-kind.test.cjs internal/gui/tests/web-page.test.cjs internal/gui/tests/balance-parts.test.cjs internal/gui/tests/search-api.test.cjs
|
||||
node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/rollup-short.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs internal/gui/tests/library-loading.test.cjs internal/gui/tests/provider-typed.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs internal/gui/tests/plugin-signin.test.cjs internal/gui/tests/plugin-market.test.cjs internal/gui/tests/s3-sync.test.cjs internal/gui/tests/routing-wb-refused.test.cjs internal/gui/tests/routing-blocked.test.cjs internal/gui/tests/routing-shape.test.cjs internal/gui/tests/market-have.test.cjs internal/gui/tests/sessions-today.test.cjs internal/gui/tests/routing-side-calls.test.cjs internal/gui/tests/import-all-skills.test.cjs internal/gui/tests/routing-flood.test.cjs internal/gui/tests/routing-busy.test.cjs internal/gui/tests/own-skill.test.cjs internal/gui/tests/routing-manual.test.cjs internal/gui/tests/tray-usages.test.cjs internal/gui/tests/provider-remove.test.cjs internal/gui/tests/signin-link.test.cjs internal/gui/tests/tray-inuse.test.cjs internal/gui/tests/codex-auto-reset.test.cjs internal/gui/tests/routing-auto-reset.test.cjs internal/gui/tests/claude-usage-asked.test.cjs internal/gui/tests/claude-risk.test.cjs internal/gui/tests/library-toggle-status.test.cjs internal/gui/tests/library-all.test.cjs internal/gui/tests/routing-reroute-title.test.cjs internal/gui/tests/plain-names.test.cjs internal/gui/tests/panel-effort.test.cjs internal/gui/tests/agent-models-all-hidden.test.cjs internal/gui/tests/omp-roles.test.cjs internal/gui/tests/claude-effort.test.cjs internal/gui/tests/remote-magpie.test.cjs internal/gui/tests/qoder-cn-tile.test.cjs internal/gui/tests/provider-levels-scroll.test.cjs internal/gui/tests/signin-paste-codex.test.cjs internal/gui/tests/usage-alert.test.cjs internal/gui/tests/quota-families.test.cjs internal/gui/tests/provider-search.test.cjs internal/gui/tests/webdav-settings-refresh.test.cjs internal/gui/tests/lan-docker.test.cjs internal/gui/tests/plugin-key-hint.test.cjs internal/gui/tests/update-hide.test.cjs internal/gui/tests/tray-logos.test.cjs internal/gui/tests/routing-proxy.test.cjs internal/gui/tests/sse-body.test.cjs internal/gui/tests/remote-group-served.test.cjs internal/gui/tests/plugin-move-overflow.test.cjs internal/gui/tests/routing-time.test.cjs internal/gui/tests/unlisted-models.test.cjs internal/gui/tests/plugin-updates.test.cjs internal/gui/tests/request-archive.test.cjs internal/gui/tests/group-member-fast.test.cjs internal/gui/tests/providers-off-fold.test.cjs internal/gui/tests/account-return.test.cjs internal/gui/tests/usage-ledger-detail.test.cjs internal/gui/tests/usage-ledger-chart.test.cjs internal/gui/tests/panel-usage.test.cjs internal/gui/tests/usage-refresh.test.cjs internal/gui/tests/usage-ledger-content.test.cjs internal/gui/tests/claude-direct.test.cjs internal/gui/tests/signin-again.test.cjs internal/gui/tests/sessions-calendar-fill.test.cjs internal/gui/tests/editor-short-window.test.cjs internal/gui/tests/group-from-model-add.test.cjs internal/gui/tests/sync-other-kind.test.cjs internal/gui/tests/web-page.test.cjs internal/gui/tests/balance-parts.test.cjs internal/gui/tests/search-api.test.cjs internal/gui/tests/provider-file-error.test.cjs
|
||||
```
|
||||
|
||||
If Playwright is installed outside the repository, set `NODE_PATH` to the
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Run with Node's test runner and Playwright on the module path; see README.md.
|
||||
// A providers.json that is there but can't be read (#415's review) is not
|
||||
// an empty list: the Providers page says over the list that the file can't
|
||||
// be read, was left unchanged, and is to be fixed or moved aside, with the
|
||||
// reason magpie gives; the Add sheet doesn't open as for a first use, and
|
||||
// the signed-in accounts still listed stay. A file that reads shows none of
|
||||
// it. In English and Chinese, Chromium and WebKit.
|
||||
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 REASON = "/home/me/.config/magpie/providers.json can't be read (unexpected end of JSON input); magpie left it unchanged — fix it or move it aside";
|
||||
|
||||
function serve(lang, list, fileError) {
|
||||
const providers = () => ({ providers: list, presets: [], excluded: [], gateway: { running: true, window: true }, ...(fileError ? { fileError } : {}) });
|
||||
const state = { agents: [], profiles: [], settings: { lang, theme: "light" } };
|
||||
return async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const json = (data) => route.fulfill({ json: data });
|
||||
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
|
||||
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
|
||||
if (url.pathname === "/api/state") return json(state);
|
||||
if (url.pathname === "/api/providers") return json(providers());
|
||||
if (url.pathname === "/api/plugins") return json({ plugins: [] });
|
||||
if (url.pathname === "/api/groups") return json({ groups: [] });
|
||||
if (url.pathname.startsWith("/api/")) return json({});
|
||||
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: "" }); }
|
||||
};
|
||||
}
|
||||
|
||||
const account = {
|
||||
id: "codex", name: "Codex", icon: "codex-color", chat: "", responses: "https://chatgpt.com/backend-api/codex", anthropic: "", catalog: "",
|
||||
host: "chatgpt.com", models: [{ id: "gpt-5", name: "GPT-5", on: true }], agents: [], fallback: [], headers: {},
|
||||
key: { set: false }, keyList: [], balanceToken: { takes: false, set: false }, proxy: "", ready: true,
|
||||
account: { agent: "codex", agentName: "Codex", user: "me@example.com", plan: "Plus", logins: [] },
|
||||
};
|
||||
const heading = { en: "Your providers file can't be read", zh: "无法读取供应商配置文件" };
|
||||
const body = { en: "Fix the file or move it aside", zh: "请修复该文件或将其移走" };
|
||||
|
||||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||||
for (const lang of ["en", "zh"]) {
|
||||
test(`${engine} ${lang}: an unreadable providers.json is said, not shown as no providers`, async (t) => {
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
const page = await (await browser.newContext({ viewport: { width: 900, height: 560 }, reducedMotion: "reduce" })).newPage();
|
||||
t.after(async () => {
|
||||
if (process.env.ARTIFACT_DIR) {
|
||||
await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
|
||||
await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-provider-file-error.png`) });
|
||||
}
|
||||
await browser.close();
|
||||
});
|
||||
page.setDefaultTimeout(5000);
|
||||
const errors = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
|
||||
// nothing listed: the error, and no Add sheet as for a first use
|
||||
await page.route("**/*", serve(lang, [], REASON));
|
||||
await page.goto("http://magpie.test/?view=providers");
|
||||
const box = page.locator("#fileError");
|
||||
await box.locator(".signing").waitFor();
|
||||
assert(await box.isVisible(), "the error shows");
|
||||
const text = await box.textContent();
|
||||
assert(text.includes(heading[lang]), text);
|
||||
assert(text.includes(body[lang]), text);
|
||||
assert(text.includes(REASON), "the reason is said");
|
||||
assert.equal(await box.locator('[role="alert"]').count(), 1);
|
||||
await page.waitForTimeout(200);
|
||||
assert(await page.locator("#addSheet").evaluate((s) => s.hidden), "the Add sheet opened as for a first use");
|
||||
|
||||
// a signed-in account still listed stays, under the error
|
||||
await page.unroute("**/*");
|
||||
await page.route("**/*", serve(lang, [account], REASON));
|
||||
await page.reload();
|
||||
await page.locator('#providers .row.provider[data-id="codex"]').waitFor();
|
||||
assert(await box.isVisible(), "the error shows over the accounts");
|
||||
const errBottom = await box.evaluate((e) => e.getBoundingClientRect().bottom);
|
||||
const listTop = await page.locator("#providers").evaluate((e) => e.getBoundingClientRect().top);
|
||||
assert(errBottom <= listTop, "the error is over the list");
|
||||
|
||||
// a file that reads: none of it
|
||||
await page.unroute("**/*");
|
||||
await page.route("**/*", serve(lang, [account], ""));
|
||||
await page.reload();
|
||||
await page.locator('#providers .row.provider[data-id="codex"]').waitFor();
|
||||
assert(await box.evaluate((b) => b.hidden), "the error shows for a file that reads");
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,13 @@ package provider
|
||||
import "slices"
|
||||
|
||||
// Stored is the providers as providers.json keeps them: keys included,
|
||||
// signed-in accounts only as the model picks the user made for them.
|
||||
func Stored() []Provider { return load().Providers }
|
||||
// signed-in accounts only as the model picks the user made for them. A
|
||||
// file that can't be read is an error, never none: a backup or a sync
|
||||
// carrying none would take every provider away where it is put back.
|
||||
func Stored() ([]Provider, error) {
|
||||
f, err := read()
|
||||
return f.Providers, err
|
||||
}
|
||||
|
||||
// Restore puts providers from a backup in. Each replaces the one here with
|
||||
// its id; one that came without keys keeps the keys already here. It
|
||||
@@ -50,8 +55,11 @@ func Restore(ps []Provider) (added, replaced int, err error) {
|
||||
}
|
||||
|
||||
// StoredGroups is the groups as saved: the user's own, and the found ones
|
||||
// the user removed.
|
||||
func StoredGroups() []Group { return load().Groups }
|
||||
// the user removed. As with Stored, a file that can't be read is an error.
|
||||
func StoredGroups() ([]Group, error) {
|
||||
f, err := read()
|
||||
return f.Groups, err
|
||||
}
|
||||
|
||||
// RestoreGroups puts groups from a backup in, each replacing the one here
|
||||
// with its id.
|
||||
|
||||
@@ -48,7 +48,7 @@ func TestBackupBalanceToken(t *testing.T) {
|
||||
if err := op.put([]Provider{p}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps := Stored()
|
||||
ps, _ := Stored()
|
||||
if len(ps) != 1 {
|
||||
t.Fatalf("providers: %+v", ps)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/yetone/magpie/internal/catalog"
|
||||
"github.com/yetone/magpie/internal/plugin"
|
||||
"github.com/yetone/magpie/internal/settings"
|
||||
"github.com/yetone/magpie/internal/steady"
|
||||
)
|
||||
|
||||
// Protocol is a wire API magpie can speak to an upstream.
|
||||
@@ -197,28 +198,57 @@ func Path() string {
|
||||
return filepath.Join(home, ".config", "magpie", "providers.json")
|
||||
}
|
||||
|
||||
// load is the file for a read that goes on without it: start-up, the
|
||||
// gateway, the catalog. One that can't be read is taken as empty there;
|
||||
// what lists the providers to the user says so (FileError), and an edit
|
||||
// refuses (read).
|
||||
func load() file {
|
||||
f, _ := read()
|
||||
return f
|
||||
}
|
||||
|
||||
// ErrUnreadable is a providers.json that is there but can't be read or
|
||||
// decoded: never an empty catalog, to list as none, back up or write over.
|
||||
var ErrUnreadable = errors.New("providers.json can't be read")
|
||||
|
||||
type unreadableError struct {
|
||||
path string
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *unreadableError) Error() string {
|
||||
return fmt.Sprintf("%s can't be read (%v); magpie left it unchanged — fix it or move it aside", e.path, e.err)
|
||||
}
|
||||
|
||||
func (e *unreadableError) Unwrap() []error { return []error{ErrUnreadable, e.err} }
|
||||
|
||||
// Reads used for an edit must keep errors: a broken file is not an empty
|
||||
// catalog to write over. Only a missing file is a first use.
|
||||
func read() (file, error) {
|
||||
var f file
|
||||
b, err := os.ReadFile(Path())
|
||||
b, err := steady.ReadFile(Path())
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return f, nil
|
||||
}
|
||||
if err != nil {
|
||||
return f, err
|
||||
return f, &unreadableError{Path(), err}
|
||||
}
|
||||
if err := json.Unmarshal(b, &f); err != nil {
|
||||
return file{}, fmt.Errorf("%s: %w", Path(), err)
|
||||
return file{}, &unreadableError{Path(), err}
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// FileError is why providers.json can't be read, nil when it can or isn't
|
||||
// there: for what lists the providers to say so, not "none yet".
|
||||
func FileError() error {
|
||||
_, err := read()
|
||||
return err
|
||||
}
|
||||
|
||||
// store replaces providers.json whole, through a file renamed over it, so
|
||||
// that a read at that moment sees the old catalog or the new one, never a
|
||||
// file cut short.
|
||||
func store(f file) error {
|
||||
p := Path()
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
@@ -228,13 +258,10 @@ func store(f file) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(p, append(b, '\n'), 0o600); err != nil {
|
||||
if err := writePrivate(p, append(b, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
pruneIcons(f)
|
||||
if err := os.Chmod(p, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
// what agents were handed of the catalog may be out of date now
|
||||
catalog.Touched()
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeProviderFile(t *testing.T, data string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(Path()), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(Path(), []byte(data), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// What lists the providers learns that providers.json can't be read
|
||||
// (FileError), rather than taking it for none; start-up and the gateway
|
||||
// still go on with it as empty (#415's review).
|
||||
func TestFileErrorSaysUnreadable(t *testing.T) {
|
||||
providerFileHome(t)
|
||||
if err := FileError(); err != nil {
|
||||
t.Fatalf("no file is a first use: %v", err)
|
||||
}
|
||||
writeProviderFile(t, originalProviderFile)
|
||||
if err := FileError(); err != nil {
|
||||
t.Fatalf("a file that reads: %v", err)
|
||||
}
|
||||
writeProviderFile(t, originalProviderFile[:40])
|
||||
err := FileError()
|
||||
if !errors.Is(err, ErrUnreadable) {
|
||||
t.Fatalf("a file cut short: %v", err)
|
||||
}
|
||||
for _, want := range []string{Path(), "can't be read", "left it unchanged", "move it aside"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%q doesn't say %q", err, want)
|
||||
}
|
||||
}
|
||||
if ps := load().Providers; len(ps) != 0 {
|
||||
t.Fatalf("load of a broken file: %+v", ps)
|
||||
}
|
||||
_ = All()
|
||||
_ = Catalog()
|
||||
}
|
||||
|
||||
// An edit refused over a broken file says it was left unchanged and is to
|
||||
// be fixed or moved aside, not only the decoder's complaint.
|
||||
func TestWriteErrorsSayLeftUnchanged(t *testing.T) {
|
||||
p := Provider{ID: "new", Name: "New", Chat: "https://new.example.invalid/v1", Key: "synthetic-new"}
|
||||
for name, run := range map[string]func() error{
|
||||
"save": func() error { return Save(p) },
|
||||
"restore": func() error { _, _, err := Restore([]Provider{p}); return err },
|
||||
"restore-groups": func() error { return RestoreGroups([]Group{{ID: "g", Members: []string{"new/m"}}}) },
|
||||
"mirror": func() error { return Mirror([]Provider{p}, nil) },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
providerFileHome(t)
|
||||
writeProviderFile(t, "{")
|
||||
err := run()
|
||||
if !errors.Is(err, ErrUnreadable) || !strings.Contains(err.Error(), "left it unchanged") || !strings.Contains(err.Error(), "fix it or move it aside") {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Stored and StoredGroups, which backups and sync carry, never give a
|
||||
// broken file as no providers: another computer mirroring that would lose
|
||||
// all of its own.
|
||||
func TestStoredRefusesUnreadable(t *testing.T) {
|
||||
providerFileHome(t)
|
||||
if ps, err := Stored(); err != nil || len(ps) != 0 {
|
||||
t.Fatalf("no file: %v %v", ps, err)
|
||||
}
|
||||
writeProviderFile(t, originalProviderFile)
|
||||
if ps, err := Stored(); err != nil || len(ps) != 1 {
|
||||
t.Fatalf("a file that reads: %v %v", ps, err)
|
||||
}
|
||||
if gs, err := StoredGroups(); err != nil || len(gs) != 1 {
|
||||
t.Fatalf("a file that reads: %v %v", gs, err)
|
||||
}
|
||||
writeProviderFile(t, originalProviderFile[:len(originalProviderFile)-2])
|
||||
if _, err := Stored(); !errors.Is(err, ErrUnreadable) {
|
||||
t.Fatalf("Stored of a broken file: %v", err)
|
||||
}
|
||||
if _, err := StoredGroups(); !errors.Is(err, ErrUnreadable) {
|
||||
t.Fatalf("StoredGroups of a broken file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// providers.json is replaced through a file renamed over it, never cut
|
||||
// short and written again in place: a read at that moment sees one whole
|
||||
// catalog or the other. The file stays the user's alone.
|
||||
func TestStoreReplacesAtomically(t *testing.T) {
|
||||
providerFileHome(t)
|
||||
writeProviderFile(t, originalProviderFile)
|
||||
before, err := os.Stat(Path())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Save(Provider{ID: "new", Name: "New", Chat: "https://new.example.invalid/v1", Key: "synthetic-new"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := os.Stat(Path())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if os.SameFile(before, after) {
|
||||
t.Fatal("providers.json was written in place, not renamed over")
|
||||
}
|
||||
if runtime.GOOS != "windows" && after.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("providers.json is %v", after.Mode().Perm())
|
||||
}
|
||||
if ps, err := Stored(); err != nil || len(ps) != 2 {
|
||||
t.Fatalf("after the save: %v %v", ps, err)
|
||||
}
|
||||
if left, _ := filepath.Glob(filepath.Join(filepath.Dir(Path()), ".providers.json.*")); len(left) != 0 {
|
||||
t.Fatalf("temp files left: %v", left)
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ type balanceMsg map[string]string
|
||||
|
||||
func (m *model) reloadProviders() {
|
||||
m.provs = provider.All()
|
||||
m.provsErr = provider.FileError()
|
||||
m.prow = clamp(m.prow, len(m.provs))
|
||||
}
|
||||
|
||||
@@ -377,8 +378,13 @@ func (m model) viewProviders() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(m.header())
|
||||
b.WriteString("\n\n")
|
||||
if m.provsErr != nil {
|
||||
b.WriteString(pad + " " + sBad.Render("! "+m.provsErr.Error()) + "\n\n")
|
||||
}
|
||||
if len(m.provs) == 0 {
|
||||
b.WriteString(pad + " " + sMuted.Render("no providers yet · a adds one"))
|
||||
if m.provsErr == nil {
|
||||
b.WriteString(pad + " " + sMuted.Render("no providers yet · a adds one"))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
type row struct{ name, id, key, models, note string }
|
||||
|
||||
@@ -102,6 +102,7 @@ type model struct {
|
||||
ask ask
|
||||
confirm string // what a second d removes
|
||||
provs []provider.Provider
|
||||
provsErr error // providers.json can't be read: said, not "none yet"
|
||||
prow int
|
||||
bal map[string]string
|
||||
asked bool // balances were asked for
|
||||
|
||||
+14
-2
@@ -68,7 +68,13 @@ const providerUsage = `usage:
|
||||
// providers: `magpie providers`
|
||||
func providers() error {
|
||||
all := provider.All()
|
||||
// a providers.json that can't be read is not "no providers yet": what
|
||||
// is listed is then the signed-in accounts alone, and it ends in why
|
||||
bad := provider.FileError()
|
||||
if len(all) == 0 && len(provider.Excluded()) == 0 {
|
||||
if bad != nil {
|
||||
return bad
|
||||
}
|
||||
fmt.Println(muted.Render("no providers yet ·"), "magpie provider add deepseek sk-…", muted.Render("· magpie presets lists the vendors"))
|
||||
return nil
|
||||
}
|
||||
@@ -129,7 +135,7 @@ func providers() error {
|
||||
}
|
||||
fmt.Println(" ", muted.Render(name+" is signed in but not offered: "+x.Why+back))
|
||||
}
|
||||
return nil
|
||||
return bad
|
||||
}
|
||||
|
||||
// usesByProvider maps provider ids to the agents currently routed to them.
|
||||
@@ -183,6 +189,7 @@ func presets() error {
|
||||
// one agent is shown and what is kept from it
|
||||
func models(args []string) error {
|
||||
entries := provider.Catalog()
|
||||
bad := provider.FileError() // as in providers: said, not "no models yet"
|
||||
var hidden []provider.Entry
|
||||
agentID := ""
|
||||
if len(args) > 0 {
|
||||
@@ -198,6 +205,8 @@ func models(args []string) error {
|
||||
if len(entries) == 0 && agentID != "" {
|
||||
names, _ := provider.VisibleTo(agentID)
|
||||
fmt.Println(amber.Render("!"), agentID, "is shown none of them: nothing is in", strings.Join(names, ", "), muted.Render("· magpie visible "+agentID+" all shows it every model"))
|
||||
} else if len(entries) == 0 && bad != nil {
|
||||
return bad
|
||||
} else if len(entries) == 0 {
|
||||
fmt.Println(muted.Render("no models yet · add a provider first:"), "magpie provider add deepseek sk-…")
|
||||
return nil
|
||||
@@ -229,7 +238,7 @@ func models(args []string) error {
|
||||
explainHidden(agentID, hidden)
|
||||
}
|
||||
fmt.Println(faint.Render(" " + gateway.URL() + "/v1"))
|
||||
return nil
|
||||
return bad
|
||||
}
|
||||
|
||||
// providerCmd: `magpie provider <verb> …`
|
||||
@@ -827,6 +836,9 @@ func serve() error {
|
||||
fmt.Println(l)
|
||||
}
|
||||
n := len(provider.Catalog())
|
||||
if err := provider.FileError(); err != nil {
|
||||
fmt.Println(amber.Render("!"), err) // served without it, as no providers
|
||||
}
|
||||
if n == 0 {
|
||||
fmt.Println(amber.Render("!"), "no models yet ·", "magpie provider add deepseek sk-…")
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/yetone/magpie/internal/catalog"
|
||||
"github.com/yetone/magpie/internal/provider"
|
||||
)
|
||||
|
||||
// printed is what fn printed, and the error it ended with.
|
||||
func printed(t *testing.T, fn func() error) (string, error) {
|
||||
t.Helper()
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := os.Stdout
|
||||
os.Stdout = w
|
||||
var out strings.Builder
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
b, _ := io.ReadAll(r)
|
||||
out.Write(b)
|
||||
close(done)
|
||||
}()
|
||||
err = fn()
|
||||
w.Close()
|
||||
os.Stdout = old
|
||||
<-done
|
||||
r.Close()
|
||||
return out.String(), err
|
||||
}
|
||||
|
||||
// A providers.json that can't be read is not "no providers yet": magpie
|
||||
// providers and magpie models end in why, and leave the file as it is
|
||||
// (#415's review).
|
||||
func TestProvidersCmdSaysUnreadable(t *testing.T) {
|
||||
groupsHome(t)
|
||||
catalog.Changed = nil
|
||||
whole, err := os.ReadFile(provider.Path())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
broken := whole[:len(whole)/2]
|
||||
if err := os.WriteFile(provider.Path(), broken, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, fn := range map[string]func() error{
|
||||
"providers": providers,
|
||||
"models": func() error { return models(nil) },
|
||||
} {
|
||||
out, err := printed(t, fn)
|
||||
if !errors.Is(err, provider.ErrUnreadable) || !strings.Contains(err.Error(), "left it unchanged") {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
}
|
||||
if strings.Contains(out, "yet") {
|
||||
t.Errorf("%s said there are none yet: %q", name, out)
|
||||
}
|
||||
}
|
||||
if after, _ := os.ReadFile(provider.Path()); !bytes.Equal(after, broken) {
|
||||
t.Fatal("the broken providers.json was changed")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user