first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:52:23 +03:00
commit 880f412e2c
2662 changed files with 866266 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
/**
* Test data factories for MCP logs tests
*/
/**
* Sample MCP log entry data for testing
*/
export interface SampleMCPLogData {
mcpClient: string
tool: string
status: 'success' | 'error' | 'pending'
content?: string
}
/**
* Create sample MCP log search query
*/
export function createMCPLogSearchQuery(overrides: Partial<{ query: string }> = {}): string {
return overrides.query || `mcp-test-query-${Date.now()}`
}
/**
* Sample MCP clients for filtering
*/
export const SAMPLE_MCP_CLIENTS = ['test-client-1', 'test-client-2'] as const
/**
* Sample MCP tools for filtering
*/
export const SAMPLE_MCP_TOOLS = ['tool-1', 'tool-2', 'tool-3'] as const
/**
* Sample statuses for filtering
*/
export const SAMPLE_STATUSES = ['success', 'error', 'pending'] as const

View File

@@ -0,0 +1,275 @@
import { expect, test } from '../../core/fixtures/base.fixture'
test.describe('MCP Logs', () => {
test.beforeEach(async ({ mcpLogsPage }) => {
await mcpLogsPage.goto()
})
test.describe('MCP Logs Display', () => {
test('should display MCP logs table or getting started guide', async ({ mcpLogsPage }) => {
// When MCP logs exist the table is visible; otherwise a "Get Started" guide is shown
const tableExists = await mcpLogsPage.logsTable.isVisible().catch(() => false)
const gettingStarted = await mcpLogsPage.page.getByText(/Get Started/i).isVisible().catch(() => false)
expect(tableExists || gettingStarted).toBe(true)
})
test('should display stats cards', async ({ mcpLogsPage }) => {
// Stats cards are only visible when MCP log data exists
const tableExists = await mcpLogsPage.logsTable.isVisible().catch(() => false)
if (!tableExists) {
test.skip(true, 'No MCP logs — stats cards not rendered in getting-started view')
return
}
const statsVisible = await mcpLogsPage.areStatsVisible()
expect(statsVisible).toBe(true)
})
test('should display filters section', async ({ mcpLogsPage }) => {
// Filters are only visible when MCP log data exists (not in getting-started view)
const tableExists = await mcpLogsPage.logsTable.isVisible().catch(() => false)
if (!tableExists) {
test.skip(true, 'No MCP logs — filters not rendered in getting-started view')
return
}
const searchVisible = await mcpLogsPage.searchInput.isVisible().catch(() => false)
const filtersButtonVisible = await mcpLogsPage.filtersButton.isVisible().catch(() => false)
expect(searchVisible || filtersButtonVisible).toBe(true)
})
})
test.describe('MCP Log Filtering', () => {
test('should filter logs by tool name', async ({ mcpLogsPage, page }) => {
const filtersVisible = await mcpLogsPage.filtersButton.isVisible().catch(() => false)
if (!filtersVisible) {
test.skip(true, 'Filters button not visible')
return
}
const applied = await mcpLogsPage.filterByToolName()
if (!applied) {
test.skip(true, 'No tool name options in filter list')
return
}
await expect
.poll(() => page.url(), { timeout: 5000, intervals: [200, 300, 500] })
.toMatch(/tool_names=/)
})
test('should filter logs by server label', async ({ mcpLogsPage, page }) => {
const filtersVisible = await mcpLogsPage.filtersButton.isVisible().catch(() => false)
if (!filtersVisible) {
test.skip(true, 'Filters button not visible')
return
}
const applied = await mcpLogsPage.filterByServerLabel()
if (!applied) {
test.skip(true, 'No server label options in filter list')
return
}
await expect
.poll(() => page.url(), { timeout: 5000, intervals: [200, 300, 500] })
.toMatch(/server_labels=/)
})
test('should filter logs by status', async ({ mcpLogsPage, page }) => {
const filtersVisible = await mcpLogsPage.filtersButton.isVisible().catch(() => false)
if (!filtersVisible) {
test.skip(true, 'Filters button not visible')
return
}
const applied = await mcpLogsPage.filterByStatus('success')
if (!applied) {
test.skip(true, 'No status options in filter list')
return
}
await expect
.poll(() => page.url(), { timeout: 5000, intervals: [200, 300, 500] })
.toMatch(/status=success/)
})
test('should search logs by content', async ({ mcpLogsPage }) => {
const searchInput = mcpLogsPage.searchInput
const isVisible = await searchInput.isVisible().catch(() => false)
if (isVisible) {
const query = `test-query-${Date.now()}`
await mcpLogsPage.searchLogs(query)
// Check that search input contains the query (DOM state)
const inputValue = await searchInput.inputValue().catch(() => '')
expect(inputValue).toContain(query)
}
})
test('should filter by time period', async ({ mcpLogsPage }) => {
const datePicker = mcpLogsPage.dateRangePicker
const isVisible = await datePicker.isVisible().catch(() => false)
if (isVisible) {
// Get initial date picker value
const initialValue = await datePicker.textContent().catch(() => '')
await mcpLogsPage.selectTimePeriod('7d')
// Check that date picker value changed (DOM state)
const newValue = await datePicker.textContent().catch(() => '')
expect(newValue || initialValue).toBeTruthy()
}
})
})
test.describe('MCP Log Details', () => {
test('should open log details sheet', async ({ mcpLogsPage }) => {
const logCount = await mcpLogsPage.getLogCount()
if (logCount > 0) {
await mcpLogsPage.viewLogDetails(0)
const sheetVisible = await mcpLogsPage.logDetailSheet.isVisible().catch(() => false)
expect(sheetVisible).toBe(true)
await mcpLogsPage.closeLogDetails()
}
})
test('should close log details sheet', async ({ mcpLogsPage }) => {
const logCount = await mcpLogsPage.getLogCount()
if (logCount > 0) {
await mcpLogsPage.viewLogDetails(0)
await mcpLogsPage.closeLogDetails()
const sheetVisible = await mcpLogsPage.logDetailSheet.isVisible().catch(() => false)
expect(sheetVisible).toBe(false)
}
})
})
test.describe('Pagination', () => {
test('should navigate to next page', async ({ mcpLogsPage }) => {
const paginationVisible = await mcpLogsPage.paginationControls.isVisible().catch(() => false)
if (!paginationVisible) {
test.skip(true, 'No MCP logs — pagination not rendered')
return
}
const nextBtn = mcpLogsPage.nextPageBtn
const isEnabled = await nextBtn.isEnabled().catch(() => false)
if (!isEnabled) {
test.skip(true, 'Only one page of results; skipping pagination test')
return
}
const initialPage = mcpLogsPage.getCurrentPageNumber()
await mcpLogsPage.goToNextPage()
await expect
.poll(() => mcpLogsPage.getCurrentPageNumber(), { timeout: 5000 })
.toBe(initialPage + 1)
})
test('should navigate to previous page', async ({ mcpLogsPage }) => {
const paginationVisible = await mcpLogsPage.paginationControls.isVisible().catch(() => false)
if (!paginationVisible) {
test.skip(true, 'No MCP logs — pagination not rendered')
return
}
const nextBtn = mcpLogsPage.nextPageBtn
const nextEnabled = await nextBtn.isEnabled().catch(() => false)
if (!nextEnabled) {
test.skip(true, 'Only one page of results; skipping pagination test')
return
}
await mcpLogsPage.goToNextPage()
await expect
.poll(() => mcpLogsPage.getCurrentPageNumber(), { timeout: 5000 })
.toBe(2)
const prevBtn = mcpLogsPage.prevPageBtn
const prevEnabled = await prevBtn.isEnabled().catch(() => false)
if (!prevEnabled) {
test.skip(true, 'Only one page of results; skipping previous-page test')
return
}
await mcpLogsPage.goToPreviousPage()
// We were on page 2; after previous we must be on page 1 (assert concrete value to avoid race with captured page number)
await expect
.poll(() => mcpLogsPage.getCurrentPageNumber(), { timeout: 5000 })
.toBe(1)
})
})
test.describe('Table Sorting', () => {
test('should sort by timestamp', async ({ mcpLogsPage }) => {
const tableExists = await mcpLogsPage.logsTable.isVisible().catch(() => false)
if (!tableExists) {
test.skip(true, 'No MCP logs — table not rendered')
return
}
// Timestamp is the default sort column (desc), so clicking it toggles to asc
const initialUrl = mcpLogsPage.page.url()
await mcpLogsPage.sortBy('timestamp')
// Wait for URL to actually change after sort
await expect
.poll(() => mcpLogsPage.page.url(), { timeout: 5000 })
.not.toBe(initialUrl)
})
test('should sort by latency', async ({ mcpLogsPage }) => {
const tableExists = await mcpLogsPage.logsTable.isVisible().catch(() => false)
if (!tableExists) {
test.skip(true, 'No MCP logs — table not rendered')
return
}
await mcpLogsPage.sortBy('latency')
// Wait for URL to update
await mcpLogsPage.page.waitForURL(/sort_by=latency/, { timeout: 5000 })
// Check URL state for latency sort
const sortState = await mcpLogsPage.getSortState('latency')
expect(sortState).toBeTruthy()
})
})
test.describe('Live Updates', () => {
test('should toggle live updates', async ({ mcpLogsPage }) => {
const tableExists = await mcpLogsPage.logsTable.isVisible().catch(() => false)
if (!tableExists) {
test.skip(true, 'No MCP logs — live toggle not rendered in getting-started view')
return
}
const liveToggle = mcpLogsPage.liveToggle
const isVisible = await liveToggle.isVisible().catch(() => false)
if (isVisible) {
// Default is live_enabled=true (but URL may not have it since it's the default)
// Check for live_enabled=false to determine if currently disabled
const initialUrl = mcpLogsPage.page.url()
const initialLiveDisabled = initialUrl.includes('live_enabled=false')
await mcpLogsPage.toggleLiveUpdates()
// Wait for URL to reflect live_enabled toggle
await mcpLogsPage.page.waitForURL(/live_enabled=/, { timeout: 5000 })
const newUrl = mcpLogsPage.page.url()
const newLiveDisabled = newUrl.includes('live_enabled=false')
// Live enabled state should have toggled
// If initially enabled (not disabled), after toggle it should be disabled
// If initially disabled, after toggle it should be enabled (no live_enabled=false)
expect(newLiveDisabled).not.toBe(initialLiveDisabled)
}
})
})
})

