Files
nexu-io--open-design/apps/daemon/tests/plugins-figma-extract.test.ts
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

299 lines
11 KiB
TypeScript

// Phase 6 entry slice — figma-extract atom impl.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { runFigmaExtract } from '../src/plugins/atoms/figma-extract.js';
let cwd: string;
beforeEach(async () => {
cwd = await mkdtemp(path.join(os.tmpdir(), 'od-figma-extract-'));
});
afterEach(async () => {
await rm(cwd, { recursive: true, force: true });
});
const fixtureFile = {
document: {
id: '0:0',
name: 'Document',
type: 'DOCUMENT',
children: [
{
id: '1:1', name: 'Page', type: 'CANVAS',
children: [
{
id: '2:1', name: 'Hero',
type: 'FRAME',
absoluteBoundingBox: { x: 0, y: 0, width: 1280, height: 720 },
cornerRadius: 12,
fills: [{ type: 'SOLID', color: { r: 0.357, g: 0.553, b: 0.937 } }],
children: [
{
id: '3:1', name: 'Title', type: 'TEXT',
characters: 'Hello world',
absoluteBoundingBox: { x: 24, y: 24, width: 200, height: 48 },
fills: [{ type: 'SOLID', color: { r: 0.067, g: 0.067, b: 0.067 } }],
},
{
id: '3:2', name: 'BG card', type: 'RECTANGLE',
fills: [{ type: 'GRADIENT_LINEAR' }],
absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 100 },
},
],
},
],
},
],
},
version: '1234',
lastModified: '2026-05-09T00:00:00Z',
};
const stubFetch = (response: { ok?: boolean; status?: number; statusText?: string; body?: unknown; text?: string }) => {
return vi.fn(async (_url: string, init?: { headers?: Record<string, string> }) => {
return {
ok: response.ok ?? true,
status: response.status ?? 200,
statusText: response.statusText ?? 'OK',
headers: { get: () => null },
json: async () => response.body ?? {},
text: async () => response.text ?? '',
} as unknown as Response;
});
};
describe('runFigmaExtract — happy paths', () => {
it('walks the Figma document into a flat tree with parents + boxes', async () => {
const fetchFn = stubFetch({ body: fixtureFile });
const report = await runFigmaExtract({
cwd,
fileUrl: 'https://figma.com/file/ABC123/Whatever',
token: 'tok',
fetchFn: fetchFn as unknown as typeof fetch,
});
expect(report.tree).toHaveLength(5); // doc + canvas + hero + title + rect
const hero = report.tree.find((n) => n.id === '2:1');
expect(hero?.parent).toBe('1:1');
expect(hero?.box?.w).toBe(1280);
expect(hero?.fill).toBe('#5b8def');
const title = report.tree.find((n) => n.id === '3:1');
expect(title?.text).toBe('Hello world');
expect(title?.fill).toBe('#111111');
});
it('lifts tokens (color + radius + spacing) into a design-extract-shaped bag', async () => {
const fetchFn = stubFetch({ body: fixtureFile });
const report = await runFigmaExtract({
cwd,
fileUrl: 'https://figma.com/file/ABC123/Whatever',
token: 'tok',
fetchFn: fetchFn as unknown as typeof fetch,
});
const colorValues = report.tokens.colors.map((t) => t.value);
expect(colorValues).toEqual(expect.arrayContaining(['#5b8def', '#111111']));
expect(report.tokens.radius.map((t) => t.value)).toContain('12px');
expect(report.tokens.spacing.length).toBeGreaterThan(0);
});
it('persists figma/{tree.json, tokens.json, meta.json} under cwd', async () => {
const fetchFn = stubFetch({ body: fixtureFile });
await runFigmaExtract({
cwd,
fileUrl: 'https://figma.com/file/ABC123/Whatever',
token: 'tok',
fetchFn: fetchFn as unknown as typeof fetch,
});
const tree = JSON.parse(await readFile(path.join(cwd, 'figma', 'tree.json'), 'utf8'));
const tokens = JSON.parse(await readFile(path.join(cwd, 'figma', 'tokens.json'), 'utf8'));
const meta = JSON.parse(await readFile(path.join(cwd, 'figma', 'meta.json'), 'utf8'));
expect(Array.isArray(tree)).toBe(true);
expect(meta.fileKey).toBe('ABC123');
expect(meta.version).toBe('1234');
expect(meta.lastModified).toBe('2026-05-09T00:00:00Z');
expect(meta.atomDigest.length).toBe(40);
expect(meta.unsupportedNodes.length).toBeGreaterThan(0); // gradient
expect(typeof tokens.colors[0]?.value).toBe('string');
});
it('records gradient + image fills in meta.unsupportedNodes', async () => {
const fetchFn = stubFetch({ body: fixtureFile });
const report = await runFigmaExtract({
cwd,
fileUrl: 'https://figma.com/file/ABC123/Whatever',
token: 'tok',
fetchFn: fetchFn as unknown as typeof fetch,
});
const unsup = report.meta.unsupportedNodes.find((u) => u.id === '3:2');
expect(unsup).toBeDefined();
expect(unsup?.reason).toMatch(/GRADIENT_LINEAR/);
});
it('uses fileKey directly when supplied (skipping URL parsing)', async () => {
const fetchFn = stubFetch({ body: fixtureFile });
const report = await runFigmaExtract({
cwd,
fileKey: 'XYZ789',
token: 'tok',
fetchFn: fetchFn as unknown as typeof fetch,
});
expect(report.meta.fileKey).toBe('XYZ789');
// First call URL contains the key.
expect(fetchFn).toHaveBeenCalledWith(
expect.stringContaining('XYZ789'),
expect.objectContaining({ headers: { Authorization: 'Bearer tok' } }),
);
});
});
describe('runFigmaExtract — asset rasterisation', () => {
// Fixture with two non-text leaf nodes (3:2 + 3:3) so the asset
// candidate set is multi-id. The base fixtureFile only has one
// (3:2 — a RECTANGLE with a gradient fill).
const assetFixture = {
document: {
id: '0:0', name: 'Document', type: 'DOCUMENT',
children: [{
id: '1:1', name: 'Page', type: 'CANVAS',
children: [{
id: '2:1', name: 'Hero', type: 'FRAME',
absoluteBoundingBox: { x: 0, y: 0, width: 1280, height: 720 },
children: [
{ id: '3:2', name: 'Card 1', type: 'RECTANGLE',
absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 100 },
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 } }],
},
{ id: '3:3', name: 'Card 2', type: 'RECTANGLE',
absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 100 },
fills: [{ type: 'SOLID', color: { r: 0, g: 1, b: 0 } }],
},
],
}],
}],
},
};
// Multi-call stub: returns a different response per invocation.
const sequenceFetch = (responses: Array<{ ok?: boolean; status?: number; statusText?: string; body?: unknown; binary?: Buffer; text?: string }>) => {
let idx = 0;
return vi.fn(async (_url: string) => {
const r = responses[Math.min(idx, responses.length - 1)];
idx++;
if (!r) throw new Error('test stub: no response queued');
return {
ok: r.ok ?? true,
status: r.status ?? 200,
statusText: r.statusText ?? 'OK',
headers: { get: () => null },
json: async () => r.body ?? {},
text: async () => r.text ?? '',
arrayBuffer: async () => r.binary ? r.binary.buffer.slice(r.binary.byteOffset, r.binary.byteOffset + r.binary.byteLength) : new ArrayBuffer(0),
} as unknown as Response;
});
};
it('downloads assets per leaf node when offlineAssets=false', async () => {
const fetchFn = sequenceFetch([
{ body: assetFixture },
{ body: { images: { '3:2': 'https://cdn/a.svg', '3:3': 'https://cdn/b.svg' } } },
{ binary: Buffer.from('<svg>a</svg>') },
{ binary: Buffer.from('<svg>b</svg>') },
]);
const report = await runFigmaExtract({
cwd: cwd,
fileUrl: 'https://figma.com/file/ABC123/x',
token: 'tok',
fetchFn: fetchFn as unknown as typeof fetch,
offlineAssets: false,
});
const assets = (await readdir(path.join(cwd, 'figma', 'assets'))).sort();
expect(assets).toEqual(['3:2.svg', '3:3.svg']);
expect(report.meta.unsupportedNodes.find((u) => u.id === '3:2' && u.type === 'asset')).toBeUndefined();
});
it('records per-id download issues without aborting the run', async () => {
const fetchFn = sequenceFetch([
{ body: assetFixture },
{ body: { images: { '3:2': null, '3:3': 'https://cdn/b.svg' } } },
{ binary: Buffer.from('<svg>b</svg>') },
]);
const report = await runFigmaExtract({
cwd: cwd,
fileUrl: 'https://figma.com/file/ABC123/x',
token: 'tok',
fetchFn: fetchFn as unknown as typeof fetch,
offlineAssets: false,
});
const issues = report.meta.unsupportedNodes.filter((u) => u.type === 'asset');
expect(issues.find((i) => i.id === '3:2')?.reason).toMatch(/no download URL/);
const assets = await readdir(path.join(cwd, 'figma', 'assets'));
expect(assets).toEqual(['3:3.svg']);
});
it('skips assets above assetMaxBytes', async () => {
const fetchFn = sequenceFetch([
{ body: assetFixture },
{ body: { images: { '3:2': 'https://cdn/big.svg', '3:3': 'https://cdn/small.svg' } } },
{ binary: Buffer.alloc(8 * 1024) },
{ binary: Buffer.from('<svg>x</svg>') },
]);
const report = await runFigmaExtract({
cwd: cwd,
fileUrl: 'https://figma.com/file/ABC123/x',
token: 'tok',
fetchFn: fetchFn as unknown as typeof fetch,
offlineAssets: false,
assetMaxBytes: 1024,
});
const skipped = report.meta.unsupportedNodes.find((u) => u.id === '3:2' && u.type === 'asset');
expect(skipped?.reason).toMatch(/asset-too-large/);
const assets = await readdir(path.join(cwd, 'figma', 'assets'));
expect(assets).toEqual(['3:3.svg']);
});
it('keeps assets/ empty when offlineAssets=true (default)', async () => {
const fetchFn = sequenceFetch([{ body: fixtureFile }]);
await runFigmaExtract({
cwd: cwd,
fileUrl: 'https://figma.com/file/ABC123/x',
token: 'tok',
fetchFn: fetchFn as unknown as typeof fetch,
});
const assets = await readdir(path.join(cwd, 'figma', 'assets'));
expect(assets).toEqual([]);
});
});
describe('runFigmaExtract — error paths', () => {
it('throws when neither fileUrl nor fileKey resolves', async () => {
await expect(runFigmaExtract({
cwd,
fileUrl: 'https://example.com/not-figma',
token: 'tok',
fetchFn: stubFetch({}) as unknown as typeof fetch,
})).rejects.toThrow(/missing fileKey/);
});
it('throws when token is missing', async () => {
await expect(runFigmaExtract({
cwd,
fileUrl: 'https://figma.com/file/ABC123/X',
token: '',
fetchFn: stubFetch({}) as unknown as typeof fetch,
})).rejects.toThrow(/missing OAuth token/);
});
it('surfaces non-2xx responses with the upstream status text', async () => {
const fetchFn = stubFetch({ ok: false, status: 403, statusText: 'Forbidden', text: 'invalid token' });
await expect(runFigmaExtract({
cwd,
fileUrl: 'https://figma.com/file/ABC123/X',
token: 'expired',
fetchFn: fetchFn as unknown as typeof fetch,
})).rejects.toThrow(/403/);
});
});