@1agh/maude 0.37.0 → 0.38.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.
- package/README.md +2 -0
- package/apps/studio/acp/bootstrap-brief.ts +93 -0
- package/apps/studio/acp/bridge.ts +114 -1
- package/apps/studio/acp/index.ts +184 -21
- package/apps/studio/acp/plugin-bootstrap.ts +115 -0
- package/apps/studio/acp/transcript.ts +36 -3
- package/apps/studio/activity.ts +45 -0
- package/apps/studio/api.ts +265 -47
- package/apps/studio/bin/_ensure-browser.mjs +305 -0
- package/apps/studio/bin/ensure-browser.sh +26 -0
- package/apps/studio/bin/screenshot.sh +33 -8
- package/apps/studio/bin/smoke.sh +46 -0
- package/apps/studio/canvas-edit.ts +422 -6
- package/apps/studio/canvas-lib.tsx +48 -0
- package/apps/studio/canvas-shell.tsx +684 -12
- package/apps/studio/client/app.jsx +683 -33
- package/apps/studio/client/panels/ChatPanel.jsx +593 -31
- package/apps/studio/client/panels/acp-runtime.js +227 -70
- package/apps/studio/client/panels/chat-context.js +124 -0
- package/apps/studio/client/panels/slash-commands.js +147 -0
- package/apps/studio/client/styles/3-shell-maude.css +15 -0
- package/apps/studio/client/styles/6-acp-chat.css +244 -0
- package/apps/studio/commands/reorder-command.ts +77 -0
- package/apps/studio/config.schema.json +6 -0
- package/apps/studio/dist/client.bundle.js +25 -53617
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/hmr-broadcast.ts +27 -19
- package/apps/studio/http.ts +168 -26
- package/apps/studio/inspect.ts +108 -1
- package/apps/studio/paths.ts +32 -0
- package/apps/studio/readiness.ts +79 -30
- package/apps/studio/server.ts +11 -2
- package/apps/studio/test/acp-activity.test.ts +154 -0
- package/apps/studio/test/acp-ai-activity.test.ts +182 -0
- package/apps/studio/test/acp-bootstrap-brief.test.ts +167 -0
- package/apps/studio/test/acp-commands.test.ts +108 -0
- package/apps/studio/test/acp-origin-gate.test.ts +64 -1
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +89 -0
- package/apps/studio/test/acp-session-plugins.test.ts +132 -0
- package/apps/studio/test/acp-transcript.test.ts +53 -0
- package/apps/studio/test/active-state.test.ts +41 -0
- package/apps/studio/test/canvas-freshness-deps.test.ts +64 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
- package/apps/studio/test/canvas-reorder.test.ts +211 -0
- package/apps/studio/test/chat-context.test.ts +129 -0
- package/apps/studio/test/csrf-write-guard.test.ts +24 -0
- package/apps/studio/test/edit-suppress.test.ts +170 -0
- package/apps/studio/test/ensure-browser.test.ts +92 -0
- package/apps/studio/test/fixtures/mock-acp-agent-commands.mjs +44 -0
- package/apps/studio/test/hmr-broadcast.test.ts +44 -0
- package/apps/studio/test/inspect-selections.test.ts +219 -0
- package/apps/studio/test/paths.test.ts +57 -0
- package/apps/studio/test/readiness.test.ts +83 -13
- package/apps/studio/test/reorder-api.test.ts +210 -0
- package/apps/studio/test/slash-commands.test.ts +117 -0
- package/apps/studio/undo-stack.ts +6 -0
- package/apps/studio/whats-new.json +45 -0
- package/apps/studio/ws.ts +37 -0
- package/cli/commands/design.mjs +1 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +3 -3
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Screenshot-browser resolver (bundled-screenshots feature). Covers the pure
|
|
2
|
+
// resolution priority; the actual download (priority 5) is exercised only when no
|
|
3
|
+
// browser exists on the host — untestable in CI where system Chrome / a Playwright
|
|
4
|
+
// headless-shell are usually present, so we assert `download:false` never fetches.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { CFT_VERSION_RE, isTrustedDownloadUrl, resolveBrowser } from '../bin/_ensure-browser.mjs';
|
|
12
|
+
|
|
13
|
+
function withEnv<T>(patch: Record<string, string | undefined>, fn: () => T): T {
|
|
14
|
+
const saved: Record<string, string | undefined> = {};
|
|
15
|
+
for (const k of Object.keys(patch)) {
|
|
16
|
+
saved[k] = process.env[k];
|
|
17
|
+
if (patch[k] === undefined) delete process.env[k];
|
|
18
|
+
else process.env[k] = patch[k];
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
return fn();
|
|
22
|
+
} finally {
|
|
23
|
+
for (const k of Object.keys(patch)) {
|
|
24
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
25
|
+
else process.env[k] = saved[k];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('resolveBrowser — resolution priority', () => {
|
|
31
|
+
test('AGENT_BROWSER_EXECUTABLE_PATH override wins', async () => {
|
|
32
|
+
const r = await withEnv({ AGENT_BROWSER_EXECUTABLE_PATH: import.meta.path }, () =>
|
|
33
|
+
resolveBrowser({ download: false })
|
|
34
|
+
);
|
|
35
|
+
expect(r.source).toBe('override');
|
|
36
|
+
expect(r.path).toBe(import.meta.path);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('a cached chrome-headless-shell in MAUDE_BROWSERS_DIR resolves before system Chrome', async () => {
|
|
40
|
+
const dir = mkdtempSync(join(tmpdir(), 'maude-browsers-'));
|
|
41
|
+
const sub = join(dir, 'chrome-headless-shell-1.2.3-mac-arm64');
|
|
42
|
+
mkdirSync(sub, { recursive: true });
|
|
43
|
+
const exeName =
|
|
44
|
+
process.platform === 'win32' ? 'chrome-headless-shell.exe' : 'chrome-headless-shell';
|
|
45
|
+
const exe = join(sub, exeName);
|
|
46
|
+
writeFileSync(exe, '#!/bin/sh\n');
|
|
47
|
+
try {
|
|
48
|
+
const r = await withEnv(
|
|
49
|
+
{
|
|
50
|
+
MAUDE_BROWSERS_DIR: dir,
|
|
51
|
+
AGENT_BROWSER_EXECUTABLE_PATH: undefined,
|
|
52
|
+
MAUDE_BROWSER_EXECUTABLE: undefined,
|
|
53
|
+
},
|
|
54
|
+
() => resolveBrowser({ download: false })
|
|
55
|
+
);
|
|
56
|
+
expect(r.source).toBe('cache');
|
|
57
|
+
expect(r.path).toBe(exe);
|
|
58
|
+
} finally {
|
|
59
|
+
rmSync(dir, { recursive: true, force: true });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('download:false never fetches (source is never "downloaded")', async () => {
|
|
64
|
+
const r = await resolveBrowser({ download: false });
|
|
65
|
+
expect(r.source).not.toBe('downloaded');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('download hardening (DDR-144 security fixes)', () => {
|
|
70
|
+
test('isTrustedDownloadUrl accepts only https on the allowlisted CDN hosts (F2)', () => {
|
|
71
|
+
expect(
|
|
72
|
+
isTrustedDownloadUrl('https://storage.googleapis.com/chrome-for-testing-public/x.zip')
|
|
73
|
+
).toBe(true);
|
|
74
|
+
expect(isTrustedDownloadUrl('https://googlechromelabs.github.io/x.json')).toBe(true);
|
|
75
|
+
// plaintext scheme (MITM-able) rejected
|
|
76
|
+
expect(isTrustedDownloadUrl('http://storage.googleapis.com/x.zip')).toBe(false);
|
|
77
|
+
// foreign host (manifest tampering / redirect) rejected
|
|
78
|
+
expect(isTrustedDownloadUrl('https://evil.example.com/x.zip')).toBe(false);
|
|
79
|
+
// garbage rejected, no throw
|
|
80
|
+
expect(isTrustedDownloadUrl('not a url')).toBe(false);
|
|
81
|
+
expect(isTrustedDownloadUrl('')).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('CFT_VERSION_RE accepts real versions, rejects shell-injection payloads (F3)', () => {
|
|
85
|
+
expect(CFT_VERSION_RE.test('150.0.7871.46')).toBe(true);
|
|
86
|
+
expect(CFT_VERSION_RE.test('120')).toBe(true);
|
|
87
|
+
// the PowerShell -Command break-out attempt
|
|
88
|
+
expect(CFT_VERSION_RE.test("1.0'; Remove-Item C:\\ -Recurse #")).toBe(false);
|
|
89
|
+
expect(CFT_VERSION_RE.test('$(rm -rf /)')).toBe(false);
|
|
90
|
+
expect(CFT_VERSION_RE.test('../../etc')).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Mock ACP agent variant that publishes an `available_commands_update` the moment
|
|
3
|
+
// a session is created (before the prompt) — stands in for Claude Code's command
|
|
4
|
+
// catalogue so acp-commands.test.ts can exercise the warm-up + broadcast path
|
|
5
|
+
// WITHOUT a real `claude`. Also answers a prompt so prompt-based paths still work.
|
|
6
|
+
|
|
7
|
+
import { Readable, Writable } from 'node:stream';
|
|
8
|
+
|
|
9
|
+
import * as acp from '@agentclientprotocol/sdk';
|
|
10
|
+
|
|
11
|
+
const stream = acp.ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
|
|
12
|
+
|
|
13
|
+
let n = 0;
|
|
14
|
+
|
|
15
|
+
acp
|
|
16
|
+
.agent({ name: 'mock-acp-agent-commands' })
|
|
17
|
+
.onRequest('initialize', () => ({
|
|
18
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
19
|
+
agentCapabilities: { loadSession: false },
|
|
20
|
+
}))
|
|
21
|
+
.onRequest('session/new', async (ctx) => {
|
|
22
|
+
const sessionId = `mock-session-${++n}`;
|
|
23
|
+
// Publish the command catalogue on session creation — the warm-up path relies
|
|
24
|
+
// on this arriving without a prompt.
|
|
25
|
+
await ctx.client.notify('session/update', {
|
|
26
|
+
sessionId,
|
|
27
|
+
update: {
|
|
28
|
+
sessionUpdate: 'available_commands_update',
|
|
29
|
+
availableCommands: [
|
|
30
|
+
{ name: 'design:edit', description: 'Iterate on the active canvas' },
|
|
31
|
+
{ name: 'flow:plan', description: 'Create a plan' },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
return { sessionId };
|
|
36
|
+
})
|
|
37
|
+
.onRequest('session/prompt', async (ctx) => {
|
|
38
|
+
await ctx.client.notify('session/update', {
|
|
39
|
+
sessionId: ctx.params.sessionId,
|
|
40
|
+
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'ok' } },
|
|
41
|
+
});
|
|
42
|
+
return { stopReason: 'end_turn' };
|
|
43
|
+
})
|
|
44
|
+
.connect(stream);
|
|
@@ -95,6 +95,50 @@ describe('hmr-broadcast / debouncing', () => {
|
|
|
95
95
|
});
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
describe('hmr-broadcast / multi-file bursts (RC4)', () => {
|
|
99
|
+
test('a burst touching two canvases broadcasts one message PER file', async () => {
|
|
100
|
+
// The old single-slot pendingMsg kept only the LAST file of a <50ms burst —
|
|
101
|
+
// the other open canvas never got its module reload and sat stale until a
|
|
102
|
+
// manual hard refresh (rca/issue-canvas-hmr-optimistic-update-consistency).
|
|
103
|
+
const ctx = mkCtx();
|
|
104
|
+
const got: HmrMessage[] = [];
|
|
105
|
+
const h = createHmrBroadcaster(ctx, (m) => got.push(m));
|
|
106
|
+
ctx.bus.emit('fs:any', 'ui/A.tsx');
|
|
107
|
+
ctx.bus.emit('fs:any', 'ui/B.tsx');
|
|
108
|
+
await awaitNextFlush();
|
|
109
|
+
expect(got).toHaveLength(2);
|
|
110
|
+
expect(new Set(got.map((m) => m.file))).toEqual(new Set(['ui/A.tsx', 'ui/B.tsx']));
|
|
111
|
+
for (const m of got) expect(m.mode).toBe('module');
|
|
112
|
+
h.stop();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('a pending hard supersedes the whole per-file queue', async () => {
|
|
116
|
+
const ctx = mkCtx();
|
|
117
|
+
const got: HmrMessage[] = [];
|
|
118
|
+
const h = createHmrBroadcaster(ctx, (m) => got.push(m));
|
|
119
|
+
ctx.bus.emit('fs:any', 'ui/A.tsx');
|
|
120
|
+
ctx.bus.emit('fs:any', 'ui/B.tsx');
|
|
121
|
+
ctx.bus.emit('fs:any', '_lib/canvas-lib.tsx');
|
|
122
|
+
await awaitNextFlush();
|
|
123
|
+
expect(got).toHaveLength(1);
|
|
124
|
+
expect(got[0]?.mode).toBe('hard');
|
|
125
|
+
h.stop();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('same-file meta echo never downgrades a queued module reload', async () => {
|
|
129
|
+
const ctx = mkCtx();
|
|
130
|
+
const got: HmrMessage[] = [];
|
|
131
|
+
const h = createHmrBroadcaster(ctx, (m) => got.push(m));
|
|
132
|
+
ctx.bus.emit('fs:any', 'ui/A.tsx');
|
|
133
|
+
ctx.bus.emit('fs:any', 'ui/A.meta.json');
|
|
134
|
+
await awaitNextFlush();
|
|
135
|
+
const forA = got.filter((m) => m.file === 'ui/A.tsx');
|
|
136
|
+
expect(forA).toHaveLength(1);
|
|
137
|
+
expect(forA[0]?.mode).toBe('module');
|
|
138
|
+
h.stop();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
98
142
|
describe('hmr-broadcast / stop', () => {
|
|
99
143
|
test('stop() prevents further broadcasts', async () => {
|
|
100
144
|
const ctx = mkCtx();
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// feature-acp-context-hardening — per-canvas selection memory (`selections`
|
|
2
|
+
// map) with the `selected` mirror invariant, restore-on-switch, the mtime
|
|
3
|
+
// drift gate, html size cap on parked entries, and open_tabs GC.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
import { type Context, createBus } from '../context.ts';
|
|
9
|
+
import { createInspect, type SelectedElement } from '../inspect.ts';
|
|
10
|
+
import { makeSandbox } from './_helpers.ts';
|
|
11
|
+
|
|
12
|
+
function mkCtx(root: string, designRoot: string): Context {
|
|
13
|
+
return {
|
|
14
|
+
cfg: {} as Context['cfg'],
|
|
15
|
+
projectLabel: 'test',
|
|
16
|
+
bus: createBus(),
|
|
17
|
+
paths: {
|
|
18
|
+
repoRoot: root,
|
|
19
|
+
designRel: '.design',
|
|
20
|
+
designRoot,
|
|
21
|
+
serverInfoFile: join(designRoot, '_server.json'),
|
|
22
|
+
activeFile: join(designRoot, '_active.json'),
|
|
23
|
+
commentsDir: join(designRoot, '_comments'),
|
|
24
|
+
canvasStateDir: join(designRoot, '_canvas-state'),
|
|
25
|
+
historyDir: join(designRoot, '_history'),
|
|
26
|
+
tokensUrlRel: '',
|
|
27
|
+
systemDirRel: 'system',
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const A = '.design/ui/fixture.html'; // exists via makeSandbox
|
|
33
|
+
const B = '.design/ui/second.tsx';
|
|
34
|
+
|
|
35
|
+
function selFor(file: string, text = 'hello') {
|
|
36
|
+
return {
|
|
37
|
+
file,
|
|
38
|
+
selector: 'h1',
|
|
39
|
+
tag: 'h1',
|
|
40
|
+
classes: '',
|
|
41
|
+
text,
|
|
42
|
+
dom_path: ['html', 'body', 'h1'],
|
|
43
|
+
bounds: { x: 0, y: 0, w: 100, h: 24 },
|
|
44
|
+
html: `<h1>${text}</h1>`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function mkRig() {
|
|
49
|
+
const { root, designRoot } = makeSandbox();
|
|
50
|
+
await Bun.write(join(root, B), 'export default function Second() { return <h1>B</h1>; }');
|
|
51
|
+
const ctx = mkCtx(root, designRoot);
|
|
52
|
+
const inspect = createInspect(ctx, async () => []);
|
|
53
|
+
return { root, designRoot, ctx, inspect };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const asOne = (v: unknown): SelectedElement => v as SelectedElement;
|
|
57
|
+
|
|
58
|
+
describe('per-canvas selections — restore on switch', () => {
|
|
59
|
+
test('selection survives a canvas switch and comes back on return', async () => {
|
|
60
|
+
const { inspect } = await mkRig();
|
|
61
|
+
inspect.setActive(A);
|
|
62
|
+
inspect.setSelected(selFor(A));
|
|
63
|
+
expect(asOne(inspect.state.selected).selector).toBe('h1');
|
|
64
|
+
|
|
65
|
+
inspect.setActive(B);
|
|
66
|
+
expect(inspect.state.selected).toBeNull(); // B has no memory
|
|
67
|
+
|
|
68
|
+
inspect.setActive(A);
|
|
69
|
+
const restored = asOne(inspect.state.selected);
|
|
70
|
+
expect(restored).not.toBeNull();
|
|
71
|
+
expect(restored.selector).toBe('h1');
|
|
72
|
+
expect(restored.file).toBe(A); // SEL_VALID (`selected.file === active`) holds
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('parked (non-active) entries carry html: "" — size cap', async () => {
|
|
76
|
+
const { inspect } = await mkRig();
|
|
77
|
+
inspect.setActive(A);
|
|
78
|
+
inspect.setSelected(selFor(A));
|
|
79
|
+
// Active canvas's write-through keeps the rich copy…
|
|
80
|
+
expect(asOne(inspect.state.selections['ui/fixture']).html).toContain('<h1>');
|
|
81
|
+
inspect.setActive(B);
|
|
82
|
+
// …but parking on switch-away strips it.
|
|
83
|
+
expect(asOne(inspect.state.selections['ui/fixture']).html).toBe('');
|
|
84
|
+
inspect.setActive(A);
|
|
85
|
+
expect(asOne(inspect.state.selected).html).toBe('');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('mirror invariant: selected === selections[activeSlug] after select', async () => {
|
|
89
|
+
const { inspect } = await mkRig();
|
|
90
|
+
inspect.setActive(A);
|
|
91
|
+
inspect.setSelected(selFor(A));
|
|
92
|
+
expect(inspect.state.selections['ui/fixture']).toBe(inspect.state.selected);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('a cross-canvas plant (selection.file != active) is NOT stored (attacker F2 residual)', async () => {
|
|
96
|
+
// An active untrusted canvas posts a selection claiming a DIFFERENT canvas's
|
|
97
|
+
// file — it must not land in that canvas's slot for later agent delivery.
|
|
98
|
+
const { inspect } = await mkRig();
|
|
99
|
+
inspect.setActive(A);
|
|
100
|
+
inspect.setSelected(selFor(B, 'planted')); // A is active, payload claims B
|
|
101
|
+
expect(inspect.state.selections['ui/second']).toBeUndefined();
|
|
102
|
+
expect(inspect.state.selections['ui/fixture']).toBeUndefined();
|
|
103
|
+
// Later opening B must not resurrect the plant.
|
|
104
|
+
inspect.setActive(B);
|
|
105
|
+
expect(inspect.state.selected).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('single-entry array still collapses to a bare object (Phase 4.1 contract)', async () => {
|
|
109
|
+
const { inspect } = await mkRig();
|
|
110
|
+
inspect.setActive(A);
|
|
111
|
+
inspect.setSelected([selFor(A)]);
|
|
112
|
+
expect(Array.isArray(inspect.state.selected)).toBe(false);
|
|
113
|
+
expect(asOne(inspect.state.selected).tag).toBe('h1');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('per-canvas selections — drift gate', () => {
|
|
118
|
+
test('canvas edited while parked → restored selection is stale', async () => {
|
|
119
|
+
const { root, inspect } = await mkRig();
|
|
120
|
+
inspect.setActive(A);
|
|
121
|
+
inspect.setSelected(selFor(A));
|
|
122
|
+
const stamped = asOne(inspect.state.selected).canvas_mtime;
|
|
123
|
+
expect(stamped).toBeGreaterThan(0);
|
|
124
|
+
|
|
125
|
+
inspect.setActive(B);
|
|
126
|
+
// Another writer edits canvas A while it's parked (ensure mtime moves).
|
|
127
|
+
await Bun.sleep(5);
|
|
128
|
+
await Bun.write(join(root, A), '<!doctype html><html><body><h1>changed</h1></body></html>');
|
|
129
|
+
|
|
130
|
+
inspect.setActive(A);
|
|
131
|
+
expect(asOne(inspect.state.selected).stale).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('untouched canvas restores without the stale flag', async () => {
|
|
135
|
+
const { inspect } = await mkRig();
|
|
136
|
+
inspect.setActive(A);
|
|
137
|
+
inspect.setSelected(selFor(A));
|
|
138
|
+
inspect.setActive(B);
|
|
139
|
+
inspect.setActive(A);
|
|
140
|
+
expect(asOne(inspect.state.selected).stale).toBeUndefined();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('per-canvas selections — lifecycle', () => {
|
|
145
|
+
test('explicit deselect clears the active canvas memory', async () => {
|
|
146
|
+
const { inspect } = await mkRig();
|
|
147
|
+
inspect.setActive(A);
|
|
148
|
+
inspect.setSelected(selFor(A));
|
|
149
|
+
inspect.setSelected(null);
|
|
150
|
+
expect(inspect.state.selections['ui/fixture']).toBeUndefined();
|
|
151
|
+
inspect.setActive(B);
|
|
152
|
+
inspect.setActive(A);
|
|
153
|
+
expect(inspect.state.selected).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('single-canvas shell ordering (tabs BEFORE active) never GCs the outgoing canvas', async () => {
|
|
157
|
+
// The shell replaces the tab and sends `tabs` with ONLY the incoming canvas
|
|
158
|
+
// before `active` parks the outgoing one — the exact wire order of app.jsx
|
|
159
|
+
// openTab effects. The outgoing canvas's memory must survive the round trip.
|
|
160
|
+
const { inspect } = await mkRig();
|
|
161
|
+
inspect.setActive(A);
|
|
162
|
+
inspect.setOpenTabs([A]);
|
|
163
|
+
inspect.setSelected(selFor(A));
|
|
164
|
+
|
|
165
|
+
inspect.setOpenTabs([B]); // switch A→B: tabs first…
|
|
166
|
+
inspect.setActive(B); // …then active (parks A)
|
|
167
|
+
expect(inspect.state.selections['ui/fixture']).toBeDefined();
|
|
168
|
+
|
|
169
|
+
inspect.setOpenTabs([A]); // switch back B→A: tabs first…
|
|
170
|
+
inspect.setActive(A); // …then active (restores A)
|
|
171
|
+
expect(asOne(inspect.state.selected).selector).toBe('h1');
|
|
172
|
+
expect(asOne(inspect.state.selected).file).toBe(A);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('closing a tab GCs its parked selection', async () => {
|
|
176
|
+
const { inspect } = await mkRig();
|
|
177
|
+
inspect.setActive(A);
|
|
178
|
+
inspect.setSelected(selFor(A));
|
|
179
|
+
inspect.setActive(B); // parks A
|
|
180
|
+
inspect.setOpenTabs([B]); // A closed
|
|
181
|
+
expect(inspect.state.selections['ui/fixture']).toBeUndefined();
|
|
182
|
+
expect(Object.keys(inspect.state.selections)).toHaveLength(0);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('save/load round-trips selections; legacy file without them loads as {}', async () => {
|
|
186
|
+
const { designRoot, ctx, inspect } = await mkRig();
|
|
187
|
+
inspect.setActive(A);
|
|
188
|
+
inspect.setSelected(selFor(A));
|
|
189
|
+
await inspect.save();
|
|
190
|
+
|
|
191
|
+
const reloaded = createInspect(ctx, async () => []);
|
|
192
|
+
await reloaded.load();
|
|
193
|
+
expect(asOne(reloaded.state.selections['ui/fixture']).selector).toBe('h1');
|
|
194
|
+
|
|
195
|
+
// Legacy shape (pre-selections) → invariant restored on load.
|
|
196
|
+
await Bun.write(
|
|
197
|
+
join(designRoot, '_active.json'),
|
|
198
|
+
JSON.stringify({ active: A, open_tabs: [A], selected: null, last_change: null })
|
|
199
|
+
);
|
|
200
|
+
const legacy = createInspect(ctx, async () => []);
|
|
201
|
+
await legacy.load();
|
|
202
|
+
expect(legacy.state.selections).toEqual({});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('switch emits the restored selection on the bus (clients stay in sync)', async () => {
|
|
206
|
+
const { ctx, inspect } = await mkRig();
|
|
207
|
+
const emitted: unknown[] = [];
|
|
208
|
+
ctx.bus.on('selected', (s: unknown) => emitted.push(s));
|
|
209
|
+
inspect.setActive(A);
|
|
210
|
+
inspect.setSelected(selFor(A));
|
|
211
|
+
inspect.setActive(B);
|
|
212
|
+
inspect.setActive(A);
|
|
213
|
+
// emits: initial activate (null), select(A), switch→B (null), switch→A (restored)
|
|
214
|
+
expect(emitted).toHaveLength(4);
|
|
215
|
+
expect(emitted[0]).toBeNull();
|
|
216
|
+
expect(emitted[2]).toBeNull();
|
|
217
|
+
expect(asOne(emitted[3]).selector).toBe('h1');
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -2,12 +2,18 @@
|
|
|
2
2
|
// install, and compiled marketplace install. Phase 19.1 / v0.18.1.
|
|
3
3
|
|
|
4
4
|
import { describe, expect, test } from 'bun:test';
|
|
5
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
5
8
|
|
|
6
9
|
import {
|
|
7
10
|
CLIENT_DIR,
|
|
11
|
+
DESIGN_PLUGIN_DIR,
|
|
8
12
|
DEV_SERVER_ROOT,
|
|
9
13
|
DIST_DIR,
|
|
14
|
+
FLOW_PLUGIN_DIR,
|
|
10
15
|
IS_COMPILED_BINARY,
|
|
16
|
+
pluginDirFrom,
|
|
11
17
|
RUNTIME_BUNDLES_DIR,
|
|
12
18
|
} from '../paths.ts';
|
|
13
19
|
|
|
@@ -29,3 +35,54 @@ describe('paths.ts', () => {
|
|
|
29
35
|
expect(IS_COMPILED_BINARY).toBe(false);
|
|
30
36
|
});
|
|
31
37
|
});
|
|
38
|
+
|
|
39
|
+
describe('pluginDirFrom — session-scoped plugin auto-bootstrap resolution (DDR-143)', () => {
|
|
40
|
+
// Both simulated layouts keep `plugins/` a sibling of `apps/studio/`, matching
|
|
41
|
+
// the dev tree and the desktop Resources bundle (stage-resources.mjs).
|
|
42
|
+
function makeLayout(shape: 'desktop-bundle' | 'npm'): { root: string; devServerRoot: string } {
|
|
43
|
+
const root = mkdtempSync(join(tmpdir(), 'maude-paths-'));
|
|
44
|
+
const devServerRoot = join(root, 'apps', 'studio');
|
|
45
|
+
mkdirSync(devServerRoot, { recursive: true });
|
|
46
|
+
for (const plugin of ['design', 'flow'] as const) {
|
|
47
|
+
const pluginRoot = join(root, 'plugins', plugin);
|
|
48
|
+
// Both layouts ship templates (DDR-044).
|
|
49
|
+
mkdirSync(join(pluginRoot, 'templates'), { recursive: true });
|
|
50
|
+
if (shape === 'desktop-bundle') {
|
|
51
|
+
// The desktop bundle stages the full loadable tree incl. the manifest.
|
|
52
|
+
mkdirSync(join(pluginRoot, '.claude-plugin'), { recursive: true });
|
|
53
|
+
mkdirSync(join(pluginRoot, 'commands'), { recursive: true });
|
|
54
|
+
writeFileSync(join(pluginRoot, '.claude-plugin', 'plugin.json'), '{"name":"x"}');
|
|
55
|
+
}
|
|
56
|
+
// npm layout: NO .claude-plugin/plugin.json (DDR-044 minimal surface).
|
|
57
|
+
}
|
|
58
|
+
return { root, devServerRoot };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
test('desktop Resources layout → resolves both plugin dirs (manifest present)', () => {
|
|
62
|
+
const { root, devServerRoot } = makeLayout('desktop-bundle');
|
|
63
|
+
try {
|
|
64
|
+
expect(pluginDirFrom(devServerRoot, 'design')).toBe(join(root, 'plugins', 'design'));
|
|
65
|
+
expect(pluginDirFrom(devServerRoot, 'flow')).toBe(join(root, 'plugins', 'flow'));
|
|
66
|
+
} finally {
|
|
67
|
+
rmSync(root, { recursive: true, force: true });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('npm/web layout → null (templates ship, but no plugin manifest)', () => {
|
|
72
|
+
const { root, devServerRoot } = makeLayout('npm');
|
|
73
|
+
try {
|
|
74
|
+
expect(pluginDirFrom(devServerRoot, 'design')).toBeNull();
|
|
75
|
+
expect(pluginDirFrom(devServerRoot, 'flow')).toBeNull();
|
|
76
|
+
} finally {
|
|
77
|
+
rmSync(root, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('exported constants resolve under the running dev tree (manifest present here)', () => {
|
|
82
|
+
// This suite runs from the repo dev tree, where plugins/{design,flow}/ carry
|
|
83
|
+
// their manifests — so the module-load-time constants are non-null and point
|
|
84
|
+
// at the real plugin roots.
|
|
85
|
+
expect(DESIGN_PLUGIN_DIR).toBe(join(DEV_SERVER_ROOT, '..', '..', 'plugins', 'design'));
|
|
86
|
+
expect(FLOW_PLUGIN_DIR).toBe(join(DEV_SERVER_ROOT, '..', '..', 'plugins', 'flow'));
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -22,6 +22,9 @@ const INSTALLED_BOTH = {
|
|
|
22
22
|
},
|
|
23
23
|
};
|
|
24
24
|
const INSTALLED_DESIGN_ONLY = { version: 2, plugins: { 'design@maude': [{ scope: 'user' }] } };
|
|
25
|
+
// `/flow` is no longer part of the chat (2026-07-03) — the gate tracks `design`
|
|
26
|
+
// alone, so a registry with flow-but-not-design is the "design missing" case.
|
|
27
|
+
const INSTALLED_FLOW_ONLY = { version: 2, plugins: { 'flow@maude': [{ scope: 'user' }] } };
|
|
25
28
|
|
|
26
29
|
function fixtureClaudeDir(opts: { markets?: unknown; installed?: unknown }): string {
|
|
27
30
|
const dir = mkdtempSync(join(tmpdir(), 'maude-readiness-'));
|
|
@@ -45,43 +48,95 @@ async function withClaudeDir<T>(dir: string, fn: () => Promise<T> | T): Promise<
|
|
|
45
48
|
}
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
/** Plugins row under the given ~/.claude fixture. `nativeBootstrap:false` sets
|
|
52
|
+
* MAUDE_NO_PLUGIN_BOOTSTRAP to force the web / manual (DDR-128) path — needed to
|
|
53
|
+
* isolate the registry scan, since this suite runs in the dev tree where the
|
|
54
|
+
* bundled plugin dirs resolve and DDR-143 auto-load would otherwise mask it. */
|
|
55
|
+
const pluginsItem = async (dir: string, opts: { nativeBootstrap?: boolean } = {}) => {
|
|
56
|
+
const savedOptOut = process.env.MAUDE_NO_PLUGIN_BOOTSTRAP;
|
|
57
|
+
if (opts.nativeBootstrap === false) process.env.MAUDE_NO_PLUGIN_BOOTSTRAP = '1';
|
|
58
|
+
try {
|
|
59
|
+
return (await withClaudeDir(dir, () => probeReadiness())).items.find(
|
|
60
|
+
(i) => i.id === 'plugins'
|
|
61
|
+
)!;
|
|
62
|
+
} finally {
|
|
63
|
+
if (savedOptOut === undefined) delete process.env.MAUDE_NO_PLUGIN_BOOTSTRAP;
|
|
64
|
+
else process.env.MAUDE_NO_PLUGIN_BOOTSTRAP = savedOptOut;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
50
67
|
|
|
51
|
-
|
|
52
|
-
|
|
68
|
+
// Registry-scan branches under the WEB / manual path (auto-bootstrap opted out) —
|
|
69
|
+
// the pure DDR-128 detect-and-guide behavior, still what a `maude design serve`
|
|
70
|
+
// user sees.
|
|
71
|
+
describe('readiness — plugin registry scan (web / manual path)', () => {
|
|
72
|
+
test('design@maude + marketplace present → plugins item is present, no remediation', async () => {
|
|
53
73
|
const item = await pluginsItem(
|
|
54
|
-
fixtureClaudeDir({ markets: MARKET_OK, installed:
|
|
74
|
+
fixtureClaudeDir({ markets: MARKET_OK, installed: INSTALLED_DESIGN_ONLY }),
|
|
75
|
+
{ nativeBootstrap: false }
|
|
55
76
|
);
|
|
56
77
|
expect(item.status).toBe('present');
|
|
57
78
|
expect(item.remediation).toBeUndefined();
|
|
58
79
|
});
|
|
59
80
|
|
|
60
|
-
test('
|
|
81
|
+
test('design@maude NOT installed → missing, names the absent design@maude + offers the fix', async () => {
|
|
82
|
+
// flow-only registry: `/flow` is irrelevant to the chat now, so this reads as
|
|
83
|
+
// "design missing" — the gate tracks design alone.
|
|
61
84
|
const item = await pluginsItem(
|
|
62
|
-
fixtureClaudeDir({ markets: MARKET_OK, installed:
|
|
85
|
+
fixtureClaudeDir({ markets: MARKET_OK, installed: INSTALLED_FLOW_ONLY }),
|
|
86
|
+
{ nativeBootstrap: false }
|
|
63
87
|
);
|
|
64
88
|
expect(item.status).toBe('missing');
|
|
65
|
-
expect(item.detail).toContain('
|
|
89
|
+
expect(item.detail).toContain('design@maude');
|
|
66
90
|
expect(item.remediation).toContain('/plugin install');
|
|
67
91
|
});
|
|
68
92
|
|
|
69
93
|
test('registry absent → unknown (Claude Code internal contract; never throws)', async () => {
|
|
70
|
-
const item = await pluginsItem(fixtureClaudeDir({}));
|
|
94
|
+
const item = await pluginsItem(fixtureClaudeDir({}), { nativeBootstrap: false });
|
|
71
95
|
expect(item.status).toBe('unknown');
|
|
72
96
|
});
|
|
73
97
|
|
|
74
|
-
test('plugin presence is the gate — a foreign-repo marketplace does not change a
|
|
98
|
+
test('plugin presence is the gate — a foreign-repo marketplace does not change a design-installed verdict', async () => {
|
|
75
99
|
const item = await pluginsItem(
|
|
76
100
|
fixtureClaudeDir({
|
|
77
101
|
markets: { other: { source: { repo: 'someone/else' } } },
|
|
78
102
|
installed: INSTALLED_BOTH,
|
|
79
|
-
})
|
|
103
|
+
}),
|
|
104
|
+
{ nativeBootstrap: false }
|
|
80
105
|
);
|
|
81
106
|
expect(item.status).toBe('present');
|
|
82
107
|
});
|
|
83
108
|
});
|
|
84
109
|
|
|
110
|
+
// DDR-143 — on the native/desktop path the ACP session auto-loads the bundled
|
|
111
|
+
// plugins, so the row is satisfied even against a pristine registry. This suite
|
|
112
|
+
// runs in the dev tree, so the bundled plugin dirs resolve → native context is
|
|
113
|
+
// on by default (no opt-out).
|
|
114
|
+
describe('readiness — plugin auto-bootstrap (native, DDR-143)', () => {
|
|
115
|
+
test('pristine registry → present + "Auto-loaded" detail, no manual remediation', async () => {
|
|
116
|
+
const item = await pluginsItem(fixtureClaudeDir({}));
|
|
117
|
+
expect(item.status).toBe('present');
|
|
118
|
+
expect(item.detail).toContain('Auto-loaded');
|
|
119
|
+
expect(item.remediation).toBeUndefined();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('design NOT installed (flow-only registry) → still present (design is auto-loaded, no red wall)', async () => {
|
|
123
|
+
const item = await pluginsItem(
|
|
124
|
+
fixtureClaudeDir({ markets: MARKET_OK, installed: INSTALLED_FLOW_ONLY })
|
|
125
|
+
);
|
|
126
|
+
expect(item.status).toBe('present');
|
|
127
|
+
expect(item.detail).toContain('Auto-loaded');
|
|
128
|
+
expect(item.remediation).toBeUndefined();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('design already installed → present, keeps the "is installed" detail (no auto-load framing)', async () => {
|
|
132
|
+
const item = await pluginsItem(
|
|
133
|
+
fixtureClaudeDir({ markets: MARKET_OK, installed: INSTALLED_DESIGN_ONLY })
|
|
134
|
+
);
|
|
135
|
+
expect(item.status).toBe('present');
|
|
136
|
+
expect(item.detail).toContain('installed');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
85
140
|
describe('readiness — resolveOnPath', () => {
|
|
86
141
|
test('finds a binary that is on PATH', async () => {
|
|
87
142
|
const hit = await resolveOnPath('sh');
|
|
@@ -126,9 +181,24 @@ describe('readiness — report shape', () => {
|
|
|
126
181
|
expect(typeof report.ready).toBe('boolean');
|
|
127
182
|
});
|
|
128
183
|
|
|
129
|
-
test('
|
|
184
|
+
test('screenshot engine: required + present (bundled) on native → never blocks ready', async () => {
|
|
185
|
+
// Native (this dev tree): agent-browser is bundled, so the row is first-class
|
|
186
|
+
// (required) but PRESENT — it can't turn `ready` false.
|
|
130
187
|
const ab = (await probeReadiness()).items.find((i) => i.id === 'agent-browser')!;
|
|
131
|
-
expect(ab.required).toBe(
|
|
188
|
+
expect(ab.required).toBe(true);
|
|
189
|
+
expect(ab.status).toBe('present');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('screenshot engine: optional on the web/opt-out path', async () => {
|
|
193
|
+
const saved = process.env.MAUDE_NO_PLUGIN_BOOTSTRAP;
|
|
194
|
+
process.env.MAUDE_NO_PLUGIN_BOOTSTRAP = '1'; // force native=false
|
|
195
|
+
try {
|
|
196
|
+
const ab = (await probeReadiness()).items.find((i) => i.id === 'agent-browser')!;
|
|
197
|
+
expect(ab.required).toBe(false);
|
|
198
|
+
} finally {
|
|
199
|
+
if (saved === undefined) delete process.env.MAUDE_NO_PLUGIN_BOOTSTRAP;
|
|
200
|
+
else process.env.MAUDE_NO_PLUGIN_BOOTSTRAP = saved;
|
|
201
|
+
}
|
|
132
202
|
});
|
|
133
203
|
|
|
134
204
|
test('adapter row tracks the real chat gate (resolveAdapterEntry) and is required', async () => {
|