@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,567 @@
|
|
|
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.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { listPushableAssets, pushAssets, putTimeoutMs } from '../sync/asset-push.ts';
|
|
14
|
+
|
|
15
|
+
function scratchDesignRoot(): string {
|
|
16
|
+
return mkdtempSync(join(tmpdir(), 'asset-push-'));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('listPushableAssets — every assets/ dir, asset extensions only', () => {
|
|
20
|
+
test('finds top-level content-addressed AND nested DS/brand assets', () => {
|
|
21
|
+
const designRoot = scratchDesignRoot();
|
|
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');
|
|
39
|
+
|
|
40
|
+
expect(listPushableAssets(designRoot)).toEqual([
|
|
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',
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('non-asset extensions and dotfiles are filtered; missing root is []', () => {
|
|
49
|
+
const designRoot = scratchDesignRoot();
|
|
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');
|
|
54
|
+
expect(listPushableAssets(designRoot)).toEqual([]);
|
|
55
|
+
expect(listPushableAssets(join(designRoot, 'nope'))).toEqual([]);
|
|
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
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('pushAssets — routes each class to the right hub endpoint', () => {
|
|
68
|
+
test('top-level → /assets/, DS → /_asset-file/, HEAD-first, live token', async () => {
|
|
69
|
+
const designRoot = scratchDesignRoot();
|
|
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');
|
|
74
|
+
|
|
75
|
+
const calls: Array<{ method: string; url: string; auth: string }> = [];
|
|
76
|
+
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
77
|
+
const url = String(input);
|
|
78
|
+
const method = init?.method ?? 'GET';
|
|
79
|
+
const headers = (init?.headers ?? {}) as Record<string, string>;
|
|
80
|
+
calls.push({ method, url, auth: String(headers.authorization) });
|
|
81
|
+
if (method === 'HEAD') return new Response(null, { status: 404 }); // nothing there yet
|
|
82
|
+
return new Response('{"ok":true}', { status: 200 });
|
|
83
|
+
}) as typeof fetch;
|
|
84
|
+
|
|
85
|
+
const r = await pushAssets({
|
|
86
|
+
designRoot,
|
|
87
|
+
hubUrl: 'https://alligators.cloud.maude.sh/',
|
|
88
|
+
token: () => 'mau_tok',
|
|
89
|
+
fetchImpl,
|
|
90
|
+
log: { log: () => {}, warn: () => {} },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(r.failed).toEqual([]);
|
|
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'
|
|
100
|
+
);
|
|
101
|
+
expect(calls.every((c) => c.auth === 'Bearer mau_tok')).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('skips what the cloud already holds (HEAD 200)', async () => {
|
|
105
|
+
const designRoot = scratchDesignRoot();
|
|
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
|
+
});
|
|
120
|
+
|
|
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) =>
|
|
126
|
+
new Response(null, { status: init?.method === 'HEAD' ? 404 : 413 })) as typeof fetch;
|
|
127
|
+
const r = await pushAssets({
|
|
128
|
+
designRoot,
|
|
129
|
+
hubUrl: 'https://x.example',
|
|
130
|
+
token: () => 't',
|
|
131
|
+
fetchImpl,
|
|
132
|
+
log: { log: () => {}, warn: () => {} },
|
|
133
|
+
});
|
|
134
|
+
expect(r.pushed).toEqual([]);
|
|
135
|
+
expect(r.failed).toEqual([{ key: 'system/ds/assets/logos/x.svg', reason: 'HTTP 413' }]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('no assets dir → zero network calls', async () => {
|
|
139
|
+
const designRoot = scratchDesignRoot();
|
|
140
|
+
let calls = 0;
|
|
141
|
+
const fetchImpl = (async () => {
|
|
142
|
+
calls += 1;
|
|
143
|
+
return new Response(null, { status: 200 });
|
|
144
|
+
}) as typeof fetch;
|
|
145
|
+
const r = await pushAssets({
|
|
146
|
+
designRoot,
|
|
147
|
+
hubUrl: 'https://x.example',
|
|
148
|
+
token: () => 't',
|
|
149
|
+
fetchImpl,
|
|
150
|
+
log: { log: () => {}, warn: () => {} },
|
|
151
|
+
});
|
|
152
|
+
expect(calls).toBe(0);
|
|
153
|
+
expect(r).toEqual({ pushed: [], skipped: 0, failed: [] });
|
|
154
|
+
});
|
|
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
|
+
});
|
|
@@ -61,6 +61,19 @@ describe('connection monitor', () => {
|
|
|
61
61
|
expect(monitor.snapshot().state).toBe('online');
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
+
test('the snapshot carries a stable startedAt — the clock a stall is measured against', () => {
|
|
65
|
+
const { clock, monitor } = makeMonitor();
|
|
66
|
+
const born = monitor.snapshot().startedAt;
|
|
67
|
+
expect(typeof born).toBe('number');
|
|
68
|
+
// It must NOT move with later emits — `updatedAt` does that job, and a
|
|
69
|
+
// rejection storm refreshing `updatedAt` forever is exactly why a second,
|
|
70
|
+
// fixed stamp exists.
|
|
71
|
+
clock.advance(60_000);
|
|
72
|
+
monitor.noteProviderStatus('p1', 'connecting');
|
|
73
|
+
expect(monitor.snapshot().startedAt).toBe(born);
|
|
74
|
+
expect(monitor.snapshot().updatedAt).toBeGreaterThan(born as number);
|
|
75
|
+
});
|
|
76
|
+
|
|
64
77
|
test('a link nobody answers still escalates to offline', () => {
|
|
65
78
|
// The seed change must not create a state that waits forever: a provider
|
|
66
79
|
// that only ever reports 'connecting' has to reach the offline banner on
|
|
@@ -256,3 +269,69 @@ describe('what this run pulled down', () => {
|
|
|
256
269
|
expect(pulled?.count).toBe(50);
|
|
257
270
|
});
|
|
258
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
|
+
});
|
|
@@ -44,6 +44,11 @@ describe('normalizeUrl', () => {
|
|
|
44
44
|
test('preserves non-root path case', () => {
|
|
45
45
|
expect(normalizeUrl('https://Hub.example.com/Path')).toBe('https://hub.example.com/Path');
|
|
46
46
|
});
|
|
47
|
+
|
|
48
|
+
test('rejects embedded credentials (claim-a hardening — user:pass@ survives toString)', () => {
|
|
49
|
+
expect(() => normalizeUrl('https://evil@proj.cloud.maude.sh')).toThrow(/embedded credentials/);
|
|
50
|
+
expect(() => normalizeUrl('https://u:p@proj.cloud.maude.sh')).toThrow(/embedded credentials/);
|
|
51
|
+
});
|
|
47
52
|
});
|
|
48
53
|
|
|
49
54
|
// Write the test hubs.json with mode 0600 so the DDR-054 §2h mode-warning
|