@1agh/maude 0.58.3 → 0.60.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 (81) 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 +1180 -242
  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 +320 -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 +40 -0
  17. package/apps/studio/client/styles/4-components.css +4 -4
  18. package/apps/studio/context.ts +4 -0
  19. package/apps/studio/dist/client.bundle.js +772 -772
  20. package/apps/studio/dist/styles.css +1 -1
  21. package/apps/studio/exporters/video-encode-lib.ts +8 -5
  22. package/apps/studio/exporters/video.ts +10 -0
  23. package/apps/studio/figma/assets.test.ts +92 -0
  24. package/apps/studio/figma/assets.ts +63 -9
  25. package/apps/studio/figma/codegen-client.test.ts +276 -0
  26. package/apps/studio/figma/codegen-client.ts +509 -0
  27. package/apps/studio/figma/codegen-fonts.test.ts +103 -0
  28. package/apps/studio/figma/codegen-fonts.ts +195 -0
  29. package/apps/studio/figma/codegen-values.test.ts +179 -0
  30. package/apps/studio/figma/codegen-values.ts +270 -0
  31. package/apps/studio/figma/endpoints.ts +73 -0
  32. package/apps/studio/figma/fig-decode.test.ts +788 -0
  33. package/apps/studio/figma/fig-decode.ts +839 -0
  34. package/apps/studio/figma/fig-differential.test.ts +182 -0
  35. package/apps/studio/figma/fig-kiwi.ts +410 -0
  36. package/apps/studio/figma/fig-translator.test.ts +192 -0
  37. package/apps/studio/figma/fig-vector.test.ts +113 -0
  38. package/apps/studio/figma/fig-vector.ts +145 -0
  39. package/apps/studio/figma/fig-zip.ts +270 -0
  40. package/apps/studio/figma/from-codegen.test.ts +408 -0
  41. package/apps/studio/figma/from-codegen.ts +1103 -0
  42. package/apps/studio/figma/sanitize.test.ts +69 -0
  43. package/apps/studio/figma/sanitize.ts +146 -47
  44. package/apps/studio/figma/tailwind-map.test.ts +142 -0
  45. package/apps/studio/figma/tailwind-map.ts +545 -0
  46. package/apps/studio/figma/to-artboard.ts +41 -1
  47. package/apps/studio/figma/to-render.ts +25 -3
  48. package/apps/studio/figma/types.ts +6 -1
  49. package/apps/studio/http.ts +94 -0
  50. package/apps/studio/sync/asset-push-worker.ts +84 -0
  51. package/apps/studio/sync/asset-push.ts +441 -39
  52. package/apps/studio/sync/asset-sweep.ts +262 -0
  53. package/apps/studio/sync/connection-state.ts +71 -3
  54. package/apps/studio/sync/index.ts +39 -6
  55. package/apps/studio/sync/presentation.ts +21 -0
  56. package/apps/studio/sync/status.ts +18 -0
  57. package/apps/studio/sync/supervisor.ts +20 -0
  58. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  59. package/apps/studio/test/figma-explode.test.ts +438 -0
  60. package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
  61. package/apps/studio/test/import-figma.test.ts +192 -4
  62. package/apps/studio/test/sync-asset-push-worker.test.ts +183 -0
  63. package/apps/studio/test/sync-asset-push.test.ts +639 -47
  64. package/apps/studio/test/sync-asset-sweep.test.ts +243 -0
  65. package/apps/studio/test/sync-connection-state.test.ts +66 -0
  66. package/apps/studio/test/sync-panel-surface.test.ts +123 -0
  67. package/apps/studio/test/sync-resync-routes.test.ts +125 -0
  68. package/apps/studio/test/sync-status.test.ts +28 -0
  69. package/apps/studio/test/sync-supervisor.test.ts +46 -0
  70. package/apps/studio/test/timeline-comp-target.test.ts +139 -0
  71. package/apps/studio/test/video-comp.test.ts +81 -1
  72. package/apps/studio/test/video-encode-lib.test.ts +63 -0
  73. package/apps/studio/use-artboard-drag.tsx +37 -3
  74. package/apps/studio/video-comp.tsx +51 -0
  75. package/apps/studio/whats-new.json +87 -0
  76. package/cli/commands/design.mjs +7 -0
  77. package/cli/commands/kg.mjs +8 -1
  78. package/cli/commands/kg.test.mjs +24 -0
  79. package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
  80. package/cli/lib/figma-import-controls.test.mjs +70 -0
  81. package/package.json +8 -8
