import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; vi.mock("~/state/entities", () => ({ useThreadShell: () => null })); vi.mock("~/state/session", () => ({ usePreparedConnection: () => ({ _tag: "None" }) })); import { flushPendingFaviconsForThread, lookupFavicon, mergeBrowserFaviconState, recordFaviconForProject, recordFaviconForThread, registerFaviconProjectForThread, resetBrowserFaviconsForTests, resolveBrowserFaviconStorage, useBrowserFaviconStore, } from "./browserFaviconStore"; import { BROWSER_FAVICON_MAX_ENTRIES, migratePersistedBrowserFaviconState, } from "./browserFaviconLogic"; const environmentId = EnvironmentId.make("env-1"); const projectRef = scopeProjectRef(environmentId, ProjectId.make("project-1")); const threadRef = { environmentId, threadId: ThreadId.make("thread-1") }; const PNG = "data:image/png;base64,AAAA"; const favicon = (pageUrl: string, capturedAt: number, dataUrl = PNG) => ({ pageUrl, capturedAt, dataUrl, }); describe("browser favicon store", () => { beforeEach(resetBrowserFaviconsForTests); afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); it("keeps the newest capture for an origin and permits an identical later revisit", () => { const recordFavicon = vi.spyOn(useBrowserFaviconStore.getState(), "recordFavicon"); recordFaviconForProject(projectRef, favicon("http://localhost:3000/", 20), null); recordFaviconForProject( projectRef, favicon("http://localhost:3000/old", 10, "data:image/png;base64,QkJCQg=="), null, ); recordFaviconForProject(projectRef, favicon("http://localhost:3000/new", 30), null); recordFaviconForProject(projectRef, favicon("http://localhost:3000/new", 30), null); expect(Object.values(useBrowserFaviconStore.getState().byKey)).toEqual([ { dataUrl: PNG, capturedAt: 30 }, ]); expect(recordFavicon).toHaveBeenCalledTimes(2); }); it("does not share localhost icons across environments or physical projects", () => { const otherEnvironment = scopeProjectRef( EnvironmentId.make("env-2"), ProjectId.make("project-1"), ); const otherProject = scopeProjectRef(environmentId, ProjectId.make("project-2")); recordFaviconForProject(projectRef, favicon("http://localhost:3000/", 1), null); recordFaviconForProject(otherEnvironment, favicon("http://localhost:3000/", 2), null); recordFaviconForProject(otherProject, favicon("http://localhost:3000/", 3), null); expect(Object.keys(useBrowserFaviconStore.getState().byKey)).toEqual([ "env-1:project-1 http://localhost:3000", "env-2:project-1 http://localhost:3000", "env-1:project-2 http://localhost:3000", ]); }); it("finds a persisted environment icon after shell hydration without a live connection", () => { recordFaviconForProject(projectRef, favicon("http://192.168.64.2:3000/app", 5), "192.168.64.2"); const byKey = useBrowserFaviconStore.getState().byKey; expect(lookupFavicon(byKey, null, "http://localhost:3000/app", null)).toBeNull(); expect(lookupFavicon(byKey, projectRef, "http://localhost:3000/app", null)).toBe(PNG); expect(lookupFavicon(byKey, projectRef, "http://192.168.64.2:3000/app", null)).toBe(PNG); expect(lookupFavicon(byKey, projectRef, "http://192.168.64.3:3000/app", null)).toBeNull(); expect(lookupFavicon(byKey, projectRef, "https://192.168.64.2:3000/app", null)).toBeNull(); expect(lookupFavicon(byKey, projectRef, "http://192.168.64.2:3001/app", null)).toBeNull(); expect( lookupFavicon( byKey, scopeProjectRef(environmentId, ProjectId.make("project-2")), "http://192.168.64.2:3000/app", null, ), ).toBeNull(); }); it("finds a persisted IPv6 environment icon without a live connection", () => { recordFaviconForProject(projectRef, favicon("http://[fd00::1]:3000/app", 5), "fd00::1"); const migrated = migratePersistedBrowserFaviconState({ byKey: useBrowserFaviconStore.getState().byKey, }).byKey; expect(lookupFavicon(migrated, projectRef, "http://[fd00::1]:3000/app", null)).toBe(PNG); }); it("keeps exact host aliases attached to the newest canonical icon", () => { const olderPng = "data:image/png;base64,QkJCQg=="; const newerPng = "data:image/png;base64,Q0NDQw=="; recordFaviconForProject( projectRef, favicon("http://192.168.64.2:3000/", 10, olderPng), "192.168.64.2", ); recordFaviconForProject( projectRef, favicon("http://192.168.64.3:3000/", 20, newerPng), "192.168.64.3", ); recordFaviconForProject( projectRef, favicon("http://192.168.64.4:3000/", 15, olderPng), "192.168.64.4", ); const byKey = useBrowserFaviconStore.getState().byKey; for (const host of ["192.168.64.2", "192.168.64.3", "192.168.64.4"]) { expect(lookupFavicon(byKey, projectRef, `http://${host}:3000/`, null)).toBe(newerPng); } expect(byKey["env-1:project-1 http://localhost:3000"]?.capturedAt).toBe(20); }); it("evicts an icon and its exact host aliases atomically", () => { recordFaviconForProject(projectRef, favicon("http://192.168.64.2:3000/", 100), "192.168.64.2"); for (let index = 1; index < BROWSER_FAVICON_MAX_ENTRIES; index += 1) { recordFaviconForProject( projectRef, favicon(`https://example-${index}.com/`, 100 + index), null, ); } expect( lookupFavicon( useBrowserFaviconStore.getState().byKey, projectRef, "http://192.168.64.2:3000/", null, ), ).toBe(PNG); recordFaviconForProject(projectRef, favicon("https://evicts-oldest.example/", 1_000), null); expect( lookupFavicon( useBrowserFaviconStore.getState().byKey, projectRef, "http://192.168.64.2:3000/", null, ), ).toBeNull(); }); it("retains multiple origins until project and connection metadata hydrate", () => { expect( recordFaviconForThread(threadRef, favicon("http://localhost:3000/", 1), null, undefined), ).toBe(false); expect( recordFaviconForThread(threadRef, favicon("http://localhost:5173/", 2), null, undefined), ).toBe(false); expect( Object.keys(Object.values(useBrowserFaviconStore.getState().pendingByThreadKey)[0] ?? {}), ).toHaveLength(2); expect(flushPendingFaviconsForThread(threadRef, projectRef, "192.168.64.2")).toBe(true); expect(Object.keys(useBrowserFaviconStore.getState().byKey).toSorted()).toEqual([ "env-1:project-1 http://localhost:3000", "env-1:project-1 http://localhost:5173", ]); expect(useBrowserFaviconStore.getState().pendingByThreadKey).toEqual({}); }); it("persists unambiguous loopback captures while the environment is offline", () => { expect( recordFaviconForThread( threadRef, favicon("http://localhost:3000/", 1), projectRef, undefined, ), ).toBe(true); expect(useBrowserFaviconStore.getState().byKey).toEqual({ "env-1:project-1 http://localhost:3000": { dataUrl: PNG, capturedAt: 1 }, }); recordFaviconForThread( threadRef, favicon("http://192.168.64.2:5173/", 2), projectRef, undefined, ); expect(flushPendingFaviconsForThread(threadRef, projectRef, undefined)).toBe(false); expect(Object.values(useBrowserFaviconStore.getState().pendingByThreadKey)[0]).toBeDefined(); }); it("keeps pending captures in store-owned state independent of bridge lifetime", () => { recordFaviconForThread(threadRef, favicon("http://localhost:3000/", 10), null, undefined); const pendingAfterUnmount = useBrowserFaviconStore.getState().pendingByThreadKey; useBrowserFaviconStore.setState({ pendingByThreadKey: pendingAfterUnmount }); flushPendingFaviconsForThread(threadRef, projectRef, "localhost"); expect(useBrowserFaviconStore.getState().byKey).toEqual({ "env-1:project-1 http://localhost:3000": { dataUrl: PNG, capturedAt: 10 }, }); }); it("flushes and resolves a pending draft-thread favicon after physical project registration", () => { recordFaviconForThread(threadRef, favicon("http://localhost:8025/", 10), null, undefined); registerFaviconProjectForThread(threadRef, projectRef); const registered = useBrowserFaviconStore.getState().projectRefByThreadKey["env-1:thread-1"]; expect(registered).toEqual(projectRef); expect(flushPendingFaviconsForThread(threadRef, registered!, undefined)).toBe(true); expect( lookupFavicon( useBrowserFaviconStore.getState().byKey, registered!, "http://localhost:8025/", null, ), ).toBe(PNG); }); it("bounds pending memory by origin and thread", () => { for (let thread = 0; thread < 22; thread += 1) { for (let port = 3000; port < 3012; port += 1) { recordFaviconForThread( { environmentId, threadId: ThreadId.make(`thread-${thread}`) }, favicon(`http://localhost:${port}/`, port), null, undefined, ); } } const pending = useBrowserFaviconStore.getState().pendingByThreadKey; expect(Object.keys(pending)).toHaveLength(20); expect(Object.values(pending).every((byOrigin) => Object.keys(byOrigin).length === 10)).toBe( true, ); }); it("sanitizes hydrated state while preserving actions and transient pending data", () => { recordFaviconForThread(threadRef, favicon("http://localhost:3000/", 1), null, undefined); const current = useBrowserFaviconStore.getState(); const merged = mergeBrowserFaviconState( { byKey: { "env-1:project-1 http://localhost:3000": { dataUrl: PNG, capturedAt: 2 }, "env-1:project-1 http://localhost:3001": { dataUrl: "bad", capturedAt: 3 }, "env-1:project-1 http://localhost:3002": { dataUrl: PNG, capturedAt: 1e308 }, ["x".repeat(5_000)]: { dataUrl: PNG, capturedAt: 4 }, }, }, current, ); expect(merged.byKey).toEqual({ "env-1:project-1 http://localhost:3000": { dataUrl: PNG, capturedAt: 2 }, }); expect(merged.pendingByThreadKey).toEqual(current.pendingByThreadKey); expect(typeof merged.recordFavicon).toBe("function"); }); it("falls back to memory when localStorage access throws", () => { vi.stubGlobal( "window", Object.defineProperty({}, "localStorage", { get: () => { throw new Error("storage blocked"); }, }), ); const storage = resolveBrowserFaviconStorage(); storage.setItem("key", "value"); expect(storage.getItem("key")).toBe("value"); }); it("falls back to memory when localStorage operations throw", () => { vi.stubGlobal("window", { localStorage: { getItem: vi.fn(() => { throw new Error("read blocked"); }), setItem: vi.fn(() => { throw new Error("quota exceeded"); }), removeItem: vi.fn(() => { throw new Error("remove blocked"); }), }, }); const storage = resolveBrowserFaviconStorage(); storage.setItem("key", "value"); expect(storage.getItem("key")).toBe("value"); storage.removeItem("key"); expect(storage.getItem("key")).toBeNull(); }); it("keeps the memory shadow authoritative after an asymmetric primary write failure", () => { vi.stubGlobal("window", { localStorage: { getItem: vi.fn(() => "stale"), setItem: vi.fn(() => { throw new Error("quota exceeded"); }), removeItem: vi.fn(() => { throw new Error("remove blocked"); }), }, }); const storage = resolveBrowserFaviconStorage(); storage.setItem("key", "fresh"); expect(storage.getItem("key")).toBe("fresh"); storage.removeItem("key"); expect(storage.getItem("key")).toBeNull(); }); });