Files
NetMesh/components/terminal/terminalClipboardPaste.test.ts

455 lines
14 KiB
TypeScript
Raw Normal View History

import test from "node:test";
import assert from "node:assert/strict";
import { handleTerminalClipboardPaste } from "./terminalClipboardPaste";
test("terminal user paste does not inspect or upload remote clipboard images", async () => {
const pasted: string[] = [];
const readTextCalls: string[] = [];
const bridge = {
readClipboardImage: async () => assert.fail("user paste must not read clipboard images"),
readClipboardFiles: async () => [],
};
await handleTerminalClipboardPaste({
bridge,
isLocalConnection: false,
readClipboardText: async () => {
readTextCalls.push("read");
return "hello";
},
sessionId: "session-1",
terminalBackend: {
writeToSession: () => assert.fail("remote user paste should use xterm paste handling"),
},
term: {
paste: (text) => pasted.push(text),
scrollToBottom: () => {},
},
});
assert.deepEqual(readTextCalls, ["read"]);
assert.deepEqual(pasted, ["hello"]);
});
test("terminal user paste still inserts local clipboard file paths", async () => {
const writes: Array<{ data: string; sensitive?: boolean }> = [];
const scrolled: string[] = [];
let focused = false;
await handleTerminalClipboardPaste({
bridge: {
readClipboardFiles: async () => [
{ path: "/Users/alice/shot.png", name: "shot.png", isDirectory: false },
{ path: "/Users/alice/report.txt", name: "report.txt", isDirectory: false },
],
hasClipboardImage: async () => assert.fail("local file paste must not probe clipboard images"),
},
isLocalConnection: true,
isSensitiveInput: () => true,
readClipboardText: async () => assert.fail("local file paste should not fall through to text"),
scrollToBottomAfterProgrammaticInput: (data) => scrolled.push(data),
sessionId: "session-1",
terminalBackend: {
writeToSession: (_sessionId, data, options) => writes.push({
data,
sensitive: options?.sensitive,
}),
},
term: {
focus: () => {
focused = true;
},
paste: () => assert.fail("local file paste should write paths directly"),
scrollToBottom: () => {},
},
});
assert.deepEqual(writes, [{
data: "/Users/alice/shot.png /Users/alice/report.txt",
sensitive: true,
}]);
assert.deepEqual(scrolled, ["/Users/alice/shot.png /Users/alice/report.txt"]);
assert.equal(focused, true);
});
test("terminal user paste auto-uploads a clipboard image in remote sessions", async () => {
const writes: Array<{ data: string }> = [];
const readTextCalls: string[] = [];
const image = {
path: "/tmp/netcatty/shot.png",
name: "shot.png",
mediaType: "image/png",
size: 12,
};
await handleTerminalClipboardPaste({
bridge: {
readClipboardFiles: async () => [],
},
autoUploadClipboardImage: true,
clipboardImageBridge: {
readClipboardImage: async () => image,
openSftpForSession: async () => "sftp-1",
startStreamTransfer: async (options) => ({ transferId: options.transferId }),
closeSftp: async () => {},
deleteTempFile: async () => ({ success: true }),
},
getRemoteCwd: async () => "/home/alice",
isLocalConnection: false,
readClipboardText: async () => {
readTextCalls.push("read");
return "hello";
},
sessionId: "session-1",
terminalBackend: {
writeToSession: (_sessionId, data) => writes.push({ data }),
},
term: {
paste: () => assert.fail("image upload should insert the remote path, not paste text"),
scrollToBottom: () => {},
},
});
assert.deepEqual(writes, [{ data: "/home/alice/.netcatty-paste-images/shot.png" }]);
assert.deepEqual(readTextCalls, []);
});
test("terminal user paste falls back to text when the clipboard holds no image", async () => {
const pasted: string[] = [];
let sftpOpened = false;
await handleTerminalClipboardPaste({
bridge: {
readClipboardFiles: async () => [],
},
autoUploadClipboardImage: true,
clipboardImageBridge: {
readClipboardImage: async () => null,
openSftpForSession: async () => {
sftpOpened = true;
return "sftp-1";
},
startStreamTransfer: async (options) => ({ transferId: options.transferId }),
},
getRemoteCwd: async () => "/home/alice",
isLocalConnection: false,
readClipboardText: async () => "hello",
sessionId: "session-1",
terminalBackend: {
writeToSession: () => assert.fail("no image should fall back to text paste"),
},
term: {
paste: (text) => pasted.push(text),
scrollToBottom: () => {},
},
});
assert.equal(sftpOpened, false);
assert.deepEqual(pasted, ["hello"]);
});
test("terminal user paste reports failed uploads instead of pasting text", async () => {
const results: unknown[] = [];
const readTextCalls: string[] = [];
const image = {
path: "/tmp/netcatty/shot.png",
name: "shot.png",
mediaType: "image/png",
size: 12,
};
await handleTerminalClipboardPaste({
bridge: {
readClipboardFiles: async () => [],
},
autoUploadClipboardImage: true,
clipboardImageBridge: {
readClipboardImage: async () => image,
openSftpForSession: async () => "sftp-1",
startStreamTransfer: async (options) => ({ transferId: options.transferId, error: "disk full" }),
},
getRemoteCwd: async () => "/home/alice",
isLocalConnection: false,
onClipboardImageUploadResult: (result) => results.push(result),
readClipboardText: async () => {
readTextCalls.push("read");
return "hello";
},
sessionId: "session-1",
terminalBackend: {
writeToSession: () => assert.fail("failed upload should not paste anything"),
},
term: {
paste: () => assert.fail("failed upload should not paste text"),
scrollToBottom: () => {},
},
});
assert.deepEqual(results, [{ ok: false, reason: "upload-failed" }]);
assert.deepEqual(readTextCalls, []);
});
test("terminal user paste reports thrown upload failures instead of pasting text", async () => {
const results: unknown[] = [];
const readTextCalls: string[] = [];
const image = {
path: "/tmp/netcatty/shot.png",
name: "shot.png",
mediaType: "image/png",
size: 12,
};
await handleTerminalClipboardPaste({
bridge: {
readClipboardFiles: async () => [],
},
autoUploadClipboardImage: true,
clipboardImageBridge: {
readClipboardImage: async () => image,
openSftpForSession: async () => {
throw new Error("SFTP unavailable");
},
startStreamTransfer: async (options) => ({ transferId: options.transferId }),
},
getRemoteCwd: async () => "/home/alice",
isLocalConnection: false,
onClipboardImageUploadResult: (result) => results.push(result),
readClipboardText: async () => {
readTextCalls.push("read");
return "hello";
},
sessionId: "session-1",
terminalBackend: {
writeToSession: () => assert.fail("thrown upload failure must not paste anything"),
},
term: {
paste: () => assert.fail("thrown upload failure must not paste text"),
scrollToBottom: () => {},
},
});
assert.deepEqual(results, [{ ok: false, reason: "upload-failed" }]);
assert.deepEqual(readTextCalls, []);
});
test("terminal user paste keeps local file path paste even with auto-upload enabled", async () => {
const writes: Array<{ data: string }> = [];
await handleTerminalClipboardPaste({
bridge: {
readClipboardFiles: async () => [
{ path: "/Users/alice/shot.png", name: "shot.png", isDirectory: false },
],
},
autoUploadClipboardImage: true,
isLocalConnection: true,
readClipboardText: async () => assert.fail("local file paste should not fall through to text"),
sessionId: "session-1",
terminalBackend: {
writeToSession: (_sessionId, data) => writes.push({ data }),
},
term: {
paste: () => assert.fail("local file paste should write paths directly"),
scrollToBottom: () => {},
},
});
assert.deepEqual(writes, [{ data: "/Users/alice/shot.png" }]);
});
test("local paste forwards Ctrl+V when clipboard holds only an image", async () => {
const writes: Array<{ data: string; sensitive?: boolean }> = [];
const scrolled: string[] = [];
let focused = false;
await handleTerminalClipboardPaste({
bridge: {
readClipboardFiles: async () => [],
hasClipboardImage: async () => true,
},
isLocalConnection: true,
isSensitiveInput: () => true,
readClipboardText: async () => "",
sessionId: "session-1",
scrollToBottomAfterProgrammaticInput: (data) => scrolled.push(data),
terminalBackend: {
writeToSession: (_sessionId, data, options) => writes.push({
data,
sensitive: options?.sensitive,
}),
},
term: {
focus: () => {
focused = true;
},
paste: () => assert.fail("image-only local paste must not use xterm paste"),
scrollToBottom: () => {},
},
});
assert.deepEqual(writes, [{ data: "\u0016", sensitive: true }]);
assert.deepEqual(scrolled, ["\u0016"]);
assert.equal(focused, true);
});
test("local paste prefers clipboard text over forwarding Ctrl+V for images", async () => {
const pasted: string[] = [];
let hasImageCalls = 0;
await handleTerminalClipboardPaste({
bridge: {
readClipboardFiles: async () => [],
hasClipboardImage: async () => {
hasImageCalls += 1;
return true;
},
},
isLocalConnection: true,
readClipboardText: async () => "hello",
sessionId: "session-1",
terminalBackend: {
writeToSession: () => assert.fail("text paste should not write Ctrl+V"),
},
term: {
paste: (text) => pasted.push(text),
scrollToBottom: () => {},
},
});
assert.deepEqual(pasted, ["hello"]);
assert.equal(hasImageCalls, 0);
});
test("local paste forwards Ctrl+V when clipboard text read fails but an image is present", async () => {
const writes: string[] = [];
await handleTerminalClipboardPaste({
bridge: {
readClipboardFiles: async () => [],
hasClipboardImage: async () => true,
},
isLocalConnection: true,
readClipboardText: async () => {
throw new Error("clipboard text unavailable");
},
sessionId: "session-1",
terminalBackend: {
writeToSession: (_sessionId, data) => writes.push(data),
},
term: {
paste: () => assert.fail("failed text read must not use xterm paste"),
scrollToBottom: () => {},
},
});
assert.deepEqual(writes, ["\u0016"]);
});
test("local paste treats whitespace-only clipboard text as empty for image forwarding", async () => {
const writes: string[] = [];
await handleTerminalClipboardPaste({
bridge: {
readClipboardFiles: async () => [],
hasClipboardImage: async () => true,
},
isLocalConnection: true,
readClipboardText: async () => " \n\t",
sessionId: "session-1",
terminalBackend: {
writeToSession: (_sessionId, data) => writes.push(data),
},
term: {
paste: () => assert.fail("whitespace-only text must not use xterm paste"),
scrollToBottom: () => {},
},
});
assert.deepEqual(writes, ["\u0016"]);
});
test("local paste keeps whitespace-only clipboard text when no image is present", async () => {
const pasted: string[] = [];
await handleTerminalClipboardPaste({
bridge: {
readClipboardFiles: async () => [],
hasClipboardImage: async () => false,
},
isLocalConnection: true,
readClipboardText: async () => " \n\t",
sessionId: "session-1",
terminalBackend: {
writeToSession: () => assert.fail("whitespace paste should use xterm paste"),
},
term: {
paste: (text) => pasted.push(text),
scrollToBottom: () => {},
},
});
assert.deepEqual(pasted, [" \n\t"]);
});
test("remote paste keeps whitespace-only clipboard text", async () => {
const pasted: string[] = [];
await handleTerminalClipboardPaste({
bridge: {
readClipboardFiles: async () => [],
hasClipboardImage: async () => assert.fail("remote paste must not probe clipboard images"),
},
isLocalConnection: false,
readClipboardText: async () => "\t",
sessionId: "session-1",
terminalBackend: {
writeToSession: () => assert.fail("remote whitespace paste should use xterm paste"),
},
term: {
paste: (text) => pasted.push(text),
scrollToBottom: () => {},
},
});
assert.deepEqual(pasted, ["\t"]);
});
test("local paste does not write when clipboard has neither text nor image", async () => {
await handleTerminalClipboardPaste({
bridge: {
readClipboardFiles: async () => [],
hasClipboardImage: async () => false,
},
isLocalConnection: true,
readClipboardText: async () => "",
sessionId: "session-1",
terminalBackend: {
writeToSession: () => assert.fail("empty local clipboard must not write"),
},
term: {
paste: () => assert.fail("empty local clipboard must not use xterm paste"),
scrollToBottom: () => {},
},
});
});
test("remote paste does not forward Ctrl+V for clipboard images", async () => {
await handleTerminalClipboardPaste({
bridge: {
readClipboardFiles: async () => [],
hasClipboardImage: async () => assert.fail("remote paste must not probe clipboard images"),
},
isLocalConnection: false,
readClipboardText: async () => "",
sessionId: "session-1",
terminalBackend: {
writeToSession: () => assert.fail("remote empty paste must not write Ctrl+V"),
},
term: {
paste: () => assert.fail("empty remote paste must not call xterm paste"),
scrollToBottom: () => {},
},
});
});