@@ -0,0 +1,243 @@
1
+ // The PARENT half of the out-of-process asset sweep.
2
+ //
3
+ // The child's own contract is `sync-asset-push-worker.test.ts`; here the child
4
+ // is a stand-in script, because what needs pinning is the parent's behaviour
5
+ // when that child misbehaves — and a real sweep cannot be made to segfault on
6
+ // demand. Every case below is a way the sweep can end, and the property under
7
+ // test is the same one each time: the parent always produces a FINAL emit, so
8
+ // the panel can never sit at "92 of 182" forever.
9
+
10
+ import { describe, expect, test } from 'bun:test';
11
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+
15
+ import type { AssetPushProgress } from '../sync/asset-push.ts';
16
+ import { assetWorkerScript, runAssetSweep } from '../sync/asset-sweep.ts';
17
+
18
+ const dirs: string[] = [];
19
+
20
+ /** Write a stand-in child and return its path. */
21
+ function fakeChild(body: string): string {
22
+ const dir = mkdtempSync(join(tmpdir(), 'sweep-parent-'));
23
+ dirs.push(dir);
24
+ const p = join(dir, 'child.ts');
25
+ writeFileSync(p, body);
26
+ return p;
27
+ }
28
+
29
+ function progressLine(over: Partial<AssetPushProgress> = {}): string {
30
+ const base = {
31
+ t: 'progress',
32
+ total: 3,
33
+ done: 1,
34
+ pushed: 1,
35
+ skipped: 0,
36
+ failedCount: 0,
37
+ failures: [],
38
+ active: 'system/ds/assets/logos/a.svg',
39
+ finished: false,
40
+ };
41
+ return JSON.stringify({ ...base, ...over });
42
+ }
43
+
44
+ function sweep(script: string, onProgress: (p: AssetPushProgress) => void) {
45
+ const designRoot = mkdtempSync(join(tmpdir(), 'sweep-root-'));
46
+ dirs.push(designRoot);
47
+ return runAssetSweep({
48
+ designRoot,
49
+ hubUrl: 'https://hub.example',
50
+ token: () => 'secret-token',
51
+ onProgress,
52
+ script,
53
+ log: { log: () => {}, warn: () => {} },
54
+ });
55
+ }
56
+
57
+ describe('a sweep that finishes', () => {
58
+ test('progress reaches the panel and the result comes back', async () => {
59
+ const seen: AssetPushProgress[] = [];
60
+ const child = fakeChild(`
61
+ process.stdout.write(${JSON.stringify(`${progressLine()}\n`)});
62
+ process.stdout.write(${JSON.stringify(`${progressLine({ done: 3, pushed: 3, active: null, finished: true })}\n`)});
63
+ process.stdout.write(JSON.stringify({ t: 'result', pushed: ['a', 'b', 'c'], skipped: 0, failed: [] }) + '\\n');
64
+ `);
65
+ const result = await sweep(child, (p) => seen.push(p)).done;
66
+
67
+ expect(result).toEqual({ pushed: ['a', 'b', 'c'], skipped: 0, failed: [] });
68
+ expect(seen.length).toBe(2);
69
+ expect(seen[1].finished).toBe(true);
70
+ });
71
+
72
+ test('unparseable output is dropped, not fatal', async () => {
73
+ const seen: AssetPushProgress[] = [];
74
+ const child = fakeChild(`
75
+ process.stdout.write('a stray console.log from an import\\n');
76
+ process.stdout.write(${JSON.stringify(`${progressLine()}\n`)});
77
+ process.stdout.write('{ half a li');
78
+ process.stdout.write('ne }\\n');
79
+ process.stdout.write(JSON.stringify({ t: 'result', pushed: [], skipped: 1, failed: [] }) + '\\n');
80
+ `);
81
+ const result = await sweep(child, (p) => seen.push(p)).done;
82
+
83
+ expect(result).toEqual({ pushed: [], skipped: 1, failed: [] });
84
+ expect(seen.length).toBe(1);
85
+ });
86
+ });
87
+
88
+ describe('a sweep that dies is a REPORTED failure, never a stall', () => {
89
+ test('a signalled child names the signal in the final emit', async () => {
90
+ const seen: AssetPushProgress[] = [];
91
+ const child = fakeChild(`
92
+ process.stdout.write(${JSON.stringify(`${progressLine({ done: 92, total: 182, pushed: 92 })}\n`)});
93
+ await Bun.sleep(20);
94
+ process.kill(process.pid, 'SIGKILL');
95
+ await new Promise(() => {});
96
+ `);
97
+ const result = await sweep(child, (p) => seen.push(p)).done;
98
+
99
+ expect(result).toBeNull();
100
+ const final = seen[seen.length - 1];
101
+ expect(final.finished).toBe(true);
102
+ // The counts it got to are KEPT — the person is told where it stopped.
103
+ expect(final.done).toBe(92);
104
+ expect(final.total).toBe(182);
105
+ expect(final.failedCount).toBe(1);
106
+ expect(final.failures[0].reason).toContain('SIGKILL');
107
+ expect(final.active).toBeNull();
108
+ });
109
+
110
+ test('a child that reports an error carries the message through', async () => {
111
+ const seen: AssetPushProgress[] = [];
112
+ const child = fakeChild(`
113
+ process.stdout.write(JSON.stringify({ t: 'error', message: 'the design root vanished' }) + '\\n');
114
+ process.exit(1);
115
+ `);
116
+ const result = await sweep(child, (p) => seen.push(p)).done;
117
+
118
+ expect(result).toBeNull();
119
+ expect(seen[seen.length - 1].failures[0].reason).toContain('the design root vanished');
120
+ });
121
+
122
+ test('a missing worker script is reported — never a silent in-process fallback', async () => {
123
+ const seen: AssetPushProgress[] = [];
124
+ const result = await sweep(join(tmpdir(), 'no-such-worker-xyz.ts'), (p) => seen.push(p)).done;
125
+
126
+ expect(result).toBeNull();
127
+ expect(seen.length).toBe(1);
128
+ expect(seen[0].finished).toBe(true);
129
+ expect(seen[0].failedCount).toBe(1);
130
+ });
131
+
132
+ test('a result line with a non-zero exit is NOT trusted as success', async () => {
133
+ const seen: AssetPushProgress[] = [];
134
+ const child = fakeChild(`
135
+ process.stdout.write(JSON.stringify({ t: 'result', pushed: ['a'], skipped: 0, failed: [] }) + '\\n');
136
+ process.exit(3);
137
+ `);
138
+ const result = await sweep(child, (p) => seen.push(p)).done;
139
+
140
+ expect(result).toBeNull();
141
+ expect(seen[seen.length - 1].failures[0].reason).toContain('3');
142
+ });
143
+ });
144
+
145
+ describe('cancel ends the sweep, and says that is what happened', () => {
146
+ test('a wedged child is killed and the lane closes as cancelled', async () => {
147
+ const seen: AssetPushProgress[] = [];
148
+ const child = fakeChild(`
149
+ process.stdout.write(${JSON.stringify(`${progressLine()}\n`)});
150
+ await new Promise(() => {});
151
+ `);
152
+ const handle = sweep(child, (p) => seen.push(p));
153
+ // Wait for the first emit so the child is definitely alive — BOUNDED, so a
154
+ // machine that cannot spawn fails this test instead of hanging the suite.
155
+ for (let i = 0; seen.length === 0 && i < 500; i++) await Bun.sleep(10);
156
+ expect(seen.length).toBeGreaterThan(0);
157
+ handle.cancel();
158
+ const result = await handle.done;
159
+
160
+ expect(result).toBeNull();
161
+ const final = seen[seen.length - 1];
162
+ expect(final.finished).toBe(true);
163
+ expect(final.failures[0].reason).toBe('cancelled');
164
+ });
165
+
166
+ test('cancel is idempotent and safe after the sweep already ended', async () => {
167
+ const child = fakeChild(
168
+ `process.stdout.write(JSON.stringify({ t: 'result', pushed: [], skipped: 0, failed: [] }) + '\\n');`
169
+ );
170
+ const handle = sweep(child, () => {});
171
+ await handle.done;
172
+ expect(() => {
173
+ handle.cancel();
174
+ handle.cancel();
175
+ }).not.toThrow();
176
+ });
177
+ });
178
+
179
+ describe('the credential never touches argv', () => {
180
+ test('the token goes to a 0600 file that is gone when the sweep ends', async () => {
181
+ let sawArgv = '';
182
+ let sawToken = '';
183
+ let sawMode = '';
184
+ const child = fakeChild(`
185
+ const { readFileSync, statSync } = await import('node:fs');
186
+ const tokenFile = process.argv[4];
187
+ process.stdout.write(JSON.stringify({
188
+ t: 'result', pushed: [], skipped: 0,
189
+ failed: [
190
+ { key: 'argv', reason: process.argv.slice(2).join(' ') },
191
+ { key: 'token', reason: readFileSync(tokenFile, 'utf8') },
192
+ { key: 'mode', reason: (statSync(tokenFile).mode & 0o777).toString(8) },
193
+ { key: 'file', reason: tokenFile },
194
+ ],
195
+ }) + '\\n');
196
+ `);
197
+ const result = await sweep(child, () => {});
198
+ const failed = (await result.done)?.failed ?? [];
199
+ sawArgv = failed.find((f) => f.key === 'argv')?.reason ?? '';
200
+ sawToken = failed.find((f) => f.key === 'token')?.reason ?? '';
201
+ sawMode = failed.find((f) => f.key === 'mode')?.reason ?? '';
202
+ const tokenFile = failed.find((f) => f.key === 'file')?.reason ?? '';
203
+
204
+ expect(sawToken).toBe('secret-token');
205
+ // `ps` shows argv to every user on the machine.
206
+ expect(sawArgv).not.toContain('secret-token');
207
+ expect(sawMode).toBe('600');
208
+ expect(existsSync(tokenFile)).toBe(false);
209
+ });
210
+ });
211
+
212
+ describe('the credential stays current across the boundary', () => {
213
+ test('the token file is written atomically — the child can never read half of it', async () => {
214
+ // The child re-reads this file per request; a plain overwrite is observable
215
+ // half-done, and half a bearer token is a 401 on every remaining upload.
216
+ const src = readFileSync(join(import.meta.dir, '..', 'sync', 'asset-sweep.ts'), 'utf8');
217
+ expect(src).toContain('renameSync(tmp, tokenFile)');
218
+ // And the parent keeps it current: a renewal mid-sweep must reach the child,
219
+ // which is why `token` is a GETTER and not the string it resolved to.
220
+ expect(src).toMatch(/token: \(\) => string/);
221
+ expect(src).toMatch(/setInterval\(/);
222
+ });
223
+ });
224
+
225
+ describe('the worker ships where the server can find it', () => {
226
+ test('the resolved script is a real file on disk (DDR-045, not a bunfs path)', () => {
227
+ const script = assetWorkerScript();
228
+ expect(script.endsWith(join('sync', 'asset-push-worker.ts'))).toBe(true);
229
+ expect(existsSync(script)).toBe(true);
230
+ });
231
+ });
232
+
233
+ // Temp roots outlive individual tests (the token-file case reads one back), so
234
+ // they are swept once at the end rather than per-test.
235
+ process.on('exit', () => {
236
+ for (const d of dirs) {
237
+ try {
238
+ if (existsSync(d) && readdirSync(d)) rmSync(d, { recursive: true, force: true });
239
+ } catch {
240
+ /* best-effort */
241
+ }
242
+ }
243
+ });
@@ -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
+ });
@@ -0,0 +1,123 @@
1
+ // feature-sync-progress-modal — the Sync panel + HUB SYNC chip surface.
2
+ //
3
+ // Source-level assertions (cloud-shell-surfaces style): what these pin is
4
+ // exactly what would regress — someone dropping the linked-only gate (a solo
5
+ // project would get a dock tab that opens onto nothing), rewiring the chip
6
+ // away from the dock helpers (breaking the one-panel-per-side invariant), or
7
+ // the panel inventing sync vocabulary / dropping the a11y live region the
8
+ // CloudBar note established.
9
+
10
+ import { describe, expect, test } from 'bun:test';
11
+ import { readFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+
14
+ const STUDIO = join(import.meta.dir, '..');
15
+ const APP = readFileSync(join(STUDIO, 'client', 'app.jsx'), 'utf8');
16
+ const PANEL = readFileSync(join(STUDIO, 'client', 'panels', 'SyncPanel.jsx'), 'utf8');
17
+
18
+ describe('the Sync panel exists only where a hub link exists', () => {
19
+ test('sync is a dock panel with a right-side default', () => {
20
+ expect(APP).toContain("{ id: 'sync', label: 'Sync' }");
21
+ expect(APP).toMatch(/sync: 'right'/);
22
+ });
23
+
24
+ test('panelAvailable gates sync on a live syncStatus — solo projects get no tab', () => {
25
+ expect(APP).toContain("if (id === 'sync') return !!syncStatus;");
26
+ });
27
+
28
+ test('the chip toggles through the dock helpers, not a raw setState', () => {
29
+ // toggleRightPanel keeps the one-panel-per-side invariant; a bare
30
+ // setSyncPanelOpen(true) here would let Sync render behind an open sibling.
31
+ expect(APP).toMatch(/onOpenSync=\{syncStatus \? \(\) => toggleRightPanel\('sync'\)/);
32
+ });
33
+
34
+ test('the chip is a real button with pressed-state semantics', () => {
35
+ expect(APP).toContain('data-testid="open-sync"');
36
+ expect(APP).toMatch(/aria-pressed=\{syncOpen\}/);
37
+ });
38
+
39
+ test('the span fallback remains for sessions without a handler', () => {
40
+ // Older wiring / no-handler renders must keep the passive chip rather
41
+ // than a dead button.
42
+ expect(APP).toMatch(/onOpenSync \? \(/);
43
+ });
44
+ });
45
+
46
+ describe('the Sync panel speaks the presentation vocabulary, safely', () => {
47
+ test('the header sentence comes from syncPresentation — the one shared rule', () => {
48
+ expect(PANEL).toContain("from '../../sync/presentation.ts'");
49
+ expect(PANEL).toContain('syncPresentation(status');
50
+ });
51
+
52
+ test('rows map DocSyncState onto existing words, never new ones', () => {
53
+ expect(PANEL).toMatch(/pending: 'syncing'/);
54
+ expect(PANEL).toMatch(/connected: 'synced'/);
55
+ expect(PANEL).toMatch(/'auth-rejected': 'refused'/);
56
+ });
57
+
58
+ test('every name that reaches the DOM goes through safeName', () => {
59
+ // Slugs and asset keys are local, but the payload is read back off disk —
60
+ // bounded text-only rendering is the house rule (DDR-054).
61
+ expect(PANEL).toContain('import { safeDetail, safeName, syncPresentation }');
62
+ expect(PANEL).not.toMatch(/dangerouslySetInnerHTML/);
63
+ });
64
+
65
+ test('the live header is a polite status region (the CloudBar a11y pattern)', () => {
66
+ expect(PANEL).toMatch(/role="status" aria-live="polite"/);
67
+ });
68
+
69
+ test('the asset lane renders from the payload, with the retry promise', () => {
70
+ expect(PANEL).toContain('data-testid="sync-assets"');
71
+ expect(PANEL).toContain('retry on the next launch');
72
+ });
73
+ });
74
+
75
+ describe('the panel reads the off-disk payload fail-closed (DDR-102 discipline)', () => {
76
+ test('items, assets and the header counts all pass a readCounts-style gate', () => {
77
+ // `_sync.json` is JSON.parse with no schema — a partial write or older
78
+ // producer must degrade to "nothing to show", never crash or print NaN
79
+ // (security review 2026-08-11, defender W1).
80
+ expect(PANEL).toContain('function readItems(');
81
+ expect(PANEL).toContain('function readAssets(');
82
+ expect(PANEL).toContain('readItems(status?.items)');
83
+ expect(PANEL).toContain('readAssets(status?.assets)');
84
+ expect(PANEL).toMatch(/Number\.isInteger/);
85
+ // The header chip validates the same counts the note fails closed on.
86
+ expect(PANEL).toMatch(
87
+ /\[rawDocs\.synced, rawDocs\.pending, rawDocs\.rejected\]\.every\(isCount\)/
88
+ );
89
+ });
90
+ });
91
+
92
+ // feature-sync-resync-and-out-of-process-sweep — the panel gained the one
93
+ // control the whole feature exists for. What is pinned here is not that a
94
+ // button renders, but the three properties that make it safe to press: it
95
+ // re-runs the WHOLE sync (not just assets), it cannot be spammed, and cancel
96
+ // reaches only the sweep.
97
+ describe('the Resync control', () => {
98
+ test('it calls the whole-sync route, not an asset-only one', () => {
99
+ expect(PANEL).toContain("fetch('/_api/sync/resync'");
100
+ expect(PANEL).toContain('data-testid="sync-resync"');
101
+ });
102
+
103
+ test('it disables itself while running AND during the cooldown', () => {
104
+ // A resync re-authenticates every document against the DDR-102 600/min
105
+ // bucket; without the cooldown an impatient person pins their own hub.
106
+ expect(PANEL).toContain('disabled={resyncing || cooling}');
107
+ expect(PANEL).toMatch(/RESYNC_COOLDOWN_MS = [\d_]+/);
108
+ });
109
+
110
+ test('a 409 reads as "already restarting", never as a failure', () => {
111
+ expect(PANEL).toMatch(/res\.status === 409/);
112
+ });
113
+
114
+ test('cancel is scoped to the sweep and only offered while one runs', () => {
115
+ expect(PANEL).toContain("fetch('/_api/sync/cancel-assets'");
116
+ expect(PANEL).toContain('data-testid="sync-assets-cancel"');
117
+ expect(PANEL).toMatch(/!assets\.finished && \(/);
118
+ });
119
+
120
+ test('the note is sanitized too — server detail is still bounded text', () => {
121
+ expect(PANEL).toContain("safeDetail(note, '')");
122
+ });
123
+ });
@@ -0,0 +1,125 @@
1
+ // `/_api/sync/resync` + `/_api/sync/cancel-assets` — the MAIN-origin half.
2
+ //
3
+ // `canvas-origin-gate.test.ts` already asserts both routes are unreachable from
4
+ // the segregated canvas origin. That alone proves the wrong property: the main
5
+ // origin is reachable from any website the user happens to visit while the dev
6
+ // server is up. A cross-site POST that lands here would re-authenticate every
7
+ // document against the person's own hub and re-run a whole-project upload — an
8
+ // amplification primitive against their own DDR-102 rate-limit budget, with an
9
+ // opaque response and a very real side effect.
10
+ //
11
+ // So this file asserts the complementary half — the same posture the Figma
12
+ // routes carry (DDR-216 D3): cross-origin write → 403, foreign Host → 403,
13
+ // wrong method → 405. Both halves are required; neither is sufficient.
14
+
15
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
16
+ import { readFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import type { Subprocess } from 'bun';
19
+
20
+ import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
21
+
22
+ const HTTP = readFileSync(join(import.meta.dir, '..', 'http.ts'), 'utf8');
23
+
24
+ let proc: Subprocess;
25
+ let base: string;
26
+
27
+ const ROUTES = ['/_api/sync/resync', '/_api/sync/cancel-assets'];
28
+
29
+ beforeAll(async () => {
30
+ const { root } = makeSandbox();
31
+ const port = nextPort();
32
+ base = `http://localhost:${port}`;
33
+ proc = await bootServer(root, port);
34
+ });
35
+
36
+ afterAll(async () => {
37
+ await killProc(proc);
38
+ });
39
+
40
+ describe('cross-origin writes are refused (the CSRF guard)', () => {
41
+ for (const route of ROUTES) {
42
+ test(`POST ${route} from another origin is 403`, async () => {
43
+ const res = await fetch(`${base}${route}`, {
44
+ method: 'POST',
45
+ headers: { Origin: 'https://evil.example' },
46
+ });
47
+ expect(res.status).toBe(403);
48
+ });
49
+
50
+ test(`a CORS-simple POST to ${route} is still refused`, async () => {
51
+ // No preflight, unreadable response — but the side effect would land.
52
+ const res = await fetch(`${base}${route}`, {
53
+ method: 'POST',
54
+ headers: { 'Content-Type': 'text/plain', Origin: 'https://evil.example' },
55
+ body: '{}',
56
+ });
57
+ expect(res.status).toBe(403);
58
+ });
59
+ }
60
+ });
61
+
62
+ describe('a foreign Host header is refused (the DNS-rebinding guard)', () => {
63
+ for (const route of ROUTES) {
64
+ test(`POST ${route} with a rebinding Host is 403`, async () => {
65
+ const res = await fetch(`${base}${route}`, {
66
+ method: 'POST',
67
+ headers: { Host: 'sync.127.0.0.1.nip.io' },
68
+ });
69
+ expect(res.status).toBe(403);
70
+ });
71
+ }
72
+ });
73
+
74
+ describe('method gating', () => {
75
+ for (const route of ROUTES) {
76
+ test(`GET ${route} is 405 — resync is a write`, async () => {
77
+ expect((await fetch(`${base}${route}`)).status).toBe(405);
78
+ });
79
+ }
80
+ });
81
+
82
+ // Source-level, because these two properties only show up on a LINKED project
83
+ // with a live hub — a state a booted sandbox cannot reach — and they are
84
+ // exactly the two that would be silently wrong.
85
+ describe('the route is wired to the whole-sync restart, and refuses rather than queues', () => {
86
+ test('restart() is called with NO argument — this route can never unlink', () => {
87
+ // `restart(null)` is the DETACH lane: it clears the in-memory link and
88
+ // cycles to solo. A resync that passed a falsy value through would
89
+ // disconnect the person's project instead of re-checking it.
90
+ expect(HTTP).toContain('const sync = await control.restart();');
91
+ expect(HTTP).not.toMatch(/sync\/resync[\s\S]{0,900}restart\(null\)/);
92
+ });
93
+
94
+ test('a cycle in flight is a 409, not a second full re-link', () => {
95
+ expect(HTTP).toMatch(/control\.busy\?\.\(\)/);
96
+ expect(HTTP).toMatch(/status: 409[\s\S]{0,120}'busy'/);
97
+ });
98
+
99
+ test('cancel reaches the sweep through the live runtime, not the supervisor', () => {
100
+ expect(HTTP).toContain('ctx.syncControl?.current?.()?.cancelAssetSweep() ?? false');
101
+ });
102
+ });
103
+
104
+ describe('same-origin behaviour on an unlinked project', () => {
105
+ test('resync answers honestly rather than pretending it synced', async () => {
106
+ // The sandbox project has no linkedHub, so the supervisor is present but
107
+ // declines. The panel hides the control in this state; the ROUTE still has
108
+ // to give a truthful answer to anything that reaches it.
109
+ const res = await fetch(`${base}/_api/sync/resync`, { method: 'POST' });
110
+ expect(res.status).toBe(200);
111
+ const json = (await res.json()) as {
112
+ ok: boolean;
113
+ sync?: { syncing: boolean; reason?: string };
114
+ };
115
+ expect(json.ok).toBe(true);
116
+ expect(json.sync?.syncing).toBe(false);
117
+ expect(json.sync?.reason).toBe('unlinked');
118
+ });
119
+
120
+ test('cancelling with no sweep in flight says so instead of erroring', async () => {
121
+ const res = await fetch(`${base}/_api/sync/cancel-assets`, { method: 'POST' });
122
+ expect(res.status).toBe(200);
123
+ expect(await res.json()).toEqual({ ok: true, cancelled: false });
124
+ });
125
+ });
@@ -144,3 +144,31 @@ describe('sync status store — DDR-102 additive fields', () => {
144
144
  expect(p.state).toBe('online');
145
145
  });
146
146
  });
147
+
148
+ describe('sync status store — asset lane (feature-sync-progress-modal)', () => {
149
+ test('updateAssets() merges progress into the payload + flushes', () => {
150
+ const { store, writes, broadcasts } = makeStore();
151
+ const progress = {
152
+ total: 10,
153
+ done: 4,
154
+ pushed: 3,
155
+ skipped: 1,
156
+ failedCount: 0,
157
+ failures: [],
158
+ active: 'assets/hero.png',
159
+ finished: false,
160
+ };
161
+ store.updateAssets(progress);
162
+ expect(writes[writes.length - 1].assets).toEqual(progress);
163
+ expect(broadcasts[broadcasts.length - 1].assets).toEqual(progress);
164
+ // …and a later monitor update keeps the asset lane (separate slices).
165
+ store.update(snap({ state: 'online' }));
166
+ expect(store.get().assets).toEqual(progress);
167
+ });
168
+
169
+ test('payloads before the first asset emit carry no assets field', () => {
170
+ const { store } = makeStore();
171
+ store.update(snap());
172
+ expect('assets' in store.get()).toBe(false);
173
+ });
174
+ });
@@ -77,6 +77,7 @@ function fakeRuntime(log: string[], id: string, size = 3): SyncRuntime {
77
77
  size: () => size,
78
78
  agentFor: () => undefined,
79
79
  status: () => null,
80
+ cancelAssetSweep: () => false,
80
81
  };
81
82
  }
82
83
 
@@ -188,6 +189,50 @@ describe('createSyncSupervisor', () => {
188
189
  rmSync(ctx.paths.repoRoot, { recursive: true, force: true });
189
190
  });
190
191
 
192
+ // feature-sync-resync-and-out-of-process-sweep — `busy()` is what lets the
193
+ // Resync route answer 409 instead of quietly queueing a second full re-link
194
+ // of every canvas. It reports the chain; it must never BE the chain.
195
+ test('busy() reports a cycle in flight and clears when it settles', async () => {
196
+ const ctx = makeCtx({ url: 'https://hub.example', linkedAt: 1 });
197
+ let release: () => void = () => {};
198
+ const held = new Promise<void>((r) => {
199
+ release = r;
200
+ });
201
+ const sup = createSyncSupervisor(ctx, {}, () => ({
202
+ start: () => held,
203
+ stop: async () => {},
204
+ size: () => 1,
205
+ agentFor: () => undefined,
206
+ status: () => null,
207
+ cancelAssetSweep: () => false,
208
+ }));
209
+
210
+ expect(sup.busy()).toBe(false);
211
+ const cycle = sup.start();
212
+ expect(sup.busy()).toBe(true);
213
+ release();
214
+ await cycle;
215
+ expect(sup.busy()).toBe(false);
216
+ rmSync(ctx.paths.repoRoot, { recursive: true, force: true });
217
+ });
218
+
219
+ test('busy() clears after a cycle that THREW — Resync never wedges shut', async () => {
220
+ const ctx = makeCtx({ url: 'https://hub.example', linkedAt: 1 });
221
+ const sup = createSyncSupervisor(ctx, {}, () => ({
222
+ start: async () => {
223
+ throw new Error('nope');
224
+ },
225
+ stop: async () => {},
226
+ size: () => 0,
227
+ agentFor: () => undefined,
228
+ status: () => null,
229
+ cancelAssetSweep: () => false,
230
+ }));
231
+ await sup.start();
232
+ expect(sup.busy()).toBe(false);
233
+ rmSync(ctx.paths.repoRoot, { recursive: true, force: true });
234
+ });
235
+
191
236
  test('a throwing start leaves the server up and answers with the reason', async () => {
192
237
  const ctx = makeCtx({ url: 'https://hub.example', linkedAt: 1 });
193
238
  const sup = createSyncSupervisor(ctx, {}, () => ({
@@ -198,6 +243,7 @@ describe('createSyncSupervisor', () => {
198
243
  size: () => 0,
199
244
  agentFor: () => undefined,
200
245
  status: () => null,
246
+ cancelAssetSweep: () => false,
201
247
  }));
202
248
  const out = await sup.start();
203
249
  expect(out.syncing).toBe(false);