Compare commits

...

7 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
Ashwin Bhatkal
762ea483db test(e2e): harden the V2 helpers against what the real UI does
Everything here was a spec failing for a reason no reading of the component would
have given up:

  - clicking an option ROW does not toggle its checkbox, so a second value silently
    never joined the commit — and an assertion on the closed control could not see
    it, because the control shows one tag plus a "+N". Hence readCheckedOptions,
    which reads the open list;
  - single-select renders a different dropdown (`.custom-select-dropdown`) whose rows
    have no `.option-label-text`, so row matching needs both shapes;
  - Escape only closes the list while the control holds focus, which a row click
    moves — closing now falls back to a click outside, which is what a user does;
  - the tooltip primitive renders its content twice, so an unscoped locator is a
    strict-mode violation, not a missing element;
  - `beforeAll` runs once per worker and the v2 API rejects a duplicate name, so
    seed titles carry the worker index.

Verified by running the smoke spec and the variables spec together on two workers.
2026-08-06 00:12:55 +05:30
Ashwin Bhatkal
6d60ea0c5e test(e2e): seed and drive V2 dashboards from a spec
Helpers for the page that now serves /dashboard/:id. Seeding goes through the v2 API
with the Perses-shape spec the UI itself writes, so a spec asserts against data it
placed rather than against migration output or whatever telemetry the stack happens
to hold — the fixture here is text + custom variables only, whose option lists come
from the definition and are therefore fixed.

The helpers encode the interaction contracts that cost the most to rediscover, each
found by driving the real UI:

  - a multi-select commits when the dropdown CLOSES, not per toggle;
  - an ALL selection renders as an overlay that is a SIBLING of the element carrying
    the testid, so reading it from the control returns empty;
  - opening an ALL selection checks every option, so clicking the wanted value
    UNchecks it — the row's "Only" button is the deterministic way in, and the clear
    icon is deliberately absent while the draft is all;
  - hovering a row reveals Only / Toggle, whose text joins the row's accessible name,
    so rows are matched on their label element instead;
  - the bar collapses variables past the first into a "+N" overflow at the config's
    1280px viewport, hence WIDE_VIEWPORT.

01-smoke.spec.ts exercises exactly this path — seed, open, read, pick, commit — so the
foundation is covered by a test rather than asserted in a description.
2026-08-05 23:50:57 +05:30
Ashwin Bhatkal
dc248794d9 test(e2e): stop .env.local clobbering an exported env var
The config loaded .env then .env.local with override:true, so a generated file beat
the process environment — the opposite of the comment above it, and it silently sent
a run at whatever stale port .env.local still held. dotenv never overwrites a var
that is already set, so loading .env.local first and .env second gives the intended
precedence: real env > .env.local > .env.
2026-08-05 23:50:57 +05:30
Ashwin Bhatkal
bd2e0aaa18 test(e2e): one registry for parked specs, guarded against rot
The dashboards suite was excluded by a blanket `testIgnore: ['**/tests/dashboards/**']`,
and inside it 26 tests had also been left as `test.skip` placeholders — invisible,
since the files were not collected at all. Two more sit in trace-details, skipped
inside a spec that IS collected.

Specs are now parked one by one in parked-specs.json, each with a reason, and
`pnpm guard:specs` fails on a skipped, fixme'd or .only test in any spec that is
NOT parked, plus on a parked entry matching no file. So a spec is either running
and complete, or parked with a reason — and every entry removed by the PRs that
migrate these specs to V2 permanently closes the door behind it.

Collection is unchanged: same 8 specs, same 19 tests.
2026-08-05 23:26:14 +05:30
Ashwin Bhatkal
5a210c114d test(e2e): make the suite's own typecheck run
`pnpm typecheck` exited on a config error before it checked anything —
moduleResolution 'bundler' is invalid with module 'commonjs' (TS5095) — so type
errors in specs and helpers have been invisible. Fixing that surfaced a second
one: helpers evaluate code in the page (`document`, `window`, `localStorage`)
with no DOM lib. Both are one-liners; the suite now typechecks clean at 0 errors.

