Files
hyperframes/packages/cli/src/commands/figma/asset.test.ts
T
Vance IngallsandClaude Fable 5 87e2a70f9a fix(core,cli): address PR review — consistent 403 error shape, cap Retry-After, URL-safe ref split
Rames's inline findings on #2112:
- forbiddenError now RETURNS in every branch (BAD_TOKEN no longer throws
  inside) so the caller's single throw covers all cases — no mixed
  throw/return contract for a future wrapping caller.
- retryAfterMs capped at 60s: a spec-legal Retry-After: 3600 no longer
  silently blocks the CLI for an hour before RATE_LIMITED.
- asset ref gathering extracted to gatherAssetRefs() and made URL-safe:
  bare fileKey:nodeId tokens comma-split, but a figma URL with commas in
  its query (multi-select node-id=1:2,3:4) is kept whole.
- Documented in SKILL that 429 retry lives in the shared request path, so
  EVERY read endpoint retries (not just asset) — blast-radius note the
  reviewer asked for. variables intentionally still retries: its fallback
  is REQUIRES_ENTERPRISE-only, and a 429 there is transient, not a gate.

Tests: retry-cap (3600→60000), non-styles endpoint retry, gatherAssetRefs
URL-vs-bare split. client 24, cli asset 11.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 15:31:11 -07:00

221 lines
8.1 KiB
TypeScript

