mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
fix(cli): undo complete timeline batch receipts atomically (#5279)
* fix(cli): undo complete timeline batch receipts atomically * test(cli): retain both timeline suites before merging main
This commit is contained in:
@@ -253,16 +253,22 @@ export async function runApply(args: Record<string, unknown>): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
export async function runUndo(args: Record<string, unknown>): Promise<void> {
|
||||
const project = resolveProject(typeof args.dir === "string" ? args.dir : undefined);
|
||||
const json = args.json === true;
|
||||
const input = typeof args.receipt === "string" ? args.receipt : positional(args)[1];
|
||||
if (!input)
|
||||
return refuse(
|
||||
"timeline undo",
|
||||
{ reason: "an undo receipt is required", fix: "pass the receipt JSON or its file" },
|
||||
json,
|
||||
);
|
||||
type UndoReceipt = { file: string; version: string; backupPath: string };
|
||||
|
||||
function isUndoReceipt(value: unknown): value is UndoReceipt {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.file === "string" &&
|
||||
typeof value.version === "string" &&
|
||||
typeof value.backupPath === "string"
|
||||
);
|
||||
}
|
||||
|
||||
type UndoInput =
|
||||
| { ok: true; receipts: UndoReceipt[]; batch: boolean }
|
||||
| { ok: false; reason: string; fix: string };
|
||||
|
||||
function readUndoInput(input: string): UndoInput {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(input, "utf-8");
|
||||
@@ -273,40 +279,64 @@ export async function runUndo(args: Record<string, unknown>): Promise<void> {
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "undo receipt is not valid JSON",
|
||||
fix: "pass the applied JSON receipt",
|
||||
};
|
||||
}
|
||||
const value = isRecord(parsed) && "receipt" in parsed ? parsed.receipt : parsed;
|
||||
const values: unknown[] = Array.isArray(value) ? value : [value];
|
||||
if (!values.every(isUndoReceipt)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "undo receipt is missing file, version, or backupPath",
|
||||
fix: "pass an applied timeline receipt",
|
||||
};
|
||||
}
|
||||
return { ok: true, receipts: values, batch: Array.isArray(value) };
|
||||
}
|
||||
|
||||
export async function runUndo(args: Record<string, unknown>): Promise<void> {
|
||||
const project = resolveProject(typeof args.dir === "string" ? args.dir : undefined);
|
||||
const json = args.json === true;
|
||||
const input = typeof args.receipt === "string" ? args.receipt : positional(args)[1];
|
||||
if (!input)
|
||||
return refuse(
|
||||
"timeline undo",
|
||||
{ reason: "undo receipt is not valid JSON", fix: "pass the applied JSON receipt" },
|
||||
{ reason: "an undo receipt is required", fix: "pass the receipt JSON or its file" },
|
||||
json,
|
||||
);
|
||||
const undo = readUndoInput(input);
|
||||
if (!undo.ok) return refuse("timeline undo", undo, json);
|
||||
const inputs = undo.receipts.map((receipt) => {
|
||||
const target = join(project.dir, receipt.file);
|
||||
return {
|
||||
sourceFile: receipt.file,
|
||||
absPath: target,
|
||||
before: readFileSync(target, "utf-8"),
|
||||
after: readFileSync(join(project.dir, receipt.backupPath), "utf-8"),
|
||||
expectedVersion: receipt.version,
|
||||
};
|
||||
});
|
||||
let receipts: AppliedFileMutation[];
|
||||
try {
|
||||
receipts = applyFileMutations(project.dir, inputs);
|
||||
} catch (error) {
|
||||
if (isFileChanged(error))
|
||||
return refusal(error.message, "read the latest timeline before retrying undo", json);
|
||||
throw error;
|
||||
}
|
||||
const value = isRecord(parsed) && isRecord(parsed.receipt) ? parsed.receipt : parsed;
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
typeof value.file !== "string" ||
|
||||
typeof value.version !== "string" ||
|
||||
typeof value.backupPath !== "string"
|
||||
) {
|
||||
return refuse(
|
||||
"timeline undo",
|
||||
{
|
||||
reason: "undo receipt is missing file, version, or backupPath",
|
||||
fix: "pass an applied timeline receipt",
|
||||
},
|
||||
json,
|
||||
);
|
||||
}
|
||||
const backup = join(project.dir, value.backupPath);
|
||||
const target = join(project.dir, value.file);
|
||||
const before = readFileSync(target, "utf-8");
|
||||
const after = readFileSync(backup, "utf-8");
|
||||
const receipts = applyFileMutations(project.dir, [
|
||||
{ sourceFile: value.file, absPath: target, before, after, expectedVersion: value.version },
|
||||
]);
|
||||
const result = {
|
||||
ok: true,
|
||||
receipt: receipts.map((receipt) => publicReceipt(receipt)),
|
||||
file: value.file,
|
||||
file: undo.batch ? undo.receipts.map((receipt) => receipt.file) : undo.receipts[0]!.file,
|
||||
};
|
||||
if (json) console.log(JSON.stringify(withMeta(result), null, 2));
|
||||
else console.log(`undid ${value.file}`);
|
||||
else
|
||||
console.log(
|
||||
undo.batch
|
||||
? `undid ${receipts.length} file${receipts.length === 1 ? "" : "s"}`
|
||||
: `undid ${undo.receipts[0]!.file}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,31 @@ function project(): string {
|
||||
return dir;
|
||||
}
|
||||
|
||||
function batchProject() {
|
||||
const dir = project();
|
||||
mkdirSync(join(dir, "compositions"));
|
||||
writeFileSync(
|
||||
join(dir, "index.html"),
|
||||
`<div data-composition-id="main" data-duration="12"><div id="clip" data-hf-id="clip" data-start="1" data-duration="2" data-track-index="0"></div><div id="scene" data-composition-id="scene" data-composition-src="compositions/scene.html" data-start="0" data-duration="4" data-track-index="1"></div></div>`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(dir, "compositions/scene.html"),
|
||||
`<div data-composition-id="scene"><div id="nested" data-hf-id="nested" data-start="0" data-duration="2" data-track-index="0"></div></div>`,
|
||||
);
|
||||
const files = ["index.html", "compositions/scene.html"];
|
||||
const sources = () => files.map((file) => readFileSync(join(dir, file), "utf8"));
|
||||
const before = sources();
|
||||
const planPath = join(dir, "edits.json");
|
||||
writeFileSync(
|
||||
planPath,
|
||||
JSON.stringify([
|
||||
{ verb: "set", ref: "#clip", volume: "0.25" },
|
||||
{ verb: "set", ref: "#nested", volume: "0.5" },
|
||||
]),
|
||||
);
|
||||
return { dir, files, sources, before, planPath };
|
||||
}
|
||||
|
||||
function run(dir: string, ...args: string[]) {
|
||||
return spawnSync(
|
||||
"bun",
|
||||
@@ -569,4 +594,106 @@ describe("timeline edit command", () => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["response", "array", "file"])(
|
||||
"undoes all files in an apply receipt passed as a %s and returns a redo receipt",
|
||||
(format) => {
|
||||
const { dir, files, sources, before, planPath } = batchProject();
|
||||
try {
|
||||
const applied = run(dir, "apply", planPath);
|
||||
expect(applied.status, applied.stderr).toBe(0);
|
||||
const appliedJson: { receipt: Array<Record<string, unknown>> } = JSON.parse(applied.stdout);
|
||||
expect(appliedJson.receipt).toHaveLength(2);
|
||||
const after = sources();
|
||||
expect(after[0]).toContain('data-volume="0.25"');
|
||||
expect(after[1]).toContain('data-volume="0.5"');
|
||||
let input = format === "array" ? JSON.stringify(appliedJson.receipt) : applied.stdout;
|
||||
if (format === "file") {
|
||||
input = join(dir, "receipt.json");
|
||||
writeFileSync(input, applied.stdout);
|
||||
}
|
||||
|
||||
const undone = run(dir, "undo", input);
|
||||
expect(undone.status, undone.stderr).toBe(0);
|
||||
expect(JSON.parse(undone.stdout)).toMatchObject({ ok: true, file: files });
|
||||
expect(sources()).toEqual(before);
|
||||
|
||||
const redone = run(dir, "undo", undone.stdout);
|
||||
expect(redone.status, redone.stderr).toBe(0);
|
||||
expect(sources()).toEqual(after);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["stale", "invalid"])(
|
||||
"leaves the whole undo batch untouched when a later receipt is %s",
|
||||
(failure) => {
|
||||
const { dir, sources, planPath } = batchProject();
|
||||
try {
|
||||
const applied = run(dir, "apply", planPath);
|
||||
expect(applied.status, applied.stderr).toBe(0);
|
||||
const appliedJson: { receipt: Array<Record<string, unknown>> } = JSON.parse(applied.stdout);
|
||||
if (failure === "stale") {
|
||||
const childPath = join(dir, "compositions/scene.html");
|
||||
writeFileSync(childPath, readFileSync(childPath, "utf8") + "<!-- newer edit -->");
|
||||
}
|
||||
const current = sources();
|
||||
const input =
|
||||
failure === "stale"
|
||||
? applied.stdout
|
||||
: JSON.stringify([appliedJson.receipt[0], { file: "bad" }]);
|
||||
|
||||
const undone = run(dir, "undo", input);
|
||||
expect(undone.status).toBe(2);
|
||||
expect(JSON.parse(undone.stderr)).toMatchObject({
|
||||
ok: false,
|
||||
reason:
|
||||
failure === "stale"
|
||||
? "file changed since the timeline was read"
|
||||
: "timeline undo: undo receipt is missing file, version, or backupPath",
|
||||
});
|
||||
expect(sources()).toEqual(current);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("undoes a complete timeline ids response", () => {
|
||||
const dir = project();
|
||||
try {
|
||||
const indexPath = join(dir, "index.html");
|
||||
const before = readFileSync(indexPath, "utf8").replace(/ data-hf-id="[^"]*"/g, "");
|
||||
writeFileSync(indexPath, before);
|
||||
const stamped = run(dir, "ids");
|
||||
expect(stamped.status, stamped.stderr).toBe(0);
|
||||
expect(readFileSync(indexPath, "utf8")).toContain("data-hf-id=");
|
||||
|
||||
const undone = run(dir, "undo", stamped.stdout);
|
||||
expect(undone.status, undone.stderr).toBe(0);
|
||||
expect(readFileSync(indexPath, "utf8")).toBe(before);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts an empty receipt from an unchanged apply", () => {
|
||||
const dir = project();
|
||||
try {
|
||||
const before = readFileSync(join(dir, "index.html"), "utf8");
|
||||
const planPath = join(dir, "edits.json");
|
||||
writeFileSync(planPath, "[]");
|
||||
const applied = run(dir, "apply", planPath);
|
||||
expect(applied.status, applied.stderr).toBe(0);
|
||||
|
||||
const undone = run(dir, "undo", applied.stdout);
|
||||
expect(undone.status, undone.stderr).toBe(0);
|
||||
expect(JSON.parse(undone.stdout)).toMatchObject({ ok: true, receipt: [], file: [] });
|
||||
expect(readFileSync(join(dir, "index.html"), "utf8")).toBe(before);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user