Skip to content
Source
Sign inSign up

serverBrowserStream.test.ts

193 lines6.0 KB

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 }
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("does not grant control from malformed dialog metadata", () => {
67 const { client, socket, onControl } = connect();
68 socket.message(
69 JSON.stringify({
70 type: "control",
71 ...agent,
72 controller: "you",
73 dialog: { type: "prompt", message: 42, defaultValue: "" },
74 }),
75 );
76 expect(onControl).not.toHaveBeenCalled();
77 expect(client.send({ type: "reload" })).toBe(false);
78 client.stop();
79 });
80​
81 it("keeps read-only frames flowing while refusing takeover and every mutation", () => {
82 const { client, socket, onFrame } = connect();
83 socket.control({ ...agent, canOperate: false });
84 const frame = new ArrayBuffer(3);
85 socket.message(frame);
86 expect(onFrame).toHaveBeenCalledWith(frame);
87 expect(client.send({ type: "takeControl" })).toBe(false);
88 expect(client.send({ type: "resize", width: 390, height: 844 })).toBe(false);
89 expect(client.send({ type: "reload" })).toBe(false);
90 expect(socket.sent).toEqual(['{"type":"ack"}']);
91 client.stop();
92 });
93​
94 it("revokes input immediately on release and exposes pending dialog state", () => {
95 const { client, socket, onControl } = connect();
96 const owned = {
97 ...agent,
98 controller: "you" as const,
99 dialog: { type: "prompt", message: "Name?", defaultValue: "initial" },
100 };
101 socket.control(owned);
102 expect(onControl).toHaveBeenLastCalledWith(owned);
103 expect(client.send({ type: "dialog", accept: true, promptText: "answer" })).toBe(true);
104 expect(client.send({ type: "releaseControl" })).toBe(true);
105 socket.control({ ...agent, generation: 2 });
106 expect(client.send({ type: "text", text: "late input" })).toBe(false);
107 expect(client.send({ type: "dialog", accept: false })).toBe(false);
108 client.stop();
109 });
110​
111 it("withdraws an offered file picker when the stream drops or stops", () => {
112 const onFileChooser = vi.fn();
113 const events = {
114 onFrame: vi.fn(),
115 onFileChooser,
116 onViewport: vi.fn(),
117 onConnectedChange: vi.fn(),
118 onUnauthorized: vi.fn(),
119 };
120 const client = createPreviewStreamClient(target, events);
121 const socket = FakeSocket.current;
122 socket.dispatchEvent(new Event("open"));
123 socket.message(JSON.stringify({ type: "fileChooser", id: "c1", multiple: false, accept: "" }));
124 socket.dispatchEvent(Object.assign(new Event("close"), { code: 1012 }));
125 expect(onFileChooser).toHaveBeenLastCalledWith(null);
126 client.stop();
127​
128 const restarted = createPreviewStreamClient(target, events);
129 FakeSocket.current.message(
130 JSON.stringify({ type: "fileChooser", id: "c2", multiple: false, accept: "" }),
131 );
132 restarted.stop();
133 expect(onFileChooser).toHaveBeenLastCalledWith(null);
134 });
135});
136​
137describe("preview stream downloads", () => {
138 beforeEach(() => vi.stubGlobal("WebSocket", FakeSocket));
139 afterEach(() => vi.unstubAllGlobals());
140​
141 it("offers a download at a URL carrying the stream's ticket", () => {
142 const onDownload = vi.fn();
143 createPreviewStreamClient(
144 {
145 ...target,
146 access: { ...target.access, query: { wsTicket: "ticket" }, credentials: false },
147 },
148 {
149 onFrame: vi.fn(),
150 onDownload,
151 onViewport: vi.fn(),
152 onConnectedChange: vi.fn(),
153 onUnauthorized: vi.fn(),
154 },
155 );
156 FakeSocket.current.message(
157 JSON.stringify({ type: "download", id: "d1", fileName: "a b.csv", sizeBytes: 3 }),
158 );
159 expect(onDownload).toHaveBeenCalledExactlyOnceWith({
160 fileName: "a b.csv",
161 sizeBytes: 3,
162 url: "http://preview.test/api/preview-stream/download?threadId=thread&tabId=tab&id=d1&wsTicket=ticket",
163 });
164 });
165});
166​
167describe("preview stream agent pointer", () => {
168 beforeEach(() => vi.stubGlobal("WebSocket", FakeSocket));
169 afterEach(() => vi.unstubAllGlobals());
170​
171 it("reports where the agent moves and clicks, ignoring malformed points", () => {
172 const onPointer = vi.fn();
173 createPreviewStreamClient(target, {
174 onFrame: vi.fn(),
175 onPointer,
176 onViewport: vi.fn(),
177 onConnectedChange: vi.fn(),
178 onUnauthorized: vi.fn(),
179 });
180 FakeSocket.current.message(
181 JSON.stringify({ type: "pointer", phase: "drag", x: 1, y: 2, sequence: 1 }),
182 );
183 FakeSocket.current.message(
184 JSON.stringify({ type: "pointer", phase: "click", x: 140, y: 50, sequence: 2 }),
185 );
186 expect(onPointer).toHaveBeenCalledExactlyOnceWith({
187 phase: "click",
188 x: 140,
189 y: 50,
190 sequence: 2,
191 });
192 });
193});