Files
magpie/internal/gui/settings_keep_test.go
T
yetone 490d2ea362 gui: the tray panel's Allowances tab hides subscriptions and moves them, from an Arrange at its foot (H20 on Discord: "菜单栏的详情页能不能选择某些不显示,有点额度并不想看到或者可以调整顺序,目前的还不行") — the panel's tab already followed the Usage page's order (usageOrder, byUsageOrder), but it could be set only by dragging a card's logo on the window's Usage page, which nothing in the panel said, and there was no way to leave a subscription out of the panel at all. The tab's foot now has Arrange, and "{n} hidden" beside it when some are: Arrange lists a row a subscription (balances too) in the shared order, its logo the handle (a drag, or Alt+arrows; the grip by it on hover only, as on the Usage page) and a Hide/Show pill, under a note that hiding is the panel's only and the order is the Usage page's too; Done draws the cards again. What's hidden is kept in magpie's settings (settings.PanelUsageHidden, panelUsageHidden), not the browser: routing, caps, the Usage page and the menu bar's cells still have it, and a menu bar cell for a hidden subscription still opens its card (panelPeek) until the panel is put away or the tab left. POST /api/usage/arrange takes order and panelHidden and changes only the one it is sent (both were whole-order before; an order alone still works), and the Settings save keeps panelUsageHidden as it does usageOrder. Saves draw at once and are put back with the error if they fail; no click moves the page (the view's scroll is kept across the redraw); no native select, no left-border stripe. Which allowances the menu bar shows beside its icon stays Settings' pick (trayUsages). New t() strings have zh, ja and de. TestUsageArrangePanelHidden (each field set and cleared on its own, a Settings save between) passes -race -count=20, TestSettingsSaveKeepsWhatItDoesNotSend's fixture names the field. panel-arrange.test.cjs (440px: the hidden card left out and counted, Arrange keeping the reader's scroll, the rows and note, the hover-only grip, no stripe, no sideways overflow, Show/Hide posting panelHidden without a scroll and a failed save put back, Alt+arrow and a drag posting the order, Done's cards in it, a menu bar cell opening a hidden card until the panel hides, the Usage page keeping the hidden card) passes on Chromium and WebKit in en, zh, ja and de; click-scroll, gui-ja, gui-de, i18n-keys, panel-fold, panel-usage, quota-*, routing-quota-left, tray-cell-click, tray-inuse, tray-logos, tray-usages, usage-accounts(-fold), usage-arrange, usage-fold, usage-quota-head, usage-card-refresh, usage-stale-answer, usage-remote-cards, api-key-usage, claude-usage-asked and usage-checkin pass on both. go test -tags nogui ./internal/... under a temp HOME, go vet, gofmt -l, linux (nogui) and windows builds pass. Not tried in the real tray panel on a Mac. docs/reference.md and docs/subsystems/gui-shell.md say it.
2026-10-06 19:18:14 +08:00

215 lines
8.1 KiB
Go

