mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
test(desktop): add MCP servers E2E and bridge seeding support (P4)
Adds invoke-layer round-trip tests + UI interaction tests for the editable MCP servers feature (P3), covering all three dialog layers. Round-trip tests (invokeTauri, behavior not impl): - persona create+update mcp_servers with add/change/drop - update_persona absent-preserves contract (omit → keeps, []→ clears) - agent create+update mcp_servers round-trip + absent-preserves UI tests with screenshots: - McpServersEditor in PersonaDialog (buzz-agent auto-expands Advanced) - McpServersEditor in global config card with pre-seeded servers - McpServersEditor in agent-instance edit dialog with pre-seeded servers Bridge changes: - MockManagedAgentSeed: add agentCommand (default "goose" preserved) and mcpServers seeding, wired into buildSeededManagedAgent - bridge.ts helper: add agentCommand/mcpServers to mock seed type; add mcp_servers to globalAgentConfig mock shape
This commit is contained in:
@@ -73,6 +73,10 @@ type MockManagedAgentSeed = {
|
||||
autoRestartOnConfigChange?: boolean;
|
||||
respondTo?: RawManagedAgent["respond_to"];
|
||||
respondToAllowlist?: string[];
|
||||
/** Override the default "goose" agent command (e.g. "buzz-agent"). */
|
||||
agentCommand?: string;
|
||||
/** Pre-seeded MCP server layer for the agent record. */
|
||||
mcpServers?: McpServerConfig[];
|
||||
};
|
||||
|
||||
type MockRelayAgentSeed = {
|
||||
@@ -1768,7 +1772,7 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent {
|
||||
persona_id: seed.personaId ?? null,
|
||||
relay_url: DEFAULT_RELAY_WS_URL,
|
||||
acp_command: "buzz-acp",
|
||||
agent_command: "goose",
|
||||
agent_command: seed.agentCommand ?? "goose",
|
||||
agent_args: ["acp"],
|
||||
mcp_command: "",
|
||||
turn_timeout_seconds: 320,
|
||||
@@ -1779,6 +1783,7 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent {
|
||||
avatar_url: seed.avatarUrl ?? null,
|
||||
model: null,
|
||||
env_vars: {},
|
||||
mcp_servers: (seed.mcpServers ?? []).map(cloneMcpServer),
|
||||
status,
|
||||
pid: status === "running" ? 42000 + mockManagedAgents.length : null,
|
||||
created_at: now,
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
});
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function gotoApp(page: import("@playwright/test").Page) {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await waitForInvokeBridge(page);
|
||||
await expect(page.getByTestId("open-agents-view")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForInvokeBridge(page: import("@playwright/test").Page) {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const w = window as Window & {
|
||||
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
|
||||
__TAURI_INTERNALS__?: { invoke?: unknown };
|
||||
};
|
||||
return (
|
||||
typeof w.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function" ||
|
||||
typeof w.__TAURI_INTERNALS__?.invoke === "function"
|
||||
);
|
||||
},
|
||||
null,
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
}
|
||||
|
||||
async function invokeTauri<T>(
|
||||
page: import("@playwright/test").Page,
|
||||
command: string,
|
||||
payload?: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
await waitForInvokeBridge(page);
|
||||
return page.evaluate(
|
||||
async ({ command: c, payload: p }) => {
|
||||
const w = window as Window & {
|
||||
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
|
||||
c: string,
|
||||
p?: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
__TAURI_INTERNALS__?: {
|
||||
invoke?: (c: string, p?: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
const invoke =
|
||||
w.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ ?? w.__TAURI_INTERNALS__?.invoke;
|
||||
if (!invoke) throw new Error("Mock invoke bridge is unavailable.");
|
||||
return (await invoke(c, p)) as T;
|
||||
},
|
||||
{ command, payload },
|
||||
);
|
||||
}
|
||||
|
||||
const AGENT_PUBKEY = TEST_IDENTITIES.tyler.pubkey;
|
||||
const AGENT_NAME = "MCP Test Agent";
|
||||
|
||||
async function openEditDialog(page: import("@playwright/test").Page) {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
const agentButton = page.getByRole("button", {
|
||||
name: `${AGENT_NAME} agent profile`,
|
||||
});
|
||||
await expect(agentButton).toBeVisible({ timeout: 10_000 });
|
||||
await agentButton.click();
|
||||
await expect(page.getByTestId("user-profile-panel")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await page.getByTestId("user-profile-edit-agent").click();
|
||||
await expect(page.getByTestId("edit-agent-dialog")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
// Wait for the runtime catalog to load and form to settle (buzz-agent auto-
|
||||
// expands Advanced, which makes the MCP servers editor immediately visible).
|
||||
await expect(page.getByTestId("mcp-servers-editor").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
// ── round-trip tests ─────────────────────────────────────────────────────────
|
||||
|
||||
test("persona mcp_servers round-trip through create_persona + update_persona", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoApp(page);
|
||||
|
||||
const fsServer = {
|
||||
name: "filesystem",
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
||||
env: [],
|
||||
enabled: true,
|
||||
};
|
||||
const fetchServer = {
|
||||
name: "fetch-mcp",
|
||||
command: "uvx",
|
||||
args: ["mcp-server-fetch"],
|
||||
env: [],
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const created = await invokeTauri<{
|
||||
id: string;
|
||||
mcp_servers?: unknown[];
|
||||
}>(page, "create_persona", {
|
||||
input: {
|
||||
displayName: "MCP Persona",
|
||||
systemPrompt: "You use MCP.",
|
||||
mcpServers: [fsServer, fetchServer],
|
||||
},
|
||||
});
|
||||
|
||||
expect(created.mcp_servers).toHaveLength(2);
|
||||
expect(created.mcp_servers?.[0]).toMatchObject({ name: "filesystem" });
|
||||
expect(created.mcp_servers?.[1]).toMatchObject({ name: "fetch-mcp" });
|
||||
|
||||
// Update: drop one, change one, add one.
|
||||
const dbServer = {
|
||||
name: "sqlite-db",
|
||||
command: "uvx",
|
||||
args: ["mcp-server-sqlite", "--db", "/tmp/test.db"],
|
||||
env: [],
|
||||
enabled: true,
|
||||
};
|
||||
const updated = await invokeTauri<{ mcp_servers?: unknown[] }>(
|
||||
page,
|
||||
"update_persona",
|
||||
{
|
||||
input: {
|
||||
id: created.id,
|
||||
displayName: "MCP Persona",
|
||||
systemPrompt: "You use MCP.",
|
||||
mcpServers: [
|
||||
{ ...fsServer, command: "bunx" }, // changed command
|
||||
dbServer, // added
|
||||
// fetch-mcp dropped
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(updated.mcp_servers).toHaveLength(2);
|
||||
expect(updated.mcp_servers?.[0]).toMatchObject({
|
||||
name: "filesystem",
|
||||
command: "bunx",
|
||||
});
|
||||
expect(updated.mcp_servers?.[1]).toMatchObject({ name: "sqlite-db" });
|
||||
});
|
||||
|
||||
test("update_persona preserves mcp_servers when caller omits the field", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoApp(page);
|
||||
|
||||
const created = await invokeTauri<{
|
||||
id: string;
|
||||
mcp_servers?: unknown[];
|
||||
}>(page, "create_persona", {
|
||||
input: {
|
||||
displayName: "MCP Keeper",
|
||||
systemPrompt: "You keep MCP.",
|
||||
mcpServers: [
|
||||
{
|
||||
name: "filesystem",
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
||||
env: [],
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(created.mcp_servers).toHaveLength(1);
|
||||
|
||||
// Update WITHOUT including mcpServers. Stored list must survive.
|
||||
const preserved = await invokeTauri<{ mcp_servers?: unknown[] }>(
|
||||
page,
|
||||
"update_persona",
|
||||
{
|
||||
input: {
|
||||
id: created.id,
|
||||
displayName: "MCP Keeper (renamed)",
|
||||
systemPrompt: "You keep MCP.",
|
||||
// mcpServers intentionally omitted
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(preserved.mcp_servers).toHaveLength(1);
|
||||
|
||||
// Explicit empty array still clears (intentional).
|
||||
const cleared = await invokeTauri<{ mcp_servers?: unknown[] }>(
|
||||
page,
|
||||
"update_persona",
|
||||
{
|
||||
input: {
|
||||
id: created.id,
|
||||
displayName: "MCP Keeper (renamed)",
|
||||
systemPrompt: "You keep MCP.",
|
||||
mcpServers: [],
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(cleared.mcp_servers ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("agent mcp_servers round-trip through create_managed_agent + update_managed_agent", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoApp(page);
|
||||
|
||||
const created = await invokeTauri<{
|
||||
agent: { pubkey: string; mcp_servers?: unknown[] };
|
||||
}>(page, "create_managed_agent", {
|
||||
input: {
|
||||
name: "mcp-e2e-agent",
|
||||
backend: { type: "local" },
|
||||
mcpServers: [
|
||||
{
|
||||
name: "filesystem",
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
||||
env: [],
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(created.agent.mcp_servers).toHaveLength(1);
|
||||
expect(created.agent.mcp_servers?.[0]).toMatchObject({
|
||||
name: "filesystem",
|
||||
command: "npx",
|
||||
});
|
||||
|
||||
// Update mcpServers — replaces entire list.
|
||||
const updated = await invokeTauri<{
|
||||
agent: { mcp_servers?: unknown[] };
|
||||
}>(page, "update_managed_agent", {
|
||||
input: {
|
||||
pubkey: created.agent.pubkey,
|
||||
mcpServers: [
|
||||
{
|
||||
name: "fetch-mcp",
|
||||
command: "uvx",
|
||||
args: ["mcp-server-fetch"],
|
||||
env: [],
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(updated.agent.mcp_servers).toHaveLength(1);
|
||||
expect(updated.agent.mcp_servers?.[0]).toMatchObject({
|
||||
name: "fetch-mcp",
|
||||
command: "uvx",
|
||||
});
|
||||
|
||||
// Omitting mcpServers on a subsequent update preserves the stored list.
|
||||
const preserved = await invokeTauri<{
|
||||
agent: { mcp_servers?: unknown[] };
|
||||
}>(page, "update_managed_agent", {
|
||||
input: {
|
||||
pubkey: created.agent.pubkey,
|
||||
name: "mcp-e2e-agent-renamed",
|
||||
// mcpServers intentionally omitted
|
||||
},
|
||||
});
|
||||
expect(preserved.agent.mcp_servers).toHaveLength(1);
|
||||
expect(preserved.agent.mcp_servers?.[0]).toMatchObject({ name: "fetch-mcp" });
|
||||
});
|
||||
|
||||
// ── UI tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test("mcp servers editor renders in PersonaDialog new-persona form", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoApp(page);
|
||||
|
||||
// Open the Agents view, click New > New agent.
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
await page.getByTestId("new-agent-card").click();
|
||||
await page.getByRole("menuitem", { name: /^New agent$/ }).click();
|
||||
|
||||
const dialog = page.getByRole("dialog");
|
||||
|
||||
// The default runtime is buzz-agent — Advanced auto-expands so the MCP
|
||||
// servers editor is immediately visible without a click.
|
||||
await expect(dialog.getByTestId("mcp-servers-editor")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
// Initially empty — no rows.
|
||||
await expect(dialog.getByTestId("mcp-servers-row")).toHaveCount(0);
|
||||
|
||||
// Add a row and fill the name + command fields.
|
||||
await dialog.getByTestId("mcp-servers-add").click();
|
||||
await expect(dialog.getByTestId("mcp-servers-row")).toHaveCount(1);
|
||||
|
||||
await dialog.getByTestId("mcp-servers-name").fill("filesystem");
|
||||
await dialog.getByTestId("mcp-servers-command").fill("npx");
|
||||
|
||||
// Add a second row to verify multi-row rendering.
|
||||
await dialog.getByTestId("mcp-servers-add").click();
|
||||
await dialog.getByTestId("mcp-servers-name").last().fill("fetch-mcp");
|
||||
await dialog.getByTestId("mcp-servers-command").last().fill("uvx");
|
||||
|
||||
await waitForAnimations(page);
|
||||
await dialog.screenshot({
|
||||
path: "test-results/mcp-servers-persona-dialog.png",
|
||||
});
|
||||
|
||||
// Remove the first row — verify per-row removal works.
|
||||
await dialog.getByTestId("mcp-servers-remove").first().click();
|
||||
await expect(dialog.getByTestId("mcp-servers-row")).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("mcp servers editor renders in global agent config card", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
globalAgentConfig: {
|
||||
env_vars: {},
|
||||
mcp_servers: [
|
||||
{
|
||||
name: "filesystem",
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
||||
env: [],
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
provider: null,
|
||||
model: null,
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
await expect(page.getByTestId("settings-global-agent-config")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
const card = page.getByTestId("settings-global-agent-config");
|
||||
|
||||
// The McpServersEditor is inside the global config card.
|
||||
await expect(card.getByTestId("mcp-servers-editor")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
// The existing server from the mock shows as a pre-populated row.
|
||||
await expect(card.getByTestId("mcp-servers-row")).toHaveCount(1);
|
||||
await expect(card.getByTestId("mcp-servers-name")).toHaveValue("filesystem");
|
||||
|
||||
await card.scrollIntoViewIfNeeded();
|
||||
await waitForAnimations(page);
|
||||
await card.screenshot({
|
||||
path: "test-results/mcp-servers-global-config-card.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("mcp servers editor renders in agent-instance edit dialog", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
managedAgents: [
|
||||
{
|
||||
pubkey: AGENT_PUBKEY,
|
||||
name: AGENT_NAME,
|
||||
status: "stopped",
|
||||
channelNames: ["agents"],
|
||||
agentCommand: "buzz-agent",
|
||||
mcpServers: [
|
||||
{
|
||||
name: "filesystem",
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
||||
env: [],
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await openEditDialog(page);
|
||||
|
||||
const dialog = page.getByRole("dialog");
|
||||
|
||||
// buzz-agent auto-expands Advanced — the MCP servers editor is already visible.
|
||||
const editor = dialog.getByTestId("mcp-servers-editor");
|
||||
await expect(editor).toBeVisible();
|
||||
|
||||
// The agent's pre-existing MCP server renders as an editable row.
|
||||
await expect(editor.getByTestId("mcp-servers-row")).toHaveCount(1);
|
||||
await expect(editor.getByTestId("mcp-servers-name")).toHaveValue(
|
||||
"filesystem",
|
||||
);
|
||||
|
||||
// Add a second row.
|
||||
await editor.getByTestId("mcp-servers-add").click();
|
||||
await editor.getByTestId("mcp-servers-name").last().fill("fetch-mcp");
|
||||
await editor.getByTestId("mcp-servers-command").last().fill("uvx");
|
||||
|
||||
await waitForAnimations(page);
|
||||
await dialog.screenshot({
|
||||
path: "test-results/mcp-servers-edit-agent-dialog.png",
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,16 @@ type MockManagedAgentSeed = {
|
||||
autoRestartOnConfigChange?: boolean;
|
||||
respondTo?: "owner-only" | "allowlist" | "anyone";
|
||||
respondToAllowlist?: string[];
|
||||
/** Override the default "goose" agent command (e.g. "buzz-agent"). */
|
||||
agentCommand?: string;
|
||||
/** Pre-seeded MCP server layer for the agent record. */
|
||||
mcpServers?: Array<{
|
||||
name: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
env: Array<{ name: string; value: string }>;
|
||||
enabled: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
type MockSearchProfileSeed = {
|
||||
@@ -283,11 +293,19 @@ type MockBridgeOptions = {
|
||||
identityLocked?: boolean;
|
||||
/**
|
||||
* Global agent config returned by `get_global_agent_config`. Defaults to
|
||||
* an empty config (no provider, model, or env vars) if not specified.
|
||||
* Pass a config with a provider to test Inherit-from-global behavior.
|
||||
* an empty config (no provider, model, env vars, or MCP servers) if not
|
||||
* specified. Pass a config with a provider to test Inherit-from-global
|
||||
* behavior.
|
||||
*/
|
||||
globalAgentConfig?: {
|
||||
env_vars: Record<string, string>;
|
||||
mcp_servers?: Array<{
|
||||
name: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
env: Array<{ name: string; value: string }>;
|
||||
enabled: boolean;
|
||||
}>;
|
||||
provider: string | null;
|
||||
model: string | null;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user