@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,189 @@
|
|
|
1
|
+
// `_fetch-asset.mjs` — the gate's pure decision functions, plus the three
|
|
2
|
+
// DDR-216 additions. Standing tests, not optional hardening: DDR-216's
|
|
3
|
+
// Consequences names each of these by hand because this is a SHARED,
|
|
4
|
+
// security-reviewed helper whose OTHER caller (the DDR-147 moodboard lane) has
|
|
5
|
+
// no host allowlist at all.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
|
|
9
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
FetchAssetError,
|
|
15
|
+
fetchAsset,
|
|
16
|
+
parseHttpsTarget,
|
|
17
|
+
sniffImageExt,
|
|
18
|
+
sniffStagedKind,
|
|
19
|
+
} from '../bin/_fetch-asset.mjs';
|
|
20
|
+
|
|
21
|
+
const FIGMA_HOSTS = ['figma.com', 'figma-alpha-api.s3.us-west-2.amazonaws.com'];
|
|
22
|
+
|
|
23
|
+
const png = () => Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);
|
|
24
|
+
const svg = (s = '<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>') => Buffer.from(s, 'utf8');
|
|
25
|
+
|
|
26
|
+
describe('the host allowlist is exact-or-dotted-suffix, never endsWith', () => {
|
|
27
|
+
test.each([
|
|
28
|
+
['the apex', 'https://figma.com/x'],
|
|
29
|
+
['a real subdomain', 'https://www.figma.com/x'],
|
|
30
|
+
['a deep subdomain', 'https://a.b.figma.com/x'],
|
|
31
|
+
['the render bucket, exactly', 'https://figma-alpha-api.s3.us-west-2.amazonaws.com/x'],
|
|
32
|
+
])('admits %s', (_label, url) => {
|
|
33
|
+
expect(() => parseHttpsTarget(url, { allowHosts: FIGMA_HOSTS })).not.toThrow();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test.each([
|
|
37
|
+
// The classic suffix bug: a bare endsWith('figma.com') admits this.
|
|
38
|
+
['a suffix look-alike', 'https://evil-figma.com/x'],
|
|
39
|
+
['figma.com as a prefix of another domain', 'https://figma.com.evil.tld/x'],
|
|
40
|
+
['an unrelated host', 'https://evil.tld/x'],
|
|
41
|
+
// A bare endsWith('.amazonaws.com') would admit EVERY S3 bucket on earth.
|
|
42
|
+
['a different S3 bucket', 'https://attacker-bucket.s3.us-west-2.amazonaws.com/x'],
|
|
43
|
+
['the bare bucket parent', 'https://s3.us-west-2.amazonaws.com/x'],
|
|
44
|
+
])('refuses %s', (_label, url) => {
|
|
45
|
+
expect(() => parseHttpsTarget(url, { allowHosts: FIGMA_HOSTS })).toThrow(FetchAssetError);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("an absent allowlist keeps today's unrestricted behaviour", () => {
|
|
49
|
+
// The moodboard/research lane passes no allowlist and must be untouched.
|
|
50
|
+
expect(() => parseHttpsTarget('https://upload.wikimedia.org/x.png')).not.toThrow();
|
|
51
|
+
expect(parseHttpsTarget('https://anything.example/x').host).toBe('anything.example');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('a refusal names the host as a charset-validated token, never raw (D10)', () => {
|
|
55
|
+
try {
|
|
56
|
+
parseHttpsTarget('https://evil.tld/x', { allowHosts: FIGMA_HOSTS });
|
|
57
|
+
throw new Error('expected a rejection');
|
|
58
|
+
} catch (err) {
|
|
59
|
+
expect((err as Error).message).toMatch(/^host not in this lane's allowlist: [a-z0-9.-]+$/);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('port pinning is opt-in, and is new logic', () => {
|
|
65
|
+
test('the default still accepts any valid port (unchanged behaviour)', () => {
|
|
66
|
+
expect(parseHttpsTarget('https://example.test:8443/x').port).toBe(8443);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('the pinned lane refuses a non-443 port', () => {
|
|
70
|
+
expect(() => parseHttpsTarget('https://figma.com:8443/x', { pinPort443: true })).toThrow(
|
|
71
|
+
FetchAssetError
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('the pinned lane accepts 443, explicit or implicit', () => {
|
|
76
|
+
expect(parseHttpsTarget('https://figma.com/x', { pinPort443: true }).port).toBe(443);
|
|
77
|
+
expect(parseHttpsTarget('https://figma.com:443/x', { pinPort443: true }).port).toBe(443);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('the allowlist NARROWS the gate — it never replaces the rest of it', () => {
|
|
82
|
+
test.each([
|
|
83
|
+
['non-https', 'http://figma.com/x'],
|
|
84
|
+
['embedded credentials', 'https://user:pw@figma.com/x'],
|
|
85
|
+
['a file URL', 'file:///etc/passwd'],
|
|
86
|
+
])('%s is still refused even on an allowlisted host', (_label, url) => {
|
|
87
|
+
expect(() => parseHttpsTarget(url, { allowHosts: FIGMA_HOSTS })).toThrow(FetchAssetError);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('sniffImageExt is NOT widened — the moodboard lane depends on it', () => {
|
|
92
|
+
test.each([
|
|
93
|
+
['an SVG', svg()],
|
|
94
|
+
['an XML-prologue SVG', svg('<?xml version="1.0"?><svg></svg>')],
|
|
95
|
+
['an SVG comment prologue', svg('<!-- c --><svg></svg>')],
|
|
96
|
+
['HTML', Buffer.from('<!doctype html><html></html>', 'utf8')],
|
|
97
|
+
['a script', Buffer.from('alert(1)', 'utf8')],
|
|
98
|
+
])('still returns null for %s', (_label, bytes) => {
|
|
99
|
+
// DDR-216 D11 step 6: this is THE test that makes the tempting two-line
|
|
100
|
+
// "just add svg to the sniff" fix fail CI. If it ever goes green for SVG,
|
|
101
|
+
// arbitrary remote vectors can land in the versioned, peer-synced assets/
|
|
102
|
+
// tree of a lane this feature never touches.
|
|
103
|
+
expect(sniffImageExt(bytes)).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('still accepts a real raster', () => {
|
|
107
|
+
expect(sniffImageExt(png())).toBe('png');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('sniffStagedKind — the --raw-out accept set', () => {
|
|
112
|
+
test('accepts everything sniffImageExt accepts', () => {
|
|
113
|
+
expect(sniffStagedKind(png())).toBe('png');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('additionally accepts SVG, which is the whole point of the mode', () => {
|
|
117
|
+
expect(sniffStagedKind(svg())).toBe('svg');
|
|
118
|
+
expect(sniffStagedKind(svg('<?xml version="1.0"?><svg></svg>'))).toBe('svg');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('still REFUSES the things a bypass would let through', () => {
|
|
122
|
+
// `--raw-out` must never mean "skip the type gate" — that would turn the one
|
|
123
|
+
// reviewable downloader into an unsniffed one for the next caller.
|
|
124
|
+
expect(sniffStagedKind(Buffer.from('<!doctype html><html></html>', 'utf8'))).toBeNull();
|
|
125
|
+
expect(sniffStagedKind(Buffer.from('alert(1)', 'utf8'))).toBeNull();
|
|
126
|
+
expect(sniffStagedKind(Buffer.from('PKzipbytes', 'utf8'))).toBeNull();
|
|
127
|
+
expect(sniffStagedKind(Buffer.from('', 'utf8'))).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('only the HEAD is probed — a late `<svg` does not rescue a blob', () => {
|
|
131
|
+
const late = Buffer.concat([Buffer.alloc(512, 0x41), svg()]);
|
|
132
|
+
expect(sniffStagedKind(late)).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('--raw-out containment survives an OS-symlinked temp root', () => {
|
|
137
|
+
test('a legitimate staging path under os.tmpdir() is ACCEPTED', async () => {
|
|
138
|
+
// The bug this pins: `realpathSync` on the root but not the target compares
|
|
139
|
+
// `/private/var/…` against `/var/…` on macOS (both `/var` and `/tmp` are
|
|
140
|
+
// OS-level symlinks) and rejects every legitimate path. It shipped, and the
|
|
141
|
+
// first real import skipped all 29 of its assets with a generic
|
|
142
|
+
// "download or sanitize failed". Same trap DDR-172 Decision 1 documents.
|
|
143
|
+
const dir = mkdtempSync(join(tmpdir(), 'raw-out-probe-'));
|
|
144
|
+
try {
|
|
145
|
+
const out = join(dir, 'staged.png');
|
|
146
|
+
// Reaches the network gate rather than the containment check: any throw
|
|
147
|
+
// must NOT be the containment one.
|
|
148
|
+
let err: unknown;
|
|
149
|
+
try {
|
|
150
|
+
await fetchAsset({
|
|
151
|
+
url: 'https://figma.com/definitely-not-real.png',
|
|
152
|
+
root: dir,
|
|
153
|
+
rawOut: out,
|
|
154
|
+
rawRoot: dir,
|
|
155
|
+
allowHosts: FIGMA_HOSTS,
|
|
156
|
+
maxTime: 1,
|
|
157
|
+
});
|
|
158
|
+
} catch (e) {
|
|
159
|
+
err = e;
|
|
160
|
+
}
|
|
161
|
+
expect((err as Error | undefined)?.message ?? '').not.toContain('--raw-out must resolve');
|
|
162
|
+
} finally {
|
|
163
|
+
rmSync(dir, { recursive: true, force: true });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('a path OUTSIDE the declared root is still refused', async () => {
|
|
168
|
+
const dir = mkdtempSync(join(tmpdir(), 'raw-out-probe-'));
|
|
169
|
+
try {
|
|
170
|
+
await expect(
|
|
171
|
+
fetchAsset({
|
|
172
|
+
url: 'https://figma.com/x.png',
|
|
173
|
+
root: dir,
|
|
174
|
+
rawOut: join(dir, '..', 'escaped.png'),
|
|
175
|
+
rawRoot: dir,
|
|
176
|
+
allowHosts: FIGMA_HOSTS,
|
|
177
|
+
})
|
|
178
|
+
).rejects.toThrow(/--raw-out must resolve inside/);
|
|
179
|
+
} finally {
|
|
180
|
+
rmSync(dir, { recursive: true, force: true });
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('--raw-out without --raw-root is refused outright', async () => {
|
|
185
|
+
await expect(
|
|
186
|
+
fetchAsset({ url: 'https://figma.com/x.png', root: '/tmp', rawOut: '/tmp/x.png' })
|
|
187
|
+
).rejects.toThrow(/requires --raw-root/);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
// `maude design import-figma --explode` — the DDR-219 D8 write model.
|
|
2
|
+
//
|
|
3
|
+
// Every clause of D8 is a REFUSAL, so most of this file is refusals. The one
|
|
4
|
+
// happy path drives the whole verb against a stubbed local MCP server and a
|
|
5
|
+
// stubbed asset lane, and then asserts the two things that make a codegen
|
|
6
|
+
// artboard honest: it renders as JSX, and the file says where it came from.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { parseSync } from 'oxc-parser';
|
|
13
|
+
import { explodeArtboard, ImportFigmaError } from '../bin/_import-figma.mjs';
|
|
14
|
+
import { CodegenSession } from '../figma/codegen-client.ts';
|
|
15
|
+
|
|
16
|
+
// The asset constant deliberately omits the Dev Mode PORT: the reachability
|
|
17
|
+
// guard (`cli/lib/figma-codegen-reachability.test.mjs`) bans that literal
|
|
18
|
+
// outside the codegen client and its own tests, and a guard is only worth having
|
|
19
|
+
// while it stays absolute. The port is irrelevant to what this file asserts —
|
|
20
|
+
// that the URL is DISCARDED and the artwork comes back through the node-id lane.
|
|
21
|
+
// The byte-faithful capture lives in `figma/from-codegen.test.ts`.
|
|
22
|
+
const MODULE = `const imgIcon = "http://localhost/assets/aaaa.svg";
|
|
23
|
+
|
|
24
|
+
export default function Cover() {
|
|
25
|
+
return (
|
|
26
|
+
<div className="bg-white flex flex-col size-full" data-node-id="6:907" data-name="Cover">
|
|
27
|
+
<p className="text-[16px] font-bold">Hello</p>
|
|
28
|
+
<div className="size-[24px]" data-node-id="6:908" data-name="Mark">
|
|
29
|
+
<img alt="" className="size-full" src={imgIcon} />
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}`;
|
|
34
|
+
|
|
35
|
+
const CANVAS = `// Imported from Figma — THIRD-PARTY CONTENT (DDR-216).
|
|
36
|
+
import { DCArtboard, DesignCanvas } from '@maude/canvas-lib';
|
|
37
|
+
|
|
38
|
+
export default function Canvas() {
|
|
39
|
+
return (
|
|
40
|
+
<DesignCanvas>
|
|
41
|
+
<DCArtboard id="node-6-907" label="Cover" width={1440} height={1024} kind="digital" layout="block" background="#ffffff">
|
|
42
|
+
<img src="/assets/aaa.svg" alt="Cover" data-dc-element="Cover" data-figma-node="6:907" style={{ width: "100%", height: "100%" }} />
|
|
43
|
+
</DCArtboard>
|
|
44
|
+
<DCArtboard id="node-6-910" label="Second" width={800} height={600} kind="digital" layout="block" background="#ffffff">
|
|
45
|
+
<img src="/assets/bbb.svg" alt="Second" data-figma-node="6:910" style={{ width: "100%", height: "100%" }} />
|
|
46
|
+
</DCArtboard>
|
|
47
|
+
</DesignCanvas>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
const META = {
|
|
53
|
+
kind: 'imported-figma',
|
|
54
|
+
mode: 'render',
|
|
55
|
+
source: { fileKey: '2H6a9YUgPAu0AGdEiwP895', nodeId: '0:1', importedAt: '2026-08-11T00:00:00Z' },
|
|
56
|
+
layout: { artboards: [{ id: 'node-6-907', x: 0, y: 0 }] },
|
|
57
|
+
figma: {
|
|
58
|
+
frames: [
|
|
59
|
+
{
|
|
60
|
+
id: 'node-6-907',
|
|
61
|
+
nodeId: '6:907',
|
|
62
|
+
type: 'FRAME',
|
|
63
|
+
label: 'Cover',
|
|
64
|
+
w: 1440,
|
|
65
|
+
h: 1024,
|
|
66
|
+
route: 'render',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: 'node-6-910',
|
|
70
|
+
nodeId: '6:910',
|
|
71
|
+
type: 'FRAME',
|
|
72
|
+
label: 'Second',
|
|
73
|
+
w: 800,
|
|
74
|
+
h: 600,
|
|
75
|
+
route: 'render',
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function sse(payload: unknown): Response {
|
|
82
|
+
return new Response(`event: message\ndata: ${JSON.stringify(payload)}\n\n`, {
|
|
83
|
+
status: 200,
|
|
84
|
+
headers: { 'content-type': 'text/event-stream', 'mcp-session-id': 's' },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A stubbed asset lane. The REAL one is exercised by `figma/assets.test.ts`; the
|
|
90
|
+
* codegen route reuses it verbatim (D6 — re-fetch by node id), and wiring the
|
|
91
|
+
* real one in here would spend the developer's actual Figma PAT against a
|
|
92
|
+
* fixture file key.
|
|
93
|
+
*/
|
|
94
|
+
function stubAssets(refs: Record<string, string> = {}) {
|
|
95
|
+
return async (
|
|
96
|
+
_fileKey: string,
|
|
97
|
+
requests: ReadonlyArray<{ nodeId: string; placeholder: string }>
|
|
98
|
+
) => {
|
|
99
|
+
const rewrites = new Map<string, string>();
|
|
100
|
+
const resolved: Array<{ nodeId: string }> = [];
|
|
101
|
+
for (const r of requests) {
|
|
102
|
+
const ref = refs[r.nodeId];
|
|
103
|
+
if (!ref) continue;
|
|
104
|
+
rewrites.set(r.placeholder, ref);
|
|
105
|
+
resolved.push({ nodeId: r.nodeId });
|
|
106
|
+
}
|
|
107
|
+
return { resolved, rewrites, totalBytes: 0 };
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A local Dev Mode server that answers exactly like the measured one. */
|
|
112
|
+
function mcpSession(code = MODULE) {
|
|
113
|
+
const fetchImpl = async (_url: string, init: RequestInit): Promise<Response> => {
|
|
114
|
+
const body = JSON.parse(String(init.body)) as { method: string; id: number };
|
|
115
|
+
if (body.method === 'initialize') {
|
|
116
|
+
return sse({ result: { serverInfo: { name: 'Figma Dev Mode MCP Server' } }, id: body.id });
|
|
117
|
+
}
|
|
118
|
+
if (body.method === 'notifications/initialized') return new Response('', { status: 202 });
|
|
119
|
+
if (body.method === 'tools/list') {
|
|
120
|
+
return sse({ result: { tools: [{ name: 'get_design_context' }] }, id: body.id });
|
|
121
|
+
}
|
|
122
|
+
return sse({ result: { content: [{ type: 'text', text: code }] }, id: body.id });
|
|
123
|
+
};
|
|
124
|
+
return new CodegenSession({ fetchImpl });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function project(over: { meta?: unknown; canvas?: string } = {}) {
|
|
128
|
+
const root = mkdtempSync(join(tmpdir(), 'maude-explode-'));
|
|
129
|
+
const uiDir = join(root, '.design', 'ui', 'figma-2h6a9yug');
|
|
130
|
+
mkdirSync(uiDir, { recursive: true });
|
|
131
|
+
writeFileSync(join(uiDir, 'Page 1.tsx'), over.canvas ?? CANVAS);
|
|
132
|
+
writeFileSync(join(uiDir, 'Page 1.meta.json'), `${JSON.stringify(over.meta ?? META, null, 2)}\n`);
|
|
133
|
+
return { root, canvasRel: 'ui/figma-2h6a9yug/Page 1.tsx' };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const BASE = { artboardId: 'node-6-907', designRootRel: '.design' };
|
|
137
|
+
|
|
138
|
+
describe('the happy path', () => {
|
|
139
|
+
test('one artboard becomes editable JSX, and only that one', async () => {
|
|
140
|
+
const { root, canvasRel } = project();
|
|
141
|
+
try {
|
|
142
|
+
const r = await explodeArtboard({
|
|
143
|
+
root,
|
|
144
|
+
canvasRel,
|
|
145
|
+
...BASE,
|
|
146
|
+
session: mcpSession(),
|
|
147
|
+
resolveAssetsImpl: stubAssets({ '6:908': '/assets/deadbeef.svg' }),
|
|
148
|
+
});
|
|
149
|
+
expect(r.written).toBe(true);
|
|
150
|
+
|
|
151
|
+
const out = readFileSync(join(root, '.design', canvasRel), 'utf8');
|
|
152
|
+
// The exploded artboard is now real JSX…
|
|
153
|
+
expect(out).toContain("{'Hello'}");
|
|
154
|
+
expect(out).toContain('label="Cover · codegen"');
|
|
155
|
+
// …and the other artboard is untouched.
|
|
156
|
+
expect(out).toContain('label="Second"');
|
|
157
|
+
expect(out).toContain('src="/assets/bbb.svg"');
|
|
158
|
+
// D6 — the response's own asset URL was discarded and the artwork came
|
|
159
|
+
// back through the node-id lane instead.
|
|
160
|
+
expect(out).toContain('src="/assets/deadbeef.svg"');
|
|
161
|
+
expect(out).not.toContain('localhost');
|
|
162
|
+
// The canvas still parses, which is the whole bar.
|
|
163
|
+
expect(parseSync('c.tsx', out, { sourceType: 'module' }).errors).toHaveLength(0);
|
|
164
|
+
} finally {
|
|
165
|
+
rmSync(root, { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('provenance lands in the FILE, because the chip cannot express mixed provenance', async () => {
|
|
170
|
+
const { root, canvasRel } = project();
|
|
171
|
+
try {
|
|
172
|
+
const r = await explodeArtboard({
|
|
173
|
+
root,
|
|
174
|
+
canvasRel,
|
|
175
|
+
...BASE,
|
|
176
|
+
session: mcpSession(),
|
|
177
|
+
resolveAssetsImpl: stubAssets({ '6:908': '/assets/deadbeef.svg' }),
|
|
178
|
+
});
|
|
179
|
+
const out = readFileSync(join(root, '.design', canvasRel), 'utf8');
|
|
180
|
+
// `design-system-keeper`, the critic panel and `/design:edit` read the
|
|
181
|
+
// FILE and never see a tree chip (DDR-219 D7).
|
|
182
|
+
expect(out).toContain('GENERATED BY FIGMA, NOT BY MAUDE');
|
|
183
|
+
expect(out).toContain('NOT reproducible');
|
|
184
|
+
expect(out).toContain(r.responseSha256);
|
|
185
|
+
|
|
186
|
+
const meta = JSON.parse(
|
|
187
|
+
readFileSync(join(root, '.design', 'ui/figma-2h6a9yug/Page 1.meta.json'), 'utf8')
|
|
188
|
+
);
|
|
189
|
+
const frames = meta.figma.frames as Array<Record<string, unknown>>;
|
|
190
|
+
expect(frames[0]).toMatchObject({
|
|
191
|
+
id: 'node-6-907',
|
|
192
|
+
route: 'codegen',
|
|
193
|
+
endpoint: 'local',
|
|
194
|
+
tool: 'get_design_context',
|
|
195
|
+
responseSha256: r.responseSha256,
|
|
196
|
+
});
|
|
197
|
+
// The sibling frame's provenance must NOT have been rewritten.
|
|
198
|
+
expect(frames[1].route).toBe('render');
|
|
199
|
+
} finally {
|
|
200
|
+
rmSync(root, { recursive: true, force: true });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('the prior canvas is snapshotted before the write', async () => {
|
|
205
|
+
const { root, canvasRel } = project();
|
|
206
|
+
try {
|
|
207
|
+
await explodeArtboard({
|
|
208
|
+
root,
|
|
209
|
+
canvasRel,
|
|
210
|
+
...BASE,
|
|
211
|
+
session: mcpSession(),
|
|
212
|
+
resolveAssetsImpl: stubAssets({ '6:908': '/assets/deadbeef.svg' }),
|
|
213
|
+
});
|
|
214
|
+
const histDir = join(root, '.design', '_history', 'ui-figma-2h6a9yug-page_1');
|
|
215
|
+
const files = readdirSync(histDir);
|
|
216
|
+
expect(files.some((f) => f.endsWith('.tsx'))).toBe(true);
|
|
217
|
+
const snap = readFileSync(join(histDir, files.find((f) => f.endsWith('.tsx'))!), 'utf8');
|
|
218
|
+
expect(snap).toBe(CANVAS);
|
|
219
|
+
} finally {
|
|
220
|
+
rmSync(root, { recursive: true, force: true });
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('--dry-run spends the codegen call and writes NOTHING', async () => {
|
|
225
|
+
const { root, canvasRel } = project();
|
|
226
|
+
try {
|
|
227
|
+
const r = await explodeArtboard({
|
|
228
|
+
root,
|
|
229
|
+
canvasRel,
|
|
230
|
+
...BASE,
|
|
231
|
+
dryRun: true,
|
|
232
|
+
session: mcpSession(),
|
|
233
|
+
resolveAssetsImpl: stubAssets({ '6:908': '/assets/deadbeef.svg' }),
|
|
234
|
+
});
|
|
235
|
+
expect(r.written).toBe(false);
|
|
236
|
+
expect(readFileSync(join(root, '.design', canvasRel), 'utf8')).toBe(CANVAS);
|
|
237
|
+
} finally {
|
|
238
|
+
rmSync(root, { recursive: true, force: true });
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe('the target is never the producer’s choice (D8 / DDR-216 D3)', () => {
|
|
244
|
+
const cases: Array<[string, (p: ReturnType<typeof project>) => Record<string, unknown>]> = [
|
|
245
|
+
['a canvas outside the design root', (_p) => ({ canvasRel: '../../../etc/passwd.tsx' })],
|
|
246
|
+
['an absolute path', () => ({ canvasRel: '/etc/passwd.tsx' })],
|
|
247
|
+
['a non-existent canvas', () => ({ canvasRel: 'ui/nope.tsx' })],
|
|
248
|
+
['a bad artboard charset', (p) => ({ canvasRel: p.canvasRel, artboardId: '../../etc' })],
|
|
249
|
+
[
|
|
250
|
+
'an artboard not in figma.frames[]',
|
|
251
|
+
(p) => ({ canvasRel: p.canvasRel, artboardId: 'node-9-9' }),
|
|
252
|
+
],
|
|
253
|
+
];
|
|
254
|
+
|
|
255
|
+
for (const [label, build] of cases) {
|
|
256
|
+
test(`refuses: ${label}`, async () => {
|
|
257
|
+
const p = project();
|
|
258
|
+
try {
|
|
259
|
+
await expect(
|
|
260
|
+
explodeArtboard({
|
|
261
|
+
root: p.root,
|
|
262
|
+
...BASE,
|
|
263
|
+
canvasRel: p.canvasRel,
|
|
264
|
+
...build(p),
|
|
265
|
+
session: mcpSession(),
|
|
266
|
+
resolveAssetsImpl: stubAssets({ '6:908': '/assets/deadbeef.svg' }),
|
|
267
|
+
})
|
|
268
|
+
).rejects.toBeInstanceOf(ImportFigmaError);
|
|
269
|
+
} finally {
|
|
270
|
+
rmSync(p.root, { recursive: true, force: true });
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
test('refuses to CREATE a canvas — an explode is an edit, never a mint', async () => {
|
|
276
|
+
const p = project();
|
|
277
|
+
try {
|
|
278
|
+
rmSync(join(p.root, '.design', p.canvasRel));
|
|
279
|
+
await expect(
|
|
280
|
+
explodeArtboard({
|
|
281
|
+
root: p.root,
|
|
282
|
+
canvasRel: p.canvasRel,
|
|
283
|
+
...BASE,
|
|
284
|
+
session: mcpSession(),
|
|
285
|
+
resolveAssetsImpl: stubAssets({ '6:908': '/assets/deadbeef.svg' }),
|
|
286
|
+
})
|
|
287
|
+
).rejects.toThrow(/no such imported canvas/);
|
|
288
|
+
} finally {
|
|
289
|
+
rmSync(p.root, { recursive: true, force: true });
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('refuses a canvas that is not imported-figma', async () => {
|
|
294
|
+
const p = project({ meta: { ...META, kind: 'digital' } });
|
|
295
|
+
try {
|
|
296
|
+
await expect(
|
|
297
|
+
explodeArtboard({
|
|
298
|
+
root: p.root,
|
|
299
|
+
canvasRel: p.canvasRel,
|
|
300
|
+
...BASE,
|
|
301
|
+
session: mcpSession(),
|
|
302
|
+
resolveAssetsImpl: stubAssets({ '6:908': '/assets/deadbeef.svg' }),
|
|
303
|
+
})
|
|
304
|
+
).rejects.toThrow(/not an imported-figma canvas/);
|
|
305
|
+
} finally {
|
|
306
|
+
rmSync(p.root, { recursive: true, force: true });
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('refuses to re-explode an artboard that is already codegen', async () => {
|
|
311
|
+
const meta = structuredClone(META);
|
|
312
|
+
meta.figma.frames[0].route = 'codegen';
|
|
313
|
+
const p = project({ meta });
|
|
314
|
+
try {
|
|
315
|
+
await expect(
|
|
316
|
+
explodeArtboard({
|
|
317
|
+
root: p.root,
|
|
318
|
+
canvasRel: p.canvasRel,
|
|
319
|
+
...BASE,
|
|
320
|
+
session: mcpSession(),
|
|
321
|
+
resolveAssetsImpl: stubAssets({ '6:908': '/assets/deadbeef.svg' }),
|
|
322
|
+
})
|
|
323
|
+
).rejects.toThrow(/already codegen/);
|
|
324
|
+
} finally {
|
|
325
|
+
rmSync(p.root, { recursive: true, force: true });
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
describe('the open-document coupling (probe finding 1 / residual 8)', () => {
|
|
331
|
+
test('a node id that resolves in the WRONG document is caught by the name cross-check', async () => {
|
|
332
|
+
// `get_design_context` takes NO file key — it reads whatever Figma has open —
|
|
333
|
+
// and node ids are not unique across files. Every other control passes here:
|
|
334
|
+
// the id matches, the canvas is right, the write model is satisfied. Only
|
|
335
|
+
// the name says the document is wrong.
|
|
336
|
+
const p = project();
|
|
337
|
+
try {
|
|
338
|
+
const wrongDoc = MODULE.replace('data-name="Cover"', 'data-name="Checkout step 2"');
|
|
339
|
+
await expect(
|
|
340
|
+
explodeArtboard({
|
|
341
|
+
root: p.root,
|
|
342
|
+
canvasRel: p.canvasRel,
|
|
343
|
+
...BASE,
|
|
344
|
+
session: mcpSession(wrongDoc),
|
|
345
|
+
resolveAssetsImpl: stubAssets(),
|
|
346
|
+
})
|
|
347
|
+
).rejects.toThrow(/does not match this canvas/);
|
|
348
|
+
// Nothing was written.
|
|
349
|
+
expect(readFileSync(join(p.root, '.design', p.canvasRel), 'utf8')).toBe(CANVAS);
|
|
350
|
+
} finally {
|
|
351
|
+
rmSync(p.root, { recursive: true, force: true });
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test('the refusal names NEITHER document’s text — stdout is code-owned (D10)', async () => {
|
|
356
|
+
const p = project();
|
|
357
|
+
try {
|
|
358
|
+
const wrongDoc = MODULE.replace('data-name="Cover"', 'data-name="Secret Client Project"');
|
|
359
|
+
await explodeArtboard({
|
|
360
|
+
root: p.root,
|
|
361
|
+
canvasRel: p.canvasRel,
|
|
362
|
+
...BASE,
|
|
363
|
+
session: mcpSession(wrongDoc),
|
|
364
|
+
resolveAssetsImpl: stubAssets(),
|
|
365
|
+
});
|
|
366
|
+
throw new Error('should have refused');
|
|
367
|
+
} catch (err) {
|
|
368
|
+
expect((err as Error).message).not.toContain('Secret Client Project');
|
|
369
|
+
} finally {
|
|
370
|
+
rmSync(p.root, { recursive: true, force: true });
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test('--confirm-document is the escape hatch for a frame renamed in Figma', async () => {
|
|
375
|
+
const p = project();
|
|
376
|
+
try {
|
|
377
|
+
const renamed = MODULE.replace('data-name="Cover"', 'data-name="Cover v2"');
|
|
378
|
+
const r = await explodeArtboard({
|
|
379
|
+
root: p.root,
|
|
380
|
+
canvasRel: p.canvasRel,
|
|
381
|
+
...BASE,
|
|
382
|
+
confirmDocument: true,
|
|
383
|
+
session: mcpSession(renamed),
|
|
384
|
+
resolveAssetsImpl: stubAssets(),
|
|
385
|
+
});
|
|
386
|
+
expect(r.written).toBe(true);
|
|
387
|
+
} finally {
|
|
388
|
+
rmSync(p.root, { recursive: true, force: true });
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test('a different NODE than the one requested is always a refusal', async () => {
|
|
393
|
+
const p = project();
|
|
394
|
+
try {
|
|
395
|
+
const otherNode = MODULE.replace('data-node-id="6:907"', 'data-node-id="9:999"');
|
|
396
|
+
await expect(
|
|
397
|
+
explodeArtboard({
|
|
398
|
+
root: p.root,
|
|
399
|
+
canvasRel: p.canvasRel,
|
|
400
|
+
...BASE,
|
|
401
|
+
confirmDocument: true,
|
|
402
|
+
session: mcpSession(otherNode),
|
|
403
|
+
resolveAssetsImpl: stubAssets(),
|
|
404
|
+
})
|
|
405
|
+
).rejects.toThrow(/different node/);
|
|
406
|
+
} finally {
|
|
407
|
+
rmSync(p.root, { recursive: true, force: true });
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
describe('the call ceiling is the verb’s, not the caller’s (T20b / D10)', () => {
|
|
413
|
+
test('one session serves one explode; a second refuses', async () => {
|
|
414
|
+
const p = project();
|
|
415
|
+
const session = mcpSession();
|
|
416
|
+
try {
|
|
417
|
+
await explodeArtboard({
|
|
418
|
+
root: p.root,
|
|
419
|
+
canvasRel: p.canvasRel,
|
|
420
|
+
...BASE,
|
|
421
|
+
session,
|
|
422
|
+
resolveAssetsImpl: stubAssets(),
|
|
423
|
+
});
|
|
424
|
+
await expect(
|
|
425
|
+
explodeArtboard({
|
|
426
|
+
root: p.root,
|
|
427
|
+
canvasRel: p.canvasRel,
|
|
428
|
+
artboardId: 'node-6-910',
|
|
429
|
+
designRootRel: '.design',
|
|
430
|
+
session,
|
|
431
|
+
resolveAssetsImpl: stubAssets(),
|
|
432
|
+
})
|
|
433
|
+
).rejects.toMatchObject({ kind: 'ceiling' });
|
|
434
|
+
} finally {
|
|
435
|
+
rmSync(p.root, { recursive: true, force: true });
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
});
|