Files
NetMesh/application/state/sftp/directoryReplacePromotion.test.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

140 lines
5.0 KiB
TypeScript

import assert from "node:assert/strict";
import fs from "node:fs";
import test from "node:test";
import { promoteDirectoryReplaceStage } from "./directoryReplacePromotion";
function createPathHarness(initialPaths: string[]) {
const paths = new Set(initialPaths);
const operations: string[] = [];
return {
paths,
operations,
statPath: async (candidate: string) => paths.has(candidate) ? { type: "directory" } : null,
renamePath: async (source: string, target: string) => {
operations.push(`rename:${source}->${target}`);
if (!paths.has(source)) throw new Error(`ENOENT: ${source}`);
if (paths.has(target)) throw new Error(`EEXIST: ${target}`);
paths.delete(source);
paths.add(target);
},
deletePath: async (candidate: string) => {
operations.push(`delete:${candidate}`);
if (!paths.delete(candidate)) throw new Error(`ENOENT: ${candidate}`);
},
};
}
const targetPath = "/target/final";
const stagedPath = "/target/final.netcatty-live.part";
const backupPath = "/target/final.netcatty-live.backup";
test("live directory replace restores an interrupted backup before retrying publication", async () => {
const harness = createPathHarness([stagedPath, backupPath]);
await promoteDirectoryReplaceStage({
targetPath,
stagedPath,
backupPath,
statPath: harness.statPath,
renamePath: harness.renamePath,
deletePath: harness.deletePath,
});
assert.equal(harness.operations[0], `rename:${backupPath}->${targetPath}`);
assert.equal(harness.paths.has(targetPath), true);
assert.equal(harness.paths.has(stagedPath), false);
assert.equal(harness.paths.has(backupPath), false);
});
test("live directory replace stops when the existing target cannot be backed up", async () => {
const harness = createPathHarness([targetPath, stagedPath]);
const renamePath = async (source: string, target: string) => {
harness.operations.push(`rename:${source}->${target}`);
if (source === targetPath && target === backupPath) throw new Error("EACCES: backup denied");
return harness.renamePath(source, target);
};
await assert.rejects(
promoteDirectoryReplaceStage({
targetPath,
stagedPath,
backupPath,
statPath: harness.statPath,
renamePath,
deletePath: harness.deletePath,
}),
/backup denied/,
);
assert.equal(harness.paths.has(targetPath), true);
assert.equal(harness.paths.has(stagedPath), true);
assert.equal(harness.paths.has(backupPath), false);
assert.equal(harness.operations.some((operation) => operation === `rename:${stagedPath}->${targetPath}`), false);
});
test("live directory replace retries transient backup cleanup", async () => {
const harness = createPathHarness([targetPath, stagedPath]);
let deleteAttempts = 0;
const deletePath = async (candidate: string) => {
deleteAttempts += 1;
if (deleteAttempts < 3) throw new Error("EBUSY: backup locked");
return harness.deletePath(candidate);
};
await promoteDirectoryReplaceStage({
targetPath,
stagedPath,
backupPath,
statPath: harness.statPath,
renamePath: harness.renamePath,
deletePath,
});
assert.equal(deleteAttempts, 3);
assert.equal(harness.paths.has(targetPath), true);
assert.equal(harness.paths.has(backupPath), false);
});
test("live directory replace keeps the committed target recoverable when backup cleanup persists", async () => {
const harness = createPathHarness([targetPath, stagedPath]);
let deleteAttempts = 0;
await assert.rejects(
promoteDirectoryReplaceStage({
targetPath,
stagedPath,
backupPath,
statPath: harness.statPath,
renamePath: harness.renamePath,
deletePath: async () => {
deleteAttempts += 1;
throw new Error("EPERM: backup retained");
},
}),
/backup retained/,
);
assert.equal(deleteAttempts, 3);
assert.equal(harness.paths.has(targetPath), true, "the new committed target remains published");
assert.equal(harness.paths.has(backupPath), true, "the old tree remains available for recovery");
assert.equal(harness.paths.has(stagedPath), false);
});
test("live and restart-resume directory replacement both call the shared promotion helper", () => {
const liveSource = fs.readFileSync(new URL("./useSftpTransfers.ts", import.meta.url), "utf8");
const resumeSource = fs.readFileSync(new URL("./dedicatedTransferResume.ts", import.meta.url), "utf8");
assert.match(liveSource, /promoteDirectoryReplacePaths\(\{/);
assert.match(resumeSource, /promoteDirectoryReplacePaths\(\{/);
});
test("live directory replacement bypasses the merge-only same-host copy shortcut", () => {
const liveSource = fs.readFileSync(new URL("./useSftpTransfers.ts", import.meta.url), "utf8");
const sameHostCopyGuard = liveSource.slice(
liveSource.indexOf("if (\n task.isDirectory"),
liveSource.indexOf("sameHostCopyDirectory!", liveSource.indexOf("if (\n task.isDirectory")),
);
assert.match(sameHostCopyGuard, /!task\.replaceExistingTarget/);
});