@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.
Files changed (140) hide show
  1. package/apps/studio/annotations-bindings.ts +83 -4
  2. package/apps/studio/annotations-layer.tsx +49 -15
  3. package/apps/studio/api.ts +6 -1
  4. package/apps/studio/bin/_fetch-asset.mjs +169 -5
  5. package/apps/studio/bin/_import-asset.mjs +90 -0
  6. package/apps/studio/bin/_import-figma.mjs +1775 -0
  7. package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
  8. package/apps/studio/bin/_perf-probe.mjs +228 -0
  9. package/apps/studio/bin/_perf-shared.mjs +345 -0
  10. package/apps/studio/bin/_video-playwright.mjs +103 -7
  11. package/apps/studio/bin/import-figma.sh +47 -0
  12. package/apps/studio/bin/perf.sh +228 -0
  13. package/apps/studio/bin/read-annotations.mjs +11 -1
  14. package/apps/studio/bin/smoke.sh +49 -5
  15. package/apps/studio/bun.lock +16 -22
  16. package/apps/studio/canvas-edit.ts +29 -5
  17. package/apps/studio/canvas-lib.tsx +148 -6
  18. package/apps/studio/client/app.jsx +196 -38
  19. package/apps/studio/client/export-center.jsx +42 -4
  20. package/apps/studio/client/panels/CloudBar.jsx +92 -1
  21. package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
  22. package/apps/studio/client/panels/GitPanel.jsx +26 -6
  23. package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
  24. package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
  25. package/apps/studio/client/panels/SyncPanel.jsx +229 -0
  26. package/apps/studio/client/panels/TimelinePanel.jsx +31 -3
  27. package/apps/studio/client/panels/timeline-comp-target.js +101 -0
  28. package/apps/studio/client/panels/timeline-parse.js +3 -3
  29. package/apps/studio/client/styles/3-shell-maude.css +37 -0
  30. package/apps/studio/client/styles/4-components.css +134 -0
  31. package/apps/studio/clip-ops.ts +93 -17
  32. package/apps/studio/cloud/endpoints.ts +78 -10
  33. package/apps/studio/cloud/renew.ts +183 -0
  34. package/apps/studio/context.ts +2 -1
  35. package/apps/studio/dist/client.bundle.js +1231 -1231
  36. package/apps/studio/dist/runtime/@remotion_media.js +56 -136
  37. package/apps/studio/dist/runtime/@remotion_player.js +18 -18
  38. package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
  39. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
  40. package/apps/studio/dist/runtime/remotion.js +12 -12
  41. package/apps/studio/dist/styles.css +1 -1
  42. package/apps/studio/exporters/_browser-bundles.ts +20 -6
  43. package/apps/studio/exporters/_runtime.ts +19 -0
  44. package/apps/studio/exporters/degraded.ts +92 -0
  45. package/apps/studio/exporters/index.ts +5 -0
  46. package/apps/studio/exporters/jobs.ts +19 -0
  47. package/apps/studio/exporters/unsupported-media.ts +170 -0
  48. package/apps/studio/exporters/video-encode-lib.ts +35 -6
  49. package/apps/studio/exporters/video-render-lib.ts +6 -0
  50. package/apps/studio/exporters/video.ts +72 -1
  51. package/apps/studio/figma/assets.test.ts +464 -0
  52. package/apps/studio/figma/assets.ts +452 -0
  53. package/apps/studio/figma/client.test.ts +395 -0
  54. package/apps/studio/figma/client.ts +513 -0
  55. package/apps/studio/figma/codegen-client.test.ts +276 -0
  56. package/apps/studio/figma/codegen-client.ts +509 -0
  57. package/apps/studio/figma/codegen-fonts.test.ts +103 -0
  58. package/apps/studio/figma/codegen-fonts.ts +195 -0
  59. package/apps/studio/figma/codegen-values.test.ts +179 -0
  60. package/apps/studio/figma/codegen-values.ts +270 -0
  61. package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
  62. package/apps/studio/figma/comments-to-strokes.ts +173 -0
  63. package/apps/studio/figma/endpoints.ts +273 -0
  64. package/apps/studio/figma/fig-decode.test.ts +702 -0
  65. package/apps/studio/figma/fig-decode.ts +617 -0
  66. package/apps/studio/figma/fig-kiwi.ts +410 -0
  67. package/apps/studio/figma/fig-zip.ts +270 -0
  68. package/apps/studio/figma/from-codegen.test.ts +408 -0
  69. package/apps/studio/figma/from-codegen.ts +1103 -0
  70. package/apps/studio/figma/sanitize.test.ts +325 -0
  71. package/apps/studio/figma/sanitize.ts +407 -0
  72. package/apps/studio/figma/style-map.ts +352 -0
  73. package/apps/studio/figma/tailwind-map.test.ts +142 -0
  74. package/apps/studio/figma/tailwind-map.ts +545 -0
  75. package/apps/studio/figma/to-artboard.test.ts +808 -0
  76. package/apps/studio/figma/to-artboard.ts +701 -0
  77. package/apps/studio/figma/to-render.test.ts +180 -0
  78. package/apps/studio/figma/to-render.ts +328 -0
  79. package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
  80. package/apps/studio/figma/to-strokes.test.ts +705 -0
  81. package/apps/studio/figma/to-strokes.ts +749 -0
  82. package/apps/studio/figma/to-tokens.test.ts +321 -0
  83. package/apps/studio/figma/to-tokens.ts +305 -0
  84. package/apps/studio/figma/types.ts +544 -0
  85. package/apps/studio/figma/url.test.ts +167 -0
  86. package/apps/studio/figma/url.ts +160 -0
  87. package/apps/studio/http.ts +176 -0
  88. package/apps/studio/sync/asset-push.ts +432 -0
  89. package/apps/studio/sync/connection-state.ts +82 -3
  90. package/apps/studio/sync/hub-link.ts +63 -7
  91. package/apps/studio/sync/hubs-config.ts +31 -3
  92. package/apps/studio/sync/index.ts +286 -27
  93. package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
  94. package/apps/studio/sync/presentation.ts +45 -1
  95. package/apps/studio/sync/status.ts +18 -0
  96. package/apps/studio/sync/supervisor.ts +5 -1
  97. package/apps/studio/sync/workspace-signin.ts +7 -3
  98. package/apps/studio/test/annotations-bindings.test.ts +150 -12
  99. package/apps/studio/test/canvas-create-api.test.ts +4 -1
  100. package/apps/studio/test/canvas-origin-gate.test.ts +17 -0
  101. package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
  102. package/apps/studio/test/clip-addressing.test.ts +6 -1
  103. package/apps/studio/test/clip-ops.test.ts +5 -1
  104. package/apps/studio/test/cloud-endpoints.test.ts +96 -0
  105. package/apps/studio/test/cloud-renew.test.ts +205 -0
  106. package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
  107. package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
  108. package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
  109. package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
  110. package/apps/studio/test/figma-explode.test.ts +438 -0
  111. package/apps/studio/test/figma-provenance.test.ts +108 -0
  112. package/apps/studio/test/figma-routes.test.ts +294 -0
  113. package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
  114. package/apps/studio/test/git-cloud-posture.test.ts +50 -0
  115. package/apps/studio/test/hub-link.test.ts +11 -0
  116. package/apps/studio/test/import-figma.test.ts +667 -0
  117. package/apps/studio/test/sync-asset-push.test.ts +567 -0
  118. package/apps/studio/test/sync-connection-state.test.ts +79 -0
  119. package/apps/studio/test/sync-hubs-config.test.ts +5 -0
  120. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
  121. package/apps/studio/test/sync-panel-surface.test.ts +90 -0
  122. package/apps/studio/test/sync-path-pull.test.ts +63 -0
  123. package/apps/studio/test/sync-presentation.test.ts +77 -0
  124. package/apps/studio/test/sync-runtime.test.ts +316 -1
  125. package/apps/studio/test/sync-status.test.ts +28 -0
  126. package/apps/studio/test/timeline-comp-target.test.ts +139 -0
  127. package/apps/studio/test/video-comp.test.ts +104 -2
  128. package/apps/studio/test/video-encode-lib.test.ts +63 -0
  129. package/apps/studio/test/workspace-containment.test.ts +1 -0
  130. package/apps/studio/use-artboard-drag.tsx +37 -3
  131. package/apps/studio/video-comp.tsx +121 -6
  132. package/apps/studio/whats-new.json +98 -0
  133. package/apps/studio/workspace-mode.ts +4 -0
  134. package/cli/commands/design.mjs +15 -0
  135. package/cli/commands/kg.mjs +8 -1
  136. package/cli/commands/kg.test.mjs +24 -0
  137. package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
  138. package/cli/lib/figma-import-controls.test.mjs +70 -0
  139. package/package.json +8 -8
  140. package/plugins/flow/.claude-plugin/config.schema.json +3 -3
