| 1 | // Run: tsx src/__tests__/activity-bar-store.test.ts |
| 2 | // Verifies the right-dock activity-bar store: open-entry reuse, add/close tab |
| 3 | // activation fallback, and localStorage persistence round-trip. |
| 4 | |
| 5 | import { readFileSync } from "node:fs"; |
| 6 | import { dirname, resolve } from "node:path"; |
| 7 | import { fileURLToPath } from "node:url"; |
| 8 | import { JSDOM } from "jsdom"; |
| 9 | |
| 10 | // The store reads localStorage at module load, so install a jsdom global |
| 11 | // before importing it (mirrors how browser-only tests bootstrap the DOM). |
| 12 | const dom = new JSDOM("", { url: "https://reasonix.local/" }); |
| 13 | globalThis.window = dom.window as unknown as Window & typeof globalThis; |
| 14 | globalThis.document = dom.window.document; |
| 15 | globalThis.localStorage = dom.window.localStorage; |
| 16 | |
| 17 | const testDir = dirname(fileURLToPath(import.meta.url)); |
| 18 | const storeSource = readFileSync(resolve(testDir, "../store/activityBar.ts"), "utf8"); |
| 19 | const { useActivityBarStore } = await import("../store/activityBar"); |
| 20 | |
| 21 | let passed = 0; |
| 22 | let failed = 0; |
| 23 | |
| 24 | function check(name: string, condition: boolean, detail = "") { |
| 25 | if (condition) { |
| 26 | passed += 1; |
| 27 | console.log(` PASS ${name}`); |
| 28 | } else { |
| 29 | failed += 1; |
| 30 | console.error(` FAIL ${name}${detail ? ` — ${detail}` : ""}`); |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | function resetStore(): void { |
| 35 | localStorage.clear(); |
| 36 | useActivityBarStore.setState({ tabs: [], activeTabId: null, addMenuOpen: false, recentlyClosed: [] }); |
| 37 | } |
| 38 | |
| 39 | // --- persistence contract: store reads/writes localStorage under one key --- |
| 40 | check( |
| 41 | "store persists under reasonix.dock.tabs", |
| 42 | /STORAGE_KEY\s*=\s*"reasonix\.dock\.tabs"/.test(storeSource), |
| 43 | "STORAGE_KEY literal not found", |
| 44 | ); |
| 45 | |
| 46 | // --- openEntry reuses an existing tab of the same type --- |
| 47 | resetStore(); |
| 48 | useActivityBarStore.getState().openEntry("file", "Files"); |
| 49 | const first = useActivityBarStore.getState().tabs[0].id; |
| 50 | check("openEntry opens the first tab", useActivityBarStore.getState().tabs.length === 1, "expected 1 tab"); |
| 51 | check("openEntry activates the tab", useActivityBarStore.getState().activeTabId === first, "activeTabId mismatch"); |
| 52 | useActivityBarStore.getState().openEntry("changed", "Changes"); |
| 53 | check("openEntry appends a different type", useActivityBarStore.getState().tabs.length === 2, "expected 2 tabs"); |
| 54 | useActivityBarStore.getState().openEntry("file", "Files"); |
| 55 | check("openEntry reuses same-type tab", useActivityBarStore.getState().tabs.length === 2, "expected still 2 tabs"); |
| 56 | check("openEntry switches back to the reused tab", useActivityBarStore.getState().activeTabId === first, "activeTabId mismatch"); |
| 57 | |
| 58 | // --- closeTab falls back to the left neighbor, then right, then null --- |
| 59 | resetStore(); |
| 60 | useActivityBarStore.getState().openEntry("file", "Files"); |
| 61 | useActivityBarStore.getState().openEntry("changed", "Changes"); |
| 62 | useActivityBarStore.getState().openEntry("browser", "Browser"); |
| 63 | const ids = useActivityBarStore.getState().tabs.map((tab) => tab.id); |
| 64 | check("opening a tab expands the container", useActivityBarStore.getState().activityBarOpen === true, "expected expanded"); |
| 65 | // Closing a non-active tab leaves the active tab untouched. |
| 66 | useActivityBarStore.getState().activateTab(ids[2]); |
| 67 | useActivityBarStore.getState().closeTab(ids[1]); |
| 68 | check("closeTab removes the tab", useActivityBarStore.getState().tabs.length === 2, "expected 2 tabs left"); |
| 69 | check("closing a non-active tab keeps the active tab", useActivityBarStore.getState().activeTabId === ids[2], "activeTabId mismatch"); |
| 70 | // Closing the active tab falls back to its left neighbor. |
| 71 | useActivityBarStore.getState().closeTab(ids[2]); |
| 72 | check("closeTab activates left neighbor", useActivityBarStore.getState().activeTabId === ids[0], "activeTabId mismatch"); |
| 73 | // Closing the last tab collapses back to the activity bar. |
| 74 | useActivityBarStore.getState().closeTab(ids[0]); |
| 75 | check("closing the last tab clears activeTabId", useActivityBarStore.getState().tabs.length === 0 && useActivityBarStore.getState().activeTabId === null, "expected empty dock"); |
| 76 | check("closing the last tab collapses the container", useActivityBarStore.getState().activityBarOpen === false, "expected collapsed"); |
| 77 | |
| 78 | // --- persistence round-trip survives a store re-init --- |
| 79 | resetStore(); |
| 80 | useActivityBarStore.getState().openEntry("file", "Files"); |
| 81 | useActivityBarStore.getState().openEntry("browser", "Browser"); |
| 82 | const persisted = JSON.parse(localStorage.getItem("reasonix.dock.tabs") ?? "{}"); |
| 83 | check("persistence writes tabs", Array.isArray(persisted.tabs) && persisted.tabs.length === 2, "expected 2 persisted tabs"); |
| 84 | check("persistence writes activeTabId", typeof persisted.activeTabId === "string", "activeTabId not persisted"); |
| 85 | |
| 86 | // --- activityBarOpen is independent of tabs (toggle collapse keeps tabs) --- |
| 87 | resetStore(); |
| 88 | useActivityBarStore.getState().openEntry("file", "Files"); |
| 89 | useActivityBarStore.getState().openEntry("browser", "Browser"); |
| 90 | useActivityBarStore.getState().setActivityBarOpen(false); |
| 91 | check("collapse via setActivityBarOpen keeps tabs", useActivityBarStore.getState().tabs.length === 2, "expected 2 tabs kept"); |
| 92 | check("collapse via setActivityBarOpen closes the container", useActivityBarStore.getState().activityBarOpen === false, "expected collapsed"); |
| 93 | useActivityBarStore.getState().setActivityBarOpen(true); |
| 94 | check("re-expand restores the container", useActivityBarStore.getState().activityBarOpen === true, "expected expanded"); |
| 95 | |
| 96 | // --- addMenuOpen is session-local --- |
| 97 | resetStore(); |
| 98 | useActivityBarStore.getState().setAddMenuOpen(true); |
| 99 | check("addMenuOpen setter works", useActivityBarStore.getState().addMenuOpen === true, "expected open"); |
| 100 | |
| 101 | // --- fresh tab ids never collide with restored/persisted ones --- |
| 102 | // New identities remain unique even when closed tabs only exist in view memory. |
| 103 | resetStore(); |
| 104 | useActivityBarStore.getState().addTab("file", "a"); |
| 105 | useActivityBarStore.getState().addTab("file", "b"); |
| 106 | useActivityBarStore.getState().addTab("file", "c"); |
| 107 | const freshIds = useActivityBarStore.getState().tabs.map((tab) => tab.id); |
| 108 | check( |
| 109 | "fresh tab ids are unique", |
| 110 | freshIds.length === new Set(freshIds).size && freshIds.every((id) => /^dock-tab-[a-f0-9-]+$/.test(id)), |
| 111 | `ids=${freshIds.join(",")}`, |
| 112 | ); |
| 113 | |
| 114 | // --- moveTab reorders tabs and persists the new order --- |
| 115 | resetStore(); |
| 116 | useActivityBarStore.getState().addTab("file", "a"); |
| 117 | useActivityBarStore.getState().addTab("changed", "b"); |
| 118 | useActivityBarStore.getState().addTab("context", "c"); |
| 119 | const orderBefore = useActivityBarStore.getState().tabs.map((tab) => tab.id); |
| 120 | // Move c to the left of a → [c, a, b] |
| 121 | useActivityBarStore.getState().moveTab(orderBefore[2], orderBefore[0], "left"); |
| 122 | const afterLeft = useActivityBarStore.getState().tabs.map((tab) => tab.id); |
| 123 | check("moveTab left places the tab before the target", afterLeft[0] === orderBefore[2] && afterLeft[1] === orderBefore[0], `order=${afterLeft.join(",")}`); |
| 124 | check("moveTab persists", JSON.parse(localStorage.getItem("reasonix.dock.tabs") ?? "{}").tabs.map((t: { id: string }) => t.id).join(",") === afterLeft.join(","), "order not persisted"); |
| 125 | // Move a to the right of c → [c, a, b] stays? a is at index 1, target c at 0, right → [c, a, b]? move a right of c inserts after c → [c, a, b] (already). Pick a different move: b right of c → [c, b, a] |
| 126 | useActivityBarStore.getState().moveTab(afterLeft[2], afterLeft[0], "right"); |
| 127 | const afterRight = useActivityBarStore.getState().tabs.map((tab) => tab.id); |
| 128 | check("moveTab right places the tab after the target", afterRight[0] === orderBefore[2] && afterRight[1] === orderBefore[1], `order=${afterRight.join(",")}`); |
| 129 | check("moveTab keeps the active tab", useActivityBarStore.getState().activeTabId === orderBefore[2], "activeTabId changed"); |
| 130 | // Same-id move is a no-op. |
| 131 | useActivityBarStore.getState().moveTab(afterRight[0], afterRight[0], "left"); |
| 132 | check("moveTab same id is a no-op", useActivityBarStore.getState().tabs.map((tab) => tab.id).join(",") === afterRight.join(","), "order changed"); |
| 133 | |
| 134 | // --- recently closed tabs are recorded and can be reopened --- |
| 135 | resetStore(); |
| 136 | useActivityBarStore.getState().addTab("file", "a"); |
| 137 | useActivityBarStore.getState().addTab("changed", "b"); |
| 138 | const closedId = useActivityBarStore.getState().tabs[1].id; |
| 139 | useActivityBarStore.getState().closeTab(closedId); |
| 140 | const closedList = useActivityBarStore.getState().recentlyClosed; |
| 141 | check("closeTab records the closed tab", closedList.length === 1 && closedList[0].tab.id === closedId && closedList[0].tab.label === "b", |
| 142 | `recentlyClosed=${JSON.stringify(closedList)}`); |
| 143 | check("closeTab stamps the close time", typeof closedList[0].closedAt === "number" && closedList[0].closedAt > 0, "no closedAt"); |
| 144 | check("the closed tab is gone from the list", useActivityBarStore.getState().tabs.every((tab) => tab.id !== closedId), "still open"); |
| 145 | useActivityBarStore.getState().reopenTab(closedId); |
| 146 | check("reopenTab restores the tab", useActivityBarStore.getState().tabs.some((tab) => tab.id === closedId), "not restored"); |
| 147 | check("reopenTab activates it", useActivityBarStore.getState().activeTabId === closedId, "not active"); |
| 148 | check("reopenTab drops it from the closed list", useActivityBarStore.getState().recentlyClosed.length === 0, "still listed"); |
| 149 | check("reopenTab persists the restored tab", JSON.parse(localStorage.getItem("reasonix.dock.tabs") ?? "{}").tabs.some((t: { id: string }) => t.id === closedId), "not persisted"); |
| 150 | // Closing several tabs keeps newest first and is bounded. |
| 151 | for (let index = 0; index < 12; index += 1) { |
| 152 | useActivityBarStore.getState().addTab("file", `f${index}`); |
| 153 | const id = useActivityBarStore.getState().tabs[useActivityBarStore.getState().tabs.length - 1].id; |
| 154 | useActivityBarStore.getState().closeTab(id); |
| 155 | } |
| 156 | check("recently closed is bounded to 10 newest first", |
| 157 | useActivityBarStore.getState().recentlyClosed.length === 10 && useActivityBarStore.getState().recentlyClosed[0].tab.label === "f11", |
| 158 | `len=${useActivityBarStore.getState().recentlyClosed.length}`); |
| 159 | |
| 160 | const projectAClosed = useActivityBarStore.getState().recentlyClosed.map(record => record.tab.id); |
| 161 | useActivityBarStore.getState().setWorkspaceRoot("/project-b"); |
| 162 | check("new project has no closed tabs", useActivityBarStore.getState().recentlyClosed.length === 0, "leaked A history"); |
| 163 | useActivityBarStore.getState().reopenTab(projectAClosed[0]); |
| 164 | check("cannot reopen another project's tab", useActivityBarStore.getState().tabs.length === 0, "cross-project reopen"); |
| 165 | useActivityBarStore.getState().addTab("file", "B files"); |
| 166 | const projectBTab = useActivityBarStore.getState().activeTabId!; |
| 167 | useActivityBarStore.getState().closeTab(projectBTab); |
| 168 | useActivityBarStore.getState().setWorkspaceRoot(""); |
| 169 | check("returning restores A closed history", JSON.stringify(useActivityBarStore.getState().recentlyClosed.map(record => record.tab.id)) === JSON.stringify(projectAClosed), "A history changed"); |
| 170 | useActivityBarStore.getState().setWorkspaceRoot("/project-b"); |
| 171 | useActivityBarStore.getState().reopenTab(projectBTab); |
| 172 | check("reopen preserves B identity", useActivityBarStore.getState().activeTabId === projectBTab, "B identity lost"); |
| 173 | |
| 174 | console.log(` |
| 175 | activity-bar-store: ${passed} passed, ${failed} failed`); |
| 176 | if (failed > 0) process.exit(1); |
| 177 |