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