Compare commits

...

14 Commits

Author SHA1 Message Date
SagarRajput-7
6a869709b9 feat(ingestion): updated alert name format 2026-05-07 21:09:55 +05:30
SagarRajput-7
18ea6cb9c2 Merge branch 'main' into ingestion-alert-option 2026-05-07 20:47:35 +05:30
SagarRajput-7
7573c4472e feat(ingestion): updated test cases 2026-05-07 20:45:47 +05:30
SagarRajput-7
0bfcc959dc feat(ingestion): pass on relevant unit also from ingestion to alert 2026-05-07 20:30:22 +05:30
Ashwin Bhatkal
ae2127afe8 test: dashboards list spec with new e2e framework (#11190)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* test: dashboards list spec with new e2e framework

* chore: update docker ignore

* test: dashboards list spec with new e2e framework

* test: fix skipped ones

* test: fix scroll

* test: fix flaky clicks

* test: fix formatting

* chore: doc update + ignore file changes

* chore: update fixtures vs helpers

* chore: resolve comments

* chore: resolve comments
2026-05-07 14:55:38 +00:00
Vikrant Gupta
0e97204c77 feat(sqlstore): enable transaction_mode immediate (#10825)
* feat(sqlstore): enable transaction_mode immediate

* feat(sqlstore): fix dashboard delete

* feat(sqlstore): fix rebase issue

* feat(sqlstore): fix golang lint

* feat(sqlstore): do not start with default immediate

* feat(sqlstore): revert the integrationci changes
2026-05-07 14:41:35 +00:00
SagarRajput-7
6bacd78719 feat(ingestion): base path fixes 2026-05-07 16:03:12 +05:30
SagarRajput-7
6c19ce0854 feat(ingestion): updated test cases 2026-05-07 05:37:55 +05:30
SagarRajput-7
11212100be feat(ingestion): code refactor 2026-05-07 05:06:59 +05:30
SagarRajput-7
fb91440f56 feat(ingestion): added test cases 2026-05-07 04:52:04 +05:30
SagarRajput-7
6087c3aa09 Merge branch 'main' into ingestion-alert-option 2026-05-07 04:43:06 +05:30
SagarRajput-7
4c191b60fa feat(ingestion): removed antd button, used signozhq library 2026-05-07 04:38:12 +05:30
SagarRajput-7
9106930cbd feat(ingestion): added helper text and set alert badge for ingestion 2026-05-07 04:05:09 +05:30
SagarRajput-7
f6bc255f4d feat(ingestion): added default alert name when coming from ingestion limit 2026-05-07 00:55:35 +05:30
24 changed files with 5203 additions and 238 deletions

View File

@@ -0,0 +1,163 @@
---
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. Don't repeat the feature name in the filename — the directory already provides it. `dashboards/list.spec.ts`, not `dashboards/dashboards-list.spec.ts`.
- **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.** The bootstrap creates a fresh stack with **zero** dashboards / alerts / etc. — never assume pre-existing data. Two cleanup shapes are valid; pick based on the spec size:
- **Per-test `try / finally`** — small specs (~ <10 scenarios) where each test owns its data.
- **Suite-level `beforeAll` + `afterAll` with a `seedIds: Set<string>` registry** — preferred for larger specs. Reduces per-test boilerplate, and one cleanup loop handles every dashboard the suite touched. See [tests/e2e/tests/dashboards/list.spec.ts](../../tests/e2e/tests/dashboards/list.spec.ts) for the canonical shape.
- **Reuse helpers from `tests/e2e/helpers/`.** Don't reinvent. The current set:
- [`helpers/auth.ts`](../../tests/e2e/helpers/auth.ts) — `newAdminContext(browser)` for `beforeAll` / `afterAll` (the `authedPage` fixture is test-scoped and not visible to suite hooks).
- [`helpers/dashboards.ts`](../../tests/e2e/helpers/dashboards.ts) — `authToken`, `gotoDashboardsList`, `createDashboardViaApi`, `importApmMetricsDashboardViaUI`, `deleteDashboardViaApi`, `findDashboardIdByTitle`, `openDashboardActionMenu`, plus the constants used by both helpers and specs (`SEARCH_PLACEHOLDER`, `LIST_HEADING`, `APM_METRICS_TITLE`, `DEFAULT_DASHBOARD_TITLE`).
- **Seed via API when the UI flow is multi-step or brittle.** Implementation lives in `createDashboardViaApi` — use it. `page.request.*` does **not** auto-attach `Authorization`; the helpers handle that for you. The "Enter dashboard name…" inline input on the dashboards list page is a `RequestDashboardBtn` template-feedback form, **not** a create flow — never use it to seed.
- **Reusable JSON fixtures live in [tests/e2e/fixtures/](../../tests/e2e/fixtures/).** `apm-metrics.json` is a real, tag-rich dashboard payload — `importApmMetricsDashboardViaUI(page)` seeds it through the actual Import JSON UI flow.
- **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). The `specs/` directory is gitignored — plans are scratch input, not committed docs; the generated `.spec.ts` is the source of truth. 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. Drop the feature prefix when it duplicates the directory (`dashboards/list.spec.ts`, not `dashboards/dashboards-list.spec.ts`).
- **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.
- **Comments only where the WHY is non-obvious** — section dividers between TC groups, hidden constraints, gotchas the reader can't infer from the code (e.g. "Monaco swallows Escape — click the title to blur first"). **Do not narrate steps** by pasting the plan's bullets back as `// 1. Navigate…` `// 2. Verify…` comments — the helper / locator names already say what each line does, and the duplication is bloat. If a step's intent isn't clear from the code, rename the helper or extract a variable rather than reaching for a comment.
- **Imports** from `../../fixtures/auth`. **Do not** import from `@playwright/test` directly.
- **Try / finally** cleanup using the API (delete the resources you seeded).
# Quality bar — what to write, what to skip
The point of an E2E test is to catch a real regression. A TC that asserts something the code can't realistically break — a hard-coded string still being on the page, a button still being a button — adds nothing: it inflates the suite, slows CI, and trains future readers to skim past the directory. Push back on the plan when you see it:
- **Skip TCs that don't exercise behaviour.** "Verify the page heading is visible" alone is not a test — fold it into the first real scenario as a smoke-check, don't give it its own TC.
- **Collapse near-duplicates.** Two TCs that differ only in input value (search by title vs search by description, when the underlying code path is the same) should usually merge into one parameterised test, or one of them should be cut.
- **Prefer one assertion-rich test over three thin ones.** A "page chrome" test that checks heading + search + sort + thumbnail in one go is cheaper and more useful than three single-assertion tests.
- **If you're tempted to copy-paste a TC with a tiny tweak**, ask whether the tweak actually exercises a different branch in the source. If not, drop it.
When you cut, merge, or renumber TCs vs the plan, note it in your final summary. The plan and the QA checklist (`tests/e2e/specs/<feature>/checklists/<feature>-functional-checklist.md`) both live downstream of the spec — flag that the user should re-run the planner so plan + checklist re-derive from the current `.spec.ts`. Don't silently skip.
# 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 (suite-level shape, preferred for files with multiple scenarios):
```ts
// tests/e2e/tests/dashboards/list.spec.ts
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
import { newAdminContext } from '../../helpers/auth';
import {
authToken,
createDashboardViaApi,
deleteDashboardViaApi,
gotoDashboardsList,
} from '../../helpers/dashboards';
test.describe.configure({ mode: 'serial' });
const seedIds = new Set<string>();
async function seed(page: Page, title: string): Promise<string> {
const id = await createDashboardViaApi(page, title);
seedIds.add(id);
return id;
}
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 deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
test.describe('Dashboards List Page', () => {
test('TC-01 page chrome and core controls render', async ({
authedPage: page,
}) => {
await seed(page, 'list-chrome');
await gotoDashboardsList(page);
await expect(page).toHaveTitle('SigNoz | All Dashboards');
await expect(
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
).toBeVisible();
});
});
```
Note how the example carries no `// 1. …` `// 2. …` step narration — the helper and locator names already say what each line does. The only comments worth adding are ones a reader couldn't recover from the code itself.
# Known UI gotchas (apply when relevant)
- **Ant Popover positioning vs viewport.** Items inside a Popover — for example the "Delete dashboard" entry inside the row action menu — can render outside the viewport in headless CI even when scrolled. `click({ force: true })` skips actionability checks but Playwright still requires the click coordinates to land inside the viewport. Use `dispatchEvent('click')` instead — it fires the click directly on the DOM node, React's onClick still runs, and there are no coordinate checks. Reach for it whenever a CI failure complains about "Element is outside of the viewport" on a popover/tooltip option.
- **Sticky-header rows below the fold.** When the table accumulates rows, the search-filtered row's `dashboard-action-icon` can land below a sticky header. Always `await actionIcon.scrollIntoViewIfNeeded()` before clicking. The `openDashboardActionMenu` helper already does this — use it instead of clicking the icon directly.
- **React Query mutations vs navigation.** UI delete clicks fire an async DELETE through React Query. Navigating away before the mutation completes cancels it. Pair the click with `page.waitForResponse((r) => r.request().method() === 'DELETE' && /\/dashboards\//.test(r.url()))` and `await expect(dialog).not.toBeVisible()` before the next `page.goto(...)`.
- **Monaco editor swallows Escape.** Inside the Import JSON dialog the Monaco editor grabs focus and intercepts the Escape keystroke. Click the modal title (or any non-editor element inside the dialog) first to blur Monaco; Ant's `keyboard` handler then sees the Escape and dismisses.
- **Empty zero-state hides controls.** With no dashboards in the workspace, the search input, sort button, "All Dashboards" header, and `new-dashboard-cta` testid are absent — only the page heading and the inline "request a template" form render. Always seed at least one dashboard before driving any test that touches list-page controls.
# 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')`, `page.context().grantPermissions(...)` — note `page.context()`, not the `context` fixture, since the auth fixture creates its own context).
- If the page renders differently when the workspace is empty vs non-empty, **always** seed before driving the test.
- Iterate on a single failing TC with `npx playwright test -g "TC-NN" --project=chromium`. Use `--last-failed` after a multi-failure run to replay only what failed.

View File

@@ -0,0 +1,63 @@
---
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/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. If you renumber, retitle, or `test.fixme(...)` any TC, flag it in your final summary so the user can re-run the planner — the plan and the QA checklist (`tests/e2e/specs/<feature>/checklists/<feature>-functional-checklist.md`) re-derive from the current `.spec.ts` and will otherwise drift.
6. **Re-run only the fixed test** before moving to the next failure. Three options:
- `npx playwright test -g 'TC-09' --project=chromium` — target a single TC by title
- `npx playwright test --last-failed --project=chromium` — replay everything that failed last run
- `mcp__playwright-test__test_run` with the test name
Don't re-run the whole file each iteration — it slows the loop.
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
- **Reuse helpers before adding new code.** [`tests/e2e/helpers/dashboards.ts`](../../tests/e2e/helpers/dashboards.ts) and [`tests/e2e/helpers/auth.ts`](../../tests/e2e/helpers/auth.ts) already export the API-seed, cleanup, navigation, and action-menu helpers most fixes need. Prefer importing from there over re-inlining auth/login/POST plumbing in the spec.
- **Ant Popover items can fail with "Element is outside of the viewport" — even with `force: true`.** `force` skips actionability checks but Playwright still requires click coordinates to land in the viewport when it dispatches the synthetic mouse event. The robust fix is `tooltip.getByText('…').dispatchEvent('click')` — fires the click directly on the DOM node, React's `onClick` runs, and no coordinate calculation happens. Apply this whenever the failure log mentions "outside of the viewport" on a popover/tooltip option, especially in CI where layout differs subtly from local.
- **Action-icon rows below the fold.** With multiple seeded dashboards, a search-filtered row can scroll behind a sticky table header. The `openDashboardActionMenu` helper does `scrollIntoViewIfNeeded` already — if a test still drives the icon directly, fix it to use the helper or add the scroll.
- **React Query mutations vs page.goto.** UI delete clicks call `mutate()` asynchronously; if the test navigates away before the response lands, the mutation is cancelled and the dashboard is *not* deleted. Wait for the DELETE response and the dialog dismissal explicitly: `page.waitForResponse((r) => r.request().method() === 'DELETE' && /\/dashboards\//.test(r.url()))` plus `await expect(dialog).not.toBeVisible()`.
- **Monaco editor swallows Escape inside the Import JSON dialog.** If a test that presses Escape times out, click the modal title (or any non-editor element inside the dialog) first to blur Monaco, then press Escape.
- **The list pages render zero-state when the workspace is empty.** Many locators (search input, sort button, `new-dashboard-cta` testid, "All Dashboards" header) are absent in zero-state. A 30s timeout on those usually means the workspace was empty — seed first via `createDashboardViaApi`.
- **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 paths are the "New dashboard" dropdown → "Create dashboard" (default name "Sample Title", see `DEFAULT_DASHBOARD_TITLE`) or "Import JSON".
- **Auth.** `tests/e2e/fixtures/auth.ts` logs in once per worker and caches `storageState` (cookies + localStorage with `AUTH_TOKEN`). For API-driven seeding/cleanup, use `authToken(page)` from `helpers/dashboards.ts` 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,104 @@
---
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. **Check what's already wired up.**
- [tests/e2e/helpers/dashboards.ts](../../tests/e2e/helpers/dashboards.ts) and [tests/e2e/helpers/auth.ts](../../tests/e2e/helpers/auth.ts) hold reusable helpers (`createDashboardViaApi`, `gotoDashboardsList`, `openDashboardActionMenu`, `newAdminContext`, `importApmMetricsDashboardViaUI`, etc.). When the plan touches dashboards, reference these by name in the steps so the generator can reuse them rather than reinvent.
- [tests/e2e/fixtures/apm-metrics.json](../../tests/e2e/fixtures/apm-metrics.json) is a real-world dashboard payload (rich tags, panels, description) suitable as a seed fixture — note in the plan if a scenario benefits from it.
3. **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.
4. **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)
5. **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).
6. **Save the plan and the checklist**
- **Plan:** `tests/e2e/specs/<feature>/<feature>-test-plan.md`. **`tests/e2e/specs/` is gitignored** — plans are scratch artifacts: working input for the generator, regenerable, not committed. Don't treat them as durable documentation. The committed tests are the source of truth.
- **Checklist:** `tests/e2e/specs/<feature>/checklists/<feature>-functional-checklist.md`. A manual-verification runbook that mirrors the TC list one-to-one (one checkbox per TC) for QA hand-off. Same gitignore, same scratch status.
- **The checklist must stay in sync with the TCs.** When you regenerate the plan, regenerate the checklist alongside it — they share TC IDs and titles, and the checklist ordering must match. If the existing spec under `tests/e2e/tests/<feature>/` has more / fewer / different TCs than the prior plan, the spec is authoritative: re-derive plan and checklist from it.
- **On re-runs against an evolved feature:** read the existing `.spec.ts` files first. Treat the committed tests as ground truth; produce a plan and checklist that reflect *what is currently in the spec*, not what the prior plan said. This is how the planner handles TC additions, deletions, merges, and renumbering performed by the generator or healer.
- Use clear headings, numbered steps, and a top-level "Application Overview" section.
- At the top of the plan, 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. **Do not invent cross-file global fixtures** — they break parallel-by-file execution. Suite-level `beforeAll` / `afterAll` *within* a single spec file is fine and is the preferred shape for files with > ~10 scenarios; per-test `try / finally` is fine for smaller specs.
- Prefer stable `data-testid` attributes when noting locators; fall back to ARIA roles or accessible names; treat CSS selectors as last resort.
- **Don't pad coverage.** Every TC must catch a regression that would actually ship if the test were missing — a real branch in the source, a real user-visible failure mode. A TC that asserts a hard-coded string is still rendered, or that a button is still a button, adds nothing but maintenance cost: it inflates the suite, slows CI, and trains readers to skim past the directory. Before writing a scenario, ask "what code change would break this?" — if the only answer is "deleting the literal under test," cut it or fold it into a richer scenario as one assertion among many.
- **Collapse near-duplicates.** Two TCs that differ only in input value (search by title vs search by description, when the underlying code path is the same) should merge into one parameterised scenario unless each input genuinely exercises a distinct branch. Prefer one assertion-rich TC over three thin ones.
- **Smoke-checks aren't TCs.** "Heading is visible" belongs as the first assertion inside a real scenario, not as its own numbered case.
**Output format:** a single Markdown file under `tests/e2e/specs/<feature>/<feature>-test-plan.md` (gitignored scratch path) ready to hand to the generator agent. The file is regenerable; once the spec is written, the plan can be discarded.

