Files
NetMesh/lib/progressiveFolderUpload.test.ts
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

816 lines
24 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import { uploadLocalFoldersProgressively } from "./progressiveFolderUpload.ts";
import { UploadController } from "./uploadController.ts";
import type { LocalTreeListEntry } from "./sftpFileUtils.ts";
test("progressive folder upload starts file transfers before the tree walk finishes", async () => {
const events: string[] = [];
let releaseSecondBatch: (() => void) | null = null;
const secondBatchGate = new Promise<void>((resolve) => {
releaseSecondBatch = resolve;
});
const listLocalTree = async (
_path: string,
options: {
onEntries?: (entries: LocalTreeListEntry[]) => void;
},
) => {
options.onEntries?.([
{
localPath: "/tmp/docs",
relativePath: "docs",
type: "directory",
size: 0,
lastModified: 1,
},
{
localPath: "/tmp/docs/a.txt",
relativePath: "docs/a.txt",
type: "file",
size: 3,
lastModified: 1,
},
]);
events.push("batch1");
// First file should be uploadable while we still "discover" more.
await secondBatchGate;
options.onEntries?.([
{
localPath: "/tmp/docs/b.txt",
relativePath: "docs/b.txt",
type: "file",
size: 4,
lastModified: 2,
},
]);
events.push("batch2");
return [];
};
const transferred: string[] = [];
const controller = new UploadController();
const uploadPromise = uploadLocalFoldersProgressively(
[{ name: "docs", localPath: "/tmp/docs" }],
{
targetPath: "/remote",
sftpId: "sftp-1",
isLocal: false,
joinPath: (base, name) => `${base}/${name}`,
bridge: {
mkdirSftp: async () => {},
startStreamTransfer: async (payload) => {
transferred.push(payload.sourcePath);
events.push(`upload:${payload.sourcePath}`);
if (payload.sourcePath.endsWith("a.txt")) {
// Let the second discovery batch proceed only after the first upload started.
releaseSecondBatch?.();
}
return { transferId: payload.transferId };
},
},
listLocalTree,
callbacks: {
onTaskCreated: (task) => events.push(`created:${task.fileName}`),
onTaskCompleted: (taskId) => events.push(`completed:${taskId.slice(0, 8)}`),
onTaskProgress: (taskId, progress) => {
events.push(`progress:${progress.transferred}/${progress.total}:${progress.phase ?? ""}`);
},
},
},
controller,
);
const results = await uploadPromise;
assert.equal(results.filter((row) => row.success).length, 2);
assert.deepEqual(transferred, ["/tmp/docs/a.txt", "/tmp/docs/b.txt"]);
// First upload must happen before the second discovery batch finishes.
const uploadA = events.indexOf("upload:/tmp/docs/a.txt");
const batch2 = events.indexOf("batch2");
assert.ok(uploadA >= 0 && batch2 >= 0, "expected both upload and batch events");
assert.ok(uploadA < batch2, "first file must upload while scan still running");
});
test("progressive folder upload honors an explicit transfer concurrency above the default", async () => {
const requestedConcurrency = 16;
const entries: LocalTreeListEntry[] = Array.from(
{ length: requestedConcurrency + 2 },
(_, index) => ({
localPath: `/tmp/docs/file-${index}.txt`,
relativePath: `docs/file-${index}.txt`,
type: "file",
size: 1,
lastModified: index,
}),
);
const started: string[] = [];
let releaseAll!: () => void;
const gate = new Promise<void>((resolve) => {
releaseAll = resolve;
});
const config = {
targetPath: "/remote",
sftpId: "sftp-1",
fileTransferConcurrency: requestedConcurrency,
isLocal: false,
joinPath: (base: string, name: string) => `${base}/${name}`,
bridge: {
mkdirSftp: async () => {},
startStreamTransfer: async (payload: { sourcePath: string; transferId: string }) => {
started.push(payload.sourcePath);
await gate;
return { transferId: payload.transferId };
},
},
listLocalTree: async (
_path: string,
options: { onEntries?: (batch: LocalTreeListEntry[]) => void },
) => {
options.onEntries?.(entries);
return [];
},
};
const uploading = uploadLocalFoldersProgressively(
[{ name: "docs", localPath: "/tmp/docs" }],
config,
new UploadController(),
);
await new Promise((resolve) => setImmediate(resolve));
const initiallyStarted = started.length;
releaseAll();
await uploading;
assert.equal(initiallyStarted, requestedConcurrency);
});
test("progressive folder upload enqueues nested subdirectory files", async () => {
const transferred: string[] = [];
const mkdirs: string[] = [];
const listLocalTree = async (
_path: string,
options: {
onEntries?: (entries: LocalTreeListEntry[]) => void;
},
) => {
options.onEntries?.([
{
localPath: "/tmp/docs",
relativePath: "docs",
type: "directory",
size: 0,
lastModified: 1,
},
{
localPath: "/tmp/docs/a.txt",
relativePath: "docs/a.txt",
type: "file",
size: 1,
lastModified: 1,
},
]);
options.onEntries?.([
{
localPath: "/tmp/docs/nested",
relativePath: "docs/nested",
type: "directory",
size: 0,
lastModified: 1,
},
{
localPath: "/tmp/docs/nested/deep",
relativePath: "docs/nested/deep",
type: "directory",
size: 0,
lastModified: 1,
},
{
localPath: "/tmp/docs/nested/deep/b.txt",
relativePath: "docs/nested/deep/b.txt",
type: "file",
size: 2,
lastModified: 2,
},
]);
return [];
};
const results = await uploadLocalFoldersProgressively(
[{ name: "docs", localPath: "/tmp/docs" }],
{
targetPath: "/remote",
sftpId: "sftp-1",
isLocal: false,
joinPath: (base, name) => `${base}/${name}`,
bridge: {
mkdirSftp: async (_id, dirPath) => {
mkdirs.push(dirPath);
},
startStreamTransfer: async (payload) => {
transferred.push(payload.targetPath);
return { transferId: payload.transferId };
},
},
listLocalTree,
},
);
assert.equal(results.filter((row) => row.success).length, 2);
assert.deepEqual(transferred.sort(), [
"/remote/docs/a.txt",
"/remote/docs/nested/deep/b.txt",
].sort());
assert.ok(mkdirs.includes("/remote/docs/nested"));
assert.ok(mkdirs.includes("/remote/docs/nested/deep"));
});
test("progressive folder upload stops enqueueing children after cancel", async () => {
const controller = new UploadController();
const createdChildren: string[] = [];
let entriesCb: ((entries: LocalTreeListEntry[]) => void) | null = null;
const listLocalTree = async (
_path: string,
options: {
onEntries?: (entries: LocalTreeListEntry[]) => void;
abortSignal?: AbortSignal;
},
) => {
entriesCb = options.onEntries ?? null;
options.onEntries?.([
{
localPath: "/tmp/big/a.txt",
relativePath: "big/a.txt",
type: "file",
size: 1,
lastModified: 1,
},
]);
// Wait until cancelled.
await new Promise<void>((resolve) => {
if (options.abortSignal?.aborted) {
resolve();
return;
}
options.abortSignal?.addEventListener("abort", () => resolve(), { once: true });
});
return [];
};
const abort = new AbortController();
const uploadPromise = uploadLocalFoldersProgressively(
[{ name: "big", localPath: "/tmp/big" }],
{
targetPath: "/remote",
sftpId: "sftp-1",
isLocal: false,
joinPath: (base, name) => `${base}/${name}`,
abortSignal: abort.signal,
bridge: {
mkdirSftp: async () => {},
startStreamTransfer: async (payload) => {
// Cancel as soon as the first child begins.
await controller.cancel();
abort.abort();
// Push more discovered files after cancel — workers must not create more children.
entriesCb?.([
{
localPath: "/tmp/big/b.txt",
relativePath: "big/b.txt",
type: "file",
size: 1,
lastModified: 1,
},
{
localPath: "/tmp/big/c.txt",
relativePath: "big/c.txt",
type: "file",
size: 1,
lastModified: 1,
},
]);
return { transferId: payload.transferId, cancelled: true };
},
},
listLocalTree,
callbacks: {
onTaskCreated: (task) => {
if (!task.isDirectory) createdChildren.push(task.fileName);
},
},
},
controller,
);
await uploadPromise;
// At most the in-flight child should have been created.
assert.ok(createdChildren.length <= 1, `expected no post-cancel flood, got ${createdChildren.join(",")}`);
});
test("progressive folder upload stops enqueueing children while soft-paused", async () => {
const createdChildren: string[] = [];
const transferred: string[] = [];
let paused = true;
let releasePause!: () => void;
const pauseGate = new Promise<void>((resolve) => {
releasePause = resolve;
});
let releaseMoreFiles!: () => void;
const moreFilesGate = new Promise<void>((resolve) => {
releaseMoreFiles = resolve;
});
const waitWhilePaused = async () => {
while (paused) {
await pauseGate;
}
};
const listLocalTree = async (
_path: string,
options: {
onEntries?: (entries: LocalTreeListEntry[]) => void;
},
) => {
options.onEntries?.([
{
localPath: "/tmp/docs/a.txt",
relativePath: "docs/a.txt",
type: "file",
size: 1,
lastModified: 1,
},
]);
// Stay in the walk until the test unblocks the second batch (after pause assert).
await moreFilesGate;
options.onEntries?.([
{
localPath: "/tmp/docs/b.txt",
relativePath: "docs/b.txt",
type: "file",
size: 1,
lastModified: 2,
},
{
localPath: "/tmp/docs/c.txt",
relativePath: "docs/c.txt",
type: "file",
size: 1,
lastModified: 3,
},
]);
return [];
};
const uploadPromise = uploadLocalFoldersProgressively(
[{ name: "docs", localPath: "/tmp/docs" }],
{
targetPath: "/remote",
sftpId: "sftp-1",
isLocal: false,
joinPath: (base, name) => `${base}/${name}`,
waitWhilePaused,
bridge: {
mkdirSftp: async () => {},
startStreamTransfer: async (payload) => {
transferred.push(payload.sourcePath);
return { transferId: payload.transferId };
},
},
listLocalTree,
callbacks: {
onTaskCreated: (task) => {
if (!task.isDirectory) createdChildren.push(task.fileName);
},
},
},
);
// Give workers a turn; soft-pause must block child creation entirely.
await new Promise((resolve) => setTimeout(resolve, 40));
assert.deepEqual(createdChildren, [], "no children while paused");
assert.deepEqual(transferred, [], "no transfers while paused");
paused = false;
releasePause();
// Let first file create/upload, then release the rest of the tree.
await new Promise((resolve) => setTimeout(resolve, 40));
releaseMoreFiles();
const results = await uploadPromise;
assert.equal(results.filter((row) => row.success).length, 3);
assert.deepEqual(
createdChildren.sort(),
["docs/a.txt", "docs/b.txt", "docs/c.txt"].sort(),
);
assert.equal(transferred.length, 3);
});
test("progressive multi-root pause does not HOL-block an unpaused sibling root", async () => {
const transferred: string[] = [];
const pausedParents = new Set<string>(["parent-a"]);
const parentIds = new Map([
["folderA", "parent-a"],
["folderB", "parent-b"],
]);
const waitWhilePaused = async (parentId: string) => {
while (pausedParents.has(parentId)) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
};
const listLocalTree = async (
localPath: string,
options: {
onEntries?: (entries: LocalTreeListEntry[]) => void;
},
) => {
if (localPath.endsWith("folderA")) {
options.onEntries?.([
{
localPath: "/tmp/folderA/a.txt",
relativePath: "folderA/a.txt",
type: "file",
size: 1,
lastModified: 1,
},
]);
} else {
options.onEntries?.([
{
localPath: "/tmp/folderB/b.txt",
relativePath: "folderB/b.txt",
type: "file",
size: 1,
lastModified: 1,
},
]);
}
return [];
};
const uploadPromise = uploadLocalFoldersProgressively(
[
{ name: "folderA", localPath: "/tmp/folderA" },
{ name: "folderB", localPath: "/tmp/folderB" },
],
{
targetPath: "/remote",
sftpId: "sftp-1",
isLocal: false,
joinPath: (base, name) => `${base}/${name}`,
parentTaskIds: parentIds,
waitWhilePaused,
isPaused: (parentId) => pausedParents.has(parentId),
bridge: {
mkdirSftp: async () => {},
startStreamTransfer: async (payload) => {
transferred.push(payload.sourcePath);
// Unpause A only after B has uploaded — proves HOL skip works.
if (payload.sourcePath.includes("folderB")) {
pausedParents.delete("parent-a");
}
return { transferId: payload.transferId };
},
},
listLocalTree,
},
);
const results = await uploadPromise;
assert.equal(results.filter((row) => row.success).length, 2);
assert.ok(transferred.some((p) => p.includes("folderB")), "unpaused root must transfer");
assert.ok(transferred.some((p) => p.includes("folderA")), "paused root resumes after unlatch");
// folderB must not wait behind folderA indefinitely — B completes first.
assert.ok(
transferred.indexOf(transferred.find((p) => p.includes("folderB"))!)
< transferred.indexOf(transferred.find((p) => p.includes("folderA"))!),
`expected B before A, got ${transferred.join(",")}`,
);
});
test("progressive backpressure wakes every parked enqueue waiter", async () => {
// Discovery floods the queue while workers hold transfers open so the queue
// stays above the high-water mark. Multiple enqueueBatch waiters must all
// be released when workers drain past the low-water mark.
const transferred: string[] = [];
let releaseTransfers!: () => void;
const transferGate = new Promise<void>((resolve) => {
releaseTransfers = resolve;
});
let started = 0;
const listLocalTree = async (
_path: string,
options: {
onEntries?: (entries: LocalTreeListEntry[]) => void;
},
) => {
const batches = Array.from({ length: 8 }, (_, batch) => (
Array.from({ length: 300 }, (__, i) => ({
localPath: `/tmp/docs/f-${batch}-${i}.txt`,
relativePath: `docs/f-${batch}-${i}.txt`,
type: "file" as const,
size: 1,
lastModified: 1,
}))
));
// Overlapping handlers so several park on waitIfQueueHigh together.
await Promise.all(batches.map((batch) => Promise.resolve().then(() => options.onEntries?.(batch))));
return [];
};
const uploadPromise = uploadLocalFoldersProgressively(
[{ name: "docs", localPath: "/tmp/docs" }],
{
targetPath: "/remote",
sftpId: "sftp-1",
isLocal: false,
joinPath: (base, name) => `${base}/${name}`,
bridge: {
mkdirSftp: async () => {},
startStreamTransfer: async (payload) => {
started += 1;
transferred.push(payload.sourcePath);
await transferGate;
return { transferId: payload.transferId };
},
},
listLocalTree,
},
);
// Let discovery fill past high-water (2000) while 2 workers hold the gate.
await new Promise((resolve) => setTimeout(resolve, 80));
assert.ok(started >= 1, "workers should have started");
releaseTransfers();
const results = await uploadPromise;
assert.equal(results.filter((row) => row.success).length, 8 * 300);
assert.equal(transferred.length, 8 * 300);
});
test("progressive root conflict skip does not overwrite an existing remote folder", async () => {
const transferred: string[] = [];
const result = await uploadLocalFoldersProgressively(
[{ name: "docs", localPath: "/tmp/docs" }],
{
targetPath: "/remote",
sftpId: "sftp-1",
isLocal: false,
joinPath: (base, name) => `${base}/${name}`,
resolveConflict: async () => "skip",
bridge: {
mkdirSftp: async () => {},
statSftp: async () => ({ type: "directory", size: 0, lastModified: 1 }),
startStreamTransfer: async (payload) => {
transferred.push(payload.sourcePath);
return { transferId: payload.transferId };
},
},
listLocalTree: async () => {
throw new Error("scan must not run after skip");
},
},
);
assert.equal(transferred.length, 0);
assert.equal(result[0]?.cancelled, true);
});
test("progressive root conflict check fails closed when destination access is denied", async () => {
let conflictPrompts = 0;
let scans = 0;
let transfers = 0;
await assert.rejects(
() => uploadLocalFoldersProgressively(
[{ name: "docs", localPath: "/tmp/docs" }],
{
targetPath: "/remote",
sftpId: "sftp-1",
isLocal: false,
joinPath: (base, name) => `${base}/${name}`,
resolveConflict: async () => {
conflictPrompts += 1;
return "skip";
},
bridge: {
mkdirSftp: async () => {},
lstatSftp: async () => {
const error = new Error("Permission denied") as Error & { code: string };
error.code = "EACCES";
throw error;
},
startStreamTransfer: async (payload) => {
transfers += 1;
return { transferId: payload.transferId };
},
},
listLocalTree: async () => {
scans += 1;
return [];
},
},
),
/Permission denied/,
);
assert.equal(conflictPrompts, 0, "unknown destination state must not be treated as a conflict");
assert.equal(scans, 0, "folder scanning must not start after a failed destination check");
assert.equal(transfers, 0, "upload must not start when destination safety is unknown");
});
test("progressive root conflict check treats explicit ENOENT as an absent destination", async () => {
const transferred: string[] = [];
let followedStats = 0;
const results = await uploadLocalFoldersProgressively(
[{ name: "docs", localPath: "/tmp/docs" }],
{
targetPath: "/remote",
sftpId: "sftp-1",
isLocal: false,
joinPath: (base, name) => `${base}/${name}`,
resolveConflict: async () => {
throw new Error("absent destination must not prompt for conflict");
},
bridge: {
mkdirSftp: async () => {},
statSftp: async () => {
followedStats += 1;
return { type: "directory", size: 0, lastModified: 1 };
},
lstatSftp: async () => {
const error = new Error("No such file") as Error & { code: string };
error.code = "ENOENT";
throw error;
},
startStreamTransfer: async (payload) => {
transferred.push(payload.targetPath);
return { transferId: payload.transferId };
},
},
listLocalTree: async (_path, options) => {
options.onEntries?.([{
localPath: "/tmp/docs/a.txt",
relativePath: "docs/a.txt",
type: "file",
size: 1,
lastModified: 1,
}]);
return [];
},
},
);
assert.equal(followedStats, 0, "destination checks should prefer no-follow lstat");
assert.deepEqual(transferred, ["/remote/docs/a.txt"]);
assert.equal(results[0]?.success, true);
});
test("progressive root replace carries the inspected type into the guarded delete", async () => {
let currentType: "file" | "directory" = "directory";
let scans = 0;
let transfers = 0;
await assert.rejects(
() => uploadLocalFoldersProgressively(
[{ name: "docs", localPath: "/tmp/docs" }],
{
targetPath: "/remote",
sftpId: "sftp-1",
isLocal: false,
joinPath: (base, name) => `${base}/${name}`,
resolveConflict: async () => {
currentType = "file";
return "replace";
},
bridge: {
mkdirSftp: async () => {},
lstatSftp: async () => ({ type: "directory", size: 0, lastModified: 1 }),
deleteSftp: async (_sftpId, _path, expectedType) => {
assert.equal(expectedType, "directory");
if (currentType !== expectedType) {
throw new Error(`Expected ${expectedType}, found ${currentType}`);
}
},
startStreamTransfer: async (payload) => {
transfers += 1;
return { transferId: payload.transferId };
},
},
listLocalTree: async () => {
scans += 1;
return [];
},
},
),
/Expected directory, found file/,
);
assert.equal(scans, 0, "changed targets must abort before scanning starts");
assert.equal(transfers, 0, "changed targets must not be overwritten");
});
test("progressive multi-root resume of non-head parent unblocks while head stays paused", async () => {
const transferred: string[] = [];
// Both latched initially; head is always parent-a in FIFO order.
const pausedParents = new Set<string>(["parent-a", "parent-b"]);
const parentIds = new Map([
["folderA", "parent-a"],
["folderB", "parent-b"],
]);
const waiters = new Map<string, Array<() => void>>();
const waitWhilePaused = async (parentId: string) => {
while (pausedParents.has(parentId)) {
await new Promise<void>((resolve) => {
const list = waiters.get(parentId) ?? [];
list.push(resolve);
waiters.set(parentId, list);
});
}
};
const release = (parentId: string) => {
pausedParents.delete(parentId);
for (const resolve of waiters.get(parentId) ?? []) resolve();
waiters.delete(parentId);
};
const listLocalTree = async (
localPath: string,
options: {
onEntries?: (entries: LocalTreeListEntry[]) => void;
},
) => {
if (localPath.endsWith("folderA")) {
options.onEntries?.([
{
localPath: "/tmp/folderA/a.txt",
relativePath: "folderA/a.txt",
type: "file",
size: 1,
lastModified: 1,
},
]);
} else {
options.onEntries?.([
{
localPath: "/tmp/folderB/b.txt",
relativePath: "folderB/b.txt",
type: "file",
size: 1,
lastModified: 1,
},
]);
}
return [];
};
const uploadPromise = uploadLocalFoldersProgressively(
[
{ name: "folderA", localPath: "/tmp/folderA" },
{ name: "folderB", localPath: "/tmp/folderB" },
],
{
targetPath: "/remote",
sftpId: "sftp-1",
isLocal: false,
joinPath: (base, name) => `${base}/${name}`,
parentTaskIds: parentIds,
waitWhilePaused,
isPaused: (parentId) => pausedParents.has(parentId),
bridge: {
mkdirSftp: async () => {},
startStreamTransfer: async (payload) => {
transferred.push(payload.sourcePath);
return { transferId: payload.transferId };
},
},
listLocalTree,
},
);
// Let both jobs queue and workers park on all-paused race.
await new Promise((resolve) => setTimeout(resolve, 40));
assert.deepEqual(transferred, []);
// Resume only non-head parent B while A stays paused.
release("parent-b");
await new Promise((resolve) => setTimeout(resolve, 40));
assert.ok(
transferred.some((p) => p.includes("folderB")),
`B must transfer while A paused, got ${transferred.join(",")}`,
);
assert.equal(
transferred.some((p) => p.includes("folderA")),
false,
"A must stay blocked",
);
release("parent-a");
const results = await uploadPromise;
assert.equal(results.filter((row) => row.success).length, 2);
});