1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { it } from "@effect/vitest";
4import { type PreviewEvent, ThreadId } from "@t3tools/contracts";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as PubSub from "effect/PubSub";
8import { expect } from "vite-plus/test";
9
10import * as ServerConfig from "../config.ts";
11import * as PreviewManager from "./Manager.ts";
12
13const DRAIN_LIMIT = 100;
14
15interface EventCollector {
16 readonly drain: Effect.Effect<ReadonlyArray<PreviewEvent>>;
17}
18
19/** The shared live layer retains state between tests; counts need unique thread ids. */
20let nextThreadId = 0;
21const freshThreadId = () => ThreadId.make(`thread-${++nextThreadId}`);
22
23/** Subscribe synchronously before publishing so the forked consumer cannot miss events. */
24const collectEvents = Effect.gen(function* () {
25 const manager = yield* PreviewManager.PreviewManager;
26 const subscription = yield* manager.subscribeEvents;
27 const collector: EventCollector = {
28 drain: PubSub.takeUpTo(subscription, DRAIN_LIMIT),
29 };
30 return collector;
31}).pipe(Effect.withSpan("preview.test.collectEvents"));
32
33const layer = PreviewManager.layer.pipe(
34 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-preview-manager-" })),
35 Layer.provide(NodeCrypto.layer),
36 Layer.provide(NodeServices.layer),
37);
38
39it.layer(layer)("PreviewManager", (it) => {
40 it.effect("opens a session and emits opened with normalized URL", () =>
41 Effect.gen(function* () {
42 const threadId = freshThreadId();
43 const manager = yield* PreviewManager.PreviewManager;
44 const collector = yield* collectEvents;
45
46 const snapshot = yield* manager.open({ threadId, url: "localhost:5173" });
47 expect(snapshot.tabId.startsWith("tab_")).toBe(true);
48 expect(snapshot.navStatus._tag).toBe("Loading");
49 if (snapshot.navStatus._tag === "Loading") {
50 expect(snapshot.navStatus.url).toBe("http://localhost:5173/");
51 }
52
53 const events = yield* collector.drain;
54 expect(events).toHaveLength(1);
55 expect(events[0]?.type).toBe("opened");
56 if (events[0]?.type === "opened") {
57 expect(events[0].tabId).toBe(snapshot.tabId);
58 }
59 }),
60 );
61
62 it.effect("keeps the tab's profile across status reports", () =>
63 Effect.gen(function* () {
64 const threadId = freshThreadId();
65 const manager = yield* PreviewManager.PreviewManager;
66
67 const opened = yield* manager.open({ threadId, profileId: "work" });
68 expect(opened.profileId).toBe("work");
69
70 yield* manager.reportStatus({
71 threadId,
72 tabId: opened.tabId,
73 navStatus: { _tag: "Success", url: "http://localhost:5173/", title: "Dev" },
74 canGoBack: true,
75 canGoForward: false,
76 });
77 const listed = yield* manager.list({ threadId });
78 expect(listed.sessions.find((s) => s.tabId === opened.tabId)?.profileId).toBe("work");
79 }),
80 );
81
82 it.effect("reissues presentation requests without replaying them on navigation", () =>
83 Effect.gen(function* () {
84 const threadId = freshThreadId();
85 const manager = yield* PreviewManager.PreviewManager;
86 const collector = yield* collectEvents;
87 const opened = yield* manager.open({ threadId, runtime: "server", reveal: false });
88 yield* manager.requestReveal({ threadId, tabId: opened.tabId, force: true });
89 const first = (yield* manager.list({ threadId })).sessions[0];
90 expect(first?.reveal).toBe(true);
91 expect(first?.revealRequest?.force).toBe(true);
92 yield* manager.reportStatus({
93 threadId,
94 tabId: opened.tabId,
95 serverControlled: true,
96 navStatus: { _tag: "Success", url: "http://localhost:5173/", title: "Dev" },
97 canGoBack: false,
98 canGoForward: false,
99 });
100 expect((yield* manager.list({ threadId })).sessions[0]?.revealRequest).toEqual(
101 first?.revealRequest,
102 );
103 yield* manager.requestReveal({ threadId, tabId: opened.tabId, force: false });
104 const second = (yield* manager.list({ threadId })).sessions[0];
105 expect(second?.revealRequest?.id).not.toBe(first?.revealRequest?.id);
106 expect(second?.revealRequest?.force).toBe(false);
107 const events = yield* collector.drain;
108 const last = events.at(-1);
109 expect(last?.type).toBe("navigated");
110 if (last?.type === "navigated")
111 expect(last.snapshot.revealRequest).toEqual(second?.revealRequest);
112 }),
113 );
114
115 it.effect("orders list snapshots and events with one monotonic revision", () =>
116 Effect.gen(function* () {
117 const threadId = freshThreadId();
118 const manager = yield* PreviewManager.PreviewManager;
119 const collector = yield* collectEvents;
120 const before = yield* manager.list({ threadId });
121
122 const opened = yield* manager.open({ threadId, url: "http://localhost:5173" });
123 yield* manager.reportStatus({
124 threadId,
125 tabId: opened.tabId,
126 navStatus: { _tag: "Success", url: "http://localhost:5173/ready", title: "Ready" },
127 canGoBack: true,
128 canGoForward: false,
129 });
130
131 const events = yield* collector.drain;
132 const listed = yield* manager.list({ threadId });
133 expect(events).toHaveLength(2);
134 expect(events[0]!.serverEpoch).toBe(listed.serverEpoch);
135 expect(events[1]!.serverEpoch).toBe(listed.serverEpoch);
136 expect(events[0]!.revision).toBeGreaterThan(before.revision);
137 expect(events[1]!.revision).toBeGreaterThan(events[0]!.revision);
138 expect(listed.revision).toBe(events[1]!.revision);
139 expect(listed.sessions).toHaveLength(1);
140 }),
141 );
142
143 it.effect("resizes a tab and preserves its viewport across navigation reports", () =>
144 Effect.gen(function* () {
145 const threadId = freshThreadId();
146 const manager = yield* PreviewManager.PreviewManager;
147 const collector = yield* collectEvents;
148 const opened = yield* manager.open({ threadId, url: "http://localhost:5173" });
149
150 const resized = yield* manager.resize({
151 threadId,
152 tabId: opened.tabId,
153 viewport: { _tag: "freeform", width: 1024, height: 768 },
154 });
155 expect(resized.viewport).toEqual({ _tag: "freeform", width: 1024, height: 768 });
156
157 yield* manager.reportStatus({
158 threadId,
159 tabId: opened.tabId,
160 navStatus: { _tag: "Success", url: "http://localhost:5173/resized", title: "Resized" },
161 canGoBack: true,
162 canGoForward: false,
163 });
164 const listed = yield* manager.list({ threadId });
165 expect(listed.sessions[0]?.viewport).toEqual(resized.viewport);
166
167 const events = yield* collector.drain;
168 expect(events.map((event) => event.type)).toEqual(["opened", "resized", "navigated"]);
169 }),
170 );
171
172 it.effect(
173 "rejects client status reports for server tabs without changing state or emitting events",
174 () =>
175 Effect.gen(function* () {
176 const threadId = freshThreadId();
177 const manager = yield* PreviewManager.PreviewManager;
178 const opened = yield* manager.open({ threadId, runtime: "server", reveal: false });
179 const collector = yield* collectEvents;
180 const before = yield* manager.list({ threadId });
181 const input = {
182 threadId,
183 tabId: opened.tabId,
184 navStatus: {
185 _tag: "Success" as const,
186 url: "http://localhost:5173/changed",
187 title: "Changed",
188 },
189 canGoBack: true,
190 canGoForward: true,
191 };
192 const rejected = yield* manager.reportStatus(input).pipe(Effect.flip);
193 expect(rejected._tag).toBe("PreviewControlRequiredError");
194 expect(yield* manager.list({ threadId })).toEqual(before);
195 expect(yield* collector.drain).toEqual([]);
196
197 yield* manager.reportStatus({ ...input, serverControlled: true });
198 expect((yield* manager.list({ threadId })).sessions[0]).toMatchObject({
199 navStatus: input.navStatus,
200 canGoBack: true,
201 canGoForward: true,
202 });
203 const events = yield* collector.drain;
204 expect(events).toHaveLength(1);
205 expect(events[0]).toMatchObject({
206 type: "navigated",
207 snapshot: { navStatus: input.navStatus },
208 });
209 }),
210 );
211
212 it.effect("gives every tab in a batch close its own monotonic revision", () =>
213 Effect.gen(function* () {
214 const threadId = freshThreadId();
215 const manager = yield* PreviewManager.PreviewManager;
216 yield* manager.open({ threadId, url: "http://localhost:5173" });
217 yield* manager.open({ threadId, url: "http://localhost:3000" });
218 const collector = yield* collectEvents;
219
220 yield* manager.close({ threadId });
221
222 const events = yield* collector.drain;
223 const listed = yield* manager.list({ threadId });
224 expect(events).toHaveLength(2);
225 expect(events.every((event) => event.type === "closed")).toBe(true);
226 expect(events[1]!.revision).toBeGreaterThan(events[0]!.revision);
227 expect(listed.revision).toBe(events[1]!.revision);
228 expect(listed.sessions).toHaveLength(0);
229 }),
230 );
231
232 it.effect("close with explicit tabId removes only that tab", () =>
233 Effect.gen(function* () {
234 const threadId = freshThreadId();
235 const manager = yield* PreviewManager.PreviewManager;
236 const a = yield* manager.open({ threadId, url: "http://localhost:5173" });
237 const b = yield* manager.open({ threadId, url: "http://localhost:3000" });
238
239 yield* manager.close({ threadId, tabId: a.tabId });
240
241 const list = yield* manager.list({ threadId });
242 expect(list.sessions.map((s) => s.tabId)).toEqual([b.tabId]);
243 }),
244 );
245});