@1agh/maude 0.58.1 → 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.
Files changed (119) hide show
  1. package/apps/studio/.ai/cache/_stats.json +1 -1
  2. package/apps/studio/acp/bridge.ts +165 -15
  3. package/apps/studio/annotations-bindings.ts +83 -4
  4. package/apps/studio/api.ts +6 -1
  5. package/apps/studio/bin/_fetch-asset.mjs +169 -5
  6. package/apps/studio/bin/_import-asset.mjs +72 -0
  7. package/apps/studio/bin/_import-figma.mjs +1121 -0
  8. package/apps/studio/bin/_video-playwright.mjs +86 -3
  9. package/apps/studio/bin/import-figma.sh +38 -0
  10. package/apps/studio/bin/read-annotations.mjs +11 -1
  11. package/apps/studio/bun.lock +16 -22
  12. package/apps/studio/canvas-edit.ts +29 -5
  13. package/apps/studio/client/app.jsx +129 -23
  14. package/apps/studio/client/export-center.jsx +42 -4
  15. package/apps/studio/client/panels/ChatPanel.jsx +25 -2
  16. package/apps/studio/client/panels/CloudBar.jsx +92 -1
  17. package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
  18. package/apps/studio/client/panels/GitPanel.jsx +26 -6
  19. package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
  20. package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
  21. package/apps/studio/client/panels/TimelinePanel.jsx +2 -2
  22. package/apps/studio/client/panels/timeline-parse.js +3 -3
  23. package/apps/studio/client/styles/3-shell-maude.css +7 -0
  24. package/apps/studio/client/styles/4-components.css +134 -0
  25. package/apps/studio/client/styles/6-acp-chat.css +12 -0
  26. package/apps/studio/clip-ops.ts +93 -17
  27. package/apps/studio/cloud/endpoints.ts +78 -10
  28. package/apps/studio/cloud/renew.ts +183 -0
  29. package/apps/studio/context.ts +2 -1
  30. package/apps/studio/dist/client.bundle.js +1491 -1491
  31. package/apps/studio/dist/runtime/@remotion_media.js +56 -136
  32. package/apps/studio/dist/runtime/@remotion_player.js +18 -18
  33. package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
  34. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
  35. package/apps/studio/dist/runtime/remotion.js +12 -12
  36. package/apps/studio/dist/styles.css +1 -1
  37. package/apps/studio/exporters/_browser-bundles.ts +20 -6
  38. package/apps/studio/exporters/_runtime.ts +19 -0
  39. package/apps/studio/exporters/degraded.ts +92 -0
  40. package/apps/studio/exporters/index.ts +5 -0
  41. package/apps/studio/exporters/jobs.ts +19 -0
  42. package/apps/studio/exporters/unsupported-media.ts +170 -0
  43. package/apps/studio/exporters/video-encode-lib.ts +27 -1
  44. package/apps/studio/exporters/video-render-lib.ts +6 -0
  45. package/apps/studio/exporters/video.ts +62 -1
  46. package/apps/studio/figma/assets.test.ts +372 -0
  47. package/apps/studio/figma/assets.ts +398 -0
  48. package/apps/studio/figma/client.test.ts +395 -0
  49. package/apps/studio/figma/client.ts +513 -0
  50. package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
  51. package/apps/studio/figma/comments-to-strokes.ts +173 -0
  52. package/apps/studio/figma/endpoints.ts +200 -0
  53. package/apps/studio/figma/sanitize.test.ts +256 -0
  54. package/apps/studio/figma/sanitize.ts +315 -0
  55. package/apps/studio/figma/style-map.ts +352 -0
  56. package/apps/studio/figma/to-artboard.test.ts +808 -0
  57. package/apps/studio/figma/to-artboard.ts +701 -0
  58. package/apps/studio/figma/to-render.test.ts +180 -0
  59. package/apps/studio/figma/to-render.ts +306 -0
  60. package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
  61. package/apps/studio/figma/to-strokes.test.ts +705 -0
  62. package/apps/studio/figma/to-strokes.ts +749 -0
  63. package/apps/studio/figma/to-tokens.test.ts +321 -0
  64. package/apps/studio/figma/to-tokens.ts +305 -0
  65. package/apps/studio/figma/types.ts +539 -0
  66. package/apps/studio/figma/url.test.ts +167 -0
  67. package/apps/studio/figma/url.ts +160 -0
  68. package/apps/studio/http.ts +129 -0
  69. package/apps/studio/sync/asset-push.ts +124 -0
  70. package/apps/studio/sync/canvas-path.ts +329 -0
  71. package/apps/studio/sync/codec.ts +42 -0
  72. package/apps/studio/sync/connection-state.ts +11 -0
  73. package/apps/studio/sync/hub-link.ts +63 -7
  74. package/apps/studio/sync/hubs-config.ts +31 -3
  75. package/apps/studio/sync/index.ts +755 -32
  76. package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
  77. package/apps/studio/sync/presentation.ts +45 -1
  78. package/apps/studio/sync/projection.ts +11 -1
  79. package/apps/studio/sync/remote-docs.ts +122 -15
  80. package/apps/studio/sync/supervisor.ts +5 -1
  81. package/apps/studio/sync/workspace-signin.ts +7 -3
  82. package/apps/studio/test/acp-bridge-lifetime.test.ts +106 -0
  83. package/apps/studio/test/annotations-bindings.test.ts +150 -12
  84. package/apps/studio/test/canvas-create-api.test.ts +4 -1
  85. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  86. package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
  87. package/apps/studio/test/clip-addressing.test.ts +6 -1
  88. package/apps/studio/test/clip-ops.test.ts +5 -1
  89. package/apps/studio/test/cloud-endpoints.test.ts +96 -0
  90. package/apps/studio/test/cloud-renew.test.ts +205 -0
  91. package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
  92. package/apps/studio/test/comment-relay-origin-gate.test.ts +117 -0
  93. package/apps/studio/test/comments-fs-rebroadcast.test.ts +155 -0
  94. package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
  95. package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
  96. package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
  97. package/apps/studio/test/figma-provenance.test.ts +108 -0
  98. package/apps/studio/test/figma-routes.test.ts +294 -0
  99. package/apps/studio/test/fixtures/mock-acp-agent-wedged.mjs +37 -0
  100. package/apps/studio/test/git-cloud-posture.test.ts +50 -0
  101. package/apps/studio/test/hub-link.test.ts +11 -0
  102. package/apps/studio/test/import-figma.test.ts +479 -0
  103. package/apps/studio/test/sync-asset-push.test.ts +124 -0
  104. package/apps/studio/test/sync-canvas-path.test.ts +200 -0
  105. package/apps/studio/test/sync-connection-state.test.ts +13 -0
  106. package/apps/studio/test/sync-hubs-config.test.ts +5 -0
  107. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
  108. package/apps/studio/test/sync-path-pull.test.ts +465 -0
  109. package/apps/studio/test/sync-presentation.test.ts +77 -0
  110. package/apps/studio/test/sync-remote-docs.test.ts +55 -3
  111. package/apps/studio/test/sync-runtime.test.ts +434 -1
  112. package/apps/studio/test/video-comp.test.ts +23 -1
  113. package/apps/studio/test/workspace-containment.test.ts +1 -0
  114. package/apps/studio/video-comp.tsx +70 -6
  115. package/apps/studio/whats-new.json +36 -0
  116. package/apps/studio/workspace-mode.ts +4 -0
  117. package/cli/commands/design.mjs +8 -0
  118. package/package.json +8 -8
  119. package/plugins/flow/.claude-plugin/config.schema.json +3 -3