@@ -30,11 +30,13 @@ import { createConnectionMonitor } from '../sync/connection-state.ts';
30
30
  import {
31
31
  type AwarenessRegistry,
32
32
  buildNoSyncablePayload,
33
+ classifyAuthFailure,
33
34
  createSyncRuntime,
34
35
  discoverCanvases,
35
36
  type SyncProvider,
36
37
  scanCanvases,
37
38
  toWsUrl,
39
+ validExpiry,
38
40
  } from '../sync/index.ts';
39
41
  import { createSyncStatusStore } from '../sync/status.ts';
40
42
 
@@ -1029,6 +1031,49 @@ describe('createDefaultProviderFactory — shared socket multiplexing', () => {
1029
1031
 
1030
1032
  // DDR-102 — auth-failure intelligence (classification, aggregation,
1031
1033
  // destroy-on-permanent + re-probe) and the honest settled boot summary.
1034
+ describe('validExpiry — a hub-reported deadline sane enough to schedule against', () => {
1035
+ const NOW = 1_000_000_000_000;
1036
+ test('accepts a plausible future integer', () => {
1037
+ expect(validExpiry(NOW + 12 * 60 * 60 * 1000, NOW)).toBe(NOW + 12 * 60 * 60 * 1000);
1038
+ });
1039
+ test('rejects a past or present stamp (falls to the invalid-token path instead)', () => {
1040
+ expect(validExpiry(NOW - 1, NOW)).toBeNull();
1041
+ expect(validExpiry(NOW, NOW)).toBeNull();
1042
+ });
1043
+ test('rejects an absurdly far-future stamp (the setTimeout-overflow trap)', () => {
1044
+ expect(validExpiry(NOW + 60 * 24 * 60 * 60 * 1000, NOW)).toBeNull(); // 60 days
1045
+ });
1046
+ test('rejects non-integers and non-numbers', () => {
1047
+ expect(validExpiry(NOW + 1000.5, NOW)).toBeNull();
1048
+ expect(validExpiry('soon', NOW)).toBeNull();
1049
+ expect(validExpiry(Number.NaN, NOW)).toBeNull();
1050
+ expect(validExpiry(Number.POSITIVE_INFINITY, NOW)).toBeNull();
1051
+ expect(validExpiry(undefined, NOW)).toBeNull();
1052
+ });
1053
+ });
1054
+
1055
+ describe('classifyAuthFailure — permanent classes outrank the transient ones', () => {
1056
+ test('the invalid-token bucket refusal classifies as invalid-token, not rate-limit', () => {
1057
+ // The hub's invalid-token bucket says BOTH things. Filing it under
1058
+ // rate-limit (transient) made the runtime retry into the very bucket
1059
+ // refusing it — the alligators incident's 1840-vs-138 log ratio.
1060
+ expect(classifyAuthFailure('invalid token — rate limited, retry in up to 60s')).toBe(
1061
+ 'invalid-token'
1062
+ );
1063
+ });
1064
+
1065
+ test('plain reasons still land in their own classes', () => {
1066
+ expect(classifyAuthFailure('invalid token')).toBe('invalid-token');
1067
+ expect(classifyAuthFailure('token not authorized for this documentName')).toBe(
1068
+ 'not-authorized'
1069
+ );
1070
+ expect(classifyAuthFailure('rate limit exceeded for this token — retry in up to 60s')).toBe(
1071
+ 'rate-limit'
1072
+ );
1073
+ expect(classifyAuthFailure('permission-denied')).toBe('generic');
1074
+ });
1075
+ });
1076
+
1032
1077
  describe('DDR-102 — auth-failure intelligence + boot summary', () => {
1033
1078
  function fakeTimerQueue() {
1034
1079
  let nextId = 1;
@@ -1059,15 +1104,17 @@ describe('DDR-102 — auth-failure intelligence + boot summary', () => {
1059
1104
  function authStubFactory() {
1060
1105
  const made: Array<{
1061
1106
  documentName: string;
1107
+ token: string | undefined;
1062
1108
  destroyed: boolean;
1063
1109
  emitAuthFailure: (reason: string) => void;
1064
1110
  document: Y.Doc;
1065
1111
  }> = [];
1066
- const factory = (args: { documentName: string; document?: Y.Doc }) => {
1112
+ const factory = (args: { documentName: string; token?: string; document?: Y.Doc }) => {
1067
1113
  const document = args.document ?? new Y.Doc();
1068
1114
  const cbs = new Set<(info: { reason: string }) => void>();
1069
1115
  const entry = {
1070
1116
  documentName: args.documentName,
1117
+ token: args.token,
1071
1118
  destroyed: false,
1072
1119
  emitAuthFailure: (reason: string) => {
1073
1120
  for (const cb of cbs) cb({ reason });
@@ -1171,6 +1218,274 @@ describe('DDR-102 — auth-failure intelligence + boot summary', () => {
1171
1218
  }
1172
1219
  });
1173
1220
 
1221
+ test('invalid-token triggers ONE silent renewal and an immediate re-probe with the fresh token', async () => {
1222
+ const url = 'https://hub.example.com';
1223
+ writeHubsConfig(url, 'mau_stale');
1224
+ const ctx = makeCtx({ url, linkedAt: 1 });
1225
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'a.html'), '<a/>');
1226
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'b.html'), '<b/>');
1227
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'c.html'), '<c/>');
1228
+
1229
+ const timers = fakeTimerQueue();
1230
+ const { factory, made } = authStubFactory();
1231
+ let renewCalls = 0;
1232
+ const origWarn = console.warn;
1233
+ const origLog = console.log;
1234
+ console.warn = () => {};
1235
+ console.log = () => {};
1236
+ try {
1237
+ const runtime = createSyncRuntime(ctx, {
1238
+ providerFactory: factory,
1239
+ auth: {
1240
+ warnDebounceMs: 2_000,
1241
+ reprobeMs: 300_000,
1242
+ settleTimeoutMs: 15_000,
1243
+ setTimer: timers.setTimer,
1244
+ clearTimer: timers.clearTimer,
1245
+ renewCredential: async () => {
1246
+ renewCalls++;
1247
+ return { token: 'mau_fresh', expiresAt: null };
1248
+ },
1249
+ },
1250
+ });
1251
+ await runtime?.start();
1252
+ expect(made).toHaveLength(3);
1253
+ expect(made.every((m) => m.token === 'mau_stale')).toBe(true);
1254
+
1255
+ // The whole burst rejects with the expired credential…
1256
+ for (const m of made.slice(0, 3)) m.emitAuthFailure('invalid token');
1257
+ await new Promise((res) => setTimeout(res, 10));
1258
+
1259
+ // …ONE renewal (single-flight), and the re-probe happened NOW — no
1260
+ // 5-minute wait — with the fresh token on every reconnected provider.
1261
+ expect(renewCalls).toBe(1);
1262
+ const reprobed = made.slice(3);
1263
+ expect(reprobed.map((m) => m.documentName).sort()).toEqual(['ui-a', 'ui-b', 'ui-c']);
1264
+ expect(reprobed.every((m) => m.token === 'mau_fresh')).toBe(true);
1265
+
1266
+ await runtime?.stop();
1267
+ } finally {
1268
+ console.warn = origWarn;
1269
+ console.log = origLog;
1270
+ }
1271
+ });
1272
+
1273
+ test('F1: a hub refusing on VOLUME cannot drive a renewal storm — the cap stops it', async () => {
1274
+ // The regression this whole review turned on. A cell refusing its own
1275
+ // clients with "invalid token — rate limited" classifies permanent →
1276
+ // triggers renewal → renewal succeeds (token was fine) → reprobe → every
1277
+ // reconnected doc is refused again → loop. Reproduced at 2342/s before the
1278
+ // cap. Here every reconnected provider auto-re-rejects, so without the cap
1279
+ // this test would spin forever; with it, renewals are bounded.
1280
+ const url = 'https://hub.example.com';
1281
+ writeHubsConfig(url, 'mau_stale');
1282
+ const ctx = makeCtx({ url, linkedAt: 1 });
1283
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'a.html'), '<a/>');
1284
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'b.html'), '<b/>');
1285
+
1286
+ let renewCalls = 0;
1287
+ let providersMade = 0;
1288
+ const origWarn = console.warn;
1289
+ const origLog = console.log;
1290
+ console.warn = () => {};
1291
+ console.log = () => {};
1292
+ // Every provider auto-rejects with the volume-refusal string the moment a
1293
+ // failure handler is attached — modelling a full hub bucket.
1294
+ const factory = (args: { documentName: string; token?: string; document?: Y.Doc }) => {
1295
+ const document = args.document ?? new Y.Doc();
1296
+ providersMade++;
1297
+ return {
1298
+ document,
1299
+ onAuthFailed(cb: (info: { reason: string }) => void) {
1300
+ queueMicrotask(() => cb({ reason: 'invalid token — rate limited, retry in up to 60s' }));
1301
+ return () => {};
1302
+ },
1303
+ onceSynced: () => new Promise<void>(() => {}),
1304
+ destroy() {},
1305
+ };
1306
+ };
1307
+ try {
1308
+ const runtime = createSyncRuntime(ctx, {
1309
+ providerFactory: factory,
1310
+ auth: {
1311
+ warnDebounceMs: 2_000,
1312
+ reprobeMs: 300_000,
1313
+ settleTimeoutMs: 15_000,
1314
+ setTimer: () => 0 as unknown as ReturnType<typeof setTimeout>,
1315
+ clearTimer: () => {},
1316
+ renewMinIntervalMs: 0, // isolate the CAP from the floor
1317
+ renewCredential: async () => {
1318
+ renewCalls++;
1319
+ return { token: `mau_fresh_${renewCalls}`, expiresAt: null };
1320
+ },
1321
+ },
1322
+ });
1323
+ await runtime?.start();
1324
+ // Let the storm run itself out — if the cap failed, this never settles
1325
+ // and the test times out (which is itself the failure signal).
1326
+ for (let i = 0; i < 20; i++) await new Promise((res) => setTimeout(res, 5));
1327
+
1328
+ // Bounded by the cap (default 3), NOT thousands.
1329
+ expect(renewCalls).toBeLessThanOrEqual(3);
1330
+ expect(renewCalls).toBeGreaterThan(0);
1331
+ // Providers created is likewise bounded — no runaway reconnect fan-out.
1332
+ expect(providersMade).toBeLessThan(20);
1333
+ await runtime?.stop();
1334
+ } finally {
1335
+ console.warn = origWarn;
1336
+ console.log = origLog;
1337
+ }
1338
+ });
1339
+
1340
+ test('F1: the frequency floor collapses a rapid second burst to no extra renewal', async () => {
1341
+ const url = 'https://hub.example.com';
1342
+ writeHubsConfig(url, 'mau_stale');
1343
+ const ctx = makeCtx({ url, linkedAt: 1 });
1344
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'a.html'), '<a/>');
1345
+
1346
+ let renewCalls = 0;
1347
+ let clock = 1_000_000;
1348
+ const origWarn = console.warn;
1349
+ const origLog = console.log;
1350
+ console.warn = () => {};
1351
+ console.log = () => {};
1352
+ const { factory, made } = authStubFactory();
1353
+ try {
1354
+ const runtime = createSyncRuntime(ctx, {
1355
+ providerFactory: factory,
1356
+ auth: {
1357
+ warnDebounceMs: 2_000,
1358
+ reprobeMs: 300_000,
1359
+ settleTimeoutMs: 15_000,
1360
+ setTimer: () => 0 as unknown as ReturnType<typeof setTimeout>,
1361
+ clearTimer: () => {},
1362
+ now: () => clock,
1363
+ renewMinIntervalMs: 60_000,
1364
+ renewCredential: async () => {
1365
+ renewCalls++;
1366
+ return { token: 'mau_fresh', expiresAt: null };
1367
+ },
1368
+ },
1369
+ });
1370
+ await runtime?.start();
1371
+ made[0]?.emitAuthFailure('invalid token');
1372
+ await new Promise((res) => setTimeout(res, 10));
1373
+ expect(renewCalls).toBe(1);
1374
+ // A second rejection 10 s later (inside the 60 s floor) → no new renewal.
1375
+ clock += 10_000;
1376
+ made[0]?.emitAuthFailure('invalid token');
1377
+ await new Promise((res) => setTimeout(res, 10));
1378
+ expect(renewCalls).toBe(1);
1379
+ // Past the floor → renewal is allowed again.
1380
+ clock += 60_000;
1381
+ made[0]?.emitAuthFailure('invalid token');
1382
+ await new Promise((res) => setTimeout(res, 10));
1383
+ expect(renewCalls).toBe(2);
1384
+ await runtime?.stop();
1385
+ } finally {
1386
+ console.warn = origWarn;
1387
+ console.log = origLog;
1388
+ }
1389
+ });
1390
+
1391
+ test('a failed renewal changes nothing — rejected docs wait for the slow re-probe as before', async () => {
1392
+ const url = 'https://hub.example.com';
1393
+ writeHubsConfig(url, 'mau_stale');
1394
+ const ctx = makeCtx({ url, linkedAt: 1 });
1395
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'a.html'), '<a/>');
1396
+
1397
+ const timers = fakeTimerQueue();
1398
+ const { factory, made } = authStubFactory();
1399
+ const origWarn = console.warn;
1400
+ const origLog = console.log;
1401
+ console.warn = () => {};
1402
+ console.log = () => {};
1403
+ try {
1404
+ const runtime = createSyncRuntime(ctx, {
1405
+ providerFactory: factory,
1406
+ auth: {
1407
+ warnDebounceMs: 2_000,
1408
+ reprobeMs: 300_000,
1409
+ settleTimeoutMs: 15_000,
1410
+ setTimer: timers.setTimer,
1411
+ clearTimer: timers.clearTimer,
1412
+ renewCredential: async () => null, // signed out / revoked / self-hosted
1413
+ },
1414
+ });
1415
+ await runtime?.start();
1416
+ made[0]?.emitAuthFailure('invalid token');
1417
+ await new Promise((res) => setTimeout(res, 10));
1418
+
1419
+ // No immediate re-probe (the renewal failed) — the doc stays rejected…
1420
+ expect(made).toHaveLength(1);
1421
+ expect(runtime?.status()?.docs?.rejected).toBe(1);
1422
+ // …until the slow re-probe fires, with the ORIGINAL token (unchanged).
1423
+ timers.fire(300_000);
1424
+ await new Promise((res) => setTimeout(res, 10));
1425
+ expect(made).toHaveLength(2);
1426
+ expect(made[1]?.token).toBe('mau_stale');
1427
+
1428
+ await runtime?.stop();
1429
+ } finally {
1430
+ console.warn = origWarn;
1431
+ console.log = origLog;
1432
+ }
1433
+ });
1434
+
1435
+ test('a stored expiry arms a pre-expiry renewal at ~80% of the remaining life', async () => {
1436
+ const url = 'https://hub.example.com';
1437
+ const cfgPath = process.env.HUBS_CONFIG_PATH;
1438
+ if (!cfgPath) throw new Error('HUBS_CONFIG_PATH not set by beforeEach');
1439
+ writeFileSync(
1440
+ cfgPath,
1441
+ JSON.stringify({
1442
+ hubs: { [url]: { token: 'mau_stale', linkedAt: 1, expiresAt: Date.now() + 100_000 } },
1443
+ })
1444
+ );
1445
+ chmodSync(cfgPath, 0o600);
1446
+ const ctx = makeCtx({ url, linkedAt: 1 });
1447
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'a.html'), '<a/>');
1448
+
1449
+ const timers = fakeTimerQueue();
1450
+ const { factory } = authStubFactory();
1451
+ let renewCalls = 0;
1452
+ const origWarn = console.warn;
1453
+ const origLog = console.log;
1454
+ console.warn = () => {};
1455
+ console.log = () => {};
1456
+ try {
1457
+ const runtime = createSyncRuntime(ctx, {
1458
+ providerFactory: factory,
1459
+ auth: {
1460
+ warnDebounceMs: 2_000,
1461
+ reprobeMs: 300_000,
1462
+ settleTimeoutMs: 15_000,
1463
+ setTimer: timers.setTimer,
1464
+ clearTimer: timers.clearTimer,
1465
+ renewCredential: async () => {
1466
+ renewCalls++;
1467
+ return { token: 'mau_fresh', expiresAt: null };
1468
+ },
1469
+ },
1470
+ });
1471
+ await runtime?.start();
1472
+ // 100 s of life left → the renewal timer sits at 80 s, so a 30 s sweep
1473
+ // must not reach it (only the settle timers live down there)…
1474
+ timers.fire(30_000);
1475
+ await new Promise((res) => setTimeout(res, 5));
1476
+ expect(renewCalls).toBe(0);
1477
+ // …and the 80 s sweep fires it, once, without any rejection happening.
1478
+ timers.fire(80_000);
1479
+ await new Promise((res) => setTimeout(res, 5));
1480
+ expect(renewCalls).toBe(1);
1481
+
1482
+ await runtime?.stop();
1483
+ } finally {
1484
+ console.warn = origWarn;
1485
+ console.log = origLog;
1486
+ }
1487
+ });
1488
+
1174
1489
  test('a successful re-probe clears the refusal, and the reconcile is a separate word', async () => {
1175
1490
  // A REGRESSION GUARD, not a falsifier — this already held. It is here
1176
1491
  // because the clear moved: it used to sit at the BOTTOM of the
@@ -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
+ });
@@ -0,0 +1,139 @@
1
+ // timeline-comp-target — issue #75 regression coverage.
2
+ //
3
+ // The Timeline draws the rows of ONE artboard's comp but drives the transport
4
+ // through a targeted postMessage, so "which comp id" is a real decision. The
5
+ // old rule matched the parsed row total against `durationInFrames`; two comps
6
+ // of equal length collapsed onto the FIRST one, so Play moved artboard #1 while
7
+ // the panel showed artboard #2. These tests pin the artboard-first rule.
8
+
9
+ import { describe, expect, test } from 'bun:test';
10
+
11
+ import {
12
+ activeComp,
13
+ resolveCompTarget,
14
+ sanitizeArtboardText,
15
+ } from '../client/panels/timeline-comp-target.js';
16
+
17
+ const comp = (id: string, artboardId: string | null, durationInFrames: number) => ({
18
+ id,
19
+ artboardId,
20
+ durationInFrames,
21
+ fps: 30,
22
+ width: 1920,
23
+ height: 1080,
24
+ artboardLabel: artboardId,
25
+ });
26
+
27
+ describe('resolveCompTarget', () => {
28
+ test('empty / missing comps → null', () => {
29
+ expect(resolveCompTarget([], { artboardId: 'a' })).toBe(null);
30
+ // @ts-expect-error — defensive: the shell hands through canvas-origin data.
31
+ expect(resolveCompTarget(null, {})).toBe(null);
32
+ });
33
+
34
+ test('THE BUG — equal-duration comps resolve by artboard, not by order', () => {
35
+ const comps = [comp('videocomp-1', 'intro', 150), comp('videocomp-2', 'outro', 150)];
36
+ expect(resolveCompTarget(comps, { artboardId: 'outro', total: 150 })).toBe('videocomp-2');
37
+ expect(resolveCompTarget(comps, { artboardId: 'intro', total: 150 })).toBe('videocomp-1');
38
+ });
39
+
40
+ test('an author-set comp id equal to the artboard id also matches', () => {
41
+ const comps = [comp('intro', null, 150), comp('outro', null, 150)];
42
+ expect(resolveCompTarget(comps, { artboardId: 'outro', total: 150 })).toBe('outro');
43
+ });
44
+
45
+ test('no artboard known → an UNAMBIGUOUS duration still identifies the comp', () => {
46
+ const comps = [comp('videocomp-1', 'intro', 150), comp('videocomp-2', 'outro', 90)];
47
+ expect(resolveCompTarget(comps, { artboardId: null, total: 90 })).toBe('videocomp-2');
48
+ });
49
+
50
+ test('no artboard known + ambiguous duration → first comp (the only guess left)', () => {
51
+ const comps = [comp('videocomp-1', 'intro', 150), comp('videocomp-2', 'outro', 150)];
52
+ expect(resolveCompTarget(comps, { artboardId: null, total: 150 })).toBe('videocomp-1');
53
+ });
54
+
55
+ // Adversarial review 2026-08-12 — comp ids and artboard ids are separate
56
+ // author-chosen namespaces, so the weaker claim must never beat the real one.
57
+ test('an announced artboard OUTRANKS a comp that merely names itself after it', () => {
58
+ const comps = [comp('outro', 'intro', 150), comp('videocomp-2', 'outro', 150)];
59
+ expect(resolveCompTarget(comps, { artboardId: 'outro', total: 150 })).toBe('videocomp-2');
60
+ });
61
+
62
+ // The row parser derives the artboard lexically from the .tsx; canvas-lib
63
+ // reports the viewport one structurally. When the lexical claim names an
64
+ // artboard no comp is mounted in, the pan signal must still land the target.
65
+ test('candidate list — a stale first candidate falls through to the live one', () => {
66
+ const comps = [comp('videocomp-1', 'intro', 150), comp('videocomp-2', 'outro', 150)];
67
+ expect(resolveCompTarget(comps, { artboardId: ['ghost', 'outro'], total: 150 })).toBe(
68
+ 'videocomp-2'
69
+ );
70
+ // First candidate wins whenever it DOES name a mounted comp.
71
+ expect(resolveCompTarget(comps, { artboardId: ['intro', 'outro'], total: 150 })).toBe(
72
+ 'videocomp-1'
73
+ );
74
+ expect(resolveCompTarget(comps, { artboardId: [null, undefined, 'outro'] })).toBe(
75
+ 'videocomp-2'
76
+ );
77
+ });
78
+
79
+ test('an artboard with no comp falls back rather than returning null', () => {
80
+ const comps = [comp('videocomp-1', 'intro', 150)];
81
+ expect(resolveCompTarget(comps, { artboardId: 'a-still-image-board', total: 150 })).toBe(
82
+ 'videocomp-1'
83
+ );
84
+ });
85
+ });
86
+
87
+ // The artboard label is untrusted canvas-origin text rendered as shell chrome
88
+ // (security-review 2026-08-12): a chip that can be made to read as a different
89
+ // artboard than the transport is scoped to defeats its own purpose.
90
+ describe('sanitizeArtboardText', () => {
91
+ test('strips bidi overrides + isolates', () => {
92
+ expect(sanitizeArtboardText('Intro\u202e')).toBe('Intro');
93
+ expect(sanitizeArtboardText('\u2066Outro\u2069')).toBe('Outro');
94
+ expect(sanitizeArtboardText('gnp\u202egpj.Hero')).toBe('gnpgpj.Hero');
95
+ });
96
+
97
+ test('strips zero-width, soft hyphen, BOM and C0/C1 controls', () => {
98
+ expect(sanitizeArtboardText('In\u200btro')).toBe('Intro');
99
+ expect(sanitizeArtboardText('\ufeffHero\u00ad')).toBe('Hero');
100
+ expect(sanitizeArtboardText('Line\nBreak\tTab')).toBe('LineBreakTab');
101
+ expect(sanitizeArtboardText('Bell\u0007')).toBe('Bell');
102
+ });
103
+
104
+ test('an all-invisible label reads as NO label, not as a blank chip', () => {
105
+ expect(sanitizeArtboardText('\u200b\u200b\u200b')).toBe(null);
106
+ expect(sanitizeArtboardText(' ')).toBe(null);
107
+ expect(sanitizeArtboardText('')).toBe(null);
108
+ });
109
+
110
+ test('non-strings are null, never coerced', () => {
111
+ expect(sanitizeArtboardText(undefined)).toBe(null);
112
+ expect(sanitizeArtboardText(null)).toBe(null);
113
+ expect(sanitizeArtboardText(42)).toBe(null);
114
+ expect(sanitizeArtboardText({ toString: () => 'nope' })).toBe(null);
115
+ });
116
+
117
+ test('caps length, and a cap that lands mid-space still trims', () => {
118
+ expect(sanitizeArtboardText('x'.repeat(500))).toHaveLength(120);
119
+ expect(sanitizeArtboardText(`${'x'.repeat(119)} yz`)).toBe('x'.repeat(119));
120
+ });
121
+
122
+ test('ordinary labels survive intact (accents, emoji, punctuation)', () => {
123
+ expect(sanitizeArtboardText('Přehled — 2. krok')).toBe('Přehled — 2. krok');
124
+ expect(sanitizeArtboardText('Hero 🎬')).toBe('Hero 🎬');
125
+ });
126
+ });
127
+
128
+ describe('activeComp', () => {
129
+ test('reads meta off the SAME comp the transport targets', () => {
130
+ const comps = [comp('videocomp-1', 'intro', 150), comp('videocomp-2', 'outro', 90)];
131
+ expect(activeComp(comps, 'videocomp-2')?.durationInFrames).toBe(90);
132
+ });
133
+
134
+ test('unknown id → first comp (panel still renders); empty → null', () => {
135
+ const comps = [comp('videocomp-1', 'intro', 150)];
136
+ expect(activeComp(comps, 'nope')?.id).toBe('videocomp-1');
137
+ expect(activeComp([], 'x')).toBe(null);
138
+ });
139
+ });
@@ -11,7 +11,7 @@
11
11
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
12
12
  import { GlobalRegistrator } from '@happy-dom/global-registrator';
