1import { Schema } from "effect";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 ConfiguredLocalServerUrls,
6 CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS,
7 DiscoveredLocalServer,
8 PREVIEW_URL_MAX_LENGTH,
9 PreviewEvent,
10 PreviewNavStatus,
11 PreviewSessionSnapshot,
12 PreviewViewportSetting,
13} from "./preview.ts";
14import {
15 PreviewAutomationHost,
16 PreviewAutomationError,
17 PreviewAutomationOpenInput,
18 PreviewAutomationResizeInput,
19 PreviewAutomationResizeResult,
20 PreviewAutomationStatus,
21} from "./previewAutomation.ts";
22
23const decodePreviewEvent = Schema.decodeUnknownSync(PreviewEvent);
24const decodeSnapshot = Schema.decodeUnknownSync(PreviewSessionSnapshot);
25const decodeNavStatus = Schema.decodeUnknownSync(PreviewNavStatus);
26const decodeServer = Schema.decodeUnknownSync(DiscoveredLocalServer);
27const decodeConfiguredLocalServerUrls = Schema.decodeUnknownSync(ConfiguredLocalServerUrls);
28const decodeViewport = Schema.decodeUnknownSync(PreviewViewportSetting);
29const decodeResizeInput = Schema.decodeUnknownSync(PreviewAutomationResizeInput);
30const decodeOpenInput = Schema.decodeUnknownSync(PreviewAutomationOpenInput);
31const decodeResizeResult = Schema.decodeUnknownSync(PreviewAutomationResizeResult);
32const decodeAutomationHost = Schema.decodeUnknownSync(PreviewAutomationHost);
33const decodeAutomationError = Schema.decodeUnknownSync(PreviewAutomationError);
34const decodeAutomationStatus = Schema.decodeUnknownSync(PreviewAutomationStatus);
35
36describe("PreviewAutomationOpenInput", () => {
37 it("accepts the inline preview visibility flag", () => {
38 expect(decodeOpenInput({ open: false })).toEqual({ open: false });
39 });
40
41 it("retains the legacy show visibility alias", () => {
42 expect(decodeOpenInput({ show: false })).toEqual({ show: false });
43 });
44});
45
46describe("PreviewNavStatus", () => {
47 it("decodes Idle", () => {
48 expect(decodeNavStatus({ _tag: "Idle" })).toEqual({ _tag: "Idle" });
49 });
50
51 it("decodes Loading with title", () => {
52 expect(decodeNavStatus({ _tag: "Loading", url: "http://localhost:5173/", title: "" })).toEqual({
53 _tag: "Loading",
54 url: "http://localhost:5173/",
55 title: "",
56 });
57 });
58
59 it("decodes LoadFailed with code/description", () => {
60 expect(
61 decodeNavStatus({
62 _tag: "LoadFailed",
63 url: "https://example.com/",
64 title: "Example",
65 code: -105,
66 description: "ERR_NAME_NOT_RESOLVED",
67 }),
68 ).toEqual({
69 _tag: "LoadFailed",
70 url: "https://example.com/",
71 title: "Example",
72 code: -105,
73 description: "ERR_NAME_NOT_RESOLVED",
74 });
75 });
76
77 it("rejects empty url", () => {
78 expect(() => decodeNavStatus({ _tag: "Loading", url: "", title: "" })).toThrow();
79 });
80});
81
82describe("PreviewSessionSnapshot", () => {
83 it("round-trips a Success snapshot", () => {
84 const snapshot = decodeSnapshot({
85 threadId: "thread-1",
86 tabId: "preview-thread-1",
87 navStatus: {
88 _tag: "Success",
89 url: "http://localhost:5173/",
90 title: "Vite App",
91 },
92 canGoBack: false,
93 canGoForward: false,
94 updatedAt: "2026-01-01T00:00:00.000Z",
95 });
96 expect(snapshot.tabId).toBe("preview-thread-1");
97 expect(snapshot.navStatus._tag).toBe("Success");
98 });
99});
100
101describe("PreviewViewportSetting", () => {
102 it("decodes fill, freeform, and preset modes", () => {
103 expect(decodeViewport({ _tag: "fill" })).toEqual({ _tag: "fill" });
104 expect(decodeViewport({ _tag: "freeform", width: 1024, height: 768 })).toEqual({
105 _tag: "freeform",
106 width: 1024,
107 height: 768,
108 });
109 expect(
110 decodeViewport({
111 _tag: "preset",
112 presetId: "iphone-15-pro",
113 width: 393,
114 height: 852,
115 }),
116 ).toMatchObject({ _tag: "preset", presetId: "iphone-15-pro" });
117 });
118
119 it("rejects unsafe dimensions and oversized render areas", () => {
120 expect(() => decodeViewport({ _tag: "freeform", width: 100, height: 800 })).toThrow();
121 expect(() => decodeViewport({ _tag: "freeform", width: 3840, height: 3840 })).toThrow();
122 });
123});
124
125describe("PreviewAutomationResizeInput", () => {
126 it("requires fields that match the selected mode", () => {
127 expect(decodeResizeInput({ mode: "fill" })).toEqual({ mode: "fill" });
128 expect(
129 decodeResizeInput({ mode: "preset", preset: "pixel-7", orientation: "landscape" }),
130 ).toMatchObject({ mode: "preset", preset: "pixel-7" });
131 expect(() => decodeResizeInput({ mode: "preset", preset: "pixel-8" })).toThrow();
132 expect(() => decodeResizeInput({ mode: "freeform", width: 1024 })).toThrow();
133 expect(() => decodeResizeInput({ mode: "fill", width: 1024, height: 768 })).toThrow();
134 });
135
136 it("allows fill-mode measurements below the minimum selectable fixed size", () => {
137 expect(
138 decodeResizeResult({
139 tabId: "preview-t",
140 setting: { _tag: "fill" },
141 viewport: { width: 180, height: 120 },
142 }).viewport,
143 ).toEqual({ width: 180, height: 120 });
144 });
145});
146
147describe("preview automation tab targeting", () => {
148 it("accepts an explicit tab and rejects contradictory open behavior", () => {
149 expect(decodeResizeInput({ tabId: "tab-app", mode: "fill" })).toMatchObject({
150 tabId: "tab-app",
151 mode: "fill",
152 });
153 expect(decodeOpenInput({ tabId: "tab-app", reuseExistingTab: true })).toMatchObject({
154 tabId: "tab-app",
155 reuseExistingTab: true,
156 });
157 expect(() => decodeOpenInput({ tabId: "tab-app", reuseExistingTab: false })).toThrow();
158 });
159});
160
161describe("PreviewAutomationHost", () => {
162 it("accepts legacy hosts and current operation advertisements", () => {
163 expect(decodeAutomationHost({ clientId: "legacy", environmentId: "environment-1" })).toEqual({
164 clientId: "legacy",
165 environmentId: "environment-1",
166 });
167 expect(
168 decodeAutomationHost({
169 clientId: "current",
170 environmentId: "environment-1",
171 supportedOperations: ["status", "resize"],
172 }).supportedOperations,
173 ).toEqual(["status", "resize"]);
174 });
175});
176
177describe("PreviewAutomationError", () => {
178 it("preserves a typed non-editable target failure", () => {
179 const error = decodeAutomationError({
180 _tag: "PreviewAutomationTargetNotEditableError",
181 operation: "type",
182 environmentId: "environment-1",
183 threadId: "thread-1",
184 providerSessionId: "provider-session-1",
185 providerInstanceId: "codex",
186 clientId: "client-1",
187 connectionId: "connection-1",
188 requestId: "request-1",
189 tabId: "tab-1",
190 timeoutMs: 1_000,
191 remoteTag: "PreviewAutomationTargetNotEditableError",
192 remoteMessageLength: 12,
193 cause: {},
194 selectorKind: "focused-element",
195 });
196
197 expect(error._tag).toBe("PreviewAutomationTargetNotEditableError");
198 if (error._tag === "PreviewAutomationTargetNotEditableError") {
199 expect(error.selectorKind).toBe("focused-element");
200 expect(error.message).toBe("Preview automation type requires an editable focused element.");
201 }
202 });
203});
204
205describe("PreviewAutomationStatus", () => {
206 it("accepts old hosts without viewport data and exposes it from current hosts", () => {
207 const base = {
208 available: true,
209 visible: false,
210 tabId: "preview-t",
211 url: "https://example.com",
212 title: "Example",
213 loading: false,
214 };
215 expect(decodeAutomationStatus(base)).toEqual(base);
216 expect(
217 decodeAutomationStatus({
218 ...base,
219 viewportSetting: { _tag: "preset", presetId: "pixel-8", width: 412, height: 915 },
220 viewport: { width: 412, height: 915 },
221 }).viewport,
222 ).toEqual({ width: 412, height: 915 });
223 });
224});
225
226describe("PreviewEvent", () => {
227 it("decodes opened", () => {
228 const event = decodePreviewEvent({
229 type: "opened",
230 threadId: "t",
231 tabId: "preview-t",
232 createdAt: "2026-01-01T00:00:00.000Z",
233 serverEpoch: "server-a",
234 revision: 1,
235 snapshot: {
236 threadId: "t",
237 tabId: "preview-t",
238 navStatus: { _tag: "Idle" },
239 canGoBack: false,
240 canGoForward: false,
241 updatedAt: "2026-01-01T00:00:00.000Z",
242 },
243 });
244 expect(event.type).toBe("opened");
245 });
246
247 it("decodes failed with code/description", () => {
248 const event = decodePreviewEvent({
249 type: "failed",
250 threadId: "t",
251 tabId: "preview-t",
252 createdAt: "2026-01-01T00:00:00.000Z",
253 serverEpoch: "server-a",
254 revision: 1,
255 url: "https://example.com/",
256 title: "",
257 code: -105,
258 description: "ERR_NAME_NOT_RESOLVED",
259 });
260 expect(event.type).toBe("failed");
261 if (event.type === "failed") {
262 expect(event.code).toBe(-105);
263 }
264 });
265
266 it("decodes resized with tab viewport state", () => {
267 const event = decodePreviewEvent({
268 type: "resized",
269 threadId: "t",
270 tabId: "preview-t",
271 createdAt: "2026-01-01T00:00:00.000Z",
272 serverEpoch: "server-a",
273 revision: 1,
274 snapshot: {
275 threadId: "t",
276 tabId: "preview-t",
277 navStatus: { _tag: "Idle" },
278 canGoBack: false,
279 canGoForward: false,
280 viewport: { _tag: "freeform", width: 1024, height: 768 },
281 updatedAt: "2026-01-01T00:00:00.000Z",
282 },
283 });
284 expect(event.type).toBe("resized");
285 });
286
287 it("decodes closed without snapshot", () => {
288 const event = decodePreviewEvent({
289 type: "closed",
290 threadId: "t",
291 tabId: "preview-t",
292 createdAt: "2026-01-01T00:00:00.000Z",
293 serverEpoch: "server-a",
294 revision: 1,
295 });
296 expect(event.type).toBe("closed");
297 });
298});
299
300describe("DiscoveredLocalServer", () => {
301 it("decodes a server with process metadata", () => {
302 const server = decodeServer({
303 host: "localhost",
304 port: 5173,
305 url: "http://localhost:5173",
306 processName: "node",
307 pid: 12345,
308 terminal: null,
309 });
310 expect(server.port).toBe(5173);
311 expect(server.processName).toBe("node");
312 });
313
314 it("decodes a server without process metadata", () => {
315 const server = decodeServer({
316 host: "localhost",
317 port: 3000,
318 url: "http://localhost:3000",
319 processName: null,
320 pid: null,
321 terminal: null,
322 });
323 expect(server.processName).toBeNull();
324 });
325
326 it("rejects invalid ports", () => {
327 expect(() =>
328 decodeServer({
329 host: "localhost",
330 port: 0,
331 url: "http://localhost:0",
332 processName: null,
333 pid: null,
334 terminal: null,
335 }),
336 ).toThrow();
337 expect(() =>
338 decodeServer({
339 host: "localhost",
340 port: 70000,
341 url: "http://localhost:70000",
342 processName: null,
343 pid: null,
344 terminal: null,
345 }),
346 ).toThrow();
347 });
348});
349
350describe("ConfiguredLocalServerUrls", () => {
351 it("bounds the number and length of probe candidates", () => {
352 expect(() =>
353 decodeConfiguredLocalServerUrls(
354 Array.from(
355 { length: CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS + 1 },
356 (_, index) => `http://localhost:${3_000 + index}`,
357 ),
358 ),
359 ).toThrow();
360 expect(() =>
361 decodeConfiguredLocalServerUrls([`http://localhost/${"a".repeat(PREVIEW_URL_MAX_LENGTH)}`]),
362 ).toThrow();
363 });
364});