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,57 @@
/**
* Test data factories for config settings tests
*/
/**
* Config toggle state interface
*/
export interface ConfigToggleState {
name: string
enabled: boolean
}
/**
* Client settings data factory
*/
export function createClientSettingsData(overrides: Partial<{
dropExcessRequests: boolean
enableLiteLLMFallbacks: boolean
disableDBPings: boolean
}> = {}) {
return {
dropExcessRequests: false,
enableLiteLLMFallbacks: true,
disableDBPings: false,
...overrides
}
}
/**
* Logging settings data factory
*/
export function createLoggingSettingsData(overrides: Partial<{
enableLogging: boolean
disableContentLogging: boolean
retentionDays: number
}> = {}) {
return {
enableLogging: true,
disableContentLogging: false,
retentionDays: 30,
...overrides
}
}
/**
* Performance tuning settings data factory
*/
export function createPerformanceTuningData(overrides: Partial<{
workerPoolSize: number
maxRequestBodySize: number
}> = {}) {
return {
workerPoolSize: 100,
maxRequestBodySize: 10485760, // 10MB
...overrides
}
}

View File

@@ -0,0 +1,547 @@
import { expect, test } from '../../core/fixtures/base.fixture'
import { ConfigSettingsState } from './pages/config-settings.page'
test.describe('Config Settings', () => {
// Run all config tests serially to avoid parallel writes to the same config/store
test.describe.configure({ mode: 'serial' })
test.describe('Navigation', () => {
test('should navigate to client settings', async ({ configSettingsPage }) => {
await configSettingsPage.goto('client-settings')
await expect(configSettingsPage.saveBtn).toBeVisible()
// Use heading to avoid matching sidebar link
await expect(configSettingsPage.page.getByRole('heading', { name: /Client Settings/i })).toBeVisible()
})
test('should navigate to caching config', async ({ configSettingsPage }) => {
await configSettingsPage.goto('caching')
// Caching page exists - verify page loaded
await expect(configSettingsPage.page.getByRole('heading', { name: /Caching/i })).toBeVisible()
})
test('should navigate to logging config', async ({ configSettingsPage }) => {
await configSettingsPage.goto('logging')
await expect(configSettingsPage.saveBtn).toBeVisible()
await expect(configSettingsPage.page.getByRole('heading', { name: /Logging/i })).toBeVisible()
})
test('should navigate to security config', async ({ configSettingsPage }) => {
await configSettingsPage.goto('security')
await expect(configSettingsPage.saveBtn).toBeVisible()
await expect(configSettingsPage.page.getByRole('heading', { name: /Security/i })).toBeVisible()
})
test('should navigate to performance tuning config', async ({ configSettingsPage }) => {
await configSettingsPage.goto('performance-tuning')
await expect(configSettingsPage.saveBtn).toBeVisible()
await expect(configSettingsPage.page.getByRole('heading', { name: /Performance Tuning/i })).toBeVisible()
})
test('should navigate to pricing config', async ({ configSettingsPage }) => {
await configSettingsPage.goto('pricing-config')
await expect(configSettingsPage.saveBtn).toBeVisible()
await expect(configSettingsPage.page.getByRole('heading', { name: /Pricing/i })).toBeVisible()
})
test('should navigate to MCP settings', async ({ configSettingsPage }) => {
await configSettingsPage.goto('mcp-gateway')
await expect(configSettingsPage.page.getByTestId('mcp-settings-view')).toBeVisible()
await expect(configSettingsPage.page.getByTestId('mcp-agent-depth-input')).toBeVisible()
await expect(configSettingsPage.page.getByTestId('mcp-tool-timeout-input')).toBeVisible()
})
})
test.describe('MCP Settings', () => {
test('should display MCP settings form', async ({ configSettingsPage }) => {
await configSettingsPage.goto('mcp-gateway')
await expect(configSettingsPage.page.getByTestId('mcp-settings-view')).toBeVisible()
await expect(configSettingsPage.page.getByTestId('mcp-agent-depth-input')).toBeVisible()
await expect(configSettingsPage.page.getByTestId('mcp-tool-timeout-input')).toBeVisible()
await expect(configSettingsPage.page.getByTestId('mcp-binding-level')).toBeVisible()
})
test('should have save button disabled when no changes', async ({ configSettingsPage }) => {
await configSettingsPage.goto('mcp-gateway')
const saveBtn = configSettingsPage.page.getByTestId('mcp-settings-save-btn')
await expect(saveBtn).toBeVisible()
await expect(saveBtn).toBeDisabled()
})
})
test.describe('Pricing Config', () => {
let originalPricingUrl: string | null = null
test.beforeEach(async ({ configSettingsPage }) => {
await configSettingsPage.goto('pricing-config')
originalPricingUrl = await configSettingsPage.pricingDatasheetUrlInput.inputValue()
})
test.afterEach(async ({ configSettingsPage }) => {
await configSettingsPage.goto('pricing-config')
const canEdit = await configSettingsPage.pricingDatasheetUrlInput.isEditable().catch(() => false)
if (!canEdit || originalPricingUrl === null) return
await configSettingsPage.setPricingDatasheetUrl(originalPricingUrl)
const isSaveEnabled = await configSettingsPage.pricingSaveBtn.isDisabled().then((d) => !d)
if (isSaveEnabled) {
await configSettingsPage.savePricingConfig()
await configSettingsPage.dismissToasts()
}
})
test('should display pricing config view', async ({ configSettingsPage }) => {
await expect(configSettingsPage.pricingConfigView).toBeVisible()
await expect(configSettingsPage.pricingDatasheetUrlInput).toBeVisible()
await expect(configSettingsPage.pricingForceSyncBtn).toBeVisible()
await expect(configSettingsPage.pricingSaveBtn).toBeVisible()
})
test('should set and save datasheet URL', async ({ configSettingsPage }) => {
const testUrl = 'https://example.com/pricing.json'
await configSettingsPage.setPricingDatasheetUrl(testUrl)
const isSaveEnabled = await configSettingsPage.pricingSaveBtn.isDisabled().then((d) => !d)
if (!isSaveEnabled) {
test.skip(true, 'Save button disabled (no changes detected or RBAC)')
return
}
await configSettingsPage.savePricingConfig()
await configSettingsPage.dismissToasts()
})
test('should trigger force sync', async ({ configSettingsPage }) => {
const isForceSyncEnabled = await configSettingsPage.pricingForceSyncBtn.isDisabled().then((d) => !d)
if (!isForceSyncEnabled) {
test.skip(true, 'Force sync button disabled (RBAC or no datasheet URL)')
return
}
await configSettingsPage.triggerForceSync()
await configSettingsPage.dismissToasts()
})
test('should validate URL format', async ({ configSettingsPage }) => {
await configSettingsPage.pricingDatasheetUrlInput.fill('invalid-url-no-http')
const canSave = await configSettingsPage.pricingSaveBtn.isDisabled().then((d) => !d)
if (!canSave) {
test.skip(true, 'Save button disabled (RBAC)')
return
}
await configSettingsPage.pricingSaveBtn.click()
await expect(configSettingsPage.page.getByText(/URL must start with http|valid URL/i)).toBeVisible()
})
})
test.describe('Client Settings', () => {
let originalState: ConfigSettingsState
test.beforeEach(async ({ configSettingsPage }) => {
await configSettingsPage.goto('client-settings')
// Capture original state for restoration
originalState = await configSettingsPage.getCurrentSettings('client-settings')
})
test.afterEach(async ({ configSettingsPage }) => {
// Restore original settings
if (originalState) {
await configSettingsPage.restoreSettings(originalState)
}
})
test('should display client settings controls', async ({ configSettingsPage }) => {
// Check for main controls
await expect(configSettingsPage.dropExcessRequestsSwitch).toBeVisible()
await expect(configSettingsPage.enableLiteLLMFallbacksSwitch).toBeVisible()
await expect(configSettingsPage.disableDBPingsSwitch).toBeVisible()
})
test('should display async job result TTL input when available', async ({ configSettingsPage }) => {
const isVisible = await configSettingsPage.asyncJobResultTtlInput.isVisible().catch(() => false)
if (isVisible) {
await expect(configSettingsPage.asyncJobResultTtlInput).toBeVisible()
} else {
test.skip(true, 'Async job result TTL not available')
}
})
test('should toggle drop excess requests', async ({ configSettingsPage }) => {
const initialState = await configSettingsPage.getSwitchState(configSettingsPage.dropExcessRequestsSwitch)
await configSettingsPage.toggleDropExcessRequests()
const newState = await configSettingsPage.getSwitchState(configSettingsPage.dropExcessRequestsSwitch)
expect(newState).toBe(!initialState)
// Verify changes are pending
const hasChanges = await configSettingsPage.hasPendingChanges()
expect(hasChanges).toBe(true)
})
test('should save and persist drop excess requests toggle', async ({ configSettingsPage }) => {
const initialState = await configSettingsPage.getSwitchState(configSettingsPage.dropExcessRequestsSwitch)
await configSettingsPage.toggleDropExcessRequests()
await configSettingsPage.saveSettings()
await configSettingsPage.goto('client-settings')
const expectedState = !initialState
await expect(configSettingsPage.dropExcessRequestsSwitch).toHaveAttribute(
'data-state',
expectedState ? 'checked' : 'unchecked'
)
})
test('should toggle LiteLLM fallbacks', async ({ configSettingsPage }) => {
const initialState = await configSettingsPage.getSwitchState(configSettingsPage.enableLiteLLMFallbacksSwitch)
await configSettingsPage.toggleLiteLLMFallbacks()
const newState = await configSettingsPage.getSwitchState(configSettingsPage.enableLiteLLMFallbacksSwitch)
expect(newState).toBe(!initialState)
})
test('should save and persist LiteLLM fallbacks toggle', async ({ configSettingsPage }) => {
const initialState = await configSettingsPage.getSwitchState(configSettingsPage.enableLiteLLMFallbacksSwitch)
await configSettingsPage.toggleLiteLLMFallbacks()
await configSettingsPage.saveSettings()
await configSettingsPage.goto('client-settings')
// Wait for persisted state (form is populated async after navigation)
const expectedState = !initialState
await expect(configSettingsPage.enableLiteLLMFallbacksSwitch).toHaveAttribute(
'data-state',
expectedState ? 'checked' : 'unchecked'
)
})
test('should toggle disable DB pings', async ({ configSettingsPage }) => {
const initialState = await configSettingsPage.getSwitchState(configSettingsPage.disableDBPingsSwitch)
await configSettingsPage.toggleDisableDBPings()
const newState = await configSettingsPage.getSwitchState(configSettingsPage.disableDBPingsSwitch)
expect(newState).toBe(!initialState)
})
test('should save and persist disable DB pings toggle', async ({ configSettingsPage }) => {
const initialState = await configSettingsPage.getSwitchState(configSettingsPage.disableDBPingsSwitch)
await configSettingsPage.toggleDisableDBPings()
await configSettingsPage.saveSettings()
await configSettingsPage.goto('client-settings')
const expectedState = !initialState
await expect(configSettingsPage.disableDBPingsSwitch).toHaveAttribute(
'data-state',
expectedState ? 'checked' : 'unchecked'
)
})
})
test.describe('Logging Settings', () => {
let originalState: ConfigSettingsState
test.beforeEach(async ({ configSettingsPage }) => {
await configSettingsPage.goto('logging')
// Capture original state for restoration
originalState = await configSettingsPage.getCurrentSettings('logging')
})
test.afterEach(async ({ configSettingsPage }) => {
// Restore original settings
if (originalState) {
await configSettingsPage.restoreSettings(originalState)
}
})
test('should display logging settings controls', async ({ configSettingsPage }) => {
// Check for main logging controls
await expect(configSettingsPage.page.getByText(/Enable Logs/i)).toBeVisible()
await expect(configSettingsPage.page.getByText(/Log Retention/i)).toBeVisible()
await expect(configSettingsPage.hideDeletedVirtualKeysInFiltersSwitch).toBeVisible()
})
test('should toggle hide deleted virtual keys in filters', async ({ configSettingsPage }) => {
const initialState = await configSettingsPage.getSwitchState(configSettingsPage.hideDeletedVirtualKeysInFiltersSwitch)
await configSettingsPage.toggleHideDeletedVirtualKeysInFilters()
const newState = await configSettingsPage.getSwitchState(configSettingsPage.hideDeletedVirtualKeysInFiltersSwitch)
expect(newState).toBe(!initialState)
const hasChanges = await configSettingsPage.hasPendingChanges()
expect(hasChanges).toBe(true)
})
test('should save and persist hide deleted virtual keys in filters toggle', async ({ configSettingsPage }) => {
const initialState = await configSettingsPage.getSwitchState(configSettingsPage.hideDeletedVirtualKeysInFiltersSwitch)
await configSettingsPage.toggleHideDeletedVirtualKeysInFilters()
await configSettingsPage.saveSettings()
await configSettingsPage.goto('logging')
const expectedState = !initialState
await expect(configSettingsPage.hideDeletedVirtualKeysInFiltersSwitch).toHaveAttribute(
'data-state',
expectedState ? 'checked' : 'unchecked'
)
})
test('should display workspace logging headers textarea when available', async ({ configSettingsPage }) => {
const isVisible = await configSettingsPage.workspaceLoggingHeadersTextarea.isVisible().catch(() => false)
if (isVisible) {
await expect(configSettingsPage.workspaceLoggingHeadersTextarea).toBeVisible()
} else {
test.skip(true, 'Workspace logging headers not available (depends on log connector)')
}
})
test('should toggle content logging when available', async ({ configSettingsPage }) => {
// Check if the switch is available (depends on logs being connected)
const disableContentLoggingVisible = await configSettingsPage.disableContentLoggingSwitch.isVisible().catch(() => false)
if (disableContentLoggingVisible) {
const initialState = await configSettingsPage.getSwitchState(configSettingsPage.disableContentLoggingSwitch)
await configSettingsPage.toggleDisableContentLogging()
const newState = await configSettingsPage.getSwitchState(configSettingsPage.disableContentLoggingSwitch)
expect(newState).toBe(!initialState)
} else {
// Skip if logging not available
test.skip()
}
})
test('should save and persist content logging toggle when available', async ({ configSettingsPage }) => {
// Check if the switch is available (depends on logs being connected)
const disableContentLoggingVisible = await configSettingsPage.disableContentLoggingSwitch.isVisible().catch(() => false)
if (disableContentLoggingVisible) {
const initialState = await configSettingsPage.getSwitchState(configSettingsPage.disableContentLoggingSwitch)
// Toggle
await configSettingsPage.toggleDisableContentLogging()
// Save
await configSettingsPage.saveSettings()
// Reload the page
await configSettingsPage.goto('logging')
// Verify change persisted
const savedState = await configSettingsPage.getSwitchState(configSettingsPage.disableContentLoggingSwitch)
expect(savedState).toBe(!initialState)
} else {
// Skip if logging not available
test.skip()
}
})
test('should change log retention days', async ({ configSettingsPage }) => {
const retentionInput = configSettingsPage.logRetentionDaysInput
const isVisible = await retentionInput.isVisible().catch(() => false)
if (isVisible) {
const originalValue = await retentionInput.inputValue()
const newValue = originalValue === '30' ? '60' : '30'
await retentionInput.clear()
await retentionInput.fill(newValue)
const currentValue = await retentionInput.inputValue()
expect(currentValue).toBe(newValue)
// Verify changes are pending
const hasChanges = await configSettingsPage.hasPendingChanges()
expect(hasChanges).toBe(true)
}
})
test('should save and persist log retention days', async ({ configSettingsPage }) => {
const retentionInput = configSettingsPage.logRetentionDaysInput
const isVisible = await retentionInput.isVisible().catch(() => false)
if (isVisible) {
const originalValue = await retentionInput.inputValue()
const newValue = originalValue === '30' ? '60' : '30'
// Change value
await retentionInput.clear()
await retentionInput.fill(newValue)
// Save
await configSettingsPage.saveSettings()
// Reload the page
await configSettingsPage.goto('logging')
// Verify change persisted
const savedValue = await retentionInput.inputValue()
expect(savedValue).toBe(newValue)
}
})
})
test.describe('Security Settings', () => {
let originalState: ConfigSettingsState
test.beforeEach(async ({ configSettingsPage }) => {
await configSettingsPage.goto('security')
// Capture original state for restoration
originalState = await configSettingsPage.getCurrentSettings('security')
})
test.afterEach(async ({ configSettingsPage }) => {
// Restore original settings
if (originalState) {
await configSettingsPage.restoreSettings(originalState)
}
})
test('should display security settings', async ({ configSettingsPage }) => {
await expect(configSettingsPage.page.getByRole('heading', { name: /Security/i })).toBeVisible()
await expect(configSettingsPage.saveBtn).toBeVisible()
})
test('should display enforce auth on inference switch', async ({ configSettingsPage }) => {
const isVisible = await configSettingsPage.enforceAuthOnInferenceSwitch.isVisible().catch(() => false)
if (!isVisible) {
test.skip(true, 'Enforce auth on inference not available')
return
}
await expect(configSettingsPage.enforceAuthOnInferenceSwitch).toBeVisible()
})
test('should toggle enforce auth on inference', async ({ configSettingsPage }) => {
const isVisible = await configSettingsPage.enforceAuthOnInferenceSwitch.isVisible().catch(() => false)
if (!isVisible) {
test.skip(true, 'Enforce auth on inference not available')
return
}
const initialState = await configSettingsPage.getSwitchState(configSettingsPage.enforceAuthOnInferenceSwitch)
await configSettingsPage.toggleEnforceAuthOnInference()
const newState = await configSettingsPage.getSwitchState(configSettingsPage.enforceAuthOnInferenceSwitch)
expect(newState).toBe(!initialState)
await configSettingsPage.toggleEnforceAuthOnInference()
if (await configSettingsPage.hasPendingChanges()) {
await configSettingsPage.saveSettings()
}
})
test('should display required headers textarea', async ({ configSettingsPage }) => {
const isVisible = await configSettingsPage.requiredHeadersTextarea.isVisible().catch(() => false)
if (!isVisible) {
test.skip(true, 'Required headers control not available')
return
}
await expect(configSettingsPage.requiredHeadersTextarea).toBeVisible()
})
test('should display rate limiting section', async ({ configSettingsPage }) => {
const isVisible = await configSettingsPage.isRateLimitingSectionVisible()
expect(isVisible).toBeDefined()
})
})
test.describe('Performance Tuning Settings', () => {
let originalState: ConfigSettingsState
test.beforeEach(async ({ configSettingsPage }) => {
await configSettingsPage.goto('performance-tuning')
originalState = await configSettingsPage.getCurrentSettings('performance-tuning')
})
test.afterEach(async ({ configSettingsPage }) => {
if (originalState) {
await configSettingsPage.restoreSettings(originalState)
}
})
test('should display performance tuning settings', async ({ configSettingsPage }) => {
await expect(configSettingsPage.page.getByRole('heading', { name: /Performance Tuning/i })).toBeVisible()
})
test('should change worker pool size', async ({ configSettingsPage }) => {
const workerPoolInput = configSettingsPage.workerPoolSizeInput
const isVisible = await workerPoolInput.isVisible().catch(() => false)
if (isVisible) {
const originalValue = await workerPoolInput.inputValue()
const newValue = parseInt(originalValue) === 100 ? '200' : '100'
await workerPoolInput.clear()
await workerPoolInput.fill(newValue)
const currentValue = await workerPoolInput.inputValue()
expect(currentValue).toBe(newValue)
}
})
test('should save and persist worker pool size', async ({ configSettingsPage }) => {
const workerPoolInput = configSettingsPage.workerPoolSizeInput
const isVisible = await workerPoolInput.isVisible().catch(() => false)
if (isVisible) {
const originalValue = await workerPoolInput.inputValue()
const newValue = parseInt(originalValue) === 100 ? '200' : '100'
// Change value
await workerPoolInput.clear()
await workerPoolInput.fill(newValue)
// Save
await configSettingsPage.saveSettings()
// Reload the page
await configSettingsPage.goto('performance-tuning')
// Verify change persisted
const savedValue = await workerPoolInput.inputValue()
expect(savedValue).toBe(newValue)
}
})
})
test.describe('Pricing Config Settings', () => {
let originalState: ConfigSettingsState
test.beforeEach(async ({ configSettingsPage }) => {
await configSettingsPage.goto('pricing-config')
originalState = await configSettingsPage.getCurrentSettings('pricing-config')
})
test.afterEach(async ({ configSettingsPage }) => {
if (originalState) {
await configSettingsPage.restoreSettings(originalState)
}
})
test('should display pricing config settings', async ({ configSettingsPage }) => {
await expect(configSettingsPage.page.getByRole('heading', { name: /Pricing/i })).toBeVisible()
})
})
test.describe('Caching Settings', () => {
let originalState: ConfigSettingsState
test.beforeEach(async ({ configSettingsPage }) => {
await configSettingsPage.goto('caching')
originalState = await configSettingsPage.getCurrentSettings('caching')
})
test.afterEach(async ({ configSettingsPage }) => {
if (originalState) {
await configSettingsPage.restoreSettings(originalState)
}
})
test('should display caching settings', async ({ configSettingsPage }) => {
await expect(configSettingsPage.page.getByRole('heading', { name: /Caching/i })).toBeVisible()
})
})
})

View File

@@ -0,0 +1,343 @@
import { Locator, Page } from '@playwright/test'
import { BasePage } from '../../../core/pages/base.page'
import { waitForNetworkIdle } from '../../../core/utils/test-helpers'
/**
* Config settings state interface
*/
export interface ConfigSettingsState {
toggleStates: Record<string, boolean>
inputValues: Record<string, string>
configPath: string
}
export class ConfigSettingsPage extends BasePage {
readonly saveBtn: Locator
// Client Settings
readonly dropExcessRequestsSwitch: Locator
readonly enableLiteLLMFallbacksSwitch: Locator
readonly disableDBPingsSwitch: Locator
readonly asyncJobResultTtlInput: Locator
// Logging Settings
readonly enableLoggingSwitch: Locator
readonly disableContentLoggingSwitch: Locator
readonly hideDeletedVirtualKeysInFiltersSwitch: Locator
readonly logRetentionDaysInput: Locator
readonly workspaceLoggingHeadersTextarea: Locator
// Security Settings
readonly rateLimitingSection: Locator
readonly enforceAuthOnInferenceSwitch: Locator
readonly requiredHeadersTextarea: Locator
// Performance Tuning Settings
readonly workerPoolSizeInput: Locator
readonly maxRequestBodySizeInput: Locator
// Observability Settings
readonly observabilityToggles: Locator
// Pricing Config
readonly pricingConfigView: Locator
readonly pricingDatasheetUrlInput: Locator
readonly pricingForceSyncBtn: Locator
readonly pricingSaveBtn: Locator
constructor(page: Page) {
super(page)
this.saveBtn = page.getByRole('button', { name: /Save/i })
// Client Settings locators
this.dropExcessRequestsSwitch = page.locator('#drop-excess-requests')
this.enableLiteLLMFallbacksSwitch = page.locator('#enable-litellm-fallbacks')
this.disableDBPingsSwitch = page.locator('#disable-db-pings-in-health')
this.asyncJobResultTtlInput = page.getByTestId('client-settings-async-job-result-ttl-input')
// Logging Settings locators
this.enableLoggingSwitch = page.locator('#enable-logging')
this.disableContentLoggingSwitch = page.locator('#disable-content-logging')
this.hideDeletedVirtualKeysInFiltersSwitch = page.getByTestId('hide-deleted-virtual-keys-in-filters-switch')
this.logRetentionDaysInput = page.getByLabel(/Log Retention Days/i).or(
page.locator('#log-n-days')
)
this.workspaceLoggingHeadersTextarea = page.getByTestId('workspace-logging-headers-textarea')
// Security Settings locators
this.rateLimitingSection = page.locator('text=Rate Limiting').locator('..')
this.enforceAuthOnInferenceSwitch = page.getByTestId('enforce-auth-on-inference-switch')
this.requiredHeadersTextarea = page.getByTestId('required-headers-textarea')
// Performance Tuning locators
this.workerPoolSizeInput = page.getByLabel(/Worker Pool Size/i)
this.maxRequestBodySizeInput = page.getByLabel(/Max Request Body Size/i)
// Observability locators
this.observabilityToggles = page.locator('button[role="switch"]')
// Pricing Config locators
this.pricingConfigView = page.getByTestId('pricing-config-view')
this.pricingDatasheetUrlInput = page.getByTestId('pricing-datasheet-url-input')
this.pricingForceSyncBtn = page.getByTestId('pricing-force-sync-btn')
this.pricingSaveBtn = page.getByTestId('pricing-save-btn')
}
async goto(path: string): Promise<void> {
await this.page.goto(`/workspace/config/${path}`)
await waitForNetworkIdle(this.page)
}
async saveSettings(): Promise<void> {
await this.saveBtn.click()
await this.waitForSuccessToast()
}
/**
* Check if save button is enabled (changes pending)
*/
async hasPendingChanges(): Promise<boolean> {
const isDisabled = await this.saveBtn.isDisabled()
return !isDisabled
}
/**
* Toggle a switch element
*/
async toggleSwitch(switchLocator: Locator): Promise<void> {
await switchLocator.click()
}
/**
* Get the state of a switch
*/
async getSwitchState(switchLocator: Locator): Promise<boolean> {
const state = await switchLocator.getAttribute('data-state')
return state === 'checked'
}
/**
* Set input value
*/
async setInputValue(inputLocator: Locator, value: string): Promise<void> {
await inputLocator.clear()
await inputLocator.fill(value)
}
/**
* Get input value
*/
async getInputValue(inputLocator: Locator): Promise<string> {
return await inputLocator.inputValue()
}
/**
* Capture current settings state for a config page
*/
async getCurrentSettings(configPath: string): Promise<ConfigSettingsState> {
const state: ConfigSettingsState = {
toggleStates: {},
inputValues: {},
configPath,
}
// Get all switch states on the page
const switches = this.page.locator('button[role="switch"]')
const switchCount = await switches.count()
for (let i = 0; i < switchCount; i++) {
const switchEl = switches.nth(i)
const elId = await switchEl.getAttribute('id')
if (!elId) {
console.warn(`Switch at index ${i} has no id attribute — using positional fallback "switch-${i}" which may mismatch on restore`)
}
const id = elId || `switch-${i}`
const isChecked = await switchEl.getAttribute('data-state') === 'checked'
state.toggleStates[id] = isChecked
}
// Get all number input values on the page
const numberInputs = this.page.locator('input[type="number"]')
const inputCount = await numberInputs.count()
for (let i = 0; i < inputCount; i++) {
const input = numberInputs.nth(i)
const elId = await input.getAttribute('id')
if (!elId) {
console.warn(`Input at index ${i} has no id attribute — using positional fallback "input-${i}" which may mismatch on restore`)
}
const id = elId || `input-${i}`
const value = await input.inputValue()
state.inputValues[id] = value
}
return state
}
/**
* Restore settings to a previous state
*/
async restoreSettings(state: ConfigSettingsState): Promise<void> {
// Navigate to the config page if not already there
const currentUrl = this.page.url()
if (!currentUrl.includes(state.configPath)) {
await this.goto(state.configPath)
}
let hasChanges = false
// Restore switch states
const switches = this.page.locator('button[role="switch"]')
const switchCount = await switches.count()
for (let i = 0; i < switchCount; i++) {
const switchEl = switches.nth(i)
const elId = await switchEl.getAttribute('id')
if (!elId) {
console.warn(`Switch at index ${i} has no id attribute — using positional fallback "switch-${i}" which may mismatch on restore`)
}
const id = elId || `switch-${i}`
if (state.toggleStates[id] !== undefined) {
const currentState = await switchEl.getAttribute('data-state') === 'checked'
if (currentState !== state.toggleStates[id]) {
await switchEl.click()
hasChanges = true
}
}
}
// Restore input values
const numberInputs = this.page.locator('input[type="number"]')
const inputCount = await numberInputs.count()
for (let i = 0; i < inputCount; i++) {
const input = numberInputs.nth(i)
const elId = await input.getAttribute('id')
if (!elId) {
console.warn(`Input at index ${i} has no id attribute — using positional fallback "input-${i}" which may mismatch on restore`)
}
const id = elId || `input-${i}`
if (state.inputValues[id] !== undefined) {
const currentValue = await input.inputValue()
if (currentValue !== state.inputValues[id]) {
await input.clear()
await input.fill(state.inputValues[id])
hasChanges = true
}
}
}
// Save if changes were made
if (hasChanges) {
const canSave = await this.hasPendingChanges()
if (canSave) {
await this.saveSettings()
}
}
}
// === Client Settings Methods ===
async toggleDropExcessRequests(): Promise<void> {
await this.dropExcessRequestsSwitch.click()
}
async toggleLiteLLMFallbacks(): Promise<void> {
await this.enableLiteLLMFallbacksSwitch.click()
}
async toggleDisableDBPings(): Promise<void> {
await this.disableDBPingsSwitch.click()
}
// === Logging Settings Methods ===
async toggleEnableLogging(): Promise<void> {
await this.enableLoggingSwitch.click()
}
async toggleDisableContentLogging(): Promise<void> {
await this.disableContentLoggingSwitch.click()
}
async toggleHideDeletedVirtualKeysInFilters(): Promise<void> {
await this.hideDeletedVirtualKeysInFiltersSwitch.click()
}
async setLogRetentionDays(days: number): Promise<void> {
const input = this.page.locator('input[type="number"]').first()
await input.clear()
await input.fill(days.toString())
}
async getLogRetentionDays(): Promise<number> {
const input = this.page.locator('input[type="number"]').first()
const value = await input.inputValue()
return parseInt(value, 10)
}
// === Security Settings Methods ===
async isRateLimitingSectionVisible(): Promise<boolean> {
return await this.page.getByText(/Rate Limiting/i).isVisible()
}
async toggleEnforceAuthOnInference(): Promise<void> {
await this.enforceAuthOnInferenceSwitch.click()
}
async setRequiredHeaders(value: string): Promise<void> {
await this.requiredHeadersTextarea.clear()
await this.requiredHeadersTextarea.fill(value)
}
async setWorkspaceLoggingHeaders(value: string): Promise<void> {
await this.workspaceLoggingHeadersTextarea.clear()
await this.workspaceLoggingHeadersTextarea.fill(value)
}
async setAsyncJobResultTtl(value: string): Promise<void> {
await this.asyncJobResultTtlInput.clear()
await this.asyncJobResultTtlInput.fill(value)
}
// === Observability Settings Methods ===
async getObservabilityConnectors(): Promise<string[]> {
const connectorHeadings = this.page.locator('h3, h4').filter({ hasText: /Datadog|New Relic|OTel|OpenTelemetry|Maxim/i })
const count = await connectorHeadings.count()
const connectors: string[] = []
for (let i = 0; i < count; i++) {
const text = await connectorHeadings.nth(i).textContent()
if (text) connectors.push(text)
}
return connectors
}
async toggleObservabilityConnector(connectorName: string): Promise<void> {
const connectorSection = this.page.locator('div').filter({ hasText: new RegExp(connectorName, 'i') }).first()
const toggleSwitch = connectorSection.locator('button[role="switch"]').first()
await toggleSwitch.click()
}
// === Pricing Config Methods ===
async setPricingDatasheetUrl(url: string): Promise<void> {
await this.pricingDatasheetUrlInput.clear()
await this.pricingDatasheetUrlInput.fill(url)
}
async triggerForceSync(): Promise<void> {
await this.pricingForceSyncBtn.click()
await this.waitForSuccessToast()
}
async savePricingConfig(): Promise<void> {
await this.pricingSaveBtn.click()
await this.waitForSuccessToast()
}
}

View File

