@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,464 @@
|
|
|
1
|
+
// figma/assets.ts — batching, caps, and the fail-closed composition (T8).
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
4
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
type AssetRequest,
|
|
10
|
+
applyRewrites,
|
|
11
|
+
batchIds,
|
|
12
|
+
FIGMA_ASSET_HOSTS,
|
|
13
|
+
FIGMA_ASSET_MAX_BYTES,
|
|
14
|
+
FIGMA_SVG_MAX_BYTES,
|
|
15
|
+
MAX_ASSETS_PER_IMPORT,
|
|
16
|
+
MAX_SVG_PROMOTE_CHUNK,
|
|
17
|
+
type ResolveDeps,
|
|
18
|
+
renderKey,
|
|
19
|
+
resolveAssets,
|
|
20
|
+
} from './assets.ts';
|
|
21
|
+
import { ImportReport } from './sanitize.ts';
|
|
22
|
+
|
|
23
|
+
const KEY = 'dGNzRC2kmrmGnOxaBa0RI7';
|
|
24
|
+
let keysDir: string;
|
|
25
|
+
let realFetch: typeof fetch;
|
|
26
|
+
let imageCalls: URL[] = [];
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
keysDir = mkdtempSync(join(tmpdir(), 'maude-figma-assets-keys-'));
|
|
30
|
+
const keysPath = join(keysDir, 'keys.json');
|
|
31
|
+
writeFileSync(keysPath, JSON.stringify({ keys: { figma: 'figd_x'.padEnd(24, 'y') } }), {
|
|
32
|
+
mode: 0o600,
|
|
33
|
+
});
|
|
34
|
+
process.env.MAUDE_GEN_KEYS_PATH = keysPath;
|
|
35
|
+
imageCalls = [];
|
|
36
|
+
realFetch = globalThis.fetch;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
globalThis.fetch = realFetch;
|
|
41
|
+
rmSync(keysDir, { recursive: true, force: true });
|
|
42
|
+
delete process.env.MAUDE_GEN_KEYS_PATH;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** Stub `/v1/images` — answers a URL for every requested id. */
|
|
46
|
+
function stubImages(answer: (id: string) => string | null = (id) => `https://figma.com/r/${id}`) {
|
|
47
|
+
globalThis.fetch = mock(async (input: RequestInfo | URL) => {
|
|
48
|
+
const url = new URL(String(input));
|
|
49
|
+
imageCalls.push(url);
|
|
50
|
+
const ids = (url.searchParams.get('ids') ?? '').split(',').filter(Boolean);
|
|
51
|
+
const images: Record<string, string | null> = {};
|
|
52
|
+
for (const id of ids) images[id] = answer(id);
|
|
53
|
+
return new Response(JSON.stringify({ images }), {
|
|
54
|
+
status: 200,
|
|
55
|
+
headers: { 'Content-Type': 'application/json' },
|
|
56
|
+
});
|
|
57
|
+
}) as unknown as typeof fetch;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface Recorder {
|
|
61
|
+
staged: string[];
|
|
62
|
+
discarded: string[];
|
|
63
|
+
promoted: string[];
|
|
64
|
+
caps: number[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function makeDeps(over: Partial<ResolveDeps> = {}): { deps: ResolveDeps; rec: Recorder } {
|
|
68
|
+
const rec: Recorder = { staged: [], discarded: [], promoted: [], caps: [] };
|
|
69
|
+
const deps: ResolveDeps = {
|
|
70
|
+
stagingPath: (nodeId, ext) => `/outside/staging/${nodeId.replace(/:/g, '-')}.${ext}`,
|
|
71
|
+
stage: async (_url, outPath, maxBytes) => {
|
|
72
|
+
rec.staged.push(outPath);
|
|
73
|
+
rec.caps.push(maxBytes);
|
|
74
|
+
return { bytes: 1000, ext: outPath.endsWith('.svg') ? 'svg' : 'png' };
|
|
75
|
+
},
|
|
76
|
+
promote: async (stagedPath) => {
|
|
77
|
+
rec.promoted.push(stagedPath);
|
|
78
|
+
return { ref: `/assets/${stagedPath.split('/').pop()?.slice(0, 8)}.png` };
|
|
79
|
+
},
|
|
80
|
+
discard: (p) => rec.discarded.push(p),
|
|
81
|
+
...over,
|
|
82
|
+
};
|
|
83
|
+
return { deps, rec };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const req = (nodeId: string, format: 'svg' | 'png' = 'png'): AssetRequest => ({
|
|
87
|
+
nodeId,
|
|
88
|
+
format,
|
|
89
|
+
placeholder: `/assets/pending-${nodeId.replace(/:/g, '-')}.${format}`,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('batching — never one call per node', () => {
|
|
93
|
+
test('splits into endpoint-sized batches', () => {
|
|
94
|
+
const ids = Array.from({ length: 250 }, (_, i) => `1:${i}`);
|
|
95
|
+
const batches = batchIds(ids);
|
|
96
|
+
expect(batches.length).toBe(3);
|
|
97
|
+
expect(batches[0].length).toBe(100);
|
|
98
|
+
expect(batches[2].length).toBe(50);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('120 assets cost 2 image calls, not 120', async () => {
|
|
102
|
+
stubImages();
|
|
103
|
+
const { deps } = makeDeps();
|
|
104
|
+
const requests = Array.from({ length: 120 }, (_, i) => req(`1:${i}`));
|
|
105
|
+
await resolveAssets(KEY, requests, deps, new ImportReport());
|
|
106
|
+
expect(imageCalls.length).toBe(2);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('svg and png are requested separately (format is per-call)', async () => {
|
|
110
|
+
stubImages();
|
|
111
|
+
const { deps } = makeDeps();
|
|
112
|
+
await resolveAssets(KEY, [req('1:1', 'svg'), req('1:2', 'png')], deps, new ImportReport());
|
|
113
|
+
const formats = imageCalls.map((u) => u.searchParams.get('format')).sort();
|
|
114
|
+
expect(formats).toEqual(['png', 'svg']);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('no requests means no network call at all', async () => {
|
|
118
|
+
stubImages();
|
|
119
|
+
const { deps } = makeDeps();
|
|
120
|
+
const out = await resolveAssets(KEY, [], deps, new ImportReport());
|
|
121
|
+
expect(imageCalls.length).toBe(0);
|
|
122
|
+
expect(out.resolved).toEqual([]);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('caps are reported degradations, never silent', () => {
|
|
127
|
+
test('past the asset ceiling the extras are reported, not dropped quietly', async () => {
|
|
128
|
+
stubImages();
|
|
129
|
+
const { deps } = makeDeps();
|
|
130
|
+
const report = new ImportReport();
|
|
131
|
+
const requests = Array.from({ length: MAX_ASSETS_PER_IMPORT + 5 }, (_, i) => req(`1:${i}`));
|
|
132
|
+
await resolveAssets(KEY, requests, deps, report);
|
|
133
|
+
expect(report.count('asset-cap-reached')).toBe(5);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('the lane pins a byte cap far below the shared 10 MB default', async () => {
|
|
137
|
+
stubImages();
|
|
138
|
+
const { deps, rec } = makeDeps();
|
|
139
|
+
await resolveAssets(KEY, [req('1:1', 'png'), req('1:2', 'svg')], deps, new ImportReport());
|
|
140
|
+
expect(rec.caps).toContain(FIGMA_ASSET_MAX_BYTES);
|
|
141
|
+
expect(rec.caps).toContain(FIGMA_SVG_MAX_BYTES);
|
|
142
|
+
for (const cap of rec.caps) expect(cap).toBeLessThan(10 * 1024 * 1024);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('a node Figma declines to render is reported as skipped', async () => {
|
|
146
|
+
stubImages((id) => (id === '1:2' ? null : `https://figma.com/r/${id}`));
|
|
147
|
+
const { deps } = makeDeps();
|
|
148
|
+
const report = new ImportReport();
|
|
149
|
+
await resolveAssets(KEY, [req('1:1'), req('1:2')], deps, report);
|
|
150
|
+
expect(report.count('asset-skipped')).toBe(1);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('fail closed — a failure never leaves usable bytes behind', () => {
|
|
155
|
+
test('a download failure discards the staged file and reports', async () => {
|
|
156
|
+
stubImages();
|
|
157
|
+
const { deps, rec } = makeDeps({
|
|
158
|
+
stage: async () => {
|
|
159
|
+
throw new Error('network down');
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
const report = new ImportReport();
|
|
163
|
+
const out = await resolveAssets(KEY, [req('1:1')], deps, report);
|
|
164
|
+
expect(out.resolved).toEqual([]);
|
|
165
|
+
expect(rec.discarded.length).toBe(1);
|
|
166
|
+
expect(report.count('asset-skipped')).toBe(1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('a MISSING SANITIZER discards the bytes — the DDR-177 packaged-app case', async () => {
|
|
170
|
+
// The natural (and wrong) recovery is "we already have the bytes". This is
|
|
171
|
+
// the test that pins the right one.
|
|
172
|
+
stubImages();
|
|
173
|
+
const { deps, rec } = makeDeps({
|
|
174
|
+
promote: async () => {
|
|
175
|
+
throw new Error('bun required: happy-dom not bundled');
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
const report = new ImportReport();
|
|
179
|
+
const out = await resolveAssets(KEY, [req('1:1', 'svg')], deps, report);
|
|
180
|
+
expect(out.resolved).toEqual([]);
|
|
181
|
+
expect(out.rewrites.size).toBe(0);
|
|
182
|
+
expect(rec.discarded.length).toBe(1);
|
|
183
|
+
expect(report.count('asset-skipped')).toBe(1);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('a format mismatch is refused rather than promoted', async () => {
|
|
187
|
+
// We asked for a png and got something that sniffs as svg — the response is
|
|
188
|
+
// not what the request implied, so it must not reach a versioned tree.
|
|
189
|
+
stubImages();
|
|
190
|
+
const { deps, rec } = makeDeps({
|
|
191
|
+
stage: async (_u, out) => {
|
|
192
|
+
rec.staged.push(out);
|
|
193
|
+
return { bytes: 100, ext: 'svg' };
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
const report = new ImportReport();
|
|
197
|
+
await resolveAssets(KEY, [req('1:1', 'png')], deps, report);
|
|
198
|
+
expect(rec.promoted).toEqual([]);
|
|
199
|
+
expect(rec.discarded.length).toBe(1);
|
|
200
|
+
expect(report.count('asset-skipped')).toBe(1);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('staging happens OUTSIDE the design root', async () => {
|
|
204
|
+
stubImages();
|
|
205
|
+
const { deps, rec } = makeDeps();
|
|
206
|
+
await resolveAssets(KEY, [req('1:1')], deps, new ImportReport());
|
|
207
|
+
for (const p of rec.staged) {
|
|
208
|
+
expect(p).not.toContain('.design');
|
|
209
|
+
expect(p).not.toContain('_history');
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe('rewrites', () => {
|
|
215
|
+
test('a resolved asset replaces its placeholder in the emitted source', async () => {
|
|
216
|
+
stubImages();
|
|
217
|
+
const { deps } = makeDeps();
|
|
218
|
+
const request = req('2:1', 'svg');
|
|
219
|
+
const out = await resolveAssets(KEY, [request], deps, new ImportReport());
|
|
220
|
+
const src = `<img src="${request.placeholder}" />`;
|
|
221
|
+
const rewritten = applyRewrites(src, out.rewrites);
|
|
222
|
+
expect(rewritten).not.toContain('pending-');
|
|
223
|
+
expect(rewritten).toContain('/assets/');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('an UNRESOLVED placeholder is left in place — a visible break beats a silent gap', async () => {
|
|
227
|
+
stubImages(() => null);
|
|
228
|
+
const { deps } = makeDeps();
|
|
229
|
+
const request = req('2:1', 'svg');
|
|
230
|
+
const out = await resolveAssets(KEY, [request], deps, new ImportReport());
|
|
231
|
+
const src = `<img src="${request.placeholder}" />`;
|
|
232
|
+
expect(applyRewrites(src, out.rewrites)).toBe(src);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe('the host allowlist is frozen and exact', () => {
|
|
237
|
+
test('it names figma.com and the render bucket, and nothing wildcard', () => {
|
|
238
|
+
expect([...FIGMA_ASSET_HOSTS]).toEqual([
|
|
239
|
+
'figma.com',
|
|
240
|
+
'figma-alpha-api.s3.us-west-2.amazonaws.com',
|
|
241
|
+
]);
|
|
242
|
+
for (const h of FIGMA_ASSET_HOSTS) expect(h).not.toContain('*');
|
|
243
|
+
expect(Object.isFrozen(FIGMA_ASSET_HOSTS)).toBe(true);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe('dedupe by render key — an instance renders as its component', () => {
|
|
248
|
+
test('instance-scoped ids collapse to the component', () => {
|
|
249
|
+
// Figma scopes a node inside an instance as `I<instancePath>;<compId>`.
|
|
250
|
+
expect(renderKey('I2:796;2:794')).toBe('2:794');
|
|
251
|
+
expect(renderKey('I6:799;2:794')).toBe('2:794');
|
|
252
|
+
expect(renderKey('I24:454;103:731')).toBe('103:731');
|
|
253
|
+
// A plain node keys as itself.
|
|
254
|
+
expect(renderKey('2:794')).toBe('2:794');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('40 placements of one icon cost ONE render and ONE download', async () => {
|
|
258
|
+
stubImages();
|
|
259
|
+
const { deps, rec } = makeDeps();
|
|
260
|
+
const requests = Array.from({ length: 40 }, (_, i) => ({
|
|
261
|
+
nodeId: `I${i}:1;2:794`,
|
|
262
|
+
format: 'svg' as const,
|
|
263
|
+
placeholder: `/assets/pending-${i}.svg`,
|
|
264
|
+
}));
|
|
265
|
+
const out = await resolveAssets(KEY, requests, deps, new ImportReport());
|
|
266
|
+
expect(imageCalls.length).toBe(1);
|
|
267
|
+
expect(rec.staged.length).toBe(1);
|
|
268
|
+
expect(rec.promoted.length).toBe(1);
|
|
269
|
+
// …and every placement still gets rewritten to that one asset.
|
|
270
|
+
expect(out.rewrites.size).toBe(40);
|
|
271
|
+
expect(new Set(out.rewrites.values()).size).toBe(1);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('the cap bounds DISTINCT artwork, not repeated placements', async () => {
|
|
275
|
+
stubImages();
|
|
276
|
+
const { deps } = makeDeps();
|
|
277
|
+
const report = new ImportReport();
|
|
278
|
+
// 400 placements of 3 icons — well under the cap once deduped.
|
|
279
|
+
const requests = Array.from({ length: 400 }, (_, i) => ({
|
|
280
|
+
nodeId: `I${i}:1;9:${i % 3}`,
|
|
281
|
+
format: 'svg' as const,
|
|
282
|
+
placeholder: `/assets/pending-${i}.svg`,
|
|
283
|
+
}));
|
|
284
|
+
await resolveAssets(KEY, requests, deps, report);
|
|
285
|
+
expect(report.count('asset-cap-reached')).toBe(0);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('distinct components still each count against the cap', async () => {
|
|
289
|
+
stubImages();
|
|
290
|
+
const { deps } = makeDeps();
|
|
291
|
+
const report = new ImportReport();
|
|
292
|
+
const requests = Array.from({ length: MAX_ASSETS_PER_IMPORT + 7 }, (_, i) => ({
|
|
293
|
+
nodeId: `I1:1;9:${i}`,
|
|
294
|
+
format: 'svg' as const,
|
|
295
|
+
placeholder: `/assets/pending-${i}.svg`,
|
|
296
|
+
}));
|
|
297
|
+
await resolveAssets(KEY, requests, deps, report);
|
|
298
|
+
expect(report.count('asset-cap-reached')).toBe(7);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe('SVG promotes are batched — the canary is a browser launch each', () => {
|
|
303
|
+
test('every staged svg goes through ONE batch call, not N promotes', async () => {
|
|
304
|
+
stubImages();
|
|
305
|
+
const batches: string[][] = [];
|
|
306
|
+
const { deps, rec } = makeDeps({
|
|
307
|
+
promoteSvgBatch: async (paths) => {
|
|
308
|
+
batches.push([...paths]);
|
|
309
|
+
return paths.map((p) => `/assets/${p.split('/').pop()}`);
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
const requests = Array.from({ length: 12 }, (_, i) => req(`9:${i}`, 'svg'));
|
|
313
|
+
const out = await resolveAssets(KEY, requests, deps, new ImportReport());
|
|
314
|
+
expect(batches.length).toBe(1);
|
|
315
|
+
expect(batches[0].length).toBe(12);
|
|
316
|
+
expect(rec.promoted).toEqual([]); // the per-file path is not used
|
|
317
|
+
expect(out.rewrites.size).toBe(12);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// A live 6-page import lost ALL 272 frame renders to one canary timeout: the
|
|
321
|
+
// promote was a single unbounded batch and the caller fails closed, so one
|
|
322
|
+
// `spawnSync agent-browser ETIMEDOUT` became 272 x `asset-skipped` — while the
|
|
323
|
+
// import still exited 0 and reported success over a folder of broken images.
|
|
324
|
+
test('a batch bigger than the chunk cap is split', async () => {
|
|
325
|
+
stubImages();
|
|
326
|
+
const batches: string[][] = [];
|
|
327
|
+
const { deps } = makeDeps({
|
|
328
|
+
promoteSvgBatch: async (paths) => {
|
|
329
|
+
batches.push([...paths]);
|
|
330
|
+
return paths.map((p) => `/assets/${p.split('/').pop()}`);
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
const requests = Array.from({ length: MAX_SVG_PROMOTE_CHUNK + 5 }, (_, i) =>
|
|
334
|
+
req(`8:${i}`, 'svg')
|
|
335
|
+
);
|
|
336
|
+
const out = await resolveAssets(KEY, requests, deps, new ImportReport());
|
|
337
|
+
expect(batches.length).toBe(2);
|
|
338
|
+
expect(batches[0].length).toBe(MAX_SVG_PROMOTE_CHUNK);
|
|
339
|
+
expect(batches[1].length).toBe(5);
|
|
340
|
+
expect(out.rewrites.size).toBe(MAX_SVG_PROMOTE_CHUNK + 5);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('a chunk that throws through its retry costs only its OWN chunk', async () => {
|
|
344
|
+
stubImages();
|
|
345
|
+
let call = 0;
|
|
346
|
+
const { deps } = makeDeps({
|
|
347
|
+
promoteSvgBatch: async (paths) => {
|
|
348
|
+
call += 1;
|
|
349
|
+
// Both attempts on the FIRST chunk fail; later chunks are healthy.
|
|
350
|
+
if (call <= 2) throw new Error('spawnSync agent-browser ETIMEDOUT');
|
|
351
|
+
return paths.map((p) => `/assets/${p.split('/').pop()}`);
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
const requests = Array.from({ length: MAX_SVG_PROMOTE_CHUNK + 5 }, (_, i) =>
|
|
355
|
+
req(`7:${i}`, 'svg')
|
|
356
|
+
);
|
|
357
|
+
const report = new ImportReport();
|
|
358
|
+
const out = await resolveAssets(KEY, requests, deps, report);
|
|
359
|
+
// The surviving chunk still lands — that is the whole point.
|
|
360
|
+
expect(out.rewrites.size).toBe(5);
|
|
361
|
+
expect(report.count('asset-skipped')).toBe(MAX_SVG_PROMOTE_CHUNK);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test('a chunk that throws ONCE is retried, and the retry keeps the assets', async () => {
|
|
365
|
+
// Measured: the same lane promoted 4/4 and 24/24 but threw on 12. The
|
|
366
|
+
// failure is a coin-flip against `agent-browser`'s per-invocation budget,
|
|
367
|
+
// and a lost chunk is a permanently broken image in a versioned artifact.
|
|
368
|
+
stubImages();
|
|
369
|
+
let calls = 0;
|
|
370
|
+
const { deps } = makeDeps({
|
|
371
|
+
promoteSvgBatch: async (paths) => {
|
|
372
|
+
calls += 1;
|
|
373
|
+
if (calls === 1) throw new Error('spawnSync agent-browser ETIMEDOUT');
|
|
374
|
+
return paths.map((p) => `/assets/${p.split('/').pop()}`);
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
const report = new ImportReport();
|
|
378
|
+
const out = await resolveAssets(KEY, [req('5:1', 'svg'), req('5:2', 'svg')], deps, report);
|
|
379
|
+
expect(calls).toBe(2);
|
|
380
|
+
expect(out.rewrites.size).toBe(2);
|
|
381
|
+
expect(report.count('asset-skipped')).toBe(0);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('the retry is bounded at ONE — a lane that is down still fails fast', async () => {
|
|
385
|
+
stubImages();
|
|
386
|
+
let calls = 0;
|
|
387
|
+
const { deps } = makeDeps({
|
|
388
|
+
promoteSvgBatch: async () => {
|
|
389
|
+
calls += 1;
|
|
390
|
+
throw new Error('spawnSync agent-browser ETIMEDOUT');
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
const report = new ImportReport();
|
|
394
|
+
await resolveAssets(KEY, [req('4:1', 'svg')], deps, report);
|
|
395
|
+
expect(calls).toBe(2);
|
|
396
|
+
expect(report.count('asset-skipped')).toBe(1);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test('a SHORT return pads rather than shifting refs onto the wrong nodes', async () => {
|
|
400
|
+
stubImages();
|
|
401
|
+
const { deps } = makeDeps({
|
|
402
|
+
promoteSvgBatch: async (paths) => paths.slice(0, 1).map(() => '/assets/only.svg'),
|
|
403
|
+
});
|
|
404
|
+
const requests = Array.from({ length: 3 }, (_, i) => req(`6:${i}`, 'svg'));
|
|
405
|
+
const report = new ImportReport();
|
|
406
|
+
const out = await resolveAssets(KEY, requests, deps, report);
|
|
407
|
+
expect(out.rewrites.size).toBe(1);
|
|
408
|
+
expect(report.count('asset-skipped')).toBe(2);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test('rasters still take the per-file path', async () => {
|
|
412
|
+
stubImages();
|
|
413
|
+
const batches: string[][] = [];
|
|
414
|
+
const { deps, rec } = makeDeps({
|
|
415
|
+
promoteSvgBatch: async (paths) => {
|
|
416
|
+
batches.push([...paths]);
|
|
417
|
+
return paths.map(() => '/assets/x.svg');
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
await resolveAssets(KEY, [req('9:1', 'png')], deps, new ImportReport());
|
|
421
|
+
expect(batches.length).toBe(0);
|
|
422
|
+
expect(rec.promoted.length).toBe(1);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test('a batch that THROWS fails closed for every file in it', async () => {
|
|
426
|
+
stubImages();
|
|
427
|
+
const { deps, rec } = makeDeps({
|
|
428
|
+
promoteSvgBatch: async () => {
|
|
429
|
+
throw new Error('bun required: happy-dom not bundled');
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
const report = new ImportReport();
|
|
433
|
+
const out = await resolveAssets(KEY, [req('9:1', 'svg'), req('9:2', 'svg')], deps, report);
|
|
434
|
+
expect(out.rewrites.size).toBe(0);
|
|
435
|
+
expect(rec.discarded.length).toBe(2);
|
|
436
|
+
expect(report.count('asset-skipped')).toBe(2);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test('one refused file in a batch does not sink the others', async () => {
|
|
440
|
+
stubImages();
|
|
441
|
+
const { deps, rec } = makeDeps({
|
|
442
|
+
promoteSvgBatch: async (paths) =>
|
|
443
|
+
paths.map((p, i) => (i === 1 ? null : `/assets/ok${i}.svg`)),
|
|
444
|
+
});
|
|
445
|
+
const report = new ImportReport();
|
|
446
|
+
const out = await resolveAssets(
|
|
447
|
+
KEY,
|
|
448
|
+
[req('9:1', 'svg'), req('9:2', 'svg'), req('9:3', 'svg')],
|
|
449
|
+
deps,
|
|
450
|
+
report
|
|
451
|
+
);
|
|
452
|
+
expect(out.rewrites.size).toBe(2);
|
|
453
|
+
expect(rec.discarded.length).toBe(1);
|
|
454
|
+
expect(report.count('asset-skipped')).toBe(1);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test('a caller without promoteSvgBatch still works (per-file fallback)', async () => {
|
|
458
|
+
stubImages();
|
|
459
|
+
const { deps, rec } = makeDeps();
|
|
460
|
+
const out = await resolveAssets(KEY, [req('9:1', 'svg')], deps, new ImportReport());
|
|
461
|
+
expect(rec.promoted.length).toBe(1);
|
|
462
|
+
expect(out.rewrites.size).toBe(1);
|
|
463
|
+
});
|
|
464
|
+
});
|