View File

@@ -0,0 +1,392 @@
import { Locator, Page, expect } from '@playwright/test'
import { BasePage } from '../../../core/pages/base.page'
import { waitForNetworkIdle } from '../../../core/utils/test-helpers'
/**
* Page object for the MCP Logs page
*/
export class MCPLogsPage extends BasePage {
// Main elements
readonly logsTable: Locator
readonly filtersSection: Locator
readonly filtersButton: Locator
readonly statsCards: Locator
// Filter elements
readonly toolNameFilter: Locator
readonly serverLabelFilter: Locator
readonly statusFilter: Locator
readonly searchInput: Locator
readonly dateRangePicker: Locator
readonly liveToggle: Locator
// Table elements
readonly tableRows: Locator
readonly paginationControls: Locator
readonly nextPageBtn: Locator
readonly prevPageBtn: Locator
// Log detail sheet
readonly logDetailSheet: Locator
readonly closeDetailSheetBtn: Locator
constructor(page: Page) {
super(page)
// Main elements
this.logsTable = page.locator('[data-testid="mcp-logs-table"]').or(page.locator('table'))
// The filters section is the container with search input and filters button
this.filtersSection = page.locator('input[placeholder="Search MCP logs"]').locator('..')
this.filtersButton = page.getByRole('button', { name: /Filters/i })
this.statsCards = page.locator('[data-testid="mcp-stats-cards"]').or(
page.locator('text=Total Executions').locator('..').locator('..')
)
// Filter elements - filters are inside a popover opened by the Filters button
this.toolNameFilter = page.locator('[data-testid="filter-tool-name"]').or(
page.locator('button').filter({ hasText: /Tool Name/i })
)
this.serverLabelFilter = page.locator('[data-testid="filter-server-label"]').or(
page.locator('button').filter({ hasText: /Server/i })
)
this.statusFilter = page.locator('[data-testid="filter-status"]').or(
page.locator('button').filter({ hasText: /Status/i })
)
this.searchInput = page.locator('[data-testid="filter-search"]').or(
page.getByPlaceholder('Search MCP logs')
)
this.dateRangePicker = page.locator('[data-testid="filter-date-range"]').or(
page.locator('button').filter({ hasText: /Last/i })
)
this.liveToggle = page.locator('[data-testid="live-toggle"]').or(
page.getByRole('button', { name: /Live updates/i })
)
// Table elements - exclude status message rows
this.tableRows = this.logsTable.locator('tbody tr').filter({ hasNot: page.locator('text=Listening for') }).filter({ hasNot: page.locator('text=Live updates paused') }).filter({ hasNot: page.locator('text=Not connected') }).filter({ hasNot: page.locator('text=No results found') })
// Scope pagination to the MCP logs view (avoid matching other pages when navigating)
const paginationContainer = page.getByTestId('pagination').filter({ has: page.locator('[data-testid="next-page"]') }).first()
this.paginationControls = paginationContainer
this.nextPageBtn = paginationContainer.getByRole('button', { name: 'Next page' }).or(
paginationContainer.locator('[data-testid="next-page"]')
)
this.prevPageBtn = paginationContainer.getByRole('button', { name: 'Previous page' }).or(
paginationContainer.locator('[data-testid="prev-page"]')
)
// Log detail sheet - Sheet component with role="dialog"
this.logDetailSheet = page.locator('[role="dialog"]')
this.closeDetailSheetBtn = page.locator('[role="dialog"]').locator('button').filter({ has: page.locator('svg.lucide-x') })
}
/**
* Navigate to the MCP logs page
*/
async goto(): Promise<void> {
await this.page.goto('/workspace/mcp-logs')
await waitForNetworkIdle(this.page)
// Wait for table to be visible
await this.logsTable.waitFor({ state: 'visible', timeout: 10000 }).catch(() => {})
}
/**
* Open Filters popover and wait for the command list. Caller can then resolve group/option locators.
*/
private async openFiltersPopover(): Promise<void> {
await this.filtersButton.first().waitFor({ state: 'visible' })
await this.filtersButton.first().click()
await this.page.waitForSelector('[role="listbox"], [data-slot="command-list"]', { timeout: 5000 })
}
/**
* Close Filters popover (Escape) and wait for network idle.
*/
private async closeFiltersPopover(): Promise<void> {
await this.page.keyboard.press('Escape')
await this.page.waitForSelector('[role="listbox"]', { state: 'hidden', timeout: 5000 }).catch(() => {})
await waitForNetworkIdle(this.page)
}
/**
* Get the first selectable option in a filter group by heading (e.g. "Tool Names", "Servers").
* Skips "Loading..." so we only click real options.
*/
private async getFirstOptionInGroup(groupHeading: string): Promise<Locator | null> {
const list = this.page.locator('[data-slot="command-list"]').or(this.page.locator('[role="listbox"]'))
const group = list.locator('[data-slot="command-group"]').filter({
has: this.page.getByText(groupHeading, { exact: true }),
})
const items = group.locator('[data-slot="command-item"]').or(group.getByRole('option'))
const count = await items.count()
for (let i = 0; i < count; i++) {
const item = items.nth(i)
const text = await item.textContent().catch(() => '')
if (text && !/loading/i.test(text)) {
return item
}
}
return null
}
/**
* Open Filters popover and click an option by name. Returns true if the option was found and clicked.
*/
private async openFiltersAndSelectOption(optionText: string | RegExp): Promise<boolean> {
await this.openFiltersPopover()
const re = typeof optionText === 'string' ? new RegExp(optionText, 'i') : optionText
const option = this.page.getByRole('option', { name: re })
const count = await option.count()
if (count > 0) {
await option.first().click()
await this.closeFiltersPopover()
return true
}
await this.closeFiltersPopover()
return false
}
/**
* Filter by tool name: open Filters and select the first available tool name option.
* @returns true if at least one tool name option was found and selected
*/
async filterByToolName(): Promise<boolean> {
await this.openFiltersPopover()
const first = await this.getFirstOptionInGroup('Tool Names')
if (!first) {
await this.closeFiltersPopover()
return false
}
await first.click()
await this.closeFiltersPopover()
return true
}
/**
* Filter by server label: open Filters and select the first available server label option.
* @returns true if at least one server label option was found and selected
*/
async filterByServerLabel(): Promise<boolean> {
await this.openFiltersPopover()
const first = await this.getFirstOptionInGroup('Servers')
if (!first) {
await this.closeFiltersPopover()
return false
}
await first.click()
await this.closeFiltersPopover()
return true
}
/**
* Filter by status. Opens Filters popover and toggles the given status option (e.g. success, error).
* @returns true if the option was found and clicked
*/
async filterByStatus(status: 'success' | 'error' | 'pending'): Promise<boolean> {
return this.openFiltersAndSelectOption(status)
}
/**
* Search logs by content
*/
async searchLogs(query: string): Promise<void> {
await this.searchInput.fill(query)
// Wait for debounced search to trigger network request
await waitForNetworkIdle(this.page)
}
/**
* Clear search
*/
async clearSearch(): Promise<void> {
await this.searchInput.clear()
await waitForNetworkIdle(this.page)
}
/**
* Select time period
*/
async selectTimePeriod(period: '1h' | '6h' | '24h' | '7d' | '30d'): Promise<void> {
await this.dateRangePicker.first().click()
await this.page.waitForSelector('[role="listbox"], [role="menu"]', { timeout: 5000 }).catch(() => {})
const periodLabels: Record<string, string> = {
'1h': 'Last hour',
'6h': 'Last 6 hours',
'24h': 'Last 24 hours',
'7d': 'Last 7 days',
'30d': 'Last 30 days',
}
const periodButton = this.page.getByRole('button', { name: periodLabels[period] })
if (await periodButton.count() > 0) {
await periodButton.click()
} else {
await this.page.keyboard.press('Escape')
}
await waitForNetworkIdle(this.page)
}
/**
* Toggle live updates
*/
async toggleLiveUpdates(): Promise<void> {
await this.liveToggle.first().waitFor({ state: 'visible' })
await this.liveToggle.first().click()
}
/**
* Click on a log row to view details
*/
async viewLogDetails(rowIndex: number = 0): Promise<void> {
const rows = this.tableRows
const count = await rows.count()
if (count <= rowIndex) {
throw new Error(`Row index ${rowIndex} out of bounds (${count} rows available)`)
}
await rows.nth(rowIndex).click()
await expect(this.logDetailSheet).toBeVisible({ timeout: 5000 })
}
/**
* Close log detail sheet
*/
async closeLogDetails(): Promise<void> {
if (await this.logDetailSheet.isVisible()) {
await this.closeDetailSheetBtn.click().catch(async () => {
await this.page.keyboard.press('Escape')
})
await expect(this.logDetailSheet).not.toBeVisible({ timeout: 5000 })
}
}
/**
* Get log count from table
*/
async getLogCount(): Promise<number> {
return await this.tableRows.count()
}
/**
* Check if log exists in table
*/
async logExists(searchText: string): Promise<boolean> {
const row = this.tableRows.filter({ hasText: searchText })
return await row.count() > 0
}
/**
* Get current 1-based page number from URL (offset/limit).
*/
getCurrentPageNumber(): number {
const url = this.page.url()
const params = new URL(url).searchParams
const offset = Number.parseInt(params.get('offset') ?? '0', 10)
const limit = Number.parseInt(params.get('limit') ?? '50', 10) || 50
return Math.floor(offset / limit) + 1
}
/**
* Navigate to next page (waits for URL to update)
*/
async goToNextPage(): Promise<void> {
const btn = this.nextPageBtn.first()
const isEnabled = await btn.isEnabled().catch(() => false)
if (!isEnabled) return
await btn.scrollIntoViewIfNeeded()
await btn.waitFor({ state: 'visible' })
const limit = Number.parseInt(new URL(this.page.url()).searchParams.get('limit') ?? '50', 10) || 50
const currentOffset = Number.parseInt(new URL(this.page.url()).searchParams.get('offset') ?? '0', 10)
const expectedOffset = currentOffset + limit
await btn.click()
await this.page.waitForURL((url) => {
const params = new URL(url).searchParams
const offset = params.get('offset')
return offset === String(expectedOffset)
}, { timeout: 10000 })
await waitForNetworkIdle(this.page)
}
/**
* Navigate to previous page (waits for URL to update)
*/
async goToPreviousPage(): Promise<void> {
const btn = this.prevPageBtn.first()
const isEnabled = await btn.isEnabled().catch(() => false)
if (!isEnabled) return
await btn.scrollIntoViewIfNeeded()
await btn.waitFor({ state: 'visible' })
const limit = Number.parseInt(new URL(this.page.url()).searchParams.get('limit') ?? '50', 10) || 50
const currentOffset = Number.parseInt(new URL(this.page.url()).searchParams.get('offset') ?? '0', 10)
const expectedOffset = Math.max(0, currentOffset - limit)
await btn.click()
await this.page.waitForURL((url) => {
const params = new URL(url).searchParams
const offset = params.get('offset')
// When going back to page 1, offset param may be removed (null) or set to "0"
if (expectedOffset === 0) return offset === null || offset === '0'
return offset === String(expectedOffset)
}, { timeout: 10000 })
await waitForNetworkIdle(this.page)
}
/**
* Sort table by column - clicks the sort button in the column header
*/
async sortBy(column: 'timestamp' | 'latency'): Promise<void> {
await this.dismissToasts()
// Map column names to header button text
const columnLabels: Record<string, string> = {
'timestamp': 'Time',
'latency': 'Latency'
}
const label = columnLabels[column] || column
// The sortable column headers have a button with the column name
const sortButton = this.logsTable.getByRole('button', { name: new RegExp(label, 'i') })
if (await sortButton.count() > 0) {
await sortButton.first().waitFor({ state: 'visible' })
await sortButton.first().click()
await waitForNetworkIdle(this.page)
}
}
/**
* Check if stats cards are visible
*/
async areStatsVisible(): Promise<boolean> {
// MCP logs page shows "Total Executions" not "Total Requests"
const statsText = this.page.locator('text=Total Executions')
return await statsText.isVisible().catch(() => false)
}
/**
* Check if empty state is shown
*/
async isEmptyStateVisible(): Promise<boolean> {
const emptyState = this.page.locator('text=/No logs found/i').or(
this.page.locator('text=/No data/i')
)
return await emptyState.isVisible().catch(() => false)
}
/**
* Get sort state for a column from URL parameters
* Returns 'asc', 'desc', or null if column is not the current sort column
*/
async getSortState(column: 'timestamp' | 'latency'): Promise<string | null> {
const url = this.page.url()
const urlParams = new URL(url).searchParams
const sortBy = urlParams.get('sort_by')
const order = urlParams.get('order')
// Check if this column is the currently sorted column
if (sortBy === column) {
return order || 'desc' // default is desc
}
return null
}
}