@1agh/maude 0.58.2 → 0.58.3
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/apps/studio/annotations-bindings.ts +83 -4
- package/apps/studio/api.ts +6 -1
- package/apps/studio/bin/_fetch-asset.mjs +169 -5
- package/apps/studio/bin/_import-asset.mjs +72 -0
- package/apps/studio/bin/_import-figma.mjs +1121 -0
- package/apps/studio/bin/_video-playwright.mjs +86 -3
- package/apps/studio/bin/import-figma.sh +38 -0
- package/apps/studio/bin/read-annotations.mjs +11 -1
- package/apps/studio/bun.lock +16 -22
- package/apps/studio/canvas-edit.ts +29 -5
- package/apps/studio/client/app.jsx +44 -1
- package/apps/studio/client/export-center.jsx +42 -4
- package/apps/studio/client/panels/CloudBar.jsx +92 -1
- package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
- package/apps/studio/client/panels/GitPanel.jsx +26 -6
- package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
- package/apps/studio/client/panels/TimelinePanel.jsx +2 -2
- package/apps/studio/client/panels/timeline-parse.js +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +7 -0
- package/apps/studio/client/styles/4-components.css +134 -0
- package/apps/studio/clip-ops.ts +93 -17
- package/apps/studio/cloud/endpoints.ts +78 -10
- package/apps/studio/cloud/renew.ts +183 -0
- package/apps/studio/context.ts +2 -1
- package/apps/studio/dist/client.bundle.js +1261 -1261
- package/apps/studio/dist/runtime/@remotion_media.js +56 -136
- package/apps/studio/dist/runtime/@remotion_player.js +18 -18
- package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
- package/apps/studio/dist/runtime/remotion.js +12 -12
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +20 -6
- package/apps/studio/exporters/_runtime.ts +19 -0
- package/apps/studio/exporters/degraded.ts +92 -0
- package/apps/studio/exporters/index.ts +5 -0
- package/apps/studio/exporters/jobs.ts +19 -0
- package/apps/studio/exporters/unsupported-media.ts +170 -0
- package/apps/studio/exporters/video-encode-lib.ts +27 -1
- package/apps/studio/exporters/video-render-lib.ts +6 -0
- package/apps/studio/exporters/video.ts +62 -1
- package/apps/studio/figma/assets.test.ts +372 -0
- package/apps/studio/figma/assets.ts +398 -0
- package/apps/studio/figma/client.test.ts +395 -0
- package/apps/studio/figma/client.ts +513 -0
- package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
- package/apps/studio/figma/comments-to-strokes.ts +173 -0
- package/apps/studio/figma/endpoints.ts +200 -0
- package/apps/studio/figma/sanitize.test.ts +256 -0
- package/apps/studio/figma/sanitize.ts +315 -0
- package/apps/studio/figma/style-map.ts +352 -0
- package/apps/studio/figma/to-artboard.test.ts +808 -0
- package/apps/studio/figma/to-artboard.ts +701 -0
- package/apps/studio/figma/to-render.test.ts +180 -0
- package/apps/studio/figma/to-render.ts +306 -0
- package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
- package/apps/studio/figma/to-strokes.test.ts +705 -0
- package/apps/studio/figma/to-strokes.ts +749 -0
- package/apps/studio/figma/to-tokens.test.ts +321 -0
- package/apps/studio/figma/to-tokens.ts +305 -0
- package/apps/studio/figma/types.ts +539 -0
- package/apps/studio/figma/url.test.ts +167 -0
- package/apps/studio/figma/url.ts +160 -0
- package/apps/studio/http.ts +129 -0
- package/apps/studio/sync/asset-push.ts +124 -0
- package/apps/studio/sync/connection-state.ts +11 -0
- package/apps/studio/sync/hub-link.ts +63 -7
- package/apps/studio/sync/hubs-config.ts +31 -3
- package/apps/studio/sync/index.ts +276 -26
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- package/apps/studio/sync/supervisor.ts +5 -1
- package/apps/studio/sync/workspace-signin.ts +7 -3
- package/apps/studio/test/annotations-bindings.test.ts +150 -12
- package/apps/studio/test/canvas-create-api.test.ts +4 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
- package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
- package/apps/studio/test/clip-addressing.test.ts +6 -1
- package/apps/studio/test/clip-ops.test.ts +5 -1
- package/apps/studio/test/cloud-endpoints.test.ts +96 -0
- package/apps/studio/test/cloud-renew.test.ts +205 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
- package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
- package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
- package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
- package/apps/studio/test/figma-provenance.test.ts +108 -0
- package/apps/studio/test/figma-routes.test.ts +294 -0
- package/apps/studio/test/git-cloud-posture.test.ts +50 -0
- package/apps/studio/test/hub-link.test.ts +11 -0
- package/apps/studio/test/import-figma.test.ts +479 -0
- package/apps/studio/test/sync-asset-push.test.ts +124 -0
- package/apps/studio/test/sync-connection-state.test.ts +13 -0
- package/apps/studio/test/sync-hubs-config.test.ts +5 -0
- package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
- package/apps/studio/test/sync-path-pull.test.ts +63 -0
- package/apps/studio/test/sync-presentation.test.ts +77 -0
- package/apps/studio/test/sync-runtime.test.ts +316 -1
- package/apps/studio/test/video-comp.test.ts +23 -1
- package/apps/studio/test/workspace-containment.test.ts +1 -0
- package/apps/studio/video-comp.tsx +70 -6
- package/apps/studio/whats-new.json +27 -0
- package/apps/studio/workspace-mode.ts +4 -0
- package/cli/commands/design.mjs +8 -0
- package/package.json +8 -8
- package/plugins/flow/.claude-plugin/config.schema.json +3 -3
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// figma/client.ts + types.ts — the properties DDR-216 relies on, with a stubbed
|
|
2
|
+
// `fetch`. Asserts: the base URL is never influenced by input, the token never
|
|
3
|
+
// reaches a thrown error or a log line, 429 backoff is bounded, the response
|
|
4
|
+
// byte cap trips on a body that does not declare its length, and the
|
|
5
|
+
// node-count / depth caps are hard refusals rather than truncations.
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
8
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
FigmaApiError,
|
|
14
|
+
fetchDocument,
|
|
15
|
+
fetchImageUrls,
|
|
16
|
+
fetchLocalVariables,
|
|
17
|
+
fetchStyles,
|
|
18
|
+
MAX_IMAGE_BATCH,
|
|
19
|
+
retryAfterMs,
|
|
20
|
+
} from './client.ts';
|
|
21
|
+
import {
|
|
22
|
+
FigmaCapError,
|
|
23
|
+
MAX_NODE_COUNT,
|
|
24
|
+
MAX_RESPONSE_BYTES,
|
|
25
|
+
MAX_TREE_DEPTH,
|
|
26
|
+
normalizeDocument,
|
|
27
|
+
walkNodes,
|
|
28
|
+
} from './types.ts';
|
|
29
|
+
|
|
30
|
+
const TOKEN = 'figd_SUPERSECRETCANARY';
|
|
31
|
+
const KEY = 'dGNzRC2kmrmGnOxaBa0RI7';
|
|
32
|
+
|
|
33
|
+
let keysDir: string;
|
|
34
|
+
let realFetch: typeof fetch;
|
|
35
|
+
const calls: Array<{ url: string; headers: Record<string, string> }> = [];
|
|
36
|
+
|
|
37
|
+
/** Point `keys.ts` at a throwaway 0600 store instead of the user's real one. */
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
keysDir = mkdtempSync(join(tmpdir(), 'maude-figma-keys-'));
|
|
40
|
+
const keysPath = join(keysDir, 'keys.json');
|
|
41
|
+
// 0600 like the real store — otherwise `keys.ts` correctly warns on every read.
|
|
42
|
+
writeFileSync(keysPath, JSON.stringify({ keys: { figma: TOKEN } }), { mode: 0o600 });
|
|
43
|
+
process.env.MAUDE_GEN_KEYS_PATH = keysPath;
|
|
44
|
+
delete process.env.MAUDE_GEN_KEY_ENDPOINT;
|
|
45
|
+
delete process.env.MAUDE_GEN_KEY_KEY;
|
|
46
|
+
calls.length = 0;
|
|
47
|
+
realFetch = globalThis.fetch;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
globalThis.fetch = realFetch;
|
|
52
|
+
rmSync(keysDir, { recursive: true, force: true });
|
|
53
|
+
delete process.env.MAUDE_GEN_KEYS_PATH;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
function stubFetch(handler: (url: string) => Response | Promise<Response>) {
|
|
57
|
+
globalThis.fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
58
|
+
const url = String(input);
|
|
59
|
+
calls.push({ url, headers: (init?.headers ?? {}) as Record<string, string> });
|
|
60
|
+
return handler(url);
|
|
61
|
+
}) as unknown as typeof fetch;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function jsonResponse(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
|
65
|
+
return new Response(JSON.stringify(body), {
|
|
66
|
+
status,
|
|
67
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const MINIMAL_DOC = {
|
|
72
|
+
document: { id: '0:0', name: 'Document', type: 'DOCUMENT', children: [] },
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
describe('the base URL is never influenced by input (SSRF chokepoint 1)', () => {
|
|
76
|
+
test('every request goes to api.figma.com regardless of the key given', async () => {
|
|
77
|
+
stubFetch(() => jsonResponse(MINIMAL_DOC));
|
|
78
|
+
await fetchDocument({ fileKey: KEY, surface: 'design' });
|
|
79
|
+
await fetchStyles(KEY);
|
|
80
|
+
await fetchImageUrls(KEY, ['1:2']);
|
|
81
|
+
expect(calls.length).toBe(3);
|
|
82
|
+
for (const call of calls) {
|
|
83
|
+
expect(call.url.startsWith('https://api.figma.com/v1/')).toBe(true);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('a key-shaped string carrying a path is encoded, never spliced into the path', async () => {
|
|
88
|
+
stubFetch(() => jsonResponse(MINIMAL_DOC));
|
|
89
|
+
// `url.ts` would have rejected this long before here — belt-and-braces that
|
|
90
|
+
// the client encodes rather than trusting an upstream validation.
|
|
91
|
+
await fetchStyles('../../evil');
|
|
92
|
+
const target = new URL(calls[0].url);
|
|
93
|
+
expect(target.host).toBe('api.figma.com');
|
|
94
|
+
expect(target.pathname).toBe('/v1/files/..%2F..%2Fevil/styles');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('node ids are sent as a query value, not as a path segment', async () => {
|
|
98
|
+
stubFetch(() => jsonResponse({ nodes: { '2:17': { document: MINIMAL_DOC.document } } }));
|
|
99
|
+
await fetchDocument({ fileKey: KEY, surface: 'design', nodeId: '2:17' });
|
|
100
|
+
const target = new URL(calls[0].url);
|
|
101
|
+
expect(target.pathname).toBe(`/v1/files/${KEY}/nodes`);
|
|
102
|
+
expect(target.searchParams.get('ids')).toBe('2:17');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('a node-id present means /nodes, not the whole file', async () => {
|
|
106
|
+
stubFetch(() => jsonResponse({ nodes: { '2:17': { document: MINIMAL_DOC.document } } }));
|
|
107
|
+
await fetchDocument({ fileKey: KEY, surface: 'design', nodeId: '2:17' });
|
|
108
|
+
expect(calls[0].url).toContain('/nodes');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('the token is sent, and never comes back out', () => {
|
|
113
|
+
test('the PAT rides the X-Figma-Token header', async () => {
|
|
114
|
+
stubFetch(() => jsonResponse(MINIMAL_DOC));
|
|
115
|
+
await fetchDocument({ fileKey: KEY, surface: 'design' });
|
|
116
|
+
expect(calls[0].headers['X-Figma-Token']).toBe(TOKEN);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('no thrown error carries the token, for ANY failure status', async () => {
|
|
120
|
+
for (const status of [400, 401, 403, 404, 418, 500, 502]) {
|
|
121
|
+
stubFetch(() => jsonResponse({ err: `boom ${TOKEN}` }, status));
|
|
122
|
+
let caught: unknown;
|
|
123
|
+
try {
|
|
124
|
+
await fetchDocument({ fileKey: KEY, surface: 'design' });
|
|
125
|
+
} catch (err) {
|
|
126
|
+
caught = err;
|
|
127
|
+
}
|
|
128
|
+
expect(caught).toBeInstanceOf(FigmaApiError);
|
|
129
|
+
const serialized = `${(caught as Error).message} ${(caught as Error).stack ?? ''} ${JSON.stringify(caught)}`;
|
|
130
|
+
expect(serialized).not.toContain(TOKEN);
|
|
131
|
+
expect(serialized).not.toContain('SUPERSECRETCANARY');
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('an upstream error body is never echoed into the message (D10)', async () => {
|
|
136
|
+
stubFetch(() => jsonResponse({ err: 'UPSTREAM_CANARY_STRING' }, 500));
|
|
137
|
+
try {
|
|
138
|
+
await fetchDocument({ fileKey: KEY, surface: 'design' });
|
|
139
|
+
throw new Error('expected a rejection');
|
|
140
|
+
} catch (err) {
|
|
141
|
+
expect((err as Error).message).not.toContain('UPSTREAM_CANARY');
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('a network-layer failure does not leak the URL or the cause', async () => {
|
|
146
|
+
globalThis.fetch = mock(async () => {
|
|
147
|
+
throw new Error(`connect ECONNREFUSED ${TOKEN} https://api.figma.com/v1/files`);
|
|
148
|
+
}) as unknown as typeof fetch;
|
|
149
|
+
try {
|
|
150
|
+
await fetchDocument({ fileKey: KEY, surface: 'design' });
|
|
151
|
+
throw new Error('expected a rejection');
|
|
152
|
+
} catch (err) {
|
|
153
|
+
expect(err).toBeInstanceOf(FigmaApiError);
|
|
154
|
+
expect((err as FigmaApiError).kind).toBe('network');
|
|
155
|
+
expect((err as Error).message).not.toContain(TOKEN);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('no key configured is a typed refusal, not an unauthenticated call', async () => {
|
|
160
|
+
writeFileSync(join(keysDir, 'keys.json'), JSON.stringify({ keys: {} }), { mode: 0o600 });
|
|
161
|
+
stubFetch(() => jsonResponse(MINIMAL_DOC));
|
|
162
|
+
try {
|
|
163
|
+
await fetchDocument({ fileKey: KEY, surface: 'design' });
|
|
164
|
+
throw new Error('expected a rejection');
|
|
165
|
+
} catch (err) {
|
|
166
|
+
expect((err as FigmaApiError).kind).toBe('not_configured');
|
|
167
|
+
}
|
|
168
|
+
expect(calls.length).toBe(0);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('429 backoff is bounded', () => {
|
|
173
|
+
test('retries, then gives up with a rate_limited error', async () => {
|
|
174
|
+
stubFetch(() => jsonResponse({}, 429, { 'Retry-After': '0' }));
|
|
175
|
+
const started = Date.now();
|
|
176
|
+
try {
|
|
177
|
+
await fetchDocument({ fileKey: KEY, surface: 'design' });
|
|
178
|
+
throw new Error('expected a rejection');
|
|
179
|
+
} catch (err) {
|
|
180
|
+
expect((err as FigmaApiError).kind).toBe('rate_limited');
|
|
181
|
+
}
|
|
182
|
+
// 3 retries + the original = 4 calls, and it does not sleep for minutes.
|
|
183
|
+
expect(calls.length).toBe(4);
|
|
184
|
+
expect(Date.now() - started).toBeLessThan(15_000);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Asserted arithmetically rather than by waiting: an upstream header is
|
|
188
|
+
// untrusted input for SCHEDULING too, and the property under test is the
|
|
189
|
+
// clamp itself, not how long a test process is willing to sit still.
|
|
190
|
+
test.each([
|
|
191
|
+
['86400', 10_000, 'an absurd delay is clamped'],
|
|
192
|
+
['3600', 10_000, 'an hour is clamped'],
|
|
193
|
+
['5', 5_000, 'a sane delay is honoured'],
|
|
194
|
+
['0', 0, 'zero means retry now'],
|
|
195
|
+
['-1', 1_000, 'a negative falls back to the default'],
|
|
196
|
+
['not-a-number', 1_000, 'garbage falls back to the default'],
|
|
197
|
+
[null, 1_000, 'an absent header falls back to the default'],
|
|
198
|
+
])('Retry-After %p → %ims (%s)', (header, expected) => {
|
|
199
|
+
expect(retryAfterMs(header as string | null)).toBe(expected as number);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('recovers when the retry succeeds', async () => {
|
|
203
|
+
let seen = 0;
|
|
204
|
+
stubFetch(() => {
|
|
205
|
+
seen += 1;
|
|
206
|
+
return seen === 1 ? jsonResponse({}, 429, { 'Retry-After': '0' }) : jsonResponse(MINIMAL_DOC);
|
|
207
|
+
});
|
|
208
|
+
const doc = await fetchDocument({ fileKey: KEY, surface: 'design' });
|
|
209
|
+
expect(doc.root.id).toBe('0:0');
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe('the response byte cap is a refusal', () => {
|
|
214
|
+
test('trips on a declared Content-Length over the cap', async () => {
|
|
215
|
+
stubFetch(() =>
|
|
216
|
+
jsonResponse(MINIMAL_DOC, 200, { 'Content-Length': String(MAX_RESPONSE_BYTES + 1) })
|
|
217
|
+
);
|
|
218
|
+
try {
|
|
219
|
+
await fetchDocument({ fileKey: KEY, surface: 'design' });
|
|
220
|
+
throw new Error('expected a rejection');
|
|
221
|
+
} catch (err) {
|
|
222
|
+
expect((err as FigmaApiError).kind).toBe('too_large');
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('trips on an UNDECLARED oversized body — the header is not the control', async () => {
|
|
227
|
+
const chunk = new Uint8Array(1024 * 1024).fill(65);
|
|
228
|
+
stubFetch(
|
|
229
|
+
() =>
|
|
230
|
+
new Response(
|
|
231
|
+
new ReadableStream({
|
|
232
|
+
pull(controller) {
|
|
233
|
+
controller.enqueue(chunk); // never ends
|
|
234
|
+
},
|
|
235
|
+
}),
|
|
236
|
+
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
|
237
|
+
)
|
|
238
|
+
);
|
|
239
|
+
try {
|
|
240
|
+
await fetchDocument({ fileKey: KEY, surface: 'design' });
|
|
241
|
+
throw new Error('expected a rejection');
|
|
242
|
+
} catch (err) {
|
|
243
|
+
expect((err as FigmaApiError).kind).toBe('too_large');
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe('the Variables endpoint degrades rather than failing', () => {
|
|
249
|
+
test('403 (the common Pro-plan case) reports unavailable, not an error', async () => {
|
|
250
|
+
stubFetch(() => jsonResponse({ err: 'plan' }, 403));
|
|
251
|
+
const result = await fetchLocalVariables(KEY);
|
|
252
|
+
expect(result.available).toBe(false);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('a genuine failure still throws', async () => {
|
|
256
|
+
stubFetch(() => jsonResponse({}, 500));
|
|
257
|
+
await expect(fetchLocalVariables(KEY)).rejects.toBeInstanceOf(FigmaApiError);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe('image batching', () => {
|
|
262
|
+
test('refuses a batch over the ceiling rather than splitting silently', async () => {
|
|
263
|
+
stubFetch(() => jsonResponse({ images: {} }));
|
|
264
|
+
const ids = Array.from({ length: MAX_IMAGE_BATCH + 1 }, (_, i) => `1:${i}`);
|
|
265
|
+
await expect(fetchImageUrls(KEY, ids)).rejects.toBeInstanceOf(FigmaCapError);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('an empty batch makes no request at all', async () => {
|
|
269
|
+
stubFetch(() => jsonResponse({ images: {} }));
|
|
270
|
+
const result = await fetchImageUrls(KEY, []);
|
|
271
|
+
expect(result.images).toEqual({});
|
|
272
|
+
expect(calls.length).toBe(0);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('svg format omits scale (Figma rejects it there)', async () => {
|
|
276
|
+
stubFetch(() => jsonResponse({ images: {} }));
|
|
277
|
+
await fetchImageUrls(KEY, ['1:2'], 'svg');
|
|
278
|
+
expect(new URL(calls[0].url).searchParams.get('scale')).toBeNull();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("returns URLs but never fetches one — downloading is fetch-asset's job", async () => {
|
|
282
|
+
stubFetch(() => jsonResponse({ images: { '1:2': 'https://evil.example/x.png' } }));
|
|
283
|
+
const result = await fetchImageUrls(KEY, ['1:2']);
|
|
284
|
+
expect(result.images['1:2']).toBe('https://evil.example/x.png');
|
|
285
|
+
// Exactly one call — the images endpoint. No follow-on download.
|
|
286
|
+
expect(calls.length).toBe(1);
|
|
287
|
+
expect(calls[0].url).toContain('api.figma.com');
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe('normalizeDocument — caps are refusals, and untrusted strings survive verbatim', () => {
|
|
292
|
+
function deepDoc(depth: number): unknown {
|
|
293
|
+
let node: Record<string, unknown> = { id: `0:${depth}`, name: 'leaf', type: 'FRAME' };
|
|
294
|
+
for (let i = depth - 1; i >= 0; i--) {
|
|
295
|
+
node = { id: `0:${i}`, name: 'wrap', type: 'FRAME', children: [node] };
|
|
296
|
+
}
|
|
297
|
+
return node;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
test('depth over the cap is a hard refusal', () => {
|
|
301
|
+
expect(() =>
|
|
302
|
+
normalizeDocument(deepDoc(MAX_TREE_DEPTH + 5), { fileKey: KEY, surface: 'design' })
|
|
303
|
+
).toThrow(FigmaCapError);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('depth under the cap normalizes and records maxDepth', () => {
|
|
307
|
+
const doc = normalizeDocument(deepDoc(10), { fileKey: KEY, surface: 'design' });
|
|
308
|
+
expect(doc.maxDepth).toBe(10);
|
|
309
|
+
expect(doc.nodeCount).toBe(11);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('node count over the cap is a hard refusal, not a truncation', () => {
|
|
313
|
+
const children = Array.from({ length: MAX_NODE_COUNT + 10 }, (_, i) => ({
|
|
314
|
+
id: `1:${i}`,
|
|
315
|
+
name: 'n',
|
|
316
|
+
type: 'RECTANGLE',
|
|
317
|
+
}));
|
|
318
|
+
expect(() =>
|
|
319
|
+
normalizeDocument(
|
|
320
|
+
{ id: '0:0', name: 'root', type: 'CANVAS', children },
|
|
321
|
+
{
|
|
322
|
+
fileKey: KEY,
|
|
323
|
+
surface: 'design',
|
|
324
|
+
}
|
|
325
|
+
)
|
|
326
|
+
).toThrow(FigmaCapError);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("hostile layer names and text pass through UNCHANGED — sanitizing is the emitter's job", () => {
|
|
330
|
+
const hostile = 'Příliš žluťoučký — "test" / <b> & \'x\'';
|
|
331
|
+
const doc = normalizeDocument(
|
|
332
|
+
{
|
|
333
|
+
id: '0:0',
|
|
334
|
+
name: 'root',
|
|
335
|
+
type: 'CANVAS',
|
|
336
|
+
children: [
|
|
337
|
+
{ id: '2:17', name: hostile, type: 'TEXT', characters: '<script>alert(1)</script>' },
|
|
338
|
+
],
|
|
339
|
+
},
|
|
340
|
+
{ fileKey: KEY, surface: 'board' }
|
|
341
|
+
);
|
|
342
|
+
const text = doc.root.children?.[0];
|
|
343
|
+
expect(text?.name).toBe(hostile);
|
|
344
|
+
expect(text?.characters).toBe('<script>alert(1)</script>');
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test('prototype-polluting keys in the input do not reach Object.prototype', () => {
|
|
348
|
+
normalizeDocument(
|
|
349
|
+
JSON.parse('{"id":"0:0","name":"r","type":"CANVAS","__proto__":{"polluted":"yes"}}'),
|
|
350
|
+
{ fileKey: KEY, surface: 'design' }
|
|
351
|
+
);
|
|
352
|
+
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test('a free-floating connector endpoint yields no host id', () => {
|
|
356
|
+
const doc = normalizeDocument(
|
|
357
|
+
{
|
|
358
|
+
id: '0:0',
|
|
359
|
+
name: 'r',
|
|
360
|
+
type: 'CANVAS',
|
|
361
|
+
children: [
|
|
362
|
+
{
|
|
363
|
+
id: '2:67',
|
|
364
|
+
name: 'c',
|
|
365
|
+
type: 'CONNECTOR',
|
|
366
|
+
connectorStart: { endpointNodeId: '2:17', magnet: 'AUTO' },
|
|
367
|
+
connectorEnd: { position: { x: 10, y: 20 } },
|
|
368
|
+
},
|
|
369
|
+
],
|
|
370
|
+
},
|
|
371
|
+
{ fileKey: KEY, surface: 'board' }
|
|
372
|
+
);
|
|
373
|
+
const connector = doc.root.children?.[0];
|
|
374
|
+
expect(connector?.connectorStart).toBe('2:17');
|
|
375
|
+
expect(connector?.connectorEnd).toBeUndefined();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test('an unknown node type normalizes to UNKNOWN rather than being dropped', () => {
|
|
379
|
+
const doc = normalizeDocument(
|
|
380
|
+
{ id: '0:0', name: 'r', type: 'SOME_FUTURE_THING' },
|
|
381
|
+
{ fileKey: KEY, surface: 'board' }
|
|
382
|
+
);
|
|
383
|
+
expect(doc.root.type).toBe('UNKNOWN');
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test('walkNodes visits every node without recursion', () => {
|
|
387
|
+
const doc = normalizeDocument(deepDoc(200 > MAX_TREE_DEPTH ? 20 : 20), {
|
|
388
|
+
fileKey: KEY,
|
|
389
|
+
surface: 'design',
|
|
390
|
+
});
|
|
391
|
+
const seen: string[] = [];
|
|
392
|
+
walkNodes(doc.root, (n) => seen.push(n.id));
|
|
393
|
+
expect(seen.length).toBe(doc.nodeCount);
|
|
394
|
+
});
|
|
395
|
+
});
|