Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 134 additions & 41 deletions apps/web/src/browser/ServerBrowserSurface.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -1,102 +1,195 @@
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { EnvironmentId } from "@t3tools/contracts";
import type {
PreviewStreamControl,
PreviewStreamEvents,
PreviewStreamInput,
} from "@t3tools/client-runtime/preview/server-browser-stream";

import "~/index.css";
import { describe, expect, it, vi } from "vite-plus/test";
import { userEvent } from "vite-plus/test/browser";

import { render } from "../../test/render";
import { act, render } from "../../test/render";

const mocks = vi.hoisted(() => ({
send: vi.fn<(input: PreviewStreamInput) => void>(),
connect: vi.fn(),
events: null as PreviewStreamEvents | null,
createClient: vi.fn<(target: unknown, handlers: PreviewStreamEvents) => unknown>(),
sent: [] as PreviewStreamInput[],
access: { url: "ws://preview.test", token: "test" },
painted: null as (() => void) | null,
}));

vi.mock(import("@t3tools/client-runtime/preview/server-browser-stream"), async (original) => ({
...(await original()),
createPreviewStreamClient: mocks.createClient as never,
createPreviewFramePainter: (_canvas, onPainted) => {
mocks.painted = onPainted ?? null;
return { paint: () => undefined, stop: () => undefined };
},
createPreviewStreamClient: (_options, events) => {
mocks.events = events;
mocks.connect();
return { send: mocks.send, stop: () => undefined } as never;
},
}));

vi.mock(import("~/state/previewStream"), async (original) => ({
...(await original()),
usePreviewStreamAccess: () => ({}) as never,
usePreviewStreamAccess: () => mocks.access as never,
}));

import { ServerBrowserSurface } from "./ServerBrowserSurface";

