Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion packages/cli/src/commands/snapshot.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { computeSnapshotTimes, tailFrameTime } from "./snapshot.js";
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { cleanSnapshotDir, computeSnapshotTimes, tailFrameTime } from "./snapshot.js";

describe("tailFrameTime", () => {
it("backs off ~3% of duration so the final frame isn't the blank exact-end", () => {
Expand Down Expand Up @@ -59,3 +62,41 @@ describe("computeSnapshotTimes (FINDING [7]: tail is always captured)", () => {
expect(appendedTail).toBe(false);
});
});

// Regression: every `snapshot` run unconditionally wiped the output
// directory's existing .png/.jpg files before capturing new ones, so two
// consecutive runs with different --at sets clobbered each other with no
// way to opt out. cleanSnapshotDir is now a separate, callable-or-skippable
// step (see the --clean/--no-clean flag) instead of being inlined and
// unconditional.
describe("cleanSnapshotDir", () => {
it("deletes existing image files in the directory", () => {
const dir = mkdtempSync(join(tmpdir(), "snapshot-clean-"));
writeFileSync(join(dir, "frame-1.png"), "stub");
writeFileSync(join(dir, "frame-2.jpg"), "stub");
try {
cleanSnapshotDir(dir);
expect(readdirSync(dir)).toEqual([]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it("only removes image files, leaving other files untouched", () => {
const dir = mkdtempSync(join(tmpdir(), "snapshot-clean-scope-"));
writeFileSync(join(dir, "frame.png"), "stub");
writeFileSync(join(dir, "descriptions.md"), "keep me");
try {
cleanSnapshotDir(dir);
expect(readdirSync(dir)).toEqual(["descriptions.md"]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it("does not throw when the directory does not exist", () => {
expect(() =>
cleanSnapshotDir(join(tmpdir(), "snapshot-clean-missing-nonexistent")),
).not.toThrow();
});
});
48 changes: 37 additions & 11 deletions packages/cli/src/commands/snapshot.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
// fallow-ignore-file complexity
import { spawn } from "node:child_process";
import { defineCommand } from "citty";
import { existsSync, mkdtempSync, readFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import {
existsSync,
mkdtempSync,
readdirSync,
readFileSync,
mkdirSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { resolve, join, relative, isAbsolute, basename } from "node:path";
import { resolveProject } from "../utils/project.js";
Expand Down Expand Up @@ -177,6 +185,25 @@ export function computeSnapshotTimes(
return { times: times.map(round), appendedTail: false };
}

/**
* Deletes existing .png/.jpg/.jpeg files from a snapshot output directory.
* Pure enough to unit test directly: two consecutive `snapshot` runs with
* different --at sets otherwise clobber each other's output with no way to
* opt out, since every run wiped the directory unconditionally before this
* was made a separate, disableable step (see the `clean` flag).
*/
export function cleanSnapshotDir(dir: string): void {
try {
for (const file of readdirSync(dir)) {
if (/\.(png|jpg|jpeg)$/i.test(file)) {
rmSync(join(dir, file), { force: true });
}
}
} catch {
/* best-effort — proceed even if cleanup fails */
}
}

/**
* Render key frames from a composition as PNG screenshots.
* The agent can Read these to verify its output visually.
Expand All @@ -190,6 +217,7 @@ async function captureSnapshots(
outputDir?: string;
angle?: Camera;
includeEnd?: boolean;
clean?: boolean;
},
): Promise<string[]> {
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
Expand Down Expand Up @@ -343,16 +371,7 @@ async function captureSnapshots(

const snapshotDir = opts.outputDir ?? join(projectDir, "snapshots");
mkdirSync(snapshotDir, { recursive: true });
try {
const { readdirSync } = await import("node:fs");
for (const file of readdirSync(snapshotDir)) {
if (/\.(png|jpg|jpeg)$/i.test(file)) {
rmSync(join(snapshotDir, file), { force: true });
}
}
} catch {
/* best-effort — proceed even if cleanup fails */
}
if (opts.clean !== false) cleanSnapshotDir(snapshotDir);

// Chrome-headless ignores programmatic <video>.currentTime writes, so
// we extract frames via FFmpeg and overlay them as <img> elements.
Expand Down Expand Up @@ -581,6 +600,12 @@ export default defineCommand({
"Always include a readable end-of-timeline frame (default: true). Pass --no-end to capture only your exact --at times.",
default: true,
},
clean: {
type: "boolean",
description:
"Delete existing .png/.jpg files in the output directory before capturing (default: true). Pass --no-clean to preserve them — useful for running snapshot multiple times with different --at sets without each run clobbering the last.",
default: true,
},
describe: {
type: "string",
description:
Expand Down Expand Up @@ -630,6 +655,7 @@ export default defineCommand({
outputDir: snapshotDir,
angle: camera,
includeEnd: args.end !== false,
clean: args.clean !== false,
});

if (paths.length === 0) {
Expand Down
Loading