// @vitest-environment node
import { describe, expect, it, afterEach } from "vitest";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
gatherAssetRefs,
runAssetImport,
runAssetImportMany,
type AssetImportDeps,
} from "./asset.js";
import type { FigmaClient } from "@hyperframes/core/figma";
const dirs: string[] = [];
function scratch(): string {
const d = mkdtempSync(join(tmpdir(), "hf-figma-asset-"));
dirs.push(d);
return d;
}
afterEach(() => {
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
});
function fakeClient(overrides: Partial<FigmaClient> = {}): FigmaClient {
const client: FigmaClient = {
renderNode: () => Promise.resolve({ url: "https://cdn.example/a", ext: "png" }),
renderNodes: () => Promise.resolve([]),
imageFills: () => Promise.resolve(new Map()),
variables: () => Promise.resolve({ variables: {}, variableCollections: {} }),
styles: () => Promise.resolve([]),
nodeTree: () => Promise.resolve({ id: "1:2", name: "n", type: "FRAME" }),
fileVersion: () => Promise.resolve({ version: "7", lastModified: "2026-07-01" }),
...overrides,
};
// Default renderNodes delegates to renderNode (honoring any override) so
// existing single-node tests keep controlling behavior via renderNode.
if (!overrides.renderNodes) {
client.renderNodes = (fileKey, nodeIds, opts) =>
Promise.all(
nodeIds.map((nodeId) =>
client
.renderNode({ fileKey, nodeId }, opts)
.then((r) => ({ nodeId, url: r.url, ext: r.ext })),
),
);
}
return client;
}
const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
function deps(projectDir: string, overrides: Partial<AssetImportDeps> = {}): AssetImportDeps {
return {
projectDir,
client: fakeClient(),
download: () => Promise.resolve(PNG_BYTES),
...overrides,
};
}
describe("runAssetImport", () => {
it("freezes the render, appends a manifest record with provenance, returns a snippet", async () => {
const dir = scratch();
const out = await runAssetImport(
"https://www.figma.com/design/FILEKEY/T?node-id=1-2",
{ format: "png" },
deps(dir),
);
expect(out.record.provenance).toMatchObject({
source: "figma",
fileKey: "FILEKEY",
nodeId: "1:2",
version: "7",
format: "png",
});
expect(out.snippet.html).toContain("<img");
const frozen = readFileSync(join(dir, out.record.path));
expect(Array.from(frozen)).toEqual(Array.from(PNG_BYTES));
const manifest = readFileSync(join(dir, ".media", "manifest.jsonl"), "utf8");
expect(manifest).toContain('"fileKey":"FILEKEY"');
});
it("sanitizes svg output before freezing", async () => {
const dir = scratch();
const dirty = `<svg><script>evil()</script><rect width="1"/></svg>`;
const out = await runAssetImport(
"FILEKEY:1-2",
{ format: "svg" },
deps(dir, {
client: fakeClient({
renderNode: () => Promise.resolve({ url: "https://cdn.example/a", ext: "svg" }),
}),
download: () => Promise.resolve(new TextEncoder().encode(dirty)),
}),
);
const frozen = readFileSync(join(dir, out.record.path), "utf8");
expect(frozen).not.toContain("script");
expect(frozen).toContain("<rect");
});
it("reuses on identical version, re-imports when the file version moved on", async () => {
const dir = scratch();
const importPng = (over?: Partial<AssetImportDeps>) =>
runAssetImport("FILEKEY:1-2", { format: "png" }, deps(dir, over));
const first = await importPng();
const sameVersion = await importPng();
expect(sameVersion.record.id).toBe(first.record.id);
expect(sameVersion.reused).toBe(true);
const bumped = await importPng({
client: fakeClient({
fileVersion: () => Promise.resolve({ version: "8", lastModified: "2026-07-02" }),
}),
});
expect(bumped.reused).toBe(false);
expect(bumped.record.id).not.toBe(first.record.id);
});
it("rejects a ref without a node id", async () => {
await expect(runAssetImport("FILEKEYONLY", { format: "png" }, deps(scratch()))).rejects.toThrow(
/node/i,
);
});
it("records description + entity and regenerates .media/index.md", async () => {
const dir = scratch();
const out = await runAssetImport(
"KEY:1-2",
{ format: "png", description: "hero illustration", entity: "Acme hero" },
deps(dir),
);
expect(out.record.description).toBe("hero illustration");
expect(out.record.entity).toBe("Acme hero");
expect(out.snippet.html).toContain('alt="hero illustration"');
const index = readFileSync(join(dir, ".media", "index.md"), "utf8");
expect(index).toContain("hero illustration");
expect(index).toContain(out.record.id);
});
it("applies --description/--entity on a reuse hit instead of dropping them", async () => {
const dir = scratch();
const first = await runAssetImport("KEY:1-2", { format: "png" }, deps(dir));
expect(first.record.description).toBeUndefined();
const again = await runAssetImport(
"KEY:1-2",
{ format: "png", description: "Acme logo", entity: "Acme logo" },
deps(dir),
);
expect(again.reused).toBe(true);
expect(again.record.description).toBe("Acme logo");
expect(again.snippet.html).toContain('alt="Acme logo"');
const index = readFileSync(join(dir, ".media", "index.md"), "utf8");
expect(index).toContain("Acme logo");
expect(index).not.toContain("image_002");
});
it("batches many nodes into ONE renderNodes call and freezes each", async () => {
const dir = scratch();
let renderNodesCalls = 0;
let batchSize = 0;
const batchClient = fakeClient({
renderNodes: (fileKey, nodeIds, opts) => {
renderNodesCalls += 1;
batchSize = nodeIds.length;
return Promise.resolve(
nodeIds.map((nodeId) => ({ nodeId, url: `https://cdn/${nodeId}`, ext: opts.format })),
);
},
});
const results = await runAssetImportMany(
["KEY:1-2", "KEY:3-4", "KEY:5-6"],
{ format: "png" },
deps(dir, { client: batchClient }),
);
expect(results).toHaveLength(3);
expect(results.every((r) => !r.reused)).toBe(true);
expect(renderNodesCalls).toBe(1); // one REST call for all three
expect(batchSize).toBe(3);
// distinct frozen files, all recorded
expect(new Set(results.map((r) => r.record.id)).size).toBe(3);
});
it("gatherAssetRefs splits bare comma-joined ids but keeps URLs whole", () => {
// bare tokens comma-split
expect(gatherAssetRefs(["KEY:1-2,KEY:3-4"])).toEqual(["KEY:1-2", "KEY:3-4"]);
// space-separated positionals preserved
expect(gatherAssetRefs(["KEY:1-2", "KEY:3-4"])).toEqual(["KEY:1-2", "KEY:3-4"]);
// a URL with a comma in its query is NOT torn apart
const url = "https://www.figma.com/design/KEY/F?node-id=1:2,3:4";
expect(gatherAssetRefs([url])).toEqual([url]);
// mixed: URL stays whole, bare token splits
expect(gatherAssetRefs([url, "KEY:5-6,KEY:7-8"])).toEqual([url, "KEY:5-6", "KEY:7-8"]);
});
it("splits comma-joined refs and rejects a cross-file batch", async () => {
const dir = scratch();
await expect(
runAssetImportMany(["KEY:1-2", "OTHER:3-4"], { format: "png" }, deps(dir)),
).rejects.toThrow(/share a fileKey/);
});
it("reuses against ANY matching tuple, not just the oldest row", async () => {
const dir = scratch();
await runAssetImport("KEY:1-2", { format: "svg" }, deps(dir)); // image_001 (svg)
const png = await runAssetImport("KEY:1-2", { format: "png" }, deps(dir)); // image_002
expect(png.reused).toBe(false);
const pngAgain = await runAssetImport("KEY:1-2", { format: "png" }, deps(dir));
expect(pngAgain.reused).toBe(true);
expect(pngAgain.record.id).toBe(png.record.id);
});
it("flattens whitespace in descriptions so index.md rows stay single-line", async () => {
const dir = scratch();
const out = await runAssetImport(
"KEY:1-2",
{ format: "png", description: "line one\nline two" },
deps(dir),
);
expect(out.record.description).toBe("line one line two");
});
});