@@ -0,0 +1,385 @@
import { expect, test } from '../../core/fixtures/base.fixture'
import { waitForNetworkIdle } from '../../core/utils/test-helpers'
import { DashboardPage } from './pages/dashboard.page'
test.describe('Dashboard', () => {
test.beforeEach(async ({ dashboardPage }) => {
await dashboardPage.goto()
})
test.describe('Dashboard Display', () => {
test('should display dashboard page', async ({ dashboardPage }) => {
await expect(dashboardPage.pageTitle).toBeVisible()
})
test('should display all chart cards', async ({ dashboardPage }) => {
// Check that all four main charts are visible
await expect(dashboardPage.logVolumeChart).toBeVisible()
await expect(dashboardPage.tokenUsageChart).toBeVisible()
await expect(dashboardPage.costChart).toBeVisible()
await expect(dashboardPage.modelUsageChart).toBeVisible()
})
test('should display date time picker', async ({ dashboardPage }) => {
// Date picker should be visible (may be a button with date text)
const datePicker = dashboardPage.page.locator('button').filter({ hasText: /Last/i }).or(
dashboardPage.page.locator('[data-testid="dashboard-date-picker"]')
)
await expect(datePicker.first()).toBeVisible()
})
})
test.describe('Time Period Selection', () => {
test('should filter by time period (full flow)', async ({ dashboardPage }) => {
// Time period control must exist and be visible (no skip)
const trigger = dashboardPage.getDatePickerTrigger()
await expect(trigger).toBeVisible({ timeout: 10000 })
// Let initial chart load finish so the refetch we wait for is the one from the period change
await dashboardPage.waitForChartsToLoad()
// Wait for the chart data request that fires when we change the period (proves filter is applied)
const responsePromise = dashboardPage.page.waitForResponse(
(res) => res.url().includes('/logs/histogram') && res.status() === 200,
{ timeout: 15000 }
)
await dashboardPage.selectTimePeriod('1h')
// UI: trigger shows the selected period
const label = await dashboardPage.getSelectedPeriodLabel()
expect(label).toContain('Last hour')
// URL: selection is reflected in query state
const url = dashboardPage.page.url()
expect(url).toMatch(/period=1h|start_time=\d+&end_time=\d+/)
// Data: dashboard refetched with the new range
await responsePromise
})
test('should change time period to last hour', async ({ dashboardPage }) => {
await dashboardPage.selectTimePeriod('1h')
const label = await dashboardPage.getSelectedPeriodLabel()
expect(label).toContain('Last hour')
const url = dashboardPage.page.url()
expect(url).toMatch(/period=1h|start_time=\d+&end_time=\d+/)
})
test('should change time period to last 7 days', async ({ dashboardPage }) => {
await dashboardPage.selectTimePeriod('7d')
const label = await dashboardPage.getSelectedPeriodLabel()
expect(label).toContain('Last 7 days')
const url = dashboardPage.page.url()
expect(url).toMatch(/period=7d|start_time=\d+&end_time=\d+/)
})
test('should change time period to last 30 days', async ({ dashboardPage }) => {
await dashboardPage.selectTimePeriod('30d')
const label = await dashboardPage.getSelectedPeriodLabel()
expect(label).toContain('Last 30 days')
const url = dashboardPage.page.url()
expect(url).toMatch(/period=30d|start_time=\d+&end_time=\d+/)
})
})
test.describe('Chart Type Toggling', () => {
test('should toggle volume chart type', async ({ dashboardPage }) => {
// Get initial toggle state from DOM
const initialToggle = dashboardPage.volumeChartToggle
const initialState = await dashboardPage.getChartToggleState(initialToggle)
// Toggle the chart (method handles waiting internally)
await dashboardPage.toggleVolumeChartType()
// Get new toggle state
const newToggle = dashboardPage.volumeChartToggle
const newState = await dashboardPage.getChartToggleState(newToggle)
// Chart type should have changed (state should be different)
expect(newState).not.toBe(initialState)
})
test('should toggle token chart type', async ({ dashboardPage }) => {
const initialToggle = dashboardPage.tokenChartToggle
const initialState = await dashboardPage.getChartToggleState(initialToggle)
await dashboardPage.toggleTokenChartType()
const newToggle = dashboardPage.tokenChartToggle
const newState = await dashboardPage.getChartToggleState(newToggle)
expect(newState).not.toBe(initialState)
})
test('should toggle cost chart type', async ({ dashboardPage }) => {
const initialToggle = dashboardPage.costChartToggle
const initialState = await dashboardPage.getChartToggleState(initialToggle)
await dashboardPage.toggleCostChartType()
const newToggle = dashboardPage.costChartToggle
const newState = await dashboardPage.getChartToggleState(newToggle)
expect(newState).not.toBe(initialState)
})
test('should toggle model chart type', async ({ dashboardPage }) => {
const initialToggle = dashboardPage.modelChartToggle
const initialState = await dashboardPage.getChartToggleState(initialToggle)
await dashboardPage.toggleModelChartType()
const newToggle = dashboardPage.modelChartToggle
const newState = await dashboardPage.getChartToggleState(newToggle)
expect(newState).not.toBe(initialState)
})
})
test.describe('Model Filtering', () => {
test('should filter cost chart by model', async ({ dashboardPage }) => {
// Wait for charts to fully load
await dashboardPage.waitForChartsToLoad()
// Try to filter by a specific model if available
const costModelFilter = dashboardPage.costModelFilter
const isVisible = await costModelFilter.isVisible().catch(() => false)
if (isVisible) {
await dashboardPage.filterCostChartByModel('all')
// Check that filter value is "All Models"
const newSelected = await dashboardPage.getSelectedModel(costModelFilter)
expect(newSelected).toContain('All Models')
}
})
test('should filter usage chart by model', async ({ dashboardPage }) => {
// Wait for charts to fully load
await dashboardPage.waitForChartsToLoad()
const usageModelFilter = dashboardPage.usageModelFilter
const isVisible = await usageModelFilter.isVisible().catch(() => false)
if (isVisible) {
await dashboardPage.filterUsageChartByModel('all')
// Check that filter value is "All Models"
const newSelected = await dashboardPage.getSelectedModel(usageModelFilter)
expect(newSelected).toContain('All Models')
}
})
})
test.describe('Chart Loading States', () => {
test('should show loading state initially', async ({ dashboardPage }) => {
// Navigate to a fresh dashboard
await dashboardPage.page.reload()
await dashboardPage.waitForPageLoad()
// Charts may show loading state briefly
// This test verifies the page loads without errors
await expect(dashboardPage.pageTitle).toBeVisible({ timeout: 10000 })
})
})
test.describe('URL State Management', () => {
test('should preserve chart state in URL', async ({ dashboardPage }) => {
// Change some settings
await dashboardPage.selectTimePeriod('7d')
await dashboardPage.toggleVolumeChartType()
// Check URL for period (time period should still be in URL)
const url = dashboardPage.page.url()
expect(url).toContain('period=7d')
// Check DOM state for chart toggle (may or may not be in URL)
const toggleState = await dashboardPage.getChartToggleState(dashboardPage.volumeChartToggle)
expect(toggleState).toBeTruthy()
})
test('should restore state from URL on page load', async ({ dashboardPage }) => {
// Set URL with specific state
await dashboardPage.page.goto('/workspace/dashboard?period=7d&volume_chart=line')
await waitForNetworkIdle(dashboardPage.page)
await dashboardPage.waitForChartsToLoad()
// Verify page loaded with correct state from URL
const url = dashboardPage.page.url()
expect(url).toContain('period=7d')
// volume_chart=line was in the URL - verify exact value persisted
expect(url).toContain('volume_chart=line')
})
})
test.describe('Chart Data Validation', () => {
test('should render chart elements after data loads', async ({ dashboardPage }) => {
// Wait for charts to load
await dashboardPage.waitForChartsToLoad()
// Check that each chart card has a canvas or chart surface SVG (recharts-surface = actual chart, not icons)
const volumeChartContent = dashboardPage.logVolumeChart.locator('canvas, svg.recharts-surface')
const tokenChartContent = dashboardPage.tokenUsageChart.locator('canvas, svg.recharts-surface')
const costChartContent = dashboardPage.costChart.locator('canvas, svg.recharts-surface')
const modelChartContent = dashboardPage.modelUsageChart.locator('canvas, svg.recharts-surface')
// Each chart card should have canvas or SVG content (chart library renders into these)
const volumeCount = await volumeChartContent.count()
const tokenCount = await tokenChartContent.count()
const costCount = await costChartContent.count()
const modelCount = await modelChartContent.count()
// All four chart cards should have rendered content (count > 0)
expect(volumeCount).toBeGreaterThan(0)
expect(tokenCount).toBeGreaterThan(0)
expect(costCount).toBeGreaterThan(0)
expect(modelCount).toBeGreaterThan(0)
})
test('should show chart legends', async ({ dashboardPage }) => {
await dashboardPage.waitForChartsToLoad()
// Check that chart actions (legends/toggles) are visible
const volumeActions = dashboardPage.page.locator('[data-testid="chart-log-volume-actions"]')
const tokenActions = dashboardPage.page.locator('[data-testid="chart-token-usage-actions"]')
// Actions should be visible (they contain legends and toggles)
await expect(volumeActions).toBeVisible()
await expect(tokenActions).toBeVisible()
})
test('should not show loading skeletons after data loads', async ({ dashboardPage }) => {
await dashboardPage.waitForChartsToLoad()
// Check that no skeletons are visible (data has loaded)
const skeletons = dashboardPage.page.locator('[data-testid="skeleton"]')
const skeletonCount = await skeletons.count()
expect(skeletonCount).toBe(0)
})
})
test.describe('Chart Interactions', () => {
test('should toggle between bar and line chart for volume', async ({ dashboardPage }) => {
await dashboardPage.waitForChartsToLoad()
// Get initial toggle state
const initialState = await dashboardPage.getChartToggleState(dashboardPage.volumeChartToggle)
// Toggle volume chart type
await dashboardPage.toggleVolumeChartType()
// DOM state should change (chart type toggles are in DOM, not URL)
const newState = await dashboardPage.getChartToggleState(dashboardPage.volumeChartToggle)
expect(newState).not.toBe(initialState)
})
test('should update chart when time period changes', async ({ dashboardPage }) => {
await dashboardPage.waitForChartsToLoad()
const initialUrl = dashboardPage.page.url()
await dashboardPage.selectTimePeriod('1h')
// Trigger should show new period (filter was applied)
const label = await dashboardPage.getSelectedPeriodLabel()
expect(label).toContain('Last hour')
const newUrl = dashboardPage.page.url()
expect(newUrl).toMatch(/period=1h|start_time=\d+&end_time=\d+/)
expect(newUrl).not.toBe(initialUrl)
})
test('should sync model filter between cost and usage charts', async ({ dashboardPage }) => {
await dashboardPage.waitForChartsToLoad()
// Check if model filters are visible
const costFilterVisible = await dashboardPage.costModelFilter.isVisible().catch(() => false)
const usageFilterVisible = await dashboardPage.usageModelFilter.isVisible().catch(() => false)
if (costFilterVisible && usageFilterVisible) {
// Filter cost chart
await dashboardPage.filterCostChartByModel('all')
// Verify filter was applied (check DOM state, not URL)
const selectedModel = await dashboardPage.getSelectedModel(dashboardPage.costModelFilter)
expect(selectedModel).toContain('All Models')
}
})
test('should display correct time period labels', async ({ dashboardPage }) => {
const periods: Array<'1h' | '6h' | '24h' | '7d' | '30d'> = ['1h', '6h', '24h', '7d', '30d']
for (const period of periods) {
await dashboardPage.selectTimePeriod(period)
// Assert the date picker trigger shows the selected period (actual selected value)
const label = await dashboardPage.getSelectedPeriodLabel()
const expected = DashboardPage.PERIOD_LABELS[period]
expect(label).toContain(expected)
}
})
})
test.describe('Error States', () => {
test('should handle empty data gracefully', async ({ dashboardPage }) => {
// Navigate with very short time range that may have no data
await dashboardPage.page.goto('/workspace/dashboard?period=1h')
await waitForNetworkIdle(dashboardPage.page)
await dashboardPage.waitForChartsToLoad()
// Page should still render without errors
await expect(dashboardPage.pageTitle).toBeVisible()
// All chart containers should still be visible
await expect(dashboardPage.logVolumeChart).toBeVisible()
await expect(dashboardPage.tokenUsageChart).toBeVisible()
})
})
test.describe('Custom Date Range', () => {
test('should open custom date range picker', async ({ dashboardPage }) => {
await dashboardPage.waitForChartsToLoad()
// Look for date picker button
const datePicker = dashboardPage.page.getByRole('button').filter({ hasText: /Last|Custom/i }).first()
const isVisible = await datePicker.isVisible().catch(() => false)
if (isVisible) {
await datePicker.click()
// Should see date range options or calendar
const calendarVisible = await dashboardPage.page.locator('[role="dialog"], [role="listbox"]').isVisible().catch(() => false)
const optionsVisible = await dashboardPage.page.getByRole('option').first().isVisible().catch(() => false)
expect(calendarVisible || optionsVisible).toBe(true)
// Close the picker
await dashboardPage.page.keyboard.press('Escape')
}
})
test('should handle empty data for custom range', async ({ dashboardPage }) => {
// Set a custom time range that likely has no data
await dashboardPage.page.goto('/workspace/dashboard?period=1h')
await waitForNetworkIdle(dashboardPage.page)
await dashboardPage.waitForChartsToLoad()
// Charts should still be visible even with no data
await expect(dashboardPage.logVolumeChart).toBeVisible()
await expect(dashboardPage.costChart).toBeVisible()
// Page should not show error alerts (not matching chart legend "Error")
const errorAlert = dashboardPage.page.locator('[role="alert"][data-variant="destructive"], .text-destructive, [data-sonner-toast][data-type="error"]')
const hasErrorAlert = await errorAlert.count() > 0
expect(hasErrorAlert).toBe(false)
})
})
})

View File

@@ -0,0 +1,349 @@
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 Dashboard page
*/
export class DashboardPage extends BasePage {
// Main elements
readonly pageTitle: Locator
readonly dateTimePicker: Locator
// Chart cards
readonly logVolumeChart: Locator
readonly tokenUsageChart: Locator
readonly costChart: Locator
readonly modelUsageChart: Locator
// Chart type toggles
readonly volumeChartToggle: Locator
readonly tokenChartToggle: Locator
readonly costChartToggle: Locator
readonly modelChartToggle: Locator
// Model filters
readonly costModelFilter: Locator
readonly usageModelFilter: Locator
constructor(page: Page) {
super(page)
// Main elements
this.pageTitle = page.getByRole('heading', { name: /Dashboard/i })
this.dateTimePicker = page.locator('[data-testid="dashboard-date-picker"]')
// Chart cards - using data-testid for robust selectors
this.logVolumeChart = page.locator('[data-testid="chart-log-volume"]')
this.tokenUsageChart = page.locator('[data-testid="chart-token-usage"]')
this.costChart = page.locator('[data-testid="chart-cost-total"]')
this.modelUsageChart = page.locator('[data-testid="chart-model-usage"]')
// Chart type toggles - using data-testid with actions suffix
// Volume and token charts have only ChartTypeToggle in the actions bar
this.volumeChartToggle = page.locator('[data-testid="chart-log-volume-actions"]').locator('button').filter({ has: page.locator('svg') })
this.tokenChartToggle = page.locator('[data-testid="chart-token-usage-actions"]').locator('button').filter({ has: page.locator('svg') })
// Cost and model charts have model filter + ChartTypeToggle; scope to ChartTypeToggle buttons only so getChartToggleState reads the right element
this.costChartToggle = page.locator('[data-testid="chart-cost-total-actions"]').locator('> div > div').last().locator('button')
this.modelChartToggle = page.locator('[data-testid="chart-model-usage-actions"]').locator('> div > div').last().locator('button')
// Model filters - select trigger inside each chart's actions area (opens dropdown; Radix uses role=combobox or data-slot=select-trigger)
this.costModelFilter = page.locator('[data-testid="chart-cost-total-actions"]').locator('[role="combobox"], [data-slot="select-trigger"]').first()
this.usageModelFilter = page.locator('[data-testid="chart-model-usage-actions"]').locator('[role="combobox"], [data-slot="select-trigger"]').first()
}
/**
* Navigate to the dashboard page
*/
async goto(): Promise<void> {
await this.page.goto('/workspace/dashboard')
await waitForNetworkIdle(this.page)
// Wait for charts to load
await this.waitForChartsToLoad()
}
/**
* Check if dashboard is loaded
*/
async isLoaded(): Promise<boolean> {
try {
await expect(this.pageTitle).toBeVisible({ timeout: 5000 })
return true
} catch {
return false
}
}
/**
* Close any open popups (date picker, dropdowns, etc.)
*/
async closePopups(): Promise<void> {
// Check for open date picker dialog and close it
const datePickerDialog = this.page.locator('[data-radix-popper-content-wrapper]')
if (await datePickerDialog.isVisible().catch(() => false)) {
await this.page.keyboard.press('Escape')
await datePickerDialog.waitFor({ state: 'hidden', timeout: 2000 }).catch(() => {})
}
// Check for open listbox and close it
const listbox = this.page.locator('[role="listbox"]')
if (await listbox.isVisible().catch(() => false)) {
await this.page.keyboard.press('Escape')
await listbox.waitFor({ state: 'hidden', timeout: 2000 }).catch(() => {})
}
}
/** Period label map used by the date picker (must match UI) */
static readonly PERIOD_LABELS: Record<string, string> = {
'1h': 'Last hour',
'6h': 'Last 6 hours',
'24h': 'Last 24 hours',
'7d': 'Last 7 days',
'30d': 'Last 30 days',
}
/**
* Get the date picker trigger button (the button that shows the current period and opens the popover).
* Identified by having the calendar icon so we don't match preset buttons inside the popover.
*/
getDatePickerTrigger(): Locator {
return this.page.locator('button').filter({ has: this.page.locator('svg') }).filter({ hasText: /Last|Pick/i }).first()
}
/**
* Get the currently displayed period label from the date picker trigger (what the user sees as selected).
*/
async getSelectedPeriodLabel(): Promise<string> {
const trigger = this.getDatePickerTrigger()
await trigger.waitFor({ state: 'visible', timeout: 5000 })
const text = await trigger.textContent()
return (text ?? '').trim()
}
/**
* Select a predefined time period
*/
async selectTimePeriod(period: '1h' | '6h' | '24h' | '7d' | '30d'): Promise<void> {
await this.closePopups()
const trigger = this.getDatePickerTrigger()
await trigger.click()
// Wait for dialog to open
await this.page.waitForSelector('[data-radix-popper-content-wrapper]', { timeout: 5000 }).catch(() => {})
const label = DashboardPage.PERIOD_LABELS[period]
await this.page.getByRole('button', { name: label }).click()
// Wait for dialog to close
await this.page.locator('[data-radix-popper-content-wrapper]').waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {})
await waitForNetworkIdle(this.page)
}
/**
* Get the inactive toggle button from a set of buttons (the one to click to switch chart type).
*/
private async getInactiveToggleButtonFrom(buttons: Locator): Promise<Locator> {
const count = await buttons.count()
for (let i = 0; i < count; i++) {
const btn = buttons.nth(i)
const className = await btn.getAttribute('class').catch(() => '')
const hasActive = await btn.evaluate((el) => el.hasAttribute('active')).catch(() => false)
if (!className?.includes('bg-secondary') && !hasActive) {
return btn
}
}
throw new Error(`No inactive toggle button found among ${count} buttons`)
}
/**
* Get the inactive toggle button (the one to click to switch chart type) from a full actions container.
*/
private async getInactiveToggleButton(actionsContainer: Locator): Promise<Locator> {
const buttons = actionsContainer.locator('button')
return this.getInactiveToggleButtonFrom(buttons)
}
/**
* Toggle chart type for volume chart (clicks inactive button to switch)
*/
async toggleVolumeChartType(): Promise<void> {
await this.dismissToasts()
await this.closePopups()
const actionsContainer = this.page.locator('[data-testid="chart-log-volume-actions"]')
const toggleBtn = await this.getInactiveToggleButton(actionsContainer)
await toggleBtn.waitFor({ state: 'visible' })
await toggleBtn.click()
await this.page.waitForLoadState('networkidle').catch(() => {})
}
/**
* Toggle chart type for token chart
*/
async toggleTokenChartType(): Promise<void> {
await this.dismissToasts()
await this.closePopups()
const actionsContainer = this.page.locator('[data-testid="chart-token-usage-actions"]')
const toggleBtn = await this.getInactiveToggleButton(actionsContainer)
await toggleBtn.waitFor({ state: 'visible' })
await toggleBtn.click()
await this.page.waitForLoadState('networkidle').catch(() => {})
}
/**
* Toggle chart type for cost chart.
* Scopes to the ChartTypeToggle only (excludes the model dropdown in the same actions bar).
*/
async toggleCostChartType(): Promise<void> {
await this.dismissToasts()
await this.closePopups()
const actionsContainer = this.page.locator('[data-testid="chart-cost-total-actions"]')
// ChartTypeToggle is the last child in the actions bar; its two buttons are the bar/line toggles only
const chartTypeButtons = actionsContainer.locator('> div > div').last().locator('button')
const toggleBtn = await this.getInactiveToggleButtonFrom(chartTypeButtons)
await toggleBtn.waitFor({ state: 'visible' })
await toggleBtn.click()
await this.page.waitForLoadState('networkidle').catch(() => {})
}
/**
* Toggle chart type for model chart.
* Scopes to the ChartTypeToggle only (excludes the model dropdown in the same actions bar).
*/
async toggleModelChartType(): Promise<void> {
await this.dismissToasts()
await this.closePopups()
const actionsContainer = this.page.locator('[data-testid="chart-model-usage-actions"]')
// ChartTypeToggle is the last child in the actions bar; its two buttons are the bar/line toggles only
const chartTypeButtons = actionsContainer.locator('> div > div').last().locator('button')
const toggleBtn = await this.getInactiveToggleButtonFrom(chartTypeButtons)
await toggleBtn.waitFor({ state: 'visible' })
await toggleBtn.click()
await this.page.waitForLoadState('networkidle').catch(() => {})
}
/**
* Filter cost chart by model. Opens the model dropdown, then selects the option.
*/
async filterCostChartByModel(model: string): Promise<void> {
await this.dismissToasts()
await this.costModelFilter.waitFor({ state: 'visible' })
// Open the dropdown by clicking the trigger
await this.costModelFilter.click()
// Wait for dropdown to open (option becomes visible in portal)
const optionName = model === 'all' ? 'All Models' : model
await this.page.getByRole('option', { name: optionName }).waitFor({ state: 'visible', timeout: 5000 })
await this.page.getByRole('option', { name: optionName }).click()
// Wait for dropdown to close and data to refresh
await this.page.getByRole('option', { name: optionName }).waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {})
await waitForNetworkIdle(this.page)
}
/**
* Filter usage chart by model. Opens the model dropdown, then selects the option.
*/
async filterUsageChartByModel(model: string): Promise<void> {
await this.dismissToasts()
await this.usageModelFilter.waitFor({ state: 'visible' })
// Open the dropdown by clicking the trigger
await this.usageModelFilter.click()
// Wait for dropdown to open (option becomes visible in portal)
const optionName = model === 'all' ? 'All Models' : model
await this.page.getByRole('option', { name: optionName }).waitFor({ state: 'visible', timeout: 5000 })
await this.page.getByRole('option', { name: optionName }).click()
// Wait for dropdown to close and data to refresh
await this.page.getByRole('option', { name: optionName }).waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {})
await waitForNetworkIdle(this.page)
}
/**
* Check if chart is visible
*/
async isChartVisible(chartTitle: string): Promise<boolean> {
// Map chart titles to test IDs
const testIdMap: Record<string, string> = {
'Request Volume': 'chart-log-volume',
'Token Usage': 'chart-token-usage',
'Cost': 'chart-cost-total',
'Model Usage': 'chart-model-usage',
}
const testId = testIdMap[chartTitle]
if (testId) {
return await this.page.locator(`[data-testid="${testId}"]`).isVisible()
}
// Fallback for unknown titles
const chart = this.page.locator(`text=${chartTitle}`).locator('..').locator('..')
return await chart.isVisible()
}
/**
* Check if chart is loading
*/
async isChartLoading(chartTitle: string): Promise<boolean> {
// Map chart titles to test IDs
const testIdMap: Record<string, string> = {
'Request Volume': 'chart-log-volume',
'Token Usage': 'chart-token-usage',
'Cost': 'chart-cost-total',
'Model Usage': 'chart-model-usage',
}
const testId = testIdMap[chartTitle]
if (testId) {
const chartCard = this.page.locator(`[data-testid="${testId}"]`)
const skeleton = chartCard.locator('[data-testid="skeleton"]')
return await skeleton.isVisible().catch(() => false)
}
// Fallback for unknown titles
const chartCard = this.page.locator(`text=${chartTitle}`).locator('..').locator('..')
const skeleton = chartCard.locator('[data-testid="skeleton"]').or(chartCard.locator('.skeleton'))
return await skeleton.isVisible().catch(() => false)
}
/**
* Get URL parameters
*/
getUrlParams(): URLSearchParams {
return new URLSearchParams(this.page.url().split('?')[1] || '')
}
/**
* Get chart toggle state (checks aria-pressed, data-state, or active class)
*/
async getChartToggleState(toggle: Locator): Promise<string | null> {
// Handle case where toggle might match multiple elements
const firstToggle = toggle.first()
// Try aria-pressed first (for button toggles)
const ariaPressed = await firstToggle.getAttribute('aria-pressed').catch(() => null)
if (ariaPressed) {
return ariaPressed
}
// Try data-state (for switch components)
const dataState = await firstToggle.getAttribute('data-state').catch(() => null)
if (dataState) {
return dataState
}
// Check if button is active (has active class or attribute)
const classAttr = await firstToggle.getAttribute('class').catch(() => null)
if (classAttr?.includes('bg-secondary')) {
return 'active'
}
// Check for [active] attribute
const isActive = await firstToggle.evaluate((el) => el.hasAttribute('active')).catch(() => false)
if (isActive) {
return 'active'
}
return 'inactive'
}
/**
* Get selected model from filter combobox
*/
async getSelectedModel(filter: Locator): Promise<string | null> {
const selectedText = await filter.textContent()
return selectedText
}
}

View File

@@ -0,0 +1,19 @@
import { CustomerConfig, TeamConfig } from './pages/governance.page'
export function createTeamData(overrides: Partial<TeamConfig> = {}): TeamConfig {
const timestamp = Date.now()
return {
name: `E2E Team ${timestamp}`,
budget: { maxLimit: 100, resetDuration: '1M' },
...overrides,
}
}
export function createCustomerData(overrides: Partial<CustomerConfig> = {}): CustomerConfig {
const timestamp = Date.now()
return {
name: `E2E Customer ${timestamp}`,
budget: { maxLimit: 50, resetDuration: '1d' },
...overrides,
}
}

View File

@@ -0,0 +1,173 @@
import { expect, test } from '../../core/fixtures/base.fixture'
import { createCustomerData, createTeamData } from './governance.data'
const createdTeams: string[] = []
const createdCustomers: string[] = []
test.describe('Governance - Teams', () => {
test.describe.configure({ mode: 'serial' })
test.beforeEach(async ({ governancePage }) => {
await governancePage.gotoTeams()
})
test.afterEach(async ({ governancePage }) => {
await governancePage.closeTeamDialog()
for (const name of [...createdTeams]) {
try {
const exists = await governancePage.teamExists(name)
if (exists) {
await governancePage.deleteTeam(name)
}
} catch (e) {
console.error(`[CLEANUP] Failed to delete team ${name}:`, e)
}
}
createdTeams.length = 0
for (const name of [...createdCustomers]) {
try {
await governancePage.gotoCustomers()
const exists = await governancePage.customerExists(name)
if (exists) {
await governancePage.deleteCustomer(name)
}
} catch (e) {
console.error(`[CLEANUP] Failed to delete customer ${name}:`, e)
}
}
createdCustomers.length = 0
})
test('should display create team button or empty state', async ({ governancePage }) => {
const createVisible = await governancePage.teamsCreateBtn.isVisible().catch(() => false)
const emptyAddVisible = await governancePage.page.getByTestId('team-button-add').isVisible().catch(() => false)
expect(createVisible || emptyAddVisible).toBe(true)
})
test('should create a team', async ({ governancePage }) => {
const teamData = createTeamData({ name: `E2E Test Team ${Date.now()}` })
createdTeams.push(teamData.name)
await governancePage.createTeam(teamData)
const exists = await governancePage.teamExists(teamData.name)
expect(exists).toBe(true)
})
test('should edit a team', async ({ governancePage }) => {
const teamData = createTeamData({ name: `E2E Edit Team ${Date.now()}` })
createdTeams.push(teamData.name)
await governancePage.createTeam(teamData)
await governancePage.editTeam(teamData.name, { budget: { maxLimit: 129 } })
const exists = await governancePage.teamExists(teamData.name)
expect(exists).toBe(true)
})
test('should create team with customer assignment', async ({ governancePage }) => {
// 1. Create a customer (UI)
const customerData = createCustomerData({ name: `E2E Customer For Team ${Date.now()}` })
createdCustomers.push(customerData.name)
await governancePage.gotoCustomers()
await governancePage.createCustomer(customerData)
// 2. Go to Teams and create a team, assign the customer from the create-team dropdown (UI)
await governancePage.gotoTeams()
const teamData = createTeamData({
name: `E2E Team With Customer ${Date.now()}`,
customerName: customerData.name,
})
createdTeams.push(teamData.name)
await governancePage.createTeam(teamData)
// 3. Validate in UI that the customer was assigned (via data-testid)
const exists = await governancePage.teamExists(teamData.name)
expect(exists).toBe(true)
const customerCell = governancePage.getTeamRowCustomerCell(teamData.name)
await expect(customerCell).toContainText(customerData.name)
})
test('should delete a team', async ({ governancePage }) => {
const teamData = createTeamData({ name: `E2E Delete Team ${Date.now()}` })
createdTeams.push(teamData.name)
await governancePage.createTeam(teamData)
let exists = await governancePage.teamExists(teamData.name)
expect(exists).toBe(true)
await governancePage.deleteTeam(teamData.name)
const idx = createdTeams.indexOf(teamData.name)
if (idx >= 0) createdTeams.splice(idx, 1)
exists = await governancePage.teamExists(teamData.name)
expect(exists).toBe(false)
})
})
test.describe('Governance - Customers', () => {
test.describe.configure({ mode: 'serial' })
test.beforeEach(async ({ governancePage }) => {
await governancePage.gotoCustomers()
})
test.afterEach(async ({ governancePage }) => {
for (const name of [...createdCustomers]) {
try {
const exists = await governancePage.customerExists(name)
if (exists) {
await governancePage.deleteCustomer(name)
}
} catch (e) {
console.error(`[CLEANUP] Failed to delete customer ${name}:`, e)
}
}
createdCustomers.length = 0
})
test('should display create customer button or empty state', async ({ governancePage }) => {
const createVisible = await governancePage.customersCreateBtn.isVisible().catch(() => false)
const emptyCreateVisible = await governancePage.page.getByTestId('customer-button-create').isVisible().catch(() => false)
expect(createVisible || emptyCreateVisible).toBe(true)
})
test('should create a customer', async ({ governancePage }) => {
const customerData = createCustomerData({ name: `E2E Test Customer ${Date.now()}` })
createdCustomers.push(customerData.name)
await governancePage.createCustomer(customerData)
const exists = await governancePage.customerExists(customerData.name)
expect(exists).toBe(true)
})
test('should edit a customer', async ({ governancePage }) => {
const customerData = createCustomerData({ name: `E2E Edit Customer ${Date.now()}` })
createdCustomers.push(customerData.name)
await governancePage.createCustomer(customerData)
const newName = `E2E Edited Customer ${Date.now()}`
createdCustomers[createdCustomers.length - 1] = newName
await governancePage.editCustomer(customerData.name, { name: newName })
const oldExists = await governancePage.customerExists(customerData.name)
const newExists = await governancePage.customerExists(newName)
expect(oldExists).toBe(false)
expect(newExists).toBe(true)
})
test('should delete a customer', async ({ governancePage }) => {
const customerData = createCustomerData({ name: `E2E Delete Customer ${Date.now()}` })
createdCustomers.push(customerData.name)
await governancePage.createCustomer(customerData)
let exists = await governancePage.customerExists(customerData.name)
expect(exists).toBe(true)
await governancePage.deleteCustomer(customerData.name)
const idx = createdCustomers.indexOf(customerData.name)
if (idx >= 0) createdCustomers.splice(idx, 1)
exists = await governancePage.customerExists(customerData.name)
expect(exists).toBe(false)
})
})

View File

@@ -0,0 +1,229 @@
import { Locator, Page } from '@playwright/test'
import { expect } from '../../../core/fixtures/base.fixture'
import { BasePage } from '../../../core/pages/base.page'
import { fillSelect, waitForNetworkIdle } from '../../../core/utils/test-helpers'
export interface TeamConfig {
name: string
/** Assign by customer id (from API). Prefer customerName for UI-only flow. */
customerId?: string
/** Assign by customer name in the create-team dropdown (UI-only, no API). */
customerName?: string
budget?: { maxLimit: number; resetDuration?: string }
rateLimit?: {
tokenMaxLimit?: number
tokenResetDuration?: string
requestMaxLimit?: number
requestResetDuration?: string
}
}
export interface CustomerConfig {
name: string
budget?: { maxLimit: number; resetDuration?: string }
rateLimit?: {
tokenMaxLimit?: number
tokenResetDuration?: string
requestMaxLimit?: number
requestResetDuration?: string
}
}
export class GovernancePage extends BasePage {
// Teams
readonly teamsCreateBtn: Locator
readonly teamsTable: Locator
readonly teamDialog: Locator
readonly teamNameInput: Locator
// Customers
readonly customersCreateBtn: Locator
readonly customersTable: Locator
readonly customerDialog: Locator
readonly customerNameInput: Locator
constructor(page: Page) {
super(page)
this.teamsCreateBtn = page.getByTestId('create-team-btn').or(page.getByTestId('team-button-add'))
this.teamsTable = page.getByTestId('teams-table')
this.teamDialog = page.getByTestId('team-dialog-content')
this.teamNameInput = page.getByTestId('team-name-input')
this.customersCreateBtn = page.getByTestId('customer-button-create')
this.customersTable = page.getByTestId('customer-table-container')
this.customerDialog = page.getByTestId('customer-dialog-content')
this.customerNameInput = page.getByTestId('customer-name-input')
}
async gotoTeams(): Promise<void> {
await this.page.goto('/workspace/governance/teams')
await waitForNetworkIdle(this.page)
}
async gotoCustomers(): Promise<void> {
await this.page.goto('/workspace/governance/customers')
await waitForNetworkIdle(this.page)
}
getTeamRow(name: string): Locator {
return this.page.getByTestId(`team-row-${name}`)
}
/** Customer cell for a team row (use for asserting assigned customer in UI). */
getTeamRowCustomerCell(teamName: string): Locator {
return this.page.getByTestId(`team-row-${teamName}-customer`)
}
async teamExists(name: string): Promise<boolean> {
const row = this.getTeamRow(name)
return (await row.count()) > 0
}
async createTeam(config: TeamConfig): Promise<void> {
await this.teamsCreateBtn.click()
await expect(this.teamDialog).toBeVisible({ timeout: 5000 })
await this.waitForSheetAnimation()
await this.teamNameInput.fill(config.name)
if (config.customerId !== undefined || config.customerName !== undefined) {
const trigger = this.page.getByTestId('team-customer-select-trigger')
await trigger.click()
if (config.customerId === '') {
await this.page.getByTestId('team-customer-option-none').click()
} else if (config.customerName !== undefined) {
const customerOption = this.page
.locator('[data-testid^="team-customer-option-"]')
.filter({ hasText: config.customerName })
await customerOption.waitFor({ state: 'visible', timeout: 5000 })
await customerOption.click()
} else if (config.customerId !== undefined && config.customerId !== '') {
const customerOption = this.page.getByTestId(`team-customer-option-${config.customerId}`)
await customerOption.waitFor({ state: 'visible', timeout: 5000 })
await customerOption.click()
}
}
if (config.budget?.maxLimit !== undefined) {
const budgetInput = this.page.getByTestId('budget-max-limit-input')
await budgetInput.fill(String(config.budget.maxLimit))
}
const saveBtn = this.teamDialog.getByRole('button', { name: /Create Team/i })
await expect(saveBtn).toBeEnabled()
await saveBtn.click()
await this.waitForSuccessToast()
await expect(this.teamDialog).not.toBeVisible({ timeout: 10000 })
}
async deleteTeam(name: string): Promise<void> {
const deleteBtn = this.page.getByTestId(`team-delete-btn-${name}`)
await deleteBtn.click()
const confirmDialog = this.page.locator('[role="alertdialog"]')
await confirmDialog.getByRole('button', { name: /Delete/i }).click()
await this.waitForSuccessToast()
await expect.poll(() => this.teamExists(name), { timeout: 10000 }).toBe(false)
}
async closeTeamDialog(): Promise<void> {
if (await this.teamDialog.isVisible().catch(() => false)) {
await this.teamDialog.getByRole('button', { name: /Cancel/i }).click()
await expect(this.teamDialog).not.toBeVisible({ timeout: 10000 })
}
}
getCustomerRow(name: string): Locator {
return this.customersTable.getByTestId(`customer-row-${name}`)
}
async customerExists(name: string): Promise<boolean> {
const row = this.getCustomerRow(name)
return (await row.count()) > 0
}
async createCustomer(config: CustomerConfig): Promise<void> {
await this.customersCreateBtn.click()
await expect(this.customerDialog).toBeVisible({ timeout: 5000 })
await this.waitForSheetAnimation()
await this.customerNameInput.fill(config.name)
if (config.budget?.maxLimit !== undefined) {
const budgetInput = this.page.getByTestId('budget-max-limit-input')
await budgetInput.fill(String(config.budget.maxLimit))
}
const saveBtn = this.customerDialog.getByRole('button', { name: /Create Customer/i })
await expect(saveBtn).toBeEnabled()
await saveBtn.click()
await this.waitForSuccessToast()
await expect(this.customerDialog).not.toBeVisible({ timeout: 10000 })
}
async deleteCustomer(name: string): Promise<void> {
const row = this.getCustomerRow(name)
const deleteBtn = row.locator('[data-testid^="customer-button-delete-"]')
await deleteBtn.click()
const confirmBtn = this.page.getByTestId('customer-button-delete-confirm')
await confirmBtn.waitFor({ state: 'visible', timeout: 5000 })
await confirmBtn.click()
await this.waitForSuccessToast()
await expect.poll(() => this.customerExists(name), { timeout: 10000 }).toBe(false)
}
async editTeam(name: string, updates: Partial<TeamConfig>): Promise<void> {
const editBtn = this.page.getByTestId(`team-edit-btn-${name}`)
await editBtn.click()
await expect(this.teamDialog).toBeVisible({ timeout: 5000 })
await this.waitForSheetAnimation()
if (updates.name) {
await this.teamNameInput.clear()
await this.teamNameInput.fill(updates.name)
}
if (updates.customerId !== undefined) {
const trigger = this.page.getByTestId('team-customer-select-trigger')
await trigger.click()
if (updates.customerId === '') {
await this.page.getByTestId('team-customer-option-none').click()
} else {
await this.page.getByTestId(`team-customer-option-${updates.customerId}`).click()
}
}
if (updates.budget?.maxLimit !== undefined) {
const budgetInput = this.page.getByTestId('budget-max-limit-input')
await budgetInput.clear()
await budgetInput.fill(String(updates.budget.maxLimit))
}
const saveBtn = this.teamDialog.getByRole('button', { name: /Save|Update/i })
await expect(saveBtn).toBeEnabled()
await saveBtn.click()
await this.waitForSuccessToast()
await expect(this.teamDialog).not.toBeVisible({ timeout: 10000 })
}
async editCustomer(name: string, updates: Partial<CustomerConfig>): Promise<void> {
const row = this.getCustomerRow(name)
const editBtn = row.locator('[data-testid^="customer-button-edit-"]')
await editBtn.click()
await expect(this.customerDialog).toBeVisible({ timeout: 5000 })
await this.waitForSheetAnimation()
if (updates.name) {
await this.customerNameInput.clear()
await this.customerNameInput.fill(updates.name)
}
const saveBtn = this.customerDialog.getByRole('button', { name: /Save|Update/i })
await expect(saveBtn).toBeEnabled()
await saveBtn.click()
await this.waitForSuccessToast()
await expect(this.customerDialog).not.toBeVisible({ timeout: 10000 })
}
}

View 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

View 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+/)
})
})
})

View 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
}
}

View File

@@ -0,0 +1,13 @@
import { expect, test } from '../../core/fixtures/base.fixture'
// MCP Auth Config routes to @enterprise components not present in OSS.
// Tests only verify URL routing; do not add UI assertions for enterprise-only content.
test.describe('MCP Auth Config', () => {
test.beforeEach(async ({ mcpAuthConfigPage }) => {
await mcpAuthConfigPage.goto()
})
test('should load MCP auth config page', async ({ mcpAuthConfigPage }) => {
await expect(mcpAuthConfigPage.page).toHaveURL(/mcp-auth-config/)
})
})

View File

@@ -0,0 +1,14 @@
import { Page } from '@playwright/test'
import { BasePage } from '../../../core/pages/base.page'
import { waitForNetworkIdle } from '../../../core/utils/test-helpers'
export class MCPAuthConfigPage extends BasePage {
constructor(page: Page) {
super(page)
}
async goto(): Promise<void> {
await this.page.goto('/workspace/mcp-auth-config')
await waitForNetworkIdle(this.page)
}
}

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
}
}

View File

