Compare commits

..

12 Commits

Author SHA1 Message Date
Abhi kumar
7224fc61dc Merge branch 'main' into e2e/dashboard-create-flow 2026-05-31 04:17:35 +05:30
Abhi Kumar
0a346605c7 chore: pr review fixes 2026-05-31 04:17:23 +05:30
Abhi Kumar
33a7789f9a chore: removed duplicate tests 2026-05-25 20:51:03 +05:30
Abhi kumar
c3762b789d Merge branch 'main' into e2e/dashboard-create-flow 2026-05-25 17:41:21 +05:30
Abhi Kumar
cff88f9d9f chore: fixed lint issue 2026-05-25 17:41:06 +05:30
Abhi Kumar
4b3518cb9e chore: cleaned up duplicate tests 2026-05-25 16:56:12 +05:30
Abhi Kumar
bd99b637f3 chore: ran oxfmt 2026-05-25 14:16:41 +05:30
Abhi Kumar
46844cf091 Merge branch 'main' of https://github.com/SigNoz/signoz into e2e/dashboard-create-flow 2026-05-25 14:16:04 +05:30
Abhi Kumar
1f63ebff14 chore: added more tests 2026-05-25 13:27:00 +05:30
Abhi Kumar
ed463a87e4 Merge branch 'main' of https://github.com/SigNoz/signoz into e2e/dashboard-create-flow 2026-05-25 12:02:32 +05:30
Abhi kumar
9cba7e88ec Merge branch 'main' into e2e/dashboard-create-flow 2026-05-18 00:19:17 +05:30
Abhi Kumar
e4949379e2 test: added e2e tests for dashboard create flow 2026-05-18 00:11:35 +05:30
7 changed files with 443 additions and 20 deletions

View File

