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 { 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 { 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 { 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 { await expect.poll(() => this.ruleExists(name), { timeout: timeoutMs }).toBe(true) } /** * Create a new routing rule */ async createRoutingRule(config: RoutingRuleConfig): Promise { 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): Promise { 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 { 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 { 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 { 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 { 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 { // 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 { 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 { 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 { 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 { 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 { 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 { // 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 { // 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 { 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 = { '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 { // 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 { 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 { // 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 { 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 { 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 { // 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 = { '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 { 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 } }