13
13
 
14
- import { installMaudeSeekBridge } from '../video-comp.tsx';
14
+ import { findArtboardOf, installMaudeSeekBridge } from '../video-comp.tsx';
15
15
 
16
16
  interface FakePlayer {
17
17
  seekTo: (frame: number) => void;
@@ -111,6 +111,11 @@ describe('video-comp seek bridge', () => {
111
111
  durationInFrames: 90,
112
112
  width: 640,
113
113
  height: 360,
114
+ // issue #75 — always present in the snapshot (null when the comp was
115
+ // registered outside an artboard), so the shell can key the transport
116
+ // target on artboard identity instead of guessing from duration.
117
+ artboardId: null,
118
+ artboardLabel: null,
114
119
  });
115
120
  // No ref/functions leak into the snapshot.
116
121
  expect(Object.keys(comps[0])).not.toContain('ref');
@@ -151,7 +156,7 @@ describe('video-comp seek bridge', () => {
151
156
  expect(a.seeks).toEqual([42, 42]);
152
157
  });
153
158
 
154
- test('a Player ref that is not yet ready (null) does not throw', async () => {
159
+ test('a not-yet-ready Player is resilient in UI mode but still RECORDED', async () => {
155
160
  const w = window as unknown as SeekWindow;
156
161
  installMaudeSeekBridge();
157
162
  if (!w.__maudeVideoComps) w.__maudeVideoComps = new Map();
@@ -163,6 +168,103 @@ describe('video-comp seek bridge', () => {
163
168
  height: 360,
164
169
  ref: { current: null },
165
170
  });
171
+ // A live scrub of a still-mounting comp must not blow up the UI...
172
+ const before = w.__maude_seek_failures__ ?? 0;
166
173
  await expect(w.__maude_seek__?.(10)).resolves.toBeUndefined();
174
+ // ...but it is no longer silent: the capture shim reads this counter and
175
+ // refuses to encode, because a seek that never landed means a stale frame.
176
+ expect(w.__maude_seek_failures__ ?? 0).toBeGreaterThan(before);
177
+ });
178
+
179
+ test('capture mode (strict) throws instead of pretending the seek landed', async () => {
180
+ const w = window as unknown as SeekWindow;
181
+ installMaudeSeekBridge();
182
+ if (!w.__maudeVideoComps) w.__maudeVideoComps = new Map();
183
+ w.__maudeVideoComps.set('pending-strict', {
184
+ id: 'pending-strict',
185
+ fps: 30,
186
+ durationInFrames: 90,
187
+ width: 640,
188
+ height: 360,
189
+ ref: { current: null },
190
+ });
191
+ await expect(w.__maude_seek__?.(10, { strict: true })).rejects.toThrow(
192
+ /refusing to report a seek that did not happen/
193
+ );
194
+ });
195
+ });
196
+
197
+ // issue #75 — a comp's own id is a mount slug (`videocomp-N`) unless the author
198
+ // set one, so the shell can only tell two comps apart by the artboard they are
199
+ // mounted in. This is the resolution that puts that id on the wire.
200
+ describe('findArtboardOf', () => {
201
+ test('resolves the enclosing artboard id + visible label', () => {
202
+ document.body.innerHTML = `
203
+ <article data-dc-screen="outro">
204
+ <button class="dc-artboard-label sku"><span aria-hidden="true"></span>Outro</button>
205
+ <div class="dc-artboard-body"><div class="dc-video-comp" data-comp-id="videocomp-2"></div></div>
206
+ </article>`;
207
+ const host = document.querySelector('.dc-video-comp');
208
+ expect(findArtboardOf(host)).toEqual({ artboardId: 'outro', artboardLabel: 'Outro' });
209
+ });
210
+
211
+ test('a comp mounted outside any artboard reports nulls, never throws', () => {
212
+ document.body.innerHTML = '<div class="dc-video-comp" data-comp-id="bare"></div>';
213
+ expect(findArtboardOf(document.querySelector('.dc-video-comp'))).toEqual({
214
+ artboardId: null,
215
+ artboardLabel: null,
216
+ });
217
+ expect(findArtboardOf(null)).toEqual({ artboardId: null, artboardLabel: null });
218
+ });
219
+
220
+ // Adversarial review 2026-08-12 — `.dc-artboard-label` is a class anyone can
221
+ // write, so the label must come from the element canvas-lib actually renders
222
+ // (a DIRECT header/button child), not from the first match anywhere below.
223
+ test('a planted label node cannot outrank the real header', () => {
224
+ document.body.innerHTML = `
225
+ <article data-dc-screen="intro">
226
+ <span class="dc-artboard-label" hidden>Outro</span>
227
+ <header class="dc-artboard-label sku">Intro</header>
228
+ <div class="dc-artboard-body"><div class="dc-video-comp"></div></div>
229
+ </article>`;
230
+ expect(findArtboardOf(document.querySelector('.dc-video-comp')).artboardLabel).toBe('Intro');
231
+ });
232
+
233
+ test('a nested artboard resolves to the NEAREST board, not the outer label', () => {
234
+ document.body.innerHTML = `
235
+ <article data-dc-screen="outer">
236
+ <header class="dc-artboard-label sku">Outer</header>
237
+ <article data-dc-screen="inner">
238
+ <header class="dc-artboard-label sku">Inner</header>
239
+ <div class="dc-video-comp"></div>
240
+ </article>
241
+ </article>`;
242
+ expect(findArtboardOf(document.querySelector('.dc-video-comp'))).toEqual({
243
+ artboardId: 'inner',
244
+ artboardLabel: 'Inner',
245
+ });
246
+ });
247
+
248
+ test('a label deep in the body is ignored — only a direct header/button counts', () => {
249
+ document.body.innerHTML = `
250
+ <article data-dc-screen="intro">
251
+ <div class="dc-artboard-body">
252
+ <p class="dc-artboard-label">Outro</p>
253
+ <div class="dc-video-comp"></div>
254
+ </div>
255
+ </article>`;
256
+ expect(findArtboardOf(document.querySelector('.dc-video-comp'))).toEqual({
257
+ artboardId: 'intro',
258
+ artboardLabel: null,
259
+ });
260
+ });
261
+
262
+ test('an unlabelled artboard still yields its id', () => {
263
+ document.body.innerHTML =
264
+ '<article data-dc-screen="intro"><div class="dc-video-comp"></div></article>';
265
+ expect(findArtboardOf(document.querySelector('.dc-video-comp'))).toEqual({
266
+ artboardId: 'intro',
267
+ artboardLabel: null,
268
+ });
167
269
  });
168
270
  });