@1agh/maude 0.58.2 → 0.58.3
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/api.ts +6 -1
- package/apps/studio/bin/_fetch-asset.mjs +169 -5
- package/apps/studio/bin/_import-asset.mjs +72 -0
- package/apps/studio/bin/_import-figma.mjs +1121 -0
- package/apps/studio/bin/_video-playwright.mjs +86 -3
- package/apps/studio/bin/import-figma.sh +38 -0
- package/apps/studio/bin/read-annotations.mjs +11 -1
- package/apps/studio/bun.lock +16 -22
- package/apps/studio/canvas-edit.ts +29 -5
- package/apps/studio/client/app.jsx +44 -1
- 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/TimelinePanel.jsx +2 -2
- package/apps/studio/client/panels/timeline-parse.js +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +7 -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 +1261 -1261
- 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 +27 -1
- package/apps/studio/exporters/video-render-lib.ts +6 -0
- package/apps/studio/exporters/video.ts +62 -1
- package/apps/studio/figma/assets.test.ts +372 -0
- package/apps/studio/figma/assets.ts +398 -0
- package/apps/studio/figma/client.test.ts +395 -0
- package/apps/studio/figma/client.ts +513 -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 +200 -0
- package/apps/studio/figma/sanitize.test.ts +256 -0
- package/apps/studio/figma/sanitize.ts +315 -0
- package/apps/studio/figma/style-map.ts +352 -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 +306 -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 +539 -0
- package/apps/studio/figma/url.test.ts +167 -0
- package/apps/studio/figma/url.ts +160 -0
- package/apps/studio/http.ts +129 -0
- package/apps/studio/sync/asset-push.ts +124 -0
- package/apps/studio/sync/connection-state.ts +11 -0
- package/apps/studio/sync/hub-link.ts +63 -7
- package/apps/studio/sync/hubs-config.ts +31 -3
- package/apps/studio/sync/index.ts +276 -26
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- 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 +13 -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-provenance.test.ts +108 -0
- package/apps/studio/test/figma-routes.test.ts +294 -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 +479 -0
- package/apps/studio/test/sync-asset-push.test.ts +124 -0
- package/apps/studio/test/sync-connection-state.test.ts +13 -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-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/video-comp.test.ts +23 -1
- package/apps/studio/test/workspace-containment.test.ts +1 -0
- package/apps/studio/video-comp.tsx +70 -6
- package/apps/studio/whats-new.json +27 -0
- package/apps/studio/workspace-mode.ts +4 -0
- package/cli/commands/design.mjs +8 -0
- package/package.json +8 -8
- package/plugins/flow/.claude-plugin/config.schema.json +3 -3
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Fix 4 of the 2026-08-10 sync RCA — the one-shot quarantine of pre-fix-5
|
|
2
|
+
// flat fallback twins. The rules under test: a flat body moves ONLY when a
|
|
3
|
+
// grouped twin of the same slug exists; siblings ride along; annotations stay
|
|
4
|
+
// (they serve the surviving twin); a lone flat canvas is somebody's work and
|
|
5
|
+
// never moves; a second run is a no-op; nothing ever throws into boot.
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
8
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { migrateFlatFallback } from '../sync/migrate-flat-fallback.ts';
|
|
13
|
+
|
|
14
|
+
let designRoot: string;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
designRoot = mkdtempSync(join(tmpdir(), 'migrate-flat-'));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
rmSync(designRoot, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const run = () => migrateFlatFallback({ designRoot, designRel: '.design', log: () => {} });
|
|
25
|
+
|
|
26
|
+
describe('migrateFlatFallback — quarantine the redundant flat copy, never work', () => {
|
|
27
|
+
test('a collision pair: the flat copy (+ siblings) moves to _trash, the grouped twin stays', () => {
|
|
28
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
29
|
+
writeFileSync(join(designRoot, 'ui/welcome.tsx'), 'grouped\n');
|
|
30
|
+
writeFileSync(join(designRoot, 'ui-welcome.tsx'), 'flat stub\n');
|
|
31
|
+
writeFileSync(join(designRoot, 'ui-welcome.meta.json'), '{}\n');
|
|
32
|
+
writeFileSync(join(designRoot, 'ui-welcome.css'), '.x{}\n');
|
|
33
|
+
// Annotations are keyed by the flat slug and serve the SURVIVING twin.
|
|
34
|
+
writeFileSync(join(designRoot, 'ui-welcome.annotations.svg'), '<svg/>\n');
|
|
35
|
+
|
|
36
|
+
const moves = run();
|
|
37
|
+
|
|
38
|
+
expect(moves).toHaveLength(1);
|
|
39
|
+
expect(moves[0].slug).toBe('ui-welcome');
|
|
40
|
+
expect(moves[0].from).toBe('ui-welcome.tsx');
|
|
41
|
+
expect(moves[0].keptTwin).toBe('ui/welcome.tsx');
|
|
42
|
+
|
|
43
|
+
expect(existsSync(join(designRoot, 'ui/welcome.tsx'))).toBe(true);
|
|
44
|
+
expect(existsSync(join(designRoot, 'ui-welcome.tsx'))).toBe(false);
|
|
45
|
+
expect(existsSync(join(designRoot, 'ui-welcome.meta.json'))).toBe(false);
|
|
46
|
+
expect(existsSync(join(designRoot, 'ui-welcome.css'))).toBe(false);
|
|
47
|
+
expect(existsSync(join(designRoot, 'ui-welcome.annotations.svg'))).toBe(true);
|
|
48
|
+
|
|
49
|
+
const trashed = readdirSync(join(designRoot, moves[0].trashedTo)).sort();
|
|
50
|
+
expect(trashed).toEqual(['ui-welcome.css', 'ui-welcome.meta.json', 'ui-welcome.tsx']);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('a lone flat canvas has no twin — it is somebody’s work and stays put', () => {
|
|
54
|
+
writeFileSync(join(designRoot, 'scratch-pad.tsx'), 'mine\n');
|
|
55
|
+
expect(run()).toEqual([]);
|
|
56
|
+
expect(existsSync(join(designRoot, 'scratch-pad.tsx'))).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('a grouped file with no flat twin is untouched', () => {
|
|
60
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
61
|
+
writeFileSync(join(designRoot, 'ui/card.tsx'), 'x\n');
|
|
62
|
+
expect(run()).toEqual([]);
|
|
63
|
+
expect(existsSync(join(designRoot, 'ui/card.tsx'))).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('the second run is a no-op — the quarantine dir is runtime state the scan skips', () => {
|
|
67
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
68
|
+
writeFileSync(join(designRoot, 'ui/welcome.tsx'), 'grouped\n');
|
|
69
|
+
writeFileSync(join(designRoot, 'ui-welcome.tsx'), 'flat stub\n');
|
|
70
|
+
|
|
71
|
+
expect(run()).toHaveLength(1);
|
|
72
|
+
expect(run()).toEqual([]);
|
|
73
|
+
// The quarantined copy is still there — moved, never deleted.
|
|
74
|
+
const trash = readdirSync(join(designRoot, '_trash'));
|
|
75
|
+
expect(trash).toHaveLength(1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('nested twins at any depth count', () => {
|
|
79
|
+
mkdirSync(join(designRoot, 'ui/2026/social'), { recursive: true });
|
|
80
|
+
writeFileSync(join(designRoot, 'ui/2026/social/summer-camp.tsx'), 'deep\n');
|
|
81
|
+
writeFileSync(join(designRoot, 'ui-2026-social-summer-camp.tsx'), 'flat stub\n');
|
|
82
|
+
|
|
83
|
+
const moves = run();
|
|
84
|
+
expect(moves).toHaveLength(1);
|
|
85
|
+
expect(moves[0].keptTwin).toBe('ui/2026/social/summer-camp.tsx');
|
|
86
|
+
expect(existsSync(join(designRoot, 'ui/2026/social/summer-camp.tsx'))).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('a missing design root is a quiet no-op, not a throw', () => {
|
|
90
|
+
expect(
|
|
91
|
+
migrateFlatFallback({
|
|
92
|
+
designRoot: join(designRoot, 'does-not-exist'),
|
|
93
|
+
designRel: '.design',
|
|
94
|
+
log: () => {},
|
|
95
|
+
})
|
|
96
|
+
).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -233,6 +233,69 @@ describe('a peer with an empty design root pulls the project down whole', () =>
|
|
|
233
233
|
});
|
|
234
234
|
});
|
|
235
235
|
|
|
236
|
+
describe('boot runs the flat-fallback quarantine BEFORE the scan (fix 4)', () => {
|
|
237
|
+
test('a pre-fix-5 flat twin is in _trash by the time the runtime settles', async () => {
|
|
238
|
+
const ctx = makeCtx();
|
|
239
|
+
mkdirSync(join(ctx.paths.designRoot, 'ui'), { recursive: true });
|
|
240
|
+
writeFileSync(join(ctx.paths.designRoot, 'ui/welcome.tsx'), 'grouped\n');
|
|
241
|
+
writeFileSync(join(ctx.paths.designRoot, 'ui-welcome.tsx'), 'flat stub\n');
|
|
242
|
+
hubListing([]);
|
|
243
|
+
|
|
244
|
+
const runtime = createSyncRuntime(ctx, { providerFactory: hubDocProviderFactory({}) });
|
|
245
|
+
await runtime?.start();
|
|
246
|
+
await runtime?.stop();
|
|
247
|
+
|
|
248
|
+
expect(existsSync(join(ctx.paths.designRoot, 'ui-welcome.tsx'))).toBe(false);
|
|
249
|
+
expect(existsSync(join(ctx.paths.designRoot, 'ui/welcome.tsx'))).toBe(true);
|
|
250
|
+
expect(existsSync(join(ctx.paths.designRoot, '_trash'))).toBe(true);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe('the stamp race — the path is on the doc BEFORE the handshake (fix 5)', () => {
|
|
255
|
+
test('a local canvas stamps syncMeta.path before onceSynced, so the hub’s first store sees it', async () => {
|
|
256
|
+
// The RCA bug: the stamp lived only in the post-reconcile tail, so the
|
|
257
|
+
// hub's FIRST onDocumentStored saw a bare body, memoised the flat fallback
|
|
258
|
+
// in its pathIndex, and the real nested path could never win — a stub was
|
|
259
|
+
// born whose dynamic import failed forever.
|
|
260
|
+
const ctx = makeCtx();
|
|
261
|
+
const bodyAbs = join(ctx.paths.designRoot, NESTED_REL);
|
|
262
|
+
mkdirSync(join(ctx.paths.designRoot, 'ui/2026/social'), { recursive: true });
|
|
263
|
+
writeFileSync(bodyAbs, BODY);
|
|
264
|
+
hubListing([]);
|
|
265
|
+
|
|
266
|
+
let pathAtHandshake: unknown = 'never-called';
|
|
267
|
+
const runtime = createSyncRuntime(ctx, {
|
|
268
|
+
canvases: [
|
|
269
|
+
{
|
|
270
|
+
slug: NESTED_SLUG,
|
|
271
|
+
html: bodyAbs,
|
|
272
|
+
comments: join(ctx.paths.commentsDir, `${NESTED_SLUG}.json`),
|
|
273
|
+
annotations: join(ctx.paths.designRoot, `${NESTED_SLUG}.annotations.svg`),
|
|
274
|
+
meta: bodyAbs.replace(/\.tsx$/, '.meta.json'),
|
|
275
|
+
css: bodyAbs.replace(/\.tsx$/, '.css'),
|
|
276
|
+
},
|
|
277
|
+
],
|
|
278
|
+
providerFactory: (args) => {
|
|
279
|
+
const doc = args.document ?? new Y.Doc();
|
|
280
|
+
return {
|
|
281
|
+
document: doc,
|
|
282
|
+
awareness: new Awareness(doc),
|
|
283
|
+
async onceSynced() {
|
|
284
|
+
// What the hub can see by the time the handshake settles — the
|
|
285
|
+
// stamp must ALREADY be there, not arrive with the reconcile tail.
|
|
286
|
+
pathAtHandshake = doc.getMap('syncMeta').get('path');
|
|
287
|
+
},
|
|
288
|
+
destroy() {},
|
|
289
|
+
};
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
await runtime?.start();
|
|
293
|
+
await runtime?.stop();
|
|
294
|
+
|
|
295
|
+
expect(pathAtHandshake).toBe(NESTED_REL);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
236
299
|
describe("rule 7 governs a path's IDENTITY, not what already lives there", () => {
|
|
237
300
|
test('a pulled canvas never lands on a file that already exists locally', async () => {
|
|
238
301
|
// `scanCanvases` omits a canvas whose `.meta.json` says `syncable: false` —
|
|
@@ -183,3 +183,80 @@ describe('hub-supplied text is untrusted (DDR-054)', () => {
|
|
|
183
183
|
expect(p?.names).toEqual([]);
|
|
184
184
|
});
|
|
185
185
|
});
|
|
186
|
+
|
|
187
|
+
describe('zero progress has a deadline — the stalled phase', () => {
|
|
188
|
+
const NOW = 10 * 60_000; // 10 minutes after the epoch-zero base
|
|
189
|
+
|
|
190
|
+
test('connecting past the ceiling with nothing synced becomes stalled, with the move named', () => {
|
|
191
|
+
const p = syncPresentation(
|
|
192
|
+
{ ...base, state: 'connecting', startedAt: 1, docs: { synced: 0, pending: 73, rejected: 0 } },
|
|
193
|
+
{ project: 'alligators', now: NOW }
|
|
194
|
+
);
|
|
195
|
+
expect(p?.phase).toBe('stalled');
|
|
196
|
+
expect(p?.online).toBe(false);
|
|
197
|
+
expect(p?.title).toContain('Nothing has synced');
|
|
198
|
+
expect(p?.next).toContain('Reconnect');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('a fresh connect is NOT stalled — the handshake gets its moment', () => {
|
|
202
|
+
const p = syncPresentation(
|
|
203
|
+
{ ...base, state: 'connecting', startedAt: 1, docs: { synced: 0, pending: 73, rejected: 0 } },
|
|
204
|
+
{ project: 'alligators', now: 30_000 }
|
|
205
|
+
);
|
|
206
|
+
expect(p?.phase).toBe('connecting');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('any progress at all cancels the stall — syncing is not stalling', () => {
|
|
210
|
+
const p = syncPresentation(
|
|
211
|
+
{ ...base, startedAt: 1, docs: { synced: 1, pending: 72, rejected: 0 } },
|
|
212
|
+
{ project: 'alligators', now: NOW }
|
|
213
|
+
);
|
|
214
|
+
expect(p?.phase).toBe('syncing');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('zero known documents past the ceiling also stalls', () => {
|
|
218
|
+
const p = syncPresentation(
|
|
219
|
+
{ ...base, state: 'connecting', startedAt: 1, docs: { synced: 0, pending: 0, rejected: 0 } },
|
|
220
|
+
{ project: 'alligators', now: NOW }
|
|
221
|
+
);
|
|
222
|
+
expect(p?.phase).toBe('stalled');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('an old payload without startedAt can never stall (fail open to connecting)', () => {
|
|
226
|
+
const p = syncPresentation(
|
|
227
|
+
{ ...base, state: 'connecting' },
|
|
228
|
+
{ project: 'alligators', now: NOW }
|
|
229
|
+
);
|
|
230
|
+
expect(p?.phase).toBe('connecting');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('a refusal still outranks a stall — the more specific cause wins', () => {
|
|
234
|
+
const p = syncPresentation(
|
|
235
|
+
{ ...base, startedAt: 1, docs: { synced: 0, pending: 0, rejected: 73 } },
|
|
236
|
+
{ project: 'alligators', now: NOW }
|
|
237
|
+
);
|
|
238
|
+
expect(p?.phase).toBe('refused');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('an unreachable hub still outranks a stall — offline explains itself', () => {
|
|
242
|
+
const p = syncPresentation(
|
|
243
|
+
{ ...base, state: 'offline', startedAt: 1, docs: { synced: 0, pending: 3, rejected: 0 } },
|
|
244
|
+
{ project: 'alligators', now: NOW }
|
|
245
|
+
);
|
|
246
|
+
expect(p?.phase).toBe('offline');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('a garbage startedAt fails open, never into a false alarm', () => {
|
|
250
|
+
const p = syncPresentation(
|
|
251
|
+
// biome-ignore lint/suspicious/noExplicitAny: hostile-payload shape test
|
|
252
|
+
{
|
|
253
|
+
...base,
|
|
254
|
+
state: 'connecting',
|
|
255
|
+
startedAt: 'yes' as any,
|
|
256
|
+
docs: { synced: 0, pending: 1, rejected: 0 },
|
|
257
|
+
},
|
|
258
|
+
{ project: 'alligators', now: NOW }
|
|
259
|
+
);
|
|
260
|
+
expect(p?.phase).toBe('connecting');
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -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
|
|
@@ -151,7 +151,7 @@ describe('video-comp seek bridge', () => {
|
|
|
151
151
|
expect(a.seeks).toEqual([42, 42]);
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
-
test('a Player
|
|
154
|
+
test('a not-yet-ready Player is resilient in UI mode but still RECORDED', async () => {
|
|
155
155
|
const w = window as unknown as SeekWindow;
|
|
156
156
|
installMaudeSeekBridge();
|
|
157
157
|
if (!w.__maudeVideoComps) w.__maudeVideoComps = new Map();
|
|
@@ -163,6 +163,28 @@ describe('video-comp seek bridge', () => {
|
|
|
163
163
|
height: 360,
|
|
164
164
|
ref: { current: null },
|
|
165
165
|
});
|
|
166
|
+
// A live scrub of a still-mounting comp must not blow up the UI...
|
|
167
|
+
const before = w.__maude_seek_failures__ ?? 0;
|
|
166
168
|
await expect(w.__maude_seek__?.(10)).resolves.toBeUndefined();
|
|
169
|
+
// ...but it is no longer silent: the capture shim reads this counter and
|
|
170
|
+
// refuses to encode, because a seek that never landed means a stale frame.
|
|
171
|
+
expect(w.__maude_seek_failures__ ?? 0).toBeGreaterThan(before);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('capture mode (strict) throws instead of pretending the seek landed', async () => {
|
|
175
|
+
const w = window as unknown as SeekWindow;
|
|
176
|
+
installMaudeSeekBridge();
|
|
177
|
+
if (!w.__maudeVideoComps) w.__maudeVideoComps = new Map();
|
|
178
|
+
w.__maudeVideoComps.set('pending-strict', {
|
|
179
|
+
id: 'pending-strict',
|
|
180
|
+
fps: 30,
|
|
181
|
+
durationInFrames: 90,
|
|
182
|
+
width: 640,
|
|
183
|
+
height: 360,
|
|
184
|
+
ref: { current: null },
|
|
185
|
+
});
|
|
186
|
+
await expect(w.__maude_seek__?.(10, { strict: true })).rejects.toThrow(
|
|
187
|
+
/refusing to report a seek that did not happen/
|
|
188
|
+
);
|
|
167
189
|
});
|
|
168
190
|
});
|