@1agh/maude 0.30.0 → 0.31.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 (90) hide show
  1. package/README.md +5 -5
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/annotations-layer.tsx +6 -1
  8. package/apps/studio/api.ts +225 -66
  9. package/apps/studio/bin/chat-open.sh +44 -0
  10. package/apps/studio/canvas-lib.tsx +112 -19
  11. package/apps/studio/canvas-list-watch.ts +177 -0
  12. package/apps/studio/canvas-shell.tsx +22 -2
  13. package/apps/studio/client/app.jsx +781 -26
  14. package/apps/studio/client/canvas-url.js +5 -0
  15. package/apps/studio/client/github.js +99 -0
  16. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  17. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  18. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  19. package/apps/studio/client/panels/DiffView.jsx +590 -0
  20. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  21. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  22. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  23. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  24. package/apps/studio/client/panels/acp-runtime.js +286 -0
  25. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  26. package/apps/studio/client/panels/git-grouping.js +86 -0
  27. package/apps/studio/client/styles/0-reset.css +4 -0
  28. package/apps/studio/client/styles/3-shell-maude.css +613 -3
  29. package/apps/studio/client/styles/5-maude-overrides.css +25 -0
  30. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  31. package/apps/studio/client/styles/_index.css +2 -0
  32. package/apps/studio/client/tour/collab-tour.js +61 -0
  33. package/apps/studio/client/tour/overlay.jsx +11 -1
  34. package/apps/studio/client/whats-new.jsx +25 -10
  35. package/apps/studio/collab/registry.ts +13 -0
  36. package/apps/studio/collab/room.ts +36 -0
  37. package/apps/studio/cursors-overlay.tsx +17 -1
  38. package/apps/studio/dist/client.bundle.js +28780 -1534
  39. package/apps/studio/dist/comment-mount.js +4 -2
  40. package/apps/studio/dist/styles.css +5633 -1617
  41. package/apps/studio/git/endpoints.ts +338 -0
  42. package/apps/studio/git/service.ts +1334 -0
  43. package/apps/studio/git/watch.ts +97 -0
  44. package/apps/studio/github/endpoints.ts +358 -0
  45. package/apps/studio/github/service.ts +231 -0
  46. package/apps/studio/github/token.ts +53 -0
  47. package/apps/studio/hmr-broadcast.ts +9 -2
  48. package/apps/studio/http.ts +384 -1
  49. package/apps/studio/participants-chrome.tsx +69 -9
  50. package/apps/studio/paths.ts +12 -0
  51. package/apps/studio/scaffold-design.ts +57 -0
  52. package/apps/studio/server.ts +65 -2
  53. package/apps/studio/sync/agent.ts +81 -1
  54. package/apps/studio/sync/codec.ts +24 -0
  55. package/apps/studio/sync/cold-start.ts +40 -0
  56. package/apps/studio/sync/hub-link.ts +137 -0
  57. package/apps/studio/test/acp-bridge.test.ts +127 -0
  58. package/apps/studio/test/acp-env.test.ts +65 -0
  59. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  60. package/apps/studio/test/acp-transcript.test.ts +112 -0
  61. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  62. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  63. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  64. package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
  65. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  66. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  67. package/apps/studio/test/csrf-write-guard.test.ts +26 -0
  68. package/apps/studio/test/editing-presence.test.ts +103 -0
  69. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  70. package/apps/studio/test/git-api.test.ts +0 -0
  71. package/apps/studio/test/git-branches.test.ts +106 -0
  72. package/apps/studio/test/git-grouping.test.ts +106 -0
  73. package/apps/studio/test/git-watch.test.ts +97 -0
  74. package/apps/studio/test/github-api.test.ts +465 -0
  75. package/apps/studio/test/hub-link.test.ts +69 -0
  76. package/apps/studio/test/participants-chrome.test.ts +36 -1
  77. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  78. package/apps/studio/test/sync-cold-start.test.ts +61 -1
  79. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  80. package/apps/studio/tool-palette.tsx +18 -9
  81. package/apps/studio/use-chrome-visibility.tsx +66 -0
  82. package/apps/studio/use-collab.tsx +414 -187
  83. package/apps/studio/whats-new.json +73 -0
  84. package/apps/studio/ws.ts +44 -1
  85. package/cli/commands/design.mjs +1 -0
  86. package/cli/lib/gitignore-block.mjs +16 -3
  87. package/cli/lib/gitignore-block.test.mjs +13 -1
  88. package/package.json +11 -9
  89. package/plugins/design/dependencies.json +17 -0
  90. package/plugins/design/templates/_shell.html +30 -8
