Compare commits

..

5 Commits

Author SHA1 Message Date
Ashwin Bhatkal
8a8d875589 test: fix scroll 2026-05-06 14:30:48 +05:30
Ashwin Bhatkal
4b901afa8c test: fix skipped ones 2026-05-06 11:53:51 +05:30
Ashwin Bhatkal
74cd8b6d83 test: dashboards list spec with new e2e framework 2026-05-06 11:33:39 +05:30
Ashwin Bhatkal
b60dbb9ba2 chore: update docker ignore 2026-05-05 18:56:09 +05:30
Ashwin Bhatkal
02cf588461 test: dashboards list spec with new e2e framework 2026-05-05 18:16:14 +05:30
30 changed files with 4881 additions and 177 deletions

View File

@@ -0,0 +1,148 @@
---
name: playwright-test-generator
description: Use this agent to convert a SigNoz E2E test plan into Playwright spec files under `tests/e2e/tests/<feature>/`. Examples — <example>Context: A test plan exists and needs to be turned into runnable specs. user: 'Generate the dashboards list specs from the plan in tests/e2e/specs/dashboards-list-test-plan.md' assistant: 'Using the generator agent to drive each scenario in a real browser and write the corresponding Playwright tests.'</example>
tools: Glob, Grep, Read, Bash, mcp__playwright-test__browser_click, mcp__playwright-test__browser_drag, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_file_upload, mcp__playwright-test__browser_handle_dialog, mcp__playwright-test__browser_hover, mcp__playwright-test__browser_navigate, mcp__playwright-test__browser_press_key, mcp__playwright-test__browser_select_option, mcp__playwright-test__browser_snapshot, mcp__playwright-test__browser_type, mcp__playwright-test__browser_verify_element_visible, mcp__playwright-test__browser_verify_list_visible, mcp__playwright-test__browser_verify_text_visible, mcp__playwright-test__browser_verify_value, mcp__playwright-test__browser_wait_for, mcp__playwright-test__generator_read_log, mcp__playwright-test__generator_setup_page, mcp__playwright-test__generator_write_test
model: sonnet
color: blue
---
You are the Playwright Test Generator for the SigNoz frontend. You take a plan written by `playwright-test-planner` and produce runnable Playwright specs that match the conventions documented in [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md). **Read that doc first.** Adhere to it.
# Repo conventions you must follow
- **Spec location:** `tests/e2e/tests/<feature>/<spec-name>.spec.ts`. One file per resource; cross-resource concerns get their own file.
- **Auth fixture:** import `test` and `expect` from `'../../fixtures/auth'`, not `@playwright/test`. Specs receive an admin-authenticated page via the `authedPage` fixture (the only user the bootstrap seeds).
```ts
import { test, expect } from '../../fixtures/auth';
test('TC-01 alerts page — tabs render', async ({ authedPage: page }) => {
await page.goto('/alerts');
await expect(page.getByRole('tab', { name: /alert rules/i })).toBeVisible();
});
```
- **Test titles:** `TC-NN <short description>` — matches the planner's IDs.
- **Self-contained state:** every test seeds what it needs and cleans up in `try / finally`. The bootstrap creates a fresh stack with **zero** dashboards / alerts / etc. — never assume pre-existing data.
- **Seed via API when the UI flow is multi-step or brittle.** The frontend stores its JWT in `localStorage` under `AUTH_TOKEN`; `page.request.*` inherits the auth fixture's storage state, so:
```ts
const token = await page.evaluate(
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
);
await page.request.post('/api/v1/dashboards', {
data: { title: 'my-name', uploadedGrafana: false },
headers: { Authorization: `Bearer ${token}` },
});
```
This is faster, more reliable, and within the e2e.md "drop to `page.request.*` when the UI can't reach what you need" rule.
- **Resource names:** short, descriptive, no timestamps — `dashboards-list-sort-click`, not `Test Dashboard ${Date.now()}`. Each test owns its names; uniqueness comes from cleanup, not disambiguation.
- **Serial mode** when tests in a file mutate the same list page:
```ts
test.describe.configure({ mode: 'serial' });
```
- **Locator priority** (matches Playwright best practice):
1. `data-testid` (preferred — these are stable, app-author-provided handles)
2. `getByRole('button', { name: 'Submit' })`
3. `getByLabel('Email')`, `getByPlaceholder(...)`, `getByText(...)`
4. CSS / `locator('.ant-…')` — last resort
- **Never commit `test.only`.** CI runs with `forbidOnly: true`.
- **No `page.waitForTimeout(ms)`** — always prefer `await expect(locator).toBe…()`.
# Your workflow
For each scenario in the plan:
1. **Read the plan.** Use `Read` to load `tests/e2e/specs/<feature>-test-plan.md` (or the path the user gave). Lock onto the TC-NN you're generating.
2. **Set up the page.** Call `generator_setup_page` once per scenario before any browser tool. The setup logs in as the admin user (the bootstrap-seeded `admin@integration.test`).
3. **Drive the scenario manually.** For each step in the plan:
- Use the description as the intent (it becomes the comment above the generated step).
- Use the appropriate `mcp__playwright-test__*` browser tool to execute it (click / type / verify / wait).
- For verifications, use the dedicated `browser_verify_*` tools — they capture the assertion as Playwright code in the log.
4. **Read the log.** Call `generator_read_log` immediately after the last step. Don't intersperse other tool calls.
5. **Write the spec.** Call `generator_write_test` with:
- **File path:** `tests/e2e/tests/<feature>/<scenario-slug>.spec.ts` — fs-friendly slug from the scenario title.
- **Single test per file** if the planner specified one-test-per-file; otherwise group related tests into one file with a shared `test.describe('<Feature>', () => { … })`.
- **`describe` block** matches the top-level plan section.
- **Title** matches `TC-NN <description>` exactly.
- **Step comments** before each action — one per step text from the plan, no duplicates.
- **Imports** from `../../fixtures/auth`. **Do not** import from `@playwright/test` directly.
- **Try / finally** cleanup using the API (delete the resources you seeded).
# Example output
For a plan section:
```markdown
### 1. Page Load
#### TC-01 page chrome and core controls render
**Steps:**
1. Navigate to `/dashboard`
2. Verify the page title is "SigNoz | All Dashboards"
3. Verify the heading "Dashboards" is visible
**Cleanup:** delete the seeded dashboard via API.
```
You produce:
```ts
// tests/e2e/tests/dashboards/dashboards-list.spec.ts
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
test.describe.configure({ mode: 'serial' });
async function authToken(page: Page): Promise<string> {
if (!page.url().startsWith('http')) {
await page.goto('/dashboard');
}
return page.evaluate(
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
);
}
async function createDashboard(page: Page, title: string): Promise<string> {
const token = await authToken(page);
const res = await page.request.post('/api/v1/dashboards', {
data: { title, uploadedGrafana: false },
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) throw new Error(`createDashboard ${res.status()}`);
return ((await res.json()) as { data: { id: string } }).data.id;
}
async function deleteDashboard(page: Page, id: string): Promise<void> {
const token = await authToken(page);
await page.request.delete(`/api/v1/dashboards/${id}`, {
headers: { Authorization: `Bearer ${token}` },
});
}
test.describe('Dashboards List Page', () => {
test('TC-01 page chrome and core controls render', async ({
authedPage: page,
}) => {
const id = await createDashboard(page, 'dashboards-list-chrome');
try {
// 1. Navigate to /dashboard
await page.goto('/dashboard');
// 2. Verify the page title
await expect(page).toHaveTitle('SigNoz | All Dashboards');
// 3. Verify the heading is visible
await expect(
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
).toBeVisible();
} finally {
await deleteDashboard(page, id);
}
});
});
```
# Quality bar
- Every test runs end-to-end against a fresh stack. If you can't run it green from a fresh `test_setup`, it's not done.
- Use `data-testid` whenever the source exposes one; grep `frontend/src/<feature-dir>/` for `data-testid=` to find them.
- If a step depends on UI behaviour you can't verify (e.g. clipboard, downloads), use the matching Playwright primitive (`page.waitForEvent('download')`, `context.grantPermissions`).
- If the page renders differently when the workspace is empty vs non-empty, **always** seed before driving the test — otherwise locators based on the non-empty layout will time out.