package gui
import (
"bytes"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"regexp"
"strings"
"testing"
"github.com/yetone/magpie/internal/agentenv"
"github.com/yetone/magpie/internal/redact"
"github.com/yetone/magpie/internal/settings"
)
func TestSettingsSaveKeepsCorruptFile(t *testing.T) {
for _, tc := range []struct{ path, body string }{
{"/api/settings", `{"theme":"light"}`},
{"/api/settings/quota-left", `{"on":true}`},
} {
t.Run(tc.path, func(t *testing.T) {
h := t.TempDir()
t.Setenv("HOME", h)
t.Setenv("USERPROFILE", h)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(h, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(h, ".cache"))
t.Setenv("APPDATA", filepath.Join(h, "AppData", "Roaming"))
t.Setenv("LOCALAPPDATA", filepath.Join(h, "AppData", "Local"))
for _, v := range agentenv.Vars {
t.Setenv(v, "")
}
if err := os.MkdirAll(settings.Dir(), 0o755); err != nil {
t.Fatal(err)
}
const token = "SYNTHETIC_PRIVATE_TOKEN"
original := []byte(`{"theme":"dark","githubToken":"` + token + `"`)
if err := os.WriteFile(settings.Path(), original, 0o600); err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
Handler(nil, nil).ServeHTTP(rec, httptest.NewRequest("POST", tc.path, strings.NewReader(tc.body)))
if rec.Code != http.StatusBadRequest {
t.Errorf("save returned %d, want a settings error", rec.Code)
}
if strings.Contains(rec.Body.String(), token) {
t.Error("the error response exposed the settings contents")
}
if after, err := os.ReadFile(settings.Path()); err != nil || !bytes.Equal(after, original) {
t.Errorf("the request overwrote the corrupt settings: %v", err)
}
})
}
}
// What the models' page keeps in settings — names, levels, whether a model
// takes images — outlives a save of the Settings page, which never sends it.
func TestSettingsSaveKeepsModelChoices(t *testing.T) {
h := t.TempDir()
t.Setenv("HOME", h)
t.Setenv("USERPROFILE", h)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(h, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(h, ".cache"))
if err := settings.Save(settings.Settings{
ModelNames: map[string]string{"p/m": "Mine"},
ModelEfforts: map[string][]string{"p/m": {"low"}},
ModelImages: map[string]bool{"p/m": true},
}); err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
Handler(nil, nil).ServeHTTP(rec, httptest.NewRequest("POST", "/api/settings", strings.NewReader(`{"theme":"dark","lang":"en"}`)))
if rec.Code != http.StatusOK {
t.Fatalf("%d %s", rec.Code, rec.Body)
}
s := settings.Load()
if s.Theme != "dark" || s.ModelNames["p/m"] != "Mine" || len(s.ModelEfforts["p/m"]) != 1 || !s.ModelImages["p/m"] {
t.Fatalf("theme %q names %v efforts %v images %v", s.Theme, s.ModelNames, s.ModelEfforts, s.ModelImages)
}
}
// Everything in settings the Settings page doesn't send — the models hidden
// from an agent one by one among it — outlives a save of that page: each
// field prefsKeep leaves out is set here, and must be as it was after a
// save of the theme alone. A field added to settings is either sent by the
// page or has to be set here, and kept by the handler.
func TestSettingsSaveKeepsWhatItDoesNotSend(t *testing.T) {
h := t.TempDir()
t.Setenv("HOME", h)
t.Setenv("USERPROFILE", h)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(h, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(h, ".cache"))
js, err := os.ReadFile("assets/app.js")
if err != nil {
t.Fatal(err)
}
_, fn, _ := strings.Cut(string(js), "\nfunction prefsKeep(s) {")
fn, _, _ = strings.Cut(fn, "\n}\n")
sent := map[string]bool{}
for _, m := range regexp.MustCompile(`(\w+):`).FindAllStringSubmatch(fn, -1) {
sent[m[1]] = true
}
if !sent["theme"] || !sent["proxy"] {
t.Fatalf("prefsKeep not read from app.js: %v", sent)
}
one, two := 1.0, 2.0
was := settings.Settings{
AgentOrder: []string{"codex"},
AgentsHidden: []string{"goose"},
AgentsShown: []string{"pi"},
Visible: map[string][]string{"claude": {"anthropic"}},
HiddenModels: map[string][]string{"claude": {"p/m"}, "codex": {"p/n", "group/g"}},
OrderedModels: map[string][]string{"codex": {"group/g", "p/n"}},
FastPicks: map[string][]string{"codex": {"p/n"}},
AgentEfforts: map[string]string{"cursor-local": "high"},
PluginCheckins: map[string]bool{"fakeco": true},
ModelNames: map[string]string{"p/m": "Mine"},
ModelEfforts: map[string][]string{"p/m": {"low"}},
ModelImages: map[string]bool{"p/m": true},
ModelOutputs: map[string]int{"p/m": 131072},
ModelCompacts: map[string]int{"p/*": 500000},
CompactAt: 400000,
ModelPrices: map[string]settings.ModelPrice{"p/m": {Input: &one, Output: &two}},
ModelWires: map[string]string{"p/m": "vendor-c/m"},
ModelAPIs: map[string]string{"p/m": "anthropic"},
ModelSameAs: map[string]string{"p/m": "deepseek-v4.1-flash"},
RedactRules: []redact.Rule{{Kind: "prefix", Prefix: "oc_sk_"}},
LAN: true,
LANKey: "sk-lan",
LANKeyID: "lan-key-id",
Port: 3591,
GitHubToken: "ghp_kept",
RequestArchive: true,
RequestArchiveMaxMB: 64,
QuotaLeft: true,
UsageOrder: []string{"kimi", "codex"},
PanelUsageHidden: []string{"deepseek"},
PlainNames: true,
PlainOwnNames: true,
CodexAgentsV1: true,
CodexTitles: "off",
CodexAutoReview: "fake/m1",
ChinaMirror: true,
CodexAutoReset: []string{"me@example.com"},
CodexNoCredits: []string{"me@example.com"},
CodexWarmAtOf: map[string]string{"me@example.com": "09:00"},
TextSize: 125,
UpdateSkip: "0.1.500",
UpdateMirror: "https://mirror.example/",
Window: []int{900, 700},
WindowMaximised: true,
FullContext: true,
GatewayMode: "off",
}
if err := settings.Save(was); err != nil {
t.Fatal(err)
}
was = settings.Load()
rec := httptest.NewRecorder()
Handler(nil, nil).ServeHTTP(rec, httptest.NewRequest("POST", "/api/settings", strings.NewReader(`{"theme":"dark"}`)))
if rec.Code != http.StatusOK {
t.Fatalf("%d %s", rec.Code, rec.Body)
}
now := settings.Load()
if now.Theme != "dark" {
t.Fatalf("theme %q", now.Theme)
}
wv, nv, typ := reflect.ValueOf(was), reflect.ValueOf(now), reflect.TypeOf(was)
for i := range typ.NumField() {
name, _, _ := strings.Cut(typ.Field(i).Tag.Get("json"), ",")
if sent[name] {
continue
}
if wv.Field(i).IsZero() {
t.Errorf("%s: not sent by the Settings page; set it in this test and keep it in POST /api/settings", typ.Field(i).Name)
continue
}
if !reflect.DeepEqual(wv.Field(i).Interface(), nv.Field(i).Interface()) {
t.Errorf("%s: %v after a save of the Settings page, was %v", typ.Field(i).Name, nv.Field(i).Interface(), wv.Field(i).Interface())
}
}
}
// The Update pill hidden for one version stays hidden for it through a save
// of the Settings page, which doesn't send it, and comes back once cleared.
func TestUpdateSkip(t *testing.T) {
h := t.TempDir()
t.Setenv("HOME", h)
t.Setenv("USERPROFILE", h)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(h, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(h, ".cache"))
post := func(path, body string) {
t.Helper()
rec := httptest.NewRecorder()
Handler(nil, nil).ServeHTTP(rec, httptest.NewRequest("POST", path, strings.NewReader(body)))
if rec.Code != http.StatusOK {
t.Fatalf("%s: %d %s", path, rec.Code, rec.Body)
}
}
post("/api/settings/update-skip", `{"version":"0.1.501"}`)
if s := settings.Load(); s.UpdateSkip != "0.1.501" {
t.Fatalf("skip %q", s.UpdateSkip)
}
post("/api/settings", `{"theme":"dark","noUpdatePill":true}`)
if s := settings.Load(); s.UpdateSkip != "0.1.501" || !s.NoUpdatePill || s.Theme != "dark" {
t.Fatalf("after a save: skip %q pill off %v theme %q", s.UpdateSkip, s.NoUpdatePill, s.Theme)
}
post("/api/settings/update-skip", `{"version":""}`)
if s := settings.Load(); s.UpdateSkip != "" || !s.NoUpdatePill {
t.Fatalf("cleared: skip %q pill off %v", s.UpdateSkip, s.NoUpdatePill)
}
}