@1agh/maude 0.58.2 → 0.59.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/apps/studio/annotations-bindings.ts +83 -4
- package/apps/studio/annotations-layer.tsx +49 -15
- package/apps/studio/api.ts +6 -1
- package/apps/studio/bin/_fetch-asset.mjs +169 -5
- package/apps/studio/bin/_import-asset.mjs +90 -0
- package/apps/studio/bin/_import-figma.mjs +1775 -0
- package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
- package/apps/studio/bin/_perf-probe.mjs +228 -0
- package/apps/studio/bin/_perf-shared.mjs +345 -0
- package/apps/studio/bin/_video-playwright.mjs +103 -7
- package/apps/studio/bin/import-figma.sh +47 -0
- package/apps/studio/bin/perf.sh +228 -0
- package/apps/studio/bin/read-annotations.mjs +11 -1
- package/apps/studio/bin/smoke.sh +49 -5
- package/apps/studio/bun.lock +16 -22
- package/apps/studio/canvas-edit.ts +29 -5
- package/apps/studio/canvas-lib.tsx +148 -6
- package/apps/studio/client/app.jsx +196 -38
- 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/SyncPanel.jsx +229 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +31 -3
- package/apps/studio/client/panels/timeline-comp-target.js +101 -0
- package/apps/studio/client/panels/timeline-parse.js +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +37 -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 +1231 -1231
- 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 +35 -6
- package/apps/studio/exporters/video-render-lib.ts +6 -0
- package/apps/studio/exporters/video.ts +72 -1
- package/apps/studio/figma/assets.test.ts +464 -0
- package/apps/studio/figma/assets.ts +452 -0
- package/apps/studio/figma/client.test.ts +395 -0
- package/apps/studio/figma/client.ts +513 -0
- package/apps/studio/figma/codegen-client.test.ts +276 -0
- package/apps/studio/figma/codegen-client.ts +509 -0
- package/apps/studio/figma/codegen-fonts.test.ts +103 -0
- package/apps/studio/figma/codegen-fonts.ts +195 -0
- package/apps/studio/figma/codegen-values.test.ts +179 -0
- package/apps/studio/figma/codegen-values.ts +270 -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 +273 -0
- package/apps/studio/figma/fig-decode.test.ts +702 -0
- package/apps/studio/figma/fig-decode.ts +617 -0
- package/apps/studio/figma/fig-kiwi.ts +410 -0
- package/apps/studio/figma/fig-zip.ts +270 -0
- package/apps/studio/figma/from-codegen.test.ts +408 -0
- package/apps/studio/figma/from-codegen.ts +1103 -0
- package/apps/studio/figma/sanitize.test.ts +325 -0
- package/apps/studio/figma/sanitize.ts +407 -0
- package/apps/studio/figma/style-map.ts +352 -0
- package/apps/studio/figma/tailwind-map.test.ts +142 -0
- package/apps/studio/figma/tailwind-map.ts +545 -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 +328 -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 +544 -0
- package/apps/studio/figma/url.test.ts +167 -0
- package/apps/studio/figma/url.ts +160 -0
- package/apps/studio/http.ts +176 -0
- package/apps/studio/sync/asset-push.ts +432 -0
- package/apps/studio/sync/connection-state.ts +82 -3
- package/apps/studio/sync/hub-link.ts +63 -7
- package/apps/studio/sync/hubs-config.ts +31 -3
- package/apps/studio/sync/index.ts +286 -27
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- package/apps/studio/sync/status.ts +18 -0
- 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 +17 -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-explode.test.ts +438 -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/fixtures/perf-canvas.mjs +201 -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 +667 -0
- package/apps/studio/test/sync-asset-push.test.ts +567 -0
- package/apps/studio/test/sync-connection-state.test.ts +79 -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-panel-surface.test.ts +90 -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/sync-status.test.ts +28 -0
- package/apps/studio/test/timeline-comp-target.test.ts +139 -0
- package/apps/studio/test/video-comp.test.ts +104 -2
- package/apps/studio/test/video-encode-lib.test.ts +63 -0
- package/apps/studio/test/workspace-containment.test.ts +1 -0
- package/apps/studio/use-artboard-drag.tsx +37 -3
- package/apps/studio/video-comp.tsx +121 -6
- package/apps/studio/whats-new.json +98 -0
- package/apps/studio/workspace-mode.ts +4 -0
- package/cli/commands/design.mjs +15 -0
- package/cli/commands/kg.mjs +8 -1
- package/cli/commands/kg.test.mjs +24 -0
- package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
- package/cli/lib/figma-import-controls.test.mjs +70 -0
- package/package.json +8 -8
- package/plugins/flow/.claude-plugin/config.schema.json +3 -3
|
@@ -0,0 +1,667 @@
|
|
|
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
|
+
importPages,
|
|
34
|
+
importTokens,
|
|
35
|
+
withSansFallback,
|
|
36
|
+
} from '../bin/_import-figma.mjs';
|
|
37
|
+
|
|
38
|
+
const TOKEN = 'figd_VERBCANARY_0123456789abcdef';
|
|
39
|
+
const BOARD_URL = 'https://www.figma.com/board/Em6NOwaOFTYV7NlQT4NK8l/Analyza';
|
|
40
|
+
|
|
41
|
+
let sandbox: string;
|
|
42
|
+
let keysDir: string;
|
|
43
|
+
let realFetch: typeof fetch;
|
|
44
|
+
|
|
45
|
+
const box = (x: number, y: number, width: number, height: number) => ({ x, y, width, height });
|
|
46
|
+
|
|
47
|
+
/** A board carrying a hostile layer name, a hidden node and an unmappable one. */
|
|
48
|
+
const DOC = {
|
|
49
|
+
document: {
|
|
50
|
+
id: '0:0',
|
|
51
|
+
name: 'Document',
|
|
52
|
+
type: 'CANVAS',
|
|
53
|
+
children: [
|
|
54
|
+
{
|
|
55
|
+
id: '1:8',
|
|
56
|
+
name: 'Sticky',
|
|
57
|
+
type: 'STICKY',
|
|
58
|
+
absoluteBoundingBox: box(-3244, -6272, 240, 240),
|
|
59
|
+
characters: 'Persona A',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: '2:17',
|
|
63
|
+
name: 'Příliš žluťoučký — "test" / <b> & \'x\'',
|
|
64
|
+
type: 'SHAPE_WITH_TEXT',
|
|
65
|
+
shapeType: 'SQUARE',
|
|
66
|
+
absoluteBoundingBox: box(0, 0, 200, 200),
|
|
67
|
+
characters: 'SECRET_BODY_TEXT',
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: '2:40',
|
|
71
|
+
name: 'Parallelogram',
|
|
72
|
+
type: 'SHAPE_WITH_TEXT',
|
|
73
|
+
shapeType: 'PARALLELOGRAM_RIGHT',
|
|
74
|
+
absoluteBoundingBox: box(400, 0, 200, 200),
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: '9:1',
|
|
78
|
+
name: 'hidden',
|
|
79
|
+
type: 'STICKY',
|
|
80
|
+
visible: false,
|
|
81
|
+
characters: 'you cannot see me',
|
|
82
|
+
absoluteBoundingBox: box(800, 0, 240, 240),
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
sandbox = mkdtempSync(join(tmpdir(), 'maude-figma-verb-'));
|
|
90
|
+
mkdirSync(join(sandbox, '.design'), { recursive: true });
|
|
91
|
+
keysDir = mkdtempSync(join(tmpdir(), 'maude-figma-verb-keys-'));
|
|
92
|
+
const keysPath = join(keysDir, 'keys.json');
|
|
93
|
+
writeFileSync(keysPath, JSON.stringify({ keys: { figma: TOKEN } }), { mode: 0o600 });
|
|
94
|
+
process.env.MAUDE_GEN_KEYS_PATH = keysPath;
|
|
95
|
+
realFetch = globalThis.fetch;
|
|
96
|
+
globalThis.fetch = mock(
|
|
97
|
+
async () =>
|
|
98
|
+
new Response(JSON.stringify(DOC), {
|
|
99
|
+
status: 200,
|
|
100
|
+
headers: { 'Content-Type': 'application/json' },
|
|
101
|
+
})
|
|
102
|
+
) as unknown as typeof fetch;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
afterEach(() => {
|
|
106
|
+
globalThis.fetch = realFetch;
|
|
107
|
+
rmSync(sandbox, { recursive: true, force: true });
|
|
108
|
+
rmSync(keysDir, { recursive: true, force: true });
|
|
109
|
+
delete process.env.MAUDE_GEN_KEYS_PATH;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('a rendered SVG never falls back to a SERIF (live-migration report)', () => {
|
|
113
|
+
// Measured on the StudyFi cover page: Figma renders text as
|
|
114
|
+
// `font-family="Inter"` and nothing else. An SVG behind `<img src>` renders
|
|
115
|
+
// in an isolated document — the page CSS, its @font-face rules and the DS
|
|
116
|
+
// webfonts do not reach inside — so an uninstalled family lands on the
|
|
117
|
+
// browser default, which is a serif. A sans-serif product design arrived in
|
|
118
|
+
// Times, and every count-based check called it a success.
|
|
119
|
+
test('a bare family gains a generic sans fallback', () => {
|
|
120
|
+
expect(withSansFallback('<text font-family="Inter">x</text>')).toContain(
|
|
121
|
+
'font-family="Inter, sans-serif"'
|
|
122
|
+
);
|
|
123
|
+
expect(withSansFallback('<text style="font-family: SF Pro Text">x</text>')).toContain(
|
|
124
|
+
'font-family:SF Pro Text, sans-serif'
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('a stack that already ends in a generic is left alone', () => {
|
|
129
|
+
for (const already of [
|
|
130
|
+
'<text font-family="Inter, sans-serif">x</text>',
|
|
131
|
+
'<text font-family="Georgia, serif">x</text>',
|
|
132
|
+
'<text font-family="ui-monospace, monospace">x</text>',
|
|
133
|
+
'<text font-family="system-ui">x</text>',
|
|
134
|
+
]) {
|
|
135
|
+
expect(withSansFallback(already)).toBe(already);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('it is a FALLBACK, not a substitution — the requested family still wins', () => {
|
|
140
|
+
const out = withSansFallback('<text font-family="Hanken Grotesk">x</text>');
|
|
141
|
+
expect(out.indexOf('Hanken Grotesk')).toBeLessThan(out.indexOf('sans-serif'));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('it rewrites nothing but font-family', () => {
|
|
145
|
+
const svg = '<svg><rect fill="#fff"/><text font-size="12" font-family="Inter">hi</text></svg>';
|
|
146
|
+
const out = withSansFallback(svg);
|
|
147
|
+
expect(out).toContain('fill="#fff"');
|
|
148
|
+
expect(out).toContain('font-size="12"');
|
|
149
|
+
expect(out).toContain('>hi<');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('an unterminated / oversized attribute cannot run away', () => {
|
|
153
|
+
const huge = `<text font-family="${'A'.repeat(500)}">x</text>`;
|
|
154
|
+
expect(withSansFallback(huge)).toBe(huge);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('--pages contains a page failure instead of losing the rest of the file', () => {
|
|
159
|
+
// Measured on the first live migration: a fault entering page 4 of 6 cost
|
|
160
|
+
// pages 4, 5 and 6 — twice in a row — because the loop caught `too_large`,
|
|
161
|
+
// empty pages and comment failures but NOT a page fetch failure. There is no
|
|
162
|
+
// resume, so the retry re-fetched and re-rendered the three that had already
|
|
163
|
+
// succeeded.
|
|
164
|
+
const PAGES = {
|
|
165
|
+
document: {
|
|
166
|
+
id: '0:0',
|
|
167
|
+
type: 'DOCUMENT',
|
|
168
|
+
children: [
|
|
169
|
+
{ id: '1:1', name: 'One', type: 'CANVAS' },
|
|
170
|
+
{ id: '2:2', name: 'Two', type: 'CANVAS' },
|
|
171
|
+
{ id: '3:3', name: 'Three', type: 'CANVAS' },
|
|
172
|
+
],
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
const PAGE_BODY = (id: string) => ({
|
|
176
|
+
nodes: {
|
|
177
|
+
[id]: {
|
|
178
|
+
document: {
|
|
179
|
+
id,
|
|
180
|
+
name: 'P',
|
|
181
|
+
type: 'CANVAS',
|
|
182
|
+
children: [
|
|
183
|
+
{
|
|
184
|
+
id: `${id}0`,
|
|
185
|
+
name: 'Frame',
|
|
186
|
+
type: 'FRAME',
|
|
187
|
+
visible: true,
|
|
188
|
+
absoluteBoundingBox: box(0, 0, 100, 100),
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
/** Page two's document fetch drops the connection; one and three are fine. */
|
|
197
|
+
function stubWithPageTwoDown() {
|
|
198
|
+
globalThis.fetch = mock(async (input: unknown) => {
|
|
199
|
+
const url = String(input);
|
|
200
|
+
if (url.includes('/images'))
|
|
201
|
+
return new Response(JSON.stringify({ images: {} }), { status: 200 });
|
|
202
|
+
if (url.includes('/comments'))
|
|
203
|
+
return new Response(JSON.stringify({ comments: [] }), { status: 200 });
|
|
204
|
+
if (
|
|
205
|
+
url.includes('depth=1') ||
|
|
206
|
+
/\/files\/[^/]+\?/.test(url) ||
|
|
207
|
+
url.endsWith('/files/2H6a9YUgPAu0AGdEiwP895')
|
|
208
|
+
) {
|
|
209
|
+
return new Response(JSON.stringify(PAGES), { status: 200 });
|
|
210
|
+
}
|
|
211
|
+
const m = /ids=([^&]+)/.exec(url);
|
|
212
|
+
const id = m ? decodeURIComponent(m[1]) : '';
|
|
213
|
+
if (id.startsWith('2:2')) throw new TypeError('fetch failed');
|
|
214
|
+
if (id) return new Response(JSON.stringify(PAGE_BODY(id)), { status: 200 });
|
|
215
|
+
return new Response(JSON.stringify(PAGES), { status: 200 });
|
|
216
|
+
}) as unknown as typeof fetch;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
test('the pages either side of the fault still import, and the fault is REPORTED', async () => {
|
|
220
|
+
stubWithPageTwoDown();
|
|
221
|
+
const r = await importPages({
|
|
222
|
+
url: 'https://www.figma.com/design/2H6a9YUgPAu0AGdEiwP895/x',
|
|
223
|
+
root: sandbox,
|
|
224
|
+
folder: 'f',
|
|
225
|
+
});
|
|
226
|
+
// Page three is the one the old behaviour lost: it comes AFTER the fault.
|
|
227
|
+
expect(r.written.length).toBeGreaterThanOrEqual(2);
|
|
228
|
+
const failed = r.skipped.find((x: { page?: string }) => x.page === '2:2');
|
|
229
|
+
expect(failed).toBeDefined();
|
|
230
|
+
// Reported by node id + a code-owned reason, never document text (D10).
|
|
231
|
+
expect(String(failed?.why)).toMatch(/^(network|bad_response|failed \(\w+\))$/);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('a MISCONFIGURED request stays fatal — a partial folder must not look complete', async () => {
|
|
235
|
+
stubWithPageTwoDown();
|
|
236
|
+
await expect(
|
|
237
|
+
importPages({
|
|
238
|
+
url: 'https://www.figma.com/design/2H6a9YUgPAu0AGdEiwP895/x',
|
|
239
|
+
root: sandbox,
|
|
240
|
+
folder: 'NOT A SLUG',
|
|
241
|
+
})
|
|
242
|
+
).rejects.toBeInstanceOf(ImportFigmaError);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('--board writes a sanitized annotation layer', () => {
|
|
247
|
+
test('lands at a code-computed slug under the design root', async () => {
|
|
248
|
+
const result = await importBoard({ url: BOARD_URL, root: sandbox });
|
|
249
|
+
// The slug is the HOST CANVAS's slug — an annotation layer named anything
|
|
250
|
+
// else has nothing to render it (found on the first live board import).
|
|
251
|
+
expect(result.slug).toMatch(/^ui-[a-z0-9_]+$/);
|
|
252
|
+
expect(result.path?.startsWith(join(sandbox, '.design'))).toBe(true);
|
|
253
|
+
expect(existsSync(result.path as string)).toBe(true);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('the slug is NEVER derived from a Figma string', async () => {
|
|
257
|
+
const result = await importBoard({ url: BOARD_URL, root: sandbox });
|
|
258
|
+
expect(result.slug).not.toContain('žlu');
|
|
259
|
+
expect(result.slug).not.toContain('<');
|
|
260
|
+
expect(result.slug).toMatch(/^[a-z0-9_-]{1,64}$/);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('the written SVG is sanitizer-clean — no markup from the hostile name', async () => {
|
|
264
|
+
const result = await importBoard({ url: BOARD_URL, root: sandbox });
|
|
265
|
+
const svg = readFileSync(result.path as string, 'utf8');
|
|
266
|
+
expect(svg.startsWith('<svg')).toBe(true);
|
|
267
|
+
expect(svg).not.toContain('<b>');
|
|
268
|
+
expect(svg).not.toContain('<script');
|
|
269
|
+
expect(svg).not.toMatch(/\son[a-z]+=/i);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('a hidden node is not written', async () => {
|
|
273
|
+
const result = await importBoard({ url: BOARD_URL, root: sandbox });
|
|
274
|
+
const svg = readFileSync(result.path as string, 'utf8');
|
|
275
|
+
expect(svg).not.toContain('you cannot see me');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// A FigJam board is not a screen, and framing it as one was wrong twice: the
|
|
279
|
+
// artboard drew screen chrome around whiteboard content, and it took the DS
|
|
280
|
+
// surface colour — which paints a white board near-black on a dark-default
|
|
281
|
+
// design system. Reported from a live migration into `studyfi-design`.
|
|
282
|
+
test('the host canvas has NO artboard — the board is not a screen', async () => {
|
|
283
|
+
const result = await importBoard({ url: BOARD_URL, root: sandbox });
|
|
284
|
+
const tsx = readFileSync(join(sandbox, '.design', result.canvas as string), 'utf8');
|
|
285
|
+
expect(tsx).not.toContain('<DCArtboard');
|
|
286
|
+
expect(tsx).not.toContain('DCArtboard,');
|
|
287
|
+
expect(tsx).toContain('<DesignCanvas />');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('the board gets an OPAQUE light paper — a section is only a 6% tint', async () => {
|
|
291
|
+
// `annotations-model.ts` paints a section at a hardcoded fill-opacity of
|
|
292
|
+
// 0.06, so it cannot be the ground: white-at-6% over a dark-default DS is
|
|
293
|
+
// still dark. Measured on the first migration into `studyfi-design`.
|
|
294
|
+
const result = await importBoard({ url: BOARD_URL, root: sandbox });
|
|
295
|
+
const svg = readFileSync(result.path as string, 'utf8');
|
|
296
|
+
expect(svg).toContain('data-id="figma-board-paper"');
|
|
297
|
+
expect(svg.toLowerCase()).toContain('fill="#ffffff"');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test('the region is a labelled SECTION, on the annotation layer', async () => {
|
|
301
|
+
const result = await importBoard({ url: BOARD_URL, root: sandbox });
|
|
302
|
+
const svg = readFileSync(result.path as string, 'utf8');
|
|
303
|
+
expect(svg).toContain('data-tool="section"');
|
|
304
|
+
expect(svg).toContain('data-id="figma-board-region"');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('paint order is paper -> region -> content; either one later would veil the board', async () => {
|
|
308
|
+
const result = await importBoard({ url: BOARD_URL, root: sandbox });
|
|
309
|
+
const svg = readFileSync(result.path as string, 'utf8');
|
|
310
|
+
const paper = svg.indexOf('data-id="figma-board-paper"');
|
|
311
|
+
const region = svg.indexOf('data-id="figma-board-region"');
|
|
312
|
+
const firstContent = svg.search(/data-tool="(sticky|image|text|ellipse|arrow)"/);
|
|
313
|
+
expect(paper).toBeGreaterThan(-1);
|
|
314
|
+
expect(paper).toBeLessThan(region);
|
|
315
|
+
if (firstContent > -1) expect(region).toBeLessThan(firstContent);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('an explicit --slug is honoured when it is a valid slug', async () => {
|
|
319
|
+
const result = await importBoard({ url: BOARD_URL, root: sandbox, slug: 'retro-q3' });
|
|
320
|
+
// `--slug` names the CANVAS; the annotation layer follows from it.
|
|
321
|
+
expect(result.canvas).toBe('ui/Retro Q3.tsx');
|
|
322
|
+
expect(result.slug).toBe('ui-retro_q3');
|
|
323
|
+
expect(result.path?.endsWith('ui-retro_q3.annotations.svg')).toBe(true);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test.each([
|
|
327
|
+
['traversal', '../../etc/passwd'],
|
|
328
|
+
['absolute', '/etc/passwd'],
|
|
329
|
+
['uppercase + spaces', 'My Board'],
|
|
330
|
+
['too long', 'a'.repeat(65)],
|
|
331
|
+
])('a hostile --slug is rejected (%s), nothing is written', async (_label, slug) => {
|
|
332
|
+
await expect(importBoard({ url: BOARD_URL, root: sandbox, slug })).rejects.toBeInstanceOf(
|
|
333
|
+
ImportFigmaError
|
|
334
|
+
);
|
|
335
|
+
expect(readdirSync(join(sandbox, '.design'))).toEqual([]);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
describe('staging is outside the design root, and leaves nothing behind', () => {
|
|
340
|
+
test('a successful run leaves the layer + its host canvas, and no staging residue', async () => {
|
|
341
|
+
const result = await importBoard({ url: BOARD_URL, root: sandbox });
|
|
342
|
+
const entries = readdirSync(join(sandbox, '.design'));
|
|
343
|
+
expect(entries.sort()).toEqual(['ui', `${result.slug}.annotations.svg`]);
|
|
344
|
+
// The host canvas — without it the strokes are on disk and invisible.
|
|
345
|
+
expect(readdirSync(join(sandbox, '.design', 'ui')).sort()).toEqual([
|
|
346
|
+
'Figjam Em6nowao.meta.json',
|
|
347
|
+
'Figjam Em6nowao.tsx',
|
|
348
|
+
]);
|
|
349
|
+
// No `.tmp-*` / staging directory anywhere under the design root.
|
|
350
|
+
expect(entries.some((e) => e.startsWith('.tmp') || e.startsWith('maude-figma-'))).toBe(false);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test('a dry run writes NOTHING but still reports', async () => {
|
|
354
|
+
const result = await importBoard({ url: BOARD_URL, root: sandbox, dryRun: true });
|
|
355
|
+
expect(result.path).toBeUndefined();
|
|
356
|
+
expect(result.strokeCount).toBeGreaterThan(0);
|
|
357
|
+
expect(readdirSync(join(sandbox, '.design'))).toEqual([]);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
describe('the summary is model input — enum + node ids only (D10)', () => {
|
|
362
|
+
test('names and body text never appear in the summary', async () => {
|
|
363
|
+
const result = await importBoard({ url: BOARD_URL, root: sandbox, dryRun: true });
|
|
364
|
+
const summary = formatSummary(result.report);
|
|
365
|
+
expect(summary).not.toContain('žluťoučký');
|
|
366
|
+
expect(summary).not.toContain('SECRET_BODY_TEXT');
|
|
367
|
+
expect(summary).not.toContain('you cannot see me');
|
|
368
|
+
expect(summary).not.toContain('Persona A');
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test('every non-clean disposition is named by node id and a fixed reason', async () => {
|
|
372
|
+
const result = await importBoard({ url: BOARD_URL, root: sandbox, dryRun: true });
|
|
373
|
+
const summary = formatSummary(result.report);
|
|
374
|
+
// The unmappable shape is reported, not silently dropped.
|
|
375
|
+
expect(summary).toContain('2:40');
|
|
376
|
+
expect(summary).toContain('unmappable-shape');
|
|
377
|
+
// The hidden node is reported too.
|
|
378
|
+
expect(summary).toContain('9:1');
|
|
379
|
+
expect(summary).toContain('hidden-node-skipped');
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test('the origin shift is reported so a huge negative-space board is explicable', async () => {
|
|
383
|
+
const result = await importBoard({ url: BOARD_URL, root: sandbox, dryRun: true });
|
|
384
|
+
expect(result.origin).toEqual({ x: -3244, y: -6272 });
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// ── --frames (Phase 3) ──────────────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
const DESIGN_DOC = {
|
|
391
|
+
document: {
|
|
392
|
+
id: '0:1',
|
|
393
|
+
name: 'Page 1',
|
|
394
|
+
type: 'CANVAS',
|
|
395
|
+
children: [
|
|
396
|
+
{
|
|
397
|
+
id: '1:2',
|
|
398
|
+
name: 'AL Horizontal (-> flex row)',
|
|
399
|
+
type: 'FRAME',
|
|
400
|
+
layoutMode: 'HORIZONTAL',
|
|
401
|
+
itemSpacing: 16,
|
|
402
|
+
absoluteBoundingBox: box(0, 0, 400, 100),
|
|
403
|
+
children: [
|
|
404
|
+
{
|
|
405
|
+
id: '1:3',
|
|
406
|
+
name: 'Chip One',
|
|
407
|
+
type: 'RECTANGLE',
|
|
408
|
+
absoluteBoundingBox: box(0, 0, 80, 40),
|
|
409
|
+
},
|
|
410
|
+
],
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
id: '2:23',
|
|
414
|
+
name: 'Karta — "uvozovky" / <script> & {curly}',
|
|
415
|
+
type: 'FRAME',
|
|
416
|
+
absoluteBoundingBox: box(500, 0, 400, 300),
|
|
417
|
+
children: [
|
|
418
|
+
{
|
|
419
|
+
id: '2:20',
|
|
420
|
+
name: 'H',
|
|
421
|
+
type: 'TEXT',
|
|
422
|
+
characters: 'Nadpis',
|
|
423
|
+
style: { fontSize: 32 },
|
|
424
|
+
absoluteBoundingBox: box(520, 20, 200, 40),
|
|
425
|
+
},
|
|
426
|
+
],
|
|
427
|
+
},
|
|
428
|
+
// A loose non-frame node at page level — must not become a canvas.
|
|
429
|
+
{ id: '3:1', name: 'stray', type: 'RECTANGLE', absoluteBoundingBox: box(0, 400, 10, 10) },
|
|
430
|
+
],
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
describe('--frames writes one canvas per top-level frame', () => {
|
|
435
|
+
function stubDesign() {
|
|
436
|
+
globalThis.fetch = mock(
|
|
437
|
+
async () =>
|
|
438
|
+
new Response(JSON.stringify(DESIGN_DOC), {
|
|
439
|
+
status: 200,
|
|
440
|
+
headers: { 'Content-Type': 'application/json' },
|
|
441
|
+
})
|
|
442
|
+
) as unknown as typeof fetch;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const URL_D = 'https://www.figma.com/design/dGNzRC2kmrmGnOxaBa0RI7/Fixtures';
|
|
446
|
+
|
|
447
|
+
test('emits a .tsx + .meta.json pair per frame, and skips loose nodes', async () => {
|
|
448
|
+
stubDesign();
|
|
449
|
+
const r = await importFrames({ url: URL_D, root: sandbox });
|
|
450
|
+
expect(r.frameCount).toBe(2);
|
|
451
|
+
const files = readdirSync(join(sandbox, '.design', 'ui')).sort();
|
|
452
|
+
expect(files.length).toBe(4);
|
|
453
|
+
expect(files.filter((f) => f.endsWith('.tsx')).length).toBe(2);
|
|
454
|
+
expect(files.filter((f) => f.endsWith('.meta.json')).length).toBe(2);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test('the meta stamps kind + a real importedAt', async () => {
|
|
458
|
+
stubDesign();
|
|
459
|
+
const r = await importFrames({ url: URL_D, root: sandbox });
|
|
460
|
+
const metaPath = (r.written[0].path as string).replace(/\.tsx$/, '.meta.json');
|
|
461
|
+
const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
|
|
462
|
+
expect(meta.kind).toBe('imported-figma');
|
|
463
|
+
expect(meta.source.nodeId).toMatch(/^[0-9]+:[0-9]+$/);
|
|
464
|
+
expect(Number.isNaN(Date.parse(meta.source.importedAt))).toBe(false);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test('a hostile frame name never reaches a FILENAME', async () => {
|
|
468
|
+
stubDesign();
|
|
469
|
+
await importFrames({ url: URL_D, root: sandbox });
|
|
470
|
+
for (const f of readdirSync(join(sandbox, '.design', 'ui'))) {
|
|
471
|
+
expect(f).toMatch(/^[a-z0-9-]+\.(tsx|meta\.json)$/);
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test('a dry run writes nothing but reports metrics', async () => {
|
|
476
|
+
stubDesign();
|
|
477
|
+
const r = await importFrames({ url: URL_D, root: sandbox, dryRun: true });
|
|
478
|
+
expect(r.written[0].metrics.bytes).toBeGreaterThan(0);
|
|
479
|
+
expect(existsSync(join(sandbox, '.design', 'ui'))).toBe(false);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test('a document with no frame refuses rather than writing an empty canvas', async () => {
|
|
483
|
+
globalThis.fetch = mock(
|
|
484
|
+
async () =>
|
|
485
|
+
new Response(
|
|
486
|
+
JSON.stringify({ document: { id: '0:1', name: 'P', type: 'CANVAS', children: [] } }),
|
|
487
|
+
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
|
488
|
+
)
|
|
489
|
+
) as unknown as typeof fetch;
|
|
490
|
+
await expect(importFrames({ url: URL_D, root: sandbox })).rejects.toBeInstanceOf(
|
|
491
|
+
ImportFigmaError
|
|
492
|
+
);
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// ── --tokens (Phase 4) ──────────────────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
describe('--tokens degrades from Variables to styles without surfacing an error', () => {
|
|
499
|
+
const URL_D = 'https://www.figma.com/design/dGNzRC2kmrmGnOxaBa0RI7/Fixtures';
|
|
500
|
+
|
|
501
|
+
test('a 403 on the Enterprise-gated Variables endpoint falls back to styles', async () => {
|
|
502
|
+
// This is the COMMON case, not an error state — the dogfood account is Pro.
|
|
503
|
+
let call = 0;
|
|
504
|
+
globalThis.fetch = mock(async (input: RequestInfo | URL) => {
|
|
505
|
+
call += 1;
|
|
506
|
+
const url = String(input);
|
|
507
|
+
if (url.includes('/variables/local')) {
|
|
508
|
+
return new Response(JSON.stringify({ err: 'plan' }), { status: 403 });
|
|
509
|
+
}
|
|
510
|
+
if (url.includes('/styles')) {
|
|
511
|
+
return new Response(
|
|
512
|
+
JSON.stringify({
|
|
513
|
+
meta: {
|
|
514
|
+
styles: [{ key: 'k', name: 'Brand/Primary', style_type: 'FILL', node_id: '1:1' }],
|
|
515
|
+
},
|
|
516
|
+
}),
|
|
517
|
+
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
return new Response(
|
|
521
|
+
JSON.stringify({
|
|
522
|
+
nodes: {
|
|
523
|
+
'1:1': {
|
|
524
|
+
document: {
|
|
525
|
+
id: '1:1',
|
|
526
|
+
name: 'swatch',
|
|
527
|
+
type: 'RECTANGLE',
|
|
528
|
+
fills: [{ type: 'SOLID', visible: true, color: { r: 1, g: 0, b: 0, a: 1 } }],
|
|
529
|
+
},
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
}),
|
|
533
|
+
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
|
534
|
+
);
|
|
535
|
+
}) as unknown as typeof fetch;
|
|
536
|
+
|
|
537
|
+
const r = await importTokens({ url: URL_D, root: sandbox, dryRun: true });
|
|
538
|
+
expect(r.source).toBe('styles');
|
|
539
|
+
expect(r.count).toBe(1);
|
|
540
|
+
expect(r.tokens).toEqual({ brand: { primary: { $type: 'color', $value: '#ff0000' } } });
|
|
541
|
+
expect(call).toBeGreaterThan(1);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test('the emitted file is plain JSON, staged outside then promoted', async () => {
|
|
545
|
+
globalThis.fetch = mock(async (input: RequestInfo | URL) => {
|
|
546
|
+
const url = String(input);
|
|
547
|
+
if (url.includes('/variables/local')) return new Response('{}', { status: 403 });
|
|
548
|
+
if (url.includes('/styles')) {
|
|
549
|
+
return new Response(JSON.stringify({ meta: { styles: [] } }), {
|
|
550
|
+
status: 200,
|
|
551
|
+
headers: { 'Content-Type': 'application/json' },
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
return new Response('{}', { status: 200 });
|
|
555
|
+
}) as unknown as typeof fetch;
|
|
556
|
+
const r = await importTokens({ url: URL_D, root: sandbox });
|
|
557
|
+
expect(r.path).toContain('_history');
|
|
558
|
+
expect(() => JSON.parse(readFileSync(r.path as string, 'utf8'))).not.toThrow();
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
describe('the asset pipeline is actually WIRED into --frames', () => {
|
|
563
|
+
const URL_D = 'https://www.figma.com/design/dGNzRC2kmrmGnOxaBa0RI7/Fixtures';
|
|
564
|
+
|
|
565
|
+
const VECTOR_DOC = {
|
|
566
|
+
document: {
|
|
567
|
+
id: '0:1',
|
|
568
|
+
name: 'Page 1',
|
|
569
|
+
type: 'CANVAS',
|
|
570
|
+
children: [
|
|
571
|
+
{
|
|
572
|
+
id: '1:1',
|
|
573
|
+
name: 'Frame with a mark',
|
|
574
|
+
type: 'FRAME',
|
|
575
|
+
absoluteBoundingBox: box(0, 0, 400, 300),
|
|
576
|
+
children: [
|
|
577
|
+
{
|
|
578
|
+
id: '2:1',
|
|
579
|
+
name: 'Mark wrapper',
|
|
580
|
+
type: 'GROUP',
|
|
581
|
+
absoluteBoundingBox: box(0, 0, 90, 72),
|
|
582
|
+
children: [
|
|
583
|
+
{ id: '2:2', name: 'v', type: 'VECTOR', absoluteBoundingBox: box(0, 0, 20, 20) },
|
|
584
|
+
{ id: '2:3', name: 'v', type: 'VECTOR', absoluteBoundingBox: box(20, 0, 20, 20) },
|
|
585
|
+
],
|
|
586
|
+
},
|
|
587
|
+
],
|
|
588
|
+
},
|
|
589
|
+
],
|
|
590
|
+
},
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
/** Answer the document, and decline to render anything. */
|
|
594
|
+
function stubDeclineRender() {
|
|
595
|
+
globalThis.fetch = mock(async (input: RequestInfo | URL) => {
|
|
596
|
+
const url = String(input);
|
|
597
|
+
if (url.includes('/v1/images/')) {
|
|
598
|
+
return new Response(JSON.stringify({ images: { '2:1': null } }), {
|
|
599
|
+
status: 200,
|
|
600
|
+
headers: { 'Content-Type': 'application/json' },
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
return new Response(JSON.stringify(VECTOR_DOC), {
|
|
604
|
+
status: 200,
|
|
605
|
+
headers: { 'Content-Type': 'application/json' },
|
|
606
|
+
});
|
|
607
|
+
}) as unknown as typeof fetch;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
test('a collapsed cluster reaches the image endpoint as ONE batched request', async () => {
|
|
611
|
+
const seen: string[] = [];
|
|
612
|
+
globalThis.fetch = mock(async (input: RequestInfo | URL) => {
|
|
613
|
+
const url = String(input);
|
|
614
|
+
seen.push(url);
|
|
615
|
+
if (url.includes('/v1/images/')) {
|
|
616
|
+
return new Response(JSON.stringify({ images: { '2:1': null } }), {
|
|
617
|
+
status: 200,
|
|
618
|
+
headers: { 'Content-Type': 'application/json' },
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
return new Response(JSON.stringify(VECTOR_DOC), {
|
|
622
|
+
status: 200,
|
|
623
|
+
headers: { 'Content-Type': 'application/json' },
|
|
624
|
+
});
|
|
625
|
+
}) as unknown as typeof fetch;
|
|
626
|
+
|
|
627
|
+
const r = await importFrames({ url: URL_D, root: sandbox });
|
|
628
|
+
const imageCalls = seen.filter((u) => u.includes('/v1/images/'));
|
|
629
|
+
const byFormat = (f: string) =>
|
|
630
|
+
imageCalls.filter((u) => new URL(u).searchParams.get('format') === f);
|
|
631
|
+
|
|
632
|
+
// ONE call for the whole cluster — the collapse is what keeps this at 1
|
|
633
|
+
// instead of 2 (or, on a real logo, 14). Asserted PER FORMAT, because this
|
|
634
|
+
// stub returns `{ '2:1': null }`, i.e. Figma DECLINING to render, which the
|
|
635
|
+
// later SVG→PNG degradation path answers with a second, legitimate request.
|
|
636
|
+
// The original `imageCalls.length === 1` conflated "the collapse batched the
|
|
637
|
+
// cluster" with "nothing retried", and went red in main when the degradation
|
|
638
|
+
// landed — the same incomplete change that left `asset-degraded` outside the
|
|
639
|
+
// Disposition union (DDR-219 D9). The collapse claim is what this test owns.
|
|
640
|
+
expect(byFormat('svg')).toHaveLength(1);
|
|
641
|
+
expect(new URL(byFormat('svg')[0]).searchParams.get('ids')).toBe('2:1');
|
|
642
|
+
// The degradation is intended behaviour, so it is asserted rather than
|
|
643
|
+
// tolerated: one retry, for the same collapsed id, never one per leaf.
|
|
644
|
+
expect(byFormat('png')).toHaveLength(1);
|
|
645
|
+
expect(new URL(byFormat('png')[0]).searchParams.get('ids')).toBe('2:1');
|
|
646
|
+
expect(r.pendingExports).toBe(1);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
test('an unresolved asset is REPORTED and its placeholder survives in the source', async () => {
|
|
650
|
+
stubDeclineRender();
|
|
651
|
+
const r = await importFrames({ url: URL_D, root: sandbox });
|
|
652
|
+
expect(r.resolvedAssets).toBe(0);
|
|
653
|
+
const tsx = readFileSync(r.written[0].path as string, 'utf8');
|
|
654
|
+
// A visibly broken image beats a silently missing element.
|
|
655
|
+
expect(tsx).toContain('/assets/pending-');
|
|
656
|
+
const dispositions = r.reports.flatMap((rep) => rep.entries.map((e) => e.disposition));
|
|
657
|
+
expect(dispositions).toContain('asset-skipped');
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
test('the emitted canvas NEVER references a figma.com URL, resolved or not', async () => {
|
|
661
|
+
stubDeclineRender();
|
|
662
|
+
const r = await importFrames({ url: URL_D, root: sandbox });
|
|
663
|
+
const tsx = readFileSync(r.written[0].path as string, 'utf8');
|
|
664
|
+
expect(tsx).not.toContain('figma.com');
|
|
665
|
+
expect(tsx).not.toMatch(/src="https?:/);
|
|
666
|
+
});
|
|
667
|
+
});
|