Compare commits

..

3 Commits

Author SHA1 Message Date
Abhi Kumar
0b42daf39f test: added tests for table/value/list panels 2026-05-18 10:51:33 +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
11 changed files with 1975 additions and 43 deletions

View File

@@ -93,6 +93,7 @@ function ValueGraph({
<div
ref={containerRef}
className="value-graph-container"
data-testid="value-graph-container"
style={{
backgroundColor:
threshold.thresholdFormat === 'Background'

View File

@@ -159,6 +159,8 @@ function GridTableComponent({
if (threshold && idx !== -1) {
return (
<div
data-testid="threshold-styled-cell"
data-threshold-format={threshold.thresholdFormat}
style={
threshold.thresholdFormat === 'Background'
? { backgroundColor: threshold.thresholdColor }

View File

@@ -231,12 +231,14 @@ function Threshold({
type="text"
icon={<Pencil size={14} />}
className="edit-btn"
data-testid="threshold-edit-btn"
onClick={editHandler}
/>
<Button
type="text"
icon={<Trash2 size={14} />}
className="delete-btn"
data-testid="threshold-delete-btn"
onClick={deleteHandler}
/>
</div>

View File

@@ -1156,11 +1156,9 @@ func (r *ClickHouseReader) GetFlamegraphSpansForTrace(ctx context.Context, orgID
r.logger.Info("cache miss for getFlamegraphSpansForTrace", "traceID", traceID)
selectCols := "timestamp, duration_nano, span_id, trace_id, has_error, links as references, resource_string_service$$name, name, events"
selectFieldCols := req.GetSelectedFieldsSourceColumns()
if len(selectFieldCols) > 0 {
selectCols = fmt.Sprintf("%s, %s", selectCols, strings.Join(selectFieldCols, ", "))
if len(req.SelectFields) > 0 {
selectCols += ", attributes_string, attributes_number, attributes_bool, resources_string"
}
flamegraphQuery := fmt.Sprintf("SELECT %s FROM %s.%s WHERE trace_id=$1 and ts_bucket_start>=$2 and ts_bucket_start<=$3 ORDER BY timestamp ASC, name ASC", selectCols, r.TraceDB, r.traceTableName)
searchScanResponses, err := r.GetSpansForTrace(ctx, traceID, flamegraphQuery)

View File

@@ -346,45 +346,6 @@ type GetFlamegraphSpansForTraceParams struct {
SelectFields []telemetrytypes.TelemetryFieldKey `json:"selectFields"`
}
func (r *GetFlamegraphSpansForTraceParams) GetSelectedFieldsSourceColumns() []string {
var needsAttrString, needsAttrNumber, needsAttrBool, needsResourceMap bool
for _, f := range r.SelectFields {
switch f.FieldContext {
case telemetrytypes.FieldContextAttribute:
switch f.FieldDataType {
case telemetrytypes.FieldDataTypeString:
needsAttrString = true
case telemetrytypes.FieldDataTypeFloat64, telemetrytypes.FieldDataTypeNumber, telemetrytypes.FieldDataTypeInt64:
needsAttrNumber = true
case telemetrytypes.FieldDataTypeBool:
needsAttrBool = true
default:
// Unknown type: AttributeValue searches all three maps, so we need all.
needsAttrString = true
needsAttrNumber = true
needsAttrBool = true
}
case telemetrytypes.FieldContextResource:
needsResourceMap = true
}
}
var cols []string
if needsAttrString {
cols = append(cols, "attributes_string")
}
if needsAttrNumber {
cols = append(cols, "attributes_number")
}
if needsAttrBool {
cols = append(cols, "attributes_bool")
}
if needsResourceMap {
cols = append(cols, "resources_string")
}
return cols
}
type SpanFilterParams struct {
TraceID []string `json:"traceID"`
Status []string `json:"status"`

View File

@@ -3,6 +3,10 @@ import path from 'path';
import type { APIRequestContext, Locator, Page } 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;
// ─── Constants ───────────────────────────────────────────────────────────
//
@@ -177,3 +181,248 @@ 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, confirm the modal, 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();
// Confirmation modal (title varies: "Save Widget" vs "Unsaved Changes" —
// don't assert title, just click OK on the topmost dialog).
const confirmModal = page.getByRole('dialog').last();
await confirmModal.waitFor({ state: 'visible' });
await confirmModal.getByRole('button', { name: /^OK$/i }).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-]+(?:\?|$)/);
}
// ─── Widget editor (re-open existing panel) ────────────────────────────────
/**
* Display labels surfaced in the `panel-change-select` Ant Select inside the
* widget editor. The mapping to URL `graphType` values comes from the
* `PANEL_TYPES` enum: TIME_SERIES='graph', VALUE='value', and so on.
*/
export type PanelDisplayLabel =
| 'Time Series'
| 'Number'
| 'Table'
| 'List'
| 'Bar'
| 'Pie'
| 'Histogram';
const PANEL_DISPLAY_TO_GRAPH_TYPE: Record<PanelDisplayLabel, string> = {
'Time Series': 'graph',
Number: 'value',
Table: 'table',
List: 'list',
Bar: 'bar',
Pie: 'pie',
Histogram: 'histogram',
};
/**
* Open the widget editor for an existing panel by driving the panel header
* options menu (the three-dot Ant `Dropdown` next to the title).
*
* The widget-header-options button is `visibility: hidden` until the panel is
* hovered (see `GridCardLayout.styles.scss`) — except on TABLE panels, where
* `globalSearchAvailable` keeps it permanently visible. Hovering the title
* testid first works for both states.
*/
export async function openWidgetEditor(
page: Page,
panelTitle: string,
): Promise<void> {
await page.getByTestId(panelTitle).first().hover();
await page.getByTestId('widget-header-options').first().click();
await page
.getByRole('menuitem', { name: /^edit$/i })
.first()
.click();
await page.waitForURL(/widgetId=/);
await page.getByTestId('new-widget-save').waitFor({ state: 'visible' });
}
/**
* Click "Save Changes" in the widget editor, confirm via the OK button on the
* resulting modal, await the dashboard PUT response, and wait for navigation
* back to `/dashboard/<id>`. Throws if the PUT response is not 2xx.
*
* The confirmation modal title varies between "Save Widget" and "Unsaved
* Changes" depending on whether the query was modified — don't assert title,
* just OK the topmost dialog.
*/
export async function saveWidgetEdit(page: Page): Promise<void> {
const putResponse = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
);
await page.getByTestId('new-widget-save').click();
const confirmModal = page.getByRole('dialog').last();
await confirmModal.waitFor({ state: 'visible' });
await confirmModal.getByRole('button', { name: /^OK$/i }).click();
const res = await putResponse;
if (!res.ok()) {
throw new Error(
`PUT /api/v1/dashboards failed ${res.status()}: ${await res.text()}`,
);
}
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
}
/**
* Switch the editor's panel display type via the Ant `Select` exposed as
* `data-testid="panel-change-select"`. The select options carry the display
* label as visible text (matches `PanelDisplay` enum values). After the
* change, this helper waits for the URL `graphType` param to reflect the new
* panel type and for the Save Changes button to re-render — the editor
* re-routes mid-flow via `redirectWithQueryBuilderData`.
*
* Note: the "List" option is filtered out of the dropdown when the current
* query contains a metrics data source (see VisualizationSettingsSection).
*/
export async function changePanelType(
page: Page,
displayLabel: PanelDisplayLabel,
): Promise<void> {
const expectedGraphType = PANEL_DISPLAY_TO_GRAPH_TYPE[displayLabel];
await page.getByTestId('panel-change-select').click();
// Each option renders a .select-option containing the display text — match
// against the typography element to avoid matching the trigger itself.
await page
.locator('.ant-select-item-option .display', { hasText: displayLabel })
.first()
.click();
await page.waitForURL(new RegExp(`graphType=${expectedGraphType}`));
await page.getByTestId('new-widget-save').waitFor({ state: 'visible' });
}

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

@@ -0,0 +1,550 @@
import path from 'path';
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
import { newAdminContext } from '../../helpers/auth';
import {
APM_METRICS_TITLE,
authToken,
configureAndSavePanel,
createDashboardViaApi,
deleteDashboardViaApi,
gotoDashboardsList,
openDashboardSettingsDrawer,
renameDashboardViaToolbar,
saveDashboardSettings,
SEARCH_PLACEHOLDER,
} from '../../helpers/dashboards';
// All tests mutate dashboard state (create / rename / delete). Run serially to
// prevent cross-test interference on the list and detail pages.
test.describe.configure({ mode: 'serial' });
// ─── Suite-level seed registry ────────────────────────────────────────────────
//
// Every dashboard created by any test is registered here; one afterAll tears
// them all down. Tests that don't create anything (TC-10, TC-11, TC-13) need
// no cleanup entry.
const seedIds = new Set<string>();
const BASE_FIXTURE_TITLE = 'create-flow-base-fixture';
const APM_METRICS_TESTDATA_PATH = path.resolve(
__dirname,
'../../testdata/apm-metrics.json',
);
async function seed(page: Page, title: string): Promise<string> {
const id = await createDashboardViaApi(page, title);
seedIds.add(id);
return id;
}
test.beforeAll(async ({ browser }) => {
// Seed one base dashboard so the list is non-empty and the
// `new-dashboard-cta` header button is rendered for all tests that
// drive the "New dashboard" dropdown from the list page.
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
seedIds.add(await createDashboardViaApi(page, BASE_FIXTURE_TITLE));
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of [...seedIds]) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
test.describe('Dashboard Create Flow', () => {
// ─── 1. Create Dashboard (blank) ─────────────────────────────────────────
test('TC-01 blank create lands on onboarding state with correct default title', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
const postResponse = page.waitForResponse(
(r) =>
r.request().method() === 'POST' && /\/api\/v1\/dashboards/.test(r.url()),
);
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('create-dashboard-menu-cta').click();
const res = await postResponse;
await page.waitForURL(/\/dashboard\/[0-9a-f-]+/);
expect(res.status()).toBeGreaterThanOrEqual(200);
expect(res.status()).toBeLessThan(300);
const body = (await res.json()) as {
data: { data: { title: string }; id: string };
};
expect(body.data.data.title).toBe('Sample Title');
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
// DashboardDescription always renders dashboard-title even on blank dashboards.
await expect(page.getByTestId('dashboard-title')).toBeVisible();
await expect(page.getByText('Welcome to your new dashboard')).toBeVisible();
await expect(page.getByText('Configure your new dashboard')).toBeVisible();
await expect(page.getByTestId('show-drawer').first()).toBeVisible();
await expect(page.getByTestId('add-panel')).toBeVisible();
// Register the UI-created dashboard for cleanup.
const id = body.data.id;
expect(id, 'POST response must include a dashboard id').toBeTruthy();
seedIds.add(id);
});
test('TC-02 configure drawer opens with Overview tab and pre-fills existing title', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc02');
await page.goto(`/dashboard/${id}`);
const drawer = await openDashboardSettingsDrawer(page);
// Overview tab is the default active tab.
await expect(drawer.getByRole('button', { name: 'Overview' })).toBeVisible();
const nameInput = drawer.getByTestId('dashboard-name');
await expect(nameInput).toHaveValue('create-flow-tc02');
const descInput = drawer.getByTestId('dashboard-desc');
await expect(descInput).toBeVisible();
await expect(descInput).toHaveValue('');
await expect(
drawer.getByPlaceholder('Start typing your tag name'),
).toBeVisible();
// Ant Drawer does not close on Escape — use the X close button in the header.
await drawer.getByRole('button', { name: 'Close' }).click();
await expect(drawer).not.toHaveClass(/ant-drawer-open/);
});
test('TC-03 rename title, add description and tags, save persists to list', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc03-original');
await page.goto(`/dashboard/${id}`);
const drawer = await openDashboardSettingsDrawer(page);
const nameInput = drawer.getByTestId('dashboard-name');
await nameInput.clear();
await nameInput.fill('create-flow-tc03-renamed');
await expect(drawer.getByText(/1 unsaved change/)).toBeVisible();
await drawer.getByTestId('dashboard-desc').fill('A test description');
await expect(drawer.getByText(/2 unsaved changes/)).toBeVisible();
const tagInput = drawer.getByPlaceholder('Start typing your tag name');
await tagInput.click();
await tagInput.fill('e2e-tag');
await page.keyboard.press('Enter');
await expect(drawer.getByText(/3 unsaved changes/)).toBeVisible();
// Click save and wait for the unsaved-changes footer to disappear — the
// footer only clears after the PUT success callback re-syncs local state.
await page.getByTestId('save-dashboard-config').click();
await expect(drawer.getByText(/unsaved change/)).not.toBeVisible();
await drawer.getByRole('button', { name: 'Close' }).click();
// Renamed dashboard appears in the list.
await gotoDashboardsList(page);
const searchInput = page.getByPlaceholder(SEARCH_PLACEHOLDER);
await searchInput.fill('create-flow-tc03-renamed');
await expect(page.getByText('create-flow-tc03-renamed').first()).toBeVisible();
// Tag search also surfaces the renamed dashboard.
await searchInput.fill('e2e-tag');
await expect(page.getByText('create-flow-tc03-renamed').first()).toBeVisible();
});
test('TC-04 discard reverts unsaved changes without API call', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc04');
await page.goto(`/dashboard/${id}`);
const drawer = await openDashboardSettingsDrawer(page);
const nameInput = drawer.getByTestId('dashboard-name');
await nameInput.clear();
await nameInput.fill('create-flow-tc04-discarded');
await drawer.getByTestId('dashboard-desc').fill('discarded desc');
await expect(drawer.getByText(/unsaved change/)).toBeVisible();
// Intercept any PUT to detect an unwanted save.
let patchFired = false;
await page.route(/\/api\/v1\/dashboards\//, (route) => {
if (route.request().method() === 'PUT') {
patchFired = true;
}
route.continue();
});
await drawer.getByRole('button', { name: 'Discard' }).click();
await expect(drawer.getByText(/unsaved change/)).not.toBeVisible();
await expect(nameInput).toHaveValue('create-flow-tc04');
await expect(drawer.getByTestId('dashboard-desc')).toHaveValue('');
expect(patchFired).toBe(false);
});
test('TC-05 rename via toolbar options popover persists to the toolbar title', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc05');
await page.goto(`/dashboard/${id}`);
// DashboardDescription toolbar always renders — even on blank dashboards.
await expect(page.getByTestId('options')).toBeVisible();
await renameDashboardViaToolbar(page, 'create-flow-tc05-renamed');
await expect(page.getByTestId('dashboard-title')).toHaveText(
'create-flow-tc05-renamed',
);
});
// ─── 2. Variables ─────────────────────────────────────────────────────────
test('TC-06 add a Custom variable, verify it appears in the variables bar', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc06');
await page.goto(`/dashboard/${id}`);
const drawer = await openDashboardSettingsDrawer(page);
await drawer.getByRole('button', { name: 'Variables' }).click();
await drawer.getByTestId('add-new-variable').click();
await expect(drawer.getByRole('button', { name: 'All variables' })).toBeVisible();
await drawer
.getByPlaceholder('Unique name of the variable')
.fill('env');
await drawer.getByRole('button', { name: 'Custom' }).click();
// After selecting "Custom" type, the Options collapse panel contains a
// textarea with placeholder "Enter options separated by commas."
const customInput = drawer.getByPlaceholder(
'Enter options separated by commas.',
);
await customInput.fill('prod,staging,dev');
const patchResponse = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
);
await drawer.getByRole('button', { name: 'Save Variable' }).click();
const res = await patchResponse;
expect(res.status()).toBeGreaterThanOrEqual(200);
expect(res.status()).toBeLessThan(300);
// After saving, the variable form disappears and the table row is visible.
await expect(drawer.getByRole('button', { name: 'All variables' })).not.toBeVisible();
await expect(drawer.getByText('env')).toBeVisible();
// Close the drawer via its X button and check the variables bar.
await drawer.getByRole('button', { name: 'Close' }).click();
await expect(page.locator('.dashboard-variables')).toBeVisible();
});
test('TC-07 duplicate variable name is rejected inline', async ({
authedPage: page,
}) => {
// Seed a dashboard that already has a variable named 'env'.
const id = await seed(page, 'create-flow-tc07');
await page.goto(`/dashboard/${id}`);
// Use the UI to add the first variable so the state is real.
const drawer = await openDashboardSettingsDrawer(page);
await drawer.getByRole('button', { name: 'Variables' }).click();
await drawer.getByTestId('add-new-variable').click();
await drawer.getByPlaceholder('Unique name of the variable').fill('env');
await drawer.getByRole('button', { name: 'Custom' }).click();
await drawer
.getByPlaceholder('Enter options separated by commas.')
.fill('prod');
const firstSave = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
);
await drawer.getByRole('button', { name: 'Save Variable' }).click();
await firstSave;
// Now try to add a second variable with the same name.
await drawer.getByTestId('add-new-variable').click();
const nameInput = drawer.getByPlaceholder('Unique name of the variable');
await nameInput.fill('env');
await expect(
drawer.getByText('Variable name already exists'),
).toBeVisible();
await expect(
drawer.getByRole('button', { name: 'Save Variable' }),
).toBeDisabled();
});
// ─── 3. Import JSON ───────────────────────────────────────────────────────
//
// TC-08 and TC-12 are merged: TC-08 covers the POST contract and navigation;
// the merged test also navigates back to the list and verifies metadata
// surfacing (the TC-12 concern). This avoids two identical import flows.
test('TC-08 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);
// Navigate back and confirm the imported dashboard surfaces in the list
// with at least one tag chip (TC-12 coverage).
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();
});
// TC-09 (Monaco paste path) is intentionally dropped — the file-upload
// path (TC-08) exercises the same populate-editor-then-import code path.
// Keyboard-typing large JSON into Monaco is unreliable in headless CI.
test('TC-10 invalid JSON via file upload shows "Invalid JSON" error', async ({
authedPage: page,
}) => {
// No dashboard is created by this test — no cleanup entry needed.
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();
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();
});
test('TC-11 import with empty editor clicking Import and Next shows error', async ({
authedPage: page,
}) => {
// No dashboard is created — no cleanup entry needed.
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();
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-]+/);
});
// ─── 4. View Templates ────────────────────────────────────────────────────
test('TC-13 View templates menu item is an external link targeting a new tab', async ({
authedPage: page,
}) => {
// No dashboard is created — no cleanup entry needed.
// The assertion guards against the link being silently changed to an
// in-app modal or a different URL (the DashboardTemplatesModal exists in
// source but is never triggered from this menu item).
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
const link = page.getByTestId('view-templates-menu-cta');
await expect(link).toBeVisible();
await expect(link).toHaveAttribute(
'href',
/signoz\.io\/docs\/dashboards\/dashboard-templates/,
);
await expect(link).toHaveAttribute('target', '_blank');
await expect(link).toHaveAttribute('rel', /noopener/);
});
// ─── 5. Post-Create Dashboard Detail — Panel Addition ────────────────────
test('TC-14 New Panel modal opens and selecting Time Series navigates to widget editor', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc14');
await page.goto(`/dashboard/${id}`);
await expect(page.getByText('Welcome to your new dashboard')).toBeVisible();
await page.getByTestId('add-panel').click();
// PANEL_TYPES enum: TIME_SERIES='graph', VALUE='value', TABLE='table'
// — the testid is panel-type-<enum-value>, not panel-type-<enum-name>.
const modal = page.getByRole('dialog').filter({ hasText: 'New Panel' });
await expect(modal).toBeVisible();
await expect(modal.getByTestId('panel-type-graph')).toBeVisible();
await expect(modal.getByTestId('panel-type-value')).toBeVisible();
await expect(modal.getByTestId('panel-type-table')).toBeVisible();
await modal.getByTestId('panel-type-graph').click();
await expect(page).toHaveURL(/graphType=graph/);
});
test('TC-15 New Panel button from toolbar header opens the same panel type modal', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc15');
await page.goto(`/dashboard/${id}`);
// The toolbar "New Panel" button (add-panel-header) is present even on
// a blank dashboard, alongside the empty-state "add-panel" button.
await expect(page.getByTestId('add-panel-header')).toBeVisible();
await page.getByTestId('add-panel-header').click();
const modal = page.getByRole('dialog').filter({ hasText: 'New Panel' });
await expect(modal).toBeVisible();
await expect(modal.getByTestId('panel-type-graph')).toBeVisible();
// Click the modal X button to close (Escape also works but may conflict
// with the Enterprise modal in the background; explicit click is more reliable).
await modal.getByRole('button', { name: 'Close' }).click();
await expect(modal).not.toBeVisible();
});
// ─── 6. Cancellation and Navigation Away ─────────────────────────────────
test('TC-16 browser Back from dashboard detail returns to list with URL preserved', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc16');
await page.goto(`/dashboard?search=create-flow-tc16`);
await page
.getByRole('heading', { name: 'Dashboards', level: 1 })
.waitFor({ state: 'visible' });
await page.getByAltText('dashboard-image').first().click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
await page.goBack();
await expect(page).toHaveURL(/search=create-flow-tc16/);
await expect(
page.getByPlaceholder(SEARCH_PLACEHOLDER),
).toHaveValue('create-flow-tc16');
});
test('TC-17 navigating away with the settings drawer open does not crash', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc17');
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);
});
// ─── 7. Add Panel — end-to-end per signal ────────────────────────────────
//
// TC-14/TC-15 verify the New Panel modal opens and routes to the widget
// editor. The TCs below go further: 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.
test('TC-18 add metrics Time Series panel using signoz_calls_total from queries.json', async ({
authedPage: page,
}) => {
const id = await seed(page, 'add-panel-metrics');
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();
});
test('TC-19 add logs Time Series panel with default query from queries.json', async ({
authedPage: page,
}) => {
const id = await seed(page, 'add-panel-logs');
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();
});
test('TC-20 add traces Time Series panel with default query from queries.json', async ({
authedPage: page,
}) => {
const id = await seed(page, 'add-panel-traces');
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();
});
});