@@ -0,0 +1,167 @@
import { join, resolve } from 'path'
import { MCPClientConfig, EnvVarLike } from './pages/mcp-registry.page'
/** Normalize header value from env (string or EnvVarLike) to EnvVarLike */
function toEnvVarLike(v: string | EnvVarLike): EnvVarLike {
if (typeof v === 'object' && v !== null && 'value' in v) return v as EnvVarLike
return { value: String(v), env_var: '', from_env: false }
}
/**
* Resolve header value: if string starts with "env.", use process.env[VAR_NAME].
*/
function resolveHeaderValue(v: EnvVarLike): EnvVarLike {
if (v.value.startsWith('env.')) {
const envVar = v.value.slice(4)
const resolved = process.env[envVar]
if (resolved !== undefined) {
return { value: resolved, env_var: envVar, from_env: true }
}
}
return v
}
/**
* Parse MCP_SSE_HEADERS: supports single object, array of objects, or concatenated objects.
* e.g. {"Authorization":"Bearer ..."},{"ENV_EXA_API_KEY":"..."} → merged into one record
*/
function parseSSEHeadersRaw(raw: string): Record<string, string | EnvVarLike> {
const trimmed = raw.trim()
if (!trimmed) return {}
try {
const parsed = JSON.parse(trimmed)
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
return parsed
}
} catch {
// Fallback: concatenated objects {"a":1},{"b":2} → wrap in [ ] and merge
}
try {
const asArray = JSON.parse('[' + trimmed + ']')
if (Array.isArray(asArray) && asArray.every((o) => typeof o === 'object' && o !== null)) {
return Object.assign({}, ...asArray)
}
} catch {
// ignore
}
return {}
}
/**
* Create basic MCP client data
*/
export function createMCPClientData(overrides: Partial<MCPClientConfig> = {}): MCPClientConfig {
return {
name: `test_client_${Date.now()}`,
connectionType: 'http',
connectionUrl: 'http://localhost:3001/',
...overrides,
}
}
/**
* Create HTTP MCP client data
* Uses http-no-ping-server from examples/mcps
*/
export function createHTTPClientData(overrides: Partial<MCPClientConfig> = {}): MCPClientConfig {
return createMCPClientData({
connectionType: 'http',
connectionUrl: 'http://localhost:3001/', // http-no-ping-server
authType: 'none',
isPingAvailable: false, // http-no-ping-server doesn't support ping
...overrides,
})
}
/**
* Normalize parsed headers to EnvVarLike format (handles both plain values and nested EnvVar objects).
*/
function normalizeHeaders(parsed: Record<string, string | EnvVarLike>): Record<string, EnvVarLike> {
const out: Record<string, EnvVarLike> = {}
for (const [k, v] of Object.entries(parsed)) {
if (v !== undefined && v !== null) {
out[k] = resolveHeaderValue(toEnvVarLike(v))
}
}
return out
}
/**
* Create SSE MCP client data.
* URL and headers are injected via workflow env: MCP_SSE_URL, MCP_SSE_HEADERS (JSON).
* Supports: {"Authorization":"Bearer ...","K":"V"} or {"Authorization":{"value":"...","env_var":"","from_env":false}}.
*/
export function createSSEClientData(overrides: Partial<MCPClientConfig> = {}): MCPClientConfig {
const connectionUrl = "https://ts-mcp-sse-proxy.fly.dev/npx%20-y%20exa-mcp-server/sse"
const raw = process.env.MCP_SSE_HEADERS
const parsed = raw ? parseSSEHeadersRaw(raw) : {}
const headers = normalizeHeaders(parsed)
return createMCPClientData({
connectionType: 'sse',
connectionUrl,
authType: 'headers',
headers: headers,
isPingAvailable: false,
...overrides,
})
}
/**
* Create STDIO MCP client data
* Uses test-tools-server from examples/mcps
*/
export function createSTDIOClientData(overrides: Partial<MCPClientConfig> = {}): MCPClientConfig {
// Use the built test-tools-server
const REPO_ROOT = resolve(__dirname, '..', '..', '..', '..')
// Then for the stdio server:
const serverPath = join(REPO_ROOT, 'examples', 'mcps', 'test-tools-server', 'dist', 'index.js')
return createMCPClientData({
name: `stdio_client_${Date.now()}`,
connectionType: 'stdio',
command: 'node',
args: serverPath, // Run the actual MCP server
...overrides,
})
}
/**
* Create HTTP client with headers auth
*/
export function createHTTPClientWithHeaders(overrides: Partial<MCPClientConfig> = {}): MCPClientConfig {
return createMCPClientData({
connectionType: 'http',
connectionUrl: 'http://localhost:3001/', // http-no-ping-server
authType: 'headers',
isPingAvailable: false,
...overrides,
})
}
/**
* Create HTTP client with OAuth auth (minimal config for testing)
* Note: http-no-ping-server doesn't support OAuth, so this is for UI testing only
*/
export function createHTTPClientWithOAuth(overrides: Partial<MCPClientConfig> = {}): MCPClientConfig {
return createMCPClientData({
name: `oauth_client_${Date.now()}`,
connectionType: 'http',
connectionUrl: 'http://localhost:3001/', // http-no-ping-server
authType: 'oauth',
oauthClientId: 'test-client-id',
oauthAuthorizeUrl: 'http://localhost:3001/oauth/authorize',
oauthTokenUrl: 'http://localhost:3001/oauth/token',
isPingAvailable: false,
...overrides,
})
}
/**
* Create client with code mode enabled
*/
export function createCodeModeClientData(overrides: Partial<MCPClientConfig> = {}): MCPClientConfig {
return createMCPClientData({
isCodeMode: true,
...overrides,
})
}

View File

@@ -0,0 +1,357 @@
import { expect, test } from '../../core/fixtures/base.fixture'
import {
createCodeModeClientData,
createHTTPClientData,
createSSEClientData,
createSTDIOClientData
} from './mcp-registry.data'
const hasSSEHeaders = Boolean(process.env.MCP_SSE_HEADERS)
// Track created clients for cleanup
const createdClients: string[] = []
test.describe('MCP Registry', () => {
// MCP client creation can be slow (backend connects to MCP server); give tests room to complete
test.setTimeout(120000)
test.beforeEach(async ({ mcpRegistryPage }) => {
await mcpRegistryPage.goto()
})
test.afterEach(async ({ mcpRegistryPage }) => {
const toClean = [...createdClients]
createdClients.length = 0
if (toClean.length > 0) {
await mcpRegistryPage.cleanupMCPClients(toClean)
}
})
test.describe('MCP Client Display', () => {
test('should display MCP clients table', async ({ mcpRegistryPage }) => {
await expect(mcpRegistryPage.table).toBeVisible()
})
test('should display create button', async ({ mcpRegistryPage }) => {
await expect(mcpRegistryPage.createBtn).toBeVisible()
})
test('should show empty state or client list', async ({ mcpRegistryPage }) => {
const count = await mcpRegistryPage.getClientCount()
const isEmptyStateVisible = await mcpRegistryPage.isEmptyStateVisible()
if (count === 0) {
expect(isEmptyStateVisible).toBe(true)
} else {
expect(count).toBeGreaterThan(0)
expect(isEmptyStateVisible).toBe(false)
}
})
})
test.describe('MCP Client Creation', () => {
test('should open client creation sheet', async ({ mcpRegistryPage }) => {
await mcpRegistryPage.createBtn.click()
await expect(mcpRegistryPage.sheet).toBeVisible()
await expect(mcpRegistryPage.nameInput).toBeVisible()
// Cancel to clean up
await mcpRegistryPage.cancelCreation()
})
test('should create basic HTTP client', async ({ mcpRegistryPage }) => {
const clientData = createHTTPClientData()
const created = await mcpRegistryPage.createClient(clientData)
expect(created).toBe(true) // Client creation must succeed
createdClients.push(clientData.name)
const exists = await mcpRegistryPage.clientExists(clientData.name)
expect(exists).toBe(true)
// Verify connection type displayed correctly
const connectionType = await mcpRegistryPage.getClientConnectionType(clientData.name)
expect(connectionType).toBe('HTTP')
})
test('should create SSE client', async ({ mcpRegistryPage }) => {
test.skip(!hasSSEHeaders, 'Requires MCP_SSE_HEADERS for authenticated SSE MCP endpoint')
const clientData = createSSEClientData({
name: `sse_test_${Date.now()}`,
})
const created = await mcpRegistryPage.createClient(clientData)
expect(created).toBe(true) // Client creation must succeed
createdClients.push(clientData.name)
const exists = await mcpRegistryPage.clientExists(clientData.name)
expect(exists).toBe(true)
// Verify connection type displayed correctly
const connectionType = await mcpRegistryPage.getClientConnectionType(clientData.name)
expect(connectionType).toBe('SSE')
})
test('should create STDIO client with command', async ({ mcpRegistryPage }) => {
const clientData = createSTDIOClientData()
const created = await mcpRegistryPage.createClient(clientData)
expect(created).toBe(true) // Client creation must succeed
createdClients.push(clientData.name)
const exists = await mcpRegistryPage.clientExists(clientData.name)
expect(exists).toBe(true)
})
test('should create client with code mode enabled', async ({ mcpRegistryPage }) => {
const clientData = createCodeModeClientData({
name: `codemode_test_${Date.now()}`,
})
const created = await mcpRegistryPage.createClient(clientData)
expect(created).toBe(true) // Client creation must succeed
createdClients.push(clientData.name)
const exists = await mcpRegistryPage.clientExists(clientData.name)
expect(exists).toBe(true)
})
test('should cancel client creation', async ({ mcpRegistryPage }) => {
await mcpRegistryPage.createBtn.click()
await expect(mcpRegistryPage.sheet).toBeVisible()
const testName = `cancelled_client_${Date.now()}`
await mcpRegistryPage.nameInput.fill(testName)
await mcpRegistryPage.cancelCreation()
// Sheet should be closed
await expect(mcpRegistryPage.sheet).not.toBeVisible()
// Client should not exist
const exists = await mcpRegistryPage.clientExists(testName)
expect(exists).toBe(false)
})
})
test.describe('MCP Server Connection Validation', () => {
test('should connect to HTTP server and list tools', async ({ mcpRegistryPage }) => {
const clientData = createHTTPClientData({
name: `http_validation_${Date.now()}`,
})
const created = await mcpRegistryPage.createClient(clientData)
expect(created).toBe(true)
createdClients.push(clientData.name)
// Wait a moment for connection to establish
await mcpRegistryPage.page.waitForTimeout(2000)
// Verify client shows connection status
const status = await mcpRegistryPage.getClientStatus(clientData.name)
expect(status).toBeTruthy()
// Status could be connecting, connected, or disconnected depending on timing
expect(['connected', 'disconnected', 'connecting', 'error']).toContain(status.toLowerCase())
// Verify tools are loaded (http-no-ping-server has: echo, add, greet)
await mcpRegistryPage.viewClientDetails(clientData.name)
const toolsCount = await mcpRegistryPage.getToolsCount()
expect(toolsCount).toBeGreaterThanOrEqual(3)
await mcpRegistryPage.closeDetailSheet()
})
test('should connect to SSE server and list tools', async ({ mcpRegistryPage }) => {
test.skip(!hasSSEHeaders, 'Requires MCP_SSE_HEADERS for authenticated SSE MCP endpoint')
const clientData = createSSEClientData({
name: `sse_validation_${Date.now()}`,
})
const created = await mcpRegistryPage.createClient(clientData)
expect(created).toBe(true)
createdClients.push(clientData.name)
// Wait a moment for connection to establish
await mcpRegistryPage.page.waitForTimeout(2000)
// Verify tools are loaded
await mcpRegistryPage.viewClientDetails(clientData.name)
const toolsCount = await mcpRegistryPage.getToolsCount()
expect(toolsCount).toBeGreaterThanOrEqual(3)
await mcpRegistryPage.closeDetailSheet()
})
test('should connect to STDIO server and list tools', async ({ mcpRegistryPage }) => {
const clientData = createSTDIOClientData({
name: `stdio_validation_${Date.now()}`,
})
const created = await mcpRegistryPage.createClient(clientData)
expect(created).toBe(true)
createdClients.push(clientData.name)
// Wait a moment for connection to establish
await mcpRegistryPage.page.waitForTimeout(2000)
// Verify tools from test-tools-server (echo, calculator, get_weather, delay, throw_error)
await mcpRegistryPage.viewClientDetails(clientData.name)
const toolsCount = await mcpRegistryPage.getToolsCount()
expect(toolsCount).toBeGreaterThanOrEqual(5)
await mcpRegistryPage.closeDetailSheet()
})
})
test.describe('MCP Client Management', () => {
test('should delete MCP client', async ({ mcpRegistryPage }) => {
// Create a client first using HTTP (most reliable)
const clientData = createHTTPClientData({
name: `delete_test_${Date.now()}`,
})
const created = await mcpRegistryPage.createClient(clientData)
expect(created).toBe(true) // Client creation must succeed for this test
// Verify it exists
let exists = await mcpRegistryPage.clientExists(clientData.name)
expect(exists).toBe(true)
// Delete it
await mcpRegistryPage.deleteClient(clientData.name)
// Verify it's gone
exists = await mcpRegistryPage.clientExists(clientData.name)
expect(exists).toBe(false)
})
test('should view client details', async ({ mcpRegistryPage }) => {
// Create a client first
const clientData = createHTTPClientData({
name: `view_test_${Date.now()}`,
})
const created = await mcpRegistryPage.createClient(clientData)
expect(created).toBe(true) // Client creation must succeed for this test
createdClients.push(clientData.name)
// View details
await mcpRegistryPage.viewClientDetails(clientData.name)
// Detail sheet should be visible
await expect(mcpRegistryPage.detailSheet).toBeVisible()
// Close the sheet
await mcpRegistryPage.closeDetailSheet()
})
test('should close client details sheet', async ({ mcpRegistryPage }) => {
// Create a client first
const clientData = createHTTPClientData({
name: `close_sheet_test_${Date.now()}`,
})
const created = await mcpRegistryPage.createClient(clientData)
expect(created).toBe(true) // Client creation must succeed for this test
createdClients.push(clientData.name)
// Open details
await mcpRegistryPage.viewClientDetails(clientData.name)
await expect(mcpRegistryPage.detailSheet).toBeVisible()
// Close it
await mcpRegistryPage.closeDetailSheet()
// Should be closed
await expect(mcpRegistryPage.detailSheet).not.toBeVisible()
})
test('should reconnect MCP client', async ({ mcpRegistryPage }) => {
// Create a client first
const clientData = createHTTPClientData({
name: `reconnect_test_${Date.now()}`,
})
const created = await mcpRegistryPage.createClient(clientData)
expect(created).toBe(true) // Client creation must succeed for this test
createdClients.push(clientData.name)
// Reconnect - method waits for success toast
await mcpRegistryPage.reconnectClient(clientData.name)
// Verify client still exists and has a status (reconnect completed)
const exists = await mcpRegistryPage.clientExists(clientData.name)
expect(exists).toBe(true)
const status = await mcpRegistryPage.getClientStatus(clientData.name)
expect(status).toBeTruthy()
expect(['connected', 'disconnected', 'connecting']).toContain(status.toLowerCase())
})
})
test.describe('Client Status Display', () => {
test('should display client connection status', async ({ mcpRegistryPage }) => {
// Create a client first
const clientData = createHTTPClientData({
name: `status_test_${Date.now()}`,
})
const created = await mcpRegistryPage.createClient(clientData)
expect(created).toBe(true) // Client creation must succeed for this test
createdClients.push(clientData.name)
// Get status
const status = await mcpRegistryPage.getClientStatus(clientData.name)
// Status should be one of the expected values
expect(status).toBeTruthy()
expect(['connected', 'disconnected', 'connecting', 'error']).toContain(status?.toLowerCase())
})
})
test.describe('Form Validation', () => {
test('should require name for client', async ({ mcpRegistryPage }) => {
await mcpRegistryPage.createBtn.click()
await expect(mcpRegistryPage.sheet).toBeVisible()
// Clear name field (should be empty by default)
await mcpRegistryPage.nameInput.clear()
// Save button should be disabled when name is empty
const saveBtn = mcpRegistryPage.saveBtn
await expect(saveBtn).toBeDisabled()
await mcpRegistryPage.cancelCreation()
})
test('should validate name format', async ({ mcpRegistryPage }) => {
await mcpRegistryPage.createBtn.click()
await expect(mcpRegistryPage.sheet).toBeVisible()
// Try invalid name with hyphens (not allowed)
await mcpRegistryPage.nameInput.fill('invalid-name-with-hyphens')
// Fill connection URL to satisfy other validation
await mcpRegistryPage.connectionUrlInput.fill('http://localhost:3001')
// Save button should be disabled due to validation error
const saveBtn = mcpRegistryPage.saveBtn
await expect(saveBtn).toBeDisabled()
await mcpRegistryPage.cancelCreation()
})
test('should require connection URL for HTTP clients', async ({ mcpRegistryPage }) => {
await mcpRegistryPage.createBtn.click()
await expect(mcpRegistryPage.sheet).toBeVisible()
// Fill valid name
await mcpRegistryPage.nameInput.fill(`valid_name_${Date.now()}`)
// Leave connection URL empty - save should be disabled
const saveBtn = mcpRegistryPage.saveBtn
await expect(saveBtn).toBeDisabled()
await mcpRegistryPage.cancelCreation()
})
})
})

View File

@@ -0,0 +1,703 @@
import { Page, Locator, expect } from '@playwright/test'
import { BasePage } from '../../../core/pages/base.page'
import { waitForNetworkIdle } from '../../../core/utils/test-helpers'
/**
* Connection types supported by MCP clients
*/
export type MCPConnectionType = 'http' | 'sse' | 'stdio'
/**
* Authentication types for HTTP/SSE connections
*/
export type MCPAuthType = 'none' | 'headers' | 'oauth'
/** Header value shape used by API (value / env_var / from_env) */
export type EnvVarLike = { value: string; env_var?: string; from_env?: boolean }
/**
* MCP Client configuration
*/
export interface MCPClientConfig {
name: string
connectionType?: MCPConnectionType
connectionUrl?: string
authType?: MCPAuthType
/** Headers for auth_type 'headers'. API shape: Record<string, EnvVarLike> */
headers?: Record<string, EnvVarLike | string>
isCodeMode?: boolean
isPingAvailable?: boolean
// STDIO specific
command?: string
args?: string
envs?: string
// OAuth specific
oauthClientId?: string
oauthClientSecret?: string
oauthAuthorizeUrl?: string
oauthTokenUrl?: string
oauthScopes?: string
}
/**
* Page object for the MCP Registry page
*/
export class MCPRegistryPage extends BasePage {
readonly table: Locator
readonly createBtn: Locator
readonly sheet: Locator
readonly detailSheet: Locator
readonly nameInput: Locator
readonly saveBtn: Locator
readonly cancelBtn: Locator
readonly connectionTypeSelect: Locator
readonly authTypeSelect: Locator
readonly connectionUrlInput: Locator
readonly codeModeSwitch: Locator
readonly pingAvailableSwitch: Locator
// STDIO inputs
readonly commandInput: Locator
readonly argsInput: Locator
readonly envsInput: Locator
// OAuth inputs
readonly oauthClientIdInput: Locator
readonly oauthClientSecretInput: Locator
readonly oauthAuthorizeUrlInput: Locator
readonly oauthTokenUrlInput: Locator
readonly oauthScopesInput: Locator
constructor(page: Page) {
super(page)
this.table = page.locator('[data-testid="mcp-clients-table"]').or(page.locator('table'))
this.createBtn = page.locator('[data-testid="create-mcp-client-btn"]').or(
page.getByRole('button', { name: /New MCP Server/i }).or(page.getByRole('button', { name: /Add/i }))
)
this.sheet = page.locator('[role="dialog"]')
this.detailSheet = page.locator('[role="dialog"]')
this.nameInput = page.locator('[data-testid="client-name-input"]').or(
this.sheet.getByLabel(/Name/i).first()
)
this.saveBtn = page.locator('[data-testid="save-client-btn"]').or(
this.sheet.getByRole('button', { name: /Create/i }).or(
this.sheet.getByRole('button', { name: /Save/i })
)
)
this.cancelBtn = page.locator('[data-testid="cancel-client-btn"]').or(
this.sheet.getByRole('button', { name: /Cancel/i })
)
// Connection type and auth
this.connectionTypeSelect = page.locator('[data-testid="connection-type-select"]')
this.authTypeSelect = page.locator('[data-testid="auth-type-select"]')
// Use placeholder as primary selector for EnvVarInput (more reliable)
this.connectionUrlInput = this.sheet.getByPlaceholder(/http:\/\/your-mcp-server/i).or(
page.locator('[data-testid="connection-url-input"]')
)
// Switches (Radix UI switches)
this.codeModeSwitch = page.locator('[data-testid="code-mode-switch"]')
this.pingAvailableSwitch = this.sheet.locator('#ping-available')
// STDIO inputs
this.commandInput = page.locator('[data-testid="stdio-command-input"]')
this.argsInput = page.locator('[data-testid="stdio-args-input"]')
this.envsInput = page.locator('[data-testid="stdio-envs-input"]')
// OAuth inputs
this.oauthClientIdInput = this.sheet.getByPlaceholder(/your-client-id/i)
this.oauthClientSecretInput = this.sheet.getByPlaceholder(/your-client-secret/i)
this.oauthAuthorizeUrlInput = this.sheet.getByPlaceholder(/oauth\/authorize/i)
this.oauthTokenUrlInput = this.sheet.getByPlaceholder(/oauth\/token/i)
this.oauthScopesInput = this.sheet.getByPlaceholder(/read, write, admin/i)
}
async goto(): Promise<void> {
await this.page.goto('/workspace/mcp-registry')
await waitForNetworkIdle(this.page)
// Wait for table to be visible
await this.table.waitFor({ state: 'visible', timeout: 10000 }).catch(() => {})
}
/** Get the table row for a client by name. Scoped to tbody so the header row is never matched; first() for stable single-row target. */
getClientRow(name: string): Locator {
return this.table.locator('tbody tr').filter({ hasText: name }).first()
}
async clientExists(name: string): Promise<boolean> {
await this.page.waitForTimeout(500) // Brief wait for UI update
return (await this.getClientRow(name).count()) > 0
}
/**
* Poll until the client row appears in the table or timeout.
* Used as a fallback success signal when the create form doesn't close (e.g. SSE/stdio).
*/
async waitForClientInTable(name: string, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if ((await this.getClientRow(name).count()) > 0) return true
await this.page.waitForTimeout(500)
}
return false
}
async getClientCount(): Promise<number> {
// Exclude header row
const rows = this.table.locator('tbody tr')
return await rows.count()
}
/**
* Select connection type from dropdown
*/
async selectConnectionType(type: MCPConnectionType): Promise<void> {
// Click the connection type select trigger
const selectTrigger = this.page.locator('[data-testid="connection-type-select"]')
await expect(selectTrigger).toBeVisible({ timeout: 5000 })
await selectTrigger.click()
// Wait for dropdown to open and select the option by data-testid
const optionTestId = `connection-type-${type}`
const option = this.page.locator(`[data-testid="${optionTestId}"]`)
await expect(option).toBeVisible({ timeout: 5000 })
await option.click()
// Wait for dropdown to close
await expect(option).not.toBeVisible({ timeout: 3000 }).catch(() => {})
}
/**
* Select authentication type from dropdown
*/
async selectAuthType(type: MCPAuthType): Promise<void> {
const selectTrigger = this.page.locator('[data-testid="auth-type-select"]')
await expect(selectTrigger).toBeVisible({ timeout: 5000 })
await selectTrigger.click()
// Select the option by data-testid
const optionTestId = `auth-type-${type}`
const option = this.page.locator(`[data-testid="${optionTestId}"]`)
await expect(option).toBeVisible({ timeout: 5000 })
await option.click()
// Wait for dropdown to close
await expect(option).not.toBeVisible({ timeout: 3000 }).catch(() => {})
}
/**
* Fill the MCP client form with configuration (doesn't submit)
*/
async fillClientForm(config: MCPClientConfig): Promise<void> {
// Fill name
await this.nameInput.fill(config.name)
// Select connection type if specified
if (config.connectionType) {
await this.selectConnectionType(config.connectionType)
// Wait for the form to update after connection type change
await this.page.waitForTimeout(500)
}
// Toggle code mode if specified (Radix Switch uses data-state="checked"/"unchecked")
if (config.isCodeMode !== undefined) {
await expect(this.codeModeSwitch).toBeVisible({ timeout: 5000 })
const dataState = await this.codeModeSwitch.getAttribute('data-state')
const currentState = dataState === 'checked'
if (currentState !== config.isCodeMode) {
await this.codeModeSwitch.click()
// Wait for state to change
const expectedState = config.isCodeMode ? 'checked' : 'unchecked'
await expect(this.codeModeSwitch).toHaveAttribute('data-state', expectedState, { timeout: 3000 })
}
}
// Toggle ping available if specified (Radix Switch)
if (config.isPingAvailable !== undefined) {
const dataState = await this.pingAvailableSwitch.getAttribute('data-state')
const currentState = dataState === 'checked'
if (currentState !== config.isPingAvailable) {
await this.pingAvailableSwitch.click()
}
}
// Handle connection-type specific fields
if (config.connectionType === 'http' || config.connectionType === 'sse' || !config.connectionType) {
// Wait for auth type field to be visible (only shows for HTTP/SSE)
await expect(this.authTypeSelect).toBeVisible({ timeout: 5000 })
// Select auth type if specified
if (config.authType) {
await this.selectAuthType(config.authType)
await this.page.waitForTimeout(500)
}
// Fill connection URL
if (config.connectionUrl) {
await expect(this.connectionUrlInput).toBeVisible({ timeout: 5000 })
await this.connectionUrlInput.fill(config.connectionUrl)
// Wait for React to process the input
await this.page.waitForTimeout(500)
}
// Fill headers when auth_type is 'headers' (required for SSE test; export MCP_SSE_HEADERS in your environment)
if (config.authType === 'headers' && config.headers && Object.keys(config.headers).length > 0) {
const headersTable = this.sheet.locator('[data-testid="mcp-headers-table"]')
await expect(headersTable).toBeVisible({ timeout: 5000 })
const entries = Object.entries(config.headers)
for (let i = 0; i < entries.length; i++) {
const [key, val] = entries[i]
const valueStr = typeof val === 'object' && val !== null && 'value' in val ? (val as EnvVarLike).value : String(val)
const keyInput = headersTable.locator(`input[data-row="${i}"][data-column="key"]`)
const valueInput = headersTable.locator(`input[data-row="${i}"][data-column="value"]`).or(
headersTable.locator(`[data-row="${i}"][data-column="value"] input`)
)
await keyInput.waitFor({ state: 'visible', timeout: 8000 })
await keyInput.scrollIntoViewIfNeeded()
await keyInput.click()
await keyInput.fill(key)
await this.page.waitForTimeout(400)
const valueEl = valueInput.first()
await valueEl.waitFor({ state: 'visible', timeout: 3000 })
await valueEl.scrollIntoViewIfNeeded()
await valueEl.click()
await valueEl.fill(valueStr)
await this.page.waitForTimeout(500)
}
}
// Handle OAuth config
if (config.authType === 'oauth') {
if (config.oauthClientId) {
await this.oauthClientIdInput.fill(config.oauthClientId)
}
if (config.oauthClientSecret) {
await this.oauthClientSecretInput.fill(config.oauthClientSecret)
}
if (config.oauthAuthorizeUrl) {
await this.oauthAuthorizeUrlInput.fill(config.oauthAuthorizeUrl)
}
if (config.oauthTokenUrl) {
await this.oauthTokenUrlInput.fill(config.oauthTokenUrl)
}
if (config.oauthScopes) {
await this.oauthScopesInput.fill(config.oauthScopes)
}
}
} else if (config.connectionType === 'stdio') {
// Fill STDIO specific fields - wait for them to be visible after type change
if (config.command) {
await expect(this.commandInput).toBeVisible({ timeout: 5000 })
await this.commandInput.fill(config.command)
// Wait for React to process the input
await this.page.waitForTimeout(500)
}
if (config.args) {
await expect(this.argsInput).toBeVisible({ timeout: 5000 })
await this.argsInput.fill(config.args)
}
if (config.envs) {
await expect(this.envsInput).toBeVisible({ timeout: 5000 })
await this.envsInput.fill(config.envs)
}
}
}
/**
* Create an MCP client with full configuration
*/
async createClient(config: MCPClientConfig): Promise<boolean> {
await this.dismissToasts()
await this.createBtn.click()
await expect(this.sheet).toBeVisible({ timeout: 5000 })
// Fill the form
await this.fillClientForm(config)
// Wait for form validation to complete
await this.page.waitForTimeout(1500)
// Wait for save button to be enabled (validation passed)
await expect(this.saveBtn).toBeEnabled({ timeout: 10000 })
// Verify button is visible and contains expected text
await expect(this.saveBtn).toBeVisible()
await expect(this.saveBtn).toContainText(/Create|Save/i)
// Wait for create-client API response then click save (backend may be slow connecting to MCP server)
// Create is POST to /mcp/client (singular); do not match GET /mcp/clients
const responsePromise = this.page.waitForResponse(
(response) => {
const url = response.url()
const method = response.request().method()
return (
(url.includes('/mcp/client') && !url.endsWith('/mcp/clients')) &&
(method === 'POST' || method === 'PUT')
)
},
{ timeout: 60000 }
)
await this.saveBtn.click({ force: true })
const response = await responsePromise.catch(() => null)
const ok = response && response.ok()
if (response && !ok) {
const body = await response.text().catch(() => '')
await this.page.keyboard.press('Escape')
await this.sheet.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {})
throw new Error(`Create MCP client failed: ${response.status()} ${body}`)
}
// Success: backend returned 2xx. Wait for create form to close (short timeout; UI usually updates quickly).
const createFormHeading = this.page.getByRole('heading', { name: 'New MCP Server' })
await createFormHeading.waitFor({ state: 'hidden', timeout: 15000 }).catch(() => null)
const createFormClosed = !(await createFormHeading.isVisible().catch(() => false))
if (createFormClosed) {
return true
}
// Backend succeeded but form may not close quickly (e.g. SSE/stdio). If client appears in table, treat as success.
const inTable = await this.waitForClientInTable(config.name, 10000)
if (inTable) {
await this.page.keyboard.press('Escape')
await this.sheet.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {})
return true
}
// Fallback: wait for toast or heading (e.g. slow UI)
const toast = this.getToast()
await Promise.race([
createFormHeading.waitFor({ state: 'hidden', timeout: 10000 }).catch(() => null),
toast.waitFor({ state: 'visible', timeout: 10000 }).catch(() => null),
])
if (!(await createFormHeading.isVisible().catch(() => true))) {
return true
}
// Sheet still open - check for toast (success or error)
let toastText = ''
let toastVisible = false
try {
toastVisible = await toast.isVisible()
if (toastVisible) toastText = (await toast.textContent()) || ''
} catch {
// ignore
}
if (toastVisible && toastText) {
const isSuccess =
toastText.toLowerCase().includes('success') ||
toastText.toLowerCase().includes('created') ||
toastText.toLowerCase().includes('server created')
if (isSuccess) {
await createFormHeading.waitFor({ state: 'hidden', timeout: 10000 }).catch(() => null)
return true
}
await this.page.keyboard.press('Escape')
await this.sheet.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {})
throw new Error(`Client creation failed with error: ${toastText}`)
}
// No toast, sheet still open - validation or unknown failure
const errorMessages = (await this.page.locator('[role="alert"]').allTextContents().catch(() => []))
.map((t) => t.trim())
.filter(Boolean)
if (errorMessages.length > 0) {
throw new Error(`Form validation errors: ${errorMessages.join(', ')}`)
}
const isDisabled = await this.saveBtn.isDisabled().catch(() => false)
if (isDisabled) {
throw new Error('Save button is disabled - form validation failed')
}
throw new Error('No toast appeared and sheet did not close - form submission may have failed')
}
/**
* View client details by clicking on the row
*/
async viewClientDetails(name: string): Promise<void> {
const row = this.getClientRow(name)
await row.click()
await expect(this.detailSheet).toBeVisible({ timeout: 5000 })
}
/**
* Close the detail sheet
*/
async closeDetailSheet(): Promise<void> {
// Press Escape or click the X button
await this.page.keyboard.press('Escape')
await expect(this.detailSheet).not.toBeVisible({ timeout: 5000 }).catch(async () => {
// If still visible, try clicking X button
const closeBtn = this.detailSheet.locator('button').filter({ has: this.page.locator('svg.lucide-x') })
if (await closeBtn.isVisible()) {
await closeBtn.click()
}
})
}
/**
* Close any open sheet/dialog (create or detail) so the table is visible
*/
async closeSheet(): Promise<void> {
const isVisible = await this.sheet.isVisible().catch(() => false)
if (isVisible) {
await this.page.keyboard.press('Escape')
await expect(this.sheet).not.toBeVisible({ timeout: 5000 }).catch(() => {})
}
}
/**
* Clean up MCP clients by name. Ensures we're on the page and any sheet is closed before deleting.
*/
async cleanupMCPClients(names: string[]): Promise<void> {
if (names.length === 0) return
await this.goto()
await this.closeSheet()
await this.dismissToasts()
await this.table.waitFor({ state: 'visible', timeout: 10000 }).catch(() => {})
await this.page.waitForTimeout(500)
for (const name of names) {
const tryDelete = async (): Promise<void> => {
const exists = await this.clientExists(name)
if (!exists) return
await this.closeSheet()
await this.deleteClient(name, { requireToast: false })
}
try {
await tryDelete()
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
console.error(`[CLEANUP ERROR] Failed to delete MCP client: ${name} - ${errorMsg}`)
await this.closeSheet()
await this.page.waitForTimeout(1000)
try {
await tryDelete()
} catch (retryErr) {
const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr)
console.error(`[CLEANUP ERROR] Retry failed for MCP client: ${name} - ${retryMsg}`)
}
}
}
}
/**
* Edit an existing client
*/
async editClient(name: string, updates: Partial<MCPClientConfig>): Promise<void> {
await this.viewClientDetails(name)
// Update name if provided
if (updates.name) {
const nameInput = this.detailSheet.getByLabel(/Name/i).first()
await nameInput.clear()
await nameInput.fill(updates.name)
}
// Toggle code mode if specified
if (updates.isCodeMode !== undefined) {
const codeModeSwitch = this.detailSheet
.locator('input[type="checkbox"]')
.filter({ has: this.page.locator('#code-mode') })
.or(this.detailSheet.getByRole('switch', { name: /Code Mode/i }))
const isVisible = await codeModeSwitch.isVisible().catch(() => false)
if (isVisible) {
const currentState = await codeModeSwitch.isChecked()
if (currentState !== updates.isCodeMode) {
await codeModeSwitch.click()
}
}
}
// Save changes
const saveBtn = this.detailSheet.getByRole('button', { name: /Save/i })
await saveBtn.click()
await this.waitForSuccessToast()
await expect(this.detailSheet).not.toBeVisible({ timeout: 10000 })
}
/**
* Reconnect an MCP client
*/
async reconnectClient(name: string): Promise<void> {
const row = this.getClientRow(name)
// Stop propagation by clicking the reconnect button directly
const reconnectBtn = row.locator('button').filter({ has: this.page.locator('svg.lucide-refresh-ccw') })
await reconnectBtn.click()
await this.waitForSuccessToast('Reconnected')
}
/**
* Toggle tool enabled state in the detail sheet
*/
async toggleToolEnabled(clientName: string, toolName: string): Promise<void> {
await this.viewClientDetails(clientName)
// Find the tool row and toggle its enabled switch
const toolRow = this.detailSheet.locator('tr').filter({ hasText: toolName })
const enabledSwitch = toolRow.locator('button[role="switch"]').first()
await enabledSwitch.click()
// Save
const saveBtn = this.detailSheet.getByRole('button', { name: /Save/i })
await saveBtn.click()
await this.waitForSuccessToast()
await expect(this.detailSheet).not.toBeVisible({ timeout: 10000 })
}
/**
* Toggle auto-execute for a tool in the detail sheet
*/
async toggleAutoExecute(clientName: string, toolName: string): Promise<void> {
await this.viewClientDetails(clientName)
// Find the tool row and toggle its auto-execute switch (second switch)
const toolRow = this.detailSheet.locator('tr').filter({ hasText: toolName })
const autoExecuteSwitch = toolRow.locator('button[role="switch"]').nth(1)
await autoExecuteSwitch.click()
// Save
const saveBtn = this.detailSheet.getByRole('button', { name: /Save/i })
await saveBtn.click()
await this.waitForSuccessToast()
await expect(this.detailSheet).not.toBeVisible({ timeout: 10000 })
}
/**
* Toggle code mode for a client
*/
async toggleCodeMode(clientName: string): Promise<void> {
await this.viewClientDetails(clientName)
// Find and toggle the code mode switch
const codeModeSwitch = this.detailSheet
.getByRole('switch', { name: /Code Mode/i })
.or(this.detailSheet.locator('#code-mode'))
await codeModeSwitch.click()
// Save
const saveBtn = this.detailSheet.getByRole('button', { name: /Save/i })
await saveBtn.click()
await this.waitForSuccessToast()
await expect(this.detailSheet).not.toBeVisible({ timeout: 10000 })
}
/**
* Get client status from the table
*/
async getClientStatus(name: string): Promise<string> {
const row = this.getClientRow(name)
const statusBadge = row
.locator('[class*="badge"]')
.or(row.locator('span').filter({ hasText: /connected|disconnected|connecting|error/i }))
.last()
const statusText = await statusBadge.textContent()
return statusText?.toLowerCase().trim() || ''
}
/**
* Get connection type displayed in the table (HTTP, SSE, STDIO)
*/
async getClientConnectionType(name: string): Promise<string> {
const row = this.getClientRow(name)
const typeCell = row.getByTestId('mcp-client-connection-type')
if ((await typeCell.count()) > 0) {
return (await typeCell.first().textContent())?.trim() ?? ''
}
const cells = row.locator('td')
if ((await cells.count()) >= 2) {
return (await cells.nth(1).textContent())?.trim() ?? ''
}
return ''
}
/**
* Get tools count from the client details sheet
* Assumes the detail sheet is already open
*/
async getToolsCount(): Promise<number> {
// Tools are displayed in a table in the detail sheet
const toolRows = this.detailSheet.locator('table tbody tr')
const count = await toolRows.count()
return count
}
/**
* Get enabled tools count from table
*/
async getEnabledToolsCount(name: string): Promise<string | null> {
const row = this.getClientRow(name)
// Enabled tools is typically shown as "X/Y" format
const cells = row.locator('td')
const count = await cells.count()
if (count >= 5) {
return await cells.nth(4).textContent()
}
return null
}
/**
* Cancel client creation
*/
async cancelCreation(): Promise<void> {
await this.cancelBtn.click()
await expect(this.sheet).not.toBeVisible({ timeout: 5000 })
}
/**
* Wait for the client row to disappear from the table (e.g. after delete or refetch).
* Polls so we don't rely on a stale locator.
*/
async waitForClientGone(name: string, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if ((await this.getClientRow(name).count()) === 0) return true
await this.page.waitForTimeout(500)
}
return false
}
/**
* Delete an MCP client. Success is determined by the DELETE API completing and the row
* disappearing from the table after the list refetches.
*/
async deleteClient(name: string, options?: { requireToast?: boolean }): Promise<void> {
const row = this.getClientRow(name)
const deleteBtn = row
.locator('button')
.filter({ has: this.page.locator('svg.lucide-trash-2') })
.or(row.locator('button').filter({ has: this.page.locator('svg.lucide-trash') }))
await deleteBtn.click()
const confirmDialog = this.page.locator('[role="alertdialog"]')
await expect(confirmDialog).toBeVisible({ timeout: 5000 })
const deleteResponsePromise = this.page.waitForResponse(
(response) => {
const url = response.url()
return url.includes('/mcp/client/') && response.request().method() === 'DELETE'
},
{ timeout: 15000 }
)
await confirmDialog.getByRole('button', { name: /Delete/i }).click()
await deleteResponsePromise.catch(() => null)
// Wait for table to refetch and row to disappear (poll fresh locator; avoid stale row reference)
const gone = await this.waitForClientGone(name, 20000)
if (!gone) {
throw new Error(`Client "${name}" still visible after delete`)
}
if (options?.requireToast !== false) {
await this.getToast().waitFor({ state: 'visible', timeout: 5000 }).catch(() => {})
}
}
/**
* Check if empty state is visible
*/
async isEmptyStateVisible(): Promise<boolean> {
const emptyMessage = this.page.getByText(/No clients found/i)
return await emptyMessage.isVisible().catch(() => false)
}
}

