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