@1agh/maude 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +34 -1
  2. package/cli/bin/maude.mjs +3 -0
  3. package/cli/commands/cache.mjs +181 -0
  4. package/cli/commands/cache.test.mjs +166 -0
  5. package/cli/commands/design-link.test.mjs +32 -2
  6. package/cli/commands/design.mjs +95 -4
  7. package/cli/commands/design.test.mjs +56 -0
  8. package/cli/commands/help.mjs +34 -0
  9. package/cli/commands/hub.mjs +255 -30
  10. package/cli/commands/hub.test.mjs +126 -2
  11. package/cli/commands/init.mjs +3 -0
  12. package/cli/commands/preflight.mjs +11 -0
  13. package/cli/commands/preflight.test.mjs +46 -0
  14. package/cli/commands/scenario-report.mjs +45 -0
  15. package/cli/lib/cache.mjs +407 -0
  16. package/cli/lib/cache.test.mjs +303 -0
  17. package/cli/lib/design-link.mjs +74 -10
  18. package/cli/lib/flow-design-integration.test.mjs +165 -0
  19. package/cli/lib/gitignore-block.mjs +90 -0
  20. package/cli/lib/gitignore-block.test.mjs +123 -0
  21. package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
  22. package/cli/lib/preflight.mjs +41 -10
  23. package/package.json +8 -8
  24. package/plugins/design/dependencies.json +30 -2
  25. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  26. package/plugins/design/dev-server/annotations-context-toolbar.tsx +763 -123
  27. package/plugins/design/dev-server/annotations-layer.tsx +1624 -208
  28. package/plugins/design/dev-server/api.ts +123 -4
  29. package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
  30. package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
  31. package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
  32. package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
  33. package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
  34. package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
  35. package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
  36. package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
  37. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  38. package/plugins/design/dev-server/bin/prep.sh +211 -0
  39. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  40. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  41. package/plugins/design/dev-server/bin/smoke.sh +208 -23
  42. package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
  43. package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
  44. package/plugins/design/dev-server/canvas-cursors.ts +173 -0
  45. package/plugins/design/dev-server/canvas-icons.tsx +299 -0
  46. package/plugins/design/dev-server/canvas-lib.tsx +25 -21
  47. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  48. package/plugins/design/dev-server/canvas-shell.tsx +404 -66
  49. package/plugins/design/dev-server/client/app.jsx +1231 -416
  50. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  51. package/plugins/design/dev-server/collab/index.ts +87 -9
  52. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  53. package/plugins/design/dev-server/collab/registry.ts +80 -2
  54. package/plugins/design/dev-server/collab/room.ts +21 -8
  55. package/plugins/design/dev-server/context-menu.tsx +167 -23
  56. package/plugins/design/dev-server/context.ts +24 -0
  57. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  58. package/plugins/design/dev-server/dist/client.bundle.js +364 -29
  59. package/plugins/design/dev-server/dist/comment-mount.js +78 -33
  60. package/plugins/design/dev-server/dist/styles.css +16 -0
  61. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  62. package/plugins/design/dev-server/export-dialog.tsx +208 -18
  63. package/plugins/design/dev-server/exporters/html.ts +4 -1
  64. package/plugins/design/dev-server/exporters/index.ts +4 -1
  65. package/plugins/design/dev-server/exporters/pdf.ts +7 -1
  66. package/plugins/design/dev-server/exporters/png.ts +21 -2
  67. package/plugins/design/dev-server/exporters/pptx.ts +330 -201
  68. package/plugins/design/dev-server/exporters/scope.ts +107 -11
  69. package/plugins/design/dev-server/exporters/svg.ts +4 -1
  70. package/plugins/design/dev-server/fs-watch.ts +1 -0
  71. package/plugins/design/dev-server/http.ts +299 -28
  72. package/plugins/design/dev-server/input-router.tsx +35 -5
  73. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  74. package/plugins/design/dev-server/server.ts +123 -1
  75. package/plugins/design/dev-server/sync/agent.ts +95 -0
  76. package/plugins/design/dev-server/sync/codec.ts +155 -0
  77. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  78. package/plugins/design/dev-server/sync/index.ts +479 -35
  79. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  80. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  81. package/plugins/design/dev-server/sync/origins.ts +57 -0
  82. package/plugins/design/dev-server/sync/projection.ts +368 -0
  83. package/plugins/design/dev-server/sync/status.ts +115 -0
  84. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  85. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  86. package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
  87. package/plugins/design/dev-server/test/annotations-layer.test.ts +311 -0
  88. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +519 -0
  89. package/plugins/design/dev-server/test/canvas-cursors.test.ts +162 -0
  90. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  91. package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
  92. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  93. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  94. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  95. package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
  96. package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
  97. package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
  98. package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
  99. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  100. package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
  101. package/plugins/design/dev-server/test/input-router.test.ts +32 -4
  102. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +132 -0
  103. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  104. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  105. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  106. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  107. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  108. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  109. package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
  110. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  111. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  112. package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
  113. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +39 -13
  114. package/plugins/design/dev-server/tool-palette.tsx +155 -24
  115. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  116. package/plugins/design/dev-server/undo-stack.ts +20 -4
  117. package/plugins/design/dev-server/use-annotation-resize.tsx +219 -61
  118. package/plugins/design/dev-server/use-tool-mode.tsx +68 -19
  119. package/plugins/design/dev-server/ws.ts +40 -1
  120. package/plugins/design/templates/_shell.html +85 -10
  121. package/plugins/design/templates/canvas.tsx.template +13 -0
  122. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
  123. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  124. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  125. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  126. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  127. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -0,0 +1,76 @@
