@1agh/maude 0.58.3 → 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.
Files changed (67) hide show
  1. package/apps/studio/annotations-layer.tsx +49 -15
  2. package/apps/studio/bin/_import-asset.mjs +18 -0
  3. package/apps/studio/bin/_import-figma.mjs +868 -214
  4. package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
  5. package/apps/studio/bin/_perf-probe.mjs +228 -0
  6. package/apps/studio/bin/_perf-shared.mjs +345 -0
  7. package/apps/studio/bin/_video-playwright.mjs +17 -4
  8. package/apps/studio/bin/import-figma.sh +10 -1
  9. package/apps/studio/bin/perf.sh +228 -0
  10. package/apps/studio/bin/smoke.sh +49 -5
  11. package/apps/studio/canvas-lib.tsx +148 -6
  12. package/apps/studio/client/app.jsx +152 -37
  13. package/apps/studio/client/panels/SyncPanel.jsx +229 -0
  14. package/apps/studio/client/panels/TimelinePanel.jsx +29 -1
  15. package/apps/studio/client/panels/timeline-comp-target.js +101 -0
  16. package/apps/studio/client/styles/3-shell-maude.css +30 -0
  17. package/apps/studio/client/styles/4-components.css +4 -4
  18. package/apps/studio/dist/client.bundle.js +772 -772
  19. package/apps/studio/dist/styles.css +1 -1
  20. package/apps/studio/exporters/video-encode-lib.ts +8 -5
  21. package/apps/studio/exporters/video.ts +10 -0
  22. package/apps/studio/figma/assets.test.ts +92 -0
  23. package/apps/studio/figma/assets.ts +63 -9
  24. package/apps/studio/figma/codegen-client.test.ts +276 -0
  25. package/apps/studio/figma/codegen-client.ts +509 -0
  26. package/apps/studio/figma/codegen-fonts.test.ts +103 -0
  27. package/apps/studio/figma/codegen-fonts.ts +195 -0
  28. package/apps/studio/figma/codegen-values.test.ts +179 -0
  29. package/apps/studio/figma/codegen-values.ts +270 -0
  30. package/apps/studio/figma/endpoints.ts +73 -0
  31. package/apps/studio/figma/fig-decode.test.ts +702 -0
  32. package/apps/studio/figma/fig-decode.ts +617 -0
  33. package/apps/studio/figma/fig-kiwi.ts +410 -0
  34. package/apps/studio/figma/fig-zip.ts +270 -0
  35. package/apps/studio/figma/from-codegen.test.ts +408 -0
  36. package/apps/studio/figma/from-codegen.ts +1103 -0
  37. package/apps/studio/figma/sanitize.test.ts +69 -0
  38. package/apps/studio/figma/sanitize.ts +139 -47
  39. package/apps/studio/figma/tailwind-map.test.ts +142 -0
  40. package/apps/studio/figma/tailwind-map.ts +545 -0
  41. package/apps/studio/figma/to-render.ts +25 -3
  42. package/apps/studio/figma/types.ts +6 -1
  43. package/apps/studio/http.ts +47 -0
  44. package/apps/studio/sync/asset-push.ts +346 -38
  45. package/apps/studio/sync/connection-state.ts +71 -3
  46. package/apps/studio/sync/index.ts +10 -1
  47. package/apps/studio/sync/status.ts +18 -0
  48. package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
  49. package/apps/studio/test/figma-explode.test.ts +438 -0
  50. package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
  51. package/apps/studio/test/import-figma.test.ts +192 -4
  52. package/apps/studio/test/sync-asset-push.test.ts +490 -47
  53. package/apps/studio/test/sync-connection-state.test.ts +66 -0
  54. package/apps/studio/test/sync-panel-surface.test.ts +90 -0
  55. package/apps/studio/test/sync-status.test.ts +28 -0
  56. package/apps/studio/test/timeline-comp-target.test.ts +139 -0
  57. package/apps/studio/test/video-comp.test.ts +81 -1
  58. package/apps/studio/test/video-encode-lib.test.ts +63 -0
  59. package/apps/studio/use-artboard-drag.tsx +37 -3
  60. package/apps/studio/video-comp.tsx +51 -0
  61. package/apps/studio/whats-new.json +71 -0
  62. package/cli/commands/design.mjs +7 -0
  63. package/cli/commands/kg.mjs +8 -1
  64. package/cli/commands/kg.test.mjs +24 -0
  65. package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
  66. package/cli/lib/figma-import-controls.test.mjs +70 -0
  67. package/package.json +8 -8
@@ -1,56 +1,76 @@
1
- // DDR-217 (fix 6, sync RCA 2026-08-10) — the desktop→cell asset push.
2
- // The properties under test: the listing mirrors the hub's key shape (junk
3
- // stays off the wire), the sweep is HEAD-first (upload only what the cloud
4
- // lacks), the credential is read at call time (silent renewal), and a failed
5
- // upload is reported, never thrown.
1
+ // DDR-217 + the 2026-08-11 addendum — the desktop→cell asset push, both
2
+ // classes. Properties under test: the sweep finds top-level content-addressed
3
+ // assets AND nested DS/brand assets under system/*/assets/; each routes to the
4
+ // right hub endpoint (bucket `/assets/` vs checkout `/_asset-file/`); non-asset
5
+ // files (.photo.json) and junk are filtered; the sweep is HEAD-first; the
6
+ // credential is read at call time; a failure is reported, never thrown.
6
7
 