View File

@@ -0,0 +1,21 @@
import { expect, test } from '../../core/fixtures/base.fixture'
test.describe('MCP Settings', () => {
test.beforeEach(async ({ mcpSettingsPage }) => {
await mcpSettingsPage.goto()
})
test('should display MCP settings page', async ({ mcpSettingsPage }) => {
await expect(mcpSettingsPage.mcpSettingsView).toBeVisible()
})
test('should display MCP settings form fields', async ({ mcpSettingsPage }) => {
await expect(mcpSettingsPage.page.getByLabel('Max Agent Depth')).toBeVisible()
await expect(mcpSettingsPage.page.getByLabel('Tool Execution Timeout (seconds)')).toBeVisible()
})
test('should have save button disabled when no changes', async ({ mcpSettingsPage }) => {
await expect(mcpSettingsPage.saveBtn).toBeVisible()
await expect(mcpSettingsPage.saveBtn).toBeDisabled()
})
})

View File

@@ -0,0 +1,24 @@
import { Locator, Page } from '@playwright/test'
import { BasePage } from '../../../core/pages/base.page'
import { waitForNetworkIdle } from '../../../core/utils/test-helpers'
export class MCPSettingsPage extends BasePage {
readonly mcpSettingsView: Locator
readonly saveBtn: Locator
readonly maxAgentDepthInput: Locator
readonly toolTimeoutInput: Locator
constructor(page: Page) {
super(page)
this.mcpSettingsView = page.getByTestId('mcp-settings-view')
this.saveBtn = page.getByTestId('mcp-settings-save-btn')
this.maxAgentDepthInput = page.getByTestId('mcp-agent-depth-input').or(page.locator('#mcp-agent-depth'))
this.toolTimeoutInput = page.getByTestId('mcp-tool-timeout-input').or(page.locator('#mcp-tool-execution-timeout'))
}
async goto(): Promise<void> {
await this.page.goto('/workspace/mcp-settings')
await waitForNetworkIdle(this.page)
}
}

View File

@@ -0,0 +1,13 @@
import { expect, test } from '../../core/fixtures/base.fixture'
// MCP Tool Groups routes to @enterprise components not present in OSS.
// Tests only verify URL routing; do not add UI assertions for enterprise-only content.
test.describe('MCP Tool Groups', () => {
test.beforeEach(async ({ mcpToolGroupsPage }) => {
await mcpToolGroupsPage.goto()
})
test('should load MCP tool groups page', async ({ mcpToolGroupsPage }) => {
await expect(mcpToolGroupsPage.page).toHaveURL(/mcp-tool-groups/)
})
})

View File

@@ -0,0 +1,14 @@
import { Page } from '@playwright/test'
import { BasePage } from '../../../core/pages/base.page'
import { waitForNetworkIdle } from '../../../core/utils/test-helpers'
export class MCPToolGroupsPage extends BasePage {
constructor(page: Page) {
super(page)
}
async goto(): Promise<void> {
await this.page.goto('/workspace/mcp-tool-groups')
await waitForNetworkIdle(this.page)
}
}

View File

@@ -0,0 +1,11 @@
import { ModelLimitConfig } from './pages/model-limits.page'
export function createModelLimitData(overrides: Partial<ModelLimitConfig> = {}): ModelLimitConfig {
return {
provider: 'openai',
modelName: 'gpt-4o-mini',
budget: { maxLimit: 10, resetDuration: '1M' },
rateLimit: { tokenMaxLimit: 1000, requestMaxLimit: 50 },
...overrides,
}
}

View File

@@ -0,0 +1,81 @@
import { expect, test } from '../../core/fixtures/base.fixture'
import { createModelLimitData } from './model-limits.data'
const createdLimits: { modelName: string; provider: string }[] = []
test.describe('Model Limits', () => {
test.beforeEach(async ({ modelLimitsPage }) => {
await modelLimitsPage.goto()
})
test.afterEach(async ({ modelLimitsPage }) => {
await modelLimitsPage.closeSheet()
for (const { modelName, provider } of [...createdLimits]) {
try {
const exists = await modelLimitsPage.modelLimitExists(modelName, provider)
if (exists) {
await modelLimitsPage.deleteModelLimit(modelName, provider)
}
} catch (e) {
console.error(`[CLEANUP] Failed to delete model limit ${modelName}:`, e)
}
}
createdLimits.length = 0
})
test('should display create button or empty state', async ({ modelLimitsPage }) => {
const createVisible = await modelLimitsPage.createBtn.isVisible().catch(() => false)
expect(createVisible).toBe(true)
})
test('should create a model limit with budget and rate limit', async ({ modelLimitsPage }) => {
const limitData = createModelLimitData({
provider: 'openai',
budget: { maxLimit: 5, resetDuration: '1M' },
rateLimit: { tokenMaxLimit: 500, requestMaxLimit: 20 },
})
const modelName = await modelLimitsPage.createModelLimit(limitData)
createdLimits.push({ modelName, provider: limitData.provider })
const exists = await modelLimitsPage.modelLimitExists(modelName, limitData.provider)
expect(exists).toBe(true)
})
test('should edit a model limit budget and rate limit', async ({ modelLimitsPage }) => {
const limitData = createModelLimitData({ provider: 'openai' })
const modelName = await modelLimitsPage.createModelLimit(limitData)
createdLimits.push({ modelName, provider: limitData.provider })
await modelLimitsPage.editModelLimit(modelName, limitData.provider, {
budget: { maxLimit: 20 },
rateLimit: { tokenMaxLimit: 2000, requestMaxLimit: 100 },
})
const exists = await modelLimitsPage.modelLimitExists(modelName, limitData.provider)
expect(exists).toBe(true)
})
test('should delete a model limit', async ({ modelLimitsPage }) => {
const limitData = createModelLimitData({
provider: 'openai',
budget: { maxLimit: 5 },
})
const modelName = await modelLimitsPage.createModelLimit(limitData)
createdLimits.push({ modelName, provider: limitData.provider })
let exists = await modelLimitsPage.modelLimitExists(modelName, limitData.provider)
expect(exists).toBe(true)
await modelLimitsPage.deleteModelLimit(modelName, limitData.provider)
const idx = createdLimits.findIndex(
(limit) => limit.modelName === modelName && limit.provider === limitData.provider
)
if (idx >= 0) createdLimits.splice(idx, 1)
exists = await modelLimitsPage.modelLimitExists(modelName, limitData.provider)
expect(exists).toBe(false)
})
})

View File

@@ -0,0 +1,138 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '../../../core/fixtures/base.fixture'
import { BasePage } from '../../../core/pages/base.page'
import { fillSelect, waitForNetworkIdle } from '../../../core/utils/test-helpers'
export interface ModelLimitConfig {
provider: string
modelName: string
budget?: { maxLimit: number; resetDuration?: string }
rateLimit?: {
tokenMaxLimit?: number
requestMaxLimit?: number
}
}
function toTestIdPart(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
}
export class ModelLimitsPage extends BasePage {
readonly createBtn: Locator
readonly table: Locator
readonly sheet: Locator
constructor(page: Page) {
super(page)
this.createBtn = page.getByTestId('model-limits-button-create')
this.table = page.getByTestId('model-limits-table')
this.sheet = page.getByTestId('model-limit-sheet')
}
async goto(): Promise<void> {
await this.page.goto('/workspace/model-limits')
await waitForNetworkIdle(this.page)
}
getModelLimitRow(modelName: string, provider: string = 'all'): Locator {
return this.page.getByTestId(`model-limit-row-${toTestIdPart(modelName)}-${toTestIdPart(provider)}`)
}
async modelLimitExists(modelName: string, provider: string = 'all'): Promise<boolean> {
const row = this.getModelLimitRow(modelName, provider)
return (await row.count()) > 0
}
/**
* Create a model limit via the sheet: selects provider, selects the requested
* model (config.modelName) in the search dropdown, fills budget and rate
* limit, then saves. Returns the selected model name for use in exists/edit/delete.
*/
async createModelLimit(config: ModelLimitConfig): Promise<string> {
await this.createBtn.click()
await expect(this.sheet).toBeVisible({ timeout: 5000 })
await this.waitForSheetAnimation()
// Select provider (stable testid; sheet is the only one open)
await fillSelect(
this.page,
'[data-testid="model-limit-provider-select"]',
config.provider === 'all' ? 'All Providers' : config.provider
)
// Model multiselect - search and select requested model deterministically
const modelSelectContainer = this.sheet.getByTestId('model-limit-model-select')
const modelInput = modelSelectContainer.locator('input')
await modelInput.fill(config.modelName)
await this.page.waitForSelector('[role="option"]', { timeout: 10000 })
const targetOption = this.page.getByRole('option', { name: config.modelName, exact: true })
await expect(targetOption).toBeVisible({ timeout: 10000 })
await targetOption.click()
const selectedModelName = config.modelName
if (config.budget?.maxLimit !== undefined) {
const budgetInput = this.page.locator('#modelBudgetMaxLimit')
await budgetInput.fill(String(config.budget.maxLimit))
}
if (config.rateLimit?.tokenMaxLimit !== undefined) {
await this.page.locator('#modelTokenMaxLimit').fill(String(config.rateLimit.tokenMaxLimit))
}
if (config.rateLimit?.requestMaxLimit !== undefined) {
await this.page.locator('#modelRequestMaxLimit').fill(String(config.rateLimit.requestMaxLimit))
}
const saveBtn = this.page.getByRole('button', { name: /Create Limit/i })
await saveBtn.click()
await this.waitForSuccessToast()
await expect(this.sheet).not.toBeVisible({ timeout: 10000 })
return selectedModelName
}
async editModelLimit(modelName: string, provider: string, updates: Partial<ModelLimitConfig>): Promise<void> {
const editBtn = this.page.getByTestId(`model-limit-button-edit-${toTestIdPart(modelName)}-${toTestIdPart(provider)}`)
await editBtn.click()
await expect(this.sheet).toBeVisible({ timeout: 5000 })
await this.waitForSheetAnimation()
if (updates.budget?.maxLimit !== undefined) {
const budgetInput = this.page.locator('#modelBudgetMaxLimit')
await budgetInput.clear()
await budgetInput.fill(String(updates.budget.maxLimit))
}
if (updates.rateLimit?.tokenMaxLimit !== undefined) {
const tokenInput = this.page.locator('#modelTokenMaxLimit')
await tokenInput.clear()
await tokenInput.fill(String(updates.rateLimit.tokenMaxLimit))
}
if (updates.rateLimit?.requestMaxLimit !== undefined) {
const requestInput = this.page.locator('#modelRequestMaxLimit')
await requestInput.clear()
await requestInput.fill(String(updates.rateLimit.requestMaxLimit))
}
const saveBtn = this.page.getByRole('button', { name: /Save Changes|Create Limit/i })
await saveBtn.click()
await this.waitForSuccessToast()
await expect(this.sheet).not.toBeVisible({ timeout: 10000 })
}
async deleteModelLimit(modelName: string, provider: string = 'all'): Promise<void> {
const deleteBtn = this.page.getByTestId(`model-limit-button-delete-${toTestIdPart(modelName)}-${toTestIdPart(provider)}`)
await deleteBtn.click()
const confirmDialog = this.page.locator('[role="alertdialog"]')
await confirmDialog.getByRole('button', { name: /Delete/i }).click()
await this.waitForSuccessToast()
await this.page.waitForTimeout(1000)
}
async closeSheet(): Promise<void> {
if (await this.sheet.isVisible().catch(() => false)) {
await this.page.keyboard.press('Escape')
await expect(this.sheet).not.toBeVisible({ timeout: 5000 }).catch(() => {})
}
}
}

View File

@@ -0,0 +1,38 @@
/**
* Test data factories for observability connector tests
*/
/**
* Observability connector configuration
*/
export interface ObservabilityConnectorConfig {
type: 'otel' | 'maxim'
enabled: boolean
endpoint?: string
apiKey?: string
}
/**
* Create OTEL connector configuration data
*/
export function createOtelConnectorData(overrides: Partial<ObservabilityConnectorConfig> = {}): ObservabilityConnectorConfig {
return {
type: 'otel',
enabled: true,
endpoint: 'http://localhost:4318',
...overrides
}
}
/**
* Create Maxim connector configuration data
*/
export function createMaximConnectorData(overrides: Partial<ObservabilityConnectorConfig> = {}): ObservabilityConnectorConfig {
return {
type: 'maxim',
enabled: true,
endpoint: 'http://localhost:8080',
apiKey: 'test-api-key',
...overrides
}
}

View File

@@ -0,0 +1,330 @@
import { expect, test } from '../../core/fixtures/base.fixture'
import { ObservabilityState } from './pages/observability.page'
test.describe('Observability', () => {
let originalState: ObservabilityState
test.beforeEach(async ({ observabilityPage }) => {
await observabilityPage.goto()
// Capture original state for restoration
originalState = await observabilityPage.getCurrentState()
})
test.afterEach(async ({ observabilityPage }) => {
// Restore original state - disable all connectors that weren't enabled before
await observabilityPage.disableAllConnectors()
})
test.describe('Navigation', () => {
test('should display observability page', async ({ observabilityPage }) => {
// Check for the sidebar section header "Providers" (exact match to avoid strict mode)
const providersHeader = observabilityPage.page.locator('.text-muted-foreground').filter({ hasText: 'Providers' }).first()
await expect(providersHeader).toBeVisible()
})
test('should display OTel connector tab', async ({ observabilityPage }) => {
await expect(observabilityPage.getConnectorTab('otel')).toBeVisible()
})
test('should display Maxim connector tab', async ({ observabilityPage }) => {
await expect(observabilityPage.getConnectorTab('maxim')).toBeVisible()
})
test('should display Datadog connector tab', async ({ observabilityPage }) => {
await expect(observabilityPage.getConnectorTab('datadog')).toBeVisible()
})
})
test.describe('OTel Connector', () => {
test('should select OTel connector', async ({ observabilityPage }) => {
await observabilityPage.selectConnector('otel')
// Should see OTel-specific content - check for metrics label or input
const metricsVisible = await observabilityPage.isMetricsEndpointVisible()
expect(metricsVisible).toBe(true)
})
test('should display metrics endpoint', async ({ observabilityPage }) => {
await observabilityPage.selectConnector('otel')
// The metrics endpoint is in an input field with value containing /metrics
await observabilityPage.enableMetricsExport()
const metricsValue = await observabilityPage.getMetricsEndpointValue()
const metricsInput = observabilityPage.page.getByPlaceholder(/v1\/metrics|otel-collector.*metrics/i)
const placeholder = await metricsInput.getAttribute('placeholder').catch(() => null)
const hasMetrics =
(metricsValue != null && metricsValue.includes('/metrics')) ||
(placeholder != null && placeholder.includes('/metrics'))
expect(hasMetrics).toBe(true)
})
test('should toggle OTel connector', async ({ observabilityPage }) => {
await observabilityPage.selectConnector('otel')
// Check if toggle is enabled (not disabled)
const isToggleEnabled = await observabilityPage.isToggleEnabled('otel')
if (!isToggleEnabled) {
test.skip(true, 'OTel toggle is disabled (requires configuration)')
return
}
const initialState = await observabilityPage.isConnectorEnabled('otel')
const toggled = await observabilityPage.toggleConnector('otel')
expect(toggled).toBe(true)
// Verify toggle state flipped; poll briefly in case UI updates async (form can reset from refetch)
await expect
.poll(async () => observabilityPage.isConnectorEnabled('otel'), { timeout: 3000 })
.toBe(!initialState)
})
test('should display OTel delete button when connector is configured', async ({ observabilityPage }) => {
await observabilityPage.selectConnector('otel')
const deleteBtn = observabilityPage.getConnectorDeleteBtn('otel')
const isVisible = await deleteBtn.isVisible().catch(() => false)
if (!isVisible) {
test.skip(true, 'OTel delete button not visible (connector may not be configured)')
return
}
await expect(deleteBtn).toBeVisible()
})
test('should configure OTel endpoint', async ({ observabilityPage }) => {
await observabilityPage.selectConnector('otel')
const endpointInput = observabilityPage.page.getByPlaceholder(/otel-collector/i)
const isVisible = await endpointInput.isVisible().catch(() => false)
if (!isVisible) {
// Skip if endpoint input not available
test.skip(true, 'OTel endpoint input not available')
return
}
const testEndpoint = 'http://test-otel-collector:4317'
await endpointInput.clear()
await endpointInput.fill(testEndpoint)
const value = await endpointInput.inputValue()
expect(value).toBe(testEndpoint)
})
})
test.describe('Maxim Connector', () => {
test('should select Maxim connector', async ({ observabilityPage }) => {
await observabilityPage.selectConnector('maxim')
// Verify Maxim is selected by checking aria-current
const selected = await observabilityPage.getSelectedConnector()
expect(selected).toContain('Maxim')
})
test('should toggle Maxim connector', async ({ observabilityPage }) => {
await observabilityPage.selectConnector('maxim')
// Check if toggle is enabled
const isToggleEnabled = await observabilityPage.isToggleEnabled('maxim')
if (!isToggleEnabled) {
test.skip(true, 'Maxim toggle is disabled (requires configuration)')
return
}
const initialState = await observabilityPage.isConnectorEnabled('maxim')
const toggled = await observabilityPage.toggleConnector('maxim')
expect(toggled).toBe(true)
const newState = await observabilityPage.isConnectorEnabled('maxim')
expect(newState).toBe(!initialState)
})
test('should display Maxim configuration form', async ({ observabilityPage }) => {
await observabilityPage.selectConnector('maxim')
// Should see a form with configuration elements
const form = observabilityPage.page.locator('form')
const formVisible = await form.isVisible().catch(() => false)
if (formVisible) {
const hasInputs = await form.locator('input').first().isVisible().catch(() => false)
const hasSwitches = await form.locator('button[role="switch"]').first().isVisible().catch(() => false)
expect(hasInputs || hasSwitches).toBe(true)
} else {
// Fallback: at minimum expect some configuration inputs
const inputsVisible = await observabilityPage.page.locator('input').first().isVisible().catch(() => false)
expect(inputsVisible).toBe(true)
}
})
})
test.describe('Prometheus Connector', () => {
test('should select Prometheus connector', async ({ observabilityPage }) => {
const isAvailable = await observabilityPage.isConnectorAvailable('prometheus')
if (!isAvailable) {
test.skip(true, 'Prometheus connector not available')
return
}
await observabilityPage.selectConnector('prometheus')
const selected = await observabilityPage.getSelectedConnector()
expect(selected).toContain('Prometheus')
})
test('should display Prometheus configuration when available', async ({ observabilityPage }) => {
const isAvailable = await observabilityPage.isConnectorAvailable('prometheus')
if (!isAvailable) {
test.skip(true, 'Prometheus connector not available')
return
}
await observabilityPage.selectConnector('prometheus')
const toggle = observabilityPage.getConnectorToggle('prometheus')
const isVisible = await toggle.isVisible().catch(() => false)
expect(isVisible).toBe(true)
})
test('should toggle Prometheus connector when available', async ({ observabilityPage }) => {
const isAvailable = await observabilityPage.isConnectorAvailable('prometheus')
if (!isAvailable) {
test.skip(true, 'Prometheus connector not available')
return
}
await observabilityPage.selectConnector('prometheus')
const isToggleEnabled = await observabilityPage.isToggleEnabled('prometheus')
if (!isToggleEnabled) {
test.skip(true, 'Prometheus toggle is disabled')
return
}
const initialState = await observabilityPage.isConnectorEnabled('prometheus')
const toggled = await observabilityPage.toggleConnector('prometheus')
expect(toggled).toBe(true)
const newState = await observabilityPage.isConnectorEnabled('prometheus')
expect(newState).toBe(!initialState)
})
test('should display Prometheus delete button when connector is configured', async ({ observabilityPage }) => {
const isAvailable = await observabilityPage.isConnectorAvailable('prometheus')
if (!isAvailable) {
test.skip(true, 'Prometheus connector not available')
return
}
await observabilityPage.selectConnector('prometheus')
const deleteBtn = observabilityPage.getConnectorDeleteBtn('prometheus')
const isVisible = await deleteBtn.isVisible().catch(() => false)
if (!isVisible) {
test.skip(true, 'Prometheus delete button not visible (connector may not be configured)')
return
}
await expect(deleteBtn).toBeVisible()
})
})
test.describe('BigQuery Connector', () => {
test('should select BigQuery connector', async ({ observabilityPage }) => {
const isAvailable = await observabilityPage.isConnectorAvailable('bigquery')
if (!isAvailable) {
test.skip(true, 'BigQuery connector not available')
return
}
await observabilityPage.selectConnector('bigquery')
const selected = await observabilityPage.getSelectedConnector()
expect(selected).toContain('BigQuery')
})
})
test.describe('Datadog Connector', () => {
test('should select Datadog connector if available', async ({ observabilityPage }) => {
const isAvailable = await observabilityPage.isConnectorAvailable('datadog')
if (!isAvailable) {
test.skip(true, 'Datadog connector not available (enterprise feature)')
return
}
await observabilityPage.selectConnector('datadog')
// Datadog view should be displayed
const selected = await observabilityPage.getSelectedConnector()
expect(selected).toContain('Datadog')
})
test('should toggle Datadog connector if available', async ({ observabilityPage }) => {
const isAvailable = await observabilityPage.isConnectorAvailable('datadog')
if (!isAvailable) {
test.skip(true, 'Datadog connector not available (enterprise feature)')
return
}
await observabilityPage.selectConnector('datadog')
const isToggleEnabled = await observabilityPage.isToggleEnabled('datadog')
if (!isToggleEnabled) {
test.skip(true, 'Datadog toggle is disabled')
return
}
const initialState = await observabilityPage.isConnectorEnabled('datadog')
const toggled = await observabilityPage.toggleConnector('datadog')
if (!toggled) {
test.skip(true, 'Datadog toggle could not be toggled')
return
}
const newState = await observabilityPage.isConnectorEnabled('datadog')
expect(newState).toBe(!initialState)
})
})
test.describe('Multiple Connectors', () => {
test('should switch between connectors', async ({ observabilityPage }) => {
// Start with OTel
await observabilityPage.selectConnector('otel')
let selected = await observabilityPage.getSelectedConnector()
expect(selected).toContain('Open Telemetry')
// Switch to Maxim
await observabilityPage.selectConnector('maxim')
selected = await observabilityPage.getSelectedConnector()
expect(selected).toContain('Maxim')
// Switch back to OTel
await observabilityPage.selectConnector('otel')
selected = await observabilityPage.getSelectedConnector()
expect(selected).toContain('Open Telemetry')
})
test('should persist connector selection via URL', async ({ observabilityPage }) => {
// Select Maxim (URL update via nuqs is async)
await observabilityPage.selectConnector('maxim')
// Wait for URL to reflect selection before asserting
await expect(observabilityPage.page).toHaveURL(/plugin=maxim/, { timeout: 5000 })
})
})
})

View File

@@ -0,0 +1,316 @@
import { Page, Locator } from '@playwright/test'
import { BasePage } from '../../../core/pages/base.page'
import { waitForNetworkIdle } from '../../../core/utils/test-helpers'
/**
* Observability connector state
*/
export interface ObservabilityState {
otelEnabled: boolean
prometheusEnabled: boolean
maximEnabled: boolean
datadogEnabled: boolean
bigqueryEnabled: boolean
newRelicEnabled: boolean
}
export type ObservabilityConnector = 'otel' | 'prometheus' | 'maxim' | 'datadog' | 'bigquery' | 'newrelic'
export class ObservabilityPage extends BasePage {
// Save button (within the active view)
readonly saveBtn: Locator
constructor(page: Page) {
super(page)
// Save button
this.saveBtn = page.getByRole('button', { name: /Save/i })
}
/** Map of connector -> data-testid for enable toggle (otel/prometheus have specific testids) */
private static readonly CONNECTOR_TOGGLE_TESTIDS: Partial<Record<ObservabilityConnector, string>> = {
otel: 'otel-connector-enable-toggle',
prometheus: 'prometheus-connector-enable-toggle',
}
/** Map of connector -> data-testid for delete button (otel/prometheus have specific testids) */
private static readonly CONNECTOR_DELETE_TESTIDS: Partial<Record<ObservabilityConnector, string>> = {
otel: 'otel-connector-delete-btn',
prometheus: 'prometheus-connector-delete-btn',
}
/**
* Get connector tab locator by data-testid
*/
getConnectorTab(connector: ObservabilityConnector): Locator {
return this.page.getByTestId(`observability-provider-btn-${connector}`)
}
/**
* Get connector enable toggle locator. Uses specific data-testid for otel/prometheus.
*/
getConnectorToggle(connector: ObservabilityConnector): Locator {
const testId = ObservabilityPage.CONNECTOR_TOGGLE_TESTIDS[connector]
return testId ? this.page.getByTestId(testId) : this.page.locator('button[role="switch"]').first()
}
/**
* Get connector delete button locator. Returns locator for otel/prometheus; for others returns a no-match locator.
*/
getConnectorDeleteBtn(connector: ObservabilityConnector): Locator {
const testId = ObservabilityPage.CONNECTOR_DELETE_TESTIDS[connector]
return testId ? this.page.getByTestId(testId) : this.page.locator('[data-testid="connector-delete-unused"]')
}
async goto(): Promise<void> {
await this.page.goto('/workspace/observability')
await waitForNetworkIdle(this.page)
}
/**
* Select a connector tab
*/
async selectConnector(connector: ObservabilityConnector): Promise<void> {
const tab = this.getConnectorTab(connector)
// Wait for tab to be visible first
await tab.waitFor({ state: 'visible', timeout: 10000 })
const isDisabled = (await tab.getAttribute('aria-disabled')) === 'true' || (await tab.isDisabled())
if (!isDisabled) {
await tab.click()
await waitForNetworkIdle(this.page)
}
}
/**
* Check if a connector tab is available (not disabled)
*/
async isConnectorAvailable(connector: ObservabilityConnector): Promise<boolean> {
const tab = this.getConnectorTab(connector)
const isVisible = await tab.isVisible().catch(() => false)
if (!isVisible) return false
const isDisabled = (await tab.getAttribute('aria-disabled')) === 'true' || (await tab.isDisabled())
return !isDisabled
}
/**
* Get the currently selected connector (display name)
*/
async getSelectedConnector(): Promise<string | null> {
// Observability view uses plain buttons with aria-current="page" for the selected tab
const selected = this.page.locator('[data-testid^="observability-provider-btn-"][aria-current="page"]')
const isVisible = await selected.isVisible().catch(() => false)
if (!isVisible) return null
return await selected.textContent()
}
/**
* Check if a connector is enabled (toggle is checked)
*/
async isConnectorEnabled(connector: ObservabilityConnector): Promise<boolean> {
const toggle = this.getConnectorToggle(connector)
const isVisible = await toggle.isVisible().catch(() => false)
if (!isVisible) return false
const state = await toggle.getAttribute('data-state')
return state === 'checked'
}
/**
* Check if the toggle is clickable (not disabled)
*/
async isToggleEnabled(connector: ObservabilityConnector): Promise<boolean> {
const toggle = this.getConnectorToggle(connector)
const isVisible = await toggle.isVisible().catch(() => false)
if (!isVisible) return false
const isDisabled = await toggle.isDisabled()
return !isDisabled
}
/**
* Toggle the current connector enabled state (only if toggle is enabled).
* Waits for data-state to transition after click to avoid race with subsequent assertions.
*/
async toggleConnector(connector: ObservabilityConnector): Promise<boolean> {
const toggle = this.getConnectorToggle(connector)
const isVisible = await toggle.isVisible().catch(() => false)
if (!isVisible) return false
const isDisabled = await toggle.isDisabled()
if (isDisabled) return false
const previousState = await toggle.getAttribute('data-state')
await toggle.click()
const expectedState = previousState === 'checked' ? 'unchecked' : 'checked'
await this.waitForStateChange(toggle, 'data-state', expectedState, 5000)
return true
}
/**
* Enable a connector
*/
async enableConnector(toggle: Locator): Promise<void> {
const isChecked = await toggle.getAttribute('data-state') === 'checked'
if (!isChecked) {
const isDisabled = await toggle.isDisabled()
if (!isDisabled) {
await toggle.click()
}
}
}
/**
* Disable a connector
*/
async disableConnector(toggle: Locator): Promise<void> {
const isChecked = await toggle.getAttribute('data-state') === 'checked'
if (isChecked) {
const isDisabled = await toggle.isDisabled()
if (!isDisabled) {
await toggle.click()
}
}
}
/**
* Enable Metrics Export
*/
async enableMetricsExport(): Promise<void> {
await this.selectConnector('otel')
const switch_ = this.page.getByTestId('otel-metrics-export-toggle')
await switch_.waitFor({ state: 'visible', timeout: 5000 })
const checked = await switch_.getAttribute('data-state') === 'checked'
if (!checked) {
await switch_.click()
await this.page.waitForTimeout(400)
}
}
/**
* Configure OTel endpoint
*/
async configureOtelEndpoint(endpoint: string): Promise<void> {
await this.selectConnector('otel')
const endpointInput = this.page.getByPlaceholder(/otel-collector/i)
const isVisible = await endpointInput.isVisible().catch(() => false)
if (isVisible) {
await endpointInput.clear()
await endpointInput.fill(endpoint)
}
}
/**
* Configure Maxim API key
*/
async configureMaximApiKey(apiKey: string): Promise<void> {
await this.selectConnector('maxim')
const apiKeyInput = this.page.getByPlaceholder(/api-key/i)
const isVisible = await apiKeyInput.isVisible().catch(() => false)
if (isVisible) {
await apiKeyInput.clear()
await apiKeyInput.fill(apiKey)
}
}
/**
* Save the current connector configuration
*/
async saveConfiguration(): Promise<void> {
await this.saveBtn.click()
await this.waitForSuccessToast()
}
/**
* Get current state of all connectors (enabled/disabled)
*/
async getCurrentState(): Promise<ObservabilityState> {
const state: ObservabilityState = {
otelEnabled: false,
prometheusEnabled: false,
maximEnabled: false,
datadogEnabled: false,
bigqueryEnabled: false,
newRelicEnabled: false,
}
const connectors: ObservabilityConnector[] = ['otel', 'prometheus', 'maxim', 'datadog', 'bigquery', 'newrelic']
for (const connector of connectors) {
if (await this.isConnectorAvailable(connector)) {
await this.selectConnector(connector)
const enabled = await this.isConnectorEnabled(connector)
if (connector === 'otel') state.otelEnabled = enabled
else if (connector === 'prometheus') state.prometheusEnabled = enabled
else if (connector === 'maxim') state.maximEnabled = enabled
else if (connector === 'datadog') state.datadogEnabled = enabled
else if (connector === 'bigquery') state.bigqueryEnabled = enabled
else if (connector === 'newrelic') state.newRelicEnabled = enabled
}
}
return state
}
/**
* Disable all connectors
*/
async disableAllConnectors(): Promise<void> {
const cleanupErrors: string[] = []
const connectors: ObservabilityConnector[] = ['otel', 'prometheus', 'maxim', 'datadog', 'bigquery', 'newrelic']
for (const connector of connectors) {
if (await this.isConnectorAvailable(connector)) {
try {
await this.selectConnector(connector)
if ((await this.isConnectorEnabled(connector)) && (await this.isToggleEnabled(connector))) {
await this.toggleConnector(connector)
// If Save is disabled there is nothing to persist (connector is already off in UI)
const saveEnabled = await this.saveBtn.isEnabled().catch(() => false)
if (saveEnabled) {
await this.saveConfiguration().catch((e) => {
cleanupErrors.push(`${connector} save: ${e instanceof Error ? e.message : String(e)}`)
})
}
}
} catch (error) {
cleanupErrors.push(`${connector}: ${error instanceof Error ? error.message : String(error)}`)
}
}
}
if (cleanupErrors.length > 0) {
throw new Error(`disableAllConnectors failed for: ${cleanupErrors.join('; ')}`)
}
}
/**
* Check if OTel-specific content is visible (confirms we're on the OTel panel).
* The metrics endpoint input is only in the DOM when "Enable Metrics Export" is on,
* so we also treat the "Enable Metrics Export" section as OTel content.
*/
async isMetricsEndpointVisible(): Promise<boolean> {
// Metrics endpoint input (only visible when Enable Metrics Export is on)
const metricsInputByValue = this.page.locator('input[value*="/metrics"]')
const valueVisible = await metricsInputByValue.isVisible().catch(() => false)
if (valueVisible) return true
// "Enable Metrics Export" section is always visible on OTel tab (metrics subsection)
const enableMetricsVisible = await this.page.getByText(/Enable Metrics Export/i).isVisible().catch(() => false)
if (enableMetricsVisible) return true
// Label "Metrics Endpoint" (when metrics export is enabled)
const labelVisible = await this.page.getByText(/Metrics Endpoint/i).isVisible().catch(() => false)
return labelVisible
}
/**
* Get the metrics endpoint URL value
*/
async getMetricsEndpointValue(): Promise<string | null> {
const metricsInput = this.page.locator('input[value*="/metrics"]').first()
const isVisible = await metricsInput.isVisible().catch(() => false)
if (!isVisible) return null
return await metricsInput.inputValue()
}
}

View File

@@ -0,0 +1,78 @@
import { expect, test } from '../../core/fixtures/base.fixture'
test.describe('Placeholder and Enterprise Pages', () => {
test('should load prompt-repo coming soon page', async ({ page }) => {
await page.goto('/workspace/prompt-repo')
await expect(page.getByText(/Prompt repository is coming soon/i)).toBeVisible({ timeout: 10000 })
})
test('should load alert-channels page', async ({ page }) => {
await page.goto('/workspace/alert-channels')
await page.waitForLoadState('networkidle')
await expect(page.getByText('Unlock alert channels for better observability')).toBeVisible()
const readMore = page.getByRole('button', { name: /Read more/i })
await expect(readMore).toBeVisible()
const [popup] = await Promise.all([page.waitForEvent('popup'), readMore.click()])
await expect(popup).toHaveURL(/^https:\/\/docs\.getbifrost\.ai\/enterprise\/alert-channels(\?|$)/)
await popup.close()
})
test('should load guardrails page', async ({ page }) => {
await page.goto('/workspace/guardrails')
await page.waitForLoadState('networkidle')
await expect(page).toHaveURL(/\/workspace\/guardrails(?:\?.*)?$/)
})
test('should load audit-logs page', async ({ page }) => {
await page.goto('/workspace/audit-logs')
await page.waitForLoadState('networkidle')
await expect(page).toHaveURL(/\/workspace\/audit-logs(?:\?.*)?$/)
})
test('should load cluster page', async ({ page }) => {
await page.goto('/workspace/cluster')
await page.waitForLoadState('networkidle')
await expect(page).toHaveURL(/\/workspace\/cluster(?:\?.*)?$/)
})
test('should load custom-pricing page', async ({ page }) => {
await page.goto('/workspace/custom-pricing')
await page.waitForLoadState('networkidle')
await expect(page).toHaveURL(/\/workspace\/custom-pricing(?:\?.*)?$/)
})
test('should load rbac page', async ({ page }) => {
await page.goto('/workspace/rbac')
await page.waitForLoadState('networkidle')
await expect(page).toHaveURL(/\/workspace\/governance\/rbac(?:\?.*)?$/)
})
test('should load scim page', async ({ page }) => {
await page.goto('/workspace/scim')
await page.waitForLoadState('networkidle')
await expect(page).toHaveURL(/\/workspace\/scim(?:\?.*)?$/)
})
test('should load adaptive-routing page', async ({ page }) => {
await page.goto('/workspace/adaptive-routing')
await page.waitForLoadState('networkidle')
await expect(page.getByText('Unlock adaptive routing for better performance')).toBeVisible()
const readMore = page.getByRole('button', { name: /Read more/i })
await expect(readMore).toBeVisible()
const [popup] = await Promise.all([page.waitForEvent('popup'), readMore.click()])
await expect(popup).toHaveURL(/^https:\/\/docs\.getbifrost\.ai\/enterprise\/adaptive-load-balancing(\?|$)/)
await popup.close()
})
test('should load guardrails configuration page', async ({ page }) => {
await page.goto('/workspace/guardrails/configuration')
await page.waitForLoadState('networkidle')
await expect(page).toHaveURL(/\/workspace\/guardrails\/configuration(?:\?.*)?$/)
})
test('should load guardrails providers page', async ({ page }) => {
await page.goto('/workspace/guardrails/providers')
await page.waitForLoadState('networkidle')
await expect(page).toHaveURL(/\/workspace\/guardrails\/providers(?:\?.*)?$/)
})
})