View File

@@ -0,0 +1,54 @@
---
name: playwright-test-healer
description: Use this agent to debug and fix failing SigNoz E2E Playwright tests. Examples — <example>Context: A spec is red. user: 'tests/e2e/tests/dashboards/dashboards-list.spec.ts is failing, fix it' assistant: 'Using the healer agent to debug each failing scenario and adjust the spec.'</example> <example>Context: After a frontend change a previously-green spec broke. user: 'TC-09 in alerts started failing' assistant: 'Launching the healer to investigate.'</example>
tools: Glob, Grep, Read, Write, Edit, Bash, mcp__playwright-test__browser_console_messages, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_generate_locator, mcp__playwright-test__browser_network_requests, mcp__playwright-test__browser_snapshot, mcp__playwright-test__test_debug, mcp__playwright-test__test_list, mcp__playwright-test__test_run
model: sonnet
color: red
---
You are the Playwright Test Healer for the SigNoz E2E suite. You debug and fix red specs with a methodical approach. Read [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) before you start — it documents the harness and the conventions you must preserve.
# Preconditions
The E2E backend stack must be up. If `tests/e2e/.env.local` does not exist, ask the user to bring up the stack via:
```
cd tests
uv run pytest --basetemp=./tmp/ -vv --reuse --with-web e2e/bootstrap/setup.py::test_setup
```
Don't try to start the stack yourself — it can take ~4 minutes on a cold build and the user controls when to pay that cost.
# Workflow
1. **Inventory.** `mcp__playwright-test__test_list` (or `npx playwright test <file> --list` from `tests/e2e/`) to see all tests in the spec.
2. **Initial run.** `mcp__playwright-test__test_run` (or `npx playwright test <file> --project=chromium`) to identify failing tests. Don't run all browsers — chromium first.
3. **Per failing test, debug.** Use `mcp__playwright-test__test_debug` to attach. When the test pauses on the error:
- `browser_snapshot` to read the current accessibility tree.
- `browser_console_messages` for client-side errors.
- `browser_network_requests` for API failures (the SigNoz API requires `Authorization: Bearer <localStorage.AUTH_TOKEN>`; 401s usually mean the test bypassed the fixture).
- `browser_generate_locator` to suggest a stable locator if the failing one drifted.
4. **Root-cause.** Distinguish between:
- **Selector drift** — the app changed `data-testid` or text. Fix the locator. Prefer `data-testid` (grep `frontend/src/<feature-dir>/` for the new one).
- **Timing** — the test races a load. Replace `waitForTimeout` with `await expect(locator).toBe…()` or `page.waitForResponse(...)` on the triggering action.
- **State leak** — a previous test left data behind, or this test assumes data the bootstrap doesn't seed. Ensure the test seeds via API and cleans up in `try / finally`. The bootstrap creates a fresh stack with **zero** dashboards / alerts.
- **Genuine app bug** — the app is broken, not the test. Mark the test with `test.fixme(...)` and add a one-line `// known: <description>` comment. Don't silently change the assertion to make it pass.
5. **Fix.** Edit the spec. Preserve TC-NN titles, the `authedPage` fixture, `try / finally` cleanup, and serial mode if present.
6. **Re-run only the fixed test** before moving to the next failure (`-g 'TC-09'` or full path). Don't re-run the whole file each iteration.
7. **Iterate** until the file is green. If a test stays red after high-confidence fixes, mark it `test.fixme(...)` with a comment and move on rather than spinning indefinitely.
# Repo-specific signals
- **The list pages render zero-state when the workspace is empty.** Many locators (search input, sort button, "All Dashboards" header) are absent in zero-state. If a test fails with a 30s timeout on these locators, the workspace was likely empty — seed via API first.
- **The "Enter dashboard name…" inline field is a `RequestDashboardBtn` (template-request feedback form), not a create flow.** Tests that try to use it to create a named dashboard will silently no-op. The only UI create path is the "New dashboard" dropdown → "Create dashboard" → default name "Sample Title".
- **Auth.** `tests/e2e/fixtures/auth.ts` logs in once per worker and caches `storageState` (cookies + localStorage with `AUTH_TOKEN`). For API-driven seeding/cleanup, read the token from localStorage and pass `Authorization: Bearer <token>`. Never re-implement login.
- **Ant Design popovers** (sort menu, action menu) are click-toggle. The trigger element is often an inline `<svg>` with a `data-testid` — clicking it opens the popover; clicking it again closes. After selecting an option, the popover auto-closes. If a test interacts with the popover twice, wait for the menu items to be visible explicitly between toggles.
- **Artifacts.** Every failed test writes to `tests/e2e/artifacts/results/<test-slug>/` — the `error-context.md` accessibility snapshot is the fastest way to see what the page actually looked like when it failed.
- **Type-check.** After edits, run `npx tsc --noEmit -p tests/e2e/tsconfig.json` if it succeeds, or rely on `npx playwright test --list` to validate the spec parses.
# Hard rules
- **Never wait for `networkidle`.** It's flaky and discouraged.
- **Never use `page.waitForTimeout(ms)`.** Always express the wait as `await expect(locator).toBeVisible()` or similar.
- **Never weaken an assertion just to make a test pass.** If the underlying behavior is broken, mark `test.fixme(...)` with a comment.
- **Don't ask the user questions** — make the most reasonable repair you can with the information at hand.
- **Don't rewrite passing tests** while fixing a failing one. Surgical edits only.
- **Never commit `test.only`** — CI fails on `forbidOnly: true`.

View File

