Files
openhuman/app/test/playwright/specs/mcp-tab-flow.spec.ts
T

618 lines
24 KiB
TypeScript

/**
* MCP Tab — full lifecycle e2e tests.
*
* Covers: browse catalog → search → install (env-key form) → verify in
* installed list → manage detail view → connect/disconnect → uninstall →
* verify removal. All RPC calls are mocked via page.route so no running
* core is required.
*/
import { expect, type Page, test } from '@playwright/test';
import packageJson from '../../../package.json' with { type: 'json' };
// Derive from the build's real version, never hardcode. `update_version` feeds
// the bootCheck version-match gate; a stale literal makes the mock mismatch the
// app build, leaving BootCheckGate in "outdated" so `#root` never renders and
// the whole spec times out (the 0.57.18→0.57.19 bump blanked it this way).
const APP_VERSION = packageJson.version;
// ---------------------------------------------------------------------------
// Mock data
// ---------------------------------------------------------------------------
const REGISTRY_SERVERS = [
{
qualified_name: 'io.github.test/memory-server',
display_name: 'Memory Server',
description: 'A test MCP server for memory operations',
icon_url: null,
use_count: 1200,
is_deployed: false,
source: 'mcp_official',
},
{
qualified_name: 'io.github.test/github-tools',
display_name: 'GitHub Tools',
description: 'MCP server for GitHub API integration',
icon_url: null,
use_count: 5600,
is_deployed: true,
source: 'mcp_official',
},
{
qualified_name: 'io.github.test/notion-connector',
display_name: 'Notion Connector',
description: 'Connect to Notion workspaces via MCP',
icon_url: null,
use_count: 980,
is_deployed: false,
source: 'mcp_official',
},
];
function makeInstalledServer(overrides: Partial<typeof INSTALLED_DEFAULT> = {}) {
return { ...INSTALLED_DEFAULT, ...overrides };
}
const INSTALLED_DEFAULT = {
server_id: 'srv_installed_1',
qualified_name: 'io.github.test/memory-server',
display_name: 'Memory Server',
description: 'A test MCP server for memory operations',
command_kind: 'node',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-memory'],
env_keys: [],
installed_at: 1700000000,
enabled: true,
};
const STATUS_CONNECTED = {
server_id: 'srv_installed_1',
qualified_name: 'io.github.test/memory-server',
display_name: 'Memory Server',
status: 'connected' as const,
tool_count: 5,
};
// Tools a connected server exposes — returned by the mocked connect so the
// detail's tool list (and the execution playground) have something to drive.
const MOCK_TOOLS = [
{ name: 'create_memory', description: 'Create a memory', input_schema: {} },
{ name: 'list_memories', description: 'List all memories', input_schema: {} },
];
const GITHUB_DETAIL = {
...REGISTRY_SERVERS[1],
connections: [{ type: 'stdio', published: true }],
required_env_keys: ['GITHUB_TOKEN'],
};
const GITHUB_INSTALLED = {
server_id: 'srv_github_1',
qualified_name: 'io.github.test/github-tools',
display_name: 'GitHub Tools',
description: 'MCP server for GitHub API integration',
command_kind: 'node',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-github'],
env_keys: ['GITHUB_TOKEN'],
installed_at: 1700000100,
enabled: true,
};
// ---------------------------------------------------------------------------
// RPC mock layer — mutable state so tests can drive lifecycle transitions
// ---------------------------------------------------------------------------
interface MockState {
installed: (typeof INSTALLED_DEFAULT)[];
// `status` is broadened to a string so tests can seed non-connected states
// (e.g. `error`) that keep the status poll active.
statuses: Array<Omit<typeof STATUS_CONNECTED, 'status'> & { status: string }>;
}
function rpcOk(id: number, result: unknown) {
return {
status: 200,
contentType: 'application/json',
body: JSON.stringify({ jsonrpc: '2.0', id, result }),
};
}
function rpcError(id: number, message: string) {
return {
status: 200,
contentType: 'application/json',
body: JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32000, message } }),
};
}
async function setupMockRpc(page: Page, state: MockState) {
await page.route('**/rpc', async (route, request) => {
const body = JSON.parse(request.postData() || '{}');
const method: string = body.method;
const id: number = body.id;
const params = body.params ?? {};
switch (method) {
case 'openhuman.update_version':
return route.fulfill(
rpcOk(id, {
result: {
version: APP_VERSION,
target_triple: 'x86_64-apple-darwin',
asset_prefix: '',
},
})
);
case 'openhuman.app_state_snapshot':
return route.fulfill(
rpcOk(id, {
result: {
auth: { isAuthenticated: true, userId: 'pw-mcp-user', user: null, profileId: null },
sessionToken: 'fake-session-token',
currentUser: { _id: 'pw-mcp-user', displayName: 'Test User' },
onboardingCompleted: true,
chatOnboardingCompleted: true,
analyticsEnabled: false,
meetAutoOrchestratorHandoff: false,
localState: {},
keyringStatus: { isUnlocked: true, hasPassphrase: false },
runtime: {
screenIntelligence: { enabled: false },
localAi: { enabled: false },
autocomplete: { enabled: false },
service: { running: false },
},
},
})
);
// ---- MCP registry ----
case 'openhuman.mcp_clients_registry_search': {
const query = (params.query ?? '').toLowerCase();
const installedNames = new Set(state.installed.map(s => s.qualified_name));
const queryFiltered = query
? REGISTRY_SERVERS.filter(
s =>
s.display_name.toLowerCase().includes(query) ||
s.qualified_name.toLowerCase().includes(query)
)
: REGISTRY_SERVERS;
// Exclude servers that are already installed — mirrors real backend behaviour
const filtered = queryFiltered.filter(s => !installedNames.has(s.qualified_name));
return route.fulfill(rpcOk(id, { servers: filtered, page: 1, total_pages: 1 }));
}
case 'openhuman.mcp_clients_registry_get':
if (params.qualified_name === GITHUB_DETAIL.qualified_name) {
return route.fulfill(rpcOk(id, { server: GITHUB_DETAIL }));
}
return route.fulfill(rpcError(id, `server not found: ${params.qualified_name}`));
// ---- Installed servers (mutable) ----
case 'openhuman.mcp_clients_installed_list':
return route.fulfill(rpcOk(id, { installed: state.installed }));
case 'openhuman.mcp_clients_status':
return route.fulfill(rpcOk(id, { servers: state.statuses }));
case 'openhuman.mcp_clients_install':
if (!params.qualified_name) {
return route.fulfill(rpcError(id, "missing required param 'qualified_name'"));
}
state.installed.push(GITHUB_INSTALLED);
return route.fulfill(rpcOk(id, { server: GITHUB_INSTALLED }));
// Auth probe for the upfront connect modal — these test servers need no
// credentials, so report `none` and the modal shows a single Connect button.
case 'openhuman.mcp_clients_detect_auth':
return route.fulfill(rpcOk(id, { kind: 'none' }));
case 'openhuman.mcp_clients_connect': {
const sid = params.server_id;
const inst = state.installed.find(s => s.server_id === sid);
// Reject unknown server ids so the test can't pass while wired to the
// wrong server.
if (!inst) {
return route.fulfill(rpcError(id, `server not installed: ${sid}`));
}
// Mark the server connected for subsequent status polls and hand back
// its tool list (what `onConnected` feeds into the detail's tool list).
state.statuses = [
...state.statuses.filter(s => s.server_id !== sid),
{
server_id: sid,
qualified_name: inst.qualified_name,
display_name: inst.display_name,
status: 'connected',
tool_count: MOCK_TOOLS.length,
},
];
return route.fulfill(rpcOk(id, { status: 'connected', tools: MOCK_TOOLS }));
}
// Tool execution — what the playground's "Run tool" calls. Unknown tools
// come back as a tool error so the spec can't pass on a wrong tool name.
case 'openhuman.mcp_clients_tool_call': {
const known = MOCK_TOOLS.some(t => t.name === params.tool_name);
if (!known) {
return route.fulfill(
rpcOk(id, { result: `unknown tool: ${params.tool_name}`, is_error: true })
);
}
return route.fulfill(
rpcOk(id, { result: `ran ${params.tool_name}: memory created id=42`, is_error: false })
);
}
case 'openhuman.mcp_clients_disconnect':
state.statuses = state.statuses.filter(s => s.server_id !== params.server_id);
return route.fulfill(rpcOk(id, { status: 'disconnected' }));
case 'openhuman.mcp_clients_uninstall':
state.installed = state.installed.filter(s => s.server_id !== params.server_id);
state.statuses = state.statuses.filter(s => s.server_id !== params.server_id);
return route.fulfill(rpcOk(id, { success: true }));
case 'openhuman.mcp_clients_tools':
return route.fulfill(
rpcOk(id, {
tools: [
{ name: 'create_memory', description: 'Create a memory', input_schema: {} },
{ name: 'list_memories', description: 'List all memories', input_schema: {} },
],
})
);
default:
return route.fulfill(rpcOk(id, {}));
}
});
}
async function seedLocalStorage(page: Page) {
await page.addInitScript(() => {
window.localStorage.setItem('openhuman_core_mode', 'cloud');
window.localStorage.setItem('openhuman_core_rpc_url', 'http://127.0.0.1:17788/rpc');
window.localStorage.setItem('openhuman_core_rpc_token', 'test-token');
window.localStorage.setItem('openhuman:walkthrough_completed', 'true');
window.localStorage.removeItem('openhuman:walkthrough_pending');
});
}
async function navigateToMcpTab(page: Page) {
// Phase 2: /skills → /connections, ?tab=mcp → ?tab=tools (back-compat alias also works)
await page.goto('/#/connections?tab=tools');
await page.waitForSelector('#root', { state: 'visible', timeout: 20_000 });
await page.locator('input[type="search"]').waitFor({ state: 'visible', timeout: 10_000 });
await page.locator('table').waitFor({ state: 'visible', timeout: 10_000 });
}
// ==========================================================================
// Tests
// ==========================================================================
test.describe('MCP Tab — Table View & Filtering', () => {
let state: MockState;
test.beforeEach(async ({ page }) => {
state = { installed: [makeInstalledServer()], statuses: [{ ...STATUS_CONNECTED }] };
await seedLocalStorage(page);
await setupMockRpc(page, state);
await navigateToMcpTab(page);
});
test('renders search bar and filter chips', async ({ page }) => {
await expect(page.locator('input[type="search"]')).toBeVisible();
await expect(page.getByRole('tab', { name: /^All$/ })).toBeVisible();
await expect(page.getByRole('tab', { name: /Installed/ })).toBeVisible();
await expect(page.getByRole('tab', { name: /Registry/ })).toBeVisible();
});
test('displays installed servers with status dot and Manage action', async ({ page }) => {
const row = page.locator('table tbody tr').first();
await expect(row.locator('td:first-child')).toContainText('Memory Server');
await expect(row.locator('text=Manage')).toBeVisible();
});
test('displays registry servers as clickable rows', async ({ page }) => {
const registryRow = page.locator('table tbody tr[role="button"]', {
has: page.locator('text=GitHub Tools'),
});
await expect(registryRow).toBeVisible({ timeout: 10_000 });
await expect(registryRow.locator('text=Install')).toBeVisible();
});
test('filter "Installed" hides registry rows', async ({ page }) => {
await page.getByRole('tab', { name: /Installed/ }).click();
const rows = page.locator('table tbody tr');
const count = await rows.count();
expect(count).toBeGreaterThan(0);
for (let i = 0; i < count; i++) {
await expect(rows.nth(i).locator('text=Manage')).toBeVisible();
}
});
test('filter "Registry" hides installed rows', async ({ page }) => {
await page.getByRole('tab', { name: /Registry/ }).click();
const rows = page.locator('table tbody tr');
const count = await rows.count();
expect(count).toBeGreaterThan(0);
for (let i = 0; i < count; i++) {
await expect(rows.nth(i).locator('text=Install')).toBeVisible();
}
});
test('already-installed servers are excluded from registry rows', async ({ page }) => {
await page.getByRole('tab', { name: /Registry/ }).click();
const rows = page.locator('table tbody tr');
const count = await rows.count();
for (let i = 0; i < count; i++) {
const text = await rows.nth(i).locator('td:first-child').innerText();
expect(text).not.toContain('Memory Server');
}
});
test('search filters both installed and registry servers', async ({ page }) => {
const search = page.locator('input[type="search"]');
await search.fill('notion');
// Wait for a Notion row to appear AND for non-matching rows (e.g. "Memory
// Server") to disappear — the table re-renders asynchronously and a naive
// count() immediately after the first visible check can race against the
// previous state still being in the DOM.
await expect(
page.locator('table tbody tr', { has: page.locator('td:has-text("Notion")') })
).toBeVisible({ timeout: 5_000 });
await expect(
page.locator('table tbody tr', { has: page.locator('td:has-text("Memory Server")') })
).toHaveCount(0, { timeout: 5_000 });
// The positive (Notion row present) + negative (Memory Server gone) checks
// above already prove the filter works. Avoid iterating `td:first-child`
// per row — the #3480 registry redesign changed the column layout, and the
// table re-renders async (the old per-row loop raced + assumed name-first).
await expect(page.locator('table tbody tr')).not.toHaveCount(0);
});
test('no Smithery branding visible anywhere', async ({ page }) => {
// Wait for the table to be fully rendered before scanning body text
await page.locator('table tbody tr').first().waitFor({ state: 'visible', timeout: 10_000 });
const bodyText = await page.locator('body').innerText();
expect(bodyText.toLowerCase()).not.toContain('smithery');
});
});
test.describe('MCP Tab — Install Lifecycle', () => {
let state: MockState;
test.beforeEach(async ({ page }) => {
state = { installed: [makeInstalledServer()], statuses: [{ ...STATUS_CONNECTED }] };
await seedLocalStorage(page);
await setupMockRpc(page, state);
await navigateToMcpTab(page);
});
test('install flow: click row → detail → configure → fill env → submit → appears installed', async ({
page,
}) => {
// 1. Click the GitHub Tools registry row (entire row is clickable)
const githubRow = page.locator('table tbody tr[role="button"]', {
has: page.locator('td:first-child:has-text("GitHub Tools")'),
});
await expect(githubRow).toBeVisible({ timeout: 10_000 });
await githubRow.click();
// 2. Install dialog detail step — shows server info and "Configure & install"
await expect(page.locator('text=GitHub Tools').first()).toBeVisible({ timeout: 5_000 });
const configureBtn = page.locator('button:has-text("Configure & install")');
await expect(configureBtn).toBeVisible({ timeout: 5_000 });
await configureBtn.click();
// 3. Configure step — env input appears
const envInput = page.locator('input[id="env-GITHUB_TOKEN"]');
await expect(envInput).toBeVisible({ timeout: 5_000 });
// 4. Fill in the env value
await envInput.fill('ghp_test_token_123');
// 5. Click "Install" submit button
const submitBtn = page.locator('button:has-text("Install")');
await submitBtn.click();
// 6. Should navigate to detail view (the installed server detail)
await expect(page.locator('button:has-text("Go back")')).toBeVisible({ timeout: 10_000 });
// 7. Go back and verify the server appears in the installed list
await page.locator('button:has-text("Go back")').click();
await expect(page.locator('table')).toBeVisible({ timeout: 5_000 });
const installedGithub = page.locator('table tbody tr', {
has: page.locator('td:has-text("GitHub Tools")'),
});
await expect(installedGithub).toBeVisible({ timeout: 5_000 });
});
test('cancel from install dialog returns to table', async ({ page }) => {
// Click a registry row to open install dialog
const registryRow = page.locator('table tbody tr[role="button"]', {
has: page.locator('td:first-child:has-text("GitHub Tools")'),
});
await registryRow.click();
// Cancel button should be visible on detail step
await expect(page.locator('button:has-text("Cancel")')).toBeVisible({ timeout: 5_000 });
await page.locator('button:has-text("Cancel")').click();
await expect(page.locator('table')).toBeVisible({ timeout: 5_000 });
});
});
test.describe('MCP Tab — Manage & Uninstall Lifecycle', () => {
let state: MockState;
test.beforeEach(async ({ page }) => {
state = { installed: [makeInstalledServer()], statuses: [{ ...STATUS_CONNECTED }] };
await seedLocalStorage(page);
await setupMockRpc(page, state);
await navigateToMcpTab(page);
});
test('click installed server row → detail view shows server info', async ({ page }) => {
const row = page.locator('table tbody tr', {
has: page.locator('td:first-child:has-text("Memory Server")'),
});
await row.click();
await expect(page.locator('button:has-text("Go back")')).toBeVisible({ timeout: 5_000 });
await expect(page.locator('text=Memory Server')).toBeVisible();
});
test('detail view shows qualified name', async ({ page }) => {
const row = page.locator('table tbody tr', {
has: page.locator('td:first-child:has-text("Memory Server")'),
});
await row.click();
await expect(page.locator('button:has-text("Go back")')).toBeVisible({ timeout: 5_000 });
await expect(page.locator('text=io.github.test/memory-server')).toBeVisible();
});
test('uninstall flow: detail → confirm uninstall → returns to table', async ({ page }) => {
const row = page.locator('table tbody tr', {
has: page.locator('td:first-child:has-text("Memory Server")'),
});
await row.click();
await expect(page.locator('button:has-text("Go back")')).toBeVisible({ timeout: 5_000 });
const uninstallBtn = page.locator('button:has-text("Uninstall")');
await expect(uninstallBtn.first()).toBeVisible({ timeout: 5_000 });
await uninstallBtn.first().click();
const confirmBtn = page.locator('button:has-text("Yes")');
await expect(confirmBtn.first()).toBeVisible({ timeout: 5_000 });
await confirmBtn.first().click();
await expect(page.locator('table')).toBeVisible({ timeout: 10_000 });
await page.getByRole('tab', { name: /Installed/ }).click();
const removedRow = page.locator('table tbody tr', {
has: page.locator('td:first-child:has-text("Memory Server")'),
});
await expect(removedRow).toHaveCount(0, { timeout: 5_000 });
});
test('back button from detail returns to table', async ({ page }) => {
const row = page.locator('table tbody tr', {
has: page.locator('td:first-child:has-text("Memory Server")'),
});
await row.click();
await expect(page.locator('button:has-text("Go back")')).toBeVisible({ timeout: 5_000 });
await page.locator('button:has-text("Go back")').click();
await expect(page.locator('table')).toBeVisible({ timeout: 5_000 });
});
});
test.describe('MCP Tab — Connect & Tool Execution', () => {
let state: MockState;
test.beforeEach(async ({ page }) => {
// Seed the installed server in `error` status: the detail offers a Connect
// affordance AND the status poll stays active (error is non-terminal), so
// the status flips to connected once the modal connects.
state = {
installed: [makeInstalledServer()],
statuses: [
{
server_id: 'srv_installed_1',
qualified_name: 'io.github.test/memory-server',
display_name: 'Memory Server',
status: 'error',
tool_count: 0,
},
],
};
await seedLocalStorage(page);
await setupMockRpc(page, state);
await navigateToMcpTab(page);
});
test('connect a server, then run one of its tools and see the result', async ({ page }) => {
// Open the installed server's detail view.
const row = page.locator('table tbody tr', {
has: page.locator('td:first-child:has-text("Memory Server")'),
});
await row.click();
await expect(page.locator('button:has-text("Go back")')).toBeVisible({ timeout: 5_000 });
// Connect via the upfront auth modal (no-auth server → a single Connect
// button). `exact` avoids the "Connections" sidebar nav button.
await page.getByRole('button', { name: 'Connect', exact: true }).click();
const connectDialog = page.getByRole('dialog');
await expect(connectDialog).toBeVisible({ timeout: 5_000 });
await connectDialog.getByRole('button', { name: /^Connect$/ }).click();
// The status poll flips the server to connected → its (collapsed) tool list
// appears. Expand it, then open the execution playground for a tool. This is
// the connect → tool step of the install→connect→tool path.
const toolsToggle = page.getByRole('button', { name: /tools available/ });
await expect(toolsToggle).toBeVisible({ timeout: 15_000 });
await toolsToggle.click();
const tryButton = page.getByRole('button', {
name: 'Open execution playground for create_memory',
});
await expect(tryButton).toBeVisible({ timeout: 5_000 });
await tryButton.click();
// The Tool Execution Playground opens; run the tool and assert the result
// surfaced from the mocked `mcp_clients_tool_call`.
const playground = page.getByRole('dialog');
await expect(playground.getByText('Run create_memory')).toBeVisible({ timeout: 5_000 });
await playground.getByRole('button', { name: 'Run tool' }).click();
await expect(page.getByTestId('mcp-playground-result')).toContainText('memory created id=42', {
timeout: 10_000,
});
});
});
test.describe('MCP Tab — Empty & Edge States', () => {
test('empty installed list shows appropriate message', async ({ page }) => {
const state: MockState = { installed: [], statuses: [] };
await seedLocalStorage(page);
await setupMockRpc(page, state);
await navigateToMcpTab(page);
await page.getByRole('tab', { name: /Installed/ }).click();
// Target the empty-state element directly: a broad `text=/no.*servers/i`
// locator also matches ancestor containers (the root shell wraps the panel),
// tripping Playwright strict mode.
await expect(page.getByTestId('mcp-installed-empty')).toBeVisible({ timeout: 10_000 });
});
test('search with no results shows no-results message', async ({ page }) => {
const state: MockState = { installed: [], statuses: [] };
await seedLocalStorage(page);
await setupMockRpc(page, state);
// Registered after setupMockRpc — Playwright routes use LIFO ordering, so
// this handler runs first and falls through to the base mock for all other
// methods.
await page.route('**/rpc', async (route, request) => {
const body = JSON.parse(request.postData() || '{}');
if (
body.method === 'openhuman.mcp_clients_registry_search' &&
body.params?.query === 'xyznonexistent999'
) {
return route.fulfill(rpcOk(body.id, { servers: [], page: 1, total_pages: 1 }));
}
await route.fallback();
});
await navigateToMcpTab(page);
await page.locator('input[type="search"]').fill('xyznonexistent999');
// Target the catalog empty-state element directly — a broad text regex also
// matches the root-shell ancestor container and trips strict mode.
await expect(page.getByTestId('mcp-catalog-empty')).toBeVisible({ timeout: 10_000 });
});
});