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