async function renderSurface() {
mocks.send.mockReset();
mocks.connect.mockReset();
function setup() {
mocks.sent.length = 0;
mocks.painted = null;
mocks.createClient.mockReset().mockImplementation(() => ({
send: (input: PreviewStreamInput) => mocks.sent.push(input),
stop: () => undefined,
}));
}

async function renderSurface(controller: PreviewStreamControl["controller"]) {
const container = document.createElement("div");
container.style.cssText = "width: 400px; height: 300px;";
container.style.cssText = "width: 400px; height: 300px; display: flex;";
await using stack = new AsyncDisposableStack();
stack.use(
const screen = stack.use(
await render(
<ServerBrowserSurface
environmentId={EnvironmentId.make("environment-1")}
threadId={ThreadId.make("thread-1")}
tabId="tab-1"
environmentId={EnvironmentId.make("environment-copy")}
threadId="thread-copy"
tabId="tab-copy"
visible
followSize={false}
className="flex-1"
/>,
{ container },
),
);
const canvas = container.querySelector("canvas");
if (!canvas) throw new Error("The surface did not render its canvas.");
// The stream connects once the surface has measured its size.
await vi.waitFor(() => expect(mocks.connect).toHaveBeenCalled());
return Object.assign(stack.move(), { canvas });
await vi.waitFor(() => expect(mocks.createClient).toHaveBeenCalled());
const handlers = mocks.createClient.mock.calls.at(-1)![1];
await act(() =>
handlers.onControl?.({ canOperate: true, controller, generation: 1, dialog: null }),
);
mocks.sent.length = 0;
const input = container.querySelector("textarea")!;
const canvas = container.querySelector("canvas")!;
stack.move();
return { screen, input, canvas };
}

const keys = () => mocks.sent.filter((input) => input.type === "key");

describe("ServerBrowserSurface clipboard commands", () => {
it("sends a native Copy or Cut menu command to the page once", async () => {
setup();
const { screen, input, canvas } = await renderSurface("you");
await using _screen = screen;
// Chromium runs a script Copy or Cut only during a user gesture, as a menu click is.
await userEvent.click(canvas);
input.focus();
mocks.sent.length = 0;

// Like Electron's webContents.copy(): the browser's Copy command, with no key.
expect(document.execCommand("copy")).toBe(true);
expect(keys()).toEqual([
{ type: "key", action: "down", key: "c", code: "KeyC", keyCode: 67, modifiers: 2 },
{ type: "key", action: "up", key: "c", code: "KeyC", keyCode: 67, modifiers: 2 },
]);

mocks.sent.length = 0;
expect(document.execCommand("cut")).toBe(true);
expect(input.value).toBe("\u200b");
expect(keys()).toEqual([
{ type: "key", action: "down", key: "x", code: "KeyX", keyCode: 88, modifiers: 2 },
{ type: "key", action: "up", key: "x", code: "KeyX", keyCode: 88, modifiers: 2 },
]);

input.dispatchEvent(
new KeyboardEvent("keydown", {
key: "c",
code: "KeyC",
ctrlKey: true,
bubbles: true,
cancelable: true,
}),
);
input.blur();
input.focus();
mocks.sent.length = 0;
expect(document.execCommand("copy")).toBe(true);
expect(keys()).toEqual([
{ type: "key", action: "down", key: "c", code: "KeyC", keyCode: 67, modifiers: 2 },
{ type: "key", action: "up", key: "c", code: "KeyC", keyCode: 67, modifiers: 2 },
]);
});

it("sends a keyboard Cut once even though the browser also fires cut", async () => {
setup();
const { screen, input } = await renderSurface("you");
await using _screen = screen;
input.focus();

await userEvent.keyboard("{Control>}x{/Control}");
expect(keys().filter((key) => key.key === "x")).toEqual([
{ type: "key", action: "down", key: "x", code: "KeyX", keyCode: 88, modifiers: 2 },
{ type: "key", action: "up", key: "x", code: "KeyX", keyCode: 88, modifiers: 2 },
]);
expect(input.value).toBe("\u200b");
});

it("sends nothing when another viewer controls the page", async () => {
setup();
const { screen, input } = await renderSurface("another-viewer");
await using _screen = screen;

expect(
input.dispatchEvent(new ClipboardEvent("copy", { bubbles: true, cancelable: true })),
).toBe(false);
expect(
input.dispatchEvent(new ClipboardEvent("cut", { bubbles: true, cancelable: true })),
).toBe(false);
expect(mocks.sent).toEqual([]);
});
});

const mouseUp = (canvas: HTMLCanvasElement, button: number) => {
const event = new MouseEvent("mouseup", { button, bubbles: true, cancelable: true });
canvas.dispatchEvent(event);
return event;
};

describe("ServerBrowserSurface", () => {
describe("ServerBrowserSurface history", () => {
it("routes mouse thumb buttons to the preview's history instead of the app", async () => {
await using surface = await renderSurface();
const { canvas } = surface;
setup();
const { screen, input } = await renderSurface("you");
await using _screen = screen;
const canvas = input.closest("[data-server-browser-surface]")!.querySelector("canvas")!;

expect(mouseUp(canvas, 3).defaultPrevented).toBe(true);
expect(mouseUp(canvas, 4).defaultPrevented).toBe(true);
expect(mocks.send.mock.calls).toEqual([
[{ type: "history", delta: -1 }],
[{ type: "history", delta: 1 }],
expect(mocks.sent).toEqual([
{ type: "history", delta: -1 },
{ type: "history", delta: 1 },
]);
});

it("leaves other mouse buttons alone", async () => {
await using surface = await renderSurface();
const { canvas } = surface;
setup();
const { screen, input } = await renderSurface("you");
await using _screen = screen;
const canvas = input.closest("[data-server-browser-surface]")!.querySelector("canvas")!;

expect(mouseUp(canvas, 0).defaultPrevented).toBe(false);
expect(mocks.send).not.toHaveBeenCalled();
expect(mocks.sent).toEqual([]);
});

it("does not forward thumb-button presses to the page", async () => {
await using surface = await renderSurface();
const { canvas } = surface;
setup();
const { screen, input } = await renderSurface("you");
await using _screen = screen;
const canvas = input.closest("[data-server-browser-surface]")!.querySelector("canvas")!;
canvas.width = 400;
canvas.height = 300;
// Synthetic pointers have no active pointer id to capture.
canvas.setPointerCapture = () => undefined;
mocks.painted?.();
mocks.events?.onViewport({ width: 400, height: 300 });
mocks.events?.onControl?.({ canOperate: true, controller: "you", generation: 1, dialog: null });
const handlers = mocks.createClient.mock.calls.at(-1)![1];
await act(() => {
mocks.painted?.();
handlers.onViewport({ width: 400, height: 300 });
});
const { left, top } = canvas.getBoundingClientRect();
const press = (type: "pointerdown" | "pointerup", button: number) =>
canvas.dispatchEvent(
Expand All @@ -109,7 +202,7 @@ describe("ServerBrowserSurface", () => {
bubbles: true,
}),
);
const mouseInputs = () => mocks.send.mock.calls.filter(([input]) => input.type === "mouse");
const mouseInputs = () => mocks.sent.filter((input) => input.type === "mouse");

for (const button of [3, 4]) {
press("pointerdown", button);
Expand Down
37 changes: 29 additions & 8 deletions apps/web/src/browser/ServerBrowserSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
PreviewViewportSetting,
} from "@t3tools/contracts";
import {
type ClipboardEvent,
type FormEvent,
type KeyboardEvent,
type MouseEvent,
Expand Down Expand Up @@ -57,6 +58,9 @@ const PROBE_REUSE_MS = 10_000;
const INPUT_SENTINEL = "\u200b";
const BACKSPACE = { key: "Backspace", code: "Backspace", keyCode: 8 } as const;
const DELETE = { key: "Delete", code: "Delete", keyCode: 46 } as const;
const COPY = { key: "c", code: "KeyC", keyCode: 67 } as const;
const CUT = { key: "x", code: "KeyX", keyCode: 88 } as const;
const CTRL = 2;
const MAX_UNAUTHORIZED_REFUSALS = 3;
const TAP_SLOP_PX = 8;
const MULTI_CLICK_MS = 500;
Expand Down Expand Up @@ -168,6 +172,7 @@ export function ServerBrowserSurface(props: {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const keySentRef = useRef(false);
const clipboardKeySentRef = useRef(false);
const clientRef = useRef<PreviewStreamClient | null>(null);
const viewportRef = useRef<PreviewStreamViewport | null>(null);
const sizeRef = useRef<{ width: number; height: number } | null>(null);
Expand Down Expand Up @@ -230,7 +235,7 @@ export function ServerBrowserSurface(props: {
mouseClicksRef.current = { left: 1, middle: 1, right: 1, none: 1 };
lastClickRef.current = null;
touchRef.current = probeRef.current = lastProbeRef.current = null;
keySentRef.current = false;
keySentRef.current = clipboardKeySentRef.current = false;
if (inputRef.current) inputRef.current.value = INPUT_SENTINEL;
inputRef.current?.blur();
};
Expand Down Expand Up @@ -645,18 +650,32 @@ export function ServerBrowserSurface(props: {

const resetInput = (textarea: HTMLTextAreaElement) => {
keySentRef.current = false;
clipboardKeySentRef.current = false;
textarea.value = INPUT_SENTINEL;
textarea.setSelectionRange(INPUT_SENTINEL.length, INPUT_SENTINEL.length);
};

const sendKeyPress = (key: typeof BACKSPACE | typeof DELETE) => {
send({ type: "key", action: "down", ...key, modifiers: 0 });
send({ type: "key", action: "up", ...key, modifiers: 0 });
const sendKeyPress = (
key: typeof BACKSPACE | typeof DELETE | typeof COPY | typeof CUT,
modifiers = 0,
) => {
send({ type: "key", action: "down", ...key, modifiers });
send({ type: "key", action: "up", ...key, modifiers });
};

// Edit menu Copy and Cut fire no key, so send the shortcut the server copies on.
const handleClipboard = (key: typeof COPY | typeof CUT, event: ClipboardEvent) => {
// Keep the sentinel off the clipboard.
event.preventDefault();
const keyWasSent = clipboardKeySentRef.current;
clipboardKeySentRef.current = false;
if (controlRef.current?.controller !== "you" || keyWasSent) return;
sendKeyPress(key, CTRL);
};

const handleKey = (action: "down" | "up", event: KeyboardEvent<HTMLTextAreaElement>) => {
if (controlRef.current?.controller !== "you") return;
keySentRef.current = false;
keySentRef.current = clipboardKeySentRef.current = false;
if (
event.nativeEvent.isComposing ||
event.keyCode === 229 ||
Expand Down Expand Up @@ -696,6 +715,9 @@ export function ServerBrowserSurface(props: {
action === "down" &&
!shortcut &&
(text !== undefined || event.key === "Backspace" || event.key === "Delete");
// The browser fires copy or cut for this key too; the page already has it.
clipboardKeySentRef.current =
action === "down" && shortcut && ["c", "x"].includes(event.key.toLowerCase());
if (shortcut) return;
event.preventDefault();
event.stopPropagation();
Expand Down Expand Up @@ -790,9 +812,8 @@ export function ServerBrowserSurface(props: {
if (text) send({ type: "text", text });
resetInput(event.currentTarget);
}}
// Keep the sentinel off the clipboard.
onCopy={(event) => event.preventDefault()}
onCut={(event) => event.preventDefault()}
onCopy={(event) => handleClipboard(COPY, event)}
onCut={(event) => handleClipboard(CUT, event)}
onPaste={(event) => {
event.preventDefault();
const text = event.clipboardData.getData("text/plain");
Expand Down
Loading