@@ -68,22 +68,9 @@ jobs:
- name: pnpm-install
run: |
cd tests/e2e && pnpm install --frozen-lockfile
- name: playwright-cache
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('tests/e2e/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-playwright-
- name: playwright-browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: |
cd tests/e2e && pnpm playwright install --with-deps ${{ matrix.project }}
- name: playwright-system-deps
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: |
cd tests/e2e && pnpm playwright install-deps ${{ matrix.project }}
- name: bring-up-stack
run: |
cd tests && \

View File

@@ -8,6 +8,10 @@ import {
} from '@playwright/test';
import apmMetricsTemplate from '../testdata/apm-metrics.json';
import queriesData from '../testdata/queries.json';
export type SignalType = 'metrics' | 'logs' | 'traces';
export type QueriesData = typeof queriesData;
import chartDataTemplate from '../testdata/chart-data-dashboard.json';
import variablesTemplate from '../testdata/variables-dashboard.json';
@@ -366,6 +370,36 @@ export async function findDashboardIdByTitle(
return body.data.find((d) => d.data.title === title)?.id;
}
/** Shape of the persisted dashboard payload returned by GET /api/v1/dashboards/<id>. */
export interface DashboardData {
title: string;
description?: string;
tags?: string[];
widgets?: Array<{ id?: string; title?: string }>;
variables?: Record<string, Record<string, unknown>>;
layout?: unknown[];
}
/**
* Fetch the persisted dashboard payload via API. Use this for "did the save
* actually land on the server?" assertions — UI-only checks can pass on
* optimistic-update bugs.
*/
export async function fetchDashboardData(
page: Page,
id: string,
): Promise<DashboardData> {
const token = await authToken(page);
const res = await page.request.get(`/api/v1/dashboards/${id}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
throw new Error(`GET /dashboards/${id} ${res.status()}: ${await res.text()}`);
}
const body = (await res.json()) as { data: { data: DashboardData } };
return body.data.data;
}
// ─── List page UI helpers ────────────────────────────────────────────────
/**
@@ -387,3 +421,142 @@ export async function openDashboardActionMenu(
await icon.click();
return page.getByRole('tooltip');
}
// ─── Dashboard detail page helpers ──────────────────────────────────────────
/**
* Click the Configure button (`data-testid="show-drawer"`) on a dashboard
* detail page and wait for the settings drawer (`.settings-container-root`) to
* be visible. Works from both the empty-state view and the populated toolbar —
* both render the same testid.
*
* Returns the drawer locator so callers can scope further assertions to it.
*/
export async function openDashboardSettingsDrawer(
page: Page,
): Promise<Locator> {
await page.getByTestId('show-drawer').first().click();
const drawer = page.locator('.settings-container-root');
await drawer.waitFor({ state: 'visible' });
return drawer;
}
/**
* Click `data-testid="save-dashboard-config"` and wait for the resulting
* `PUT /api/v1/dashboards/<id>` response. The Save button is only rendered
* when there is at least one unsaved change — callers must ensure the drawer
* has been dirtied before calling this.
*/
export async function saveDashboardSettings(page: Page): Promise<void> {
const patchResponse = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
);
await page.getByTestId('save-dashboard-config').click();
await patchResponse;
}
/**
* Rename a dashboard via the toolbar options popover:
* opens the popover (`data-testid="options"`), clicks "Rename", fills the
* input, clicks "Rename Dashboard", and waits for the PUT response.
*
* Pre-condition: the caller must be on the dashboard detail page.
*/
export async function renameDashboardViaToolbar(
page: Page,
newTitle: string,
): Promise<void> {
await page.getByTestId('options').click();
await page.getByRole('button', { name: 'Rename' }).click();
const modal = page.getByRole('dialog');
await modal.waitFor({ state: 'visible' });
const input = modal.getByTestId('dashboard-name');
await input.clear();
await input.fill(newTitle);
const patchResponse = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
);
await page.getByRole('button', { name: 'Rename Dashboard' }).click();
await patchResponse;
await modal.waitFor({ state: 'hidden' });
}
// ─── Add panel flow ─────────────────────────────────────────────────────────
/**
* From the dashboard detail page (must already be loaded), drive the full
* "Add Panel" flow for the given signal type:
* 1. Click the empty-state `add-panel` CTA to open the New Panel modal.
* 2. Pick the Time Series panel type.
* 3. Fill the panel name in the right pane (drives the post-save assertion).
* 4. For metrics: type the metric name from `queries.json` into the metric
* AutoComplete and select it from the dropdown. For logs/traces: switch
* the data-source selector to LOGS / TRACES; default Query Builder state
* is sufficient (queries.json query strings are empty by design).
* 5. Click Save Changes and wait for the PUT /api/v1/dashboards/<id> response.
*
* Throws if the PUT response is not 2xx. After return, the page is back on
* the dashboard detail page; the caller asserts the panel rendered.
*/
export async function configureAndSavePanel(
page: Page,
signal: SignalType,
panelTitle: string,
): Promise<void> {
await page.getByTestId('add-panel').click();
const newPanelModal = page
.getByRole('dialog')
.filter({ hasText: 'New Panel' });
await newPanelModal.waitFor({ state: 'visible' });
await newPanelModal.getByTestId('panel-type-graph').click();
await page.getByTestId('new-widget-save').waitFor({ state: 'visible' });
await page.getByTestId('panel-name-input').fill(panelTitle);
if (signal === 'metrics') {
const metricName = queriesData.metrics.metricName;
// The testid is on the Ant Select wrapper <div>; the editable input
// lives inside it. Target the descendant input for fill().
const metricInput = page
.getByTestId('metric-name-selector-0')
.locator('input');
await metricInput.click();
await metricInput.fill(metricName);
// AutoComplete debounces and fetches; wait for the option then click.
await page
.locator('.ant-select-item-option-content', { hasText: metricName })
.first()
.click();
} else {
// logs / traces — switch the data source. Default query is sufficient.
await page.getByTestId('query-data-source-selector-0').click();
await page
.locator('.ant-select-item-option-content', {
hasText: signal.toUpperCase(),
})
.click();
}
const putResponse = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
);
await page.getByTestId('new-widget-save').click();
const res = await putResponse;
if (!res.ok()) {
throw new Error(
`PUT /api/v1/dashboards failed ${res.status()}: ${await res.text()}`,
);
}
// Save navigates back to /dashboard/<id> (no /new suffix).
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
}

12
tests/e2e/testdata/queries.json vendored Normal file
View File

@@ -0,0 +1,12 @@
{
"logs": {
"query": ""
},
"metrics": {
"metricName": "signoz_calls_total",
"query": ""
},
"traces": {
"query": ""
}
}

View File

@@ -2,6 +2,7 @@ import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
configureAndSavePanel,
createDashboardViaApi,
deleteDashboardViaApi,
} from '../../../helpers/dashboards';
@@ -143,4 +144,59 @@ test.describe('Dashboard Detail — Add Panel (entry-point + persistence)', () =
page.getByText(panelName, { exact: true }).first(),
).toBeVisible();
});
// ─── Per-signal panel creation ───────────────────────────────────────────
//
// Configure a query for each signal using values from testdata/queries.json,
// save the panel, return to the dashboard, and verify the panel card renders
// and survives a reload.
test('TC-03 add metrics Time Series panel using signoz_calls_total from queries.json', async ({
authedPage: page,
}) => {
const id = await createDashboardViaApi(page, 'add-panel-metrics');
seedIds.add(id);
await page.goto(`/dashboard/${id}`);
await expect(page.getByTestId('add-panel')).toBeVisible();
await configureAndSavePanel(page, 'metrics', 'metrics-timeseries');
await expect(page.getByTestId('metrics-timeseries')).toBeVisible();
// Reload — proves the panel persists, not just optimistic UI from the save.
await page.reload();
await expect(page.getByTestId('metrics-timeseries')).toBeVisible();
});
test('TC-04 add logs Time Series panel with default query from queries.json', async ({
authedPage: page,
}) => {
const id = await createDashboardViaApi(page, 'add-panel-logs');
seedIds.add(id);
await page.goto(`/dashboard/${id}`);
await expect(page.getByTestId('add-panel')).toBeVisible();
await configureAndSavePanel(page, 'logs', 'logs-timeseries');
await expect(page.getByTestId('logs-timeseries')).toBeVisible();
await page.reload();
await expect(page.getByTestId('logs-timeseries')).toBeVisible();
});
test('TC-05 add traces Time Series panel with default query from queries.json', async ({
authedPage: page,
}) => {
const id = await createDashboardViaApi(page, 'add-panel-traces');
seedIds.add(id);
await page.goto(`/dashboard/${id}`);
await expect(page.getByTestId('add-panel')).toBeVisible();
await configureAndSavePanel(page, 'traces', 'traces-timeseries');
await expect(page.getByTestId('traces-timeseries')).toBeVisible();
await page.reload();
await expect(page.getByTestId('traces-timeseries')).toBeVisible();
});
});

View File

@@ -7,6 +7,10 @@ import {
awaitVariablesResolved,
createDashboardViaApi,
deleteDashboardViaApi,
fetchDashboardData,
gotoDashboardsList,
renameDashboardViaToolbar,
SEARCH_PLACEHOLDER,
} from '../../../helpers/dashboards';
const TELEMETRY_DEPENDENT_VARS = ['q_env', 'q_service', 'd_namespace'];
@@ -684,4 +688,38 @@ test.describe('Dashboard Detail — Configure drawer', () => {
.first(),
).toBeVisible();
});
// ─── Toolbar rename ──────────────────────────────────────────────────────
// The toolbar options popover uses a separate PUT path from the Configure
// drawer (TC-02 above). Both paths must persist server-side and surface in
// the list view — this catches optimistic-update regressions specific to
// the toolbar entry point.
test('TC-22 rename via toolbar options popover persists to the toolbar title', async ({
authedPage: page,
}) => {
const id = await seed(page, 'cfg-toolbar-rename');
await page.goto(`/dashboard/${id}`);
// DashboardDescription toolbar always renders — even on blank dashboards.
await expect(page.getByTestId('options')).toBeVisible();
await renameDashboardViaToolbar(page, 'cfg-toolbar-rename-renamed');
await expect(page.getByTestId('dashboard-title')).toHaveText(
'cfg-toolbar-rename-renamed',
);
const persisted = await fetchDashboardData(page, id);
expect(persisted.title).toBe('cfg-toolbar-rename-renamed');
// List view reflects the rename after navigating back.
await gotoDashboardsList(page);
await page
.getByPlaceholder(SEARCH_PLACEHOLDER)
.fill('cfg-toolbar-rename-renamed');
await expect(
page.getByText('cfg-toolbar-rename-renamed').first(),
).toBeVisible();
});
});

View File

@@ -9,6 +9,7 @@ import {
createDashboardViaApi,
createVariablesDashboardViaApi,
deleteDashboardViaApi,
openDashboardSettingsDrawer,
} from '../../../helpers/dashboards';
const seedIds = new Set<string>();
@@ -240,4 +241,25 @@ test.describe('Dashboard Detail Page — Edge Cases', () => {
// document.title is set from the dashboard name — confirm it is intact.
await expect(page).toHaveTitle(new RegExp('Spec & Chars'));
});
test('TC-09 navigating away with the settings drawer open does not crash', async ({
authedPage: page,
}) => {
const id = await createDashboardViaApi(page, 'edge-drawer-nav-away');
seedIds.add(id);
await page.goto(`/dashboard/${id}`);
await openDashboardSettingsDrawer(page);
// Navigate away without closing the drawer.
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/dashboard($|\?)/);
await expect(
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
).toBeVisible();
// No error overlay should be present.
await expect(
page.getByRole('alert').filter({ hasText: /error/i }),
).toHaveCount(0);
});
});

View File

@@ -1,3 +1,4 @@
import path from 'path';
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
@@ -8,6 +9,7 @@ import {
createDashboardViaApi,
DEFAULT_DASHBOARD_TITLE,
deleteDashboardViaApi,
fetchDashboardData,
findDashboardIdByTitle,
gotoDashboardsList,
importApmMetricsDashboardViaUI,
@@ -15,6 +17,11 @@ import {
SEARCH_PLACEHOLDER,
} from '../../helpers/dashboards';
const APM_METRICS_TESTDATA_PATH = path.resolve(
__dirname,
'../../testdata/apm-metrics.json',
);
// Tests in this file mutate the dashboard list (create / delete). Run them
// serially within the worker so state from one test does not leak into
// another's assertions. Files still run in parallel via the project-level
@@ -422,12 +429,140 @@ test.describe('Dashboards List Page', () => {
await expect(page).toHaveURL(/\/dashboard($|\?)/);
});
test('TC-19 import via file upload creates dashboard, navigates to detail, and surfaces metadata in list', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('import-json-menu-cta').click();
const dialog = page
.getByRole('dialog')
.filter({ hasText: 'Import Dashboard JSON' });
await expect(dialog).toBeVisible();
const postResponse = page.waitForResponse(
(r) =>
r.request().method() === 'POST' && /\/api\/v1\/dashboards/.test(r.url()),
);
await dialog
.locator('input[type="file"]')
.setInputFiles(APM_METRICS_TESTDATA_PATH);
await dialog.getByRole('button', { name: 'Import and Next' }).click();
const res = await postResponse;
expect(res.status()).toBeGreaterThanOrEqual(200);
expect(res.status()).toBeLessThan(300);
await page.waitForURL(/\/dashboard\/[0-9a-f-]+/);
// Register for cleanup.
const urlMatch = page.url().match(/\/dashboard\/([0-9a-f-]+)/);
expect(urlMatch, 'URL must contain dashboard ID').not.toBeNull();
seedIds.add(urlMatch![1]);
await expect(page.getByTestId('dashboard-title')).toHaveText(
APM_METRICS_TITLE,
);
// Server-side check: every widget + tag from the fixture must be persisted.
// A partial import (e.g. silently dropped widgets) would pass the UI title
// check but fail here. The apm-metrics fixture has 16 widgets and 4 tags.
const persisted = await fetchDashboardData(page, urlMatch![1]);
expect(persisted.widgets?.length).toBe(16);
expect(persisted.tags).toEqual(
expect.arrayContaining(['apm', 'latency', 'error rate', 'throughput']),
);
// Navigate back and confirm the imported dashboard surfaces in the list
// with at least one tag chip.
await gotoDashboardsList(page);
await page.getByPlaceholder(SEARCH_PLACEHOLDER).fill(APM_METRICS_TITLE);
await expect(page.getByText(APM_METRICS_TITLE).first()).toBeVisible();
// The apm-metrics fixture has tags ['apm', 'latency', 'error rate', 'throughput'].
await expect(page.getByText('apm').first()).toBeVisible();
});
// The Monaco paste path is intentionally not covered — the file-upload
// path (TC-19) exercises the same populate-editor-then-import code path.
// Keyboard-typing large JSON into Monaco is unreliable in headless CI.
test('TC-20 invalid JSON via file upload shows "Invalid JSON" error', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('import-json-menu-cta').click();
const dialog = page
.getByRole('dialog')
.filter({ hasText: 'Import Dashboard JSON' });
await expect(dialog).toBeVisible();
// Track POST attempts: invalid JSON must never reach the create endpoint.
let postFired = false;
await page.route(/\/api\/v1\/dashboards(\?|$)/, (route) => {
if (route.request().method() === 'POST') {
postFired = true;
}
void route.continue();
});
await dialog.locator('input[type="file"]').setInputFiles({
name: 'bad.json',
mimeType: 'application/json',
buffer: Buffer.from('not valid json {'),
});
await expect(dialog.getByText('Invalid JSON')).toBeVisible();
await expect(dialog).toBeVisible();
// Clicking "Import and Next" with invalid content should surface an error
// and keep the dialog open.
await dialog.getByRole('button', { name: 'Import and Next' }).click();
await expect(page).not.toHaveURL(/\/dashboard\/[0-9a-f-]+/);
await expect(dialog).toBeVisible();
await page.waitForLoadState('networkidle');
expect(postFired, 'invalid JSON must not trigger POST').toBe(false);
});
test('TC-21 import with empty editor clicking Import and Next shows error', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('import-json-menu-cta').click();
const dialog = page
.getByRole('dialog')
.filter({ hasText: 'Import Dashboard JSON' });
await expect(dialog).toBeVisible();
let postFired = false;
await page.route(/\/api\/v1\/dashboards(\?|$)/, (route) => {
if (route.request().method() === 'POST') {
postFired = true;
}
void route.continue();
});
await dialog.getByRole('button', { name: 'Import and Next' }).click();
await expect(dialog.getByText('Error loading JSON file')).toBeVisible();
await expect(dialog).toBeVisible();
await expect(page).not.toHaveURL(/\/dashboard\/[0-9a-f-]+/);
await page.waitForLoadState('networkidle');
expect(postFired, 'empty editor must not trigger POST').toBe(false);
});
// ─── Deleting dashboards ─────────────────────────────────────────────────
//
// Known behaviour: clicking Cancel in the confirmation dialog navigates to
// the dashboard detail page rather than staying on the list.
test('TC-19 delete confirmation dialog shows dashboard name with Cancel and Delete buttons', async ({
test('TC-22 delete confirmation dialog shows dashboard name with Cancel and Delete buttons', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-delete-confirm';
@@ -456,7 +591,7 @@ test.describe('Dashboards List Page', () => {
await expect(dialog.getByRole('button', { name: 'Delete' })).toBeVisible();
});
test('TC-20 cancelling delete navigates to the dashboard detail page (known behaviour)', async ({
test('TC-23 cancelling delete navigates to the dashboard detail page (known behaviour)', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-delete-cancel';
@@ -471,7 +606,7 @@ test.describe('Dashboards List Page', () => {
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
});
test('TC-21 confirming delete removes the dashboard from the list', async ({
test('TC-24 confirming delete removes the dashboard from the list', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-delete-confirmed';
@@ -506,7 +641,7 @@ test.describe('Dashboards List Page', () => {
// ─── Row click navigation ────────────────────────────────────────────────
test('TC-22 clicking a dashboard row navigates to the detail page', async ({
test('TC-25 clicking a dashboard row navigates to the detail page', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-row-click';
@@ -520,7 +655,7 @@ test.describe('Dashboards List Page', () => {
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
});
test('TC-23 sidebar Dashboards link navigates to the list page', async ({
test('TC-26 sidebar Dashboards link navigates to the list page', async ({
authedPage: page,
}) => {
await page.goto('/home');
@@ -538,7 +673,7 @@ test.describe('Dashboards List Page', () => {
// ─── URL state and deep linking ──────────────────────────────────────────
test('TC-24 browser Back after navigating to a dashboard restores search state', async ({
test('TC-27 browser Back after navigating to a dashboard restores search state', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-back-search';
@@ -557,7 +692,7 @@ test.describe('Dashboards List Page', () => {
await expect(page.getByPlaceholder(SEARCH_PLACEHOLDER)).toHaveValue(name);
});
test('TC-25 direct navigation with sort params honours them on load', async ({
test('TC-28 direct navigation with sort params honours them on load', async ({
authedPage: page,
}) => {
await page.goto('/dashboard?columnKey=updatedAt&order=descend');