1import { scopeProjectRef } from "@t3tools/client-runtime/environment";
2import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5vi.mock("~/state/entities", () => ({ useThreadShell: () => null }));
6vi.mock("~/state/session", () => ({ usePreparedConnection: () => ({ _tag: "None" }) }));
7
8import {
9 flushPendingFaviconsForThread,
10 lookupFavicon,
11 mergeBrowserFaviconState,
12 recordFaviconForProject,
13 recordFaviconForThread,
14 registerFaviconProjectForThread,
15 resetBrowserFaviconsForTests,
16 resolveBrowserFaviconStorage,
17 useBrowserFaviconStore,
18} from "./browserFaviconStore";
19import {
20 BROWSER_FAVICON_MAX_ENTRIES,
21 migratePersistedBrowserFaviconState,
22} from "./browserFaviconLogic";
23
24const environmentId = EnvironmentId.make("env-1");
25const projectRef = scopeProjectRef(environmentId, ProjectId.make("project-1"));
26const threadRef = { environmentId, threadId: ThreadId.make("thread-1") };
27const PNG = "data:image/png;base64,AAAA";
28const favicon = (pageUrl: string, capturedAt: number, dataUrl = PNG) => ({
29 pageUrl,
30 capturedAt,
31 dataUrl,
32});
33
34describe("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});