mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-05 20:50:45 +01:00
Compare commits
1 Commits
test/dashb
...
test/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35888d0006 |
225
tests/e2e/tests/dashboards/v2/editor/03-creation.spec.ts
Normal file
225
tests/e2e/tests/dashboards/v2/editor/03-creation.spec.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { expect, test } from '../../../../fixtures/dashboards';
|
||||
import { PanelKind } from '../../../../helpers/dashboard-v2-spec';
|
||||
import {
|
||||
createDashboardV2ViaApi,
|
||||
getDashboardV2ViaApi,
|
||||
gotoEmptyDashboardV2,
|
||||
} from '../../../../helpers/dashboards-v2';
|
||||
import {
|
||||
capturePatchOps,
|
||||
editor,
|
||||
savePanel,
|
||||
} from '../../../../helpers/panel-editor-v2';
|
||||
import { panelRoot } from '../../../../helpers/panels-v2';
|
||||
import {
|
||||
emptyDashboard,
|
||||
singlePanelDashboard,
|
||||
compactDashboard,
|
||||
} from '../../../../testdata/v2/panels-dashboard';
|
||||
|
||||
// Scope: creating a panel — the modal's two branches, the route it hands off
|
||||
// to, and the JSON-Patch a save emits.
|
||||
//
|
||||
// The subtlety: one section means a tile click creates immediately; several
|
||||
// means select-then-confirm. Backwards, and the user is stranded on a dialog.
|
||||
|
||||
const ALL_TILES: [PanelKind, string][] = [
|
||||
[PanelKind.TimeSeries, 'Time Series'],
|
||||
[PanelKind.Number, 'Number'],
|
||||
[PanelKind.Table, 'Table'],
|
||||
[PanelKind.BarChart, 'Bar Chart'],
|
||||
[PanelKind.PieChart, 'Pie Chart'],
|
||||
[PanelKind.Histogram, 'Histogram'],
|
||||
[PanelKind.List, 'List'],
|
||||
];
|
||||
|
||||
test.describe('Dashboards V2 — panel creation', () => {
|
||||
test('TC-01 the New Panel modal lists every panel kind', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
const id = await dashboards.seed(emptyDashboard());
|
||||
await gotoEmptyDashboardV2(page, id);
|
||||
|
||||
await page.getByTestId('add-panel').click();
|
||||
const dialog = page.getByRole('dialog', { name: 'New Panel' });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
for (const [kind, label] of ALL_TILES) {
|
||||
const tile = page.getByTestId(`panel-type-${kind}`);
|
||||
await expect(tile).toBeVisible();
|
||||
await expect(tile).toContainText(label);
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-02 with one section a tile click creates immediately', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
|
||||
await page.getByTestId('add-panel-header').click();
|
||||
await expect(page.getByRole('dialog', { name: 'New Panel' })).toBeVisible();
|
||||
|
||||
// One section: no footer, no picker, no confirm.
|
||||
await expect(page.getByTestId('panel-type-confirm')).toHaveCount(0);
|
||||
|
||||
await page.getByTestId(`panel-type-${PanelKind.Table}`).click();
|
||||
await page.waitForURL(/\/panel\/new\?/);
|
||||
|
||||
const params = new URL(page.url()).searchParams;
|
||||
expect(params.get('panelKind')).toBe(PanelKind.Table);
|
||||
await expect(editor.root(page)).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-03 with several sections the modal requires an explicit confirm', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
await page.getByTestId('add-panel-header').click();
|
||||
await expect(page.getByRole('dialog', { name: 'New Panel' })).toBeVisible();
|
||||
|
||||
const confirm = page.getByTestId('panel-type-confirm');
|
||||
await expect(confirm).toBeVisible();
|
||||
|
||||
await expect(confirm).toBeDisabled();
|
||||
await expect(page.getByTestId('panel-section-select')).toBeVisible();
|
||||
|
||||
await page.getByTestId(`panel-type-${PanelKind.Number}`).click();
|
||||
await expect(confirm).toBeEnabled();
|
||||
await confirm.click();
|
||||
|
||||
await page.waitForURL(/\/panel\/new\?/);
|
||||
expect(new URL(page.url()).searchParams.get('panelKind')).toBe(
|
||||
PanelKind.Number,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-04 the chosen section becomes the new panel layoutIndex', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
await page.getByTestId('add-panel-header').click();
|
||||
await page.getByTestId(`panel-type-${PanelKind.Number}`).click();
|
||||
await page.getByTestId('panel-section-select').click();
|
||||
await page.getByTestId('panel-section-option-1').click();
|
||||
await page.getByTestId('panel-type-confirm').click();
|
||||
|
||||
await page.waitForURL(/\/panel\/new\?/);
|
||||
expect(new URL(page.url()).searchParams.get('layoutIndex')).toBe('1');
|
||||
});
|
||||
|
||||
test('TC-05 creating from an empty section targets that section', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
const dashboard = compactDashboard();
|
||||
|
||||
dashboard.spec.layouts.push({
|
||||
kind: 'Grid',
|
||||
spec: { display: { title: 'Empty' }, items: [] },
|
||||
});
|
||||
await dashboards.seedAndOpen(dashboard);
|
||||
|
||||
const sectionCta = page.locator('[data-testid^="section-add-panel-"]');
|
||||
await sectionCta.first().scrollIntoViewIfNeeded();
|
||||
await sectionCta.first().click();
|
||||
|
||||
await expect(page.getByRole('dialog', { name: 'New Panel' })).toBeVisible();
|
||||
await page.getByTestId(`panel-type-${PanelKind.TimeSeries}`).click();
|
||||
await page.getByTestId('panel-type-confirm').click();
|
||||
await page.waitForURL(/\/panel\/new\?/);
|
||||
expect(new URL(page.url()).searchParams.get('layoutIndex')).toBe('2');
|
||||
});
|
||||
|
||||
test('TC-06 saving a new panel emits add-panel and add-layout-item ops', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
|
||||
const patches = capturePatchOps(page);
|
||||
|
||||
await page.getByTestId('add-panel-header').click();
|
||||
// List: its seeded query passes validation (see TC-07).
|
||||
await page.getByTestId(`panel-type-${PanelKind.List}`).click();
|
||||
await expect(editor.root(page)).toBeVisible();
|
||||
|
||||
await editor.title(page).fill('Created from the modal');
|
||||
await savePanel(page);
|
||||
|
||||
// Targeted adds for panel AND grid item — a replace would clobber edits.
|
||||
expect(patches.length).toBeGreaterThan(0);
|
||||
const ops = patches[patches.length - 1];
|
||||
const panelAdd = ops.find((op) => /^\/spec\/panels\/[^/]+$/.test(op.path));
|
||||
expect(panelAdd?.op).toBe('add');
|
||||
expect(
|
||||
ops.some((op) => /^\/spec\/layouts\/\d+\/spec\/items\/-$/.test(op.path)),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('TC-07 the created panel lands on the dashboard and persists', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
|
||||
const before = await getDashboardV2ViaApi(page, id);
|
||||
const beforeCount = Object.keys(before.spec.panels).length;
|
||||
|
||||
await page.getByTestId('add-panel-header').click();
|
||||
// List is the one kind whose seeded query saves as-is (logs `count()`);
|
||||
// metrics kinds are rejected until a metric is chosen — see TC-09.
|
||||
await page.getByTestId(`panel-type-${PanelKind.List}`).click();
|
||||
await expect(editor.root(page)).toBeVisible();
|
||||
await editor.title(page).fill('Fresh list panel');
|
||||
await savePanel(page);
|
||||
|
||||
await page.waitForURL(new RegExp(`/dashboard/${id}(\\?|$)`));
|
||||
await expect(
|
||||
page.getByTestId('panel-title').filter({ hasText: 'Fresh list panel' }),
|
||||
).toBeVisible();
|
||||
|
||||
const after = await getDashboardV2ViaApi(page, id);
|
||||
expect(Object.keys(after.spec.panels)).toHaveLength(beforeCount + 1);
|
||||
});
|
||||
|
||||
test('TC-09 saving a metrics panel with no metric chosen is rejected', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
|
||||
await page.getByTestId('add-panel-header').click();
|
||||
await page.getByTestId(`panel-type-${PanelKind.Number}`).click();
|
||||
await expect(editor.root(page)).toBeVisible();
|
||||
|
||||
// Metrics kinds seed an empty aggregation, so the editor must stay open and
|
||||
// surface the reason rather than dropping the panel.
|
||||
const rejected = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'PATCH' && /\/api\/v2\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await editor.save(page).click();
|
||||
const response = await rejected;
|
||||
expect(response.status()).toBe(400);
|
||||
expect(await response.text()).toContain('metric name is required');
|
||||
|
||||
await expect(editor.root(page)).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-08 the editor route redirects when panelKind is missing', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await createDashboardV2ViaApi(page, singlePanelDashboard());
|
||||
|
||||
// No kind to seed, so the page bounces back.
|
||||
await page.goto(`/dashboard/${id}/panel/new`);
|
||||
await page.waitForURL(new RegExp(`/dashboard/${id}(\\?|$)`));
|
||||
await expect(panelRoot(page, 'solo-panel')).toBeVisible();
|
||||
});
|
||||
});
|
||||
201
tests/e2e/tests/dashboards/v2/editor/12-shell.spec.ts
Normal file
201
tests/e2e/tests/dashboards/v2/editor/12-shell.spec.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { expect, test } from '../../../../fixtures/dashboards';
|
||||
import {
|
||||
getDashboardV2ViaApi,
|
||||
gotoPanelEditor,
|
||||
setDashboardLockedViaApi,
|
||||
} from '../../../../helpers/dashboards-v2';
|
||||
import {
|
||||
EditorText,
|
||||
capturePatchOps,
|
||||
closeEditor,
|
||||
editor,
|
||||
savePanel,
|
||||
} from '../../../../helpers/panel-editor-v2';
|
||||
import {
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
} from '../../../../testdata/v2/panels-dashboard';
|
||||
|
||||
// Scope: the editor shell — editing, dirty badge, save, discard guard, locking.
|
||||
//
|
||||
// Two counter-intuitive behaviours are pinned so a "cleanup" doesn't change
|
||||
// them: Save is NOT gated on dirty state (TC-03), and only the in-app close
|
||||
// button guards unsaved edits — there is no beforeunload blocker (TC-07).
|
||||
|
||||
test.describe('Dashboards V2 — panel editor shell', () => {
|
||||
test('TC-01 the editor opens on the panel with its saved title', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
|
||||
await expect(page.getByText(EditorText.title)).toBeVisible();
|
||||
await expect(editor.title(page)).toHaveValue('Solo panel');
|
||||
await expect(page.getByTestId('preview-pane')).toBeVisible();
|
||||
await expect(editor.queryBuilder(page)).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 editing the title marks the editor dirty', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
|
||||
await expect(editor.unsavedBadge(page)).toHaveCount(0);
|
||||
await editor.title(page).fill('Renamed panel');
|
||||
await expect(editor.unsavedBadge(page)).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-03 Save stays enabled on a pristine panel', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
|
||||
// Gated on editability, not isDirty.
|
||||
await expect(editor.unsavedBadge(page)).toHaveCount(0);
|
||||
await expect(editor.save(page)).toBeEnabled();
|
||||
});
|
||||
|
||||
test('TC-04 saving persists the title and returns to the dashboard', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
const patches = capturePatchOps(page);
|
||||
|
||||
await editor.title(page).fill('Renamed via editor');
|
||||
await editor.description(page).fill('Edited in the E2E suite');
|
||||
await savePanel(page);
|
||||
|
||||
await page.waitForURL(new RegExp(`/dashboard/${id}(\\?|$)`));
|
||||
|
||||
const after = await getDashboardV2ViaApi(page, id);
|
||||
expect(after.spec.panels[SINGLE_PANEL_ID].spec.display.name).toBe(
|
||||
'Renamed via editor',
|
||||
);
|
||||
|
||||
// A single targeted add at the panel's spec pointer.
|
||||
const ops = patches[patches.length - 1];
|
||||
expect(ops).toHaveLength(1);
|
||||
expect(ops[0].path).toBe(`/spec/panels/${SINGLE_PANEL_ID}/spec`);
|
||||
expect(ops[0].op).toBe('add');
|
||||
});
|
||||
|
||||
test('TC-05 closing a pristine editor leaves immediately', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await closeEditor(page);
|
||||
await page.waitForURL(new RegExp(`/dashboard/${id}(\\?|$)`));
|
||||
await expect(page.getByTestId('panel-editor-v2-discard-modal')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-06 closing a dirty editor asks before discarding', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await editor.title(page).fill('Throwaway edit');
|
||||
await expect(editor.unsavedBadge(page)).toBeVisible();
|
||||
|
||||
// Keep editing leaves the edit intact.
|
||||
await closeEditor(page, { expectDirty: true, keepEditing: true });
|
||||
await expect(editor.root(page)).toBeVisible();
|
||||
await expect(editor.title(page)).toHaveValue('Throwaway edit');
|
||||
|
||||
await closeEditor(page, { expectDirty: true });
|
||||
await page.waitForURL(new RegExp(`/dashboard/${id}(\\?|$)`));
|
||||
|
||||
const after = await getDashboardV2ViaApi(page, id);
|
||||
expect(after.spec.panels[SINGLE_PANEL_ID].spec.display.name).toBe(
|
||||
'Solo panel',
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-07 navigating away by URL loses edits without a prompt', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
await editor.title(page).fill('Never saved');
|
||||
|
||||
// Known behaviour, not endorsed: the guard is on the close button only. If
|
||||
// a router blocker is added, update this test rather than deleting it.
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await expect(page.getByTestId('panel-editor-v2-discard-modal')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
const after = await getDashboardV2ViaApi(page, id);
|
||||
expect(after.spec.panels[SINGLE_PANEL_ID].spec.display.name).toBe(
|
||||
'Solo panel',
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-08 a locked dashboard disables Save and never PATCHes', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
const id = await dashboards.seed(singlePanelDashboard());
|
||||
await setDashboardLockedViaApi(page, id, true);
|
||||
|
||||
const patches = capturePatchOps(page);
|
||||
await gotoPanelEditor(page, id, SINGLE_PANEL_ID);
|
||||
|
||||
const save = editor.save(page);
|
||||
await expect(save).toBeDisabled();
|
||||
|
||||
// A disabled button swallows pointer events; hover the wrapping trigger.
|
||||
await page
|
||||
.locator('[data-slot="tooltip-trigger"]')
|
||||
.filter({ has: save })
|
||||
.hover();
|
||||
await expect(page.getByText(EditorText.lockedReason)).toBeVisible();
|
||||
|
||||
// The store short-circuits a locked patch before the network.
|
||||
expect(patches).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('TC-09 an unknown panel id redirects back to the dashboard', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
const id = await dashboards.seed(singlePanelDashboard());
|
||||
|
||||
await page.goto(`/dashboard/${id}/panel/does-not-exist`);
|
||||
await page.waitForURL(new RegExp(`/dashboard/${id}(\\?|$)`));
|
||||
await expect(editor.root(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-10 Switch to View Mode hands off to the View modal', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await editor.switchToView(page).click();
|
||||
await page.waitForURL(new RegExp(`/dashboard/${id}\\?`));
|
||||
await expect(page.getByTestId('view-panel-modal-content')).toBeVisible();
|
||||
});
|
||||
});
|
||||
182
tests/e2e/tests/dashboards/v2/editor/21-query.spec.ts
Normal file
182
tests/e2e/tests/dashboards/v2/editor/21-query.spec.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { expect, test } from '../../../../fixtures/dashboards';
|
||||
import {
|
||||
GOLDEN,
|
||||
PanelKind,
|
||||
logsCountQuery,
|
||||
} from '../../../../helpers/dashboard-v2-spec';
|
||||
import { getDashboardV2ViaApi } from '../../../../helpers/dashboards-v2';
|
||||
import {
|
||||
EditorText,
|
||||
QueryTab,
|
||||
editor,
|
||||
queryTab,
|
||||
runQuery,
|
||||
savePanel,
|
||||
selectMetric,
|
||||
} from '../../../../helpers/panel-editor-v2';
|
||||
import {
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
} from '../../../../testdata/v2/panels-dashboard';
|
||||
|
||||
// Scope: the query pane and its commit semantics.
|
||||
//
|
||||
// The rule worth pinning: editing the builder does NOT move the preview (only
|
||||
// Run, or a structural change, commits into the draft) — but Save serialises
|
||||
// the LIVE query anyway, so an unrun edit still persists. Contradictory-looking
|
||||
// and easy to "fix" into a regression.
|
||||
|
||||
test.describe('Dashboards V2 — panel editor query pane', () => {
|
||||
test('TC-01 the builder offers every query type the kind supports', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
|
||||
await expect(queryTab(page, QueryTab.builder)).toBeVisible();
|
||||
await expect(queryTab(page, QueryTab.clickhouse)).toBeVisible();
|
||||
await expect(queryTab(page, QueryTab.promql)).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 a List panel offers only the Query Builder tab', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ kind: PanelKind.List }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
// Hidden here, not disabled — the switcher is the one that disables.
|
||||
await expect(queryTab(page, QueryTab.builder)).toBeVisible();
|
||||
await expect(queryTab(page, QueryTab.clickhouse)).toHaveCount(0);
|
||||
await expect(queryTab(page, QueryTab.promql)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-03 Run Query issues a fresh query_range', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
await expect(page.getByTestId('time-series-renderer')).toBeVisible();
|
||||
|
||||
await runQuery(page);
|
||||
});
|
||||
|
||||
test('TC-04 the run keyboard shortcut works from inside the builder', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
await expect(page.getByTestId('time-series-renderer')).toBeVisible();
|
||||
|
||||
// Bound with onKeyDownCapture, so it fires from inside inputs too.
|
||||
const response = page.waitForResponse((r) =>
|
||||
r.url().includes('/query_range'),
|
||||
);
|
||||
await editor.queryBuilder(page).click();
|
||||
await page.keyboard.press('ControlOrMeta+Enter');
|
||||
await response;
|
||||
});
|
||||
|
||||
test('TC-05 switching query type re-renders the pane and marks the panel dirty', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
await expect(editor.unsavedBadge(page)).toHaveCount(0);
|
||||
|
||||
await queryTab(page, QueryTab.promql).click();
|
||||
await expect(queryTab(page, QueryTab.promql)).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
|
||||
// Structural change: auto-commits without Run.
|
||||
await expect(editor.unsavedBadge(page)).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-06 choosing a metric makes a new panel savable', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
// create → configure → save. A new metrics panel seeds an empty
|
||||
// aggregation and is rejected until a metric is picked (03-creation TC-09).
|
||||
const id = await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
|
||||
await page.getByTestId('add-panel-header').click();
|
||||
await page.getByTestId(`panel-type-${PanelKind.TimeSeries}`).click();
|
||||
await expect(editor.root(page)).toBeVisible();
|
||||
|
||||
await selectMetric(page, GOLDEN.metrics.calls);
|
||||
await editor.title(page).fill('Configured then saved');
|
||||
await savePanel(page);
|
||||
|
||||
await page.waitForURL(new RegExp(`/dashboard/${id}(\\?|$)`));
|
||||
|
||||
const after = await getDashboardV2ViaApi(page, id);
|
||||
const saved = Object.values(after.spec.panels).find(
|
||||
(candidate) => candidate.spec.display.name === 'Configured then saved',
|
||||
);
|
||||
expect(saved).toBeDefined();
|
||||
expect(JSON.stringify(saved?.spec.queries)).toContain(GOLDEN.metrics.calls);
|
||||
});
|
||||
|
||||
test('TC-07 an unrun query edit is still persisted by Save', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
// No Run: `buildSaveSpec` serialises the live query, so the edit must
|
||||
// survive — otherwise saving without running loses work.
|
||||
await selectMetric(page, GOLDEN.metrics.latencyCount);
|
||||
await savePanel(page);
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const after = await getDashboardV2ViaApi(page, id);
|
||||
return JSON.stringify(
|
||||
after.spec.panels[SINGLE_PANEL_ID].spec.queries,
|
||||
).includes(GOLDEN.metrics.latencyCount);
|
||||
})
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('TC-08 the in-editor query survives a reload', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
|
||||
await selectMetric(page, GOLDEN.metrics.latencySum);
|
||||
await runQuery(page);
|
||||
|
||||
// No forceReset, so the URL query wins and survives a refresh.
|
||||
await page.reload();
|
||||
await expect(editor.root(page)).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId('metric-name-selector-0').locator('input'),
|
||||
).toHaveValue(GOLDEN.metrics.latencySum);
|
||||
});
|
||||
|
||||
test('TC-09 a logs panel runs without needing a metric', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ query: logsCountQuery() }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
// Logs aggregate by expression, so there's no metric to fill.
|
||||
await expect(
|
||||
page.getByRole('button', { name: EditorText.runQuery }),
|
||||
).toBeVisible();
|
||||
await runQuery(page);
|
||||
await savePanel(page);
|
||||
});
|
||||
});
|
||||
198
tests/e2e/tests/dashboards/v2/editor/30-type-switch.spec.ts
Normal file
198
tests/e2e/tests/dashboards/v2/editor/30-type-switch.spec.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../../fixtures/dashboards';
|
||||
import {
|
||||
PanelKind,
|
||||
logsCountQuery,
|
||||
} from '../../../../helpers/dashboard-v2-spec';
|
||||
import { getDashboardV2ViaApi } from '../../../../helpers/dashboards-v2';
|
||||
import {
|
||||
Section,
|
||||
editor,
|
||||
expandSection,
|
||||
savePanel,
|
||||
selectOption,
|
||||
} from '../../../../helpers/panel-editor-v2';
|
||||
import {
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
} from '../../../../testdata/v2/panels-dashboard';
|
||||
|
||||
// Scope: switching kind mid-edit — what the session cache restores, and which
|
||||
// config survives a first-visit transfer. Config only carries when the TARGET
|
||||
// kind declares that control (buildPluginSpec).
|
||||
|
||||
async function switchKind(page: Page, label: string): Promise<void> {
|
||||
await selectOption(page, 'panel-editor-v2-type-switcher', label);
|
||||
}
|
||||
|
||||
test.describe('Dashboards V2 — panel type switching', () => {
|
||||
test('TC-01 switching kind re-renders the preview', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
await expect(page.getByTestId('time-series-renderer')).toBeVisible();
|
||||
|
||||
await switchKind(page, 'Table');
|
||||
await expect(page.getByTestId('table-panel-renderer')).toBeVisible();
|
||||
await expect(page.getByTestId('time-series-renderer')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-02 switching marks the editor dirty but does not persist', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
await switchKind(page, 'Number');
|
||||
|
||||
await expect(editor.unsavedBadge(page)).toBeVisible();
|
||||
const after = await getDashboardV2ViaApi(page, id);
|
||||
expect(after.spec.panels[SINGLE_PANEL_ID].spec.plugin.kind).toBe(
|
||||
PanelKind.TimeSeries,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-03 switching back restores the original kind from the session cache', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
// Logs, because List rejects metrics; time_series-shaped, because Table
|
||||
// can't read `raw`.
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ kind: PanelKind.Table, query: logsCountQuery() }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
await expect(page.getByTestId('table-panel-renderer')).toBeVisible();
|
||||
|
||||
await switchKind(page, 'List');
|
||||
await expect(page.getByTestId('list-panel-renderer')).toBeVisible();
|
||||
|
||||
// The per-kind cache makes the round trip reversible.
|
||||
await switchKind(page, 'Table');
|
||||
await expect(page.getByTestId('table-panel-renderer')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-04 the session cache does not survive a reload', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
await switchKind(page, 'Table');
|
||||
await expect(page.getByTestId('table-panel-renderer')).toBeVisible();
|
||||
|
||||
// The cache is a ref on the mounted editor, so a reload drops it. Reload
|
||||
// alone — a redundant goto afterwards crashed the page on WebKit.
|
||||
await page.reload();
|
||||
await expect(editor.root(page)).toBeVisible();
|
||||
await expect(page.getByTestId('time-series-renderer')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-05 a saved switch persists the new kind', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
await switchKind(page, 'Bar Chart');
|
||||
await savePanel(page);
|
||||
|
||||
const after = await getDashboardV2ViaApi(page, id);
|
||||
expect(after.spec.panels[SINGLE_PANEL_ID].spec.plugin.kind).toBe(
|
||||
PanelKind.BarChart,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-06 sections follow the target kind', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
// Time Series declares Axes and Chart Appearance.
|
||||
await expect(
|
||||
page.getByTestId('config-section-chart-appearance'),
|
||||
).toBeVisible();
|
||||
|
||||
await switchKind(page, 'Number');
|
||||
// Number declares neither, but keeps Formatting.
|
||||
await expect(page.getByTestId('config-section-chart-appearance')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(page.getByTestId('config-section-axes')).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId('config-section-formatting-&-units'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-07 a panel-wide unit fans out into per-column units on Table', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ pluginSpec: { formatting: { unit: 'ms' } } }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await switchKind(page, 'Table');
|
||||
await savePanel(page);
|
||||
|
||||
// Table has no panel-wide unit, so it fans out to columns — one-way.
|
||||
const after = await getDashboardV2ViaApi(page, id);
|
||||
const formatting =
|
||||
after.spec.panels[SINGLE_PANEL_ID].spec.plugin.spec.formatting;
|
||||
expect(formatting?.unit).toBeUndefined();
|
||||
expect(Object.values(formatting?.columnUnits ?? {})).toContain('ms');
|
||||
});
|
||||
|
||||
test('TC-08 custom legend colours are dropped on a first-visit switch', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({
|
||||
pluginSpec: {
|
||||
legend: { position: 'bottom', customColors: { adservice: '#ff0000' } },
|
||||
},
|
||||
}),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await switchKind(page, 'Bar Chart');
|
||||
await savePanel(page);
|
||||
|
||||
// Keyed by series label, which the new kind may not reproduce, so dropped.
|
||||
// Round-trips as null rather than being omitted.
|
||||
const after = await getDashboardV2ViaApi(page, id);
|
||||
const { customColors } =
|
||||
after.spec.panels[SINGLE_PANEL_ID].spec.plugin.spec.legend ?? {};
|
||||
expect(customColors ?? undefined).toBeUndefined();
|
||||
});
|
||||
|
||||
test('TC-09 axis bounds survive a switch between kinds that both declare them', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await expandSection(page, Section.axes);
|
||||
await page.getByTestId('panel-editor-v2-soft-min').fill('5');
|
||||
await page.getByTestId('panel-editor-v2-soft-max').fill('50');
|
||||
|
||||
// Bar Chart also declares minMax, so the bounds must carry over.
|
||||
await switchKind(page, 'Bar Chart');
|
||||
await savePanel(page);
|
||||
|
||||
const after = await getDashboardV2ViaApi(page, id);
|
||||
const axes = after.spec.panels[SINGLE_PANEL_ID].spec.plugin.spec.axes;
|
||||
expect(axes?.softMin).toBe(5);
|
||||
expect(axes?.softMax).toBe(50);
|
||||
});
|
||||
});
|
||||
209
tests/e2e/tests/dashboards/v2/editor/39-capabilities.spec.ts
Normal file
209
tests/e2e/tests/dashboards/v2/editor/39-capabilities.spec.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { expect, test } from '../../../../fixtures/dashboards';
|
||||
import {
|
||||
GOLDEN,
|
||||
PanelKind,
|
||||
clickhouseQuery,
|
||||
logsCountQuery,
|
||||
metricsQuery,
|
||||
promqlQuery,
|
||||
rawQuery,
|
||||
} from '../../../../helpers/dashboard-v2-spec';
|
||||
import {
|
||||
QueryTab,
|
||||
queryTab,
|
||||
selectOptions,
|
||||
} from '../../../../helpers/panel-editor-v2';
|
||||
import {
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
} from '../../../../testdata/v2/panels-dashboard';
|
||||
|
||||
// Scope: the panelKind × queryType × signal matrix, as the editor surfaces it.
|
||||
//
|
||||
// Two treatments, and mixing them up is the bug this guards: the type SWITCHER
|
||||
// disables unsupported kinds (with a reason); the query TABS omit them.
|
||||
|
||||
/** The switcher's option labels paired with whether they're selectable. */
|
||||
async function switcherOptions(
|
||||
page: Parameters<typeof selectOptions>[0],
|
||||
): Promise<{ label: string; disabled: boolean }[]> {
|
||||
return selectOptions(page, 'panel-editor-v2-type-switcher');
|
||||
}
|
||||
|
||||
function optionFor(
|
||||
options: { label: string; disabled: boolean }[],
|
||||
label: string,
|
||||
): { label: string; disabled: boolean } {
|
||||
const match = options.find((option) => option.label.startsWith(label));
|
||||
expect(
|
||||
match,
|
||||
`expected a "${label}" option in the type switcher`,
|
||||
).toBeDefined();
|
||||
return match as { label: string; disabled: boolean };
|
||||
}
|
||||
|
||||
test.describe('Dashboards V2 — editor capabilities matrix', () => {
|
||||
test('TC-01 every kind is selectable for a metrics builder query', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ query: metricsQuery() }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
const options = await switcherOptions(page);
|
||||
// List is the exception: logs/traces only.
|
||||
for (const label of [
|
||||
'Time Series',
|
||||
'Number',
|
||||
'Table',
|
||||
'Bar Chart',
|
||||
'Pie Chart',
|
||||
'Histogram',
|
||||
]) {
|
||||
expect(optionFor(options, label).disabled).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-02 List is disabled for a metrics query', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ query: metricsQuery() }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
const options = await switcherOptions(page);
|
||||
expect(optionFor(options, 'List').disabled).toBe(true);
|
||||
});
|
||||
|
||||
test('TC-03 List becomes selectable for a logs query', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ query: logsCountQuery() }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
const options = await switcherOptions(page);
|
||||
expect(optionFor(options, 'List').disabled).toBe(false);
|
||||
});
|
||||
|
||||
test('TC-04 a PromQL panel disables the kinds that cannot read PromQL', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({
|
||||
query: promqlQuery(`sum(rate(${GOLDEN.metrics.calls}[5m]))`),
|
||||
}),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
const options = await switcherOptions(page);
|
||||
// Pie, Table and List omit PromQL.
|
||||
expect(optionFor(options, 'Pie Chart').disabled).toBe(true);
|
||||
expect(optionFor(options, 'Table').disabled).toBe(true);
|
||||
expect(optionFor(options, 'List').disabled).toBe(true);
|
||||
|
||||
expect(optionFor(options, 'Time Series').disabled).toBe(false);
|
||||
expect(optionFor(options, 'Bar Chart').disabled).toBe(false);
|
||||
});
|
||||
|
||||
test('TC-05 a disabled option explains itself in a tooltip', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ query: metricsQuery() }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await page.getByTestId('panel-editor-v2-type-switcher').click();
|
||||
const dropdown = page.locator(
|
||||
'.ant-select-dropdown:not(.ant-select-dropdown-hidden)',
|
||||
);
|
||||
await dropdown
|
||||
.locator('.ant-select-item-option-disabled')
|
||||
.filter({ hasText: 'List' })
|
||||
.first()
|
||||
.hover();
|
||||
|
||||
// The wording is the contract users read.
|
||||
await expect(
|
||||
page.getByText("List doesn't support metrics data"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-06 a ClickHouse panel keeps Table selectable but not List', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({
|
||||
kind: PanelKind.Table,
|
||||
query: clickhouseQuery(
|
||||
"SELECT now() AS ts, 'adservice' AS service, 1 AS A",
|
||||
),
|
||||
}),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
const options = await switcherOptions(page);
|
||||
expect(optionFor(options, 'Table').disabled).toBe(false);
|
||||
|
||||
expect(optionFor(options, 'List').disabled).toBe(true);
|
||||
});
|
||||
|
||||
test('TC-07 a List panel hides the query types it cannot use', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({
|
||||
kind: PanelKind.List,
|
||||
query: rawQuery({ signal: 'logs' }),
|
||||
}),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
// Hidden, not disabled.
|
||||
await expect(queryTab(page, QueryTab.builder)).toBeVisible();
|
||||
await expect(queryTab(page, QueryTab.clickhouse)).toHaveCount(0);
|
||||
await expect(queryTab(page, QueryTab.promql)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-08 a Table panel offers ClickHouse but not PromQL', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ kind: PanelKind.Table }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await expect(queryTab(page, QueryTab.builder)).toBeVisible();
|
||||
await expect(queryTab(page, QueryTab.clickhouse)).toBeVisible();
|
||||
await expect(queryTab(page, QueryTab.promql)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-09 the backend rejects a combination the editor disables', async ({
|
||||
dashboards,
|
||||
}) => {
|
||||
// Enforced server-side too, so an invalid pairing can never be persisted.
|
||||
// Pins the layers together: if capabilities.ts and allowedQueryKinds
|
||||
// drift, either TC-04 or this fails.
|
||||
await expect(
|
||||
dashboards.seed(
|
||||
singlePanelDashboard({
|
||||
kind: PanelKind.PieChart,
|
||||
query: promqlQuery(`sum(rate(${GOLDEN.metrics.calls}[5m]))`),
|
||||
}),
|
||||
),
|
||||
// Quotes in the message are JSON-escaped, so match around them.
|
||||
).rejects.toThrow(/PromQLQuery.*not supported by panel kind.*PieChartPanel/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user