first commit
This commit is contained in:
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user