Files
hyperframes/packages/cli/src/utils/publishProject.test.ts
T
Miguel Ángel 856bb0980f feat(cli): add --public flag to publish (#1815)
Opt-in --public flag on `hyperframes publish` sends is_public to the
publish endpoints (staged complete body and direct multipart form) so a
claimed project's studio session can be created public instead of the
default private. Absent the flag the request shape is unchanged.
2026-06-30 11:54:10 -07:00

582 lines
20 KiB
TypeScript

import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, relative } from "node:path";
import AdmZip from "adm-zip";
import {
createPublishArchive,
getPublishApiBaseUrl,
localizeExternalAssets,
publishProjectArchive,
uploadTimeoutMs,
} from "./publishProject.js";
function makeProjectDir(): string {
return mkdtempSync(join(tmpdir(), "hf-publish-"));
}
describe("createPublishArchive", () => {
it("packages the project and skips hidden files and node_modules", () => {
const dir = makeProjectDir();
try {
writeFileSync(join(dir, "index.html"), "<html></html>", "utf-8");
mkdirSync(join(dir, "assets"));
writeFileSync(join(dir, "assets/logo.svg"), "<svg />", "utf-8");
mkdirSync(join(dir, ".git"));
writeFileSync(join(dir, ".env"), "SECRET=1", "utf-8");
mkdirSync(join(dir, "node_modules"));
writeFileSync(join(dir, "node_modules/ignored.js"), "console.log('ignore')", "utf-8");
const archive = createPublishArchive(dir);
expect(archive.fileCount).toBe(2);
expect(archive.buffer.byteLength).toBeGreaterThan(0);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("localizeExternalAssets", () => {
it("copies external src/href assets and rewrites HTML paths", () => {
const projectDir = makeProjectDir();
const extDir = mkdtempSync(join(tmpdir(), "hf-ext-"));
try {
writeFileSync(join(extDir, "logo.png"), "PNG_DATA", "utf-8");
const relToExt = relative(projectDir, join(extDir, "logo.png")).replaceAll("\\", "/");
const html = `<html><body><img src="${relToExt}"></body></html>`;
const files = new Map<string, Buffer>();
files.set("index.html", Buffer.from(html, "utf-8"));
const count = localizeExternalAssets(projectDir, files);
expect(count).toBe(1);
const rewrittenHtml = files.get("index.html")!.toString("utf-8");
expect(rewrittenHtml).not.toContain(relToExt);
expect(rewrittenHtml).toContain("_ext/");
const extEntries = [...files.keys()].filter((k) => k.startsWith("_ext/"));
expect(extEntries).toHaveLength(1);
expect(files.get(extEntries[0]!)!.toString("utf-8")).toBe("PNG_DATA");
} finally {
rmSync(projectDir, { recursive: true, force: true });
rmSync(extDir, { recursive: true, force: true });
}
});
it("rewrites CSS url() in <style> blocks", () => {
const projectDir = makeProjectDir();
const extDir = mkdtempSync(join(tmpdir(), "hf-ext-"));
try {
writeFileSync(join(extDir, "bg.jpg"), "JPEG_DATA", "utf-8");
const relToExt = relative(projectDir, join(extDir, "bg.jpg")).replaceAll("\\", "/");
const html = `<html><head><style>body { background: url("${relToExt}"); }</style></head></html>`;
const files = new Map<string, Buffer>();
files.set("index.html", Buffer.from(html, "utf-8"));
const count = localizeExternalAssets(projectDir, files);
expect(count).toBe(1);
const rewrittenHtml = files.get("index.html")!.toString("utf-8");
expect(rewrittenHtml).toContain("url(");
expect(rewrittenHtml).toContain("_ext/");
expect(rewrittenHtml).not.toContain(relToExt);
} finally {
rmSync(projectDir, { recursive: true, force: true });
rmSync(extDir, { recursive: true, force: true });
}
});
it("rewrites url() in standalone CSS files", () => {
const projectDir = makeProjectDir();
const extDir = mkdtempSync(join(tmpdir(), "hf-ext-"));
try {
writeFileSync(join(extDir, "font.woff2"), "FONT_DATA", "utf-8");
const relToExt = relative(projectDir, join(extDir, "font.woff2")).replaceAll("\\", "/");
const css = `@font-face { src: url("${relToExt}"); }`;
const files = new Map<string, Buffer>();
files.set("index.html", Buffer.from("<html></html>", "utf-8"));
files.set("styles.css", Buffer.from(css, "utf-8"));
const count = localizeExternalAssets(projectDir, files);
expect(count).toBe(1);
const rewrittenCss = files.get("styles.css")!.toString("utf-8");
expect(rewrittenCss).toContain("_ext/");
expect(rewrittenCss).not.toContain(relToExt);
} finally {
rmSync(projectDir, { recursive: true, force: true });
rmSync(extDir, { recursive: true, force: true });
}
});
it("leaves internal assets unchanged", () => {
const projectDir = makeProjectDir();
try {
mkdirSync(join(projectDir, "assets"));
writeFileSync(join(projectDir, "assets", "logo.svg"), "<svg/>", "utf-8");
const html = `<html><body><img src="assets/logo.svg"></body></html>`;
const files = new Map<string, Buffer>();
files.set("index.html", Buffer.from(html, "utf-8"));
files.set("assets/logo.svg", Buffer.from("<svg/>", "utf-8"));
const count = localizeExternalAssets(projectDir, files);
expect(count).toBe(0);
const rewrittenHtml = files.get("index.html")!.toString("utf-8");
expect(rewrittenHtml).toContain('src="assets/logo.svg"');
expect([...files.keys()].filter((k) => k.startsWith("_ext/"))).toHaveLength(0);
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});
it("leaves remote URLs unchanged", () => {
const projectDir = makeProjectDir();
try {
const html = `<html><body><img src="https://cdn.example.com/logo.png"><video src="http://cdn.example.com/vid.mp4"></video></body></html>`;
const files = new Map<string, Buffer>();
files.set("index.html", Buffer.from(html, "utf-8"));
const count = localizeExternalAssets(projectDir, files);
expect(count).toBe(0);
const rewrittenHtml = files.get("index.html")!.toString("utf-8");
expect(rewrittenHtml).toContain("https://cdn.example.com/logo.png");
expect(rewrittenHtml).toContain("http://cdn.example.com/vid.mp4");
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});
it("deduplicates: same external asset referenced from multiple files", () => {
const projectDir = makeProjectDir();
const extDir = mkdtempSync(join(tmpdir(), "hf-ext-"));
try {
writeFileSync(join(extDir, "shared.png"), "SHARED", "utf-8");
const relToExt = relative(projectDir, join(extDir, "shared.png")).replaceAll("\\", "/");
const files = new Map<string, Buffer>();
files.set(
"index.html",
Buffer.from(`<html><body><img src="${relToExt}"></body></html>`, "utf-8"),
);
mkdirSync(join(projectDir, "compositions"));
files.set(
"compositions/scene.html",
Buffer.from(`<html><body><img src="../${relToExt}"></body></html>`, "utf-8"),
);
const count = localizeExternalAssets(projectDir, files);
expect(count).toBe(1);
const extEntries = [...files.keys()].filter((k) => k.startsWith("_ext/"));
expect(extEntries).toHaveLength(1);
} finally {
rmSync(projectDir, { recursive: true, force: true });
rmSync(extDir, { recursive: true, force: true });
}
});
it("handles sub-composition HTML with external refs", () => {
const projectDir = makeProjectDir();
const extDir = mkdtempSync(join(tmpdir(), "hf-ext-"));
try {
mkdirSync(join(projectDir, "compositions"));
writeFileSync(join(extDir, "overlay.png"), "OVERLAY", "utf-8");
const relFromComps = relative(
join(projectDir, "compositions"),
join(extDir, "overlay.png"),
).replaceAll("\\", "/");
const files = new Map<string, Buffer>();
files.set("index.html", Buffer.from("<html></html>", "utf-8"));
files.set(
"compositions/scene.html",
Buffer.from(`<html><body><img src="${relFromComps}"></body></html>`, "utf-8"),
);
const count = localizeExternalAssets(projectDir, files);
expect(count).toBe(1);
const rewritten = files.get("compositions/scene.html")!.toString("utf-8");
expect(rewritten).toContain("_ext/");
expect(rewritten).not.toContain(relFromComps);
} finally {
rmSync(projectDir, { recursive: true, force: true });
rmSync(extDir, { recursive: true, force: true });
}
});
it("no-op when no external assets exist", () => {
const projectDir = makeProjectDir();
try {
const html = `<html><body><p>Hello</p></body></html>`;
const files = new Map<string, Buffer>();
files.set("index.html", Buffer.from(html, "utf-8"));
const count = localizeExternalAssets(projectDir, files);
expect(count).toBe(0);
expect(files.size).toBe(1);
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});
it("skips references to non-existent external files", () => {
const projectDir = makeProjectDir();
try {
const html = `<html><body><img src="../nonexistent/file.png"></body></html>`;
const files = new Map<string, Buffer>();
files.set("index.html", Buffer.from(html, "utf-8"));
const count = localizeExternalAssets(projectDir, files);
expect(count).toBe(0);
const rewrittenHtml = files.get("index.html")!.toString("utf-8");
expect(rewrittenHtml).toContain("../nonexistent/file.png");
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});
it("rewrites inline style url() references", () => {
const projectDir = makeProjectDir();
const extDir = mkdtempSync(join(tmpdir(), "hf-ext-"));
try {
writeFileSync(join(extDir, "bg.jpg"), "JPEG_DATA", "utf-8");
const relToExt = relative(projectDir, join(extDir, "bg.jpg")).replaceAll("\\", "/");
const html = `<html><body><div style="background-image: url('${relToExt}')"></div></body></html>`;
const files = new Map<string, Buffer>();
files.set("index.html", Buffer.from(html, "utf-8"));
const count = localizeExternalAssets(projectDir, files);
expect(count).toBe(1);
const rewrittenHtml = files.get("index.html")!.toString("utf-8");
expect(rewrittenHtml).toContain("_ext/");
expect(rewrittenHtml).not.toContain(relToExt);
} finally {
rmSync(projectDir, { recursive: true, force: true });
rmSync(extDir, { recursive: true, force: true });
}
});
it("createPublishArchive includes localized external assets", () => {
const projectDir = makeProjectDir();
const extDir = mkdtempSync(join(tmpdir(), "hf-ext-"));
try {
writeFileSync(join(extDir, "video.mp4"), "MP4_DATA", "utf-8");
const relToExt = relative(projectDir, join(extDir, "video.mp4")).replaceAll("\\", "/");
writeFileSync(
join(projectDir, "index.html"),
`<html><body><video src="${relToExt}"></video></body></html>`,
"utf-8",
);
const archive = createPublishArchive(projectDir);
expect(archive.fileCount).toBe(2);
const zip = new AdmZip(archive.buffer);
const entries = zip.getEntries().map((e) => e.entryName);
expect(entries).toContain("index.html");
expect(entries.some((e) => e.startsWith("_ext/") && e.endsWith("video.mp4"))).toBe(true);
const indexHtml = zip.readAsText("index.html");
expect(indexHtml).toContain("_ext/");
expect(indexHtml).not.toContain(relToExt);
} finally {
rmSync(projectDir, { recursive: true, force: true });
rmSync(extDir, { recursive: true, force: true });
}
});
});
describe("uploadTimeoutMs", () => {
it("returns the minimum timeout for small files", () => {
expect(uploadTimeoutMs(0)).toBe(120_000);
expect(uploadTimeoutMs(50 * 1024 * 1024)).toBe(120_000);
});
it("scales above the floor for large files", () => {
expect(uploadTimeoutMs(64 * 1024 * 1024)).toBeGreaterThan(120_000);
expect(uploadTimeoutMs(500 * 1024 * 1024)).toBeGreaterThan(900_000);
});
it("returns an integer", () => {
expect(Number.isInteger(uploadTimeoutMs(123_456))).toBe(true);
});
});
describe("publishProjectArchive", () => {
beforeEach(() => {
vi.stubEnv("HYPERFRAMES_PUBLISHED_PROJECTS_API_URL", "");
vi.stubEnv("HEYGEN_API_URL", "");
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
it("uploads through the staged publish flow and returns the stable project URL", async () => {
const dir = makeProjectDir();
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify({
data: {
upload_url:
"https://s3.example.com/upload?X-Amz-SignedHeaders=content-length;content-type;host;x-amz-server-side-encryption",
upload_key: "ephemeral_store/hyperframes/project_uploads/upload-1/demo.zip",
upload_headers: {
"content-type": "application/zip",
"x-amz-server-side-encryption": "AES256",
},
content_type: "application/zip",
},
}),
{ status: 200 },
),
)
.mockResolvedValueOnce(new Response(null, { status: 200 }))
.mockResolvedValueOnce(
new Response(
JSON.stringify({
data: {
project_id: "hfp_123",
title: "demo",
file_count: 2,
url: "https://hyperframes.dev/p/hfp_123",
claim_token: "claim-token",
},
}),
{ status: 200 },
),
);
vi.stubGlobal("fetch", fetchMock);
try {
writeFileSync(join(dir, "index.html"), "<html></html>", "utf-8");
writeFileSync(join(dir, "styles.css"), "body {}", "utf-8");
const result = await publishProjectArchive(dir);
expect(getPublishApiBaseUrl()).toBe("https://api2.heygen.com");
expect(result).toMatchObject({
projectId: "hfp_123",
url: "https://hyperframes.dev/p/hfp_123",
});
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"https://api2.heygen.com/v1/hyperframes/projects/publish/upload",
expect.objectContaining({
method: "POST",
headers: { "content-type": "application/json", heygen_route: "canary" },
signal: expect.any(AbortSignal),
}),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"https://s3.example.com/upload?X-Amz-SignedHeaders=content-length;content-type;host;x-amz-server-side-encryption",
expect.objectContaining({
method: "PUT",
headers: {
"content-length": expect.any(String),
"content-type": "application/zip",
"x-amz-server-side-encryption": "AES256",
},
signal: expect.any(AbortSignal),
}),
);
expect(fetchMock).toHaveBeenNthCalledWith(
3,
"https://api2.heygen.com/v1/hyperframes/projects/publish/complete",
expect.objectContaining({
method: "POST",
headers: { "content-type": "application/json", heygen_route: "canary" },
signal: expect.any(AbortSignal),
}),
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("falls back to the legacy multipart endpoint when staged publish is not deployed", async () => {
const dir = makeProjectDir();
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response("not found", { status: 404 }))
.mockResolvedValueOnce(
new Response(
JSON.stringify({
data: {
project_id: "hfp_123",
title: "demo",
file_count: 2,
url: "https://hyperframes.dev/p/hfp_123",
claim_token: "claim-token",
},
}),
{ status: 200 },
),
);
vi.stubGlobal("fetch", fetchMock);
try {
writeFileSync(join(dir, "index.html"), "<html></html>", "utf-8");
const result = await publishProjectArchive(dir);
expect(result.projectId).toBe("hfp_123");
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"https://api2.heygen.com/v1/hyperframes/projects/publish",
expect.objectContaining({
method: "POST",
headers: { heygen_route: "canary" },
signal: expect.any(AbortSignal),
}),
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("sends is_public in the staged complete body only when public is requested", async () => {
const dir = makeProjectDir();
const stagedFetch = () =>
vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify({
data: {
upload_url: "https://s3.example.com/upload",
upload_key: "ephemeral_store/hyperframes/project_uploads/upload-1/demo.zip",
upload_headers: { "content-type": "application/zip" },
content_type: "application/zip",
},
}),
{ status: 200 },
),
)
.mockResolvedValueOnce(new Response(null, { status: 200 }))
.mockResolvedValueOnce(
new Response(
JSON.stringify({
data: {
project_id: "hfp_123",
title: "demo",
file_count: 1,
url: "https://hyperframes.dev/p/hfp_123",
claim_token: "claim-token",
},
}),
{ status: 200 },
),
);
try {
writeFileSync(join(dir, "index.html"), "<html></html>", "utf-8");
const publicFetch = stagedFetch();
vi.stubGlobal("fetch", publicFetch);
await publishProjectArchive(dir, { public: true });
const publicCompleteBody = JSON.parse(publicFetch.mock.calls[2]![1].body);
expect(publicCompleteBody.is_public).toBe(true);
const defaultFetch = stagedFetch();
vi.stubGlobal("fetch", defaultFetch);
await publishProjectArchive(dir);
const defaultCompleteBody = JSON.parse(defaultFetch.mock.calls[2]![1].body);
expect(defaultCompleteBody).not.toHaveProperty("is_public");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("sends is_public in the direct multipart form only when public is requested", async () => {
const dir = makeProjectDir();
const directFetch = () =>
vi
.fn()
.mockResolvedValueOnce(new Response("not found", { status: 404 }))
.mockResolvedValueOnce(
new Response(
JSON.stringify({
data: {
project_id: "hfp_123",
title: "demo",
file_count: 1,
url: "https://hyperframes.dev/p/hfp_123",
claim_token: "claim-token",
},
}),
{ status: 200 },
),
);
try {
writeFileSync(join(dir, "index.html"), "<html></html>", "utf-8");
const publicFetch = directFetch();
vi.stubGlobal("fetch", publicFetch);
await publishProjectArchive(dir, { public: true });
const publicForm = publicFetch.mock.calls[1]![1].body as FormData;
expect(publicForm.get("is_public")).toBe("true");
const defaultFetch = directFetch();
vi.stubGlobal("fetch", defaultFetch);
await publishProjectArchive(dir);
const defaultForm = defaultFetch.mock.calls[1]![1].body as FormData;
expect(defaultForm.get("is_public")).toBeNull();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("does not fall back to multipart when a staged S3 upload fails", async () => {
const dir = makeProjectDir();
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify({
data: {
upload_url: "https://s3.example.com/upload",
upload_key: "ephemeral_store/hyperframes/project_uploads/upload-1/demo.zip",
upload_headers: {
"content-type": "application/zip",
"x-amz-server-side-encryption": "AES256",
},
content_type: "application/zip",
},
}),
{ status: 200 },
),
)
.mockResolvedValueOnce(new Response("denied", { status: 403 }));
vi.stubGlobal("fetch", fetchMock);
try {
writeFileSync(join(dir, "index.html"), "<html></html>", "utf-8");
await expect(publishProjectArchive(dir)).rejects.toThrow("Failed to upload project archive");
expect(fetchMock).toHaveBeenCalledTimes(2);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});