@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.
- package/apps/studio/annotations-layer.tsx +49 -15
- package/apps/studio/bin/_import-asset.mjs +18 -0
- package/apps/studio/bin/_import-figma.mjs +868 -214
- 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 +17 -4
- package/apps/studio/bin/import-figma.sh +10 -1
- package/apps/studio/bin/perf.sh +228 -0
- package/apps/studio/bin/smoke.sh +49 -5
- package/apps/studio/canvas-lib.tsx +148 -6
- package/apps/studio/client/app.jsx +152 -37
- package/apps/studio/client/panels/SyncPanel.jsx +229 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +29 -1
- package/apps/studio/client/panels/timeline-comp-target.js +101 -0
- package/apps/studio/client/styles/3-shell-maude.css +30 -0
- package/apps/studio/client/styles/4-components.css +4 -4
- package/apps/studio/dist/client.bundle.js +772 -772
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/video-encode-lib.ts +8 -5
- package/apps/studio/exporters/video.ts +10 -0
- package/apps/studio/figma/assets.test.ts +92 -0
- package/apps/studio/figma/assets.ts +63 -9
- 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/endpoints.ts +73 -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 +69 -0
- package/apps/studio/figma/sanitize.ts +139 -47
- package/apps/studio/figma/tailwind-map.test.ts +142 -0
- package/apps/studio/figma/tailwind-map.ts +545 -0
- package/apps/studio/figma/to-render.ts +25 -3
- package/apps/studio/figma/types.ts +6 -1
- package/apps/studio/http.ts +47 -0
- package/apps/studio/sync/asset-push.ts +346 -38
- package/apps/studio/sync/connection-state.ts +71 -3
- package/apps/studio/sync/index.ts +10 -1
- package/apps/studio/sync/status.ts +18 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
- package/apps/studio/test/figma-explode.test.ts +438 -0
- package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
- package/apps/studio/test/import-figma.test.ts +192 -4
- package/apps/studio/test/sync-asset-push.test.ts +490 -47
- package/apps/studio/test/sync-connection-state.test.ts +66 -0
- package/apps/studio/test/sync-panel-surface.test.ts +90 -0
- 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 +81 -1
- package/apps/studio/test/video-encode-lib.test.ts +63 -0
- package/apps/studio/use-artboard-drag.tsx +37 -3
- package/apps/studio/video-comp.tsx +51 -0
- package/apps/studio/whats-new.json +71 -0
- package/cli/commands/design.mjs +7 -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
|
@@ -1,56 +1,76 @@
|
|
|
1
|
-
// DDR-217
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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 —
|
|
19
|
-
test('
|
|
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
|
-
|
|
22
|
-
mkdirSync(join(
|
|
23
|
-
|
|
24
|
-
writeFileSync(join(
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
'
|
|
34
|
-
'
|
|
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('
|
|
48
|
+
test('non-asset extensions and dotfiles are filtered; missing root is []', () => {
|
|
39
49
|
const designRoot = scratchDesignRoot();
|
|
40
|
-
mkdirSync(join(designRoot, 'assets
|
|
41
|
-
writeFileSync(join(designRoot, 'assets/
|
|
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 —
|
|
48
|
-
test('
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
writeFileSync(join(
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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('
|
|
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/
|
|
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
|
-
|
|
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: '
|
|
135
|
+
expect(r.failed).toEqual([{ key: 'system/ds/assets/logos/x.svg', reason: 'HTTP 413' }]);
|
|
105
136
|
});
|
|
106
137
|
|
|
107
|
-
test('
|
|
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
|
+
});
|