mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-01 14:50:29 +01:00
Compare commits
1 Commits
e2e/table-
...
chore/play
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6477a5e2ee |
13
.github/workflows/e2eci.yaml
vendored
13
.github/workflows/e2eci.yaml
vendored
@@ -68,9 +68,22 @@ jobs:
|
||||
- name: pnpm-install
|
||||
run: |
|
||||
cd tests/e2e && pnpm install --frozen-lockfile
|
||||
- name: playwright-cache
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ hashFiles('tests/e2e/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-playwright-
|
||||
- name: playwright-browsers
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
cd tests/e2e && pnpm playwright install --with-deps ${{ matrix.project }}
|
||||
- name: playwright-system-deps
|
||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||
run: |
|
||||
cd tests/e2e && pnpm playwright install-deps ${{ matrix.project }}
|
||||
- name: bring-up-stack
|
||||
run: |
|
||||
cd tests && \
|
||||
|
||||
@@ -93,7 +93,6 @@ function ValueGraph({
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="value-graph-container"
|
||||
data-testid="value-graph-container"
|
||||
style={{
|
||||
backgroundColor:
|
||||
threshold.thresholdFormat === 'Background'
|
||||
|
||||
@@ -159,8 +159,6 @@ 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 }
|
||||
|
||||
@@ -80,23 +80,18 @@ export function ColumnUnitSelector(
|
||||
{aggregationQueries.map(({ value, label }) => {
|
||||
const baseQueryName = value.split('.')[0];
|
||||
return (
|
||||
<div
|
||||
<YAxisUnitSelectorV2
|
||||
value={columnUnits[value] || ''}
|
||||
onSelect={(unitValue: string): void =>
|
||||
handleColumnUnitSelect(value, unitValue)
|
||||
}
|
||||
fieldLabel={label}
|
||||
key={value}
|
||||
className="column-unit-row"
|
||||
data-testid={`column-unit-row-${baseQueryName}`}
|
||||
>
|
||||
<YAxisUnitSelectorV2
|
||||
value={columnUnits[value] || ''}
|
||||
onSelect={(unitValue: string): void =>
|
||||
handleColumnUnitSelect(value, unitValue)
|
||||
}
|
||||
fieldLabel={label}
|
||||
data-testid={props['data-testid']}
|
||||
selectedQueryName={baseQueryName}
|
||||
// Update the column unit value automatically only in create mode
|
||||
shouldUpdateYAxisUnit={isNewDashboard}
|
||||
/>
|
||||
</div>
|
||||
data-testid={props['data-testid']}
|
||||
selectedQueryName={baseQueryName}
|
||||
// Update the column unit value automatically only in create mode
|
||||
shouldUpdateYAxisUnit={isNewDashboard}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -78,10 +78,7 @@ export default function VisualizationSettingsSection({
|
||||
>
|
||||
{graphTypes.map((item) => (
|
||||
<Option key={item.name} value={item.name}>
|
||||
<div
|
||||
className="select-option"
|
||||
data-testid={`panel-type-option-${item.name}`}
|
||||
>
|
||||
<div className="select-option">
|
||||
<div className="icon">{item.icon}</div>
|
||||
<Typography.Text className="display">{item.display}</Typography.Text>
|
||||
</div>
|
||||
|
||||
@@ -231,14 +231,12 @@ 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>
|
||||
|
||||
@@ -8,10 +8,6 @@ import {
|
||||
} from '@playwright/test';
|
||||
|
||||
import apmMetricsTemplate from '../testdata/apm-metrics.json';
|
||||
import queriesData from '../testdata/queries.json';
|
||||
|
||||
export type SignalType = 'metrics' | 'logs' | 'traces';
|
||||
export type QueriesData = typeof queriesData;
|
||||
import chartDataTemplate from '../testdata/chart-data-dashboard.json';
|
||||
import variablesTemplate from '../testdata/variables-dashboard.json';
|
||||
|
||||
@@ -370,56 +366,6 @@ export async function findDashboardIdByTitle(
|
||||
return body.data.find((d) => d.data.title === title)?.id;
|
||||
}
|
||||
|
||||
/** Shape of a single persisted widget — only the fields these specs assert on. */
|
||||
export interface PersistedWidget {
|
||||
id?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
panelTypes?: string;
|
||||
timePreferance?: string;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: number;
|
||||
thresholds?: Array<{
|
||||
thresholdFormat?: string;
|
||||
thresholdOperator?: string;
|
||||
thresholdValue?: number;
|
||||
thresholdColor?: string;
|
||||
thresholdTableOptions?: string;
|
||||
}>;
|
||||
columnUnits?: Record<string, string>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Shape of the persisted dashboard payload returned by GET /api/v1/dashboards/<id>. */
|
||||
export interface DashboardData {
|
||||
title: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
widgets?: PersistedWidget[];
|
||||
variables?: Record<string, Record<string, unknown>>;
|
||||
layout?: unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the persisted dashboard payload via API. Use this for "did the save
|
||||
* actually land on the server?" assertions — UI-only checks can pass on
|
||||
* optimistic-update bugs.
|
||||
*/
|
||||
export async function fetchDashboardData(
|
||||
page: Page,
|
||||
id: string,
|
||||
): Promise<DashboardData> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.get(`/api/v1/dashboards/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`GET /dashboards/${id} ${res.status()}: ${await res.text()}`);
|
||||
}
|
||||
const body = (await res.json()) as { data: { data: DashboardData } };
|
||||
return body.data.data;
|
||||
}
|
||||
|
||||
// ─── List page UI helpers ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -441,245 +387,3 @@ export async function openDashboardActionMenu(
|
||||
await icon.click();
|
||||
return page.getByRole('tooltip');
|
||||
}
|
||||
|
||||
// ─── Dashboard detail page helpers ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Click the Configure button (`data-testid="show-drawer"`) on a dashboard
|
||||
* detail page and wait for the settings drawer (`.settings-container-root`) to
|
||||
* be visible. Works from both the empty-state view and the populated toolbar —
|
||||
* both render the same testid.
|
||||
*
|
||||
* Returns the drawer locator so callers can scope further assertions to it.
|
||||
*/
|
||||
export async function openDashboardSettingsDrawer(
|
||||
page: Page,
|
||||
): Promise<Locator> {
|
||||
await page.getByTestId('show-drawer').first().click();
|
||||
const drawer = page.locator('.settings-container-root');
|
||||
await drawer.waitFor({ state: 'visible' });
|
||||
return drawer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click `data-testid="save-dashboard-config"` and wait for the resulting
|
||||
* `PUT /api/v1/dashboards/<id>` response. The Save button is only rendered
|
||||
* when there is at least one unsaved change — callers must ensure the drawer
|
||||
* has been dirtied before calling this.
|
||||
*/
|
||||
export async function saveDashboardSettings(page: Page): Promise<void> {
|
||||
const patchResponse = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await page.getByTestId('save-dashboard-config').click();
|
||||
await patchResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a dashboard via the toolbar options popover:
|
||||
* opens the popover (`data-testid="options"`), clicks "Rename", fills the
|
||||
* input, clicks "Rename Dashboard", and waits for the PUT response.
|
||||
*
|
||||
* Pre-condition: the caller must be on the dashboard detail page.
|
||||
*/
|
||||
export async function renameDashboardViaToolbar(
|
||||
page: Page,
|
||||
newTitle: string,
|
||||
): Promise<void> {
|
||||
await page.getByTestId('options').click();
|
||||
await page.getByRole('button', { name: 'Rename' }).click();
|
||||
|
||||
const modal = page.getByRole('dialog');
|
||||
await modal.waitFor({ state: 'visible' });
|
||||
|
||||
const input = modal.getByTestId('dashboard-name');
|
||||
await input.clear();
|
||||
await input.fill(newTitle);
|
||||
|
||||
const patchResponse = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await page.getByRole('button', { name: 'Rename Dashboard' }).click();
|
||||
await patchResponse;
|
||||
|
||||
await modal.waitFor({ state: 'hidden' });
|
||||
}
|
||||
|
||||
// ─── Add panel flow ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* From the dashboard detail page (must already be loaded), drive the full
|
||||
* "Add Panel" flow for the given signal type:
|
||||
* 1. Click the empty-state `add-panel` CTA to open the New Panel modal.
|
||||
* 2. Pick the Time Series panel type.
|
||||
* 3. Fill the panel name in the right pane (drives the post-save assertion).
|
||||
* 4. For metrics: type the metric name from `queries.json` into the metric
|
||||
* AutoComplete and select it from the dropdown. For logs/traces: switch
|
||||
* the data-source selector to LOGS / TRACES; default Query Builder state
|
||||
* is sufficient (queries.json query strings are empty by design).
|
||||
* 5. Click Save Changes and wait for the PUT /api/v1/dashboards/<id> response.
|
||||
*
|
||||
* Throws if the PUT response is not 2xx. After return, the page is back on
|
||||
* the dashboard detail page; the caller asserts the panel rendered.
|
||||
*/
|
||||
export async function configureAndSavePanel(
|
||||
page: Page,
|
||||
signal: SignalType,
|
||||
panelTitle: string,
|
||||
): Promise<void> {
|
||||
await page.getByTestId('add-panel').click();
|
||||
|
||||
const newPanelModal = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'New Panel' });
|
||||
await newPanelModal.waitFor({ state: 'visible' });
|
||||
await newPanelModal.getByTestId('panel-type-graph').click();
|
||||
|
||||
await page.getByTestId('new-widget-save').waitFor({ state: 'visible' });
|
||||
await page.getByTestId('panel-name-input').fill(panelTitle);
|
||||
|
||||
if (signal === 'metrics') {
|
||||
const metricName = queriesData.metrics.metricName;
|
||||
// The testid is on the Ant Select wrapper <div>; the editable input
|
||||
// lives inside it. Target the descendant input for fill().
|
||||
const metricInput = page
|
||||
.getByTestId('metric-name-selector-0')
|
||||
.locator('input');
|
||||
await metricInput.click();
|
||||
await metricInput.fill(metricName);
|
||||
// AutoComplete debounces and fetches; wait for the option then click.
|
||||
await page
|
||||
.locator('.ant-select-item-option-content', { hasText: metricName })
|
||||
.first()
|
||||
.click();
|
||||
} else {
|
||||
// logs / traces — switch the data source. Default query is sufficient.
|
||||
await page.getByTestId('query-data-source-selector-0').click();
|
||||
await page
|
||||
.locator('.ant-select-item-option-content', {
|
||||
hasText: signal.toUpperCase(),
|
||||
})
|
||||
.click();
|
||||
}
|
||||
|
||||
const putResponse = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await page.getByTestId('new-widget-save').click();
|
||||
|
||||
const res = await putResponse;
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`PUT /api/v1/dashboards failed ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Save navigates back to /dashboard/<id> (no /new suffix).
|
||||
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
|
||||
}
|
||||
|
||||
// ─── Widget editor (re-open existing panel) ────────────────────────────────
|
||||
|
||||
/**
|
||||
* Display labels surfaced in the `panel-change-select` Ant Select inside the
|
||||
* widget editor. Mirrors the `PanelDisplay` enum in
|
||||
* `frontend/src/constants/queryBuilder.ts` — keep in sync if a label is added,
|
||||
* removed, or renamed there.
|
||||
*/
|
||||
export type PanelDisplayLabel =
|
||||
| 'Time Series'
|
||||
| 'Number'
|
||||
| 'Table'
|
||||
| 'List'
|
||||
| 'Bar'
|
||||
| 'Pie'
|
||||
| 'Histogram';
|
||||
|
||||
/**
|
||||
* Maps each display label to the URL `graphType` value. The right-hand strings
|
||||
* mirror the `PANEL_TYPES` enum in
|
||||
* `frontend/src/constants/queryBuilder.ts` (TIME_SERIES='graph',
|
||||
* VALUE='value', and so on) — keep in sync with the enum.
|
||||
*/
|
||||
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, await the dashboard PUT response,
|
||||
* and wait for navigation back to `/dashboard/<id>`. Throws if the PUT
|
||||
* response is not 2xx. NewWidget's save handler calls the mutation and
|
||||
* navigates on success — there is no confirmation modal in this flow.
|
||||
*/
|
||||
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 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 `data-testid="panel-type-option-<graphType>"` hook
|
||||
// on its inner `.select-option` wrapper (see VisualizationSettingsSection).
|
||||
// Targeting that is more stable than Ant's hidden-select option role —
|
||||
// whose accessible name is the URL `graphType` value, not the display label.
|
||||
await page.getByTestId(`panel-type-option-${expectedGraphType}`).click();
|
||||
await page.waitForURL(new RegExp(`graphType=${expectedGraphType}`));
|
||||
await page.getByTestId('new-widget-save').waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
12
tests/e2e/testdata/queries.json
vendored
12
tests/e2e/testdata/queries.json
vendored
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"logs": {
|
||||
"query": ""
|
||||
},
|
||||
"metrics": {
|
||||
"metricName": "signoz_calls_total",
|
||||
"query": ""
|
||||
},
|
||||
"traces": {
|
||||
"query": ""
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
configureAndSavePanel,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
@@ -144,59 +143,4 @@ test.describe('Dashboard Detail — Add Panel (entry-point + persistence)', () =
|
||||
page.getByText(panelName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Per-signal panel creation ───────────────────────────────────────────
|
||||
//
|
||||
// Configure a query for each signal using values from testdata/queries.json,
|
||||
// save the panel, return to the dashboard, and verify the panel card renders
|
||||
// and survives a reload.
|
||||
|
||||
test('TC-03 add metrics Time Series panel using signoz_calls_total from queries.json', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await createDashboardViaApi(page, 'add-panel-metrics');
|
||||
seedIds.add(id);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await expect(page.getByTestId('add-panel')).toBeVisible();
|
||||
|
||||
await configureAndSavePanel(page, 'metrics', 'metrics-timeseries');
|
||||
|
||||
await expect(page.getByTestId('metrics-timeseries')).toBeVisible();
|
||||
|
||||
// Reload — proves the panel persists, not just optimistic UI from the save.
|
||||
await page.reload();
|
||||
await expect(page.getByTestId('metrics-timeseries')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-04 add logs Time Series panel with default query from queries.json', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await createDashboardViaApi(page, 'add-panel-logs');
|
||||
seedIds.add(id);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await expect(page.getByTestId('add-panel')).toBeVisible();
|
||||
|
||||
await configureAndSavePanel(page, 'logs', 'logs-timeseries');
|
||||
|
||||
await expect(page.getByTestId('logs-timeseries')).toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByTestId('logs-timeseries')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-05 add traces Time Series panel with default query from queries.json', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await createDashboardViaApi(page, 'add-panel-traces');
|
||||
seedIds.add(id);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await expect(page.getByTestId('add-panel')).toBeVisible();
|
||||
|
||||
await configureAndSavePanel(page, 'traces', 'traces-timeseries');
|
||||
|
||||
await expect(page.getByTestId('traces-timeseries')).toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByTestId('traces-timeseries')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,10 +7,6 @@ import {
|
||||
awaitVariablesResolved,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
fetchDashboardData,
|
||||
gotoDashboardsList,
|
||||
renameDashboardViaToolbar,
|
||||
SEARCH_PLACEHOLDER,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
const TELEMETRY_DEPENDENT_VARS = ['q_env', 'q_service', 'd_namespace'];
|
||||
@@ -688,38 +684,4 @@ test.describe('Dashboard Detail — Configure drawer', () => {
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Toolbar rename ──────────────────────────────────────────────────────
|
||||
// The toolbar options popover uses a separate PUT path from the Configure
|
||||
// drawer (TC-02 above). Both paths must persist server-side and surface in
|
||||
// the list view — this catches optimistic-update regressions specific to
|
||||
// the toolbar entry point.
|
||||
|
||||
test('TC-22 rename via toolbar options popover persists to the toolbar title', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'cfg-toolbar-rename');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
// DashboardDescription toolbar always renders — even on blank dashboards.
|
||||
await expect(page.getByTestId('options')).toBeVisible();
|
||||
|
||||
await renameDashboardViaToolbar(page, 'cfg-toolbar-rename-renamed');
|
||||
|
||||
await expect(page.getByTestId('dashboard-title')).toHaveText(
|
||||
'cfg-toolbar-rename-renamed',
|
||||
);
|
||||
|
||||
const persisted = await fetchDashboardData(page, id);
|
||||
expect(persisted.title).toBe('cfg-toolbar-rename-renamed');
|
||||
|
||||
// List view reflects the rename after navigating back.
|
||||
await gotoDashboardsList(page);
|
||||
await page
|
||||
.getByPlaceholder(SEARCH_PLACEHOLDER)
|
||||
.fill('cfg-toolbar-rename-renamed');
|
||||
await expect(
|
||||
page.getByText('cfg-toolbar-rename-renamed').first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
createDashboardViaApi,
|
||||
createVariablesDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
openDashboardSettingsDrawer,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
@@ -241,25 +240,4 @@ test.describe('Dashboard Detail Page — Edge Cases', () => {
|
||||
// document.title is set from the dashboard name — confirm it is intact.
|
||||
await expect(page).toHaveTitle(new RegExp('Spec & Chars'));
|
||||
});
|
||||
|
||||
test('TC-09 navigating away with the settings drawer open does not crash', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await createDashboardViaApi(page, 'edge-drawer-nav-away');
|
||||
seedIds.add(id);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openDashboardSettingsDrawer(page);
|
||||
|
||||
// Navigate away without closing the drawer.
|
||||
await page.goto('/dashboard');
|
||||
await expect(page).toHaveURL(/\/dashboard($|\?)/);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
|
||||
).toBeVisible();
|
||||
// No error overlay should be present.
|
||||
await expect(
|
||||
page.getByRole('alert').filter({ hasText: /error/i }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import path from 'path';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
@@ -9,7 +8,6 @@ import {
|
||||
createDashboardViaApi,
|
||||
DEFAULT_DASHBOARD_TITLE,
|
||||
deleteDashboardViaApi,
|
||||
fetchDashboardData,
|
||||
findDashboardIdByTitle,
|
||||
gotoDashboardsList,
|
||||
importApmMetricsDashboardViaUI,
|
||||
@@ -17,11 +15,6 @@ import {
|
||||
SEARCH_PLACEHOLDER,
|
||||
} from '../../helpers/dashboards';
|
||||
|
||||
const APM_METRICS_TESTDATA_PATH = path.resolve(
|
||||
__dirname,
|
||||
'../../testdata/apm-metrics.json',
|
||||
);
|
||||
|
||||
// Tests in this file mutate the dashboard list (create / delete). Run them
|
||||
// serially within the worker so state from one test does not leak into
|
||||
// another's assertions. Files still run in parallel via the project-level
|
||||
@@ -429,140 +422,12 @@ test.describe('Dashboards List Page', () => {
|
||||
await expect(page).toHaveURL(/\/dashboard($|\?)/);
|
||||
});
|
||||
|
||||
test('TC-19 import via file upload creates dashboard, navigates to detail, and surfaces metadata in list', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDashboardsList(page);
|
||||
await page.getByTestId('new-dashboard-cta').click();
|
||||
await page.getByTestId('import-json-menu-cta').click();
|
||||
|
||||
const dialog = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Import Dashboard JSON' });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
const postResponse = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'POST' && /\/api\/v1\/dashboards/.test(r.url()),
|
||||
);
|
||||
await dialog
|
||||
.locator('input[type="file"]')
|
||||
.setInputFiles(APM_METRICS_TESTDATA_PATH);
|
||||
await dialog.getByRole('button', { name: 'Import and Next' }).click();
|
||||
const res = await postResponse;
|
||||
|
||||
expect(res.status()).toBeGreaterThanOrEqual(200);
|
||||
expect(res.status()).toBeLessThan(300);
|
||||
|
||||
await page.waitForURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
|
||||
// Register for cleanup.
|
||||
const urlMatch = page.url().match(/\/dashboard\/([0-9a-f-]+)/);
|
||||
expect(urlMatch, 'URL must contain dashboard ID').not.toBeNull();
|
||||
seedIds.add(urlMatch![1]);
|
||||
|
||||
await expect(page.getByTestId('dashboard-title')).toHaveText(
|
||||
APM_METRICS_TITLE,
|
||||
);
|
||||
|
||||
// Server-side check: every widget + tag from the fixture must be persisted.
|
||||
// A partial import (e.g. silently dropped widgets) would pass the UI title
|
||||
// check but fail here. The apm-metrics fixture has 16 widgets and 4 tags.
|
||||
const persisted = await fetchDashboardData(page, urlMatch![1]);
|
||||
expect(persisted.widgets?.length).toBe(16);
|
||||
expect(persisted.tags).toEqual(
|
||||
expect.arrayContaining(['apm', 'latency', 'error rate', 'throughput']),
|
||||
);
|
||||
|
||||
// Navigate back and confirm the imported dashboard surfaces in the list
|
||||
// with at least one tag chip.
|
||||
await gotoDashboardsList(page);
|
||||
await page.getByPlaceholder(SEARCH_PLACEHOLDER).fill(APM_METRICS_TITLE);
|
||||
await expect(page.getByText(APM_METRICS_TITLE).first()).toBeVisible();
|
||||
// The apm-metrics fixture has tags ['apm', 'latency', 'error rate', 'throughput'].
|
||||
await expect(page.getByText('apm').first()).toBeVisible();
|
||||
});
|
||||
|
||||
// The Monaco paste path is intentionally not covered — the file-upload
|
||||
// path (TC-19) exercises the same populate-editor-then-import code path.
|
||||
// Keyboard-typing large JSON into Monaco is unreliable in headless CI.
|
||||
|
||||
test('TC-20 invalid JSON via file upload shows "Invalid JSON" error', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDashboardsList(page);
|
||||
await page.getByTestId('new-dashboard-cta').click();
|
||||
await page.getByTestId('import-json-menu-cta').click();
|
||||
|
||||
const dialog = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Import Dashboard JSON' });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Track POST attempts: invalid JSON must never reach the create endpoint.
|
||||
let postFired = false;
|
||||
await page.route(/\/api\/v1\/dashboards(\?|$)/, (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
postFired = true;
|
||||
}
|
||||
void route.continue();
|
||||
});
|
||||
|
||||
await dialog.locator('input[type="file"]').setInputFiles({
|
||||
name: 'bad.json',
|
||||
mimeType: 'application/json',
|
||||
buffer: Buffer.from('not valid json {'),
|
||||
});
|
||||
|
||||
await expect(dialog.getByText('Invalid JSON')).toBeVisible();
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Clicking "Import and Next" with invalid content should surface an error
|
||||
// and keep the dialog open.
|
||||
await dialog.getByRole('button', { name: 'Import and Next' }).click();
|
||||
await expect(page).not.toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
expect(postFired, 'invalid JSON must not trigger POST').toBe(false);
|
||||
});
|
||||
|
||||
test('TC-21 import with empty editor clicking Import and Next shows error', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDashboardsList(page);
|
||||
await page.getByTestId('new-dashboard-cta').click();
|
||||
await page.getByTestId('import-json-menu-cta').click();
|
||||
|
||||
const dialog = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Import Dashboard JSON' });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
let postFired = false;
|
||||
await page.route(/\/api\/v1\/dashboards(\?|$)/, (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
postFired = true;
|
||||
}
|
||||
void route.continue();
|
||||
});
|
||||
|
||||
await dialog.getByRole('button', { name: 'Import and Next' }).click();
|
||||
|
||||
await expect(dialog.getByText('Error loading JSON file')).toBeVisible();
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(page).not.toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
expect(postFired, 'empty editor must not trigger POST').toBe(false);
|
||||
});
|
||||
|
||||
// ─── Deleting dashboards ─────────────────────────────────────────────────
|
||||
//
|
||||
// Known behaviour: clicking Cancel in the confirmation dialog navigates to
|
||||
// the dashboard detail page rather than staying on the list.
|
||||
|
||||
test('TC-22 delete confirmation dialog shows dashboard name with Cancel and Delete buttons', async ({
|
||||
test('TC-19 delete confirmation dialog shows dashboard name with Cancel and Delete buttons', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-delete-confirm';
|
||||
@@ -591,7 +456,7 @@ test.describe('Dashboards List Page', () => {
|
||||
await expect(dialog.getByRole('button', { name: 'Delete' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-23 cancelling delete navigates to the dashboard detail page (known behaviour)', async ({
|
||||
test('TC-20 cancelling delete navigates to the dashboard detail page (known behaviour)', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-delete-cancel';
|
||||
@@ -606,7 +471,7 @@ test.describe('Dashboards List Page', () => {
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
});
|
||||
|
||||
test('TC-24 confirming delete removes the dashboard from the list', async ({
|
||||
test('TC-21 confirming delete removes the dashboard from the list', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-delete-confirmed';
|
||||
@@ -641,7 +506,7 @@ test.describe('Dashboards List Page', () => {
|
||||
|
||||
// ─── Row click navigation ────────────────────────────────────────────────
|
||||
|
||||
test('TC-25 clicking a dashboard row navigates to the detail page', async ({
|
||||
test('TC-22 clicking a dashboard row navigates to the detail page', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-row-click';
|
||||
@@ -655,7 +520,7 @@ test.describe('Dashboards List Page', () => {
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
});
|
||||
|
||||
test('TC-26 sidebar Dashboards link navigates to the list page', async ({
|
||||
test('TC-23 sidebar Dashboards link navigates to the list page', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto('/home');
|
||||
@@ -673,7 +538,7 @@ test.describe('Dashboards List Page', () => {
|
||||
|
||||
// ─── URL state and deep linking ──────────────────────────────────────────
|
||||
|
||||
test('TC-27 browser Back after navigating to a dashboard restores search state', async ({
|
||||
test('TC-24 browser Back after navigating to a dashboard restores search state', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-back-search';
|
||||
@@ -692,7 +557,7 @@ test.describe('Dashboards List Page', () => {
|
||||
await expect(page.getByPlaceholder(SEARCH_PLACEHOLDER)).toHaveValue(name);
|
||||
});
|
||||
|
||||
test('TC-28 direct navigation with sort params honours them on load', async ({
|
||||
test('TC-25 direct navigation with sort params honours them on load', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto('/dashboard?columnKey=updatedAt&order=descend');
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
changePanelType,
|
||||
configureAndSavePanel,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
fetchDashboardData,
|
||||
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' });
|
||||
}
|
||||
|
||||
/** Fetch the persisted fixture dashboard's first widget. */
|
||||
async function fetchFixtureWidget(page: Page) {
|
||||
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
|
||||
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
|
||||
const dashboard = await fetchDashboardData(page, id!);
|
||||
const widget = dashboard.widgets?.[0];
|
||||
expect(widget, 'fixture dashboard must have at least one widget').toBeTruthy();
|
||||
return widget!;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// Server-side check.
|
||||
expect((await fetchFixtureWidget(page)).title).toBe('list-controls-renamed');
|
||||
|
||||
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();
|
||||
|
||||
expect((await fetchFixtureWidget(page)).description).toBe('E2E list description');
|
||||
|
||||
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();
|
||||
|
||||
// Server-side: panelTypes is 'table'.
|
||||
expect((await fetchFixtureWidget(page)).panelTypes).toBe('table');
|
||||
|
||||
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();
|
||||
|
||||
// Settle before asserting no PUT.
|
||||
await page.waitForLoadState('networkidle');
|
||||
expect(putFired).toBe(false);
|
||||
|
||||
// Server-side double-check: persisted title is still the fixture name.
|
||||
expect((await fetchFixtureWidget(page)).title).toBe(FIXTURE_PANEL_TITLE);
|
||||
});
|
||||
|
||||
// ─── Reload persistence ──────────────────────────────────────────────────
|
||||
|
||||
test('TC-06 panel state survives a hard dashboard reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// Save description + a non-default panel type, then hard-reload and
|
||||
// re-verify the panel card rehydrates with the right state.
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await page
|
||||
.getByTestId('panel-description-input')
|
||||
.fill('reload persistence description');
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
await page.reload();
|
||||
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
|
||||
|
||||
// Description info icon must render after rehydration.
|
||||
await expect(
|
||||
page
|
||||
.locator('.widget-header-container')
|
||||
.filter({ hasText: FIXTURE_PANEL_TITLE })
|
||||
.locator('.info-tooltip')
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Server-side check post-reload — confirms the load path read the same JSON.
|
||||
const persisted = await fetchFixtureWidget(page);
|
||||
expect(persisted.description).toBe('reload persistence description');
|
||||
expect(persisted.panelTypes).toBe('list');
|
||||
|
||||
// Reset.
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await page.getByTestId('panel-description-input').fill('');
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
});
|
||||
@@ -1,588 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
changePanelType,
|
||||
configureAndSavePanel,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
fetchDashboardData,
|
||||
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' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the persisted fixture dashboard JSON and return the first widget.
|
||||
* Use this after a save to confirm the PUT actually landed the expected
|
||||
* shape on the backend — UI-only round-trips pass on optimistic-update bugs.
|
||||
*/
|
||||
async function fetchFixtureWidget(page: Page) {
|
||||
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
|
||||
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
|
||||
const dashboard = await fetchDashboardData(page, id!);
|
||||
const widget = dashboard.widgets?.[0];
|
||||
expect(widget, 'fixture dashboard must have at least one widget').toBeTruthy();
|
||||
return widget!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 row = page.getByTestId(/^column-unit-row-/).first();
|
||||
await row.click();
|
||||
// `showSearch` is enabled; the visible text input is rendered by Ant
|
||||
// inside the row but outside its testid wrapper post-focus, so reach in
|
||||
// via the remaining ant-select internals on the focused row.
|
||||
await row.locator('.ant-select input').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();
|
||||
|
||||
// Server-side check — the PUT must carry the new title.
|
||||
expect((await fetchFixtureWidget(page)).title).toBe('table-controls-renamed');
|
||||
|
||||
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();
|
||||
|
||||
expect((await fetchFixtureWidget(page)).description).toBe(
|
||||
'E2E table description',
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
// Server-side: persisted timePreferance enum, not just visible label.
|
||||
expect((await fetchFixtureWidget(page)).timePreferance).toBe('LAST_15_MIN');
|
||||
|
||||
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)');
|
||||
|
||||
// Wait for the dropdown selection to settle in the editor state
|
||||
// before saving — otherwise the PUT can race the React state update.
|
||||
await expect(
|
||||
page.getByTestId(/^column-unit-row-/).first(),
|
||||
).toContainText('Milliseconds');
|
||||
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
// Render-side: the cell text should carry the `ms` suffix, confirming
|
||||
// the unit selection reached the formatter. Asserting the *shape* of
|
||||
// the number (digit pattern) belongs to the formatter's unit tests.
|
||||
const cell = await getFirstDataCell(page);
|
||||
await expect(cell).toContainText('ms');
|
||||
|
||||
// Server-side: columnUnits must record the unit code, not just the
|
||||
// label. UI display can use a fancy label while the persisted enum drifts.
|
||||
const persistedAfterUnit = await fetchFixtureWidget(page);
|
||||
const columnUnitValues = Object.values(persistedAfterUnit.columnUnits ?? {});
|
||||
expect(columnUnitValues, 'columnUnits must include the chosen unit').toContain(
|
||||
'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.getByTestId(/^column-unit-row-/).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();
|
||||
|
||||
// Wait for both selections to settle in the editor before saving.
|
||||
await expect(page.getByTestId('decimal-precision-selector')).toContainText(
|
||||
'0 decimals',
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId(/^column-unit-row-/).first(),
|
||||
).toContainText('Seconds');
|
||||
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
// Render-side: the cell should carry the `s` unit suffix. The exact
|
||||
// numeric formatting (integer vs decimal) is covered by the
|
||||
// formatter's unit tests — keep e2e on the persistence contract.
|
||||
const cell = await getFirstDataCell(page);
|
||||
await expect(cell).toContainText('s');
|
||||
|
||||
// Server-side: decimalPrecision must be 0 in the persisted widget.
|
||||
expect((await fetchFixtureWidget(page)).decimalPrecision).toBe(0);
|
||||
|
||||
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);
|
||||
|
||||
// Inspect the threshold-styled cell directly. The testid host carries
|
||||
// `data-threshold-format="Background"` so we can confirm the format too.
|
||||
const row = page.locator('tr.ant-table-row').first();
|
||||
await row.waitFor({ state: 'visible' });
|
||||
const styledCell = row.getByTestId('threshold-styled-cell').first();
|
||||
await expect(styledCell).toBeVisible();
|
||||
await expect(styledCell).toHaveAttribute('data-threshold-format', 'Background');
|
||||
const dataStyle = (await styledCell.getAttribute('style')) ?? '';
|
||||
expect(dataStyle).toMatch(/background-color:/);
|
||||
|
||||
// Server-side: thresholds[] must be persisted with format=Background.
|
||||
const persistedThresholds = (await fetchFixtureWidget(page)).thresholds ?? [];
|
||||
expect(persistedThresholds.length).toBe(1);
|
||||
expect(persistedThresholds[0].thresholdFormat).toBe('Background');
|
||||
expect(persistedThresholds[0].thresholdOperator).toBe('>=');
|
||||
|
||||
// Reset — delete the threshold via its testid.
|
||||
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();
|
||||
await firstCard.getByTestId('threshold-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);
|
||||
|
||||
const row = page.locator('tr.ant-table-row').first();
|
||||
await row.waitFor({ state: 'visible' });
|
||||
const styledCell = row.getByTestId('threshold-styled-cell').first();
|
||||
await expect(styledCell).toBeVisible();
|
||||
await expect(styledCell).toHaveAttribute('data-threshold-format', 'Text');
|
||||
const dataStyle = (await styledCell.getAttribute('style')) ?? '';
|
||||
expect(dataStyle).toMatch(/color:/);
|
||||
expect(dataStyle).not.toMatch(/background-color:/);
|
||||
|
||||
// Server-side: thresholds[] must be persisted with format=Text.
|
||||
const persistedThresholds = (await fetchFixtureWidget(page)).thresholds ?? [];
|
||||
expect(persistedThresholds.length).toBe(1);
|
||||
expect(persistedThresholds[0].thresholdFormat).toBe('Text');
|
||||
|
||||
// Reset
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await expandSection(page, 'Thresholds');
|
||||
const firstCard = page.locator('.threshold-card-container').first();
|
||||
await firstCard.hover();
|
||||
await firstCard.getByTestId('threshold-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();
|
||||
|
||||
// Server-side: persisted panelTypes is the PANEL_TYPES enum value 'value'.
|
||||
expect((await fetchFixtureWidget(page)).panelTypes).toBe('value');
|
||||
|
||||
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();
|
||||
|
||||
// Settle before asserting — a delayed PUT could otherwise sneak past.
|
||||
await page.waitForLoadState('networkidle');
|
||||
expect(putFired).toBe(false);
|
||||
|
||||
// Server-side double-check: persisted title is still the fixture name.
|
||||
expect((await fetchFixtureWidget(page)).title).toBe(FIXTURE_PANEL_TITLE);
|
||||
});
|
||||
|
||||
// ─── Reload persistence ──────────────────────────────────────────────────
|
||||
|
||||
test('TC-11 panel state survives a hard dashboard reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// Apply a combination of edits, save, then hard-reload the page and
|
||||
// re-verify everything renders from the persisted JSON. Catches backend
|
||||
// → frontend rehydration regressions that round-trips via close+reopen
|
||||
// editor miss (re-opening the editor reuses the in-memory query state).
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await page
|
||||
.getByTestId('panel-description-input')
|
||||
.fill('reload persistence description');
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
await selectColumnUnit(page, 'Milliseconds', 'Milliseconds (ms)');
|
||||
// Wait for the column-unit dropdown to settle before saving so the
|
||||
// PUT carries the new unit (Ant Select onChange is async via React).
|
||||
await expect(
|
||||
page.getByTestId(/^column-unit-row-/).first(),
|
||||
).toContainText('Milliseconds');
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
// Hard reload — purges in-memory state, forces a fresh fetch.
|
||||
await page.reload();
|
||||
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
|
||||
|
||||
// Cell value must still carry the unit after reload (proves the
|
||||
// columnUnits + decimalPrecision + panelType rehydrated correctly).
|
||||
// Numeric-shape validation lives in the formatter's unit tests.
|
||||
const cell = await getFirstDataCell(page);
|
||||
await expect(cell).toContainText('ms');
|
||||
|
||||
// Description info icon (the only header surface for description) must
|
||||
// still render after rehydration.
|
||||
await expect(
|
||||
page
|
||||
.locator('.widget-header-container')
|
||||
.filter({ hasText: FIXTURE_PANEL_TITLE })
|
||||
.locator('.info-tooltip')
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Reset: clear unit + description.
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await page.getByTestId('panel-description-input').fill('');
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,589 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
changePanelType,
|
||||
configureAndSavePanel,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
fetchDashboardData,
|
||||
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' });
|
||||
}
|
||||
|
||||
/** Fetch the persisted fixture dashboard's first widget. */
|
||||
async function fetchFixtureWidget(page: Page) {
|
||||
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
|
||||
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
|
||||
const dashboard = await fetchDashboardData(page, id!);
|
||||
const widget = dashboard.widgets?.[0];
|
||||
expect(widget, 'fixture dashboard must have at least one widget').toBeTruthy();
|
||||
return widget!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
// Server-side check.
|
||||
expect((await fetchFixtureWidget(page)).title).toBe('value-controls-renamed');
|
||||
|
||||
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();
|
||||
|
||||
expect((await fetchFixtureWidget(page)).description).toBe(
|
||||
'E2E test description',
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
// Server-side: persisted timePreferance enum.
|
||||
expect((await fetchFixtureWidget(page)).timePreferance).toBe('LAST_15_MIN');
|
||||
|
||||
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();
|
||||
|
||||
// Server-side: yAxisUnit must hold the unit code (catches a label-only
|
||||
// regression where the UI shows "Seconds" but persists nothing).
|
||||
expect((await fetchFixtureWidget(page)).yAxisUnit).toBe('s');
|
||||
|
||||
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();
|
||||
|
||||
// Wait for the dropdown to reflect the chosen precision before saving
|
||||
// so the editor's state has actually applied the change.
|
||||
await expect(page.getByTestId('decimal-precision-selector')).toContainText(
|
||||
'0 decimals',
|
||||
);
|
||||
|
||||
// Reformatting check: with decimalPrecision=0 the rendered value must
|
||||
// not contain a decimal separator. The exact numeric value depends on
|
||||
// query/seed alignment and lives in the formatter's unit tests; this
|
||||
// asserts only that the precision setting reaches the render layer.
|
||||
const valueText = page.getByTestId('value-graph-text').first();
|
||||
await expect(valueText).toBeVisible();
|
||||
await expect(valueText).not.toContainText('.');
|
||||
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
// Same assertion post-save: the dashboard render must respect the
|
||||
// persisted precision, not just the editor's live preview.
|
||||
await expect(page.getByTestId('value-graph-text').first()).not.toContainText(
|
||||
'.',
|
||||
);
|
||||
|
||||
// Server-side: decimalPrecision is 0 and yAxisUnit is 's'.
|
||||
const persistedDecimals = await fetchFixtureWidget(page);
|
||||
expect(persistedDecimals.decimalPrecision).toBe(0);
|
||||
expect(persistedDecimals.yAxisUnit).toBe('s');
|
||||
|
||||
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:/);
|
||||
|
||||
// Server-side: thresholds[] persisted with format=Text.
|
||||
const persistedThresholds = (await fetchFixtureWidget(page)).thresholds ?? [];
|
||||
expect(persistedThresholds.length).toBe(1);
|
||||
expect(persistedThresholds[0].thresholdFormat).toBe('Text');
|
||||
expect(persistedThresholds[0].thresholdOperator).toBe('>=');
|
||||
|
||||
// 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 via testid.
|
||||
const firstCard = page.locator('.threshold-card-container').first();
|
||||
await firstCard.hover();
|
||||
await firstCard.getByTestId('threshold-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 must carry an inline background.
|
||||
const container = page.getByTestId('value-graph-container').first();
|
||||
await expect(container).toBeVisible();
|
||||
const inlineStyle = await container.getAttribute('style');
|
||||
expect(inlineStyle).toMatch(/background-color:/);
|
||||
|
||||
// Server-side: thresholds[] persisted with format=Background.
|
||||
const persistedThresholds = (await fetchFixtureWidget(page)).thresholds ?? [];
|
||||
expect(persistedThresholds.length).toBe(1);
|
||||
expect(persistedThresholds[0].thresholdFormat).toBe('Background');
|
||||
|
||||
// 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();
|
||||
await firstCard.getByTestId('threshold-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);
|
||||
|
||||
// Server-side: yAxisUnit must be cleared (empty / undefined).
|
||||
const cleared = await fetchFixtureWidget(page);
|
||||
expect(cleared.yAxisUnit ?? '').toBe('');
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
// Server-side: panelTypes is 'graph' (PANEL_TYPES.TIME_SERIES).
|
||||
expect((await fetchFixtureWidget(page)).panelTypes).toBe('graph');
|
||||
|
||||
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();
|
||||
|
||||
// Settle before asserting no PUT.
|
||||
await page.waitForLoadState('networkidle');
|
||||
expect(putFired).toBe(false);
|
||||
|
||||
// Server-side double-check: persisted title is still the fixture name.
|
||||
expect((await fetchFixtureWidget(page)).title).toBe(FIXTURE_PANEL_TITLE);
|
||||
});
|
||||
|
||||
// ─── Reload persistence ──────────────────────────────────────────────────
|
||||
|
||||
test('TC-12 panel state survives a hard dashboard reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// Apply unit + description + decimal precision, save, hard-reload, and
|
||||
// re-verify the panel renders correctly from the persisted JSON.
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await page
|
||||
.getByTestId('panel-description-input')
|
||||
.fill('reload persistence description');
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
await selectYAxisUnit(page, 'Seconds', 'Seconds (s)');
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
await page.reload();
|
||||
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
|
||||
|
||||
// Suffix unit must render after rehydration.
|
||||
await expect(page.getByTestId('value-graph-suffix-unit').first()).toBeVisible();
|
||||
|
||||
// Description info icon must render after rehydration.
|
||||
await expect(
|
||||
page
|
||||
.locator('.widget-header-container')
|
||||
.filter({ hasText: FIXTURE_PANEL_TITLE })
|
||||
.locator('.info-tooltip')
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Reset.
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await page.getByTestId('panel-description-input').fill('');
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user