248 lines
8.6 KiB
TypeScript
248 lines
8.6 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { Readable, Writable } from "node:stream";
|
|
import { describe, expect, test } from "bun:test";
|
|
import {
|
|
ApplyPatchV2Error,
|
|
runApplyPatchV2,
|
|
type ApplyPatchV2BulkReplacementWritePlan,
|
|
type ApplyPatchV2FileSystem,
|
|
} from "./apply-patch-v2";
|
|
|
|
class CaptureWritable extends Writable {
|
|
chunks: Buffer[] = [];
|
|
|
|
_write(chunk: Buffer | string, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
|
|
this.chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
callback();
|
|
}
|
|
|
|
text(): string {
|
|
return Buffer.concat(this.chunks).toString("utf8");
|
|
}
|
|
}
|
|
|
|
function sha256Hex(text: string): string {
|
|
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
}
|
|
|
|
function applyReplacementPlan(original: string, plan: ApplyPatchV2BulkReplacementWritePlan): string {
|
|
const lines = original.split("\n");
|
|
if (lines.at(-1) === "") lines.pop();
|
|
for (const [start, oldLength, newLines] of [...plan.replacements].sort((left, right) => right[0] - left[0])) {
|
|
lines.splice(start, oldLength, ...newLines);
|
|
}
|
|
return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
describe("apply-patch v2 fs bulk update path", () => {
|
|
test("single fs update uses readFiles and applyReplacementsBulk instead of whole-file write", async () => {
|
|
const files = new Map<string, string>([["docs/test.md", "alpha\nold\nomega\n"]]);
|
|
const calls: string[] = [];
|
|
const fs: ApplyPatchV2FileSystem = {
|
|
async stat(path) {
|
|
calls.push(`stat:${path}`);
|
|
throw new Error("stat should not be used by the single-file bulk path");
|
|
},
|
|
async readBlock(path) {
|
|
calls.push(`readBlock:${path}`);
|
|
throw new Error("readBlock should not be used by the single-file bulk path");
|
|
},
|
|
async writeFile(path) {
|
|
calls.push(`writeFile:${path}`);
|
|
throw new Error("writeFile should not be used for update hunks with replacement bulk");
|
|
},
|
|
async deleteFile(path) {
|
|
calls.push(`deleteFile:${path}`);
|
|
},
|
|
async readFiles(paths) {
|
|
calls.push(`readFiles:${paths.join(",")}`);
|
|
return new Map(paths.map((path) => [path, files.get(path) ?? ""]));
|
|
},
|
|
async applyReplacementsBulk(paths, plans) {
|
|
const targets = Array.from(paths);
|
|
calls.push(`applyReplacementsBulk:${targets.join(",")}`);
|
|
for (const target of targets) {
|
|
const plan = plans.get(target);
|
|
if (plan === undefined) throw new Error(`missing plan for ${target}`);
|
|
const next = applyReplacementPlan(files.get(target) ?? "", plan);
|
|
expect(Buffer.byteLength(next, "utf8")).toBe(plan.finalBytes);
|
|
expect(sha256Hex(next)).toBe(plan.finalSha256);
|
|
files.set(target, next);
|
|
}
|
|
},
|
|
};
|
|
const stdout = new CaptureWritable();
|
|
const stderr = new CaptureWritable();
|
|
|
|
const exitCode = await runApplyPatchV2({
|
|
executor: { fs },
|
|
stdin: Readable.from([[
|
|
"*** Begin Patch",
|
|
"*** Update File: docs/test.md",
|
|
"@@",
|
|
" alpha",
|
|
"-old",
|
|
"+new",
|
|
" omega",
|
|
"*** End Patch",
|
|
].join("\n")]),
|
|
stdout,
|
|
stderr,
|
|
timing: { transport: "local", route: "D601:win/F/Work/ConStart" },
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(files.get("docs/test.md")).toBe("alpha\nnew\nomega\n");
|
|
expect(calls).toEqual(["readFiles:docs/test.md", "applyReplacementsBulk:docs/test.md"]);
|
|
expect(stdout.text()).toContain("M docs/test.md");
|
|
expect(stderr.text()).toContain("\"remoteOperationCounts\":{\"fs.readFiles\":1,\"fs.applyReplacementsBulk\":1}");
|
|
});
|
|
|
|
test("bulk read failure falls back to block reads but still writes through replacement bulk", async () => {
|
|
const files = new Map<string, string>([
|
|
["a.md", "first\nold-a\nlast\n"],
|
|
["b.md", "first\nold-b\nlast\n"],
|
|
]);
|
|
const calls: string[] = [];
|
|
const fs: ApplyPatchV2FileSystem = {
|
|
async stat(path) {
|
|
calls.push(`stat:${path}`);
|
|
const text = files.get(path);
|
|
if (text === undefined) throw new Error(`missing ${path}`);
|
|
return { bytes: Buffer.byteLength(text, "utf8"), sha256: sha256Hex(text) };
|
|
},
|
|
async readBlock(path, blockIndex, blockBytes) {
|
|
calls.push(`readBlock:${path}:${blockIndex}:${blockBytes}`);
|
|
const text = files.get(path);
|
|
if (text === undefined) throw new Error(`missing ${path}`);
|
|
return blockIndex === 0 ? Buffer.from(text, "utf8") : Buffer.alloc(0);
|
|
},
|
|
async writeFile(path) {
|
|
calls.push(`writeFile:${path}`);
|
|
throw new Error("writeFile should not be used after bulk read fallback");
|
|
},
|
|
async deleteFile(path) {
|
|
calls.push(`deleteFile:${path}`);
|
|
},
|
|
async readFiles(paths) {
|
|
calls.push(`readFiles:${paths.join(",")}`);
|
|
throw new ApplyPatchV2Error("windows apply-patch fs operation failed: read-bulk-b64", {
|
|
operation: "read-bulk-b64",
|
|
targetCount: paths.length,
|
|
remoteElapsedMs: 60000,
|
|
landed: false,
|
|
});
|
|
},
|
|
async applyReplacementsBulk(paths, plans) {
|
|
const targets = Array.from(paths);
|
|
calls.push(`applyReplacementsBulk:${targets.join(",")}`);
|
|
for (const target of targets) {
|
|
const plan = plans.get(target);
|
|
if (plan === undefined) throw new Error(`missing plan for ${target}`);
|
|
files.set(target, applyReplacementPlan(files.get(target) ?? "", plan));
|
|
}
|
|
},
|
|
};
|
|
const stdout = new CaptureWritable();
|
|
const stderr = new CaptureWritable();
|
|
|
|
const exitCode = await runApplyPatchV2({
|
|
executor: { fs },
|
|
stdin: Readable.from([[
|
|
"*** Begin Patch",
|
|
"*** Update File: a.md",
|
|
"@@",
|
|
" first",
|
|
"-old-a",
|
|
"+new-a",
|
|
" last",
|
|
"*** Update File: b.md",
|
|
"@@",
|
|
" first",
|
|
"-old-b",
|
|
"+new-b",
|
|
" last",
|
|
"*** End Patch",
|
|
].join("\n")]),
|
|
stdout,
|
|
stderr,
|
|
timing: { transport: "local", route: "D601:win/F/Work/ConStart" },
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(files.get("a.md")).toBe("first\nnew-a\nlast\n");
|
|
expect(files.get("b.md")).toBe("first\nnew-b\nlast\n");
|
|
expect(calls).toEqual([
|
|
"readFiles:a.md,b.md",
|
|
"stat:a.md",
|
|
"readBlock:a.md:0:45000",
|
|
"stat:b.md",
|
|
"readBlock:b.md:0:45000",
|
|
"applyReplacementsBulk:a.md,b.md",
|
|
]);
|
|
expect(stdout.text()).toContain("M a.md");
|
|
expect(stdout.text()).toContain("M b.md");
|
|
expect(stderr.text()).toContain("\"remoteFailureCount\":1");
|
|
});
|
|
|
|
test("remote fs failure prints operation details", async () => {
|
|
const fs: ApplyPatchV2FileSystem = {
|
|
async stat() {
|
|
throw new Error("stat should not be used");
|
|
},
|
|
async readBlock() {
|
|
throw new Error("readBlock should not be used");
|
|
},
|
|
async writeFile() {
|
|
throw new Error("writeFile should not be used");
|
|
},
|
|
async deleteFile() {
|
|
throw new Error("deleteFile should not be used");
|
|
},
|
|
async readFiles(paths) {
|
|
return new Map(paths.map((path) => [path, "alpha\nold\nomega\n"]));
|
|
},
|
|
async applyReplacementsBulk() {
|
|
throw new ApplyPatchV2Error("windows apply-patch fs operation failed: apply-replacements-bulk-stdin", {
|
|
operation: "apply-replacements-bulk-stdin",
|
|
route: "D601:win/F/Work/ConStart",
|
|
targetCount: 1,
|
|
inputBytes: 190,
|
|
expectedBytes: "22",
|
|
expectedSha256: "abc123",
|
|
remoteElapsedMs: 60000,
|
|
landed: false,
|
|
exitCode: 124,
|
|
stderrTail: "UNIDESK_SSH_RUNTIME_TIMEOUT timeoutSeconds=60",
|
|
});
|
|
},
|
|
};
|
|
const stdout = new CaptureWritable();
|
|
const stderr = new CaptureWritable();
|
|
|
|
const exitCode = await runApplyPatchV2({
|
|
executor: { fs },
|
|
stdin: Readable.from([[
|
|
"*** Begin Patch",
|
|
"*** Update File: docs/test.md",
|
|
"@@",
|
|
" alpha",
|
|
"-old",
|
|
"+new",
|
|
" omega",
|
|
"*** End Patch",
|
|
].join("\n")]),
|
|
stdout,
|
|
stderr,
|
|
timing: { transport: "local", route: "D601:win/F/Work/ConStart" },
|
|
});
|
|
|
|
expect(exitCode).toBe(1);
|
|
expect(stdout.text()).toBe("");
|
|
const err = stderr.text();
|
|
expect(err).toContain("Remote operation: operation=apply-replacements-bulk-stdin route=D601:win/F/Work/ConStart targetCount=1 inputBytes=190 expectedBytes=22 sha256=abc123 elapsedMs=60000 landed=false exitCode=124");
|
|
expect(err).toContain("Remote stderr tail:");
|
|
expect(err).toContain("UNIDESK_SSH_RUNTIME_TIMEOUT timeoutSeconds=60");
|
|
});
|
|
});
|