1import * as NodeVM from "node:vm";
2import { describe, expect, it, vi } from "vite-plus/test";
3
4import { deviceStreamDocument, deviceStreamMessage } from "./device-stream-document";
5
6describe("native device stream document", () => {
7 it("keeps ticket and device values from terminating the embedded script", () => {
8 const deviceId = '</script><script>alert("device")</script>';
9 const configuration = JSON.stringify({ deviceId, ticket: "<ticket>" });
10 const html = deviceStreamDocument(
11 configuration,
12 "var T3DeviceStream={start(input){return input}};",
13 );
14 const script = html.match(/<script>([\s\S]*)<\/script>/)?.[1];
15 expect(html.match(/<script>/g)).toHaveLength(1);
16 const input: unknown = NodeVM.runInNewContext(script!, { window: { addEventListener() {} } });
17 expect(input).toEqual({ deviceId, ticket: "<ticket>" });
18 });
19
20 it("preserves script strings containing an HTML closing delimiter", () => {
21 const html = deviceStreamDocument("{}", 'var text="</script>";');
22 expect(html.match(/<\/script>/g)).toHaveLength(1);
23 expect(html).toContain('var text="<\\/script>";');
24 });
25
26 it("reports a bootstrap exception to the native recovery UI", () => {
27 const postMessage = vi.fn();
28 const html = deviceStreamDocument(
29 "{}",
30 'var T3DeviceStream={start(){throw new Error("startup")}};',
31 );
32 NodeVM.runInNewContext(html.match(/<script>([\s\S]*)<\/script>/)![1]!, {
33 window: { addEventListener() {}, ReactNativeWebView: { postMessage } },
34 });
35 expect(JSON.parse(postMessage.mock.calls[0]![0] as string)).toEqual({
36 type: "status",
37 status: "error",
38 detail: "Device viewer stopped unexpectedly.",
39 });
40 });
41
42 it.each(["error", "unhandledrejection"])("reports later %s failures to native", (event) => {
43 const postMessage = vi.fn();
44 const listeners = new Map<string, () => void>();
45 const html = deviceStreamDocument("{}", "var T3DeviceStream={start(){}};");
46 NodeVM.runInNewContext(html.match(/<script>([\s\S]*)<\/script>/)![1]!, {
47 window: {
48 addEventListener: (name: string, callback: () => void) => listeners.set(name, callback),
49 ReactNativeWebView: { postMessage },
50 },
51 });
52 listeners.get(event)!();
53 expect(JSON.parse(postMessage.mock.calls[0]![0] as string)).toMatchObject({
54 type: "status",
55 status: "error",
56 });
57 });
58});
59
60describe("native device stream messages", () => {
61 it("accepts authentication renewal and input connection changes", () => {
62 expect(deviceStreamMessage('{"type":"unauthorized"}')).toEqual({ type: "unauthorized" });
63 expect(deviceStreamMessage('{"type":"input","connected":true}')).toEqual({
64 type: "input",
65 connected: true,
66 });
67 expect(deviceStreamMessage('{"type":"input","connected":false}')).toEqual({
68 type: "input",
69 connected: false,
70 });
71 expect(deviceStreamMessage('{"type":"retry"}')).toEqual({ type: "retry" });
72 });
73
74 it.each(["connecting", "streaming", "error"])("accepts stream %s feedback", (status) => {
75 expect(
76 deviceStreamMessage(JSON.stringify({ type: "status", status, detail: "Stream feedback" })),
77 ).toEqual({ type: "status", status, detail: "Stream feedback" });
78 });
79
80 it.each([
81 "invalid JSON",
82 "null",
83 '"input"',
84 "{}",
85 '{"type":"input","connected":"yes"}',
86 '{"type":"unknown"}',
87 '{"type":"status","status":"unknown"}',
88 '{"type":"status","status":"error","detail":42}',
89 '{"type":"status"}',
90 ])("ignores invalid bridge messages: %s", (data) => expect(deviceStreamMessage(data)).toBeNull());
91});