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
1199 lines
37 KiB
TypeScript
1199 lines
37 KiB
TypeScript
import test, { type TestContext } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import {
|
|
UploadController,
|
|
startUploadScanningTask,
|
|
uploadEntriesDirect,
|
|
uploadFromDataTransfer,
|
|
uploadFromFileList,
|
|
} from "../../lib/uploadService.ts";
|
|
import { DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY } from "../../domain/sftpTransferConcurrency.ts";
|
|
|
|
function createDataTransfer(files: File[]): DataTransfer {
|
|
return {
|
|
items: { length: 0 },
|
|
files,
|
|
} as unknown as DataTransfer;
|
|
}
|
|
|
|
function createDataTransferWithNullEntries(files: File[]): DataTransfer {
|
|
const items = files.map((file) => ({
|
|
kind: "file",
|
|
getAsFile: () => file,
|
|
webkitGetAsEntry: () => null,
|
|
}));
|
|
return {
|
|
items,
|
|
files,
|
|
} as unknown as DataTransfer;
|
|
}
|
|
|
|
function installCompressedUploadBridge(
|
|
t: TestContext,
|
|
options: {
|
|
supported?: boolean;
|
|
onStart?: (payload: {
|
|
compressionId: string;
|
|
folderPath: string;
|
|
targetPath: string;
|
|
folderName: string;
|
|
}) => void;
|
|
} = {},
|
|
) {
|
|
const previousWindow = globalThis.window;
|
|
const previousNetcatty = previousWindow?.netcatty;
|
|
const nextWindow = previousWindow ?? ({} as Window & typeof globalThis);
|
|
nextWindow.netcatty = {
|
|
...previousNetcatty,
|
|
getPathForFile: (file: File) => (file as File & { path?: string }).path,
|
|
checkCompressedUploadSupport: async () => ({
|
|
supported: options.supported !== false,
|
|
localTar: true,
|
|
remoteTar: options.supported !== false,
|
|
}),
|
|
startCompressedUpload: async (payload) => {
|
|
options.onStart?.(payload);
|
|
return { compressionId: payload.compressionId, success: true };
|
|
},
|
|
} as NetcattyBridge;
|
|
Object.defineProperty(globalThis, "window", {
|
|
value: nextWindow,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
t.after(() => {
|
|
if (previousWindow) {
|
|
previousWindow.netcatty = previousNetcatty;
|
|
Object.defineProperty(globalThis, "window", {
|
|
value: previousWindow,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
} else {
|
|
Reflect.deleteProperty(globalThis, "window");
|
|
}
|
|
});
|
|
}
|
|
|
|
function createPickedFolderFile(): File {
|
|
const file = new File(["folder payload"], "file.txt", { lastModified: 1234 });
|
|
Object.defineProperty(file, "path", { value: "/local/docs/file.txt", writable: true });
|
|
Object.defineProperty(file, "webkitRelativePath", { value: "docs/file.txt" });
|
|
return file;
|
|
}
|
|
|
|
test("upload scanning task can be shown and cancelled before transfers start", () => {
|
|
const events: string[] = [];
|
|
const scanningTask = startUploadScanningTask(
|
|
{
|
|
onScanningStart: (taskId) => events.push(`start:${taskId}`),
|
|
onScanningEnd: (taskId) => events.push(`end:${taskId}`),
|
|
onTaskCancelled: (taskId) => events.push(`cancel:${taskId}`),
|
|
},
|
|
"scan-folder-1",
|
|
);
|
|
|
|
assert.equal(scanningTask.isOpen(), true);
|
|
scanningTask.cancel();
|
|
scanningTask.complete();
|
|
|
|
assert.equal(scanningTask.isOpen(), false);
|
|
assert.deepEqual(events, ["start:scan-folder-1", "cancel:scan-folder-1"]);
|
|
});
|
|
|
|
test("clears the scanning placeholder when every dropped file is skipped by conflict resolution", async () => {
|
|
const events: string[] = [];
|
|
const file = new File(["local"], "conflict.txt", { lastModified: 1234 });
|
|
|
|
const results = await uploadFromDataTransfer(
|
|
createDataTransfer([file]),
|
|
{
|
|
targetPath: "/target",
|
|
sftpId: null,
|
|
isLocal: true,
|
|
bridge: {
|
|
mkdirSftp: async () => {},
|
|
statLocal: async () => ({ type: "file", size: 10, lastModified: 1000 }),
|
|
writeLocalFile: async () => {
|
|
throw new Error("skipped conflicts should not upload");
|
|
},
|
|
},
|
|
joinPath: (base, name) => `${base}/${name}`,
|
|
callbacks: {
|
|
onScanningStart: () => events.push("scan:start"),
|
|
onScanningEnd: () => events.push("scan:end"),
|
|
onTaskCreated: () => events.push("task:create"),
|
|
},
|
|
resolveConflict: async () => "skip",
|
|
},
|
|
);
|
|
|
|
assert.deepEqual(results, [
|
|
{ fileName: "conflict.txt", success: false, cancelled: true },
|
|
]);
|
|
assert.deepEqual(events, ["scan:start", "scan:end"]);
|
|
});
|
|
|
|
test("uploads DataTransfer files when entry extraction returns no entries", async () => {
|
|
const file = new File(["picked"], "picked.txt", { lastModified: 1234 });
|
|
Object.defineProperty(file, "path", { value: "/tmp/picked.txt" });
|
|
const uploadedPaths: string[] = [];
|
|
|
|
const results = await uploadFromDataTransfer(
|
|
createDataTransferWithNullEntries([file]),
|
|
{
|
|
targetPath: "/target",
|
|
sftpId: "sftp-1",
|
|
isLocal: false,
|
|
bridge: {
|
|
mkdirSftp: async () => {},
|
|
startStreamTransfer: async ({ targetPath }) => {
|
|
uploadedPaths.push(targetPath);
|
|
return { transferId: "picked-transfer" };
|
|
},
|
|
},
|
|
joinPath: (base, name) => `${base}/${name}`,
|
|
},
|
|
);
|
|
|
|
assert.deepEqual(uploadedPaths, ["/target/picked.txt"]);
|
|
assert.deepEqual(results, [
|
|
{ fileName: "picked.txt", success: true },
|
|
]);
|
|
});
|
|
|
|
test("uploads picked folder files with their relative directory structure", async () => {
|
|
const file = new File(["nested"], "file.txt", { lastModified: 1234 });
|
|
Object.defineProperty(file, "path", { value: "/tmp/folder/sub/file.txt" });
|
|
Object.defineProperty(file, "webkitRelativePath", {
|
|
value: "folder/sub/file.txt",
|
|
});
|
|
const madeDirs: string[] = [];
|
|
const uploadedPaths: string[] = [];
|
|
|
|
const results = await uploadFromFileList(
|
|
[file],
|
|
{
|
|
targetPath: "/target",
|
|
sftpId: "sftp-1",
|
|
isLocal: false,
|
|
bridge: {
|
|
mkdirSftp: async (_sftpId, path) => {
|
|
madeDirs.push(path);
|
|
},
|
|
startStreamTransfer: async ({ targetPath }) => {
|
|
uploadedPaths.push(targetPath);
|
|
return { transferId: "folder-transfer" };
|
|
},
|
|
},
|
|
joinPath: (base, name) => `${base}/${name}`,
|
|
},
|
|
);
|
|
|
|
assert.deepEqual(madeDirs, ["/target/folder", "/target/folder/sub"]);
|
|
assert.deepEqual(uploadedPaths, ["/target/folder/sub/file.txt"]);
|
|
assert.deepEqual(results, [
|
|
{ fileName: "folder/sub/file.txt", success: true },
|
|
]);
|
|
});
|
|
|
|
test("ordinary multi-file uploads use the shared transfer concurrency", async () => {
|
|
const countInitiallyStarted = async (fileTransferConcurrency?: number) => {
|
|
const started: string[] = [];
|
|
let releaseAll!: () => void;
|
|
const gate = new Promise<void>((resolve) => { releaseAll = resolve; });
|
|
const files = Array.from({ length: DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY + 2 }, (_, index) => {
|
|
const file = new File([`file-${index}`], `file-${index}.txt`);
|
|
Object.defineProperty(file, "path", { value: `/local/file-${index}.txt` });
|
|
return file;
|
|
});
|
|
|
|
const uploading = uploadFromFileList(
|
|
files,
|
|
{
|
|
targetPath: "/target",
|
|
sftpId: "sftp-1",
|
|
fileTransferConcurrency,
|
|
isLocal: false,
|
|
bridge: {
|
|
mkdirSftp: async () => {},
|
|
startStreamTransfer: async ({ targetPath }) => {
|
|
started.push(targetPath);
|
|
await gate;
|
|
return { transferId: targetPath };
|
|
},
|
|
},
|
|
joinPath: (base, name) => `${base}/${name}`,
|
|
},
|
|
);
|
|
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
const initiallyStarted = started.length;
|
|
releaseAll();
|
|
await uploading;
|
|
return initiallyStarted;
|
|
};
|
|
|
|
assert.equal(
|
|
await countInitiallyStarted(),
|
|
DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY,
|
|
);
|
|
assert.equal(await countInitiallyStarted(3), 3);
|
|
});
|
|
|
|
test("compression remains enabled for DataTransfer folder uploads that have a conflict resolver", async (t) => {
|
|
const starts: string[] = [];
|
|
installCompressedUploadBridge(t, {
|
|
onStart: (payload) => starts.push(`${payload.folderName}:${payload.folderPath}`),
|
|
});
|
|
let conflictCalls = 0;
|
|
let streamCalls = 0;
|
|
const file = createPickedFolderFile();
|
|
|
|
const results = await uploadFromDataTransfer(
|
|
createDataTransferWithNullEntries([file]),
|
|
{
|
|
targetPath: "/remote",
|
|
sftpId: "sftp-1",
|
|
isLocal: false,
|
|
bridge: {
|
|
mkdirSftp: async () => {},
|
|
statSftp: async () => null,
|
|
startStreamTransfer: async (payload) => {
|
|
streamCalls += 1;
|
|
return { transferId: payload.transferId };
|
|
},
|
|
},
|
|
joinPath: (base, name) => `${base}/${name}`,
|
|
useCompressedUpload: true,
|
|
resolveConflict: async () => {
|
|
conflictCalls += 1;
|
|
return "merge";
|
|
},
|
|
},
|
|
);
|
|
|
|
assert.deepEqual(starts, ["docs:/local/docs"]);
|
|
assert.equal(conflictCalls, 0);
|
|
assert.equal(streamCalls, 0);
|
|
assert.deepEqual(results, [{ fileName: "docs", success: true }]);
|
|
});
|
|
|
|
test("compression remains enabled for picked-folder uploads that have a conflict resolver", async (t) => {
|
|
let compressedStarts = 0;
|
|
installCompressedUploadBridge(t, {
|
|
onStart: () => { compressedStarts += 1; },
|
|
});
|
|
let streamCalls = 0;
|
|
const results = await uploadFromFileList(
|
|
[createPickedFolderFile()],
|
|
{
|
|
targetPath: "/remote",
|
|
sftpId: "sftp-1",
|
|
isLocal: false,
|
|
bridge: {
|
|
mkdirSftp: async () => {},
|
|
statSftp: async () => null,
|
|
startStreamTransfer: async (payload) => {
|
|
streamCalls += 1;
|
|
return { transferId: payload.transferId };
|
|
},
|
|
},
|
|
joinPath: (base, name) => `${base}/${name}`,
|
|
useCompressedUpload: true,
|
|
resolveConflict: async () => "merge",
|
|
},
|
|
);
|
|
|
|
assert.equal(compressedStarts, 1);
|
|
assert.equal(streamCalls, 0);
|
|
assert.deepEqual(results, [{ fileName: "docs", success: true }]);
|
|
});
|
|
|
|
test("compressed folder conflict decisions preserve merge, replace, and fallback semantics", async (t) => {
|
|
let supported = true;
|
|
let compressedStarts = 0;
|
|
installCompressedUploadBridge(t, {
|
|
get supported() { return supported; },
|
|
onStart: () => { compressedStarts += 1; },
|
|
});
|
|
const entry = {
|
|
file: createPickedFolderFile(),
|
|
relativePath: "docs/file.txt",
|
|
isDirectory: false,
|
|
};
|
|
|
|
let conflictCalls = 0;
|
|
const merged = await uploadEntriesDirect(
|
|
[entry],
|
|
{
|
|
targetPath: "/remote",
|
|
sftpId: "sftp-1",
|
|
isLocal: false,
|
|
bridge: {
|
|
mkdirSftp: async () => {},
|
|
statSftp: async () => ({ type: "directory", size: 0, lastModified: 1000 }),
|
|
},
|
|
joinPath: (base, name) => `${base}/${name}`,
|
|
useCompressedUpload: true,
|
|
resolveConflict: async () => {
|
|
conflictCalls += 1;
|
|
return "merge";
|
|
},
|
|
},
|
|
);
|
|
assert.equal(compressedStarts, 1, "directory merge should retain atomic compressed upload");
|
|
assert.equal(conflictCalls, 1);
|
|
assert.deepEqual(merged, [{ fileName: "docs", success: true }]);
|
|
|
|
let exists = true;
|
|
let deletes = 0;
|
|
let streams = 0;
|
|
conflictCalls = 0;
|
|
const replaced = await uploadEntriesDirect(
|
|
[entry],
|
|
{
|
|
targetPath: "/remote",
|
|
sftpId: "sftp-1",
|
|
isLocal: false,
|
|
bridge: {
|
|
mkdirSftp: async () => {},
|
|
statSftp: async (_sftpId, path) => (
|
|
path === "/remote/docs" && exists
|
|
? { type: "directory", size: 0, lastModified: 1000 }
|
|
: null
|
|
),
|
|
deleteSftp: async () => { exists = false; deletes += 1; },
|
|
startStreamTransfer: async (payload) => {
|
|
streams += 1;
|
|
return { transferId: payload.transferId };
|
|
},
|
|
},
|
|
joinPath: (base, name) => `${base}/${name}`,
|
|
useCompressedUpload: true,
|
|
resolveConflict: async () => {
|
|
conflictCalls += 1;
|
|
return "replace";
|
|
},
|
|
},
|
|
);
|
|
assert.equal(compressedStarts, 1, "replace stays on the regular path");
|
|
assert.equal(conflictCalls, 1, "a pre-resolved conflict must not prompt twice");
|
|
assert.equal(deletes, 1);
|
|
assert.equal(streams, 1);
|
|
assert.equal(replaced[0]?.success, true);
|
|
|
|
supported = false;
|
|
conflictCalls = 0;
|
|
streams = 0;
|
|
const fallback = await uploadEntriesDirect(
|
|
[entry],
|
|
{
|
|
targetPath: "/remote",
|
|
sftpId: "sftp-1",
|
|
isLocal: false,
|
|
bridge: {
|
|
mkdirSftp: async () => {},
|
|
statSftp: async () => ({ type: "directory", size: 0, lastModified: 1000 }),
|
|
startStreamTransfer: async (payload) => {
|
|
streams += 1;
|
|
return { transferId: payload.transferId };
|
|
},
|
|
},
|
|
joinPath: (base, name) => `${base}/${name}`,
|
|
useCompressedUpload: true,
|
|
resolveConflict: async () => {
|
|
conflictCalls += 1;
|
|
return "merge";
|
|
},
|
|
},
|
|
);
|
|
assert.equal(conflictCalls, 1, "compression fallback must replay the selected action without another prompt");
|
|
assert.equal(streams, 1);
|
|
assert.equal(fallback[0]?.success, true);
|
|
});
|
|
|
|
test("remote upload without a local path never buffers the whole File into renderer memory", async () => {
|
|
let arrayBufferCalls = 0;
|
|
let stagedFiles = 0;
|
|
let streamTransfers = 0;
|
|
const deleted: string[] = [];
|
|
const file = new File(["payload"], "memory-only.bin") as File & {
|
|
arrayBuffer: () => Promise<ArrayBuffer>;
|
|
};
|
|
Object.defineProperty(file, "arrayBuffer", {
|
|
value: async () => {
|
|
arrayBufferCalls += 1;
|
|
return new ArrayBuffer(7);
|
|
},
|
|
});
|
|
|
|
const results = await uploadEntriesDirect(
|
|
[{ file, relativePath: "memory-only.bin", isDirectory: false }],
|
|
{
|
|
targetPath: "/target",
|
|
sftpId: "sftp-1",
|
|
isLocal: false,
|
|
bridge: {
|
|
mkdirSftp: async () => {},
|
|
stageUploadFile: async (stagedFile) => {
|
|
assert.equal(stagedFile, file);
|
|
stagedFiles += 1;
|
|
return "/netcatty-temp/memory-only.bin";
|
|
},
|
|
deleteTempFile: async (localPath) => { deleted.push(localPath); },
|
|
startStreamTransfer: async (payload) => {
|
|
assert.equal(payload.sourcePath, "/netcatty-temp/memory-only.bin");
|
|
streamTransfers += 1;
|
|
return { transferId: payload.transferId };
|
|
},
|
|
},
|
|
joinPath: (base, name) => `${base}/${name}`,
|
|
},
|
|
);
|
|
|
|
assert.equal(arrayBufferCalls, 0);
|
|
assert.equal(stagedFiles, 1);
|
|
assert.equal(streamTransfers, 1);
|
|
assert.deepEqual(deleted, ["/netcatty-temp/memory-only.bin"]);
|
|
assert.equal(results[0]?.success, true);
|
|
});
|
|
|
|
test("cancelling while a pathless file is being staged aborts before stream transfer", async () => {
|
|
const controller = new UploadController();
|
|
let rejectStage: ((error: Error) => void) | undefined;
|
|
let streamTransfers = 0;
|
|
const file = new File(["large payload"], "large.bin");
|
|
const uploading = uploadEntriesDirect(
|
|
[{ file, relativePath: "large.bin", isDirectory: false }],
|
|
{
|
|
targetPath: "/target",
|
|
sftpId: "sftp-1",
|
|
isLocal: false,
|
|
bridge: {
|
|
mkdirSftp: async () => {},
|
|
stageUploadFile: async () => new Promise<string>((_resolve, reject) => {
|
|
rejectStage = reject;
|
|
}),
|
|
cancelStagedUploadFile: async () => {
|
|
rejectStage?.(new Error("Upload staging cancelled"));
|
|
},
|
|
deleteTempFile: async () => {},
|
|
cancelTransfer: async () => {},
|
|
startStreamTransfer: async (payload) => {
|
|
streamTransfers += 1;
|
|
return { transferId: payload.transferId };
|
|
},
|
|
},
|
|
joinPath: (base, name) => `${base}/${name}`,
|
|
},
|
|
controller,
|
|
);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
await controller.cancel();
|
|
const results = await uploading;
|
|
assert.equal(streamTransfers, 0);
|
|
assert.equal(results.some((result) => result.cancelled), true);
|
|
});
|
|
|
|
test("does not replace an existing directory when uploading a same-named file", async () => {
|
|
const file = new File(["local"], "dddd", { lastModified: 1234 });
|
|
const deletedPaths: string[] = [];
|
|
const uploadedPaths: string[] = [];
|
|
|
|
const results = await uploadFromFileList(
|
|
[file],
|
|
{
|
|
targetPath: "/target",
|
|
sftpId: "sftp-1",
|
|
isLocal: false,
|
|
bridge: {
|
|
mkdirSftp: async () => {},
|
|
statSftp: async (_sftpId, path) =>
|
|
path === "/target/dddd"
|
|
? { type: "directory", size: 0, lastModified: 1000 }
|
|
: null,
|
|
deleteSftp: async (_sftpId, path) => {
|
|
deletedPaths.push(path);
|
|
},
|
|
startStreamTransfer: async ({ targetPath: path }) => {
|
|
uploadedPaths.push(path);
|
|
return { transferId: "must-not-run" };
|
|
},
|
|
},
|
|
joinPath: (base, name) => `${base}/${name}`,
|
|
resolveConflict: async () => "replace",
|
|
},
|
|
);
|
|
|
|
assert.deepEqual(deletedPaths, []);
|
|
assert.deepEqual(uploadedPaths, []);
|
|
assert.equal(results.length, 1);
|
|
assert.equal(results[0].fileName, "dddd");
|
|
assert.equal(results[0].success, false);
|
|
assert.match(results[0].error ?? "", /directory/i);
|
|
});
|
|
|
|
test("file replace leaves the remote destination so upload can restore mode bits", async () => {
|
|
// Deleting before overwrite recreates the inode with umask defaults and drops
|
|
// bits like +x (#2954). Keep the target so stage+rename can restore mode.
|
|
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
|
|
Object.defineProperty(file, "path", { value: "/local/tool.sh" });
|
|
const deletedPaths: string[] = [];
|
|
const deletedTypes: Array<string | undefined> = [];
|
|
const uploadedPaths: string[] = [];
|
|
|
|
const results = await uploadFromFileList(
|
|
[file],
|
|
{
|
|
targetPath: "/usr/local/bin",
|
|
sftpId: "sftp-1",
|
|
isLocal: false,
|
|
bridge: {
|
|
mkdirSftp: async () => {},
|
|
statSftp: async (_sftpId, path) =>
|
|
path === "/usr/local/bin/tool.sh"
|
|
? { type: "file", size: 9, lastModified: 1000 }
|
|
: null,
|
|
deleteSftp: async (_sftpId, path, expectedType) => {
|
|
deletedPaths.push(path);
|
|
deletedTypes.push(expectedType);
|
|
},
|
|
startStreamTransfer: async ({ targetPath: path }) => {
|
|
uploadedPaths.push(path);
|
|
return { transferId: "upload-1" };
|
|
},
|
|
},
|
|
joinPath: (base, name) => `${base}/${name}`,
|
|
resolveConflict: async () => "replace",
|
|
},
|
|
);
|
|
|
|
assert.deepEqual(deletedPaths, [], "file replace must not delete before upload");
|
|
assert.deepEqual(uploadedPaths, ["/usr/local/bin/tool.sh"]);
|
|
assert.equal(results.length, 1);
|
|
assert.equal(results[0].success, true);
|
|
});
|
|
|
|
test("file replace unlinks an existing symlink before upload", async () => {
|
|
// Leaving the symlink would let in-place upload follow it and overwrite the
|
|
// link target outside the displayed directory.
|
|
// Conflict checks use lstatSftp so Replace unlinks the link first.
|
|
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
|
|
Object.defineProperty(file, "path", { value: "/local/tool.sh" });
|
|
const deletedPaths: string[] = [];
|
|
const deletedTypes: Array<string | undefined> = [];
|
|
const uploadedPaths: string[] = [];
|
|
|
|
const results = await uploadFromFileList(
|
|
[file],
|
|
{
|
|
targetPath: "/usr/local/bin",
|
|
sftpId: "sftp-1",
|
|
isLocal: false,
|
|
bridge: {
|
|
mkdirSftp: async () => {},
|
|
// Followed stat would report type "file" and skip pre-delete.
|
|
statSftp: async () => ({ type: "file", size: 4096, lastModified: 1000 }),
|
|
lstatSftp: async (_sftpId, path) =>
|
|
path === "/usr/local/bin/tool.sh"
|
|
? { type: "symlink", size: 12, lastModified: 1000 }
|
|
: null,
|
|
deleteSftp: async (_sftpId, path, expectedType) => {
|
|
deletedPaths.push(path);
|
|
deletedTypes.push(expectedType);
|
|
},
|
|
startStreamTransfer: async ({ targetPath: path }) => {
|
|
uploadedPaths.push(path);
|
|
return { transferId: "upload-1" };
|
|
},
|
|
},
|
|
joinPath: (base, name) => `${base}/${name}`,
|
|
resolveConflict: async () => "replace",
|
|
},
|
|
);
|
|
|
|
assert.deepEqual(deletedPaths, ["/usr/local/bin/tool.sh"]);
|
|
assert.deepEqual(deletedTypes, ["symlink"]);
|
|
assert.deepEqual(uploadedPaths, ["/usr/local/bin/tool.sh"]);
|
|
assert.equal(results.length, 1);
|
|
assert.equal(results[0].success, true);
|
|
});
|
|
|
|
test("LSTAT ENOTSUP is not treated as an absent destination during conflict check", async () => {
|
|
// If unsupported LSTAT is swallowed as "missing", upload skips Replace and may
|
|
// write through an existing symlink to a target outside the displayed dir.
|
|
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
|
|
Object.defineProperty(file, "path", { value: "/local/tool.sh" });
|
|
let uploaded = 0;
|
|
let conflictPrompts = 0;
|
|
|
|
await assert.rejects(
|
|
() => uploadFromFileList(
|
|
[file],
|
|
{
|
|
targetPath: "/usr/local/bin",
|
|
sftpId: "sftp-1",
|
|
isLocal: false,
|
|
bridge: {
|
|
mkdirSftp: async () => {},
|
|
lstatSftp: async () => {
|
|
const error = new Error(
|
|
"Remote server does not support LSTAT; cannot classify path without following symlinks",
|
|
) as Error & { code: string; lstatUnavailable: boolean };
|
|
error.code = "ENOTSUP";
|
|
error.lstatUnavailable = true;
|
|
throw error;
|
|
},
|
|
startStreamTransfer: async () => {
|
|
uploaded += 1;
|
|
return { transferId: "upload-1" };
|
|
},
|
|
},
|
|
joinPath: (base, name) => `${base}/${name}`,
|
|
resolveConflict: async () => {
|
|
conflictPrompts += 1;
|
|
return "replace";
|
|
},
|
|
},
|
|
),
|
|
(error: Error & { code?: string }) => {
|
|
assert.equal(error.code, "ENOTSUP");
|
|
assert.match(String(error.message), /does not support LSTAT/i);
|
|
return true;
|
|
},
|
|
);
|
|
|
|
assert.equal(uploaded, 0, "must not upload when destination type is unknown");
|
|
assert.equal(conflictPrompts, 0, "must not pretend destination is missing");
|
|
});
|
|
|
|
test("ENOENT from lstat still means absent destination for conflict check", async () => {
|
|
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
|
|
Object.defineProperty(file, "path", { value: "/local/tool.sh" });
|
|
const uploadedPaths: string[] = [];
|
|
|
|
const results = await uploadFromFileList(
|
|
[file],
|
|
{
|
|
targetPath: "/usr/local/bin",
|
|
sftpId: "sftp-1",
|
|
isLocal: false,
|
|
bridge: {
|
|
mkdirSftp: async () => {},
|
|
lstatSftp: async () => {
|
|
const error = new Error("No such file") as Error & { code: string };
|
|
error.code = "ENOENT";
|
|
throw error;
|
|
},
|
|
startStreamTransfer: async ({ targetPath: path }) => {
|
|
uploadedPaths.push(path);
|
|
return { transferId: "upload-1" };
|
|
},
|
|
},
|
|
joinPath: (base, name) => `${base}/${name}`,
|
|
resolveConflict: async () => {
|
|
throw new Error("absent destination must not prompt for conflict");
|
|
},
|
|
},
|
|
);
|
|
|
|
assert.deepEqual(uploadedPaths, ["/usr/local/bin/tool.sh"]);
|
|
assert.equal(results.length, 1);
|
|
assert.equal(results[0].success, true);
|
|
});
|
|
|
|
test("Electron-wrapped lstat absence still means absent destination", async () => {
|
|
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
|
|
Object.defineProperty(file, "path", { value: "/local/tool.sh" });
|
|
const uploadedPaths: string[] = [];
|
|
|
|
const results = await uploadFromFileList(
|
|
[file],
|
|
{
|
|
targetPath: "/usr/local/bin",
|
|
sftpId: "sftp-1",
|
|
isLocal: false,
|
|
bridge: {
|
|
mkdirSftp: async () => {},
|
|
lstatSftp: async () => {
|
|
throw new Error("Error invoking remote method 'netcatty:sftp:lstat': Error: No such file");
|
|
},
|
|
startStreamTransfer: async ({ targetPath: path }) => {
|
|
uploadedPaths.push(path);
|
|
return { transferId: "upload-1" };
|
|
},
|
|
},
|
|
joinPath: (base, name) => `${base}/${name}`,
|
|
resolveConflict: async () => {
|
|
throw new Error("absent destination must not prompt for conflict");
|
|
},
|
|
},
|
|
);
|
|
|
|
assert.deepEqual(uploadedPaths, ["/usr/local/bin/tool.sh"]);
|
|
assert.equal(results.length, 1);
|
|
assert.equal(results[0].success, true);
|
|
});
|
|
|
|
test("compressed-upload LSTAT ENOTSUP is not treated as an absent destination", async (t) => {
|
|
let compressedStarts = 0;
|
|
installCompressedUploadBridge(t, {
|
|
onStart: () => { compressedStarts += 1; },
|
|
});
|
|
let streamCalls = 0;
|
|
|
|
await assert.rejects(
|
|
() => uploadFromFileList(
|
|
[createPickedFolderFile()],
|
|
{
|
|
targetPath: "/remote",
|
|
sftpId: "sftp-1",
|
|
isLocal: false,
|
|
bridge: {
|
|
mkdirSftp: async () => {},
|
|
lstatSftp: async () => {
|
|
const error = new Error(
|
|
"Remote server does not support LSTAT; cannot classify path without following symlinks",
|
|
) as Error & { code: string; lstatUnavailable: boolean };
|
|
error.code = "ENOTSUP";
|
|
error.lstatUnavailable = true;
|
|
throw error;
|
|
},
|
|
startStreamTransfer: async (payload) => {
|
|
streamCalls += 1;
|
|
return { transferId: payload.transferId };
|
|
},
|
|
},
|
|
joinPath: (base, name) => `${base}/${name}`,
|
|
useCompressedUpload: true,
|
|
resolveConflict: async () => "merge",
|
|
},
|
|
),
|
|
(error: Error & { code?: string }) => {
|
|
assert.equal(error.code, "ENOTSUP");
|
|
return true;
|
|
},
|
|
);
|
|
|
|
assert.equal(compressedStarts, 0);
|
|
assert.equal(streamCalls, 0);
|
|
});
|
|
|
|
test("directory replace unlinks an existing symlink before mkdir", async () => {
|
|
// No-follow lstat reports symlink; Replace must unlink then create the dir
|
|
// (followed stat used to classify symlink-to-dir as directory and succeed).
|
|
const file = new File(["nested"], "file.txt", { lastModified: 1234 });
|
|
Object.defineProperty(file, "path", { value: "/local/docs/file.txt" });
|
|
Object.defineProperty(file, "webkitRelativePath", { value: "docs/file.txt" });
|
|
const deletedPaths: string[] = [];
|
|
const deletedTypes: Array<string | undefined> = [];
|
|
const madeDirs: string[] = [];
|
|
const uploadedPaths: string[] = [];
|
|
|
|
const results = await uploadFromFileList(
|
|
[file],
|
|
{
|
|
targetPath: "/remote",
|
|
sftpId: "sftp-1",
|
|
isLocal: false,
|
|
bridge: {
|
|
mkdirSftp: async (_sftpId, path) => {
|
|
madeDirs.push(path);
|
|
},
|
|
lstatSftp: async (_sftpId, path) =>
|
|
path === "/remote/docs"
|
|
? { type: "symlink", size: 8, lastModified: 1000 }
|
|
: null,
|
|
deleteSftp: async (_sftpId, path, expectedType) => {
|
|
deletedPaths.push(path);
|
|
deletedTypes.push(expectedType);
|
|
},
|
|
startStreamTransfer: async ({ targetPath: path }) => {
|
|
uploadedPaths.push(path);
|
|
return { transferId: "upload-dir-1" };
|
|
},
|
|
},
|
|
joinPath: (base, name) => `${base}/${name}`,
|
|
resolveConflict: async () => "replace",
|
|
},
|
|
);
|
|
|
|
assert.deepEqual(deletedPaths, ["/remote/docs"]);
|
|
assert.deepEqual(deletedTypes, ["symlink"]);
|
|
assert.ok(madeDirs.includes("/remote/docs"), `expected mkdir after unlink, got ${JSON.stringify(madeDirs)}`);
|
|
assert.deepEqual(uploadedPaths, ["/remote/docs/file.txt"]);
|
|
assert.equal(results.some((r) => r.success), true);
|
|
});
|
|
|
|
test("local file replace unlinks an existing symlink before writeLocalFile", async () => {
|
|
// Pathless File uploads fall back to writeLocalFile, which follows symlinks.
|
|
// Conflict checks use lstatLocal so Replace unlinks the link first.
|
|
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
|
|
const deletedPaths: string[] = [];
|
|
const deletedTypes: Array<string | undefined> = [];
|
|
const writtenPaths: string[] = [];
|
|
|
|
const results = await uploadFromFileList(
|
|
[file],
|
|
{
|
|
targetPath: "/Users/me/bin",
|
|
sftpId: null,
|
|
isLocal: true,
|
|
bridge: {
|
|
mkdirSftp: async () => {},
|
|
lstatLocal: async (path) =>
|
|
path === "/Users/me/bin/tool.sh"
|
|
? { type: "symlink", size: 12, lastModified: 1000 }
|
|
: null,
|
|
deleteLocalFile: async (path, expectedType) => {
|
|
deletedPaths.push(path);
|
|
deletedTypes.push(expectedType);
|
|
},
|
|
writeLocalFile: async (path) => {
|
|
writtenPaths.push(path);
|
|
},
|
|
},
|
|
joinPath: (base, name) => `${base}/${name}`,
|
|
resolveConflict: async () => "replace",
|
|
},
|
|
);
|
|
|
|
assert.deepEqual(deletedPaths, ["/Users/me/bin/tool.sh"]);
|
|
assert.deepEqual(deletedTypes, ["symlink"]);
|
|
assert.deepEqual(writtenPaths, ["/Users/me/bin/tool.sh"]);
|
|
assert.equal(results.length, 1);
|
|
assert.equal(results[0].success, true);
|
|
});
|
|
|
|
test("local file replace unlinks a regular file before writeLocalFile", async () => {
|
|
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
|
|
const operations: string[] = [];
|
|
|
|
const results = await uploadFromFileList(
|
|
[file],
|
|
{
|
|
targetPath: "/Users/me/bin",
|
|
sftpId: null,
|
|
isLocal: true,
|
|
bridge: {
|
|
mkdirSftp: async () => {},
|
|
lstatLocal: async (path) =>
|
|
path === "/Users/me/bin/tool.sh"
|
|
? { type: "file", size: 12, lastModified: 1000 }
|
|
: null,
|
|
deleteLocalFile: async (path, expectedType) => {
|
|
operations.push(`delete:${path}:${expectedType}`);
|
|
},
|
|
writeLocalFile: async (path) => {
|
|
operations.push(`write:${path}`);
|
|
},
|
|
},
|
|
joinPath: (base, name) => `${base}/${name}`,
|
|
resolveConflict: async () => "replace",
|
|
},
|
|
);
|
|
|
|
assert.deepEqual(operations, [
|
|
"delete:/Users/me/bin/tool.sh:file",
|
|
"write:/Users/me/bin/tool.sh",
|
|
]);
|
|
assert.equal(results.length, 1);
|
|
assert.equal(results[0].success, true);
|
|
});
|
|
|
|
test("counts apply-to-all upload conflicts by incoming and existing type", async () => {
|
|
const files = [
|
|
new File(["local"], "existing-file", { lastModified: 1234 }),
|
|
new File(["local"], "existing-directory", { lastModified: 1234 }),
|
|
];
|
|
const conflictCounts: number[] = [];
|
|
|
|
const results = await uploadFromFileList(
|
|
files,
|
|
{
|
|
targetPath: "/target",
|
|
sftpId: "sftp-1",
|
|
isLocal: false,
|
|
bridge: {
|
|
mkdirSftp: async () => {},
|
|
statSftp: async (_sftpId, path) => {
|
|
if (path === "/target/existing-file") {
|
|
return { type: "file", size: 2, lastModified: 1000 };
|
|
}
|
|
if (path === "/target/existing-directory") {
|
|
return { type: "directory", size: 0, lastModified: 1000 };
|
|
}
|
|
return null;
|
|
},
|
|
startStreamTransfer: async () => {
|
|
throw new Error("skipped conflicts should not upload");
|
|
},
|
|
},
|
|
joinPath: (base, name) => `${base}/${name}`,
|
|
resolveConflict: async (conflict) => {
|
|
conflictCounts.push(conflict.applyToAllCount);
|
|
return "skip";
|
|
},
|
|
},
|
|
);
|
|
|
|
assert.deepEqual(conflictCounts, [1, 1]);
|
|
assert.deepEqual(results, [
|
|
{ fileName: "existing-file", success: false, cancelled: true },
|
|
{ fileName: "existing-directory", success: false, cancelled: true },
|
|
]);
|
|
});
|
|
|
|
test("folder drag-drop creates a bundle task with a resolved local source path", async () => {
|
|
const created: Array<{ fileName: string; sourcePath?: string; isDirectory?: boolean }> = [];
|
|
const fileA = { size: 10, path: "/Users/me/Desktop/docs/a.txt" } as File & { path?: string };
|
|
const fileB = { size: 20, path: "/Users/me/Desktop/docs/b.txt" } as File & { path?: string };
|
|
|
|
const results = await uploadEntriesDirect(
|
|
[
|
|
{
|
|
file: fileA,
|
|
relativePath: "docs/a.txt",
|
|
isDirectory: false,
|
|
},
|
|
{
|
|
file: fileB,
|
|
relativePath: "docs/b.txt",
|
|
isDirectory: false,
|
|
},
|
|
],
|
|
{
|
|
targetPath: "/remote",
|
|
sftpId: "sftp-1",
|
|
isLocal: false,
|
|
bridge: {
|
|
mkdirSftp: async () => {},
|
|
startStreamTransfer: async (payload) => ({ transferId: payload.transferId }),
|
|
},
|
|
joinPath: (base, name) => `${base}/${name}`,
|
|
callbacks: {
|
|
onTaskCreated: (task) => {
|
|
created.push({
|
|
fileName: task.fileName,
|
|
sourcePath: task.sourcePath,
|
|
isDirectory: task.isDirectory,
|
|
});
|
|
},
|
|
},
|
|
},
|
|
);
|
|
|
|
assert.equal(results.every((result) => result.success), true);
|
|
assert.ok(
|
|
created.some((task) => task.fileName === "docs" && task.isDirectory === true && task.sourcePath === "/Users/me/Desktop/docs"),
|
|
`expected folder bundle task with local source path, got ${JSON.stringify(created)}`,
|
|
);
|
|
});
|
|
|
|
test("uploads path-backed clipboard files through stream transfer", async () => {
|
|
const transfers: Array<{ sourcePath: string; targetPath: string; totalBytes?: number }> = [];
|
|
const taskTotals: number[] = [];
|
|
|
|
const results = await uploadEntriesDirect(
|
|
[
|
|
{
|
|
file: null,
|
|
localPath: "/Users/me/Desktop/report.txt",
|
|
relativePath: "report.txt",
|
|
isDirectory: false,
|
|
size: 42,
|
|
},
|
|
],
|
|
{
|
|
targetPath: "/target",
|
|
sftpId: "sftp-1",
|
|
isLocal: false,
|
|
bridge: {
|
|
mkdirSftp: async () => {},
|
|
startStreamTransfer: async (payload) => {
|
|
transfers.push({
|
|
sourcePath: payload.sourcePath,
|
|
targetPath: payload.targetPath,
|
|
totalBytes: payload.totalBytes,
|
|
});
|
|
return { transferId: payload.transferId };
|
|
},
|
|
},
|
|
joinPath: (base, name) => `${base}/${name}`,
|
|
callbacks: {
|
|
onTaskCreated: (task) => taskTotals.push(task.totalBytes),
|
|
},
|
|
},
|
|
);
|
|
|
|
assert.deepEqual(taskTotals, [42]);
|
|
assert.deepEqual(transfers, [
|
|
{
|
|
sourcePath: "/Users/me/Desktop/report.txt",
|
|
targetPath: "/target/report.txt",
|
|
totalBytes: 42,
|
|
},
|
|
]);
|
|
assert.deepEqual(results, [
|
|
{ fileName: "report.txt", success: true },
|
|
]);
|
|
});
|
|
|
|
test("unified transfer events are the sole writer of file completion", async () => {
|
|
const created: string[] = [];
|
|
const completed: string[] = [];
|
|
const results = await uploadEntriesDirect(
|
|
[{
|
|
file: null,
|
|
localPath: "/Users/me/Desktop/report.txt",
|
|
relativePath: "report.txt",
|
|
isDirectory: false,
|
|
size: 42,
|
|
}],
|
|
{
|
|
targetPath: "/target",
|
|
sftpId: "sftp-1",
|
|
isLocal: false,
|
|
bridge: {
|
|
managesTransferLifecycle: true,
|
|
mkdirSftp: async () => {},
|
|
startStreamTransfer: async (payload) => ({ transferId: payload.transferId }),
|
|
},
|
|
joinPath: (base, name) => `${base}/${name}`,
|
|
callbacks: {
|
|
onTaskCreated: (task) => created.push(task.id),
|
|
onTaskCompleted: (taskId) => completed.push(taskId),
|
|
},
|
|
},
|
|
);
|
|
|
|
assert.equal(results[0].success, true);
|
|
assert.equal(created.length, 1);
|
|
assert.deepEqual(completed, []);
|
|
});
|
|
|
|
test("a failed unified SFTP upload has no secondary upload path", async () => {
|
|
const file = new File(["payload"], "payload.txt");
|
|
Object.defineProperty(file, "path", { value: "/tmp/payload.txt" });
|
|
const results = await uploadEntriesDirect(
|
|
[{ file, relativePath: "payload.txt", isDirectory: false }],
|
|
{
|
|
targetPath: "/target",
|
|
sftpId: "sftp-1",
|
|
isLocal: false,
|
|
bridge: {
|
|
mkdirSftp: async () => {},
|
|
startStreamTransfer: async () => ({ transferId: "stream", error: "stream failed" }),
|
|
},
|
|
joinPath: (base, name) => `${base}/${name}`,
|
|
},
|
|
);
|
|
|
|
assert.deepEqual(results, [{ fileName: "payload.txt", success: false, error: "stream failed" }]);
|
|
});
|
|
|
|
test("copies path-backed clipboard files into local panes through stream transfer", async () => {
|
|
const transfers: Array<{ sourcePath: string; targetPath: string; targetType: string; totalBytes?: number }> = [];
|
|
|
|
const results = await uploadEntriesDirect(
|
|
[
|
|
{
|
|
file: null,
|
|
localPath: "/Users/me/Desktop/report.txt",
|
|
relativePath: "report.txt",
|
|
isDirectory: false,
|
|
size: 42,
|
|
},
|
|
],
|
|
{
|
|
targetPath: "/target",
|
|
sftpId: null,
|
|
isLocal: true,
|
|
bridge: {
|
|
mkdirLocal: async () => {},
|
|
startStreamTransfer: async (payload) => {
|
|
transfers.push({
|
|
sourcePath: payload.sourcePath,
|
|
targetPath: payload.targetPath,
|
|
targetType: payload.targetType,
|
|
totalBytes: payload.totalBytes,
|
|
});
|
|
return { transferId: payload.transferId };
|
|
},
|
|
},
|
|
joinPath: (base, name) => `${base}/${name}`,
|
|
},
|
|
);
|
|
|
|
assert.deepEqual(transfers, [
|
|
{
|
|
sourcePath: "/Users/me/Desktop/report.txt",
|
|
targetPath: "/target/report.txt",
|
|
targetType: "local",
|
|
totalBytes: 42,
|
|
},
|
|
]);
|
|
assert.deepEqual(results, [
|
|
{ fileName: "report.txt", success: true },
|
|
]);
|
|
});
|
|
|
|
test("reports empty directory creation failures", async () => {
|
|
const madeDirs: string[] = [];
|
|
|
|
const results = await uploadEntriesDirect(
|
|
[
|
|
{ file: null, relativePath: "folder", isDirectory: true },
|
|
{ file: null, relativePath: "folder/empty", isDirectory: true },
|
|
],
|
|
{
|
|
targetPath: "/target",
|
|
sftpId: "sftp-1",
|
|
isLocal: false,
|
|
bridge: {
|
|
mkdirSftp: async (_sftpId, path) => {
|
|
madeDirs.push(path);
|
|
if (path.endsWith("/empty")) {
|
|
throw new Error("permission denied");
|
|
}
|
|
},
|
|
},
|
|
joinPath: (base, name) => `${base}/${name}`,
|
|
},
|
|
);
|
|
|
|
assert.deepEqual(madeDirs, ["/target/folder", "/target/folder/empty"]);
|
|
assert.deepEqual(results, [
|
|
{ fileName: "folder/empty", success: false, error: "permission denied" },
|
|
]);
|
|
});
|
|
|
|
test("does not restart a direct upload that was already cancelled", async () => {
|
|
const controller = new UploadController();
|
|
await controller.cancel();
|
|
let mkdirCalled = false;
|
|
|
|
const results = await uploadEntriesDirect(
|
|
[{ file: null, relativePath: "folder", isDirectory: true }],
|
|
{
|
|
targetPath: "/target",
|
|
sftpId: "sftp-1",
|
|
isLocal: false,
|
|
bridge: {
|
|
mkdirSftp: async () => {
|
|
mkdirCalled = true;
|
|
},
|
|
},
|
|
joinPath: (base, name) => `${base}/${name}`,
|
|
},
|
|
controller,
|
|
);
|
|
|
|
assert.equal(mkdirCalled, false);
|
|
assert.deepEqual(results, [
|
|
{ fileName: "", success: false, cancelled: true },
|
|
]);
|
|
});
|