[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
139
application/state/sftp/directoryReplacePromotion.test.ts
Normal file
139
application/state/sftp/directoryReplacePromotion.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
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/);
|
||||
});
|
||||
Reference in New Issue
Block a user