@1agh/maude 0.58.1 → 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.
Files changed (119) hide show
  1. package/apps/studio/.ai/cache/_stats.json +1 -1
  2. package/apps/studio/acp/bridge.ts +165 -15
  3. package/apps/studio/annotations-bindings.ts +83 -4
  4. package/apps/studio/api.ts +6 -1
  5. package/apps/studio/bin/_fetch-asset.mjs +169 -5
  6. package/apps/studio/bin/_import-asset.mjs +72 -0
  7. package/apps/studio/bin/_import-figma.mjs +1121 -0
  8. package/apps/studio/bin/_video-playwright.mjs +86 -3
  9. package/apps/studio/bin/import-figma.sh +38 -0
  10. package/apps/studio/bin/read-annotations.mjs +11 -1
  11. package/apps/studio/bun.lock +16 -22
  12. package/apps/studio/canvas-edit.ts +29 -5
  13. package/apps/studio/client/app.jsx +129 -23
  14. package/apps/studio/client/export-center.jsx +42 -4
  15. package/apps/studio/client/panels/ChatPanel.jsx +25 -2
  16. package/apps/studio/client/panels/CloudBar.jsx +92 -1
  17. package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
  18. package/apps/studio/client/panels/GitPanel.jsx +26 -6
  19. package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
  20. package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
  21. package/apps/studio/client/panels/TimelinePanel.jsx +2 -2
  22. package/apps/studio/client/panels/timeline-parse.js +3 -3
  23. package/apps/studio/client/styles/3-shell-maude.css +7 -0
  24. package/apps/studio/client/styles/4-components.css +134 -0
  25. package/apps/studio/client/styles/6-acp-chat.css +12 -0
  26. package/apps/studio/clip-ops.ts +93 -17
  27. package/apps/studio/cloud/endpoints.ts +78 -10
  28. package/apps/studio/cloud/renew.ts +183 -0
  29. package/apps/studio/context.ts +2 -1
  30. package/apps/studio/dist/client.bundle.js +1491 -1491
  31. package/apps/studio/dist/runtime/@remotion_media.js +56 -136
  32. package/apps/studio/dist/runtime/@remotion_player.js +18 -18
  33. package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
  34. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
  35. package/apps/studio/dist/runtime/remotion.js +12 -12
  36. package/apps/studio/dist/styles.css +1 -1
  37. package/apps/studio/exporters/_browser-bundles.ts +20 -6
  38. package/apps/studio/exporters/_runtime.ts +19 -0
  39. package/apps/studio/exporters/degraded.ts +92 -0
  40. package/apps/studio/exporters/index.ts +5 -0
  41. package/apps/studio/exporters/jobs.ts +19 -0
  42. package/apps/studio/exporters/unsupported-media.ts +170 -0
  43. package/apps/studio/exporters/video-encode-lib.ts +27 -1
  44. package/apps/studio/exporters/video-render-lib.ts +6 -0
  45. package/apps/studio/exporters/video.ts +62 -1
  46. package/apps/studio/figma/assets.test.ts +372 -0
  47. package/apps/studio/figma/assets.ts +398 -0
  48. package/apps/studio/figma/client.test.ts +395 -0
  49. package/apps/studio/figma/client.ts +513 -0
  50. package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
  51. package/apps/studio/figma/comments-to-strokes.ts +173 -0
  52. package/apps/studio/figma/endpoints.ts +200 -0
  53. package/apps/studio/figma/sanitize.test.ts +256 -0
  54. package/apps/studio/figma/sanitize.ts +315 -0
  55. package/apps/studio/figma/style-map.ts +352 -0
  56. package/apps/studio/figma/to-artboard.test.ts +808 -0
  57. package/apps/studio/figma/to-artboard.ts +701 -0
  58. package/apps/studio/figma/to-render.test.ts +180 -0
  59. package/apps/studio/figma/to-render.ts +306 -0
  60. package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
  61. package/apps/studio/figma/to-strokes.test.ts +705 -0
  62. package/apps/studio/figma/to-strokes.ts +749 -0
  63. package/apps/studio/figma/to-tokens.test.ts +321 -0
  64. package/apps/studio/figma/to-tokens.ts +305 -0
  65. package/apps/studio/figma/types.ts +539 -0
  66. package/apps/studio/figma/url.test.ts +167 -0
  67. package/apps/studio/figma/url.ts +160 -0
  68. package/apps/studio/http.ts +129 -0
  69. package/apps/studio/sync/asset-push.ts +124 -0
  70. package/apps/studio/sync/canvas-path.ts +329 -0
  71. package/apps/studio/sync/codec.ts +42 -0
  72. package/apps/studio/sync/connection-state.ts +11 -0
  73. package/apps/studio/sync/hub-link.ts +63 -7
  74. package/apps/studio/sync/hubs-config.ts +31 -3
  75. package/apps/studio/sync/index.ts +755 -32
  76. package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
  77. package/apps/studio/sync/presentation.ts +45 -1
  78. package/apps/studio/sync/projection.ts +11 -1
  79. package/apps/studio/sync/remote-docs.ts +122 -15
  80. package/apps/studio/sync/supervisor.ts +5 -1
  81. package/apps/studio/sync/workspace-signin.ts +7 -3
  82. package/apps/studio/test/acp-bridge-lifetime.test.ts +106 -0
  83. package/apps/studio/test/annotations-bindings.test.ts +150 -12
  84. package/apps/studio/test/canvas-create-api.test.ts +4 -1
  85. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  86. package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
  87. package/apps/studio/test/clip-addressing.test.ts +6 -1
  88. package/apps/studio/test/clip-ops.test.ts +5 -1
  89. package/apps/studio/test/cloud-endpoints.test.ts +96 -0
  90. package/apps/studio/test/cloud-renew.test.ts +205 -0
  91. package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
  92. package/apps/studio/test/comment-relay-origin-gate.test.ts +117 -0
  93. package/apps/studio/test/comments-fs-rebroadcast.test.ts +155 -0
  94. package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
  95. package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
  96. package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
  97. package/apps/studio/test/figma-provenance.test.ts +108 -0
  98. package/apps/studio/test/figma-routes.test.ts +294 -0
  99. package/apps/studio/test/fixtures/mock-acp-agent-wedged.mjs +37 -0
  100. package/apps/studio/test/git-cloud-posture.test.ts +50 -0
  101. package/apps/studio/test/hub-link.test.ts +11 -0
  102. package/apps/studio/test/import-figma.test.ts +479 -0
  103. package/apps/studio/test/sync-asset-push.test.ts +124 -0
  104. package/apps/studio/test/sync-canvas-path.test.ts +200 -0
  105. package/apps/studio/test/sync-connection-state.test.ts +13 -0
  106. package/apps/studio/test/sync-hubs-config.test.ts +5 -0
  107. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
  108. package/apps/studio/test/sync-path-pull.test.ts +465 -0
  109. package/apps/studio/test/sync-presentation.test.ts +77 -0
  110. package/apps/studio/test/sync-remote-docs.test.ts +55 -3
  111. package/apps/studio/test/sync-runtime.test.ts +434 -1
  112. package/apps/studio/test/video-comp.test.ts +23 -1
  113. package/apps/studio/test/workspace-containment.test.ts +1 -0
  114. package/apps/studio/video-comp.tsx +70 -6
  115. package/apps/studio/whats-new.json +36 -0
  116. package/apps/studio/workspace-mode.ts +4 -0
  117. package/cli/commands/design.mjs +8 -0
  118. package/package.json +8 -8
  119. package/plugins/flow/.claude-plugin/config.schema.json +3 -3
