import { it } from "@effect/vitest"; import { type PreviewEvent, ThreadId } from "@t3tools/contracts"; import { PreviewUrlNormalizationError } from "@t3tools/shared/preview"; import { Effect, PubSub } from "effect"; import { expect } from "vite-plus/test"; import * as PreviewManager from "./Manager.ts"; const DRAIN_LIMIT = 100; interface EventCollector { /** Drain everything published since the last call (or since subscribe). */ readonly drain: Effect.Effect>; } /** * Each `it.effect` shares the live PreviewManager layer across the whole * `it.layer` block, so tests that assert per-thread counts must use a unique * thread id to avoid bleeding state from earlier tests. */ let nextThreadId = 0; const freshThreadId = () => ThreadId.make(`thread-${++nextThreadId}`); /** * Subscribe to the manager's event stream BEFORE the test publishes. We * use `subscribeEvents` (synchronous PubSub.subscribe under the hood) so * no event can land between subscribe and the consumer drain. */ const collectEvents = Effect.gen(function* () { const manager = yield* PreviewManager.PreviewManager; const subscription = yield* manager.subscribeEvents; const collector: EventCollector = { drain: PubSub.takeUpTo(subscription, DRAIN_LIMIT), }; return collector; }).pipe(Effect.withSpan("preview.test.collectEvents")); it.layer(PreviewManager.layer)("PreviewManager", (it) => { it.effect("opens a session and emits opened with normalized URL", () => Effect.gen(function* () { const threadId = freshThreadId(); const manager = yield* PreviewManager.PreviewManager; const collector = yield* collectEvents; const snapshot = yield* manager.open({ threadId, url: "localhost:5173" }); expect(snapshot.tabId.startsWith("tab_")).toBe(true); expect(snapshot.navStatus._tag).toBe("Loading"); if (snapshot.navStatus._tag === "Loading") { expect(snapshot.navStatus.url).toBe("http://localhost:5173/"); } const events = yield* collector.drain; expect(events).toHaveLength(1); expect(events[0]?.type).toBe("opened"); if (events[0]?.type === "opened") { expect(events[0].tabId).toBe(snapshot.tabId); } }), ); it.effect("keeps the tab's profile across navigation and status reports", () => Effect.gen(function* () { const threadId = freshThreadId(); const manager = yield* PreviewManager.PreviewManager; const opened = yield* manager.open({ threadId, profileId: "work" }); expect(opened.profileId).toBe("work"); // `navigate` and `reportStatus` rebuild the snapshot field by field // rather than spreading it, so a new field is dropped unless carried // explicitly — which would silently move the tab to another profile's // partition on its first navigation. const navigated = yield* manager.navigate({ threadId, tabId: opened.tabId, url: "localhost:5173", }); expect(navigated.profileId).toBe("work"); yield* manager.reportStatus({ threadId, tabId: opened.tabId, navStatus: { _tag: "Success", url: "http://localhost:5173/", title: "Dev" }, canGoBack: true, canGoForward: false, }); const listed = yield* manager.list({ threadId }); expect(listed.sessions.find((s) => s.tabId === opened.tabId)?.profileId).toBe("work"); }), ); it.effect("opens an Idle tab when no URL is supplied", () => Effect.gen(function* () { const threadId = freshThreadId(); const manager = yield* PreviewManager.PreviewManager; const snapshot = yield* manager.open({ threadId }); expect(snapshot.navStatus._tag).toBe("Idle"); }), ); it.effect("orders list snapshots and events with one monotonic revision", () => Effect.gen(function* () { const threadId = freshThreadId(); const manager = yield* PreviewManager.PreviewManager; const collector = yield* collectEvents; const before = yield* manager.list({ threadId }); const opened = yield* manager.open({ threadId, url: "http://localhost:5173" }); yield* manager.navigate({ threadId, tabId: opened.tabId, url: "http://localhost:5173/ready", }); const events = yield* collector.drain; const listed = yield* manager.list({ threadId }); expect(events).toHaveLength(2); expect(events[0]!.serverEpoch).toBe(listed.serverEpoch); expect(events[1]!.serverEpoch).toBe(listed.serverEpoch); expect(events[0]!.revision).toBeGreaterThan(before.revision); expect(events[1]!.revision).toBeGreaterThan(events[0]!.revision); expect(listed.revision).toBe(events[1]!.revision); expect(listed.sessions).toHaveLength(1); }), ); it.effect("treats bare hosts as https", () => Effect.gen(function* () { const threadId = freshThreadId(); const manager = yield* PreviewManager.PreviewManager; const snapshot = yield* manager.open({ threadId, url: "example.com" }); if (snapshot.navStatus._tag === "Loading") { expect(snapshot.navStatus.url).toBe("https://example.com/"); } }), ); it.effect("rejects empty URL with PreviewInvalidUrlError", () => Effect.gen(function* () { const threadId = freshThreadId(); const manager = yield* PreviewManager.PreviewManager; const error = yield* Effect.flip(manager.open({ threadId, url: " " })); expect(error._tag).toBe("PreviewInvalidUrlError"); expect(error).toMatchObject({ inputLength: 3, reason: "empty" }); expect(error).not.toHaveProperty("rawUrl"); expect(error.cause).toBeInstanceOf(PreviewUrlNormalizationError); expect((error.cause as PreviewUrlNormalizationError).reason).toBe("empty"); }), ); it.effect("preserves URL parser failures as the invalid URL cause chain", () => Effect.gen(function* () { const threadId = freshThreadId(); const manager = yield* PreviewManager.PreviewManager; const rawUrl = "https://user:password@example.com:bad/path?access_token=secret#fragment"; const error = yield* Effect.flip(manager.open({ threadId, url: rawUrl })); expect(error).toMatchObject({ inputLength: rawUrl.length, reason: "parse", protocol: "https:", }); expect(error).not.toHaveProperty("rawUrl"); expect(error.cause).toBeInstanceOf(PreviewUrlNormalizationError); const normalizationError = error.cause as PreviewUrlNormalizationError; expect(normalizationError.cause).toBeInstanceOf(Error); expect(error.message).not.toContain((normalizationError.cause as Error).message); expect(error.message).not.toMatch(/user|password|access_token|secret|fragment/); }), ); it.effect("navigate updates snapshot and emits navigated", () => Effect.gen(function* () { const threadId = freshThreadId(); const manager = yield* PreviewManager.PreviewManager; const collector = yield* collectEvents; const opened = yield* manager.open({ threadId, url: "http://localhost:5173" }); const snapshot = yield* manager.navigate({ threadId, tabId: opened.tabId, url: "http://localhost:5173/about", resolvedTitle: "About", }); expect(snapshot.navStatus._tag).toBe("Success"); if (snapshot.navStatus._tag === "Success") { expect(snapshot.navStatus.url).toBe("http://localhost:5173/about"); expect(snapshot.navStatus.title).toBe("About"); } const events = yield* collector.drain; expect(events.map((e) => e.type)).toEqual(["opened", "navigated"]); }), ); it.effect("navigate fails for unknown tab", () => Effect.gen(function* () { const threadId = freshThreadId(); const manager = yield* PreviewManager.PreviewManager; const error = yield* Effect.flip( manager.navigate({ threadId, tabId: "tab_missing", url: "http://localhost:5173", }), ); expect(error._tag).toBe("PreviewSessionLookupError"); }), ); it.effect("resizes a tab and preserves its viewport across navigation reports", () => Effect.gen(function* () { const threadId = freshThreadId(); const manager = yield* PreviewManager.PreviewManager; const collector = yield* collectEvents; const opened = yield* manager.open({ threadId, url: "http://localhost:5173" }); const resized = yield* manager.resize({ threadId, tabId: opened.tabId, viewport: { _tag: "freeform", width: 1024, height: 768 }, }); expect(resized.viewport).toEqual({ _tag: "freeform", width: 1024, height: 768 }); const navigated = yield* manager.navigate({ threadId, tabId: opened.tabId, url: "http://localhost:5173/resized", }); expect(navigated.viewport).toEqual(resized.viewport); yield* manager.reportStatus({ threadId, tabId: opened.tabId, navStatus: { _tag: "Success", url: "http://localhost:5173/resized", title: "Resized" }, canGoBack: true, canGoForward: false, }); const listed = yield* manager.list({ threadId }); expect(listed.sessions[0]?.viewport).toEqual(resized.viewport); const events = yield* collector.drain; expect(events.map((event) => event.type)).toEqual([ "opened", "resized", "navigated", "navigated", ]); }), ); it.effect("rejects resize for an unknown tab", () => Effect.gen(function* () { const manager = yield* PreviewManager.PreviewManager; const error = yield* Effect.flip( manager.resize({ threadId: freshThreadId(), tabId: "tab_missing", viewport: { _tag: "fill" }, }), ); expect(error._tag).toBe("PreviewSessionLookupError"); }), ); it.effect("reportStatus emits failed for LoadFailed nav", () => Effect.gen(function* () { const threadId = freshThreadId(); const manager = yield* PreviewManager.PreviewManager; const collector = yield* collectEvents; const opened = yield* manager.open({ threadId, url: "http://localhost:5173" }); yield* manager.reportStatus({ threadId, tabId: opened.tabId, navStatus: { _tag: "LoadFailed", url: "http://localhost:5173", title: "", code: -105, description: "ERR_NAME_NOT_RESOLVED", }, canGoBack: false, canGoForward: false, }); const events = yield* collector.drain; const failed = events.find((e) => e.type === "failed"); expect(failed?.type).toBe("failed"); if (failed?.type === "failed") { expect(failed.code).toBe(-105); expect(failed.description).toBe("ERR_NAME_NOT_RESOLVED"); } }), ); it.effect("close removes the session and emits closed", () => Effect.gen(function* () { const threadId = freshThreadId(); const manager = yield* PreviewManager.PreviewManager; const collector = yield* collectEvents; yield* manager.open({ threadId, url: "http://localhost:5173" }); yield* manager.close({ threadId }); const result = yield* manager.list({ threadId }); expect(result.sessions).toHaveLength(0); const events = yield* collector.drain; const closed = events.find((e) => e.type === "closed"); expect(closed?.type).toBe("closed"); }), ); it.effect("gives every tab in a batch close its own monotonic revision", () => Effect.gen(function* () { const threadId = freshThreadId(); const manager = yield* PreviewManager.PreviewManager; yield* manager.open({ threadId, url: "http://localhost:5173" }); yield* manager.open({ threadId, url: "http://localhost:3000" }); const collector = yield* collectEvents; yield* manager.close({ threadId }); const events = yield* collector.drain; const listed = yield* manager.list({ threadId }); expect(events).toHaveLength(2); expect(events.every((event) => event.type === "closed")).toBe(true); expect(events[1]!.revision).toBeGreaterThan(events[0]!.revision); expect(listed.revision).toBe(events[1]!.revision); expect(listed.sessions).toHaveLength(0); }), ); it.effect("close is idempotent for unknown threads", () => Effect.gen(function* () { const threadId = freshThreadId(); const manager = yield* PreviewManager.PreviewManager; yield* manager.close({ threadId }); const result = yield* manager.list({ threadId }); expect(result.sessions).toHaveLength(0); }), ); it.effect("list returns every snapshot for the thread sorted by updatedAt", () => Effect.gen(function* () { const threadId = freshThreadId(); const manager = yield* PreviewManager.PreviewManager; const first = yield* manager.open({ threadId, url: "http://localhost:5173" }); const second = yield* manager.open({ threadId, url: "http://localhost:3000" }); const result = yield* manager.list({ threadId }); expect(result.sessions).toHaveLength(2); const ids = result.sessions.map((s) => s.tabId); expect(ids).toContain(first.tabId); expect(ids).toContain(second.tabId); }), ); it.effect("open creates an independent tab on every call", () => Effect.gen(function* () { const threadId = freshThreadId(); const manager = yield* PreviewManager.PreviewManager; const collector = yield* collectEvents; const a = yield* manager.open({ threadId, url: "http://localhost:5173" }); const b = yield* manager.open({ threadId, url: "http://localhost:3000/path" }); expect(a.tabId).not.toBe(b.tabId); const list = yield* manager.list({ threadId }); expect(list.sessions).toHaveLength(2); const events = yield* collector.drain; expect(events.map((e) => e.type)).toEqual(["opened", "opened"]); }), ); it.effect("close with mismatching tabId is a no-op", () => Effect.gen(function* () { const threadId = freshThreadId(); const manager = yield* PreviewManager.PreviewManager; yield* manager.open({ threadId, url: "http://localhost:5173" }); yield* manager.close({ threadId, tabId: "tab_missing" }); const list = yield* manager.list({ threadId }); expect(list.sessions).toHaveLength(1); }), ); it.effect("close with explicit tabId removes only that tab", () => Effect.gen(function* () { const threadId = freshThreadId(); const manager = yield* PreviewManager.PreviewManager; const a = yield* manager.open({ threadId, url: "http://localhost:5173" }); const b = yield* manager.open({ threadId, url: "http://localhost:3000" }); yield* manager.close({ threadId, tabId: a.tabId }); const list = yield* manager.list({ threadId }); expect(list.sessions.map((s) => s.tabId)).toEqual([b.tabId]); }), ); it.effect("multiple subscribers receive every event independently", () => Effect.gen(function* () { const threadId = freshThreadId(); const manager = yield* PreviewManager.PreviewManager; const aSub = yield* manager.subscribeEvents; const bSub = yield* manager.subscribeEvents; yield* manager.open({ threadId, url: "http://localhost:5173" }); yield* manager.open({ threadId, url: "http://localhost:3000" }); const aEvents = yield* PubSub.takeUpTo(aSub, DRAIN_LIMIT); const bEvents = yield* PubSub.takeUpTo(bSub, DRAIN_LIMIT); expect(aEvents.map((e) => e.type)).toEqual(["opened", "opened"]); expect(bEvents.map((e) => e.type)).toEqual(["opened", "opened"]); }), ); });