@1agh/maude 0.60.7 → 1.0.2

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 (145) hide show
  1. package/apps/studio/acp/index.ts +1 -0
  2. package/apps/studio/ai-banner.tsx +1 -0
  3. package/apps/studio/annotations-context-toolbar.tsx +3 -1
  4. package/apps/studio/annotations-layer.tsx +33 -16
  5. package/apps/studio/api.ts +108 -18
  6. package/apps/studio/artboard-guides-overlay.tsx +5 -1
  7. package/apps/studio/assets-s3.ts +6 -1
  8. package/apps/studio/bin/_import-figma.mjs +8 -3
  9. package/apps/studio/build.ts +1 -1
  10. package/apps/studio/canvas-artifacts.ts +21 -0
  11. package/apps/studio/canvas-build.ts +14 -9
  12. package/apps/studio/canvas-comment-mount.tsx +27 -30
  13. package/apps/studio/canvas-icons.tsx +1 -1
  14. package/apps/studio/canvas-lib.tsx +94 -5
  15. package/apps/studio/canvas-list-watch.ts +14 -1
  16. package/apps/studio/canvas-shell.tsx +8 -0
  17. package/apps/studio/client/app.jsx +224 -45
  18. package/apps/studio/client/panels/GitPanel.jsx +121 -13
  19. package/apps/studio/client/panels/SettingsPanel.jsx +1 -1
  20. package/apps/studio/client/panels/SyncConsentDialog.jsx +182 -0
  21. package/apps/studio/client/panels/SyncPanel.jsx +595 -1
  22. package/apps/studio/client/styles/3-shell-maude.css +38 -0
  23. package/apps/studio/clip-ops.ts +8 -1
  24. package/apps/studio/cloud/endpoints.ts +265 -3
  25. package/apps/studio/collab/origins.ts +3 -1
  26. package/apps/studio/comments-overlay.tsx +5 -0
  27. package/apps/studio/config.schema.json +24 -0
  28. package/apps/studio/context-menu.tsx +40 -27
  29. package/apps/studio/context.ts +57 -8
  30. package/apps/studio/cursors-overlay.tsx +25 -13
  31. package/apps/studio/dist/client.bundle.js +686 -686
  32. package/apps/studio/dist/comment-mount.js +2 -2
  33. package/apps/studio/dist/styles.css +1 -1
  34. package/apps/studio/exporters/jobs.ts +77 -15
  35. package/apps/studio/exporters/remote.ts +190 -0
  36. package/apps/studio/exporters/video-encode-lib.ts +10 -4
  37. package/apps/studio/figma/to-strokes.ts +11 -9
  38. package/apps/studio/gifenc.d.ts +51 -0
  39. package/apps/studio/git/log-format.ts +88 -0
  40. package/apps/studio/git/safe-rel.ts +96 -0
  41. package/apps/studio/git/service.ts +46 -27
  42. package/apps/studio/hmr-broadcast.ts +10 -0
  43. package/apps/studio/http.ts +384 -6
  44. package/apps/studio/participants-chrome.tsx +1 -0
  45. package/apps/studio/photo-store.ts +7 -0
  46. package/apps/studio/react-augment.d.ts +17 -0
  47. package/apps/studio/runtime-bundle.ts +6 -1
  48. package/apps/studio/server.ts +43 -8
  49. package/apps/studio/sync/agent.ts +70 -82
  50. package/apps/studio/sync/asset-push.ts +28 -71
  51. package/apps/studio/sync/autocommit.ts +106 -5
  52. package/apps/studio/sync/cell-file-events.ts +117 -0
  53. package/apps/studio/sync/cell-pairing.ts +20 -5
  54. package/apps/studio/sync/cell-write-nudge.ts +244 -0
  55. package/apps/studio/sync/codec.ts +155 -3
  56. package/apps/studio/sync/cold-start-apply.ts +211 -0
  57. package/apps/studio/sync/ctl-heal.ts +253 -0
  58. package/apps/studio/sync/ctl-provider.ts +217 -0
  59. package/apps/studio/sync/decide-file.ts +335 -0
  60. package/apps/studio/sync/file-ledger.ts +581 -0
  61. package/apps/studio/sync/file-membership.ts +32 -0
  62. package/apps/studio/sync/file-plane.ts +1400 -0
  63. package/apps/studio/sync/file-pull.ts +41 -4
  64. package/apps/studio/sync/hub-link.ts +16 -1
  65. package/apps/studio/sync/hub-listing.ts +46 -0
  66. package/apps/studio/sync/hubs-config.ts +16 -0
  67. package/apps/studio/sync/index.ts +915 -308
  68. package/apps/studio/sync/journal-client.ts +200 -0
  69. package/apps/studio/sync/migrate-seed.ts +99 -67
  70. package/apps/studio/sync/poke.ts +50 -0
  71. package/apps/studio/sync/projection.ts +13 -0
  72. package/apps/studio/sync/pull-budget.ts +86 -0
  73. package/apps/studio/sync/settings.ts +110 -0
  74. package/apps/studio/sync/status.ts +68 -0
  75. package/apps/studio/sync/trash.ts +243 -0
  76. package/apps/studio/sync/untrusted.ts +30 -10
  77. package/apps/studio/test/_helpers.ts +8 -0
  78. package/apps/studio/test/canvas-build.test.ts +63 -0
  79. package/apps/studio/test/canvas-list-watch.test.ts +17 -0
  80. package/apps/studio/test/canvas-move-api.test.ts +31 -0
  81. package/apps/studio/test/canvas-origin-gate.test.ts +12 -0
  82. package/apps/studio/test/canvas-shell-build-error.test.ts +49 -0
  83. package/apps/studio/test/cloud-history-hardening.test.ts +165 -0
  84. package/apps/studio/test/cloud-history-posture.test.ts +230 -0
  85. package/apps/studio/test/cloud-session-role.test.ts +30 -0
  86. package/apps/studio/test/cloud-shell-surfaces.test.ts +39 -0
  87. package/apps/studio/test/cold-start-apply.test.ts +303 -0
  88. package/apps/studio/test/collab-stress.test.ts +9 -1
  89. package/apps/studio/test/export-lane.test.ts +245 -0
  90. package/apps/studio/test/fixtures/video-comp-fixture.tsx +1 -1
  91. package/apps/studio/test/git-log-format.test.ts +95 -0
  92. package/apps/studio/test/git-safe-rel.test.ts +132 -0
  93. package/apps/studio/test/hmr-broadcast.test.ts +26 -0
  94. package/apps/studio/test/peer-selection-follows-camera.test.tsx +131 -0
  95. package/apps/studio/test/shared-doc-cell-pairing.test.ts +5 -2
  96. package/apps/studio/test/sync-agent.test.ts +78 -0
  97. package/apps/studio/test/sync-asset-push.test.ts +71 -108
  98. package/apps/studio/test/sync-autocommit.test.ts +80 -0
  99. package/apps/studio/test/sync-cell-write-nudge.test.ts +346 -0
  100. package/apps/studio/test/sync-ctl-channel.test.ts +508 -0
  101. package/apps/studio/test/sync-decide-file.test.ts +420 -0
  102. package/apps/studio/test/sync-file-ledger.test.ts +334 -0
  103. package/apps/studio/test/sync-file-membership.test.ts +17 -1
  104. package/apps/studio/test/sync-file-plane.test.ts +976 -0
  105. package/apps/studio/test/sync-hub-listing.test.ts +46 -0
  106. package/apps/studio/test/sync-meta-codec.test.ts +76 -0
  107. package/apps/studio/test/sync-move-retirement.test.ts +231 -0
  108. package/apps/studio/test/sync-panel-surface.test.ts +20 -0
  109. package/apps/studio/test/sync-path-pull.test.ts +67 -1
  110. package/apps/studio/test/sync-pull-budget.test.ts +169 -0
  111. package/apps/studio/test/sync-seed-defers-to-hub.test.ts +83 -0
  112. package/apps/studio/test/sync-settings-routes.test.ts +195 -0
  113. package/apps/studio/test/sync-settings.test.ts +151 -0
  114. package/apps/studio/test/sync-status.test.ts +69 -0
  115. package/apps/studio/test/sync-trash.test.ts +132 -0
  116. package/apps/studio/test/workspace-containment.test.ts +45 -9
  117. package/apps/studio/tsconfig.json +9 -10
  118. package/apps/studio/use-annotation-resize.tsx +14 -3
  119. package/apps/studio/use-collab.tsx +3 -1
  120. package/apps/studio/whats-new.json +90 -0
  121. package/apps/studio/workspace-mode.ts +110 -62
  122. package/apps/studio/ws.ts +22 -1
  123. package/cli/bin/claude-design-server.mjs +19 -0
  124. package/cli/commands/design.mjs +25 -6
  125. package/cli/commands/hub-workspace.mjs +243 -22
  126. package/cli/commands/hub-workspace.test.mjs +171 -0
  127. package/cli/commands/hub.mjs +71 -1
  128. package/cli/lib/design-link.mjs +186 -1
  129. package/cli/lib/design-ownership.mjs +330 -0
  130. package/cli/lib/design-ownership.test.mjs +329 -0
  131. package/cli/lib/hubs-config.mjs +21 -0
  132. package/cli/lib/hubs-config.test.mjs +47 -1
  133. package/cli/lib/workspace-plan.mjs +298 -5
  134. package/cli/lib/workspace-plan.test.mjs +215 -1
  135. package/package.json +10 -10
  136. package/plugins/design/templates/_shell.html +43 -2
  137. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -1
  138. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
  139. package/apps/studio/server.mjs +0 -1312
  140. package/apps/studio/sync/asset-pull.ts +0 -210
  141. package/apps/studio/sync/asset-push-worker.ts +0 -84
  142. package/apps/studio/sync/asset-sweep.ts +0 -262
  143. package/apps/studio/test/sync-asset-pull.test.ts +0 -161
  144. package/apps/studio/test/sync-asset-push-worker.test.ts +0 -183
  145. package/apps/studio/test/sync-asset-sweep.test.ts +0 -243
