1404 lines
41 KiB
JavaScript
1404 lines
41 KiB
JavaScript
|
|
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);
|
||
|
|
});
|