@pedroab0/skills 1.0.1 → 1.2.0
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/README.md +19 -3
- package/bin/cli.js +175 -35
- package/figma-extractor/README.md +14 -6
- package/figma-extractor/SKILL.md +18 -16
- package/figma-extractor/figma-plugin/README.md +10 -5
- package/figma-extractor/figma-plugin/code.js +461 -87
- package/figma-extractor/figma-plugin/ui.html +76 -23
- package/figma-extractor/scripts/fetch_figma.js +465 -61
- package/figma-node-builder/README.md +1 -1
- package/figma-node-builder/SKILL.md +7 -8
- package/figma-node-builder/scripts/inspect_spec.js +15 -2
- package/figma-to-code/README.md +10 -4
- package/figma-to-code/SKILL.md +18 -16
- package/figma-to-code/scripts/fetch_figma.js +448 -53
- package/package.json +12 -3
- package/figma-extractor/demo/desktop_home.json +0 -2434
- package/figma-extractor/demo/dt_money_desktop_home.png +0 -0
- package/figma-extractor/demo/dt_money_preview.png +0 -0
- package/figma-extractor/demo/dt_money_selection.json +0 -472
- package/figma-extractor/demo/index.html +0 -188
- package/figma-extractor/demo/style.css +0 -360
- package/figma-extractor/figma-plugin/assets/cover-1920.png +0 -0
- package/figma-extractor/figma-plugin/assets/cover.jpg +0 -0
- package/figma-extractor/figma-plugin/assets/cover.png +0 -0
- package/figma-extractor/figma-plugin/assets/icon-128.png +0 -0
- package/figma-extractor/figma-plugin/assets/icon.png +0 -0
- package/figma-extractor/scripts/test_fetch_figma.js +0 -601
- package/figma-node-builder/scripts/test_inspect_spec.js +0 -148
- package/figma-to-code/scripts/test_fetch_figma.js +0 -581
|
@@ -1,601 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
const assert = require('assert');
|
|
4
|
-
const {
|
|
5
|
-
loadEnv,
|
|
6
|
-
parseEnvLine,
|
|
7
|
-
parseFigmaUrl,
|
|
8
|
-
normalizeNodeId,
|
|
9
|
-
getAuthHeaders,
|
|
10
|
-
formatRgba,
|
|
11
|
-
simplifyFills,
|
|
12
|
-
normalizePropKey,
|
|
13
|
-
normalizeComponentProps,
|
|
14
|
-
buildVariableMap,
|
|
15
|
-
pruneFigmaNode,
|
|
16
|
-
parseArguments,
|
|
17
|
-
formatApiError,
|
|
18
|
-
fetchFigmaVariables,
|
|
19
|
-
checkAndApplyRateGate,
|
|
20
|
-
recordRateLimitLockout,
|
|
21
|
-
resetRateGateState,
|
|
22
|
-
loadRateGateState,
|
|
23
|
-
} = require('./fetch_figma.js');
|
|
24
|
-
|
|
25
|
-
let passedTests = 0;
|
|
26
|
-
let totalTests = 0;
|
|
27
|
-
|
|
28
|
-
function test(name, fn) {
|
|
29
|
-
totalTests++;
|
|
30
|
-
try {
|
|
31
|
-
fn();
|
|
32
|
-
console.log(` ✓ ${name}`);
|
|
33
|
-
passedTests++;
|
|
34
|
-
} catch (err) {
|
|
35
|
-
console.error(` ✗ ${name}`);
|
|
36
|
-
console.error(` ${err.message}`);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
console.log('--- Running Figma Extractor Unit Tests ---');
|
|
41
|
-
|
|
42
|
-
// 0. Environment Parsing Tests
|
|
43
|
-
test('parseEnvLine: parses double-quoted value and strips trailing comments', () => {
|
|
44
|
-
const line = 'export FIGMA_ACCESS_TOKEN="figd_abc123" # personal token';
|
|
45
|
-
const res = parseEnvLine(line);
|
|
46
|
-
assert.deepStrictEqual(res, { key: 'FIGMA_ACCESS_TOKEN', val: 'figd_abc123' });
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test('parseEnvLine: parses single-quoted value and strips trailing comments', () => {
|
|
50
|
-
const line = "FIGMA_ACCESS_TOKEN='figd_xyz789' # comment with quotes";
|
|
51
|
-
const res = parseEnvLine(line);
|
|
52
|
-
assert.deepStrictEqual(res, { key: 'FIGMA_ACCESS_TOKEN', val: 'figd_xyz789' });
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test('parseEnvLine: parses unquoted value and strips trailing comments', () => {
|
|
56
|
-
const line = 'FIGMA_ACCESS_TOKEN=figd_plain_token # dev token';
|
|
57
|
-
const res = parseEnvLine(line);
|
|
58
|
-
assert.deepStrictEqual(res, { key: 'FIGMA_ACCESS_TOKEN', val: 'figd_plain_token' });
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test('parseEnvLine: parses value containing equal sign', () => {
|
|
62
|
-
const line = 'FIGMA_OAUTH="abc=123=xyz"';
|
|
63
|
-
const res = parseEnvLine(line);
|
|
64
|
-
assert.deepStrictEqual(res, { key: 'FIGMA_OAUTH', val: 'abc=123=xyz' });
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
test('parseEnvLine: returns null for comments, empty lines, and lines without equal sign', () => {
|
|
68
|
-
assert.strictEqual(parseEnvLine('# FIGMA_TOKEN=123'), null);
|
|
69
|
-
assert.strictEqual(parseEnvLine(''), null);
|
|
70
|
-
assert.strictEqual(parseEnvLine('export FIGMA_TOKEN'), null);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
// 1. URL Parsing Tests
|
|
74
|
-
test('parseFigmaUrl: extracts fileKey and nodeId with hyphens', () => {
|
|
75
|
-
const url = 'https://www.figma.com/design/1KP9pVQ1ptZzHa3Gcpr6ta/Consulta-e-pagamento?node-id=4023-474&t=abcd';
|
|
76
|
-
const res = parseFigmaUrl(url);
|
|
77
|
-
assert.strictEqual(res.fileKey, '1KP9pVQ1ptZzHa3Gcpr6ta');
|
|
78
|
-
assert.strictEqual(res.nodeId, '4023:474');
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test('parseFigmaUrl: handles /file/ URL with encoded colon', () => {
|
|
82
|
-
const url = 'https://www.figma.com/file/ABCD1234efgh5678ijkl/Design-System?node-id=0%3A1';
|
|
83
|
-
const res = parseFigmaUrl(url);
|
|
84
|
-
assert.strictEqual(res.fileKey, 'ABCD1234efgh5678ijkl');
|
|
85
|
-
assert.strictEqual(res.nodeId, '0:1');
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test('parseFigmaUrl: handles /board/ FigJam URL', () => {
|
|
89
|
-
const url = 'https://www.figma.com/board/FigJamKey1234567890/Sticky-Notes?node-id=10-20';
|
|
90
|
-
const res = parseFigmaUrl(url);
|
|
91
|
-
assert.strictEqual(res.fileKey, 'FigJamKey1234567890');
|
|
92
|
-
assert.strictEqual(res.nodeId, '10:20');
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
test('parseFigmaUrl: handles URL without node-id', () => {
|
|
96
|
-
const url = 'https://www.figma.com/design/1KP9pVQ1ptZzHa3Gcpr6ta/App-Design';
|
|
97
|
-
const res = parseFigmaUrl(url);
|
|
98
|
-
assert.strictEqual(res.fileKey, '1KP9pVQ1ptZzHa3Gcpr6ta');
|
|
99
|
-
assert.strictEqual(res.nodeId, null);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
// 2. Node ID Normalization Tests
|
|
103
|
-
test('normalizeNodeId: preserves colons and converts hyphens', () => {
|
|
104
|
-
assert.strictEqual(normalizeNodeId('4023-474'), '4023:474');
|
|
105
|
-
assert.strictEqual(normalizeNodeId('0:1'), '0:1');
|
|
106
|
-
assert.strictEqual(normalizeNodeId('4023:474'), '4023:474');
|
|
107
|
-
assert.strictEqual(normalizeNodeId('0-0'), '0:0');
|
|
108
|
-
assert.strictEqual(normalizeNodeId('0%3A1'), '0:1');
|
|
109
|
-
assert.strictEqual(normalizeNodeId(':123:456:'), '123:456');
|
|
110
|
-
assert.strictEqual(normalizeNodeId(null), null);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
// 3. Auth Headers Tests
|
|
114
|
-
test('getAuthHeaders: legacy PAT uses X-Figma-Token', () => {
|
|
115
|
-
const headers = getAuthHeaders('figma_pat_123456789');
|
|
116
|
-
assert.deepStrictEqual(headers, { 'X-Figma-Token': 'figma_pat_123456789' });
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
test('getAuthHeaders: figd_ PAT token uses X-Figma-Token', () => {
|
|
120
|
-
const headers = getAuthHeaders('figd_abcdef123456');
|
|
121
|
-
assert.deepStrictEqual(headers, { 'X-Figma-Token': 'figd_abcdef123456' });
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
test('getAuthHeaders: Bearer prefix token passes through', () => {
|
|
125
|
-
const headers = getAuthHeaders('Bearer custom_oauth_token');
|
|
126
|
-
assert.deepStrictEqual(headers, { Authorization: 'Bearer custom_oauth_token' });
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
// 4. Color formatting Tests
|
|
130
|
-
test('formatRgba: converts solid colors to uppercase HEX', () => {
|
|
131
|
-
assert.strictEqual(formatRgba({ r: 1, g: 0, b: 0, a: 1 }), '#FF0000');
|
|
132
|
-
assert.strictEqual(formatRgba({ r: 0, g: 1, b: 0, a: 1 }), '#00FF00');
|
|
133
|
-
assert.strictEqual(formatRgba({ r: 0, g: 0, b: 1, a: 1 }), '#0000FF');
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
test('formatRgba: converts semi-transparent colors to rgba string', () => {
|
|
137
|
-
const rgba = formatRgba({ r: 0, g: 0.5, b: 1, a: 0.8 });
|
|
138
|
-
assert.strictEqual(rgba, 'rgba(0, 128, 255, 0.8)');
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
test('formatRgba: combines color.a and fill opacity correctly', () => {
|
|
142
|
-
const rgba = formatRgba({ r: 1, g: 0, b: 0, a: 1 }, 0.5);
|
|
143
|
-
assert.strictEqual(rgba, 'rgba(255, 0, 0, 0.5)');
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
test('formatRgba: clamps RGB values below 0 and above 1', () => {
|
|
147
|
-
assert.strictEqual(formatRgba({ r: -0.1, g: 1.2, b: 0.5, a: 1 }), '#00FF80');
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
test('formatRgba: clamps alpha values above 1', () => {
|
|
151
|
-
assert.strictEqual(formatRgba({ r: 1, g: 0, b: 0, a: 1.5 }, 1.2), '#FF0000');
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
// 5. Component Props Normalization Tests
|
|
155
|
-
test('normalizePropKey: strips hash and converts to camelCase', () => {
|
|
156
|
-
assert.strictEqual(normalizePropKey('Variant#1234:56'), 'variant');
|
|
157
|
-
assert.strictEqual(normalizePropKey('Has Icon#402:1'), 'hasIcon');
|
|
158
|
-
assert.strictEqual(normalizePropKey('Button Size#999'), 'buttonSize');
|
|
159
|
-
assert.strictEqual(normalizePropKey('state'), 'state');
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
test('normalizePropKey: handles Unicode letters and Portuguese/Spanish accents', () => {
|
|
163
|
-
assert.strictEqual(normalizePropKey('Opção Selecionada'), 'opçãoSelecionada');
|
|
164
|
-
assert.strictEqual(normalizePropKey('Ícone Ativo#123'), 'íconeAtivo');
|
|
165
|
-
assert.strictEqual(normalizePropKey('Configuração Geral'), 'configuraçãoGeral');
|
|
166
|
-
assert.strictEqual(normalizePropKey('Has Icon?'), 'hasIcon');
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
test('normalizeComponentProps: cleans values and keys', () => {
|
|
170
|
-
const rawProps = {
|
|
171
|
-
'Variant#123': { type: 'VARIANT', value: 'Primary' },
|
|
172
|
-
'Has Icon#456': { type: 'BOOLEAN', value: true },
|
|
173
|
-
'Label#789': { type: 'TEXT', value: 'Confirm Order' },
|
|
174
|
-
};
|
|
175
|
-
const cleaned = normalizeComponentProps(rawProps);
|
|
176
|
-
assert.deepStrictEqual(cleaned, {
|
|
177
|
-
variant: 'Primary',
|
|
178
|
-
hasIcon: true,
|
|
179
|
-
label: 'Confirm Order',
|
|
180
|
-
});
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
// 6. Figma Variables / Token Mapping Tests
|
|
184
|
-
test('buildVariableMap: formats tokens dictionary and lookup map', () => {
|
|
185
|
-
const mockMeta = {
|
|
186
|
-
variables: {
|
|
187
|
-
'VariableID:100': {
|
|
188
|
-
id: 'VariableID:100',
|
|
189
|
-
name: 'colors/brand/primary',
|
|
190
|
-
resolvedType: 'COLOR',
|
|
191
|
-
variableSetId: 'Set:1',
|
|
192
|
-
valuesByMode: { 'Mode:1': { r: 0.1, g: 0.5, b: 0.9, a: 1 } },
|
|
193
|
-
},
|
|
194
|
-
'VariableID:200': {
|
|
195
|
-
id: 'VariableID:200',
|
|
196
|
-
name: 'spacing/md',
|
|
197
|
-
resolvedType: 'FLOAT',
|
|
198
|
-
variableSetId: 'Set:1',
|
|
199
|
-
valuesByMode: { 'Mode:1': 16 },
|
|
200
|
-
},
|
|
201
|
-
},
|
|
202
|
-
variableSets: {
|
|
203
|
-
'Set:1': { id: 'Set:1', name: 'Default', defaultModeId: 'Mode:1' },
|
|
204
|
-
},
|
|
205
|
-
};
|
|
206
|
-
|
|
207
|
-
const { varMap, dictionary } = buildVariableMap(mockMeta);
|
|
208
|
-
assert.strictEqual(varMap['VariableID:100'].name, 'colors/brand/primary');
|
|
209
|
-
assert.strictEqual(dictionary.colors['colors/brand/primary'], '#1A80E6');
|
|
210
|
-
assert.strictEqual(dictionary.spacing['spacing/md'], 16);
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
// 7. AST Pruning & Token Attachment Tests
|
|
214
|
-
test('pruneFigmaNode: resolves variables and component instance', () => {
|
|
215
|
-
const instanceNode = {
|
|
216
|
-
id: '4023:474',
|
|
217
|
-
name: 'Primary Button Instance',
|
|
218
|
-
type: 'INSTANCE',
|
|
219
|
-
componentId: 'Comp:10',
|
|
220
|
-
componentProperties: {
|
|
221
|
-
'Variant#1': { type: 'VARIANT', value: 'Primary' },
|
|
222
|
-
'Size#2': { type: 'VARIANT', value: 'Large' },
|
|
223
|
-
},
|
|
224
|
-
layoutMode: 'HORIZONTAL',
|
|
225
|
-
itemSpacing: 12,
|
|
226
|
-
paddingLeft: 16,
|
|
227
|
-
paddingRight: 16,
|
|
228
|
-
paddingTop: 12,
|
|
229
|
-
paddingBottom: 12,
|
|
230
|
-
cornerRadius: 8,
|
|
231
|
-
fills: [
|
|
232
|
-
{
|
|
233
|
-
type: 'SOLID',
|
|
234
|
-
color: { r: 0.1, g: 0.5, b: 0.9, a: 1 },
|
|
235
|
-
},
|
|
236
|
-
],
|
|
237
|
-
boundVariables: {
|
|
238
|
-
fills: [{ type: 'VARIABLE_ALIAS', id: 'VariableID:100' }],
|
|
239
|
-
itemSpacing: { type: 'VARIABLE_ALIAS', id: 'VariableID:200' },
|
|
240
|
-
cornerRadius: { type: 'VARIABLE_ALIAS', id: 'VariableID:300' },
|
|
241
|
-
},
|
|
242
|
-
absoluteBoundingBox: { width: 140, height: 44, x: 10, y: 20 },
|
|
243
|
-
// Bloated fields:
|
|
244
|
-
fillGeometry: [{ path: 'M0 0' }],
|
|
245
|
-
vectorData: {},
|
|
246
|
-
};
|
|
247
|
-
|
|
248
|
-
const context = {
|
|
249
|
-
varMap: {
|
|
250
|
-
'VariableID:100': { name: 'colors/brand/primary', resolvedType: 'COLOR' },
|
|
251
|
-
'VariableID:200': { name: 'spacing/sm', resolvedType: 'FLOAT' },
|
|
252
|
-
'VariableID:300': { name: 'radii/md', resolvedType: 'FLOAT' },
|
|
253
|
-
},
|
|
254
|
-
components: {
|
|
255
|
-
'Comp:10': { id: 'Comp:10', name: 'Variant=Primary, Size=Large', componentSetId: 'Set:Button' },
|
|
256
|
-
},
|
|
257
|
-
componentSets: {
|
|
258
|
-
'Set:Button': { id: 'Set:Button', name: 'Button' },
|
|
259
|
-
},
|
|
260
|
-
};
|
|
261
|
-
|
|
262
|
-
const pruned = pruneFigmaNode(instanceNode, context);
|
|
263
|
-
|
|
264
|
-
// Component & props resolution
|
|
265
|
-
assert.strictEqual(pruned.component.name, 'Button');
|
|
266
|
-
assert.deepStrictEqual(pruned.props, { variant: 'Primary', size: 'Large' });
|
|
267
|
-
|
|
268
|
-
// Token resolution
|
|
269
|
-
assert.strictEqual(pruned.fills[0].color, '#1A80E6');
|
|
270
|
-
assert.strictEqual(pruned.fills[0].token, 'colors/brand/primary');
|
|
271
|
-
assert.strictEqual(pruned.itemSpacingToken, 'spacing/sm');
|
|
272
|
-
assert.strictEqual(pruned.cornerRadiusToken, 'radii/md');
|
|
273
|
-
|
|
274
|
-
// Bloated fields stripped
|
|
275
|
-
assert.strictEqual(pruned.fillGeometry, undefined);
|
|
276
|
-
assert.strictEqual(pruned.vectorData, undefined);
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
test('pruneFigmaNode: --shallow-instances collapses children of instances', () => {
|
|
280
|
-
const instanceWithChildren = {
|
|
281
|
-
id: '4023:474',
|
|
282
|
-
name: 'Button',
|
|
283
|
-
type: 'INSTANCE',
|
|
284
|
-
componentProperties: { 'Label#1': { value: 'Click' } },
|
|
285
|
-
absoluteBoundingBox: { width: 120, height: 40 },
|
|
286
|
-
children: [
|
|
287
|
-
{ id: '1', name: 'Icon Frame', type: 'FRAME', children: [] },
|
|
288
|
-
{ id: '2', name: 'Label', type: 'TEXT', characters: 'Click' },
|
|
289
|
-
],
|
|
290
|
-
};
|
|
291
|
-
|
|
292
|
-
const shallow = pruneFigmaNode(instanceWithChildren, {}, { shallowInstances: true });
|
|
293
|
-
assert.strictEqual(shallow.type, 'INSTANCE');
|
|
294
|
-
assert.deepStrictEqual(shallow.props, { label: 'Click' });
|
|
295
|
-
assert.strictEqual(shallow.children, undefined);
|
|
296
|
-
assert.strictEqual(shallow.bounds.width, 120);
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
// 8. Argument Parsing Tests
|
|
300
|
-
test('parseArguments: --tokens and --shallow-instances flags', () => {
|
|
301
|
-
const argv = [
|
|
302
|
-
'node',
|
|
303
|
-
'fetch_figma.js',
|
|
304
|
-
'https://www.figma.com/design/xyz123/Test?node-id=1-2',
|
|
305
|
-
'--tokens',
|
|
306
|
-
'--shallow-instances',
|
|
307
|
-
];
|
|
308
|
-
const res = parseArguments(argv);
|
|
309
|
-
assert.strictEqual(res.fileKey, 'xyz123');
|
|
310
|
-
assert.strictEqual(res.tokensOnly, true);
|
|
311
|
-
assert.strictEqual(res.shallowInstances, true);
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
test('parseArguments: --pretty, --include-tokens, and --include-ids flags', () => {
|
|
315
|
-
const argv = [
|
|
316
|
-
'node',
|
|
317
|
-
'fetch_figma.js',
|
|
318
|
-
'https://www.figma.com/design/xyz123/Test?node-id=1-2',
|
|
319
|
-
'--pretty',
|
|
320
|
-
'--include-tokens',
|
|
321
|
-
'--include-ids',
|
|
322
|
-
];
|
|
323
|
-
const res = parseArguments(argv);
|
|
324
|
-
assert.strictEqual(res.pretty, true);
|
|
325
|
-
assert.strictEqual(res.includeTokens, true);
|
|
326
|
-
assert.strictEqual(res.includeIds, true);
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
test('parseArguments: -i, --image, and -o flags', () => {
|
|
330
|
-
const argv1 = ['node', 'fetch_figma.js', 'https://www.figma.com/design/xyz123/Test?node-id=1-2', '-i'];
|
|
331
|
-
assert.strictEqual(parseArguments(argv1).downloadImage, true);
|
|
332
|
-
|
|
333
|
-
const argv2 = ['node', 'fetch_figma.js', 'xyz123', '1:2', '-o', './custom.png'];
|
|
334
|
-
const res2 = parseArguments(argv2);
|
|
335
|
-
assert.strictEqual(res2.fileKey, 'xyz123');
|
|
336
|
-
assert.strictEqual(res2.nodeId, '1:2');
|
|
337
|
-
assert.strictEqual(res2.downloadImage, true);
|
|
338
|
-
assert.strictEqual(res2.imagePath, './custom.png');
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
test('parseArguments: --spec sets atomic specJsonPath and companion imagePath', () => {
|
|
342
|
-
const argv = ['node', 'fetch_figma.js', 'https://www.figma.com/design/xyz123/Test?node-id=1-2', '--spec', 'card'];
|
|
343
|
-
const res = parseArguments(argv);
|
|
344
|
-
assert.strictEqual(res.specName, 'card');
|
|
345
|
-
assert.strictEqual(res.specJsonPath, '.specs/card.json');
|
|
346
|
-
assert.strictEqual(res.imagePath, '.specs/card.png');
|
|
347
|
-
assert.strictEqual(res.downloadImage, false, 'Default --spec must NOT download image to conserve Tier 1 quota');
|
|
348
|
-
|
|
349
|
-
const argvWithImage = ['node', 'fetch_figma.js', 'https://www.figma.com/design/xyz123/Test?node-id=1-2', '--spec', 'card', '-i'];
|
|
350
|
-
const resWithImage = parseArguments(argvWithImage);
|
|
351
|
-
assert.strictEqual(resWithImage.specName, 'card');
|
|
352
|
-
assert.strictEqual(resWithImage.downloadImage, true, '--spec with -i must enable image download');
|
|
353
|
-
|
|
354
|
-
const argvCustom = ['node', 'fetch_figma.js', 'xyz123', '1:2', '--spec', 'custom/dir/navbar.json'];
|
|
355
|
-
const resCustom = parseArguments(argvCustom);
|
|
356
|
-
assert.strictEqual(resCustom.specJsonPath, 'custom/dir/navbar.json');
|
|
357
|
-
assert.strictEqual(resCustom.imagePath, 'custom/dir/navbar.png');
|
|
358
|
-
assert.strictEqual(resCustom.downloadImage, false);
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
test('parseArguments: supports base URL with positional nodeId fallback', () => {
|
|
362
|
-
const argv = [
|
|
363
|
-
'node',
|
|
364
|
-
'fetch_figma.js',
|
|
365
|
-
'https://www.figma.com/design/1KP9pVQ1ptZzHa3Gcpr6ta/Consulta-e-pagamento',
|
|
366
|
-
'4023-474',
|
|
367
|
-
];
|
|
368
|
-
const res = parseArguments(argv);
|
|
369
|
-
assert.strictEqual(res.fileKey, '1KP9pVQ1ptZzHa3Gcpr6ta');
|
|
370
|
-
assert.strictEqual(res.nodeId, '4023:474');
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
test('parseArguments: URL with node-id preserves its node-id even if extra positional provided', () => {
|
|
374
|
-
const argv = [
|
|
375
|
-
'node',
|
|
376
|
-
'fetch_figma.js',
|
|
377
|
-
'https://www.figma.com/design/1KP9pVQ1ptZzHa3Gcpr6ta/Consulta-e-pagamento?node-id=10-20',
|
|
378
|
-
'99-99',
|
|
379
|
-
];
|
|
380
|
-
const res = parseArguments(argv);
|
|
381
|
-
assert.strictEqual(res.fileKey, '1KP9pVQ1ptZzHa3Gcpr6ta');
|
|
382
|
-
assert.strictEqual(res.nodeId, '10:20');
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
// 9. ID Stripping & Optimization Tests
|
|
386
|
-
test('pruneFigmaNode: omits node id by default and keeps it with includeIds', () => {
|
|
387
|
-
const node = { id: '10:20', name: 'Header', type: 'FRAME' };
|
|
388
|
-
const compact = pruneFigmaNode(node);
|
|
389
|
-
assert.strictEqual(compact.id, undefined);
|
|
390
|
-
|
|
391
|
-
const withId = pruneFigmaNode(node, {}, { includeIds: true });
|
|
392
|
-
assert.strictEqual(withId.id, '10:20');
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
// 10. API Error Formatting & 429 Rate Limit Tests
|
|
396
|
-
test('formatApiError: formats 429 error with exact retryAfter seconds and policy', () => {
|
|
397
|
-
const err = new Error('Too Many Requests');
|
|
398
|
-
err.statusCode = 429;
|
|
399
|
-
err.retryAfter = 45;
|
|
400
|
-
err.rateLimitType = 'tier_1';
|
|
401
|
-
|
|
402
|
-
const res = formatApiError(err);
|
|
403
|
-
assert.strictEqual(res.statusCode, 429);
|
|
404
|
-
assert.strictEqual(res.isRateLimited, true);
|
|
405
|
-
assert.strictEqual(res.retryAfter, 45);
|
|
406
|
-
assert.strictEqual(res.rateLimitType, 'tier_1');
|
|
407
|
-
assert.ok(res.error.includes('Please retry after 45 seconds'));
|
|
408
|
-
assert.ok(res.error.includes('(Policy: tier_1)'));
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
test('formatApiError: formats 429 error without retryAfter using fallback message', () => {
|
|
412
|
-
const err = new Error('Too Many Requests');
|
|
413
|
-
err.statusCode = 429;
|
|
414
|
-
|
|
415
|
-
const res = formatApiError(err);
|
|
416
|
-
assert.strictEqual(res.statusCode, 429);
|
|
417
|
-
assert.strictEqual(res.isRateLimited, true);
|
|
418
|
-
assert.strictEqual(res.retryAfter, undefined);
|
|
419
|
-
assert.ok(res.error.includes('Please wait 30-60 seconds before making more requests.'));
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
test('formatApiError: formats 401/403 token expiration error', () => {
|
|
423
|
-
const err = new Error('Unauthorized');
|
|
424
|
-
err.statusCode = 401;
|
|
425
|
-
|
|
426
|
-
const res = formatApiError(err);
|
|
427
|
-
assert.strictEqual(res.statusCode, 401);
|
|
428
|
-
assert.strictEqual(res.isTokenExpiredOrInvalid, true);
|
|
429
|
-
assert.ok(res.error.includes('FIGMA_TOKEN_EXPIRED_OR_INVALID'));
|
|
430
|
-
});
|
|
431
|
-
|
|
432
|
-
test('formatApiError: formats 429 error with large retryAfter explaining Starter plan quota', () => {
|
|
433
|
-
const err = new Error('Too Many Requests');
|
|
434
|
-
err.statusCode = 429;
|
|
435
|
-
err.retryAfter = 255590;
|
|
436
|
-
err.rateLimitType = 'high';
|
|
437
|
-
|
|
438
|
-
const res = formatApiError(err);
|
|
439
|
-
assert.strictEqual(res.statusCode, 429);
|
|
440
|
-
assert.strictEqual(res.isRateLimited, true);
|
|
441
|
-
assert.strictEqual(res.retryAfter, 255590);
|
|
442
|
-
assert.ok(res.error.includes('Please retry after 255590 seconds'));
|
|
443
|
-
assert.ok(res.error.includes('Starter plan quota'));
|
|
444
|
-
assert.ok(res.error.includes('(Policy: high)'));
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
test('fetchFigmaVariables: handles 403 gracefully and re-throws 429', async () => {
|
|
448
|
-
// Test error handling branch behavior
|
|
449
|
-
const https = require('https');
|
|
450
|
-
const originalGet = https.get;
|
|
451
|
-
|
|
452
|
-
// 1. Simulate 403 Forbidden (missing file_variables:read scope)
|
|
453
|
-
const key403 = 'testKey_scope_403';
|
|
454
|
-
resetRateGateState(key403);
|
|
455
|
-
https.get = (options, cb) => {
|
|
456
|
-
const res = {
|
|
457
|
-
statusCode: 403,
|
|
458
|
-
headers: {},
|
|
459
|
-
setEncoding: () => {},
|
|
460
|
-
on: (event, handler) => {
|
|
461
|
-
if (event === 'data') handler('{"status":403,"error":true,"message":"Invalid scope"}');
|
|
462
|
-
if (event === 'end') handler();
|
|
463
|
-
},
|
|
464
|
-
};
|
|
465
|
-
cb(res);
|
|
466
|
-
return { on: () => {}, setTimeout: () => {} };
|
|
467
|
-
};
|
|
468
|
-
|
|
469
|
-
try {
|
|
470
|
-
const res403 = await fetchFigmaVariables(key403, {});
|
|
471
|
-
assert.strictEqual(res403, null, '403 on variables should return null fallback');
|
|
472
|
-
|
|
473
|
-
// Proactively verify capability caching:
|
|
474
|
-
// With state.variablesSupported === false, a subsequent call must skip the network request
|
|
475
|
-
let getCalled = false;
|
|
476
|
-
https.get = () => {
|
|
477
|
-
getCalled = true;
|
|
478
|
-
throw new Error('Network should not have been called');
|
|
479
|
-
};
|
|
480
|
-
const cachedRes = await fetchFigmaVariables(key403, {});
|
|
481
|
-
assert.strictEqual(cachedRes, null);
|
|
482
|
-
assert.strictEqual(getCalled, false, 'Cached variablesSupported: false must skip network request');
|
|
483
|
-
} finally {
|
|
484
|
-
https.get = originalGet;
|
|
485
|
-
resetRateGateState(key403);
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
// 2. Simulate 429 Rate Limit (must re-throw)
|
|
489
|
-
const key429 = 'testKey_rate_429';
|
|
490
|
-
resetRateGateState(key429);
|
|
491
|
-
https.get = (options, cb) => {
|
|
492
|
-
const res = {
|
|
493
|
-
statusCode: 429,
|
|
494
|
-
headers: { 'retry-after': '60' },
|
|
495
|
-
setEncoding: () => {},
|
|
496
|
-
on: (event, handler) => {
|
|
497
|
-
if (event === 'data') handler('{"status":429,"err":"Rate limit"}');
|
|
498
|
-
if (event === 'end') handler();
|
|
499
|
-
},
|
|
500
|
-
};
|
|
501
|
-
cb(res);
|
|
502
|
-
return { on: () => {}, setTimeout: () => {} };
|
|
503
|
-
};
|
|
504
|
-
|
|
505
|
-
try {
|
|
506
|
-
let threw = false;
|
|
507
|
-
try {
|
|
508
|
-
await fetchFigmaVariables(key429, {});
|
|
509
|
-
} catch (e) {
|
|
510
|
-
threw = true;
|
|
511
|
-
assert.strictEqual(e.statusCode, 429);
|
|
512
|
-
}
|
|
513
|
-
assert.ok(threw, '429 on variables should be rethrown');
|
|
514
|
-
} finally {
|
|
515
|
-
https.get = originalGet;
|
|
516
|
-
resetRateGateState(key429);
|
|
517
|
-
}
|
|
518
|
-
});
|
|
519
|
-
|
|
520
|
-
// 11. Rate Limit Gate & Circuit Breaker Tests
|
|
521
|
-
test('parseArguments: --reset-circuit-breaker, --bypass-rate-gate, and --force flags', () => {
|
|
522
|
-
const res1 = parseArguments(['node', 'fetch.js', 'key123', '--reset-circuit-breaker']);
|
|
523
|
-
assert.strictEqual(res1.resetRateGate, true);
|
|
524
|
-
|
|
525
|
-
const res2 = parseArguments(['node', 'fetch.js', 'key123', '--bypass-rate-gate']);
|
|
526
|
-
assert.strictEqual(res2.bypassRateGate, true);
|
|
527
|
-
|
|
528
|
-
const res3 = parseArguments(['node', 'fetch.js', 'key123', '--force']);
|
|
529
|
-
assert.strictEqual(res3.force, true);
|
|
530
|
-
|
|
531
|
-
const res4 = parseArguments(['node', 'fetch.js', 'key123', '-f']);
|
|
532
|
-
assert.strictEqual(res4.force, true);
|
|
533
|
-
});
|
|
534
|
-
|
|
535
|
-
test('checkAndApplyRateGate & recordRateLimitLockout: enforces circuit breaker lockout', async () => {
|
|
536
|
-
const testFileKey = 'test_locked_file_123';
|
|
537
|
-
resetRateGateState(testFileKey);
|
|
538
|
-
|
|
539
|
-
// 1. Initial check on unlocked file should succeed
|
|
540
|
-
await checkAndApplyRateGate(testFileKey, { noWait: true });
|
|
541
|
-
|
|
542
|
-
// 2. Record 429 lockout of 300 seconds
|
|
543
|
-
recordRateLimitLockout(testFileKey, 300);
|
|
544
|
-
const state = loadRateGateState();
|
|
545
|
-
assert.ok(state.files[testFileKey].lockedUntil > Date.now());
|
|
546
|
-
|
|
547
|
-
// 3. Next check must be blocked by circuit breaker
|
|
548
|
-
let blocked = false;
|
|
549
|
-
try {
|
|
550
|
-
await checkAndApplyRateGate(testFileKey, { noWait: true });
|
|
551
|
-
} catch (err) {
|
|
552
|
-
blocked = true;
|
|
553
|
-
assert.strictEqual(err.isCircuitBreaker, true);
|
|
554
|
-
assert.strictEqual(err.statusCode, 429);
|
|
555
|
-
assert.ok(err.message.includes('FIGMA_CIRCUIT_BREAKER_ACTIVE'));
|
|
556
|
-
}
|
|
557
|
-
assert.ok(blocked, 'Circuit breaker should block requests to locked file');
|
|
558
|
-
|
|
559
|
-
// 4. Reset rate gate clears lock
|
|
560
|
-
resetRateGateState(testFileKey);
|
|
561
|
-
let cleared = true;
|
|
562
|
-
try {
|
|
563
|
-
await checkAndApplyRateGate(testFileKey, { noWait: true });
|
|
564
|
-
} catch (_) {
|
|
565
|
-
cleared = false;
|
|
566
|
-
}
|
|
567
|
-
assert.ok(cleared, 'Resetting rate gate should clear lockout');
|
|
568
|
-
});
|
|
569
|
-
|
|
570
|
-
test('formatApiError: formats circuit breaker error with isCircuitBreaker flag', () => {
|
|
571
|
-
const err = new Error('FIGMA_CIRCUIT_BREAKER_ACTIVE: File locked');
|
|
572
|
-
err.isCircuitBreaker = true;
|
|
573
|
-
err.statusCode = 429;
|
|
574
|
-
err.retryAfter = 120;
|
|
575
|
-
|
|
576
|
-
const res = formatApiError(err);
|
|
577
|
-
assert.strictEqual(res.isCircuitBreaker, true);
|
|
578
|
-
assert.strictEqual(res.isRateLimited, true);
|
|
579
|
-
assert.strictEqual(res.statusCode, 429);
|
|
580
|
-
assert.strictEqual(res.retryAfter, 120);
|
|
581
|
-
assert.ok(res.error.includes('FIGMA_CIRCUIT_BREAKER_ACTIVE'));
|
|
582
|
-
});
|
|
583
|
-
|
|
584
|
-
test('checkAndApplyRateGate: records request timestamps into sliding window', async () => {
|
|
585
|
-
const testKey = 'test_sliding_window_123';
|
|
586
|
-
resetRateGateState(testKey);
|
|
587
|
-
|
|
588
|
-
await checkAndApplyRateGate(testKey, { noWait: true });
|
|
589
|
-
await checkAndApplyRateGate(testKey, { noWait: true });
|
|
590
|
-
|
|
591
|
-
const state = loadRateGateState();
|
|
592
|
-
assert.strictEqual(state.files[testKey].timestamps.length, 2);
|
|
593
|
-
resetRateGateState(testKey);
|
|
594
|
-
});
|
|
595
|
-
|
|
596
|
-
console.log(`\nResults: ${passedTests}/${totalTests} tests passed.`);
|
|
597
|
-
|
|
598
|
-
if (passedTests !== totalTests) {
|
|
599
|
-
process.exit(1);
|
|
600
|
-
}
|
|
601
|
-
|