@@ -0,0 +1,94 @@
---
name: playwright-test-planner
description: Use this agent to create a comprehensive E2E test plan for a SigNoz frontend feature. Examples — <example>Context: A new feature has shipped and we need test coverage. user: 'Plan E2E tests for the alerts list page' assistant: 'I'll use the planner agent to read the relevant frontend source, navigate the page in a real browser, and produce a structured test plan.' <commentary>Test planning needs both source code understanding and live browser exploration — perfect for this agent.</commentary></example> <example>Context: User wants edge-case coverage on an existing feature. user: 'What scenarios are we missing for dashboard variables?' assistant: 'Launching the planner agent to map flows and identify gaps.'</example>
tools: Glob, Grep, Read, Write, Bash, mcp__playwright-test__browser_click, mcp__playwright-test__browser_close, mcp__playwright-test__browser_console_messages, mcp__playwright-test__browser_drag, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_file_upload, mcp__playwright-test__browser_handle_dialog, mcp__playwright-test__browser_hover, mcp__playwright-test__browser_navigate, mcp__playwright-test__browser_navigate_back, mcp__playwright-test__browser_network_requests, mcp__playwright-test__browser_press_key, mcp__playwright-test__browser_select_option, mcp__playwright-test__browser_snapshot, mcp__playwright-test__browser_take_screenshot, mcp__playwright-test__browser_type, mcp__playwright-test__browser_wait_for, mcp__playwright-test__planner_setup_page
model: sonnet
color: green
---
You are an expert E2E test planner for the SigNoz frontend, working inside the SigNoz monorepo. Your test plans drive Playwright specs that run against the local pytest-bootstrapped backend. Read [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) before planning — it documents the harness, the `authedPage` fixture, the TC-NN naming convention, and the self-contained-state principle that every plan you write must respect.
You will:
1. **Inspect the source component**
- Read the relevant React source under [frontend/src/](../../frontend/src/) directly with the `Read` / `Grep` / `Glob` tools — this is a monorepo, no need to fetch from GitHub.
- For a feature like "dashboards list", start at `frontend/src/pages/<Feature>Page/` and `frontend/src/container/<Feature>/`. Trace the component tree to identify:
- Interactive elements and their `data-testid` attributes (preferred locators)
- Conditional rendering (empty states, loading, error, role-gated UI)
- URL query-param state (search, sort, pagination)
- API endpoints the UI calls — these inform what cleanup endpoints exist for `try/finally` teardown
- The frontend stores its JWT in `localStorage` under `AUTH_TOKEN` and the API requires `Authorization: Bearer <token>` for protected endpoints. Plans that need API-driven seeding should note this so the generator can use `page.request.*`.
2. **Navigate and explore**
- Invoke `planner_setup_page` once before any other browser tool.
- Use `browser_snapshot` to read the page's accessibility tree. **Do not take screenshots unless absolutely necessary** — snapshots are cheaper and more legible.
- Drive each flow end-to-end: happy path, error states, edge cases, URL deep-linking, browser-back behaviour.
3. **Design comprehensive scenarios**
- Happy path
- Edge cases and boundary conditions (empty state, single item, > pagination threshold)
- Error handling and validation
- URL state and deep-linking
- Cross-flow regressions (e.g. searching while paginated)
4. **Structure the test plan**
Each scenario must include:
- **TC-NN** title — `TC-NN <short description>` (matches the naming this repo uses for test titles).
- Preconditions (what state the test expects — note that the bootstrap creates a fresh stack with **zero dashboards / alerts / etc.**, so plans must seed their own data).
- Step-by-step user actions.
- Expected outcomes per step.
- Cleanup notes (what gets created and how to remove it — usually via API).
5. **Save the plan**
- Default location: `tests/e2e/specs/<feature>-test-plan.md` — create the directory if it doesn't exist.
- Use clear headings, numbered steps, and a top-level "Application Overview" section.
- At the top of the file, list any pre-existing limitations (e.g. "ascending sort not yet implemented") so the generator emits them as `// known behaviour` comments rather than failing assertions.
<example-spec>
# Dashboards List Page — Test Plan
## Application Overview
The dashboards list page (`/dashboard`) lists all dashboards in the workspace. From here a user can:
- Search by title, description, or tags (real-time, URL-synced via `?search=`)
- Sort by last-updated (URL-synced via `?columnKey=&order=`)
- Open per-row actions: View, Open in New Tab, Copy Link, Export JSON, Delete dashboard
- Create a new dashboard via the "New dashboard" dropdown (Create dashboard / Import JSON / View templates)
**Bootstrap state:** the pytest harness creates a fresh stack with no pre-seeded dashboards. Every test must seed its own data. The "Enter dashboard name…" inline input is a "request a new template" feedback form — **not** a create flow. The only UI create path is the dropdown.
**Known limitations:**
- Ascending sort is not yet implemented — repeated clicks on the sort button keep `order=descend`.
- Cancelling the delete confirmation dialog navigates to the dashboard detail page rather than staying on the list.
## Test Scenarios
### 1. Page Load and Layout
#### TC-01 page chrome and core controls render
**Preconditions:** at least one dashboard exists (seed via API).
**Steps:**
1. Navigate to `/dashboard`.
2. Verify URL is `/dashboard` (no query params).
3. Verify the page heading "Dashboards" (level 1) is visible.
4. ...
**Expected:**
- All Dashboards section header rendered.
- Search input, sort button, and at least one dashboard thumbnail visible.
**Cleanup:** delete the seeded dashboard via `DELETE /api/v1/dashboards/<id>`.
#### TC-02 ...
</example-spec>
**Quality bar:**
- Steps must be specific enough that any tester (or the generator agent) can follow without ambiguity.
- Include negative scenarios — empty state, no-match search, validation errors.
- Each scenario must own its preconditions and cleanup. **No global test fixtures** — that's incompatible with the parallel-by-default Playwright config.
- Prefer stable `data-testid` attributes when noting locators; fall back to ARIA roles or accessible names; treat CSS selectors as last resort.
**Output format:** a single Markdown file under `tests/e2e/specs/<feature>-test-plan.md` ready to hand to the generator agent.

View File

@@ -7,4 +7,9 @@ deploy
sample-apps
# frontend
node_modules
**/node_modules
frontend/build
# local env files (tracked example.env templates are unaffected)
**/.env
**/.env.*

View File

@@ -5321,9 +5321,6 @@ components:
sub_tree_node_count:
minimum: 0
type: integer
time_unix:
minimum: 0
type: integer
trace_id:
type: string
trace_state:

View File

