Compare commits

..

2 Commits

Author SHA1 Message Date
Ashwin Bhatkal
4afdafdeb9 test(e2e): assert the duplicate-name refusal directly
oxlint's playwright/no-conditional-in-test was right to object: branching on whether
Save happened to be enabled meant the test asserted one of two things and reported
neither. It is disabled — the form refuses the duplicate outright — so assert that,
and keep the check that no second variable of that name exists.
2026-08-06 00:35:48 +05:30
Ashwin Bhatkal
414e4285ba test(e2e): rewrite the variable-settings spec for V2
87-configure.spec.ts held 30 tests, 15 of them `test.skip` placeholders, all written
against the V1 settings UI. This replaces it with eight tests against the V2 form and
list — create, duplicate-name refusal, empty-name refusal, edit, delete, single-select
vs ALL, discard — and drops its parked-specs.json entry, so the guard now forbids a
skipped test in it.

Each test seeds its own dashboard, so a create or delete cannot leak into another and
the file runs in parallel.

Two things the tests had to be taught, found by running them:

  - the variables list is reached through the form's "All variables" back-link, which
    does not depend on the settings drawer's tab layout;
  - a delete leaves the row optimistically, so a reload can outrun the write — the
    test waits for the persisted spec before reloading. The delete itself is
    persisted; that was worth confirming rather than assuming.
2026-08-06 00:26:01 +05:30
3 changed files with 680 additions and 786 deletions

View File

