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([["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([ ["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"); }); });