Finalize clipboard and runtime profile cleanup
- commit
- 72b6970fb6a3a00de85edb4ec49d3c7a6c1ea643
- author
- brice · · 2026-04-18 10:23 UTC
- parent
- 21f7418
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
41 files · +1301 -555
M .mockery.yaml +1 -0
| @@ -49,6 +49,7 @@ packages: | |||
| 49 | 49 | AccentKeyHandler: {} | |
| 50 | 50 | AutoCopyConfig: {} | |
| 51 | 51 | Clipboard: {} | |
| 52 | + | ClipboardTextOrchestrator: {} | |
| 52 | 53 | ContextMenuBuilder: {} | |
| 53 | 54 | ContextMenuActionExecutor: {} | |
| 54 | 55 | ContextMenuActionExecutorFactory: {} | |
| @@ -49,6 +49,7 @@ packages: | |||||
| 49 | AccentKeyHandler: {} | 49 | AccentKeyHandler: {} | ||
| 50 | AutoCopyConfig: {} | 50 | AutoCopyConfig: {} | ||
| 51 | Clipboard: {} | 51 | Clipboard: {} | ||
| 52 | + | ClipboardTextOrchestrator: {} | |||
| 52 | ContextMenuBuilder: {} | 53 | ContextMenuBuilder: {} | ||
| 53 | ContextMenuActionExecutor: {} | 54 | ContextMenuActionExecutor: {} | ||
| 54 | ContextMenuActionExecutorFactory: {} | 55 | ContextMenuActionExecutorFactory: {} | ||
M cmd/dumber/main.go +44 -19
| @@ -26,6 +26,7 @@ import ( | |||
| 26 | 26 | "github.com/bnema/dumber/internal/infrastructure/filesystem" | |
| 27 | 27 | "github.com/bnema/dumber/internal/infrastructure/idle" | |
| 28 | 28 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 29 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 29 | 30 | "github.com/bnema/dumber/internal/infrastructure/snapshot" | |
| 30 | 31 | "github.com/bnema/dumber/internal/infrastructure/textinput" | |
| 31 | 32 | "github.com/bnema/dumber/internal/infrastructure/updater" | |
| @@ -54,7 +55,7 @@ var initialURL string | |||
| 54 | 55 | var restoreSessionID string | |
| 55 | 56 | ||
| 56 | 57 | // browserLaunchRelay is shared for early browse handoff and in-app browser launches. | |
| 57 | - | var browserLaunchRelay = desktop.NewBrowserLaunchRelay(xdg.New()) | |
| 58 | + | var browserLaunchRelay port.BrowserLaunchRelay | |
| 58 | 59 | ||
| 59 | 60 | type launchMode string | |
| 60 | 61 | ||
| @@ -97,6 +98,24 @@ func tryForwardBrowseURLToRunningInstance(ctx context.Context, relay port.Browse | |||
| 97 | 98 | return relay.DeliverOpenFreshWindow(ctx, browseURL) | |
| 98 | 99 | } | |
| 99 | 100 | ||
| 101 | + | func resolveBrowserLaunchRelay(cfg *config.Config) (port.BrowserLaunchRelay, error) { | |
| 102 | + | profile, err := bootstrap.ResolveRuntimeProfile(cfg) | |
| 103 | + | if err != nil { | |
| 104 | + | return nil, err | |
| 105 | + | } | |
| 106 | + | return desktop.NewBrowserLaunchRelay(profile.IPC), nil | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func configureBrowserLaunchRelay(cfg *config.Config) { | |
| 110 | + | relay, err := resolveBrowserLaunchRelay(cfg) | |
| 111 | + | if err != nil { | |
| 112 | + | fmt.Fprintf(os.Stderr, "warning: failed to resolve browser launch relay profile: %v\n", err) | |
| 113 | + | browserLaunchRelay = nil | |
| 114 | + | return | |
| 115 | + | } | |
| 116 | + | browserLaunchRelay = relay | |
| 117 | + | } | |
| 118 | + | ||
| 100 | 119 | func main() { | |
| 101 | 120 | enableCrashForensics() | |
| 102 | 121 | ||
| @@ -114,6 +133,8 @@ func main() { | |||
| 114 | 133 | mode, browseURL := launchModeFromArgs(os.Args) | |
| 115 | 134 | // Run GUI mode for browse command | |
| 116 | 135 | if mode == launchModeBrowse { | |
| 136 | + | cfg := initConfig() | |
| 137 | + | configureBrowserLaunchRelay(cfg) | |
| 117 | 138 | if forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), browserLaunchRelay, browseURL); err != nil { | |
| 118 | 139 | fmt.Fprintf( | |
| 119 | 140 | os.Stderr, | |
| @@ -128,7 +149,7 @@ func main() { | |||
| 128 | 149 | initialURL = browseURL | |
| 129 | 150 | restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION") | |
| 130 | 151 | os.Args = os.Args[:1] | |
| 131 | - | os.Exit(runGUI()) | |
| 152 | + | os.Exit(runGUI(cfg)) | |
| 132 | 153 | return | |
| 133 | 154 | } | |
| 134 | 155 | ||
| @@ -149,14 +170,17 @@ func main() { | |||
| 149 | 170 | cmd.Execute() | |
| 150 | 171 | } | |
| 151 | 172 | ||
| 152 | - | func runGUI() int { | |
| 173 | + | func runGUI(cfg *config.Config) int { | |
| 153 | 174 | bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr) | |
| 154 | 175 | ||
| 155 | 176 | runtime.LockOSThread() | |
| 156 | 177 | component.SetSkeletonVersion(version) | |
| 157 | 178 | timer := bootstrap.NewStartupTimer() | |
| 158 | 179 | ||
| 159 | - | cfg := initConfig() | |
| 180 | + | if cfg == nil { | |
| 181 | + | cfg = initConfig() | |
| 182 | + | configureBrowserLaunchRelay(cfg) | |
| 183 | + | } | |
| 160 | 184 | timer.Mark("config") | |
| 161 | 185 | ||
| 162 | 186 | ctx := initStartupContextWithTrace(cfg) | |
| @@ -232,6 +256,7 @@ func runStandaloneOmnibox() int { | |||
| 232 | 256 | component.SetSkeletonVersion(version) | |
| 233 | 257 | ||
| 234 | 258 | cfg := initConfig() | |
| 259 | + | configureBrowserLaunchRelay(cfg) | |
| 235 | 260 | ctx := initStartupContextWithTrace(cfg) | |
| 236 | 261 | ||
| 237 | 262 | initResult, err := runParallelInitPhase(ctx, cfg) | |
| @@ -260,6 +285,7 @@ func runStandaloneOmnibox() int { | |||
| 260 | 285 | uiDeps, err := buildUIDependencies( | |
| 261 | 286 | ctx, | |
| 262 | 287 | cfg, | |
| 288 | + | initResult.RuntimeProfile, | |
| 263 | 289 | initResult.ThemeManager, | |
| 264 | 290 | initResult.ColorResolver, | |
| 265 | 291 | initResult.AdwaitaDetector, | |
| @@ -391,7 +417,7 @@ func buildAndConfigureApp( | |||
| 391 | 417 | browserSession *bootstrap.BrowserSession, | |
| 392 | 418 | ) (*ui.App, error) { | |
| 393 | 419 | uiDeps, err := buildUIDependencies( | |
| 394 | - | ctx, cfg, initResult.ThemeManager, | |
| 420 | + | ctx, cfg, initResult.RuntimeProfile, initResult.ThemeManager, | |
| 395 | 421 | initResult.ColorResolver, initResult.AdwaitaDetector, | |
| 396 | 422 | engine, repos, useCases, idleInhibitor, browserSession.Session.ID, browserSession.CrashReports(), | |
| 397 | 423 | ) | |
| @@ -427,12 +453,11 @@ func initStackAndRepos( | |||
| 427 | 453 | if needsEagerDB { | |
| 428 | 454 | // Parallel phase 2: Database + engine initialize concurrently | |
| 429 | 455 | dbEngine, err := bootstrap.RunParallelDBEngine(bootstrap.ParallelDBEngineInput{ | |
| 430 | - | Ctx: ctx, | |
| 431 | - | Config: cfg, | |
| 432 | - | DataDir: initResult.DataDir, | |
| 433 | - | CacheDir: initResult.CacheDir, | |
| 434 | - | ThemeManager: initResult.ThemeManager, | |
| 435 | - | ColorResolver: initResult.ColorResolver, | |
| 456 | + | Ctx: ctx, | |
| 457 | + | Config: cfg, | |
| 458 | + | RuntimeProfile: initResult.RuntimeProfile, | |
| 459 | + | ThemeManager: initResult.ThemeManager, | |
| 460 | + | ColorResolver: initResult.ColorResolver, | |
| 436 | 461 | }) | |
| 437 | 462 | if err != nil { | |
| 438 | 463 | return nil, nil, nil, err | |
| @@ -442,13 +467,12 @@ func initStackAndRepos( | |||
| 442 | 467 | ||
| 443 | 468 | log := logging.FromContext(ctx) | |
| 444 | 469 | engine, err := bootstrap.BuildEngine(bootstrap.EngineInput{ | |
| 445 | - | Ctx: ctx, | |
| 446 | - | Config: cfg, | |
| 447 | - | DataDir: initResult.DataDir, | |
| 448 | - | CacheDir: initResult.CacheDir, | |
| 449 | - | ThemeManager: initResult.ThemeManager, | |
| 450 | - | ColorResolver: initResult.ColorResolver, | |
| 451 | - | Logger: *log, | |
| 470 | + | Ctx: ctx, | |
| 471 | + | Config: cfg, | |
| 472 | + | RuntimeProfile: initResult.RuntimeProfile, | |
| 473 | + | ThemeManager: initResult.ThemeManager, | |
| 474 | + | ColorResolver: initResult.ColorResolver, | |
| 475 | + | Logger: *log, | |
| 452 | 476 | }) | |
| 453 | 477 | if err != nil { | |
| 454 | 478 | return nil, nil, nil, err | |
| @@ -717,6 +741,7 @@ func isCEFSubprocess() bool { | |||
| 717 | 741 | func buildUIDependencies( | |
| 718 | 742 | ctx context.Context, | |
| 719 | 743 | cfg *config.Config, | |
| 744 | + | runtimeProfile runtimeprofile.Profile, | |
| 720 | 745 | themeManager *theme.Manager, | |
| 721 | 746 | colorResolver port.ColorSchemeResolver, | |
| 722 | 747 | adwaitaDetector port.ToolkitAvailabilityNotifier, | |
| @@ -782,7 +807,7 @@ func buildUIDependencies( | |||
| 782 | 807 | }, | |
| 783 | 808 | CheckUpdateUC: uc.checkUpdate, | |
| 784 | 809 | ApplyUpdateUC: uc.applyUpdate, | |
| 785 | - | SessionSpawner: bootstrap.NewSessionSpawner(ctx, cfg), | |
| 810 | + | SessionSpawner: bootstrap.NewSessionSpawner(ctx, runtimeProfile), | |
| 786 | 811 | FileSystem: filesystem.New(), | |
| 787 | 812 | AccentFocusProvider: focusProvider, | |
| 788 | 813 | NewGTKEntryTarget: func(entry *gtk.SearchEntry) port.TextInputTarget { | |
| @@ -26,6 +26,7 @@ import ( | |||||
| 26 | "github.com/bnema/dumber/internal/infrastructure/filesystem" | 26 | "github.com/bnema/dumber/internal/infrastructure/filesystem" | ||
| 27 | "github.com/bnema/dumber/internal/infrastructure/idle" | 27 | "github.com/bnema/dumber/internal/infrastructure/idle" | ||
| 28 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | 28 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | ||
| 29 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 29 | "github.com/bnema/dumber/internal/infrastructure/snapshot" | 30 | "github.com/bnema/dumber/internal/infrastructure/snapshot" | ||
| 30 | "github.com/bnema/dumber/internal/infrastructure/textinput" | 31 | "github.com/bnema/dumber/internal/infrastructure/textinput" | ||
| 31 | "github.com/bnema/dumber/internal/infrastructure/updater" | 32 | "github.com/bnema/dumber/internal/infrastructure/updater" | ||
| @@ -54,7 +55,7 @@ var initialURL string | |||||
| 54 | var restoreSessionID string | 55 | var restoreSessionID string | ||
| 55 | 56 | ||||
| 56 | // browserLaunchRelay is shared for early browse handoff and in-app browser launches. | 57 | // browserLaunchRelay is shared for early browse handoff and in-app browser launches. | ||
| 57 | - | var browserLaunchRelay = desktop.NewBrowserLaunchRelay(xdg.New()) | 58 | + | var browserLaunchRelay port.BrowserLaunchRelay |
| 58 | 59 | ||||
| 59 | type launchMode string | 60 | type launchMode string | ||
| 60 | 61 | ||||
| @@ -97,6 +98,24 @@ func tryForwardBrowseURLToRunningInstance(ctx context.Context, relay port.Browse | |||||
| 97 | return relay.DeliverOpenFreshWindow(ctx, browseURL) | 98 | return relay.DeliverOpenFreshWindow(ctx, browseURL) | ||
| 98 | } | 99 | } | ||
| 99 | 100 | ||||
| 101 | + | func resolveBrowserLaunchRelay(cfg *config.Config) (port.BrowserLaunchRelay, error) { | |||
| 102 | + | profile, err := bootstrap.ResolveRuntimeProfile(cfg) | |||
| 103 | + | if err != nil { | |||
| 104 | + | return nil, err | |||
| 105 | + | } | |||
| 106 | + | return desktop.NewBrowserLaunchRelay(profile.IPC), nil | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func configureBrowserLaunchRelay(cfg *config.Config) { | |||
| 110 | + | relay, err := resolveBrowserLaunchRelay(cfg) | |||
| 111 | + | if err != nil { | |||
| 112 | + | fmt.Fprintf(os.Stderr, "warning: failed to resolve browser launch relay profile: %v\n", err) | |||
| 113 | + | browserLaunchRelay = nil | |||
| 114 | + | return | |||
| 115 | + | } | |||
| 116 | + | browserLaunchRelay = relay | |||
| 117 | + | } | |||
| 118 | + | ||||
| 100 | func main() { | 119 | func main() { | ||
| 101 | enableCrashForensics() | 120 | enableCrashForensics() | ||
| 102 | 121 | ||||
| @@ -114,6 +133,8 @@ func main() { | |||||
| 114 | mode, browseURL := launchModeFromArgs(os.Args) | 133 | mode, browseURL := launchModeFromArgs(os.Args) | ||
| 115 | // Run GUI mode for browse command | 134 | // Run GUI mode for browse command | ||
| 116 | if mode == launchModeBrowse { | 135 | if mode == launchModeBrowse { | ||
| 136 | + | cfg := initConfig() | |||
| 137 | + | configureBrowserLaunchRelay(cfg) | |||
| 117 | if forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), browserLaunchRelay, browseURL); err != nil { | 138 | if forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), browserLaunchRelay, browseURL); err != nil { | ||
| 118 | fmt.Fprintf( | 139 | fmt.Fprintf( | ||
| 119 | os.Stderr, | 140 | os.Stderr, | ||
| @@ -128,7 +149,7 @@ func main() { | |||||
| 128 | initialURL = browseURL | 149 | initialURL = browseURL | ||
| 129 | restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION") | 150 | restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION") | ||
| 130 | os.Args = os.Args[:1] | 151 | os.Args = os.Args[:1] | ||
| 131 | - | os.Exit(runGUI()) | 152 | + | os.Exit(runGUI(cfg)) |
| 132 | return | 153 | return | ||
| 133 | } | 154 | } | ||
| 134 | 155 | ||||
| @@ -149,14 +170,17 @@ func main() { | |||||
| 149 | cmd.Execute() | 170 | cmd.Execute() | ||
| 150 | } | 171 | } | ||
| 151 | 172 | ||||
| 152 | - | func runGUI() int { | 173 | + | func runGUI(cfg *config.Config) int { |
| 153 | bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr) | 174 | bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr) | ||
| 154 | 175 | ||||
| 155 | runtime.LockOSThread() | 176 | runtime.LockOSThread() | ||
| 156 | component.SetSkeletonVersion(version) | 177 | component.SetSkeletonVersion(version) | ||
| 157 | timer := bootstrap.NewStartupTimer() | 178 | timer := bootstrap.NewStartupTimer() | ||
| 158 | 179 | ||||
| 159 | - | cfg := initConfig() | 180 | + | if cfg == nil { |
| 181 | + | cfg = initConfig() | |||
| 182 | + | configureBrowserLaunchRelay(cfg) | |||
| 183 | + | } | |||
| 160 | timer.Mark("config") | 184 | timer.Mark("config") | ||
| 161 | 185 | ||||
| 162 | ctx := initStartupContextWithTrace(cfg) | 186 | ctx := initStartupContextWithTrace(cfg) | ||
| @@ -232,6 +256,7 @@ func runStandaloneOmnibox() int { | |||||
| 232 | component.SetSkeletonVersion(version) | 256 | component.SetSkeletonVersion(version) | ||
| 233 | 257 | ||||
| 234 | cfg := initConfig() | 258 | cfg := initConfig() | ||
| 259 | + | configureBrowserLaunchRelay(cfg) | |||
| 235 | ctx := initStartupContextWithTrace(cfg) | 260 | ctx := initStartupContextWithTrace(cfg) | ||
| 236 | 261 | ||||
| 237 | initResult, err := runParallelInitPhase(ctx, cfg) | 262 | initResult, err := runParallelInitPhase(ctx, cfg) | ||
| @@ -260,6 +285,7 @@ func runStandaloneOmnibox() int { | |||||
| 260 | uiDeps, err := buildUIDependencies( | 285 | uiDeps, err := buildUIDependencies( | ||
| 261 | ctx, | 286 | ctx, | ||
| 262 | cfg, | 287 | cfg, | ||
| 288 | + | initResult.RuntimeProfile, | |||
| 263 | initResult.ThemeManager, | 289 | initResult.ThemeManager, | ||
| 264 | initResult.ColorResolver, | 290 | initResult.ColorResolver, | ||
| 265 | initResult.AdwaitaDetector, | 291 | initResult.AdwaitaDetector, | ||
| @@ -391,7 +417,7 @@ func buildAndConfigureApp( | |||||
| 391 | browserSession *bootstrap.BrowserSession, | 417 | browserSession *bootstrap.BrowserSession, | ||
| 392 | ) (*ui.App, error) { | 418 | ) (*ui.App, error) { | ||
| 393 | uiDeps, err := buildUIDependencies( | 419 | uiDeps, err := buildUIDependencies( | ||
| 394 | - | ctx, cfg, initResult.ThemeManager, | 420 | + | ctx, cfg, initResult.RuntimeProfile, initResult.ThemeManager, |
| 395 | initResult.ColorResolver, initResult.AdwaitaDetector, | 421 | initResult.ColorResolver, initResult.AdwaitaDetector, | ||
| 396 | engine, repos, useCases, idleInhibitor, browserSession.Session.ID, browserSession.CrashReports(), | 422 | engine, repos, useCases, idleInhibitor, browserSession.Session.ID, browserSession.CrashReports(), | ||
| 397 | ) | 423 | ) | ||
| @@ -427,12 +453,11 @@ func initStackAndRepos( | |||||
| 427 | if needsEagerDB { | 453 | if needsEagerDB { | ||
| 428 | // Parallel phase 2: Database + engine initialize concurrently | 454 | // Parallel phase 2: Database + engine initialize concurrently | ||
| 429 | dbEngine, err := bootstrap.RunParallelDBEngine(bootstrap.ParallelDBEngineInput{ | 455 | dbEngine, err := bootstrap.RunParallelDBEngine(bootstrap.ParallelDBEngineInput{ | ||
| 430 | - | Ctx: ctx, | 456 | + | Ctx: ctx, |
| 431 | - | Config: cfg, | 457 | + | Config: cfg, |
| 432 | - | DataDir: initResult.DataDir, | 458 | + | RuntimeProfile: initResult.RuntimeProfile, |
| 433 | - | CacheDir: initResult.CacheDir, | 459 | + | ThemeManager: initResult.ThemeManager, |
| 434 | - | ThemeManager: initResult.ThemeManager, | 460 | + | ColorResolver: initResult.ColorResolver, |
| 435 | - | ColorResolver: initResult.ColorResolver, | |||
| 436 | }) | 461 | }) | ||
| 437 | if err != nil { | 462 | if err != nil { | ||
| 438 | return nil, nil, nil, err | 463 | return nil, nil, nil, err | ||
| @@ -442,13 +467,12 @@ func initStackAndRepos( | |||||
| 442 | 467 | ||||
| 443 | log := logging.FromContext(ctx) | 468 | log := logging.FromContext(ctx) | ||
| 444 | engine, err := bootstrap.BuildEngine(bootstrap.EngineInput{ | 469 | engine, err := bootstrap.BuildEngine(bootstrap.EngineInput{ | ||
| 445 | - | Ctx: ctx, | 470 | + | Ctx: ctx, |
| 446 | - | Config: cfg, | 471 | + | Config: cfg, |
| 447 | - | DataDir: initResult.DataDir, | 472 | + | RuntimeProfile: initResult.RuntimeProfile, |
| 448 | - | CacheDir: initResult.CacheDir, | 473 | + | ThemeManager: initResult.ThemeManager, |
| 449 | - | ThemeManager: initResult.ThemeManager, | 474 | + | ColorResolver: initResult.ColorResolver, |
| 450 | - | ColorResolver: initResult.ColorResolver, | 475 | + | Logger: *log, |
| 451 | - | Logger: *log, | |||
| 452 | }) | 476 | }) | ||
| 453 | if err != nil { | 477 | if err != nil { | ||
| 454 | return nil, nil, nil, err | 478 | return nil, nil, nil, err | ||
| @@ -717,6 +741,7 @@ func isCEFSubprocess() bool { | |||||
| 717 | func buildUIDependencies( | 741 | func buildUIDependencies( | ||
| 718 | ctx context.Context, | 742 | ctx context.Context, | ||
| 719 | cfg *config.Config, | 743 | cfg *config.Config, | ||
| 744 | + | runtimeProfile runtimeprofile.Profile, | |||
| 720 | themeManager *theme.Manager, | 745 | themeManager *theme.Manager, | ||
| 721 | colorResolver port.ColorSchemeResolver, | 746 | colorResolver port.ColorSchemeResolver, | ||
| 722 | adwaitaDetector port.ToolkitAvailabilityNotifier, | 747 | adwaitaDetector port.ToolkitAvailabilityNotifier, | ||
| @@ -782,7 +807,7 @@ func buildUIDependencies( | |||||
| 782 | }, | 807 | }, | ||
| 783 | CheckUpdateUC: uc.checkUpdate, | 808 | CheckUpdateUC: uc.checkUpdate, | ||
| 784 | ApplyUpdateUC: uc.applyUpdate, | 809 | ApplyUpdateUC: uc.applyUpdate, | ||
| 785 | - | SessionSpawner: bootstrap.NewSessionSpawner(ctx, cfg), | 810 | + | SessionSpawner: bootstrap.NewSessionSpawner(ctx, runtimeProfile), |
| 786 | FileSystem: filesystem.New(), | 811 | FileSystem: filesystem.New(), | ||
| 787 | AccentFocusProvider: focusProvider, | 812 | AccentFocusProvider: focusProvider, | ||
| 788 | NewGTKEntryTarget: func(entry *gtk.SearchEntry) port.TextInputTarget { | 813 | NewGTKEntryTarget: func(entry *gtk.SearchEntry) port.TextInputTarget { | ||
M internal/application/port/mocks/mocks.go +141 -0
| @@ -730,6 +730,147 @@ func (_c *MockClipboard_WriteText_Call) RunAndReturn(run func(ctx context.Contex | |||
| 730 | 730 | return _c | |
| 731 | 731 | } | |
| 732 | 732 | ||
| 733 | + | // NewMockClipboardTextOrchestrator creates a new instance of MockClipboardTextOrchestrator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 734 | + | // The first argument is typically a *testing.T value. | |
| 735 | + | func NewMockClipboardTextOrchestrator(t interface { | |
| 736 | + | mock.TestingT | |
| 737 | + | Cleanup(func()) | |
| 738 | + | }) *MockClipboardTextOrchestrator { | |
| 739 | + | mock := &MockClipboardTextOrchestrator{} | |
| 740 | + | mock.Mock.Test(t) | |
| 741 | + | ||
| 742 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 743 | + | ||
| 744 | + | return mock | |
| 745 | + | } | |
| 746 | + | ||
| 747 | + | // MockClipboardTextOrchestrator is an autogenerated mock type for the ClipboardTextOrchestrator type | |
| 748 | + | type MockClipboardTextOrchestrator struct { | |
| 749 | + | mock.Mock | |
| 750 | + | } | |
| 751 | + | ||
| 752 | + | type MockClipboardTextOrchestrator_Expecter struct { | |
| 753 | + | mock *mock.Mock | |
| 754 | + | } | |
| 755 | + | ||
| 756 | + | func (_m *MockClipboardTextOrchestrator) EXPECT() *MockClipboardTextOrchestrator_Expecter { | |
| 757 | + | return &MockClipboardTextOrchestrator_Expecter{mock: &_m.Mock} | |
| 758 | + | } | |
| 759 | + | ||
| 760 | + | // HandleExplicitCopy provides a mock function for the type MockClipboardTextOrchestrator | |
| 761 | + | func (_mock *MockClipboardTextOrchestrator) HandleExplicitCopy(ctx context.Context, input port.ExplicitClipboardInput) error { | |
| 762 | + | ret := _mock.Called(ctx, input) | |
| 763 | + | ||
| 764 | + | if len(ret) == 0 { | |
| 765 | + | panic("no return value specified for HandleExplicitCopy") | |
| 766 | + | } | |
| 767 | + | ||
| 768 | + | var r0 error | |
| 769 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, port.ExplicitClipboardInput) error); ok { | |
| 770 | + | r0 = returnFunc(ctx, input) | |
| 771 | + | } else { | |
| 772 | + | r0 = ret.Error(0) | |
| 773 | + | } | |
| 774 | + | return r0 | |
| 775 | + | } | |
| 776 | + | ||
| 777 | + | // MockClipboardTextOrchestrator_HandleExplicitCopy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HandleExplicitCopy' | |
| 778 | + | type MockClipboardTextOrchestrator_HandleExplicitCopy_Call struct { | |
| 779 | + | *mock.Call | |
| 780 | + | } | |
| 781 | + | ||
| 782 | + | // HandleExplicitCopy is a helper method to define mock.On call | |
| 783 | + | // - ctx context.Context | |
| 784 | + | // - input port.ExplicitClipboardInput | |
| 785 | + | func (_e *MockClipboardTextOrchestrator_Expecter) HandleExplicitCopy(ctx interface{}, input interface{}) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call { | |
| 786 | + | return &MockClipboardTextOrchestrator_HandleExplicitCopy_Call{Call: _e.mock.On("HandleExplicitCopy", ctx, input)} | |
| 787 | + | } | |
| 788 | + | ||
| 789 | + | func (_c *MockClipboardTextOrchestrator_HandleExplicitCopy_Call) Run(run func(ctx context.Context, input port.ExplicitClipboardInput)) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call { | |
| 790 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 791 | + | var arg0 context.Context | |
| 792 | + | if args[0] != nil { | |
| 793 | + | arg0 = args[0].(context.Context) | |
| 794 | + | } | |
| 795 | + | var arg1 port.ExplicitClipboardInput | |
| 796 | + | if args[1] != nil { | |
| 797 | + | arg1 = args[1].(port.ExplicitClipboardInput) | |
| 798 | + | } | |
| 799 | + | run( | |
| 800 | + | arg0, | |
| 801 | + | arg1, | |
| 802 | + | ) | |
| 803 | + | }) | |
| 804 | + | return _c | |
| 805 | + | } | |
| 806 | + | ||
| 807 | + | func (_c *MockClipboardTextOrchestrator_HandleExplicitCopy_Call) Return(err error) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call { | |
| 808 | + | _c.Call.Return(err) | |
| 809 | + | return _c | |
| 810 | + | } | |
| 811 | + | ||
| 812 | + | func (_c *MockClipboardTextOrchestrator_HandleExplicitCopy_Call) RunAndReturn(run func(ctx context.Context, input port.ExplicitClipboardInput) error) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call { | |
| 813 | + | _c.Call.Return(run) | |
| 814 | + | return _c | |
| 815 | + | } | |
| 816 | + | ||
| 817 | + | // HandleSelectionUpdate provides a mock function for the type MockClipboardTextOrchestrator | |
| 818 | + | func (_mock *MockClipboardTextOrchestrator) HandleSelectionUpdate(ctx context.Context, input port.SelectionClipboardInput) error { | |
| 819 | + | ret := _mock.Called(ctx, input) | |
| 820 | + | ||
| 821 | + | if len(ret) == 0 { | |
| 822 | + | panic("no return value specified for HandleSelectionUpdate") | |
| 823 | + | } | |
| 824 | + | ||
| 825 | + | var r0 error | |
| 826 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, port.SelectionClipboardInput) error); ok { | |
| 827 | + | r0 = returnFunc(ctx, input) | |
| 828 | + | } else { | |
| 829 | + | r0 = ret.Error(0) | |
| 830 | + | } | |
| 831 | + | return r0 | |
| 832 | + | } | |
| 833 | + | ||
| 834 | + | // MockClipboardTextOrchestrator_HandleSelectionUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HandleSelectionUpdate' | |
| 835 | + | type MockClipboardTextOrchestrator_HandleSelectionUpdate_Call struct { | |
| 836 | + | *mock.Call | |
| 837 | + | } | |
| 838 | + | ||
| 839 | + | // HandleSelectionUpdate is a helper method to define mock.On call | |
| 840 | + | // - ctx context.Context | |
| 841 | + | // - input port.SelectionClipboardInput | |
| 842 | + | func (_e *MockClipboardTextOrchestrator_Expecter) HandleSelectionUpdate(ctx interface{}, input interface{}) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call { | |
| 843 | + | return &MockClipboardTextOrchestrator_HandleSelectionUpdate_Call{Call: _e.mock.On("HandleSelectionUpdate", ctx, input)} | |
| 844 | + | } | |
| 845 | + | ||
| 846 | + | func (_c *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call) Run(run func(ctx context.Context, input port.SelectionClipboardInput)) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call { | |
| 847 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 848 | + | var arg0 context.Context | |
| 849 | + | if args[0] != nil { | |
| 850 | + | arg0 = args[0].(context.Context) | |
| 851 | + | } | |
| 852 | + | var arg1 port.SelectionClipboardInput | |
| 853 | + | if args[1] != nil { | |
| 854 | + | arg1 = args[1].(port.SelectionClipboardInput) | |
| 855 | + | } | |
| 856 | + | run( | |
| 857 | + | arg0, | |
| 858 | + | arg1, | |
| 859 | + | ) | |
| 860 | + | }) | |
| 861 | + | return _c | |
| 862 | + | } | |
| 863 | + | ||
| 864 | + | func (_c *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call) Return(err error) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call { | |
| 865 | + | _c.Call.Return(err) | |
| 866 | + | return _c | |
| 867 | + | } | |
| 868 | + | ||
| 869 | + | func (_c *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call) RunAndReturn(run func(ctx context.Context, input port.SelectionClipboardInput) error) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call { | |
| 870 | + | _c.Call.Return(run) | |
| 871 | + | return _c | |
| 872 | + | } | |
| 873 | + | ||
| 733 | 874 | // NewMockColorSchemeDetector creates a new instance of MockColorSchemeDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 734 | 875 | // The first argument is typically a *testing.T value. | |
| 735 | 876 | func NewMockColorSchemeDetector(t interface { | |
| @@ -730,6 +730,147 @@ func (_c *MockClipboard_WriteText_Call) RunAndReturn(run func(ctx context.Contex | |||||
| 730 | return _c | 730 | return _c | ||
| 731 | } | 731 | } | ||
| 732 | 732 | ||||
| 733 | + | // NewMockClipboardTextOrchestrator creates a new instance of MockClipboardTextOrchestrator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 734 | + | // The first argument is typically a *testing.T value. | |||
| 735 | + | func NewMockClipboardTextOrchestrator(t interface { | |||
| 736 | + | mock.TestingT | |||
| 737 | + | Cleanup(func()) | |||
| 738 | + | }) *MockClipboardTextOrchestrator { | |||
| 739 | + | mock := &MockClipboardTextOrchestrator{} | |||
| 740 | + | mock.Mock.Test(t) | |||
| 741 | + | ||||
| 742 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 743 | + | ||||
| 744 | + | return mock | |||
| 745 | + | } | |||
| 746 | + | ||||
| 747 | + | // MockClipboardTextOrchestrator is an autogenerated mock type for the ClipboardTextOrchestrator type | |||
| 748 | + | type MockClipboardTextOrchestrator struct { | |||
| 749 | + | mock.Mock | |||
| 750 | + | } | |||
| 751 | + | ||||
| 752 | + | type MockClipboardTextOrchestrator_Expecter struct { | |||
| 753 | + | mock *mock.Mock | |||
| 754 | + | } | |||
| 755 | + | ||||
| 756 | + | func (_m *MockClipboardTextOrchestrator) EXPECT() *MockClipboardTextOrchestrator_Expecter { | |||
| 757 | + | return &MockClipboardTextOrchestrator_Expecter{mock: &_m.Mock} | |||
| 758 | + | } | |||
| 759 | + | ||||
| 760 | + | // HandleExplicitCopy provides a mock function for the type MockClipboardTextOrchestrator | |||
| 761 | + | func (_mock *MockClipboardTextOrchestrator) HandleExplicitCopy(ctx context.Context, input port.ExplicitClipboardInput) error { | |||
| 762 | + | ret := _mock.Called(ctx, input) | |||
| 763 | + | ||||
| 764 | + | if len(ret) == 0 { | |||
| 765 | + | panic("no return value specified for HandleExplicitCopy") | |||
| 766 | + | } | |||
| 767 | + | ||||
| 768 | + | var r0 error | |||
| 769 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, port.ExplicitClipboardInput) error); ok { | |||
| 770 | + | r0 = returnFunc(ctx, input) | |||
| 771 | + | } else { | |||
| 772 | + | r0 = ret.Error(0) | |||
| 773 | + | } | |||
| 774 | + | return r0 | |||
| 775 | + | } | |||
| 776 | + | ||||
| 777 | + | // MockClipboardTextOrchestrator_HandleExplicitCopy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HandleExplicitCopy' | |||
| 778 | + | type MockClipboardTextOrchestrator_HandleExplicitCopy_Call struct { | |||
| 779 | + | *mock.Call | |||
| 780 | + | } | |||
| 781 | + | ||||
| 782 | + | // HandleExplicitCopy is a helper method to define mock.On call | |||
| 783 | + | // - ctx context.Context | |||
| 784 | + | // - input port.ExplicitClipboardInput | |||
| 785 | + | func (_e *MockClipboardTextOrchestrator_Expecter) HandleExplicitCopy(ctx interface{}, input interface{}) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call { | |||
| 786 | + | return &MockClipboardTextOrchestrator_HandleExplicitCopy_Call{Call: _e.mock.On("HandleExplicitCopy", ctx, input)} | |||
| 787 | + | } | |||
| 788 | + | ||||
| 789 | + | func (_c *MockClipboardTextOrchestrator_HandleExplicitCopy_Call) Run(run func(ctx context.Context, input port.ExplicitClipboardInput)) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call { | |||
| 790 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 791 | + | var arg0 context.Context | |||
| 792 | + | if args[0] != nil { | |||
| 793 | + | arg0 = args[0].(context.Context) | |||
| 794 | + | } | |||
| 795 | + | var arg1 port.ExplicitClipboardInput | |||
| 796 | + | if args[1] != nil { | |||
| 797 | + | arg1 = args[1].(port.ExplicitClipboardInput) | |||
| 798 | + | } | |||
| 799 | + | run( | |||
| 800 | + | arg0, | |||
| 801 | + | arg1, | |||
| 802 | + | ) | |||
| 803 | + | }) | |||
| 804 | + | return _c | |||
| 805 | + | } | |||
| 806 | + | ||||
| 807 | + | func (_c *MockClipboardTextOrchestrator_HandleExplicitCopy_Call) Return(err error) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call { | |||
| 808 | + | _c.Call.Return(err) | |||
| 809 | + | return _c | |||
| 810 | + | } | |||
| 811 | + | ||||
| 812 | + | func (_c *MockClipboardTextOrchestrator_HandleExplicitCopy_Call) RunAndReturn(run func(ctx context.Context, input port.ExplicitClipboardInput) error) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call { | |||
| 813 | + | _c.Call.Return(run) | |||
| 814 | + | return _c | |||
| 815 | + | } | |||
| 816 | + | ||||
| 817 | + | // HandleSelectionUpdate provides a mock function for the type MockClipboardTextOrchestrator | |||
| 818 | + | func (_mock *MockClipboardTextOrchestrator) HandleSelectionUpdate(ctx context.Context, input port.SelectionClipboardInput) error { | |||
| 819 | + | ret := _mock.Called(ctx, input) | |||
| 820 | + | ||||
| 821 | + | if len(ret) == 0 { | |||
| 822 | + | panic("no return value specified for HandleSelectionUpdate") | |||
| 823 | + | } | |||
| 824 | + | ||||
| 825 | + | var r0 error | |||
| 826 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, port.SelectionClipboardInput) error); ok { | |||
| 827 | + | r0 = returnFunc(ctx, input) | |||
| 828 | + | } else { | |||
| 829 | + | r0 = ret.Error(0) | |||
| 830 | + | } | |||
| 831 | + | return r0 | |||
| 832 | + | } | |||
| 833 | + | ||||
| 834 | + | // MockClipboardTextOrchestrator_HandleSelectionUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HandleSelectionUpdate' | |||
| 835 | + | type MockClipboardTextOrchestrator_HandleSelectionUpdate_Call struct { | |||
| 836 | + | *mock.Call | |||
| 837 | + | } | |||
| 838 | + | ||||
| 839 | + | // HandleSelectionUpdate is a helper method to define mock.On call | |||
| 840 | + | // - ctx context.Context | |||
| 841 | + | // - input port.SelectionClipboardInput | |||
| 842 | + | func (_e *MockClipboardTextOrchestrator_Expecter) HandleSelectionUpdate(ctx interface{}, input interface{}) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call { | |||
| 843 | + | return &MockClipboardTextOrchestrator_HandleSelectionUpdate_Call{Call: _e.mock.On("HandleSelectionUpdate", ctx, input)} | |||
| 844 | + | } | |||
| 845 | + | ||||
| 846 | + | func (_c *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call) Run(run func(ctx context.Context, input port.SelectionClipboardInput)) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call { | |||
| 847 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 848 | + | var arg0 context.Context | |||
| 849 | + | if args[0] != nil { | |||
| 850 | + | arg0 = args[0].(context.Context) | |||
| 851 | + | } | |||
| 852 | + | var arg1 port.SelectionClipboardInput | |||
| 853 | + | if args[1] != nil { | |||
| 854 | + | arg1 = args[1].(port.SelectionClipboardInput) | |||
| 855 | + | } | |||
| 856 | + | run( | |||
| 857 | + | arg0, | |||
| 858 | + | arg1, | |||
| 859 | + | ) | |||
| 860 | + | }) | |||
| 861 | + | return _c | |||
| 862 | + | } | |||
| 863 | + | ||||
| 864 | + | func (_c *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call) Return(err error) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call { | |||
| 865 | + | _c.Call.Return(err) | |||
| 866 | + | return _c | |||
| 867 | + | } | |||
| 868 | + | ||||
| 869 | + | func (_c *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call) RunAndReturn(run func(ctx context.Context, input port.SelectionClipboardInput) error) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call { | |||
| 870 | + | _c.Call.Return(run) | |||
| 871 | + | return _c | |||
| 872 | + | } | |||
| 873 | + | ||||
| 733 | // NewMockColorSchemeDetector creates a new instance of MockColorSchemeDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | 874 | // NewMockColorSchemeDetector creates a new instance of MockColorSchemeDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | ||
| 734 | // The first argument is typically a *testing.T value. | 875 | // The first argument is typically a *testing.T value. | ||
| 735 | func NewMockColorSchemeDetector(t interface { | 876 | func NewMockColorSchemeDetector(t interface { | ||
M internal/application/usecase/clipboard_text_orchestrator.go +10 -29
| @@ -20,17 +20,12 @@ type ClipboardTextOrchestratorUseCase struct { | |||
| 20 | 20 | autoCopyConfig port.AutoCopyConfig | |
| 21 | 21 | toast func(textLen int) | |
| 22 | 22 | mu sync.Mutex | |
| 23 | - | lastSelection map[clipboardSelectionScope]string | |
| 24 | - | lastExplicit map[clipboardExplicitScope]explicitCopyState | |
| 23 | + | lastSelection map[clipboardScope]string | |
| 24 | + | lastExplicit map[clipboardScope]explicitCopyState | |
| 25 | 25 | now func() time.Time | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | - | type clipboardSelectionScope struct { | |
| 29 | - | source port.ClipboardSource | |
| 30 | - | viewID port.WebViewID | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | type clipboardExplicitScope struct { | |
| 28 | + | type clipboardScope struct { | |
| 34 | 29 | source port.ClipboardSource | |
| 35 | 30 | viewID port.WebViewID | |
| 36 | 31 | } | |
| @@ -52,8 +47,8 @@ func NewClipboardTextOrchestrator( | |||
| 52 | 47 | clipboard: clipboard, | |
| 53 | 48 | autoCopyConfig: autoCopyConfig, | |
| 54 | 49 | toast: toast, | |
| 55 | - | lastSelection: make(map[clipboardSelectionScope]string), | |
| 56 | - | lastExplicit: make(map[clipboardExplicitScope]explicitCopyState), | |
| 50 | + | lastSelection: make(map[clipboardScope]string), | |
| 51 | + | lastExplicit: make(map[clipboardScope]explicitCopyState), | |
| 57 | 52 | now: time.Now, | |
| 58 | 53 | } | |
| 59 | 54 | } | |
| @@ -66,9 +61,7 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate( | |||
| 66 | 61 | if uc == nil || uc.autoCopyConfig == nil || !uc.autoCopyConfig.IsAutoCopyEnabled() { | |
| 67 | 62 | if uc != nil { | |
| 68 | 63 | uc.mu.Lock() | |
| 69 | - | if uc.lastSelection != nil { | |
| 70 | - | delete(uc.lastSelection, selectionScope(input)) | |
| 71 | - | } | |
| 64 | + | delete(uc.lastSelection, selectionScope(input)) | |
| 72 | 65 | uc.mu.Unlock() | |
| 73 | 66 | } | |
| 74 | 67 | return nil | |
| @@ -76,9 +69,6 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate( | |||
| 76 | 69 | ||
| 77 | 70 | if input.Text == "" { | |
| 78 | 71 | uc.mu.Lock() | |
| 79 | - | if uc.lastSelection == nil { | |
| 80 | - | uc.lastSelection = make(map[clipboardSelectionScope]string) | |
| 81 | - | } | |
| 82 | 72 | delete(uc.lastSelection, selectionScope(input)) | |
| 83 | 73 | uc.mu.Unlock() | |
| 84 | 74 | return nil | |
| @@ -96,9 +86,6 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate( | |||
| 96 | 86 | currentTime := now() | |
| 97 | 87 | ||
| 98 | 88 | uc.mu.Lock() | |
| 99 | - | if uc.lastSelection == nil { | |
| 100 | - | uc.lastSelection = make(map[clipboardSelectionScope]string) | |
| 101 | - | } | |
| 102 | 89 | if input.Text == uc.lastSelection[scope] { | |
| 103 | 90 | uc.mu.Unlock() | |
| 104 | 91 | return nil | |
| @@ -113,9 +100,6 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate( | |||
| 113 | 100 | } | |
| 114 | 101 | ||
| 115 | 102 | uc.lastSelection[scope] = input.Text | |
| 116 | - | if uc.lastExplicit == nil { | |
| 117 | - | uc.lastExplicit = make(map[clipboardExplicitScope]explicitCopyState) | |
| 118 | - | } | |
| 119 | 103 | explicitInput := port.ExplicitClipboardInput{SourceEngine: input.SourceEngine, ViewID: input.ViewID} | |
| 120 | 104 | uc.lastExplicit[explicitScope(explicitInput)] = explicitCopyState{ | |
| 121 | 105 | text: input.Text, | |
| @@ -151,9 +135,6 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy( | |||
| 151 | 135 | scope := explicitScope(input) | |
| 152 | 136 | ||
| 153 | 137 | uc.mu.Lock() | |
| 154 | - | if uc.lastExplicit == nil { | |
| 155 | - | uc.lastExplicit = make(map[clipboardExplicitScope]explicitCopyState) | |
| 156 | - | } | |
| 157 | 138 | if state, ok := uc.lastExplicit[scope]; ok && | |
| 158 | 139 | input.Text == state.text && | |
| 159 | 140 | currentTime.Sub(state.at) < explicitClipboardDedupWindow { | |
| @@ -186,10 +167,10 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy( | |||
| 186 | 167 | return nil | |
| 187 | 168 | } | |
| 188 | 169 | ||
| 189 | - | func selectionScope(input port.SelectionClipboardInput) clipboardSelectionScope { | |
| 190 | - | return clipboardSelectionScope{source: input.SourceEngine, viewID: input.ViewID} | |
| 170 | + | func selectionScope(input port.SelectionClipboardInput) clipboardScope { | |
| 171 | + | return clipboardScope{source: input.SourceEngine, viewID: input.ViewID} | |
| 191 | 172 | } | |
| 192 | 173 | ||
| 193 | - | func explicitScope(input port.ExplicitClipboardInput) clipboardExplicitScope { | |
| 194 | - | return clipboardExplicitScope{source: input.SourceEngine, viewID: input.ViewID} | |
| 174 | + | func explicitScope(input port.ExplicitClipboardInput) clipboardScope { | |
| 175 | + | return clipboardScope{source: input.SourceEngine, viewID: input.ViewID} | |
| 195 | 176 | } | |
| @@ -20,17 +20,12 @@ type ClipboardTextOrchestratorUseCase struct { | |||||
| 20 | autoCopyConfig port.AutoCopyConfig | 20 | autoCopyConfig port.AutoCopyConfig | ||
| 21 | toast func(textLen int) | 21 | toast func(textLen int) | ||
| 22 | mu sync.Mutex | 22 | mu sync.Mutex | ||
| 23 | - | lastSelection map[clipboardSelectionScope]string | 23 | + | lastSelection map[clipboardScope]string |
| 24 | - | lastExplicit map[clipboardExplicitScope]explicitCopyState | 24 | + | lastExplicit map[clipboardScope]explicitCopyState |
| 25 | now func() time.Time | 25 | now func() time.Time | ||
| 26 | } | 26 | } | ||
| 27 | 27 | ||||
| 28 | - | type clipboardSelectionScope struct { | 28 | + | type clipboardScope struct { |
| 29 | - | source port.ClipboardSource | |||
| 30 | - | viewID port.WebViewID | |||
| 31 | - | } | |||
| 32 | - | ||||
| 33 | - | type clipboardExplicitScope struct { | |||
| 34 | source port.ClipboardSource | 29 | source port.ClipboardSource | ||
| 35 | viewID port.WebViewID | 30 | viewID port.WebViewID | ||
| 36 | } | 31 | } | ||
| @@ -52,8 +47,8 @@ func NewClipboardTextOrchestrator( | |||||
| 52 | clipboard: clipboard, | 47 | clipboard: clipboard, | ||
| 53 | autoCopyConfig: autoCopyConfig, | 48 | autoCopyConfig: autoCopyConfig, | ||
| 54 | toast: toast, | 49 | toast: toast, | ||
| 55 | - | lastSelection: make(map[clipboardSelectionScope]string), | 50 | + | lastSelection: make(map[clipboardScope]string), |
| 56 | - | lastExplicit: make(map[clipboardExplicitScope]explicitCopyState), | 51 | + | lastExplicit: make(map[clipboardScope]explicitCopyState), |
| 57 | now: time.Now, | 52 | now: time.Now, | ||
| 58 | } | 53 | } | ||
| 59 | } | 54 | } | ||
| @@ -66,9 +61,7 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate( | |||||
| 66 | if uc == nil || uc.autoCopyConfig == nil || !uc.autoCopyConfig.IsAutoCopyEnabled() { | 61 | if uc == nil || uc.autoCopyConfig == nil || !uc.autoCopyConfig.IsAutoCopyEnabled() { | ||
| 67 | if uc != nil { | 62 | if uc != nil { | ||
| 68 | uc.mu.Lock() | 63 | uc.mu.Lock() | ||
| 69 | - | if uc.lastSelection != nil { | 64 | + | delete(uc.lastSelection, selectionScope(input)) |
| 70 | - | delete(uc.lastSelection, selectionScope(input)) | |||
| 71 | - | } | |||
| 72 | uc.mu.Unlock() | 65 | uc.mu.Unlock() | ||
| 73 | } | 66 | } | ||
| 74 | return nil | 67 | return nil | ||
| @@ -76,9 +69,6 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate( | |||||
| 76 | 69 | ||||
| 77 | if input.Text == "" { | 70 | if input.Text == "" { | ||
| 78 | uc.mu.Lock() | 71 | uc.mu.Lock() | ||
| 79 | - | if uc.lastSelection == nil { | |||
| 80 | - | uc.lastSelection = make(map[clipboardSelectionScope]string) | |||
| 81 | - | } | |||
| 82 | delete(uc.lastSelection, selectionScope(input)) | 72 | delete(uc.lastSelection, selectionScope(input)) | ||
| 83 | uc.mu.Unlock() | 73 | uc.mu.Unlock() | ||
| 84 | return nil | 74 | return nil | ||
| @@ -96,9 +86,6 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate( | |||||
| 96 | currentTime := now() | 86 | currentTime := now() | ||
| 97 | 87 | ||||
| 98 | uc.mu.Lock() | 88 | uc.mu.Lock() | ||
| 99 | - | if uc.lastSelection == nil { | |||
| 100 | - | uc.lastSelection = make(map[clipboardSelectionScope]string) | |||
| 101 | - | } | |||
| 102 | if input.Text == uc.lastSelection[scope] { | 89 | if input.Text == uc.lastSelection[scope] { | ||
| 103 | uc.mu.Unlock() | 90 | uc.mu.Unlock() | ||
| 104 | return nil | 91 | return nil | ||
| @@ -113,9 +100,6 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate( | |||||
| 113 | } | 100 | } | ||
| 114 | 101 | ||||
| 115 | uc.lastSelection[scope] = input.Text | 102 | uc.lastSelection[scope] = input.Text | ||
| 116 | - | if uc.lastExplicit == nil { | |||
| 117 | - | uc.lastExplicit = make(map[clipboardExplicitScope]explicitCopyState) | |||
| 118 | - | } | |||
| 119 | explicitInput := port.ExplicitClipboardInput{SourceEngine: input.SourceEngine, ViewID: input.ViewID} | 103 | explicitInput := port.ExplicitClipboardInput{SourceEngine: input.SourceEngine, ViewID: input.ViewID} | ||
| 120 | uc.lastExplicit[explicitScope(explicitInput)] = explicitCopyState{ | 104 | uc.lastExplicit[explicitScope(explicitInput)] = explicitCopyState{ | ||
| 121 | text: input.Text, | 105 | text: input.Text, | ||
| @@ -151,9 +135,6 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy( | |||||
| 151 | scope := explicitScope(input) | 135 | scope := explicitScope(input) | ||
| 152 | 136 | ||||
| 153 | uc.mu.Lock() | 137 | uc.mu.Lock() | ||
| 154 | - | if uc.lastExplicit == nil { | |||
| 155 | - | uc.lastExplicit = make(map[clipboardExplicitScope]explicitCopyState) | |||
| 156 | - | } | |||
| 157 | if state, ok := uc.lastExplicit[scope]; ok && | 138 | if state, ok := uc.lastExplicit[scope]; ok && | ||
| 158 | input.Text == state.text && | 139 | input.Text == state.text && | ||
| 159 | currentTime.Sub(state.at) < explicitClipboardDedupWindow { | 140 | currentTime.Sub(state.at) < explicitClipboardDedupWindow { | ||
| @@ -186,10 +167,10 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy( | |||||
| 186 | return nil | 167 | return nil | ||
| 187 | } | 168 | } | ||
| 188 | 169 | ||||
| 189 | - | func selectionScope(input port.SelectionClipboardInput) clipboardSelectionScope { | 170 | + | func selectionScope(input port.SelectionClipboardInput) clipboardScope { |
| 190 | - | return clipboardSelectionScope{source: input.SourceEngine, viewID: input.ViewID} | 171 | + | return clipboardScope{source: input.SourceEngine, viewID: input.ViewID} |
| 191 | } | 172 | } | ||
| 192 | 173 | ||||
| 193 | - | func explicitScope(input port.ExplicitClipboardInput) clipboardExplicitScope { | 174 | + | func explicitScope(input port.ExplicitClipboardInput) clipboardScope { |
| 194 | - | return clipboardExplicitScope{source: input.SourceEngine, viewID: input.ViewID} | 175 | + | return clipboardScope{source: input.SourceEngine, viewID: input.ViewID} |
| 195 | } | 176 | } | ||
M internal/application/usecase/clipboard_text_orchestrator_test.go +3 -3
| @@ -295,13 +295,13 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_KeepsBoundedStatePerScope( | |||
| 295 | 295 | clipboard.EXPECT().WriteText(ctx, "three").Return(nil).Once() | |
| 296 | 296 | ||
| 297 | 297 | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil) | |
| 298 | - | uc.now = func() time.Time { return time.Unix(0, 0) } | |
| 299 | 298 | ||
| 300 | - | for _, text := range []string{"one", "two", "three"} { | |
| 299 | + | for i, text := range []string{"one", "two", "three"} { | |
| 300 | + | advancedTime := time.Unix(0, 0).Add(time.Duration(i) * time.Second) | |
| 301 | + | uc.now = func() time.Time { return advancedTime } | |
| 301 | 302 | if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: text, SourceEngine: port.ClipboardSourceCEF, ViewID: 7, Action: "copy"}); err != nil { | |
| 302 | 303 | t.Fatalf("HandleExplicitCopy(%q) error = %v, want nil", text, err) | |
| 303 | 304 | } | |
| 304 | - | uc.now = func() time.Time { return time.Unix(0, 0).Add(time.Second) } | |
| 305 | 305 | } | |
| 306 | 306 | ||
| 307 | 307 | if got := len(uc.lastExplicit); got != 1 { | |
| @@ -295,13 +295,13 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_KeepsBoundedStatePerScope( | |||||
| 295 | clipboard.EXPECT().WriteText(ctx, "three").Return(nil).Once() | 295 | clipboard.EXPECT().WriteText(ctx, "three").Return(nil).Once() | ||
| 296 | 296 | ||||
| 297 | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil) | 297 | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil) | ||
| 298 | - | uc.now = func() time.Time { return time.Unix(0, 0) } | |||
| 299 | 298 | ||||
| 300 | - | for _, text := range []string{"one", "two", "three"} { | 299 | + | for i, text := range []string{"one", "two", "three"} { |
| 300 | + | advancedTime := time.Unix(0, 0).Add(time.Duration(i) * time.Second) | |||
| 301 | + | uc.now = func() time.Time { return advancedTime } | |||
| 301 | if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: text, SourceEngine: port.ClipboardSourceCEF, ViewID: 7, Action: "copy"}); err != nil { | 302 | if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: text, SourceEngine: port.ClipboardSourceCEF, ViewID: 7, Action: "copy"}); err != nil { | ||
| 302 | t.Fatalf("HandleExplicitCopy(%q) error = %v, want nil", text, err) | 303 | t.Fatalf("HandleExplicitCopy(%q) error = %v, want nil", text, err) | ||
| 303 | } | 304 | } | ||
| 304 | - | uc.now = func() time.Time { return time.Unix(0, 0).Add(time.Second) } | |||
| 305 | } | 305 | } | ||
| 306 | 306 | ||||
| 307 | if got := len(uc.lastExplicit); got != 1 { | 307 | if got := len(uc.lastExplicit); got != 1 { | ||
M internal/bootstrap/engine.go +27 -13
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 14 | 14 | "github.com/bnema/dumber/internal/infrastructure/env" | |
| 15 | 15 | "github.com/bnema/dumber/internal/infrastructure/handlers" | |
| 16 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 16 | 17 | "github.com/bnema/dumber/internal/infrastructure/transcoder" | |
| 17 | 18 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 18 | 19 | "github.com/bnema/dumber/internal/ui/theme" | |
| @@ -21,13 +22,12 @@ import ( | |||
| 21 | 22 | ||
| 22 | 23 | // EngineInput holds the input for BuildEngine. | |
| 23 | 24 | type EngineInput struct { | |
| 24 | - | Ctx context.Context | |
| 25 | - | Config *config.Config | |
| 26 | - | DataDir string | |
| 27 | - | CacheDir string | |
| 28 | - | ThemeManager *theme.Manager | |
| 29 | - | ColorResolver port.ColorSchemeResolver | |
| 30 | - | Logger zerolog.Logger | |
| 25 | + | Ctx context.Context | |
| 26 | + | Config *config.Config | |
| 27 | + | RuntimeProfile runtimeprofile.Profile | |
| 28 | + | ThemeManager *theme.Manager | |
| 29 | + | ColorResolver port.ColorSchemeResolver | |
| 30 | + | Logger zerolog.Logger | |
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | 33 | // BuildEngine constructs a port.Engine for the engine type specified in cfg.Engine.Type. | |
| @@ -38,23 +38,27 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||
| 38 | 38 | engineType := cfg.Engine.ResolveEngineType() | |
| 39 | 39 | switch engineType { | |
| 40 | 40 | case config.EngineTypeWebKit: | |
| 41 | + | profile, err := requireRuntimeProfile(input.RuntimeProfile, engineType) | |
| 42 | + | if err != nil { | |
| 43 | + | return nil, err | |
| 44 | + | } | |
| 41 | 45 | opts := port.EngineOptions{ | |
| 42 | - | DataDir: input.DataDir, | |
| 43 | - | CacheDir: input.CacheDir, | |
| 44 | 46 | CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy), | |
| 45 | 47 | } | |
| 46 | 48 | wkCfg := webkit.EngineConfigFromConfig(cfg.Engine.WebKit) | |
| 47 | 49 | ||
| 48 | 50 | return webkit.NewEngine( | |
| 49 | - | input.Ctx, cfg, opts, wkCfg, | |
| 51 | + | input.Ctx, cfg, opts, profile, wkCfg, | |
| 50 | 52 | input.ThemeManager, input.ColorResolver, | |
| 51 | 53 | contextMenuBuilder, contextMenuExecutorFactory, | |
| 52 | 54 | input.Logger, | |
| 53 | 55 | ) | |
| 54 | 56 | case config.EngineTypeCEF: | |
| 57 | + | profile, err := requireRuntimeProfile(input.RuntimeProfile, engineType) | |
| 58 | + | if err != nil { | |
| 59 | + | return nil, err | |
| 60 | + | } | |
| 55 | 61 | opts := port.EngineOptions{ | |
| 56 | - | DataDir: input.DataDir, | |
| 57 | - | CacheDir: input.CacheDir, | |
| 58 | 62 | CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy), | |
| 59 | 63 | } | |
| 60 | 64 | cefCfg := cef.RuntimeConfig{ | |
| @@ -102,8 +106,18 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||
| 102 | 106 | }, | |
| 103 | 107 | } | |
| 104 | 108 | audioFactory := audiofactory.NewAudioOutputFactory() | |
| 105 | - | return cef.NewEngine(input.Ctx, opts, cefCfg, transcodingCfg, audioFactory, deps) | |
| 109 | + | return cef.NewEngine(input.Ctx, opts, profile, cefCfg, transcodingCfg, audioFactory, deps) | |
| 106 | 110 | default: | |
| 107 | 111 | return nil, fmt.Errorf("unknown engine type: %q", engineType) | |
| 108 | 112 | } | |
| 109 | 113 | } | |
| 114 | + | ||
| 115 | + | func requireRuntimeProfile(profile runtimeprofile.Profile, engineType string) (runtimeprofile.Profile, error) { | |
| 116 | + | if profile.Engine == "" { | |
| 117 | + | return runtimeprofile.Profile{}, fmt.Errorf("missing runtime profile for %s engine", engineType) | |
| 118 | + | } | |
| 119 | + | if profile.Engine != engineType { | |
| 120 | + | return runtimeprofile.Profile{}, fmt.Errorf("runtime profile engine %q does not match %q", profile.Engine, engineType) | |
| 121 | + | } | |
| 122 | + | return profile, nil | |
| 123 | + | } | |
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | "github.com/bnema/dumber/internal/infrastructure/config" | 13 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 14 | "github.com/bnema/dumber/internal/infrastructure/env" | 14 | "github.com/bnema/dumber/internal/infrastructure/env" | ||
| 15 | "github.com/bnema/dumber/internal/infrastructure/handlers" | 15 | "github.com/bnema/dumber/internal/infrastructure/handlers" | ||
| 16 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 16 | "github.com/bnema/dumber/internal/infrastructure/transcoder" | 17 | "github.com/bnema/dumber/internal/infrastructure/transcoder" | ||
| 17 | "github.com/bnema/dumber/internal/infrastructure/webkit" | 18 | "github.com/bnema/dumber/internal/infrastructure/webkit" | ||
| 18 | "github.com/bnema/dumber/internal/ui/theme" | 19 | "github.com/bnema/dumber/internal/ui/theme" | ||
| @@ -21,13 +22,12 @@ import ( | |||||
| 21 | 22 | ||||
| 22 | // EngineInput holds the input for BuildEngine. | 23 | // EngineInput holds the input for BuildEngine. | ||
| 23 | type EngineInput struct { | 24 | type EngineInput struct { | ||
| 24 | - | Ctx context.Context | 25 | + | Ctx context.Context |
| 25 | - | Config *config.Config | 26 | + | Config *config.Config |
| 26 | - | DataDir string | 27 | + | RuntimeProfile runtimeprofile.Profile |
| 27 | - | CacheDir string | 28 | + | ThemeManager *theme.Manager |
| 28 | - | ThemeManager *theme.Manager | 29 | + | ColorResolver port.ColorSchemeResolver |
| 29 | - | ColorResolver port.ColorSchemeResolver | 30 | + | Logger zerolog.Logger |
| 30 | - | Logger zerolog.Logger | |||
| 31 | } | 31 | } | ||
| 32 | 32 | ||||
| 33 | // BuildEngine constructs a port.Engine for the engine type specified in cfg.Engine.Type. | 33 | // BuildEngine constructs a port.Engine for the engine type specified in cfg.Engine.Type. | ||
| @@ -38,23 +38,27 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||||
| 38 | engineType := cfg.Engine.ResolveEngineType() | 38 | engineType := cfg.Engine.ResolveEngineType() | ||
| 39 | switch engineType { | 39 | switch engineType { | ||
| 40 | case config.EngineTypeWebKit: | 40 | case config.EngineTypeWebKit: | ||
| 41 | + | profile, err := requireRuntimeProfile(input.RuntimeProfile, engineType) | |||
| 42 | + | if err != nil { | |||
| 43 | + | return nil, err | |||
| 44 | + | } | |||
| 41 | opts := port.EngineOptions{ | 45 | opts := port.EngineOptions{ | ||
| 42 | - | DataDir: input.DataDir, | |||
| 43 | - | CacheDir: input.CacheDir, | |||
| 44 | CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy), | 46 | CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy), | ||
| 45 | } | 47 | } | ||
| 46 | wkCfg := webkit.EngineConfigFromConfig(cfg.Engine.WebKit) | 48 | wkCfg := webkit.EngineConfigFromConfig(cfg.Engine.WebKit) | ||
| 47 | 49 | ||||
| 48 | return webkit.NewEngine( | 50 | return webkit.NewEngine( | ||
| 49 | - | input.Ctx, cfg, opts, wkCfg, | 51 | + | input.Ctx, cfg, opts, profile, wkCfg, |
| 50 | input.ThemeManager, input.ColorResolver, | 52 | input.ThemeManager, input.ColorResolver, | ||
| 51 | contextMenuBuilder, contextMenuExecutorFactory, | 53 | contextMenuBuilder, contextMenuExecutorFactory, | ||
| 52 | input.Logger, | 54 | input.Logger, | ||
| 53 | ) | 55 | ) | ||
| 54 | case config.EngineTypeCEF: | 56 | case config.EngineTypeCEF: | ||
| 57 | + | profile, err := requireRuntimeProfile(input.RuntimeProfile, engineType) | |||
| 58 | + | if err != nil { | |||
| 59 | + | return nil, err | |||
| 60 | + | } | |||
| 55 | opts := port.EngineOptions{ | 61 | opts := port.EngineOptions{ | ||
| 56 | - | DataDir: input.DataDir, | |||
| 57 | - | CacheDir: input.CacheDir, | |||
| 58 | CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy), | 62 | CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy), | ||
| 59 | } | 63 | } | ||
| 60 | cefCfg := cef.RuntimeConfig{ | 64 | cefCfg := cef.RuntimeConfig{ | ||
| @@ -102,8 +106,18 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||||
| 102 | }, | 106 | }, | ||
| 103 | } | 107 | } | ||
| 104 | audioFactory := audiofactory.NewAudioOutputFactory() | 108 | audioFactory := audiofactory.NewAudioOutputFactory() | ||
| 105 | - | return cef.NewEngine(input.Ctx, opts, cefCfg, transcodingCfg, audioFactory, deps) | 109 | + | return cef.NewEngine(input.Ctx, opts, profile, cefCfg, transcodingCfg, audioFactory, deps) |
| 106 | default: | 110 | default: | ||
| 107 | return nil, fmt.Errorf("unknown engine type: %q", engineType) | 111 | return nil, fmt.Errorf("unknown engine type: %q", engineType) | ||
| 108 | } | 112 | } | ||
| 109 | } | 113 | } | ||
| 114 | + | ||||
| 115 | + | func requireRuntimeProfile(profile runtimeprofile.Profile, engineType string) (runtimeprofile.Profile, error) { | |||
| 116 | + | if profile.Engine == "" { | |||
| 117 | + | return runtimeprofile.Profile{}, fmt.Errorf("missing runtime profile for %s engine", engineType) | |||
| 118 | + | } | |||
| 119 | + | if profile.Engine != engineType { | |||
| 120 | + | return runtimeprofile.Profile{}, fmt.Errorf("runtime profile engine %q does not match %q", profile.Engine, engineType) | |||
| 121 | + | } | |||
| 122 | + | return profile, nil | |||
| 123 | + | } | |||
M internal/bootstrap/engine_test.go +33 -3
| @@ -2,10 +2,12 @@ package bootstrap | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "path/filepath" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/dumber/internal/infrastructure/cef" | |
| 8 | 9 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 9 | 11 | "github.com/stretchr/testify/require" | |
| 10 | 12 | ) | |
| 11 | 13 | ||
| @@ -21,7 +23,7 @@ func TestBuildEngine_CEF_ReturnsErrorWhenRuntimeUnavailable(t *testing.T) { | |||
| 21 | 23 | cfg := config.DefaultConfig() | |
| 22 | 24 | cfg.Engine.Type = "cef" | |
| 23 | 25 | cfg.Engine.CEF.CEFDir = t.TempDir() | |
| 24 | - | _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background()}) | |
| 26 | + | _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background(), RuntimeProfile: testRuntimeProfile(t)}) | |
| 25 | 27 | require.Error(t, err) | |
| 26 | 28 | require.Contains(t, err.Error(), "cef.InitWithApp") | |
| 27 | 29 | } | |
| @@ -30,7 +32,7 @@ func TestBuildEngine_CEF_ReturnsErrorForUnsupportedCookiePolicy(t *testing.T) { | |||
| 30 | 32 | cfg := config.DefaultConfig() | |
| 31 | 33 | cfg.Engine.Type = "cef" | |
| 32 | 34 | cfg.Engine.CookiePolicy = config.CookiePolicyNever | |
| 33 | - | _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background()}) | |
| 35 | + | _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background(), RuntimeProfile: testRuntimeProfile(t)}) | |
| 34 | 36 | require.ErrorIs(t, err, cef.ErrCookiePolicyUnsupported) | |
| 35 | 37 | } | |
| 36 | 38 | ||
| @@ -39,6 +41,34 @@ func TestBuildEngine_CEF_AllowsNoThirdPartyCookiePolicy(t *testing.T) { | |||
| 39 | 41 | cfg.Engine.Type = "cef" | |
| 40 | 42 | cfg.Engine.CookiePolicy = config.CookiePolicyNoThirdParty | |
| 41 | 43 | cfg.Engine.CEF.CEFDir = t.TempDir() | |
| 42 | - | _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background()}) | |
| 44 | + | _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background(), RuntimeProfile: testRuntimeProfile(t)}) | |
| 43 | 45 | require.NotErrorIs(t, err, cef.ErrCookiePolicyUnsupported) | |
| 44 | 46 | } | |
| 47 | + | ||
| 48 | + | func TestBuildEngine_ReceivesResolvedProfile(t *testing.T) { | |
| 49 | + | cfg := config.DefaultConfig() | |
| 50 | + | cfg.Engine.Type = config.EngineTypeCEF | |
| 51 | + | cfg.Engine.CEF.CEFDir = t.TempDir() | |
| 52 | + | ||
| 53 | + | _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background(), RuntimeProfile: testRuntimeProfile(t)}) | |
| 54 | + | require.Error(t, err) | |
| 55 | + | require.NotContains(t, err.Error(), "missing runtime profile") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func testRuntimeProfile(t *testing.T) runtimeprofile.Profile { | |
| 59 | + | t.Helper() | |
| 60 | + | root := t.TempDir() | |
| 61 | + | profile, err := runtimeprofile.Resolve(runtimeprofile.ResolveInput{ | |
| 62 | + | Env: func(string) string { return "" }, | |
| 63 | + | Engine: config.EngineTypeCEF, | |
| 64 | + | CWD: func() (string, error) { return root, nil }, | |
| 65 | + | Base: runtimeprofile.BasePaths{ | |
| 66 | + | ConfigHome: filepath.Join(root, "config"), | |
| 67 | + | DataHome: filepath.Join(root, "data"), | |
| 68 | + | StateHome: filepath.Join(root, "state"), | |
| 69 | + | CacheHome: filepath.Join(root, "cache"), | |
| 70 | + | }, | |
| 71 | + | }) | |
| 72 | + | require.NoError(t, err) | |
| 73 | + | return profile | |
| 74 | + | } | |
| @@ -2,10 +2,12 @@ package bootstrap | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "path/filepath" | |||
| 5 | "testing" | 6 | "testing" | ||
| 6 | 7 | ||||
| 7 | "github.com/bnema/dumber/internal/infrastructure/cef" | 8 | "github.com/bnema/dumber/internal/infrastructure/cef" | ||
| 8 | "github.com/bnema/dumber/internal/infrastructure/config" | 9 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 9 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| 10 | ) | 12 | ) | ||
| 11 | 13 | ||||
| @@ -21,7 +23,7 @@ func TestBuildEngine_CEF_ReturnsErrorWhenRuntimeUnavailable(t *testing.T) { | |||||
| 21 | cfg := config.DefaultConfig() | 23 | cfg := config.DefaultConfig() | ||
| 22 | cfg.Engine.Type = "cef" | 24 | cfg.Engine.Type = "cef" | ||
| 23 | cfg.Engine.CEF.CEFDir = t.TempDir() | 25 | cfg.Engine.CEF.CEFDir = t.TempDir() | ||
| 24 | - | _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background()}) | 26 | + | _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background(), RuntimeProfile: testRuntimeProfile(t)}) |
| 25 | require.Error(t, err) | 27 | require.Error(t, err) | ||
| 26 | require.Contains(t, err.Error(), "cef.InitWithApp") | 28 | require.Contains(t, err.Error(), "cef.InitWithApp") | ||
| 27 | } | 29 | } | ||
| @@ -30,7 +32,7 @@ func TestBuildEngine_CEF_ReturnsErrorForUnsupportedCookiePolicy(t *testing.T) { | |||||
| 30 | cfg := config.DefaultConfig() | 32 | cfg := config.DefaultConfig() | ||
| 31 | cfg.Engine.Type = "cef" | 33 | cfg.Engine.Type = "cef" | ||
| 32 | cfg.Engine.CookiePolicy = config.CookiePolicyNever | 34 | cfg.Engine.CookiePolicy = config.CookiePolicyNever | ||
| 33 | - | _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background()}) | 35 | + | _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background(), RuntimeProfile: testRuntimeProfile(t)}) |
| 34 | require.ErrorIs(t, err, cef.ErrCookiePolicyUnsupported) | 36 | require.ErrorIs(t, err, cef.ErrCookiePolicyUnsupported) | ||
| 35 | } | 37 | } | ||
| 36 | 38 | ||||
| @@ -39,6 +41,34 @@ func TestBuildEngine_CEF_AllowsNoThirdPartyCookiePolicy(t *testing.T) { | |||||
| 39 | cfg.Engine.Type = "cef" | 41 | cfg.Engine.Type = "cef" | ||
| 40 | cfg.Engine.CookiePolicy = config.CookiePolicyNoThirdParty | 42 | cfg.Engine.CookiePolicy = config.CookiePolicyNoThirdParty | ||
| 41 | cfg.Engine.CEF.CEFDir = t.TempDir() | 43 | cfg.Engine.CEF.CEFDir = t.TempDir() | ||
| 42 | - | _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background()}) | 44 | + | _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background(), RuntimeProfile: testRuntimeProfile(t)}) |
| 43 | require.NotErrorIs(t, err, cef.ErrCookiePolicyUnsupported) | 45 | require.NotErrorIs(t, err, cef.ErrCookiePolicyUnsupported) | ||
| 44 | } | 46 | } | ||
| 47 | + | ||||
| 48 | + | func TestBuildEngine_ReceivesResolvedProfile(t *testing.T) { | |||
| 49 | + | cfg := config.DefaultConfig() | |||
| 50 | + | cfg.Engine.Type = config.EngineTypeCEF | |||
| 51 | + | cfg.Engine.CEF.CEFDir = t.TempDir() | |||
| 52 | + | ||||
| 53 | + | _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background(), RuntimeProfile: testRuntimeProfile(t)}) | |||
| 54 | + | require.Error(t, err) | |||
| 55 | + | require.NotContains(t, err.Error(), "missing runtime profile") | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func testRuntimeProfile(t *testing.T) runtimeprofile.Profile { | |||
| 59 | + | t.Helper() | |||
| 60 | + | root := t.TempDir() | |||
| 61 | + | profile, err := runtimeprofile.Resolve(runtimeprofile.ResolveInput{ | |||
| 62 | + | Env: func(string) string { return "" }, | |||
| 63 | + | Engine: config.EngineTypeCEF, | |||
| 64 | + | CWD: func() (string, error) { return root, nil }, | |||
| 65 | + | Base: runtimeprofile.BasePaths{ | |||
| 66 | + | ConfigHome: filepath.Join(root, "config"), | |||
| 67 | + | DataHome: filepath.Join(root, "data"), | |||
| 68 | + | StateHome: filepath.Join(root, "state"), | |||
| 69 | + | CacheHome: filepath.Join(root, "cache"), | |||
| 70 | + | }, | |||
| 71 | + | }) | |||
| 72 | + | require.NoError(t, err) | |||
| 73 | + | return profile | |||
| 74 | + | } | |||
M internal/bootstrap/gui.go +31 -35
| @@ -6,7 +6,6 @@ import ( | |||
| 6 | 6 | "database/sql" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "os" | |
| 9 | - | "path/filepath" | |
| 10 | 9 | "sync" | |
| 11 | 10 | "time" | |
| 12 | 11 | ||
| @@ -18,6 +17,7 @@ import ( | |||
| 18 | 17 | "github.com/bnema/dumber/internal/infrastructure/env" | |
| 19 | 18 | "github.com/bnema/dumber/internal/infrastructure/media" | |
| 20 | 19 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 20 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 21 | 21 | "github.com/bnema/dumber/internal/logging" | |
| 22 | 22 | "github.com/bnema/dumber/internal/ui/theme" | |
| 23 | 23 | ) | |
| @@ -30,8 +30,7 @@ type DatabaseResult struct { | |||
| 30 | 30 | ||
| 31 | 31 | // ParallelInitResult holds the results of parallel initialization phase. | |
| 32 | 32 | type ParallelInitResult struct { | |
| 33 | - | DataDir string | |
| 34 | - | CacheDir string | |
| 33 | + | RuntimeProfile runtimeprofile.Profile | |
| 35 | 34 | ThemeManager *theme.Manager | |
| 36 | 35 | ColorResolver port.ColorSchemeResolver | |
| 37 | 36 | AdwaitaDetector *colorscheme.AdwaitaDetector | |
| @@ -91,11 +90,15 @@ func (e *RuntimeRequirementsError) LogDetails(ctx context.Context) { | |||
| 91 | 90 | // Returns the first fatal error encountered, or nil with the results. | |
| 92 | 91 | func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |
| 93 | 92 | var ( | |
| 94 | - | dataDir, cacheDir string | |
| 95 | - | dirsErr error | |
| 96 | - | wg sync.WaitGroup | |
| 93 | + | dirsErr error | |
| 94 | + | wg sync.WaitGroup | |
| 97 | 95 | ) | |
| 98 | 96 | ||
| 97 | + | profile, err := ResolveRuntimeProfile(input.Config) | |
| 98 | + | if err != nil { | |
| 99 | + | return nil, fmt.Errorf("resolve runtime profile: %w", err) | |
| 100 | + | } | |
| 101 | + | ||
| 99 | 102 | start := time.Now() | |
| 100 | 103 | ||
| 101 | 104 | // Create color scheme resolver with initial detectors. | |
| @@ -116,7 +119,7 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |||
| 116 | 119 | // Resolve directories | |
| 117 | 120 | go func() { | |
| 118 | 121 | defer wg.Done() | |
| 119 | - | dataDir, cacheDir, dirsErr = resolveWebKitDirs() | |
| 122 | + | dirsErr = resolveWebKitDirs(profile) | |
| 120 | 123 | }() | |
| 121 | 124 | ||
| 122 | 125 | // Theme manager (CPU-bound, no I/O) | |
| @@ -138,8 +141,7 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |||
| 138 | 141 | } | |
| 139 | 142 | ||
| 140 | 143 | return &ParallelInitResult{ | |
| 141 | - | DataDir: dataDir, | |
| 142 | - | CacheDir: cacheDir, | |
| 144 | + | RuntimeProfile: profile, | |
| 143 | 145 | ThemeManager: themeManager, | |
| 144 | 146 | ColorResolver: resolver, | |
| 145 | 147 | AdwaitaDetector: adwaitaDetector, | |
| @@ -218,21 +220,17 @@ func CheckMediaRequirements(ctx context.Context, cfg *config.Config) error { | |||
| 218 | 220 | } | |
| 219 | 221 | ||
| 220 | 222 | // resolveWebKitDirs resolves and creates the data and cache directories for WebKit. | |
| 221 | - | func resolveWebKitDirs() (dataDir, cacheDir string, err error) { | |
| 222 | - | const cacheDirPerm = 0o755 | |
| 223 | - | dataDir, err = config.GetDataDir() | |
| 224 | - | if err != nil { | |
| 225 | - | return "", "", fmt.Errorf("resolve data directory: %w", err) | |
| 223 | + | func resolveWebKitDirs(profile runtimeprofile.Profile) error { | |
| 224 | + | const dirPerm = 0o755 | |
| 225 | + | dataDir := profile.WebKitDataDir() | |
| 226 | + | cacheDir := profile.WebKitCacheDir() | |
| 227 | + | if mkErr := os.MkdirAll(dataDir, dirPerm); mkErr != nil { | |
| 228 | + | return fmt.Errorf("create data directory %s: %w", dataDir, mkErr) | |
| 226 | 229 | } | |
| 227 | - | stateDir, err := config.GetStateDir() | |
| 228 | - | if err != nil { | |
| 229 | - | return "", "", fmt.Errorf("resolve state directory: %w", err) | |
| 230 | - | } | |
| 231 | - | cacheDir = filepath.Join(stateDir, "webkit-cache") | |
| 232 | - | if mkErr := os.MkdirAll(cacheDir, cacheDirPerm); mkErr != nil { | |
| 233 | - | return "", "", fmt.Errorf("create cache directory %s: %w", cacheDir, mkErr) | |
| 230 | + | if mkErr := os.MkdirAll(cacheDir, dirPerm); mkErr != nil { | |
| 231 | + | return fmt.Errorf("create cache directory %s: %w", cacheDir, mkErr) | |
| 234 | 232 | } | |
| 235 | - | return dataDir, cacheDir, nil | |
| 233 | + | return nil | |
| 236 | 234 | } | |
| 237 | 235 | ||
| 238 | 236 | // OpenDatabase opens and initializes the SQLite database using XDG paths. | |
| @@ -277,12 +275,11 @@ type ParallelDBEngineResult struct { | |||
| 277 | 275 | ||
| 278 | 276 | // ParallelDBEngineInput holds inputs for parallel DB and engine initialization. | |
| 279 | 277 | type ParallelDBEngineInput struct { | |
| 280 | - | Ctx context.Context | |
| 281 | - | Config *config.Config | |
| 282 | - | DataDir string // For engine context | |
| 283 | - | CacheDir string // For engine cache | |
| 284 | - | ThemeManager *theme.Manager | |
| 285 | - | ColorResolver port.ColorSchemeResolver | |
| 278 | + | Ctx context.Context | |
| 279 | + | Config *config.Config | |
| 280 | + | RuntimeProfile runtimeprofile.Profile | |
| 281 | + | ThemeManager *theme.Manager | |
| 282 | + | ColorResolver port.ColorSchemeResolver | |
| 286 | 283 | } | |
| 287 | 284 | ||
| 288 | 285 | // Note: Database path is resolved via config.GetDatabaseFile() internally. | |
| @@ -317,13 +314,12 @@ func RunParallelDBEngine(input ParallelDBEngineInput) (*ParallelDBEngineResult, | |||
| 317 | 314 | ||
| 318 | 315 | // Engine on main thread (GTK requirement) | |
| 319 | 316 | engine, err := BuildEngine(EngineInput{ | |
| 320 | - | Ctx: input.Ctx, | |
| 321 | - | Config: input.Config, | |
| 322 | - | DataDir: input.DataDir, | |
| 323 | - | CacheDir: input.CacheDir, | |
| 324 | - | ThemeManager: input.ThemeManager, | |
| 325 | - | ColorResolver: input.ColorResolver, | |
| 326 | - | Logger: *log, | |
| 317 | + | Ctx: input.Ctx, | |
| 318 | + | Config: input.Config, | |
| 319 | + | RuntimeProfile: input.RuntimeProfile, | |
| 320 | + | ThemeManager: input.ThemeManager, | |
| 321 | + | ColorResolver: input.ColorResolver, | |
| 322 | + | Logger: *log, | |
| 327 | 323 | }) | |
| 328 | 324 | if err != nil { | |
| 329 | 325 | dbRes := <-dbCh | |
| @@ -6,7 +6,6 @@ import ( | |||||
| 6 | "database/sql" | 6 | "database/sql" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "os" | 8 | "os" | ||
| 9 | - | "path/filepath" | |||
| 10 | "sync" | 9 | "sync" | ||
| 11 | "time" | 10 | "time" | ||
| 12 | 11 | ||||
| @@ -18,6 +17,7 @@ import ( | |||||
| 18 | "github.com/bnema/dumber/internal/infrastructure/env" | 17 | "github.com/bnema/dumber/internal/infrastructure/env" | ||
| 19 | "github.com/bnema/dumber/internal/infrastructure/media" | 18 | "github.com/bnema/dumber/internal/infrastructure/media" | ||
| 20 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | 19 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | ||
| 20 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 21 | "github.com/bnema/dumber/internal/logging" | 21 | "github.com/bnema/dumber/internal/logging" | ||
| 22 | "github.com/bnema/dumber/internal/ui/theme" | 22 | "github.com/bnema/dumber/internal/ui/theme" | ||
| 23 | ) | 23 | ) | ||
| @@ -30,8 +30,7 @@ type DatabaseResult struct { | |||||
| 30 | 30 | ||||
| 31 | // ParallelInitResult holds the results of parallel initialization phase. | 31 | // ParallelInitResult holds the results of parallel initialization phase. | ||
| 32 | type ParallelInitResult struct { | 32 | type ParallelInitResult struct { | ||
| 33 | - | DataDir string | 33 | + | RuntimeProfile runtimeprofile.Profile |
| 34 | - | CacheDir string | |||
| 35 | ThemeManager *theme.Manager | 34 | ThemeManager *theme.Manager | ||
| 36 | ColorResolver port.ColorSchemeResolver | 35 | ColorResolver port.ColorSchemeResolver | ||
| 37 | AdwaitaDetector *colorscheme.AdwaitaDetector | 36 | AdwaitaDetector *colorscheme.AdwaitaDetector | ||
| @@ -91,11 +90,15 @@ func (e *RuntimeRequirementsError) LogDetails(ctx context.Context) { | |||||
| 91 | // Returns the first fatal error encountered, or nil with the results. | 90 | // Returns the first fatal error encountered, or nil with the results. | ||
| 92 | func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | 91 | func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | ||
| 93 | var ( | 92 | var ( | ||
| 94 | - | dataDir, cacheDir string | 93 | + | dirsErr error |
| 95 | - | dirsErr error | 94 | + | wg sync.WaitGroup |
| 96 | - | wg sync.WaitGroup | |||
| 97 | ) | 95 | ) | ||
| 98 | 96 | ||||
| 97 | + | profile, err := ResolveRuntimeProfile(input.Config) | |||
| 98 | + | if err != nil { | |||
| 99 | + | return nil, fmt.Errorf("resolve runtime profile: %w", err) | |||
| 100 | + | } | |||
| 101 | + | ||||
| 99 | start := time.Now() | 102 | start := time.Now() | ||
| 100 | 103 | ||||
| 101 | // Create color scheme resolver with initial detectors. | 104 | // Create color scheme resolver with initial detectors. | ||
| @@ -116,7 +119,7 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |||||
| 116 | // Resolve directories | 119 | // Resolve directories | ||
| 117 | go func() { | 120 | go func() { | ||
| 118 | defer wg.Done() | 121 | defer wg.Done() | ||
| 119 | - | dataDir, cacheDir, dirsErr = resolveWebKitDirs() | 122 | + | dirsErr = resolveWebKitDirs(profile) |
| 120 | }() | 123 | }() | ||
| 121 | 124 | ||||
| 122 | // Theme manager (CPU-bound, no I/O) | 125 | // Theme manager (CPU-bound, no I/O) | ||
| @@ -138,8 +141,7 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |||||
| 138 | } | 141 | } | ||
| 139 | 142 | ||||
| 140 | return &ParallelInitResult{ | 143 | return &ParallelInitResult{ | ||
| 141 | - | DataDir: dataDir, | 144 | + | RuntimeProfile: profile, |
| 142 | - | CacheDir: cacheDir, | |||
| 143 | ThemeManager: themeManager, | 145 | ThemeManager: themeManager, | ||
| 144 | ColorResolver: resolver, | 146 | ColorResolver: resolver, | ||
| 145 | AdwaitaDetector: adwaitaDetector, | 147 | AdwaitaDetector: adwaitaDetector, | ||
| @@ -218,21 +220,17 @@ func CheckMediaRequirements(ctx context.Context, cfg *config.Config) error { | |||||
| 218 | } | 220 | } | ||
| 219 | 221 | ||||
| 220 | // resolveWebKitDirs resolves and creates the data and cache directories for WebKit. | 222 | // resolveWebKitDirs resolves and creates the data and cache directories for WebKit. | ||
| 221 | - | func resolveWebKitDirs() (dataDir, cacheDir string, err error) { | 223 | + | func resolveWebKitDirs(profile runtimeprofile.Profile) error { |
| 222 | - | const cacheDirPerm = 0o755 | 224 | + | const dirPerm = 0o755 |
| 223 | - | dataDir, err = config.GetDataDir() | 225 | + | dataDir := profile.WebKitDataDir() |
| 224 | - | if err != nil { | 226 | + | cacheDir := profile.WebKitCacheDir() |
| 225 | - | return "", "", fmt.Errorf("resolve data directory: %w", err) | 227 | + | if mkErr := os.MkdirAll(dataDir, dirPerm); mkErr != nil { |
| 228 | + | return fmt.Errorf("create data directory %s: %w", dataDir, mkErr) | |||
| 226 | } | 229 | } | ||
| 227 | - | stateDir, err := config.GetStateDir() | 230 | + | if mkErr := os.MkdirAll(cacheDir, dirPerm); mkErr != nil { |
| 228 | - | if err != nil { | 231 | + | return fmt.Errorf("create cache directory %s: %w", cacheDir, mkErr) |
| 229 | - | return "", "", fmt.Errorf("resolve state directory: %w", err) | |||
| 230 | - | } | |||
| 231 | - | cacheDir = filepath.Join(stateDir, "webkit-cache") | |||
| 232 | - | if mkErr := os.MkdirAll(cacheDir, cacheDirPerm); mkErr != nil { | |||
| 233 | - | return "", "", fmt.Errorf("create cache directory %s: %w", cacheDir, mkErr) | |||
| 234 | } | 232 | } | ||
| 235 | - | return dataDir, cacheDir, nil | 233 | + | return nil |
| 236 | } | 234 | } | ||
| 237 | 235 | ||||
| 238 | // OpenDatabase opens and initializes the SQLite database using XDG paths. | 236 | // OpenDatabase opens and initializes the SQLite database using XDG paths. | ||
| @@ -277,12 +275,11 @@ type ParallelDBEngineResult struct { | |||||
| 277 | 275 | ||||
| 278 | // ParallelDBEngineInput holds inputs for parallel DB and engine initialization. | 276 | // ParallelDBEngineInput holds inputs for parallel DB and engine initialization. | ||
| 279 | type ParallelDBEngineInput struct { | 277 | type ParallelDBEngineInput struct { | ||
| 280 | - | Ctx context.Context | 278 | + | Ctx context.Context |
| 281 | - | Config *config.Config | 279 | + | Config *config.Config |
| 282 | - | DataDir string // For engine context | 280 | + | RuntimeProfile runtimeprofile.Profile |
| 283 | - | CacheDir string // For engine cache | 281 | + | ThemeManager *theme.Manager |
| 284 | - | ThemeManager *theme.Manager | 282 | + | ColorResolver port.ColorSchemeResolver |
| 285 | - | ColorResolver port.ColorSchemeResolver | |||
| 286 | } | 283 | } | ||
| 287 | 284 | ||||
| 288 | // Note: Database path is resolved via config.GetDatabaseFile() internally. | 285 | // Note: Database path is resolved via config.GetDatabaseFile() internally. | ||
| @@ -317,13 +314,12 @@ func RunParallelDBEngine(input ParallelDBEngineInput) (*ParallelDBEngineResult, | |||||
| 317 | 314 | ||||
| 318 | // Engine on main thread (GTK requirement) | 315 | // Engine on main thread (GTK requirement) | ||
| 319 | engine, err := BuildEngine(EngineInput{ | 316 | engine, err := BuildEngine(EngineInput{ | ||
| 320 | - | Ctx: input.Ctx, | 317 | + | Ctx: input.Ctx, |
| 321 | - | Config: input.Config, | 318 | + | Config: input.Config, |
| 322 | - | DataDir: input.DataDir, | 319 | + | RuntimeProfile: input.RuntimeProfile, |
| 323 | - | CacheDir: input.CacheDir, | 320 | + | ThemeManager: input.ThemeManager, |
| 324 | - | ThemeManager: input.ThemeManager, | 321 | + | ColorResolver: input.ColorResolver, |
| 325 | - | ColorResolver: input.ColorResolver, | 322 | + | Logger: *log, |
| 326 | - | Logger: *log, | |||
| 327 | }) | 323 | }) | ||
| 328 | if err != nil { | 324 | if err != nil { | ||
| 329 | dbRes := <-dbCh | 325 | dbRes := <-dbCh | ||
A internal/bootstrap/runtime_profile.go +33 -0
| @@ -0,0 +1,33 @@ | |||
| 1 | + | package bootstrap | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 7 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // ResolveRuntimeProfile resolves the current mode+engine profile once in bootstrap. | |
| 11 | + | func ResolveRuntimeProfile(cfg *config.Config) (runtimeprofile.Profile, error) { | |
| 12 | + | engine := config.EngineTypeWebKit | |
| 13 | + | if cfg != nil { | |
| 14 | + | engine = cfg.Engine.ResolveEngineType() | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | dirs, err := config.GetXDGDirs() | |
| 18 | + | if err != nil { | |
| 19 | + | return runtimeprofile.Profile{}, err | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | return runtimeprofile.Resolve(runtimeprofile.ResolveInput{ | |
| 23 | + | Env: os.Getenv, | |
| 24 | + | CWD: os.Getwd, | |
| 25 | + | Engine: engine, | |
| 26 | + | Base: runtimeprofile.BasePaths{ | |
| 27 | + | ConfigHome: dirs.ConfigHome, | |
| 28 | + | DataHome: dirs.DataHome, | |
| 29 | + | StateHome: dirs.StateHome, | |
| 30 | + | CacheHome: dirs.CacheHome, | |
| 31 | + | }, | |
| 32 | + | }) | |
| 33 | + | } | |
| @@ -0,0 +1,33 @@ | |||||
| 1 | + | package bootstrap | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 7 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // ResolveRuntimeProfile resolves the current mode+engine profile once in bootstrap. | |||
| 11 | + | func ResolveRuntimeProfile(cfg *config.Config) (runtimeprofile.Profile, error) { | |||
| 12 | + | engine := config.EngineTypeWebKit | |||
| 13 | + | if cfg != nil { | |||
| 14 | + | engine = cfg.Engine.ResolveEngineType() | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | dirs, err := config.GetXDGDirs() | |||
| 18 | + | if err != nil { | |||
| 19 | + | return runtimeprofile.Profile{}, err | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | return runtimeprofile.Resolve(runtimeprofile.ResolveInput{ | |||
| 23 | + | Env: os.Getenv, | |||
| 24 | + | CWD: os.Getwd, | |||
| 25 | + | Engine: engine, | |||
| 26 | + | Base: runtimeprofile.BasePaths{ | |||
| 27 | + | ConfigHome: dirs.ConfigHome, | |||
| 28 | + | DataHome: dirs.DataHome, | |||
| 29 | + | StateHome: dirs.StateHome, | |||
| 30 | + | CacheHome: dirs.CacheHome, | |||
| 31 | + | }, | |||
| 32 | + | }) | |||
| 33 | + | } | |||
A internal/bootstrap/runtime_profile_test.go +60 -0
| @@ -0,0 +1,60 @@ | |||
| 1 | + | package bootstrap | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "path/filepath" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestResolveRuntimeProfile_UsesConfigEngineResolution(t *testing.T) { | |
| 12 | + | cfg := config.DefaultConfig() | |
| 13 | + | cfg.Engine.Type = "webkit" | |
| 14 | + | t.Setenv("DUMBER_ENGINE", "cef") | |
| 15 | + | t.Setenv("ENV", "") | |
| 16 | + | home := t.TempDir() | |
| 17 | + | t.Setenv("HOME", home) | |
| 18 | + | t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) | |
| 19 | + | t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share")) | |
| 20 | + | t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state")) | |
| 21 | + | t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) | |
| 22 | + | ||
| 23 | + | profile, err := ResolveRuntimeProfile(cfg) | |
| 24 | + | require.NoError(t, err) | |
| 25 | + | require.Equal(t, "cef", profile.Engine) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestResolveRuntimeProfile_ProdPreservesSharedPaths(t *testing.T) { | |
| 29 | + | cfg := config.DefaultConfig() | |
| 30 | + | cfg.Engine.Type = config.EngineTypeWebKit | |
| 31 | + | t.Setenv("ENV", "") | |
| 32 | + | home := t.TempDir() | |
| 33 | + | t.Setenv("HOME", home) | |
| 34 | + | t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) | |
| 35 | + | t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share")) | |
| 36 | + | t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state")) | |
| 37 | + | t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) | |
| 38 | + | ||
| 39 | + | profile, err := ResolveRuntimeProfile(cfg) | |
| 40 | + | require.NoError(t, err) | |
| 41 | + | require.Equal(t, filepath.Join(home, ".config", "dumber"), profile.Shared.ConfigDir) | |
| 42 | + | require.Equal(t, filepath.Join(home, ".local", "share", "dumber"), profile.Shared.DataDir) | |
| 43 | + | require.Equal(t, filepath.Join(home, ".local", "state", "dumber"), profile.Shared.StateDir) | |
| 44 | + | require.Equal(t, filepath.Join(home, ".cache", "dumber"), profile.Shared.CacheDir) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestResolveRuntimeProfile_DevUsesEngineSpecificNamespaces(t *testing.T) { | |
| 48 | + | cfg := config.DefaultConfig() | |
| 49 | + | cfg.Engine.Type = config.EngineTypeCEF | |
| 50 | + | ||
| 51 | + | wd := t.TempDir() | |
| 52 | + | t.Chdir(wd) | |
| 53 | + | ||
| 54 | + | t.Setenv("ENV", "dev") | |
| 55 | + | profile, err := ResolveRuntimeProfile(cfg) | |
| 56 | + | require.NoError(t, err) | |
| 57 | + | require.Equal(t, filepath.Join(wd, ".dev", "dumber", "state"), profile.Shared.StateDir) | |
| 58 | + | require.Equal(t, filepath.Join(wd, ".dev", "dumber", "engines", "cef", "data"), profile.CEFUserDataDir()) | |
| 59 | + | require.Equal(t, filepath.Join(wd, ".dev", "dumber", "runtime", "cef", "browser-launch.sock"), profile.IPC.BrowserLaunchSocket) | |
| 60 | + | } | |
| @@ -0,0 +1,60 @@ | |||||
| 1 | + | package bootstrap | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "path/filepath" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestResolveRuntimeProfile_UsesConfigEngineResolution(t *testing.T) { | |||
| 12 | + | cfg := config.DefaultConfig() | |||
| 13 | + | cfg.Engine.Type = "webkit" | |||
| 14 | + | t.Setenv("DUMBER_ENGINE", "cef") | |||
| 15 | + | t.Setenv("ENV", "") | |||
| 16 | + | home := t.TempDir() | |||
| 17 | + | t.Setenv("HOME", home) | |||
| 18 | + | t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) | |||
| 19 | + | t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share")) | |||
| 20 | + | t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state")) | |||
| 21 | + | t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) | |||
| 22 | + | ||||
| 23 | + | profile, err := ResolveRuntimeProfile(cfg) | |||
| 24 | + | require.NoError(t, err) | |||
| 25 | + | require.Equal(t, "cef", profile.Engine) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestResolveRuntimeProfile_ProdPreservesSharedPaths(t *testing.T) { | |||
| 29 | + | cfg := config.DefaultConfig() | |||
| 30 | + | cfg.Engine.Type = config.EngineTypeWebKit | |||
| 31 | + | t.Setenv("ENV", "") | |||
| 32 | + | home := t.TempDir() | |||
| 33 | + | t.Setenv("HOME", home) | |||
| 34 | + | t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) | |||
| 35 | + | t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share")) | |||
| 36 | + | t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state")) | |||
| 37 | + | t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) | |||
| 38 | + | ||||
| 39 | + | profile, err := ResolveRuntimeProfile(cfg) | |||
| 40 | + | require.NoError(t, err) | |||
| 41 | + | require.Equal(t, filepath.Join(home, ".config", "dumber"), profile.Shared.ConfigDir) | |||
| 42 | + | require.Equal(t, filepath.Join(home, ".local", "share", "dumber"), profile.Shared.DataDir) | |||
| 43 | + | require.Equal(t, filepath.Join(home, ".local", "state", "dumber"), profile.Shared.StateDir) | |||
| 44 | + | require.Equal(t, filepath.Join(home, ".cache", "dumber"), profile.Shared.CacheDir) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestResolveRuntimeProfile_DevUsesEngineSpecificNamespaces(t *testing.T) { | |||
| 48 | + | cfg := config.DefaultConfig() | |||
| 49 | + | cfg.Engine.Type = config.EngineTypeCEF | |||
| 50 | + | ||||
| 51 | + | wd := t.TempDir() | |||
| 52 | + | t.Chdir(wd) | |||
| 53 | + | ||||
| 54 | + | t.Setenv("ENV", "dev") | |||
| 55 | + | profile, err := ResolveRuntimeProfile(cfg) | |||
| 56 | + | require.NoError(t, err) | |||
| 57 | + | require.Equal(t, filepath.Join(wd, ".dev", "dumber", "state"), profile.Shared.StateDir) | |||
| 58 | + | require.Equal(t, filepath.Join(wd, ".dev", "dumber", "engines", "cef", "data"), profile.CEFUserDataDir()) | |||
| 59 | + | require.Equal(t, filepath.Join(wd, ".dev", "dumber", "runtime", "cef", "browser-launch.sock"), profile.IPC.BrowserLaunchSocket) | |||
| 60 | + | } | |||
M internal/bootstrap/session_spawner.go +4 -3
| @@ -7,14 +7,15 @@ import ( | |||
| 7 | 7 | "github.com/bnema/dumber/internal/infrastructure/cef" | |
| 8 | 8 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 9 | 9 | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 12 | 13 | // NewSessionSpawner wires the session spawner with any engine-specific launch | |
| 13 | 14 | // environment required by the current browser backend. | |
| 14 | - | func NewSessionSpawner(ctx context.Context, cfg *config.Config) port.SessionSpawner { | |
| 15 | + | func NewSessionSpawner(ctx context.Context, profile runtimeprofile.Profile) port.SessionSpawner { | |
| 15 | 16 | var spawnEnv port.SessionSpawnEnvironment | |
| 16 | - | if cfg != nil && cfg.Engine.ResolveEngineType() == config.EngineTypeCEF { | |
| 17 | - | spawnEnv = cef.SessionSpawnEnvironment{} | |
| 17 | + | if profile.Engine == config.EngineTypeCEF { | |
| 18 | + | spawnEnv = cef.NewSessionSpawnEnvironment(profile) | |
| 18 | 19 | } | |
| 19 | 20 | return desktop.NewSessionSpawner(ctx, spawnEnv) | |
| 20 | 21 | } | |
| @@ -7,14 +7,15 @@ import ( | |||||
| 7 | "github.com/bnema/dumber/internal/infrastructure/cef" | 7 | "github.com/bnema/dumber/internal/infrastructure/cef" | ||
| 8 | "github.com/bnema/dumber/internal/infrastructure/config" | 8 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 9 | "github.com/bnema/dumber/internal/infrastructure/desktop" | 9 | "github.com/bnema/dumber/internal/infrastructure/desktop" | ||
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| 12 | // NewSessionSpawner wires the session spawner with any engine-specific launch | 13 | // NewSessionSpawner wires the session spawner with any engine-specific launch | ||
| 13 | // environment required by the current browser backend. | 14 | // environment required by the current browser backend. | ||
| 14 | - | func NewSessionSpawner(ctx context.Context, cfg *config.Config) port.SessionSpawner { | 15 | + | func NewSessionSpawner(ctx context.Context, profile runtimeprofile.Profile) port.SessionSpawner { |
| 15 | var spawnEnv port.SessionSpawnEnvironment | 16 | var spawnEnv port.SessionSpawnEnvironment | ||
| 16 | - | if cfg != nil && cfg.Engine.ResolveEngineType() == config.EngineTypeCEF { | 17 | + | if profile.Engine == config.EngineTypeCEF { |
| 17 | - | spawnEnv = cef.SessionSpawnEnvironment{} | 18 | + | spawnEnv = cef.NewSessionSpawnEnvironment(profile) |
| 18 | } | 19 | } | ||
| 19 | return desktop.NewSessionSpawner(ctx, spawnEnv) | 20 | return desktop.NewSessionSpawner(ctx, spawnEnv) | ||
| 20 | } | 21 | } | ||
M internal/cli/app.go +5 -1
| @@ -51,6 +51,10 @@ func NewApp() (*App, error) { | |||
| 51 | 51 | var err error | |
| 52 | 52 | // Load config | |
| 53 | 53 | cfg := loadConfig() | |
| 54 | + | profile, err := bootstrap.ResolveRuntimeProfile(cfg) | |
| 55 | + | if err != nil { | |
| 56 | + | return nil, fmt.Errorf("resolve runtime profile: %w", err) | |
| 57 | + | } | |
| 54 | 58 | ||
| 55 | 59 | // Create theme from config | |
| 56 | 60 | theme := styles.NewTheme(cfg) | |
| @@ -133,7 +137,7 @@ func NewApp() (*App, error) { | |||
| 133 | 137 | RestoreUC: restoreUC, | |
| 134 | 138 | DeleteSessionUC: deleteSessionUC, | |
| 135 | 139 | FaviconService: faviconService, | |
| 136 | - | SessionSpawner: bootstrap.NewSessionSpawner(ctx, cfg), | |
| 140 | + | SessionSpawner: bootstrap.NewSessionSpawner(ctx, profile), | |
| 137 | 141 | ctx: ctx, | |
| 138 | 142 | logCleanup: logCleanup, | |
| 139 | 143 | }, nil | |
| @@ -51,6 +51,10 @@ func NewApp() (*App, error) { | |||||
| 51 | var err error | 51 | var err error | ||
| 52 | // Load config | 52 | // Load config | ||
| 53 | cfg := loadConfig() | 53 | cfg := loadConfig() | ||
| 54 | + | profile, err := bootstrap.ResolveRuntimeProfile(cfg) | |||
| 55 | + | if err != nil { | |||
| 56 | + | return nil, fmt.Errorf("resolve runtime profile: %w", err) | |||
| 57 | + | } | |||
| 54 | 58 | ||||
| 55 | // Create theme from config | 59 | // Create theme from config | ||
| 56 | theme := styles.NewTheme(cfg) | 60 | theme := styles.NewTheme(cfg) | ||
| @@ -133,7 +137,7 @@ func NewApp() (*App, error) { | |||||
| 133 | RestoreUC: restoreUC, | 137 | RestoreUC: restoreUC, | ||
| 134 | DeleteSessionUC: deleteSessionUC, | 138 | DeleteSessionUC: deleteSessionUC, | ||
| 135 | FaviconService: faviconService, | 139 | FaviconService: faviconService, | ||
| 136 | - | SessionSpawner: bootstrap.NewSessionSpawner(ctx, cfg), | 140 | + | SessionSpawner: bootstrap.NewSessionSpawner(ctx, profile), |
| 137 | ctx: ctx, | 141 | ctx: ctx, | ||
| 138 | logCleanup: logCleanup, | 142 | logCleanup: logCleanup, | ||
| 139 | }, nil | 143 | }, nil | ||
M internal/infrastructure/cef/engine.go +1 -2
| @@ -296,7 +296,6 @@ func (e *Engine) handleExplicitClipboardBridgeText(viewID port.WebViewID, action | |||
| 296 | 296 | Msg("cef: clipboard orchestration skipped — orchestrator nil") | |
| 297 | 297 | return | |
| 298 | 298 | } | |
| 299 | - | textLen := utf8.RuneCountInString(text) | |
| 300 | 299 | if err := e.clipboardTextOrchestrator.HandleExplicitCopy(e.currentContext(), port.ExplicitClipboardInput{ | |
| 301 | 300 | Text: text, | |
| 302 | 301 | Action: action, | |
| @@ -306,7 +305,7 @@ func (e *Engine) handleExplicitClipboardBridgeText(viewID port.WebViewID, action | |||
| 306 | 305 | logging.FromContext(e.currentContext()).Debug(). | |
| 307 | 306 | Err(err). | |
| 308 | 307 | Str("action", action). | |
| 309 | - | Int("text_len", textLen). | |
| 308 | + | Int("text_len", utf8.RuneCountInString(text)). | |
| 310 | 309 | Msg("cef: clipboard explicit copy handling failed") | |
| 311 | 310 | } | |
| 312 | 311 | } | |
| @@ -296,7 +296,6 @@ func (e *Engine) handleExplicitClipboardBridgeText(viewID port.WebViewID, action | |||||
| 296 | Msg("cef: clipboard orchestration skipped — orchestrator nil") | 296 | Msg("cef: clipboard orchestration skipped — orchestrator nil") | ||
| 297 | return | 297 | return | ||
| 298 | } | 298 | } | ||
| 299 | - | textLen := utf8.RuneCountInString(text) | |||
| 300 | if err := e.clipboardTextOrchestrator.HandleExplicitCopy(e.currentContext(), port.ExplicitClipboardInput{ | 299 | if err := e.clipboardTextOrchestrator.HandleExplicitCopy(e.currentContext(), port.ExplicitClipboardInput{ | ||
| 301 | Text: text, | 300 | Text: text, | ||
| 302 | Action: action, | 301 | Action: action, | ||
| @@ -306,7 +305,7 @@ func (e *Engine) handleExplicitClipboardBridgeText(viewID port.WebViewID, action | |||||
| 306 | logging.FromContext(e.currentContext()).Debug(). | 305 | logging.FromContext(e.currentContext()).Debug(). | ||
| 307 | Err(err). | 306 | Err(err). | ||
| 308 | Str("action", action). | 307 | Str("action", action). | ||
| 309 | - | Int("text_len", textLen). | 308 | + | Int("text_len", utf8.RuneCountInString(text)). |
| 310 | Msg("cef: clipboard explicit copy handling failed") | 309 | Msg("cef: clipboard explicit copy handling failed") | ||
| 311 | } | 310 | } | ||
| 312 | } | 311 | } | ||
M internal/infrastructure/cef/engine_init.go +24 -53
| @@ -16,6 +16,7 @@ import ( | |||
| 16 | 16 | "github.com/bnema/dumber/assets" | |
| 17 | 17 | "github.com/bnema/dumber/internal/application/port" | |
| 18 | 18 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 19 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 19 | 20 | transcoderpkg "github.com/bnema/dumber/internal/infrastructure/transcoder" | |
| 20 | 21 | "github.com/bnema/dumber/internal/logging" | |
| 21 | 22 | ) | |
| @@ -27,6 +28,7 @@ const CEFRootCachePathEnvVar = "DUMBER_CEF_ROOT_CACHE_PATH" | |||
| 27 | 28 | func NewEngine( | |
| 28 | 29 | ctx context.Context, | |
| 29 | 30 | opts port.EngineOptions, | |
| 31 | + | profile runtimeprofile.Profile, | |
| 30 | 32 | cfg RuntimeConfig, | |
| 31 | 33 | transcodingCfg TranscodingRuntimeConfig, | |
| 32 | 34 | audioFactory port.AudioOutputFactory, | |
| @@ -34,11 +36,11 @@ func NewEngine( | |||
| 34 | 36 | ) (*Engine, error) { | |
| 35 | 37 | logger := logging.FromContext(ctx) | |
| 36 | 38 | deps.MediaClassifier = deps.MediaClassifier.normalize() | |
| 37 | - | stateRoot := resolvedStateRoot(opts) | |
| 39 | + | stateRoot := resolvedStateRoot(profile, opts) | |
| 38 | 40 | cleanStaleSingletonLocks(logger, stateRoot) | |
| 39 | 41 | windowlessFrameRate := config.CEFEngineConfig{WindowlessFrameRate: cfg.WindowlessFrameRate}.CEFWindowlessFrameRate() | |
| 40 | 42 | ||
| 41 | - | settings, err := prepareCEFSettings(opts, cfg, logger) | |
| 43 | + | settings, err := prepareCEFSettings(opts, profile, cfg, logger) | |
| 42 | 44 | if err != nil { | |
| 43 | 45 | return nil, err | |
| 44 | 46 | } | |
| @@ -88,23 +90,23 @@ func NewEngine( | |||
| 88 | 90 | ) | |
| 89 | 91 | } | |
| 90 | 92 | ||
| 91 | - | func resolvedStateRoot(opts port.EngineOptions) string { | |
| 93 | + | func resolvedStateRoot(profile runtimeprofile.Profile, opts port.EngineOptions) string { | |
| 92 | 94 | if root := os.Getenv(CEFRootCachePathEnvVar); root != "" { | |
| 93 | 95 | return root | |
| 94 | 96 | } | |
| 95 | - | ||
| 96 | - | switch { | |
| 97 | - | case opts.DataDir != "": | |
| 97 | + | if opts.DataDir != "" { | |
| 98 | 98 | return opts.DataDir | |
| 99 | - | case opts.CacheDir != "": | |
| 100 | - | return opts.CacheDir | |
| 101 | - | default: | |
| 102 | - | return defaultCEFUserDataDir() | |
| 103 | 99 | } | |
| 100 | + | return profile.CEFUserDataDir() | |
| 104 | 101 | } | |
| 105 | 102 | ||
| 106 | 103 | // prepareCEFSettings builds purecef.Settings from the engine config. | |
| 107 | - | func prepareCEFSettings(opts port.EngineOptions, cfg RuntimeConfig, logger *zerolog.Logger) (purecef.Settings, error) { | |
| 104 | + | func prepareCEFSettings( | |
| 105 | + | opts port.EngineOptions, | |
| 106 | + | profile runtimeprofile.Profile, | |
| 107 | + | cfg RuntimeConfig, | |
| 108 | + | logger *zerolog.Logger, | |
| 109 | + | ) (purecef.Settings, error) { | |
| 108 | 110 | switch opts.CookiePolicy { | |
| 109 | 111 | case "", port.CookiePolicyAlways: | |
| 110 | 112 | case port.CookiePolicyNoThirdParty: | |
| @@ -120,16 +122,16 @@ func prepareCEFSettings(opts port.EngineOptions, cfg RuntimeConfig, logger *zero | |||
| 120 | 122 | settings := purecef.DefaultSettings() | |
| 121 | 123 | settings.MultiThreadedMessageLoop = true | |
| 122 | 124 | settings.ExternalMessagePump = false | |
| 123 | - | settings.RootCachePath = resolvedStateRoot(opts) | |
| 125 | + | settings.RootCachePath = resolvedStateRoot(profile, opts) | |
| 124 | 126 | if cfg.CEFDir != "" { | |
| 125 | 127 | settings.CEFDir = cfg.CEFDir | |
| 126 | 128 | } | |
| 127 | - | if runtimeLogFile, err := prepareCEFLogFile(cfg.LogFile); err != nil { | |
| 129 | + | if runtimeLogFile, err := prepareCEFLogFile(profile, cfg.LogFile); err != nil { | |
| 128 | 130 | logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file") | |
| 129 | 131 | } else { | |
| 130 | 132 | settings.LogFile = runtimeLogFile | |
| 131 | 133 | } | |
| 132 | - | if bootstrapLogFile, err := prepareCEFInitTraceFile(cfg.LogFile); err != nil { | |
| 134 | + | if bootstrapLogFile, err := prepareCEFInitTraceFile(profile, cfg.LogFile); err != nil { | |
| 133 | 135 | logger.Warn().Err(err).Msg("cef: failed to prepare init trace file") | |
| 134 | 136 | } else if bootstrapLogFile != "" { | |
| 135 | 137 | settings.InitTraceFile = bootstrapLogFile | |
| @@ -150,24 +152,6 @@ func prepareCEFSettings(opts port.EngineOptions, cfg RuntimeConfig, logger *zero | |||
| 150 | 152 | return settings, nil | |
| 151 | 153 | } | |
| 152 | 154 | ||
| 153 | - | func defaultCEFUserDataDir() string { | |
| 154 | - | // Keep CEF state under Dumber's XDG data directory like the rest of runtime data. | |
| 155 | - | dirs, err := config.GetXDGDirs() | |
| 156 | - | if err != nil { | |
| 157 | - | dataHome := os.Getenv("XDG_DATA_HOME") | |
| 158 | - | if dataHome == "" { | |
| 159 | - | dataHome = filepath.Join(os.Getenv("HOME"), ".local", "share") | |
| 160 | - | } | |
| 161 | - | return filepath.Clean(filepath.Join(dataHome, "dumber", "cef_user_data")) | |
| 162 | - | } | |
| 163 | - | return filepath.Join(dirs.DataHome, "cef_user_data") | |
| 164 | - | } | |
| 165 | - | ||
| 166 | - | // DefaultCEFUserDataDir returns the default CEF state root. | |
| 167 | - | func DefaultCEFUserDataDir() string { | |
| 168 | - | return defaultCEFUserDataDir() | |
| 169 | - | } | |
| 170 | - | ||
| 171 | 155 | // initializeCEF calls cef_initialize with the App to register custom schemes | |
| 172 | 156 | // and browser-process callbacks (OnBeforeCommandLineProcessing, etc.). | |
| 173 | 157 | func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logger) error { | |
| @@ -419,29 +403,20 @@ const ( | |||
| 419 | 403 | filePerm = 0o644 | |
| 420 | 404 | ) | |
| 421 | 405 | ||
| 422 | - | func prepareCEFLogFile(logFile string) (string, error) { | |
| 423 | - | if logFile != "" { | |
| 424 | - | runtimeLogFile := filepath.Clean(logFile) | |
| 425 | - | if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil { | |
| 426 | - | return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err) | |
| 427 | - | } | |
| 428 | - | return runtimeLogFile, nil | |
| 429 | - | } | |
| 430 | - | ||
| 431 | - | // Dev-mode default: logs go into .dev/dumber/logs/ relative to CWD. | |
| 432 | - | // Production deployments should set cfg.LogFile explicitly. | |
| 433 | - | cwd, cwdErr := os.Getwd() | |
| 434 | - | if cwdErr != nil { | |
| 435 | - | return "", fmt.Errorf("getwd: %w", cwdErr) | |
| 406 | + | func prepareCEFLogFile(profile runtimeprofile.Profile, logFile string) (string, error) { | |
| 407 | + | runtimeLogFile := logFile | |
| 408 | + | if runtimeLogFile != "" { | |
| 409 | + | runtimeLogFile = filepath.Clean(runtimeLogFile) | |
| 410 | + | } else { | |
| 411 | + | runtimeLogFile = profile.CEFLogFile() | |
| 436 | 412 | } | |
| 437 | - | runtimeLogFile := filepath.Join(cwd, ".dev", "dumber", "logs", "cef_runtime.log") | |
| 438 | 413 | if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil { | |
| 439 | 414 | return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err) | |
| 440 | 415 | } | |
| 441 | 416 | return runtimeLogFile, nil | |
| 442 | 417 | } | |
| 443 | 418 | ||
| 444 | - | func prepareCEFInitTraceFile(logFile string) (string, error) { | |
| 419 | + | func prepareCEFInitTraceFile(profile runtimeprofile.Profile, logFile string) (string, error) { | |
| 445 | 420 | if !puregoCEFInitTraceEnabled() { | |
| 446 | 421 | return "", nil | |
| 447 | 422 | } | |
| @@ -450,11 +425,7 @@ func prepareCEFInitTraceFile(logFile string) (string, error) { | |||
| 450 | 425 | if runtimeLogFile != "" { | |
| 451 | 426 | runtimeLogFile = filepath.Clean(runtimeLogFile) | |
| 452 | 427 | } else { | |
| 453 | - | cwd, cwdErr := os.Getwd() | |
| 454 | - | if cwdErr != nil { | |
| 455 | - | return "", fmt.Errorf("getwd: %w", cwdErr) | |
| 456 | - | } | |
| 457 | - | runtimeLogFile = filepath.Join(cwd, ".dev", "dumber", "logs", "cef_runtime.log") | |
| 428 | + | runtimeLogFile = profile.CEFLogFile() | |
| 458 | 429 | } | |
| 459 | 430 | bootstrapLogFile := strings.TrimSuffix(runtimeLogFile, filepath.Ext(runtimeLogFile)) + ".bootstrap.log" | |
| 460 | 431 | if err := os.MkdirAll(filepath.Dir(bootstrapLogFile), dirPerm); err != nil { | |
| @@ -16,6 +16,7 @@ import ( | |||||
| 16 | "github.com/bnema/dumber/assets" | 16 | "github.com/bnema/dumber/assets" | ||
| 17 | "github.com/bnema/dumber/internal/application/port" | 17 | "github.com/bnema/dumber/internal/application/port" | ||
| 18 | "github.com/bnema/dumber/internal/infrastructure/config" | 18 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 19 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 19 | transcoderpkg "github.com/bnema/dumber/internal/infrastructure/transcoder" | 20 | transcoderpkg "github.com/bnema/dumber/internal/infrastructure/transcoder" | ||
| 20 | "github.com/bnema/dumber/internal/logging" | 21 | "github.com/bnema/dumber/internal/logging" | ||
| 21 | ) | 22 | ) | ||
| @@ -27,6 +28,7 @@ const CEFRootCachePathEnvVar = "DUMBER_CEF_ROOT_CACHE_PATH" | |||||
| 27 | func NewEngine( | 28 | func NewEngine( | ||
| 28 | ctx context.Context, | 29 | ctx context.Context, | ||
| 29 | opts port.EngineOptions, | 30 | opts port.EngineOptions, | ||
| 31 | + | profile runtimeprofile.Profile, | |||
| 30 | cfg RuntimeConfig, | 32 | cfg RuntimeConfig, | ||
| 31 | transcodingCfg TranscodingRuntimeConfig, | 33 | transcodingCfg TranscodingRuntimeConfig, | ||
| 32 | audioFactory port.AudioOutputFactory, | 34 | audioFactory port.AudioOutputFactory, | ||
| @@ -34,11 +36,11 @@ func NewEngine( | |||||
| 34 | ) (*Engine, error) { | 36 | ) (*Engine, error) { | ||
| 35 | logger := logging.FromContext(ctx) | 37 | logger := logging.FromContext(ctx) | ||
| 36 | deps.MediaClassifier = deps.MediaClassifier.normalize() | 38 | deps.MediaClassifier = deps.MediaClassifier.normalize() | ||
| 37 | - | stateRoot := resolvedStateRoot(opts) | 39 | + | stateRoot := resolvedStateRoot(profile, opts) |
| 38 | cleanStaleSingletonLocks(logger, stateRoot) | 40 | cleanStaleSingletonLocks(logger, stateRoot) | ||
| 39 | windowlessFrameRate := config.CEFEngineConfig{WindowlessFrameRate: cfg.WindowlessFrameRate}.CEFWindowlessFrameRate() | 41 | windowlessFrameRate := config.CEFEngineConfig{WindowlessFrameRate: cfg.WindowlessFrameRate}.CEFWindowlessFrameRate() | ||
| 40 | 42 | ||||
| 41 | - | settings, err := prepareCEFSettings(opts, cfg, logger) | 43 | + | settings, err := prepareCEFSettings(opts, profile, cfg, logger) |
| 42 | if err != nil { | 44 | if err != nil { | ||
| 43 | return nil, err | 45 | return nil, err | ||
| 44 | } | 46 | } | ||
| @@ -88,23 +90,23 @@ func NewEngine( | |||||
| 88 | ) | 90 | ) | ||
| 89 | } | 91 | } | ||
| 90 | 92 | ||||
| 91 | - | func resolvedStateRoot(opts port.EngineOptions) string { | 93 | + | func resolvedStateRoot(profile runtimeprofile.Profile, opts port.EngineOptions) string { |
| 92 | if root := os.Getenv(CEFRootCachePathEnvVar); root != "" { | 94 | if root := os.Getenv(CEFRootCachePathEnvVar); root != "" { | ||
| 93 | return root | 95 | return root | ||
| 94 | } | 96 | } | ||
| 95 | - | 97 | + | if opts.DataDir != "" { | |
| 96 | - | switch { | |||
| 97 | - | case opts.DataDir != "": | |||
| 98 | return opts.DataDir | 98 | return opts.DataDir | ||
| 99 | - | case opts.CacheDir != "": | |||
| 100 | - | return opts.CacheDir | |||
| 101 | - | default: | |||
| 102 | - | return defaultCEFUserDataDir() | |||
| 103 | } | 99 | } | ||
| 100 | + | return profile.CEFUserDataDir() | |||
| 104 | } | 101 | } | ||
| 105 | 102 | ||||
| 106 | // prepareCEFSettings builds purecef.Settings from the engine config. | 103 | // prepareCEFSettings builds purecef.Settings from the engine config. | ||
| 107 | - | func prepareCEFSettings(opts port.EngineOptions, cfg RuntimeConfig, logger *zerolog.Logger) (purecef.Settings, error) { | 104 | + | func prepareCEFSettings( |
| 105 | + | opts port.EngineOptions, | |||
| 106 | + | profile runtimeprofile.Profile, | |||
| 107 | + | cfg RuntimeConfig, | |||
| 108 | + | logger *zerolog.Logger, | |||
| 109 | + | ) (purecef.Settings, error) { | |||
| 108 | switch opts.CookiePolicy { | 110 | switch opts.CookiePolicy { | ||
| 109 | case "", port.CookiePolicyAlways: | 111 | case "", port.CookiePolicyAlways: | ||
| 110 | case port.CookiePolicyNoThirdParty: | 112 | case port.CookiePolicyNoThirdParty: | ||
| @@ -120,16 +122,16 @@ func prepareCEFSettings(opts port.EngineOptions, cfg RuntimeConfig, logger *zero | |||||
| 120 | settings := purecef.DefaultSettings() | 122 | settings := purecef.DefaultSettings() | ||
| 121 | settings.MultiThreadedMessageLoop = true | 123 | settings.MultiThreadedMessageLoop = true | ||
| 122 | settings.ExternalMessagePump = false | 124 | settings.ExternalMessagePump = false | ||
| 123 | - | settings.RootCachePath = resolvedStateRoot(opts) | 125 | + | settings.RootCachePath = resolvedStateRoot(profile, opts) |
| 124 | if cfg.CEFDir != "" { | 126 | if cfg.CEFDir != "" { | ||
| 125 | settings.CEFDir = cfg.CEFDir | 127 | settings.CEFDir = cfg.CEFDir | ||
| 126 | } | 128 | } | ||
| 127 | - | if runtimeLogFile, err := prepareCEFLogFile(cfg.LogFile); err != nil { | 129 | + | if runtimeLogFile, err := prepareCEFLogFile(profile, cfg.LogFile); err != nil { |
| 128 | logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file") | 130 | logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file") | ||
| 129 | } else { | 131 | } else { | ||
| 130 | settings.LogFile = runtimeLogFile | 132 | settings.LogFile = runtimeLogFile | ||
| 131 | } | 133 | } | ||
| 132 | - | if bootstrapLogFile, err := prepareCEFInitTraceFile(cfg.LogFile); err != nil { | 134 | + | if bootstrapLogFile, err := prepareCEFInitTraceFile(profile, cfg.LogFile); err != nil { |
| 133 | logger.Warn().Err(err).Msg("cef: failed to prepare init trace file") | 135 | logger.Warn().Err(err).Msg("cef: failed to prepare init trace file") | ||
| 134 | } else if bootstrapLogFile != "" { | 136 | } else if bootstrapLogFile != "" { | ||
| 135 | settings.InitTraceFile = bootstrapLogFile | 137 | settings.InitTraceFile = bootstrapLogFile | ||
| @@ -150,24 +152,6 @@ func prepareCEFSettings(opts port.EngineOptions, cfg RuntimeConfig, logger *zero | |||||
| 150 | return settings, nil | 152 | return settings, nil | ||
| 151 | } | 153 | } | ||
| 152 | 154 | ||||
| 153 | - | func defaultCEFUserDataDir() string { | |||
| 154 | - | // Keep CEF state under Dumber's XDG data directory like the rest of runtime data. | |||
| 155 | - | dirs, err := config.GetXDGDirs() | |||
| 156 | - | if err != nil { | |||
| 157 | - | dataHome := os.Getenv("XDG_DATA_HOME") | |||
| 158 | - | if dataHome == "" { | |||
| 159 | - | dataHome = filepath.Join(os.Getenv("HOME"), ".local", "share") | |||
| 160 | - | } | |||
| 161 | - | return filepath.Clean(filepath.Join(dataHome, "dumber", "cef_user_data")) | |||
| 162 | - | } | |||
| 163 | - | return filepath.Join(dirs.DataHome, "cef_user_data") | |||
| 164 | - | } | |||
| 165 | - | ||||
| 166 | - | // DefaultCEFUserDataDir returns the default CEF state root. | |||
| 167 | - | func DefaultCEFUserDataDir() string { | |||
| 168 | - | return defaultCEFUserDataDir() | |||
| 169 | - | } | |||
| 170 | - | ||||
| 171 | // initializeCEF calls cef_initialize with the App to register custom schemes | 155 | // initializeCEF calls cef_initialize with the App to register custom schemes | ||
| 172 | // and browser-process callbacks (OnBeforeCommandLineProcessing, etc.). | 156 | // and browser-process callbacks (OnBeforeCommandLineProcessing, etc.). | ||
| 173 | func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logger) error { | 157 | func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logger) error { | ||
| @@ -419,29 +403,20 @@ const ( | |||||
| 419 | filePerm = 0o644 | 403 | filePerm = 0o644 | ||
| 420 | ) | 404 | ) | ||
| 421 | 405 | ||||
| 422 | - | func prepareCEFLogFile(logFile string) (string, error) { | 406 | + | func prepareCEFLogFile(profile runtimeprofile.Profile, logFile string) (string, error) { |
| 423 | - | if logFile != "" { | 407 | + | runtimeLogFile := logFile |
| 424 | - | runtimeLogFile := filepath.Clean(logFile) | 408 | + | if runtimeLogFile != "" { |
| 425 | - | if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil { | 409 | + | runtimeLogFile = filepath.Clean(runtimeLogFile) |
| 426 | - | return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err) | 410 | + | } else { |
| 427 | - | } | 411 | + | runtimeLogFile = profile.CEFLogFile() |
| 428 | - | return runtimeLogFile, nil | |||
| 429 | - | } | |||
| 430 | - | ||||
| 431 | - | // Dev-mode default: logs go into .dev/dumber/logs/ relative to CWD. | |||
| 432 | - | // Production deployments should set cfg.LogFile explicitly. | |||
| 433 | - | cwd, cwdErr := os.Getwd() | |||
| 434 | - | if cwdErr != nil { | |||
| 435 | - | return "", fmt.Errorf("getwd: %w", cwdErr) | |||
| 436 | } | 412 | } | ||
| 437 | - | runtimeLogFile := filepath.Join(cwd, ".dev", "dumber", "logs", "cef_runtime.log") | |||
| 438 | if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil { | 413 | if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil { | ||
| 439 | return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err) | 414 | return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err) | ||
| 440 | } | 415 | } | ||
| 441 | return runtimeLogFile, nil | 416 | return runtimeLogFile, nil | ||
| 442 | } | 417 | } | ||
| 443 | 418 | ||||
| 444 | - | func prepareCEFInitTraceFile(logFile string) (string, error) { | 419 | + | func prepareCEFInitTraceFile(profile runtimeprofile.Profile, logFile string) (string, error) { |
| 445 | if !puregoCEFInitTraceEnabled() { | 420 | if !puregoCEFInitTraceEnabled() { | ||
| 446 | return "", nil | 421 | return "", nil | ||
| 447 | } | 422 | } | ||
| @@ -450,11 +425,7 @@ func prepareCEFInitTraceFile(logFile string) (string, error) { | |||||
| 450 | if runtimeLogFile != "" { | 425 | if runtimeLogFile != "" { | ||
| 451 | runtimeLogFile = filepath.Clean(runtimeLogFile) | 426 | runtimeLogFile = filepath.Clean(runtimeLogFile) | ||
| 452 | } else { | 427 | } else { | ||
| 453 | - | cwd, cwdErr := os.Getwd() | 428 | + | runtimeLogFile = profile.CEFLogFile() |
| 454 | - | if cwdErr != nil { | |||
| 455 | - | return "", fmt.Errorf("getwd: %w", cwdErr) | |||
| 456 | - | } | |||
| 457 | - | runtimeLogFile = filepath.Join(cwd, ".dev", "dumber", "logs", "cef_runtime.log") | |||
| 458 | } | 429 | } | ||
| 459 | bootstrapLogFile := strings.TrimSuffix(runtimeLogFile, filepath.Ext(runtimeLogFile)) + ".bootstrap.log" | 430 | bootstrapLogFile := strings.TrimSuffix(runtimeLogFile, filepath.Ext(runtimeLogFile)) + ".bootstrap.log" | ||
| 460 | if err := os.MkdirAll(filepath.Dir(bootstrapLogFile), dirPerm); err != nil { | 431 | if err := os.MkdirAll(filepath.Dir(bootstrapLogFile), dirPerm); err != nil { | ||
M internal/infrastructure/cef/engine_init_test.go +65 -55
| @@ -1,111 +1,86 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "os" | |
| 5 | 4 | "path/filepath" | |
| 6 | 5 | "testing" | |
| 7 | 6 | ||
| 8 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | 8 | "github.com/bnema/dumber/internal/infrastructure/audio/factory" | |
| 10 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 9 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 11 | 10 | "github.com/rs/zerolog" | |
| 12 | 11 | "github.com/stretchr/testify/require" | |
| 13 | 12 | ) | |
| 14 | 13 | ||
| 15 | 14 | func TestResolvedStateRoot_PrefersDataDir(t *testing.T) { | |
| 16 | - | root := resolvedStateRoot(port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}) | |
| 15 | + | profile := testCEFDevProfile(t) | |
| 16 | + | root := resolvedStateRoot(profile, port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}) | |
| 17 | 17 | require.Equal(t, "/tmp/data", root) | |
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | - | func TestResolvedStateRoot_FallsBackToCacheDir(t *testing.T) { | |
| 21 | - | root := resolvedStateRoot(port.EngineOptions{CacheDir: "/tmp/cache"}) | |
| 22 | - | require.Equal(t, "/tmp/cache", root) | |
| 23 | - | } | |
| 24 | - | ||
| 25 | 20 | func TestResolvedStateRoot_UsesEnvOverride(t *testing.T) { | |
| 21 | + | profile := testCEFDevProfile(t) | |
| 26 | 22 | t.Setenv(CEFRootCachePathEnvVar, "/tmp/override") | |
| 27 | 23 | ||
| 28 | - | root := resolvedStateRoot(port.EngineOptions{}) | |
| 24 | + | root := resolvedStateRoot(profile, port.EngineOptions{}) | |
| 29 | 25 | require.Equal(t, "/tmp/override", root) | |
| 30 | 26 | ||
| 31 | - | root = resolvedStateRoot(port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}) | |
| 27 | + | root = resolvedStateRoot(profile, port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}) | |
| 32 | 28 | require.Equal(t, "/tmp/override", root) | |
| 33 | 29 | } | |
| 34 | 30 | ||
| 35 | - | func TestPrepareCEFSettings_UsesResolvedStateRoot(t *testing.T) { | |
| 31 | + | func TestResolvedStateRoot_UsesProfileDefault(t *testing.T) { | |
| 32 | + | profile := testCEFProdProfile(t) | |
| 33 | + | root := resolvedStateRoot(profile, port.EngineOptions{}) | |
| 34 | + | require.Equal(t, profile.CEFUserDataDir(), root) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestPrepareCEFSettings_UsesResolvedProfilePaths(t *testing.T) { | |
| 36 | 38 | logger := zerolog.Nop() | |
| 37 | - | settings, err := prepareCEFSettings(port.EngineOptions{CacheDir: "/tmp/cache"}, RuntimeConfig{}, &logger) | |
| 39 | + | profile := testCEFDevProfile(t) | |
| 40 | + | settings, err := prepareCEFSettings(port.EngineOptions{}, profile, RuntimeConfig{}, &logger) | |
| 38 | 41 | require.NoError(t, err) | |
| 39 | - | require.Equal(t, "/tmp/cache", settings.RootCachePath) | |
| 42 | + | require.Equal(t, profile.CEFUserDataDir(), settings.RootCachePath) | |
| 43 | + | require.Equal(t, profile.CEFLogFile(), settings.LogFile) | |
| 40 | 44 | } | |
| 41 | 45 | ||
| 42 | 46 | func TestPrepareCEFSettings_RejectsNonDefaultCookiePolicy(t *testing.T) { | |
| 43 | 47 | logger := zerolog.Nop() | |
| 44 | - | _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNever}, RuntimeConfig{}, &logger) | |
| 48 | + | _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNever}, testCEFDevProfile(t), RuntimeConfig{}, &logger) | |
| 45 | 49 | require.ErrorIs(t, err, ErrCookiePolicyUnsupported) | |
| 46 | 50 | } | |
| 47 | 51 | ||
| 48 | 52 | func TestPrepareCEFSettings_AllowsNoThirdPartyCookiePolicy(t *testing.T) { | |
| 49 | 53 | logger := zerolog.Nop() | |
| 50 | - | _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNoThirdParty}, RuntimeConfig{}, &logger) | |
| 54 | + | _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNoThirdParty}, testCEFDevProfile(t), RuntimeConfig{}, &logger) | |
| 51 | 55 | require.NoError(t, err) | |
| 52 | 56 | } | |
| 53 | 57 | ||
| 54 | 58 | func TestPrepareCEFInitTraceFile_DisabledByDefault(t *testing.T) { | |
| 55 | 59 | t.Setenv(puregoCEFInitTraceEnvVar, "") | |
| 56 | 60 | ||
| 57 | - | path, err := prepareCEFInitTraceFile("") | |
| 61 | + | path, err := prepareCEFInitTraceFile(testCEFDevProfile(t), "") | |
| 58 | 62 | require.NoError(t, err) | |
| 59 | 63 | require.Empty(t, path) | |
| 60 | 64 | } | |
| 61 | 65 | ||
| 62 | - | func TestPrepareCEFInitTraceFile_EnabledViaEnv(t *testing.T) { | |
| 66 | + | func TestPrepareCEFInitTraceFile_EnabledViaExplicitLogFile(t *testing.T) { | |
| 63 | 67 | t.Setenv(puregoCEFInitTraceEnvVar, "1") | |
| 64 | - | cfg := config.CEFEngineConfig{ | |
| 65 | - | LogFile: filepath.Join(t.TempDir(), "cef_runtime.log"), | |
| 66 | - | } | |
| 68 | + | logFile := filepath.Join(t.TempDir(), "custom", "cef_runtime.log") | |
| 67 | 69 | ||
| 68 | - | path, err := prepareCEFInitTraceFile(cfg.LogFile) | |
| 70 | + | path, err := prepareCEFInitTraceFile(testCEFDevProfile(t), logFile) | |
| 69 | 71 | require.NoError(t, err) | |
| 70 | - | require.Equal(t, filepath.Join(filepath.Dir(cfg.LogFile), "cef_runtime.bootstrap.log"), path) | |
| 72 | + | require.Equal(t, filepath.Join(filepath.Dir(logFile), "cef_runtime.bootstrap.log"), path) | |
| 71 | 73 | require.FileExists(t, path) | |
| 72 | 74 | } | |
| 73 | 75 | ||
| 74 | - | func TestPrepareCEFSettings_RootCachePath_UsesXDGDataDir(t *testing.T) { | |
| 75 | - | homeDir := filepath.Join(t.TempDir(), "home") | |
| 76 | - | dataHome := filepath.Join(homeDir, ".local", "share") | |
| 77 | - | t.Setenv("HOME", homeDir) | |
| 78 | - | t.Setenv("ENV", "") | |
| 79 | - | t.Setenv("XDG_DATA_HOME", dataHome) | |
| 80 | - | ||
| 81 | - | logger := zerolog.Nop() | |
| 82 | - | settings, err := prepareCEFSettings(port.EngineOptions{}, RuntimeConfig{}, &logger) | |
| 83 | - | require.NoError(t, err) | |
| 84 | - | ||
| 85 | - | require.Equal(t, filepath.Join(dataHome, "dumber", "cef_user_data"), settings.RootCachePath) | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | func TestPrepareCEFSettings_RootCachePath_DevMode(t *testing.T) { | |
| 89 | - | // Create a temp directory and change into it for the test | |
| 90 | - | tempDir := t.TempDir() | |
| 91 | - | originalDir, err := os.Getwd() | |
| 92 | - | require.NoError(t, err) | |
| 93 | - | defer func() { | |
| 94 | - | _ = os.Chdir(originalDir) | |
| 95 | - | }() | |
| 96 | - | ||
| 97 | - | err = os.Chdir(tempDir) | |
| 98 | - | require.NoError(t, err) | |
| 99 | - | ||
| 100 | - | // Set ENV=dev to trigger dev mode | |
| 101 | - | t.Setenv("ENV", "dev") | |
| 76 | + | func TestPrepareCEFInitTraceFile_UsesResolvedProfileLogDirByDefault(t *testing.T) { | |
| 77 | + | t.Setenv(puregoCEFInitTraceEnvVar, "1") | |
| 78 | + | profile := testCEFDevProfile(t) | |
| 102 | 79 | ||
| 103 | - | logger := zerolog.Nop() | |
| 104 | - | settings, err := prepareCEFSettings(port.EngineOptions{}, RuntimeConfig{}, &logger) | |
| 80 | + | path, err := prepareCEFInitTraceFile(profile, "") | |
| 105 | 81 | require.NoError(t, err) | |
| 106 | - | ||
| 107 | - | // In dev mode, should resolve under .dev/dumber/cef_user_data | |
| 108 | - | require.Equal(t, filepath.Join(tempDir, ".dev", "dumber", "cef_user_data"), settings.RootCachePath) | |
| 82 | + | require.Equal(t, filepath.Join(profile.EnginePaths.LogDir, "cef_runtime.bootstrap.log"), path) | |
| 83 | + | require.FileExists(t, path) | |
| 109 | 84 | } | |
| 110 | 85 | ||
| 111 | 86 | // TestCreateAudioOutputFactory_CreatesUsableFactory verifies that the audio | |
| @@ -116,3 +91,38 @@ func TestCreateAudioOutputFactory_ReturnsFactory(t *testing.T) { | |||
| 116 | 91 | ||
| 117 | 92 | require.NotNil(t, audioFactory, "Audio factory should not be nil") | |
| 118 | 93 | } | |
| 94 | + | ||
| 95 | + | func testCEFDevProfile(t *testing.T) runtimeprofile.Profile { | |
| 96 | + | t.Helper() | |
| 97 | + | cwd := t.TempDir() | |
| 98 | + | profile, err := runtimeprofile.Resolve(runtimeprofile.ResolveInput{ | |
| 99 | + | Env: func(key string) string { | |
| 100 | + | if key == "ENV" { | |
| 101 | + | return "dev" | |
| 102 | + | } | |
| 103 | + | return "" | |
| 104 | + | }, | |
| 105 | + | Engine: "cef", | |
| 106 | + | CWD: func() (string, error) { return cwd, nil }, | |
| 107 | + | }) | |
| 108 | + | require.NoError(t, err) | |
| 109 | + | return profile | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func testCEFProdProfile(t *testing.T) runtimeprofile.Profile { | |
| 113 | + | t.Helper() | |
| 114 | + | root := t.TempDir() | |
| 115 | + | profile, err := runtimeprofile.Resolve(runtimeprofile.ResolveInput{ | |
| 116 | + | Env: func(string) string { return "" }, | |
| 117 | + | Engine: "cef", | |
| 118 | + | CWD: func() (string, error) { return root, nil }, | |
| 119 | + | Base: runtimeprofile.BasePaths{ | |
| 120 | + | ConfigHome: filepath.Join(root, "config"), | |
| 121 | + | DataHome: filepath.Join(root, "data"), | |
| 122 | + | StateHome: filepath.Join(root, "state"), | |
| 123 | + | CacheHome: filepath.Join(root, "cache"), | |
| 124 | + | }, | |
| 125 | + | }) | |
| 126 | + | require.NoError(t, err) | |
| 127 | + | return profile | |
| 128 | + | } | |
| @@ -1,111 +1,86 @@ | |||||
| 1 | package cef | 1 | package cef | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "os" | |||
| 5 | "path/filepath" | 4 | "path/filepath" | ||
| 6 | "testing" | 5 | "testing" | ||
| 7 | 6 | ||||
| 8 | "github.com/bnema/dumber/internal/application/port" | 7 | "github.com/bnema/dumber/internal/application/port" | ||
| 9 | "github.com/bnema/dumber/internal/infrastructure/audio/factory" | 8 | "github.com/bnema/dumber/internal/infrastructure/audio/factory" | ||
| 10 | - | "github.com/bnema/dumber/internal/infrastructure/config" | 9 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" |
| 11 | "github.com/rs/zerolog" | 10 | "github.com/rs/zerolog" | ||
| 12 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| 13 | ) | 12 | ) | ||
| 14 | 13 | ||||
| 15 | func TestResolvedStateRoot_PrefersDataDir(t *testing.T) { | 14 | func TestResolvedStateRoot_PrefersDataDir(t *testing.T) { | ||
| 16 | - | root := resolvedStateRoot(port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}) | 15 | + | profile := testCEFDevProfile(t) |
| 16 | + | root := resolvedStateRoot(profile, port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}) | |||
| 17 | require.Equal(t, "/tmp/data", root) | 17 | require.Equal(t, "/tmp/data", root) | ||
| 18 | } | 18 | } | ||
| 19 | 19 | ||||
| 20 | - | func TestResolvedStateRoot_FallsBackToCacheDir(t *testing.T) { | |||
| 21 | - | root := resolvedStateRoot(port.EngineOptions{CacheDir: "/tmp/cache"}) | |||
| 22 | - | require.Equal(t, "/tmp/cache", root) | |||
| 23 | - | } | |||
| 24 | - | ||||
| 25 | func TestResolvedStateRoot_UsesEnvOverride(t *testing.T) { | 20 | func TestResolvedStateRoot_UsesEnvOverride(t *testing.T) { | ||
| 21 | + | profile := testCEFDevProfile(t) | |||
| 26 | t.Setenv(CEFRootCachePathEnvVar, "/tmp/override") | 22 | t.Setenv(CEFRootCachePathEnvVar, "/tmp/override") | ||
| 27 | 23 | ||||
| 28 | - | root := resolvedStateRoot(port.EngineOptions{}) | 24 | + | root := resolvedStateRoot(profile, port.EngineOptions{}) |
| 29 | require.Equal(t, "/tmp/override", root) | 25 | require.Equal(t, "/tmp/override", root) | ||
| 30 | 26 | ||||
| 31 | - | root = resolvedStateRoot(port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}) | 27 | + | root = resolvedStateRoot(profile, port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}) |
| 32 | require.Equal(t, "/tmp/override", root) | 28 | require.Equal(t, "/tmp/override", root) | ||
| 33 | } | 29 | } | ||
| 34 | 30 | ||||
| 35 | - | func TestPrepareCEFSettings_UsesResolvedStateRoot(t *testing.T) { | 31 | + | func TestResolvedStateRoot_UsesProfileDefault(t *testing.T) { |
| 32 | + | profile := testCEFProdProfile(t) | |||
| 33 | + | root := resolvedStateRoot(profile, port.EngineOptions{}) | |||
| 34 | + | require.Equal(t, profile.CEFUserDataDir(), root) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestPrepareCEFSettings_UsesResolvedProfilePaths(t *testing.T) { | |||
| 36 | logger := zerolog.Nop() | 38 | logger := zerolog.Nop() | ||
| 37 | - | settings, err := prepareCEFSettings(port.EngineOptions{CacheDir: "/tmp/cache"}, RuntimeConfig{}, &logger) | 39 | + | profile := testCEFDevProfile(t) |
| 40 | + | settings, err := prepareCEFSettings(port.EngineOptions{}, profile, RuntimeConfig{}, &logger) | |||
| 38 | require.NoError(t, err) | 41 | require.NoError(t, err) | ||
| 39 | - | require.Equal(t, "/tmp/cache", settings.RootCachePath) | 42 | + | require.Equal(t, profile.CEFUserDataDir(), settings.RootCachePath) |
| 43 | + | require.Equal(t, profile.CEFLogFile(), settings.LogFile) | |||
| 40 | } | 44 | } | ||
| 41 | 45 | ||||
| 42 | func TestPrepareCEFSettings_RejectsNonDefaultCookiePolicy(t *testing.T) { | 46 | func TestPrepareCEFSettings_RejectsNonDefaultCookiePolicy(t *testing.T) { | ||
| 43 | logger := zerolog.Nop() | 47 | logger := zerolog.Nop() | ||
| 44 | - | _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNever}, RuntimeConfig{}, &logger) | 48 | + | _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNever}, testCEFDevProfile(t), RuntimeConfig{}, &logger) |
| 45 | require.ErrorIs(t, err, ErrCookiePolicyUnsupported) | 49 | require.ErrorIs(t, err, ErrCookiePolicyUnsupported) | ||
| 46 | } | 50 | } | ||
| 47 | 51 | ||||
| 48 | func TestPrepareCEFSettings_AllowsNoThirdPartyCookiePolicy(t *testing.T) { | 52 | func TestPrepareCEFSettings_AllowsNoThirdPartyCookiePolicy(t *testing.T) { | ||
| 49 | logger := zerolog.Nop() | 53 | logger := zerolog.Nop() | ||
| 50 | - | _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNoThirdParty}, RuntimeConfig{}, &logger) | 54 | + | _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNoThirdParty}, testCEFDevProfile(t), RuntimeConfig{}, &logger) |
| 51 | require.NoError(t, err) | 55 | require.NoError(t, err) | ||
| 52 | } | 56 | } | ||
| 53 | 57 | ||||
| 54 | func TestPrepareCEFInitTraceFile_DisabledByDefault(t *testing.T) { | 58 | func TestPrepareCEFInitTraceFile_DisabledByDefault(t *testing.T) { | ||
| 55 | t.Setenv(puregoCEFInitTraceEnvVar, "") | 59 | t.Setenv(puregoCEFInitTraceEnvVar, "") | ||
| 56 | 60 | ||||
| 57 | - | path, err := prepareCEFInitTraceFile("") | 61 | + | path, err := prepareCEFInitTraceFile(testCEFDevProfile(t), "") |
| 58 | require.NoError(t, err) | 62 | require.NoError(t, err) | ||
| 59 | require.Empty(t, path) | 63 | require.Empty(t, path) | ||
| 60 | } | 64 | } | ||
| 61 | 65 | ||||
| 62 | - | func TestPrepareCEFInitTraceFile_EnabledViaEnv(t *testing.T) { | 66 | + | func TestPrepareCEFInitTraceFile_EnabledViaExplicitLogFile(t *testing.T) { |
| 63 | t.Setenv(puregoCEFInitTraceEnvVar, "1") | 67 | t.Setenv(puregoCEFInitTraceEnvVar, "1") | ||
| 64 | - | cfg := config.CEFEngineConfig{ | 68 | + | logFile := filepath.Join(t.TempDir(), "custom", "cef_runtime.log") |
| 65 | - | LogFile: filepath.Join(t.TempDir(), "cef_runtime.log"), | |||
| 66 | - | } | |||
| 67 | 69 | ||||
| 68 | - | path, err := prepareCEFInitTraceFile(cfg.LogFile) | 70 | + | path, err := prepareCEFInitTraceFile(testCEFDevProfile(t), logFile) |
| 69 | require.NoError(t, err) | 71 | require.NoError(t, err) | ||
| 70 | - | require.Equal(t, filepath.Join(filepath.Dir(cfg.LogFile), "cef_runtime.bootstrap.log"), path) | 72 | + | require.Equal(t, filepath.Join(filepath.Dir(logFile), "cef_runtime.bootstrap.log"), path) |
| 71 | require.FileExists(t, path) | 73 | require.FileExists(t, path) | ||
| 72 | } | 74 | } | ||
| 73 | 75 | ||||
| 74 | - | func TestPrepareCEFSettings_RootCachePath_UsesXDGDataDir(t *testing.T) { | 76 | + | func TestPrepareCEFInitTraceFile_UsesResolvedProfileLogDirByDefault(t *testing.T) { |
| 75 | - | homeDir := filepath.Join(t.TempDir(), "home") | 77 | + | t.Setenv(puregoCEFInitTraceEnvVar, "1") |
| 76 | - | dataHome := filepath.Join(homeDir, ".local", "share") | 78 | + | profile := testCEFDevProfile(t) |
| 77 | - | t.Setenv("HOME", homeDir) | |||
| 78 | - | t.Setenv("ENV", "") | |||
| 79 | - | t.Setenv("XDG_DATA_HOME", dataHome) | |||
| 80 | - | ||||
| 81 | - | logger := zerolog.Nop() | |||
| 82 | - | settings, err := prepareCEFSettings(port.EngineOptions{}, RuntimeConfig{}, &logger) | |||
| 83 | - | require.NoError(t, err) | |||
| 84 | - | ||||
| 85 | - | require.Equal(t, filepath.Join(dataHome, "dumber", "cef_user_data"), settings.RootCachePath) | |||
| 86 | - | } | |||
| 87 | - | ||||
| 88 | - | func TestPrepareCEFSettings_RootCachePath_DevMode(t *testing.T) { | |||
| 89 | - | // Create a temp directory and change into it for the test | |||
| 90 | - | tempDir := t.TempDir() | |||
| 91 | - | originalDir, err := os.Getwd() | |||
| 92 | - | require.NoError(t, err) | |||
| 93 | - | defer func() { | |||
| 94 | - | _ = os.Chdir(originalDir) | |||
| 95 | - | }() | |||
| 96 | - | ||||
| 97 | - | err = os.Chdir(tempDir) | |||
| 98 | - | require.NoError(t, err) | |||
| 99 | - | ||||
| 100 | - | // Set ENV=dev to trigger dev mode | |||
| 101 | - | t.Setenv("ENV", "dev") | |||
| 102 | 79 | ||||
| 103 | - | logger := zerolog.Nop() | 80 | + | path, err := prepareCEFInitTraceFile(profile, "") |
| 104 | - | settings, err := prepareCEFSettings(port.EngineOptions{}, RuntimeConfig{}, &logger) | |||
| 105 | require.NoError(t, err) | 81 | require.NoError(t, err) | ||
| 106 | - | 82 | + | require.Equal(t, filepath.Join(profile.EnginePaths.LogDir, "cef_runtime.bootstrap.log"), path) | |
| 107 | - | // In dev mode, should resolve under .dev/dumber/cef_user_data | 83 | + | require.FileExists(t, path) |
| 108 | - | require.Equal(t, filepath.Join(tempDir, ".dev", "dumber", "cef_user_data"), settings.RootCachePath) | |||
| 109 | } | 84 | } | ||
| 110 | 85 | ||||
| 111 | // TestCreateAudioOutputFactory_CreatesUsableFactory verifies that the audio | 86 | // TestCreateAudioOutputFactory_CreatesUsableFactory verifies that the audio | ||
| @@ -116,3 +91,38 @@ func TestCreateAudioOutputFactory_ReturnsFactory(t *testing.T) { | |||||
| 116 | 91 | ||||
| 117 | require.NotNil(t, audioFactory, "Audio factory should not be nil") | 92 | require.NotNil(t, audioFactory, "Audio factory should not be nil") | ||
| 118 | } | 93 | } | ||
| 94 | + | ||||
| 95 | + | func testCEFDevProfile(t *testing.T) runtimeprofile.Profile { | |||
| 96 | + | t.Helper() | |||
| 97 | + | cwd := t.TempDir() | |||
| 98 | + | profile, err := runtimeprofile.Resolve(runtimeprofile.ResolveInput{ | |||
| 99 | + | Env: func(key string) string { | |||
| 100 | + | if key == "ENV" { | |||
| 101 | + | return "dev" | |||
| 102 | + | } | |||
| 103 | + | return "" | |||
| 104 | + | }, | |||
| 105 | + | Engine: "cef", | |||
| 106 | + | CWD: func() (string, error) { return cwd, nil }, | |||
| 107 | + | }) | |||
| 108 | + | require.NoError(t, err) | |||
| 109 | + | return profile | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func testCEFProdProfile(t *testing.T) runtimeprofile.Profile { | |||
| 113 | + | t.Helper() | |||
| 114 | + | root := t.TempDir() | |||
| 115 | + | profile, err := runtimeprofile.Resolve(runtimeprofile.ResolveInput{ | |||
| 116 | + | Env: func(string) string { return "" }, | |||
| 117 | + | Engine: "cef", | |||
| 118 | + | CWD: func() (string, error) { return root, nil }, | |||
| 119 | + | Base: runtimeprofile.BasePaths{ | |||
| 120 | + | ConfigHome: filepath.Join(root, "config"), | |||
| 121 | + | DataHome: filepath.Join(root, "data"), | |||
| 122 | + | StateHome: filepath.Join(root, "state"), | |||
| 123 | + | CacheHome: filepath.Join(root, "cache"), | |||
| 124 | + | }, | |||
| 125 | + | }) | |||
| 126 | + | require.NoError(t, err) | |||
| 127 | + | return profile | |||
| 128 | + | } | |||
M internal/infrastructure/cef/engine_runtime_test.go +1 -1
| @@ -85,7 +85,7 @@ func TestEngineExplicitClipboardCopy_ForwardsViewID(t *testing.T) { | |||
| 85 | 85 | ||
| 86 | 86 | eng.handleExplicitClipboardBridgeText(77, "copy", "copied") | |
| 87 | 87 | ||
| 88 | - | require.Equal(t, port.ExplicitClipboardInput{Text: "copied", SourceEngine: port.ClipboardSourceCEF, ViewID: 77, Action: "copy"}, orchestrator.explicit) | |
| 88 | + | require.Equal(t, port.ExplicitClipboardInput{Text: "copied", SourceEngine: port.ClipboardSourceCEF, ViewID: 77, Action: "copy", NativeHandled: false}, orchestrator.explicit) | |
| 89 | 89 | } | |
| 90 | 90 | ||
| 91 | 91 | type stubClipboardTextOrchestrator struct{} | |
| @@ -85,7 +85,7 @@ func TestEngineExplicitClipboardCopy_ForwardsViewID(t *testing.T) { | |||||
| 85 | 85 | ||||
| 86 | eng.handleExplicitClipboardBridgeText(77, "copy", "copied") | 86 | eng.handleExplicitClipboardBridgeText(77, "copy", "copied") | ||
| 87 | 87 | ||||
| 88 | - | require.Equal(t, port.ExplicitClipboardInput{Text: "copied", SourceEngine: port.ClipboardSourceCEF, ViewID: 77, Action: "copy"}, orchestrator.explicit) | 88 | + | require.Equal(t, port.ExplicitClipboardInput{Text: "copied", SourceEngine: port.ClipboardSourceCEF, ViewID: 77, Action: "copy", NativeHandled: false}, orchestrator.explicit) |
| 89 | } | 89 | } | ||
| 90 | 90 | ||||
| 91 | type stubClipboardTextOrchestrator struct{} | 91 | type stubClipboardTextOrchestrator struct{} | ||
M internal/infrastructure/cef/input_bridge.go +2 -1
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "sync" | |
| 6 | 6 | "sync/atomic" | |
| 7 | + | "unicode/utf8" | |
| 7 | 8 | ||
| 8 | 9 | purecef "github.com/bnema/purego-cef/cef" | |
| 9 | 10 | "github.com/bnema/puregotk/v4/gdk" | |
| @@ -392,7 +393,7 @@ func (ib *inputBridge) maybeMirrorClipboardShortcut(keyval, mods uint) { | |||
| 392 | 393 | } | |
| 393 | 394 | logging.FromContext(ib.ctx).Debug(). | |
| 394 | 395 | Str("action", action). | |
| 395 | - | Int("text_len", len(text)). | |
| 396 | + | Int("text_len", utf8.RuneCountInString(text)). | |
| 396 | 397 | Msg("cef: clipboard shortcut routed through explicit copy handler") | |
| 397 | 398 | ib.explicitCopyText(action, text) | |
| 398 | 399 | } | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "sync" | 5 | "sync" | ||
| 6 | "sync/atomic" | 6 | "sync/atomic" | ||
| 7 | + | "unicode/utf8" | |||
| 7 | 8 | ||||
| 8 | purecef "github.com/bnema/purego-cef/cef" | 9 | purecef "github.com/bnema/purego-cef/cef" | ||
| 9 | "github.com/bnema/puregotk/v4/gdk" | 10 | "github.com/bnema/puregotk/v4/gdk" | ||
| @@ -392,7 +393,7 @@ func (ib *inputBridge) maybeMirrorClipboardShortcut(keyval, mods uint) { | |||||
| 392 | } | 393 | } | ||
| 393 | logging.FromContext(ib.ctx).Debug(). | 394 | logging.FromContext(ib.ctx).Debug(). | ||
| 394 | Str("action", action). | 395 | Str("action", action). | ||
| 395 | - | Int("text_len", len(text)). | 396 | + | Int("text_len", utf8.RuneCountInString(text)). |
| 396 | Msg("cef: clipboard shortcut routed through explicit copy handler") | 397 | Msg("cef: clipboard shortcut routed through explicit copy handler") | ||
| 397 | ib.explicitCopyText(action, text) | 398 | ib.explicitCopyText(action, text) | ||
| 398 | } | 399 | } | ||
M internal/infrastructure/cef/renderer_bridge.go +5 -4
| @@ -74,11 +74,12 @@ const rendererBridgeExtensionJS = ` | |||
| 74 | 74 | ||
| 75 | 75 | function mirrorClipboardEvent(action, e) { | |
| 76 | 76 | if (!e.isTrusted) return; | |
| 77 | + | var capturedText = ''; | |
| 78 | + | if (e.clipboardData && typeof e.clipboardData.getData === 'function') { | |
| 79 | + | try { capturedText = e.clipboardData.getData('text/plain'); } catch(_) {} | |
| 80 | + | } | |
| 77 | 81 | setTimeout(function() { | |
| 78 | - | var text = ''; | |
| 79 | - | if (e.clipboardData && typeof e.clipboardData.getData === 'function') { | |
| 80 | - | try { text = e.clipboardData.getData('text/plain'); } catch(_) {} | |
| 81 | - | } | |
| 82 | + | var text = capturedText; | |
| 82 | 83 | if (!text) text = getSelectedText(); | |
| 83 | 84 | if (text) notifyExplicitTextCopy(action, text); | |
| 84 | 85 | }, 0); | |
| @@ -74,11 +74,12 @@ const rendererBridgeExtensionJS = ` | |||||
| 74 | 74 | ||||
| 75 | function mirrorClipboardEvent(action, e) { | 75 | function mirrorClipboardEvent(action, e) { | ||
| 76 | if (!e.isTrusted) return; | 76 | if (!e.isTrusted) return; | ||
| 77 | + | var capturedText = ''; | |||
| 78 | + | if (e.clipboardData && typeof e.clipboardData.getData === 'function') { | |||
| 79 | + | try { capturedText = e.clipboardData.getData('text/plain'); } catch(_) {} | |||
| 80 | + | } | |||
| 77 | setTimeout(function() { | 81 | setTimeout(function() { | ||
| 78 | - | var text = ''; | 82 | + | var text = capturedText; |
| 79 | - | if (e.clipboardData && typeof e.clipboardData.getData === 'function') { | |||
| 80 | - | try { text = e.clipboardData.getData('text/plain'); } catch(_) {} | |||
| 81 | - | } | |||
| 82 | if (!text) text = getSelectedText(); | 83 | if (!text) text = getSelectedText(); | ||
| 83 | if (text) notifyExplicitTextCopy(action, text); | 84 | if (text) notifyExplicitTextCopy(action, text); | ||
| 84 | }, 0); | 85 | }, 0); | ||
M internal/infrastructure/cef/renderer_bridge_test.go +58 -10
| @@ -1,7 +1,11 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "go/ast" | |
| 5 | + | "go/parser" | |
| 6 | + | "go/token" | |
| 4 | 7 | "os" | |
| 8 | + | "strings" | |
| 5 | 9 | "testing" | |
| 6 | 10 | ||
| 7 | 11 | purecef "github.com/bnema/purego-cef/cef" | |
| @@ -100,18 +104,22 @@ func TestRendererBridgeExtensionJS_EncodesTrustedSuccessSemantics(t *testing.T) | |||
| 100 | 104 | require.Contains(t, rendererBridgeExtensionJS, "return result;") | |
| 101 | 105 | } | |
| 102 | 106 | ||
| 103 | - | func TestRendererBridgeSourceDoesNotNeedGoLinkname(t *testing.T) { | |
| 104 | - | src, err := os.ReadFile("renderer_bridge.go") | |
| 105 | - | require.NoError(t, err) | |
| 106 | - | require.NotContains(t, string(src), "go:linkname") | |
| 107 | + | func TestRendererBridgePackageDoesNotNeedGoLinkname(t *testing.T) { | |
| 108 | + | files := parseCEFPackageFiles(t) | |
| 109 | + | for _, file := range files { | |
| 110 | + | for _, group := range file.Comments { | |
| 111 | + | for _, comment := range group.List { | |
| 112 | + | require.NotContains(t, comment.Text, "go:linkname") | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | } | |
| 107 | 116 | } | |
| 108 | 117 | ||
| 109 | - | func TestCEFContentInjectorSourceDoesNotKeepDeprecatedClipboardBridgeConstants(t *testing.T) { | |
| 110 | - | src, err := os.ReadFile("content_injector.go") | |
| 111 | - | require.NoError(t, err) | |
| 112 | - | require.NotContains(t, string(src), "autoCopySelectionBridgeJS") | |
| 113 | - | require.NotContains(t, string(src), "clipboardCopyBridgeJS") | |
| 114 | - | require.NotContains(t, string(src), "editableFocusBridgeJS") | |
| 118 | + | func TestCEFPackageDoesNotKeepDeprecatedClipboardBridgeConstants(t *testing.T) { | |
| 119 | + | files := parseCEFPackageFiles(t) | |
| 120 | + | for _, banned := range []string{"autoCopySelectionBridgeJS", "clipboardCopyBridgeJS", "editableFocusBridgeJS"} { | |
| 121 | + | require.False(t, packageDeclaresIdentifier(files, banned), "unexpected deprecated identifier %q", banned) | |
| 122 | + | } | |
| 115 | 123 | } | |
| 116 | 124 | ||
| 117 | 125 | func TestDecodeRendererBridgeExplicitTextCopyPayload(t *testing.T) { | |
| @@ -121,6 +129,46 @@ func TestDecodeRendererBridgeExplicitTextCopyPayload(t *testing.T) { | |||
| 121 | 129 | require.Equal(t, "cut", req.Action) | |
| 122 | 130 | } | |
| 123 | 131 | ||
| 132 | + | func parseCEFPackageFiles(t *testing.T) []*ast.File { | |
| 133 | + | t.Helper() | |
| 134 | + | entries, err := os.ReadDir(".") | |
| 135 | + | require.NoError(t, err) | |
| 136 | + | ||
| 137 | + | fset := token.NewFileSet() | |
| 138 | + | files := make([]*ast.File, 0, len(entries)) | |
| 139 | + | for _, entry := range entries { | |
| 140 | + | name := entry.Name() | |
| 141 | + | if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { | |
| 142 | + | continue | |
| 143 | + | } | |
| 144 | + | file, err := parser.ParseFile(fset, name, nil, parser.ParseComments) | |
| 145 | + | require.NoError(t, err) | |
| 146 | + | if file.Name != nil && file.Name.Name == "cef" { | |
| 147 | + | files = append(files, file) | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | require.NotEmpty(t, files) | |
| 151 | + | return files | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func packageDeclaresIdentifier(files []*ast.File, name string) bool { | |
| 155 | + | for _, file := range files { | |
| 156 | + | found := false | |
| 157 | + | ast.Inspect(file, func(node ast.Node) bool { | |
| 158 | + | ident, ok := node.(*ast.Ident) | |
| 159 | + | if ok && ident.Name == name { | |
| 160 | + | found = true | |
| 161 | + | return false | |
| 162 | + | } | |
| 163 | + | return true | |
| 164 | + | }) | |
| 165 | + | if found { | |
| 166 | + | return true | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | return false | |
| 170 | + | } | |
| 171 | + | ||
| 124 | 172 | func TestRendererBridgeProcessHandler_OnFocusedNodeChanged_ReportsEditableState(t *testing.T) { | |
| 125 | 173 | oldFactory := newRendererBridgeProcessMessage | |
| 126 | 174 | t.Cleanup(func() { newRendererBridgeProcessMessage = oldFactory }) | |
| @@ -1,7 +1,11 @@ | |||||
| 1 | package cef | 1 | package cef | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "go/ast" | |||
| 5 | + | "go/parser" | |||
| 6 | + | "go/token" | |||
| 4 | "os" | 7 | "os" | ||
| 8 | + | "strings" | |||
| 5 | "testing" | 9 | "testing" | ||
| 6 | 10 | ||||
| 7 | purecef "github.com/bnema/purego-cef/cef" | 11 | purecef "github.com/bnema/purego-cef/cef" | ||
| @@ -100,18 +104,22 @@ func TestRendererBridgeExtensionJS_EncodesTrustedSuccessSemantics(t *testing.T) | |||||
| 100 | require.Contains(t, rendererBridgeExtensionJS, "return result;") | 104 | require.Contains(t, rendererBridgeExtensionJS, "return result;") | ||
| 101 | } | 105 | } | ||
| 102 | 106 | ||||
| 103 | - | func TestRendererBridgeSourceDoesNotNeedGoLinkname(t *testing.T) { | 107 | + | func TestRendererBridgePackageDoesNotNeedGoLinkname(t *testing.T) { |
| 104 | - | src, err := os.ReadFile("renderer_bridge.go") | 108 | + | files := parseCEFPackageFiles(t) |
| 105 | - | require.NoError(t, err) | 109 | + | for _, file := range files { |
| 106 | - | require.NotContains(t, string(src), "go:linkname") | 110 | + | for _, group := range file.Comments { |
| 111 | + | for _, comment := range group.List { | |||
| 112 | + | require.NotContains(t, comment.Text, "go:linkname") | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 107 | } | 116 | } | ||
| 108 | 117 | ||||
| 109 | - | func TestCEFContentInjectorSourceDoesNotKeepDeprecatedClipboardBridgeConstants(t *testing.T) { | 118 | + | func TestCEFPackageDoesNotKeepDeprecatedClipboardBridgeConstants(t *testing.T) { |
| 110 | - | src, err := os.ReadFile("content_injector.go") | 119 | + | files := parseCEFPackageFiles(t) |
| 111 | - | require.NoError(t, err) | 120 | + | for _, banned := range []string{"autoCopySelectionBridgeJS", "clipboardCopyBridgeJS", "editableFocusBridgeJS"} { |
| 112 | - | require.NotContains(t, string(src), "autoCopySelectionBridgeJS") | 121 | + | require.False(t, packageDeclaresIdentifier(files, banned), "unexpected deprecated identifier %q", banned) |
| 113 | - | require.NotContains(t, string(src), "clipboardCopyBridgeJS") | 122 | + | } |
| 114 | - | require.NotContains(t, string(src), "editableFocusBridgeJS") | |||
| 115 | } | 123 | } | ||
| 116 | 124 | ||||
| 117 | func TestDecodeRendererBridgeExplicitTextCopyPayload(t *testing.T) { | 125 | func TestDecodeRendererBridgeExplicitTextCopyPayload(t *testing.T) { | ||
| @@ -121,6 +129,46 @@ func TestDecodeRendererBridgeExplicitTextCopyPayload(t *testing.T) { | |||||
| 121 | require.Equal(t, "cut", req.Action) | 129 | require.Equal(t, "cut", req.Action) | ||
| 122 | } | 130 | } | ||
| 123 | 131 | ||||
| 132 | + | func parseCEFPackageFiles(t *testing.T) []*ast.File { | |||
| 133 | + | t.Helper() | |||
| 134 | + | entries, err := os.ReadDir(".") | |||
| 135 | + | require.NoError(t, err) | |||
| 136 | + | ||||
| 137 | + | fset := token.NewFileSet() | |||
| 138 | + | files := make([]*ast.File, 0, len(entries)) | |||
| 139 | + | for _, entry := range entries { | |||
| 140 | + | name := entry.Name() | |||
| 141 | + | if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { | |||
| 142 | + | continue | |||
| 143 | + | } | |||
| 144 | + | file, err := parser.ParseFile(fset, name, nil, parser.ParseComments) | |||
| 145 | + | require.NoError(t, err) | |||
| 146 | + | if file.Name != nil && file.Name.Name == "cef" { | |||
| 147 | + | files = append(files, file) | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | require.NotEmpty(t, files) | |||
| 151 | + | return files | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func packageDeclaresIdentifier(files []*ast.File, name string) bool { | |||
| 155 | + | for _, file := range files { | |||
| 156 | + | found := false | |||
| 157 | + | ast.Inspect(file, func(node ast.Node) bool { | |||
| 158 | + | ident, ok := node.(*ast.Ident) | |||
| 159 | + | if ok && ident.Name == name { | |||
| 160 | + | found = true | |||
| 161 | + | return false | |||
| 162 | + | } | |||
| 163 | + | return true | |||
| 164 | + | }) | |||
| 165 | + | if found { | |||
| 166 | + | return true | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | return false | |||
| 170 | + | } | |||
| 171 | + | ||||
| 124 | func TestRendererBridgeProcessHandler_OnFocusedNodeChanged_ReportsEditableState(t *testing.T) { | 172 | func TestRendererBridgeProcessHandler_OnFocusedNodeChanged_ReportsEditableState(t *testing.T) { | ||
| 125 | oldFactory := newRendererBridgeProcessMessage | 173 | oldFactory := newRendererBridgeProcessMessage | ||
| 126 | t.Cleanup(func() { newRendererBridgeProcessMessage = oldFactory }) | 174 | t.Cleanup(func() { newRendererBridgeProcessMessage = oldFactory }) | ||
M internal/infrastructure/cef/scheme_handler.go +3 -1
| @@ -42,6 +42,8 @@ var pageRootFiles = map[string]string{ | |||
| 42 | 42 | errorPath: "error.html", | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | + | var cefNewResourceHandler = purecef.NewResourceHandler | |
| 46 | + | ||
| 45 | 47 | // dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual | |
| 46 | 48 | // internal https://dumber.invalid origin used by CEF. | |
| 47 | 49 | type dumbSchemeHandler struct { | |
| @@ -419,7 +421,7 @@ type staticResourceHandler struct { | |||
| 419 | 421 | } | |
| 420 | 422 | ||
| 421 | 423 | func (h *dumbSchemeHandler) newRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler { | |
| 422 | - | return purecef.NewResourceHandler(&staticResourceHandler{ | |
| 424 | + | return cefNewResourceHandler(&staticResourceHandler{ | |
| 423 | 425 | data: data, | |
| 424 | 426 | contentType: contentType, | |
| 425 | 427 | statusCode: status, | |
| @@ -42,6 +42,8 @@ var pageRootFiles = map[string]string{ | |||||
| 42 | errorPath: "error.html", | 42 | errorPath: "error.html", | ||
| 43 | } | 43 | } | ||
| 44 | 44 | ||||
| 45 | + | var cefNewResourceHandler = purecef.NewResourceHandler | |||
| 46 | + | ||||
| 45 | // dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual | 47 | // dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual | ||
| 46 | // internal https://dumber.invalid origin used by CEF. | 48 | // internal https://dumber.invalid origin used by CEF. | ||
| 47 | type dumbSchemeHandler struct { | 49 | type dumbSchemeHandler struct { | ||
| @@ -419,7 +421,7 @@ type staticResourceHandler struct { | |||||
| 419 | } | 421 | } | ||
| 420 | 422 | ||||
| 421 | func (h *dumbSchemeHandler) newRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler { | 423 | func (h *dumbSchemeHandler) newRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler { | ||
| 422 | - | return purecef.NewResourceHandler(&staticResourceHandler{ | 424 | + | return cefNewResourceHandler(&staticResourceHandler{ |
| 423 | data: data, | 425 | data: data, | ||
| 424 | contentType: contentType, | 426 | contentType: contentType, | ||
| 425 | statusCode: status, | 427 | statusCode: status, | ||
M internal/infrastructure/cef/scheme_handler_test.go +21 -6
| @@ -4,9 +4,11 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/base64" | |
| 6 | 6 | "errors" | |
| 7 | - | "os" | |
| 7 | + | "net/http" | |
| 8 | 8 | "testing" | |
| 9 | + | "unsafe" | |
| 9 | 10 | ||
| 11 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 10 | 12 | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |
| 11 | 13 | "github.com/stretchr/testify/require" | |
| 12 | 14 | ) | |
| @@ -57,10 +59,23 @@ func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) { | |||
| 57 | 59 | require.JSONEq(t, `{"text":"copied from js"}`, string(body)) | |
| 58 | 60 | } | |
| 59 | 61 | ||
| 60 | - | func TestSchemeHandlerSourceDoesNotKeepClipboardSetPath(t *testing.T) { | |
| 61 | - | src, err := os.ReadFile("scheme_handler.go") | |
| 62 | + | func TestSchemeHandler_APIClipboardSetPathReturnsNotFound(t *testing.T) { | |
| 63 | + | oldNewResourceHandler := cefNewResourceHandler | |
| 64 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 65 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 66 | + | ||
| 67 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 62 | 68 | require.NoError(t, err) | |
| 63 | - | require.NotContains(t, string(src), "/api/clipboard-set") | |
| 64 | - | require.NotContains(t, string(src), "onClipboardSet") | |
| 65 | - | require.NotContains(t, string(src), "decodeClipboardSetBody") | |
| 69 | + | ||
| 70 | + | response := cefmocks.NewMockResponse(t) | |
| 71 | + | response.EXPECT().SetStatus(int32(http.StatusNotFound)).Once() | |
| 72 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusNotFound)).Once() | |
| 73 | + | response.EXPECT().SetMimeType("application/json").Once() | |
| 74 | + | ||
| 75 | + | handler := h.handleAPI(nil, http.MethodPost, "/api/clipboard-set", nil) | |
| 76 | + | require.NotNil(t, handler) | |
| 77 | + | ||
| 78 | + | var responseLength int64 | |
| 79 | + | handler.GetResponseHeaders(response, unsafe.Pointer(&responseLength), 0) | |
| 80 | + | require.Positive(t, responseLength) | |
| 66 | 81 | } | |
| @@ -4,9 +4,11 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/base64" | 5 | "encoding/base64" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | - | "os" | 7 | + | "net/http" |
| 8 | "testing" | 8 | "testing" | ||
| 9 | + | "unsafe" | |||
| 9 | 10 | ||||
| 11 | + | purecef "github.com/bnema/purego-cef/cef" | |||
| 10 | cefmocks "github.com/bnema/purego-cef/cef/mocks" | 12 | cefmocks "github.com/bnema/purego-cef/cef/mocks" | ||
| 11 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| 12 | ) | 14 | ) | ||
| @@ -57,10 +59,23 @@ func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) { | |||||
| 57 | require.JSONEq(t, `{"text":"copied from js"}`, string(body)) | 59 | require.JSONEq(t, `{"text":"copied from js"}`, string(body)) | ||
| 58 | } | 60 | } | ||
| 59 | 61 | ||||
| 60 | - | func TestSchemeHandlerSourceDoesNotKeepClipboardSetPath(t *testing.T) { | 62 | + | func TestSchemeHandler_APIClipboardSetPathReturnsNotFound(t *testing.T) { |
| 61 | - | src, err := os.ReadFile("scheme_handler.go") | 63 | + | oldNewResourceHandler := cefNewResourceHandler |
| 64 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |||
| 65 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |||
| 66 | + | ||||
| 67 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |||
| 62 | require.NoError(t, err) | 68 | require.NoError(t, err) | ||
| 63 | - | require.NotContains(t, string(src), "/api/clipboard-set") | 69 | + | |
| 64 | - | require.NotContains(t, string(src), "onClipboardSet") | 70 | + | response := cefmocks.NewMockResponse(t) |
| 65 | - | require.NotContains(t, string(src), "decodeClipboardSetBody") | 71 | + | response.EXPECT().SetStatus(int32(http.StatusNotFound)).Once() |
| 72 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusNotFound)).Once() | |||
| 73 | + | response.EXPECT().SetMimeType("application/json").Once() | |||
| 74 | + | ||||
| 75 | + | handler := h.handleAPI(nil, http.MethodPost, "/api/clipboard-set", nil) | |||
| 76 | + | require.NotNil(t, handler) | |||
| 77 | + | ||||
| 78 | + | var responseLength int64 | |||
| 79 | + | handler.GetResponseHeaders(response, unsafe.Pointer(&responseLength), 0) | |||
| 80 | + | require.Positive(t, responseLength) | |||
| 66 | } | 81 | } | ||
M internal/infrastructure/cef/session_spawn_environment.go +10 -3
| @@ -5,18 +5,25 @@ import ( | |||
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/dumber/internal/application/port" | |
| 7 | 7 | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 10 | 11 | // SessionSpawnEnvironment exposes the engine-specific launch environment needed | |
| 11 | 12 | // when restoring a session into a freshly spawned browser process. | |
| 12 | - | type SessionSpawnEnvironment struct{} | |
| 13 | + | type SessionSpawnEnvironment struct { | |
| 14 | + | profile runtimeprofile.Profile | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func NewSessionSpawnEnvironment(profile runtimeprofile.Profile) SessionSpawnEnvironment { | |
| 18 | + | return SessionSpawnEnvironment{profile: profile} | |
| 19 | + | } | |
| 13 | 20 | ||
| 14 | 21 | func (SessionSpawnEnvironment) RootCacheEnvVar() string { | |
| 15 | 22 | return CEFRootCachePathEnvVar | |
| 16 | 23 | } | |
| 17 | 24 | ||
| 18 | - | func (SessionSpawnEnvironment) SessionRootCachePath(sessionID entity.SessionID) string { | |
| 19 | - | return filepath.Join(DefaultCEFUserDataDir(), "sessions", string(sessionID)) | |
| 25 | + | func (e SessionSpawnEnvironment) SessionRootCachePath(sessionID entity.SessionID) string { | |
| 26 | + | return filepath.Join(e.profile.CEFUserDataDir(), "sessions", string(sessionID)) | |
| 20 | 27 | } | |
| 21 | 28 | ||
| 22 | 29 | var _ port.SessionSpawnEnvironment = SessionSpawnEnvironment{} | |
| @@ -5,18 +5,25 @@ import ( | |||||
| 5 | 5 | ||||
| 6 | "github.com/bnema/dumber/internal/application/port" | 6 | "github.com/bnema/dumber/internal/application/port" | ||
| 7 | "github.com/bnema/dumber/internal/domain/entity" | 7 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 8 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 8 | ) | 9 | ) | ||
| 9 | 10 | ||||
| 10 | // SessionSpawnEnvironment exposes the engine-specific launch environment needed | 11 | // SessionSpawnEnvironment exposes the engine-specific launch environment needed | ||
| 11 | // when restoring a session into a freshly spawned browser process. | 12 | // when restoring a session into a freshly spawned browser process. | ||
| 12 | - | type SessionSpawnEnvironment struct{} | 13 | + | type SessionSpawnEnvironment struct { |
| 14 | + | profile runtimeprofile.Profile | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func NewSessionSpawnEnvironment(profile runtimeprofile.Profile) SessionSpawnEnvironment { | |||
| 18 | + | return SessionSpawnEnvironment{profile: profile} | |||
| 19 | + | } | |||
| 13 | 20 | ||||
| 14 | func (SessionSpawnEnvironment) RootCacheEnvVar() string { | 21 | func (SessionSpawnEnvironment) RootCacheEnvVar() string { | ||
| 15 | return CEFRootCachePathEnvVar | 22 | return CEFRootCachePathEnvVar | ||
| 16 | } | 23 | } | ||
| 17 | 24 | ||||
| 18 | - | func (SessionSpawnEnvironment) SessionRootCachePath(sessionID entity.SessionID) string { | 25 | + | func (e SessionSpawnEnvironment) SessionRootCachePath(sessionID entity.SessionID) string { |
| 19 | - | return filepath.Join(DefaultCEFUserDataDir(), "sessions", string(sessionID)) | 26 | + | return filepath.Join(e.profile.CEFUserDataDir(), "sessions", string(sessionID)) |
| 20 | } | 27 | } | ||
| 21 | 28 | ||||
| 22 | var _ port.SessionSpawnEnvironment = SessionSpawnEnvironment{} | 29 | var _ port.SessionSpawnEnvironment = SessionSpawnEnvironment{} | ||
M internal/infrastructure/cef/webview.go +19 -8
| @@ -51,6 +51,7 @@ var ( | |||
| 51 | 51 | cefNewTask = purecef.NewTask | |
| 52 | 52 | cefPostTask = purecef.PostTask | |
| 53 | 53 | cefPostDelayedTask = purecef.PostDelayedTask | |
| 54 | + | cefScheduleAfter = func(delay time.Duration, fn func()) { time.AfterFunc(delay, fn) } | |
| 54 | 55 | ) | |
| 55 | 56 | ||
| 56 | 57 | // WebView implements port.WebView using a CEF off-screen browser rendered | |
| @@ -827,12 +828,25 @@ func (wv *WebView) schedulePendingNavigationReplay(browser purecef.Browser, atte | |||
| 827 | 828 | } else { | |
| 828 | 829 | result = cefPostDelayedTask(purecef.ThreadIDTidUi, task, int64(pendingNavigationRetryDelay/time.Millisecond)) | |
| 829 | 830 | } | |
| 830 | - | if result != 1 && wv.ctx != nil { | |
| 831 | - | logging.FromContext(wv.ctx).Warn(). | |
| 831 | + | if result == 1 { | |
| 832 | + | return | |
| 833 | + | } | |
| 834 | + | if wv.ctx != nil { | |
| 835 | + | log := logging.FromContext(wv.ctx).Warn(). | |
| 832 | 836 | Int("attempt", attempt). | |
| 833 | - | Int32("result", result). | |
| 834 | - | Msg("cef: failed to schedule pending navigation replay") | |
| 837 | + | Int32("result", result) | |
| 838 | + | if attempt >= pendingNavigationMaxRetries { | |
| 839 | + | log.Msg("cef: failed to schedule pending navigation replay; retries exhausted") | |
| 840 | + | } else { | |
| 841 | + | log.Msg("cef: failed to schedule pending navigation replay; retrying") | |
| 842 | + | } | |
| 835 | 843 | } | |
| 844 | + | if attempt >= pendingNavigationMaxRetries { | |
| 845 | + | return | |
| 846 | + | } | |
| 847 | + | cefScheduleAfter(pendingNavigationRetryDelay, func() { | |
| 848 | + | wv.schedulePendingNavigationReplay(browser, attempt+1) | |
| 849 | + | }) | |
| 836 | 850 | } | |
| 837 | 851 | ||
| 838 | 852 | func (wv *WebView) replayPendingNavigation(browser purecef.Browser, attempt int) { | |
| @@ -865,10 +879,7 @@ func (wv *WebView) replayPendingNavigation(browser purecef.Browser, attempt int) | |||
| 865 | 879 | wv.schedulePendingNavigationReplay(browser, attempt+1) | |
| 866 | 880 | return | |
| 867 | 881 | } | |
| 868 | - | currentURL := "" | |
| 869 | - | if frame != nil { | |
| 870 | - | currentURL = frame.GetURL() | |
| 871 | - | } | |
| 882 | + | currentURL := frame.GetURL() | |
| 872 | 883 | if pendingURIEquivalent(currentURL, uri) { | |
| 873 | 884 | wv.mu.Lock() | |
| 874 | 885 | if pendingURIEquivalent(wv.pendingURI, uri) { | |
| @@ -51,6 +51,7 @@ var ( | |||||
| 51 | cefNewTask = purecef.NewTask | 51 | cefNewTask = purecef.NewTask | ||
| 52 | cefPostTask = purecef.PostTask | 52 | cefPostTask = purecef.PostTask | ||
| 53 | cefPostDelayedTask = purecef.PostDelayedTask | 53 | cefPostDelayedTask = purecef.PostDelayedTask | ||
| 54 | + | cefScheduleAfter = func(delay time.Duration, fn func()) { time.AfterFunc(delay, fn) } | |||
| 54 | ) | 55 | ) | ||
| 55 | 56 | ||||
| 56 | // WebView implements port.WebView using a CEF off-screen browser rendered | 57 | // WebView implements port.WebView using a CEF off-screen browser rendered | ||
| @@ -827,12 +828,25 @@ func (wv *WebView) schedulePendingNavigationReplay(browser purecef.Browser, atte | |||||
| 827 | } else { | 828 | } else { | ||
| 828 | result = cefPostDelayedTask(purecef.ThreadIDTidUi, task, int64(pendingNavigationRetryDelay/time.Millisecond)) | 829 | result = cefPostDelayedTask(purecef.ThreadIDTidUi, task, int64(pendingNavigationRetryDelay/time.Millisecond)) | ||
| 829 | } | 830 | } | ||
| 830 | - | if result != 1 && wv.ctx != nil { | 831 | + | if result == 1 { |
| 831 | - | logging.FromContext(wv.ctx).Warn(). | 832 | + | return |
| 833 | + | } | |||
| 834 | + | if wv.ctx != nil { | |||
| 835 | + | log := logging.FromContext(wv.ctx).Warn(). | |||
| 832 | Int("attempt", attempt). | 836 | Int("attempt", attempt). | ||
| 833 | - | Int32("result", result). | 837 | + | Int32("result", result) |
| 834 | - | Msg("cef: failed to schedule pending navigation replay") | 838 | + | if attempt >= pendingNavigationMaxRetries { |
| 839 | + | log.Msg("cef: failed to schedule pending navigation replay; retries exhausted") | |||
| 840 | + | } else { | |||
| 841 | + | log.Msg("cef: failed to schedule pending navigation replay; retrying") | |||
| 842 | + | } | |||
| 835 | } | 843 | } | ||
| 844 | + | if attempt >= pendingNavigationMaxRetries { | |||
| 845 | + | return | |||
| 846 | + | } | |||
| 847 | + | cefScheduleAfter(pendingNavigationRetryDelay, func() { | |||
| 848 | + | wv.schedulePendingNavigationReplay(browser, attempt+1) | |||
| 849 | + | }) | |||
| 836 | } | 850 | } | ||
| 837 | 851 | ||||
| 838 | func (wv *WebView) replayPendingNavigation(browser purecef.Browser, attempt int) { | 852 | func (wv *WebView) replayPendingNavigation(browser purecef.Browser, attempt int) { | ||
| @@ -865,10 +879,7 @@ func (wv *WebView) replayPendingNavigation(browser purecef.Browser, attempt int) | |||||
| 865 | wv.schedulePendingNavigationReplay(browser, attempt+1) | 879 | wv.schedulePendingNavigationReplay(browser, attempt+1) | ||
| 866 | return | 880 | return | ||
| 867 | } | 881 | } | ||
| 868 | - | currentURL := "" | 882 | + | currentURL := frame.GetURL() |
| 869 | - | if frame != nil { | |||
| 870 | - | currentURL = frame.GetURL() | |||
| 871 | - | } | |||
| 872 | if pendingURIEquivalent(currentURL, uri) { | 883 | if pendingURIEquivalent(currentURL, uri) { | ||
| 873 | wv.mu.Lock() | 884 | wv.mu.Lock() | ||
| 874 | if pendingURIEquivalent(wv.pendingURI, uri) { | 885 | if pendingURIEquivalent(wv.pendingURI, uri) { | ||
M internal/infrastructure/cef/webview_handlers.go +6 -2
| @@ -700,6 +700,7 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) { | |||
| 700 | 700 | } | |
| 701 | 701 | host := browser.GetHost() | |
| 702 | 702 | ||
| 703 | + | shouldSyncFocus := false | |
| 703 | 704 | h.wv.mu.Lock() | |
| 704 | 705 | h.wv.browser = browser | |
| 705 | 706 | h.wv.host = host | |
| @@ -714,11 +715,14 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) { | |||
| 714 | 715 | ||
| 715 | 716 | // If GTK focus entered before the browser existed, reassert browser focus | |
| 716 | 717 | // now. Otherwise CEF may stay internally unfocused and suppress caret paint. | |
| 717 | - | if h.wv.input != nil && h.wv.input.hasGTKFocus() { | |
| 718 | - | syncWindowlessBrowserFocus(host) | |
| 718 | + | if h.wv.input != nil { | |
| 719 | + | shouldSyncFocus = h.wv.input.hasGTKFocus() | |
| 719 | 720 | } | |
| 720 | 721 | ||
| 721 | 722 | h.wv.mu.Unlock() | |
| 723 | + | if shouldSyncFocus { | |
| 724 | + | syncWindowlessBrowserFocus(host) | |
| 725 | + | } | |
| 722 | 726 | ||
| 723 | 727 | h.wv.scheduleStartBeginFrameLoop() | |
| 724 | 728 | } | |
| @@ -700,6 +700,7 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) { | |||||
| 700 | } | 700 | } | ||
| 701 | host := browser.GetHost() | 701 | host := browser.GetHost() | ||
| 702 | 702 | ||||
| 703 | + | shouldSyncFocus := false | |||
| 703 | h.wv.mu.Lock() | 704 | h.wv.mu.Lock() | ||
| 704 | h.wv.browser = browser | 705 | h.wv.browser = browser | ||
| 705 | h.wv.host = host | 706 | h.wv.host = host | ||
| @@ -714,11 +715,14 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) { | |||||
| 714 | 715 | ||||
| 715 | // If GTK focus entered before the browser existed, reassert browser focus | 716 | // If GTK focus entered before the browser existed, reassert browser focus | ||
| 716 | // now. Otherwise CEF may stay internally unfocused and suppress caret paint. | 717 | // now. Otherwise CEF may stay internally unfocused and suppress caret paint. | ||
| 717 | - | if h.wv.input != nil && h.wv.input.hasGTKFocus() { | 718 | + | if h.wv.input != nil { |
| 718 | - | syncWindowlessBrowserFocus(host) | 719 | + | shouldSyncFocus = h.wv.input.hasGTKFocus() |
| 719 | } | 720 | } | ||
| 720 | 721 | ||||
| 721 | h.wv.mu.Unlock() | 722 | h.wv.mu.Unlock() | ||
| 723 | + | if shouldSyncFocus { | |||
| 724 | + | syncWindowlessBrowserFocus(host) | |||
| 725 | + | } | |||
| 722 | 726 | ||||
| 723 | h.wv.scheduleStartBeginFrameLoop() | 727 | h.wv.scheduleStartBeginFrameLoop() | ||
| 724 | } | 728 | } | ||
M internal/infrastructure/cef/webview_handlers_test.go +60 -52
| @@ -13,26 +13,13 @@ import ( | |||
| 13 | 13 | "github.com/stretchr/testify/require" | |
| 14 | 14 | ||
| 15 | 15 | "github.com/bnema/dumber/internal/application/port" | |
| 16 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 16 | 17 | ) | |
| 17 | 18 | ||
| 18 | - | type recordingClipboardOrchestrator struct { | |
| 19 | + | type clipboardOrchestratorRecorder struct { | |
| 19 | 20 | mu sync.Mutex | |
| 20 | 21 | selection port.SelectionClipboardInput | |
| 21 | 22 | selectionCalls int | |
| 22 | - | explicit port.ExplicitClipboardInput | |
| 23 | - | } | |
| 24 | - | ||
| 25 | - | func (r *recordingClipboardOrchestrator) HandleSelectionUpdate(_ context.Context, input port.SelectionClipboardInput) error { | |
| 26 | - | r.mu.Lock() | |
| 27 | - | r.selection = input | |
| 28 | - | r.selectionCalls++ | |
| 29 | - | r.mu.Unlock() | |
| 30 | - | return nil | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | func (r *recordingClipboardOrchestrator) HandleExplicitCopy(_ context.Context, input port.ExplicitClipboardInput) error { | |
| 34 | - | r.explicit = input | |
| 35 | - | return nil | |
| 36 | 23 | } | |
| 37 | 24 | ||
| 38 | 25 | func newTestPipeline(w, h, s int32) *renderPipeline { | |
| @@ -114,7 +101,14 @@ func TestOnLoadEndDoesNotDispatchBrowserLevelCompletion(t *testing.T) { | |||
| 114 | 101 | } | |
| 115 | 102 | ||
| 116 | 103 | func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *testing.T) { | |
| 117 | - | orchestrator := &recordingClipboardOrchestrator{} | |
| 104 | + | recorder := &clipboardOrchestratorRecorder{} | |
| 105 | + | orchestrator := portmocks.NewMockClipboardTextOrchestrator(t) | |
| 106 | + | orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input port.SelectionClipboardInput) { | |
| 107 | + | recorder.mu.Lock() | |
| 108 | + | defer recorder.mu.Unlock() | |
| 109 | + | recorder.selection = input | |
| 110 | + | recorder.selectionCalls++ | |
| 111 | + | }).Return(nil).Once() | |
| 118 | 112 | wv := &WebView{ | |
| 119 | 113 | ctx: context.Background(), | |
| 120 | 114 | id: 42, | |
| @@ -127,17 +121,24 @@ func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *test | |||
| 127 | 121 | h.OnTextSelectionChanged(nil, "selected text", nil) | |
| 128 | 122 | ||
| 129 | 123 | require.Eventually(t, func() bool { | |
| 130 | - | orchestrator.mu.Lock() | |
| 131 | - | defer orchestrator.mu.Unlock() | |
| 132 | - | return orchestrator.selectionCalls == 1 && | |
| 133 | - | orchestrator.selection.Text == "selected text" && | |
| 134 | - | orchestrator.selection.SourceEngine == port.ClipboardSourceCEF && | |
| 135 | - | orchestrator.selection.ViewID == port.WebViewID(42) | |
| 124 | + | recorder.mu.Lock() | |
| 125 | + | defer recorder.mu.Unlock() | |
| 126 | + | return recorder.selectionCalls == 1 && | |
| 127 | + | recorder.selection.Text == "selected text" && | |
| 128 | + | recorder.selection.SourceEngine == port.ClipboardSourceCEF && | |
| 129 | + | recorder.selection.ViewID == port.WebViewID(42) | |
| 136 | 130 | }, 2*time.Second, 10*time.Millisecond) | |
| 137 | 131 | } | |
| 138 | 132 | ||
| 139 | 133 | func TestOnTextSelectionChanged_DebouncesAndCollapsesRapidUpdates(t *testing.T) { | |
| 140 | - | orchestrator := &recordingClipboardOrchestrator{} | |
| 134 | + | recorder := &clipboardOrchestratorRecorder{} | |
| 135 | + | orchestrator := portmocks.NewMockClipboardTextOrchestrator(t) | |
| 136 | + | orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input port.SelectionClipboardInput) { | |
| 137 | + | recorder.mu.Lock() | |
| 138 | + | defer recorder.mu.Unlock() | |
| 139 | + | recorder.selection = input | |
| 140 | + | recorder.selectionCalls++ | |
| 141 | + | }).Return(nil).Once() | |
| 141 | 142 | wv := &WebView{ | |
| 142 | 143 | ctx: context.Background(), | |
| 143 | 144 | id: 42, | |
| @@ -150,21 +151,28 @@ func TestOnTextSelectionChanged_DebouncesAndCollapsesRapidUpdates(t *testing.T) | |||
| 150 | 151 | h.OnTextSelectionChanged(nil, "first selection", nil) | |
| 151 | 152 | h.OnTextSelectionChanged(nil, "second selection", nil) | |
| 152 | 153 | ||
| 153 | - | orchestrator.mu.Lock() | |
| 154 | - | gotCalls := orchestrator.selectionCalls | |
| 155 | - | orchestrator.mu.Unlock() | |
| 154 | + | recorder.mu.Lock() | |
| 155 | + | gotCalls := recorder.selectionCalls | |
| 156 | + | recorder.mu.Unlock() | |
| 156 | 157 | require.Zero(t, gotCalls) | |
| 157 | 158 | ||
| 158 | 159 | require.Eventually(t, func() bool { | |
| 159 | - | orchestrator.mu.Lock() | |
| 160 | - | defer orchestrator.mu.Unlock() | |
| 161 | - | return orchestrator.selectionCalls == 1 && orchestrator.selection.Text == "second selection" | |
| 160 | + | recorder.mu.Lock() | |
| 161 | + | defer recorder.mu.Unlock() | |
| 162 | + | return recorder.selectionCalls == 1 && recorder.selection.Text == "second selection" | |
| 162 | 163 | }, 2*time.Second, 10*time.Millisecond) | |
| 163 | 164 | require.Equal(t, "second selection", wv.selectedTextSnapshot()) | |
| 164 | 165 | } | |
| 165 | 166 | ||
| 166 | 167 | func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResumesWhenCleared(t *testing.T) { | |
| 167 | - | orchestrator := &recordingClipboardOrchestrator{} | |
| 168 | + | recorder := &clipboardOrchestratorRecorder{} | |
| 169 | + | orchestrator := portmocks.NewMockClipboardTextOrchestrator(t) | |
| 170 | + | orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input port.SelectionClipboardInput) { | |
| 171 | + | recorder.mu.Lock() | |
| 172 | + | defer recorder.mu.Unlock() | |
| 173 | + | recorder.selection = input | |
| 174 | + | recorder.selectionCalls++ | |
| 175 | + | }).Return(nil).Once() | |
| 168 | 176 | wv := &WebView{ | |
| 169 | 177 | ctx: context.Background(), | |
| 170 | 178 | id: 42, | |
| @@ -192,15 +200,16 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu | |||
| 192 | 200 | h.OnTextSelectionChanged(nil, "editable selection", nil) | |
| 193 | 201 | require.Equal(t, "editable selection", wv.selectedTextSnapshot()) | |
| 194 | 202 | ||
| 195 | - | orchestrator.mu.Lock() | |
| 196 | - | gotCalls := orchestrator.selectionCalls | |
| 197 | - | orchestrator.mu.Unlock() | |
| 198 | - | require.Zero(t, gotCalls) | |
| 199 | - | time.Sleep(350 * time.Millisecond) | |
| 200 | - | orchestrator.mu.Lock() | |
| 201 | - | gotCalls = orchestrator.selectionCalls | |
| 202 | - | orchestrator.mu.Unlock() | |
| 203 | + | recorder.mu.Lock() | |
| 204 | + | gotCalls := recorder.selectionCalls | |
| 205 | + | recorder.mu.Unlock() | |
| 203 | 206 | require.Zero(t, gotCalls) | |
| 207 | + | start := time.Now() | |
| 208 | + | require.Eventually(t, func() bool { | |
| 209 | + | recorder.mu.Lock() | |
| 210 | + | defer recorder.mu.Unlock() | |
| 211 | + | return recorder.selectionCalls == 0 && time.Since(start) >= clipboardSelectionDebounceDelay+50*time.Millisecond | |
| 212 | + | }, 2*time.Second, 10*time.Millisecond) | |
| 204 | 213 | ||
| 205 | 214 | newRendererBridgeProcessMessage = func(name string) purecef.ProcessMessage { | |
| 206 | 215 | return newTestBridgeProcessMessage(name, false) | |
| @@ -216,16 +225,19 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu | |||
| 216 | 225 | ||
| 217 | 226 | h.OnTextSelectionChanged(nil, "free selection", nil) | |
| 218 | 227 | require.Equal(t, "free selection", wv.selectedTextSnapshot()) | |
| 219 | - | require.Zero(t, orchestrator.selectionCalls) | |
| 228 | + | recorder.mu.Lock() | |
| 229 | + | gotCalls = recorder.selectionCalls | |
| 230 | + | recorder.mu.Unlock() | |
| 231 | + | require.Zero(t, gotCalls) | |
| 220 | 232 | require.Eventually(t, func() bool { | |
| 221 | - | orchestrator.mu.Lock() | |
| 222 | - | defer orchestrator.mu.Unlock() | |
| 223 | - | return orchestrator.selectionCalls == 1 && orchestrator.selection.Text == "free selection" | |
| 233 | + | recorder.mu.Lock() | |
| 234 | + | defer recorder.mu.Unlock() | |
| 235 | + | return recorder.selectionCalls == 1 && recorder.selection.Text == "free selection" | |
| 224 | 236 | }, 2*time.Second, 10*time.Millisecond) | |
| 225 | 237 | } | |
| 226 | 238 | ||
| 227 | 239 | func TestOnTextSelectionChanged_DoesNotEmitLateDebouncedUpdateAfterDestroy(t *testing.T) { | |
| 228 | - | orchestrator := &recordingClipboardOrchestrator{} | |
| 240 | + | orchestrator := portmocks.NewMockClipboardTextOrchestrator(t) | |
| 229 | 241 | wv := &WebView{ | |
| 230 | 242 | ctx: context.Background(), | |
| 231 | 243 | id: 42, | |
| @@ -238,15 +250,11 @@ func TestOnTextSelectionChanged_DoesNotEmitLateDebouncedUpdateAfterDestroy(t *te | |||
| 238 | 250 | h.OnTextSelectionChanged(nil, "selected text", nil) | |
| 239 | 251 | wv.Destroy() | |
| 240 | 252 | ||
| 241 | - | orchestrator.mu.Lock() | |
| 242 | - | gotCalls := orchestrator.selectionCalls | |
| 243 | - | orchestrator.mu.Unlock() | |
| 244 | - | require.Zero(t, gotCalls) | |
| 245 | - | time.Sleep(350 * time.Millisecond) | |
| 246 | - | orchestrator.mu.Lock() | |
| 247 | - | gotCalls = orchestrator.selectionCalls | |
| 248 | - | orchestrator.mu.Unlock() | |
| 249 | - | require.Zero(t, gotCalls) | |
| 253 | + | start := time.Now() | |
| 254 | + | require.Eventually(t, func() bool { | |
| 255 | + | return time.Since(start) >= clipboardSelectionDebounceDelay+50*time.Millisecond | |
| 256 | + | }, 2*time.Second, 10*time.Millisecond) | |
| 257 | + | orchestrator.AssertNotCalled(t, "HandleSelectionUpdate", mock.Anything, mock.Anything) | |
| 250 | 258 | require.Equal(t, "selected text", wv.selectedTextSnapshot()) | |
| 251 | 259 | } | |
| 252 | 260 | ||
| @@ -13,26 +13,13 @@ import ( | |||||
| 13 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| 14 | 14 | ||||
| 15 | "github.com/bnema/dumber/internal/application/port" | 15 | "github.com/bnema/dumber/internal/application/port" | ||
| 16 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 16 | ) | 17 | ) | ||
| 17 | 18 | ||||
| 18 | - | type recordingClipboardOrchestrator struct { | 19 | + | type clipboardOrchestratorRecorder struct { |
| 19 | mu sync.Mutex | 20 | mu sync.Mutex | ||
| 20 | selection port.SelectionClipboardInput | 21 | selection port.SelectionClipboardInput | ||
| 21 | selectionCalls int | 22 | selectionCalls int | ||
| 22 | - | explicit port.ExplicitClipboardInput | |||
| 23 | - | } | |||
| 24 | - | ||||
| 25 | - | func (r *recordingClipboardOrchestrator) HandleSelectionUpdate(_ context.Context, input port.SelectionClipboardInput) error { | |||
| 26 | - | r.mu.Lock() | |||
| 27 | - | r.selection = input | |||
| 28 | - | r.selectionCalls++ | |||
| 29 | - | r.mu.Unlock() | |||
| 30 | - | return nil | |||
| 31 | - | } | |||
| 32 | - | ||||
| 33 | - | func (r *recordingClipboardOrchestrator) HandleExplicitCopy(_ context.Context, input port.ExplicitClipboardInput) error { | |||
| 34 | - | r.explicit = input | |||
| 35 | - | return nil | |||
| 36 | } | 23 | } | ||
| 37 | 24 | ||||
| 38 | func newTestPipeline(w, h, s int32) *renderPipeline { | 25 | func newTestPipeline(w, h, s int32) *renderPipeline { | ||
| @@ -114,7 +101,14 @@ func TestOnLoadEndDoesNotDispatchBrowserLevelCompletion(t *testing.T) { | |||||
| 114 | } | 101 | } | ||
| 115 | 102 | ||||
| 116 | func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *testing.T) { | 103 | func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *testing.T) { | ||
| 117 | - | orchestrator := &recordingClipboardOrchestrator{} | 104 | + | recorder := &clipboardOrchestratorRecorder{} |
| 105 | + | orchestrator := portmocks.NewMockClipboardTextOrchestrator(t) | |||
| 106 | + | orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input port.SelectionClipboardInput) { | |||
| 107 | + | recorder.mu.Lock() | |||
| 108 | + | defer recorder.mu.Unlock() | |||
| 109 | + | recorder.selection = input | |||
| 110 | + | recorder.selectionCalls++ | |||
| 111 | + | }).Return(nil).Once() | |||
| 118 | wv := &WebView{ | 112 | wv := &WebView{ | ||
| 119 | ctx: context.Background(), | 113 | ctx: context.Background(), | ||
| 120 | id: 42, | 114 | id: 42, | ||
| @@ -127,17 +121,24 @@ func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *test | |||||
| 127 | h.OnTextSelectionChanged(nil, "selected text", nil) | 121 | h.OnTextSelectionChanged(nil, "selected text", nil) | ||
| 128 | 122 | ||||
| 129 | require.Eventually(t, func() bool { | 123 | require.Eventually(t, func() bool { | ||
| 130 | - | orchestrator.mu.Lock() | 124 | + | recorder.mu.Lock() |
| 131 | - | defer orchestrator.mu.Unlock() | 125 | + | defer recorder.mu.Unlock() |
| 132 | - | return orchestrator.selectionCalls == 1 && | 126 | + | return recorder.selectionCalls == 1 && |
| 133 | - | orchestrator.selection.Text == "selected text" && | 127 | + | recorder.selection.Text == "selected text" && |
| 134 | - | orchestrator.selection.SourceEngine == port.ClipboardSourceCEF && | 128 | + | recorder.selection.SourceEngine == port.ClipboardSourceCEF && |
| 135 | - | orchestrator.selection.ViewID == port.WebViewID(42) | 129 | + | recorder.selection.ViewID == port.WebViewID(42) |
| 136 | }, 2*time.Second, 10*time.Millisecond) | 130 | }, 2*time.Second, 10*time.Millisecond) | ||
| 137 | } | 131 | } | ||
| 138 | 132 | ||||
| 139 | func TestOnTextSelectionChanged_DebouncesAndCollapsesRapidUpdates(t *testing.T) { | 133 | func TestOnTextSelectionChanged_DebouncesAndCollapsesRapidUpdates(t *testing.T) { | ||
| 140 | - | orchestrator := &recordingClipboardOrchestrator{} | 134 | + | recorder := &clipboardOrchestratorRecorder{} |
| 135 | + | orchestrator := portmocks.NewMockClipboardTextOrchestrator(t) | |||
| 136 | + | orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input port.SelectionClipboardInput) { | |||
| 137 | + | recorder.mu.Lock() | |||
| 138 | + | defer recorder.mu.Unlock() | |||
| 139 | + | recorder.selection = input | |||
| 140 | + | recorder.selectionCalls++ | |||
| 141 | + | }).Return(nil).Once() | |||
| 141 | wv := &WebView{ | 142 | wv := &WebView{ | ||
| 142 | ctx: context.Background(), | 143 | ctx: context.Background(), | ||
| 143 | id: 42, | 144 | id: 42, | ||
| @@ -150,21 +151,28 @@ func TestOnTextSelectionChanged_DebouncesAndCollapsesRapidUpdates(t *testing.T) | |||||
| 150 | h.OnTextSelectionChanged(nil, "first selection", nil) | 151 | h.OnTextSelectionChanged(nil, "first selection", nil) | ||
| 151 | h.OnTextSelectionChanged(nil, "second selection", nil) | 152 | h.OnTextSelectionChanged(nil, "second selection", nil) | ||
| 152 | 153 | ||||
| 153 | - | orchestrator.mu.Lock() | 154 | + | recorder.mu.Lock() |
| 154 | - | gotCalls := orchestrator.selectionCalls | 155 | + | gotCalls := recorder.selectionCalls |
| 155 | - | orchestrator.mu.Unlock() | 156 | + | recorder.mu.Unlock() |
| 156 | require.Zero(t, gotCalls) | 157 | require.Zero(t, gotCalls) | ||
| 157 | 158 | ||||
| 158 | require.Eventually(t, func() bool { | 159 | require.Eventually(t, func() bool { | ||
| 159 | - | orchestrator.mu.Lock() | 160 | + | recorder.mu.Lock() |
| 160 | - | defer orchestrator.mu.Unlock() | 161 | + | defer recorder.mu.Unlock() |
| 161 | - | return orchestrator.selectionCalls == 1 && orchestrator.selection.Text == "second selection" | 162 | + | return recorder.selectionCalls == 1 && recorder.selection.Text == "second selection" |
| 162 | }, 2*time.Second, 10*time.Millisecond) | 163 | }, 2*time.Second, 10*time.Millisecond) | ||
| 163 | require.Equal(t, "second selection", wv.selectedTextSnapshot()) | 164 | require.Equal(t, "second selection", wv.selectedTextSnapshot()) | ||
| 164 | } | 165 | } | ||
| 165 | 166 | ||||
| 166 | func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResumesWhenCleared(t *testing.T) { | 167 | func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResumesWhenCleared(t *testing.T) { | ||
| 167 | - | orchestrator := &recordingClipboardOrchestrator{} | 168 | + | recorder := &clipboardOrchestratorRecorder{} |
| 169 | + | orchestrator := portmocks.NewMockClipboardTextOrchestrator(t) | |||
| 170 | + | orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input port.SelectionClipboardInput) { | |||
| 171 | + | recorder.mu.Lock() | |||
| 172 | + | defer recorder.mu.Unlock() | |||
| 173 | + | recorder.selection = input | |||
| 174 | + | recorder.selectionCalls++ | |||
| 175 | + | }).Return(nil).Once() | |||
| 168 | wv := &WebView{ | 176 | wv := &WebView{ | ||
| 169 | ctx: context.Background(), | 177 | ctx: context.Background(), | ||
| 170 | id: 42, | 178 | id: 42, | ||
| @@ -192,15 +200,16 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu | |||||
| 192 | h.OnTextSelectionChanged(nil, "editable selection", nil) | 200 | h.OnTextSelectionChanged(nil, "editable selection", nil) | ||
| 193 | require.Equal(t, "editable selection", wv.selectedTextSnapshot()) | 201 | require.Equal(t, "editable selection", wv.selectedTextSnapshot()) | ||
| 194 | 202 | ||||
| 195 | - | orchestrator.mu.Lock() | 203 | + | recorder.mu.Lock() |
| 196 | - | gotCalls := orchestrator.selectionCalls | 204 | + | gotCalls := recorder.selectionCalls |
| 197 | - | orchestrator.mu.Unlock() | 205 | + | recorder.mu.Unlock() |
| 198 | - | require.Zero(t, gotCalls) | |||
| 199 | - | time.Sleep(350 * time.Millisecond) | |||
| 200 | - | orchestrator.mu.Lock() | |||
| 201 | - | gotCalls = orchestrator.selectionCalls | |||
| 202 | - | orchestrator.mu.Unlock() | |||
| 203 | require.Zero(t, gotCalls) | 206 | require.Zero(t, gotCalls) | ||
| 207 | + | start := time.Now() | |||
| 208 | + | require.Eventually(t, func() bool { | |||
| 209 | + | recorder.mu.Lock() | |||
| 210 | + | defer recorder.mu.Unlock() | |||
| 211 | + | return recorder.selectionCalls == 0 && time.Since(start) >= clipboardSelectionDebounceDelay+50*time.Millisecond | |||
| 212 | + | }, 2*time.Second, 10*time.Millisecond) | |||
| 204 | 213 | ||||
| 205 | newRendererBridgeProcessMessage = func(name string) purecef.ProcessMessage { | 214 | newRendererBridgeProcessMessage = func(name string) purecef.ProcessMessage { | ||
| 206 | return newTestBridgeProcessMessage(name, false) | 215 | return newTestBridgeProcessMessage(name, false) | ||
| @@ -216,16 +225,19 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu | |||||
| 216 | 225 | ||||
| 217 | h.OnTextSelectionChanged(nil, "free selection", nil) | 226 | h.OnTextSelectionChanged(nil, "free selection", nil) | ||
| 218 | require.Equal(t, "free selection", wv.selectedTextSnapshot()) | 227 | require.Equal(t, "free selection", wv.selectedTextSnapshot()) | ||
| 219 | - | require.Zero(t, orchestrator.selectionCalls) | 228 | + | recorder.mu.Lock() |
| 229 | + | gotCalls = recorder.selectionCalls | |||
| 230 | + | recorder.mu.Unlock() | |||
| 231 | + | require.Zero(t, gotCalls) | |||
| 220 | require.Eventually(t, func() bool { | 232 | require.Eventually(t, func() bool { | ||
| 221 | - | orchestrator.mu.Lock() | 233 | + | recorder.mu.Lock() |
| 222 | - | defer orchestrator.mu.Unlock() | 234 | + | defer recorder.mu.Unlock() |
| 223 | - | return orchestrator.selectionCalls == 1 && orchestrator.selection.Text == "free selection" | 235 | + | return recorder.selectionCalls == 1 && recorder.selection.Text == "free selection" |
| 224 | }, 2*time.Second, 10*time.Millisecond) | 236 | }, 2*time.Second, 10*time.Millisecond) | ||
| 225 | } | 237 | } | ||
| 226 | 238 | ||||
| 227 | func TestOnTextSelectionChanged_DoesNotEmitLateDebouncedUpdateAfterDestroy(t *testing.T) { | 239 | func TestOnTextSelectionChanged_DoesNotEmitLateDebouncedUpdateAfterDestroy(t *testing.T) { | ||
| 228 | - | orchestrator := &recordingClipboardOrchestrator{} | 240 | + | orchestrator := portmocks.NewMockClipboardTextOrchestrator(t) |
| 229 | wv := &WebView{ | 241 | wv := &WebView{ | ||
| 230 | ctx: context.Background(), | 242 | ctx: context.Background(), | ||
| 231 | id: 42, | 243 | id: 42, | ||
| @@ -238,15 +250,11 @@ func TestOnTextSelectionChanged_DoesNotEmitLateDebouncedUpdateAfterDestroy(t *te | |||||
| 238 | h.OnTextSelectionChanged(nil, "selected text", nil) | 250 | h.OnTextSelectionChanged(nil, "selected text", nil) | ||
| 239 | wv.Destroy() | 251 | wv.Destroy() | ||
| 240 | 252 | ||||
| 241 | - | orchestrator.mu.Lock() | 253 | + | start := time.Now() |
| 242 | - | gotCalls := orchestrator.selectionCalls | 254 | + | require.Eventually(t, func() bool { |
| 243 | - | orchestrator.mu.Unlock() | 255 | + | return time.Since(start) >= clipboardSelectionDebounceDelay+50*time.Millisecond |
| 244 | - | require.Zero(t, gotCalls) | 256 | + | }, 2*time.Second, 10*time.Millisecond) |
| 245 | - | time.Sleep(350 * time.Millisecond) | 257 | + | orchestrator.AssertNotCalled(t, "HandleSelectionUpdate", mock.Anything, mock.Anything) |
| 246 | - | orchestrator.mu.Lock() | |||
| 247 | - | gotCalls = orchestrator.selectionCalls | |||
| 248 | - | orchestrator.mu.Unlock() | |||
| 249 | - | require.Zero(t, gotCalls) | |||
| 250 | require.Equal(t, "selected text", wv.selectedTextSnapshot()) | 258 | require.Equal(t, "selected text", wv.selectedTextSnapshot()) | ||
| 251 | } | 259 | } | ||
| 252 | 260 | ||||
M internal/infrastructure/cef/webview_navigation_test.go +40 -1
| @@ -27,7 +27,7 @@ func TestWebViewReplayPendingNavigation_LoadsQueuedURIWhenMainFrameAvailable(t * | |||
| 27 | 27 | ||
| 28 | 28 | func TestWebViewReplayPendingNavigation_RetriesWhenMainFrameUnavailable(t *testing.T) { | |
| 29 | 29 | browser := cefmocks.NewMockBrowser(t) | |
| 30 | - | browser.EXPECT().GetMainFrame().Return((purecef.Frame)(nil)).Once() | |
| 30 | + | browser.EXPECT().GetMainFrame().Return(purecef.Frame(nil)).Once() | |
| 31 | 31 | ||
| 32 | 32 | oldTask := cefNewTask | |
| 33 | 33 | oldDelayed := cefPostDelayedTask | |
| @@ -55,6 +55,45 @@ func TestWebViewReplayPendingNavigation_RetriesWhenMainFrameUnavailable(t *testi | |||
| 55 | 55 | require.Equal(t, "https://github.com/bnema", wv.pendingURI) | |
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | + | func TestWebViewSchedulePendingNavigationReplay_RetriesWhenTaskPostFails(t *testing.T) { | |
| 59 | + | browser := cefmocks.NewMockBrowser(t) | |
| 60 | + | ||
| 61 | + | oldTask := cefNewTask | |
| 62 | + | oldPost := cefPostTask | |
| 63 | + | oldDelayed := cefPostDelayedTask | |
| 64 | + | oldAfter := cefScheduleAfter | |
| 65 | + | defer func() { | |
| 66 | + | cefNewTask = oldTask | |
| 67 | + | cefPostTask = oldPost | |
| 68 | + | cefPostDelayedTask = oldDelayed | |
| 69 | + | cefScheduleAfter = oldAfter | |
| 70 | + | }() | |
| 71 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |
| 72 | + | ||
| 73 | + | retried := false | |
| 74 | + | cefPostTask = func(threadID purecef.ThreadID, task purecef.Task) int32 { | |
| 75 | + | require.Equal(t, purecef.ThreadIDTidUi, threadID) | |
| 76 | + | require.NotNil(t, task) | |
| 77 | + | return 0 | |
| 78 | + | } | |
| 79 | + | cefScheduleAfter = func(delay time.Duration, fn func()) { | |
| 80 | + | require.Equal(t, pendingNavigationRetryDelay, delay) | |
| 81 | + | fn() | |
| 82 | + | } | |
| 83 | + | cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 { | |
| 84 | + | require.Equal(t, purecef.ThreadIDTidUi, threadID) | |
| 85 | + | require.NotNil(t, task) | |
| 86 | + | require.Equal(t, int64(pendingNavigationRetryDelay/time.Millisecond), delayMs) | |
| 87 | + | retried = true | |
| 88 | + | return 1 | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | wv := &WebView{ctx: context.Background(), pendingURI: "https://github.com/bnema"} | |
| 92 | + | wv.schedulePendingNavigationReplay(browser, 0) | |
| 93 | + | ||
| 94 | + | require.True(t, retried) | |
| 95 | + | } | |
| 96 | + | ||
| 58 | 97 | func TestWebViewLoadURI_QueuesPendingNavigationReplayForExistingBrowser(t *testing.T) { | |
| 59 | 98 | browser := cefmocks.NewMockBrowser(t) | |
| 60 | 99 | frame := cefmocks.NewMockFrame(t) | |
| @@ -27,7 +27,7 @@ func TestWebViewReplayPendingNavigation_LoadsQueuedURIWhenMainFrameAvailable(t * | |||||
| 27 | 27 | ||||
| 28 | func TestWebViewReplayPendingNavigation_RetriesWhenMainFrameUnavailable(t *testing.T) { | 28 | func TestWebViewReplayPendingNavigation_RetriesWhenMainFrameUnavailable(t *testing.T) { | ||
| 29 | browser := cefmocks.NewMockBrowser(t) | 29 | browser := cefmocks.NewMockBrowser(t) | ||
| 30 | - | browser.EXPECT().GetMainFrame().Return((purecef.Frame)(nil)).Once() | 30 | + | browser.EXPECT().GetMainFrame().Return(purecef.Frame(nil)).Once() |
| 31 | 31 | ||||
| 32 | oldTask := cefNewTask | 32 | oldTask := cefNewTask | ||
| 33 | oldDelayed := cefPostDelayedTask | 33 | oldDelayed := cefPostDelayedTask | ||
| @@ -55,6 +55,45 @@ func TestWebViewReplayPendingNavigation_RetriesWhenMainFrameUnavailable(t *testi | |||||
| 55 | require.Equal(t, "https://github.com/bnema", wv.pendingURI) | 55 | require.Equal(t, "https://github.com/bnema", wv.pendingURI) | ||
| 56 | } | 56 | } | ||
| 57 | 57 | ||||
| 58 | + | func TestWebViewSchedulePendingNavigationReplay_RetriesWhenTaskPostFails(t *testing.T) { | |||
| 59 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 60 | + | ||||
| 61 | + | oldTask := cefNewTask | |||
| 62 | + | oldPost := cefPostTask | |||
| 63 | + | oldDelayed := cefPostDelayedTask | |||
| 64 | + | oldAfter := cefScheduleAfter | |||
| 65 | + | defer func() { | |||
| 66 | + | cefNewTask = oldTask | |||
| 67 | + | cefPostTask = oldPost | |||
| 68 | + | cefPostDelayedTask = oldDelayed | |||
| 69 | + | cefScheduleAfter = oldAfter | |||
| 70 | + | }() | |||
| 71 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |||
| 72 | + | ||||
| 73 | + | retried := false | |||
| 74 | + | cefPostTask = func(threadID purecef.ThreadID, task purecef.Task) int32 { | |||
| 75 | + | require.Equal(t, purecef.ThreadIDTidUi, threadID) | |||
| 76 | + | require.NotNil(t, task) | |||
| 77 | + | return 0 | |||
| 78 | + | } | |||
| 79 | + | cefScheduleAfter = func(delay time.Duration, fn func()) { | |||
| 80 | + | require.Equal(t, pendingNavigationRetryDelay, delay) | |||
| 81 | + | fn() | |||
| 82 | + | } | |||
| 83 | + | cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 { | |||
| 84 | + | require.Equal(t, purecef.ThreadIDTidUi, threadID) | |||
| 85 | + | require.NotNil(t, task) | |||
| 86 | + | require.Equal(t, int64(pendingNavigationRetryDelay/time.Millisecond), delayMs) | |||
| 87 | + | retried = true | |||
| 88 | + | return 1 | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | wv := &WebView{ctx: context.Background(), pendingURI: "https://github.com/bnema"} | |||
| 92 | + | wv.schedulePendingNavigationReplay(browser, 0) | |||
| 93 | + | ||||
| 94 | + | require.True(t, retried) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 58 | func TestWebViewLoadURI_QueuesPendingNavigationReplayForExistingBrowser(t *testing.T) { | 97 | func TestWebViewLoadURI_QueuesPendingNavigationReplayForExistingBrowser(t *testing.T) { | ||
| 59 | browser := cefmocks.NewMockBrowser(t) | 98 | browser := cefmocks.NewMockBrowser(t) | ||
| 60 | frame := cefmocks.NewMockFrame(t) | 99 | frame := cefmocks.NewMockFrame(t) | ||
M internal/infrastructure/config/engine.go +1 -1
| @@ -85,7 +85,7 @@ type CEFEngineConfig struct { | |||
| 85 | 85 | // libcef.so and the Resources/locales subdirectories. | |
| 86 | 86 | CEFDir string `mapstructure:"cef_dir" toml:"cef_dir" yaml:"cef_dir"` | |
| 87 | 87 | // LogFile overrides the CEF internal log destination. When empty, | |
| 88 | - | // Dumber writes CEF startup logs to .dev/dumber/logs/cef_runtime.log. | |
| 88 | + | // Dumber uses the resolved runtime-profile default engine log path. | |
| 89 | 89 | LogFile string `mapstructure:"log_file" toml:"log_file" yaml:"log_file"` | |
| 90 | 90 | // LogSeverity controls CEF's internal log verbosity. | |
| 91 | 91 | // 0 = default, 1 = verbose, 2 = info, 3 = warning, 4 = error, 99 = disable. | |
| @@ -85,7 +85,7 @@ type CEFEngineConfig struct { | |||||
| 85 | // libcef.so and the Resources/locales subdirectories. | 85 | // libcef.so and the Resources/locales subdirectories. | ||
| 86 | CEFDir string `mapstructure:"cef_dir" toml:"cef_dir" yaml:"cef_dir"` | 86 | CEFDir string `mapstructure:"cef_dir" toml:"cef_dir" yaml:"cef_dir"` | ||
| 87 | // LogFile overrides the CEF internal log destination. When empty, | 87 | // LogFile overrides the CEF internal log destination. When empty, | ||
| 88 | - | // Dumber writes CEF startup logs to .dev/dumber/logs/cef_runtime.log. | 88 | + | // Dumber uses the resolved runtime-profile default engine log path. |
| 89 | LogFile string `mapstructure:"log_file" toml:"log_file" yaml:"log_file"` | 89 | LogFile string `mapstructure:"log_file" toml:"log_file" yaml:"log_file"` | ||
| 90 | // LogSeverity controls CEF's internal log verbosity. | 90 | // LogSeverity controls CEF's internal log verbosity. | ||
| 91 | // 0 = default, 1 = verbose, 2 = info, 3 = warning, 4 = error, 99 = disable. | 91 | // 0 = default, 1 = verbose, 2 = info, 3 = warning, 4 = error, 99 = disable. | ||
M internal/infrastructure/config/xdg.go +39 -25
| @@ -4,6 +4,8 @@ package config | |||
| 4 | 4 | import ( | |
| 5 | 5 | "os" | |
| 6 | 6 | "path/filepath" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 7 | 9 | ) | |
| 8 | 10 | ||
| 9 | 11 | // File permission constants | |
| @@ -25,25 +27,42 @@ type XDGDirs struct { | |||
| 25 | 27 | CacheHome string | |
| 26 | 28 | } | |
| 27 | 29 | ||
| 28 | - | // GetXDGDirs returns the XDG Base Directory paths for dumber. | |
| 29 | - | // It follows the XDG Base Directory specification: | |
| 30 | - | // - $XDG_CONFIG_HOME/dumber (default: ~/.config/dumber) | |
| 31 | - | // - $XDG_DATA_HOME/dumber (default: ~/.local/share/dumber) | |
| 32 | - | // - $XDG_STATE_HOME/dumber (default: ~/.local/state/dumber) | |
| 30 | + | // GetXDGDirs returns the dumber-specific XDG directories. | |
| 31 | + | // In prod they match the current XDG layout exactly. | |
| 32 | + | // In dev they resolve into the shared sandbox under .dev/dumber/{config,data,state,cache}. | |
| 33 | 33 | func GetXDGDirs() (*XDGDirs, error) { | |
| 34 | - | // Development mode: use .dev directory in current working directory | |
| 34 | + | profile, err := resolveSharedProfile() | |
| 35 | + | if err != nil { | |
| 36 | + | return nil, err | |
| 37 | + | } | |
| 38 | + | return &XDGDirs{ | |
| 39 | + | ConfigHome: profile.Shared.ConfigDir, | |
| 40 | + | DataHome: profile.Shared.DataDir, | |
| 41 | + | StateHome: profile.Shared.StateDir, | |
| 42 | + | CacheHome: profile.Shared.CacheDir, | |
| 43 | + | }, nil | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func resolveSharedProfile() (runtimeprofile.Profile, error) { | |
| 47 | + | base, err := getBaseXDGDirs() | |
| 48 | + | if err != nil { | |
| 49 | + | return runtimeprofile.Profile{}, err | |
| 50 | + | } | |
| 51 | + | return runtimeprofile.Resolve(runtimeprofile.ResolveInput{ | |
| 52 | + | Env: os.Getenv, | |
| 53 | + | CWD: os.Getwd, | |
| 54 | + | Base: runtimeprofile.BasePaths{ | |
| 55 | + | ConfigHome: base.ConfigHome, | |
| 56 | + | DataHome: base.DataHome, | |
| 57 | + | StateHome: base.StateHome, | |
| 58 | + | CacheHome: base.CacheHome, | |
| 59 | + | }, | |
| 60 | + | }) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func getBaseXDGDirs() (*XDGDirs, error) { | |
| 35 | 64 | if os.Getenv("ENV") == "dev" { | |
| 36 | - | cwd, err := os.Getwd() | |
| 37 | - | if err != nil { | |
| 38 | - | return nil, err | |
| 39 | - | } | |
| 40 | - | devDir := filepath.Join(cwd, ".dev", appName) | |
| 41 | - | return &XDGDirs{ | |
| 42 | - | ConfigHome: devDir, | |
| 43 | - | DataHome: devDir, | |
| 44 | - | StateHome: devDir, | |
| 45 | - | CacheHome: devDir, | |
| 46 | - | }, nil | |
| 65 | + | return &XDGDirs{}, nil | |
| 47 | 66 | } | |
| 48 | 67 | ||
| 49 | 68 | homeDir, err := os.UserHomeDir() | |
| @@ -51,28 +70,24 @@ func GetXDGDirs() (*XDGDirs, error) { | |||
| 51 | 70 | return nil, err | |
| 52 | 71 | } | |
| 53 | 72 | ||
| 54 | - | // XDG_CONFIG_HOME | |
| 55 | 73 | configHome := os.Getenv("XDG_CONFIG_HOME") | |
| 56 | 74 | if configHome == "" { | |
| 57 | 75 | configHome = filepath.Join(homeDir, ".config") | |
| 58 | 76 | } | |
| 59 | 77 | configHome = filepath.Join(configHome, appName) | |
| 60 | 78 | ||
| 61 | - | // XDG_DATA_HOME | |
| 62 | 79 | dataHome := os.Getenv("XDG_DATA_HOME") | |
| 63 | 80 | if dataHome == "" { | |
| 64 | 81 | dataHome = filepath.Join(homeDir, ".local", "share") | |
| 65 | 82 | } | |
| 66 | 83 | dataHome = filepath.Join(dataHome, appName) | |
| 67 | 84 | ||
| 68 | - | // XDG_STATE_HOME | |
| 69 | 85 | stateHome := os.Getenv("XDG_STATE_HOME") | |
| 70 | 86 | if stateHome == "" { | |
| 71 | 87 | stateHome = filepath.Join(homeDir, ".local", "state") | |
| 72 | 88 | } | |
| 73 | 89 | stateHome = filepath.Join(stateHome, appName) | |
| 74 | 90 | ||
| 75 | - | // XDG_CACHE_HOME | |
| 76 | 91 | cacheHome := os.Getenv("XDG_CACHE_HOME") | |
| 77 | 92 | if cacheHome == "" { | |
| 78 | 93 | cacheHome = filepath.Join(homeDir, ".cache") | |
| @@ -114,14 +129,13 @@ func GetStateDir() (string, error) { | |||
| 114 | 129 | return dirs.StateHome, nil | |
| 115 | 130 | } | |
| 116 | 131 | ||
| 117 | - | // GetLogDir returns the XDG-compliant log directory for dumber. | |
| 118 | - | // Logs are stored in XDG_STATE_HOME as per specification. | |
| 132 | + | // GetLogDir returns the shared application log directory for dumber. | |
| 119 | 133 | func GetLogDir() (string, error) { | |
| 120 | - | stateDir, err := GetStateDir() | |
| 134 | + | profile, err := resolveSharedProfile() | |
| 121 | 135 | if err != nil { | |
| 122 | 136 | return "", err | |
| 123 | 137 | } | |
| 124 | - | return filepath.Join(stateDir, "logs"), nil | |
| 138 | + | return profile.Shared.LogDir, nil | |
| 125 | 139 | } | |
| 126 | 140 | ||
| 127 | 141 | // GetConfigFile returns the path to the main configuration file. | |
| @@ -4,6 +4,8 @@ package config | |||||
| 4 | import ( | 4 | import ( | ||
| 5 | "os" | 5 | "os" | ||
| 6 | "path/filepath" | 6 | "path/filepath" | ||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 7 | ) | 9 | ) | ||
| 8 | 10 | ||||
| 9 | // File permission constants | 11 | // File permission constants | ||
| @@ -25,25 +27,42 @@ type XDGDirs struct { | |||||
| 25 | CacheHome string | 27 | CacheHome string | ||
| 26 | } | 28 | } | ||
| 27 | 29 | ||||
| 28 | - | // GetXDGDirs returns the XDG Base Directory paths for dumber. | 30 | + | // GetXDGDirs returns the dumber-specific XDG directories. |
| 29 | - | // It follows the XDG Base Directory specification: | 31 | + | // In prod they match the current XDG layout exactly. |
| 30 | - | // - $XDG_CONFIG_HOME/dumber (default: ~/.config/dumber) | 32 | + | // In dev they resolve into the shared sandbox under .dev/dumber/{config,data,state,cache}. |
| 31 | - | // - $XDG_DATA_HOME/dumber (default: ~/.local/share/dumber) | |||
| 32 | - | // - $XDG_STATE_HOME/dumber (default: ~/.local/state/dumber) | |||
| 33 | func GetXDGDirs() (*XDGDirs, error) { | 33 | func GetXDGDirs() (*XDGDirs, error) { | ||
| 34 | - | // Development mode: use .dev directory in current working directory | 34 | + | profile, err := resolveSharedProfile() |
| 35 | + | if err != nil { | |||
| 36 | + | return nil, err | |||
| 37 | + | } | |||
| 38 | + | return &XDGDirs{ | |||
| 39 | + | ConfigHome: profile.Shared.ConfigDir, | |||
| 40 | + | DataHome: profile.Shared.DataDir, | |||
| 41 | + | StateHome: profile.Shared.StateDir, | |||
| 42 | + | CacheHome: profile.Shared.CacheDir, | |||
| 43 | + | }, nil | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func resolveSharedProfile() (runtimeprofile.Profile, error) { | |||
| 47 | + | base, err := getBaseXDGDirs() | |||
| 48 | + | if err != nil { | |||
| 49 | + | return runtimeprofile.Profile{}, err | |||
| 50 | + | } | |||
| 51 | + | return runtimeprofile.Resolve(runtimeprofile.ResolveInput{ | |||
| 52 | + | Env: os.Getenv, | |||
| 53 | + | CWD: os.Getwd, | |||
| 54 | + | Base: runtimeprofile.BasePaths{ | |||
| 55 | + | ConfigHome: base.ConfigHome, | |||
| 56 | + | DataHome: base.DataHome, | |||
| 57 | + | StateHome: base.StateHome, | |||
| 58 | + | CacheHome: base.CacheHome, | |||
| 59 | + | }, | |||
| 60 | + | }) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func getBaseXDGDirs() (*XDGDirs, error) { | |||
| 35 | if os.Getenv("ENV") == "dev" { | 64 | if os.Getenv("ENV") == "dev" { | ||
| 36 | - | cwd, err := os.Getwd() | 65 | + | return &XDGDirs{}, nil |
| 37 | - | if err != nil { | |||
| 38 | - | return nil, err | |||
| 39 | - | } | |||
| 40 | - | devDir := filepath.Join(cwd, ".dev", appName) | |||
| 41 | - | return &XDGDirs{ | |||
| 42 | - | ConfigHome: devDir, | |||
| 43 | - | DataHome: devDir, | |||
| 44 | - | StateHome: devDir, | |||
| 45 | - | CacheHome: devDir, | |||
| 46 | - | }, nil | |||
| 47 | } | 66 | } | ||
| 48 | 67 | ||||
| 49 | homeDir, err := os.UserHomeDir() | 68 | homeDir, err := os.UserHomeDir() | ||
| @@ -51,28 +70,24 @@ func GetXDGDirs() (*XDGDirs, error) { | |||||
| 51 | return nil, err | 70 | return nil, err | ||
| 52 | } | 71 | } | ||
| 53 | 72 | ||||
| 54 | - | // XDG_CONFIG_HOME | |||
| 55 | configHome := os.Getenv("XDG_CONFIG_HOME") | 73 | configHome := os.Getenv("XDG_CONFIG_HOME") | ||
| 56 | if configHome == "" { | 74 | if configHome == "" { | ||
| 57 | configHome = filepath.Join(homeDir, ".config") | 75 | configHome = filepath.Join(homeDir, ".config") | ||
| 58 | } | 76 | } | ||
| 59 | configHome = filepath.Join(configHome, appName) | 77 | configHome = filepath.Join(configHome, appName) | ||
| 60 | 78 | ||||
| 61 | - | // XDG_DATA_HOME | |||
| 62 | dataHome := os.Getenv("XDG_DATA_HOME") | 79 | dataHome := os.Getenv("XDG_DATA_HOME") | ||
| 63 | if dataHome == "" { | 80 | if dataHome == "" { | ||
| 64 | dataHome = filepath.Join(homeDir, ".local", "share") | 81 | dataHome = filepath.Join(homeDir, ".local", "share") | ||
| 65 | } | 82 | } | ||
| 66 | dataHome = filepath.Join(dataHome, appName) | 83 | dataHome = filepath.Join(dataHome, appName) | ||
| 67 | 84 | ||||
| 68 | - | // XDG_STATE_HOME | |||
| 69 | stateHome := os.Getenv("XDG_STATE_HOME") | 85 | stateHome := os.Getenv("XDG_STATE_HOME") | ||
| 70 | if stateHome == "" { | 86 | if stateHome == "" { | ||
| 71 | stateHome = filepath.Join(homeDir, ".local", "state") | 87 | stateHome = filepath.Join(homeDir, ".local", "state") | ||
| 72 | } | 88 | } | ||
| 73 | stateHome = filepath.Join(stateHome, appName) | 89 | stateHome = filepath.Join(stateHome, appName) | ||
| 74 | 90 | ||||
| 75 | - | // XDG_CACHE_HOME | |||
| 76 | cacheHome := os.Getenv("XDG_CACHE_HOME") | 91 | cacheHome := os.Getenv("XDG_CACHE_HOME") | ||
| 77 | if cacheHome == "" { | 92 | if cacheHome == "" { | ||
| 78 | cacheHome = filepath.Join(homeDir, ".cache") | 93 | cacheHome = filepath.Join(homeDir, ".cache") | ||
| @@ -114,14 +129,13 @@ func GetStateDir() (string, error) { | |||||
| 114 | return dirs.StateHome, nil | 129 | return dirs.StateHome, nil | ||
| 115 | } | 130 | } | ||
| 116 | 131 | ||||
| 117 | - | // GetLogDir returns the XDG-compliant log directory for dumber. | 132 | + | // GetLogDir returns the shared application log directory for dumber. |
| 118 | - | // Logs are stored in XDG_STATE_HOME as per specification. | |||
| 119 | func GetLogDir() (string, error) { | 133 | func GetLogDir() (string, error) { | ||
| 120 | - | stateDir, err := GetStateDir() | 134 | + | profile, err := resolveSharedProfile() |
| 121 | if err != nil { | 135 | if err != nil { | ||
| 122 | return "", err | 136 | return "", err | ||
| 123 | } | 137 | } | ||
| 124 | - | return filepath.Join(stateDir, "logs"), nil | 138 | + | return profile.Shared.LogDir, nil |
| 125 | } | 139 | } | ||
| 126 | 140 | ||||
| 127 | // GetConfigFile returns the path to the main configuration file. | 141 | // GetConfigFile returns the path to the main configuration file. | ||
A internal/infrastructure/config/xdg_test.go +42 -0
| @@ -0,0 +1,42 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "path/filepath" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestGetXDGDirs_ProdPreservesLayout(t *testing.T) { | |
| 11 | + | home := t.TempDir() | |
| 12 | + | t.Setenv("ENV", "") | |
| 13 | + | t.Setenv("HOME", home) | |
| 14 | + | t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) | |
| 15 | + | t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share")) | |
| 16 | + | t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state")) | |
| 17 | + | t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) | |
| 18 | + | ||
| 19 | + | dirs, err := GetXDGDirs() | |
| 20 | + | require.NoError(t, err) | |
| 21 | + | require.Equal(t, filepath.Join(home, ".config", appName), dirs.ConfigHome) | |
| 22 | + | require.Equal(t, filepath.Join(home, ".local", "share", appName), dirs.DataHome) | |
| 23 | + | require.Equal(t, filepath.Join(home, ".local", "state", appName), dirs.StateHome) | |
| 24 | + | require.Equal(t, filepath.Join(home, ".cache", appName), dirs.CacheHome) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestGetXDGDirs_DevUsesSharedSandboxLayout(t *testing.T) { | |
| 28 | + | wd := t.TempDir() | |
| 29 | + | t.Chdir(wd) | |
| 30 | + | ||
| 31 | + | t.Setenv("ENV", "dev") | |
| 32 | + | dirs, err := GetXDGDirs() | |
| 33 | + | require.NoError(t, err) | |
| 34 | + | require.Equal(t, filepath.Join(wd, ".dev", appName, "config"), dirs.ConfigHome) | |
| 35 | + | require.Equal(t, filepath.Join(wd, ".dev", appName, "data"), dirs.DataHome) | |
| 36 | + | require.Equal(t, filepath.Join(wd, ".dev", appName, "state"), dirs.StateHome) | |
| 37 | + | require.Equal(t, filepath.Join(wd, ".dev", appName, "cache"), dirs.CacheHome) | |
| 38 | + | ||
| 39 | + | logDir, err := GetLogDir() | |
| 40 | + | require.NoError(t, err) | |
| 41 | + | require.Equal(t, filepath.Join(wd, ".dev", appName, "logs"), logDir) | |
| 42 | + | } | |
| @@ -0,0 +1,42 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "path/filepath" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestGetXDGDirs_ProdPreservesLayout(t *testing.T) { | |||
| 11 | + | home := t.TempDir() | |||
| 12 | + | t.Setenv("ENV", "") | |||
| 13 | + | t.Setenv("HOME", home) | |||
| 14 | + | t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) | |||
| 15 | + | t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share")) | |||
| 16 | + | t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state")) | |||
| 17 | + | t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) | |||
| 18 | + | ||||
| 19 | + | dirs, err := GetXDGDirs() | |||
| 20 | + | require.NoError(t, err) | |||
| 21 | + | require.Equal(t, filepath.Join(home, ".config", appName), dirs.ConfigHome) | |||
| 22 | + | require.Equal(t, filepath.Join(home, ".local", "share", appName), dirs.DataHome) | |||
| 23 | + | require.Equal(t, filepath.Join(home, ".local", "state", appName), dirs.StateHome) | |||
| 24 | + | require.Equal(t, filepath.Join(home, ".cache", appName), dirs.CacheHome) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func TestGetXDGDirs_DevUsesSharedSandboxLayout(t *testing.T) { | |||
| 28 | + | wd := t.TempDir() | |||
| 29 | + | t.Chdir(wd) | |||
| 30 | + | ||||
| 31 | + | t.Setenv("ENV", "dev") | |||
| 32 | + | dirs, err := GetXDGDirs() | |||
| 33 | + | require.NoError(t, err) | |||
| 34 | + | require.Equal(t, filepath.Join(wd, ".dev", appName, "config"), dirs.ConfigHome) | |||
| 35 | + | require.Equal(t, filepath.Join(wd, ".dev", appName, "data"), dirs.DataHome) | |||
| 36 | + | require.Equal(t, filepath.Join(wd, ".dev", appName, "state"), dirs.StateHome) | |||
| 37 | + | require.Equal(t, filepath.Join(wd, ".dev", appName, "cache"), dirs.CacheHome) | |||
| 38 | + | ||||
| 39 | + | logDir, err := GetLogDir() | |||
| 40 | + | require.NoError(t, err) | |||
| 41 | + | require.Equal(t, filepath.Join(wd, ".dev", appName, "logs"), logDir) | |||
| 42 | + | } | |||
M internal/infrastructure/desktop/browser_launch_relay.go +9 -22
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | 14 | "time" | |
| 15 | 15 | ||
| 16 | 16 | "github.com/bnema/dumber/internal/application/port" | |
| 17 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 17 | 18 | "github.com/bnema/dumber/internal/logging" | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| @@ -24,7 +25,7 @@ const browserLaunchIOTimeout = 50 * time.Millisecond | |||
| 24 | 25 | const browserLaunchDirPerm = 0o700 | |
| 25 | 26 | ||
| 26 | 27 | type browserLaunchRelay struct { | |
| 27 | - | xdg port.XDGPaths | |
| 28 | + | ipc runtimeprofile.IPCPaths | |
| 28 | 29 | } | |
| 29 | 30 | ||
| 30 | 31 | type browserLaunchRequest struct { | |
| @@ -42,8 +43,8 @@ type browserLaunchRelayListener struct { | |||
| 42 | 43 | err error | |
| 43 | 44 | } | |
| 44 | 45 | ||
| 45 | - | func NewBrowserLaunchRelay(xdg port.XDGPaths) port.BrowserLaunchRelay { | |
| 46 | - | return &browserLaunchRelay{xdg: xdg} | |
| 46 | + | func NewBrowserLaunchRelay(ipc runtimeprofile.IPCPaths) port.BrowserLaunchRelay { | |
| 47 | + | return &browserLaunchRelay{ipc: ipc} | |
| 47 | 48 | } | |
| 48 | 49 | ||
| 49 | 50 | func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) { | |
| @@ -171,27 +172,13 @@ func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWind | |||
| 171 | 172 | } | |
| 172 | 173 | ||
| 173 | 174 | func (r *browserLaunchRelay) socketPath() (string, error) { | |
| 174 | - | if r == nil || r.xdg == nil { | |
| 175 | - | return "", errors.New("browser launch relay missing XDG paths") | |
| 175 | + | if r == nil { | |
| 176 | + | return "", errors.New("browser launch relay missing IPC paths") | |
| 176 | 177 | } | |
| 177 | - | ||
| 178 | - | runtimeDir, err := r.xdg.RuntimeDir() | |
| 179 | - | if err != nil { | |
| 180 | - | return "", fmt.Errorf("get runtime dir: %w", err) | |
| 181 | - | } | |
| 182 | - | if runtimeDir != "" { | |
| 183 | - | return filepath.Join(runtimeDir, browserLaunchSocketName), nil | |
| 184 | - | } | |
| 185 | - | ||
| 186 | - | stateDir, err := r.xdg.StateDir() | |
| 187 | - | if err != nil { | |
| 188 | - | return "", fmt.Errorf("get state dir: %w", err) | |
| 178 | + | if r.ipc.BrowserLaunchSocket == "" { | |
| 179 | + | return "", errors.New("browser launch relay missing browser launch socket path") | |
| 189 | 180 | } | |
| 190 | - | if stateDir == "" { | |
| 191 | - | return "", errors.New("browser launch relay needs runtime or state dir") | |
| 192 | - | } | |
| 193 | - | ||
| 194 | - | return filepath.Join(stateDir, "runtime", browserLaunchSocketName), nil | |
| 181 | + | return r.ipc.BrowserLaunchSocket, nil | |
| 195 | 182 | } | |
| 196 | 183 | ||
| 197 | 184 | func (l *browserLaunchRelayListener) Close() error { | |
| @@ -14,6 +14,7 @@ import ( | |||||
| 14 | "time" | 14 | "time" | ||
| 15 | 15 | ||||
| 16 | "github.com/bnema/dumber/internal/application/port" | 16 | "github.com/bnema/dumber/internal/application/port" | ||
| 17 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 17 | "github.com/bnema/dumber/internal/logging" | 18 | "github.com/bnema/dumber/internal/logging" | ||
| 18 | ) | 19 | ) | ||
| 19 | 20 | ||||
| @@ -24,7 +25,7 @@ const browserLaunchIOTimeout = 50 * time.Millisecond | |||||
| 24 | const browserLaunchDirPerm = 0o700 | 25 | const browserLaunchDirPerm = 0o700 | ||
| 25 | 26 | ||||
| 26 | type browserLaunchRelay struct { | 27 | type browserLaunchRelay struct { | ||
| 27 | - | xdg port.XDGPaths | 28 | + | ipc runtimeprofile.IPCPaths |
| 28 | } | 29 | } | ||
| 29 | 30 | ||||
| 30 | type browserLaunchRequest struct { | 31 | type browserLaunchRequest struct { | ||
| @@ -42,8 +43,8 @@ type browserLaunchRelayListener struct { | |||||
| 42 | err error | 43 | err error | ||
| 43 | } | 44 | } | ||
| 44 | 45 | ||||
| 45 | - | func NewBrowserLaunchRelay(xdg port.XDGPaths) port.BrowserLaunchRelay { | 46 | + | func NewBrowserLaunchRelay(ipc runtimeprofile.IPCPaths) port.BrowserLaunchRelay { |
| 46 | - | return &browserLaunchRelay{xdg: xdg} | 47 | + | return &browserLaunchRelay{ipc: ipc} |
| 47 | } | 48 | } | ||
| 48 | 49 | ||||
| 49 | func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) { | 50 | func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) { | ||
| @@ -171,27 +172,13 @@ func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWind | |||||
| 171 | } | 172 | } | ||
| 172 | 173 | ||||
| 173 | func (r *browserLaunchRelay) socketPath() (string, error) { | 174 | func (r *browserLaunchRelay) socketPath() (string, error) { | ||
| 174 | - | if r == nil || r.xdg == nil { | 175 | + | if r == nil { |
| 175 | - | return "", errors.New("browser launch relay missing XDG paths") | 176 | + | return "", errors.New("browser launch relay missing IPC paths") |
| 176 | } | 177 | } | ||
| 177 | - | 178 | + | if r.ipc.BrowserLaunchSocket == "" { | |
| 178 | - | runtimeDir, err := r.xdg.RuntimeDir() | 179 | + | return "", errors.New("browser launch relay missing browser launch socket path") |
| 179 | - | if err != nil { | |||
| 180 | - | return "", fmt.Errorf("get runtime dir: %w", err) | |||
| 181 | - | } | |||
| 182 | - | if runtimeDir != "" { | |||
| 183 | - | return filepath.Join(runtimeDir, browserLaunchSocketName), nil | |||
| 184 | - | } | |||
| 185 | - | ||||
| 186 | - | stateDir, err := r.xdg.StateDir() | |||
| 187 | - | if err != nil { | |||
| 188 | - | return "", fmt.Errorf("get state dir: %w", err) | |||
| 189 | } | 180 | } | ||
| 190 | - | if stateDir == "" { | 181 | + | return r.ipc.BrowserLaunchSocket, nil |
| 191 | - | return "", errors.New("browser launch relay needs runtime or state dir") | |||
| 192 | - | } | |||
| 193 | - | ||||
| 194 | - | return filepath.Join(stateDir, "runtime", browserLaunchSocketName), nil | |||
| 195 | } | 182 | } | ||
| 196 | 183 | ||||
| 197 | func (l *browserLaunchRelayListener) Close() error { | 184 | func (l *browserLaunchRelayListener) Close() error { | ||
M internal/infrastructure/desktop/browser_launch_relay_test.go +85 -86
| @@ -9,29 +9,11 @@ import ( | |||
| 9 | 9 | "testing" | |
| 10 | 10 | "time" | |
| 11 | 11 | ||
| 12 | - | "github.com/bnema/dumber/internal/application/port" | |
| 12 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 13 | 13 | "github.com/stretchr/testify/assert" | |
| 14 | 14 | "github.com/stretchr/testify/require" | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | - | type testXDGPaths struct { | |
| 18 | - | runtimeDir string | |
| 19 | - | stateDir string | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | var _ port.XDGPaths = testXDGPaths{} | |
| 23 | - | ||
| 24 | - | func (testXDGPaths) ConfigDir() (string, error) { return "", nil } | |
| 25 | - | func (testXDGPaths) DataDir() (string, error) { return "", nil } | |
| 26 | - | func (x testXDGPaths) StateDir() (string, error) { return x.stateDir, nil } | |
| 27 | - | func (x testXDGPaths) RuntimeDir() (string, error) { return x.runtimeDir, nil } | |
| 28 | - | func (testXDGPaths) CacheDir() (string, error) { return "", nil } | |
| 29 | - | func (testXDGPaths) FilterJSONDir() (string, error) { return "", nil } | |
| 30 | - | func (testXDGPaths) FilterStoreDir() (string, error) { return "", nil } | |
| 31 | - | func (testXDGPaths) FilterCacheDir() (string, error) { return "", nil } | |
| 32 | - | func (testXDGPaths) ManDir() (string, error) { return "", nil } | |
| 33 | - | func (testXDGPaths) DownloadDir() (string, error) { return "", nil } | |
| 34 | - | ||
| 35 | 17 | type browserWindowOpenerFunc func(context.Context, string) error | |
| 36 | 18 | ||
| 37 | 19 | func (f browserWindowOpenerFunc) OpenFreshWindow(ctx context.Context, url string) error { | |
| @@ -68,8 +50,24 @@ func shortTempDir(t *testing.T) string { | |||
| 68 | 50 | return dir | |
| 69 | 51 | } | |
| 70 | 52 | ||
| 53 | + | func testIPC(root string) runtimeprofile.IPCPaths { | |
| 54 | + | runtimeDir := filepath.Join(root, "runtime") | |
| 55 | + | return runtimeprofile.IPCPaths{ | |
| 56 | + | RuntimeDir: runtimeDir, | |
| 57 | + | BrowserLaunchSocket: filepath.Join(runtimeDir, browserLaunchSocketName), | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func testNamespacedIPC(root, engine string) runtimeprofile.IPCPaths { | |
| 62 | + | runtimeDir := filepath.Join(root, "runtime", engine) | |
| 63 | + | return runtimeprofile.IPCPaths{ | |
| 64 | + | RuntimeDir: runtimeDir, | |
| 65 | + | BrowserLaunchSocket: filepath.Join(runtimeDir, browserLaunchSocketName), | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 71 | 69 | func TestBrowserLaunchRelay_DeliverOpenFreshWindow_MissingListenerReturnsFalseNil(t *testing.T) { | |
| 72 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: shortTempDir(t)}) | |
| 70 | + | relay := NewBrowserLaunchRelay(testIPC(shortTempDir(t))) | |
| 73 | 71 | ||
| 74 | 72 | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com") | |
| 75 | 73 | ||
| @@ -77,39 +75,40 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_MissingListenerReturnsFalseNi | |||
| 77 | 75 | assert.False(t, delivered) | |
| 78 | 76 | } | |
| 79 | 77 | ||
| 80 | - | func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RoundTrip(t *testing.T) { | |
| 81 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | |
| 82 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | |
| 78 | + | func TestBrowserLaunchRelay_SameProfileAndEngine_UsesSameSocket(t *testing.T) { | |
| 79 | + | ipc := testNamespacedIPC(shortTempDir(t), "cef") | |
| 80 | + | listenerRelay := NewBrowserLaunchRelay(ipc) | |
| 81 | + | clientRelay := NewBrowserLaunchRelay(ipc) | |
| 83 | 82 | ||
| 84 | 83 | received := make(chan string, 1) | |
| 85 | 84 | ctx, cancel := context.WithCancel(context.Background()) | |
| 86 | 85 | defer cancel() | |
| 87 | 86 | ||
| 88 | - | closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error { | |
| 87 | + | closer, err := listenerRelay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error { | |
| 89 | 88 | received <- url | |
| 90 | 89 | return nil | |
| 91 | 90 | })) | |
| 92 | 91 | require.NoError(t, err) | |
| 93 | 92 | defer closer.Close() | |
| 94 | 93 | ||
| 95 | - | waitForSocket(t, filepath.Join(runtimeDir, "browser-launch.sock")) | |
| 96 | - | ||
| 97 | - | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/new") | |
| 94 | + | waitForSocket(t, ipc.BrowserLaunchSocket) | |
| 98 | 95 | ||
| 96 | + | delivered, err := clientRelay.DeliverOpenFreshWindow(context.Background(), "https://example.com/same-namespace") | |
| 99 | 97 | require.NoError(t, err) | |
| 100 | - | assert.True(t, delivered) | |
| 98 | + | require.True(t, delivered) | |
| 99 | + | require.Equal(t, "https://example.com/same-namespace", <-received) | |
| 100 | + | } | |
| 101 | 101 | ||
| 102 | - | select { | |
| 103 | - | case got := <-received: | |
| 104 | - | assert.Equal(t, "https://example.com/new", got) | |
| 105 | - | case <-time.After(time.Second): | |
| 106 | - | t.Fatal("expected opener to receive the URL") | |
| 107 | - | } | |
| 102 | + | func TestBrowserLaunchRelay_DevCEFAndDevWebKit_UseDifferentSockets(t *testing.T) { | |
| 103 | + | root := t.TempDir() | |
| 104 | + | cefIPC := testNamespacedIPC(root, "cef") | |
| 105 | + | wkIPC := testNamespacedIPC(root, "webkit") | |
| 106 | + | require.NotEqual(t, cefIPC.BrowserLaunchSocket, wkIPC.BrowserLaunchSocket) | |
| 108 | 107 | } | |
| 109 | 108 | ||
| 110 | - | func TestBrowserLaunchRelay_UsesStateFallbackWhenRuntimeDirMissing(t *testing.T) { | |
| 111 | - | stateDir := shortTempDir(t) | |
| 112 | - | relay := NewBrowserLaunchRelay(testXDGPaths{stateDir: stateDir}) | |
| 109 | + | func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RoundTrip(t *testing.T) { | |
| 110 | + | ipc := testIPC(shortTempDir(t)) | |
| 111 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 113 | 112 | ||
| 114 | 113 | received := make(chan string, 1) | |
| 115 | 114 | ctx, cancel := context.WithCancel(context.Background()) | |
| @@ -122,25 +121,32 @@ func TestBrowserLaunchRelay_UsesStateFallbackWhenRuntimeDirMissing(t *testing.T) | |||
| 122 | 121 | require.NoError(t, err) | |
| 123 | 122 | defer closer.Close() | |
| 124 | 123 | ||
| 125 | - | socketPath := filepath.Join(stateDir, "runtime", "browser-launch.sock") | |
| 126 | - | waitForSocket(t, socketPath) | |
| 124 | + | waitForSocket(t, ipc.BrowserLaunchSocket) | |
| 127 | 125 | ||
| 128 | - | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/fallback") | |
| 126 | + | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/new") | |
| 129 | 127 | ||
| 130 | 128 | require.NoError(t, err) | |
| 131 | 129 | assert.True(t, delivered) | |
| 132 | 130 | ||
| 133 | 131 | select { | |
| 134 | 132 | case got := <-received: | |
| 135 | - | assert.Equal(t, "https://example.com/fallback", got) | |
| 133 | + | assert.Equal(t, "https://example.com/new", got) | |
| 136 | 134 | case <-time.After(time.Second): | |
| 137 | 135 | t.Fatal("expected opener to receive the URL") | |
| 138 | 136 | } | |
| 139 | 137 | } | |
| 140 | 138 | ||
| 139 | + | func TestBrowserLaunchRelay_RequiresBrowserLaunchSocketPath(t *testing.T) { | |
| 140 | + | relay := NewBrowserLaunchRelay(runtimeprofile.IPCPaths{}) | |
| 141 | + | ||
| 142 | + | _, err := relay.Listen(context.Background(), browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | |
| 143 | + | require.Error(t, err) | |
| 144 | + | require.Contains(t, err.Error(), "browser launch socket path") | |
| 145 | + | } | |
| 146 | + | ||
| 141 | 147 | func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t *testing.T) { | |
| 142 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | |
| 143 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | |
| 148 | + | ipc := testIPC(shortTempDir(t)) | |
| 149 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 144 | 150 | ||
| 145 | 151 | firstReceived := make(chan string, 1) | |
| 146 | 152 | ctx, cancel := context.WithCancel(context.Background()) | |
| @@ -153,7 +159,7 @@ func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t | |||
| 153 | 159 | require.NoError(t, err) | |
| 154 | 160 | defer closer.Close() | |
| 155 | 161 | ||
| 156 | - | waitForSocket(t, filepath.Join(runtimeDir, "browser-launch.sock")) | |
| 162 | + | waitForSocket(t, ipc.BrowserLaunchSocket) | |
| 157 | 163 | ||
| 158 | 164 | _, err = relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | |
| 159 | 165 | require.Error(t, err) | |
| @@ -171,12 +177,11 @@ func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t | |||
| 171 | 177 | } | |
| 172 | 178 | ||
| 173 | 179 | func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) { | |
| 174 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | |
| 175 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | |
| 180 | + | ipc := testIPC(shortTempDir(t)) | |
| 181 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 176 | 182 | ||
| 177 | - | socketPath := filepath.Join(runtimeDir, "browser-launch.sock") | |
| 178 | - | require.NoError(t, os.MkdirAll(runtimeDir, 0o700)) | |
| 179 | - | require.NoError(t, os.WriteFile(socketPath, []byte("stale"), 0o600)) | |
| 183 | + | require.NoError(t, os.MkdirAll(filepath.Dir(ipc.BrowserLaunchSocket), 0o700)) | |
| 184 | + | require.NoError(t, os.WriteFile(ipc.BrowserLaunchSocket, []byte("stale"), 0o600)) | |
| 180 | 185 | ||
| 181 | 186 | received := make(chan string, 1) | |
| 182 | 187 | ctx, cancel := context.WithCancel(context.Background()) | |
| @@ -189,7 +194,7 @@ func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) { | |||
| 189 | 194 | require.NoError(t, err) | |
| 190 | 195 | defer closer.Close() | |
| 191 | 196 | ||
| 192 | - | waitForSocket(t, socketPath) | |
| 197 | + | waitForSocket(t, ipc.BrowserLaunchSocket) | |
| 193 | 198 | ||
| 194 | 199 | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/stale") | |
| 195 | 200 | require.NoError(t, err) | |
| @@ -204,8 +209,8 @@ func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) { | |||
| 204 | 209 | } | |
| 205 | 210 | ||
| 206 | 211 | func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) { | |
| 207 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | |
| 208 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | |
| 212 | + | ipc := testIPC(shortTempDir(t)) | |
| 213 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 209 | 214 | ||
| 210 | 215 | received := make(chan string, 1) | |
| 211 | 216 | ctx, cancel := context.WithCancel(context.Background()) | |
| @@ -218,10 +223,9 @@ func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) { | |||
| 218 | 223 | require.NoError(t, err) | |
| 219 | 224 | defer closer.Close() | |
| 220 | 225 | ||
| 221 | - | socketPath := filepath.Join(runtimeDir, "browser-launch.sock") | |
| 222 | - | waitForSocket(t, socketPath) | |
| 226 | + | waitForSocket(t, ipc.BrowserLaunchSocket) | |
| 223 | 227 | ||
| 224 | - | conn, err := net.Dial("unix", socketPath) | |
| 228 | + | conn, err := net.Dial("unix", ipc.BrowserLaunchSocket) | |
| 225 | 229 | require.NoError(t, err) | |
| 226 | 230 | ||
| 227 | 231 | _, err = conn.Write([]byte("not-json")) | |
| @@ -242,8 +246,8 @@ func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) { | |||
| 242 | 246 | } | |
| 243 | 247 | ||
| 244 | 248 | func TestBrowserLaunchRelay_CloseRemovesSocketPath(t *testing.T) { | |
| 245 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | |
| 246 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | |
| 249 | + | ipc := testIPC(shortTempDir(t)) | |
| 250 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 247 | 251 | ||
| 248 | 252 | ctx, cancel := context.WithCancel(context.Background()) | |
| 249 | 253 | defer cancel() | |
| @@ -251,26 +255,24 @@ func TestBrowserLaunchRelay_CloseRemovesSocketPath(t *testing.T) { | |||
| 251 | 255 | closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | |
| 252 | 256 | require.NoError(t, err) | |
| 253 | 257 | ||
| 254 | - | socketPath := filepath.Join(runtimeDir, "browser-launch.sock") | |
| 255 | - | waitForSocket(t, socketPath) | |
| 258 | + | waitForSocket(t, ipc.BrowserLaunchSocket) | |
| 256 | 259 | ||
| 257 | 260 | require.NoError(t, closer.Close()) | |
| 258 | - | waitForSocketGone(t, socketPath) | |
| 261 | + | waitForSocketGone(t, ipc.BrowserLaunchSocket) | |
| 259 | 262 | } | |
| 260 | 263 | ||
| 261 | 264 | func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) { | |
| 262 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | |
| 263 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | |
| 265 | + | ipc := testIPC(shortTempDir(t)) | |
| 266 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 264 | 267 | ||
| 265 | 268 | ctx, cancel := context.WithCancel(context.Background()) | |
| 266 | 269 | closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | |
| 267 | 270 | require.NoError(t, err) | |
| 268 | 271 | ||
| 269 | - | socketPath := filepath.Join(runtimeDir, "browser-launch.sock") | |
| 270 | - | waitForSocket(t, socketPath) | |
| 272 | + | waitForSocket(t, ipc.BrowserLaunchSocket) | |
| 271 | 273 | ||
| 272 | 274 | cancel() | |
| 273 | - | waitForSocketGone(t, socketPath) | |
| 275 | + | waitForSocketGone(t, ipc.BrowserLaunchSocket) | |
| 274 | 276 | require.NoError(t, closer.Close()) | |
| 275 | 277 | ||
| 276 | 278 | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/after-cancel") | |
| @@ -279,15 +281,14 @@ func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) { | |||
| 279 | 281 | } | |
| 280 | 282 | ||
| 281 | 283 | func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t *testing.T) { | |
| 282 | - | runtimeDir := shortTempDir(t) | |
| 283 | - | socketPath := filepath.Join(runtimeDir, "browser-launch.sock") | |
| 284 | - | require.NoError(t, os.MkdirAll(runtimeDir, 0o700)) | |
| 284 | + | ipc := testIPC(shortTempDir(t)) | |
| 285 | + | require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700)) | |
| 285 | 286 | ||
| 286 | - | listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"}) | |
| 287 | + | listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: ipc.BrowserLaunchSocket, Net: "unix"}) | |
| 287 | 288 | require.NoError(t, err) | |
| 288 | 289 | defer func() { | |
| 289 | 290 | _ = listener.Close() | |
| 290 | - | _ = os.Remove(socketPath) | |
| 291 | + | _ = os.Remove(ipc.BrowserLaunchSocket) | |
| 291 | 292 | }() | |
| 292 | 293 | ||
| 293 | 294 | ready := make(chan struct{}) | |
| @@ -302,7 +303,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t | |||
| 302 | 303 | }() | |
| 303 | 304 | <-ready | |
| 304 | 305 | ||
| 305 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | |
| 306 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 306 | 307 | ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) | |
| 307 | 308 | defer cancel() | |
| 308 | 309 | ||
| @@ -313,15 +314,14 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t | |||
| 313 | 314 | } | |
| 314 | 315 | ||
| 315 | 316 | func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsTrueOnNoDeadlineTimeout(t *testing.T) { | |
| 316 | - | runtimeDir := shortTempDir(t) | |
| 317 | - | socketPath := filepath.Join(runtimeDir, "browser-launch.sock") | |
| 318 | - | require.NoError(t, os.MkdirAll(runtimeDir, 0o700)) | |
| 317 | + | ipc := testIPC(shortTempDir(t)) | |
| 318 | + | require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700)) | |
| 319 | 319 | ||
| 320 | - | listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"}) | |
| 320 | + | listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: ipc.BrowserLaunchSocket, Net: "unix"}) | |
| 321 | 321 | require.NoError(t, err) | |
| 322 | 322 | defer func() { | |
| 323 | 323 | _ = listener.Close() | |
| 324 | - | _ = os.Remove(socketPath) | |
| 324 | + | _ = os.Remove(ipc.BrowserLaunchSocket) | |
| 325 | 325 | }() | |
| 326 | 326 | ||
| 327 | 327 | ready := make(chan struct{}) | |
| @@ -336,7 +336,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsTrueOnNoDeadlineTimeou | |||
| 336 | 336 | }() | |
| 337 | 337 | <-ready | |
| 338 | 338 | ||
| 339 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | |
| 339 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 340 | 340 | ||
| 341 | 341 | started := make(chan struct{}) | |
| 342 | 342 | ||
| @@ -362,8 +362,8 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsTrueOnNoDeadlineTimeou | |||
| 362 | 362 | } | |
| 363 | 363 | ||
| 364 | 364 | func TestBrowserLaunchRelay_AcknowledgesBeforeOpenerReturns(t *testing.T) { | |
| 365 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | |
| 366 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | |
| 365 | + | ipc := testIPC(shortTempDir(t)) | |
| 366 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 367 | 367 | ||
| 368 | 368 | started := make(chan struct{}) | |
| 369 | 369 | release := make(chan struct{}) | |
| @@ -378,9 +378,9 @@ func TestBrowserLaunchRelay_AcknowledgesBeforeOpenerReturns(t *testing.T) { | |||
| 378 | 378 | require.NoError(t, err) | |
| 379 | 379 | defer closer.Close() | |
| 380 | 380 | ||
| 381 | - | waitForSocket(t, filepath.Join(runtimeDir, "browser-launch.sock")) | |
| 381 | + | waitForSocket(t, ipc.BrowserLaunchSocket) | |
| 382 | 382 | ||
| 383 | - | conn, err := net.Dial("unix", filepath.Join(runtimeDir, "browser-launch.sock")) | |
| 383 | + | conn, err := net.Dial("unix", ipc.BrowserLaunchSocket) | |
| 384 | 384 | require.NoError(t, err) | |
| 385 | 385 | defer conn.Close() | |
| 386 | 386 | require.NoError(t, conn.SetDeadline(time.Now().Add(300*time.Millisecond))) | |
| @@ -399,8 +399,8 @@ func TestBrowserLaunchRelay_AcknowledgesBeforeOpenerReturns(t *testing.T) { | |||
| 399 | 399 | } | |
| 400 | 400 | ||
| 401 | 401 | func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) { | |
| 402 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | |
| 403 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | |
| 402 | + | ipc := testIPC(shortTempDir(t)) | |
| 403 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 404 | 404 | ||
| 405 | 405 | received := make(chan string, 1) | |
| 406 | 406 | ctx, cancel := context.WithCancel(context.Background()) | |
| @@ -413,10 +413,9 @@ func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) { | |||
| 413 | 413 | require.NoError(t, err) | |
| 414 | 414 | defer closer.Close() | |
| 415 | 415 | ||
| 416 | - | socketPath := filepath.Join(runtimeDir, "browser-launch.sock") | |
| 417 | - | waitForSocket(t, socketPath) | |
| 416 | + | waitForSocket(t, ipc.BrowserLaunchSocket) | |
| 418 | 417 | ||
| 419 | - | conn, err := net.Dial("unix", socketPath) | |
| 418 | + | conn, err := net.Dial("unix", ipc.BrowserLaunchSocket) | |
| 420 | 419 | require.NoError(t, err) | |
| 421 | 420 | defer conn.Close() | |
| 422 | 421 | time.Sleep(20 * time.Millisecond) | |
| @@ -9,29 +9,11 @@ import ( | |||||
| 9 | "testing" | 9 | "testing" | ||
| 10 | "time" | 10 | "time" | ||
| 11 | 11 | ||||
| 12 | - | "github.com/bnema/dumber/internal/application/port" | 12 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" |
| 13 | "github.com/stretchr/testify/assert" | 13 | "github.com/stretchr/testify/assert" | ||
| 14 | "github.com/stretchr/testify/require" | 14 | "github.com/stretchr/testify/require" | ||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | - | type testXDGPaths struct { | |||
| 18 | - | runtimeDir string | |||
| 19 | - | stateDir string | |||
| 20 | - | } | |||
| 21 | - | ||||
| 22 | - | var _ port.XDGPaths = testXDGPaths{} | |||
| 23 | - | ||||
| 24 | - | func (testXDGPaths) ConfigDir() (string, error) { return "", nil } | |||
| 25 | - | func (testXDGPaths) DataDir() (string, error) { return "", nil } | |||
| 26 | - | func (x testXDGPaths) StateDir() (string, error) { return x.stateDir, nil } | |||
| 27 | - | func (x testXDGPaths) RuntimeDir() (string, error) { return x.runtimeDir, nil } | |||
| 28 | - | func (testXDGPaths) CacheDir() (string, error) { return "", nil } | |||
| 29 | - | func (testXDGPaths) FilterJSONDir() (string, error) { return "", nil } | |||
| 30 | - | func (testXDGPaths) FilterStoreDir() (string, error) { return "", nil } | |||
| 31 | - | func (testXDGPaths) FilterCacheDir() (string, error) { return "", nil } | |||
| 32 | - | func (testXDGPaths) ManDir() (string, error) { return "", nil } | |||
| 33 | - | func (testXDGPaths) DownloadDir() (string, error) { return "", nil } | |||
| 34 | - | ||||
| 35 | type browserWindowOpenerFunc func(context.Context, string) error | 17 | type browserWindowOpenerFunc func(context.Context, string) error | ||
| 36 | 18 | ||||
| 37 | func (f browserWindowOpenerFunc) OpenFreshWindow(ctx context.Context, url string) error { | 19 | func (f browserWindowOpenerFunc) OpenFreshWindow(ctx context.Context, url string) error { | ||
| @@ -68,8 +50,24 @@ func shortTempDir(t *testing.T) string { | |||||
| 68 | return dir | 50 | return dir | ||
| 69 | } | 51 | } | ||
| 70 | 52 | ||||
| 53 | + | func testIPC(root string) runtimeprofile.IPCPaths { | |||
| 54 | + | runtimeDir := filepath.Join(root, "runtime") | |||
| 55 | + | return runtimeprofile.IPCPaths{ | |||
| 56 | + | RuntimeDir: runtimeDir, | |||
| 57 | + | BrowserLaunchSocket: filepath.Join(runtimeDir, browserLaunchSocketName), | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func testNamespacedIPC(root, engine string) runtimeprofile.IPCPaths { | |||
| 62 | + | runtimeDir := filepath.Join(root, "runtime", engine) | |||
| 63 | + | return runtimeprofile.IPCPaths{ | |||
| 64 | + | RuntimeDir: runtimeDir, | |||
| 65 | + | BrowserLaunchSocket: filepath.Join(runtimeDir, browserLaunchSocketName), | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 71 | func TestBrowserLaunchRelay_DeliverOpenFreshWindow_MissingListenerReturnsFalseNil(t *testing.T) { | 69 | func TestBrowserLaunchRelay_DeliverOpenFreshWindow_MissingListenerReturnsFalseNil(t *testing.T) { | ||
| 72 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: shortTempDir(t)}) | 70 | + | relay := NewBrowserLaunchRelay(testIPC(shortTempDir(t))) |
| 73 | 71 | ||||
| 74 | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com") | 72 | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com") | ||
| 75 | 73 | ||||
| @@ -77,39 +75,40 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_MissingListenerReturnsFalseNi | |||||
| 77 | assert.False(t, delivered) | 75 | assert.False(t, delivered) | ||
| 78 | } | 76 | } | ||
| 79 | 77 | ||||
| 80 | - | func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RoundTrip(t *testing.T) { | 78 | + | func TestBrowserLaunchRelay_SameProfileAndEngine_UsesSameSocket(t *testing.T) { |
| 81 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | 79 | + | ipc := testNamespacedIPC(shortTempDir(t), "cef") |
| 82 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | 80 | + | listenerRelay := NewBrowserLaunchRelay(ipc) |
| 81 | + | clientRelay := NewBrowserLaunchRelay(ipc) | |||
| 83 | 82 | ||||
| 84 | received := make(chan string, 1) | 83 | received := make(chan string, 1) | ||
| 85 | ctx, cancel := context.WithCancel(context.Background()) | 84 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 86 | defer cancel() | 85 | defer cancel() | ||
| 87 | 86 | ||||
| 88 | - | closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error { | 87 | + | closer, err := listenerRelay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error { |
| 89 | received <- url | 88 | received <- url | ||
| 90 | return nil | 89 | return nil | ||
| 91 | })) | 90 | })) | ||
| 92 | require.NoError(t, err) | 91 | require.NoError(t, err) | ||
| 93 | defer closer.Close() | 92 | defer closer.Close() | ||
| 94 | 93 | ||||
| 95 | - | waitForSocket(t, filepath.Join(runtimeDir, "browser-launch.sock")) | 94 | + | waitForSocket(t, ipc.BrowserLaunchSocket) |
| 96 | - | ||||
| 97 | - | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/new") | |||
| 98 | 95 | ||||
| 96 | + | delivered, err := clientRelay.DeliverOpenFreshWindow(context.Background(), "https://example.com/same-namespace") | |||
| 99 | require.NoError(t, err) | 97 | require.NoError(t, err) | ||
| 100 | - | assert.True(t, delivered) | 98 | + | require.True(t, delivered) |
| 99 | + | require.Equal(t, "https://example.com/same-namespace", <-received) | |||
| 100 | + | } | |||
| 101 | 101 | ||||
| 102 | - | select { | 102 | + | func TestBrowserLaunchRelay_DevCEFAndDevWebKit_UseDifferentSockets(t *testing.T) { |
| 103 | - | case got := <-received: | 103 | + | root := t.TempDir() |
| 104 | - | assert.Equal(t, "https://example.com/new", got) | 104 | + | cefIPC := testNamespacedIPC(root, "cef") |
| 105 | - | case <-time.After(time.Second): | 105 | + | wkIPC := testNamespacedIPC(root, "webkit") |
| 106 | - | t.Fatal("expected opener to receive the URL") | 106 | + | require.NotEqual(t, cefIPC.BrowserLaunchSocket, wkIPC.BrowserLaunchSocket) |
| 107 | - | } | |||
| 108 | } | 107 | } | ||
| 109 | 108 | ||||
| 110 | - | func TestBrowserLaunchRelay_UsesStateFallbackWhenRuntimeDirMissing(t *testing.T) { | 109 | + | func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RoundTrip(t *testing.T) { |
| 111 | - | stateDir := shortTempDir(t) | 110 | + | ipc := testIPC(shortTempDir(t)) |
| 112 | - | relay := NewBrowserLaunchRelay(testXDGPaths{stateDir: stateDir}) | 111 | + | relay := NewBrowserLaunchRelay(ipc) |
| 113 | 112 | ||||
| 114 | received := make(chan string, 1) | 113 | received := make(chan string, 1) | ||
| 115 | ctx, cancel := context.WithCancel(context.Background()) | 114 | ctx, cancel := context.WithCancel(context.Background()) | ||
| @@ -122,25 +121,32 @@ func TestBrowserLaunchRelay_UsesStateFallbackWhenRuntimeDirMissing(t *testing.T) | |||||
| 122 | require.NoError(t, err) | 121 | require.NoError(t, err) | ||
| 123 | defer closer.Close() | 122 | defer closer.Close() | ||
| 124 | 123 | ||||
| 125 | - | socketPath := filepath.Join(stateDir, "runtime", "browser-launch.sock") | 124 | + | waitForSocket(t, ipc.BrowserLaunchSocket) |
| 126 | - | waitForSocket(t, socketPath) | |||
| 127 | 125 | ||||
| 128 | - | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/fallback") | 126 | + | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/new") |
| 129 | 127 | ||||
| 130 | require.NoError(t, err) | 128 | require.NoError(t, err) | ||
| 131 | assert.True(t, delivered) | 129 | assert.True(t, delivered) | ||
| 132 | 130 | ||||
| 133 | select { | 131 | select { | ||
| 134 | case got := <-received: | 132 | case got := <-received: | ||
| 135 | - | assert.Equal(t, "https://example.com/fallback", got) | 133 | + | assert.Equal(t, "https://example.com/new", got) |
| 136 | case <-time.After(time.Second): | 134 | case <-time.After(time.Second): | ||
| 137 | t.Fatal("expected opener to receive the URL") | 135 | t.Fatal("expected opener to receive the URL") | ||
| 138 | } | 136 | } | ||
| 139 | } | 137 | } | ||
| 140 | 138 | ||||
| 139 | + | func TestBrowserLaunchRelay_RequiresBrowserLaunchSocketPath(t *testing.T) { | |||
| 140 | + | relay := NewBrowserLaunchRelay(runtimeprofile.IPCPaths{}) | |||
| 141 | + | ||||
| 142 | + | _, err := relay.Listen(context.Background(), browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | |||
| 143 | + | require.Error(t, err) | |||
| 144 | + | require.Contains(t, err.Error(), "browser launch socket path") | |||
| 145 | + | } | |||
| 146 | + | ||||
| 141 | func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t *testing.T) { | 147 | func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t *testing.T) { | ||
| 142 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | 148 | + | ipc := testIPC(shortTempDir(t)) |
| 143 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | 149 | + | relay := NewBrowserLaunchRelay(ipc) |
| 144 | 150 | ||||
| 145 | firstReceived := make(chan string, 1) | 151 | firstReceived := make(chan string, 1) | ||
| 146 | ctx, cancel := context.WithCancel(context.Background()) | 152 | ctx, cancel := context.WithCancel(context.Background()) | ||
| @@ -153,7 +159,7 @@ func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t | |||||
| 153 | require.NoError(t, err) | 159 | require.NoError(t, err) | ||
| 154 | defer closer.Close() | 160 | defer closer.Close() | ||
| 155 | 161 | ||||
| 156 | - | waitForSocket(t, filepath.Join(runtimeDir, "browser-launch.sock")) | 162 | + | waitForSocket(t, ipc.BrowserLaunchSocket) |
| 157 | 163 | ||||
| 158 | _, err = relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | 164 | _, err = relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | ||
| 159 | require.Error(t, err) | 165 | require.Error(t, err) | ||
| @@ -171,12 +177,11 @@ func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t | |||||
| 171 | } | 177 | } | ||
| 172 | 178 | ||||
| 173 | func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) { | 179 | func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) { | ||
| 174 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | 180 | + | ipc := testIPC(shortTempDir(t)) |
| 175 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | 181 | + | relay := NewBrowserLaunchRelay(ipc) |
| 176 | 182 | ||||
| 177 | - | socketPath := filepath.Join(runtimeDir, "browser-launch.sock") | 183 | + | require.NoError(t, os.MkdirAll(filepath.Dir(ipc.BrowserLaunchSocket), 0o700)) |
| 178 | - | require.NoError(t, os.MkdirAll(runtimeDir, 0o700)) | 184 | + | require.NoError(t, os.WriteFile(ipc.BrowserLaunchSocket, []byte("stale"), 0o600)) |
| 179 | - | require.NoError(t, os.WriteFile(socketPath, []byte("stale"), 0o600)) | |||
| 180 | 185 | ||||
| 181 | received := make(chan string, 1) | 186 | received := make(chan string, 1) | ||
| 182 | ctx, cancel := context.WithCancel(context.Background()) | 187 | ctx, cancel := context.WithCancel(context.Background()) | ||
| @@ -189,7 +194,7 @@ func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) { | |||||
| 189 | require.NoError(t, err) | 194 | require.NoError(t, err) | ||
| 190 | defer closer.Close() | 195 | defer closer.Close() | ||
| 191 | 196 | ||||
| 192 | - | waitForSocket(t, socketPath) | 197 | + | waitForSocket(t, ipc.BrowserLaunchSocket) |
| 193 | 198 | ||||
| 194 | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/stale") | 199 | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/stale") | ||
| 195 | require.NoError(t, err) | 200 | require.NoError(t, err) | ||
| @@ -204,8 +209,8 @@ func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) { | |||||
| 204 | } | 209 | } | ||
| 205 | 210 | ||||
| 206 | func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) { | 211 | func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) { | ||
| 207 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | 212 | + | ipc := testIPC(shortTempDir(t)) |
| 208 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | 213 | + | relay := NewBrowserLaunchRelay(ipc) |
| 209 | 214 | ||||
| 210 | received := make(chan string, 1) | 215 | received := make(chan string, 1) | ||
| 211 | ctx, cancel := context.WithCancel(context.Background()) | 216 | ctx, cancel := context.WithCancel(context.Background()) | ||
| @@ -218,10 +223,9 @@ func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) { | |||||
| 218 | require.NoError(t, err) | 223 | require.NoError(t, err) | ||
| 219 | defer closer.Close() | 224 | defer closer.Close() | ||
| 220 | 225 | ||||
| 221 | - | socketPath := filepath.Join(runtimeDir, "browser-launch.sock") | 226 | + | waitForSocket(t, ipc.BrowserLaunchSocket) |
| 222 | - | waitForSocket(t, socketPath) | |||
| 223 | 227 | ||||
| 224 | - | conn, err := net.Dial("unix", socketPath) | 228 | + | conn, err := net.Dial("unix", ipc.BrowserLaunchSocket) |
| 225 | require.NoError(t, err) | 229 | require.NoError(t, err) | ||
| 226 | 230 | ||||
| 227 | _, err = conn.Write([]byte("not-json")) | 231 | _, err = conn.Write([]byte("not-json")) | ||
| @@ -242,8 +246,8 @@ func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) { | |||||
| 242 | } | 246 | } | ||
| 243 | 247 | ||||
| 244 | func TestBrowserLaunchRelay_CloseRemovesSocketPath(t *testing.T) { | 248 | func TestBrowserLaunchRelay_CloseRemovesSocketPath(t *testing.T) { | ||
| 245 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | 249 | + | ipc := testIPC(shortTempDir(t)) |
| 246 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | 250 | + | relay := NewBrowserLaunchRelay(ipc) |
| 247 | 251 | ||||
| 248 | ctx, cancel := context.WithCancel(context.Background()) | 252 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 249 | defer cancel() | 253 | defer cancel() | ||
| @@ -251,26 +255,24 @@ func TestBrowserLaunchRelay_CloseRemovesSocketPath(t *testing.T) { | |||||
| 251 | closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | 255 | closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | ||
| 252 | require.NoError(t, err) | 256 | require.NoError(t, err) | ||
| 253 | 257 | ||||
| 254 | - | socketPath := filepath.Join(runtimeDir, "browser-launch.sock") | 258 | + | waitForSocket(t, ipc.BrowserLaunchSocket) |
| 255 | - | waitForSocket(t, socketPath) | |||
| 256 | 259 | ||||
| 257 | require.NoError(t, closer.Close()) | 260 | require.NoError(t, closer.Close()) | ||
| 258 | - | waitForSocketGone(t, socketPath) | 261 | + | waitForSocketGone(t, ipc.BrowserLaunchSocket) |
| 259 | } | 262 | } | ||
| 260 | 263 | ||||
| 261 | func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) { | 264 | func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) { | ||
| 262 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | 265 | + | ipc := testIPC(shortTempDir(t)) |
| 263 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | 266 | + | relay := NewBrowserLaunchRelay(ipc) |
| 264 | 267 | ||||
| 265 | ctx, cancel := context.WithCancel(context.Background()) | 268 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 266 | closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | 269 | closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | ||
| 267 | require.NoError(t, err) | 270 | require.NoError(t, err) | ||
| 268 | 271 | ||||
| 269 | - | socketPath := filepath.Join(runtimeDir, "browser-launch.sock") | 272 | + | waitForSocket(t, ipc.BrowserLaunchSocket) |
| 270 | - | waitForSocket(t, socketPath) | |||
| 271 | 273 | ||||
| 272 | cancel() | 274 | cancel() | ||
| 273 | - | waitForSocketGone(t, socketPath) | 275 | + | waitForSocketGone(t, ipc.BrowserLaunchSocket) |
| 274 | require.NoError(t, closer.Close()) | 276 | require.NoError(t, closer.Close()) | ||
| 275 | 277 | ||||
| 276 | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/after-cancel") | 278 | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/after-cancel") | ||
| @@ -279,15 +281,14 @@ func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) { | |||||
| 279 | } | 281 | } | ||
| 280 | 282 | ||||
| 281 | func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t *testing.T) { | 283 | func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t *testing.T) { | ||
| 282 | - | runtimeDir := shortTempDir(t) | 284 | + | ipc := testIPC(shortTempDir(t)) |
| 283 | - | socketPath := filepath.Join(runtimeDir, "browser-launch.sock") | 285 | + | require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700)) |
| 284 | - | require.NoError(t, os.MkdirAll(runtimeDir, 0o700)) | |||
| 285 | 286 | ||||
| 286 | - | listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"}) | 287 | + | listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: ipc.BrowserLaunchSocket, Net: "unix"}) |
| 287 | require.NoError(t, err) | 288 | require.NoError(t, err) | ||
| 288 | defer func() { | 289 | defer func() { | ||
| 289 | _ = listener.Close() | 290 | _ = listener.Close() | ||
| 290 | - | _ = os.Remove(socketPath) | 291 | + | _ = os.Remove(ipc.BrowserLaunchSocket) |
| 291 | }() | 292 | }() | ||
| 292 | 293 | ||||
| 293 | ready := make(chan struct{}) | 294 | ready := make(chan struct{}) | ||
| @@ -302,7 +303,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t | |||||
| 302 | }() | 303 | }() | ||
| 303 | <-ready | 304 | <-ready | ||
| 304 | 305 | ||||
| 305 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | 306 | + | relay := NewBrowserLaunchRelay(ipc) |
| 306 | ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) | 307 | ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) | ||
| 307 | defer cancel() | 308 | defer cancel() | ||
| 308 | 309 | ||||
| @@ -313,15 +314,14 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t | |||||
| 313 | } | 314 | } | ||
| 314 | 315 | ||||
| 315 | func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsTrueOnNoDeadlineTimeout(t *testing.T) { | 316 | func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsTrueOnNoDeadlineTimeout(t *testing.T) { | ||
| 316 | - | runtimeDir := shortTempDir(t) | 317 | + | ipc := testIPC(shortTempDir(t)) |
| 317 | - | socketPath := filepath.Join(runtimeDir, "browser-launch.sock") | 318 | + | require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700)) |
| 318 | - | require.NoError(t, os.MkdirAll(runtimeDir, 0o700)) | |||
| 319 | 319 | ||||
| 320 | - | listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"}) | 320 | + | listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: ipc.BrowserLaunchSocket, Net: "unix"}) |
| 321 | require.NoError(t, err) | 321 | require.NoError(t, err) | ||
| 322 | defer func() { | 322 | defer func() { | ||
| 323 | _ = listener.Close() | 323 | _ = listener.Close() | ||
| 324 | - | _ = os.Remove(socketPath) | 324 | + | _ = os.Remove(ipc.BrowserLaunchSocket) |
| 325 | }() | 325 | }() | ||
| 326 | 326 | ||||
| 327 | ready := make(chan struct{}) | 327 | ready := make(chan struct{}) | ||
| @@ -336,7 +336,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsTrueOnNoDeadlineTimeou | |||||
| 336 | }() | 336 | }() | ||
| 337 | <-ready | 337 | <-ready | ||
| 338 | 338 | ||||
| 339 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | 339 | + | relay := NewBrowserLaunchRelay(ipc) |
| 340 | 340 | ||||
| 341 | started := make(chan struct{}) | 341 | started := make(chan struct{}) | ||
| 342 | 342 | ||||
| @@ -362,8 +362,8 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsTrueOnNoDeadlineTimeou | |||||
| 362 | } | 362 | } | ||
| 363 | 363 | ||||
| 364 | func TestBrowserLaunchRelay_AcknowledgesBeforeOpenerReturns(t *testing.T) { | 364 | func TestBrowserLaunchRelay_AcknowledgesBeforeOpenerReturns(t *testing.T) { | ||
| 365 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | 365 | + | ipc := testIPC(shortTempDir(t)) |
| 366 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | 366 | + | relay := NewBrowserLaunchRelay(ipc) |
| 367 | 367 | ||||
| 368 | started := make(chan struct{}) | 368 | started := make(chan struct{}) | ||
| 369 | release := make(chan struct{}) | 369 | release := make(chan struct{}) | ||
| @@ -378,9 +378,9 @@ func TestBrowserLaunchRelay_AcknowledgesBeforeOpenerReturns(t *testing.T) { | |||||
| 378 | require.NoError(t, err) | 378 | require.NoError(t, err) | ||
| 379 | defer closer.Close() | 379 | defer closer.Close() | ||
| 380 | 380 | ||||
| 381 | - | waitForSocket(t, filepath.Join(runtimeDir, "browser-launch.sock")) | 381 | + | waitForSocket(t, ipc.BrowserLaunchSocket) |
| 382 | 382 | ||||
| 383 | - | conn, err := net.Dial("unix", filepath.Join(runtimeDir, "browser-launch.sock")) | 383 | + | conn, err := net.Dial("unix", ipc.BrowserLaunchSocket) |
| 384 | require.NoError(t, err) | 384 | require.NoError(t, err) | ||
| 385 | defer conn.Close() | 385 | defer conn.Close() | ||
| 386 | require.NoError(t, conn.SetDeadline(time.Now().Add(300*time.Millisecond))) | 386 | require.NoError(t, conn.SetDeadline(time.Now().Add(300*time.Millisecond))) | ||
| @@ -399,8 +399,8 @@ func TestBrowserLaunchRelay_AcknowledgesBeforeOpenerReturns(t *testing.T) { | |||||
| 399 | } | 399 | } | ||
| 400 | 400 | ||||
| 401 | func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) { | 401 | func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) { | ||
| 402 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | 402 | + | ipc := testIPC(shortTempDir(t)) |
| 403 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | 403 | + | relay := NewBrowserLaunchRelay(ipc) |
| 404 | 404 | ||||
| 405 | received := make(chan string, 1) | 405 | received := make(chan string, 1) | ||
| 406 | ctx, cancel := context.WithCancel(context.Background()) | 406 | ctx, cancel := context.WithCancel(context.Background()) | ||
| @@ -413,10 +413,9 @@ func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) { | |||||
| 413 | require.NoError(t, err) | 413 | require.NoError(t, err) | ||
| 414 | defer closer.Close() | 414 | defer closer.Close() | ||
| 415 | 415 | ||||
| 416 | - | socketPath := filepath.Join(runtimeDir, "browser-launch.sock") | 416 | + | waitForSocket(t, ipc.BrowserLaunchSocket) |
| 417 | - | waitForSocket(t, socketPath) | |||
| 418 | 417 | ||||
| 419 | - | conn, err := net.Dial("unix", socketPath) | 418 | + | conn, err := net.Dial("unix", ipc.BrowserLaunchSocket) |
| 420 | require.NoError(t, err) | 419 | require.NoError(t, err) | ||
| 421 | defer conn.Close() | 420 | defer conn.Close() | ||
| 422 | time.Sleep(20 * time.Millisecond) | 421 | time.Sleep(20 * time.Millisecond) | ||
M internal/infrastructure/handlers/clipboard_test.go +11 -30
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 12 | 13 | type recordingClipboardOrchestrator struct { | |
| @@ -35,21 +36,13 @@ func TestClipboardHandler_HandleAutoCopySelection_DelegatesToOrchestrator(t *tes | |||
| 35 | 36 | handler := NewClipboardHandler(orchestrator) | |
| 36 | 37 | ||
| 37 | 38 | _, err := handler.HandleAutoCopySelection().Handle(ctx, 42, json.RawMessage(`{"text":"selected text"}`)) | |
| 38 | - | if err != nil { | |
| 39 | - | t.Fatalf("Handle() error = %v, want nil", err) | |
| 40 | - | } | |
| 39 | + | require.NoError(t, err) | |
| 41 | 40 | ||
| 42 | 41 | orchestrator.mu.Lock() | |
| 43 | 42 | defer orchestrator.mu.Unlock() | |
| 44 | - | if orchestrator.selection.Text != "selected text" { | |
| 45 | - | t.Fatalf("selection text = %q, want %q", orchestrator.selection.Text, "selected text") | |
| 46 | - | } | |
| 47 | - | if orchestrator.selection.SourceEngine != port.ClipboardSourceWebKit { | |
| 48 | - | t.Fatalf("selection source = %q, want %q", orchestrator.selection.SourceEngine, port.ClipboardSourceWebKit) | |
| 49 | - | } | |
| 50 | - | if orchestrator.selection.ViewID != 42 { | |
| 51 | - | t.Fatalf("selection view id = %d, want %d", orchestrator.selection.ViewID, 42) | |
| 52 | - | } | |
| 43 | + | require.Equal(t, "selected text", orchestrator.selection.Text) | |
| 44 | + | require.Equal(t, port.ClipboardSourceWebKit, orchestrator.selection.SourceEngine) | |
| 45 | + | require.Equal(t, port.WebViewID(42), orchestrator.selection.ViewID) | |
| 53 | 46 | } | |
| 54 | 47 | ||
| 55 | 48 | func TestClipboardHandler_HandleExplicitCopy_DelegatesToOrchestrator(t *testing.T) { | |
| @@ -58,25 +51,13 @@ func TestClipboardHandler_HandleExplicitCopy_DelegatesToOrchestrator(t *testing. | |||
| 58 | 51 | handler := NewClipboardHandler(orchestrator) | |
| 59 | 52 | ||
| 60 | 53 | _, err := handler.HandleExplicitCopy().Handle(ctx, 42, json.RawMessage(`{"text":"copied text","action":"copy"}`)) | |
| 61 | - | if err != nil { | |
| 62 | - | t.Fatalf("Handle() error = %v, want nil", err) | |
| 63 | - | } | |
| 54 | + | require.NoError(t, err) | |
| 64 | 55 | ||
| 65 | 56 | orchestrator.mu.Lock() | |
| 66 | 57 | defer orchestrator.mu.Unlock() | |
| 67 | - | if orchestrator.explicit.Text != "copied text" { | |
| 68 | - | t.Fatalf("explicit text = %q, want %q", orchestrator.explicit.Text, "copied text") | |
| 69 | - | } | |
| 70 | - | if orchestrator.explicit.Action != "copy" { | |
| 71 | - | t.Fatalf("explicit action = %q, want %q", orchestrator.explicit.Action, "copy") | |
| 72 | - | } | |
| 73 | - | if orchestrator.explicit.SourceEngine != port.ClipboardSourceWebKit { | |
| 74 | - | t.Fatalf("explicit source = %q, want %q", orchestrator.explicit.SourceEngine, port.ClipboardSourceWebKit) | |
| 75 | - | } | |
| 76 | - | if orchestrator.explicit.ViewID != 42 { | |
| 77 | - | t.Fatalf("explicit view id = %d, want %d", orchestrator.explicit.ViewID, 42) | |
| 78 | - | } | |
| 79 | - | if !orchestrator.explicit.NativeHandled { | |
| 80 | - | t.Fatal("explicit native handled = false, want true") | |
| 81 | - | } | |
| 58 | + | require.Equal(t, "copied text", orchestrator.explicit.Text) | |
| 59 | + | require.Equal(t, "copy", orchestrator.explicit.Action) | |
| 60 | + | require.Equal(t, port.ClipboardSourceWebKit, orchestrator.explicit.SourceEngine) | |
| 61 | + | require.Equal(t, port.WebViewID(42), orchestrator.explicit.ViewID) | |
| 62 | + | require.True(t, orchestrator.explicit.NativeHandled) | |
| 82 | 63 | } | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "testing" | 7 | "testing" | ||
| 8 | 8 | ||||
| 9 | "github.com/bnema/dumber/internal/application/port" | 9 | "github.com/bnema/dumber/internal/application/port" | ||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| 12 | type recordingClipboardOrchestrator struct { | 13 | type recordingClipboardOrchestrator struct { | ||
| @@ -35,21 +36,13 @@ func TestClipboardHandler_HandleAutoCopySelection_DelegatesToOrchestrator(t *tes | |||||
| 35 | handler := NewClipboardHandler(orchestrator) | 36 | handler := NewClipboardHandler(orchestrator) | ||
| 36 | 37 | ||||
| 37 | _, err := handler.HandleAutoCopySelection().Handle(ctx, 42, json.RawMessage(`{"text":"selected text"}`)) | 38 | _, err := handler.HandleAutoCopySelection().Handle(ctx, 42, json.RawMessage(`{"text":"selected text"}`)) | ||
| 38 | - | if err != nil { | 39 | + | require.NoError(t, err) |
| 39 | - | t.Fatalf("Handle() error = %v, want nil", err) | |||
| 40 | - | } | |||
| 41 | 40 | ||||
| 42 | orchestrator.mu.Lock() | 41 | orchestrator.mu.Lock() | ||
| 43 | defer orchestrator.mu.Unlock() | 42 | defer orchestrator.mu.Unlock() | ||
| 44 | - | if orchestrator.selection.Text != "selected text" { | 43 | + | require.Equal(t, "selected text", orchestrator.selection.Text) |
| 45 | - | t.Fatalf("selection text = %q, want %q", orchestrator.selection.Text, "selected text") | 44 | + | require.Equal(t, port.ClipboardSourceWebKit, orchestrator.selection.SourceEngine) |
| 46 | - | } | 45 | + | require.Equal(t, port.WebViewID(42), orchestrator.selection.ViewID) |
| 47 | - | if orchestrator.selection.SourceEngine != port.ClipboardSourceWebKit { | |||
| 48 | - | t.Fatalf("selection source = %q, want %q", orchestrator.selection.SourceEngine, port.ClipboardSourceWebKit) | |||
| 49 | - | } | |||
| 50 | - | if orchestrator.selection.ViewID != 42 { | |||
| 51 | - | t.Fatalf("selection view id = %d, want %d", orchestrator.selection.ViewID, 42) | |||
| 52 | - | } | |||
| 53 | } | 46 | } | ||
| 54 | 47 | ||||
| 55 | func TestClipboardHandler_HandleExplicitCopy_DelegatesToOrchestrator(t *testing.T) { | 48 | func TestClipboardHandler_HandleExplicitCopy_DelegatesToOrchestrator(t *testing.T) { | ||
| @@ -58,25 +51,13 @@ func TestClipboardHandler_HandleExplicitCopy_DelegatesToOrchestrator(t *testing. | |||||
| 58 | handler := NewClipboardHandler(orchestrator) | 51 | handler := NewClipboardHandler(orchestrator) | ||
| 59 | 52 | ||||
| 60 | _, err := handler.HandleExplicitCopy().Handle(ctx, 42, json.RawMessage(`{"text":"copied text","action":"copy"}`)) | 53 | _, err := handler.HandleExplicitCopy().Handle(ctx, 42, json.RawMessage(`{"text":"copied text","action":"copy"}`)) | ||
| 61 | - | if err != nil { | 54 | + | require.NoError(t, err) |
| 62 | - | t.Fatalf("Handle() error = %v, want nil", err) | |||
| 63 | - | } | |||
| 64 | 55 | ||||
| 65 | orchestrator.mu.Lock() | 56 | orchestrator.mu.Lock() | ||
| 66 | defer orchestrator.mu.Unlock() | 57 | defer orchestrator.mu.Unlock() | ||
| 67 | - | if orchestrator.explicit.Text != "copied text" { | 58 | + | require.Equal(t, "copied text", orchestrator.explicit.Text) |
| 68 | - | t.Fatalf("explicit text = %q, want %q", orchestrator.explicit.Text, "copied text") | 59 | + | require.Equal(t, "copy", orchestrator.explicit.Action) |
| 69 | - | } | 60 | + | require.Equal(t, port.ClipboardSourceWebKit, orchestrator.explicit.SourceEngine) |
| 70 | - | if orchestrator.explicit.Action != "copy" { | 61 | + | require.Equal(t, port.WebViewID(42), orchestrator.explicit.ViewID) |
| 71 | - | t.Fatalf("explicit action = %q, want %q", orchestrator.explicit.Action, "copy") | 62 | + | require.True(t, orchestrator.explicit.NativeHandled) |
| 72 | - | } | |||
| 73 | - | if orchestrator.explicit.SourceEngine != port.ClipboardSourceWebKit { | |||
| 74 | - | t.Fatalf("explicit source = %q, want %q", orchestrator.explicit.SourceEngine, port.ClipboardSourceWebKit) | |||
| 75 | - | } | |||
| 76 | - | if orchestrator.explicit.ViewID != 42 { | |||
| 77 | - | t.Fatalf("explicit view id = %d, want %d", orchestrator.explicit.ViewID, 42) | |||
| 78 | - | } | |||
| 79 | - | if !orchestrator.explicit.NativeHandled { | |||
| 80 | - | t.Fatal("explicit native handled = false, want true") | |||
| 81 | - | } | |||
| 82 | } | 63 | } | ||
A internal/infrastructure/runtimeprofile/profile.go +193 -0
| @@ -0,0 +1,193 @@ | |||
| 1 | + | package runtimeprofile | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "path/filepath" | |
| 6 | + | "strings" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | const ( | |
| 10 | + | browserLaunchSocketName = "browser-launch.sock" | |
| 11 | + | engineWebKit = "webkit" | |
| 12 | + | engineCEF = "cef" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // Mode identifies the execution sandbox mode. | |
| 16 | + | type Mode string | |
| 17 | + | ||
| 18 | + | const ( | |
| 19 | + | ModeProd Mode = "prod" | |
| 20 | + | ModeDev Mode = "dev" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // BasePaths holds the already-resolved dumber XDG homes used in prod. | |
| 24 | + | type BasePaths struct { | |
| 25 | + | ConfigHome string | |
| 26 | + | DataHome string | |
| 27 | + | StateHome string | |
| 28 | + | CacheHome string | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // ResolveInput contains the external inputs needed to resolve a runtime profile. | |
| 32 | + | type ResolveInput struct { | |
| 33 | + | Env func(string) string | |
| 34 | + | CWD func() (string, error) | |
| 35 | + | Engine string | |
| 36 | + | Base BasePaths | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // SharedPaths are shared by the whole runtime sandbox. | |
| 40 | + | type SharedPaths struct { | |
| 41 | + | // RootDir is only populated when the shared sandbox has a single root | |
| 42 | + | // directory (for example ENV=dev under .dev/dumber). It is intentionally | |
| 43 | + | // empty in prod, where shared paths come from distinct XDG homes. | |
| 44 | + | RootDir string | |
| 45 | + | ConfigDir string | |
| 46 | + | DataDir string | |
| 47 | + | StateDir string | |
| 48 | + | CacheDir string | |
| 49 | + | LogDir string | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // EnginePaths are engine-specific technical paths. | |
| 53 | + | type EnginePaths struct { | |
| 54 | + | Name string | |
| 55 | + | RootDir string | |
| 56 | + | RuntimeDir string | |
| 57 | + | LogDir string | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // IPCPaths are runtime coordination paths. | |
| 61 | + | type IPCPaths struct { | |
| 62 | + | RuntimeDir string | |
| 63 | + | BrowserLaunchSocket string | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Profile is the fully resolved runtime profile for one mode+engine namespace. | |
| 67 | + | type Profile struct { | |
| 68 | + | Mode Mode | |
| 69 | + | Engine string | |
| 70 | + | Shared SharedPaths | |
| 71 | + | EnginePaths EnginePaths | |
| 72 | + | IPC IPCPaths | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // Resolve builds a runtime profile from pure inputs. | |
| 76 | + | func Resolve(input ResolveInput) (Profile, error) { | |
| 77 | + | engine := normalizeEngine(input.Engine) | |
| 78 | + | if envValue(input.Env, "ENV") == string(ModeDev) { | |
| 79 | + | cwd, err := cwdValue(input.CWD) | |
| 80 | + | if err != nil { | |
| 81 | + | return Profile{}, fmt.Errorf("resolve cwd: %w", err) | |
| 82 | + | } | |
| 83 | + | root := filepath.Join(cwd, ".dev", "dumber") | |
| 84 | + | engineRoot := filepath.Join(root, "engines", engine) | |
| 85 | + | ipcRoot := filepath.Join(root, "runtime", engine) | |
| 86 | + | return Profile{ | |
| 87 | + | Mode: ModeDev, | |
| 88 | + | Engine: engine, | |
| 89 | + | Shared: SharedPaths{ | |
| 90 | + | RootDir: root, | |
| 91 | + | ConfigDir: filepath.Join(root, "config"), | |
| 92 | + | DataDir: filepath.Join(root, "data"), | |
| 93 | + | StateDir: filepath.Join(root, "state"), | |
| 94 | + | CacheDir: filepath.Join(root, "cache"), | |
| 95 | + | LogDir: filepath.Join(root, "logs"), | |
| 96 | + | }, | |
| 97 | + | EnginePaths: EnginePaths{ | |
| 98 | + | Name: engine, | |
| 99 | + | RootDir: engineRoot, | |
| 100 | + | RuntimeDir: filepath.Join(engineRoot, "runtime"), | |
| 101 | + | LogDir: filepath.Join(engineRoot, "logs"), | |
| 102 | + | }, | |
| 103 | + | IPC: IPCPaths{ | |
| 104 | + | RuntimeDir: ipcRoot, | |
| 105 | + | BrowserLaunchSocket: filepath.Join(ipcRoot, browserLaunchSocketName), | |
| 106 | + | }, | |
| 107 | + | }, nil | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | shared := SharedPaths{ | |
| 111 | + | ConfigDir: input.Base.ConfigHome, | |
| 112 | + | DataDir: input.Base.DataHome, | |
| 113 | + | StateDir: input.Base.StateHome, | |
| 114 | + | CacheDir: input.Base.CacheHome, | |
| 115 | + | LogDir: filepath.Join(input.Base.StateHome, "logs"), | |
| 116 | + | } | |
| 117 | + | engineRoot := filepath.Join(shared.StateDir, "engines", engine) | |
| 118 | + | ipcRoot := filepath.Join(shared.StateDir, "runtime", engine) | |
| 119 | + | return Profile{ | |
| 120 | + | Mode: ModeProd, | |
| 121 | + | Engine: engine, | |
| 122 | + | Shared: shared, | |
| 123 | + | EnginePaths: EnginePaths{ | |
| 124 | + | Name: engine, | |
| 125 | + | RootDir: engineRoot, | |
| 126 | + | RuntimeDir: filepath.Join(engineRoot, "runtime"), | |
| 127 | + | LogDir: filepath.Join(engineRoot, "logs"), | |
| 128 | + | }, | |
| 129 | + | IPC: IPCPaths{ | |
| 130 | + | RuntimeDir: ipcRoot, | |
| 131 | + | BrowserLaunchSocket: filepath.Join(ipcRoot, browserLaunchSocketName), | |
| 132 | + | }, | |
| 133 | + | }, nil | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // normalizeEngine trims and lowercases the runtime engine name. | |
| 137 | + | // Supported engines are "webkit" and "cef"; any other value falls back to | |
| 138 | + | // the default "webkit" runtime namespace. | |
| 139 | + | func normalizeEngine(engine string) string { | |
| 140 | + | switch normalized := strings.ToLower(strings.TrimSpace(engine)); normalized { | |
| 141 | + | case "", engineWebKit: | |
| 142 | + | return engineWebKit | |
| 143 | + | case engineCEF: | |
| 144 | + | return engineCEF | |
| 145 | + | default: | |
| 146 | + | return engineWebKit | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func envValue(get func(string) string, key string) string { | |
| 151 | + | if get == nil { | |
| 152 | + | return "" | |
| 153 | + | } | |
| 154 | + | return get(key) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func cwdValue(get func() (string, error)) (string, error) { | |
| 158 | + | if get == nil { | |
| 159 | + | return "", fmt.Errorf("cwd getter is nil") | |
| 160 | + | } | |
| 161 | + | return get() | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // CEFUserDataDir returns the resolved CEF root cache/user-data directory. | |
| 165 | + | func (p Profile) CEFUserDataDir() string { | |
| 166 | + | if p.Mode == ModeDev { | |
| 167 | + | return filepath.Join(p.EnginePaths.RootDir, "data") | |
| 168 | + | } | |
| 169 | + | return filepath.Join(p.Shared.DataDir, "cef_user_data") | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // CEFLogFile returns the resolved default CEF runtime log path. | |
| 173 | + | func (p Profile) CEFLogFile() string { | |
| 174 | + | return filepath.Join(p.EnginePaths.LogDir, "cef_runtime.log") | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // WebKitDataDir returns the resolved WebKit website data directory. | |
| 178 | + | func (p Profile) WebKitDataDir() string { | |
| 179 | + | if p.Mode == ModeDev { | |
| 180 | + | return filepath.Join(p.EnginePaths.RootDir, "data") | |
| 181 | + | } | |
| 182 | + | return p.Shared.DataDir | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // WebKitCacheDir returns the resolved WebKit cache directory. | |
| 186 | + | func (p Profile) WebKitCacheDir() string { | |
| 187 | + | if p.Mode == ModeDev { | |
| 188 | + | return filepath.Join(p.EnginePaths.RootDir, "cache") | |
| 189 | + | } | |
| 190 | + | // Keep the current prod layout under state for non-user-managed technical | |
| 191 | + | // engine cache data; this intentionally preserves existing WebKit behavior. | |
| 192 | + | return filepath.Join(p.Shared.StateDir, "webkit-cache") | |
| 193 | + | } | |
| @@ -0,0 +1,193 @@ | |||||
| 1 | + | package runtimeprofile | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "strings" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | const ( | |||
| 10 | + | browserLaunchSocketName = "browser-launch.sock" | |||
| 11 | + | engineWebKit = "webkit" | |||
| 12 | + | engineCEF = "cef" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // Mode identifies the execution sandbox mode. | |||
| 16 | + | type Mode string | |||
| 17 | + | ||||
| 18 | + | const ( | |||
| 19 | + | ModeProd Mode = "prod" | |||
| 20 | + | ModeDev Mode = "dev" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // BasePaths holds the already-resolved dumber XDG homes used in prod. | |||
| 24 | + | type BasePaths struct { | |||
| 25 | + | ConfigHome string | |||
| 26 | + | DataHome string | |||
| 27 | + | StateHome string | |||
| 28 | + | CacheHome string | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // ResolveInput contains the external inputs needed to resolve a runtime profile. | |||
| 32 | + | type ResolveInput struct { | |||
| 33 | + | Env func(string) string | |||
| 34 | + | CWD func() (string, error) | |||
| 35 | + | Engine string | |||
| 36 | + | Base BasePaths | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // SharedPaths are shared by the whole runtime sandbox. | |||
| 40 | + | type SharedPaths struct { | |||
| 41 | + | // RootDir is only populated when the shared sandbox has a single root | |||
| 42 | + | // directory (for example ENV=dev under .dev/dumber). It is intentionally | |||
| 43 | + | // empty in prod, where shared paths come from distinct XDG homes. | |||
| 44 | + | RootDir string | |||
| 45 | + | ConfigDir string | |||
| 46 | + | DataDir string | |||
| 47 | + | StateDir string | |||
| 48 | + | CacheDir string | |||
| 49 | + | LogDir string | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // EnginePaths are engine-specific technical paths. | |||
| 53 | + | type EnginePaths struct { | |||
| 54 | + | Name string | |||
| 55 | + | RootDir string | |||
| 56 | + | RuntimeDir string | |||
| 57 | + | LogDir string | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // IPCPaths are runtime coordination paths. | |||
| 61 | + | type IPCPaths struct { | |||
| 62 | + | RuntimeDir string | |||
| 63 | + | BrowserLaunchSocket string | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // Profile is the fully resolved runtime profile for one mode+engine namespace. | |||
| 67 | + | type Profile struct { | |||
| 68 | + | Mode Mode | |||
| 69 | + | Engine string | |||
| 70 | + | Shared SharedPaths | |||
| 71 | + | EnginePaths EnginePaths | |||
| 72 | + | IPC IPCPaths | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // Resolve builds a runtime profile from pure inputs. | |||
| 76 | + | func Resolve(input ResolveInput) (Profile, error) { | |||
| 77 | + | engine := normalizeEngine(input.Engine) | |||
| 78 | + | if envValue(input.Env, "ENV") == string(ModeDev) { | |||
| 79 | + | cwd, err := cwdValue(input.CWD) | |||
| 80 | + | if err != nil { | |||
| 81 | + | return Profile{}, fmt.Errorf("resolve cwd: %w", err) | |||
| 82 | + | } | |||
| 83 | + | root := filepath.Join(cwd, ".dev", "dumber") | |||
| 84 | + | engineRoot := filepath.Join(root, "engines", engine) | |||
| 85 | + | ipcRoot := filepath.Join(root, "runtime", engine) | |||
| 86 | + | return Profile{ | |||
| 87 | + | Mode: ModeDev, | |||
| 88 | + | Engine: engine, | |||
| 89 | + | Shared: SharedPaths{ | |||
| 90 | + | RootDir: root, | |||
| 91 | + | ConfigDir: filepath.Join(root, "config"), | |||
| 92 | + | DataDir: filepath.Join(root, "data"), | |||
| 93 | + | StateDir: filepath.Join(root, "state"), | |||
| 94 | + | CacheDir: filepath.Join(root, "cache"), | |||
| 95 | + | LogDir: filepath.Join(root, "logs"), | |||
| 96 | + | }, | |||
| 97 | + | EnginePaths: EnginePaths{ | |||
| 98 | + | Name: engine, | |||
| 99 | + | RootDir: engineRoot, | |||
| 100 | + | RuntimeDir: filepath.Join(engineRoot, "runtime"), | |||
| 101 | + | LogDir: filepath.Join(engineRoot, "logs"), | |||
| 102 | + | }, | |||
| 103 | + | IPC: IPCPaths{ | |||
| 104 | + | RuntimeDir: ipcRoot, | |||
| 105 | + | BrowserLaunchSocket: filepath.Join(ipcRoot, browserLaunchSocketName), | |||
| 106 | + | }, | |||
| 107 | + | }, nil | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | shared := SharedPaths{ | |||
| 111 | + | ConfigDir: input.Base.ConfigHome, | |||
| 112 | + | DataDir: input.Base.DataHome, | |||
| 113 | + | StateDir: input.Base.StateHome, | |||
| 114 | + | CacheDir: input.Base.CacheHome, | |||
| 115 | + | LogDir: filepath.Join(input.Base.StateHome, "logs"), | |||
| 116 | + | } | |||
| 117 | + | engineRoot := filepath.Join(shared.StateDir, "engines", engine) | |||
| 118 | + | ipcRoot := filepath.Join(shared.StateDir, "runtime", engine) | |||
| 119 | + | return Profile{ | |||
| 120 | + | Mode: ModeProd, | |||
| 121 | + | Engine: engine, | |||
| 122 | + | Shared: shared, | |||
| 123 | + | EnginePaths: EnginePaths{ | |||
| 124 | + | Name: engine, | |||
| 125 | + | RootDir: engineRoot, | |||
| 126 | + | RuntimeDir: filepath.Join(engineRoot, "runtime"), | |||
| 127 | + | LogDir: filepath.Join(engineRoot, "logs"), | |||
| 128 | + | }, | |||
| 129 | + | IPC: IPCPaths{ | |||
| 130 | + | RuntimeDir: ipcRoot, | |||
| 131 | + | BrowserLaunchSocket: filepath.Join(ipcRoot, browserLaunchSocketName), | |||
| 132 | + | }, | |||
| 133 | + | }, nil | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // normalizeEngine trims and lowercases the runtime engine name. | |||
| 137 | + | // Supported engines are "webkit" and "cef"; any other value falls back to | |||
| 138 | + | // the default "webkit" runtime namespace. | |||
| 139 | + | func normalizeEngine(engine string) string { | |||
| 140 | + | switch normalized := strings.ToLower(strings.TrimSpace(engine)); normalized { | |||
| 141 | + | case "", engineWebKit: | |||
| 142 | + | return engineWebKit | |||
| 143 | + | case engineCEF: | |||
| 144 | + | return engineCEF | |||
| 145 | + | default: | |||
| 146 | + | return engineWebKit | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func envValue(get func(string) string, key string) string { | |||
| 151 | + | if get == nil { | |||
| 152 | + | return "" | |||
| 153 | + | } | |||
| 154 | + | return get(key) | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func cwdValue(get func() (string, error)) (string, error) { | |||
| 158 | + | if get == nil { | |||
| 159 | + | return "", fmt.Errorf("cwd getter is nil") | |||
| 160 | + | } | |||
| 161 | + | return get() | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | // CEFUserDataDir returns the resolved CEF root cache/user-data directory. | |||
| 165 | + | func (p Profile) CEFUserDataDir() string { | |||
| 166 | + | if p.Mode == ModeDev { | |||
| 167 | + | return filepath.Join(p.EnginePaths.RootDir, "data") | |||
| 168 | + | } | |||
| 169 | + | return filepath.Join(p.Shared.DataDir, "cef_user_data") | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // CEFLogFile returns the resolved default CEF runtime log path. | |||
| 173 | + | func (p Profile) CEFLogFile() string { | |||
| 174 | + | return filepath.Join(p.EnginePaths.LogDir, "cef_runtime.log") | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // WebKitDataDir returns the resolved WebKit website data directory. | |||
| 178 | + | func (p Profile) WebKitDataDir() string { | |||
| 179 | + | if p.Mode == ModeDev { | |||
| 180 | + | return filepath.Join(p.EnginePaths.RootDir, "data") | |||
| 181 | + | } | |||
| 182 | + | return p.Shared.DataDir | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | // WebKitCacheDir returns the resolved WebKit cache directory. | |||
| 186 | + | func (p Profile) WebKitCacheDir() string { | |||
| 187 | + | if p.Mode == ModeDev { | |||
| 188 | + | return filepath.Join(p.EnginePaths.RootDir, "cache") | |||
| 189 | + | } | |||
| 190 | + | // Keep the current prod layout under state for non-user-managed technical | |||
| 191 | + | // engine cache data; this intentionally preserves existing WebKit behavior. | |||
| 192 | + | return filepath.Join(p.Shared.StateDir, "webkit-cache") | |||
| 193 | + | } | |||
A internal/infrastructure/runtimeprofile/profile_test.go +120 -0
| @@ -0,0 +1,120 @@ | |||
| 1 | + | package runtimeprofile | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestResolve_ProdPreservesSharedLayout(t *testing.T) { | |
| 11 | + | profile, err := Resolve(ResolveInput{ | |
| 12 | + | Env: func(string) string { return "" }, | |
| 13 | + | Engine: "webkit", | |
| 14 | + | CWD: func() (string, error) { return "/repo", nil }, | |
| 15 | + | Base: BasePaths{ | |
| 16 | + | ConfigHome: "/home/u/.config/dumber", | |
| 17 | + | DataHome: "/home/u/.local/share/dumber", | |
| 18 | + | StateHome: "/home/u/.local/state/dumber", | |
| 19 | + | CacheHome: "/home/u/.cache/dumber", | |
| 20 | + | }, | |
| 21 | + | }) | |
| 22 | + | require.NoError(t, err) | |
| 23 | + | require.Equal(t, ModeProd, profile.Mode) | |
| 24 | + | require.Equal(t, "/home/u/.config/dumber", profile.Shared.ConfigDir) | |
| 25 | + | require.Equal(t, "/home/u/.local/share/dumber", profile.Shared.DataDir) | |
| 26 | + | require.Equal(t, "/home/u/.local/state/dumber", profile.Shared.StateDir) | |
| 27 | + | require.Equal(t, "/home/u/.cache/dumber", profile.Shared.CacheDir) | |
| 28 | + | require.Equal(t, "/home/u/.local/state/dumber/logs", profile.Shared.LogDir) | |
| 29 | + | require.Equal(t, "/home/u/.local/share/dumber/cef_user_data", profile.CEFUserDataDir()) | |
| 30 | + | require.Equal(t, "/home/u/.local/share/dumber", profile.WebKitDataDir()) | |
| 31 | + | require.Equal(t, "/home/u/.local/state/dumber/webkit-cache", profile.WebKitCacheDir()) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestResolve_DevCEF_SeparatesSharedEngineAndIPC(t *testing.T) { | |
| 35 | + | profile, err := Resolve(ResolveInput{ | |
| 36 | + | Env: func(key string) string { | |
| 37 | + | if key == "ENV" { | |
| 38 | + | return "dev" | |
| 39 | + | } | |
| 40 | + | return "" | |
| 41 | + | }, | |
| 42 | + | Engine: "cef", | |
| 43 | + | CWD: func() (string, error) { return "/repo", nil }, | |
| 44 | + | }) | |
| 45 | + | require.NoError(t, err) | |
| 46 | + | require.Equal(t, ModeDev, profile.Mode) | |
| 47 | + | require.Equal(t, "/repo/.dev/dumber/config", profile.Shared.ConfigDir) | |
| 48 | + | require.Equal(t, "/repo/.dev/dumber/data", profile.Shared.DataDir) | |
| 49 | + | require.Equal(t, "/repo/.dev/dumber/engines/cef/data", profile.CEFUserDataDir()) | |
| 50 | + | require.Equal(t, "/repo/.dev/dumber/runtime/cef/browser-launch.sock", profile.IPC.BrowserLaunchSocket) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestResolve_DevWebKitAndCEF_HaveDifferentTechnicalNamespaces(t *testing.T) { | |
| 54 | + | devEnv := func(key string) string { | |
| 55 | + | if key == "ENV" { | |
| 56 | + | return "dev" | |
| 57 | + | } | |
| 58 | + | return "" | |
| 59 | + | } | |
| 60 | + | repoCWD := func() (string, error) { return "/repo", nil } | |
| 61 | + | ||
| 62 | + | cefProfile, err := Resolve(ResolveInput{Env: devEnv, Engine: "cef", CWD: repoCWD}) | |
| 63 | + | require.NoError(t, err) | |
| 64 | + | wkProfile, err := Resolve(ResolveInput{Env: devEnv, Engine: "webkit", CWD: repoCWD}) | |
| 65 | + | require.NoError(t, err) | |
| 66 | + | ||
| 67 | + | require.NotEqual(t, cefProfile.EnginePaths.RootDir, wkProfile.EnginePaths.RootDir) | |
| 68 | + | require.NotEqual(t, cefProfile.IPC.RuntimeDir, wkProfile.IPC.RuntimeDir) | |
| 69 | + | require.NotEqual(t, cefProfile.IPC.BrowserLaunchSocket, wkProfile.IPC.BrowserLaunchSocket) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestResolve_DefaultsEmptyEngineToWebKit(t *testing.T) { | |
| 73 | + | profile, err := Resolve(ResolveInput{ | |
| 74 | + | Env: func(string) string { return "" }, | |
| 75 | + | CWD: func() (string, error) { return "/repo", nil }, | |
| 76 | + | Base: BasePaths{StateHome: "/state", DataHome: "/data"}, | |
| 77 | + | }) | |
| 78 | + | require.NoError(t, err) | |
| 79 | + | require.Equal(t, "webkit", profile.Engine) | |
| 80 | + | require.Equal(t, "/state/runtime/webkit/browser-launch.sock", profile.IPC.BrowserLaunchSocket) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func TestResolve_NormalizesEngineWhitespaceAndCase(t *testing.T) { | |
| 84 | + | profile, err := Resolve(ResolveInput{ | |
| 85 | + | Env: func(string) string { return "" }, | |
| 86 | + | Engine: " CEF ", | |
| 87 | + | CWD: func() (string, error) { return "/repo", nil }, | |
| 88 | + | Base: BasePaths{StateHome: "/state", DataHome: "/data"}, | |
| 89 | + | }) | |
| 90 | + | require.NoError(t, err) | |
| 91 | + | require.Equal(t, "cef", profile.Engine) | |
| 92 | + | require.Equal(t, "/state/runtime/cef/browser-launch.sock", profile.IPC.BrowserLaunchSocket) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestResolve_DefaultsUnknownEngineToWebKit(t *testing.T) { | |
| 96 | + | profile, err := Resolve(ResolveInput{ | |
| 97 | + | Env: func(string) string { return "" }, | |
| 98 | + | Engine: "wekbit", | |
| 99 | + | CWD: func() (string, error) { return "/repo", nil }, | |
| 100 | + | Base: BasePaths{StateHome: "/state", DataHome: "/data"}, | |
| 101 | + | }) | |
| 102 | + | require.NoError(t, err) | |
| 103 | + | require.Equal(t, "webkit", profile.Engine) | |
| 104 | + | require.Equal(t, "/state/runtime/webkit/browser-launch.sock", profile.IPC.BrowserLaunchSocket) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestResolve_DevFailsWhenCWDUnavailable(t *testing.T) { | |
| 108 | + | want := errors.New("boom") | |
| 109 | + | _, err := Resolve(ResolveInput{ | |
| 110 | + | Env: func(key string) string { | |
| 111 | + | if key == "ENV" { | |
| 112 | + | return "dev" | |
| 113 | + | } | |
| 114 | + | return "" | |
| 115 | + | }, | |
| 116 | + | Engine: "cef", | |
| 117 | + | CWD: func() (string, error) { return "", want }, | |
| 118 | + | }) | |
| 119 | + | require.ErrorIs(t, err, want) | |
| 120 | + | } | |
| @@ -0,0 +1,120 @@ | |||||
| 1 | + | package runtimeprofile | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestResolve_ProdPreservesSharedLayout(t *testing.T) { | |||
| 11 | + | profile, err := Resolve(ResolveInput{ | |||
| 12 | + | Env: func(string) string { return "" }, | |||
| 13 | + | Engine: "webkit", | |||
| 14 | + | CWD: func() (string, error) { return "/repo", nil }, | |||
| 15 | + | Base: BasePaths{ | |||
| 16 | + | ConfigHome: "/home/u/.config/dumber", | |||
| 17 | + | DataHome: "/home/u/.local/share/dumber", | |||
| 18 | + | StateHome: "/home/u/.local/state/dumber", | |||
| 19 | + | CacheHome: "/home/u/.cache/dumber", | |||
| 20 | + | }, | |||
| 21 | + | }) | |||
| 22 | + | require.NoError(t, err) | |||
| 23 | + | require.Equal(t, ModeProd, profile.Mode) | |||
| 24 | + | require.Equal(t, "/home/u/.config/dumber", profile.Shared.ConfigDir) | |||
| 25 | + | require.Equal(t, "/home/u/.local/share/dumber", profile.Shared.DataDir) | |||
| 26 | + | require.Equal(t, "/home/u/.local/state/dumber", profile.Shared.StateDir) | |||
| 27 | + | require.Equal(t, "/home/u/.cache/dumber", profile.Shared.CacheDir) | |||
| 28 | + | require.Equal(t, "/home/u/.local/state/dumber/logs", profile.Shared.LogDir) | |||
| 29 | + | require.Equal(t, "/home/u/.local/share/dumber/cef_user_data", profile.CEFUserDataDir()) | |||
| 30 | + | require.Equal(t, "/home/u/.local/share/dumber", profile.WebKitDataDir()) | |||
| 31 | + | require.Equal(t, "/home/u/.local/state/dumber/webkit-cache", profile.WebKitCacheDir()) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestResolve_DevCEF_SeparatesSharedEngineAndIPC(t *testing.T) { | |||
| 35 | + | profile, err := Resolve(ResolveInput{ | |||
| 36 | + | Env: func(key string) string { | |||
| 37 | + | if key == "ENV" { | |||
| 38 | + | return "dev" | |||
| 39 | + | } | |||
| 40 | + | return "" | |||
| 41 | + | }, | |||
| 42 | + | Engine: "cef", | |||
| 43 | + | CWD: func() (string, error) { return "/repo", nil }, | |||
| 44 | + | }) | |||
| 45 | + | require.NoError(t, err) | |||
| 46 | + | require.Equal(t, ModeDev, profile.Mode) | |||
| 47 | + | require.Equal(t, "/repo/.dev/dumber/config", profile.Shared.ConfigDir) | |||
| 48 | + | require.Equal(t, "/repo/.dev/dumber/data", profile.Shared.DataDir) | |||
| 49 | + | require.Equal(t, "/repo/.dev/dumber/engines/cef/data", profile.CEFUserDataDir()) | |||
| 50 | + | require.Equal(t, "/repo/.dev/dumber/runtime/cef/browser-launch.sock", profile.IPC.BrowserLaunchSocket) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestResolve_DevWebKitAndCEF_HaveDifferentTechnicalNamespaces(t *testing.T) { | |||
| 54 | + | devEnv := func(key string) string { | |||
| 55 | + | if key == "ENV" { | |||
| 56 | + | return "dev" | |||
| 57 | + | } | |||
| 58 | + | return "" | |||
| 59 | + | } | |||
| 60 | + | repoCWD := func() (string, error) { return "/repo", nil } | |||
| 61 | + | ||||
| 62 | + | cefProfile, err := Resolve(ResolveInput{Env: devEnv, Engine: "cef", CWD: repoCWD}) | |||
| 63 | + | require.NoError(t, err) | |||
| 64 | + | wkProfile, err := Resolve(ResolveInput{Env: devEnv, Engine: "webkit", CWD: repoCWD}) | |||
| 65 | + | require.NoError(t, err) | |||
| 66 | + | ||||
| 67 | + | require.NotEqual(t, cefProfile.EnginePaths.RootDir, wkProfile.EnginePaths.RootDir) | |||
| 68 | + | require.NotEqual(t, cefProfile.IPC.RuntimeDir, wkProfile.IPC.RuntimeDir) | |||
| 69 | + | require.NotEqual(t, cefProfile.IPC.BrowserLaunchSocket, wkProfile.IPC.BrowserLaunchSocket) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func TestResolve_DefaultsEmptyEngineToWebKit(t *testing.T) { | |||
| 73 | + | profile, err := Resolve(ResolveInput{ | |||
| 74 | + | Env: func(string) string { return "" }, | |||
| 75 | + | CWD: func() (string, error) { return "/repo", nil }, | |||
| 76 | + | Base: BasePaths{StateHome: "/state", DataHome: "/data"}, | |||
| 77 | + | }) | |||
| 78 | + | require.NoError(t, err) | |||
| 79 | + | require.Equal(t, "webkit", profile.Engine) | |||
| 80 | + | require.Equal(t, "/state/runtime/webkit/browser-launch.sock", profile.IPC.BrowserLaunchSocket) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func TestResolve_NormalizesEngineWhitespaceAndCase(t *testing.T) { | |||
| 84 | + | profile, err := Resolve(ResolveInput{ | |||
| 85 | + | Env: func(string) string { return "" }, | |||
| 86 | + | Engine: " CEF ", | |||
| 87 | + | CWD: func() (string, error) { return "/repo", nil }, | |||
| 88 | + | Base: BasePaths{StateHome: "/state", DataHome: "/data"}, | |||
| 89 | + | }) | |||
| 90 | + | require.NoError(t, err) | |||
| 91 | + | require.Equal(t, "cef", profile.Engine) | |||
| 92 | + | require.Equal(t, "/state/runtime/cef/browser-launch.sock", profile.IPC.BrowserLaunchSocket) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func TestResolve_DefaultsUnknownEngineToWebKit(t *testing.T) { | |||
| 96 | + | profile, err := Resolve(ResolveInput{ | |||
| 97 | + | Env: func(string) string { return "" }, | |||
| 98 | + | Engine: "wekbit", | |||
| 99 | + | CWD: func() (string, error) { return "/repo", nil }, | |||
| 100 | + | Base: BasePaths{StateHome: "/state", DataHome: "/data"}, | |||
| 101 | + | }) | |||
| 102 | + | require.NoError(t, err) | |||
| 103 | + | require.Equal(t, "webkit", profile.Engine) | |||
| 104 | + | require.Equal(t, "/state/runtime/webkit/browser-launch.sock", profile.IPC.BrowserLaunchSocket) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func TestResolve_DevFailsWhenCWDUnavailable(t *testing.T) { | |||
| 108 | + | want := errors.New("boom") | |||
| 109 | + | _, err := Resolve(ResolveInput{ | |||
| 110 | + | Env: func(key string) string { | |||
| 111 | + | if key == "ENV" { | |||
| 112 | + | return "dev" | |||
| 113 | + | } | |||
| 114 | + | return "" | |||
| 115 | + | }, | |||
| 116 | + | Engine: "cef", | |||
| 117 | + | CWD: func() (string, error) { return "", want }, | |||
| 118 | + | }) | |||
| 119 | + | require.ErrorIs(t, err, want) | |||
| 120 | + | } | |||
M internal/infrastructure/webkit/context_menu.go +2 -0
| @@ -388,6 +388,8 @@ func (p *contextMenuPipeline) newExecutor(wv *WebView) port.ContextMenuActionExe | |||
| 388 | 388 | ) | |
| 389 | 389 | } | |
| 390 | 390 | ||
| 391 | + | var _ port.Clipboard = (*clipboardCopyNotifier)(nil) | |
| 392 | + | ||
| 391 | 393 | type clipboardCopyNotifier struct { | |
| 392 | 394 | clipboard port.Clipboard | |
| 393 | 395 | onCopied func(textLen int) | |
| @@ -388,6 +388,8 @@ func (p *contextMenuPipeline) newExecutor(wv *WebView) port.ContextMenuActionExe | |||||
| 388 | ) | 388 | ) | ||
| 389 | } | 389 | } | ||
| 390 | 390 | ||||
| 391 | + | var _ port.Clipboard = (*clipboardCopyNotifier)(nil) | |||
| 392 | + | ||||
| 391 | type clipboardCopyNotifier struct { | 393 | type clipboardCopyNotifier struct { | ||
| 392 | clipboard port.Clipboard | 394 | clipboard port.Clipboard | ||
| 393 | onCopied func(textLen int) | 395 | onCopied func(textLen int) | ||
M internal/infrastructure/webkit/context_menu_test.go +23 -41
| @@ -12,9 +12,10 @@ import ( | |||
| 12 | 12 | "unicode/utf8" | |
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/dumber/internal/application/port" | |
| 15 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 15 | 16 | "github.com/bnema/dumber/internal/application/usecase" | |
| 16 | - | "github.com/bnema/dumber/internal/domain/entity" | |
| 17 | 17 | "github.com/stretchr/testify/assert" | |
| 18 | + | "github.com/stretchr/testify/mock" | |
| 18 | 19 | "github.com/stretchr/testify/require" | |
| 19 | 20 | ) | |
| 20 | 21 | ||
| @@ -318,8 +319,21 @@ func TestWebkitMenuDelegator_OpenLinkNewTabRequiresHandlerSuccess(t *testing.T) | |||
| 318 | 319 | ||
| 319 | 320 | func TestContextMenuPipeline_NewExecutor_NotifiesOnCopiedText(t *testing.T) { | |
| 320 | 321 | var copiedLen int | |
| 321 | - | baseClipboard := &recordingClipboard{} | |
| 322 | - | factory := &capturingExecutorFactory{} | |
| 322 | + | ctx := context.Background() | |
| 323 | + | baseClipboard := portmocks.NewMockClipboard(t) | |
| 324 | + | factory := portmocks.NewMockContextMenuActionExecutorFactory(t) | |
| 325 | + | var capturedClipboard port.Clipboard | |
| 326 | + | factory.EXPECT().NewContextMenuActionExecutor(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 327 | + | func( | |
| 328 | + | clipboard port.Clipboard, | |
| 329 | + | resolver port.ImageDataResolver, | |
| 330 | + | saver port.ResolvedImageSaver, | |
| 331 | + | delegator port.MenuActionDelegator, | |
| 332 | + | ) port.ContextMenuActionExecutor { | |
| 333 | + | capturedClipboard = clipboard | |
| 334 | + | return usecase.NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |
| 335 | + | }, | |
| 336 | + | ).Once() | |
| 323 | 337 | pipeline := &contextMenuPipeline{ | |
| 324 | 338 | clipboard: baseClipboard, | |
| 325 | 339 | onCopied: func(textLen int) { copiedLen = textLen }, | |
| @@ -331,23 +345,21 @@ func TestContextMenuPipeline_NewExecutor_NotifiesOnCopiedText(t *testing.T) { | |||
| 331 | 345 | ||
| 332 | 346 | t.Run("copy link", func(t *testing.T) { | |
| 333 | 347 | copiedLen = 0 | |
| 334 | - | require.NotNil(t, factory.clipboard) | |
| 348 | + | require.NotNil(t, capturedClipboard) | |
| 349 | + | baseClipboard.EXPECT().WriteText(ctx, "https://example.com/new").Return(nil).Once() | |
| 335 | 350 | ||
| 336 | - | err := executor.ExecuteMenuAction(context.Background(), port.MenuActionCopyLink, port.MenuContext{LinkURI: "https://example.com/new"}) | |
| 351 | + | err := executor.ExecuteMenuAction(ctx, port.MenuActionCopyLink, port.MenuContext{LinkURI: "https://example.com/new"}) | |
| 337 | 352 | require.NoError(t, err) | |
| 338 | - | require.Equal(t, "https://example.com/new", baseClipboard.text) | |
| 339 | - | require.Equal(t, 1, baseClipboard.writeTextCalls) | |
| 340 | 353 | require.Equal(t, utf8.RuneCountInString("https://example.com/new"), copiedLen) | |
| 341 | 354 | }) | |
| 342 | 355 | ||
| 343 | 356 | t.Run("copy selection", func(t *testing.T) { | |
| 344 | 357 | copiedLen = 0 | |
| 345 | - | require.NotNil(t, factory.clipboard) | |
| 358 | + | require.NotNil(t, capturedClipboard) | |
| 359 | + | baseClipboard.EXPECT().WriteText(ctx, "selected text").Return(nil).Once() | |
| 346 | 360 | ||
| 347 | - | err := executor.ExecuteMenuAction(context.Background(), port.MenuActionCopySelection, port.MenuContext{HasSelection: true, SelectionText: "selected text"}) | |
| 361 | + | err := executor.ExecuteMenuAction(ctx, port.MenuActionCopySelection, port.MenuContext{HasSelection: true, SelectionText: "selected text"}) | |
| 348 | 362 | require.NoError(t, err) | |
| 349 | - | require.Equal(t, "selected text", baseClipboard.text) | |
| 350 | - | require.Equal(t, 2, baseClipboard.writeTextCalls) | |
| 351 | 363 | require.Equal(t, utf8.RuneCountInString("selected text"), copiedLen) | |
| 352 | 364 | }) | |
| 353 | 365 | } | |
| @@ -374,36 +386,6 @@ func buildMenuContextFromStubHitTest(hit stubHitTest, canGoBack, canGoForward bo | |||
| 374 | 386 | return ctx | |
| 375 | 387 | } | |
| 376 | 388 | ||
| 377 | - | type recordingClipboard struct { | |
| 378 | - | writeTextCalls int | |
| 379 | - | text string | |
| 380 | - | } | |
| 381 | - | ||
| 382 | - | func (r *recordingClipboard) WriteText(_ context.Context, text string) error { | |
| 383 | - | r.writeTextCalls++ | |
| 384 | - | r.text = text | |
| 385 | - | return nil | |
| 386 | - | } | |
| 387 | - | ||
| 388 | - | func (*recordingClipboard) WriteImage(context.Context, entity.ImageData) error { return nil } | |
| 389 | - | func (*recordingClipboard) ReadText(context.Context) (string, error) { return "", nil } | |
| 390 | - | func (*recordingClipboard) Clear(context.Context) error { return nil } | |
| 391 | - | func (*recordingClipboard) HasText(context.Context) (bool, error) { return false, nil } | |
| 392 | - | ||
| 393 | - | type capturingExecutorFactory struct { | |
| 394 | - | clipboard port.Clipboard | |
| 395 | - | } | |
| 396 | - | ||
| 397 | - | func (f *capturingExecutorFactory) NewContextMenuActionExecutor( | |
| 398 | - | clipboard port.Clipboard, | |
| 399 | - | resolver port.ImageDataResolver, | |
| 400 | - | saver port.ResolvedImageSaver, | |
| 401 | - | delegator port.MenuActionDelegator, | |
| 402 | - | ) port.ContextMenuActionExecutor { | |
| 403 | - | f.clipboard = clipboard | |
| 404 | - | return usecase.NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |
| 405 | - | } | |
| 406 | - | ||
| 407 | 389 | type roundTripperFunc func(*http.Request) (*http.Response, error) | |
| 408 | 390 | ||
| 409 | 391 | func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { | |
| @@ -12,9 +12,10 @@ import ( | |||||
| 12 | "unicode/utf8" | 12 | "unicode/utf8" | ||
| 13 | 13 | ||||
| 14 | "github.com/bnema/dumber/internal/application/port" | 14 | "github.com/bnema/dumber/internal/application/port" | ||
| 15 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 15 | "github.com/bnema/dumber/internal/application/usecase" | 16 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 16 | - | "github.com/bnema/dumber/internal/domain/entity" | |||
| 17 | "github.com/stretchr/testify/assert" | 17 | "github.com/stretchr/testify/assert" | ||
| 18 | + | "github.com/stretchr/testify/mock" | |||
| 18 | "github.com/stretchr/testify/require" | 19 | "github.com/stretchr/testify/require" | ||
| 19 | ) | 20 | ) | ||
| 20 | 21 | ||||
| @@ -318,8 +319,21 @@ func TestWebkitMenuDelegator_OpenLinkNewTabRequiresHandlerSuccess(t *testing.T) | |||||
| 318 | 319 | ||||
| 319 | func TestContextMenuPipeline_NewExecutor_NotifiesOnCopiedText(t *testing.T) { | 320 | func TestContextMenuPipeline_NewExecutor_NotifiesOnCopiedText(t *testing.T) { | ||
| 320 | var copiedLen int | 321 | var copiedLen int | ||
| 321 | - | baseClipboard := &recordingClipboard{} | 322 | + | ctx := context.Background() |
| 322 | - | factory := &capturingExecutorFactory{} | 323 | + | baseClipboard := portmocks.NewMockClipboard(t) |
| 324 | + | factory := portmocks.NewMockContextMenuActionExecutorFactory(t) | |||
| 325 | + | var capturedClipboard port.Clipboard | |||
| 326 | + | factory.EXPECT().NewContextMenuActionExecutor(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |||
| 327 | + | func( | |||
| 328 | + | clipboard port.Clipboard, | |||
| 329 | + | resolver port.ImageDataResolver, | |||
| 330 | + | saver port.ResolvedImageSaver, | |||
| 331 | + | delegator port.MenuActionDelegator, | |||
| 332 | + | ) port.ContextMenuActionExecutor { | |||
| 333 | + | capturedClipboard = clipboard | |||
| 334 | + | return usecase.NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |||
| 335 | + | }, | |||
| 336 | + | ).Once() | |||
| 323 | pipeline := &contextMenuPipeline{ | 337 | pipeline := &contextMenuPipeline{ | ||
| 324 | clipboard: baseClipboard, | 338 | clipboard: baseClipboard, | ||
| 325 | onCopied: func(textLen int) { copiedLen = textLen }, | 339 | onCopied: func(textLen int) { copiedLen = textLen }, | ||
| @@ -331,23 +345,21 @@ func TestContextMenuPipeline_NewExecutor_NotifiesOnCopiedText(t *testing.T) { | |||||
| 331 | 345 | ||||
| 332 | t.Run("copy link", func(t *testing.T) { | 346 | t.Run("copy link", func(t *testing.T) { | ||
| 333 | copiedLen = 0 | 347 | copiedLen = 0 | ||
| 334 | - | require.NotNil(t, factory.clipboard) | 348 | + | require.NotNil(t, capturedClipboard) |
| 349 | + | baseClipboard.EXPECT().WriteText(ctx, "https://example.com/new").Return(nil).Once() | |||
| 335 | 350 | ||||
| 336 | - | err := executor.ExecuteMenuAction(context.Background(), port.MenuActionCopyLink, port.MenuContext{LinkURI: "https://example.com/new"}) | 351 | + | err := executor.ExecuteMenuAction(ctx, port.MenuActionCopyLink, port.MenuContext{LinkURI: "https://example.com/new"}) |
| 337 | require.NoError(t, err) | 352 | require.NoError(t, err) | ||
| 338 | - | require.Equal(t, "https://example.com/new", baseClipboard.text) | |||
| 339 | - | require.Equal(t, 1, baseClipboard.writeTextCalls) | |||
| 340 | require.Equal(t, utf8.RuneCountInString("https://example.com/new"), copiedLen) | 353 | require.Equal(t, utf8.RuneCountInString("https://example.com/new"), copiedLen) | ||
| 341 | }) | 354 | }) | ||
| 342 | 355 | ||||
| 343 | t.Run("copy selection", func(t *testing.T) { | 356 | t.Run("copy selection", func(t *testing.T) { | ||
| 344 | copiedLen = 0 | 357 | copiedLen = 0 | ||
| 345 | - | require.NotNil(t, factory.clipboard) | 358 | + | require.NotNil(t, capturedClipboard) |
| 359 | + | baseClipboard.EXPECT().WriteText(ctx, "selected text").Return(nil).Once() | |||
| 346 | 360 | ||||
| 347 | - | err := executor.ExecuteMenuAction(context.Background(), port.MenuActionCopySelection, port.MenuContext{HasSelection: true, SelectionText: "selected text"}) | 361 | + | err := executor.ExecuteMenuAction(ctx, port.MenuActionCopySelection, port.MenuContext{HasSelection: true, SelectionText: "selected text"}) |
| 348 | require.NoError(t, err) | 362 | require.NoError(t, err) | ||
| 349 | - | require.Equal(t, "selected text", baseClipboard.text) | |||
| 350 | - | require.Equal(t, 2, baseClipboard.writeTextCalls) | |||
| 351 | require.Equal(t, utf8.RuneCountInString("selected text"), copiedLen) | 363 | require.Equal(t, utf8.RuneCountInString("selected text"), copiedLen) | ||
| 352 | }) | 364 | }) | ||
| 353 | } | 365 | } | ||
| @@ -374,36 +386,6 @@ func buildMenuContextFromStubHitTest(hit stubHitTest, canGoBack, canGoForward bo | |||||
| 374 | return ctx | 386 | return ctx | ||
| 375 | } | 387 | } | ||
| 376 | 388 | ||||
| 377 | - | type recordingClipboard struct { | |||
| 378 | - | writeTextCalls int | |||
| 379 | - | text string | |||
| 380 | - | } | |||
| 381 | - | ||||
| 382 | - | func (r *recordingClipboard) WriteText(_ context.Context, text string) error { | |||
| 383 | - | r.writeTextCalls++ | |||
| 384 | - | r.text = text | |||
| 385 | - | return nil | |||
| 386 | - | } | |||
| 387 | - | ||||
| 388 | - | func (*recordingClipboard) WriteImage(context.Context, entity.ImageData) error { return nil } | |||
| 389 | - | func (*recordingClipboard) ReadText(context.Context) (string, error) { return "", nil } | |||
| 390 | - | func (*recordingClipboard) Clear(context.Context) error { return nil } | |||
| 391 | - | func (*recordingClipboard) HasText(context.Context) (bool, error) { return false, nil } | |||
| 392 | - | ||||
| 393 | - | type capturingExecutorFactory struct { | |||
| 394 | - | clipboard port.Clipboard | |||
| 395 | - | } | |||
| 396 | - | ||||
| 397 | - | func (f *capturingExecutorFactory) NewContextMenuActionExecutor( | |||
| 398 | - | clipboard port.Clipboard, | |||
| 399 | - | resolver port.ImageDataResolver, | |||
| 400 | - | saver port.ResolvedImageSaver, | |||
| 401 | - | delegator port.MenuActionDelegator, | |||
| 402 | - | ) port.ContextMenuActionExecutor { | |||
| 403 | - | f.clipboard = clipboard | |||
| 404 | - | return usecase.NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |||
| 405 | - | } | |||
| 406 | - | ||||
| 407 | type roundTripperFunc func(*http.Request) (*http.Response, error) | 389 | type roundTripperFunc func(*http.Request) (*http.Response, error) | ||
| 408 | 390 | ||||
| 409 | func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { | 391 | func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { | ||
M internal/infrastructure/webkit/engine_init.go +16 -4
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 11 | 11 | "github.com/bnema/dumber/internal/infrastructure/env" | |
| 12 | 12 | "github.com/bnema/dumber/internal/infrastructure/filtering" | |
| 13 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 13 | 14 | "github.com/bnema/dumber/internal/logging" | |
| 14 | 15 | "github.com/bnema/dumber/internal/ui/theme" | |
| 15 | 16 | "github.com/bnema/puregotk/v4/gdk" | |
| @@ -22,6 +23,7 @@ func NewEngine( | |||
| 22 | 23 | ctx context.Context, | |
| 23 | 24 | cfg *config.Config, | |
| 24 | 25 | opts port.EngineOptions, | |
| 26 | + | profile runtimeprofile.Profile, | |
| 25 | 27 | wkCfg WebKitEngineConfig, | |
| 26 | 28 | themeManager *theme.Manager, | |
| 27 | 29 | colorResolver port.ColorSchemeResolver, | |
| @@ -37,7 +39,7 @@ func NewEngine( | |||
| 37 | 39 | logging.Trace().Mark("render_env") | |
| 38 | 40 | ||
| 39 | 41 | // --- Build webKitContextOptions from opts + wkCfg + perfSettings --- | |
| 40 | - | wkOpts := engineBuildContextOptions(opts, wkCfg, &perfSettings) | |
| 42 | + | wkOpts := engineBuildContextOptions(opts, profile, wkCfg, &perfSettings) | |
| 41 | 43 | logger.Info(). | |
| 42 | 44 | Str("cookie_policy", string(wkOpts.CookiePolicy)). | |
| 43 | 45 | Bool("itp_enabled", wkOpts.ITPEnabled). | |
| @@ -50,7 +52,7 @@ func NewEngine( | |||
| 50 | 52 | logging.Trace().Mark("webkit_context") | |
| 51 | 53 | ||
| 52 | 54 | // --- Filter manager --- | |
| 53 | - | filterManager := engineInitFilterManager(ctx, cfg, opts.DataDir, logger) | |
| 55 | + | filterManager := engineInitFilterManager(ctx, cfg, profile.Shared.DataDir, logger) | |
| 54 | 56 | ||
| 55 | 57 | // --- Scheme handler --- | |
| 56 | 58 | schemeHandler := NewDumbSchemeHandler(ctx) | |
| @@ -203,14 +205,24 @@ func engineConfigureRenderingEnvironment( | |||
| 203 | 205 | // engineBuildContextOptions builds webKitContextOptions from EngineOptions, wkCfg and perfSettings. | |
| 204 | 206 | func engineBuildContextOptions( | |
| 205 | 207 | opts port.EngineOptions, | |
| 208 | + | profile runtimeprofile.Profile, | |
| 206 | 209 | wkCfg WebKitEngineConfig, | |
| 207 | 210 | perfSettings *config.ResolvedPerformanceSettings, | |
| 208 | 211 | ) webKitContextOptions { | |
| 209 | 212 | cp := opts.CookiePolicy // empty preserves runtime default per port contract | |
| 210 | 213 | ||
| 214 | + | dataDir := profile.WebKitDataDir() | |
| 215 | + | if opts.DataDir != "" { | |
| 216 | + | dataDir = opts.DataDir | |
| 217 | + | } | |
| 218 | + | cacheDir := profile.WebKitCacheDir() | |
| 219 | + | if opts.CacheDir != "" { | |
| 220 | + | cacheDir = opts.CacheDir | |
| 221 | + | } | |
| 222 | + | ||
| 211 | 223 | wkOpts := webKitContextOptions{ | |
| 212 | - | DataDir: opts.DataDir, | |
| 213 | - | CacheDir: opts.CacheDir, | |
| 224 | + | DataDir: dataDir, | |
| 225 | + | CacheDir: cacheDir, | |
| 214 | 226 | CookiePolicy: cp, | |
| 215 | 227 | ITPEnabled: wkCfg.ITPEnabled, | |
| 216 | 228 | } | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "github.com/bnema/dumber/internal/infrastructure/config" | 10 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 11 | "github.com/bnema/dumber/internal/infrastructure/env" | 11 | "github.com/bnema/dumber/internal/infrastructure/env" | ||
| 12 | "github.com/bnema/dumber/internal/infrastructure/filtering" | 12 | "github.com/bnema/dumber/internal/infrastructure/filtering" | ||
| 13 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 13 | "github.com/bnema/dumber/internal/logging" | 14 | "github.com/bnema/dumber/internal/logging" | ||
| 14 | "github.com/bnema/dumber/internal/ui/theme" | 15 | "github.com/bnema/dumber/internal/ui/theme" | ||
| 15 | "github.com/bnema/puregotk/v4/gdk" | 16 | "github.com/bnema/puregotk/v4/gdk" | ||
| @@ -22,6 +23,7 @@ func NewEngine( | |||||
| 22 | ctx context.Context, | 23 | ctx context.Context, | ||
| 23 | cfg *config.Config, | 24 | cfg *config.Config, | ||
| 24 | opts port.EngineOptions, | 25 | opts port.EngineOptions, | ||
| 26 | + | profile runtimeprofile.Profile, | |||
| 25 | wkCfg WebKitEngineConfig, | 27 | wkCfg WebKitEngineConfig, | ||
| 26 | themeManager *theme.Manager, | 28 | themeManager *theme.Manager, | ||
| 27 | colorResolver port.ColorSchemeResolver, | 29 | colorResolver port.ColorSchemeResolver, | ||
| @@ -37,7 +39,7 @@ func NewEngine( | |||||
| 37 | logging.Trace().Mark("render_env") | 39 | logging.Trace().Mark("render_env") | ||
| 38 | 40 | ||||
| 39 | // --- Build webKitContextOptions from opts + wkCfg + perfSettings --- | 41 | // --- Build webKitContextOptions from opts + wkCfg + perfSettings --- | ||
| 40 | - | wkOpts := engineBuildContextOptions(opts, wkCfg, &perfSettings) | 42 | + | wkOpts := engineBuildContextOptions(opts, profile, wkCfg, &perfSettings) |
| 41 | logger.Info(). | 43 | logger.Info(). | ||
| 42 | Str("cookie_policy", string(wkOpts.CookiePolicy)). | 44 | Str("cookie_policy", string(wkOpts.CookiePolicy)). | ||
| 43 | Bool("itp_enabled", wkOpts.ITPEnabled). | 45 | Bool("itp_enabled", wkOpts.ITPEnabled). | ||
| @@ -50,7 +52,7 @@ func NewEngine( | |||||
| 50 | logging.Trace().Mark("webkit_context") | 52 | logging.Trace().Mark("webkit_context") | ||
| 51 | 53 | ||||
| 52 | // --- Filter manager --- | 54 | // --- Filter manager --- | ||
| 53 | - | filterManager := engineInitFilterManager(ctx, cfg, opts.DataDir, logger) | 55 | + | filterManager := engineInitFilterManager(ctx, cfg, profile.Shared.DataDir, logger) |
| 54 | 56 | ||||
| 55 | // --- Scheme handler --- | 57 | // --- Scheme handler --- | ||
| 56 | schemeHandler := NewDumbSchemeHandler(ctx) | 58 | schemeHandler := NewDumbSchemeHandler(ctx) | ||
| @@ -203,14 +205,24 @@ func engineConfigureRenderingEnvironment( | |||||
| 203 | // engineBuildContextOptions builds webKitContextOptions from EngineOptions, wkCfg and perfSettings. | 205 | // engineBuildContextOptions builds webKitContextOptions from EngineOptions, wkCfg and perfSettings. | ||
| 204 | func engineBuildContextOptions( | 206 | func engineBuildContextOptions( | ||
| 205 | opts port.EngineOptions, | 207 | opts port.EngineOptions, | ||
| 208 | + | profile runtimeprofile.Profile, | |||
| 206 | wkCfg WebKitEngineConfig, | 209 | wkCfg WebKitEngineConfig, | ||
| 207 | perfSettings *config.ResolvedPerformanceSettings, | 210 | perfSettings *config.ResolvedPerformanceSettings, | ||
| 208 | ) webKitContextOptions { | 211 | ) webKitContextOptions { | ||
| 209 | cp := opts.CookiePolicy // empty preserves runtime default per port contract | 212 | cp := opts.CookiePolicy // empty preserves runtime default per port contract | ||
| 210 | 213 | ||||
| 214 | + | dataDir := profile.WebKitDataDir() | |||
| 215 | + | if opts.DataDir != "" { | |||
| 216 | + | dataDir = opts.DataDir | |||
| 217 | + | } | |||
| 218 | + | cacheDir := profile.WebKitCacheDir() | |||
| 219 | + | if opts.CacheDir != "" { | |||
| 220 | + | cacheDir = opts.CacheDir | |||
| 221 | + | } | |||
| 222 | + | ||||
| 211 | wkOpts := webKitContextOptions{ | 223 | wkOpts := webKitContextOptions{ | ||
| 212 | - | DataDir: opts.DataDir, | 224 | + | DataDir: dataDir, |
| 213 | - | CacheDir: opts.CacheDir, | 225 | + | CacheDir: cacheDir, |
| 214 | CookiePolicy: cp, | 226 | CookiePolicy: cp, | ||
| 215 | ITPEnabled: wkCfg.ITPEnabled, | 227 | ITPEnabled: wkCfg.ITPEnabled, | ||
| 216 | } | 228 | } | ||
A internal/infrastructure/webkit/engine_init_test.go +39 -0
| @@ -0,0 +1,39 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "path/filepath" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 9 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestEngineBuildContextOptions_UsesResolvedWebKitPaths(t *testing.T) { | |
| 14 | + | profile := runtimeprofile.Profile{ | |
| 15 | + | Mode: runtimeprofile.ModeDev, | |
| 16 | + | Engine: "webkit", | |
| 17 | + | EnginePaths: runtimeprofile.EnginePaths{ | |
| 18 | + | RootDir: filepath.Join(t.TempDir(), "engines", "webkit"), | |
| 19 | + | }, | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | wkOpts := engineBuildContextOptions(port.EngineOptions{}, profile, WebKitEngineConfig{}, &config.ResolvedPerformanceSettings{}) | |
| 23 | + | require.Equal(t, profile.WebKitDataDir(), wkOpts.DataDir) | |
| 24 | + | require.Equal(t, profile.WebKitCacheDir(), wkOpts.CacheDir) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestEngineBuildContextOptions_PrefersExplicitEngineOptionsDirs(t *testing.T) { | |
| 28 | + | profile := runtimeprofile.Profile{ | |
| 29 | + | Mode: runtimeprofile.ModeDev, | |
| 30 | + | Engine: "webkit", | |
| 31 | + | EnginePaths: runtimeprofile.EnginePaths{ | |
| 32 | + | RootDir: filepath.Join(t.TempDir(), "engines", "webkit"), | |
| 33 | + | }, | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | wkOpts := engineBuildContextOptions(port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}, profile, WebKitEngineConfig{}, &config.ResolvedPerformanceSettings{}) | |
| 37 | + | require.Equal(t, "/tmp/data", wkOpts.DataDir) | |
| 38 | + | require.Equal(t, "/tmp/cache", wkOpts.CacheDir) | |
| 39 | + | } | |
| @@ -0,0 +1,39 @@ | |||||
| 1 | + | package webkit | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "path/filepath" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 8 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 9 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestEngineBuildContextOptions_UsesResolvedWebKitPaths(t *testing.T) { | |||
| 14 | + | profile := runtimeprofile.Profile{ | |||
| 15 | + | Mode: runtimeprofile.ModeDev, | |||
| 16 | + | Engine: "webkit", | |||
| 17 | + | EnginePaths: runtimeprofile.EnginePaths{ | |||
| 18 | + | RootDir: filepath.Join(t.TempDir(), "engines", "webkit"), | |||
| 19 | + | }, | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | wkOpts := engineBuildContextOptions(port.EngineOptions{}, profile, WebKitEngineConfig{}, &config.ResolvedPerformanceSettings{}) | |||
| 23 | + | require.Equal(t, profile.WebKitDataDir(), wkOpts.DataDir) | |||
| 24 | + | require.Equal(t, profile.WebKitCacheDir(), wkOpts.CacheDir) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func TestEngineBuildContextOptions_PrefersExplicitEngineOptionsDirs(t *testing.T) { | |||
| 28 | + | profile := runtimeprofile.Profile{ | |||
| 29 | + | Mode: runtimeprofile.ModeDev, | |||
| 30 | + | Engine: "webkit", | |||
| 31 | + | EnginePaths: runtimeprofile.EnginePaths{ | |||
| 32 | + | RootDir: filepath.Join(t.TempDir(), "engines", "webkit"), | |||
| 33 | + | }, | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | wkOpts := engineBuildContextOptions(port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}, profile, WebKitEngineConfig{}, &config.ResolvedPerformanceSettings{}) | |||
| 37 | + | require.Equal(t, "/tmp/data", wkOpts.DataDir) | |||
| 38 | + | require.Equal(t, "/tmp/cache", wkOpts.CacheDir) | |||
| 39 | + | } | |||
M internal/infrastructure/xdg/adapter.go +3 -3
| @@ -29,9 +29,6 @@ func (a *Adapter) StateDir() (string, error) { | |||
| 29 | 29 | } | |
| 30 | 30 | ||
| 31 | 31 | func (a *Adapter) RuntimeDir() (string, error) { | |
| 32 | - | // In dev mode, keep runtime IPC isolated inside the .dev sandbox even when | |
| 33 | - | // XDG_RUNTIME_DIR is set. This prevents browser-launch relay collisions | |
| 34 | - | // between the installed app and a repo-local ENV=dev instance. | |
| 35 | 32 | if os.Getenv("ENV") != "dev" { | |
| 36 | 33 | if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" { | |
| 37 | 34 | return dir, nil | |
| @@ -42,6 +39,9 @@ func (a *Adapter) RuntimeDir() (string, error) { | |||
| 42 | 39 | if err != nil { | |
| 43 | 40 | return "", err | |
| 44 | 41 | } | |
| 42 | + | if os.Getenv("ENV") == "dev" { | |
| 43 | + | return filepath.Join(filepath.Dir(stateDir), "runtime"), nil | |
| 44 | + | } | |
| 45 | 45 | return filepath.Join(stateDir, "runtime"), nil | |
| 46 | 46 | } | |
| 47 | 47 | ||
| @@ -29,9 +29,6 @@ func (a *Adapter) StateDir() (string, error) { | |||||
| 29 | } | 29 | } | ||
| 30 | 30 | ||||
| 31 | func (a *Adapter) RuntimeDir() (string, error) { | 31 | func (a *Adapter) RuntimeDir() (string, error) { | ||
| 32 | - | // In dev mode, keep runtime IPC isolated inside the .dev sandbox even when | |||
| 33 | - | // XDG_RUNTIME_DIR is set. This prevents browser-launch relay collisions | |||
| 34 | - | // between the installed app and a repo-local ENV=dev instance. | |||
| 35 | if os.Getenv("ENV") != "dev" { | 32 | if os.Getenv("ENV") != "dev" { | ||
| 36 | if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" { | 33 | if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" { | ||
| 37 | return dir, nil | 34 | return dir, nil | ||
| @@ -42,6 +39,9 @@ func (a *Adapter) RuntimeDir() (string, error) { | |||||
| 42 | if err != nil { | 39 | if err != nil { | ||
| 43 | return "", err | 40 | return "", err | ||
| 44 | } | 41 | } | ||
| 42 | + | if os.Getenv("ENV") == "dev" { | |||
| 43 | + | return filepath.Join(filepath.Dir(stateDir), "runtime"), nil | |||
| 44 | + | } | |||
| 45 | return filepath.Join(stateDir, "runtime"), nil | 45 | return filepath.Join(stateDir, "runtime"), nil | ||
| 46 | } | 46 | } | ||
| 47 | 47 | ||||
M internal/ui/app_clipboard_test.go +11 -35
| @@ -5,47 +5,23 @@ import ( | |||
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 8 | 9 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | 10 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 11 | + | "github.com/stretchr/testify/mock" | |
| 10 | 12 | "github.com/stretchr/testify/require" | |
| 11 | 13 | ) | |
| 12 | 14 | ||
| 13 | - | type fakeClipboardEngine struct { | |
| 14 | - | registerDeps port.HandlerDependencies | |
| 15 | - | } | |
| 16 | - | ||
| 17 | - | var _ port.Engine = (*fakeClipboardEngine)(nil) | |
| 18 | - | ||
| 19 | - | func (f *fakeClipboardEngine) Factory() port.WebViewFactory { return nil } | |
| 20 | - | func (f *fakeClipboardEngine) Pool() port.WebViewPool { return nil } | |
| 21 | - | func (f *fakeClipboardEngine) ContentInjector() port.ContentInjector { return nil } | |
| 22 | - | func (f *fakeClipboardEngine) SettingsApplier() port.SettingsApplier { return nil } | |
| 23 | - | func (f *fakeClipboardEngine) FilterApplier() port.FilterApplier { return nil } | |
| 24 | - | func (f *fakeClipboardEngine) FaviconDatabase() port.FaviconDatabase { return nil } | |
| 25 | - | func (f *fakeClipboardEngine) InternalSchemePath() string { return "" } | |
| 26 | - | func (f *fakeClipboardEngine) Close() error { return nil } | |
| 27 | - | func (f *fakeClipboardEngine) RegisterHandlers(_ context.Context, deps port.HandlerDependencies) error { | |
| 28 | - | f.registerDeps = deps | |
| 29 | - | return nil | |
| 30 | - | } | |
| 31 | - | func (f *fakeClipboardEngine) RegisterAccentHandlers(context.Context, port.AccentKeyHandler) error { | |
| 32 | - | return nil | |
| 33 | - | } | |
| 34 | - | func (f *fakeClipboardEngine) ConfigureDownloads(context.Context, string, port.DownloadEventHandler, port.DownloadPreparer) error { | |
| 35 | - | return nil | |
| 36 | - | } | |
| 37 | - | func (f *fakeClipboardEngine) OnToolkitReady(context.Context) error { return nil } | |
| 38 | - | func (f *fakeClipboardEngine) UpdateAppearance(context.Context, float64, float64, float64, float64) error { | |
| 39 | - | return nil | |
| 40 | - | } | |
| 41 | - | func (f *fakeClipboardEngine) UpdateSettings(context.Context, port.EngineSettingsUpdate) error { | |
| 42 | - | return nil | |
| 43 | - | } | |
| 44 | - | func (f *fakeClipboardEngine) SetHandlerContext(context.Context) {} | |
| 45 | - | ||
| 46 | 15 | func TestNew_DoesNotWireClipboardOrchestratorWhenClipboardMissing(t *testing.T) { | |
| 47 | 16 | ctx := context.Background() | |
| 48 | - | engine := &fakeClipboardEngine{} | |
| 17 | + | engine := portmocks.NewMockEngine(t) | |
| 18 | + | var registerDeps port.HandlerDependencies | |
| 19 | + | engine.EXPECT().RegisterHandlers(mock.Anything, mock.Anything). | |
| 20 | + | Run(func(_ context.Context, deps port.HandlerDependencies) { | |
| 21 | + | registerDeps = deps | |
| 22 | + | }). | |
| 23 | + | Return(nil). | |
| 24 | + | Once() | |
| 49 | 25 | ||
| 50 | 26 | deps := &Dependencies{ | |
| 51 | 27 | Ctx: ctx, | |
| @@ -61,5 +37,5 @@ func TestNew_DoesNotWireClipboardOrchestratorWhenClipboardMissing(t *testing.T) | |||
| 61 | 37 | app, err := New(deps) | |
| 62 | 38 | require.NoError(t, err) | |
| 63 | 39 | require.NotNil(t, app) | |
| 64 | - | require.Nil(t, engine.registerDeps.ClipboardTextOrchestrator) | |
| 40 | + | require.Nil(t, registerDeps.ClipboardTextOrchestrator) | |
| 65 | 41 | } | |
| @@ -5,47 +5,23 @@ import ( | |||||
| 5 | "testing" | 5 | "testing" | ||
| 6 | 6 | ||||
| 7 | "github.com/bnema/dumber/internal/application/port" | 7 | "github.com/bnema/dumber/internal/application/port" | ||
| 8 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 8 | "github.com/bnema/dumber/internal/domain/entity" | 9 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 9 | "github.com/bnema/dumber/internal/infrastructure/config" | 10 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 11 | + | "github.com/stretchr/testify/mock" | |||
| 10 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" | ||
| 11 | ) | 13 | ) | ||
| 12 | 14 | ||||
| 13 | - | type fakeClipboardEngine struct { | |||
| 14 | - | registerDeps port.HandlerDependencies | |||
| 15 | - | } | |||
| 16 | - | ||||
| 17 | - | var _ port.Engine = (*fakeClipboardEngine)(nil) | |||
| 18 | - | ||||
| 19 | - | func (f *fakeClipboardEngine) Factory() port.WebViewFactory { return nil } | |||
| 20 | - | func (f *fakeClipboardEngine) Pool() port.WebViewPool { return nil } | |||
| 21 | - | func (f *fakeClipboardEngine) ContentInjector() port.ContentInjector { return nil } | |||
| 22 | - | func (f *fakeClipboardEngine) SettingsApplier() port.SettingsApplier { return nil } | |||
| 23 | - | func (f *fakeClipboardEngine) FilterApplier() port.FilterApplier { return nil } | |||
| 24 | - | func (f *fakeClipboardEngine) FaviconDatabase() port.FaviconDatabase { return nil } | |||
| 25 | - | func (f *fakeClipboardEngine) InternalSchemePath() string { return "" } | |||
| 26 | - | func (f *fakeClipboardEngine) Close() error { return nil } | |||
| 27 | - | func (f *fakeClipboardEngine) RegisterHandlers(_ context.Context, deps port.HandlerDependencies) error { | |||
| 28 | - | f.registerDeps = deps | |||
| 29 | - | return nil | |||
| 30 | - | } | |||
| 31 | - | func (f *fakeClipboardEngine) RegisterAccentHandlers(context.Context, port.AccentKeyHandler) error { | |||
| 32 | - | return nil | |||
| 33 | - | } | |||
| 34 | - | func (f *fakeClipboardEngine) ConfigureDownloads(context.Context, string, port.DownloadEventHandler, port.DownloadPreparer) error { | |||
| 35 | - | return nil | |||
| 36 | - | } | |||
| 37 | - | func (f *fakeClipboardEngine) OnToolkitReady(context.Context) error { return nil } | |||
| 38 | - | func (f *fakeClipboardEngine) UpdateAppearance(context.Context, float64, float64, float64, float64) error { | |||
| 39 | - | return nil | |||
| 40 | - | } | |||
| 41 | - | func (f *fakeClipboardEngine) UpdateSettings(context.Context, port.EngineSettingsUpdate) error { | |||
| 42 | - | return nil | |||
| 43 | - | } | |||
| 44 | - | func (f *fakeClipboardEngine) SetHandlerContext(context.Context) {} | |||
| 45 | - | ||||
| 46 | func TestNew_DoesNotWireClipboardOrchestratorWhenClipboardMissing(t *testing.T) { | 15 | func TestNew_DoesNotWireClipboardOrchestratorWhenClipboardMissing(t *testing.T) { | ||
| 47 | ctx := context.Background() | 16 | ctx := context.Background() | ||
| 48 | - | engine := &fakeClipboardEngine{} | 17 | + | engine := portmocks.NewMockEngine(t) |
| 18 | + | var registerDeps port.HandlerDependencies | |||
| 19 | + | engine.EXPECT().RegisterHandlers(mock.Anything, mock.Anything). | |||
| 20 | + | Run(func(_ context.Context, deps port.HandlerDependencies) { | |||
| 21 | + | registerDeps = deps | |||
| 22 | + | }). | |||
| 23 | + | Return(nil). | |||
| 24 | + | Once() | |||
| 49 | 25 | ||||
| 50 | deps := &Dependencies{ | 26 | deps := &Dependencies{ | ||
| 51 | Ctx: ctx, | 27 | Ctx: ctx, | ||
| @@ -61,5 +37,5 @@ func TestNew_DoesNotWireClipboardOrchestratorWhenClipboardMissing(t *testing.T) | |||||
| 61 | app, err := New(deps) | 37 | app, err := New(deps) | ||
| 62 | require.NoError(t, err) | 38 | require.NoError(t, err) | ||
| 63 | require.NotNil(t, app) | 39 | require.NotNil(t, app) | ||
| 64 | - | require.Nil(t, engine.registerDeps.ClipboardTextOrchestrator) | 40 | + | require.Nil(t, registerDeps.ClipboardTextOrchestrator) |
| 65 | } | 41 | } | ||
M internal/ui/component/progress_bar.go +0 -3
| @@ -37,7 +37,6 @@ type ProgressBar struct { | |||
| 37 | 37 | timeoutTimer uint // Timer source ID for auto-hide timeout | |
| 38 | 38 | lastShowAt time.Time | |
| 39 | 39 | lastProgressAt time.Time | |
| 40 | - | lastHideReason string | |
| 41 | 40 | progressEventSeq uint64 | |
| 42 | 41 | ||
| 43 | 42 | mu sync.Mutex | |
| @@ -191,7 +190,6 @@ func (pb *ProgressBar) Show() { | |||
| 191 | 190 | if !pb.visible { | |
| 192 | 191 | pb.visible = true | |
| 193 | 192 | pb.lastShowAt = time.Now() | |
| 194 | - | pb.lastHideReason = "" | |
| 195 | 193 | // Set an initial non-zero fraction so the bar is visually noticeable | |
| 196 | 194 | // immediately. Without this, the bar is technically visible but | |
| 197 | 195 | // renders as empty (0% fill) until the first progress callback, | |
| @@ -282,7 +280,6 @@ func (pb *ProgressBar) hideInternal(reason string) { | |||
| 282 | 280 | ||
| 283 | 281 | if pb.visible { | |
| 284 | 282 | pb.visible = false | |
| 285 | - | pb.lastHideReason = reason | |
| 286 | 283 | pb.progressBar.SetVisible(false) | |
| 287 | 284 | ||
| 288 | 285 | // Stop any running animation | |
| @@ -37,7 +37,6 @@ type ProgressBar struct { | |||||
| 37 | timeoutTimer uint // Timer source ID for auto-hide timeout | 37 | timeoutTimer uint // Timer source ID for auto-hide timeout | ||
| 38 | lastShowAt time.Time | 38 | lastShowAt time.Time | ||
| 39 | lastProgressAt time.Time | 39 | lastProgressAt time.Time | ||
| 40 | - | lastHideReason string | |||
| 41 | progressEventSeq uint64 | 40 | progressEventSeq uint64 | ||
| 42 | 41 | ||||
| 43 | mu sync.Mutex | 42 | mu sync.Mutex | ||
| @@ -191,7 +190,6 @@ func (pb *ProgressBar) Show() { | |||||
| 191 | if !pb.visible { | 190 | if !pb.visible { | ||
| 192 | pb.visible = true | 191 | pb.visible = true | ||
| 193 | pb.lastShowAt = time.Now() | 192 | pb.lastShowAt = time.Now() | ||
| 194 | - | pb.lastHideReason = "" | |||
| 195 | // Set an initial non-zero fraction so the bar is visually noticeable | 193 | // Set an initial non-zero fraction so the bar is visually noticeable | ||
| 196 | // immediately. Without this, the bar is technically visible but | 194 | // immediately. Without this, the bar is technically visible but | ||
| 197 | // renders as empty (0% fill) until the first progress callback, | 195 | // renders as empty (0% fill) until the first progress callback, | ||
| @@ -282,7 +280,6 @@ func (pb *ProgressBar) hideInternal(reason string) { | |||||
| 282 | 280 | ||||
| 283 | if pb.visible { | 281 | if pb.visible { | ||
| 284 | pb.visible = false | 282 | pb.visible = false | ||
| 285 | - | pb.lastHideReason = reason | |||
| 286 | pb.progressBar.SetVisible(false) | 283 | pb.progressBar.SetVisible(false) | ||
| 287 | 284 | ||||
| 288 | // Stop any running animation | 285 | // Stop any running animation | ||