| 1 | import { scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| 2 | import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; |
| 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 4 | |
| 5 | vi.mock("~/state/entities", () => ({ useThreadShell: () => null })); |
| 6 | vi.mock("~/state/session", () => ({ usePreparedConnection: () => ({ _tag: "None" }) })); |
| 7 | |
| 8 | import { |
| 9 | flushPendingFaviconsForThread, |
| 10 | lookupFavicon, |
| 11 | mergeBrowserFaviconState, |
| 12 | recordFaviconForProject, |
| 13 | recordFaviconForThread, |
| 14 | registerFaviconProjectForThread, |
| 15 | resetBrowserFaviconsForTests, |
| 16 | resolveBrowserFaviconStorage, |
| 17 | useBrowserFaviconStore, |
| 18 | } from "./browserFaviconStore"; |
| 19 | import { |
| 20 | BROWSER_FAVICON_MAX_ENTRIES, |
| 21 | migratePersistedBrowserFaviconState, |
| 22 | } from "./browserFaviconLogic"; |
| 23 | |
| 24 | const environmentId = EnvironmentId.make("env-1"); |
| 25 | const projectRef = scopeProjectRef(environmentId, ProjectId.make("project-1")); |
| 26 | const threadRef = { environmentId, threadId: ThreadId.make("thread-1") }; |
| 27 | const PNG = "data:image/png;base64,AAAA"; |
| 28 | const favicon = (pageUrl: string, capturedAt: number, dataUrl = PNG) => ({ |
| 29 | pageUrl, |
| 30 | capturedAt, |
| 31 | dataUrl, |
| 32 | }); |
| 33 | |
| 34 | describe("browser favicon store", () => { |
| 35 | beforeEach(resetBrowserFaviconsForTests); |
| 36 | afterEach(() => { |
| 37 | vi.unstubAllGlobals(); |
| 38 | vi.restoreAllMocks(); |
| 39 | }); |
| 40 | |
| 41 | it("keeps the newest capture for an origin and permits an identical later revisit", () => { |
| 42 | const recordFavicon = vi.spyOn(useBrowserFaviconStore.getState(), "recordFavicon"); |
| 43 | recordFaviconForProject(projectRef, favicon("http://localhost:3000/", 20), null); |
| 44 | recordFaviconForProject( |
| 45 | projectRef, |
| 46 | favicon("http://localhost:3000/old", 10, "data:image/png;base64,QkJCQg=="), |
| 47 | null, |
| 48 | ); |
| 49 | recordFaviconForProject(projectRef, favicon("http://localhost:3000/new", 30), null); |
| 50 | recordFaviconForProject(projectRef, favicon("http://localhost:3000/new", 30), null); |
| 51 | expect(Object.values(useBrowserFaviconStore.getState().byKey)).toEqual([ |
| 52 | { dataUrl: PNG, capturedAt: 30 }, |
| 53 | ]); |
| 54 | expect(recordFavicon).toHaveBeenCalledTimes(2); |
| 55 | }); |
| 56 | |
| 57 | it("does not share localhost icons across environments or physical projects", () => { |
| 58 | const otherEnvironment = scopeProjectRef( |
| 59 | EnvironmentId.make("env-2"), |
| 60 | ProjectId.make("project-1"), |
| 61 | ); |
| 62 | const otherProject = scopeProjectRef(environmentId, ProjectId.make("project-2")); |
| 63 | recordFaviconForProject(projectRef, favicon("http://localhost:3000/", 1), null); |
| 64 | recordFaviconForProject(otherEnvironment, favicon("http://localhost:3000/", 2), null); |
| 65 | recordFaviconForProject(otherProject, favicon("http://localhost:3000/", 3), null); |
| 66 | expect(Object.keys(useBrowserFaviconStore.getState().byKey)).toEqual([ |
| 67 | "env-1:project-1 http://localhost:3000", |
| 68 | "env-2:project-1 http://localhost:3000", |
| 69 | "env-1:project-2 http://localhost:3000", |
| 70 | ]); |
| 71 | }); |
| 72 | |
| 73 | it("finds a persisted environment icon after shell hydration without a live connection", () => { |
| 74 | recordFaviconForProject(projectRef, favicon("http://192.168.64.2:3000/app", 5), "192.168.64.2"); |
| 75 | const byKey = useBrowserFaviconStore.getState().byKey; |
| 76 | expect(lookupFavicon(byKey, null, "http://localhost:3000/app", null)).toBeNull(); |
| 77 | expect(lookupFavicon(byKey, projectRef, "http://localhost:3000/app", null)).toBe(PNG); |
| 78 | expect(lookupFavicon(byKey, projectRef, "http://192.168.64.2:3000/app", null)).toBe(PNG); |
| 79 | expect(lookupFavicon(byKey, projectRef, "http://192.168.64.3:3000/app", null)).toBeNull(); |
| 80 | expect(lookupFavicon(byKey, projectRef, "https://192.168.64.2:3000/app", null)).toBeNull(); |
| 81 | expect(lookupFavicon(byKey, projectRef, "http://192.168.64.2:3001/app", null)).toBeNull(); |
| 82 | expect( |
| 83 | lookupFavicon( |
| 84 | byKey, |
| 85 | scopeProjectRef(environmentId, ProjectId.make("project-2")), |
| 86 | "http://192.168.64.2:3000/app", |
| 87 | null, |
| 88 | ), |
| 89 | ).toBeNull(); |
| 90 | }); |
| 91 | |
| 92 | it("finds a persisted IPv6 environment icon without a live connection", () => { |
| 93 | recordFaviconForProject(projectRef, favicon("http://[fd00::1]:3000/app", 5), "fd00::1"); |
| 94 | const migrated = migratePersistedBrowserFaviconState({ |
| 95 | byKey: useBrowserFaviconStore.getState().byKey, |
| 96 | }).byKey; |
| 97 | expect(lookupFavicon(migrated, projectRef, "http://[fd00::1]:3000/app", null)).toBe(PNG); |
| 98 | }); |
| 99 | |
| 100 | it("keeps exact host aliases attached to the newest canonical icon", () => { |
| 101 | const olderPng = "data:image/png;base64,QkJCQg=="; |
| 102 | const newerPng = "data:image/png;base64,Q0NDQw=="; |
| 103 | recordFaviconForProject( |
| 104 | projectRef, |
| 105 | favicon("http://192.168.64.2:3000/", 10, olderPng), |
| 106 | "192.168.64.2", |
| 107 | ); |
| 108 | recordFaviconForProject( |
| 109 | projectRef, |
| 110 | favicon("http://192.168.64.3:3000/", 20, newerPng), |
| 111 | "192.168.64.3", |
| 112 | ); |
| 113 | recordFaviconForProject( |
| 114 | projectRef, |
| 115 | favicon("http://192.168.64.4:3000/", 15, olderPng), |
| 116 | "192.168.64.4", |
| 117 | ); |
| 118 | const byKey = useBrowserFaviconStore.getState().byKey; |
| 119 | for (const host of ["192.168.64.2", "192.168.64.3", "192.168.64.4"]) { |
| 120 | expect(lookupFavicon(byKey, projectRef, `http://${host}:3000/`, null)).toBe(newerPng); |
| 121 | } |
| 122 | expect(byKey["env-1:project-1 http://localhost:3000"]?.capturedAt).toBe(20); |
| 123 | }); |
| 124 | |
| 125 | it("evicts an icon and its exact host aliases atomically", () => { |
| 126 | recordFaviconForProject(projectRef, favicon("http://192.168.64.2:3000/", 100), "192.168.64.2"); |
| 127 | for (let index = 1; index < BROWSER_FAVICON_MAX_ENTRIES; index += 1) { |
| 128 | recordFaviconForProject( |
| 129 | projectRef, |
| 130 | favicon(`https://example-${index}.com/`, 100 + index), |
| 131 | null, |
| 132 | ); |
| 133 | } |
| 134 | expect( |
| 135 | lookupFavicon( |
| 136 | useBrowserFaviconStore.getState().byKey, |
| 137 | projectRef, |
| 138 | "http://192.168.64.2:3000/", |
| 139 | null, |
| 140 | ), |
| 141 | ).toBe(PNG); |
| 142 | |
| 143 | recordFaviconForProject(projectRef, favicon("https://evicts-oldest.example/", 1_000), null); |
| 144 | expect( |
| 145 | lookupFavicon( |
| 146 | useBrowserFaviconStore.getState().byKey, |
| 147 | projectRef, |
| 148 | "http://192.168.64.2:3000/", |
| 149 | null, |
| 150 | ), |
| 151 | ).toBeNull(); |
| 152 | }); |
| 153 | |
| 154 | it("retains multiple origins until project and connection metadata hydrate", () => { |
| 155 | expect( |
| 156 | recordFaviconForThread(threadRef, favicon("http://localhost:3000/", 1), null, undefined), |
| 157 | ).toBe(false); |
| 158 | expect( |
| 159 | recordFaviconForThread(threadRef, favicon("http://localhost:5173/", 2), null, undefined), |
| 160 | ).toBe(false); |
| 161 | expect( |
| 162 | Object.keys(Object.values(useBrowserFaviconStore.getState().pendingByThreadKey)[0] ?? {}), |
| 163 | ).toHaveLength(2); |
| 164 | |
| 165 | expect(flushPendingFaviconsForThread(threadRef, projectRef, "192.168.64.2")).toBe(true); |
| 166 | expect(Object.keys(useBrowserFaviconStore.getState().byKey).toSorted()).toEqual([ |
| 167 | "env-1:project-1 http://localhost:3000", |
| 168 | "env-1:project-1 http://localhost:5173", |
| 169 | ]); |
| 170 | expect(useBrowserFaviconStore.getState().pendingByThreadKey).toEqual({}); |
| 171 | }); |
| 172 | |
| 173 | it("persists unambiguous loopback captures while the environment is offline", () => { |
| 174 | expect( |
| 175 | recordFaviconForThread( |
| 176 | threadRef, |
| 177 | favicon("http://localhost:3000/", 1), |
| 178 | projectRef, |
| 179 | undefined, |
| 180 | ), |
| 181 | ).toBe(true); |
| 182 | expect(useBrowserFaviconStore.getState().byKey).toEqual({ |
| 183 | "env-1:project-1 http://localhost:3000": { dataUrl: PNG, capturedAt: 1 }, |
| 184 | }); |
| 185 | |
| 186 | recordFaviconForThread( |
| 187 | threadRef, |
| 188 | favicon("http://192.168.64.2:5173/", 2), |
| 189 | projectRef, |
| 190 | undefined, |
| 191 | ); |
| 192 | expect(flushPendingFaviconsForThread(threadRef, projectRef, undefined)).toBe(false); |
| 193 | expect(Object.values(useBrowserFaviconStore.getState().pendingByThreadKey)[0]).toBeDefined(); |
| 194 | }); |
| 195 | |
| 196 | it("keeps pending captures in store-owned state independent of bridge lifetime", () => { |
| 197 | recordFaviconForThread(threadRef, favicon("http://localhost:3000/", 10), null, undefined); |
| 198 | const pendingAfterUnmount = useBrowserFaviconStore.getState().pendingByThreadKey; |
| 199 | useBrowserFaviconStore.setState({ pendingByThreadKey: pendingAfterUnmount }); |
| 200 | flushPendingFaviconsForThread(threadRef, projectRef, "localhost"); |
| 201 | expect(useBrowserFaviconStore.getState().byKey).toEqual({ |
| 202 | "env-1:project-1 http://localhost:3000": { dataUrl: PNG, capturedAt: 10 }, |
| 203 | }); |
| 204 | }); |
| 205 | |
| 206 | it("flushes and resolves a pending draft-thread favicon after physical project registration", () => { |
| 207 | recordFaviconForThread(threadRef, favicon("http://localhost:8025/", 10), null, undefined); |
| 208 | registerFaviconProjectForThread(threadRef, projectRef); |
| 209 | const registered = useBrowserFaviconStore.getState().projectRefByThreadKey["env-1:thread-1"]; |
| 210 | expect(registered).toEqual(projectRef); |
| 211 | expect(flushPendingFaviconsForThread(threadRef, registered!, undefined)).toBe(true); |
| 212 | expect( |
| 213 | lookupFavicon( |
| 214 | useBrowserFaviconStore.getState().byKey, |
| 215 | registered!, |
| 216 | "http://localhost:8025/", |
| 217 | null, |
| 218 | ), |
| 219 | ).toBe(PNG); |
| 220 | }); |
| 221 | |
| 222 | it("bounds pending memory by origin and thread", () => { |
| 223 | for (let thread = 0; thread < 22; thread += 1) { |
| 224 | for (let port = 3000; port < 3012; port += 1) { |
| 225 | recordFaviconForThread( |
| 226 | { environmentId, threadId: ThreadId.make(`thread-${thread}`) }, |
| 227 | favicon(`http://localhost:${port}/`, port), |
| 228 | null, |
| 229 | undefined, |
| 230 | ); |
| 231 | } |
| 232 | } |
| 233 | const pending = useBrowserFaviconStore.getState().pendingByThreadKey; |
| 234 | expect(Object.keys(pending)).toHaveLength(20); |
| 235 | expect(Object.values(pending).every((byOrigin) => Object.keys(byOrigin).length === 10)).toBe( |
| 236 | true, |
| 237 | ); |
| 238 | }); |
| 239 | |
| 240 | it("sanitizes hydrated state while preserving actions and transient pending data", () => { |
| 241 | recordFaviconForThread(threadRef, favicon("http://localhost:3000/", 1), null, undefined); |
| 242 | const current = useBrowserFaviconStore.getState(); |
| 243 | const merged = mergeBrowserFaviconState( |
| 244 | { |
| 245 | byKey: { |
| 246 | "env-1:project-1 http://localhost:3000": { dataUrl: PNG, capturedAt: 2 }, |
| 247 | "env-1:project-1 http://localhost:3001": { dataUrl: "bad", capturedAt: 3 }, |
| 248 | "env-1:project-1 http://localhost:3002": { dataUrl: PNG, capturedAt: 1e308 }, |
| 249 | ["x".repeat(5_000)]: { dataUrl: PNG, capturedAt: 4 }, |
| 250 | }, |
| 251 | }, |
| 252 | current, |
| 253 | ); |
| 254 | expect(merged.byKey).toEqual({ |
| 255 | "env-1:project-1 http://localhost:3000": { dataUrl: PNG, capturedAt: 2 }, |
| 256 | }); |
| 257 | expect(merged.pendingByThreadKey).toEqual(current.pendingByThreadKey); |
| 258 | expect(typeof merged.recordFavicon).toBe("function"); |
| 259 | }); |
| 260 | |
| 261 | it("falls back to memory when localStorage access throws", () => { |
| 262 | vi.stubGlobal( |
| 263 | "window", |
| 264 | Object.defineProperty({}, "localStorage", { |
| 265 | get: () => { |
| 266 | throw new Error("storage blocked"); |
| 267 | }, |
| 268 | }), |
| 269 | ); |
| 270 | const storage = resolveBrowserFaviconStorage(); |
| 271 | storage.setItem("key", "value"); |
| 272 | expect(storage.getItem("key")).toBe("value"); |
| 273 | }); |
| 274 | |
| 275 | it("falls back to memory when localStorage operations throw", () => { |
| 276 | vi.stubGlobal("window", { |
| 277 | localStorage: { |
| 278 | getItem: vi.fn(() => { |
| 279 | throw new Error("read blocked"); |
| 280 | }), |
| 281 | setItem: vi.fn(() => { |
| 282 | throw new Error("quota exceeded"); |
| 283 | }), |
| 284 | removeItem: vi.fn(() => { |
| 285 | throw new Error("remove blocked"); |
| 286 | }), |
| 287 | }, |
| 288 | }); |
| 289 | const storage = resolveBrowserFaviconStorage(); |
| 290 | |
| 291 | storage.setItem("key", "value"); |
| 292 | expect(storage.getItem("key")).toBe("value"); |
| 293 | storage.removeItem("key"); |
| 294 | expect(storage.getItem("key")).toBeNull(); |
| 295 | }); |
| 296 | |
| 297 | it("keeps the memory shadow authoritative after an asymmetric primary write failure", () => { |
| 298 | vi.stubGlobal("window", { |
| 299 | localStorage: { |
| 300 | getItem: vi.fn(() => "stale"), |
| 301 | setItem: vi.fn(() => { |
| 302 | throw new Error("quota exceeded"); |
| 303 | }), |
| 304 | removeItem: vi.fn(() => { |
| 305 | throw new Error("remove blocked"); |
| 306 | }), |
| 307 | }, |
| 308 | }); |
| 309 | const storage = resolveBrowserFaviconStorage(); |
| 310 | |
| 311 | storage.setItem("key", "fresh"); |
| 312 | expect(storage.getItem("key")).toBe("fresh"); |
| 313 | storage.removeItem("key"); |
| 314 | expect(storage.getItem("key")).toBeNull(); |
| 315 | }); |
| 316 | }); |