View File

@@ -0,0 +1,350 @@
import { Locator, Page, expect } from '@playwright/test'
import { BasePage } from '../../../core/pages/base.page'
import { waitForNetworkIdle } from '../../../core/utils/test-helpers'
export interface PluginConfig {
name: string
path?: string
type?: string
enabled?: boolean
config?: Record<string, any>
}
export class PluginsPage extends BasePage {
readonly sidebar: Locator
readonly table: Locator // Alias for sidebar (plugins page doesn't have a traditional table)
readonly pluginList: Locator
readonly createBtn: Locator
readonly sheet: Locator
readonly nameInput: Locator
readonly pathInput: Locator
readonly enabledToggle: Locator
readonly saveBtn: Locator
readonly cancelBtn: Locator
constructor(page: Page) {
super(page)
// Plugins page has a sidebar with plugin buttons, not a table
// The sidebar contains the "Plugins" label and plugin list
this.sidebar = page.locator('div').filter({ hasText: /^Plugins$/ }).locator('..').first()
this.table = this.sidebar // Alias for backward compatibility
this.pluginList = page.locator('button[type="button"]').filter({ has: page.locator('svg.lucide-puzzle') })
this.createBtn = page.getByRole('button').filter({
hasText: /Install New Plugin/i
}).or(
page.locator('button').filter({ has: page.locator('svg.lucide-plus') }).filter({ hasText: /Install/i })
)
this.sheet = page.locator('[role="dialog"]')
this.nameInput = page.getByLabel(/Name/i).or(page.locator('input[name="name"]'))
this.pathInput = page.getByLabel(/Path/i).or(page.locator('input[name="path"]'))
this.enabledToggle = page.locator('button[role="switch"]')
this.saveBtn = page.getByRole('button', { name: /Install Plugin/i }).or(
page.getByRole('button', { name: /Update Plugin/i })
)
this.cancelBtn = page.getByRole('button', { name: /Cancel/i }).filter({
hasNot: page.locator('svg.lucide-trash-2')
})
}
/**
* Navigate to the plugins page
*/
async goto(): Promise<void> {
await this.page.goto('/workspace/plugins')
await waitForNetworkIdle(this.page)
// Wait for create button or empty state to be visible (indicates page loaded).
// Use .first() so the locator resolves to one element when both are visible (strict mode).
await this.page.getByTestId('plugins-create-button')
.or(this.page.getByTestId('plugins-empty-state'))
.first()
.waitFor({ state: 'visible', timeout: 10000 })
// Ensure sheet is closed (in case it was left open from previous test)
await this.ensureSheetClosed()
}
/**
* Ensure the plugin sheet is closed
*/
async ensureSheetClosed(): Promise<void> {
const isVisible = await this.sheet.isVisible().catch(() => false)
if (isVisible) {
// Try clicking cancel button first
const cancelVisible = await this.cancelBtn.isVisible().catch(() => false)
if (cancelVisible) {
await this.cancelBtn.click()
// Wait for sheet to close after cancel click
await expect(this.sheet).not.toBeVisible({ timeout: 5000 }).catch(() => {})
}
// Double-check: if still visible, try Escape key
const stillVisible = await this.sheet.isVisible().catch(() => false)
if (stillVisible) {
await this.page.keyboard.press('Escape')
// Wait for sheet to close after Escape
await expect(this.sheet).not.toBeVisible({ timeout: 5000 }).catch(() => {})
}
// Final check: wait for sheet to be detached or not visible
await this.page.waitForFunction(() => {
const sheet = document.querySelector('[role="dialog"]')
return !sheet || window.getComputedStyle(sheet).display === 'none'
}, { timeout: 3000 }).catch(() => {})
}
}
/**
* Get plugin button locator by name (plugins are shown as buttons in sidebar)
*/
getPluginButton(name: string): Locator {
// Find button that contains the plugin name and has a Puzzle icon
return this.page.locator('button[type="button"]')
.filter({ hasText: name })
.filter({ has: this.page.locator('svg.lucide-puzzle') })
.first()
}
/**
* Check if a plugin exists in the sidebar
*/
async pluginExists(name: string): Promise<boolean> {
return await this.getPluginButton(name).count() > 0
}
/**
* Get the count of plugins in the sidebar
*/
async getPluginCount(): Promise<number> {
// Check if it's empty state (no plugin list, only empty state is shown)
const emptyState = this.page.getByTestId('plugins-empty-state')
const isEmptyVisible = await emptyState.isVisible().catch(() => false)
if (isEmptyVisible) {
return 0
}
const buttons = this.page.getByTestId('plugin-list-item')
const count = await buttons.count()
return count
}
/**
* Create a new plugin.
* Returns true if the plugin was created successfully, false if the backend rejected it (e.g. .so load failure).
*/
async createPlugin(config: PluginConfig): Promise<boolean> {
await this.dismissToasts()
// Ensure sheet is closed before starting
await this.ensureSheetClosed()
await this.createBtn.waitFor({ state: 'visible' })
await this.createBtn.click()
await expect(this.sheet).toBeVisible({ timeout: 5000 })
await this.waitForSheetAnimation()
// Scope inputs to the dialog sheet to avoid matching background form inputs
// (PluginsView shows existing plugin form in background with same field names)
const sheetNameInput = this.sheet.getByRole('textbox', { name: /Plugin Name/i })
const sheetPathInput = this.sheet.getByRole('textbox', { name: /Plugin Path/i })
// Fill name (required)
await sheetNameInput.waitFor({ state: 'visible' })
await sheetNameInput.fill(config.name)
// Fill path (required) - use the path from config
await sheetPathInput.waitFor({ state: 'visible' })
const pluginPath = config.path || '/tmp/bifrost-test-plugin.so'
await sheetPathInput.fill(pluginPath)
// Note: enabled state is set to true by default when creating,
// and can't be changed during creation (only during edit)
// Save
await this.saveBtn.waitFor({ state: 'visible' })
await this.saveBtn.click()
// Wait for either a success toast or an error toast (backend may fail to load .so)
const successToast = this.getToast('success')
const errorToast = this.getToast('error')
await successToast.or(errorToast).waitFor({ state: 'visible', timeout: 15000 })
const hasError = await errorToast.isVisible().catch(() => false)
if (hasError) {
// Backend rejected plugin creation (e.g. failed to load .so)
console.warn(`[Plugin] Backend error on create "${config.name}" — plugin was not created`)
await this.dismissToasts()
// Sheet may stay open on error — close it manually
await this.ensureSheetClosed()
await waitForNetworkIdle(this.page)
return false
}
await this.dismissToasts()
// Wait for sheet to close with multiple checks
await expect(this.sheet).not.toBeVisible({ timeout: 10000 })
await this.ensureSheetClosed() // Double-check it's closed
await waitForNetworkIdle(this.page)
return true
}
/**
* Edit an existing plugin
* Note: Name cannot be changed after creation (it's read-only in edit mode)
* Only enabled state, path, and config can be updated
*/
async editPlugin(name: string, updates: Partial<PluginConfig>): Promise<void> {
await this.dismissToasts()
// Ensure sheet is closed before starting
await this.ensureSheetClosed()
// Click on the plugin button to select it (this opens the edit view)
const pluginBtn = this.getPluginButton(name)
await pluginBtn.waitFor({ state: 'visible' })
await pluginBtn.click()
// Wait for the plugin details view to load
await waitForNetworkIdle(this.page)
// The form is in PluginsView - wait for it to be visible
const form = this.page.locator('form')
await form.waitFor({ state: 'visible', timeout: 5000 })
// Update enabled state if provided
if (updates.enabled !== undefined) {
const toggle = this.page.locator('button[role="switch"]').first()
await toggle.waitFor({ state: 'visible' })
const isChecked = await toggle.getAttribute('data-state') === 'checked'
if (isChecked !== updates.enabled) {
await toggle.click()
}
}
// Update path if provided
if (updates.path) {
const pathInput = this.page.getByLabel(/Path/i).or(this.page.locator('input[name="path"]'))
await pathInput.waitFor({ state: 'visible' })
await pathInput.clear()
await pathInput.fill(updates.path)
}
// Wait for Save Changes button to be enabled (form.isDirty must be true)
const saveBtn = this.page.getByRole('button', { name: /Save Changes/i })
await expect(saveBtn).toBeEnabled({ timeout: 5000 })
await saveBtn.click()
await this.waitForSuccessToast()
await this.dismissToasts()
await waitForNetworkIdle(this.page)
// Ensure sheet is closed after edit
await this.ensureSheetClosed()
}
/**
* Delete a plugin
*/
async deletePlugin(name: string): Promise<void> {
await this.dismissToasts()
// Ensure sheet is closed before starting
await this.ensureSheetClosed()
// Click on the plugin button to select it (this opens the details view)
const pluginBtn = this.getPluginButton(name)
await pluginBtn.waitFor({ state: 'visible' })
await pluginBtn.click()
// Wait for the plugin details view to load
await waitForNetworkIdle(this.page)
// Find delete button in the PluginsView (has Trash2Icon)
const deleteBtn = this.page.getByRole('button', { name: /Delete Plugin/i })
await deleteBtn.waitFor({ state: 'visible' })
await deleteBtn.click()
// Wait for the AlertDialog confirmation to appear (uses role="alertdialog")
const alertDialog = this.page.locator('[role="alertdialog"]')
await alertDialog.waitFor({ state: 'visible', timeout: 5000 })
// Click the confirm Delete button inside the AlertDialog
const confirmBtn = alertDialog.getByRole('button', { name: /^Delete$/i })
await confirmBtn.waitFor({ state: 'visible' })
await confirmBtn.click()
await this.waitForSuccessToast('deleted')
await this.dismissToasts()
await waitForNetworkIdle(this.page)
// Ensure sheet is closed after delete
await this.ensureSheetClosed()
}
/**
* Toggle plugin enabled state
*/
async togglePluginEnabled(name: string): Promise<void> {
await this.dismissToasts()
// Ensure sheet is closed before starting
await this.ensureSheetClosed()
// Click on the plugin button to select it (this opens the details view)
const pluginBtn = this.getPluginButton(name)
await pluginBtn.waitFor({ state: 'visible' })
await pluginBtn.click()
// Wait for the plugin details view to load
await waitForNetworkIdle(this.page)
// Find toggle switch in the PluginsView form
const toggle = this.page.locator('button[role="switch"]').first()
await toggle.waitFor({ state: 'visible' })
await toggle.click()
// Wait for the Save Changes button to become enabled (form.isDirty must be true)
const saveBtn = this.page.getByRole('button', { name: /Save Changes/i })
await expect(saveBtn).toBeEnabled({ timeout: 5000 })
await saveBtn.click()
await this.waitForSuccessToast()
await this.dismissToasts()
await waitForNetworkIdle(this.page)
// Ensure sheet is closed after toggle
await this.ensureSheetClosed()
}
/**
* Get plugin enabled state
*/
async getPluginEnabledState(name: string): Promise<boolean> {
// Ensure sheet is closed before starting
await this.ensureSheetClosed()
// Click on the plugin button to select it
const pluginBtn = this.getPluginButton(name)
await pluginBtn.waitFor({ state: 'visible' })
await pluginBtn.click()
// Wait for the plugin details view to load
await waitForNetworkIdle(this.page)
// Find toggle switch in the PluginsView form
const toggle = this.page.locator('button[role="switch"]').first()
let result = false
if (await toggle.count() > 0) {
const dataState = await toggle.getAttribute('data-state')
result = dataState === 'checked'
}
await this.ensureSheetClosed()
return result
}
/**
* Cancel plugin creation/edit
*/
async cancelPlugin(): Promise<void> {
if (await this.sheet.isVisible()) {
await this.cancelBtn.click()
await expect(this.sheet).not.toBeVisible({ timeout: 5000 })
await this.page.waitForTimeout(300) // Small delay for animation
}
// Double-check it's closed
await this.ensureSheetClosed()
}
}

View File

@@ -0,0 +1,20 @@
import { existsSync } from 'fs'
import { join, resolve } from 'path'
// Same location as Makefile build-test-plugin and global setup (repo root tmp/)
const REPO_ROOT = resolve(__dirname, '..', '..', '..', '..')
const TEST_PLUGIN_PATH = join(REPO_ROOT, 'tmp', 'bifrost-test-plugin.so')
/**
* Gets the test plugin path.
* The plugin is built by global setup / make build-test-plugin at repo_root/tmp/bifrost-test-plugin.so.
*/
export function ensureTestPluginExists(): string {
if (!existsSync(TEST_PLUGIN_PATH)) {
throw new Error(
`Test plugin not found at ${TEST_PLUGIN_PATH}. ` +
`Please build it first: make build-test-plugin (from repo root)`
)
}
return TEST_PLUGIN_PATH
}

View File

@@ -0,0 +1,35 @@
import { PluginConfig } from './pages/plugins.page'
import { ensureTestPluginExists } from './plugins-test-helper'
/**
* Sanitize plugin name to only contain letters, numbers, hyphens, and underscores
*/
function sanitizePluginName(name: string): string {
return name
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/[^A-Za-z0-9-_]/g, '') // Remove any invalid characters
.replace(/-+/g, '-') // Replace multiple hyphens with single hyphen
.replace(/^-|-$/g, '') // Remove leading/trailing hyphens
}
/**
* Get the test plugin path (builds if necessary)
*/
let testPluginPath: string | null = null
function getTestPluginPath(): string {
if (!testPluginPath) {
testPluginPath = ensureTestPluginExists()
}
return testPluginPath
}
export function createPluginData(overrides: Partial<PluginConfig> = {}): PluginConfig {
const baseName = overrides.name || `test-plugin-${Date.now()}`
const pluginPath = overrides.path || getTestPluginPath()
return {
name: sanitizePluginName(baseName),
path: pluginPath,
...overrides,
}
}

View File

@@ -0,0 +1,367 @@
import { expect, test } from '../../core/fixtures/base.fixture'
import { createPluginData } from './plugins.data'
import { ensureTestPluginExists } from './plugins-test-helper'
// Track created plugins for cleanup
const createdPlugins: string[] = []
test.describe('Plugins', () => {
test.beforeEach(async ({ pluginsPage }) => {
await pluginsPage.goto()
// Ensure sheet is closed before each test (in case it was left open)
await pluginsPage.ensureSheetClosed()
})
test.afterEach(async ({ pluginsPage }) => {
// Clean up any plugins created during tests
for (const pluginName of [...createdPlugins]) {
try {
const exists = await pluginsPage.pluginExists(pluginName)
if (exists) {
await pluginsPage.deletePlugin(pluginName)
}
} catch {
// Ignore cleanup errors
}
}
// Clear the array
createdPlugins.length = 0
})
test.describe('Plugin Display', () => {
test('should display plugins table', async ({ pluginsPage }) => {
// Ensure at least one plugin exists so the table (sidebar) is shown
const count = await pluginsPage.getPluginCount()
if (count === 0) {
const pluginData = createPluginData({ name: `e2e-display-table-${Date.now()}` })
const created = await pluginsPage.createPlugin(pluginData)
if (!created) {
test.skip(true, 'Backend rejected plugin creation (failed to load .so)')
return
}
createdPlugins.push(pluginData.name)
}
// Plugins page has a sidebar (aliased as table), not a traditional table
await expect(pluginsPage.table).toBeVisible()
})
test('should display create plugin button', async ({ pluginsPage }) => {
await expect(pluginsPage.createBtn).toBeVisible()
})
test('should show empty state or plugin list', async ({ pluginsPage }) => {
const count = await pluginsPage.getPluginCount()
const emptyState = pluginsPage.page.getByTestId('plugins-empty-state')
const isEmptyStateVisible = await emptyState.isVisible().catch(() => false)
if (count === 0) {
expect(isEmptyStateVisible).toBe(true)
} else {
expect(count).toBeGreaterThan(0)
expect(isEmptyStateVisible).toBe(false)
}
})
})
test.describe('CRUD Operations', () => {
test('should create a basic plugin', async ({ pluginsPage }) => {
const pluginData = createPluginData({
name: `e2e-test-plugin-${Date.now()}`,
})
createdPlugins.push(pluginData.name) // Track for cleanup
const created = await pluginsPage.createPlugin(pluginData)
if (!created) {
test.skip(true, 'Backend rejected plugin creation (failed to load .so)')
return
}
const pluginExists = await pluginsPage.pluginExists(pluginData.name)
expect(pluginExists).toBe(true)
})
test('should create a disabled plugin', async ({ pluginsPage }) => {
const pluginData = createPluginData({
name: `disabled-plugin-${Date.now()}`,
enabled: false,
})
createdPlugins.push(pluginData.name) // Track for cleanup
const created = await pluginsPage.createPlugin(pluginData)
if (!created) {
test.skip(true, 'Backend rejected plugin creation (failed to load .so)')
return
}
const pluginExists = await pluginsPage.pluginExists(pluginData.name)
expect(pluginExists).toBe(true)
// Note: Plugins are created with enabled=true by default
// We need to disable it after creation
const initialState = await pluginsPage.getPluginEnabledState(pluginData.name)
expect(initialState).toBe(true) // Created enabled by default
// Now disable it
await pluginsPage.togglePluginEnabled(pluginData.name)
const isEnabled = await pluginsPage.getPluginEnabledState(pluginData.name)
expect(isEnabled).toBe(false)
})
test('should toggle plugin enabled state', async ({ pluginsPage }) => {
const originalName = `edit-test-plugin-${Date.now()}`
const pluginData = createPluginData({ name: originalName })
createdPlugins.push(originalName) // Track for cleanup
const created = await pluginsPage.createPlugin(pluginData)
if (!created) {
test.skip(true, 'Backend rejected plugin creation (failed to load .so)')
return
}
// Verify plugin exists; we verify editability via enabled-state toggling (name is read-only after creation)
const pluginExists = await pluginsPage.pluginExists(originalName)
expect(pluginExists).toBe(true)
// Toggle enabled state to verify the plugin is editable
const initialState = await pluginsPage.getPluginEnabledState(originalName)
await pluginsPage.togglePluginEnabled(originalName)
const newState = await pluginsPage.getPluginEnabledState(originalName)
expect(newState).not.toBe(initialState)
})
test('should delete plugin', async ({ pluginsPage }) => {
const pluginData = createPluginData({
name: `delete-test-plugin-${Date.now()}`,
})
createdPlugins.push(pluginData.name) // Track for cleanup (in case test fails before delete)
const created = await pluginsPage.createPlugin(pluginData)
if (!created) {
test.skip(true, 'Backend rejected plugin creation (failed to load .so)')
return
}
// Verify it exists
let pluginExists = await pluginsPage.pluginExists(pluginData.name)
expect(pluginExists).toBe(true)
// Delete it
await pluginsPage.deletePlugin(pluginData.name)
// Verify it's gone
pluginExists = await pluginsPage.pluginExists(pluginData.name)
expect(pluginExists).toBe(false)
})
test('should change plugin enabled state when toggled', async ({ pluginsPage }) => {
const pluginData = createPluginData({
name: `toggle-test-plugin-${Date.now()}`,
enabled: true,
})
createdPlugins.push(pluginData.name) // Track for cleanup
const created = await pluginsPage.createPlugin(pluginData)
if (!created) {
test.skip(true, 'Backend rejected plugin creation (failed to load .so)')
return
}
// Get initial state
const initialState = await pluginsPage.getPluginEnabledState(pluginData.name)
// Toggle state
await pluginsPage.togglePluginEnabled(pluginData.name)
// Verify state changed
const newState = await pluginsPage.getPluginEnabledState(pluginData.name)
expect(newState).not.toBe(initialState)
})
})
test.describe('Form Validation', () => {
test('should require name for plugin', async ({ pluginsPage }) => {
await pluginsPage.dismissToasts()
await pluginsPage.createBtn.click()
await expect(pluginsPage.sheet).toBeVisible()
await pluginsPage.waitForSheetAnimation()
// Save button should be disabled when name is empty
await expect(pluginsPage.saveBtn).toBeDisabled()
await pluginsPage.cancelPlugin()
})
test('should cancel plugin creation', async ({ pluginsPage }) => {
await pluginsPage.createBtn.click()
await expect(pluginsPage.sheet).toBeVisible()
// Fill some data (scope to sheet to avoid matching background form)
const testName = `cancelled-plugin-${Date.now()}`
const sheetNameInput = pluginsPage.sheet.getByRole('textbox', { name: /Plugin Name/i })
await sheetNameInput.fill(testName)
// Cancel
await pluginsPage.cancelPlugin()
// Sheet should close
await expect(pluginsPage.sheet).not.toBeVisible()
// Plugin should not exist
const pluginExists = await pluginsPage.pluginExists(testName)
expect(pluginExists).toBe(false)
})
test('should open and close plugin sheet', async ({ pluginsPage }) => {
// Open sheet
await pluginsPage.createBtn.click()
await expect(pluginsPage.sheet).toBeVisible()
// Close sheet
await pluginsPage.cancelPlugin()
await expect(pluginsPage.sheet).not.toBeVisible()
})
})
test.describe('Error Handling', () => {
test('should handle duplicate plugin name gracefully', async ({ pluginsPage }) => {
const pluginName = `duplicate-test-${Date.now()}`
const pluginData = createPluginData({ name: pluginName })
createdPlugins.push(pluginName) // Track for cleanup
// Create first plugin
const created = await pluginsPage.createPlugin(pluginData)
if (!created) {
test.skip(true, 'Backend rejected plugin creation (failed to load .so)')
return
}
expect(await pluginsPage.pluginExists(pluginName)).toBe(true)
// Try to create duplicate
await pluginsPage.dismissToasts()
await pluginsPage.createBtn.click()
await expect(pluginsPage.sheet).toBeVisible()
await pluginsPage.waitForSheetAnimation()
// Scope to sheet to avoid matching background form
const sheetNameInput = pluginsPage.sheet.getByRole('textbox', { name: /Plugin Name/i })
const sheetPathInput = pluginsPage.sheet.getByRole('textbox', { name: /Plugin Path/i })
await sheetNameInput.fill(pluginName)
await sheetPathInput.fill(ensureTestPluginExists()) // Path is required; use same path as build
await pluginsPage.saveBtn.click()
// Duplicate creation should be rejected: either sheet stays open with error OR error toast appears
const inlineError = pluginsPage.sheet.locator('[role="alert"], .text-destructive').first()
const errorToast = pluginsPage.page.locator('[data-sonner-toast][data-type="error"]').first()
await inlineError.or(errorToast).waitFor({ state: 'visible', timeout: 10000 })
const hasInlineError = await inlineError.isVisible().catch(() => false)
if (hasInlineError) {
await expect(pluginsPage.sheet).toBeVisible()
await expect(inlineError).toBeVisible()
await pluginsPage.cancelPlugin()
} else {
await expect(errorToast).toBeVisible()
}
})
})
test.describe('Plugin Configuration', () => {
test('should view plugin details', async ({ pluginsPage }) => {
const pluginData = createPluginData({
name: `view-details-${Date.now()}`,
})
createdPlugins.push(pluginData.name)
const created = await pluginsPage.createPlugin(pluginData)
if (!created) {
test.skip(true, 'Backend rejected plugin creation (failed to load .so)')
return
}
// Click on the plugin to view details
const pluginItem = pluginsPage.page.locator('button').filter({ hasText: pluginData.name })
await pluginItem.click()
// Should see plugin details/form
const detailsVisible = await pluginsPage.page.locator('form, [role="tabpanel"]').isVisible().catch(() => false)
expect(detailsVisible).toBe(true)
})
test('should edit plugin configuration', async ({ pluginsPage }) => {
const pluginData = createPluginData({
name: `edit-config-${Date.now()}`,
})
createdPlugins.push(pluginData.name)
const created = await pluginsPage.createPlugin(pluginData)
if (!created) {
test.skip(true, 'Backend rejected plugin creation (failed to load .so)')
return
}
// Get initial enabled state
const initialState = await pluginsPage.getPluginEnabledState(pluginData.name)
// Toggle enabled state (a form of editing)
await pluginsPage.togglePluginEnabled(pluginData.name)
// State should have changed
const newState = await pluginsPage.getPluginEnabledState(pluginData.name)
expect(newState).toBe(!initialState)
})
test('should validate plugin path', async ({ pluginsPage }) => {
await pluginsPage.createBtn.click()
await expect(pluginsPage.sheet).toBeVisible()
await pluginsPage.waitForSheetAnimation()
// Fill name
const sheetNameInput = pluginsPage.sheet.getByRole('textbox', { name: /Plugin Name/i })
await sheetNameInput.fill(`path-validation-${Date.now()}`)
// Fill invalid path (doesn't start with / or http)
const sheetPathInput = pluginsPage.sheet.getByRole('textbox', { name: /Plugin Path/i })
await sheetPathInput.fill('invalid-path-no-extension')
// Validation should disable the save button
await expect(pluginsPage.saveBtn).toBeDisabled()
// Validation message should be visible
const validationMessage = pluginsPage.sheet.getByText(/valid absolute file path|valid path/i)
await expect(validationMessage).toBeVisible()
await pluginsPage.cancelPlugin()
})
test('should handle invalid plugin path', async ({ pluginsPage }) => {
await pluginsPage.createBtn.click()
await expect(pluginsPage.sheet).toBeVisible()
await pluginsPage.waitForSheetAnimation()
const sheetNameInput = pluginsPage.sheet.getByRole('textbox', { name: /Plugin Name/i })
const sheetPathInput = pluginsPage.sheet.getByRole('textbox', { name: /Plugin Path/i })
await sheetNameInput.fill(`invalid-plugin-${Date.now()}`)
await sheetPathInput.fill('/nonexistent/path/to/plugin.so')
// Try to save - this might succeed (path not validated until load) or fail
await pluginsPage.saveBtn.click()
// Wait for response
await pluginsPage.page.waitForTimeout(1000)
// Invalid path: sheet may close with error toast or stay open with error
const inlineError = pluginsPage.sheet.locator('[role="alert"], .text-destructive').first()
const errorToast = pluginsPage.page.locator('[data-sonner-toast][data-type="error"]').first()
await inlineError.or(errorToast).waitFor({ state: 'visible', timeout: 10000 })
const hasInlineError = await inlineError.isVisible().catch(() => false)
if (hasInlineError) {
await expect(pluginsPage.sheet).toBeVisible()
await expect(inlineError).toBeVisible()
await pluginsPage.cancelPlugin()
} else {
await expect(errorToast).toBeVisible()
}
})
})
})

View File

@@ -0,0 +1,666 @@
import { Locator, Page, expect } from '@playwright/test'
import { CustomProviderConfig, ProviderKeyConfig } from '../../../core/fixtures/test-data.fixture'
import { BasePage } from '../../../core/pages/base.page'
import { Selectors } from '../../../core/utils/selectors'
import { fillSelect, waitForNetworkIdle } from '../../../core/utils/test-helpers'
export type { CustomProviderConfig, ProviderKeyConfig }
/**
* Page object for the Providers page
*/
export class ProvidersPage extends BasePage {
// Locators
readonly providerList: Locator
readonly addProviderBtn: Locator
/** Add New Provider dropdown > "Custom provider..." menu item */
readonly addProviderOptionCustom: Locator
readonly addKeyBtn: Locator
readonly keysTable: Locator
// Custom provider sheet
readonly customProviderSheet: Locator
readonly customProviderNameInput: Locator
readonly baseProviderSelect: Locator
readonly baseUrlInput: Locator
readonly customProviderSaveBtn: Locator
readonly customProviderCancelBtn: Locator
// Keys table empty state (when provider has no keys)
readonly keysTableEmptyState: Locator
// Key form
readonly keyForm: Locator
readonly keySaveBtn: Locator
readonly keyCancelBtn: Locator
constructor(page: Page) {
super(page)
// Provider list
this.providerList = page.locator(Selectors.providers.providerList)
this.addProviderBtn = page.getByTestId('add-provider-btn')
this.addProviderOptionCustom = page.getByTestId('add-provider-option-custom')
// Keys table
this.addKeyBtn = page.getByTestId('add-key-btn')
this.keysTable = page.getByTestId('keys-table')
// Custom provider sheet
this.customProviderSheet = page.getByTestId('custom-provider-sheet')
this.customProviderNameInput = page.getByTestId('custom-provider-name')
this.baseProviderSelect = page.getByTestId('base-provider-select')
this.baseUrlInput = page.getByTestId('base-url-input')
this.customProviderSaveBtn = page.getByTestId('custom-provider-save-btn')
this.customProviderCancelBtn = page.getByTestId('custom-provider-cancel-btn')
// Keys table empty state
this.keysTableEmptyState = page.getByTestId('keys-table-empty-state')
// Key form
this.keyForm = page.getByTestId('key-form')
this.keySaveBtn = page.getByTestId('key-save-btn')
this.keyCancelBtn = page.getByTestId('key-cancel-btn')
}
/**
* Navigate to the providers page
*/
async goto(): Promise<void> {
await this.page.goto('/workspace/providers')
await waitForNetworkIdle(this.page)
}
/**
* Select a provider from the sidebar list
*/
async selectProvider(name: string): Promise<void> {
const providerItem = this.page.getByTestId(`provider-item-${name.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`)
await providerItem.click()
await waitForNetworkIdle(this.page)
}
/**
* Get provider item locator
*/
getProviderItem(name: string): Locator {
return this.page.getByTestId(`provider-item-${name.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`)
}
/**
* Check if a provider exists in the list
*/
async providerExists(name: string): Promise<boolean> {
const providerItem = this.getProviderItem(name)
return await providerItem.isVisible()
}
/**
* Add a new key to the currently selected provider
*/
async addKey(config: ProviderKeyConfig): Promise<void> {
await this.dismissToasts()
// Click add key button
await this.addKeyBtn.click()
// Wait for key form to appear
await expect(this.keyForm).toBeVisible()
// Fill in key details
await this.page.getByLabel('Name').fill(config.name)
await this.page.getByLabel('API Key').fill(config.value)
// Fill weight if provided
if (config.weight !== undefined) {
const weightInput = this.page.getByLabel('Weight')
if (await weightInput.isVisible()) {
await weightInput.fill(String(config.weight))
}
}
// Note: Model selection is skipped for now as it requires specific UI interaction
// that may vary based on the provider type
// Save the key
await this.keySaveBtn.click()
// Wait for success toast
await this.waitForSuccessToast()
// Wait for form to close and table to refresh
await expect(this.keyForm).not.toBeVisible({ timeout: 5000 })
await waitForNetworkIdle(this.page)
}
/**
* Add a known provider from the "Add provider" dropdown (e.g. Nebius, OpenAI).
* Opens the dropdown and clicks the option with data-testid add-provider-option-{name}.
*/
async addKnownProviderFromDropdown(providerName: string): Promise<void> {
await this.addProviderBtn.click()
const option = this.page.getByTestId(`add-provider-option-${providerName}`)
await option.waitFor({ state: 'visible', timeout: 5000 })
await option.click()
await waitForNetworkIdle(this.page)
}
/**
* Open the custom provider sheet via Add New Provider > Custom provider...
*/
async openCustomProviderSheet(): Promise<void> {
await this.addProviderBtn.click()
await this.addProviderOptionCustom.waitFor({ state: 'visible', timeout: 5000 })
await this.addProviderOptionCustom.click()
await expect(this.customProviderSheet).toBeVisible({ timeout: 5000 })
}
/**
* Create a custom provider
*/
async createProvider(config: CustomProviderConfig): Promise<void> {
await this.openCustomProviderSheet()
// Fill in provider name
await this.customProviderNameInput.fill(config.name)
// Select base provider type
await fillSelect(
this.page,
'[data-testid="base-provider-select"]',
this.getBaseProviderLabel(config.baseProviderType)
)
// Fill in base URL
if (config.baseUrl) {
await this.baseUrlInput.fill(config.baseUrl)
}
// Save the provider
await this.customProviderSaveBtn.click()
// Wait for sheet to close (indicates success)
await expect(this.customProviderSheet).not.toBeVisible({ timeout: 10000 })
// Wait for network to settle
await waitForNetworkIdle(this.page)
}
/**
* Delete a custom provider.
* @param options.skipToastWait - If true, do not wait for success toast (e.g. for cleanup); avoids cleanup failures when toast is missing or already gone.
*/
async deleteProvider(name: string, options?: { skipToastWait?: boolean }): Promise<void> {
// First select the provider (config panel shows with delete button)
await this.selectProvider(name)
// Click the delete button in the config panel
const deleteBtn = this.page.getByTestId('provider-delete-btn')
await deleteBtn.waitFor({ state: 'visible', timeout: 5000 })
await deleteBtn.click()
// Confirm deletion in dialog
await this.page.getByRole('button', { name: 'Delete' }).click()
if (options?.skipToastWait) {
// Wait for dialog to close; do not require toast so cleanup does not fail
await this.page.locator('[role="alertdialog"]').waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {})
await waitForNetworkIdle(this.page)
return
}
// Wait for success toast
await this.waitForSuccessToast('deleted')
}
/**
* Get key row locator
*/
getKeyRow(name: string): Locator {
// Try data-testid first, fall back to finding row by text content
return this.page.getByTestId(`key-row-${name}`).or(
this.page.locator('tr, [role="row"]').filter({ hasText: name })
)
}
/**
* Get the displayed weight for a key
*/
async getKeyWeight(name: string): Promise<string> {
const keyRow = this.getKeyRow(name)
return (await keyRow.getByTestId('key-weight-value').textContent()) ?? ''
}
/**
* Get the enabled state of a key (switch checked or not)
*/
async getKeyEnabledState(name: string): Promise<boolean> {
const keyRow = this.getKeyRow(name)
const switchEl = keyRow.getByTestId('key-enabled-switch')
const checked = await switchEl.getAttribute('data-state')
return checked === 'checked'
}
/**
* Check if a key exists in the table (waits for it to appear)
*/
async keyExists(name: string, timeout: number = 5000): Promise<boolean> {
// Wait for network to settle first
await waitForNetworkIdle(this.page)
// Try to find the key with waiting
const keyRow = this.getKeyRow(name)
try {
await keyRow.waitFor({ state: 'visible', timeout })
return true
} catch {
return false
}
}
/**
* Edit an existing key
*/
async editKey(keyName: string, updates: Partial<ProviderKeyConfig>): Promise<void> {
// Find the key row and open the dropdown menu
const keyRow = this.getKeyRow(keyName)
await keyRow.scrollIntoViewIfNeeded()
// The dropdown trigger - look for ellipsis/more button
const menuBtn = keyRow.locator('button').filter({ has: this.page.locator('svg') }).last()
await menuBtn.waitFor({ state: 'visible', timeout: 5000 })
await menuBtn.click()
// Wait for dropdown to appear and click Edit
await this.page.getByRole('menuitem', { name: /Edit/i }).click()
// Wait for form
await expect(this.keyForm).toBeVisible()
// Update fields
if (updates.name) {
await this.page.getByLabel('Name').clear()
await this.page.getByLabel('Name').fill(updates.name)
}
if (updates.value) {
await this.page.getByLabel('API Key').clear()
await this.page.getByLabel('API Key').fill(updates.value)
}
if (updates.weight !== undefined) {
const weightInput = this.page.getByLabel('Weight')
if (await weightInput.isVisible()) {
await weightInput.clear()
await weightInput.fill(String(updates.weight))
}
}
// Save
await this.keySaveBtn.click()
await this.waitForSuccessToast()
}
/**
* Delete a key
*/
async deleteKey(keyName: string): Promise<void> {
await this.dismissToasts()
// Find the key row
const keyRow = this.getKeyRow(keyName)
await keyRow.scrollIntoViewIfNeeded()
// The dropdown trigger - look for ellipsis/more button (last button with svg icon)
const menuBtn = keyRow.locator('button').filter({ has: this.page.locator('svg') }).last()
await menuBtn.waitFor({ state: 'visible', timeout: 5000 })
await menuBtn.click()
// Click Delete in the dropdown
await this.page.getByRole('menuitem', { name: /Delete/i }).click()
// Confirm deletion in the alert dialog
const confirmBtn = this.page.locator('[role="alertdialog"]').getByRole('button', { name: /Delete/i })
await confirmBtn.waitFor({ state: 'visible', timeout: 5000 })
await confirmBtn.click()
// Wait for success toast
await this.waitForSuccessToast('deleted')
}
/**
* Toggle key enabled/disabled
*/
async toggleKeyEnabled(keyName: string): Promise<void> {
const keyRow = this.getKeyRow(keyName)
const switchEl = keyRow.getByTestId('key-enabled-switch')
await switchEl.click()
await this.waitForSuccessToast()
}
/**
* Get the count of keys in the table
*/
async getKeyCount(): Promise<number> {
const rows = this.keysTable.locator('tbody tr')
const count = await rows.count()
if (count === 0) {
return 0
}
// Check if it's the "No keys found" row
const firstRowText = await rows.first().textContent()
if (firstRowText?.includes('No keys found')) {
return 0
}
return count
}
/**
* Helper to get base provider label for select
*/
private getBaseProviderLabel(type: string): string {
const labels: Record<string, string> = {
openai: 'OpenAI',
anthropic: 'Anthropic',
gemini: 'Gemini',
cohere: 'Cohere',
bedrock: 'AWS Bedrock',
}
return labels[type] || type
}
// ============================================
// Provider Configuration Methods
// ============================================
/**
* Open the provider configuration sheet
*/
async openConfigSheet(): Promise<void> {
// If the config sheet is already open, just return
const dialog = this.page.locator('[role="dialog"]')
if (await dialog.isVisible().catch(() => false)) {
return
}
const editConfigBtn = this.page.getByRole('button', { name: /Edit Provider Config/i })
await editConfigBtn.waitFor({ state: 'visible', timeout: 10000 })
await editConfigBtn.click()
// Wait for the sheet to appear (SheetContent renders with role="dialog")
await dialog.waitFor({ state: 'visible' })
await this.waitForSheetAnimation()
}
/**
* Select a configuration tab
*/
async selectConfigTab(tabName: 'network' | 'proxy' | 'performance' | 'governance' | 'debugging'): Promise<void> {
await this.openConfigSheet()
const tab = this.page.getByTestId(`provider-tab-${tabName}`)
await tab.click()
await this.page.waitForTimeout(300)
}
/**
* Get the save button for the current config tab
*/
getConfigSaveBtn(configType: 'network' | 'proxy' | 'performance' | 'governance' | 'debugging'): Locator {
const buttonNames: Record<string, string> = {
network: 'Save Network Configuration',
proxy: 'Save Proxy Configuration',
performance: 'Save Performance Configuration',
governance: 'Save Governance Configuration',
debugging: 'Save Debugging Configuration',
}
return this.page.getByRole('button', { name: buttonNames[configType] })
}
// ============================================
// Performance Configuration
// ============================================
/**
* Get concurrency input
*/
getConcurrencyInput(): Locator {
return this.page.getByLabel('Concurrency')
}
/**
* Get buffer size input
*/
getBufferSizeInput(): Locator {
return this.page.getByLabel('Buffer Size')
}
/**
* Get raw request switch (Debugging tab: "Send Back Raw Request")
*/
getRawRequestSwitch(): Locator {
return this.page.getByLabel('Send Back Raw Request').locator('..').locator('button[role="switch"]')
}
/**
* Get raw response switch (Debugging tab: "Send Back Raw Response")
*/
getRawResponseSwitch(): Locator {
return this.page.getByLabel('Send Back Raw Response').locator('..').locator('button[role="switch"]')
}
/**
* Fill a React controlled number input by using the native value setter
* and dispatching an input event. This bypasses React's value tracker
* to reliably update controlled input components.
*/
async fillNumberInput(input: Locator, value: string): Promise<void> {
await input.click()
await input.press('ControlOrMeta+a')
await input.pressSequentially(value)
await input.blur()
}
/**
* Save performance configuration and wait for success toast
*/
async savePerformanceConfig(): Promise<void> {
const saveBtn = this.getConfigSaveBtn('performance')
await saveBtn.click()
await this.waitForSuccessToast()
}
/**
* Save network configuration and wait for success toast
*/
async saveNetworkConfig(): Promise<void> {
const saveBtn = this.getConfigSaveBtn('network')
await saveBtn.click()
await this.waitForSuccessToast()
}
/**
* Save debugging configuration and wait for success toast
*/
async saveDebuggingConfig(): Promise<void> {
const saveBtn = this.getConfigSaveBtn('debugging')
await saveBtn.click()
await this.waitForSuccessToast()
}
/**
* Set performance configuration (concurrency, buffer size only).
* For raw request/response toggles use setDebuggingConfig.
*/
async setPerformanceConfig(config: {
concurrency?: number
bufferSize?: number
}): Promise<void> {
await this.selectConfigTab('performance')
if (config.concurrency !== undefined) {
const input = this.getConcurrencyInput()
await this.fillNumberInput(input, String(config.concurrency))
}
if (config.bufferSize !== undefined) {
const input = this.getBufferSizeInput()
await this.fillNumberInput(input, String(config.bufferSize))
}
}
/**
* Set debugging configuration (raw request/response toggles).
*/
async setDebuggingConfig(config: { rawRequest?: boolean; rawResponse?: boolean }): Promise<void> {
await this.selectConfigTab('debugging')
if (config.rawRequest !== undefined) {
const switchEl = this.getRawRequestSwitch()
const isChecked = (await switchEl.getAttribute('data-state')) === 'checked'
if (isChecked !== config.rawRequest) {
await switchEl.click()
}
}
if (config.rawResponse !== undefined) {
const switchEl = this.getRawResponseSwitch()
const isChecked = (await switchEl.getAttribute('data-state')) === 'checked'
if (isChecked !== config.rawResponse) {
await switchEl.click()
}
}
}
// ============================================
// Proxy Configuration
// ============================================
/**
* Get proxy type select
*/
getProxyTypeSelect(): Locator {
return this.page.getByLabel('Proxy Type').locator('..').locator('button[role="combobox"]')
}
/**
* Set proxy configuration
*/
async setProxyConfig(config: {
type: 'http' | 'socks5' | 'environment' | 'none'
url?: string
username?: string
password?: string
}): Promise<void> {
await this.selectConfigTab('proxy')
// Select proxy type
const proxySelect = this.getProxyTypeSelect()
await proxySelect.click()
await this.page.getByRole('option', { name: new RegExp(config.type, 'i') }).click()
// Fill additional fields if not 'none' or 'environment'
if (config.type === 'http' || config.type === 'socks5') {
if (config.url) {
await this.page.getByLabel('Proxy URL').fill(config.url)
}
if (config.username) {
await this.page.getByLabel('Username').fill(config.username)
}
if (config.password) {
await this.page.getByLabel('Password').fill(config.password)
}
}
}
// ============================================
// Network Configuration
// ============================================
/**
* Set network configuration
*/
async setNetworkConfig(config: {
baseUrl?: string
timeout?: number
maxRetries?: number
initialBackoff?: number
maxBackoff?: number
}): Promise<void> {
await this.selectConfigTab('network')
if (config.baseUrl !== undefined) {
const input = this.page.getByLabel(/Base URL/i)
await input.clear()
await input.fill(config.baseUrl)
}
if (config.timeout !== undefined) {
const input = this.page.getByLabel(/Timeout/i)
await input.clear()
await input.fill(String(config.timeout))
}
if (config.maxRetries !== undefined) {
const input = this.page.getByLabel(/Max Retries/i)
await input.clear()
await input.fill(String(config.maxRetries))
}
if (config.initialBackoff !== undefined) {
const input = this.page.getByLabel(/Initial Backoff/i)
await input.clear()
await input.fill(String(config.initialBackoff))
}
if (config.maxBackoff !== undefined) {
const input = this.page.getByLabel(/Max Backoff/i)
await input.clear()
await input.fill(String(config.maxBackoff))
}
}
// ============================================
// Governance Configuration (Budget/Rate Limits)
// ============================================
/**
* Set governance configuration (budget and rate limits)
*/
async setGovernanceConfig(config: {
budgetLimit?: number
tokenLimit?: number
requestLimit?: number
}): Promise<void> {
await this.selectConfigTab('governance')
if (config.budgetLimit !== undefined) {
const input = this.page.locator('#providerBudgetMaxLimit')
await input.clear()
await input.fill(String(config.budgetLimit))
}
if (config.tokenLimit !== undefined) {
const input = this.page.locator('#providerTokenMaxLimit')
await input.clear()
await input.fill(String(config.tokenLimit))
}
if (config.requestLimit !== undefined) {
const input = this.page.locator('#providerRequestMaxLimit')
await input.clear()
await input.fill(String(config.requestLimit))
}
}
/**
* Check if governance tab is visible (depends on permissions)
*/
async isGovernanceTabVisible(): Promise<boolean> {
await this.openConfigSheet()
const tab = this.page.getByTestId('provider-tab-governance')
return await tab.isVisible().catch(() => false)
}
}