View File

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

View File

@@ -81,23 +81,52 @@ tests/
├── .env.local # generated by bootstrap/setup.py (gitignored)
├── bootstrap/
│ └── setup.py # test_setup / test_teardown — pytest lifecycle
├── fixtures/
│ └── auth.ts # authedPage Playwright fixture + per-worker storageState cache
├── fixtures/ # Playwright test fixtures (test.extend) only
│ └── auth.ts
├── helpers/ # function helpers + the constants they share with tests
│ ├── auth.ts
│ └── dashboards.ts
├── testdata/ # static data files (JSON) used by helpers and tests
│ └── apm-metrics.json # (example)
├── tests/ # Playwright .spec.ts files, one dir per feature area
│ └── alerts/
│ └── alerts.spec.ts
│ └── alerts.spec.ts # (example)
└── artifacts/ # per-run output (gitignored)
├── html/ # HTML reporter output
├── json/ # JSON reporter output
└── results/ # per-test traces / screenshots / videos on failure
```
### `fixtures/` vs `helpers/` — what goes where
These two folders look similar but mean different things:
- **`fixtures/`** holds *Playwright test fixtures* (created via `test.extend({...})`). By the canonical definition, a fixture is "a consistent, predefined set of data, objects, or environmental conditions used to ensure tests run in a stable state" — i.e. setup/teardown that runs *automatically* around each test or worker. `auth.ts` matches: it extends Playwright's `test` with an `authedPage` that's logged-in before every test runs and torn down after. If the only thing in this folder ever is `auth.ts`, that's fine — fixtures are a deliberately small surface.
- **`helpers/`** holds plain function helpers that you call *explicitly* from a test or hook — they don't extend Playwright's `test`. This covers both behaviour helpers (e.g. `gotoDashboardsList(page)`) and the constants those helpers and the tests both refer to (e.g. `SEARCH_PLACEHOLDER`). Constants live next to the helpers that use them so a single import line in a test covers both.
- **`testdata/`** holds static data files (typically JSON / YAML) consumed by the helpers — for example, `apm-metrics.json`, a real dashboard payload uploaded through the UI by an importer helper.
Rule of thumb: if it's a `test.extend` fixture, put it in `fixtures/`. If it's a function you call explicitly (or a constant the function uses), put it in `helpers/`. If it's a static file the helpers read, put it in `testdata/`.
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/list.spec.ts` for the full pattern. `test.beforeAll` / `test.afterAll` cannot use `authedPage` directly (it's test-scoped); use `newAdminContext(browser)` from `helpers/auth.ts` instead — it performs one fresh login per suite hook.
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 static data lives in `tests/e2e/testdata/`.** For example, `apm-metrics.json` is a real dashboard payload that `importApmMetricsDashboardViaUI` (in `helpers/dashboards.ts`) uploads through the actual Import JSON UI flow to seed a richly-tagged dashboard for search/list tests.
## How to write an E2E test?
@@ -155,13 +184,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 as a scratch markdown file (under `tests/e2e/specs/`, which is gitignored — plans are working artifacts for the generator, not committed docs).
- **`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

@@ -143,7 +143,7 @@ func (module *module) Delete(ctx context.Context, orgID valuer.UUID, id valuer.U
}
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
err := module.deletePublic(ctx, orgID, id)
err := module.store.DeletePublic(ctx, id.String())
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return err
}
@@ -216,7 +216,3 @@ func (module *module) Update(ctx context.Context, orgID valuer.UUID, id valuer.U
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, isAdmin, lock)
}
func (module *module) deletePublic(ctx context.Context, _ valuer.UUID, dashboardID valuer.UUID) error {
return module.store.DeletePublic(ctx, dashboardID.StringValue())
}

View File

@@ -56,4 +56,5 @@ export enum QueryParams {
showClassicCreateAlertsPage = 'showClassicCreateAlertsPage',
isTestAlert = 'isTestAlert',
yAxisUnit = 'yAxisUnit',
ruleName = 'ruleName',
}

View File

@@ -9,6 +9,7 @@ import {
AlertThresholdOperator,
} from 'container/CreateAlertV2/context/types';
import { getSelectedQueryOptions } from 'container/FormAlertRules/utils';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { ArrowRight } from 'lucide-react';
import { IUser } from 'providers/App/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -409,6 +410,7 @@ export function RoutingPolicyBanner({
notificationSettings,
setNotificationSettings,
}: RoutingPolicyBannerProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
return (
<div className="routing-policies-info-banner">
<Typography.Text>
@@ -426,10 +428,10 @@ export function RoutingPolicyBanner({
}}
/>
<Button
href={ROUTING_POLICIES_ROUTE}
type="link"
className="view-routing-policies-button"
data-testid="view-routing-policies-button"
onClick={(): void => safeNavigate(ROUTING_POLICIES_ROUTE)}
>
View Routing Policies
<ArrowRight size={14} />

View File

@@ -1,9 +1,11 @@
import { useEffect, useMemo, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import YAxisUnitSelector from 'components/YAxisUnitSelector';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { QueryParams } from 'constants/query';
import { useCreateAlertState } from 'container/CreateAlertV2/context';
import ChartPreviewComponent from 'container/FormAlertRules/ChartPreview';
import PlotTag from 'container/NewWidget/LeftContainer/WidgetGraph/PlotTag';
@@ -39,14 +41,22 @@ function ChartPreview({
const yAxisUnit = alertState.yAxisUnit || '';
// Only update automatically when creating a new metrics-based alert rule
const location = useLocation();
const yAxisUnitFromURL = new URLSearchParams(location.search).get(
QueryParams.yAxisUnit,
);
// Only update automatically when creating a new metrics-based alert rule.
// Skip when yAxisUnit was explicitly provided via URL (e.g. from ingestion settings).
const shouldUpdateYAxisUnit = useMemo(() => {
// Do not update if we are coming to the page from dashboards (we still show warning)
if (source === YAxisSource.DASHBOARDS) {
return false;
}
if (yAxisUnitFromURL) {
return false;
}
return !isEditMode && alertType === AlertTypes.METRICS_BASED_ALERT;
}, [isEditMode, alertType, source]);
}, [isEditMode, alertType, source, yAxisUnitFromURL]);
const selectedQueryName = thresholdState.selectedQuery;
const { yAxisUnit: initialYAxisUnit, isLoading } =

View File

@@ -7,6 +7,7 @@ import {
useEffect,
useMemo,
useReducer,
useRef,
useState,
} from 'react';
import { useLocation } from 'react-router-dom';
@@ -123,6 +124,8 @@ export function CreateAlertProvider(
const location = useLocation();
const queryParams = new URLSearchParams(location.search);
const thresholdsFromURL = queryParams.get(QueryParams.thresholds);
const ruleNameFromURL = queryParams.get(QueryParams.ruleName);
const yAxisUnitFromURL = queryParams.get(QueryParams.yAxisUnit);
const [alertType, setAlertType] = useState<AlertTypes>(() => {
if (isEditMode) {
@@ -154,6 +157,9 @@ export function CreateAlertProvider(
[redirectWithQueryBuilderData],
);
const ruleNameAppliedRef = useRef(false);
const yAxisUnitAppliedRef = useRef(false);
useEffect(() => {
setCreateAlertState({
slice: CreateAlertSlice.THRESHOLD,
@@ -191,7 +197,29 @@ export function CreateAlertProvider(
},
});
}
}, [alertType, thresholdsFromURL]);
if (ruleNameFromURL && !ruleNameAppliedRef.current) {
ruleNameAppliedRef.current = true;
setCreateAlertState({
slice: CreateAlertSlice.BASIC,
action: {
type: 'SET_ALERT_NAME',
payload: ruleNameFromURL,
},
});
}
if (yAxisUnitFromURL && !yAxisUnitAppliedRef.current) {
yAxisUnitAppliedRef.current = true;
setCreateAlertState({
slice: CreateAlertSlice.BASIC,
action: {
type: 'SET_Y_AXIS_UNIT',
payload: yAxisUnitFromURL,
},
});
}
}, [alertType, thresholdsFromURL, ruleNameFromURL, yAxisUnitFromURL]);
useEffect(() => {
if (isEditMode && initialAlertState) {

View File

@@ -443,7 +443,25 @@
.signal-limit-save-discard {
display: flex;
gap: 8px;
gap: var(--spacing-4);
.signal-limit-save-discard-actions {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.signal-limit-alert-helper {
display: flex;
align-items: center;
gap: var(--spacing-2);
font-size: var(--paragraph-small-400-font-size);
color: var(--l2-foreground);
border-bottom: 1px dashed var(--l2-foreground);
padding-bottom: 1px;
font-style: italic;
margin-left: var(--spacing-6);
}
}
}
}
@@ -475,6 +493,7 @@
.ant-modal-footer {
padding: 16px;
margin-top: 0;
gap: 8px;
display: flex;
justify-content: flex-end;

View File

@@ -3,8 +3,8 @@ import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { Color } from '@signozhq/design-tokens';
import { Badge, Button } from '@signozhq/ui';
import {
Button,
Col,
Collapse,
DatePicker,
@@ -41,6 +41,7 @@ import {
import { AxiosError } from 'axios';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import Tags from 'components/Tags/Tags';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { QueryParams } from 'constants/query';
@@ -98,9 +99,30 @@ const COUNT_MULTIPLIER = {
};
const SIGNALS_CONFIG = [
{ name: 'logs', usesSize: true, usesCount: false },
{ name: 'traces', usesSize: true, usesCount: false },
{ name: 'metrics', usesSize: false, usesCount: true },
{
name: 'logs',
usesSize: true,
usesCount: false,
metricName: 'signoz.meter.log.size',
yAxisUnit: UniversalYAxisUnit.BYTES_IEC,
thresholdUnit: UniversalYAxisUnit.GIBIBYTES,
},
{
name: 'traces',
usesSize: true,
usesCount: false,
metricName: 'signoz.meter.span.size',
yAxisUnit: UniversalYAxisUnit.BYTES_IEC,
thresholdUnit: UniversalYAxisUnit.GIBIBYTES,
},
{
name: 'metrics',
usesSize: false,
usesCount: true,
metricName: 'signoz.meter.metric.datapoint.count',
yAxisUnit: UniversalYAxisUnit.COUNT,
thresholdUnit: UniversalYAxisUnit.COUNT,
},
];
// Using any type here because antd's DatePicker expects its own internal Dayjs type
@@ -394,7 +416,7 @@ function MultiIngestionSettings(): JSX.Element {
notifications.success({
message: 'Ingestion key deleted successfully',
});
refetchAPIKeys();
void refetchAPIKeys();
setIsDeleteModalOpen(false);
},
onError: (error) => {
@@ -426,7 +448,7 @@ function MultiIngestionSettings(): JSX.Element {
notifications.success({
message: 'Ingestion key updated successfully',
});
refetchAPIKeys();
void refetchAPIKeys();
setIsEditModalOpen(false);
},
onError: (error) => {
@@ -466,7 +488,7 @@ function MultiIngestionSettings(): JSX.Element {
setActiveAPIKey(null);
setUpdatedTags([]);
hideAddViewModal();
refetchAPIKeys();
void refetchAPIKeys();
},
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
@@ -630,13 +652,14 @@ function MultiIngestionSettings(): JSX.Element {
onSuccess: () => {
notifications.success({
message: 'Limit created successfully',
description: "Set up an alert to know when you're close to hitting it.",
});
setActiveSignal(null);
setActiveAPIKey(null);
setIsEditAddLimitOpen(false);
setUpdatedTags([]);
hideAddViewModal();
refetchAPIKeys();
void refetchAPIKeys();
setHasCreateLimitForIngestionKeyError(false);
},
onError: (error: AxiosError<RenderErrorResponseDTO>) => {
@@ -733,13 +756,14 @@ function MultiIngestionSettings(): JSX.Element {
onSuccess: () => {
notifications.success({
message: 'Limit updated successfully',
description: "Set up an alert to know when you're close to hitting it.",
});
setActiveSignal(null);
setActiveAPIKey(null);
setIsEditAddLimitOpen(false);
setUpdatedTags([]);
hideAddViewModal();
refetchAPIKeys();
void refetchAPIKeys();
setHasUpdateLimitForIngestionKeyError(false);
},
onError: (error: AxiosError<RenderErrorResponseDTO>) => {
@@ -824,7 +848,7 @@ function MultiIngestionSettings(): JSX.Element {
});
setIsDeleteModalOpen(false);
setIsDeleteLimitModalOpen(false);
refetchAPIKeys();
void refetchAPIKeys();
},
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
@@ -840,29 +864,22 @@ function MultiIngestionSettings(): JSX.Element {
APIKey: GatewaytypesIngestionKeyDTO,
signal: LimitProps,
): void => {
let metricName = '';
switch (signal.signal) {
case 'metrics':
metricName = 'signoz.meter.metric.datapoint.count';
break;
case 'traces':
metricName = 'signoz.meter.span.size';
break;
case 'logs':
metricName = 'signoz.meter.log.size';
break;
default:
return;
const signalCfg = SIGNALS_CONFIG.find((cfg) => cfg.name === signal.signal);
if (!signalCfg) {
return;
}
const threshold =
signal.signal === 'metrics'
? signal.config?.day?.count || 0
: signal.config?.day?.size || 0;
const { metricName, yAxisUnit, thresholdUnit } = signalCfg;
// Size signals store the limit in bytes but the user entered GiB; pass the GiB
// value so the threshold reads "400 GiB" while the chart Y-axis stays in bytes.
const thresholdValue = signalCfg.usesCount
? signal.config?.day?.count || 0
: bytesToGb(signal.config?.day?.size);
const query = {
...initialQueryMeterWithType,
unit: yAxisUnit,
builder: {
...initialQueryMeterWithType.builder,
queryData: [
@@ -887,13 +904,23 @@ function MultiIngestionSettings(): JSX.Element {
const stringifiedQuery = JSON.stringify(query);
const thresholds = cloneDeep(INITIAL_ALERT_THRESHOLD_STATE.thresholds);
thresholds[0].thresholdValue = threshold;
thresholds[0].thresholdValue = thresholdValue;
thresholds[0].unit = thresholdUnit;
const keyName = APIKey.name?.trim();
const ruleName = keyName
? `[ingestion][${signal.signal}] ${keyName} has exceeded daily ingestion limit`
: `[ingestion][${signal.signal}] ${signal.signal} has exceeded daily ingestion limit`;
const URL = `${ROUTES.ALERTS_NEW}?${
QueryParams.compositeQuery
}=${encodeURIComponent(stringifiedQuery)}&${
QueryParams.thresholds
}=${encodeURIComponent(JSON.stringify(thresholds))}`;
}=${encodeURIComponent(JSON.stringify(thresholds))}&${
QueryParams.ruleName
}=${encodeURIComponent(ruleName)}&${
QueryParams.yAxisUnit
}=${encodeURIComponent(yAxisUnit)}`;
history.push(URL);
};
@@ -980,13 +1007,18 @@ function MultiIngestionSettings(): JSX.Element {
</div>
<div className="action-btn">
<Button
className="periscope-btn ghost"
icon={<PenLine size={14} />}
variant="link"
size="icon"
color="secondary"
suffix={<PenLine size={14} />}
aria-label="Edit ingestion key"
onClick={onEditKey}
/>
<Button
className="periscope-btn ghost"
icon={<Trash2 color={Color.BG_CHERRY_500} size={14} />}
variant="link"
size="icon"
color="destructive"
suffix={<Trash2 color={Color.BG_CHERRY_500} size={14} />}
onClick={onDeleteKey}
/>
</div>
@@ -1092,16 +1124,22 @@ function MultiIngestionSettings(): JSX.Element {
{hasLimits(signalName) ? (
<>
<Button
className="periscope-btn ghost"
icon={<PenLine size={14} />}
variant="link"
size="icon"
color="secondary"
prefix={<PenLine size={14} />}
aria-label={`Edit ${signalName} limit`}
disabled={
!!(activeAPIKey?.id === APIKey?.id && activeSignal)
}
onClick={onEditSignalLimit}
/>
<Button
className="periscope-btn ghost"
icon={<Trash2 color={Color.BG_CHERRY_500} size={14} />}
variant="link"
size="icon"
color="destructive"
prefix={<Trash2 color={Color.BG_CHERRY_500} size={14} />}
aria-label={`Delete ${signalName} limit`}
disabled={
!!(activeAPIKey?.id === APIKey?.id && activeSignal)
}
@@ -1110,10 +1148,10 @@ function MultiIngestionSettings(): JSX.Element {
</>
) : (
<Button
className="periscope-btn"
size="small"
shape="round"
icon={<PlusIcon size={14} />}
variant="outlined"
size="sm"
color="secondary"
prefix={<PlusIcon size={12} />}
disabled={!!(activeAPIKey?.id === APIKey?.id && activeSignal)}
onClick={onAddSignalLimit}
>
@@ -1344,31 +1382,35 @@ function MultiIngestionSettings(): JSX.Element {
activeSignal.signal === signalName &&
isEditAddLimitOpen && (
<div className="signal-limit-save-discard">
<Button
type="primary"
className="periscope-btn primary"
size="small"
disabled={
isLoadingLimitForKey || isLoadingUpdatedLimitForKey
}
loading={
isLoadingLimitForKey || isLoadingUpdatedLimitForKey
}
onClick={onSaveSignalLimit}
>
Save
</Button>
<Button
type="default"
className="periscope-btn"
size="small"
disabled={
isLoadingLimitForKey || isLoadingUpdatedLimitForKey
}
onClick={handleDiscardSaveLimit}
>
Discard
</Button>
<div className="signal-limit-save-discard-actions">
<Button
variant="solid"
size="sm"
disabled={
isLoadingLimitForKey || isLoadingUpdatedLimitForKey
}
loading={
isLoadingLimitForKey || isLoadingUpdatedLimitForKey
}
onClick={onSaveSignalLimit}
>
Save
</Button>
<Button
variant="outlined"
color="secondary"
size="sm"
disabled={
isLoadingLimitForKey || isLoadingUpdatedLimitForKey
}
onClick={handleDiscardSaveLimit}
>
Discard
</Button>
<span className="signal-limit-alert-helper">
You can set up an alert after saving
</span>
</div>
</div>
)}
</Form>
@@ -1425,19 +1467,18 @@ function MultiIngestionSettings(): JSX.Element {
limit?.config?.day?.size !== undefined) ||
(signalCfg.usesCount &&
limit?.config?.day?.count !== undefined)) && (
<Tooltip
title="Set alert on this limit"
placement="top"
arrow={false}
<Badge
asChild
color="cherry"
variant="outline"
testId={`set-alert-btn-${signalName}`}
className="set-alert-btn"
>
<Button
icon={<BellPlus size={14} color={Color.BG_CHERRY_400} />}
className="set-alert-btn periscope-btn ghost"
type="text"
data-testid={`set-alert-btn-${signalName}`}
onClick={onCreateSignalAlert}
/>
</Tooltip>
<Button onClick={onCreateSignalAlert} size="sm">
<BellPlus size={12} />
Set alert
</Button>
</Badge>
)}
</div>
@@ -1617,7 +1658,13 @@ function MultiIngestionSettings(): JSX.Element {
}
placement="topLeft"
>
<Button type="text" icon={<TriangleAlert size={14} />} />
<Button
variant="ghost"
size="icon"
color="secondary"
prefix={<TriangleAlert size={14} />}
aria-label="Ingestion URL error details"
/>
</Tooltip>
)}
</div>
@@ -1633,11 +1680,12 @@ function MultiIngestionSettings(): JSX.Element {
/>
<Button
variant="solid"
className="add-new-ingestion-key-btn"
type="primary"
prefix={<Plus size={14} />}
onClick={showAddModal}
>
<Plus size={14} /> New Ingestion key
New Ingestion key
</Button>
</div>
@@ -1670,15 +1718,19 @@ function MultiIngestionSettings(): JSX.Element {
footer={[
<Button
key="cancel"
variant="ghost"
color="secondary"
prefix={<X size={16} />}
onClick={hideDeleteViewModal}
className="cancel-btn"
icon={<X size={16} />}
>
Cancel
</Button>,
<Button
key="submit"
icon={<Trash2 size={16} />}
variant="solid"
color="destructive"
prefix={<Trash2 size={16} />}
loading={isDeleteingAPIKey}
onClick={onDeleteHandler}
className="delete-btn"
@@ -1706,15 +1758,19 @@ function MultiIngestionSettings(): JSX.Element {
footer={[
<Button
key="cancel"
variant="ghost"
color="secondary"
prefix={<X size={16} />}
onClick={hideDeleteLimitModal}
className="cancel-btn"
icon={<X size={16} />}
>
Cancel
</Button>,
<Button
key="submit"
icon={<Trash2 size={16} />}
variant="solid"
color="destructive"
prefix={<Trash2 size={16} />}
loading={isDeletingLimit}
onClick={onDeleteLimitHandler}
className="delete-btn"
@@ -1745,18 +1801,18 @@ function MultiIngestionSettings(): JSX.Element {
footer={[
<Button
key="cancel"
variant="ghost"
color="secondary"
prefix={<X size={16} />}
onClick={hideEditViewModal}
className="periscope-btn cancel-btn"
icon={<X size={16} />}
>
Cancel
</Button>,
<Button
className="periscope-btn primary"
key="submit"
type="primary"
variant="solid"
prefix={<Check size={14} />}
loading={isLoadingUpdateAPIKey}
icon={<Check size={14} />}
onClick={onUpdateApiKey}
>
Update Ingestion Key
@@ -1813,18 +1869,18 @@ function MultiIngestionSettings(): JSX.Element {
footer={[
<Button
key="cancel"
variant="ghost"
color="secondary"
prefix={<X size={16} />}
onClick={hideAddViewModal}
className="periscope-btn cancel-btn"
icon={<X size={16} />}
>
Cancel
</Button>,
<Button
className="periscope-btn primary"
test-id="create-new-key"
key="submit"
type="primary"
icon={<Check size={14} />}
variant="solid"
testId="create-new-key"
prefix={<Check size={14} />}
loading={isLoadingCreateAPIKey}
onClick={onCreateIngestionKey}
>
@@ -1858,7 +1914,7 @@ function MultiIngestionSettings(): JSX.Element {
]}
validateTrigger="onBlur"
>
<Input placeholder="Enter Ingestion Key name" autoFocus />
<Input placeholder="Enter Ingestion Key name" />
</Form.Item>
<Form.Item

View File

@@ -1,24 +1,16 @@
import { GatewaytypesGettableIngestionKeysDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { LimitProps } from 'types/api/ingestionKeys/limits/types';
import {
AllIngestionKeyProps,
IngestionKeyProps,
} from 'types/api/ingestionKeys/types';
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import MultiIngestionSettings from '../MultiIngestionSettings';
// Extend the existing types to include limits with proper structure
interface TestIngestionKeyProps extends Omit<IngestionKeyProps, 'limits'> {
limits?: LimitProps[];
}
interface TestAllIngestionKeyProps extends Omit<AllIngestionKeyProps, 'data'> {
data: TestIngestionKeyProps[];
}
// Gateway API response type (uses actual schema types for contract safety)
interface TestGatewayIngestionKeysResponse {
status: string;
@@ -40,6 +32,16 @@ const TEST_EXPIRES_AT = '2030-01-01T00:00:00Z';
const TEST_WORKSPACE_ID = 'w1';
const INGESTION_SETTINGS_ROUTE = '/ingestion-settings';
const GLOBAL_CONFIG_RESPONSE = {
status: 'success',
data: {
external_url: '',
ingestion_url: 'http://ingest.example.com',
ai_assistant_url: null,
mcp_url: null,
},
};
describe('MultiIngestionSettings Page', () => {
beforeEach(() => {
mockPush.mockClear();
@@ -71,9 +73,6 @@ describe('MultiIngestionSettings Page', () => {
});
it('navigates to create alert with metrics count threshold', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
// Arrange API response with a metrics daily count limit so the alert button is visible
const response: TestGatewayIngestionKeysResponse = {
status: 'success',
data: {
@@ -101,95 +100,174 @@ describe('MultiIngestionSettings Page', () => {
};
server.use(
rest.get('*/api/v1/global/config*', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(GLOBAL_CONFIG_RESPONSE)),
),
rest.get('*/api/v2/gateway/ingestion_keys*', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(response)),
),
);
// Render with initial route to test navigation
render(<MultiIngestionSettings />, undefined, {
initialRoute: INGESTION_SETTINGS_ROUTE,
});
// Wait for ingestion key to load and expand the row to show limits
await screen.findByText('Key One');
const expandButton = screen.getByRole('button', { name: /right Key One/i });
await user.click(expandButton);
fireEvent.click(screen.getByRole('button', { name: /right Key One/i }));
// Wait for limits section to render and click metrics alert button by test id
await screen.findByText('LIMITS');
const metricsAlertBtn = (await screen.findByTestId(
'set-alert-btn-metrics',
)) as HTMLButtonElement;
await user.click(metricsAlertBtn);
fireEvent.click(
(await screen.findByTestId('set-alert-btn-metrics')) as HTMLButtonElement,
);
// Wait for navigation to occur
await waitFor(() => {
expect(mockPush).toHaveBeenCalledTimes(1);
});
// Assert: navigation occurred with correct query parameters
const navigationCall = mockPush.mock.calls[0][0] as string;
// Check URL contains alerts/new route
expect(navigationCall).toContain('/alerts/new');
// Parse query parameters
const urlParams = new URLSearchParams(navigationCall.split('?')[1]);
const thresholds = JSON.parse(urlParams.get(QueryParams.thresholds) || '{}');
expect(thresholds).toBeDefined();
expect(thresholds[0].thresholdValue).toBe(1000);
expect(thresholds[0].unit).toBe('{count}');
// Verify compositeQuery parameter exists and contains correct data
const compositeQuery = JSON.parse(
urlParams.get(QueryParams.compositeQuery) || '{}',
);
expect(compositeQuery.builder).toBeDefined();
expect(compositeQuery.unit).toBe('{count}');
expect(compositeQuery.builder.queryData).toBeDefined();
// Check that the query contains the correct filter expression for the key
const firstQueryData = compositeQuery.builder.queryData[0];
expect(firstQueryData.filter.expression).toContain(
"signoz.workspace.key.id='k1'",
);
// Verify metric name for metrics signal
expect(firstQueryData.aggregations[0].metricName).toBe(
'signoz.meter.metric.datapoint.count',
);
expect(urlParams.get(QueryParams.yAxisUnit)).toBe('{count}');
expect(urlParams.get(QueryParams.ruleName)).toContain('metrics');
});
// skipping the flaky test
it.skip('navigates to create alert for logs with size threshold', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
it('navigates to create alert for logs with GiB threshold and bytes yAxisUnit', async () => {
const GIB = 1073741824;
const sizeInBytes = 400 * GIB;
// Arrange API response with a logs daily size limit so the alert button is visible
const response: TestAllIngestionKeyProps = {
const response: TestGatewayIngestionKeysResponse = {
status: 'success',
data: [
{
name: 'Key Two',
expires_at: TEST_EXPIRES_AT,
value: 'secret',
workspace_id: TEST_WORKSPACE_ID,
id: 'k2',
created_at: TEST_CREATED_UPDATED,
updated_at: TEST_CREATED_UPDATED,
tags: [],
limits: [
{
id: 'l2',
signal: 'logs',
config: { day: { size: 2048 } },
},
],
},
],
_pagination: { page: 1, per_page: 10, pages: 1, total: 1 },
data: {
keys: [
{
name: 'Key Logs',
expires_at: new Date(TEST_EXPIRES_AT),
value: 'secret',
workspace_id: TEST_WORKSPACE_ID,
id: 'k2',
created_at: new Date(TEST_CREATED_UPDATED),
updated_at: new Date(TEST_CREATED_UPDATED),
tags: [],
limits: [
{
id: 'l2',
signal: 'logs',
config: { day: { size: sizeInBytes } },
},
],
},
],
_pagination: { page: 1, per_page: 10, pages: 1, total: 1 },
},
};
server.use(
rest.get('*/workspaces/me/keys*', (_req, res, ctx) =>
rest.get('*/api/v1/global/config*', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(GLOBAL_CONFIG_RESPONSE)),
),
rest.get('*/api/v2/gateway/ingestion_keys*', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(response)),
),
);
render(<MultiIngestionSettings />, undefined, {
initialRoute: INGESTION_SETTINGS_ROUTE,
});
await screen.findByText('Key Logs');
fireEvent.click(screen.getByRole('button', { name: /right Key Logs/i }));
await screen.findByText('LIMITS');
fireEvent.click(
(await screen.findByTestId('set-alert-btn-logs')) as HTMLButtonElement,
);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledTimes(1);
});
const navigationCall = mockPush.mock.calls[0][0] as string;
expect(navigationCall).toContain('/alerts/new');
const urlParams = new URLSearchParams(navigationCall.split('?')[1]);
const thresholds = JSON.parse(urlParams.get(QueryParams.thresholds) || '{}');
expect(thresholds[0].thresholdValue).toBe(400);
expect(thresholds[0].unit).toBe('GiBy');
const compositeQuery = JSON.parse(
urlParams.get(QueryParams.compositeQuery) || '{}',
);
expect(compositeQuery.unit).toBe('bytes');
const firstQueryData = compositeQuery.builder.queryData[0];
expect(firstQueryData.filter.expression).toContain(
"signoz.workspace.key.id='k2'",
);
expect(firstQueryData.aggregations[0].metricName).toBe(
'signoz.meter.log.size',
);
expect(urlParams.get(QueryParams.yAxisUnit)).toBe('bytes');
expect(urlParams.get(QueryParams.ruleName)).toContain('logs');
});
it('shows alert CTAs in view mode and helper text in edit mode for configured limits', async () => {
const KEY_NAME = 'Key With Limits';
const response: TestGatewayIngestionKeysResponse = {
status: 'success',
data: {
keys: [
{
name: KEY_NAME,
expires_at: new Date(TEST_EXPIRES_AT),
value: 'secret',
workspace_id: TEST_WORKSPACE_ID,
id: 'k1',
created_at: new Date(TEST_CREATED_UPDATED),
updated_at: new Date(TEST_CREATED_UPDATED),
tags: [],
limits: [
{
id: 'l1',
signal: 'metrics',
config: { day: { count: 1000 } },
},
{
id: 'l2',
signal: 'logs',
config: { day: { size: 1073741824 } },
},
],
},
],
_pagination: { page: 1, per_page: 10, pages: 1, total: 1 },
},
};
server.use(
rest.get('*/api/v1/global/config*', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(GLOBAL_CONFIG_RESPONSE)),
),
rest.get('*/api/v2/gateway/ingestion_keys*', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(response)),
),
);
@@ -198,54 +276,18 @@ describe('MultiIngestionSettings Page', () => {
initialRoute: INGESTION_SETTINGS_ROUTE,
});
// Wait for ingestion key to load and expand the row to show limits
await screen.findByText('Key Two');
const expandButton = screen.getByRole('button', { name: /right Key Two/i });
await user.click(expandButton);
// Wait for limits section to render and click logs alert button by test id
await screen.findByText(KEY_NAME);
fireEvent.click(
screen.getByRole('button', { name: new RegExp(`right ${KEY_NAME}`, 'i') }),
);
await screen.findByText('LIMITS');
const logsAlertBtn = (await screen.findByTestId(
'set-alert-btn-logs',
)) as HTMLButtonElement;
await user.click(logsAlertBtn);
// Wait for navigation to occur
await waitFor(() => {
expect(mockPush).toHaveBeenCalledTimes(1);
});
expect(screen.getAllByText('Set alert').length).toBeGreaterThan(0);
// Assert: navigation occurred with correct query parameters
const navigationCall = mockPush.mock.calls[0][0] as string;
// Check URL contains alerts/new route
expect(navigationCall).toContain('/alerts/new');
// Parse query parameters
const urlParams = new URLSearchParams(navigationCall.split('?')[1]);
// Verify thresholds parameter
const thresholds = JSON.parse(urlParams.get(QueryParams.thresholds) || '{}');
expect(thresholds).toBeDefined();
expect(thresholds[0].thresholdValue).toBe(2048);
// Verify compositeQuery parameter exists and contains correct data
const compositeQuery = JSON.parse(
urlParams.get(QueryParams.compositeQuery) || '{}',
);
expect(compositeQuery.builder).toBeDefined();
expect(compositeQuery.builder.queryData).toBeDefined();
// Check that the query contains the correct filter expression for the key
const firstQueryData = compositeQuery.builder.queryData[0];
expect(firstQueryData.filter.expression).toContain(
"signoz.workspace.key.id='k2'",
);
// Verify metric name for logs signal
expect(firstQueryData.aggregations[0].metricName).toBe(
'signoz.meter.log.size',
);
fireEvent.click(screen.getByRole('button', { name: 'Edit logs limit' }));
expect(
screen.getByText('You can set up an alert after saving'),
).toBeInTheDocument();
});
it('switches to search API when search text is entered', async () => {
@@ -295,6 +337,9 @@ describe('MultiIngestionSettings Page', () => {
const searchHandler = jest.fn();
server.use(
rest.get('*/api/v1/global/config*', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(GLOBAL_CONFIG_RESPONSE)),
),
rest.get('*/api/v2/gateway/ingestion_keys', (req, res, ctx) => {
if (req.url.pathname.endsWith('/search')) {
return undefined;

View File

@@ -3,6 +3,7 @@ import { Button, Tooltip, Typography } from 'antd';
import ROUTES from 'constants/routes';
import { formUrlParams } from 'container/TraceDetail/utils';
import { Span } from 'types/api/trace/getTraceV2';
import { withBasePath } from 'utils/basePath';
import NoData from '../NoData/NoData';
@@ -25,11 +26,13 @@ function LinkedSpans(props: LinkedSpansProps): JSX.Element {
if (!item.traceId || !item.spanId) {
return null;
}
return `${ROUTES.TRACE}/${item.traceId}${formUrlParams({
spanId: item.spanId,
levelUp: 0,
levelDown: 0,
})}`;
return withBasePath(
`${ROUTES.TRACE}/${item.traceId}${formUrlParams({
spanId: item.spanId,
levelUp: 0,
levelDown: 0,
})}`,
);
}, []);
// Filter out CHILD_OF references as they are parent-child relationships

View File

@@ -184,7 +184,7 @@ func (store *store) UpdatePublic(ctx context.Context, storable *dashboardtypes.S
func (store *store) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
_, err := store.
sqlstore.
BunDB().
BunDBCtx(ctx).
NewDelete().
Model(new(dashboardtypes.StorableDashboard)).
Where("id = ?", id).
@@ -200,7 +200,7 @@ func (store *store) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUI
func (store *store) DeletePublic(ctx context.Context, dashboardID string) error {
_, err := store.
sqlstore.
BunDB().
BunDBCtx(ctx).
NewDelete().
Model(new(dashboardtypes.StorablePublicDashboard)).
Where("dashboard_id = ?", dashboardID).

View File

@@ -147,7 +147,7 @@ func (store *store) CountByOrgID(ctx context.Context, orgID valuer.UUID) (int64,
count, err := store.
sqlstore.
BunDB().
BunDBCtx(ctx).
NewSelect().
Model(storable).
Where("org_id = ?", orgID).

View File

@@ -59,6 +59,12 @@ def pytest_addoption(parser: pytest.Parser):
default="delete",
help="sqlite mode",
)
parser.addoption(
"--sqlite-transaction-mode",
action="store",
default="deferred",
help="sqlite transaction mode",
)
parser.addoption(
"--postgres-version",
action="store",

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();

34
tests/e2e/helpers/auth.ts Normal file
View File

@@ -0,0 +1,34 @@
import type { Browser, BrowserContext } from '@playwright/test';
/**
* Build a fresh authenticated `BrowserContext` via UI login. Used by suite
* hooks (`test.beforeAll` / `test.afterAll`), where the test-scoped
* `authedPage` fixture from `fixtures/auth.ts` is not reachable.
*
* Each call performs one fresh login (~1s). The per-worker storageState
* cache in `fixtures/auth.ts` is intentionally not shared here — keeping
* this helper standalone avoids coupling suite hooks to the fixture's
* private cache.
*/
export 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;
}

View File

@@ -0,0 +1,179 @@
import path from 'path';
import type { APIRequestContext, Locator, Page } from '@playwright/test';
import apmMetricsTemplate from '../testdata/apm-metrics.json';
// ─── Constants ───────────────────────────────────────────────────────────
//
// UI strings and well-known values referenced both within this file and by
// specs. Centralised here so a copy-edit in the app updates one place.
export const DASHBOARDS_LIST_PATH = '/dashboard';
export const SEARCH_PLACEHOLDER = 'Search by name, description, or tags...';
export const LIST_HEADING = 'Dashboards';
/** Title the "Create dashboard" dropdown option assigns by default. */
export const DEFAULT_DASHBOARD_TITLE = 'Sample Title';
/** Title of the APM Metrics dashboard imported from the JSON test fixture. */
export const APM_METRICS_TITLE = (apmMetricsTemplate as { title: string })
.title;
const APM_METRICS_TESTDATA_PATH = path.resolve(
__dirname,
'../testdata/apm-metrics.json',
);
// ─── Auth ────────────────────────────────────────────────────────────────
/**
* Read the JWT the auth fixture stored in `localStorage.AUTH_TOKEN`. The
* page must be on the SigNoz origin first; if not, this navigates to the
* dashboards list to populate localStorage from the context's storageState.
*/
export async function authToken(page: Page): Promise<string> {
if (!page.url().startsWith('http')) {
await page.goto(DASHBOARDS_LIST_PATH);
}
return page.evaluate(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
);
}
// ─── Navigation ──────────────────────────────────────────────────────────
/**
* Navigate to the dashboards list and wait for the page heading. The heading
* is the only element guaranteed to render in both the empty zero-state and
* the populated list view — search input and sort button are hidden in the
* zero-state.
*/
export async function gotoDashboardsList(page: Page): Promise<void> {
await page.goto(DASHBOARDS_LIST_PATH);
await page
.getByRole('heading', { name: LIST_HEADING, level: 1 })
.waitFor({ state: 'visible' });
}
// ─── API helpers ─────────────────────────────────────────────────────────
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 } };
return json.data.id;
}
/** Seed a minimally-named dashboard via API. Returns the new ID. */
export async function createDashboardViaApi(
page: Page,
title: string,
): Promise<string> {
return postDashboard(page, { title, uploadedGrafana: false });
}
/**
* Seed the APM Metrics dashboard by driving the real "Import JSON" UI flow:
* opens the New-dashboard dropdown, picks Import JSON, uploads the fixture
* file, and clicks Import and Next. Returns the new dashboard ID parsed
* from the destination URL.
*
* Pre-condition: the workspace must already be non-empty so the
* `new-dashboard-cta` testid is rendered. Seed a minimal base dashboard via
* `createDashboardViaApi` first if you're calling this from `beforeAll`.
*/
export async function importApmMetricsDashboardViaUI(
page: Page,
): Promise<string> {
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('import-json-menu-cta').click();
const dialog = page.getByRole('dialog');
await dialog.waitFor({ state: 'visible' });
// Ant Upload's hidden <input type="file"> — `setInputFiles` drives the
// file selection without opening the OS file picker. The component's
// `beforeUpload` returns false, so the file is parsed client-side into
// the Monaco editor rather than being POSTed by Ant.
await dialog
.locator('input[type="file"]')
.setInputFiles(APM_METRICS_TESTDATA_PATH);
await dialog.getByRole('button', { name: 'Import and Next' }).click();
await page.waitForURL(/\/dashboard\/[0-9a-f-]+/);
const match = page.url().match(/\/dashboard\/([0-9a-f-]+)/);
if (!match) {
throw new Error(`Expected dashboard ID in URL, got: ${page.url()}`);
}
return match[1];
}
/**
* Best-effort delete via API. Errors are swallowed so suite-level cleanup
* stays resilient when a UI flow already deleted the resource (404) or the
* stack is mid-shutdown.
*/
export async function deleteDashboardViaApi(
request: APIRequestContext,
id: string,
token: string,
): Promise<void> {
await request
.delete(`/api/v1/dashboards/${id}`, {
headers: { Authorization: `Bearer ${token}` },
})
.catch(() => undefined);
}
/** Look up a dashboard ID by exact title via the list API. */
export 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;
}
// ─── List page UI helpers ────────────────────────────────────────────────
/**
* Filter the list to a specific dashboard by name and open its row action
* menu. Returns the tooltip locator that wraps View / Open in New Tab /
* Copy Link / Export JSON / Delete dashboard.
*
* The action icon scrolls out of the viewport when the matching row lands
* below the table's sticky header — `scrollIntoViewIfNeeded` keeps the
* click reliable regardless of how many other dashboards exist.
*/
export async function openDashboardActionMenu(
page: Page,
dashboardName: string,
): Promise<Locator> {
await page.getByPlaceholder(SEARCH_PLACEHOLDER).fill(dashboardName);
const icon = page.getByTestId('dashboard-action-icon').first();
await icon.scrollIntoViewIfNeeded();
await icon.click();
return page.getByRole('tooltip');
}

3639
tests/e2e/testdata/apm-metrics.json vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,570 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
import { newAdminContext } from '../../helpers/auth';
import {
APM_METRICS_TITLE,
authToken,
createDashboardViaApi,
DEFAULT_DASHBOARD_TITLE,
deleteDashboardViaApi,
findDashboardIdByTitle,
gotoDashboardsList,
importApmMetricsDashboardViaUI,
openDashboardActionMenu,
SEARCH_PLACEHOLDER,
} from '../../helpers/dashboards';
// Tests in this file mutate the dashboard list (create / delete). Run them
// serially within the worker so state from one test does not leak into
// another's assertions. Files still run in parallel via the project-level
// fullyParallel setting.
test.describe.configure({ mode: 'serial' });
// ─── Suite-level seed registry ───────────────────────────────────────────
//
// Every dashboard a test creates is recorded here, and one `afterAll`
// deletes the lot at suite teardown. Individual tests do not need their
// own `try / finally` cleanup blocks.
const seedIds = new Set<string>();
const BASE_FIXTURE_TITLE = 'dashboards-list-base-fixture';
/** Seed a dashboard via API and register it for suite cleanup. */
async function seed(page: Page, title: string): Promise<string> {
const id = await createDashboardViaApi(page, title);
seedIds.add(id);
return id;
}
test.beforeAll(async ({ browser }) => {
// Persistent fixtures the read-only tests rely on:
// - A minimal base dashboard — keeps the list non-empty so the search
// input / sort button render. Seeded first via API so the workspace
// is populated before the UI import flow runs.
// - APM Metrics — a richer, real-world dashboard imported through the
// real Import JSON UI flow (file upload + Monaco editor + submit).
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
seedIds.add(await createDashboardViaApi(page, BASE_FIXTURE_TITLE));
seedIds.add(await importApmMetricsDashboardViaUI(page));
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) {
return;
}
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of seedIds) {
await deleteDashboardViaApi(ctx.request, id, token);
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 gotoDashboardsList(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.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 ({
authedPage: page,
}) => {
const name = 'dashboards-list-search-title';
await seed(page, name);
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/);
}
});
// ─── Row actions (context menu) ──────────────────────────────────────────
test('TC-09 admin sees all five options in the action menu', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-actions-menu';
await seed(page, name);
await gotoDashboardsList(page);
const tooltip = await openDashboardActionMenu(page, name);
await expect(tooltip).toBeVisible();
await expect(tooltip.getByRole('button', { name: 'View' })).toBeVisible();
await expect(
tooltip.getByRole('button', { name: 'Open in New Tab' }),
).toBeVisible();
await expect(
tooltip.getByRole('button', { name: 'Copy Link' }),
).toBeVisible();
await expect(
tooltip.getByRole('button', { name: 'Export JSON' }),
).toBeVisible();
// Delete is rendered as a generic, not a button.
await expect(tooltip.getByText('Delete dashboard')).toBeVisible();
});
test('TC-10 view action navigates to the dashboard detail page', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-action-view';
await seed(page, name);
await gotoDashboardsList(page);
const tooltip = await openDashboardActionMenu(page, name);
await tooltip.getByRole('button', { name: 'View' }).click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
});
test('TC-11 open in new tab opens the dashboard in a new browser tab', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-action-newtab';
await seed(page, name);
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.
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/);
});
});

View File

@@ -12,12 +12,12 @@
"types": ["node", "@playwright/test"],
"paths": {
"@tests/*": ["./tests/*"],
"@utils/*": ["./utils/*"],
"@helpers/*": ["./helpers/*"],
"@specs/*": ["./specs/*"]
},
"outDir": "./dist",
"rootDir": "."
},
"include": ["tests/**/*.ts", "utils/**/*.ts", "playwright.config.ts"],
"include": ["tests/**/*.ts", "helpers/**/*.ts", "fixtures/**/*.ts", "playwright.config.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -30,6 +30,7 @@ def sqlite(
assert result.fetchone()[0] == 1
mode = pytestconfig.getoption("--sqlite-mode")
transaction_mode = pytestconfig.getoption("--sqlite-transaction-mode")
return types.TestContainerSQL(
container=types.TestContainerDocker(
id="",
@@ -41,6 +42,7 @@ def sqlite(
"SIGNOZ_SQLSTORE_PROVIDER": "sqlite",
"SIGNOZ_SQLSTORE_SQLITE_PATH": str(path),
"SIGNOZ_SQLSTORE_SQLITE_MODE": mode,
"SIGNOZ_SQLSTORE_SQLITE_TRANSACTION__MODE": transaction_mode,
},
)