fix: address PR review findings
- commit
- fd1a5db925ae175e0ee4fe04e01777bd8be1afb1
- author
- brice · · 2026-06-25 21:27 UTC
- parent
- 895ec25
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
15 files · +574 -51
M .mockery.yaml +1 -0
| @@ -107,6 +107,7 @@ packages: | |||
| 107 | 107 | FaviconRepository: {} | |
| 108 | 108 | FaviconService: {} | |
| 109 | 109 | FileSystem: {} | |
| 110 | + | LocalPathResolver: {} | |
| 110 | 111 | FilterManager: {} | |
| 111 | 112 | Focusable: {} | |
| 112 | 113 | FindController: {} | |
| @@ -107,6 +107,7 @@ packages: | |||||
| 107 | FaviconRepository: {} | 107 | FaviconRepository: {} | ||
| 108 | FaviconService: {} | 108 | FaviconService: {} | ||
| 109 | FileSystem: {} | 109 | FileSystem: {} | ||
| 110 | + | LocalPathResolver: {} | |||
| 110 | FilterManager: {} | 111 | FilterManager: {} | ||
| 111 | Focusable: {} | 112 | Focusable: {} | ||
| 112 | FindController: {} | 113 | FindController: {} | ||
M docs/config/index.md +6 -0
| @@ -630,6 +630,7 @@ dumber doctor --media | |||
| 630 | 630 | - `XDG_DATA_DIRS` (schemas/resources) | |
| 631 | 631 | ||
| 632 | 632 | **Example:** | |
| 633 | + | ||
| 633 | 634 | ```toml | |
| 634 | 635 | [engine.cef] | |
| 635 | 636 | cef_dir = "/opt/cef" | |
| @@ -647,6 +648,7 @@ prefix = "/opt/webkitgtk" | |||
| 647 | 648 | When enabled, selecting text in a web page immediately copies it to the clipboard with a brief toast notification. Does not apply to text selection in input fields or textareas. | |
| 648 | 649 | ||
| 649 | 650 | **Example:** | |
| 651 | + | ||
| 650 | 652 | ```toml | |
| 651 | 653 | [clipboard] | |
| 652 | 654 | auto_copy_on_selection = true # Enabled by default | |
| @@ -672,6 +674,7 @@ Notes: | |||
| 672 | 674 | | `update.notify_on_new_settings` | bool | `true` | Show toast notification when new config settings are available | | |
| 673 | 675 | ||
| 674 | 676 | **Example:** | |
| 677 | + | ||
| 675 | 678 | ```toml | |
| 676 | 679 | [update] | |
| 677 | 680 | enable_on_startup = true # Check for updates on startup | |
| @@ -680,6 +683,7 @@ notify_on_new_settings = true # Show toast when config migration available | |||
| 680 | 683 | ``` | |
| 681 | 684 | ||
| 682 | 685 | **CLI Commands:** | |
| 686 | + | ||
| 683 | 687 | ```bash | |
| 684 | 688 | # Check config status and available migrations | |
| 685 | 689 | dumber config status | |
| @@ -714,6 +718,7 @@ Performance profiles tune engine resource behavior. CEF is the default engine; t | |||
| 714 | 718 | | `custom` | Manual control over all settings | Advanced users who want fine-grained control | | |
| 715 | 719 | ||
| 716 | 720 | **Example:** | |
| 721 | + | ||
| 717 | 722 | ```toml | |
| 718 | 723 | [engine] | |
| 719 | 724 | profile = "default" | |
| @@ -740,6 +745,7 @@ When `engine.profile = "custom"` and `engine.type = "webkit"`, you can configure | |||
| 740 | 745 | | `engine.webkit.network_process_memory_strict_threshold` | float | `0` | Network strict threshold | | |
| 741 | 746 | ||
| 742 | 747 | **Custom WebKit fallback example:** | |
| 748 | + | ||
| 743 | 749 | ```toml | |
| 744 | 750 | [engine] | |
| 745 | 751 | type = "webkit" | |
| @@ -630,6 +630,7 @@ dumber doctor --media | |||||
| 630 | - `XDG_DATA_DIRS` (schemas/resources) | 630 | - `XDG_DATA_DIRS` (schemas/resources) | ||
| 631 | 631 | ||||
| 632 | **Example:** | 632 | **Example:** | ||
| 633 | + | ||||
| 633 | ```toml | 634 | ```toml | ||
| 634 | [engine.cef] | 635 | [engine.cef] | ||
| 635 | cef_dir = "/opt/cef" | 636 | cef_dir = "/opt/cef" | ||
| @@ -647,6 +648,7 @@ prefix = "/opt/webkitgtk" | |||||
| 647 | When enabled, selecting text in a web page immediately copies it to the clipboard with a brief toast notification. Does not apply to text selection in input fields or textareas. | 648 | When enabled, selecting text in a web page immediately copies it to the clipboard with a brief toast notification. Does not apply to text selection in input fields or textareas. | ||
| 648 | 649 | ||||
| 649 | **Example:** | 650 | **Example:** | ||
| 651 | + | ||||
| 650 | ```toml | 652 | ```toml | ||
| 651 | [clipboard] | 653 | [clipboard] | ||
| 652 | auto_copy_on_selection = true # Enabled by default | 654 | auto_copy_on_selection = true # Enabled by default | ||
| @@ -672,6 +674,7 @@ Notes: | |||||
| 672 | | `update.notify_on_new_settings` | bool | `true` | Show toast notification when new config settings are available | | 674 | | `update.notify_on_new_settings` | bool | `true` | Show toast notification when new config settings are available | | ||
| 673 | 675 | ||||
| 674 | **Example:** | 676 | **Example:** | ||
| 677 | + | ||||
| 675 | ```toml | 678 | ```toml | ||
| 676 | [update] | 679 | [update] | ||
| 677 | enable_on_startup = true # Check for updates on startup | 680 | enable_on_startup = true # Check for updates on startup | ||
| @@ -680,6 +683,7 @@ notify_on_new_settings = true # Show toast when config migration available | |||||
| 680 | ``` | 683 | ``` | ||
| 681 | 684 | ||||
| 682 | **CLI Commands:** | 685 | **CLI Commands:** | ||
| 686 | + | ||||
| 683 | ```bash | 687 | ```bash | ||
| 684 | # Check config status and available migrations | 688 | # Check config status and available migrations | ||
| 685 | dumber config status | 689 | dumber config status | ||
| @@ -714,6 +718,7 @@ Performance profiles tune engine resource behavior. CEF is the default engine; t | |||||
| 714 | | `custom` | Manual control over all settings | Advanced users who want fine-grained control | | 718 | | `custom` | Manual control over all settings | Advanced users who want fine-grained control | | ||
| 715 | 719 | ||||
| 716 | **Example:** | 720 | **Example:** | ||
| 721 | + | ||||
| 717 | ```toml | 722 | ```toml | ||
| 718 | [engine] | 723 | [engine] | ||
| 719 | profile = "default" | 724 | profile = "default" | ||
| @@ -740,6 +745,7 @@ When `engine.profile = "custom"` and `engine.type = "webkit"`, you can configure | |||||
| 740 | | `engine.webkit.network_process_memory_strict_threshold` | float | `0` | Network strict threshold | | 745 | | `engine.webkit.network_process_memory_strict_threshold` | float | `0` | Network strict threshold | | ||
| 741 | 746 | ||||
| 742 | **Custom WebKit fallback example:** | 747 | **Custom WebKit fallback example:** | ||
| 748 | + | ||||
| 743 | ```toml | 749 | ```toml | ||
| 744 | [engine] | 750 | [engine] | ||
| 745 | type = "webkit" | 751 | type = "webkit" | ||
M docs/reference/troubleshooting.md +14 -5
| @@ -17,7 +17,7 @@ dumber doctor --media # GStreamer/VA-API only | |||
| 17 | 17 | **Symptoms:** Error about the default CEF runtime, GTK libraries, or WebKitGTK fallback version. | |
| 18 | 18 | ||
| 19 | 19 | **Solution:** | |
| 20 | - | 1. Run `dumber doctor --runtime`. | |
| 20 | + | 1. If the error mentions GTK or the WebKit fallback, run `dumber doctor --runtime`. | |
| 21 | 21 | 2. Match the fix to the backend in the error: | |
| 22 | 22 | - **Default CEF backend:** install or provide a CEF runtime. On Arch, use `pacman -S cef`. On Fedora or Ubuntu, install an available CEF runtime/package or set `engine.cef.cef_dir` to a downloaded runtime. | |
| 23 | 23 | - **GTK/WebKit fallback backend:** install GTK4 and WebKitGTK. On Arch, use `pacman -S webkitgtk-6.0 gtk4`. On Fedora, install GTK4 and WebKitGTK packages from your distribution. On Ubuntu, use `apt install libwebkitgtk-6.0-4 libgtk-4-1`. | |
| @@ -28,15 +28,19 @@ dumber doctor --media # GStreamer/VA-API only | |||
| 28 | 28 | ||
| 29 | 29 | **Solution:** | |
| 30 | 30 | 1. Run `dumber doctor --media` | |
| 31 | - | 2. Enable diagnostic mode for startup media warnings: | |
| 31 | + | 2. If the issue occurs with `engine.type = "webkit"`, enable GStreamer startup diagnostics: | |
| 32 | 32 | ||
| 33 | 33 | ```toml | |
| 34 | 34 | # ~/.config/dumber/config.toml | |
| 35 | 35 | [media] | |
| 36 | 36 | show_diagnostics = true | |
| 37 | + | ||
| 38 | + | [engine.webkit] | |
| 37 | 39 | gstreamer_debug_level = 3 | |
| 38 | 40 | ``` | |
| 39 | 41 | ||
| 42 | + | For the default CEF backend, skip `engine.webkit.gstreamer_debug_level`; it only affects the WebKit fallback. | |
| 43 | + | ||
| 40 | 44 | 3. Check hardware decoding: | |
| 41 | 45 | ||
| 42 | 46 | ```toml | |
| @@ -49,11 +53,13 @@ dumber doctor --media # GStreamer/VA-API only | |||
| 49 | 53 | - AMD: `libva-mesa-driver` | |
| 50 | 54 | - Intel: `intel-media-driver` | |
| 51 | 55 | - NVIDIA: `libva-nvidia-driver` | |
| 52 | - | 5. After diagnosing, disable diagnostic mode for quieter daily runs: | |
| 56 | + | 5. After diagnosing the WebKit fallback, disable diagnostic mode for quieter daily runs: | |
| 53 | 57 | ||
| 54 | 58 | ```toml | |
| 55 | 59 | [media] | |
| 56 | 60 | show_diagnostics = false | |
| 61 | + | ||
| 62 | + | [engine.webkit] | |
| 57 | 63 | gstreamer_debug_level = 0 | |
| 58 | 64 | ``` | |
| 59 | 65 | ||
| @@ -62,8 +68,9 @@ dumber doctor --media # GStreamer/VA-API only | |||
| 62 | 68 | **Symptoms:** Flicker when scrolling or rendering while using `engine.type = "webkit"` | |
| 63 | 69 | ||
| 64 | 70 | **Solution:** | |
| 71 | + | ||
| 65 | 72 | ```toml | |
| 66 | - | [rendering] | |
| 73 | + | [engine.webkit] | |
| 67 | 74 | disable_dmabuf_renderer = true | |
| 68 | 75 | ``` | |
| 69 | 76 | ||
| @@ -72,6 +79,7 @@ disable_dmabuf_renderer = true | |||
| 72 | 79 | **Symptoms:** Missing characters, wrong font rendering | |
| 73 | 80 | ||
| 74 | 81 | **Solution:** | |
| 82 | + | ||
| 75 | 83 | ```toml | |
| 76 | 84 | [appearance] | |
| 77 | 85 | sans_font = "Your Preferred Font" | |
| @@ -84,8 +92,9 @@ default_font_size = 16 | |||
| 84 | 92 | **Symptoms:** Browser uses too much RAM | |
| 85 | 93 | ||
| 86 | 94 | **Solution:** | |
| 95 | + | ||
| 87 | 96 | ```toml | |
| 88 | - | [performance] | |
| 97 | + | [engine] | |
| 89 | 98 | profile = "lite" # Reduces resource usage | |
| 90 | 99 | ``` | |
| 91 | 100 | ||
| @@ -17,7 +17,7 @@ dumber doctor --media # GStreamer/VA-API only | |||||
| 17 | **Symptoms:** Error about the default CEF runtime, GTK libraries, or WebKitGTK fallback version. | 17 | **Symptoms:** Error about the default CEF runtime, GTK libraries, or WebKitGTK fallback version. | ||
| 18 | 18 | ||||
| 19 | **Solution:** | 19 | **Solution:** | ||
| 20 | - | 1. Run `dumber doctor --runtime`. | 20 | + | 1. If the error mentions GTK or the WebKit fallback, run `dumber doctor --runtime`. |
| 21 | 2. Match the fix to the backend in the error: | 21 | 2. Match the fix to the backend in the error: | ||
| 22 | - **Default CEF backend:** install or provide a CEF runtime. On Arch, use `pacman -S cef`. On Fedora or Ubuntu, install an available CEF runtime/package or set `engine.cef.cef_dir` to a downloaded runtime. | 22 | - **Default CEF backend:** install or provide a CEF runtime. On Arch, use `pacman -S cef`. On Fedora or Ubuntu, install an available CEF runtime/package or set `engine.cef.cef_dir` to a downloaded runtime. | ||
| 23 | - **GTK/WebKit fallback backend:** install GTK4 and WebKitGTK. On Arch, use `pacman -S webkitgtk-6.0 gtk4`. On Fedora, install GTK4 and WebKitGTK packages from your distribution. On Ubuntu, use `apt install libwebkitgtk-6.0-4 libgtk-4-1`. | 23 | - **GTK/WebKit fallback backend:** install GTK4 and WebKitGTK. On Arch, use `pacman -S webkitgtk-6.0 gtk4`. On Fedora, install GTK4 and WebKitGTK packages from your distribution. On Ubuntu, use `apt install libwebkitgtk-6.0-4 libgtk-4-1`. | ||
| @@ -28,15 +28,19 @@ dumber doctor --media # GStreamer/VA-API only | |||||
| 28 | 28 | ||||
| 29 | **Solution:** | 29 | **Solution:** | ||
| 30 | 1. Run `dumber doctor --media` | 30 | 1. Run `dumber doctor --media` | ||
| 31 | - | 2. Enable diagnostic mode for startup media warnings: | 31 | + | 2. If the issue occurs with `engine.type = "webkit"`, enable GStreamer startup diagnostics: |
| 32 | 32 | ||||
| 33 | ```toml | 33 | ```toml | ||
| 34 | # ~/.config/dumber/config.toml | 34 | # ~/.config/dumber/config.toml | ||
| 35 | [media] | 35 | [media] | ||
| 36 | show_diagnostics = true | 36 | show_diagnostics = true | ||
| 37 | + | ||||
| 38 | + | [engine.webkit] | |||
| 37 | gstreamer_debug_level = 3 | 39 | gstreamer_debug_level = 3 | ||
| 38 | ``` | 40 | ``` | ||
| 39 | 41 | ||||
| 42 | + | For the default CEF backend, skip `engine.webkit.gstreamer_debug_level`; it only affects the WebKit fallback. | |||
| 43 | + | ||||
| 40 | 3. Check hardware decoding: | 44 | 3. Check hardware decoding: | ||
| 41 | 45 | ||||
| 42 | ```toml | 46 | ```toml | ||
| @@ -49,11 +53,13 @@ dumber doctor --media # GStreamer/VA-API only | |||||
| 49 | - AMD: `libva-mesa-driver` | 53 | - AMD: `libva-mesa-driver` | ||
| 50 | - Intel: `intel-media-driver` | 54 | - Intel: `intel-media-driver` | ||
| 51 | - NVIDIA: `libva-nvidia-driver` | 55 | - NVIDIA: `libva-nvidia-driver` | ||
| 52 | - | 5. After diagnosing, disable diagnostic mode for quieter daily runs: | 56 | + | 5. After diagnosing the WebKit fallback, disable diagnostic mode for quieter daily runs: |
| 53 | 57 | ||||
| 54 | ```toml | 58 | ```toml | ||
| 55 | [media] | 59 | [media] | ||
| 56 | show_diagnostics = false | 60 | show_diagnostics = false | ||
| 61 | + | ||||
| 62 | + | [engine.webkit] | |||
| 57 | gstreamer_debug_level = 0 | 63 | gstreamer_debug_level = 0 | ||
| 58 | ``` | 64 | ``` | ||
| 59 | 65 | ||||
| @@ -62,8 +68,9 @@ dumber doctor --media # GStreamer/VA-API only | |||||
| 62 | **Symptoms:** Flicker when scrolling or rendering while using `engine.type = "webkit"` | 68 | **Symptoms:** Flicker when scrolling or rendering while using `engine.type = "webkit"` | ||
| 63 | 69 | ||||
| 64 | **Solution:** | 70 | **Solution:** | ||
| 71 | + | ||||
| 65 | ```toml | 72 | ```toml | ||
| 66 | - | [rendering] | 73 | + | [engine.webkit] |
| 67 | disable_dmabuf_renderer = true | 74 | disable_dmabuf_renderer = true | ||
| 68 | ``` | 75 | ``` | ||
| 69 | 76 | ||||
| @@ -72,6 +79,7 @@ disable_dmabuf_renderer = true | |||||
| 72 | **Symptoms:** Missing characters, wrong font rendering | 79 | **Symptoms:** Missing characters, wrong font rendering | ||
| 73 | 80 | ||||
| 74 | **Solution:** | 81 | **Solution:** | ||
| 82 | + | ||||
| 75 | ```toml | 83 | ```toml | ||
| 76 | [appearance] | 84 | [appearance] | ||
| 77 | sans_font = "Your Preferred Font" | 85 | sans_font = "Your Preferred Font" | ||
| @@ -84,8 +92,9 @@ default_font_size = 16 | |||||
| 84 | **Symptoms:** Browser uses too much RAM | 92 | **Symptoms:** Browser uses too much RAM | ||
| 85 | 93 | ||||
| 86 | **Solution:** | 94 | **Solution:** | ||
| 95 | + | ||||
| 87 | ```toml | 96 | ```toml | ||
| 88 | - | [performance] | 97 | + | [engine] |
| 89 | profile = "lite" # Reduces resource usage | 98 | profile = "lite" # Reduces resource usage | ||
| 90 | ``` | 99 | ``` | ||
| 91 | 100 | ||||
A internal/application/port/mocks/local_path_resolver.go +110 -0
| @@ -0,0 +1,110 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | mock "github.com/stretchr/testify/mock" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // NewMockLocalPathResolver creates a new instance of MockLocalPathResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 14 | + | // The first argument is typically a *testing.T value. | |
| 15 | + | func NewMockLocalPathResolver(t interface { | |
| 16 | + | mock.TestingT | |
| 17 | + | Cleanup(func()) | |
| 18 | + | }) *MockLocalPathResolver { | |
| 19 | + | mock := &MockLocalPathResolver{} | |
| 20 | + | mock.Mock.Test(t) | |
| 21 | + | ||
| 22 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 23 | + | ||
| 24 | + | return mock | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // MockLocalPathResolver is an autogenerated mock type for the LocalPathResolver type | |
| 28 | + | type MockLocalPathResolver struct { | |
| 29 | + | mock.Mock | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | type MockLocalPathResolver_Expecter struct { | |
| 33 | + | mock *mock.Mock | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func (_m *MockLocalPathResolver) EXPECT() *MockLocalPathResolver_Expecter { | |
| 37 | + | return &MockLocalPathResolver_Expecter{mock: &_m.Mock} | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // ResolveExistingPath provides a mock function for the type MockLocalPathResolver | |
| 41 | + | func (_mock *MockLocalPathResolver) ResolveExistingPath(ctx context.Context, input string) (string, bool, error) { | |
| 42 | + | ret := _mock.Called(ctx, input) | |
| 43 | + | ||
| 44 | + | if len(ret) == 0 { | |
| 45 | + | panic("no return value specified for ResolveExistingPath") | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | var r0 string | |
| 49 | + | var r1 bool | |
| 50 | + | var r2 error | |
| 51 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, bool, error)); ok { | |
| 52 | + | return returnFunc(ctx, input) | |
| 53 | + | } | |
| 54 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok { | |
| 55 | + | r0 = returnFunc(ctx, input) | |
| 56 | + | } else { | |
| 57 | + | r0 = ret.Get(0).(string) | |
| 58 | + | } | |
| 59 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) bool); ok { | |
| 60 | + | r1 = returnFunc(ctx, input) | |
| 61 | + | } else { | |
| 62 | + | r1 = ret.Get(1).(bool) | |
| 63 | + | } | |
| 64 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok { | |
| 65 | + | r2 = returnFunc(ctx, input) | |
| 66 | + | } else { | |
| 67 | + | r2 = ret.Error(2) | |
| 68 | + | } | |
| 69 | + | return r0, r1, r2 | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // MockLocalPathResolver_ResolveExistingPath_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveExistingPath' | |
| 73 | + | type MockLocalPathResolver_ResolveExistingPath_Call struct { | |
| 74 | + | *mock.Call | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // ResolveExistingPath is a helper method to define mock.On call | |
| 78 | + | // - ctx context.Context | |
| 79 | + | // - input string | |
| 80 | + | func (_e *MockLocalPathResolver_Expecter) ResolveExistingPath(ctx any, input any) *MockLocalPathResolver_ResolveExistingPath_Call { | |
| 81 | + | return &MockLocalPathResolver_ResolveExistingPath_Call{Call: _e.mock.On("ResolveExistingPath", ctx, input)} | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func (_c *MockLocalPathResolver_ResolveExistingPath_Call) Run(run func(ctx context.Context, input string)) *MockLocalPathResolver_ResolveExistingPath_Call { | |
| 85 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 86 | + | var arg0 context.Context | |
| 87 | + | if args[0] != nil { | |
| 88 | + | arg0 = args[0].(context.Context) | |
| 89 | + | } | |
| 90 | + | var arg1 string | |
| 91 | + | if args[1] != nil { | |
| 92 | + | arg1 = args[1].(string) | |
| 93 | + | } | |
| 94 | + | run( | |
| 95 | + | arg0, | |
| 96 | + | arg1, | |
| 97 | + | ) | |
| 98 | + | }) | |
| 99 | + | return _c | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func (_c *MockLocalPathResolver_ResolveExistingPath_Call) Return(s string, b bool, err error) *MockLocalPathResolver_ResolveExistingPath_Call { | |
| 103 | + | _c.Call.Return(s, b, err) | |
| 104 | + | return _c | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func (_c *MockLocalPathResolver_ResolveExistingPath_Call) RunAndReturn(run func(ctx context.Context, input string) (string, bool, error)) *MockLocalPathResolver_ResolveExistingPath_Call { | |
| 108 | + | _c.Call.Return(run) | |
| 109 | + | return _c | |
| 110 | + | } | |
| @@ -0,0 +1,110 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | mock "github.com/stretchr/testify/mock" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // NewMockLocalPathResolver creates a new instance of MockLocalPathResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 14 | + | // The first argument is typically a *testing.T value. | |||
| 15 | + | func NewMockLocalPathResolver(t interface { | |||
| 16 | + | mock.TestingT | |||
| 17 | + | Cleanup(func()) | |||
| 18 | + | }) *MockLocalPathResolver { | |||
| 19 | + | mock := &MockLocalPathResolver{} | |||
| 20 | + | mock.Mock.Test(t) | |||
| 21 | + | ||||
| 22 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 23 | + | ||||
| 24 | + | return mock | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // MockLocalPathResolver is an autogenerated mock type for the LocalPathResolver type | |||
| 28 | + | type MockLocalPathResolver struct { | |||
| 29 | + | mock.Mock | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | type MockLocalPathResolver_Expecter struct { | |||
| 33 | + | mock *mock.Mock | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func (_m *MockLocalPathResolver) EXPECT() *MockLocalPathResolver_Expecter { | |||
| 37 | + | return &MockLocalPathResolver_Expecter{mock: &_m.Mock} | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // ResolveExistingPath provides a mock function for the type MockLocalPathResolver | |||
| 41 | + | func (_mock *MockLocalPathResolver) ResolveExistingPath(ctx context.Context, input string) (string, bool, error) { | |||
| 42 | + | ret := _mock.Called(ctx, input) | |||
| 43 | + | ||||
| 44 | + | if len(ret) == 0 { | |||
| 45 | + | panic("no return value specified for ResolveExistingPath") | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | var r0 string | |||
| 49 | + | var r1 bool | |||
| 50 | + | var r2 error | |||
| 51 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, bool, error)); ok { | |||
| 52 | + | return returnFunc(ctx, input) | |||
| 53 | + | } | |||
| 54 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok { | |||
| 55 | + | r0 = returnFunc(ctx, input) | |||
| 56 | + | } else { | |||
| 57 | + | r0 = ret.Get(0).(string) | |||
| 58 | + | } | |||
| 59 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) bool); ok { | |||
| 60 | + | r1 = returnFunc(ctx, input) | |||
| 61 | + | } else { | |||
| 62 | + | r1 = ret.Get(1).(bool) | |||
| 63 | + | } | |||
| 64 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok { | |||
| 65 | + | r2 = returnFunc(ctx, input) | |||
| 66 | + | } else { | |||
| 67 | + | r2 = ret.Error(2) | |||
| 68 | + | } | |||
| 69 | + | return r0, r1, r2 | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // MockLocalPathResolver_ResolveExistingPath_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveExistingPath' | |||
| 73 | + | type MockLocalPathResolver_ResolveExistingPath_Call struct { | |||
| 74 | + | *mock.Call | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // ResolveExistingPath is a helper method to define mock.On call | |||
| 78 | + | // - ctx context.Context | |||
| 79 | + | // - input string | |||
| 80 | + | func (_e *MockLocalPathResolver_Expecter) ResolveExistingPath(ctx any, input any) *MockLocalPathResolver_ResolveExistingPath_Call { | |||
| 81 | + | return &MockLocalPathResolver_ResolveExistingPath_Call{Call: _e.mock.On("ResolveExistingPath", ctx, input)} | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func (_c *MockLocalPathResolver_ResolveExistingPath_Call) Run(run func(ctx context.Context, input string)) *MockLocalPathResolver_ResolveExistingPath_Call { | |||
| 85 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 86 | + | var arg0 context.Context | |||
| 87 | + | if args[0] != nil { | |||
| 88 | + | arg0 = args[0].(context.Context) | |||
| 89 | + | } | |||
| 90 | + | var arg1 string | |||
| 91 | + | if args[1] != nil { | |||
| 92 | + | arg1 = args[1].(string) | |||
| 93 | + | } | |||
| 94 | + | run( | |||
| 95 | + | arg0, | |||
| 96 | + | arg1, | |||
| 97 | + | ) | |||
| 98 | + | }) | |||
| 99 | + | return _c | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func (_c *MockLocalPathResolver_ResolveExistingPath_Call) Return(s string, b bool, err error) *MockLocalPathResolver_ResolveExistingPath_Call { | |||
| 103 | + | _c.Call.Return(s, b, err) | |||
| 104 | + | return _c | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func (_c *MockLocalPathResolver_ResolveExistingPath_Call) RunAndReturn(run func(ctx context.Context, input string) (string, bool, error)) *MockLocalPathResolver_ResolveExistingPath_Call { | |||
| 108 | + | _c.Call.Return(run) | |||
| 109 | + | return _c | |||
| 110 | + | } | |||
M internal/application/usecase/navigation_url_normalizer.go +9 -2
| @@ -3,7 +3,6 @@ package usecase | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | stdurl "net/url" | |
| 6 | - | "strings" | |
| 7 | 6 | ||
| 8 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | 8 | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| @@ -45,8 +44,16 @@ func shouldProbeLocalPath(input string) bool { | |||
| 45 | 44 | if input == "" { | |
| 46 | 45 | return false | |
| 47 | 46 | } | |
| 48 | - | if strings.Contains(input, "://") || strings.HasPrefix(input, "about:") || domainurl.IsExternalScheme(input) { | |
| 47 | + | if hasPreservedNavigationScheme(input) { | |
| 49 | 48 | return false | |
| 50 | 49 | } | |
| 51 | 50 | return true | |
| 52 | 51 | } | |
| 52 | + | ||
| 53 | + | func hasPreservedNavigationScheme(input string) bool { | |
| 54 | + | parsed, err := stdurl.Parse(input) | |
| 55 | + | if err != nil || parsed.Scheme == "" { | |
| 56 | + | return false | |
| 57 | + | } | |
| 58 | + | return domainurl.Normalize(input) == input | |
| 59 | + | } | |
| @@ -3,7 +3,6 @@ package usecase | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | stdurl "net/url" | 5 | stdurl "net/url" | ||
| 6 | - | "strings" | |||
| 7 | 6 | ||||
| 8 | "github.com/bnema/dumber/internal/application/port" | 7 | "github.com/bnema/dumber/internal/application/port" | ||
| 9 | domainurl "github.com/bnema/dumber/internal/domain/url" | 8 | domainurl "github.com/bnema/dumber/internal/domain/url" | ||
| @@ -45,8 +44,16 @@ func shouldProbeLocalPath(input string) bool { | |||||
| 45 | if input == "" { | 44 | if input == "" { | ||
| 46 | return false | 45 | return false | ||
| 47 | } | 46 | } | ||
| 48 | - | if strings.Contains(input, "://") || strings.HasPrefix(input, "about:") || domainurl.IsExternalScheme(input) { | 47 | + | if hasPreservedNavigationScheme(input) { |
| 49 | return false | 48 | return false | ||
| 50 | } | 49 | } | ||
| 51 | return true | 50 | return true | ||
| 52 | } | 51 | } | ||
| 52 | + | ||||
| 53 | + | func hasPreservedNavigationScheme(input string) bool { | |||
| 54 | + | parsed, err := stdurl.Parse(input) | |||
| 55 | + | if err != nil || parsed.Scheme == "" { | |||
| 56 | + | return false | |||
| 57 | + | } | |||
| 58 | + | return domainurl.Normalize(input) == input | |||
| 59 | + | } | |||
M internal/application/usecase/navigation_url_normalizer_test.go +60 -0
| @@ -8,6 +8,15 @@ import ( | |||
| 8 | 8 | "testing" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | + | type spyLocalPathResolver struct { | |
| 12 | + | calls int | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | func (r *spyLocalPathResolver) ResolveExistingPath(_ context.Context, _ string) (string, bool, error) { | |
| 16 | + | r.calls++ | |
| 17 | + | return filepath.Join(string(filepath.Separator), "tmp", "should-not-be-used.html"), true, nil | |
| 18 | + | } | |
| 19 | + | ||
| 11 | 20 | func TestNavigationURLNormalizerResolvesExistingLocalPaths(t *testing.T) { | |
| 12 | 21 | ctx := context.Background() | |
| 13 | 22 | absFile := filepath.Join(string(filepath.Separator), "tmp", "page.html") | |
| @@ -39,3 +48,54 @@ func TestNavigationURLNormalizerFallsBackToDomainNormalize(t *testing.T) { | |||
| 39 | 48 | t.Fatalf("Normalize(./missing.html) = %q", got) | |
| 40 | 49 | } | |
| 41 | 50 | } | |
| 51 | + | ||
| 52 | + | func TestNavigationURLNormalizerDoesNotProbeSchemeBearingInputs(t *testing.T) { | |
| 53 | + | ctx := context.Background() | |
| 54 | + | ||
| 55 | + | tests := []struct { | |
| 56 | + | name string | |
| 57 | + | input string | |
| 58 | + | want string | |
| 59 | + | }{ | |
| 60 | + | { | |
| 61 | + | name: "https scheme", | |
| 62 | + | input: "https://example.com/page", | |
| 63 | + | want: "https://example.com/page", | |
| 64 | + | }, | |
| 65 | + | { | |
| 66 | + | name: "file scheme", | |
| 67 | + | input: "file:///tmp/page.html", | |
| 68 | + | want: "file:///tmp/page.html", | |
| 69 | + | }, | |
| 70 | + | { | |
| 71 | + | name: "about scheme", | |
| 72 | + | input: "about:blank", | |
| 73 | + | want: "about:blank", | |
| 74 | + | }, | |
| 75 | + | { | |
| 76 | + | name: "external scheme without slashes", | |
| 77 | + | input: "mailto:user@example.com", | |
| 78 | + | want: "mailto:user@example.com", | |
| 79 | + | }, | |
| 80 | + | { | |
| 81 | + | name: "data scheme", | |
| 82 | + | input: "data:text/plain,hello", | |
| 83 | + | want: "data:text/plain,hello", | |
| 84 | + | }, | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | for _, tt := range tests { | |
| 88 | + | t.Run(tt.name, func(t *testing.T) { | |
| 89 | + | resolver := &spyLocalPathResolver{} | |
| 90 | + | normalizer := NewNavigationURLNormalizer(resolver) | |
| 91 | + | ||
| 92 | + | got := normalizer.Normalize(ctx, tt.input) | |
| 93 | + | if got != tt.want { | |
| 94 | + | t.Fatalf("Normalize(%q) = %q, want %q", tt.input, got, tt.want) | |
| 95 | + | } | |
| 96 | + | if resolver.calls != 0 { | |
| 97 | + | t.Fatalf("ResolveExistingPath called %d times for %q, want 0", resolver.calls, tt.input) | |
| 98 | + | } | |
| 99 | + | }) | |
| 100 | + | } | |
| 101 | + | } | |
| @@ -8,6 +8,15 @@ import ( | |||||
| 8 | "testing" | 8 | "testing" | ||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | + | type spyLocalPathResolver struct { | |||
| 12 | + | calls int | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | func (r *spyLocalPathResolver) ResolveExistingPath(_ context.Context, _ string) (string, bool, error) { | |||
| 16 | + | r.calls++ | |||
| 17 | + | return filepath.Join(string(filepath.Separator), "tmp", "should-not-be-used.html"), true, nil | |||
| 18 | + | } | |||
| 19 | + | ||||
| 11 | func TestNavigationURLNormalizerResolvesExistingLocalPaths(t *testing.T) { | 20 | func TestNavigationURLNormalizerResolvesExistingLocalPaths(t *testing.T) { | ||
| 12 | ctx := context.Background() | 21 | ctx := context.Background() | ||
| 13 | absFile := filepath.Join(string(filepath.Separator), "tmp", "page.html") | 22 | absFile := filepath.Join(string(filepath.Separator), "tmp", "page.html") | ||
| @@ -39,3 +48,54 @@ func TestNavigationURLNormalizerFallsBackToDomainNormalize(t *testing.T) { | |||||
| 39 | t.Fatalf("Normalize(./missing.html) = %q", got) | 48 | t.Fatalf("Normalize(./missing.html) = %q", got) | ||
| 40 | } | 49 | } | ||
| 41 | } | 50 | } | ||
| 51 | + | ||||
| 52 | + | func TestNavigationURLNormalizerDoesNotProbeSchemeBearingInputs(t *testing.T) { | |||
| 53 | + | ctx := context.Background() | |||
| 54 | + | ||||
| 55 | + | tests := []struct { | |||
| 56 | + | name string | |||
| 57 | + | input string | |||
| 58 | + | want string | |||
| 59 | + | }{ | |||
| 60 | + | { | |||
| 61 | + | name: "https scheme", | |||
| 62 | + | input: "https://example.com/page", | |||
| 63 | + | want: "https://example.com/page", | |||
| 64 | + | }, | |||
| 65 | + | { | |||
| 66 | + | name: "file scheme", | |||
| 67 | + | input: "file:///tmp/page.html", | |||
| 68 | + | want: "file:///tmp/page.html", | |||
| 69 | + | }, | |||
| 70 | + | { | |||
| 71 | + | name: "about scheme", | |||
| 72 | + | input: "about:blank", | |||
| 73 | + | want: "about:blank", | |||
| 74 | + | }, | |||
| 75 | + | { | |||
| 76 | + | name: "external scheme without slashes", | |||
| 77 | + | input: "mailto:user@example.com", | |||
| 78 | + | want: "mailto:user@example.com", | |||
| 79 | + | }, | |||
| 80 | + | { | |||
| 81 | + | name: "data scheme", | |||
| 82 | + | input: "data:text/plain,hello", | |||
| 83 | + | want: "data:text/plain,hello", | |||
| 84 | + | }, | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | for _, tt := range tests { | |||
| 88 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 89 | + | resolver := &spyLocalPathResolver{} | |||
| 90 | + | normalizer := NewNavigationURLNormalizer(resolver) | |||
| 91 | + | ||||
| 92 | + | got := normalizer.Normalize(ctx, tt.input) | |||
| 93 | + | if got != tt.want { | |||
| 94 | + | t.Fatalf("Normalize(%q) = %q, want %q", tt.input, got, tt.want) | |||
| 95 | + | } | |||
| 96 | + | if resolver.calls != 0 { | |||
| 97 | + | t.Fatalf("ResolveExistingPath called %d times for %q, want 0", resolver.calls, tt.input) | |||
| 98 | + | } | |||
| 99 | + | }) | |||
| 100 | + | } | |||
| 101 | + | } | |||
M internal/application/usecase/navigation_url_usecase_integration_test.go +47 -0
| @@ -83,6 +83,53 @@ func TestManagePanesSplitResolvesExistingLocalInitialURL(t *testing.T) { | |||
| 83 | 83 | } | |
| 84 | 84 | } | |
| 85 | 85 | ||
| 86 | + | func TestManagePanesCreateStackResolvesExistingLocalInitialURL(t *testing.T) { | |
| 87 | + | ctx := context.Background() | |
| 88 | + | stackFile := filepath.Join(string(filepath.Separator), "tmp", "stack.html") | |
| 89 | + | uc := NewManagePanesUseCase( | |
| 90 | + | sequentialIDGenerator("new-pane"), | |
| 91 | + | fakeLocalPathResolver{paths: map[string]string{"stack.html": stackFile}}, | |
| 92 | + | ) | |
| 93 | + | active := entity.NewPane("active-pane") | |
| 94 | + | activeNode := &entity.PaneNode{ID: "active-node", Pane: active} | |
| 95 | + | workspace := &entity.Workspace{ID: "workspace", Root: activeNode, ActivePaneID: active.ID} | |
| 96 | + | ||
| 97 | + | out, err := uc.CreateStack(ctx, workspace, activeNode, "stack.html") | |
| 98 | + | if err != nil { | |
| 99 | + | t.Fatalf("CreateStack returned error: %v", err) | |
| 100 | + | } | |
| 101 | + | if got, want := out.NewPaneNode.Pane.URI, "file://"+stackFile; got != want { | |
| 102 | + | t.Fatalf("stack pane URI = %q, want %q", got, want) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestManagePanesAddToStackResolvesExistingLocalInitialURL(t *testing.T) { | |
| 107 | + | ctx := context.Background() | |
| 108 | + | stackFile := filepath.Join(string(filepath.Separator), "tmp", "added.html") | |
| 109 | + | uc := NewManagePanesUseCase( | |
| 110 | + | sequentialIDGenerator("added-pane"), | |
| 111 | + | fakeLocalPathResolver{paths: map[string]string{"added.html": stackFile}}, | |
| 112 | + | ) | |
| 113 | + | active := entity.NewPane("active-pane") | |
| 114 | + | activeNode := &entity.PaneNode{ID: "active-node", Pane: active} | |
| 115 | + | stackNode := &entity.PaneNode{ | |
| 116 | + | ID: "stack", | |
| 117 | + | IsStacked: true, | |
| 118 | + | ActiveStackIndex: 0, | |
| 119 | + | Children: []*entity.PaneNode{activeNode}, | |
| 120 | + | } | |
| 121 | + | activeNode.Parent = stackNode | |
| 122 | + | workspace := &entity.Workspace{ID: "workspace", Root: stackNode, ActivePaneID: active.ID} | |
| 123 | + | ||
| 124 | + | out, err := uc.AddToStack(ctx, workspace, stackNode, nil, "added.html") | |
| 125 | + | if err != nil { | |
| 126 | + | t.Fatalf("AddToStack returned error: %v", err) | |
| 127 | + | } | |
| 128 | + | if got, want := out.NewPaneNode.Pane.URI, "file://"+stackFile; got != want { | |
| 129 | + | t.Fatalf("added stack pane URI = %q, want %q", got, want) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 86 | 133 | func sequentialIDGenerator(ids ...string) IDGenerator { | |
| 87 | 134 | idx := 0 | |
| 88 | 135 | return func() string { | |
| @@ -83,6 +83,53 @@ func TestManagePanesSplitResolvesExistingLocalInitialURL(t *testing.T) { | |||||
| 83 | } | 83 | } | ||
| 84 | } | 84 | } | ||
| 85 | 85 | ||||
| 86 | + | func TestManagePanesCreateStackResolvesExistingLocalInitialURL(t *testing.T) { | |||
| 87 | + | ctx := context.Background() | |||
| 88 | + | stackFile := filepath.Join(string(filepath.Separator), "tmp", "stack.html") | |||
| 89 | + | uc := NewManagePanesUseCase( | |||
| 90 | + | sequentialIDGenerator("new-pane"), | |||
| 91 | + | fakeLocalPathResolver{paths: map[string]string{"stack.html": stackFile}}, | |||
| 92 | + | ) | |||
| 93 | + | active := entity.NewPane("active-pane") | |||
| 94 | + | activeNode := &entity.PaneNode{ID: "active-node", Pane: active} | |||
| 95 | + | workspace := &entity.Workspace{ID: "workspace", Root: activeNode, ActivePaneID: active.ID} | |||
| 96 | + | ||||
| 97 | + | out, err := uc.CreateStack(ctx, workspace, activeNode, "stack.html") | |||
| 98 | + | if err != nil { | |||
| 99 | + | t.Fatalf("CreateStack returned error: %v", err) | |||
| 100 | + | } | |||
| 101 | + | if got, want := out.NewPaneNode.Pane.URI, "file://"+stackFile; got != want { | |||
| 102 | + | t.Fatalf("stack pane URI = %q, want %q", got, want) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestManagePanesAddToStackResolvesExistingLocalInitialURL(t *testing.T) { | |||
| 107 | + | ctx := context.Background() | |||
| 108 | + | stackFile := filepath.Join(string(filepath.Separator), "tmp", "added.html") | |||
| 109 | + | uc := NewManagePanesUseCase( | |||
| 110 | + | sequentialIDGenerator("added-pane"), | |||
| 111 | + | fakeLocalPathResolver{paths: map[string]string{"added.html": stackFile}}, | |||
| 112 | + | ) | |||
| 113 | + | active := entity.NewPane("active-pane") | |||
| 114 | + | activeNode := &entity.PaneNode{ID: "active-node", Pane: active} | |||
| 115 | + | stackNode := &entity.PaneNode{ | |||
| 116 | + | ID: "stack", | |||
| 117 | + | IsStacked: true, | |||
| 118 | + | ActiveStackIndex: 0, | |||
| 119 | + | Children: []*entity.PaneNode{activeNode}, | |||
| 120 | + | } | |||
| 121 | + | activeNode.Parent = stackNode | |||
| 122 | + | workspace := &entity.Workspace{ID: "workspace", Root: stackNode, ActivePaneID: active.ID} | |||
| 123 | + | ||||
| 124 | + | out, err := uc.AddToStack(ctx, workspace, stackNode, nil, "added.html") | |||
| 125 | + | if err != nil { | |||
| 126 | + | t.Fatalf("AddToStack returned error: %v", err) | |||
| 127 | + | } | |||
| 128 | + | if got, want := out.NewPaneNode.Pane.URI, "file://"+stackFile; got != want { | |||
| 129 | + | t.Fatalf("added stack pane URI = %q, want %q", got, want) | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | ||||
| 86 | func sequentialIDGenerator(ids ...string) IDGenerator { | 133 | func sequentialIDGenerator(ids ...string) IDGenerator { | ||
| 87 | idx := 0 | 134 | idx := 0 | ||
| 88 | return func() string { | 135 | return func() string { | ||
M internal/cli/cmd/sessions.go +8 -5
| @@ -44,7 +44,7 @@ func runSessions(_ *cobra.Command, _ []string) error { | |||
| 44 | 44 | return fmt.Errorf("app not initialized") | |
| 45 | 45 | } | |
| 46 | 46 | ||
| 47 | - | if cliApp.ListSessionsUC == nil { | |
| 47 | + | if cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil { | |
| 48 | 48 | return fmt.Errorf("session management not available") | |
| 49 | 49 | } | |
| 50 | 50 | ||
| @@ -95,7 +95,7 @@ func runSessionsList(_ *cobra.Command, _ []string) error { | |||
| 95 | 95 | } | |
| 96 | 96 | renderer := styles.NewSessionsCLIRenderer(cliApp.Theme) | |
| 97 | 97 | ||
| 98 | - | if cliApp.ListSessionsUC == nil { | |
| 98 | + | if cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil { | |
| 99 | 99 | err := fmt.Errorf("session management not available") | |
| 100 | 100 | fmt.Fprintln(os.Stderr, renderer.RenderError(err)) | |
| 101 | 101 | return wrapPrintedError(err) | |
| @@ -157,7 +157,7 @@ func runSessionsRestore(_ *cobra.Command, args []string) error { | |||
| 157 | 157 | } | |
| 158 | 158 | renderer := styles.NewSessionsCLIRenderer(cliApp.Theme) | |
| 159 | 159 | ||
| 160 | - | if cliApp.RestoreUC == nil || cliApp.ListSessionsUC == nil { | |
| 160 | + | if cliApp.RestoreUC == nil || cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil { | |
| 161 | 161 | err := fmt.Errorf("session restoration not available") | |
| 162 | 162 | fmt.Fprintln(os.Stderr, renderer.RenderError(err)) | |
| 163 | 163 | return wrapPrintedError(err) | |
| @@ -225,7 +225,7 @@ func runSessionsDelete(_ *cobra.Command, args []string) error { | |||
| 225 | 225 | } | |
| 226 | 226 | renderer := styles.NewSessionsCLIRenderer(cliApp.Theme) | |
| 227 | 227 | ||
| 228 | - | if cliApp.DeleteSessionUC == nil || cliApp.ListSessionsUC == nil { | |
| 228 | + | if cliApp.DeleteSessionUC == nil || cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil { | |
| 229 | 229 | err := fmt.Errorf("session management not available") | |
| 230 | 230 | fmt.Fprintln(os.Stderr, renderer.RenderError(err)) | |
| 231 | 231 | return wrapPrintedError(err) | |
| @@ -262,9 +262,12 @@ func runSessionsDelete(_ *cobra.Command, args []string) error { | |||
| 262 | 262 | // Users typically identify sessions by the last few characters (e.g., "dee5"). | |
| 263 | 263 | func findSessionByIDOrSuffix(idOrSuffix string) (*entity.SessionInfo, error) { | |
| 264 | 264 | cliApp := GetApp() | |
| 265 | - | if cliApp == nil || cliApp.ListSessionsUC == nil { | |
| 265 | + | if cliApp == nil { | |
| 266 | 266 | return nil, fmt.Errorf("app not initialized") | |
| 267 | 267 | } | |
| 268 | + | if cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil { | |
| 269 | + | return nil, fmt.Errorf("session management not available") | |
| 270 | + | } | |
| 268 | 271 | ||
| 269 | 272 | // Get current session ID (empty if not running as browser) | |
| 270 | 273 | var currentSessionID entity.SessionID | |
| @@ -44,7 +44,7 @@ func runSessions(_ *cobra.Command, _ []string) error { | |||||
| 44 | return fmt.Errorf("app not initialized") | 44 | return fmt.Errorf("app not initialized") | ||
| 45 | } | 45 | } | ||
| 46 | 46 | ||||
| 47 | - | if cliApp.ListSessionsUC == nil { | 47 | + | if cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil { |
| 48 | return fmt.Errorf("session management not available") | 48 | return fmt.Errorf("session management not available") | ||
| 49 | } | 49 | } | ||
| 50 | 50 | ||||
| @@ -95,7 +95,7 @@ func runSessionsList(_ *cobra.Command, _ []string) error { | |||||
| 95 | } | 95 | } | ||
| 96 | renderer := styles.NewSessionsCLIRenderer(cliApp.Theme) | 96 | renderer := styles.NewSessionsCLIRenderer(cliApp.Theme) | ||
| 97 | 97 | ||||
| 98 | - | if cliApp.ListSessionsUC == nil { | 98 | + | if cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil { |
| 99 | err := fmt.Errorf("session management not available") | 99 | err := fmt.Errorf("session management not available") | ||
| 100 | fmt.Fprintln(os.Stderr, renderer.RenderError(err)) | 100 | fmt.Fprintln(os.Stderr, renderer.RenderError(err)) | ||
| 101 | return wrapPrintedError(err) | 101 | return wrapPrintedError(err) | ||
| @@ -157,7 +157,7 @@ func runSessionsRestore(_ *cobra.Command, args []string) error { | |||||
| 157 | } | 157 | } | ||
| 158 | renderer := styles.NewSessionsCLIRenderer(cliApp.Theme) | 158 | renderer := styles.NewSessionsCLIRenderer(cliApp.Theme) | ||
| 159 | 159 | ||||
| 160 | - | if cliApp.RestoreUC == nil || cliApp.ListSessionsUC == nil { | 160 | + | if cliApp.RestoreUC == nil || cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil { |
| 161 | err := fmt.Errorf("session restoration not available") | 161 | err := fmt.Errorf("session restoration not available") | ||
| 162 | fmt.Fprintln(os.Stderr, renderer.RenderError(err)) | 162 | fmt.Fprintln(os.Stderr, renderer.RenderError(err)) | ||
| 163 | return wrapPrintedError(err) | 163 | return wrapPrintedError(err) | ||
| @@ -225,7 +225,7 @@ func runSessionsDelete(_ *cobra.Command, args []string) error { | |||||
| 225 | } | 225 | } | ||
| 226 | renderer := styles.NewSessionsCLIRenderer(cliApp.Theme) | 226 | renderer := styles.NewSessionsCLIRenderer(cliApp.Theme) | ||
| 227 | 227 | ||||
| 228 | - | if cliApp.DeleteSessionUC == nil || cliApp.ListSessionsUC == nil { | 228 | + | if cliApp.DeleteSessionUC == nil || cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil { |
| 229 | err := fmt.Errorf("session management not available") | 229 | err := fmt.Errorf("session management not available") | ||
| 230 | fmt.Fprintln(os.Stderr, renderer.RenderError(err)) | 230 | fmt.Fprintln(os.Stderr, renderer.RenderError(err)) | ||
| 231 | return wrapPrintedError(err) | 231 | return wrapPrintedError(err) | ||
| @@ -262,9 +262,12 @@ func runSessionsDelete(_ *cobra.Command, args []string) error { | |||||
| 262 | // Users typically identify sessions by the last few characters (e.g., "dee5"). | 262 | // Users typically identify sessions by the last few characters (e.g., "dee5"). | ||
| 263 | func findSessionByIDOrSuffix(idOrSuffix string) (*entity.SessionInfo, error) { | 263 | func findSessionByIDOrSuffix(idOrSuffix string) (*entity.SessionInfo, error) { | ||
| 264 | cliApp := GetApp() | 264 | cliApp := GetApp() | ||
| 265 | - | if cliApp == nil || cliApp.ListSessionsUC == nil { | 265 | + | if cliApp == nil { |
| 266 | return nil, fmt.Errorf("app not initialized") | 266 | return nil, fmt.Errorf("app not initialized") | ||
| 267 | } | 267 | } | ||
| 268 | + | if cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil { | |||
| 269 | + | return nil, fmt.Errorf("session management not available") | |||
| 270 | + | } | |||
| 268 | 271 | ||||
| 269 | // Get current session ID (empty if not running as browser) | 272 | // Get current session ID (empty if not running as browser) | ||
| 270 | var currentSessionID entity.SessionID | 273 | var currentSessionID entity.SessionID | ||
A internal/cli/cmd/sessions_test.go +92 -0
| @@ -0,0 +1,92 @@ | |||
| 1 | + | package cmd | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "io" | |
| 5 | + | "os" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 9 | + | "github.com/bnema/dumber/internal/cli" | |
| 10 | + | "github.com/bnema/dumber/internal/cli/styles" | |
| 11 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestSessionCommandsReturnManagementErrorWhenSessionUseCaseMissing(t *testing.T) { | |
| 16 | + | tests := []struct { | |
| 17 | + | name string | |
| 18 | + | run func() error | |
| 19 | + | }{ | |
| 20 | + | { | |
| 21 | + | name: "interactive sessions", | |
| 22 | + | run: func() error { return runSessions(nil, nil) }, | |
| 23 | + | }, | |
| 24 | + | { | |
| 25 | + | name: "list sessions", | |
| 26 | + | run: func() error { return runSessionsList(nil, nil) }, | |
| 27 | + | }, | |
| 28 | + | { | |
| 29 | + | name: "delete session", | |
| 30 | + | run: func() error { return runSessionsDelete(nil, []string{"session-id"}) }, | |
| 31 | + | }, | |
| 32 | + | { | |
| 33 | + | name: "find session helper", | |
| 34 | + | run: func() error { | |
| 35 | + | _, err := findSessionByIDOrSuffix("session-id") | |
| 36 | + | return err | |
| 37 | + | }, | |
| 38 | + | }, | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | for _, tt := range tests { | |
| 42 | + | t.Run(tt.name, func(t *testing.T) { | |
| 43 | + | withApp(t, sessionCommandTestApp()) | |
| 44 | + | discardStderr(t) | |
| 45 | + | ||
| 46 | + | err := tt.run() | |
| 47 | + | ||
| 48 | + | require.ErrorContains(t, err, "session management not available") | |
| 49 | + | }) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestSessionRestoreReturnsRestorationErrorWhenSessionUseCaseMissing(t *testing.T) { | |
| 54 | + | withApp(t, sessionCommandTestApp()) | |
| 55 | + | discardStderr(t) | |
| 56 | + | ||
| 57 | + | err := runSessionsRestore(nil, []string{"session-id"}) | |
| 58 | + | ||
| 59 | + | require.ErrorContains(t, err, "session restoration not available") | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func sessionCommandTestApp() *cli.App { | |
| 63 | + | cfg := config.DefaultConfig() | |
| 64 | + | return &cli.App{ | |
| 65 | + | Config: cfg, | |
| 66 | + | Theme: styles.NewTheme(cfg), | |
| 67 | + | ListSessionsUC: &usecase.ListSessionsUseCase{}, | |
| 68 | + | RestoreUC: &usecase.RestoreSessionUseCase{}, | |
| 69 | + | DeleteSessionUC: &usecase.DeleteSessionUseCase{}, | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func withApp(t *testing.T, testApp *cli.App) { | |
| 74 | + | t.Helper() | |
| 75 | + | oldApp := app | |
| 76 | + | app = testApp | |
| 77 | + | t.Cleanup(func() { app = oldApp }) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func discardStderr(t *testing.T) { | |
| 81 | + | t.Helper() | |
| 82 | + | oldStderr := os.Stderr | |
| 83 | + | reader, writer, err := os.Pipe() | |
| 84 | + | require.NoError(t, err) | |
| 85 | + | os.Stderr = writer | |
| 86 | + | t.Cleanup(func() { | |
| 87 | + | os.Stderr = oldStderr | |
| 88 | + | require.NoError(t, writer.Close()) | |
| 89 | + | _, _ = io.Copy(io.Discard, reader) | |
| 90 | + | require.NoError(t, reader.Close()) | |
| 91 | + | }) | |
| 92 | + | } | |
| @@ -0,0 +1,92 @@ | |||||
| 1 | + | package cmd | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "io" | |||
| 5 | + | "os" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 9 | + | "github.com/bnema/dumber/internal/cli" | |||
| 10 | + | "github.com/bnema/dumber/internal/cli/styles" | |||
| 11 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestSessionCommandsReturnManagementErrorWhenSessionUseCaseMissing(t *testing.T) { | |||
| 16 | + | tests := []struct { | |||
| 17 | + | name string | |||
| 18 | + | run func() error | |||
| 19 | + | }{ | |||
| 20 | + | { | |||
| 21 | + | name: "interactive sessions", | |||
| 22 | + | run: func() error { return runSessions(nil, nil) }, | |||
| 23 | + | }, | |||
| 24 | + | { | |||
| 25 | + | name: "list sessions", | |||
| 26 | + | run: func() error { return runSessionsList(nil, nil) }, | |||
| 27 | + | }, | |||
| 28 | + | { | |||
| 29 | + | name: "delete session", | |||
| 30 | + | run: func() error { return runSessionsDelete(nil, []string{"session-id"}) }, | |||
| 31 | + | }, | |||
| 32 | + | { | |||
| 33 | + | name: "find session helper", | |||
| 34 | + | run: func() error { | |||
| 35 | + | _, err := findSessionByIDOrSuffix("session-id") | |||
| 36 | + | return err | |||
| 37 | + | }, | |||
| 38 | + | }, | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | for _, tt := range tests { | |||
| 42 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 43 | + | withApp(t, sessionCommandTestApp()) | |||
| 44 | + | discardStderr(t) | |||
| 45 | + | ||||
| 46 | + | err := tt.run() | |||
| 47 | + | ||||
| 48 | + | require.ErrorContains(t, err, "session management not available") | |||
| 49 | + | }) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestSessionRestoreReturnsRestorationErrorWhenSessionUseCaseMissing(t *testing.T) { | |||
| 54 | + | withApp(t, sessionCommandTestApp()) | |||
| 55 | + | discardStderr(t) | |||
| 56 | + | ||||
| 57 | + | err := runSessionsRestore(nil, []string{"session-id"}) | |||
| 58 | + | ||||
| 59 | + | require.ErrorContains(t, err, "session restoration not available") | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func sessionCommandTestApp() *cli.App { | |||
| 63 | + | cfg := config.DefaultConfig() | |||
| 64 | + | return &cli.App{ | |||
| 65 | + | Config: cfg, | |||
| 66 | + | Theme: styles.NewTheme(cfg), | |||
| 67 | + | ListSessionsUC: &usecase.ListSessionsUseCase{}, | |||
| 68 | + | RestoreUC: &usecase.RestoreSessionUseCase{}, | |||
| 69 | + | DeleteSessionUC: &usecase.DeleteSessionUseCase{}, | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func withApp(t *testing.T, testApp *cli.App) { | |||
| 74 | + | t.Helper() | |||
| 75 | + | oldApp := app | |||
| 76 | + | app = testApp | |||
| 77 | + | t.Cleanup(func() { app = oldApp }) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func discardStderr(t *testing.T) { | |||
| 81 | + | t.Helper() | |||
| 82 | + | oldStderr := os.Stderr | |||
| 83 | + | reader, writer, err := os.Pipe() | |||
| 84 | + | require.NoError(t, err) | |||
| 85 | + | os.Stderr = writer | |||
| 86 | + | t.Cleanup(func() { | |||
| 87 | + | os.Stderr = oldStderr | |||
| 88 | + | require.NoError(t, writer.Close()) | |||
| 89 | + | _, _ = io.Copy(io.Discard, reader) | |||
| 90 | + | require.NoError(t, reader.Close()) | |||
| 91 | + | }) | |||
| 92 | + | } | |||
M internal/infrastructure/config/schema_provider_test.go +68 -13
| @@ -53,25 +53,80 @@ func configurationReferenceCoversKey(docKeys map[string]struct{}, schemaKey stri | |||
| 53 | 53 | return true | |
| 54 | 54 | } | |
| 55 | 55 | ||
| 56 | - | if strings.HasSuffix(schemaKey, ".*") { | |
| 57 | - | prefix := strings.TrimSuffix(schemaKey, "*") | |
| 58 | - | for docKey := range docKeys { | |
| 59 | - | if strings.HasPrefix(docKey, prefix) { | |
| 60 | - | return true | |
| 61 | - | } | |
| 56 | + | schemaSegments := strings.Split(schemaKey, ".") | |
| 57 | + | for docKey := range docKeys { | |
| 58 | + | if configurationReferencePatternCoversKey(strings.Split(docKey, "."), schemaSegments) { | |
| 59 | + | return true | |
| 62 | 60 | } | |
| 63 | 61 | } | |
| 64 | 62 | ||
| 65 | - | if idx := strings.Index(schemaKey, "<"); idx >= 0 { | |
| 66 | - | prefix := schemaKey[:idx] | |
| 67 | - | for docKey := range docKeys { | |
| 68 | - | if strings.HasPrefix(docKey, prefix) { | |
| 69 | - | return true | |
| 70 | - | } | |
| 63 | + | return false | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func configurationReferencePatternCoversKey(docSegments, schemaSegments []string) bool { | |
| 67 | + | for idx, schemaSegment := range schemaSegments { | |
| 68 | + | if schemaSegment == "*" { | |
| 69 | + | return idx == len(schemaSegments)-1 && len(docSegments) == idx+1 | |
| 70 | + | } | |
| 71 | + | if idx >= len(docSegments) { | |
| 72 | + | return false | |
| 73 | + | } | |
| 74 | + | if isPlaceholderSegment(schemaSegment) && isPlaceholderSegment(docSegments[idx]) { | |
| 75 | + | continue | |
| 76 | + | } | |
| 77 | + | if docSegments[idx] != schemaSegment { | |
| 78 | + | return false | |
| 71 | 79 | } | |
| 72 | 80 | } | |
| 73 | 81 | ||
| 74 | - | return false | |
| 82 | + | if len(docSegments) == len(schemaSegments) { | |
| 83 | + | return true | |
| 84 | + | } | |
| 85 | + | return configurationReferenceAllowsObjectFields(strings.Join(schemaSegments, ".")) && | |
| 86 | + | len(docSegments) > len(schemaSegments) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func isPlaceholderSegment(segment string) bool { | |
| 90 | + | return strings.HasPrefix(segment, "<") && strings.HasSuffix(segment, ">") | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func configurationReferenceAllowsObjectFields(schemaKey string) bool { | |
| 94 | + | switch schemaKey { | |
| 95 | + | case "search_shortcuts.<key>", "workspace.floating_pane.profiles.<name>": | |
| 96 | + | return true | |
| 97 | + | default: | |
| 98 | + | return false | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func TestConfigurationReferenceCoversKeyRequiresPlaceholderSegmentMatches(t *testing.T) { | |
| 103 | + | docKeys := map[string]struct{}{ | |
| 104 | + | "workspace.floating_pane.profiles.<profile>.keys_extra": {}, | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | require.False(t, configurationReferenceCoversKey( | |
| 108 | + | docKeys, | |
| 109 | + | "workspace.floating_pane.profiles.<name>.keys", | |
| 110 | + | )) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestConfigurationReferenceCoversKeyAllowsDocumentedPlaceholderSegments(t *testing.T) { | |
| 114 | + | docKeys := map[string]struct{}{ | |
| 115 | + | "workspace.floating_pane.profiles.<profile>.keys": {}, | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | require.True(t, configurationReferenceCoversKey( | |
| 119 | + | docKeys, | |
| 120 | + | "workspace.floating_pane.profiles.<name>.keys", | |
| 121 | + | )) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func TestConfigurationReferenceCoversKeyAllowsDocumentedObjectFields(t *testing.T) { | |
| 125 | + | docKeys := map[string]struct{}{ | |
| 126 | + | "search_shortcuts.<name>.url": {}, | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | require.True(t, configurationReferenceCoversKey(docKeys, "search_shortcuts.<key>")) | |
| 75 | 130 | } | |
| 76 | 131 | ||
| 77 | 132 | func TestSchemaProviderCoversViperDefaults(t *testing.T) { | |
| @@ -53,25 +53,80 @@ func configurationReferenceCoversKey(docKeys map[string]struct{}, schemaKey stri | |||||
| 53 | return true | 53 | return true | ||
| 54 | } | 54 | } | ||
| 55 | 55 | ||||
| 56 | - | if strings.HasSuffix(schemaKey, ".*") { | 56 | + | schemaSegments := strings.Split(schemaKey, ".") |
| 57 | - | prefix := strings.TrimSuffix(schemaKey, "*") | 57 | + | for docKey := range docKeys { |
| 58 | - | for docKey := range docKeys { | 58 | + | if configurationReferencePatternCoversKey(strings.Split(docKey, "."), schemaSegments) { |
| 59 | - | if strings.HasPrefix(docKey, prefix) { | 59 | + | return true |
| 60 | - | return true | |||
| 61 | - | } | |||
| 62 | } | 60 | } | ||
| 63 | } | 61 | } | ||
| 64 | 62 | ||||
| 65 | - | if idx := strings.Index(schemaKey, "<"); idx >= 0 { | 63 | + | return false |
| 66 | - | prefix := schemaKey[:idx] | 64 | + | } |
| 67 | - | for docKey := range docKeys { | 65 | + | |
| 68 | - | if strings.HasPrefix(docKey, prefix) { | 66 | + | func configurationReferencePatternCoversKey(docSegments, schemaSegments []string) bool { |
| 69 | - | return true | 67 | + | for idx, schemaSegment := range schemaSegments { |
| 70 | - | } | 68 | + | if schemaSegment == "*" { |
| 69 | + | return idx == len(schemaSegments)-1 && len(docSegments) == idx+1 | |||
| 70 | + | } | |||
| 71 | + | if idx >= len(docSegments) { | |||
| 72 | + | return false | |||
| 73 | + | } | |||
| 74 | + | if isPlaceholderSegment(schemaSegment) && isPlaceholderSegment(docSegments[idx]) { | |||
| 75 | + | continue | |||
| 76 | + | } | |||
| 77 | + | if docSegments[idx] != schemaSegment { | |||
| 78 | + | return false | |||
| 71 | } | 79 | } | ||
| 72 | } | 80 | } | ||
| 73 | 81 | ||||
| 74 | - | return false | 82 | + | if len(docSegments) == len(schemaSegments) { |
| 83 | + | return true | |||
| 84 | + | } | |||
| 85 | + | return configurationReferenceAllowsObjectFields(strings.Join(schemaSegments, ".")) && | |||
| 86 | + | len(docSegments) > len(schemaSegments) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func isPlaceholderSegment(segment string) bool { | |||
| 90 | + | return strings.HasPrefix(segment, "<") && strings.HasSuffix(segment, ">") | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func configurationReferenceAllowsObjectFields(schemaKey string) bool { | |||
| 94 | + | switch schemaKey { | |||
| 95 | + | case "search_shortcuts.<key>", "workspace.floating_pane.profiles.<name>": | |||
| 96 | + | return true | |||
| 97 | + | default: | |||
| 98 | + | return false | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func TestConfigurationReferenceCoversKeyRequiresPlaceholderSegmentMatches(t *testing.T) { | |||
| 103 | + | docKeys := map[string]struct{}{ | |||
| 104 | + | "workspace.floating_pane.profiles.<profile>.keys_extra": {}, | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | require.False(t, configurationReferenceCoversKey( | |||
| 108 | + | docKeys, | |||
| 109 | + | "workspace.floating_pane.profiles.<name>.keys", | |||
| 110 | + | )) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestConfigurationReferenceCoversKeyAllowsDocumentedPlaceholderSegments(t *testing.T) { | |||
| 114 | + | docKeys := map[string]struct{}{ | |||
| 115 | + | "workspace.floating_pane.profiles.<profile>.keys": {}, | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | require.True(t, configurationReferenceCoversKey( | |||
| 119 | + | docKeys, | |||
| 120 | + | "workspace.floating_pane.profiles.<name>.keys", | |||
| 121 | + | )) | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func TestConfigurationReferenceCoversKeyAllowsDocumentedObjectFields(t *testing.T) { | |||
| 125 | + | docKeys := map[string]struct{}{ | |||
| 126 | + | "search_shortcuts.<name>.url": {}, | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | require.True(t, configurationReferenceCoversKey(docKeys, "search_shortcuts.<key>")) | |||
| 75 | } | 130 | } | ||
| 76 | 131 | ||||
| 77 | func TestSchemaProviderCoversViperDefaults(t *testing.T) { | 132 | func TestSchemaProviderCoversViperDefaults(t *testing.T) { | ||
M internal/infrastructure/config/validation.go +33 -7
| @@ -446,15 +446,19 @@ func validateWorkspaceNewPaneURL(config *Config) []string { | |||
| 446 | 446 | ||
| 447 | 447 | func validateWorkspaceURLValue(fieldPath, value string) []string { | |
| 448 | 448 | trimmed := strings.TrimSpace(value) | |
| 449 | + | if isRelativeWorkspacePath(trimmed) { | |
| 450 | + | return []string{fmt.Sprintf( | |
| 451 | + | "%s relative local path cannot be resolved without config file context (got: %s)", | |
| 452 | + | fieldPath, | |
| 453 | + | value, | |
| 454 | + | )} | |
| 455 | + | } | |
| 449 | 456 | if isLocalPathLikeWorkspaceURL(trimmed) { | |
| 450 | 457 | if isExistingWorkspacePath(trimmed) { | |
| 451 | 458 | return nil | |
| 452 | 459 | } | |
| 453 | 460 | return []string{fmt.Sprintf("%s local path must exist (got: %s)", fieldPath, value)} | |
| 454 | 461 | } | |
| 455 | - | if isExistingRelativeWorkspacePath(trimmed) { | |
| 456 | - | return nil | |
| 457 | - | } | |
| 458 | 462 | candidate := trimmed | |
| 459 | 463 | parsed, err := url.Parse(candidate) | |
| 460 | 464 | if err != nil || parsed.Scheme == "" { | |
| @@ -504,11 +508,33 @@ func isExistingWorkspacePath(value string) bool { | |||
| 504 | 508 | return err == nil | |
| 505 | 509 | } | |
| 506 | 510 | ||
| 507 | - | func isExistingRelativeWorkspacePath(value string) bool { | |
| 508 | - | if value == "" || strings.Contains(value, "://") || filepath.IsAbs(value) { | |
| 511 | + | func isRelativeWorkspacePath(value string) bool { | |
| 512 | + | if value == "" || filepath.IsAbs(value) || strings.HasPrefix(value, "~/") || | |
| 513 | + | strings.Contains(value, "://") { | |
| 514 | + | return false | |
| 515 | + | } | |
| 516 | + | if strings.HasPrefix(value, "./") || strings.HasPrefix(value, "../") || | |
| 517 | + | looksLikeBareRelativeWorkspacePath(value) { | |
| 518 | + | return true | |
| 519 | + | } | |
| 520 | + | return !domainurl.LooksLikeURL(value) && !strings.Contains(value, ":") | |
| 521 | + | } | |
| 522 | + | ||
| 523 | + | func looksLikeBareRelativeWorkspacePath(value string) bool { | |
| 524 | + | if strings.ContainsAny(value, `/\`) { | |
| 525 | + | firstSegment, _, _ := strings.Cut(strings.ReplaceAll(value, `\`, "/"), "/") | |
| 526 | + | return firstSegment == "" || (!strings.Contains(firstSegment, ".") && | |
| 527 | + | firstSegment != "localhost" && !strings.HasPrefix(firstSegment, "localhost:")) | |
| 528 | + | } | |
| 529 | + | ||
| 530 | + | switch strings.ToLower(filepath.Ext(value)) { | |
| 531 | + | case ".css", ".gif", ".htm", ".html", ".jpeg", ".jpg", ".js", ".json", | |
| 532 | + | ".md", ".pdf", ".png", ".svg", ".toml", ".txt", ".webp", ".xml", | |
| 533 | + | ".yaml", ".yml": | |
| 534 | + | return true | |
| 535 | + | default: | |
| 509 | 536 | return false | |
| 510 | 537 | } | |
| 511 | - | return isExistingWorkspacePath(value) | |
| 512 | 538 | } | |
| 513 | 539 | ||
| 514 | 540 | func validateOmnibox(config *Config) []string { | |
| @@ -532,7 +558,7 @@ func validateOmnibox(config *Config) []string { | |||
| 532 | 558 | ||
| 533 | 559 | func validateEngine(config *Config) []string { | |
| 534 | 560 | switch config.Engine.Type { | |
| 535 | - | case "", EngineTypeCEF, EngineTypeWebKit: | |
| 561 | + | case EngineTypeCEF, EngineTypeWebKit: | |
| 536 | 562 | return nil | |
| 537 | 563 | default: | |
| 538 | 564 | return []string{fmt.Sprintf( | |
| @@ -446,15 +446,19 @@ func validateWorkspaceNewPaneURL(config *Config) []string { | |||||
| 446 | 446 | ||||
| 447 | func validateWorkspaceURLValue(fieldPath, value string) []string { | 447 | func validateWorkspaceURLValue(fieldPath, value string) []string { | ||
| 448 | trimmed := strings.TrimSpace(value) | 448 | trimmed := strings.TrimSpace(value) | ||
| 449 | + | if isRelativeWorkspacePath(trimmed) { | |||
| 450 | + | return []string{fmt.Sprintf( | |||
| 451 | + | "%s relative local path cannot be resolved without config file context (got: %s)", | |||
| 452 | + | fieldPath, | |||
| 453 | + | value, | |||
| 454 | + | )} | |||
| 455 | + | } | |||
| 449 | if isLocalPathLikeWorkspaceURL(trimmed) { | 456 | if isLocalPathLikeWorkspaceURL(trimmed) { | ||
| 450 | if isExistingWorkspacePath(trimmed) { | 457 | if isExistingWorkspacePath(trimmed) { | ||
| 451 | return nil | 458 | return nil | ||
| 452 | } | 459 | } | ||
| 453 | return []string{fmt.Sprintf("%s local path must exist (got: %s)", fieldPath, value)} | 460 | return []string{fmt.Sprintf("%s local path must exist (got: %s)", fieldPath, value)} | ||
| 454 | } | 461 | } | ||
| 455 | - | if isExistingRelativeWorkspacePath(trimmed) { | |||
| 456 | - | return nil | |||
| 457 | - | } | |||
| 458 | candidate := trimmed | 462 | candidate := trimmed | ||
| 459 | parsed, err := url.Parse(candidate) | 463 | parsed, err := url.Parse(candidate) | ||
| 460 | if err != nil || parsed.Scheme == "" { | 464 | if err != nil || parsed.Scheme == "" { | ||
| @@ -504,11 +508,33 @@ func isExistingWorkspacePath(value string) bool { | |||||
| 504 | return err == nil | 508 | return err == nil | ||
| 505 | } | 509 | } | ||
| 506 | 510 | ||||
| 507 | - | func isExistingRelativeWorkspacePath(value string) bool { | 511 | + | func isRelativeWorkspacePath(value string) bool { |
| 508 | - | if value == "" || strings.Contains(value, "://") || filepath.IsAbs(value) { | 512 | + | if value == "" || filepath.IsAbs(value) || strings.HasPrefix(value, "~/") || |
| 513 | + | strings.Contains(value, "://") { | |||
| 514 | + | return false | |||
| 515 | + | } | |||
| 516 | + | if strings.HasPrefix(value, "./") || strings.HasPrefix(value, "../") || | |||
| 517 | + | looksLikeBareRelativeWorkspacePath(value) { | |||
| 518 | + | return true | |||
| 519 | + | } | |||
| 520 | + | return !domainurl.LooksLikeURL(value) && !strings.Contains(value, ":") | |||
| 521 | + | } | |||
| 522 | + | ||||
| 523 | + | func looksLikeBareRelativeWorkspacePath(value string) bool { | |||
| 524 | + | if strings.ContainsAny(value, `/\`) { | |||
| 525 | + | firstSegment, _, _ := strings.Cut(strings.ReplaceAll(value, `\`, "/"), "/") | |||
| 526 | + | return firstSegment == "" || (!strings.Contains(firstSegment, ".") && | |||
| 527 | + | firstSegment != "localhost" && !strings.HasPrefix(firstSegment, "localhost:")) | |||
| 528 | + | } | |||
| 529 | + | ||||
| 530 | + | switch strings.ToLower(filepath.Ext(value)) { | |||
| 531 | + | case ".css", ".gif", ".htm", ".html", ".jpeg", ".jpg", ".js", ".json", | |||
| 532 | + | ".md", ".pdf", ".png", ".svg", ".toml", ".txt", ".webp", ".xml", | |||
| 533 | + | ".yaml", ".yml": | |||
| 534 | + | return true | |||
| 535 | + | default: | |||
| 509 | return false | 536 | return false | ||
| 510 | } | 537 | } | ||
| 511 | - | return isExistingWorkspacePath(value) | |||
| 512 | } | 538 | } | ||
| 513 | 539 | ||||
| 514 | func validateOmnibox(config *Config) []string { | 540 | func validateOmnibox(config *Config) []string { | ||
| @@ -532,7 +558,7 @@ func validateOmnibox(config *Config) []string { | |||||
| 532 | 558 | ||||
| 533 | func validateEngine(config *Config) []string { | 559 | func validateEngine(config *Config) []string { | ||
| 534 | switch config.Engine.Type { | 560 | switch config.Engine.Type { | ||
| 535 | - | case "", EngineTypeCEF, EngineTypeWebKit: | 561 | + | case EngineTypeCEF, EngineTypeWebKit: |
| 536 | return nil | 562 | return nil | ||
| 537 | default: | 563 | default: | ||
| 538 | return []string{fmt.Sprintf( | 564 | return []string{fmt.Sprintf( | ||
M internal/infrastructure/config/validation_test.go +42 -12
| @@ -20,7 +20,7 @@ func TestValidateConfig_EngineType(t *testing.T) { | |||
| 20 | 20 | }{ | |
| 21 | 21 | {name: "cef", engineType: EngineTypeCEF, wantErr: false}, | |
| 22 | 22 | {name: "webkit", engineType: EngineTypeWebKit, wantErr: false}, | |
| 23 | - | {name: "empty defaults to cef", engineType: "", wantErr: false}, | |
| 23 | + | {name: "empty is invalid", engineType: "", wantErr: true}, | |
| 24 | 24 | {name: "invalid", engineType: "netscape", wantErr: true}, | |
| 25 | 25 | } | |
| 26 | 26 | ||
| @@ -294,7 +294,18 @@ func TestValidateConfig_CEFConfig(t *testing.T) { | |||
| 294 | 294 | } | |
| 295 | 295 | } | |
| 296 | 296 | ||
| 297 | - | func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingLocalPathLikeValues(t *testing.T) { | |
| 297 | + | func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingAbsoluteLocalPath(t *testing.T) { | |
| 298 | + | tmpDir := t.TempDir() | |
| 299 | + | path := filepath.Join(tmpDir, "page.html") | |
| 300 | + | require.NoError(t, os.WriteFile(path, []byte("ok"), 0644)) | |
| 301 | + | ||
| 302 | + | cfg := DefaultConfig() | |
| 303 | + | cfg.Workspace.NewPaneURL = path | |
| 304 | + | ||
| 305 | + | require.NoError(t, validateConfig(cfg)) | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | func TestValidateConfig_WorkspaceNewPaneURLRejectsRelativeLocalPathLikeValues(t *testing.T) { | |
| 298 | 309 | // This test mutates process CWD; do not add t.Parallel here. | |
| 299 | 310 | tmpDir := t.TempDir() | |
| 300 | 311 | oldWD, err := os.Getwd() | |
| @@ -306,19 +317,20 @@ func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingLocalPathLikeValues(t * | |||
| 306 | 317 | require.NoError(t, os.Chdir(childDir)) | |
| 307 | 318 | t.Cleanup(func() { _ = os.Chdir(oldWD) }) | |
| 308 | 319 | ||
| 309 | - | for _, value := range []string{filepath.Join(tmpDir, "page.html"), "./page.html", "../page.html"} { | |
| 320 | + | for _, value := range []string{"./page.html", "../page.html"} { | |
| 310 | 321 | t.Run(value, func(t *testing.T) { | |
| 311 | 322 | cfg := DefaultConfig() | |
| 312 | 323 | cfg.Workspace.NewPaneURL = value | |
| 313 | 324 | ||
| 314 | 325 | err := validateConfig(cfg) | |
| 315 | - | require.NoError(t, err) | |
| 326 | + | require.Error(t, err) | |
| 327 | + | assert.Contains(t, err.Error(), "workspace.new_pane_url relative local path cannot be resolved") | |
| 316 | 328 | }) | |
| 317 | 329 | } | |
| 318 | 330 | } | |
| 319 | 331 | ||
| 320 | 332 | func TestValidateConfig_WorkspaceNewPaneURLRejectsMissingLocalPathLikeValues(t *testing.T) { | |
| 321 | - | for _, value := range []string{"/missing/dumber/page.html", "./missing.html", "../missing.html", "~/missing-dumber-page.html"} { | |
| 333 | + | for _, value := range []string{"/missing/dumber/page.html", "~/missing-dumber-page.html"} { | |
| 322 | 334 | t.Run(value, func(t *testing.T) { | |
| 323 | 335 | cfg := DefaultConfig() | |
| 324 | 336 | cfg.Workspace.NewPaneURL = value | |
| @@ -330,7 +342,7 @@ func TestValidateConfig_WorkspaceNewPaneURLRejectsMissingLocalPathLikeValues(t * | |||
| 330 | 342 | } | |
| 331 | 343 | } | |
| 332 | 344 | ||
| 333 | - | func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingBareRelativeFile(t *testing.T) { | |
| 345 | + | func TestValidateConfig_WorkspaceNewPaneURLRejectsExistingBareRelativeFile(t *testing.T) { | |
| 334 | 346 | // This test mutates process CWD; do not add t.Parallel here. | |
| 335 | 347 | tmpDir := t.TempDir() | |
| 336 | 348 | oldWD, err := os.Getwd() | |
| @@ -338,20 +350,38 @@ func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingBareRelativeFile(t *tes | |||
| 338 | 350 | require.NoError(t, os.Chdir(tmpDir)) | |
| 339 | 351 | t.Cleanup(func() { _ = os.Chdir(oldWD) }) | |
| 340 | 352 | require.NoError(t, os.WriteFile("README", []byte("ok"), 0644)) | |
| 353 | + | require.NoError(t, os.WriteFile("index.html", []byte("ok"), 0644)) | |
| 341 | 354 | ||
| 342 | - | cfg := DefaultConfig() | |
| 343 | - | cfg.Workspace.NewPaneURL = "README" | |
| 355 | + | for _, value := range []string{"README", "index.html"} { | |
| 356 | + | t.Run(value, func(t *testing.T) { | |
| 357 | + | cfg := DefaultConfig() | |
| 358 | + | cfg.Workspace.NewPaneURL = value | |
| 344 | 359 | ||
| 345 | - | require.NoError(t, validateConfig(cfg)) | |
| 360 | + | validationErr := validateConfig(cfg) | |
| 361 | + | require.Error(t, validationErr) | |
| 362 | + | assert.Contains( | |
| 363 | + | t, | |
| 364 | + | validationErr.Error(), | |
| 365 | + | "workspace.new_pane_url relative local path cannot be resolved", | |
| 366 | + | ) | |
| 367 | + | }) | |
| 368 | + | } | |
| 346 | 369 | } | |
| 347 | 370 | ||
| 348 | 371 | func TestValidateConfig_WorkspaceNewPaneURLRejectsMissingBareRelativeValue(t *testing.T) { | |
| 372 | + | // This test mutates process CWD; do not add t.Parallel here. | |
| 373 | + | tmpDir := t.TempDir() | |
| 374 | + | oldWD, err := os.Getwd() | |
| 375 | + | require.NoError(t, err) | |
| 376 | + | require.NoError(t, os.Chdir(tmpDir)) | |
| 377 | + | t.Cleanup(func() { _ = os.Chdir(oldWD) }) | |
| 378 | + | ||
| 349 | 379 | cfg := DefaultConfig() | |
| 350 | 380 | cfg.Workspace.NewPaneURL = "missing-local-file" | |
| 351 | 381 | ||
| 352 | - | err := validateConfig(cfg) | |
| 353 | - | require.Error(t, err) | |
| 354 | - | assert.Contains(t, err.Error(), "workspace.new_pane_url") | |
| 382 | + | validationErr := validateConfig(cfg) | |
| 383 | + | require.Error(t, validationErr) | |
| 384 | + | assert.Contains(t, validationErr.Error(), "workspace.new_pane_url") | |
| 355 | 385 | } | |
| 356 | 386 | ||
| 357 | 387 | func TestValidateConfig_WebKitDefaultProfileIgnoresZeroGPUThreads(t *testing.T) { | |
| @@ -20,7 +20,7 @@ func TestValidateConfig_EngineType(t *testing.T) { | |||||
| 20 | }{ | 20 | }{ | ||
| 21 | {name: "cef", engineType: EngineTypeCEF, wantErr: false}, | 21 | {name: "cef", engineType: EngineTypeCEF, wantErr: false}, | ||
| 22 | {name: "webkit", engineType: EngineTypeWebKit, wantErr: false}, | 22 | {name: "webkit", engineType: EngineTypeWebKit, wantErr: false}, | ||
| 23 | - | {name: "empty defaults to cef", engineType: "", wantErr: false}, | 23 | + | {name: "empty is invalid", engineType: "", wantErr: true}, |
| 24 | {name: "invalid", engineType: "netscape", wantErr: true}, | 24 | {name: "invalid", engineType: "netscape", wantErr: true}, | ||
| 25 | } | 25 | } | ||
| 26 | 26 | ||||
| @@ -294,7 +294,18 @@ func TestValidateConfig_CEFConfig(t *testing.T) { | |||||
| 294 | } | 294 | } | ||
| 295 | } | 295 | } | ||
| 296 | 296 | ||||
| 297 | - | func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingLocalPathLikeValues(t *testing.T) { | 297 | + | func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingAbsoluteLocalPath(t *testing.T) { |
| 298 | + | tmpDir := t.TempDir() | |||
| 299 | + | path := filepath.Join(tmpDir, "page.html") | |||
| 300 | + | require.NoError(t, os.WriteFile(path, []byte("ok"), 0644)) | |||
| 301 | + | ||||
| 302 | + | cfg := DefaultConfig() | |||
| 303 | + | cfg.Workspace.NewPaneURL = path | |||
| 304 | + | ||||
| 305 | + | require.NoError(t, validateConfig(cfg)) | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | func TestValidateConfig_WorkspaceNewPaneURLRejectsRelativeLocalPathLikeValues(t *testing.T) { | |||
| 298 | // This test mutates process CWD; do not add t.Parallel here. | 309 | // This test mutates process CWD; do not add t.Parallel here. | ||
| 299 | tmpDir := t.TempDir() | 310 | tmpDir := t.TempDir() | ||
| 300 | oldWD, err := os.Getwd() | 311 | oldWD, err := os.Getwd() | ||
| @@ -306,19 +317,20 @@ func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingLocalPathLikeValues(t * | |||||
| 306 | require.NoError(t, os.Chdir(childDir)) | 317 | require.NoError(t, os.Chdir(childDir)) | ||
| 307 | t.Cleanup(func() { _ = os.Chdir(oldWD) }) | 318 | t.Cleanup(func() { _ = os.Chdir(oldWD) }) | ||
| 308 | 319 | ||||
| 309 | - | for _, value := range []string{filepath.Join(tmpDir, "page.html"), "./page.html", "../page.html"} { | 320 | + | for _, value := range []string{"./page.html", "../page.html"} { |
| 310 | t.Run(value, func(t *testing.T) { | 321 | t.Run(value, func(t *testing.T) { | ||
| 311 | cfg := DefaultConfig() | 322 | cfg := DefaultConfig() | ||
| 312 | cfg.Workspace.NewPaneURL = value | 323 | cfg.Workspace.NewPaneURL = value | ||
| 313 | 324 | ||||
| 314 | err := validateConfig(cfg) | 325 | err := validateConfig(cfg) | ||
| 315 | - | require.NoError(t, err) | 326 | + | require.Error(t, err) |
| 327 | + | assert.Contains(t, err.Error(), "workspace.new_pane_url relative local path cannot be resolved") | |||
| 316 | }) | 328 | }) | ||
| 317 | } | 329 | } | ||
| 318 | } | 330 | } | ||
| 319 | 331 | ||||
| 320 | func TestValidateConfig_WorkspaceNewPaneURLRejectsMissingLocalPathLikeValues(t *testing.T) { | 332 | func TestValidateConfig_WorkspaceNewPaneURLRejectsMissingLocalPathLikeValues(t *testing.T) { | ||
| 321 | - | for _, value := range []string{"/missing/dumber/page.html", "./missing.html", "../missing.html", "~/missing-dumber-page.html"} { | 333 | + | for _, value := range []string{"/missing/dumber/page.html", "~/missing-dumber-page.html"} { |
| 322 | t.Run(value, func(t *testing.T) { | 334 | t.Run(value, func(t *testing.T) { | ||
| 323 | cfg := DefaultConfig() | 335 | cfg := DefaultConfig() | ||
| 324 | cfg.Workspace.NewPaneURL = value | 336 | cfg.Workspace.NewPaneURL = value | ||
| @@ -330,7 +342,7 @@ func TestValidateConfig_WorkspaceNewPaneURLRejectsMissingLocalPathLikeValues(t * | |||||
| 330 | } | 342 | } | ||
| 331 | } | 343 | } | ||
| 332 | 344 | ||||
| 333 | - | func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingBareRelativeFile(t *testing.T) { | 345 | + | func TestValidateConfig_WorkspaceNewPaneURLRejectsExistingBareRelativeFile(t *testing.T) { |
| 334 | // This test mutates process CWD; do not add t.Parallel here. | 346 | // This test mutates process CWD; do not add t.Parallel here. | ||
| 335 | tmpDir := t.TempDir() | 347 | tmpDir := t.TempDir() | ||
| 336 | oldWD, err := os.Getwd() | 348 | oldWD, err := os.Getwd() | ||
| @@ -338,20 +350,38 @@ func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingBareRelativeFile(t *tes | |||||
| 338 | require.NoError(t, os.Chdir(tmpDir)) | 350 | require.NoError(t, os.Chdir(tmpDir)) | ||
| 339 | t.Cleanup(func() { _ = os.Chdir(oldWD) }) | 351 | t.Cleanup(func() { _ = os.Chdir(oldWD) }) | ||
| 340 | require.NoError(t, os.WriteFile("README", []byte("ok"), 0644)) | 352 | require.NoError(t, os.WriteFile("README", []byte("ok"), 0644)) | ||
| 353 | + | require.NoError(t, os.WriteFile("index.html", []byte("ok"), 0644)) | |||
| 341 | 354 | ||||
| 342 | - | cfg := DefaultConfig() | 355 | + | for _, value := range []string{"README", "index.html"} { |
| 343 | - | cfg.Workspace.NewPaneURL = "README" | 356 | + | t.Run(value, func(t *testing.T) { |
| 357 | + | cfg := DefaultConfig() | |||
| 358 | + | cfg.Workspace.NewPaneURL = value | |||
| 344 | 359 | ||||
| 345 | - | require.NoError(t, validateConfig(cfg)) | 360 | + | validationErr := validateConfig(cfg) |
| 361 | + | require.Error(t, validationErr) | |||
| 362 | + | assert.Contains( | |||
| 363 | + | t, | |||
| 364 | + | validationErr.Error(), | |||
| 365 | + | "workspace.new_pane_url relative local path cannot be resolved", | |||
| 366 | + | ) | |||
| 367 | + | }) | |||
| 368 | + | } | |||
| 346 | } | 369 | } | ||
| 347 | 370 | ||||
| 348 | func TestValidateConfig_WorkspaceNewPaneURLRejectsMissingBareRelativeValue(t *testing.T) { | 371 | func TestValidateConfig_WorkspaceNewPaneURLRejectsMissingBareRelativeValue(t *testing.T) { | ||
| 372 | + | // This test mutates process CWD; do not add t.Parallel here. | |||
| 373 | + | tmpDir := t.TempDir() | |||
| 374 | + | oldWD, err := os.Getwd() | |||
| 375 | + | require.NoError(t, err) | |||
| 376 | + | require.NoError(t, os.Chdir(tmpDir)) | |||
| 377 | + | t.Cleanup(func() { _ = os.Chdir(oldWD) }) | |||
| 378 | + | ||||
| 349 | cfg := DefaultConfig() | 379 | cfg := DefaultConfig() | ||
| 350 | cfg.Workspace.NewPaneURL = "missing-local-file" | 380 | cfg.Workspace.NewPaneURL = "missing-local-file" | ||
| 351 | 381 | ||||
| 352 | - | err := validateConfig(cfg) | 382 | + | validationErr := validateConfig(cfg) |
| 353 | - | require.Error(t, err) | 383 | + | require.Error(t, validationErr) |
| 354 | - | assert.Contains(t, err.Error(), "workspace.new_pane_url") | 384 | + | assert.Contains(t, validationErr.Error(), "workspace.new_pane_url") |
| 355 | } | 385 | } | ||
| 356 | 386 | ||||
| 357 | func TestValidateConfig_WebKitDefaultProfileIgnoresZeroGPUThreads(t *testing.T) { | 387 | func TestValidateConfig_WebKitDefaultProfileIgnoresZeroGPUThreads(t *testing.T) { | ||
M internal/ui/app.go +10 -1
| @@ -2455,8 +2455,17 @@ func (a *App) createInitialTab(ctx context.Context) { | |||
| 2455 | 2455 | } | |
| 2456 | 2456 | } | |
| 2457 | 2457 | ||
| 2458 | + | focusedWindow := a.lastFocusedBrowserWindow() | |
| 2459 | + | if tabs := a.tabListForBrowserWindow(focusedWindow); tabs != nil && tabs.Count() > 0 { | |
| 2460 | + | log.Debug(). | |
| 2461 | + | Str("window_id", focusedWindow.id). | |
| 2462 | + | Int("tab_count", tabs.Count()). | |
| 2463 | + | Msg("initial tab creation skipped; focused window already has tabs") | |
| 2464 | + | return | |
| 2465 | + | } | |
| 2466 | + | ||
| 2458 | 2467 | // Create an initial tab using coordinator. | |
| 2459 | - | target := a.ensureTabTargetForBrowserWindow(a.lastFocusedBrowserWindow()) | |
| 2468 | + | target := a.ensureTabTargetForBrowserWindow(focusedWindow) | |
| 2460 | 2469 | if _, err := a.tabCoord.Create(ctx, target, a.initialWindowURL()); err != nil { | |
| 2461 | 2470 | log.Error().Err(err).Msg("failed to create initial tab") | |
| 2462 | 2471 | } | |
| @@ -2455,8 +2455,17 @@ func (a *App) createInitialTab(ctx context.Context) { | |||||
| 2455 | } | 2455 | } | ||
| 2456 | } | 2456 | } | ||
| 2457 | 2457 | ||||
| 2458 | + | focusedWindow := a.lastFocusedBrowserWindow() | |||
| 2459 | + | if tabs := a.tabListForBrowserWindow(focusedWindow); tabs != nil && tabs.Count() > 0 { | |||
| 2460 | + | log.Debug(). | |||
| 2461 | + | Str("window_id", focusedWindow.id). | |||
| 2462 | + | Int("tab_count", tabs.Count()). | |||
| 2463 | + | Msg("initial tab creation skipped; focused window already has tabs") | |||
| 2464 | + | return | |||
| 2465 | + | } | |||
| 2466 | + | ||||
| 2458 | // Create an initial tab using coordinator. | 2467 | // Create an initial tab using coordinator. | ||
| 2459 | - | target := a.ensureTabTargetForBrowserWindow(a.lastFocusedBrowserWindow()) | 2468 | + | target := a.ensureTabTargetForBrowserWindow(focusedWindow) |
| 2460 | if _, err := a.tabCoord.Create(ctx, target, a.initialWindowURL()); err != nil { | 2469 | if _, err := a.tabCoord.Create(ctx, target, a.initialWindowURL()); err != nil { | ||
| 2461 | log.Error().Err(err).Msg("failed to create initial tab") | 2470 | log.Error().Err(err).Msg("failed to create initial tab") | ||
| 2462 | } | 2471 | } | ||
M internal/ui/app_window_lifecycle.go +3 -3
| @@ -110,8 +110,8 @@ func (a *App) openFreshWindow(ctx context.Context, url string) error { | |||
| 110 | 110 | Int("window_count_after_register", len(a.browserWindows)). | |
| 111 | 111 | Msg("ui: open fresh window registered browser window") | |
| 112 | 112 | ||
| 113 | - | if len(a.browserWindows) == 1 { | |
| 114 | - | log.Debug().Str("window_id", created.id).Msg("ui: activating first browser window") | |
| 113 | + | if len(a.browserWindows) == 1 && (url == "" || (a.deps != nil && a.deps.RestoreSessionID != "")) { | |
| 114 | + | log.Debug().Str("window_id", created.id).Msg("ui: activating first browser window without initial tab") | |
| 115 | 115 | a.activateBrowserWindow(created) | |
| 116 | 116 | return nil | |
| 117 | 117 | } | |
| @@ -234,7 +234,7 @@ func (a *App) openFreshWindowWithTabsUC(ctx context.Context, url string, created | |||
| 234 | 234 | // This path uses the tabs usecase directly instead of tabCoord.Create, | |
| 235 | 235 | // so the per-window TabList must be populated explicitly. | |
| 236 | 236 | a.ensureTabListForBrowserWindow(created).Add(output.Tab) | |
| 237 | - | if a.widgetFactory != nil { | |
| 237 | + | if a.widgetFactory != nil || a.workspaceViewCreateOverride != nil { | |
| 238 | 238 | a.createWorkspaceView(ctx, output.Tab) | |
| 239 | 239 | a.switchWorkspaceView(ctx, output.Tab.ID) | |
| 240 | 240 | } | |
| @@ -110,8 +110,8 @@ func (a *App) openFreshWindow(ctx context.Context, url string) error { | |||||
| 110 | Int("window_count_after_register", len(a.browserWindows)). | 110 | Int("window_count_after_register", len(a.browserWindows)). | ||
| 111 | Msg("ui: open fresh window registered browser window") | 111 | Msg("ui: open fresh window registered browser window") | ||
| 112 | 112 | ||||
| 113 | - | if len(a.browserWindows) == 1 { | 113 | + | if len(a.browserWindows) == 1 && (url == "" || (a.deps != nil && a.deps.RestoreSessionID != "")) { |
| 114 | - | log.Debug().Str("window_id", created.id).Msg("ui: activating first browser window") | 114 | + | log.Debug().Str("window_id", created.id).Msg("ui: activating first browser window without initial tab") |
| 115 | a.activateBrowserWindow(created) | 115 | a.activateBrowserWindow(created) | ||
| 116 | return nil | 116 | return nil | ||
| 117 | } | 117 | } | ||
| @@ -234,7 +234,7 @@ func (a *App) openFreshWindowWithTabsUC(ctx context.Context, url string, created | |||||
| 234 | // This path uses the tabs usecase directly instead of tabCoord.Create, | 234 | // This path uses the tabs usecase directly instead of tabCoord.Create, | ||
| 235 | // so the per-window TabList must be populated explicitly. | 235 | // so the per-window TabList must be populated explicitly. | ||
| 236 | a.ensureTabListForBrowserWindow(created).Add(output.Tab) | 236 | a.ensureTabListForBrowserWindow(created).Add(output.Tab) | ||
| 237 | - | if a.widgetFactory != nil { | 237 | + | if a.widgetFactory != nil || a.workspaceViewCreateOverride != nil { |
| 238 | a.createWorkspaceView(ctx, output.Tab) | 238 | a.createWorkspaceView(ctx, output.Tab) | ||
| 239 | a.switchWorkspaceView(ctx, output.Tab.ID) | 239 | a.switchWorkspaceView(ctx, output.Tab.ID) | ||
| 240 | } | 240 | } | ||
M internal/ui/browser_window_test.go +71 -3
| @@ -459,7 +459,7 @@ func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) { | |||
| 459 | 459 | } | |
| 460 | 460 | } | |
| 461 | 461 | ||
| 462 | - | func TestOpenFreshWindow_FirstWindowRegistersShellWithoutCreatingTab(t *testing.T) { | |
| 462 | + | func TestOpenFreshWindow_FirstWindowWithEmptyURLRegistersShellWithoutCreatingTab(t *testing.T) { | |
| 463 | 463 | app := &App{ | |
| 464 | 464 | tabs: entity.NewTabList(), | |
| 465 | 465 | tabsUC: usecase.NewManageTabsUseCase(func() string { return "unexpected-tab" }, nil), | |
| @@ -473,7 +473,7 @@ func TestOpenFreshWindow_FirstWindowRegistersShellWithoutCreatingTab(t *testing. | |||
| 473 | 473 | return &browserWindow{id: "window-1", tabs: entity.NewTabList()}, nil | |
| 474 | 474 | } | |
| 475 | 475 | ||
| 476 | - | if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil { | |
| 476 | + | if err := app.OpenFreshWindow(context.Background(), ""); err != nil { | |
| 477 | 477 | t.Fatalf("OpenFreshWindow returned error: %v", err) | |
| 478 | 478 | } | |
| 479 | 479 | if got := len(app.browserWindows); got != 1 { | |
| @@ -483,8 +483,76 @@ func TestOpenFreshWindow_FirstWindowRegistersShellWithoutCreatingTab(t *testing. | |||
| 483 | 483 | t.Fatalf("lastFocusedWindowID = %q, want window-1", app.lastFocusedWindowID) | |
| 484 | 484 | } | |
| 485 | 485 | if got := app.tabs.Count(); got != 0 { | |
| 486 | - | t.Fatalf("tabs count = %d, want 0; first shell should wait for createInitialTab", got) | |
| 486 | + | t.Fatalf("tabs count = %d, want 0; first empty shell should wait for createInitialTab", got) | |
| 487 | + | } | |
| 488 | + | if got := app.browserWindows["window-1"].tabs.Count(); got != 0 { | |
| 489 | + | t.Fatalf("window tabs count = %d, want 0; first empty shell should wait for createInitialTab", got) | |
| 490 | + | } | |
| 491 | + | } | |
| 492 | + | ||
| 493 | + | func TestOpenFreshWindow_FirstWindowWithRequestedURLCreatesTabInAppAndWindow(t *testing.T) { | |
| 494 | + | app := &App{ | |
| 495 | + | tabs: entity.NewTabList(), | |
| 496 | + | tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil), | |
| 497 | + | browserWindows: make(map[string]*browserWindow), | |
| 498 | + | workspaceViews: map[entity.TabID]*component.WorkspaceView{}, | |
| 499 | + | windowForTab: map[entity.TabID]*browserWindow{}, | |
| 500 | + | } | |
| 501 | + | app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult { | |
| 502 | + | fn() | |
| 503 | + | return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted} | |
| 504 | + | } | |
| 505 | + | app.browserWindowFactory = func(context.Context, string) (*browserWindow, error) { | |
| 506 | + | return &browserWindow{id: "window-1", tabs: entity.NewTabList()}, nil | |
| 487 | 507 | } | |
| 508 | + | app.workspaceViewCreateOverride = func(_ context.Context, tab *entity.Tab) bool { | |
| 509 | + | app.workspaceViews[tab.ID] = &component.WorkspaceView{} | |
| 510 | + | return true | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil { | |
| 514 | + | t.Fatalf("OpenFreshWindow returned error: %v", err) | |
| 515 | + | } | |
| 516 | + | ||
| 517 | + | windowTabs := app.browserWindows["window-1"].tabs | |
| 518 | + | require.NotNil(t, windowTabs) | |
| 519 | + | require.Equal(t, 1, app.tabs.Count()) | |
| 520 | + | require.Equal(t, 1, windowTabs.Count()) | |
| 521 | + | tab := windowTabs.ActiveTab() | |
| 522 | + | require.NotNil(t, tab) | |
| 523 | + | require.NotNil(t, tab.Workspace) | |
| 524 | + | activePane := tab.Workspace.FindPane(tab.Workspace.ActivePaneID) | |
| 525 | + | require.NotNil(t, activePane) | |
| 526 | + | require.Equal(t, "https://example.com", activePane.Pane.URI) | |
| 527 | + | require.NotNil(t, app.tabs.Find(tab.ID)) | |
| 528 | + | require.Same(t, app.browserWindows["window-1"], app.browserWindowForTab(tab.ID)) | |
| 529 | + | } | |
| 530 | + | ||
| 531 | + | func TestCreateInitialTabSkipsFocusedWindowThatAlreadyHasTabs(t *testing.T) { | |
| 532 | + | ctx := context.Background() | |
| 533 | + | existing := entity.NewTab(entity.TabID("existing-tab"), entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane"))) | |
| 534 | + | windowTabs := entity.NewTabList() | |
| 535 | + | windowTabs.Add(existing) | |
| 536 | + | windowTabs.SetActive(existing.ID) | |
| 537 | + | bw := &browserWindow{id: "window-1", tabs: windowTabs} | |
| 538 | + | app := &App{ | |
| 539 | + | runtimeConfig: runtimeConfigStateForTest(config.DefaultConfig()), | |
| 540 | + | tabs: entity.NewTabList(), | |
| 541 | + | tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil), | |
| 542 | + | browserWindows: map[string]*browserWindow{bw.id: bw}, | |
| 543 | + | lastFocusedWindowID: bw.id, | |
| 544 | + | workspaceViews: map[entity.TabID]*component.WorkspaceView{}, | |
| 545 | + | windowForTab: map[entity.TabID]*browserWindow{existing.ID: bw}, | |
| 546 | + | } | |
| 547 | + | app.tabs.Add(existing) | |
| 548 | + | app.initTabCoordinator(ctx) | |
| 549 | + | ||
| 550 | + | app.createInitialTab(ctx) | |
| 551 | + | ||
| 552 | + | require.Equal(t, 1, windowTabs.Count()) | |
| 553 | + | require.NotNil(t, windowTabs.Find(existing.ID)) | |
| 554 | + | require.Equal(t, 1, app.tabs.Count()) | |
| 555 | + | require.NotNil(t, app.tabs.Find(existing.ID)) | |
| 488 | 556 | } | |
| 489 | 557 | ||
| 490 | 558 | func TestOpenFreshWindow_PropagatesFactoryError(t *testing.T) { | |
| @@ -459,7 +459,7 @@ func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) { | |||||
| 459 | } | 459 | } | ||
| 460 | } | 460 | } | ||
| 461 | 461 | ||||
| 462 | - | func TestOpenFreshWindow_FirstWindowRegistersShellWithoutCreatingTab(t *testing.T) { | 462 | + | func TestOpenFreshWindow_FirstWindowWithEmptyURLRegistersShellWithoutCreatingTab(t *testing.T) { |
| 463 | app := &App{ | 463 | app := &App{ | ||
| 464 | tabs: entity.NewTabList(), | 464 | tabs: entity.NewTabList(), | ||
| 465 | tabsUC: usecase.NewManageTabsUseCase(func() string { return "unexpected-tab" }, nil), | 465 | tabsUC: usecase.NewManageTabsUseCase(func() string { return "unexpected-tab" }, nil), | ||
| @@ -473,7 +473,7 @@ func TestOpenFreshWindow_FirstWindowRegistersShellWithoutCreatingTab(t *testing. | |||||
| 473 | return &browserWindow{id: "window-1", tabs: entity.NewTabList()}, nil | 473 | return &browserWindow{id: "window-1", tabs: entity.NewTabList()}, nil | ||
| 474 | } | 474 | } | ||
| 475 | 475 | ||||
| 476 | - | if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil { | 476 | + | if err := app.OpenFreshWindow(context.Background(), ""); err != nil { |
| 477 | t.Fatalf("OpenFreshWindow returned error: %v", err) | 477 | t.Fatalf("OpenFreshWindow returned error: %v", err) | ||
| 478 | } | 478 | } | ||
| 479 | if got := len(app.browserWindows); got != 1 { | 479 | if got := len(app.browserWindows); got != 1 { | ||
| @@ -483,8 +483,76 @@ func TestOpenFreshWindow_FirstWindowRegistersShellWithoutCreatingTab(t *testing. | |||||
| 483 | t.Fatalf("lastFocusedWindowID = %q, want window-1", app.lastFocusedWindowID) | 483 | t.Fatalf("lastFocusedWindowID = %q, want window-1", app.lastFocusedWindowID) | ||
| 484 | } | 484 | } | ||
| 485 | if got := app.tabs.Count(); got != 0 { | 485 | if got := app.tabs.Count(); got != 0 { | ||
| 486 | - | t.Fatalf("tabs count = %d, want 0; first shell should wait for createInitialTab", got) | 486 | + | t.Fatalf("tabs count = %d, want 0; first empty shell should wait for createInitialTab", got) |
| 487 | + | } | |||
| 488 | + | if got := app.browserWindows["window-1"].tabs.Count(); got != 0 { | |||
| 489 | + | t.Fatalf("window tabs count = %d, want 0; first empty shell should wait for createInitialTab", got) | |||
| 490 | + | } | |||
| 491 | + | } | |||
| 492 | + | ||||
| 493 | + | func TestOpenFreshWindow_FirstWindowWithRequestedURLCreatesTabInAppAndWindow(t *testing.T) { | |||
| 494 | + | app := &App{ | |||
| 495 | + | tabs: entity.NewTabList(), | |||
| 496 | + | tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil), | |||
| 497 | + | browserWindows: make(map[string]*browserWindow), | |||
| 498 | + | workspaceViews: map[entity.TabID]*component.WorkspaceView{}, | |||
| 499 | + | windowForTab: map[entity.TabID]*browserWindow{}, | |||
| 500 | + | } | |||
| 501 | + | app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult { | |||
| 502 | + | fn() | |||
| 503 | + | return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted} | |||
| 504 | + | } | |||
| 505 | + | app.browserWindowFactory = func(context.Context, string) (*browserWindow, error) { | |||
| 506 | + | return &browserWindow{id: "window-1", tabs: entity.NewTabList()}, nil | |||
| 487 | } | 507 | } | ||
| 508 | + | app.workspaceViewCreateOverride = func(_ context.Context, tab *entity.Tab) bool { | |||
| 509 | + | app.workspaceViews[tab.ID] = &component.WorkspaceView{} | |||
| 510 | + | return true | |||
| 511 | + | } | |||
| 512 | + | ||||
| 513 | + | if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil { | |||
| 514 | + | t.Fatalf("OpenFreshWindow returned error: %v", err) | |||
| 515 | + | } | |||
| 516 | + | ||||
| 517 | + | windowTabs := app.browserWindows["window-1"].tabs | |||
| 518 | + | require.NotNil(t, windowTabs) | |||
| 519 | + | require.Equal(t, 1, app.tabs.Count()) | |||
| 520 | + | require.Equal(t, 1, windowTabs.Count()) | |||
| 521 | + | tab := windowTabs.ActiveTab() | |||
| 522 | + | require.NotNil(t, tab) | |||
| 523 | + | require.NotNil(t, tab.Workspace) | |||
| 524 | + | activePane := tab.Workspace.FindPane(tab.Workspace.ActivePaneID) | |||
| 525 | + | require.NotNil(t, activePane) | |||
| 526 | + | require.Equal(t, "https://example.com", activePane.Pane.URI) | |||
| 527 | + | require.NotNil(t, app.tabs.Find(tab.ID)) | |||
| 528 | + | require.Same(t, app.browserWindows["window-1"], app.browserWindowForTab(tab.ID)) | |||
| 529 | + | } | |||
| 530 | + | ||||
| 531 | + | func TestCreateInitialTabSkipsFocusedWindowThatAlreadyHasTabs(t *testing.T) { | |||
| 532 | + | ctx := context.Background() | |||
| 533 | + | existing := entity.NewTab(entity.TabID("existing-tab"), entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane"))) | |||
| 534 | + | windowTabs := entity.NewTabList() | |||
| 535 | + | windowTabs.Add(existing) | |||
| 536 | + | windowTabs.SetActive(existing.ID) | |||
| 537 | + | bw := &browserWindow{id: "window-1", tabs: windowTabs} | |||
| 538 | + | app := &App{ | |||
| 539 | + | runtimeConfig: runtimeConfigStateForTest(config.DefaultConfig()), | |||
| 540 | + | tabs: entity.NewTabList(), | |||
| 541 | + | tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil), | |||
| 542 | + | browserWindows: map[string]*browserWindow{bw.id: bw}, | |||
| 543 | + | lastFocusedWindowID: bw.id, | |||
| 544 | + | workspaceViews: map[entity.TabID]*component.WorkspaceView{}, | |||
| 545 | + | windowForTab: map[entity.TabID]*browserWindow{existing.ID: bw}, | |||
| 546 | + | } | |||
| 547 | + | app.tabs.Add(existing) | |||
| 548 | + | app.initTabCoordinator(ctx) | |||
| 549 | + | ||||
| 550 | + | app.createInitialTab(ctx) | |||
| 551 | + | ||||
| 552 | + | require.Equal(t, 1, windowTabs.Count()) | |||
| 553 | + | require.NotNil(t, windowTabs.Find(existing.ID)) | |||
| 554 | + | require.Equal(t, 1, app.tabs.Count()) | |||
| 555 | + | require.NotNil(t, app.tabs.Find(existing.ID)) | |||
| 488 | } | 556 | } | ||
| 489 | 557 | ||||
| 490 | func TestOpenFreshWindow_PropagatesFactoryError(t *testing.T) { | 558 | func TestOpenFreshWindow_PropagatesFactoryError(t *testing.T) { | ||