Skip to content
Source
Sign inSign up

ServerBrowserSurface.browser.test.tsx

222 lines8.0 KB

1import { EnvironmentId } from "@t3tools/contracts";
2import type {
3 PreviewStreamControl,
4 PreviewStreamEvents,
5 PreviewStreamInput,
6} from "@t3tools/client-runtime/preview/server-browser-stream";
7import { describe, expect, it, vi } from "vite-plus/test";
8import { userEvent } from "vite-plus/test/browser";
9​
10import { act, render } from "../../test/render";
11​
12const mocks = vi.hoisted(() => ({
13 createClient: vi.fn<(target: unknown, handlers: PreviewStreamEvents) => unknown>(),
14 sent: [] as PreviewStreamInput[],
15 access: { url: "ws://preview.test", token: "test" },
16 painted: null as (() => void) | null,
17}));
18​
19vi.mock(import("@t3tools/client-runtime/preview/server-browser-stream"), async (original) => ({
20 ...(await original()),
21 createPreviewStreamClient: mocks.createClient as never,
22 createPreviewFramePainter: (_canvas, onPainted) => {
23 mocks.painted = onPainted ?? null;
24 return { paint: () => undefined, stop: () => undefined };
25 },
26}));
27​
28vi.mock(import("~/state/previewStream"), async (original) => ({
29 ...(await original()),
30 usePreviewStreamAccess: () => mocks.access as never,
31}));
32​
33import { ServerBrowserSurface } from "./ServerBrowserSurface";
34​
35function setup() {
36 mocks.sent.length = 0;
37 mocks.painted = null;
38 mocks.createClient.mockReset().mockImplementation(() => ({
39 send: (input: PreviewStreamInput) => mocks.sent.push(input),
40 stop: () => undefined,
41 }));
43​
44async function renderSurface(controller: PreviewStreamControl["controller"]) {
45 const container = document.createElement("div");
46 container.style.cssText = "width: 400px; height: 300px; display: flex;";
47 await using stack = new AsyncDisposableStack();
48 const screen = stack.use(
49 await render(
50 <ServerBrowserSurface
51 environmentId={EnvironmentId.make("environment-copy")}
52 threadId="thread-copy"
53 tabId="tab-copy"
54 visible
55 followSize={false}
56 className="flex-1"
57 />,
58 { container },
59 ),
60 );
61 await vi.waitFor(() => expect(mocks.createClient).toHaveBeenCalled());
62 const handlers = mocks.createClient.mock.calls.at(-1)![1];
63 await act(() =>
64 handlers.onControl?.({ canOperate: true, controller, generation: 1, dialog: null }),
65 );
66 mocks.sent.length = 0;
67 const input = container.querySelector("textarea")!;
68 const canvas = container.querySelector("canvas")!;
69 stack.move();
70 return { screen, input, canvas };
72​
73const keys = () => mocks.sent.filter((input) => input.type === "key");
74​
75describe("ServerBrowserSurface clipboard commands", () => {
76 it("sends a native Copy or Cut menu command to the page once", async () => {
77 setup();
78 const { screen, input, canvas } = await renderSurface("you");
79 await using _screen = screen;
80 // Chromium runs a script Copy or Cut only during a user gesture, as a menu click is.
81 await userEvent.click(canvas);
82 input.focus();
83 mocks.sent.length = 0;
84​
85 // Like Electron's webContents.copy(): the browser's Copy command, with no key.
86 expect(document.execCommand("copy")).toBe(true);
87 expect(keys()).toEqual([
88 { type: "key", action: "down", key: "c", code: "KeyC", keyCode: 67, modifiers: 2 },
89 { type: "key", action: "up", key: "c", code: "KeyC", keyCode: 67, modifiers: 2 },
90 ]);
91​
92 mocks.sent.length = 0;
93 expect(document.execCommand("cut")).toBe(true);
94 expect(input.value).toBe("\u200b");
95 expect(keys()).toEqual([
96 { type: "key", action: "down", key: "x", code: "KeyX", keyCode: 88, modifiers: 2 },
97 { type: "key", action: "up", key: "x", code: "KeyX", keyCode: 88, modifiers: 2 },
98 ]);
99 });
100​
101 it("sends a keyboard Copy or Cut once and leaves menu Copy working after any shortcut", async () => {
102 setup();
103 const { screen, input, canvas } = await renderSurface("you");
104 await using _screen = screen;
105 await userEvent.click(canvas);
106 input.focus();
107 const prevented: boolean[] = [];
108 const onKeyDown = (event: KeyboardEvent) => {
109 if (/^[cx]$/i.test(event.key)) prevented.push(event.defaultPrevented);
110 };
111 window.addEventListener("keydown", onKeyDown);
112 using _listener = { [Symbol.dispose]: () => window.removeEventListener("keydown", onKeyDown) };
113​
114 // An unprevented copy or cut key also makes the browser fire copy or cut: here Chromium's
115 // Ctrl key binding, on macOS Electron's menu after the Meta keyup.
116 for (const [shortcut, cancels] of [
117 ["{Control>}x{/Control}", true],
118 ["{Control>}c{/Control}", true],
119 ["{Meta>}{x>}{/Meta}{/x}", true],
120 ["{Control>}{Shift>}c{/Shift}{/Control}", false],
121 ["{Control>}{Alt>}x{/Alt}{/Control}", false],
122 ] as const) {
123 mocks.sent.length = 0;
124 prevented.length = 0;
125 await userEvent.keyboard(shortcut);
126 expect(prevented, shortcut).toEqual([cancels]);
127 expect(keys().filter((key) => /^[cx]$/i.test(key.key)).length, shortcut).toBe(2);
128 expect(input.value).toBe("\u200b");
129​
130 mocks.sent.length = 0;
131 expect(document.execCommand("copy")).toBe(true);
132 expect(keys(), shortcut).toEqual([
133 { type: "key", action: "down", key: "c", code: "KeyC", keyCode: 67, modifiers: 2 },
134 { type: "key", action: "up", key: "c", code: "KeyC", keyCode: 67, modifiers: 2 },
135 ]);
136 }
137 });
138​
139 it("sends nothing when another viewer controls the page", async () => {
140 setup();
141 const { screen, input } = await renderSurface("another-viewer");
142 await using _screen = screen;
143​
144 expect(
145 input.dispatchEvent(new ClipboardEvent("copy", { bubbles: true, cancelable: true })),
146 ).toBe(false);
147 expect(
148 input.dispatchEvent(new ClipboardEvent("cut", { bubbles: true, cancelable: true })),
149 ).toBe(false);
150 expect(mocks.sent).toEqual([]);
151 });
152});
153​
154const mouseUp = (canvas: HTMLCanvasElement, button: number) => {
155 const event = new MouseEvent("mouseup", { button, bubbles: true, cancelable: true });
156 canvas.dispatchEvent(event);
157 return event;
158};
159​
160describe("ServerBrowserSurface history", () => {
161 it("routes mouse thumb buttons to the preview's history instead of the app", async () => {
162 setup();
163 const { screen, input } = await renderSurface("you");
164 await using _screen = screen;
165 const canvas = input.closest("[data-server-browser-surface]")!.querySelector("canvas")!;
166​
167 expect(mouseUp(canvas, 3).defaultPrevented).toBe(true);
168 expect(mouseUp(canvas, 4).defaultPrevented).toBe(true);
169 expect(mocks.sent).toEqual([
170 { type: "history", delta: -1 },
171 { type: "history", delta: 1 },
172 ]);
173 });
174​
175 it("leaves other mouse buttons alone", async () => {
176 setup();
177 const { screen, input } = await renderSurface("you");
178 await using _screen = screen;
179 const canvas = input.closest("[data-server-browser-surface]")!.querySelector("canvas")!;
180​
181 expect(mouseUp(canvas, 0).defaultPrevented).toBe(false);
182 expect(mocks.sent).toEqual([]);
183 });
184​
185 it("does not forward thumb-button presses to the page", async () => {
186 setup();
187 const { screen, input } = await renderSurface("you");
188 await using _screen = screen;
189 const canvas = input.closest("[data-server-browser-surface]")!.querySelector("canvas")!;
190 canvas.width = 400;
191 canvas.height = 300;
192 // Synthetic pointers have no active pointer id to capture.
193 canvas.setPointerCapture = () => undefined;
194 const handlers = mocks.createClient.mock.calls.at(-1)![1];
195 await act(() => {
196 mocks.painted?.();
197 handlers.onViewport({ width: 400, height: 300 });
198 });
199 const { left, top } = canvas.getBoundingClientRect();
200 const press = (type: "pointerdown" | "pointerup", button: number) =>
201 canvas.dispatchEvent(
202 new PointerEvent(type, {
203 button,
204 buttons: type === "pointerdown" ? 1 << button : 0,
205 clientX: left + 10,
206 clientY: top + 10,
207 pointerType: "mouse",
208 bubbles: true,
209 }),
210 );
211 const mouseInputs = () => mocks.sent.filter((input) => input.type === "mouse");
212​
213 for (const button of [3, 4]) {
214 press("pointerdown", button);
215 press("pointerup", button);
216 }
217 expect(mouseInputs()).toEqual([]);
218​
219 press("pointerdown", 0);
220 expect(mouseInputs()).not.toEqual([]);
221 });
222});