first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:52:23 +03:00
commit 880f412e2c
2662 changed files with 866266 additions and 0 deletions

View File

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

View File

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

View File

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