1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 createDeviceStreamClient,
5 AvccDemuxer,
6 avcCodecString,
7 parseSemuPacket,
8 scanAccessUnit,
9} from "./stream.ts";
10
11const envelope = (tag: number, payload: number[]) => {
12 const length = 1 + payload.length;
13 return [
14 (length >>> 24) & 0xff,
15 (length >>> 16) & 0xff,
16 (length >>> 8) & 0xff,
17 length & 0xff,
18 tag,
19 ...payload,
20 ];
21};
22
23describe("AvccDemuxer", () => {
24 it("reassembles envelopes split across reads", () => {
25 const demuxer = new AvccDemuxer();
26 const bytes = new Uint8Array([
27 ...envelope(1, [1, 0x64, 0x00, 0x1f]),
28 ...envelope(2, [9, 9, 9]),
29 ...envelope(0x7f, [0]),
30 ...envelope(3, [4]),
31 ]);
32 const first = demuxer.push(bytes.subarray(0, 7));
33 const rest = demuxer.push(bytes.subarray(7));
34 const chunks = [...first, ...rest];
35 expect(chunks.map((chunk) => chunk.type)).toEqual(["description", "keyframe", "delta"]);
36 expect(Array.from(chunks[0]!.payload)).toEqual([1, 0x64, 0x00, 0x1f]);
37 expect(Array.from(chunks[1]!.payload)).toEqual([9, 9, 9]);
38 });
39
40 it("derives the WebCodecs codec string from the avcC record", () => {
41 expect(avcCodecString(new Uint8Array([1, 0x64, 0x00, 0x1f]))).toBe("avc1.64001f");
42 expect(avcCodecString(new Uint8Array([1]))).toBe("avc1.42E01E");
43 });
44});
45
46describe("native device stream transport", () => {
47 afterEach(() => {
48 vi.useRealTimers();
49 vi.unstubAllGlobals();
50 });
51
52 function setup(platform: "ios" | "android") {
53 vi.useFakeTimers();
54 vi.stubGlobal("VideoDecoder", vi.fn());
55 vi.stubGlobal("EncodedVideoChunk", vi.fn());
56 let resolveOpened!: (socket: FakeSocket) => void;
57 const waitForSocket = () =>
58 new Promise<FakeSocket>((resolve) => {
59 resolveOpened = resolve;
60 });
61 const opened = waitForSocket();
62 class FakeSocket {
63 static OPEN = 1;
64 readyState = 1;
65 binaryType = "";
66 onopen: (() => void) | null = null;
67 onmessage: ((event: { data: ArrayBuffer }) => void) | null = null;
68 onclose: ((event: { code: number; reason: string }) => void) | null = null;
69 send = vi.fn();
70 close = vi.fn();
71 readonly url: string;
72 constructor(url: string) {
73 this.url = url;
74 resolveOpened(this);
75 }
76 }
77 vi.stubGlobal("WebSocket", FakeSocket);
78 const fetch = vi.fn(() => Promise.resolve(new Response("frame")));
79 vi.stubGlobal("fetch", fetch);
80 const events = {
81 onStatus: vi.fn(),
82 onScreen: vi.fn(),
83 onUnauthorized: vi.fn(),
84 onMjpegFallback: vi.fn(),
85 onInputConnected: vi.fn(),
86 };
87 const client = createDeviceStreamClient(
88 {
89 platform,
90 deviceId: "test device",
91 preferMjpeg: platform === "ios",
92 access: {
93 httpBase: "https://environment.test/api/device-hub",
94 wsBase: "wss://environment.test/api/device-hub",
95 credentials: false,
96 query: { wsTicket: "stream-ticket", hostId: "ssh-host" },
97 },
98 },
99 { getContext: () => null } as unknown as HTMLCanvasElement,
100 events,
101 );
102 return { client, opened, waitForSocket, events, fetch };
103 }
104
105 it("uses authenticated iOS MJPEG and forwards controls without a cross-origin video fetch", async () => {
106 const { client, opened, events, fetch } = setup("ios");
107 client.start();
108 const socket = await opened;
109 const imageUrl = new URL(events.onMjpegFallback.mock.calls[0]![0] as string);
110 expect(imageUrl.pathname).toContain("test%20device/stream.mjpeg");
111 expect(imageUrl.searchParams.get("wsTicket")).toBe("stream-ticket");
112 expect(imageUrl.searchParams.get("hostId")).toBe("ssh-host");
113 expect(fetch).toHaveBeenCalledTimes(1);
114 socket.onopen?.();
115 expect(socket.send).not.toHaveBeenCalled();
116 socket.onmessage?.({ data: Uint8Array.of(0x83).buffer });
117 client.setOrientation("landscape_right");
118 client.pressButton("home");
119 client.sendTouch("begin", 0.25, 0.75);
120 const messages = socket.send.mock.calls.map(([bytes]) => {
121 const packet = bytes as Uint8Array;
122 return {
123 tag: packet[0],
124 body: JSON.parse(new TextDecoder().decode(packet.subarray(1))) as unknown,
125 };
126 });
127 expect(messages).toEqual([
128 { tag: 0x07, body: { orientation: "landscape_right" } },
129 { tag: 0x04, body: { button: "home" } },
130 { tag: 0x03, body: { type: "begin", x: 0.25, y: 0.75 } },
131 ]);
132 client.stop();
133 expect(socket.close).toHaveBeenCalledTimes(1);
134 expect(vi.getTimerCount()).toBe(0);
135 });
136
137 it("forwards Android gestures and device controls through the ticketed host socket", async () => {
138 const { client, opened } = setup("android");
139 client.start();
140 const socket = await opened;
141 const url = new URL(socket.url);
142 expect(url.searchParams.get("device")).toBe("test device");
143 expect(url.searchParams.get("wsTicket")).toBe("stream-ticket");
144 expect(url.searchParams.get("hostId")).toBe("ssh-host");
145 client.sendTouch("begin", 0.2, 0.8);
146 client.sendTouch("move", 0.2, 0.4);
147 client.sendTouch("end", 0.2, 0.1);
148 client.setOrientation("landscape_right");
149 client.pressButton("home");
150 client.pressButton("appSwitcher");
151 expect(
152 socket.send.mock.calls.map(([message]) => JSON.parse(message as string) as unknown),
153 ).toEqual([
154 { type: "touch", action: "down", x: 0.2, y: 0.8 },
155 { type: "touch", action: "move", x: 0.2, y: 0.4 },
156 { type: "touch", action: "up", x: 0.2, y: 0.1 },
157 { type: "home" },
158 { type: "recents" },
159 ]);
160 client.stop();
161 expect(socket.close).toHaveBeenCalledTimes(1);
162 });
163
164 it("renews an expired Android ticket when the HTTP upgrade is rejected instead of retrying it forever", async () => {
165 const { client, opened, events } = setup("android");
166 client.start();
167 const socket = await opened;
168 socket.onclose?.({ code: 1006, reason: "" });
169 expect(events.onUnauthorized).toHaveBeenCalledTimes(1);
170 expect(vi.getTimerCount()).toBe(0);
171 client.stop();
172 });
173
174 it.each(["ios", "android"] as const)(
175 "keeps a restarted %s stream connected when the discarded socket closes late",
176 async (platform) => {
177 const { client, opened, waitForSocket, events } = setup(platform);
178 client.start();
179 const discarded = await opened;
180 client.stop();
181 const reopened = waitForSocket();
182 client.start();
183 const replacement = await reopened;
184 replacement.onopen?.();
185 if (platform === "ios") replacement.onmessage?.({ data: Uint8Array.of(0x83).buffer });
186 replacement.send.mockClear();
187 events.onInputConnected.mockClear();
188
189 discarded.onclose?.({ code: 1006, reason: "" });
190
191 expect(events.onUnauthorized).not.toHaveBeenCalled();
192 expect(events.onInputConnected).not.toHaveBeenCalled();
193 expect(replacement.close).not.toHaveBeenCalled();
194 client.pressButton("home");
195 expect(replacement.send).toHaveBeenCalledTimes(1);
196 client.stop();
197 expect(replacement.close).toHaveBeenCalledTimes(1);
198 expect(vi.getTimerCount()).toBe(0);
199 },
200 );
201
202 it.each(["ios", "android"] as const)(
203 "does not renew credentials or reconnect a stopped %s stream",
204 async (platform) => {
205 const { client, opened, events } = setup(platform);
206 client.start();
207 const socket = await opened;
208 events.onInputConnected.mockClear();
209 client.stop();
210 socket.onclose?.({ code: 1006, reason: "" });
211 expect(events.onUnauthorized).not.toHaveBeenCalled();
212 expect(events.onInputConnected).not.toHaveBeenCalled();
213 expect(vi.getTimerCount()).toBe(0);
214 },
215 );
216});
217
218describe("serve-emu frames", () => {
219 it("strips the SEMU header and reads the keyframe flag and timestamp", () => {
220 const buffer = new ArrayBuffer(16 + 3);
221 const view = new DataView(buffer);
222 view.setUint32(0, 0x53454d55);
223 view.setUint8(4, 1);
224 view.setUint8(5, 1);
225 view.setBigUint64(8, 123456n);
226 new Uint8Array(buffer).set([7, 8, 9], 16);
227 const packet = parseSemuPacket(buffer);
228 expect(packet.isKey).toBe(true);
229 expect(packet.timestamp).toBe(123456);
230 expect(Array.from(packet.data)).toEqual([7, 8, 9]);
231 });
232
233 it("treats a frame without the header as raw data", () => {
234 const packet = parseSemuPacket(new Uint8Array([0, 0, 1, 0x65]).buffer);
235 expect(packet.isKey).toBeNull();
236 expect(packet.data.length).toBe(4);
237 });
238
239 it("finds the SPS and IDR NAL units in an Annex-B access unit", () => {
240 const unit = new Uint8Array([0, 0, 0, 1, 0x67, 0x64, 0x00, 0x1f, 0, 0, 1, 0x65, 0xaa]);
241 const scanned = scanAccessUnit(unit);
242 expect(scanned.isKey).toBe(true);
243 expect(scanned.sps && avcCodecString(scanned.sps)).toBe("avc1.64001f");
244 expect(scanAccessUnit(new Uint8Array([0, 0, 1, 0x41, 0x00])).isKey).toBe(false);
245 });
246});
247
248describe("iOS input startup", () => {
249 afterEach(() => {
250 vi.useRealTimers();
251 vi.unstubAllGlobals();
252 });
253
254 const setup = () => {
255 vi.useFakeTimers();
256 const sockets: FakeSocket[] = [];
257 class FakeSocket {
258 static OPEN = 1;
259 readyState = 1;
260 binaryType = "";
261 onopen: (() => void) | null = null;
262 onmessage: ((event: { data: ArrayBuffer }) => void) | null = null;
263 onclose: ((event: { code: number; reason: string }) => void) | null = null;
264 send = vi.fn();
265 close = vi.fn();
266 constructor() {
267 sockets.push(this);
268 }
269 }
270 vi.stubGlobal("WebSocket", FakeSocket);
271 const signals: AbortSignal[] = [];
272 vi.stubGlobal(
273 "fetch",
274 vi.fn((_url, init: RequestInit) => {
275 const signal = init.signal!;
276 signals.push(signal);
277 return Promise.resolve(
278 new Response(
279 new ReadableStream({
280 start(controller) {
281 signal.addEventListener("abort", () => controller.error(new Error("aborted")));
282 },
283 }),
284 ),
285 );
286 }),
287 );
288 const client = createDeviceStreamClient(
289 {
290 platform: "ios",
291 deviceId: "test-device",
292 access: {
293 httpBase: "http://test/api/device-hub",
294 wsBase: "ws://test/api/device-hub",
295 credentials: true,
296 query: {},
297 },
298 },
299 { getContext: () => null } as unknown as HTMLCanvasElement,
300 {
301 onStatus: vi.fn(),
302 onScreen: vi.fn(),
303 onUnauthorized: vi.fn(),
304 onMjpegFallback: vi.fn(),
305 onInputConnected: vi.fn(),
306 },
307 );
308 return { client, sockets, signals };
309 };
310
311 it("connects input when the MJPEG prime never produces a frame", async () => {
312 const { client, sockets, signals } = setup();
313 client.start();
314 await vi.advanceTimersByTimeAsync(2_000);
315 expect(signals[0]?.aborted).toBe(true);
316 expect(sockets).toHaveLength(1);
317 client.stop();
318 });
319
320 it("aborts priming immediately when hidden without opening a socket later", async () => {
321 const { client, sockets, signals } = setup();
322 client.start();
323 await vi.advanceTimersByTimeAsync(0);
324 client.stop();
325 expect(signals[0]?.aborted).toBe(true);
326 await vi.advanceTimersByTimeAsync(2_000);
327 expect(sockets).toHaveLength(0);
328 });
329
330 it.each([
331 ["landscape_left", 0.7, 1 - 0.2],
332 ["landscape_right", 1 - 0.7, 0.2],
333 ])("maps %s touches back to the raw iOS framebuffer", async (orientation, x, y) => {
334 const { client, sockets } = setup();
335 client.start();
336 await vi.advanceTimersByTimeAsync(2_000);
337 const socket = sockets[0]!;
338 const json = new TextEncoder().encode(JSON.stringify({ width: 400, height: 800, orientation }));
339 const packet = new Uint8Array(1 + json.length);
340 packet[0] = 0x82;
341 packet.set(json, 1);
342 socket.onmessage?.({ data: packet.buffer });
343 socket.onmessage?.({ data: Uint8Array.of(0x83).buffer });
344 client.sendTouch("begin", 0.2, 0.7);
345 const sent = socket.send.mock.calls[0]![0] as Uint8Array;
346 expect(JSON.parse(new TextDecoder().decode(sent.subarray(1)))).toEqual({
347 type: "begin",
348 x,
349 y,
350 });
351 client.stop();
352 });
353});
354
355class MjpegImage extends EventTarget {
356 naturalWidth = 0;
357 naturalHeight = 0;
358 src = "";
359 removeAttribute(name: string) {
360 if (name === "src") {
361 this.src = "";
362 this.naturalWidth = 0;
363 this.naturalHeight = 0;
364 }
365 }
366}
367
368function recoveryFixture(platform: "ios" | "android" = "ios", preferMjpeg = true) {
369 vi.useFakeTimers();
370 const sockets: Socket[] = [];
371 class Socket {
372 static OPEN = 1;
373 readyState = 1;
374 binaryType = "";
375 onopen: (() => void) | null = null;
376 onmessage: ((event: { data: ArrayBuffer | string }) => void) | null = null;
377 onclose: ((event: { code: number; reason: string }) => void) | null = null;
378 send = vi.fn();
379 close = vi.fn();
380 constructor() {
381 sockets.push(this);
382 }
383 }
384 vi.stubGlobal("WebSocket", Socket);
385 const decoders: Decoder[] = [];
386 class Decoder {
387 static isConfigSupported = vi.fn(async () => ({ supported: true }));
388 state = "unconfigured";
389 decodeQueueSize = 0;
390 configure() {
391 this.state = "configured";
392 }
393 reset = vi.fn(() => {
394 this.state = "unconfigured";
395 this.decodeQueueSize = 0;
396 });
397 close() {
398 this.state = "closed";
399 }
400 decode = vi.fn();
401 readonly callbacks: { output: (frame: VideoFrame) => void; error: () => void };
402 constructor(callbacks: { output: (frame: VideoFrame) => void; error: () => void }) {
403 this.callbacks = callbacks;
404 decoders.push(this);
405 }
406 }
407 vi.stubGlobal("VideoDecoder", Decoder);
408 vi.stubGlobal("EncodedVideoChunk", vi.fn());
409 const videoBodies: ReadableStreamDefaultController<Uint8Array>[] = [];
410 const signals: AbortSignal[] = [];
411 const fetch = vi.fn((url: string, init: RequestInit) => {
412 const signal = init.signal!;
413 signals.push(signal);
414 if (!url.includes("stream.avcc")) return Promise.resolve(new Response("prime"));
415 return Promise.resolve(
416 new Response(
417 new ReadableStream<Uint8Array>({
418 start(body) {
419 videoBodies.push(body);
420 signal.addEventListener("abort", () => body.error(new Error("aborted")), {
421 once: true,
422 });
423 },
424 }),
425 ),
426 );
427 });
428 vi.stubGlobal("fetch", fetch);
429 const events = {
430 onStatus: vi.fn(),
431 onScreen: vi.fn(),
432 onUnauthorized: vi.fn(),
433 onMjpegFallback: vi.fn(),
434 onInputConnected: vi.fn(),
435 };
436 const drawImage = vi.fn();
437 const image = new MjpegImage();
438 const client = createDeviceStreamClient(
439 {
440 platform,
441 preferMjpeg,
442 deviceId: "test-device",
443 access: {
444 httpBase: "https://test/api/device-hub",
445 wsBase: "wss://test/api/device-hub",
446 credentials: true,
447 query: {},
448 },
449 },
450 { getContext: () => ({ drawImage }) } as unknown as HTMLCanvasElement,
451 events,
452 );
453 client.setMjpegImage(image as unknown as HTMLImageElement);
454 const imageReady = () => {
455 image.naturalWidth = 400;
456 image.naturalHeight = 800;
457 image.dispatchEvent(new Event("load"));
458 };
459 const decodedFrame = (index = decoders.length - 1) => {
460 const close = vi.fn();
461 decoders[index]!.callbacks.output({
462 displayWidth: 400,
463 displayHeight: 800,
464 close,
465 } as unknown as VideoFrame);
466 return close;
467 };
468 const sps = new Uint8Array([0, 0, 0, 1, 0x67, 0x64, 0, 0x1f, 0, 0, 1, 0x65]).buffer;
469 return {
470 client,
471 events,
472 image,
473 imageReady,
474 sockets,
475 signals,
476 fetch,
477 videoBodies,
478 decoders,
479 Decoder,
480 drawImage,
481 decodedFrame,
482 sps,
483 };
484}
485
486describe("shared device stream readiness and recovery", () => {
487 afterEach(() => {
488 vi.useRealTimers();
489 vi.unstubAllGlobals();
490 });
491
492 it("waits for iOS input admission and respects native input loss and recovery", async () => {
493 const { client, sockets, events } = recoveryFixture();
494 client.start();
495 await vi.advanceTimersByTimeAsync(0);
496 const socket = sockets[0]!;
497 socket.onopen?.();
498 expect(events.onInputConnected).not.toHaveBeenCalledWith(true);
499 socket.onmessage?.({ data: new Uint8Array([0x83]).buffer });
500 expect(events.onInputConnected).toHaveBeenLastCalledWith(true);
501 const config = (inputUnavailable: boolean) => {
502 const json = new TextEncoder().encode(
503 JSON.stringify({ width: 400, height: 800, orientation: "portrait", inputUnavailable }),
504 );
505 const packet = new Uint8Array(1 + json.length);
506 packet[0] = 0x82;
507 packet.set(json, 1);
508 socket.onmessage?.({ data: packet.buffer });
509 };
510 config(true);
511 expect(events.onInputConnected).toHaveBeenLastCalledWith(
512 false,
513 "Simulator input is unavailable",
514 );
515 socket.send.mockClear();
516 client.sendTouch("begin", 0.2, 0.3);
517 client.pressButton("home");
518 expect(socket.send).not.toHaveBeenCalled();
519 config(false);
520 expect(events.onInputConnected).toHaveBeenLastCalledWith(true, undefined);
521 client.sendTouch("begin", 0.2, 0.3);
522 expect(socket.send).toHaveBeenCalledOnce();
523 client.stop();
524 expect(vi.getTimerCount()).toBe(0);
525 });
526
527 it("requires admission on each iOS socket and does not mistake config for admission", async () => {
528 const { client, sockets, events, imageReady } = recoveryFixture();
529 client.start();
530 await vi.advanceTimersByTimeAsync(0);
531 imageReady();
532 const config = (socket: (typeof sockets)[number], unavailable: boolean) => {
533 const json = new TextEncoder().encode(
534 JSON.stringify({
535 width: 400,
536 height: 800,
537 orientation: "portrait",
538 inputUnavailable: unavailable,
539 supportsHingeAngle: true,
540 }),
541 );
542 socket.onmessage?.({ data: Uint8Array.of(0x82, ...json).buffer });
543 };
544 const controls = () => {
545 client.sendTouch("begin", 0.2, 0.3);
546 client.pressButton("home");
547 client.sendKey({ code: "KeyA" } as KeyboardEvent, "down");
548 client.setOrientation("landscape_left");
549 client.controlDuo({ control: "angle", value: 90 });
550 };
551 const first = sockets[0]!;
552 config(first, false);
553 controls();
554 expect(first.send).not.toHaveBeenCalled();
555 expect(events.onInputConnected).not.toHaveBeenCalledWith(true, undefined);
556 config(first, true);
557 first.onmessage?.({ data: Uint8Array.of(0x83).buffer });
558 controls();
559 expect(first.send).not.toHaveBeenCalled();
560 expect(events.onInputConnected).toHaveBeenLastCalledWith(false);
561 config(first, false);
562 client.pressButton("home");
563 expect(first.send).toHaveBeenCalled();
564 first.onclose?.({ code: 1013, reason: "retry" });
565 await vi.advanceTimersByTimeAsync(1_000);
566 const replacement = sockets[1]!;
567 config(replacement, false);
568 controls();
569 expect(replacement.send).not.toHaveBeenCalled();
570 first.onmessage?.({ data: Uint8Array.of(0x83).buffer });
571 expect(events.onInputConnected).toHaveBeenLastCalledWith(false, undefined);
572 replacement.onmessage?.({ data: Uint8Array.of(0x83).buffer });
573 client.pressButton("home");
574 expect(replacement.send).toHaveBeenCalledOnce();
575 client.stop();
576 expect(vi.getTimerCount()).toBe(0);
577 });
578
579 it("reopens a backlogged iOS feed for a fresh keyframe while keeping input connected", async () => {
580 const { client, videoBodies, decoders, events, decodedFrame, signals, sockets, drawImage } =
581 recoveryFixture("ios", false);
582 client.start();
583 await vi.advanceTimersByTimeAsync(0);
584 const socket = sockets[0]!;
585 socket.onmessage?.({ data: Uint8Array.of(0x83).buffer });
586 const body = videoBodies[0]!;
587 body.enqueue(new Uint8Array([...envelope(1, [1, 0x64, 0, 0x1f]), ...envelope(2, [1])]));
588 await vi.advanceTimersByTimeAsync(0);
589 decodedFrame();
590 const decoder = decoders[0]!;
591 decoder.decodeQueueSize = 9;
592 body.enqueue(new Uint8Array([...envelope(3, [2]), ...envelope(3, [3])]));
593 await vi.advanceTimersByTimeAsync(0);
594 expect(signals[1]!.aborted).toBe(true);
595 expect(decoder.state).toBe("closed");
596 expect(events.onStatus).toHaveBeenLastCalledWith(
597 "connecting",
598 expect.stringContaining("fell behind"),
599 );
600 expect(socket.close).not.toHaveBeenCalled();
601 client.sendTouch("begin", 0.2, 0.3);
602 expect(socket.send).toHaveBeenCalledOnce();
603 await vi.advanceTimersByTimeAsync(1_000);
604 expect(videoBodies).toHaveLength(2);
605 // The old feed never produces another IDR. Only a fresh subscription can recover.
606 videoBodies[1]!.enqueue(
607 new Uint8Array([...envelope(1, [1, 0x64, 0, 0x1f]), ...envelope(2, [4])]),
608 );
609 await vi.advanceTimersByTimeAsync(0);
610 expect(decoders[1]!.decode).toHaveBeenCalledOnce();
611 drawImage.mockClear();
612 decodedFrame(0);
613 expect(drawImage).not.toHaveBeenCalled();
614 decodedFrame();
615 expect(drawImage).toHaveBeenCalledOnce();
616 expect(events.onMjpegFallback).not.toHaveBeenCalled();
617 expect(events.onStatus).toHaveBeenLastCalledWith("streaming", undefined);
618 expect(events.onInputConnected).toHaveBeenLastCalledWith(true);
619 client.stop();
620 expect(vi.getTimerCount()).toBe(0);
621 });
622
623 it("does not repaint a delayed JPEG seed over newer decoded video", async () => {
624 const { client, videoBodies, drawImage, decodedFrame } = recoveryFixture("ios", false);
625 let resolveBitmap!: (bitmap: ImageBitmap) => void;
626 vi.stubGlobal(
627 "createImageBitmap",
628 () =>
629 new Promise((resolve) => {
630 resolveBitmap = resolve;
631 }),
632 );
633 client.start();
634 await vi.advanceTimersByTimeAsync(0);
635 videoBodies[0]!.enqueue(
636 new Uint8Array([
637 ...envelope(4, [1]),
638 ...envelope(1, [1, 0x64, 0, 0x1f]),
639 ...envelope(2, [2]),
640 ]),
641 );
642 await vi.advanceTimersByTimeAsync(0);
643 decodedFrame();
644 const close = vi.fn();
645 resolveBitmap({ width: 400, height: 800, close } as unknown as ImageBitmap);
646 await vi.advanceTimersByTimeAsync(0);
647 expect(drawImage).toHaveBeenCalledOnce();
648 expect(close).toHaveBeenCalledOnce();
649 client.stop();
650 });
651
652 it("waits for actual MJPEG dimensions without requiring a multipart load event", async () => {
653 const { client, events, image } = recoveryFixture();
654 client.start();
655 await vi.advanceTimersByTimeAsync(0);
656 expect(image.src).toContain("stream.mjpeg");
657 expect(events.onStatus).not.toHaveBeenCalledWith("streaming", undefined);
658 image.naturalWidth = 400;
659 image.naturalHeight = 800;
660 await vi.advanceTimersByTimeAsync(250);
661 expect(events.onStatus).toHaveBeenLastCalledWith("streaming", undefined);
662 expect(vi.getTimerCount()).toBe(0);
663 await vi.advanceTimersByTimeAsync(20_000);
664 expect(events.onStatus).toHaveBeenLastCalledWith("streaming", undefined);
665 client.stop();
666 });
667
668 it("observes a web image attached after MJPEG fallback has already begun", async () => {
669 const { client, image, imageReady, events } = recoveryFixture();
670 client.setMjpegImage(null);
671 client.start();
672 expect(image.src).toBe("");
673 await vi.advanceTimersByTimeAsync(1000);
674 client.setMjpegImage(image as unknown as HTMLImageElement);
675 imageReady();
676 expect(events.onStatus).toHaveBeenLastCalledWith("streaming", undefined);
677 client.stop();
678 expect(image.src).toBe("");
679 expect(vi.getTimerCount()).toBe(0);
680 });
681
682 it("stops input and video on an image error without treating it as expired credentials", async () => {
683 const { client, image, imageReady, sockets, events } = recoveryFixture();
684 client.start();
685 await vi.advanceTimersByTimeAsync(0);
686 sockets[0]!.onopen?.();
687 imageReady();
688 image.dispatchEvent(new Event("error"));
689 expect(events.onStatus).toHaveBeenLastCalledWith("error", expect.stringContaining("Reconnect"));
690 expect(events.onInputConnected).toHaveBeenLastCalledWith(false, expect.any(String));
691 expect(events.onUnauthorized).not.toHaveBeenCalled();
692 expect(sockets[0]!.close).toHaveBeenCalledOnce();
693 expect(image.src).toBe("");
694 client.pressButton("home");
695 expect(sockets[0]!.send).not.toHaveBeenCalled();
696 expect(vi.getTimerCount()).toBe(0);
697 });
698
699 it.each([
700 ["ios", true],
701 ["ios", false],
702 ["android", false],
703 ] as const)(
704 "bounds missing first frames for %s with preferMjpeg=%s",
705 async (platform, preferMjpeg) => {
706 const { client, events, signals, sockets, image } = recoveryFixture(platform, preferMjpeg);
707 client.start();
708 await vi.advanceTimersByTimeAsync(14_999);
709 expect(events.onStatus.mock.calls.some(([status]) => status === "error")).toBe(false);
710 await vi.advanceTimersByTimeAsync(1);
711 expect(events.onStatus).toHaveBeenLastCalledWith(
712 "error",
713 expect.stringContaining("No video"),
714 );
715 expect(events.onInputConnected).toHaveBeenLastCalledWith(false, expect.any(String));
716 expect(signals.every((signal) => signal.aborted)).toBe(true);
717 expect(sockets[0]!.close).toHaveBeenCalledOnce();
718 expect(image.src).toBe("");
719 expect(vi.getTimerCount()).toBe(0);
720 },
721 );
722
723 it("does not extend the first-frame deadline each time a socket reconnects", async () => {
724 const { client, sockets, events } = recoveryFixture("android");
725 client.start();
726 sockets[0]!.onopen?.();
727 sockets[0]!.onclose?.({ code: 1000, reason: "restart" });
728 await vi.advanceTimersByTimeAsync(14_999);
729 sockets[1]!.onopen?.();
730 await vi.advanceTimersByTimeAsync(1);
731 expect(events.onStatus).toHaveBeenLastCalledWith("error", expect.stringContaining("No video"));
732 expect(sockets[1]!.close).toHaveBeenCalledOnce();
733 expect(vi.getTimerCount()).toBe(0);
734 });
735
736 it("gives a manual restart a fresh deadline and ignores queued image events from its old binding", async () => {
737 const { client, image, events } = recoveryFixture();
738 const listeners: EventListener[] = [];
739 const add = image.addEventListener.bind(image);
740 vi.spyOn(image, "addEventListener").mockImplementation((name, callback, options) => {
741 if (typeof callback === "function") listeners.push(callback);
742 add(name, callback, options);
743 });
744 client.start();
745 await vi.advanceTimersByTimeAsync(10_000);
746 client.stop();
747 client.start();
748 events.onStatus.mockClear();
749 for (const listener of listeners.slice(0, 2)) listener(new Event("error"));
750 await vi.advanceTimersByTimeAsync(10_000);
751 expect(events.onStatus).not.toHaveBeenCalled();
752 const replacement = new MjpegImage();
753 client.setMjpegImage(replacement as unknown as HTMLImageElement);
754 replacement.naturalWidth = 400;
755 replacement.naturalHeight = 800;
756 replacement.dispatchEvent(new Event("load"));
757 expect(events.onStatus).toHaveBeenLastCalledWith("streaming", undefined);
758 client.stop();
759 expect(vi.getTimerCount()).toBe(0);
760 });
761
762 it("falls back from unsupported iOS H.264 without publishing a terminal error", async () => {
763 const { client, videoBodies, Decoder, events, imageReady } = recoveryFixture("ios", false);
764 Decoder.isConfigSupported.mockResolvedValue({ supported: false });
765 client.start();
766 await vi.advanceTimersByTimeAsync(0);
767 videoBodies[0]!.enqueue(new Uint8Array(envelope(1, [1, 0x64, 0, 0x1f])));
768 await vi.advanceTimersByTimeAsync(0);
769 expect(events.onMjpegFallback).toHaveBeenCalledOnce();
770 expect(events.onStatus.mock.calls.some(([status]) => status === "error")).toBe(false);
771 imageReady();
772 expect(events.onStatus).toHaveBeenLastCalledWith("streaming", undefined);
773 client.stop();
774 });
775
776 it("publishes a terminal unsupported-profile error for Android and closes its socket", async () => {
777 const { client, sockets, sps, Decoder, events } = recoveryFixture("android");
778 Decoder.isConfigSupported.mockResolvedValue({ supported: false });
779 client.start();
780 sockets[0]!.onmessage?.({ data: sps });
781 await vi.advanceTimersByTimeAsync(0);
782 expect(events.onStatus).toHaveBeenLastCalledWith(
783 "error",
784 expect.stringContaining("cannot decode"),
785 );
786 expect(sockets[0]!.close).toHaveBeenCalledOnce();
787 expect(events.onMjpegFallback).not.toHaveBeenCalled();
788 expect(vi.getTimerCount()).toBe(0);
789 });
790
791 it("waits for decoded Android output and bounds a rotation that never produces another frame", async () => {
792 const { client, sockets, sps, events, decodedFrame, drawImage } = recoveryFixture("android");
793 client.start();
794 sockets[0]!.onmessage?.({ data: sps });
795 await vi.advanceTimersByTimeAsync(0);
796 expect(events.onStatus.mock.calls.some(([status]) => status === "streaming")).toBe(false);
797 expect(decodedFrame()).toHaveBeenCalledOnce();
798 expect(drawImage).toHaveBeenCalledOnce();
799 expect(events.onStatus).toHaveBeenLastCalledWith("streaming", undefined);
800 sockets[0]!.onmessage?.({ data: JSON.stringify({ type: "video-session" }) });
801 await vi.advanceTimersByTimeAsync(15_000);
802 expect(events.onStatus).toHaveBeenLastCalledWith("error", expect.stringContaining("No video"));
803 expect(vi.getTimerCount()).toBe(0);
804 });
805
806 it("discards decoder support results and output from a previous Android socket", async () => {
807 const { client, sockets, sps, events, Decoder, decoders, decodedFrame, drawImage } =
808 recoveryFixture("android");
809 let resolveSupport!: (support: { supported: boolean }) => void;
810 Decoder.isConfigSupported.mockImplementationOnce(
811 () =>
812 new Promise((resolve) => {
813 resolveSupport = resolve;
814 }),
815 );
816 client.start();
817 sockets[0]!.onmessage?.({ data: sps });
818 client.stop();
819 client.start();
820 resolveSupport({ supported: true });
821 await vi.advanceTimersByTimeAsync(0);
822 expect(decoders).toHaveLength(0);
823 sockets[1]!.onmessage?.({ data: sps });
824 await vi.advanceTimersByTimeAsync(0);
825 client.stop();
826 client.start();
827 events.onStatus.mockClear();
828 expect(decodedFrame()).toHaveBeenCalledOnce();
829 expect(drawImage).not.toHaveBeenCalled();
830 expect(events.onStatus).not.toHaveBeenCalled();
831 client.stop();
832 });
833
834 it("ignores an old AVCC response rejected after a new attempt is connected", async () => {
835 const { client, fetch, events } = recoveryFixture("ios", false);
836 let resolveResponse!: (response: Response) => void;
837 fetch.mockImplementationOnce(() => Promise.resolve(new Response("prime")));
838 fetch.mockImplementationOnce(
839 () =>
840 new Promise((resolve) => {
841 resolveResponse = resolve;
842 }),
843 );
844 client.start();
845 await vi.advanceTimersByTimeAsync(0);
846 client.stop();
847 client.start();
848 await vi.advanceTimersByTimeAsync(0);
849 events.onStatus.mockClear();
850 resolveResponse(new Response(null, { status: 401 }));
851 await vi.advanceTimersByTimeAsync(0);
852 expect(events.onUnauthorized).not.toHaveBeenCalled();
853 expect(events.onStatus).not.toHaveBeenCalled();
854 client.stop();
855 expect(vi.getTimerCount()).toBe(0);
856 });
857
858 it("closes a delayed JPEG seed from a stopped AVCC attempt without painting it", async () => {
859 const { client, videoBodies, drawImage, events } = recoveryFixture("ios", false);
860 let resolveBitmap!: (bitmap: ImageBitmap) => void;
861 vi.stubGlobal(
862 "createImageBitmap",
863 () =>
864 new Promise((resolve) => {
865 resolveBitmap = resolve;
866 }),
867 );
868 client.start();
869 await vi.advanceTimersByTimeAsync(0);
870 videoBodies[0]!.enqueue(new Uint8Array(envelope(4, [1])));
871 await vi.advanceTimersByTimeAsync(0);
872 client.stop();
873 client.start();
874 events.onStatus.mockClear();
875 const close = vi.fn();
876 resolveBitmap({ width: 400, height: 800, close } as unknown as ImageBitmap);
877 await vi.advanceTimersByTimeAsync(0);
878 expect(close).toHaveBeenCalledOnce();
879 expect(drawImage).not.toHaveBeenCalled();
880 expect(events.onStatus).not.toHaveBeenCalled();
881 client.stop();
882 });
883});
884
885it("reopens a stalled AVCC body without disconnecting input, then receives fresh video", async () => {
886 const { client, videoBodies, events, signals, sockets, decodedFrame } = recoveryFixture(
887 "ios",
888 false,
889 );
890 const close = vi.fn();
891 vi.stubGlobal("createImageBitmap", () => Promise.resolve({ width: 400, height: 800, close }));
892 try {
893 client.start();
894 await vi.advanceTimersByTimeAsync(0);
895 sockets[0]!.onmessage?.({ data: new Uint8Array([0x83]).buffer });
896 videoBodies[0]!.enqueue(new Uint8Array(envelope(4, [1])));
897 await vi.advanceTimersByTimeAsync(0);
898 expect(events.onStatus).toHaveBeenLastCalledWith("streaming", undefined);
899 await vi.advanceTimersByTimeAsync(15_000);
900 expect(events.onStatus).toHaveBeenLastCalledWith(
901 "connecting",
902 expect.stringContaining("stopped receiving video"),
903 );
904 expect(signals[1]!.aborted).toBe(true);
905 expect(sockets[0]!.close).not.toHaveBeenCalled();
906 expect(events.onInputConnected).toHaveBeenLastCalledWith(true);
907 await vi.advanceTimersByTimeAsync(1_000);
908 expect(videoBodies).toHaveLength(2);
909 videoBodies[1]!.enqueue(new Uint8Array(envelope(1, [1, 0x64, 0, 0x1f])));
910 await vi.advanceTimersByTimeAsync(0);
911 decodedFrame();
912 expect(events.onStatus).toHaveBeenLastCalledWith("streaming", undefined);
913 client.stop();
914 expect(vi.getTimerCount()).toBe(0);
915 } finally {
916 client.stop();
917 vi.useRealTimers();
918 vi.unstubAllGlobals();
919 }
920});