Files
pikasTech-unidesk/scripts/src/apply-patch-v2.test.ts
T
2026-06-26 17:05:09 +00:00

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