213 lines
7.3 KiB
TypeScript
213 lines
7.3 KiB
TypeScript
|
|
import { readFileSync } from "node:fs";
|
||
|
|
import path from "node:path";
|
||
|
|
import { describe, expect, it, vi } from "vitest";
|
||
|
|
import {
|
||
|
|
attachHostClipboardPaste,
|
||
|
|
clipboardTextFromPaste,
|
||
|
|
isPasteChord,
|
||
|
|
pasteHostText,
|
||
|
|
releaseModifierKeys,
|
||
|
|
sendRemotePaste,
|
||
|
|
} from "../../computer/clipboard-bridge.js";
|
||
|
|
|
||
|
|
describe("host clipboard paste bridge", () => {
|
||
|
|
it("detects Ctrl/Cmd+V and ignores other chords", () => {
|
||
|
|
expect(isPasteChord({ ctrlKey: true, code: "KeyV", key: "v" })).toBe(true);
|
||
|
|
expect(isPasteChord({ metaKey: true, code: "KeyV", key: "v" })).toBe(true);
|
||
|
|
expect(isPasteChord({ ctrlKey: true, metaKey: true, code: "KeyV", key: "V" })).toBe(true);
|
||
|
|
expect(isPasteChord({ ctrlKey: true, code: "KeyC", key: "c" })).toBe(false);
|
||
|
|
expect(isPasteChord({ altKey: true, ctrlKey: true, code: "KeyV", key: "v" })).toBe(false);
|
||
|
|
expect(isPasteChord({ code: "KeyV", key: "v" })).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("blocks repeated Ctrl/Cmd+V keydowns so noVNC cannot see an unmatched KeyV", () => {
|
||
|
|
const listeners = new Map<string, (event: object) => void>();
|
||
|
|
const target = {
|
||
|
|
addEventListener: (type: string, listener: (event: object) => void) => {
|
||
|
|
listeners.set(type, listener);
|
||
|
|
},
|
||
|
|
removeEventListener: (type: string) => {
|
||
|
|
listeners.delete(type);
|
||
|
|
},
|
||
|
|
};
|
||
|
|
attachHostClipboardPaste(
|
||
|
|
{ viewOnly: false, clipboardPasteFrom: vi.fn(), sendKey: vi.fn() },
|
||
|
|
{ target },
|
||
|
|
);
|
||
|
|
const stopPropagation = vi.fn();
|
||
|
|
listeners.get("keydown")?.({
|
||
|
|
ctrlKey: true,
|
||
|
|
metaKey: false,
|
||
|
|
altKey: false,
|
||
|
|
repeat: true,
|
||
|
|
code: "KeyV",
|
||
|
|
key: "v",
|
||
|
|
stopPropagation,
|
||
|
|
});
|
||
|
|
expect(stopPropagation).toHaveBeenCalledOnce();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("reads plain text from a paste event", () => {
|
||
|
|
expect(
|
||
|
|
clipboardTextFromPaste({
|
||
|
|
clipboardData: {
|
||
|
|
getData: (type: string) => (type === "text/plain" ? "secret" : ""),
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
).toBe("secret");
|
||
|
|
expect(
|
||
|
|
clipboardTextFromPaste({
|
||
|
|
clipboardData: {
|
||
|
|
getData: (type: string) => (type === "text" ? "fallback" : ""),
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
).toBe("fallback");
|
||
|
|
expect(clipboardTextFromPaste({})).toBe("");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("releases host modifiers including noVNC macOS remaps, then pastes with Linux Ctrl+V", () => {
|
||
|
|
const keys: Array<[number, string, boolean | undefined]> = [];
|
||
|
|
const sendKey = (keysym: number, code: string, down?: boolean) => {
|
||
|
|
keys.push([keysym, code, down]);
|
||
|
|
};
|
||
|
|
releaseModifierKeys(sendKey);
|
||
|
|
expect(keys).toEqual([
|
||
|
|
[0xffe3, "ControlLeft", false],
|
||
|
|
[0xffe4, "ControlRight", false],
|
||
|
|
[0xffe9, "AltLeft", false],
|
||
|
|
[0xffea, "AltRight", false],
|
||
|
|
[0xffeb, "MetaLeft", false],
|
||
|
|
[0xffec, "MetaRight", false],
|
||
|
|
[0xff7e, "AltLeft", false],
|
||
|
|
[0xfe03, "AltRight", false],
|
||
|
|
]);
|
||
|
|
keys.length = 0;
|
||
|
|
sendRemotePaste(sendKey);
|
||
|
|
expect(keys).toEqual([
|
||
|
|
[0xffe3, "ControlLeft", true],
|
||
|
|
[0x76, "KeyV", true],
|
||
|
|
[0x76, "KeyV", false],
|
||
|
|
[0xffe3, "ControlLeft", false],
|
||
|
|
]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("syncs host text into RFB then sends a remote paste", () => {
|
||
|
|
const keys: Array<[number, string, boolean | undefined]> = [];
|
||
|
|
const rfb = {
|
||
|
|
viewOnly: false,
|
||
|
|
_rfbConnectionState: "connected",
|
||
|
|
clipboardPasteFrom: vi.fn(),
|
||
|
|
sendKey: (keysym: number, code: string, down?: boolean) => {
|
||
|
|
keys.push([keysym, code, down]);
|
||
|
|
},
|
||
|
|
};
|
||
|
|
expect(pasteHostText(rfb, "agent-password")).toBe(true);
|
||
|
|
expect(rfb.clipboardPasteFrom).toHaveBeenCalledWith("agent-password");
|
||
|
|
expect(keys[0]).toEqual([0xffe3, "ControlLeft", false]);
|
||
|
|
expect(keys).toContainEqual([0xffe9, "AltLeft", false]);
|
||
|
|
expect(keys.at(-4)).toEqual([0xffe3, "ControlLeft", true]);
|
||
|
|
expect(keys.at(-3)).toEqual([0x76, "KeyV", true]);
|
||
|
|
expect(pasteHostText({ ...rfb, viewOnly: true }, "x")).toBe(false);
|
||
|
|
expect(pasteHostText(rfb, "")).toBe(false);
|
||
|
|
expect(pasteHostText({ ...rfb, _rfbConnectionState: "connecting" }, "x")).toBe(false);
|
||
|
|
expect(pasteHostText({ ...rfb, _rfbConnectionState: "disconnected" }, "x")).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("does not swallow paste while the RFB session is still connecting", () => {
|
||
|
|
const listeners = new Map<string, (event: object) => void>();
|
||
|
|
const target = {
|
||
|
|
addEventListener: (type: string, listener: (event: object) => void) => {
|
||
|
|
listeners.set(type, listener);
|
||
|
|
},
|
||
|
|
removeEventListener: (type: string) => {
|
||
|
|
listeners.delete(type);
|
||
|
|
},
|
||
|
|
};
|
||
|
|
const rfb = {
|
||
|
|
viewOnly: false,
|
||
|
|
_rfbConnectionState: "connecting",
|
||
|
|
clipboardPasteFrom: vi.fn(),
|
||
|
|
sendKey: vi.fn(),
|
||
|
|
};
|
||
|
|
attachHostClipboardPaste(rfb, { target });
|
||
|
|
const preventDefault = vi.fn();
|
||
|
|
const pasteStop = vi.fn();
|
||
|
|
listeners.get("paste")?.({
|
||
|
|
clipboardData: {
|
||
|
|
getData: (type: string) => (type === "text/plain" ? "from-host" : ""),
|
||
|
|
},
|
||
|
|
preventDefault,
|
||
|
|
stopPropagation: pasteStop,
|
||
|
|
});
|
||
|
|
expect(preventDefault).not.toHaveBeenCalled();
|
||
|
|
expect(pasteStop).not.toHaveBeenCalled();
|
||
|
|
expect(rfb.clipboardPasteFrom).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("intercepts paste chords and applies host clipboard text", () => {
|
||
|
|
const listeners = new Map<string, (event: object) => void>();
|
||
|
|
const target = {
|
||
|
|
addEventListener: (type: string, listener: (event: object) => void) => {
|
||
|
|
listeners.set(type, listener);
|
||
|
|
},
|
||
|
|
removeEventListener: (type: string) => {
|
||
|
|
listeners.delete(type);
|
||
|
|
},
|
||
|
|
};
|
||
|
|
const rfb = {
|
||
|
|
viewOnly: false,
|
||
|
|
_rfbConnectionState: "connected",
|
||
|
|
clipboardPasteFrom: vi.fn(),
|
||
|
|
sendKey: vi.fn(),
|
||
|
|
};
|
||
|
|
const detach = attachHostClipboardPaste(rfb, { target });
|
||
|
|
|
||
|
|
const keydown = listeners.get("keydown");
|
||
|
|
const paste = listeners.get("paste");
|
||
|
|
expect(keydown).toBeTypeOf("function");
|
||
|
|
expect(paste).toBeTypeOf("function");
|
||
|
|
|
||
|
|
const stopPropagation = vi.fn();
|
||
|
|
keydown?.({
|
||
|
|
ctrlKey: true,
|
||
|
|
metaKey: false,
|
||
|
|
altKey: false,
|
||
|
|
repeat: false,
|
||
|
|
code: "KeyV",
|
||
|
|
key: "v",
|
||
|
|
stopPropagation,
|
||
|
|
});
|
||
|
|
expect(stopPropagation).toHaveBeenCalledOnce();
|
||
|
|
|
||
|
|
const preventDefault = vi.fn();
|
||
|
|
const pasteStop = vi.fn();
|
||
|
|
paste?.({
|
||
|
|
clipboardData: {
|
||
|
|
getData: (type: string) => (type === "text/plain" ? "from-host" : ""),
|
||
|
|
},
|
||
|
|
preventDefault,
|
||
|
|
stopPropagation: pasteStop,
|
||
|
|
});
|
||
|
|
expect(preventDefault).toHaveBeenCalledOnce();
|
||
|
|
expect(pasteStop).toHaveBeenCalledOnce();
|
||
|
|
expect(rfb.clipboardPasteFrom).toHaveBeenCalledWith("from-host");
|
||
|
|
expect(rfb.sendKey).toHaveBeenCalled();
|
||
|
|
|
||
|
|
detach();
|
||
|
|
expect(listeners.size).toBe(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("ships the bridge next to embed.html in the computer image", () => {
|
||
|
|
const root = path.resolve(import.meta.dirname, "../../computer");
|
||
|
|
const dockerfile = readFileSync(path.join(root, "Dockerfile"), "utf8");
|
||
|
|
const embed = readFileSync(path.join(root, "embed.html"), "utf8");
|
||
|
|
const start = readFileSync(path.join(root, "start.sh"), "utf8");
|
||
|
|
const supervisor = readFileSync(path.join(import.meta.dirname, "index.ts"), "utf8");
|
||
|
|
expect(dockerfile).toMatch(/clipboard-bridge\.js/);
|
||
|
|
expect(embed).toMatch(/attachHostClipboardPaste/);
|
||
|
|
expect(embed).toMatch(/clipboard-bridge\.js/);
|
||
|
|
expect(start).toMatch(/clipboard-bridge\.js/);
|
||
|
|
expect(supervisor).toMatch(/"clipboard-bridge\.js"/);
|
||
|
|
});
|
||
|
|
});
|