@@ -0,0 +1,303 @@
1
+ // The ONE cold-start application body — Sync v2 Increment 0 (DDR-226).
2
+ //
3
+ // Three things are pinned here:
4
+ //
5
+ // 1. **The tripwire.** Both architectures import `sync/cold-start-apply.ts`.
6
+ // DDR-102 said the decision table is "a pure module consumed by BOTH sync
7
+ // paths" — the table was, the APPLICATION was not, and the twin switches
8
+ // drifted three times (DDR-076's empty-hub guard written twice, DDR-223's
9
+ // eraser FIXED twice, and the `recover-seed-dup` fallthrough below). This
10
+ // test makes a re-split a red test, the same way
11
+ // `sync-file-membership.test.ts` pins the classifier's hub mirror.
12
+ //
13
+ // 2. **Totality.** Every `ColdStartAction` reaches a row and produces the
14
+ // right body winner + effect. The compile-time `never` default catches new
15
+ // actions at build time; this catches a hand-edited switch at test time.
16
+ //
17
+ // 3. **The live regression.** `migrateSeed` on a hub body that is the local
18
+ // body repeated must COLLAPSE it, not keep it. Before this module,
19
+ // migrate-seed's switch had no `recover-seed-dup` case and no default, so
20
+ // the decision fell through to `hub-wins` and the duplicated body was kept
21
+ // and materialized to disk — an un-buildable canvas (two `export default`).
22
+
23
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
24
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
25
+ import { tmpdir } from 'node:os';
26
+ import { dirname, join } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+ import * as Y from 'yjs';
29
+
30
+ import { applyHtmlToDoc } from '../sync/codec.ts';
31
+ import type { ColdStartAction, ColdStartDecision } from '../sync/cold-start.ts';
32
+ import { decideColdStart } from '../sync/cold-start.ts';
33
+ import { applyColdStart } from '../sync/cold-start-apply.ts';
34
+ import { migrateSeed } from '../sync/migrate-seed.ts';
35
+ import { ORIGINS } from '../sync/origins.ts';
36
+
37
+ const SYNC_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'sync');
38
+
39
+ /** Every action the table can emit. Kept as a literal list on purpose: if
40
+ * `ColdStartAction` grows a member, the `satisfies` below fails to compile. */
41
+ const ALL_ACTIONS = [
42
+ 'noop',
43
+ 'materialize-hub',
44
+ 'seed-local-up',
45
+ 'fast-forward-hub',
46
+ 'recover-seed-dup',
47
+ 'conflict',
48
+ ] as const satisfies readonly ColdStartAction[];
49
+
50
+ const silent = { warn: () => {}, error: () => {} };
51
+
52
+ /** Record which effect the applier invoked. */
53
+ function spyOps() {
54
+ const calls: string[] = [];
55
+ return {
56
+ calls,
57
+ takeHub: () => {
58
+ calls.push('takeHub');
59
+ },
60
+ takeLocal: (body: string) => {
61
+ calls.push(`takeLocal:${body}`);
62
+ },
63
+ checkpointIdentity: (body: string) => {
64
+ calls.push(`checkpoint:${body}`);
65
+ },
66
+ };
67
+ }
68
+
69
+ describe('cold-start-apply — the tripwire (one applier, both architectures)', () => {
70
+ test('agent.ts and migrate-seed.ts both import cold-start-apply.ts', () => {
71
+ for (const file of ['agent.ts', 'migrate-seed.ts']) {
72
+ const src = readFileSync(join(SYNC_DIR, file), 'utf8');
73
+ expect(src).toContain("from './cold-start-apply.ts'");
74
+ expect(src).toContain('applyColdStart(');
75
+ }
76
+ });
77
+
78
+ test('neither caller keeps a private switch over decision.action', () => {
79
+ // The whole point of the module: the row set lives in ONE place. A
80
+ // `switch (decision.action)` reappearing in either caller is the drift
81
+ // this test exists to catch.
82
+ for (const file of ['agent.ts', 'migrate-seed.ts']) {
83
+ const src = readFileSync(join(SYNC_DIR, file), 'utf8');
84
+ expect(src).not.toContain('switch (decision.action)');
85
+ }
86
+ });
87
+ });
88
+
89
+ describe('cold-start-apply — total over every action', () => {
90
+ test.each(ALL_ACTIONS)('%s reaches a row', async (action) => {
91
+ const decision: ColdStartDecision = {
92
+ action,
93
+ reason: 'test',
94
+ ...(action === 'conflict' ? { winner: 'hub' as const } : {}),
95
+ };
96
+ const ops = spyOps();
97
+ const result = await applyColdStart({
98
+ slug: 's',
99
+ decision,
100
+ localBody: 'LOCAL',
101
+ docBody: 'HUB',
102
+ log: silent,
103
+ ...ops,
104
+ });
105
+ expect(result.action).toBe(action);
106
+ expect(['local', 'hub']).toContain(result.bodyWinner);
107
+ });
108
+
109
+ test('an action with no row throws instead of silently keeping the hub', async () => {
110
+ const ops = spyOps();
111
+ await expect(
112
+ applyColdStart({
113
+ slug: 's',
114
+ // Deliberately outside the union — the shape a hand-edited table or a
115
+ // stale build would produce.
116
+ decision: { action: 'invented-row' as ColdStartAction, reason: 'test' },
117
+ localBody: 'LOCAL',
118
+ docBody: 'HUB',
119
+ log: silent,
120
+ ...ops,
121
+ })
122
+ ).rejects.toThrow(/unhandled action/);
123
+ expect(ops.calls).toEqual([]);
124
+ });
125
+
126
+ test('winners and effects per row', async () => {
127
+ const expected: Array<[ColdStartAction, 'local' | 'hub', string | null]> = [
128
+ ['noop', 'hub', null],
129
+ ['materialize-hub', 'hub', 'takeHub'],
130
+ ['fast-forward-hub', 'hub', 'takeHub'],
131
+ ['seed-local-up', 'local', 'takeLocal:LOCAL'],
132
+ ['recover-seed-dup', 'local', 'takeLocal:LOCAL'],
133
+ ];
134
+ for (const [action, winner, call] of expected) {
135
+ const ops = spyOps();
136
+ const result = await applyColdStart({
137
+ slug: 's',
138
+ decision: { action, reason: 'test' },
139
+ localBody: 'LOCAL',
140
+ docBody: 'HUB',
141
+ log: silent,
142
+ ...ops,
143
+ });
144
+ expect(result.bodyWinner).toBe(winner);
145
+ expect(ops.calls).toEqual(call === null ? [] : [call]);
146
+ }
147
+ });
148
+
149
+ test('noop checkpoints identity only when both sides carry the same bytes', async () => {
150
+ const same = spyOps();
151
+ await applyColdStart({
152
+ slug: 's',
153
+ decision: { action: 'noop', reason: 'identical' },
154
+ localBody: 'SAME',
155
+ docBody: 'SAME',
156
+ log: silent,
157
+ ...same,
158
+ });
159
+ expect(same.calls).toEqual(['checkpoint:SAME']);
160
+
161
+ const bothEmpty = spyOps();
162
+ await applyColdStart({
163
+ slug: 's',
164
+ decision: { action: 'noop', reason: 'both empty' },
165
+ localBody: null,
166
+ docBody: '',
167
+ log: silent,
168
+ ...bothEmpty,
169
+ });
170
+ expect(bothEmpty.calls).toEqual([]);
171
+ });
172
+ });
173
+
174
+ describe('cold-start-apply — DDR-102 fail-closed, in one place', () => {
175
+ test('hub wins normally when the snapshot lands', async () => {
176
+ const ops = spyOps();
177
+ const result = await applyColdStart({
178
+ slug: 's',
179
+ decision: { action: 'conflict', winner: 'hub', reason: 'diverged' },
180
+ localBody: 'LOCAL',
181
+ docBody: 'HUB',
182
+ snapshot: async () => 'ts-1',
183
+ log: silent,
184
+ ...ops,
185
+ });
186
+ expect(result.bodyWinner).toBe('hub');
187
+ expect(result.conflict?.snapshotFailed).toBe(false);
188
+ expect(ops.calls).toEqual(['takeHub']);
189
+ });
190
+
191
+ test('a hub-wins verdict is REFUSED when the local snapshot did not land', async () => {
192
+ const ops = spyOps();
193
+ const errors: string[] = [];
194
+ const result = await applyColdStart({
195
+ slug: 's',
196
+ decision: { action: 'conflict', winner: 'hub', reason: 'diverged' },
197
+ localBody: 'LOCAL',
198
+ docBody: 'HUB',
199
+ snapshot: async () => null, // both snapshots fail
200
+ log: { warn: () => {}, error: (m) => errors.push(m) },
201
+ ...ops,
202
+ });
203
+ expect(result.bodyWinner).toBe('local');
204
+ expect(result.conflict?.snapshotFailed).toBe(true);
205
+ expect(ops.calls).toEqual(['takeLocal:LOCAL']);
206
+ expect(errors.join(' ')).toContain('REFUSING to overwrite local');
207
+ });
208
+
209
+ test('no snapshot fn at all ⇒ plain newest-wins (standalone wiring)', async () => {
210
+ const ops = spyOps();
211
+ const result = await applyColdStart({
212
+ slug: 's',
213
+ decision: { action: 'conflict', winner: 'hub', reason: 'diverged' },
214
+ localBody: 'LOCAL',
215
+ docBody: 'HUB',
216
+ log: silent,
217
+ ...ops,
218
+ });
219
+ expect(result.bodyWinner).toBe('hub');
220
+ expect(result.conflict?.snapshotFailed).toBe(false);
221
+ });
222
+
223
+ test('the conflict report carries the POST-guard winner', async () => {
224
+ const seen: Array<{ winner?: string; snapshotFailed?: boolean }> = [];
225
+ await applyColdStart({
226
+ slug: 's',
227
+ decision: { action: 'conflict', winner: 'hub', reason: 'diverged' },
228
+ localBody: 'LOCAL',
229
+ docBody: 'HUB',
230
+ snapshot: async () => null,
231
+ onConflict: (info) => seen.push(info),
232
+ log: silent,
233
+ ...spyOps(),
234
+ });
235
+ expect(seen).toHaveLength(1);
236
+ expect(seen[0]?.winner).toBe('local');
237
+ expect(seen[0]?.snapshotFailed).toBe(true);
238
+ });
239
+ });
240
+
241
+ /* ---------------------------------------------------------------------- */
242
+
243
+ describe('migrate-seed — the recover-seed-dup fallthrough (LIVE regression)', () => {
244
+ let dir: string;
245
+ beforeEach(() => {
246
+ dir = mkdtempSync(join(tmpdir(), 'cold-start-apply-'));
247
+ });
248
+ afterEach(() => {
249
+ rmSync(dir, { recursive: true, force: true });
250
+ });
251
+
252
+ const paths = () => ({
253
+ html: join(dir, 'screen.html'),
254
+ comments: join(dir, 'screen.comments.json'),
255
+ annotations: join(dir, 'screen.annotations.svg'),
256
+ meta: join(dir, 'screen.meta.json'),
257
+ css: join(dir, 'screen.css'),
258
+ });
259
+
260
+ test('a doubled hub body is COLLAPSED to one copy, not kept', async () => {
261
+ const p = paths();
262
+ const body = '<main>one copy</main>';
263
+ writeFileSync(p.html, body);
264
+
265
+ // The shape of a concurrent cold-seed collision: two peers each seeded the
266
+ // same body into an empty hub and the CRDT concatenated both insertions.
267
+ const doc = new Y.Doc();
268
+ applyHtmlToDoc(doc, body + body, ORIGINS.MIGRATION);
269
+
270
+ // Sanity: the table really does emit `recover-seed-dup` for this input.
271
+ expect(
272
+ decideColdStart({
273
+ localBody: body,
274
+ docBody: body + body,
275
+ journalHash: null,
276
+ localMtimeMs: null,
277
+ docBodyEditAtMs: null,
278
+ }).action
279
+ ).toBe('recover-seed-dup');
280
+
281
+ const result = await migrateSeed({ slug: 's', doc, paths: p });
282
+
283
+ // Before Increment 0 this returned 'hub-wins' and the doc kept `body+body`.
284
+ expect(result).toBe('recover-seed-dup');
285
+ expect(doc.getText('html').toString()).toBe(body);
286
+ });
287
+
288
+ test('a THREE-fold repeat collapses too, and re-running is a no-op', async () => {
289
+ const p = paths();
290
+ const body = '<main>x</main>';
291
+ writeFileSync(p.html, body);
292
+
293
+ const doc = new Y.Doc();
294
+ applyHtmlToDoc(doc, body.repeat(3), ORIGINS.MIGRATION);
295
+
296
+ expect(await migrateSeed({ slug: 's', doc, paths: p })).toBe('recover-seed-dup');
297
+ expect(doc.getText('html').toString()).toBe(body);
298
+
299
+ // Idempotent: the second pass sees identical sides.
300
+ expect(await migrateSeed({ slug: 's', doc, paths: p })).toBe('hub-wins');
301
+ expect(doc.getText('html').toString()).toBe(body);
302
+ });
303
+ });
@@ -116,6 +116,14 @@ describe('multi-tab stress (Phase 8 Task 8)', () => {
116
116
  room.disconnect(B);
117
117
  expect(room.size()).toBe(0);
118
118
  },
119
- STRESS_MS + 5_000
119
+ // Headroom must scale WITH the stress window, not sit a flat few seconds
120
+ // above it. The body burns `STRESS_MS` of wall clock by construction
121
+ // (`while (Date.now() - start < STRESS_MS)`), so on a loaded machine the
122
+ // setup/teardown and the ~33 ms tick drift are the only slack — a flat
123
+ // +5 s put the budget at 15 s against a measured 10.1 s run, and this was
124
+ // the one test that tipped over in a full-suite run under load. Nothing
125
+ // here asserts speed (the checks are RSS + Y.Doc growth), so a wide budget
126
+ // costs no signal: a real hang still dies, just later.
127
+ STRESS_MS * 2 + 10_000
120
128
  );