module moves to esnext rather than moduleResolution to node10, which oxlint's
tsgolint rejects outright.
2026-08-05 23:26:14 +05:30
9 changed files with 754 additions and 649 deletions

View File

@@ -0,0 +1,299 @@
import type { APIRequestContext, Locator, Page } from '@playwright/test';
import { expect } from '@playwright/test';
import { authToken } from './dashboards';
// Helpers for the V2 dashboard detail page (`DashboardPageV2`), which now serves
// /dashboard/:id unconditionally. The V1 helpers in ./dashboards.ts still cover
// seeding through the v1 API and the list page.
//
// Interaction contracts encoded here rather than in each spec:
// - a multi-select variable commits on dropdown CLOSE, not per toggle;
// - an ALL selection renders as an overlay reading "ALL", not as tags;
// - a variable is only settled once its options have arrived.
export const dashboardV2Path = (id: string): string => `/dashboard/${id}`;
/** Perses-style spec the v2 API stores. Only what the specs need is typed. */
export interface DashboardV2Spec {
display: { name: string; description?: string };
layouts: unknown[];
panels: Record<string, unknown>;
variables: unknown[];
[key: string]: unknown;
}
export const SCHEMA_VERSION = 'v6';
/** An empty but valid spec — the base every fixture spreads over. */
export function emptyV2Spec(name: string): DashboardV2Spec {
return { display: { name }, layouts: [], panels: {}, variables: [] };
}
// ─── Seeding through the v2 API ───────────────────────────────────────────
//
// Specs seed the shape they assert against, rather than relying on whatever the
// v1 -> v2 migration happens to produce or on telemetry that ambient data may or
// may not contain. Migration output is covered on its own, from the v1 fixtures.
export async function createDashboardV2ViaApi(
page: Page,
name: string,
spec?: Partial<DashboardV2Spec>,
): Promise<string> {
const token = await authToken(page);
const res = await page.request.post('/api/v2/dashboards', {
data: {
name,
schemaVersion: SCHEMA_VERSION,
tags: [],
// `name` wins over any display name the fixture carries, so a fixture can be
// seeded twice under two titles and each spec can still find its own.
spec: {
...emptyV2Spec(name),
...spec,
display: { ...spec?.display, name },
},
},
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
throw new Error(
`POST /api/v2/dashboards ${res.status()}: ${await res.text()}`,
);
}
const body = (await res.json()) as { data?: { id?: string } };
const id = body.data?.id;
if (!id) {
throw new Error(
`POST /api/v2/dashboards returned no id: ${JSON.stringify(body)}`,
);
}
return id;
}
export async function getDashboardV2(
page: Page,
id: string,
): Promise<{ spec: DashboardV2Spec; [key: string]: unknown }> {
const token = await authToken(page);
const res = await page.request.get(`/api/v2/dashboards/${id}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
throw new Error(
`GET /api/v2/dashboards/${id} ${res.status()}: ${await res.text()}`,
);
}
const body = (await res.json()) as {
data: { spec: DashboardV2Spec; [key: string]: unknown };
};
return body.data;
}
export async function deleteDashboardV2ViaApi(
request: APIRequestContext,
id: string,
token: string,
): Promise<void> {
await request.delete(`/api/v2/dashboards/${id}`, {
headers: { Authorization: `Bearer ${token}` },
});
}
// ─── Variables bar ────────────────────────────────────────────────────────
export const variablesBar = (page: Page): Locator =>
page.getByTestId('dashboard-variables-bar');
/** The pill for one variable: its name, the control, and (while loading) a spinner. */
export const variablePill = (page: Page, name: string): Locator =>
page.getByTestId(`variable-${name}`);
/** List variables (query / custom / dynamic) — the select control. */
export const variableControl = (page: Page, name: string): Locator =>
page.getByTestId(`variable-select-${name}`);
/** Text variables — a plain input, not a select. */
export const variableTextInput = (page: Page, name: string): Locator =>
page.getByTestId(`variable-input-${name}`);
/**
* The bar collapses variables that do not fit into a "+N" button. At the config's
* 1280px viewport that starts with the second variable, so a spec asserting on
* several pills at once must widen the viewport:
*
* test.use({ viewport: WIDE_VIEWPORT });
*/
export const WIDE_VIEWPORT = { width: 1920, height: 1080 };
/**
* The overflow ("+N") tooltip listing the collapsed variables. `.first()` because the
* tooltip primitive renders its content twice — once visible, once as an a11y copy —
* so an unscoped locator is a strict-mode violation rather than a missing element.
*/
export const hiddenVariablesTooltip = (page: Page): Locator =>
page.getByTestId('hidden-variables-tooltip').first();
/** Resolved when the variable's options have arrived and its spinner is gone. */
export async function awaitVariableSettled(
page: Page,
name: string,
): Promise<void> {
await expect(variablePill(page, name)).toBeVisible();
await expect(page.getByTestId(`variable-loading-${name}`)).toBeHidden();
}
/**
* What a list variable's closed control shows — "ALL" for an ALL selection, else its
* tags. Asserts the control exists first: a missing one (wrong name, or a text
* variable, which uses {@link variableTextInput}) otherwise hangs until the test
* times out with nothing to point at.
*/
export async function readVariableSelection(
page: Page,
name: string,
): Promise<string> {
const pill = variablePill(page, name);
await expect(pill).toBeVisible();
// The ALL overlay sits in the control's wrapper, as a SIBLING of the element
// carrying the testid — scope from the pill, or an ALL selection reads as empty.
const allOverlay = pill.locator('.all-text');
if ((await allOverlay.count()) > 0 && (await allOverlay.isVisible())) {
return (await allOverlay.textContent())?.trim() ?? '';
}
return (await variableControl(page, name).innerText()).trim();
}
/** The open option list, whichever control opened it. */
export const anyDropdown = (page: Page): Locator =>
page.locator('.custom-multiselect-dropdown, .custom-select-dropdown');
export async function openVariableDropdown(
page: Page,
name: string,
): Promise<void> {
await awaitVariableSettled(page, name);
await variableControl(page, name).click();
await expect(anyDropdown(page)).toBeVisible();
}
/**
* Close the open dropdown, which is what commits a multi-select edit. Pressing
* Escape leaves the control focused without re-opening it, unlike clicking away.
*/
export async function closeVariableDropdown(page: Page): Promise<void> {
// Escape closes it when the control still holds focus, which a row click can move.
// Falling back to a click outside covers that, and is what a user does anyway —
// either way the close is what commits the edit.
await page.keyboard.press('Escape');
if (await anyDropdown(page).first().isVisible()) {
await page.getByTestId('dashboard-title').click();
}
await expect(anyDropdown(page).first()).toBeHidden();
}
const escapeForRegExp = (value: string): string =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/**
* One option row in the open dropdown, matched on its label element rather than the
* row's accessible name: hovering reveals the Only / Toggle buttons, whose text joins
* that name, so a name-based locator stops matching halfway through an interaction.
*/
export function optionRow(page: Page, value: string): Locator {
const exact = new RegExp(`^${escapeForRegExp(value)}$`);
// Multi-select rows carry a `.option-label-text` and, once hovered, Only / Toggle
// buttons whose text would break an exact match on the row itself. Single-select
// rows have neither — just the label — so each shape needs its own matcher.
const multi = page
.locator('.custom-multiselect-dropdown .option-item')
.filter({ has: page.locator('.option-label-text', { hasText: exact }) });
const single = page
.locator('.custom-select-dropdown .option-item')
.filter({ hasText: exact });
return multi.or(single);
}
export async function pickVariableValues(
page: Page,
name: string,
values: string[],
): Promise<void> {
await openVariableDropdown(page, name);
const [first, ...rest] = values;
// Start from the row's "Only" button rather than its checkbox: an ALL selection
// opens with every option checked, so a click would UNcheck the wanted value and
// leave the rest selected. "Only" collapses to exactly this option either way,
// and the clear icon is deliberately unavailable while the draft is all.
// Wait for each target to be visible before acting: the dropdown re-renders as
// options resolve, and a click on a row that is still arriving (or has just been
// replaced) hangs until the test times out.
const firstRow = optionRow(page, first);
await expect(firstRow).toBeVisible();
await firstRow.hover();
const onlyButton = firstRow.locator('.only-btn');
await expect(onlyButton).toBeVisible();
await onlyButton.click();
// Additional values then add to that selection. Click the row's checkbox, not the
// row: the row body carries no toggle handler, so clicking it leaves the option
// unchecked and the value silently absent from the commit.
for (const value of rest) {
const row = optionRow(page, value);
await expect(row).toBeVisible();
const checkbox = row.locator('.option-checkbox');
if ((await checkbox.count()) > 0) {
await checkbox.first().click();
} else {
await row.click();
}
}
await closeVariableDropdown(page);
}
/** Type a value the option list does not offer, and commit it. */
export async function typeVariableValue(
page: Page,
name: string,
value: string,
): Promise<void> {
await openVariableDropdown(page, name);
await page.keyboard.type(value);
const dropdown = page.locator('.custom-multiselect-dropdown');
await dropdown.getByText(value, { exact: true }).first().click();
await closeVariableDropdown(page);
}
// ─── Panels and sections ──────────────────────────────────────────────────
export const panelByTitle = (page: Page, title: string): Locator =>
page.locator('[data-panel-id]').filter({ hasText: title });
export const sectionByName = (page: Page, name: string): Locator =>
page.locator('[data-section-id]').filter({ hasText: name });
/** Resolved when no panel on the page is still fetching. */
export async function awaitPanelsSettled(page: Page): Promise<void> {
await expect(page.getByTestId('panel-refetching')).toHaveCount(0);
}
/**
* The values currently checked in a multi-select's list, read from the open dropdown —
* the closed control shows at most one tag plus a "+N", so it cannot confirm a
* multi-value selection on its own. Excludes the aggregate ALL row.
*/
export async function readCheckedOptions(
page: Page,
name: string,
): Promise<string[]> {
await openVariableDropdown(page, name);
const labels = await page
.locator(
'.custom-multiselect-dropdown .option-item[aria-selected="true"]:not(.all-option) .option-label-text',
)
.allInnerTexts();
await closeVariableDropdown(page);
return labels.map((label) => label.trim());
}

View File

@@ -21,6 +21,7 @@
"fmt:check": "oxfmt --check .",
"lint": "oxlint .",
"lint:fix": "oxlint . --fix",
"guard:specs": "node scripts/guard-specs.mjs",
"typecheck": "tsc --noEmit"
},
"keywords": [

View File

@@ -0,0 +1,20 @@
{
"$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",
"**/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/95-edge-cases.spec.ts",
"**/tests/trace-details/preview-fields.spec.ts"
],
"reasons": {
"**/tests/dashboards/**": "Written against V1 dashboard behaviour; the V1 -> V2 migration changed what they assert. Being rewritten area by area — see the E0-E8 plan.",
"**/tests/trace-details/preview-fields.spec.ts": "Entirely `describe.skip` since it was added: the hover card's preview field needs seeded telemetry the suite does not provide yet."
}
}

View File

@@ -2,22 +2,23 @@ import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'path';
// .env holds user-provided defaults (staging creds).
// .env.local is written by tests/e2e/bootstrap/setup.py when the pytest
// lifecycle brings the backend up locally; override=true so local-backend
// coordinates win over any stale .env values. Subprocess-injected env
// (e.g. when pytest shells out to `pnpm test`) still takes priority —
// dotenv doesn't touch vars that are already set in process.env.
import parkedSpecs from './parked-specs.json';
// Precedence: real env > .env.local > .env. dotenv never overwrites a var that is
// already set, so loading in that order gives local-backend coordinates (.env.local,
// written by bootstrap/setup.py) priority over the staging defaults in .env, while an
// explicitly exported var still wins over both — which is what lets a run be pointed
// at another environment without editing a generated file.
dotenv.config({ path: path.resolve(__dirname, '.env.local') });
dotenv.config({ path: path.resolve(__dirname, '.env') });
dotenv.config({ path: path.resolve(__dirname, '.env.local'), override: true });
export default defineConfig({
testDir: './tests',
// Temporarily excluded: the V1 -> V2 dashboard migration changes the
// behaviour the dashboards specs assert against, so they fail as written.
// Remove this once they are updated for the V2 dashboard.
testIgnore: ['**/tests/dashboards/**'],
// Parked specs, listed one by one with a reason in parked-specs.json — not a
// blanket glob, so nothing new can land inside an excluded directory unnoticed.
// `pnpm guard:specs` keeps this list and the suite honest.
testIgnore: parkedSpecs.specs,
// All Playwright output lands under artifacts/. One subdir per reporter
// plus results/ for per-test artifacts (traces/screenshots/videos).

View File

@@ -0,0 +1,91 @@
// Keeps the suite honest: a spec is either running and complete, or parked in
// parked-specs.json with a reason. Fails on a skipped/fixme'd/only test in a spec that
// is not parked, and on a parked entry that no longer matches anything.
//
// Run: pnpm guard:specs
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const parked = JSON.parse(
readFileSync(join(root, 'parked-specs.json'), 'utf8'),
);
/** Every *.spec.ts under tests/, repo-relative with forward slashes. */
function specFiles(dir) {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
return specFiles(full);
}
return entry.name.endsWith('.spec.ts') ? [full] : [];
});
}
/** A parked glob (`**/tests/x/y.spec.ts`) matched against an absolute path. */
function matchesGlob(glob, absolutePath) {
const ANY_DIRS = '\u0000';
const pattern = glob
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*\*\//g, ANY_DIRS)
// Single `*` never crosses a path separator; do this before expanding ANY_DIRS,
// whose replacement itself contains a `*`.
.replace(/\*/g, '[^/]*')
.split(ANY_DIRS)
.join('(?:.*/)?');
return new RegExp(`^${pattern}$`).test(absolutePath.split('\\').join('/'));
}
// Declaration form only — `test.skip(condition, reason)` inside a test body is a
// legitimate runtime guard, not a parked test.
const OFFENDERS = [
{ label: 'test.skip', re: /(?<![\w.])test\.skip\(\s*['"`]/g },
{ label: 'test.fixme', re: /(?<![\w.])test\.fixme\(\s*['"`]/g },
{ label: 'describe.skip', re: /describe\.skip\(/g },
{ label: 'describe.fixme', re: /describe\.fixme\(/g },
{ label: 'test.only', re: /(?<![\w.])test\.only\(/g },
{ label: 'describe.only', re: /describe\.only\(/g },
];
const testsDir = join(root, 'tests');
const files = statSync(testsDir, { throwIfNoEntry: false })
? specFiles(testsDir)
: [];
const failures = [];
for (const file of files) {
if (parked.specs.some((glob) => matchesGlob(glob, file))) {
continue;
}
const source = readFileSync(file, 'utf8');
for (const { label, re } of OFFENDERS) {
const hits = source.match(re);
if (hits) {
failures.push(
`${relative(root, file)}: ${hits.length} × ${label} — finish it, or park the spec in parked-specs.json with a reason`,
);
}
}
}
for (const glob of parked.specs) {
if (!files.some((file) => matchesGlob(glob, file))) {
failures.push(
`parked-specs.json: "${glob}" matches no spec — drop the stale entry`,
);
}
}
if (failures.length > 0) {
console.error(
`\nguard:specs failed\n\n${failures.map((f) => `${f}`).join('\n')}\n`,
);
process.exit(1);
}
// eslint-disable-next-line no-console
console.log(
`guard:specs ok — ${files.length - parked.specs.length}/${files.length} specs running, ${parked.specs.length} parked`,
);

View File

@@ -0,0 +1,48 @@
{
"$comment": "V2 (Perses-shape) dashboard spec seeded through POST /api/v2/dashboards. Only text and custom variables, so it resolves without telemetry: option lists are fixed by the definition, which keeps assertions on them deterministic. Query and dynamic variables are seeded per-spec alongside the telemetry they need.",
"spec": {
"display": { "name": "variables-v2", "description": "" },
"layouts": [],
"panels": {},
"variables": [
{
"kind": "TextVariable",
"spec": {
"name": "tb_env",
"display": { "name": "tb_env", "description": "Free-text environment" },
"value": "prod",
"constant": false
}
},
{
"kind": "ListVariable",
"spec": {
"name": "cu_service",
"display": { "name": "cu_service", "description": "Multi-select with ALL" },
"allowMultiple": true,
"allowAllValue": true,
"sort": "none",
"plugin": {
"kind": "signoz/CustomVariable",
"spec": { "customValue": "checkout,payments,cart" }
}
}
},
{
"kind": "ListVariable",
"spec": {
"name": "cu_region",
"display": { "name": "cu_region", "description": "Single-select with a default" },
"allowMultiple": false,
"allowAllValue": false,
"sort": "none",
"defaultValue": "eu-west",
"plugin": {
"kind": "signoz/CustomVariable",
"spec": { "customValue": "us-east,eu-west" }
}
}
}
]
}
}

View File

@@ -0,0 +1,110 @@
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import { authToken } from '../../../helpers/dashboards';
import {
createDashboardV2ViaApi,
dashboardV2Path,
deleteDashboardV2ViaApi,
pickVariableValues,
readVariableSelection,
variablePill,
variablesBar,
variableTextInput,
WIDE_VIEWPORT,
} from '../../../helpers/dashboards-v2';
import customVariables from '../../../testdata/variables-dashboard-v2.json';
// The foundation the other dashboards specs build on: seeding a V2 spec through the
// v2 API, opening it, and driving the variables bar. Everything here is deterministic
// — custom and text variables need no telemetry, so this spec cannot go red because
// of what the stack happens to hold.
test.use({ viewport: WIDE_VIEWPORT });
const seedIds = new Set<string>();
let dashboardId = '';
// Per worker: `beforeAll` runs once in each, and the v2 API rejects a duplicate name.
const SUITE_TITLE = `detail-smoke-suite-${process.env.TEST_WORKER_INDEX ?? '0'}`;
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
dashboardId = await createDashboardV2ViaApi(
page,
SUITE_TITLE,
customVariables.spec,
);
seedIds.add(dashboardId);
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) {
return;
}
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of seedIds) {
await deleteDashboardV2ViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
test.describe('Dashboard detail — V2 foundation', () => {
test('TC-01 a seeded V2 dashboard opens with its title and variables bar', async ({
authedPage: page,
}) => {
await page.goto(dashboardV2Path(dashboardId));
await expect(page.getByTestId('dashboard-title')).toContainText(SUITE_TITLE);
await expect(variablesBar(page)).toBeVisible();
for (const name of ['tb_env', 'cu_service', 'cu_region']) {
await expect(variablePill(page, name)).toBeVisible();
}
});
test('TC-02 a text variable renders the value it was seeded with', async ({
authedPage: page,
}) => {
await page.goto(dashboardV2Path(dashboardId));
await expect(variableTextInput(page, 'tb_env')).toHaveValue('prod');
});
test('TC-03 an ALL-enabled multi-select reads ALL until a value is picked', async ({
authedPage: page,
}) => {
await page.goto(dashboardV2Path(dashboardId));
// Seeded with allowAllValue and no default, so it resolves to ALL.
await expect
.poll(() => readVariableSelection(page, 'cu_service'))
.toBe('ALL');
// A multi-select commits when the dropdown closes, not per toggle.
await pickVariableValues(page, 'cu_service', ['checkout']);
await expect
.poll(() => readVariableSelection(page, 'cu_service'))
.toContain('checkout');
});
test('TC-04 a single-select renders its configured default', async ({
authedPage: page,
}) => {
await page.goto(dashboardV2Path(dashboardId));
await expect
.poll(() => readVariableSelection(page, 'cu_region'))
.toContain('eu-west');
});
});

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,9 +1,9 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"module": "esnext",
"moduleResolution": "bundler",
"lib": ["ES2020"],
"lib": ["ES2020", "DOM"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,