first commit
This commit is contained in:
385
tests/e2e/features/dashboard/dashboard.spec.ts
Normal file
385
tests/e2e/features/dashboard/dashboard.spec.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import { expect, test } from '../../core/fixtures/base.fixture'
|
||||
import { waitForNetworkIdle } from '../../core/utils/test-helpers'
|
||||
import { DashboardPage } from './pages/dashboard.page'
|
||||
|
||||
test.describe('Dashboard', () => {
|
||||
test.beforeEach(async ({ dashboardPage }) => {
|
||||
await dashboardPage.goto()
|
||||
})
|
||||
|
||||
test.describe('Dashboard Display', () => {
|
||||
test('should display dashboard page', async ({ dashboardPage }) => {
|
||||
await expect(dashboardPage.pageTitle).toBeVisible()
|
||||
})
|
||||
|
||||
test('should display all chart cards', async ({ dashboardPage }) => {
|
||||
// Check that all four main charts are visible
|
||||
await expect(dashboardPage.logVolumeChart).toBeVisible()
|
||||
await expect(dashboardPage.tokenUsageChart).toBeVisible()
|
||||
await expect(dashboardPage.costChart).toBeVisible()
|
||||
await expect(dashboardPage.modelUsageChart).toBeVisible()
|
||||
})
|
||||
|
||||
test('should display date time picker', async ({ dashboardPage }) => {
|
||||
// Date picker should be visible (may be a button with date text)
|
||||
const datePicker = dashboardPage.page.locator('button').filter({ hasText: /Last/i }).or(
|
||||
dashboardPage.page.locator('[data-testid="dashboard-date-picker"]')
|
||||
)
|
||||
await expect(datePicker.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Time Period Selection', () => {
|
||||
test('should filter by time period (full flow)', async ({ dashboardPage }) => {
|
||||
// Time period control must exist and be visible (no skip)
|
||||
const trigger = dashboardPage.getDatePickerTrigger()
|
||||
await expect(trigger).toBeVisible({ timeout: 10000 })
|
||||
|
||||
// Let initial chart load finish so the refetch we wait for is the one from the period change
|
||||
await dashboardPage.waitForChartsToLoad()
|
||||
|
||||
// Wait for the chart data request that fires when we change the period (proves filter is applied)
|
||||
const responsePromise = dashboardPage.page.waitForResponse(
|
||||
(res) => res.url().includes('/logs/histogram') && res.status() === 200,
|
||||
{ timeout: 15000 }
|
||||
)
|
||||
|
||||
await dashboardPage.selectTimePeriod('1h')
|
||||
|
||||
// UI: trigger shows the selected period
|
||||
const label = await dashboardPage.getSelectedPeriodLabel()
|
||||
expect(label).toContain('Last hour')
|
||||
|
||||
// URL: selection is reflected in query state
|
||||
const url = dashboardPage.page.url()
|
||||
expect(url).toMatch(/period=1h|start_time=\d+&end_time=\d+/)
|
||||
|
||||
// Data: dashboard refetched with the new range
|
||||
await responsePromise
|
||||
})
|
||||
|
||||
test('should change time period to last hour', async ({ dashboardPage }) => {
|
||||
await dashboardPage.selectTimePeriod('1h')
|
||||
|
||||
const label = await dashboardPage.getSelectedPeriodLabel()
|
||||
expect(label).toContain('Last hour')
|
||||
|
||||
const url = dashboardPage.page.url()
|
||||
expect(url).toMatch(/period=1h|start_time=\d+&end_time=\d+/)
|
||||
})
|
||||
|
||||
test('should change time period to last 7 days', async ({ dashboardPage }) => {
|
||||
await dashboardPage.selectTimePeriod('7d')
|
||||
|
||||
const label = await dashboardPage.getSelectedPeriodLabel()
|
||||
expect(label).toContain('Last 7 days')
|
||||
|
||||
const url = dashboardPage.page.url()
|
||||
expect(url).toMatch(/period=7d|start_time=\d+&end_time=\d+/)
|
||||
})
|
||||
|
||||
test('should change time period to last 30 days', async ({ dashboardPage }) => {
|
||||
await dashboardPage.selectTimePeriod('30d')
|
||||
|
||||
const label = await dashboardPage.getSelectedPeriodLabel()
|
||||
expect(label).toContain('Last 30 days')
|
||||
|
||||
const url = dashboardPage.page.url()
|
||||
expect(url).toMatch(/period=30d|start_time=\d+&end_time=\d+/)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Chart Type Toggling', () => {
|
||||
test('should toggle volume chart type', async ({ dashboardPage }) => {
|
||||
// Get initial toggle state from DOM
|
||||
const initialToggle = dashboardPage.volumeChartToggle
|
||||
const initialState = await dashboardPage.getChartToggleState(initialToggle)
|
||||
|
||||
// Toggle the chart (method handles waiting internally)
|
||||
await dashboardPage.toggleVolumeChartType()
|
||||
|
||||
// Get new toggle state
|
||||
const newToggle = dashboardPage.volumeChartToggle
|
||||
const newState = await dashboardPage.getChartToggleState(newToggle)
|
||||
|
||||
// Chart type should have changed (state should be different)
|
||||
expect(newState).not.toBe(initialState)
|
||||
})
|
||||
|
||||
test('should toggle token chart type', async ({ dashboardPage }) => {
|
||||
const initialToggle = dashboardPage.tokenChartToggle
|
||||
const initialState = await dashboardPage.getChartToggleState(initialToggle)
|
||||
|
||||
await dashboardPage.toggleTokenChartType()
|
||||
|
||||
const newToggle = dashboardPage.tokenChartToggle
|
||||
const newState = await dashboardPage.getChartToggleState(newToggle)
|
||||
|
||||
expect(newState).not.toBe(initialState)
|
||||
})
|
||||
|
||||
test('should toggle cost chart type', async ({ dashboardPage }) => {
|
||||
const initialToggle = dashboardPage.costChartToggle
|
||||
const initialState = await dashboardPage.getChartToggleState(initialToggle)
|
||||
|
||||
await dashboardPage.toggleCostChartType()
|
||||
|
||||
const newToggle = dashboardPage.costChartToggle
|
||||
const newState = await dashboardPage.getChartToggleState(newToggle)
|
||||
|
||||
expect(newState).not.toBe(initialState)
|
||||
})
|
||||
|
||||
test('should toggle model chart type', async ({ dashboardPage }) => {
|
||||
const initialToggle = dashboardPage.modelChartToggle
|
||||
const initialState = await dashboardPage.getChartToggleState(initialToggle)
|
||||
|
||||
await dashboardPage.toggleModelChartType()
|
||||
|
||||
const newToggle = dashboardPage.modelChartToggle
|
||||
const newState = await dashboardPage.getChartToggleState(newToggle)
|
||||
|
||||
expect(newState).not.toBe(initialState)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Model Filtering', () => {
|
||||
test('should filter cost chart by model', async ({ dashboardPage }) => {
|
||||
// Wait for charts to fully load
|
||||
await dashboardPage.waitForChartsToLoad()
|
||||
|
||||
// Try to filter by a specific model if available
|
||||
const costModelFilter = dashboardPage.costModelFilter
|
||||
const isVisible = await costModelFilter.isVisible().catch(() => false)
|
||||
|
||||
if (isVisible) {
|
||||
await dashboardPage.filterCostChartByModel('all')
|
||||
|
||||
// Check that filter value is "All Models"
|
||||
const newSelected = await dashboardPage.getSelectedModel(costModelFilter)
|
||||
expect(newSelected).toContain('All Models')
|
||||
}
|
||||
})
|
||||
|
||||
test('should filter usage chart by model', async ({ dashboardPage }) => {
|
||||
// Wait for charts to fully load
|
||||
await dashboardPage.waitForChartsToLoad()
|
||||
|
||||
const usageModelFilter = dashboardPage.usageModelFilter
|
||||
const isVisible = await usageModelFilter.isVisible().catch(() => false)
|
||||
|
||||
if (isVisible) {
|
||||
await dashboardPage.filterUsageChartByModel('all')
|
||||
|
||||
// Check that filter value is "All Models"
|
||||
const newSelected = await dashboardPage.getSelectedModel(usageModelFilter)
|
||||
expect(newSelected).toContain('All Models')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Chart Loading States', () => {
|
||||
test('should show loading state initially', async ({ dashboardPage }) => {
|
||||
// Navigate to a fresh dashboard
|
||||
await dashboardPage.page.reload()
|
||||
await dashboardPage.waitForPageLoad()
|
||||
|
||||
// Charts may show loading state briefly
|
||||
// This test verifies the page loads without errors
|
||||
await expect(dashboardPage.pageTitle).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('URL State Management', () => {
|
||||
test('should preserve chart state in URL', async ({ dashboardPage }) => {
|
||||
// Change some settings
|
||||
await dashboardPage.selectTimePeriod('7d')
|
||||
await dashboardPage.toggleVolumeChartType()
|
||||
|
||||
// Check URL for period (time period should still be in URL)
|
||||
const url = dashboardPage.page.url()
|
||||
expect(url).toContain('period=7d')
|
||||
|
||||
// Check DOM state for chart toggle (may or may not be in URL)
|
||||
const toggleState = await dashboardPage.getChartToggleState(dashboardPage.volumeChartToggle)
|
||||
expect(toggleState).toBeTruthy()
|
||||
})
|
||||
|
||||
test('should restore state from URL on page load', async ({ dashboardPage }) => {
|
||||
// Set URL with specific state
|
||||
await dashboardPage.page.goto('/workspace/dashboard?period=7d&volume_chart=line')
|
||||
await waitForNetworkIdle(dashboardPage.page)
|
||||
await dashboardPage.waitForChartsToLoad()
|
||||
|
||||
// Verify page loaded with correct state from URL
|
||||
const url = dashboardPage.page.url()
|
||||
expect(url).toContain('period=7d')
|
||||
// volume_chart=line was in the URL - verify exact value persisted
|
||||
expect(url).toContain('volume_chart=line')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Chart Data Validation', () => {
|
||||
test('should render chart elements after data loads', async ({ dashboardPage }) => {
|
||||
// Wait for charts to load
|
||||
await dashboardPage.waitForChartsToLoad()
|
||||
|
||||
// Check that each chart card has a canvas or chart surface SVG (recharts-surface = actual chart, not icons)
|
||||
const volumeChartContent = dashboardPage.logVolumeChart.locator('canvas, svg.recharts-surface')
|
||||
const tokenChartContent = dashboardPage.tokenUsageChart.locator('canvas, svg.recharts-surface')
|
||||
const costChartContent = dashboardPage.costChart.locator('canvas, svg.recharts-surface')
|
||||
const modelChartContent = dashboardPage.modelUsageChart.locator('canvas, svg.recharts-surface')
|
||||
|
||||
// Each chart card should have canvas or SVG content (chart library renders into these)
|
||||
const volumeCount = await volumeChartContent.count()
|
||||
const tokenCount = await tokenChartContent.count()
|
||||
const costCount = await costChartContent.count()
|
||||
const modelCount = await modelChartContent.count()
|
||||
|
||||
// All four chart cards should have rendered content (count > 0)
|
||||
expect(volumeCount).toBeGreaterThan(0)
|
||||
expect(tokenCount).toBeGreaterThan(0)
|
||||
expect(costCount).toBeGreaterThan(0)
|
||||
expect(modelCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('should show chart legends', async ({ dashboardPage }) => {
|
||||
await dashboardPage.waitForChartsToLoad()
|
||||
|
||||
// Check that chart actions (legends/toggles) are visible
|
||||
const volumeActions = dashboardPage.page.locator('[data-testid="chart-log-volume-actions"]')
|
||||
const tokenActions = dashboardPage.page.locator('[data-testid="chart-token-usage-actions"]')
|
||||
|
||||
// Actions should be visible (they contain legends and toggles)
|
||||
await expect(volumeActions).toBeVisible()
|
||||
await expect(tokenActions).toBeVisible()
|
||||
})
|
||||
|
||||
test('should not show loading skeletons after data loads', async ({ dashboardPage }) => {
|
||||
await dashboardPage.waitForChartsToLoad()
|
||||
|
||||
// Check that no skeletons are visible (data has loaded)
|
||||
const skeletons = dashboardPage.page.locator('[data-testid="skeleton"]')
|
||||
const skeletonCount = await skeletons.count()
|
||||
|
||||
expect(skeletonCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Chart Interactions', () => {
|
||||
test('should toggle between bar and line chart for volume', async ({ dashboardPage }) => {
|
||||
await dashboardPage.waitForChartsToLoad()
|
||||
|
||||
// Get initial toggle state
|
||||
const initialState = await dashboardPage.getChartToggleState(dashboardPage.volumeChartToggle)
|
||||
|
||||
// Toggle volume chart type
|
||||
await dashboardPage.toggleVolumeChartType()
|
||||
|
||||
// DOM state should change (chart type toggles are in DOM, not URL)
|
||||
const newState = await dashboardPage.getChartToggleState(dashboardPage.volumeChartToggle)
|
||||
expect(newState).not.toBe(initialState)
|
||||
})
|
||||
|
||||
test('should update chart when time period changes', async ({ dashboardPage }) => {
|
||||
await dashboardPage.waitForChartsToLoad()
|
||||
|
||||
const initialUrl = dashboardPage.page.url()
|
||||
|
||||
await dashboardPage.selectTimePeriod('1h')
|
||||
|
||||
// Trigger should show new period (filter was applied)
|
||||
const label = await dashboardPage.getSelectedPeriodLabel()
|
||||
expect(label).toContain('Last hour')
|
||||
|
||||
const newUrl = dashboardPage.page.url()
|
||||
expect(newUrl).toMatch(/period=1h|start_time=\d+&end_time=\d+/)
|
||||
expect(newUrl).not.toBe(initialUrl)
|
||||
})
|
||||
|
||||
test('should sync model filter between cost and usage charts', async ({ dashboardPage }) => {
|
||||
await dashboardPage.waitForChartsToLoad()
|
||||
|
||||
// Check if model filters are visible
|
||||
const costFilterVisible = await dashboardPage.costModelFilter.isVisible().catch(() => false)
|
||||
const usageFilterVisible = await dashboardPage.usageModelFilter.isVisible().catch(() => false)
|
||||
|
||||
if (costFilterVisible && usageFilterVisible) {
|
||||
// Filter cost chart
|
||||
await dashboardPage.filterCostChartByModel('all')
|
||||
|
||||
// Verify filter was applied (check DOM state, not URL)
|
||||
const selectedModel = await dashboardPage.getSelectedModel(dashboardPage.costModelFilter)
|
||||
expect(selectedModel).toContain('All Models')
|
||||
}
|
||||
})
|
||||
|
||||
test('should display correct time period labels', async ({ dashboardPage }) => {
|
||||
const periods: Array<'1h' | '6h' | '24h' | '7d' | '30d'> = ['1h', '6h', '24h', '7d', '30d']
|
||||
|
||||
for (const period of periods) {
|
||||
await dashboardPage.selectTimePeriod(period)
|
||||
// Assert the date picker trigger shows the selected period (actual selected value)
|
||||
const label = await dashboardPage.getSelectedPeriodLabel()
|
||||
const expected = DashboardPage.PERIOD_LABELS[period]
|
||||
expect(label).toContain(expected)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Error States', () => {
|
||||
test('should handle empty data gracefully', async ({ dashboardPage }) => {
|
||||
// Navigate with very short time range that may have no data
|
||||
await dashboardPage.page.goto('/workspace/dashboard?period=1h')
|
||||
await waitForNetworkIdle(dashboardPage.page)
|
||||
await dashboardPage.waitForChartsToLoad()
|
||||
|
||||
// Page should still render without errors
|
||||
await expect(dashboardPage.pageTitle).toBeVisible()
|
||||
|
||||
// All chart containers should still be visible
|
||||
await expect(dashboardPage.logVolumeChart).toBeVisible()
|
||||
await expect(dashboardPage.tokenUsageChart).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Custom Date Range', () => {
|
||||
test('should open custom date range picker', async ({ dashboardPage }) => {
|
||||
await dashboardPage.waitForChartsToLoad()
|
||||
|
||||
// Look for date picker button
|
||||
const datePicker = dashboardPage.page.getByRole('button').filter({ hasText: /Last|Custom/i }).first()
|
||||
const isVisible = await datePicker.isVisible().catch(() => false)
|
||||
|
||||
if (isVisible) {
|
||||
await datePicker.click()
|
||||
|
||||
// Should see date range options or calendar
|
||||
const calendarVisible = await dashboardPage.page.locator('[role="dialog"], [role="listbox"]').isVisible().catch(() => false)
|
||||
const optionsVisible = await dashboardPage.page.getByRole('option').first().isVisible().catch(() => false)
|
||||
|
||||
expect(calendarVisible || optionsVisible).toBe(true)
|
||||
|
||||
// Close the picker
|
||||
await dashboardPage.page.keyboard.press('Escape')
|
||||
}
|
||||
})
|
||||
|
||||
test('should handle empty data for custom range', async ({ dashboardPage }) => {
|
||||
// Set a custom time range that likely has no data
|
||||
await dashboardPage.page.goto('/workspace/dashboard?period=1h')
|
||||
await waitForNetworkIdle(dashboardPage.page)
|
||||
await dashboardPage.waitForChartsToLoad()
|
||||
|
||||
// Charts should still be visible even with no data
|
||||
await expect(dashboardPage.logVolumeChart).toBeVisible()
|
||||
await expect(dashboardPage.costChart).toBeVisible()
|
||||
|
||||
// Page should not show error alerts (not matching chart legend "Error")
|
||||
const errorAlert = dashboardPage.page.locator('[role="alert"][data-variant="destructive"], .text-destructive, [data-sonner-toast][data-type="error"]')
|
||||
const hasErrorAlert = await errorAlert.count() > 0
|
||||
expect(hasErrorAlert).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
349
tests/e2e/features/dashboard/pages/dashboard.page.ts
Normal file
349
tests/e2e/features/dashboard/pages/dashboard.page.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import { Locator, Page, expect } from '@playwright/test'
|
||||
import { BasePage } from '../../../core/pages/base.page'
|
||||
import { waitForNetworkIdle } from '../../../core/utils/test-helpers'
|
||||
|
||||
/**
|
||||
* Page object for the Dashboard page
|
||||
*/
|
||||
export class DashboardPage extends BasePage {
|
||||
// Main elements
|
||||
readonly pageTitle: Locator
|
||||
readonly dateTimePicker: Locator
|
||||
|
||||
// Chart cards
|
||||
readonly logVolumeChart: Locator
|
||||
readonly tokenUsageChart: Locator
|
||||
readonly costChart: Locator
|
||||
readonly modelUsageChart: Locator
|
||||
|
||||
// Chart type toggles
|
||||
readonly volumeChartToggle: Locator
|
||||
readonly tokenChartToggle: Locator
|
||||
readonly costChartToggle: Locator
|
||||
readonly modelChartToggle: Locator
|
||||
|
||||
// Model filters
|
||||
readonly costModelFilter: Locator
|
||||
readonly usageModelFilter: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
|
||||
// Main elements
|
||||
this.pageTitle = page.getByRole('heading', { name: /Dashboard/i })
|
||||
this.dateTimePicker = page.locator('[data-testid="dashboard-date-picker"]')
|
||||
|
||||
// Chart cards - using data-testid for robust selectors
|
||||
this.logVolumeChart = page.locator('[data-testid="chart-log-volume"]')
|
||||
this.tokenUsageChart = page.locator('[data-testid="chart-token-usage"]')
|
||||
this.costChart = page.locator('[data-testid="chart-cost-total"]')
|
||||
this.modelUsageChart = page.locator('[data-testid="chart-model-usage"]')
|
||||
|
||||
// Chart type toggles - using data-testid with actions suffix
|
||||
// Volume and token charts have only ChartTypeToggle in the actions bar
|
||||
this.volumeChartToggle = page.locator('[data-testid="chart-log-volume-actions"]').locator('button').filter({ has: page.locator('svg') })
|
||||
this.tokenChartToggle = page.locator('[data-testid="chart-token-usage-actions"]').locator('button').filter({ has: page.locator('svg') })
|
||||
// Cost and model charts have model filter + ChartTypeToggle; scope to ChartTypeToggle buttons only so getChartToggleState reads the right element
|
||||
this.costChartToggle = page.locator('[data-testid="chart-cost-total-actions"]').locator('> div > div').last().locator('button')
|
||||
this.modelChartToggle = page.locator('[data-testid="chart-model-usage-actions"]').locator('> div > div').last().locator('button')
|
||||
|
||||
// Model filters - select trigger inside each chart's actions area (opens dropdown; Radix uses role=combobox or data-slot=select-trigger)
|
||||
this.costModelFilter = page.locator('[data-testid="chart-cost-total-actions"]').locator('[role="combobox"], [data-slot="select-trigger"]').first()
|
||||
this.usageModelFilter = page.locator('[data-testid="chart-model-usage-actions"]').locator('[role="combobox"], [data-slot="select-trigger"]').first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the dashboard page
|
||||
*/
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto('/workspace/dashboard')
|
||||
await waitForNetworkIdle(this.page)
|
||||
// Wait for charts to load
|
||||
await this.waitForChartsToLoad()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if dashboard is loaded
|
||||
*/
|
||||
async isLoaded(): Promise<boolean> {
|
||||
try {
|
||||
await expect(this.pageTitle).toBeVisible({ timeout: 5000 })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close any open popups (date picker, dropdowns, etc.)
|
||||
*/
|
||||
async closePopups(): Promise<void> {
|
||||
// Check for open date picker dialog and close it
|
||||
const datePickerDialog = this.page.locator('[data-radix-popper-content-wrapper]')
|
||||
if (await datePickerDialog.isVisible().catch(() => false)) {
|
||||
await this.page.keyboard.press('Escape')
|
||||
await datePickerDialog.waitFor({ state: 'hidden', timeout: 2000 }).catch(() => {})
|
||||
}
|
||||
// Check for open listbox and close it
|
||||
const listbox = this.page.locator('[role="listbox"]')
|
||||
if (await listbox.isVisible().catch(() => false)) {
|
||||
await this.page.keyboard.press('Escape')
|
||||
await listbox.waitFor({ state: 'hidden', timeout: 2000 }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Period label map used by the date picker (must match UI) */
|
||||
static readonly PERIOD_LABELS: Record<string, string> = {
|
||||
'1h': 'Last hour',
|
||||
'6h': 'Last 6 hours',
|
||||
'24h': 'Last 24 hours',
|
||||
'7d': 'Last 7 days',
|
||||
'30d': 'Last 30 days',
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the date picker trigger button (the button that shows the current period and opens the popover).
|
||||
* Identified by having the calendar icon so we don't match preset buttons inside the popover.
|
||||
*/
|
||||
getDatePickerTrigger(): Locator {
|
||||
return this.page.locator('button').filter({ has: this.page.locator('svg') }).filter({ hasText: /Last|Pick/i }).first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently displayed period label from the date picker trigger (what the user sees as selected).
|
||||
*/
|
||||
async getSelectedPeriodLabel(): Promise<string> {
|
||||
const trigger = this.getDatePickerTrigger()
|
||||
await trigger.waitFor({ state: 'visible', timeout: 5000 })
|
||||
const text = await trigger.textContent()
|
||||
return (text ?? '').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a predefined time period
|
||||
*/
|
||||
async selectTimePeriod(period: '1h' | '6h' | '24h' | '7d' | '30d'): Promise<void> {
|
||||
await this.closePopups()
|
||||
|
||||
const trigger = this.getDatePickerTrigger()
|
||||
await trigger.click()
|
||||
|
||||
// Wait for dialog to open
|
||||
await this.page.waitForSelector('[data-radix-popper-content-wrapper]', { timeout: 5000 }).catch(() => {})
|
||||
|
||||
const label = DashboardPage.PERIOD_LABELS[period]
|
||||
await this.page.getByRole('button', { name: label }).click()
|
||||
|
||||
// Wait for dialog to close
|
||||
await this.page.locator('[data-radix-popper-content-wrapper]').waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {})
|
||||
|
||||
await waitForNetworkIdle(this.page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the inactive toggle button from a set of buttons (the one to click to switch chart type).
|
||||
*/
|
||||
private async getInactiveToggleButtonFrom(buttons: Locator): Promise<Locator> {
|
||||
const count = await buttons.count()
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const btn = buttons.nth(i)
|
||||
const className = await btn.getAttribute('class').catch(() => '')
|
||||
const hasActive = await btn.evaluate((el) => el.hasAttribute('active')).catch(() => false)
|
||||
|
||||
if (!className?.includes('bg-secondary') && !hasActive) {
|
||||
return btn
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`No inactive toggle button found among ${count} buttons`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the inactive toggle button (the one to click to switch chart type) from a full actions container.
|
||||
*/
|
||||
private async getInactiveToggleButton(actionsContainer: Locator): Promise<Locator> {
|
||||
const buttons = actionsContainer.locator('button')
|
||||
return this.getInactiveToggleButtonFrom(buttons)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle chart type for volume chart (clicks inactive button to switch)
|
||||
*/
|
||||
async toggleVolumeChartType(): Promise<void> {
|
||||
await this.dismissToasts()
|
||||
await this.closePopups()
|
||||
const actionsContainer = this.page.locator('[data-testid="chart-log-volume-actions"]')
|
||||
const toggleBtn = await this.getInactiveToggleButton(actionsContainer)
|
||||
await toggleBtn.waitFor({ state: 'visible' })
|
||||
await toggleBtn.click()
|
||||
await this.page.waitForLoadState('networkidle').catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle chart type for token chart
|
||||
*/
|
||||
async toggleTokenChartType(): Promise<void> {
|
||||
await this.dismissToasts()
|
||||
await this.closePopups()
|
||||
const actionsContainer = this.page.locator('[data-testid="chart-token-usage-actions"]')
|
||||
const toggleBtn = await this.getInactiveToggleButton(actionsContainer)
|
||||
await toggleBtn.waitFor({ state: 'visible' })
|
||||
await toggleBtn.click()
|
||||
await this.page.waitForLoadState('networkidle').catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle chart type for cost chart.
|
||||
* Scopes to the ChartTypeToggle only (excludes the model dropdown in the same actions bar).
|
||||
*/
|
||||
async toggleCostChartType(): Promise<void> {
|
||||
await this.dismissToasts()
|
||||
await this.closePopups()
|
||||
const actionsContainer = this.page.locator('[data-testid="chart-cost-total-actions"]')
|
||||
// ChartTypeToggle is the last child in the actions bar; its two buttons are the bar/line toggles only
|
||||
const chartTypeButtons = actionsContainer.locator('> div > div').last().locator('button')
|
||||
const toggleBtn = await this.getInactiveToggleButtonFrom(chartTypeButtons)
|
||||
await toggleBtn.waitFor({ state: 'visible' })
|
||||
await toggleBtn.click()
|
||||
await this.page.waitForLoadState('networkidle').catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle chart type for model chart.
|
||||
* Scopes to the ChartTypeToggle only (excludes the model dropdown in the same actions bar).
|
||||
*/
|
||||
async toggleModelChartType(): Promise<void> {
|
||||
await this.dismissToasts()
|
||||
await this.closePopups()
|
||||
const actionsContainer = this.page.locator('[data-testid="chart-model-usage-actions"]')
|
||||
// ChartTypeToggle is the last child in the actions bar; its two buttons are the bar/line toggles only
|
||||
const chartTypeButtons = actionsContainer.locator('> div > div').last().locator('button')
|
||||
const toggleBtn = await this.getInactiveToggleButtonFrom(chartTypeButtons)
|
||||
await toggleBtn.waitFor({ state: 'visible' })
|
||||
await toggleBtn.click()
|
||||
await this.page.waitForLoadState('networkidle').catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter cost chart by model. Opens the model dropdown, then selects the option.
|
||||
*/
|
||||
async filterCostChartByModel(model: string): Promise<void> {
|
||||
await this.dismissToasts()
|
||||
await this.costModelFilter.waitFor({ state: 'visible' })
|
||||
// Open the dropdown by clicking the trigger
|
||||
await this.costModelFilter.click()
|
||||
// Wait for dropdown to open (option becomes visible in portal)
|
||||
const optionName = model === 'all' ? 'All Models' : model
|
||||
await this.page.getByRole('option', { name: optionName }).waitFor({ state: 'visible', timeout: 5000 })
|
||||
await this.page.getByRole('option', { name: optionName }).click()
|
||||
// Wait for dropdown to close and data to refresh
|
||||
await this.page.getByRole('option', { name: optionName }).waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {})
|
||||
await waitForNetworkIdle(this.page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter usage chart by model. Opens the model dropdown, then selects the option.
|
||||
*/
|
||||
async filterUsageChartByModel(model: string): Promise<void> {
|
||||
await this.dismissToasts()
|
||||
await this.usageModelFilter.waitFor({ state: 'visible' })
|
||||
// Open the dropdown by clicking the trigger
|
||||
await this.usageModelFilter.click()
|
||||
// Wait for dropdown to open (option becomes visible in portal)
|
||||
const optionName = model === 'all' ? 'All Models' : model
|
||||
await this.page.getByRole('option', { name: optionName }).waitFor({ state: 'visible', timeout: 5000 })
|
||||
await this.page.getByRole('option', { name: optionName }).click()
|
||||
// Wait for dropdown to close and data to refresh
|
||||
await this.page.getByRole('option', { name: optionName }).waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {})
|
||||
await waitForNetworkIdle(this.page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if chart is visible
|
||||
*/
|
||||
async isChartVisible(chartTitle: string): Promise<boolean> {
|
||||
// Map chart titles to test IDs
|
||||
const testIdMap: Record<string, string> = {
|
||||
'Request Volume': 'chart-log-volume',
|
||||
'Token Usage': 'chart-token-usage',
|
||||
'Cost': 'chart-cost-total',
|
||||
'Model Usage': 'chart-model-usage',
|
||||
}
|
||||
const testId = testIdMap[chartTitle]
|
||||
if (testId) {
|
||||
return await this.page.locator(`[data-testid="${testId}"]`).isVisible()
|
||||
}
|
||||
// Fallback for unknown titles
|
||||
const chart = this.page.locator(`text=${chartTitle}`).locator('..').locator('..')
|
||||
return await chart.isVisible()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if chart is loading
|
||||
*/
|
||||
async isChartLoading(chartTitle: string): Promise<boolean> {
|
||||
// Map chart titles to test IDs
|
||||
const testIdMap: Record<string, string> = {
|
||||
'Request Volume': 'chart-log-volume',
|
||||
'Token Usage': 'chart-token-usage',
|
||||
'Cost': 'chart-cost-total',
|
||||
'Model Usage': 'chart-model-usage',
|
||||
}
|
||||
const testId = testIdMap[chartTitle]
|
||||
if (testId) {
|
||||
const chartCard = this.page.locator(`[data-testid="${testId}"]`)
|
||||
const skeleton = chartCard.locator('[data-testid="skeleton"]')
|
||||
return await skeleton.isVisible().catch(() => false)
|
||||
}
|
||||
// Fallback for unknown titles
|
||||
const chartCard = this.page.locator(`text=${chartTitle}`).locator('..').locator('..')
|
||||
const skeleton = chartCard.locator('[data-testid="skeleton"]').or(chartCard.locator('.skeleton'))
|
||||
return await skeleton.isVisible().catch(() => false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get URL parameters
|
||||
*/
|
||||
getUrlParams(): URLSearchParams {
|
||||
return new URLSearchParams(this.page.url().split('?')[1] || '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chart toggle state (checks aria-pressed, data-state, or active class)
|
||||
*/
|
||||
async getChartToggleState(toggle: Locator): Promise<string | null> {
|
||||
// Handle case where toggle might match multiple elements
|
||||
const firstToggle = toggle.first()
|
||||
|
||||
// Try aria-pressed first (for button toggles)
|
||||
const ariaPressed = await firstToggle.getAttribute('aria-pressed').catch(() => null)
|
||||
if (ariaPressed) {
|
||||
return ariaPressed
|
||||
}
|
||||
// Try data-state (for switch components)
|
||||
const dataState = await firstToggle.getAttribute('data-state').catch(() => null)
|
||||
if (dataState) {
|
||||
return dataState
|
||||
}
|
||||
// Check if button is active (has active class or attribute)
|
||||
const classAttr = await firstToggle.getAttribute('class').catch(() => null)
|
||||
if (classAttr?.includes('bg-secondary')) {
|
||||
return 'active'
|
||||
}
|
||||
// Check for [active] attribute
|
||||
const isActive = await firstToggle.evaluate((el) => el.hasAttribute('active')).catch(() => false)
|
||||
if (isActive) {
|
||||
return 'active'
|
||||
}
|
||||
return 'inactive'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get selected model from filter combobox
|
||||
*/
|
||||
async getSelectedModel(filter: Locator): Promise<string | null> {
|
||||
const selectedText = await filter.textContent()
|
||||
return selectedText
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user