test: cli and studio-server tests remove the temp folders they create (#4566)

* test: cli and studio-server tests remove the temp folders they create

* test(cli): each temp folder is removed where it is made, even when a test fails

* test(cli): the one-liner helper relies on its project's own cleanup
This commit is contained in:
Miguel Ángel
2026-09-27 04:06:05 +00:00
committed by GitHub
parent 8dfdceb167
commit 950cccdac0
10 changed files with 151 additions and 141 deletions
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { mkdtempSync } from "node:fs";
import { describe, expect, it, onTestFinished, vi } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { TimeoutError } from "puppeteer-core";
@@ -9,6 +9,7 @@ import { captureScrollScreenshots } from "./screenshotCapture.js";
describe("captureScrollScreenshots degradation", () => {
it("rethrows protocol evaluate timeouts for the caller warning path", async () => {
const dir = mkdtempSync(join(tmpdir(), "hf-scroll-degrade-"));
onTestFinished(() => rmSync(dir, { recursive: true, force: true }));
const page = {
evaluate: vi.fn(async () => {
throw new TimeoutError(
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { existsSync, mkdtempSync, readFileSync } from "node:fs";
import { describe, expect, it, onTestFinished, vi } from "vitest";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Page } from "puppeteer-core";
@@ -10,6 +10,12 @@ import {
pngHeight,
} from "./screenshotCapture.js";
function tempDir(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
onTestFinished(() => rmSync(dir, { recursive: true, force: true }));
return dir;
}
// A real 1920x800 PNG header, so the produced-height guard sees something valid.
function pngBuffer(height: number, width = 1920): Buffer {
const buf = Buffer.alloc(24);
@@ -38,7 +44,7 @@ function fakePage(
describe("captureFullPagePlate — the scroll shot's plate", () => {
it("writes one full-page png and returns its relative path", async () => {
const dir = mkdtempSync(join(tmpdir(), "hf-plate-"));
const dir = tempDir("hf-plate-");
const { page, screenshot } = fakePage({ docHeight: 10962, plateHeight: 10962 });
const out = await captureFullPagePlate(page, dir);
@@ -49,7 +55,7 @@ describe("captureFullPagePlate — the scroll shot's plate", () => {
});
it("stays 1x: it never touches the viewport's deviceScaleFactor", async () => {
const dir = mkdtempSync(join(tmpdir(), "hf-plate-"));
const dir = tempDir("hf-plate-");
const setViewport = vi.fn(async () => undefined);
const { page } = fakePage({}, { setViewport });
@@ -60,7 +66,7 @@ describe("captureFullPagePlate — the scroll shot's plate", () => {
});
it("skips a page taller than Chrome can capture, instead of writing a clipped plate", async () => {
const dir = mkdtempSync(join(tmpdir(), "hf-plate-"));
const dir = tempDir("hf-plate-");
const { page, screenshot } = fakePage({ docHeight: MAX_PLATE_HEIGHT_PX + 1 });
const out = await captureFullPagePlate(page, dir);
@@ -71,7 +77,7 @@ describe("captureFullPagePlate — the scroll shot's plate", () => {
});
it("neutralises sticky/fixed chrome for the shot and restores it afterwards", async () => {
const dir = mkdtempSync(join(tmpdir(), "hf-plate-"));
const dir = tempDir("hf-plate-");
const { page, evaluate, screenshot } = fakePage();
await captureFullPagePlate(page, dir);
@@ -96,7 +102,7 @@ describe("captureFullPagePlate — the scroll shot's plate", () => {
});
it("restores the page even when the screenshot throws", async () => {
const dir = mkdtempSync(join(tmpdir(), "hf-plate-"));
const dir = tempDir("hf-plate-");
const screenshot = vi.fn(async (_opts?: unknown) => {
throw new Error("capture failed");
});
@@ -111,7 +117,7 @@ describe("captureFullPagePlate — the scroll shot's plate", () => {
describe("captureScrollScreenshots — capture budget", () => {
it("does not begin page work when the post-navigation budget is exhausted", async () => {
const dir = mkdtempSync(join(tmpdir(), "hf-scroll-budget-"));
const dir = tempDir("hf-scroll-budget-");
const evaluate = vi.fn(async () => 1080);
const screenshot = vi.fn(async () => pngBuffer(1080));
const page = { evaluate, screenshot } as unknown as Page;
@@ -126,7 +132,7 @@ describe("captureScrollScreenshots — capture budget", () => {
it("re-checks the budget after settling and before each viewport screenshot", async () => {
vi.useFakeTimers();
vi.setSystemTime(0);
const dir = mkdtempSync(join(tmpdir(), "hf-scroll-expiring-budget-"));
const dir = tempDir("hf-scroll-expiring-budget-");
const evaluate = vi.fn(async (expression: unknown) => {
const source = String(expression);
if (source.includes("Math.max(document.body.scrollHeight")) return 1080;
@@ -155,7 +161,7 @@ describe("captureFullPagePlate — capture budget", () => {
it("re-checks the budget immediately before the full-page screenshot", async () => {
vi.useFakeTimers();
vi.setSystemTime(0);
const dir = mkdtempSync(join(tmpdir(), "hf-plate-expiring-budget-"));
const dir = tempDir("hf-plate-expiring-budget-");
const screenshot = vi.fn(async () => pngBuffer(8000));
const evaluate = vi.fn(async (expression: unknown) => {
if (String(expression).includes("scrollHeight")) {
@@ -181,7 +187,7 @@ describe("captureFullPagePlate — capture budget", () => {
describe("captureFullPagePlate — guards against a silently clipped plate", () => {
it("measures the height itself, after lazy content has grown the page", async () => {
const dir = mkdtempSync(join(tmpdir(), "hf-plate-"));
const dir = tempDir("hf-plate-");
// A page that measured 9000 before scrolling but is 20000 once lazy images land: the
// pre-scroll number would have passed the guard and emitted a clipped plate.
const { page, screenshot } = fakePage({ docHeight: 20000 });
@@ -191,7 +197,7 @@ describe("captureFullPagePlate — guards against a silently clipped plate", ()
});
it("discards a plate Chrome clipped, even when the measurement passed", async () => {
const dir = mkdtempSync(join(tmpdir(), "hf-plate-"));
const dir = tempDir("hf-plate-");
// Measurement said 16000, but the capture itself triggered more loading and came back
// over the cap. Emitting it would be undetectable downstream.
const { page } = fakePage({ docHeight: 16000, plateHeight: MAX_PLATE_HEIGHT_PX + 500 });
@@ -201,7 +207,7 @@ describe("captureFullPagePlate — guards against a silently clipped plate", ()
});
it("survives a restore that throws — the real error is what propagates", async () => {
const dir = mkdtempSync(join(tmpdir(), "hf-plate-"));
const dir = tempDir("hf-plate-");
let call = 0;
const evaluate = vi.fn(async (script?: unknown) => {
call++;
@@ -233,7 +239,7 @@ describe("pngHeight", () => {
describe("captureFullPagePlate — the guard sees the post-neutralisation page (Magi's case)", () => {
it("skips when the initial height is under the cap but the final height is over it", async () => {
const dir = mkdtempSync(join(tmpdir(), "hf-plate-"));
const dir = tempDir("hf-plate-");
// Pre-traversal the page measured 9000. Lazy content and un-fixing the sticky header push
// it over the cap by the time the plate would be shot. Probing before either step would
// have passed the guard and emitted a clipped plate.
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from "vitest";
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
@@ -26,6 +26,7 @@ afterEach(() => {
function writeComposition(width: number, height: number): string {
const dir = mkdtempSync(join(tmpdir(), "hf-cloud-render-test-"));
onTestFinished(() => rmSync(dir, { recursive: true, force: true }));
writeFileSync(
join(dir, "index.html"),
`<!doctype html><html><body><div data-composition-id="main" data-width="${width}" data-height="${height}"></div></body></html>`,
+14 -2
View File
@@ -1,7 +1,15 @@
import { existsSync, linkSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import {
existsSync,
linkSync,
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { beforeAll, describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it, onTestFinished } from "vitest";
import type { ArgsDef } from "citty";
import { ensureDOMParser } from "../utils/dom.js";
import keyframesCommand from "./keyframes.js";
@@ -28,6 +36,7 @@ const wrap = (script: string) =>
describe("keyframes direct composition scope", () => {
it("keeps the project root and passes the nested HTML entry to --shot", () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-keyframes-target-"));
onTestFinished(() => rmSync(projectDir, { recursive: true, force: true }));
const compositionsDir = join(projectDir, "compositions");
mkdirSync(compositionsDir);
writeFileSync(join(projectDir, "index.html"), wrap(""));
@@ -44,6 +53,7 @@ describe("keyframes direct composition scope", () => {
describe("keyframes shot output", () => {
it("rejects an output path that would overwrite the composition source", () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-keyframes-shot-source-"));
onTestFinished(() => rmSync(projectDir, { recursive: true, force: true }));
const sourcePath = join(projectDir, "index.html");
writeFileSync(sourcePath, wrap(""));
@@ -55,6 +65,7 @@ describe("keyframes shot output", () => {
it("rejects an existing output alias that refers to the composition source", () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-keyframes-shot-alias-"));
onTestFinished(() => rmSync(projectDir, { recursive: true, force: true }));
const sourcePath = join(projectDir, "index.html");
const aliasPath = join(projectDir, "shot.png");
writeFileSync(sourcePath, wrap(""));
@@ -67,6 +78,7 @@ describe("keyframes shot output", () => {
it("creates a missing parent directory before writing --shot", () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-keyframes-shot-dir-"));
onTestFinished(() => rmSync(projectDir, { recursive: true, force: true }));
const outputDir = join(projectDir, "nested", "proofs");
ensureShotOutputDir(join(outputDir, "shot.png"));
expect(existsSync(outputDir)).toBe(true);
@@ -1,7 +1,7 @@
import { existsSync, mkdtempSync, readFileSync, readdirSync } from "node:fs";
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it, onTestFinished, vi } from "vitest";
import type { ActiveServer } from "../server/portUtils.js";
import {
buildBackgroundPreviewArgs,
@@ -22,6 +22,12 @@ const server: ActiveServer = {
pid: "4321",
};
function tempDir(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
onTestFinished(() => rmSync(dir, { recursive: true, force: true }));
return dir;
}
function savePreviewSession(stateHome: string): void {
writePreviewSession(
{ pid: 4321, port: 3210, projectDir, logPath: "/tmp/preview.log" },
@@ -42,7 +48,7 @@ async function expectStaleSessionRemoved(stateHome: string): Promise<void> {
describe("background preview lifecycle", () => {
it("keeps case-distinct project paths separate on case-sensitive platforms", () => {
if (process.platform === "win32") return;
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const stateHome = tempDir("hf-preview-state-");
expect(previewSessionPath("/tmp/Project", stateHome)).not.toBe(
previewSessionPath("/tmp/project", stateHome),
@@ -71,7 +77,7 @@ describe("background preview lifecycle", () => {
execPath: "/usr/bin/node",
scan,
spawn,
stateHome: mkdtempSync(join(tmpdir(), "hf-preview-state-")),
stateHome: tempDir("hf-preview-state-"),
});
expect(result).toMatchObject({ type: "reused", port: 3210 });
@@ -79,7 +85,7 @@ describe("background preview lifecycle", () => {
});
it("reuses a saved managed preview on a custom port without repeating --port", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const stateHome = tempDir("hf-preview-state-");
writePreviewSession(
{ pid: 4321, port: 41402, projectDir, logPath: "/tmp/custom.log" },
stateHome,
@@ -100,7 +106,7 @@ describe("background preview lifecycle", () => {
});
it("discovers managed previews outside the default port scan and removes stale records", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const stateHome = tempDir("hf-preview-state-");
const otherProjectDir = resolve("/tmp/hyperframes-preview-managed-custom-port");
const staleProjectDir = resolve("/tmp/hyperframes-preview-managed-stale");
writePreviewSession(
@@ -160,7 +166,7 @@ describe("background preview lifecycle", () => {
scan,
spawn,
sleep: async () => {},
stateHome: mkdtempSync(join(tmpdir(), "hf-preview-state-")),
stateHome: tempDir("hf-preview-state-"),
});
expect(result).toMatchObject({ type: "started", port: 3211, pid: 5432 });
@@ -173,7 +179,7 @@ describe("background preview lifecycle", () => {
])(
"%s replaces a previously managed server instead of orphaning it",
async (_label, forceNew) => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const stateHome = tempDir("hf-preview-state-");
const oldServer = { ...server, port: 41490, browserGpuMode: "hardware" as const };
writePreviewSession(
{ pid: 4321, port: 41490, projectDir, logPath: "/tmp/preview.log" },
@@ -218,7 +224,7 @@ describe("background preview lifecycle", () => {
);
it("replaces the owned preview instead of reusing an unmanaged policy-matching sibling", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const stateHome = tempDir("hf-preview-state-");
const owned = { ...server, port: 41490, browserGpuMode: "hardware" as const };
const sibling = {
...server,
@@ -270,7 +276,7 @@ describe("background preview lifecycle", () => {
// one inside the stop. "Nothing left to stop" is the goal state for a
// replacement; treating it as fatal refused to start any preview at all
// until the session record was deleted by hand.
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const stateHome = tempDir("hf-preview-state-");
const owned = { ...server, port: 41490 };
const replacement = { ...server, port: 41491, pid: "5432" };
writePreviewSession(
@@ -327,7 +333,7 @@ describe("background preview lifecycle", () => {
scan,
spawn,
sleep: async () => {},
stateHome: mkdtempSync(join(tmpdir(), "hf-preview-state-")),
stateHome: tempDir("hf-preview-state-"),
});
expect(result).toMatchObject({ type: "started", port: 3211, pid: 5432 });
@@ -342,7 +348,7 @@ describe("background preview lifecycle", () => {
spawned = true;
return { pid: 4321, unref };
});
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const stateHome = tempDir("hf-preview-state-");
const result = await startBackgroundPreview(projectDir, 3002, {
argv: ["/opt/hyperframes/cli.js", "preview", projectDir, "--background"],
@@ -362,7 +368,7 @@ describe("background preview lifecycle", () => {
const liveServer = { ...server, pid: "9876" };
let spawned = false;
const scan = vi.fn(async () => (spawned ? [liveServer] : []));
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const stateHome = tempDir("hf-preview-state-");
const result = await startBackgroundPreview(projectDir, 3002, {
scan,
@@ -380,7 +386,7 @@ describe("background preview lifecycle", () => {
});
it("reaps a detached child that never becomes reachable without recording ownership", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const stateHome = tempDir("hf-preview-state-");
const kill = vi.fn();
await expect(
@@ -399,7 +405,7 @@ describe("background preview lifecycle", () => {
});
it("removes a stale session when no matching server or process survives", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const stateHome = tempDir("hf-preview-state-");
writePreviewSession(
{ pid: 999_999, port: 3210, projectDir, logPath: "/tmp/missing.log" },
stateHome,
@@ -409,7 +415,7 @@ describe("background preview lifecycle", () => {
});
it("removes stale session metadata when its PID is alive but no server proves ownership", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const stateHome = tempDir("hf-preview-state-");
savePreviewSession(stateHome);
await expectStaleSessionRemoved(stateHome);
@@ -420,7 +426,7 @@ describe("background preview lifecycle", () => {
// capture will do it) answers nothing for a second or two. Retiring the
// record on that destroys the wrapperIdentity that is the only PID-reuse
// guard `--stop` has, and it never comes back.
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const stateHome = tempDir("hf-preview-state-");
writePreviewSession(
{ pid: 4321, wrapperIdentity: "posix:birth", port: 3210, projectDir, logPath: "/tmp/p.log" },
stateHome,
@@ -442,7 +448,7 @@ describe("background preview lifecycle", () => {
// macOS that failure is a subprocess timeout on a LIVE process — under the
// same load that made the HTTP probe miss. Treating no-answer as
// "recycled" destroyed the only PID-reuse guard `--stop` has.
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const stateHome = tempDir("hf-preview-state-");
writePreviewSession(
{ pid: 4321, wrapperIdentity: "posix:birth", port: 3210, projectDir, logPath: "/tmp/p.log" },
stateHome,
@@ -461,7 +467,7 @@ describe("background preview lifecycle", () => {
it("retires the record when the PID cannot be signalled at all", async () => {
// Gone is gone: no birth token needed, and no subprocess spawned for it.
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const stateHome = tempDir("hf-preview-state-");
writePreviewSession(
{ pid: 4321, wrapperIdentity: "posix:birth", port: 3210, projectDir, logPath: "/tmp/p.log" },
stateHome,
@@ -481,7 +487,7 @@ describe("background preview lifecycle", () => {
});
it("retires the record once the wrapper PID has been recycled", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const stateHome = tempDir("hf-preview-state-");
writePreviewSession(
{ pid: 4321, wrapperIdentity: "posix:birth", port: 3210, projectDir, logPath: "/tmp/p.log" },
stateHome,
@@ -499,7 +505,7 @@ describe("background preview lifecycle", () => {
});
it("never leaves a partial session record for a concurrent reader", () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const stateHome = tempDir("hf-preview-state-");
savePreviewSession(stateHome);
const path = previewSessionPath(projectDir, stateHome);
@@ -514,7 +520,7 @@ describe("background preview lifecycle", () => {
});
it("uses the recorded custom port when status is called without repeating --port", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const stateHome = tempDir("hf-preview-state-");
savePreviewSession(stateHome);
const scan = vi.fn(async () => [server]);
@@ -538,7 +544,7 @@ describe("background preview lifecycle", () => {
scan,
kill,
sleep: async () => {},
stateHome: mkdtempSync(join(tmpdir(), "hf-preview-state-")),
stateHome: tempDir("hf-preview-state-"),
});
expect(result).toBe(true);
@@ -547,7 +553,7 @@ describe("background preview lifecycle", () => {
});
it("does not kill an unmatched saved PID that may have been reused", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const stateHome = tempDir("hf-preview-state-");
savePreviewSession(stateHome);
const kill = vi.fn();
@@ -563,7 +569,7 @@ describe("background preview lifecycle", () => {
});
it("refuses to stop when the live server cannot prove its own PID", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const stateHome = tempDir("hf-preview-state-");
writePreviewSession(
{ pid: 4321, port: 3210, projectDir, logPath: "/tmp/preview.log" },
stateHome,
@@ -584,7 +590,7 @@ describe("background preview lifecycle", () => {
});
it("reaps the saved wrapper when the live server is proven to be its descendant", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const stateHome = tempDir("hf-preview-state-");
writePreviewSession(
{
pid: 4321,
@@ -615,7 +621,7 @@ describe("background preview lifecycle", () => {
});
it("kills only the live server when the saved wrapper birth identity has changed", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const stateHome = tempDir("hf-preview-state-");
writePreviewSession(
{
pid: 4321,
@@ -646,7 +652,7 @@ describe("background preview lifecycle", () => {
});
it("fails loudly when the server remains reachable after stop", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const stateHome = tempDir("hf-preview-state-");
writePreviewSession(
{ pid: 4321, port: 3210, projectDir, logPath: "/tmp/preview.log" },
stateHome,
@@ -664,7 +670,7 @@ describe("background preview lifecycle", () => {
});
it("verifies the owned port stopped even when another server serves the same project", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const stateHome = tempDir("hf-preview-state-");
const owned = { ...server, port: 41490 };
const sibling = { ...server, port: 41491, pid: "8765" };
writePreviewSession(
+19 -35
View File
@@ -10,7 +10,7 @@ import {
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it, onTestFinished, vi } from "vitest";
import type { RegistryItem } from "@hyperframes/core";
// The installer fetches over the network; the point of these tests is what it
@@ -24,7 +24,9 @@ vi.mock("./remote.js", () => ({
const { hasLocalEdits, installItem, prepareItem, publishItem } = await import("./installer.js");
function project(): string {
return mkdtempSync(join(tmpdir(), "hf-installer-"));
const dir = mkdtempSync(join(tmpdir(), "hf-installer-"));
onTestFinished(() => rmSync(dir, { recursive: true, force: true }));
return dir;
}
const item = {
@@ -62,29 +64,16 @@ describe("installItem", () => {
it("rejects a target directory symlink that escapes the project even with force", async () => {
const dir = project();
const outside = project();
try {
symlinkSync(outside, join(dir, "components"), "junction");
await expect(installItem(item, { destDir: dir, force: true })).rejects.toThrow(
/Unsafe target/,
);
expect(existsSync(join(outside, "data-chart.html"))).toBe(false);
} finally {
rmSync(dir, { recursive: true, force: true });
rmSync(outside, { recursive: true, force: true });
}
symlinkSync(outside, join(dir, "components"), "junction");
await expect(installItem(item, { destDir: dir, force: true })).rejects.toThrow(/Unsafe target/);
expect(existsSync(join(outside, "data-chart.html"))).toBe(false);
});
it("preserves directory symlinks whose destination stays inside the project", async () => {
const dir = project();
try {
mkdirSync(join(dir, "actual"));
symlinkSync(join(dir, "actual"), join(dir, "components"), "junction");
await installItem(item, { destDir: dir, force: true });
expect(readFileSync(join(dir, "actual/data-chart.html"), "utf8")).toContain(
"REGISTRY VERSION",
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
mkdirSync(join(dir, "actual"));
symlinkSync(join(dir, "actual"), join(dir, "components"), "junction");
await installItem(item, { destDir: dir, force: true });
expect(readFileSync(join(dir, "actual/data-chart.html"), "utf8")).toContain("REGISTRY VERSION");
});
it.each(["darwin", "win32"] as const)(
"rejects absent case and Unicode aliases on %s before downloads",
@@ -96,18 +85,14 @@ describe("installItem", () => {
["Café.html", "Cafe\u0301.html"],
]) {
const dir = project();
try {
const conflicting = {
...item,
files: names.map((name) => ({ ...item.files[0]!, target: `components/${name}` })),
};
await expect(installItem(conflicting, { destDir: dir, force: true })).rejects.toThrow(
/duplicate/,
);
expect(existsSync(join(dir, "components"))).toBe(false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
const conflicting = {
...item,
files: names.map((name) => ({ ...item.files[0]!, target: `components/${name}` })),
};
await expect(installItem(conflicting, { destDir: dir, force: true })).rejects.toThrow(
/duplicate/,
);
expect(existsSync(join(dir, "components"))).toBe(false);
}
} finally {
vi.restoreAllMocks();
@@ -285,7 +270,6 @@ describe("installing with --vars the item cannot take", () => {
expect(existsSync(join(dir, file))).toBe(false);
} finally {
remote.contents = "REGISTRY VERSION\n";
rmSync(dir, { recursive: true, force: true });
}
},
);
@@ -2,12 +2,13 @@
// payloads. Unit tests cover the streak arithmetic; this proves the wiring
// and pins the event shape a dashboard will be built against.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterAll } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const HOME = mkdtempSync(join(tmpdir(), "hf-lintrun-"));
afterAll(() => rmSync(HOME, { recursive: true, force: true }));
vi.mock("node:os", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:os")>();
return { ...actual, homedir: () => HOME };
@@ -12,7 +12,7 @@ import { tmpdir } from "node:os";
import { basename, dirname, join } from "node:path";
import { MEDIA_DURATION_FIXTURES } from "@hyperframes/parsers/media-duration-fixtures";
import { fileURLToPath } from "node:url";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it, onTestFinished } from "vitest";
import { ensureDOMParser } from "../utils/dom.js";
import {
createProbeGate,
@@ -47,6 +47,11 @@ const childrenOf = (t: ProjectTimeline, row: TimelineRow) =>
allRows(t).filter((r) => row.children.some((c) => c.kind === r.trackKind && c.index === r.index));
let dir = "";
const tempDir = (prefix: string) => {
const made = mkdtempSync(join(tmpdir(), prefix));
onTestFinished(() => rmSync(made, { recursive: true, force: true }));
return made;
};
const TINY_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==",
"base64",
@@ -67,15 +72,14 @@ const rowsOf = async (
};
const project = () => {
dir = mkdtempSync(join(tmpdir(), "hf-timeline-"));
mkdirSync(join(dir, "compositions"));
dir = join(tempDir("hf-timeline-"), "project");
mkdirSync(join(dir, "compositions"), { recursive: true });
writeFileSync(join(dir, "index.html"), INDEX);
writeFileSync(join(dir, "compositions", "title.html"), TITLE);
return join(dir, "index.html");
};
beforeAll(ensureDOMParser);
afterEach(() => rmSync(dir, { recursive: true, force: true }));
describe("describeProject", () => {
it("groups rows into tracks by kind with resolved timing and clip facts", async () => {
@@ -133,7 +137,6 @@ describe("describeProject", () => {
["o", 0],
["d", 0],
]);
rmSync(join(dir, "..", "hf-outside.html"));
});
it("reads a sub-composition whose folder name starts with two dots", async () => {
@@ -150,7 +153,7 @@ describe("describeProject", () => {
it("does not follow a symlink out of the project", async () => {
const index = project();
const outside = mkdtempSync(join(tmpdir(), "hf-outside-"));
const outside = tempDir("hf-outside-");
writeFileSync(join(outside, "secret.html"), TITLE);
symlinkSync(join(outside, "secret.html"), join(dir, "compositions", "link.html"));
writeFileSync(
@@ -159,7 +162,6 @@ describe("describeProject", () => {
);
const [row] = (await describeProject(index)).tracks.flatMap((t) => t.rows);
expect(row!.children).toEqual([]);
rmSync(outside, { recursive: true, force: true });
});
it("claims no duration source for a leaf with nothing authored and no children", async () => {
@@ -172,7 +174,7 @@ describe("describeProject", () => {
it("does not probe a remote, absolute or parent-relative src and says why", async () => {
const index = project();
const outside = mkdtempSync(join(tmpdir(), "hf-outside-media-"));
const outside = tempDir("hf-outside-media-");
copyFileSync(REAL_AUDIO, join(outside, "out.mp3"));
copyFileSync(REAL_AUDIO, join(dir, "..", "hf-parent-media.mp3"));
writeFileSync(
@@ -196,8 +198,6 @@ describe("describeProject", () => {
duration: 0,
});
}
rmSync(outside, { recursive: true, force: true });
rmSync(join(dir, "..", "hf-parent-media.mp3"));
});
it("reports a still image used as a video source as pending, not a measured zero", async () => {
@@ -215,22 +215,18 @@ describe("describeProject", () => {
});
it("does not probe a media src that is a symlink out of the project", async () => {
const outside = mkdtempSync(join(tmpdir(), "hf-outside-"));
try {
copyFileSync(REAL_AUDIO, join(outside, "secret.mp3"));
const { rows } = await rowsOf(
`<div data-composition-id="m"><audio id="a" src="link.mp3" data-start="0"></audio></div>`,
false,
(root) => symlinkSync(join(outside, "secret.mp3"), join(root, "link.mp3")),
);
expect(rows[0]).toMatchObject({
durationSource: "pending",
pendingReason: "source file not found",
duration: 0,
});
} finally {
rmSync(outside, { recursive: true, force: true });
}
const outside = tempDir("hf-outside-");
copyFileSync(REAL_AUDIO, join(outside, "secret.mp3"));
const { rows } = await rowsOf(
`<div data-composition-id="m"><audio id="a" src="link.mp3" data-start="0"></audio></div>`,
false,
(root) => symlinkSync(join(outside, "secret.mp3"), join(root, "link.mp3")),
);
expect(rows[0]).toMatchObject({
durationSource: "pending",
pendingReason: "source file not found",
duration: 0,
});
});
it("gives an image with no authored duration the resolver's default length", async () => {
@@ -328,7 +324,7 @@ const INVERSION_INDEX = `<div data-composition-id="main" data-duration="30">
const INVERSION_SCENE = `<template><div data-composition-id="scene"><video id="nested" src="n.mp4" data-start="1" data-duration="2" data-track-index="0"></video></div></template>`;
const inversionProject = () => {
dir = mkdtempSync(join(tmpdir(), "hf-timeline-abs-"));
dir = tempDir("hf-timeline-abs-");
mkdirSync(join(dir, "compositions"));
writeFileSync(join(dir, "index.html"), INVERSION_INDEX);
writeFileSync(join(dir, "compositions", "scene.html"), INVERSION_SCENE);
@@ -369,7 +365,7 @@ describe("absolute main-timeline time", () => {
});
it("places a nested media clip with a negative start where the runtime plays it", async () => {
dir = mkdtempSync(join(tmpdir(), "hf-timeline-neg-"));
dir = tempDir("hf-timeline-neg-");
mkdirSync(join(dir, "compositions"));
writeFileSync(join(dir, "index.html"), INVERSION_INDEX);
writeFileSync(
@@ -382,7 +378,7 @@ describe("absolute main-timeline time", () => {
});
it("resolves a media start given as an expression like any other clip, not as a literal", async () => {
dir = mkdtempSync(join(tmpdir(), "hf-timeline-expr-"));
dir = tempDir("hf-timeline-expr-");
mkdirSync(join(dir, "compositions"));
writeFileSync(join(dir, "index.html"), INVERSION_INDEX);
writeFileSync(
@@ -398,7 +394,7 @@ describe("absolute main-timeline time", () => {
});
it("clamps a negative media start to 0 when the host starts at 0, as the runtime does", async () => {
dir = mkdtempSync(join(tmpdir(), "hf-timeline-neg0-"));
dir = tempDir("hf-timeline-neg0-");
writeFileSync(
join(dir, "index.html"),
`<div data-composition-id="main" data-duration="10"><video id="v" src="v.mp4" data-start="-3" data-duration="2" data-track-index="0"></video></div>`,
@@ -412,7 +408,7 @@ describe("absolute main-timeline time", () => {
// hosted at main-timeline 5.2s too, and a video in an EARLIER host ends at
// 5.27s — a 0.07s overlap only visible once both are on the same clock.
it("carries enough absolute time to detect a sub-second overlap across two different sub-compositions", async () => {
dir = mkdtempSync(join(tmpdir(), "hf-timeline-overlap-"));
dir = tempDir("hf-timeline-overlap-");
mkdirSync(join(dir, "compositions"));
writeFileSync(
join(dir, "index.html"),
@@ -442,7 +438,7 @@ describe("absolute main-timeline time", () => {
// clip" has to be found by comparing videos nested in DIFFERENT
// sub-compositions, each printed with its own local 0-based start.
it("orders videos nested in different sub-compositions by absolute start, not local start", async () => {
dir = mkdtempSync(join(tmpdir(), "hf-timeline-order-"));
dir = tempDir("hf-timeline-order-");
mkdirSync(join(dir, "compositions"));
writeFileSync(
join(dir, "index.html"),
@@ -534,7 +530,7 @@ const EVAL_MUSIC = `<template><div data-composition-id="terminal-music">
</div></template>`;
const evalTimeline = () => {
dir = mkdtempSync(join(tmpdir(), "hf-timeline-eval-"));
dir = tempDir("hf-timeline-eval-");
mkdirSync(join(dir, "compositions"));
writeFileSync(join(dir, "index.html"), EVAL_INDEX);
writeFileSync(join(dir, "compositions", "terminal-sfx.html"), EVAL_SFX);
@@ -544,7 +540,7 @@ const evalTimeline = () => {
/** Ids, absent ids, and same ids across kinds and hosts; every clip has a distinct src. */
const collidingTimeline = () => {
dir = mkdtempSync(join(tmpdir(), "hf-timeline-collide-"));
dir = tempDir("hf-timeline-collide-");
mkdirSync(join(dir, "compositions"));
writeFileSync(
join(dir, "index.html"),
@@ -635,7 +631,7 @@ describe("kind tracks list every clip once", () => {
});
it("does not count a sub-composition inside a sub-composition", async () => {
dir = mkdtempSync(join(tmpdir(), "hf-timeline-deep-"));
dir = tempDir("hf-timeline-deep-");
mkdirSync(join(dir, "compositions"));
writeFileSync(join(dir, "index.html"), EVAL_INDEX);
writeFileSync(
@@ -674,17 +670,13 @@ const hasJq = (() => {
const runOneLiner = async (line: string, timeline?: ProjectTimeline): Promise<string> => {
const own = timeline ? null : inversionProject();
try {
return execFileSync("bash", ["-c", line], {
env: {
...process.env,
TL: JSON.stringify({ timeline: timeline ?? (await describeProject(own!)) }),
},
encoding: "utf8",
});
} finally {
if (own) rmSync(dirname(own), { recursive: true, force: true });
}
return execFileSync("bash", ["-c", line], {
env: {
...process.env,
TL: JSON.stringify({ timeline: timeline ?? (await describeProject(own!)) }),
},
encoding: "utf8",
});
};
const parseStream = (out: string) => JSON.parse(`[${out.replace(/}\s*{/g, "},{")}]`);
+15 -9
View File
@@ -1,7 +1,7 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, onTestFinished } from "vitest";
import { findMotionSpec, parseMotionSpec, readMotionSpec, type MotionSpec } from "./motionSpec.js";
const RFC_SPEC = {
@@ -19,6 +19,12 @@ function expectOk(result: ReturnType<typeof parseMotionSpec>): MotionSpec {
return result.spec;
}
function tempDir(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
onTestFinished(() => rmSync(dir, { recursive: true, force: true }));
return dir;
}
describe("parseMotionSpec", () => {
it("parses the RFC four-assertion spec", () => {
const spec = expectOk(parseMotionSpec(RFC_SPEC));
@@ -100,19 +106,19 @@ describe("parseMotionSpec", () => {
describe("findMotionSpec", () => {
it("returns null when no sidecar is present", () => {
const dir = mkdtempSync(join(tmpdir(), "motion-none-"));
const dir = tempDir("motion-none-");
writeFileSync(join(dir, "main.html"), "<div></div>");
expect(findMotionSpec(dir)).toBeNull();
});
it("finds the single sidecar", () => {
const dir = mkdtempSync(join(tmpdir(), "motion-one-"));
const dir = tempDir("motion-one-");
writeFileSync(join(dir, "anything.motion.json"), "{}");
expect(findMotionSpec(dir)).toBe(join(dir, "anything.motion.json"));
});
it("prefers the sidecar matching a composition html basename", () => {
const dir = mkdtempSync(join(tmpdir(), "motion-many-"));
const dir = tempDir("motion-many-");
writeFileSync(join(dir, "aaa.motion.json"), "{}");
writeFileSync(join(dir, "main.motion.json"), "{}");
writeFileSync(join(dir, "main.html"), "<div></div>");
@@ -120,7 +126,7 @@ describe("findMotionSpec", () => {
});
it("throws when multiple sidecars each match a different composition", () => {
const dir = mkdtempSync(join(tmpdir(), "motion-ambig-"));
const dir = tempDir("motion-ambig-");
writeFileSync(join(dir, "hero.motion.json"), "{}");
writeFileSync(join(dir, "landing.motion.json"), "{}");
writeFileSync(join(dir, "hero.html"), "<div></div>");
@@ -137,7 +143,7 @@ describe("readMotionSpec", () => {
});
it("returns error for a file with invalid JSON", () => {
const dir = mkdtempSync(join(tmpdir(), "motion-bad-"));
const dir = tempDir("motion-bad-");
const path = join(dir, "bad.motion.json");
writeFileSync(path, "not json {{");
const result = readMotionSpec(path);
@@ -146,7 +152,7 @@ describe("readMotionSpec", () => {
});
it("returns error for a file with a valid JSON but invalid spec", () => {
const dir = mkdtempSync(join(tmpdir(), "motion-invalid-"));
const dir = tempDir("motion-invalid-");
const path = join(dir, "invalid.motion.json");
writeFileSync(path, JSON.stringify({ assertions: [] }));
const result = readMotionSpec(path);
@@ -155,7 +161,7 @@ describe("readMotionSpec", () => {
});
it("parses a valid sidecar file", () => {
const dir = mkdtempSync(join(tmpdir(), "motion-valid-"));
const dir = tempDir("motion-valid-");
const path = join(dir, "main.motion.json");
writeFileSync(
path,
@@ -1,12 +1,13 @@
// @vitest-environment node
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, onTestFinished } from "vitest";
import { buildSubCompositionHtml, hasBaseElement } from "./subComposition";
function makeTempProject(files: Record<string, string>): string {
const dir = mkdtempSync(join(tmpdir(), "hf-subcomp-preview-"));
onTestFinished(() => rmSync(dir, { recursive: true, force: true }));
for (const [rel, content] of Object.entries(files)) {
const full = join(dir, rel);
mkdirSync(join(full, ".."), { recursive: true });