first commit
This commit is contained in:
35
tests/e2e/features/logs/logs.data.ts
Normal file
35
tests/e2e/features/logs/logs.data.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Test data factories for logs tests
|
||||
*/
|
||||
|
||||
/**
|
||||
* Sample log entry data for testing
|
||||
*/
|
||||
export interface SampleLogData {
|
||||
provider: string
|
||||
model: string
|
||||
status: 'success' | 'error' | 'pending'
|
||||
content?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Create sample log search query
|
||||
*/
|
||||
export function createLogSearchQuery(overrides: Partial<{ query: string }> = {}): string {
|
||||
return overrides.query || `test-query-${Date.now()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample providers for filtering
|
||||
*/
|
||||
export const SAMPLE_PROVIDERS = ['openai', 'anthropic', 'gemini'] as const
|
||||
|
||||
/**
|
||||
* Sample models for filtering
|
||||
*/
|
||||
export const SAMPLE_MODELS = ['gpt-4', 'claude-3-opus', 'gemini-pro'] as const
|
||||
|
||||
/**
|
||||
* Sample statuses for filtering
|
||||
*/
|
||||
export const SAMPLE_STATUSES = ['success', 'error', 'pending'] as const
|
||||
427
tests/e2e/features/logs/logs.spec.ts
Normal file
427
tests/e2e/features/logs/logs.spec.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import { expect, test } from '../../core/fixtures/base.fixture'
|
||||
import { createLogSearchQuery, SAMPLE_MODELS, SAMPLE_PROVIDERS } from './logs.data'
|
||||
|
||||
test.describe('LLM Logs', () => {
|
||||
test.beforeEach(async ({ logsPage }) => {
|
||||
await logsPage.goto()
|
||||
})
|
||||
|
||||
test.describe('Logs Display', () => {
|
||||
test('should display logs table', async ({ logsPage }) => {
|
||||
// Table should be visible after goto (which waits for load)
|
||||
const tableExists = await logsPage.logsTable.isVisible().catch(() => false)
|
||||
expect(tableExists).toBe(true)
|
||||
})
|
||||
|
||||
test('should display stats cards', async ({ logsPage }) => {
|
||||
const statsVisible = await logsPage.areStatsVisible()
|
||||
expect(statsVisible).toBe(true)
|
||||
})
|
||||
|
||||
test('should display filters section', async ({ logsPage }) => {
|
||||
// Check if the search input or filters button is visible
|
||||
// These are always visible when the page loads (not inside empty state)
|
||||
const searchVisible = await logsPage.searchInput.isVisible().catch(() => false)
|
||||
const filtersButtonVisible = await logsPage.filtersButton.isVisible().catch(() => false)
|
||||
|
||||
// Either search input OR filters button should be visible
|
||||
expect(searchVisible || filtersButtonVisible).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Log Filtering', () => {
|
||||
test('should filter logs by provider', async ({ logsPage }) => {
|
||||
// Try to filter by first available provider
|
||||
const providerFilter = logsPage.providerFilter
|
||||
const isVisible = await providerFilter.isVisible().catch(() => false)
|
||||
|
||||
if (!isVisible || SAMPLE_PROVIDERS.length === 0) {
|
||||
test.skip(!isVisible || SAMPLE_PROVIDERS.length === 0, 'Provider filter not visible or no sample providers')
|
||||
return
|
||||
}
|
||||
|
||||
// Get initial filter state
|
||||
const initialValue = await providerFilter.textContent().catch(() => '')
|
||||
|
||||
await logsPage.filterByProvider(SAMPLE_PROVIDERS[0])
|
||||
|
||||
// Check that filter value changed (or verify filter is applied via DOM)
|
||||
const newValue = await providerFilter.textContent().catch(() => '')
|
||||
// Filter should have changed or show selected provider
|
||||
expect(newValue || initialValue).toBeTruthy()
|
||||
})
|
||||
|
||||
test('should filter logs by model', async ({ logsPage }) => {
|
||||
const modelFilter = logsPage.modelFilter
|
||||
const isVisible = await modelFilter.isVisible().catch(() => false)
|
||||
|
||||
if (!isVisible || SAMPLE_MODELS.length === 0) {
|
||||
test.skip(!isVisible || SAMPLE_MODELS.length === 0, 'Model filter not visible or no sample models')
|
||||
return
|
||||
}
|
||||
|
||||
// Get initial filter state
|
||||
const initialValue = await modelFilter.textContent().catch(() => '')
|
||||
|
||||
await logsPage.filterByModel(SAMPLE_MODELS[0])
|
||||
|
||||
// Check that filter value changed (or verify filter is applied via DOM)
|
||||
const newValue = await modelFilter.textContent().catch(() => '')
|
||||
// Filter should have changed or show selected model
|
||||
expect(newValue || initialValue).toBeTruthy()
|
||||
})
|
||||
|
||||
test('should filter logs by status', async ({ logsPage, page }) => {
|
||||
const filtersVisible = await logsPage.filtersButton.isVisible().catch(() => false)
|
||||
if (!filtersVisible) {
|
||||
test.skip(true, 'Filters button not visible')
|
||||
return
|
||||
}
|
||||
|
||||
await logsPage.filterByStatus('success')
|
||||
|
||||
// Assert status filter is applied: logs page persists filters in URL (e.g. status=success)
|
||||
await expect
|
||||
.poll(() => page.url(), { timeout: 5000, intervals: [200, 300, 500] })
|
||||
.toMatch(/status=success/)
|
||||
})
|
||||
|
||||
test('should search logs by content', async ({ logsPage }) => {
|
||||
const searchInput = logsPage.searchInput
|
||||
const isVisible = await searchInput.isVisible().catch(() => false)
|
||||
|
||||
if (!isVisible) {
|
||||
test.skip(true, 'Search input not visible')
|
||||
return
|
||||
}
|
||||
|
||||
const query = createLogSearchQuery()
|
||||
await logsPage.searchLogs(query)
|
||||
|
||||
// Check that search input contains the query (DOM state)
|
||||
const inputValue = await searchInput.inputValue().catch(() => '')
|
||||
expect(inputValue).toContain(query)
|
||||
})
|
||||
|
||||
test('should clear search', async ({ logsPage }) => {
|
||||
const searchInput = logsPage.searchInput
|
||||
const isVisible = await searchInput.isVisible().catch(() => false)
|
||||
|
||||
if (!isVisible) {
|
||||
test.skip(true, 'Search input not visible')
|
||||
return
|
||||
}
|
||||
|
||||
await logsPage.searchLogs('test query')
|
||||
await logsPage.clearSearch()
|
||||
|
||||
// Search should be cleared
|
||||
const inputValue = await searchInput.inputValue().catch(() => '')
|
||||
expect(inputValue).toBe('')
|
||||
})
|
||||
|
||||
test('should filter by time period', async ({ logsPage }) => {
|
||||
const datePicker = logsPage.dateRangePicker
|
||||
const isVisible = await datePicker.isVisible().catch(() => false)
|
||||
|
||||
if (!isVisible) {
|
||||
test.skip(true, 'Date range picker not visible')
|
||||
return
|
||||
}
|
||||
|
||||
// Get initial date picker value
|
||||
const initialValue = await datePicker.textContent().catch(() => '')
|
||||
|
||||
await logsPage.selectTimePeriod('7d')
|
||||
|
||||
// Check that date picker value changed (DOM state)
|
||||
const newValue = await datePicker.textContent().catch(() => '')
|
||||
// Date picker should show "Last 7 days" or similar
|
||||
expect(newValue || initialValue).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Log Details', () => {
|
||||
test('should open log details sheet', async ({ logsPage }) => {
|
||||
// Wait a bit for logs to potentially load
|
||||
await logsPage.page.waitForTimeout(1000)
|
||||
|
||||
const logCount = await logsPage.getLogCount()
|
||||
|
||||
if (logCount > 0) {
|
||||
await logsPage.viewLogDetails(0)
|
||||
|
||||
// Wait for sheet animation
|
||||
await logsPage.page.waitForTimeout(500)
|
||||
|
||||
// Detail sheet should be visible
|
||||
const sheetVisible = await logsPage.logDetailSheet.isVisible().catch(() => false)
|
||||
expect(sheetVisible).toBe(true)
|
||||
|
||||
// Close the sheet
|
||||
await logsPage.closeLogDetails()
|
||||
} else {
|
||||
// If no logs exist, the test passes (nothing to click)
|
||||
expect(logCount).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
test('should close log details sheet', async ({ logsPage }) => {
|
||||
const logCount = await logsPage.getLogCount()
|
||||
|
||||
if (logCount > 0) {
|
||||
await logsPage.viewLogDetails(0)
|
||||
await logsPage.closeLogDetails()
|
||||
|
||||
// Sheet should be closed
|
||||
const sheetVisible = await logsPage.logDetailSheet.isVisible().catch(() => false)
|
||||
expect(sheetVisible).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Pagination', () => {
|
||||
test('should navigate to next page', async ({ logsPage }) => {
|
||||
// Wait for pagination to settle (useTablePageSize may adjust limit dynamically)
|
||||
await logsPage.page.waitForTimeout(2000)
|
||||
const paginationVisible = await logsPage.paginationControls.isVisible().catch(() => false)
|
||||
if (!paginationVisible) {
|
||||
test.skip(true, 'Pagination controls not visible')
|
||||
return
|
||||
}
|
||||
const nextBtn = logsPage.nextPageBtn.first()
|
||||
const isEnabled = await nextBtn.isEnabled().catch(() => false)
|
||||
|
||||
if (!isEnabled) {
|
||||
test.skip(true, 'Only one page of results; skipping pagination test')
|
||||
return
|
||||
}
|
||||
|
||||
const initialPage = logsPage.getCurrentPageNumber()
|
||||
expect(initialPage).toBe(1)
|
||||
await logsPage.goToNextPage()
|
||||
|
||||
await expect
|
||||
.poll(() => logsPage.getCurrentPageNumber(), { timeout: 5000 })
|
||||
.toBe(initialPage + 1)
|
||||
})
|
||||
|
||||
test('should navigate to previous page', async ({ logsPage }) => {
|
||||
// Wait for pagination to settle (useTablePageSize may adjust limit dynamically)
|
||||
await logsPage.page.waitForTimeout(2000)
|
||||
const paginationVisible = await logsPage.paginationControls.isVisible().catch(() => false)
|
||||
if (!paginationVisible) {
|
||||
test.skip(true, 'Pagination controls not visible')
|
||||
return
|
||||
}
|
||||
const nextBtn = logsPage.nextPageBtn.first()
|
||||
const nextEnabled = await nextBtn.isEnabled().catch(() => false)
|
||||
|
||||
if (!nextEnabled) {
|
||||
test.skip(true, 'Only one page of results; skipping pagination test')
|
||||
return
|
||||
}
|
||||
|
||||
await logsPage.goToNextPage()
|
||||
|
||||
await expect
|
||||
.poll(() => logsPage.getCurrentPageNumber(), { timeout: 5000 })
|
||||
.toBe(2)
|
||||
|
||||
const prevBtn = logsPage.prevPageBtn.first()
|
||||
const prevEnabled = await prevBtn.isEnabled().catch(() => false)
|
||||
|
||||
if (!prevEnabled) {
|
||||
test.skip(true, 'Only one page of results; skipping previous-page test')
|
||||
return
|
||||
}
|
||||
|
||||
await logsPage.goToPreviousPage()
|
||||
|
||||
await expect
|
||||
.poll(() => logsPage.getCurrentPageNumber(), { timeout: 5000 })
|
||||
.toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Table Sorting', () => {
|
||||
test('should sort by timestamp', async ({ logsPage }) => {
|
||||
// Timestamp is the default sort column (desc), so clicking it toggles to asc
|
||||
await logsPage.sortBy('timestamp')
|
||||
|
||||
// Timestamp sort toggles order; wait for URL to reflect the change
|
||||
await logsPage.page.waitForURL(/order=asc|sort_by=timestamp/, { timeout: 5000 })
|
||||
})
|
||||
|
||||
test('should sort by latency', async ({ logsPage }) => {
|
||||
await logsPage.sortBy('latency')
|
||||
|
||||
// Wait for URL to update
|
||||
await logsPage.page.waitForURL(/sort_by=latency/, { timeout: 5000 })
|
||||
|
||||
// Check URL state for latency sort
|
||||
const sortState = await logsPage.getSortState('latency')
|
||||
expect(sortState).toBeTruthy()
|
||||
})
|
||||
|
||||
test('should sort by cost', async ({ logsPage }) => {
|
||||
await logsPage.sortBy('cost')
|
||||
|
||||
// Wait for URL to update
|
||||
await logsPage.page.waitForURL(/sort_by=cost/, { timeout: 5000 })
|
||||
|
||||
// Check URL state for cost sort
|
||||
const sortState = await logsPage.getSortState('cost')
|
||||
expect(sortState).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Live Updates', () => {
|
||||
test('should toggle live updates', async ({ logsPage }) => {
|
||||
const liveToggle = logsPage.liveToggle
|
||||
const isVisible = await liveToggle.isVisible().catch(() => false)
|
||||
|
||||
if (!isVisible) {
|
||||
test.skip(true, 'Live toggle not visible')
|
||||
return
|
||||
}
|
||||
|
||||
// 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 = logsPage.page.url()
|
||||
const initialLiveDisabled = initialUrl.includes('live_enabled=false')
|
||||
|
||||
await logsPage.toggleLiveUpdates()
|
||||
|
||||
// Wait for URL to reflect live_enabled toggle
|
||||
await logsPage.page.waitForURL(/live_enabled=/, { timeout: 5000 })
|
||||
|
||||
const newUrl = logsPage.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)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Empty State', () => {
|
||||
test('should show empty state when no logs', async ({ logsPage }) => {
|
||||
// Try to filter by a non-existent provider
|
||||
const searchInput = logsPage.searchInput
|
||||
const isVisible = await searchInput.isVisible().catch(() => false)
|
||||
|
||||
if (!isVisible) {
|
||||
test.skip(true, 'Search input not visible')
|
||||
return
|
||||
}
|
||||
|
||||
await logsPage.searchLogs(`nonexistent-query-${Date.now()}`)
|
||||
|
||||
// After searching for a non-existent query, empty state should appear (wait for API + render)
|
||||
await expect(
|
||||
logsPage.page.locator('text=/No results found|No logs found/i')
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Advanced Filtering', () => {
|
||||
test('should combine multiple filters', async ({ logsPage }) => {
|
||||
// Apply multiple filters if they're visible
|
||||
const searchVisible = await logsPage.searchInput.isVisible().catch(() => false)
|
||||
const providerVisible = await logsPage.providerFilter.isVisible().catch(() => false)
|
||||
|
||||
if (!searchVisible || !providerVisible) {
|
||||
test.skip(true, 'Search input or provider filter not visible')
|
||||
return
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
await logsPage.searchLogs('test')
|
||||
|
||||
// Apply provider filter
|
||||
if (SAMPLE_PROVIDERS.length > 0) {
|
||||
await logsPage.filterByProvider(SAMPLE_PROVIDERS[0])
|
||||
}
|
||||
|
||||
// Both filters should be applied
|
||||
const searchValue = await logsPage.searchInput.inputValue().catch(() => '')
|
||||
expect(searchValue).toContain('test')
|
||||
})
|
||||
|
||||
test('should clear all filters', async ({ logsPage }) => {
|
||||
const searchVisible = await logsPage.searchInput.isVisible().catch(() => false)
|
||||
|
||||
if (!searchVisible) {
|
||||
test.skip(true, 'Search input not visible')
|
||||
return
|
||||
}
|
||||
|
||||
// Apply a filter first
|
||||
await logsPage.searchLogs('test query to clear')
|
||||
|
||||
// Clear the search
|
||||
await logsPage.clearSearch()
|
||||
|
||||
// Search should be empty
|
||||
const searchValue = await logsPage.searchInput.inputValue().catch(() => '')
|
||||
expect(searchValue).toBe('')
|
||||
})
|
||||
|
||||
test('should search within filtered results', async ({ logsPage }) => {
|
||||
const searchVisible = await logsPage.searchInput.isVisible().catch(() => false)
|
||||
const statusVisible = await logsPage.statusFilter.isVisible().catch(() => false)
|
||||
|
||||
if (!searchVisible || !statusVisible) {
|
||||
test.skip(true, 'Search input or status filter not visible')
|
||||
return
|
||||
}
|
||||
|
||||
// Apply status filter first
|
||||
await logsPage.filterByStatus('success')
|
||||
|
||||
// Then apply search
|
||||
await logsPage.searchLogs('api')
|
||||
|
||||
// Search input should contain the query
|
||||
const searchValue = await logsPage.searchInput.inputValue().catch(() => '')
|
||||
expect(searchValue).toContain('api')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('URL State Persistence', () => {
|
||||
test('should persist filters in URL', async ({ logsPage }) => {
|
||||
const searchVisible = await logsPage.searchInput.isVisible().catch(() => false)
|
||||
if (!searchVisible) return
|
||||
|
||||
await logsPage.searchLogs('persistent-search')
|
||||
|
||||
// Search is debounced (500ms) then URL updates; wait for URL to contain the param
|
||||
await expect
|
||||
.poll(
|
||||
() => logsPage.page.url(),
|
||||
{ timeout: 8000, intervals: [300, 500, 500] }
|
||||
)
|
||||
.toContain('content_search=')
|
||||
const url = logsPage.page.url()
|
||||
// Value may be percent-encoded (e.g. persistent-search → persistent%2Dsearch)
|
||||
expect(decodeURIComponent(url)).toContain('persistent-search')
|
||||
})
|
||||
|
||||
test('should restore state from URL', async ({ logsPage, page }) => {
|
||||
// Logs page uses start_time and end_time (unix timestamps), not period
|
||||
const endTime = Math.floor(Date.now() / 1000)
|
||||
const startTime = endTime - 7 * 24 * 60 * 60 // 7 days ago
|
||||
await page.goto(`/workspace/logs?start_time=${startTime}&end_time=${endTime}`)
|
||||
|
||||
// Wait for page to load and URL to reflect state (nuqs may merge or keep params)
|
||||
await expect
|
||||
.poll(() => page.url(), { timeout: 5000, intervals: [200, 300, 500] })
|
||||
.toMatch(/start_time=\d+/)
|
||||
const url = page.url()
|
||||
expect(url).toMatch(/start_time=\d+/)
|
||||
expect(url).toMatch(/end_time=\d+/)
|
||||
})
|
||||
})
|
||||
})
|
||||
384
tests/e2e/features/logs/pages/logs.page.ts
Normal file
384
tests/e2e/features/logs/pages/logs.page.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
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 LLM Logs page
|
||||
*/
|
||||
export class LogsPage extends BasePage {
|
||||
// Main elements
|
||||
readonly logsTable: Locator
|
||||
readonly filtersSection: Locator
|
||||
readonly filtersButton: Locator
|
||||
readonly statsCards: Locator
|
||||
|
||||
// Filter elements
|
||||
readonly providerFilter: Locator
|
||||
readonly modelFilter: 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="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 logs"]').locator('..')
|
||||
this.filtersButton = page.getByRole('button', { name: /Filters/i })
|
||||
this.statsCards = page.locator('[data-testid="stats-cards"]').or(page.locator('text=Total Requests').locator('..').locator('..'))
|
||||
|
||||
// Filter elements - filters are inside a popover opened by the Filters button
|
||||
this.providerFilter = page.locator('[data-testid="filter-provider"]').or(
|
||||
page.locator('button').filter({ hasText: /Provider/i })
|
||||
)
|
||||
this.modelFilter = page.locator('[data-testid="filter-model"]').or(
|
||||
page.locator('button').filter({ hasText: /Model/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 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 the "Listening for logs" row which is not a data row
|
||||
this.tableRows = this.logsTable.locator('tbody tr').filter({ hasNot: page.locator('text=Listening for logs') }).filter({ hasNot: page.locator('text=Live updates paused') }).filter({ hasNot: page.locator('text=Not connected') }).filter({ hasNot: page.locator('text=No results found') })
|
||||
// LLM logs pagination (data-testid added to logsTable.tsx)
|
||||
this.paginationControls = page.getByTestId('pagination')
|
||||
this.nextPageBtn = page.getByTestId('next-page')
|
||||
this.prevPageBtn = page.getByTestId('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 logs page
|
||||
*/
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto('/workspace/logs')
|
||||
await waitForNetworkIdle(this.page)
|
||||
// Wait for table or empty state to be visible
|
||||
await this.logsTable.or(this.page.locator('text=/No logs found|No results found/i')).waitFor({ state: 'visible', timeout: 10000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the logs page with a small page size so pagination can be tested with fewer total logs.
|
||||
*/
|
||||
async gotoWithSmallPageSize(limit = 5): Promise<void> {
|
||||
await this.page.goto(`/workspace/logs?limit=${limit}&offset=0`)
|
||||
await waitForNetworkIdle(this.page)
|
||||
await this.logsTable.or(this.page.locator('text=/No logs found|No results found/i')).waitFor({ state: 'visible', timeout: 10000 })
|
||||
// The useTablePageSize hook may override the limit from URL, causing a re-render.
|
||||
// Wait for pagination to become visible, retrying if the dynamic page size effect causes a brief re-render.
|
||||
await this.page.waitForTimeout(1500) // Allow useTablePageSize effect to settle
|
||||
await waitForNetworkIdle(this.page)
|
||||
await this.paginationControls.waitFor({ state: 'visible', timeout: 10000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter by provider
|
||||
*/
|
||||
async filterByProvider(provider: string): Promise<void> {
|
||||
await this.dismissToasts()
|
||||
await this.providerFilter.first().waitFor({ state: 'visible' })
|
||||
await this.providerFilter.first().click()
|
||||
await this.page.waitForSelector('[role="listbox"]', { timeout: 5000 }).catch(() => {})
|
||||
|
||||
// Try to find the provider option
|
||||
const option = this.page.getByRole('option', { name: new RegExp(provider, 'i') })
|
||||
if (await option.count() > 0) {
|
||||
await option.first().click()
|
||||
} else {
|
||||
// Close dropdown if option not found
|
||||
await this.page.keyboard.press('Escape')
|
||||
}
|
||||
|
||||
// Wait for dropdown to close and data to refresh
|
||||
await this.page.waitForSelector('[role="listbox"]', { state: 'hidden', timeout: 5000 }).catch(() => {})
|
||||
await waitForNetworkIdle(this.page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter by model
|
||||
*/
|
||||
async filterByModel(model: string): Promise<void> {
|
||||
await this.dismissToasts()
|
||||
await this.modelFilter.first().waitFor({ state: 'visible' })
|
||||
await this.modelFilter.first().click()
|
||||
await this.page.waitForSelector('[role="listbox"]', { timeout: 5000 }).catch(() => {})
|
||||
|
||||
const option = this.page.getByRole('option', { name: new RegExp(model, 'i') })
|
||||
if (await option.count() > 0) {
|
||||
await option.first().click()
|
||||
} else {
|
||||
await this.page.keyboard.press('Escape')
|
||||
}
|
||||
|
||||
await this.page.waitForSelector('[role="listbox"]', { state: 'hidden', timeout: 5000 }).catch(() => {})
|
||||
await waitForNetworkIdle(this.page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter by status. Opens the Filters popover and toggles the given status option (Status group uses lowercase: success, error, etc.).
|
||||
*/
|
||||
async filterByStatus(status: 'success' | 'error' | 'pending'): Promise<void> {
|
||||
await this.dismissToasts()
|
||||
await this.filtersButton.first().waitFor({ state: 'visible' })
|
||||
await this.filtersButton.first().click()
|
||||
await this.page.waitForSelector('[role="listbox"], [data-slot="command-list"]', { timeout: 5000 }).catch(() => {})
|
||||
|
||||
const option = this.page.getByRole('option', { name: new RegExp(status, 'i') })
|
||||
if (await option.count() > 0) {
|
||||
await option.first().click()
|
||||
} else {
|
||||
await this.page.keyboard.press('Escape')
|
||||
}
|
||||
|
||||
await this.page.waitForSelector('[role="listbox"]', { state: 'hidden', timeout: 5000 }).catch(() => {})
|
||||
await waitForNetworkIdle(this.page)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. Opens the date range popover, then clicks the predefined period button.
|
||||
*/
|
||||
async selectTimePeriod(period: '1h' | '6h' | '24h' | '7d' | '30d'): Promise<void> {
|
||||
await this.dismissToasts()
|
||||
const trigger = this.dateRangePicker.first()
|
||||
await trigger.waitFor({ state: 'visible' })
|
||||
// Open the time period popover by clicking the date range trigger
|
||||
await trigger.click()
|
||||
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] })
|
||||
// Wait for popover to open (predefined period button becomes visible)
|
||||
await periodButton.waitFor({ state: 'visible', timeout: 5000 })
|
||||
await periodButton.click()
|
||||
// Wait for popover to close and requests to settle
|
||||
await periodButton.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {})
|
||||
await waitForNetworkIdle(this.page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle live updates
|
||||
*/
|
||||
async toggleLiveUpdates(): Promise<void> {
|
||||
await this.dismissToasts()
|
||||
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()
|
||||
// Wait for detail sheet to appear
|
||||
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 () => {
|
||||
// Try pressing Escape if close button not found
|
||||
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') ?? '25', 10) || 25
|
||||
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') ?? '25', 10) || 25
|
||||
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) => new URL(url).searchParams.get('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') ?? '25', 10) || 25
|
||||
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 offset = new URL(url).searchParams.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' | 'tokens' | 'cost'): Promise<void> {
|
||||
await this.dismissToasts()
|
||||
|
||||
// Map column names to header button text
|
||||
const columnLabels: Record<string, string> = {
|
||||
'timestamp': 'Time',
|
||||
'latency': 'Latency',
|
||||
'tokens': 'Tokens',
|
||||
'cost': 'Cost'
|
||||
}
|
||||
|
||||
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> {
|
||||
const statsText = this.page.locator('text=Total Requests')
|
||||
return await statsText.isVisible().catch(() => false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stats value
|
||||
*/
|
||||
async getStatValue(statName: string): Promise<string | null> {
|
||||
const statCard = this.page.locator(`text=${statName}`).locator('..').locator('..')
|
||||
if (await statCard.isVisible()) {
|
||||
const value = statCard.locator('.font-mono').or(statCard.locator('text=/\\d+/'))
|
||||
return await value.textContent()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if empty state is shown (no logs, or no results for current filters)
|
||||
*/
|
||||
async isEmptyStateVisible(): Promise<boolean> {
|
||||
const emptyState = this.page
|
||||
.locator('text=/No logs found/i')
|
||||
.or(this.page.locator('text=/No data/i'))
|
||||
.or(this.page.locator('text=/No results found/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' | 'tokens' | 'cost'): 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user