mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-05 20:50:45 +01:00
Compare commits
2 Commits
test/e2e-d
...
test/e2e-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4afdafdeb9 | ||
|
|
414e4285ba |
@@ -8,8 +8,8 @@
|
||||
"**/tests/dashboards/details/35-add-panel.spec.ts",
|
||||
"**/tests/dashboards/details/44-edit-panel.spec.ts",
|
||||
"**/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"
|
||||
],
|
||||
|
||||
@@ -2,59 +2,42 @@ import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import { authToken } from '../../../helpers/dashboards';
|
||||
import {
|
||||
anyDropdown,
|
||||
closeVariableDropdown,
|
||||
readCheckedOptions,
|
||||
createDashboardV2ViaApi,
|
||||
dashboardV2Path,
|
||||
deleteDashboardV2ViaApi,
|
||||
hiddenVariablesTooltip,
|
||||
openVariableDropdown,
|
||||
optionRow,
|
||||
pickVariableValues,
|
||||
readVariableSelection,
|
||||
variableControl,
|
||||
variablePill,
|
||||
variablesBar,
|
||||
variableTextInput,
|
||||
WIDE_VIEWPORT,
|
||||
} from '../../../helpers/dashboards-v2';
|
||||
import variablesFixture from '../../../testdata/variables-dashboard-v2.json';
|
||||
authToken,
|
||||
awaitVariablesResolved,
|
||||
createVariablesDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
import variablesTemplate from '../../../testdata/variables-dashboard.json';
|
||||
|
||||
// The runtime variables bar on the V2 detail page. Everything here is driven from
|
||||
// text + custom variables, whose option lists come from the definition — so no
|
||||
// assertion depends on telemetry the stack may or may not hold.
|
||||
//
|
||||
// Fetched variables (QUERY / DYNAMIC) and the behaviours that only they can show —
|
||||
// a selection surviving a time-range refetch, a typed value surviving a cascade —
|
||||
// need seeded telemetry and are covered separately.
|
||||
// Variables that depend on backend resolution against seeded telemetry the
|
||||
// bootstrap stack does not produce. Skip them so `awaitVariablesResolved`
|
||||
// does not block on values that can never appear.
|
||||
const TELEMETRY_DEPENDENT_VARS = ['q_env', 'q_service', 'd_namespace'];
|
||||
|
||||
test.use({ viewport: WIDE_VIEWPORT });
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
let dashboardId = '';
|
||||
|
||||
// Each worker seeds its own dashboard: the v2 API rejects a duplicate name, and
|
||||
// `beforeAll` runs once per worker.
|
||||
const SUITE_TITLE = `detail-variables-suite-${process.env.TEST_WORKER_INDEX ?? '0'}`;
|
||||
|
||||
async function open(page: Page): Promise<void> {
|
||||
await page.goto(dashboardV2Path(dashboardId));
|
||||
await expect(variablesBar(page)).toBeVisible();
|
||||
}
|
||||
let varDashboardId = '';
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
dashboardId = await createDashboardV2ViaApi(
|
||||
varDashboardId = await createVariablesDashboardViaApi(
|
||||
page,
|
||||
SUITE_TITLE,
|
||||
variablesFixture.spec,
|
||||
'detail-variables-suite',
|
||||
);
|
||||
seedIds.add(dashboardId);
|
||||
seedIds.add(varDashboardId);
|
||||
// Per the framework contract: every variable with a default has its
|
||||
// `selectedValue` set in the seed JSON; backend-resolved variables
|
||||
// (Query / Dynamic) cannot resolve without seeded telemetry, so we
|
||||
// list them in `skipNames`. Tests must not race ahead of seed
|
||||
// materialisation — this gate ensures the persisted dashboard is in
|
||||
// a known state before any test runs.
|
||||
await awaitVariablesResolved(page, varDashboardId, {
|
||||
skipNames: TELEMETRY_DEPENDENT_VARS,
|
||||
});
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
@@ -69,7 +52,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 {
|
||||
@@ -77,130 +60,424 @@ test.afterAll(async ({ browser }) => {
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Dashboard detail — variables bar', () => {
|
||||
test('TC-01 every seeded variable renders as a pill labelled with its name', async ({
|
||||
function variablesQueryParam(state: Record<string, unknown>): string {
|
||||
return encodeURIComponent(encodeURIComponent(JSON.stringify(state)));
|
||||
}
|
||||
|
||||
async function gotoVariablesDashboard(
|
||||
page: Page,
|
||||
urlState?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const url = urlState
|
||||
? `/dashboard/${varDashboardId}?variables=${variablesQueryParam(urlState)}`
|
||||
: `/dashboard/${varDashboardId}`;
|
||||
await page.goto(url);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: /dashboard-icon detail-variables-suite/,
|
||||
}),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail — Variables', () => {
|
||||
test('TC-01 variables bar renders all four types', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await open(page);
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
for (const name of ['tb_env', 'cu_service', 'cu_region']) {
|
||||
await expect(variablePill(page, name)).toBeVisible();
|
||||
await expect(variablePill(page, name)).toContainText(`$${name}`);
|
||||
for (const name of [
|
||||
'$tb_env',
|
||||
'$tb_service',
|
||||
'$cu_single',
|
||||
'$cu_env_all',
|
||||
'$cu_services',
|
||||
'$q_env',
|
||||
'$q_service',
|
||||
'$d_namespace',
|
||||
]) {
|
||||
await expect(page.getByText(name, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
// Textbox variables expose their current value via `value` and `title`
|
||||
// attributes (the antd Input has no accessible name matching the value),
|
||||
// so we match on input[value="..."] rather than getByRole+name.
|
||||
await expect(page.locator('input[value="otel-demo"]')).toBeVisible();
|
||||
await expect(page.locator('input[value="frontend"]')).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId('variable-select')).toHaveCount(6);
|
||||
});
|
||||
|
||||
test('TC-02 an ALL-enabled multi-select with no default resolves to ALL', async ({
|
||||
test('TC-02 selecting a value in a single-value Custom variable updates URL and aria-selected', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await open(page);
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
await expect
|
||||
.poll(() => readVariableSelection(page, 'cu_service'))
|
||||
.toBe('ALL');
|
||||
});
|
||||
// $cu_single (nth(0)) — single-select Custom with three static
|
||||
// options. Driving Custom rather than Query keeps the test
|
||||
// deterministic regardless of seeded telemetry.
|
||||
const dropdown = page.getByTestId('variable-select').nth(0);
|
||||
await dropdown.click();
|
||||
await page.getByRole('option', { name: 'mq-kafka' }).click();
|
||||
|
||||
test('TC-03 a single-select renders its configured default', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await open(page);
|
||||
await expect(
|
||||
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/variables=.*mq-kafka/);
|
||||
|
||||
await expect
|
||||
.poll(() => readVariableSelection(page, 'cu_region'))
|
||||
.toContain('eu-west');
|
||||
});
|
||||
|
||||
test('TC-04 "Only" on a row collapses an ALL selection to that one value', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await open(page);
|
||||
|
||||
await pickVariableValues(page, 'cu_service', ['payments']);
|
||||
|
||||
const shown = await readVariableSelection(page, 'cu_service');
|
||||
expect(shown).toContain('payments');
|
||||
expect(shown).not.toContain('checkout');
|
||||
});
|
||||
|
||||
test('TC-05 checking a second value adds it to the selection', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await open(page);
|
||||
|
||||
await pickVariableValues(page, 'cu_service', ['payments', 'cart']);
|
||||
|
||||
// Read from the open list: the closed control shows one tag plus a "+N", so it
|
||||
// cannot tell a two-value selection from a one-value one.
|
||||
expect(await readCheckedOptions(page, 'cu_service')).toEqual(
|
||||
expect.arrayContaining(['payments', 'cart']),
|
||||
await dropdown.click();
|
||||
await expect(page.getByRole('option', { name: 'mq-kafka' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('TC-06 a multi-select edit is committed by closing the dropdown, not per toggle', async ({
|
||||
test('TC-03 multi-select renders chips and URL encodes array', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await open(page);
|
||||
await pickVariableValues(page, 'cu_service', ['checkout']);
|
||||
// URL state seeds adservice + cartservice as initial selection; this also
|
||||
// guarantees the URL contains the encoded array so we can assert on it
|
||||
// without relying on the seeded server-side selection rendering identically
|
||||
// across reloads.
|
||||
await gotoVariablesDashboard(page, {
|
||||
cu_services: ['adservice', 'cartservice'],
|
||||
});
|
||||
|
||||
// Toggle a second value on, and read the closed control's committed text BEFORE
|
||||
// closing: it must still show only what was committed on the previous close.
|
||||
await openVariableDropdown(page, 'cu_service');
|
||||
await optionRow(page, 'cart').click();
|
||||
await expect(anyDropdown(page).first()).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Remove tag adservice' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Remove tag cartservice' }),
|
||||
).toBeVisible();
|
||||
|
||||
await closeVariableDropdown(page);
|
||||
expect(await readCheckedOptions(page, 'cu_service')).toEqual(
|
||||
expect.arrayContaining(['checkout', 'cart']),
|
||||
await expect(page).toHaveURL(/adservice/);
|
||||
await expect(page).toHaveURL(/cartservice/);
|
||||
});
|
||||
|
||||
test('TC-04 removing a chip updates URL', async ({ authedPage: page }) => {
|
||||
await gotoVariablesDashboard(page, {
|
||||
cu_services: ['adservice', 'cartservice'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Remove tag adservice' }),
|
||||
).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Remove tag adservice' }).click();
|
||||
|
||||
// Removing a chip on a multi-select expands the dropdown; URL state
|
||||
// only commits when the dropdown closes (onDropdownVisibleChange =>
|
||||
// false). The CustomMultiSelect swallows Escape, so click outside the
|
||||
// dropdown to dismiss it.
|
||||
await page.locator('img[alt="dashboard-img"]').click();
|
||||
await expect(page.getByRole('listbox')).toBeHidden();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Remove tag adservice' }),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Remove tag cartservice' }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/variables=/);
|
||||
await expect(page).not.toHaveURL(/adservice/);
|
||||
});
|
||||
|
||||
test('TC-05 ALL option on a Custom variable', async ({ authedPage: page }) => {
|
||||
await gotoVariablesDashboard(page, { cu_env_all: 'otel-demo' });
|
||||
|
||||
// $cu_env_all (nth(1)) — multi-select Custom with showALLOption: true,
|
||||
// so the dropdown exposes an "ALL" toggle alongside the static options.
|
||||
const dropdown = page.getByTestId('variable-select').nth(1);
|
||||
await expect(
|
||||
dropdown.locator('.ant-select-selection-item', {
|
||||
hasText: 'otel-demo',
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await dropdown.click();
|
||||
await page.getByRole('option', { name: 'ALL' }).click();
|
||||
|
||||
// When ALL is selected, the multi-select renders an "ALL" badge in a
|
||||
// custom container (not the standard .ant-select-selection-item), so
|
||||
// match on the option's checked state inside the dropdown listbox
|
||||
// rather than on the closed-state chip.
|
||||
await expect(page.getByRole('option', { name: 'ALL' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
await expect(page).toHaveURL(/variables=/);
|
||||
});
|
||||
|
||||
test('TC-07 a selection survives a reload', async ({ authedPage: page }) => {
|
||||
await open(page);
|
||||
await pickVariableValues(page, 'cu_service', ['cart']);
|
||||
test('TC-06 textbox variable update propagates to URL', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// Locate by the testid wrapping a stable id, since `input[value="..."]`
|
||||
// becomes stale the moment we fill('') the field.
|
||||
await expect(page.locator('input[value="otel-demo"]')).toBeVisible();
|
||||
const tb = page.getByPlaceholder('Enter value').first();
|
||||
await tb.click();
|
||||
await tb.fill('');
|
||||
await tb.fill('production');
|
||||
await tb.press('Enter');
|
||||
|
||||
await expect(page.locator('input[value="production"]')).toBeVisible();
|
||||
await expect(page).toHaveURL(/variables=.*production/);
|
||||
});
|
||||
|
||||
test('TC-07 cascading: child variable listbox opens after parent change', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page, { q_env: 'otel-demo' });
|
||||
|
||||
// q_service (nth(4)) is cascaded from q_env (nth(3)).
|
||||
const child = page.getByTestId('variable-select').nth(4);
|
||||
await child.click();
|
||||
|
||||
// known behaviour: the child's option list requires seeded telemetry —
|
||||
// the bootstrap stack has none, so we only assert that the listbox
|
||||
// renders without crashing rather than checking specific options.
|
||||
await expect(page.getByRole('listbox').first()).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(page).toHaveURL(/otel-demo/);
|
||||
});
|
||||
|
||||
test('TC-08 URL deep-link restores variable state on hard reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page, { cu_env_all: 'mq-kafka' });
|
||||
|
||||
const dropdown = page.getByTestId('variable-select').nth(1);
|
||||
await expect(
|
||||
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: /dashboard-icon detail-variables-suite/,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page).toHaveURL(/variables=%257B/);
|
||||
});
|
||||
|
||||
// ─── Deep coverage ───────────────────────────────────────────────────────
|
||||
|
||||
test('TC-09 ALL → specific value → ALL round-trip preserves URL state', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
const dropdown = page.getByTestId('variable-select').nth(1); // cu_env_all
|
||||
|
||||
// Seed defaults to ALL — open, pick a specific value, assert URL.
|
||||
await dropdown.click();
|
||||
await page.getByRole('option', { name: 'mq-kafka' }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page).toHaveURL(/mq-kafka/);
|
||||
|
||||
// Re-open, switch back to ALL — URL must update again.
|
||||
await dropdown.click();
|
||||
const allOption = page.getByRole('option', { name: 'ALL' });
|
||||
await allOption.click();
|
||||
await expect(allOption).toHaveAttribute('aria-selected', 'true');
|
||||
await page.keyboard.press('Escape');
|
||||
// `mq-kafka` should no longer appear in the URL after reverting to ALL.
|
||||
await expect(page).not.toHaveURL(/mq-kafka/);
|
||||
});
|
||||
|
||||
test('TC-10 two variables changed in sequence both encode in URL', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// cu_single — pick `production`.
|
||||
const single = page.getByTestId('variable-select').nth(0);
|
||||
await single.click();
|
||||
await page.getByRole('option', { name: 'production' }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page).toHaveURL(/production/);
|
||||
|
||||
// q_service — open the multi-select, dismiss without picking. The URL
|
||||
// should still contain the previous selection.
|
||||
const cuServices = page.getByTestId('variable-select').nth(2);
|
||||
await cuServices.click();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page).toHaveURL(/production/);
|
||||
await expect(page).toHaveURL(/cu_single/);
|
||||
});
|
||||
|
||||
test('TC-11 navigating away and back preserves the URL-encoded state', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page, { cu_single: 'mq-kafka' });
|
||||
const dropdown = page.getByTestId('variable-select').nth(0);
|
||||
await expect(
|
||||
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
|
||||
).toBeVisible();
|
||||
const stateUrl = page.url();
|
||||
|
||||
// Leave to the list, come back via browser back — URL is restored.
|
||||
// `dispatchEvent('click')` — the expanded sidenav intercepts pointer
|
||||
// events at the breadcrumb's center, defeating even `force: true`.
|
||||
// Dispatching the click directly on the DOM node bypasses hit testing.
|
||||
await page
|
||||
.getByRole('button', { name: 'Dashboard /' })
|
||||
.dispatchEvent('click');
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(stateUrl);
|
||||
await expect(
|
||||
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── TBD coverage — placeholders to fill in when each feature lands ──────
|
||||
//
|
||||
// Each `test.skip` below marks a behaviour the spec does NOT yet exercise.
|
||||
// They are intentional gaps, not bugs — when the feature ships or the seed
|
||||
// gains telemetry, replace `test.skip` with `test`, drop the comment, and
|
||||
// implement.
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-12 Custom variable without a default prompts user to select a value', async () => {
|
||||
// Requires extending variables-dashboard.json with a Custom variable
|
||||
// that has no `selectedValue` and no `allSelected`. The UI should
|
||||
// render the dropdown empty/"Select value" until a user picks.
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-13 Query variable with pre-seeded selectedValue renders without backend resolution', async () => {
|
||||
// Requires extending variables-dashboard.json with a Query variable
|
||||
// that ships with `selectedValue` already populated — the UI should
|
||||
// trust the seed and not block on a query.
|
||||
});
|
||||
|
||||
test('TC-14 multi-select Query variable without telemetry shows an empty option list', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// q_service is the only multi-select Query in the seed (nth(4) in
|
||||
// the dropdown order). Without telemetry the option list is empty —
|
||||
// assert the empty-state explicitly.
|
||||
const child = page.getByTestId('variable-select').nth(4);
|
||||
await child.click();
|
||||
const listbox = page.getByRole('listbox').first();
|
||||
await expect(listbox).toBeVisible();
|
||||
await expect(listbox.getByRole('option')).toHaveCount(0);
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('TC-15 Dynamic variable resolves a seeded namespace value', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// d_namespace's `dynamicVariablesAttribute` is `k8s.namespace.name`
|
||||
// over the `metrics` source. The bootstrap OTel collector ingests
|
||||
// the golden dataset which tags every resource with
|
||||
// `k8s.namespace.name=signoz-<service>` for 8 distinct services.
|
||||
// SigNoz's `signoz_metrics.distributed_metadata` table is populated
|
||||
// naturally by the collector's signozclickhousemetrics exporter, and
|
||||
// `/api/v1/fields/values?signal=metrics&name=k8s.namespace.name`
|
||||
// surfaces the values so the Dynamic variable auto-resolves.
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// d_namespace is the 6th dropdown variable in DOM order. The
|
||||
// closed-state of the combobox renders the auto-resolved value
|
||||
// inline next to the variable name. Match any of the 8 seeded
|
||||
// namespaces — ordering depends on the backend sort, so we accept
|
||||
// whichever it returns first.
|
||||
const dynamic = page.getByTestId('variable-select').nth(5);
|
||||
await expect(dynamic).toContainText(/signoz-\w+/, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-16 changing a variable referenced in a panel query refetches the panel data', async () => {
|
||||
// $service.name and $deployment.environment are referenced by APM
|
||||
// panel queries. Asserting that a variable change triggers a
|
||||
// query_range refetch with the new substitution requires either
|
||||
// seeded telemetry or a network-request listener that confirms the
|
||||
// outbound query body contains the new value. Defer until the
|
||||
// chart-data assertion path is in place.
|
||||
});
|
||||
|
||||
test('TC-17 variable bar order matches the `order` field in dashboard JSON', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// Expected order matches the `order` field in variables-dashboard.json.
|
||||
const expected = [
|
||||
'$tb_env',
|
||||
'$tb_service',
|
||||
'$cu_single',
|
||||
'$cu_env_all',
|
||||
'$cu_services',
|
||||
'$q_env',
|
||||
'$q_service',
|
||||
'$d_namespace',
|
||||
];
|
||||
const allText = await page.locator('text=/^\\$\\w+$/').allInnerTexts();
|
||||
const actual = allText.filter((t) => /^\$\w+$/.test(t));
|
||||
expect(actual.slice(0, expected.length)).toEqual(expected);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-18 reordering variables via drag persists to the dashboard JSON', async () => {
|
||||
// The Configure → Variables tab supports drag handles. After a
|
||||
// reorder, the persisted `order` fields should update and the
|
||||
// variables bar should re-render in the new order.
|
||||
});
|
||||
|
||||
test('TC-19 variable removed via Configure disappears from the variables bar', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// `tb_service` (textbox, no dependents) — easiest to remove cleanly.
|
||||
await expect(page.getByText('$tb_service', { exact: true })).toBeVisible();
|
||||
|
||||
await page
|
||||
.locator('.dashboard-details .right-section')
|
||||
.getByTestId('show-drawer')
|
||||
.click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
|
||||
const nameCell = tabpanel.getByText('tb_service', { exact: true }).first();
|
||||
await nameCell.hover();
|
||||
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('tb_service', { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
|
||||
await expect(page.getByText('$tb_service', { exact: true })).toHaveCount(0);
|
||||
|
||||
// Restore the persisted variable so subsequent serial-mode tests still pass.
|
||||
const token = await authToken(page);
|
||||
await page.request.put(`/api/v1/dashboards/${varDashboardId}`, {
|
||||
data: { ...variablesTemplate, title: 'detail-variables-suite' },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
await page.reload();
|
||||
await expect(variablesBar(page)).toBeVisible();
|
||||
|
||||
await expect
|
||||
.poll(() => readVariableSelection(page, 'cu_service'))
|
||||
.toContain('cart');
|
||||
});
|
||||
|
||||
test('TC-08 a text variable keeps a typed value across a reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await open(page);
|
||||
|
||||
const input = variableTextInput(page, 'tb_env');
|
||||
await input.fill('staging');
|
||||
await input.blur();
|
||||
|
||||
await page.reload();
|
||||
await expect(variableTextInput(page, 'tb_env')).toHaveValue('staging');
|
||||
});
|
||||
|
||||
test('TC-09 switching a single-select replaces its value', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await open(page);
|
||||
|
||||
await variableControl(page, 'cu_region').click();
|
||||
await optionRow(page, 'us-east').click();
|
||||
|
||||
await expect
|
||||
.poll(() => readVariableSelection(page, 'cu_region'))
|
||||
.toContain('us-east');
|
||||
});
|
||||
|
||||
test('TC-10 variables that do not fit collapse into a "+N" overflow', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await open(page);
|
||||
|
||||
const overflow = page.getByRole('button', { name: /^\+\d+$/ });
|
||||
await expect(overflow).toBeVisible();
|
||||
|
||||
await overflow.hover();
|
||||
await expect(hiddenVariablesTooltip(page)).toBeVisible();
|
||||
await expect(page.getByText('$tb_service', { exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user