@@ -0,0 +1,58 @@
1
+ // chat-markdown — Phase 31. The ACP chat feed's compact, XSS-safe markdown
2
+ // renderer. Builds React nodes (never innerHTML), so we assert the rendered
3
+ // markup + that unsafe link schemes are dropped.
4
+
5
+ import { describe, expect, test } from 'bun:test';
6
+ import { renderToStaticMarkup } from 'react-dom/server';
7
+
8
+ import { Markdown } from '../client/panels/chat-markdown.jsx';
9
+
10
+ const html = (text: string) => renderToStaticMarkup(<Markdown text={text} />);
11
+
12
+ describe('chat-markdown', () => {
13
+ test('inline bold / italic / code', () => {
14
+ const out = html('Use **bold**, *italic* and `code` here.');
15
+ expect(out).toContain('<strong>bold</strong>');
16
+ expect(out).toContain('<em>italic</em>');
17
+ expect(out).toContain('<code>code</code>');
18
+ });
19
+
20
+ test('fenced code block', () => {
21
+ const out = html('text\n```\nconst x = 1;\n```\nmore');
22
+ expect(out).toContain('class="chat-code"');
23
+ expect(out).toContain('const x = 1;');
24
+ });
25
+
26
+ test('bullet + numbered lists', () => {
27
+ const bullet = html('- one\n- two');
28
+ expect(bullet).toContain('<ul class="chat-md-list">');
29
+ expect(bullet).toContain('<li>one</li>');
30
+ const ordered = html('1. first\n2. second');
31
+ expect(ordered).toContain('<ol class="chat-md-list">');
32
+ expect(ordered).toContain('<li>first</li>');
33
+ });
34
+
35
+ test('headings render as styled paragraphs (no giant h1)', () => {
36
+ const out = html('## Section');
37
+ expect(out).toContain('class="chat-md-h"');
38
+ expect(out).toContain('Section');
39
+ expect(out).not.toContain('<h1');
40
+ });
41
+
42
+ test('http(s) links are kept, unsafe schemes are dropped to text', () => {
43
+ const safe = html('see [Anthropic](https://anthropic.com)');
44
+ expect(safe).toContain('href="https://anthropic.com"');
45
+ expect(safe).toContain('rel="noreferrer noopener"');
46
+
47
+ const unsafe = html('[click](javascript:alert(1))');
48
+ expect(unsafe).not.toContain('javascript:');
49
+ expect(unsafe).not.toContain('<a ');
50
+ expect(unsafe).toContain('click'); // falls back to plain text
51
+ });
52
+
53
+ test('plain text passes through as a paragraph', () => {
54
+ const out = html('just a line');
55
+ expect(out).toContain('class="chat-md-p"');
56
+ expect(out).toContain('just a line');
57
+ });
58
+ });
@@ -0,0 +1,176 @@
1
+ // Collab session survives a canvas hot-swap remount — F4 regression.
2
+ //
3
+ // RCA: `.ai/logs/rca/issue-collab-observer-tsx-reload-flicker.md`. A cross-peer
4
+ // synced edit hot-swaps the canvas module in place, which REMOUNTS the whole
5
+ // canvas subtree — including <CollabProvider>. If the Y.Doc + Awareness + socket
6
+ // were per-component, that remount would close + re-handshake the awareness
7
+ // socket and every peer's cursor/avatar would blink. The fix makes the session
8
+ // a module-level, refcounted singleton keyed by slug, so a same-slug remount
9
+ // reuses the SAME live socket + Awareness (stable clientID → the server never
10
+ // sees the peer leave + rejoin → no blink).
11
+ //
12
+ // We register happy-dom (error boundaries / real createRoot need a live DOM,
13
+ // like canvas-hmr-runtime.test.tsx) and stub WebSocket + fetch.
14
+
15
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
16
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
17
+
18
+ beforeAll(() => {
19
+ GlobalRegistrator.register();
20
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
21
+ });
22
+ afterAll(() => {
23
+ GlobalRegistrator.unregister();
24
+ });
25
+
26
+ import { act, createElement, useEffect } from 'react';
27
+ import { createRoot, type Root } from 'react-dom/client';
28
+ import type * as Y from 'yjs';
29
+
30
+ import { CollabProvider, useCollab } from '../use-collab.tsx';
31
+
32
+ // ── Fake WebSocket: count constructions + closes; never touches the network ──
33
+ let wsConstructed = 0;
34
+ let wsClosed = 0;
35
+ class FakeWS {
36
+ static OPEN = 1;
37
+ static CLOSING = 2;
38
+ static CLOSED = 3;
39
+ readyState = 1; // OPEN immediately — the provider only broadcasts when OPEN
40
+ url: string;
41
+ private handlers: Record<string, Array<(e: unknown) => void>> = {};
42
+ constructor(url: string) {
43
+ this.url = url;
44
+ wsConstructed++;
45
+ // Fire `open` on the next microtask so the provider's open handler runs.
46
+ queueMicrotask(() => this.dispatch('open', {}));
47
+ }
48
+ addEventListener(type: string, fn: (e: unknown) => void) {
49
+ const bucket = this.handlers[type] ?? [];
50
+ bucket.push(fn);
51
+ this.handlers[type] = bucket;
52
+ }
53
+ removeEventListener() {}
54
+ send() {}
55
+ close() {
56
+ if (this.readyState === FakeWS.CLOSED) return;
57
+ this.readyState = FakeWS.CLOSED;
58
+ wsClosed++;
59
+ this.dispatch('close', {});
60
+ }
61
+ private dispatch(type: string, e: unknown) {
62
+ for (const fn of this.handlers[type] ?? []) fn(e);
63
+ }
64
+ }
65
+
66
+ const realWebSocket = globalThis.WebSocket;
67
+ const realFetch = globalThis.fetch;
68
+
69
+ beforeAll(() => {
70
+ (globalThis as unknown as { WebSocket: unknown }).WebSocket = FakeWS;
71
+ (globalThis as unknown as { fetch: unknown }).fetch = () =>
72
+ Promise.resolve({ json: () => Promise.resolve({ name: 'Tester' }) });
73
+ });
74
+ afterAll(() => {
75
+ (globalThis as unknown as { WebSocket: unknown }).WebSocket = realWebSocket;
76
+ (globalThis as unknown as { fetch: unknown }).fetch = realFetch;
77
+ });
78
+
79
+ afterEach(() => {
80
+ wsConstructed = 0;
81
+ wsClosed = 0;
82
+ });
83
+
84
+ // Captures the live doc + awareness clientID for the mounted provider.
85
+ let captured: { doc: Y.Doc; clientID: number } | null = null;
86
+ function Probe() {
87
+ const collab = useCollab();
88
+ useEffect(() => {
89
+ if (collab) captured = { doc: collab.doc, clientID: collab.awareness.clientID };
90
+ });
91
+ return null;
92
+ }
93
+
94
+ function mount(el: HTMLElement): Root {
95
+ let root!: Root;
96
+ act(() => {
97
+ root = createRoot(el);
98
+ });
99
+ return root;
100
+ }
101
+
102
+ function renderProvider(root: Root, slug: string) {
103
+ act(() => {
104
+ root.render(createElement(CollabProvider, { slug }, createElement(Probe)));
105
+ });
106
+ }
107
+
108
+ describe('collab session survival across a hot-swap remount', () => {
109
+ test('a same-slug remount reuses the live socket + Awareness (no reconnect)', () => {
110
+ captured = null;
111
+ const host = document.createElement('div');
112
+ const root = mount(host);
113
+
114
+ renderProvider(root, 'survival-slug');
115
+ expect(wsConstructed).toBe(1);
116
+ const first = captured;
117
+ expect(first).not.toBeNull();
118
+
119
+ // Hot-swap = unmount the canvas subtree, then immediately remount the SAME
120
+ // slug (what CanvasHmrRuntime's key=attempt does on a synced module swap).
121
+ act(() => {
122
+ root.render(createElement('div', null));
123
+ });
124
+ renderProvider(root, 'survival-slug');
125
+
126
+ // The session was reused within the grace window — NO second socket, and the
127
+ // SAME doc + Awareness clientID (the server never saw a leave/rejoin).
128
+ expect(wsConstructed).toBe(1);
129
+ expect(wsClosed).toBe(0);
130
+ expect(captured?.doc).toBe(first?.doc);
131
+ expect(captured?.clientID).toBe(first?.clientID);
132
+
133
+ act(() => root.unmount());
134
+ });
135
+
136
+ test('the session registry is anchored on window under a non-enumerable Symbol', () => {
137
+ // A hot-swap re-imports the canvas bundle with a `?v=` cache-buster, and
138
+ // use-collab is INLINED into that bundle — so a module-scoped `const
139
+ // SESSIONS = new Map()` would be FRESH (empty) on every hot-swap and spin a
140
+ // new clientID each time (phantom self-avatars pile up). Anchoring the map
141
+ // on `window` (under a global Symbol so the re-imported module resolves the
142
+ // same key) is what makes the session survive the swap. This pins that
143
+ // invariant — the registry holds the slug — AND the security posture: the
144
+ // key is a NON-ENUMERABLE Symbol so untrusted same-realm canvas script can't
145
+ // harvest the live network handles via a for-in / Object.keys sweep.
146
+ captured = null;
147
+ const host = document.createElement('div');
148
+ const root = mount(host);
149
+ renderProvider(root, 'window-anchored-slug');
150
+ const w = window as unknown as Record<symbol, Map<string, unknown> | undefined>;
151
+ const reg = w[Symbol.for('maude.collab.sessions.v1')];
152
+ expect(reg).toBeInstanceOf(Map);
153
+ expect(reg?.has('window-anchored-slug')).toBe(true);
154
+ // Not reachable via string-key enumeration (defense in depth).
155
+ expect(Object.keys(window)).not.toContain('__maudeCollabSessions');
156
+ act(() => root.unmount());
157
+ });
158
+
159
+ test('a different slug opens its own session (no cross-slug sharing)', () => {
160
+ captured = null;
161
+ const host = document.createElement('div');
162
+ const root = mount(host);
163
+
164
+ renderProvider(root, 'slug-a');
165
+ const a = captured;
166
+ renderProvider(root, 'slug-b');
167
+ const b = captured;
168
+
169
+ // slug-b reuses slug-a's <CollabProvider> instance but a NEW session →
170
+ // a second socket + a distinct doc.
171
+ expect(wsConstructed).toBe(2);
172
+ expect(b?.doc).not.toBe(a?.doc);
173
+
174
+ act(() => root.unmount());
175
+ });
176
+ });
@@ -15,6 +15,8 @@
15
15
  // race unrelated to this guard. See the Phase 12.2 validate notes.)