View File

@@ -0,0 +1,66 @@
import { ProviderKeyConfig, CustomProviderConfig } from '../../core/fixtures/test-data.fixture'
/**
* Factory function to create provider key test data
*/
export function createProviderKeyData(overrides: Partial<ProviderKeyConfig> = {}): ProviderKeyConfig {
const timestamp = Date.now()
return {
name: `Test Key ${timestamp}`,
value: `sk-test-${timestamp}-${Math.random().toString(36).substring(7)}`,
models: ['*'],
weight: 1.0,
...overrides,
}
}
/**
* Factory function to create custom provider test data
*/
export function createCustomProviderData(overrides: Partial<CustomProviderConfig> = {}): CustomProviderConfig {
const timestamp = Date.now()
return {
name: `test-provider-${timestamp}`,
baseProviderType: 'openai',
baseUrl: 'https://api.example.com',
isKeyless: false,
...overrides,
}
}
/**
* Known provider names for testing
*/
export const KNOWN_PROVIDERS = [
'openai',
'anthropic',
'gemini',
'cohere',
'bedrock',
'azure',
'vertex',
'groq',
'mistral',
'deepseek',
'cerebras',
'nebius',
'sambanova',
] as const
/**
* Sample API keys for testing (fake values)
*/
export const SAMPLE_API_KEYS = {
openai: 'sk-test-openai-key-12345678901234567890',
anthropic: 'sk-ant-test-key-12345678901234567890',
gemini: 'test-gemini-api-key-1234567890',
}
/**
* Sample models for each provider
*/
export const SAMPLE_MODELS = {
openai: ['gpt-4', 'gpt-4-turbo', 'gpt-3.5-turbo'],
anthropic: ['claude-3-opus', 'claude-3-sonnet', 'claude-3-haiku'],
gemini: ['gemini-pro', 'gemini-pro-vision'],
}

View File

