返回 DeepSeek-Reasonix
theme_official_test.go
根目录 / desktop / theme_official_test.go
1 package main
2
3 import (
4 "fmt"
5 "net/http"
6 "net/http/httptest"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 )
12
13 // The release gate: all eight embedded official themes must parse through the
14 // Theme Pack V1 validator with unique ids/names, valid images and budgets.
15 func TestOfficialThemesAllValid(t *testing.T) {
16 resetOfficialRegistryForTest()
17 if err := validateOfficialThemes(); err != nil {
18 t.Fatalf("official registry invalid: %v", err)
19 }
20 themes := officialThemes()
21 if len(themes) != officialExpectedCount {
22 t.Fatalf("expected %d official themes, got %d", officialExpectedCount, len(themes))
23 }
24 ids := map[string]bool{}
25 names := map[string]bool{}
26 var total int64
27 for _, ot := range themes {
28 m := ot.manifest
29 if ids[m.ID] {
30 t.Fatalf("duplicate official id %q", m.ID)
31 }
32 ids[m.ID] = true
33 if names[m.Name] {
34 t.Fatalf("duplicate official name %q", m.Name)
35 }
36 names[m.Name] = true
37 if !strings.HasPrefix(m.ID, "official-") {
38 t.Fatalf("official id %q must carry the official- prefix", m.ID)
39 }
40 if isBuiltinThemeID(m.ID) {
41 t.Fatalf("official id %q collides with a base style", m.ID)
42 }
43 if m.Background == nil || m.Background.Image != "background.webp" {
44 t.Fatalf("%s: background must be background.webp", m.ID)
45 }
46 if m.Background.PaneOpacity == nil || *m.Background.PaneOpacity != 0.5 {
47 t.Fatalf("%s: paneOpacity must be 0.5, got %v", m.ID, m.Background.PaneOpacity)
48 }
49 if m.Background.SafeArea != "left" {
50 t.Fatalf("%s: safeArea must be left, got %q", m.ID, m.Background.SafeArea)
51 }
52 if m.Recipes.Density != "comfortable" {
53 t.Fatalf("%s: density must be comfortable", m.ID)
54 }
55 if m.Author != "Reasonix Contributors" || m.License != "MIT" {
56 t.Fatalf("%s: author/license = %q/%q", m.ID, m.Author, m.License)
57 }
58 if ot.bgSize > officialMaxBackground {
59 t.Fatalf("%s: background %d bytes exceeds budget", m.ID, ot.bgSize)
60 }
61 if len(ot.bgDigest) != 16 || len(ot.previewDigest) != 16 {
62 t.Fatalf("%s: digests not computed", m.ID)
63 }
64 total += ot.bgSize
65 // Every official manifest must ship full light+dark surface coverage and
66 // zero contrast warnings.
67 for _, mode := range []string{"light", "dark"} {
68 tk := m.Tokens.Light
69 if mode == "dark" {
70 tk = m.Tokens.Dark
71 }
72 for _, key := range []string{"bg", "bgSoft", "bgElev", "panel", "sidebar", "chat", "workspace", "workspaceFiles", "border", "borderSoft", "fg", "fgDim", "fgFaint", "accent", "accentFg"} {
73 if strings.TrimSpace(tk[key]) == "" {
74 t.Fatalf("%s %s: missing token %q", m.ID, mode, key)
75 }
76 }
77 // ok/warn/err stay inherited from the base style.
78 for _, key := range []string{"ok", "warn", "err"} {
79 if _, set := tk[key]; set {
80 t.Fatalf("%s %s: %s must inherit the base style", m.ID, mode, key)
81 }
82 }
83 }
84 if warns := computeContrastWarnings(&m); len(warns) != 0 {
85 t.Fatalf("%s: contrast warnings: %v", m.ID, warns)
86 }
87 }
88 if total > officialMaxTotalBytes {
89 t.Fatalf("official backgrounds total %d bytes exceeds %d", total, officialMaxTotalBytes)
90 }
91 }
92
93 func TestOfficialImagesDimensionsAndBudgets(t *testing.T) {
94 for _, ot := range officialThemes() {
95 bg, err := officialThemesFS.ReadFile(officialThemeDirName + "/" + ot.manifest.ID + "/background.webp")
96 if err != nil {
97 t.Fatal(err)
98 }
99 if err := validateOfficialImage(bg, "background.webp", officialBackgroundWidth, officialBackgroundHeight, officialMaxBackground); err != nil {
100 t.Fatalf("%s background: %v", ot.manifest.ID, err)
101 }
102 pv, err := officialThemesFS.ReadFile(officialThemeDirName + "/" + ot.manifest.ID + "/preview.webp")
103 if err != nil {
104 t.Fatal(err)
105 }
106 if err := validateOfficialImage(pv, "preview.webp", officialPreviewWidth, officialPreviewHeight, officialMaxPreview); err != nil {
107 t.Fatalf("%s preview: %v", ot.manifest.ID, err)
108 }
109 }
110 }
111
112 // Fail-closed: a broken entry never makes it into the registry.
113 func TestOfficialFailClosedSkipsInvalid(t *testing.T) {
114 if _, err := loadOfficialTheme("does-not-exist"); err == nil {
115 t.Fatal("expected error for missing official theme")
116 }
117 if findOfficialTheme("does-not-exist") != nil {
118 t.Fatal("missing theme must not enter the registry")
119 }
120 if isOfficialThemeID("graphite") {
121 t.Fatal("base styles are not official themes")
122 }
123 }
124
125 func TestOfficialReservedIDsRefused(t *testing.T) {
126 home := t.TempDir()
127 t.Setenv("REASONIX_HOME", home)
128 app := NewApp()
129
130 officialID := officialThemes()[0].manifest.ID
131 for _, id := range []string{"graphite", "aurora", officialID} {
132 if !isReservedThemeID(id) {
133 t.Fatalf("%s must be reserved", id)
134 }
135 if err := app.DeleteThemePack(id); err == nil {
136 t.Fatalf("delete %s must fail", id)
137 }
138 if _, err := app.SaveThemePack(ThemeSaveInput{ID: id, Name: "Hijack", BaseStyle: "graphite"}); err == nil {
139 t.Fatalf("save %s must fail", id)
140 }
141 if _, err := app.CopyThemePack("graphite", id, "Hijack"); err == nil {
142 t.Fatalf("copy onto reserved id %s must fail", id)
143 }
144 if _, err := app.ExportThemePack(id, filepath.Join(t.TempDir(), "out.reasonix-theme")); err == nil {
145 t.Fatalf("export %s must fail", id)
146 }
147 if _, _, err := importThemePackZIPBytesForID(id); err == nil {
148 t.Fatalf("import over reserved id %s must fail", id)
149 }
150 }
151 // Overwrite path must refuse reserved ids too.
152 if err := publishThemeDir(officialID, t.TempDir(), true); err == nil {
153 t.Fatal("publish over official id must fail")
154 }
155 }
156
157 // importThemePackZIPBytesForID builds an in-memory package for a reserved id.
158 func importThemePackZIPBytesForID(id string) (*ThemePackManifest, string, error) {
159 raw := fmt.Sprintf(`{"schemaVersion":1,"id":%q,"name":"Hijack","baseStyle":"graphite"}`, id)
160 tmp := filepath.Join(os.TempDir(), "hijack-"+id+".reasonix-theme")
161 if err := writeThemeZip(tmp, &ThemePackManifest{SchemaVersion: 1, ID: id, Name: "Hijack", BaseStyle: "graphite"}, nil); err != nil {
162 return nil, "", err
163 }
164 defer os.Remove(tmp)
165 _ = raw
166 return importThemePackZIP(tmp)
167 }
168
169 func TestOfficialListOrderAndKinds(t *testing.T) {
170 home := t.TempDir()
171 t.Setenv("REASONIX_HOME", home)
172 app := NewApp()
173
174 // Two user themes.
175 for _, id := range []string{"user-a", "user-b"} {
176 m := &ThemePackManifest{SchemaVersion: 1, ID: id, Name: id, BaseStyle: "slate", Recipes: defaultThemePackRecipes()}
177 staging, err := writeThemeStaging(m, "", nil)
178 if err != nil {
179 t.Fatal(err)
180 }
181 defer os.RemoveAll(staging)
182 if err := publishThemeDir(id, staging, false); err != nil {
183 t.Fatal(err)
184 }
185 }
186
187 list, err := app.ListThemePacks()
188 if err != nil {
189 t.Fatal(err)
190 }
191 base, official, user := 0, 0, 0
192 seenOfficial := 0
193 for i, p := range list {
194 switch p.Kind {
195 case themeKindBase:
196 base++
197 if !p.Builtin {
198 t.Fatal("base must keep builtin=true")
199 }
200 case themeKindOfficial:
201 official++
202 seenOfficial = i
203 if !p.Builtin || !p.HasBackground || p.BackgroundURL == "" || p.PreviewURL == "" {
204 t.Fatalf("official view incomplete: %+v", p)
205 }
206 if p.NameKey == "" || p.DescriptionKey == "" {
207 t.Fatalf("official i18n keys missing: %+v", p)
208 }
209 case themeKindUser:
210 user++
211 if p.Builtin {
212 t.Fatal("user theme must not be builtin")
213 }
214 default:
215 t.Fatalf("unknown kind %q", p.Kind)
216 }
217 }
218 if base != 6 || official != officialExpectedCount || user < 2 {
219 t.Fatalf("list = %d base + %d official + %d user", base, official, user)
220 }
221 haveA, haveB := false, false
222 for _, p := range list {
223 if p.ID == "user-a" {
224 haveA = true
225 }
226 if p.ID == "user-b" {
227 haveB = true
228 }
229 }
230 if !haveA || !haveB {
231 t.Fatal("expected user-a and user-b in list")
232 }
233 // Order: base first, then official, then user.
234 if list[0].Kind != themeKindBase || list[6].Kind != themeKindOfficial || list[len(list)-1].Kind != themeKindUser {
235 t.Fatal("list order must be base, official, user")
236 }
237 _ = seenOfficial
238 }
239
240 func TestOfficialActivateRestoreReset(t *testing.T) {
241 home := t.TempDir()
242 t.Setenv("REASONIX_HOME", home)
243 app := NewApp()
244
245 id := officialThemes()[1].manifest.ID
246 if err := app.ActivateThemePack(id); err != nil {
247 t.Fatal(err)
248 }
249 active, err := app.GetActiveThemePack()
250 if err != nil {
251 t.Fatal(err)
252 }
253 if active.ActiveThemeID != id || active.Pack == nil || active.Pack.Kind != themeKindOfficial {
254 t.Fatalf("active = %+v", active)
255 }
256 if active.Pack.BackgroundURL == "" || active.Pack.PreviewURL == "" {
257 t.Fatalf("official pack must carry background + preview URLs: %+v", active.Pack)
258 }
259 // Restart recovery: a fresh App reads the same state file.
260 app2 := NewApp()
261 active2, err := app2.GetActiveThemePack()
262 if err != nil {
263 t.Fatal(err)
264 }
265 if active2.ActiveThemeID != id {
266 t.Fatalf("restart did not restore official theme: %+v", active2)
267 }
268 // Reset returns to the Graphite path.
269 if err := app2.ResetThemePack(); err != nil {
270 t.Fatal(err)
271 }
272 active3, _ := app2.GetActiveThemePack()
273 if active3.ActiveThemeID != "" || active3.Pack != nil {
274 t.Fatalf("reset failed: %+v", active3)
275 }
276 }
277
278 func TestOfficialCopyBecomesEditableUserTheme(t *testing.T) {
279 home := t.TempDir()
280 t.Setenv("REASONIX_HOME", home)
281 app := NewApp()
282
283 src := officialThemes()[2].manifest.ID
284 created, err := app.CopyThemePack(src, "my-fortune", "My Fortune")
285 if err != nil {
286 t.Fatal(err)
287 }
288 if created.Kind != themeKindUser || created.ID != "my-fortune" {
289 t.Fatalf("copy view = %+v", created)
290 }
291 // Background bytes were embedded -> private copy on disk.
292 m, err := loadUserThemeManifest("my-fortune")
293 if err != nil {
294 t.Fatal(err)
295 }
296 if m.Background == nil || m.Background.Image == "" {
297 t.Fatal("copy must keep the background")
298 }
299 img, err := resolveThemeImageAbs("my-fortune", m.Background.Image)
300 if err != nil {
301 t.Fatal(err)
302 }
303 if err := validateThemeImageFile(img); err != nil {
304 t.Fatalf("copied background invalid: %v", err)
305 }
306 // The copy is an ordinary user theme: editable and exportable.
307 if _, err := app.SaveThemePack(ThemeSaveInput{ID: "my-fortune", Name: "Renamed", BaseStyle: m.BaseStyle, Replace: true}); err != nil {
308 t.Fatalf("edit copy: %v", err)
309 }
310 if _, err := app.ExportThemePack("my-fortune", filepath.Join(t.TempDir(), "copy")); err != nil {
311 t.Fatalf("export copy: %v", err)
312 }
313 }
314
315 func TestOfficialAssetRoute(t *testing.T) {
316 home := t.TempDir()
317 t.Setenv("REASONIX_HOME", home)
318 app := NewApp()
319
320 ot := officialThemes()[0]
321 id := ot.manifest.ID
322 bgURL := officialAssetURL(id, "background.webp")
323 pvURL := officialAssetURL(id, officialPreviewName)
324 if bgURL == "" || pvURL == "" {
325 t.Fatal("official URLs empty")
326 }
327 if officialAssetURL(id, "theme.json") != "" || officialAssetURL(id, "../theme.json") != "" {
328 t.Fatal("undeclared assets must not resolve")
329 }
330
331 mw := app.themeAssetMiddleware()
332 handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
333 w.WriteHeader(http.StatusTeapot)
334 }))
335
336 // GET background
337 rec := httptest.NewRecorder()
338 handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, bgURL, nil))
339 if rec.Code != http.StatusOK {
340 t.Fatalf("GET bg status %d", rec.Code)
341 }
342 if ct := rec.Header().Get("Content-Type"); ct != "image/webp" {
343 t.Fatalf("bg content-type %q", ct)
344 }
345 if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") {
346 t.Fatalf("official cache-control %q", cc)
347 }
348 if rec.Body.Len() != int(ot.bgSize) {
349 t.Fatalf("bg bytes %d != %d", rec.Body.Len(), ot.bgSize)
350 }
351
352 // HEAD preview
353 rec = httptest.NewRecorder()
354 handler.ServeHTTP(rec, httptest.NewRequest(http.MethodHead, pvURL, nil))
355 if rec.Code != http.StatusOK {
356 t.Fatalf("HEAD preview status %d", rec.Code)
357 }
358
359 // Wrong digest
360 rec = httptest.NewRecorder()
361 handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, themeAssetURLPrefix+id+"/deadbeefdeadbeef/background.webp", nil))
362 if rec.Code != http.StatusNotFound {
363 t.Fatalf("wrong digest status %d", rec.Code)
364 }
365
366 // Undeclared filename
367 rec = httptest.NewRecorder()
368 handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, themeAssetURLPrefix+id+"/"+ot.bgDigest+"/theme.json", nil))
369 if rec.Code != http.StatusNotFound {
370 t.Fatalf("undeclared file status %d", rec.Code)
371 }
372
373 // Path traversal stays rejected
374 rec = httptest.NewRecorder()
375 handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, themeAssetURLPrefix+id+"/x/../background.webp", nil))
376 if rec.Code != http.StatusNotFound {
377 t.Fatalf("traversal status %d", rec.Code)
378 }
379
380 // Wrong method
381 rec = httptest.NewRecorder()
382 handler.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, bgURL, nil))
383 if rec.Code != http.StatusMethodNotAllowed {
384 t.Fatalf("POST status %d", rec.Code)
385 }
386 }
387
388 // v1.20+: REASONIX_SAFE_MODE no longer restricts official theme packs.
389 func TestOfficialThemesAvailableDespiteSafeModeEnv(t *testing.T) {
390 home := t.TempDir()
391 t.Setenv("REASONIX_HOME", home)
392 t.Setenv("REASONIX_SAFE_MODE", "1")
393 app := NewApp()
394
395 list, err := app.ListThemePacks()
396 if err != nil {
397 t.Fatal(err)
398 }
399 if len(list) < 6 {
400 t.Fatalf("official themes must remain available, got %d packs", len(list))
401 }
402 // Activation of an official pack must not be refused solely by Safe Mode env.
403 if err := app.ActivateThemePack(officialThemes()[0].manifest.ID); err != nil {
404 t.Fatalf("official activation: %v", err)
405 }
406 }
407
407 lines GO