Isolate tab snapshots
- commit
- 3183bf0fd76a31b6a5b743b476bf3adaed50b5ad
- author
- brice · · 2026-05-05 17:53 UTC
- parent
- 0747243
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +163 -15
M internal/domain/entity/session_state_test.go +53 -0
| @@ -262,6 +262,59 @@ func TestTabList_ReplaceFrom(t *testing.T) { | |||
| 262 | 262 | assert.Equal(t, "https://new1.com", ref.Tabs[0].Workspace.Root.Pane.URI) | |
| 263 | 263 | } | |
| 264 | 264 | ||
| 265 | + | func TestTabList_SnapshotClonesTabsAndWorkspace(t *testing.T) { | |
| 266 | + | parentPaneID := entity.PaneID("parent-pane") | |
| 267 | + | pane := entity.NewPane("pane-1") | |
| 268 | + | pane.URI = "https://original.example" | |
| 269 | + | pane.ParentPaneID = &parentPaneID | |
| 270 | + | tab := entity.NewTab("tab-1", "workspace-1", pane) | |
| 271 | + | tab.Name = "Original" | |
| 272 | + | tabs := entity.NewTabList() | |
| 273 | + | tabs.Add(tab) | |
| 274 | + | ||
| 275 | + | snapshot := tabs.Snapshot() | |
| 276 | + | ||
| 277 | + | tab.Position = 99 | |
| 278 | + | tab.Name = "Mutated" | |
| 279 | + | tab.Workspace.Root.Pane.URI = "https://mutated.example" | |
| 280 | + | *tab.Workspace.Root.Pane.ParentPaneID = entity.PaneID("mutated-parent") | |
| 281 | + | ||
| 282 | + | require.Len(t, snapshot.Tabs, 1) | |
| 283 | + | clonedTab := snapshot.Tabs[0] | |
| 284 | + | assert.NotSame(t, tab, clonedTab) | |
| 285 | + | assert.Equal(t, 0, clonedTab.Position) | |
| 286 | + | assert.Equal(t, "Original", clonedTab.Name) | |
| 287 | + | require.NotNil(t, clonedTab.Workspace) | |
| 288 | + | assert.NotSame(t, tab.Workspace, clonedTab.Workspace) | |
| 289 | + | require.NotNil(t, clonedTab.Workspace.Root) | |
| 290 | + | assert.NotSame(t, tab.Workspace.Root, clonedTab.Workspace.Root) | |
| 291 | + | require.NotNil(t, clonedTab.Workspace.Root.Pane) | |
| 292 | + | assert.NotSame(t, tab.Workspace.Root.Pane, clonedTab.Workspace.Root.Pane) | |
| 293 | + | assert.Equal(t, "https://original.example", clonedTab.Workspace.Root.Pane.URI) | |
| 294 | + | require.NotNil(t, clonedTab.Workspace.Root.Pane.ParentPaneID) | |
| 295 | + | assert.Equal(t, entity.PaneID("parent-pane"), *clonedTab.Workspace.Root.Pane.ParentPaneID) | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | func TestTabList_ReplaceFromClonesSnapshotSource(t *testing.T) { | |
| 299 | + | pane := entity.NewPane("pane-1") | |
| 300 | + | pane.URI = "https://original.example" | |
| 301 | + | tab := entity.NewTab("tab-1", "workspace-1", pane) | |
| 302 | + | other := entity.NewTabList() | |
| 303 | + | other.Add(tab) | |
| 304 | + | ||
| 305 | + | original := entity.NewTabList() | |
| 306 | + | original.ReplaceFrom(other) | |
| 307 | + | ||
| 308 | + | tab.Name = "Mutated" | |
| 309 | + | tab.Workspace.Root.Pane.URI = "https://mutated.example" | |
| 310 | + | ||
| 311 | + | restored := original.Find("tab-1") | |
| 312 | + | require.NotNil(t, restored) | |
| 313 | + | assert.NotSame(t, tab, restored) | |
| 314 | + | assert.Empty(t, restored.Name) | |
| 315 | + | assert.Equal(t, "https://original.example", restored.Workspace.Root.Pane.URI) | |
| 316 | + | } | |
| 317 | + | ||
| 265 | 318 | func TestTabList_ReplaceFrom_Nil(t *testing.T) { | |
| 266 | 319 | original := entity.NewTabList() | |
| 267 | 320 | pane := entity.NewPane("pane1") | |
| @@ -262,6 +262,59 @@ func TestTabList_ReplaceFrom(t *testing.T) { | |||||
| 262 | assert.Equal(t, "https://new1.com", ref.Tabs[0].Workspace.Root.Pane.URI) | 262 | assert.Equal(t, "https://new1.com", ref.Tabs[0].Workspace.Root.Pane.URI) | ||
| 263 | } | 263 | } | ||
| 264 | 264 | ||||
| 265 | + | func TestTabList_SnapshotClonesTabsAndWorkspace(t *testing.T) { | |||
| 266 | + | parentPaneID := entity.PaneID("parent-pane") | |||
| 267 | + | pane := entity.NewPane("pane-1") | |||
| 268 | + | pane.URI = "https://original.example" | |||
| 269 | + | pane.ParentPaneID = &parentPaneID | |||
| 270 | + | tab := entity.NewTab("tab-1", "workspace-1", pane) | |||
| 271 | + | tab.Name = "Original" | |||
| 272 | + | tabs := entity.NewTabList() | |||
| 273 | + | tabs.Add(tab) | |||
| 274 | + | ||||
| 275 | + | snapshot := tabs.Snapshot() | |||
| 276 | + | ||||
| 277 | + | tab.Position = 99 | |||
| 278 | + | tab.Name = "Mutated" | |||
| 279 | + | tab.Workspace.Root.Pane.URI = "https://mutated.example" | |||
| 280 | + | *tab.Workspace.Root.Pane.ParentPaneID = entity.PaneID("mutated-parent") | |||
| 281 | + | ||||
| 282 | + | require.Len(t, snapshot.Tabs, 1) | |||
| 283 | + | clonedTab := snapshot.Tabs[0] | |||
| 284 | + | assert.NotSame(t, tab, clonedTab) | |||
| 285 | + | assert.Equal(t, 0, clonedTab.Position) | |||
| 286 | + | assert.Equal(t, "Original", clonedTab.Name) | |||
| 287 | + | require.NotNil(t, clonedTab.Workspace) | |||
| 288 | + | assert.NotSame(t, tab.Workspace, clonedTab.Workspace) | |||
| 289 | + | require.NotNil(t, clonedTab.Workspace.Root) | |||
| 290 | + | assert.NotSame(t, tab.Workspace.Root, clonedTab.Workspace.Root) | |||
| 291 | + | require.NotNil(t, clonedTab.Workspace.Root.Pane) | |||
| 292 | + | assert.NotSame(t, tab.Workspace.Root.Pane, clonedTab.Workspace.Root.Pane) | |||
| 293 | + | assert.Equal(t, "https://original.example", clonedTab.Workspace.Root.Pane.URI) | |||
| 294 | + | require.NotNil(t, clonedTab.Workspace.Root.Pane.ParentPaneID) | |||
| 295 | + | assert.Equal(t, entity.PaneID("parent-pane"), *clonedTab.Workspace.Root.Pane.ParentPaneID) | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | func TestTabList_ReplaceFromClonesSnapshotSource(t *testing.T) { | |||
| 299 | + | pane := entity.NewPane("pane-1") | |||
| 300 | + | pane.URI = "https://original.example" | |||
| 301 | + | tab := entity.NewTab("tab-1", "workspace-1", pane) | |||
| 302 | + | other := entity.NewTabList() | |||
| 303 | + | other.Add(tab) | |||
| 304 | + | ||||
| 305 | + | original := entity.NewTabList() | |||
| 306 | + | original.ReplaceFrom(other) | |||
| 307 | + | ||||
| 308 | + | tab.Name = "Mutated" | |||
| 309 | + | tab.Workspace.Root.Pane.URI = "https://mutated.example" | |||
| 310 | + | ||||
| 311 | + | restored := original.Find("tab-1") | |||
| 312 | + | require.NotNil(t, restored) | |||
| 313 | + | assert.NotSame(t, tab, restored) | |||
| 314 | + | assert.Empty(t, restored.Name) | |||
| 315 | + | assert.Equal(t, "https://original.example", restored.Workspace.Root.Pane.URI) | |||
| 316 | + | } | |||
| 317 | + | ||||
| 265 | func TestTabList_ReplaceFrom_Nil(t *testing.T) { | 318 | func TestTabList_ReplaceFrom_Nil(t *testing.T) { | ||
| 266 | original := entity.NewTabList() | 319 | original := entity.NewTabList() | ||
| 267 | pane := entity.NewPane("pane1") | 320 | pane := entity.NewPane("pane1") | ||
M internal/domain/entity/tab.go +62 -7
| @@ -111,6 +111,10 @@ func (tl *TabList) Find(id TabID) *Tab { | |||
| 111 | 111 | tl.mu.RLock() | |
| 112 | 112 | defer tl.mu.RUnlock() | |
| 113 | 113 | ||
| 114 | + | return tl.findNoLock(id) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func (tl *TabList) findNoLock(id TabID) *Tab { | |
| 114 | 118 | for _, tab := range tl.Tabs { | |
| 115 | 119 | if tab.ID == id { | |
| 116 | 120 | return tab | |
| @@ -121,7 +125,10 @@ func (tl *TabList) Find(id TabID) *Tab { | |||
| 121 | 125 | ||
| 122 | 126 | // ActiveTab returns the currently active tab. | |
| 123 | 127 | func (tl *TabList) ActiveTab() *Tab { | |
| 124 | - | return tl.Find(tl.ActiveTabID) | |
| 128 | + | tl.mu.RLock() | |
| 129 | + | defer tl.mu.RUnlock() | |
| 130 | + | ||
| 131 | + | return tl.findNoLock(tl.ActiveTabID) | |
| 125 | 132 | } | |
| 126 | 133 | ||
| 127 | 134 | // Count returns the number of tabs. | |
| @@ -185,10 +192,8 @@ func (tl *TabList) Move(id TabID, newPos int) bool { | |||
| 185 | 192 | return true | |
| 186 | 193 | } | |
| 187 | 194 | ||
| 188 | - | // Snapshot returns a shallow copy of the TabList safe for concurrent read. | |
| 189 | - | // The returned TabList shares the underlying Tab pointers but has its own | |
| 190 | - | // slice header and scalar fields, protecting against races from concurrent | |
| 191 | - | // structural mutations (add/remove) and ActiveTabID changes on the original. | |
| 195 | + | // Snapshot returns an isolated copy of the TabList safe for concurrent read. | |
| 196 | + | // The returned TabList has its own slice, Tab values, and workspace tree. | |
| 192 | 197 | func (tl *TabList) Snapshot() *TabList { | |
| 193 | 198 | if tl == nil { | |
| 194 | 199 | return nil | |
| @@ -196,8 +201,10 @@ func (tl *TabList) Snapshot() *TabList { | |||
| 196 | 201 | tl.mu.RLock() | |
| 197 | 202 | defer tl.mu.RUnlock() | |
| 198 | 203 | ||
| 199 | - | tabs := make([]*Tab, len(tl.Tabs)) | |
| 200 | - | copy(tabs, tl.Tabs) | |
| 204 | + | tabs := make([]*Tab, 0, len(tl.Tabs)) | |
| 205 | + | for _, tab := range tl.Tabs { | |
| 206 | + | tabs = append(tabs, cloneTab(tab)) | |
| 207 | + | } | |
| 201 | 208 | return &TabList{ | |
| 202 | 209 | Tabs: tabs, | |
| 203 | 210 | ActiveTabID: tl.ActiveTabID, | |
| @@ -205,6 +212,54 @@ func (tl *TabList) Snapshot() *TabList { | |||
| 205 | 212 | } | |
| 206 | 213 | } | |
| 207 | 214 | ||
| 215 | + | func cloneTab(tab *Tab) *Tab { | |
| 216 | + | if tab == nil { | |
| 217 | + | return nil | |
| 218 | + | } | |
| 219 | + | cloned := *tab | |
| 220 | + | cloned.Workspace = cloneWorkspace(tab.Workspace) | |
| 221 | + | return &cloned | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | func cloneWorkspace(workspace *Workspace) *Workspace { | |
| 225 | + | if workspace == nil { | |
| 226 | + | return nil | |
| 227 | + | } | |
| 228 | + | cloned := *workspace | |
| 229 | + | cloned.Root = clonePaneNode(workspace.Root, nil) | |
| 230 | + | return &cloned | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func clonePaneNode(node, parent *PaneNode) *PaneNode { | |
| 234 | + | if node == nil { | |
| 235 | + | return nil | |
| 236 | + | } | |
| 237 | + | cloned := *node | |
| 238 | + | cloned.Parent = parent | |
| 239 | + | cloned.Pane = clonePane(node.Pane) | |
| 240 | + | if len(node.Children) > 0 { | |
| 241 | + | cloned.Children = make([]*PaneNode, 0, len(node.Children)) | |
| 242 | + | for _, child := range node.Children { | |
| 243 | + | cloned.Children = append(cloned.Children, clonePaneNode(child, &cloned)) | |
| 244 | + | } | |
| 245 | + | } else { | |
| 246 | + | cloned.Children = nil | |
| 247 | + | } | |
| 248 | + | return &cloned | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func clonePane(pane *Pane) *Pane { | |
| 252 | + | if pane == nil { | |
| 253 | + | return nil | |
| 254 | + | } | |
| 255 | + | cloned := *pane | |
| 256 | + | if pane.ParentPaneID != nil { | |
| 257 | + | parentPaneID := *pane.ParentPaneID | |
| 258 | + | cloned.ParentPaneID = &parentPaneID | |
| 259 | + | } | |
| 260 | + | return &cloned | |
| 261 | + | } | |
| 262 | + | ||
| 208 | 263 | // ReplaceFrom replaces this TabList's contents with those from another TabList. | |
| 209 | 264 | // This modifies in-place so existing references to this TabList remain valid. | |
| 210 | 265 | func (tl *TabList) ReplaceFrom(other *TabList) { | |
| @@ -111,6 +111,10 @@ func (tl *TabList) Find(id TabID) *Tab { | |||||
| 111 | tl.mu.RLock() | 111 | tl.mu.RLock() | ||
| 112 | defer tl.mu.RUnlock() | 112 | defer tl.mu.RUnlock() | ||
| 113 | 113 | ||||
| 114 | + | return tl.findNoLock(id) | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func (tl *TabList) findNoLock(id TabID) *Tab { | |||
| 114 | for _, tab := range tl.Tabs { | 118 | for _, tab := range tl.Tabs { | ||
| 115 | if tab.ID == id { | 119 | if tab.ID == id { | ||
| 116 | return tab | 120 | return tab | ||
| @@ -121,7 +125,10 @@ func (tl *TabList) Find(id TabID) *Tab { | |||||
| 121 | 125 | ||||
| 122 | // ActiveTab returns the currently active tab. | 126 | // ActiveTab returns the currently active tab. | ||
| 123 | func (tl *TabList) ActiveTab() *Tab { | 127 | func (tl *TabList) ActiveTab() *Tab { | ||
| 124 | - | return tl.Find(tl.ActiveTabID) | 128 | + | tl.mu.RLock() |
| 129 | + | defer tl.mu.RUnlock() | |||
| 130 | + | ||||
| 131 | + | return tl.findNoLock(tl.ActiveTabID) | |||
| 125 | } | 132 | } | ||
| 126 | 133 | ||||
| 127 | // Count returns the number of tabs. | 134 | // Count returns the number of tabs. | ||
| @@ -185,10 +192,8 @@ func (tl *TabList) Move(id TabID, newPos int) bool { | |||||
| 185 | return true | 192 | return true | ||
| 186 | } | 193 | } | ||
| 187 | 194 | ||||
| 188 | - | // Snapshot returns a shallow copy of the TabList safe for concurrent read. | 195 | + | // Snapshot returns an isolated copy of the TabList safe for concurrent read. |
| 189 | - | // The returned TabList shares the underlying Tab pointers but has its own | 196 | + | // The returned TabList has its own slice, Tab values, and workspace tree. |
| 190 | - | // slice header and scalar fields, protecting against races from concurrent | |||
| 191 | - | // structural mutations (add/remove) and ActiveTabID changes on the original. | |||
| 192 | func (tl *TabList) Snapshot() *TabList { | 197 | func (tl *TabList) Snapshot() *TabList { | ||
| 193 | if tl == nil { | 198 | if tl == nil { | ||
| 194 | return nil | 199 | return nil | ||
| @@ -196,8 +201,10 @@ func (tl *TabList) Snapshot() *TabList { | |||||
| 196 | tl.mu.RLock() | 201 | tl.mu.RLock() | ||
| 197 | defer tl.mu.RUnlock() | 202 | defer tl.mu.RUnlock() | ||
| 198 | 203 | ||||
| 199 | - | tabs := make([]*Tab, len(tl.Tabs)) | 204 | + | tabs := make([]*Tab, 0, len(tl.Tabs)) |
| 200 | - | copy(tabs, tl.Tabs) | 205 | + | for _, tab := range tl.Tabs { |
| 206 | + | tabs = append(tabs, cloneTab(tab)) | |||
| 207 | + | } | |||
| 201 | return &TabList{ | 208 | return &TabList{ | ||
| 202 | Tabs: tabs, | 209 | Tabs: tabs, | ||
| 203 | ActiveTabID: tl.ActiveTabID, | 210 | ActiveTabID: tl.ActiveTabID, | ||
| @@ -205,6 +212,54 @@ func (tl *TabList) Snapshot() *TabList { | |||||
| 205 | } | 212 | } | ||
| 206 | } | 213 | } | ||
| 207 | 214 | ||||
| 215 | + | func cloneTab(tab *Tab) *Tab { | |||
| 216 | + | if tab == nil { | |||
| 217 | + | return nil | |||
| 218 | + | } | |||
| 219 | + | cloned := *tab | |||
| 220 | + | cloned.Workspace = cloneWorkspace(tab.Workspace) | |||
| 221 | + | return &cloned | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | func cloneWorkspace(workspace *Workspace) *Workspace { | |||
| 225 | + | if workspace == nil { | |||
| 226 | + | return nil | |||
| 227 | + | } | |||
| 228 | + | cloned := *workspace | |||
| 229 | + | cloned.Root = clonePaneNode(workspace.Root, nil) | |||
| 230 | + | return &cloned | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | func clonePaneNode(node, parent *PaneNode) *PaneNode { | |||
| 234 | + | if node == nil { | |||
| 235 | + | return nil | |||
| 236 | + | } | |||
| 237 | + | cloned := *node | |||
| 238 | + | cloned.Parent = parent | |||
| 239 | + | cloned.Pane = clonePane(node.Pane) | |||
| 240 | + | if len(node.Children) > 0 { | |||
| 241 | + | cloned.Children = make([]*PaneNode, 0, len(node.Children)) | |||
| 242 | + | for _, child := range node.Children { | |||
| 243 | + | cloned.Children = append(cloned.Children, clonePaneNode(child, &cloned)) | |||
| 244 | + | } | |||
| 245 | + | } else { | |||
| 246 | + | cloned.Children = nil | |||
| 247 | + | } | |||
| 248 | + | return &cloned | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | func clonePane(pane *Pane) *Pane { | |||
| 252 | + | if pane == nil { | |||
| 253 | + | return nil | |||
| 254 | + | } | |||
| 255 | + | cloned := *pane | |||
| 256 | + | if pane.ParentPaneID != nil { | |||
| 257 | + | parentPaneID := *pane.ParentPaneID | |||
| 258 | + | cloned.ParentPaneID = &parentPaneID | |||
| 259 | + | } | |||
| 260 | + | return &cloned | |||
| 261 | + | } | |||
| 262 | + | ||||
| 208 | // ReplaceFrom replaces this TabList's contents with those from another TabList. | 263 | // ReplaceFrom replaces this TabList's contents with those from another TabList. | ||
| 209 | // This modifies in-place so existing references to this TabList remain valid. | 264 | // This modifies in-place so existing references to this TabList remain valid. | ||
| 210 | func (tl *TabList) ReplaceFrom(other *TabList) { | 265 | func (tl *TabList) ReplaceFrom(other *TabList) { | ||
M internal/ui/coordinator/navigation_webview_targeted_test.go +34 -0
| @@ -61,12 +61,18 @@ func TestNavigationCoordinator_WebViewTargetedActionsUseProvidedWebView(t *testi | |||
| 61 | 61 | t.Run("OpenDevToolsWebView with nil webview returns error", func(t *testing.T) { | |
| 62 | 62 | testOpenDevToolsWebViewNilWebView(t, ctx) | |
| 63 | 63 | }) | |
| 64 | + | t.Run("OpenDevToolsWebView unsupported capability returns error", func(t *testing.T) { | |
| 65 | + | testOpenDevToolsWebViewUnsupportedCapability(t, ctx) | |
| 66 | + | }) | |
| 64 | 67 | t.Run("OpenDevToolsWebView calls OpenDevTools once", func(t *testing.T) { | |
| 65 | 68 | testOpenDevToolsWebViewTargetsProvidedWebView(t, ctx) | |
| 66 | 69 | }) | |
| 67 | 70 | t.Run("PrintWebView with nil webview returns error", func(t *testing.T) { | |
| 68 | 71 | testPrintWebViewNilWebView(t, ctx) | |
| 69 | 72 | }) | |
| 73 | + | t.Run("PrintWebView unsupported capability returns error", func(t *testing.T) { | |
| 74 | + | testPrintWebViewUnsupportedCapability(t, ctx) | |
| 75 | + | }) | |
| 70 | 76 | t.Run("PrintWebView calls PrintPage once", func(t *testing.T) { | |
| 71 | 77 | testPrintWebViewTargetsProvidedWebView(t, ctx) | |
| 72 | 78 | }) | |
| @@ -198,6 +204,20 @@ func testOpenDevToolsWebViewNilWebView(t *testing.T, ctx context.Context) { | |||
| 198 | 204 | } | |
| 199 | 205 | } | |
| 200 | 206 | ||
| 207 | + | func testOpenDevToolsWebViewUnsupportedCapability(t *testing.T, ctx context.Context) { | |
| 208 | + | t.Helper() | |
| 209 | + | wv := mocks.NewMockWebView(t) | |
| 210 | + | wv.EXPECT().ID().Return(port.WebViewID(1)).Once() | |
| 211 | + | c := &NavigationCoordinator{} | |
| 212 | + | err := c.OpenDevToolsWebView(ctx, wv) | |
| 213 | + | if err == nil { | |
| 214 | + | t.Fatal("expected unsupported devtools error, got nil") | |
| 215 | + | } | |
| 216 | + | if got, want := err.Error(), "webview does not support devtools"; got != want { | |
| 217 | + | t.Fatalf("error = %q, want %q", got, want) | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | ||
| 201 | 221 | func testOpenDevToolsWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) { | |
| 202 | 222 | t.Helper() | |
| 203 | 223 | base := mocks.NewMockWebView(t) | |
| @@ -221,6 +241,20 @@ func testPrintWebViewNilWebView(t *testing.T, ctx context.Context) { | |||
| 221 | 241 | } | |
| 222 | 242 | } | |
| 223 | 243 | ||
| 244 | + | func testPrintWebViewUnsupportedCapability(t *testing.T, ctx context.Context) { | |
| 245 | + | t.Helper() | |
| 246 | + | wv := mocks.NewMockWebView(t) | |
| 247 | + | wv.EXPECT().ID().Return(port.WebViewID(1)).Once() | |
| 248 | + | c := &NavigationCoordinator{} | |
| 249 | + | err := c.PrintWebView(ctx, wv) | |
| 250 | + | if err == nil { | |
| 251 | + | t.Fatal("expected unsupported printing error, got nil") | |
| 252 | + | } | |
| 253 | + | if got, want := err.Error(), "webview does not support printing"; got != want { | |
| 254 | + | t.Fatalf("error = %q, want %q", got, want) | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | ||
| 224 | 258 | func testPrintWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) { | |
| 225 | 259 | t.Helper() | |
| 226 | 260 | base := mocks.NewMockWebView(t) | |
| @@ -61,12 +61,18 @@ func TestNavigationCoordinator_WebViewTargetedActionsUseProvidedWebView(t *testi | |||||
| 61 | t.Run("OpenDevToolsWebView with nil webview returns error", func(t *testing.T) { | 61 | t.Run("OpenDevToolsWebView with nil webview returns error", func(t *testing.T) { | ||
| 62 | testOpenDevToolsWebViewNilWebView(t, ctx) | 62 | testOpenDevToolsWebViewNilWebView(t, ctx) | ||
| 63 | }) | 63 | }) | ||
| 64 | + | t.Run("OpenDevToolsWebView unsupported capability returns error", func(t *testing.T) { | |||
| 65 | + | testOpenDevToolsWebViewUnsupportedCapability(t, ctx) | |||
| 66 | + | }) | |||
| 64 | t.Run("OpenDevToolsWebView calls OpenDevTools once", func(t *testing.T) { | 67 | t.Run("OpenDevToolsWebView calls OpenDevTools once", func(t *testing.T) { | ||
| 65 | testOpenDevToolsWebViewTargetsProvidedWebView(t, ctx) | 68 | testOpenDevToolsWebViewTargetsProvidedWebView(t, ctx) | ||
| 66 | }) | 69 | }) | ||
| 67 | t.Run("PrintWebView with nil webview returns error", func(t *testing.T) { | 70 | t.Run("PrintWebView with nil webview returns error", func(t *testing.T) { | ||
| 68 | testPrintWebViewNilWebView(t, ctx) | 71 | testPrintWebViewNilWebView(t, ctx) | ||
| 69 | }) | 72 | }) | ||
| 73 | + | t.Run("PrintWebView unsupported capability returns error", func(t *testing.T) { | |||
| 74 | + | testPrintWebViewUnsupportedCapability(t, ctx) | |||
| 75 | + | }) | |||
| 70 | t.Run("PrintWebView calls PrintPage once", func(t *testing.T) { | 76 | t.Run("PrintWebView calls PrintPage once", func(t *testing.T) { | ||
| 71 | testPrintWebViewTargetsProvidedWebView(t, ctx) | 77 | testPrintWebViewTargetsProvidedWebView(t, ctx) | ||
| 72 | }) | 78 | }) | ||
| @@ -198,6 +204,20 @@ func testOpenDevToolsWebViewNilWebView(t *testing.T, ctx context.Context) { | |||||
| 198 | } | 204 | } | ||
| 199 | } | 205 | } | ||
| 200 | 206 | ||||
| 207 | + | func testOpenDevToolsWebViewUnsupportedCapability(t *testing.T, ctx context.Context) { | |||
| 208 | + | t.Helper() | |||
| 209 | + | wv := mocks.NewMockWebView(t) | |||
| 210 | + | wv.EXPECT().ID().Return(port.WebViewID(1)).Once() | |||
| 211 | + | c := &NavigationCoordinator{} | |||
| 212 | + | err := c.OpenDevToolsWebView(ctx, wv) | |||
| 213 | + | if err == nil { | |||
| 214 | + | t.Fatal("expected unsupported devtools error, got nil") | |||
| 215 | + | } | |||
| 216 | + | if got, want := err.Error(), "webview does not support devtools"; got != want { | |||
| 217 | + | t.Fatalf("error = %q, want %q", got, want) | |||
| 218 | + | } | |||
| 219 | + | } | |||
| 220 | + | ||||
| 201 | func testOpenDevToolsWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) { | 221 | func testOpenDevToolsWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) { | ||
| 202 | t.Helper() | 222 | t.Helper() | ||
| 203 | base := mocks.NewMockWebView(t) | 223 | base := mocks.NewMockWebView(t) | ||
| @@ -221,6 +241,20 @@ func testPrintWebViewNilWebView(t *testing.T, ctx context.Context) { | |||||
| 221 | } | 241 | } | ||
| 222 | } | 242 | } | ||
| 223 | 243 | ||||
| 244 | + | func testPrintWebViewUnsupportedCapability(t *testing.T, ctx context.Context) { | |||
| 245 | + | t.Helper() | |||
| 246 | + | wv := mocks.NewMockWebView(t) | |||
| 247 | + | wv.EXPECT().ID().Return(port.WebViewID(1)).Once() | |||
| 248 | + | c := &NavigationCoordinator{} | |||
| 249 | + | err := c.PrintWebView(ctx, wv) | |||
| 250 | + | if err == nil { | |||
| 251 | + | t.Fatal("expected unsupported printing error, got nil") | |||
| 252 | + | } | |||
| 253 | + | if got, want := err.Error(), "webview does not support printing"; got != want { | |||
| 254 | + | t.Fatalf("error = %q, want %q", got, want) | |||
| 255 | + | } | |||
| 256 | + | } | |||
| 257 | + | ||||
| 224 | func testPrintWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) { | 258 | func testPrintWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) { | ||
| 225 | t.Helper() | 259 | t.Helper() | ||
| 226 | base := mocks.NewMockWebView(t) | 260 | base := mocks.NewMockWebView(t) | ||
M internal/ui/theme/css_test.go +14 -8
| @@ -188,16 +188,12 @@ func TestGenerateCSSWithScale_UsesMediumWeightForOmniboxSuggestionTitle(t *testi | |||
| 188 | 188 | func TestGenerateCSS_ContentAreaTabBarInset(t *testing.T) { | |
| 189 | 189 | css := GenerateCSS(DefaultDarkPalette()) | |
| 190 | 190 | ||
| 191 | - | if !strings.Contains(css, ".content-area-tabbar-inset-top") { | |
| 192 | - | t.Fatal("expected .content-area-tabbar-inset-top selector in generated CSS") | |
| 193 | - | } | |
| 194 | - | if !strings.Contains(css, "padding-top: 2em;") { | |
| 191 | + | topBlock := cssRuleBlock(t, css, ".content-area-tabbar-inset-top") | |
| 192 | + | if !strings.Contains(topBlock, "padding-top: 2em;") { | |
| 195 | 193 | t.Fatal("expected padding-top: 2em in .content-area-tabbar-inset-top block") | |
| 196 | 194 | } | |
| 197 | - | if !strings.Contains(css, ".content-area-tabbar-inset-bottom") { | |
| 198 | - | t.Fatal("expected .content-area-tabbar-inset-bottom selector in generated CSS") | |
| 199 | - | } | |
| 200 | - | if !strings.Contains(css, "padding-bottom: 2em;") { | |
| 195 | + | bottomBlock := cssRuleBlock(t, css, ".content-area-tabbar-inset-bottom") | |
| 196 | + | if !strings.Contains(bottomBlock, "padding-bottom: 2em;") { | |
| 201 | 197 | t.Fatal("expected padding-bottom: 2em in .content-area-tabbar-inset-bottom block") | |
| 202 | 198 | } | |
| 203 | 199 | if !strings.Contains(css, ".tab-bar") { | |
| @@ -208,6 +204,16 @@ func TestGenerateCSS_ContentAreaTabBarInset(t *testing.T) { | |||
| 208 | 204 | } | |
| 209 | 205 | } | |
| 210 | 206 | ||
| 207 | + | func cssRuleBlock(t *testing.T, css, selector string) string { | |
| 208 | + | t.Helper() | |
| 209 | + | re := regexp.MustCompile(regexp.QuoteMeta(selector) + `\s*\{([^}]*)\}`) | |
| 210 | + | matches := re.FindStringSubmatch(css) | |
| 211 | + | if len(matches) != 2 { | |
| 212 | + | t.Fatalf("expected %s selector in generated CSS", selector) | |
| 213 | + | } | |
| 214 | + | return matches[1] | |
| 215 | + | } | |
| 216 | + | ||
| 211 | 217 | func TestGenerateCSS_DoesNotEmitUnsupportedGTKProperties(t *testing.T) { | |
| 212 | 218 | css := GenerateCSS(DefaultDarkPalette()) | |
| 213 | 219 | ||
| @@ -188,16 +188,12 @@ func TestGenerateCSSWithScale_UsesMediumWeightForOmniboxSuggestionTitle(t *testi | |||||
| 188 | func TestGenerateCSS_ContentAreaTabBarInset(t *testing.T) { | 188 | func TestGenerateCSS_ContentAreaTabBarInset(t *testing.T) { | ||
| 189 | css := GenerateCSS(DefaultDarkPalette()) | 189 | css := GenerateCSS(DefaultDarkPalette()) | ||
| 190 | 190 | ||||
| 191 | - | if !strings.Contains(css, ".content-area-tabbar-inset-top") { | 191 | + | topBlock := cssRuleBlock(t, css, ".content-area-tabbar-inset-top") |
| 192 | - | t.Fatal("expected .content-area-tabbar-inset-top selector in generated CSS") | 192 | + | if !strings.Contains(topBlock, "padding-top: 2em;") { |
| 193 | - | } | |||
| 194 | - | if !strings.Contains(css, "padding-top: 2em;") { | |||
| 195 | t.Fatal("expected padding-top: 2em in .content-area-tabbar-inset-top block") | 193 | t.Fatal("expected padding-top: 2em in .content-area-tabbar-inset-top block") | ||
| 196 | } | 194 | } | ||
| 197 | - | if !strings.Contains(css, ".content-area-tabbar-inset-bottom") { | 195 | + | bottomBlock := cssRuleBlock(t, css, ".content-area-tabbar-inset-bottom") |
| 198 | - | t.Fatal("expected .content-area-tabbar-inset-bottom selector in generated CSS") | 196 | + | if !strings.Contains(bottomBlock, "padding-bottom: 2em;") { |
| 199 | - | } | |||
| 200 | - | if !strings.Contains(css, "padding-bottom: 2em;") { | |||
| 201 | t.Fatal("expected padding-bottom: 2em in .content-area-tabbar-inset-bottom block") | 197 | t.Fatal("expected padding-bottom: 2em in .content-area-tabbar-inset-bottom block") | ||
| 202 | } | 198 | } | ||
| 203 | if !strings.Contains(css, ".tab-bar") { | 199 | if !strings.Contains(css, ".tab-bar") { | ||
| @@ -208,6 +204,16 @@ func TestGenerateCSS_ContentAreaTabBarInset(t *testing.T) { | |||||
| 208 | } | 204 | } | ||
| 209 | } | 205 | } | ||
| 210 | 206 | ||||
| 207 | + | func cssRuleBlock(t *testing.T, css, selector string) string { | |||
| 208 | + | t.Helper() | |||
| 209 | + | re := regexp.MustCompile(regexp.QuoteMeta(selector) + `\s*\{([^}]*)\}`) | |||
| 210 | + | matches := re.FindStringSubmatch(css) | |||
| 211 | + | if len(matches) != 2 { | |||
| 212 | + | t.Fatalf("expected %s selector in generated CSS", selector) | |||
| 213 | + | } | |||
| 214 | + | return matches[1] | |||
| 215 | + | } | |||
| 216 | + | ||||
| 211 | func TestGenerateCSS_DoesNotEmitUnsupportedGTKProperties(t *testing.T) { | 217 | func TestGenerateCSS_DoesNotEmitUnsupportedGTKProperties(t *testing.T) { | ||
| 212 | css := GenerateCSS(DefaultDarkPalette()) | 218 | css := GenerateCSS(DefaultDarkPalette()) | ||
| 213 | 219 | ||||