chore(finalizer): tighten clipboard cleanup and coverage
- commit
- b4e534751feb40db9d9a2fbc798821074c1e2398
- author
- brice · · 2026-04-18 14:00 UTC
- parent
- 41800e7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +107 -28
M internal/application/usecase/clipboard_text_orchestrator.go +9 -10
| @@ -81,11 +81,7 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate( | |||
| 81 | 81 | } | |
| 82 | 82 | textLen := utf8.RuneCountInString(input.Text) | |
| 83 | 83 | scope := selectionScope(input) | |
| 84 | - | now := uc.now | |
| 85 | - | if now == nil { | |
| 86 | - | now = time.Now | |
| 87 | - | } | |
| 88 | - | currentTime := now() | |
| 84 | + | currentTime := uc.currentTime() | |
| 89 | 85 | ||
| 90 | 86 | uc.mu.Lock() | |
| 91 | 87 | if input.Text == uc.lastSelection[scope] { | |
| @@ -134,11 +130,7 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy( | |||
| 134 | 130 | return nil | |
| 135 | 131 | } | |
| 136 | 132 | ||
| 137 | - | now := uc.now | |
| 138 | - | if now == nil { | |
| 139 | - | now = time.Now | |
| 140 | - | } | |
| 141 | - | currentTime := now() | |
| 133 | + | currentTime := uc.currentTime() | |
| 142 | 134 | textLen := utf8.RuneCountInString(input.Text) | |
| 143 | 135 | scope := explicitScope(input) | |
| 144 | 136 | ||
| @@ -189,3 +181,10 @@ func selectionScope(input dto.SelectionClipboardInput) clipboardScope { | |||
| 189 | 181 | func explicitScope(input dto.ExplicitClipboardInput) clipboardScope { | |
| 190 | 182 | return clipboardScope{source: input.SourceEngine, viewID: input.ViewID} | |
| 191 | 183 | } | |
| 184 | + | ||
| 185 | + | func (uc *ClipboardTextOrchestratorUseCase) currentTime() time.Time { | |
| 186 | + | if uc == nil || uc.now == nil { | |
| 187 | + | return time.Now() | |
| 188 | + | } | |
| 189 | + | return uc.now() | |
| 190 | + | } | |
| @@ -81,11 +81,7 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate( | |||||
| 81 | } | 81 | } | ||
| 82 | textLen := utf8.RuneCountInString(input.Text) | 82 | textLen := utf8.RuneCountInString(input.Text) | ||
| 83 | scope := selectionScope(input) | 83 | scope := selectionScope(input) | ||
| 84 | - | now := uc.now | 84 | + | currentTime := uc.currentTime() |
| 85 | - | if now == nil { | |||
| 86 | - | now = time.Now | |||
| 87 | - | } | |||
| 88 | - | currentTime := now() | |||
| 89 | 85 | ||||
| 90 | uc.mu.Lock() | 86 | uc.mu.Lock() | ||
| 91 | if input.Text == uc.lastSelection[scope] { | 87 | if input.Text == uc.lastSelection[scope] { | ||
| @@ -134,11 +130,7 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy( | |||||
| 134 | return nil | 130 | return nil | ||
| 135 | } | 131 | } | ||
| 136 | 132 | ||||
| 137 | - | now := uc.now | 133 | + | currentTime := uc.currentTime() |
| 138 | - | if now == nil { | |||
| 139 | - | now = time.Now | |||
| 140 | - | } | |||
| 141 | - | currentTime := now() | |||
| 142 | textLen := utf8.RuneCountInString(input.Text) | 134 | textLen := utf8.RuneCountInString(input.Text) | ||
| 143 | scope := explicitScope(input) | 135 | scope := explicitScope(input) | ||
| 144 | 136 | ||||
| @@ -189,3 +181,10 @@ func selectionScope(input dto.SelectionClipboardInput) clipboardScope { | |||||
| 189 | func explicitScope(input dto.ExplicitClipboardInput) clipboardScope { | 181 | func explicitScope(input dto.ExplicitClipboardInput) clipboardScope { | ||
| 190 | return clipboardScope{source: input.SourceEngine, viewID: input.ViewID} | 182 | return clipboardScope{source: input.SourceEngine, viewID: input.ViewID} | ||
| 191 | } | 183 | } | ||
| 184 | + | ||||
| 185 | + | func (uc *ClipboardTextOrchestratorUseCase) currentTime() time.Time { | |||
| 186 | + | if uc == nil || uc.now == nil { | |||
| 187 | + | return time.Now() | |||
| 188 | + | } | |||
| 189 | + | return uc.now() | |||
| 190 | + | } | |||
M internal/application/usecase/clipboard_text_orchestrator_test.go +16 -2
| @@ -375,8 +375,13 @@ func TestClipboardTextOrchestrator_HandleSelectionUpdate_DedupIsAtomic(t *testin | |||
| 375 | 375 | ctx := context.Background() | |
| 376 | 376 | clipboard := newBlockingClipboard() | |
| 377 | 377 | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |
| 378 | - | var toastCalls []int | |
| 378 | + | var ( | |
| 379 | + | toastCalls []int | |
| 380 | + | toastMu sync.Mutex | |
| 381 | + | ) | |
| 379 | 382 | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |
| 383 | + | toastMu.Lock() | |
| 384 | + | defer toastMu.Unlock() | |
| 380 | 385 | toastCalls = append(toastCalls, textLen) | |
| 381 | 386 | }) | |
| 382 | 387 | ||
| @@ -409,6 +414,8 @@ func TestClipboardTextOrchestrator_HandleSelectionUpdate_DedupIsAtomic(t *testin | |||
| 409 | 414 | if got := clipboard.writeCount(); got != 1 { | |
| 410 | 415 | t.Fatalf("clipboard writes = %d, want 1", got) | |
| 411 | 416 | } | |
| 417 | + | toastMu.Lock() | |
| 418 | + | defer toastMu.Unlock() | |
| 412 | 419 | if len(toastCalls) != 1 || toastCalls[0] != 2 { | |
| 413 | 420 | t.Fatalf("toast calls = %v, want [2]", toastCalls) | |
| 414 | 421 | } | |
| @@ -418,8 +425,13 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_DedupIsAtomic(t *testing.T | |||
| 418 | 425 | ctx := context.Background() | |
| 419 | 426 | clipboard := newBlockingClipboard() | |
| 420 | 427 | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |
| 421 | - | var toastCalls []int | |
| 428 | + | var ( | |
| 429 | + | toastCalls []int | |
| 430 | + | toastMu sync.Mutex | |
| 431 | + | ) | |
| 422 | 432 | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |
| 433 | + | toastMu.Lock() | |
| 434 | + | defer toastMu.Unlock() | |
| 423 | 435 | toastCalls = append(toastCalls, textLen) | |
| 424 | 436 | }) | |
| 425 | 437 | uc.now = func() time.Time { return time.Unix(0, 0) } | |
| @@ -453,6 +465,8 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_DedupIsAtomic(t *testing.T | |||
| 453 | 465 | if got := clipboard.writeCount(); got != 1 { | |
| 454 | 466 | t.Fatalf("clipboard writes = %d, want 1", got) | |
| 455 | 467 | } | |
| 468 | + | toastMu.Lock() | |
| 469 | + | defer toastMu.Unlock() | |
| 456 | 470 | if len(toastCalls) != 1 || toastCalls[0] != 2 { | |
| 457 | 471 | t.Fatalf("toast calls = %v, want [2]", toastCalls) | |
| 458 | 472 | } | |
| @@ -375,8 +375,13 @@ func TestClipboardTextOrchestrator_HandleSelectionUpdate_DedupIsAtomic(t *testin | |||||
| 375 | ctx := context.Background() | 375 | ctx := context.Background() | ||
| 376 | clipboard := newBlockingClipboard() | 376 | clipboard := newBlockingClipboard() | ||
| 377 | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | 377 | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | ||
| 378 | - | var toastCalls []int | 378 | + | var ( |
| 379 | + | toastCalls []int | |||
| 380 | + | toastMu sync.Mutex | |||
| 381 | + | ) | |||
| 379 | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | 382 | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | ||
| 383 | + | toastMu.Lock() | |||
| 384 | + | defer toastMu.Unlock() | |||
| 380 | toastCalls = append(toastCalls, textLen) | 385 | toastCalls = append(toastCalls, textLen) | ||
| 381 | }) | 386 | }) | ||
| 382 | 387 | ||||
| @@ -409,6 +414,8 @@ func TestClipboardTextOrchestrator_HandleSelectionUpdate_DedupIsAtomic(t *testin | |||||
| 409 | if got := clipboard.writeCount(); got != 1 { | 414 | if got := clipboard.writeCount(); got != 1 { | ||
| 410 | t.Fatalf("clipboard writes = %d, want 1", got) | 415 | t.Fatalf("clipboard writes = %d, want 1", got) | ||
| 411 | } | 416 | } | ||
| 417 | + | toastMu.Lock() | |||
| 418 | + | defer toastMu.Unlock() | |||
| 412 | if len(toastCalls) != 1 || toastCalls[0] != 2 { | 419 | if len(toastCalls) != 1 || toastCalls[0] != 2 { | ||
| 413 | t.Fatalf("toast calls = %v, want [2]", toastCalls) | 420 | t.Fatalf("toast calls = %v, want [2]", toastCalls) | ||
| 414 | } | 421 | } | ||
| @@ -418,8 +425,13 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_DedupIsAtomic(t *testing.T | |||||
| 418 | ctx := context.Background() | 425 | ctx := context.Background() | ||
| 419 | clipboard := newBlockingClipboard() | 426 | clipboard := newBlockingClipboard() | ||
| 420 | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | 427 | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | ||
| 421 | - | var toastCalls []int | 428 | + | var ( |
| 429 | + | toastCalls []int | |||
| 430 | + | toastMu sync.Mutex | |||
| 431 | + | ) | |||
| 422 | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | 432 | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | ||
| 433 | + | toastMu.Lock() | |||
| 434 | + | defer toastMu.Unlock() | |||
| 423 | toastCalls = append(toastCalls, textLen) | 435 | toastCalls = append(toastCalls, textLen) | ||
| 424 | }) | 436 | }) | ||
| 425 | uc.now = func() time.Time { return time.Unix(0, 0) } | 437 | uc.now = func() time.Time { return time.Unix(0, 0) } | ||
| @@ -453,6 +465,8 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_DedupIsAtomic(t *testing.T | |||||
| 453 | if got := clipboard.writeCount(); got != 1 { | 465 | if got := clipboard.writeCount(); got != 1 { | ||
| 454 | t.Fatalf("clipboard writes = %d, want 1", got) | 466 | t.Fatalf("clipboard writes = %d, want 1", got) | ||
| 455 | } | 467 | } | ||
| 468 | + | toastMu.Lock() | |||
| 469 | + | defer toastMu.Unlock() | |||
| 456 | if len(toastCalls) != 1 || toastCalls[0] != 2 { | 470 | if len(toastCalls) != 1 || toastCalls[0] != 2 { | ||
| 457 | t.Fatalf("toast calls = %v, want [2]", toastCalls) | 471 | t.Fatalf("toast calls = %v, want [2]", toastCalls) | ||
| 458 | } | 472 | } | ||
M internal/application/usecase/execute_context_menu_action.go +3 -0
| @@ -106,6 +106,9 @@ func (uc *ExecuteContextMenuActionUseCase) executeMenuAction( | |||
| 106 | 106 | } | |
| 107 | 107 | return nil | |
| 108 | 108 | case port.MenuActionCopySelection: | |
| 109 | + | // When selection text is present and clipboard wiring is available, copy it here. | |
| 110 | + | // Otherwise intentionally fall through to DelegateMenuAction so native browser copy | |
| 111 | + | // behavior can handle document selections that are only accessible inside the engine. | |
| 109 | 112 | if menuContext.SelectionText != "" && uc.clipboard != nil { | |
| 110 | 113 | if err := uc.clipboard.WriteText(ctx, menuContext.SelectionText); err != nil { | |
| 111 | 114 | return fmt.Errorf("copy selection: %w", err) | |
| @@ -106,6 +106,9 @@ func (uc *ExecuteContextMenuActionUseCase) executeMenuAction( | |||||
| 106 | } | 106 | } | ||
| 107 | return nil | 107 | return nil | ||
| 108 | case port.MenuActionCopySelection: | 108 | case port.MenuActionCopySelection: | ||
| 109 | + | // When selection text is present and clipboard wiring is available, copy it here. | |||
| 110 | + | // Otherwise intentionally fall through to DelegateMenuAction so native browser copy | |||
| 111 | + | // behavior can handle document selections that are only accessible inside the engine. | |||
| 109 | if menuContext.SelectionText != "" && uc.clipboard != nil { | 112 | if menuContext.SelectionText != "" && uc.clipboard != nil { | ||
| 110 | if err := uc.clipboard.WriteText(ctx, menuContext.SelectionText); err != nil { | 113 | if err := uc.clipboard.WriteText(ctx, menuContext.SelectionText); err != nil { | ||
| 111 | return fmt.Errorf("copy selection: %w", err) | 114 | return fmt.Errorf("copy selection: %w", err) | ||
M internal/infrastructure/cef/engine_runtime_test.go +1 -11
| @@ -54,7 +54,7 @@ func TestEngineRegisterHandlers_PropagatesRegistrarError(t *testing.T) { | |||
| 54 | 54 | } | |
| 55 | 55 | ||
| 56 | 56 | func TestEngineRegisterHandlers_StoresClipboardWiring(t *testing.T) { | |
| 57 | - | orchestrator := &stubClipboardTextOrchestrator{} | |
| 57 | + | orchestrator := &recordingClipboardTextOrchestrator{} | |
| 58 | 58 | onCopied := func(int) {} | |
| 59 | 59 | eng := &Engine{ | |
| 60 | 60 | messageRouter: NewMessageRouter(context.Background()), | |
| @@ -89,21 +89,11 @@ func TestEngineExplicitClipboardCopy_ForwardsViewID(t *testing.T) { | |||
| 89 | 89 | require.Equal(t, dto.ExplicitClipboardInput{Text: "copied", SourceEngine: dto.ClipboardSourceCEF, ViewID: 77, Action: "copy", NativeHandled: false}, orchestrator.explicit) | |
| 90 | 90 | } | |
| 91 | 91 | ||
| 92 | - | type stubClipboardTextOrchestrator struct{} | |
| 93 | - | ||
| 94 | 92 | type recordingClipboardTextOrchestrator struct { | |
| 95 | 93 | selection dto.SelectionClipboardInput | |
| 96 | 94 | explicit dto.ExplicitClipboardInput | |
| 97 | 95 | } | |
| 98 | 96 | ||
| 99 | - | func (stubClipboardTextOrchestrator) HandleSelectionUpdate(context.Context, dto.SelectionClipboardInput) error { | |
| 100 | - | return nil | |
| 101 | - | } | |
| 102 | - | ||
| 103 | - | func (stubClipboardTextOrchestrator) HandleExplicitCopy(context.Context, dto.ExplicitClipboardInput) error { | |
| 104 | - | return nil | |
| 105 | - | } | |
| 106 | - | ||
| 107 | 97 | func (r *recordingClipboardTextOrchestrator) HandleSelectionUpdate(_ context.Context, input dto.SelectionClipboardInput) error { | |
| 108 | 98 | r.selection = input | |
| 109 | 99 | return nil | |
| @@ -54,7 +54,7 @@ func TestEngineRegisterHandlers_PropagatesRegistrarError(t *testing.T) { | |||||
| 54 | } | 54 | } | ||
| 55 | 55 | ||||
| 56 | func TestEngineRegisterHandlers_StoresClipboardWiring(t *testing.T) { | 56 | func TestEngineRegisterHandlers_StoresClipboardWiring(t *testing.T) { | ||
| 57 | - | orchestrator := &stubClipboardTextOrchestrator{} | 57 | + | orchestrator := &recordingClipboardTextOrchestrator{} |
| 58 | onCopied := func(int) {} | 58 | onCopied := func(int) {} | ||
| 59 | eng := &Engine{ | 59 | eng := &Engine{ | ||
| 60 | messageRouter: NewMessageRouter(context.Background()), | 60 | messageRouter: NewMessageRouter(context.Background()), | ||
| @@ -89,21 +89,11 @@ func TestEngineExplicitClipboardCopy_ForwardsViewID(t *testing.T) { | |||||
| 89 | require.Equal(t, dto.ExplicitClipboardInput{Text: "copied", SourceEngine: dto.ClipboardSourceCEF, ViewID: 77, Action: "copy", NativeHandled: false}, orchestrator.explicit) | 89 | require.Equal(t, dto.ExplicitClipboardInput{Text: "copied", SourceEngine: dto.ClipboardSourceCEF, ViewID: 77, Action: "copy", NativeHandled: false}, orchestrator.explicit) | ||
| 90 | } | 90 | } | ||
| 91 | 91 | ||||
| 92 | - | type stubClipboardTextOrchestrator struct{} | |||
| 93 | - | ||||
| 94 | type recordingClipboardTextOrchestrator struct { | 92 | type recordingClipboardTextOrchestrator struct { | ||
| 95 | selection dto.SelectionClipboardInput | 93 | selection dto.SelectionClipboardInput | ||
| 96 | explicit dto.ExplicitClipboardInput | 94 | explicit dto.ExplicitClipboardInput | ||
| 97 | } | 95 | } | ||
| 98 | 96 | ||||
| 99 | - | func (stubClipboardTextOrchestrator) HandleSelectionUpdate(context.Context, dto.SelectionClipboardInput) error { | |||
| 100 | - | return nil | |||
| 101 | - | } | |||
| 102 | - | ||||
| 103 | - | func (stubClipboardTextOrchestrator) HandleExplicitCopy(context.Context, dto.ExplicitClipboardInput) error { | |||
| 104 | - | return nil | |||
| 105 | - | } | |||
| 106 | - | ||||
| 107 | func (r *recordingClipboardTextOrchestrator) HandleSelectionUpdate(_ context.Context, input dto.SelectionClipboardInput) error { | 97 | func (r *recordingClipboardTextOrchestrator) HandleSelectionUpdate(_ context.Context, input dto.SelectionClipboardInput) error { | ||
| 108 | r.selection = input | 98 | r.selection = input | ||
| 109 | return nil | 99 | return nil | ||
M internal/infrastructure/cef/internal_urls.go +2 -1
| @@ -12,6 +12,7 @@ const ( | |||
| 12 | 12 | actualInternalHost = "dumber.invalid" | |
| 13 | 13 | actualInternalOrigin = actualInternalScheme + "://" + actualInternalHost | |
| 14 | 14 | internalAPIPathPrefix = "/api/" | |
| 15 | + | internalAPIPathPrefixTrimmed = "api/" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| 17 | 18 | func isConceptualInternalURL(raw string) bool { | |
| @@ -50,7 +51,7 @@ func toActualInternalURL(raw string) string { | |||
| 50 | 51 | case trimmedPath == "": | |
| 51 | 52 | // Keep page roots at /home instead of /home/ so relative assets resolve | |
| 52 | 53 | // from the origin root. | |
| 53 | - | case strings.HasPrefix(trimmedPath, strings.TrimPrefix(internalAPIPathPrefix, "/")): | |
| 54 | + | case strings.HasPrefix(trimmedPath, internalAPIPathPrefixTrimmed): | |
| 54 | 55 | actualPath = "/" + trimmedPath | |
| 55 | 56 | case path.Ext(trimmedPath) != "": | |
| 56 | 57 | actualPath = "/" + trimmedPath | |
| @@ -12,6 +12,7 @@ const ( | |||||
| 12 | actualInternalHost = "dumber.invalid" | 12 | actualInternalHost = "dumber.invalid" | ||
| 13 | actualInternalOrigin = actualInternalScheme + "://" + actualInternalHost | 13 | actualInternalOrigin = actualInternalScheme + "://" + actualInternalHost | ||
| 14 | internalAPIPathPrefix = "/api/" | 14 | internalAPIPathPrefix = "/api/" | ||
| 15 | + | internalAPIPathPrefixTrimmed = "api/" | |||
| 15 | ) | 16 | ) | ||
| 16 | 17 | ||||
| 17 | func isConceptualInternalURL(raw string) bool { | 18 | func isConceptualInternalURL(raw string) bool { | ||
| @@ -50,7 +51,7 @@ func toActualInternalURL(raw string) string { | |||||
| 50 | case trimmedPath == "": | 51 | case trimmedPath == "": | ||
| 51 | // Keep page roots at /home instead of /home/ so relative assets resolve | 52 | // Keep page roots at /home instead of /home/ so relative assets resolve | ||
| 52 | // from the origin root. | 53 | // from the origin root. | ||
| 53 | - | case strings.HasPrefix(trimmedPath, strings.TrimPrefix(internalAPIPathPrefix, "/")): | 54 | + | case strings.HasPrefix(trimmedPath, internalAPIPathPrefixTrimmed): |
| 54 | actualPath = "/" + trimmedPath | 55 | actualPath = "/" + trimmedPath | ||
| 55 | case path.Ext(trimmedPath) != "": | 56 | case path.Ext(trimmedPath) != "": | ||
| 56 | actualPath = "/" + trimmedPath | 57 | actualPath = "/" + trimmedPath | ||
M internal/infrastructure/cef/internal_urls_test.go +13 -3
| @@ -59,6 +59,12 @@ func TestResolveAPIPath(t *testing.T) { | |||
| 59 | 59 | want string | |
| 60 | 60 | ok bool | |
| 61 | 61 | }{ | |
| 62 | + | { | |
| 63 | + | name: "nil url returns false", | |
| 64 | + | in: "", | |
| 65 | + | want: "", | |
| 66 | + | ok: false, | |
| 67 | + | }, | |
| 62 | 68 | { | |
| 63 | 69 | name: "conceptual api host", | |
| 64 | 70 | in: "dumb://api/clipboard-set", | |
| @@ -88,9 +94,13 @@ func TestResolveAPIPath(t *testing.T) { | |||
| 88 | 94 | for _, tt := range tests { | |
| 89 | 95 | t.Run(tt.name, func(t *testing.T) { | |
| 90 | 96 | t.Parallel() | |
| 91 | - | parsed, err := url.Parse(tt.in) | |
| 92 | - | if err != nil { | |
| 93 | - | t.Fatalf("url.Parse(%q) error = %v", tt.in, err) | |
| 97 | + | var parsed *url.URL | |
| 98 | + | if tt.in != "" { | |
| 99 | + | var err error | |
| 100 | + | parsed, err = url.Parse(tt.in) | |
| 101 | + | if err != nil { | |
| 102 | + | t.Fatalf("url.Parse(%q) error = %v", tt.in, err) | |
| 103 | + | } | |
| 94 | 104 | } | |
| 95 | 105 | got, ok := resolveAPIPath(parsed) | |
| 96 | 106 | if got != tt.want || ok != tt.ok { | |
| @@ -59,6 +59,12 @@ func TestResolveAPIPath(t *testing.T) { | |||||
| 59 | want string | 59 | want string | ||
| 60 | ok bool | 60 | ok bool | ||
| 61 | }{ | 61 | }{ | ||
| 62 | + | { | |||
| 63 | + | name: "nil url returns false", | |||
| 64 | + | in: "", | |||
| 65 | + | want: "", | |||
| 66 | + | ok: false, | |||
| 67 | + | }, | |||
| 62 | { | 68 | { | ||
| 63 | name: "conceptual api host", | 69 | name: "conceptual api host", | ||
| 64 | in: "dumb://api/clipboard-set", | 70 | in: "dumb://api/clipboard-set", | ||
| @@ -88,9 +94,13 @@ func TestResolveAPIPath(t *testing.T) { | |||||
| 88 | for _, tt := range tests { | 94 | for _, tt := range tests { | ||
| 89 | t.Run(tt.name, func(t *testing.T) { | 95 | t.Run(tt.name, func(t *testing.T) { | ||
| 90 | t.Parallel() | 96 | t.Parallel() | ||
| 91 | - | parsed, err := url.Parse(tt.in) | 97 | + | var parsed *url.URL |
| 92 | - | if err != nil { | 98 | + | if tt.in != "" { |
| 93 | - | t.Fatalf("url.Parse(%q) error = %v", tt.in, err) | 99 | + | var err error |
| 100 | + | parsed, err = url.Parse(tt.in) | |||
| 101 | + | if err != nil { | |||
| 102 | + | t.Fatalf("url.Parse(%q) error = %v", tt.in, err) | |||
| 103 | + | } | |||
| 94 | } | 104 | } | ||
| 95 | got, ok := resolveAPIPath(parsed) | 105 | got, ok := resolveAPIPath(parsed) | ||
| 96 | if got != tt.want || ok != tt.ok { | 106 | if got != tt.want || ok != tt.ok { | ||
M internal/infrastructure/cef/scheme_handler.go +1 -1
| @@ -225,7 +225,7 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef. | |||
| 225 | 225 | Int("forwarded_header_count", len(headers)). | |
| 226 | 226 | Msg("scheme handler returning transcoding stream") | |
| 227 | 227 | ||
| 228 | - | return purecef.NewResourceHandler(&transcodingResourceHandler{ | |
| 228 | + | return cefNewResourceHandler(&transcodingResourceHandler{ | |
| 229 | 229 | transcoder: h.transcoder, | |
| 230 | 230 | sourceURL: sourceURL, | |
| 231 | 231 | headers: headers, | |
| @@ -225,7 +225,7 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef. | |||||
| 225 | Int("forwarded_header_count", len(headers)). | 225 | Int("forwarded_header_count", len(headers)). | ||
| 226 | Msg("scheme handler returning transcoding stream") | 226 | Msg("scheme handler returning transcoding stream") | ||
| 227 | 227 | ||||
| 228 | - | return purecef.NewResourceHandler(&transcodingResourceHandler{ | 228 | + | return cefNewResourceHandler(&transcodingResourceHandler{ |
| 229 | transcoder: h.transcoder, | 229 | transcoder: h.transcoder, | ||
| 230 | sourceURL: sourceURL, | 230 | sourceURL: sourceURL, | ||
| 231 | headers: headers, | 231 | headers: headers, | ||
M internal/infrastructure/webkit/injector.go +1 -0
| @@ -125,6 +125,7 @@ const explicitCopyScript = `(function() { | |||
| 125 | 125 | var originalWriteText = navigator.clipboard.writeText.bind(navigator.clipboard); | |
| 126 | 126 | navigator.clipboard.writeText = function(text) { | |
| 127 | 127 | return Promise.resolve(originalWriteText(text)).then(function(result) { | |
| 128 | + | // writeText only has copy semantics, so postExplicitCopy always uses "copy" here. | |
| 128 | 129 | postExplicitCopy('copy', typeof text === 'string' ? text : String(text || '')); | |
| 129 | 130 | return result; | |
| 130 | 131 | }); | |
| @@ -125,6 +125,7 @@ const explicitCopyScript = `(function() { | |||||
| 125 | var originalWriteText = navigator.clipboard.writeText.bind(navigator.clipboard); | 125 | var originalWriteText = navigator.clipboard.writeText.bind(navigator.clipboard); | ||
| 126 | navigator.clipboard.writeText = function(text) { | 126 | navigator.clipboard.writeText = function(text) { | ||
| 127 | return Promise.resolve(originalWriteText(text)).then(function(result) { | 127 | return Promise.resolve(originalWriteText(text)).then(function(result) { | ||
| 128 | + | // writeText only has copy semantics, so postExplicitCopy always uses "copy" here. | |||
| 128 | postExplicitCopy('copy', typeof text === 'string' ? text : String(text || '')); | 129 | postExplicitCopy('copy', typeof text === 'string' ? text : String(text || '')); | ||
| 129 | return result; | 130 | return result; | ||
| 130 | }); | 131 | }); | ||
M internal/ui/app_clipboard_test.go +29 -0
| @@ -39,3 +39,32 @@ func TestNew_DoesNotWireClipboardOrchestratorWhenClipboardMissing(t *testing.T) | |||
| 39 | 39 | require.NotNil(t, app) | |
| 40 | 40 | require.Nil(t, registerDeps.ClipboardTextOrchestrator) | |
| 41 | 41 | } | |
| 42 | + | ||
| 43 | + | func TestNew_WiresClipboardOrchestratorWhenClipboardPresent(t *testing.T) { | |
| 44 | + | ctx := context.Background() | |
| 45 | + | engine := portmocks.NewMockEngine(t) | |
| 46 | + | clipboard := portmocks.NewMockClipboard(t) | |
| 47 | + | var registerDeps port.HandlerDependencies | |
| 48 | + | engine.EXPECT().RegisterHandlers(mock.Anything, mock.Anything). | |
| 49 | + | Run(func(_ context.Context, deps port.HandlerDependencies) { | |
| 50 | + | registerDeps = deps | |
| 51 | + | }). | |
| 52 | + | Return(nil). | |
| 53 | + | Once() | |
| 54 | + | ||
| 55 | + | deps := &Dependencies{ | |
| 56 | + | Ctx: ctx, | |
| 57 | + | Config: &config.Config{}, | |
| 58 | + | Engine: engine, | |
| 59 | + | HandlerDeps: port.HandlerDeps{ | |
| 60 | + | SaveConfig: func(context.Context, port.WebUIConfig) error { return nil }, | |
| 61 | + | SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil }, | |
| 62 | + | }, | |
| 63 | + | Clipboard: clipboard, | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | app, err := New(deps) | |
| 67 | + | require.NoError(t, err) | |
| 68 | + | require.NotNil(t, app) | |
| 69 | + | require.NotNil(t, registerDeps.ClipboardTextOrchestrator) | |
| 70 | + | } | |
| @@ -39,3 +39,32 @@ func TestNew_DoesNotWireClipboardOrchestratorWhenClipboardMissing(t *testing.T) | |||||
| 39 | require.NotNil(t, app) | 39 | require.NotNil(t, app) | ||
| 40 | require.Nil(t, registerDeps.ClipboardTextOrchestrator) | 40 | require.Nil(t, registerDeps.ClipboardTextOrchestrator) | ||
| 41 | } | 41 | } | ||
| 42 | + | ||||
| 43 | + | func TestNew_WiresClipboardOrchestratorWhenClipboardPresent(t *testing.T) { | |||
| 44 | + | ctx := context.Background() | |||
| 45 | + | engine := portmocks.NewMockEngine(t) | |||
| 46 | + | clipboard := portmocks.NewMockClipboard(t) | |||
| 47 | + | var registerDeps port.HandlerDependencies | |||
| 48 | + | engine.EXPECT().RegisterHandlers(mock.Anything, mock.Anything). | |||
| 49 | + | Run(func(_ context.Context, deps port.HandlerDependencies) { | |||
| 50 | + | registerDeps = deps | |||
| 51 | + | }). | |||
| 52 | + | Return(nil). | |||
| 53 | + | Once() | |||
| 54 | + | ||||
| 55 | + | deps := &Dependencies{ | |||
| 56 | + | Ctx: ctx, | |||
| 57 | + | Config: &config.Config{}, | |||
| 58 | + | Engine: engine, | |||
| 59 | + | HandlerDeps: port.HandlerDeps{ | |||
| 60 | + | SaveConfig: func(context.Context, port.WebUIConfig) error { return nil }, | |||
| 61 | + | SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil }, | |||
| 62 | + | }, | |||
| 63 | + | Clipboard: clipboard, | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | app, err := New(deps) | |||
| 67 | + | require.NoError(t, err) | |||
| 68 | + | require.NotNil(t, app) | |||
| 69 | + | require.NotNil(t, registerDeps.ClipboardTextOrchestrator) | |||
| 70 | + | } | |||
M internal/ui/component/progress_bar_test.go +32 -0
| @@ -28,3 +28,35 @@ func TestProgressBarTimeoutRemainingLocked_UsesShowTimeBeforeFirstProgress(t *te | |||
| 28 | 28 | ||
| 29 | 29 | require.Zero(t, remaining) | |
| 30 | 30 | } | |
| 31 | + | ||
| 32 | + | func TestProgressBarTimeoutRemainingLocked_ZeroTimestamps(t *testing.T) { | |
| 33 | + | pb := &ProgressBar{} | |
| 34 | + | ||
| 35 | + | remaining := pb.timeoutRemainingLocked(time.Unix(10, 0)) | |
| 36 | + | ||
| 37 | + | require.Zero(t, remaining) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestProgressBarTimeoutRemainingLocked_EqualTimestamps(t *testing.T) { | |
| 41 | + | showAt := time.Unix(0, 0) | |
| 42 | + | pb := &ProgressBar{ | |
| 43 | + | lastShowAt: showAt, | |
| 44 | + | lastProgressAt: showAt, | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | remaining := pb.timeoutRemainingLocked(showAt.Add(10 * time.Second)) | |
| 48 | + | ||
| 49 | + | require.Equal(t, 20*time.Second, remaining) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestProgressBarTimeoutRemainingLocked_NegativeClampsToZero(t *testing.T) { | |
| 53 | + | showAt := time.Unix(0, 0) | |
| 54 | + | pb := &ProgressBar{ | |
| 55 | + | lastShowAt: showAt, | |
| 56 | + | lastProgressAt: showAt.Add(5 * time.Second), | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | remaining := pb.timeoutRemainingLocked(showAt.Add(36 * time.Second)) | |
| 60 | + | ||
| 61 | + | require.Zero(t, remaining) | |
| 62 | + | } | |
| @@ -28,3 +28,35 @@ func TestProgressBarTimeoutRemainingLocked_UsesShowTimeBeforeFirstProgress(t *te | |||||
| 28 | 28 | ||||
| 29 | require.Zero(t, remaining) | 29 | require.Zero(t, remaining) | ||
| 30 | } | 30 | } | ||
| 31 | + | ||||
| 32 | + | func TestProgressBarTimeoutRemainingLocked_ZeroTimestamps(t *testing.T) { | |||
| 33 | + | pb := &ProgressBar{} | |||
| 34 | + | ||||
| 35 | + | remaining := pb.timeoutRemainingLocked(time.Unix(10, 0)) | |||
| 36 | + | ||||
| 37 | + | require.Zero(t, remaining) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestProgressBarTimeoutRemainingLocked_EqualTimestamps(t *testing.T) { | |||
| 41 | + | showAt := time.Unix(0, 0) | |||
| 42 | + | pb := &ProgressBar{ | |||
| 43 | + | lastShowAt: showAt, | |||
| 44 | + | lastProgressAt: showAt, | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | remaining := pb.timeoutRemainingLocked(showAt.Add(10 * time.Second)) | |||
| 48 | + | ||||
| 49 | + | require.Equal(t, 20*time.Second, remaining) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestProgressBarTimeoutRemainingLocked_NegativeClampsToZero(t *testing.T) { | |||
| 53 | + | showAt := time.Unix(0, 0) | |||
| 54 | + | pb := &ProgressBar{ | |||
| 55 | + | lastShowAt: showAt, | |||
| 56 | + | lastProgressAt: showAt.Add(5 * time.Second), | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | remaining := pb.timeoutRemainingLocked(showAt.Add(36 * time.Second)) | |||
| 60 | + | ||||
| 61 | + | require.Zero(t, remaining) | |||
| 62 | + | } | |||