Files
NetMesh/electron/bridges/zmodemHelper.test.cjs

1404 lines
41 KiB
JavaScript
Raw Permalink Normal View History

const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {
createZmodemSentry,
buildUploadPlan,
buildModeRestores,
handleUpload,
handleDownload,
waitForWritableDrain,
createZmodemUploadDrainWaiter,
UPLOAD_CHUNK_SIZE,
UPLOAD_DRAIN_TIMEOUT_MS,
} = require("./zmodemHelper.cjs");
const never = () => { throw new Error("resolver should not be called"); };
test("no conflicts: all indices offered, none removed, resolver untouched", async () => {
const plan = await buildUploadPlan(["a.txt", "b.txt"], [], never);
assert.deepEqual(plan, { offerIndices: [0, 1], removeIndices: [], aborted: false });
});
test("overwrite a conflict: index both removed and offered", async () => {
const plan = await buildUploadPlan(["a.txt", "b.txt"], ["b.txt"], async () => ({ action: "overwrite" }));
assert.deepEqual(plan, { offerIndices: [0, 1], removeIndices: [1], aborted: false });
});
test("skip a conflict: index omitted from offer and remove", async () => {
const plan = await buildUploadPlan(["a.txt", "b.txt"], ["b.txt"], async () => ({ action: "skip" }));
assert.deepEqual(plan, { offerIndices: [0], removeIndices: [], aborted: false });
});
test("cancel aborts the whole transfer", async () => {
const plan = await buildUploadPlan(["a.txt", "b.txt"], ["b.txt"], async () => ({ action: "cancel" }));
assert.deepEqual(plan, { offerIndices: [], removeIndices: [], aborted: true });
});
test("applyToRest reuses the action and stops prompting", async () => {
let calls = 0;
const plan = await buildUploadPlan(["a", "b", "c"], ["a", "b", "c"],
async () => { calls++; return { action: "overwrite", applyToRest: true }; });
assert.equal(calls, 1);
assert.deepEqual(plan, { offerIndices: [0, 1, 2], removeIndices: [0, 1, 2], aborted: false });
});
test("only conflicting files invoke the resolver; order preserved", async () => {
const seen = [];
const plan = await buildUploadPlan(["a", "b", "c"], ["b"],
async (n) => { seen.push(n); return { action: "skip" }; });
assert.deepEqual(seen, ["b"]);
assert.deepEqual(plan.offerIndices, [0, 2]);
});
test("duplicate basenames keep independent per-file decisions", async () => {
// Two different local files share a basename; skip the first, overwrite the second.
const actions = ["skip", "overwrite"];
let i = 0;
const plan = await buildUploadPlan(["x.txt", "x.txt"], ["x.txt"],
async () => ({ action: actions[i++] }));
assert.deepEqual(plan, { offerIndices: [1], removeIndices: [1], aborted: false });
});
// Issue #1079: overwriting (rm + rz re-create) drops the original permission
// bits. buildModeRestores resolves which overwritten files to chmod back.
test("buildModeRestores maps overwritten files to their captured modes", () => {
assert.deepEqual(
buildModeRestores("/home/u", ["a.sh", "b.txt"], [0], { "a.sh": "755" }),
[{ path: "/home/u/a.sh", mode: "755" }],
);
});
test("buildModeRestores skips files whose mode was not captured", () => {
assert.deepEqual(
buildModeRestores("/srv", ["a", "b"], [0, 1], { a: "644" }),
[{ path: "/srv/a", mode: "644" }],
);
});
test("buildModeRestores strips trailing slashes and dedupes duplicate basenames", () => {
assert.deepEqual(
buildModeRestores("/srv//", ["x", "x"], [0, 1], { x: "600" }),
[{ path: "/srv/x", mode: "600" }],
);
});
test("queued drag-drop upload keeps temp files until cancel", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const tempPath = path.join(tempDir, "upload.txt");
fs.writeFileSync(tempPath, "payload");
const sentry = createZmodemSentry({
sessionId: "session-1",
onData: () => {},
writeToRemote: () => true,
getWebContents: () => null,
});
sentry.queueDragDropUpload({
filePaths: [tempPath],
remoteNames: ["upload.txt"],
tempPaths: [tempPath],
});
assert.equal(fs.existsSync(tempPath), true);
sentry.cancel();
assert.equal(fs.existsSync(tempPath), false);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("queued drag-drop upload interrupts the remote command when cancelled before detect", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const tempPath = path.join(tempDir, "upload.txt");
fs.writeFileSync(tempPath, "payload");
const writes = [];
let interrupted = false;
const sentry = createZmodemSentry({
sessionId: "session-1",
onData: () => {},
writeToRemote: (buf) => {
writes.push(Buffer.from(buf));
return true;
},
interruptRemote: () => {
interrupted = true;
},
getWebContents: () => null,
dragDropStartTimeoutMs: 0,
});
sentry.queueDragDropUpload({
filePaths: [tempPath],
remoteNames: ["upload.txt"],
tempPaths: [tempPath],
});
sentry.cancel();
assert.equal(fs.existsSync(tempPath), false);
assert.equal(interrupted, true);
assert.equal(writes[0].toString("utf8"), "rz -y\r");
assert.deepEqual([...writes[1]], [0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18]);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("queued drag-drop upload cleans temp files when rz never starts", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const tempPath = path.join(tempDir, "upload.txt");
fs.writeFileSync(tempPath, "payload");
const writes = [];
const sentry = createZmodemSentry({
sessionId: "session-1",
onData: () => {},
writeToRemote: (buf) => {
writes.push(Buffer.from(buf));
return true;
},
getWebContents: () => null,
dragDropStartTimeoutMs: 1,
});
sentry.queueDragDropUpload({
filePaths: [tempPath],
remoteNames: ["upload.txt"],
tempPaths: [tempPath],
});
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(fs.existsSync(tempPath), false);
assert.equal(writes[0].toString("utf8"), "rz -y\r");
assert.deepEqual([...writes[1]], [0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18]);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("queued drag-drop upload rejects a second pending upload", () => {
const sentry = createZmodemSentry({
sessionId: "session-1",
onData: () => {},
writeToRemote: () => true,
getWebContents: () => null,
});
sentry.queueDragDropUpload({
filePaths: ["/tmp/first.txt"],
remoteNames: ["first.txt"],
});
assert.throws(
() => sentry.queueDragDropUpload({
filePaths: ["/tmp/second.txt"],
remoteNames: ["second.txt"],
}),
/already pending/,
);
sentry.cancel({ interrupt: false });
});
test("queued drag-drop upload cleans temp files when command write fails", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const firstTempPath = path.join(tempDir, "first.txt");
const secondTempPath = path.join(tempDir, "second.txt");
fs.writeFileSync(firstTempPath, "first");
fs.writeFileSync(secondTempPath, "second");
const sentry = createZmodemSentry({
sessionId: "session-1",
onData: () => {},
writeToRemote: () => {
throw new Error("socket closed");
},
getWebContents: () => null,
});
assert.throws(
() => sentry.queueDragDropUpload({
filePaths: [firstTempPath],
remoteNames: ["first.txt"],
tempPaths: [firstTempPath],
}),
/socket closed/,
);
assert.equal(fs.existsSync(firstTempPath), false);
assert.throws(
() => sentry.queueDragDropUpload({
filePaths: [secondTempPath],
remoteNames: ["second.txt"],
tempPaths: [secondTempPath],
}),
/socket closed/,
);
assert.equal(fs.existsSync(secondTempPath), false);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("handleUpload completes when the remote confirms after progress reaches 100 percent", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const filePath = path.join(tempDir, "upload.txt");
fs.writeFileSync(filePath, "payload");
const events = [];
let closed = false;
let endCalled = false;
const zsession = {
async send_offer() {
return {
send() {},
async end() {
endCalled = true;
},
};
},
async close() {
closed = true;
},
};
await handleUpload(zsession, {
sessionId: "session-1",
getWebContents: () => ({
isDestroyed: () => false,
send: (channel, data) => events.push({ channel, data }),
}),
takeDragDropUpload: () => ({
filePaths: [filePath],
remoteNames: ["upload.txt"],
}),
});
assert.equal(endCalled, true);
assert.equal(closed, true);
assert.equal(
events.some((event) => event.channel === "netcatty:zmodem:progress" && event.data.finalizing === true),
true,
);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("handleUpload does not read the next chunk until transport backpressure clears", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-drain-"));
const filePath = path.join(tempDir, "large-upload.bin");
fs.writeFileSync(filePath, Buffer.alloc(UPLOAD_CHUNK_SIZE + 1, 0x5a));
const sentChunkSizes = [];
const progress = [];
let releaseFirstDrain;
const firstDrain = new Promise((resolve) => {
releaseFirstDrain = resolve;
});
let drainCalls = 0;
const zsession = {
async send_offer() {
return {
send(chunk) {
sentChunkSizes.push(chunk.byteLength);
},
async end() {},
};
},
async close() {},
};
const upload = handleUpload(zsession, {
sessionId: "session-1",
getWebContents: () => ({
isDestroyed: () => false,
send(channel, data) {
if (channel === "netcatty:zmodem:progress") progress.push(data);
},
}),
takeDragDropUpload: () => ({
filePaths: [filePath],
remoteNames: ["large-upload.bin"],
}),
async waitForDrain() {
drainCalls += 1;
if (drainCalls === 1) await firstDrain;
},
});
while (drainCalls === 0) {
await new Promise((resolve) => setImmediate(resolve));
}
assert.deepEqual(sentChunkSizes, [UPLOAD_CHUNK_SIZE]);
assert.equal(progress.at(-1).transferred, UPLOAD_CHUNK_SIZE);
releaseFirstDrain();
await upload;
assert.deepEqual(sentChunkSizes, [UPLOAD_CHUNK_SIZE, 1]);
assert.equal(progress.at(-1).finalizing, true);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("handleUpload throttles progress IPC by default for every transport", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-progress-"));
const filePath = path.join(tempDir, "large-upload.bin");
const size = UPLOAD_CHUNK_SIZE + 1;
fs.writeFileSync(filePath, Buffer.alloc(size, 0x5a));
const progress = [];
const originalNow = Date.now;
Date.now = () => 1000;
try {
const zsession = {
async send_offer() {
return {
send() {},
async end() {},
};
},
async close() {},
};
await handleUpload(zsession, {
sessionId: "session-1",
getWebContents: () => ({
isDestroyed: () => false,
send(channel, data) {
if (channel === "netcatty:zmodem:progress") progress.push(data);
},
}),
takeDragDropUpload: () => ({
filePaths: [filePath],
remoteNames: ["large-upload.bin"],
}),
});
assert.deepEqual(
progress.map((event) => [event.transferred, Boolean(event.finalizing)]),
[[0, false], [UPLOAD_CHUNK_SIZE, false], [size, true]],
);
} finally {
Date.now = originalNow;
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test("handleUpload progress follows a display rebind during transfer", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-rebind-"));
const filePath = path.join(tempDir, "upload.txt");
fs.writeFileSync(filePath, "payload");
const events = [];
const contents = {
home: {
isDestroyed: () => false,
send: (channel, data) => events.push({ target: "home", channel, data }),
},
popup: {
isDestroyed: () => false,
send: (channel, data) => events.push({ target: "popup", channel, data }),
},
};
let target = "home";
const zsession = {
async send_offer() {
return {
send() { target = "popup"; },
async end() {},
};
},
async close() {},
};
await handleUpload(zsession, {
sessionId: "session-1",
getWebContents: () => contents[target],
takeDragDropUpload: () => ({
filePaths: [filePath],
remoteNames: ["upload.txt"],
}),
});
const progressTargets = events
.filter((event) => event.channel === "netcatty:zmodem:progress")
.map((event) => event.target);
assert.equal(progressTargets[0], "home");
assert.deepEqual(progressTargets.slice(1), ["popup", "popup"]);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("handleUpload uses injected file picker when no drag-drop upload is queued", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const filePath = path.join(tempDir, "picker-upload.txt");
fs.writeFileSync(filePath, "payload");
let pickerCalled = false;
let endCalled = false;
const zsession = {
async send_offer() {
return {
send() {},
async end() {
endCalled = true;
},
};
},
async close() {},
};
await handleUpload(zsession, {
sessionId: "session-1",
getWebContents: () => ({
isDestroyed: () => false,
send() {},
}),
selectUploadFiles: async () => {
pickerCalled = true;
return { canceled: false, filePaths: [filePath] };
},
});
assert.equal(pickerCalled, true);
assert.equal(endCalled, true);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("handleDownload uses injected directory picker before accepting remote files", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-download-"));
let pickerCalled = false;
const handlers = new Map();
let startCalled = false;
const zsession = {
on(event, handler) {
handlers.set(event, handler);
},
start() {
startCalled = true;
setImmediate(() => handlers.get("session_end")?.());
},
};
await handleDownload(zsession, {
sessionId: "session-1",
getWebContents: () => ({
isDestroyed: () => false,
send() {},
}),
selectDownloadDirectory: async () => {
pickerCalled = true;
return { canceled: false, filePaths: [tempDir] };
},
});
assert.equal(startCalled, true);
assert.equal(pickerCalled, true);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("handleUpload times out when the remote never confirms after progress reaches 100 percent", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const filePath = path.join(tempDir, "upload.txt");
fs.writeFileSync(filePath, "payload");
let releaseEnd;
const writes = [];
let timeoutNotified = false;
const zsession = {
async send_offer() {
return {
send() {},
end() {
return new Promise((resolve) => {
releaseEnd = resolve;
});
},
};
},
async close() {},
};
const uploadPromise = handleUpload(zsession, {
sessionId: "session-1",
getWebContents: () => null,
writeToRemote: (buf) => {
writes.push(Buffer.from(buf));
return true;
},
takeDragDropUpload: () => ({
filePaths: [filePath],
remoteNames: ["upload.txt"],
}),
uploadFileEndTimeoutMs: 10,
uploadSessionCloseTimeoutMs: 10,
onUploadTimeout: () => {
timeoutNotified = true;
},
});
const outcome = await Promise.race([
uploadPromise.then(
() => "resolved",
(err) => String(err.message || err),
),
new Promise((resolve) => setTimeout(() => resolve("still waiting"), 50)),
]);
assert.match(outcome, /Remote did not confirm receiving upload\.txt/);
assert.equal(timeoutNotified, true);
assert.deepEqual([...writes[0]], [0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18]);
releaseEnd();
await uploadPromise.catch(() => {});
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("handleUpload does not run timeout recovery when the remote rejects the final confirmation", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const filePath = path.join(tempDir, "upload.txt");
fs.writeFileSync(filePath, "payload");
const writes = [];
let timeoutNotified = false;
const zsession = {
async send_offer() {
return {
send() {},
async end() {
throw new Error("remote cancelled upload");
},
};
},
async close() {},
};
await assert.rejects(
handleUpload(zsession, {
sessionId: "session-1",
getWebContents: () => null,
writeToRemote: (buf) => {
writes.push(Buffer.from(buf));
return true;
},
takeDragDropUpload: () => ({
filePaths: [filePath],
remoteNames: ["upload.txt"],
}),
uploadFileEndTimeoutMs: 50,
uploadSessionCloseTimeoutMs: 50,
onUploadTimeout: () => {
timeoutNotified = true;
},
}),
/remote cancelled upload/,
);
assert.equal(timeoutNotified, false);
assert.equal(writes.length, 0);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("handleUpload times out when the remote never answers the offer", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const filePath = path.join(tempDir, "upload.txt");
fs.writeFileSync(filePath, "payload");
const writes = [];
let timeoutNotified = false;
// rz died before answering ZFILE: send_offer never resolves. The upload
// loop must not park here forever — bound it like xfer.end()/close().
const zsession = {
send_offer: () => new Promise(() => {}),
async abort() {},
async close() {},
};
await assert.rejects(
handleUpload(zsession, {
sessionId: "session-1",
getWebContents: () => null,
writeToRemote: (buf) => {
writes.push(Buffer.from(buf));
return true;
},
takeDragDropUpload: () => ({
filePaths: [filePath],
remoteNames: ["upload.txt"],
}),
uploadFileEndTimeoutMs: 50,
onUploadTimeout: () => {
timeoutNotified = true;
},
}),
/Remote did not respond to the offer for upload\.txt/,
);
assert.equal(timeoutNotified, true);
assert.ok(writes.length > 0, "CAN abort bytes were sent to the remote");
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("handleUpload stops an unresolved offer promptly when cancelled", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const filePath = path.join(tempDir, "upload.txt");
fs.writeFileSync(filePath, "payload");
const controller = new AbortController();
const upload = handleUpload(
{
send_offer: () => new Promise(() => {}),
async close() {},
},
{
sessionId: "session-1",
signal: controller.signal,
getWebContents: () => null,
takeDragDropUpload: () => ({
filePaths: [filePath],
remoteNames: ["upload.txt"],
}),
},
);
await new Promise((resolve) => setImmediate(resolve));
controller.abort();
await assert.rejects(
() => upload,
(err) => err && err.code === "NETCATTY_ZMODEM_CANCELLED",
);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("handleUpload does not delete an overwrite target after cancellation", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-cancel-overwrite-"));
const filePath = path.join(tempDir, "upload.txt");
fs.writeFileSync(filePath, "payload");
const controller = new AbortController();
let removeCalls = 0;
let sendOfferCalls = 0;
let releaseDecision;
const upload = handleUpload(
{
send_offer() {
sendOfferCalls += 1;
return Promise.reject(new Error("send_offer should not run after cancellation"));
},
async close() {},
},
{
sessionId: "session-1",
signal: controller.signal,
getWebContents: () => null,
selectUploadFiles: async () => ({ canceled: false, filePaths: [filePath] }),
probeReceiveConflicts: async () => ({
dir: "/home/u",
existing: ["upload.txt"],
modes: { "upload.txt": "644" },
}),
requestOverwriteDecision: () => new Promise((resolve) => {
releaseDecision = resolve;
}),
removeRemoteFiles: async () => {
removeCalls += 1;
},
},
);
while (!releaseDecision) await new Promise((resolve) => setImmediate(resolve));
controller.abort();
releaseDecision({ action: "overwrite", applyToRest: false });
await assert.rejects(
() => upload,
(err) => err && err.code === "NETCATTY_ZMODEM_CANCELLED",
);
assert.equal(removeCalls, 0);
assert.equal(sendOfferCalls, 0);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("handleUpload aborts an in-flight remote overwrite cleanup", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-cancel-remove-"));
const filePath = path.join(tempDir, "upload.txt");
fs.writeFileSync(filePath, "payload");
const controller = new AbortController();
let releaseRemove;
let removeSignal;
const upload = handleUpload(
{
async send_offer() {
throw new Error("send_offer should not run after cancellation");
},
async close() {},
},
{
sessionId: "session-1",
signal: controller.signal,
getWebContents: () => null,
selectUploadFiles: async () => ({ canceled: false, filePaths: [filePath] }),
probeReceiveConflicts: async () => ({ dir: "/home/u", existing: ["upload.txt"] }),
requestOverwriteDecision: async () => ({ action: "overwrite", applyToRest: false }),
removeRemoteFiles: (_paths, { signal } = {}) => {
removeSignal = signal;
return new Promise((resolve) => { releaseRemove = resolve; });
},
},
);
while (!releaseRemove) await new Promise((resolve) => setImmediate(resolve));
controller.abort();
await assert.rejects(
() => upload,
(err) => err && err.code === "NETCATTY_ZMODEM_CANCELLED",
);
assert.equal(removeSignal, controller.signal);
assert.equal(removeSignal.aborted, true);
releaseRemove();
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("handleUpload allows a longer final wait after upload backpressure", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const filePath = path.join(tempDir, "upload.txt");
fs.writeFileSync(filePath, "payload");
let closed = false;
const zsession = {
async send_offer() {
return {
send() {},
end() {
return new Promise((resolve) => setTimeout(resolve, 30));
},
};
},
async close() {
closed = true;
},
};
await handleUpload(zsession, {
sessionId: "session-1",
getWebContents: () => null,
writeToRemote: () => true,
takeDragDropUpload: () => ({
filePaths: [filePath],
remoteNames: ["upload.txt"],
}),
hasUploadBackpressure: () => true,
resetUploadBackpressure: () => {},
uploadFileEndTimeoutMs: 10,
slowUploadFileEndTimeoutMs: 80,
uploadSessionCloseTimeoutMs: 10,
});
assert.equal(closed, true);
fs.rmSync(tempDir, { recursive: true, force: true });
});
// Issue #2863: rz protect/skip must not look like a successful upload.
test("handleUpload fails when the remote skips an offered file", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const filePath = path.join(tempDir, "upload.txt");
fs.writeFileSync(filePath, "payload");
let closed = false;
const zsession = {
async send_offer() {
return undefined; // ZSKIP / protect mode
},
async close() {
closed = true;
},
};
await assert.rejects(
handleUpload(zsession, {
sessionId: "session-1",
getWebContents: () => null,
writeToRemote: () => true,
takeDragDropUpload: () => ({
filePaths: [filePath],
remoteNames: ["upload.txt"],
}),
}),
/skipped|not overwritten|protect/i,
);
assert.equal(closed, false);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("drag-drop upload relies on rz overwrite without probing, prompting, or pre-deleting", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const filePath = path.join(tempDir, "upload.txt");
fs.writeFileSync(filePath, "payload");
let probed = false;
let prompted = false;
let removed = [];
let offerNames = [];
const zsession = {
async send_offer(params) {
offerNames.push(params.name);
return {
send() {},
async end() {},
};
},
async close() {},
};
await handleUpload(zsession, {
sessionId: "session-1",
getWebContents: () => null,
writeToRemote: () => true,
takeDragDropUpload: () => ({
filePaths: [filePath],
remoteNames: ["upload.txt"],
}),
probeReceiveConflicts: async () => {
probed = true;
return {
dir: "/home/u",
existing: ["upload.txt"],
modes: { "upload.txt": "644" },
};
},
requestOverwriteDecision: async () => {
prompted = true;
return { action: "skip", applyToRest: false };
},
removeRemoteFiles: async (paths) => {
removed.push(...paths);
},
});
assert.equal(probed, false);
assert.equal(prompted, false);
assert.deepEqual(removed, []);
assert.deepEqual(offerNames, ["upload.txt"]);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("drag-drop offers duplicate remote names independently without pre-deleting", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const firstPath = path.join(tempDir, "first.txt");
const secondPath = path.join(tempDir, "second.txt");
fs.writeFileSync(firstPath, "first");
fs.writeFileSync(secondPath, "second");
const offered = [];
const removed = [];
const zsession = {
async send_offer(params) {
offered.push(params.name);
return {
send() {},
async end() {},
};
},
async close() {},
};
await handleUpload(zsession, {
sessionId: "session-1",
getWebContents: () => null,
writeToRemote: () => true,
takeDragDropUpload: () => ({
filePaths: [firstPath, secondPath],
remoteNames: ["x.txt", "x.txt"],
}),
probeReceiveConflicts: async () => {
throw new Error("drag-drop should not probe conflicts");
},
requestOverwriteDecision: async () => {
throw new Error("drag-drop should not prompt for conflicts");
},
removeRemoteFiles: async (paths) => {
removed.push(...paths);
},
});
assert.deepEqual(offered, ["x.txt", "x.txt"]);
assert.deepEqual(removed, []);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("failed drag-drop offer does not delete any existing conflict targets", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const firstPath = path.join(tempDir, "a.txt");
const secondPath = path.join(tempDir, "b.txt");
fs.writeFileSync(firstPath, "a");
fs.writeFileSync(secondPath, "b");
const removed = [];
const zsession = {
async send_offer(params) {
if (params.name === "a.txt") {
throw new Error("simulated early failure");
}
return {
send() {},
async end() {},
};
},
abort() {},
async close() {},
};
await assert.rejects(
handleUpload(zsession, {
sessionId: "session-1",
getWebContents: () => null,
writeToRemote: () => true,
takeDragDropUpload: () => ({
filePaths: [firstPath, secondPath],
remoteNames: ["a.txt", "b.txt"],
}),
probeReceiveConflicts: async () => ({
dir: "/home/u",
existing: ["a.txt", "b.txt"],
modes: { "a.txt": "644", "b.txt": "600" },
}),
requestOverwriteDecision: async () => ({ action: "skip", applyToRest: false }),
removeRemoteFiles: async (paths) => {
removed.push(...paths);
},
}),
/simulated early failure/,
);
assert.deepEqual(removed, []);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("handleUpload restores modes for accepted overwrites before partial ZSKIP failure", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const acceptedPath = path.join(tempDir, "a.sh");
const skippedPath = path.join(tempDir, "b.txt");
fs.writeFileSync(acceptedPath, "a");
fs.writeFileSync(skippedPath, "b");
let offerCount = 0;
let restored = [];
const zsession = {
async send_offer(params) {
offerCount += 1;
if (params.name === "b.txt") return undefined;
return {
send() {},
async end() {},
};
},
abort() {},
async close() {},
};
await assert.rejects(
handleUpload(zsession, {
sessionId: "session-1",
getWebContents: () => null,
writeToRemote: () => true,
selectUploadFiles: async () => ({
canceled: false,
filePaths: [acceptedPath, skippedPath],
}),
probeReceiveConflicts: async () => ({
dir: "/home/u",
existing: ["a.sh", "b.txt"],
modes: { "a.sh": "755", "b.txt": "644" },
}),
requestOverwriteDecision: async () => ({ action: "overwrite", applyToRest: true }),
removeRemoteFiles: async () => {},
restoreRemoteModes: async (entries) => {
restored = entries;
},
}),
/Remote skipped some files/,
);
assert.equal(offerCount, 2);
assert.deepEqual(restored, [{ path: "/home/u/a.sh", mode: "755" }]);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("mode restore tracks skipped offers by index for duplicate basenames", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const firstDir = path.join(tempDir, "first");
const secondDir = path.join(tempDir, "second");
fs.mkdirSync(firstDir);
fs.mkdirSync(secondDir);
const firstPath = path.join(firstDir, "x.txt");
const secondPath = path.join(secondDir, "x.txt");
fs.writeFileSync(firstPath, "first");
fs.writeFileSync(secondPath, "second");
let offerCount = 0;
let restored = [];
const zsession = {
async send_offer() {
offerCount += 1;
// Accept the first duplicate-name offer; ZSKIP the second.
if (offerCount > 1) return undefined;
return {
send() {},
async end() {},
};
},
abort() {},
async close() {},
};
await assert.rejects(
handleUpload(zsession, {
sessionId: "session-1",
getWebContents: () => null,
writeToRemote: () => true,
selectUploadFiles: async () => ({
canceled: false,
filePaths: [firstPath, secondPath],
}),
probeReceiveConflicts: async () => ({
dir: "/home/u",
existing: ["x.txt"],
modes: { "x.txt": "600" },
}),
requestOverwriteDecision: async () => ({ action: "overwrite", applyToRest: true }),
removeRemoteFiles: async () => {},
restoreRemoteModes: async (entries) => {
restored = entries;
},
}),
/Remote skipped some files/,
);
assert.equal(offerCount, 2);
// Filename-based skip filtering would drop this restore; index-based keeps it.
assert.deepEqual(restored, [{ path: "/home/u/x.txt", mode: "600" }]);
fs.rmSync(tempDir, { recursive: true, force: true });
});
// Issue #2967: large rz uploads must wait for real transport drain, not one
// setImmediate tick. Flooding the SSH channel completes UI progress early,
// drops the session, and leaves a truncated remote file.
test("waitForWritableDrain resolves immediately when the stream does not need drain", async () => {
const stream = {
writableNeedDrain: false,
once() {
throw new Error("should not wait for drain");
},
off() {},
};
await waitForWritableDrain(stream);
});
test("waitForWritableDrain waits until the stream emits drain", async () => {
const listeners = new Map();
const stream = {
writableNeedDrain: true,
once(event, cb) {
if (!listeners.has(event)) listeners.set(event, []);
listeners.get(event).push(cb);
},
off(event, cb) {
const list = listeners.get(event) || [];
listeners.set(event, list.filter((fn) => fn !== cb));
},
};
let resolved = false;
const pending = waitForWritableDrain(stream).then(() => {
resolved = true;
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(resolved, false);
assert.equal((listeners.get("drain") || []).length, 1);
stream.writableNeedDrain = false;
for (const cb of listeners.get("drain") || []) cb();
await pending;
assert.equal(resolved, true);
});
test("waitForWritableDrain rejects when the transport never drains", async () => {
const listeners = new Map();
const stream = {
writableNeedDrain: true,
once(event, cb) {
if (!listeners.has(event)) listeners.set(event, []);
listeners.get(event).push(cb);
},
off(event, cb) {
const list = listeners.get(event) || [];
listeners.set(event, list.filter((fn) => fn !== cb));
},
};
await assert.rejects(
() => waitForWritableDrain(stream, { timeoutMs: 20 }),
(err) => err && err.code === "NETCATTY_ZMODEM_TIMEOUT",
);
assert.equal((listeners.get("drain") || []).length, 0);
});
test("waitForWritableDrain keeps waiting while a slow SSH channel makes progress", async () => {
const listeners = new Map();
const stream = {
writableNeedDrain: true,
writableLength: 2 * 1024 * 1024,
pendingChunkLength: 8 * 1024,
once(event, cb) {
if (!listeners.has(event)) listeners.set(event, []);
listeners.get(event).push(cb);
},
off(event, cb) {
const list = listeners.get(event) || [];
listeners.set(event, list.filter((fn) => fn !== cb));
},
};
let settled = false;
const pending = waitForWritableDrain(stream, {
timeoutMs: 20,
progressIntervalMs: 5,
getProgressValue: () => stream.writableLength + stream.pendingChunkLength,
}).then(() => {
settled = true;
});
for (let i = 0; i < 5; i++) {
await new Promise((resolve) => setTimeout(resolve, 10));
// ssh2 can advance part of its current frame without completing the
// corresponding Node write, so writableLength remains unchanged.
stream.pendingChunkLength -= 1024;
}
assert.equal(settled, false);
stream.writableNeedDrain = false;
for (const cb of listeners.get("drain") || []) cb();
await pending;
assert.equal(settled, true);
});
test("waitForWritableDrain rejects when an SSH channel stops making progress", async () => {
const listeners = new Map();
const stream = {
writableNeedDrain: true,
writableLength: 2 * 1024 * 1024,
once(event, cb) {
if (!listeners.has(event)) listeners.set(event, []);
listeners.get(event).push(cb);
},
off(event, cb) {
const list = listeners.get(event) || [];
listeners.set(event, list.filter((fn) => fn !== cb));
},
};
await assert.rejects(
() => waitForWritableDrain(stream, { timeoutMs: 20, progressIntervalMs: 5 }),
(err) => err && err.code === "NETCATTY_ZMODEM_TIMEOUT",
);
});
test("waitForWritableDrain rejects when the transport closes before drain", async () => {
const listeners = new Map();
const stream = {
writableNeedDrain: true,
once(event, cb) {
if (!listeners.has(event)) listeners.set(event, []);
listeners.get(event).push(cb);
},
off(event, cb) {
const list = listeners.get(event) || [];
listeners.set(event, list.filter((fn) => fn !== cb));
},
};
const pending = waitForWritableDrain(stream, { timeoutMs: 5_000 });
await new Promise((resolve) => setImmediate(resolve));
for (const cb of listeners.get("close") || []) cb();
await assert.rejects(
() => pending,
(err) => err && err.code === "NETCATTY_ZMODEM_TRANSPORT_CLOSED",
);
assert.equal((listeners.get("drain") || []).length, 0);
});
test("waitForWritableDrain rejects with the transport error event", async () => {
const listeners = new Map();
const stream = {
writableNeedDrain: true,
once(event, cb) {
if (!listeners.has(event)) listeners.set(event, []);
listeners.get(event).push(cb);
},
off(event, cb) {
const list = listeners.get(event) || [];
listeners.set(event, list.filter((fn) => fn !== cb));
},
};
const pending = waitForWritableDrain(stream, { timeoutMs: 5_000 });
await new Promise((resolve) => setImmediate(resolve));
const boom = new Error("socket hang up");
boom.code = "ECONNRESET";
for (const cb of listeners.get("error") || []) cb(boom);
await assert.rejects(
() => pending,
(err) => err === boom,
);
});
test("waitForWritableDrain rejects promptly when the transfer AbortSignal aborts", async () => {
const listeners = new Map();
const stream = {
writableNeedDrain: true,
once(event, cb) {
if (!listeners.has(event)) listeners.set(event, []);
listeners.get(event).push(cb);
},
off(event, cb) {
const list = listeners.get(event) || [];
listeners.set(event, list.filter((fn) => fn !== cb));
},
};
const controller = new AbortController();
const pending = waitForWritableDrain(stream, {
timeoutMs: 60_000,
signal: controller.signal,
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal((listeners.get("drain") || []).length, 1);
controller.abort();
await assert.rejects(
() => pending,
(err) => err && err.code === "NETCATTY_ZMODEM_CANCELLED",
);
assert.equal((listeners.get("drain") || []).length, 0);
});
test("createZmodemUploadDrainWaiter waits on transport drain after backpressure", async () => {
let needsDrain = true;
let transportWaits = 0;
let releaseTransport;
const transportPending = new Promise((resolve) => {
releaseTransport = resolve;
});
const waitForDrain = createZmodemUploadDrainWaiter({
getNeedsDrain: () => needsDrain,
clearNeedsDrain: () => {
needsDrain = false;
},
waitForTransportDrain: async () => {
transportWaits += 1;
await transportPending;
},
});
let resolved = false;
const pending = waitForDrain().then(() => {
resolved = true;
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(resolved, false);
assert.equal(transportWaits, 1);
assert.equal(needsDrain, true);
releaseTransport();
await pending;
assert.equal(resolved, true);
assert.equal(needsDrain, false);
});
test("createZmodemUploadDrainWaiter recovers remote rz on transport drain timeout", async () => {
let needsDrain = true;
let timeoutNotified = false;
const writes = [];
const waitForDrain = createZmodemUploadDrainWaiter({
getNeedsDrain: () => needsDrain,
clearNeedsDrain: () => {
needsDrain = false;
},
waitForTransportDrain: async () => {
const err = new Error("Transport drain timeout");
err.code = "NETCATTY_ZMODEM_TIMEOUT";
throw err;
},
onUploadTimeout: () => {
timeoutNotified = true;
},
writeToRemote: (buf) => {
writes.push(Buffer.from(buf));
},
});
await assert.rejects(
() => waitForDrain(),
(err) => err && err.code === "NETCATTY_ZMODEM_TIMEOUT",
);
assert.equal(timeoutNotified, true);
assert.equal(needsDrain, false);
assert.deepEqual([...writes[0]], [0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18]);
});
test("createZmodemUploadDrainWaiter rejects on transfer cancel without timeout recovery", async () => {
let needsDrain = true;
let timeoutNotified = false;
let releaseTransport;
const transportPending = new Promise((resolve) => {
releaseTransport = resolve;
});
const controller = new AbortController();
const waitForDrain = createZmodemUploadDrainWaiter({
getNeedsDrain: () => needsDrain,
clearNeedsDrain: () => {
needsDrain = false;
},
signal: controller.signal,
waitForTransportDrain: async ({ signal } = {}) => waitForWritableDrain({
writableNeedDrain: true,
once(event, cb) {
if (event === "drain") {
transportPending.then(() => cb());
}
},
off() {},
}, { timeoutMs: 60_000, signal }),
onUploadTimeout: () => {
timeoutNotified = true;
},
writeToRemote: () => {
throw new Error("cancel must not abort remote via timeout recovery");
},
});
const pending = waitForDrain();
await new Promise((resolve) => setImmediate(resolve));
controller.abort();
await assert.rejects(
() => pending,
(err) => err && err.code === "NETCATTY_ZMODEM_CANCELLED",
);
assert.equal(timeoutNotified, false);
assert.equal(needsDrain, false);
releaseTransport();
});
test("createZmodemUploadDrainWaiter falls back to a single yield without transport drain", async () => {
let needsDrain = true;
const waitForDrain = createZmodemUploadDrainWaiter({
getNeedsDrain: () => needsDrain,
clearNeedsDrain: () => {
needsDrain = false;
},
});
await waitForDrain();
assert.equal(needsDrain, false);
});