@@ -1,6 +1,7 @@
{
"$comment": "Specs not currently running, and why. This is the ONLY place a spec may be excluded from the suite: `playwright.config.ts` feeds `specs` to `testIgnore`, and `pnpm guard:specs` fails if any spec that is NOT listed here contains a skipped, fixme'd or .only test. So a spec is either running and complete, or parked here with a reason — nothing rots quietly in between. Every entry is removed by the PR that migrates it; the list only shrinks.",
"specs": [
"**/tests/dashboards/list.spec.ts",
"**/tests/dashboards/details/03-viewing.spec.ts",
"**/tests/dashboards/details/12-sections.spec.ts",
"**/tests/dashboards/details/21-panel-actions.spec.ts",
@@ -9,7 +10,6 @@
"**/tests/dashboards/details/56-time-range.spec.ts",
"**/tests/dashboards/details/67-variables.spec.ts",
"**/tests/dashboards/details/78-edit-mode.spec.ts",
"**/tests/dashboards/details/87-configure.spec.ts",
"**/tests/dashboards/details/95-edge-cases.spec.ts",
"**/tests/trace-details/preview-fields.spec.ts"
],

View File

@@ -2,65 +2,67 @@ import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import { authToken } from '../../../helpers/dashboards';
import {
authToken,
awaitVariablesResolved,
createDashboardViaApi,
deleteDashboardViaApi,
} from '../../../helpers/dashboards';
createDashboardV2ViaApi,
dashboardV2Path,
deleteDashboardV2ViaApi,
getDashboardV2,
readVariableSelection,
variablePill,
variablesBar,
WIDE_VIEWPORT,
} from '../../../helpers/dashboards-v2';
import variablesFixture from '../../../testdata/variables-dashboard-v2.json';
const TELEMETRY_DEPENDENT_VARS = ['q_env', 'q_service', 'd_namespace'];
// Defining variables in dashboard settings: the list, the form, and what reaches the
// runtime bar. Each test seeds its own dashboard, so a create or delete in one cannot
// affect another and the file runs in parallel.
// `createVariablesDashboardViaApi` is added by the group-3 spec. Import lazily
// so this file still compiles while it is missing — tests that need it skip
// at runtime.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const dashboardsHelpers = require('../../../helpers/dashboards') as {
createVariablesDashboardViaApi?: (
page: Page,
title: string,
) => Promise<string>;
};
const hasVariablesHelper =
typeof dashboardsHelpers.createVariablesDashboardViaApi === 'function';
test.describe.configure({ mode: 'serial' });
test.use({ viewport: WIDE_VIEWPORT });
const seedIds = new Set<string>();
async function seed(page: Page, title: string): Promise<string> {
const id = await createDashboardViaApi(page, title);
/** Seed a dashboard carrying the shared variable fixture, and open it. */
async function seedAndOpen(page: Page, label: string): Promise<string> {
const id = await createDashboardV2ViaApi(
page,
`detail-configure-${label}-${process.env.TEST_WORKER_INDEX ?? '0'}`,
variablesFixture.spec,
);
seedIds.add(id);
await page.goto(dashboardV2Path(id));
await expect(variablesBar(page)).toBeVisible();
return id;
}
async function seedVariablesDashboard(
page: Page,
title: string,
): Promise<string> {
if (!dashboardsHelpers.createVariablesDashboardViaApi) {
throw new Error('createVariablesDashboardViaApi helper is not available');
}
const id = await dashboardsHelpers.createVariablesDashboardViaApi(page, title);
seedIds.add(id);
// Wait for the seeded dashboard's variables to fully resolve before any
// caller test acts on them. Variables with defaults already have
// `selectedValue` set; Query/Dynamic variables can't resolve without
// telemetry and are skipped.
await awaitVariablesResolved(page, id, {
skipNames: TELEMETRY_DEPENDENT_VARS,
});
return id;
/**
* Open the variables list. "Add variable" in the bar lands on the blank form, whose
* "All variables" back-link is the list — one click fewer than going through the
* settings drawer, and it does not depend on the drawer's tab layout.
*/
async function openVariablesList(page: Page): Promise<void> {
await page.getByRole('button', { name: 'Add variable' }).click();
await page.getByTestId('variable-form-back').click();
await expect(page.getByTestId('variables-list')).toBeVisible();
}
/** Open the blank variable form straight from the bar. */
async function openNewVariableForm(page: Page): Promise<void> {
await page.getByRole('button', { name: 'Add variable' }).click();
await expect(page.getByTestId('variable-name')).toBeVisible();
}
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
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);
await deleteDashboardV2ViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
@@ -68,620 +70,153 @@ test.afterAll(async ({ browser }) => {
}
});
async function openConfigureDrawer(page: Page) {
// An empty dashboard renders an onboarding canvas with a duplicate
// `data-testid="show-drawer"` Configure CTA alongside the toolbar one.
// Scope to the toolbar (`.dashboard-details .right-section`) to avoid the
// strict-mode collision.
await page
.locator('.dashboard-details .right-section')
.getByTestId('show-drawer')
.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
return dialog;
}
async function deleteVariableByName(page: Page, varName: string) {
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
const nameCell = tabpanel.getByText(varName, { exact: true }).first();
await nameCell.hover();
// Walk up to the surrounding row container to scope the delete-button
// search; `.variable-item` (or the variable row container) wraps the
// hover-revealed delete button.
await nameCell
.locator('xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]')
.locator('.delete-variable-button')
.first()
.dispatchEvent('click');
const confirm = page
.getByRole('dialog')
.filter({ hasText: /delete variable/i })
.last();
await confirm.getByRole('button', { name: 'OK' }).click();
await expect(tabpanel.getByText(varName, { exact: true })).toHaveCount(0);
await dialog.getByRole('button', { name: /close/i }).first().click();
}
test.describe('Dashboard Detail — Configure drawer', () => {
test('TC-01 Configure drawer opens with three tabs and Overview is active', async ({
test.describe('Dashboard settings — variables', () => {
test('TC-01 the list shows every variable the dashboard defines', async ({
authedPage: page,
}) => {
const id = await seed(page, 'cfg-drawer-chrome');
await page.goto(`/dashboard/${id}`);
await seedAndOpen(page, 'list');
await openVariablesList(page);
const dialog = await openConfigureDrawer(page);
await expect(dialog.getByText('Dashboard Configuration')).toBeVisible();
await expect(dialog.getByRole('tab', { name: 'Overview' })).toBeVisible();
await expect(dialog.getByRole('tab', { name: 'Variables' })).toBeVisible();
await expect(dialog.getByRole('tab', { name: 'Publish' })).toBeVisible();
await expect(dialog.getByRole('tab', { name: 'Overview' })).toHaveAttribute(
'aria-selected',
'true',
);
await expect(
dialog.getByRole('tabpanel', { name: 'Overview' }),
).toBeVisible();
await dialog.getByRole('button', { name: /close/i }).first().click();
await expect(dialog).not.toBeVisible();
});
test('TC-02 update name, description, and tag — persists across reload', async ({
authedPage: page,
}) => {
const ts = Date.now();
const original = `cfg-overview-save-${ts}`;
const updated = `Configured-${ts}`;
const id = await seed(page, original);
await page.goto(`/dashboard/${id}`);
const dialog = await openConfigureDrawer(page);
const nameInput = dialog.getByTestId('dashboard-name');
await nameInput.click();
await nameInput.fill('');
await nameInput.fill(updated);
await dialog.getByTestId('dashboard-desc').fill('Automated test description');
const tagInput = dialog.getByPlaceholder('Start typing your tag name');
await tagInput.fill(`e2e-tag-${ts}`);
await tagInput.press('Enter');
const saveBtn = dialog.getByRole('button', { name: 'Save' });
await saveBtn.scrollIntoViewIfNeeded();
const [putResp] = await Promise.all([
page.waitForResponse(
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
),
saveBtn.click({ force: true }),
]);
expect(putResp.ok()).toBeTruthy();
await dialog.getByRole('button', { name: /close/i }).first().click();
await page.reload();
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${updated}`),
}),
).toBeVisible();
});
test('TC-03 Discard reverts unsaved Overview changes', async ({
authedPage: page,
}) => {
const original = 'cfg-overview-discard';
const id = await seed(page, original);
await page.goto(`/dashboard/${id}`);
const dialog = await openConfigureDrawer(page);
const nameInput = dialog.getByTestId('dashboard-name');
await expect(nameInput).toHaveValue(original);
await nameInput.fill('Temp Modified Name');
const discard = dialog.getByRole('button', { name: 'Discard' });
await expect(discard).toBeVisible();
await discard.click();
await expect(nameInput).toHaveValue(original);
await expect(dialog.getByRole('button', { name: 'Save' })).not.toBeVisible();
await dialog.getByRole('button', { name: /close/i }).first().click();
});
test('TC-04 Variables tab lists existing variables', async ({
authedPage: page,
}) => {
test.skip(
!hasVariablesHelper,
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
);
const id = await seedVariablesDashboard(page, 'cfg-variables-list');
await page.goto(`/dashboard/${id}`);
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
await expect(tabpanel).toBeVisible();
for (const varName of [
'tb_env',
'tb_service',
'cu_env_all',
'cu_services',
'q_env',
'q_service',
'd_namespace',
]) {
// Variable rows render as plain text inside the Variables tab
// (not a true Antd `Table` with role="row"). Locate via text.
await expect(
tabpanel.getByText(varName, { exact: true }).first(),
).toBeVisible();
for (const name of ['tb_env', 'cu_service', 'cu_region']) {
await expect(page.getByTestId(`variable-row-${name}`)).toBeVisible();
}
await dialog.getByRole('button', { name: /close/i }).first().click();
});
test('TC-05 add a Textbox variable — appears in the variables bar and is interactive', async ({
test('TC-02 a new custom variable reaches the runtime bar', async ({
authedPage: page,
}) => {
test.skip(
!hasVariablesHelper,
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
);
const id = await seedAndOpen(page, 'create');
await openNewVariableForm(page);
const id = await seedVariablesDashboard(page, 'cfg-variables-add-textbox');
await page.goto(`/dashboard/${id}`);
await page.getByTestId('variable-name').fill('cu_tier');
await page.getByTestId('variable-type-custom').click();
await page.getByTestId('variable-custom-input').fill('gold,silver');
await page.getByTestId('variable-save').click();
const ts = Date.now();
const varName = `tb_var_${ts}`;
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
await dialog.getByTestId('add-new-variable').click();
await dialog.getByPlaceholder('Unique name of the variable').fill(varName);
await dialog.getByRole('button', { name: 'Textbox' }).click();
const saveBtn = dialog.getByRole('button', { name: 'Save Variable' });
await expect(saveBtn).toBeEnabled();
await saveBtn.click({ force: true });
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
await expect(
tabpanel.getByText(varName, { exact: true }).first(),
).toBeVisible();
await dialog.getByRole('button', { name: /close/i }).first().click();
await expect(dialog).not.toBeVisible();
await expect(page.getByText(`$${varName}`)).toBeVisible();
const newTextbox = page.locator('input[placeholder="Enter value"]').last();
await newTextbox.fill('test-value');
await newTextbox.press('Enter');
await expect(page).toHaveURL(/test-value/);
await deleteVariableByName(page, varName);
});
test('TC-06 add a Custom variable — appears in the list', async ({
authedPage: page,
}) => {
test.skip(
!hasVariablesHelper,
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
);
const id = await seedVariablesDashboard(page, 'cfg-variables-add-custom');
await page.goto(`/dashboard/${id}`);
const ts = Date.now();
const varName = `custom_var_${ts}`;
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
await dialog.getByTestId('add-new-variable').click();
await dialog.getByPlaceholder('Unique name of the variable').fill(varName);
await dialog.getByRole('button', { name: 'Custom' }).click();
await dialog
.getByRole('button', { name: 'Save Variable' })
.click({ force: true });
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
await expect(
tabpanel.getByText(varName, { exact: true }).first(),
).toBeVisible();
await dialog.getByRole('button', { name: /close/i }).first().click();
await deleteVariableByName(page, varName);
});
// known limitation: TC-07 (add a Dynamic (Beta) variable) is intentionally
// not implemented. Dynamic variables source from the SigNoz attribute
// index — the bootstrap stack ingests no telemetry, so the field selector
// renders an empty option list and Save Variable can never be enabled.
// Re-add once the bootstrap seeds telemetry attributes.
test('TC-08 selecting Query type renders the query editor', async ({
authedPage: page,
}) => {
test.skip(
!hasVariablesHelper,
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
);
const id = await seedVariablesDashboard(page, 'cfg-variables-add-query');
await page.goto(`/dashboard/${id}`);
const ts = Date.now();
const varName = `query_var_${ts}`;
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
await dialog.getByTestId('add-new-variable').click();
await dialog.getByPlaceholder('Unique name of the variable').fill(varName);
await dialog.getByRole('button', { name: /Query/ }).click();
// Monaco is lazy-loaded — its bundle chunk can take several seconds to
// arrive under parallel-worker CI load, far longer than the default 5 s
// locator timeout. 20 s is comfortable headroom without masking real
// regressions.
await expect(dialog.locator('.monaco-editor').first()).toBeVisible({
timeout: 20_000,
});
await dialog.getByRole('button', { name: 'Discard' }).click();
await dialog.getByRole('button', { name: /close/i }).first().click();
});
test('TC-09 Save Variable disabled when name is empty', async ({
authedPage: page,
}) => {
const id = await seed(page, 'cfg-variables-empty-name');
await page.goto(`/dashboard/${id}`);
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
await dialog.getByTestId('add-new-variable').click();
const nameField = dialog.getByPlaceholder('Unique name of the variable');
await expect(nameField).toHaveValue('');
await expect(
dialog.getByRole('button', { name: 'Save Variable' }),
).toBeDisabled();
await dialog.getByRole('button', { name: 'Discard' }).click();
await dialog.getByRole('button', { name: /close/i }).first().click();
});
test('TC-10 Publish tab shows private message and Publish button', async ({
authedPage: page,
}) => {
const id = await seed(page, 'cfg-publish');
await page.goto(`/dashboard/${id}`);
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Publish' }).click();
await expect(dialog.getByRole('tabpanel', { name: 'Publish' })).toBeVisible();
await expect(
dialog.getByText(
'This dashboard is private. Publish it to make it accessible to anyone with the link.',
),
).toBeVisible();
await expect(
dialog.getByRole('checkbox', { name: 'Enable time range' }),
).toBeVisible();
await expect(
dialog.getByText("Dashboard variables won't work in public dashboards"),
).toBeVisible();
await expect(
dialog.getByRole('button', { name: 'Publish dashboard' }),
).toBeVisible();
await dialog.getByRole('button', { name: /close/i }).first().click();
});
// ─── TBD coverage — placeholders to fill in when each feature lands ──────
//
// `test.skip` placeholders for behaviours not yet covered. Replace with
// `test` and implement when the corresponding feature ships or the seed
// gains the necessary state.
test('TC-11 edit existing variable — rename', async ({ authedPage: page }) => {
test.skip(
!hasVariablesHelper,
'createVariablesDashboardViaApi helper not available',
);
const id = await seedVariablesDashboard(page, 'cfg-rename-variable');
await page.goto(`/dashboard/${id}`);
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
// Hover the row to reveal the edit button (Pylon overlay can intercept,
// so dispatchEvent fires the click directly on the React onClick).
const nameCell = tabpanel.getByText('tb_env', { exact: true }).first();
await nameCell.hover();
await nameCell
.locator(
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
)
.locator('.edit-variable-button')
.first()
.dispatchEvent('click');
// Editor form mounts; rename and save.
const renamed = `tb_env_renamed_${Date.now()}`;
const nameInput = dialog.getByPlaceholder('Unique name of the variable');
await expect(nameInput).toHaveValue('tb_env');
await nameInput.fill(renamed);
await dialog
.getByRole('button', { name: 'Save Variable' })
.click({ force: true });
// Variables bar reflects the rename; the original label is gone.
await dialog.getByRole('button', { name: /close/i }).first().click();
await expect(page.getByText(`$${renamed}`, { exact: true })).toBeVisible();
await expect(page.getByText('$tb_env', { exact: true })).toHaveCount(0);
});
test('TC-12 edit existing variable — change type (CUSTOM → QUERY)', async ({
authedPage: page,
}) => {
test.skip(
!hasVariablesHelper,
'createVariablesDashboardViaApi helper not available',
);
const id = await seedVariablesDashboard(page, 'cfg-change-type');
await page.goto(`/dashboard/${id}`);
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
const nameCell = tabpanel.getByText('cu_single', { exact: true }).first();
await nameCell.hover();
await nameCell
.locator(
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
)
.locator('.edit-variable-button')
.first()
.dispatchEvent('click');
// Change type from Custom to Query and verify the form swaps to the
// Query editor (Monaco SQL editor mounts where the comma-separated
// values input used to live).
await dialog.getByRole('button', { name: /Query/ }).click();
// Monaco is lazy-loaded — its bundle chunk can take several seconds to
// arrive under parallel-worker CI load, far longer than the default 5 s
// locator timeout. 20 s is comfortable headroom without masking real
// regressions.
await expect(dialog.locator('.monaco-editor').first()).toBeVisible({
timeout: 20_000,
});
// The previous Custom-specific fields must no longer be visible.
await expect(dialog.getByPlaceholder(/Comma separated values/i)).toHaveCount(
0,
);
// Discard rather than save — saving without filling the new query
// would leave a half-configured Query variable. The contract this TC
// guards is "type switching swaps the form correctly", which the
// assertions above already prove.
await dialog.getByRole('button', { name: 'Discard' }).click();
await dialog.getByRole('button', { name: /close/i }).first().click();
});
test('TC-13 edit existing variable — change default textbox value persists across reload', async ({
authedPage: page,
}) => {
test.skip(
!hasVariablesHelper,
'createVariablesDashboardViaApi helper not available',
);
const id = await seedVariablesDashboard(page, 'cfg-change-default');
await page.goto(`/dashboard/${id}`);
await expect(page.locator('input[value="otel-demo"]')).toBeVisible();
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
const nameCell = tabpanel.getByText('tb_env', { exact: true }).first();
await nameCell.hover();
await nameCell
.locator(
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
)
.locator('.edit-variable-button')
.first()
.dispatchEvent('click');
// Update the default textbox value. The Default Value input is the
// second/third field (Name first); locate it via its placeholder.
const defaultInput = dialog
.getByPlaceholder(/Enter default value|Default value/i)
.first();
await defaultInput.fill('new-default');
// PUT confirms the variable persisted server-side before we close +
// reload. Without this wait the reload races the save and the old
// "otel-demo" default renders, producing the observed flake.
const putResponse = page.waitForResponse(
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
);
await dialog
.getByRole('button', { name: 'Save Variable' })
.click({ force: true });
await putResponse;
// Reload — the new default renders without URL state because it's
// now the persisted seed value.
await dialog.getByRole('button', { name: /close/i }).first().click();
await page.reload();
await expect(page.locator('input[value="new-default"]')).toBeVisible();
});
test('TC-14 delete variable — removed from variables bar', async ({
authedPage: page,
}) => {
test.skip(
!hasVariablesHelper,
'createVariablesDashboardViaApi helper not available',
);
const id = await seedVariablesDashboard(page, 'cfg-delete-variable');
await page.goto(`/dashboard/${id}`);
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
// Reuse the existing helper and assert the variables bar reflects
// the deletion — `deleteVariableByName` covers the Configure-side
// removal; the bar update is the new contract this TC adds.
await deleteVariableByName(page, 'tb_env');
await expect(page.getByText('$tb_env', { exact: true })).toHaveCount(0);
// Sibling textbox is unaffected.
await expect(page.getByText('$tb_service', { exact: true })).toBeVisible();
});
test('TC-15 variable name validation — duplicate name keeps Save disabled', async ({
authedPage: page,
}) => {
test.skip(
!hasVariablesHelper,
'createVariablesDashboardViaApi helper not available',
);
const id = await seedVariablesDashboard(page, 'cfg-validate-duplicate');
await page.goto(`/dashboard/${id}`);
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
await dialog.getByTestId('add-new-variable').click();
await dialog.getByPlaceholder('Unique name of the variable').fill('tb_env');
await dialog.getByRole('button', { name: 'Textbox' }).click();
// Save Variable should refuse to enable while the name collides with
// an existing variable. Assert the button stays disabled, OR a
// validation message surfaces — UI may pick either signal.
const saveBtn = dialog.getByRole('button', { name: 'Save Variable' });
const errorMsg = dialog.getByText(/already exists|duplicate|in use/i);
// Either Save is disabled, or an explicit error is shown — both are
// valid contracts. `Promise.race` between the two assertions tolerates
// whichever the UI provides.
// Persisted in the spec, and rendered by the bar.
await expect
.poll(async () => {
const disabled = await saveBtn.isDisabled().catch(() => false);
const err = await errorMsg.isVisible().catch(() => false);
return disabled || err;
const stored = await getDashboardV2(page, id);
return (stored.spec.variables as { spec: { name: string } }[]).map(
(variable) => variable.spec.name,
);
})
.toBeTruthy();
await dialog.getByRole('button', { name: 'Discard' }).click();
await dialog.getByRole('button', { name: /close/i }).first().click();
.toContain('cu_tier');
await expect(variablePill(page, 'cu_tier')).toBeVisible();
});
// eslint-disable-next-line playwright/expect-expect
test.skip('TC-16 variable name validation — invalid characters / whitespace', async () => {
// Names containing spaces, $-prefix, dots, etc. should be rejected
// by the validator. Confirm Save Variable stays disabled with an
// inline error message.
});
// eslint-disable-next-line playwright/expect-expect
test.skip('TC-17 reorder variables via drag persists `order` in JSON', async () => {
// The Variables tab supports drag handles. After a reorder, the
// persisted `data.variables[*].order` reflects the new sequence and
// the variables bar re-renders accordingly.
});
// eslint-disable-next-line playwright/expect-expect
test.skip('TC-18 add a Dynamic (Beta) variable via Configure → pick seeded attribute', async () => {
// Dynamic-variable resolution itself is covered by
// `67-variables` TC-15 (seed metric → Dynamic dropdown lists the
// namespace → URL state updates). What this TC adds is the Configure
// drawer's *Add Variable → Dynamic* form, whose attribute-picker
// uses a combobox whose stable locator hasn't been pinned in this
// suite yet — leave skipped pending a snapshot pass.
});
// eslint-disable-next-line playwright/expect-expect
test.skip('TC-19 Variable description renders in tooltip / inline metadata', async () => {
// `description` field on each variable should be surfaced in the
// variables bar tooltip and in the Variables tab's row.
});
// eslint-disable-next-line playwright/expect-expect
test.skip('TC-20 Save Variable disabled while query is in flight', async () => {
// For a Query variable mid-resolution, Save Variable should be
// disabled until the query returns options. Otherwise we'd save
// a variable with an empty option list.
});
test('TC-21 cancel-mid-edit variable changes are not persisted', async ({
test('TC-03 a duplicate name cannot be saved', async ({
authedPage: page,
}) => {
test.skip(
!hasVariablesHelper,
'createVariablesDashboardViaApi helper not available',
const id = await seedAndOpen(page, 'dupe');
await openNewVariableForm(page);
await page.getByTestId('variable-name').fill('cu_service');
await page.getByTestId('variable-type-custom').click();
await page.getByTestId('variable-custom-input').fill('a,b');
// The form refuses the duplicate outright, so Save never becomes available.
await expect(page.getByTestId('variable-save')).toBeDisabled();
// And the dashboard does not end up with two `cu_service`.
const names = (
(await getDashboardV2(page, id)).spec.variables as {
spec: { name: string };
}[]
).map((variable) => variable.spec.name);
expect(names.filter((name) => name === 'cu_service')).toHaveLength(1);
});
test('TC-04 an empty name cannot be saved', async ({ authedPage: page }) => {
await seedAndOpen(page, 'noname');
await openNewVariableForm(page);
await page.getByTestId('variable-type-custom').click();
await page.getByTestId('variable-custom-input').fill('a,b');
await expect(page.getByTestId('variable-save')).toBeDisabled();
});
test('TC-05 editing a custom variable changes the options it offers', async ({
authedPage: page,
}) => {
const id = await seedAndOpen(page, 'edit');
await openVariablesList(page);
await page.getByTestId('variable-edit-cu_region').click();
await page.getByTestId('variable-custom-input').fill('ap-south');
await page.getByTestId('variable-save').click();
await expect
.poll(async () =>
JSON.stringify((await getDashboardV2(page, id)).spec.variables),
)
.toContain('ap-south');
});
test('TC-06 deleting a variable takes it off the list and out of the bar', async ({
authedPage: page,
}) => {
const id = await seedAndOpen(page, 'delete');
await openVariablesList(page);
await page.getByTestId('variable-delete-cu_region').click();
await page.getByTestId('variable-delete-confirm-cu_region').click();
await expect(page.getByTestId('variable-row-cu_region')).toBeHidden();
// The row goes optimistically; wait for the write before reloading, or the
// reload can race it and legitimately still show the variable.
await expect
.poll(async () =>
(
(await getDashboardV2(page, id)).spec.variables as {
spec: { name: string };
}[]
).map((variable) => variable.spec.name),
)
.not.toContain('cu_region');
await page.reload();
await expect(variablesBar(page)).toBeVisible();
await expect(variablePill(page, 'cu_region')).toBeHidden();
});
test('TC-07 a variable saved as single-select does not render as ALL', async ({
authedPage: page,
}) => {
await seedAndOpen(page, 'single');
await openNewVariableForm(page);
await page.getByTestId('variable-name').fill('cu_single_tier');
await page.getByTestId('variable-type-custom').click();
await page.getByTestId('variable-custom-input').fill('gold,silver');
// ALL is only offered to a multi-select, so assert the switch state rather than
// assuming the form's default.
await expect(page.getByTestId('variable-multi-switch')).not.toBeChecked();
await page.getByTestId('variable-save').click();
await expect(variablePill(page, 'cu_single_tier')).toBeVisible();
await expect
.poll(() => readVariableSelection(page, 'cu_single_tier'))
.not.toBe('ALL');
});
test('TC-08 discarding the form leaves the dashboard untouched', async ({
authedPage: page,
}) => {
const id = await seedAndOpen(page, 'discard');
const before = JSON.stringify(
(await getDashboardV2(page, id)).spec.variables,
);
const id = await seedVariablesDashboard(page, 'cfg-cancel-edit-variable');
await page.goto(`/dashboard/${id}`);
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
await openNewVariableForm(page);
await page.getByTestId('variable-name').fill('cu_discarded');
await page.getByTestId('variable-type-custom').click();
await page.getByTestId('variable-custom-input').fill('x,y');
await page.getByRole('button', { name: 'Discard' }).click();
// Open the editor for tb_env and dirty the Name field.
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
const nameCell = tabpanel.getByText('tb_env', { exact: true }).first();
await nameCell.hover();
await nameCell
.locator(
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
)
.locator('.edit-variable-button')
.first()
.dispatchEvent('click');
const nameInput = dialog.getByPlaceholder('Unique name of the variable');
await expect(nameInput).toHaveValue('tb_env');
await nameInput.fill('SHOULD_NOT_PERSIST');
// Discard, then re-open the same row. The Name must still be the
// original — abandoned edits never reach the persisted JSON.
await dialog.getByRole('button', { name: 'Discard' }).click();
await dialog.getByRole('button', { name: /close/i }).first().click();
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
await expect(
page.getByText('$SHOULD_NOT_PERSIST', { exact: true }),
).toHaveCount(0);
// Reopen Configure → tb_env still has the original name.
const dialog2 = await openConfigureDrawer(page);
await dialog2.getByRole('tab', { name: 'Variables' }).click();
await expect(
dialog2
.getByRole('tabpanel', { name: 'Variables' })
.getByText('tb_env', { exact: true })
.first(),
).toBeVisible();
const after = JSON.stringify((await getDashboardV2(page, id)).spec.variables);
expect(after).toBe(before);
await expect(variablePill(page, 'cu_discarded')).toBeHidden();
});
});

View File

@@ -1,53 +1,57 @@
import type { Locator, Page } from '@playwright/test';
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
import { newAdminContext } from '../../helpers/auth';
import { authToken } from '../../helpers/dashboards';
import {
createDashboardV2ViaApi,
deleteDashboardV2ViaApi,
WIDE_VIEWPORT,
} from '../../helpers/dashboards-v2';
APM_METRICS_TITLE,
authToken,
createDashboardViaApi,
DEFAULT_DASHBOARD_TITLE,
deleteDashboardViaApi,
findDashboardIdByTitle,
gotoDashboardsList,
importApmMetricsDashboardViaUI,
openDashboardActionMenu,
SEARCH_PLACEHOLDER,
} from '../../helpers/dashboards';
// The V2 dashboards list: the views rail, the list itself, search, sort and pinning.
// Seeded through the v2 API, and every assertion is scoped to this suite's own
// dashboards — the workspace is shared, so counting rows or asserting on "the first
// row" would depend on what else exists.
test.use({ viewport: WIDE_VIEWPORT });
// 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
// fullyParallel setting.
test.describe.configure({ mode: 'serial' });
// ─── Suite-level seed registry ───────────────────────────────────────────
//
// Every dashboard a test creates is recorded here, and one `afterAll`
// deletes the lot at suite teardown. Individual tests do not need their
// own `try / finally` cleanup blocks.
const seedIds = new Set<string>();
const RUN = `${Date.now()}-${process.env.TEST_WORKER_INDEX ?? '0'}`;
const listPath = '/dashboard';
const BASE_FIXTURE_TITLE = 'dashboards-list-base-fixture';
/** A title unique to this run, so searches can only match what this suite made. */
const title = (label: string): string => `e2e-list-${label}-${RUN}`;
async function seed(page: Page, label: string): Promise<string> {
const id = await createDashboardV2ViaApi(page, title(label));
/** Seed a dashboard via API and register it for suite cleanup. */
async function seed(page: Page, title: string): Promise<string> {
const id = await createDashboardViaApi(page, title);
seedIds.add(id);
return id;
}
async function gotoList(page: Page): Promise<void> {
await page.goto(listPath);
await expect(
page.getByRole('heading', { name: 'All dashboards' }),
).toBeVisible();
}
/** Rows are indexed, not keyed by name — find the row holding a given title. */
const rowByTitle = (page: Page, dashboardTitle: string): Locator =>
page.locator('[data-testid^="dashboard-title-"]').filter({
hasText: dashboardTitle,
});
/** Type into the list's query box and run it. */
async function search(page: Page, term: string): Promise<void> {
await page.getByTestId('dashboards-list-search').click();
await page.keyboard.type(term);
await page.getByTestId('dashboards-list-search-submit').click();
}
test.beforeAll(async ({ browser }) => {
// Persistent fixtures the read-only tests rely on:
// - A minimal base dashboard — keeps the list non-empty so the search
// input / sort button render. Seeded first via API so the workspace
// is populated before the UI import flow runs.
// - APM Metrics — a richer, real-world dashboard imported through the
// real Import JSON UI flow (file upload + Monaco editor + submit).
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
seedIds.add(await createDashboardViaApi(page, BASE_FIXTURE_TITLE));
seedIds.add(await importApmMetricsDashboardViaUI(page));
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) {
@@ -58,7 +62,7 @@ test.afterAll(async ({ browser }) => {
try {
const token = await authToken(page);
for (const id of seedIds) {
await deleteDashboardV2ViaApi(ctx.request, id, token);
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
@@ -66,146 +70,501 @@ test.afterAll(async ({ browser }) => {
}
});
test.describe('Dashboards list', () => {
test.describe('Dashboards List Page', () => {
// ─── Page load and layout ────────────────────────────────────────────────
test('TC-01 page chrome and core controls render', async ({
authedPage: page,
}) => {
await gotoList(page);
await gotoDashboardsList(page);
await expect(page.getByRole('heading', { name: 'Views' })).toBeVisible();
await expect(page.getByTestId('new-dashboard-cta')).toBeVisible();
await expect(page.getByTestId('dashboards-list-search')).toBeVisible();
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveTitle('SigNoz | All Dashboards');
await expect(
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
).toBeVisible();
await expect(
page.getByText('Create and manage dashboards for your workspace.'),
).toBeVisible();
await expect(page.getByPlaceholder(SEARCH_PLACEHOLDER)).toBeVisible();
await expect(page.getByText('All Dashboards')).toBeVisible();
await expect(page.getByTestId('sort-by')).toBeVisible();
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
await expect(page.getByRole('button', { name: 'Feedback' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
});
test('TC-02 every view in the rail is reachable', async ({
// ─── Search functionality ────────────────────────────────────────────────
test('TC-02 search by title returns matching dashboard', async ({
authedPage: page,
}) => {
await gotoList(page);
const name = 'dashboards-list-search-title';
await seed(page, name);
for (const view of ['mine', 'pinned', 'recent', 'all', 'locked']) {
await page.getByTestId(`dashboards-view-${view}`).click();
await expect(page.getByTestId(`dashboards-view-${view}`)).toBeVisible();
// The list frame survives every view switch, empty or not.
await expect(page.getByTestId('dashboards-list-search')).toBeVisible();
await gotoDashboardsList(page);
const search = page.getByPlaceholder(SEARCH_PLACEHOLDER);
await search.fill(name);
await expect(page).toHaveURL(new RegExp(`search=${name}`));
await expect(search).toHaveValue(name);
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
await expect(page.getByText(name).first()).toBeVisible();
});
test('TC-03 search by tag returns the APM Metrics dashboard', async ({
authedPage: page,
}) => {
// APM Metrics carries multiple tags — searching by one of them ("apm")
// surfaces the imported dashboard. This exercises the tag-match branch
// in the filter, distinct from title-match.
await gotoDashboardsList(page);
const search = page.getByPlaceholder(SEARCH_PLACEHOLDER);
await search.fill('apm');
await expect(page).toHaveURL(/search=apm/);
await expect(page.getByText(APM_METRICS_TITLE).first()).toBeVisible();
});
test('TC-04 direct navigation with ?search= pre-fills the input and filters results', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-search-deeplink';
await seed(page, name);
await page.goto(`/dashboard?search=${name}`);
await page
.getByRole('heading', { name: 'Dashboards', level: 1 })
.waitFor({ state: 'visible' });
await expect(page.getByPlaceholder(SEARCH_PLACEHOLDER)).toHaveValue(name);
await expect(page.getByText(name).first()).toBeVisible();
});
test('TC-05 clearing search restores the full list', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
const search = page.getByPlaceholder(SEARCH_PLACEHOLDER);
await search.fill('apm');
await expect(page).toHaveURL(/search=apm/);
await search.fill('');
// The app keeps the empty `search=` param in the URL — assert that no
// non-empty value remains and that rows are rendered again.
await expect(page).not.toHaveURL(/search=[^&]/);
await expect(search).toHaveValue('');
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
});
test('TC-06 search with no matching results shows empty state', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
const search = page.getByPlaceholder(SEARCH_PLACEHOLDER);
await search.fill('xyznonexistent999');
await expect(page.getByAltText('dashboard-image')).toHaveCount(0);
await expect(search).toBeVisible();
await expect(search).toHaveValue('xyznonexistent999');
});
test('TC-07 search is case-insensitive', async ({ authedPage: page }) => {
await gotoDashboardsList(page);
const search = page.getByPlaceholder(SEARCH_PLACEHOLDER);
await search.fill(APM_METRICS_TITLE.toLowerCase());
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
await expect(page.getByText(APM_METRICS_TITLE).first()).toBeVisible();
});
// ─── Sorting ─────────────────────────────────────────────────────────────
//
// `sortHandle` in DashboardsList.tsx hard-codes `order: 'descend'` —
// ascending mode is not yet implemented. Both sort options ride the same
// descending-only path, so one parameterised test covers them.
test('TC-08 sort options write columnKey & order=descend to the URL', async ({
authedPage: page,
}) => {
for (const [optionTestId, columnKey] of [
['sort-by-last-updated', 'updatedAt'],
['sort-by-last-created', 'createdAt'],
] as const) {
await gotoDashboardsList(page);
await expect(page).not.toHaveURL(/columnKey/);
await page.getByTestId('sort-by').click();
const option = page.getByTestId(optionTestId);
await option.waitFor({ state: 'visible' });
await option.click();
await expect(page).toHaveURL(new RegExp(`columnKey=${columnKey}`));
await expect(page).toHaveURL(/order=descend/);
await expect(page).not.toHaveURL(/order=ascend/);
}
});
test('TC-03 a newly created dashboard is listed', async ({
// ─── Row actions (context menu) ──────────────────────────────────────────
test('TC-09 admin sees all five options in the action menu', async ({
authedPage: page,
}) => {
await seed(page, 'listed');
await gotoList(page);
const name = 'dashboards-list-actions-menu';
await seed(page, name);
await expect(rowByTitle(page, title('listed'))).toBeVisible();
await gotoDashboardsList(page);
const tooltip = await openDashboardActionMenu(page, name);
await expect(tooltip).toBeVisible();
await expect(tooltip.getByRole('button', { name: 'View' })).toBeVisible();
await expect(
tooltip.getByRole('button', { name: 'Open in New Tab' }),
).toBeVisible();
await expect(
tooltip.getByRole('button', { name: 'Copy Link' }),
).toBeVisible();
await expect(
tooltip.getByRole('button', { name: 'Export JSON' }),
).toBeVisible();
// Delete is rendered as a generic, not a button.
await expect(tooltip.getByText('Delete dashboard')).toBeVisible();
});
test('TC-04 opening a dashboard from the list lands on its detail page', async ({
test('TC-10 view action navigates to the dashboard detail page', async ({
authedPage: page,
}) => {
const id = await seed(page, 'open');
await gotoList(page);
const name = 'dashboards-list-action-view';
await seed(page, name);
await rowByTitle(page, title('open')).click();
await expect(page).toHaveURL(new RegExp(`/dashboard/${id}`));
await expect(page.getByTestId('dashboard-title')).toContainText(
title('open'),
);
});
test('TC-05 search narrows the list to a matching dashboard', async ({
authedPage: page,
}) => {
await seed(page, 'searchable');
await seed(page, 'other');
await gotoList(page);
await search(page, title('searchable'));
await expect(rowByTitle(page, title('searchable'))).toBeVisible();
await expect(rowByTitle(page, title('other'))).toBeHidden();
});
test('TC-06 a search matching nothing leaves no rows of ours', async ({
authedPage: page,
}) => {
await seed(page, 'nomatch');
await gotoList(page);
await search(page, `${title('nomatch')}-absent`);
await expect(rowByTitle(page, title('nomatch'))).toBeHidden();
});
test('TC-07 pinning a dashboard puts it in the Pinned view', async ({
authedPage: page,
}) => {
await seed(page, 'pin');
await gotoList(page);
const row = rowByTitle(page, title('pin'));
await expect(row).toBeVisible();
// The pin control is indexed like the title it sits beside.
const index = await row.getAttribute('data-testid');
const pinIndex = (index ?? '').replace('dashboard-title-', '');
await page.getByTestId(`dashboard-pin-${pinIndex}`).click();
await page.getByTestId('dashboards-view-pinned').click();
await expect(rowByTitle(page, title('pin'))).toBeVisible();
});
test('TC-08 the create CTA opens the new-dashboard modal', async ({
authedPage: page,
}) => {
await gotoList(page);
await page.getByTestId('new-dashboard-cta').click();
for (const field of [
'create-dashboard-name',
'create-dashboard-description',
'create-dashboard-tags',
]) {
await expect(page.getByTestId(field)).toBeVisible();
}
await expect(page.getByTestId('create-dashboard-submit')).toBeVisible();
});
test('TC-09 creating a dashboard through the modal lands on it', async ({
authedPage: page,
}) => {
await gotoList(page);
await page.getByTestId('new-dashboard-cta').click();
const name = title('via-modal');
await page.getByTestId('create-dashboard-name').fill(name);
await page.getByTestId('create-dashboard-submit').click();
await gotoDashboardsList(page);
const tooltip = await openDashboardActionMenu(page, name);
await tooltip.getByRole('button', { name: 'View' }).click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
await expect(page.getByTestId('dashboard-title')).toContainText(name);
// Created through the UI, so register it for cleanup by id from the URL.
const created = page.url().split('/dashboard/')[1]?.split('?')[0] ?? '';
expect(created).not.toBe('');
seedIds.add(created);
});
test('TC-10 a deleted dashboard leaves the list', async ({
test('TC-11 open in new tab opens the dashboard in a new browser tab', async ({
authedPage: page,
}) => {
const id = await seed(page, 'deleted');
await gotoList(page);
await expect(rowByTitle(page, title('deleted'))).toBeVisible();
const name = 'dashboards-list-action-newtab';
await seed(page, name);
const token = await authToken(page);
await deleteDashboardV2ViaApi(page.request, id, token);
seedIds.delete(id);
await gotoDashboardsList(page);
const tooltip = await openDashboardActionMenu(page, name);
await page.reload();
// Use page.context() — the auth fixture creates its own context per
// test, which is not the same as the default `context` fixture.
const [newPage] = await Promise.all([
page.context().waitForEvent('page'),
tooltip.getByRole('button', { name: 'Open in New Tab' }).click(),
]);
await newPage.waitForLoadState();
await expect(newPage).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
await newPage.close();
});
test('TC-12 copy link copies the dashboard URL to the clipboard', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-action-copy';
await seed(page, name);
await gotoDashboardsList(page);
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
const tooltip = await openDashboardActionMenu(page, name);
await tooltip.getByRole('button', { name: 'Copy Link' }).click();
await expect(page.getByText(/copied|success/i)).toBeVisible();
const clipboardText = await page.evaluate(async () =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).navigator.clipboard.readText(),
);
expect(clipboardText).toMatch(/\/dashboard\/[0-9a-f-]+/);
});
test('TC-13 export JSON downloads the dashboard as a JSON file', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-action-export';
await seed(page, name);
await gotoDashboardsList(page);
const tooltip = await openDashboardActionMenu(page, name);
const [download] = await Promise.all([
page.waitForEvent('download'),
tooltip.getByRole('button', { name: 'Export JSON' }).click(),
]);
expect(download.suggestedFilename()).toMatch(/\.json$/);
});
test('TC-14 action menu closes when clicking outside the popover', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-action-dismiss';
await seed(page, name);
await gotoDashboardsList(page);
await openDashboardActionMenu(page, name);
await expect(page.getByRole('tooltip')).toBeVisible();
await page.getByRole('heading', { name: 'Dashboards', level: 1 }).click();
await expect(page.getByRole('tooltip')).not.toBeVisible();
await expect(page).toHaveURL(/\/dashboard($|\?)/);
});
// ─── Creating dashboards via "New dashboard" dropdown ─────────────────────
//
// The "Enter dashboard name…" inline input on the list page is a
// `RequestDashboardBtn` (template-request feedback form), not a create
// flow. The only UI create path is the "New dashboard" dropdown.
test('TC-15 New dashboard dropdown shows exactly three options', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
await expect(menu.getByTestId('create-dashboard-menu-cta')).toBeVisible();
await expect(menu.getByTestId('import-json-menu-cta')).toBeVisible();
await expect(menu.getByTestId('view-templates-menu-cta')).toBeVisible();
});
test('TC-16 Create dashboard dropdown option creates a dashboard with the default name', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('create-dashboard-menu-cta').click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
await expect(page.getByText('Configure your new dashboard')).toBeVisible();
// "Configure" appears twice on the new-dashboard onboarding state — once
// in the toolbar and once in the empty-state section. The test only
// needs to confirm the onboarding rendered, so .first() is sufficient.
await expect(
page.getByRole('heading', { name: 'All dashboards' }),
page.getByRole('button', { name: 'Configure' }).first(),
).toBeVisible();
await expect(rowByTitle(page, title('deleted'))).toBeHidden();
await expect(
page.getByRole('button', { name: /New Panel/ }).first(),
).toBeVisible();
// Register the UI-created dashboard with the suite teardown. After a
// successful "Create dashboard" the row must exist — assert that and
// then unconditionally register, so the test contains no `if`.
const sampleId = await findDashboardIdByTitle(page, DEFAULT_DASHBOARD_TITLE);
expect(
sampleId,
`${DEFAULT_DASHBOARD_TITLE} not found after UI create`,
).toBeDefined();
seedIds.add(sampleId as string);
});
test('TC-17 Import JSON dialog opens with code editor and upload button', 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');
await expect(dialog).toBeVisible();
await expect(dialog.getByText('Import Dashboard JSON')).toBeVisible();
// "Upload JSON file" appears twice — once as the Ant Upload's hidden
// span wrapper, once as the visible button. .first() is enough to
// confirm the upload affordance rendered.
await expect(
dialog.getByRole('button', { name: 'Upload JSON file' }).first(),
).toBeVisible();
await expect(
dialog.getByRole('button', { name: 'Import and Next' }),
).toBeVisible();
});
test('TC-18 Import JSON dialog dismisses via Escape and via the close button', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
// Escape path — Monaco grabs focus on mount and swallows Escape; click
// the modal title first to blur Monaco so Ant's Modal `keyboard`
// handler picks up the keystroke.
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('import-json-menu-cta').click();
let dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByText('Import Dashboard JSON').click();
await page.keyboard.press('Escape');
await expect(dialog).not.toBeVisible();
await expect(page).toHaveURL(/\/dashboard($|\?)/);
// Close-button path — re-open and dismiss via the X.
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('import-json-menu-cta').click();
dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: /close/i }).click();
await expect(dialog).not.toBeVisible();
await expect(page).toHaveURL(/\/dashboard($|\?)/);
});
// ─── Deleting dashboards ─────────────────────────────────────────────────
//
// Known behaviour: clicking Cancel in the confirmation dialog navigates to
// the dashboard detail page rather than staying on the list.
test('TC-19 delete confirmation dialog shows dashboard name with Cancel and Delete buttons', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-delete-confirm';
await seed(page, name);
await gotoDashboardsList(page);
const tooltip = await openDashboardActionMenu(page, name);
// Ant's Popover can position the tooltip so the "Delete dashboard"
// item ends up outside the viewport (especially in CI, where font
// rendering shifts layout subtly). `click({ force: true })` skips
// actionability checks but Playwright still requires the click
// coordinates to land inside the viewport. `dispatchEvent('click')`
// fires the synthetic event directly on the DOM node — React's
// onClick handler runs normally — and bypasses coordinate checks
// entirely. This is the robust fix for Ant Popover positioning.
await tooltip.getByText('Delete dashboard').dispatchEvent('click');
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('heading')).toContainText(
'Are you sure you want to delete the',
);
await expect(dialog.getByRole('heading')).toContainText(name);
await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Delete' })).toBeVisible();
});
test('TC-20 cancelling delete navigates to the dashboard detail page (known behaviour)', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-delete-cancel';
await seed(page, name);
await gotoDashboardsList(page);
const tooltip = await openDashboardActionMenu(page, name);
await tooltip.getByText('Delete dashboard').dispatchEvent('click');
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Cancel' }).click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
});
test('TC-21 confirming delete removes the dashboard from the list', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-delete-confirmed';
const id = await seed(page, name);
await gotoDashboardsList(page);
const tooltip = await openDashboardActionMenu(page, name);
await tooltip.getByText('Delete dashboard').dispatchEvent('click');
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
// The Delete mutation is async — wait for the API response *and* the
// dialog to dismiss before navigating away, otherwise React Query's
// in-flight mutation gets cancelled by the navigation.
const deleteResponse = page.waitForResponse(
(r) => r.request().method() === 'DELETE' && /\/dashboards\//.test(r.url()),
);
await dialog.getByRole('button', { name: 'Delete' }).click();
await deleteResponse;
await expect(dialog).not.toBeVisible();
// After deletion, searching for the name should return no results.
await gotoDashboardsList(page);
await page.getByPlaceholder(SEARCH_PLACEHOLDER).fill(name);
await expect(page.getByAltText('dashboard-image')).toHaveCount(0);
// The UI delete already removed the resource — drop it from the
// suite-cleanup set so afterAll doesn't 404 on it.
seedIds.delete(id);
});
// ─── Row click navigation ────────────────────────────────────────────────
test('TC-22 clicking a dashboard row navigates to the detail page', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-row-click';
await seed(page, name);
await gotoDashboardsList(page);
await page.getByPlaceholder(SEARCH_PLACEHOLDER).fill(name);
await page.getByAltText('dashboard-image').first().click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
});
test('TC-23 sidebar Dashboards link navigates to the list page', async ({
authedPage: page,
}) => {
await page.goto('/home');
// Sidebar items are <div class="nav-item"> with the label as visible
// text — they're not <a role="link">, so getByRole won't reach them.
// Filter on the exact label to avoid matching nested items that
// happen to contain the substring.
await page
.locator('.nav-item')
.filter({ hasText: /^Dashboards$/ })
.click();
await expect(page).toHaveURL(/\/dashboard/);
await expect(page).toHaveTitle('SigNoz | All Dashboards');
});
// ─── URL state and deep linking ──────────────────────────────────────────
test('TC-24 browser Back after navigating to a dashboard restores search state', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-back-search';
await seed(page, name);
await page.goto(`/dashboard?search=${name}`);
await page
.getByRole('heading', { name: 'Dashboards', level: 1 })
.waitFor({ state: 'visible' });
await page.getByAltText('dashboard-image').first().click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
await page.goBack();
await expect(page).toHaveURL(new RegExp(`search=${name}`));
await expect(page.getByPlaceholder(SEARCH_PLACEHOLDER)).toHaveValue(name);
});
test('TC-25 direct navigation with sort params honours them on load', async ({
authedPage: page,
}) => {
await page.goto('/dashboard?columnKey=updatedAt&order=descend');
await page
.getByRole('heading', { name: 'Dashboards', level: 1 })
.waitFor({ state: 'visible' });
await expect(page).toHaveURL(/columnKey=updatedAt/);
await expect(page).toHaveURL(/order=descend/);
});
});