@@ -0,0 +1,785 @@
import { expect, test } from '../../core/fixtures/base.fixture';
import { createCustomProviderData, createProviderKeyData } from './providers.data';
// Track created resources for cleanup
const createdKeys: { provider: string; keyName: string }[] = []
const createdProviders: string[] = []
test.describe('Providers', () => {
test.describe.configure({ mode: 'serial' })
test.beforeEach(async ({ providersPage }) => {
await providersPage.goto()
})
test.afterEach(async ({ providersPage }) => {
// Clean up any keys created during tests
for (const { provider, keyName } of [...createdKeys]) {
try {
await providersPage.selectProvider(provider)
const exists = await providersPage.keyExists(keyName, 2000)
if (exists) {
await providersPage.deleteKey(keyName)
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
console.error(`[CLEANUP ERROR] Failed to delete provider key ${provider}/${keyName}: ${errorMsg}`)
}
}
createdKeys.length = 0
// Clean up any custom providers created during tests (skip toast wait so cleanup does not fail if toast is missing)
for (const providerName of [...createdProviders]) {
try {
await providersPage.deleteProvider(providerName, { skipToastWait: true })
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
console.error(`[CLEANUP ERROR] Failed to delete provider ${providerName}: ${errorMsg}`)
}
}
createdProviders.length = 0
})
test.describe('Provider Navigation', () => {
test('should display standard providers in sidebar', async ({ providersPage }) => {
// Check that OpenAI provider is visible
const openaiProvider = providersPage.getProviderItem('openai')
await expect(openaiProvider).toBeVisible()
// Check that Anthropic provider is visible
const anthropicProvider = providersPage.getProviderItem('anthropic')
await expect(anthropicProvider).toBeVisible()
})
test('should select a provider from the sidebar', async ({ providersPage }) => {
await providersPage.selectProvider('openai')
// Verify URL contains provider param
await expect(providersPage.page).toHaveURL(/provider=openai/)
})
test('should switch between providers', async ({ providersPage }) => {
// Select OpenAI first
await providersPage.selectProvider('openai')
await expect(providersPage.page).toHaveURL(/provider=openai/)
// Switch to Anthropic
await providersPage.selectProvider('anthropic')
await expect(providersPage.page).toHaveURL(/provider=anthropic/)
})
})
test.describe('Provider Keys', () => {
test('should add a new key to OpenAI provider', async ({ providersPage }) => {
// Select OpenAI provider
await providersPage.selectProvider('openai')
// Create test key data with unique name (no spaces for easier locating)
const keyData = createProviderKeyData({
name: `E2E-Test-Key-${Date.now()}`,
value: 'sk-test-e2e-key-12345',
weight: 1.0,
})
// Track for cleanup
createdKeys.push({ provider: 'openai', keyName: keyData.name })
// Add the key
await providersPage.addKey(keyData)
// Verify key appears in table (with waiting)
const keyExists = await providersPage.keyExists(keyData.name)
expect(keyExists).toBe(true)
})
test('should add a key with custom weight', async ({ providersPage }) => {
await providersPage.selectProvider('openai')
const keyData = createProviderKeyData({
name: `Weight-Key-${Date.now()}`,
value: 'sk-test-weight-key-12345',
weight: 0.5,
})
// Track for cleanup
createdKeys.push({ provider: 'openai', keyName: keyData.name })
await providersPage.addKey(keyData)
const keyExists = await providersPage.keyExists(keyData.name)
expect(keyExists).toBe(true)
})
test('should display empty state when no keys configured', async ({ providersPage }) => {
// Add Nebius from the dropdown if not already in sidebar (created with no keys)
if (!(await providersPage.providerExists('nebius'))) {
await providersPage.addKnownProviderFromDropdown('nebius')
createdProviders.push('nebius')
}
// Select Nebius (it has zero keys)
const providerItem = providersPage.getProviderItem('nebius')
await expect(providerItem).toBeVisible({ timeout: 15000 })
await providersPage.selectProvider('nebius')
const keyCount = await providersPage.getKeyCount()
expect(keyCount).toBe(0)
// Empty state row should be visible
await expect(providersPage.keysTableEmptyState).toBeVisible()
})
})
test.describe('Custom Providers', () => {
test('should open custom provider creation sheet', async ({ providersPage }) => {
await providersPage.openCustomProviderSheet()
// Verify form fields are present
await expect(providersPage.customProviderNameInput).toBeVisible()
await expect(providersPage.baseProviderSelect).toBeVisible()
await expect(providersPage.baseUrlInput).toBeVisible()
})
test('should create a custom OpenAI-compatible provider', async ({ providersPage }) => {
const providerData = createCustomProviderData({
name: `test-openai-${Date.now()}`,
baseProviderType: 'openai',
baseUrl: 'https://api.test-provider.com/v1',
})
// Track for cleanup
createdProviders.push(providerData.name)
await providersPage.createProvider(providerData)
// Wait for provider to appear in sidebar
const providerItem = providersPage.getProviderItem(providerData.name)
await expect(providerItem).toBeVisible({ timeout: 15000 })
})
test('should create a custom Anthropic-compatible provider', async ({ providersPage }) => {
const providerData = createCustomProviderData({
name: `test-anthropic-${Date.now()}`,
baseProviderType: 'anthropic',
baseUrl: 'https://api.anthropic-proxy.com',
})
// Track for cleanup
createdProviders.push(providerData.name)
await providersPage.createProvider(providerData)
// Wait for provider to appear in sidebar
const providerItem = providersPage.getProviderItem(providerData.name)
await expect(providerItem).toBeVisible({ timeout: 15000 })
})
test('should cancel custom provider creation', async ({ providersPage }) => {
await providersPage.openCustomProviderSheet()
// Fill some data
await providersPage.customProviderNameInput.fill('cancelled-provider')
// Cancel
await providersPage.customProviderCancelBtn.click()
// Sheet should close
await expect(providersPage.customProviderSheet).not.toBeVisible()
// Provider should not exist
const providerExists = await providersPage.providerExists('cancelled-provider')
expect(providerExists).toBe(false)
})
test('should delete custom provider and update UI', async ({ providersPage }) => {
const providerData = createCustomProviderData({
name: `delete-test-${Date.now()}`,
baseProviderType: 'openai',
baseUrl: 'https://api.delete-test.com/v1',
})
createdProviders.push(providerData.name)
await providersPage.createProvider(providerData)
const providerItem = providersPage.getProviderItem(providerData.name)
await expect(providerItem).toBeVisible({ timeout: 15000 })
await providersPage.deleteProvider(providerData.name, { skipToastWait: true })
const idx = createdProviders.indexOf(providerData.name)
if (idx >= 0) createdProviders.splice(idx, 1)
// Assert provider is no longer in the configured providers list (do not rely on toast)
await expect(providerItem).not.toBeVisible({ timeout: 5000 })
})
})
test.describe('Form Validation', () => {
test('should require name for custom provider', async ({ providersPage }) => {
await providersPage.openCustomProviderSheet()
// Try to save without name
await providersPage.baseUrlInput.fill('https://api.example.com')
// The save button should be disabled or show error
const saveBtn = providersPage.customProviderSaveBtn
await saveBtn.click()
// Form should still be visible (not submitted)
await expect(providersPage.customProviderSheet).toBeVisible()
})
test('should require base URL for custom provider', async ({ providersPage }) => {
await providersPage.openCustomProviderSheet()
// Fill only name
await providersPage.customProviderNameInput.fill('test-provider')
// Try to save
await providersPage.customProviderSaveBtn.click()
// Form should still be visible
await expect(providersPage.customProviderSheet).toBeVisible()
})
})
})
test.describe('Provider Key Management', () => {
test.describe.configure({ mode: 'serial' })
// Track keys for cleanup in this test suite
const managementKeys: string[] = []
test.beforeEach(async ({ providersPage }) => {
await providersPage.goto()
await providersPage.selectProvider('openai')
})
test.afterEach(async ({ providersPage }) => {
// Clean up any keys created during tests
for (const keyName of [...managementKeys]) {
try {
const exists = await providersPage.keyExists(keyName, 2000)
if (exists) {
await providersPage.deleteKey(keyName)
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
console.error(`[CLEANUP ERROR] Failed to delete provider key ${keyName}: ${errorMsg}`)
}
}
managementKeys.length = 0
})
test('should edit an existing key', async ({ providersPage }) => {
// First add a key
const keyData = createProviderKeyData({
name: `Edit-Test-Key-${Date.now()}`,
value: 'sk-test-edit-key',
})
// Track for cleanup
managementKeys.push(keyData.name)
await providersPage.addKey(keyData)
// Now edit it - set weight to 0.7
await providersPage.editKey(keyData.name, {
weight: 0.7,
})
// Verify weight was saved and displayed (wait for table to refresh after save)
const keyRow = providersPage.getKeyRow(keyData.name)
await expect(keyRow.getByTestId('key-weight-value')).toContainText('0.7', { timeout: 10000 })
})
test('should delete a key', async ({ providersPage }) => {
// First add a key
const keyData = createProviderKeyData({
name: `Delete-Test-Key-${Date.now()}`,
value: 'sk-test-delete-key',
})
// Don't track for cleanup - we're testing delete
await providersPage.addKey(keyData)
// Verify it exists
let keyExists = await providersPage.keyExists(keyData.name)
expect(keyExists).toBe(true)
// Delete it
await providersPage.deleteKey(keyData.name)
// Verify it's gone (use short timeout since we expect it to be gone)
keyExists = await providersPage.keyExists(keyData.name, 1000)
expect(keyExists).toBe(false)
})
test('should toggle key enabled state', async ({ providersPage }) => {
// First add a key
const keyData = createProviderKeyData({
name: `Toggle-Test-Key-${Date.now()}`,
value: 'sk-test-toggle-key',
})
// Track for cleanup
managementKeys.push(keyData.name)
await providersPage.addKey(keyData)
// Key starts enabled
let isEnabled = await providersPage.getKeyEnabledState(keyData.name)
expect(isEnabled).toBe(true)
// Toggle to disabled
await providersPage.toggleKeyEnabled(keyData.name)
await providersPage.page.waitForTimeout(9000)
isEnabled = await providersPage.getKeyEnabledState(keyData.name)
expect(isEnabled).toBe(false)
})
})
test.describe('Provider Configuration', () => {
test.beforeEach(async ({ providersPage }) => {
await providersPage.goto()
})
test('should view provider configuration', async ({ providersPage }) => {
// Select OpenAI provider
await providersPage.selectProvider('openai')
// Should see the provider's key table
await expect(providersPage.keysTable).toBeVisible()
// Should see the add key button
await expect(providersPage.addKeyBtn).toBeVisible()
})
test('should show provider models list', async ({ providersPage }) => {
// Select OpenAI provider
await providersPage.selectProvider('openai')
// Models section should be visible for selected provider
const modelsSection = providersPage.page.getByText(/Models/i).first()
await expect(modelsSection).toBeVisible()
})
})
test.describe('Performance Tuning', () => {
test.beforeEach(async ({ providersPage }) => {
await providersPage.goto()
await providersPage.selectProvider('openai')
})
test('should display performance tuning tab', async ({ providersPage }) => {
await providersPage.selectConfigTab('performance')
// Should see concurrency and buffer size inputs
await expect(providersPage.getConcurrencyInput()).toBeVisible()
await expect(providersPage.getBufferSizeInput()).toBeVisible()
})
test('should display raw request/response toggles', async ({ providersPage }) => {
await providersPage.selectConfigTab('debugging')
// Should see raw request and response toggles (Debugging tab labels)
const rawRequestLabel = providersPage.page.getByText('Send Back Raw Request')
const rawResponseLabel = providersPage.page.getByText('Send Back Raw Response')
await expect(rawRequestLabel).toBeVisible()
await expect(rawResponseLabel).toBeVisible()
})
test('should update concurrency value', async ({ providersPage }) => {
await providersPage.selectConfigTab('performance')
const concurrencyInput = providersPage.getConcurrencyInput()
const originalValue = await concurrencyInput.inputValue()
// Use a small value that is always <= buffer size
const newValue = '5'
await providersPage.fillNumberInput(concurrencyInput, newValue)
// Verify value changed
const currentValue = await concurrencyInput.inputValue()
expect(currentValue).toBe(newValue)
// Blur the input
await concurrencyInput.blur()
// No validation error should appear
await expect(providersPage.page.getByText('Concurrency must be a number')).not.toBeVisible()
await expect(providersPage.page.getByText('Concurrency must be greater than 0')).not.toBeVisible()
await expect(providersPage.page.getByText('Concurrency must be less than or equal to buffer size')).not.toBeVisible()
// Save and verify success
const saveBtn = providersPage.getConfigSaveBtn('performance')
await expect(saveBtn).toBeEnabled()
await providersPage.savePerformanceConfig()
// Verify value persisted after save (reload would be ideal but we restore instead)
const afterSaveValue = await concurrencyInput.inputValue()
expect(afterSaveValue).toBe(newValue)
// Restore original value
await providersPage.fillNumberInput(concurrencyInput, originalValue)
// Blur the input
await concurrencyInput.blur()
await providersPage.savePerformanceConfig()
})
test('should update buffer size value', async ({ providersPage }) => {
await providersPage.selectConfigTab('performance')
const bufferSizeInput = providersPage.getBufferSizeInput()
const originalValue = await bufferSizeInput.inputValue()
// Use a large value that is always >= concurrency
const newValue = '6000'
await providersPage.fillNumberInput(bufferSizeInput, newValue)
// Verify value changed
const currentValue = await bufferSizeInput.inputValue()
expect(currentValue).toBe(newValue)
// Blur the input
await bufferSizeInput.blur()
// No validation error should appear
await expect(providersPage.page.getByText('Buffer size must be a number')).not.toBeVisible()
await expect(providersPage.page.getByText('Buffer size must be greater than 0')).not.toBeVisible()
await expect(providersPage.page.getByText('Concurrency must be less than or equal to buffer size')).not.toBeVisible()
// Save and verify success
const saveBtn = providersPage.getConfigSaveBtn('performance')
await expect(saveBtn).toBeEnabled()
await providersPage.savePerformanceConfig()
// Restore original value
await providersPage.fillNumberInput(bufferSizeInput, originalValue)
// Blur the input
await bufferSizeInput.blur()
await providersPage.savePerformanceConfig()
})
test('should toggle and save raw request/response', async ({ providersPage }) => {
await providersPage.selectConfigTab('debugging')
const rawRequestSwitch = providersPage.getRawRequestSwitch()
const rawResponseSwitch = providersPage.getRawResponseSwitch()
// Capture original states
const originalRawRequest = await rawRequestSwitch.getAttribute('data-state') === 'checked'
const originalRawResponse = await rawResponseSwitch.getAttribute('data-state') === 'checked'
// Toggle both switches
await rawRequestSwitch.click()
await rawResponseSwitch.click()
// Save and verify success
const saveBtn = providersPage.getConfigSaveBtn('debugging')
await expect(saveBtn).toBeEnabled()
await providersPage.saveDebuggingConfig()
// Restore original states
const currentRawRequest = await rawRequestSwitch.getAttribute('data-state') === 'checked'
const currentRawResponse = await rawResponseSwitch.getAttribute('data-state') === 'checked'
if (currentRawRequest !== originalRawRequest) {
await rawRequestSwitch.click()
}
if (currentRawResponse !== originalRawResponse) {
await rawResponseSwitch.click()
}
await providersPage.saveDebuggingConfig()
})
})
test.describe('Proxy Configuration', () => {
test.beforeEach(async ({ providersPage }) => {
await providersPage.goto()
await providersPage.selectProvider('openai')
})
test('should display proxy config tab', async ({ providersPage }) => {
await providersPage.selectConfigTab('proxy')
// Should see proxy type selector
const proxyTypeLabel = providersPage.page.getByText('Proxy Type')
await expect(proxyTypeLabel).toBeVisible()
})
test('should show proxy type options', async ({ providersPage }) => {
await providersPage.selectConfigTab('proxy')
// Open the proxy type dropdown
const proxySelect = providersPage.getProxyTypeSelect()
await proxySelect.click()
// Should see HTTP, SOCKS5, Environment options
await expect(providersPage.page.getByRole('option', { name: /HTTP/i })).toBeVisible()
await expect(providersPage.page.getByRole('option', { name: /SOCKS5/i })).toBeVisible()
await expect(providersPage.page.getByRole('option', { name: /Environment/i })).toBeVisible()
// Close dropdown
await providersPage.page.keyboard.press('Escape')
})
test('should show URL fields when HTTP proxy selected', async ({ providersPage }) => {
await providersPage.selectConfigTab('proxy')
// Select HTTP proxy type
const proxySelect = providersPage.getProxyTypeSelect()
await proxySelect.click()
await providersPage.page.getByRole('option', { name: /HTTP/i }).click()
// Should show URL, username, password fields
await expect(providersPage.page.getByLabel('Proxy URL')).toBeVisible()
await expect(providersPage.page.getByLabel('Username')).toBeVisible()
await expect(providersPage.page.getByLabel('Password')).toBeVisible()
})
})
test.describe('Network Configuration', () => {
test.beforeEach(async ({ providersPage }) => {
await providersPage.goto()
await providersPage.selectProvider('openai')
})
test('should display network config tab', async ({ providersPage }) => {
await providersPage.selectConfigTab('network')
// Should see timeout and retry settings
await expect(providersPage.page.getByLabel(/Timeout/i)).toBeVisible()
await expect(providersPage.page.getByLabel(/Max Retries/i)).toBeVisible()
})
test('should display backoff settings', async ({ providersPage }) => {
await providersPage.selectConfigTab('network')
// Should see backoff configuration
await expect(providersPage.page.getByLabel(/Initial Backoff/i)).toBeVisible()
await expect(providersPage.page.getByLabel(/Max Backoff/i)).toBeVisible()
})
test('should update timeout value', async ({ providersPage }) => {
await providersPage.selectConfigTab('network')
// Ensure backoff fields are valid (minimum 100ms) so form validation passes
const initialBackoff = providersPage.page.getByLabel(/Initial Backoff/i)
const maxBackoff = providersPage.page.getByLabel(/Max Backoff/i)
const ibVal = await initialBackoff.inputValue()
const mbVal = await maxBackoff.inputValue()
if (Number(ibVal) < 100) {
await providersPage.fillNumberInput(initialBackoff, '500')
}
if (Number(mbVal) < 100) {
await providersPage.fillNumberInput(maxBackoff, '10000')
}
const timeoutInput = providersPage.page.getByLabel(/Timeout/i)
const originalValue = await timeoutInput.inputValue()
const newValue = originalValue === '30' ? '60' : '30'
await providersPage.fillNumberInput(timeoutInput, newValue)
// Verify value changed
const currentValue = await timeoutInput.inputValue()
expect(currentValue).toBe(newValue)
// Save button should be enabled
const saveBtn = providersPage.getConfigSaveBtn('network')
await expect(saveBtn).toBeEnabled()
await providersPage.saveNetworkConfig()
// Restore original value to avoid leaving form dirty
await providersPage.fillNumberInput(timeoutInput, originalValue)
await providersPage.saveNetworkConfig()
})
test('should update max retries value', async ({ providersPage }) => {
await providersPage.selectConfigTab('network')
// Ensure backoff fields are valid (minimum 100ms) so form validation passes
const initialBackoff = providersPage.page.getByLabel(/Initial Backoff/i)
const maxBackoff = providersPage.page.getByLabel(/Max Backoff/i)
const ibVal = await initialBackoff.inputValue()
const mbVal = await maxBackoff.inputValue()
if (Number(ibVal) < 100) {
await providersPage.fillNumberInput(initialBackoff, '500')
}
if (Number(mbVal) < 100) {
await providersPage.fillNumberInput(maxBackoff, '10000')
}
const retriesInput = providersPage.page.getByLabel(/Max Retries/i)
const originalValue = await retriesInput.inputValue()
const newValue = originalValue === '0' ? '3' : '0'
await providersPage.fillNumberInput(retriesInput, newValue)
// Verify value changed
const currentValue = await retriesInput.inputValue()
expect(currentValue).toBe(newValue)
// Save button should be enabled
const saveBtn = providersPage.getConfigSaveBtn('network')
await expect(saveBtn).toBeEnabled()
await providersPage.saveNetworkConfig()
// Restore original value to avoid leaving form dirty
await providersPage.fillNumberInput(retriesInput, originalValue)
await providersPage.saveNetworkConfig()
})
})
test.describe('Governance (Budget & Rate Limits)', () => {
test.beforeEach(async ({ providersPage }) => {
await providersPage.goto()
await providersPage.selectProvider('openai')
})
test('should display governance tab', async ({ providersPage }) => {
const isVisible = await providersPage.isGovernanceTabVisible()
if (isVisible) {
await providersPage.selectConfigTab('governance')
// Should see budget configuration section
await expect(providersPage.page.getByText('Budget Configuration')).toBeVisible()
}
})
test('should display budget configuration', async ({ providersPage }) => {
const isVisible = await providersPage.isGovernanceTabVisible()
if (isVisible) {
await providersPage.selectConfigTab('governance')
// Should see budget limit input
const budgetInput = providersPage.page.locator('#providerBudgetMaxLimit')
await expect(budgetInput).toBeVisible()
}
})
test('should display rate limiting configuration', async ({ providersPage }) => {
const isVisible = await providersPage.isGovernanceTabVisible()
if (isVisible) {
await providersPage.selectConfigTab('governance')
// Should see rate limiting section
await expect(providersPage.page.getByText('Rate Limiting Configuration')).toBeVisible()
// Should see token and request limit inputs
const tokenInput = providersPage.page.locator('#providerTokenMaxLimit')
const requestInput = providersPage.page.locator('#providerRequestMaxLimit')
await expect(tokenInput).toBeVisible()
await expect(requestInput).toBeVisible()
}
})
test('should set budget limit', async ({ providersPage }) => {
const isVisible = await providersPage.isGovernanceTabVisible()
if (isVisible) {
await providersPage.selectConfigTab('governance')
const budgetInput = providersPage.page.locator('#providerBudgetMaxLimit')
await budgetInput.click()
await budgetInput.fill('')
// Type character by character to trigger React's onChange
await budgetInput.pressSequentially('100')
// Verify value
const value = await budgetInput.inputValue()
expect(value).toBe('100')
// Form should now be dirty - save button should be enabled
const saveBtn = providersPage.getConfigSaveBtn('governance')
// Give React time to update the form state
await providersPage.page.waitForTimeout(500)
await expect(saveBtn).toBeEnabled({ timeout: 5000 })
}
})
test('should set rate limits', async ({ providersPage }) => {
const isVisible = await providersPage.isGovernanceTabVisible()
if (isVisible) {
await providersPage.selectConfigTab('governance')
// Set token limit - use pressSequentially for proper React onChange
const tokenInput = providersPage.page.locator('#providerTokenMaxLimit')
await tokenInput.click()
await tokenInput.fill('')
await tokenInput.pressSequentially('100000')
// Set request limit
const requestInput = providersPage.page.locator('#providerRequestMaxLimit')
await requestInput.click()
await requestInput.fill('')
await requestInput.pressSequentially('1000')
// Verify values
expect(await tokenInput.inputValue()).toBe('100000')
expect(await requestInput.inputValue()).toBe('1000')
}
})
})
test.describe('Debugging Tab', () => {
test.beforeEach(async ({ providersPage }) => {
await providersPage.goto()
await providersPage.selectProvider('openai')
})
test('should display debugging tab', async ({ providersPage }) => {
await providersPage.openConfigSheet()
const debuggingTab = providersPage.page.getByTestId('provider-tab-debugging')
await expect(debuggingTab).toBeVisible()
})
test('should navigate to debugging tab', async ({ providersPage }) => {
await providersPage.selectConfigTab('debugging')
const debuggingTab = providersPage.page.getByTestId('provider-tab-debugging')
await expect(debuggingTab).toHaveAttribute('data-state', 'active')
const debuggingContent = providersPage.page.getByTestId('provider-config-debugging-content')
await expect(debuggingContent).toBeVisible()
})
})
test.describe('vLLM Provider', () => {
test.beforeEach(async ({ providersPage }) => {
await providersPage.goto()
})
test('should display vLLM-specific key fields when adding key to vLLM provider', async ({ providersPage }) => {
const vllmAvailable = await providersPage.providerExists('vllm')
if (!vllmAvailable) {
test.skip(true, 'vLLM provider not in sidebar (add from dropdown first)')
return
}
await providersPage.selectProvider('vllm')
await providersPage.addKeyBtn.click()
const vllmUrlInput = providersPage.page.getByTestId('key-input-vllm-url')
const vllmModelInput = providersPage.page.getByTestId('key-input-vllm-model-name')
const urlVisible = await vllmUrlInput.isVisible().catch(() => false)
const modelVisible = await vllmModelInput.isVisible().catch(() => false)
if (!urlVisible && !modelVisible) {
test.skip(true, 'vLLM key form fields not shown (provider may use standard key form)')
return
}
await expect(vllmUrlInput).toBeVisible()
await expect(vllmModelInput).toBeVisible()
await providersPage.keyCancelBtn.click()
})
})

View File

@@ -0,0 +1,676 @@
import { Locator, Page, expect } from '@playwright/test'
import { BasePage } from '../../../core/pages/base.page'
import { waitForNetworkIdle } from '../../../core/utils/test-helpers'
/**
* Routing rule configuration
* Note: CEL expression is auto-generated from the visual Rule Builder in the UI
*/
export interface RoutingRuleConfig {
name: string
description?: string
provider?: string
model?: string
priority?: number
enabled?: boolean
scope?: 'global' | 'team' | 'customer' | 'virtual_key'
scopeId?: string
// Fallback providers
fallbacks?: string[]
}
/**
* Filter conditions for the rule builder
*/
export interface RuleFilterCondition {
field: 'model' | 'provider' | 'virtualKey' | 'customer' | 'metadata'
operator: 'equals' | 'notEquals' | 'contains' | 'startsWith' | 'endsWith' | 'regex'
value: string
}
/**
* Page object for the Routing Rules page
*/
export class RoutingRulesPage extends BasePage {
// Main elements
readonly table: Locator
/** View-level empty state (no table is rendered when there are 0 rules) */
readonly emptyState: Locator
readonly createBtn: Locator
// Sheet elements
readonly sheet: Locator
readonly nameInput: Locator
readonly descriptionInput: Locator
readonly providerSelect: Locator
readonly modelSelect: Locator
readonly priorityInput: Locator
readonly enabledToggle: Locator
readonly scopeSelect: Locator
readonly saveBtn: Locator
readonly cancelBtn: Locator
constructor(page: Page) {
super(page)
// Main elements: scope to the routing rules table (has Priority column); no data-testid in UI
this.table = page.locator('table').filter({
has: page.locator('th').filter({ hasText: /^Priority$/ })
}).first()
this.emptyState = page.getByTestId('routing-rules-empty-state')
// Use .first() to handle both "New Rule" and "Create First Rule" buttons
this.createBtn = page.locator('[data-testid="create-routing-rule-btn"]').or(
page.getByRole('button', { name: /New Rule|Create First Rule/i }).first()
)
// Sheet elements
this.sheet = page.locator('[role="dialog"]').or(page.locator('[data-testid="routing-rule-sheet"]'))
// Scope inputs to the sheet to avoid matching other elements on page
this.nameInput = page.locator('[data-testid="rule-name-input"]').or(
page.locator('[role="dialog"]').getByLabel(/Rule Name/i)
)
this.descriptionInput = page.locator('[data-testid="rule-description-input"]').or(
page.locator('[role="dialog"]').getByLabel(/Description/i)
)
this.providerSelect = page.locator('[data-testid="rule-provider-select"]').or(
page.locator('button').filter({ hasText: /Provider/i })
)
this.modelSelect = page.locator('[data-testid="rule-model-select"]').or(
page.locator('button').filter({ hasText: /Model/i })
)
this.priorityInput = page.locator('[data-testid="rule-priority-input"]').or(
page.getByLabel(/Priority/i)
)
this.enabledToggle = page.locator('[data-testid="rule-enabled-toggle"]').or(
page.locator('[role="dialog"] button[role="switch"]').first()
)
// Note: CEL expression is auto-generated from the visual Rule Builder - no direct input
this.scopeSelect = page.locator('[data-testid="rule-scope-select"]').or(
page.locator('button').filter({ hasText: /Scope/i })
)
// Use exact button names to avoid matching wrong buttons
// Match both "Save Rule" (create) and "Update Rule" (edit)
this.saveBtn = page.locator('[data-testid="save-rule-btn"]').or(
page.locator('[role="dialog"]').getByRole('button', { name: /Save Rule|Update Rule/i })
)
// Cancel button specifically (not the Close/X button in header)
this.cancelBtn = page.locator('[data-testid="cancel-rule-btn"]').or(
page.locator('[role="dialog"]').getByRole('button', { name: 'Cancel', exact: true })
)
}
/**
* Navigate to the routing rules page
*/
async goto(): Promise<void> {
await this.page.goto('/workspace/routing-rules')
// Wait for page content (create button, empty state, or table); avoid networkidle (SPA often never idles)
await Promise.race([
this.createBtn.waitFor({ state: 'visible', timeout: 15000 }),
this.emptyState.waitFor({ state: 'visible', timeout: 15000 }),
this.table.waitFor({ state: 'visible', timeout: 15000 }),
])
}
/**
* Get routing rule row locator (tbody data row containing the rule name).
*/
getRuleRow(name: string): Locator {
return this.table.locator('tbody tr').filter({ hasText: name }).first()
}
private async waitForToastAndAssertSuccess(action: string): Promise<void> {
const toast = this.page.locator('[data-sonner-toast]:not([data-removed="true"])').first()
await expect(toast).toBeVisible({ timeout: 10000 })
const toastText = await toast.textContent()
if (toastText?.toLowerCase().includes('error') || toastText?.toLowerCase().includes('failed')) {
throw new Error(`Failed to ${action}: ${toastText}`)
}
await this.dismissToasts()
}
/**
* Check if routing rule exists
*/
async ruleExists(name: string): Promise<boolean> {
const row = this.getRuleRow(name)
return await row.count() > 0
}
/**
* Wait for a rule to appear in the table (e.g. after create)
*/
async waitForRuleToAppear(name: string, timeoutMs: number = 10000): Promise<void> {
await expect.poll(() => this.ruleExists(name), { timeout: timeoutMs }).toBe(true)
}
/**
* Create a new routing rule
*/
async createRoutingRule(config: RoutingRuleConfig): Promise<void> {
await this.dismissToasts()
await this.createBtn.click()
await expect(this.sheet).toBeVisible({ timeout: 5000 })
await this.waitForSheetAnimation()
// Fill name (required) - scoped to sheet
await this.nameInput.waitFor({ state: 'visible' })
await this.nameInput.fill(config.name)
// Fill description if provided
if (config.description) {
await this.descriptionInput.waitFor({ state: 'visible' })
await this.descriptionInput.fill(config.description)
}
// Select provider if provided (in the Routing Target section)
if (config.provider) {
const providerCombo = this.sheet.getByRole('combobox').filter({ hasText: /Select provider/i }).first()
if (await providerCombo.isVisible().catch(() => false)) {
await providerCombo.click()
await this.page.waitForSelector('[role="listbox"]', { timeout: 5000 })
const option = this.page.getByRole('option', { name: new RegExp(config.provider, 'i') }).first()
await option.scrollIntoViewIfNeeded()
await option.click({ force: true })
// Wait for dropdown to close
await this.page.waitForSelector('[role="listbox"]', { state: 'hidden', timeout: 5000 }).catch(() => {})
}
}
// Note: CEL expression is auto-generated from the Rule Builder (visual query builder)
// The UI doesn't have a direct CEL input field - it shows a read-only preview
// To add conditions, use the "Add Rule" button in the Rule Builder section
// For basic tests, leaving the builder empty applies the rule to all requests
// Set priority if provided - clear first then fill
if (config.priority !== undefined) {
await this.priorityInput.waitFor({ state: 'visible' })
await this.priorityInput.clear()
await this.priorityInput.fill(String(config.priority))
}
// Set enabled state if explicitly specified
if (config.enabled !== undefined) {
const isChecked = await this.enabledToggle.getAttribute('data-state') === 'checked'
if (config.enabled && !isChecked) {
await this.enabledToggle.click()
} else if (!config.enabled && isChecked) {
await this.enabledToggle.click()
}
}
// Save
await this.saveBtn.waitFor({ state: 'visible' })
await this.saveBtn.click()
await this.waitForToastAndAssertSuccess('create routing rule')
await expect(this.sheet).not.toBeVisible({ timeout: 10000 })
await waitForNetworkIdle(this.page)
// Wait for the new rule to appear in the table (list may refresh async)
await this.waitForRuleToAppear(config.name, 10000)
}
/**
* Edit an existing routing rule
*/
async editRoutingRule(name: string, updates: Partial<RoutingRuleConfig>): Promise<void> {
await this.dismissToasts()
const row = this.getRuleRow(name)
await row.scrollIntoViewIfNeeded()
// Find edit button
const editBtn = row.locator('button').filter({ has: this.page.locator('svg.lucide-pencil') }).or(
row.getByRole('button', { name: /Edit/i })
)
await editBtn.waitFor({ state: 'visible' })
await editBtn.click()
await expect(this.sheet).toBeVisible({ timeout: 5000 })
await this.waitForSheetAnimation()
// Update fields
if (updates.name) {
await this.nameInput.waitFor({ state: 'visible' })
await this.nameInput.clear()
await this.nameInput.fill(updates.name)
}
if (updates.description !== undefined) {
await this.descriptionInput.waitFor({ state: 'visible' })
await this.descriptionInput.clear()
if (updates.description) {
await this.descriptionInput.fill(updates.description)
}
}
if (updates.priority !== undefined) {
await this.priorityInput.waitFor({ state: 'visible' })
await this.priorityInput.clear()
await this.priorityInput.fill(String(updates.priority))
}
// Save
await this.saveBtn.waitFor({ state: 'visible' })
await this.saveBtn.click()
await this.waitForToastAndAssertSuccess('edit routing rule')
await expect(this.sheet).not.toBeVisible({ timeout: 10000 })
await waitForNetworkIdle(this.page)
}
/**
* Delete a routing rule
*/
async deleteRoutingRule(name: string): Promise<void> {
await this.dismissToasts()
const row = this.getRuleRow(name)
await row.scrollIntoViewIfNeeded()
// Find delete button (may have lucide-trash or lucide-trash-2 icon)
const deleteBtn = row.locator('button').filter({
has: this.page.locator('svg.lucide-trash, svg.lucide-trash-2')
}).first()
await deleteBtn.waitFor({ state: 'visible' })
await deleteBtn.click()
// Wait for confirmation dialog (AlertDialog uses role="alertdialog")
const alertDialog = this.page.locator('[role="alertdialog"]')
await alertDialog.waitFor({ state: 'visible', timeout: 5000 })
// Click confirm delete button inside the dialog
const confirmBtn = alertDialog.getByRole('button', { name: /Delete/i })
await confirmBtn.waitFor({ state: 'visible' })
await confirmBtn.click()
await this.waitForSuccessToast('deleted')
await this.dismissToasts()
await waitForNetworkIdle(this.page)
}
/**
* Toggle rule enabled state
*/
async toggleRuleEnabled(name: string): Promise<void> {
await this.dismissToasts() // Dismiss any existing toasts
const row = this.getRuleRow(name)
await row.scrollIntoViewIfNeeded()
// Find toggle switch in the row
const toggle = row.locator('button[role="switch"]')
if (await toggle.count() > 0) {
await toggle.waitFor({ state: 'visible' })
await toggle.click()
await this.waitForSuccessToast()
await this.dismissToasts() // Wait for toasts to disappear
}
}
/**
* Cancel rule creation/edit
*/
async cancelRule(): Promise<void> {
if (await this.sheet.isVisible()) {
await this.cancelBtn.click()
await expect(this.sheet).not.toBeVisible({ timeout: 5000 })
}
}
/**
* Get routing rule count.
* When there are 0 rules, the view shows an empty state (no table in DOM).
*/
async getRuleCount(): Promise<number> {
const emptyVisible = await this.emptyState.isVisible().catch(() => false)
if (emptyVisible) {
return 0
}
const tableVisible = await this.table.isVisible().catch(() => false)
if (!tableVisible) {
return 0
}
const rows = this.table.locator('tbody tr')
const count = await rows.count()
const firstRowText = await rows.first().textContent({ timeout: 5000 }).catch(() => '')
if (firstRowText?.includes('No routing rules')) {
return 0
}
return count
}
/**
* Get the rule builder container (the Query builder generic element)
*/
getRuleBuilder(): Locator {
return this.sheet.locator('[aria-label="Query builder"]')
}
/**
* Wait for the CEL rule builder to be fully loaded
*/
async waitForRuleBuilder(): Promise<void> {
// Wait for the Add Rule button to be visible (indicates builder is loaded)
const addRuleBtn = this.sheet.getByRole('button', { name: 'Add Rule', exact: true })
await addRuleBtn.waitFor({ state: 'visible', timeout: 10000 })
// Give time for React to fully render
await this.page.waitForTimeout(500)
}
/**
* Click the "Add Rule" button in the rule builder
*/
async clickAddRule(): Promise<void> {
await this.waitForRuleBuilder()
const addRuleBtn = this.sheet.getByRole('button', { name: 'Add Rule', exact: true })
await addRuleBtn.click()
await this.page.waitForTimeout(500) // Wait for new rule row to appear
}
/**
* Click the "Add Rule Group" button in the rule builder
*/
async clickAddRuleGroup(): Promise<void> {
await this.waitForRuleBuilder()
const addGroupBtn = this.sheet.getByRole('button', { name: 'Add Rule Group', exact: true })
await addGroupBtn.click()
await this.page.waitForTimeout(500) // Wait for new group to appear
}
/**
* Get all comboboxes for a specific rule row
* The rule builder has a specific structure where rule rows have remove buttons ()
*/
async getRuleRowComboboxes(ruleIndex: number): Promise<{ field: Locator; operator: Locator; value: Locator }> {
const ruleBuilder = this.getRuleBuilder()
// Find all rows that have the remove button () - these are rule rows
const ruleRows = ruleBuilder.locator('> div').filter({
has: this.page.locator('button').filter({ hasText: '' })
})
const ruleRow = ruleRows.nth(ruleIndex)
// Within the rule row, comboboxes are in order: field, (hidden), operator, (hidden), then value area
// Get all visible comboboxes in this row
const allComboboxes = ruleRow.locator('[role="combobox"]')
return {
field: allComboboxes.first(),
operator: allComboboxes.nth(2), // Skip the hidden one at index 1
value: ruleRow.locator('[role="combobox"]').last() // Value selector is in a nested structure
}
}
/**
* Select field for a rule (by rule index, 0-based)
*/
async selectRuleField(ruleIndex: number, fieldName: string): Promise<void> {
const { field: fieldSelector } = await this.getRuleRowComboboxes(ruleIndex)
await fieldSelector.waitFor({ state: 'visible', timeout: 5000 })
await fieldSelector.click()
await this.page.waitForTimeout(300)
await this.page.getByRole('option', { name: new RegExp(`^${fieldName}$`, 'i') }).first().click({ force: true })
await this.page.waitForTimeout(300)
}
/**
* Select operator for a rule (by rule index, 0-based)
* Operator symbols: =, !=, >, <, >=, <=, contains, starts with, ends with, matches regex
*/
async selectRuleOperator(ruleIndex: number, operatorName: string): Promise<void> {
const { operator: operatorSelector } = await this.getRuleRowComboboxes(ruleIndex)
await operatorSelector.waitFor({ state: 'visible', timeout: 5000 })
await operatorSelector.click()
await this.page.waitForTimeout(300)
// Match operator by exact symbol or text
const option = this.page.getByRole('option').filter({ hasText: new RegExp(`^${operatorName}$|^${operatorName} `, 'i') }).first()
await option.click({ force: true })
await this.page.waitForTimeout(300)
}
/**
* Set value for a rule using the value combobox/input
* For Model/Provider fields, this is a searchable dropdown
*/
async setRuleValue(ruleIndex: number, value: string): Promise<void> {
const ruleBuilder = this.getRuleBuilder()
const ruleRows = ruleBuilder.locator('> div').filter({
has: this.page.locator('button').filter({ hasText: '' })
})
const ruleRow = ruleRows.nth(ruleIndex)
// Value input can be either a text input or a searchable combobox
// Try text input first
const textInput = ruleRow.locator('input[type="text"]').first()
if (await textInput.isVisible().catch(() => false)) {
await textInput.fill(value)
await this.page.waitForTimeout(200)
return
}
// Otherwise use the value combobox (for Model/Provider fields)
// It's in a nested structure, look for "Select a model..." or similar text
const valueArea = ruleRow.locator('div').filter({ hasText: /Select a/ }).last()
const valueSelector = valueArea.locator('[role="combobox"]').first()
if (await valueSelector.isVisible().catch(() => false)) {
await valueSelector.click()
await this.page.waitForTimeout(300)
// Type in the search input
const searchInput = this.page.locator('[cmdk-input]').or(
this.page.locator('input[placeholder*="Search"]')
).or(
this.page.locator('[role="listbox"] input')
)
if (await searchInput.isVisible().catch(() => false)) {
await searchInput.fill(value)
await this.page.waitForTimeout(500)
}
// Try to select matching option
const option = this.page.getByRole('option', { name: new RegExp(value, 'i') }).first()
if (await option.isVisible({ timeout: 2000 }).catch(() => false)) {
await option.click({ force: true })
} else {
// Press escape and type directly in input if no option found
await this.page.keyboard.press('Escape')
}
await this.page.waitForTimeout(300)
}
}
/**
* Change the combinator (AND/OR) for a rule group
*/
async setCombinator(combinator: 'and' | 'or'): Promise<void> {
// AND/OR are toggle buttons - click the one we want to activate
const targetBtn = this.sheet.getByRole('button', { name: combinator.toUpperCase(), exact: true })
await targetBtn.click()
await this.page.waitForTimeout(300)
}
/**
* Get the CEL expression preview text
*/
async getCelExpression(): Promise<string> {
// The CEL preview is in a readonly textarea with label "CEL Expression Preview"
const celPreview = this.sheet.locator('textarea').last()
await celPreview.waitFor({ state: 'visible', timeout: 5000 })
return await celPreview.inputValue()
}
/**
* Add a complete rule condition (field + operator + value)
*/
async addRuleCondition(condition: RuleFilterCondition): Promise<void> {
await this.clickAddRule()
// Get the index of the new rule (last one)
const ruleBuilder = this.getRuleBuilder()
const ruleRows = ruleBuilder.locator('> div').filter({
has: this.page.locator('button').filter({ hasText: '' })
})
const ruleCount = await ruleRows.count()
const newRuleIndex = ruleCount - 1
// Select field
await this.selectRuleField(newRuleIndex, condition.field)
// Select operator (map our operator names to UI labels)
const operatorMap: Record<string, string> = {
'equals': '=',
'notEquals': '!=',
'contains': 'contains',
'startsWith': 'starts with',
'endsWith': 'ends with',
'regex': 'matches regex'
}
await this.selectRuleOperator(newRuleIndex, operatorMap[condition.operator] || condition.operator)
// Set value
await this.setRuleValue(newRuleIndex, condition.value)
}
/**
* Add a fallback provider
*/
async addFallbackProvider(provider: string, model?: string): Promise<void> {
// Find the "Add Fallback" button
const addFallbackBtn = this.sheet.getByRole('button', { name: /Add Fallback/i }).or(
this.sheet.locator('button').filter({ hasText: /Fallback/i })
)
const isVisible = await addFallbackBtn.isVisible().catch(() => false)
if (isVisible) {
await addFallbackBtn.click()
// Fill in provider/model - typically in format "provider/model"
const fallbackInput = this.sheet.locator('input[placeholder*="fallback" i], input[placeholder*="provider" i]').first()
const value = model ? `${provider}/${model}` : provider
await fallbackInput.fill(value)
}
}
/**
* Duplicate an existing rule
*/
async duplicateRule(name: string): Promise<string | null> {
await this.dismissToasts()
const row = this.getRuleRow(name)
await row.scrollIntoViewIfNeeded()
// Find duplicate button
const duplicateBtn = row.locator('button').filter({
has: this.page.locator('svg.lucide-copy')
}).or(
row.getByRole('button', { name: /Duplicate/i })
)
const isVisible = await duplicateBtn.isVisible().catch(() => false)
if (!isVisible) {
return null
}
await duplicateBtn.click()
await this.waitForSuccessToast()
// NOTE: Assumes UI appends " (copy)" to duplicated rule names.
// If this convention changes, update this return value.
return `${name} (copy)`
}
/**
* Reorder rules by drag and drop (if supported)
* Note: Many UIs use priority field instead of drag-drop
*/
async reorderRuleByPriority(name: string, newPriority: number): Promise<void> {
// Edit the rule and change its priority
await this.editRoutingRule(name, { priority: newPriority })
}
/**
* Get rule's description from the table (first column contains name + description)
*/
async getRuleDescription(name: string): Promise<string> {
const row = this.getRuleRow(name)
const descEl = row.getByTestId('routing-rule-description')
const count = await descEl.count()
if (count === 0) return ''
return (await descEl.textContent()) ?? ''
}
/**
* Get rule's current priority
*/
async getRulePriority(name: string): Promise<number | null> {
const row = this.getRuleRow(name)
// Table columns: Name(0), Provider(1), Model(2), Scope(3), Priority(4), Expression(5), Status(6), Actions(7)
const cells = row.locator('td')
const count = await cells.count()
// Priority is in the 5th column (index 4)
if (count > 4) {
const text = await cells.nth(4).textContent()
const num = parseInt(text || '', 10)
if (!isNaN(num) && num > 0) {
return num
}
}
return null
}
/**
* Set scope for a rule in the form
*/
async setRuleScope(scope: 'global' | 'team' | 'customer' | 'virtual_key', scopeId?: string): Promise<void> {
// Find scope select
const scopeSelect = this.sheet.locator('[role="combobox"]').filter({ hasText: /Scope|Global/i }).first()
if (await scopeSelect.isVisible().catch(() => false)) {
await scopeSelect.click()
// Map scope values to display labels
const labels: Record<string, string> = {
'global': 'Global',
'team': 'Team',
'customer': 'Customer',
'virtual_key': 'Virtual Key'
}
await this.page.getByRole('option', { name: labels[scope] }).click({ force: true })
// If not global, fill in the scope ID
if (scope !== 'global' && scopeId) {
// Wait for scope ID select/input to appear
const scopeIdInput = this.sheet.locator('[role="combobox"]').filter({ hasText: /Select/i }).last().or(
this.sheet.locator('input[placeholder*="Select" i]').last()
)
if (await scopeIdInput.isVisible().catch(() => false)) {
await scopeIdInput.click()
await this.page.getByRole('option', { name: new RegExp(scopeId, 'i') }).first().click({ force: true })
}
}
}
}
/**
* Get all rule names from the table
*/
async getAllRuleNames(): Promise<string[]> {
const rows = this.table.locator('tbody tr')
const count = await rows.count()
const names: string[] = []
for (let i = 0; i < count; i++) {
const firstCell = rows.nth(i).locator('td').first()
const name = await firstCell.textContent()
if (name && !name.includes('No routing rules')) {
names.push(name.trim())
}
}
return names
}
}

View File

@@ -0,0 +1,36 @@
import { RoutingRuleConfig } from './pages/routing-rules.page'
// Counter to ensure unique priorities within a test run
let priorityCounter = 0
/**
* Get a unique priority for each rule created in this test session
* Priority must be between 0 and 1000 (inclusive)
*/
function getUniquePriority(): number {
priorityCounter++
// Per-process and time spread so parallel workers get different priorities (backend rejects duplicate).
// Use high-resolution time to minimise collisions across test runs.
const pid = typeof process !== 'undefined' && process.pid ? process.pid : 0
const now = Date.now()
return 1 + (pid * 7 + now % 100000 + priorityCounter * 131 + Math.floor(Math.random() * 500)) % 999
}
/**
* Factory function to create routing rule test data
* Note: CEL expression is auto-generated from the visual Rule Builder in the UI
* An empty rule builder means the rule applies to all requests
*/
export function createRoutingRuleData(overrides: Partial<RoutingRuleConfig> = {}): RoutingRuleConfig {
const timestamp = Date.now()
return {
name: `test-rule-${timestamp}`,
description: 'Test routing rule',
priority: overrides.priority ?? getUniquePriority(),
enabled: true,
...overrides,
}
}
// Note: CEL expressions are auto-generated from the visual Rule Builder in the UI
// The Rule Builder allows users to create conditions without writing CEL directly

View File

@@ -0,0 +1,374 @@
import { expect, test } from '../../core/fixtures/base.fixture'
import { createRoutingRuleData } from './routing-rules.data'
// Track created rules for cleanup
const createdRules: string[] = []
test.describe('Routing Rules', () => {
test.beforeEach(async ({ routingRulesPage }) => {
await routingRulesPage.goto()
})
test.afterEach(async ({ routingRulesPage }) => {
// Clean up any rules created during tests
for (const ruleName of [...createdRules]) {
try {
const exists = await routingRulesPage.ruleExists(ruleName)
if (exists) {
await routingRulesPage.deleteRoutingRule(ruleName)
}
} catch {
// Ignore cleanup errors
}
}
createdRules.length = 0
})
test.describe('Routing Rule Creation', () => {
test('should display create routing rule button', async ({ routingRulesPage }) => {
await expect(routingRulesPage.createBtn).toBeVisible()
})
test('should open routing rule creation sheet', async ({ routingRulesPage }) => {
await routingRulesPage.createBtn.click()
await expect(routingRulesPage.sheet).toBeVisible({ timeout: 5000 })
await expect(routingRulesPage.nameInput).toBeVisible()
})
test('should create a basic routing rule', async ({ routingRulesPage }) => {
// Note: CEL expression is auto-generated from the visual Rule Builder
// An empty builder means the rule applies to all requests
const ruleData = createRoutingRuleData({
name: `Basic Rule ${Date.now()}`,
})
createdRules.push(ruleData.name)
await routingRulesPage.createRoutingRule(ruleData)
const exists = await routingRulesPage.ruleExists(ruleData.name)
expect(exists).toBe(true)
})
test('should create routing rule with description', async ({ routingRulesPage }) => {
const ruleData = createRoutingRuleData({
name: `Described Rule ${Date.now()}`,
description: 'A rule with a detailed description for testing',
})
createdRules.push(ruleData.name)
await routingRulesPage.createRoutingRule(ruleData)
const exists = await routingRulesPage.ruleExists(ruleData.name)
expect(exists).toBe(true)
})
test('should create disabled routing rule', async ({ routingRulesPage }) => {
const ruleData = createRoutingRuleData({
name: `Disabled Rule ${Date.now()}`,
enabled: false,
})
createdRules.push(ruleData.name)
await routingRulesPage.createRoutingRule(ruleData)
const exists = await routingRulesPage.ruleExists(ruleData.name)
expect(exists).toBe(true)
})
test('should cancel routing rule creation', async ({ routingRulesPage }) => {
await routingRulesPage.createBtn.click()
await expect(routingRulesPage.sheet).toBeVisible()
const testName = `Cancelled Rule ${Date.now()}`
await routingRulesPage.nameInput.fill(testName)
await routingRulesPage.cancelRule()
const exists = await routingRulesPage.ruleExists(testName)
expect(exists).toBe(false)
})
})
test.describe('Routing Rule Management', () => {
test('should edit routing rule', async ({ routingRulesPage }) => {
// Create a rule first
const ruleData = createRoutingRuleData({
name: `Edit Test Rule ${Date.now()}`,
})
createdRules.push(ruleData.name)
await routingRulesPage.createRoutingRule(ruleData)
// Edit it - change description
await routingRulesPage.editRoutingRule(ruleData.name, {
description: 'Updated description',
})
// Verify description was saved and displayed in table
const description = await routingRulesPage.getRuleDescription(ruleData.name)
expect(description).toContain('Updated description')
})
test('should delete routing rule', async ({ routingRulesPage }) => {
// Create a rule first
const ruleData = createRoutingRuleData({
name: `Delete Test Rule ${Date.now()}`,
})
// Don't add to createdRules since we're testing delete
await routingRulesPage.createRoutingRule(ruleData)
// Verify it exists
let exists = await routingRulesPage.ruleExists(ruleData.name)
expect(exists).toBe(true)
// Delete it
await routingRulesPage.deleteRoutingRule(ruleData.name)
// Verify it's gone
exists = await routingRulesPage.ruleExists(ruleData.name)
expect(exists).toBe(false)
})
test('should toggle rule enabled state', async ({ routingRulesPage }) => {
// Create a rule first
const ruleData = createRoutingRuleData({
name: `Toggle Test Rule ${Date.now()}`,
enabled: true,
})
createdRules.push(ruleData.name)
await routingRulesPage.createRoutingRule(ruleData)
// Toggle it
await routingRulesPage.toggleRuleEnabled(ruleData.name)
// Verify it still exists
const exists = await routingRulesPage.ruleExists(ruleData.name)
expect(exists).toBe(true)
})
})
test.describe('Form Validation', () => {
test('should require name for routing rule', async ({ routingRulesPage }) => {
await routingRulesPage.createBtn.click()
await expect(routingRulesPage.sheet).toBeVisible()
// Try to save without name
await routingRulesPage.saveBtn.click()
// Form should still be visible (not submitted)
await expect(routingRulesPage.sheet).toBeVisible()
await routingRulesPage.cancelRule()
})
})
test.describe('Table Display', () => {
test('should display routing rules table', async ({ routingRulesPage }) => {
// With 0 rules the view shows empty state (no table); with 1+ rules it shows the table
const count = await routingRulesPage.getRuleCount()
if (count === 0) {
await expect(routingRulesPage.emptyState).toBeVisible()
await expect(routingRulesPage.table).not.toBeVisible()
} else {
await expect(routingRulesPage.table).toBeVisible()
await expect(routingRulesPage.emptyState).not.toBeVisible()
}
})
test('should show empty state when no rules', async ({ routingRulesPage }) => {
const count = await routingRulesPage.getRuleCount()
if (count === 0) {
await expect(routingRulesPage.emptyState).toBeVisible()
}
// When rules exist, getRuleCount > 0 is already implied by the condition
})
})
test.describe('Advanced Rule Features', () => {
test('should create rule with provider filter', async ({ routingRulesPage }) => {
const ruleData = createRoutingRuleData({
name: `Provider Filter Rule ${Date.now()}`,
provider: 'openai', // Set target provider
})
createdRules.push(ruleData.name)
await routingRulesPage.createRoutingRule(ruleData)
const exists = await routingRulesPage.ruleExists(ruleData.name)
expect(exists).toBe(true)
})
test('should create rule with model filter', async ({ routingRulesPage }) => {
const ruleData = createRoutingRuleData({
name: `Model Filter Rule ${Date.now()}`,
provider: 'openai',
model: 'gpt-4',
})
createdRules.push(ruleData.name)
await routingRulesPage.createRoutingRule(ruleData)
const exists = await routingRulesPage.ruleExists(ruleData.name)
expect(exists).toBe(true)
})
test('should reorder rules by changing priority', async ({ routingRulesPage }) => {
// Create two rules with unique priorities (avoid fixed 500/600 so parallel workers don't collide)
const rule1 = createRoutingRuleData({ name: `Reorder Test Rule 1 ${Date.now()}` })
const rule2 = createRoutingRuleData({ name: `Reorder Test Rule 2 ${Date.now()}` })
createdRules.push(rule1.name, rule2.name)
await routingRulesPage.createRoutingRule(rule1)
await routingRulesPage.createRoutingRule(rule2)
// Change first rule's priority (edit to a new value to test reorder)
const newPriority = (rule1.priority! + 100) % 901
await routingRulesPage.editRoutingRule(rule1.name, { priority: newPriority })
// Verify priority was saved and displayed
const displayedPriority = await routingRulesPage.getRulePriority(rule1.name)
expect(displayedPriority).toBe(newPriority)
})
test('should create rule with virtual key scope', async ({ routingRulesPage }) => {
await routingRulesPage.createBtn.click()
await expect(routingRulesPage.sheet).toBeVisible()
const ruleName = `VK Scope Rule ${Date.now()}`
await routingRulesPage.nameInput.fill(ruleName)
// Try to set scope to virtual key
const scopeSelect = routingRulesPage.sheet.locator('[role="combobox"]').filter({ hasText: /Global|Scope/i }).first()
const scopeVisible = await scopeSelect.isVisible().catch(() => false)
if (scopeVisible) {
// Scope selection is available
await scopeSelect.click()
const vkOption = routingRulesPage.page.getByRole('option', { name: /Virtual Key/i })
const vkVisible = await vkOption.isVisible().catch(() => false)
if (vkVisible) {
await vkOption.click()
// Note: Would need to select a specific VK - for now just verify the option exists
}
}
// Cancel since we're just testing the UI
await routingRulesPage.cancelRule()
})
})
test.describe('Rule Builder and CEL Generation', () => {
test('should show CEL preview with "No rules defined" when empty', async ({ routingRulesPage }) => {
await routingRulesPage.createBtn.click()
await expect(routingRulesPage.sheet).toBeVisible()
await routingRulesPage.waitForSheetAnimation()
// Wait for rule builder to fully load
await routingRulesPage.waitForRuleBuilder()
// Get CEL expression - should show no rules message when empty
const celExpression = await routingRulesPage.getCelExpression()
expect(celExpression).toContain('No rules defined')
await routingRulesPage.cancelRule()
})
test('should add rule condition and update CEL preview', async ({ routingRulesPage }) => {
await routingRulesPage.createBtn.click()
await expect(routingRulesPage.sheet).toBeVisible()
await routingRulesPage.waitForSheetAnimation()
await routingRulesPage.waitForRuleBuilder()
// Fill required name
const ruleName = `CEL Test ${Date.now()}`
await routingRulesPage.nameInput.fill(ruleName)
createdRules.push(ruleName)
// Verify initial CEL is empty/no rules
const initialCel = await routingRulesPage.getCelExpression()
expect(initialCel).toContain('No rules defined')
// Add a rule condition
await routingRulesPage.clickAddRule()
// Wait for rule row to appear and CEL to update
await routingRulesPage.page.waitForTimeout(500)
// After adding a rule, CEL should no longer say "No rules defined"
// The default rule shows model == "" (empty model condition)
const celAfterAdd = await routingRulesPage.getCelExpression()
expect(celAfterAdd).not.toContain('No rules defined')
expect(celAfterAdd).toContain('model') // Default field is Model
await routingRulesPage.cancelRule()
})
test('should switch between AND and OR combinators', async ({ routingRulesPage }) => {
await routingRulesPage.createBtn.click()
await expect(routingRulesPage.sheet).toBeVisible()
await routingRulesPage.waitForSheetAnimation()
await routingRulesPage.waitForRuleBuilder()
// Fill required name
const ruleName = `CEL Combinator Test ${Date.now()}`
await routingRulesPage.nameInput.fill(ruleName)
createdRules.push(ruleName)
// Add two rule conditions to see the combinator in action
await routingRulesPage.clickAddRule()
await routingRulesPage.clickAddRule()
// Wait for rules to render
await routingRulesPage.page.waitForTimeout(500)
// Get CEL with default AND combinator
const celWithAnd = await routingRulesPage.getCelExpression()
// Default is AND - should have && operator
expect(celWithAnd).toContain('&&')
// Switch to OR
await routingRulesPage.setCombinator('or')
await routingRulesPage.page.waitForTimeout(300)
// Verify CEL now contains OR logic
const celWithOr = await routingRulesPage.getCelExpression()
expect(celWithOr).toContain('||')
await routingRulesPage.cancelRule()
})
test('should save rule with conditions successfully', async ({ routingRulesPage }) => {
const ruleName = `CEL Save Test ${Date.now()}`
createdRules.push(ruleName)
await routingRulesPage.createBtn.click()
await expect(routingRulesPage.sheet).toBeVisible()
await routingRulesPage.waitForSheetAnimation()
await routingRulesPage.waitForRuleBuilder()
// Fill name
await routingRulesPage.nameInput.fill(ruleName)
// Add a condition (default Model field with default operator)
await routingRulesPage.clickAddRule()
await routingRulesPage.page.waitForTimeout(500)
// Verify CEL was generated before saving
const celBeforeSave = await routingRulesPage.getCelExpression()
expect(celBeforeSave).not.toContain('No rules defined')
// Save the rule
await routingRulesPage.saveBtn.click()
await routingRulesPage.waitForSuccessToast()
await expect(routingRulesPage.sheet).not.toBeVisible({ timeout: 10000 })
// Verify rule was created
const exists = await routingRulesPage.ruleExists(ruleName)
expect(exists).toBe(true)
})
})
})

View File

@@ -0,0 +1,683 @@
import { Locator, Page, expect } from '@playwright/test'
import { BasePage } from '../../../core/pages/base.page'
import { fillSelect, waitForNetworkIdle } from '../../../core/utils/test-helpers'
/**
* Provider display names mapping - matches the UI's ProviderLabels
* Used for exact matching when selecting providers in dropdowns
*/
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
openai: 'OpenAI',
anthropic: 'Anthropic',
azure: 'Azure',
bedrock: 'AWS Bedrock',
cohere: 'Cohere',
vertex: 'Vertex AI',
mistral: 'Mistral AI',
ollama: 'Ollama',
groq: 'Groq',
gemini: 'Gemini',
openrouter: 'OpenRouter',
huggingface: 'HuggingFace',
cerebras: 'Cerebras',
perplexity: 'Perplexity',
elevenlabs: 'Elevenlabs',
parasail: 'Parasail',
sgl: 'SGLang',
nebius: 'Nebius Token Factory',
xai: 'xAI',
}
/**
* Escape regex special characters in a string
*/
function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* Budget configuration
*/
export interface BudgetConfig {
maxLimit: number
resetDuration?: string
}
/**
* Rate limit configuration
*/
export interface RateLimitConfig {
tokenMaxLimit?: number
tokenResetDuration?: string
requestMaxLimit?: number
requestResetDuration?: string
}
/**
* Provider configuration for virtual key
*/
export interface ProviderConfig {
provider: string
weight?: number
allowedModels?: string[]
keyIds?: string[]
budget?: BudgetConfig
rateLimit?: RateLimitConfig
}
/**
* Virtual key configuration
*/
export interface VirtualKeyConfig {
name: string
description?: string
isActive?: boolean
providerConfigs?: ProviderConfig[]
budget?: BudgetConfig
rateLimit?: RateLimitConfig
entityType?: 'none' | 'team' | 'customer'
teamId?: string
customerId?: string
}
/**
* Page object for the Virtual Keys page
*/
export class VirtualKeysPage extends BasePage {
// Main page elements
readonly createBtn: Locator
readonly table: Locator
readonly emptyState: Locator
// Virtual key sheet elements
readonly sheet: Locator
readonly nameInput: Locator
readonly descriptionInput: Locator
readonly isActiveToggle: Locator
readonly providerSelect: Locator
readonly saveBtn: Locator
readonly cancelBtn: Locator
constructor(page: Page) {
super(page)
// Main page elements
this.createBtn = page.getByTestId('create-vk-btn')
this.table = page.getByTestId('vk-table')
this.emptyState = page.getByTestId('virtual-keys-empty-state')
// Virtual key sheet elements
this.sheet = page.getByTestId('vk-sheet')
this.nameInput = page.getByTestId('vk-name-input')
this.descriptionInput = page.getByTestId('vk-description-input')
this.isActiveToggle = page.getByTestId('vk-is-active-toggle')
this.providerSelect = page.getByTestId('vk-provider-select')
this.saveBtn = page.getByTestId('vk-save-btn')
this.cancelBtn = page.getByTestId('vk-cancel-btn')
}
/**
* Navigate to the virtual keys page
*/
async goto(): Promise<void> {
await this.page.goto('/workspace/virtual-keys')
await waitForNetworkIdle(this.page)
}
/**
* Get virtual key row locator by name
*/
getVirtualKeyRow(name: string): Locator {
return this.page.getByTestId(`vk-row-${name}`)
}
/**
* Check if a virtual key exists in the table
*/
async virtualKeyExists(name: string): Promise<boolean> {
const row = this.getVirtualKeyRow(name)
// Use count() to check if element exists in DOM (doesn't require visibility)
const count = await row.count()
return count > 0
}
/**
* Check if the key value is revealed (visible) or masked in the table.
* When masked, the display shows bullets (•); when revealed, it shows the full key.
*/
async isKeyRevealed(name: string): Promise<boolean> {
const row = this.getVirtualKeyRow(name)
const keyCell = row.getByTestId('vk-key-value')
await keyCell.waitFor({ state: 'visible', timeout: 5000 })
const text = (await keyCell.textContent())?.trim() ?? ''
// Masked keys contain bullet character; revealed keys do not
return text.length > 0 && !text.includes('•')
}
/**
* Create a new virtual key
*/
async createVirtualKey(config: VirtualKeyConfig): Promise<void> {
// Click create button
await this.createBtn.click()
// Wait for sheet to appear and animation to complete
await expect(this.sheet).toBeVisible()
await this.waitForSheetAnimation()
// Fill basic information using keyboard navigation
await this.nameInput.focus()
await this.page.keyboard.type(config.name)
if (config.description) {
await this.page.keyboard.press('Tab') // Move to description
await this.page.keyboard.type(config.description)
}
// Set active state if specified (default is true, so only toggle if we want inactive)
if (config.isActive === false) {
await this.isActiveToggle.focus()
await this.page.keyboard.press('Space') // Toggle the switch
}
// Add provider configurations
if (config.providerConfigs && config.providerConfigs.length > 0) {
for (const providerConfig of config.providerConfigs) {
await this.addProviderConfig(providerConfig)
}
}
// Set budget if specified
if (config.budget) {
await this.setBudget(config.budget)
}
// Set rate limits if specified
if (config.rateLimit) {
await this.setRateLimit(config.rateLimit)
}
// Set entity assignment if specified
if (config.entityType && config.entityType !== 'none') {
await this.setEntityAssignment(config.entityType, config.teamId, config.customerId)
}
// Save the virtual key by clicking the save button
await this.saveBtn.click()
// Wait for success toast
await this.waitForSuccessToast()
// Wait for toasts to disappear before continuing
await this.dismissToasts()
// Wait for sheet to close
await expect(this.sheet).not.toBeVisible({ timeout: 5000 })
// Wait for the new row to appear in the table (ensures table has refreshed)
const row = this.getVirtualKeyRow(config.name)
await row.waitFor({ state: 'attached', timeout: 10000 })
await row.scrollIntoViewIfNeeded()
}
/**
* Add a provider configuration to the virtual key form
*/
private async addProviderConfig(config: ProviderConfig): Promise<void> {
// Click the provider select dropdown
await this.providerSelect.click()
// Wait for dropdown content
await this.page.waitForSelector('[role="listbox"]', { timeout: 5000 })
// Get display name - use mapping for known providers, otherwise use exact name
const displayName = PROVIDER_DISPLAY_NAMES[config.provider.toLowerCase()] || config.provider
// First try exact match for base providers (e.g., "OpenAI", "Anthropic")
let option = this.page.getByRole('option', { name: displayName, exact: true })
if (await option.count() === 0) {
// Fallback: try partial match for custom providers (contains provider name)
// This handles custom providers like "test-anthropic-1234567890"
option = this.page.getByRole('option').filter({
hasText: new RegExp(escapeRegExp(config.provider), 'i')
}).first()
}
// Verify we found a matching option
const optionCount = await option.count()
if (optionCount === 0) {
throw new Error(`No provider option found matching "${config.provider}" (display name: "${displayName}")`)
}
await option.click()
// Wait for dropdown to close after selection
await this.page.waitForSelector('[role="listbox"]', { state: 'hidden', timeout: 5000 })
}
/**
* Set budget configuration in the form
*/
private async setBudget(budget: BudgetConfig): Promise<void> {
// Find budget max limit input and fill (fill() clears and sets atomically)
const budgetInput = this.page.locator('#budgetMaxLimit')
await budgetInput.fill(String(budget.maxLimit))
// Set reset duration if specified - skip for now as default is fine
// The reset duration select is complex and default "Monthly" is usually correct
}
/**
* Set rate limit configuration in the form
*/
private async setRateLimit(rateLimit: RateLimitConfig): Promise<void> {
// Set token limits (fill() clears and sets atomically)
if (rateLimit.tokenMaxLimit !== undefined) {
const tokenInput = this.page.locator('#tokenMaxLimit')
await tokenInput.fill(String(rateLimit.tokenMaxLimit))
}
// Set request limits (fill() clears and sets atomically)
if (rateLimit.requestMaxLimit !== undefined) {
const requestInput = this.page.locator('#requestMaxLimit')
await requestInput.fill(String(rateLimit.requestMaxLimit))
}
}
/**
* Set entity assignment (team or customer)
*/
private async setEntityAssignment(
entityType: 'team' | 'customer',
teamId?: string,
customerId?: string
): Promise<void> {
// Find and click entity type select
const entityTypeSelect = this.page.locator('[data-testid="vk-entity-type-select"]')
if (await entityTypeSelect.isVisible()) {
await fillSelect(
this.page,
'[data-testid="vk-entity-type-select"]',
entityType === 'team' ? 'Assign to Team' : 'Assign to Customer'
)
// Select team or customer
if (entityType === 'team' && teamId) {
const teamSelect = this.page.locator('[data-testid="vk-team-select"]')
if (await teamSelect.isVisible()) {
await fillSelect(this.page, '[data-testid="vk-team-select"]', teamId)
}
} else if (entityType === 'customer' && customerId) {
const customerSelect = this.page.locator('[data-testid="vk-customer-select"]')
if (await customerSelect.isVisible()) {
await fillSelect(this.page, '[data-testid="vk-customer-select"]', customerId)
}
}
}
}
/**
* Edit an existing virtual key
*/
async editVirtualKey(name: string, updates: Partial<VirtualKeyConfig>): Promise<void> {
// Wait for any existing toasts to disappear
await this.forceCloseToasts()
// Find and click the edit button using data-testid
const editBtn = this.page.getByTestId(`vk-edit-btn-${name}`)
await editBtn.waitFor({ state: 'visible', timeout: 10000 })
await editBtn.scrollIntoViewIfNeeded()
await editBtn.click()
// Wait for sheet to appear and animation to complete
await expect(this.sheet).toBeVisible()
await this.waitForSheetAnimation()
// Update name using clear() and fill() for cross-platform compatibility
if (updates.name) {
await this.nameInput.clear()
await this.nameInput.fill(updates.name)
}
// Update description using clear() and fill() for cross-platform compatibility
if (updates.description !== undefined) {
await this.descriptionInput.clear()
if (updates.description) {
await this.descriptionInput.fill(updates.description)
}
}
// Update toggle using click() and data-state attribute for reliability
if (updates.isActive !== undefined) {
// Check current state using data-state attribute (Radix Switch)
const isCurrentlyChecked = await this.isActiveToggle.getAttribute('data-state') === 'checked'
if (isCurrentlyChecked !== updates.isActive) {
await this.isActiveToggle.click()
}
}
if (updates.budget) {
await this.setBudget(updates.budget)
}
if (updates.rateLimit) {
await this.setRateLimit(updates.rateLimit)
}
// Save changes by clicking the save button
await this.saveBtn.click()
// Wait for success toast
await this.waitForSuccessToast()
// Wait for toasts to disappear before continuing
await this.dismissToasts()
// Check if sheet is still visible - it may not auto-close
const isSheetVisible = await this.sheet.isVisible().catch(() => false)
if (isSheetVisible) {
// Try clicking the close button or pressing Escape
const closeBtn = this.sheet.locator('button[aria-label*="close"], button:has(svg.lucide-x)').first()
if (await closeBtn.isVisible().catch(() => false)) {
await closeBtn.click()
} else {
await this.page.keyboard.press('Escape')
}
await expect(this.sheet).not.toBeVisible({ timeout: 5000 })
}
}
/**
* Poll until the virtual key row disappears from the table (e.g. after delete or refetch).
* Polls so we don't rely on a stale locator.
*/
async waitForVirtualKeyGone(name: string, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if ((await this.getVirtualKeyRow(name).count()) === 0) return true
await this.page.waitForTimeout(500)
}
return false
}
async deleteVirtualKey(name: string, options?: { requireToast?: boolean }): Promise<void> {
// Check if virtual key exists first
const exists = await this.virtualKeyExists(name)
if (!exists) {
// Already deleted or doesn't exist, nothing to do
return
}
// Wait for any existing toasts to disappear
await this.forceCloseToasts()
// Find the delete button using data-testid (scroll row into view in case table just loaded)
const row = this.getVirtualKeyRow(name)
await row.scrollIntoViewIfNeeded().catch(() => {})
await this.page.waitForTimeout(300)
const deleteBtn = this.page.getByTestId(`vk-delete-btn-${name}`)
// Check if button exists; if not, give table a moment and re-check once
let btnCount = await deleteBtn.count()
if (btnCount === 0) {
await this.page.waitForTimeout(800)
btnCount = await deleteBtn.count()
}
if (btnCount === 0) {
const stillExists = await this.virtualKeyExists(name)
if (!stillExists) return
throw new Error(`Delete button not found for virtual key: ${name}`)
}
// Check if button is disabled
const isDisabled = await deleteBtn.isDisabled().catch(() => false)
if (isDisabled) {
throw new Error(`Delete button is disabled for virtual key: ${name} (likely due to RBAC permissions)`)
}
await deleteBtn.waitFor({ state: 'visible', timeout: 10000 })
await deleteBtn.scrollIntoViewIfNeeded()
await deleteBtn.click()
// Wait for confirmation dialog and confirm deletion (match "Delete" or "Deleting...")
const confirmDialog = this.page.locator('[role="alertdialog"]')
await confirmDialog.waitFor({ state: 'visible', timeout: 5000 })
const confirmBtn = confirmDialog.getByRole('button', { name: /Delete/i })
await confirmBtn.waitFor({ state: 'visible', timeout: 2000 })
// Wait for DELETE API response
const deleteResponsePromise = this.page.waitForResponse(
(response) => {
const url = response.url()
return url.includes('/api/virtual-keys/') && response.request().method() === 'DELETE'
},
{ timeout: 15000 }
)
await confirmBtn.click()
const deleteResponse = await deleteResponsePromise.catch((err) => {
console.warn(`[deleteVirtualKey] No DELETE response captured for "${name}": ${err}`)
return null
})
if (deleteResponse && !deleteResponse.ok()) {
console.warn(`[deleteVirtualKey] DELETE responded with ${deleteResponse.status()} for "${name}"`)
}
// Wait for table to refetch and row to disappear (poll fresh locator; avoid stale row reference)
const gone = await this.waitForVirtualKeyGone(name, 20000)
if (!gone) {
throw new Error(`Virtual key "${name}" still visible after delete`)
}
// Optionally wait for success toast (skip in cleanup to avoid false failures)
if (options?.requireToast !== false) {
await this.getToast().waitFor({ state: 'visible', timeout: 5000 }).catch(() => {})
}
await this.dismissToasts()
}
/**
* Click on a virtual key to view/edit details (opens via edit button)
*/
async viewVirtualKey(name: string): Promise<void> {
// Wait for any existing toasts to disappear
await this.forceCloseToasts()
// Use the edit button to open the detail sheet
const editBtn = this.page.getByTestId(`vk-edit-btn-${name}`)
await editBtn.waitFor({ state: 'visible', timeout: 10000 })
await editBtn.scrollIntoViewIfNeeded()
await editBtn.click()
// Wait for detail sheet to appear
await expect(this.sheet).toBeVisible({ timeout: 5000 })
}
/**
* Get the count of virtual keys in the table
*/
async getVirtualKeyCount(): Promise<number> {
const rows = this.table.locator('tbody tr')
const count = await rows.count()
if (count === 0) {
return 0
}
// Check if it's the empty state row
const firstRowText = await rows.first().textContent()
if (firstRowText?.includes('No virtual keys found')) {
return 0
}
return count
}
/**
* Copy virtual key value to clipboard
*/
async copyVirtualKeyValue(name: string): Promise<void> {
// Find and click the copy button using data-testid
const copyBtn = this.page.getByTestId(`vk-copy-btn-${name}`)
await copyBtn.waitFor({ state: 'attached', timeout: 10000 })
await copyBtn.scrollIntoViewIfNeeded()
await copyBtn.click()
await this.waitForSuccessToast('Copied')
}
/**
* Toggle key visibility (show/hide)
*/
async toggleKeyVisibility(name: string): Promise<void> {
// Find and click the visibility toggle button using data-testid
const toggleBtn = this.page.getByTestId(`vk-visibility-btn-${name}`)
await toggleBtn.waitFor({ state: 'attached', timeout: 10000 })
await toggleBtn.scrollIntoViewIfNeeded()
await toggleBtn.click()
}
/**
* Close any open sheet/dialog
*/
async closeSheet(): Promise<void> {
const isSheetVisible = await this.sheet.isVisible().catch(() => false)
if (isSheetVisible) {
// We have to click on the close button to close the sheet
const closeBtn = this.sheet.locator('button[aria-label*="close"], button:has(svg.lucide-x)').first()
if (await closeBtn.isVisible().catch(() => false)) {
await closeBtn.click()
} else {
await this.page.keyboard.press('Escape')
}
await expect(this.sheet).not.toBeVisible({ timeout: 5000 }).catch(() => {})
}
}
/**
* Get all virtual key names from the table
*/
async getAllVirtualKeyNames(): Promise<string[]> {
const names: string[] = []
const count = await this.getVirtualKeyCount()
if (count === 0) return names
// Find all delete buttons which have the VK name in their test-id
const deleteButtons = this.page.locator('[data-testid^="vk-delete-btn-"]')
const buttonCount = await deleteButtons.count()
for (let i = 0; i < buttonCount; i++) {
const testId = await deleteButtons.nth(i).getAttribute('data-testid')
if (testId) {
// Extract name from test-id: "vk-delete-btn-{name}"
const name = testId.replace('vk-delete-btn-', '')
names.push(name)
}
}
return names
}
/**
* Clean up all virtual keys (delete all)
*/
async cleanupAllVirtualKeys(): Promise<void> {
// First close any open sheet
await this.closeSheet()
// Wait for any toasts to clear
await this.dismissToasts()
// Keep trying until no more VKs exist
let attempts = 0
const maxAttempts = 10 // Prevent infinite loops
while (attempts < maxAttempts) {
// Get current VK names (refresh the list each iteration)
const names = await this.getAllVirtualKeyNames()
if (names.length === 0) {
// No more VKs to delete
break
}
// Delete each one
for (const name of names) {
try {
// Check if VK still exists before trying to delete
const exists = await this.virtualKeyExists(name)
if (!exists) {
// Already deleted, skip
continue
}
// Make sure sheet is closed before each delete
await this.closeSheet()
await this.deleteVirtualKey(name)
// Wait a bit for table to refresh
await this.page.waitForTimeout(500)
} catch (error) {
// If delete fails, try to close sheet and continue
await this.closeSheet()
const errorMsg = error instanceof Error ? error.message : String(error)
console.log(`Failed to delete virtual key: ${name} - ${errorMsg}`)
// Continue with next VK
}
}
attempts++
// Wait a bit before next iteration to allow table to refresh
await this.page.waitForTimeout(1000)
}
if (attempts >= maxAttempts) {
const remainingNames = await this.getAllVirtualKeyNames()
if (remainingNames.length > 0) {
console.log(`Warning: Could not delete all virtual keys after ${maxAttempts} attempts. Remaining: ${remainingNames.join(', ')}`)
}
}
}
/**
* Clean up specific virtual keys by name
*/
async cleanupVirtualKeys(names: string[]): Promise<void> {
if (names.length === 0) return
// Ensure we're on the virtual keys list with a fresh load so table is ready
await this.goto()
await this.closeSheet()
await this.dismissToasts()
await this.table.waitFor({ state: 'visible', timeout: 10000 }).catch(() => {})
await this.page.waitForTimeout(500)
for (const name of names) {
const tryDelete = async (): Promise<void> => {
const exists = await this.virtualKeyExists(name)
if (!exists) return
await this.closeSheet()
await this.deleteVirtualKey(name, { requireToast: false })
}
try {
await tryDelete()
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
console.error(`[CLEANUP ERROR] Failed to delete virtual key: ${name} - ${errorMsg}`)
await this.closeSheet()
await this.page.waitForTimeout(1000)
try {
await tryDelete()
} catch (retryError) {
const retryMsg = retryError instanceof Error ? retryError.message : String(retryError)
console.error(`[CLEANUP ERROR] Retry failed for virtual key: ${name} - ${retryMsg}`)
}
}
}
}
}

