Files
Netcatty/lib/sftpFileUtils.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

146 lines
4.1 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import {
captureDropPayload,
formatDropScanLabel,
getFileExtension,
hasFileExtension,
localTreeToDropEntries,
materializeDropEntries,
type LocalTreeListEntry,
} from "./sftpFileUtils.ts";
test("hasFileExtension identifies extensionless and dotted filenames", () => {
assert.equal(hasFileExtension("nginx"), false);
assert.equal(hasFileExtension("my-binary"), false);
assert.equal(hasFileExtension(".git"), false);
assert.equal(getFileExtension("nginx"), "file");
assert.equal(hasFileExtension("readme.txt"), true);
assert.equal(hasFileExtension(".bashrc"), false);
assert.equal(hasFileExtension("archive.tar.gz"), true);
});
test("formatDropScanLabel summarizes dropped root names", () => {
assert.equal(formatDropScanLabel([]), "Scanning files...");
assert.equal(formatDropScanLabel([{ name: "docs", isDirectory: true }]), "docs");
assert.equal(
formatDropScanLabel([
{ name: "a", isDirectory: true },
{ name: "b", isDirectory: true },
]),
"a, b",
);
assert.equal(
formatDropScanLabel([
{ name: "a", isDirectory: true },
{ name: "b", isDirectory: true },
{ name: "c", isDirectory: true },
]),
"a, b +1",
);
});
test("captureDropPayload reads webkit entries synchronously", () => {
const file = new File(["x"], "note.txt");
Object.defineProperty(file, "path", { value: "/tmp/note.txt" });
const dataTransfer = {
items: [{
kind: "file",
getAsFile: () => file,
webkitGetAsEntry: () => ({
name: "note.txt",
isFile: true,
isDirectory: false,
}),
}],
files: [file],
} as unknown as DataTransfer;
const payload = captureDropPayload(dataTransfer);
assert.equal(payload.roots.length, 1);
assert.equal(payload.roots[0].name, "note.txt");
assert.equal(payload.roots[0].isDirectory, false);
assert.equal(payload.roots[0].localPath, "/tmp/note.txt");
});
test("materializeDropEntries prefers listLocalTree for directory roots with paths", async () => {
const progress: Array<{ fileCount: number; directoryCount: number }> = [];
const tree: LocalTreeListEntry[] = [
{
localPath: "/tmp/project",
relativePath: "project",
type: "directory",
size: 0,
lastModified: 1,
},
{
localPath: "/tmp/project/src",
relativePath: "project/src",
type: "directory",
size: 0,
lastModified: 1,
},
{
localPath: "/tmp/project/src/main.ts",
relativePath: "project/src/main.ts",
type: "file",
size: 12,
lastModified: 2,
},
];
const entries = await materializeDropEntries(
{
roots: [{
name: "project",
isDirectory: true,
localPath: "/tmp/project",
}],
filesFallback: [],
},
{
listLocalTree: async (path, options) => {
assert.equal(path, "/tmp/project");
options?.onProgress?.({ fileCount: 1, directoryCount: 2, entryCount: 3 });
return tree;
},
onProgress: (p) => progress.push({ fileCount: p.fileCount, directoryCount: p.directoryCount }),
},
);
assert.deepEqual(
entries.map((entry) => ({
relativePath: entry.relativePath,
isDirectory: entry.isDirectory,
localPath: entry.localPath,
size: entry.size,
})),
[
{ relativePath: "project", isDirectory: true, localPath: "/tmp/project", size: undefined },
{ relativePath: "project/src", isDirectory: true, localPath: "/tmp/project/src", size: undefined },
{
relativePath: "project/src/main.ts",
isDirectory: false,
localPath: "/tmp/project/src/main.ts",
size: 12,
},
],
);
assert.ok(progress.some((p) => p.fileCount === 1 && p.directoryCount === 2));
});
test("localTreeToDropEntries preserves file sizes without File handles", () => {
const entries = localTreeToDropEntries([{
localPath: "/tmp/a.txt",
relativePath: "a.txt",
type: "file",
size: 42,
lastModified: 9,
}]);
assert.equal(entries[0].file, null);
assert.equal(entries[0].localPath, "/tmp/a.txt");
assert.equal(entries[0].size, 42);
});