1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3import { createPreviewStreamClient, type PreviewStreamControl } from "./serverBrowserStream.ts";
4
5class FakeSocket extends EventTarget {
6 static readonly OPEN = 1;
7 static current: FakeSocket;
8 readyState = 1;
9 binaryType = "";
10 readonly sent: string[] = [];
11 readonly url: string;
12 constructor(url: string) {
13 super();
14 this.url = url;
15 FakeSocket.current = this;
16 }
17 send(value: string) {
18 this.sent.push(value);
19 }
20 close() {
21 this.readyState = 3;
22 }
23 message(data: unknown) {
24 this.dispatchEvent(new MessageEvent("message", { data }));
25 }
26 control(value: PreviewStreamControl) {
27 this.message(JSON.stringify({ type: "control", ...value }));
28 }
29}
30
31const target = {
32 access: {
33 httpBase: "http://preview.test/api/preview-stream",
34 wsBase: "ws://preview.test/api/preview-stream",
35 query: {},
36 credentials: true,
37 },
38 threadId: "thread",
39 tabId: "tab",
40 maxWidth: 800,
41 maxHeight: 600,
42};
43
44describe("preview stream control", () => {
45 beforeEach(() => vi.stubGlobal("WebSocket", FakeSocket));
46 afterEach(() => vi.unstubAllGlobals());
47 const connect = () => {
48 const onFrame = vi.fn();
49 const onControl = vi.fn();
50 const client = createPreviewStreamClient(target, {
51 onFrame,
52 onControl,
53 onViewport: vi.fn(),
54 onConnectedChange: vi.fn(),
55 onUnauthorized: vi.fn(),
56 });
57 return { client, socket: FakeSocket.current, onFrame, onControl };
58 };
59 const agent: PreviewStreamControl = {
60 canOperate: true,
61 controller: "agent",
62 generation: 1,
63 dialog: null,
64 };
65
66 it("marks passive viewers without changing active viewer URLs", () => {
67 const { client, socket } = connect();
68 expect(new URL(socket.url).searchParams.has("interactive")).toBe(false);
69 client.stop();
70 const passive = createPreviewStreamClient(
71 { ...target, interactive: false },
72 {
73 onFrame: vi.fn(),
74 onViewport: vi.fn(),
75 onConnectedChange: vi.fn(),
76 onUnauthorized: vi.fn(),
77 },
78 );
79 expect(new URL(FakeSocket.current.url).searchParams.get("interactive")).toBe("false");
80 passive.stop();
81 });
82
83 it("does not resize or navigate before ownership is granted", () => {
84 const { client, socket } = connect();
85 expect(client.send({ type: "resize", width: 390, height: 844 })).toBe(false);
86 expect(client.send({ type: "navigate", url: "http://example.test" })).toBe(false);
87 socket.control(agent);
88 expect(client.send({ type: "takeControl" })).toBe(true);
89 expect(client.send({ type: "text", text: "premature" })).toBe(false);
90 socket.control({ ...agent, controller: "you", generation: 2 });
91 expect(client.send({ type: "text", text: "owned" })).toBe(true);
92 expect(socket.sent.map((entry) => JSON.parse(entry))).toEqual([
93 { type: "takeControl" },
94 { type: "text", text: "owned" },
95 ]);
96 client.stop();
97 });
98
99 it("does not grant control from malformed dialog metadata", () => {
100 const { client, socket, onControl } = connect();
101 socket.message(
102 JSON.stringify({
103 type: "control",
104 ...agent,
105 controller: "you",
106 dialog: { type: "prompt", message: 42, defaultValue: "" },
107 }),
108 );
109 expect(onControl).not.toHaveBeenCalled();
110 expect(client.send({ type: "reload" })).toBe(false);
111 client.stop();
112 });
113
114 it("keeps read-only frames flowing while refusing takeover and every mutation", () => {
115 const { client, socket, onFrame } = connect();
116 socket.control({ ...agent, canOperate: false });
117 const frame = new ArrayBuffer(3);
118 socket.message(frame);
119 expect(onFrame).toHaveBeenCalledWith(frame);
120 expect(client.send({ type: "takeControl" })).toBe(false);
121 expect(client.send({ type: "resize", width: 390, height: 844 })).toBe(false);
122 expect(client.send({ type: "reload" })).toBe(false);
123 expect(socket.sent).toEqual(['{"type":"ack"}']);
124 client.stop();
125 });
126
127 it("revokes input immediately on release and exposes pending dialog state", () => {
128 const { client, socket, onControl } = connect();
129 const owned = {
130 ...agent,
131 controller: "you" as const,
132 dialog: { type: "prompt", message: "Name?", defaultValue: "initial" },
133 };
134 socket.control(owned);
135 expect(onControl).toHaveBeenLastCalledWith(owned);
136 expect(client.send({ type: "dialog", accept: true, promptText: "answer" })).toBe(true);
137 expect(client.send({ type: "releaseControl" })).toBe(true);
138 socket.control({ ...agent, generation: 2 });
139 expect(client.send({ type: "text", text: "late input" })).toBe(false);
140 expect(client.send({ type: "dialog", accept: false })).toBe(false);
141 client.stop();
142 });
143});
144
145describe("preview stream downloads", () => {
146 beforeEach(() => vi.stubGlobal("WebSocket", FakeSocket));
147 afterEach(() => vi.unstubAllGlobals());
148
149 it("offers a download at a URL carrying the stream's ticket", () => {
150 const onDownload = vi.fn();
151 createPreviewStreamClient(
152 {
153 ...target,
154 access: { ...target.access, query: { wsTicket: "ticket" }, credentials: false },
155 },
156 {
157 onFrame: vi.fn(),
158 onDownload,
159 onViewport: vi.fn(),
160 onConnectedChange: vi.fn(),
161 onUnauthorized: vi.fn(),
162 },
163 );
164 FakeSocket.current.message(
165 JSON.stringify({ type: "download", id: "d1", fileName: "a b.csv", sizeBytes: 3 }),
166 );
167 expect(onDownload).toHaveBeenCalledExactlyOnceWith({
168 fileName: "a b.csv",
169 sizeBytes: 3,
170 url: "http://preview.test/api/preview-stream/download?threadId=thread&tabId=tab&id=d1&wsTicket=ticket",
171 });
172 });
173});
174
175describe("preview stream agent pointer", () => {
176 beforeEach(() => vi.stubGlobal("WebSocket", FakeSocket));
177 afterEach(() => vi.unstubAllGlobals());
178
179 it("reports where the agent moves and clicks, ignoring malformed points", () => {
180 const onPointer = vi.fn();
181 createPreviewStreamClient(target, {
182 onFrame: vi.fn(),
183 onPointer,
184 onViewport: vi.fn(),
185 onConnectedChange: vi.fn(),
186 onUnauthorized: vi.fn(),
187 });
188 FakeSocket.current.message(
189 JSON.stringify({ type: "pointer", phase: "drag", x: 1, y: 2, sequence: 1 }),
190 );
191 FakeSocket.current.message(
192 JSON.stringify({ type: "pointer", phase: "click", x: 140, y: 50, sequence: 2 }),
193 );
194 expect(onPointer).toHaveBeenCalledExactlyOnceWith({
195 phase: "click",
196 x: 140,
197 y: 50,
198 sequence: 2,
199 });
200 });
201});