1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3import { scopeProjectRef } from "@t3tools/client-runtime/environment";
4import {
5 type BrowserProfileId,
6 EnvironmentId,
7 INCOGNITO_BROWSER_PROFILE_ID,
8 type PreviewSessionSnapshot,
9 ProjectId,
10 ThreadId,
11} from "@t3tools/contracts";
12
13const { readPreparedConnection, entities, settingsHydration } = vi.hoisted(() => ({
14 readPreparedConnection: vi.fn<() => { httpBaseUrl: string } | null>(() => null),
15 entities: {
16 projects: [] as Array<Record<string, unknown>>,
17 shellProjectId: null as string | null,
18 },
19 settingsHydration: {
20 hydrated: true,
21 finish: () => {},
22 promise: Promise.resolve(),
23 },
24}));
25
26vi.mock("~/state/session", () => ({ readPreparedConnection }));
27vi.mock("~/state/entities", () => ({
28 readThreadShell: () => (entities.shellProjectId ? { projectId: entities.shellProjectId } : null),
29 readProject: (ref: { environmentId: string; projectId: string }) =>
30 entities.projects.find(
31 (project) => project.environmentId === ref.environmentId && project.id === ref.projectId,
32 ) ?? null,
33 readProjects: () => entities.projects,
34 useProjects: () => entities.projects,
35 useQuietThreadShell: () => null,
36}));
37vi.mock("./hooks/useSettings", async () => {
38 const { DEFAULT_CLIENT_SETTINGS } = await import("@t3tools/contracts/settings");
39 return {
40 getClientSettings: () => DEFAULT_CLIENT_SETTINGS,
41 useClientSettings: () => DEFAULT_CLIENT_SETTINGS,
42 isClientSettingsHydrated: () => settingsHydration.hydrated,
43 ensureClientSettingsHydrated: () => settingsHydration.promise,
44 };
45});
46vi.mock("~/state/environments", () => ({
47 readPrimaryEnvironmentId: () => null,
48 usePrimaryEnvironmentId: () => null,
49}));
50
51import { DraftId, useComposerDraftStore } from "./composerDraftStore";
52import {
53 BROWSER_HISTORY_MAX_PROJECTS,
54 type BrowserHistoryEntry,
55 mergeBrowserHistoryState,
56 migratePersistedBrowserHistoryState,
57 normalizeHistoryUrl,
58 recordVisitForThread,
59 resetBrowserHistoryForTests,
60 startPreviewTitleRecording,
61 upsertHistoryEntry,
62 useBrowserHistoryStore,
63} from "./browserHistoryStore";
64import { applyPreviewServerSnapshot, resetPreviewStateForTests } from "./previewStateStore";
65
66function entry(overrides: Partial<BrowserHistoryEntry> = {}): BrowserHistoryEntry {
67 return { url: "http://localhost:3000/", lastVisitedAt: 1000, ...overrides };
68}
69
70beforeEach(() => {
71 readPreparedConnection.mockReturnValue(null);
72 entities.projects = [];
73 entities.shellProjectId = null;
74 settingsHydration.hydrated = true;
75 settingsHydration.promise = Promise.resolve();
76 useComposerDraftStore.setState({ draftThreadsByThreadKey: {} });
77});
78afterEach(() => vi.restoreAllMocks());
79
80describe("normalizeHistoryUrl", () => {
81 it("normalizes bare loopback hosts to http and keeps path/query", () => {
82 expect(normalizeHistoryUrl("localhost:3000/admin?tab=1")).toBe(
83 "http://localhost:3000/admin?tab=1",
84 );
85 });
86
87 it("preserves hash routes and strips credentials", () => {
88 expect(normalizeHistoryUrl("http://localhost:3000/app#/route")).toBe(
89 "http://localhost:3000/app#/route",
90 );
91 expect(normalizeHistoryUrl("https://user:secret@example.com/")).toBe("https://example.com/");
92 });
93
94 it("rejects non-http(s), unparseable, and oversized urls", () => {
95 expect(normalizeHistoryUrl("ftp://example.com")).toBeNull();
96 expect(normalizeHistoryUrl("")).toBeNull();
97 expect(normalizeHistoryUrl(`http://localhost/${"a".repeat(2048)}`)).toBeNull();
98 });
99});
100
101describe("upsertHistoryEntry", () => {
102 it("with insertOrdered, replaying an older visit for an existing entry keeps its newer timestamp", () => {
103 const existing = [entry({ url: "http://a.test/", lastVisitedAt: 2000 })];
104 const next = upsertHistoryEntry(existing, "http://a.test/", 1000, { insertOrdered: true });
105 expect(next).toEqual([{ url: "http://a.test/", lastVisitedAt: 2000 }]);
106 });
107});
108
109describe("migratePersistedBrowserHistoryState", () => {
110 it("drops malformed state and invalid entries", () => {
111 expect(migratePersistedBrowserHistoryState(null)).toEqual({ byProjectKey: {} });
112 expect(migratePersistedBrowserHistoryState({ byProjectKey: 42 })).toEqual({ byProjectKey: {} });
113 const migrated = migratePersistedBrowserHistoryState({
114 byProjectKey: {
115 good: [
116 { url: "http://a.test/", lastVisitedAt: 100, title: "A" },
117 { url: "", lastVisitedAt: 100 },
118 { url: "ftp://ghost.test/", lastVisitedAt: 100 },
119 { url: "http://b.test/", lastVisitedAt: Number.NaN },
120 "junk",
121 ],
122 bad: "junk",
123 },
124 });
125 expect(migrated.byProjectKey["good"]).toEqual([
126 { url: "http://a.test/", lastVisitedAt: 100, title: "A" },
127 ]);
128 expect(migrated.byProjectKey["bad"]).toBeUndefined();
129 });
130
131 it("restores MRU ordering, deduplicates normalized urls, and enforces project bounds", () => {
132 const byProjectKey = Object.fromEntries(
133 Array.from({ length: BROWSER_HISTORY_MAX_PROJECTS + 1 }, (_, index) => [
134 `project-${index}`,
135 [{ url: `http://project-${index}.test/`, lastVisitedAt: index }],
136 ]),
137 );
138 byProjectKey["project-1"] = [
139 { url: "a.test/", lastVisitedAt: 1 },
140 { url: "http://newer.test/", lastVisitedAt: 3 },
141 { url: "https://a.test/", lastVisitedAt: 2 },
142 ];
143
144 const migrated = migratePersistedBrowserHistoryState({ byProjectKey });
145
146 expect(Object.keys(migrated.byProjectKey)).toHaveLength(BROWSER_HISTORY_MAX_PROJECTS);
147 expect(migrated.byProjectKey["project-0"]).toBeUndefined();
148 expect(migrated.byProjectKey["project-1"]).toEqual([
149 { url: "http://newer.test/", lastVisitedAt: 3 },
150 { url: "https://a.test/", lastVisitedAt: 2 },
151 ]);
152 });
153});
154
155const threadRef = {
156 environmentId: EnvironmentId.make("env-1"),
157 threadId: ThreadId.make("thread-1"),
158};
159
160let settledAt = 0;
161
162function settle(
163 url: string,
164 title: string,
165 tabId = "tab_a",
166 ref = threadRef,
167 profileId?: BrowserProfileId,
168): void {
169 settledAt += 1;
170 const snapshot: PreviewSessionSnapshot = {
171 threadId: ref.threadId,
172 tabId,
173 navStatus: { _tag: "Success", url, title },
174 canGoBack: false,
175 canGoForward: false,
176 updatedAt: new Date(settledAt).toISOString(),
177 ...(profileId ? { profileId } : {}),
178 };
179 applyPreviewServerSnapshot(ref, snapshot);
180}
181
182function resetStores(): () => void {
183 resetBrowserHistoryForTests();
184 resetPreviewStateForTests();
185 return startPreviewTitleRecording();
186}
187
188describe("useBrowserHistoryStore", () => {
189 beforeEach(resetStores);
190
191 it("titles only pages already in history when they settle", () => {
192 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
193 settle("http://a.test/", "Should not create");
194 expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({});
195 recordVisitForThread(threadRef, "http://a.test/#/settings", 1);
196 settle("http://a.test/#/settings", "My App");
197 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("My App");
198 });
199
200 it("titles a visit recorded after its page already settled", () => {
201 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
202 recordVisitForThread(threadRef, "http://other.test/", 1);
203 settle("http://a.test/", "My App");
204 recordVisitForThread(threadRef, "http://a.test/", 2);
205 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]).toMatchObject({
206 url: "http://a.test/",
207 title: "My App",
208 });
209 });
210
211 it("keeps the newest title when another tab shows the same url", () => {
212 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
213 recordVisitForThread(threadRef, "http://a.test/", 1);
214 settle("http://a.test/", "Old", "tab_a");
215 settle("http://a.test/", "Old", "tab_b");
216 settle("http://a.test/", "New", "tab_a");
217 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("New");
218 });
219
220 it("never titles history from an Incognito page, whichever settles first", () => {
221 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
222 settle("http://a.test/", "Private A", "tab_incognito", threadRef, INCOGNITO_BROWSER_PROFILE_ID);
223 recordVisitForThread(threadRef, "http://a.test/", 1);
224 recordVisitForThread(threadRef, "http://b.test/", 2);
225 settle("http://b.test/", "Private B", "tab_incognito", threadRef, INCOGNITO_BROWSER_PROFILE_ID);
226 expect(
227 useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.map((entry) => entry.title),
228 ).toEqual([undefined, undefined]);
229 });
230
231 it("keeps another thread's newer title when an unrelated tab settles", () => {
232 const otherThreadRef = { ...threadRef, threadId: ThreadId.make("thread-2") };
233 const store = useBrowserHistoryStore.getState();
234 store.registerThreadProject(threadRef, "proj-a");
235 store.registerThreadProject(otherThreadRef, "proj-a");
236 recordVisitForThread(threadRef, "http://a.test/", 1);
237 settle("http://a.test/", "Old", "tab_a", threadRef);
238 settle("http://a.test/", "New", "tab_a", otherThreadRef);
239 settle("http://b.test/", "Unrelated", "tab_b", threadRef);
240 expect(
241 useBrowserHistoryStore
242 .getState()
243 .byProjectKey["proj-a"]?.find((e) => e.url === "http://a.test/")?.title,
244 ).toBe("New");
245 });
246
247 it("keeps another thread's newer title when this thread records a different visit", () => {
248 const otherThreadRef = { ...threadRef, threadId: ThreadId.make("thread-2") };
249 const store = useBrowserHistoryStore.getState();
250 store.registerThreadProject(threadRef, "proj-a");
251 store.registerThreadProject(otherThreadRef, "proj-a");
252 recordVisitForThread(threadRef, "http://a.test/", 1);
253 settle("http://a.test/", "Old", "tab_a", threadRef);
254 settle("http://a.test/", "New", "tab_a", otherThreadRef);
255 recordVisitForThread(threadRef, "http://b.test/", 2);
256 expect(
257 useBrowserHistoryStore
258 .getState()
259 .byProjectKey["proj-a"]?.find((e) => e.url === "http://a.test/")?.title,
260 ).toBe("New");
261 });
262
263 it("keeps another thread's newer title when this thread's unchanged tab is re-sent", () => {
264 const otherThreadRef = { ...threadRef, threadId: ThreadId.make("thread-2") };
265 const store = useBrowserHistoryStore.getState();
266 store.registerThreadProject(threadRef, "proj-a");
267 store.registerThreadProject(otherThreadRef, "proj-a");
268 recordVisitForThread(threadRef, "http://a.test/", 1);
269 settle("http://a.test/", "Old", "tab_a", threadRef);
270 settle("http://a.test/", "New", "tab_a", otherThreadRef);
271 // A resize or a list reconcile replaces the session without a new page load.
272 settle("http://a.test/", "Old", "tab_a", threadRef);
273 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("New");
274 });
275
276 it("keeps another thread's newer title when this thread revisits the same url", () => {
277 const otherThreadRef = { ...threadRef, threadId: ThreadId.make("thread-2") };
278 const store = useBrowserHistoryStore.getState();
279 store.registerThreadProject(threadRef, "proj-a");
280 store.registerThreadProject(otherThreadRef, "proj-a");
281 recordVisitForThread(threadRef, "http://a.test/", 1);
282 settle("http://a.test/", "Old", "tab_a", threadRef);
283 settle("http://a.test/", "New", "tab_a", otherThreadRef);
284 recordVisitForThread(threadRef, "http://a.test/", 2);
285 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("New");
286 });
287
288 it("sets a title against a settled url that differs from the stored one only by a trailing slash", () => {
289 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
290 recordVisitForThread(threadRef, "http://a.test/community", 1);
291 settle("http://a.test/community/", "Community");
292 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]).toMatchObject({
293 url: "http://a.test/community",
294 title: "Community",
295 });
296
297 useBrowserHistoryStore.setState({ byProjectKey: {} });
298 recordVisitForThread(threadRef, "http://a.test/community/", 1);
299 settle("http://a.test/community", "Community");
300 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]).toMatchObject({
301 url: "http://a.test/community/",
302 title: "Community",
303 });
304 });
305
306 it("matches a requested localhost URL to the resolved environment host", () => {
307 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
308 recordVisitForThread(threadRef, "http://localhost:5173/app", 1);
309 readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.64.2:3773" });
310 settle("http://192.168.64.2:5173/app", "Local App");
311 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("Local App");
312 });
313
314 it("deduplicates loopback aliases and the resolved environment host", () => {
315 readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.64.2:3773" });
316 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
317 recordVisitForThread(threadRef, "http://localhost:5173/app", 1);
318 recordVisitForThread(threadRef, "http://127.0.0.1:5173/app", 2);
319 recordVisitForThread(threadRef, "http://192.168.64.2:5173/app", 3);
320 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]).toEqual([
321 { url: "http://localhost:5173/app", lastVisitedAt: 3 },
322 ]);
323
324 useBrowserHistoryStore.setState({ byProjectKey: {} });
325 recordVisitForThread(threadRef, "http://192.168.64.2:5173/app", 4);
326 recordVisitForThread(threadRef, "http://localhost:5173/app", 5);
327 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]).toEqual([
328 { url: "http://localhost:5173/app", lastVisitedAt: 5 },
329 ]);
330 });
331});
332
333const repoProject = {
334 environmentId: EnvironmentId.make("env-1"),
335 id: ProjectId.make("project-1"),
336 workspaceRoot: "/repo",
337 repositoryIdentity: null,
338 isScratch: false,
339};
340
341describe("thread project resolution", () => {
342 beforeEach(resetStores);
343
344 it("files a browser tab's visits and titles under the thread's project with no view registering it", () => {
345 entities.projects = [repoProject];
346 entities.shellProjectId = "project-1";
347
348 recordVisitForThread(threadRef, "http://localhost:5173/", 1);
349 settle("http://localhost:5173/", "Dev App");
350
351 expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({
352 "env-1:/repo": [{ url: "http://localhost:5173/", lastVisitedAt: 1, title: "Dev App" }],
353 });
354 expect(useBrowserHistoryStore.getState().pendingVisitsByThreadKey).toEqual({});
355 });
356
357 it("files a draft thread's visits under the draft's project", () => {
358 entities.projects = [repoProject];
359 const projectRef = scopeProjectRef(EnvironmentId.make("env-1"), ProjectId.make("project-1"));
360 useComposerDraftStore
361 .getState()
362 .setLogicalProjectDraftThreadId("env-1:/repo", projectRef, DraftId.make("draft-1"), {
363 threadId: threadRef.threadId,
364 });
365
366 recordVisitForThread(threadRef, "http://localhost:5173/", 1);
367
368 expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({
369 "env-1:/repo": [{ url: "http://localhost:5173/", lastVisitedAt: 1 }],
370 });
371 expect(useBrowserHistoryStore.getState().pendingVisitsByThreadKey).toEqual({});
372 });
373
374 it("waits for client settings before filing a thread under its project", async () => {
375 entities.projects = [repoProject];
376 entities.shellProjectId = "project-1";
377 settingsHydration.hydrated = false;
378 settingsHydration.promise = new Promise<void>((resolve) => {
379 settingsHydration.finish = () => {
380 settingsHydration.hydrated = true;
381 resolve();
382 };
383 });
384
385 recordVisitForThread(threadRef, "http://localhost:5173/", 1);
386 expect(useBrowserHistoryStore.getState().projectKeyByThreadKey).toEqual({});
387
388 settingsHydration.finish();
389 await settingsHydration.promise;
390 expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({
391 "env-1:/repo": [{ url: "http://localhost:5173/", lastVisitedAt: 1 }],
392 });
393 expect(useBrowserHistoryStore.getState().pendingVisitsByThreadKey).toEqual({});
394 });
395
396 it("lands visits queued before the thread's project loaded with the next visit", () => {
397 recordVisitForThread(threadRef, "http://a.test/", 1);
398 entities.projects = [repoProject];
399 entities.shellProjectId = "project-1";
400 recordVisitForThread(threadRef, "http://b.test/", 2);
401
402 expect(
403 useBrowserHistoryStore.getState().byProjectKey["env-1:/repo"]?.map((entry) => entry.url),
404 ).toEqual(["http://b.test/", "http://a.test/"]);
405 });
406});
407
408describe("pendingVisitsByThreadKey", () => {
409 beforeEach(resetStores);
410
411 it("queues a visit recorded before registration and drains it in order on registration", () => {
412 recordVisitForThread(threadRef, "http://a.test/", 1);
413 recordVisitForThread(threadRef, "http://b.test/", 2);
414 expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({});
415 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
416 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.map((e) => e.url)).toEqual([
417 "http://b.test/",
418 "http://a.test/",
419 ]);
420 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.lastVisitedAt).toBe(2);
421 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[1]?.lastVisitedAt).toBe(1);
422 expect(useBrowserHistoryStore.getState().pendingVisitsByThreadKey).toEqual({});
423 });
424
425 it("slots a replayed visit by timestamp instead of hoisting it above a newer live visit", () => {
426 const otherThreadRef = {
427 environmentId: EnvironmentId.make("env-1"),
428 threadId: ThreadId.make("thread-2"),
429 };
430 useBrowserHistoryStore.getState().registerThreadProject(otherThreadRef, "proj-a");
431 recordVisitForThread(otherThreadRef, "http://newer.test/", 2000);
432 recordVisitForThread(threadRef, "http://older.test/", 1000);
433 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
434
435 const entries = useBrowserHistoryStore.getState().byProjectKey["proj-a"];
436 expect(entries?.map((e) => e.url)).toEqual(["http://newer.test/", "http://older.test/"]);
437 expect(entries?.[0]?.lastVisitedAt).toBe(2000);
438 });
439});
440
441describe("titles before registration", () => {
442 beforeEach(resetStores);
443
444 it("applies a settled page's title once the matching visit drains", () => {
445 recordVisitForThread(threadRef, "http://a.test/", 1);
446 settle("http://a.test/", "My App");
447 expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({});
448
449 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
450
451 const entries = useBrowserHistoryStore.getState().byProjectKey["proj-a"];
452 expect(entries?.[0]).toMatchObject({ url: "http://a.test/", title: "My App" });
453 });
454
455 it("keeps another thread's title when a queued visit to the same url drains", () => {
456 const otherThreadRef = { ...threadRef, threadId: ThreadId.make("thread-2") };
457 recordVisitForThread(threadRef, "http://a.test/", 1);
458 settle("http://a.test/", "Old", "tab_a", threadRef);
459 useBrowserHistoryStore.getState().registerThreadProject(otherThreadRef, "proj-a");
460 recordVisitForThread(otherThreadRef, "http://a.test/", 2);
461 settle("http://a.test/", "New", "tab_a", otherThreadRef);
462
463 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
464
465 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("New");
466 });
467});
468
469describe("mergeBrowserHistoryState", () => {
470 it("sanitizes same-version corrupt persisted data and preserves actions", () => {
471 const current = useBrowserHistoryStore.getState();
472 const merged = mergeBrowserHistoryState(
473 {
474 byProjectKey: {
475 a: [{ url: "ftp://bad.test/", lastVisitedAt: 1 }],
476 b: [{ url: "http://ok.test/", lastVisitedAt: 5 }],
477 },
478 projectKeyByThreadKey: { good: "b", stale: "a", malformed: 42 },
479 },
480 current,
481 );
482 expect(merged.byProjectKey).toEqual({
483 b: [{ url: "http://ok.test/", lastVisitedAt: 5 }],
484 });
485 expect(typeof merged.recordVisit).toBe("function");
486 expect(merged.projectKeyByThreadKey).toEqual({ good: "b" });
487 expect(merged.pendingVisitsByThreadKey).toEqual({});
488 });
489});