1package daemon
2
3import (
4 "sync"
5 "testing"
6
7 "github.com/stretchr/testify/require"
8
9 renderer "github.com/bnema/vev-vt"
10 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/protocol"
12 "github.com/bnema/vev/internal/usecase/keys"
13 themeui "github.com/bnema/vev/internal/usecase/theme"
14)
15
16type captureKeyHandler struct {
17 actions []keys.Action
18 forward [][]byte
19}
20
21func (h *captureKeyHandler) Forward(data []byte) {
22 h.forward = append(h.forward, append([]byte(nil), data...))
23}
24func (h *captureKeyHandler) Action(action keys.Action, _ []byte) {
25 h.actions = append(h.actions, action)
26}
27
28func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) {
29 d := newTestDaemon(t, nil, stubClock{})
30 h := &captureKeyHandler{}
31 r := keys.NewRouter(stubClock{}, h, &d.bindings)
32
33 r.Route([]byte("\x1bh"))
34 require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft}, h.actions)
35
36 d.ApplyConfig(domain.Config{
37 Theme: domain.ThemeAuto,
38 BindingEntries: []domain.ConfigEntry{
39 {Key: "focus-pane-left", Value: "alt+q"},
40 },
41 Codes: map[string]string{
42 "new-tab": "nt",
43 },
44 })
45
46 r.Route([]byte("\x1bh"))
47 require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft}, h.actions, "old key should no longer be intercepted")
48 require.Equal(t, [][]byte{{'\x1b', 'h'}}, h.forward)
49 r.Route([]byte("\x1bq"))
50 require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft, keys.ActionFocusPaneLeft}, h.actions)
51
52 cmd, ok := d.commandByEffectiveCode("NT")
53 require.True(t, ok)
54 require.Equal(t, "new-tab", cmd.Slug)
55 require.Equal(t, "NT", cmd.Code)
56}
57
58func TestApplyThemeCopiesTerminalPaletteAndHotReloadGatesIt(t *testing.T) {
59 p, release := newBlockingPTY(t)
60 defer release()
61 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
62
63 clientPalette := [16]renderer.RGB{}
64 clientPalette[1] = renderer.RGB{R: 1, G: 2, B: 3}
65 clientPalette[12] = renderer.RGB{R: 4, G: 5, B: 6}
66 d.applyTheme(sess, ac, protocol.Theme{
67 HasForeground: true,
68 Foreground: renderer.RGB{R: 7, G: 8, B: 9},
69 HasBackground: true,
70 Background: renderer.RGB{R: 10, G: 11, B: 12},
71 Palette: clientPalette,
72 PaletteKnown: 1<<1 | 1<<12,
73 })
74
75 require.Equal(t, clientPalette, ac.getClientTheme().Palette)
76 require.Equal(t, uint16(1<<1|1<<12), ac.getClientTheme().PaletteKnown)
77 require.True(t, ac.getAppliedTheme().Raw.UsePalette)
78
79 cfg := domain.Defaults()
80 cfg.ThemePalette = false
81 d.ApplyConfig(cfg)
82
83 // ApplyConfig reuses the live client report and repaints without requiring
84 // a reconnect; only palette inheritance changes.
85 reloaded := ac.getAppliedTheme().Raw
86 require.Equal(t, clientPalette, reloaded.Palette)
87 require.Equal(t, uint16(1<<1|1<<12), reloaded.PaletteKnown)
88 require.False(t, reloaded.UsePalette)
89}
90
91func TestEffectiveThemePaletteGate(t *testing.T) {
92 clientPalette := [16]renderer.RGB{}
93 clientPalette[3] = renderer.RGB{R: 1, G: 2, B: 3}
94 clientTheme := themeui.Theme{
95 Palette: clientPalette,
96 PaletteKnown: 1 << 3,
97 }
98
99 tests := []struct {
100 name string
101 config domain.Config
102 want themeui.Theme
103 usePalette bool
104 }{
105 {
106 name: "auto inherits terminal palette by default",
107 config: domain.Defaults(),
108 want: clientTheme,
109 usePalette: true,
110 },
111 {
112 name: "auto disables terminal palette",
113 config: func() domain.Config {
114 cfg := domain.Defaults()
115 cfg.ThemePalette = false
116 return cfg
117 }(),
118 want: clientTheme,
119 },
120 {
121 name: "forced dark uses palette free builtin",
122 config: domain.Config{
123 Theme: domain.ThemeDark,
124 ThemePalette: true,
125 },
126 want: themeui.BuiltinDark,
127 },
128 {
129 name: "forced dark ignores disabled palette inheritance",
130 config: domain.Config{
131 Theme: domain.ThemeDark,
132 ThemePalette: false,
133 },
134 want: themeui.BuiltinDark,
135 },
136 {
137 name: "forced light uses palette free builtin",
138 config: domain.Config{
139 Theme: domain.ThemeLight,
140 ThemePalette: true,
141 },
142 want: themeui.BuiltinLight,
143 },
144 {
145 name: "forced light ignores disabled palette inheritance",
146 config: domain.Config{
147 Theme: domain.ThemeLight,
148 ThemePalette: false,
149 },
150 want: themeui.BuiltinLight,
151 },
152 }
153
154 for _, tt := range tests {
155 t.Run(tt.name, func(t *testing.T) {
156 d := newTestDaemon(t, nil, stubClock{})
157 d.ApplyConfig(tt.config)
158
159 got := d.effectiveTheme(clientTheme)
160 want := tt.want
161 want.UsePalette = tt.usePalette
162 require.Equal(t, want, got)
163 })
164 }
165}
166
167// updateRuntimeConfig publishes a modified copy of the current runtime config
168// snapshot, the way ApplyConfig does, without touching unrelated sections.
169func updateRuntimeConfig(d *Daemon, mutate func(*runtimeConfig)) {
170 next := *d.currentRuntimeConfig()
171 mutate(&next)
172 d.config.Store(&next)
173}
174
175func storeThemeForTest(d *Daemon, cfg domain.Config) {
176 updateRuntimeConfig(d, func(rc *runtimeConfig) {
177 rc.theme = themeConfigSnapshot{mode: cfg.Theme, paletteOff: !cfg.ThemePalette, accent: cfg.ThemeAccent}
178 })
179}
180
181func TestThemeConfigSnapshotIsAtomic(t *testing.T) {
182 d := newTestDaemon(t, nil, stubClock{})
183
184 // A nil atomic pointer is the zero/default snapshot: auto theme with
185 // terminal palette inheritance enabled.
186 require.Equal(t, themeConfigSnapshot{}, d.currentThemeConfig())
187
188 for _, tt := range []struct {
189 name string
190 mode domain.ThemeMode
191 gate bool
192 }{
193 {name: "auto palette enabled", mode: domain.ThemeAuto, gate: true},
194 {name: "auto palette disabled", mode: domain.ThemeAuto, gate: false},
195 {name: "dark palette enabled", mode: domain.ThemeDark, gate: true},
196 {name: "dark palette disabled", mode: domain.ThemeDark, gate: false},
197 {name: "light palette enabled", mode: domain.ThemeLight, gate: true},
198 {name: "light palette disabled", mode: domain.ThemeLight, gate: false},
199 } {
200 t.Run(tt.name, func(t *testing.T) {
201 storeThemeForTest(d, domain.Config{Theme: tt.mode, ThemePalette: tt.gate})
202 require.Equal(t, themeConfigSnapshot{mode: tt.mode, paletteOff: !tt.gate}, d.currentThemeConfig())
203 })
204 }
205
206 // Publish complementary configurations concurrently. Every load must be a
207 // complete published snapshot, never mode from one update and gate from
208 // another. Coordination establishes concurrent readers/writers without
209 // timing sleeps.
210 first := domain.Config{Theme: domain.ThemeAuto, ThemePalette: false}
211 second := domain.Config{Theme: domain.ThemeDark, ThemePalette: true}
212 allowed := map[themeConfigSnapshot]struct{}{
213 {mode: domain.ThemeAuto, paletteOff: true}: {},
214 {mode: domain.ThemeDark, paletteOff: false}: {},
215 }
216 firstPublished := make(chan struct{})
217 firstObserved := make(chan struct{})
218 secondPublished := make(chan struct{})
219 secondObserved := make(chan struct{})
220 observed := make(chan themeConfigSnapshot, 2)
221 invalid := make(chan themeConfigSnapshot, 1)
222 var wg sync.WaitGroup
223 wg.Add(2)
224 go func() {
225 defer wg.Done()
226 storeThemeForTest(d, first)
227 close(firstPublished)
228 <-firstObserved
229 storeThemeForTest(d, second)
230 close(secondPublished)
231 <-secondObserved
232 for range 10_000 {
233 storeThemeForTest(d, second)
234 storeThemeForTest(d, first)
235 }
236 }()
237 go func() {
238 defer wg.Done()
239 <-firstPublished
240 observed <- d.currentThemeConfig()
241 close(firstObserved)
242 <-secondPublished
243 observed <- d.currentThemeConfig()
244 close(secondObserved)
245 for range 20_000 {
246 snapshot := d.currentThemeConfig()
247 if _, ok := allowed[snapshot]; !ok {
248 select {
249 case invalid <- snapshot:
250 default:
251 }
252 return
253 }
254 }
255 }()
256 wg.Wait()
257 require.Equal(t, themeConfigSnapshot{mode: domain.ThemeAuto, paletteOff: true}, <-observed)
258 require.Equal(t, themeConfigSnapshot{mode: domain.ThemeDark, paletteOff: false}, <-observed)
259 select {
260 case snapshot := <-invalid:
261 t.Fatalf("observed mixed theme configuration: %#v", snapshot)
262 default:
263 }
264}
265
266func TestApplyConfigPublishesCopyConfig(t *testing.T) {
267 tests := []struct {
268 name string
269 apply func(*Daemon)
270 want domain.CopyConfig
271 }{
272 {
273 name: "default",
274 want: domain.CopyConfig{WordSeparators: domain.DefaultWordSeparators},
275 },
276 {
277 name: "custom",
278 apply: func(d *Daemon) {
279 cfg := domain.Defaults()
280 cfg.Copy = domain.CopyConfig{WordSeparators: "/:"}
281 d.ApplyConfig(cfg)
282 },
283 want: domain.CopyConfig{WordSeparators: "/:"},
284 },
285 {
286 name: "reload empty",
287 apply: func(d *Daemon) {
288 configured := domain.Defaults()
289 configured.Copy = domain.CopyConfig{WordSeparators: "/:"}
290 d.ApplyConfig(configured)
291
292 reloaded := domain.Defaults()
293 reloaded.Copy = domain.CopyConfig{}
294 d.ApplyConfig(reloaded)
295 },
296 want: domain.CopyConfig{},
297 },
298 }
299
300 for _, tt := range tests {
301 t.Run(tt.name, func(t *testing.T) {
302 d := newTestDaemon(t, nil, stubClock{})
303 if tt.apply != nil {
304 tt.apply(d)
305 }
306 require.Equal(t, tt.want, d.currentCopyConfig())
307 })
308 }
309}
310
311func TestApplyConfigPublishesImmutablePaletteSnapshot(t *testing.T) {
312 d := newTestDaemon(t, nil, stubClock{})
313 require.Equal(t, domain.Defaults().Palette, d.currentPaletteConfig())
314
315 firstConfig := domain.Defaults()
316 firstConfig.Palette = domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}
317 d.ApplyConfig(firstConfig)
318 first := d.currentPaletteConfig()
319
320 secondConfig := domain.Defaults()
321 secondConfig.Palette = domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}
322 d.ApplyConfig(secondConfig)
323 second := d.currentPaletteConfig()
324
325 require.Equal(t, domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}, first)
326 require.Equal(t, domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}, second)
327}
328
329func TestApplyConfigRepaintsActivePaletteWithoutReplacingModel(t *testing.T) {
330 p, release := newBlockingPTY(t)
331 defer release()
332 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
333 d.enterPalette(sess, ac)
334 awaitFrame(t, sends, "Output")
335 d.handlePaletteInput(ac, []byte("new"))
336 awaitFrame(t, sends, "Output")
337 d.handlePaletteInput(ac, []byte{0x0e})
338 awaitFrame(t, sends, "Output")
339
340 ac.overlays.paletteMu.Lock()
341 model := ac.overlays.palette
342 query := model.Query()
343 selected, ok := model.Selected()
344 ac.overlays.paletteMu.Unlock()
345 require.True(t, ok)
346
347 cfg := domain.Defaults()
348 cfg.Palette = domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}
349 d.ApplyConfig(cfg)
350 awaitFrame(t, sends, "Output")
351
352 ac.overlays.paletteMu.Lock()
353 defer ac.overlays.paletteMu.Unlock()
354 require.Same(t, model, ac.overlays.palette)
355 require.Equal(t, query, ac.overlays.palette.Query())
356 selectedAfter, ok := ac.overlays.palette.Selected()
357 require.True(t, ok)
358 selectedCommand, ok := selected.Command()
359 require.True(t, ok)
360 selectedAfterCommand, ok := selectedAfter.Command()
361 require.True(t, ok)
362 require.Equal(t, selectedCommand.Slug, selectedAfterCommand.Slug)
363 require.Equal(t, selectedCommand.Code, selectedAfterCommand.Code)
364}
365
366func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) {
367 d := newTestDaemon(t, nil, stubClock{})
368 require.Equal(t, domain.Defaults().Floating, d.currentFloatingConfig())
369
370 firstConfig := domain.Defaults()
371 firstConfig.Floating = domain.FloatingConfig{Command: "btop", Width: 70, Height: 60}
372 d.ApplyConfig(firstConfig)
373 first := d.currentFloatingConfig()
374
375 secondConfig := domain.Defaults()
376 secondConfig.Floating = domain.FloatingConfig{Command: "lazygit", Width: 90, Height: 85}
377 d.ApplyConfig(secondConfig)
378 second := d.currentFloatingConfig()
379
380 require.Equal(t, domain.FloatingConfig{Command: "btop", Width: 70, Height: 60}, first)
381 require.Equal(t, domain.FloatingConfig{Command: "lazygit", Width: 90, Height: 85}, second)
382}
383
384func TestApplyConfigPublishesImmutableNavSnapshot(t *testing.T) {
385 d := newTestDaemon(t, nil, stubClock{})
386 require.Equal(t, domain.NavConfig{}, d.currentNavConfig())
387
388 firstConfig := domain.Defaults()
389 firstConfig.Nav = domain.NavConfig{OverflowTabs: true}
390 d.ApplyConfig(firstConfig)
391 first := d.currentNavConfig()
392
393 secondConfig := domain.Defaults()
394 secondConfig.Nav = domain.NavConfig{OverflowSessions: true}
395 d.ApplyConfig(secondConfig)
396 second := d.currentNavConfig()
397
398 require.Equal(t, domain.NavConfig{OverflowTabs: true}, first)
399 require.Equal(t, domain.NavConfig{OverflowSessions: true}, second)
400}
401
402func TestApplyConfigPublishesImmutableTabsSnapshot(t *testing.T) {
403 d := newTestDaemon(t, nil, stubClock{})
404 require.Equal(t, domain.Defaults().Tabs, d.currentTabsConfig())
405
406 firstConfig := domain.Defaults()
407 firstConfig.Tabs = domain.TabsConfig{TerminalTitle: false}
408 d.ApplyConfig(firstConfig)
409 first := d.currentTabsConfig()
410
411 secondConfig := domain.Defaults()
412 secondConfig.Tabs = domain.TabsConfig{TerminalTitle: true}
413 d.ApplyConfig(secondConfig)
414 second := d.currentTabsConfig()
415
416 require.Equal(t, domain.TabsConfig{TerminalTitle: false}, first)
417 require.Equal(t, domain.TabsConfig{TerminalTitle: true}, second)
418}
419
420func TestApplyConfigSnapshotRestoreProcesses(t *testing.T) {
421 tests := []struct {
422 name string
423 cfg domain.Config
424 check func(t *testing.T, allow map[string]struct{})
425 }{
426 {
427 name: "default allowlist",
428 check: func(t *testing.T, allow map[string]struct{}) {
429 t.Helper()
430 require.Contains(t, allow, "claude")
431 },
432 },
433 {
434 name: "explicit empty disables restore",
435 cfg: domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true}},
436 check: func(t *testing.T, allow map[string]struct{}) {
437 t.Helper()
438 require.Empty(t, allow)
439 },
440 },
441 {
442 name: "explicit values are trimmed",
443 cfg: domain.Config{Snapshot: domain.SnapshotConfig{
444 RestoreProcessesSet: true,
445 RestoreProcesses: []string{"less", "", " pi "},
446 }},
447 check: func(t *testing.T, allow map[string]struct{}) {
448 t.Helper()
449 require.Contains(t, allow, "less")
450 require.Contains(t, allow, "pi")
451 require.NotContains(t, allow, "")
452 },
453 },
454 }
455
456 for _, tt := range tests {
457 t.Run(tt.name, func(t *testing.T) {
458 d := newTestDaemon(t, nil, stubClock{})
459 if tt.cfg.Snapshot.RestoreProcessesSet || len(tt.cfg.Snapshot.RestoreProcesses) > 0 {
460 d.ApplyConfig(tt.cfg)
461 }
462 tt.check(t, d.restoreProcessAllowlistSnapshot())
463 })
464 }
465}
466
467func TestPaletteCommandsApplyOverridesAndResolveByCode(t *testing.T) {
468 d := newTestDaemon(t, nil, stubClock{})
469 d.ApplyConfig(domain.Config{
470 Theme: domain.ThemeAuto,
471 Codes: map[string]string{
472 "new-tab": "NN",
473 },
474 })
475
476 commands := d.paletteCommands()
477 require.NotEmpty(t, commands)
478 require.Equal(t, "new-tab", commands[0].Slug)
479 require.Equal(t, "NN", commands[0].Code)
480
481 cmd, ok := d.commandByEffectiveCode("nn")
482 require.True(t, ok)
483 require.Equal(t, "new-tab", cmd.Slug)
484 require.Equal(t, "NN", cmd.Code)
485}
486
487func TestPaletteCommandsUseOverrideSnapshotForRecentAndListing(t *testing.T) {
488 d := newTestDaemon(t, nil, stubClock{})
489 d.ApplyConfig(domain.Config{
490 Theme: domain.ThemeAuto,
491 Codes: map[string]string{
492 "split-right": "SRX",
493 },
494 })
495 d.paletteHistory.record("SRX")
496
497 commands := d.paletteCommands()
498 require.NotEmpty(t, commands)
499 require.Equal(t, "split-right", commands[0].Slug)
500 require.Equal(t, "SRX", commands[0].Code)
501
502 seen := 0
503 for _, cmd := range commands {
504 if cmd.Slug == "split-right" {
505 seen++
506 require.Equal(t, "SRX", cmd.Code)
507 }
508 }
509 require.Equal(t, 1, seen)
510}
511
512func TestCodeOverrideConflictRestoresDefaultWithoutDuplicates(t *testing.T) {
513 d := newTestDaemon(t, nil, stubClock{})
514 d.ApplyConfig(domain.Config{
515 Theme: domain.ThemeAuto,
516 Codes: map[string]string{
517 "new-session": "CNT",
518 "new-tab": "DET",
519 },
520 })
521
522 commands := d.paletteCommands()
523 byCode := make(map[string]string, len(commands))
524 for _, cmd := range commands {
525 if existing, ok := byCode[cmd.Code]; ok {
526 t.Fatalf("duplicate effective code %q for %q and %q", cmd.Code, existing, cmd.Slug)
527 }
528 byCode[cmd.Code] = cmd.Slug
529 }
530 require.Equal(t, "new-tab", byCode["CNT"])
531 require.Equal(t, "new-session", byCode["CNS"])
532 require.Equal(t, "detach", byCode["DET"])
533}
534
535func TestCodeOverrideVacatedDefaultIsReusableRegardlessOfSlugOrder(t *testing.T) {
536 d := newTestDaemon(t, nil, stubClock{})
537 d.ApplyConfig(domain.Config{
538 Theme: domain.ThemeAuto,
539 Codes: map[string]string{
540 "new-tab": "NT",
541 "new-session": "CNT",
542 },
543 })
544
545 commands := d.paletteCommands()
546 byCode := make(map[string]string, len(commands))
547 for _, cmd := range commands {
548 if existing, ok := byCode[cmd.Code]; ok {
549 t.Fatalf("duplicate effective code %q for %q and %q", cmd.Code, existing, cmd.Slug)
550 }
551 byCode[cmd.Code] = cmd.Slug
552 }
553 require.Equal(t, "new-tab", byCode["NT"])
554 require.Equal(t, "new-session", byCode["CNT"])
555}
556
557func TestBuildCodeOverridesOrdersIndependentConflictWarnings(t *testing.T) {
558 d := newTestDaemon(t, nil, stubClock{})
559
560 _, warnings := d.buildCodeOverrides(map[string]string{
561 "new-session": "ZZ",
562 "new-tab": "ZZ",
563 "split-left": "YY",
564 "split-right": "YY",
565 })
566
567 require.Equal(t, []domain.Warning{
568 {Msg: `command code "YY" for "split-right" conflicts with "split-left"`},
569 {Msg: `command code "ZZ" for "new-tab" conflicts with "new-session"`},
570 }, warnings)
571}
572
573func TestApplyConfigInvalidDefaultsWithoutPanic(t *testing.T) {
574 d := newTestDaemon(t, nil, stubClock{})
575 require.NotPanics(t, func() {
576 d.ApplyConfig(domain.Config{
577 Theme: domain.ThemeAuto,
578 BindingEntries: []domain.ConfigEntry{
579 {Key: "unknown-action", Value: "alt+x"},
580 {Key: "focus-pane-left", Value: "ctrl+x"},
581 },
582 Codes: map[string]string{
583 "new-tab": "toolong",
584 "split-right": "det",
585 "missing-command": "OK",
586 },
587 })
588 })
589
590 cmd, ok := d.commandByEffectiveCode("CNT")
591 require.True(t, ok)
592 require.Equal(t, "new-tab", cmd.Slug)
593 _, ok = d.commandByEffectiveCode("TOOLONG")
594 require.False(t, ok)
595}
596
597func TestConfigReloadNoticeMessageIncludesLineOnlyWhenKnown(t *testing.T) {
598 tests := []struct {
599 name string
600 warnings []domain.Warning
601 want string
602 }{
603 {
604 name: "no source line",
605 warnings: []domain.Warning{{Msg: "unknown action"}},
606 want: "config reloaded with 1 warning(s): unknown action",
607 },
608 {
609 name: "source line",
610 warnings: []domain.Warning{{Line: 12, Msg: "unknown action"}},
611 want: "config reloaded with 1 warning(s): line 12: unknown action",
612 },
613 }
614 for _, tt := range tests {
615 t.Run(tt.name, func(t *testing.T) {
616 require.Equal(t, tt.want, configReloadNoticeMessage(tt.warnings))
617 })
618 }
619}
620
621func TestApplyConfigSurfacesOneNoticeAggregatingAllWarnings(t *testing.T) {
622 d := newTestDaemon(t, nil, stubClock{})
623 d.ApplyConfig(domain.Config{
624 Theme: domain.ThemeAuto,
625 BindingEntries: []domain.ConfigEntry{
626 {Key: "unknown-action", Value: "alt+x"},
627 },
628 Codes: map[string]string{
629 "missing-command": "OK",
630 "new-tab": "toolong",
631 },
632 })
633
634 history := d.notices.history()
635 var reloadNotices []domain.Notification
636 for _, n := range history {
637 if n.Code == domain.NoticeConfigReload {
638 reloadNotices = append(reloadNotices, n)
639 }
640 }
641 require.Len(t, reloadNotices, 1, "expected exactly one config-reload notice per ApplyConfig call")
642
643 n := reloadNotices[0]
644 require.Equal(t, domain.NoticeWarn, n.Severity)
645 require.Equal(t, domain.SessionID(""), n.SessionID, "config reload notice must be global")
646 require.Contains(t, n.Message, "config reloaded with 3 warning(s):")
647 require.NotContains(t, n.Message, "line 0:")
648 require.Contains(t, n.Details, `unknown action "unknown-action"`)
649 require.Contains(t, n.Details, `unknown command code slug "missing-command"`)
650 require.Contains(t, n.Details, `invalid command code for "new-tab"`)
651}