import { Locator, Page, expect } from '@playwright/test' import { CustomProviderConfig, ProviderKeyConfig } from '../../../core/fixtures/test-data.fixture' import { BasePage } from '../../../core/pages/base.page' import { Selectors } from '../../../core/utils/selectors' import { fillSelect, waitForNetworkIdle } from '../../../core/utils/test-helpers' export type { CustomProviderConfig, ProviderKeyConfig } /** * Page object for the Providers page */ export class ProvidersPage extends BasePage { // Locators readonly providerList: Locator readonly addProviderBtn: Locator /** Add New Provider dropdown > "Custom provider..." menu item */ readonly addProviderOptionCustom: Locator readonly addKeyBtn: Locator readonly keysTable: Locator // Custom provider sheet readonly customProviderSheet: Locator readonly customProviderNameInput: Locator readonly baseProviderSelect: Locator readonly baseUrlInput: Locator readonly customProviderSaveBtn: Locator readonly customProviderCancelBtn: Locator // Keys table empty state (when provider has no keys) readonly keysTableEmptyState: Locator // Key form readonly keyForm: Locator readonly keySaveBtn: Locator readonly keyCancelBtn: Locator constructor(page: Page) { super(page) // Provider list this.providerList = page.locator(Selectors.providers.providerList) this.addProviderBtn = page.getByTestId('add-provider-btn') this.addProviderOptionCustom = page.getByTestId('add-provider-option-custom') // Keys table this.addKeyBtn = page.getByTestId('add-key-btn') this.keysTable = page.getByTestId('keys-table') // Custom provider sheet this.customProviderSheet = page.getByTestId('custom-provider-sheet') this.customProviderNameInput = page.getByTestId('custom-provider-name') this.baseProviderSelect = page.getByTestId('base-provider-select') this.baseUrlInput = page.getByTestId('base-url-input') this.customProviderSaveBtn = page.getByTestId('custom-provider-save-btn') this.customProviderCancelBtn = page.getByTestId('custom-provider-cancel-btn') // Keys table empty state this.keysTableEmptyState = page.getByTestId('keys-table-empty-state') // Key form this.keyForm = page.getByTestId('key-form') this.keySaveBtn = page.getByTestId('key-save-btn') this.keyCancelBtn = page.getByTestId('key-cancel-btn') } /** * Navigate to the providers page */ async goto(): Promise { await this.page.goto('/workspace/providers') await waitForNetworkIdle(this.page) } /** * Select a provider from the sidebar list */ async selectProvider(name: string): Promise { const providerItem = this.page.getByTestId(`provider-item-${name.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`) await providerItem.click() await waitForNetworkIdle(this.page) } /** * Get provider item locator */ getProviderItem(name: string): Locator { return this.page.getByTestId(`provider-item-${name.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`) } /** * Check if a provider exists in the list */ async providerExists(name: string): Promise { const providerItem = this.getProviderItem(name) return await providerItem.isVisible() } /** * Add a new key to the currently selected provider */ async addKey(config: ProviderKeyConfig): Promise { await this.dismissToasts() // Click add key button await this.addKeyBtn.click() // Wait for key form to appear await expect(this.keyForm).toBeVisible() // Fill in key details await this.page.getByLabel('Name').fill(config.name) await this.page.getByLabel('API Key').fill(config.value) // Fill weight if provided if (config.weight !== undefined) { const weightInput = this.page.getByLabel('Weight') if (await weightInput.isVisible()) { await weightInput.fill(String(config.weight)) } } // Note: Model selection is skipped for now as it requires specific UI interaction // that may vary based on the provider type // Save the key await this.keySaveBtn.click() // Wait for success toast await this.waitForSuccessToast() // Wait for form to close and table to refresh await expect(this.keyForm).not.toBeVisible({ timeout: 5000 }) await waitForNetworkIdle(this.page) } /** * Add a known provider from the "Add provider" dropdown (e.g. Nebius, OpenAI). * Opens the dropdown and clicks the option with data-testid add-provider-option-{name}. */ async addKnownProviderFromDropdown(providerName: string): Promise { await this.addProviderBtn.click() const option = this.page.getByTestId(`add-provider-option-${providerName}`) await option.waitFor({ state: 'visible', timeout: 5000 }) await option.click() await waitForNetworkIdle(this.page) } /** * Open the custom provider sheet via Add New Provider > Custom provider... */ async openCustomProviderSheet(): Promise { await this.addProviderBtn.click() await this.addProviderOptionCustom.waitFor({ state: 'visible', timeout: 5000 }) await this.addProviderOptionCustom.click() await expect(this.customProviderSheet).toBeVisible({ timeout: 5000 }) } /** * Create a custom provider */ async createProvider(config: CustomProviderConfig): Promise { await this.openCustomProviderSheet() // Fill in provider name await this.customProviderNameInput.fill(config.name) // Select base provider type await fillSelect( this.page, '[data-testid="base-provider-select"]', this.getBaseProviderLabel(config.baseProviderType) ) // Fill in base URL if (config.baseUrl) { await this.baseUrlInput.fill(config.baseUrl) } // Save the provider await this.customProviderSaveBtn.click() // Wait for sheet to close (indicates success) await expect(this.customProviderSheet).not.toBeVisible({ timeout: 10000 }) // Wait for network to settle await waitForNetworkIdle(this.page) } /** * Delete a custom provider. * @param options.skipToastWait - If true, do not wait for success toast (e.g. for cleanup); avoids cleanup failures when toast is missing or already gone. */ async deleteProvider(name: string, options?: { skipToastWait?: boolean }): Promise { // First select the provider (config panel shows with delete button) await this.selectProvider(name) // Click the delete button in the config panel const deleteBtn = this.page.getByTestId('provider-delete-btn') await deleteBtn.waitFor({ state: 'visible', timeout: 5000 }) await deleteBtn.click() // Confirm deletion in dialog await this.page.getByRole('button', { name: 'Delete' }).click() if (options?.skipToastWait) { // Wait for dialog to close; do not require toast so cleanup does not fail await this.page.locator('[role="alertdialog"]').waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}) await waitForNetworkIdle(this.page) return } // Wait for success toast await this.waitForSuccessToast('deleted') } /** * Get key row locator */ getKeyRow(name: string): Locator { // Try data-testid first, fall back to finding row by text content return this.page.getByTestId(`key-row-${name}`).or( this.page.locator('tr, [role="row"]').filter({ hasText: name }) ) } /** * Get the displayed weight for a key */ async getKeyWeight(name: string): Promise { const keyRow = this.getKeyRow(name) return (await keyRow.getByTestId('key-weight-value').textContent()) ?? '' } /** * Get the enabled state of a key (switch checked or not) */ async getKeyEnabledState(name: string): Promise { const keyRow = this.getKeyRow(name) const switchEl = keyRow.getByTestId('key-enabled-switch') const checked = await switchEl.getAttribute('data-state') return checked === 'checked' } /** * Check if a key exists in the table (waits for it to appear) */ async keyExists(name: string, timeout: number = 5000): Promise { // Wait for network to settle first await waitForNetworkIdle(this.page) // Try to find the key with waiting const keyRow = this.getKeyRow(name) try { await keyRow.waitFor({ state: 'visible', timeout }) return true } catch { return false } } /** * Edit an existing key */ async editKey(keyName: string, updates: Partial): Promise { // Find the key row and open the dropdown menu const keyRow = this.getKeyRow(keyName) await keyRow.scrollIntoViewIfNeeded() // The dropdown trigger - look for ellipsis/more button const menuBtn = keyRow.locator('button').filter({ has: this.page.locator('svg') }).last() await menuBtn.waitFor({ state: 'visible', timeout: 5000 }) await menuBtn.click() // Wait for dropdown to appear and click Edit await this.page.getByRole('menuitem', { name: /Edit/i }).click() // Wait for form await expect(this.keyForm).toBeVisible() // Update fields if (updates.name) { await this.page.getByLabel('Name').clear() await this.page.getByLabel('Name').fill(updates.name) } if (updates.value) { await this.page.getByLabel('API Key').clear() await this.page.getByLabel('API Key').fill(updates.value) } if (updates.weight !== undefined) { const weightInput = this.page.getByLabel('Weight') if (await weightInput.isVisible()) { await weightInput.clear() await weightInput.fill(String(updates.weight)) } } // Save await this.keySaveBtn.click() await this.waitForSuccessToast() } /** * Delete a key */ async deleteKey(keyName: string): Promise { await this.dismissToasts() // Find the key row const keyRow = this.getKeyRow(keyName) await keyRow.scrollIntoViewIfNeeded() // The dropdown trigger - look for ellipsis/more button (last button with svg icon) const menuBtn = keyRow.locator('button').filter({ has: this.page.locator('svg') }).last() await menuBtn.waitFor({ state: 'visible', timeout: 5000 }) await menuBtn.click() // Click Delete in the dropdown await this.page.getByRole('menuitem', { name: /Delete/i }).click() // Confirm deletion in the alert dialog const confirmBtn = this.page.locator('[role="alertdialog"]').getByRole('button', { name: /Delete/i }) await confirmBtn.waitFor({ state: 'visible', timeout: 5000 }) await confirmBtn.click() // Wait for success toast await this.waitForSuccessToast('deleted') } /** * Toggle key enabled/disabled */ async toggleKeyEnabled(keyName: string): Promise { const keyRow = this.getKeyRow(keyName) const switchEl = keyRow.getByTestId('key-enabled-switch') await switchEl.click() await this.waitForSuccessToast() } /** * Get the count of keys in the table */ async getKeyCount(): Promise { const rows = this.keysTable.locator('tbody tr') const count = await rows.count() if (count === 0) { return 0 } // Check if it's the "No keys found" row const firstRowText = await rows.first().textContent() if (firstRowText?.includes('No keys found')) { return 0 } return count } /** * Helper to get base provider label for select */ private getBaseProviderLabel(type: string): string { const labels: Record = { openai: 'OpenAI', anthropic: 'Anthropic', gemini: 'Gemini', cohere: 'Cohere', bedrock: 'AWS Bedrock', } return labels[type] || type } // ============================================ // Provider Configuration Methods // ============================================ /** * Open the provider configuration sheet */ async openConfigSheet(): Promise { // If the config sheet is already open, just return const dialog = this.page.locator('[role="dialog"]') if (await dialog.isVisible().catch(() => false)) { return } const editConfigBtn = this.page.getByRole('button', { name: /Edit Provider Config/i }) await editConfigBtn.waitFor({ state: 'visible', timeout: 10000 }) await editConfigBtn.click() // Wait for the sheet to appear (SheetContent renders with role="dialog") await dialog.waitFor({ state: 'visible' }) await this.waitForSheetAnimation() } /** * Select a configuration tab */ async selectConfigTab(tabName: 'network' | 'proxy' | 'performance' | 'governance' | 'debugging'): Promise { await this.openConfigSheet() const tab = this.page.getByTestId(`provider-tab-${tabName}`) await tab.click() await this.page.waitForTimeout(300) } /** * Get the save button for the current config tab */ getConfigSaveBtn(configType: 'network' | 'proxy' | 'performance' | 'governance' | 'debugging'): Locator { const buttonNames: Record = { network: 'Save Network Configuration', proxy: 'Save Proxy Configuration', performance: 'Save Performance Configuration', governance: 'Save Governance Configuration', debugging: 'Save Debugging Configuration', } return this.page.getByRole('button', { name: buttonNames[configType] }) } // ============================================ // Performance Configuration // ============================================ /** * Get concurrency input */ getConcurrencyInput(): Locator { return this.page.getByLabel('Concurrency') } /** * Get buffer size input */ getBufferSizeInput(): Locator { return this.page.getByLabel('Buffer Size') } /** * Get raw request switch (Debugging tab: "Send Back Raw Request") */ getRawRequestSwitch(): Locator { return this.page.getByLabel('Send Back Raw Request').locator('..').locator('button[role="switch"]') } /** * Get raw response switch (Debugging tab: "Send Back Raw Response") */ getRawResponseSwitch(): Locator { return this.page.getByLabel('Send Back Raw Response').locator('..').locator('button[role="switch"]') } /** * Fill a React controlled number input by using the native value setter * and dispatching an input event. This bypasses React's value tracker * to reliably update controlled input components. */ async fillNumberInput(input: Locator, value: string): Promise { await input.click() await input.press('ControlOrMeta+a') await input.pressSequentially(value) await input.blur() } /** * Save performance configuration and wait for success toast */ async savePerformanceConfig(): Promise { const saveBtn = this.getConfigSaveBtn('performance') await saveBtn.click() await this.waitForSuccessToast() } /** * Save network configuration and wait for success toast */ async saveNetworkConfig(): Promise { const saveBtn = this.getConfigSaveBtn('network') await saveBtn.click() await this.waitForSuccessToast() } /** * Save debugging configuration and wait for success toast */ async saveDebuggingConfig(): Promise { const saveBtn = this.getConfigSaveBtn('debugging') await saveBtn.click() await this.waitForSuccessToast() } /** * Set performance configuration (concurrency, buffer size only). * For raw request/response toggles use setDebuggingConfig. */ async setPerformanceConfig(config: { concurrency?: number bufferSize?: number }): Promise { await this.selectConfigTab('performance') if (config.concurrency !== undefined) { const input = this.getConcurrencyInput() await this.fillNumberInput(input, String(config.concurrency)) } if (config.bufferSize !== undefined) { const input = this.getBufferSizeInput() await this.fillNumberInput(input, String(config.bufferSize)) } } /** * Set debugging configuration (raw request/response toggles). */ async setDebuggingConfig(config: { rawRequest?: boolean; rawResponse?: boolean }): Promise { await this.selectConfigTab('debugging') if (config.rawRequest !== undefined) { const switchEl = this.getRawRequestSwitch() const isChecked = (await switchEl.getAttribute('data-state')) === 'checked' if (isChecked !== config.rawRequest) { await switchEl.click() } } if (config.rawResponse !== undefined) { const switchEl = this.getRawResponseSwitch() const isChecked = (await switchEl.getAttribute('data-state')) === 'checked' if (isChecked !== config.rawResponse) { await switchEl.click() } } } // ============================================ // Proxy Configuration // ============================================ /** * Get proxy type select */ getProxyTypeSelect(): Locator { return this.page.getByLabel('Proxy Type').locator('..').locator('button[role="combobox"]') } /** * Set proxy configuration */ async setProxyConfig(config: { type: 'http' | 'socks5' | 'environment' | 'none' url?: string username?: string password?: string }): Promise { await this.selectConfigTab('proxy') // Select proxy type const proxySelect = this.getProxyTypeSelect() await proxySelect.click() await this.page.getByRole('option', { name: new RegExp(config.type, 'i') }).click() // Fill additional fields if not 'none' or 'environment' if (config.type === 'http' || config.type === 'socks5') { if (config.url) { await this.page.getByLabel('Proxy URL').fill(config.url) } if (config.username) { await this.page.getByLabel('Username').fill(config.username) } if (config.password) { await this.page.getByLabel('Password').fill(config.password) } } } // ============================================ // Network Configuration // ============================================ /** * Set network configuration */ async setNetworkConfig(config: { baseUrl?: string timeout?: number maxRetries?: number initialBackoff?: number maxBackoff?: number }): Promise { await this.selectConfigTab('network') if (config.baseUrl !== undefined) { const input = this.page.getByLabel(/Base URL/i) await input.clear() await input.fill(config.baseUrl) } if (config.timeout !== undefined) { const input = this.page.getByLabel(/Timeout/i) await input.clear() await input.fill(String(config.timeout)) } if (config.maxRetries !== undefined) { const input = this.page.getByLabel(/Max Retries/i) await input.clear() await input.fill(String(config.maxRetries)) } if (config.initialBackoff !== undefined) { const input = this.page.getByLabel(/Initial Backoff/i) await input.clear() await input.fill(String(config.initialBackoff)) } if (config.maxBackoff !== undefined) { const input = this.page.getByLabel(/Max Backoff/i) await input.clear() await input.fill(String(config.maxBackoff)) } } // ============================================ // Governance Configuration (Budget/Rate Limits) // ============================================ /** * Set governance configuration (budget and rate limits) */ async setGovernanceConfig(config: { budgetLimit?: number tokenLimit?: number requestLimit?: number }): Promise { await this.selectConfigTab('governance') if (config.budgetLimit !== undefined) { const input = this.page.locator('#providerBudgetMaxLimit') await input.clear() await input.fill(String(config.budgetLimit)) } if (config.tokenLimit !== undefined) { const input = this.page.locator('#providerTokenMaxLimit') await input.clear() await input.fill(String(config.tokenLimit)) } if (config.requestLimit !== undefined) { const input = this.page.locator('#providerRequestMaxLimit') await input.clear() await input.fill(String(config.requestLimit)) } } /** * Check if governance tab is visible (depends on permissions) */ async isGovernanceTabVisible(): Promise { await this.openConfigSheet() const tab = this.page.getByTestId('provider-tab-governance') return await tab.isVisible().catch(() => false) } }