diff --git a/packages/cli/src/commands/compare.test.ts b/packages/cli/src/commands/compare.test.ts index f5381449ed..3e88e23ed6 100644 --- a/packages/cli/src/commands/compare.test.ts +++ b/packages/cli/src/commands/compare.test.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { buildCompareSuccessPayload, @@ -9,6 +9,12 @@ import { prepareCompareVariantProjects, } from "./compare.js"; +const temporaryRoot = vi.hoisted(() => ({ path: "" })); +vi.mock("node:os", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, tmpdir: () => temporaryRoot.path || actual.tmpdir() }; +}); + function tempDir(): string { return mkdtempSync(join(tmpdir(), "hf-compare-test-")); } @@ -78,6 +84,74 @@ describe("capCompareVariants", () => { }); describe("prepareCompareVariantProjects", () => { + it("keeps earlier temporary staging folders out of later variants", () => { + const dir = tempDir(); + const first = join(dir, "first.html"); + const second = join(dir, "second.html"); + writeFileSync(first, "First"); + writeFileSync(second, "Second"); + temporaryRoot.path = dir; + + try { + const prepared = prepareCompareVariantProjects([ + { label: "First", inputPath: first, displayPath: "first.html" }, + { label: "Second", inputPath: second, displayPath: "second.html" }, + ]); + const firstStage = prepared[0]; + const secondStage = prepared[1]; + if (!firstStage || !secondStage) throw new Error("expected two staged variants"); + expect(existsSync(join(secondStage.projectDir, basename(firstStage.projectDir)))).toBe(false); + expect(readFileSync(join(secondStage.projectDir, "first.html"), "utf8")).toContain("First"); + expect(readFileSync(join(secondStage.projectDir, "index.html"), "utf8")).toContain("Second"); + } finally { + temporaryRoot.path = ""; + rmSync(dir, { recursive: true, force: true }); + } + }); + + it.each(["direct", "nested"])( + "stages siblings when the temporary output is %s inside the source directory", + (placement) => { + const dir = tempDir(); + const htmlFile = join(dir, "candidate.html"); + mkdirSync(join(dir, "assets")); + writeFileSync(htmlFile, "Temporary candidate"); + writeFileSync(join(dir, "assets", "asset.txt"), "sibling asset"); + mkdirSync(join(dir, "node_modules")); + writeFileSync(join(dir, "node_modules", "ignored.txt"), "ignored"); + temporaryRoot.path = placement === "direct" ? dir : join(dir, "temporary"); + mkdirSync(temporaryRoot.path, { recursive: true }); + writeFileSync(join(temporaryRoot.path, "sibling.txt"), "temporary-root sibling"); + + try { + const prepared = prepareCompareVariantProjects([ + { label: "Temp", inputPath: htmlFile, displayPath: "candidate.html" }, + ]); + const staged = prepared[0]; + if (!staged) throw new Error("expected a staged variant"); + expect(staged.stagedDir).toBe(staged.projectDir); + expect(readFileSync(join(staged.projectDir, "index.html"), "utf8")).toContain( + "Temporary candidate", + ); + expect(readFileSync(join(staged.projectDir, "assets", "asset.txt"), "utf8")).toBe( + "sibling asset", + ); + expect( + readFileSync( + join(staged.projectDir, placement === "direct" ? "" : "temporary", "sibling.txt"), + "utf8", + ), + ).toBe("temporary-root sibling"); + expect(existsSync(join(staged.projectDir, "candidate.html"))).toBe(false); + expect(existsSync(join(staged.projectDir, "node_modules"))).toBe(false); + expect(readFileSync(htmlFile, "utf8")).toContain("Temporary candidate"); + } finally { + temporaryRoot.path = ""; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + it("uses project directories directly and stages standalone html files as index.html", () => { const dir = tempDir(); try { diff --git a/packages/cli/src/commands/compare.ts b/packages/cli/src/commands/compare.ts index d7d7d38383..c01360ed46 100644 --- a/packages/cli/src/commands/compare.ts +++ b/packages/cli/src/commands/compare.ts @@ -1,5 +1,15 @@ import { failCommand } from "../utils/commandResult.js"; -import { cpSync, existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, statSync } from "node:fs"; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + realpathSync, + renameSync, + rmSync, + statSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, extname, join } from "node:path"; import { defineCommand } from "citty"; @@ -16,6 +26,7 @@ import { trackCompareSheet } from "../telemetry/events.js"; import { serveStaticProjectHtml } from "../utils/staticProjectServer.js"; import { withMeta } from "../utils/updateCheck.js"; import type { Example } from "./_examples.js"; +import { isPathInside } from "@hyperframes/parsers/asset-paths"; const MAX_COMPARE_VARIANTS = 16; const MAX_COLUMNS = 4; @@ -198,18 +209,39 @@ function inputError(variant: CompareVariantSpec): Error { ); } -function stageHtmlVariant(variant: CompareVariantSpec): PreparedCompareVariant { +function copyVariantSiblings( + sourceDir: string, + destinationDir: string, + stagingPaths: readonly string[], +): void { + mkdirSync(destinationDir, { recursive: true }); + for (const entry of readdirSync(sourceDir)) { + if (entry === "node_modules" || entry === ".git") continue; + const src = join(sourceDir, entry); + const dest = join(destinationDir, entry); + if (stagingPaths.includes(src)) continue; + if (stagingPaths.some((path) => isPathInside(path, src))) { + copyVariantSiblings(src, dest, stagingPaths); + continue; + } + cpSync(src, dest, { + recursive: true, + filter: (path) => basename(path) !== "node_modules" && basename(path) !== ".git", + }); + } +} + +function stageHtmlVariant( + variant: CompareVariantSpec, + existingStagedDirs: readonly string[], +): PreparedCompareVariant { const stagedDir = mkdtempSync(join(tmpdir(), "hf-compare-variant-")); try { // Copy the composition's sibling files but skip heavy/irrelevant trees — a // variant sitting next to node_modules or .git shouldn't drag them into tmp. - cpSync(dirname(variant.inputPath), stagedDir, { - recursive: true, - filter: (src) => { - const base = basename(src); - return base !== "node_modules" && base !== ".git"; - }, - }); + const sourceDir = realpathSync(dirname(variant.inputPath)); + const stagingPaths = [stagedDir, ...existingStagedDirs].map((path) => realpathSync(path)); + copyVariantSiblings(sourceDir, realpathSync(stagedDir), stagingPaths); const sourceName = basename(variant.inputPath); if (sourceName !== "index.html") { renameSync(join(stagedDir, sourceName), join(stagedDir, "index.html")); @@ -243,7 +275,10 @@ export function prepareCompareVariantProjects( continue; } if (stat.isFile() && extname(variant.inputPath).toLowerCase() === ".html") { - prepared.push(stageHtmlVariant(variant)); + const stagedDirs = prepared.flatMap((project) => + project.stagedDir ? [project.stagedDir] : [], + ); + prepared.push(stageHtmlVariant(variant, stagedDirs)); continue; } throw inputError(variant);