1import { afterEach, expect, it, vi } from "vite-plus/test";
2import { start, stop } from "./device-stream.browser";
3
4class Element extends EventTarget {
5 readonly style = {};
6 naturalWidth = 0;
7 naturalHeight = 0;
8 src = "";
9 readonly tag: string;
10 constructor(tag: string) {
11 super();
12 this.tag = tag;
13 }
14 setAttribute() {}
15 removeAttribute(name: string) {
16 if (name === "src") this.src = "";
17 }
18 append() {}
19}
20
21async function setup() {
22 vi.useFakeTimers();
23 const elements: Element[] = [];
24 vi.stubGlobal("document", {
25 documentElement: { style: {} },
26 body: { style: {}, replaceChildren() {} },
27 createElement: (tag: string) => {
28 const element = new Element(tag);
29 elements.push(element);
30 return element;
31 },
32 });
33 const postMessage = vi.fn();
34 vi.stubGlobal("window", { ReactNativeWebView: { postMessage }, addEventListener() {} });
35 vi.stubGlobal("fetch", () => Promise.resolve(new Response("prime")));
36 const sockets: Socket[] = [];
37 class Socket {
38 static OPEN = 1;
39 readyState = 1;
40 onopen: (() => void) | null = null;
41 onmessage: ((event: { data: ArrayBuffer }) => void) | null = null;
42 close = vi.fn();
43 send = vi.fn();
44 constructor() {
45 sockets.push(this);
46 }
47 }
48 vi.stubGlobal("WebSocket", Socket);
49 const configuration = {
50 platform: "ios" as const,
51 deviceId: "fixture-device",
52 access: {
53 httpBase: "https://device.test",
54 wsBase: "wss://device.test",
55 credentials: false,
56 query: {},
57 },
58 colors: {
59 background: "white",
60 foreground: "black",
61 muted: "gray",
62 buttonBackground: "gray",
63 buttonForeground: "black",
64 buttonBorder: "gray",
65 },
66 };
67 start(configuration);
68 await vi.advanceTimersByTimeAsync(0);
69 return {
70 configuration,
71 elements,
72 sockets,
73 messages: () =>
74 postMessage.mock.calls.map(
75 ([message]) => JSON.parse(message as string) as { type: string; status?: string },
76 ),
77 };
78}
79
80afterEach(() => {
81 stop();
82 vi.useRealTimers();
83 vi.unstubAllGlobals();
84});
85
86it("bridges shared first-frame readiness, image failure, and a successful fresh attempt to native", async () => {
87 const { elements, messages, sockets, configuration } = await setup();
88 sockets[0]!.onopen?.();
89 expect(messages()).not.toContainEqual({ type: "status", status: "streaming" });
90 expect(messages()).not.toContainEqual({ type: "input", connected: true });
91 sockets[0]!.onmessage?.({ data: new Uint8Array([0x83]).buffer });
92 expect(messages()).toContainEqual({ type: "input", connected: true });
93 const image = elements.find((element) => element.tag === "img")!;
94 image.naturalWidth = 400;
95 image.naturalHeight = 800;
96 await vi.advanceTimersByTimeAsync(250);
97 expect(messages()).toContainEqual({ type: "status", status: "streaming" });
98 image.dispatchEvent(new Event("error"));
99 expect(messages()).toContainEqual({
100 type: "status",
101 status: "error",
102 detail: "Could not receive the device stream. Reconnect to try again.",
103 });
104 expect(messages()).toContainEqual({ type: "input", connected: false });
105 expect(messages()).not.toContainEqual({ type: "unauthorized" });
106 expect(image.src).toBe("");
107 expect(sockets[0]!.close).toHaveBeenCalledOnce();
108 start(configuration);
109 await vi.advanceTimersByTimeAsync(0);
110 const replacement = elements.findLast((element) => element.tag === "img")!;
111 replacement.naturalWidth = 400;
112 replacement.naturalHeight = 800;
113 replacement.dispatchEvent(new Event("load"));
114 expect(messages().at(-1)).toEqual({ type: "status", status: "streaming" });
115 image.dispatchEvent(new Event("error"));
116 expect(sockets[1]!.close).not.toHaveBeenCalled();
117 stop();
118 expect(replacement.src).toBe("");
119 expect(vi.getTimerCount()).toBe(0);
120});