Files
NetMesh/application/state/sftpTransferCenterStore.test.ts

3870 lines
131 KiB
TypeScript
Raw Permalink Normal View History

import assert from "node:assert/strict";
import test from "node:test";
import type { TransferTask } from "../../domain/models";
import { createSftpTransferCenterStore } from "./sftpTransferCenterStore";
const makeTask = (id: string, status: TransferTask["status"] = "transferring"): TransferTask => ({
id,
fileName: `${id}.txt`,
sourcePath: `/source/${id}.txt`,
targetPath: `/target/${id}.txt`,
sourceConnectionId: "local",
targetConnectionId: `remote-${id}`,
direction: "upload",
status,
totalBytes: 10,
transferredBytes: 2,
speed: 1,
startTime: 1,
isDirectory: false,
resumable: true,
});
test("store aggregates owner snapshots without duplicating tasks", () => {
const store = createSftpTransferCenterStore();
store.publishOwner("panel-a", [makeTask("a")]);
store.publishOwner("panel-b", [makeTask("b")]);
store.publishOwner("panel-a", [{ ...makeTask("a"), transferredBytes: 5 }]);
assert.deepEqual(store.getSnapshot().tasks.map((task) => [task.id, task.transferredBytes]), [
["a", 5],
["b", 2],
]);
});
test("store routes cancel/retry/prioritize/dismiss to the task owner; pause/resume are process-global", async () => {
const calls: string[] = [];
const store = createSftpTransferCenterStore();
store.registerOwner("panel-a", {
pause: async (id) => { calls.push(`pause:${id}`); },
resume: async (id) => { calls.push(`resume:${id}`); },
cancel: async (id) => { calls.push(`cancel:${id}`); },
retry: async (id) => { calls.push(`retry:${id}`); },
prioritize: async (id) => { calls.push(`prioritize:${id}`); },
dismiss: (id) => calls.push(`dismiss:${id}`),
});
store.publishOwner("panel-a", [makeTask("a")]);
await store.cancel("a");
await store.retry("a");
await store.prioritize("a");
store.dismiss("a");
// Pause/resume are process-global (not owner controllers).
assert.deepEqual(calls, [
"cancel:a",
"retry:a",
"prioritize:a",
"dismiss:a",
]);
assert.ok(!calls.some((c) => c.startsWith("pause:") || c.startsWith("resume:")));
});
test("background-agent retry after restart uses fresh dedicated recovery instead of the dead session controller", async () => {
let controllerRetryCalls = 0;
const dedicatedTasks: TransferTask[] = [];
const store = createSftpTransferCenterStore();
store.registerOwner("background-agent", {
pause: async () => {},
resume: async () => {},
cancel: async () => {},
retry: async () => { controllerRetryCalls += 1; },
prioritize: async () => {},
dismiss: () => {},
});
store.setDedicatedResumeHandler(async (task) => {
dedicatedTasks.push(task);
return { success: true };
});
store.publishOwner("background-agent", [{
...makeTask("background-retry", "failed"),
sourceHostId: "host-a",
targetConnectionId: "dead-terminal-session",
checkpointBytes: 7,
transferredBytes: 7,
error: "connection closed",
endTime: Date.now(),
}]);
await store.retry("background-retry");
assert.equal(controllerRetryCalls, 0);
assert.equal(dedicatedTasks.length, 1);
assert.equal(dedicatedTasks[0]?.checkpointBytes, 0);
assert.equal(dedicatedTasks[0]?.transferredBytes, 0);
assert.equal(dedicatedTasks[0]?.reconnectRequired, true);
assert.equal(store.getTask("background-retry")?.status, "completed");
});
test("resume without an owner uses a live backend transfer session when available", async (t) => {
const resumeCalls: string[] = [];
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
resumeTransfer: async (id: string) => {
resumeCalls.push(id);
return { success: true };
},
},
},
});
const store = createSftpTransferCenterStore();
store.publishOwner("closed-panel", [{
...makeTask("orphaned-paused", "paused"),
direction: "download",
sourceHostId: "host-a",
sourceConnectionId: "remote-conn",
targetConnectionId: "local",
}]);
await store.resume("orphaned-paused");
assert.deepEqual(resumeCalls, ["orphaned-paused"]);
assert.equal(store.getSnapshot().tasks[0]?.status, "transferring");
assert.equal(store.getSnapshot().tasks[0]?.error, undefined);
});
test("resume soft-controls without panel when canAdopt is false", async (t) => {
// Downloads often have only the remote pane open. Soft-resume is process-global
// (bridge + latch) and must not require canAdopt / both panes.
const resumeCalls: string[] = [];
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
resumeTransfer: async (id: string) => {
resumeCalls.push(id);
return { success: true };
},
},
},
});
const store = createSftpTransferCenterStore();
store.registerOwner("panel-a", {
pause: async () => {},
resume: async () => {},
cancel: async () => {},
retry: async () => {},
prioritize: async () => {},
dismiss: () => {},
canAdopt: () => false,
canPrepareAdoption: true,
});
store.publishOwner("panel-a", [{
...makeTask("download-paused", "paused"),
direction: "download",
sourceHostId: "host-a",
sourceConnectionId: "remote-conn",
targetConnectionId: "local",
}]);
await store.resume("download-paused");
assert.deepEqual(resumeCalls, ["download-paused"]);
assert.equal(store.getSnapshot().tasks[0]?.status, "transferring");
assert.equal(store.getSnapshot().tasks[0]?.error, undefined);
});
test("persisted unfinished tasks restore as interrupted without controllers", () => {
let persisted = "";
const first = createSftpTransferCenterStore({
read: () => null,
write: (value) => { persisted = value; },
});
first.publishOwner("panel-a", [makeTask("a")]);
const restored = createSftpTransferCenterStore({
read: () => persisted,
write: () => {},
});
assert.equal(restored.getSnapshot().tasks[0]?.status, "interrupted");
assert.equal(restored.getSnapshot().tasks[0]?.ownerId, "panel-a");
assert.equal(restored.canControl("a"), true);
});
test("paused source fingerprint patches are persisted for restart", () => {
let persisted = "";
const first = createSftpTransferCenterStore({
read: () => null,
write: (value) => { persisted = value; },
});
first.publishOwner("panel-a", [makeTask("paused-fingerprint", "paused")]);
first.patchTask("paused-fingerprint", { sourceFingerprint: "sha256:durable" });
const restored = createSftpTransferCenterStore({
read: () => persisted,
write: () => {},
});
assert.equal(restored.getSnapshot().tasks[0]?.status, "interrupted");
assert.equal(restored.getSnapshot().tasks[0]?.sourceFingerprint, "sha256:durable");
});
test("background source fingerprint progress is persisted immediately for restart", () => {
let persisted = "";
const first = createSftpTransferCenterStore({
read: () => null,
write: (value) => { persisted = value; },
});
first.publishOwner("panel-a", [makeTask("background-fingerprint", "paused")]);
first.ingestBackgroundEvent({
type: "progress",
transferId: "background-fingerprint",
transferred: 2,
totalBytes: 10,
speed: 0,
checkpointBytes: 2,
sourceFingerprint: "sha256:background-durable",
lifecycleState: "paused",
});
const restored = createSftpTransferCenterStore({
read: () => persisted,
write: () => {},
});
assert.equal(restored.getSnapshot().tasks[0]?.sourceFingerprint, "sha256:background-durable");
});
test("background terminal lifecycle releases parent and child cancel and control state", async (t) => {
const {
isTransferCancelledFlag,
markTransferCancelledTree,
resetTransferCancelLatchesForTests,
} = await import("./sftp/transferCancelLatch");
const {
bumpTransferControlEpoch,
isTransferControlEpochCurrent,
resetTransferControlEpochsForTests,
} = await import("./sftp/transferControlEpoch");
resetTransferCancelLatchesForTests();
resetTransferControlEpochsForTests();
t.after(() => {
resetTransferCancelLatchesForTests();
resetTransferControlEpochsForTests();
});
const store = createSftpTransferCenterStore();
store.publishOwner("background-agent", [
{ ...makeTask("background-root"), isDirectory: true, progressMode: "files" },
{ ...makeTask("background-child"), parentTaskId: "background-root" },
]);
markTransferCancelledTree("background-root", ["background-child"]);
const rootEpoch = bumpTransferControlEpoch("background-root");
const childEpoch = bumpTransferControlEpoch("background-child");
store.ingestBackgroundEvent({
type: "progress",
transferId: "background-root",
transferred: 5,
totalBytes: 10,
speed: 1,
});
assert.equal(isTransferCancelledFlag("background-root"), true);
assert.equal(isTransferControlEpochCurrent("background-root", rootEpoch), true);
store.ingestBackgroundEvent({ type: "completed", transferId: "background-root" });
assert.equal(isTransferCancelledFlag("background-root"), false);
assert.equal(isTransferCancelledFlag("background-child"), false);
assert.equal(isTransferControlEpochCurrent("background-root", rootEpoch), false);
assert.equal(isTransferControlEpochCurrent("background-child", childEpoch), false);
});
test("registered background owner still releases a finished parent and child control tree", async (t) => {
const {
isTransferCancelledFlag,
markTransferCancelledTree,
resetTransferCancelLatchesForTests,
} = await import("./sftp/transferCancelLatch");
const {
bumpTransferControlEpoch,
isTransferControlEpochCurrent,
resetTransferControlEpochsForTests,
} = await import("./sftp/transferControlEpoch");
resetTransferCancelLatchesForTests();
resetTransferControlEpochsForTests();
t.after(() => {
resetTransferCancelLatchesForTests();
resetTransferControlEpochsForTests();
});
const store = createSftpTransferCenterStore();
const unregisterOwner = store.registerOwner("background-agent", {
pause: async () => {},
resume: async () => {},
cancel: async () => {},
retry: async () => {},
prioritize: async () => {},
dismiss: () => {},
});
t.after(unregisterOwner);
store.publishOwner("background-agent", [
{ ...makeTask("registered-root"), isDirectory: true, progressMode: "files" },
{ ...makeTask("registered-child"), parentTaskId: "registered-root" },
]);
markTransferCancelledTree("registered-root", ["registered-child"]);
const rootEpoch = bumpTransferControlEpoch("registered-root");
const childEpoch = bumpTransferControlEpoch("registered-child");
store.ingestBackgroundEvent({ type: "completed", transferId: "registered-root" });
assert.equal(isTransferCancelledFlag("registered-root"), false);
assert.equal(isTransferCancelledFlag("registered-child"), false);
assert.equal(isTransferControlEpochCurrent("registered-root", rootEpoch), false);
assert.equal(isTransferControlEpochCurrent("registered-child", childEpoch), false);
});
test("registered owner completion waits for its active directory walk before releasing controls", async (t) => {
const {
isTransferCancelledFlag,
markTransferCancelledTree,
resetTransferCancelLatchesForTests,
} = await import("./sftp/transferCancelLatch");
const {
bumpTransferControlEpoch,
isTransferControlEpochCurrent,
resetTransferControlEpochsForTests,
} = await import("./sftp/transferControlEpoch");
const {
registerTransferWalk,
resetTransferWalkRegistryForTests,
unregisterTransferWalk,
} = await import("./sftp/transferWalkRegistry");
resetTransferCancelLatchesForTests();
resetTransferControlEpochsForTests();
resetTransferWalkRegistryForTests();
t.after(() => {
resetTransferCancelLatchesForTests();
resetTransferControlEpochsForTests();
resetTransferWalkRegistryForTests();
});
const store = createSftpTransferCenterStore();
const unregisterOwner = store.registerOwner("active-background-agent", {
pause: async () => {},
resume: async () => {},
cancel: async () => {},
retry: async () => {},
prioritize: async () => {},
dismiss: () => {},
});
store.publishOwner("active-background-agent", [
{ ...makeTask("active-root"), isDirectory: true, progressMode: "files" },
{ ...makeTask("active-child"), parentTaskId: "active-root" },
]);
markTransferCancelledTree("active-root", ["active-child"]);
const rootEpoch = bumpTransferControlEpoch("active-root");
const childEpoch = bumpTransferControlEpoch("active-child");
registerTransferWalk("active-root");
store.ingestBackgroundEvent({ type: "completed", transferId: "active-root" });
unregisterOwner();
assert.equal(isTransferCancelledFlag("active-root"), true);
assert.equal(isTransferCancelledFlag("active-child"), true);
assert.equal(isTransferControlEpochCurrent("active-root", rootEpoch), true);
assert.equal(isTransferControlEpochCurrent("active-child", childEpoch), true);
unregisterTransferWalk("active-root");
store.upsertTasks(store.getSnapshot().tasks);
assert.equal(isTransferCancelledFlag("active-root"), false);
assert.equal(isTransferCancelledFlag("active-child"), false);
assert.equal(isTransferControlEpochCurrent("active-root", rootEpoch), false);
assert.equal(isTransferControlEpochCurrent("active-child", childEpoch), false);
});
test("owner source fingerprint publish is persisted immediately for restart", () => {
let persisted = "";
const first = createSftpTransferCenterStore({
read: () => null,
write: (value) => { persisted = value; },
});
const paused = makeTask("owner-fingerprint", "paused");
first.publishOwner("panel-a", [paused]);
first.publishOwner("panel-a", [{
...paused,
sourceFingerprint: "sha256:owner-durable",
}]);
const restored = createSftpTransferCenterStore({
read: () => persisted,
write: () => {},
});
assert.equal(restored.getSnapshot().tasks[0]?.sourceFingerprint, "sha256:owner-durable");
});
test("top-level completion is persisted immediately while child completions stay coalesced", () => {
let writes = 0;
let persisted = "";
const first = createSftpTransferCenterStore({
read: () => null,
write: (value) => {
writes += 1;
persisted = value;
},
});
const parent = {
...makeTask("completed-parent"),
isDirectory: true,
progressMode: "files" as const,
};
const child = { ...makeTask("completed-child"), parentTaskId: parent.id };
first.publishOwner("panel-a", [parent, child]);
first.publishOwner("panel-a", [parent, { ...child, status: "completed", endTime: Date.now() }]);
assert.equal(writes, 1, "child completion should remain coalesced");
first.publishOwner("panel-a", [
{ ...parent, status: "completed", endTime: Date.now() },
{ ...child, status: "completed", endTime: Date.now() },
]);
assert.equal(writes, 2, "parent completion should flush immediately");
const restored = createSftpTransferCenterStore({
read: () => persisted,
write: () => {},
});
assert.equal(restored.getSnapshot().tasks.find((task) => task.id === parent.id)?.status, "completed");
});
test("explicit history deletions are persisted before returning", () => {
const cases = [
{
name: "dismiss",
task: makeTask("dismissed-history", "failed"),
remove: (store: ReturnType<typeof createSftpTransferCenterStore>) => store.dismiss("dismissed-history"),
},
{
name: "clear terminal",
task: makeTask("cleared-history", "completed"),
remove: (store: ReturnType<typeof createSftpTransferCenterStore>) => store.clearTerminal("completed"),
},
{
name: "owner publish",
task: makeTask("owner-removed-history", "failed"),
remove: (store: ReturnType<typeof createSftpTransferCenterStore>) => store.publishOwner("panel-a", []),
},
];
for (const scenario of cases) {
let writes = 0;
let persisted = "";
const store = createSftpTransferCenterStore({
read: () => null,
write: (value) => {
writes += 1;
persisted = value;
},
});
store.publishOwner("panel-a", [scenario.task]);
assert.equal(writes, 1, `${scenario.name}: setup should write once`);
scenario.remove(store);
assert.equal(writes, 2, `${scenario.name}: deletion should flush immediately`);
const restored = createSftpTransferCenterStore({
read: () => persisted,
write: () => {},
});
assert.equal(restored.getSnapshot().tasks.length, 0, `${scenario.name}: deleted history must not return`);
}
});
test("dismissing a finished directory releases retained parent and child controls", async (t) => {
const {
isTransferCancelledFlag,
markTransferCancelledTree,
resetTransferCancelLatchesForTests,
} = await import("./sftp/transferCancelLatch");
const {
bumpTransferControlEpoch,
isTransferControlEpochCurrent,
resetTransferControlEpochsForTests,
} = await import("./sftp/transferControlEpoch");
resetTransferCancelLatchesForTests();
resetTransferControlEpochsForTests();
t.after(() => {
resetTransferCancelLatchesForTests();
resetTransferControlEpochsForTests();
});
const store = createSftpTransferCenterStore();
store.upsertTasks([
{ ...makeTask("dismiss-root", "completed"), isDirectory: true, progressMode: "files" },
{ ...makeTask("dismiss-child", "completed"), parentTaskId: "dismiss-root" },
]);
markTransferCancelledTree("dismiss-root", ["dismiss-child"]);
const rootEpoch = bumpTransferControlEpoch("dismiss-root");
const childEpoch = bumpTransferControlEpoch("dismiss-child");
store.dismiss("dismiss-root");
assert.equal(isTransferCancelledFlag("dismiss-root"), false);
assert.equal(isTransferCancelledFlag("dismiss-child"), false);
assert.equal(isTransferControlEpochCurrent("dismiss-root", rootEpoch), false);
assert.equal(isTransferControlEpochCurrent("dismiss-child", childEpoch), false);
});
test("history pruning releases retained controls for an evicted directory tree", async (t) => {
const {
isTransferCancelledFlag,
markTransferCancelledTree,
resetTransferCancelLatchesForTests,
} = await import("./sftp/transferCancelLatch");
const {
bumpTransferControlEpoch,
isTransferControlEpochCurrent,
resetTransferControlEpochsForTests,
} = await import("./sftp/transferControlEpoch");
resetTransferCancelLatchesForTests();
resetTransferControlEpochsForTests();
t.after(() => {
resetTransferCancelLatchesForTests();
resetTransferControlEpochsForTests();
});
const store = createSftpTransferCenterStore();
const now = Date.now();
store.upsertTasks([
{ ...makeTask("prune-root", "completed"), isDirectory: true, progressMode: "files", endTime: now - 10_000 },
{ ...makeTask("prune-child", "completed"), parentTaskId: "prune-root", endTime: now - 9_999 },
...Array.from({ length: 198 }, (_, index) => ({
...makeTask(`retained-${index}`, "completed"),
endTime: now - index,
})),
]);
markTransferCancelledTree("prune-root", ["prune-child"]);
const rootEpoch = bumpTransferControlEpoch("prune-root");
const childEpoch = bumpTransferControlEpoch("prune-child");
store.upsertTasks([
{ ...makeTask("newest-1", "completed"), endTime: now + 1 },
{ ...makeTask("newest-2", "completed"), endTime: now + 2 },
]);
assert.equal(store.getTask("prune-root"), undefined);
assert.equal(store.getTask("prune-child"), undefined);
assert.equal(isTransferCancelledFlag("prune-root"), false);
assert.equal(isTransferCancelledFlag("prune-child"), false);
assert.equal(isTransferControlEpochCurrent("prune-root", rootEpoch), false);
assert.equal(isTransferControlEpochCurrent("prune-child", childEpoch), false);
});
test("orphaned unfinished tasks stay controllable so dead rows can be cancelled", () => {
const store = createSftpTransferCenterStore();
store.publishOwner("gone-panel", [
makeTask("dead-transferring", "transferring"),
makeTask("dead-paused", "paused"),
]);
// No owner controller registered — simulates app restart.
assert.equal(store.canControl("dead-transferring"), true);
assert.equal(store.canControl("dead-paused"), true);
});
test("pause on an orphaned transferring task demotes it to interrupted", async () => {
const store = createSftpTransferCenterStore();
store.publishOwner("gone-panel", [makeTask("stuck", "transferring")]);
await store.pause("stuck");
assert.equal(store.getSnapshot().tasks[0]?.status, "interrupted");
assert.equal(store.getSnapshot().tasks[0]?.reconnectRequired, true);
});
test("orphan pause with a live walk latches paused without cancel/demote", async (t) => {
const {
isTransferOrRootPauseLatched,
isTransferPauseLatched,
resetTransferPauseLatchesForTests,
} = await import("./sftp/transferPauseLatch");
const {
registerTransferWalk,
unregisterTransferWalk,
resetTransferWalkRegistryForTests,
} = await import("./sftp/transferWalkRegistry");
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
registerTransferWalk("live-dir");
t.after(() => {
unregisterTransferWalk("live-dir");
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
});
const cancelCalls: string[] = [];
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
// No pauseTransfer → falls to no-bridge branch.
cancelTransfer: async (id: string) => {
cancelCalls.push(id);
return { success: true };
},
},
},
});
const store = createSftpTransferCenterStore();
store.publishOwner("terminal:closed", [
{
...makeTask("live-dir", "transferring"),
isDirectory: true,
progressMode: "files",
transferredBytes: 2,
totalBytes: 10,
},
{
...makeTask("live-child", "transferring"),
parentTaskId: "live-dir",
transferredBytes: 1,
totalBytes: 5,
},
]);
await store.pause("live-dir");
assert.equal(store.getSnapshot().tasks.find((row) => row.id === "live-dir")?.status, "paused");
assert.equal(store.getSnapshot().tasks.find((row) => row.id === "live-child")?.status, "paused");
assert.equal(isTransferPauseLatched("live-dir"), true);
assert.equal(isTransferOrRootPauseLatched("live-dir", "live-child"), true);
assert.deepEqual(cancelCalls, [], "must not cancelTransfer a still-running walk");
});
test("orphan resume clears full latch tree so walk is not blocked", async (t) => {
const {
isTransferOrRootPauseLatched,
latchTransferPauseTree,
resetTransferPauseLatchesForTests,
} = await import("./sftp/transferPauseLatch");
const {
registerTransferWalk,
unregisterTransferWalk,
resetTransferWalkRegistryForTests,
} = await import("./sftp/transferWalkRegistry");
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
latchTransferPauseTree("dir-latched", ["c1", "c2"]);
registerTransferWalk("dir-latched");
t.after(() => {
unregisterTransferWalk("dir-latched");
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
});
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
},
},
});
const store = createSftpTransferCenterStore();
store.publishOwner("gone", [
{
...makeTask("dir-latched", "paused"),
isDirectory: true,
progressMode: "files",
transferredBytes: 1,
totalBytes: 5,
speed: 0,
},
{ ...makeTask("c1", "paused"), parentTaskId: "dir-latched", speed: 0 },
{ ...makeTask("c2", "paused"), parentTaskId: "dir-latched", speed: 0 },
]);
await store.resume("dir-latched");
assert.equal(isTransferOrRootPauseLatched("dir-latched", "c1"), false);
assert.equal(isTransferOrRootPauseLatched("dir-latched", "c2"), false);
assert.equal(store.getSnapshot().tasks.find((row) => row.id === "dir-latched")?.status, "transferring");
});
test("snapshot counts only parent tasks and clearing completed history preserves failures", () => {
const store = createSftpTransferCenterStore();
store.publishOwner("panel-a", [
makeTask("parent"),
{ ...makeTask("child"), parentTaskId: "parent" },
makeTask("done", "completed"),
makeTask("failed", "failed"),
]);
assert.equal(store.getSnapshot().activeCount, 1);
store.clearTerminal("completed");
assert.deepEqual(store.getSnapshot().tasks.map((task) => task.id), ["parent", "child", "failed"]);
});
test("background agent transfers are recorded and retained in history", () => {
const store = createSftpTransferCenterStore();
const now = Date.now();
store.ingestBackgroundEvent({
type: "started",
transferId: "agent-transfer",
direction: "upload",
sourcePath: "/local/report.txt",
targetPath: "/remote/report.txt",
startedAt: now - 10,
});
assert.equal(store.getSnapshot().tasks[0]?.background, true);
assert.equal(store.getSnapshot().tasks[0]?.origin, "agent");
store.ingestBackgroundEvent({ type: "completed", transferId: "agent-transfer", endedAt: now });
assert.equal(store.getSnapshot().tasks[0]?.status, "completed");
assert.equal(store.getSnapshot().tasks[0]?.endTime, now);
});
test("unified started event creates a correctly described download and progress preserves pause capability", () => {
const store = createSftpTransferCenterStore();
store.ingestBackgroundEvent({
type: "started",
transferId: "download-described",
direction: "download",
fileName: "report.bin",
sourcePath: "/remote/report.bin",
targetPath: "/tmp/report.bin",
totalBytes: 100,
resumable: true,
});
store.ingestBackgroundEvent({
type: "progress",
transferId: "download-described",
transferred: 10,
totalBytes: 100,
speed: 5,
resumable: false,
pauseUnavailableReason: "SCP transfers cannot be paused",
});
const task = store.getTask("download-described");
assert.equal(task?.direction, "download");
assert.equal(task?.sourcePath, "/remote/report.bin");
assert.equal(task?.targetPath, "/tmp/report.bin");
assert.equal(task?.fileName, "report.bin");
assert.equal(task?.resumable, false);
assert.equal(task?.pauseUnavailableReason, "SCP transfers cannot be paused");
});
test("main-process progress ingest keeps panel-owned transfers moving without React callbacks", () => {
const store = createSftpTransferCenterStore();
// Simulate panel publish then unmount: task remains in store, no live controller needed.
store.publishOwner("panel-hidden", [{
...makeTask("upload-1", "queued"),
transferredBytes: 0,
totalBytes: 1000,
}]);
store.ingestBackgroundEvent({
type: "progress",
transferId: "upload-1",
transferred: 400,
totalBytes: 1000,
speed: 200,
checkpointBytes: 400,
});
const task = store.getSnapshot().tasks.find((row) => row.id === "upload-1");
assert.equal(task?.status, "transferring");
assert.equal(task?.transferredBytes, 400);
assert.equal(task?.speed, 200);
assert.equal(task?.checkpointBytes, 400);
store.ingestBackgroundEvent({
type: "completed",
transferId: "upload-1",
endedAt: Date.now(),
});
assert.equal(store.getSnapshot().tasks.find((row) => row.id === "upload-1")?.status, "completed");
});
test("duplicate main-process progress does not notify or persist a second renderer update", async () => {
let writes = 0;
let notifications = 0;
const store = createSftpTransferCenterStore({
read: () => null,
write: () => { writes += 1; },
});
store.publishOwner("panel-a", [{
...makeTask("duplicate-progress"),
transferredBytes: 5,
totalBytes: 10,
speed: 2,
lifecycleEpoch: 0,
}]);
store.subscribe(() => { notifications += 1; });
store.ingestBackgroundEvent({
type: "progress",
transferId: "duplicate-progress",
transferred: 5,
totalBytes: 10,
speed: 2,
lifecycleEpoch: 0,
lifecycleState: "transferring",
});
await new Promise((resolve) => setTimeout(resolve, 300));
assert.equal(notifications, 0);
assert.equal(writes, 1);
});
test("owner publish after global progress does not repeat the same renderer update", async () => {
const store = createSftpTransferCenterStore();
store.publishOwner("panel-a", [{
...makeTask("global-first"),
transferredBytes: 0,
totalBytes: 10,
speed: 0,
lifecycleEpoch: 0,
}]);
let lifecycleNotifications = 0;
let progressNotifications = 0;
store.subscribe(() => { lifecycleNotifications += 1; });
store.subscribeProgress(() => { progressNotifications += 1; });
store.ingestBackgroundEvent({
type: "progress",
transferId: "global-first",
transferred: 5,
totalBytes: 10,
speed: 2,
lifecycleEpoch: 0,
lifecycleState: "transferring",
});
// Progress paints are coalesced on the progress channel; a matching panel
// publish must not force a lifecycle React update.
store.publishOwner("panel-a", [{
...makeTask("global-first"),
transferredBytes: 5,
totalBytes: 10,
speed: 2,
lifecycleEpoch: 0,
}]);
// Progress channel is rAF-coalesced (setTimeout 16ms in Node tests).
await new Promise((resolve) => setTimeout(resolve, 50));
assert.equal(lifecycleNotifications, 0);
assert.equal(progressNotifications, 1);
});
test("newer backend lifecycle progress reopens a paused row", () => {
const store = createSftpTransferCenterStore();
store.publishOwner("closed-panel", [{
...makeTask("paused-but-moving", "paused"),
transferredBytes: 50,
checkpointBytes: 50,
lifecycleEpoch: 1,
totalBytes: 100,
speed: 0,
}]);
store.ingestBackgroundEvent({
type: "progress",
transferId: "paused-but-moving",
transferred: 75,
checkpointBytes: 75,
totalBytes: 100,
speed: 10,
lifecycleEpoch: 2,
lifecycleState: "transferring",
});
const task = store.getSnapshot().tasks.find((row) => row.id === "paused-but-moving");
assert.equal(task?.status, "transferring");
assert.equal(task?.transferredBytes, 75);
assert.equal(task?.checkpointBytes, 75);
});
test("older in-flight progress cannot reopen a newer confirmed pause", () => {
const store = createSftpTransferCenterStore();
store.publishOwner("closed-panel", [{
...makeTask("paused-with-late-event", "paused"),
transferredBytes: 50,
checkpointBytes: 50,
lifecycleEpoch: 1,
totalBytes: 100,
speed: 0,
}]);
store.ingestBackgroundEvent({
type: "progress",
transferId: "paused-with-late-event",
transferred: 75,
checkpointBytes: 75,
totalBytes: 100,
speed: 10,
lifecycleEpoch: 0,
lifecycleState: "transferring",
});
const task = store.getSnapshot().tasks.find((row) => row.id === "paused-with-late-event");
assert.equal(task?.status, "paused");
assert.equal(task?.transferredBytes, 50);
});
test("older queued or started events cannot overwrite a newer pause", () => {
const store = createSftpTransferCenterStore();
store.publishOwner("closed-panel", [{
...makeTask("paused-newer", "paused"),
transferredBytes: 50,
checkpointBytes: 50,
lifecycleEpoch: 2,
totalBytes: 100,
speed: 0,
}]);
store.ingestBackgroundEvent({
type: "started",
transferId: "paused-newer",
lifecycleEpoch: 1,
});
store.ingestBackgroundEvent({
type: "queued",
transferId: "paused-newer",
});
assert.equal(store.getSnapshot().tasks[0]?.status, "paused");
assert.equal(store.getSnapshot().tasks[0]?.lifecycleEpoch, 2);
});
test("background compressed lifecycle creates and updates the same controllable task in every window", () => {
const store = createSftpTransferCenterStore();
store.ingestBackgroundEvent({
type: "started",
transferId: "compressed-global",
fileName: "photos (compressed)",
sourcePath: "/local/photos",
targetPath: "/remote/photos",
direction: "upload",
totalBytes: 1_000,
isDirectory: true,
controlKind: "compressed-upload",
phase: "compressing",
});
store.ingestBackgroundEvent({
type: "progress",
transferId: "compressed-global",
transferred: 650,
totalBytes: 1_000,
phase: "uploading",
});
const task = store.getSnapshot().tasks.find((candidate) => candidate.id === "compressed-global");
assert.equal(task?.fileName, "photos (compressed)");
assert.equal(task?.isDirectory, true);
assert.equal(task?.controlKind, "compressed-upload");
assert.equal(task?.phase, "uploading");
assert.equal(task?.transferredBytes, 650);
assert.equal(task?.totalBytes, 1_000);
});
test("a worker compressed progress event can create the task after the source page closed", () => {
const store = createSftpTransferCenterStore();
store.ingestBackgroundEvent({
type: "progress",
transferId: "worker-compressed",
fileName: "worker folder (compressed)",
sourcePath: "/local/worker folder",
targetPath: "/remote/worker folder",
direction: "upload",
transferred: 250,
totalBytes: 1_000,
isDirectory: true,
controlKind: "compressed-upload",
phase: "compressing",
});
const task = store.getSnapshot().tasks[0];
assert.equal(task?.id, "worker-compressed");
assert.equal(task?.transferredBytes, 250);
assert.equal(task?.controlKind, "compressed-upload");
});
test("stale panel publishOwner cannot roll back background progress after tab close", () => {
const store = createSftpTransferCenterStore();
store.publishOwner("panel-a", [{
...makeTask("upload-1", "transferring"),
transferredBytes: 100,
totalBytes: 1000,
speed: 10,
}]);
store.ingestBackgroundEvent({
type: "progress",
transferId: "upload-1",
transferred: 700,
totalBytes: 1000,
speed: 50,
checkpointBytes: 700,
});
assert.equal(store.getSnapshot().tasks[0]?.transferredBytes, 700);
// Panel React state still frozen at 100 (unmount / missed setState) — must not clobber.
store.publishOwner("panel-a", [{
...makeTask("upload-1", "transferring"),
transferredBytes: 100,
totalBytes: 1000,
speed: 0,
checkpointBytes: 100,
}]);
const task = store.getSnapshot().tasks.find((row) => row.id === "upload-1");
assert.equal(task?.transferredBytes, 700);
assert.equal(task?.checkpointBytes, 700);
assert.equal(task?.status, "transferring");
});
test("stale paused panel snapshot cannot hide progress beyond its pause checkpoint", () => {
const store = createSftpTransferCenterStore();
store.publishOwner("panel-a", [{
...makeTask("upload-1", "paused"),
transferredBytes: 50,
checkpointBytes: 50,
lifecycleEpoch: 1,
totalBytes: 100,
speed: 0,
}]);
store.ingestBackgroundEvent({
type: "progress",
transferId: "upload-1",
transferred: 75,
checkpointBytes: 75,
totalBytes: 100,
speed: 10,
lifecycleEpoch: 2,
lifecycleState: "transferring",
});
assert.equal(store.getSnapshot().tasks[0]?.status, "transferring");
store.publishOwner("panel-a", [{
...makeTask("upload-1", "paused"),
transferredBytes: 50,
checkpointBytes: 50,
lifecycleEpoch: 1,
totalBytes: 100,
speed: 0,
}]);
const task = store.getSnapshot().tasks[0];
assert.equal(task?.status, "transferring");
assert.equal(task?.transferredBytes, 75);
assert.equal(task?.checkpointBytes, 75);
});
test("stale panel progress cannot move a bar after a newer backend pause", () => {
const store = createSftpTransferCenterStore();
store.publishOwner("panel-a", [{
...makeTask("upload-paused", "transferring"),
transferredBytes: 50,
checkpointBytes: 50,
totalBytes: 100,
}]);
store.ingestBackgroundEvent({
type: "paused",
transferId: "upload-paused",
transferred: 50,
checkpointBytes: 50,
lifecycleEpoch: 1,
lifecycleState: "paused",
});
store.publishOwner("panel-a", [{
...makeTask("upload-paused", "transferring"),
transferredBytes: 75,
checkpointBytes: 75,
totalBytes: 100,
speed: 10,
}]);
const task = store.getSnapshot().tasks[0];
assert.equal(task?.status, "paused");
assert.equal(task?.transferredBytes, 50);
assert.equal(task?.checkpointBytes, 50);
assert.equal(task?.speed, 0);
});
test("progress under a paused folder parent cannot re-open the child as transferring", () => {
const store = createSftpTransferCenterStore();
store.publishOwner("panel-a", [
{
...makeTask("folder", "paused"),
isDirectory: true,
progressMode: "files",
transferredBytes: 1,
totalBytes: 12,
speed: 0,
},
{
...makeTask("child", "paused"),
parentTaskId: "folder",
transferredBytes: 6_000_000,
totalBytes: 58_000_000,
speed: 0,
lifecycleEpoch: 1,
},
]);
// Soft-drain progress without a resume epoch must stay paused (no blink row).
store.ingestBackgroundEvent({
type: "progress",
transferId: "child",
transferred: 7_000_000,
totalBytes: 58_000_000,
speed: 50,
checkpointBytes: 7_000_000,
lifecycleEpoch: 1,
lifecycleState: "transferring",
});
const child = store.getSnapshot().tasks.find((row) => row.id === "child");
assert.equal(child?.status, "paused");
assert.equal(child?.transferredBytes, 6_000_000);
assert.equal(child?.speed, 0);
assert.equal(child?.checkpointBytes, 7_000_000);
});
test("publishOwner cannot resurrect a live child under a paused folder parent", () => {
const store = createSftpTransferCenterStore();
store.publishOwner("panel-a", [
{
...makeTask("folder", "paused"),
isDirectory: true,
progressMode: "files",
transferredBytes: 1,
totalBytes: 12,
speed: 0,
},
{
...makeTask("child", "paused"),
parentTaskId: "folder",
transferredBytes: 1_000,
totalBytes: 10_000,
speed: 0,
},
]);
store.publishOwner("panel-a", [
{
...makeTask("folder", "paused"),
isDirectory: true,
progressMode: "files",
transferredBytes: 1,
totalBytes: 12,
speed: 0,
},
{
...makeTask("child", "transferring"),
parentTaskId: "folder",
transferredBytes: 2_000,
totalBytes: 10_000,
speed: 40,
},
]);
const child = store.getSnapshot().tasks.find((row) => row.id === "child");
assert.equal(child?.status, "paused");
assert.equal(child?.speed, 0);
});
test("intentional panel pause wins even when soft-drain store bytes are ahead", () => {
const store = createSftpTransferCenterStore();
// Background soft-drain advanced the global bar past the panel snapshot.
store.publishOwner("panel-a", [{
...makeTask("folder-parent", "transferring"),
isDirectory: true,
progressMode: "files",
transferredBytes: 3,
totalBytes: 12,
checkpointBytes: 3,
}]);
store.publishOwner("panel-a", [{
...makeTask("child-live", "transferring"),
parentTaskId: "folder-parent",
transferredBytes: 4_000_000,
totalBytes: 45_000_000,
checkpointBytes: 4_000_000,
speed: 80,
}]);
store.ingestBackgroundEvent({
type: "progress",
transferId: "child-live",
transferred: 8_000_000,
totalBytes: 45_000_000,
speed: 90,
checkpointBytes: 8_000_000,
});
assert.equal(store.getSnapshot().tasks.find((row) => row.id === "child-live")?.transferredBytes, 8_000_000);
// User hits pause: panel freezes child at a lower snapshot and paints pausing.
store.publishOwner("panel-a", [
{
...makeTask("folder-parent", "pausing"),
isDirectory: true,
progressMode: "files",
transferredBytes: 3,
totalBytes: 12,
checkpointBytes: 3,
speed: 0,
},
{
...makeTask("child-live", "pausing"),
parentTaskId: "folder-parent",
transferredBytes: 4_000_000,
totalBytes: 45_000_000,
checkpointBytes: 4_000_000,
speed: 0,
},
]);
const parent = store.getSnapshot().tasks.find((row) => row.id === "folder-parent");
const child = store.getSnapshot().tasks.find((row) => row.id === "child-live");
assert.equal(parent?.status, "pausing", "folder parent pause must stick in the global center");
assert.equal(child?.status, "pausing", "child pause must not be rejected for higher store bytes");
assert.equal(child?.transferredBytes, 8_000_000, "keep soft-drain water mark, freeze further motion");
assert.equal(child?.speed, 0);
});
test("pause/resume are process-global even when a live owner is registered", async (t) => {
const {
registerTransferWalk,
unregisterTransferWalk,
resetTransferWalkRegistryForTests,
} = await import("./sftp/transferWalkRegistry");
resetTransferWalkRegistryForTests();
registerTransferWalk("dir");
t.after(() => {
unregisterTransferWalk("dir");
resetTransferWalkRegistryForTests();
});
const store = createSftpTransferCenterStore();
const controllerCalls: string[] = [];
let syncCalls = 0;
store.registerOwner("panel-a", {
pause: async (id) => { controllerCalls.push(`pause:${id}`); },
resume: async (id) => { controllerCalls.push(`resume:${id}`); },
cancel: async () => {},
retry: async () => {},
prioritize: async () => {},
dismiss: () => {},
ownsTask: () => true,
syncOwnedTasks: () => { syncCalls += 1; },
});
store.publishOwner("panel-a", [{
...makeTask("dir", "transferring"),
isDirectory: true,
progressMode: "files",
transferredBytes: 2,
totalBytes: 10,
}, {
...makeTask("file", "transferring"),
parentTaskId: "dir",
transferredBytes: 100,
totalBytes: 1000,
}]);
await store.pause("dir");
assert.deepEqual(controllerCalls, [], "soft-control must not route pause through React owner");
assert.equal(store.getSnapshot().tasks.find((row) => row.id === "dir")?.status, "paused");
assert.ok(syncCalls >= 1, "owners still get a sync so local lists can mirror store");
await store.resume("dir");
assert.deepEqual(controllerCalls, [], "soft-control must not route resume through React owner");
assert.equal(store.getSnapshot().tasks.find((row) => row.id === "dir")?.status, "transferring");
});
test("pause after terminal close works without any React owner", async () => {
const {
isTransferPauseLatched,
resetTransferPauseLatchesForTests,
} = await import("./sftp/transferPauseLatch");
const {
registerTransferWalk,
unregisterTransferWalk,
resetTransferWalkRegistryForTests,
} = await import("./sftp/transferWalkRegistry");
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
registerTransferWalk("stale-dir");
const pauseCalls: string[] = [];
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const cleanup = () => {
unregisterTransferWalk("stale-dir");
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
};
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
pauseTransfer: async (id: string) => {
pauseCalls.push(id);
return { success: true, checkpointBytes: 1 };
},
},
},
});
const store = createSftpTransferCenterStore();
// No owner registered — pure process-global control after tab close.
store.publishOwner("terminal:closed-tab", [{
...makeTask("stale-dir", "transferring"),
isDirectory: true,
progressMode: "files",
transferredBytes: 2,
totalBytes: 12,
}, {
...makeTask("stale-child", "transferring"),
parentTaskId: "stale-dir",
transferredBytes: 1,
totalBytes: 10,
}]);
await store.pause("stale-dir");
assert.equal(store.getSnapshot().tasks.find((row) => row.id === "stale-dir")?.status, "paused");
assert.equal(isTransferPauseLatched("stale-dir"), true);
assert.ok(pauseCalls.includes("stale-child"));
cleanup();
});
test("soft pause checkpoint and source fingerprint persist before pause returns", async (t) => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
pauseTransfer: async () => ({
success: true,
checkpointBytes: 7,
sourceFingerprint: "sha256:pause-return",
lifecycleEpoch: 1,
}),
},
},
});
let persisted = "";
const store = createSftpTransferCenterStore({
read: () => null,
write: (value) => { persisted = value; },
});
store.publishOwner("closed-panel", [{
...makeTask("pause-durable", "transferring"),
transferredBytes: 7,
totalBytes: 10,
}]);
await store.pause("pause-durable");
const restored = createSftpTransferCenterStore({
read: () => persisted,
write: () => {},
});
const restoredTask = restored.getSnapshot().tasks[0];
assert.equal(restoredTask?.status, "interrupted");
assert.equal(restoredTask?.checkpointBytes, 7);
assert.equal(restoredTask?.sourceFingerprint, "sha256:pause-return");
});
test("publishOwner still allows explicit restart that resets progress to zero", () => {
const store = createSftpTransferCenterStore();
store.publishOwner("panel-a", [{
...makeTask("upload-1", "transferring"),
transferredBytes: 500,
totalBytes: 1000,
}]);
store.publishOwner("panel-a", [{
...makeTask("upload-1", "queued"),
transferredBytes: 0,
checkpointBytes: 0,
totalBytes: 1000,
}]);
const task = store.getSnapshot().tasks.find((row) => row.id === "upload-1");
assert.equal(task?.status, "queued");
assert.equal(task?.transferredBytes, 0);
});
test("stale panel publishOwner cannot un-complete after background completed", () => {
const store = createSftpTransferCenterStore();
const endedAt = Date.now();
store.publishOwner("panel-a", [{
...makeTask("upload-1", "transferring"),
transferredBytes: 900,
totalBytes: 1000,
startTime: endedAt - 1000,
}]);
store.ingestBackgroundEvent({
type: "completed",
transferId: "upload-1",
endedAt,
});
assert.equal(store.getSnapshot().tasks.find((row) => row.id === "upload-1")?.status, "completed");
assert.equal(store.getSnapshot().activeCount, 0);
// Late panel snapshot still says transferring (React unmount / dual-writer race).
store.publishOwner("panel-a", [{
...makeTask("upload-1", "transferring"),
transferredBytes: 900,
totalBytes: 1000,
speed: 50,
startTime: endedAt - 1000,
}]);
const task = store.getSnapshot().tasks.find((row) => row.id === "upload-1");
assert.equal(task?.status, "completed");
assert.equal(task?.endTime, endedAt);
assert.equal(store.getSnapshot().activeCount, 0);
});
test("stale panel publishOwner cannot un-cancel after background cancelled", () => {
const store = createSftpTransferCenterStore();
const endedAt = Date.now();
store.publishOwner("panel-a", [{
...makeTask("upload-1", "transferring"),
transferredBytes: 100,
totalBytes: 1000,
startTime: endedAt - 1000,
}]);
store.ingestBackgroundEvent({
type: "cancelled",
transferId: "upload-1",
endedAt,
});
assert.equal(store.getSnapshot().tasks.find((row) => row.id === "upload-1")?.status, "cancelled");
store.publishOwner("panel-a", [{
...makeTask("upload-1", "transferring"),
transferredBytes: 150,
totalBytes: 1000,
startTime: endedAt - 1000,
}]);
const task = store.getSnapshot().tasks.find((row) => row.id === "upload-1");
assert.equal(task?.status, "cancelled");
assert.equal(task?.endTime, endedAt);
assert.equal(store.getSnapshot().activeCount, 0);
});
test("orphan pause latches process-global pause without a panel controller", async () => {
const {
isTransferPauseLatched,
resetTransferPauseLatchesForTests,
} = await import("./sftp/transferPauseLatch");
resetTransferPauseLatchesForTests();
const store = createSftpTransferCenterStore();
const started = Date.now();
store.publishOwner("gone-panel", [{
...makeTask("live-1", "transferring"),
transferredBytes: 10,
totalBytes: 100,
startTime: started,
}]);
// No registerOwner — simulates tab close / panel unmount.
// pauseTransfer is unavailable in pure unit tests → demote path still latches.
await store.pause("live-1");
assert.equal(isTransferPauseLatched("live-1"), true, "pause must latch even without a panel owner");
const task = store.getSnapshot().tasks.find((row) => row.id === "live-1");
assert.ok(task);
assert.ok(
task!.status === "paused" || task!.status === "interrupted" || task!.status === "pausing",
`expected paused-like status, got ${task!.status}`,
);
resetTransferPauseLatchesForTests();
});
test("orphan pause retries while the backend stream is still arming", async (t) => {
const pauseCalls: string[] = [];
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
pauseTransfer: async (id: string) => {
pauseCalls.push(id);
return pauseCalls.length === 1
? { success: false, reason: "This transfer cannot be paused yet" }
: { success: true, checkpointBytes: 42 };
},
},
},
});
const store = createSftpTransferCenterStore();
store.publishOwner("closed-panel", [{
...makeTask("arming-upload", "transferring"),
transferredBytes: 20,
totalBytes: 100,
}]);
await store.pause("arming-upload");
assert.deepEqual(pauseCalls, ["arming-upload", "arming-upload"]);
const task = store.getSnapshot().tasks.find((candidate) => candidate.id === "arming-upload");
assert.equal(task?.status, "paused");
assert.equal(task?.checkpointBytes, 42);
assert.equal(task?.pauseUnavailableReason, undefined);
});
test("orphan compressed upload pause uses the compression job and reports deferred pause honestly", async (t) => {
const compressedCalls: string[] = [];
const streamCalls: string[] = [];
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
pauseCompressedUpload: async (id: string) => {
compressedCalls.push(id);
return { success: true, deferred: true };
},
pauseTransfer: async (id: string) => {
streamCalls.push(id);
return { success: true };
},
},
},
});
const store = createSftpTransferCenterStore();
store.publishOwner("closed-panel", [{
...makeTask("compressed-1", "transferring"),
fileName: "photos (compressed)",
isDirectory: true,
phase: "extracting",
controlKind: "compressed-upload",
} as TransferTask]);
await store.pause("compressed-1");
assert.deepEqual(compressedCalls, ["compressed-1"]);
assert.deepEqual(streamCalls, []);
assert.equal(store.getSnapshot().tasks[0]?.status, "pausing");
assert.equal(store.getSnapshot().tasks[0]?.pauseUnavailableReason, undefined);
});
test("orphan compressed upload resume and cancel keep using the compression job", async (t) => {
const calls: string[] = [];
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
resumeCompressedUpload: async (id: string) => {
calls.push(`resume:${id}`);
return { success: true };
},
cancelCompressedUpload: async (id: string) => {
calls.push(`cancel:${id}`);
return { success: true };
},
resumeTransfer: async (id: string) => {
calls.push(`wrong-resume:${id}`);
return { success: true };
},
cancelTransfer: async (id: string) => {
calls.push(`wrong-cancel:${id}`);
},
},
},
});
const store = createSftpTransferCenterStore();
store.publishOwner("closed-panel", [{
...makeTask("compressed-2", "paused"),
fileName: "archive (compressed)",
isDirectory: true,
phase: "compressing",
controlKind: "compressed-upload",
} as TransferTask]);
await store.resume("compressed-2");
assert.equal(store.getSnapshot().tasks[0]?.status, "transferring");
await store.cancel("compressed-2");
assert.deepEqual(calls, ["resume:compressed-2", "cancel:compressed-2"]);
assert.equal(store.getSnapshot().tasks[0]?.status, "cancelled");
});
test("orphan cancel marks process-global cancel so surviving walks stop", async (t) => {
const {
isTransferCancelledFlag,
resetTransferCancelLatchesForTests,
} = await import("./sftp/transferCancelLatch");
const {
registerTransferWalk,
resetTransferWalkRegistryForTests,
unregisterTransferWalk,
} = await import("./sftp/transferWalkRegistry");
resetTransferCancelLatchesForTests();
resetTransferWalkRegistryForTests();
registerTransferWalk("walk-1");
t.after(() => {
unregisterTransferWalk("walk-1");
resetTransferCancelLatchesForTests();
resetTransferWalkRegistryForTests();
});
const store = createSftpTransferCenterStore();
const started = Date.now();
store.publishOwner("gone-panel", [{
...makeTask("walk-1", "transferring"),
transferredBytes: 10,
totalBytes: 100,
startTime: started,
}]);
await store.cancel("walk-1");
assert.equal(isTransferCancelledFlag("walk-1"), true);
assert.equal(store.getSnapshot().tasks.find((row) => row.id === "walk-1")?.status, "cancelled");
});
test("orphan resume releases process-global pause latch without a panel owner", async (t) => {
const {
isTransferPauseLatched,
latchTransferPause,
resetTransferPauseLatchesForTests,
} = await import("./sftp/transferPauseLatch");
const {
registerTransferWalk,
unregisterTransferWalk,
resetTransferWalkRegistryForTests,
} = await import("./sftp/transferWalkRegistry");
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
// Live walk: soft-unlatch only (do not fall through to prepareAdopter).
registerTransferWalk("paused-1");
t.after(() => {
unregisterTransferWalk("paused-1");
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
});
const store = createSftpTransferCenterStore();
const started = Date.now();
store.publishOwner("gone-panel", [{
...makeTask("paused-1", "paused"),
transferredBytes: 10,
totalBytes: 100,
checkpointBytes: 10,
startTime: started,
}]);
// Simulate a walk that outlived the panel and is still latched.
latchTransferPause("paused-1");
assert.equal(isTransferPauseLatched("paused-1"), true);
await store.resume("paused-1");
assert.equal(
isTransferPauseLatched("paused-1"),
false,
"resume without a panel owner must unlatch so surviving walks continue",
);
});
test("failed rows may reopen via publishOwner for same-id checkpoint resume", () => {
// failed is not sticky: resumeTransfer paints transferring with preserved checkpoint.
const store = createSftpTransferCenterStore();
const endedAt = Date.now();
store.publishOwner("panel-a", [{
...makeTask("upload-1", "transferring"),
transferredBytes: 100,
totalBytes: 1000,
checkpointBytes: 100,
startTime: endedAt - 1000,
}]);
store.ingestBackgroundEvent({
type: "failed",
transferId: "upload-1",
endedAt,
error: "disk full",
});
assert.equal(store.getSnapshot().tasks.find((row) => row.id === "upload-1")?.status, "failed");
store.publishOwner("panel-a", [{
...makeTask("upload-1", "transferring"),
transferredBytes: 100,
totalBytes: 1000,
checkpointBytes: 100,
startTime: endedAt - 1000,
}]);
const task = store.getSnapshot().tasks.find((row) => row.id === "upload-1");
assert.equal(task?.status, "transferring");
assert.equal(task?.checkpointBytes, 100);
});
test("clearing terminal history asks each owner to clean transfer artifacts", () => {
const dismissed: string[] = [];
const store = createSftpTransferCenterStore();
store.registerOwner("panel-a", {
pause: async () => {}, resume: async () => {}, cancel: async () => {}, retry: async () => {}, prioritize: async () => {},
dismiss: (id) => { dismissed.push(id); },
});
store.publishOwner("panel-a", [makeTask("done", "completed"), makeTask("failed", "failed")]);
store.clearTerminal("completed");
assert.deepEqual(dismissed, ["done"]);
assert.deepEqual(store.getSnapshot().tasks.map((task) => task.id), ["failed"]);
});
test("history pruning passes removed task data to background cleanup after snapshot eviction", () => {
let removedTask: TransferTask | undefined;
const store = createSftpTransferCenterStore();
store.registerOwner("background-agent", {
pause: async () => {},
resume: async () => {},
cancel: async () => {},
retry: async () => {},
prioritize: async () => {},
dismiss: (_taskId: string, task?: TransferTask) => {
removedTask = task;
},
});
const now = Date.now();
store.publishOwner("background-agent", Array.from({ length: 201 }, (_, index) => ({
...makeTask(`background-history-${index}`, "failed"),
startTime: now - index,
endTime: now - index,
stagedTargetPath: `/target/.background-history-${index}.part`,
})));
assert.equal(store.getSnapshot().tasks.length, 200);
assert.equal(removedTask?.id, "background-history-200");
assert.equal(removedTask?.stagedTargetPath, "/target/.background-history-200.part");
assert.equal(store.getSnapshot().tasks.some((task) => task.id === removedTask?.id), false);
});
test("failed reauthentication leaves a paused transfer requiring attention with the failure reason", async (t) => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
dispatchEvent(event: CustomEvent<{ reportFailure?: (error: string) => void }>) {
event.detail.reportFailure?.("Authentication failed");
return true;
},
},
});
const store = createSftpTransferCenterStore();
// Original panel is gone — resume must open/authenticate a new one.
// A preparer is present but adoption never becomes ready because auth fails.
store.registerOwner("visible-preparer", {
pause: async () => {}, resume: async () => {}, cancel: async () => {}, retry: async () => {}, prioritize: async () => {},
dismiss: () => {},
canAdopt: () => false,
canPrepareAdoption: true,
adopt: async () => {},
});
store.publishOwner("closed-panel", [{
...makeTask("paused", "paused"),
sourceConnectionId: "closed",
sourceHostId: "host-a",
}]);
await store.resume("paused");
assert.equal(store.getSnapshot().tasks[0]?.status, "attention");
assert.equal(store.getSnapshot().tasks[0]?.error, "Authentication failed");
});
test("background events do not resurrect a cancelled agent transfer", () => {
const store = createSftpTransferCenterStore();
store.ingestBackgroundEvent({
type: "queued",
transferId: "agent-1",
direction: "download",
sourcePath: "/r/a",
targetPath: "/l/a",
startedAt: Date.now(),
});
store.ingestBackgroundEvent({ type: "cancelled", transferId: "agent-1", endedAt: Date.now() });
assert.equal(store.getSnapshot().tasks[0]?.status, "cancelled");
store.ingestBackgroundEvent({ type: "started", transferId: "agent-1" });
store.ingestBackgroundEvent({ type: "progress", transferId: "agent-1", transferred: 50, totalBytes: 100, speed: 1 });
store.ingestBackgroundEvent({ type: "completed", transferId: "agent-1", endedAt: Date.now() });
assert.equal(store.getSnapshot().tasks[0]?.status, "cancelled");
});
test("background lifecycle events keep directory children nested under their parent", () => {
const store = createSftpTransferCenterStore();
store.publishOwner("terminal:folder", [{
...makeTask("folder-root", "transferring"),
isDirectory: true,
progressMode: "files",
totalBytes: 2,
}]);
store.ingestBackgroundEvent({
type: "started",
transferId: "folder-child",
parentTaskId: "folder-root",
fileName: "nested.txt",
sourcePath: "/local/nested.txt",
targetPath: "/remote/nested.txt",
direction: "upload",
totalBytes: 12,
});
const snapshot = store.getSnapshot().tasks;
const child = snapshot.find((task) => task.id === "folder-child");
assert.equal(child?.parentTaskId, "folder-root");
assert.deepEqual(
snapshot.filter((task) => !task.parentTaskId).map((task) => task.id),
["folder-root"],
"a directory child must never reappear as a top-level background task",
);
});
test("orphaned resume prefers a dedicated SFTP session without a panel owner", async () => {
const store = createSftpTransferCenterStore();
store.publishOwner("closed-panel", [{
...makeTask("dedicated", "interrupted"),
direction: "download",
sourceHostId: "host-a",
sourceHostLabel: "CI-Build-01",
targetConnectionId: "local",
checkpointBytes: 100,
reconnectRequired: true,
}]);
let sawTaskId = "";
store.setDedicatedResumeHandler(async (task) => {
sawTaskId = task.id;
store.patchTask(task.id, {
status: "transferring",
transferredBytes: 100,
speed: 10,
});
return { success: true };
});
await store.resume("dedicated");
assert.equal(sawTaskId, "dedicated");
assert.equal(store.getSnapshot().tasks[0]?.status, "completed");
assert.equal(store.getSnapshot().tasks[0]?.reconnectRequired, false);
});
test("dedicated server-to-server resume failure without a panel owner stays recoverable", async () => {
const store = createSftpTransferCenterStore();
store.publishOwner("dedicated-resume", [{
...makeTask("dedicated-s2s", "interrupted"),
direction: "remote-to-remote",
sourceHostId: "host-a",
targetHostId: "host-b",
reconnectRequired: true,
}]);
store.setDedicatedResumeHandler(async () => ({
success: false,
error: "Open the SFTP panel to reconnect both hosts",
}));
await store.resume("dedicated-s2s");
const task = store.getSnapshot().tasks.find((candidate) => candidate.id === "dedicated-s2s");
assert.equal(task?.status, "attention");
assert.equal(task?.ownerId, "dedicated-resume");
assert.equal(task?.reconnectRequired, true);
});
test("force-quit continue skips dead soft-resume and uses dedicated handler", async (t) => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
let softResumeCalls = 0;
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
clearPendingTransferCancel: async () => ({ success: true }),
resumeTransfer: async () => {
softResumeCalls += 1;
return { success: true }; // would be wrong to honor after force-quit
},
},
},
});
const store = createSftpTransferCenterStore();
store.publishOwner("closed-panel", [{
...makeTask("after-quit", "interrupted"),
direction: "upload",
sourceConnectionId: "local",
targetHostId: "host-a",
targetHostLabel: "box-a",
checkpointBytes: 20,
transferredBytes: 20,
totalBytes: 100,
reconnectRequired: true,
}]);
let sawDedicated = false;
store.setDedicatedResumeHandler(async (task) => {
sawDedicated = true;
store.patchTask(task.id, {
status: "transferring",
transferredBytes: 40,
checkpointBytes: 40,
reconnectRequired: false,
ownerId: "dedicated-resume",
});
return { success: true };
});
await store.resume("after-quit");
assert.equal(softResumeCalls, 0, "must not soft-resume a dead post-quit transfer");
assert.equal(sawDedicated, true);
assert.equal(store.getSnapshot().tasks[0]?.status, "completed");
assert.equal(store.getSnapshot().tasks[0]?.ownerId, "dedicated-resume");
});
test("restart resume waits for the dedicated handler to become ready and keeps progress live", async (t) => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
clearPendingTransferCancel: async () => ({ success: true }),
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
},
},
});
const store = createSftpTransferCenterStore();
store.publishOwner("closed-panel", [{
...makeTask("startup-race", "interrupted"),
direction: "upload",
sourceConnectionId: "local",
targetHostId: "host-a",
targetHostLabel: "box-a",
checkpointBytes: 20,
transferredBytes: 20,
totalBytes: 100,
reconnectRequired: true,
}]);
let releaseTransfer!: () => void;
const transferFinished = new Promise<void>((resolve) => { releaseTransfer = resolve; });
let handlerStarted!: () => void;
const sawHandlerStart = new Promise<void>((resolve) => { handlerStarted = resolve; });
const resume = store.resume("startup-race");
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(store.getSnapshot().tasks[0]?.status, "pending");
store.setDedicatedResumeHandler(async (task) => {
store.patchTask(task.id, {
status: "transferring",
transferredBytes: 35,
checkpointBytes: 35,
reconnectRequired: false,
});
handlerStarted();
await transferFinished;
return { success: true };
});
await sawHandlerStart;
assert.equal(store.getSnapshot().tasks[0]?.status, "transferring");
assert.equal(store.getSnapshot().tasks[0]?.transferredBytes, 35);
releaseTransfer();
await resume;
assert.equal(store.getSnapshot().tasks[0]?.status, "completed");
});
test("directory resume uses dedicated handler and rehomes children", async () => {
const store = createSftpTransferCenterStore();
store.publishOwner("closed-panel", [
{
...makeTask("dir-parent", "interrupted"),
isDirectory: true,
progressMode: "files",
direction: "download",
sourceHostId: "host-a",
sourceHostLabel: "CI-Build-01",
targetConnectionId: "local",
totalBytes: 2,
transferredBytes: 1,
reconnectRequired: true,
},
{
...makeTask("dir-child-done", "completed"),
parentTaskId: "dir-parent",
sourcePath: "/r/a",
targetPath: "/l/a",
},
{
...makeTask("dir-child-open", "interrupted"),
parentTaskId: "dir-parent",
sourcePath: "/r/b",
targetPath: "/l/b",
checkpointBytes: 50,
reconnectRequired: true,
},
]);
let sawDirectory = false;
store.setDedicatedResumeHandler(async (task) => {
sawDirectory = !!task.isDirectory;
store.upsertTasks([{
...makeTask("dir-child-open", "completed"),
parentTaskId: "dir-parent",
ownerId: "dedicated-resume",
sourcePath: "/r/b",
targetPath: "/l/b",
}]);
return { success: true };
});
await store.resume("dir-parent");
assert.equal(sawDirectory, true);
const snapshot = store.getSnapshot().tasks;
const parent = snapshot.find((task) => task.id === "dir-parent");
const children = snapshot.filter((task) => task.parentTaskId === "dir-parent");
assert.equal(parent?.status, "completed");
assert.equal(parent?.ownerId, "dedicated-resume");
assert.equal(children.length, 0, "completed resume children stay compacted out of history");
assert.equal(parent?.directoryResumeCheckpoint?.completedEntries, 2);
});
test("upsertTasks refuses new children under a cancelled directory parent", () => {
const store = createSftpTransferCenterStore();
store.publishOwner("dedicated-resume", [{
...makeTask("dir", "cancelled"),
isDirectory: true,
ownerId: "dedicated-resume",
}]);
store.upsertTasks([{
...makeTask("late-child", "transferring"),
parentTaskId: "dir",
ownerId: "dedicated-resume",
}]);
assert.equal(store.getSnapshot().tasks.some((task) => task.id === "late-child"), false);
});
test("pause on dedicated directory parent freezes unfinished children", async () => {
const store = createSftpTransferCenterStore();
store.publishOwner("dedicated-resume", [
{
...makeTask("dir", "transferring"),
isDirectory: true,
ownerId: "dedicated-resume",
direction: "download",
sourceHostId: "host-a",
targetConnectionId: "local",
totalBytes: 2,
transferredBytes: 0,
},
{
...makeTask("c1", "transferring"),
parentTaskId: "dir",
ownerId: "dedicated-resume",
},
]);
// No live bridge pause — directory soft-pause latches + paints paused without
// cancel demotion (cancel would kill a still-running dedicated walk).
await store.pause("dir");
const snapshot = store.getSnapshot().tasks;
assert.equal(snapshot.find((task) => task.id === "dir")?.status, "paused");
assert.equal(snapshot.find((task) => task.id === "c1")?.status, "paused");
});
test("dedicated directory resume after soft-pause winds down then startFresh (no dead transferring)", async (t) => {
const store = createSftpTransferCenterStore();
let resumeCalls = 0;
const cancelCalls: string[] = [];
// Models bridge cancel when the child is no longer in activeTransfers: the
// pendingCancel latch sticks until clearPendingTransferCancel runs.
const pendingCancel = new Set<string>();
const clearPendingCalls: string[] = [];
let firstRunStarted: (() => void) | null = null;
const firstRunBlocked = new Promise<void>((resolve) => { firstRunStarted = resolve; });
let releaseFirstRun: (() => void) | null = null;
const firstRunHold = new Promise<void>((resolve) => { releaseFirstRun = resolve; });
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
cancelTransfer: async (id: string) => {
cancelCalls.push(id);
// Sticky latch when not active (real bridge behavior for missing handle).
pendingCancel.add(id);
return { success: true };
},
clearPendingTransferCancel: async (id: string) => {
clearPendingCalls.push(id);
pendingCancel.delete(id);
return { success: true };
},
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
pauseTransfer: async () => ({ success: true, checkpointBytes: 1 }),
},
},
});
store.publishOwner("dedicated-resume", [{
...makeTask("dir", "interrupted"),
isDirectory: true,
ownerId: "dedicated-resume",
direction: "download",
sourceHostId: "host-a",
targetConnectionId: "local",
totalBytes: 2,
transferredBytes: 0,
reconnectRequired: true,
}, {
...makeTask("c1", "interrupted"),
parentTaskId: "dir",
ownerId: "dedicated-resume",
reconnectRequired: true,
}]);
store.setDedicatedResumeHandler(async () => {
resumeCalls += 1;
if (resumeCalls === 1) {
// Leave reconnectRequired so pause is not skipped only after live transfer.
store.patchTask("dir", { status: "transferring", reconnectRequired: false });
store.upsertTasks([{
...makeTask("c1", "transferring"),
parentTaskId: "dir",
ownerId: "dedicated-resume",
reconnectRequired: false,
}]);
firstRunStarted?.();
await firstRunHold;
return { success: false, error: "Transfer cancelled" };
}
// startFresh reuses the same child transfer id — latch must be clear or
// startStreamTransfer would immediately cancel (production failure mode).
assert.equal(
pendingCancel.has("c1"),
false,
"child pendingCancel latch must be cleared before startFresh reuses c1",
);
assert.ok(clearPendingCalls.includes("c1"), "must call clearPendingTransferCancel for c1");
return { success: true };
});
// Start first dedicated run (held in resumeInvocations).
const first = store.resume("dir");
await firstRunBlocked;
assert.equal(store.getSnapshot().tasks.find((task) => task.id === "dir")?.status, "transferring");
// Soft-pause paints paused under dedicated-resume.
await store.pause("dir");
assert.equal(store.getSnapshot().tasks.find((task) => task.id === "dir")?.status, "paused");
// Resume must cancel soft-paused children, clear sticky latches, await wind-down, then startFresh.
const second = store.resume("dir");
// Allow the held first run to settle after cancel wind-down begins.
releaseFirstRun?.();
await second;
await first.catch(() => {});
assert.ok(cancelCalls.includes("c1"), "must cancel soft-paused children before startFresh");
assert.equal(resumeCalls, 2, "must startFresh after wind-down");
assert.equal(pendingCancel.has("c1"), false, "child latch must stay clear after startFresh");
assert.equal(store.getSnapshot().tasks.find((task) => task.id === "dir")?.status, "completed");
});
test("resume refuses when another active transfer already owns the same path", async () => {
const store = createSftpTransferCenterStore();
store.publishOwner("panel-a", [
{
...makeTask("live", "transferring"),
sourcePath: "/root/sing-box",
targetPath: "/Users/me/Desktop/sing-box",
fileName: "sing-box",
direction: "download",
sourceConnectionId: "remote-live",
targetConnectionId: "local",
},
{
...makeTask("stale", "interrupted"),
sourcePath: "/root/other",
targetPath: "/Users/me/Desktop/sing-box",
fileName: "sing-box",
direction: "download",
sourceConnectionId: "remote-stale",
targetConnectionId: "local",
reconnectRequired: true,
},
]);
store.setDedicatedResumeHandler(async () => {
throw new Error("dedicated resume must not run when path is busy");
});
await store.resume("stale");
const stale = store.getSnapshot().tasks.find((task) => task.id === "stale");
assert.equal(stale?.status, "attention");
assert.match(stale?.error ?? "", /already in progress/i);
});
test("orphan soft-resume with a live walk does not start dedicated resume", async (t) => {
const { registerTransferWalk, unregisterTransferWalk, resetTransferWalkRegistryForTests } =
await import("./sftp/transferWalkRegistry");
resetTransferWalkRegistryForTests();
registerTransferWalk("dir-alive");
t.after(() => {
unregisterTransferWalk("dir-alive");
resetTransferWalkRegistryForTests();
});
const resumeCalls: string[] = [];
let dedicatedCalls = 0;
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
// Between files: no active child streams — bridge resume misses.
resumeTransfer: async (id: string) => {
resumeCalls.push(id);
return { success: false, reason: "Transfer is no longer active" };
},
},
},
});
const store = createSftpTransferCenterStore();
store.setDedicatedResumeHandler(async () => {
dedicatedCalls += 1;
return { success: false, error: "should not dedicated-resume a live walk" };
});
store.publishOwner("terminal:closed-tab", [{
...makeTask("dir-alive", "paused"),
isDirectory: true,
progressMode: "files",
transferredBytes: 3,
totalBytes: 12,
speed: 0,
}, {
...makeTask("child-done", "completed"),
parentTaskId: "dir-alive",
}]);
await store.resume("dir-alive");
assert.equal(dedicatedCalls, 0, "must not start a second dedicated walk while processTransfer is alive");
assert.equal(store.getSnapshot().tasks.find((row) => row.id === "dir-alive")?.status, "transferring");
assert.equal(store.getSnapshot().tasks.find((row) => row.id === "dir-alive")?.reconnectRequired, false);
});
test("async single-file soft-drain does not re-pause after immediate resume", async (t) => {
let resolveFilePause!: (value: { success: boolean; checkpointBytes?: number }) => void;
const filePauseGate = new Promise<{ success: boolean; checkpointBytes?: number }>((resolve) => {
resolveFilePause = resolve;
});
const resumeCalls: string[] = [];
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
pauseTransfer: async (id: string) => {
if (id === "file-slow") return filePauseGate;
return { success: true, checkpointBytes: 1 };
},
resumeTransfer: async (id: string) => {
resumeCalls.push(id);
return { success: true };
},
},
},
});
const store = createSftpTransferCenterStore();
store.publishOwner("gone-panel", [{
...makeTask("file-slow", "transferring"),
transferredBytes: 10,
totalBytes: 100,
checkpointBytes: 10,
}]);
// Pause returns after soft-drain await for single-file (not fire-and-forget).
// Hold the bridge pause so Resume can win mid-drain.
const pausePromise = store.pause("file-slow");
// Yield so pause paints pausing and enters soft-drain.
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(store.getSnapshot().tasks.find((row) => row.id === "file-slow")?.status, "pausing");
await store.resume("file-slow");
// Soft-drain finally completes after resume.
resolveFilePause({ success: true, checkpointBytes: 20 });
await pausePromise;
await new Promise((resolve) => setTimeout(resolve, 10));
const file = store.getSnapshot().tasks.find((row) => row.id === "file-slow");
assert.equal(file?.status, "transferring", "late single-file soft-drain must not re-paint paused");
assert.ok(resumeCalls.includes("file-slow"), "must resumeTransfer after superseded single-file pause");
const {
isTransferPauseLatched,
resetTransferPauseLatchesForTests,
} = await import("./sftp/transferPauseLatch");
assert.equal(isTransferPauseLatched("file-slow"), false, "latches must be clear after resume wins");
resetTransferPauseLatchesForTests();
});
test("async folder soft-drain does not re-pause after immediate resume", async (t) => {
let resolveChildPause!: (value: { success: boolean; checkpointBytes?: number }) => void;
const childPauseGate = new Promise<{ success: boolean; checkpointBytes?: number }>((resolve) => {
resolveChildPause = resolve;
});
const resumeCalls: string[] = [];
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
pauseTransfer: async (id: string) => {
if (id === "child-slow") return childPauseGate;
return { success: true, checkpointBytes: 1 };
},
resumeTransfer: async (id: string) => {
resumeCalls.push(id);
return { success: true };
},
},
},
});
const store = createSftpTransferCenterStore();
store.publishOwner("gone-panel", [
{
...makeTask("dir-race", "transferring"),
isDirectory: true,
progressMode: "files",
transferredBytes: 0,
totalBytes: 2,
},
{
...makeTask("child-slow", "transferring"),
parentTaskId: "dir-race",
transferredBytes: 10,
totalBytes: 100,
},
]);
// Pause returns immediately (async soft-drain).
await store.pause("dir-race");
assert.equal(store.getSnapshot().tasks.find((row) => row.id === "dir-race")?.status, "paused");
// User hits resume before soft-drain finishes — full store resume (bumps epoch
// + releases latch tree), which is what the UI does.
await store.resume("dir-race");
assert.equal(store.getSnapshot().tasks.find((row) => row.id === "dir-race")?.status, "transferring");
// Soft-drain pause finally resolves after resume.
resolveChildPause({ success: true, checkpointBytes: 10 });
await new Promise((resolve) => setTimeout(resolve, 30));
const dir = store.getSnapshot().tasks.find((row) => row.id === "dir-race");
const child = store.getSnapshot().tasks.find((row) => row.id === "child-slow");
assert.equal(dir?.status, "transferring", "late soft-drain must not re-paint parent paused");
assert.equal(child?.status, "transferring", "late soft-drain must not re-paint child paused");
// Bridge pause that landed after resume must be undone.
assert.ok(resumeCalls.includes("child-slow"), "must resumeTransfer the child after superseded pause");
});
test("orphan directory pause stays latched even when some children hard-miss pause", async (t) => {
const pauseCalls: string[] = [];
const resumeCalls: string[] = [];
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
pauseTransfer: async (id: string) => {
pauseCalls.push(id);
if (id === "c-ok") return { success: true, checkpointBytes: 4 };
return { success: false, reason: "This transfer cannot be paused safely" };
},
resumeTransfer: async (id: string) => {
resumeCalls.push(id);
return { success: true };
},
},
},
});
const store = createSftpTransferCenterStore();
store.publishOwner("dedicated-resume", [
{
...makeTask("dir", "transferring"),
isDirectory: true,
ownerId: "dedicated-resume",
direction: "download",
sourceHostId: "host-a",
targetConnectionId: "local",
totalBytes: 2,
transferredBytes: 0,
},
{
...makeTask("c-ok", "transferring"),
parentTaskId: "dir",
ownerId: "dedicated-resume",
},
{
...makeTask("c-fail", "transferring"),
parentTaskId: "dir",
ownerId: "dedicated-resume",
},
]);
await store.pause("dir");
// Folder pause is async: UI is paused immediately; soft-drain may still be running.
const dir = store.getSnapshot().tasks.find((task) => task.id === "dir");
assert.equal(dir?.status, "paused", "folder parent must paint paused without waiting on children");
assert.equal(store.getSnapshot().tasks.find((task) => task.id === "c-ok")?.status, "paused");
assert.equal(store.getSnapshot().tasks.find((task) => task.id === "c-fail")?.status, "paused");
// Soft-drain is fire-and-forget — give it a tick to hit the bridge.
await new Promise((resolve) => setTimeout(resolve, 20));
assert.deepEqual(pauseCalls.sort(), ["c-fail", "c-ok"]);
// Latch-first: do not bridge-resume partial successes (that re-opened the queue).
assert.deepEqual(resumeCalls, []);
assert.equal(
store.getSnapshot().tasks.find((task) => task.id === "dir")?.status,
"paused",
"hard-miss soft-drain must not demote folder out of paused",
);
});
test("dedicated resume source-changed marks attention and can reset checkpoint", async () => {
const store = createSftpTransferCenterStore();
store.publishOwner("closed-panel", [{
...makeTask("src-changed", "interrupted"),
direction: "download",
sourceHostId: "host-a",
targetConnectionId: "local",
checkpointBytes: 40,
transferredBytes: 40,
totalBytes: 100,
reconnectRequired: true,
}]);
store.setDedicatedResumeHandler(async () => ({
success: false,
needsAttention: true,
resetCheckpoint: true,
error: "Source was modified while the transfer was paused",
}));
await store.resume("src-changed");
const task = store.getSnapshot().tasks[0];
assert.equal(task?.status, "attention");
assert.equal(task?.retryable, true);
assert.equal(task?.checkpointBytes, 0);
assert.equal(task?.transferredBytes, 0);
assert.match(task?.error ?? "", /modified/i);
});
test("reconnectRequired resume skips a retained panel that cannot adopt", async () => {
const store = createSftpTransferCenterStore();
const ownerCalls: string[] = [];
store.registerOwner("stale-panel", {
pause: async () => {},
resume: async (id) => { ownerCalls.push(`resume:${id}`); },
cancel: async () => {},
retry: async () => {},
prioritize: async () => {},
dismiss: () => {},
canAdopt: () => false,
});
store.publishOwner("stale-panel", [{
...makeTask("stuck", "attention"),
direction: "download",
sourceHostId: "host-a",
sourceHostLabel: "CI-Build-01",
targetConnectionId: "local",
reconnectRequired: true,
error: "Reconnect the source and target before resuming",
}]);
let dedicated = false;
store.setDedicatedResumeHandler(async () => {
dedicated = true;
return { success: true };
});
await store.resume("stuck");
assert.equal(dedicated, true);
assert.deepEqual(ownerCalls, []);
assert.equal(store.getSnapshot().tasks[0]?.status, "completed");
});
test("resume marks orphaned tasks pending while reconnecting", async (t) => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
dispatchEvent: () => true,
netcatty: {
resumeTransfer: async () => ({ success: false }),
},
},
});
const store = createSftpTransferCenterStore();
store.publishOwner("closed-panel", [{
...makeTask("reconnect-me", "interrupted"),
sourceHostId: "host-a",
reconnectRequired: true,
}]);
const resumePromise = store.resume("reconnect-me");
// Status flips to pending before the long prepare wait finishes.
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(store.getSnapshot().tasks[0]?.status, "pending");
assert.equal(store.getSnapshot().tasks[0]?.reconnectRequired, true);
// Unblock prepare loop by cancelling.
await store.cancel("reconnect-me");
await resumePromise;
assert.equal(store.getSnapshot().tasks[0]?.status, "cancelled");
});
test("first dedicated resume progress leaves reconnecting state while transfer keeps running", async () => {
const store = createSftpTransferCenterStore();
store.publishOwner("closed-panel", [{
...makeTask("restart-resume", "interrupted"),
sourceHostId: "host-a",
checkpointBytes: 2,
transferredBytes: 2,
reconnectRequired: true,
}]);
let releaseResume!: () => void;
const resumeHeld = new Promise<void>((resolve) => {
releaseResume = resolve;
});
let publishProgress!: () => void;
const progressPublished = new Promise<void>((resolve) => {
publishProgress = resolve;
});
store.setDedicatedResumeHandler(async (task) => {
store.ingestBackgroundEvent({
type: "started",
transferId: task.id,
direction: "upload",
transferred: 2,
totalBytes: 10,
resumable: true,
});
store.ingestBackgroundEvent({
type: "progress",
transferId: task.id,
direction: "upload",
transferred: 4,
totalBytes: 10,
speed: 2,
checkpointBytes: 4,
lifecycleState: "transferring",
resumable: true,
});
publishProgress();
await resumeHeld;
return { success: true };
});
const resumePromise = store.resume("restart-resume");
await progressPublished;
try {
const live = store.getSnapshot().tasks[0];
assert.equal(live?.status, "transferring");
assert.equal(live?.transferredBytes, 4);
assert.equal(
live?.reconnectRequired,
false,
"real progress must replace the reconnect spinner with live pause controls",
);
} finally {
releaseResume();
await resumePromise;
}
});
test("resume waits for a transfer panel that becomes visible after the click", async (t) => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { dispatchEvent: () => true },
});
const calls: string[] = [];
const store = createSftpTransferCenterStore();
store.publishOwner("closed-panel", [{
...makeTask("waiting", "paused"),
sourceHostId: "host-a",
}]);
const resumePromise = store.resume("waiting");
setTimeout(() => {
store.registerOwner("visible-panel", {
pause: async () => {},
resume: async (id) => { calls.push(`resume:${id}`); },
cancel: async () => {}, retry: async () => {}, prioritize: async () => {}, dismiss: () => {},
canAdopt: () => true,
canPrepareAdoption: true,
adopt: async (task) => { calls.push(`adopt:${task.id}`); },
});
}, 10);
await resumePromise;
assert.deepEqual(calls, ["adopt:waiting"]);
assert.equal(store.getSnapshot().tasks[0]?.ownerId, "visible-panel");
});
test("an interrupted task without its old controller can still be cancelled", async () => {
const store = createSftpTransferCenterStore();
store.publishOwner("closed-panel", [makeTask("interrupted", "interrupted")]);
await store.cancel("interrupted");
assert.equal(store.getSnapshot().tasks[0]?.status, "cancelled");
});
test("concurrent resume clicks adopt a task only once", async () => {
let adoptCount = 0;
const store = createSftpTransferCenterStore();
store.publishOwner("closed-panel", [{
...makeTask("resume-once", "interrupted"),
sourceHostId: "host-a",
}]);
store.registerOwner("visible-panel", {
pause: async () => {}, resume: async () => {}, cancel: async () => {}, retry: async () => {}, prioritize: async () => {}, dismiss: () => {},
canAdopt: () => true,
adopt: async () => { adoptCount += 1; },
});
await Promise.all([store.resume("resume-once"), store.resume("resume-once")]);
assert.equal(adoptCount, 1);
});
test("cancelling while resume waits prevents later adoption", async (t) => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { dispatchEvent: () => true },
});
let adoptCount = 0;
const store = createSftpTransferCenterStore();
store.publishOwner("closed-panel", [{
...makeTask("cancel-waiting", "paused"),
sourceHostId: "host-a",
}]);
const resumePromise = store.resume("cancel-waiting");
setTimeout(() => { void store.cancel("cancel-waiting"); }, 10);
setTimeout(() => {
store.registerOwner("visible-panel", {
pause: async () => {}, resume: async () => {}, cancel: async () => {}, retry: async () => {}, prioritize: async () => {}, dismiss: () => {},
canAdopt: () => true,
canPrepareAdoption: true,
adopt: async () => { adoptCount += 1; },
});
}, 20);
await resumePromise;
assert.equal(adoptCount, 0);
assert.equal(store.getSnapshot().tasks[0]?.status, "cancelled");
});
test("patchTask freezes transferredBytes while paused or latched (runtime soft-drain)", async () => {
const {
latchTransferPause,
releaseTransferPause,
resetTransferPauseLatchesForTests,
} = await import("./sftp/transferPauseLatch");
resetTransferPauseLatchesForTests();
const store = createSftpTransferCenterStore();
store.publishOwner("panel-a", [{
...makeTask("live", "transferring"),
transferredBytes: 1000,
totalBytes: 10_000,
checkpointBytes: 1000,
lifecycleEpoch: 1,
}]);
// User pause paints + latches (soft-control).
latchTransferPause("live");
store.patchTask("live", { status: "paused", speed: 0, lifecycleEpoch: 2 });
assert.equal(store.getSnapshot().tasks[0]?.status, "paused");
// Soft-drain still reports higher bytes via runtime writer.
store.patchTask("live", {
transferredBytes: 9000,
speed: 50,
checkpointBytes: 9000,
});
const afterDrain = store.getSnapshot().tasks[0];
assert.equal(afterDrain?.status, "paused");
assert.equal(afterDrain?.transferredBytes, 1000, "visible bar must freeze after pause");
assert.equal(afterDrain?.speed, 0);
assert.equal(afterDrain?.checkpointBytes, 9000, "durable checkpoint may still advance");
releaseTransferPause("live");
// Explicit resume with epoch may reopen and accept progress.
store.patchTask("live", {
status: "transferring",
lifecycleEpoch: 3,
transferredBytes: 1500,
speed: 10,
});
assert.equal(store.getSnapshot().tasks[0]?.status, "transferring");
assert.equal(store.getSnapshot().tasks[0]?.transferredBytes, 1500);
resetTransferPauseLatchesForTests();
});
test("unchanged sourceFingerprint on progress patches coalesces listener notifies", async () => {
const store = createSftpTransferCenterStore();
store.publishOwner("panel-a", [{
...makeTask("fp-live", "transferring"),
transferredBytes: 0,
totalBytes: 100 * 1024 * 1024,
sourceFingerprint: "sha256:stable",
}]);
let progressNotifies = 0;
let lifecycleNotifies = 0;
store.subscribe(() => {
lifecycleNotifies += 1;
});
store.subscribeProgress(() => {
progressNotifies += 1;
});
for (let i = 1; i <= 80; i += 1) {
store.patchTask("fp-live", {
transferredBytes: i * 1024 * 1024,
speed: 40 * 1024 * 1024,
status: "transferring",
// Progress IPC always re-sends the current fingerprint once computed.
sourceFingerprint: "sha256:stable",
});
}
// All 80 patches are sync; rAF/setTimeout(16) should collapse to one notify.
await new Promise((resolve) => setTimeout(resolve, 50));
assert.equal(lifecycleNotifies, 0, "pure progress must not wake lifecycle subscribers");
assert.ok(
progressNotifies <= 3,
`unchanged fingerprint must not bypass progress coalesce (notifies=${progressNotifies})`,
);
assert.equal(store.getSnapshot().tasks[0]?.transferredBytes, 80 * 1024 * 1024);
progressNotifies = 0;
lifecycleNotifies = 0;
store.patchTask("fp-live", {
transferredBytes: 81 * 1024 * 1024,
sourceFingerprint: "sha256:rotated",
});
assert.equal(lifecycleNotifies, 1, "a real fingerprint change still notifies lifecycle immediately");
assert.equal(store.getSnapshot().tasks[0]?.sourceFingerprint, "sha256:rotated");
});
test("pure progress patches do not synchronously persist transfer history", async () => {
let writes = 0;
const store = createSftpTransferCenterStore({
read: () => null,
write: () => { writes += 1; },
});
store.publishOwner("panel-a", [{
...makeTask("no-progress-persist", "transferring"),
transferredBytes: 0,
totalBytes: 100,
}]);
const writesAfterPublish = writes;
for (let i = 1; i <= 40; i += 1) {
store.patchTask("no-progress-persist", {
transferredBytes: i,
speed: 10,
status: "transferring",
});
}
await new Promise((resolve) => setTimeout(resolve, 700));
assert.equal(
writes,
writesAfterPublish,
"byte progress must not touch localStorage (spinner hitch / CPU)",
);
assert.equal(store.getSnapshot().tasks[0]?.transferredBytes, 40);
});
test("publishOwner while latched does not raise transferredBytes after pause", async () => {
const {
latchTransferPauseTree,
resetTransferPauseLatchesForTests,
} = await import("./sftp/transferPauseLatch");
resetTransferPauseLatchesForTests();
const store = createSftpTransferCenterStore();
store.publishOwner("panel-a", [{
...makeTask("file", "transferring"),
transferredBytes: 5000,
totalBytes: 20_000,
lifecycleEpoch: 1,
}]);
latchTransferPauseTree("file", []);
store.publishOwner("panel-a", [{
...makeTask("file", "paused"),
transferredBytes: 5000,
totalBytes: 20_000,
speed: 0,
lifecycleEpoch: 2,
}]);
// Soft-drain panel snapshot tries to push the bar.
store.publishOwner("panel-a", [{
...makeTask("file", "paused"),
transferredBytes: 18_000,
totalBytes: 20_000,
speed: 0,
lifecycleEpoch: 2,
}]);
assert.equal(
store.getSnapshot().tasks[0]?.transferredBytes,
5000,
"latched paused row must not take soft-drain higher bytes from panel",
);
resetTransferPauseLatchesForTests();
});
test("soft-resume transient bridge miss stays paused without dedicated rehome", async (t) => {
// Regression: pause→resume was flipping "reconnecting" then stuck paused, and
// the SFTP panel queue lost the row because soft-fail always hard-reconnected.
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
resumeTransfer: async () => ({
success: false,
reason: "The current file is still finishing. Try resume again.",
}),
clearPendingTransferCancel: async () => {},
},
},
});
const store = createSftpTransferCenterStore();
let dedicated = 0;
store.setDedicatedResumeHandler(async () => {
dedicated += 1;
return { success: false, error: "should not hard reconnect" };
});
store.registerOwner("panel-a", {
pause: async () => {},
resume: async () => {},
cancel: async () => {},
retry: async () => {},
prioritize: async () => {},
dismiss: () => {},
ownsTask: () => true,
});
store.publishOwner("panel-a", [{
...makeTask("live-soft-miss", "paused"),
sourceHostId: "host-a",
transferredBytes: 2_000_000,
checkpointBytes: 2_000_000,
ownerId: "panel-a",
}]);
await store.resume("live-soft-miss");
const row = store.getSnapshot().tasks.find((task) => task.id === "live-soft-miss");
assert.equal(dedicated, 0, "must not rehome to dedicated-resume for transient soft-miss");
assert.equal(row?.status, "paused");
assert.equal(row?.ownerId, "panel-a", "panel queue must keep the task");
assert.equal(row?.reconnectRequired, false);
assert.match(row?.error || "", /still finishing/i);
});
test("soft-resume failure demotes and uses dedicated handler even with a live owner", async (t) => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
resumeTransfer: async () => ({ success: false, reason: "not active" }),
clearPendingTransferCancel: async () => {},
},
},
});
const store = createSftpTransferCenterStore();
let dedicated = 0;
store.setDedicatedResumeHandler(async (task) => {
dedicated += 1;
store.patchTask(task.id, {
status: "completed",
ownerId: "dedicated-resume",
transferredBytes: task.totalBytes || 10,
reconnectRequired: false,
});
return { success: true };
});
// Live owner present but cannot soft-resume a dead stream.
store.registerOwner("panel-a", {
pause: async () => {},
resume: async () => {},
cancel: async () => {},
retry: async () => {},
prioritize: async () => {},
dismiss: () => {},
ownsTask: () => true,
canAdopt: () => false,
});
store.publishOwner("panel-a", [{
...makeTask("dead-soft", "paused"),
sourceHostId: "host-a",
transferredBytes: 4,
checkpointBytes: 4,
}]);
await store.resume("dead-soft");
assert.equal(dedicated, 1, "must not silent-return when soft fails with owner present");
assert.equal(store.getSnapshot().tasks[0]?.status, "completed");
});
test("single-file soft-resume bridge-fail with live walk goes hard reconnect (not stuck transferring)", async (t) => {
const {
registerTransferWalk,
unregisterTransferWalk,
resetTransferWalkRegistryForTests,
} = await import("./sftp/transferWalkRegistry");
const { resetTransferPauseLatchesForTests } = await import("./sftp/transferPauseLatch");
resetTransferWalkRegistryForTests();
resetTransferPauseLatchesForTests();
registerTransferWalk("stuck-file");
t.after(() => {
unregisterTransferWalk("stuck-file");
resetTransferWalkRegistryForTests();
resetTransferPauseLatchesForTests();
});
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
pauseTransfer: async () => ({ success: true, checkpointBytes: 10, lifecycleEpoch: 1 }),
resumeTransfer: async () => ({ success: false, reason: "not active" }),
clearPendingTransferCancel: async () => {},
},
},
});
const store = createSftpTransferCenterStore();
let dedicated = 0;
store.setDedicatedResumeHandler(async (task) => {
dedicated += 1;
store.patchTask(task.id, {
status: "completed",
ownerId: "dedicated-resume",
transferredBytes: 100,
reconnectRequired: false,
});
return { success: true };
});
store.publishOwner("panel-a", [{
...makeTask("stuck-file", "transferring"),
sourceHostId: "host-a",
transferredBytes: 10,
totalBytes: 100,
}]);
await store.pause("stuck-file");
assert.equal(store.getSnapshot().tasks[0]?.status, "paused");
await store.resume("stuck-file");
assert.equal(dedicated, 1, "hard reconnect must run when soft bridge resume fails");
const row = store.getSnapshot().tasks[0];
assert.equal(row?.status, "completed");
assert.notEqual(row?.status, "transferring");
});
test("ingestBackgroundEvent progress advances after soft pause then soft resume (bridge epochs)", async (t) => {
const { resetTransferPauseLatchesForTests } = await import("./sftp/transferPauseLatch");
const { resetTransferControlEpochsForTests } = await import("./sftp/transferControlEpoch");
resetTransferPauseLatchesForTests();
resetTransferControlEpochsForTests();
let bridgeEpoch = 0;
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
resetTransferPauseLatchesForTests();
resetTransferControlEpochsForTests();
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
pauseTransfer: async () => {
// First pause bumps; second already-paused returns same epoch (bridge behavior).
if (bridgeEpoch === 0) bridgeEpoch = 1;
return { success: true, checkpointBytes: 10, lifecycleEpoch: bridgeEpoch };
},
resumeTransfer: async () => {
bridgeEpoch += 1;
return { success: true, lifecycleEpoch: bridgeEpoch };
},
clearPendingTransferCancel: async () => {},
},
},
});
const store = createSftpTransferCenterStore();
store.publishOwner("panel-a", [{
...makeTask("prog", "transferring"),
transferredBytes: 10,
totalBytes: 100,
speed: 5,
}]);
// Double soft-pause then resume (reproduces control-plane vs bridge epoch skew).
await store.pause("prog");
await store.pause("prog");
await store.resume("prog");
const afterResume = store.getSnapshot().tasks.find((t) => t.id === "prog");
assert.equal(afterResume?.status, "transferring");
assert.equal(afterResume?.lifecycleEpoch, bridgeEpoch, "store must track bridge epoch after soft resume");
// Bridge-shaped progress at bridge epoch must advance the bar (not stale-dropped).
store.ingestBackgroundEvent({
type: "progress",
transferId: "prog",
transferred: 70,
totalBytes: 100,
speed: 20,
lifecycleEpoch: bridgeEpoch,
lifecycleState: "transferring",
});
const afterProgress = store.getSnapshot().tasks.find((t) => t.id === "prog");
assert.equal(afterProgress?.transferredBytes, 70, "progress after soft resume must move the bar");
assert.equal(afterProgress?.status, "transferring");
assert.ok((afterProgress?.speed ?? 0) > 0);
});
test("directory soft resume then new/queued child progress at bridge epoch 0 advances (no dual-epoch freeze)", async (t) => {
const {
registerTransferWalk,
unregisterTransferWalk,
resetTransferWalkRegistryForTests,
} = await import("./sftp/transferWalkRegistry");
const { resetTransferPauseLatchesForTests } = await import("./sftp/transferPauseLatch");
const { resetTransferControlEpochsForTests } = await import("./sftp/transferControlEpoch");
resetTransferWalkRegistryForTests();
resetTransferPauseLatchesForTests();
resetTransferControlEpochsForTests();
registerTransferWalk("dir-prog");
t.after(() => {
unregisterTransferWalk("dir-prog");
resetTransferWalkRegistryForTests();
resetTransferPauseLatchesForTests();
resetTransferControlEpochsForTests();
});
let liveEpoch = 0;
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
pauseTransfer: async (id: string) => {
if (id === "live-child") {
liveEpoch = Math.max(liveEpoch, 1);
return { success: true, checkpointBytes: 50, lifecycleEpoch: liveEpoch };
}
return { success: false, reason: "not active" };
},
resumeTransfer: async (id: string) => {
if (id === "live-child") {
liveEpoch += 1;
return { success: true, lifecycleEpoch: liveEpoch };
}
return { success: false, reason: "not active" };
},
clearPendingTransferCancel: async () => {},
},
},
});
const store = createSftpTransferCenterStore();
store.publishOwner("panel-a", [
{
...makeTask("dir-prog", "transferring"),
isDirectory: true,
progressMode: "files",
totalBytes: 3,
transferredBytes: 1,
ownerId: "panel-a",
},
{
...makeTask("live-child", "transferring"),
parentTaskId: "dir-prog",
transferredBytes: 50,
totalBytes: 200,
ownerId: "panel-a",
},
{
...makeTask("queued-child", "queued"),
parentTaskId: "dir-prog",
transferredBytes: 0,
totalBytes: 100,
ownerId: "panel-a",
},
]);
await store.pause("dir-prog");
await store.resume("dir-prog");
const afterResume = store.getSnapshot().tasks;
assert.equal(afterResume.find((t) => t.id === "live-child")?.lifecycleEpoch, liveEpoch);
assert.equal(
afterResume.find((t) => t.id === "queued-child")?.lifecycleEpoch,
undefined,
"queued sibling must not inherit live-child resume epoch",
);
// Simulate next file arming at bridge epoch 0 (startStreamTransfer default).
store.upsertTasks([{
...makeTask("new-child", "transferring"),
parentTaskId: "dir-prog",
transferredBytes: 0,
totalBytes: 80,
lifecycleEpoch: undefined,
ownerId: "panel-a",
}]);
store.ingestBackgroundEvent({
type: "progress",
transferId: "new-child",
transferred: 40,
totalBytes: 80,
speed: 12,
lifecycleEpoch: 0,
lifecycleState: "transferring",
});
const newChild = store.getSnapshot().tasks.find((t) => t.id === "new-child");
assert.equal(newChild?.transferredBytes, 40, "new child progress at bridge epoch 0 must advance");
assert.equal(newChild?.status, "transferring");
// Queued sibling also starts a stream at epoch 0 after resume.
store.ingestBackgroundEvent({
type: "progress",
transferId: "queued-child",
transferred: 25,
totalBytes: 100,
speed: 8,
lifecycleEpoch: 0,
lifecycleState: "transferring",
});
// Soft resume left queued status; progress with transferring lifecycle should open bar.
const queued = store.getSnapshot().tasks.find((t) => t.id === "queued-child");
assert.equal(queued?.transferredBytes, 25, "queued sibling progress at epoch 0 must not be stale-dropped");
});
test("large directory progress coalesces synchronous persistence work", async () => {
let writes = 0;
let writtenCharacters = 0;
const store = createSftpTransferCenterStore({
read: () => null,
write(value) {
writes += 1;
writtenCharacters += value.length;
},
});
const parent = {
...makeTask("large-folder"),
isDirectory: true,
progressMode: "files" as const,
totalBytes: 2_000,
transferredBytes: 0,
};
let tasks: TransferTask[] = [
parent,
...Array.from({ length: 2_000 }, (_, index) => ({
...makeTask(`large-child-${index}`, index < 1_000 ? "completed" : "queued"),
parentTaskId: parent.id,
startTime: index + 2,
})),
];
store.publishOwner("panel-a", tasks);
const oneSnapshotCharacters = writtenCharacters;
for (let tick = 1; tick <= 24; tick += 1) {
tasks = tasks.map((task) => task.id === parent.id
? { ...task, transferredBytes: 1_000 + tick }
: task);
store.publishOwner("panel-a", tasks);
}
await new Promise((resolve) => setTimeout(resolve, 300));
assert.ok(writes <= 3, `expected at most 3 storage writes, got ${writes}`);
assert.ok(
writtenCharacters <= oneSnapshotCharacters * 3,
`expected bounded persistence volume, wrote ${writtenCharacters} characters for a ${oneSnapshotCharacters}-character snapshot`,
);
});
test("store keeps 50,000 completed directory files as one bounded persisted parent", () => {
let persisted = "";
const store = createSftpTransferCenterStore({
read: () => null,
write(value) { persisted = value; },
});
const startedAt = Date.now();
const parent: TransferTask = {
...makeTask("bounded-directory"),
isDirectory: true,
progressMode: "files",
totalBytes: 50_000,
transferredBytes: 50_000,
};
store.publishOwner("panel-a", [
parent,
...Array.from({ length: 50_000 }, (_, index): TransferTask => ({
...makeTask(`bounded-child-${index}`, "completed"),
parentTaskId: parent.id,
sourcePath: `/source/file-${index.toString().padStart(5, "0")}`,
targetPath: `/target/file-${index.toString().padStart(5, "0")}`,
sourceLastModified: index + 1,
endTime: Date.now(),
})),
]);
const snapshot = store.getSnapshot();
const restored = createSftpTransferCenterStore({ read: () => persisted, write: () => {} });
assert.equal(snapshot.tasks.length, 1);
assert.equal(snapshot.tasks[0]?.directoryResumeCheckpoint?.completedEntries, 50_000);
assert.equal(restored.getSnapshot().tasks.length, 1);
assert.ok(persisted.length < 10_000, "localStorage payload must stay bounded");
assert.ok(Date.now() - startedAt < 5_000, "full store compaction should finish within 5 seconds");
});
test("legacy 50,000-file history restores cooperatively and converges to one persisted row", async () => {
const now = Date.now();
const parent: TransferTask = {
...makeTask("legacy-bounded-directory", "paused"),
isDirectory: true,
progressMode: "files",
totalBytes: 50_000,
transferredBytes: 50_000,
};
const legacyRaw = JSON.stringify({
version: 1,
tasks: [
parent,
...Array.from({ length: 50_000 }, (_, index): TransferTask => ({
...makeTask(`legacy-bounded-child-${index}`, "completed"),
parentTaskId: parent.id,
sourcePath: `/source/file-${index.toString().padStart(5, "0")}`,
targetPath: `/target/file-${index.toString().padStart(5, "0")}`,
sourceLastModified: index + 1,
endTime: now,
})),
],
});
let persisted = legacyRaw;
const writes: string[] = [];
let eventLoopHeartbeats = 0;
const heartbeat = setInterval(() => { eventLoopHeartbeats += 1; }, 1);
const createStartedAt = performance.now();
const store = createSftpTransferCenterStore({
read: () => persisted,
write(value) {
writes.push(value);
persisted = value;
},
});
const createElapsedMs = performance.now() - createStartedAt;
try {
assert.equal(store.getSnapshot().tasks.length, 0, "large restore must not block initial store creation");
assert.equal(persisted, legacyRaw, "legacy data must remain untouched until migration succeeds");
assert.ok(createElapsedMs < 50, `store creation should be fast, took ${createElapsedMs.toFixed(1)}ms`);
await new Promise<void>((resolve, reject) => {
let unsubscribe = () => {};
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error("timed out waiting for legacy transfer history migration"));
}, 10_000);
const check = () => {
const restoredParent = store.getTask(parent.id);
if (restoredParent?.directoryResumeCheckpoint?.completedEntries !== 50_000) return;
clearTimeout(timeout);
unsubscribe();
resolve();
};
unsubscribe = store.subscribe(check);
check();
});
} finally {
clearInterval(heartbeat);
}
assert.ok(
eventLoopHeartbeats >= 5,
`legacy migration must yield repeatedly, observed ${eventLoopHeartbeats} heartbeats`,
);
assert.equal(store.getSnapshot().tasks.length, 1);
assert.equal(writes.length, 1, "migration should publish one final compacted snapshot");
assert.equal(JSON.parse(persisted).tasks.length, 1, "persisted history must converge to one row");
assert.ok(persisted.length < 10_000, "migrated localStorage payload must stay bounded");
});
test("live mutations wait for cooperative restore and merge without erasing old rows", async () => {
const legacyOnly = makeTask("legacy-only", "paused");
const staleCollision = makeTask("collision", "paused");
const legacyRaw = JSON.stringify({
version: 1,
padding: "x".repeat(600_000),
tasks: [legacyOnly, staleCollision],
});
let persisted = legacyRaw;
const writes: string[] = [];
const store = createSftpTransferCenterStore({
read: () => persisted,
write(value) {
writes.push(value);
persisted = value;
},
});
const liveCollision = {
...makeTask("collision", "transferring"),
transferredBytes: 9,
};
store.publishOwner("live-owner", [liveCollision]);
assert.equal(writes.length, 0, "pending migration must hold live persistence writes");
assert.equal(persisted, legacyRaw, "pending migration must not replace the source snapshot");
await new Promise<void>((resolve, reject) => {
let unsubscribe = () => {};
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error("timed out waiting for cooperative transfer merge"));
}, 2_000);
const check = () => {
if (!store.getTask(legacyOnly.id)) return;
clearTimeout(timeout);
unsubscribe();
resolve();
};
unsubscribe = store.subscribe(check);
check();
});
assert.equal(store.getTask(legacyOnly.id)?.status, "interrupted");
assert.equal(store.getTask(liveCollision.id)?.status, "transferring");
assert.equal(store.getTask(liveCollision.id)?.transferredBytes, 9);
assert.equal(writes.length, 1);
assert.deepEqual(
JSON.parse(persisted).tasks.map((task: TransferTask) => task.id).sort(),
[legacyOnly.id, liveCollision.id].sort(),
);
});
test("background lifecycle events received during cooperative restore are applied after the snapshot", async () => {
const restoredTask = {
...makeTask("restore-event-race", "transferring"),
ownerId: "background-agent",
transferredBytes: 4,
checkpointBytes: 4,
};
const legacyRaw = JSON.stringify({
version: 1,
padding: "x".repeat(600_000),
tasks: [restoredTask],
});
let persisted = legacyRaw;
const store = createSftpTransferCenterStore({
read: () => persisted,
write(value) { persisted = value; },
});
store.ingestBackgroundEvent({
type: "completed",
transferId: restoredTask.id,
transferred: 10,
totalBytes: 10,
endedAt: Date.now(),
});
await new Promise<void>((resolve, reject) => {
let unsubscribe = () => {};
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error("timed out waiting for cooperative lifecycle replay"));
}, 2_000);
const check = () => {
if (store.getTask(restoredTask.id)?.status !== "completed") return;
clearTimeout(timeout);
unsubscribe();
resolve();
};
unsubscribe = store.subscribe(check);
check();
});
const completed = store.getTask(restoredTask.id);
assert.equal(completed?.status, "completed");
assert.equal(completed?.reconnectRequired, false, "completion must clear stale reconnect metadata");
assert.equal(JSON.parse(persisted).tasks[0]?.status, "completed");
});
test("cooperative restore replays lifecycle order without regressing byte watermarks", async () => {
const restoredTask = {
...makeTask("restore-progress-race", "transferring"),
ownerId: "background-agent",
transferredBytes: 2,
checkpointBytes: 2,
lifecycleEpoch: 1,
};
const legacyRaw = JSON.stringify({
version: 1,
padding: "x".repeat(600_000),
tasks: [restoredTask],
});
const store = createSftpTransferCenterStore({ read: () => legacyRaw, write: () => {} });
let notifications = 0;
const unsubscribe = store.subscribe(() => { notifications += 1; });
store.ingestBackgroundEvent({
type: "started",
transferId: restoredTask.id,
lifecycleEpoch: 2,
totalBytes: 20,
});
store.ingestBackgroundEvent({
type: "progress",
transferId: restoredTask.id,
transferred: 12,
totalBytes: 20,
checkpointBytes: 10,
lifecycleEpoch: 2,
lifecycleState: "transferring",
});
store.ingestBackgroundEvent({
type: "progress",
transferId: restoredTask.id,
transferred: 8,
totalBytes: 20,
checkpointBytes: 7,
lifecycleEpoch: 2,
lifecycleState: "transferring",
});
store.ingestBackgroundEvent({
type: "paused",
transferId: restoredTask.id,
checkpointBytes: 12,
lifecycleEpoch: 3,
});
store.ingestBackgroundEvent({
type: "progress",
transferId: restoredTask.id,
transferred: 18,
totalBytes: 20,
checkpointBytes: 18,
lifecycleEpoch: 2,
lifecycleState: "transferring",
});
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("timed out waiting for lifecycle replay")), 2_000);
const check = () => {
if (store.getTask(restoredTask.id)?.status !== "paused") return;
clearTimeout(timeout);
resolve();
};
const off = store.subscribe(() => {
check();
if (store.getTask(restoredTask.id)?.status === "paused") off();
});
check();
});
unsubscribe();
const paused = store.getTask(restoredTask.id);
assert.equal(paused?.status, "paused");
assert.equal(paused?.transferredBytes, 12, "same-epoch delayed progress must not move bytes backwards");
assert.equal(paused?.checkpointBytes, 12, "older progress must not lower the durable checkpoint");
assert.equal(paused?.lifecycleEpoch, 3);
assert.equal(notifications, 1, "migration and buffered events should publish one atomic snapshot");
});
test("completed directory history is pruned with one owner update and no reentrant dismiss storm", () => {
const store = createSftpTransferCenterStore();
const now = Date.now();
const parent = {
...makeTask("finished-folder"),
isDirectory: true,
progressMode: "files" as const,
totalBytes: 250,
transferredBytes: 250,
};
let ownerTasks: TransferTask[] = [
parent,
...Array.from({ length: 250 }, (_, index) => ({
...makeTask(`finished-child-${index}`, "completed"),
parentTaskId: parent.id,
startTime: index + 2,
endTime: now - index,
})),
];
let individualDismisses = 0;
let batchDismisses = 0;
let publishDepth = 0;
let maxPublishDepth = 0;
const republish = () => {
publishDepth += 1;
maxPublishDepth = Math.max(maxPublishDepth, publishDepth);
store.publishOwner("panel-a", ownerTasks);
publishDepth -= 1;
};
const controls = {
pause: async () => {},
resume: async () => {},
cancel: async () => {},
retry: async () => {},
prioritize: async () => {},
dismiss(id: string) {
individualDismisses += 1;
ownerTasks = ownerTasks.filter((task) => task.id !== id && task.parentTaskId !== id);
republish();
},
dismissMany(prunedTasks: readonly TransferTask[]) {
batchDismisses += 1;
const removing = new Set(prunedTasks.map((task) => task.id));
ownerTasks = ownerTasks.filter((task) => !removing.has(task.id) && !removing.has(task.parentTaskId ?? ""));
republish();
},
};
store.registerOwner("panel-a", controls);
republish();
ownerTasks = ownerTasks.map((task) => task.id === parent.id
? { ...task, status: "completed", endTime: now }
: task);
republish();
assert.equal(individualDismisses, 0);
assert.equal(batchDismisses, 1);
assert.equal(maxPublishDepth, 2);
assert.ok(store.getSnapshot().tasks.length <= 200);
});
test("storage exhaustion cannot escape into the renderer update path", () => {
const store = createSftpTransferCenterStore({
read: () => null,
write() {
throw new DOMException("quota exceeded", "QuotaExceededError");
},
});
assert.doesNotThrow(() => {
store.publishOwner("panel-a", [makeTask("quota-safe")]);
});
assert.equal(store.getSnapshot().tasks[0]?.id, "quota-safe");
});
test("patchTask lifecycle failures notify and persist through the full store path", () => {
const writes: string[] = [];
const store = createSftpTransferCenterStore({
read: () => null,
write(value) { writes.push(value); },
});
store.publishOwner("panel-a", [makeTask("external-open-failure", "completed")]);
const baselineWrites = writes.length;
assert.equal(store.getBadgeSnapshot().hasAttention, false);
let notifications = 0;
store.subscribe(() => { notifications += 1; });
store.patchTask("external-open-failure", {
status: "failed",
error: "Could not open the downloaded file",
endTime: Date.now(),
});
assert.equal(store.getSnapshot().tasks[0]?.status, "failed");
assert.equal(store.getBadgeSnapshot().hasAttention, true, "failed lifecycle must refresh the badge");
assert.equal(notifications, 1, "lifecycle subscribers must update immediately");
assert.ok(writes.length > baselineWrites, "terminal lifecycle changes must be persisted");
const persisted = JSON.parse(writes.at(-1) ?? "{}") as { tasks?: TransferTask[] };
assert.equal(persisted.tasks?.[0]?.status, "failed", "persisted history must not retain completed");
assert.equal(persisted.tasks?.[0]?.error, "Could not open the downloaded file");
});
test("clearing a changed directory checkpoint is persisted before resume continues", () => {
let persisted = "";
const store = createSftpTransferCenterStore({
read: () => null,
write(value) { persisted = value; },
});
store.publishOwner("dedicated-resume", [{
...makeTask("changed-directory", "interrupted"),
isDirectory: true,
directoryResumeCheckpoint: {
version: 1,
coveredEntries: 2,
completedEntries: 2,
manifestHash: "a".repeat(64),
},
}]);
store.patchTask("changed-directory", { directoryResumeCheckpoint: undefined });
const restored = createSftpTransferCenterStore({ read: () => persisted, write: () => {} });
assert.equal(restored.getTask("changed-directory")?.directoryResumeCheckpoint, undefined);
});
test("hard directory resume failure falls back to bounded fresh retry history", async () => {
const store = createSftpTransferCenterStore();
const parent: TransferTask = {
...makeTask("failed-directory-resume", "interrupted"),
isDirectory: true,
progressMode: "files",
checkpointBytes: 500,
reconnectRequired: true,
targetHostId: "host-a",
targetHostLabel: "box-a",
directoryResumeCheckpoint: {
version: 1,
coveredEntries: 500,
completedEntries: 0,
manifestHash: "a".repeat(64),
},
};
store.publishOwner("dedicated-resume", [parent]);
store.setDedicatedResumeHandler(async () => {
store.upsertTasks(Array.from({ length: 500 }, (_, index) => ({
...makeTask(`failed-directory-child-${index}`, "failed"),
ownerId: "dedicated-resume",
parentTaskId: parent.id,
sourcePath: `/source/file-${index}`,
targetPath: `/target/file-${index}`,
directoryEntryIndex: index,
directoryEntryIdentity: index.toString(16).padStart(64, "0"),
endTime: Date.now() - index,
})));
return { success: false, error: "500 files failed to resume" };
});
await store.resume(parent.id);
const snapshot = store.getSnapshot().tasks;
const retainedParent = snapshot.find((task) => task.id === parent.id);
assert.equal(retainedParent?.status, "failed");
assert.equal(retainedParent?.directoryResumeCheckpoint, undefined);
assert.equal(retainedParent?.checkpointBytes, 0);
assert.ok(snapshot.length <= 200, `failed directory history must stay bounded, got ${snapshot.length}`);
});
test("held dedicated file resume cannot overwrite a subsequent pause", async (t) => {
const store = createSftpTransferCenterStore();
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
let releaseRun!: () => void;
let started!: () => void;
let releaseResume!: (result: { success: boolean; lifecycleEpoch: number }) => void;
const runGate = new Promise<void>((resolve) => { releaseRun = resolve; });
const startGate = new Promise<void>((resolve) => { started = resolve; });
const resumeGate = new Promise<{ success: boolean; lifecycleEpoch: number }>((resolve) => { releaseResume = resolve; });
t.after(async () => {
releaseRun();
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
const { resetTransferPauseLatchesForTests } = await import("./sftp/transferPauseLatch");
resetTransferPauseLatchesForTests();
});
Object.defineProperty(globalThis, "window", { configurable: true, value: { netcatty: {
pauseTransfer: async () => ({ success: true, checkpointBytes: 2, lifecycleEpoch: 3 }),
resumeTransfer: () => resumeGate,
} } });
store.publishOwner("dedicated-resume", [{
...makeTask("held-file", "interrupted"),
ownerId: "dedicated-resume", targetHostId: "host-a", reconnectRequired: true,
}]);
store.setDedicatedResumeHandler(async () => {
store.patchTask("held-file", { status: "transferring", reconnectRequired: false });
started();
await runGate;
return { success: true };
});
const first = store.resume("held-file");
await startGate;
await store.pause("held-file");
const second = store.resume("held-file");
await store.pause("held-file");
releaseResume({ success: true, lifecycleEpoch: 2 });
await new Promise((resolve) => setImmediate(resolve));
const row = store.getSnapshot().tasks.find((task) => task.id === "held-file");
releaseRun();
await Promise.all([first, second]);
assert.equal(row?.status, "paused", "held transfer must respect the latest pause");
assert.equal(row?.lifecycleEpoch, 3);
});
for (const outcome of ["rejected", "stream-gone"] as const) {
test(`held file ${outcome} resume cannot restart after a newer pause during wind-down`, async (t) => {
const store = createSftpTransferCenterStore();
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
let releaseRun!: () => void;
let started!: () => void;
let resolveResume!: (result: { success: boolean; reason: string }) => void;
let rejectResume!: (error: Error) => void;
let resumeCalls = 0;
const runGate = new Promise<void>((resolve) => { releaseRun = resolve; });
const startGate = new Promise<void>((resolve) => { started = resolve; });
const resumeGate = new Promise<{ success: boolean; reason: string }>((resolve, reject) => {
resolveResume = resolve;
rejectResume = reject;
});
t.after(async () => {
releaseRun();
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
const { resetTransferPauseLatchesForTests } = await import("./sftp/transferPauseLatch");
resetTransferPauseLatchesForTests();
});
Object.defineProperty(globalThis, "window", { configurable: true, value: { netcatty: {
pauseTransfer: async () => ({ success: true, checkpointBytes: 2, lifecycleEpoch: 3 }),
resumeTransfer: () => ++resumeCalls === 1
? resumeGate
: Promise.resolve({ success: true, lifecycleEpoch: 4 }),
} } });
const id = `held-file-${outcome}`;
store.publishOwner("dedicated-resume", [{
...makeTask(id, "interrupted"),
ownerId: "dedicated-resume", targetHostId: "host-a", reconnectRequired: true,
}]);
store.setDedicatedResumeHandler(async () => {
store.patchTask(id, { status: "transferring", reconnectRequired: false });
started();
await runGate;
return { success: false, error: "Transfer cancelled" };
});
const first = store.resume(id);
await startGate;
await store.pause(id);
const second = store.resume(id);
await new Promise((resolve) => setImmediate(resolve));
if (outcome === "stream-gone") {
resolveResume({ success: false, reason: "Transfer is no longer active" });
await new Promise((resolve) => setImmediate(resolve));
}
await store.pause(id);
if (outcome === "rejected") {
rejectResume(new Error("worker channel unavailable"));
await new Promise((resolve) => setImmediate(resolve));
}
releaseRun();
await Promise.all([first, second]);
assert.equal(store.getSnapshot().tasks.find((task) => task.id === id)?.status, "paused");
assert.equal(resumeCalls, 1, "obsolete resume must not issue another resume after the held run ends");
});
}