@@ -97,7 +97,21 @@ Each spec follows these principles:
1. **Directory per feature**: `tests/e2e/tests/<feature>/*.spec.ts`. Cross-resource junction concerns (e.g. cascade-delete) go in their own file, not packed into one giant spec.
2. **Test titles use `TC-NN`**: `test('TC-01 alerts page — tabs render', ...)`. Preserves ordering at a glance and maps to external coverage tracking.
3. **UI-first**: drive flows through the UI. Playwright traces capture every BE request/response the UI triggers, so asserting on UI outcomes implicitly validates BE contracts. Reach for direct `page.request.*` only when the test's *purpose* is asserting a response contract (use `page.waitForResponse` on a UI click) or when a specific UI step is structurally flaky (e.g. Ant DatePicker calendar-cell indices) — and even then try UI first.
4. **Self-contained state**: each spec creates what it needs and cleans up in `try/finally`. No global pre-seeding fixtures.
4. **Self-contained state**: each spec seeds its own data and cleans up at suite teardown. The pytest harness creates a fresh stack with **zero** dashboards / alerts / etc. — never assume pre-existing data. Two patterns work:
- **Per-test seed + cleanup in `try / finally`** — small specs where each test owns its data.
- **Suite-level seed + `afterAll` teardown** — preferred for larger specs. Each `createDashboard(...)` call adds the resulting ID to a module-level `Set<string>`, and one `test.afterAll(...)` deletes everything in the set. See `tests/e2e/tests/dashboards/dashboards-list.spec.ts` for the full pattern. `test.beforeAll` / `test.afterAll` cannot use `authedPage` directly (it's test-scoped); use `newAuthedContext(browser)` from `fixtures/auth.ts` instead — it reuses the per-worker storage cache so no extra login happens.
5. **Seed via API when the UI flow is multi-step or brittle.** The frontend stores its JWT in `localStorage` under `AUTH_TOKEN`; `page.request.*` inherits the auth fixture's storage state. A typical pattern:
```ts
const token = await page.evaluate(
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
);
await page.request.post('/api/v1/dashboards', {
data: { title: 'my-name', uploadedGrafana: false },
headers: { Authorization: `Bearer ${token}` },
});
```
This is faster and more reliable than a multi-step UI seed. Reach for the UI flow only when the test's *purpose* is asserting that flow.
6. **Reusable fixture data lives in `tests/e2e/fixtures/`.** For example, `apm-metrics.json` is a real dashboard payload that specs import and `POST` to `/api/v1/dashboards` to seed a richly-tagged dashboard for search/list tests.
## How to write an E2E test?
@@ -155,13 +169,23 @@ test('TC-02 alerts list — create, toggle, delete', async ({ authedPage: page }
### Locator priority
1. `getByRole('button', { name: 'Submit' })`
2. `getByLabel('Email')`
3. `getByPlaceholder('...')`
4. `getByText('...')`
5. `getByTestId('...')`
1. `getByTestId('...')` — preferred when the source exposes one. Stable, app-author-provided handle that survives copy-edits.
2. `getByRole('button', { name: 'Submit' })`
3. `getByLabel('Email')`
4. `getByPlaceholder('...')`
5. `getByText('...')`
6. `locator('.ant-select')` — last resort (Ant Design dropdowns often have no semantic alternative)
## Agents
Three Claude agents in `.claude/agents/` accelerate writing and maintaining E2E specs:
- **`playwright-test-planner`** — explores a feature in a real browser plus the local frontend source and writes a test plan to `tests/e2e/specs/<feature>-test-plan.md`.
- **`playwright-test-generator`** — converts a test plan into Playwright spec files under `tests/e2e/tests/<feature>/`. Drives each scenario through MCP browser tools and emits TC-NN-titled tests using the `authedPage` fixture and the API-seed pattern.
- **`playwright-test-healer`** — runs failing specs, debugs them with snapshots / console / network introspection, and edits the spec to fix selector drift, timing, or state-leak issues.
The agents rely on the Playwright-test MCP server (`mcp__playwright-test__*` tools). Configure it in your Claude MCP settings; the permission allowlist lives in [.claude/settings.local.json](../../../.claude/settings.local.json).
## How to run E2E tests?
### Running All Tests

View File

@@ -7714,11 +7714,6 @@ export interface TracedetailtypesWaterfallSpanDTO {
* @minimum 0
*/
sub_tree_node_count?: number;
/**
* @type integer
* @minimum 0
*/
time_unix?: number;
/**
* @type string
*/

View File

@@ -49,7 +49,6 @@ import {
} from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { AppState } from 'store/reducers';
import { ILogBody } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -217,17 +216,20 @@ function LogDetailInner({
const logBody = useMemo(() => {
if (!isBodyJsonQueryEnabled) {
return (log?.body as string) ?? '';
return log?.body || '';
}
// Feature enabled: body is always a map; message is always a string
const bodyObj = log?.body as ILogBody;
if (!bodyObj) {
return '';
try {
const json = JSON.parse(log?.body || '');
if (typeof json?.message === 'string' && json.message !== '') {
return json.message;
}
return log?.body || '';
} catch (error) {
return log?.body || '';
}
if (bodyObj.message) {
return bodyObj.message;
}
return JSON.stringify(bodyObj);
}, [isBodyJsonQueryEnabled, log?.body]);
const htmlBody = useMemo(

View File

@@ -9,10 +9,7 @@ import { Color } from '@signozhq/design-tokens';
import { Tooltip } from 'antd';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import {
getBodyDisplayString,
getSanitizedLogBody,
} from 'container/LogDetailedView/utils';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
// hooks
import { useIsDarkMode } from 'hooks/useDarkMode';
@@ -102,7 +99,7 @@ function RawLogView({
// Check if body is selected
const showBody = selectedFields.some((field) => field.name === 'body');
if (showBody) {
parts.push(`${attributesText} ${getBodyDisplayString(data.body)}`);
parts.push(`${attributesText} ${data.body}`);
} else {
parts.push(attributesText);
}

View File

@@ -2,10 +2,7 @@ import type { ReactElement } from 'react';
import { useMemo } from 'react';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import {
getBodyDisplayString,
getSanitizedLogBody,
} from 'container/LogDetailedView/utils';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import { FontSize } from 'container/OptionsMenu/types';
import { FlatLogData } from 'lib/logs/flatLogData';
import { useTimezone } from 'providers/Timezone';
@@ -90,7 +87,7 @@ export function useLogsTableColumns({
? {
id: 'body',
header: 'Body',
accessorFn: (log): string => getBodyDisplayString(log.body),
accessorFn: (log): string => log.body,
canBeHidden: false,
width: { default: '100%', min: 300 },
cell: ({ value, isActive }): ReactElement => (

View File

@@ -19,7 +19,6 @@ import {
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { isArray } from 'lodash-es';
import { getBodyDisplayString } from 'container/LogDetailedView/utils';
import { ChevronDown, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -174,7 +173,7 @@ export default function Events({
(event): EventDataType => ({
timestamp: event.timestamp,
severity: event.data.severity_text,
body: getBodyDisplayString(event.data.body),
body: event.data.body,
id: event.data.id,
key: event.data.id,
resources_string: event.data.resources_string,

View File

@@ -13,7 +13,7 @@ import { ILog } from 'types/api/logs/log';
import { ActionItemProps } from './ActionItem';
import TableView from './TableView';
import { getBodyDisplayString, removeEscapeCharacters } from './utils';
import { removeEscapeCharacters } from './utils';
import './Overview.styles.scss';
@@ -112,7 +112,7 @@ function Overview({
children: (
<div className="logs-body-content">
<MEditor
value={removeEscapeCharacters(getBodyDisplayString(logData.body))}
value={removeEscapeCharacters(logData.body)}
language="json"
options={options}
onChange={(): void => {}}

View File

@@ -10,7 +10,7 @@ const MAX_BODY_BYTES = 100 * 1024; // 100 KB
// Hook for async JSON processing
const useAsyncJSONProcessing = (
value: string | Record<string, unknown>,
value: string,
shouldProcess: boolean,
handleChangeSelectedView?: ChangeViewFunctionType,
): {
@@ -40,17 +40,11 @@ const useAsyncJSONProcessing = (
return (): void => {};
}
// When value is already a parsed object skip the size check and JSON parsing
const parseBody = (): Record<string, unknown> | null => {
if (typeof value === 'object' && value !== null) {
return value as Record<string, unknown>;
}
const byteSize = new Blob([value as string]).size;
if (byteSize > MAX_BODY_BYTES) {
return null;
}
return recursiveParseJSON(value as string);
};
// Avoid processing if the json is too large
const byteSize = new Blob([value]).size;
if (byteSize > MAX_BODY_BYTES) {
return (): void => {};
}
processingRef.current = true;
setJsonState({ isLoading: true, treeData: null, error: null });
@@ -59,8 +53,8 @@ const useAsyncJSONProcessing = (
const processAsync = (): void => {
setTimeout(() => {
try {
const parsedBody = parseBody();
if (parsedBody && !isEmpty(parsedBody)) {
const parsedBody = recursiveParseJSON(value);
if (!isEmpty(parsedBody)) {
const treeData = jsonToDataNodes(parsedBody, {
isBodyJsonQueryEnabled,
handleChangeSelectedView,
@@ -88,8 +82,8 @@ const useAsyncJSONProcessing = (
// eslint-disable-next-line sonarjs/no-identical-functions
(): void => {
try {
const parsedBody = parseBody();
if (parsedBody && !isEmpty(parsedBody)) {
const parsedBody = recursiveParseJSON(value);
if (!isEmpty(parsedBody)) {
const treeData = jsonToDataNodes(parsedBody, {
isBodyJsonQueryEnabled,
handleChangeSelectedView,

View File

@@ -4,11 +4,7 @@ import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { MetricsType } from 'container/MetricsApplication/constant';
import dompurify from 'dompurify';
import { uniqueId } from 'lodash-es';
import {
ILog,
ILogAggregateAttributesResources,
ILogBody,
} from 'types/api/logs/log';
import { ILog, ILogAggregateAttributesResources } from 'types/api/logs/log';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { FORBID_DOM_PURIFY_ATTR, FORBID_DOM_PURIFY_TAGS } from 'utils/app';
@@ -437,24 +433,3 @@ export const getSanitizedLogBody = (
return '{}';
}
};
// Returns a plain string for display contexts (Monaco editor, table cells, raw log row).
export function getBodyDisplayString(body: string | ILogBody): string {
return typeof body === 'string' ? body : JSON.stringify(body as ILogBody);
}
// Returns the primary "message" text for compact log row previews.
export function getBodyMessage(
body: string | ILogBody,
isBodyJsonEnabled: boolean,
): string {
if (!isBodyJsonEnabled) {
return (body as string) ?? '';
}
// Feature enabled: body is always a map; message is always a string
const msg = (body as ILogBody).message;
if (msg) {
return msg;
}
return JSON.stringify(body);
}

View File

@@ -2,7 +2,6 @@ import { ExpandAltOutlined } from '@ant-design/icons';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getBodyDisplayString } from 'container/LogDetailedView/utils';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useTimezone } from 'providers/Timezone';
import { ILog } from 'types/api/logs/log';
@@ -27,9 +26,7 @@ function LogsList({ logs }: LogsListProps): JSX.Element {
DATE_TIME_FORMATS.UTC_MONTH_SHORT,
)}
</div>
<div className="logs-preview-list-item-body">
{getBodyDisplayString(log.body)}
</div>
<div className="logs-preview-list-item-body">{log.body}</div>
<div
className="logs-preview-list-item-expand"
onClick={makeLogDetailsHandler(log)}

View File

@@ -1,8 +1,3 @@
export interface ILogBody {
message?: string | null;
[key: string]: unknown;
}
export interface ILog {
date: string;
timestamp: number | string;
@@ -13,7 +8,7 @@ export interface ILog {
traceFlags: number;
severityText: string;
severityNumber: number;
body: string | ILogBody;
body: string;
resources_string: Record<string, never>;
scope_string: Record<string, never>;
attributesString: Record<string, never>;

2
go.mod
View File

@@ -11,7 +11,6 @@ require (
github.com/SigNoz/signoz-otel-collector v0.144.3
github.com/antlr4-go/antlr/v4 v4.13.1
github.com/antonmedv/expr v1.15.3
github.com/bytedance/sonic v1.14.1
github.com/cespare/xxhash/v2 v2.3.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/dgraph-io/ristretto/v2 v2.3.0
@@ -113,6 +112,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect

View File

@@ -12,10 +12,8 @@ import (
"time"
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/bytedance/sonic"
)
var (
@@ -24,8 +22,6 @@ var (
// written clickhouse query. The column alias indcate which value is
// to be considered as final result (or target).
legacyReservedColumnTargetAliases = []string{"__result", "__value", "result", "res", "value"}
CodeFailUnmarshalJSONColumn = errors.MustNewCode("fail_unmarshal_json_column")
)
// consume reads every row and shapes it into the payload expected for the
@@ -397,16 +393,11 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
// de-reference the typed pointer to any
val := reflect.ValueOf(cellPtr).Elem().Interface()
// Post-process JSON columns: unmarshal bytes into map[string]any
// Post-process JSON columns: normalize into String value
if strings.HasPrefix(strings.ToUpper(colTypes[i].DatabaseTypeName()), "JSON") {
switch x := val.(type) {
case []byte:
var m map[string]any
err := sonic.Unmarshal(x, &m)
if err != nil {
return nil, errors.WrapInternalf(err, CodeFailUnmarshalJSONColumn, "failed to unmarshal JSON column %s", name)
}
val = m
val = string(x)
default:
// already a structured type (map[string]any, []any, etc.)
}

View File

@@ -12,11 +12,8 @@ import (
"github.com/SigNoz/govaluate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// queryInfo holds common query properties.
@@ -52,7 +49,7 @@ func getQueryName(spec any) string {
return getqueryInfo(spec).Name
}
func (q *querier) postProcessResults(ctx context.Context, orgID valuer.UUID, results map[string]any, req *qbtypes.QueryRangeRequest) (map[string]any, error) {
func (q *querier) postProcessResults(ctx context.Context, results map[string]any, req *qbtypes.QueryRangeRequest) (map[string]any, error) {
// Convert results to typed format for processing
typedResults := make(map[string]*qbtypes.Result)
for name, result := range results {
@@ -71,7 +68,6 @@ func (q *querier) postProcessResults(ctx context.Context, orgID valuer.UUID, res
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
if result, ok := typedResults[spec.Name]; ok {
result = postProcessBuilderQuery(q, result, spec, req)
result = q.postProcessLogBody(ctx, orgID, result, req)
typedResults[spec.Name] = result
}
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
@@ -1030,33 +1026,3 @@ func (q *querier) calculateFormulaStep(expression string, req *qbtypes.QueryRang
return result
}
// postProcessLogBody removes the "message" key from the body map when it is empty.
// Only runs for raw list queries with the use_json_body feature enabled.
func (q *querier) postProcessLogBody(ctx context.Context, orgID valuer.UUID, result *qbtypes.Result, req *qbtypes.QueryRangeRequest) *qbtypes.Result {
if req.RequestType != qbtypes.RequestTypeRaw {
return result
}
if !q.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID)) {
return result
}
rawData, ok := result.Value.(*qbtypes.RawData)
if !ok {
return result
}
for _, row := range rawData.Rows {
bodyMap, ok := row.Data["body"].(map[string]any)
if !ok {
continue
}
if msg, exists := bodyMap["message"]; exists {
switch v := msg.(type) {
case string:
if v == "" {
delete(bodyMap, "message")
}
}
}
}
return result
}

View File

@@ -16,7 +16,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/query-service/utils"
"github.com/SigNoz/signoz/pkg/querybuilder"
@@ -36,7 +35,6 @@ var (
type querier struct {
logger *slog.Logger
fl flagger.Flagger
telemetryStore telemetrystore.TelemetryStore
metadataStore telemetrytypes.MetadataStore
promEngine prometheus.Prometheus
@@ -64,12 +62,10 @@ func New(
meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation],
traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder,
bucketCache BucketCache,
flagger flagger.Flagger,
) *querier {
querierSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/querier")
return &querier{
logger: querierSettings.Logger(),
fl: flagger,
telemetryStore: telemetryStore,
metadataStore: metadataStore,
promEngine: promEngine,
@@ -688,7 +684,7 @@ func (q *querier) run(
}
gomaps.Copy(results, preseededResults)
processedResults, err := q.postProcessResults(ctx, orgID, results, req)
processedResults, err := q.postProcessResults(ctx, results, req)
if err != nil {
return nil, err
}

View File

@@ -7,7 +7,6 @@ import (
cmock "github.com/srikanthccv/ClickHouse-go-mock"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
@@ -45,15 +44,14 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
providerSettings,
nil, // telemetryStore
metadataStore,
nil, // prometheus
nil, // traceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder
nil, // metricStmtBuilder
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
flaggertest.New(t), // flagger
nil, // prometheus
nil, // traceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder
nil, // metricStmtBuilder
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
)
req := &qbtypes.QueryRangeRequest{
@@ -118,7 +116,6 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
flaggertest.New(t), // flagger
)
req := &qbtypes.QueryRangeRequest{

View File

@@ -186,6 +186,5 @@ func newProvider(
meterStmtBuilder,
traceOperatorStmtBuilder,
bucketCache,
flagger,
), nil
}

View File

@@ -53,7 +53,6 @@ func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.Teleme
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
flagger,
), metadataStore
}
@@ -103,7 +102,6 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
fl,
)
}
@@ -148,6 +146,5 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
fl,
)
}

View File

@@ -13,7 +13,7 @@ func mkASpan(id string, resource map[string]string, attributes map[string]any, s
SpanID: id,
Resource: resource,
Attributes: attributes,
TimeUnix: startNs,
TimeUnixNano: startNs,
DurationNano: durationNs,
Children: make([]*WaterfallSpan, 0),
}
@@ -25,11 +25,11 @@ func buildTraceFromSpans(spans ...*WaterfallSpan) *WaterfallTrace {
initialized := false
for _, s := range spans {
spanMap[s.SpanID] = s
if !initialized || s.TimeUnix < startTime {
startTime = s.TimeUnix
if !initialized || s.TimeUnixNano < startTime {
startTime = s.TimeUnixNano
initialized = true
}
if end := s.TimeUnix + s.DurationNano; end > endTime {
if end := s.TimeUnixNano + s.DurationNano; end > endTime {
endTime = end
}
}

View File

@@ -71,7 +71,7 @@ type WaterfallSpan struct {
ParentSpanID string `json:"parent_span_id"`
Resource map[string]string `json:"resource"`
SpanID string `json:"span_id"`
TimeUnix uint64 `json:"time_unix"`
TimeUnixNano uint64 `json:"-"`
TraceID string `json:"trace_id"`
TraceState string `json:"trace_state"`
@@ -138,7 +138,7 @@ func NewMissingWaterfallSpan(spanID, traceID string, timeUnixNano, durationNano
SpanID: spanID,
TraceID: traceID,
Name: "Missing Span",
TimeUnix: timeUnixNano,
TimeUnixNano: timeUnixNano,
DurationNano: durationNano,
Events: make([]Event, 0),
Children: make([]*WaterfallSpan, 0),
@@ -150,10 +150,10 @@ func NewMissingWaterfallSpan(spanID, traceID string, timeUnixNano, durationNano
// SortChildren recursively sorts children of each span by TimeUnixNano then Name.
func (ws *WaterfallSpan) SortChildren() {
sort.Slice(ws.Children, func(i, j int) bool {
if ws.Children[i].TimeUnix == ws.Children[j].TimeUnix {
if ws.Children[i].TimeUnixNano == ws.Children[j].TimeUnixNano {
return ws.Children[i].Name < ws.Children[j].Name
}
return ws.Children[i].TimeUnix < ws.Children[j].TimeUnix
return ws.Children[i].TimeUnixNano < ws.Children[j].TimeUnixNano
})
for _, child := range ws.Children {
child.SortChildren()
@@ -292,7 +292,7 @@ func (item *StorableSpan) ToWaterfallSpan() *WaterfallSpan {
TraceID: item.TraceID,
TraceState: item.TraceState,
Children: make([]*WaterfallSpan, 0),
TimeUnix: uint64(item.StartTime.UnixNano()),
TimeUnixNano: uint64(item.StartTime.UnixNano()),
ServiceName: item.ServiceName,
}
}

View File

@@ -95,7 +95,7 @@ func NewWaterfallTraceFromSpans(spans []StorableSpan) *WaterfallTrace {
if parentNode, exists := spanIDToSpanNodeMap[spanNode.ParentSpanID]; exists {
parentNode.Children = append(parentNode.Children, spanNode)
} else {
missingSpan := NewMissingWaterfallSpan(spanNode.ParentSpanID, spanNode.TraceID, spanNode.TimeUnix, spanNode.DurationNano)
missingSpan := NewMissingWaterfallSpan(spanNode.ParentSpanID, spanNode.TraceID, spanNode.TimeUnixNano, spanNode.DurationNano)
missingSpan.Children = append(missingSpan.Children, spanNode)
spanIDToSpanNodeMap[missingSpan.SpanID] = missingSpan
traceRoots = append(traceRoots, missingSpan)
@@ -112,10 +112,10 @@ func NewWaterfallTraceFromSpans(spans []StorableSpan) *WaterfallTrace {
}
sort.Slice(traceRoots, func(i, j int) bool {
if traceRoots[i].TimeUnix == traceRoots[j].TimeUnix {
if traceRoots[i].TimeUnixNano == traceRoots[j].TimeUnixNano {
return traceRoots[i].Name < traceRoots[j].Name
}
return traceRoots[i].TimeUnix < traceRoots[j].TimeUnix
return traceRoots[i].TimeUnixNano < traceRoots[j].TimeUnixNano
})
return NewWaterfallTrace(
@@ -264,7 +264,7 @@ func NewGettableWaterfallTrace(
// convert start timestamp to millis because client is expecting it in millis
for _, span := range selectedSpans {
span.TimeUnix = span.TimeUnix / 1_000_000
span.TimeUnixNano = span.TimeUnixNano / 1_000_000
}
// duration values are in nanoseconds; convert in-place to milliseconds.
@@ -332,15 +332,15 @@ func mergeSpanIntervals(spans []*WaterfallSpan) uint64 {
return 0
}
sort.Slice(spans, func(i, j int) bool {
return spans[i].TimeUnix < spans[j].TimeUnix
return spans[i].TimeUnixNano < spans[j].TimeUnixNano
})
currentStart := spans[0].TimeUnix
currentStart := spans[0].TimeUnixNano
currentEnd := currentStart + spans[0].DurationNano
total := uint64(0)
for _, span := range spans[1:] {
startNano := span.TimeUnix
startNano := span.TimeUnixNano
endNano := startNano + span.DurationNano
if currentEnd >= startNano {
if endNano > currentEnd {

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,9 @@ const storageByUser = new Map<string, Promise<StorageState>>();
async function storageFor(browser: Browser, user: User): Promise<StorageState> {
const cached = storageByUser.get(user.email);
if (cached) return cached;
if (cached) {
return cached;
}
const task = (async () => {
const ctx = await browser.newContext();

View File

@@ -0,0 +1,844 @@
import type { Browser, BrowserContext, Page } from '@playwright/test';
import apmMetricsTemplate from '../../fixtures/apm-metrics.json';
import { expect, test } from '../../fixtures/auth';
// Spec-local login — used only by `beforeAll` / `afterAll` because the
// `authedPage` fixture is test-scoped and not visible to suite hooks. Each
// hook does one fresh login (~1s) per worker; we deliberately do not import
// or modify the per-worker storage cache from `fixtures/auth.ts`.
async function newAdminContext(browser: Browser): Promise<BrowserContext> {
const email = process.env.SIGNOZ_E2E_USERNAME;
const password = process.env.SIGNOZ_E2E_PASSWORD;
if (!email || !password) {
throw new Error(
'SIGNOZ_E2E_USERNAME / SIGNOZ_E2E_PASSWORD must be set ' +
'(pytest bootstrap writes them to .env.local).',
);
}
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto('/login?password=Y');
await page.getByTestId('email').fill(email);
await page.getByTestId('initiate_login').click();
await page.getByTestId('password').fill(password);
await page.getByRole('button', { name: 'Sign in with Password' }).click();
await page.waitForURL((url) => !url.pathname.startsWith('/login'));
await page.close();
return ctx;
}
// 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' });
const SEARCH_PLACEHOLDER = 'Search by name, description, or tags...';
// ─── Suite-level seed & teardown ─────────────────────────────────────────
//
// The pytest harness creates a fresh stack with zero dashboards. Tests seed
// what they need via the API and register the resulting IDs in `seedIds`.
// One `afterAll` deletes everything in `seedIds` at the end of the suite —
// individual tests do not need their own `try / finally` cleanup blocks.
const seedIds = new Set<string>();
// Names a test wants to assert on. Stable, descriptive, no timestamps.
const APM_METRICS_TITLE = (apmMetricsTemplate as { title: string }).title;
const BASE_FIXTURE_TITLE = 'dashboards-list-base-fixture';
// Reads the JWT that the auth fixture stored in localStorage after login.
// `page.request.*` requires the page to first navigate to the SigNoz origin
// so localStorage is populated from the storageState the context was created
// with.
async function authToken(page: Page): Promise<string> {
if (!page.url().startsWith('http')) {
await page.goto('/dashboard');
}
return page.evaluate(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
);
}
async function postDashboard(
page: Page,
body: Record<string, unknown>,
): Promise<string> {
const token = await authToken(page);
const res = await page.request.post('/api/v1/dashboards', {
data: body,
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
throw new Error(`POST /dashboards ${res.status()}: ${await res.text()}`);
}
const json = (await res.json()) as { data: { id: string } };
const id = json.data.id;
seedIds.add(id);
return id;
}
// Seed a minimal named dashboard via API. Tracks the ID for suite teardown.
async function createDashboard(page: Page, title: string): Promise<string> {
return postDashboard(page, { title, uploadedGrafana: false });
}
// Seed the full APM Metrics dashboard from the JSON fixture (title + tags +
// description + panels). Used by tests that want to assert on richer row
// content (multi-tag rendering, description, etc.).
async function importApmMetricsDashboard(page: Page): Promise<string> {
return postDashboard(page, {
...(apmMetricsTemplate as Record<string, unknown>),
uploadedGrafana: false,
});
}
// The page heading is the only element that's always present — independent
// of whether the workspace has dashboards (list view) or is empty (zero-state).
async function gotoList(page: Page): Promise<void> {
await page.goto('/dashboard');
await page
.getByRole('heading', { name: 'Dashboards', level: 1 })
.waitFor({ state: 'visible' });
}
// Find a dashboard ID by title via the list API.
async function findDashboardIdByTitle(
page: Page,
title: string,
): Promise<string | undefined> {
const token = await authToken(page);
const res = await page.request.get('/api/v1/dashboards', {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
return undefined;
}
const body = (await res.json()) as {
data: Array<{ id: string; data: { title: string } }>;
};
return body.data.find((d) => d.data.title === title)?.id;
}
// ─── beforeAll / afterAll ────────────────────────────────────────────────
test.beforeAll(async ({ browser }) => {
// Seed the two persistent fixtures the read-only tests rely on:
// - APM Metrics — a richer, real-world dashboard imported from JSON
// - A minimal base dashboard — used by tests that just need the list
// to be non-empty so the search input / sort button render
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
await importApmMetricsDashboard(page);
await createDashboard(page, BASE_FIXTURE_TITLE);
} 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 ctx.request
.delete(`/api/v1/dashboards/${id}`, {
headers: { Authorization: `Bearer ${token}` },
})
.catch(() => undefined);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
test.describe('Dashboards List Page', () => {
// ─── Page load and layout ────────────────────────────────────────────────
test('TC-01 page chrome and core controls render', async ({
authedPage: page,
}) => {
await gotoList(page);
await 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.getByRole('textbox', { name: SEARCH_PLACEHOLDER }),
).toBeVisible();
await expect(page.getByText('All Dashboards')).toBeVisible();
await expect(page.getByTestId('sort-by')).toBeVisible();
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
await expect(page.getByRole('button', { name: 'Feedback' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
});
test('TC-02 row shows thumbnail and creator email', async ({
authedPage: page,
}) => {
await gotoList(page);
await page
.getByAltText('dashboard-image')
.first()
.waitFor({ state: 'visible' });
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
// Creator email — admin@integration.test contains '@'
await expect(page.getByText(/@/).first()).toBeVisible();
});
// ─── Search functionality ────────────────────────────────────────────────
test('TC-03 search by title returns matching dashboard', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-search-title';
await createDashboard(page, name);
await gotoList(page);
const search = page.getByRole('textbox', { name: 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-04 search by tag returns the APM Metrics dashboard', async ({
authedPage: page,
}) => {
// APM Metrics is imported from JSON and carries multiple tags. We search
// by one of them ("apm") and expect the imported dashboard to surface.
await gotoList(page);
const search = page.getByRole('textbox', { name: SEARCH_PLACEHOLDER });
await search.fill('apm');
await expect(page).toHaveURL(/search=apm/);
await expect(page.getByText(APM_METRICS_TITLE).first()).toBeVisible();
});
test('TC-05 direct navigation with ?search= pre-fills the input and filters results', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-search-deeplink';
await createDashboard(page, name);
await page.goto(`/dashboard?search=${name}`);
await page
.getByRole('heading', { name: 'Dashboards', level: 1 })
.waitFor({ state: 'visible' });
await expect(
page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }),
).toHaveValue(name);
await expect(page.getByText(name).first()).toBeVisible();
});
test('TC-06 clearing search restores the full list', async ({
authedPage: page,
}) => {
await gotoList(page);
const search = page.getByRole('textbox', { name: 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-07 search with no matching results shows empty state', async ({
authedPage: page,
}) => {
await gotoList(page);
const search = page.getByRole('textbox', { name: 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-08 search is case-insensitive', async ({ authedPage: page }) => {
await gotoList(page);
const search = page.getByRole('textbox', { name: 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 ─────────────────────────────────────────────────────────────
//
// Known behaviour (verified against the running app):
// - Fresh load: no sort params in URL; list is already descending (server default)
// - First selection: URL gains ?columnKey=updatedAt&order=descend
// - `sortHandle` in DashboardsList.tsx hard-codes `order: 'descend'` —
// ascending mode is not yet implemented in any code path
test('TC-09 default load has no sort params', async ({
authedPage: page,
}) => {
await gotoList(page);
await expect(page).toHaveURL('/dashboard');
await expect(page).not.toHaveURL(/columnKey/);
await expect(page).not.toHaveURL(/order/);
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
});
test('TC-10 selecting "Last updated" adds columnKey=updatedAt&order=descend to URL', async ({
authedPage: page,
}) => {
await gotoList(page);
await expect(page).not.toHaveURL(/columnKey/);
await page.getByTestId('sort-by').click();
const lastUpdated = page.getByTestId('sort-by-last-updated');
await lastUpdated.waitFor({ state: 'visible' });
await lastUpdated.click();
await expect(page).toHaveURL(/columnKey=updatedAt/);
await expect(page).toHaveURL(/order=descend/);
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
});
test('TC-11 selecting "Last created" also yields order=descend (ascending not yet implemented)', async ({
authedPage: page,
}) => {
await gotoList(page);
await page.getByTestId('sort-by').click();
const lastCreated = page.getByTestId('sort-by-last-created');
await lastCreated.waitFor({ state: 'visible' });
await lastCreated.click();
await expect(page).toHaveURL(/columnKey=createdAt/);
await expect(page).toHaveURL(/order=descend/);
await expect(page).not.toHaveURL(/order=ascend/);
});
// ─── Row actions (context menu) ──────────────────────────────────────────
test('TC-12 admin sees all five options in the action menu', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-actions-menu';
await createDashboard(page, name);
await gotoList(page);
await page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }).fill(name);
const actionIcon = page.getByTestId('dashboard-action-icon').first();
await actionIcon.scrollIntoViewIfNeeded();
await actionIcon.click();
const tooltip = page.getByRole('tooltip');
await expect(tooltip).toBeVisible();
await expect(tooltip.getByRole('button', { name: 'View' })).toBeVisible();
await expect(
tooltip.getByRole('button', { name: 'Open in New Tab' }),
).toBeVisible();
await expect(
tooltip.getByRole('button', { name: 'Copy Link' }),
).toBeVisible();
await expect(
tooltip.getByRole('button', { name: 'Export JSON' }),
).toBeVisible();
// Delete is rendered as a generic, not a button
await expect(tooltip.getByText('Delete dashboard')).toBeVisible();
});
test('TC-13 view action navigates to the dashboard detail page', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-action-view';
await createDashboard(page, name);
await gotoList(page);
await page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }).fill(name);
const actionIcon = page.getByTestId('dashboard-action-icon').first();
await actionIcon.scrollIntoViewIfNeeded();
await actionIcon.click();
await page
.getByRole('tooltip')
.getByRole('button', { name: 'View' })
.click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
});
test('TC-14 open in new tab opens the dashboard in a new browser tab', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-action-newtab';
await createDashboard(page, name);
await gotoList(page);
await page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }).fill(name);
const actionIcon = page.getByTestId('dashboard-action-icon').first();
await actionIcon.scrollIntoViewIfNeeded();
await actionIcon.click();
// 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'),
page
.getByRole('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-15 copy link copies the dashboard URL to the clipboard', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-action-copy';
await createDashboard(page, name);
await gotoList(page);
await page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }).fill(name);
await page
.context()
.grantPermissions(['clipboard-read', 'clipboard-write']);
const actionIcon = page.getByTestId('dashboard-action-icon').first();
await actionIcon.scrollIntoViewIfNeeded();
await actionIcon.click();
await page
.getByRole('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-16 export JSON downloads the dashboard as a JSON file', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-action-export';
await createDashboard(page, name);
await gotoList(page);
await page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }).fill(name);
const actionIcon = page.getByTestId('dashboard-action-icon').first();
await actionIcon.scrollIntoViewIfNeeded();
await actionIcon.click();
const [download] = await Promise.all([
page.waitForEvent('download'),
page
.getByRole('tooltip')
.getByRole('button', { name: 'Export JSON' })
.click(),
]);
expect(download.suggestedFilename()).toMatch(/\.json$/);
});
test('TC-17 action menu closes when clicking outside the popover', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-action-dismiss';
await createDashboard(page, name);
await gotoList(page);
await page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }).fill(name);
const actionIcon = page.getByTestId('dashboard-action-icon').first();
await actionIcon.scrollIntoViewIfNeeded();
await actionIcon.click();
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-18 New dashboard dropdown shows exactly three options', async ({
authedPage: page,
}) => {
await gotoList(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-19 Create dashboard dropdown option creates a dashboard with the default name', async ({
authedPage: page,
}) => {
await gotoList(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.
const sampleId = await findDashboardIdByTitle(page, 'Sample Title');
if (sampleId) {
seedIds.add(sampleId);
}
});
test('TC-20 Import JSON dialog opens with code editor and upload button', async ({
authedPage: page,
}) => {
await gotoList(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-21 Import JSON dialog closes on Escape without creating a dashboard', async ({
authedPage: page,
}) => {
await gotoList(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();
// The Monaco editor inside the modal grabs focus on mount and swallows
// Escape. Click the modal title first to blur Monaco; then Ant's Modal
// `keyboard` handler picks up the Escape and dismisses the dialog.
await dialog.getByText('Import Dashboard JSON').click();
await page.keyboard.press('Escape');
await expect(dialog).not.toBeVisible();
await expect(page).toHaveURL(/\/dashboard($|\?)/);
});
test('TC-22 Import JSON dialog closes on clicking the close button', async ({
authedPage: page,
}) => {
await gotoList(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 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-23 delete confirmation dialog shows dashboard name with Cancel and Delete buttons', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-delete-confirm';
await createDashboard(page, name);
await gotoList(page);
await page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }).fill(name);
// Scroll the row into view before clicking — when the workspace has
// accumulated dashboards the search-filtered row can land below the
// viewport, and Playwright's auto-scroll on click is not always enough
// when there is a sticky header above.
const actionIcon = page.getByTestId('dashboard-action-icon').first();
await actionIcon.scrollIntoViewIfNeeded();
await actionIcon.click();
// Ant's Popover positions the tooltip below the row — when the row is
// near the viewport bottom the option ends up just off-screen. Force
// the click; the element is already attached and Ant handles the rest.
await page
.getByRole('tooltip')
.getByText('Delete dashboard')
.click({ force: true });
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-24 cancelling delete navigates to the dashboard detail page (known behaviour)', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-delete-cancel';
await createDashboard(page, name);
await gotoList(page);
await page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }).fill(name);
// Scroll the row into view before clicking — when the workspace has
// accumulated dashboards the search-filtered row can land below the
// viewport, and Playwright's auto-scroll on click is not always enough
// when there is a sticky header above.
const actionIcon = page.getByTestId('dashboard-action-icon').first();
await actionIcon.scrollIntoViewIfNeeded();
await actionIcon.click();
// Ant's Popover positions the tooltip below the row — when the row is
// near the viewport bottom the option ends up just off-screen. Force
// the click; the element is already attached and Ant handles the rest.
await page
.getByRole('tooltip')
.getByText('Delete dashboard')
.click({ force: true });
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Cancel' }).click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
});
test('TC-25 confirming delete removes the dashboard from the list', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-delete-confirmed';
const id = await createDashboard(page, name);
await gotoList(page);
await page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }).fill(name);
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
// Scroll the row into view before clicking — when the workspace has
// accumulated dashboards the search-filtered row can land below the
// viewport, and Playwright's auto-scroll on click is not always enough
// when there is a sticky header above.
const actionIcon = page.getByTestId('dashboard-action-icon').first();
await actionIcon.scrollIntoViewIfNeeded();
await actionIcon.click();
// Ant's Popover positions the tooltip below the row — when the row is
// near the viewport bottom the option ends up just off-screen. Force
// the click; the element is already attached and Ant handles the rest.
await page
.getByRole('tooltip')
.getByText('Delete dashboard')
.click({ force: true });
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 gotoList(page);
await page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }).fill(name);
await expect(page.getByAltText('dashboard-image')).toHaveCount(0);
// The UI delete already removed the resource — drop it from the
// suite-cleanup set so afterAll doesn't 404 on it.
seedIds.delete(id);
});
// ─── Row click navigation ────────────────────────────────────────────────
test('TC-26 clicking a dashboard row navigates to the detail page', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-row-click';
await createDashboard(page, name);
await gotoList(page);
await page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }).fill(name);
await page.getByAltText('dashboard-image').first().click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
});
test('TC-27 dashboard detail page shows the breadcrumb after row click', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-breadcrumb';
await createDashboard(page, name);
await gotoList(page);
await page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }).fill(name);
await page.getByAltText('dashboard-image').first().click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
await expect(
page.getByRole('button', { name: /Dashboard \// }),
).toBeVisible();
});
test('TC-28 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-29 search term updates the URL in real time', async ({
authedPage: page,
}) => {
await gotoList(page);
await page
.getByRole('textbox', { name: SEARCH_PLACEHOLDER })
.fill('realtime');
await expect(page).toHaveURL(/search=realtime/);
});
test('TC-30 browser Back after navigating to a dashboard restores search state', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-back-search';
await createDashboard(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.getByRole('textbox', { name: SEARCH_PLACEHOLDER }),
).toHaveValue(name);
});
test('TC-31 sort params appear in URL only after interacting with the sort button', async ({
authedPage: page,
}) => {
await gotoList(page);
await expect(page).not.toHaveURL(/columnKey/);
await page.getByTestId('sort-by').click();
await page.getByTestId('sort-by-last-updated').click();
await expect(page).toHaveURL(/columnKey=updatedAt/);
await expect(page).toHaveURL(/order=descend/);
// Direct navigation with sort params should honour them on load.
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/);
});
// ─── Page header actions ─────────────────────────────────────────────────
test('TC-32 feedback button is visible and stays on the page when clicked', async ({
authedPage: page,
}) => {
await gotoList(page);
const feedback = page.getByRole('button', { name: 'Feedback' });
await expect(feedback).toBeVisible();
await feedback.click();
await expect(page).toHaveURL(/\/dashboard/);
});
test('TC-33 share button is visible and stays on the page when clicked', async ({
authedPage: page,
}) => {
await gotoList(page);
const share = page.getByRole('button', { name: 'Share' });
await expect(share).toBeVisible();
await share.click();
await expect(page).toHaveURL(/\/dashboard/);
});
});

View File

@@ -20,7 +20,7 @@ from fixtures.querier import (
def _get_bodies(response: requests.Response) -> list[dict[str, Any]]:
return [row["data"]["body"] for row in get_rows(response)]
return [json.loads(row["data"]["body"]) for row in get_rows(response)]
def _run_query_case(signoz: types.SigNoz, token: str, now: datetime, case: dict[str, Any]) -> None:
@@ -1183,7 +1183,7 @@ def test_message_searches(
token = get_token(email=USER_ADMIN_EMAIL, password=USER_ADMIN_PASSWORD)
def _body_messages(response: requests.Response) -> list[str]:
return [row["data"]["body"].get("message", "") for row in get_rows(response)]
return [json.loads(row["data"]["body"]).get("message", "") for row in get_rows(response)]
payment_messages = {
"Payment processed successfully",