1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3import { EnvironmentId, ThreadId } from "@t3tools/contracts";
4
5const { readPreparedConnection } = vi.hoisted(() => ({
6 readPreparedConnection: vi.fn<() => { httpBaseUrl: string } | null>(() => null),
7}));
8
9vi.mock("~/state/session", () => ({ readPreparedConnection }));
10
11import {
12 BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT,
13 BROWSER_HISTORY_MAX_PROJECTS,
14 BROWSER_HISTORY_MAX_TITLE_LENGTH,
15 type BrowserHistoryEntry,
16 evictExcessProjects,
17 mergeBrowserHistoryState,
18 migratePersistedBrowserHistoryState,
19 normalizeHistoryUrl,
20 recordVisitForThread,
21 removeUrlForThread,
22 resetBrowserHistoryForTests,
23 setTitleForThreadUrl,
24 upsertHistoryEntry,
25 useBrowserHistoryStore,
26} from "./browserHistoryStore";
27
28function entry(overrides: Partial<BrowserHistoryEntry> = {}): BrowserHistoryEntry {
29 return { url: "http://localhost:3000/", lastVisitedAt: 1000, ...overrides };
30}
31
32beforeEach(() => readPreparedConnection.mockReturnValue(null));
33afterEach(() => vi.restoreAllMocks());
34
35function spyOnPersistWrites() {
36 const storage = useBrowserHistoryStore.persist.getOptions().storage;
37 if (!storage) throw new Error("Browser history persistence storage is unavailable.");
38 return vi.spyOn(storage, "setItem");
39}
40
41describe("normalizeHistoryUrl", () => {
42 it("normalizes bare loopback hosts to http and keeps path/query", () => {
43 expect(normalizeHistoryUrl("localhost:3000/admin?tab=1")).toBe(
44 "http://localhost:3000/admin?tab=1",
45 );
46 });
47
48 it("normalizes bare public hosts to https", () => {
49 expect(normalizeHistoryUrl("myapp.test")).toBe("https://myapp.test/");
50 });
51
52 it("preserves hash routes and strips credentials", () => {
53 expect(normalizeHistoryUrl("http://localhost:3000/app#/route")).toBe(
54 "http://localhost:3000/app#/route",
55 );
56 expect(normalizeHistoryUrl("https://user:secret@example.com/")).toBe("https://example.com/");
57 });
58
59 it("rejects non-http(s), unparseable, and oversized urls", () => {
60 expect(normalizeHistoryUrl("ftp://example.com")).toBeNull();
61 expect(normalizeHistoryUrl("")).toBeNull();
62 expect(normalizeHistoryUrl(`http://localhost/${"a".repeat(2048)}`)).toBeNull();
63 });
64});
65
66describe("upsertHistoryEntry", () => {
67 it("prepends new urls", () => {
68 const next = upsertHistoryEntry([entry()], "http://localhost:5173/", 2000);
69 expect(next.map((e) => e.url)).toEqual(["http://localhost:5173/", "http://localhost:3000/"]);
70 expect(next[0]).toEqual({ url: "http://localhost:5173/", lastVisitedAt: 2000 });
71 });
72
73 it("moves revisits to front, updates the timestamp, and keeps the title", () => {
74 const existing = [
75 entry({ url: "http://a.test/", lastVisitedAt: 500, title: "A" }),
76 entry({ url: "http://b.test/", lastVisitedAt: 400 }),
77 ];
78 const next = upsertHistoryEntry(existing, "http://b.test/", 3000);
79 expect(next.map((e) => e.url)).toEqual(["http://b.test/", "http://a.test/"]);
80 expect(next[0]?.lastVisitedAt).toBe(3000);
81 expect(next[1]?.title).toBe("A");
82 });
83
84 it("caps the list at the per-project limit", () => {
85 const full = Array.from({ length: BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT }, (_, i) =>
86 entry({ url: `http://localhost:${3000 + i}/`, lastVisitedAt: i }),
87 );
88 const next = upsertHistoryEntry(full, "http://new.test/", 9999);
89 expect(next).toHaveLength(BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT);
90 expect(next[0]?.url).toBe("http://new.test/");
91 const lastPort = 3000 + BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT - 1;
92 expect(next.some((e) => e.url === `http://localhost:${lastPort}/`)).toBe(false);
93 expect(next.some((e) => e.url === "http://localhost:3000/")).toBe(true);
94 });
95
96 it("with insertOrdered, slots an older entry below a newer one instead of prepending", () => {
97 const existing = [entry({ url: "http://newer.test/", lastVisitedAt: 2000 })];
98 const next = upsertHistoryEntry(existing, "http://older.test/", 1000, {
99 insertOrdered: true,
100 });
101 expect(next.map((e) => e.url)).toEqual(["http://newer.test/", "http://older.test/"]);
102 });
103
104 it("with insertOrdered, replaying an older visit for an existing entry keeps its newer timestamp", () => {
105 const existing = [entry({ url: "http://a.test/", lastVisitedAt: 2000 })];
106 const next = upsertHistoryEntry(existing, "http://a.test/", 1000, { insertOrdered: true });
107 expect(next).toEqual([{ url: "http://a.test/", lastVisitedAt: 2000 }]);
108 });
109});
110
111describe("evictExcessProjects", () => {
112 it("keeps the most recently visited projects when over the cap", () => {
113 const byProjectKey = Object.fromEntries(
114 Array.from({ length: BROWSER_HISTORY_MAX_PROJECTS + 2 }, (_, i) => [
115 `project-${i}`,
116 [entry({ lastVisitedAt: i })],
117 ]),
118 );
119 const next = evictExcessProjects(byProjectKey);
120 expect(Object.keys(next)).toHaveLength(BROWSER_HISTORY_MAX_PROJECTS);
121 expect(next["project-0"]).toBeUndefined();
122 expect(next["project-1"]).toBeUndefined();
123 expect(next[`project-${BROWSER_HISTORY_MAX_PROJECTS + 1}`]).toBeDefined();
124 });
125});
126
127describe("migratePersistedBrowserHistoryState", () => {
128 it("drops malformed state and invalid entries", () => {
129 expect(migratePersistedBrowserHistoryState(null)).toEqual({ byProjectKey: {} });
130 expect(migratePersistedBrowserHistoryState({ byProjectKey: 42 })).toEqual({ byProjectKey: {} });
131 const migrated = migratePersistedBrowserHistoryState({
132 byProjectKey: {
133 good: [
134 { url: "http://a.test/", lastVisitedAt: 100, title: "A" },
135 { url: "", lastVisitedAt: 100 },
136 { url: "ftp://ghost.test/", lastVisitedAt: 100 },
137 { url: "http://b.test/", lastVisitedAt: Number.NaN },
138 "junk",
139 ],
140 bad: "junk",
141 },
142 });
143 expect(migrated.byProjectKey["good"]).toEqual([
144 { url: "http://a.test/", lastVisitedAt: 100, title: "A" },
145 ]);
146 expect(migrated.byProjectKey["bad"]).toBeUndefined();
147 });
148
149 it("normalizes persisted urls with the same rules as live writes", () => {
150 const migrated = migratePersistedBrowserHistoryState({
151 byProjectKey: {
152 good: [{ url: "a.test/path#section", lastVisitedAt: 100 }],
153 },
154 });
155 expect(migrated.byProjectKey["good"]).toEqual([
156 { url: "https://a.test/path#section", lastVisitedAt: 100 },
157 ]);
158 });
159
160 it("restores MRU ordering, deduplicates normalized urls, and enforces project bounds", () => {
161 const byProjectKey = Object.fromEntries(
162 Array.from({ length: BROWSER_HISTORY_MAX_PROJECTS + 1 }, (_, index) => [
163 `project-${index}`,
164 [{ url: `http://project-${index}.test/`, lastVisitedAt: index }],
165 ]),
166 );
167 byProjectKey["project-1"] = [
168 { url: "a.test/", lastVisitedAt: 1 },
169 { url: "http://newer.test/", lastVisitedAt: 3 },
170 { url: "https://a.test/", lastVisitedAt: 2 },
171 ];
172
173 const migrated = migratePersistedBrowserHistoryState({ byProjectKey });
174
175 expect(Object.keys(migrated.byProjectKey)).toHaveLength(BROWSER_HISTORY_MAX_PROJECTS);
176 expect(migrated.byProjectKey["project-0"]).toBeUndefined();
177 expect(migrated.byProjectKey["project-1"]).toEqual([
178 { url: "http://newer.test/", lastVisitedAt: 3 },
179 { url: "https://a.test/", lastVisitedAt: 2 },
180 ]);
181 });
182
183 it("rejects a lastVisitedAt outside Date's valid range", () => {
184 const migrated = migratePersistedBrowserHistoryState({
185 byProjectKey: {
186 good: [
187 { url: "http://a.test/", lastVisitedAt: 100 },
188 { url: "http://b.test/", lastVisitedAt: 1e20 },
189 ],
190 },
191 });
192 expect(migrated.byProjectKey["good"]).toEqual([{ url: "http://a.test/", lastVisitedAt: 100 }]);
193 });
194
195 it("truncates oversized persisted titles to the contract bound", () => {
196 const oversized = "x".repeat(BROWSER_HISTORY_MAX_TITLE_LENGTH + 100);
197 const migrated = migratePersistedBrowserHistoryState({
198 byProjectKey: {
199 good: [{ url: "http://a.test/", lastVisitedAt: 100, title: oversized }],
200 },
201 });
202 expect(migrated.byProjectKey["good"]?.[0]?.title).toHaveLength(
203 BROWSER_HISTORY_MAX_TITLE_LENGTH,
204 );
205 expect(migrated.byProjectKey["good"]?.[0]?.title).toBe(
206 oversized.slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH),
207 );
208 });
209});
210
211const threadRef = {
212 environmentId: EnvironmentId.make("env-1"),
213 threadId: ThreadId.make("thread-1"),
214};
215
216describe("useBrowserHistoryStore", () => {
217 beforeEach(() => {
218 resetBrowserHistoryForTests();
219 });
220
221 it("records visits for registered threads under the project key", () => {
222 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
223 recordVisitForThread(threadRef, "myapp.test/admin#section", 1234);
224 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]).toEqual([
225 { url: "https://myapp.test/admin#section", lastVisitedAt: 1234 },
226 ]);
227 });
228
229 it("does not persist when a thread is already registered to the same project", () => {
230 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
231 const persist = spyOnPersistWrites();
232
233 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
234
235 expect(persist).not.toHaveBeenCalled();
236 });
237
238 it("ignores invalid urls whether queued pending or recorded post-registration", () => {
239 recordVisitForThread(threadRef, "ftp://a.test/", 1);
240 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
241 recordVisitForThread(threadRef, "ftp://a.test/", 2);
242 expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({});
243 });
244
245 it("sets titles update-only via the thread helper", () => {
246 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
247 setTitleForThreadUrl(threadRef, "http://a.test/", "Should not create");
248 expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({});
249 recordVisitForThread(threadRef, "http://a.test/#/settings", 1);
250 setTitleForThreadUrl(threadRef, "http://a.test/#/settings", "My App");
251 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("My App");
252 });
253
254 it("does not persist when the title is already set", () => {
255 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
256 recordVisitForThread(threadRef, "http://a.test/", 1);
257 setTitleForThreadUrl(threadRef, "http://a.test/", "My App");
258 const persist = spyOnPersistWrites();
259 const byProjectKey = useBrowserHistoryStore.getState().byProjectKey;
260
261 setTitleForThreadUrl(threadRef, "http://a.test/", "My App");
262
263 expect(useBrowserHistoryStore.getState().byProjectKey).toBe(byProjectKey);
264 expect(persist).not.toHaveBeenCalled();
265 });
266
267 it("sets a title against a settled url that differs from the stored one only by a trailing slash", () => {
268 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
269 recordVisitForThread(threadRef, "http://a.test/community", 1);
270 setTitleForThreadUrl(threadRef, "http://a.test/community/", "Community");
271 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]).toMatchObject({
272 url: "http://a.test/community",
273 title: "Community",
274 });
275
276 useBrowserHistoryStore.setState({ byProjectKey: {} });
277 recordVisitForThread(threadRef, "http://a.test/community/", 1);
278 setTitleForThreadUrl(threadRef, "http://a.test/community", "Community");
279 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]).toMatchObject({
280 url: "http://a.test/community/",
281 title: "Community",
282 });
283 });
284
285 it("matches a requested localhost URL to the resolved environment host", () => {
286 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
287 recordVisitForThread(threadRef, "http://localhost:5173/app", 1);
288 setTitleForThreadUrl(threadRef, "http://192.168.64.2:5173/app", "Local App", "192.168.64.2");
289 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("Local App");
290 });
291
292 it("deduplicates loopback aliases and the resolved environment host", () => {
293 readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.64.2:3773" });
294 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
295 recordVisitForThread(threadRef, "http://localhost:5173/app", 1);
296 recordVisitForThread(threadRef, "http://127.0.0.1:5173/app", 2);
297 recordVisitForThread(threadRef, "http://192.168.64.2:5173/app", 3);
298 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]).toEqual([
299 { url: "http://localhost:5173/app", lastVisitedAt: 3 },
300 ]);
301
302 useBrowserHistoryStore.setState({ byProjectKey: {} });
303 recordVisitForThread(threadRef, "http://192.168.64.2:5173/app", 4);
304 recordVisitForThread(threadRef, "http://localhost:5173/app", 5);
305 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]).toEqual([
306 { url: "http://localhost:5173/app", lastVisitedAt: 5 },
307 ]);
308 });
309
310 it("does not match a genuinely different path via the trailing-slash comparison", () => {
311 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
312 recordVisitForThread(threadRef, "http://a.test/community", 1);
313 setTitleForThreadUrl(threadRef, "http://a.test/community/foo", "Foo");
314 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBeUndefined();
315 });
316
317 it("updates only the most recent entry when several share a trailing-slash comparison key", () => {
318 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
319 recordVisitForThread(threadRef, "http://a.test/community/", 1);
320 recordVisitForThread(threadRef, "http://a.test/community", 2);
321 setTitleForThreadUrl(threadRef, "http://a.test/community/", "Community");
322 const entries = useBrowserHistoryStore.getState().byProjectKey["proj-a"];
323 expect(entries?.[0]).toMatchObject({ url: "http://a.test/community", title: "Community" });
324 expect(entries?.[1]).toMatchObject({ url: "http://a.test/community/" });
325 expect(entries?.[1]?.title).toBeUndefined();
326 });
327
328 it("truncates oversized titles to the contract bound", () => {
329 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
330 recordVisitForThread(threadRef, "http://a.test/", 1);
331 const oversized = "y".repeat(BROWSER_HISTORY_MAX_TITLE_LENGTH + 50);
332 setTitleForThreadUrl(threadRef, "http://a.test/", oversized);
333 const title = useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title;
334 expect(title).toHaveLength(BROWSER_HISTORY_MAX_TITLE_LENGTH);
335 expect(title).toBe(oversized.slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH));
336 });
337
338 it("removes entries", () => {
339 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
340 recordVisitForThread(threadRef, "http://a.test/", 1);
341 recordVisitForThread(threadRef, "http://b.test/", 2);
342 removeUrlForThread(threadRef, "http://a.test/");
343 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.map((e) => e.url)).toEqual([
344 "http://b.test/",
345 ]);
346 });
347});
348
349describe("pendingVisitsByThreadKey", () => {
350 beforeEach(() => {
351 resetBrowserHistoryForTests();
352 });
353
354 it("queues a visit recorded before registration and drains it in order on registration", () => {
355 recordVisitForThread(threadRef, "http://a.test/", 1);
356 recordVisitForThread(threadRef, "http://b.test/", 2);
357 expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({});
358 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
359 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.map((e) => e.url)).toEqual([
360 "http://b.test/",
361 "http://a.test/",
362 ]);
363 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.lastVisitedAt).toBe(2);
364 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[1]?.lastVisitedAt).toBe(1);
365 expect(useBrowserHistoryStore.getState().pendingVisitsByThreadKey).toEqual({});
366 });
367
368 it("caps the per-thread pending list at 10, dropping the oldest", () => {
369 for (let i = 0; i < 12; i++) {
370 recordVisitForThread(threadRef, `http://a.test/${i}`, i);
371 }
372 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
373 const urls = useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.map((e) => e.url);
374 expect(urls).toHaveLength(10);
375 expect(urls).not.toContain("http://a.test/0");
376 expect(urls).not.toContain("http://a.test/1");
377 expect(urls?.[0]).toBe("http://a.test/11");
378 });
379
380 it("slots a replayed visit by timestamp instead of hoisting it above a newer live visit", () => {
381 const otherThreadRef = {
382 environmentId: EnvironmentId.make("env-1"),
383 threadId: ThreadId.make("thread-2"),
384 };
385 useBrowserHistoryStore.getState().registerThreadProject(otherThreadRef, "proj-a");
386 recordVisitForThread(otherThreadRef, "http://newer.test/", 2000);
387 recordVisitForThread(threadRef, "http://older.test/", 1000);
388 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
389
390 const entries = useBrowserHistoryStore.getState().byProjectKey["proj-a"];
391 expect(entries?.map((e) => e.url)).toEqual(["http://newer.test/", "http://older.test/"]);
392 // `entries[0]` being the most recent is the invariant `evictExcessProjects` relies on.
393 expect(entries?.[0]?.lastVisitedAt).toBe(2000);
394 });
395});
396
397describe("pendingTitlesByThreadKey", () => {
398 beforeEach(() => {
399 resetBrowserHistoryForTests();
400 });
401
402 it("buffers a title set before registration and applies it once the matching visit drains", () => {
403 recordVisitForThread(threadRef, "http://a.test/", 1);
404 setTitleForThreadUrl(threadRef, "http://a.test/", "My App");
405 expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({});
406
407 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
408
409 const entries = useBrowserHistoryStore.getState().byProjectKey["proj-a"];
410 expect(entries?.[0]).toMatchObject({ url: "http://a.test/", title: "My App" });
411 expect(useBrowserHistoryStore.getState().pendingTitlesByThreadKey).toEqual({});
412 });
413
414 it("preserves environment host matching while a title is pending", () => {
415 recordVisitForThread(threadRef, "http://localhost:5173/app", 1);
416 setTitleForThreadUrl(threadRef, "http://192.168.64.2:5173/app", "Local App", "192.168.64.2");
417 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
418 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("Local App");
419 });
420});
421
422describe("mergeBrowserHistoryState", () => {
423 it("sanitizes same-version corrupt persisted data and preserves actions", () => {
424 // `migrate` only runs when versions differ; `merge` runs on every rehydrate.
425 const current = useBrowserHistoryStore.getState();
426 const merged = mergeBrowserHistoryState(
427 {
428 byProjectKey: {
429 a: [{ url: "ftp://bad.test/", lastVisitedAt: 1 }],
430 b: [{ url: "http://ok.test/", lastVisitedAt: 5 }],
431 },
432 projectKeyByThreadKey: { good: "b", stale: "a", malformed: 42 },
433 },
434 current,
435 );
436 expect(merged.byProjectKey).toEqual({
437 b: [{ url: "http://ok.test/", lastVisitedAt: 5 }],
438 });
439 expect(typeof merged.recordVisit).toBe("function");
440 expect(merged.projectKeyByThreadKey).toEqual({ good: "b" });
441 expect(merged.pendingVisitsByThreadKey).toEqual({});
442 expect(merged.pendingTitlesByThreadKey).toEqual({});
443 });
444});