mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-11 17:00:22 +00:00
* fix(sftp): instant folder-drop scan UI and drop full-tree pre-scan Restore interleaved folder transfers as the only non-compressed path, remove the folder pre-scan setting, and make local folder drops show a scanning task with live discovered counts via native listLocalTree (with cancel + progress). UI shows "done · found" instead of a fake grand-total percentage while discovery is still growing. * fix(sftp): make folder scan cancellation bridge-safe * fix(sftp): preserve path-only terminal drops * fix(terminal): accept native folder drop paths * fix(sftp): keep scan task cancellable during setup * fix(sftp): preserve scan failure state during setup errors * fix(sftp): keep folder scan task across retries * fix(sftp): cancel scan task immediately * fix(sftp): keep cancellation active through session setup * fix(sftp): settle failed drop upload retry * fix(sftp): preserve cancellation in final scan cleanup * fix(sftp): harden folder scan cancellation and conflicts * fix(sftp): finish Codex follow-ups on folder scan cancel UX - Abort sibling listLocalTree walks when one multi-root scan fails - Prefer ERR_*_CANCELLED over matching "cancel" in error text - Clear scanning phase on settle; only label live rows as Scanning - Omit directory metadata sizes from drop entries used for totals * fix(sftp): stream folder discovery into concurrent uploads Local path-backed folder drops now edge-scan and edge-transfer: listLocalTree streams entry batches while upload workers consume them, so the first files start before a 100k-file walk finishes. Cancel aborts the walk, clears the queue, and stops creating child tasks. Compressed uploads still materialize fully then pack. * fix(sftp): harden progressive folder UX, retry, and size-based uploads Align the SFTP panel with transfer-center progress, keep nested tree batches until tree-end, honor pause during progressive enqueue, add low-cost retry for external drag-drop files, and drop whole-file upload digests for industry size-based resume (download live-log prefix checks unchanged). * fix(sftp): address multi-agent review on progressive upload and retry Gate progressive drops to path-backed folders only, prefer pool sessions on drag-drop retry with in-flight and cancel guards, soft-check upload mtime without fail-open contentVerifiedSeparately, skip multi-root pause HOL, and fail closed when the local tree stream misses tree-end. * fix(sftp): finish multi-root pause HOL and parent rollup guards Race-wait on all latched parents so resuming a non-head folder can proceed, and only roll progressive parents to completed after terminal failed status. * fix(sftp): address Codex progressive conflict and hang findings Add root-level conflict preflight to progressive folder uploads, wake all backpressure waiters, preserve cancel listeners across uploadEntriesDirect, and surface empty-directory mkdir failures.
155 lines
4.8 KiB
TypeScript
155 lines
4.8 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import type { TransferTask } from "../../../domain/models";
|
|
import { createUploadTaskCallbacks } from "./uploadTaskCallbacks";
|
|
|
|
test("upload task callbacks write through the transfer store without page callbacks", () => {
|
|
const upserts: TransferTask[][] = [];
|
|
const patches: Array<{ taskId: string; updates: Partial<TransferTask> }> = [];
|
|
const dismissed: string[] = [];
|
|
const store = {
|
|
upsertTasks: (tasks: readonly TransferTask[]) => upserts.push([...tasks]),
|
|
patchTask: (taskId: string, updates: Partial<TransferTask>) => patches.push({ taskId, updates }),
|
|
dismiss: (taskId: string) => dismissed.push(taskId),
|
|
};
|
|
const callbacks = createUploadTaskCallbacks({
|
|
ownerId: "owner-1",
|
|
connectionId: "connection-1",
|
|
targetPath: "/remote",
|
|
targetHostId: "host-1",
|
|
store,
|
|
});
|
|
|
|
callbacks.onTaskCreated?.({
|
|
id: "upload-1",
|
|
fileName: "file.bin",
|
|
displayName: "file.bin",
|
|
sourcePath: "/local/file.bin",
|
|
totalBytes: 100,
|
|
isDirectory: false,
|
|
});
|
|
callbacks.onTaskProgress?.("upload-1", {
|
|
transferred: 40,
|
|
total: 100,
|
|
speed: 20,
|
|
percent: 40,
|
|
checkpointBytes: 32,
|
|
phase: "transferring",
|
|
});
|
|
callbacks.onTaskCompleted?.("upload-1", 100);
|
|
|
|
assert.equal(upserts.length, 1);
|
|
assert.equal(upserts[0][0].ownerId, "owner-1");
|
|
assert.equal(upserts[0][0].id, "upload-1");
|
|
assert.deepEqual(patches[0], {
|
|
taskId: "upload-1",
|
|
updates: {
|
|
transferredBytes: 40,
|
|
totalBytes: 100,
|
|
checkpointBytes: 32,
|
|
speed: 20,
|
|
phase: "transferring",
|
|
resumable: undefined,
|
|
pauseUnavailableReason: undefined,
|
|
},
|
|
});
|
|
assert.equal(patches[1].taskId, "upload-1");
|
|
assert.equal(patches[1].updates.status, "completed");
|
|
assert.equal(patches[1].updates.transferredBytes, 100);
|
|
assert.deepEqual(dismissed, []);
|
|
});
|
|
|
|
test("progress promotes pending scanning folder rows into transferring", () => {
|
|
const patches: Array<{ taskId: string; updates: Partial<TransferTask> }> = [];
|
|
const liveTask: TransferTask = {
|
|
id: "folder-1",
|
|
fileName: "docs",
|
|
sourcePath: "local",
|
|
targetPath: "/remote/docs",
|
|
sourceConnectionId: "external",
|
|
targetConnectionId: "connection-1",
|
|
direction: "upload",
|
|
status: "pending",
|
|
totalBytes: 0,
|
|
transferredBytes: 0,
|
|
speed: 0,
|
|
startTime: 1,
|
|
isDirectory: true,
|
|
progressMode: "files",
|
|
phase: "scanning",
|
|
};
|
|
const store = {
|
|
upsertTasks: () => {},
|
|
patchTask: (taskId: string, updates: Partial<TransferTask>) => patches.push({ taskId, updates }),
|
|
dismiss: () => {},
|
|
getTask: (taskId: string) => (taskId === "folder-1" ? liveTask : undefined),
|
|
};
|
|
const callbacks = createUploadTaskCallbacks({
|
|
ownerId: "owner-1",
|
|
connectionId: "connection-1",
|
|
targetPath: "/remote",
|
|
store,
|
|
});
|
|
|
|
callbacks.onTaskProgress?.("folder-1", {
|
|
transferred: 12,
|
|
total: 400,
|
|
speed: 0,
|
|
percent: 3,
|
|
phase: "transferring",
|
|
});
|
|
|
|
assert.equal(patches[0].taskId, "folder-1");
|
|
assert.equal(patches[0].updates.status, "transferring");
|
|
assert.equal(patches[0].updates.transferredBytes, 12);
|
|
assert.equal(patches[0].updates.totalBytes, 400);
|
|
assert.equal(patches[0].updates.phase, "transferring");
|
|
});
|
|
|
|
test("scanning callbacks expose live file counts in files progress mode", () => {
|
|
const upserts: TransferTask[][] = [];
|
|
const patches: Array<{ taskId: string; updates: Partial<TransferTask> }> = [];
|
|
const store = {
|
|
upsertTasks: (tasks: readonly TransferTask[]) => upserts.push([...tasks]),
|
|
patchTask: (taskId: string, updates: Partial<TransferTask>) => patches.push({ taskId, updates }),
|
|
dismiss: () => {},
|
|
};
|
|
const callbacks = createUploadTaskCallbacks({
|
|
ownerId: "owner-1",
|
|
connectionId: "connection-1",
|
|
targetPath: "/remote",
|
|
store,
|
|
});
|
|
|
|
callbacks.onScanningStart?.("scan-1", { label: "docs" });
|
|
callbacks.onScanningProgress?.("scan-1", {
|
|
fileCount: 1284,
|
|
directoryCount: 40,
|
|
entryCount: 1324,
|
|
label: "docs",
|
|
});
|
|
|
|
assert.equal(upserts[0][0].fileName, "docs");
|
|
assert.equal(upserts[0][0].phase, "scanning");
|
|
assert.equal(upserts[0][0].progressMode, "files");
|
|
assert.equal(upserts[0][0].status, "pending");
|
|
assert.deepEqual(patches[0], {
|
|
taskId: "scan-1",
|
|
updates: {
|
|
// Found files live in totalBytes; completed stays 0 during scan.
|
|
transferredBytes: 0,
|
|
totalBytes: 1284,
|
|
progressMode: "files",
|
|
phase: "scanning",
|
|
fileName: "docs",
|
|
},
|
|
});
|
|
|
|
// Display contract: 0 done · N found (not N done · N found).
|
|
const discovered = Math.max(patches[0].updates.totalBytes ?? 0, patches[0].updates.transferredBytes ?? 0);
|
|
const completed = patches[0].updates.transferredBytes ?? 0;
|
|
assert.equal(completed, 0);
|
|
assert.equal(discovered, 1284);
|
|
});
|