diff --git a/packages/cli/src/commands/checkArtifacts.test.ts b/packages/cli/src/commands/checkArtifacts.test.ts index 79439f7b34..59e7d1e899 100644 --- a/packages/cli/src/commands/checkArtifacts.test.ts +++ b/packages/cli/src/commands/checkArtifacts.test.ts @@ -71,7 +71,7 @@ it("only warns when the report cannot be recorded; the exit status is the check' } }); -it("still reports the check when another process holds history, without waiting for it", async () => { +it("records the check when another process holds history, without waiting for it", async () => { const project = mkdtempSync(join(tmpdir(), "hf-check-history-held-")); const log = vi.spyOn(console, "log").mockImplementation(() => undefined); const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); @@ -90,12 +90,10 @@ it("still reports the check when another process holds history, without waiting try { await runCommand(check, { rawArgs: [project, "--json"] }); const output = JSON.parse(String(log.mock.calls.at(-1)?.[0])) as CheckReport; - expect(output).not.toHaveProperty("saved"); + expect(output).toHaveProperty("saved.id"); expect(output.browserSkipped).toBe(true); expect(consumeCommandResult().exitCode).toBe(checkExitCode(output)); - expect( - warn.mock.calls.some((call) => String(call[0]).includes("Check report was not recorded")), - ).toBe(true); + expect(warn.mock.calls.flat().join("\n")).not.toContain("not recorded"); } finally { historyDeps.ownerWaitMs = previousWait; await held.close(); diff --git a/packages/cli/src/commands/snapshot-contactSheet.test.ts b/packages/cli/src/commands/snapshot-contactSheet.test.ts index cf43763c45..2e0a6b2e98 100644 --- a/packages/cli/src/commands/snapshot-contactSheet.test.ts +++ b/packages/cli/src/commands/snapshot-contactSheet.test.ts @@ -152,7 +152,7 @@ describe("snapshot contact sheet failures", () => { expect(console.error).not.toHaveBeenCalled(); }); - it("keeps the captured frames when another process holds history", async () => { + it("keeps and records the captured frames when another process holds history", async () => { const args = { rawArgs: [ project, @@ -181,12 +181,13 @@ describe("snapshot contact sheet failures", () => { expect(readFileSync(frame)).toEqual(Buffer.from("replaced PNG")); } const after = JSON.parse(readFileSync(statePath, "utf8")); - expect(after.snapshots.unversioned).toEqual([]); - expect(after.snapshots.latest).toBeNull(); - expect(console.error).not.toHaveBeenCalled(); - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining("Snapshots were not recorded"), + expect(after.snapshots.unversioned.map((record: { image: string }) => record.image)).toEqual( + ["0.5", "1"].map((time, index) => + join(project, "snapshots", `frame-0${index}-at-${time}s.png`), + ), ); + expect(console.error).not.toHaveBeenCalled(); + expect(console.warn).not.toHaveBeenCalledWith(expect.stringContaining("not recorded")); } finally { historyDeps.ownerWaitMs = previousWait; await held.close(); diff --git a/packages/cli/src/utils/artifactCapture.test.ts b/packages/cli/src/utils/artifactCapture.test.ts index a55e479b31..7d12bda779 100644 --- a/packages/cli/src/utils/artifactCapture.test.ts +++ b/packages/cli/src/utils/artifactCapture.test.ts @@ -1,7 +1,9 @@ +import { spawnSync } from "node:child_process"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import type { AddressInfo } from "node:net"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; import { createAdaptorServer } from "@hono/node-server"; import { createStudioApi, @@ -82,6 +84,43 @@ it("does not assign results when inputs change during capture", async () => { expect(f.state().snapshots.latest?.revision).toBeNull(); }); +it("a check run while the app keeps the history open is still recorded, never under an unfiled revision", async () => { + const f = await fixture(); + // Held here as the desktop app holds every open project's history; the CLI runs as the app runs it, a child. + const held = await openProjectHistory({ + projectDir: f.dir, + historyRoot: historyDeps.historyRoot, + undoScope: "everyone", + }); + const cli = resolve(fileURLToPath(import.meta.url), "..", "..", "cli.ts"); + const checkFromChild = () => { + const run = spawnSync("bun", ["run", cli, "check", "--json", f.dir], { + encoding: "utf-8", + env: { + ...process.env, + HYPERFRAMES_HISTORY_ROOT: historyDeps.historyRoot, + HYPERFRAMES_SKIP_UPDATE_CHECK: "1", + HYPERFRAMES_NO_TELEMETRY: "1", + }, + }); + expect(run.stderr).not.toContain("not recorded"); + }; + try { + writeFileSync(join(f.dir, "index.html"), "filed"); + await held.checkpoint(); + checkFromChild(); + expect(f.state().currentRevision).toBeTruthy(); + expect(f.state().checks.latest?.revision).toBe(f.state().currentRevision); + + writeFileSync(join(f.dir, "index.html"), "not filed yet"); + checkFromChild(); + expect(f.state().checks.latest?.revision).toBeNull(); + expect(f.state().checks.unversioned).toHaveLength(1); + } finally { + await held.close(); + } +}); + it("posts a preview checkpoint instead of sending GET", async () => { const dir = mkdtempSync(join(tmpdir(), "hf-artifact-preview-")); roots.push(dir); diff --git a/packages/cli/src/utils/artifactCapture.ts b/packages/cli/src/utils/artifactCapture.ts index a65d3672f4..e3aaa96a2e 100644 --- a/packages/cli/src/utils/artifactCapture.ts +++ b/packages/cli/src/utils/artifactCapture.ts @@ -1,15 +1,15 @@ -import { beginArtifactCapture } from "@hyperframes/studio-server/history"; +import { HistoryBusyError, beginArtifactCapture } from "@hyperframes/studio-server/history"; import { withOwner } from "./historyOwner.js"; const SKIP_BUSY_HISTORY = { ownerWaitMs: 0 }; +function unlessAnotherProcessKeepsTheHistory(error: unknown): void { + if (!(error instanceof HistoryBusyError)) throw error; +} + export async function captureArtifactRevision(projectDir: string) { - return withOwner( - projectDir, - async (owner) => { - await owner.checkpoint(); - return beginArtifactCapture(projectDir); - }, - SKIP_BUSY_HISTORY, + await withOwner(projectDir, (owner) => owner.checkpoint(), SKIP_BUSY_HISTORY).catch( + unlessAnotherProcessKeepsTheHistory, ); + return beginArtifactCapture(projectDir); }