Files
agent-desktop/e2e/tests/test_mcp.py
T

616 lines
26 KiB
Python

# -*- coding: utf-8 -*-
"""
QwenPaw MCP module P0 end-to-end tests
Combined test design:
- MCP-001: Page load + card info hard assertions + enable/disable toggle + state restore
- MCP-002: Create dialog open + title/format hint validation + JSON fill + cancel close
Run command: pytest tests/test_mcp_p0.py -v
"""
from __future__ import annotations
import json
import logging
import pytest
from playwright.sync_api import Page, expect
from config.settings import config
from utils.helpers import log_test_step, log_test_result
logger = logging.getLogger(__name__)
MCP_URL = f"{config.base_url}/mcp"
MCP_CARD_SELECTOR = 'div[class*="mcpCard"]'
TOGGLE_BTN_SELECTOR = 'button[class*="toggleButton"]'
CREATE_BTN_SELECTOR = 'button.qwenpaw-btn-primary:has-text("创建客户端"), button.qwenpaw-btn-primary:has-text("Create Client"), button.qwenpaw-btn-primary:has-text("Create")'
def navigate_to_mcp(page: Page):
"""Navigate to the MCP page and wait for it to load."""
page.goto(MCP_URL)
page.wait_for_load_state("domcontentloaded")
page.wait_for_timeout(2000)
# ============================================================================
# MCP-001: Page load + card info + enable/disable toggle
# ============================================================================
@pytest.mark.integration
@pytest.mark.p0
@pytest.mark.mcp
class TestMCPListAndOperations:
"""
MCP-001: Page load + card info hard assertions + enable/disable toggle + state restore
Functional coverage:
1. Breadcrumb hard assertion
2. Create button hard assertion
3. Card title/type/status hard assertions
4. Toggle enable/disable button + assert state change + restore
"""
@pytest.mark.test_id("MCP-001")
def test_mcp_list_toggle_and_cancel_delete(self, page: Page, request: pytest.FixtureRequest):
"""Verify MCP client list display and enable/disable toggle."""
test_name = request.node.name
# Step 1: Navigate to MCP page
log_test_step("1. Navigate to MCP page")
navigate_to_mcp(page)
# Step 2: Verify breadcrumb (supports both English and Chinese UI)
log_test_step("2. Verify breadcrumb")
try:
breadcrumb_cn = page.locator('span[class*="breadcrumbCurrent"]:has-text("MCP")').first
breadcrumb_en = page.locator('span[class*="breadcrumbCurrent"]:has-text("MCP")').first
if breadcrumb_cn.is_visible(timeout=3000):
logger.info("Breadcrumb validation passed (Chinese)")
elif breadcrumb_en.is_visible(timeout=3000):
logger.info("Breadcrumb validation passed (English)")
else:
logger.warning("Breadcrumb not found, skipping validation")
except Exception:
logger.warning("Breadcrumb validation skipped")
# Step 3: Verify create button
log_test_step("3. Verify create button")
create_btn = page.locator(CREATE_BTN_SELECTOR).first
expect(create_btn).to_be_visible(timeout=5000)
assert not create_btn.is_disabled(), "Create client button should not be disabled"
logger.info("Create client button is visible and enabled")
# Step 4: Verify client cards
log_test_step("4. Verify client cards")
mcp_cards = page.locator(MCP_CARD_SELECTOR).all()
if len(mcp_cards) == 0:
logger.info("MCP client list is empty, skipping card and toggle validation")
log_test_result(test_name, True, 0)
return
card_count = len(mcp_cards)
assert card_count >= 1, "Should have at least 1 MCP client"
logger.info(f"MCP client count: {card_count}")
# Verify info on the first card
first_card = mcp_cards[0]
title_el = first_card.locator('h3[class*="mcpTitle"]').first
expect(title_el).to_be_visible(timeout=5000)
title_text = title_el.inner_text()
assert len(title_text) > 0, "MCP client title is empty"
logger.info(f"Client title: {title_text}")
type_badge = first_card.locator('span[class*="typeBadge"]').first
expect(type_badge).to_be_visible(timeout=3000)
type_text = type_badge.inner_text()
assert type_text in ["Local", "Remote", "local", "remote"], f"Unexpected type label: {type_text}"
logger.info(f"Type: {type_text}")
status_el = first_card.locator('span[class*="statusText"]').first
expect(status_el).to_be_visible(timeout=3000)
status_text = status_el.inner_text()
assert status_text in ["已启用", "已禁用", "Enabled", "Disabled"], f"Unexpected status label: {status_text}"
logger.info(f"Status: {status_text}")
# Step 5: Test enable/disable toggle
log_test_step("5. Test enable/disable toggle")
toggle_btn = first_card.locator(TOGGLE_BTN_SELECTOR).first
expect(toggle_btn).to_be_visible(timeout=5000)
initial_text = toggle_btn.inner_text().strip()
initial_status = status_el.inner_text()
logger.info(f"Initial button text: {initial_text}, status: {initial_status}")
# Click to toggle
toggle_btn.click()
page.wait_for_timeout(2000)
new_text = toggle_btn.inner_text().strip()
new_status = status_el.inner_text()
assert new_text != initial_text, (
f"Toggle button text did not change: {initial_text} -> {new_text}"
)
assert new_status != initial_status, (
f"Status label did not change: {initial_status} -> {new_status}"
)
logger.info(f"Toggle succeeded: {initial_text} -> {new_text}, {initial_status} -> {new_status}")
# Step 6: Restore original state
log_test_step("6. Restore original state")
toggle_btn.click()
page.wait_for_timeout(2000)
restored_text = toggle_btn.inner_text().strip()
restored_status = status_el.inner_text()
assert restored_text == initial_text, (
f"Button text did not restore: expected {initial_text}, actual {restored_text}"
)
assert restored_status == initial_status, (
f"Status did not restore: expected {initial_status}, actual {restored_status}"
)
logger.info("State restored")
log_test_result(test_name, True, 0)
logger.info(f"Test {test_name} passed - MCP list display and enable/disable toggle work")
# ============================================================================
# MCP-002: Create dialog + JSON fill + cancel close
# ============================================================================
@pytest.mark.integration
@pytest.mark.p0
@pytest.mark.mcp
class TestCreateMCPClient:
"""
MCP-002: Create dialog open + title/format hint validation + JSON fill + cancel close
Functional coverage:
1. Click create button -> dialog opens hard assertion
2. Dialog title hard assertion
3. Format hint area hard assertion
4. JSON input filled with stdio config -> verify content
5. Switch to HTTP config -> verify content
6. Cancel button closes dialog -> verify dialog gone
"""
@pytest.mark.test_id("MCP-002")
def test_create_mcp_client_stdio_and_http(self, page: Page, request: pytest.FixtureRequest):
"""Verify create dialog open, JSON fill, and cancel close."""
test_name = request.node.name
# Step 1: Navigate to MCP page
log_test_step("1. Navigate to MCP page")
navigate_to_mcp(page)
# Step 2: Click create button
log_test_step("2. Click create button")
create_btn = page.locator(CREATE_BTN_SELECTOR).first
expect(create_btn).to_be_visible(timeout=5000)
create_btn.click()
page.wait_for_timeout(1000)
# Step 3: Verify dialog opens
log_test_step("3. Verify dialog opens")
modal = page.locator('.qwenpaw-modal-content').first
expect(modal).to_be_visible(timeout=5000)
logger.info("Create dialog opened")
# Step 4: Verify dialog title
log_test_step("4. Verify dialog title")
modal_title = modal.locator('.qwenpaw-spark-modal-title').first
expect(modal_title).to_be_visible(timeout=3000)
title_text = modal_title.inner_text()
assert "创建客户端" in title_text or "Create" in title_text, f"Unexpected dialog title: {title_text}"
logger.info(f"Dialog title: {title_text}")
# Step 5: Verify format hint
log_test_step("5. Verify format hint")
import_hint = modal.locator('[class*="importHint"]').first
expect(import_hint).to_be_visible(timeout=3000)
hint_text = import_hint.inner_text()
assert "支持的格式" in hint_text or "Supported format" in hint_text, f"Unexpected format hint: {hint_text[:50]}"
logger.info("Format hint validation passed")
# Step 6: Fill stdio-type JSON config
log_test_step("6. Fill stdio-type config")
json_textarea = modal.locator('textarea[class*="jsonTextArea"]').first
if not json_textarea.is_visible():
json_textarea = modal.locator('textarea').first
expect(json_textarea).to_be_visible(timeout=5000)
stdio_config = json.dumps({
"mcpServers": {
"test_stdio": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-everything"]
}
}
}, indent=2)
json_textarea.fill(stdio_config)
page.wait_for_timeout(500)
filled_value = json_textarea.input_value()
assert "test_stdio" in filled_value, "stdio config was not filled correctly"
assert "npx" in filled_value, "stdio config missing command"
logger.info("stdio config filled and verified")
# Step 7: Switch to HTTP-type config
log_test_step("7. Switch to HTTP-type config")
http_config = json.dumps({
"mcpServers": {
"test_http": {
"url": "https://example-mcp-server.com/mcp",
"transport": "streamable_http"
}
}
}, indent=2)
json_textarea.fill(http_config)
page.wait_for_timeout(500)
filled_http = json_textarea.input_value()
assert "test_http" in filled_http, "HTTP config was not filled correctly"
assert "streamable_http" in filled_http, "HTTP config missing transport"
logger.info("HTTP config filled and verified")
# Step 8: Cancel creation and verify dialog closes
log_test_step("8. Cancel creation and verify dialog closes")
cancel_btn = modal.locator('button:has-text("取 消"), button:has-text("取消"), button:has-text("Cancel")').first
expect(cancel_btn).to_be_visible(timeout=3000)
cancel_btn.click()
page.wait_for_timeout(1000)
expect(modal).not_to_be_visible(timeout=5000)
logger.info("Dialog closed")
log_test_result(test_name, True, 0)
logger.info(f"Test {test_name} passed - create dialog open, JSON fill, and cancel close work")
# ============================================================================
# MCP-003: Create and delete MCP client
# ============================================================================
@pytest.mark.integration
@pytest.mark.p0
@pytest.mark.mcp
class TestMCPClientCreateAndDelete:
"""
MCP-003: Create and delete MCP client
Functional coverage:
1. Navigate to MCP page and record initial client count
2. Click create button to open dialog
3. Fill stdio-type JSON config (using test_e2e_client as name)
4. Click confirm/create button
5. Verify new client appears in the list
6. Find the new client card
7. Find delete button and click
8. Confirm deletion
9. Verify client removed from the list
"""
@pytest.mark.test_id("MCP-003")
def test_create_and_delete_mcp_client(self, page: Page, request: pytest.FixtureRequest):
"""Verify MCP client creation and deletion flow."""
test_name = request.node.name
client_name = None
client_created = False
try:
# Step 1: Navigate to MCP page
log_test_step("1. Navigate to MCP page")
navigate_to_mcp(page)
# Step 2: Record initial client count
log_test_step("2. Record initial client count")
initial_cards = page.locator(MCP_CARD_SELECTOR).all()
initial_count = len(initial_cards)
logger.info(f"Initial MCP client count: {initial_count}")
# Step 3: Click create button
log_test_step("3. Click create button")
create_btn = page.locator(CREATE_BTN_SELECTOR).first
expect(create_btn).to_be_visible(timeout=5000)
create_btn.click()
page.wait_for_timeout(1500)
# Step 4: Verify dialog opens
log_test_step("4. Verify dialog opens")
modal = page.locator('.qwenpaw-modal-content').first
expect(modal).to_be_visible(timeout=5000)
logger.info("Create dialog opened")
# Step 5: Fill stdio-type JSON config
log_test_step("5. Fill stdio-type config")
timestamp = int(page.evaluate("Date.now()"))
client_name = f"test_e2e_client_{timestamp}"
stdio_config = json.dumps({
"mcpServers": {
client_name: {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-everything"]
}
}
}, indent=2)
json_textarea = modal.locator('textarea[class*="jsonTextArea"]').first
if not json_textarea.is_visible():
json_textarea = modal.locator('textarea').first
expect(json_textarea).to_be_visible(timeout=5000)
json_textarea.fill(stdio_config)
page.wait_for_timeout(500)
filled_value = json_textarea.input_value()
assert client_name in filled_value, f"Client name was not filled correctly: {client_name}"
logger.info(f"JSON config filled, client name: {client_name}")
# Step 6: Click confirm/create button
log_test_step("6. Click confirm/create button")
confirm_btn = modal.locator('button.qwenpaw-btn-primary:has-text("确 定"), button:has-text("确定"), button:has-text("创建")').first
if not confirm_btn.is_visible():
confirm_btn = modal.locator('button.qwenpaw-btn-primary').last
expect(confirm_btn).to_be_visible(timeout=5000)
confirm_btn.click()
page.wait_for_timeout(2000)
# Verify dialog closes
expect(modal).not_to_be_visible(timeout=5000)
client_created = True
logger.info("Client created, dialog closed")
# Step 7: Verify new client appears in the list.
# Assert the count grew by at least one rather than an exact
# delta: under CI `--reruns=1`, a client left over from a prior
# attempt can offset the baseline (observed expected 1 / actual
# 3). The authoritative check is Step 8, which locates our
# uniquely-named client by title.
log_test_step("7. Verify new client appears in the list")
page.wait_for_timeout(1000)
updated_cards = page.locator(MCP_CARD_SELECTOR).all()
updated_count = len(updated_cards)
assert updated_count >= initial_count + 1, (
f"Client count did not grow after creation: "
f"initial {initial_count}, now {updated_count}"
)
logger.info(f"Creation succeeded, current client count: {updated_count}")
# Step 8: Find the new client card
log_test_step("8. Find the new client card")
new_client_card = None
for card in updated_cards:
title_el = card.locator('h3[class*="mcpTitle"]').first
if title_el.is_visible():
title_text = title_el.inner_text()
if client_name in title_text:
new_client_card = card
break
assert new_client_card is not None, f"New client not found: {client_name}"
logger.info("New client card found")
# Step 9-10: Delete verification runs in finally
log_test_result(test_name, True, 0)
logger.info(f"Test {test_name} passed - MCP client creation validation passed")
finally:
# Cleanup: delete the client created by the test (re-navigate to ensure correct page state)
if client_created and client_name:
try:
log_test_step("Cleanup: delete test client")
page.goto(f"{config.base_url}/mcp")
page.wait_for_timeout(2000)
cleanup_cards = page.locator(MCP_CARD_SELECTOR).all()
for card in cleanup_cards:
title_el = card.locator('h3[class*="mcpTitle"]').first
if title_el.is_visible():
title_text = title_el.inner_text()
if client_name in title_text:
delete_btn = card.locator('button:has-text("删除"), button[title="删除"], button[class*="deleteBtn"]').first
if not delete_btn.is_visible():
card_footer = card.locator('div[class*="cardFooter"], div[class*="actions"]').first
if card_footer.is_visible():
delete_btn = card_footer.locator('button:has-text("删除")').first
if delete_btn.is_visible():
delete_btn.click()
page.wait_for_timeout(1000)
confirm_delete_btn = page.locator('button.qwenpaw-btn-danger:has-text("删除"), .qwenpaw-modal-confirm button.qwenpaw-btn-primary, button:has-text("确 定"), button:has-text("确定")').first
if confirm_delete_btn.is_visible():
confirm_delete_btn.click()
page.wait_for_timeout(2000)
logger.info(f"Cleanup: deleted test client '{client_name}'")
break
except Exception:
logger.warning(f"Cleanup failed: could not delete test client '{client_name}'")
# ============================================================================
# MCP-004: MCP client edit API
# ============================================================================
@pytest.mark.integration
@pytest.mark.p1
@pytest.mark.mcp
class TestMcpClientEdit:
"""
MCP-P1-004: MCP client edit configuration
Functional coverage:
1. Click MCP card to open config Modal
2. Verify Modal has Edit button
3. Click Edit to enter edit mode
4. Verify JSON edit area exists
"""
@pytest.mark.test_id("MCP-P1-004")
def test_mcp_client_edit(self, page: Page, request: pytest.FixtureRequest):
"""Test MCP client edit configuration."""
test_name = request.node.name
log_test_step("Navigate to MCP management page")
page.goto(f"{config.base_url}/mcp")
page.wait_for_load_state("domcontentloaded")
page.wait_for_timeout(3000)
log_test_step("Find MCP client cards")
mcp_cards = page.locator(MCP_CARD_SELECTOR).all()
if len(mcp_cards) == 0:
logger.info("No MCP client cards, skipping edit test")
log_test_result(test_name, True, 0)
return
logger.info(f"Found {len(mcp_cards)} MCP client cards")
log_test_step("Click the first MCP card")
mcp_cards[0].click()
page.wait_for_timeout(2000)
log_test_step("Verify config Modal opened")
modal = page.locator('.qwenpaw-modal').last
expect(modal).to_be_visible(timeout=5000)
logger.info("Config Modal opened")
log_test_step("Find Edit button")
edit_btn = modal.locator(
'button:has-text("Edit"), button:has-text("编辑")'
).first
if edit_btn.count() > 0:
expect(edit_btn).to_be_visible(timeout=5000)
logger.info("Edit button exists")
log_test_step("Click Edit to enter edit mode")
edit_btn.click()
page.wait_for_timeout(1500)
log_test_step("Verify JSON edit area exists and test editing")
json_editor = modal.locator('textarea, .qwenpaw-input-textarea, [class*="editor"]').first
if json_editor.count() > 0:
expect(json_editor).to_be_visible(timeout=5000)
tag_name = json_editor.evaluate('el => el.tagName')
original_content = json_editor.input_value() if tag_name == 'TEXTAREA' else json_editor.inner_text()
assert len(original_content) > 2, "JSON edit area content is empty"
logger.info(f"JSON edit area exists, content length: {len(original_content)}")
# Verify editor is editable: add test content then restore
if tag_name == 'TEXTAREA':
test_content = original_content.rstrip() + '\n'
json_editor.fill(test_content)
page.wait_for_timeout(500)
edited_value = json_editor.input_value()
assert len(edited_value) > 0, "Editor should be editable"
logger.info("JSON editor is editable")
# Restore original content
json_editor.fill(original_content)
page.wait_for_timeout(300)
else:
code_editor = modal.locator('[class*="CodeMirror"], [class*="monaco"], pre code').first
if code_editor.count() > 0:
assert code_editor.is_visible(), "Code editor should be visible"
logger.info("Found code editor component")
else:
logger.info("No editor component found")
else:
logger.info("No Edit button found, verify Modal has config content")
modal_content = modal.inner_text()
assert len(modal_content) > 20, "Modal content too short"
logger.info(f"Modal content length: {len(modal_content)}")
log_test_step("Close Modal")
close_btn = modal.locator('.qwenpaw-modal-close, button:has-text("Cancel"), button:has-text("取消"), button:has-text("Close"), button:has-text("关闭")').first
if close_btn.count() > 0:
close_btn.click()
else:
page.keyboard.press("Escape")
page.wait_for_timeout(1000)
log_test_result(test_name, True, 0)
# ============================================================================
# MCP-P1-005: Multi-protocol creation (stdio/sse/streamable-http)
# ============================================================================
@pytest.mark.integration
@pytest.mark.p1
@pytest.mark.mcp
class TestMcpMultiProtocol:
"""
MCP-P1-005: Multi-protocol creation
Functional coverage:
1. Open create MCP client dialog
2. Verify JSON input area exists
3. Enter stdio protocol JSON config
4. Verify JSON format is correct
"""
@pytest.mark.test_id("MCP-P1-005")
def test_mcp_multi_protocol(self, page: Page, request: pytest.FixtureRequest):
"""Test MCP multi-protocol creation."""
test_name = request.node.name
log_test_step("Navigate to MCP management page")
page.goto(f"{config.base_url}/mcp")
page.wait_for_load_state("domcontentloaded")
page.wait_for_timeout(3000)
log_test_step("Find create button")
create_btn = page.locator(
'button:has-text("Add"), button:has-text("添加"), '
'button:has-text("Create"), button:has-text("创建"), '
'button:has-text("New"), button:has-text("新建")'
).first
assert create_btn.count() > 0, "Create MCP client button not found"
expect(create_btn).to_be_visible(timeout=5000)
logger.info("Create button exists")
log_test_step("Click create button")
create_btn.click()
page.wait_for_timeout(1500)
log_test_step("Verify create dialog/area")
# Create uses JSON TextArea input; exclude hidden textareas
# First search within the dialog/drawer context
modal_or_drawer = page.locator('.qwenpaw-modal, .ant-modal, .qwenpaw-drawer, .ant-drawer').last
if modal_or_drawer.count() > 0:
json_input = modal_or_drawer.locator(
'textarea:not([aria-hidden="true"]), '
'.qwenpaw-input-textarea textarea, '
'[class*="editor"], [class*="CodeMirror"]'
).first
else:
json_input = page.locator(
'textarea:not([aria-hidden="true"]):visible'
).first
if json_input.count() > 0:
expect(json_input).to_be_visible(timeout=5000)
logger.info("JSON input area exists")
log_test_step("Enter stdio protocol config")
stdio_config = '{"name": "test-stdio", "transport": "stdio", "command": "echo", "args": ["hello"]}'
json_input.fill(stdio_config)
page.wait_for_timeout(500)
filled_value = json_input.input_value()
assert "stdio" in filled_value, "JSON input does not contain stdio config"
logger.info("stdio protocol config entered")
# Clear input without actually creating
json_input.clear()
page.wait_for_timeout(500)
else:
logger.info("No JSON input area found, may be using a different creation method")
modal = page.locator('.qwenpaw-modal, .ant-modal').last
if modal.count() > 0:
modal_content = modal.inner_text()
logger.info(f"Dialog content length: {len(modal_content)}")
log_test_step("Close create dialog")
page.keyboard.press("Escape")
page.wait_for_timeout(1000)
log_test_result(test_name, True, 0)