| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "testing" |
| 5 | "time" |
| 6 | |
| 7 | "reasonix/internal/config" |
| 8 | ) |
| 9 | |
| 10 | func TestRemoteTabTitleRefreshRejectsDifferentServeCurrent(t *testing.T) { |
| 11 | fs := newFakeServe(t, "s3cret", []serveSessionEntry{ |
| 12 | {Name: "a", Path: "/a.jsonl", Title: "Title A", Current: true}, |
| 13 | {Name: "b", Path: "/b.jsonl", Title: "Title B"}, |
| 14 | }) |
| 15 | kernel := &fakeRemoteKernel{ |
| 16 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 17 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret", |
| 18 | } |
| 19 | seedBridgeTestHost(t, "box") |
| 20 | a := &App{remoteRuntime: kernel} |
| 21 | cleanupRemoteTabPumps(t, a) |
| 22 | meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionName: "a", SessionPath: "/a.jsonl", SessionTitle: "Selected A"}) |
| 23 | fs.mu.Lock() |
| 24 | fs.sessions[0].Current = false |
| 25 | fs.sessions[1].Current = true |
| 26 | fs.mu.Unlock() |
| 27 | |
| 28 | a.refreshRemoteTabTitle(meta.ID) |
| 29 | |
| 30 | a.remoteTabMu.Lock() |
| 31 | tab := a.remoteTabs[meta.ID] |
| 32 | name, sessionPath, route, title := tab.session.name, tab.session.path, tab.routing.currentPath, tab.topicTitle |
| 33 | a.remoteTabMu.Unlock() |
| 34 | if name != "a" || sessionPath != "/a.jsonl" || route != "/a.jsonl" || title != "Selected A" { |
| 35 | t.Fatalf("different Serve current replaced title-refresh target: %q/%q/%q/%q", name, sessionPath, route, title) |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | func TestRemoteTabTitleRefreshKeepsBlankPreferencesAfterReconnect(t *testing.T) { |
| 40 | fs := newFakeServe(t, "s3cret", nil) |
| 41 | kernel := &fakeRemoteKernel{ |
| 42 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 43 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret", |
| 44 | } |
| 45 | seedBridgeTestHost(t, "box") |
| 46 | a := &App{remoteRuntime: kernel} |
| 47 | cleanupRemoteTabPumps(t, a) |
| 48 | meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true}) |
| 49 | if err := setRemoteSessionTitleOverride("box", "~/app", "", "Unsaved title"); err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | if err := setRemoteSessionPinned("box", "~/app", "", true); err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | |
| 56 | fs.mu.Lock() |
| 57 | fs.sessions = []serveSessionEntry{{Name: "stale", Path: "/stale.jsonl", Title: "Serve title", Current: true}} |
| 58 | fs.sessionsStarted = make(chan struct{}, 1) |
| 59 | fs.sessionsRelease = make(chan struct{}) |
| 60 | started, release := fs.sessionsStarted, fs.sessionsRelease |
| 61 | fs.mu.Unlock() |
| 62 | done := make(chan struct{}) |
| 63 | go func() { a.refreshRemoteTabTitle(meta.ID); close(done) }() |
| 64 | select { |
| 65 | case <-started: |
| 66 | case <-time.After(time.Second): |
| 67 | t.Fatal("title refresh did not start") |
| 68 | } |
| 69 | a.remoteTabMu.Lock() |
| 70 | a.remoteTabs[meta.ID].gen++ |
| 71 | a.remoteTabMu.Unlock() |
| 72 | close(release) |
| 73 | select { |
| 74 | case <-done: |
| 75 | case <-time.After(time.Second): |
| 76 | t.Fatal("title refresh did not return") |
| 77 | } |
| 78 | |
| 79 | if got := remoteSessionTitleOverride("box", "~/app", ""); got != "Unsaved title" { |
| 80 | t.Fatalf("blank title after stale response = %q", got) |
| 81 | } |
| 82 | if !remoteSessionPinned("box", "~/app", "") { |
| 83 | t.Fatal("blank pin was removed by a stale response") |
| 84 | } |
| 85 | if got := remoteSessionTitleOverride("box", "~/app", "stale"); got != "" || remoteSessionPinned("box", "~/app", "stale") { |
| 86 | t.Fatalf("stale response received blank preferences: title=%q pinned=%v", got, remoteSessionPinned("box", "~/app", "stale")) |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | func TestRemoteTabTitleRefreshMigratesPreferencesWithoutHoldingRegistryLock(t *testing.T) { |
| 91 | fs := newFakeServe(t, "s3cret", nil) |
| 92 | kernel := &fakeRemoteKernel{ |
| 93 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 94 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret", |
| 95 | } |
| 96 | seedBridgeTestHost(t, "box") |
| 97 | a := &App{remoteRuntime: kernel} |
| 98 | cleanupRemoteTabPumps(t, a) |
| 99 | meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true}) |
| 100 | if err := setRemoteSessionTitleOverride("box", "~/app", "", "Unsaved title"); err != nil { |
| 101 | t.Fatal(err) |
| 102 | } |
| 103 | |
| 104 | fs.mu.Lock() |
| 105 | fs.sessions = []serveSessionEntry{{Name: "fresh", Path: "/fresh.jsonl", Title: "Serve title", Current: true}} |
| 106 | fs.sessionsStarted = make(chan struct{}, 1) |
| 107 | fs.sessionsRelease = make(chan struct{}) |
| 108 | started, release := fs.sessionsStarted, fs.sessionsRelease |
| 109 | fs.mu.Unlock() |
| 110 | done := make(chan struct{}) |
| 111 | go func() { a.refreshRemoteTabTitle(meta.ID); close(done) }() |
| 112 | select { |
| 113 | case <-started: |
| 114 | case <-time.After(time.Second): |
| 115 | t.Fatal("title refresh did not start") |
| 116 | } |
| 117 | |
| 118 | unlockPrefs, err := config.LockConfigFileEdits(remotePrefsPath()) |
| 119 | if err != nil { |
| 120 | t.Fatal(err) |
| 121 | } |
| 122 | close(release) |
| 123 | deadline := time.Now().Add(time.Second) |
| 124 | for remotePrefsMu.TryLock() { |
| 125 | remotePrefsMu.Unlock() |
| 126 | if time.Now().After(deadline) { |
| 127 | unlockPrefs() |
| 128 | t.Fatal("title refresh did not enter preference migration") |
| 129 | } |
| 130 | time.Sleep(time.Millisecond) |
| 131 | } |
| 132 | |
| 133 | registryAvailable := make(chan bool, 1) |
| 134 | go func() { |
| 135 | a.remoteTabMu.Lock() |
| 136 | registered := a.remoteTabs[meta.ID] != nil |
| 137 | a.remoteTabMu.Unlock() |
| 138 | registryAvailable <- registered |
| 139 | }() |
| 140 | select { |
| 141 | case registered := <-registryAvailable: |
| 142 | if !registered { |
| 143 | unlockPrefs() |
| 144 | t.Fatal("remote tab disappeared during preference migration") |
| 145 | } |
| 146 | case <-time.After(250 * time.Millisecond): |
| 147 | unlockPrefs() |
| 148 | <-registryAvailable |
| 149 | t.Fatal("preference migration held the global remote tab registry lock") |
| 150 | } |
| 151 | unlockPrefs() |
| 152 | select { |
| 153 | case <-done: |
| 154 | case <-time.After(time.Second): |
| 155 | t.Fatal("title refresh did not finish after preference lock release") |
| 156 | } |
| 157 | |
| 158 | a.remoteTabMu.Lock() |
| 159 | tab := a.remoteTabs[meta.ID] |
| 160 | name, path, title := tab.session.name, tab.session.path, tab.topicTitle |
| 161 | a.remoteTabMu.Unlock() |
| 162 | if name != "fresh" || path != "/fresh.jsonl" || title != "Unsaved title" { |
| 163 | t.Fatalf("materialized identity/title = %q/%q/%q", name, path, title) |
| 164 | } |
| 165 | } |
| 166 |