@1agh/maude 0.30.0 → 0.32.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 (93) 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 +788 -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 +796 -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 +598 -0
  23. package/apps/studio/client/panels/ReadinessList.jsx +189 -0
  24. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  25. package/apps/studio/client/panels/acp-runtime.js +286 -0
  26. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  27. package/apps/studio/client/panels/git-grouping.js +86 -0
  28. package/apps/studio/client/styles/0-reset.css +4 -0
  29. package/apps/studio/client/styles/3-shell-maude.css +625 -3
  30. package/apps/studio/client/styles/5-maude-overrides.css +25 -0
  31. package/apps/studio/client/styles/6-acp-chat.css +821 -0
  32. package/apps/studio/client/styles/_index.css +2 -0
  33. package/apps/studio/client/tour/collab-tour.js +61 -0
  34. package/apps/studio/client/tour/overlay.jsx +11 -1
  35. package/apps/studio/client/whats-new.jsx +25 -10
  36. package/apps/studio/collab/registry.ts +13 -0
  37. package/apps/studio/collab/room.ts +36 -0
  38. package/apps/studio/cursors-overlay.tsx +17 -1
  39. package/apps/studio/dist/client.bundle.js +29097 -1534
  40. package/apps/studio/dist/comment-mount.js +4 -2
  41. package/apps/studio/dist/styles.css +5816 -1614
  42. package/apps/studio/git/endpoints.ts +338 -0
  43. package/apps/studio/git/service.ts +1334 -0
  44. package/apps/studio/git/watch.ts +97 -0
  45. package/apps/studio/github/endpoints.ts +358 -0
  46. package/apps/studio/github/service.ts +231 -0
  47. package/apps/studio/github/token.ts +53 -0
  48. package/apps/studio/hmr-broadcast.ts +9 -2
  49. package/apps/studio/http.ts +399 -1
  50. package/apps/studio/participants-chrome.tsx +69 -9
  51. package/apps/studio/paths.ts +12 -0
  52. package/apps/studio/readiness.ts +220 -0
  53. package/apps/studio/scaffold-design.ts +57 -0
  54. package/apps/studio/server.ts +65 -2
  55. package/apps/studio/sync/agent.ts +81 -1
  56. package/apps/studio/sync/codec.ts +24 -0
  57. package/apps/studio/sync/cold-start.ts +40 -0
  58. package/apps/studio/sync/hub-link.ts +137 -0
  59. package/apps/studio/test/acp-bridge.test.ts +127 -0
  60. package/apps/studio/test/acp-env.test.ts +65 -0
  61. package/apps/studio/test/acp-origin-gate.test.ts +95 -0
  62. package/apps/studio/test/acp-transcript.test.ts +112 -0
  63. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  64. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  65. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  66. package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
  67. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  68. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  69. package/apps/studio/test/csrf-write-guard.test.ts +26 -0
  70. package/apps/studio/test/editing-presence.test.ts +103 -0
  71. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  72. package/apps/studio/test/git-api.test.ts +0 -0
  73. package/apps/studio/test/git-branches.test.ts +106 -0
  74. package/apps/studio/test/git-grouping.test.ts +106 -0
  75. package/apps/studio/test/git-watch.test.ts +97 -0
  76. package/apps/studio/test/github-api.test.ts +465 -0
  77. package/apps/studio/test/hub-link.test.ts +69 -0
  78. package/apps/studio/test/participants-chrome.test.ts +36 -1
  79. package/apps/studio/test/readiness.test.ts +127 -0
  80. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  81. package/apps/studio/test/sync-cold-start.test.ts +61 -1
  82. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  83. package/apps/studio/tool-palette.tsx +18 -9
  84. package/apps/studio/use-chrome-visibility.tsx +66 -0
  85. package/apps/studio/use-collab.tsx +414 -187
  86. package/apps/studio/whats-new.json +82 -0
  87. package/apps/studio/ws.ts +44 -1
  88. package/cli/commands/design.mjs +1 -0
  89. package/cli/lib/gitignore-block.mjs +16 -3
  90. package/cli/lib/gitignore-block.test.mjs +13 -1
  91. package/package.json +11 -9
  92. package/plugins/design/dependencies.json +17 -0
  93. package/plugins/design/templates/_shell.html +30 -8
