1import { act, useEffect, useSyncExternalStore } from "react";
2import { create, type ReactTestRenderer } from "react-test-renderer";
3import { EnvironmentId } from "@t3tools/contracts";
4import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
5
6// Like the real atom, a refresh yields a new access object and re-renders subscribers.
7const accessStore = {
8 value: { httpBase: "http://test", wsBase: "ws://test", query: {}, credentials: true },
9 listeners: new Set<() => void>(),
10 refresh() {
11 accessStore.value = { ...accessStore.value };
12 for (const listener of accessStore.listeners) listener();
13 },
14 subscribe(listener: () => void) {
15 accessStore.listeners.add(listener);
16 return () => accessStore.listeners.delete(listener);
17 },
18};
19vi.mock("~/state/device", () => ({
20 useDeviceHubAccess: () => useSyncExternalStore(accessStore.subscribe, () => accessStore.value),
21 refreshDeviceHubAccess: () => accessStore.refresh(),
22}));
23// Replace GPU allocation while keeping the real React and stream lifecycles.
24let viewerMounts = 0;
25let viewerUnmounts = 0;
26vi.mock("./DevicePhoneViewport", () => ({
27 DevicePhoneViewport: function Viewer() {
28 useEffect(() => {
29 viewerMounts++;
30 return () => {
31 viewerUnmounts++;
32 };
33 }, []);
34 return null;
35 },
36}));
37import { DeviceStreamView } from "./DeviceStreamView";
38
39class Image extends EventTarget {
40 src = "";
41 naturalWidth = 0;
42 naturalHeight = 0;
43 removeAttribute(name: string) {
44 if (name === "src") this.src = "";
45 }
46}
47let renderer: ReactTestRenderer | undefined;
48let primes = 0;
49beforeEach(() => {
50 primes = 0;
51 viewerMounts = 0;
52 viewerUnmounts = 0;
53});
54afterEach(async () => {
55 await act(async () => renderer?.unmount());
56 renderer = undefined;
57 vi.useRealTimers();
58 vi.unstubAllGlobals();
59});
60
61async function setup(h264 = false) {
62 vi.useFakeTimers();
63 vi.stubGlobal("window", globalThis);
64 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
65 let videoBody: ReadableStreamDefaultController<Uint8Array> | undefined;
66 let output: VideoFrameOutputCallback | undefined;
67 if (h264) {
68 vi.stubGlobal(
69 "VideoDecoder",
70 class {
71 static isConfigSupported = async () => ({ supported: true });
72 state = "unconfigured";
73 constructor(callbacks: VideoDecoderInit) {
74 output = callbacks.output;
75 }
76 configure() {
77 this.state = "configured";
78 }
79 close() {
80 this.state = "closed";
81 }
82 },
83 );
84 vi.stubGlobal("EncodedVideoChunk", vi.fn());
85 }
86 vi.stubGlobal("fetch", (url: string, init: RequestInit) => {
87 if (url.endsWith("stream.avcc")) {
88 return Promise.resolve(
89 new Response(
90 new ReadableStream<Uint8Array>({
91 start(controller) {
92 videoBody = controller;
93 init.signal?.addEventListener("abort", () => controller.error(new Error("aborted")));
94 },
95 }),
96 ),
97 );
98 }
99 primes++;
100 return Promise.resolve(new Response("prime"));
101 });
102 const sockets: Array<{ onmessage?: (event: { data: ArrayBuffer }) => void }> = [];
103 vi.stubGlobal(
104 "WebSocket",
105 class {
106 static OPEN = 1;
107 readyState = 1;
108 constructor() {
109 sockets.push(this);
110 }
111 onmessage?: (event: { data: ArrayBuffer }) => void;
112 send() {}
113 close() {}
114 },
115 );
116 vi.stubGlobal(
117 "ResizeObserver",
118 class {
119 observe() {}
120 disconnect() {}
121 },
122 );
123 const images: Image[] = [];
124 const view = (visible: boolean) => (
125 <DeviceStreamView
126 hostId="local"
127 environmentId={EnvironmentId.make("test")}
128 deviceId="test"
129 platform="ios"
130 visible={visible}
131 allowPhoneView={h264}
132 />
133 );
134 await act(async () => {
135 renderer = create(view(true), {
136 createNodeMock: (node) => {
137 if (node.type === "img") {
138 const image = new Image();
139 images.push(image);
140 return image;
141 }
142 return {
143 getContext: () => ({ drawImage() {} }),
144 style: { setProperty() {} },
145 getBoundingClientRect: () => ({ width: 400, height: 800 }),
146 };
147 },
148 });
149 });
150 return {
151 images,
152 view,
153 configure() {
154 const json = new TextEncoder().encode(
155 JSON.stringify({ width: 400, height: 800, orientation: "portrait" }),
156 );
157 const packet = new Uint8Array(1 + json.length);
158 packet[0] = 0x82;
159 packet.set(json, 1);
160 sockets[0]?.onmessage?.({ data: Uint8Array.of(0x83).buffer });
161 sockets[0]?.onmessage?.({ data: packet.buffer });
162 videoBody?.enqueue(new Uint8Array([0, 0, 0, 5, 1, 1, 0x64, 0, 0x1f]));
163 },
164 frame() {
165 output?.({ displayWidth: 400, displayHeight: 800, close() {} } as VideoFrame);
166 },
167 };
168}
169
170it("removes MJPEG requests while hidden and reconnects when shown", async () => {
171 const { images, view } = await setup();
172 expect(images[0]!.src).toContain("stream.mjpeg");
173 await act(async () => renderer!.update(view(false)));
174 expect(renderer!.root.findAllByType("img")).toHaveLength(0);
175 expect(images[0]!.src).toBe("");
176 expect(vi.getTimerCount()).toBe(0);
177 await act(async () => renderer!.update(view(true)));
178 expect(images.at(-1)!.src).toContain("stream.mjpeg");
179});
180
181it("offers Reconnect after the shared timeout and receives a frame after retry with unchanged access", async () => {
182 const { images } = await setup();
183 await act(async () => {
184 await vi.advanceTimersByTimeAsync(15_000);
185 });
186 expect(
187 renderer!.root.findByProps({ role: "alert" }).findByType("span").children.join(""),
188 ).toContain("No video");
189 expect(images[0]!.src).toBe("");
190 await act(async () => renderer!.root.findByType("button").props.onClick());
191 expect(renderer!.root.findAllByProps({ role: "alert" })).toHaveLength(0);
192 expect(images[1]!.src).toContain("stream.mjpeg");
193 await act(async () => {
194 images[1]!.naturalWidth = 400;
195 images[1]!.naturalHeight = 800;
196 images[1]!.dispatchEvent(new Event("load"));
197 });
198 expect(renderer!.root.findAllByProps({ role: "status" })).toHaveLength(0);
199 expect(renderer!.root.findAllByType("button")).toHaveLength(0);
200 expect(vi.getTimerCount()).toBe(0);
201});
202
203it("starts exactly one new stream per Reconnect press", async () => {
204 await setup();
205 await act(async () => {
206 await vi.advanceTimersByTimeAsync(15_000);
207 });
208 expect(primes).toBe(1);
209 await act(async () => renderer!.root.findByType("button").props.onClick());
210 expect(primes).toBe(2);
211});
212
213it("keeps the 3D viewer mounted across iOS video recovery and releases it when hidden", async () => {
214 const { configure, frame, view } = await setup(true);
215 await act(async () => configure());
216 expect(viewerMounts).toBe(0);
217 await act(async () => frame());
218 expect(viewerMounts).toBe(1);
219 await act(async () => {
220 await vi.advanceTimersByTimeAsync(15_000);
221 });
222 expect(viewerUnmounts).toBe(0);
223 expect(viewerMounts).toBe(1);
224 await act(async () => {
225 await vi.advanceTimersByTimeAsync(1_000);
226 configure();
227 });
228 await act(async () => frame());
229 expect(viewerMounts).toBe(1);
230 await act(async () => renderer!.update(view(false)));
231 expect(viewerUnmounts).toBe(1);
232 expect(vi.getTimerCount()).toBe(0);
233});