1import { afterEach, expect, it, vi } from "vite-plus/test";
2import { createDeviceStreamClient, type DeviceScreenSize } from "./stream.ts";
3afterEach(() => vi.unstubAllGlobals());
4
5it("switches to fixed authenticated feeds while retaining one HID socket, routes the active panel and rejects superseded decoded output", async () => {
6 const feeds: {
7 url: string;
8 signal: AbortSignal;
9 controller: ReadableStreamDefaultController<Uint8Array>;
10 }[] = [];
11 const outputs: VideoFrameOutputCallback[] = [];
12 const errors: VideoDecoderInit["error"][] = [];
13 let supported = true;
14 let panelHttpStatus = 200;
15 let expected = 0;
16 let decoderReady = () => {};
17 const waitDecoders = (count: number) => {
18 expected = count;
19 return new Promise<void>((resolve) => {
20 decoderReady = resolve;
21 });
22 };
23 class Decoder {
24 static isConfigSupported = async () => ({ supported });
25 static instances: Decoder[] = [];
26 state = "unconfigured";
27 constructor(options: VideoDecoderInit) {
28 outputs.push(options.output);
29 errors.push(options.error);
30 Decoder.instances.push(this);
31 if (outputs.length === expected) decoderReady();
32 }
33 configure() {
34 this.state = "configured";
35 }
36 close() {
37 this.state = "closed";
38 }
39 }
40 let socketReady = () => {};
41 const socketConstructed = new Promise<void>((resolve) => {
42 socketReady = resolve;
43 });
44 class Socket {
45 static OPEN = 1;
46 static instances: Socket[] = [];
47 readyState = 1;
48 binaryType = "arraybuffer";
49 onopen?: () => void;
50 onmessage?: (event: { data: ArrayBuffer }) => void;
51 onclose?: (event: { code: number; reason: string }) => void;
52 send = vi.fn();
53 close = vi.fn();
54 constructor() {
55 Socket.instances.push(this);
56 socketReady();
57 }
58 }
59 vi.stubGlobal("VideoDecoder", Decoder);
60 vi.stubGlobal("EncodedVideoChunk", vi.fn());
61 vi.stubGlobal("WebSocket", Socket);
62 vi.stubGlobal(
63 "fetch",
64 vi.fn(async (url: string, options: { signal: AbortSignal }) => {
65 if (!url.includes("avcc")) return new Response("prime");
66 if (url.includes("/panel/") && panelHttpStatus !== 200)
67 return new Response("unsupported panel", { status: panelHttpStatus });
68 const stream = new ReadableStream<Uint8Array>({
69 start(controller) {
70 feeds.push({ url, signal: options.signal, controller });
71 options.signal.addEventListener("abort", () =>
72 controller.error(new DOMException("Aborted", "AbortError")),
73 );
74 },
75 });
76 return new Response(stream);
77 }),
78 );
79 const present = vi.fn((_source: CanvasImageSource, _width: number, _height: number) => true),
80 cover = vi.fn((_source: CanvasImageSource, _width: number, _height: number) => true),
81 inner = vi.fn((_source: CanvasImageSource, _width: number, _height: number) => true);
82 let panelFailed = () => {};
83 const panelFailure = new Promise<void>((resolve) => {
84 panelFailed = resolve;
85 });
86 const onStatus = vi.fn();
87 const onDuoControl = vi.fn();
88 const onDuoUnavailable = vi.fn((_detail?: string) => panelFailed());
89 const client = createDeviceStreamClient(
90 {
91 platform: "ios",
92 deviceId: "duo",
93 access: {
94 httpBase: "https://t3.test/api/device-hub",
95 wsBase: "wss://t3.test/api/device-hub",
96 credentials: true,
97 query: { hostId: "remote", wsTicket: "ticket" },
98 },
99 },
100 { present },
101 {
102 onStatus,
103 onDuoControl,
104 onDuoUnavailable,
105 onScreen: vi.fn(),
106 onInputConnected: vi.fn(),
107 onUnauthorized: vi.fn(),
108 onMjpegFallback: vi.fn(),
109 },
110 );
111 client.start();
112 await socketConstructed;
113 const ws = Socket.instances[0]!;
114 ws.onmessage?.({ data: Uint8Array.of(0x83).buffer });
115 const config = (
116 id: number,
117 orientation: DeviceScreenSize["orientation"] = "portrait",
118 physical = false,
119 ) => {
120 const json = new TextEncoder().encode(
121 JSON.stringify({
122 width: id === 1 ? 1398 : 2007,
123 height: id === 1 ? 2034 : 2853,
124 orientation,
125 screenId: id,
126 supportsHingeAngle: true,
127 supportsPhysicalOrientation: physical,
128 hingePose: "open",
129 }),
130 );
131 const packet = new Uint8Array(json.length + 1);
132 packet[0] = 0x82;
133 packet.set(json, 1);
134 ws.onmessage?.({ data: packet.buffer });
135 };
136 config(3);
137 const requestedOrientation = () =>
138 JSON.parse(new TextDecoder().decode(ws.send.mock.lastCall?.[0].subarray(1))).orientation;
139 client.rotate();
140 expect(requestedOrientation()).toBe("landscape_left");
141 config(3); // An orientation-locked app keeps its framebuffer orientation after the sensor rotates.
142 expect(onDuoControl.mock.lastCall?.[0].error).toBeNull();
143 client.rotate();
144 expect(requestedOrientation()).toBe("portrait_upside_down");
145 config(3, "portrait_upside_down");
146 client.rotate();
147 expect(requestedOrientation()).toBe("landscape_right");
148 config(3); // An external native orientation change becomes authoritative again.
149 client.rotate();
150 expect(requestedOrientation()).toBe("landscape_left");
151 config(3, "landscape_left");
152 expect(onDuoControl.mock.lastCall?.[0].pending).toBe(false);
153 client.controlDuo({ control: "angle", value: 40 });
154 const angleRequest = JSON.parse(new TextDecoder().decode(ws.send.mock.lastCall?.[0].subarray(1)));
155 const before = ws.send.mock.calls.length;
156 client.controlDuo({ control: "orientation", value: "portrait" });
157 expect(ws.send.mock.calls.length).toBe(before);
158 config(3, "landscape_left"); // The angle's config precedes its receipt; it cannot acknowledge a queued rotation.
159 const reply = new TextEncoder().encode(
160 JSON.stringify({ requestId: angleRequest.requestId, ok: true }),
161 );
162 const receipt = new Uint8Array(reply.length + 1);
163 receipt[0] = 0x90;
164 receipt.set(reply, 1);
165 ws.onmessage?.({ data: receipt.buffer });
166 expect(requestedOrientation()).toBe("portrait");
167 expect(ws.send.mock.lastCall?.[0][0]).toBe(0x07);
168 client.controlDuo({ control: "angle", value: 55 });
169 const orientationSends = ws.send.mock.calls.length;
170 expect(onDuoControl.mock.lastCall?.[0].pending).toBe(true);
171 config(3);
172 expect(ws.send.mock.calls.length).toBe(orientationSends + 1);
173 const after = JSON.parse(new TextDecoder().decode(ws.send.mock.lastCall?.[0].subarray(1)));
174 expect(after.command).toEqual({ control: "angle", value: 55 });
175 const finalReply = new TextEncoder().encode(
176 JSON.stringify({ requestId: after.requestId, ok: true }),
177 );
178 const finalReceipt = new Uint8Array(finalReply.length + 1);
179 finalReceipt[0] = 0x90;
180 finalReceipt.set(finalReply, 1);
181 ws.onmessage?.({ data: finalReceipt.buffer });
182 expect(onDuoControl.mock.lastCall?.[0].pending).toBe(false);
183 const description = new Uint8Array([0, 0, 0, 5, 1, 1, 0x64, 0, 0x1f]);
184 let ready = waitDecoders(1);
185 feeds[0]!.controller.enqueue(description);
186 await ready;
187 client.setDuoPanels({ cover: { present: cover }, inner: { present: inner } });
188 expect(feeds[0]!.signal.aborted).toBe(true);
189 expect(Socket.instances).toHaveLength(1);
190 for (const [index, id] of [
191 [1, 1],
192 [2, 3],
193 ]) {
194 const url = new URL(feeds[index!]!.url);
195 expect(url.pathname.endsWith(`/panel/${id}/stream.avcc`)).toBe(true);
196 expect(url.searchParams.get("hostId")).toBe("remote");
197 expect(url.searchParams.get("wsTicket")).toBe("ticket");
198 }
199 ready = waitDecoders(3);
200 feeds[1]!.controller.enqueue(description);
201 feeds[2]!.controller.enqueue(description);
202 await ready;
203 const frame = {
204 displayWidth: 2007,
205 displayHeight: 2853,
206 close: vi.fn(),
207 } as unknown as VideoFrame;
208 outputs[0]!(frame);
209 expect(present).not.toHaveBeenCalled();
210 outputs[1]!(frame);
211 expect(cover).not.toHaveBeenCalled();
212 outputs[2]!(frame);
213 expect(inner).toHaveBeenCalledOnce();
214 expect(present).toHaveBeenCalledOnce();
215 config(1);
216 outputs[2]!(frame);
217 expect(inner).toHaveBeenCalledOnce();
218 outputs[1]!(frame);
219 expect(cover).toHaveBeenCalledOnce();
220 client.setDuoPanels(null);
221 expect(feeds[1]!.signal.aborted).toBe(true);
222 expect(feeds[2]!.signal.aborted).toBe(true);
223 expect(feeds[3]!.url).not.toContain("/panel/");
224 outputs[1]!(frame);
225 expect(cover).toHaveBeenCalledOnce();
226 ready = waitDecoders(4);
227 feeds[3]!.controller.enqueue(description);
228 await ready;
229 errors[0]!(new DOMException("Late decoder failure"));
230 expect(Decoder.instances[3]!.state).toBe("configured");
231 config(1, "portrait", true);
232 client.setDuoPanels({ cover: { present: cover }, inner: { present: inner } });
233 expect(feeds).toHaveLength(5); // One active feed replaces the two fixed feeds.
234 expect(feeds[4]!.url).not.toContain("/panel/");
235 expect(new URL(feeds[4]!.url).searchParams.get("hostId")).toBe("remote");
236 ready = waitDecoders(5);
237 feeds[4]!.controller.enqueue(description);
238 await ready;
239 const primary = { ...frame, displayWidth: 1398, displayHeight: 2034 } as VideoFrame;
240 const painted = present.mock.calls.length;
241 outputs[4]!(primary);
242 expect(present).toHaveBeenCalledTimes(painted + 1);
243 expect(present.mock.lastCall?.[0]).toBe(primary);
244 expect(cover).toHaveBeenCalledOnce(); // The main sink owns primary texture delivery.
245 config(3, "portrait", true);
246 expect(feeds[4]!.signal.aborted).toBe(true);
247 expect(feeds).toHaveLength(6);
248 ready = waitDecoders(6);
249 feeds[5]!.controller.enqueue(description);
250 await ready;
251 outputs[4]!(primary); // The old elected display cannot paint after handoff.
252 expect(present).toHaveBeenCalledTimes(painted + 1);
253 outputs[5]!(frame);
254 expect(present).toHaveBeenCalledTimes(painted + 2);
255 expect(present.mock.lastCall?.[0]).toBe(frame);
256 config(3, "portrait", true);
257 expect(feeds).toHaveLength(6); // Duplicate native readback does not reconnect.
258 supported = false;
259 client.setDuoPanels(null);
260 client.setDuoPanels({ cover: { present: cover }, inner: { present: inner } });
261 expect(feeds[7]!.url).not.toContain("/panel/");
262 feeds[7]!.controller.enqueue(description);
263 await panelFailure;
264 expect(onDuoUnavailable.mock.lastCall?.[0]).toContain("cannot decode");
265 config(3);
266 const fixedPanelFailure = new Promise<void>((resolve) => {
267 panelFailed = resolve;
268 });
269 client.setDuoPanels({ cover: { present: cover }, inner: { present: inner } });
270 feeds[8]!.controller.enqueue(description);
271 await fixedPanelFailure;
272 expect(onDuoUnavailable).toHaveBeenCalled();
273 expect(onStatus.mock.calls.some(([status]) => status === "error")).toBe(false);
274 panelHttpStatus = 404;
275 const missingPanel = new Promise<void>((resolve) => {
276 panelFailed = resolve;
277 });
278 client.setDuoPanels({ cover: { present: cover }, inner: { present: inner } });
279 await missingPanel;
280 expect(onDuoUnavailable.mock.lastCall?.[0]).toContain("does not provide fixed Duo");
281 expect(Socket.instances).toHaveLength(1);
282 client.stop();
283 expect(feeds[3]!.signal.aborted).toBe(true);
284 expect(ws.close).toHaveBeenCalledOnce();
285 expect(frame.close).toHaveBeenCalledTimes(9);
286});