@@ -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
+ });
@@ -0,0 +1,97 @@
1
+ // Phase 27 Task 5 (E2) — live dirty-state broadcast.
2
+ //
3
+ // Unit: the `isVersionable` gate. Integration: boot a real server against a git
4
+ // sandbox, open the inspector WS, edit a canvas `.tsx`, and prove a `git-status`
5
+ // message with the changed file arrives — the "badge increments within 500 ms"
6
+ // acceptance (generous 2 s ceiling for fs.watch + debounce + git on CI).
7
+
8
+ import { describe, expect, test } from 'bun:test';
9
+ import { writeFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+
12
+ import { __testing } from '../git/watch.ts';
13
+ import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
14
+
15
+ describe('git/watch — isVersionable', () => {
16
+ const { isVersionable } = __testing;
17
+ test('matches versionable design content', () => {
18
+ for (const p of [
19
+ 'ui/Pricing.tsx',
20
+ 'ui/Pricing.css',
21
+ 'ui/Pricing.meta.json',
22
+ 'ui-pricing.annotations.svg',
23
+ '_comments/ui-pricing.json',
24
+ '_annotations/ui-pricing.svg',
25
+ ]) {
26
+ expect(isVersionable(p)).toBe(true);
27
+ }
28
+ });
29
+ test('ignores non-versionable noise', () => {
30
+ for (const p of ['ui/fixture.html', 'notes.txt', '_server.json', 'README.md']) {
31
+ expect(isVersionable(p)).toBe(false);
32
+ }
33
+ });
34
+ });
35
+
36
+ function sh(dir: string, args: string[]) {
37
+ Bun.spawnSync(['git', ...args], {
38
+ cwd: dir,
39
+ env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' },
40
+ });
41
+ }
42
+
43
+ describe('git/watch — live broadcast', () => {
44
+ test('editing a canvas .tsx broadcasts git-status with the changed file', async () => {
45
+ const { root, designRoot } = makeSandbox();
46
+ sh(root, ['init', '-q', '-b', 'main']);
47
+ sh(root, ['config', 'user.email', 'tester@maude.local']);
48
+ sh(root, ['config', 'user.name', 'Tester']);
49
+ sh(root, ['config', 'commit.gpgsign', 'false']);
50
+ sh(root, ['add', '-A']);
51
+ sh(root, ['commit', '-qm', 'seed']);
52
+
53
+ const port = nextPort();
54
+ const proc = await bootServer(root, port);
55
+ let socket: WebSocket | null = null;
56
+ try {
57
+ const messages: Array<{ type: string; payload?: { files?: Array<{ path: string }> } }> = [];
58
+ const sock = new WebSocket(`ws://localhost:${port}/_ws`);
59
+ socket = sock;
60
+ await new Promise<void>((res, rej) => {
61
+ sock.addEventListener('open', () => res());
62
+ sock.addEventListener('error', () => rej(new Error('ws error')));
63
+ setTimeout(() => rej(new Error('ws open timeout')), 2000);
64
+ });
65
+ sock.addEventListener('message', (ev) => {
66
+ try {
67
+ messages.push(JSON.parse(String(ev.data)));
68
+ } catch {
69
+ /* ignore non-JSON */
70
+ }
71
+ });
72
+
73
+ // Edit a NEW canvas .tsx — triggers fs:any → gitWatch → git-status.
74
+ writeFileSync(join(designRoot, 'ui', 'Live.tsx'), 'export default () => null\n');
75
+
76
+ // Poll for the broadcast (debounce 300 ms + git status latency).
77
+ const deadline = Date.now() + 2000;
78
+ let hit: (typeof messages)[number] | undefined;
79
+ while (Date.now() < deadline) {
80
+ hit = messages.find(
81
+ (m) =>
82
+ m.type === 'git-status' && m.payload?.files?.some((f) => f.path.endsWith('Live.tsx'))
83
+ );
84
+ if (hit) break;
85
+ await Bun.sleep(50);
86
+ }
87
+ expect(hit).toBeDefined();
88
+ } finally {
89
+ try {
90
+ socket?.close();
91
+ } catch {
92
+ /* ignore */
93
+ }
94
+ await killProc(proc);
95
+ }
96
+ }, 10_000);
97
+ });