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
318 lines
8.1 KiB
TypeScript
318 lines
8.1 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import type { TransferTask } from "./models";
|
|
import {
|
|
findActivePathConflict,
|
|
pathConflictMessage,
|
|
} from "./sftpTransferConflicts";
|
|
|
|
const base = (overrides: Partial<TransferTask> = {}): TransferTask => ({
|
|
id: "a",
|
|
fileName: "sing-box",
|
|
sourcePath: "/root/sing-box",
|
|
targetPath: "/Users/me/Desktop/sing-box",
|
|
sourceConnectionId: "remote",
|
|
targetConnectionId: "local",
|
|
direction: "download",
|
|
status: "transferring",
|
|
totalBytes: 100,
|
|
transferredBytes: 10,
|
|
speed: 1,
|
|
startTime: 1,
|
|
isDirectory: false,
|
|
...overrides,
|
|
});
|
|
|
|
test("findActivePathConflict matches same destination among active rows", () => {
|
|
const tasks = [
|
|
base({ id: "live", status: "transferring" }),
|
|
base({ id: "done", status: "completed", transferredBytes: 100 }),
|
|
base({ id: "other-path", targetPath: "/tmp/other" }),
|
|
];
|
|
assert.equal(
|
|
findActivePathConflict(tasks, {
|
|
id: "new",
|
|
targetPath: "/Users/me/Desktop/sing-box",
|
|
targetConnectionId: "local",
|
|
})?.id,
|
|
"live",
|
|
);
|
|
assert.equal(
|
|
findActivePathConflict(tasks, {
|
|
id: "live",
|
|
targetPath: "/Users/me/Desktop/sing-box",
|
|
targetConnectionId: "local",
|
|
}),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
test("findActivePathConflict ignores identical paths on different endpoints", () => {
|
|
const tasks = [
|
|
base({
|
|
id: "host-a",
|
|
direction: "upload",
|
|
sourcePath: "/local/file",
|
|
targetPath: "/remote/file",
|
|
sourceConnectionId: "local",
|
|
targetConnectionId: "conn-a",
|
|
targetHostId: "host-a",
|
|
}),
|
|
];
|
|
assert.equal(
|
|
findActivePathConflict(tasks, {
|
|
id: "host-b",
|
|
targetPath: "/remote/file",
|
|
targetConnectionId: "conn-b",
|
|
targetHostId: "host-b",
|
|
}),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
test("findActivePathConflict collides different sources writing one destination", () => {
|
|
const tasks = [
|
|
base({
|
|
id: "from-a",
|
|
sourcePath: "/root/a",
|
|
targetPath: "/Users/me/Desktop/out.bin",
|
|
}),
|
|
];
|
|
assert.equal(
|
|
findActivePathConflict(tasks, {
|
|
id: "from-b",
|
|
targetPath: "/Users/me/Desktop/out.bin",
|
|
targetConnectionId: "local",
|
|
})?.id,
|
|
"from-a",
|
|
);
|
|
});
|
|
|
|
test("findActivePathConflict requires local endpoint on the candidate (downloadToLocal regression)", () => {
|
|
// createDirectDownloadTransferTask stores targetConnectionId:"local".
|
|
// A bare { targetPath } candidate is not isLocalTransferDestination — it
|
|
// used to miss every active download and allow duplicate concurrent writers.
|
|
const tasks = [
|
|
base({
|
|
id: "active-download",
|
|
targetPath: "/Users/me/Downloads/frpc.tar",
|
|
targetConnectionId: "local",
|
|
targetHostLabel: "Local",
|
|
status: "transferring",
|
|
}),
|
|
];
|
|
assert.equal(
|
|
findActivePathConflict(tasks, {
|
|
id: "broken-candidate",
|
|
targetPath: "/Users/me/Downloads/frpc.tar",
|
|
}),
|
|
undefined,
|
|
"missing local endpoint must not false-match (documents the bug shape)",
|
|
);
|
|
assert.equal(
|
|
findActivePathConflict(tasks, {
|
|
id: "fixed-candidate",
|
|
targetPath: "/Users/me/Downloads/frpc.tar",
|
|
targetConnectionId: "local",
|
|
targetHostLabel: "Local",
|
|
})?.id,
|
|
"active-download",
|
|
);
|
|
});
|
|
|
|
test("findActivePathConflict treats local pane id and local sentinel as one endpoint", () => {
|
|
const tasks = [
|
|
base({
|
|
id: "dual-pane",
|
|
targetConnectionId: "right-1710000000000",
|
|
targetHostLabel: "Local",
|
|
}),
|
|
];
|
|
assert.equal(
|
|
findActivePathConflict(tasks, {
|
|
id: "save-as",
|
|
targetPath: "/Users/me/Desktop/sing-box",
|
|
targetConnectionId: "local",
|
|
})?.id,
|
|
"dual-pane",
|
|
);
|
|
// Dual-pane enqueue normalizes local candidates to the "local" sentinel + label.
|
|
assert.equal(
|
|
findActivePathConflict(tasks, {
|
|
id: "other-pane",
|
|
targetPath: "/Users/me/Desktop/sing-box",
|
|
targetConnectionId: "local",
|
|
targetHostLabel: "Local",
|
|
})?.id,
|
|
"dual-pane",
|
|
);
|
|
// Two stored dual-pane local rows (ephemeral pane ids) still collide.
|
|
assert.equal(
|
|
findActivePathConflict(
|
|
[
|
|
...tasks,
|
|
base({
|
|
id: "left-pane",
|
|
targetConnectionId: "left-1710000000001",
|
|
targetHostLabel: "Local",
|
|
status: "pending",
|
|
}),
|
|
],
|
|
{
|
|
id: "left-pane",
|
|
targetPath: "/Users/me/Desktop/sing-box",
|
|
targetConnectionId: "left-1710000000001",
|
|
targetHostLabel: "Local",
|
|
},
|
|
)?.id,
|
|
"dual-pane",
|
|
);
|
|
});
|
|
|
|
test("interrupted is not an active path conflict (resume may claim the path)", () => {
|
|
const tasks = [base({ id: "dead", status: "interrupted" })];
|
|
assert.equal(
|
|
findActivePathConflict(tasks, {
|
|
id: "resume",
|
|
targetPath: "/Users/me/Desktop/sing-box",
|
|
targetConnectionId: "local",
|
|
}),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
test("findActivePathConflict includes active directory child transfers", () => {
|
|
const tasks = [
|
|
base({
|
|
id: "dir-parent",
|
|
fileName: "bundle",
|
|
targetPath: "/Users/me/Desktop/bundle",
|
|
isDirectory: true,
|
|
status: "transferring",
|
|
}),
|
|
base({
|
|
id: "dir-child",
|
|
fileName: "out.bin",
|
|
targetPath: "/Users/me/Desktop/bundle/out.bin",
|
|
parentTaskId: "dir-parent",
|
|
status: "transferring",
|
|
}),
|
|
];
|
|
assert.equal(
|
|
findActivePathConflict(tasks, {
|
|
id: "standalone",
|
|
targetPath: "/Users/me/Desktop/bundle/out.bin",
|
|
targetConnectionId: "local",
|
|
})?.id,
|
|
"dir-child",
|
|
);
|
|
// Parent directory path alone is not the same destination as a nested file.
|
|
assert.equal(
|
|
findActivePathConflict(tasks, {
|
|
id: "standalone-dir",
|
|
targetPath: "/Users/me/Desktop/bundle",
|
|
targetConnectionId: "local",
|
|
})?.id,
|
|
"dir-parent",
|
|
);
|
|
});
|
|
|
|
test("findActivePathConflict reserves descendants of active directory transfers", () => {
|
|
const tasks = [
|
|
base({
|
|
id: "dir-parent",
|
|
fileName: "bundle",
|
|
targetPath: "/Users/me/Desktop/bundle",
|
|
isDirectory: true,
|
|
status: "pending",
|
|
}),
|
|
];
|
|
assert.equal(
|
|
findActivePathConflict(tasks, {
|
|
id: "standalone",
|
|
targetPath: "/Users/me/Desktop/bundle/out.bin",
|
|
targetConnectionId: "local",
|
|
})?.id,
|
|
"dir-parent",
|
|
);
|
|
assert.equal(
|
|
findActivePathConflict(tasks, {
|
|
id: "sibling-prefix",
|
|
targetPath: "/Users/me/Desktop/bundle-old/out.bin",
|
|
targetConnectionId: "local",
|
|
}),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
test("findActivePathConflict refuses directory that would cover an active descendant", () => {
|
|
const tasks = [
|
|
base({
|
|
id: "live-file",
|
|
fileName: "out.bin",
|
|
targetPath: "/Users/me/Desktop/bundle/out.bin",
|
|
status: "transferring",
|
|
}),
|
|
];
|
|
assert.equal(
|
|
findActivePathConflict(tasks, {
|
|
id: "incoming-dir",
|
|
targetPath: "/Users/me/Desktop/bundle",
|
|
targetConnectionId: "local",
|
|
isDirectory: true,
|
|
})?.id,
|
|
"live-file",
|
|
);
|
|
// Without isDirectory, a file-named path does not reserve descendants.
|
|
assert.equal(
|
|
findActivePathConflict(tasks, {
|
|
id: "incoming-file",
|
|
targetPath: "/Users/me/Desktop/bundle",
|
|
targetConnectionId: "local",
|
|
isDirectory: false,
|
|
}),
|
|
undefined,
|
|
);
|
|
assert.equal(
|
|
findActivePathConflict(tasks, {
|
|
id: "sibling-dir",
|
|
targetPath: "/Users/me/Desktop/bundle-old",
|
|
targetConnectionId: "local",
|
|
isDirectory: true,
|
|
}),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
test("findActivePathConflict compares Windows local paths canonically", () => {
|
|
const tasks = [
|
|
base({
|
|
id: "live",
|
|
targetPath: "C:\\Downloads\\out.bin",
|
|
status: "transferring",
|
|
}),
|
|
];
|
|
assert.equal(
|
|
findActivePathConflict(tasks, {
|
|
id: "save-as",
|
|
targetPath: "c:/downloads/out.bin",
|
|
targetConnectionId: "local",
|
|
})?.id,
|
|
"live",
|
|
);
|
|
assert.equal(
|
|
findActivePathConflict(tasks, {
|
|
id: "other",
|
|
targetPath: "C:\\Downloads\\other.bin",
|
|
targetConnectionId: "local",
|
|
}),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
test("pathConflictMessage distinguishes paused vs running", () => {
|
|
assert.match(pathConflictMessage({ fileName: "x", status: "paused" }), /paused/i);
|
|
assert.match(pathConflictMessage({ fileName: "x", status: "transferring" }), /in progress/i);
|
|
});
|