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 { PreviewUrlNormalizationError } from "@t3tools/shared/preview";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as PubSub from "effect/PubSub";
9import { expect } from "vite-plus/test";
10
11import * as ServerConfig from "../config.ts";
12import * as PreviewManager from "./Manager.ts";
13
14const DRAIN_LIMIT = 100;
15
16interface EventCollector {
17 /** Drain everything published since the last call (or since subscribe). */
18 readonly drain: Effect.Effect<ReadonlyArray<PreviewEvent>>;
19}
20
21/**
22 * Each `it.effect` shares the live PreviewManager layer across the whole
23 * `it.layer` block, so tests that assert per-thread counts must use a unique
24 * thread id to avoid bleeding state from earlier tests.
25 */
26let nextThreadId = 0;
27const freshThreadId = () => ThreadId.make(`thread-${++nextThreadId}`);
28
29/**
30 * Subscribe to the manager's event stream BEFORE the test publishes. We
31 * use `subscribeEvents` (synchronous PubSub.subscribe under the hood) so
32 * no event can land between subscribe and the consumer drain.
33 */
34const collectEvents = Effect.gen(function* () {
35 const manager = yield* PreviewManager.PreviewManager;
36 const subscription = yield* manager.subscribeEvents;
37 const collector: EventCollector = {
38 drain: PubSub.takeUpTo(subscription, DRAIN_LIMIT),
39 };
40 return collector;
41}).pipe(Effect.withSpan("preview.test.collectEvents"));
42
43const layer = PreviewManager.layer.pipe(
44 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-preview-manager-" })),
45 Layer.provide(NodeCrypto.layer),
46 Layer.provide(NodeServices.layer),
47);
48
49it.layer(layer)("PreviewManager", (it) => {
50 it.effect("opens a session and emits opened with normalized URL", () =>
51 Effect.gen(function* () {
52 const threadId = freshThreadId();
53 const manager = yield* PreviewManager.PreviewManager;
54 const collector = yield* collectEvents;
55
56 const snapshot = yield* manager.open({ threadId, url: "localhost:5173" });
57 expect(snapshot.tabId.startsWith("tab_")).toBe(true);
58 expect(snapshot.navStatus._tag).toBe("Loading");
59 if (snapshot.navStatus._tag === "Loading") {
60 expect(snapshot.navStatus.url).toBe("http://localhost:5173/");
61 }
62
63 const events = yield* collector.drain;
64 expect(events).toHaveLength(1);
65 expect(events[0]?.type).toBe("opened");
66 if (events[0]?.type === "opened") {
67 expect(events[0].tabId).toBe(snapshot.tabId);
68 }
69 }),
70 );
71
72 it.effect("keeps the tab's profile across navigation and status reports", () =>
73 Effect.gen(function* () {
74 const threadId = freshThreadId();
75 const manager = yield* PreviewManager.PreviewManager;
76
77 const opened = yield* manager.open({ threadId, profileId: "work" });
78 expect(opened.profileId).toBe("work");
79
80 // `navigate` and `reportStatus` rebuild the snapshot field by field
81 // rather than spreading it, so a new field is dropped unless carried
82 // explicitly — which would silently move the tab to another profile's
83 // partition on its first navigation.
84 const navigated = yield* manager.navigate({
85 threadId,
86 tabId: opened.tabId,
87 url: "localhost:5173",
88 });
89 expect(navigated.profileId).toBe("work");
90
91 yield* manager.reportStatus({
92 threadId,
93 tabId: opened.tabId,
94 navStatus: { _tag: "Success", url: "http://localhost:5173/", title: "Dev" },
95 canGoBack: true,
96 canGoForward: false,
97 });
98 const listed = yield* manager.list({ threadId });
99 expect(listed.sessions.find((s) => s.tabId === opened.tabId)?.profileId).toBe("work");
100 }),
101 );
102
103 it.effect("lets any client size, theme, and zoom a server tab", () =>
104 Effect.gen(function* () {
105 const threadId = freshThreadId();
106 const manager = yield* PreviewManager.PreviewManager;
107 const collector = yield* collectEvents;
108 const opened = yield* manager.open({ threadId, runtime: "server" });
109 const viewport = {
110 _tag: "preset",
111 presetId: "iphone-12-pro",
112 width: 390,
113 height: 844,
114 } as const;
115 yield* manager.resize({ threadId, tabId: opened.tabId, viewport });
116 const adjusted = yield* manager.adjust({
117 threadId,
118 tabId: opened.tabId,
119 colorScheme: "dark",
120 zoomFactor: 1.25,
121 });
122 expect(adjusted).toMatchObject({ viewport, colorScheme: "dark", zoomFactor: 1.25 });
123 // One-off requests go to the server's browser and leave the tab's state alone.
124 yield* manager.adjust({ threadId, tabId: opened.tabId, hardReload: true, clear: "cache" });
125 const events = (yield* collector.drain).filter((event) => event.type === "resized");
126 expect(events.at(-1)).toMatchObject({
127 request: { hardReload: true, clear: "cache" },
128 snapshot: { colorScheme: "dark", zoomFactor: 1.25 },
129 });
130 expect(events.at(-2)).not.toHaveProperty("request");
131 }),
132 );
133
134 it.effect("reissues presentation requests without replaying them on navigation", () =>
135 Effect.gen(function* () {
136 const threadId = freshThreadId();
137 const manager = yield* PreviewManager.PreviewManager;
138 const collector = yield* collectEvents;
139 const opened = yield* manager.open({ threadId, runtime: "server", reveal: false });
140 yield* manager.requestReveal({ threadId, tabId: opened.tabId, force: true });
141 const first = (yield* manager.list({ threadId })).sessions[0];
142 expect(first?.reveal).toBe(true);
143 expect(first?.revealRequest?.force).toBe(true);
144 // Server tabs report navigation through their controlling browser.
145 const refused = yield* manager
146 .navigate({ threadId, tabId: opened.tabId, url: "localhost:5173" })
147 .pipe(Effect.flip);
148 expect(refused._tag).toBe("PreviewControlRequiredError");
149 yield* manager.reportStatus({
150 threadId,
151 tabId: opened.tabId,
152 serverControlled: true,
153 navStatus: { _tag: "Success", url: "http://localhost:5173/", title: "Dev" },
154 canGoBack: false,
155 canGoForward: false,
156 });
157 expect((yield* manager.list({ threadId })).sessions[0]?.revealRequest).toEqual(
158 first?.revealRequest,
159 );
160 yield* manager.requestReveal({ threadId, tabId: opened.tabId, force: false });
161 const second = (yield* manager.list({ threadId })).sessions[0];
162 expect(second?.revealRequest?.id).not.toBe(first?.revealRequest?.id);
163 expect(second?.revealRequest?.force).toBe(false);
164 const events = yield* collector.drain;
165 const last = events.at(-1);
166 expect(last?.type).toBe("navigated");
167 if (last?.type === "navigated")
168 expect(last.snapshot.revealRequest).toEqual(second?.revealRequest);
169 }),
170 );
171
172 it.effect("opens an Idle tab when no URL is supplied", () =>
173 Effect.gen(function* () {
174 const threadId = freshThreadId();
175 const manager = yield* PreviewManager.PreviewManager;
176 const snapshot = yield* manager.open({ threadId });
177 expect(snapshot.navStatus._tag).toBe("Idle");
178 }),
179 );
180
181 it.effect("orders list snapshots and events with one monotonic revision", () =>
182 Effect.gen(function* () {
183 const threadId = freshThreadId();
184 const manager = yield* PreviewManager.PreviewManager;
185 const collector = yield* collectEvents;
186 const before = yield* manager.list({ threadId });
187
188 const opened = yield* manager.open({ threadId, url: "http://localhost:5173" });
189 yield* manager.navigate({
190 threadId,
191 tabId: opened.tabId,
192 url: "http://localhost:5173/ready",
193 });
194
195 const events = yield* collector.drain;
196 const listed = yield* manager.list({ threadId });
197 expect(events).toHaveLength(2);
198 expect(events[0]!.serverEpoch).toBe(listed.serverEpoch);
199 expect(events[1]!.serverEpoch).toBe(listed.serverEpoch);
200 expect(events[0]!.revision).toBeGreaterThan(before.revision);
201 expect(events[1]!.revision).toBeGreaterThan(events[0]!.revision);
202 expect(listed.revision).toBe(events[1]!.revision);
203 expect(listed.sessions).toHaveLength(1);
204 }),
205 );
206
207 it.effect("treats bare hosts as https", () =>
208 Effect.gen(function* () {
209 const threadId = freshThreadId();
210 const manager = yield* PreviewManager.PreviewManager;
211 const snapshot = yield* manager.open({ threadId, url: "example.com" });
212 if (snapshot.navStatus._tag === "Loading") {
213 expect(snapshot.navStatus.url).toBe("https://example.com/");
214 }
215 }),
216 );
217
218 it.effect("rejects empty URL with PreviewInvalidUrlError", () =>
219 Effect.gen(function* () {
220 const threadId = freshThreadId();
221 const manager = yield* PreviewManager.PreviewManager;
222 const error = yield* Effect.flip(manager.open({ threadId, url: " " }));
223 expect(error._tag).toBe("PreviewInvalidUrlError");
224 expect(error).toMatchObject({ inputLength: 3, reason: "empty" });
225 expect(error).not.toHaveProperty("rawUrl");
226 expect(error.cause).toBeInstanceOf(PreviewUrlNormalizationError);
227 expect((error.cause as PreviewUrlNormalizationError).reason).toBe("empty");
228 }),
229 );
230
231 it.effect("preserves URL parser failures as the invalid URL cause chain", () =>
232 Effect.gen(function* () {
233 const threadId = freshThreadId();
234 const manager = yield* PreviewManager.PreviewManager;
235 const rawUrl = "https://user:password@example.com:bad/path?access_token=secret#fragment";
236 const error = yield* Effect.flip(manager.open({ threadId, url: rawUrl }));
237
238 expect(error).toMatchObject({
239 inputLength: rawUrl.length,
240 reason: "parse",
241 protocol: "https:",
242 });
243 expect(error).not.toHaveProperty("rawUrl");
244 expect(error.cause).toBeInstanceOf(PreviewUrlNormalizationError);
245 const normalizationError = error.cause as PreviewUrlNormalizationError;
246 expect(normalizationError.cause).toBeInstanceOf(Error);
247 expect(error.message).not.toContain((normalizationError.cause as Error).message);
248 expect(error.message).not.toMatch(/user|password|access_token|secret|fragment/);
249 }),
250 );
251
252 it.effect("navigate updates snapshot and emits navigated", () =>
253 Effect.gen(function* () {
254 const threadId = freshThreadId();
255 const manager = yield* PreviewManager.PreviewManager;
256 const collector = yield* collectEvents;
257
258 const opened = yield* manager.open({ threadId, url: "http://localhost:5173" });
259 const snapshot = yield* manager.navigate({
260 threadId,
261 tabId: opened.tabId,
262 url: "http://localhost:5173/about",
263 resolvedTitle: "About",
264 });
265
266 expect(snapshot.navStatus._tag).toBe("Success");
267 if (snapshot.navStatus._tag === "Success") {
268 expect(snapshot.navStatus.url).toBe("http://localhost:5173/about");
269 expect(snapshot.navStatus.title).toBe("About");
270 }
271 const events = yield* collector.drain;
272 expect(events.map((e) => e.type)).toEqual(["opened", "navigated"]);
273 }),
274 );
275
276 it.effect("navigate fails for unknown tab", () =>
277 Effect.gen(function* () {
278 const threadId = freshThreadId();
279 const manager = yield* PreviewManager.PreviewManager;
280 const error = yield* Effect.flip(
281 manager.navigate({
282 threadId,
283 tabId: "tab_missing",
284 url: "http://localhost:5173",
285 }),
286 );
287 expect(error._tag).toBe("PreviewSessionLookupError");
288 }),
289 );
290
291 it.effect("resizes a tab and preserves its viewport across navigation reports", () =>
292 Effect.gen(function* () {
293 const threadId = freshThreadId();
294 const manager = yield* PreviewManager.PreviewManager;
295 const collector = yield* collectEvents;
296 const opened = yield* manager.open({ threadId, url: "http://localhost:5173" });
297
298 const resized = yield* manager.resize({
299 threadId,
300 tabId: opened.tabId,
301 viewport: { _tag: "freeform", width: 1024, height: 768 },
302 });
303 expect(resized.viewport).toEqual({ _tag: "freeform", width: 1024, height: 768 });
304
305 const navigated = yield* manager.navigate({
306 threadId,
307 tabId: opened.tabId,
308 url: "http://localhost:5173/resized",
309 });
310 expect(navigated.viewport).toEqual(resized.viewport);
311
312 yield* manager.reportStatus({
313 threadId,
314 tabId: opened.tabId,
315 navStatus: { _tag: "Success", url: "http://localhost:5173/resized", title: "Resized" },
316 canGoBack: true,
317 canGoForward: false,
318 });
319 const listed = yield* manager.list({ threadId });
320 expect(listed.sessions[0]?.viewport).toEqual(resized.viewport);
321
322 const events = yield* collector.drain;
323 expect(events.map((event) => event.type)).toEqual([
324 "opened",
325 "resized",
326 "navigated",
327 "navigated",
328 ]);
329 }),
330 );
331
332 it.effect("rejects resize for an unknown tab", () =>
333 Effect.gen(function* () {
334 const manager = yield* PreviewManager.PreviewManager;
335 const error = yield* Effect.flip(
336 manager.resize({
337 threadId: freshThreadId(),
338 tabId: "tab_missing",
339 viewport: { _tag: "fill" },
340 }),
341 );
342 expect(error._tag).toBe("PreviewSessionLookupError");
343 }),
344 );
345
346 it.effect(
347 "rejects client status reports for server tabs without changing state or emitting events",
348 () =>
349 Effect.gen(function* () {
350 const threadId = freshThreadId();
351 const manager = yield* PreviewManager.PreviewManager;
352 const opened = yield* manager.open({ threadId, runtime: "server", reveal: false });
353 const collector = yield* collectEvents;
354 const before = yield* manager.list({ threadId });
355 const input = {
356 threadId,
357 tabId: opened.tabId,
358 navStatus: {
359 _tag: "Success" as const,
360 url: "http://localhost:5173/changed",
361 title: "Changed",
362 },
363 canGoBack: true,
364 canGoForward: true,
365 };
366 const rejected = yield* manager.reportStatus(input).pipe(Effect.flip);
367 expect(rejected._tag).toBe("PreviewControlRequiredError");
368 expect(yield* manager.list({ threadId })).toEqual(before);
369 expect(yield* collector.drain).toEqual([]);
370
371 yield* manager.reportStatus({ ...input, serverControlled: true });
372 expect((yield* manager.list({ threadId })).sessions[0]).toMatchObject({
373 navStatus: input.navStatus,
374 canGoBack: true,
375 canGoForward: true,
376 });
377 const events = yield* collector.drain;
378 expect(events).toHaveLength(1);
379 expect(events[0]).toMatchObject({
380 type: "navigated",
381 snapshot: { navStatus: input.navStatus },
382 });
383 }),
384 );
385
386 it.effect("reportStatus emits failed for LoadFailed nav", () =>
387 Effect.gen(function* () {
388 const threadId = freshThreadId();
389 const manager = yield* PreviewManager.PreviewManager;
390 const collector = yield* collectEvents;
391
392 const opened = yield* manager.open({ threadId, url: "http://localhost:5173" });
393 yield* manager.reportStatus({
394 threadId,
395 tabId: opened.tabId,
396 navStatus: {
397 _tag: "LoadFailed",
398 url: "http://localhost:5173",
399 title: "",
400 code: -105,
401 description: "ERR_NAME_NOT_RESOLVED",
402 },
403 canGoBack: false,
404 canGoForward: false,
405 });
406
407 const events = yield* collector.drain;
408 const failed = events.find((e) => e.type === "failed");
409 expect(failed?.type).toBe("failed");
410 if (failed?.type === "failed") {
411 expect(failed.code).toBe(-105);
412 expect(failed.description).toBe("ERR_NAME_NOT_RESOLVED");
413 }
414 }),
415 );
416
417 it.effect("close removes the session and emits closed", () =>
418 Effect.gen(function* () {
419 const threadId = freshThreadId();
420 const manager = yield* PreviewManager.PreviewManager;
421 const collector = yield* collectEvents;
422
423 yield* manager.open({ threadId, url: "http://localhost:5173" });
424 yield* manager.close({ threadId });
425
426 const result = yield* manager.list({ threadId });
427 expect(result.sessions).toHaveLength(0);
428 const events = yield* collector.drain;
429 const closed = events.find((e) => e.type === "closed");
430 expect(closed?.type).toBe("closed");
431 }),
432 );
433
434 it.effect("gives every tab in a batch close its own monotonic revision", () =>
435 Effect.gen(function* () {
436 const threadId = freshThreadId();
437 const manager = yield* PreviewManager.PreviewManager;
438 yield* manager.open({ threadId, url: "http://localhost:5173" });
439 yield* manager.open({ threadId, url: "http://localhost:3000" });
440 const collector = yield* collectEvents;
441
442 yield* manager.close({ threadId });
443
444 const events = yield* collector.drain;
445 const listed = yield* manager.list({ threadId });
446 expect(events).toHaveLength(2);
447 expect(events.every((event) => event.type === "closed")).toBe(true);
448 expect(events[1]!.revision).toBeGreaterThan(events[0]!.revision);
449 expect(listed.revision).toBe(events[1]!.revision);
450 expect(listed.sessions).toHaveLength(0);
451 }),
452 );
453
454 it.effect("close is idempotent for unknown threads", () =>
455 Effect.gen(function* () {
456 const threadId = freshThreadId();
457 const manager = yield* PreviewManager.PreviewManager;
458 yield* manager.close({ threadId });
459 const result = yield* manager.list({ threadId });
460 expect(result.sessions).toHaveLength(0);
461 }),
462 );
463
464 it.effect("list returns every snapshot for the thread sorted by updatedAt", () =>
465 Effect.gen(function* () {
466 const threadId = freshThreadId();
467 const manager = yield* PreviewManager.PreviewManager;
468 const first = yield* manager.open({ threadId, url: "http://localhost:5173" });
469 const second = yield* manager.open({ threadId, url: "http://localhost:3000" });
470 const result = yield* manager.list({ threadId });
471 expect(result.sessions).toHaveLength(2);
472 const ids = result.sessions.map((s) => s.tabId);
473 expect(ids).toContain(first.tabId);
474 expect(ids).toContain(second.tabId);
475 }),
476 );
477
478 it.effect("open creates an independent tab on every call", () =>
479 Effect.gen(function* () {
480 const threadId = freshThreadId();
481 const manager = yield* PreviewManager.PreviewManager;
482 const collector = yield* collectEvents;
483
484 const a = yield* manager.open({ threadId, url: "http://localhost:5173" });
485 const b = yield* manager.open({ threadId, url: "http://localhost:3000/path" });
486
487 expect(a.tabId).not.toBe(b.tabId);
488 const list = yield* manager.list({ threadId });
489 expect(list.sessions).toHaveLength(2);
490
491 const events = yield* collector.drain;
492 expect(events.map((e) => e.type)).toEqual(["opened", "opened"]);
493 }),
494 );
495
496 it.effect("close with mismatching tabId is a no-op", () =>
497 Effect.gen(function* () {
498 const threadId = freshThreadId();
499 const manager = yield* PreviewManager.PreviewManager;
500 yield* manager.open({ threadId, url: "http://localhost:5173" });
501 yield* manager.close({ threadId, tabId: "tab_missing" });
502
503 const list = yield* manager.list({ threadId });
504 expect(list.sessions).toHaveLength(1);
505 }),
506 );
507
508 it.effect("close with explicit tabId removes only that tab", () =>
509 Effect.gen(function* () {
510 const threadId = freshThreadId();
511 const manager = yield* PreviewManager.PreviewManager;
512 const a = yield* manager.open({ threadId, url: "http://localhost:5173" });
513 const b = yield* manager.open({ threadId, url: "http://localhost:3000" });
514
515 yield* manager.close({ threadId, tabId: a.tabId });
516
517 const list = yield* manager.list({ threadId });
518 expect(list.sessions.map((s) => s.tabId)).toEqual([b.tabId]);
519 }),
520 );
521
522 it.effect("multiple subscribers receive every event independently", () =>
523 Effect.gen(function* () {
524 const threadId = freshThreadId();
525 const manager = yield* PreviewManager.PreviewManager;
526 const aSub = yield* manager.subscribeEvents;
527 const bSub = yield* manager.subscribeEvents;
528
529 yield* manager.open({ threadId, url: "http://localhost:5173" });
530 yield* manager.open({ threadId, url: "http://localhost:3000" });
531
532 const aEvents = yield* PubSub.takeUpTo(aSub, DRAIN_LIMIT);
533 const bEvents = yield* PubSub.takeUpTo(bSub, DRAIN_LIMIT);
534 expect(aEvents.map((e) => e.type)).toEqual(["opened", "opened"]);
535 expect(bEvents.map((e) => e.type)).toEqual(["opened", "opened"]);
536 }),
537 );
538});