1
+ // Canvas-origin route gate — A1/A2 (DDR-060 F1 re-audit). Boots a real server
2
+ // with MAUDE_CANVAS_ORIGIN_SPLIT=1 and probes the segregated canvas origin to
3
+ // prove: (1) the %2f-encoded path-traversal that previously leaked repo files
4
+ // outside designRoot is now 403'd; (2) privileged routes stay 403; (3) legit
5
+ // designRoot assets + the shell still serve; (4) the shell carries the hardened
6
+ // CSP. See .ai/logs/security-reviews/phase-9.1-t2-f1-cross-origin-reaudit.md.
7
+
8
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+
11
+ import { describe, expect, test } from 'bun:test';
12
+
13
+ import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
14
+
15
+ async function readCanvasOrigin(designRoot: string): Promise<string> {
16
+ // server.ts writes _server.json under the designRoot once both listeners bind.
17
+ for (let i = 0; i < 40; i++) {
18
+ try {
19
+ const info = JSON.parse(readFileSync(join(designRoot, '_server.json'), 'utf8'));
20
+ if (info.canvasOrigin) return info.canvasOrigin as string;
21
+ } catch {
22
+ /* not written yet */
23
+ }
24
+ await Bun.sleep(50);
25
+ }
26
+ throw new Error('canvasOrigin never appeared in _server.json');
27
+ }
28
+
29
+ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () => {
30
+ test('%2f traversal out of designRoot is 403; privileged 403; legit 200; CSP hardened', async () => {
31
+ const { root, designRoot } = makeSandbox();
32
+ // A real asset OUTSIDE .design/ but INSIDE repoRoot — the traversal target.
33
+ writeFileSync(join(root, 'secret-outside.css'), '.leak{content:"SECRET"}');
34
+ // A legit canvas asset under designRoot (the allowed case).
35
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
36
+ writeFileSync(
37
+ join(designRoot, 'ui', 'Gate.tsx'),
38
+ 'export default function G(){return <main/>}\n'
39
+ );
40
+
41
+ const port = nextPort();
42
+ const proc = await bootServer(root, port, { MAUDE_CANVAS_ORIGIN_SPLIT: '1' });
43
+ try {
44
+ const canvas = await readCanvasOrigin(designRoot);
45
+ const code = async (p: string) =>
46
+ (await fetch(canvas + p, { signal: AbortSignal.timeout(2000) })).status;
47
+
48
+ // (1) The confirmed bypass — must now be 403, not 200.
49
+ expect(await code('/.design/..%2fsecret-outside.css')).toBe(403);
50
+ expect(await code('/.design/ui/..%2f..%2fsecret-outside.css')).toBe(403);
51
+ // Double-encoded + mixed-case variants stay closed.
52
+ expect(await code('/.design/%2e%2e%2fsecret-outside.css')).toBe(403);
53
+
54
+ // (2) Privileged / out-of-scope routes stay 403 on the canvas origin.
55
+ for (const p of ['/_config', '/_sync-status', '/_api/export', '/package.json']) {
56
+ expect(await code(p)).toBe(403);
57
+ }
58
+
59
+ // (3) Legit surfaces still serve.
60
+ expect(await code('/_health')).toBe(200);
61
+ expect(await code('/_canvas-shell.html')).toBe(200);
62
+ // A real .tsx canvas under designRoot transpiles + serves (200).
63
+ expect(await code('/.design/ui/Gate.tsx')).toBe(200);
64
+
65
+ // (4) The shell carries the hardened CSP (A6).
66
+ const shell = await fetch(`${canvas}/_canvas-shell.html`);
67
+ const csp = shell.headers.get('content-security-policy') ?? '';
68
+ expect(csp).toContain("connect-src 'self'");
69
+ expect(csp).toContain("webrtc 'block'");
70
+ expect(csp).toContain('frame-ancestors');
71
+ expect(csp).toContain(`http://localhost:${port}`);
72
+ } finally {
73
+ await killProc(proc);
74
+ }
75
+ });
76
+ });
@@ -80,7 +80,10 @@ describe('TSX canvas route', () => {
80
80
  expect(r.headers.get('content-type')).toMatch(/javascript/);
81
81
  const etag = r.headers.get('etag');
82
82
  expect(etag).toBeTruthy();
83
- expect(etag).toMatch(/^[0-9a-f]+$/i);
83
+ // Phase 24 (DDR-067) — the canvas etag is `<source-hash>-<bootId>-<epoch>`:
84
+ // the boot id + chrome epoch are folded in so a chrome edit (which doesn't
85
+ // change the canvas source hash) still busts the browser's cached transpile.
86
+ expect(etag).toMatch(/^[0-9a-f]+-[a-z0-9]+-\d+$/i);
84
87
  const body = await r.text();
85
88
  // Body should contain data-cd-id metadata + an export — proves the two
86
89
  // passes ran end to end.
@@ -0,0 +1,78 @@
1
+ // Gap 1 (file-sync clobber fix) — the syncRoomFrom* no-op guards.
2
+ //
3
+ // createCollab now re-seeds a live room from disk whenever a synced file
4
+ // changes externally (sync-agent hub-push or `design:edit` write), so the
5
+ // room stops clobbering the external change back. That re-seed reuses
6
+ // syncRoomFromComments/syncRoomFromAnnotations, which ALSO run on the room's
7
+ // own persist → file → fs-event path. Without an equality short-circuit that
8
+ // would spin an 800ms persist storm. These tests pin the guard: an identical
9
+ // re-seed emits NO doc update (→ no persist → no loop); a real change emits one.
10
+
11
+ import { describe, expect, test } from 'bun:test';
12
+
13
+ import { Y_TYPES } from '../collab/persistence.ts';
14
+ import { createRegistry } from '../collab/registry.ts';
15
+ import type { RoomCallbacks } from '../collab/room.ts';
16
+
17
+ function noopCallbacks(): RoomCallbacks {
18
+ return {
19
+ async seed() {},
20
+ async persistJson() {},
21
+ async persistBinary() {},
22
+ };
23
+ }
24
+
25
+ describe('Gap 1 — syncRoomFromComments no-op guard', () => {
26
+ test('re-seeding identical comments emits NO doc update (loop-safe)', () => {
27
+ const r = createRegistry(noopCallbacks());
28
+ const room = r.get('s');
29
+ const list = [{ id: 'c1', text: 'hi' }];
30
+ r.syncRoomFromComments('s', list);
31
+
32
+ let updates = 0;
33
+ room.doc.on('update', () => {
34
+ updates++;
35
+ });
36
+ // Identical content (the room's-own-persist echo, or a redundant hub push)
37
+ // → must not emit, or the reseed↔persist loop never settles.
38
+ r.syncRoomFromComments('s', [{ id: 'c1', text: 'hi' }]);
39
+ expect(updates).toBe(0);
40
+
41
+ // A genuine change still propagates — exactly one update.
42
+ r.syncRoomFromComments('s', [
43
+ { id: 'c1', text: 'hi' },
44
+ { id: 'c2', text: 'yo' },
45
+ ]);
46
+ expect(updates).toBe(1);
47
+ });
48
+
49
+ test('re-seed brings a STALE room up to the on-disk list (the clobber fix)', () => {
50
+ const r = createRegistry(noopCallbacks());
51
+ const room = r.get('s');
52
+ const arr = room.doc.getArray(Y_TYPES.comments);
53
+ expect(arr.length).toBe(0); // room opened empty (the "B shows []" state)
54
+
55
+ // Sync agent wrote a hub-pushed comment to disk → createCollab re-seeds:
56
+ r.syncRoomFromComments('s', [{ id: 'c1', text: 'from peer A' }]);
57
+ expect(arr.toArray()).toEqual([{ id: 'c1', text: 'from peer A' }]);
58
+ });
59
+ });
60
+
61
+ describe('Gap 1 — syncRoomFromAnnotations no-op guard', () => {
62
+ test('re-seeding the identical svg emits NO doc update', () => {
63
+ const r = createRegistry(noopCallbacks());
64
+ const room = r.get('s');
65
+ const svg = '<svg xmlns="http://www.w3.org/2000/svg"><g/></svg>';
66
+ r.syncRoomFromAnnotations('s', svg);
67
+
68
+ let updates = 0;
69
+ room.doc.on('update', () => {
70
+ updates++;
71
+ });
72
+ r.syncRoomFromAnnotations('s', svg); // identical → no-op
73
+ expect(updates).toBe(0);
74
+
75
+ r.syncRoomFromAnnotations('s', '<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>');
76
+ expect(updates).toBe(1);
77
+ });
78
+ });
@@ -3,9 +3,15 @@
3
3
 
4
4
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
5
5
 
6
+ import { Awareness } from 'y-protocols/awareness';
6
7
  import * as Y from 'yjs';
7
8
 
8
- import { encodeHandshake, encodeSyncUpdate, handleMessage } from '../collab/protocol.ts';
9
+ import {
10
+ encodeAwarenessFrame,
11
+ encodeHandshake,
12
+ encodeSyncUpdate,
13
+ handleMessage,
14
+ } from '../collab/protocol.ts';
9
15
  import { type RoomCallbacks, type RoomConn, createRoom } from '../collab/room.ts';
10
16
 
11
17
  function makeConn(id: string): RoomConn & { recv: Uint8Array[] } {
@@ -140,24 +146,29 @@ describe('Room', () => {
140
146
  await room.destroy();
141
147
  });
142
148
 
143
- test('disconnect cleans up awareness states tagged with __connId', async () => {
149
+ test('disconnect removes the awareness states a conn published', async () => {
144
150
  const cb = makeCallbacks();
145
151
  const room = createRoom('aw-cleanup-slug', cb);
146
152
  const A = makeConn('a');
147
153
  await room.connect(A);
148
154
 
149
- // Simulate Alice publishing her awareness state with the matching __connId.
150
- room.awareness.setLocalState({ name: 'Alice', __connId: 'a' });
151
- expect(room.awareness.getStates().size).toBe(1);
155
+ // Alice (conn A) publishes her awareness over the wire — the server learns
156
+ // her clientID belongs to conn 'a' from the update origin (server-authoritative;
157
+ // it does NOT trust a client-supplied __connId). Mirrors the real receive path.
158
+ const clientDoc = new Y.Doc();
159
+ const clientAw = new Awareness(clientDoc);
160
+ clientAw.setLocalState({ name: 'Alice' });
161
+ room.receive(A, encodeAwarenessFrame(clientAw, [clientAw.clientID]));
162
+ expect(room.awareness.getStates().has(clientAw.clientID)).toBe(true);
152
163
 
153
164
  room.disconnect(A);
154
165
  expect(room.size()).toBe(0);
155
- // Awareness state owned by the disconnected conn is gone.
156
- const states = room.awareness.getStates();
157
- for (const s of states.values()) {
158
- expect((s as { __connId?: string }).__connId).not.toBe('a');
159
- }
166
+ // The disconnected conn's awareness state is dropped immediately no waiting
167
+ // out the ~30s awareness timeout (the phantom-accumulation bug).
168
+ expect(room.awareness.getStates().has(clientAw.clientID)).toBe(false);
160
169
 
170
+ clientAw.destroy();
171
+ clientDoc.destroy();
161
172
  await room.destroy();
162
173
  });
163
174
 
@@ -0,0 +1,46 @@
1
+ // cspForCanvasShell — A6 (DDR-060 F1 re-audit) CSP hardening for the segregated
2
+ // canvas origin. Asserts the exfil-relevant directives are present and that
3
+ // `frame-ancestors` is only emitted when the main origin is known (so the legit
4
+ // cross-origin embed is never accidentally blocked).
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import { cspForCanvasShell } from '../http.ts';
9
+
10
+ const SHELL = '<!doctype html><html><head><script>const x=1;</script></head><body></body></html>';
11
+
12
+ describe('cspForCanvasShell — A6 hardening', () => {
13
+ test('locks down the dangerous lanes', () => {
14
+ const csp = cspForCanvasShell(SHELL);
15
+ expect(csp).toContain("default-src 'none'");
16
+ expect(csp).toContain("connect-src 'self'");
17
+ expect(csp).toContain("object-src 'none'");
18
+ expect(csp).toContain("base-uri 'none'");
19
+ // No eval / no inline-script escape hatch.
20
+ expect(csp).not.toContain('unsafe-eval');
21
+ expect(csp).not.toMatch(/script-src[^;]*unsafe-inline/);
22
+ });
23
+
24
+ test("adds webrtc 'block' (connect-src does not cover WebRTC exfil)", () => {
25
+ expect(cspForCanvasShell(SHELL)).toContain("webrtc 'block'");
26
+ });
27
+
28
+ test("adds form-action 'none'", () => {
29
+ expect(cspForCanvasShell(SHELL)).toContain("form-action 'none'");
30
+ });
31
+
32
+ test('hashes inline scripts (no unsafe-inline for script-src)', () => {
33
+ const csp = cspForCanvasShell(SHELL);
34
+ expect(csp).toMatch(/script-src 'self' 'sha256-[A-Za-z0-9+/=]+'/);
35
+ });
36
+
37
+ test('frame-ancestors allowlists the main origin when known', () => {
38
+ const csp = cspForCanvasShell(SHELL, 'http://localhost:4399');
39
+ expect(csp).toContain("frame-ancestors 'self' http://localhost:4399");
40
+ });
41
+
42
+ test('frame-ancestors is OMITTED when the main origin is unknown (would block the legit embed)', () => {
43
+ const csp = cspForCanvasShell(SHELL);
44
+ expect(csp).not.toContain('frame-ancestors');
45
+ });
46
+ });
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { describe, expect, test } from 'bun:test';
10
10
 
11
- import { run } from '../../exporters/png.ts';
11
+ import { clampScale, run } from '../../exporters/png.ts';
12
12
 
13
13
  const CTX = {
14
14
  designRoot: '/tmp/.design',
@@ -30,3 +30,26 @@ describe('png adapter — contract', () => {
30
30
  );
31
31
  });
32
32
  });
33
+
34
+ describe('png clampScale — size presets (item 1)', () => {
35
+ test('defaults to 2× when scale is absent / invalid', () => {
36
+ expect(clampScale(undefined)).toBe(2);
37
+ expect(clampScale(null)).toBe(2);
38
+ expect(clampScale('nonsense')).toBe(2);
39
+ expect(clampScale(0)).toBe(2);
40
+ });
41
+
42
+ test('honours the 1×/2×/3× presets (number or string)', () => {
43
+ expect(clampScale(1)).toBe(1);
44
+ expect(clampScale(2)).toBe(2);
45
+ expect(clampScale(3)).toBe(3);
46
+ expect(clampScale('1')).toBe(1);
47
+ expect(clampScale('3')).toBe(3);
48
+ });
49
+
50
+ test('clamps out-of-range / fractional values to the preset set', () => {
51
+ expect(clampScale(4)).toBe(2); // above max → safe default
52
+ expect(clampScale(2.6)).toBe(3); // rounds to nearest preset
53
+ expect(clampScale(1.2)).toBe(1);
54
+ });
55
+ });
@@ -0,0 +1,112 @@
1
+ // PPTX adapter — native (svg2pptx) pipeline units (export-pipeline-fixes item 6).
2
+ //
3
+ // Covers the two pure pieces that make svg2pptx faithful + the deck merge:
4
+ // - preprocessSvg: lift tspan x/y onto <text> + collapse the font stack.
5
+ // - mergeDecks: combine N single-slide svg2pptx decks into one valid package.
6
+ // The svg2pptx conversion itself (Python) + live render are integration-shape.
7
+
8
+ import { describe, expect, test } from 'bun:test';
9
+ import JSZip from 'jszip';
10
+
11
+ import { mergeDecks, preprocessSvg } from '../../exporters/pptx.ts';
12
+
13
+ describe('preprocessSvg', () => {
14
+ test('lifts the first tspan x/y onto a <text> that lacks them', () => {
15
+ const svg = `<svg><text fill="rgb(0,0,0)"><tspan x="33" y="65.5">Hi</tspan></text></svg>`;
16
+ const out = preprocessSvg(svg);
17
+ // svg2pptx reads text@x/y — they must now be present (was the 0,0 pile-up).
18
+ expect(out).toMatch(/<text x="33" y="65\.5"/);
19
+ });
20
+
21
+ test('collapses a CSS font-family stack to its first concrete name', () => {
22
+ const svg = `<text font-family="&quot;Berkeley Mono&quot;, TX-02, &quot;JetBrains Mono&quot;, monospace"><tspan x="1" y="2">x</tspan></text>`;
23
+ const out = preprocessSvg(svg);
24
+ // A PPTX typeface is a single font name, not a fallback list.
25
+ expect(out).toContain('font-family="Berkeley Mono"');
26
+ expect(out).not.toContain('TX-02');
27
+ });
28
+
29
+ test('does not clobber a <text> that already has its own x/y', () => {
30
+ const svg = `<text x="10" y="20"><tspan x="99" y="88">x</tspan></text>`;
31
+ const out = preprocessSvg(svg);
32
+ expect(out).toContain('<text x="10" y="20"');
33
+ expect(out).not.toContain('<text x="99"');
34
+ });
35
+ });
36
+
37
+ const REL_NS = 'http://schemas.openxmlformats.org/package/2006/relationships';
38
+ const OD_REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
39
+ const CT_NS = 'http://schemas.openxmlformats.org/package/2006/content-types';
40
+ const SLIDE_CT = 'application/vnd.openxmlformats-officedocument.presentationml.slide+xml';
41
+
42
+ /** Minimal single-slide deck shaped like svg2pptx output (native, no media). */
43
+ async function singleSlideDeck(): Promise<Uint8Array> {
44
+ const zip = new JSZip();
45
+ zip.file(
46
+ '[Content_Types].xml',
47
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="${CT_NS}"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/><Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/><Override PartName="/ppt/slides/slide1.xml" ContentType="${SLIDE_CT}"/></Types>`
48
+ );
49
+ zip.file(
50
+ 'ppt/presentation.xml',
51
+ '<?xml version="1.0"?><p:presentation xmlns:p="x" xmlns:r="y"><p:sldMasterIdLst><p:sldMasterId id="1" r:id="rId1"/></p:sldMasterIdLst><p:sldIdLst><p:sldId id="256" r:id="rId2"/></p:sldIdLst></p:presentation>'
52
+ );
53
+ zip.file(
54
+ 'ppt/_rels/presentation.xml.rels',
55
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="${REL_NS}"><Relationship Id="rId1" Type="${OD_REL}/slideMaster" Target="slideMasters/slideMaster1.xml"/><Relationship Id="rId2" Type="${OD_REL}/slide" Target="slides/slide1.xml"/></Relationships>`
56
+ );
57
+ zip.file('ppt/slides/slide1.xml', '<?xml version="1.0"?><p:sld xmlns:p="x"/>');
58
+ zip.file(
59
+ 'ppt/slides/_rels/slide1.xml.rels',
60
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="${REL_NS}"><Relationship Id="rId1" Type="${OD_REL}/slideLayout" Target="../slideLayouts/slideLayout1.xml"/></Relationships>`
61
+ );
62
+ zip.file('ppt/slideMasters/slideMaster1.xml', '<?xml version="1.0"?><p:sldMaster xmlns:p="x"/>');
63
+ zip.file('ppt/slideLayouts/slideLayout1.xml', '<?xml version="1.0"?><p:sldLayout xmlns:p="x"/>');
64
+ return zip.generateAsync({ type: 'uint8array' });
65
+ }
66
+
67
+ describe('mergeDecks — combine svg2pptx single-slide decks', () => {
68
+ test('single deck passes through', async () => {
69
+ const d = await singleSlideDeck();
70
+ const merged = await mergeDecks([d]);
71
+ expect(merged.byteLength).toBe(d.byteLength);
72
+ });
73
+
74
+ test('three decks → one valid package, all refs + Content-Types resolve', async () => {
75
+ const decks = await Promise.all([singleSlideDeck(), singleSlideDeck(), singleSlideDeck()]);
76
+ const merged = await mergeDecks(decks);
77
+ const zip = await JSZip.loadAsync(merged);
78
+ const parts = new Set(Object.keys(zip.files).filter((n) => !n.endsWith('/')));
79
+
80
+ // 3 contiguous slides; no 4th.
81
+ for (const n of [1, 2, 3]) expect(parts.has(`ppt/slides/slide${n}.xml`)).toBe(true);
82
+ expect(parts.has('ppt/slides/slide4.xml')).toBe(false);
83
+
84
+ // presentation.xml lists 3 slides; its rels keep the slideMaster (the
85
+ // "unopenable" regression) + carry 3 slide rels.
86
+ const pres = (await zip.file('ppt/presentation.xml')?.async('string')) ?? '';
87
+ expect((pres.match(/<p:sldId /g) ?? []).length).toBe(3);
88
+ const prels = (await zip.file('ppt/_rels/presentation.xml.rels')?.async('string')) ?? '';
89
+ expect(prels).toContain('Target="slideMasters/slideMaster1.xml"');
90
+ expect((prels.match(/Type="[^"]*\/relationships\/slide"/g) ?? []).length).toBe(3);
91
+
92
+ // Every rels Target resolves.
93
+ for (const rf of [...parts].filter((p) => p.endsWith('.rels'))) {
94
+ const xml = (await zip.file(rf)?.async('string')) ?? '';
95
+ const baseDir = rf.replace(/_rels\/[^/]+$/, '');
96
+ for (const rel of xml.matchAll(/<Relationship\b[^>]*\/>/g)) {
97
+ if (/TargetMode="External"/.test(rel[0])) continue;
98
+ const tgt = /Target="([^"]+)"/.exec(rel[0])?.[1];
99
+ if (!tgt) continue;
100
+ const resolved = new URL(tgt, `file:///${baseDir}`).pathname.replace(/^\//, '');
101
+ expect(parts.has(resolved)).toBe(true);
102
+ }
103
+ }
104
+
105
+ // Every Content-Types Override resolves; exactly 3 slide overrides.
106
+ const ct = (await zip.file('[Content_Types].xml')?.async('string')) ?? '';
107
+ for (const o of ct.matchAll(/<Override\s+PartName="([^"]+)"/g)) {
108
+ expect(parts.has(o[1].replace(/^\//, ''))).toBe(true);
109
+ }
110
+ expect((ct.match(/PartName="\/ppt\/slides\/slide\d+\.xml"/g) ?? []).length).toBe(3);
111
+ });
112
+ });
@@ -0,0 +1,49 @@
1
+ // Unit cover for the shared Chromium launcher's error mapping. The launch path
2
+ // itself is integration-shape (needs a real browser); here we pin the pure
3
+ // classifier + the user-facing contract so a regression can't silently turn the
4
+ // "browser not installed" 500 back into an opaque stack trace.
5
+ //
6
+ // Regression guard for `.ai/logs/rca/issue-nefunguji-exporty.md`.
7
+
8
+ import { describe, expect, test } from 'bun:test';
9
+
10
+ import { INSTALL_HINT, NO_BROWSER_EXIT, isMissingBrowserError } from '../../bin/_pw-launch.mjs';
11
+
12
+ describe('_pw-launch — missing-browser classifier', () => {
13
+ test('matches the real Playwright "Executable doesn\'t exist" message', () => {
14
+ const real =
15
+ "browserType.launch: Executable doesn't exist at " +
16
+ '/Users/x/Library/Caches/ms-playwright/chromium_headless_shell-1223/' +
17
+ 'chrome-headless-shell-mac-arm64/chrome-headless-shell\n' +
18
+ '╔════════════════════════════════════════════════════════════╗';
19
+ expect(isMissingBrowserError(real)).toBe(true);
20
+ });
21
+
22
+ test('matches the "please run … to download" + "playwright install" variants', () => {
23
+ expect(isMissingBrowserError('Please run the following command to download new browsers')).toBe(
24
+ true
25
+ );
26
+ expect(
27
+ isMissingBrowserError('Looks like Playwright was just installed... npx playwright install')
28
+ ).toBe(true);
29
+ });
30
+
31
+ test('does NOT match unrelated launch failures (preserve real diagnostics)', () => {
32
+ expect(isMissingBrowserError('Target page, context or browser has been closed')).toBe(false);
33
+ expect(isMissingBrowserError('connect ECONNREFUSED 127.0.0.1:9222')).toBe(false);
34
+ expect(isMissingBrowserError('Timeout 30000ms exceeded')).toBe(false);
35
+ expect(isMissingBrowserError('')).toBe(false);
36
+ expect(isMissingBrowserError(null)).toBe(false);
37
+ expect(isMissingBrowserError(undefined)).toBe(false);
38
+ });
39
+ });
40
+
41
+ describe('_pw-launch — user-facing contract', () => {
42
+ test('INSTALL_HINT is the copy-pasteable remediation', () => {
43
+ expect(INSTALL_HINT).toContain('npx playwright install chromium');
44
+ });
45
+
46
+ test('NO_BROWSER_EXIT is a distinct non-zero code', () => {
47
+ expect(NO_BROWSER_EXIT).toBeGreaterThan(0);
48
+ });
49
+ });
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><path data-id="p1" data-tool="pen" stroke="#d63b1f" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="none" d="M0 0 L10 10 L20 5" pointer-events="stroke"/><rect data-id="r1" data-tool="rect" stroke="#222" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="#fff4d6" x="10" y="20" width="30" height="40"/><ellipse data-id="e1" data-tool="ellipse" stroke="#222" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="#e6f4ea" cx="100" cy="100" rx="40" ry="25"/><g data-id="a1" data-tool="arrow" stroke="#1a8f3e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="none"><line x1="0" y1="0" x2="100" y2="0"/><polyline points="85.5844981135613,6.94213982588093 100,0 85.5844981135613,-6.94213982588093" fill="#1a8f3e"/></g><text data-id="t1" data-tool="text" data-anchor-id="r1" data-font-size="14" fill="#1a1a1a" text-anchor="middle" dominant-baseline="middle">needs work</text></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><g data-id="st1" data-tool="sticky" data-r="8" data-fs="14" fill="#ffe27a"><rect x="40" y="50" width="200" height="160" rx="8" ry="8"/><text data-sticky-body="1" x="52" y="62" font-size="14" fill="#1a1a1a" dominant-baseline="hanging">ship it</text></g><rect data-id="r1" data-tool="rect" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="none" x="10" y="20" width="120" height="80" rx="8" ry="8" data-r="8"/><g data-id="a1" data-tool="arrow" stroke="#e5484d" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="none" data-start-head="triangle" data-dash="1"><line x1="0" y1="0" x2="80" y2="40" stroke-dasharray="6 4"/><polyline points="17.99801569252915,0.2672660312767773 0,0 11.012622240538908,14.238052935257258" fill="#e5484d"/><polyline points="62.00198430747085,39.732733968723224 80,40 68.98737775946108,25.761947064742742" fill="#e5484d"/></g></svg>
@@ -201,10 +201,10 @@ describe('input-router / keydown — Phase 5 draw tools', () => {
201
201
  });
202
202
  });
