Skip to content
Source
Sign inSign up
Open History to see the last commit

stream.test.ts

704 lines22.5 KB

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(Response.json({})));
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("renews an expired Android ticket when the HTTP upgrade is rejected instead of retrying it forever", async () => {
106 const { client, opened, events } = setup("android");
107 client.start();
108 const socket = await opened;
109 socket.onclose?.({ code: 1006, reason: "" });
110 expect(events.onUnauthorized).toHaveBeenCalledTimes(1);
111 expect(vi.getTimerCount()).toBe(0);
112 client.stop();
113 });
114​
115 it.each(["ios", "android"] as const)(
116 "keeps a restarted %s stream connected when the discarded socket closes late",
117 async (platform) => {
118 const { client, opened, waitForSocket, events } = setup(platform);
119 client.start();
120 const discarded = await opened;
121 client.stop();
122 const reopened = waitForSocket();
123 client.start();
124 const replacement = await reopened;
125 replacement.onopen?.();
126 if (platform === "ios") replacement.onmessage?.({ data: Uint8Array.of(0x83).buffer });
127 replacement.send.mockClear();
128 events.onInputConnected.mockClear();
129​
130 discarded.onclose?.({ code: 1006, reason: "" });
131​
132 expect(events.onUnauthorized).not.toHaveBeenCalled();
133 expect(events.onInputConnected).not.toHaveBeenCalled();
134 expect(replacement.close).not.toHaveBeenCalled();
135 client.pressButton("home");
136 expect(replacement.send).toHaveBeenCalledTimes(1);
137 client.stop();
138 expect(replacement.close).toHaveBeenCalledTimes(1);
139 expect(vi.getTimerCount()).toBe(0);
140 },
141 );
142​
143 it.each(["ios", "android"] as const)(
144 "does not renew credentials or reconnect a stopped %s stream",
145 async (platform) => {
146 const { client, opened, events } = setup(platform);
147 client.start();
148 const socket = await opened;
149 events.onInputConnected.mockClear();
150 client.stop();
151 socket.onclose?.({ code: 1006, reason: "" });
152 expect(events.onUnauthorized).not.toHaveBeenCalled();
153 expect(events.onInputConnected).not.toHaveBeenCalled();
154 expect(vi.getTimerCount()).toBe(0);
155 },
156 );
157});
158​
159describe("iOS keypad protocol", () => {
160 afterEach(() => vi.unstubAllGlobals());
161​
162 it("encodes keypad presses and releases as HID key packets", async () => {
163 const packets: Uint8Array[] = [];
164 let opened!: (socket: Socket) => void;
165 const connected = new Promise<Socket>((resolve) => {
166 opened = resolve;
167 });
168 class Socket {
169 static OPEN = 1;
170 readyState = 1;
171 binaryType = "";
172 onmessage: ((event: { data: ArrayBuffer }) => void) | null = null;
173 constructor() {
174 opened(this);
175 }
176 send(packet: Uint8Array) {
177 packets.push(packet);
178 }
179 close() {}
180 }
181 vi.stubGlobal("WebSocket", Socket);
182 vi.stubGlobal("fetch", async () => new Response("frame"));
183 const client = createDeviceStreamClient(
184 {
185 platform: "ios",
186 deviceId: "keypad-device",
187 preferMjpeg: true,
188 access: {
189 httpBase: "https://device.test",
190 wsBase: "wss://device.test",
191 credentials: false,
192 query: {},
193 },
194 },
195 { getContext: () => null } as unknown as HTMLCanvasElement,
196 {
197 onStatus() {},
198 onScreen() {},
199 onUnauthorized() {},
200 onMjpegFallback() {},
201 onInputConnected() {},
202 },
203 );
204 try {
205 client.start();
206 const socket = await connected;
207 socket.onmessage?.({ data: Uint8Array.of(0x83).buffer });
208 const keys = [
209 ["NumpadDivide", 0x54],
210 ["NumpadMultiply", 0x55],
211 ["NumpadSubtract", 0x56],
212 ["NumpadAdd", 0x57],
213 ["NumpadEnter", 0x58],
214 ["Numpad1", 0x59],
215 ["Numpad2", 0x5a],
216 ["Numpad3", 0x5b],
217 ["Numpad4", 0x5c],
218 ["Numpad5", 0x5d],
219 ["Numpad6", 0x5e],
220 ["Numpad7", 0x5f],
221 ["Numpad8", 0x60],
222 ["Numpad9", 0x61],
223 ["Numpad0", 0x62],
224 ["NumpadDecimal", 0x63],
225 ["NumpadEqual", 0x67],
226 ["Digit1", 0x1e],
227 ["Enter", 0x28],
228 ] as const;
229 for (const [code] of keys) {
230 for (const phase of ["down", "up"] as const) {
231 client.sendKey({ code } as KeyboardEvent, phase);
232 }
233 }
234 const decoder = new TextDecoder();
235 expect(
236 packets.map((packet) => [packet[0], JSON.parse(decoder.decode(packet.subarray(1)))]),
237 ).toEqual(
238 keys.flatMap(([, usage]) => [
239 [0x06, { type: "down", usage }],
240 [0x06, { type: "up", usage }],
241 ]),
242 );
243 } finally {
244 client.stop();
245 }
246 });
247});
248​
249describe("serve-emu frames", () => {
250 it("strips the SEMU header and reads the keyframe flag and timestamp", () => {
251 const buffer = new ArrayBuffer(16 + 3);
252 const view = new DataView(buffer);
253 view.setUint32(0, 0x53454d55);
254 view.setUint8(4, 1);
255 view.setUint8(5, 1);
256 view.setBigUint64(8, 123456n);
257 new Uint8Array(buffer).set([7, 8, 9], 16);
258 const packet = parseSemuPacket(buffer);
259 expect(packet.isKey).toBe(true);
260 expect(packet.timestamp).toBe(123456);
261 expect(Array.from(packet.data)).toEqual([7, 8, 9]);
262 });
263​
264 it("treats a frame without the header as raw data", () => {
265 const packet = parseSemuPacket(new Uint8Array([0, 0, 1, 0x65]).buffer);
266 expect(packet.isKey).toBeNull();
267 expect(packet.data.length).toBe(4);
268 });
269​
270 it("finds the SPS and IDR NAL units in an Annex-B access unit", () => {
271 const unit = new Uint8Array([0, 0, 0, 1, 0x67, 0x64, 0x00, 0x1f, 0, 0, 1, 0x65, 0xaa]);
272 const scanned = scanAccessUnit(unit);
273 expect(scanned.isKey).toBe(true);
274 expect(scanned.sps && avcCodecString(scanned.sps)).toBe("avc1.64001f");
275 expect(scanAccessUnit(new Uint8Array([0, 0, 1, 0x41, 0x00])).isKey).toBe(false);
276 });
277});
278​
279class MjpegImage extends EventTarget {
280 naturalWidth = 0;
281 naturalHeight = 0;
282 src = "";
283 removeAttribute(name: string) {
284 if (name === "src") {
285 this.src = "";
286 this.naturalWidth = 0;
287 this.naturalHeight = 0;
288 }
289 }
291​
292function recoveryFixture(
293 platform: "ios" | "android" = "ios",
294 preferMjpeg = true,
295 serveSimOnly = false,
296) {
297 vi.useFakeTimers();
298 const sockets: Socket[] = [];
299 class Socket {
300 static OPEN = 1;
301 readyState = 1;
302 binaryType = "";
303 onopen: (() => void) | null = null;
304 onmessage: ((event: { data: ArrayBuffer | string }) => void) | null = null;
305 onclose: ((event: { code: number; reason: string }) => void) | null = null;
306 send = vi.fn();
307 close = vi.fn();
308 readonly url: string;
309 constructor(url: string) {
310 this.url = url;
311 sockets.push(this);
312 }
313 }
314 vi.stubGlobal("WebSocket", Socket);
315 const decoders: Decoder[] = [];
316 class Decoder {
317 static isConfigSupported = vi.fn(async () => ({ supported: true }));
318 state = "unconfigured";
319 decodeQueueSize = 0;
320 configure() {
321 this.state = "configured";
322 }
323 reset = vi.fn(() => {
324 this.state = "unconfigured";
325 this.decodeQueueSize = 0;
326 });
327 close() {
328 this.state = "closed";
329 }
330 decode = vi.fn();
331 readonly callbacks: { output: (frame: VideoFrame) => void; error: () => void };
332 constructor(callbacks: { output: (frame: VideoFrame) => void; error: () => void }) {
333 this.callbacks = callbacks;
334 decoders.push(this);
335 }
336 }
337 vi.stubGlobal("VideoDecoder", Decoder);
338 vi.stubGlobal("EncodedVideoChunk", vi.fn());
339 const videoBodies: ReadableStreamDefaultController<Uint8Array>[] = [];
340 const signals: AbortSignal[] = [];
341 const fetch = vi.fn((url: string, init: RequestInit) => {
342 const signal = init.signal!;
343 signals.push(signal);
344 if (url.includes("/api?")) return Promise.resolve(Response.json({}));
345 if (!url.includes("stream.avcc")) return Promise.resolve(new Response("prime"));
346 return Promise.resolve(
347 new Response(
348 new ReadableStream<Uint8Array>({
349 start(body) {
350 videoBodies.push(body);
351 signal.addEventListener("abort", () => body.error(new Error("aborted")), {
352 once: true,
353 });
354 },
355 }),
356 ),
357 );
358 });
359 vi.stubGlobal("fetch", fetch);
360 const events = {
361 onStatus: vi.fn(),
362 onScreen: vi.fn(),
363 onUnauthorized: vi.fn(),
364 onMjpegFallback: vi.fn(),
365 onInputConnected: vi.fn(),
366 };
367 const drawImage = vi.fn();
368 const image = new MjpegImage();
369 const client = createDeviceStreamClient(
370 {
371 platform,
372 preferMjpeg,
373 serveSimOnly,
374 deviceId: "test-device",
375 access: {
376 httpBase: "https://test/api/device-hub",
377 wsBase: "wss://test/api/device-hub",
378 credentials: true,
379 query: {},
380 },
381 },
382 { getContext: () => ({ drawImage }) } as unknown as HTMLCanvasElement,
383 events,
384 );
385 client.setMjpegImage(image as unknown as HTMLImageElement);
386 const imageReady = () => {
387 image.naturalWidth = 400;
388 image.naturalHeight = 800;
389 image.dispatchEvent(new Event("load"));
390 };
391 const decodedFrame = (index = decoders.length - 1) => {
392 const close = vi.fn();
393 decoders[index]!.callbacks.output({
394 displayWidth: 400,
395 displayHeight: 800,
396 close,
397 } as unknown as VideoFrame);
398 return close;
399 };
400 const sps = new Uint8Array([0, 0, 0, 1, 0x67, 0x64, 0, 0x1f, 0, 0, 1, 0x65]).buffer;
401 return {
402 client,
403 events,
404 image,
405 imageReady,
406 sockets,
407 signals,
408 fetch,
409 videoBodies,
410 decoders,
411 Decoder,
412 drawImage,
413 decodedFrame,
414 sps,
415 };
417​
418describe("shared device stream readiness and recovery", () => {
419 afterEach(() => {
420 vi.useRealTimers();
421 vi.unstubAllGlobals();
422 });
423​
424 it("streams and drives a serve-sim-only Android device over serve-sim's AVCC and input socket", async () => {
425 const { client, events, fetch, sockets, videoBodies, decoders } = recoveryFixture(
426 "android",
427 false,
428 true,
429 );
430 client.start();
431 await vi.advanceTimersByTimeAsync(0);
432 // No MJPEG priming: Simlock serves only the AVCC stream and the input socket.
433 expect(fetch.mock.calls.map(([url]) => url)).toEqual([
434 "https://test/api/device-hub/vendor/serve-sim/helper/test-device/stream.avcc",
435 ]);
436 expect(sockets.map((socket) => socket.url)).toEqual([
437 "wss://test/api/device-hub/vendor/serve-sim/helper/ws?device=test-device",
438 ]);
439 // Simlock adds meta (5), keep-alive (6), and lease-end (7) frames to serve-sim's envelopes.
440 videoBodies[0]!.enqueue(
441 new Uint8Array([
442 ...envelope(5, [0x7b, 0x7d]),
443 ...envelope(1, [1, 0x64, 0, 0x1f]),
444 ...envelope(6, []),
445 ...envelope(2, [2]),
446 ...envelope(7, [0x7b, 0x7d]),
447 ]),
448 );
449 await vi.advanceTimersByTimeAsync(0);
450 expect(decoders[0]!.decode).toHaveBeenCalledOnce();
451​
452 const input = sockets[0]!;
453 input.onmessage?.({ data: Uint8Array.of(0x83).buffer });
454 input.onmessage?.({
455 data: new Uint8Array([
456 0x82,
457 ...new TextEncoder().encode('{"width":1080,"height":2400,"orientation":"portrait"}'),
458 ]).buffer,
459 });
460 expect(events.onInputConnected).toHaveBeenLastCalledWith(true, undefined);
461 client.sendTouch("begin", 0.25, 0.5);
462 client.pressButton("back");
463 const sent = input.send.mock.calls.map(([payload]) => {
464 const bytes = payload as Uint8Array;
465 return [bytes[0], JSON.parse(new TextDecoder().decode(bytes.subarray(1)))];
466 });
467 expect(sent).toEqual([
468 [0x03, { type: "begin", x: 0.25, y: 0.5 }],
469 [0x04, { button: "back" }],
470 ]);
471 client.stop();
472 });
473​
474 it("stops retrying a Simlock lease that ended and keeps its reason", async () => {
475 const { client, events, fetch, sockets, videoBodies } = recoveryFixture("ios", false, true);
476 client.start();
477 await vi.advanceTimersByTimeAsync(0);
478 const reason = [...new TextEncoder().encode('{"reason":"expired"}')];
479 videoBodies[0]!.enqueue(new Uint8Array([...envelope(4, [1]), ...envelope(7, reason)]));
480 videoBodies[0]!.close();
481 sockets[0]!.onclose?.({ code: 4001, reason: "expired" });
482 await vi.advanceTimersByTimeAsync(5_000);
483 expect(fetch.mock.calls.filter(([url]) => url.includes("stream.avcc"))).toHaveLength(1);
484 expect(sockets).toHaveLength(1);
485 expect(events.onStatus).toHaveBeenLastCalledWith("error", expect.stringContaining("expired"));
486 client.stop();
487 });
488​
489 it("does not repaint a delayed JPEG seed over newer decoded video", async () => {
490 const { client, videoBodies, drawImage, decodedFrame } = recoveryFixture("ios", false);
491 let resolveBitmap!: (bitmap: ImageBitmap) => void;
492 vi.stubGlobal(
493 "createImageBitmap",
494 () =>
495 new Promise((resolve) => {
496 resolveBitmap = resolve;
497 }),
498 );
499 client.start();
500 await vi.advanceTimersByTimeAsync(0);
501 videoBodies[0]!.enqueue(
502 new Uint8Array([
503 ...envelope(4, [1]),
504 ...envelope(1, [1, 0x64, 0, 0x1f]),
505 ...envelope(2, [2]),
506 ]),
507 );
508 await vi.advanceTimersByTimeAsync(0);
509 decodedFrame();
510 const close = vi.fn();
511 resolveBitmap({ width: 400, height: 800, close } as unknown as ImageBitmap);
512 await vi.advanceTimersByTimeAsync(0);
513 expect(drawImage).toHaveBeenCalledOnce();
514 expect(close).toHaveBeenCalledOnce();
515 client.stop();
516 });
517​
518 it("discards decoder support results and output from a previous Android socket", async () => {
519 const { client, sockets, sps, events, Decoder, decoders, decodedFrame, drawImage } =
520 recoveryFixture("android");
521 let resolveSupport!: (support: { supported: boolean }) => void;
522 Decoder.isConfigSupported.mockImplementationOnce(
523 () =>
524 new Promise((resolve) => {
525 resolveSupport = resolve;
526 }),
527 );
528 client.start();
529 await vi.advanceTimersByTimeAsync(0);
530 sockets[0]!.onmessage?.({ data: sps });
531 client.stop();
532 client.start();
533 await vi.advanceTimersByTimeAsync(0);
534 resolveSupport({ supported: true });
535 await vi.advanceTimersByTimeAsync(0);
536 expect(decoders).toHaveLength(0);
537 sockets[1]!.onmessage?.({ data: sps });
538 await vi.advanceTimersByTimeAsync(0);
539 client.stop();
540 client.start();
541 events.onStatus.mockClear();
542 expect(decodedFrame()).toHaveBeenCalledOnce();
543 expect(drawImage).not.toHaveBeenCalled();
544 expect(events.onStatus).not.toHaveBeenCalled();
545 client.stop();
546 });
547​
548 it("ignores an old AVCC response rejected after a new attempt is connected", async () => {
549 const { client, fetch, events } = recoveryFixture("ios", false);
550 let resolveResponse!: (response: Response) => void;
551 fetch.mockImplementationOnce(() => Promise.resolve(new Response("prime")));
552 fetch.mockImplementationOnce(
553 () =>
554 new Promise((resolve) => {
555 resolveResponse = resolve;
556 }),
557 );
558 client.start();
559 await vi.advanceTimersByTimeAsync(0);
560 client.stop();
561 client.start();
562 await vi.advanceTimersByTimeAsync(0);
563 events.onStatus.mockClear();
564 resolveResponse(new Response(null, { status: 401 }));
565 await vi.advanceTimersByTimeAsync(0);
566 expect(events.onUnauthorized).not.toHaveBeenCalled();
567 expect(events.onStatus).not.toHaveBeenCalled();
568 client.stop();
569 expect(vi.getTimerCount()).toBe(0);
570 });
571});
572​
573describe("Android WebRTC device streams", () => {
574 afterEach(() => {
575 vi.useRealTimers();
576 vi.unstubAllGlobals();
577 });
578​
579 function setup() {
580 vi.useFakeTimers();
581 const sockets: Socket[] = [];
582 class Socket {
583 static OPEN = 1;
584 readyState = 1;
585 binaryType = "";
586 onopen: (() => void) | null = null;
587 onmessage: ((event: { data: string }) => void) | null = null;
588 onclose: ((event: { code: number; reason: string }) => void) | null = null;
589 send = vi.fn();
590 close = vi.fn();
591 readonly url: string;
592 constructor(url: string) {
593 this.url = url;
594 sockets.push(this);
595 }
596 }
597 const videos: Video[] = [];
598 class Video {
599 muted = false;
600 playsInline = false;
601 style = { cssText: "" };
602 srcObject: unknown = null;
603 videoWidth = 400;
604 videoHeight = 800;
605 setAttribute = vi.fn();
606 remove = vi.fn();
607 play = vi.fn(async () => {});
608 callback: (() => void) | null = null;
609 requestVideoFrameCallback(callback: () => void) {
610 this.callback = callback;
611 return 0;
612 }
613 constructor() {
614 videos.push(this);
615 }
616 }
617 const peers: Peer[] = [];
618 class Peer {
619 iceGatheringState = "complete";
620 connectionState = "connected";
621 localDescription = { type: "offer", sdp: "offer-sdp" };
622 ontrack: ((event: { streams: unknown[]; track: unknown }) => void) | null = null;
623 onconnectionstatechange: (() => void) | null = null;
624 close = vi.fn();
625 createOffer = vi.fn(async () => this.localDescription);
626 setLocalDescription = vi.fn(async () => {});
627 setRemoteDescription = vi.fn(async () => {});
628 addTransceiver = vi.fn(() => ({ setCodecPreferences: vi.fn() }));
629 addEventListener = vi.fn();
630 removeEventListener = vi.fn();
631 constructor() {
632 peers.push(this);
633 }
634 }
635 vi.stubGlobal("WebSocket", Socket);
636 vi.stubGlobal("RTCPeerConnection", Peer);
637 vi.stubGlobal("RTCRtpReceiver", { getCapabilities: () => ({ codecs: [] }) });
638 vi.stubGlobal("document", { createElement: () => new Video(), body: { append: vi.fn() } });
639 const fetch = vi.fn((url: string, _init?: RequestInit) =>
640 Promise.resolve(
641 url.includes("/api?")
642 ? Response.json({ serial: "emulator-5554", stream: { transport: "webrtc" } })
643 : Response.json({ type: "answer", sdp: "answer-sdp" }),
644 ),
645 );
646 vi.stubGlobal("fetch", fetch);
647 const events = {
648 onStatus: vi.fn(),
649 onScreen: vi.fn(),
650 onUnauthorized: vi.fn(),
651 onMjpegFallback: vi.fn(),
652 onInputConnected: vi.fn(),
653 };
654 const present = vi.fn(() => true);
655 const client = createDeviceStreamClient(
656 {
657 platform: "android",
658 deviceId: "session-1",
659 access: {
660 httpBase: "https://test/api/device-hub",
661 wsBase: "wss://test/api/device-hub",
662 credentials: true,
663 query: { hostId: "eas" },
664 },
665 },
666 { present },
667 events,
668 );
669 return { client, events, fetch, peers, videos, sockets, present };
670 }
671​
672 it("streams over WebRTC when serve-emu advertises it, with input that reconnects on its own", async () => {
673 const { client, events, fetch, peers, videos, sockets, present } = setup();
674 client.start();
675 await vi.advanceTimersByTimeAsync(0);
676 expect(fetch.mock.calls.map(([url]) => url)).toEqual([
677 "https://test/api/device-hub/vendor/serve-emu/api?device=session-1&hostId=eas",
678 "https://test/api/device-hub/vendor/serve-emu/webrtc/offer?device=session-1&hostId=eas",
679 ]);
680 expect(peers[0]?.setRemoteDescription).toHaveBeenCalledWith({
681 type: "answer",
682 sdp: "answer-sdp",
683 });
684 peers[0]?.ontrack?.({ streams: [{}], track: {} });
685 videos[0]?.callback?.();
686 expect(present).toHaveBeenCalledWith(videos[0], 400, 800);
687 expect(events.onStatus).toHaveBeenLastCalledWith("streaming", undefined);
688​
689 expect(sockets.map((socket) => socket.url)).toEqual([
690 "wss://test/api/device-hub/vendor/serve-emu/ws?device=session-1&video=0&hostId=eas",
691 ]);
692 sockets[0]!.onopen?.();
693 client.pressButton("back");
694 expect(sockets[0]!.send).toHaveBeenCalledWith(JSON.stringify({ type: "back" }));
695 events.onStatus.mockClear();
696 sockets[0]!.onclose?.({ code: 1001, reason: "going away" });
697 await vi.advanceTimersByTimeAsync(1_000);
698 expect(sockets[1]?.url).toContain("video=0");
699 expect(peers).toHaveLength(1);
700 expect(peers[0]?.close).not.toHaveBeenCalled();
701 expect(events.onStatus).not.toHaveBeenCalled();
702 client.stop();
703 });
704});