figma-rust-mcp 3.2.8 → 3.2.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +409 -353
- package/bin/figma-mcp.js +591 -591
- package/package.json +62 -61
- package/plugin/code.js +52 -52
- package/plugin/manifest.json +25 -25
- package/plugin/ui.html +211 -211
- package/scripts/build-plugin.js +52 -51
- package/scripts/test-bridge-sessions.mjs +155 -0
- package/scripts/test-plugin-tokens.mjs +431 -143
- package/scripts/test-rust-mcp.sh +38 -38
|
@@ -1,147 +1,435 @@
|
|
|
1
|
-
import { test } from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import { readFileSync } from 'node:fs';
|
|
4
|
-
import vm from 'node:vm';
|
|
5
|
-
import { spawnSync } from 'node:child_process';
|
|
6
|
-
|
|
7
|
-
function runtime(figma = {}) {
|
|
8
|
-
const context = vm.createContext({ figma, handlers: {}, console, setTimeout, Map, Set, Uint8Array });
|
|
9
|
-
for (const file of ['utils', 'token-helpers', 'handlers-read-detail', 'handlers-read', 'handlers-tokens', 'handlers-write-ops']) {
|
|
10
|
-
vm.runInContext(readFileSync(`plugin-src/${file}.js`, 'utf8'), context, { filename: file });
|
|
11
|
-
}
|
|
12
|
-
return context;
|
|
13
|
-
}
|
|
14
|
-
const plain = value => JSON.parse(JSON.stringify(value));
|
|
15
|
-
const gradient = (transform = [[1, 0, 0], [0, 1, 0]]) => ({
|
|
16
|
-
type: 'GRADIENT_LINEAR', gradientTransform: transform, opacity: .5,
|
|
17
|
-
gradientStops: [{ position: 0, color: { r: 1, g: 0, b: 0, a: .5 } }, { position: 1, color: { r: 0, g: 0, b: 1, a: 1 } }],
|
|
18
|
-
});
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import vm from 'node:vm';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
function runtime(figma = {}) {
|
|
8
|
+
const context = vm.createContext({ figma, handlers: {}, console, setTimeout, Map, Set, Uint8Array });
|
|
9
|
+
for (const file of ['utils', 'token-helpers', 'handlers-read-detail', 'handlers-read', 'handlers-tokens', 'handlers-write-ops']) {
|
|
10
|
+
vm.runInContext(readFileSync(`plugin-src/${file}.js`, 'utf8'), context, { filename: file });
|
|
11
|
+
}
|
|
12
|
+
return context;
|
|
13
|
+
}
|
|
14
|
+
const plain = value => JSON.parse(JSON.stringify(value));
|
|
15
|
+
const gradient = (transform = [[1, 0, 0], [0, 1, 0]]) => ({
|
|
16
|
+
type: 'GRADIENT_LINEAR', gradientTransform: transform, opacity: .5,
|
|
17
|
+
gradientStops: [{ position: 0, color: { r: 1, g: 0, b: 0, a: .5 } }, { position: 1, color: { r: 0, g: 0, b: 1, a: 1 } }],
|
|
18
|
+
});
|
|
19
19
|
const shadow = type => ({ type, color: { r: 0, g: 0, b: 0, a: .125 }, offset: { x: -1, y: 3 }, radius: 6, spread: -2, visible: true });
|
|
20
20
|
|
|
21
|
-
test('
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
assert.equal(r.solidFill(color)[0].opacity, .5);
|
|
27
|
-
}
|
|
28
|
-
assert.equal(r.parseColorValue('#f008').a, 136 / 255);
|
|
29
|
-
assert.equal(r.parseColorValue('transparent').a, 0);
|
|
30
|
-
assert.equal(r.colorToCss({ r: 0, g: 0, b: 0, a: .9999 }), 'rgba(0, 0, 0, 0.9999)');
|
|
31
|
-
assert.throws(() => r.parseColorValue('rgba(garbage)'), /Invalid/);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
test('gradient projection includes translations, aspect ratio, stop and paint alpha', () => {
|
|
35
|
-
const r = runtime();
|
|
36
|
-
const p = r.serializePaint(gradient());
|
|
37
|
-
assert.deepEqual(plain(p.gradientTransform), [[1, 0, 0], [0, 1, 0]]);
|
|
38
|
-
assert.match(r.paintToCss(p, 200, 100), /^linear-gradient\(90deg, rgba\(255, 0, 0, 0.25\) 0%, rgba\(0, 0, 255, 0.5\) 100%\)$/);
|
|
39
|
-
const diagonal = r.paintToCss(r.serializePaint(gradient([[1, 1, 0], [0, 1, 0]])), 200, 100);
|
|
40
|
-
assert.match(diagonal, /153\.434948/);
|
|
41
|
-
assert.match(diagonal, /50%/);
|
|
42
|
-
const shifted = r.paintToCss(r.serializePaint(gradient([[.5, 0, .25], [0, 1, 0]])), 200, 100);
|
|
43
|
-
assert.match(shifted, /-50%/);
|
|
44
|
-
assert.match(shifted, /150%/);
|
|
45
|
-
assert.throws(() => r.paintToCss({ ...p, type: 'GRADIENT_DIAMOND' }, 100, 100), /SVG/);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test('get_css uses the numeric paint, never hex slicing a serialized rgba', async () => {
|
|
49
|
-
const node = { id: '1:2', name: 'alpha', type: 'RECTANGLE', x: 0, y: 0, width: 200, height: 100,
|
|
50
|
-
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 }, opacity: .25 }], effects: [shadow('DROP_SHADOW'), shadow('INNER_SHADOW')] };
|
|
51
|
-
const r = runtime();
|
|
52
|
-
r.findNodeByIdAsync = async () => node;
|
|
53
|
-
const result = await r.handlers.get_css({ id: node.id });
|
|
54
|
-
assert.match(result.css, /background-color: rgba\(255, 0, 0, 0.25\);/);
|
|
55
|
-
assert.doesNotMatch(result.css, /NaN|0.0625/);
|
|
56
|
-
assert.match(result.css, /inset -1px 3px 6px -2px rgba\(0, 0, 0, 0.125\)/);
|
|
57
|
-
assert.equal(result.detail.effects.length, 2);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test('styles retain full paints, effects, units and disabled layers', async () => {
|
|
61
|
-
const r = runtime({
|
|
62
|
-
getLocalPaintStylesAsync: async () => [{ id: 'p', name: 'Gradient', paints: [gradient()] }],
|
|
63
|
-
getLocalTextStylesAsync: async () => [{ id: 't', name: 'Body', fontName: { family: 'Inter', style: 'Regular' }, fontSize: 16, lineHeight: { unit: 'PERCENT', value: 150 }, letterSpacing: { unit: 'PERCENT', value: 2 } }],
|
|
64
|
-
getLocalEffectStylesAsync: async () => [{ id: 's', name: 'Card', effects: [shadow('DROP_SHADOW'), shadow('INNER_SHADOW'), { type: 'LAYER_BLUR', radius: 8, visible: false }] }],
|
|
65
|
-
getLocalGridStylesAsync: async () => [],
|
|
66
|
-
});
|
|
67
|
-
const styles = await r.handlers.get_styles();
|
|
68
|
-
assert.equal(styles.schemaVersion, 2);
|
|
69
|
-
assert.equal(styles.paintStyles[0].paints[0].gradientStops[0].rgba.a, .5);
|
|
70
|
-
assert.equal(styles.effectStyles[0].effects.length, 3);
|
|
71
|
-
assert.equal(styles.textStyles[0].lineHeightUnit, 'PERCENT');
|
|
72
|
-
assert.equal(r.effectsToCss(styles.effectStyles[0].effects).filter, '');
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test('aliases use mode names across collections and retain false/zero values', async () => {
|
|
76
|
-
const cols = [
|
|
77
|
-
{ id: 'semantic', name: 'Semantic', defaultModeId: 's-light', modes: [{ modeId: 's-light', name: 'Light' }, { modeId: 's-dark', name: 'Dark' }], variableIds: ['alias', 'zero'] },
|
|
78
|
-
{ id: 'primitive', name: 'Primitive', defaultModeId: 'p-light', modes: [{ modeId: 'p-light', name: 'Light' }, { modeId: 'p-dark', name: 'Dark' }], variableIds: ['base'] },
|
|
79
|
-
];
|
|
80
|
-
const variables = [
|
|
81
|
-
{ id: 'alias', name: 'semantic', variableCollectionId: 'semantic', resolvedType: 'BOOLEAN', valuesByMode: { 's-light': { type: 'VARIABLE_ALIAS', id: 'base' }, 's-dark': { type: 'VARIABLE_ALIAS', id: 'base' } } },
|
|
82
|
-
{ id: 'zero', name: 'zero', variableCollectionId: 'semantic', resolvedType: 'FLOAT', valuesByMode: { 's-light': 0, 's-dark': 1 } },
|
|
83
|
-
{ id: 'base', name: 'base', variableCollectionId: 'primitive', resolvedType: 'BOOLEAN', valuesByMode: { 'p-light': false, 'p-dark': true } },
|
|
21
|
+
test('compact tree and scan preserve mixed typography instead of using the first run', async () => {
|
|
22
|
+
const mixed = Symbol('mixed');
|
|
23
|
+
const segments = [
|
|
24
|
+
{ start: 0, end: 5, characters: 'Hello', fontSize: 15.5, fontName: { family: 'Inter', style: 'Regular' } },
|
|
25
|
+
{ start: 5, end: 6, characters: '!', fontSize: 22, fontName: { family: 'Inter', style: 'Semi Bold' } },
|
|
84
26
|
];
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
r.
|
|
107
|
-
assert.equal(
|
|
108
|
-
assert.equal((await r.handlers.export_node({ format: 'jpeg' })).format, 'JPG');
|
|
109
|
-
const batch = await r.handlers.batch({ operations: [{ operation: 'export_node', params: { format: 'SVG' } }, { operation: 'export_node', params: { format: 'bad' } }] });
|
|
110
|
-
assert.equal(batch.succeeded, 1);
|
|
111
|
-
assert.match(batch.results[1].error, /supports PNG/);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
test('multiple paints preserve top-first ordering and node opacity stays separate', () => {
|
|
115
|
-
const r = runtime();
|
|
116
|
-
const paints = [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 }, opacity: .5 }, { type: 'SOLID', color: { r: 0, g: 0, b: 1 }, opacity: 1 }].map(r.serializePaint);
|
|
117
|
-
const css = r.paintsToCss(paints, 200, 100);
|
|
118
|
-
assert.match(css.value, /^linear-gradient\(rgba\(255, 0, 0, 0.5\)/);
|
|
119
|
-
assert.match(css.value, /linear-gradient\(#0000ff, #0000ff\)$/);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
test('UI badge uses release version initially and follows the server on reconnect', () => {
|
|
123
|
-
const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
|
|
124
|
-
const html = readFileSync('plugin-runtime/ui.html', 'utf8');
|
|
125
|
-
assert.ok(html.includes(`id="runtime-version">v${pkg.version}</span>`));
|
|
126
|
-
const start = html.indexOf(' function updateRuntimeVersion(');
|
|
127
|
-
const end = html.indexOf(' var runtimeCapabilities', start);
|
|
128
|
-
const badge = {};
|
|
129
|
-
const context = vm.createContext({ document: { getElementById: () => badge } });
|
|
130
|
-
vm.runInContext(html.slice(start, end), context);
|
|
131
|
-
context.updateRuntimeVersion('3.2.7');
|
|
132
|
-
assert.equal(badge.textContent, 'v3.2.7');
|
|
133
|
-
context.updateRuntimeVersion('3.3.0');
|
|
134
|
-
assert.equal(badge.textContent, 'v3.3.0');
|
|
135
|
-
assert.ok(html.includes('updateRuntimeVersion(serverVer)'));
|
|
136
|
-
assert.ok(html.includes('type: "runtime-ready"'));
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
test('build rejects wrong release tags and checked-in bundle matches source', () => {
|
|
140
|
-
const check = spawnSync(process.execPath, ['scripts/build-plugin.js', '--check'], { encoding: 'utf8' });
|
|
141
|
-
assert.equal(check.status, 0, check.stderr);
|
|
142
|
-
const rejected = spawnSync(process.execPath, ['scripts/build-plugin.js'], {
|
|
143
|
-
encoding: 'utf8', env: { ...process.env, GITHUB_REF_TYPE: 'tag', GITHUB_REF_NAME: 'v0.0.0-invalid' },
|
|
144
|
-
});
|
|
145
|
-
assert.notEqual(rejected.status, 0);
|
|
146
|
-
assert.match(rejected.stderr, /does not match build version/);
|
|
27
|
+
const node = { id: '1:1', name: 'Greeting', type: 'TEXT', characters: 'Hello!',
|
|
28
|
+
fontSize: mixed, fontName: mixed, visible: true, getStyledTextSegments: () => segments };
|
|
29
|
+
const r = runtime({ root: { id: '0:0' }, currentPage: { id: '0:1' }, getNodeByIdAsync: async () => node });
|
|
30
|
+
vm.runInContext(readFileSync('plugin-src/read-helpers.js', 'utf8'), r);
|
|
31
|
+
for (const detail of ['compact', 'full']) {
|
|
32
|
+
const tokens = { colors: new Set(), fonts: new Set(), sizes: new Set() };
|
|
33
|
+
const tree = plain(r.extractDesignTree(node, 0, 10, detail, true, tokens));
|
|
34
|
+
assert.equal(tree.mixedStyles, true);
|
|
35
|
+
assert.equal(tree.fontSize, undefined);
|
|
36
|
+
assert.equal(tree.fontWeight, undefined);
|
|
37
|
+
assert.equal(tree.fontFamily, 'Inter');
|
|
38
|
+
assert.deepEqual(tree.segments.map(s => [s.start, s.end, s.fontSize, s.fontWeight]),
|
|
39
|
+
[[0, 5, 15.5, 'Regular'], [5, 6, 22, 'Semi Bold']]);
|
|
40
|
+
assert.deepEqual([...tokens.fonts], ['Inter/Regular/15.5px', 'Inter/Semi Bold/22px']);
|
|
41
|
+
}
|
|
42
|
+
const scan = plain(await r.handlers.scan_design({ id: '1:1' }));
|
|
43
|
+
assert.equal(scan.allText[0].fontSize, null);
|
|
44
|
+
assert.equal(scan.allText[0].fontWeight, null);
|
|
45
|
+
assert.equal(scan.allText[0].segments.length, 2);
|
|
46
|
+
assert.deepEqual(scan.allFonts.map(f => f.font).sort(), ['Inter/Regular/15.5px', 'Inter/Semi Bold/22px']);
|
|
47
|
+
segments[1].fontSize = 15.5;
|
|
48
|
+
assert.equal(r.resolveTextStyle(node).fontSize, 15.5);
|
|
49
|
+
assert.equal(r.resolveTextStyle(node).fontWeight, undefined);
|
|
147
50
|
});
|
|
51
|
+
|
|
52
|
+
test('equivalent CSS syntaxes preserve alpha in reads and writes', () => {
|
|
53
|
+
const r = runtime();
|
|
54
|
+
for (const color of ['rgba(255, 0, 0, .5)', 'rgb(100% 0% 0% / 50%)', 'hsl(0 100% 50% / .5)', 'hsla(0, 100%, 50%, .5)']) {
|
|
55
|
+
assert.deepEqual(plain(r.parseColorValue(color)), { r: 1, g: 0, b: 0, a: .5 });
|
|
56
|
+
assert.equal(r.hexToRgbA(color).a, .5);
|
|
57
|
+
assert.equal(r.solidFill(color)[0].opacity, .5);
|
|
58
|
+
}
|
|
59
|
+
assert.equal(r.parseColorValue('#f008').a, 136 / 255);
|
|
60
|
+
assert.equal(r.parseColorValue('transparent').a, 0);
|
|
61
|
+
assert.equal(r.colorToCss({ r: 0, g: 0, b: 0, a: .9999 }), 'rgba(0, 0, 0, 0.9999)');
|
|
62
|
+
assert.throws(() => r.parseColorValue('rgba(garbage)'), /Invalid/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('gradient projection includes translations, aspect ratio, stop and paint alpha', () => {
|
|
66
|
+
const r = runtime();
|
|
67
|
+
const p = r.serializePaint(gradient());
|
|
68
|
+
assert.deepEqual(plain(p.gradientTransform), [[1, 0, 0], [0, 1, 0]]);
|
|
69
|
+
assert.match(r.paintToCss(p, 200, 100), /^linear-gradient\(90deg, rgba\(255, 0, 0, 0.25\) 0%, rgba\(0, 0, 255, 0.5\) 100%\)$/);
|
|
70
|
+
const diagonal = r.paintToCss(r.serializePaint(gradient([[1, 1, 0], [0, 1, 0]])), 200, 100);
|
|
71
|
+
assert.match(diagonal, /153\.434948/);
|
|
72
|
+
assert.match(diagonal, /50%/);
|
|
73
|
+
const shifted = r.paintToCss(r.serializePaint(gradient([[.5, 0, .25], [0, 1, 0]])), 200, 100);
|
|
74
|
+
assert.match(shifted, /-50%/);
|
|
75
|
+
assert.match(shifted, /150%/);
|
|
76
|
+
assert.throws(() => r.paintToCss({ ...p, type: 'GRADIENT_DIAMOND' }, 100, 100), /SVG/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('get_css uses the numeric paint, never hex slicing a serialized rgba', async () => {
|
|
80
|
+
const node = { id: '1:2', name: 'alpha', type: 'RECTANGLE', x: 0, y: 0, width: 200, height: 100,
|
|
81
|
+
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 }, opacity: .25 }], effects: [shadow('DROP_SHADOW'), shadow('INNER_SHADOW')] };
|
|
82
|
+
const r = runtime();
|
|
83
|
+
r.findNodeByIdAsync = async () => node;
|
|
84
|
+
const result = await r.handlers.get_css({ id: node.id });
|
|
85
|
+
assert.match(result.css, /background-color: rgba\(255, 0, 0, 0.25\);/);
|
|
86
|
+
assert.doesNotMatch(result.css, /NaN|0.0625/);
|
|
87
|
+
assert.match(result.css, /inset -1px 3px 6px -2px rgba\(0, 0, 0, 0.125\)/);
|
|
88
|
+
assert.equal(result.detail.effects.length, 2);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('styles retain full paints, effects, units and disabled layers', async () => {
|
|
92
|
+
const r = runtime({
|
|
93
|
+
getLocalPaintStylesAsync: async () => [{ id: 'p', name: 'Gradient', paints: [gradient()] }],
|
|
94
|
+
getLocalTextStylesAsync: async () => [{ id: 't', name: 'Body', fontName: { family: 'Inter', style: 'Regular' }, fontSize: 16, lineHeight: { unit: 'PERCENT', value: 150 }, letterSpacing: { unit: 'PERCENT', value: 2 } }],
|
|
95
|
+
getLocalEffectStylesAsync: async () => [{ id: 's', name: 'Card', effects: [shadow('DROP_SHADOW'), shadow('INNER_SHADOW'), { type: 'LAYER_BLUR', radius: 8, visible: false }] }],
|
|
96
|
+
getLocalGridStylesAsync: async () => [],
|
|
97
|
+
});
|
|
98
|
+
const styles = await r.handlers.get_styles();
|
|
99
|
+
assert.equal(styles.schemaVersion, 2);
|
|
100
|
+
assert.equal(styles.paintStyles[0].paints[0].gradientStops[0].rgba.a, .5);
|
|
101
|
+
assert.equal(styles.effectStyles[0].effects.length, 3);
|
|
102
|
+
assert.equal(styles.textStyles[0].lineHeightUnit, 'PERCENT');
|
|
103
|
+
assert.equal(r.effectsToCss(styles.effectStyles[0].effects).filter, '');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('aliases use mode names across collections and retain false/zero values', async () => {
|
|
107
|
+
const cols = [
|
|
108
|
+
{ id: 'semantic', name: 'Semantic', defaultModeId: 's-light', modes: [{ modeId: 's-light', name: 'Light' }, { modeId: 's-dark', name: 'Dark' }], variableIds: ['alias', 'zero'] },
|
|
109
|
+
{ id: 'primitive', name: 'Primitive', defaultModeId: 'p-light', modes: [{ modeId: 'p-light', name: 'Light' }, { modeId: 'p-dark', name: 'Dark' }], variableIds: ['base'] },
|
|
110
|
+
];
|
|
111
|
+
const variables = [
|
|
112
|
+
{ id: 'alias', name: 'semantic', variableCollectionId: 'semantic', resolvedType: 'BOOLEAN', valuesByMode: { 's-light': { type: 'VARIABLE_ALIAS', id: 'base' }, 's-dark': { type: 'VARIABLE_ALIAS', id: 'base' } } },
|
|
113
|
+
{ id: 'zero', name: 'zero', variableCollectionId: 'semantic', resolvedType: 'FLOAT', valuesByMode: { 's-light': 0, 's-dark': 1 } },
|
|
114
|
+
{ id: 'base', name: 'base', variableCollectionId: 'primitive', resolvedType: 'BOOLEAN', valuesByMode: { 'p-light': false, 'p-dark': true } },
|
|
115
|
+
];
|
|
116
|
+
const r = runtime({ variables: {
|
|
117
|
+
getLocalVariableCollectionsAsync: async () => cols, getLocalVariablesAsync: async () => variables,
|
|
118
|
+
getVariableByIdAsync: async id => variables.find(v => v.id === id),
|
|
119
|
+
getVariableCollectionByIdAsync: async id => cols.find(c => c.id === id),
|
|
120
|
+
} });
|
|
121
|
+
const result = await r.handlers.get_variables();
|
|
122
|
+
const alias = result.collections[0].variables[0];
|
|
123
|
+
assert.equal(alias.values['s-light'].resolvedValue, false);
|
|
124
|
+
assert.equal(alias.values['s-dark'].resolvedValue, true);
|
|
125
|
+
assert.equal(result.resolvedTokens.semantic, false);
|
|
126
|
+
assert.equal(result.resolvedTokens.zero, 0);
|
|
127
|
+
assert.equal(result.collections[0].defaultModeId, 's-light');
|
|
128
|
+
assert.deepEqual(plain(result.diagnostics), []);
|
|
129
|
+
const consumer = { resolvedVariableModes: { semantic: 's-dark', primitive: 'p-light' } };
|
|
130
|
+
variables[0].resolveForConsumer = () => ({ value: false, resolvedType: 'BOOLEAN' });
|
|
131
|
+
assert.equal((await r.resolveVariableValueAsync(variables[0], consumer)).resolvedValue, false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('export_node validates format and works through batch and aliases', async () => {
|
|
135
|
+
const r = runtime();
|
|
136
|
+
r.handlers.export_svg = async () => ({ svg: '<svg/>' });
|
|
137
|
+
r.handlers.export_image = async params => ({ format: params.format });
|
|
138
|
+
assert.equal((await r.resolveOperationHandler('exportNode')({ format: 'svg' })).svg, '<svg/>');
|
|
139
|
+
assert.equal((await r.handlers.export_node({ format: 'jpeg' })).format, 'JPG');
|
|
140
|
+
const batch = await r.handlers.batch({ operations: [{ operation: 'export_node', params: { format: 'SVG' } }, { operation: 'export_node', params: { format: 'bad' } }] });
|
|
141
|
+
assert.equal(batch.succeeded, 1);
|
|
142
|
+
assert.match(batch.results[1].error, /supports PNG/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('multiple paints preserve top-first ordering and node opacity stays separate', () => {
|
|
146
|
+
const r = runtime();
|
|
147
|
+
const paints = [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 }, opacity: .5 }, { type: 'SOLID', color: { r: 0, g: 0, b: 1 }, opacity: 1 }].map(r.serializePaint);
|
|
148
|
+
const css = r.paintsToCss(paints, 200, 100);
|
|
149
|
+
assert.match(css.value, /^linear-gradient\(rgba\(255, 0, 0, 0.5\)/);
|
|
150
|
+
assert.match(css.value, /linear-gradient\(#0000ff, #0000ff\)$/);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('UI badge uses release version initially and follows the server on reconnect', () => {
|
|
154
|
+
const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
|
|
155
|
+
const html = readFileSync('plugin-runtime/ui.html', 'utf8');
|
|
156
|
+
assert.ok(html.includes(`id="runtime-version">v${pkg.version}</span>`));
|
|
157
|
+
const start = html.indexOf(' function updateRuntimeVersion(');
|
|
158
|
+
const end = html.indexOf(' var runtimeCapabilities', start);
|
|
159
|
+
const badge = {};
|
|
160
|
+
const context = vm.createContext({ document: { getElementById: () => badge } });
|
|
161
|
+
vm.runInContext(html.slice(start, end), context);
|
|
162
|
+
context.updateRuntimeVersion('3.2.7');
|
|
163
|
+
assert.equal(badge.textContent, 'v3.2.7');
|
|
164
|
+
context.updateRuntimeVersion('3.3.0');
|
|
165
|
+
assert.equal(badge.textContent, 'v3.3.0');
|
|
166
|
+
assert.ok(html.includes('updateRuntimeVersion(serverVer)'));
|
|
167
|
+
assert.ok(html.includes('type: "runtime-ready"'));
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('build rejects wrong release tags and checked-in bundle matches source', () => {
|
|
171
|
+
const check = spawnSync(process.execPath, ['scripts/build-plugin.js', '--check'], { encoding: 'utf8' });
|
|
172
|
+
assert.equal(check.status, 0, check.stderr);
|
|
173
|
+
const rejected = spawnSync(process.execPath, ['scripts/build-plugin.js'], {
|
|
174
|
+
encoding: 'utf8', env: { ...process.env, GITHUB_REF_TYPE: 'tag', GITHUB_REF_NAME: 'v0.0.0-invalid' },
|
|
175
|
+
});
|
|
176
|
+
assert.notEqual(rejected.status, 0);
|
|
177
|
+
assert.match(rejected.stderr, /does not match build version/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
test('tree reads preserve same-size screens and all repeated siblings', () => {
|
|
182
|
+
const r = runtime();
|
|
183
|
+
vm.runInContext(readFileSync('plugin-src/read-helpers.js', 'utf8'), r);
|
|
184
|
+
const screens = Array.from({ length: 12 }, (_, i) => ({
|
|
185
|
+
id: `screen:${i}`, name: `Screen ${i}`, type: 'FRAME', width: 402, height: 800,
|
|
186
|
+
layoutMode: 'VERTICAL', itemSpacing: i,
|
|
187
|
+
children: [{ id: `item:${i}`, name: `Item ${i}`, type: 'FRAME', width: 100, height: 40 }],
|
|
188
|
+
}));
|
|
189
|
+
const root = { id: 'root', name: 'Screens', type: 'SECTION', children: screens };
|
|
190
|
+
for (const detail of ['compact', 'full']) {
|
|
191
|
+
const tree = plain(r.extractDesignTree(root, 0, 10, detail, true));
|
|
192
|
+
assert.equal(tree.children.length, 12);
|
|
193
|
+
assert.deepEqual(tree.children.map(node => node.id), screens.map(node => node.id));
|
|
194
|
+
assert.equal(tree.children[11].layout.itemSpacing, 11);
|
|
195
|
+
assert.equal(tree.children[11].children[0].id, 'item:11');
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
test('startup index defers document-wide components and coalesces duplicate scans', async () => {
|
|
201
|
+
let componentQueries = 0, styleQueries = 0;
|
|
202
|
+
const page = { id: 'page:1', children: [{ id: 'frame:1', name: 'Frame', type: 'FRAME' }] };
|
|
203
|
+
const r = runtime({ currentPage: page, root: { name: 'File', getPluginData: () => '' } });
|
|
204
|
+
r.handlers.get_styles = async () => { styleQueries++; return { schemaVersion: 2 }; };
|
|
205
|
+
r.handlers.get_variables = async () => ({ schemaVersion: 2 });
|
|
206
|
+
r.handlers.get_local_components = async () => { componentQueries++; return { components: [], componentSets: [] }; };
|
|
207
|
+
const [a, b] = await Promise.all([
|
|
208
|
+
r.handlers.index_scan({ deferComponents: true }),
|
|
209
|
+
r.handlers.index_scan({ deferComponents: true }),
|
|
210
|
+
]);
|
|
211
|
+
assert.equal(componentQueries, 0);
|
|
212
|
+
assert.equal(styleQueries, 1);
|
|
213
|
+
assert.equal(a, b);
|
|
214
|
+
assert.equal(a.pageId, page.id);
|
|
215
|
+
assert.equal(a.componentsDeferred, true);
|
|
216
|
+
assert.equal(a.pageNodes[0].id, 'frame:1');
|
|
217
|
+
const [light, full] = await Promise.all([
|
|
218
|
+
r.handlers.index_scan({ deferComponents: true }), r.handlers.index_scan({}),
|
|
219
|
+
]);
|
|
220
|
+
assert.equal(light.components, null);
|
|
221
|
+
assert.equal(full.componentsDeferred, false);
|
|
222
|
+
assert.equal(componentQueries, 1);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('component catalogue walks pages cooperatively without bulk loading or root searches', async () => {
|
|
226
|
+
let loaded = 0, yields = 0, reads = 0, lastYieldReads = 0, maxSliceReads = 0;
|
|
227
|
+
const page = { type: 'PAGE', name: 'Components', loadAsync: async () => { loaded++; } };
|
|
228
|
+
page.children = Array.from({ length: 1000 }, (_, i) => ({
|
|
229
|
+
id: `c:${i}`, name: `Component ${i}`, parent: page, width: 10, height: 10,
|
|
230
|
+
get type() { reads++; return i % 2 ? 'COMPONENT_SET' : 'COMPONENT'; },
|
|
231
|
+
}));
|
|
232
|
+
const r = runtime({
|
|
233
|
+
root: { children: [page], findAllWithCriteria: () => assert.fail('root-wide search') },
|
|
234
|
+
loadAllPagesAsync: () => assert.fail('bulk page load'),
|
|
235
|
+
});
|
|
236
|
+
r.yieldToUI = async () => {
|
|
237
|
+
yields++; maxSliceReads = Math.max(maxSliceReads, reads - lastYieldReads); lastYieldReads = reads;
|
|
238
|
+
};
|
|
239
|
+
const result = await r.handlers.get_local_components();
|
|
240
|
+
assert.equal(loaded, 1);
|
|
241
|
+
assert.equal(result.components.length, 500);
|
|
242
|
+
assert.equal(result.componentSets.length, 500);
|
|
243
|
+
assert.ok(yields >= 10, 'large traversal must yield before finishing');
|
|
244
|
+
assert.ok(maxSliceReads <= 200, 'no more than 100 nodes between traversal yields');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('startup subscribes to active-page changes without loading the whole file', async () => {
|
|
248
|
+
const events = new Map(), timers = [], messages = [];
|
|
249
|
+
let pageListener;
|
|
250
|
+
const page = {
|
|
251
|
+
id: 'page:1', children: [], selection: [],
|
|
252
|
+
on: (name, callback) => { assert.equal(name, 'nodechange'); pageListener = callback; },
|
|
253
|
+
off: () => {},
|
|
254
|
+
};
|
|
255
|
+
const r = runtime({
|
|
256
|
+
currentPage: page, root: { name: 'File', getPluginData: () => 'session' },
|
|
257
|
+
clientStorage: { getAsync: async () => null }, ui: { postMessage: msg => messages.push(msg) },
|
|
258
|
+
on: (name, callback) => { events.set(name, callback); },
|
|
259
|
+
loadAllPagesAsync: () => assert.fail('startup bulk page load'),
|
|
260
|
+
});
|
|
261
|
+
r.setTimeout = (callback, delay) => { timers.push({ callback, delay }); return timers.length; };
|
|
262
|
+
r.clearTimeout = () => {};
|
|
263
|
+
vm.runInContext(readFileSync('plugin-src/main.js', 'utf8'), r);
|
|
264
|
+
assert.ok(pageListener);
|
|
265
|
+
assert.ok(events.has('stylechange'));
|
|
266
|
+
assert.ok(!events.has('documentchange'));
|
|
267
|
+
let scanOptions;
|
|
268
|
+
r.handlers.index_scan = async options => { scanOptions = options; return { pageId: page.id }; };
|
|
269
|
+
await timers.find(timer => timer.delay === 1800).callback();
|
|
270
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
271
|
+
assert.equal(scanOptions.deferComponents, true);
|
|
272
|
+
pageListener({ nodeChanges: [{ type: 'PROPERTY_CHANGE', node: { id: 'changed:1' } }] });
|
|
273
|
+
assert.ok(r.pendingChangedNodeIds.has('changed:1'));
|
|
274
|
+
assert.ok(messages.some(msg => msg.type === 'document-change'));
|
|
275
|
+
assert.deepEqual(plain(messages.filter(msg => msg.type === 'document-change').at(-1).changedNodeIds), ['changed:1']);
|
|
276
|
+
r.findNodeByIdAsync = async id => id === 'changed:1'
|
|
277
|
+
? { id, parent: page, type: 'TEXT', fontSize: 15.5, fontWeight: 'Semi Bold' } : null;
|
|
278
|
+
r.extractDesignTree = node => ({ id: node.id, type: node.type, fontSize: node.fontSize, fontWeight: node.fontWeight });
|
|
279
|
+
pageListener({ nodeChanges: [{ type: 'DELETE', id: 'deleted:1' }] });
|
|
280
|
+
await timers.filter(timer => timer.delay === 100).at(-1).callback();
|
|
281
|
+
const diff = plain(messages.filter(msg => msg.type === 'node-diff').at(-1));
|
|
282
|
+
assert.deepEqual(diff.deletedIds, ['deleted:1']);
|
|
283
|
+
assert.equal(diff.nodes[0].fontSize, 15.5);
|
|
284
|
+
assert.equal(diff.nodes[0].fontWeight, 'Semi Bold');
|
|
285
|
+
assert.equal(diff.nodes[0].parentId, page.id);
|
|
286
|
+
assert.deepEqual(diff.nodes[0].childIds, []);
|
|
287
|
+
const next = { id: 'page:2', on: () => {}, off: () => {} };
|
|
288
|
+
r.figma.currentPage = next;
|
|
289
|
+
r.handlers.index_scan = async () => ({ pageId: 'page:1' });
|
|
290
|
+
const previousUpdates = messages.filter(msg => msg.type === 'index-update').length;
|
|
291
|
+
await r.publishIndex(true);
|
|
292
|
+
assert.equal(messages.filter(msg => msg.type === 'index-update').length, previousUpdates);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
function taskRuntime() {
|
|
297
|
+
const page = { id: 'page', type: 'PAGE' };
|
|
298
|
+
const a = { id: 'a', name: 'A', type: 'FRAME', parent: page, children: [] };
|
|
299
|
+
const b = { id: 'b', name: 'B', type: 'FRAME', parent: page, children: [] };
|
|
300
|
+
const child = { id: 'a:child', type: 'TEXT', parent: a };
|
|
301
|
+
a.children.push(child);
|
|
302
|
+
const nodes = new Map([page, a, b, child].map(node => [node.id, node]));
|
|
303
|
+
const r = runtime();
|
|
304
|
+
vm.runInContext(readFileSync('plugin-src/task-scope.js', 'utf8'), r);
|
|
305
|
+
r.findNodeByIdAsync = async id => nodes.get(id);
|
|
306
|
+
return { r, nodes, a, b, page };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
test('frame tasks reject outside/global writes and batch escape before mutations', async () => {
|
|
310
|
+
const { r, a, b } = taskRuntime();
|
|
311
|
+
await r.handlers.task_start({ taskId: 'task-a', frameId: a.id });
|
|
312
|
+
await r.handlers.task_start({ taskId: 'task-b', frameId: b.id });
|
|
313
|
+
await assert.rejects(r.handlers.task_start({ taskId: 'task-c', frameId: a.id }), /reserved/);
|
|
314
|
+
const create = await r.validateTaskOperation('create', { _taskId: 'task-a', type: 'TEXT', content: 'Hello' });
|
|
315
|
+
assert.equal(create.params.parentId, a.id);
|
|
316
|
+
const read = await r.validateTaskOperation('get_selection', { _taskId: 'task-a' });
|
|
317
|
+
assert.equal(read.operation, 'get_design');
|
|
318
|
+
assert.equal(read.params.id, a.id);
|
|
319
|
+
assert.equal((await r.validateTaskOperation('screenshot', { _taskId: 'task-a', keepViewport: false })).params.keepViewport, true);
|
|
320
|
+
await r.validateTaskOperation('modify', { _taskId: 'task-a', id: 'a:child', name: 'Updated' });
|
|
321
|
+
for (const [operation, params] of [
|
|
322
|
+
['modify', { id: b.id }], ['create', { type: 'TEXT', parentId: b.id }],
|
|
323
|
+
['delete', { id: a.id, force: true }], ['clone', { id: a.id }],
|
|
324
|
+
['append', { parentId: b.id, childId: 'a:child' }],
|
|
325
|
+
['set_selection', { nodeIds: [a.id] }], ['setPage', { id: 'page' }],
|
|
326
|
+
['setVariableValue', { id: 'variable' }], ['createPaintStyle', { name: 'Global' }],
|
|
327
|
+
['create', { type: 'COMPONENT' }],
|
|
328
|
+
['batch', { operations: [{ operation: 'modify', params: { id: 'a:child' } }, { operation: 'delete', params: { id: b.id } }] }],
|
|
329
|
+
]) {
|
|
330
|
+
await assert.rejects(r.validateTaskOperation(operation, { ...params, _taskId: 'task-a' }));
|
|
331
|
+
}
|
|
332
|
+
await assert.rejects(r.validateTaskOperation('modify', { id: 'a:child' }), /require taskId/);
|
|
333
|
+
await r.handlers.task_end({ taskId: 'task-a' });
|
|
334
|
+
await assert.rejects(r.validateTaskOperation('modify', { _taskId: 'task-a', id: 'a:child' }), /Unknown task/);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test('task roots cannot be shared masters or nested layout frames', async () => {
|
|
338
|
+
const { r, nodes, a } = taskRuntime();
|
|
339
|
+
const nested = { id: 'nested', type: 'FRAME', parent: a, children: [] };
|
|
340
|
+
nodes.set(nested.id, nested);
|
|
341
|
+
await assert.rejects(r.handlers.task_start({ taskId: 'nested-task', frameId: nested.id }), /independent FRAME/);
|
|
342
|
+
a.children.push({ id: 'master', type: 'COMPONENT', parent: a });
|
|
343
|
+
await assert.rejects(r.handlers.task_start({ taskId: 'master-task', frameId: a.id }), /shared component/);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test('plugin tab IDs differ for the same document and request replay does not write twice', async () => {
|
|
347
|
+
const messages = [];
|
|
348
|
+
function boot() {
|
|
349
|
+
const r = runtime({
|
|
350
|
+
fileKey: 'same-file', root: { name: 'File', getPluginData: () => 'same-file' },
|
|
351
|
+
currentPage: { id: 'page', selection: [], on: () => {} },
|
|
352
|
+
clientStorage: { getAsync: async () => null }, ui: { postMessage: msg => messages.push(msg) }, on: () => {},
|
|
353
|
+
});
|
|
354
|
+
r.setTimeout = () => 0; r.clearTimeout = () => {};
|
|
355
|
+
r.validateTaskOperation = async (operation, params) => ({ operation, params });
|
|
356
|
+
vm.runInContext(readFileSync('plugin-src/main.js', 'utf8'), r);
|
|
357
|
+
return r;
|
|
358
|
+
}
|
|
359
|
+
const first = boot(), second = boot();
|
|
360
|
+
assert.notEqual(first.currentSessionId, second.currentSessionId);
|
|
361
|
+
assert.equal(first.currentDocumentId, second.currentDocumentId);
|
|
362
|
+
let release;
|
|
363
|
+
const blocked = new Promise(resolve => { release = resolve; });
|
|
364
|
+
const entered = [];
|
|
365
|
+
first.handlers.modify = async params => { entered.push(params.id); if (params.id === 'first') await blocked; return params; };
|
|
366
|
+
const call1 = first.figma.ui.onmessage({ id: 'request-1', operation: 'modify', params: { id: 'first' } });
|
|
367
|
+
const replay = first.figma.ui.onmessage({ id: 'request-1', operation: 'modify', params: { id: 'first' } });
|
|
368
|
+
const call2 = first.figma.ui.onmessage({ id: 'request-2', operation: 'modify', params: { id: 'second' } });
|
|
369
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
370
|
+
assert.deepEqual(entered, ['first']);
|
|
371
|
+
release(); await Promise.all([call1, replay, call2]);
|
|
372
|
+
assert.deepEqual(entered, ['first', 'second']);
|
|
373
|
+
assert.equal(messages.filter(msg => msg.id === 'request-1' && msg.success).length, 2);
|
|
374
|
+
assert.ok(messages.filter(msg => msg.id).every(msg => msg.sessionId === first.currentSessionId));
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test('old UI socket callbacks cannot clear or dispatch through a replacement socket', () => {
|
|
378
|
+
const html = readFileSync('plugin-runtime/ui.html', 'utf8');
|
|
379
|
+
const source = html.slice(html.indexOf(' function connectWs()'), html.indexOf(' async function startLongPoll()'));
|
|
380
|
+
const sockets = [], dispatched = [];
|
|
381
|
+
class Socket {
|
|
382
|
+
static OPEN = 1;
|
|
383
|
+
constructor(url) { this.url = url; this.readyState = 1; sockets.push(this); }
|
|
384
|
+
close() {} send() {}
|
|
385
|
+
}
|
|
386
|
+
const r = vm.createContext({
|
|
387
|
+
ws: null, wsConnected: false, sessionId: null, fileName: 'File', documentId: 'doc', BRIDGE: 'http://localhost:38451',
|
|
388
|
+
WebSocket: Socket, setInterval: () => 1, clearInterval: () => {}, setTimeout: () => 1, clearTimeout: () => {},
|
|
389
|
+
sendRuntimeCapabilities: () => {}, consecutiveErrors: 0, everConnected: false, retryTimer: null, polling: true,
|
|
390
|
+
setStatus: () => {}, log: () => {}, currentPort: 38451, READ_OPS: [],
|
|
391
|
+
startLongPoll: () => assert.fail('stale socket started poll'), dispatchToMain: req => dispatched.push(req),
|
|
392
|
+
});
|
|
393
|
+
vm.runInContext(source, r);
|
|
394
|
+
r.connectWs(); assert.equal(sockets.length, 0);
|
|
395
|
+
r.sessionId = 'tab-a'; r.connectWs(); const old = sockets[0];
|
|
396
|
+
old.onopen(); r.connectWs(); const replacement = sockets[1]; replacement.onopen();
|
|
397
|
+
old.onclose(); old.onmessage({ data: JSON.stringify({ id: 'old', operation: 'modify' }) });
|
|
398
|
+
assert.equal(r.wsConnected, true);
|
|
399
|
+
assert.equal(r.ws, replacement);
|
|
400
|
+
assert.equal(dispatched.length, 0);
|
|
401
|
+
replacement.onmessage({ data: JSON.stringify({ id: 'new', operation: 'modify' }) });
|
|
402
|
+
assert.equal(dispatched[0].id, 'new');
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test('tree reads preserve Regular font weight, collect Regular font tokens, and resolve textStyle', async () => {
|
|
406
|
+
const figma = {
|
|
407
|
+
getLocalPaintStylesAsync: async () => [{ id: 'S:paint-1', name: 'Brand/Primary' }],
|
|
408
|
+
getLocalTextStylesAsync: async () => [{ id: 'S:text-1', name: 'Lato/18px/regular' }],
|
|
409
|
+
getLocalEffectStylesAsync: async () => [],
|
|
410
|
+
};
|
|
411
|
+
const context = vm.createContext({ figma, handlers: {}, console, setTimeout, Map, Set, Uint8Array });
|
|
412
|
+
for (const file of ['utils', 'svg-path-helpers', 'paint-and-effects', 'token-helpers', 'read-helpers', 'handlers-read-detail', 'handlers-read']) {
|
|
413
|
+
vm.runInContext(readFileSync(`plugin-src/${file}.js`, 'utf8'), context, { filename: file });
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const walkState = await context.makeWalkStateAsync();
|
|
417
|
+
const tokenCollector = { colors: new Set(), fonts: new Set(), sizes: new Set() };
|
|
418
|
+
const textNode = {
|
|
419
|
+
id: '3139:247224',
|
|
420
|
+
name: 'Header',
|
|
421
|
+
type: 'TEXT',
|
|
422
|
+
characters: 'Shipping address',
|
|
423
|
+
fontSize: 18,
|
|
424
|
+
fontName: { family: 'Lato', style: 'Regular' },
|
|
425
|
+
textStyleId: 'S:text-1',
|
|
426
|
+
visible: true,
|
|
427
|
+
};
|
|
428
|
+
const tree = context.extractDesignTree(textNode, 0, 15, 'full', true, tokenCollector, null, walkState);
|
|
429
|
+
assert.equal(tree.fontWeight, 'Regular');
|
|
430
|
+
assert.equal(tree.fontFamily, 'Lato');
|
|
431
|
+
assert.equal(tree.fontSize, 18);
|
|
432
|
+
assert.equal(tree.textStyleId, 'S:text-1');
|
|
433
|
+
assert.equal(tree.textStyle, 'Lato/18px/regular');
|
|
434
|
+
assert.ok(tokenCollector.fonts.has('Lato/Regular/18px'));
|
|
435
|
+
});
|