mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-06 18:40:32 +01:00
Compare commits
9 Commits
no-auth-fe
...
test/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a80b7c8675 | ||
|
|
d49cc9bb63 | ||
|
|
11e2025a73 | ||
|
|
0b2c668153 | ||
|
|
8a8d875589 | ||
|
|
4b901afa8c | ||
|
|
74cd8b6d83 | ||
|
|
b60dbb9ba2 | ||
|
|
02cf588461 |
154
.claude/agents/playwright-test-generator.md
Normal file
154
.claude/agents/playwright-test-generator.md
Normal file
@@ -0,0 +1,154 @@
|
||||
---
|
||||
name: playwright-test-generator
|
||||
description: Use this agent to convert a SigNoz E2E test plan into Playwright spec files under `tests/e2e/tests/<feature>/`. Examples — <example>Context: A test plan exists and needs to be turned into runnable specs. user: 'Generate the dashboards list specs from the plan in tests/e2e/specs/dashboards-list-test-plan.md' assistant: 'Using the generator agent to drive each scenario in a real browser and write the corresponding Playwright tests.'</example>
|
||||
tools: Glob, Grep, Read, Bash, mcp__playwright-test__browser_click, mcp__playwright-test__browser_drag, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_file_upload, mcp__playwright-test__browser_handle_dialog, mcp__playwright-test__browser_hover, mcp__playwright-test__browser_navigate, mcp__playwright-test__browser_press_key, mcp__playwright-test__browser_select_option, mcp__playwright-test__browser_snapshot, mcp__playwright-test__browser_type, mcp__playwright-test__browser_verify_element_visible, mcp__playwright-test__browser_verify_list_visible, mcp__playwright-test__browser_verify_text_visible, mcp__playwright-test__browser_verify_value, mcp__playwright-test__browser_wait_for, mcp__playwright-test__generator_read_log, mcp__playwright-test__generator_setup_page, mcp__playwright-test__generator_write_test
|
||||
model: sonnet
|
||||
color: blue
|
||||
---
|
||||
|
||||
You are the Playwright Test Generator for the SigNoz frontend. You take a plan written by `playwright-test-planner` and produce runnable Playwright specs that match the conventions documented in [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md). **Read that doc first.** Adhere to it.
|
||||
|
||||
# Repo conventions you must follow
|
||||
|
||||
- **Spec location:** `tests/e2e/tests/<feature>/<spec-name>.spec.ts`. One file per resource; cross-resource concerns get their own file.
|
||||
- **Auth fixture:** import `test` and `expect` from `'../../fixtures/auth'`, not `@playwright/test`. Specs receive an admin-authenticated page via the `authedPage` fixture (the only user the bootstrap seeds).
|
||||
```ts
|
||||
import { test, expect } from '../../fixtures/auth';
|
||||
|
||||
test('TC-01 alerts page — tabs render', async ({ authedPage: page }) => {
|
||||
await page.goto('/alerts');
|
||||
await expect(page.getByRole('tab', { name: /alert rules/i })).toBeVisible();
|
||||
});
|
||||
```
|
||||
- **Test titles:** `TC-NN <short description>` — matches the planner's IDs.
|
||||
- **Self-contained state.** 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/dashboards-list.spec.ts](../../tests/e2e/tests/dashboards/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). Lock onto the TC-NN you're generating.
|
||||
2. **Set up the page.** Call `generator_setup_page` once per scenario before any browser tool. The setup logs in as the admin user (the bootstrap-seeded `admin@integration.test`).
|
||||
3. **Drive the scenario manually.** For each step in the plan:
|
||||
- Use the description as the intent (it becomes the comment above the generated step).
|
||||
- Use the appropriate `mcp__playwright-test__*` browser tool to execute it (click / type / verify / wait).
|
||||
- For verifications, use the dedicated `browser_verify_*` tools — they capture the assertion as Playwright code in the log.
|
||||
4. **Read the log.** Call `generator_read_log` immediately after the last step. Don't intersperse other tool calls.
|
||||
5. **Write the spec.** Call `generator_write_test` with:
|
||||
- **File path:** `tests/e2e/tests/<feature>/<scenario-slug>.spec.ts` — fs-friendly slug from the scenario title.
|
||||
- **Single test per file** if the planner specified one-test-per-file; otherwise group related tests into one file with a shared `test.describe('<Feature>', () => { … })`.
|
||||
- **`describe` block** matches the top-level plan section.
|
||||
- **Title** matches `TC-NN <description>` exactly.
|
||||
- **Step comments** before each action — one per step text from the plan, no duplicates.
|
||||
- **Imports** from `../../fixtures/auth`. **Do not** import from `@playwright/test` directly.
|
||||
- **Try / finally** cleanup using the API (delete the resources you seeded).
|
||||
|
||||
# Example output
|
||||
|
||||
For a plan section:
|
||||
|
||||
```markdown
|
||||
### 1. Page Load
|
||||
|
||||
#### TC-01 page chrome and core controls render
|
||||
**Steps:**
|
||||
1. Navigate to `/dashboard`
|
||||
2. Verify the page title is "SigNoz | All Dashboards"
|
||||
3. Verify the heading "Dashboards" is visible
|
||||
**Cleanup:** delete the seeded dashboard via API.
|
||||
```
|
||||
|
||||
You produce (suite-level shape, preferred for files with multiple scenarios):
|
||||
|
||||
```ts
|
||||
// tests/e2e/tests/dashboards/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, 'dashboards-list-chrome');
|
||||
|
||||
// 1. Navigate to /dashboard
|
||||
await gotoDashboardsList(page);
|
||||
|
||||
// 2. Verify the page title
|
||||
await expect(page).toHaveTitle('SigNoz | All Dashboards');
|
||||
|
||||
// 3. Verify the heading is visible
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
# 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.
|
||||
63
.claude/agents/playwright-test-healer.md
Normal file
63
.claude/agents/playwright-test-healer.md
Normal 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/dashboards-list.spec.ts is failing, fix it' assistant: 'Using the healer agent to debug each failing scenario and adjust the spec.'</example> <example>Context: After a frontend change a previously-green spec broke. user: 'TC-09 in alerts started failing' assistant: 'Launching the healer to investigate.'</example>
|
||||
tools: Glob, Grep, Read, Write, Edit, Bash, mcp__playwright-test__browser_console_messages, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_generate_locator, mcp__playwright-test__browser_network_requests, mcp__playwright-test__browser_snapshot, mcp__playwright-test__test_debug, mcp__playwright-test__test_list, mcp__playwright-test__test_run
|
||||
model: sonnet
|
||||
color: red
|
||||
---
|
||||
|
||||
You are the Playwright Test Healer for the SigNoz E2E suite. You debug and fix red specs with a methodical approach. Read [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) before you start — it documents the harness and the conventions you must preserve.
|
||||
|
||||
# Preconditions
|
||||
|
||||
The E2E backend stack must be up. If `tests/e2e/.env.local` does not exist, ask the user to bring up the stack via:
|
||||
```
|
||||
cd tests
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse --with-web e2e/bootstrap/setup.py::test_setup
|
||||
```
|
||||
Don't try to start the stack yourself — it can take ~4 minutes on a cold build and the user controls when to pay that cost.
|
||||
|
||||
# Workflow
|
||||
|
||||
1. **Inventory.** `mcp__playwright-test__test_list` (or `npx playwright test <file> --list` from `tests/e2e/`) to see all tests in the spec.
|
||||
2. **Initial run.** `mcp__playwright-test__test_run` (or `npx playwright test <file> --project=chromium`) to identify failing tests. Don't run all browsers — chromium first.
|
||||
3. **Per failing test, debug.** Use `mcp__playwright-test__test_debug` to attach. When the test pauses on the error:
|
||||
- `browser_snapshot` to read the current accessibility tree.
|
||||
- `browser_console_messages` for client-side errors.
|
||||
- `browser_network_requests` for API failures (the SigNoz API requires `Authorization: Bearer <localStorage.AUTH_TOKEN>`; 401s usually mean the test bypassed the fixture).
|
||||
- `browser_generate_locator` to suggest a stable locator if the failing one drifted.
|
||||
4. **Root-cause.** Distinguish between:
|
||||
- **Selector drift** — the app changed `data-testid` or text. Fix the locator. Prefer `data-testid` (grep `frontend/src/<feature-dir>/` for the new one).
|
||||
- **Timing** — the test races a load. Replace `waitForTimeout` with `await expect(locator).toBe…()` or `page.waitForResponse(...)` on the triggering action.
|
||||
- **State leak** — a previous test left data behind, or this test assumes data the bootstrap doesn't seed. Ensure the test seeds via API and cleans up in `try / finally`. The bootstrap creates a fresh stack with **zero** dashboards / alerts.
|
||||
- **Genuine app bug** — the app is broken, not the test. Mark the test with `test.fixme(...)` and add a one-line `// known: <description>` comment. Don't silently change the assertion to make it pass.
|
||||
5. **Fix.** Edit the spec. Preserve TC-NN titles, the `authedPage` fixture, `try / finally` cleanup, and serial mode if present.
|
||||
6. **Re-run only the fixed test** before moving to the next failure. 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`.
|
||||
99
.claude/agents/playwright-test-planner.md
Normal file
99
.claude/agents/playwright-test-planner.md
Normal file
@@ -0,0 +1,99 @@
|
||||
---
|
||||
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**
|
||||
- Default location: `tests/e2e/specs/<feature>/<feature>-test-plan.md` — one directory per feature, the test plan and any related QA checklists live alongside each other.
|
||||
- QA checklists (manual verification runbooks distinct from the TC-NN plan) go under `tests/e2e/specs/<feature>/checklists/<feature>-functional-checklist.md`. See [tests/e2e/specs/dashboards/checklists/dashboards-list-functional-checklist.md](../../tests/e2e/specs/dashboards/checklists/dashboards-list-functional-checklist.md) for shape.
|
||||
- Use clear headings, numbered steps, and a top-level "Application Overview" section.
|
||||
- At the top of the file, list any pre-existing limitations (e.g. "ascending sort not yet implemented") so the generator emits them as `// known behaviour` comments rather than failing assertions.
|
||||
|
||||
<example-spec>
|
||||
# Dashboards List Page — Test Plan
|
||||
|
||||
## Application Overview
|
||||
|
||||
The dashboards list page (`/dashboard`) lists all dashboards in the workspace. From here a user can:
|
||||
|
||||
- Search by title, description, or tags (real-time, URL-synced via `?search=`)
|
||||
- Sort by last-updated (URL-synced via `?columnKey=&order=`)
|
||||
- Open per-row actions: View, Open in New Tab, Copy Link, Export JSON, Delete dashboard
|
||||
- Create a new dashboard via the "New dashboard" dropdown (Create dashboard / Import JSON / View templates)
|
||||
|
||||
**Bootstrap state:** the pytest harness creates a fresh stack with no pre-seeded dashboards. Every test must seed its own data. The "Enter dashboard name…" inline input is a "request a new template" feedback form — **not** a create flow. The only UI create path is the dropdown.
|
||||
|
||||
**Known limitations:**
|
||||
- Ascending sort is not yet implemented — repeated clicks on the sort button keep `order=descend`.
|
||||
- Cancelling the delete confirmation dialog navigates to the dashboard detail page rather than staying on the list.
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### 1. Page Load and Layout
|
||||
|
||||
#### TC-01 page chrome and core controls render
|
||||
**Preconditions:** at least one dashboard exists (seed via API).
|
||||
|
||||
**Steps:**
|
||||
1. Navigate to `/dashboard`.
|
||||
2. Verify URL is `/dashboard` (no query params).
|
||||
3. Verify the page heading "Dashboards" (level 1) is visible.
|
||||
4. ...
|
||||
|
||||
**Expected:**
|
||||
- All Dashboards section header rendered.
|
||||
- Search input, sort button, and at least one dashboard thumbnail visible.
|
||||
|
||||
**Cleanup:** delete the seeded dashboard via `DELETE /api/v1/dashboards/<id>`.
|
||||
|
||||
#### TC-02 ...
|
||||
</example-spec>
|
||||
|
||||
**Quality bar:**
|
||||
- Steps must be specific enough that any tester (or the generator agent) can follow without ambiguity.
|
||||
- Include negative scenarios — empty state, no-match search, validation errors.
|
||||
- Each scenario must own its preconditions and cleanup. **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.
|
||||
|
||||
**Output format:** a single Markdown file under `tests/e2e/specs/<feature>/<feature>-test-plan.md` ready to hand to the generator agent.
|
||||
@@ -7,4 +7,8 @@ deploy
|
||||
sample-apps
|
||||
|
||||
# frontend
|
||||
node_modules
|
||||
**/node_modules
|
||||
|
||||
# local env files (tracked example.env templates are unaffected)
|
||||
**/.env
|
||||
**/.env.*
|
||||
@@ -81,8 +81,17 @@ tests/
|
||||
├── .env.local # generated by bootstrap/setup.py (gitignored)
|
||||
├── bootstrap/
|
||||
│ └── setup.py # test_setup / test_teardown — pytest lifecycle
|
||||
├── fixtures/
|
||||
├── fixtures/ # Playwright test fixtures (test.extend) only
|
||||
│ └── auth.ts # authedPage Playwright fixture + per-worker storageState cache
|
||||
├── helpers/ # function helpers + the constants they share with specs
|
||||
│ ├── auth.ts # newAdminContext(browser) for beforeAll/afterAll
|
||||
│ └── dashboards.ts # gotoDashboardsList, createDashboardViaApi, openDashboardActionMenu, plus SEARCH_PLACEHOLDER, APM_METRICS_TITLE…
|
||||
├── testdata/ # static data files (JSON) used by helpers and specs
|
||||
│ └── apm-metrics.json # real APM dashboard payload — seed via importApmMetricsDashboardViaUI
|
||||
├── specs/ # human-readable test plans + QA checklists per feature
|
||||
│ └── dashboards/
|
||||
│ ├── dashboards-list-test-plan.md
|
||||
│ └── checklists/
|
||||
├── tests/ # Playwright .spec.ts files, one dir per feature area
|
||||
│ └── alerts/
|
||||
│ └── alerts.spec.ts
|
||||
@@ -92,12 +101,36 @@ tests/
|
||||
└── 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 includes both behaviour helpers (`gotoDashboardsList(page)`, `createDashboardViaApi(page, title)`, `openDashboardActionMenu(page, name)`, `newAdminContext(browser)`) and the constants those helpers and the specs both refer to (`SEARCH_PLACEHOLDER`, `LIST_HEADING`, `DEFAULT_DASHBOARD_TITLE`, `APM_METRICS_TITLE`). Constants live next to the helpers that use them so a single import line in a spec covers both.
|
||||
- **`testdata/`** holds static data files (typically JSON / YAML) consumed by the helpers — currently just `apm-metrics.json`, the real dashboard payload that `importApmMetricsDashboardViaUI` uploads through the UI.
|
||||
|
||||
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/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 +188,23 @@ test('TC-02 alerts list — create, toggle, delete', async ({ authedPage: page }
|
||||
|
||||
### Locator priority
|
||||
|
||||
1. `getByRole('button', { name: 'Submit' })`
|
||||
2. `getByLabel('Email')`
|
||||
3. `getByPlaceholder('...')`
|
||||
4. `getByText('...')`
|
||||
5. `getByTestId('...')`
|
||||
1. `getByTestId('...')` — preferred when the source exposes one. Stable, app-author-provided handle that survives copy-edits.
|
||||
2. `getByRole('button', { name: 'Submit' })`
|
||||
3. `getByLabel('Email')`
|
||||
4. `getByPlaceholder('...')`
|
||||
5. `getByText('...')`
|
||||
6. `locator('.ant-select')` — last resort (Ant Design dropdowns often have no semantic alternative)
|
||||
|
||||
## Agents
|
||||
|
||||
Three Claude agents in `.claude/agents/` accelerate writing and maintaining E2E specs:
|
||||
|
||||
- **`playwright-test-planner`** — explores a feature in a real browser plus the local frontend source and writes a test plan to `tests/e2e/specs/<feature>-test-plan.md`.
|
||||
- **`playwright-test-generator`** — converts a test plan into Playwright spec files under `tests/e2e/tests/<feature>/`. Drives each scenario through MCP browser tools and emits TC-NN-titled tests using the `authedPage` fixture and the API-seed pattern.
|
||||
- **`playwright-test-healer`** — runs failing specs, debugs them with snapshots / console / network introspection, and edits the spec to fix selector drift, timing, or state-leak issues.
|
||||
|
||||
The agents rely on the Playwright-test MCP server (`mcp__playwright-test__*` tools). Configure it in your Claude MCP settings; the permission allowlist lives in [.claude/settings.local.json](../../../.claude/settings.local.json).
|
||||
|
||||
## How to run E2E tests?
|
||||
|
||||
### Running All Tests
|
||||
|
||||
@@ -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
34
tests/e2e/helpers/auth.ts
Normal 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;
|
||||
}
|
||||
179
tests/e2e/helpers/dashboards.ts
Normal file
179
tests/e2e/helpers/dashboards.ts
Normal 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
3639
tests/e2e/testdata/apm-metrics.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
668
tests/e2e/tests/dashboards/dashboards-list.spec.ts
Normal file
668
tests/e2e/tests/dashboards/dashboards-list.spec.ts
Normal file
@@ -0,0 +1,668 @@
|
||||
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();
|
||||
});
|
||||
|
||||
test('TC-02 row shows thumbnail and creator email', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDashboardsList(page);
|
||||
await page
|
||||
.getByAltText('dashboard-image')
|
||||
.first()
|
||||
.waitFor({ state: 'visible' });
|
||||
|
||||
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
|
||||
// Creator email — admin@integration.test contains '@'.
|
||||
await expect(page.getByText(/@/).first()).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Search functionality ────────────────────────────────────────────────
|
||||
|
||||
test('TC-03 search by title returns matching dashboard', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-search-title';
|
||||
await 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-04 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.
|
||||
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-05 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-06 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-07 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-08 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 in any code path.
|
||||
|
||||
test('TC-09 default load has no sort params', async ({ authedPage: page }) => {
|
||||
await gotoDashboardsList(page);
|
||||
|
||||
await expect(page).toHaveURL('/dashboard');
|
||||
await expect(page).not.toHaveURL(/columnKey/);
|
||||
await expect(page).not.toHaveURL(/order/);
|
||||
|
||||
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-10 selecting "Last updated" adds columnKey=updatedAt&order=descend to URL', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDashboardsList(page);
|
||||
|
||||
await expect(page).not.toHaveURL(/columnKey/);
|
||||
await page.getByTestId('sort-by').click();
|
||||
const lastUpdated = page.getByTestId('sort-by-last-updated');
|
||||
await lastUpdated.waitFor({ state: 'visible' });
|
||||
await lastUpdated.click();
|
||||
|
||||
await expect(page).toHaveURL(/columnKey=updatedAt/);
|
||||
await expect(page).toHaveURL(/order=descend/);
|
||||
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-11 selecting "Last created" also yields order=descend (ascending not yet implemented)', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDashboardsList(page);
|
||||
|
||||
await page.getByTestId('sort-by').click();
|
||||
const lastCreated = page.getByTestId('sort-by-last-created');
|
||||
await lastCreated.waitFor({ state: 'visible' });
|
||||
await lastCreated.click();
|
||||
|
||||
await expect(page).toHaveURL(/columnKey=createdAt/);
|
||||
await expect(page).toHaveURL(/order=descend/);
|
||||
await expect(page).not.toHaveURL(/order=ascend/);
|
||||
});
|
||||
|
||||
// ─── Row actions (context menu) ──────────────────────────────────────────
|
||||
|
||||
test('TC-12 admin sees all five options in the action menu', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-actions-menu';
|
||||
await 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-13 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-14 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-15 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-16 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-17 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-18 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-19 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-20 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-21 Import JSON dialog closes on Escape without creating a dashboard', 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();
|
||||
|
||||
// The Monaco editor inside the modal grabs focus on mount and swallows
|
||||
// Escape. Click the modal title first to blur Monaco; then Ant's Modal
|
||||
// `keyboard` handler picks up the Escape and dismisses the dialog.
|
||||
await dialog.getByText('Import Dashboard JSON').click();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(page).toHaveURL(/\/dashboard($|\?)/);
|
||||
});
|
||||
|
||||
test('TC-22 Import JSON dialog closes on clicking the close button', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await 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 dialog.getByRole('button', { name: /close/i }).click();
|
||||
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(page).toHaveURL(/\/dashboard($|\?)/);
|
||||
});
|
||||
|
||||
// ─── Deleting dashboards ─────────────────────────────────────────────────
|
||||
//
|
||||
// Known behaviour: clicking Cancel in the confirmation dialog navigates to
|
||||
// the dashboard detail page rather than staying on the list.
|
||||
|
||||
test('TC-23 delete confirmation dialog shows dashboard name with Cancel and Delete buttons', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-delete-confirm';
|
||||
await 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-24 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-25 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-26 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-27 dashboard detail page shows the breadcrumb after row click', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-breadcrumb';
|
||||
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-]+/);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Dashboard \// }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-28 sidebar Dashboards link navigates to the list page', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto('/home');
|
||||
// Sidebar items are <div class="nav-item"> with the label as visible
|
||||
// text — they're not <a role="link">, so getByRole won't reach them.
|
||||
// Filter on the exact label to avoid matching nested items that
|
||||
// happen to contain the substring.
|
||||
await page
|
||||
.locator('.nav-item')
|
||||
.filter({ hasText: /^Dashboards$/ })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
await expect(page).toHaveTitle('SigNoz | All Dashboards');
|
||||
});
|
||||
|
||||
// ─── URL state and deep linking ──────────────────────────────────────────
|
||||
|
||||
test('TC-29 search term updates the URL in real time', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDashboardsList(page);
|
||||
await page.getByPlaceholder(SEARCH_PLACEHOLDER).fill('realtime');
|
||||
await expect(page).toHaveURL(/search=realtime/);
|
||||
});
|
||||
|
||||
test('TC-30 browser Back after navigating to a dashboard restores search state', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-back-search';
|
||||
await 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-31 sort params appear in URL only after interacting with the sort button', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDashboardsList(page);
|
||||
await expect(page).not.toHaveURL(/columnKey/);
|
||||
|
||||
await page.getByTestId('sort-by').click();
|
||||
await page.getByTestId('sort-by-last-updated').click();
|
||||
await expect(page).toHaveURL(/columnKey=updatedAt/);
|
||||
await expect(page).toHaveURL(/order=descend/);
|
||||
|
||||
// Direct navigation with sort params should honour them on load.
|
||||
await page.goto('/dashboard?columnKey=updatedAt&order=descend');
|
||||
await page
|
||||
.getByRole('heading', { name: 'Dashboards', level: 1 })
|
||||
.waitFor({ state: 'visible' });
|
||||
await expect(page).toHaveURL(/columnKey=updatedAt/);
|
||||
await expect(page).toHaveURL(/order=descend/);
|
||||
});
|
||||
|
||||
// ─── Page header actions ─────────────────────────────────────────────────
|
||||
|
||||
test('TC-32 feedback button is visible and stays on the page when clicked', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDashboardsList(page);
|
||||
const feedback = page.getByRole('button', { name: 'Feedback' });
|
||||
await expect(feedback).toBeVisible();
|
||||
|
||||
await feedback.click();
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
});
|
||||
|
||||
test('TC-33 share button is visible and stays on the page when clicked', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDashboardsList(page);
|
||||
const share = page.getByRole('button', { name: 'Share' });
|
||||
await expect(share).toBeVisible();
|
||||
|
||||
await share.click();
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user