View File

@@ -0,0 +1,192 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
changePanelType,
configureAndSavePanel,
createDashboardViaApi,
deleteDashboardViaApi,
findDashboardIdByTitle,
openWidgetEditor,
saveWidgetEdit,
} from '../../../helpers/dashboards';
test.describe.configure({ mode: 'serial' });
const FIXTURE_DASHBOARD_TITLE = 'list-controls-fixture';
const FIXTURE_PANEL_TITLE = 'list-controls-panel';
const seedIds = new Set<string>();
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const id = await createDashboardViaApi(page, FIXTURE_DASHBOARD_TITLE);
seedIds.add(id);
await page.goto(`/dashboard/${id}`);
await page.getByTestId('add-panel').waitFor({ state: 'visible' });
// LIST panels require a logs (or traces) data source — metrics queries
// hide the LIST option from panel-change-select.
await configureAndSavePanel(page, 'logs', FIXTURE_PANEL_TITLE);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'List');
await saveWidgetEdit(page);
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of [...seedIds]) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
async function gotoFixtureDashboard(page: Page): Promise<void> {
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
await page.goto(`/dashboard/${id}`);
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
}
test.describe('List Panel Controls', () => {
test('TC-01 panel name persists and is reflected in the widget header', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('list-controls-renamed');
await saveWidgetEdit(page);
await expect(page.getByTestId('list-controls-renamed').first()).toBeVisible();
await openWidgetEditor(page, 'list-controls-renamed');
await expect(page.getByTestId('panel-name-input')).toHaveValue(
'list-controls-renamed',
);
await page.getByTestId('panel-name-input').fill(FIXTURE_PANEL_TITLE);
await saveWidgetEdit(page);
});
test('TC-02 description persists and shows info icon on header', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page
.getByTestId('panel-description-input')
.fill('E2E list description');
await saveWidgetEdit(page);
await expect(
page
.locator('.widget-header-container')
.filter({ hasText: FIXTURE_PANEL_TITLE })
.locator('.info-tooltip')
.first(),
).toBeVisible();
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page.getByTestId('panel-description-input')).toHaveValue(
'E2E list description',
);
await page.getByTestId('panel-description-input').fill('');
await saveWidgetEdit(page);
});
test('TC-03 panel type switch from List to Table persists and re-renders', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Table');
// Table re-renders Decimal Precision + Column Units in the right pane.
await expect(page.getByTestId('decimal-precision-selector')).toBeVisible();
await saveWidgetEdit(page);
// Panel card should now render an Ant table head.
await expect(
page
.locator('[data-testid="' + FIXTURE_PANEL_TITLE + '"]')
.first(),
).toBeVisible();
await expect(page.locator('.ant-table-thead').first()).toBeVisible();
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page).toHaveURL(/graphType=table/);
// Reset back to List.
await changePanelType(page, 'List');
await saveWidgetEdit(page);
});
test('TC-04 sections hidden for LIST are not rendered in the right pane', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page.locator('section.panel-time-preference')).toHaveCount(0);
await expect(page.locator('section.fill-gaps')).toHaveCount(0);
await expect(page.locator('section.stack-chart')).toHaveCount(0);
await expect(page.locator('section.soft-min-max')).toHaveCount(0);
await expect(page.locator('section.log-scale')).toHaveCount(0);
await expect(page.locator('section.legend-position')).toHaveCount(0);
await expect(page.locator('.decimal-precision-selector')).toHaveCount(0);
await expect(page.locator('.column-unit-selector')).toHaveCount(0);
await expect(page.locator('.y-axis-unit-selector-v2')).toHaveCount(0);
await expect(page.getByTestId('add-threshold-cta')).toHaveCount(0);
await expect(page.getByTestId('panel-name-input')).toBeVisible();
await expect(page.getByTestId('panel-description-input')).toBeVisible();
await expect(page.getByTestId('panel-change-select')).toBeVisible();
});
test('TC-05 discarding right-pane changes does not persist', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('discard-list-test');
let putFired = false;
await page.route(/\/api\/v1\/dashboards\//, (route) => {
if (route.request().method() === 'PUT') {
putFired = true;
}
route.continue();
});
await page.getByTestId('discard-button').click();
await page
.getByRole('dialog')
.last()
.getByRole('button', { name: /^OK$/i })
.click({ timeout: 1000 })
.catch(() => {
// no modal — direct navigation
});
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
await expect(page.getByTestId(FIXTURE_PANEL_TITLE).first()).toBeVisible();
expect(putFired).toBe(false);
});
});

