Files
NetMesh/components/terminal/terminalDragDropCwd.test.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

714 lines
22 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import type { DropEntry } from "../../lib/sftpFileUtils";
import type { Host } from "../../types";
import { TerminalDropNeedsSudoError } from "../../domain/sftpDropElevation";
import {
ActiveTerminalCwdUnavailableError,
DEFAULT_RZ_MISSING_FALLBACK_TIMEOUT_MS,
handleTerminalDropEntries,
resolveTerminalDropErrorMessage,
} from "./hooks/useTerminalDragDrop";
import { resolvePreferredTerminalCwd } from "./sftpCwd";
const host = {
id: "host-1",
label: "Host",
hostname: "example.com",
port: 22,
username: "alice",
protocol: "ssh",
} as Host;
const dropEntries: DropEntry[] = [
{
file: null,
relativePath: "report.txt",
isDirectory: false,
},
];
test("terminal drag-drop allows the full ZMODEM startup window before falling back", () => {
assert.equal(DEFAULT_RZ_MISSING_FALLBACK_TIMEOUT_MS, 15_000);
});
test("terminal drag-drop shows actionable guidance when the active directory is unknown", () => {
const requestedKeys: string[] = [];
const message = resolveTerminalDropErrorMessage(
new ActiveTerminalCwdUnavailableError(),
(key) => {
requestedKeys.push(key);
return `translated:${key}`;
},
);
assert.equal(message, "translated:terminal.dragDrop.destinationUnknown");
assert.deepEqual(requestedKeys, ["terminal.dragDrop.destinationUnknown"]);
});
test("terminal drag-drop asks to enable sudo when /root is not writable as the login user", () => {
const requestedKeys: string[] = [];
const message = resolveTerminalDropErrorMessage(
new TerminalDropNeedsSudoError(),
(key) => {
requestedKeys.push(key);
return `translated:${key}`;
},
);
assert.equal(message, "translated:terminal.dragDrop.needsSudoElevation");
assert.deepEqual(requestedKeys, ["terminal.dragDrop.needsSudoElevation"]);
});
test("remote SSH terminal drop triggers ZMODEM drag-drop upload", async () => {
let uploadedFiles: unknown;
let uploadedSessionId: string | undefined;
await handleTerminalDropEntries({
dropEntries: [
{
file: {
name: "report.txt",
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
} as File,
relativePath: "report.txt",
isDirectory: false,
},
],
host,
isLocalConnection: false,
resolveSftpInitialPath: async () => "/srv/app/current",
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: {
writeToSession: () => {},
startZmodemDragDropUpload: async (sessionId, files) => {
uploadedSessionId = sessionId;
uploadedFiles = files;
return { success: true };
},
},
termRef: { current: null },
});
assert.equal(uploadedSessionId, "session-1");
assert.equal(Array.isArray(uploadedFiles), true);
const files = uploadedFiles as Array<{ name: string; remoteName: string; data?: ArrayBuffer }>;
assert.equal(files.length, 1);
assert.equal(files[0].name, "report.txt");
assert.equal(files[0].remoteName, "report.txt");
assert.ok(files[0].data);
});
test("remote SSH terminal drop stays on ZMODEM when rz starts", async () => {
let openedSftp = false;
let zmodemCallback: ((event: { type: string; transferType?: string }) => void) | undefined;
await handleTerminalDropEntries({
dropEntries: [
{
file: {
name: "report.txt",
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
} as File,
relativePath: "report.txt",
isDirectory: false,
},
],
host,
isLocalConnection: false,
onOpenSftp: () => {
openedSftp = true;
},
resolveSftpInitialPath: async () => "/srv/app/current",
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: {
writeToSession: () => {},
cancelZmodem: () => {},
onSessionData: () => () => {},
onZmodemEvent: (_sessionId, cb) => {
zmodemCallback = cb;
return () => {
zmodemCallback = undefined;
};
},
startZmodemDragDropUpload: async (_sessionId, _files, uploadCommand) => {
assert.match(uploadCommand ?? "", /NetcattyRzMissing=/);
zmodemCallback?.({ type: "detect", transferType: "upload" });
return { success: true };
},
},
termRef: { current: null },
});
assert.equal(openedSftp, false);
});
test("remote SSH terminal drop still waits for rz after the old 2.5 second deadline", async () => {
let openedSftp = false;
let zmodemCallback: ((event: { type: string; transferType?: string }) => void) | undefined;
await handleTerminalDropEntries({
dropEntries: [{
file: {
name: "report.txt",
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
} as File,
relativePath: "report.txt",
isDirectory: false,
}],
host,
isLocalConnection: false,
onOpenSftp: () => { openedSftp = true; },
resolveSftpInitialPath: async () => "/srv/app/current",
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: {
writeToSession: () => {},
cancelZmodem: () => {},
onSessionData: () => () => {},
onZmodemEvent: (_sessionId, cb) => {
zmodemCallback = cb;
return () => { zmodemCallback = undefined; };
},
startZmodemDragDropUpload: async () => {
setTimeout(() => {
zmodemCallback?.({ type: "detect", transferType: "upload" });
}, 2_600);
return { success: true };
},
},
termRef: { current: null },
});
assert.equal(openedSftp, false);
});
test("serial terminal drop does not wrap rz with an SSH shell fallback", async () => {
let uploadCommandSeen: string | undefined;
await handleTerminalDropEntries({
dropEntries: [
{
file: {
name: "report.txt",
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
} as File,
relativePath: "report.txt",
isDirectory: false,
},
],
host: { ...host, protocol: "serial" } as Host,
isLocalConnection: false,
onOpenSftp: () => {},
resolveSftpInitialPath: async () => "/srv/app/current",
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: {
writeToSession: () => {},
cancelZmodem: () => {},
onSessionData: () => () => {},
startZmodemDragDropUpload: async (_sessionId, _files, uploadCommand) => {
uploadCommandSeen = uploadCommand;
return { success: true };
},
},
termRef: { current: null },
});
assert.equal(uploadCommandSeen, undefined);
});
test("telnet terminal drop does not wrap rz with an SSH shell fallback", async () => {
let uploadCommandSeen: string | undefined;
await handleTerminalDropEntries({
dropEntries: [
{
file: {
name: "report.txt",
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
} as File,
relativePath: "report.txt",
isDirectory: false,
},
],
host: { ...host, protocol: "telnet" } as Host,
isLocalConnection: false,
onOpenSftp: () => {},
resolveSftpInitialPath: async () => "/srv/app/current",
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: {
writeToSession: () => {},
cancelZmodem: () => {},
onSessionData: () => () => {},
startZmodemDragDropUpload: async (_sessionId, _files, uploadCommand) => {
uploadCommandSeen = uploadCommand;
return { success: true };
},
},
termRef: { current: null },
});
assert.equal(uploadCommandSeen, undefined);
});
test("network device drop falls back to SFTP upload with a freshly resolved cwd", async () => {
let receivedOptions: { preferFreshBackend?: boolean } | undefined;
let openedPath: string | undefined;
let openedEntries: DropEntry[] | undefined;
let openedSessionId: string | undefined;
await handleTerminalDropEntries({
dropEntries,
host,
isLocalConnection: false,
isNetworkDevice: true,
onOpenSftp: (_host, initialPath, pendingUploadEntries, sourceSessionId) => {
openedPath = initialPath;
openedEntries = pendingUploadEntries;
openedSessionId = sourceSessionId;
},
resolveSftpInitialPath: async (options) => {
receivedOptions = options;
return "/srv/app/current";
},
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: {
writeToSession: () => {},
},
termRef: { current: null },
});
assert.deepEqual(receivedOptions, {
preferFreshBackend: true,
requireActiveShellCwd: true,
});
assert.equal(openedPath, "/srv/app/current");
assert.equal(openedEntries, dropEntries);
assert.equal(openedSessionId, "session-1");
});
test("remote SSH terminal drop falls back to SFTP when rz is unavailable", async () => {
let receivedOptions: { preferFreshBackend?: boolean } | undefined;
let openedPath: string | undefined;
let openedEntries: DropEntry[] | undefined;
let openedSessionId: string | undefined;
let dataCallback: ((chunk: string) => void) | undefined;
let cancelled: { sessionId: string; interrupt?: boolean } | undefined;
await handleTerminalDropEntries({
dropEntries: [
{
file: {
name: "report.txt",
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
} as File,
relativePath: "report.txt",
isDirectory: false,
},
],
host,
isLocalConnection: false,
onOpenSftp: (_host, initialPath, pendingUploadEntries, sourceSessionId) => {
openedPath = initialPath;
openedEntries = pendingUploadEntries;
openedSessionId = sourceSessionId;
},
resolveSftpInitialPath: async (options) => {
receivedOptions = options;
return "/srv/app/current";
},
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: {
writeToSession: () => {},
onSessionData: (_sessionId: string, cb: (chunk: string) => void) => {
dataCallback = cb;
return () => {
dataCallback = undefined;
};
},
cancelZmodem: (sessionId: string, options?: { interrupt?: boolean }) => {
cancelled = { sessionId, interrupt: options?.interrupt };
},
startZmodemDragDropUpload: async (_sessionId, _files, uploadCommand) => {
assert.match(uploadCommand ?? "", /NetcattyRzMissing=/);
assert.equal((uploadCommand ?? "").includes("\u001b]1337;NetcattyRzMissing="), false);
const token = uploadCommand?.match(/NetcattyRzMissing=([A-Za-z0-9_-]+)/)?.[1];
assert.ok(token);
dataCallback?.(`\u001b]1337;NetcattyRzMissing=${token}\u0007`);
return { success: true };
},
},
termRef: { current: null },
});
assert.deepEqual(receivedOptions, {
preferFreshBackend: true,
requireActiveShellCwd: true,
});
assert.equal(openedPath, "/srv/app/current");
assert.equal(openedEntries?.length, 1);
assert.equal(openedEntries?.[0].relativePath, "report.txt");
assert.equal(openedSessionId, "session-1");
assert.deepEqual(cancelled, { sessionId: "session-1", interrupt: false });
});
test("remote SSH terminal drop falls back to SFTP when rz never starts", async () => {
let receivedOptions: { preferFreshBackend?: boolean } | undefined;
let openedPath: string | undefined;
let openedEntries: DropEntry[] | undefined;
let cancelled: { sessionId: string; interrupt?: boolean } | undefined;
await handleTerminalDropEntries({
dropEntries: [
{
file: {
name: "report.txt",
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
} as File,
relativePath: "report.txt",
isDirectory: false,
},
],
host,
isLocalConnection: false,
onOpenSftp: (_host, initialPath, pendingUploadEntries) => {
openedPath = initialPath;
openedEntries = pendingUploadEntries;
},
resolveSftpInitialPath: async (options) => {
receivedOptions = options;
return "/srv/app/current";
},
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: {
writeToSession: () => {},
onSessionData: () => () => {},
cancelZmodem: (sessionId: string, options?: { interrupt?: boolean }) => {
cancelled = { sessionId, interrupt: options?.interrupt };
},
startZmodemDragDropUpload: async () => ({ success: true }),
},
rzMissingFallbackTimeoutMs: 1,
termRef: { current: null },
});
assert.deepEqual(receivedOptions, {
preferFreshBackend: true,
requireActiveShellCwd: true,
});
assert.equal(openedPath, "/srv/app/current");
assert.equal(openedEntries?.length, 1);
assert.deepEqual(cancelled, { sessionId: "session-1", interrupt: true });
});
test("remote SSH folder drop uses SFTP to preserve directory structure", async () => {
let openedEntries: DropEntry[] | undefined;
let originSessionId: string | undefined;
let sourceSessionId: string | undefined;
let zmodemStarted = false;
const folderEntries: DropEntry[] = [
{
file: null,
relativePath: "docs",
isDirectory: true,
},
{
file: {
name: "guide.txt",
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
} as File,
relativePath: "docs/guide.txt",
isDirectory: false,
},
];
await handleTerminalDropEntries({
dropEntries: folderEntries,
host,
isLocalConnection: false,
onOpenSftp: (_host, _initialPath, pendingUploadEntries, origin, source) => {
openedEntries = pendingUploadEntries;
originSessionId = origin;
sourceSessionId = source;
},
resolveSftpInitialPath: async () => "/srv/app/current",
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: {
writeToSession: () => {},
startZmodemDragDropUpload: async () => {
zmodemStarted = true;
return { success: true };
},
},
termRef: { current: null },
});
assert.equal(zmodemStarted, false);
assert.equal(openedEntries, folderEntries);
assert.equal(originSessionId, "session-1");
assert.equal(sourceSessionId, "session-1");
});
test("Mosh folder drop keeps its origin but opens a fresh SFTP route", async () => {
let originSessionId: string | undefined;
let sourceSessionId: string | undefined;
await handleTerminalDropEntries({
dropEntries: [{ file: null, relativePath: "docs", isDirectory: true }],
host: { ...host, moshEnabled: true },
isLocalConnection: false,
onOpenSftp: (_host, _path, _entries, origin, source) => {
originSessionId = origin;
sourceSessionId = source;
},
resolveSftpInitialPath: async () => "/srv/app/current",
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "mosh-session",
sessionRef: { current: "mosh-session" },
terminalBackend: { writeToSession: () => {} },
termRef: { current: null },
});
assert.equal(originSessionId, "mosh-session");
assert.equal(sourceSessionId, undefined);
});
test("ET rz fallback keeps its origin but opens a fresh SFTP route", async () => {
let originSessionId: string | undefined;
let sourceSessionId: string | undefined;
let dataCallback: ((chunk: string) => void) | undefined;
await handleTerminalDropEntries({
dropEntries: [{
file: {
name: "report.txt",
arrayBuffer: async () => new Uint8Array([1]).buffer,
} as File,
relativePath: "report.txt",
isDirectory: false,
}],
host: { ...host, etEnabled: true },
isLocalConnection: false,
onOpenSftp: (_host, _path, _entries, origin, source) => {
originSessionId = origin;
sourceSessionId = source;
},
resolveSftpInitialPath: async () => "/srv/app/current",
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "et-session",
sessionRef: { current: "et-session" },
terminalBackend: {
writeToSession: () => {},
onSessionData: (_sessionId, callback) => {
dataCallback = callback;
return () => { dataCallback = undefined; };
},
cancelZmodem: () => {},
startZmodemDragDropUpload: async (_sessionId, _files, uploadCommand) => {
const token = uploadCommand?.match(/NetcattyRzMissing=([A-Za-z0-9_-]+)/)?.[1];
assert.ok(token);
dataCallback?.(`\u001b]1337;NetcattyRzMissing=${token}\u0007`);
return { success: true };
},
},
termRef: { current: null },
});
assert.equal(originSessionId, "et-session");
assert.equal(sourceSessionId, undefined);
});
test("remote SSH folder drop to /root reuses the saved host password for sudo SFTP", async () => {
let openedHost: Host | undefined;
let openedPath: string | undefined;
await handleTerminalDropEntries({
dropEntries: [{ file: null, relativePath: "docs", isDirectory: true }],
host: { ...host, password: "secret" },
isLocalConnection: false,
onOpenSftp: (nextHost, initialPath) => {
openedHost = nextHost;
openedPath = initialPath;
},
resolveSftpInitialPath: async () => "/root",
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: { writeToSession: () => {} },
termRef: { current: null },
});
assert.equal(openedPath, "/root");
assert.equal(openedHost?.sftpSudo, true);
assert.equal(openedHost?.password, "secret");
assert.equal(host.sftpSudo, undefined);
});
test("remote SSH folder drop to /root uses a resolved identity username over a stale host username", async () => {
let openedHost: Host | undefined;
await handleTerminalDropEntries({
dropEntries: [{ file: null, relativePath: "docs", isDirectory: true }],
host: { ...host, username: "root", password: "secret" },
resolvedLoginUsername: "alice",
resolvedSudoPassword: "secret",
isLocalConnection: false,
onOpenSftp: (nextHost) => {
openedHost = nextHost;
},
resolveSftpInitialPath: async () => "/root",
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: { writeToSession: () => {} },
termRef: { current: null },
});
assert.equal(openedHost?.sftpSudo, true);
});
test("remote SSH folder drop to /root uses a resolved identity password", async () => {
let openedHost: Host | undefined;
await handleTerminalDropEntries({
dropEntries: [{ file: null, relativePath: "docs", isDirectory: true }],
host: { ...host, identityId: "id-1" },
resolvedSudoPassword: "identity-secret",
isLocalConnection: false,
onOpenSftp: (nextHost) => {
openedHost = nextHost;
},
resolveSftpInitialPath: async () => "/root",
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: { writeToSession: () => {} },
termRef: { current: null },
});
assert.equal(openedHost?.sftpSudo, true);
assert.equal(openedHost?.password, undefined);
assert.equal(openedHost?.identityId, "id-1");
});
test("remote SSH folder drop to /root fails closed without a saved sudo password", async () => {
let openedSftp = false;
await assert.rejects(
handleTerminalDropEntries({
dropEntries: [{ file: null, relativePath: "docs", isDirectory: true }],
host,
isLocalConnection: false,
onOpenSftp: () => {
openedSftp = true;
},
resolveSftpInitialPath: async () => "/root",
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: { writeToSession: () => {} },
termRef: { current: null },
}),
TerminalDropNeedsSudoError,
);
assert.equal(openedSftp, false);
});
test("remote SSH folder drop to the login home does not enable sudo", async () => {
let openedHost: Host | undefined;
await handleTerminalDropEntries({
dropEntries: [{ file: null, relativePath: "docs", isDirectory: true }],
host: { ...host, password: "secret" },
isLocalConnection: false,
onOpenSftp: (nextHost) => {
openedHost = nextHost;
},
resolveSftpInitialPath: async () => "/home/alice",
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: { writeToSession: () => {} },
termRef: { current: null },
});
assert.equal(openedHost?.sftpSudo, undefined);
});
test("remote SSH folder drop refuses to guess a destination when the active shell cwd is unknown", async () => {
let openedSftp = false;
let receivedOptions: unknown;
await assert.rejects(
handleTerminalDropEntries({
dropEntries: [
{
file: null,
relativePath: "docs",
isDirectory: true,
},
],
host,
isLocalConnection: false,
onOpenSftp: () => {
openedSftp = true;
},
resolveSftpInitialPath: async (options) => {
receivedOptions = options;
return undefined;
},
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: {
writeToSession: () => {},
},
termRef: { current: null },
}),
/Could not determine the active terminal directory/,
);
assert.deepEqual(receivedOptions, {
preferFreshBackend: true,
requireActiveShellCwd: true,
});
assert.equal(openedSftp, false);
});
test("fresh cwd resolution falls back to the renderer cwd when backend probe has no real cwd", async () => {
const cwd = await resolvePreferredTerminalCwd({
rendererCwd: "/srv/app/current",
sessionId: "session-1",
preferFreshBackend: true,
getSessionPwd: async (_sessionId, options) => {
assert.deepEqual(options, {
allowHomeFallback: false,
allowLoginShellFallback: true,
});
return { success: false, error: "Could not determine cwd" };
},
});
assert.equal(cwd, "/srv/app/current");
});