View File

@@ -0,0 +1,178 @@
import { VirtualKeyConfig, ProviderConfig, BudgetConfig, RateLimitConfig } from './pages/virtual-keys.page'
/**
* Factory function to create virtual key test data
*/
export function createVirtualKeyData(overrides: Partial<VirtualKeyConfig> = {}): VirtualKeyConfig {
const timestamp = Date.now()
return {
name: `Test VK ${timestamp}`,
description: 'E2E test virtual key',
isActive: true,
providerConfigs: [],
...overrides,
}
}
/**
* Factory function to create virtual key with single provider
*/
export function createVirtualKeyWithProvider(
provider: string,
vkOverrides: Partial<VirtualKeyConfig> = {}
): VirtualKeyConfig {
const timestamp = Date.now()
return {
name: `Test VK ${provider} ${timestamp}`,
description: `Virtual key for ${provider}`,
isActive: true,
providerConfigs: [
{
provider,
weight: 1.0,
allowedModels: ['*'],
keyIds: ['*'],
},
],
...vkOverrides,
}
}
/**
* Factory function to create virtual key with budget
*/
export function createVirtualKeyWithBudget(
budget: BudgetConfig,
vkOverrides: Partial<VirtualKeyConfig> = {}
): VirtualKeyConfig {
const timestamp = Date.now()
return {
name: `Test VK Budget ${timestamp}`,
description: 'Virtual key with budget configuration',
isActive: true,
budget,
...vkOverrides,
}
}
/**
* Factory function to create virtual key with rate limits
*/
export function createVirtualKeyWithRateLimit(
rateLimit: RateLimitConfig,
vkOverrides: Partial<VirtualKeyConfig> = {}
): VirtualKeyConfig {
const timestamp = Date.now()
return {
name: `Test VK RateLimit ${timestamp}`,
description: 'Virtual key with rate limit configuration',
isActive: true,
rateLimit,
...vkOverrides,
}
}
/**
* Factory function to create virtual key with multiple providers
*/
export function createVirtualKeyWithMultipleProviders(
providers: string[],
vkOverrides: Partial<VirtualKeyConfig> = {}
): VirtualKeyConfig {
const timestamp = Date.now()
const weight = 1.0 / providers.length
return {
name: `Test VK Multi ${timestamp}`,
description: `Virtual key with ${providers.length} providers`,
isActive: true,
providerConfigs: providers.map((provider) => ({
provider,
weight,
allowedModels: ['*'],
keyIds: ['*'],
})),
...vkOverrides,
}
}
/**
* Factory function to create provider config
*/
export function createProviderConfig(overrides: Partial<ProviderConfig> = {}): ProviderConfig {
return {
provider: 'openai',
weight: 1.0,
allowedModels: ['*'],
keyIds: ['*'],
...overrides,
}
}
/**
* Sample budget configurations
*/
export const SAMPLE_BUDGETS: Record<string, BudgetConfig> = {
small: {
maxLimit: 10,
resetDuration: '1M',
},
medium: {
maxLimit: 100,
resetDuration: '1M',
},
large: {
maxLimit: 1000,
resetDuration: '1M',
},
daily: {
maxLimit: 50,
resetDuration: '1d',
},
weekly: {
maxLimit: 200,
resetDuration: '1w',
},
}
/**
* Sample rate limit configurations
*/
export const SAMPLE_RATE_LIMITS: Record<string, RateLimitConfig> = {
conservative: {
tokenMaxLimit: 10000,
tokenResetDuration: '1h',
requestMaxLimit: 100,
requestResetDuration: '1h',
},
moderate: {
tokenMaxLimit: 100000,
tokenResetDuration: '1h',
requestMaxLimit: 1000,
requestResetDuration: '1h',
},
aggressive: {
tokenMaxLimit: 1000000,
tokenResetDuration: '1h',
requestMaxLimit: 10000,
requestResetDuration: '1h',
},
tokenOnly: {
tokenMaxLimit: 50000,
tokenResetDuration: '1h',
},
requestOnly: {
requestMaxLimit: 500,
requestResetDuration: '1h',
},
}
/**
* Reset duration options
*/
export const RESET_DURATIONS = [
{ label: '1 Hour', value: '1h' },
{ label: '1 Day', value: '1d' },
{ label: '1 Week', value: '1w' },
{ label: '1 Month', value: '1M' },
] as const

View File

@@ -0,0 +1,557 @@
import { expect, test } from '../../core/fixtures/base.fixture'
import {
createVirtualKeyData,
createVirtualKeyWithBudget,
createVirtualKeyWithMultipleProviders,
createVirtualKeyWithProvider,
createVirtualKeyWithRateLimit,
SAMPLE_BUDGETS,
SAMPLE_RATE_LIMITS,
} from './virtual-keys.data'
// Track created VKs for cleanup
const createdVKs: string[] = []
test.describe('Virtual Keys', () => {
test.beforeEach(async ({ virtualKeysPage }) => {
await virtualKeysPage.goto()
})
test.afterEach(async ({ virtualKeysPage }) => {
// Close any open sheets first
await virtualKeysPage.closeSheet()
// Clean up all tracked VKs
if (createdVKs.length > 0) {
await virtualKeysPage.cleanupVirtualKeys([...createdVKs])
createdVKs.length = 0 // Clear the array
}
})
test.describe('Virtual Key Creation', () => {
test('should display create virtual key button', async ({ virtualKeysPage }) => {
await expect(virtualKeysPage.createBtn).toBeVisible()
})
test('should open virtual key creation sheet', async ({ virtualKeysPage }) => {
await virtualKeysPage.createBtn.click()
// Verify sheet is visible
await expect(virtualKeysPage.sheet).toBeVisible()
// Verify form fields are present
await expect(virtualKeysPage.nameInput).toBeVisible()
await expect(virtualKeysPage.descriptionInput).toBeVisible()
})
test('should create a basic virtual key', async ({ virtualKeysPage }) => {
const vkData = createVirtualKeyData({
name: `Basic VK ${Date.now()}`,
description: 'A basic virtual key for testing',
})
createdVKs.push(vkData.name)
await virtualKeysPage.createVirtualKey(vkData)
// Verify virtual key appears in table
const vkExists = await virtualKeysPage.virtualKeyExists(vkData.name)
expect(vkExists).toBe(true)
})
test('should create virtual key with single provider', async ({ virtualKeysPage }) => {
const vkData = createVirtualKeyWithProvider('openai', {
name: `OpenAI VK ${Date.now()}`,
})
createdVKs.push(vkData.name)
await virtualKeysPage.createVirtualKey(vkData)
const vkExists = await virtualKeysPage.virtualKeyExists(vkData.name)
expect(vkExists).toBe(true)
})
test('should create inactive virtual key', async ({ virtualKeysPage }) => {
const vkData = createVirtualKeyData({
name: `Inactive VK ${Date.now()}`,
isActive: false,
})
createdVKs.push(vkData.name)
await virtualKeysPage.createVirtualKey(vkData)
const vkExists = await virtualKeysPage.virtualKeyExists(vkData.name)
expect(vkExists).toBe(true)
})
test('should cancel virtual key creation', async ({ virtualKeysPage }) => {
await virtualKeysPage.createBtn.click()
await expect(virtualKeysPage.sheet).toBeVisible()
// Fill some data
const testName = `Cancelled VK ${Date.now()}`
await virtualKeysPage.nameInput.fill(testName)
// Cancel
await virtualKeysPage.cancelBtn.click()
// Sheet should close
await expect(virtualKeysPage.sheet).not.toBeVisible()
// Virtual key should not exist
const vkExists = await virtualKeysPage.virtualKeyExists(testName)
expect(vkExists).toBe(false)
})
})
test.describe('Virtual Key with Budget', () => {
test('should create virtual key with small budget', async ({ virtualKeysPage }) => {
const vkData = createVirtualKeyWithBudget(SAMPLE_BUDGETS.small, {
name: `Small Budget VK ${Date.now()}`,
})
createdVKs.push(vkData.name)
await virtualKeysPage.createVirtualKey(vkData)
const vkExists = await virtualKeysPage.virtualKeyExists(vkData.name)
expect(vkExists).toBe(true)
// Verify budget was saved correctly
await virtualKeysPage.viewVirtualKey(vkData.name)
await virtualKeysPage.waitForSheetAnimation()
const budgetInput = virtualKeysPage.page.locator('#budgetMaxLimit')
await expect(budgetInput).toHaveValue(String(SAMPLE_BUDGETS.small.maxLimit))
await virtualKeysPage.closeSheet()
})
test('should create virtual key with medium budget', async ({ virtualKeysPage }) => {
const vkData = createVirtualKeyWithBudget(SAMPLE_BUDGETS.medium, {
name: `Medium Budget VK ${Date.now()}`,
})
createdVKs.push(vkData.name)
await virtualKeysPage.createVirtualKey(vkData)
const vkExists = await virtualKeysPage.virtualKeyExists(vkData.name)
expect(vkExists).toBe(true)
})
test('should create virtual key with daily budget', async ({ virtualKeysPage }) => {
const vkData = createVirtualKeyWithBudget(SAMPLE_BUDGETS.daily, {
name: `Daily Budget VK ${Date.now()}`,
})
createdVKs.push(vkData.name)
await virtualKeysPage.createVirtualKey(vkData)
const vkExists = await virtualKeysPage.virtualKeyExists(vkData.name)
expect(vkExists).toBe(true)
})
})
test.describe('Virtual Key with Rate Limits', () => {
test('should create virtual key with token rate limit', async ({ virtualKeysPage }) => {
const vkData = createVirtualKeyWithRateLimit(SAMPLE_RATE_LIMITS.tokenOnly, {
name: `Token Limit VK ${Date.now()}`,
})
createdVKs.push(vkData.name)
await virtualKeysPage.createVirtualKey(vkData)
const vkExists = await virtualKeysPage.virtualKeyExists(vkData.name)
expect(vkExists).toBe(true)
// Verify rate limit was saved correctly
await virtualKeysPage.viewVirtualKey(vkData.name)
await virtualKeysPage.waitForSheetAnimation()
const tokenLimitInput = virtualKeysPage.page.locator('#tokenMaxLimit')
await expect(tokenLimitInput).toHaveValue(String(SAMPLE_RATE_LIMITS.tokenOnly.tokenMaxLimit))
await virtualKeysPage.closeSheet()
})
test('should create virtual key with request rate limit', async ({ virtualKeysPage }) => {
const vkData = createVirtualKeyWithRateLimit(SAMPLE_RATE_LIMITS.requestOnly, {
name: `Request Limit VK ${Date.now()}`,
})
createdVKs.push(vkData.name)
await virtualKeysPage.createVirtualKey(vkData)
const vkExists = await virtualKeysPage.virtualKeyExists(vkData.name)
expect(vkExists).toBe(true)
})
test('should create virtual key with combined rate limits', async ({ virtualKeysPage }) => {
const vkData = createVirtualKeyWithRateLimit(SAMPLE_RATE_LIMITS.conservative, {
name: `Combined Limits VK ${Date.now()}`,
})
createdVKs.push(vkData.name)
await virtualKeysPage.createVirtualKey(vkData)
const vkExists = await virtualKeysPage.virtualKeyExists(vkData.name)
expect(vkExists).toBe(true)
})
})
test.describe('Virtual Key with Multiple Providers', () => {
test('should create virtual key with two providers', async ({ virtualKeysPage }) => {
const vkData = createVirtualKeyWithMultipleProviders(['openai', 'anthropic'], {
name: `Multi Provider VK ${Date.now()}`,
})
createdVKs.push(vkData.name)
await virtualKeysPage.createVirtualKey(vkData)
const vkExists = await virtualKeysPage.virtualKeyExists(vkData.name)
expect(vkExists).toBe(true)
})
})
test.describe('Virtual Key with Budget and Rate Limits', () => {
test('should create virtual key with budget and rate limits', async ({ virtualKeysPage }) => {
const vkData = createVirtualKeyData({
name: `Full Config VK ${Date.now()}`,
description: 'Virtual key with all configurations',
isActive: true,
budget: SAMPLE_BUDGETS.medium,
rateLimit: SAMPLE_RATE_LIMITS.moderate,
})
createdVKs.push(vkData.name)
await virtualKeysPage.createVirtualKey(vkData)
const vkExists = await virtualKeysPage.virtualKeyExists(vkData.name)
expect(vkExists).toBe(true)
})
})
})
// Track created VKs for management tests
const managementVKs: string[] = []
test.describe('Virtual Key Management', () => {
test.beforeEach(async ({ virtualKeysPage }) => {
await virtualKeysPage.goto()
})
test.afterEach(async ({ virtualKeysPage }) => {
// Close any open sheets first
await virtualKeysPage.closeSheet()
// Clean up all tracked VKs
if (managementVKs.length > 0) {
await virtualKeysPage.cleanupVirtualKeys([...managementVKs])
managementVKs.length = 0
}
})
test('should edit virtual key name', async ({ virtualKeysPage }) => {
// First create a virtual key
const originalName = `Edit Test VK ${Date.now()}`
const vkData = createVirtualKeyData({ name: originalName })
await virtualKeysPage.createVirtualKey(vkData)
// Now edit it
const updatedName = `${originalName} Updated`
managementVKs.push(updatedName) // Track the updated name for cleanup
await virtualKeysPage.editVirtualKey(originalName, {
name: updatedName,
})
// Verify updated name exists
const vkExists = await virtualKeysPage.virtualKeyExists(updatedName)
expect(vkExists).toBe(true)
})
test('should edit virtual key description', async ({ virtualKeysPage }) => {
const vkName = `Desc Edit VK ${Date.now()}`
const vkData = createVirtualKeyData({
name: vkName,
description: 'Original description',
})
managementVKs.push(vkName)
await virtualKeysPage.createVirtualKey(vkData)
await virtualKeysPage.editVirtualKey(vkName, {
description: 'Updated description for testing',
})
// Virtual key should still exist
const vkExists = await virtualKeysPage.virtualKeyExists(vkName)
expect(vkExists).toBe(true)
})
test('should toggle virtual key active state', async ({ virtualKeysPage }) => {
const vkName = `Toggle Active VK ${Date.now()}`
const vkData = createVirtualKeyData({
name: vkName,
isActive: true,
})
managementVKs.push(vkName)
await virtualKeysPage.createVirtualKey(vkData)
// Toggle to inactive
await virtualKeysPage.editVirtualKey(vkName, {
isActive: false,
})
const vkExists = await virtualKeysPage.virtualKeyExists(vkName)
expect(vkExists).toBe(true)
})
test('should delete virtual key', async ({ virtualKeysPage }) => {
const vkName = `Delete Test VK ${Date.now()}`
const vkData = createVirtualKeyData({ name: vkName })
await virtualKeysPage.createVirtualKey(vkData)
// Verify it exists
let vkExists = await virtualKeysPage.virtualKeyExists(vkName)
expect(vkExists).toBe(true)
// Delete it (this is the test - no need to track for cleanup)
await virtualKeysPage.deleteVirtualKey(vkName)
// Verify it's gone
vkExists = await virtualKeysPage.virtualKeyExists(vkName)
expect(vkExists).toBe(false)
})
test('should view virtual key details', async ({ virtualKeysPage }) => {
const vkName = `View Details VK ${Date.now()}`
const vkData = createVirtualKeyData({
name: vkName,
description: 'Detailed description for viewing',
})
managementVKs.push(vkName)
await virtualKeysPage.createVirtualKey(vkData)
// Click to view details
await virtualKeysPage.viewVirtualKey(vkName)
// Detail sheet should be visible with correct content
await expect(virtualKeysPage.sheet).toBeVisible()
await expect(virtualKeysPage.nameInput).toHaveValue(vkName)
await expect(virtualKeysPage.descriptionInput).toHaveValue('Detailed description for viewing')
// Close the sheet (will be handled by afterEach if not)
await virtualKeysPage.closeSheet()
})
test('should copy virtual key value', async ({ virtualKeysPage }) => {
const vkName = `Copy Value VK ${Date.now()}`
const vkData = createVirtualKeyData({ name: vkName })
managementVKs.push(vkName)
await virtualKeysPage.createVirtualKey(vkData)
// Copy the key value - method waits for success toast
await virtualKeysPage.copyVirtualKeyValue(vkName)
// Verify copy succeeded: row still exists and key is intact
const vkExists = await virtualKeysPage.virtualKeyExists(vkName)
expect(vkExists).toBe(true)
})
test('should toggle key visibility', async ({ virtualKeysPage }) => {
const vkName = `Toggle Visibility VK ${Date.now()}`
const vkData = createVirtualKeyData({ name: vkName })
managementVKs.push(vkName)
await virtualKeysPage.createVirtualKey(vkData)
// Initially key is masked
let isRevealed = await virtualKeysPage.isKeyRevealed(vkName)
expect(isRevealed).toBe(false)
// Toggle visibility (show key)
await virtualKeysPage.toggleKeyVisibility(vkName)
isRevealed = await virtualKeysPage.isKeyRevealed(vkName)
expect(isRevealed).toBe(true)
// Toggle again (hide key)
await virtualKeysPage.toggleKeyVisibility(vkName)
isRevealed = await virtualKeysPage.isKeyRevealed(vkName)
expect(isRevealed).toBe(false)
})
})
// Track VKs created in Virtual Keys Table tests for cleanup
const tableTestVKs: string[] = []
test.describe('Virtual Keys Table', () => {
test.beforeEach(async ({ virtualKeysPage }) => {
await virtualKeysPage.goto()
})
test.afterEach(async ({ virtualKeysPage }) => {
await virtualKeysPage.closeSheet()
if (tableTestVKs.length > 0) {
await virtualKeysPage.cleanupVirtualKeys([...tableTestVKs])
tableTestVKs.length = 0
}
})
test('should display virtual keys table', async ({ virtualKeysPage }) => {
await virtualKeysPage.page.getByRole('heading', { name: /Virtual Keys/i }).or(virtualKeysPage.emptyState).first().waitFor({ state: 'visible', timeout: 10000 })
const hadTable = await virtualKeysPage.table.isVisible().catch(() => false)
if (!hadTable) {
await expect(virtualKeysPage.emptyState).toBeVisible({ timeout: 10000 })
} else {
await expect(virtualKeysPage.table).toBeVisible({ timeout: 10000 })
}
const vkData = createVirtualKeyData({ name: `Table test VK ${Date.now()}`, description: 'For table display test' })
tableTestVKs.push(vkData.name)
await virtualKeysPage.createVirtualKey(vkData)
await expect(virtualKeysPage.table).toBeVisible({ timeout: 10000 })
await expect(virtualKeysPage.table.locator('th', { hasText: 'Name' })).toBeVisible()
await expect(virtualKeysPage.table.locator('th', { hasText: 'Key' })).toBeVisible()
})
test('should show empty state when no virtual keys', async ({ virtualKeysPage }) => {
await virtualKeysPage.page.getByRole('heading', { name: /Virtual Keys/i }).or(virtualKeysPage.emptyState).first().waitFor({ state: 'visible', timeout: 10000 })
const tableVisible = await virtualKeysPage.table.isVisible().catch(() => false)
if (tableVisible) {
test.skip(true, 'Pre-existing virtual keys found; empty-state assertion requires isolated data.')
return
}
await expect(virtualKeysPage.emptyState).toBeVisible({ timeout: 10000 })
})
})
test.describe('Form Validation', () => {
test.beforeEach(async ({ virtualKeysPage }) => {
await virtualKeysPage.goto()
})
test.afterEach(async ({ virtualKeysPage }) => {
// Close any open sheets
await virtualKeysPage.closeSheet()
})
test('should require name for virtual key', async ({ virtualKeysPage }) => {
await virtualKeysPage.dismissToasts()
await virtualKeysPage.createBtn.click()
await expect(virtualKeysPage.sheet).toBeVisible()
// Wait for sheet animation to complete
await virtualKeysPage.waitForSheetAnimation()
// Save button should be disabled when name is empty
await expect(virtualKeysPage.saveBtn).toBeDisabled()
})
test('should accept valid budget values', async ({ virtualKeysPage }) => {
await virtualKeysPage.dismissToasts()
await virtualKeysPage.createBtn.click()
await expect(virtualKeysPage.sheet).toBeVisible()
// Wait for sheet animation to complete
await virtualKeysPage.waitForSheetAnimation()
// Fill name (required field)
await virtualKeysPage.nameInput.fill(`Valid Budget Test ${Date.now()}`)
// Fill budget
const budgetInput = virtualKeysPage.page.locator('#budgetMaxLimit')
await expect(budgetInput).toBeVisible({ timeout: 5000 })
await budgetInput.fill('100')
// Save button should be enabled if form is valid
await expect(virtualKeysPage.saveBtn).toBeEnabled()
})
})
// Track created VKs for provider tests
const providerVKs: string[] = []
test.describe('Provider Management', () => {
test.beforeEach(async ({ virtualKeysPage }) => {
await virtualKeysPage.goto()
})
test.afterEach(async ({ virtualKeysPage }) => {
// Close any open sheets first
await virtualKeysPage.closeSheet()
// Clean up all tracked VKs
if (providerVKs.length > 0) {
await virtualKeysPage.cleanupVirtualKeys([...providerVKs])
providerVKs.length = 0
}
})
test('should add provider to existing virtual key', async ({ virtualKeysPage }) => {
// Create a virtual key first
const vkName = `Add Provider VK ${Date.now()}`
const vkData = createVirtualKeyWithProvider('openai', { name: vkName })
providerVKs.push(vkName)
await virtualKeysPage.createVirtualKey(vkData)
// View the virtual key
await virtualKeysPage.viewVirtualKey(vkName)
// Check if we can see provider configuration
const providerSection = virtualKeysPage.page.getByText(/Providers|Provider/i).first()
const isVisible = await providerSection.isVisible().catch(() => false)
if (isVisible) {
// Provider section is available
expect(isVisible).toBe(true)
}
// Close sheet (handled by afterEach as well)
await virtualKeysPage.closeSheet()
})
test('should remove provider from virtual key', async ({ virtualKeysPage }) => {
// Create a virtual key with multiple providers
const vkName = `Remove Provider VK ${Date.now()}`
const vkData = createVirtualKeyWithMultipleProviders(['openai', 'anthropic'], { name: vkName })
providerVKs.push(vkName)
await virtualKeysPage.createVirtualKey(vkData)
// View the virtual key
await virtualKeysPage.viewVirtualKey(vkName)
// Check if we can see and interact with providers
const removeProviderBtn = virtualKeysPage.page.locator('button').filter({
has: virtualKeysPage.page.locator('svg.lucide-trash, svg.lucide-x, svg.lucide-trash-2')
}).first()
const isVisible = await removeProviderBtn.isVisible().catch(() => false)
if (isVisible) {
// Remove provider is available - this is expected behavior
expect(isVisible).toBe(true)
}
// Close sheet (handled by afterEach as well)
await virtualKeysPage.closeSheet()
})
test('should update provider-specific budget', async ({ virtualKeysPage }) => {
// Create a virtual key with budget
const vkName = `Provider Budget VK ${Date.now()}`
const vkData = createVirtualKeyWithProvider('openai', {
name: vkName,
budget: SAMPLE_BUDGETS.small,
})
providerVKs.push(vkName)
await virtualKeysPage.createVirtualKey(vkData)
// Edit the virtual key
await virtualKeysPage.editVirtualKey(vkName, {
budget: SAMPLE_BUDGETS.large,
})
// Verify it still exists
const vkExists = await virtualKeysPage.virtualKeyExists(vkName)
expect(vkExists).toBe(true)
})
})