open-design/apps/daemon/tests/plugins-figma-migration-e2e.test.ts
Cursor Agent 023a9eb56f
test(plugins): figma-migration pipeline e2e smoke (Phase 6 entry slice)
Plan T2 / spec §1 / §10 / §21.3.1.

apps/daemon/tests/plugins-figma-migration-e2e.test.ts walks every
Phase 6 atom impl in sequence on a stubbed Figma REST response:

  figma-extract \u2192 figma/{tree.json, tokens.json, meta.json}
                    via injected fetch stub (no network).
  token-map     \u2192 token-map/colors.json crosswalking the lifted
                    SOLID fills onto the active design system.
  diff-review   \u2192 review/decision.json with explicit decision.
  handoff       \u2192 handoff/manifest.json via runAndPersistHandoff()
                    with the right handoffKind from the promotion
                    ladder.

Three cases:

  1. accept happy path \u2192 implementation-plan rung; figma export
     target lands on manifest.exportTargets[].
  2. reject path \u2192 design-only rung even after figma-extract +
     token-map ran successfully.
  3. Bundled scenario folder roster lock — confirms
     plugins/_official/scenarios/od-figma-migration/open-design.json
     ships the canonical stage list (extract \u2192 tokens \u2192 generate
     \u2192 critique) so the §3.O1 fallback resolver always has the
     same target shape to copy.

Daemon tests: 1647 \u2192 1650 (+3 cases on plugins-figma-migration-e2e:
accept, reject, scenario roster).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 16:02:40 +00:00

172 lines
7.1 KiB
TypeScript

