diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 7d1a5ef607..b60478a22e 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -237,9 +237,9 @@ jobs:
echo "::error::$total_failed E2E test(s) failed"
exit 1
fi
- # Fail when any shard failed even if we couldn't read failure count from artifacts
- if [ "${{ needs.e2e-tests.result }}" = "failure" ]; then
- echo "::error::One or more E2E test shards failed"
+ # Fail when any shard failed or was cancelled before completing
+ if [ "${{ needs.e2e-tests.result }}" = "failure" ] || [ "${{ needs.e2e-tests.result }}" = "cancelled" ]; then
+ echo "::error::One or more E2E test shards failed (result: ${{ needs.e2e-tests.result }})"
exit 1
fi
diff --git a/AGENTS.md b/AGENTS.md
index baf1739709..9b32d055cd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -128,6 +128,29 @@ make dev-e2e FILE=navigation REPORT=1 # Open HTML report after run
make dev-e2e-clean # Remove test artifacts
```
+**After modifying shared E2E test utilities** (page objects in
+`tests/e2e/page-objects/`, components in `tests/e2e/components/`, or app source
+files that add/change `data-testid` attributes), run the **full** E2E suite —
+not just new tests — to catch regressions in existing specs.
+
+## Reviewing or Writing E2E Tests
+
+Before writing new E2E tests or reviewing a PR that adds them:
+
+1. **Map existing coverage first.** Read every spec file under
+ `tests/e2e/features/` and build a list of what behavior each test exercises
+ (not just its name — what it actually asserts).
+2. **Check each new test against that map.** A test is redundant if its
+ assertions are a subset of an existing test's, even when the existing test is
+ part of a larger workflow. Do not add isolated tests for behavior already
+ covered by comprehensive tests.
+3. **Present the overlap analysis before making changes.** Show the full
+ existing-vs-new matrix and get alignment, then write or modify code.
+4. **One spec file per feature area.** Do not split tests into `foo.spec.ts` and
+ `foo-extended.spec.ts`. If a file gets too long, split by sub-feature (e.g.,
+ `dashboard.spec.ts` for workflows, `dashboard-interactions.spec.ts` for
+ isolated CRUD), not by "basic vs extended."
+
## Important Context
- **Authentication**: Passport.js with team-based access control
diff --git a/packages/app/src/AlertsPage.tsx b/packages/app/src/AlertsPage.tsx
index 2b0545a353..6b73213097 100644
--- a/packages/app/src/AlertsPage.tsx
+++ b/packages/app/src/AlertsPage.tsx
@@ -394,13 +394,17 @@ function AlertDetails({ alert }: { alert: AlertsPageItem }) {
{alert.state === AlertState.ALERT && (
-
+
Alert
)}
- {alert.state === AlertState.OK && Ok}
+ {alert.state === AlertState.OK && (
+
+ Ok
+
+ )}
{alert.state === AlertState.DISABLED && (
-
+
Disabled
)}
diff --git a/packages/app/src/components/DBEditTimeChartForm.tsx b/packages/app/src/components/DBEditTimeChartForm.tsx
index 93a3354a10..daed5a925e 100644
--- a/packages/app/src/components/DBEditTimeChartForm.tsx
+++ b/packages/app/src/components/DBEditTimeChartForm.tsx
@@ -1097,36 +1097,42 @@ export default function EditTimeChartForm({
}
+ data-testid="chart-type-line"
>
Line/Bar
}
+ data-testid="chart-type-table"
>
Table
}
+ data-testid="chart-type-number"
>
Number
}
+ data-testid="chart-type-pie"
>
Pie
}
+ data-testid="chart-type-search"
>
Search
}
+ data-testid="chart-type-markdown"
>
Markdown
diff --git a/packages/app/src/components/SearchInput/SearchWhereInput.tsx b/packages/app/src/components/SearchInput/SearchWhereInput.tsx
index e57fc12018..4affdada88 100644
--- a/packages/app/src/components/SearchInput/SearchWhereInput.tsx
+++ b/packages/app/src/components/SearchInput/SearchWhereInput.tsx
@@ -191,7 +191,10 @@ export default function SearchWhereInput({
onLanguageChange={handleLanguageChange}
/>
-
+
{isSql ? (
{
+test.describe('Alerts', { tag: ['@alerts', '@full-stack'] }, () => {
let searchPage: SearchPage;
let dashboardPage: DashboardPage;
let alertsPage: AlertsPage;
@@ -14,42 +14,49 @@ test.describe('Alert Creation', { tag: ['@alerts', '@full-stack'] }, () => {
alertsPage = new AlertsPage(page);
});
- test(
- 'should create an alert from a saved search and verify on the alerts page',
- { tag: '@full-stack' },
- async () => {
- const ts = Date.now();
- const savedSearchName = `E2E Alert Search ${ts}`;
- const webhookName = `E2E Webhook SS ${ts}`;
- const webhookUrl = `https://example.com/ss-${ts}`;
+ /**
+ * Helper: creates a saved search alert and returns the names used.
+ */
+ async function createSavedSearchAlert() {
+ const ts = Date.now();
+ const savedSearchName = `E2E Alert Search ${ts}`;
+ const webhookName = `E2E Webhook SS ${ts}`;
+ const webhookUrl = `https://example.com/ss-${ts}`;
- await test.step('Create a saved search', async () => {
- await searchPage.goto();
- await searchPage.openSaveSearchModal();
- await searchPage.savedSearchModal.saveSearchAndWaitForNavigation(
- savedSearchName,
- );
- });
+ await test.step('Create a saved search', async () => {
+ await searchPage.goto();
+ await searchPage.openSaveSearchModal();
+ await searchPage.savedSearchModal.saveSearchAndWaitForNavigation(
+ savedSearchName,
+ );
+ });
- await test.step('Open the alerts modal from the saved search page', async () => {
- await expect(searchPage.alertsButton).toBeVisible();
- await searchPage.openAlertsModal();
- await expect(searchPage.alertModal.addNewWebhookButton).toBeVisible();
- });
+ await test.step('Open the alerts modal from the saved search page', async () => {
+ await expect(searchPage.alertsButton).toBeVisible();
+ await searchPage.openAlertsModal();
+ await expect(searchPage.alertModal.addNewWebhookButton).toBeVisible();
+ });
- await test.step('Create a new incoming webhook for the alert channel', async () => {
- await searchPage.alertModal.addWebhookAndWait(
- 'Generic',
- webhookName,
- webhookUrl,
- );
- });
+ await test.step('Create a new incoming webhook for the alert channel', async () => {
+ await searchPage.alertModal.addWebhookAndWait(
+ 'Generic',
+ webhookName,
+ webhookUrl,
+ );
+ });
- await test.step('Create the alert (webhook is auto-selected after creation)', async () => {
- // The webhook is automatically selected in the form after webhook creation
- // (handleWebhookCreated calls field.onChange(webhookId) before closing modal)
- await searchPage.alertModal.createAlert();
- });
+ await test.step('Create the alert (webhook is auto-selected after creation)', async () => {
+ await searchPage.alertModal.createAlert();
+ });
+
+ return { savedSearchName, webhookName, webhookUrl, ts };
+ }
+
+ test(
+ 'should create an alert from a saved search and verify on the alerts page',
+ { tag: '@full-stack' },
+ async () => {
+ const { savedSearchName } = await createSavedSearchAlert();
await test.step('Verify the alert is visible on the alerts page', async () => {
await alertsPage.goto();
@@ -92,7 +99,6 @@ test.describe('Alert Creation', { tag: ['@alerts', '@full-stack'] }, () => {
dashboardPage.chartEditor.addNewWebhookButton,
).toBeVisible();
await dashboardPage.chartEditor.addNewWebhookButton.click();
- // Verify webhook form opened by checking for its inner input
await expect(page.getByTestId('webhook-name-input')).toBeVisible();
await dashboardPage.chartEditor.webhookAlertModal.addWebhook(
'Generic',
@@ -100,8 +106,6 @@ test.describe('Alert Creation', { tag: ['@alerts', '@full-stack'] }, () => {
webhookUrl,
);
await expect(page.getByTestId('alert-modal')).toBeHidden();
- // The webhook is automatically selected in the form after creation
- // (handleWebhookCreated calls field.onChange(webhookId) before closing modal)
});
await test.step('Save the tile with the alert configured', async () => {
@@ -122,4 +126,104 @@ test.describe('Alert Creation', { tag: ['@alerts', '@full-stack'] }, () => {
});
},
);
+
+ test(
+ 'should display alert card with state badge and navigate to source',
+ { tag: '@full-stack' },
+ async () => {
+ const { savedSearchName } = await createSavedSearchAlert();
+
+ await test.step('Navigate to alerts page and verify card is visible', async () => {
+ await alertsPage.goto();
+ await expect(alertsPage.pageContainer).toBeVisible();
+ await expect(
+ alertsPage.getAlertLinkByName(savedSearchName),
+ ).toBeVisible({ timeout: 10000 });
+ });
+
+ await test.step('Verify the alert card has a state badge', async () => {
+ const alertCard = alertsPage.getAlertCardByName(savedSearchName);
+ await expect(alertCard).toBeVisible();
+
+ const stateBadge = alertsPage.getAlertStateBadge(alertCard);
+ await expect(stateBadge).toBeVisible();
+ });
+
+ await test.step('Click the alert link and verify navigation to saved search', async () => {
+ const alertLink = alertsPage.getAlertLinkByName(savedSearchName);
+ await alertLink.click();
+ await expect(searchPage.page).toHaveURL(/\/search\/[a-f0-9]+/, {
+ timeout: 10000,
+ });
+ });
+ },
+ );
+
+ test(
+ 'should display multiple alerts and verify ordering',
+ { tag: '@full-stack' },
+ async () => {
+ const ts1 = Date.now();
+ const savedSearchName1 = `E2E Multi Alert A ${ts1}`;
+ const webhookName1 = `E2E Multi WH A ${ts1}`;
+ const webhookUrl1 = `https://example.com/multi-a-${ts1}`;
+
+ await test.step('Create first saved search alert', async () => {
+ await searchPage.goto();
+ await searchPage.openSaveSearchModal();
+ await searchPage.savedSearchModal.saveSearchAndWaitForNavigation(
+ savedSearchName1,
+ );
+ await expect(searchPage.alertsButton).toBeVisible();
+ await searchPage.openAlertsModal();
+ await expect(searchPage.alertModal.addNewWebhookButton).toBeVisible();
+ await searchPage.alertModal.addWebhookAndWait(
+ 'Generic',
+ webhookName1,
+ webhookUrl1,
+ );
+ await searchPage.alertModal.createAlert();
+ });
+
+ const ts2 = Date.now();
+ const savedSearchName2 = `E2E Multi Alert B ${ts2}`;
+ const webhookName2 = `E2E Multi WH B ${ts2}`;
+ const webhookUrl2 = `https://example.com/multi-b-${ts2}`;
+
+ await test.step('Create second saved search alert', async () => {
+ await searchPage.goto();
+ await searchPage.openSaveSearchModal();
+ await searchPage.savedSearchModal.saveSearchAndWaitForNavigation(
+ savedSearchName2,
+ );
+ await expect(searchPage.alertsButton).toBeVisible();
+ await searchPage.openAlertsModal();
+ await expect(searchPage.alertModal.addNewWebhookButton).toBeVisible();
+ await searchPage.alertModal.addWebhookAndWait(
+ 'Generic',
+ webhookName2,
+ webhookUrl2,
+ );
+ await searchPage.alertModal.createAlert();
+ });
+
+ await test.step('Navigate to alerts page and verify both alerts are visible', async () => {
+ await alertsPage.goto();
+ await expect(alertsPage.pageContainer).toBeVisible();
+
+ await expect(
+ alertsPage.getAlertLinkByName(savedSearchName1),
+ ).toBeVisible({ timeout: 10000 });
+
+ await expect(
+ alertsPage.getAlertLinkByName(savedSearchName2),
+ ).toBeVisible({ timeout: 10000 });
+ });
+
+ await test.step('Verify multiple alert cards are rendered', async () => {
+ const alertCards = alertsPage.getAlertCards();
+ expect(await alertCards.count()).toBeGreaterThanOrEqual(2);
+ });
+ },
+ );
});
diff --git a/packages/app/tests/e2e/features/chart-explorer.spec.ts b/packages/app/tests/e2e/features/chart-explorer.spec.ts
index 7702811182..12764a4886 100644
--- a/packages/app/tests/e2e/features/chart-explorer.spec.ts
+++ b/packages/app/tests/e2e/features/chart-explorer.spec.ts
@@ -1,5 +1,9 @@
import { ChartExplorerPage } from '../page-objects/ChartExplorerPage';
import { expect, test } from '../utils/base-test';
+import {
+ DEFAULT_LOGS_SOURCE_NAME,
+ DEFAULT_METRICS_SOURCE_NAME,
+} from '../utils/constants';
test.describe('Chart Explorer Functionality', { tag: ['@charts'] }, () => {
let chartExplorerPage: ChartExplorerPage;
@@ -7,22 +11,75 @@ test.describe('Chart Explorer Functionality', { tag: ['@charts'] }, () => {
test.beforeEach(async ({ page }) => {
chartExplorerPage = new ChartExplorerPage(page);
await chartExplorerPage.goto();
+ await chartExplorerPage.chartEditor.waitForDataToLoad();
});
- test('should interact with chart configuration', async () => {
- await test.step('Verify chart configuration form is accessible', async () => {
- await expect(chartExplorerPage.form).toBeVisible();
+ test('should run a query and see chart types available', async () => {
+ await test.step('Run default query with line chart', async () => {
+ await chartExplorerPage.chartEditor.runQuery();
+ const chartContainer = chartExplorerPage.getFirstChart();
+ await expect(chartContainer).toBeVisible();
+ });
+
+ await test.step('Verify all chart type tabs are available', async () => {
+ await expect(chartExplorerPage.getChartTypeTab('line')).toBeVisible();
+ await expect(chartExplorerPage.getChartTypeTab('table')).toBeVisible();
+ await expect(chartExplorerPage.getChartTypeTab('number')).toBeVisible();
+ });
+ });
+
+ test('should create a chart with group by and verify grouped series', async () => {
+ await test.step('Select logs source and set group by', async () => {
+ await chartExplorerPage.chartEditor.selectSource(
+ DEFAULT_LOGS_SOURCE_NAME,
+ );
+ await chartExplorerPage.chartEditor.setGroupBy('ServiceName');
+ });
+
+ await test.step('Run query and verify chart renders', async () => {
+ await chartExplorerPage.chartEditor.runQuery();
+ const chartContainer = chartExplorerPage.getFirstChart();
+ await expect(chartContainer).toBeVisible();
+ });
+ });
+
+ test('should select a metric source and run query', async () => {
+ await test.step('Select metrics source and metric', async () => {
+ await chartExplorerPage.chartEditor.selectSource(
+ DEFAULT_METRICS_SOURCE_NAME,
+ );
+ await chartExplorerPage.chartEditor.selectMetric(
+ 'k8s.pod.cpu.utilization',
+ 'k8s.pod.cpu.utilization:::::::gauge',
+ );
+ });
+
+ await test.step('Run query and verify chart renders', async () => {
+ await chartExplorerPage.chartEditor.runQuery();
+ const chartContainer = chartExplorerPage.getFirstChart();
+ await expect(chartContainer).toBeVisible();
});
+ });
- await test.step('Can run basic query and display chart', async () => {
- // Use chart editor component to run query
- await expect(chartExplorerPage.chartEditor.runButton).toBeVisible();
- // wait for network idle
- await chartExplorerPage.page.waitForLoadState('networkidle');
+ test('should switch to SQL mode and run a raw SQL query', async () => {
+ await test.step('Switch to SQL mode', async () => {
+ await chartExplorerPage.chartEditor.switchToSqlMode();
+ });
+ await test.step('Type and run SQL query', async () => {
+ const sql = [
+ 'SELECT toStartOfInterval(TimestampTime, INTERVAL 60 SECOND) AS ts,',
+ ' count() AS count',
+ ' FROM default.e2e_otel_logs',
+ ' WHERE TimestampTime >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64})',
+ ' AND TimestampTime < fromUnixTimestamp64Milli({endDateMilliseconds:Int64})',
+ ' GROUP BY ts ORDER BY ts ASC',
+ ].join('');
+ await chartExplorerPage.chartEditor.typeSqlQuery(sql);
await chartExplorerPage.chartEditor.runQuery();
+ });
- // Verify chart is rendered
+ await test.step('Verify chart renders', async () => {
const chartContainer = chartExplorerPage.getFirstChart();
await expect(chartContainer).toBeVisible();
});
diff --git a/packages/app/tests/e2e/features/dashboard-interactions.spec.ts b/packages/app/tests/e2e/features/dashboard-interactions.spec.ts
new file mode 100644
index 0000000000..c2e7aa42a5
--- /dev/null
+++ b/packages/app/tests/e2e/features/dashboard-interactions.spec.ts
@@ -0,0 +1,99 @@
+// Dashboard interaction tests — simple, isolated CRUD operations.
+// Covers: add section, edit tile name, delete dashboard from listing.
+//
+// For complex end-to-end workflows (persistence, alerts, saved queries, filters,
+// Raw SQL tiles), see dashboard.spec.ts.
+import { DashboardPage } from '../page-objects/DashboardPage';
+import { DashboardsListPage } from '../page-objects/DashboardsListPage';
+import { expect, test } from '../utils/base-test';
+
+test.describe('Dashboard Interactions', { tag: ['@dashboard'] }, () => {
+ let dashboardPage: DashboardPage;
+ let dashboardsListPage: DashboardsListPage;
+
+ test.beforeEach(async ({ page }) => {
+ dashboardPage = new DashboardPage(page);
+ dashboardsListPage = new DashboardsListPage(page);
+ await dashboardPage.goto();
+ });
+
+ test('should add a dashboard section', async () => {
+ await test.step('Create a new dashboard', async () => {
+ await expect(dashboardPage.createButton).toBeVisible();
+ await dashboardPage.createNewDashboard();
+ });
+
+ await test.step('Add a section to the dashboard', async () => {
+ await dashboardPage.addSection();
+
+ // Verify that a section heading appears
+ const sectionHeader = dashboardPage.page.locator(
+ '[data-testid^="section-header-"]',
+ );
+ await expect(sectionHeader).toBeVisible({ timeout: 15000 });
+ });
+ });
+
+ test('should edit a tile and update its configuration', async () => {
+ const ts = Date.now();
+ const originalName = `Test Chart ${ts}`;
+ const updatedName = `Updated Chart ${ts}`;
+
+ await test.step('Create a new dashboard with a tile', async () => {
+ await expect(dashboardPage.createButton).toBeVisible();
+ await dashboardPage.createNewDashboard();
+
+ await expect(dashboardPage.addButton).toBeVisible();
+ await dashboardPage.addTile();
+
+ await expect(dashboardPage.chartEditor.nameInput).toBeVisible();
+ await dashboardPage.chartEditor.createBasicChart(originalName);
+
+ const dashboardTiles = dashboardPage.getTiles();
+ await expect(dashboardTiles).toHaveCount(1, { timeout: 10000 });
+ });
+
+ await test.step('Edit the tile and change its name', async () => {
+ await dashboardPage.editTile(0);
+
+ await expect(dashboardPage.chartEditor.nameInput).toBeVisible();
+ await dashboardPage.chartEditor.setChartName(updatedName);
+
+ await dashboardPage.saveTile();
+ });
+
+ await test.step('Verify the updated name appears on the dashboard', async () => {
+ const tile = dashboardPage.getTiles().filter({ hasText: updatedName });
+ await expect(tile).toBeVisible({ timeout: 10000 });
+ });
+ });
+
+ test('should delete a dashboard from the listing page', async () => {
+ const ts = Date.now();
+ const dashboardName = `Delete Me Dashboard ${ts}`;
+
+ await test.step('Create and name a new dashboard', async () => {
+ await expect(dashboardPage.createButton).toBeVisible();
+ await dashboardPage.createNewDashboard();
+
+ await dashboardPage.editDashboardName(dashboardName);
+
+ const heading = dashboardPage.getDashboardHeading(dashboardName);
+ await expect(heading).toBeVisible({ timeout: 10000 });
+ });
+
+ await test.step('Navigate to dashboards list and verify the dashboard appears', async () => {
+ await dashboardsListPage.goto();
+
+ const card = dashboardsListPage.getDashboardCard(dashboardName);
+ await expect(card).toBeVisible({ timeout: 10000 });
+ });
+
+ await test.step('Delete the dashboard from the listing page', async () => {
+ await dashboardsListPage.deleteDashboardFromCard(dashboardName);
+
+ const card = dashboardsListPage.getDashboardCard(dashboardName);
+ await expect(card).toHaveCount(0, { timeout: 10000 });
+ });
+ });
+});
diff --git a/packages/app/tests/e2e/features/dashboard.spec.ts b/packages/app/tests/e2e/features/dashboard.spec.ts
index 2872f0e0c1..4f7264d9f1 100644
--- a/packages/app/tests/e2e/features/dashboard.spec.ts
+++ b/packages/app/tests/e2e/features/dashboard.spec.ts
@@ -1,3 +1,10 @@
+// Dashboard feature tests — complex workflows and end-to-end scenarios.
+// Covers: persistence, multi-step CRUD workflows, granularity, unsaved changes,
+// alerts on tiles, filters (create/populate/delete), saved queries, URL params,
+// and Raw SQL tile types.
+//
+// For simple, isolated dashboard CRUD operations (duplicate tile, add section,
+// edit tile name, delete dashboard), see dashboard-interactions.spec.ts.
import { DisplayType } from '@hyperdx/common-utils/dist/types';
import { AlertsPage } from '../page-objects/AlertsPage';
diff --git a/packages/app/tests/e2e/features/search/search-modes.spec.ts b/packages/app/tests/e2e/features/search/search-modes.spec.ts
new file mode 100644
index 0000000000..712ab37857
--- /dev/null
+++ b/packages/app/tests/e2e/features/search/search-modes.spec.ts
@@ -0,0 +1,33 @@
+import { SearchPage } from '../../page-objects/SearchPage';
+import { expect, test } from '../../utils/base-test';
+
+test.describe('Search Query Modes', { tag: '@search' }, () => {
+ let searchPage: SearchPage;
+
+ test.beforeEach(async ({ page }) => {
+ searchPage = new SearchPage(page);
+ await searchPage.goto();
+ });
+
+ test('should preserve query mode across page interactions', async () => {
+ await test.step('Verify default is Lucene mode', async () => {
+ await expect(searchPage.input).toBeVisible();
+ });
+
+ await test.step('Switch to SQL mode', async () => {
+ await searchPage.switchToSQLMode();
+ });
+
+ await test.step('Verify SQL editor is shown', async () => {
+ await expect(searchPage.sqlEditor).toBeVisible();
+ });
+
+ await test.step('Switch back to Lucene mode', async () => {
+ await searchPage.switchToLuceneMode();
+ });
+
+ await test.step('Verify Lucene input is shown again', async () => {
+ await expect(searchPage.input).toBeVisible();
+ });
+ });
+});
diff --git a/packages/app/tests/e2e/features/search/search-table.spec.ts b/packages/app/tests/e2e/features/search/search-table.spec.ts
new file mode 100644
index 0000000000..9faf2fd0c9
--- /dev/null
+++ b/packages/app/tests/e2e/features/search/search-table.spec.ts
@@ -0,0 +1,24 @@
+import { SearchPage } from '../../page-objects/SearchPage';
+import { expect, test } from '../../utils/base-test';
+
+test.describe('Search Table Features', { tag: '@search' }, () => {
+ let searchPage: SearchPage;
+
+ test.beforeEach(async ({ page }) => {
+ searchPage = new SearchPage(page);
+ await searchPage.goto();
+ });
+
+ test('should handle empty search results gracefully', async () => {
+ await test.step('Search for nonexistent term', async () => {
+ // Use fill + click instead of performSearch which waits for rows
+ await searchPage.input.fill('xyznonexistent12345uniqueterm');
+ await searchPage.submitButton.click();
+ });
+
+ await test.step('Verify no results message appears', async () => {
+ const noResults = searchPage.page.getByTestId('db-row-table-no-results');
+ await expect(noResults).toBeVisible({ timeout: 15000 });
+ });
+ });
+});
diff --git a/packages/app/tests/e2e/features/search/side-panel.spec.ts b/packages/app/tests/e2e/features/search/side-panel.spec.ts
new file mode 100644
index 0000000000..af597ad4bc
--- /dev/null
+++ b/packages/app/tests/e2e/features/search/side-panel.spec.ts
@@ -0,0 +1,23 @@
+import { SearchPage } from '../../page-objects/SearchPage';
+import { expect, test } from '../../utils/base-test';
+
+test.describe('Side Panel Navigation', { tag: '@search' }, () => {
+ let searchPage: SearchPage;
+
+ test.beforeEach(async ({ page }) => {
+ searchPage = new SearchPage(page);
+ await searchPage.goto();
+ });
+
+ test('should close side panel with Escape key', async () => {
+ await test.step('Open side panel', async () => {
+ await searchPage.table.clickFirstRow();
+ await expect(searchPage.sidePanel.container).toBeVisible();
+ });
+
+ await test.step('Press Escape and verify panel is hidden', async () => {
+ await searchPage.page.keyboard.press('Escape');
+ await expect(searchPage.sidePanel.container).toBeHidden();
+ });
+ });
+});
diff --git a/packages/app/tests/e2e/features/services-dashboard.spec.ts b/packages/app/tests/e2e/features/services-dashboard.spec.ts
index e89559693f..9b84644a1a 100644
--- a/packages/app/tests/e2e/features/services-dashboard.spec.ts
+++ b/packages/app/tests/e2e/features/services-dashboard.spec.ts
@@ -27,6 +27,13 @@ test.describe('Services Dashboard', { tag: ['@services'] }, () => {
await expect(throughputChart).toBeVisible();
});
+ test('should display top endpoints table with data', async () => {
+ await expect(servicesPage.topEndpointsTable).toBeVisible();
+
+ const firstLink = servicesPage.topEndpointsTable.getByRole('link').first();
+ await expect(firstLink).toBeVisible();
+ });
+
test('should show filter by SpanName using Lucene', async () => {
await servicesPage.searchLucene('Order');
@@ -38,4 +45,17 @@ test.describe('Services Dashboard', { tag: ['@services'] }, () => {
await servicesPage.getTopEndpointsTableLink('Order create');
await expect(orderLink).toBeVisible();
});
+
+ test('should click an endpoint and navigate to filtered search', async ({
+ page,
+ }) => {
+ const orderLink =
+ await servicesPage.getTopEndpointsTableLink('Order create');
+ await expect(orderLink).toBeVisible();
+
+ const initialUrl = page.url();
+ await orderLink.click();
+
+ await expect(page).not.toHaveURL(initialUrl, { timeout: 10000 });
+ });
});
diff --git a/packages/app/tests/e2e/features/sessions.spec.ts b/packages/app/tests/e2e/features/sessions.spec.ts
index c3aba28a4a..1981498a71 100644
--- a/packages/app/tests/e2e/features/sessions.spec.ts
+++ b/packages/app/tests/e2e/features/sessions.spec.ts
@@ -1,42 +1,69 @@
import { SessionsPage } from '../page-objects/SessionsPage';
import { expect, test } from '../utils/base-test';
+import { DEFAULT_SESSIONS_SOURCE_NAME } from '../utils/constants';
test.describe('Client Sessions Functionality', { tag: ['@sessions'] }, () => {
let sessionsPage: SessionsPage;
test.beforeEach(async ({ page }) => {
sessionsPage = new SessionsPage(page);
+ // Navigate to search first to handle onboarding modal
+ await page.goto('/search');
+ await sessionsPage.goto();
});
- test('should load sessions page', async () => {
- await test.step('Navigate to sessions page', async () => {
- await sessionsPage.goto();
+ test('should display multiple session cards', async () => {
+ await test.step('Select data source', async () => {
+ await sessionsPage.selectDataSource(DEFAULT_SESSIONS_SOURCE_NAME);
});
- await test.step('Verify sessions page components are present', async () => {
- // Use web-first assertions instead of synchronous expect
- await expect(sessionsPage.form).toBeVisible();
- await expect(sessionsPage.dataSource).toBeVisible();
+ await test.step('Verify multiple session cards are visible', async () => {
+ const sessionCards = sessionsPage.getSessionCards();
+ await expect(sessionCards.first()).toBeVisible({ timeout: 10000 });
+ expect(await sessionCards.count()).toBeGreaterThan(1);
});
});
- test('should interact with session cards', async () => {
- await test.step('Navigate to sessions page and wait for load', async () => {
- // First go to search page to trigger onboarding modal handling
- await sessionsPage.page.goto('/search');
+ test('should open a session and display session details', async () => {
+ await test.step('Select data source and wait for cards', async () => {
+ await sessionsPage.selectDataSource(DEFAULT_SESSIONS_SOURCE_NAME);
+ await expect(sessionsPage.getFirstSessionCard()).toBeVisible({
+ timeout: 10000,
+ });
+ });
- // Then navigate to sessions page
- await sessionsPage.goto();
+ await test.step('Open first session and verify side panel opens', async () => {
+ await sessionsPage.openFirstSession();
+ const drawer = sessionsPage.page.locator('role=dialog');
+ await expect(drawer).toBeVisible({ timeout: 10000 });
+ });
+ });
- // Select the default data source
- await sessionsPage.selectDataSource();
+ test('should display session search form with data source selector', async () => {
+ await test.step('Verify form components are visible', async () => {
+ await expect(sessionsPage.form).toBeVisible();
+ await expect(sessionsPage.dataSource).toBeVisible();
});
- await test.step('Find and interact with session cards', async () => {
- const firstSession = sessionsPage.getFirstSessionCard();
+ await test.step('Verify data source selector is interactable', async () => {
+ await sessionsPage.dataSource.click();
+ const option = sessionsPage.page.locator(
+ `text=${DEFAULT_SESSIONS_SOURCE_NAME}`,
+ );
+ await expect(option).toBeVisible();
+ });
+ });
+
+ test('should filter sessions by selecting data source', async () => {
+ await test.step('Verify initial state', async () => {
+ await expect(sessionsPage.form).toBeVisible();
await expect(sessionsPage.dataSource).toBeVisible();
- await expect(firstSession).toBeVisible();
- await sessionsPage.openFirstSession();
+ });
+
+ await test.step('Select sessions data source and verify cards appear', async () => {
+ await sessionsPage.selectDataSource(DEFAULT_SESSIONS_SOURCE_NAME);
+ const firstCard = sessionsPage.getFirstSessionCard();
+ await expect(firstCard).toBeVisible({ timeout: 10000 });
});
});
});
diff --git a/packages/app/tests/e2e/features/sources.spec.ts b/packages/app/tests/e2e/features/sources.spec.ts
index b56bad6fbf..71a39b63d1 100644
--- a/packages/app/tests/e2e/features/sources.spec.ts
+++ b/packages/app/tests/e2e/features/sources.spec.ts
@@ -102,15 +102,46 @@ test.describe('Sources Functionality', { tag: ['@sources'] }, () => {
await searchPage.goto();
});
+ test('should list available sources in the dropdown', async () => {
+ await searchPage.sourceDropdown.click();
+
+ await expect(
+ searchPage.sourceOptions.filter({
+ hasText: DEFAULT_LOGS_SOURCE_NAME,
+ }),
+ ).toBeVisible();
+ await expect(
+ searchPage.sourceOptions.filter({
+ hasText: DEFAULT_TRACES_SOURCE_NAME,
+ }),
+ ).toBeVisible();
+ });
+
test('should show source actions in dropdown', async () => {
- // Open source selector dropdown
await searchPage.sourceDropdown.click();
- // Verify action items are visible in the dropdown
await expect(searchPage.createNewSourceItem).toBeVisible();
await expect(searchPage.editSourcesItem).toBeVisible();
});
+ test('should switch between different source types and see results', async () => {
+ // Logs source is selected by default after goto(), verify rows exist
+ await expect(searchPage.table.getRows().first()).toBeVisible();
+
+ // Switch to traces source
+ await searchPage.selectSource(DEFAULT_TRACES_SOURCE_NAME);
+ await searchPage.table.waitForRowsToPopulate();
+
+ // Verify traces table also has rows
+ await expect(searchPage.table.getRows().first()).toBeVisible();
+ });
+
+ test('should navigate to team page when editing sources', async () => {
+ await searchPage.openEditSourceModal();
+
+ await expect(searchPage.page).toHaveURL(/\/team/, { timeout: 10000 });
+ });
+
test(
'should show the correct source form when modal is open',
{ tag: ['@sources'] },
diff --git a/packages/app/tests/e2e/features/temporary-dashboard.spec.ts b/packages/app/tests/e2e/features/temporary-dashboard.spec.ts
index fc2c41fa19..7c2e5953ac 100644
--- a/packages/app/tests/e2e/features/temporary-dashboard.spec.ts
+++ b/packages/app/tests/e2e/features/temporary-dashboard.spec.ts
@@ -77,33 +77,4 @@ test.describe('Temporary Dashboard', { tag: ['@dashboard'] }, () => {
});
},
);
-
- test(
- 'should convert temporary dashboard to saved dashboard',
- { tag: '@full-stack' },
- async ({ page }) => {
- await dashboardPage.goto();
-
- await test.step('Verify the temporary dashboard banner is visible', async () => {
- await expect(dashboardPage.temporaryDashboardBanner).toBeVisible();
- });
-
- await test.step('Click Create New Saved Dashboard', async () => {
- await dashboardPage.createButton.click();
- });
-
- await test.step('Verify navigation to a saved dashboard', async () => {
- await expect(page).toHaveURL(/\/dashboards\/.+/, { timeout: 10000 });
- });
-
- await test.step('Verify the temporary banner is replaced by breadcrumbs', async () => {
- await expect(dashboardPage.temporaryDashboardBanner).toBeHidden();
- await expect(
- page
- .getByTestId('dashboard-page')
- .getByRole('link', { name: 'Dashboards' }),
- ).toBeVisible();
- });
- },
- );
});
diff --git a/packages/app/tests/e2e/features/traces-workflow.spec.ts b/packages/app/tests/e2e/features/traces-workflow.spec.ts
deleted file mode 100644
index f6c4661f26..0000000000
--- a/packages/app/tests/e2e/features/traces-workflow.spec.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-import { SearchPage } from '../page-objects/SearchPage';
-import { expect, test } from '../utils/base-test';
-import { DEFAULT_TRACES_SOURCE_NAME } from '../utils/constants';
-
-test.describe('Advanced Search Workflow - Traces', { tag: '@traces' }, () => {
- let searchPage: SearchPage;
-
- test.beforeEach(async ({ page }) => {
- searchPage = new SearchPage(page);
- await searchPage.goto();
- });
-
- test('Comprehensive traces workflow - search, view waterfall, navigate trace details', async () => {
- await test.step('Select Demo Traces data source', async () => {
- const sourceSelector = searchPage.page.locator(
- '[data-testid="source-selector"]',
- );
- await expect(sourceSelector).toBeVisible();
- await sourceSelector.click();
-
- const demoTracesOption = searchPage.page.locator(
- `text=${DEFAULT_TRACES_SOURCE_NAME}`,
- );
- await expect(demoTracesOption).toBeVisible();
- await demoTracesOption.click();
- });
-
- await test.step('Search for Order traces', async () => {
- await expect(searchPage.input).toBeVisible();
- await searchPage.input.fill('Order');
-
- // Use time picker component
- await searchPage.timePicker.selectRelativeTime('Last 1 days');
-
- // Perform search
- await searchPage.performSearch('Order');
- });
-
- await test.step('Verify search results', async () => {
- const searchResultsTable = searchPage.getSearchResultsTable();
- await expect(searchResultsTable).toBeVisible();
- });
-
- await test.step('Click on first trace result and open side panel', async () => {
- // Use table component to click first row
- await expect(searchPage.table.firstRow).toBeVisible();
- await searchPage.table.clickFirstRow();
-
- // Verify side panel opens
- await expect(searchPage.sidePanel.container).toBeVisible();
- });
-
- await test.step('Navigate to trace tab and verify trace visualization', async () => {
- // Use side panel component to navigate to trace tab
- await searchPage.sidePanel.clickTab('trace');
-
- // Verify trace panel is visible
- const tracePanel = searchPage.page.locator(
- '[data-testid="side-panel-tab-trace"]',
- );
- await expect(tracePanel).toBeVisible({ timeout: 5000 });
-
- // Look for trace timeline elements (the spans/timeline labels that show in trace view)
- const traceTimelineElements = searchPage.page
- .locator('[role="button"]')
- .filter({ hasText: /\w+/ });
-
- // Verify we have trace timeline elements (spans) visible using web-first assertion
- await expect(traceTimelineElements.first()).toBeVisible({
- timeout: 10000,
- });
- });
-
- await test.step('Verify event details and navigation tabs', async () => {
- const overviewTab = searchPage.page.locator('text=Overview').first();
- const columnValuesTab = searchPage.page
- .locator('text=Column Values')
- .first();
-
- await expect(overviewTab).toBeVisible();
- await expect(columnValuesTab).toBeVisible();
- });
-
- await test.step('Interact with span elements in trace waterfall', async () => {
- // Look for clickable trace span elements (buttons with role="button")
- const spanElements = searchPage.page
- .locator('[role="button"]')
- .filter({ hasText: /CartService|AddItem|POST|span|trace/ });
-
- // Verify we have span elements using web-first assertion
- await expect(spanElements.first()).toBeVisible({ timeout: 5000 });
-
- const spanCount = await spanElements.count();
- if (spanCount > 1) {
- const secondSpan = spanElements.nth(1);
- await secondSpan.scrollIntoViewIfNeeded();
- await secondSpan.click({ timeout: 3000 });
- }
- });
-
- await test.step('Verify trace attributes are displayed', async () => {
- const traceAttributes = ['TraceId', 'SpanId', 'SpanName'];
-
- for (const attribute of traceAttributes) {
- const attributeElement = searchPage.page
- .locator(`div[class*="HyperJson_key__"]`)
- .filter({ hasText: new RegExp(`^${attribute}$`) });
- await expect(attributeElement).toBeVisible();
- }
-
- await searchPage.page.keyboard.press('PageDown');
-
- // Look for section headers
- const topLevelAttributesSection = searchPage.page.locator(
- 'text=Top Level Attributes',
- );
- await expect(topLevelAttributesSection).toBeVisible();
-
- const spanAttributesSection = searchPage.page.locator(
- 'text=Span Attributes',
- );
- await expect(spanAttributesSection).toBeVisible();
- });
- });
-});
diff --git a/packages/app/tests/e2e/features/traces.spec.ts b/packages/app/tests/e2e/features/traces.spec.ts
new file mode 100644
index 0000000000..b4dbea572c
--- /dev/null
+++ b/packages/app/tests/e2e/features/traces.spec.ts
@@ -0,0 +1,139 @@
+import { SearchPage } from '../page-objects/SearchPage';
+import { expect, test } from '../utils/base-test';
+import { DEFAULT_TRACES_SOURCE_NAME } from '../utils/constants';
+
+test.describe('Traces', { tag: '@traces' }, () => {
+ let searchPage: SearchPage;
+
+ test.beforeEach(async ({ page }) => {
+ searchPage = new SearchPage(page);
+ await searchPage.goto();
+ });
+
+ test('should search for traces and display results', async () => {
+ await test.step('Select traces source', async () => {
+ await searchPage.selectSource(DEFAULT_TRACES_SOURCE_NAME);
+ await searchPage.table.waitForRowsToPopulate();
+ });
+
+ await test.step('Search for Order traces', async () => {
+ await searchPage.performSearch('Order');
+ });
+
+ await test.step('Verify search results are displayed', async () => {
+ const resultsTable = searchPage.getSearchResultsTable();
+ await expect(resultsTable).toBeVisible();
+ await expect(searchPage.table.firstRow).toBeVisible();
+ });
+ });
+
+ test('should open trace details and see side panel tabs', async () => {
+ await test.step('Select traces source and search', async () => {
+ await searchPage.selectSource(DEFAULT_TRACES_SOURCE_NAME);
+ await searchPage.table.waitForRowsToPopulate();
+ });
+
+ await test.step('Click first row to open side panel', async () => {
+ await searchPage.table.clickFirstRow();
+ await expect(searchPage.sidePanel.container).toBeVisible({
+ timeout: 10000,
+ });
+ });
+
+ await test.step('Verify side panel tabs are available', async () => {
+ await expect(searchPage.sidePanel.tabs).toBeVisible({ timeout: 10000 });
+
+ await expect(searchPage.sidePanel.getTab('trace')).toBeVisible({
+ timeout: 10000,
+ });
+ await expect(searchPage.sidePanel.getTab('parsed')).toBeVisible();
+ });
+ });
+
+ test('should filter traces by span name', async () => {
+ await test.step('Select traces source', async () => {
+ await searchPage.selectSource(DEFAULT_TRACES_SOURCE_NAME);
+ await searchPage.table.waitForRowsToPopulate();
+ });
+
+ await test.step('Search for AddItem span name', async () => {
+ await searchPage.performSearch('AddItem');
+ });
+
+ await test.step('Verify results contain matching span', async () => {
+ await expect(searchPage.table.firstRow).toBeVisible();
+
+ await searchPage.table.clickFirstRow();
+ await expect(searchPage.sidePanel.container).toBeVisible({
+ timeout: 10000,
+ });
+ });
+ });
+
+ test('should view trace waterfall and navigate trace details', async () => {
+ await test.step('Select traces source', async () => {
+ await searchPage.selectSource(DEFAULT_TRACES_SOURCE_NAME);
+ await searchPage.table.waitForRowsToPopulate();
+ });
+
+ await test.step('Search for Order traces', async () => {
+ await searchPage.timePicker.selectRelativeTime('Last 1 days');
+ await searchPage.performSearch('Order');
+ });
+
+ await test.step('Open side panel and navigate to trace tab', async () => {
+ await expect(searchPage.table.firstRow).toBeVisible();
+ await searchPage.table.clickFirstRow();
+
+ await expect(searchPage.sidePanel.container).toBeVisible();
+ await searchPage.sidePanel.clickTab('trace');
+
+ await expect(searchPage.sidePanel.getTabPanel('trace')).toBeVisible({
+ timeout: 5000,
+ });
+ });
+
+ await test.step('Verify trace timeline elements are visible', async () => {
+ const traceTimelineElements = searchPage.page
+ .locator('[role="button"]')
+ .filter({ hasText: /\w+/ });
+
+ await expect(traceTimelineElements.first()).toBeVisible({
+ timeout: 10000,
+ });
+ });
+
+ await test.step('Verify event detail tabs', async () => {
+ const overviewTab = searchPage.page.locator('text=Overview').first();
+ const columnValuesTab = searchPage.page
+ .locator('text=Column Values')
+ .first();
+
+ await expect(overviewTab).toBeVisible();
+ await expect(columnValuesTab).toBeVisible();
+ });
+
+ await test.step('Verify trace attributes are displayed', async () => {
+ const traceAttributes = ['TraceId', 'SpanId', 'SpanName'];
+
+ for (const attribute of traceAttributes) {
+ const attributeElement = searchPage.page
+ .locator(`div[class*="HyperJson_key__"]`)
+ .filter({ hasText: new RegExp(`^${attribute}$`) });
+ await expect(attributeElement).toBeVisible();
+ }
+
+ await searchPage.page.keyboard.press('PageDown');
+
+ const topLevelAttributesSection = searchPage.page.locator(
+ 'text=Top Level Attributes',
+ );
+ await expect(topLevelAttributesSection).toBeVisible();
+
+ const spanAttributesSection = searchPage.page.locator(
+ 'text=Span Attributes',
+ );
+ await expect(spanAttributesSection).toBeVisible();
+ });
+ });
+});
diff --git a/packages/app/tests/e2e/features/user-preferences.spec.ts b/packages/app/tests/e2e/features/user-preferences.spec.ts
new file mode 100644
index 0000000000..98fd96ce35
--- /dev/null
+++ b/packages/app/tests/e2e/features/user-preferences.spec.ts
@@ -0,0 +1,85 @@
+import { UserPreferencesPage } from '../page-objects/UserPreferencesPage';
+import { expect, test } from '../utils/base-test';
+
+test.describe('User Preferences', { tag: ['@core'] }, () => {
+ let userPreferencesPage: UserPreferencesPage;
+
+ test.beforeEach(async ({ page }) => {
+ userPreferencesPage = new UserPreferencesPage(page);
+ await page.goto('/search');
+ await page.waitForLoadState('load');
+ });
+
+ test('should open user menu and navigate to preferences', async () => {
+ await test.step('Open the user menu', async () => {
+ await userPreferencesPage.openUserMenu();
+ });
+
+ await test.step('Verify menu options are visible', async () => {
+ await expect(userPreferencesPage.preferencesOption).toBeVisible();
+ await expect(userPreferencesPage.teamSettingsOption).toBeVisible();
+ });
+
+ await test.step('Click preferences menu item', async () => {
+ await userPreferencesPage.preferencesOption.click();
+ });
+ });
+
+ test('should display preference options in the modal', async () => {
+ await test.step('Open the preferences modal', async () => {
+ await userPreferencesPage.openPreferences();
+ });
+
+ await test.step('Verify preferences modal is visible', async () => {
+ await expect(userPreferencesPage.dialog).toBeVisible();
+ });
+
+ await test.step('Verify time format setting is visible', async () => {
+ await expect(
+ userPreferencesPage.dialog.filter({ hasText: 'Time format' }),
+ ).toBeVisible();
+ });
+
+ await test.step('Verify UTC toggle is visible', async () => {
+ await expect(
+ userPreferencesPage.dialog.getByText('Use UTC time'),
+ ).toBeVisible();
+ });
+
+ await test.step('Verify color mode setting is visible', async () => {
+ await expect(
+ userPreferencesPage.dialog.filter({ hasText: 'Color Mode' }),
+ ).toBeVisible();
+ });
+ });
+
+ test('should open user menu and navigate to team settings', async ({
+ page,
+ }) => {
+ await test.step('Open the user menu and click team settings', async () => {
+ await userPreferencesPage.openTeamSettings();
+ });
+
+ await test.step('Verify navigation to team page', async () => {
+ await expect(page).toHaveURL(/\/team/);
+ });
+ });
+
+ test('should close user menu by pressing Escape', async () => {
+ await test.step('Open the user menu', async () => {
+ await userPreferencesPage.openUserMenu();
+ });
+
+ await test.step('Verify menu is open', async () => {
+ await expect(userPreferencesPage.preferencesOption).toBeVisible();
+ });
+
+ await test.step('Press Escape to close the menu', async () => {
+ await userPreferencesPage.page.keyboard.press('Escape');
+ });
+
+ await test.step('Verify menu is closed', async () => {
+ await expect(userPreferencesPage.preferencesOption).toBeHidden();
+ });
+ });
+});
diff --git a/packages/app/tests/e2e/page-objects/AlertsPage.ts b/packages/app/tests/e2e/page-objects/AlertsPage.ts
index 6f2c6047b6..f3e21e640a 100644
--- a/packages/app/tests/e2e/page-objects/AlertsPage.ts
+++ b/packages/app/tests/e2e/page-objects/AlertsPage.ts
@@ -54,6 +54,29 @@ export class AlertsPage {
return card.locator('[data-testid^="alert-link-"]');
}
+ /**
+ * Get an alert card by name (filtered by link text)
+ */
+ getAlertCardByName(name: string) {
+ return this.alertsPageContainer
+ .getByTestId(/^alert-card-/)
+ .filter({ hasText: name });
+ }
+
+ /**
+ * Get the state badge within an alert card
+ */
+ getAlertStateBadge(card: Locator) {
+ return card.getByTestId('alert-state-badge');
+ }
+
+ /**
+ * Get the alert link within an alert card
+ */
+ getAlertLinkByName(name: string) {
+ return this.alertsPageContainer.getByRole('link').filter({ hasText: name });
+ }
+
/**
* Open alerts creation modal
*/
diff --git a/packages/app/tests/e2e/page-objects/ChartExplorerPage.ts b/packages/app/tests/e2e/page-objects/ChartExplorerPage.ts
index e360b732bf..08e49d26af 100644
--- a/packages/app/tests/e2e/page-objects/ChartExplorerPage.ts
+++ b/packages/app/tests/e2e/page-objects/ChartExplorerPage.ts
@@ -38,6 +38,13 @@ export class ChartExplorerPage {
return this.getChartContainers().first();
}
+ /**
+ * Get a chart type tab by name (e.g., 'line', 'table', 'number')
+ */
+ getChartTypeTab(type: string) {
+ return this.page.getByTestId(`chart-type-${type}`);
+ }
+
// Getters for assertions
get form() {
diff --git a/packages/app/tests/e2e/page-objects/SearchPage.ts b/packages/app/tests/e2e/page-objects/SearchPage.ts
index 60eb3af68d..1f30705884 100644
--- a/packages/app/tests/e2e/page-objects/SearchPage.ts
+++ b/packages/app/tests/e2e/page-objects/SearchPage.ts
@@ -313,6 +313,13 @@ export class SearchPage {
return this.sqlTab;
}
+ /**
+ * Get the SQL mode editor container
+ */
+ get sqlEditor() {
+ return this.page.getByTestId('search-input-sql');
+ }
+
get sourceDropdown() {
return this.sourceSelector;
}
diff --git a/packages/app/tests/e2e/page-objects/ServicesDashboardPage.ts b/packages/app/tests/e2e/page-objects/ServicesDashboardPage.ts
index 11936144be..502701fa93 100644
--- a/packages/app/tests/e2e/page-objects/ServicesDashboardPage.ts
+++ b/packages/app/tests/e2e/page-objects/ServicesDashboardPage.ts
@@ -50,6 +50,10 @@ export class ServicesDashboardPage {
return this.page.getByTestId('services-dashboard-page');
}
+ get topEndpointsTable(): Locator {
+ return this.page.getByTestId('services-top-endpoints-table');
+ }
+
getChart(chartTestId: string): Locator {
return this.page
.getByTestId(chartTestId)
diff --git a/packages/app/tests/e2e/page-objects/UserPreferencesPage.ts b/packages/app/tests/e2e/page-objects/UserPreferencesPage.ts
new file mode 100644
index 0000000000..22f3895b71
--- /dev/null
+++ b/packages/app/tests/e2e/page-objects/UserPreferencesPage.ts
@@ -0,0 +1,53 @@
+/**
+ * UserPreferencesPage - Page object for user menu and preferences interactions
+ * Encapsulates all interactions with the user menu, preferences modal, and team settings
+ */
+import { Locator, Page } from '@playwright/test';
+
+export class UserPreferencesPage {
+ readonly page: Page;
+ private readonly userMenuTrigger: Locator;
+ private readonly preferencesMenuItem: Locator;
+ private readonly teamSettingsMenuItem: Locator;
+ private readonly preferencesDialog: Locator;
+
+ constructor(page: Page) {
+ this.page = page;
+ this.userMenuTrigger = page.getByTestId('user-menu-trigger');
+ this.preferencesMenuItem = page.getByTestId('user-preferences-menu-item');
+ this.teamSettingsMenuItem = page.getByTestId('team-settings-menu-item');
+ this.preferencesDialog = page.getByRole('dialog', {
+ name: /Preferences/,
+ });
+ }
+
+ async openUserMenu() {
+ await this.userMenuTrigger.click();
+ }
+
+ async openPreferences() {
+ await this.openUserMenu();
+ await this.preferencesMenuItem.click();
+ }
+
+ async openTeamSettings() {
+ await this.openUserMenu();
+ await this.teamSettingsMenuItem.click();
+ }
+
+ get menuTrigger() {
+ return this.userMenuTrigger;
+ }
+
+ get preferencesOption() {
+ return this.preferencesMenuItem;
+ }
+
+ get teamSettingsOption() {
+ return this.teamSettingsMenuItem;
+ }
+
+ get dialog() {
+ return this.preferencesDialog;
+ }
+}