Files
Netcatty/components/GlobalSftpTransferCenter.test.ts
T
陈大猫 443202ba4e fix(sftp): instant folder-drop scan UI and drop full-tree pre-scan (#2782)
* 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.
2026-08-07 02:01:18 +08:00

226 lines
7.7 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import type { TransferTask } from "../domain/models";
import {
buildGlobalTransferProgressDisplay,
getGlobalTransferBadge,
getGlobalTransferBatchEligibility,
getGlobalTransferStatusOverride,
getGlobalTransferBucket,
getTasksForGlobalTransferBucket,
isDirectoryParentTask,
listChildTasksForParent,
pickActiveChildSummaries,
shouldShowCollapsedActiveChildren,
splitBackgroundTransfers,
} from "./GlobalSftpTransferCenter";
const task = (id: string, status: TransferTask["status"], background = false) => ({
id,
fileName: id,
sourcePath: "/a",
targetPath: "/b",
sourceConnectionId: "local",
targetConnectionId: "remote",
direction: "upload" as const,
status,
totalBytes: 10,
transferredBytes: 1,
speed: 1,
startTime: 1,
isDirectory: false,
background,
});
test("global transfer statuses keep paused work separate from items needing attention", () => {
assert.equal(getGlobalTransferBucket(task("a", "transferring")), "active");
assert.equal(getGlobalTransferBucket(task("a", "pausing")), "active");
assert.equal(getGlobalTransferBucket(task("a", "queued")), "queued");
assert.equal(getGlobalTransferBucket(task("a", "paused")), "paused");
assert.equal(getGlobalTransferBucket(task("a", "interrupted")), "attention");
assert.equal(getGlobalTransferBucket(task("a", "attention")), "attention");
assert.equal(getGlobalTransferBucket(task("a", "failed")), "attention");
assert.equal(getGlobalTransferBucket(task("a", "completed")), "completed");
assert.equal(getGlobalTransferBucket(task("a", "cancelled")), "completed");
});
test("batch actions are disabled when no task can accept them", () => {
assert.deepEqual(getGlobalTransferBatchEligibility([
task("done", "completed"),
task("cancelled", "cancelled"),
]), { pausableCount: 0, resumableCount: 0 });
assert.deepEqual(getGlobalTransferBatchEligibility([
task("running", "transferring"),
task("waiting", "queued"),
task("paused", "paused"),
{ ...task("failed", "failed"), checkpointBytes: 4 },
]), { pausableCount: 2, resumableCount: 2 });
});
test("paused transfer errors remain visible instead of looking safely paused", () => {
assert.equal(getGlobalTransferStatusOverride({
error: "Upload resume is unavailable",
pauseUnavailableReason: undefined,
}), "Upload resume is unavailable");
});
test("the all bucket includes every top-level task regardless of status", () => {
const tasks = [
task("a", "transferring"),
task("b", "queued"),
task("c", "failed"),
{ ...task("child", "transferring"), parentTaskId: "a" },
];
assert.deepEqual(getTasksForGlobalTransferBucket(tasks, "all").map((item) => item.id), ["a", "b", "c"]);
});
test("badge counts active and queued work while surfacing attention", () => {
assert.deepEqual(getGlobalTransferBadge([
task("a", "transferring"),
task("b", "queued"),
task("c", "failed"),
]), { count: 2, hasAttention: true });
});
test("badge does not double count child files in a folder transfer", () => {
assert.deepEqual(getGlobalTransferBadge([
task("parent", "transferring"),
{ ...task("child", "transferring"), parentTaskId: "parent" },
]), { count: 1, hasAttention: false });
});
test("successful background work is collapsed but failures stay visible", () => {
const split = splitBackgroundTransfers([
task("a", "completed", true),
task("b", "failed", true),
task("c", "completed", false),
]);
assert.deepEqual(split.visible.map((item) => item.id), ["b", "c"]);
assert.deepEqual(split.collapsed.map((item) => item.id), ["a"]);
});
test("directory parent detection matches side-queue file-count semantics", () => {
assert.equal(isDirectoryParentTask({
isDirectory: true,
parentTaskId: undefined,
progressMode: "files",
}), true);
assert.equal(isDirectoryParentTask({
isDirectory: true,
parentTaskId: undefined,
progressMode: undefined,
}), true);
assert.equal(isDirectoryParentTask({
isDirectory: true,
parentTaskId: "p",
progressMode: "files",
}), false);
assert.equal(isDirectoryParentTask({
isDirectory: false,
parentTaskId: undefined,
progressMode: "bytes",
}), false);
assert.equal(isDirectoryParentTask({
isDirectory: true,
parentTaskId: undefined,
progressMode: "bytes",
}), false);
});
test("folder parent progress uses progressive discovery labels not byte formatting", () => {
const t = (key: string, params?: Record<string, string | number>) => {
if (key === "sftp.transfers.filesDiscoveredProgress") {
return `${params?.completed} done · ${params?.discovered} found`;
}
if (key === "sftp.transfers.filesProgress") {
return `${params?.current}/${params?.total} files`;
}
if (key === "sftp.transfers.filesCount") {
return `${params?.count} files`;
}
return key;
};
const display = buildGlobalTransferProgressDisplay({
status: "transferring",
isDirectory: true,
progressMode: "files",
totalBytes: 12,
transferredBytes: 1,
speed: 0,
phase: "transferring",
}, t);
assert.equal(display.percent, (1 / 12) * 100);
assert.equal(display.detail, "1 done · 12 found");
assert.equal(display.indeterminate, false);
// Must not look like "1 Bytes / 12 Bytes"
assert.doesNotMatch(display.detail, /Bytes/i);
});
test("folder parent scanning stays indeterminate while nothing has completed", () => {
const t = (key: string, params?: Record<string, string | number>) => {
if (key === "sftp.transfers.filesDiscoveredProgress") {
return `${params?.completed} done · ${params?.discovered} found`;
}
return key;
};
const display = buildGlobalTransferProgressDisplay({
status: "transferring",
isDirectory: true,
progressMode: "files",
totalBytes: 40,
transferredBytes: 0,
speed: 0,
phase: "scanning",
}, t);
assert.equal(display.detail, "0 done · 40 found");
assert.equal(display.indeterminate, true);
});
test("single-file progress still shows byte totals", () => {
const t = (key: string) => key;
const display = buildGlobalTransferProgressDisplay({
status: "transferring",
isDirectory: false,
progressMode: "bytes",
totalBytes: 1024,
transferredBytes: 512,
speed: 0,
}, t);
assert.equal(display.percent, 50);
assert.match(display.detail, /512\s*Bytes/);
assert.match(display.detail, /1(\.00)?\s*KB/);
});
test("active child summary prefers transferring files over queued", () => {
const children = [
{ ...task("q1", "queued"), parentTaskId: "p", startTime: 1 },
{ ...task("t1", "transferring"), parentTaskId: "p", startTime: 2 },
{ ...task("t2", "transferring"), parentTaskId: "p", startTime: 3 },
{ ...task("done", "completed"), parentTaskId: "p", startTime: 0 },
];
assert.deepEqual(
pickActiveChildSummaries(children, 2).map((item) => item.id),
["t1", "t2"],
);
});
test("collapsed active children only show while transferring or soft-draining", () => {
assert.equal(shouldShowCollapsedActiveChildren("transferring"), true);
assert.equal(shouldShowCollapsedActiveChildren("pausing"), true);
assert.equal(shouldShowCollapsedActiveChildren("paused"), false);
assert.equal(shouldShowCollapsedActiveChildren("interrupted"), false);
assert.equal(shouldShowCollapsedActiveChildren("queued"), false);
});
test("listChildTasksForParent skips cancelled siblings", () => {
const tasks = [
task("p", "transferring"),
{ ...task("c1", "transferring"), parentTaskId: "p" },
{ ...task("c2", "cancelled"), parentTaskId: "p" },
{ ...task("other", "transferring"), parentTaskId: "x" },
];
assert.deepEqual(listChildTasksForParent(tasks, "p").map((item) => item.id), ["c1"]);
});