| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "slices" |
| 8 | "testing" |
| 9 | |
| 10 | "reasonix/desktop/internal/workspacestate" |
| 11 | "reasonix/internal/config" |
| 12 | ) |
| 13 | |
| 14 | // The repair removes the losing record, while a saved tab can still name it. |
| 15 | // That tab must be restored against the directory's surviving owner instead of |
| 16 | // being dropped as a stale presentation of a workspace that no longer exists. |
| 17 | func TestSavedTabNamingAbsorbedWorkspaceIsRestored(t *testing.T) { |
| 18 | app := newSavedTabReconcileTestApp(t) |
| 19 | finishSavedTabMigration(app) |
| 20 | root, err := ensureGlobalWorkspaceRoot() |
| 21 | if err != nil { |
| 22 | t.Fatal(err) |
| 23 | } |
| 24 | // Registered while Global has no record: the project record takes the |
| 25 | // directory, the way a build without physical identity left it. |
| 26 | ref, duplicate := createLegacyCleanupSession(t, app, root, "kept-session", true) |
| 27 | if duplicate == workspacestate.GlobalWorkspaceID { |
| 28 | t.Fatalf("fixture did not register a separate project record: %q", duplicate) |
| 29 | } |
| 30 | path := config.DesktopWorkspaceStatePath() |
| 31 | body, err := os.ReadFile(path) |
| 32 | if err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | var document map[string]any |
| 36 | if err := json.Unmarshal(body, &document); err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | document["workspaceIds"] = append(document["workspaceIds"].([]any), workspacestate.GlobalWorkspaceID) |
| 40 | document["workspaces"].(map[string]any)[workspacestate.GlobalWorkspaceID] = map[string]any{ |
| 41 | "id": workspacestate.GlobalWorkspaceID, "root": root, |
| 42 | "title": "Global", "visible": true, "sessionIds": []string{}, |
| 43 | } |
| 44 | if body, err = json.Marshal(document); err != nil { |
| 45 | t.Fatal(err) |
| 46 | } |
| 47 | if err := os.WriteFile(path, append(body, '\n'), 0o600); err != nil { |
| 48 | t.Fatal(err) |
| 49 | } |
| 50 | |
| 51 | if owner, err := app.ensureDesktopWorkspace(t.Context(), "global", ""); err != nil || owner != workspacestate.GlobalWorkspaceID { |
| 52 | t.Fatalf("repair resolved %q: %v", owner, err) |
| 53 | } |
| 54 | state, err := app.workspaceRegistry().Load(t.Context()) |
| 55 | if err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | if _, stranded := state.Workspaces[duplicate]; stranded || !slices.Contains(state.Workspaces[workspacestate.GlobalWorkspaceID].SessionIDs, ref.SessionID) { |
| 59 | t.Fatalf("session did not move to the surviving owner: %+v", state.Workspaces) |
| 60 | } |
| 61 | file := desktopTabsFile{Tabs: []desktopTabEntry{savedProjectTab("kept", ref.SessionID, "", root, duplicate)}, ActiveTab: "kept"} |
| 62 | got, _ := app.reconcileSavedTabs(t.Context(), file) |
| 63 | if len(got.Tabs) != 1 || got.Tabs[0].SessionID != ref.SessionID { |
| 64 | t.Fatalf("saved tab naming the absorbed workspace was dropped: %+v", got.Tabs) |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | // A registry written before physical workspace identity could hold both the |
| 69 | // Global folder and an ordinary project for that same directory. Every sidebar |
| 70 | // projection over it resolves an owner, so an unrepaired duplicate strands the |
| 71 | // Global folder and the project together. |
| 72 | func TestProjectTopicsRecoverFromDuplicateGlobalWorkspaceIdentity(t *testing.T) { |
| 73 | isolateDesktopUserDirs(t) |
| 74 | root, err := ensureGlobalWorkspaceRoot() |
| 75 | if err != nil { |
| 76 | t.Fatal(err) |
| 77 | } |
| 78 | duplicate := desktopWorkspaceID("project", root) |
| 79 | path := config.DesktopWorkspaceStatePath() |
| 80 | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { |
| 81 | t.Fatal(err) |
| 82 | } |
| 83 | body, err := json.Marshal(map[string]any{ |
| 84 | "version": 3, "initialized": true, "sessionStates": map[string]any{}, |
| 85 | "workspaceIds": []string{workspacestate.GlobalWorkspaceID, duplicate}, |
| 86 | "workspaces": map[string]any{ |
| 87 | workspacestate.GlobalWorkspaceID: map[string]any{ |
| 88 | "id": workspacestate.GlobalWorkspaceID, "root": root, |
| 89 | "title": "Global", "visible": true, "sessionIds": []string{}, |
| 90 | }, |
| 91 | duplicate: map[string]any{ |
| 92 | "id": duplicate, "root": root, |
| 93 | "title": "global-workspace", "visible": true, "sessionIds": []string{}, |
| 94 | }, |
| 95 | }, |
| 96 | }) |
| 97 | if err != nil { |
| 98 | t.Fatal(err) |
| 99 | } |
| 100 | if err := os.WriteFile(path, append(body, '\n'), 0o600); err != nil { |
| 101 | t.Fatal(err) |
| 102 | } |
| 103 | app := NewApp() |
| 104 | app.ctx = t.Context() |
| 105 | t.Cleanup(app.closeSessionServices) |
| 106 | for _, req := range []ProjectTopicPageRequest{ |
| 107 | {Scope: "global", Limit: 50}, |
| 108 | {Scope: "project", WorkspaceRoot: root, Limit: 50}, |
| 109 | } { |
| 110 | if _, err := app.ListProjectTopics(req); err != nil { |
| 111 | t.Fatalf("list %s topics: %v", req.Scope, err) |
| 112 | } |
| 113 | } |
| 114 | state, err := app.workspaceRegistry().Load(t.Context()) |
| 115 | if err != nil { |
| 116 | t.Fatal(err) |
| 117 | } |
| 118 | if _, stranded := state.Workspaces[duplicate]; stranded || len(state.Workspaces) != 1 { |
| 119 | t.Fatalf("duplicate directory owner survived: %+v", state.Workspaces) |
| 120 | } |
| 121 | global := state.Workspaces[workspacestate.GlobalWorkspaceID] |
| 122 | if global.Title != "Global" || !global.Visible { |
| 123 | t.Fatalf("global lost its presentation: %+v", global) |
| 124 | } |
| 125 | } |
| 126 |