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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user