Compare commits

...

3 Commits

Author SHA1 Message Date
Abhi Kumar
d946d9c4bd test: added tests for histogram and pie panel 2026-05-18 19:07:42 +05:30
Abhi Kumar
2cc8032f06 test: added test for timeseries and bar panel 2026-05-18 18:34:44 +05:30
Abhi Kumar
0b42daf39f test: added tests for table/value/list panels 2026-05-18 10:51:33 +05:30
12 changed files with 3068 additions and 1 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

@@ -98,7 +98,11 @@ function YAxisUnitSelector({
{categoriesToRender.map((category) => (
<Select.OptGroup key={category.name} label={category.name}>
{category.units.map((unit) => (
<Select.Option key={unit.id} value={unit.id}>
<Select.Option
key={unit.id}
value={unit.id}
data-testid={`unit-option-${unit.id}`}
>
{unit.name}
</Select.Option>
))}

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

@@ -323,3 +323,106 @@ export async function configureAndSavePanel(
// 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' });
}

View File

@@ -0,0 +1,484 @@
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 = 'bar-controls-fixture';
const FIXTURE_PANEL_TITLE = 'bar-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, 'Bar');
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' });
}
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' });
}
}
async function selectYAxisUnit(
page: Page,
wrapperSelector: string,
searchTerm: string,
optionText: string,
): Promise<void> {
const wrapper = page.locator(wrapperSelector).first();
await wrapper.locator('.ant-select').click();
await wrapper.locator('.ant-select input').fill(searchTerm);
await page
.locator('.ant-select-item-option-content', { hasText: optionText })
.first()
.click();
}
test.describe('Bar 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('bar-controls-renamed');
await saveWidgetEdit(page);
await expect(page.getByTestId('bar-controls-renamed').first()).toBeVisible();
await openWidgetEditor(page, 'bar-controls-renamed');
await expect(page.getByTestId('panel-name-input')).toHaveValue(
'bar-controls-renamed',
);
await page.getByTestId('panel-name-input').fill(FIXTURE_PANEL_TITLE);
await saveWidgetEdit(page);
});
test('TC-02 description persists and toggles the widget-header info icon', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page
.getByTestId('panel-description-input')
.fill('E2E bar description');
await saveWidgetEdit(page);
const header = page
.locator('.widget-header-container')
.filter({ hasText: FIXTURE_PANEL_TITLE });
await expect(header.locator('.info-tooltip').first()).toBeVisible();
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page.getByTestId('panel-description-input')).toHaveValue(
'E2E bar description',
);
await page.getByTestId('panel-description-input').fill('');
await saveWidgetEdit(page);
await expect(header.locator('.info-tooltip')).toHaveCount(0);
});
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 stack series toggle persists; editor reflects state via data-stacking-state', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
const stackSwitch = page.locator('section.stack-chart').getByRole('switch');
const panelChangeSelect = page.getByTestId('panel-change-select');
await expect(stackSwitch).toHaveAttribute('aria-checked', 'false');
await expect(panelChangeSelect).toHaveAttribute('data-stacking-state', 'false');
await stackSwitch.click();
await expect(stackSwitch).toHaveAttribute('aria-checked', 'true');
await expect(panelChangeSelect).toHaveAttribute('data-stacking-state', 'true');
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(
page.locator('section.stack-chart').getByRole('switch'),
).toHaveAttribute('aria-checked', 'true');
await expect(page.getByTestId('panel-change-select')).toHaveAttribute(
'data-stacking-state',
'true',
);
// Reset
await page.locator('section.stack-chart').getByRole('switch').click();
await saveWidgetEdit(page);
});
test('TC-05 Y-axis unit persists', async ({ authedPage: page }) => {
// Tooltip-based visible-change check is omitted — the test stack's
// `signoz_calls_total` data slides outside the dashboard's default
// "Last 30 minutes" window mid-suite, so the rendered panel often
// shows "No Data" and the tooltip never appears. Verify persistence
// only — the selector value round-trips through PUT.
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await selectYAxisUnit(
page,
'.y-axis-unit-selector-v2',
'Milliseconds',
'Milliseconds (ms)',
);
await saveWidgetEdit(page);
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(/Milliseconds/);
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 decimal precision persists', async ({ authedPage: page }) => {
// Tooltip-based visible-change check is omitted for the same reason
// as TC-05.
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await page.getByTestId('decimal-precision-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '0 decimals' })
.first()
.click();
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await expect(page.getByTestId('decimal-precision-selector')).toContainText(
/0 decimals/,
);
// Reset
await page.getByTestId('decimal-precision-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '2 decimals' })
.first()
.click();
await saveWidgetEdit(page);
});
test('TC-07 soft min and soft max persist (canvas-only visual)', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Axes');
await page.locator('section.soft-min-max .ant-input-number-input').first().fill('10');
await page.locator('section.soft-min-max .ant-input-number-input').last().fill('100');
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Axes');
await expect(
page.locator('section.soft-min-max .ant-input-number-input').first(),
).toHaveValue('10');
await expect(
page.locator('section.soft-min-max .ant-input-number-input').last(),
).toHaveValue('100');
await page.locator('section.soft-min-max .ant-input-number-input').first().fill('');
await page.locator('section.soft-min-max .ant-input-number-input').last().fill('');
await saveWidgetEdit(page);
});
test('TC-08 log scale persists (canvas-only visual)', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Axes');
await page.locator('section.log-scale .ant-select').first().click();
await page
.locator('.ant-select-item-option-content', { hasText: /^Logarithmic$/ })
.first()
.click();
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Axes');
await expect(
page.locator('section.log-scale .ant-select-selection-item').first(),
).toContainText(/Logarithmic/);
await page.locator('section.log-scale .ant-select').first().click();
await page
.locator('.ant-select-item-option-content', { hasText: /^Linear$/ })
.first()
.click();
await saveWidgetEdit(page);
});
test('TC-09 legend position swap toggles chart-layout--legend-right and shows the search input', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Legend');
await expect(page.locator('.chart-layout--legend-right')).toHaveCount(0);
await expect(page.getByTestId('legend-search-input')).toHaveCount(0);
await page.locator('section.legend-position .ant-select').first().click();
await page
.locator('.ant-select-item-option-content', { hasText: /^Right$/ })
.first()
.click();
await expect(page.locator('.chart-layout--legend-right').first()).toBeVisible();
await expect(page.getByTestId('legend-search-input').first()).toBeVisible();
await saveWidgetEdit(page);
await expect(page.locator('.chart-layout--legend-right').first()).toBeVisible();
await expect(page.getByTestId('legend-search-input').first()).toBeVisible();
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Legend');
await page.locator('section.legend-position .ant-select').first().click();
await page
.locator('.ant-select-item-option-content', { hasText: /^Bottom$/ })
.first()
.click();
await saveWidgetEdit(page);
await expect(page.locator('.chart-layout--legend-right')).toHaveCount(0);
await expect(page.getByTestId('legend-search-input')).toHaveCount(0);
});
test('TC-10 Legend Colors panel renders one row per query series with a default color swatch', async ({
authedPage: page,
}) => {
// Driving the Ant ColorPicker is fiddly across builds (trigger class
// varies, preset chips may not be configured). Per-option testids have
// been added in `YAxisUnitSelector.tsx` for the unit picker, but the
// LegendColors picker uses Ant's `ColorPicker` directly with no stable
// testids. The pragmatic check is structural: when a query has run
// and produced series, the Legend Colors collapse panel renders one
// row per legend label with a `.legend-marker` carrying an inline
// `background-color` (the auto-assigned default). This guards against
// regressions in the LegendColors → query-response wiring.
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Legend');
const legendColorsCollapse = page.locator('.legend-colors-collapse').first();
await legendColorsCollapse.locator('.ant-collapse-header').first().click();
const items = page.locator('.legend-items .legend-item');
await items.first().waitFor({ state: 'visible' });
expect(await items.count()).toBeGreaterThan(0);
const firstMarker = items.first().locator('.legend-marker');
const markerStyle = (await firstMarker.getAttribute('style')) ?? '';
expect(markerStyle).toMatch(/background-color:/);
});
test('TC-11 threshold add + persistence (canvas-only line)', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Thresholds');
await page.getByTestId('add-threshold-cta').click();
const card = page.locator('.threshold-container').first();
// Bar thresholds do NOT have a label input — the time-series-alerts block
// only renders for TIME_SERIES. Skip label.
await card.getByTestId('threshold-value-input').fill('100');
await card.getByRole('button', { name: /save changes/i }).click();
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Thresholds');
await expect(page.locator('.threshold-container').first()).toBeVisible();
const firstCard = page.locator('.threshold-card-container').first();
await firstCard.hover();
// TODO: switch to `getByTestId('threshold-delete-btn')` after stack rebuild.
await firstCard.locator('button.delete-btn').click();
await saveWidgetEdit(page);
});
test('TC-12 panel type swap from Bar to Time Series and back persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Time Series');
// Editor-side visual change: Time-Series-only section appears.
await expect(page.locator('section.fill-gaps').first()).toBeVisible();
await expect(page.locator('section.stack-chart')).toHaveCount(0);
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page).toHaveURL(/graphType=graph/);
await changePanelType(page, 'Bar');
await saveWidgetEdit(page);
});
test('TC-13 sections hidden for BAR are not rendered', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// Hidden by the panel-type matrix for BAR.
await expect(page.locator('section.fill-gaps')).toHaveCount(0);
await expect(page.locator('.column-unit-selector')).toHaveCount(0);
// Expected to be present.
await expect(page.getByTestId('panel-name-input')).toBeVisible();
await expect(page.getByTestId('panel-change-select')).toBeVisible();
await expect(page.locator('section.stack-chart').first()).toBeVisible();
await expect(page.locator('section.panel-time-preference').first()).toBeVisible();
await expandSection(page, 'Axes');
await expect(page.locator('section.soft-min-max').first()).toBeVisible();
await expect(page.locator('section.log-scale').first()).toBeVisible();
await expandSection(page, 'Legend');
await expect(page.locator('section.legend-position').first()).toBeVisible();
await expandSection(page, 'Formatting & Units');
await expect(page.getByTestId('decimal-precision-selector')).toBeVisible();
await expandSection(page, 'Thresholds');
await expect(page.getByTestId('add-threshold-cta')).toBeVisible();
});
test('TC-14 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-bar-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,313 @@
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 = 'histogram-controls-fixture';
const FIXTURE_PANEL_TITLE = 'histogram-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, 'Histogram');
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' });
}
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' });
}
}
test.describe('Histogram 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('histogram-controls-renamed');
await saveWidgetEdit(page);
await expect(
page.getByTestId('histogram-controls-renamed').first(),
).toBeVisible();
await openWidgetEditor(page, 'histogram-controls-renamed');
await expect(page.getByTestId('panel-name-input')).toHaveValue(
'histogram-controls-renamed',
);
await page.getByTestId('panel-name-input').fill(FIXTURE_PANEL_TITLE);
await saveWidgetEdit(page);
});
test('TC-02 description persists and toggles the widget-header info icon', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page
.getByTestId('panel-description-input')
.fill('E2E histogram description');
await saveWidgetEdit(page);
const header = page
.locator('.widget-header-container')
.filter({ hasText: FIXTURE_PANEL_TITLE });
await expect(header.locator('.info-tooltip').first()).toBeVisible();
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page.getByTestId('panel-description-input')).toHaveValue(
'E2E histogram description',
);
await page.getByTestId('panel-description-input').fill('');
await saveWidgetEdit(page);
await expect(header.locator('.info-tooltip')).toHaveCount(0);
});
test('TC-03 bucket count and bucket width persist (canvas-only visual)', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// Section is titled "Histogram / Buckets" — literal slash + spaces.
await expandSection(page, 'Histogram / Buckets');
const bucketCount = page.locator('.bucket-input .ant-input-number-input').first();
const bucketWidth = page
.locator('.histogram-settings__bucket-input .ant-input-number-input')
.first();
await bucketCount.fill('50');
await bucketWidth.fill('1.5');
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Histogram / Buckets');
await expect(
page.locator('.bucket-input .ant-input-number-input').first(),
).toHaveValue('50');
await expect(
page
.locator('.histogram-settings__bucket-input .ant-input-number-input')
.first(),
// Ant InputNumber with precision={2} formats 1.5 → "1.50"
).toHaveValue('1.50');
// Reset
await page
.locator('.bucket-input .ant-input-number-input')
.first()
.fill('');
await page
.locator('.histogram-settings__bucket-input .ant-input-number-input')
.first()
.fill('');
await saveWidgetEdit(page);
});
test('TC-04 "Merge all series" toggle removes .legend-container from the DOM', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Histogram / Buckets');
// Live preview: legend should be present when toggle is OFF.
// (Use `.first()` because the editor may render multiple chart areas.)
await expect(page.locator('.legend-container').first()).toBeVisible();
const mergeSwitch = page
.locator('section.histogram-settings__combine-hist')
.getByRole('switch');
await expect(mergeSwitch).toHaveAttribute('aria-checked', 'false');
await mergeSwitch.click();
await expect(mergeSwitch).toHaveAttribute('aria-checked', 'true');
// Histogram passes `showLegend={!isQueriesMerged}` → legend container is
// not rendered when the merge toggle is ON.
await expect(page.locator('.legend-container')).toHaveCount(0);
await saveWidgetEdit(page);
// Dashboard render: legend container also absent.
await expect(page.locator('.legend-container')).toHaveCount(0);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Histogram / Buckets');
await expect(
page
.locator('section.histogram-settings__combine-hist')
.getByRole('switch'),
).toHaveAttribute('aria-checked', 'true');
// Reset
await page
.locator('section.histogram-settings__combine-hist')
.getByRole('switch')
.click();
await saveWidgetEdit(page);
await expect(page.locator('.legend-container').first()).toBeVisible();
});
test('TC-05 Legend Colors panel renders one row per query series with a default color swatch', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Legend');
const legendColorsCollapse = page.locator('.legend-colors-collapse').first();
await legendColorsCollapse.locator('.ant-collapse-header').first().click();
const items = page.locator('.legend-items .legend-item');
await items.first().waitFor({ state: 'visible' });
expect(await items.count()).toBeGreaterThan(0);
const firstMarker = items.first().locator('.legend-marker');
const markerStyle = (await firstMarker.getAttribute('style')) ?? '';
expect(markerStyle).toMatch(/background-color:/);
});
test('TC-06 panel type swap from Histogram to Time Series and back persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Time Series');
// Editor-side visual change: Time Series sections appear, Histogram-only
// section disappears.
await expect(page.locator('section.fill-gaps').first()).toBeVisible();
await expect(page.locator('.histogram-settings__bucket-config')).toHaveCount(0);
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page).toHaveURL(/graphType=graph/);
// Reset
await changePanelType(page, 'Histogram');
await saveWidgetEdit(page);
});
test('TC-07 sections hidden for HISTOGRAM are not rendered', 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('.y-axis-unit-selector-v2')).toHaveCount(0);
await expect(page.locator('.decimal-precision-selector')).toHaveCount(0);
await expect(page.locator('.column-unit-selector')).toHaveCount(0);
await expect(page.getByTestId('add-threshold-cta')).toHaveCount(0);
await expect(page.getByTestId('panel-name-input')).toBeVisible();
await expect(page.getByTestId('panel-change-select')).toBeVisible();
await expandSection(page, 'Histogram / Buckets');
await expect(
page.locator('.histogram-settings__bucket-config').first(),
).toBeVisible();
await expandSection(page, 'Legend');
await expect(page.locator('.legend-colors-collapse').first()).toBeVisible();
});
test('TC-08 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-histogram-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,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,417 @@
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 = 'pie-controls-fixture';
const FIXTURE_PANEL_TITLE = 'pie-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, 'Pie');
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' });
}
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' });
}
}
async function selectYAxisUnit(
page: Page,
wrapperSelector: string,
searchTerm: string,
optionText: string,
): Promise<void> {
const wrapper = page.locator(wrapperSelector).first();
await wrapper.locator('.ant-select').click();
await wrapper.locator('.ant-select input').fill(searchTerm);
await page
.locator('.ant-select-item-option-content', { hasText: optionText })
.first()
.click();
}
/**
* Trigger the arc tooltip for the first pie slice and return its rendered
* value text. Pie uses `@visx/tooltip` (plain DOM portal — not canvas) so the
* tooltip node is reliably queryable.
*
* Playwright's `.hover()` is blocked by the SVG element intercepting pointer
* events. `page.mouse.move` bypasses actionability checks but still relies on
* the browser hit-testing landing on the `<g>`. The most reliable path is
* `page.evaluate` firing a native `MouseEvent` of type `mouseover` directly
* on the arc `<g>` element — React 17+ delegates `onMouseEnter` via
* `mouseover` on the root, but also captures synthetic `mouseover` events
* dispatched on child elements and applies enter/leave semantics.
*/
async function readPieArcTooltipText(page: Page): Promise<string> {
// Wait for the arc group to be in the DOM.
const firstArcG = page.locator('.piechart-container svg g g').first();
await firstArcG.waitFor({ state: 'visible' });
// Dispatch a synthetic mouseover directly on the arc <g>. This reaches
// React's event delegation layer regardless of SVG pointer-event interception.
// All browser globals are cast via `(globalThis as any)` because the
// tsconfig lib does not include "dom" — page.evaluate callbacks run in the
// browser but are type-checked in the Node context.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await page.evaluate((sel: string) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const w = globalThis as any;
const g = w.document.querySelector(sel);
if (!g) throw new Error('Arc <g> not found');
g.dispatchEvent(new w.MouseEvent('mouseover', { bubbles: true, cancelable: true }));
}, '.piechart-container svg g g');
const tooltip = page.locator('.piechart-tooltip').first();
await tooltip.waitFor({ state: 'visible', timeout: 5000 });
const valueText = (await page.locator('.tooltip-value').first().textContent()) ?? '';
// Dispatch mouseout on the arc to close the tooltip.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await page.evaluate((sel: string) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const w = globalThis as any;
const g = w.document.querySelector(sel);
if (!g) return;
g.dispatchEvent(new w.MouseEvent('mouseout', { bubbles: true, cancelable: true }));
}, '.piechart-container svg g g');
return valueText;
}
test.describe('Pie 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('pie-controls-renamed');
await saveWidgetEdit(page);
await expect(page.getByTestId('pie-controls-renamed').first()).toBeVisible();
await openWidgetEditor(page, 'pie-controls-renamed');
await expect(page.getByTestId('panel-name-input')).toHaveValue(
'pie-controls-renamed',
);
await page.getByTestId('panel-name-input').fill(FIXTURE_PANEL_TITLE);
await saveWidgetEdit(page);
});
test('TC-02 description persists and toggles the widget-header info icon', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page
.getByTestId('panel-description-input')
.fill('E2E pie description');
await saveWidgetEdit(page);
const header = page
.locator('.widget-header-container')
.filter({ hasText: FIXTURE_PANEL_TITLE });
await expect(header.locator('.info-tooltip').first()).toBeVisible();
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page.getByTestId('panel-description-input')).toHaveValue(
'E2E pie description',
);
await page.getByTestId('panel-description-input').fill('');
await saveWidgetEdit(page);
await expect(header.locator('.info-tooltip')).toHaveCount(0);
});
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 Y-axis unit applies to the SVG centre text and arc tooltip', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await selectYAxisUnit(
page,
'.y-axis-unit-selector-v2',
'Milliseconds',
'Milliseconds (ms)',
);
await saveWidgetEdit(page);
// Visible change 1: the SVG centre text gains a `ms` tspan when a
// unit is set.
const centreTspans = page.locator('.piechart-container svg text tspan');
await centreTspans.first().waitFor({ state: 'visible' });
const tspanTexts = await centreTspans.allTextContents();
expect(tspanTexts.some((t) => /ms/.test(t))).toBe(true);
// Visible change 2: the arc tooltip includes the `ms` suffix.
const tooltipText = await readPieArcTooltipText(page);
expect(tooltipText).toMatch(/ms/);
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(/Milliseconds/);
// Reset
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 changes the rendered arc-tooltip values', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
// A unit is required for decimal precision to have a visible effect.
await selectYAxisUnit(
page,
'.y-axis-unit-selector-v2',
'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);
// Visible change: arc tooltip numeric portion has no decimal point.
const tooltipText = await readPieArcTooltipText(page);
const numericPart = tooltipText.replace(/[A-Za-z]+/g, '');
expect(numericPart).not.toMatch(/\./);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await expect(page.getByTestId('decimal-precision-selector')).toContainText(
/0 decimals/,
);
// Reset
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 Legend Colors panel renders one row per query series with a default color swatch', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Legend');
const legendColorsCollapse = page.locator('.legend-colors-collapse').first();
await legendColorsCollapse.locator('.ant-collapse-header').first().click();
const items = page.locator('.legend-items .legend-item');
await items.first().waitFor({ state: 'visible' });
expect(await items.count()).toBeGreaterThan(0);
const firstMarker = items.first().locator('.legend-marker');
const markerStyle = (await firstMarker.getAttribute('style')) ?? '';
expect(markerStyle).toMatch(/background-color:/);
});
test('TC-07 piechart-legend-item count matches the number of query series', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
// On the dashboard, count legend items and assert each has a coloured
// swatch.
await page.locator('.piechart-legend-item').first().waitFor({ state: 'visible' });
const dashboardCount = await page.locator('.piechart-legend-item').count();
expect(dashboardCount).toBeGreaterThan(0);
const firstSwatchStyle = (await page
.locator('.piechart-legend-item .piechart-legend-label')
.first()
.getAttribute('style')) ?? '';
expect(firstSwatchStyle).toMatch(/background-color:/);
});
test('TC-08 panel type swap from Pie to Time Series and back persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Time Series');
// Editor-side visual change: Time Series sections appear, Pie-only
// `.piechart-wrapper` is gone from the editor preview area.
await expect(page.locator('section.fill-gaps').first()).toBeVisible();
await saveWidgetEdit(page);
// Dashboard render now shows a uPlot chart, not a piechart.
await expect(page.getByTestId('uplot-main-div').first()).toBeVisible();
await expect(page.locator('.piechart-wrapper')).toHaveCount(0);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page).toHaveURL(/graphType=graph/);
// Reset
await changePanelType(page, 'Pie');
await saveWidgetEdit(page);
});
test('TC-09 sections hidden for PIE 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.locator('.column-unit-selector')).toHaveCount(0);
await expect(page.getByTestId('add-threshold-cta')).toHaveCount(0);
await expect(page.locator('.histogram-settings__bucket-config')).toHaveCount(
0,
);
await expect(page.getByTestId('panel-name-input')).toBeVisible();
await expect(page.getByTestId('panel-change-select')).toBeVisible();
await expect(page.locator('section.panel-time-preference').first()).toBeVisible();
await expandSection(page, 'Formatting & Units');
await expect(page.locator('.y-axis-unit-selector-v2').first()).toBeVisible();
await expect(page.getByTestId('decimal-precision-selector')).toBeVisible();
await expandSection(page, 'Legend');
await expect(page.locator('.legend-colors-collapse').first()).toBeVisible();
});
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-pie-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,584 @@
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 share one fixture panel — run serially.
test.describe.configure({ mode: 'serial' });
const FIXTURE_DASHBOARD_TITLE = 'time-series-controls-fixture';
const FIXTURE_PANEL_TITLE = 'time-series-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' });
// configureAndSavePanel creates a Time Series (graph) panel by default —
// no panel-type swap needed here.
await configureAndSavePanel(page, 'metrics', FIXTURE_PANEL_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();
}
});
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. No-op if already open.
*/
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 a Y-axis-unit-selector wrapper by typing a search term
* first (Ant Select has a virtualised option list — typing first prevents
* detached-DOM failures when the target option is off-screen).
*
* `wrapperSelector` is the CSS selector for the enclosing
* `.y-axis-unit-selector-v2` instance (use `.y-axis-unit-selector-v2` for the
* Formatting Y-axis unit; threshold cards have their own nested instance —
* scope accordingly).
*/
async function selectYAxisUnit(
page: Page,
wrapperSelector: string,
searchTerm: string,
optionText: string,
): Promise<void> {
const wrapper = page.locator(wrapperSelector).first();
await wrapper.locator('.ant-select').click();
await wrapper.locator('.ant-select input').fill(searchTerm);
await page
.locator('.ant-select-item-option-content', { hasText: optionText })
.first()
.click();
}
test.describe('Time Series 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('ts-controls-renamed');
await saveWidgetEdit(page);
await expect(page.getByTestId('ts-controls-renamed').first()).toBeVisible();
await openWidgetEditor(page, 'ts-controls-renamed');
await expect(page.getByTestId('panel-name-input')).toHaveValue(
'ts-controls-renamed',
);
await page.getByTestId('panel-name-input').fill(FIXTURE_PANEL_TITLE);
await saveWidgetEdit(page);
});
test('TC-02 description persists and toggles the widget-header info icon', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page
.getByTestId('panel-description-input')
.fill('E2E time series description');
await saveWidgetEdit(page);
// Visible change: info icon appears in the widget header.
const header = page
.locator('.widget-header-container')
.filter({ hasText: FIXTURE_PANEL_TITLE });
await expect(header.locator('.info-tooltip').first()).toBeVisible();
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page.getByTestId('panel-description-input')).toHaveValue(
'E2E time series description',
);
// Reset and assert the info icon disappears.
await page.getByTestId('panel-description-input').fill('');
await saveWidgetEdit(page);
await expect(header.locator('.info-tooltip')).toHaveCount(0);
});
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 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 fill gaps toggle persists', async ({ authedPage: page }) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// canvas-only — visible chart effect not asserted (canvas-drawn series).
const fillGapsSwitch = page.locator('section.fill-gaps').getByRole('switch');
await expect(fillGapsSwitch).toHaveAttribute('aria-checked', 'false');
await fillGapsSwitch.click();
await expect(fillGapsSwitch).toHaveAttribute('aria-checked', 'true');
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(
page.locator('section.fill-gaps').getByRole('switch'),
).toHaveAttribute('aria-checked', 'true');
// Reset
await page.locator('section.fill-gaps').getByRole('switch').click();
await saveWidgetEdit(page);
});
test('TC-05 Y-axis unit persists', async ({ authedPage: page }) => {
// The plan asks for a tooltip-driven visible-change check (hover the
// chart, assert tooltip text contains `ms`). In practice the test
// stack's `signoz_calls_total` data slides outside the dashboard's
// default "Last 30 minutes" window between the suite-start golden
// reseed and the time TC-05 runs, so the rendered panel often shows
// "No Data" and the tooltip never appears. Until the seeder either
// emits points in a rolling-now window or the dashboard global-time
// preset gets widened from the test fixture, the tooltip assertion is
// not viable. Verify persistence only — the selector value round-trips
// through PUT and re-renders in the editor.
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await selectYAxisUnit(
page,
'.y-axis-unit-selector-v2',
'Milliseconds',
'Milliseconds (ms)',
);
await saveWidgetEdit(page);
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(/Milliseconds/);
// Reset — clear via allowClear.
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 decimal precision persists', async ({ authedPage: page }) => {
// Tooltip-based visible-change assertion is omitted for the same reason
// as TC-05 — `signoz_calls_total` data window flakes mid-suite. Verify
// persistence only.
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await page.getByTestId('decimal-precision-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '0 decimals' })
.first()
.click();
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await expect(page.getByTestId('decimal-precision-selector')).toContainText(
/0 decimals/,
);
// Reset
await page.getByTestId('decimal-precision-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '2 decimals' })
.first()
.click();
await saveWidgetEdit(page);
});
test('TC-07 soft min and soft max persist (canvas-only visual)', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Axes');
// Soft Min is the first .ant-input-number inside section.soft-min-max.
const softMin = page.locator('section.soft-min-max .ant-input-number-input').first();
const softMax = page.locator('section.soft-min-max .ant-input-number-input').last();
await softMin.fill('10');
await softMax.fill('100');
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Axes');
await expect(
page.locator('section.soft-min-max .ant-input-number-input').first(),
).toHaveValue('10');
await expect(
page.locator('section.soft-min-max .ant-input-number-input').last(),
).toHaveValue('100');
// Reset — clear both. (Note: the |...|| 0 coercion in onClickSaveHandler
// will persist 0 not null after this save; that's the known behaviour.)
await page
.locator('section.soft-min-max .ant-input-number-input')
.first()
.fill('');
await page
.locator('section.soft-min-max .ant-input-number-input')
.last()
.fill('');
await saveWidgetEdit(page);
});
test('TC-08 log scale persists (canvas-only visual)', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Axes');
const logScaleSelect = page.locator('section.log-scale .ant-select').first();
await logScaleSelect.click();
await page
.locator('.ant-select-item-option-content', { hasText: /^Logarithmic$/ })
.first()
.click();
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Axes');
await expect(
page.locator('section.log-scale .ant-select-selection-item').first(),
).toContainText(/Logarithmic/);
// Reset
await page.locator('section.log-scale .ant-select').first().click();
await page
.locator('.ant-select-item-option-content', { hasText: /^Linear$/ })
.first()
.click();
await saveWidgetEdit(page);
});
test('TC-09 legend position swap toggles the chart-layout--legend-right class and shows the search input', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Legend');
// Before: legend is at the bottom; no chart-layout--legend-right; no
// legend-search-input.
await expect(page.locator('.chart-layout--legend-right')).toHaveCount(0);
await expect(page.getByTestId('legend-search-input')).toHaveCount(0);
// Switch to Right.
await page.locator('section.legend-position .ant-select').first().click();
await page
.locator('.ant-select-item-option-content', { hasText: /^Right$/ })
.first()
.click();
// In-editor live preview: layout updates.
await expect(page.locator('.chart-layout--legend-right').first()).toBeVisible();
await expect(page.getByTestId('legend-search-input').first()).toBeVisible();
await saveWidgetEdit(page);
// Dashboard: same assertions hold on the rendered panel card.
await expect(page.locator('.chart-layout--legend-right').first()).toBeVisible();
await expect(page.getByTestId('legend-search-input').first()).toBeVisible();
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Legend');
await expect(
page.locator('section.legend-position .ant-select-selection-item').first(),
).toContainText(/Right/);
// Reset to Bottom and assert the class disappears.
await page.locator('section.legend-position .ant-select').first().click();
await page
.locator('.ant-select-item-option-content', { hasText: /^Bottom$/ })
.first()
.click();
await saveWidgetEdit(page);
await expect(page.locator('.chart-layout--legend-right')).toHaveCount(0);
await expect(page.getByTestId('legend-search-input')).toHaveCount(0);
});
test('TC-09b Legend Colors panel renders one row per query series with a default color swatch', async ({
authedPage: page,
}) => {
// The original plan was to drive the Ant `ColorPicker` and assert a
// custom color round-trips. The Ant ColorPicker DOM is fiddly to drive
// reliably from Playwright (the trigger is the wrapped child element,
// presets vary by build, and committing a color requires Escape /
// click-outside semantics that depend on portal positioning). The
// pragmatic check we ship here is the *structural* one: when a query
// has run and produced series, the LegendColors collapse panel renders
// one row per legend label with a `.legend-marker` that carries an
// inline `background-color` (the auto-assigned default). This guards
// against regressions in the LegendColors → query-response wiring,
// which is the part most likely to silently break.
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Legend');
// Expand the Ant Collapse panel "Legend Colors" (it sits below the
// Position selector inside the Legend SettingsSection).
const legendColorsCollapse = page.locator('.legend-colors-collapse').first();
await legendColorsCollapse.locator('.ant-collapse-header').first().click();
// After expansion: at least one per-series row, each with a coloured
// `.legend-marker` swatch carrying inline backgroundColor.
const items = page.locator('.legend-items .legend-item');
await items.first().waitFor({ state: 'visible' });
expect(await items.count()).toBeGreaterThan(0);
const firstMarker = items.first().locator('.legend-marker');
const markerStyle = (await firstMarker.getAttribute('style')) ?? '';
expect(markerStyle).toMatch(/background-color:/);
});
test('TC-10 threshold add + persistence (canvas-only line)', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Thresholds');
await page.getByTestId('add-threshold-cta').click();
const card = page.locator('.threshold-container').first();
// Time Series thresholds have a label input (unique to TIME_SERIES).
await card.getByTestId('threshold-label-input').fill('alert-threshold');
await card.getByTestId('threshold-value-input').fill('500');
await card.getByRole('button', { name: /save changes/i }).click();
await saveWidgetEdit(page);
// canvas-only — line is canvas-drawn. Verify persistence by re-open.
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Thresholds');
await expect(page.locator('.threshold-container').first()).toBeVisible();
// Reset — delete via hover-revealed button.
const firstCard = page.locator('.threshold-card-container').first();
await firstCard.hover();
// TODO: switch to `getByTestId('threshold-delete-btn')` after stack rebuild.
await firstCard.locator('button.delete-btn').click();
await saveWidgetEdit(page);
});
test('TC-11 threshold value persists in edit mode after save + re-open', async ({
authedPage: page,
}) => {
// Originally drove the threshold's V1 unit selector to assert
// `'seconds (s)'` round-trips. The V1 selector's `handleSearch`
// filterOption hides every option when a V2-style search term is typed
// AND the dropdown options don't reliably surface in the
// currently-visible portal under Playwright. We've added per-option
// `data-testid="unit-option-<id>"` in `YAxisUnitSelector.tsx`; once the
// test stack frontend rebuilds with that testid, this TC can be
// upgraded to pick the unit deterministically via
// `page.getByTestId('unit-option-s')`. Meanwhile the TC verifies the
// numeric value field round-trips through edit mode — the most common
// regression vector and the one most worth guarding.
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Thresholds');
await page.getByTestId('add-threshold-cta').click();
const card = page.locator('.threshold-container').first();
await card.getByTestId('threshold-value-input').fill('100');
await card.getByRole('button', { name: /save changes/i }).click();
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Thresholds');
// Re-enter edit mode and assert the value field carries the saved 100.
const cardAfter = page.locator('.threshold-container').first();
await cardAfter.hover();
// TODO: switch to `getByTestId('threshold-edit-btn')` after stack rebuild.
await cardAfter.locator('button.edit-btn').click();
await expect(cardAfter.getByTestId('threshold-value-input')).toHaveValue(
'100',
);
// Reset — discard the edit, then delete.
await cardAfter.getByRole('button', { name: /^discard$/i }).click();
const firstCard = page.locator('.threshold-card-container').first();
await firstCard.hover();
// TODO: switch to `getByTestId('threshold-delete-btn')` after stack rebuild.
await firstCard.locator('button.delete-btn').click();
await saveWidgetEdit(page);
});
test('TC-12 panel type swap from Time Series to Bar and back persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Bar');
// Editor-side visual change: Bar-only section appears, Time-Series-only
// section disappears.
await expect(page.locator('section.stack-chart').first()).toBeVisible();
await expect(page.locator('section.fill-gaps')).toHaveCount(0);
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page).toHaveURL(/graphType=bar/);
// Reset
await changePanelType(page, 'Time Series');
await saveWidgetEdit(page);
});
test('TC-13 fill gaps and panel time preference persist together', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// Set both.
await page.locator('section.fill-gaps').getByRole('switch').click();
await page
.locator('section.panel-time-preference')
.getByRole('button', { name: /global time/i })
.click();
await page.getByRole('menuitem', { name: /Last 1 hr/i }).click();
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(
page.locator('section.fill-gaps').getByRole('switch'),
).toHaveAttribute('aria-checked', 'true');
await expect(
page.locator('section.panel-time-preference').getByRole('button'),
).toContainText(/Last 1 hr/i);
// Reset both.
await page.locator('section.fill-gaps').getByRole('switch').click();
await page
.locator('section.panel-time-preference')
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: /Global Time/i }).click();
await saveWidgetEdit(page);
});
test('TC-14 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-ts-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);
});
});