@1agh/maude 0.58.2 → 0.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/studio/annotations-bindings.ts +83 -4
- package/apps/studio/annotations-layer.tsx +49 -15
- package/apps/studio/api.ts +6 -1
- package/apps/studio/bin/_fetch-asset.mjs +169 -5
- package/apps/studio/bin/_import-asset.mjs +90 -0
- package/apps/studio/bin/_import-figma.mjs +1775 -0
- package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
- package/apps/studio/bin/_perf-probe.mjs +228 -0
- package/apps/studio/bin/_perf-shared.mjs +345 -0
- package/apps/studio/bin/_video-playwright.mjs +103 -7
- package/apps/studio/bin/import-figma.sh +47 -0
- package/apps/studio/bin/perf.sh +228 -0
- package/apps/studio/bin/read-annotations.mjs +11 -1
- package/apps/studio/bin/smoke.sh +49 -5
- package/apps/studio/bun.lock +16 -22
- package/apps/studio/canvas-edit.ts +29 -5
- package/apps/studio/canvas-lib.tsx +148 -6
- package/apps/studio/client/app.jsx +196 -38
- package/apps/studio/client/export-center.jsx +42 -4
- package/apps/studio/client/panels/CloudBar.jsx +92 -1
- package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
- package/apps/studio/client/panels/GitPanel.jsx +26 -6
- package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
- package/apps/studio/client/panels/SyncPanel.jsx +229 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +31 -3
- package/apps/studio/client/panels/timeline-comp-target.js +101 -0
- package/apps/studio/client/panels/timeline-parse.js +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +37 -0
- package/apps/studio/client/styles/4-components.css +134 -0
- package/apps/studio/clip-ops.ts +93 -17
- package/apps/studio/cloud/endpoints.ts +78 -10
- package/apps/studio/cloud/renew.ts +183 -0
- package/apps/studio/context.ts +2 -1
- package/apps/studio/dist/client.bundle.js +1231 -1231
- package/apps/studio/dist/runtime/@remotion_media.js +56 -136
- package/apps/studio/dist/runtime/@remotion_player.js +18 -18
- package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
- package/apps/studio/dist/runtime/remotion.js +12 -12
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +20 -6
- package/apps/studio/exporters/_runtime.ts +19 -0
- package/apps/studio/exporters/degraded.ts +92 -0
- package/apps/studio/exporters/index.ts +5 -0
- package/apps/studio/exporters/jobs.ts +19 -0
- package/apps/studio/exporters/unsupported-media.ts +170 -0
- package/apps/studio/exporters/video-encode-lib.ts +35 -6
- package/apps/studio/exporters/video-render-lib.ts +6 -0
- package/apps/studio/exporters/video.ts +72 -1
- package/apps/studio/figma/assets.test.ts +464 -0
- package/apps/studio/figma/assets.ts +452 -0
- package/apps/studio/figma/client.test.ts +395 -0
- package/apps/studio/figma/client.ts +513 -0
- package/apps/studio/figma/codegen-client.test.ts +276 -0
- package/apps/studio/figma/codegen-client.ts +509 -0
- package/apps/studio/figma/codegen-fonts.test.ts +103 -0
- package/apps/studio/figma/codegen-fonts.ts +195 -0
- package/apps/studio/figma/codegen-values.test.ts +179 -0
- package/apps/studio/figma/codegen-values.ts +270 -0
- package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
- package/apps/studio/figma/comments-to-strokes.ts +173 -0
- package/apps/studio/figma/endpoints.ts +273 -0
- package/apps/studio/figma/fig-decode.test.ts +702 -0
- package/apps/studio/figma/fig-decode.ts +617 -0
- package/apps/studio/figma/fig-kiwi.ts +410 -0
- package/apps/studio/figma/fig-zip.ts +270 -0
- package/apps/studio/figma/from-codegen.test.ts +408 -0
- package/apps/studio/figma/from-codegen.ts +1103 -0
- package/apps/studio/figma/sanitize.test.ts +325 -0
- package/apps/studio/figma/sanitize.ts +407 -0
- package/apps/studio/figma/style-map.ts +352 -0
- package/apps/studio/figma/tailwind-map.test.ts +142 -0
- package/apps/studio/figma/tailwind-map.ts +545 -0
- package/apps/studio/figma/to-artboard.test.ts +808 -0
- package/apps/studio/figma/to-artboard.ts +701 -0
- package/apps/studio/figma/to-render.test.ts +180 -0
- package/apps/studio/figma/to-render.ts +328 -0
- package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
- package/apps/studio/figma/to-strokes.test.ts +705 -0
- package/apps/studio/figma/to-strokes.ts +749 -0
- package/apps/studio/figma/to-tokens.test.ts +321 -0
- package/apps/studio/figma/to-tokens.ts +305 -0
- package/apps/studio/figma/types.ts +544 -0
- package/apps/studio/figma/url.test.ts +167 -0
- package/apps/studio/figma/url.ts +160 -0
- package/apps/studio/http.ts +176 -0
- package/apps/studio/sync/asset-push.ts +432 -0
- package/apps/studio/sync/connection-state.ts +82 -3
- package/apps/studio/sync/hub-link.ts +63 -7
- package/apps/studio/sync/hubs-config.ts +31 -3
- package/apps/studio/sync/index.ts +286 -27
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- package/apps/studio/sync/status.ts +18 -0
- package/apps/studio/sync/supervisor.ts +5 -1
- package/apps/studio/sync/workspace-signin.ts +7 -3
- package/apps/studio/test/annotations-bindings.test.ts +150 -12
- package/apps/studio/test/canvas-create-api.test.ts +4 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +17 -0
- package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
- package/apps/studio/test/clip-addressing.test.ts +6 -1
- package/apps/studio/test/clip-ops.test.ts +5 -1
- package/apps/studio/test/cloud-endpoints.test.ts +96 -0
- package/apps/studio/test/cloud-renew.test.ts +205 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
- package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
- package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
- package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
- package/apps/studio/test/figma-explode.test.ts +438 -0
- package/apps/studio/test/figma-provenance.test.ts +108 -0
- package/apps/studio/test/figma-routes.test.ts +294 -0
- package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
- package/apps/studio/test/git-cloud-posture.test.ts +50 -0
- package/apps/studio/test/hub-link.test.ts +11 -0
- package/apps/studio/test/import-figma.test.ts +667 -0
- package/apps/studio/test/sync-asset-push.test.ts +567 -0
- package/apps/studio/test/sync-connection-state.test.ts +79 -0
- package/apps/studio/test/sync-hubs-config.test.ts +5 -0
- package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
- package/apps/studio/test/sync-panel-surface.test.ts +90 -0
- package/apps/studio/test/sync-path-pull.test.ts +63 -0
- package/apps/studio/test/sync-presentation.test.ts +77 -0
- package/apps/studio/test/sync-runtime.test.ts +316 -1
- package/apps/studio/test/sync-status.test.ts +28 -0
- package/apps/studio/test/timeline-comp-target.test.ts +139 -0
- package/apps/studio/test/video-comp.test.ts +104 -2
- package/apps/studio/test/video-encode-lib.test.ts +63 -0
- package/apps/studio/test/workspace-containment.test.ts +1 -0
- package/apps/studio/use-artboard-drag.tsx +37 -3
- package/apps/studio/video-comp.tsx +121 -6
- package/apps/studio/whats-new.json +98 -0
- package/apps/studio/workspace-mode.ts +4 -0
- package/cli/commands/design.mjs +15 -0
- package/cli/commands/kg.mjs +8 -1
- package/cli/commands/kg.test.mjs +24 -0
- package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
- package/cli/lib/figma-import-controls.test.mjs +70 -0
- package/package.json +8 -8
- package/plugins/flow/.claude-plugin/config.schema.json +3 -3
|
@@ -12,6 +12,7 @@ import { join } from 'node:path';
|
|
|
12
12
|
import {
|
|
13
13
|
connectOutcomeNote,
|
|
14
14
|
isDisplayableUrl,
|
|
15
|
+
isLinkedProjectRow,
|
|
15
16
|
localIdentityHint,
|
|
16
17
|
parseDeepLink,
|
|
17
18
|
shareViewUrl,
|
|
@@ -187,6 +188,101 @@ describe('status — names the LOCAL half so a deep link can be read', () => {
|
|
|
187
188
|
});
|
|
188
189
|
});
|
|
189
190
|
|
|
191
|
+
describe('isLinkedProjectRow — Connected is a state only a credentialed link earns (fix 7)', () => {
|
|
192
|
+
const CLOUD = 'https://cloud.maude.sh';
|
|
193
|
+
const row = (over = {}) =>
|
|
194
|
+
isLinkedProjectRow({
|
|
195
|
+
projectUrl: 'https://alligators.cloud.maude.sh',
|
|
196
|
+
linkedHubUrl: 'https://alligators.cloud.maude.sh',
|
|
197
|
+
linkedHubCredentialed: true,
|
|
198
|
+
cloudUrl: CLOUD,
|
|
199
|
+
...over,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('linked + credentialed → the row is Connected', () => {
|
|
203
|
+
expect(row()).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
test('linked but UNCREDENTIALED still offers Connect — config.json travels with the repo (B2)', () => {
|
|
206
|
+
expect(row({ linkedHubCredentialed: false })).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
test('an unrelated project keeps its Connect button', () => {
|
|
209
|
+
expect(row({ projectUrl: 'https://dugmate.cloud.maude.sh' })).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
test('unlinked folder: no row is Connected', () => {
|
|
212
|
+
expect(row({ linkedHubUrl: null, linkedHubCredentialed: false })).toBe(false);
|
|
213
|
+
});
|
|
214
|
+
test('self-host addresses carry no project label — exact origin decides', () => {
|
|
215
|
+
expect(
|
|
216
|
+
row({
|
|
217
|
+
projectUrl: 'http://127.0.0.1:8788',
|
|
218
|
+
linkedHubUrl: 'http://127.0.0.1:8788',
|
|
219
|
+
cloudUrl: 'http://127.0.0.1:8788',
|
|
220
|
+
})
|
|
221
|
+
).toBe(true);
|
|
222
|
+
// Two stubs on different ports are two different workspaces.
|
|
223
|
+
expect(
|
|
224
|
+
row({
|
|
225
|
+
projectUrl: 'http://127.0.0.1:8788',
|
|
226
|
+
linkedHubUrl: 'http://127.0.0.1:9999',
|
|
227
|
+
cloudUrl: 'http://127.0.0.1:8788',
|
|
228
|
+
})
|
|
229
|
+
).toBe(false);
|
|
230
|
+
});
|
|
231
|
+
test('a label on one side and none on the other never matches', () => {
|
|
232
|
+
expect(row({ projectUrl: 'http://127.0.0.1:8788' })).toBe(false);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe('detach — the in-app unlink (fix 7 Disconnect)', () => {
|
|
237
|
+
test('drops linkedHub + the hub credential, cycles the runtime to solo; idempotent', async () => {
|
|
238
|
+
const { createCloudEndpoints } = await import('../cloud/endpoints.ts');
|
|
239
|
+
const { mkdirSync, existsSync } = await import('node:fs');
|
|
240
|
+
const repoRoot = join(scratch, 'detach-me');
|
|
241
|
+
const designRoot = join(repoRoot, '.design');
|
|
242
|
+
mkdirSync(designRoot, { recursive: true });
|
|
243
|
+
const hubUrl = 'https://alligators.cloud.maude.sh';
|
|
244
|
+
writeFileSync(
|
|
245
|
+
join(designRoot, 'config.json'),
|
|
246
|
+
JSON.stringify({ designRoot: '.design', linkedHub: { url: hubUrl, linkedAt: 1 } })
|
|
247
|
+
);
|
|
248
|
+
writeFileSync(
|
|
249
|
+
join(scratch, 'hubs.json'),
|
|
250
|
+
JSON.stringify({ hubs: { [hubUrl]: { token: 'mau_x', linkedAt: 1 } }, trusted: [hubUrl] })
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
const restarts: unknown[] = [];
|
|
254
|
+
const api = createCloudEndpoints({
|
|
255
|
+
paths: { repoRoot, designRoot },
|
|
256
|
+
syncControl: {
|
|
257
|
+
restart: async (linkedHub) => {
|
|
258
|
+
restarts.push(linkedHub);
|
|
259
|
+
return { syncing: false, canvases: 0, reason: 'unlinked' };
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const r = await api.detach();
|
|
265
|
+
expect(r.status).toBe(200);
|
|
266
|
+
expect((r.json as { detached?: boolean }).detached).toBe(true);
|
|
267
|
+
// The committed half is gone, the rest of config.json survives.
|
|
268
|
+
const cfg = JSON.parse(readFileSync(join(designRoot, 'config.json'), 'utf8'));
|
|
269
|
+
expect(cfg.linkedHub).toBeUndefined();
|
|
270
|
+
expect(cfg.designRoot).toBe('.design');
|
|
271
|
+
// The credential + trust entry are gone too (the CLI unlink default).
|
|
272
|
+
const hubs = JSON.parse(readFileSync(join(scratch, 'hubs.json'), 'utf8'));
|
|
273
|
+
expect(hubs.hubs[hubUrl]).toBeUndefined();
|
|
274
|
+
expect(hubs.trusted).not.toContain(hubUrl);
|
|
275
|
+
// The runtime was cycled with the explicit unlink sentinel, not undefined.
|
|
276
|
+
expect(restarts).toEqual([null]);
|
|
277
|
+
expect(existsSync(join(designRoot, 'config.json'))).toBe(true);
|
|
278
|
+
|
|
279
|
+
// Second detach: still ok, nothing to do.
|
|
280
|
+
const again = await api.detach();
|
|
281
|
+
expect(again.status).toBe(200);
|
|
282
|
+
expect((again.json as { detached?: boolean }).detached).toBe(false);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
190
286
|
describe('localIdentityHint — only an exact, corroborated agreement is silent', () => {
|
|
191
287
|
const CLOUD = 'https://cloud.maude.sh';
|
|
192
288
|
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Silent hub-credential renewal (cloud/renew.ts) — the lane that keeps a
|
|
2
|
+
// cloud link alive past the 12 h cell-session cap without a person pressing
|
|
3
|
+
// Connect again. Every network hop is injected; nothing here talks to a real
|
|
4
|
+
// host.
|
|
5
|
+
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
7
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { renewHubCredential } from '../cloud/renew.ts';
|
|
12
|
+
|
|
13
|
+
const HUB_URL = 'https://alligators.cloud.test';
|
|
14
|
+
|
|
15
|
+
let dir: string;
|
|
16
|
+
let envBackup: Record<string, string | undefined>;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
dir = mkdtempSync(join(tmpdir(), 'maude-renew-'));
|
|
20
|
+
envBackup = {
|
|
21
|
+
MAUDE_CLOUD_CONFIG: process.env.MAUDE_CLOUD_CONFIG,
|
|
22
|
+
HUBS_CONFIG_PATH: process.env.HUBS_CONFIG_PATH,
|
|
23
|
+
MAUDE_CLOUD_URL: process.env.MAUDE_CLOUD_URL,
|
|
24
|
+
};
|
|
25
|
+
process.env.MAUDE_CLOUD_CONFIG = join(dir, 'cloud.json');
|
|
26
|
+
process.env.HUBS_CONFIG_PATH = join(dir, 'hubs.json');
|
|
27
|
+
process.env.MAUDE_CLOUD_URL = 'https://cloud.test';
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
for (const [k, v] of Object.entries(envBackup)) {
|
|
32
|
+
if (v === undefined) delete process.env[k];
|
|
33
|
+
else process.env[k] = v;
|
|
34
|
+
}
|
|
35
|
+
rmSync(dir, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function signIn(token = 'mpt_account'): void {
|
|
39
|
+
writeFileSync(
|
|
40
|
+
join(dir, 'cloud.json'),
|
|
41
|
+
JSON.stringify({ url: 'https://cloud.test', token, connectedAt: 1 })
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function writeHubRecord(record: Record<string, unknown>): void {
|
|
46
|
+
writeFileSync(join(dir, 'hubs.json'), JSON.stringify({ hubs: { [HUB_URL]: record } }));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readHubRecord(): Record<string, unknown> | undefined {
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(readFileSync(join(dir, 'hubs.json'), 'utf8')).hubs[HUB_URL];
|
|
52
|
+
} catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Route-keyed fetch stub; records every URL it was asked for. */
|
|
58
|
+
function fakeFetch(routes: Record<string, { status: number; body: unknown }>) {
|
|
59
|
+
const calls: string[] = [];
|
|
60
|
+
const doFetch = (async (input: string | URL | Request) => {
|
|
61
|
+
const url = String(input);
|
|
62
|
+
calls.push(url);
|
|
63
|
+
for (const [prefix, r] of Object.entries(routes)) {
|
|
64
|
+
if (url.startsWith(prefix)) {
|
|
65
|
+
return new Response(JSON.stringify(r.body), { status: r.status });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return new Response('{}', { status: 404 });
|
|
69
|
+
}) as typeof fetch;
|
|
70
|
+
return { doFetch, calls };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const HAPPY_ROUTES = {
|
|
74
|
+
'https://cloud.test/api/projects': {
|
|
75
|
+
status: 200,
|
|
76
|
+
body: { projects: [{ id: 'alligators', url: HUB_URL, role: 'owner' }] },
|
|
77
|
+
},
|
|
78
|
+
'https://cloud.test/projects/open': {
|
|
79
|
+
status: 200,
|
|
80
|
+
body: { token: 'proj_tok', url: HUB_URL, role: 'owner', expiresAt: 111 },
|
|
81
|
+
},
|
|
82
|
+
[`${HUB_URL}/auth/login`]: {
|
|
83
|
+
status: 200,
|
|
84
|
+
body: { token: 'mau_new', expiresAt: 12345, user: { email: 'm@x.cz', role: 'member' } },
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
describe('renewHubCredential', () => {
|
|
89
|
+
test('signed out → not-signed-in, and no network is touched', async () => {
|
|
90
|
+
const { doFetch, calls } = fakeFetch(HAPPY_ROUTES);
|
|
91
|
+
const r = await renewHubCredential(HUB_URL, doFetch);
|
|
92
|
+
expect(r).toEqual({ ok: false, reason: 'not-signed-in' });
|
|
93
|
+
expect(calls).toHaveLength(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('happy path: projects → open → cell login → hubs.json updated, role preserved', async () => {
|
|
97
|
+
signIn();
|
|
98
|
+
writeHubRecord({ token: 'mau_stale', linkedAt: 1, role: 'member', expiresAt: 2 });
|
|
99
|
+
const { doFetch, calls } = fakeFetch(HAPPY_ROUTES);
|
|
100
|
+
|
|
101
|
+
const r = await renewHubCredential(HUB_URL, doFetch);
|
|
102
|
+
expect(r).toEqual({ ok: true, token: 'mau_new', expiresAt: 12345 });
|
|
103
|
+
|
|
104
|
+
const stored = readHubRecord();
|
|
105
|
+
expect(stored?.token).toBe('mau_new');
|
|
106
|
+
expect(stored?.expiresAt).toBe(12345);
|
|
107
|
+
// The upsert replaces the record — the vouched role must survive renewal.
|
|
108
|
+
expect(stored?.role).toBe('member');
|
|
109
|
+
|
|
110
|
+
// The cell login went to the STORED hub address, not anything the cloud named.
|
|
111
|
+
expect(calls[2]).toBe(`${HUB_URL}/auth/login`);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('F5: the FRESH role from the cell login wins over the stale stored one', async () => {
|
|
115
|
+
signIn();
|
|
116
|
+
writeHubRecord({ token: 'mau_stale', linkedAt: 1, role: 'member' });
|
|
117
|
+
const { doFetch } = fakeFetch({
|
|
118
|
+
...HAPPY_ROUTES,
|
|
119
|
+
[`${HUB_URL}/auth/login`]: {
|
|
120
|
+
status: 200,
|
|
121
|
+
body: { token: 'mau_new', expiresAt: 12345, user: { email: 'm@x.cz', role: 'owner' } },
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
const r = await renewHubCredential(HUB_URL, doFetch);
|
|
125
|
+
expect(r.ok).toBe(true);
|
|
126
|
+
expect(readHubRecord()?.role).toBe('owner'); // not the stale 'member'
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('F5: an older cell that omits the role falls back to the stored one', async () => {
|
|
130
|
+
signIn();
|
|
131
|
+
writeHubRecord({ token: 'mau_stale', linkedAt: 1, role: 'member' });
|
|
132
|
+
const { doFetch } = fakeFetch({
|
|
133
|
+
...HAPPY_ROUTES,
|
|
134
|
+
[`${HUB_URL}/auth/login`]: { status: 200, body: { token: 'mau_new', expiresAt: 12345 } },
|
|
135
|
+
});
|
|
136
|
+
await renewHubCredential(HUB_URL, doFetch);
|
|
137
|
+
expect(readHubRecord()?.role).toBe('member');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('claim-a: an embedded-credential hubUrl is refused, no network touched', async () => {
|
|
141
|
+
signIn();
|
|
142
|
+
const { doFetch, calls } = fakeFetch(HAPPY_ROUTES);
|
|
143
|
+
const r = await renewHubCredential('https://evil@alligators.cloud.test', doFetch);
|
|
144
|
+
expect(r).toEqual({ ok: false, reason: 'no-matching-project' });
|
|
145
|
+
expect(calls).toHaveLength(0);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('account token revoked (401 on projects) → account-revoked, store untouched', async () => {
|
|
149
|
+
signIn();
|
|
150
|
+
writeHubRecord({ token: 'mau_stale', linkedAt: 1 });
|
|
151
|
+
const { doFetch } = fakeFetch({
|
|
152
|
+
'https://cloud.test/api/projects': { status: 401, body: {} },
|
|
153
|
+
});
|
|
154
|
+
const r = await renewHubCredential(HUB_URL, doFetch);
|
|
155
|
+
expect(r).toEqual({ ok: false, reason: 'account-revoked' });
|
|
156
|
+
expect(readHubRecord()?.token).toBe('mau_stale');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('hub not among the account projects → no-matching-project', async () => {
|
|
160
|
+
signIn();
|
|
161
|
+
const { doFetch } = fakeFetch({
|
|
162
|
+
'https://cloud.test/api/projects': {
|
|
163
|
+
status: 200,
|
|
164
|
+
body: { projects: [{ id: 'other', url: 'https://other.cloud.test', role: 'owner' }] },
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
const r = await renewHubCredential(HUB_URL, doFetch);
|
|
168
|
+
expect(r).toEqual({ ok: false, reason: 'no-matching-project' });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('a viewer role never renews an editor credential', async () => {
|
|
172
|
+
signIn();
|
|
173
|
+
const { doFetch } = fakeFetch({
|
|
174
|
+
'https://cloud.test/api/projects': {
|
|
175
|
+
status: 200,
|
|
176
|
+
body: { projects: [{ id: 'alligators', url: HUB_URL, role: 'viewer' }] },
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
const r = await renewHubCredential(HUB_URL, doFetch);
|
|
180
|
+
expect(r).toEqual({ ok: false, reason: 'no-matching-project' });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('removed member: /projects/open refuses → open-refused (the right place to die)', async () => {
|
|
184
|
+
signIn();
|
|
185
|
+
const { doFetch } = fakeFetch({
|
|
186
|
+
'https://cloud.test/api/projects': HAPPY_ROUTES['https://cloud.test/api/projects'],
|
|
187
|
+
'https://cloud.test/projects/open': { status: 404, body: {} },
|
|
188
|
+
});
|
|
189
|
+
const r = await renewHubCredential(HUB_URL, doFetch);
|
|
190
|
+
expect(r).toEqual({ ok: false, reason: 'open-refused' });
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('cell refuses the exchange → cell-refused, store untouched', async () => {
|
|
194
|
+
signIn();
|
|
195
|
+
writeHubRecord({ token: 'mau_stale', linkedAt: 1 });
|
|
196
|
+
const { doFetch } = fakeFetch({
|
|
197
|
+
'https://cloud.test/api/projects': HAPPY_ROUTES['https://cloud.test/api/projects'],
|
|
198
|
+
'https://cloud.test/projects/open': HAPPY_ROUTES['https://cloud.test/projects/open'],
|
|
199
|
+
[`${HUB_URL}/auth/login`]: { status: 401, body: {} },
|
|
200
|
+
});
|
|
201
|
+
const r = await renewHubCredential(HUB_URL, doFetch);
|
|
202
|
+
expect(r).toEqual({ ok: false, reason: 'cell-refused' });
|
|
203
|
+
expect(readHubRecord()?.token).toBe('mau_stale');
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -36,11 +36,20 @@ const APP = readFileSync(join(STUDIO, 'client', 'app.jsx'), 'utf8');
|
|
|
36
36
|
const SETTINGS = readFileSync(join(STUDIO, 'client', 'panels', 'SettingsPanel.jsx'), 'utf8');
|
|
37
37
|
|
|
38
38
|
describe('the cloud shell offers nothing it cannot honour', () => {
|
|
39
|
-
test('the local-machine settings tabs are flagged
|
|
39
|
+
test('the local-machine settings tabs are flagged — and the list is exact', () => {
|
|
40
|
+
// An exact list, not a `toContain`: the failure this guards is a tab that
|
|
41
|
+
// configures machine-local state shipping to the cloud shell UNFLAGGED,
|
|
42
|
+
// where its routes are refused by design (DDR-209 D1) and it renders as a
|
|
43
|
+
// wall of 404s. A new tab has to be classified deliberately, which means
|
|
44
|
+
// this assertion is SUPPOSED to fail when one is added.
|
|
45
|
+
//
|
|
46
|
+
// `figma` (DDR-216) is local-machine for the same reason `ai-generation`
|
|
47
|
+
// is: the credential lives in `~/.config/maude/keys.json` on this machine,
|
|
48
|
+
// and the import writes into this machine's design root.
|
|
40
49
|
const flagged = [
|
|
41
50
|
...SETTINGS.matchAll(/\{ id: '([a-z-]+)', label: '[^']+', local: true \}/g),
|
|
42
51
|
].map((m) => m[1]);
|
|
43
|
-
expect(flagged.sort()).toEqual(['ai-generation', 'subtitles', 'video']);
|
|
52
|
+
expect(flagged.sort()).toEqual(['ai-generation', 'figma', 'subtitles', 'video']);
|
|
44
53
|
});
|
|
45
54
|
|
|
46
55
|
test('the tab rail renders the FILTERED list, not the raw table', () => {
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// RCA issue-mp4-audio-export-html5audio-silent-degrade — the silence gate.
|
|
2
|
+
//
|
|
3
|
+
// The bug was not that the export degraded; degrading to a video-only capture
|
|
4
|
+
// when the audio renderer refuses is DDR-157's deliberate design. The bug was
|
|
5
|
+
// that the degradation reached nothing a human or an agent reads: `video.ts`
|
|
6
|
+
// parsed the shim's `{degraded, audioDropped, fallbackReason}` summary, wrote ONE
|
|
7
|
+
// console.error, and returned an ExportResult without it — so the job said
|
|
8
|
+
// `done`, `_export-history.json` looked clean, `GET /_api/export-jobs` had no
|
|
9
|
+
// field for it, and the Exports panel showed a success row. The user shipped a
|
|
10
|
+
// muted file four times before anyone dug through the desktop app's stderr.
|
|
11
|
+
//
|
|
12
|
+
// The real regression gate for the artifact itself is an integration test that
|
|
13
|
+
// exports a fixture comp and asserts an audio stream exists (RCA Testing
|
|
14
|
+
// Requirements). That one needs a browser, and CI has none — so, matching the
|
|
15
|
+
// house style of `export-shim-multi-capture.test.ts`, the propagation CHAIN is
|
|
16
|
+
// guarded at the source-shape level here, plus a real unit test of the one piece
|
|
17
|
+
// that is pure.
|
|
18
|
+
|
|
19
|
+
import { describe, expect, test } from 'bun:test';
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
import { hasAudioStream, remedyFor } from '../../exporters/degraded.ts';
|
|
24
|
+
|
|
25
|
+
const STUDIO = join(import.meta.dir, '..', '..');
|
|
26
|
+
const read = (rel: string) => readFileSync(join(STUDIO, rel), 'utf8');
|
|
27
|
+
|
|
28
|
+
describe('remedyFor — a fixable cause must carry its one-line fix', () => {
|
|
29
|
+
test('names @remotion/media for the Html5Audio rejection', () => {
|
|
30
|
+
const reason =
|
|
31
|
+
'page.evaluate: Error: <Html5Audio> is not supported in @remotion/web-renderer. ' +
|
|
32
|
+
'Use <Audio> from @remotion/media instead.';
|
|
33
|
+
const remedy = remedyFor(reason);
|
|
34
|
+
expect(remedy).toBeDefined();
|
|
35
|
+
expect(remedy).toContain('@remotion/media');
|
|
36
|
+
// The remedy has to say what to STOP doing too, or it reads as advice
|
|
37
|
+
// rather than a diagnosis of this file.
|
|
38
|
+
expect(remedy).toContain('remotion');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('names the Video swap for the OffthreadVideo rejection', () => {
|
|
42
|
+
const remedy = remedyFor('<OffthreadVideo> is not supported in @remotion/web-renderer');
|
|
43
|
+
expect(remedy).toBeDefined();
|
|
44
|
+
expect(remedy).toContain('@remotion/media');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('returns nothing for a cause the author cannot fix by hand', () => {
|
|
48
|
+
// The DDR-157 recursion overflow is data-dependent and has no one-liner —
|
|
49
|
+
// inventing a remedy for it would be worse than silence.
|
|
50
|
+
expect(remedyFor('RangeError: Maximum call stack size exceeded')).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('hasAudioStream — the artifact has the last word', () => {
|
|
55
|
+
const bytes = (s: string) => new TextEncoder().encode(s);
|
|
56
|
+
|
|
57
|
+
test('mp4 with a `soun` handler counts as having audio', () => {
|
|
58
|
+
expect(hasAudioStream(bytes('....hdlrsoun....'), 'mp4')).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('mp4 with only a video handler does not', () => {
|
|
62
|
+
expect(hasAudioStream(bytes('....hdlrvide....'), 'mp4')).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('webm is recognised by its audio CodecID', () => {
|
|
66
|
+
expect(hasAudioStream(bytes('\x00\x00A_OPUS\x00'), 'webm')).toBe(true);
|
|
67
|
+
expect(hasAudioStream(bytes('\x00\x00V_VP9\x00'), 'webm')).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('an unrecognised container is assumed fine — never invent a degradation', () => {
|
|
71
|
+
expect(hasAudioStream(bytes('anything'), 'mov')).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('an empty body does not crash the check', () => {
|
|
75
|
+
expect(hasAudioStream(new Uint8Array(0), 'mp4')).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('degraded propagation chain (source-shape — the browser half runs manually)', () => {
|
|
80
|
+
test('video.ts returns the degradation instead of only logging it', () => {
|
|
81
|
+
const src = read('exporters/video.ts');
|
|
82
|
+
// The precise regression: a console.error with no accompanying assignment.
|
|
83
|
+
expect(src).toContain('degraded = {');
|
|
84
|
+
expect(src).toMatch(/audioDropped:/);
|
|
85
|
+
expect(src).toMatch(/return\s*{[\s\S]*?degraded,[\s\S]*?}/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('ExportResult carries a degraded field', () => {
|
|
89
|
+
const src = read('exporters/index.ts');
|
|
90
|
+
expect(src).toContain('ExportDegradation');
|
|
91
|
+
expect(src).toMatch(/degraded\?:\s*ExportDegradation/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('the job record, the WS emit and the history entry all carry it', () => {
|
|
95
|
+
const src = read('exporters/jobs.ts');
|
|
96
|
+
// Job type + assignment on the done path.
|
|
97
|
+
expect(src).toMatch(/degraded\?:\s*ExportDegradation/);
|
|
98
|
+
expect(src).toContain('if (res.degraded) job.degraded = res.degraded;');
|
|
99
|
+
// deriveHistory() writes `_export-history.json` — the ledger a later
|
|
100
|
+
// session reads back. It must not look clean either.
|
|
101
|
+
expect(src).toMatch(/degraded:\s*j\.degraded/);
|
|
102
|
+
// The WS emit spreads the whole job, which is how `degraded` reaches
|
|
103
|
+
// subscribers — assert the spread rather than a field list that could drift.
|
|
104
|
+
expect(src).toContain("bus.emit('export:job', { ...job })");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('status stays `done` — the file is real, the flag is what differs', () => {
|
|
108
|
+
const src = read('exporters/jobs.ts');
|
|
109
|
+
expect(src).toContain("job.status = 'done';");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('the panel and the toast both surface it', () => {
|
|
113
|
+
const src = read('client/export-center.jsx');
|
|
114
|
+
expect(src).toContain('DegradedNote');
|
|
115
|
+
expect(src).toContain('export-degraded-note');
|
|
116
|
+
// The pill must not read "Ready" on a muted file.
|
|
117
|
+
expect(src).toContain('Ready · no audio');
|
|
118
|
+
// Both surfaces, because the toast is what the user sees without opening
|
|
119
|
+
// the panel, and the panel is what they revisit afterwards.
|
|
120
|
+
const noteUses = src.match(/<DegradedNote/g) ?? [];
|
|
121
|
+
expect(noteUses.length).toBeGreaterThanOrEqual(2);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// RCA issue-mp4-audio-export-html5audio-silent-degrade — the pre-flight scanner.
|
|
2
|
+
//
|
|
3
|
+
// The cases here are the ones the RCA's Testing Requirements name, and the
|
|
4
|
+
// barrel case is the one that matters most: the reproducer does NOT import
|
|
5
|
+
// `Audio` from 'remotion' in the canvas file. It imports it from `_broadcast`,
|
|
6
|
+
// which re-exports it. A scanner that reads only the canvas sees a clean local
|
|
7
|
+
// import and waves the export through — which is exactly how this shipped.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from 'bun:test';
|
|
10
|
+
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { audioRefusalMessage, scanUnsupportedMedia } from '../../exporters/unsupported-media.ts';
|
|
15
|
+
|
|
16
|
+
function sandbox(files: Record<string, string>): string {
|
|
17
|
+
const dir = mkdtempSync(join(tmpdir(), 'maude-unsupported-'));
|
|
18
|
+
for (const [name, body] of Object.entries(files)) {
|
|
19
|
+
writeFileSync(join(dir, name), body);
|
|
20
|
+
}
|
|
21
|
+
return dir;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('scanUnsupportedMedia', () => {
|
|
25
|
+
test('flags a direct <Audio> import from remotion', () => {
|
|
26
|
+
const dir = sandbox({
|
|
27
|
+
'c.tsx': `import { AbsoluteFill, Audio } from 'remotion';
|
|
28
|
+
export const C = () => <AbsoluteFill><Audio src="x.mp3" /></AbsoluteFill>;`,
|
|
29
|
+
});
|
|
30
|
+
const found = scanUnsupportedMedia(join(dir, 'c.tsx'));
|
|
31
|
+
expect(found).toHaveLength(1);
|
|
32
|
+
expect(found[0].element).toBe('Audio');
|
|
33
|
+
expect(found[0].viaBarrel).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('flags it in a multi-binding import alongside supported names', () => {
|
|
37
|
+
const dir = sandbox({
|
|
38
|
+
'c.tsx': `import { AbsoluteFill, OffthreadVideo, Audio } from 'remotion';
|
|
39
|
+
export const C = () => <><Audio src="a" /><OffthreadVideo src="v" /></>;`,
|
|
40
|
+
});
|
|
41
|
+
const els = scanUnsupportedMedia(join(dir, 'c.tsx')).map((f) => f.element);
|
|
42
|
+
expect(els).toContain('Audio');
|
|
43
|
+
expect(els).toContain('OffthreadVideo');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('sees through a re-export barrel one hop away — the actual shape of the bug', () => {
|
|
47
|
+
const dir = sandbox({
|
|
48
|
+
'_broadcast.tsx': `export { Audio } from 'remotion';
|
|
49
|
+
export const Brand = () => null;`,
|
|
50
|
+
'c.tsx': `import { Audio, Brand } from './_broadcast';
|
|
51
|
+
export const C = () => <><Brand /><Audio src="sax.mp3" /></>;`,
|
|
52
|
+
});
|
|
53
|
+
const found = scanUnsupportedMedia(join(dir, 'c.tsx'));
|
|
54
|
+
expect(found).toHaveLength(1);
|
|
55
|
+
expect(found[0].element).toBe('Audio');
|
|
56
|
+
expect(found[0].viaBarrel).toBe(true);
|
|
57
|
+
expect(found[0].sourceFile).toContain('_broadcast');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('does NOT flag the supported @remotion/media import', () => {
|
|
61
|
+
const dir = sandbox({
|
|
62
|
+
'c.tsx': `import { Audio, Video } from '@remotion/media';
|
|
63
|
+
export const C = () => <><Audio src="a" /><Video src="v" /></>;`,
|
|
64
|
+
});
|
|
65
|
+
expect(scanUnsupportedMedia(join(dir, 'c.tsx'))).toHaveLength(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('does not flag a sibling package whose name merely starts with remotion', () => {
|
|
69
|
+
const dir = sandbox({
|
|
70
|
+
'c.tsx': `import { TransitionSeries } from '@remotion/transitions';
|
|
71
|
+
import { Audio } from '@remotion/media';
|
|
72
|
+
export const C = () => <Audio src="a" />;`,
|
|
73
|
+
});
|
|
74
|
+
expect(scanUnsupportedMedia(join(dir, 'c.tsx'))).toHaveLength(0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('ignores an unused import — not worth refusing an export over', () => {
|
|
78
|
+
const dir = sandbox({
|
|
79
|
+
'c.tsx': `import { AbsoluteFill, Audio } from 'remotion';
|
|
80
|
+
export const C = () => <AbsoluteFill>silent</AbsoluteFill>;`,
|
|
81
|
+
});
|
|
82
|
+
expect(scanUnsupportedMedia(join(dir, 'c.tsx'))).toHaveLength(0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('catches an aliased import mounted under its alias', () => {
|
|
86
|
+
const dir = sandbox({
|
|
87
|
+
'c.tsx': `import { Audio as Sound } from 'remotion';
|
|
88
|
+
export const C = () => <Sound src="a" />;`,
|
|
89
|
+
});
|
|
90
|
+
const found = scanUnsupportedMedia(join(dir, 'c.tsx'));
|
|
91
|
+
expect(found).toHaveLength(1);
|
|
92
|
+
// Reported as the element it IS, but matched on the name it renders under.
|
|
93
|
+
expect(found[0].element).toBe('Audio');
|
|
94
|
+
expect(found[0].localName).toBe('Sound');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('an aliased-but-unused import is still not worth refusing over', () => {
|
|
98
|
+
const dir = sandbox({
|
|
99
|
+
'c.tsx': `import { Audio as Sound } from 'remotion';
|
|
100
|
+
export const C = () => <div>silent</div>;`,
|
|
101
|
+
});
|
|
102
|
+
expect(scanUnsupportedMedia(join(dir, 'c.tsx'))).toHaveLength(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('an unreadable canvas is not this check’s business to report', () => {
|
|
106
|
+
expect(scanUnsupportedMedia('/definitely/not/here.tsx')).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('audioRefusalMessage', () => {
|
|
111
|
+
test('states the cause, the cost, and the one-word fix', () => {
|
|
112
|
+
const msg = audioRefusalMessage(
|
|
113
|
+
{ element: 'Audio', sourceFile: '/x/_broadcast.tsx', viaBarrel: true, localName: 'Audio' },
|
|
114
|
+
'ui/social/Krpole.tsx'
|
|
115
|
+
);
|
|
116
|
+
expect(msg).toContain('Html5Audio');
|
|
117
|
+
expect(msg).toContain('@remotion/media');
|
|
118
|
+
expect(msg).toContain('_broadcast.tsx');
|
|
119
|
+
// The escape hatch must be discoverable from the refusal itself, or a user
|
|
120
|
+
// who genuinely wants a muted export is stuck.
|
|
121
|
+
expect(msg).toContain('allowUnsupportedMedia');
|
|
122
|
+
});
|
|
123
|
+
});
|