返回 DeepSeek-Reasonix
theme_plugin_test.go
根目录 / desktop / theme_plugin_test.go
1 package main
2
3 import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "image"
8 "image/color"
9 "image/png"
10 "net/http"
11 "net/http/httptest"
12 "os"
13 "path/filepath"
14 "strings"
15 "testing"
16
17 "reasonix/internal/pluginpkg"
18 )
19
20 // Plugin theme contract tests (Stage 4b): discovery from enabled installed
21 // plugins, invalid files skipped with warnings, plugin:<plugin>:<theme> id
22 // activation/persistence, the fallback-preserve contract across plugin
23 // removal + restore on reinstall, read-only guards, and legacy state files.
24
25 type pluginThemeFixture struct {
26 fileName string // ZIP file name under themes/
27 manifest *ThemePackManifest // nil → garbage bytes (invalid pack)
28 withImage bool // embed a small PNG scene image
29 }
30
31 func testPluginThemeManifest(id, name string) *ThemePackManifest {
32 return &ThemePackManifest{
33 SchemaVersion: themePackSchemaVersion,
34 ID: id,
35 Name: name,
36 Author: "Fixture",
37 BaseStyle: "graphite",
38 Tokens: ThemePackTokens{
39 Dark: map[string]string{"accent": "#ff6a3d"},
40 },
41 Recipes: defaultThemePackRecipes(),
42 }
43 }
44
45 func testPNGBytes(t *testing.T) []byte {
46 t.Helper()
47 img := image.NewRGBA(image.Rect(0, 0, 8, 8))
48 for y := 0; y < 8; y++ {
49 for x := 0; x < 8; x++ {
50 img.Set(x, y, color.RGBA{R: uint8(x * 30), G: uint8(y * 30), B: 40, A: 255})
51 }
52 }
53 var buf bytes.Buffer
54 if err := png.Encode(&buf, img); err != nil {
55 t.Fatal(err)
56 }
57 return buf.Bytes()
58 }
59
60 // installPluginThemeFixture writes a Manifest v1 plugin root with
61 // contributes.themes globbing themes/*.reasonix-theme, plus the
62 // plugin-packages.json entry pointing at it.
63 func installPluginThemeFixture(t *testing.T, home, pluginName string, enabled bool, themes []pluginThemeFixture) string {
64 t.Helper()
65 root := filepath.Join(home, "plugin-src", pluginName)
66 themesDir := filepath.Join(root, "themes")
67 if err := os.MkdirAll(themesDir, 0o755); err != nil {
68 t.Fatal(err)
69 }
70 for _, fx := range themes {
71 dest := filepath.Join(themesDir, fx.fileName)
72 if fx.manifest == nil {
73 if err := os.WriteFile(dest, []byte("this is not a zip"), 0o644); err != nil {
74 t.Fatal(err)
75 }
76 continue
77 }
78 m := *fx.manifest
79 var img []byte
80 if fx.withImage {
81 img = testPNGBytes(t)
82 if m.Background == nil {
83 bg := defaultThemePackBackground()
84 m.Background = &bg
85 }
86 m.Background.Image = "background.png"
87 }
88 if err := writeThemeZip(dest, &m, img); err != nil {
89 t.Fatalf("write fixture theme %s: %v", fx.fileName, err)
90 }
91 }
92 manifestJSON := fmt.Sprintf(
93 `{"apiVersion":%q,"name":%q,"version":"1.0.0","contributes":{"themes":["themes/*.reasonix-theme"]}}`,
94 pluginpkg.ManifestAPIVersionV1, pluginName,
95 )
96 if err := os.WriteFile(filepath.Join(root, pluginpkg.NativeManifest), []byte(manifestJSON), 0o644); err != nil {
97 t.Fatal(err)
98 }
99 if err := pluginpkg.Upsert(home, pluginpkg.InstalledPlugin{
100 Name: pluginName,
101 Root: root,
102 Version: "1.0.0",
103 ManifestKind: "reasonix",
104 Enabled: enabled,
105 }); err != nil {
106 t.Fatal(err)
107 }
108 return root
109 }
110
111 func findThemePackView(list []ThemePackView, id string) *ThemePackView {
112 for i := range list {
113 if list[i].ID == id {
114 return &list[i]
115 }
116 }
117 return nil
118 }
119
120 func readThemeStateRaw(t *testing.T) string {
121 t.Helper()
122 raw, err := os.ReadFile(themeStatePath())
123 if err != nil {
124 t.Fatal(err)
125 }
126 return string(raw)
127 }
128
129 func TestParsePluginThemeID(t *testing.T) {
130 cases := []struct {
131 id string
132 ok bool
133 pluginName string
134 themeID string
135 }{
136 {"plugin:themery:neon-dusk", true, "themery", "neon-dusk"},
137 {"plugin:My_Plugin.2:x", true, "My_Plugin.2", "x"},
138 {"plugin:themery:neon-dusk:extra", false, "", ""}, // theme ids carry no colons
139 {"plugin:themery", false, "", ""},
140 {"plugin:themery:", false, "", ""},
141 {"plugin::neon-dusk", false, "", ""},
142 {"plugin:themery:Neon", false, "", ""}, // themePackIDRe still governs the inner id
143 {"neon-dusk", false, "", ""},
144 {"official-rose-dawn", false, "", ""},
145 {"", false, "", ""},
146 }
147 for _, tc := range cases {
148 pluginName, themeID, ok := parsePluginThemeID(tc.id)
149 if ok != tc.ok || pluginName != tc.pluginName || themeID != tc.themeID {
150 t.Fatalf("parsePluginThemeID(%q) = %q, %q, %v; want %q, %q, %v",
151 tc.id, pluginName, themeID, ok, tc.pluginName, tc.themeID, tc.ok)
152 }
153 }
154 // isPluginThemeID is prefix-only on purpose (fallback contract seam).
155 if !isPluginThemeID("plugin:garbage::") {
156 t.Fatal("isPluginThemeID must be prefix-only")
157 }
158 if isPluginThemeID("neon-dusk") || isPluginThemeID("") {
159 t.Fatal("isPluginThemeID false positive")
160 }
161 }
162
163 func TestPluginThemeDiscoveryAndList(t *testing.T) {
164 home := t.TempDir()
165 t.Setenv("REASONIX_HOME", home)
166 pluginRoot := installPluginThemeFixture(t, home, "themery", true, []pluginThemeFixture{
167 {fileName: "neon.reasonix-theme", manifest: testPluginThemeManifest("neon-dusk", "Neon Dusk"), withImage: true},
168 })
169 app := NewApp()
170
171 list, err := app.ListThemePacks()
172 if err != nil {
173 t.Fatal(err)
174 }
175 view := findThemePackView(list, "plugin:themery:neon-dusk")
176 if view == nil {
177 t.Fatalf("plugin theme not listed: %+v", list)
178 }
179 if view.Kind != themeKindPlugin {
180 t.Fatalf("kind = %q, want plugin", view.Kind)
181 }
182 if view.PluginName != "themery" {
183 t.Fatalf("pluginName = %q, want themery", view.PluginName)
184 }
185 if view.Builtin {
186 t.Fatal("plugin theme must not be builtin")
187 }
188 if view.Name != "Neon Dusk" || view.BaseStyle != "graphite" {
189 t.Fatalf("view = %+v", view)
190 }
191 if !view.HasBackground || view.BackgroundURL == "" {
192 t.Fatalf("plugin theme must expose its background: %+v", view)
193 }
194 if !strings.HasPrefix(view.BackgroundURL, themeAssetURLPrefix+"plugin:themery:neon-dusk/") {
195 t.Fatalf("background URL not content-addressed under the plugin id: %q", view.BackgroundURL)
196 }
197 // Plugin themes come after base/official/user.
198 if list[len(list)-1].ID != "plugin:themery:neon-dusk" {
199 t.Fatalf("plugin themes must sort last: %q", list[len(list)-1].ID)
200 }
201 // Nothing is copied into the user theme library (the library dir is shared
202 // across this test package, so assert on the plugin theme's own id).
203 if userThemeExists("neon-dusk") {
204 t.Fatal("plugin themes must never be copied into the user library")
205 }
206 // The pack stays inside the plugin root.
207 if _, err := os.Stat(filepath.Join(pluginRoot, "themes", "neon.reasonix-theme")); err != nil {
208 t.Fatal(err)
209 }
210 }
211
212 func TestPluginThemeInvalidSkippedWithWarning(t *testing.T) {
213 home := t.TempDir()
214 t.Setenv("REASONIX_HOME", home)
215 installPluginThemeFixture(t, home, "themery", true, []pluginThemeFixture{
216 {fileName: "neon.reasonix-theme", manifest: testPluginThemeManifest("neon-dusk", "Neon Dusk")},
217 {fileName: "broken.reasonix-theme", manifest: nil}, // invalid ZIP
218 })
219 app := NewApp()
220
221 list, err := app.ListThemePacks()
222 if err != nil {
223 t.Fatal(err)
224 }
225 if findThemePackView(list, "plugin:themery:broken") != nil {
226 t.Fatal("invalid plugin theme must be skipped")
227 }
228 view := findThemePackView(list, "plugin:themery:neon-dusk")
229 if view == nil {
230 t.Fatal("valid plugin theme missing")
231 }
232 if len(view.Warnings) == 0 || !strings.Contains(view.Warnings[0], "broken.reasonix-theme") {
233 t.Fatalf("skipped file must surface a warning on the plugin's views: %+v", view.Warnings)
234 }
235
236 exp, err := app.GetThemeExperience()
237 if err != nil {
238 t.Fatal(err)
239 }
240 found := false
241 for _, w := range exp.Warnings {
242 if strings.Contains(w, "broken.reasonix-theme") {
243 found = true
244 }
245 }
246 if !found {
247 t.Fatalf("experience warnings must aggregate the skipped file: %v", exp.Warnings)
248 }
249 }
250
251 func TestPluginThemeDisabledNotListed(t *testing.T) {
252 home := t.TempDir()
253 t.Setenv("REASONIX_HOME", home)
254 installPluginThemeFixture(t, home, "themery", false, []pluginThemeFixture{
255 {fileName: "neon.reasonix-theme", manifest: testPluginThemeManifest("neon-dusk", "Neon Dusk")},
256 })
257 app := NewApp()
258
259 list, err := app.ListThemePacks()
260 if err != nil {
261 t.Fatal(err)
262 }
263 if findThemePackView(list, "plugin:themery:neon-dusk") != nil {
264 t.Fatal("disabled plugin themes must not be listed")
265 }
266 if err := app.ActivateThemePack("plugin:themery:neon-dusk"); err == nil {
267 t.Fatal("activating a disabled plugin's theme must fail")
268 }
269 }
270
271 func TestPluginThemeActivatePersistFallbackRestore(t *testing.T) {
272 home := t.TempDir()
273 t.Setenv("REASONIX_HOME", home)
274 themes := []pluginThemeFixture{
275 {fileName: "neon.reasonix-theme", manifest: testPluginThemeManifest("neon-dusk", "Neon Dusk")},
276 }
277 installPluginThemeFixture(t, home, "themery", true, themes)
278 app := NewApp()
279
280 const activeID = "plugin:themery:neon-dusk"
281 if err := app.ActivateThemePack(activeID); err != nil {
282 t.Fatal(err)
283 }
284 active, err := app.GetActiveThemePack()
285 if err != nil {
286 t.Fatal(err)
287 }
288 if active.ActiveThemeID != activeID || active.Pack == nil || active.Pack.Kind != themeKindPlugin {
289 t.Fatalf("active = %+v", active)
290 }
291 if !strings.Contains(readThemeStateRaw(t), activeID) {
292 t.Fatal("full plugin theme id must persist in desktop-theme-state.json")
293 }
294
295 // Plugin disappears (uninstalled): rendering falls back to the base style,
296 // but the pointer is PRESERVED — the state file is not rewritten.
297 if _, ok, err := pluginpkg.Remove(home, "themery"); err != nil || !ok {
298 t.Fatalf("remove plugin: ok=%v err=%v", ok, err)
299 }
300 before := readThemeStateRaw(t)
301 app2 := NewApp()
302 exp, err := app2.GetThemeExperience()
303 if err != nil {
304 t.Fatal(err)
305 }
306 if exp.ActiveThemeID != "" || exp.ActivePack != nil {
307 t.Fatalf("missing plugin must fall back to base style: %+v", exp)
308 }
309 if exp.EffectiveStyle != exp.BaseStyle {
310 t.Fatalf("effective style must be the configured base style: %+v", exp)
311 }
312 if after := readThemeStateRaw(t); after != before {
313 t.Fatalf("state file must not be rewritten while the plugin is missing:\n%s\n!=\n%s", before, after)
314 }
315 active2, err := app2.GetActiveThemePack()
316 if err != nil {
317 t.Fatal(err)
318 }
319 if active2.ActiveThemeID != "" || active2.Pack != nil {
320 t.Fatalf("missing plugin must render no pack: %+v", active2)
321 }
322 if after := readThemeStateRaw(t); after != before {
323 t.Fatal("GetActiveThemePack must not clear a plugin pointer")
324 }
325
326 // Reinstalling the same plugin id restores the theme.
327 installPluginThemeFixture(t, home, "themery", true, themes)
328 app3 := NewApp()
329 active3, err := app3.GetActiveThemePack()
330 if err != nil {
331 t.Fatal(err)
332 }
333 if active3.ActiveThemeID != activeID || active3.Pack == nil || active3.Pack.Kind != themeKindPlugin {
334 t.Fatalf("reinstall must restore the plugin theme: %+v", active3)
335 }
336
337 // Disabled (not uninstalled) behaves the same: fallback + preserve.
338 if err := pluginpkg.SetEnabled(home, "themery", false); err != nil {
339 t.Fatal(err)
340 }
341 before = readThemeStateRaw(t)
342 exp, err = NewApp().GetThemeExperience()
343 if err != nil {
344 t.Fatal(err)
345 }
346 if exp.ActiveThemeID != "" || exp.ActivePack != nil {
347 t.Fatalf("disabled plugin must fall back to base style: %+v", exp)
348 }
349 if after := readThemeStateRaw(t); after != before {
350 t.Fatal("disabling the plugin must not rewrite the theme state")
351 }
352 }
353
354 func TestPluginThemeReadOnlyGuards(t *testing.T) {
355 home := t.TempDir()
356 t.Setenv("REASONIX_HOME", home)
357 pluginRoot := installPluginThemeFixture(t, home, "themery", true, []pluginThemeFixture{
358 {fileName: "neon.reasonix-theme", manifest: testPluginThemeManifest("neon-dusk", "Neon Dusk")},
359 })
360 app := NewApp()
361
362 const id = "plugin:themery:neon-dusk"
363 if _, err := app.SaveThemePack(ThemeSaveInput{ID: id, Name: "Hijack", BaseStyle: "graphite"}); err == nil {
364 t.Fatal("SaveThemePack must reject plugin theme ids")
365 }
366 if err := app.DeleteThemePack(id); err == nil {
367 t.Fatal("DeleteThemePack must reject plugin theme ids")
368 }
369 if _, err := app.ExportThemePack(id, filepath.Join(home, "out")); err == nil {
370 t.Fatal("ExportThemePack must reject plugin theme ids")
371 }
372 if _, err := app.CopyThemePack(id, "neon-copy", "Neon Copy"); err == nil {
373 t.Fatal("CopyThemePack must reject plugin theme sources")
374 }
375 // The plugin ZIP is untouched and no user library entry appeared.
376 if _, err := os.Stat(filepath.Join(pluginRoot, "themes", "neon.reasonix-theme")); err != nil {
377 t.Fatal(err)
378 }
379 if userThemeExists("neon-dusk") || userThemeExists("neon-copy") {
380 t.Fatal("read-only guards must not write into the user theme library")
381 }
382 }
383
384 func TestLegacyStateWithPluginIDPreserved(t *testing.T) {
385 home := t.TempDir()
386 t.Setenv("REASONIX_HOME", home)
387
388 // State written by a build that already knew plugin themes; no plugin is
389 // installed here. New code must read it, fall back, and preserve it.
390 if err := os.MkdirAll(filepath.Dir(themeStatePath()), 0o755); err != nil {
391 t.Fatal(err)
392 }
393 raw := []byte(`{"schemaVersion":2,"activeThemeId":"plugin:ghost:pastel"}`)
394 if err := os.WriteFile(themeStatePath(), raw, 0o644); err != nil {
395 t.Fatal(err)
396 }
397
398 st := loadThemeDesktopState()
399 if st.ActiveThemeID != "plugin:ghost:pastel" {
400 t.Fatalf("legacy plugin pointer must decode: %+v", st)
401 }
402 app := NewApp()
403 exp, err := app.GetThemeExperience()
404 if err != nil {
405 t.Fatal(err)
406 }
407 if exp.ActiveThemeID != "" || exp.ActivePack != nil {
408 t.Fatalf("unknown plugin pointer must fall back: %+v", exp)
409 }
410 after, err := os.ReadFile(themeStatePath())
411 if err != nil {
412 t.Fatal(err)
413 }
414 if !bytes.Equal(after, raw) {
415 t.Fatalf("legacy plugin pointer must be preserved byte-for-byte:\n%s\n!=\n%s", raw, after)
416 }
417 }
418
419 func TestUnknownNonPluginActiveIDStillCleared(t *testing.T) {
420 home := t.TempDir()
421 t.Setenv("REASONIX_HOME", home)
422
423 // The pre-plugin erase behavior is unchanged for official/user ids that no
424 // longer resolve: clear the pointer and save.
425 if err := os.MkdirAll(filepath.Dir(themeStatePath()), 0o755); err != nil {
426 t.Fatal(err)
427 }
428 if err := os.WriteFile(themeStatePath(), []byte(`{"schemaVersion":2,"activeThemeId":"ghost-theme"}`), 0o644); err != nil {
429 t.Fatal(err)
430 }
431 app := NewApp()
432 exp, err := app.GetThemeExperience()
433 if err != nil {
434 t.Fatal(err)
435 }
436 if exp.ActiveThemeID != "" {
437 t.Fatalf("unknown non-plugin id must clear: %+v", exp)
438 }
439 raw, err := os.ReadFile(themeStatePath())
440 if err != nil {
441 t.Fatal(err)
442 }
443 var saved ThemeDesktopState
444 if err := json.Unmarshal(raw, &saved); err != nil {
445 t.Fatal(err)
446 }
447 if saved.ActiveThemeID != "" {
448 t.Fatalf("unknown non-plugin id must be erased from disk: %s", raw)
449 }
450 }
451
452 func TestPluginThemeAssetRoute(t *testing.T) {
453 home := t.TempDir()
454 t.Setenv("REASONIX_HOME", home)
455 installPluginThemeFixture(t, home, "themery", true, []pluginThemeFixture{
456 {fileName: "neon.reasonix-theme", manifest: testPluginThemeManifest("neon-dusk", "Neon Dusk"), withImage: true},
457 })
458 app := NewApp()
459
460 list, err := app.ListThemePacks()
461 if err != nil {
462 t.Fatal(err)
463 }
464 view := findThemePackView(list, "plugin:themery:neon-dusk")
465 if view == nil || view.BackgroundURL == "" {
466 t.Fatalf("plugin theme with background missing: %+v", view)
467 }
468
469 mw := app.themeAssetMiddleware()
470 handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
471 w.WriteHeader(http.StatusTeapot)
472 }))
473
474 // GET background served straight out of the plugin ZIP.
475 rec := httptest.NewRecorder()
476 handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, view.BackgroundURL, nil))
477 if rec.Code != http.StatusOK {
478 t.Fatalf("GET bg status %d", rec.Code)
479 }
480 if ct := rec.Header().Get("Content-Type"); ct != "image/png" {
481 t.Fatalf("bg content-type %q", ct)
482 }
483 if rec.Body.Len() == 0 {
484 t.Fatal("bg body empty")
485 }
486
487 // Wrong digest → 404.
488 rec = httptest.NewRecorder()
489 handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, themeAssetURLPrefix+"plugin:themery:neon-dusk/deadbeefdeadbeef/background.png", nil))
490 if rec.Code != http.StatusNotFound {
491 t.Fatalf("wrong digest status %d", rec.Code)
492 }
493
494 // Undeclared filename → 404.
495 pt := findPluginTheme("themery", "neon-dusk")
496 if pt == nil {
497 t.Fatal("plugin theme lookup failed")
498 }
499 rec = httptest.NewRecorder()
500 handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, themeAssetURLPrefix+"plugin:themery:neon-dusk/"+pt.digests["background.png"]+"/theme.json", nil))
501 if rec.Code != http.StatusNotFound {
502 t.Fatalf("undeclared file status %d", rec.Code)
503 }
504
505 // Plugin disabled → the asset stops resolving (read-only live view).
506 if err := pluginpkg.SetEnabled(home, "themery", false); err != nil {
507 t.Fatal(err)
508 }
509 rec = httptest.NewRecorder()
510 handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, view.BackgroundURL, nil))
511 if rec.Code != http.StatusNotFound {
512 t.Fatalf("disabled plugin asset status %d", rec.Code)
513 }
514 }
515
516 func TestActivateThemePackRejectsMalformedPluginID(t *testing.T) {
517 home := t.TempDir()
518 t.Setenv("REASONIX_HOME", home)
519 app := NewApp()
520 if err := app.ActivateThemePack("plugin:themery"); err == nil {
521 t.Fatal("malformed plugin theme id must be rejected")
522 }
523 if err := app.ActivateThemePack("plugin:themery:Neon"); err == nil {
524 t.Fatal("plugin theme id must keep themePackIDRe on the inner id")
525 }
526 }
527
527 lines GO