16
16
 
17
17
  import { describe, expect, test } from 'bun:test';
18
+ import { readFileSync } from 'node:fs';
19
+ import { fileURLToPath } from 'node:url';
18
20
 
19
21
  import { sameOriginWrite } from '../http.ts';
20
22
 
@@ -50,3 +52,27 @@ describe('CSRF Origin guard — sameOriginWrite (DDR-105)', () => {
50
52
  expect(sameOriginWrite(post('not a url'))).toBe(false);
51
53
  });
52
54
  });
55
+
56
+ // phase-30 / DDR-120: the ai-activity bridge now projects `/_api/ai/*` POSTs
57
+ // onto room awareness, which crosses the hub to every connected peer. So a
58
+ // forged cross-origin POST to /start /heartbeat /end is no longer a harmless
59
+ // loopback banner — it injects a fake "<x> is editing <slug>" presence to all
60
+ // peers (the channel that drives the social save/publish decision). These three
61
+ // routes MUST carry the same `sameOriginWrite` guard as the other write routes.
62
+ // Source-level assertion (same "don't boot a server" rationale as above): pin
63
+ // the guard into each route block so an accidental removal fails CI.
64
+ describe('CSRF Origin guard — /_api/ai/* presence-bridge routes (phase-30)', () => {
65
+ const src = readFileSync(fileURLToPath(new URL('../http.ts', import.meta.url)), 'utf8');
66
+
67
+ for (const route of ['/_api/ai/start', '/_api/ai/heartbeat', '/_api/ai/end']) {
68
+ test(`${route} is wired with the sameOriginWrite CSRF guard`, () => {
69
+ // Slice from this route's key to the next route key, then assert the guard
70
+ // appears inside that block.
71
+ const start = src.indexOf(`'${route}':`);
72
+ expect(start).toBeGreaterThan(-1);
73
+ const after = src.indexOf("'/_api/", start + 1);
74
+ const block = src.slice(start, after === -1 ? undefined : after);
75
+ expect(block).toContain('sameOriginWrite(req)');
76
+ });
77
+ }
78
+ });
@@ -0,0 +1,103 @@
1
+ // Unit: soft editing-presence (Phase 30) — the `editing` awareness field at the
2
+ // hub trust boundary. Locking is deliberately NOT built (no lease/takeover); the
3
+ // `editing` field is a soft heads-up. Every foreign value is attacker-influenceable
4
+ // through the semi-trusted hub (DDR-054), so it MUST be validated for VALUE — a
5
+ // future / NaN / non-positive `since` is rejected so a hostile peer can't pin a
6
+ // permanent badge or poison a `Date.now() - since` age computation.
7
+
8
+ import { describe, expect, test } from 'bun:test';
9
+
10
+ import { createRoom, type RoomCallbacks } from '../collab/room.ts';
11
+ import { sanitizeForeignState } from '../use-collab.tsx';
12
+
13
+ const noopCallbacks: RoomCallbacks = {
14
+ seed() {},
15
+ persistJson() {},
16
+ persistBinary() {},
17
+ };
18
+
19
+ describe('sanitizeForeignState — editing field (Phase 30)', () => {
20
+ test('keeps a valid current `editing.since`', () => {
21
+ const now = Date.now();
22
+ const peer = sanitizeForeignState(3, { name: 'Anna', editing: { since: now } });
23
+ expect(peer?.editing).toEqual({ since: now });
24
+ });
25
+
26
+ test('absent editing → null (peer is not editing)', () => {
27
+ const peer = sanitizeForeignState(3, { name: 'Anna' });
28
+ expect(peer?.editing).toBeNull();
29
+ });
30
+
31
+ test('rejects a FUTURE since beyond the ±5s skew window (no permanent-badge pin)', () => {
32
+ const future = Date.now() + 60_000;
33
+ const peer = sanitizeForeignState(3, { name: 'Anna', editing: { since: future } });
34
+ expect(peer?.editing).toBeNull();
35
+ });
36
+
37
+ test('allows a near-future since inside the 5s skew window', () => {
38
+ const skew = Date.now() + 3_000;
39
+ const peer = sanitizeForeignState(3, { name: 'Anna', editing: { since: skew } });
40
+ expect(peer?.editing).toEqual({ since: skew });
41
+ });
42
+
43
+ test('rejects NaN / Infinity / non-positive since (no Date-math poison)', () => {
44
+ for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, 0, -1]) {
45
+ const peer = sanitizeForeignState(3, { name: 'Anna', editing: { since: bad } });
46
+ expect(peer?.editing).toBeNull();
47
+ }
48
+ });
49
+
50
+ test('rejects a non-object / malformed editing payload', () => {
51
+ expect(sanitizeForeignState(3, { name: 'Anna', editing: 'now' })?.editing).toBeNull();
52
+ expect(sanitizeForeignState(3, { name: 'Anna', editing: 12345 })?.editing).toBeNull();
53
+ expect(sanitizeForeignState(3, { name: 'Anna', editing: {} })?.editing).toBeNull();
54
+ expect(sanitizeForeignState(3, { name: 'Anna', editing: { since: '5' } })?.editing).toBeNull();
55
+ });
56
+
57
+ test('editing presence does not disturb the other sanitized fields', () => {
58
+ const peer = sanitizeForeignState(9, {
59
+ name: 'Anna',
60
+ cursor: { x: 1, y: 2 },
61
+ editing: { since: Date.now() },
62
+ });
63
+ expect(peer?.name).toBe('Anna');
64
+ expect(peer?.cursor).toEqual({ x: 1, y: 2 });
65
+ expect(peer?.editing).not.toBeNull();
66
+ });
67
+ });
68
+
69
+ describe('room.setAgentEditing — agent presence projected onto awareness (Phase 30)', () => {
70
+ test('projects an editing state that a remote peer can sanitize, then clears it', async () => {
71
+ const room = createRoom('agent-editing-slug', noopCallbacks);
72
+ const since = Date.now();
73
+
74
+ room.setAgentEditing({ name: 'Anna', since });
75
+ const states = Array.from(room.awareness.getStates().values());
76
+ expect(states.length).toBe(1);
77
+ // The projected state survives the client trust-boundary sanitizer with the
78
+ // editing field intact (name → re-derived color; since preserved).
79
+ const sanitized = sanitizeForeignState(1, states[0]);
80
+ expect(sanitized?.name).toBe('Anna');
81
+ expect(sanitized?.editing).toEqual({ since });
82
+
83
+ room.setAgentEditing(null);
84
+ expect(room.awareness.getStates().size).toBe(0);
85
+
86
+ await room.destroy();
87
+ });
88
+
89
+ test('re-setting the same {name, since} is a no-op (heartbeat re-emit guard)', async () => {
90
+ const room = createRoom('agent-editing-idempotent', noopCallbacks);
91
+ const since = Date.now();
92
+ let updates = 0;
93
+ room.awareness.on('update', () => {
94
+ updates += 1;
95
+ });
96
+
97
+ room.setAgentEditing({ name: 'Anna', since });
98
+ room.setAgentEditing({ name: 'Anna', since }); // identical → no second update
99
+ expect(updates).toBe(1);
100
+
101
+ await room.destroy();
102
+ });
103
+ });
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env bun
2
+ // Mock ACP agent for acp/bridge.test.ts. Stands in for `claude-agent-acp` so the
3
+ // bridge can be exercised without a real `claude` install. Implements the
4
+ // minimal handshake (initialize + session/new + session/prompt) and streams one
5
+ // `agent_message_chunk` whose text echoes whether ANTHROPIC_API_KEY survived
6
+ // into the child env — it MUST read `<unset>`, proving scrubAgentEnv (DDR-123
7
+ // guardrail #1) stripped it before spawn.
8
+
9
+ import { Readable, Writable } from 'node:stream';
10
+
11
+ import * as acp from '@agentclientprotocol/sdk';
12
+
13
+ const stream = acp.ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
14
+
15
+ acp
16
+ .agent({ name: 'mock-acp-agent' })
17
+ .onRequest('initialize', () => ({
18
+ protocolVersion: acp.PROTOCOL_VERSION,
19
+ agentCapabilities: { loadSession: false },
20
+ }))
21
+ .onRequest(
22
+ 'session/new',
23
+ (() => {
24
+ let n = 0;
25
+ return () => ({ sessionId: `mock-session-${++n}` });
26
+ })()
27
+ )
28
+ .onRequest('session/prompt', async (ctx) => {
29
+ // The per-request handler context exposes the client connection at `.client`.
30
+ await ctx.client.notify('session/update', {
31
+ sessionId: ctx.params.sessionId,
32
+ update: {
33
+ sessionUpdate: 'agent_message_chunk',
34
+ content: {
35
+ type: 'text',
36
+ text:
37
+ `apiKey=${process.env.ANTHROPIC_API_KEY ?? '<unset>'} ` +
38
+ `model=${process.env.ANTHROPIC_MODEL ?? '<unset>'} ` +
39
+ `thinking=${process.env.MAX_THINKING_TOKENS ?? '<unset>'}`,
40
+ },
41
+ },
42
+ });
43
+ return { stopReason: 'end_turn' };
44
+ })
45
+ .connect(stream);
Binary file
@@ -0,0 +1,106 @@
1
+ // Phase 29 (E4) — drafts (branches). List / create / switch against a REAL local
2
+ // git repo via the default isomorphic-git engine (no network). The vocabulary
3
+ // mapping (draft=branch, "Shared version"=main) lives in the UI; this proves the
4
+ // plumbing + the dash-led / duplicate / dirty-tree guards.
5
+
6
+ import { afterEach, beforeEach, expect, test } from 'bun:test';
7
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+
11
+ import { gitCheckout, gitCreateBranch, gitFoldDraft, gitListBranches } from '../git/service.ts';
12
+
13
+ let dir: string;
14
+
15
+ function sh(args: string[]): void {
16
+ const p = Bun.spawnSync(['git', ...args], {
17
+ cwd: dir,
18
+ env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' },
19
+ });
20
+ if (p.exitCode !== 0) throw new Error(`git ${args.join(' ')}: ${p.stderr.toString()}`);
21
+ }
22
+
23
+ beforeEach(() => {
24
+ dir = mkdtempSync(join(tmpdir(), 'maude-drafts-'));
25
+ sh(['init', '-q']);
26
+ sh(['config', 'user.email', 't@t.dev']);
27
+ sh(['config', 'user.name', 'Tester']);
28
+ writeFileSync(join(dir, 'a.txt'), 'hello\n');
29
+ sh(['add', '.']);
30
+ sh(['commit', '-q', '-m', 'init']);
31
+ });
32
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
33
+
34
+ test('lists the default branch as current', async () => {
35
+ const branches = await gitListBranches(dir);
36
+ expect(branches.length).toBe(1);
37
+ expect(branches[0].current).toBe(true);
38
+ expect(['main', 'master']).toContain(branches[0].name);
39
+ });
40
+
41
+ test('creates a new draft off HEAD and switches to it', async () => {
42
+ const res = await gitCreateBranch(dir, 'nav-redesign');
43
+ expect(res.ok).toBe(true);
44
+ expect(res.branch).toBe('nav-redesign');
45
+ const branches = await gitListBranches(dir);
46
+ const cur = branches.find((b) => b.current);
47
+ expect(cur?.name).toBe('nav-redesign');
48
+ expect(branches.map((b) => b.name).sort()).toContain('nav-redesign');
49
+ });
50
+
51
+ test('switches back to the shared version', async () => {
52
+ const before = await gitListBranches(dir);
53
+ const shared = before[0].name; // main/master
54
+ await gitCreateBranch(dir, 'pricing-experiment');
55
+ const res = await gitCheckout(dir, shared);
56
+ expect(res.ok).toBe(true);
57
+ const cur = (await gitListBranches(dir)).find((b) => b.current);
58
+ expect(cur?.name).toBe(shared);
59
+ });
60
+
61
+ test('rejects a dash-led / malformed draft name (argv-injection guard)', async () => {
62
+ const r1 = await gitCreateBranch(dir, '--upload-pack=evil');
63
+ expect(r1.ok).toBe(false);
64
+ const r2 = await gitCreateBranch(dir, 'has spaces');
65
+ expect(r2.ok).toBe(false);
66
+ });
67
+
68
+ test('fold: rejects a draft that does not exist', async () => {
69
+ const r = await gitFoldDraft(dir, 'ghost', undefined, {});
70
+ expect(r.ok).toBe(false);
71
+ expect(r.error).toMatch(/doesn't exist/i);
72
+ });
73
+
74
+ test('fold: rejects folding the Shared version into itself', async () => {
75
+ const shared = (await gitListBranches(dir)).find((b) => b.current)?.name as string;
76
+ const r = await gitFoldDraft(dir, shared, undefined, {});
77
+ expect(r.ok).toBe(false);
78
+ expect(r.error).toMatch(/already the Shared version/i);
79
+ });
80
+
81
+ test('fold: merges the draft into the Shared version locally; a tokenless publish leaves the draft intact', async () => {
82
+ const shared = (await gitListBranches(dir)).find((b) => b.current)?.name as string;
83
+ await gitCreateBranch(dir, 'nav'); // checks out 'nav'
84
+ writeFileSync(join(dir, 'b.txt'), 'draft work\n');
85
+ sh(['add', '.']);
86
+ sh(['commit', '-q', '-m', 'draft work']);
87
+ const r = await gitFoldDraft(dir, 'nav', undefined, {});
88
+ // No token/remote → the publish step returns authRequired, so the draft is NOT removed…
89
+ expect(r.ok).toBe(false);
90
+ expect(r.authRequired).toBe(true);
91
+ // …but the merge landed: the Shared version (now checked out) contains the draft's file…
92
+ expect(existsSync(join(dir, 'b.txt'))).toBe(true);
93
+ expect((await gitListBranches(dir)).find((b) => b.current)?.name).toBe(shared);
94
+ // …and the draft still exists (not deleted on a failed publish).
95
+ expect((await gitListBranches(dir)).some((b) => b.name === 'nav')).toBe(true);
96
+ });
97
+
98
+ test('rejects creating a draft that already exists', async () => {
99
+ await gitCreateBranch(dir, 'dupe');
100
+ // back to a fresh ref first so create (which checks out) has a clean base
101
+ const shared = (await gitListBranches(dir)).find((b) => !b.current);
102
+ if (shared) await gitCheckout(dir, shared.name);
103
+ const r = await gitCreateBranch(dir, 'dupe');
104
+ expect(r.ok).toBe(false);
105
+ expect(r.error).toMatch(/already exists/i);
106
+ });
@@ -0,0 +1,106 @@
1
+ // DDR-115 follow-up — Changes-panel canvas grouping (GitPanel variant A).
2
+ // Verifies the pure grouping helpers: a canvas unifies with its same-stem
3
+ // `.meta.json` + its slug-named `.annotations.svg`; loose / orphan files become
4
+ // standalone "other" units; sort + slug-matching (spaces, subdirs) are correct.
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import { baseName, buildUnits, canvasSlug, supportLabel } from '../client/panels/git-grouping.js';
9
+
10
+ const F = (path: string, status = 'modified') => ({ path, status });
11
+
12
+ describe('GitPanel canvas grouping (buildUnits)', () => {
13
+ test('unifies a canvas with its same-stem meta and slug-matched annotation', () => {
14
+ const files = [
15
+ F('.design/ui/Pricing.tsx'),
16
+ F('.design/ui/Pricing.meta.json'),
17
+ F('.design/ui-pricing.annotations.svg'),
18
+ ];
19
+ const { canvasUnits, otherUnits } = buildUnits(files, '.design');
20
+ expect(canvasUnits.length).toBe(1);
21
+ expect(otherUnits.length).toBe(0);
22
+ const u = canvasUnits[0];
23
+ expect(u.primary.path).toBe('.design/ui/Pricing.tsx');
24
+ expect(u.supporting.map((s) => s.path).sort()).toEqual([
25
+ '.design/ui-pricing.annotations.svg',
26
+ '.design/ui/Pricing.meta.json',
27
+ ]);
28
+ });
29
+
30
+ test('canvas with spaces + subdir matches its annotation slug', () => {
31
+ // fileSlug('.design/ui/Sync Hub Admin.tsx') => 'ui-sync_hub_admin'
32
+ expect(canvasSlug('.design/ui/Sync Hub Admin.tsx', '.design')).toBe('ui-sync_hub_admin');
33
+ const files = [
34
+ F('.design/ui/Sync Hub Admin.tsx'),
35
+ F('.design/ui-sync_hub_admin.annotations.svg'),
36
+ ];
37
+ const { canvasUnits } = buildUnits(files, '.design');
38
+ expect(canvasUnits.length).toBe(1);
39
+ expect(canvasUnits[0].supporting.map((s) => s.path)).toEqual([
40
+ '.design/ui-sync_hub_admin.annotations.svg',
41
+ ]);
42
+ });
43
+
44
+ test('a meta with no canvas in the changeset is a standalone other unit', () => {
45
+ const { canvasUnits, otherUnits } = buildUnits([F('.design/ui/Orphan.meta.json')], '.design');
46
+ expect(canvasUnits.length).toBe(0);
47
+ expect(otherUnits.length).toBe(1);
48
+ expect(otherUnits[0].primary.path).toBe('.design/ui/Orphan.meta.json');
49
+ expect(otherUnits[0].supporting.length).toBe(0);
50
+ });
51
+
52
+ test('an annotation with no canvas in the changeset is a standalone other unit', () => {
53
+ const { canvasUnits, otherUnits } = buildUnits(
54
+ [F('.design/ui-ghost.annotations.svg')],
55
+ '.design'
56
+ );
57
+ expect(canvasUnits.length).toBe(0);
58
+ expect(otherUnits).toHaveLength(1);
59
+ expect(otherUnits[0].primary.path).toBe('.design/ui-ghost.annotations.svg');
60
+ });
61
+
62
+ test('loose files (config, DS tokens) are other units, never grouped under a canvas', () => {
63
+ const files = [
64
+ F('.design/ui/Pricing.tsx'),
65
+ F('.design/config.json'),
66
+ F('.design/system/project/tokens.css'),
67
+ ];
68
+ const { canvasUnits, otherUnits } = buildUnits(files, '.design');
69
+ expect(canvasUnits.map((u) => u.primary.path)).toEqual(['.design/ui/Pricing.tsx']);
70
+ expect(canvasUnits[0].supporting.length).toBe(0);
71
+ expect(otherUnits.map((u) => u.primary.path).sort()).toEqual([
72
+ '.design/config.json',
73
+ '.design/system/project/tokens.css',
74
+ ]);
75
+ });
76
+
77
+ test('multiple canvases are sorted by display name; each keeps its own sidecars', () => {
78
+ const files = [
79
+ F('.design/ui/Zebra.tsx'),
80
+ F('.design/ui/Zebra.meta.json'),
81
+ F('.design/ui/Alpha.tsx'),
82
+ F('.design/ui/Alpha.meta.json'),
83
+ ];
84
+ const { canvasUnits } = buildUnits(files, '.design');
85
+ expect(canvasUnits.map((u) => baseName(u.primary.path))).toEqual(['Alpha', 'Zebra']);
86
+ expect(canvasUnits[0].supporting.map((s) => s.path)).toEqual(['.design/ui/Alpha.meta.json']);
87
+ expect(canvasUnits[1].supporting.map((s) => s.path)).toEqual(['.design/ui/Zebra.meta.json']);
88
+ });
89
+
90
+ test('a meta does NOT cross-attach to a different-stem canvas (Pricing vs Pricing v3)', () => {
91
+ const files = [
92
+ F('.design/ui/Pricing.tsx'),
93
+ F('.design/ui/Pricing v3.meta.json'), // belongs to a (clean, absent) "Pricing v3" canvas
94
+ ];
95
+ const { canvasUnits, otherUnits } = buildUnits(files, '.design');
96
+ expect(canvasUnits[0].supporting.length).toBe(0); // not grabbed by "Pricing"
97
+ expect(otherUnits.map((u) => u.primary.path)).toEqual(['.design/ui/Pricing v3.meta.json']);
98
+ });
99
+
100
+ test('supportLabel + baseName render friendly names', () => {
101
+ expect(supportLabel('.design/ui/Pricing.meta.json')).toBe('Layout & settings');
102
+ expect(supportLabel('.design/ui-pricing.annotations.svg')).toBe('Annotations');
103
+ expect(baseName('.design/ui/Sync Hub Admin.tsx')).toBe('Sync Hub Admin');
104
+ expect(baseName('.design/ui-pricing.annotations.svg')).toBe('ui-pricing');
105
+ });
106
+ });