// Plan §3.T2 / spec §1 / §10 / §21.3.1 — figma-migration pipeline e2e.
//
// Walks every Phase 6 atom impl in sequence on a Figma fixture
// without going to the network:
//
// figma-extract \u2192 figma/{tree.json, tokens.json, meta.json}
// via a stubbed Figma REST response.
// token-map \u2192 token-map/colors.json + unmatched.json
// against a fixture design system.
// diff-review \u2192 review/decision.json (accept).
// handoff \u2192 handoff/manifest.json with the right
// handoffKind from the promotion ladder.
//
// The figma-migration scenario doesn't go through patch-edit /
// build-test (that's the code-migration shape). The smoke test is
// therefore shorter than the code-migration one but exercises the
// network-bound figma-extract path with the same fetch stub the
// asset-rasterisation tests use.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import type { ArtifactManifest } from '@open-design/contracts';
import { runFigmaExtract } from '../src/plugins/atoms/figma-extract.js';
import { runTokenMap, type DesignSystemTokenBag } from '../src/plugins/atoms/token-map.js';
import { runDiffReview } from '../src/plugins/atoms/diff-review.js';
import { runAndPersistHandoff } from '../src/plugins/atoms/handoff.js';
let cwd: string;
const designSystem: DesignSystemTokenBag = {
id: 'fixture-ds',
tokens: [
{ name: '--ds-color-primary', value: '#5b8def', kind: 'color' },
{ name: '--ds-color-fg', value: '#111111', kind: 'color' },
],
};
const figmaFixture = {
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 },
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 } }],
}],
}],
}],
},
version: '1234',
lastModified: '2026-05-09T00:00:00Z',
};
const stubFetch = (response: { ok?: boolean; body?: unknown }) =>
vi.fn(async () => ({
ok: response.ok ?? true,
status: 200,
statusText: 'OK',
headers: { get: () => null },
json: async () => response.body ?? {},
text: async () => '',
} as unknown as Response));
beforeEach(async () => {
cwd = await mkdtemp(path.join(os.tmpdir(), 'od-figma-pipeline-e2e-'));
});
afterEach(async () => {
await rm(cwd, { recursive: true, force: true });
});
describe('figma-migration pipeline — full atom chain', () => {
it('runs figma-extract \u2192 token-map \u2192 diff-review \u2192 handoff end-to-end', async () => {
// 1. figma-extract.
const fetchFn = stubFetch({ body: figmaFixture });
const figReport = await runFigmaExtract({
cwd,
fileUrl: 'https://figma.com/file/ABC123/x',
token: 'tok',
fetchFn: fetchFn as unknown as typeof fetch,
});
expect(figReport.tree.length).toBeGreaterThan(0);
expect(figReport.meta.fileKey).toBe('ABC123');
const colors = figReport.tokens.colors.map((t) => t.value);
expect(colors).toEqual(expect.arrayContaining(['#5b8def', '#111111']));
// 2. token-map. Reads <cwd>/figma/tokens.json since
// code/tokens.json doesn't exist.
const mapping = await runTokenMap({ cwd, designSystem });
expect(mapping.meta.sourceKind).toBe('figma');
const targets = mapping.colors.map((c) => c.target);
expect(targets).toEqual(expect.arrayContaining(['--ds-color-primary', '--ds-color-fg']));
// 3. diff-review. The figma flow doesn't have patch-edit
// receipts but diff-review still emits the artefacts (with
// empty file lists / 0 lines).
const review = await runDiffReview({
cwd,
decision: { decision: 'accept', reviewer: 'user' },
});
expect(review.decision?.decision).toBe('accept');
expect(review.files).toEqual([]);
// 4. handoff. Without build-test the rung tops out at
// 'implementation-plan'.
const seed: ArtifactManifest = {
version: 1,
kind: 'html',
title: 'Hero from Figma',
entry: 'index.html',
renderer: 'html',
exports: [],
};
const handoff = await runAndPersistHandoff({
cwd,
manifestSeed: seed,
exportTarget: { surface: 'figma', target: 'file/ABC123', exportedAt: 1 },
});
expect(handoff.manifest.handoffKind).toBe('implementation-plan');
expect(handoff.signals.deployable).toBe(false);
// Verify the figma export target landed on the manifest.
expect(handoff.manifest.exportTargets?.[0]?.surface).toBe('figma');
// Verify on-disk files match the expectation.
const onDiskMeta = JSON.parse(await readFile(path.join(cwd, 'figma', 'meta.json'), 'utf8'));
expect(onDiskMeta.fileKey).toBe('ABC123');
const onDiskMapping = JSON.parse(await readFile(path.join(cwd, 'token-map', 'colors.json'), 'utf8'));
expect(onDiskMapping.length).toBeGreaterThan(0);
const onDiskHandoff = JSON.parse(await readFile(path.join(cwd, 'handoff', 'manifest.json'), 'utf8'));
expect(onDiskHandoff.handoffKind).toBe('implementation-plan');
});
it('reject decision \u2192 design-only on figma flow', async () => {
const fetchFn = stubFetch({ body: figmaFixture });
await runFigmaExtract({
cwd, fileUrl: 'https://figma.com/file/ABC123/x', token: 'tok',
fetchFn: fetchFn as unknown as typeof fetch,
});
await runTokenMap({ cwd, designSystem });
await runDiffReview({ cwd, decision: { decision: 'reject', reviewer: 'user' } });
const seed: ArtifactManifest = {
version: 1, kind: 'html', title: 'X', entry: 'x.html',
renderer: 'html', exports: [],
};
const handoff = await runAndPersistHandoff({ cwd, manifestSeed: seed });
expect(handoff.manifest.handoffKind).toBe('design-only');
});
it('crosses the bundled-scenario fallback line cleanly: a figma-migration plugin without a pipeline still resolves', async () => {
// This is a property assertion; no atoms run here. The
// bundled-scenario fallback resolver (O1) wires the canonical
// figma-migration pipeline whenever a consumer plugin omits
// od.pipeline + has taskKind='figma-migration'. The smoke test
// for the resolver lives in plugins-scenario-fallback; this
// case just locks the scenario folder still ships the canonical
// stage list so the resolver has something to copy.
const scenariosRoot = path.resolve(__dirname, '../../..', 'plugins', '_official', 'scenarios', 'od-figma-migration', 'open-design.json');
const manifest = JSON.parse(await readFile(scenariosRoot, 'utf8'));
expect(manifest.od.pipeline.stages.map((s: { id: string }) => s.id)).toEqual([
'extract', 'tokens', 'generate', 'critique',
]);
});
});