Merge pull request #214 from bnema/fix/remote-quick-switch-listing
fix(remote): retain catalog sessions across cache upgrade
- commit
- a20dc2bad622080c5f694bdcc12b5c80ef8df0bf
- author
- bnema · · 2026-08-26 04:25 UTC
- committer
- GitHub · · 2026-08-26 04:25 UTC
- parent
- 45e8d41
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
2 files · +144 -16
M internal/adapters/remote/catalog_cache.go +103 -15
| @@ -16,8 +16,9 @@ import ( | |||
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | 18 | const ( | |
| 19 | - | catalogCacheFileName = "remote-catalog-cache.json" | |
| 20 | - | catalogCacheFileVersion = 3 | |
| 19 | + | catalogCacheFileName = "remote-catalog-cache.json" | |
| 20 | + | catalogCacheExactFileVersion = 2 | |
| 21 | + | catalogCacheFileVersion = 3 | |
| 21 | 22 | ) | |
| 22 | 23 | ||
| 23 | 24 | // CatalogCachePath returns the canonical location of the remote catalog cache in stateDir. | |
| @@ -28,6 +29,10 @@ type catalogCacheFile struct { | |||
| 28 | 29 | Hosts *[]catalogCacheHost `json:"hosts"` | |
| 29 | 30 | } | |
| 30 | 31 | ||
| 32 | + | type catalogCacheVersion struct { | |
| 33 | + | Version *int `json:"version"` | |
| 34 | + | } | |
| 35 | + | ||
| 31 | 36 | type catalogCacheHost struct { | |
| 32 | 37 | Target *string `json:"target"` | |
| 33 | 38 | FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"` | |
| @@ -45,6 +50,29 @@ type catalogCacheSession struct { | |||
| 45 | 50 | Attached *bool `json:"attached"` | |
| 46 | 51 | } | |
| 47 | 52 | ||
| 53 | + | type catalogCacheFileV2 struct { | |
| 54 | + | Version *int `json:"version"` | |
| 55 | + | Hosts *[]catalogCacheHostV2 `json:"hosts"` | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | type catalogCacheHostV2 struct { | |
| 59 | + | Target *string `json:"target"` | |
| 60 | + | FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"` | |
| 61 | + | Sessions *[]catalogCacheSessionV2 `json:"sessions"` | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | type catalogCacheSessionV2 struct { | |
| 65 | + | LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id,omitempty"` | |
| 66 | + | Name *string `json:"name"` | |
| 67 | + | State *ports.RemoteCatalogSessionState `json:"state"` | |
| 68 | + | Ephemeral *bool `json:"ephemeral"` | |
| 69 | + | LastUsedSeq *uint64 `json:"last_used_seq,omitempty"` | |
| 70 | + | Tabs *uint16 `json:"tabs,omitempty"` | |
| 71 | + | TabList *[]ports.RemoteCatalogTab `json:"tab_list,omitempty"` | |
| 72 | + | ActiveTabID *string `json:"active_tab_id,omitempty"` | |
| 73 | + | Attached *bool `json:"attached"` | |
| 74 | + | } | |
| 75 | + | ||
| 48 | 76 | type fileCatalogCache struct { | |
| 49 | 77 | path string | |
| 50 | 78 | } | |
| @@ -56,8 +84,9 @@ func NewFileCatalogCache(path string) ports.RemoteCatalogCache { | |||
| 56 | 84 | ||
| 57 | 85 | var _ ports.RemoteCatalogCache = (*fileCatalogCache)(nil) | |
| 58 | 86 | ||
| 59 | - | // Load reads and validates one complete cache snapshot. A missing cache is an | |
| 60 | - | // empty snapshot; malformed cache data is never changed by Load. | |
| 87 | + | // Load reads and validates one complete cache snapshot. Exact v2 tab-list | |
| 88 | + | // snapshots are upgraded in memory; ambiguous count-only v2 data is rejected. | |
| 89 | + | // A missing cache is an empty snapshot, and Load never changes cache data. | |
| 61 | 90 | func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) { | |
| 62 | 91 | raw, err := os.ReadFile(c.path) | |
| 63 | 92 | if errors.Is(err, os.ErrNotExist) { | |
| @@ -70,20 +99,32 @@ func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) { | |||
| 70 | 99 | return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8") | |
| 71 | 100 | } | |
| 72 | 101 | ||
| 73 | - | dec := json.NewDecoder(bytes.NewReader(raw)) | |
| 74 | - | dec.DisallowUnknownFields() | |
| 75 | - | var file catalogCacheFile | |
| 76 | - | if err := dec.Decode(&file); err != nil { | |
| 77 | - | return nil, fmt.Errorf("remote catalog cache: malformed cache file: %w", err) | |
| 78 | - | } | |
| 79 | - | if err := rejectTrailingJSON(dec); err != nil { | |
| 80 | - | return nil, fmt.Errorf("remote catalog cache: malformed cache file: %w", err) | |
| 102 | + | var version catalogCacheVersion | |
| 103 | + | if err := decodeCatalogCacheFile(raw, false, &version); err != nil { | |
| 104 | + | return nil, err | |
| 81 | 105 | } | |
| 82 | - | if file.Version == nil { | |
| 106 | + | if version.Version == nil { | |
| 83 | 107 | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing version") | |
| 84 | 108 | } | |
| 85 | - | if *file.Version != catalogCacheFileVersion { | |
| 86 | - | return nil, fmt.Errorf("remote catalog cache: unsupported cache file version %d", *file.Version) | |
| 109 | + | ||
| 110 | + | var file catalogCacheFile | |
| 111 | + | switch *version.Version { | |
| 112 | + | case catalogCacheFileVersion: | |
| 113 | + | if err := decodeCatalogCacheFile(raw, true, &file); err != nil { | |
| 114 | + | return nil, err | |
| 115 | + | } | |
| 116 | + | case catalogCacheExactFileVersion: | |
| 117 | + | var legacy catalogCacheFileV2 | |
| 118 | + | if err := decodeCatalogCacheFile(raw, true, &legacy); err != nil { | |
| 119 | + | return nil, err | |
| 120 | + | } | |
| 121 | + | migrated, err := migrateExactCatalogCacheV2(legacy) | |
| 122 | + | if err != nil { | |
| 123 | + | return nil, err | |
| 124 | + | } | |
| 125 | + | file = migrated | |
| 126 | + | default: | |
| 127 | + | return nil, fmt.Errorf("remote catalog cache: unsupported cache file version %d", *version.Version) | |
| 87 | 128 | } | |
| 88 | 129 | if file.Hosts == nil { | |
| 89 | 130 | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing hosts") | |
| @@ -139,6 +180,53 @@ func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) { | |||
| 139 | 180 | return normalized, nil | |
| 140 | 181 | } | |
| 141 | 182 | ||
| 183 | + | func decodeCatalogCacheFile(raw []byte, strict bool, dst any) error { | |
| 184 | + | dec := json.NewDecoder(bytes.NewReader(raw)) | |
| 185 | + | if strict { | |
| 186 | + | dec.DisallowUnknownFields() | |
| 187 | + | } | |
| 188 | + | if err := dec.Decode(dst); err != nil { | |
| 189 | + | return fmt.Errorf("remote catalog cache: malformed cache file: %w", err) | |
| 190 | + | } | |
| 191 | + | if err := rejectTrailingJSON(dec); err != nil { | |
| 192 | + | return fmt.Errorf("remote catalog cache: malformed cache file: %w", err) | |
| 193 | + | } | |
| 194 | + | return nil | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func migrateExactCatalogCacheV2(legacy catalogCacheFileV2) (catalogCacheFile, error) { | |
| 198 | + | if legacy.Version == nil || *legacy.Version != catalogCacheExactFileVersion || legacy.Hosts == nil { | |
| 199 | + | return catalogCacheFile{}, fmt.Errorf("remote catalog cache: malformed version 2 cache file") | |
| 200 | + | } | |
| 201 | + | hosts := make([]catalogCacheHost, 0, len(*legacy.Hosts)) | |
| 202 | + | for _, host := range *legacy.Hosts { | |
| 203 | + | if host.Sessions == nil { | |
| 204 | + | return catalogCacheFile{}, fmt.Errorf("remote catalog cache: malformed cache file: missing host fields") | |
| 205 | + | } | |
| 206 | + | sessions := make([]catalogCacheSession, 0, len(*host.Sessions)) | |
| 207 | + | for _, session := range *host.Sessions { | |
| 208 | + | if session.Tabs != nil || session.TabList == nil { | |
| 209 | + | return catalogCacheFile{}, fmt.Errorf("remote catalog cache: unsupported ambiguous version 2 tab count") | |
| 210 | + | } | |
| 211 | + | sessions = append(sessions, catalogCacheSession{ | |
| 212 | + | LifecycleID: session.LifecycleID, | |
| 213 | + | Name: session.Name, | |
| 214 | + | State: session.State, | |
| 215 | + | Ephemeral: session.Ephemeral, | |
| 216 | + | LastUsedSeq: session.LastUsedSeq, | |
| 217 | + | Tabs: session.TabList, | |
| 218 | + | ActiveTabID: session.ActiveTabID, | |
| 219 | + | Attached: session.Attached, | |
| 220 | + | }) | |
| 221 | + | } | |
| 222 | + | hosts = append(hosts, catalogCacheHost{ | |
| 223 | + | Target: host.Target, FetchedAtUnixNano: host.FetchedAtUnixNano, Sessions: &sessions, | |
| 224 | + | }) | |
| 225 | + | } | |
| 226 | + | version := catalogCacheFileVersion | |
| 227 | + | return catalogCacheFile{Version: &version, Hosts: &hosts}, nil | |
| 228 | + | } | |
| 229 | + | ||
| 142 | 230 | // Store atomically replaces the cache with a complete normalized snapshot. | |
| 143 | 231 | func (c *fileCatalogCache) Store(entries []ports.RemoteCatalogCacheEntry) error { | |
| 144 | 232 | normalized, err := normalizeCatalogCacheEntries(entries) | |
| @@ -16,8 +16,9 @@ import ( | |||||
| 16 | ) | 16 | ) | ||
| 17 | 17 | ||||
| 18 | const ( | 18 | const ( | ||
| 19 | - | catalogCacheFileName = "remote-catalog-cache.json" | 19 | + | catalogCacheFileName = "remote-catalog-cache.json" |
| 20 | - | catalogCacheFileVersion = 3 | 20 | + | catalogCacheExactFileVersion = 2 |
| 21 | + | catalogCacheFileVersion = 3 | |||
| 21 | ) | 22 | ) | ||
| 22 | 23 | ||||
| 23 | // CatalogCachePath returns the canonical location of the remote catalog cache in stateDir. | 24 | // CatalogCachePath returns the canonical location of the remote catalog cache in stateDir. | ||
| @@ -28,6 +29,10 @@ type catalogCacheFile struct { | |||||
| 28 | Hosts *[]catalogCacheHost `json:"hosts"` | 29 | Hosts *[]catalogCacheHost `json:"hosts"` | ||
| 29 | } | 30 | } | ||
| 30 | 31 | ||||
| 32 | + | type catalogCacheVersion struct { | |||
| 33 | + | Version *int `json:"version"` | |||
| 34 | + | } | |||
| 35 | + | ||||
| 31 | type catalogCacheHost struct { | 36 | type catalogCacheHost struct { | ||
| 32 | Target *string `json:"target"` | 37 | Target *string `json:"target"` | ||
| 33 | FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"` | 38 | FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"` | ||
| @@ -45,6 +50,29 @@ type catalogCacheSession struct { | |||||
| 45 | Attached *bool `json:"attached"` | 50 | Attached *bool `json:"attached"` | ||
| 46 | } | 51 | } | ||
| 47 | 52 | ||||
| 53 | + | type catalogCacheFileV2 struct { | |||
| 54 | + | Version *int `json:"version"` | |||
| 55 | + | Hosts *[]catalogCacheHostV2 `json:"hosts"` | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | type catalogCacheHostV2 struct { | |||
| 59 | + | Target *string `json:"target"` | |||
| 60 | + | FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"` | |||
| 61 | + | Sessions *[]catalogCacheSessionV2 `json:"sessions"` | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | type catalogCacheSessionV2 struct { | |||
| 65 | + | LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id,omitempty"` | |||
| 66 | + | Name *string `json:"name"` | |||
| 67 | + | State *ports.RemoteCatalogSessionState `json:"state"` | |||
| 68 | + | Ephemeral *bool `json:"ephemeral"` | |||
| 69 | + | LastUsedSeq *uint64 `json:"last_used_seq,omitempty"` | |||
| 70 | + | Tabs *uint16 `json:"tabs,omitempty"` | |||
| 71 | + | TabList *[]ports.RemoteCatalogTab `json:"tab_list,omitempty"` | |||
| 72 | + | ActiveTabID *string `json:"active_tab_id,omitempty"` | |||
| 73 | + | Attached *bool `json:"attached"` | |||
| 74 | + | } | |||
| 75 | + | ||||
| 48 | type fileCatalogCache struct { | 76 | type fileCatalogCache struct { | ||
| 49 | path string | 77 | path string | ||
| 50 | } | 78 | } | ||
| @@ -56,8 +84,9 @@ func NewFileCatalogCache(path string) ports.RemoteCatalogCache { | |||||
| 56 | 84 | ||||
| 57 | var _ ports.RemoteCatalogCache = (*fileCatalogCache)(nil) | 85 | var _ ports.RemoteCatalogCache = (*fileCatalogCache)(nil) | ||
| 58 | 86 | ||||
| 59 | - | // Load reads and validates one complete cache snapshot. A missing cache is an | 87 | + | // Load reads and validates one complete cache snapshot. Exact v2 tab-list |
| 60 | - | // empty snapshot; malformed cache data is never changed by Load. | 88 | + | // snapshots are upgraded in memory; ambiguous count-only v2 data is rejected. |
| 89 | + | // A missing cache is an empty snapshot, and Load never changes cache data. | |||
| 61 | func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) { | 90 | func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) { | ||
| 62 | raw, err := os.ReadFile(c.path) | 91 | raw, err := os.ReadFile(c.path) | ||
| 63 | if errors.Is(err, os.ErrNotExist) { | 92 | if errors.Is(err, os.ErrNotExist) { | ||
| @@ -70,20 +99,32 @@ func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) { | |||||
| 70 | return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8") | 99 | return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8") | ||
| 71 | } | 100 | } | ||
| 72 | 101 | ||||
| 73 | - | dec := json.NewDecoder(bytes.NewReader(raw)) | 102 | + | var version catalogCacheVersion |
| 74 | - | dec.DisallowUnknownFields() | 103 | + | if err := decodeCatalogCacheFile(raw, false, &version); err != nil { |
| 75 | - | var file catalogCacheFile | 104 | + | return nil, err |
| 76 | - | if err := dec.Decode(&file); err != nil { | |||
| 77 | - | return nil, fmt.Errorf("remote catalog cache: malformed cache file: %w", err) | |||
| 78 | - | } | |||
| 79 | - | if err := rejectTrailingJSON(dec); err != nil { | |||
| 80 | - | return nil, fmt.Errorf("remote catalog cache: malformed cache file: %w", err) | |||
| 81 | } | 105 | } | ||
| 82 | - | if file.Version == nil { | 106 | + | if version.Version == nil { |
| 83 | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing version") | 107 | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing version") | ||
| 84 | } | 108 | } | ||
| 85 | - | if *file.Version != catalogCacheFileVersion { | 109 | + | |
| 86 | - | return nil, fmt.Errorf("remote catalog cache: unsupported cache file version %d", *file.Version) | 110 | + | var file catalogCacheFile |
| 111 | + | switch *version.Version { | |||
| 112 | + | case catalogCacheFileVersion: | |||
| 113 | + | if err := decodeCatalogCacheFile(raw, true, &file); err != nil { | |||
| 114 | + | return nil, err | |||
| 115 | + | } | |||
| 116 | + | case catalogCacheExactFileVersion: | |||
| 117 | + | var legacy catalogCacheFileV2 | |||
| 118 | + | if err := decodeCatalogCacheFile(raw, true, &legacy); err != nil { | |||
| 119 | + | return nil, err | |||
| 120 | + | } | |||
| 121 | + | migrated, err := migrateExactCatalogCacheV2(legacy) | |||
| 122 | + | if err != nil { | |||
| 123 | + | return nil, err | |||
| 124 | + | } | |||
| 125 | + | file = migrated | |||
| 126 | + | default: | |||
| 127 | + | return nil, fmt.Errorf("remote catalog cache: unsupported cache file version %d", *version.Version) | |||
| 87 | } | 128 | } | ||
| 88 | if file.Hosts == nil { | 129 | if file.Hosts == nil { | ||
| 89 | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing hosts") | 130 | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing hosts") | ||
| @@ -139,6 +180,53 @@ func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) { | |||||
| 139 | return normalized, nil | 180 | return normalized, nil | ||
| 140 | } | 181 | } | ||
| 141 | 182 | ||||
| 183 | + | func decodeCatalogCacheFile(raw []byte, strict bool, dst any) error { | |||
| 184 | + | dec := json.NewDecoder(bytes.NewReader(raw)) | |||
| 185 | + | if strict { | |||
| 186 | + | dec.DisallowUnknownFields() | |||
| 187 | + | } | |||
| 188 | + | if err := dec.Decode(dst); err != nil { | |||
| 189 | + | return fmt.Errorf("remote catalog cache: malformed cache file: %w", err) | |||
| 190 | + | } | |||
| 191 | + | if err := rejectTrailingJSON(dec); err != nil { | |||
| 192 | + | return fmt.Errorf("remote catalog cache: malformed cache file: %w", err) | |||
| 193 | + | } | |||
| 194 | + | return nil | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func migrateExactCatalogCacheV2(legacy catalogCacheFileV2) (catalogCacheFile, error) { | |||
| 198 | + | if legacy.Version == nil || *legacy.Version != catalogCacheExactFileVersion || legacy.Hosts == nil { | |||
| 199 | + | return catalogCacheFile{}, fmt.Errorf("remote catalog cache: malformed version 2 cache file") | |||
| 200 | + | } | |||
| 201 | + | hosts := make([]catalogCacheHost, 0, len(*legacy.Hosts)) | |||
| 202 | + | for _, host := range *legacy.Hosts { | |||
| 203 | + | if host.Sessions == nil { | |||
| 204 | + | return catalogCacheFile{}, fmt.Errorf("remote catalog cache: malformed cache file: missing host fields") | |||
| 205 | + | } | |||
| 206 | + | sessions := make([]catalogCacheSession, 0, len(*host.Sessions)) | |||
| 207 | + | for _, session := range *host.Sessions { | |||
| 208 | + | if session.Tabs != nil || session.TabList == nil { | |||
| 209 | + | return catalogCacheFile{}, fmt.Errorf("remote catalog cache: unsupported ambiguous version 2 tab count") | |||
| 210 | + | } | |||
| 211 | + | sessions = append(sessions, catalogCacheSession{ | |||
| 212 | + | LifecycleID: session.LifecycleID, | |||
| 213 | + | Name: session.Name, | |||
| 214 | + | State: session.State, | |||
| 215 | + | Ephemeral: session.Ephemeral, | |||
| 216 | + | LastUsedSeq: session.LastUsedSeq, | |||
| 217 | + | Tabs: session.TabList, | |||
| 218 | + | ActiveTabID: session.ActiveTabID, | |||
| 219 | + | Attached: session.Attached, | |||
| 220 | + | }) | |||
| 221 | + | } | |||
| 222 | + | hosts = append(hosts, catalogCacheHost{ | |||
| 223 | + | Target: host.Target, FetchedAtUnixNano: host.FetchedAtUnixNano, Sessions: &sessions, | |||
| 224 | + | }) | |||
| 225 | + | } | |||
| 226 | + | version := catalogCacheFileVersion | |||
| 227 | + | return catalogCacheFile{Version: &version, Hosts: &hosts}, nil | |||
| 228 | + | } | |||
| 229 | + | ||||
| 142 | // Store atomically replaces the cache with a complete normalized snapshot. | 230 | // Store atomically replaces the cache with a complete normalized snapshot. | ||
| 143 | func (c *fileCatalogCache) Store(entries []ports.RemoteCatalogCacheEntry) error { | 231 | func (c *fileCatalogCache) Store(entries []ports.RemoteCatalogCacheEntry) error { | ||
| 144 | normalized, err := normalizeCatalogCacheEntries(entries) | 232 | normalized, err := normalizeCatalogCacheEntries(entries) | ||
M internal/adapters/remote/catalog_cache_test.go +41 -1
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "time" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/vev/internal/ports" | |
| 13 | + | "github.com/bnema/vev/pkg/safedir" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | 16 | func TestCatalogCachePath(t *testing.T) { | |
| @@ -116,6 +117,45 @@ func TestCatalogCacheStoreLoad(t *testing.T) { | |||
| 116 | 117 | } | |
| 117 | 118 | } | |
| 118 | 119 | ||
| 120 | + | func TestCatalogCacheLoadMigratesExactV2TabList(t *testing.T) { | |
| 121 | + | t.Parallel() | |
| 122 | + | ||
| 123 | + | path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json") | |
| 124 | + | if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil { | |
| 125 | + | t.Fatalf("mkdir: %v", err) | |
| 126 | + | } | |
| 127 | + | raw := []byte(`{"version":2,"hosts":[{"target":"user@arch","fetched_at_unix_nano":1780000000000000000,"sessions":[{"lifecycle_id":"02000000000000000000000000000000","name":"dumber","state":"down","ephemeral":false,"tab_list":[],"attached":false},{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","ephemeral":false,"last_used_seq":42,"tab_list":[{"id":"t_work","index":0,"name":"shell"}],"active_tab_id":"t_work","attached":false}]}]}`) | |
| 128 | + | if err := os.WriteFile(path, raw, 0o600); err != nil { | |
| 129 | + | t.Fatalf("write cache: %v", err) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | got, err := NewFileCatalogCache(path).Load() | |
| 133 | + | if err != nil { | |
| 134 | + | t.Fatalf("Load() error = %v", err) | |
| 135 | + | } | |
| 136 | + | want := []ports.RemoteCatalogCacheEntry{{ | |
| 137 | + | Host: "user@arch", | |
| 138 | + | FetchedAt: time.Unix(0, 1780000000000000000), | |
| 139 | + | Sessions: []ports.RemoteCatalogSession{ | |
| 140 | + | {LifecycleID: [16]byte{2}, Name: "dumber", State: ports.RemoteCatalogSessionDown, Tabs: []ports.RemoteCatalogTab{}}, | |
| 141 | + | { | |
| 142 | + | LifecycleID: [16]byte{1}, Name: "work", State: ports.RemoteCatalogSessionUp, | |
| 143 | + | LastUsedSeq: 42, Tabs: []ports.RemoteCatalogTab{{ID: "t_work", Name: "shell"}}, ActiveTabID: "t_work", | |
| 144 | + | }, | |
| 145 | + | }, | |
| 146 | + | }} | |
| 147 | + | if !slices.EqualFunc(got, want, equalRemoteCatalogCacheEntry) { | |
| 148 | + | t.Fatalf("Load() = %#v, want %#v", got, want) | |
| 149 | + | } | |
| 150 | + | after, err := os.ReadFile(path) | |
| 151 | + | if err != nil { | |
| 152 | + | t.Fatalf("read cache after load: %v", err) | |
| 153 | + | } | |
| 154 | + | if !slices.Equal(after, raw) { | |
| 155 | + | t.Fatalf("Load changed v2 cache: got %q, want %q", after, raw) | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | ||
| 119 | 159 | func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) { | |
| 120 | 160 | t.Parallel() | |
| 121 | 161 | ||
| @@ -125,7 +165,7 @@ func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) { | |||
| 125 | 165 | }{ | |
| 126 | 166 | {name: "truncated", raw: []byte(`{"version":3,"hosts":[`)}, | |
| 127 | 167 | {name: "trailing JSON", raw: []byte(`{"version":3,"hosts":[]} {}`)}, | |
| 128 | - | {name: "obsolete count-only version", raw: []byte(`{"version":2,"hosts":[]}`)}, | |
| 168 | + | {name: "obsolete count-only version", raw: []byte(`{"version":2,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","ephemeral":false,"tabs":1,"active_tab_id":"t_work","attached":false}]}]}`)}, | |
| 129 | 169 | {name: "unknown version", raw: []byte(`{"version":4,"hosts":[]}`)}, | |
| 130 | 170 | {name: "missing hosts", raw: []byte(`{"version":3}`)}, | |
| 131 | 171 | {name: "null hosts", raw: []byte(`{"version":3,"hosts":null}`)}, | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "time" | 10 | "time" | ||
| 11 | 11 | ||||
| 12 | "github.com/bnema/vev/internal/ports" | 12 | "github.com/bnema/vev/internal/ports" | ||
| 13 | + | "github.com/bnema/vev/pkg/safedir" | |||
| 13 | ) | 14 | ) | ||
| 14 | 15 | ||||
| 15 | func TestCatalogCachePath(t *testing.T) { | 16 | func TestCatalogCachePath(t *testing.T) { | ||
| @@ -116,6 +117,45 @@ func TestCatalogCacheStoreLoad(t *testing.T) { | |||||
| 116 | } | 117 | } | ||
| 117 | } | 118 | } | ||
| 118 | 119 | ||||
| 120 | + | func TestCatalogCacheLoadMigratesExactV2TabList(t *testing.T) { | |||
| 121 | + | t.Parallel() | |||
| 122 | + | ||||
| 123 | + | path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json") | |||
| 124 | + | if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil { | |||
| 125 | + | t.Fatalf("mkdir: %v", err) | |||
| 126 | + | } | |||
| 127 | + | raw := []byte(`{"version":2,"hosts":[{"target":"user@arch","fetched_at_unix_nano":1780000000000000000,"sessions":[{"lifecycle_id":"02000000000000000000000000000000","name":"dumber","state":"down","ephemeral":false,"tab_list":[],"attached":false},{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","ephemeral":false,"last_used_seq":42,"tab_list":[{"id":"t_work","index":0,"name":"shell"}],"active_tab_id":"t_work","attached":false}]}]}`) | |||
| 128 | + | if err := os.WriteFile(path, raw, 0o600); err != nil { | |||
| 129 | + | t.Fatalf("write cache: %v", err) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | got, err := NewFileCatalogCache(path).Load() | |||
| 133 | + | if err != nil { | |||
| 134 | + | t.Fatalf("Load() error = %v", err) | |||
| 135 | + | } | |||
| 136 | + | want := []ports.RemoteCatalogCacheEntry{{ | |||
| 137 | + | Host: "user@arch", | |||
| 138 | + | FetchedAt: time.Unix(0, 1780000000000000000), | |||
| 139 | + | Sessions: []ports.RemoteCatalogSession{ | |||
| 140 | + | {LifecycleID: [16]byte{2}, Name: "dumber", State: ports.RemoteCatalogSessionDown, Tabs: []ports.RemoteCatalogTab{}}, | |||
| 141 | + | { | |||
| 142 | + | LifecycleID: [16]byte{1}, Name: "work", State: ports.RemoteCatalogSessionUp, | |||
| 143 | + | LastUsedSeq: 42, Tabs: []ports.RemoteCatalogTab{{ID: "t_work", Name: "shell"}}, ActiveTabID: "t_work", | |||
| 144 | + | }, | |||
| 145 | + | }, | |||
| 146 | + | }} | |||
| 147 | + | if !slices.EqualFunc(got, want, equalRemoteCatalogCacheEntry) { | |||
| 148 | + | t.Fatalf("Load() = %#v, want %#v", got, want) | |||
| 149 | + | } | |||
| 150 | + | after, err := os.ReadFile(path) | |||
| 151 | + | if err != nil { | |||
| 152 | + | t.Fatalf("read cache after load: %v", err) | |||
| 153 | + | } | |||
| 154 | + | if !slices.Equal(after, raw) { | |||
| 155 | + | t.Fatalf("Load changed v2 cache: got %q, want %q", after, raw) | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | ||||
| 119 | func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) { | 159 | func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) { | ||
| 120 | t.Parallel() | 160 | t.Parallel() | ||
| 121 | 161 | ||||
| @@ -125,7 +165,7 @@ func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) { | |||||
| 125 | }{ | 165 | }{ | ||
| 126 | {name: "truncated", raw: []byte(`{"version":3,"hosts":[`)}, | 166 | {name: "truncated", raw: []byte(`{"version":3,"hosts":[`)}, | ||
| 127 | {name: "trailing JSON", raw: []byte(`{"version":3,"hosts":[]} {}`)}, | 167 | {name: "trailing JSON", raw: []byte(`{"version":3,"hosts":[]} {}`)}, | ||
| 128 | - | {name: "obsolete count-only version", raw: []byte(`{"version":2,"hosts":[]}`)}, | 168 | + | {name: "obsolete count-only version", raw: []byte(`{"version":2,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","ephemeral":false,"tabs":1,"active_tab_id":"t_work","attached":false}]}]}`)}, |
| 129 | {name: "unknown version", raw: []byte(`{"version":4,"hosts":[]}`)}, | 169 | {name: "unknown version", raw: []byte(`{"version":4,"hosts":[]}`)}, | ||
| 130 | {name: "missing hosts", raw: []byte(`{"version":3}`)}, | 170 | {name: "missing hosts", raw: []byte(`{"version":3}`)}, | ||
| 131 | {name: "null hosts", raw: []byte(`{"version":3,"hosts":null}`)}, | 171 | {name: "null hosts", raw: []byte(`{"version":3,"hosts":null}`)}, | ||