7
8
  import { describe, expect, test } from 'bun:test';
8
9
  import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
9
10
  import { tmpdir } from 'node:os';
10
11
  import { join } from 'node:path';
11
12
 
12
- import { listPushableAssets, pushAssets } from '../sync/asset-push.ts';
13
+ import { listPushableAssets, pushAssets, putTimeoutMs } from '../sync/asset-push.ts';
13
14
 
14
15
  function scratchDesignRoot(): string {
15
16
  return mkdtempSync(join(tmpdir(), 'asset-push-'));
16
17
  }
17
18
 
18
- describe('listPushableAssets — the courtesy filter mirrors the hub ASSET_KEY', () => {
19
- test('admits the shapes real projects use, refuses what the hub would', () => {
19
+ describe('listPushableAssets — every assets/ dir, asset extensions only', () => {
20
+ test('finds top-level content-addressed AND nested DS/brand assets', () => {
20
21
  const designRoot = scratchDesignRoot();
21
- const assets = join(designRoot, 'assets');
22
- mkdirSync(join(assets, 'graphics'), { recursive: true });
23
- mkdirSync(join(assets, 'fonts'), { recursive: true });
24
- writeFileSync(join(assets, 'a1b2c3d4.png'), 'x');
25
- writeFileSync(join(assets, 'gator_badge_roundel.svg'), 'x');
26
- writeFileSync(join(assets, 'graphics/camo-bg.png'), 'x');
27
- writeFileSync(join(assets, 'fonts/Gators-Bold.woff2'), 'x');
28
- writeFileSync(join(assets, '.hidden'), 'x'); // dotfile — fails leading-alnum
22
+ // Class 1 — top-level content-addressed uploads (+ a .photo.json sidecar).
23
+ mkdirSync(join(designRoot, 'assets'), { recursive: true });
24
+ writeFileSync(join(designRoot, 'assets/a1b2c3d4.png'), 'x');
25
+ writeFileSync(join(designRoot, 'assets/a1b2c3d4.photo.json'), '{}'); // metadata, not an asset
26
+ // Class 2 — DS/brand assets under system/<ds>/assets/ (the ones that stayed grey).
27
+ mkdirSync(join(designRoot, 'system/alligators/assets/logos'), { recursive: true });
28
+ mkdirSync(join(designRoot, 'system/alligators/assets/fonts'), { recursive: true });
29
+ mkdirSync(join(designRoot, 'system/alligators/assets/photos'), { recursive: true });
30
+ writeFileSync(join(designRoot, 'system/alligators/assets/logos/horizontal-green.svg'), 'x');
31
+ writeFileSync(join(designRoot, 'system/alligators/assets/fonts/Gators-Bold.woff2'), 'x');
32
+ writeFileSync(join(designRoot, 'system/alligators/assets/photos/P1020428.JPG'), 'x'); // uppercase ext
33
+ // Not under any assets/ dir → never swept.
34
+ mkdirSync(join(designRoot, 'system/alligators/preview'), { recursive: true });
35
+ writeFileSync(join(designRoot, 'system/alligators/preview/logo.tsx'), 'x');
36
+ // Runtime state is skipped.
37
+ mkdirSync(join(designRoot, '_history/assets'), { recursive: true });
38
+ writeFileSync(join(designRoot, '_history/assets/old.png'), 'x');
29
39
 
30
40
  expect(listPushableAssets(designRoot)).toEqual([
31
- 'a1b2c3d4.png',
32
- 'fonts/Gators-Bold.woff2',
33
- 'gator_badge_roundel.svg',
34
- 'graphics/camo-bg.png',
41
+ 'assets/a1b2c3d4.png',
42
+ 'system/alligators/assets/fonts/Gators-Bold.woff2',
43
+ 'system/alligators/assets/logos/horizontal-green.svg',
44
+ 'system/alligators/assets/photos/P1020428.JPG',
35
45
  ]);
36
46
  });
37
47
 
38
- test('depth beyond the hub cap is skipped; a missing assets dir is []', () => {
48
+ test('non-asset extensions and dotfiles are filtered; missing root is []', () => {
39
49
  const designRoot = scratchDesignRoot();
40
- mkdirSync(join(designRoot, 'assets/a/b/c/d/e'), { recursive: true });
41
- writeFileSync(join(designRoot, 'assets/a/b/c/d/e/too-deep.png'), 'x');
50
+ mkdirSync(join(designRoot, 'assets'), { recursive: true });
51
+ writeFileSync(join(designRoot, 'assets/notes.txt'), 'x');
52
+ writeFileSync(join(designRoot, 'assets/data.json'), 'x');
53
+ writeFileSync(join(designRoot, 'assets/.hidden'), 'x');
42
54
  expect(listPushableAssets(designRoot)).toEqual([]);
43
55
  expect(listPushableAssets(join(designRoot, 'nope'))).toEqual([]);
44
56
  });
57
+
58
+ test('depth beyond the hub 8-segment cap is skipped', () => {
59
+ const designRoot = scratchDesignRoot();
60
+ // assets/a/b/c/d/e/f/g/x.png = 9 segments > 8 → skipped.
61
+ mkdirSync(join(designRoot, 'assets/a/b/c/d/e/f/g'), { recursive: true });
62
+ writeFileSync(join(designRoot, 'assets/a/b/c/d/e/f/g/x.png'), 'x');
63
+ expect(listPushableAssets(designRoot)).toEqual([]);
64
+ });
45
65
  });
46
66
 
47
- describe('pushAssets — HEAD-first, upload the misses, never throw', () => {
48
- test('skips what the cloud has, PUTs the rest, streams the file bytes', async () => {
67
+ describe('pushAssets — routes each class to the right hub endpoint', () => {
68
+ test('top-level → /assets/, DS → /_asset-file/, HEAD-first, live token', async () => {
49
69
  const designRoot = scratchDesignRoot();
50
- const assets = join(designRoot, 'assets');
51
- mkdirSync(assets, { recursive: true });
52
- writeFileSync(join(assets, 'aaaaaaaa.png'), 'already-there');
53
- writeFileSync(join(assets, 'bbbbbbbb.png'), 'missing-bytes');
70
+ mkdirSync(join(designRoot, 'assets'), { recursive: true });
71
+ writeFileSync(join(designRoot, 'assets/aaaaaaaa.png'), 'top-level');
72
+ mkdirSync(join(designRoot, 'system/ds/assets/logos'), { recursive: true });
73
+ writeFileSync(join(designRoot, 'system/ds/assets/logos/mark.svg'), 'brand-bytes');
54
74
 
55
75
  const calls: Array<{ method: string; url: string; auth: string }> = [];
56
76
  const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
@@ -58,11 +78,7 @@ describe('pushAssets — HEAD-first, upload the misses, never throw', () => {
58
78
  const method = init?.method ?? 'GET';
59
79
  const headers = (init?.headers ?? {}) as Record<string, string>;
60
80
  calls.push({ method, url, auth: String(headers.authorization) });
61
- if (method === 'HEAD')
62
- return new Response(null, { status: url.includes('aaaa') ? 200 : 404 });
63
- // The PUT body is the real file — prove the bytes went along.
64
- const body = await new Response(init?.body as BodyInit).text();
65
- expect(body).toBe('missing-bytes');
81
+ if (method === 'HEAD') return new Response(null, { status: 404 }); // nothing there yet
66
82
  return new Response('{"ok":true}', { status: 200 });
67
83
  }) as typeof fetch;
68
84
 
@@ -74,25 +90,40 @@ describe('pushAssets — HEAD-first, upload the misses, never throw', () => {
74
90
  log: { log: () => {}, warn: () => {} },
75
91
  });
76
92
 
77
- expect(r.skipped).toBe(1);
78
- expect(r.pushed).toEqual(['bbbbbbbb.png']);
79
93
  expect(r.failed).toEqual([]);
80
- // Trailing slash folded; every call authenticated with the live token.
81
- expect(calls.every((c) => c.url.startsWith('https://alligators.cloud.maude.sh/assets/'))).toBe(
82
- true
94
+ expect(r.pushed.sort()).toEqual(['assets/aaaaaaaa.png', 'system/ds/assets/logos/mark.svg']);
95
+ // Top-level → bucket route; DS → checkout route.
96
+ const puts = calls.filter((c) => c.method === 'PUT').map((c) => c.url);
97
+ expect(puts).toContain('https://alligators.cloud.maude.sh/assets/aaaaaaaa.png');
98
+ expect(puts).toContain(
99
+ 'https://alligators.cloud.maude.sh/_asset-file/system/ds/assets/logos/mark.svg'
83
100
  );
84
101
  expect(calls.every((c) => c.auth === 'Bearer mau_tok')).toBe(true);
85
- expect(calls.map((c) => c.method)).toEqual(['HEAD', 'HEAD', 'PUT']);
86
102
  });
87
103
 
88
- test('a refused upload lands in failed — reported, not thrown', async () => {
104
+ test('skips what the cloud already holds (HEAD 200)', async () => {
89
105
  const designRoot = scratchDesignRoot();
90
- mkdirSync(join(designRoot, 'assets'), { recursive: true });
91
- writeFileSync(join(designRoot, 'assets/cccccccc.png'), 'x');
106
+ mkdirSync(join(designRoot, 'system/ds/assets/logos'), { recursive: true });
107
+ writeFileSync(join(designRoot, 'system/ds/assets/logos/there.svg'), 'x');
108
+ const fetchImpl = (async (_i: RequestInfo | URL, init?: RequestInit) =>
109
+ new Response(null, { status: init?.method === 'HEAD' ? 200 : 500 })) as typeof fetch;
110
+ const r = await pushAssets({
111
+ designRoot,
112
+ hubUrl: 'https://x.example',
113
+ token: () => 't',
114
+ fetchImpl,
115
+ log: { log: () => {}, warn: () => {} },
116
+ });
117
+ expect(r.skipped).toBe(1);
118
+ expect(r.pushed).toEqual([]);
119
+ });
92
120
 
93
- const fetchImpl = (async (_input: RequestInfo | URL, init?: RequestInit) =>
121
+ test('a refused upload is reported, not thrown', async () => {
122
+ const designRoot = scratchDesignRoot();
123
+ mkdirSync(join(designRoot, 'system/ds/assets/logos'), { recursive: true });
124
+ writeFileSync(join(designRoot, 'system/ds/assets/logos/x.svg'), 'x');
125
+ const fetchImpl = (async (_i: RequestInfo | URL, init?: RequestInit) =>
94
126
  new Response(null, { status: init?.method === 'HEAD' ? 404 : 413 })) as typeof fetch;
95
-
96
127
  const r = await pushAssets({
97
128
  designRoot,
98
129
  hubUrl: 'https://x.example',
@@ -101,10 +132,10 @@ describe('pushAssets — HEAD-first, upload the misses, never throw', () => {
101
132
  log: { log: () => {}, warn: () => {} },
102
133
  });
103
134
  expect(r.pushed).toEqual([]);
104
- expect(r.failed).toEqual([{ key: 'cccccccc.png', reason: 'HTTP 413' }]);
135
+ expect(r.failed).toEqual([{ key: 'system/ds/assets/logos/x.svg', reason: 'HTTP 413' }]);
105
136
  });
106
137
 
107
- test('a project with no assets dir makes zero network calls', async () => {
138
+ test('no assets dir → zero network calls', async () => {
108
139
  const designRoot = scratchDesignRoot();
109
140
  let calls = 0;
110
141
  const fetchImpl = (async () => {
@@ -122,3 +153,415 @@ describe('pushAssets — HEAD-first, upload the misses, never throw', () => {
122
153
  expect(r).toEqual({ pushed: [], skipped: 0, failed: [] });
123
154
  });
124
155
  });
156
+
157
+ describe('pushAssets — 429 pacing, 5xx retry, honest failure reasons (RCA 2026-08-11)', () => {
158
+ /** One asset, and a fetch that answers HEAD 404 (nothing on the cloud yet). */
159
+ function oneAsset(): string {
160
+ const designRoot = scratchDesignRoot();
161
+ mkdirSync(join(designRoot, 'assets'), { recursive: true });
162
+ writeFileSync(join(designRoot, 'assets/one.png'), 'bytes');
163
+ return designRoot;
164
+ }
165
+
166
+ test('a 429 with Retry-After is waited out once, then the asset lands', async () => {
167
+ const designRoot = oneAsset();
168
+ const slept: number[] = [];
169
+ let puts = 0;
170
+ const fetchImpl = (async (_i: RequestInfo | URL, init?: RequestInit) => {
171
+ if (init?.method === 'HEAD') return new Response(null, { status: 404 });
172
+ puts += 1;
173
+ return puts === 1
174
+ ? new Response('{"error":"rate limit exceeded"}', {
175
+ status: 429,
176
+ headers: { 'retry-after': '30' },
177
+ })
178
+ : new Response('{"ok":true}', { status: 200 });
179
+ }) as typeof fetch;
180
+
181
+ const r = await pushAssets({
182
+ designRoot,
183
+ hubUrl: 'https://x.example',
184
+ token: () => 't',
185
+ fetchImpl,
186
+ log: { log: () => {}, warn: () => {} },
187
+ sleep: async (ms) => {
188
+ slept.push(ms);
189
+ },
190
+ });
191
+ expect(r.pushed).toEqual(['assets/one.png']);
192
+ expect(r.failed).toEqual([]);
193
+ expect(slept).toEqual([30_000]); // the hub's own number, not a guess
194
+ expect(puts).toBe(2);
195
+ });
196
+
197
+ test('an absent Retry-After falls back to the 60 s window; a huge one is clamped', async () => {
198
+ for (const [header, expected] of [
199
+ [null, 60_000],
200
+ ['9999', 60_000],
201
+ ['bogus', 60_000],
202
+ ] as const) {
203
+ const designRoot = oneAsset();
204
+ const slept: number[] = [];
205
+ let puts = 0;
206
+ const fetchImpl = (async (_i: RequestInfo | URL, init?: RequestInit) => {
207
+ if (init?.method === 'HEAD') return new Response(null, { status: 404 });
208
+ puts += 1;
209
+ return puts === 1
210
+ ? new Response(null, { status: 429, headers: header ? { 'retry-after': header } : {} })
211
+ : new Response(null, { status: 200 });
212
+ }) as typeof fetch;
213
+ const r = await pushAssets({
214
+ designRoot,
215
+ hubUrl: 'https://x.example',
216
+ token: () => 't',
217
+ fetchImpl,
218
+ log: { log: () => {}, warn: () => {} },
219
+ sleep: async (ms) => {
220
+ slept.push(ms);
221
+ },
222
+ });
223
+ expect(slept).toEqual([expected]);
224
+ expect(r.pushed).toEqual(['assets/one.png']);
225
+ }
226
+ });
227
+
228
+ test('a second 429 fails the asset and the sweep keeps going', async () => {
229
+ const designRoot = scratchDesignRoot();
230
+ mkdirSync(join(designRoot, 'assets'), { recursive: true });
231
+ writeFileSync(join(designRoot, 'assets/a-limited.png'), 'x');
232
+ writeFileSync(join(designRoot, 'assets/b-fine.png'), 'x');
233
+ const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
234
+ if (init?.method === 'HEAD') return new Response(null, { status: 404 });
235
+ return String(input).includes('a-limited')
236
+ ? new Response('{"error":"rate limit exceeded"}', {
237
+ status: 429,
238
+ headers: { 'retry-after': '1' },
239
+ })
240
+ : new Response(null, { status: 200 });
241
+ }) as typeof fetch;
242
+ const r = await pushAssets({
243
+ designRoot,
244
+ hubUrl: 'https://x.example',
245
+ token: () => 't',
246
+ fetchImpl,
247
+ log: { log: () => {}, warn: () => {} },
248
+ sleep: async () => {},
249
+ });
250
+ expect(r.pushed).toEqual(['assets/b-fine.png']);
251
+ expect(r.failed).toEqual([
252
+ { key: 'assets/a-limited.png', reason: 'HTTP 429 — {"error":"rate limit exceeded"}' },
253
+ ]);
254
+ });
255
+
256
+ test('a 5xx gets one immediate retry — no wait, no whole new boot', async () => {
257
+ const designRoot = oneAsset();
258
+ const slept: number[] = [];
259
+ let puts = 0;
260
+ const fetchImpl = (async (_i: RequestInfo | URL, init?: RequestInit) => {
261
+ if (init?.method === 'HEAD') return new Response(null, { status: 404 });
262
+ puts += 1;
263
+ return new Response(null, { status: puts === 1 ? 503 : 200 });
264
+ }) as typeof fetch;
265
+ const r = await pushAssets({
266
+ designRoot,
267
+ hubUrl: 'https://x.example',
268
+ token: () => 't',
269
+ fetchImpl,
270
+ log: { log: () => {}, warn: () => {} },
271
+ sleep: async (ms) => {
272
+ slept.push(ms);
273
+ },
274
+ });
275
+ expect(r.pushed).toEqual(['assets/one.png']);
276
+ expect(puts).toBe(2);
277
+ expect(slept).toEqual([]);
278
+ });
279
+
280
+ test('a 4xx that is not 429 is final — one attempt, reported as-is', async () => {
281
+ const designRoot = oneAsset();
282
+ let puts = 0;
283
+ const fetchImpl = (async (_i: RequestInfo | URL, init?: RequestInit) => {
284
+ if (init?.method === 'HEAD') return new Response(null, { status: 404 });
285
+ puts += 1;
286
+ return new Response(null, { status: 413 });
287
+ }) as typeof fetch;
288
+ const r = await pushAssets({
289
+ designRoot,
290
+ hubUrl: 'https://x.example',
291
+ token: () => 't',
292
+ fetchImpl,
293
+ log: { log: () => {}, warn: () => {} },
294
+ sleep: async () => {},
295
+ });
296
+ expect(puts).toBe(1);
297
+ expect(r.failed).toEqual([{ key: 'assets/one.png', reason: 'HTTP 413' }]);
298
+ });
299
+
300
+ test('the failure reason carries the body — a Cloudflare page is not our 429', async () => {
301
+ const designRoot = oneAsset();
302
+ const page = `<html>\n <head><title>503 Service Unavailable</title></head>\n <body>error 1016 — origin DNS error, and a great deal more text than we keep</body>\n</html>`;
303
+ const fetchImpl = (async (_i: RequestInfo | URL, init?: RequestInit) => {
304
+ if (init?.method === 'HEAD') return new Response(null, { status: 404 });
305
+ return new Response(page, { status: 503 });
306
+ }) as typeof fetch;
307
+ const r = await pushAssets({
308
+ designRoot,
309
+ hubUrl: 'https://x.example',
310
+ token: () => 't',
311
+ fetchImpl,
312
+ log: { log: () => {}, warn: () => {} },
313
+ sleep: async () => {},
314
+ });
315
+ const reason = r.failed[0].reason;
316
+ expect(reason.startsWith('HTTP 503 — <html> <head><title>503 Service Unavailable')).toBe(true);
317
+ expect(reason.includes('\n')).toBe(false); // collapsed, never multi-line in the panel
318
+ expect(reason.length).toBeLessThanOrEqual('HTTP 503 — '.length + 80);
319
+ });
320
+
321
+ test('every PUT carries an explicit Content-Length (the .mov chunked-503 probe)', async () => {
322
+ const designRoot = scratchDesignRoot();
323
+ mkdirSync(join(designRoot, 'assets'), { recursive: true });
324
+ writeFileSync(join(designRoot, 'assets/sized.png'), 'twelve bytes');
325
+ const lengths: string[] = [];
326
+ const fetchImpl = (async (_i: RequestInfo | URL, init?: RequestInit) => {
327
+ if (init?.method === 'HEAD') return new Response(null, { status: 404 });
328
+ const sent = (init?.headers ?? {}) as Record<string, string>;
329
+ lengths.push(String(sent['content-length']));
330
+ return new Response(null, { status: 200 });
331
+ }) as typeof fetch;
332
+ await pushAssets({
333
+ designRoot,
334
+ hubUrl: 'https://x.example',
335
+ token: () => 't',
336
+ fetchImpl,
337
+ log: { log: () => {}, warn: () => {} },
338
+ });
339
+ expect(lengths).toEqual([String('twelve bytes'.length)]);
340
+ });
341
+
342
+ test('the per-sweep backoff budget is finite — a 429 wall stops costing time', async () => {
343
+ // 10 assets, every one 429 with a 60 s Retry-After: the 5-minute sweep
344
+ // budget buys 5 paced retries, the rest fail fast for the next boot.
345
+ const designRoot = scratchDesignRoot();
346
+ mkdirSync(join(designRoot, 'assets'), { recursive: true });
347
+ for (let i = 0; i < 10; i++) writeFileSync(join(designRoot, `assets/a${i}.png`), 'x');
348
+ const slept: number[] = [];
349
+ const fetchImpl = (async (_i: RequestInfo | URL, init?: RequestInit) =>
350
+ init?.method === 'HEAD'
351
+ ? new Response(null, { status: 404 })
352
+ : new Response(null, { status: 429, headers: { 'retry-after': '60' } })) as typeof fetch;
353
+ const r = await pushAssets({
354
+ designRoot,
355
+ hubUrl: 'https://x.example',
356
+ token: () => 't',
357
+ fetchImpl,
358
+ log: { log: () => {}, warn: () => {} },
359
+ sleep: async (ms) => {
360
+ slept.push(ms);
361
+ },
362
+ });
363
+ expect(r.failed).toHaveLength(10);
364
+ expect(slept).toEqual([60_000, 60_000, 60_000, 60_000, 60_000]);
365
+ });
366
+ });
367
+
368
+ describe('pushAssets — a refused upload must not wedge the sweep (2026-08-11, second pass)', () => {
369
+ test('every PUT closes its connection — the keep-alive desync that killed the sidecar', async () => {
370
+ // A peer that refuses a PUT before reading the body leaves unread bytes in
371
+ // the socket; the next request over that pooled connection never gets an
372
+ // answer. Measured: `connection: close` on the retry alone does NOT help —
373
+ // it must be on the request that may be refused. Pin it.
374
+ const designRoot = scratchDesignRoot();
375
+ mkdirSync(join(designRoot, 'assets'), { recursive: true });
376
+ writeFileSync(join(designRoot, 'assets/one.png'), 'x');
377
+ const seen: Array<Record<string, string>> = [];
378
+ const fetchImpl = (async (_i: RequestInfo | URL, init?: RequestInit) => {
379
+ if (init?.method === 'HEAD') return new Response(null, { status: 404 });
380
+ seen.push((init?.headers ?? {}) as Record<string, string>);
381
+ return new Response(null, { status: 503 }); // refused → the retry re-sends
382
+ }) as typeof fetch;
383
+ await pushAssets({
384
+ designRoot,
385
+ hubUrl: 'https://x.example',
386
+ token: () => 't',
387
+ fetchImpl,
388
+ log: { log: () => {}, warn: () => {} },
389
+ sleep: async () => {},
390
+ });
391
+ expect(seen).toHaveLength(2); // first attempt + the 5xx retry
392
+ expect(seen.every((h) => h.connection === 'close')).toBe(true);
393
+ });
394
+
395
+ test('a request that never answers is abandoned, named, and the sweep goes on', async () => {
396
+ const designRoot = scratchDesignRoot();
397
+ mkdirSync(join(designRoot, 'assets'), { recursive: true });
398
+ writeFileSync(join(designRoot, 'assets/a-hangs.png'), 'x');
399
+ writeFileSync(join(designRoot, 'assets/b-fine.png'), 'x');
400
+ const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
401
+ if (init?.method === 'HEAD') return new Response(null, { status: 404 });
402
+ if (!String(input).includes('a-hangs')) return new Response(null, { status: 200 });
403
+ // Wedged: answers only when the caller's own budget aborts it.
404
+ return new Promise<Response>((_res, rej) => {
405
+ init?.signal?.addEventListener('abort', () => rej(init.signal?.reason));
406
+ });
407
+ }) as typeof fetch;
408
+ const r = await pushAssets({
409
+ designRoot,
410
+ hubUrl: 'https://x.example',
411
+ token: () => 't',
412
+ fetchImpl,
413
+ log: { log: () => {}, warn: () => {} },
414
+ sleep: async () => {},
415
+ timeoutFor: () => 25,
416
+ });
417
+ expect(r.failed).toEqual([
418
+ { key: 'assets/a-hangs.png', reason: 'timed out — the hub stopped answering' },
419
+ ]);
420
+ expect(r.pushed).toEqual(['assets/b-fine.png']);
421
+ });
422
+
423
+ test('a hub that refuses the PROBE never gets the body streamed at it', async () => {
424
+ // The deployed cloud door answers 401 for a route it does not have yet —
425
+ // once per DS asset. Uploading anyway is pure waste on every boot.
426
+ const designRoot = scratchDesignRoot();
427
+ mkdirSync(join(designRoot, 'system/ds/assets/logos'), { recursive: true });
428
+ writeFileSync(join(designRoot, 'system/ds/assets/logos/mark.svg'), '<svg/>');
429
+ const methods: string[] = [];
430
+ const fetchImpl = (async (_i: RequestInfo | URL, init?: RequestInit) => {
431
+ methods.push(init?.method ?? 'GET');
432
+ return new Response('{"error":"sign in to open this project"}', { status: 401 });
433
+ }) as typeof fetch;
434
+ const r = await pushAssets({
435
+ designRoot,
436
+ hubUrl: 'https://x.example',
437
+ token: () => 't',
438
+ fetchImpl,
439
+ log: { log: () => {}, warn: () => {} },
440
+ sleep: async () => {},
441
+ });
442
+ expect(methods).toEqual(['HEAD']); // no PUT at all
443
+ expect(r.failed).toEqual([
444
+ {
445
+ key: 'system/ds/assets/logos/mark.svg',
446
+ reason: 'HTTP 401 — {"error":"sign in to open this project"}',
447
+ },
448
+ ]);
449
+ });
450
+
451
+ test('the upload budget has a floor, scales with bytes, and is capped', () => {
452
+ expect(putTimeoutMs(0)).toBe(60_000);
453
+ expect(putTimeoutMs(30 * 1024 * 1024)).toBeGreaterThan(5 * 60_000); // the 29.7 MB .mov
454
+ expect(putTimeoutMs(10 * 1024 * 1024 * 1024)).toBe(10 * 60_000);
455
+ });
456
+ });
457
+
458
+ describe('pushAssets — incremental progress (feature-sync-progress-modal)', () => {
459
+ test('emits a first, per-failure and final progress; the final has finished:true', async () => {
460
+ const designRoot = scratchDesignRoot();
461
+ mkdirSync(join(designRoot, 'assets'), { recursive: true });
462
+ writeFileSync(join(designRoot, 'assets/ok.png'), 'x');
463
+ writeFileSync(join(designRoot, 'assets/skipme.png'), 'x');
464
+ writeFileSync(join(designRoot, 'assets/zz-bad.png'), 'x');
465
+ // HEAD: only skipme.png is already there. PUT: zz-bad.png fails.
466
+ const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
467
+ const url = String(input);
468
+ if (init?.method === 'HEAD')
469
+ return new Response(null, { status: url.includes('skipme') ? 200 : 404 });
470
+ return new Response(null, { status: url.includes('zz-bad') ? 500 : 200 });
471
+ }) as typeof fetch;
472
+ const emits: import('../sync/asset-push.ts').AssetPushProgress[] = [];
473
+ let t = 0;
474
+ const r = await pushAssets({
475
+ designRoot,
476
+ hubUrl: 'https://x.example',
477
+ token: () => 't',
478
+ fetchImpl,
479
+ log: { log: () => {}, warn: () => {} },
480
+ onProgress: (p) => emits.push(p),
481
+ now: () => (t += 1), // 1ms apart — inside the throttle window
482
+ });
483
+ expect(r.pushed).toEqual(['assets/ok.png']);
484
+ // First emit fires before anything settles (total known, push visible).
485
+ expect(emits[0]).toMatchObject({ total: 3, done: 0, finished: false });
486
+ // The failure force-emits through the throttle.
487
+ expect(emits.some((p) => p.failedCount === 1 && !p.finished)).toBe(true);
488
+ // Final emit always fires, with the full tally and no active file.
489
+ const last = emits[emits.length - 1];
490
+ expect(last).toEqual({
491
+ total: 3,
492
+ done: 3,
493
+ pushed: 1,
494
+ skipped: 1,
495
+ failedCount: 1,
496
+ failures: [{ key: 'assets/zz-bad.png', reason: 'HTTP 500' }],
497
+ active: null,
498
+ finished: true,
499
+ });
500
+ });
501
+
502
+ test('mid-flight emits are throttled; a slow clock lets them through', async () => {
503
+ const designRoot = scratchDesignRoot();
504
+ mkdirSync(join(designRoot, 'assets'), { recursive: true });
505
+ for (let i = 0; i < 5; i++) writeFileSync(join(designRoot, `assets/a${i}.png`), 'x');
506
+ const fetchImpl = (async (_i: RequestInfo | URL, init?: RequestInit) =>
507
+ new Response(null, { status: init?.method === 'HEAD' ? 404 : 200 })) as typeof fetch;
508
+ // Fast clock: everything inside 200ms → first + final only.
509
+ const fast: number[] = [];
510
+ await pushAssets({
511
+ designRoot,
512
+ hubUrl: 'https://x.example',
513
+ token: () => 't',
514
+ fetchImpl,
515
+ log: { log: () => {}, warn: () => {} },
516
+ onProgress: (p) => fast.push(p.done),
517
+ now: () => 1,
518
+ });
519
+ expect(fast).toHaveLength(2);
520
+ // Slow clock: 300ms between files → every per-file emit passes.
521
+ let t = 0;
522
+ const slow: number[] = [];
523
+ await pushAssets({
524
+ designRoot,
525
+ hubUrl: 'https://x.example',
526
+ token: () => 't',
527
+ fetchImpl,
528
+ log: { log: () => {}, warn: () => {} },
529
+ onProgress: (p) => slow.push(p.done),
530
+ now: () => (t += 300),
531
+ });
532
+ expect(slow.length).toBeGreaterThanOrEqual(6);
533
+ });
534
+
535
+ test('no assets → no progress emits at all (no empty section in the panel)', async () => {
536
+ const designRoot = scratchDesignRoot();
537
+ const emits: unknown[] = [];
538
+ await pushAssets({
539
+ designRoot,
540
+ hubUrl: 'https://x.example',
541
+ token: () => 't',
542
+ fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch,
543
+ log: { log: () => {}, warn: () => {} },
544
+ onProgress: (p) => emits.push(p),
545
+ });
546
+ expect(emits).toEqual([]);
547
+ });
548
+
549
+ test('a throwing progress listener never breaks the push', async () => {
550
+ const designRoot = scratchDesignRoot();
551
+ mkdirSync(join(designRoot, 'assets'), { recursive: true });
552
+ writeFileSync(join(designRoot, 'assets/ok.png'), 'x');
553
+ const fetchImpl = (async (_i: RequestInfo | URL, init?: RequestInit) =>
554
+ new Response(null, { status: init?.method === 'HEAD' ? 404 : 200 })) as typeof fetch;
555
+ const r = await pushAssets({
556
+ designRoot,
557
+ hubUrl: 'https://x.example',
558
+ token: () => 't',
559
+ fetchImpl,
560
+ log: { log: () => {}, warn: () => {} },
561
+ onProgress: () => {
562
+ throw new Error('boom');
563
+ },
564
+ });
565
+ expect(r.pushed).toEqual(['assets/ok.png']);
566
+ });
567
+ });
@@ -269,3 +269,69 @@ describe('what this run pulled down', () => {
269
269
  expect(pulled?.count).toBe(50);
270
270
  });
271
271
  });
272
+
273
+ describe('per-item list (feature-sync-progress-modal)', () => {
274
+ test('items carries every doc with its state, actionable states first', () => {
275
+ const { monitor } = makeMonitor();
276
+ monitor.noteDocState('ui-a', 'connected');
277
+ monitor.noteDocState('ui-b', 'pending');
278
+ monitor.noteDocState('ui-c', 'auth-rejected', 'not-authorized');
279
+ const items = monitor.snapshot().items;
280
+ expect(items).toEqual([
281
+ { slug: 'ui-c', state: 'auth-rejected', reason: 'not-authorized' },
282
+ { slug: 'ui-b', state: 'pending' },
283
+ { slug: 'ui-a', state: 'connected' },
284
+ ]);
285
+ });
286
+
287
+ test('a recovered doc drops its stale reason', () => {
288
+ const { monitor } = makeMonitor();
289
+ monitor.noteDocState('ui-a', 'auth-rejected', 'invalid-token');
290
+ monitor.noteDocState('ui-a', 'connected');
291
+ expect(monitor.snapshot().items).toEqual([{ slug: 'ui-a', state: 'connected' }]);
292
+ });
293
+
294
+ test('the reason is LATCHED per rejection episode — reason flips never emit', () => {
295
+ // The rejection text is hub-controlled. If a reason change emitted, a hub
296
+ // alternating messages on an open socket would force a full emit (items
297
+ // rebuild + synchronous _sync.json write + WS fanout) per frame — the
298
+ // amplification the security review flagged (2026-08-11). Repeat frames
299
+ // for an already-rejected doc must stay a no-op, whatever their text.
300
+ const { monitor, changes } = makeMonitor();
301
+ monitor.noteDocState('ui-a', 'auth-rejected', 'rate-limit');
302
+ const emitted = changes.length;
303
+ monitor.noteDocState('ui-a', 'auth-rejected', 'invalid-token');
304
+ monitor.noteDocState('ui-a', 'auth-rejected', 'invalid-token');
305
+ expect(changes.length).toBe(emitted);
306
+ expect(monitor.snapshot().items?.[0]?.reason).toBe('rate-limit');
307
+ // A NEW episode (re-probe cleared the state) records a fresh reason.
308
+ monitor.noteDocState('ui-a', 'pending');
309
+ monitor.noteDocState('ui-a', 'auth-rejected', 'invalid-token');
310
+ expect(monitor.snapshot().items?.[0]?.reason).toBe('invalid-token');
311
+ });
312
+
313
+ test('the cap truncates only the connected tail, and itemsTruncated says how much', () => {
314
+ const { monitor } = makeMonitor();
315
+ // 210 connected + 1 rejected + 1 pending: the interesting rows must
316
+ // survive the 200-row cap; the dropped tail is all `connected`.
317
+ for (let i = 0; i < 210; i++) monitor.noteDocState(`ui-ok-${i}`, 'connected');
318
+ monitor.noteDocState('ui-refused', 'auth-rejected', 'generic');
319
+ monitor.noteDocState('ui-waiting', 'pending');
320
+ const snap = monitor.snapshot();
321
+ expect(snap.items).toHaveLength(200);
322
+ expect(snap.itemsTruncated).toBe(12);
323
+ expect(snap.items?.[0]).toEqual({
324
+ slug: 'ui-refused',
325
+ state: 'auth-rejected',
326
+ reason: 'generic',
327
+ });
328
+ expect(snap.items?.[1]).toEqual({ slug: 'ui-waiting', state: 'pending' });
329
+ expect(snap.items?.slice(2).every((i) => i.state === 'connected')).toBe(true);
330
+ });
331
+
332
+ test('under the cap, itemsTruncated is absent (additive-payload hygiene)', () => {
333
+ const { monitor } = makeMonitor();
334
+ monitor.noteDocState('ui-a', 'connected');
335
+ expect(monitor.snapshot().itemsTruncated).toBeUndefined();
336
+ });
337
+ });