From 588c5f675e75e7de1ecae0da17f4946d7ea819e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sil=20Borm=C3=BCller?= Date: Fri, 25 Sep 2026 09:10:20 +0200 Subject: [PATCH] perf: let agents finish Figma tasks in fewer calls - eval helpers $page $var $bind $style $component $instance $fontSafe $describe shared with render - fonts that are not installed are swapped to Inter so instances can be relabelled and moved - render and render-batch take --page and print the structure they built - finds components on any page - effectStyle and textStyle in JSX and per-side padding on root frames - render-batch --variant-set builds a component set in one call - piped eval output is compact JSON capped at 20k characters - agent rules move to lib/agent-rules.js with a hashed marker and connect refreshes outdated copies - instantiate bounds every lookup so an unpublished library key no longer hangs the CLI - the roundtrip test builds on its own page and no longer leaves fixtures behind - tests/live/regression.mjs covers the commands users already rely on Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 22 +++ REFERENCE.md | 40 +++++ src/commands/export-eval.js | 37 +++- src/commands/init.js | 90 ++-------- src/commands/instantiate.js | 21 ++- src/commands/render.js | 85 ++++++++- src/commands/setup.js | 7 + src/figma-client.js | 110 ++++++++---- src/lib/agent-rules.js | 184 +++++++++++++++++++ src/lib/figma-helpers.js | 270 ++++++++++++++++++++++++++++ tests/agent-rules.test.js | 49 +++++ tests/figma-client.test.js | 14 ++ tests/instantiate-cmd.test.js | 27 ++- tests/instantiate-roundtrip.test.js | 52 ++++-- tests/live/regression.mjs | 139 ++++++++++++++ 15 files changed, 1006 insertions(+), 141 deletions(-) create mode 100644 src/lib/agent-rules.js create mode 100644 src/lib/figma-helpers.js create mode 100644 tests/agent-rules.test.js create mode 100644 tests/live/regression.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 6a54fd36..5fb05328 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,10 @@ CLI that controls Figma Desktop directly. No API key needed. | "create a rectangle/frame" | `figma-cli render '...'` | | "convert to component" | `figma-cli node to-component "ID"` | | "use the existing X component" / "don't rebuild, instance it" | `figma-cli instantiate "X"` | +| "put a Button / any existing component into this frame" | `` inside `render` | +| "build it on page X" | `figma-cli render --page "X" '…'` (creates the page when missing) | +| "make a component set with these variants" | `figma-cli render-batch '["", …]' --variant-set Button` | +| "use text style / effect style X" | ``, `` | | "what component already exists for X" | `figma-cli spec X` (shows the reuse handle) | | "list variables" | `figma-cli var list` | | "find nodes named X" | `figma-cli find "X"` | @@ -75,6 +79,24 @@ CLI that controls Figma Desktop directly. No API key needed. --- +## Agent efficiency + +Every tool call is a full agent turn (~21k tokens of context re-read), so the +CLI is built to finish a task in ONE call and to answer without a readback: + +- `eval` has helpers that absorb Figma pitfalls: `$page`, `$var`, `$bind`, + `$style`, `$component`, `$instance`, `$fontSafe`, `$describe` + (src/lib/figma-helpers.js, shared with render). +- Fonts that are not installed are swapped to Inter and + reported as `note:` lines. Without that, Figma refuses to relabel an instance + or even appendChild it. +- `render` prints the structure it built (sizes, layout, padding, bindings, + which component each instance uses) so the agent does not read it back. +- Piped `eval` output is compact JSON, capped at 20k characters. +- The user-facing agent rules live in src/lib/agent-rules.js (hashed marker + block). `init-agent` writes them, `connect` refreshes outdated copies. +- Before a release: `FIGMA_FILE="" node tests/live/regression.mjs`. + ## Key Rules 1. **Always use `render` for frames** - has smart positioning diff --git a/REFERENCE.md b/REFERENCE.md index 822832f7..92688424 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -327,8 +327,48 @@ node src/index.js eval --file /tmp/script.js node src/index.js run /tmp/script.js ``` +## Eval Helpers + +Available inside every `figma-cli eval` (and used by `render`). They install on +first use and handle lazy pages, missing fonts and name lookups: + +```js +const p = await $page('Scratch'); // switch to page, create if missing +const v = await $var('bgColor/muted'); // local variable by full name +await $bind(frame, 'bgColor/muted'); // bind to fills ('strokes' or a field as 3rd arg) +const s = await $style('Body/Medium'); // any local style by name +const set = await $component('Button'); // component or set by name/id, whole file +const i = await $instance('Button', 'variant=danger, size=large', { text: 'Delete', parent: frame }); +await $fontSafe(node); // load fonts, swap missing ones to Inter +return await $describe(node); // compact one-line-per-node summary +``` + +Piped output (an agent calling the CLI) is compact JSON and capped at 20,000 +characters; a terminal still gets indented JSON. + ## Render JSX Syntax +**Pages and design-system hooks** + +```bash +figma-cli render --page "Scratch" '' # render-batch takes --page too +``` +```jsx + // any page, font-safe +... +... // style's font missing: size/spacing kept on the fallback font + // per-side padding, root frames included +``` + +**Component sets in one call** + +```bash +figma-cli render-batch '["...", ...]' --variant-set Button +``` +Frame names must be `prop=value, prop=value` with the same properties on every +frame; the command validates names before touching the file. + + **Elements:** ``, ``, ``, ``, ``, ``, ``, `` **Size:** `w={320} h={200}`, `w="fill"`, `minW={100} maxW={500}` diff --git a/src/commands/export-eval.js b/src/commands/export-eval.js index 3fc281ea..e00e904c 100644 --- a/src/commands/export-eval.js +++ b/src/commands/export-eval.js @@ -2,6 +2,7 @@ import chalk from 'chalk'; import { existsSync, readFileSync, writeFileSync } from 'fs'; import { join } from 'path'; +import { HELPERS_SOURCE, RESET_NOTES, READ_NOTES, usesHelpers } from '../lib/figma-helpers.js'; import { program, checkConnection, @@ -299,6 +300,33 @@ program // ============ EVAL ============ +// Agents read eval output through a pipe and pay for every indent as tokens, so +// print compact JSON there and keep the indented form for a human terminal. +// A result that dumps a whole file (thousands of nodes) costs an agent more +// tokens than the task itself, so piped output is capped with a hint. +const PIPED_OUTPUT_CAP = 20000; +function formatResult(result) { + const text = typeof result !== 'object' ? String(result) + : process.stdout.isTTY ? JSON.stringify(result, null, 2) : JSON.stringify(result); + if (process.stdout.isTTY || text.length <= PIPED_OUTPUT_CAP) return text; + return text.slice(0, PIPED_OUTPUT_CAP) + + `\n… output cut at ${PIPED_OUTPUT_CAP} of ${text.length} characters. Return counts, a filtered list or .slice(0, N) instead.`; +} + +// $page / $var / $bind / $instance / ... live on globalThis in Figma. Install +// them first when the code uses one; the install is a no-op when present. +async function ensureHelpers(code, run) { + if (!usesHelpers(code)) return; + await run(HELPERS_SOURCE + '\n' + RESET_NOTES + '\nreturn true;'); +} +async function printHelperNotes(code, run) { + if (!usesHelpers(code)) return; + try { + const notes = await run(READ_NOTES); + for (const n of notes || []) console.error(chalk.yellow('note: ' + n)); + } catch {} +} + program .command('eval [code]') .description('Execute JavaScript in Figma plugin context') @@ -324,10 +352,12 @@ program // Always prefer async daemon (more reliable, no shell timeout issues) if (isDaemonRunning()) { try { + await ensureHelpers(jsCode, c => daemonExec('eval', { code: c })); const result = await daemonExec('eval', { code: jsCode }); if (result !== undefined && result !== null) { - console.log(typeof result === 'object' ? JSON.stringify(result, null, 2) : result); + console.log(formatResult(result)); } + await printHelperNotes(jsCode, c => daemonExec('eval', { code: c })); return; } catch (e) { // Check if this is a connection/daemon error vs user code error @@ -349,9 +379,10 @@ program // Sync fallback (when daemon not running) try { + await ensureHelpers(jsCode, async c => figmaEvalSync(c)); const result = figmaEvalSync(jsCode); if (result !== undefined && result !== null) { - console.log(typeof result === 'object' ? JSON.stringify(result, null, 2) : result); + console.log(formatResult(result)); } } catch (error) { console.log(chalk.red('✗ ' + error.message)); @@ -374,7 +405,7 @@ program if (isDaemonRunning()) { const result = await daemonExec('eval', { code }); if (result !== undefined) { - console.log(typeof result === 'object' ? JSON.stringify(result, null, 2) : result); + console.log(formatResult(result)); } } else { // Fallback to sync path diff --git a/src/commands/init.js b/src/commands/init.js index b4869fb6..12a6b6c1 100644 --- a/src/commands/init.js +++ b/src/commands/init.js @@ -1,79 +1,14 @@ -// Command: init — scaffold agent guidance into a designer's project so figma-cli -// "just works" with whichever AI coding tool they use (Claude Code or Cursor). -// Writes the SAME condensed usage ruleset to: +// Command: init-agent — scaffold agent guidance into a designer's project so +// figma-cli "just works" with whichever AI coding tool they use. +// Writes the SAME ruleset to: // - .cursor/rules/figma-cli.mdc (Cursor) // - AGENTS.md (Claude Code, Cursor, Codex all read it) -// The CLI binary needs nothing else — it controls Figma Desktop directly, so it -// already runs in any terminal. This just teaches the agent HOW to drive it. +// The rules and the update logic live in lib/agent-rules.js, because `connect` +// also refreshes outdated copies. import chalk from 'chalk'; -import { writeFileSync, existsSync, mkdirSync, readFileSync } from 'fs'; -import { join, dirname } from 'path'; +import { join } from 'path'; import { program } from '../lib/cli-core.js'; - -// The shared, designer-facing usage rules. Kept tight on purpose — an agent -// needs the operating rules, not the CLI's internals. -const RULES_BODY = `# Using figma-cli - -figma-cli controls **Figma Desktop** directly (no API key). It runs in any -terminal. Open Figma Desktop, then \`figma-cli connect\` once per session. - -## Golden rules -1. **Create frames with \`render\` / \`render-batch\`** — they have smart positioning. - NEVER use \`eval\` to create visual nodes (no positioning, bypasses guards). -2. **"N buttons/cards" = N separate top-level nodes**, not one wrapper frame - containing N children. Use \`render-batch '[...]'\` or \`shadcn add --count N\`. -3. **Never delete the user's existing nodes.** -4. After creating, **verify**: \`figma-cli verify "" --measure\` (returns a - screenshot + real w/h so you catch size bugs by numbers, not by eye). - -## Design tokens / variables -- Bind colors at creation with \`var:name\`, never raw hex when a system is loaded: - \`\` -- Pin a named collection when the user names one: \`render-batch ... --collection figma\`. -- Import a system: \`figma-cli import tailwind.config.js | globals.css | tokens.json\`. -- Export the open file's system: \`figma-cli extract\` → DESIGN.md. - -## JSX cheatsheet (render) -- Layout: \`flex="row|col" gap={16} p={24} px py pt pr pb pl justify="center|between" items="center"\` -- Size: \`w={320} h={200} w="fill" w="hug" w="60%"\` (percent resolves vs parent) -- Look: \`bg="#fff" stroke="#000" strokeWidth={2} rounded={12} shadow="..." opacity={0.8}\` -- Text: \`\` -- Icons (real SVG, never emojis): \`\` -- Dividers: a thin child (\`\`) auto-fills the cross axis. - -## Text wrapping (most common bug) -For text to wrap, the parent AND every \`\` need \`w="fill"\`, and the parent -needs \`flex="col"\` or \`flex="row"\`. - -## Recreating a component from an extracted DESIGN.md (hard rule) -Don't read the structure markdown by hand. Use: -- \`figma-cli spec \` → authoritative variant axes + sample size (compact). - Build EXACTLY to those axes (e.g. Variant × Size = a Component Set, not one node). -- \`figma-cli spec --check \` → enforces it (exit 1 on mismatch: - wrong structure, missing axes, wrong height). Treat non-zero as "not done". - -## Handy commands -\`\`\` -figma-cli connect # connect to Figma Desktop (yolo) -figma-cli render '' # one frame -figma-cli render-batch '[ "'\` runs JavaScript in the open file and prints the +result as compact JSON. Top-level \`await\` works. A single expression returns +itself; statements need an explicit \`return\`. Long code: \`eval --file q.js\`. +- The file loads pages lazily: call \`await figma.loadAllPagesAsync()\` before + searching beyond the current page, and use the async APIs + (\`getNodeByIdAsync\`, \`getMainComponentAsync\`, \`getLocalVariablesAsync\`, + \`getStyleByIdAsync\`); the sync ones throw. +- \`findAllWithCriteria({types: [...]})\` is much faster than \`findAll\`. +- Return only what answers the question (counts, names, ids). Output over + 20,000 characters is cut. +- A variable binding: \`node.fills[0].boundVariables.color.id\` → \`getVariableByIdAsync\`. + Resolve an alias by following \`valuesByMode\` values of type \`VARIABLE_ALIAS\`. + +Helpers available inside \`eval\` (they handle the Figma pitfalls for you): +- \`await $page(name)\` switch to a page, creating it when missing +- \`await $var(name)\` local variable by full name; \`await $bind(node, varName, 'fills'|'strokes'|field)\` +- \`await $style(name)\` any local style by name +- \`await $component(name)\` component or component set by name, whole file +- \`await $instance(name, 'axis=value, axis=value', {text, parent})\` instance of a + variant; unspecified axes use the default variant +- \`await $fontSafe(node)\` load fonts and replace fonts that are not installed +- \`await $describe(node)\` compact one-line-per-node summary of a subtree +- Loops are fine: e.g. one instance per variant of a set in a single \`eval\`: + \`for (const v of (await $component('Name')).children) await $instance(v.id, '', {parent: frame})\` + +## Writing: \`render\` +- \`--page "Name"\` renders on that page and creates it when missing. +- \`var:\` takes any variable by full name: \`bg="var:bgColor/muted"\`, \`color="var:fgColor/default"\`. +- Styles by name: \`\`, \`\`. +- Existing components, any page: \`\` + (\`text\` relabels the first text layer). +- A component text or style whose font is not installed is switched to Inter + automatically and reported as a \`note:\` line. You do not need to fix fonts. +- \`render\` prints the new node id and **the structure it built**: sizes, layout, + padding, gap, bound variables, styles, which component each instance uses. + Check that output; do not re-read the result with \`eval\`. \`--verify\` adds a + screenshot when the look matters. + +## Components +- One component: \`render '' --as-component\`. +- A component set with variants in ONE call: \`render-batch\` with every frame + named by its variant, plus \`--variant-set \`: + \`render-batch '["...", ...]' --variant-set Button\` +- Component properties (in \`eval\`): \`const k = comp.addComponentProperty('Label', 'TEXT', 'Default')\`, + then \`textNode.componentPropertyReferences = { characters: k }\`. For \`'BOOLEAN'\` + reference \`visible\`; for \`'INSTANCE_SWAP'\` reference \`mainComponent\`. + +## Design tokens / variables +- Bind colors at creation with \`var:name\`, never raw hex when a system is loaded: + \`Go\` +- Pin a named collection when the user names one: \`render-batch ... --collection figma\`. +- Import a system: \`figma-cli import tailwind.config.js | globals.css | tokens.json\`. +- Export the open file's system: \`figma-cli extract\` → DESIGN.md. + +## JSX cheatsheet (render) +- Layout: \`flex="row|col" gap={16} p={24} px py pt pr pb pl justify="center|between" items="center"\` +- Size: \`w={320} h={200} w="fill" w="hug" w="60%"\` (percent resolves vs parent) +- Look: \`bg="#fff" stroke="#000" strokeWidth={2} rounded={12} shadow="..." opacity={0.8}\` +- Text: \`\` +- Icons (real SVG, never emojis): \`\` +- Dividers: a thin child (\`\`) auto-fills the cross axis. + +## Text wrapping (most common bug) +For text to wrap, the parent AND every \`\` need \`w="fill"\`, and the parent +needs \`flex="col"\` or \`flex="row"\`. + +## Recreating a component from an extracted DESIGN.md (hard rule) +Don't read the structure markdown by hand. Use: +- \`figma-cli spec \` → authoritative variant axes + sample size (compact). + Build EXACTLY to those axes (e.g. Variant × Size = a Component Set, not one node). +- \`figma-cli spec --check \` → enforces it (exit 1 on mismatch: + wrong structure, missing axes, wrong height). Treat non-zero as "not done". + +## Handy commands +\`\`\` +figma-cli connect # connect to Figma Desktop (yolo) +figma-cli render '...' # one frame +figma-cli render-batch '[ "", ... ]' --direction row +figma-cli shadcn add button --count 3 # N distinct shadcn primitives +figma-cli node to-component "" # promote to a component +figma-cli verify "" --measure # screenshot + dimensions +figma-cli a11y audit # contrast / touch / text checks +\`\`\` +`; + + +export const RULES_HASH = createHash('sha1').update(RULES_BODY).digest('hex').slice(0, 10); +const START = ``; +const START_RE = //; +const END = ''; +const BLOCK = `${START}\n${RULES_BODY}${END}\n`; + +// Copies written before the markers existed start at this heading and end +// with the fenced "Handy commands" block. Replace exactly that span. +function legacySpan(text) { + const start = text.indexOf('# Using figma-cli'); + if (start < 0) return null; + const handy = text.indexOf('## Handy commands', start); + if (handy < 0) return null; + const open = text.indexOf('```', handy); + const close = open < 0 ? -1 : text.indexOf('```', open + 3); + if (close < 0) return null; + let end = close + 3; + if (text[end] === '\n') end++; + return { start, end }; +} + +const MDC_HEAD = '---\ndescription: How to drive figma-cli (controls Figma Desktop) from this project\nalwaysApply: true\n---\n\n'; + +export const RULE_FILES = [ + { tool: 'cursor', rel: join('.cursor', 'rules', 'figma-cli.mdc'), fresh: () => MDC_HEAD + BLOCK }, + { tool: 'claude', rel: 'AGENTS.md', fresh: () => BLOCK }, +]; + +// Write or refresh one rules file. +// create: false -> only refresh a file that already carries figma-cli rules +// Returns { path, status: written | updated | up-to-date | exists | absent }. +export function syncRulesFile(path, fresh, { create = true, force = false } = {}) { + if (!existsSync(path)) { + if (!create) return { path, status: 'absent' }; + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, fresh()); + return { path, status: 'written' }; + } + const text = readFileSync(path, 'utf8'); + const m = text.match(START_RE); + if (m) { + const s = m.index; + const e = text.indexOf(END, s); + if (m[1] === RULES_HASH && e >= 0 && !force) return { path, status: 'up-to-date' }; + const end = e >= 0 ? e + END.length + (text[e + END.length] === '\n' ? 1 : 0) : text.length; + writeFileSync(path, text.slice(0, s) + BLOCK + text.slice(end)); + return { path, status: 'updated' }; + } + const legacy = legacySpan(text); + if (legacy) { + writeFileSync(path, text.slice(0, legacy.start) + BLOCK + text.slice(legacy.end)); + return { path, status: 'updated' }; + } + if (force) { writeFileSync(path, fresh()); return { path, status: 'written' }; } + return { path, status: 'exists' }; // unrelated file: never touch it +} + +// Refresh the rules files in dir that already exist. Used by `connect`. +export function refreshRules(dir) { + return RULE_FILES.map(f => syncRulesFile(join(dir, f.rel), f.fresh, { create: false })) + .filter(r => r.status === 'updated'); +} diff --git a/src/lib/figma-helpers.js b/src/lib/figma-helpers.js new file mode 100644 index 00000000..0194f54d --- /dev/null +++ b/src/lib/figma-helpers.js @@ -0,0 +1,270 @@ +// Helpers that run INSIDE Figma, shared by `render` (, effectStyle, +// textStyle) and by `eval` (as $page, $var, $bind, $instance, $fontSafe, +// $style). One implementation, so the missing-font handling below behaves the +// same whichever command an agent reaches for. +// +// Why they exist: a component whose text uses a font that is not installed +// cannot be relabelled, and Figma even refuses to appendChild the instance into +// a frame ("unloaded font"). Without help an agent rediscovers that by trial +// and error on every task. Here it is handled once. +// +// Kept as a source string because it is evaluated in the Figma sandbox, not in +// Node. It installs itself on globalThis and is idempotent per version. + +export const HELPERS_VERSION = 6; + +export const HELPERS_SOURCE = ` +if (!globalThis.__figHelpers || globalThis.__figHelpers.v !== ${HELPERS_VERSION}) { + const H = { v: ${HELPERS_VERSION} }; + // Notes for the caller (deduplicated). eval and render reset and print them. + H.note = (msg) => { (globalThis.__figNotes || (globalThis.__figNotes = new Set())).add(msg); }; + let pagesLoaded = false; + const loadPages = async () => { if (!pagesLoaded) { await figma.loadAllPagesAsync(); pagesLoaded = true; } }; + let fontList = null; + const available = async (f) => { + if (!fontList) fontList = new Set((await figma.listAvailableFontsAsync()).map(x => x.fontName.family + '|' + x.fontName.style)); + return fontList.has(f.family + '|' + f.style); + }; + const FALLBACK = 'Inter'; + const fallbackStyle = (style) => { + const s = String(style || '').toLowerCase(); + if (s.includes('black') || s.includes('heavy')) return 'Black'; + if (s.includes('extra') && s.includes('bold')) return 'Extra Bold'; + if (s.includes('semi') || s.includes('demi')) return 'Semi Bold'; + if (s.includes('bold')) return 'Bold'; + if (s.includes('medium')) return 'Medium'; + if (s.includes('light')) return 'Light'; + return 'Regular'; + }; + + // Make every text under node editable: load installed fonts, swap missing + // ones to Inter (same weight). Returns the list of swapped fonts. + H.fontSafe = async (node) => { + const texts = node.type === 'TEXT' ? [node] : (node.findAllWithCriteria ? node.findAllWithCriteria({ types: ['TEXT'] }) : []); + const swapped = new Set(); + for (const t of texts) { + const fonts = t.fontName === figma.mixed ? t.getRangeAllFontNames(0, t.characters.length) : [t.fontName]; + let missing = false; + for (const f of fonts) { + if (await available(f)) await figma.loadFontAsync(f); else missing = true; + } + if (missing) { + const style = fallbackStyle((fonts[0] || {}).style); + const f = { family: FALLBACK, style }; + try { await figma.loadFontAsync(f); } catch (e) { f.style = 'Regular'; await figma.loadFontAsync(f); } + for (const old of fonts) swapped.add(old.family + ' ' + old.style); + t.fontName = f; + } + } + return [...swapped]; + }; + + // Page by name: switch to it, create it when missing. + H.page = async (name) => { + await loadPages(); + let p = figma.root.children.find(p => p.name === name); + if (!p) { p = figma.createPage(); p.name = name; } + await figma.setCurrentPageAsync(p); + return p; + }; + + let varIndex = null; + H.variable = async (name) => { + if (!varIndex || !varIndex.has(name)) { + varIndex = new Map(); + for (const v of await figma.variables.getLocalVariablesAsync()) if (!varIndex.has(v.name)) varIndex.set(v.name, v); + } + const v = varIndex.get(name); + if (!v) throw new Error('Variable not found: ' + name); + return v; + }; + + // Bind a variable to fills (default), strokes, or any bindable field. + H.bind = async (node, name, field = 'fills') => { + const v = await H.variable(name); + if (field === 'fills' || field === 'strokes') { + const base = (Array.isArray(node[field]) && node[field][0] && node[field][0].type === 'SOLID') ? node[field][0] : { type: 'SOLID', color: { r: 0.5, g: 0.5, b: 0.5 } }; + node[field] = [figma.variables.setBoundVariableForPaint(base, 'color', v)]; + } else { + node.setBoundVariable(field, v); + } + return node; + }; + + H.style = async (name) => { + const all = [ + ...(await figma.getLocalTextStylesAsync()), ...(await figma.getLocalEffectStylesAsync()), + ...(await figma.getLocalPaintStylesAsync()), ...(await figma.getLocalGridStylesAsync()), + ]; + const s = all.find(s => s.name === name); + if (!s) throw new Error('Style not found: ' + name); + return s; + }; + + const compCache = new Map(); + // The helpers live on globalThis for the whole Figma session, so anything + // cached here can point at a node or variable the user has since deleted. + // Every render / eval starts with a reset (see RESET_NOTES). + H.resetCaches = () => { compCache.clear(); varIndex = null; pagesLoaded = false; fontList = null; }; + // A component or component set by name (whole document) or by node id. + H.component = async (ref) => { + if (/^I?\\d+:\\d+/.test(ref)) { + const n = await figma.getNodeByIdAsync(ref); + if (n && (n.type === 'COMPONENT' || n.type === 'COMPONENT_SET')) return n; + } + if (compCache.has(ref)) return compCache.get(ref); + await loadPages(); + const all = figma.root.findAllWithCriteria({ types: ['COMPONENT_SET', 'COMPONENT'] }); + const hit = all.find(n => n.type === 'COMPONENT_SET' && n.name === ref) + || all.find(n => n.type === 'COMPONENT' && n.name === ref && (!n.parent || n.parent.type !== 'COMPONENT_SET')) + || all.find(n => n.type === 'COMPONENT_SET' && n.name.toLowerCase() === String(ref).toLowerCase()); + if (!hit) throw new Error('Component not found: ' + ref); + compCache.set(ref, hit); + return hit; + }; + + // Variant of a set. "variant=danger, size=large" or {variant:'danger'}; the + // given pairs must match, unspecified axes fall back to the default variant. + H.variant = (set, spec) => { + if (set.type !== 'COMPONENT_SET') return set; + const want = {}; + if (typeof spec === 'string') { + for (const part of spec.split(',')) { const i = part.indexOf('='); if (i > 0) want[part.slice(0, i).trim()] = part.slice(i + 1).trim(); } + } else if (spec) Object.assign(want, spec); + const keys = Object.keys(want); + if (!keys.length) return set.defaultVariant || set.children[0]; + const matches = set.children.filter(c => keys.every(k => String((c.variantProperties || {})[k]).toLowerCase() === String(want[k]).toLowerCase())); + if (!matches.length) { + const axes = Object.entries(set.variantGroupProperties || {}).map(([k, v]) => k + '=' + v.values.join('|')).join('; '); + throw new Error('No variant of "' + set.name + '" matches ' + JSON.stringify(want) + '. Axes: ' + axes); + } + const def = set.defaultVariant ? set.defaultVariant.variantProperties || {} : {}; + // prefer the match closest to the default variant on the unspecified axes + matches.sort((a, b) => { + const score = c => Object.entries(def).filter(([k, v]) => !(k in want) && (c.variantProperties || {})[k] === v).length; + return score(b) - score(a); + }); + return matches[0]; + }; + + // Instance of a component (set name, component name or id), font-safe. + // opts: { variant, text, texts: {layerName: chars}, parent } + H.instance = async (ref, variantSpec, opts = {}) => { + const c = await H.component(ref); + const comp = H.variant(c, variantSpec); + const inst = comp.createInstance(); + const swapped = await H.fontSafe(inst); + const texts = inst.findAllWithCriteria({ types: ['TEXT'] }).filter(t => t.visible); + if (opts.text !== undefined && texts[0]) texts[0].characters = String(opts.text); + if (opts.texts) for (const [layer, chars] of Object.entries(opts.texts)) { + const t = inst.findOne(n => n.type === 'TEXT' && n.name === layer); + if (t) t.characters = String(chars); + } + if (opts.parent) opts.parent.appendChild(inst); + for (const f of swapped) H.note('font "' + f + '" is not installed, used Inter instead'); + return inst; + }; + + // One line per node, compact enough to replace a verification eval: + // Card FRAME 360x212 col gap=12 pad=24 fill=var:bgColor/muted effect=shadow/small + // Title TEXT 20 Inter Bold w=FILL fill=var:fgColor/default "Release notes" + // Button INSTANCE Button[variant=danger, size=large] "Delete" + // Capped by depth and line count so a big tree never floods the output. + H.describe = async (root, maxDepth = 3, maxLines = 40) => { + const lines = []; + const varName = async (paint) => { + const id = paint && paint.boundVariables && paint.boundVariables.color && paint.boundVariables.color.id; + if (!id) return null; + const v = await figma.variables.getVariableByIdAsync(id); + return v ? 'var:' + v.name : null; + }; + const paintDesc = async (paints) => { + if (!Array.isArray(paints) || !paints.length) return null; + const p = paints[0]; + const v = await varName(p); + if (v) return v; + if (p.type === 'SOLID') return '#' + [p.color.r, p.color.g, p.color.b].map(x => Math.round(x * 255).toString(16).padStart(2, '0')).join('') + (p.opacity !== undefined && p.opacity < 1 ? '@' + Math.round(p.opacity * 100) + '%' : ''); + return p.type.toLowerCase(); + }; + const styleName = async (id) => (typeof id === 'string' && id) ? ((await figma.getStyleByIdAsync(id)) || {}).name : null; + const visit = async (n, depth) => { + if (lines.length >= maxLines) return; + try { await line(n, depth); } catch (e) { lines.push(' '.repeat(depth) + n.name + ' ' + n.type); } + // do not descend into instances (their inside is the component's business) + if ('children' in n && n.type !== 'INSTANCE' && depth < maxDepth) { + for (const c of n.children) { if (lines.length >= maxLines) break; await visit(c, depth + 1); } + } + }; + const line = async (n, depth) => { + const bits = [n.name, n.type]; + if ('width' in n) bits.push(Math.round(n.width) + 'x' + Math.round(n.height)); + if (n.layoutMode && n.layoutMode !== 'NONE') { + bits.push(n.layoutMode === 'HORIZONTAL' ? 'row' : 'col'); + if (n.itemSpacing) bits.push('gap=' + n.itemSpacing); + const pad = [n.paddingTop, n.paddingRight, n.paddingBottom, n.paddingLeft]; + if (pad.some(Boolean)) bits.push('pad=' + (pad.every(v => v === pad[0]) ? pad[0] : pad.join('/'))); + if (n.layoutWrap === 'WRAP') bits.push('wrap'); + } + // sizing mode, where it is not the plain fixed size + const inAuto = (n.layoutMode && n.layoutMode !== 'NONE') || (n.parent && n.parent.layoutMode && n.parent.layoutMode !== 'NONE'); + if (inAuto && n.type !== 'TEXT' || n.type === 'TEXT' && n.parent && n.parent.layoutMode && n.parent.layoutMode !== 'NONE') { + try { + if (n.layoutSizingHorizontal && n.layoutSizingHorizontal !== 'FIXED') bits.push('w=' + n.layoutSizingHorizontal); + if (n.layoutSizingVertical && n.layoutSizingVertical !== 'FIXED') bits.push('h=' + n.layoutSizingVertical); + } catch (e) {} + } + if (n.type === 'TEXT' && n.textAutoResize === 'HEIGHT') bits.push('wraps'); + if (n.cornerRadius && n.cornerRadius !== figma.mixed) bits.push('r=' + n.cornerRadius); + if (n.opacity !== undefined && n.opacity < 1) bits.push('opacity=' + Math.round(n.opacity * 100) / 100); + if (n.type === 'TEXT') { + const f = n.fontName !== figma.mixed ? n.fontName : null; + bits.push((n.fontSize !== figma.mixed ? n.fontSize : 'mixed') + (f ? ' ' + f.family + ' ' + f.style : '')); + const ts = await styleName(n.textStyleId); if (ts) bits.push('style=' + ts); + } + if ('fills' in n && n.type !== 'INSTANCE') { const f = await paintDesc(n.fills); if (f) bits.push('fill=' + f); } + if ('strokes' in n && n.strokes && n.strokes.length) { const s = await paintDesc(n.strokes); if (s) bits.push('stroke=' + s); } + if ('effectStyleId' in n) { const es = await styleName(n.effectStyleId); if (es) bits.push('effect=' + es); } + if (n.type === 'INSTANCE') { + const mc = await n.getMainComponentAsync(); + if (mc) bits.push('of ' + (mc.parent && mc.parent.type === 'COMPONENT_SET' ? mc.parent.name + '[' + mc.name + ']' : mc.name)); + } + if (n.type === 'COMPONENT_SET') bits.push(n.children.length + ' variants'); + // Figma throws when a VARIANT is asked for its property definitions; + // only sets and standalone components have them. + const ownsProps = n.type === 'COMPONENT_SET' || (n.type === 'COMPONENT' && !(n.parent && n.parent.type === 'COMPONENT_SET')); + if (ownsProps) { + try { + const defs = Object.entries(n.componentPropertyDefinitions).filter(([, d]) => d.type !== 'VARIANT').map(([k, d]) => k.split('#')[0] + ':' + d.type); + if (defs.length) bits.push('props={' + defs.join(', ') + '}'); + } catch (e) {} + } + if (n.componentPropertyReferences && Object.keys(n.componentPropertyReferences).length) { + bits.push('bound=' + Object.entries(n.componentPropertyReferences).map(([k, v]) => k + '→' + String(v).split('#')[0]).join(',')); + } + if (n.reactions && n.reactions.length) bits.push(n.reactions.length + ' interaction(s)'); + if (n.type === 'TEXT') bits.push(JSON.stringify(n.characters.length > 40 ? n.characters.slice(0, 40) + '…' : n.characters)); + else if (n.type === 'INSTANCE') { + const t = n.findOne(x => x.type === 'TEXT' && x.visible); + if (t) bits.push(JSON.stringify(t.characters.length > 40 ? t.characters.slice(0, 40) + '…' : t.characters)); + } + lines.push(' '.repeat(depth) + bits.join(' ')); + }; + await visit(root, 0); + if (lines.length >= maxLines) lines.push(' … (more nodes; ' + maxLines + '-line summary)'); + return lines.join('\\n'); + }; + + globalThis.__figHelpers = H; + globalThis.$page = H.page; globalThis.$var = H.variable; globalThis.$bind = H.bind; + globalThis.$style = H.style; globalThis.$component = H.component; + globalThis.$instance = H.instance; globalThis.$fontSafe = H.fontSafe; globalThis.$describe = H.describe; +} +`; + +// True when user eval code calls one of the helpers. +export const RESET_NOTES = 'globalThis.__figNotes = new Set(); if (globalThis.__figHelpers && globalThis.__figHelpers.resetCaches) globalThis.__figHelpers.resetCaches();'; +export const READ_NOTES = 'return globalThis.__figNotes ? [...globalThis.__figNotes] : [];'; + +export function usesHelpers(code) { + return /\$(page|var|bind|style|component|instance|fontSafe|describe)\s*\(/.test(code || ''); +} diff --git a/tests/agent-rules.test.js b/tests/agent-rules.test.js new file mode 100644 index 00000000..18ec84db --- /dev/null +++ b/tests/agent-rules.test.js @@ -0,0 +1,49 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { RULES_BODY, RULES_HASH, RULE_FILES, syncRulesFile, refreshRules } from '../src/lib/agent-rules.js'; + +const agents = RULE_FILES.find(f => f.rel === 'AGENTS.md'); +const dir = () => mkdtempSync(join(tmpdir(), 'rules-')); + +test('writes a marked block into a new AGENTS.md', () => { + const d = dir(); const p = join(d, 'AGENTS.md'); + assert.equal(syncRulesFile(p, agents.fresh).status, 'written'); + const t = readFileSync(p, 'utf8'); + assert.ok(t.includes(RULES_HASH) && t.includes(RULES_BODY)); + assert.equal(syncRulesFile(p, agents.fresh).status, 'up-to-date'); +}); + +test('replaces an outdated block and keeps the user text around it', () => { + const d = dir(); const p = join(d, 'AGENTS.md'); + writeFileSync(p, 'my intro\n\n# Using figma-cli\nold\n\nmy outro\n'); + assert.equal(syncRulesFile(p, agents.fresh).status, 'updated'); + const t = readFileSync(p, 'utf8'); + assert.ok(t.startsWith('my intro\n') && t.endsWith('my outro\n')); + assert.ok(t.includes(RULES_HASH) && !t.includes('\nold\n')); +}); + +test('upgrades a pre-marker copy up to the end of its Handy commands block', () => { + const d = dir(); const p = join(d, 'AGENTS.md'); + writeFileSync(p, '# Using figma-cli\nold rules\n## Handy commands\n```\nfigma-cli connect\n```\n\n## Our own notes\nkeep me\n'); + assert.equal(syncRulesFile(p, agents.fresh).status, 'updated'); + const t = readFileSync(p, 'utf8'); + assert.ok(t.includes(RULES_HASH) && t.includes('## Our own notes\nkeep me') && !t.includes('old rules')); +}); + +test('never touches an unrelated AGENTS.md', () => { + const d = dir(); const p = join(d, 'AGENTS.md'); + writeFileSync(p, '# Team rules\n'); + assert.equal(syncRulesFile(p, agents.fresh).status, 'exists'); + assert.equal(readFileSync(p, 'utf8'), '# Team rules\n'); +}); + +test('refreshRules updates existing copies and creates nothing', () => { + const d = dir(); + assert.deepEqual(refreshRules(d), []); + assert.equal(existsSync(join(d, 'AGENTS.md')), false); + writeFileSync(join(d, 'AGENTS.md'), '\nold\n\n'); + assert.equal(refreshRules(d).length, 1); +}); diff --git a/tests/figma-client.test.js b/tests/figma-client.test.js index e23a8b6d..e55153c6 100644 --- a/tests/figma-client.test.js +++ b/tests/figma-client.test.js @@ -83,3 +83,17 @@ describe('FigmaClient properties', () => { assert.strictEqual(client.pageTitle, null); }); }); + +it('root frame honors per-side padding in render and render-batch', async () => { + const c = new FigmaClient(); + const jsx = 'x'; + const single = await c.parseJSX(jsx); + for (const want of ['paddingTop = 4', 'paddingRight = 10', 'paddingBottom = 6', 'paddingLeft = 8']) { + assert.ok(single.includes(want), 'render: ' + want); + } + const batch = await c.parseJSXBatch([jsx]); + const code = typeof batch === 'string' ? batch : JSON.stringify(batch); + for (const want of ['paddingTop = 4', 'paddingRight = 10', 'paddingBottom = 6', 'paddingLeft = 8']) { + assert.ok(code.includes(want), 'render-batch: ' + want); + } +}); diff --git a/tests/instantiate-cmd.test.js b/tests/instantiate-cmd.test.js index 6d75c5d7..ffd774bd 100644 --- a/tests/instantiate-cmd.test.js +++ b/tests/instantiate-cmd.test.js @@ -1,4 +1,4 @@ -import { test } from 'node:test'; +import { test, describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { instantiateCode } from '../src/commands/instantiate.js'; @@ -11,3 +11,28 @@ test('instantiateCode is syntactically valid JS for a key+id plan', () => { // dynamic-page safe: no legacy sync getNodeById( in the generated code assert.doesNotMatch(code, /[^A-Za-z]getNodeById\(/); }); + +describe('instantiateCode — bounded steps', () => { + it('time-bounds every step', () => { + // figma.importComponentByKeyAsync on a key that was never published NEVER + // settles (measured: still pending after 2 minutes). Unbounded, the command + // hangs forever and wedges the daemon behind it, when the right answer is + // to fall through to the node-id step. + const code = instantiateCode([{ via: 'key', key: 'abc' }, { via: 'id', id: '1:2' }]); + assert.match(code, /Promise\.race/); + assert.match(code, /bounded\(figma\.importComponentByKeyAsync\(step\.key\)/); + assert.match(code, /bounded\(figma\.getNodeByIdAsync\(step\.id\)/); + }); + + it('embeds the timeout as a number', () => { + assert.match(instantiateCode([], { timeoutMs: 1234 }), /const TIMEOUT = 1234;/); + }); + + it('keeps trying the remaining steps after a timeout', () => { + // The catch must stay inside the loop, otherwise a slow library lookup + // aborts the whole plan instead of falling through to the id. + const code = instantiateCode([{ via: 'key', key: 'a' }]); + const loop = code.slice(code.indexOf('for (const step of plan)')); + assert.match(loop, /catch \(e\) \{ tried\.push/); + }); +}); diff --git a/tests/instantiate-roundtrip.test.js b/tests/instantiate-roundtrip.test.js index 846a89a3..af63bfbd 100644 --- a/tests/instantiate-roundtrip.test.js +++ b/tests/instantiate-roundtrip.test.js @@ -6,40 +6,60 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; const CLI = join(process.cwd(), 'src', 'index.js'); + +// Every call is bounded. Without a timeout this test HUNG for 15 minutes when +// the connected file was large (Primer Web: 67 pages), taking the whole suite +// with it — and its cleanup never ran, so a Button fixture was left behind in +// someone else's file. A bounded call fails loudly instead. +const TIMEOUT = 90_000; function run(args, opts = {}) { - return execFileSync('node', [CLI, ...args], { encoding: 'utf8', ...opts }); + return execFileSync('node', [CLI, ...args], { encoding: 'utf8', timeout: TIMEOUT, ...opts }); } + // Gate on a LIVE Figma connection, not just a running daemon: probe with a // trivial eval. When Figma is not connected the CLI exits 1 → execFileSync // throws → we skip. Only a real round-trip (exit 0, 'ok') unlocks the test. function figmaReady() { - try { return /ok/.test(run(['eval', `(async () => 'ok')()`])); } catch { return false; } + try { return /ok/.test(run(['eval', `(async () => 'ok')()`], { timeout: 20_000 })); } catch { return false; } } -// eval source that creates a 2-variant "Button" COMPONENT_SET and returns its id. -const BUILD_SET = `(async () => { +// The fixture gets its OWN page. Two reasons: extract can then be scoped to it +// (walking 67 pages of a real design system is what made this test hang), and +// cleanup is a single page.remove() that cannot leave stragglers behind. +const PAGE = 'figma-cli test fixture'; +const BUILD = `(async () => { + const page = figma.createPage(); + page.name = ${JSON.stringify(PAGE)}; + await figma.setCurrentPageAsync(page); const mk = (name) => { const c = figma.createComponent(); c.name = name; c.resize(80, 40); return c; }; const a = mk('Size=Small'); const b = mk('Size=Large'); - const set = figma.combineAsVariants([a, b], figma.currentPage); + const set = figma.combineAsVariants([a, b], page); set.name = 'Button'; - return JSON.stringify({ id: set.id }); + return JSON.stringify({ pageId: page.id, setId: set.id }); })()`; -const cleanup = (id) => `(async () => { - const n = await figma.getNodeByIdAsync(${JSON.stringify(id)}); - if (n) n.remove(); +const teardown = (pageId) => `(async () => { + await figma.loadAllPagesAsync(); + const p = await figma.getNodeByIdAsync(${JSON.stringify(pageId)}); + if (!p) return 'gone'; + // Figma refuses to remove the page it is currently showing. + if (figma.currentPage === p) { + const other = figma.root.children.find(x => x !== p); + if (other) await figma.setCurrentPageAsync(other); + } + p.remove(); return 'ok'; })()`; test('extract → instantiate roundtrip (gated on a connected Figma)', { skip: !figmaReady() }, () => { const dir = mkdtempSync(join(tmpdir(), 'inst-')); const md = join(dir, 'DESIGN.md'); - let setId; + let pageId; try { - const built = JSON.parse(run(['eval', BUILD_SET]).trim().split('\n').pop()); - setId = built.id; + const built = JSON.parse(run(['eval', BUILD]).trim().split('\n').pop()); + pageId = built.pageId; - run(['extract', md]); + run(['extract', md, '--pages', PAGE]); const text = readFileSync(md, 'utf8'); assert.match(text, /### Button/); assert.match(text, /Reuse: import existing/); @@ -47,8 +67,8 @@ test('extract → instantiate roundtrip (gated on a connected Figma)', { skip: ! const out = run(['instantiate', 'Button', '--file', md]); assert.match(out, /Instanced "Button" via (key|id)/); } finally { - // Delete the created set (and the instance, selected by `instantiate`). - if (setId) { try { run(['eval', cleanup(setId)]); } catch {} } - try { run(['eval', `(async () => { const s = figma.currentPage.selection; s.forEach(n => n.remove()); return 'ok'; })()`]); } catch {} + // Removing the fixture page takes the component set AND the instance + // `instantiate` dropped on it — nothing survives this test. + if (pageId) { try { run(['eval', teardown(pageId)]); } catch {} } } }); diff --git a/tests/live/regression.mjs b/tests/live/regression.mjs new file mode 100644 index 00000000..8418c172 --- /dev/null +++ b/tests/live/regression.mjs @@ -0,0 +1,139 @@ +// Live regression check for the commands users already rely on. Runs against a +// connected Figma file on its own scratch page and removes that page afterwards. +// FIGMA_FILE="Untitled" node tests/live/regression.mjs +import { execFileSync } from 'child_process'; +import { mkdtempSync, writeFileSync, readFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { tmpdir } from 'os'; +import { fileURLToPath } from 'url'; + +const CLI = join(dirname(fileURLToPath(import.meta.url)), '../../src/index.js'); +const PAGE = 'figma-cli regression'; +const run = (args, opts = {}) => execFileSync('node', [CLI, ...args], { encoding: 'utf8', ...opts }); +const evalJson = code => { + const last = run(['eval', code]).trim().split('\n').filter(l => !l.startsWith('note:')).pop(); + try { return JSON.parse(last); } catch { return last; } // strings print raw +}; +const idOf = out => (out.match(/Rendered: (\d+:\d+)/) || [])[1]; +const ids = out => [...out.matchAll(/Rendered: (\d+:\d+)/g)].map(m => m[1]); + +const results = []; +async function check(name, fn) { + try { const detail = await fn(); results.push([name, true, detail || '']); } + catch (e) { results.push([name, false, e.message.split('\n')[0].slice(0, 200)]); } +} +const assert = (cond, msg) => { if (!cond) throw new Error(msg); }; + +const tmp = mkdtempSync(join(tmpdir(), 'reg-')); +const startPage = evalJson('figma.currentPage.id'); +evalJson(`const p = figma.createPage(); p.name = ${JSON.stringify(PAGE)}; await figma.setCurrentPageAsync(p); return p.id`); + +try { + await check('render: card with p, gap, hex, rounded, wrapping text', () => { + const id = idOf(run(['render', 'TitleA long description that has to wrap onto a second line inside the card.'])); + const r = evalJson(`const n = await figma.getNodeByIdAsync("${id}"); return { w: n.width, pad: [n.paddingTop, n.paddingRight, n.paddingBottom, n.paddingLeft], gap: n.itemSpacing, r: n.cornerRadius, t: n.children.map(c => [c.layoutSizingHorizontal, c.textAutoResize, c.height]) }`); + assert(r.w === 320 && r.pad.every(v => v === 24) && r.gap === 12 && r.r === 12, JSON.stringify(r)); + assert(r.t.every(t => t[0] === 'FILL') && r.t[1][2] > 20, 'text did not wrap: ' + JSON.stringify(r.t)); + }); + await check('render: px/py padding unchanged by the per-side fix', () => { + const id = idOf(run(['render', 'Go'])); + const r = evalJson(`const n = await figma.getNodeByIdAsync("${id}"); return [n.paddingTop, n.paddingRight, n.paddingBottom, n.paddingLeft]`); + assert(r.join() === '8,16,8,16', r.join()); + }); + await check('render: nested frame per-side padding', () => { + const id = idOf(run(['render', 'x'])); + const r = evalJson(`const n = (await figma.getNodeByIdAsync("${id}")).children[0]; return [n.paddingTop, n.paddingRight, n.paddingBottom, n.paddingLeft]`); + assert(r.join() === '1,2,3,4', r.join()); + }); + await check('render: auto-split of a flex wrapper into separate nodes', () => { + const out = run(['render', '']); + assert(ids(out).length === 3, out.slice(0, 300)); + }); + await check('render --as-component', () => { + const out = run(['render', 'Chip', '--as-component']); + const cid = (out.match(/Converted to component: (\d+:\d+)/) || [])[1]; + assert(cid, out); + writeFileSync(join(tmp, 'chip-id'), cid); + return cid; + }); + await check('render-batch --as-component: 3 separate components', () => { + const out = run(['render-batch', '["","",""]', '--as-component']); + assert((out.match(/Converted to component/g) || []).length === 3, out.slice(0, 300)); + }); + await check(' (old id form)', () => { + const cid = readFileSync(join(tmp, 'chip-id'), 'utf8'); + const id = idOf(run(['render', ``])); + const r = evalJson(`const n = await figma.getNodeByIdAsync("${id}"); const c = n.children[0]; return { type: c && c.type, main: c ? (await c.getMainComponentAsync()).id : null }`); + assert(r.type === 'INSTANCE' && r.main === cid, JSON.stringify(r)); + }); + await check(' (old name form)', () => { + const id = idOf(run(['render', ''])); + const r = evalJson(`const n = await figma.getNodeByIdAsync("${id}"); return n.children.map(c => c.type)`); + assert(r.join() === 'INSTANCE', JSON.stringify(r)); + }); + await check(' after the component was deleted and recreated (no stale cache)', () => { + const mk = () => evalJson(`const c = figma.createComponent(); c.name = 'Recycled'; c.resize(20, 20); return c.id`); + const first = mk(); + idOf(run(['render', ''])); + evalJson(`(await figma.getNodeByIdAsync("${first}")).remove(); return true`); + const second = mk(); + const id = idOf(run(['render', ''])); + assert(id, 'render failed'); + const r = evalJson(`const n = await figma.getNodeByIdAsync("${id}"); return (await n.children[0].getMainComponentAsync()).id`); + assert(r === second, r + ' != ' + second); + }); + await check('render: shadow, opacity, icon', () => { + const id = idOf(run(['render', ''])); + const r = evalJson(`const n = await figma.getNodeByIdAsync("${id}"); return { fx: n.effects.map(e => e.type), o: Math.round(n.opacity * 100), kids: n.children.map(c => c.type) }`); + assert(r.fx.includes('DROP_SHADOW') && r.o === 80 && r.kids.length === 1, JSON.stringify(r)); + }); + await check('render: var: binding to a local variable', () => { + evalJson(`const col = figma.variables.createVariableCollection('regression'); const v = figma.variables.createVariable('reg/primary', col, 'COLOR'); v.setValueForMode(col.defaultModeId, { r: 0.1, g: 0.4, b: 0.9 }); return v.id`); + const id = idOf(run(['render', ''])); + const r = evalJson(`const n = await figma.getNodeByIdAsync("${id}"); const b = n.fills[0].boundVariables?.color?.id; return b ? (await figma.variables.getVariableByIdAsync(b)).name : null`); + assert(r === 'reg/primary', String(r)); + }); + await check('eval: expression, statements, compact JSON when piped', () => { + assert(run(['eval', '1 + 1']).trim() === '2', 'expression'); + assert(run(['eval', 'const a = 2; return a * 3']).trim() === '6', 'statements'); + const out = run(['eval', '({ a: 1, b: [1, 2] })']).trim(); + assert(out === '{"a":1,"b":[1,2]}', out); + }); + await check('eval: output cap on huge results', () => { + const out = run(['eval', 'Array.from({ length: 5000 }, (_, i) => "item-" + i)']); + assert(out.length < 21000 && out.includes('output cut'), String(out.length)); + }); + await check('variants from --property (old command)', () => { + const out = run(['render-batch', '["",""]']); + const [a, b] = ids(out); + const v = run(['variants', 'from', `${a},${b}`, '--property', 'Size', '--values', 'Small,Medium', '--name', 'Box']); + const r = evalJson(`await figma.loadAllPagesAsync(); const s = figma.currentPage.findOne(n => n.type === 'COMPONENT_SET' && n.name === 'Box'); return s ? Object.keys(s.variantGroupProperties) : null`); + assert(r && r.join() === 'Size', v.slice(0, 200)); + }); + await check('shadcn add button --count 2', () => { + const out = run(['shadcn', 'add', 'button', '--count', '2']); + const n = (out.match(/\d+:\d+/g) || []).length; + assert(n >= 2, out.slice(0, 300)); + }); + await check('undo removes the last render', () => { + const id = idOf(run(['render', ''])); + run(['undo']); + const r = evalJson(`return !!(await figma.getNodeByIdAsync("${id}"))`); + assert(r === false, 'node still there'); + }); +} finally { + evalJson(`await figma.loadAllPagesAsync(); +const back = await figma.getNodeByIdAsync(${JSON.stringify(startPage)}); +if (back) await figma.setCurrentPageAsync(back); +for (const p of figma.root.children.filter(p => p.name === ${JSON.stringify(PAGE)})) p.remove(); +for (const c of await figma.variables.getLocalVariableCollectionsAsync()) if (c.name === 'regression') c.remove(); +return true`); +} + +let failed = 0; +for (const [name, ok, detail] of results) { + if (!ok) failed++; + console.log((ok ? '✓ ' : '✗ ') + name + (detail ? ' ' + (ok ? '' : '→ ') + detail : '')); +} +console.log(`\n${results.length - failed}/${results.length} regression checks passed`); +process.exit(failed ? 1 : 0);