first commit
This commit is contained in:
228
tests/e2e/core/actions/api.ts
Normal file
228
tests/e2e/core/actions/api.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { APIRequestContext, APIResponse } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* API helper functions for test setup and cleanup
|
||||
*/
|
||||
|
||||
const API_BASE = '/api'
|
||||
|
||||
/**
|
||||
* Handle API response with error checking
|
||||
*/
|
||||
async function handleResponse<T>(response: APIResponse, operation: string): Promise<T> {
|
||||
if (!response.ok()) {
|
||||
throw new Error(`${operation} failed: ${response.status()} ${response.statusText()}`)
|
||||
}
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider API helpers
|
||||
*/
|
||||
export const providersApi = {
|
||||
/**
|
||||
* Get all providers
|
||||
*/
|
||||
async getAll(request: APIRequestContext) {
|
||||
const response = await request.get(`${API_BASE}/providers`)
|
||||
return handleResponse(response, 'Get all providers')
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a specific provider
|
||||
*/
|
||||
async get(request: APIRequestContext, name: string) {
|
||||
const response = await request.get(`${API_BASE}/providers/${name}`)
|
||||
return handleResponse(response, `Get provider ${name}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a provider
|
||||
*/
|
||||
async create(request: APIRequestContext, data: unknown) {
|
||||
const response = await request.post(`${API_BASE}/providers`, {
|
||||
data,
|
||||
})
|
||||
return handleResponse(response, 'Create provider')
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a provider
|
||||
*/
|
||||
async update(request: APIRequestContext, name: string, data: unknown) {
|
||||
const response = await request.put(`${API_BASE}/providers/${name}`, {
|
||||
data,
|
||||
})
|
||||
return handleResponse(response, `Update provider ${name}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a provider
|
||||
*/
|
||||
async delete(request: APIRequestContext, name: string) {
|
||||
const response = await request.delete(`${API_BASE}/providers/${name}`)
|
||||
return response.ok()
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Virtual Keys API helpers
|
||||
*/
|
||||
export const virtualKeysApi = {
|
||||
/**
|
||||
* Get all virtual keys
|
||||
*/
|
||||
async getAll(request: APIRequestContext) {
|
||||
const response = await request.get(`${API_BASE}/governance/virtual-keys`)
|
||||
return handleResponse(response, 'Get all virtual keys')
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a specific virtual key
|
||||
*/
|
||||
async get(request: APIRequestContext, id: string) {
|
||||
const response = await request.get(`${API_BASE}/governance/virtual-keys/${id}`)
|
||||
return handleResponse(response, `Get virtual key ${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a virtual key
|
||||
*/
|
||||
async create(request: APIRequestContext, data: unknown) {
|
||||
const response = await request.post(`${API_BASE}/governance/virtual-keys`, {
|
||||
data,
|
||||
})
|
||||
return handleResponse(response, 'Create virtual key')
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a virtual key
|
||||
*/
|
||||
async update(request: APIRequestContext, id: string, data: unknown) {
|
||||
const response = await request.put(`${API_BASE}/governance/virtual-keys/${id}`, {
|
||||
data,
|
||||
})
|
||||
return handleResponse(response, `Update virtual key ${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a virtual key
|
||||
*/
|
||||
async delete(request: APIRequestContext, id: string) {
|
||||
const response = await request.delete(`${API_BASE}/governance/virtual-keys/${id}`)
|
||||
return response.ok()
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Teams API helpers
|
||||
*/
|
||||
export const teamsApi = {
|
||||
/**
|
||||
* Get all teams
|
||||
*/
|
||||
async getAll(request: APIRequestContext) {
|
||||
const response = await request.get(`${API_BASE}/governance/teams`)
|
||||
return handleResponse(response, 'Get all teams')
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a team
|
||||
*/
|
||||
async create(request: APIRequestContext, data: unknown) {
|
||||
const response = await request.post(`${API_BASE}/governance/teams`, {
|
||||
data,
|
||||
})
|
||||
return handleResponse(response, 'Create team')
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a team
|
||||
*/
|
||||
async delete(request: APIRequestContext, id: string) {
|
||||
const response = await request.delete(`${API_BASE}/governance/teams/${id}`)
|
||||
return response.ok()
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Customers API helpers
|
||||
*/
|
||||
export const customersApi = {
|
||||
/**
|
||||
* Get all customers
|
||||
*/
|
||||
async getAll(request: APIRequestContext) {
|
||||
const response = await request.get(`${API_BASE}/governance/customers`)
|
||||
return handleResponse(response, 'Get all customers')
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a customer
|
||||
*/
|
||||
async create(request: APIRequestContext, data: unknown) {
|
||||
const response = await request.post(`${API_BASE}/governance/customers`, {
|
||||
data,
|
||||
})
|
||||
return handleResponse(response, 'Create customer')
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a customer
|
||||
*/
|
||||
async delete(request: APIRequestContext, id: string) {
|
||||
const response = await request.delete(`${API_BASE}/governance/customers/${id}`)
|
||||
return response.ok()
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup helper - delete all test data
|
||||
*/
|
||||
export async function cleanupTestData(
|
||||
request: APIRequestContext,
|
||||
options: {
|
||||
virtualKeyIds?: string[]
|
||||
teamIds?: string[]
|
||||
customerIds?: string[]
|
||||
providerNames?: string[]
|
||||
}
|
||||
): Promise<void> {
|
||||
const { virtualKeyIds = [], teamIds = [], customerIds = [], providerNames = [] } = options
|
||||
|
||||
// Delete virtual keys first (they may depend on teams/customers)
|
||||
for (const id of virtualKeyIds) {
|
||||
try {
|
||||
await virtualKeysApi.delete(request, id)
|
||||
} catch (e) {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
|
||||
// Delete teams
|
||||
for (const id of teamIds) {
|
||||
try {
|
||||
await teamsApi.delete(request, id)
|
||||
} catch (e) {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
|
||||
// Delete customers
|
||||
for (const id of customerIds) {
|
||||
try {
|
||||
await customersApi.delete(request, id)
|
||||
} catch (e) {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
|
||||
// Delete custom providers
|
||||
for (const name of providerNames) {
|
||||
try {
|
||||
await providersApi.delete(request, name)
|
||||
} catch (e) {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
86
tests/e2e/core/actions/navigation.ts
Normal file
86
tests/e2e/core/actions/navigation.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Page } from '@playwright/test'
|
||||
import { waitForNetworkIdle } from '../utils/test-helpers'
|
||||
|
||||
/**
|
||||
* Navigation helper functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Navigate to the workspace root
|
||||
*/
|
||||
export async function goToWorkspace(page: Page): Promise<void> {
|
||||
await page.goto('/workspace')
|
||||
await waitForNetworkIdle(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to Providers page
|
||||
*/
|
||||
export async function goToProviders(page: Page): Promise<void> {
|
||||
await page.goto('/workspace/providers')
|
||||
await waitForNetworkIdle(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to Virtual Keys page
|
||||
*/
|
||||
export async function goToVirtualKeys(page: Page): Promise<void> {
|
||||
await page.goto('/workspace/virtual-keys')
|
||||
await waitForNetworkIdle(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to User Groups page
|
||||
*/
|
||||
export async function goToUserGroups(page: Page): Promise<void> {
|
||||
await page.goto('/workspace/user-groups')
|
||||
await waitForNetworkIdle(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to MCP Clients page
|
||||
*/
|
||||
export async function goToMCPClients(page: Page): Promise<void> {
|
||||
await page.goto('/workspace/mcp-clients')
|
||||
await waitForNetworkIdle(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to Logs page
|
||||
*/
|
||||
export async function goToLogs(page: Page): Promise<void> {
|
||||
await page.goto('/workspace/logs')
|
||||
await waitForNetworkIdle(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to Plugins page
|
||||
*/
|
||||
export async function goToPlugins(page: Page): Promise<void> {
|
||||
await page.goto('/workspace/plugins')
|
||||
await waitForNetworkIdle(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to Config page
|
||||
*/
|
||||
export async function goToConfig(page: Page): Promise<void> {
|
||||
await page.goto('/workspace/config')
|
||||
await waitForNetworkIdle(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a specific provider
|
||||
*/
|
||||
export async function goToProvider(page: Page, providerName: string): Promise<void> {
|
||||
await page.goto(`/workspace/providers?provider=${encodeURIComponent(providerName)}`)
|
||||
await waitForNetworkIdle(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a specific virtual key
|
||||
*/
|
||||
export async function goToVirtualKey(page: Page, vkId: string): Promise<void> {
|
||||
await page.goto(`/workspace/virtual-keys?vk=${encodeURIComponent(vkId)}`)
|
||||
await waitForNetworkIdle(page)
|
||||
}
|
||||
123
tests/e2e/core/fixtures/base.fixture.ts
Normal file
123
tests/e2e/core/fixtures/base.fixture.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { test as base, expect } from '@playwright/test'
|
||||
import { SidebarPage } from '../pages/sidebar.page'
|
||||
import { ProvidersPage } from '../../features/providers/pages/providers.page'
|
||||
import { VirtualKeysPage } from '../../features/virtual-keys/pages/virtual-keys.page'
|
||||
import { DashboardPage } from '../../features/dashboard/pages/dashboard.page'
|
||||
import { LogsPage } from '../../features/logs/pages/logs.page'
|
||||
import { MCPLogsPage } from '../../features/mcp-logs/pages/mcp-logs.page'
|
||||
import { RoutingRulesPage } from '../../features/routing-rules/pages/routing-rules.page'
|
||||
import { MCPRegistryPage } from '../../features/mcp-registry/pages/mcp-registry.page'
|
||||
import { PluginsPage } from '../../features/plugins/pages/plugins.page'
|
||||
import { ObservabilityPage } from '../../features/observability/pages/observability.page'
|
||||
import { ConfigSettingsPage } from '../../features/config/pages/config-settings.page'
|
||||
import { GovernancePage } from '../../features/governance/pages/governance.page'
|
||||
import { MCPAuthConfigPage } from '../../features/mcp-auth-config/pages/mcp-auth-config.page'
|
||||
import { MCPSettingsPage } from '../../features/mcp-settings/pages/mcp-settings.page'
|
||||
import { MCPToolGroupsPage } from '../../features/mcp-tool-groups/pages/mcp-tool-groups.page'
|
||||
import { ModelLimitsPage } from '../../features/model-limits/pages/model-limits.page'
|
||||
|
||||
/**
|
||||
* Custom test fixtures type
|
||||
*/
|
||||
type BifrostFixtures = {
|
||||
closeDevProfiler: void
|
||||
sidebarPage: SidebarPage
|
||||
providersPage: ProvidersPage
|
||||
virtualKeysPage: VirtualKeysPage
|
||||
dashboardPage: DashboardPage
|
||||
logsPage: LogsPage
|
||||
mcpLogsPage: MCPLogsPage
|
||||
routingRulesPage: RoutingRulesPage
|
||||
mcpRegistryPage: MCPRegistryPage
|
||||
pluginsPage: PluginsPage
|
||||
observabilityPage: ObservabilityPage
|
||||
configSettingsPage: ConfigSettingsPage
|
||||
governancePage: GovernancePage
|
||||
modelLimitsPage: ModelLimitsPage
|
||||
mcpSettingsPage: MCPSettingsPage
|
||||
mcpToolGroupsPage: MCPToolGroupsPage
|
||||
mcpAuthConfigPage: MCPAuthConfigPage
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended test with Bifrost-specific fixtures
|
||||
*/
|
||||
export const test = base.extend<BifrostFixtures>({
|
||||
closeDevProfiler: [async ({ page }, use) => {
|
||||
// Automatically dismiss the Dev Profiler overlay whenever it appears.
|
||||
// Uses addLocatorHandler so it triggers before any test action if the profiler is visible.
|
||||
await page.addLocatorHandler(
|
||||
page.getByText('Dev Profiler', { exact: true }),
|
||||
async () => {
|
||||
await page.locator('button[title="Dismiss"]').click({ force: true })
|
||||
}
|
||||
)
|
||||
await use()
|
||||
}, { auto: true }],
|
||||
|
||||
sidebarPage: async ({ page }, use) => {
|
||||
await use(new SidebarPage(page))
|
||||
},
|
||||
|
||||
providersPage: async ({ page }, use) => {
|
||||
await use(new ProvidersPage(page))
|
||||
},
|
||||
|
||||
virtualKeysPage: async ({ page }, use) => {
|
||||
await use(new VirtualKeysPage(page))
|
||||
},
|
||||
|
||||
dashboardPage: async ({ page }, use) => {
|
||||
await use(new DashboardPage(page))
|
||||
},
|
||||
|
||||
logsPage: async ({ page }, use) => {
|
||||
await use(new LogsPage(page))
|
||||
},
|
||||
|
||||
mcpLogsPage: async ({ page }, use) => {
|
||||
await use(new MCPLogsPage(page))
|
||||
},
|
||||
|
||||
routingRulesPage: async ({ page }, use) => {
|
||||
await use(new RoutingRulesPage(page))
|
||||
},
|
||||
|
||||
mcpRegistryPage: async ({ page }, use) => {
|
||||
await use(new MCPRegistryPage(page))
|
||||
},
|
||||
|
||||
pluginsPage: async ({ page }, use) => {
|
||||
await use(new PluginsPage(page))
|
||||
},
|
||||
|
||||
observabilityPage: async ({ page }, use) => {
|
||||
await use(new ObservabilityPage(page))
|
||||
},
|
||||
|
||||
configSettingsPage: async ({ page }, use) => {
|
||||
await use(new ConfigSettingsPage(page))
|
||||
},
|
||||
|
||||
governancePage: async ({ page }, use) => {
|
||||
await use(new GovernancePage(page))
|
||||
},
|
||||
|
||||
modelLimitsPage: async ({ page }, use) => {
|
||||
await use(new ModelLimitsPage(page))
|
||||
},
|
||||
|
||||
mcpSettingsPage: async ({ page }, use) => {
|
||||
await use(new MCPSettingsPage(page))
|
||||
},
|
||||
|
||||
mcpToolGroupsPage: async ({ page }, use) => {
|
||||
await use(new MCPToolGroupsPage(page))
|
||||
},
|
||||
|
||||
mcpAuthConfigPage: async ({ page }, use) => {
|
||||
await use(new MCPAuthConfigPage(page))
|
||||
},
|
||||
})
|
||||
|
||||
export { expect }
|
||||
172
tests/e2e/core/fixtures/test-data.fixture.ts
Normal file
172
tests/e2e/core/fixtures/test-data.fixture.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
/**
|
||||
* Test data types
|
||||
*/
|
||||
export interface ProviderKeyConfig {
|
||||
name: string
|
||||
value: string
|
||||
models?: string[]
|
||||
weight?: number
|
||||
}
|
||||
|
||||
export interface CustomProviderConfig {
|
||||
name: string
|
||||
baseProviderType: 'openai' | 'anthropic' | 'gemini' | 'cohere' | 'bedrock' | string
|
||||
baseUrl?: string
|
||||
authType?: 'api_key' | 'bearer' | 'basic' | 'none'
|
||||
isKeyless?: boolean
|
||||
}
|
||||
|
||||
export interface VirtualKeyConfig {
|
||||
name: string
|
||||
description?: string
|
||||
isActive?: boolean
|
||||
providerConfigs?: ProviderConfigItem[]
|
||||
budget?: BudgetConfig
|
||||
rateLimit?: RateLimitConfig
|
||||
teamId?: string
|
||||
customerId?: string
|
||||
}
|
||||
|
||||
export interface ProviderConfigItem {
|
||||
provider: string
|
||||
weight?: number
|
||||
allowedModels?: string[]
|
||||
keyIds?: string[]
|
||||
budget?: BudgetConfig
|
||||
rateLimit?: RateLimitConfig
|
||||
}
|
||||
|
||||
export interface BudgetConfig {
|
||||
maxLimit: number
|
||||
resetDuration: string
|
||||
}
|
||||
|
||||
export interface RateLimitConfig {
|
||||
tokenMaxLimit?: number
|
||||
tokenResetDuration?: string
|
||||
requestMaxLimit?: number
|
||||
requestResetDuration?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Test data fixture type
|
||||
*/
|
||||
type TestDataFixtures = {
|
||||
testData: TestDataFactory
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for creating test data with unique identifiers
|
||||
*/
|
||||
export class TestDataFactory {
|
||||
private counter = 0
|
||||
private runId = randomUUID()
|
||||
|
||||
/**
|
||||
* Generate a unique ID for test data
|
||||
*/
|
||||
uniqueId(prefix = 'test'): string {
|
||||
this.counter++
|
||||
return `${prefix}-${this.runId}-${this.counter}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Create provider key test data
|
||||
*/
|
||||
createProviderKey(overrides: Partial<ProviderKeyConfig> = {}): ProviderKeyConfig {
|
||||
return {
|
||||
name: this.uniqueId('key'),
|
||||
value: `sk-test-${this.uniqueId()}`,
|
||||
models: ['*'],
|
||||
weight: 1.0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create custom provider test data
|
||||
*/
|
||||
createCustomProvider(overrides: Partial<CustomProviderConfig> = {}): CustomProviderConfig {
|
||||
return {
|
||||
name: this.uniqueId('provider'),
|
||||
baseProviderType: 'openai',
|
||||
baseUrl: 'https://api.example.com',
|
||||
authType: 'api_key',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create virtual key test data
|
||||
*/
|
||||
createVirtualKey(overrides: Partial<VirtualKeyConfig> = {}): VirtualKeyConfig {
|
||||
return {
|
||||
name: this.uniqueId('vk'),
|
||||
description: 'Test virtual key',
|
||||
isActive: true,
|
||||
providerConfigs: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create virtual key with budget
|
||||
*/
|
||||
createVirtualKeyWithBudget(
|
||||
budgetOverrides: Partial<BudgetConfig> = {},
|
||||
vkOverrides: Partial<VirtualKeyConfig> = {}
|
||||
): VirtualKeyConfig {
|
||||
return this.createVirtualKey({
|
||||
budget: {
|
||||
maxLimit: 100,
|
||||
resetDuration: '1M',
|
||||
...budgetOverrides,
|
||||
},
|
||||
...vkOverrides,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create virtual key with rate limits
|
||||
*/
|
||||
createVirtualKeyWithRateLimit(
|
||||
rateLimitOverrides: Partial<RateLimitConfig> = {},
|
||||
vkOverrides: Partial<VirtualKeyConfig> = {}
|
||||
): VirtualKeyConfig {
|
||||
return this.createVirtualKey({
|
||||
rateLimit: {
|
||||
tokenMaxLimit: 10000,
|
||||
tokenResetDuration: '1h',
|
||||
requestMaxLimit: 1000,
|
||||
requestResetDuration: '1h',
|
||||
...rateLimitOverrides,
|
||||
},
|
||||
...vkOverrides,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create provider config item for virtual key
|
||||
*/
|
||||
createProviderConfigItem(overrides: Partial<ProviderConfigItem> = {}): ProviderConfigItem {
|
||||
return {
|
||||
provider: 'openai',
|
||||
weight: 1.0,
|
||||
allowedModels: ['*'],
|
||||
keyIds: ['*'],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended test with test data fixture
|
||||
*/
|
||||
export const testWithData = base.extend<TestDataFixtures>({
|
||||
testData: async (_, use) => {
|
||||
await use(new TestDataFactory())
|
||||
},
|
||||
})
|
||||
27
tests/e2e/core/index.ts
Normal file
27
tests/e2e/core/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Core module exports
|
||||
*/
|
||||
|
||||
// Fixtures
|
||||
export { test, expect } from './fixtures/base.fixture'
|
||||
export { testWithData, TestDataFactory } from './fixtures/test-data.fixture'
|
||||
export type {
|
||||
ProviderKeyConfig,
|
||||
CustomProviderConfig,
|
||||
VirtualKeyConfig,
|
||||
ProviderConfigItem,
|
||||
BudgetConfig,
|
||||
RateLimitConfig,
|
||||
} from './fixtures/test-data.fixture'
|
||||
|
||||
// Page Objects
|
||||
export { BasePage } from './pages/base.page'
|
||||
export { SidebarPage } from './pages/sidebar.page'
|
||||
|
||||
// Actions
|
||||
export * from './actions/navigation'
|
||||
export * from './actions/api'
|
||||
|
||||
// Utils
|
||||
export { Selectors } from './utils/selectors'
|
||||
export * from './utils/test-helpers'
|
||||
219
tests/e2e/core/pages/base.page.ts
Normal file
219
tests/e2e/core/pages/base.page.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { Locator, Page, expect } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Base page object with common methods shared across all pages
|
||||
*/
|
||||
export class BasePage {
|
||||
readonly page: Page
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the page to finish loading
|
||||
*/
|
||||
async waitForPageLoad(): Promise<void> {
|
||||
await this.page.waitForLoadState('networkidle')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the toast notification element (first/most recent one)
|
||||
* Filters out toasts that are being removed to avoid matching stale toasts.
|
||||
* Optionally filters by toast type (success, error, loading, default).
|
||||
*/
|
||||
getToast(type?: 'success' | 'error' | 'loading' | 'default'): Locator {
|
||||
const selector = type
|
||||
? `[data-sonner-toast][data-type="${type}"]:not([data-removed="true"])`
|
||||
: '[data-sonner-toast]:not([data-removed="true"])'
|
||||
return this.page.locator(selector).first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a success toast to appear
|
||||
*/
|
||||
async waitForSuccessToast(message?: string): Promise<void> {
|
||||
const toast = this.getToast('success')
|
||||
await expect(toast).toBeVisible({ timeout: 10000 })
|
||||
if (message) {
|
||||
await expect(toast).toContainText(message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an error toast to appear
|
||||
*/
|
||||
async waitForErrorToast(message?: string): Promise<void> {
|
||||
const toast = this.getToast('error')
|
||||
await expect(toast).toBeVisible({ timeout: 10000 })
|
||||
if (message) {
|
||||
await expect(toast).toContainText(message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for all toasts to disappear
|
||||
*/
|
||||
async waitForToastsToDisappear(timeout = 5000): Promise<void> {
|
||||
const toasts = this.page.locator('[data-sonner-toast]:not([data-removed="true"])')
|
||||
try {
|
||||
// Wait for all toasts to be detached from DOM
|
||||
await toasts.first().waitFor({ state: 'detached', timeout }).catch(() => {
|
||||
// If no toasts exist, that's fine
|
||||
})
|
||||
// Also check if count is 0
|
||||
const count = await toasts.count()
|
||||
if (count > 0) {
|
||||
// Wait for toasts to be hidden
|
||||
await expect(toasts.first()).not.toBeVisible({ timeout: 3000 }).catch(() => {})
|
||||
}
|
||||
} catch {
|
||||
// No toasts present, which is fine
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a sheet/dialog to be fully visible (animation complete)
|
||||
*/
|
||||
async waitForSheetAnimation(): Promise<void> {
|
||||
// Wait for any sheet transition to complete by checking for stable state
|
||||
await this.page.waitForFunction(() => {
|
||||
const sheet = document.querySelector('[role="dialog"]')
|
||||
if (!sheet) return true
|
||||
const style = window.getComputedStyle(sheet)
|
||||
return style.opacity === '1' && style.transform === 'none'
|
||||
}, { timeout: 2000 }).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for element state to change (useful for toggles)
|
||||
*/
|
||||
async waitForStateChange(locator: Locator, attribute: string, expectedValue: string, timeout = 5000): Promise<void> {
|
||||
await expect(locator).toHaveAttribute(attribute, expectedValue, { timeout })
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for URL to contain a specific parameter
|
||||
*/
|
||||
async waitForUrlParam(param: string, value: string, timeout = 5000): Promise<void> {
|
||||
await expect(this.page).toHaveURL(new RegExp(`${param}=${value}`), { timeout })
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for charts/data to load after page navigation
|
||||
*/
|
||||
async waitForChartsToLoad(): Promise<void> {
|
||||
// Wait for network to be idle (data fetching complete)
|
||||
await this.page.waitForLoadState('networkidle')
|
||||
// Wait for any loading skeletons to disappear
|
||||
const skeletons = this.page.locator('[data-testid="skeleton"], .skeleton, [data-loading="true"]')
|
||||
if (await skeletons.count() > 0) {
|
||||
await skeletons.first().waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss all visible toasts by waiting for them to disappear
|
||||
*/
|
||||
async dismissToasts(): Promise<void> {
|
||||
// Just wait for toasts to auto-dismiss
|
||||
await this.waitForToastsToDisappear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Force dismiss all toasts by clicking away and waiting
|
||||
*/
|
||||
async forceCloseToasts(): Promise<void> {
|
||||
// Click somewhere neutral to potentially dismiss toasts
|
||||
await this.page.locator('body').click({ position: { x: 10, y: 10 }, force: true }).catch(() => {})
|
||||
|
||||
// Wait for toasts to auto-dismiss (they typically auto-dismiss after 4-5 seconds)
|
||||
await this.waitForToastsToDisappear(8000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the Dev Profiler overlay if it is visible.
|
||||
* Clicks the dismiss (X) button on the profiler panel. Silently continues if not present.
|
||||
*/
|
||||
async closeDevProfiler(): Promise<void> {
|
||||
const profilerHeader = this.page.locator('text=Dev Profiler')
|
||||
const isVisible = await profilerHeader.isVisible().catch(() => false)
|
||||
if (isVisible) {
|
||||
const dismissBtn = this.page.locator('button[title="Dismiss"]')
|
||||
if (await dismissBtn.isVisible().catch(() => false)) {
|
||||
await dismissBtn.click()
|
||||
await profilerHeader.waitFor({ state: 'hidden', timeout: 3000 }).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill a form field by label
|
||||
*/
|
||||
async fillByLabel(label: string, value: string): Promise<void> {
|
||||
await this.page.getByLabel(label).fill(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill a form field by placeholder
|
||||
*/
|
||||
async fillByPlaceholder(placeholder: string, value: string): Promise<void> {
|
||||
await this.page.getByPlaceholder(placeholder).fill(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill a form field by test id
|
||||
*/
|
||||
async fillByTestId(testId: string, value: string): Promise<void> {
|
||||
await this.page.getByTestId(testId).fill(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a button by text
|
||||
*/
|
||||
async clickButton(text: string): Promise<void> {
|
||||
await this.page.getByRole('button', { name: text }).click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a button by test id
|
||||
*/
|
||||
async clickByTestId(testId: string): Promise<void> {
|
||||
await this.page.getByTestId(testId).click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Select an option from a dropdown by label
|
||||
*/
|
||||
async selectOption(label: string, value: string): Promise<void> {
|
||||
await this.page.getByLabel(label).selectOption(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an element is visible
|
||||
*/
|
||||
async isVisible(selector: string): Promise<boolean> {
|
||||
return await this.page.locator(selector).isVisible()
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an element to be visible
|
||||
*/
|
||||
async waitForSelector(selector: string, timeout = 10000): Promise<void> {
|
||||
await this.page.waitForSelector(selector, { timeout })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get text content of an element
|
||||
*/
|
||||
async getTextContent(selector: string): Promise<string | null> {
|
||||
return await this.page.locator(selector).textContent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a screenshot
|
||||
*/
|
||||
async screenshot(name: string): Promise<void> {
|
||||
await this.page.screenshot({ path: `./screenshots/${name}.png` })
|
||||
}
|
||||
}
|
||||
83
tests/e2e/core/pages/sidebar.page.ts
Normal file
83
tests/e2e/core/pages/sidebar.page.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Page, Locator } from '@playwright/test'
|
||||
import { BasePage } from './base.page'
|
||||
|
||||
/**
|
||||
* Sidebar navigation page object
|
||||
*/
|
||||
export class SidebarPage extends BasePage {
|
||||
// Navigation links
|
||||
readonly providersLink: Locator
|
||||
readonly virtualKeysLink: Locator
|
||||
readonly logsLink: Locator
|
||||
readonly mcpClientsLink: Locator
|
||||
readonly userGroupsLink: Locator
|
||||
readonly pluginsLink: Locator
|
||||
readonly configLink: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
this.providersLink = page.getByRole('link', { name: /providers/i })
|
||||
this.virtualKeysLink = page.getByRole('link', { name: /virtual keys/i })
|
||||
this.logsLink = page.getByRole('link', { name: /logs/i })
|
||||
this.mcpClientsLink = page.getByRole('link', { name: /mcp/i })
|
||||
this.userGroupsLink = page.getByRole('link', { name: /user groups/i })
|
||||
this.pluginsLink = page.getByRole('link', { name: /plugins/i })
|
||||
this.configLink = page.getByRole('link', { name: /config/i })
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to Providers page
|
||||
*/
|
||||
async goToProviders(): Promise<void> {
|
||||
await this.providersLink.click()
|
||||
await this.waitForPageLoad()
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to Virtual Keys page
|
||||
*/
|
||||
async goToVirtualKeys(): Promise<void> {
|
||||
await this.virtualKeysLink.click()
|
||||
await this.waitForPageLoad()
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to Logs page
|
||||
*/
|
||||
async goToLogs(): Promise<void> {
|
||||
await this.logsLink.click()
|
||||
await this.waitForPageLoad()
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to MCP Clients page
|
||||
*/
|
||||
async goToMCPClients(): Promise<void> {
|
||||
await this.mcpClientsLink.click()
|
||||
await this.waitForPageLoad()
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to User Groups page
|
||||
*/
|
||||
async goToUserGroups(): Promise<void> {
|
||||
await this.userGroupsLink.click()
|
||||
await this.waitForPageLoad()
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to Plugins page
|
||||
*/
|
||||
async goToPlugins(): Promise<void> {
|
||||
await this.pluginsLink.click()
|
||||
await this.waitForPageLoad()
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to Config page
|
||||
*/
|
||||
async goToConfig(): Promise<void> {
|
||||
await this.configLink.click()
|
||||
await this.waitForPageLoad()
|
||||
}
|
||||
}
|
||||
102
tests/e2e/core/utils/selectors.ts
Normal file
102
tests/e2e/core/utils/selectors.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Centralized selectors for Bifrost UI elements
|
||||
* Using data-testid attributes where available, falling back to other strategies
|
||||
*/
|
||||
|
||||
export const Selectors = {
|
||||
// Common
|
||||
toast: '[data-sonner-toast]:not([data-removed="true"])',
|
||||
loadingSpinner: '[data-testid="loading-spinner"]',
|
||||
|
||||
// Providers Page
|
||||
providers: {
|
||||
// Sidebar list
|
||||
providerList: '[data-testid="provider-list"]',
|
||||
providerItem: (name: string) => `[data-testid="provider-item-${name.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}"]`,
|
||||
addProviderBtn: '[data-testid="add-provider-btn"]',
|
||||
/** Add New Provider dropdown > Custom provider... (opens custom provider sheet) */
|
||||
addProviderOptionCustom: '[data-testid="add-provider-option-custom"]',
|
||||
|
||||
// Provider config
|
||||
providerConfig: '[data-testid="provider-config"]',
|
||||
addKeyBtn: '[data-testid="add-key-btn"]',
|
||||
keysTable: '[data-testid="keys-table"]',
|
||||
keyRow: (name: string) => `[data-testid="key-row-${name}"]`,
|
||||
|
||||
// Key form
|
||||
keyForm: {
|
||||
container: '[data-testid="key-form"]',
|
||||
nameInput: '[data-testid="key-name-input"]',
|
||||
valueInput: '[data-testid="key-value-input"]',
|
||||
modelsInput: '[data-testid="key-models-input"]',
|
||||
weightInput: '[data-testid="key-weight-input"]',
|
||||
saveBtn: '[data-testid="key-save-btn"]',
|
||||
cancelBtn: '[data-testid="key-cancel-btn"]',
|
||||
},
|
||||
|
||||
// Custom provider sheet
|
||||
customProviderSheet: {
|
||||
container: '[data-testid="custom-provider-sheet"]',
|
||||
nameInput: '[data-testid="custom-provider-name"]',
|
||||
baseProviderSelect: '[data-testid="base-provider-select"]',
|
||||
baseUrlInput: '[data-testid="base-url-input"]',
|
||||
saveBtn: '[data-testid="custom-provider-save-btn"]',
|
||||
cancelBtn: '[data-testid="custom-provider-cancel-btn"]',
|
||||
},
|
||||
},
|
||||
|
||||
// Virtual Keys Page
|
||||
virtualKeys: {
|
||||
// Table
|
||||
table: '[data-testid="vk-table"]',
|
||||
row: (name: string) => `[data-testid="vk-row-${name}"]`,
|
||||
createBtn: '[data-testid="create-vk-btn"]',
|
||||
|
||||
// Sheet/Form
|
||||
sheet: {
|
||||
container: '[data-testid="vk-sheet"]',
|
||||
nameInput: '[data-testid="vk-name-input"]',
|
||||
descriptionInput: '[data-testid="vk-description-input"]',
|
||||
isActiveToggle: '[data-testid="vk-is-active-toggle"]',
|
||||
|
||||
// Provider configs
|
||||
providerSelect: '[data-testid="vk-provider-select"]',
|
||||
|
||||
// Entity assignment
|
||||
entityTypeSelect: '[data-testid="vk-entity-type-select"]',
|
||||
teamSelect: '[data-testid="vk-team-select"]',
|
||||
customerSelect: '[data-testid="vk-customer-select"]',
|
||||
|
||||
// Actions
|
||||
saveBtn: '[data-testid="vk-save-btn"]',
|
||||
cancelBtn: '[data-testid="vk-cancel-btn"]',
|
||||
},
|
||||
},
|
||||
|
||||
// User Groups Page
|
||||
userGroups: {
|
||||
teamsTab: '[data-testid="teams-tab"]',
|
||||
customersTab: '[data-testid="customers-tab"]',
|
||||
teamsTable: '[data-testid="teams-table"]',
|
||||
customersTable: '[data-testid="customers-table"]',
|
||||
createTeamBtn: '[data-testid="create-team-btn"]',
|
||||
createCustomerBtn: '[data-testid="customer-button-create"]',
|
||||
},
|
||||
|
||||
// Common form elements
|
||||
form: {
|
||||
input: (name: string) => `[data-testid="input-${name}"]`,
|
||||
select: (name: string) => `[data-testid="select-${name}"]`,
|
||||
toggle: (name: string) => `[data-testid="toggle-${name}"]`,
|
||||
saveBtn: '[data-testid="btn-save"]',
|
||||
cancelBtn: '[data-testid="btn-cancel"]',
|
||||
deleteBtn: '[data-testid="btn-delete"]',
|
||||
},
|
||||
|
||||
// Dialogs
|
||||
dialog: {
|
||||
container: '[role="dialog"]',
|
||||
confirmBtn: '[data-testid="dialog-confirm-btn"]',
|
||||
cancelBtn: '[data-testid="dialog-cancel-btn"]',
|
||||
},
|
||||
}
|
||||
169
tests/e2e/core/utils/test-helpers.ts
Normal file
169
tests/e2e/core/utils/test-helpers.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Wait for network to be idle
|
||||
*/
|
||||
export async function waitForNetworkIdle(page: Page, timeout = 5000): Promise<void> {
|
||||
await page.waitForLoadState('networkidle', { timeout })
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a specific number of milliseconds
|
||||
*/
|
||||
export async function wait(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a function until it succeeds or times out
|
||||
*/
|
||||
export async function retry<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: { retries?: number; delay?: number } = {}
|
||||
): Promise<T> {
|
||||
const { retries = 3, delay = 1000 } = options
|
||||
let lastError: Error | undefined
|
||||
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (error) {
|
||||
lastError = error as Error
|
||||
if (i < retries - 1) {
|
||||
await wait(delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random string
|
||||
*/
|
||||
export function randomString(length = 8): string {
|
||||
return Math.random().toString(36).substring(2).padEnd(length, '0').substring(0, length)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique test name
|
||||
*/
|
||||
export function uniqueTestName(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${randomString(4)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a toast message appears
|
||||
*/
|
||||
export async function assertToast(
|
||||
page: Page,
|
||||
expectedText: string,
|
||||
type: 'success' | 'error' | 'info' = 'success'
|
||||
): Promise<void> {
|
||||
const selector = `[data-sonner-toast][data-type="${type}"]:not([data-removed="true"])`
|
||||
const toast = page.locator(selector).first()
|
||||
await expect(toast).toBeVisible({ timeout: 10000 })
|
||||
await expect(toast).toContainText(expectedText)
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that page URL matches expected pattern
|
||||
*/
|
||||
export async function assertUrl(page: Page, pattern: string | RegExp): Promise<void> {
|
||||
await expect(page).toHaveURL(pattern)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill a Radix/Shadcn Select component
|
||||
*/
|
||||
export async function fillSelect(
|
||||
page: Page,
|
||||
triggerSelector: string,
|
||||
optionText: string
|
||||
): Promise<void> {
|
||||
// Click the trigger to open the dropdown
|
||||
await page.locator(triggerSelector).click()
|
||||
|
||||
// Wait for the dropdown content to appear
|
||||
await page.waitForSelector('[role="listbox"]', { timeout: 5000 })
|
||||
|
||||
// Click the option
|
||||
await page.getByRole('option', { name: optionText }).click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill a multi-select component
|
||||
*/
|
||||
export async function fillMultiSelect(
|
||||
page: Page,
|
||||
inputSelector: string,
|
||||
values: string[]
|
||||
): Promise<void> {
|
||||
const input = page.locator(inputSelector)
|
||||
|
||||
for (const value of values) {
|
||||
await input.fill(value)
|
||||
await page.keyboard.press('Enter')
|
||||
await wait(100) // Small delay between entries
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear and fill an input
|
||||
*/
|
||||
export async function clearAndFill(page: Page, selector: string, value: string): Promise<void> {
|
||||
const input = page.locator(selector)
|
||||
await input.clear()
|
||||
await input.fill(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get table row count
|
||||
*/
|
||||
export async function getTableRowCount(page: Page, tableSelector: string): Promise<number> {
|
||||
const rows = page.locator(`${tableSelector} tbody tr`)
|
||||
return await rows.count()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if table contains a row with specific text
|
||||
*/
|
||||
export async function tableContainsRow(
|
||||
page: Page,
|
||||
tableSelector: string,
|
||||
text: string
|
||||
): Promise<boolean> {
|
||||
const table = page.locator(tableSelector)
|
||||
const row = table.locator('tbody tr', { hasText: text })
|
||||
return await row.count() > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for table to load (no loading indicator)
|
||||
*/
|
||||
export async function waitForTableLoad(page: Page, tableSelector: string): Promise<void> {
|
||||
// Wait for table to be visible
|
||||
await page.locator(tableSelector).waitFor({ state: 'visible' })
|
||||
|
||||
// Wait for any loading spinners to disappear
|
||||
const loadingIndicator = page.locator('[data-testid="loading-spinner"]')
|
||||
if (await loadingIndicator.count() > 0) {
|
||||
await loadingIndicator.waitFor({ state: 'hidden', timeout: 10000 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Screenshot on failure helper
|
||||
*/
|
||||
export async function screenshotOnError(
|
||||
page: Page,
|
||||
testName: string,
|
||||
fn: () => Promise<void>
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fn()
|
||||
} catch (error) {
|
||||
await page.screenshot({ path: `./screenshots/error-${testName}-${Date.now()}.png` })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user