View File

@@ -0,0 +1,470 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
changePanelType,
configureAndSavePanel,
createDashboardViaApi,
deleteDashboardViaApi,
findDashboardIdByTitle,
openWidgetEditor,
saveWidgetEdit,
} from '../../../helpers/dashboards';
test.describe.configure({ mode: 'serial' });
const FIXTURE_DASHBOARD_TITLE = 'table-controls-fixture';
const FIXTURE_PANEL_TITLE = 'table-controls-panel';
const seedIds = new Set<string>();
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const id = await createDashboardViaApi(page, FIXTURE_DASHBOARD_TITLE);
seedIds.add(id);
await page.goto(`/dashboard/${id}`);
await page.getByTestId('add-panel').waitFor({ state: 'visible' });
await configureAndSavePanel(page, 'metrics', FIXTURE_PANEL_TITLE);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Table');
await saveWidgetEdit(page);
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of [...seedIds]) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
async function gotoFixtureDashboard(page: Page): Promise<void> {
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
await page.goto(`/dashboard/${id}`);
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
}
/**
* Return the last <td> in the first data row of the panel's Ant Design table.
* Ant Design applies .ant-table-row to actual data rows only (not header rows),
* so this correctly skips the fixed/sticky header tbody rows.
*
* For the metrics panel the row has: td[0] = label column, td[last] = value
* column (the aggregation query "A"). The last td is thus the value cell.
* However, depending on the panel query there may only be ONE td per row. Use
* the cell that contains a non-empty value: any td that is not purely the
* label placeholder.
*
* NOTE: the value cell wraps its text in a <button> element (from the
* QueryTable open-traces render path) so textContent picks it up correctly.
*/
async function getFirstDataCell(page: Page) {
// .ant-table-row targets Ant Design data rows only (not header/fixed rows).
const firstRow = page.locator('tr.ant-table-row').first();
await firstRow.waitFor({ state: 'visible' });
// Return the last <td> — for a metrics table with columns [label, A] this
// is the value column. For a single-column table it is the only column.
return firstRow.locator('td').last();
}
/**
* Ensure a SettingsSection accordion in the widget editor right pane is
* expanded. If it is already open (content div has the `open` class), this is
* a no-op. Otherwise it clicks the header button and waits for the content to
* become visible.
*/
async function expandSection(page: Page, title: string): Promise<void> {
const section = page
.locator('.settings-section')
.filter({ has: page.locator('button.settings-section-header', { hasText: title }) });
const contentDiv = section.locator('.settings-section-content');
const isOpen = await contentDiv.evaluate((el) => el.classList.contains('open'));
if (!isOpen) {
await section.locator('button.settings-section-header').click();
await contentDiv.waitFor({ state: 'visible' });
}
}
/**
* Select a unit from the column-unit selector dropdown by typing a search
* term, then clicking the filtered option. Scoped to .column-unit-selector to
* avoid matching the Y-axis unit selectors on other panel types.
*
* The selector has `showSearch` enabled and renders a long virtualised option
* list — typing first avoids instability from the list re-rendering when the
* target option is off-screen.
*/
async function selectColumnUnit(
page: Page,
searchTerm: string,
optionText: string,
): Promise<void> {
const unitSelect = page
.locator('.column-unit-selector .y-axis-unit-selector-v2 .ant-select')
.first();
await unitSelect.click();
await page
.locator('.column-unit-selector .y-axis-unit-selector-v2 .ant-select input')
.first()
.fill(searchTerm);
await page
.locator('.ant-select-item-option-content', { hasText: optionText })
.first()
.click();
}
test.describe('Table Panel Controls', () => {
test('TC-01 panel name persists and is reflected in the widget header', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('table-controls-renamed');
await saveWidgetEdit(page);
await expect(page.getByTestId('table-controls-renamed').first()).toBeVisible();
await openWidgetEditor(page, 'table-controls-renamed');
await expect(page.getByTestId('panel-name-input')).toHaveValue(
'table-controls-renamed',
);
await page.getByTestId('panel-name-input').fill(FIXTURE_PANEL_TITLE);
await saveWidgetEdit(page);
});
test('TC-02 description persists and shows info icon on header', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page
.getByTestId('panel-description-input')
.fill('E2E table description');
await saveWidgetEdit(page);
await expect(
page
.locator('.widget-header-container')
.filter({ hasText: FIXTURE_PANEL_TITLE })
.locator('.info-tooltip')
.first(),
).toBeVisible();
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page.getByTestId('panel-description-input')).toHaveValue(
'E2E table description',
);
await page.getByTestId('panel-description-input').fill('');
await saveWidgetEdit(page);
});
test('TC-03 panel time preference switches to Last 15 min and persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page
.locator('section.panel-time-preference')
.getByRole('button', { name: /global time/i })
.click();
await page.getByRole('menuitem', { name: /Last 15 min/i }).click();
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(
page.locator('section.panel-time-preference').getByRole('button'),
).toContainText(/Last 15 min/i);
await page
.locator('section.panel-time-preference')
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: /Global Time/i }).click();
await saveWidgetEdit(page);
});
test('TC-04 column unit formats the matching column cells and persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Formatting & Units" section starts collapsed — expand it first.
await expandSection(page, 'Formatting & Units');
// Use selectColumnUnit to avoid virtualised-list detached-DOM failures.
await selectColumnUnit(page, 'Milliseconds', 'Milliseconds (ms)');
await saveWidgetEdit(page);
// Cell text in the data column should now contain the `ms` suffix.
const cell = await getFirstDataCell(page);
await expect(cell).toContainText(/ms/);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// Section starts collapsed again on re-open — expand before asserting.
await expandSection(page, 'Formatting & Units');
await expect(
page
.locator('.column-unit-selector .y-axis-unit-selector-v2 .ant-select-selection-item')
.first(),
).toContainText(/Milliseconds/);
// Reset — clear the unit via the Ant Select allowClear X button.
await page
.locator('.column-unit-selector .y-axis-unit-selector-v2')
.first()
.hover();
await page
.locator('.column-unit-selector .y-axis-unit-selector-v2 .ant-select-clear')
.first()
.click();
await saveWidgetEdit(page);
});
test('TC-05 decimal precision changes the number of decimals when a column unit is set', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Formatting & Units" section starts collapsed — expand it first.
await expandSection(page, 'Formatting & Units');
// Set a column unit so decimal precision has a visible effect.
await selectColumnUnit(page, 'Seconds', 'Seconds (s)');
await page.getByTestId('decimal-precision-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '0 decimals' })
.first()
.click();
await saveWidgetEdit(page);
const cell = await getFirstDataCell(page);
await expect(cell).toContainText(/s/);
const text = (await cell.textContent()) ?? '';
expect(text.replace(/\s*s\s*$/, '')).not.toMatch(/\./);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// Section starts collapsed again on re-open — expand before asserting.
await expandSection(page, 'Formatting & Units');
await expect(page.getByTestId('decimal-precision-selector')).toContainText(
/0 decimals/,
);
// Reset: decimal precision back to 2, clear column unit.
await page.getByTestId('decimal-precision-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '2 decimals' })
.first()
.click();
await page
.locator('.column-unit-selector .y-axis-unit-selector-v2')
.first()
.hover();
await page
.locator('.column-unit-selector .y-axis-unit-selector-v2 .ant-select-clear')
.first()
.click();
await saveWidgetEdit(page);
});
test('TC-06 column-targeted Background threshold paints only the targeted column', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Thresholds" section starts collapsed when there are no thresholds.
await expandSection(page, 'Thresholds');
await page.getByTestId('add-threshold-cta').click();
const card = page.locator('.threshold-container').first();
// For TABLE thresholds the column selector (table-operator-input-selector)
// defaults to the first aggregation query column (typically `A`). Operator
// defaults to '>'; switch to '>=' so it reliably matches non-negative values.
await card.getByTestId('operator-input-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '>=' })
.first()
.click();
await card.getByTestId('threshold-color-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: 'Background' })
.first()
.click();
// Save the threshold row (commits it to the thresholds state array).
await card.getByRole('button', { name: /save changes/i }).click();
await saveWidgetEdit(page);
// Find a data row and inspect its cells. Use tr.ant-table-row to skip
// fixed-header tbody rows that Ant Design inserts for sticky scroll.
// QueryTable wraps each cell in <div role="button">; the threshold
// styled <div> is nested inside it. Use div[style] to target the first
// <div> that actually carries an inline style — that is the threshold div.
// TODO: switch to `getByTestId('threshold-styled-cell')` once the frontend
// build deployed to the test stack picks up the testid added in
// GridTableComponent/index.tsx (the host also carries
// `data-threshold-format="Background|Text"` to discriminate variants).
const row = page.locator('tr.ant-table-row').first();
await row.waitFor({ state: 'visible' });
const dataCellInner = row.locator('td').last().locator('div[style]').first();
const dataStyle = (await dataCellInner.getAttribute('style')) ?? '';
expect(dataStyle).toMatch(/background-color:/);
// Reset — delete the threshold. Edit/delete buttons are display:none
// by default and revealed only on .threshold-card-container:hover.
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// ThresholdsSection defaultOpen is based on threshold count at mount; may
// start collapsed due to async state loading — always expand before interacting.
await expandSection(page, 'Thresholds');
const firstCard = page.locator('.threshold-card-container').first();
await firstCard.hover();
// TODO: switch to `getByTestId('threshold-delete-btn')` once the stack
// frontend rebuild picks up the testid added in Threshold.tsx.
await firstCard.locator('button.delete-btn').click();
await saveWidgetEdit(page);
});
test('TC-07 column-targeted Text threshold colors only the targeted column text', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Thresholds" section starts collapsed when there are no thresholds.
await expandSection(page, 'Thresholds');
await page.getByTestId('add-threshold-cta').click();
const card = page.locator('.threshold-container').first();
await card.getByTestId('operator-input-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '>=' })
.first()
.click();
// Format defaults to 'Text' — no change needed.
await card.getByRole('button', { name: /save changes/i }).click();
await saveWidgetEdit(page);
// QueryTable wraps each cell in <div role="button">; the threshold styled
// <div> is nested inside. Use div[style] to find the threshold div directly.
// TODO: same testid migration as TC-06 once the frontend rebuild lands.
const row = page.locator('tr.ant-table-row').first();
await row.waitFor({ state: 'visible' });
const dataCellInner = row.locator('td').last().locator('div[style]').first();
const dataStyle = (await dataCellInner.getAttribute('style')) ?? '';
expect(dataStyle).toMatch(/color:/);
expect(dataStyle).not.toMatch(/background-color:/);
// Reset
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Thresholds');
const firstCard = page.locator('.threshold-card-container').first();
await firstCard.hover();
// TODO: switch to `getByTestId('threshold-delete-btn')` after frontend rebuild.
await firstCard.locator('button.delete-btn').click();
await saveWidgetEdit(page);
});
test('TC-08 sections hidden for TABLE are not rendered', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page.locator('section.fill-gaps')).toHaveCount(0);
await expect(page.locator('section.stack-chart')).toHaveCount(0);
await expect(page.locator('section.soft-min-max')).toHaveCount(0);
await expect(page.locator('section.log-scale')).toHaveCount(0);
await expect(page.locator('section.legend-position')).toHaveCount(0);
await expect(page.getByTestId('panel-name-input')).toBeVisible();
await expect(page.getByTestId('panel-change-select')).toBeVisible();
// decimal-precision-selector and column-unit-selector are inside the
// "Formatting & Units" section which starts collapsed — expand it first.
await expandSection(page, 'Formatting & Units');
await expect(page.getByTestId('decimal-precision-selector')).toBeVisible();
await expect(page.locator('.column-unit-selector').first()).toBeVisible();
// add-threshold-cta is inside "Thresholds" which is also collapsed.
await expandSection(page, 'Thresholds');
await expect(page.getByTestId('add-threshold-cta')).toBeVisible();
});
test('TC-09 panel type switch from Table to Number persists and re-renders as a number', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Number');
// Number panel exposes the Y-axis unit selector in the Formatting & Units section.
await expect(page.locator('.y-axis-unit-selector-v2').first()).toBeVisible();
await saveWidgetEdit(page);
await expect(page.getByTestId('value-graph-text').first()).toBeVisible();
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page).toHaveURL(/graphType=value/);
// Reset: switch back to Table.
await changePanelType(page, 'Table');
await saveWidgetEdit(page);
});
test('TC-10 discarding right-pane changes does not persist', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('discard-table-test');
let putFired = false;
await page.route(/\/api\/v1\/dashboards\//, (route) => {
if (route.request().method() === 'PUT') {
putFired = true;
}
route.continue();
});
await page.getByTestId('discard-button').click();
await page
.getByRole('dialog')
.last()
.getByRole('button', { name: /^OK$/i })
.click({ timeout: 1000 })
.catch(() => {
// no modal — direct navigation
});
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
await expect(page.getByTestId(FIXTURE_PANEL_TITLE).first()).toBeVisible();
expect(putFired).toBe(false);
});
});

