chore: complete hygiene sweep
- commit
- f3187c56ded9677e9287d816a1ed1ba068f8879c
- author
- brice · · 2026-07-08 16:26 UTC
- committer
- brice · · 2026-07-08 20:59 UTC
- parent
- b4f3d9b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +211 -10
M AGENTS.md +4 -1
| @@ -16,11 +16,14 @@ go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -bench=. -benchmem | |||
| 16 | 16 | ``` | |
| 17 | 17 | ||
| 18 | 18 | - Format with **goimports**. | |
| 19 | + | - Install dev tools once with `go install golang.org/x/tools/cmd/goimports@latest` and `go install github.com/vektra/mockery/v2@latest`. | |
| 19 | 20 | - `make test` runs `go test ./... -race`. | |
| 20 | 21 | - `make lint` checks `goimports -l`, then `go vet`. | |
| 21 | 22 | - Enable daemon pprof with `VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon`. | |
| 23 | + | - Set `VEV_LOG=debug|warn|error` to change log verbosity (default info). | |
| 22 | 24 | - Logs live in `$XDG_STATE_HOME/vev`, or `~/.local/state/vev` when unset. | |
| 23 | - | - Use feature worktrees under `.worktrees/<branch-name>`. | |
| 25 | + | - Use feature worktrees under `.worktrees/<branch-name>`. Remove the worktree once its branch is merged or abandoned (`git worktree remove <path>`). | |
| 26 | + | - Create vev-owned private directories with `pkg/safedir.EnsurePrivate`, not `os.MkdirAll`. | |
| 24 | 27 | ||
| 25 | 28 | ## Architecture rules | |
| 26 | 29 | ||
| @@ -16,11 +16,14 @@ go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -bench=. -benchmem | |||||
| 16 | ``` | 16 | ``` | ||
| 17 | 17 | ||||
| 18 | - Format with **goimports**. | 18 | - Format with **goimports**. | ||
| 19 | + | - Install dev tools once with `go install golang.org/x/tools/cmd/goimports@latest` and `go install github.com/vektra/mockery/v2@latest`. | |||
| 19 | - `make test` runs `go test ./... -race`. | 20 | - `make test` runs `go test ./... -race`. | ||
| 20 | - `make lint` checks `goimports -l`, then `go vet`. | 21 | - `make lint` checks `goimports -l`, then `go vet`. | ||
| 21 | - Enable daemon pprof with `VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon`. | 22 | - Enable daemon pprof with `VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon`. | ||
| 23 | + | - Set `VEV_LOG=debug|warn|error` to change log verbosity (default info). | |||
| 22 | - Logs live in `$XDG_STATE_HOME/vev`, or `~/.local/state/vev` when unset. | 24 | - Logs live in `$XDG_STATE_HOME/vev`, or `~/.local/state/vev` when unset. | ||
| 23 | - | - Use feature worktrees under `.worktrees/<branch-name>`. | 25 | + | - Use feature worktrees under `.worktrees/<branch-name>`. Remove the worktree once its branch is merged or abandoned (`git worktree remove <path>`). |
| 26 | + | - Create vev-owned private directories with `pkg/safedir.EnsurePrivate`, not `os.MkdirAll`. | |||
| 24 | 27 | ||||
| 25 | ## Architecture rules | 28 | ## Architecture rules | ||
| 26 | 29 | ||||
M README.md +2 -0
| @@ -208,6 +208,8 @@ Copy uses OSC 52 clipboard sequences when supported by your terminal. | |||
| 208 | 208 | ## Development | |
| 209 | 209 | ||
| 210 | 210 | ```sh | |
| 211 | + | go install golang.org/x/tools/cmd/goimports@latest | |
| 212 | + | go install github.com/vektra/mockery/v2@latest | |
| 211 | 213 | make test # go test ./... -race | |
| 212 | 214 | make lint # goimports check, then go vet ./... | |
| 213 | 215 | make mocks # regenerate mocks | |
| @@ -208,6 +208,8 @@ Copy uses OSC 52 clipboard sequences when supported by your terminal. | |||||
| 208 | ## Development | 208 | ## Development | ||
| 209 | 209 | ||||
| 210 | ```sh | 210 | ```sh | ||
| 211 | + | go install golang.org/x/tools/cmd/goimports@latest | |||
| 212 | + | go install github.com/vektra/mockery/v2@latest | |||
| 211 | make test # go test ./... -race | 213 | make test # go test ./... -race | ||
| 212 | make lint # goimports check, then go vet ./... | 214 | make lint # goimports check, then go vet ./... | ||
| 213 | make mocks # regenerate mocks | 215 | make mocks # regenerate mocks | ||
A internal/adapters/clock/clock_test.go +56 -0
| @@ -0,0 +1,56 @@ | |||
| 1 | + | package clock | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestRealClockNow(t *testing.T) { | |
| 9 | + | c := New() | |
| 10 | + | before := time.Now().Add(-time.Second) | |
| 11 | + | got := c.Now() | |
| 12 | + | after := time.Now().Add(time.Second) | |
| 13 | + | ||
| 14 | + | if got.Before(before) || got.After(after) { | |
| 15 | + | t.Fatalf("Now() = %v, want between %v and %v", got, before, after) | |
| 16 | + | } | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func TestRealClockTimerFires(t *testing.T) { | |
| 20 | + | c := New() | |
| 21 | + | timer := c.NewTimer(10 * time.Millisecond) | |
| 22 | + | defer timer.Stop() | |
| 23 | + | ||
| 24 | + | select { | |
| 25 | + | case <-timer.C(): | |
| 26 | + | case <-time.After(5 * time.Second): | |
| 27 | + | t.Fatal("timer did not fire before deadline") | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestRealClockTimerStopKeepsChannelQuiet(t *testing.T) { | |
| 32 | + | c := New() | |
| 33 | + | timer := c.NewTimer(time.Hour) | |
| 34 | + | _ = timer.Stop() | |
| 35 | + | ||
| 36 | + | select { | |
| 37 | + | case <-timer.C(): | |
| 38 | + | t.Fatal("stopped timer fired") | |
| 39 | + | case <-time.After(25 * time.Millisecond): | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestRealClockTimerResetRearms(t *testing.T) { | |
| 44 | + | c := New() | |
| 45 | + | timer := c.NewTimer(time.Hour) | |
| 46 | + | defer timer.Stop() | |
| 47 | + | ||
| 48 | + | _ = timer.Stop() | |
| 49 | + | timer.Reset(10 * time.Millisecond) | |
| 50 | + | ||
| 51 | + | select { | |
| 52 | + | case <-timer.C(): | |
| 53 | + | case <-time.After(5 * time.Second): | |
| 54 | + | t.Fatal("reset timer did not fire before deadline") | |
| 55 | + | } | |
| 56 | + | } | |
| @@ -0,0 +1,56 @@ | |||||
| 1 | + | package clock | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestRealClockNow(t *testing.T) { | |||
| 9 | + | c := New() | |||
| 10 | + | before := time.Now().Add(-time.Second) | |||
| 11 | + | got := c.Now() | |||
| 12 | + | after := time.Now().Add(time.Second) | |||
| 13 | + | ||||
| 14 | + | if got.Before(before) || got.After(after) { | |||
| 15 | + | t.Fatalf("Now() = %v, want between %v and %v", got, before, after) | |||
| 16 | + | } | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func TestRealClockTimerFires(t *testing.T) { | |||
| 20 | + | c := New() | |||
| 21 | + | timer := c.NewTimer(10 * time.Millisecond) | |||
| 22 | + | defer timer.Stop() | |||
| 23 | + | ||||
| 24 | + | select { | |||
| 25 | + | case <-timer.C(): | |||
| 26 | + | case <-time.After(5 * time.Second): | |||
| 27 | + | t.Fatal("timer did not fire before deadline") | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func TestRealClockTimerStopKeepsChannelQuiet(t *testing.T) { | |||
| 32 | + | c := New() | |||
| 33 | + | timer := c.NewTimer(time.Hour) | |||
| 34 | + | _ = timer.Stop() | |||
| 35 | + | ||||
| 36 | + | select { | |||
| 37 | + | case <-timer.C(): | |||
| 38 | + | t.Fatal("stopped timer fired") | |||
| 39 | + | case <-time.After(25 * time.Millisecond): | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func TestRealClockTimerResetRearms(t *testing.T) { | |||
| 44 | + | c := New() | |||
| 45 | + | timer := c.NewTimer(time.Hour) | |||
| 46 | + | defer timer.Stop() | |||
| 47 | + | ||||
| 48 | + | _ = timer.Stop() | |||
| 49 | + | timer.Reset(10 * time.Millisecond) | |||
| 50 | + | ||||
| 51 | + | select { | |||
| 52 | + | case <-timer.C(): | |||
| 53 | + | case <-time.After(5 * time.Second): | |||
| 54 | + | t.Fatal("reset timer did not fire before deadline") | |||
| 55 | + | } | |||
| 56 | + | } | |||
M internal/app/run.go +15 -0
| @@ -262,6 +262,18 @@ func snapshotDir() string { | |||
| 262 | 262 | return filepath.Join(platform.StateDir(), "snapshots") | |
| 263 | 263 | } | |
| 264 | 264 | ||
| 265 | + | func pprofAddrIsLoopback(addr string) bool { | |
| 266 | + | host, _, err := net.SplitHostPort(addr) | |
| 267 | + | if err != nil || host == "" { | |
| 268 | + | return false | |
| 269 | + | } | |
| 270 | + | if strings.EqualFold(host, "localhost") { | |
| 271 | + | return true | |
| 272 | + | } | |
| 273 | + | ip := net.ParseIP(host) | |
| 274 | + | return ip != nil && ip.IsLoopback() | |
| 275 | + | } | |
| 276 | + | ||
| 265 | 277 | // runDaemon runs the daemon in the foreground (the hidden --daemon path, | |
| 266 | 278 | // entered by an auto-spawned child): it sets up logging, binds the socket, | |
| 267 | 279 | // constructs the daemon, and serves until the last session exits or a | |
| @@ -284,6 +296,9 @@ func runDaemon() error { | |||
| 284 | 296 | defer stop() | |
| 285 | 297 | ||
| 286 | 298 | if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" { | |
| 299 | + | if !pprofAddrIsLoopback(addr) { | |
| 300 | + | log.Warn("pprof bound to non-loopback address; /debug/pprof is unauthenticated", "addr", addr) | |
| 301 | + | } | |
| 287 | 302 | go func() { | |
| 288 | 303 | if err := http.ListenAndServe(addr, nil); err != nil && !errors.Is(err, http.ErrServerClosed) { | |
| 289 | 304 | log.Error("pprof server exited", "err", err) | |
| @@ -262,6 +262,18 @@ func snapshotDir() string { | |||||
| 262 | return filepath.Join(platform.StateDir(), "snapshots") | 262 | return filepath.Join(platform.StateDir(), "snapshots") | ||
| 263 | } | 263 | } | ||
| 264 | 264 | ||||
| 265 | + | func pprofAddrIsLoopback(addr string) bool { | |||
| 266 | + | host, _, err := net.SplitHostPort(addr) | |||
| 267 | + | if err != nil || host == "" { | |||
| 268 | + | return false | |||
| 269 | + | } | |||
| 270 | + | if strings.EqualFold(host, "localhost") { | |||
| 271 | + | return true | |||
| 272 | + | } | |||
| 273 | + | ip := net.ParseIP(host) | |||
| 274 | + | return ip != nil && ip.IsLoopback() | |||
| 275 | + | } | |||
| 276 | + | ||||
| 265 | // runDaemon runs the daemon in the foreground (the hidden --daemon path, | 277 | // runDaemon runs the daemon in the foreground (the hidden --daemon path, | ||
| 266 | // entered by an auto-spawned child): it sets up logging, binds the socket, | 278 | // entered by an auto-spawned child): it sets up logging, binds the socket, | ||
| 267 | // constructs the daemon, and serves until the last session exits or a | 279 | // constructs the daemon, and serves until the last session exits or a | ||
| @@ -284,6 +296,9 @@ func runDaemon() error { | |||||
| 284 | defer stop() | 296 | defer stop() | ||
| 285 | 297 | ||||
| 286 | if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" { | 298 | if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" { | ||
| 299 | + | if !pprofAddrIsLoopback(addr) { | |||
| 300 | + | log.Warn("pprof bound to non-loopback address; /debug/pprof is unauthenticated", "addr", addr) | |||
| 301 | + | } | |||
| 287 | go func() { | 302 | go func() { | ||
| 288 | if err := http.ListenAndServe(addr, nil); err != nil && !errors.Is(err, http.ErrServerClosed) { | 303 | if err := http.ListenAndServe(addr, nil); err != nil && !errors.Is(err, http.ErrServerClosed) { | ||
| 289 | log.Error("pprof server exited", "err", err) | 304 | log.Error("pprof server exited", "err", err) | ||
M internal/app/run_test.go +23 -0
| @@ -753,3 +753,26 @@ func TestListenUDPInRange(t *testing.T) { | |||
| 753 | 753 | } | |
| 754 | 754 | }) | |
| 755 | 755 | } | |
| 756 | + | ||
| 757 | + | func TestPprofAddrIsLoopback(t *testing.T) { | |
| 758 | + | tests := []struct { | |
| 759 | + | addr string | |
| 760 | + | want bool | |
| 761 | + | }{ | |
| 762 | + | {addr: "127.0.0.1:6060", want: true}, | |
| 763 | + | {addr: "localhost:6060", want: true}, | |
| 764 | + | {addr: "[::1]:6060", want: true}, | |
| 765 | + | {addr: ":6060", want: false}, | |
| 766 | + | {addr: "0.0.0.0:6060", want: false}, | |
| 767 | + | {addr: "192.168.1.5:6060", want: false}, | |
| 768 | + | {addr: "garbage", want: false}, | |
| 769 | + | } | |
| 770 | + | ||
| 771 | + | for _, tt := range tests { | |
| 772 | + | t.Run(tt.addr, func(t *testing.T) { | |
| 773 | + | if got := pprofAddrIsLoopback(tt.addr); got != tt.want { | |
| 774 | + | t.Fatalf("pprofAddrIsLoopback(%q) = %v, want %v", tt.addr, got, tt.want) | |
| 775 | + | } | |
| 776 | + | }) | |
| 777 | + | } | |
| 778 | + | } | |
| @@ -753,3 +753,26 @@ func TestListenUDPInRange(t *testing.T) { | |||||
| 753 | } | 753 | } | ||
| 754 | }) | 754 | }) | ||
| 755 | } | 755 | } | ||
| 756 | + | ||||
| 757 | + | func TestPprofAddrIsLoopback(t *testing.T) { | |||
| 758 | + | tests := []struct { | |||
| 759 | + | addr string | |||
| 760 | + | want bool | |||
| 761 | + | }{ | |||
| 762 | + | {addr: "127.0.0.1:6060", want: true}, | |||
| 763 | + | {addr: "localhost:6060", want: true}, | |||
| 764 | + | {addr: "[::1]:6060", want: true}, | |||
| 765 | + | {addr: ":6060", want: false}, | |||
| 766 | + | {addr: "0.0.0.0:6060", want: false}, | |||
| 767 | + | {addr: "192.168.1.5:6060", want: false}, | |||
| 768 | + | {addr: "garbage", want: false}, | |||
| 769 | + | } | |||
| 770 | + | ||||
| 771 | + | for _, tt := range tests { | |||
| 772 | + | t.Run(tt.addr, func(t *testing.T) { | |||
| 773 | + | if got := pprofAddrIsLoopback(tt.addr); got != tt.want { | |||
| 774 | + | t.Fatalf("pprofAddrIsLoopback(%q) = %v, want %v", tt.addr, got, tt.want) | |||
| 775 | + | } | |||
| 776 | + | }) | |||
| 777 | + | } | |||
| 778 | + | } | |||
M internal/platform/dir_test.go +102 -4
| @@ -1,24 +1,91 @@ | |||
| 1 | 1 | package platform | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "os" | |
| 4 | 5 | "path/filepath" | |
| 6 | + | "strings" | |
| 5 | 7 | "testing" | |
| 6 | 8 | ) | |
| 7 | 9 | ||
| 10 | + | func TestStateDir(t *testing.T) { | |
| 11 | + | tests := []struct { | |
| 12 | + | name string | |
| 13 | + | xdgState string | |
| 14 | + | home string | |
| 15 | + | want func(*testing.T, string, string) string | |
| 16 | + | }{ | |
| 17 | + | { | |
| 18 | + | name: "xdg state home", | |
| 19 | + | xdgState: filepath.Join(t.TempDir(), "xdg-state"), | |
| 20 | + | home: filepath.Join(t.TempDir(), "home"), | |
| 21 | + | want: func(_ *testing.T, xdgState, _ string) string { | |
| 22 | + | return filepath.Join(xdgState, "vev") | |
| 23 | + | }, | |
| 24 | + | }, | |
| 25 | + | { | |
| 26 | + | name: "home fallback", | |
| 27 | + | home: filepath.Join(t.TempDir(), "home"), | |
| 28 | + | want: func(_ *testing.T, _, home string) string { | |
| 29 | + | return filepath.Join(home, ".local", "state", "vev") | |
| 30 | + | }, | |
| 31 | + | }, | |
| 32 | + | { | |
| 33 | + | name: "temp fallback without home", | |
| 34 | + | want: func(t *testing.T, _, _ string) string { | |
| 35 | + | t.Helper() | |
| 36 | + | return filepath.Join(os.TempDir(), "vev-state-") | |
| 37 | + | }, | |
| 38 | + | }, | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | for _, tt := range tests { | |
| 42 | + | t.Run(tt.name, func(t *testing.T) { | |
| 43 | + | t.Setenv("XDG_STATE_HOME", tt.xdgState) | |
| 44 | + | t.Setenv("HOME", tt.home) | |
| 45 | + | ||
| 46 | + | want := tt.want(t, tt.xdgState, tt.home) | |
| 47 | + | got := StateDir() | |
| 48 | + | if strings.HasSuffix(want, "vev-state-") { | |
| 49 | + | if !strings.HasPrefix(got, want) { | |
| 50 | + | t.Fatalf("StateDir() = %q, want prefix %q", got, want) | |
| 51 | + | } | |
| 52 | + | return | |
| 53 | + | } | |
| 54 | + | if got != want { | |
| 55 | + | t.Fatalf("StateDir() = %q, want %q", got, want) | |
| 56 | + | } | |
| 57 | + | }) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 8 | 61 | func TestConfigPath(t *testing.T) { | |
| 9 | 62 | tests := []struct { | |
| 10 | 63 | name string | |
| 11 | 64 | xdgConfig string | |
| 12 | 65 | home string | |
| 66 | + | want func(*testing.T, string, string) string | |
| 13 | 67 | }{ | |
| 14 | 68 | { | |
| 15 | 69 | name: "xdg config home", | |
| 16 | 70 | xdgConfig: filepath.Join(t.TempDir(), "xdg-config"), | |
| 17 | 71 | home: filepath.Join(t.TempDir(), "home"), | |
| 72 | + | want: func(_ *testing.T, xdgConfig, _ string) string { | |
| 73 | + | return filepath.Join(xdgConfig, "vev", "config") | |
| 74 | + | }, | |
| 18 | 75 | }, | |
| 19 | 76 | { | |
| 20 | 77 | name: "home fallback", | |
| 21 | 78 | home: filepath.Join(t.TempDir(), "home"), | |
| 79 | + | want: func(_ *testing.T, _, home string) string { | |
| 80 | + | return filepath.Join(home, ".config", "vev", "config") | |
| 81 | + | }, | |
| 82 | + | }, | |
| 83 | + | { | |
| 84 | + | name: "temp fallback without home", | |
| 85 | + | want: func(t *testing.T, _, _ string) string { | |
| 86 | + | t.Helper() | |
| 87 | + | return filepath.Join(os.TempDir(), "vev-config-") | |
| 88 | + | }, | |
| 22 | 89 | }, | |
| 23 | 90 | } | |
| 24 | 91 | ||
| @@ -27,13 +94,44 @@ func TestConfigPath(t *testing.T) { | |||
| 27 | 94 | t.Setenv("XDG_CONFIG_HOME", tt.xdgConfig) | |
| 28 | 95 | t.Setenv("HOME", tt.home) | |
| 29 | 96 | ||
| 30 | - | want := filepath.Join(tt.xdgConfig, "vev", "config") | |
| 31 | - | if tt.xdgConfig == "" { | |
| 32 | - | want = filepath.Join(tt.home, ".config", "vev", "config") | |
| 97 | + | want := tt.want(t, tt.xdgConfig, tt.home) | |
| 98 | + | got := ConfigPath() | |
| 99 | + | if strings.HasSuffix(want, "vev-config-") { | |
| 100 | + | if !strings.HasPrefix(got, want) || filepath.Base(got) != "config" { | |
| 101 | + | t.Fatalf("ConfigPath() = %q, want prefix %q and base config", got, want) | |
| 102 | + | } | |
| 103 | + | return | |
| 33 | 104 | } | |
| 34 | - | if got := ConfigPath(); got != want { | |
| 105 | + | if got != want { | |
| 35 | 106 | t.Fatalf("ConfigPath() = %q, want %q", got, want) | |
| 36 | 107 | } | |
| 37 | 108 | }) | |
| 38 | 109 | } | |
| 39 | 110 | } | |
| 111 | + | ||
| 112 | + | func TestDirOrHome(t *testing.T) { | |
| 113 | + | existing := t.TempDir() | |
| 114 | + | home := filepath.Join(t.TempDir(), "home") | |
| 115 | + | nonexistent := filepath.Join(t.TempDir(), "missing") | |
| 116 | + | ||
| 117 | + | tests := []struct { | |
| 118 | + | name string | |
| 119 | + | dir string | |
| 120 | + | home string | |
| 121 | + | want string | |
| 122 | + | }{ | |
| 123 | + | {name: "existing dir", dir: existing, home: home, want: existing}, | |
| 124 | + | {name: "non-existent dir falls back to home", dir: nonexistent, home: home, want: home}, | |
| 125 | + | {name: "non-existent dir without home falls back to root", dir: nonexistent, want: "/"}, | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | for _, tt := range tests { | |
| 129 | + | t.Run(tt.name, func(t *testing.T) { | |
| 130 | + | t.Setenv("HOME", tt.home) | |
| 131 | + | ||
| 132 | + | if got := DirOrHome(tt.dir); got != tt.want { | |
| 133 | + | t.Fatalf("DirOrHome(%q) = %q, want %q", tt.dir, got, tt.want) | |
| 134 | + | } | |
| 135 | + | }) | |
| 136 | + | } | |
| 137 | + | } | |
| @@ -1,24 +1,91 @@ | |||||
| 1 | package platform | 1 | package platform | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "os" | |||
| 4 | "path/filepath" | 5 | "path/filepath" | ||
| 6 | + | "strings" | |||
| 5 | "testing" | 7 | "testing" | ||
| 6 | ) | 8 | ) | ||
| 7 | 9 | ||||
| 10 | + | func TestStateDir(t *testing.T) { | |||
| 11 | + | tests := []struct { | |||
| 12 | + | name string | |||
| 13 | + | xdgState string | |||
| 14 | + | home string | |||
| 15 | + | want func(*testing.T, string, string) string | |||
| 16 | + | }{ | |||
| 17 | + | { | |||
| 18 | + | name: "xdg state home", | |||
| 19 | + | xdgState: filepath.Join(t.TempDir(), "xdg-state"), | |||
| 20 | + | home: filepath.Join(t.TempDir(), "home"), | |||
| 21 | + | want: func(_ *testing.T, xdgState, _ string) string { | |||
| 22 | + | return filepath.Join(xdgState, "vev") | |||
| 23 | + | }, | |||
| 24 | + | }, | |||
| 25 | + | { | |||
| 26 | + | name: "home fallback", | |||
| 27 | + | home: filepath.Join(t.TempDir(), "home"), | |||
| 28 | + | want: func(_ *testing.T, _, home string) string { | |||
| 29 | + | return filepath.Join(home, ".local", "state", "vev") | |||
| 30 | + | }, | |||
| 31 | + | }, | |||
| 32 | + | { | |||
| 33 | + | name: "temp fallback without home", | |||
| 34 | + | want: func(t *testing.T, _, _ string) string { | |||
| 35 | + | t.Helper() | |||
| 36 | + | return filepath.Join(os.TempDir(), "vev-state-") | |||
| 37 | + | }, | |||
| 38 | + | }, | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | for _, tt := range tests { | |||
| 42 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 43 | + | t.Setenv("XDG_STATE_HOME", tt.xdgState) | |||
| 44 | + | t.Setenv("HOME", tt.home) | |||
| 45 | + | ||||
| 46 | + | want := tt.want(t, tt.xdgState, tt.home) | |||
| 47 | + | got := StateDir() | |||
| 48 | + | if strings.HasSuffix(want, "vev-state-") { | |||
| 49 | + | if !strings.HasPrefix(got, want) { | |||
| 50 | + | t.Fatalf("StateDir() = %q, want prefix %q", got, want) | |||
| 51 | + | } | |||
| 52 | + | return | |||
| 53 | + | } | |||
| 54 | + | if got != want { | |||
| 55 | + | t.Fatalf("StateDir() = %q, want %q", got, want) | |||
| 56 | + | } | |||
| 57 | + | }) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 8 | func TestConfigPath(t *testing.T) { | 61 | func TestConfigPath(t *testing.T) { | ||
| 9 | tests := []struct { | 62 | tests := []struct { | ||
| 10 | name string | 63 | name string | ||
| 11 | xdgConfig string | 64 | xdgConfig string | ||
| 12 | home string | 65 | home string | ||
| 66 | + | want func(*testing.T, string, string) string | |||
| 13 | }{ | 67 | }{ | ||
| 14 | { | 68 | { | ||
| 15 | name: "xdg config home", | 69 | name: "xdg config home", | ||
| 16 | xdgConfig: filepath.Join(t.TempDir(), "xdg-config"), | 70 | xdgConfig: filepath.Join(t.TempDir(), "xdg-config"), | ||
| 17 | home: filepath.Join(t.TempDir(), "home"), | 71 | home: filepath.Join(t.TempDir(), "home"), | ||
| 72 | + | want: func(_ *testing.T, xdgConfig, _ string) string { | |||
| 73 | + | return filepath.Join(xdgConfig, "vev", "config") | |||
| 74 | + | }, | |||
| 18 | }, | 75 | }, | ||
| 19 | { | 76 | { | ||
| 20 | name: "home fallback", | 77 | name: "home fallback", | ||
| 21 | home: filepath.Join(t.TempDir(), "home"), | 78 | home: filepath.Join(t.TempDir(), "home"), | ||
| 79 | + | want: func(_ *testing.T, _, home string) string { | |||
| 80 | + | return filepath.Join(home, ".config", "vev", "config") | |||
| 81 | + | }, | |||
| 82 | + | }, | |||
| 83 | + | { | |||
| 84 | + | name: "temp fallback without home", | |||
| 85 | + | want: func(t *testing.T, _, _ string) string { | |||
| 86 | + | t.Helper() | |||
| 87 | + | return filepath.Join(os.TempDir(), "vev-config-") | |||
| 88 | + | }, | |||
| 22 | }, | 89 | }, | ||
| 23 | } | 90 | } | ||
| 24 | 91 | ||||
| @@ -27,13 +94,44 @@ func TestConfigPath(t *testing.T) { | |||||
| 27 | t.Setenv("XDG_CONFIG_HOME", tt.xdgConfig) | 94 | t.Setenv("XDG_CONFIG_HOME", tt.xdgConfig) | ||
| 28 | t.Setenv("HOME", tt.home) | 95 | t.Setenv("HOME", tt.home) | ||
| 29 | 96 | ||||
| 30 | - | want := filepath.Join(tt.xdgConfig, "vev", "config") | 97 | + | want := tt.want(t, tt.xdgConfig, tt.home) |
| 31 | - | if tt.xdgConfig == "" { | 98 | + | got := ConfigPath() |
| 32 | - | want = filepath.Join(tt.home, ".config", "vev", "config") | 99 | + | if strings.HasSuffix(want, "vev-config-") { |
| 100 | + | if !strings.HasPrefix(got, want) || filepath.Base(got) != "config" { | |||
| 101 | + | t.Fatalf("ConfigPath() = %q, want prefix %q and base config", got, want) | |||
| 102 | + | } | |||
| 103 | + | return | |||
| 33 | } | 104 | } | ||
| 34 | - | if got := ConfigPath(); got != want { | 105 | + | if got != want { |
| 35 | t.Fatalf("ConfigPath() = %q, want %q", got, want) | 106 | t.Fatalf("ConfigPath() = %q, want %q", got, want) | ||
| 36 | } | 107 | } | ||
| 37 | }) | 108 | }) | ||
| 38 | } | 109 | } | ||
| 39 | } | 110 | } | ||
| 111 | + | ||||
| 112 | + | func TestDirOrHome(t *testing.T) { | |||
| 113 | + | existing := t.TempDir() | |||
| 114 | + | home := filepath.Join(t.TempDir(), "home") | |||
| 115 | + | nonexistent := filepath.Join(t.TempDir(), "missing") | |||
| 116 | + | ||||
| 117 | + | tests := []struct { | |||
| 118 | + | name string | |||
| 119 | + | dir string | |||
| 120 | + | home string | |||
| 121 | + | want string | |||
| 122 | + | }{ | |||
| 123 | + | {name: "existing dir", dir: existing, home: home, want: existing}, | |||
| 124 | + | {name: "non-existent dir falls back to home", dir: nonexistent, home: home, want: home}, | |||
| 125 | + | {name: "non-existent dir without home falls back to root", dir: nonexistent, want: "/"}, | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | for _, tt := range tests { | |||
| 129 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 130 | + | t.Setenv("HOME", tt.home) | |||
| 131 | + | ||||
| 132 | + | if got := DirOrHome(tt.dir); got != tt.want { | |||
| 133 | + | t.Fatalf("DirOrHome(%q) = %q, want %q", tt.dir, got, tt.want) | |||
| 134 | + | } | |||
| 135 | + | }) | |||
| 136 | + | } | |||
| 137 | + | } | |||
M internal/usecase/client/client_test.go +5 -2
| @@ -617,6 +617,8 @@ func TestAttachForwardsResize(t *testing.T) { | |||
| 617 | 617 | defer in.unblock() | |
| 618 | 618 | ||
| 619 | 619 | detachAfterResize := make(chan struct{}) | |
| 620 | + | firstRecv := make(chan struct{}) | |
| 621 | + | var firstRecvOnce sync.Once | |
| 620 | 622 | ||
| 621 | 623 | tr := portsmocks.NewMockTransport(t) | |
| 622 | 624 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| @@ -635,6 +637,7 @@ func TestAttachForwardsResize(t *testing.T) { | |||
| 635 | 637 | recvCh <- recvItem{f: welcome} | |
| 636 | 638 | done := make(chan struct{}) | |
| 637 | 639 | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |
| 640 | + | firstRecvOnce.Do(func() { close(firstRecv) }) | |
| 638 | 641 | select { | |
| 639 | 642 | case it := <-recvCh: | |
| 640 | 643 | return it.f, it.err | |
| @@ -653,9 +656,9 @@ func TestAttachForwardsResize(t *testing.T) { | |||
| 653 | 656 | }).Maybe() | |
| 654 | 657 | tr.EXPECT().Close().Return(nil).Once() | |
| 655 | 658 | ||
| 656 | - | // Push a resize event shortly after attach begins. | |
| 659 | + | // Push a resize event once attach begins receiving daemon frames. | |
| 657 | 660 | go func() { | |
| 658 | - | time.Sleep(20 * time.Millisecond) | |
| 661 | + | <-firstRecv | |
| 659 | 662 | resizeCh <- domain.Size{Cols: 120, Rows: 40} | |
| 660 | 663 | }() | |
| 661 | 664 | ||
| @@ -617,6 +617,8 @@ func TestAttachForwardsResize(t *testing.T) { | |||||
| 617 | defer in.unblock() | 617 | defer in.unblock() | ||
| 618 | 618 | ||||
| 619 | detachAfterResize := make(chan struct{}) | 619 | detachAfterResize := make(chan struct{}) | ||
| 620 | + | firstRecv := make(chan struct{}) | |||
| 621 | + | var firstRecvOnce sync.Once | |||
| 620 | 622 | ||||
| 621 | tr := portsmocks.NewMockTransport(t) | 623 | tr := portsmocks.NewMockTransport(t) | ||
| 622 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | 624 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | ||
| @@ -635,6 +637,7 @@ func TestAttachForwardsResize(t *testing.T) { | |||||
| 635 | recvCh <- recvItem{f: welcome} | 637 | recvCh <- recvItem{f: welcome} | ||
| 636 | done := make(chan struct{}) | 638 | done := make(chan struct{}) | ||
| 637 | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | 639 | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | ||
| 640 | + | firstRecvOnce.Do(func() { close(firstRecv) }) | |||
| 638 | select { | 641 | select { | ||
| 639 | case it := <-recvCh: | 642 | case it := <-recvCh: | ||
| 640 | return it.f, it.err | 643 | return it.f, it.err | ||
| @@ -653,9 +656,9 @@ func TestAttachForwardsResize(t *testing.T) { | |||||
| 653 | }).Maybe() | 656 | }).Maybe() | ||
| 654 | tr.EXPECT().Close().Return(nil).Once() | 657 | tr.EXPECT().Close().Return(nil).Once() | ||
| 655 | 658 | ||||
| 656 | - | // Push a resize event shortly after attach begins. | 659 | + | // Push a resize event once attach begins receiving daemon frames. |
| 657 | go func() { | 660 | go func() { | ||
| 658 | - | time.Sleep(20 * time.Millisecond) | 661 | + | <-firstRecv |
| 659 | resizeCh <- domain.Size{Cols: 120, Rows: 40} | 662 | resizeCh <- domain.Size{Cols: 120, Rows: 40} | ||
| 660 | }() | 663 | }() | ||
| 661 | 664 | ||||
M internal/usecase/daemon/session.go +4 -3
| @@ -660,15 +660,16 @@ func (d *Daemon) nameLiveOrStoppedLocked(name string) bool { | |||
| 660 | 660 | } | |
| 661 | 661 | ||
| 662 | 662 | func (d *Daemon) cwdSampler(ctx context.Context) { | |
| 663 | - | t := time.NewTicker(5 * time.Second) | |
| 663 | + | t := d.clock.NewTimer(5 * time.Second) | |
| 664 | 664 | defer t.Stop() | |
| 665 | 665 | for { | |
| 666 | 666 | select { | |
| 667 | 667 | case <-ctx.Done(): | |
| 668 | 668 | return | |
| 669 | - | case <-t.C: | |
| 670 | - | d.refreshNamedSessionCwds() | |
| 669 | + | case <-t.C(): | |
| 671 | 670 | } | |
| 671 | + | d.refreshNamedSessionCwds() | |
| 672 | + | t.Reset(5 * time.Second) | |
| 672 | 673 | } | |
| 673 | 674 | } | |
| 674 | 675 | ||
| @@ -660,15 +660,16 @@ func (d *Daemon) nameLiveOrStoppedLocked(name string) bool { | |||||
| 660 | } | 660 | } | ||
| 661 | 661 | ||||
| 662 | func (d *Daemon) cwdSampler(ctx context.Context) { | 662 | func (d *Daemon) cwdSampler(ctx context.Context) { | ||
| 663 | - | t := time.NewTicker(5 * time.Second) | 663 | + | t := d.clock.NewTimer(5 * time.Second) |
| 664 | defer t.Stop() | 664 | defer t.Stop() | ||
| 665 | for { | 665 | for { | ||
| 666 | select { | 666 | select { | ||
| 667 | case <-ctx.Done(): | 667 | case <-ctx.Done(): | ||
| 668 | return | 668 | return | ||
| 669 | - | case <-t.C: | 669 | + | case <-t.C(): |
| 670 | - | d.refreshNamedSessionCwds() | |||
| 671 | } | 670 | } | ||
| 671 | + | d.refreshNamedSessionCwds() | |||
| 672 | + | t.Reset(5 * time.Second) | |||
| 672 | } | 673 | } | ||
| 673 | } | 674 | } | ||
| 674 | 675 | ||||