203
203
 
204
- test('R → tool rect', () => {
204
+ test('R → tool shape (Phase 24 — single Shape tool)', () => {
205
205
  expect(classify(base({ type: 'keydown', key: 'r' }))).toEqual({
206
206
  kind: 'tool',
207
- tool: 'rect',
207
+ tool: 'shape',
208
208
  });
209
209
  });
210
210
 
@@ -222,10 +222,31 @@ describe('input-router / keydown — Phase 5 draw tools', () => {
222
222
  });
223
223
  });
224
224
 
225
- test('O → tool ellipse (Phase 5.1)', () => {
225
+ test('O → tool shape (Phase 24 — legacy ellipse key now arms Shape)', () => {
226
226
  expect(classify(base({ type: 'keydown', key: 'o' }))).toEqual({
227
227
  kind: 'tool',
228
- tool: 'ellipse',
228
+ tool: 'shape',
229
+ });
230
+ });
231
+
232
+ test('shape tool owns bare pointer events (SVG overlay claims)', () => {
233
+ expect(classify(base({ type: 'pointerdown', activeTool: 'shape', button: 0 })).kind).toBe(
234
+ 'no-op'
235
+ );
236
+ expect(classify(base({ type: 'pointermove', activeTool: 'shape' })).kind).toBe('no-op');
237
+ });
238
+
239
+ test('N → tool sticky (Phase 21)', () => {
240
+ expect(classify(base({ type: 'keydown', key: 'n' }))).toEqual({
241
+ kind: 'tool',
242
+ tool: 'sticky',
243
+ });
244
+ });
245
+
246
+ test('T → tool text (Phase 21)', () => {
247
+ expect(classify(base({ type: 'keydown', key: 't' }))).toEqual({
248
+ kind: 'tool',
249
+ tool: 'text',
229
250
  });
230
251
  });
231
252
 
@@ -268,6 +289,13 @@ describe('input-router / pointer events — Phase 5 annotation tools', () => {
268
289
  expect(classify(base({ type: 'pointermove', activeTool: 'ellipse' })).kind).toBe('no-op');
269
290
  });
270
291
 
292
+ test('sticky + text tools own bare pointer events (Phase 21 — SVG overlay claims)', () => {
293
+ for (const activeTool of ['sticky', 'text'] as const) {
294
+ expect(classify(base({ type: 'pointerdown', activeTool, button: 0 })).kind).toBe('no-op');
295
+ expect(classify(base({ type: 'pointermove', activeTool })).kind).toBe('no-op');
296
+ }
297
+ });
298
+
271
299
  test('cmd+left-click in arrow tool → select replace (escape hatch to move)', () => {
272
300
  expect(
273
301
  classify(