| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "sync" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "reasonix/internal/sessioncatalog" |
| 12 | ) |
| 13 | |
| 14 | func TestSessionCatalogTargetsIncludeRestoredProjectTab(t *testing.T) { |
| 15 | isolateDesktopUserDirs(t) |
| 16 | app := NewApp() |
| 17 | root := t.TempDir() |
| 18 | sessionDir := desktopSessionDir(root) |
| 19 | app.tabs["restored"] = &WorkspaceTab{ |
| 20 | ID: "restored", Scope: "project", WorkspaceRoot: root, |
| 21 | SessionPath: filepath.Join(sessionDir, "restored.jsonl"), |
| 22 | } |
| 23 | for _, target := range app.sessionCatalogTargets() { |
| 24 | if target.Path == sessionDir && target.Scope == "project" && target.WorkspaceRoot == root { |
| 25 | return |
| 26 | } |
| 27 | } |
| 28 | t.Fatalf("session catalog targets = %#v, want restored project directory %q", app.sessionCatalogTargets(), sessionDir) |
| 29 | } |
| 30 | |
| 31 | func waitForCatalogSessionPath(t *testing.T, app *App, workspaceRoot, sessionDir, sessionPath string) sessioncatalog.SessionRecord { |
| 32 | t.Helper() |
| 33 | deadline := time.Now().Add(15 * time.Second) |
| 34 | var last []sessioncatalog.SessionRecord |
| 35 | for time.Now().Before(deadline) { |
| 36 | catalog := app.sessionCatalog.Load() |
| 37 | if catalog == nil { |
| 38 | t.Fatal("session catalog is not installed") |
| 39 | } |
| 40 | page, err := catalog.ListSessions(context.Background(), sessioncatalog.SessionPageRequest{ |
| 41 | Scope: "project", WorkspaceRoot: workspaceRoot, Directory: sessionDir, Limit: 20, |
| 42 | }) |
| 43 | if err != nil { |
| 44 | t.Fatalf("list project sessions: %v", err) |
| 45 | } |
| 46 | last = page.Items |
| 47 | for _, item := range page.Items { |
| 48 | if item.Path == sessionPath { |
| 49 | return item |
| 50 | } |
| 51 | } |
| 52 | time.Sleep(10 * time.Millisecond) |
| 53 | } |
| 54 | t.Fatalf("project session %q did not become visible: %#v", sessionPath, last) |
| 55 | return sessioncatalog.SessionRecord{} |
| 56 | } |
| 57 | |
| 58 | func TestRegisterProjectRootReconcilesNewProjectSessionDirectory(t *testing.T) { |
| 59 | isolateDesktopUserDirs(t) |
| 60 | app := NewApp() |
| 61 | globalDir := desktopSessionDir(globalWorkspaceRoot()) |
| 62 | if err := os.MkdirAll(globalDir, 0o755); err != nil { |
| 63 | t.Fatalf("mkdir global session dir: %v", err) |
| 64 | } |
| 65 | installSessionCatalogForTest(t, app, globalDir, "global", "") |
| 66 | |
| 67 | root := t.TempDir() |
| 68 | sessionDir := desktopSessionDir(root) |
| 69 | if err := os.MkdirAll(sessionDir, 0o755); err != nil { |
| 70 | t.Fatalf("mkdir project session dir: %v", err) |
| 71 | } |
| 72 | sessionPath := writeTopicSession(t, sessionDir, "existing.jsonl", "topic-existing", "Existing topic", root) |
| 73 | |
| 74 | app.registerProjectRoot(root) |
| 75 | |
| 76 | session := waitForCatalogSessionPath(t, app, root, sessionDir, sessionPath) |
| 77 | if session.TopicTitle != "Existing topic" { |
| 78 | t.Fatalf("session topic title = %q, want Existing topic", session.TopicTitle) |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | func TestRegisterProjectRootDoesNotRescanExistingProjectOnActivation(t *testing.T) { |
| 83 | isolateDesktopUserDirs(t) |
| 84 | app := NewApp() |
| 85 | globalDir := desktopSessionDir(globalWorkspaceRoot()) |
| 86 | if err := os.MkdirAll(globalDir, 0o755); err != nil { |
| 87 | t.Fatal(err) |
| 88 | } |
| 89 | installSessionCatalogForTest(t, app, globalDir, "global", "") |
| 90 | root := t.TempDir() |
| 91 | if err := os.MkdirAll(desktopSessionDir(root), 0o755); err != nil { |
| 92 | t.Fatal(err) |
| 93 | } |
| 94 | |
| 95 | started := make(chan struct{}, 1) |
| 96 | release := make(chan struct{}) |
| 97 | done := make(chan struct{}, 1) |
| 98 | app.catalogReconcileHook = func(sessioncatalog.DirectoryTarget) { |
| 99 | started <- struct{}{} |
| 100 | <-release |
| 101 | } |
| 102 | app.catalogReconcileDoneHook = func(sessioncatalog.DirectoryTarget) { done <- struct{}{} } |
| 103 | app.registerProjectRoot(root) |
| 104 | <-started |
| 105 | for range 25 { |
| 106 | app.registerProjectRoot(root) |
| 107 | } |
| 108 | close(release) |
| 109 | <-done |
| 110 | select { |
| 111 | case <-started: |
| 112 | t.Fatal("activating an already registered project scheduled another full reconcile") |
| 113 | default: |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | func TestExplicitReconcileCoalescesConcurrentRequests(t *testing.T) { |
| 118 | isolateDesktopUserDirs(t) |
| 119 | app := NewApp() |
| 120 | dir := t.TempDir() |
| 121 | installSessionCatalogForTest(t, app, dir, "global", "") |
| 122 | |
| 123 | started := make(chan struct{}, 2) |
| 124 | releaseFirst := make(chan struct{}) |
| 125 | done := make(chan struct{}, 1) |
| 126 | var calls int |
| 127 | var callsMu sync.Mutex |
| 128 | app.catalogReconcileHook = func(sessioncatalog.DirectoryTarget) { |
| 129 | callsMu.Lock() |
| 130 | calls++ |
| 131 | call := calls |
| 132 | callsMu.Unlock() |
| 133 | started <- struct{}{} |
| 134 | if call == 1 { |
| 135 | <-releaseFirst |
| 136 | } |
| 137 | } |
| 138 | app.catalogReconcileDoneHook = func(sessioncatalog.DirectoryTarget) { done <- struct{}{} } |
| 139 | app.requestSessionCatalogReconcile(dir) |
| 140 | <-started |
| 141 | for range 25 { |
| 142 | app.requestSessionCatalogReconcile(dir) |
| 143 | } |
| 144 | close(releaseFirst) |
| 145 | <-started |
| 146 | <-done |
| 147 | callsMu.Lock() |
| 148 | defer callsMu.Unlock() |
| 149 | if calls != 2 { |
| 150 | t.Fatalf("reconcile passes = %d, want one active pass plus one coalesced dirty pass", calls) |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | func TestStopSessionCatalogWaitsForExplicitReconcile(t *testing.T) { |
| 155 | isolateDesktopUserDirs(t) |
| 156 | app := NewApp() |
| 157 | dir := t.TempDir() |
| 158 | installSessionCatalogForTest(t, app, dir, "global", "") |
| 159 | |
| 160 | started := make(chan struct{}) |
| 161 | release := make(chan struct{}) |
| 162 | app.catalogReconcileHook = func(sessioncatalog.DirectoryTarget) { |
| 163 | close(started) |
| 164 | <-release |
| 165 | } |
| 166 | app.requestSessionCatalogReconcile(dir) |
| 167 | <-started |
| 168 | stopped := make(chan bool, 1) |
| 169 | go func() { |
| 170 | stopped <- app.stopSessionCatalog(time.Second) |
| 171 | }() |
| 172 | select { |
| 173 | case <-stopped: |
| 174 | t.Fatal("catalog stopped before its explicit reconcile released") |
| 175 | case <-time.After(25 * time.Millisecond): |
| 176 | } |
| 177 | close(release) |
| 178 | select { |
| 179 | case clean := <-stopped: |
| 180 | if !clean { |
| 181 | t.Fatal("catalog stop reported an incomplete drain after reconcile released") |
| 182 | } |
| 183 | case <-time.After(time.Second): |
| 184 | t.Fatal("catalog stop did not drain its explicit reconcile") |
| 185 | } |
| 186 | } |
| 187 |