121
129
  });
@@ -0,0 +1,245 @@
1
+ // feature-cloud-export-render-workers (DDR-230) — the render-lane dispatch.
2
+ //
3
+ // The queue's contract must hold in all three lanes:
4
+ // local — pre-render-workers behavior, byte-identical (covered by
5
+ // test/exporters/jobs.test.ts; not re-proven here).
6
+ // none — browser formats refuse BEFORE taking a render slot, with the
7
+ // remedy message; zip still runs in-cell.
8
+ // remote — browser formats POST the fully-resolved job to the render
9
+ // service with the member's canvas grant, and the artifact comes
10
+ // back as a normal job result.
11
+
12
+ import { afterEach, describe, expect, test } from 'bun:test';
13
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+
17
+ import { createBus } from '../context.ts';
18
+ import type { ExportContext } from '../exporters/index.ts';
19
+ import { createExportJobQueue, type EnqueueArgs } from '../exporters/jobs.ts';
20
+ import { NO_RENDER_SERVICE_MESSAGE, REMOTE_UNSUPPORTED_MESSAGE } from '../exporters/remote.ts';
21
+ import { resolveRenderLane } from '../workspace-mode.ts';
22
+
23
+ const SAVED = {
24
+ mode: process.env.MAUDE_WORKSPACE_MODE,
25
+ url: process.env.MAUDE_RENDER_URL,
26
+ secret: process.env.MAUDE_RENDER_SECRET,
27
+ };
28
+
29
+ function setLane(lane: 'local' | 'remote' | 'none', url?: string): void {
30
+ if (lane === 'local') {
31
+ delete process.env.MAUDE_WORKSPACE_MODE;
32
+ delete process.env.MAUDE_RENDER_URL;
33
+ } else {
34
+ process.env.MAUDE_WORKSPACE_MODE = '1';
35
+ if (lane === 'remote') process.env.MAUDE_RENDER_URL = url ?? 'http://127.0.0.1:1';
36
+ else delete process.env.MAUDE_RENDER_URL;
37
+ }
38
+ }
39
+
40
+ afterEach(() => {
41
+ if (SAVED.mode === undefined) delete process.env.MAUDE_WORKSPACE_MODE;
42
+ else process.env.MAUDE_WORKSPACE_MODE = SAVED.mode;
43
+ if (SAVED.url === undefined) delete process.env.MAUDE_RENDER_URL;
44
+ else process.env.MAUDE_RENDER_URL = SAVED.url;
45
+ if (SAVED.secret === undefined) delete process.env.MAUDE_RENDER_SECRET;
46
+ else process.env.MAUDE_RENDER_SECRET = SAVED.secret;
47
+ });
48
+
49
+ function sandboxDesignRoot(): string {
50
+ const root = mkdtempSync(join(tmpdir(), 'maude-lane-'));
51
+ const design = join(root, '.design');
52
+ mkdirSync(join(design, 'ui'), { recursive: true });
53
+ writeFileSync(join(design, 'ui', 'Home.tsx'), 'export default function Home(){return null}\n');
54
+ return design;
55
+ }
56
+
57
+ function baseArgs(
58
+ designRoot: string,
59
+ format: EnqueueArgs['format'],
60
+ scope: EnqueueArgs['scope']
61
+ ): EnqueueArgs {
62
+ const repoRoot = join(designRoot, '..');
63
+ const ctx: ExportContext = {
64
+ designRoot,
65
+ repoRoot,
66
+ serverOrigin: 'http://127.0.0.1:1', // never reached in these tests
67
+ };
68
+ return {
69
+ format,
70
+ scope,
71
+ options: {},
72
+ resolve: {
73
+ activeJson: { active: '.design/ui/Home.tsx', selected: null },
74
+ designRoot,
75
+ repoRoot,
76
+ },
77
+ ctx,
78
+ remoteCanvas: { origin: 'https://tenant.example', token: 'tok-abc' },
79
+ };
80
+ }
81
+
82
+ describe('resolveRenderLane', () => {
83
+ test('local off-workspace; remote/none track MAUDE_RENDER_URL in a workspace', () => {
84
+ setLane('local');
85
+ expect(resolveRenderLane()).toBe('local');
86
+ setLane('none');
87
+ expect(resolveRenderLane()).toBe('none');
88
+ setLane('remote', 'http://127.0.0.1:9');
89
+ expect(resolveRenderLane()).toBe('remote');
90
+ // A laptop with MAUDE_RENDER_URL exported by accident stays local — the
91
+ // lane never dispatches outside workspace mode.
92
+ delete process.env.MAUDE_WORKSPACE_MODE;
93
+ expect(resolveRenderLane()).toBe('local');
94
+ });
95
+ });
96
+
97
+ describe('lane none', () => {
98
+ test('a browser format fails fast with the remedy, without taking a slot', async () => {
99
+ setLane('none');
100
+ const design = sandboxDesignRoot();
101
+ const queue = createExportJobQueue(createBus(), design);
102
+ const { id, result } = queue.enqueue(baseArgs(design, 'png', 'artboard'));
103
+ await expect(result).rejects.toThrow(NO_RENDER_SERVICE_MESSAGE);
104
+ const job = queue.get(id);
105
+ expect(job?.status).toBe('failed');
106
+ expect(job?.error).toBe(NO_RENDER_SERVICE_MESSAGE);
107
+ });
108
+
109
+ test('zip still exports in-cell — browser-free formats ignore the lane', async () => {
110
+ setLane('none');
111
+ const design = sandboxDesignRoot();
112
+ const queue = createExportJobQueue(createBus(), design);
113
+ const { result } = queue.enqueue(baseArgs(design, 'zip', 'project-raw'));
114
+ const res = await result;
115
+ expect(res.contentType).toBe('application/zip');
116
+ expect(res.body.byteLength).toBeGreaterThan(0);
117
+ });
118
+ });
119
+
120
+ describe('lane remote', () => {
121
+ test('canva refuses with the desktop remedy even when a service is configured', async () => {
122
+ setLane('remote', 'http://127.0.0.1:9');
123
+ const design = sandboxDesignRoot();
124
+ const queue = createExportJobQueue(createBus(), design);
125
+ const { id, result } = queue.enqueue(baseArgs(design, 'canva', 'canvas-as-separate'));
126
+ await expect(result).rejects.toThrow(REMOTE_UNSUPPORTED_MESSAGE);
127
+ expect(queue.get(id)?.status).toBe('failed');
128
+ });
129
+
130
+ test('a browser format POSTs the resolved job and the artifact comes back', async () => {
131
+ const seen: { auth?: string | null; body?: Record<string, unknown> } = {};
132
+ const service = Bun.serve({
133
+ port: 0,
134
+ fetch: async (req) => {
135
+ seen.auth = req.headers.get('authorization');
136
+ seen.body = (await req.json()) as Record<string, unknown>;
137
+ return new Response(new Uint8Array([137, 80, 78, 71]), {
138
+ headers: {
139
+ 'content-type': 'image/png',
140
+ 'x-maude-filename': 'home.png',
141
+ 'x-maude-degraded': JSON.stringify({ kind: 'test-degrade' }),
142
+ },
143
+ });
144
+ },
145
+ });
146
+ try {
147
+ setLane('remote', `http://127.0.0.1:${service.port}`);
148
+ process.env.MAUDE_RENDER_SECRET = 'shh';
149
+ const design = sandboxDesignRoot();
150
+ const queue = createExportJobQueue(createBus(), design);
151
+ const { id, result } = queue.enqueue(baseArgs(design, 'png', 'artboard'));
152
+ const res = await result;
153
+
154
+ expect(res.filename).toBe('home.png');
155
+ expect(res.contentType).toBe('image/png');
156
+ expect(res.body).toEqual(new Uint8Array([137, 80, 78, 71]));
157
+ expect(res.degraded).toEqual({ kind: 'test-degrade' } as never);
158
+
159
+ // The wire contract: bearer ingress, resolved targets (pure data), the
160
+ // member's canvas grant — and nothing else (no fs paths leak).
161
+ expect(seen.auth).toBe('Bearer shh');
162
+ expect(seen.body?.format).toBe('png');
163
+ const targets = seen.body?.targets as Array<Record<string, unknown>>;
164
+ expect(targets).toHaveLength(1);
165
+ expect(targets[0]?.kind).toBe('element');
166
+ expect(targets[0]?.file).toBe('.design/ui/Home.tsx');
167
+ expect(seen.body?.canvas).toEqual({ origin: 'https://tenant.example', token: 'tok-abc' });
168
+
169
+ // Job lifecycle + bytes-on-disk contract holds on the remote lane too.
170
+ const job = queue.get(id);
171
+ expect(job?.status).toBe('done');
172
+ expect(job?.degraded).toEqual({ kind: 'test-degrade' } as never);
173
+ const dl = await queue.getBytes(id);
174
+ expect(dl.ok).toBe(true);
175
+ } finally {
176
+ service.stop(true);
177
+ }
178
+ });
179
+
180
+ test('a hostile filename header cannot escape the job directory (path traversal)', async () => {
181
+ // security-review defender: x-maude-filename lands in Bun.write(join(dir, name)).
182
+ const service = Bun.serve({
183
+ port: 0,
184
+ fetch: () =>
185
+ new Response(new Uint8Array([1, 2, 3]), {
186
+ headers: {
187
+ 'content-type': 'image/png',
188
+ // Traversal AND a header-injection attempt in one — both must die.
189
+ 'x-maude-filename': '../../../../etc/pw"ned.png',
190
+ },
191
+ }),
192
+ });
193
+ try {
194
+ setLane('remote', `http://127.0.0.1:${service.port}`);
195
+ const design = sandboxDesignRoot();
196
+ const queue = createExportJobQueue(createBus(), design);
197
+ const { result } = queue.enqueue(baseArgs(design, 'png', 'artboard'));
198
+ const res = await result;
199
+ // basename() drops the whole directory prefix — no separators, no `..`.
200
+ expect(res.filename).toBe('pwned.png');
201
+ expect(res.filename).not.toContain('/');
202
+ expect(res.filename).not.toContain('..');
203
+ } finally {
204
+ service.stop(true);
205
+ }
206
+ });
207
+
208
+ test('an oversized artifact is refused rather than buffered whole (OOM cap)', async () => {
209
+ // A body over the cap (the cap floors at 1 MB, so the body must clear it).
210
+ const service = Bun.serve({
211
+ port: 0,
212
+ fetch: () =>
213
+ new Response(new Uint8Array(1_100_000), { headers: { 'content-type': 'image/png' } }),
214
+ });
215
+ try {
216
+ setLane('remote', `http://127.0.0.1:${service.port}`);
217
+ process.env.MAUDE_RENDER_MAX_ARTIFACT_BYTES = '1000000';
218
+ const design = sandboxDesignRoot();
219
+ const queue = createExportJobQueue(createBus(), design);
220
+ const { id, result } = queue.enqueue(baseArgs(design, 'png', 'artboard'));
221
+ await expect(result).rejects.toThrow(/too large|exceeded/);
222
+ expect(queue.get(id)?.status).toBe('failed');
223
+ } finally {
224
+ service.stop(true);
225
+ delete process.env.MAUDE_RENDER_MAX_ARTIFACT_BYTES;
226
+ }
227
+ });
228
+
229
+ test('a refusing service surfaces its reason as the job error', async () => {
230
+ const service = Bun.serve({
231
+ port: 0,
232
+ fetch: () => new Response('bad token', { status: 403 }),
233
+ });
234
+ try {
235
+ setLane('remote', `http://127.0.0.1:${service.port}`);
236
+ const design = sandboxDesignRoot();
237
+ const queue = createExportJobQueue(createBus(), design);
238
+ const { id, result } = queue.enqueue(baseArgs(design, 'pdf', 'artboard'));
239
+ await expect(result).rejects.toThrow(/render service refused the job: bad token/);
240
+ expect(queue.get(id)?.status).toBe('failed');
241
+ } finally {
242
+ service.stop(true);
243
+ }
244
+ });
245
+ });
@@ -66,7 +66,7 @@ function Fixture() {
66
66
  export default function Canvas() {
67
67
  return (
68
68
  <DesignCanvas>
69
- <DCSection title="Video-comp fixture">
69
+ <DCSection id="video-comp-fixture" title="Video-comp fixture">
70
70
  <DCArtboard id="fixture" label="Fixture" width={640} height={360}>
71
71
  <VideoComp
72
72
  component={Fixture}
@@ -0,0 +1,95 @@
1
+ // One log shape, three producers — feature-cloud-managed-git-posture.
2
+ //
3
+ // A History row is drawn by ONE renderer (`GitPanel`), and it can now be fed by
4
+ // three different readers: system-git (`logSystem`), isomorphic-git (`logIso`)
5
+ // and the cloud cell (`apps/hub/src/history.mjs`). The first and the third are
6
+ // the same code — that is what `git/log-format.ts` is for. The second is a
7
+ // separate implementation against a separate library, and it is the one that
8
+ // can quietly drift.
9
+ //
10
+ // The failure mode is specific and quiet: a reader that stops emitting `date`
11
+ // (or emits `authorName` instead of `author`) does not throw anywhere. It draws
12
+ // a row with a blank field, on whichever of the two engines the reader happens
13
+ // to be on — so it is invisible on the machine of whoever made the change.
14
+
15
+ import { describe, expect, test } from 'bun:test';
16
+ import { readFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+
19
+ import { GIT_LOG_FORMAT, gitLogArgs, gitLogEnv, parseGitLog } from '../git/log-format.ts';
20
+
21
+ const SERVICE = readFileSync(join(import.meta.dir, '..', 'git', 'service.ts'), 'utf8');
22
+
23
+ /** The contract `GitLogEntry` declares and `GitPanel` reads. */
24
+ const FIELDS = ['sha', 'message', 'author', 'email', 'date'] as const;
25
+
26
+ describe('parseGitLog', () => {
27
+ test('produces exactly the declared fields', () => {
28
+ const rec = `abc123\x1fa message\x1fAda\x1fada@example.com\x1f2026-08-18T10:00:00+02:00\x1e`;
29
+ const [entry] = parseGitLog(rec);
30
+ expect(Object.keys(entry).sort()).toEqual([...FIELDS].sort());
31
+ });
32
+
33
+ test('survives every separator a commit subject can carry', () => {
34
+ const msg = 'fix: tabs\tquotes " and a | pipe, plus a comma';
35
+ const [entry] = parseGitLog(`sha\x1f${msg}\x1fAda\x1fa@b.c\x1f2026-08-18T10:00:00Z\x1e`);
36
+ expect(entry.message).toBe(msg);
37
+ });
38
+
39
+ test('a short record still yields strings, never undefined', () => {
40
+ // A renderer must never have to defend against a half-parsed row.
41
+ const [entry] = parseGitLog('justasha\x1fonly a message\x1e');
42
+ for (const f of FIELDS) expect(typeof entry[f]).toBe('string');
43
+ });
44
+
45
+ test('garbage is an empty list, never a throw', () => {
46
+ expect(parseGitLog('')).toEqual([]);
47
+ expect(parseGitLog('\x1e\x1e \x1e')).toEqual([]);
48
+ // biome-ignore lint/suspicious/noExplicitAny: deliberately hostile input
49
+ expect(parseGitLog(null as any)).toEqual([]);
50
+ });
51
+ });
52
+
53
+ describe('gitLogArgs / gitLogEnv travel together', () => {
54
+ test('the format string is the one the parser splits on', () => {
55
+ expect(GIT_LOG_FORMAT).toContain('%x1f');
56
+ expect(GIT_LOG_FORMAT).toContain('%x1e');
57
+ expect(gitLogArgs(30)).toEqual(['log', '-n30', `--pretty=format:${GIT_LOG_FORMAT}`]);
58
+ });
59
+
60
+ test('a scoped log terminates its pathspec AND asks for literal pathspecs', () => {
61
+ // The `--` terminator alone does not close pathspec magic (`:(exclude)…`);
62
+ // GIT_LITERAL_PATHSPECS does, and the two must never be separated.
63
+ const args = gitLogArgs(10, '.design/ui/Card.tsx');
64
+ expect(args[args.length - 2]).toBe('--');
65
+ expect(args[args.length - 1]).toBe('.design/ui/Card.tsx');
66
+ expect(gitLogEnv('.design/ui/Card.tsx')).toEqual({ GIT_LITERAL_PATHSPECS: '1' });
67
+ });
68
+
69
+ test('an unscoped log asks for neither', () => {
70
+ expect(gitLogArgs(10)).not.toContain('--');
71
+ expect(gitLogEnv(undefined)).toBeUndefined();
72
+ });
73
+ });
74
+
75
+ describe('the isomorphic-git engine has not drifted from the shared shape', () => {
76
+ test('logSystem reads through the shared module rather than re-typing it', () => {
77
+ expect(SERVICE).toContain('gitLogArgs(limit, filepath), gitLogEnv(filepath)');
78
+ expect(SERVICE).toContain('return parseGitLog(r.stdout);');
79
+ // The old literal must be GONE, not merely unused — a leftover copy is the
80
+ // thing a later edit picks up.
81
+ expect(SERVICE).not.toContain("const fmt = '%H%x1f");
82
+ });
83
+
84
+ test('logIso still emits exactly the same five fields', () => {
85
+ // Source-level, because `logIso` is a private fallback with no seam to call
86
+ // it through: forcing the engine would mean exporting it, which invents an
87
+ // API for the test to check. The literal it returns IS the contract.
88
+ const body = SERVICE.slice(SERVICE.indexOf('async function logIso('));
89
+ const literal = body.slice(body.indexOf('return {'), body.indexOf('};') + 2);
90
+ for (const f of FIELDS) expect(literal).toContain(`${f}:`);
91
+ // …and nothing else, so a sixth field can't appear on one engine only.
92
+ const keys = [...literal.matchAll(/^\s{8}([a-zA-Z]+):/gm)].map((m) => m[1]);
93
+ expect(keys.sort()).toEqual([...FIELDS].sort());
94
+ });
95
+ });