@@ -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, and only those three', () => {
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,117 @@
1
+ // The shell's inbound-postMessage comment relays must stay scoped to the
2
+ // ACTIVE canvas.
3
+ //
4
+ // Threat: the origin check at the top of app.jsx's `onMessage` compares against
5
+ // the single shared `canvasOrigin`, so EVERY open canvas iframe passes it —
6
+ // including ones in background tabs. `comment-submit` / `comment-patch` /
7
+ // `comment-delete` relay straight onto the privileged WS, so without a
8
+ // per-message `e.source === activeWin` gate a background canvas can write
9
+ // comments onto a file it names itself, with no user gesture. That mattered
10
+ // little while comments were an inert store; the ACP panel's one-click
11
+ // "Implement N comments" action turns open comments into a feed the agent acts
12
+ // on, which is what promoted the seam to worth-closing.
13
+ //
14
+ // This is a SOURCE-STRUCTURE test, not a behavioural one: `App()` is a
15
+ // 14k-line component that cannot be mounted in isolation, and the twenty other
16
+ // `e.source === activeWin` gates in the same handler have no coverage at all.
17
+ // Asserting on the source keeps the guard cheap and honest about its reach —
18
+ // it catches a gate being deleted, a `file:` pin regressing to the
19
+ // caller-supplied value, and (the last test) a gate that is written but cannot
20
+ // execute. It claims nothing beyond that: it does not prove the relay behaves
21
+ // correctly, only that the control is present and reachable.
22
+
23
+ import { describe, expect, test } from 'bun:test';
24
+ import { readFileSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+
27
+ const SRC = readFileSync(join(import.meta.dir, '..', 'client', 'app.jsx'), 'utf8');
28
+
29
+ /** The body of one `else if (m.dgn === '<name>' …)` branch, up to the next one. */
30
+ function branch(name: string): string {
31
+ const start = SRC.indexOf(`m.dgn === '${name}'`);
32
+ expect(start, `branch for ${name} not found in app.jsx`).toBeGreaterThan(-1);
33
+ const rest = SRC.slice(start + name.length);
34
+ const end = rest.indexOf('} else if (m.dgn ===');
35
+ return rest.slice(0, end === -1 ? 2000 : end);
36
+ }
37
+
38
+ describe('canvas → shell comment relays are scoped to the active canvas', () => {
39
+ // The comment relays, plus the three shell-control branches that were
40
+ // ungated alongside them: `shell-shortcut` (reload / panel toggles /
41
+ // Export + Handoff dialogs), and `open-export` / `open-timeline-request`,
42
+ // which reach the same dialogs from the in-canvas toolbar and context menu.
43
+ // All five are actions a user takes INSIDE the canvas in view; none has a
44
+ // reason to be honoured from a background frame.
45
+ for (const name of [
46
+ 'comment-submit',
47
+ 'comment-patch',
48
+ 'comment-delete',
49
+ 'shell-shortcut',
50
+ 'open-export',
51
+ 'open-timeline-request',
52
+ ]) {
53
+ test(`${name} is gated on e.source === activeWin`, () => {
54
+ expect(branch(name)).toContain('e.source === activeWin');
55
+ });
56
+ }
57
+
58
+ test('comment-submit pins the target file to activePath, never the payload', () => {
59
+ const body = branch('comment-submit');
60
+ // The file the comment lands on is the one the user is looking at.
61
+ expect(body).toContain('file: activePath');
62
+ // A canvas-supplied target would let a background iframe pick the victim.
63
+ expect(body).not.toContain('file: p.file');
64
+ });
65
+
66
+ test('comment-patch / comment-delete only accept ids owned by the active canvas', () => {
67
+ // A bare id would otherwise reach any comment in the project — and these
68
+ // two verbs change or remove somebody else's note (ws.ts refuses both for a
69
+ // viewer session for exactly that reason).
70
+ expect(branch('comment-patch')).toContain('ownsActiveComment(m.id)');
71
+ expect(branch('comment-delete')).toContain('ownsActiveComment(m.id)');
72
+ });
73
+
74
+ // The above only prove the gate is WRITTEN. This proves it can RUN.
75
+ //
76
+ // House style declares `const activeWin` block-locally inside each `else if`
77
+ // branch, so a branch that compares against `activeWin` without declaring it
78
+ // reads exactly like a working gate while throwing `ReferenceError` at
79
+ // runtime — fail-closed, but it takes the legitimate path down with it and
80
+ // silently reopens the moment anything introduces an outer binding. That is
81
+ // not hypothetical: the first cut of this fix shipped it, and every
82
+ // string-matching assertion above stayed green.
83
+ test('every activeWin comparison has a declaration, derived from activePath', () => {
84
+ // Line comments are blanked first — the prose in this handler quotes the
85
+ // gate expression, and a comment is not a use.
86
+ const CODE = SRC.split('\n')
87
+ .map((l) => l.replace(/^\s*\/\/.*$/, ''))
88
+ .join('\n');
89
+ const BRANCH_START = /\b(?:else )?if \(m\.dgn ===/g;
90
+ const starts = [...CODE.matchAll(BRANCH_START)].map((m) => m.index ?? 0);
91
+ const undeclared: string[] = [];
92
+
93
+ // Both idioms count: the `===` gate used by the comment relays and the
94
+ // `!== … return` early-exit form five other branches use. Checking only
95
+ // one lets a branch convert to the other and slip the check.
96
+ for (const use of [...CODE.matchAll(/e\.source [!=]== activeWin/g)].map((m) => m.index ?? 0)) {
97
+ // Nearest branch boundary at or before this usage.
98
+ const open = starts.filter((s) => s < use).pop() ?? 0;
99
+ // `= activePath` is the load-bearing half: a declaration that derives the
100
+ // window from the MESSAGE (`iframesRef.current.get(m.file)`) instead of
101
+ // from app state satisfies a bare `const activeWin` check while handing
102
+ // the attacker the very comparison the gate exists to make.
103
+ if (!/const activeWin =\s*\n?\s*activePath/.test(CODE.slice(open, use))) {
104
+ undeclared.push(
105
+ CODE.slice(open, open + 60)
106
+ .split('\n')[0]!
107
+ .trim()
108
+ );
109
+ }
110
+ }
111
+
112
+ expect(
113
+ undeclared,
114
+ `activeWin used without a block-local declaration in: ${undeclared.join(' | ')}`
115
+ ).toEqual([]);
116
+ });
117
+ });
@@ -0,0 +1,155 @@
1
+ // Issue #74 — `_comments/<slug>.json` written DIRECTLY on disk must reach
2
+ // already-open shells over WS.
3
+ //
4
+ // This pins EXISTING behaviour rather than covering a fix. `/design:edit`
5
+ // resolves comments with a plain jq-rewrite of the comments file (edit.md §3's
6
+ // documented recipe), which bypasses api.commentsPatch and so never fires the
7
+ // `onCommentsChanged` re-broadcast in server.ts. The broadcast happens anyway,
8
+ // from collab/index.ts's `fs:any` re-seed ("the sidebar still needs the
9
+ // 'comments' bus emit, so keep that unconditionally") — a non-obvious owner
10
+ // that a reader of server.ts alone would conclude was missing.
11
+ //
12
+ // It is worth locking down because the chat panel's "Implement N comments"
13
+ // quick action (issue #74) is built on it: the agent resolves comments by
14
+ // direct write, and if that stopped reaching open shells the button would keep
15
+ // counting comments that are already done, with nothing else failing.
16
+
17
+ import { describe, expect, test } from 'bun:test';
18
+ import { mkdirSync, writeFileSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+
21
+ import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
22
+
23
+ const FILE = '.design/ui/Foo.tsx';
24
+ const SLUG = 'ui-foo';
25
+
26
+ async function openWs(port: number): Promise<WebSocket> {
27
+ const ws = new WebSocket(`ws://localhost:${port}/_ws`);
28
+ await new Promise<void>((resolve, reject) => {
29
+ const t = setTimeout(() => reject(new Error('ws open timeout')), 2000);
30
+ ws.addEventListener('open', () => {
31
+ clearTimeout(t);
32
+ resolve();
33
+ });
34
+ ws.addEventListener('error', (e) => {
35
+ clearTimeout(t);
36
+ reject(e);
37
+ });
38
+ });
39
+ return ws;
40
+ }
41
+
42
+ /** Resolve on the first `comments` frame for `file`, or null after `ms`. */
43
+ function nextCommentsFrame(
44
+ ws: WebSocket,
45
+ file: string,
46
+ ms = 5000
47
+ ): Promise<{ file: string; comments: Array<{ id: string; status: string }> } | null> {
48
+ return new Promise((resolve) => {
49
+ const done = (v: never | null) => {
50
+ clearTimeout(timer);
51
+ ws.removeEventListener('message', onMsg);
52
+ resolve(v);
53
+ };
54
+ const timer = setTimeout(() => done(null), ms);
55
+ const onMsg = (ev: MessageEvent) => {
56
+ try {
57
+ const m = JSON.parse(String(ev.data));
58
+ if (m && m.type === 'comments' && m.file === file) done(m);
59
+ } catch {
60
+ /* non-JSON frame — ignore */
61
+ }
62
+ };
63
+ ws.addEventListener('message', onMsg);
64
+ });
65
+ }
66
+
67
+ function seedCanvas(designRoot: string) {
68
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
69
+ writeFileSync(join(designRoot, 'ui', 'Foo.tsx'), 'export default function P(){return <main/>}\n');
70
+ mkdirSync(join(designRoot, '_comments'), { recursive: true });
71
+ }
72
+
73
+ function commentsJson(status: 'open' | 'resolved') {
74
+ return JSON.stringify(
75
+ [
76
+ {
77
+ id: 'c_one',
78
+ file: FILE,
79
+ selector: 'main',
80
+ dom_path: ['main'],
81
+ tag: 'main',
82
+ classes: '',
83
+ bounds: null,
84
+ html_excerpt: '',
85
+ text: 'tighten the padding',
86
+ status,
87
+ created: '2026-01-01T00:00:00.000Z',
88
+ resolved_at: status === 'resolved' ? '2026-01-02T00:00:00.000Z' : null,
89
+ author: 'Test User',
90
+ thread: [],
91
+ mentions: [],
92
+ },
93
+ ],
94
+ null,
95
+ 2
96
+ );
97
+ }
98
+
99
+ describe('issue #74 — comments file-watch re-broadcast', () => {
100
+ test('a direct resolve-write broadcasts the updated list to open shells', async () => {
101
+ const { root, designRoot } = makeSandbox();
102
+ const port = nextPort();
103
+ const proc = await bootServer(root, port);
104
+ try {
105
+ seedCanvas(designRoot);
106
+ const commentsFile = join(designRoot, '_comments', `${SLUG}.json`);
107
+ writeFileSync(commentsFile, commentsJson('open'));
108
+
109
+ const ws = await openWs(port);
110
+ // Clear the fs-watch per-path debounce window (50 ms) so the resolve
111
+ // write below is seen as its own event rather than folded into the seed.
112
+ await Bun.sleep(150);
113
+
114
+ const frame = nextCommentsFrame(ws, FILE);
115
+ // Exactly what edit.md §3's resolve recipe does: rewrite the file in
116
+ // place, never touching the API.
117
+ writeFileSync(commentsFile, commentsJson('resolved'));
118
+
119
+ const got = await frame;
120
+ ws.close();
121
+
122
+ expect(got).not.toBeNull();
123
+ expect(got!.comments).toHaveLength(1);
124
+ expect(got!.comments[0]!.id).toBe('c_one');
125
+ expect(got!.comments[0]!.status).toBe('resolved');
126
+ } finally {
127
+ await killProc(proc);
128
+ }
129
+ }, 20000);
130
+
131
+ test('a comments file for an unknown slug is ignored, not fatal', async () => {
132
+ const { root, designRoot } = makeSandbox();
133
+ const port = nextPort();
134
+ const proc = await bootServer(root, port);
135
+ try {
136
+ seedCanvas(designRoot);
137
+ const ws = await openWs(port);
138
+ await Bun.sleep(150);
139
+
140
+ // No canvas resolves to this slug — the handler must bail quietly and
141
+ // leave the server healthy.
142
+ writeFileSync(
143
+ join(designRoot, '_comments', 'ui-does-not-exist.json'),
144
+ JSON.stringify([], null, 2)
145
+ );
146
+ await Bun.sleep(400);
147
+ ws.close();
148
+
149
+ const health = await fetch(`http://localhost:${port}/_health`);
150
+ expect(health.ok).toBe(true);
151
+ } finally {
152
+ await killProc(proc);
153
+ }
154
+ }, 20000);
155
+ });
@@ -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
+ });