View File

@@ -0,0 +1,495 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
changePanelType,
configureAndSavePanel,
createDashboardViaApi,
deleteDashboardViaApi,
findDashboardIdByTitle,
openWidgetEditor,
saveWidgetEdit,
} from '../../../helpers/dashboards';
// All TCs operate on the same fixture panel and toggle its state — they MUST
// run serially within the worker. Project-level fullyParallel still runs this
// file in parallel with other files.
test.describe.configure({ mode: 'serial' });
const FIXTURE_DASHBOARD_TITLE = 'value-controls-fixture';
const FIXTURE_PANEL_TITLE = 'value-controls-panel';
const seedIds = new Set<string>();
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const id = await createDashboardViaApi(page, FIXTURE_DASHBOARD_TITLE);
seedIds.add(id);
await page.goto(`/dashboard/${id}`);
await page.getByTestId('add-panel').waitFor({ state: 'visible' });
await configureAndSavePanel(page, 'metrics', FIXTURE_PANEL_TITLE);
// configureAndSavePanel creates a Time Series panel. Switch it to the
// Number (VALUE) type before the per-TC bodies run.
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Number');
await saveWidgetEdit(page);
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of [...seedIds]) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
async function gotoFixtureDashboard(page: Page): Promise<void> {
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
await page.goto(`/dashboard/${id}`);
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
}
/**
* Ensure a SettingsSection accordion in the widget editor right pane is
* expanded. If it is already open (content div has the `open` class), this is
* a no-op. Otherwise it clicks the header button and waits for the CSS
* transition to complete. This handles both the common case (collapsed on
* mount) and the defensive case (already open).
*/
async function expandSection(page: Page, title: string): Promise<void> {
// Find the settings-section that contains this title in its header.
const section = page
.locator('.settings-section')
.filter({ has: page.locator('button.settings-section-header', { hasText: title }) });
// Check if the content div already has the `open` class.
const contentDiv = section.locator('.settings-section-content');
const isOpen = await contentDiv.evaluate((el) =>
el.classList.contains('open'),
);
if (!isOpen) {
// Click the header button to open the section.
await section.locator('button.settings-section-header').click();
// Wait for the CSS transition to complete (opacity 0→1, max-height 0→1000px).
await contentDiv.waitFor({ state: 'visible' });
}
}
/**
* Select a unit from the Y-axis unit selector dropdown by typing a search
* term, then clicking the filtered option. The selector has `showSearch`
* enabled and renders a long virtualised option list — typing first avoids
* instability from the virtualised list re-rendering when the target option
* is off-screen.
*/
async function selectYAxisUnit(
page: Page,
searchTerm: string,
optionText: string,
): Promise<void> {
// Click the outer wrapper to open the dropdown.
const unitSelect = page.locator('.y-axis-unit-selector-v2 .ant-select').first();
await unitSelect.click();
// The Ant Select input is now focused — type to filter the virtual list.
await page.locator('.y-axis-unit-selector-v2 .ant-select input').first().fill(searchTerm);
// Wait for the dropdown to show the filtered option, then click it.
await page
.locator('.ant-select-item-option-content', { hasText: optionText })
.first()
.click();
}
test.describe('Value Panel Controls', () => {
test('TC-01 panel name persists and is reflected in the widget header', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('value-controls-renamed');
await saveWidgetEdit(page);
await expect(page.getByTestId('value-controls-renamed').first()).toBeVisible();
await openWidgetEditor(page, 'value-controls-renamed');
await expect(page.getByTestId('panel-name-input')).toHaveValue(
'value-controls-renamed',
);
// Reset back to fixture title so subsequent TCs locate the panel.
await page.getByTestId('panel-name-input').fill(FIXTURE_PANEL_TITLE);
await saveWidgetEdit(page);
});
test('TC-02 panel description persists and renders the info icon on the header', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page
.getByTestId('panel-description-input')
.fill('E2E test description');
await saveWidgetEdit(page);
await expect(
page
.locator('.widget-header-container')
.filter({ hasText: FIXTURE_PANEL_TITLE })
.locator('.info-tooltip')
.first(),
).toBeVisible();
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page.getByTestId('panel-description-input')).toHaveValue(
'E2E test description',
);
// Reset
await page.getByTestId('panel-description-input').fill('');
await saveWidgetEdit(page);
});
test('TC-03 panel time preference switches from Global Time to Last 15 min and persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
const timeButton = page
.locator('section.panel-time-preference')
.getByRole('button', { name: /global time/i });
await timeButton.click();
await page.getByRole('menuitem', { name: /Last 15 min/i }).click();
await expect(
page.locator('section.panel-time-preference').getByRole('button'),
).toContainText(/Last 15 min/i);
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(
page.locator('section.panel-time-preference').getByRole('button'),
).toContainText(/Last 15 min/i);
// Reset
await page
.locator('section.panel-time-preference')
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: /Global Time/i }).click();
await saveWidgetEdit(page);
});
test('TC-04 Y-axis unit applies a suffix to the rendered value and persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Formatting & Units" section starts collapsed — expand it first.
await expandSection(page, 'Formatting & Units');
// The Y-Axis Unit selector has showSearch enabled and a long virtualised
// option list. Type "Seconds" to filter before clicking.
await selectYAxisUnit(page, 'Seconds', 'Seconds (s)');
// Live preview should now render a suffix unit `s`.
await expect(page.getByTestId('value-graph-suffix-unit').first()).toBeVisible();
await saveWidgetEdit(page);
// Back on the dashboard the panel card should also render the suffix.
await expect(page.getByTestId('value-graph-suffix-unit').first()).toBeVisible();
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await expect(
page.locator('.y-axis-unit-selector-v2 .ant-select-selection-item').first(),
).toContainText(/Seconds/);
// Reset — clear the unit via allowClear (X button on the Ant Select).
await page.locator('.y-axis-unit-selector-v2').first().hover();
await page.locator('.y-axis-unit-selector-v2 .ant-select-clear').first().click();
await saveWidgetEdit(page);
});
test('TC-05 decimal precision reformats the rendered value when a unit is set', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Formatting & Units" section starts collapsed — expand it first.
await expandSection(page, 'Formatting & Units');
// Setting a unit is required for decimal precision to have a visible
// effect — see Known Limitations #3 in the test plan.
await selectYAxisUnit(page, 'Seconds', 'Seconds (s)');
await page.getByTestId('decimal-precision-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '0 decimals' })
.first()
.click();
// Live preview: the numeric text should no longer contain a decimal point.
await expect(page.getByTestId('value-graph-text').first()).not.toContainText(
/\./,
);
await saveWidgetEdit(page);
// Dashboard render: same assertion.
await expect(page.getByTestId('value-graph-text').first()).not.toContainText(
/\./,
);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await expect(page.getByTestId('decimal-precision-selector')).toContainText(
/0 decimals/,
);
// Reset: restore default 2 decimals and clear the unit.
await page.getByTestId('decimal-precision-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '2 decimals' })
.first()
.click();
await page.locator('.y-axis-unit-selector-v2').first().hover();
await page.locator('.y-axis-unit-selector-v2 .ant-select-clear').first().click();
await saveWidgetEdit(page);
});
test('TC-06 Text-format threshold colors the rendered value text and persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Thresholds" section starts collapsed when there are no thresholds
// (defaultOpen={!!thresholds.length}) — expand it first.
await expandSection(page, 'Thresholds');
await page.getByTestId('add-threshold-cta').click();
// VALUE panels do not render a threshold label input — only operator,
// value, unit, format (Text/Background), and color. Defaults: operator
// '>', format 'Text', value 0, color 'Red'. We force operator to '>=' so
// the threshold reliably matches non-negative values.
const thresholdCard = page.locator('.threshold-container').first();
await thresholdCard
.getByTestId('operator-input-selector')
.click();
await page
.locator('.ant-select-item-option-content', { hasText: '>=' })
.first()
.click();
// Save the threshold row (commits it to the thresholds state array). The
// dashboard PUT still needs `saveWidgetEdit` after this.
await thresholdCard.getByRole('button', { name: /save changes/i }).click();
await saveWidgetEdit(page);
// Dashboard render: value text should now carry an inline color style.
const valueText = page.getByTestId('value-graph-text').first();
await expect(valueText).toBeVisible();
const inlineStyle = await valueText.getAttribute('style');
expect(inlineStyle).toMatch(/color:/);
// Re-open editor and verify the threshold round-tripped.
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The ThresholdsSection defaultOpen is based on threshold count at mount
// time; due to async state loading it may start collapsed. Expand it.
await expandSection(page, 'Thresholds');
await expect(
page.locator('.threshold-container').first(),
).toBeVisible();
// Reset — delete the threshold. The delete button is `display:none` by
// default and revealed only on `.threshold-card-container:hover`; hover
// the card so the CSS :hover rule activates, then click via testid.
const firstCard = page.locator('.threshold-card-container').first();
await firstCard.hover();
// TODO: switch to `getByTestId('threshold-delete-btn')` once the frontend
// build deployed to the test stack includes the new testid (added in
// Threshold.tsx). The class-based fallback is robust meanwhile.
await firstCard.locator('button.delete-btn').click();
await saveWidgetEdit(page);
});
test('TC-07 Background-format threshold paints the value container background', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Thresholds" section starts collapsed when there are no thresholds.
await expandSection(page, 'Thresholds');
await page.getByTestId('add-threshold-cta').click();
const thresholdCard = page.locator('.threshold-container').first();
// Set operator >= and switch format from Text to Background.
await thresholdCard.getByTestId('operator-input-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '>=' })
.first()
.click();
await thresholdCard.getByTestId('threshold-color-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: 'Background' })
.first()
.click();
await thresholdCard.getByRole('button', { name: /save changes/i }).click();
await saveWidgetEdit(page);
// Dashboard render: .value-graph-container should now have an inline
// background-color style. TODO: switch to `getByTestId('value-graph-container')`
// once the frontend build deployed to the test stack picks up the testid
// added in ValueGraph/index.tsx.
const container = page.locator('.value-graph-container').first();
await expect(container).toBeVisible();
const inlineStyle = await container.getAttribute('style');
expect(inlineStyle).toMatch(/background-color:/);
// Reset
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// ThresholdsSection may start collapsed even with thresholds — always
// expand before interacting with threshold cards.
await expandSection(page, 'Thresholds');
// Edit/delete buttons are display:none by default, revealed on :hover.
const firstCard = page.locator('.threshold-card-container').first();
await firstCard.hover();
// TODO: switch to `getByTestId('threshold-delete-btn')` once the frontend
// build deployed to the test stack includes the new testid (added in
// Threshold.tsx). The class-based fallback is robust meanwhile.
await firstCard.locator('button.delete-btn').click();
await saveWidgetEdit(page);
});
test('TC-08 clearing the Y-axis unit removes the suffix from the rendered value', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Formatting & Units" section starts collapsed — expand it first.
await expandSection(page, 'Formatting & Units');
// Apply a unit first.
await selectYAxisUnit(page, 'Seconds', 'Seconds (s)');
await saveWidgetEdit(page);
await expect(page.getByTestId('value-graph-suffix-unit').first()).toBeVisible();
// Clear it.
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await page.locator('.y-axis-unit-selector-v2').first().hover();
await page.locator('.y-axis-unit-selector-v2 .ant-select-clear').first().click();
await saveWidgetEdit(page);
// Suffix should be gone from the rendered panel.
await expect(page.getByTestId('value-graph-suffix-unit')).toHaveCount(0);
});
test('TC-09 panel type switch from Number to Time Series persists and re-renders', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Time Series');
// Time Series exposes Fill gaps — confirm the right pane re-rendered.
await expect(page.locator('section.fill-gaps')).toBeVisible();
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page).toHaveURL(/graphType=graph/);
// Reset: switch back to Number for downstream TCs.
await changePanelType(page, 'Number');
await saveWidgetEdit(page);
});
test('TC-10 sections hidden for VALUE are not rendered in the right pane', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// Hidden by the panel-type matrix for VALUE — these sections are not
// rendered in the DOM at all (conditionally excluded by RightContainer).
await expect(page.locator('section.soft-min-max')).toHaveCount(0);
await expect(page.locator('section.log-scale')).toHaveCount(0);
await expect(page.locator('section.legend-position')).toHaveCount(0);
await expect(page.locator('section.fill-gaps')).toHaveCount(0);
await expect(page.locator('section.stack-chart')).toHaveCount(0);
// Expected to be present in the always-open General and Visualization
// sections.
await expect(page.getByTestId('panel-name-input')).toBeVisible();
await expect(page.getByTestId('panel-change-select')).toBeVisible();
// The "Formatting & Units" section is collapsed on open — expand it to
// verify the controls are rendered for VALUE.
await expandSection(page, 'Formatting & Units');
await expect(page.getByTestId('decimal-precision-selector')).toBeVisible();
// The "Thresholds" section is collapsed when there are no thresholds —
// expand it to verify the Add Threshold CTA is rendered for VALUE.
await expandSection(page, 'Thresholds');
await expect(page.getByTestId('add-threshold-cta')).toBeVisible();
});
test('TC-11 discarding right-pane changes does not persist or visually update', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('discard-value-test');
let putFired = false;
await page.route(/\/api\/v1\/dashboards\//, (route) => {
if (route.request().method() === 'PUT') {
putFired = true;
}
route.continue();
});
await page.getByTestId('discard-button').click();
// If a discard confirmation appears, OK it. Right-pane-only changes
// usually don't trigger one.
const confirmDialog = page.getByRole('dialog').last();
await confirmDialog
.getByRole('button', { name: /^OK$/i })
.click({ timeout: 1000 })
.catch(() => {
// no modal — the editor navigated away immediately
});
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
await expect(page.getByTestId(FIXTURE_PANEL_TITLE).first()).toBeVisible();
expect(putFired).toBe(false);
});
});