@@ -0,0 +1,479 @@
1
+ // `maude design import-figma --board` — the write path (DDR-216 T6).
2
+ //
3
+ // The translation itself is covered by `figma/to-strokes.test.ts`. What this
4
+ // file owns is the VERB's own contract:
5
+ //
6
+ // • the output goes through the canonical serializer + `sanitizeAnnotationSvg`
7
+ // (D6's annotation row) and lands at a code-computed slug;
8
+ // • assets/artifacts stage OUTSIDE the design root and are promoted only on
9
+ // success, so a failure leaves nothing in a versioned, Syncthing-replicated
10
+ // directory (D5 — "gitignored" is NOT "not replicated");
11
+ // • the write is realpath-contained;
12
+ // • the summary is enum + node ids ONLY, never node text, because this verb
13
+ // is run BY an agent and everything it prints is model input (D10).
14
+
15
+ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
16
+ import {
17
+ existsSync,
18
+ mkdirSync,
19
+ mkdtempSync,
20
+ readdirSync,
21
+ readFileSync,
22
+ rmSync,
23
+ writeFileSync,
24
+ } from 'node:fs';
25
+ import { tmpdir } from 'node:os';
26
+ import { join } from 'node:path';
27
+
28
+ import {
29
+ formatSummary,
30
+ ImportFigmaError,
31
+ importBoard,
32
+ importFrames,
33
+ importTokens,
34
+ } from '../bin/_import-figma.mjs';
35
+
36
+ const TOKEN = 'figd_VERBCANARY_0123456789abcdef';
37
+ const BOARD_URL = 'https://www.figma.com/board/Em6NOwaOFTYV7NlQT4NK8l/Analyza';
38
+
39
+ let sandbox: string;
40
+ let keysDir: string;
41
+ let realFetch: typeof fetch;
42
+
43
+ const box = (x: number, y: number, width: number, height: number) => ({ x, y, width, height });
44
+
45
+ /** A board carrying a hostile layer name, a hidden node and an unmappable one. */
46
+ const DOC = {
47
+ document: {
48
+ id: '0:0',
49
+ name: 'Document',
50
+ type: 'CANVAS',
51
+ children: [
52
+ {
53
+ id: '1:8',
54
+ name: 'Sticky',
55
+ type: 'STICKY',
56
+ absoluteBoundingBox: box(-3244, -6272, 240, 240),
57
+ characters: 'Persona A',
58
+ },
59
+ {
60
+ id: '2:17',
61
+ name: 'Příliš žluťoučký — "test" / <b> & \'x\'',
62
+ type: 'SHAPE_WITH_TEXT',
63
+ shapeType: 'SQUARE',
64
+ absoluteBoundingBox: box(0, 0, 200, 200),
65
+ characters: 'SECRET_BODY_TEXT',
66
+ },
67
+ {
68
+ id: '2:40',
69
+ name: 'Parallelogram',
70
+ type: 'SHAPE_WITH_TEXT',
71
+ shapeType: 'PARALLELOGRAM_RIGHT',
72
+ absoluteBoundingBox: box(400, 0, 200, 200),
73
+ },
74
+ {
75
+ id: '9:1',
76
+ name: 'hidden',
77
+ type: 'STICKY',
78
+ visible: false,
79
+ characters: 'you cannot see me',
80
+ absoluteBoundingBox: box(800, 0, 240, 240),
81
+ },
82
+ ],
83
+ },
84
+ };
85
+
86
+ beforeEach(() => {
87
+ sandbox = mkdtempSync(join(tmpdir(), 'maude-figma-verb-'));
88
+ mkdirSync(join(sandbox, '.design'), { recursive: true });
89
+ keysDir = mkdtempSync(join(tmpdir(), 'maude-figma-verb-keys-'));
90
+ const keysPath = join(keysDir, 'keys.json');
91
+ writeFileSync(keysPath, JSON.stringify({ keys: { figma: TOKEN } }), { mode: 0o600 });
92
+ process.env.MAUDE_GEN_KEYS_PATH = keysPath;
93
+ realFetch = globalThis.fetch;
94
+ globalThis.fetch = mock(
95
+ async () =>
96
+ new Response(JSON.stringify(DOC), {
97
+ status: 200,
98
+ headers: { 'Content-Type': 'application/json' },
99
+ })
100
+ ) as unknown as typeof fetch;
101
+ });
102
+
103
+ afterEach(() => {
104
+ globalThis.fetch = realFetch;
105
+ rmSync(sandbox, { recursive: true, force: true });
106
+ rmSync(keysDir, { recursive: true, force: true });
107
+ delete process.env.MAUDE_GEN_KEYS_PATH;
108
+ });
109
+
110
+ describe('--board writes a sanitized annotation layer', () => {
111
+ test('lands at a code-computed slug under the design root', async () => {
112
+ const result = await importBoard({ url: BOARD_URL, root: sandbox });
113
+ // The slug is the HOST CANVAS's slug — an annotation layer named anything
114
+ // else has nothing to render it (found on the first live board import).
115
+ expect(result.slug).toMatch(/^ui-[a-z0-9_]+$/);
116
+ expect(result.path?.startsWith(join(sandbox, '.design'))).toBe(true);
117
+ expect(existsSync(result.path as string)).toBe(true);
118
+ });
119
+
120
+ test('the slug is NEVER derived from a Figma string', async () => {
121
+ const result = await importBoard({ url: BOARD_URL, root: sandbox });
122
+ expect(result.slug).not.toContain('žlu');
123
+ expect(result.slug).not.toContain('<');
124
+ expect(result.slug).toMatch(/^[a-z0-9_-]{1,64}$/);
125
+ });
126
+
127
+ test('the written SVG is sanitizer-clean — no markup from the hostile name', async () => {
128
+ const result = await importBoard({ url: BOARD_URL, root: sandbox });
129
+ const svg = readFileSync(result.path as string, 'utf8');
130
+ expect(svg.startsWith('<svg')).toBe(true);
131
+ expect(svg).not.toContain('<b>');
132
+ expect(svg).not.toContain('<script');
133
+ expect(svg).not.toMatch(/\son[a-z]+=/i);
134
+ });
135
+
136
+ test('a hidden node is not written', async () => {
137
+ const result = await importBoard({ url: BOARD_URL, root: sandbox });
138
+ const svg = readFileSync(result.path as string, 'utf8');
139
+ expect(svg).not.toContain('you cannot see me');
140
+ });
141
+
142
+ test('an explicit --slug is honoured when it is a valid slug', async () => {
143
+ const result = await importBoard({ url: BOARD_URL, root: sandbox, slug: 'retro-q3' });
144
+ // `--slug` names the CANVAS; the annotation layer follows from it.
145
+ expect(result.canvas).toBe('ui/Retro Q3.tsx');
146
+ expect(result.slug).toBe('ui-retro_q3');
147
+ expect(result.path?.endsWith('ui-retro_q3.annotations.svg')).toBe(true);
148
+ });
149
+
150
+ test.each([
151
+ ['traversal', '../../etc/passwd'],
152
+ ['absolute', '/etc/passwd'],
153
+ ['uppercase + spaces', 'My Board'],
154
+ ['too long', 'a'.repeat(65)],
155
+ ])('a hostile --slug is rejected (%s), nothing is written', async (_label, slug) => {
156
+ await expect(importBoard({ url: BOARD_URL, root: sandbox, slug })).rejects.toBeInstanceOf(
157
+ ImportFigmaError
158
+ );
159
+ expect(readdirSync(join(sandbox, '.design'))).toEqual([]);
160
+ });
161
+ });
162
+
163
+ describe('staging is outside the design root, and leaves nothing behind', () => {
164
+ test('a successful run leaves the layer + its host canvas, and no staging residue', async () => {
165
+ const result = await importBoard({ url: BOARD_URL, root: sandbox });
166
+ const entries = readdirSync(join(sandbox, '.design'));
167
+ expect(entries.sort()).toEqual(['ui', `${result.slug}.annotations.svg`]);
168
+ // The host canvas — without it the strokes are on disk and invisible.
169
+ expect(readdirSync(join(sandbox, '.design', 'ui')).sort()).toEqual([
170
+ 'Figjam Em6nowao.meta.json',
171
+ 'Figjam Em6nowao.tsx',
172
+ ]);
173
+ // No `.tmp-*` / staging directory anywhere under the design root.
174
+ expect(entries.some((e) => e.startsWith('.tmp') || e.startsWith('maude-figma-'))).toBe(false);
175
+ });
176
+
177
+ test('a dry run writes NOTHING but still reports', async () => {
178
+ const result = await importBoard({ url: BOARD_URL, root: sandbox, dryRun: true });
179
+ expect(result.path).toBeUndefined();
180
+ expect(result.strokeCount).toBeGreaterThan(0);
181
+ expect(readdirSync(join(sandbox, '.design'))).toEqual([]);
182
+ });
183
+ });
184
+
185
+ describe('the summary is model input — enum + node ids only (D10)', () => {
186
+ test('names and body text never appear in the summary', async () => {
187
+ const result = await importBoard({ url: BOARD_URL, root: sandbox, dryRun: true });
188
+ const summary = formatSummary(result.report);
189
+ expect(summary).not.toContain('žluťoučký');
190
+ expect(summary).not.toContain('SECRET_BODY_TEXT');
191
+ expect(summary).not.toContain('you cannot see me');
192
+ expect(summary).not.toContain('Persona A');
193
+ });
194
+
195
+ test('every non-clean disposition is named by node id and a fixed reason', async () => {
196
+ const result = await importBoard({ url: BOARD_URL, root: sandbox, dryRun: true });
197
+ const summary = formatSummary(result.report);
198
+ // The unmappable shape is reported, not silently dropped.
199
+ expect(summary).toContain('2:40');
200
+ expect(summary).toContain('unmappable-shape');
201
+ // The hidden node is reported too.
202
+ expect(summary).toContain('9:1');
203
+ expect(summary).toContain('hidden-node-skipped');
204
+ });
205
+
206
+ test('the origin shift is reported so a huge negative-space board is explicable', async () => {
207
+ const result = await importBoard({ url: BOARD_URL, root: sandbox, dryRun: true });
208
+ expect(result.origin).toEqual({ x: -3244, y: -6272 });
209
+ });
210
+ });
211
+
212
+ // ── --frames (Phase 3) ──────────────────────────────────────────────────────
213
+
214
+ const DESIGN_DOC = {
215
+ document: {
216
+ id: '0:1',
217
+ name: 'Page 1',
218
+ type: 'CANVAS',
219
+ children: [
220
+ {
221
+ id: '1:2',
222
+ name: 'AL Horizontal (-> flex row)',
223
+ type: 'FRAME',
224
+ layoutMode: 'HORIZONTAL',
225
+ itemSpacing: 16,
226
+ absoluteBoundingBox: box(0, 0, 400, 100),
227
+ children: [
228
+ {
229
+ id: '1:3',
230
+ name: 'Chip One',
231
+ type: 'RECTANGLE',
232
+ absoluteBoundingBox: box(0, 0, 80, 40),
233
+ },
234
+ ],
235
+ },
236
+ {
237
+ id: '2:23',
238
+ name: 'Karta — "uvozovky" / <script> & {curly}',
239
+ type: 'FRAME',
240
+ absoluteBoundingBox: box(500, 0, 400, 300),
241
+ children: [
242
+ {
243
+ id: '2:20',
244
+ name: 'H',
245
+ type: 'TEXT',
246
+ characters: 'Nadpis',
247
+ style: { fontSize: 32 },
248
+ absoluteBoundingBox: box(520, 20, 200, 40),
249
+ },
250
+ ],
251
+ },
252
+ // A loose non-frame node at page level — must not become a canvas.
253
+ { id: '3:1', name: 'stray', type: 'RECTANGLE', absoluteBoundingBox: box(0, 400, 10, 10) },
254
+ ],
255
+ },
256
+ };
257
+
258
+ describe('--frames writes one canvas per top-level frame', () => {
259
+ function stubDesign() {
260
+ globalThis.fetch = mock(
261
+ async () =>
262
+ new Response(JSON.stringify(DESIGN_DOC), {
263
+ status: 200,
264
+ headers: { 'Content-Type': 'application/json' },
265
+ })
266
+ ) as unknown as typeof fetch;
267
+ }
268
+
269
+ const URL_D = 'https://www.figma.com/design/dGNzRC2kmrmGnOxaBa0RI7/Fixtures';
270
+
271
+ test('emits a .tsx + .meta.json pair per frame, and skips loose nodes', async () => {
272
+ stubDesign();
273
+ const r = await importFrames({ url: URL_D, root: sandbox });
274
+ expect(r.frameCount).toBe(2);
275
+ const files = readdirSync(join(sandbox, '.design', 'ui')).sort();
276
+ expect(files.length).toBe(4);
277
+ expect(files.filter((f) => f.endsWith('.tsx')).length).toBe(2);
278
+ expect(files.filter((f) => f.endsWith('.meta.json')).length).toBe(2);
279
+ });
280
+
281
+ test('the meta stamps kind + a real importedAt', async () => {
282
+ stubDesign();
283
+ const r = await importFrames({ url: URL_D, root: sandbox });
284
+ const metaPath = (r.written[0].path as string).replace(/\.tsx$/, '.meta.json');
285
+ const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
286
+ expect(meta.kind).toBe('imported-figma');
287
+ expect(meta.source.nodeId).toMatch(/^[0-9]+:[0-9]+$/);
288
+ expect(Number.isNaN(Date.parse(meta.source.importedAt))).toBe(false);
289
+ });
290
+
291
+ test('a hostile frame name never reaches a FILENAME', async () => {
292
+ stubDesign();
293
+ await importFrames({ url: URL_D, root: sandbox });
294
+ for (const f of readdirSync(join(sandbox, '.design', 'ui'))) {
295
+ expect(f).toMatch(/^[a-z0-9-]+\.(tsx|meta\.json)$/);
296
+ }
297
+ });
298
+
299
+ test('a dry run writes nothing but reports metrics', async () => {
300
+ stubDesign();
301
+ const r = await importFrames({ url: URL_D, root: sandbox, dryRun: true });
302
+ expect(r.written[0].metrics.bytes).toBeGreaterThan(0);
303
+ expect(existsSync(join(sandbox, '.design', 'ui'))).toBe(false);
304
+ });
305
+
306
+ test('a document with no frame refuses rather than writing an empty canvas', async () => {
307
+ globalThis.fetch = mock(
308
+ async () =>
309
+ new Response(
310
+ JSON.stringify({ document: { id: '0:1', name: 'P', type: 'CANVAS', children: [] } }),
311
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
312
+ )
313
+ ) as unknown as typeof fetch;
314
+ await expect(importFrames({ url: URL_D, root: sandbox })).rejects.toBeInstanceOf(
315
+ ImportFigmaError
316
+ );
317
+ });
318
+ });
319
+
320
+ // ── --tokens (Phase 4) ──────────────────────────────────────────────────────
321
+
322
+ describe('--tokens degrades from Variables to styles without surfacing an error', () => {
323
+ const URL_D = 'https://www.figma.com/design/dGNzRC2kmrmGnOxaBa0RI7/Fixtures';
324
+
325
+ test('a 403 on the Enterprise-gated Variables endpoint falls back to styles', async () => {
326
+ // This is the COMMON case, not an error state — the dogfood account is Pro.
327
+ let call = 0;
328
+ globalThis.fetch = mock(async (input: RequestInfo | URL) => {
329
+ call += 1;
330
+ const url = String(input);
331
+ if (url.includes('/variables/local')) {
332
+ return new Response(JSON.stringify({ err: 'plan' }), { status: 403 });
333
+ }
334
+ if (url.includes('/styles')) {
335
+ return new Response(
336
+ JSON.stringify({
337
+ meta: {
338
+ styles: [{ key: 'k', name: 'Brand/Primary', style_type: 'FILL', node_id: '1:1' }],
339
+ },
340
+ }),
341
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
342
+ );
343
+ }
344
+ return new Response(
345
+ JSON.stringify({
346
+ nodes: {
347
+ '1:1': {
348
+ document: {
349
+ id: '1:1',
350
+ name: 'swatch',
351
+ type: 'RECTANGLE',
352
+ fills: [{ type: 'SOLID', visible: true, color: { r: 1, g: 0, b: 0, a: 1 } }],
353
+ },
354
+ },
355
+ },
356
+ }),
357
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
358
+ );
359
+ }) as unknown as typeof fetch;
360
+
361
+ const r = await importTokens({ url: URL_D, root: sandbox, dryRun: true });
362
+ expect(r.source).toBe('styles');
363
+ expect(r.count).toBe(1);
364
+ expect(r.tokens).toEqual({ brand: { primary: { $type: 'color', $value: '#ff0000' } } });
365
+ expect(call).toBeGreaterThan(1);
366
+ });
367
+
368
+ test('the emitted file is plain JSON, staged outside then promoted', async () => {
369
+ globalThis.fetch = mock(async (input: RequestInfo | URL) => {
370
+ const url = String(input);
371
+ if (url.includes('/variables/local')) return new Response('{}', { status: 403 });
372
+ if (url.includes('/styles')) {
373
+ return new Response(JSON.stringify({ meta: { styles: [] } }), {
374
+ status: 200,
375
+ headers: { 'Content-Type': 'application/json' },
376
+ });
377
+ }
378
+ return new Response('{}', { status: 200 });
379
+ }) as unknown as typeof fetch;
380
+ const r = await importTokens({ url: URL_D, root: sandbox });
381
+ expect(r.path).toContain('_history');
382
+ expect(() => JSON.parse(readFileSync(r.path as string, 'utf8'))).not.toThrow();
383
+ });
384
+ });
385
+
386
+ describe('the asset pipeline is actually WIRED into --frames', () => {
387
+ const URL_D = 'https://www.figma.com/design/dGNzRC2kmrmGnOxaBa0RI7/Fixtures';
388
+
389
+ const VECTOR_DOC = {
390
+ document: {
391
+ id: '0:1',
392
+ name: 'Page 1',
393
+ type: 'CANVAS',
394
+ children: [
395
+ {
396
+ id: '1:1',
397
+ name: 'Frame with a mark',
398
+ type: 'FRAME',
399
+ absoluteBoundingBox: box(0, 0, 400, 300),
400
+ children: [
401
+ {
402
+ id: '2:1',
403
+ name: 'Mark wrapper',
404
+ type: 'GROUP',
405
+ absoluteBoundingBox: box(0, 0, 90, 72),
406
+ children: [
407
+ { id: '2:2', name: 'v', type: 'VECTOR', absoluteBoundingBox: box(0, 0, 20, 20) },
408
+ { id: '2:3', name: 'v', type: 'VECTOR', absoluteBoundingBox: box(20, 0, 20, 20) },
409
+ ],
410
+ },
411
+ ],
412
+ },
413
+ ],
414
+ },
415
+ };
416
+
417
+ /** Answer the document, and decline to render anything. */
418
+ function stubDeclineRender() {
419
+ globalThis.fetch = mock(async (input: RequestInfo | URL) => {
420
+ const url = String(input);
421
+ if (url.includes('/v1/images/')) {
422
+ return new Response(JSON.stringify({ images: { '2:1': null } }), {
423
+ status: 200,
424
+ headers: { 'Content-Type': 'application/json' },
425
+ });
426
+ }
427
+ return new Response(JSON.stringify(VECTOR_DOC), {
428
+ status: 200,
429
+ headers: { 'Content-Type': 'application/json' },
430
+ });
431
+ }) as unknown as typeof fetch;
432
+ }
433
+
434
+ test('a collapsed cluster reaches the image endpoint as ONE batched request', async () => {
435
+ const seen: string[] = [];
436
+ globalThis.fetch = mock(async (input: RequestInfo | URL) => {
437
+ const url = String(input);
438
+ seen.push(url);
439
+ if (url.includes('/v1/images/')) {
440
+ return new Response(JSON.stringify({ images: { '2:1': null } }), {
441
+ status: 200,
442
+ headers: { 'Content-Type': 'application/json' },
443
+ });
444
+ }
445
+ return new Response(JSON.stringify(VECTOR_DOC), {
446
+ status: 200,
447
+ headers: { 'Content-Type': 'application/json' },
448
+ });
449
+ }) as unknown as typeof fetch;
450
+
451
+ const r = await importFrames({ url: URL_D, root: sandbox });
452
+ const imageCalls = seen.filter((u) => u.includes('/v1/images/'));
453
+ // ONE call for the whole cluster — the collapse is what keeps this at 1
454
+ // instead of 2 (or, on a real logo, 14).
455
+ expect(imageCalls.length).toBe(1);
456
+ expect(new URL(imageCalls[0]).searchParams.get('ids')).toBe('2:1');
457
+ expect(new URL(imageCalls[0]).searchParams.get('format')).toBe('svg');
458
+ expect(r.pendingExports).toBe(1);
459
+ });
460
+
461
+ test('an unresolved asset is REPORTED and its placeholder survives in the source', async () => {
462
+ stubDeclineRender();
463
+ const r = await importFrames({ url: URL_D, root: sandbox });
464
+ expect(r.resolvedAssets).toBe(0);
465
+ const tsx = readFileSync(r.written[0].path as string, 'utf8');
466
+ // A visibly broken image beats a silently missing element.
467
+ expect(tsx).toContain('/assets/pending-');
468
+ const dispositions = r.reports.flatMap((rep) => rep.entries.map((e) => e.disposition));
469
+ expect(dispositions).toContain('asset-skipped');
470
+ });
471
+
472
+ test('the emitted canvas NEVER references a figma.com URL, resolved or not', async () => {
473
+ stubDeclineRender();
474
+ const r = await importFrames({ url: URL_D, root: sandbox });
475
+ const tsx = readFileSync(r.written[0].path as string, 'utf8');
476
+ expect(tsx).not.toContain('figma.com');
477
+ expect(tsx).not.toMatch(/src="https?:/);
478
+ });
479
+ });
@@ -0,0 +1,124 @@
1
+ // DDR-217 (fix 6, sync RCA 2026-08-10) — the desktop→cell asset push.
2
+ // The properties under test: the listing mirrors the hub's key shape (junk
3
+ // stays off the wire), the sweep is HEAD-first (upload only what the cloud
4
+ // lacks), the credential is read at call time (silent renewal), and a failed
5
+ // upload is reported, never thrown.
6
+
7
+ import { describe, expect, test } from 'bun:test';
8
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+
12
+ import { listPushableAssets, pushAssets } from '../sync/asset-push.ts';
13
+
14
+ function scratchDesignRoot(): string {
15
+ return mkdtempSync(join(tmpdir(), 'asset-push-'));
16
+ }
17
+
18
+ describe('listPushableAssets — the courtesy filter mirrors the hub ASSET_KEY', () => {
19
+ test('admits the shapes real projects use, refuses what the hub would', () => {
20
+ const designRoot = scratchDesignRoot();
21
+ const assets = join(designRoot, 'assets');
22
+ mkdirSync(join(assets, 'graphics'), { recursive: true });
23
+ mkdirSync(join(assets, 'fonts'), { recursive: true });
24
+ writeFileSync(join(assets, 'a1b2c3d4.png'), 'x');
25
+ writeFileSync(join(assets, 'gator_badge_roundel.svg'), 'x');
26
+ writeFileSync(join(assets, 'graphics/camo-bg.png'), 'x');
27
+ writeFileSync(join(assets, 'fonts/Gators-Bold.woff2'), 'x');
28
+ writeFileSync(join(assets, '.hidden'), 'x'); // dotfile — fails leading-alnum
29
+
30
+ expect(listPushableAssets(designRoot)).toEqual([
31
+ 'a1b2c3d4.png',
32
+ 'fonts/Gators-Bold.woff2',
33
+ 'gator_badge_roundel.svg',
34
+ 'graphics/camo-bg.png',
35
+ ]);
36
+ });
37
+
38
+ test('depth beyond the hub cap is skipped; a missing assets dir is []', () => {
39
+ const designRoot = scratchDesignRoot();
40
+ mkdirSync(join(designRoot, 'assets/a/b/c/d/e'), { recursive: true });
41
+ writeFileSync(join(designRoot, 'assets/a/b/c/d/e/too-deep.png'), 'x');
42
+ expect(listPushableAssets(designRoot)).toEqual([]);
43
+ expect(listPushableAssets(join(designRoot, 'nope'))).toEqual([]);
44
+ });
45
+ });
46
+
47
+ describe('pushAssets — HEAD-first, upload the misses, never throw', () => {
48
+ test('skips what the cloud has, PUTs the rest, streams the file bytes', async () => {
49
+ const designRoot = scratchDesignRoot();
50
+ const assets = join(designRoot, 'assets');
51
+ mkdirSync(assets, { recursive: true });
52
+ writeFileSync(join(assets, 'aaaaaaaa.png'), 'already-there');
53
+ writeFileSync(join(assets, 'bbbbbbbb.png'), 'missing-bytes');
54
+
55
+ const calls: Array<{ method: string; url: string; auth: string }> = [];
56
+ const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
57
+ const url = String(input);
58
+ const method = init?.method ?? 'GET';
59
+ const headers = (init?.headers ?? {}) as Record<string, string>;
60
+ calls.push({ method, url, auth: String(headers.authorization) });
61
+ if (method === 'HEAD')
62
+ return new Response(null, { status: url.includes('aaaa') ? 200 : 404 });
63
+ // The PUT body is the real file — prove the bytes went along.
64
+ const body = await new Response(init?.body as BodyInit).text();
65
+ expect(body).toBe('missing-bytes');
66
+ return new Response('{"ok":true}', { status: 200 });
67
+ }) as typeof fetch;
68
+
69
+ const r = await pushAssets({
70
+ designRoot,
71
+ hubUrl: 'https://alligators.cloud.maude.sh/',
72
+ token: () => 'mau_tok',
73
+ fetchImpl,
74
+ log: { log: () => {}, warn: () => {} },
75
+ });
76
+
77
+ expect(r.skipped).toBe(1);
78
+ expect(r.pushed).toEqual(['bbbbbbbb.png']);
79
+ expect(r.failed).toEqual([]);
80
+ // Trailing slash folded; every call authenticated with the live token.
81
+ expect(calls.every((c) => c.url.startsWith('https://alligators.cloud.maude.sh/assets/'))).toBe(
82
+ true
83
+ );
84
+ expect(calls.every((c) => c.auth === 'Bearer mau_tok')).toBe(true);
85
+ expect(calls.map((c) => c.method)).toEqual(['HEAD', 'HEAD', 'PUT']);
86
+ });
87
+
88
+ test('a refused upload lands in failed — reported, not thrown', async () => {
89
+ const designRoot = scratchDesignRoot();
90
+ mkdirSync(join(designRoot, 'assets'), { recursive: true });
91
+ writeFileSync(join(designRoot, 'assets/cccccccc.png'), 'x');
92
+
93
+ const fetchImpl = (async (_input: RequestInfo | URL, init?: RequestInit) =>
94
+ new Response(null, { status: init?.method === 'HEAD' ? 404 : 413 })) as typeof fetch;
95
+
96
+ const r = await pushAssets({
97
+ designRoot,
98
+ hubUrl: 'https://x.example',
99
+ token: () => 't',
100
+ fetchImpl,
101
+ log: { log: () => {}, warn: () => {} },
102
+ });
103
+ expect(r.pushed).toEqual([]);
104
+ expect(r.failed).toEqual([{ key: 'cccccccc.png', reason: 'HTTP 413' }]);
105
+ });
106
+
107
+ test('a project with no assets dir makes zero network calls', async () => {
108
+ const designRoot = scratchDesignRoot();
109
+ let calls = 0;
110
+ const fetchImpl = (async () => {
111
+ calls += 1;
112
+ return new Response(null, { status: 200 });
113
+ }) as typeof fetch;
114
+ const r = await pushAssets({
115
+ designRoot,
116
+ hubUrl: 'https://x.example',
117
+ token: () => 't',
118
+ fetchImpl,
119
+ log: { log: () => {}, warn: () => {} },
120
+ });
121
+ expect(calls).toBe(0);
122
+ expect(r).toEqual({ pushed: [], skipped: 0, failed: [] });
123
+ });
124
+ });