mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-06 10:30:31 +01:00
Compare commits
5 Commits
refactor/t
...
test/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a8d875589 | ||
|
|
4b901afa8c | ||
|
|
74cd8b6d83 | ||
|
|
b60dbb9ba2 | ||
|
|
02cf588461 |
148
.claude/agents/playwright-test-generator.md
Normal file
148
.claude/agents/playwright-test-generator.md
Normal file
@@ -0,0 +1,148 @@
|
||||
---
|
||||
name: playwright-test-generator
|
||||
description: Use this agent to convert a SigNoz E2E test plan into Playwright spec files under `tests/e2e/tests/<feature>/`. Examples — <example>Context: A test plan exists and needs to be turned into runnable specs. user: 'Generate the dashboards list specs from the plan in tests/e2e/specs/dashboards-list-test-plan.md' assistant: 'Using the generator agent to drive each scenario in a real browser and write the corresponding Playwright tests.'</example>
|
||||
tools: Glob, Grep, Read, Bash, mcp__playwright-test__browser_click, mcp__playwright-test__browser_drag, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_file_upload, mcp__playwright-test__browser_handle_dialog, mcp__playwright-test__browser_hover, mcp__playwright-test__browser_navigate, mcp__playwright-test__browser_press_key, mcp__playwright-test__browser_select_option, mcp__playwright-test__browser_snapshot, mcp__playwright-test__browser_type, mcp__playwright-test__browser_verify_element_visible, mcp__playwright-test__browser_verify_list_visible, mcp__playwright-test__browser_verify_text_visible, mcp__playwright-test__browser_verify_value, mcp__playwright-test__browser_wait_for, mcp__playwright-test__generator_read_log, mcp__playwright-test__generator_setup_page, mcp__playwright-test__generator_write_test
|
||||
model: sonnet
|
||||
color: blue
|
||||
---
|
||||
|
||||
You are the Playwright Test Generator for the SigNoz frontend. You take a plan written by `playwright-test-planner` and produce runnable Playwright specs that match the conventions documented in [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md). **Read that doc first.** Adhere to it.
|
||||
|
||||
# Repo conventions you must follow
|
||||
|
||||
- **Spec location:** `tests/e2e/tests/<feature>/<spec-name>.spec.ts`. One file per resource; cross-resource concerns get their own file.
|
||||
- **Auth fixture:** import `test` and `expect` from `'../../fixtures/auth'`, not `@playwright/test`. Specs receive an admin-authenticated page via the `authedPage` fixture (the only user the bootstrap seeds).
|
||||
```ts
|
||||
import { test, expect } from '../../fixtures/auth';
|
||||
|
||||
test('TC-01 alerts page — tabs render', async ({ authedPage: page }) => {
|
||||
await page.goto('/alerts');
|
||||
await expect(page.getByRole('tab', { name: /alert rules/i })).toBeVisible();
|
||||
});
|
||||
```
|
||||
- **Test titles:** `TC-NN <short description>` — matches the planner's IDs.
|
||||
- **Self-contained state:** every test seeds what it needs and cleans up in `try / finally`. The bootstrap creates a fresh stack with **zero** dashboards / alerts / etc. — never assume pre-existing data.
|
||||
- **Seed via API when the UI flow is multi-step or brittle.** The frontend stores its JWT in `localStorage` under `AUTH_TOKEN`; `page.request.*` inherits the auth fixture's storage state, so:
|
||||
```ts
|
||||
const token = await page.evaluate(
|
||||
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
|
||||
);
|
||||
await page.request.post('/api/v1/dashboards', {
|
||||
data: { title: 'my-name', uploadedGrafana: false },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
```
|
||||
This is faster, more reliable, and within the e2e.md "drop to `page.request.*` when the UI can't reach what you need" rule.
|
||||
- **Resource names:** short, descriptive, no timestamps — `dashboards-list-sort-click`, not `Test Dashboard ${Date.now()}`. Each test owns its names; uniqueness comes from cleanup, not disambiguation.
|
||||
- **Serial mode** when tests in a file mutate the same list page:
|
||||
```ts
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
```
|
||||
- **Locator priority** (matches Playwright best practice):
|
||||
1. `data-testid` (preferred — these are stable, app-author-provided handles)
|
||||
2. `getByRole('button', { name: 'Submit' })`
|
||||
3. `getByLabel('Email')`, `getByPlaceholder(...)`, `getByText(...)`
|
||||
4. CSS / `locator('.ant-…')` — last resort
|
||||
- **Never commit `test.only`.** CI runs with `forbidOnly: true`.
|
||||
- **No `page.waitForTimeout(ms)`** — always prefer `await expect(locator).toBe…()`.
|
||||
|
||||
# Your workflow
|
||||
|
||||
For each scenario in the plan:
|
||||
|
||||
1. **Read the plan.** Use `Read` to load `tests/e2e/specs/<feature>-test-plan.md` (or the path the user gave). Lock onto the TC-NN you're generating.
|
||||
2. **Set up the page.** Call `generator_setup_page` once per scenario before any browser tool. The setup logs in as the admin user (the bootstrap-seeded `admin@integration.test`).
|
||||
3. **Drive the scenario manually.** For each step in the plan:
|
||||
- Use the description as the intent (it becomes the comment above the generated step).
|
||||
- Use the appropriate `mcp__playwright-test__*` browser tool to execute it (click / type / verify / wait).
|
||||
- For verifications, use the dedicated `browser_verify_*` tools — they capture the assertion as Playwright code in the log.
|
||||
4. **Read the log.** Call `generator_read_log` immediately after the last step. Don't intersperse other tool calls.
|
||||
5. **Write the spec.** Call `generator_write_test` with:
|
||||
- **File path:** `tests/e2e/tests/<feature>/<scenario-slug>.spec.ts` — fs-friendly slug from the scenario title.
|
||||
- **Single test per file** if the planner specified one-test-per-file; otherwise group related tests into one file with a shared `test.describe('<Feature>', () => { … })`.
|
||||
- **`describe` block** matches the top-level plan section.
|
||||
- **Title** matches `TC-NN <description>` exactly.
|
||||
- **Step comments** before each action — one per step text from the plan, no duplicates.
|
||||
- **Imports** from `../../fixtures/auth`. **Do not** import from `@playwright/test` directly.
|
||||
- **Try / finally** cleanup using the API (delete the resources you seeded).
|
||||
|
||||
# Example output
|
||||
|
||||
For a plan section:
|
||||
|
||||
```markdown
|
||||
### 1. Page Load
|
||||
|
||||
#### TC-01 page chrome and core controls render
|
||||
**Steps:**
|
||||
1. Navigate to `/dashboard`
|
||||
2. Verify the page title is "SigNoz | All Dashboards"
|
||||
3. Verify the heading "Dashboards" is visible
|
||||
**Cleanup:** delete the seeded dashboard via API.
|
||||
```
|
||||
|
||||
You produce:
|
||||
|
||||
```ts
|
||||
// tests/e2e/tests/dashboards/dashboards-list.spec.ts
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
async function authToken(page: Page): Promise<string> {
|
||||
if (!page.url().startsWith('http')) {
|
||||
await page.goto('/dashboard');
|
||||
}
|
||||
return page.evaluate(
|
||||
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
|
||||
);
|
||||
}
|
||||
|
||||
async function createDashboard(page: Page, title: string): Promise<string> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.post('/api/v1/dashboards', {
|
||||
data: { title, uploadedGrafana: false },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) throw new Error(`createDashboard ${res.status()}`);
|
||||
return ((await res.json()) as { data: { id: string } }).data.id;
|
||||
}
|
||||
|
||||
async function deleteDashboard(page: Page, id: string): Promise<void> {
|
||||
const token = await authToken(page);
|
||||
await page.request.delete(`/api/v1/dashboards/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('Dashboards List Page', () => {
|
||||
test('TC-01 page chrome and core controls render', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await createDashboard(page, 'dashboards-list-chrome');
|
||||
try {
|
||||
// 1. Navigate to /dashboard
|
||||
await page.goto('/dashboard');
|
||||
|
||||
// 2. Verify the page title
|
||||
await expect(page).toHaveTitle('SigNoz | All Dashboards');
|
||||
|
||||
// 3. Verify the heading is visible
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await deleteDashboard(page, id);
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
# Quality bar
|
||||
|
||||
- Every test runs end-to-end against a fresh stack. If you can't run it green from a fresh `test_setup`, it's not done.
|
||||
- Use `data-testid` whenever the source exposes one; grep `frontend/src/<feature-dir>/` for `data-testid=` to find them.
|
||||
- If a step depends on UI behaviour you can't verify (e.g. clipboard, downloads), use the matching Playwright primitive (`page.waitForEvent('download')`, `context.grantPermissions`).
|
||||
- If the page renders differently when the workspace is empty vs non-empty, **always** seed before driving the test — otherwise locators based on the non-empty layout will time out.
|
||||
54
.claude/agents/playwright-test-healer.md
Normal file
54
.claude/agents/playwright-test-healer.md
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: playwright-test-healer
|
||||
description: Use this agent to debug and fix failing SigNoz E2E Playwright tests. Examples — <example>Context: A spec is red. user: 'tests/e2e/tests/dashboards/dashboards-list.spec.ts is failing, fix it' assistant: 'Using the healer agent to debug each failing scenario and adjust the spec.'</example> <example>Context: After a frontend change a previously-green spec broke. user: 'TC-09 in alerts started failing' assistant: 'Launching the healer to investigate.'</example>
|
||||
tools: Glob, Grep, Read, Write, Edit, Bash, mcp__playwright-test__browser_console_messages, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_generate_locator, mcp__playwright-test__browser_network_requests, mcp__playwright-test__browser_snapshot, mcp__playwright-test__test_debug, mcp__playwright-test__test_list, mcp__playwright-test__test_run
|
||||
model: sonnet
|
||||
color: red
|
||||
---
|
||||
|
||||
You are the Playwright Test Healer for the SigNoz E2E suite. You debug and fix red specs with a methodical approach. Read [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) before you start — it documents the harness and the conventions you must preserve.
|
||||
|
||||
# Preconditions
|
||||
|
||||
The E2E backend stack must be up. If `tests/e2e/.env.local` does not exist, ask the user to bring up the stack via:
|
||||
```
|
||||
cd tests
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse --with-web e2e/bootstrap/setup.py::test_setup
|
||||
```
|
||||
Don't try to start the stack yourself — it can take ~4 minutes on a cold build and the user controls when to pay that cost.
|
||||
|
||||
# Workflow
|
||||
|
||||
1. **Inventory.** `mcp__playwright-test__test_list` (or `npx playwright test <file> --list` from `tests/e2e/`) to see all tests in the spec.
|
||||
2. **Initial run.** `mcp__playwright-test__test_run` (or `npx playwright test <file> --project=chromium`) to identify failing tests. Don't run all browsers — chromium first.
|
||||
3. **Per failing test, debug.** Use `mcp__playwright-test__test_debug` to attach. When the test pauses on the error:
|
||||
- `browser_snapshot` to read the current accessibility tree.
|
||||
- `browser_console_messages` for client-side errors.
|
||||
- `browser_network_requests` for API failures (the SigNoz API requires `Authorization: Bearer <localStorage.AUTH_TOKEN>`; 401s usually mean the test bypassed the fixture).
|
||||
- `browser_generate_locator` to suggest a stable locator if the failing one drifted.
|
||||
4. **Root-cause.** Distinguish between:
|
||||
- **Selector drift** — the app changed `data-testid` or text. Fix the locator. Prefer `data-testid` (grep `frontend/src/<feature-dir>/` for the new one).
|
||||
- **Timing** — the test races a load. Replace `waitForTimeout` with `await expect(locator).toBe…()` or `page.waitForResponse(...)` on the triggering action.
|
||||
- **State leak** — a previous test left data behind, or this test assumes data the bootstrap doesn't seed. Ensure the test seeds via API and cleans up in `try / finally`. The bootstrap creates a fresh stack with **zero** dashboards / alerts.
|
||||
- **Genuine app bug** — the app is broken, not the test. Mark the test with `test.fixme(...)` and add a one-line `// known: <description>` comment. Don't silently change the assertion to make it pass.
|
||||
5. **Fix.** Edit the spec. Preserve TC-NN titles, the `authedPage` fixture, `try / finally` cleanup, and serial mode if present.
|
||||
6. **Re-run only the fixed test** before moving to the next failure (`-g 'TC-09'` or full path). Don't re-run the whole file each iteration.
|
||||
7. **Iterate** until the file is green. If a test stays red after high-confidence fixes, mark it `test.fixme(...)` with a comment and move on rather than spinning indefinitely.
|
||||
|
||||
# Repo-specific signals
|
||||
|
||||
- **The list pages render zero-state when the workspace is empty.** Many locators (search input, sort button, "All Dashboards" header) are absent in zero-state. If a test fails with a 30s timeout on these locators, the workspace was likely empty — seed via API first.
|
||||
- **The "Enter dashboard name…" inline field is a `RequestDashboardBtn` (template-request feedback form), not a create flow.** Tests that try to use it to create a named dashboard will silently no-op. The only UI create path is the "New dashboard" dropdown → "Create dashboard" → default name "Sample Title".
|
||||
- **Auth.** `tests/e2e/fixtures/auth.ts` logs in once per worker and caches `storageState` (cookies + localStorage with `AUTH_TOKEN`). For API-driven seeding/cleanup, read the token from localStorage and pass `Authorization: Bearer <token>`. Never re-implement login.
|
||||
- **Ant Design popovers** (sort menu, action menu) are click-toggle. The trigger element is often an inline `<svg>` with a `data-testid` — clicking it opens the popover; clicking it again closes. After selecting an option, the popover auto-closes. If a test interacts with the popover twice, wait for the menu items to be visible explicitly between toggles.
|
||||
- **Artifacts.** Every failed test writes to `tests/e2e/artifacts/results/<test-slug>/` — the `error-context.md` accessibility snapshot is the fastest way to see what the page actually looked like when it failed.
|
||||
- **Type-check.** After edits, run `npx tsc --noEmit -p tests/e2e/tsconfig.json` if it succeeds, or rely on `npx playwright test --list` to validate the spec parses.
|
||||
|
||||
# Hard rules
|
||||
|
||||
- **Never wait for `networkidle`.** It's flaky and discouraged.
|
||||
- **Never use `page.waitForTimeout(ms)`.** Always express the wait as `await expect(locator).toBeVisible()` or similar.
|
||||
- **Never weaken an assertion just to make a test pass.** If the underlying behavior is broken, mark `test.fixme(...)` with a comment.
|
||||
- **Don't ask the user questions** — make the most reasonable repair you can with the information at hand.
|
||||
- **Don't rewrite passing tests** while fixing a failing one. Surgical edits only.
|
||||
- **Never commit `test.only`** — CI fails on `forbidOnly: true`.
|
||||
94
.claude/agents/playwright-test-planner.md
Normal file
94
.claude/agents/playwright-test-planner.md
Normal file
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: playwright-test-planner
|
||||
description: Use this agent to create a comprehensive E2E test plan for a SigNoz frontend feature. Examples — <example>Context: A new feature has shipped and we need test coverage. user: 'Plan E2E tests for the alerts list page' assistant: 'I'll use the planner agent to read the relevant frontend source, navigate the page in a real browser, and produce a structured test plan.' <commentary>Test planning needs both source code understanding and live browser exploration — perfect for this agent.</commentary></example> <example>Context: User wants edge-case coverage on an existing feature. user: 'What scenarios are we missing for dashboard variables?' assistant: 'Launching the planner agent to map flows and identify gaps.'</example>
|
||||
tools: Glob, Grep, Read, Write, Bash, mcp__playwright-test__browser_click, mcp__playwright-test__browser_close, mcp__playwright-test__browser_console_messages, mcp__playwright-test__browser_drag, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_file_upload, mcp__playwright-test__browser_handle_dialog, mcp__playwright-test__browser_hover, mcp__playwright-test__browser_navigate, mcp__playwright-test__browser_navigate_back, mcp__playwright-test__browser_network_requests, mcp__playwright-test__browser_press_key, mcp__playwright-test__browser_select_option, mcp__playwright-test__browser_snapshot, mcp__playwright-test__browser_take_screenshot, mcp__playwright-test__browser_type, mcp__playwright-test__browser_wait_for, mcp__playwright-test__planner_setup_page
|
||||
model: sonnet
|
||||
color: green
|
||||
---
|
||||
|
||||
You are an expert E2E test planner for the SigNoz frontend, working inside the SigNoz monorepo. Your test plans drive Playwright specs that run against the local pytest-bootstrapped backend. Read [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) before planning — it documents the harness, the `authedPage` fixture, the TC-NN naming convention, and the self-contained-state principle that every plan you write must respect.
|
||||
|
||||
You will:
|
||||
|
||||
1. **Inspect the source component**
|
||||
- Read the relevant React source under [frontend/src/](../../frontend/src/) directly with the `Read` / `Grep` / `Glob` tools — this is a monorepo, no need to fetch from GitHub.
|
||||
- For a feature like "dashboards list", start at `frontend/src/pages/<Feature>Page/` and `frontend/src/container/<Feature>/`. Trace the component tree to identify:
|
||||
- Interactive elements and their `data-testid` attributes (preferred locators)
|
||||
- Conditional rendering (empty states, loading, error, role-gated UI)
|
||||
- URL query-param state (search, sort, pagination)
|
||||
- API endpoints the UI calls — these inform what cleanup endpoints exist for `try/finally` teardown
|
||||
- The frontend stores its JWT in `localStorage` under `AUTH_TOKEN` and the API requires `Authorization: Bearer <token>` for protected endpoints. Plans that need API-driven seeding should note this so the generator can use `page.request.*`.
|
||||
|
||||
2. **Navigate and explore**
|
||||
- Invoke `planner_setup_page` once before any other browser tool.
|
||||
- Use `browser_snapshot` to read the page's accessibility tree. **Do not take screenshots unless absolutely necessary** — snapshots are cheaper and more legible.
|
||||
- Drive each flow end-to-end: happy path, error states, edge cases, URL deep-linking, browser-back behaviour.
|
||||
|
||||
3. **Design comprehensive scenarios**
|
||||
- Happy path
|
||||
- Edge cases and boundary conditions (empty state, single item, > pagination threshold)
|
||||
- Error handling and validation
|
||||
- URL state and deep-linking
|
||||
- Cross-flow regressions (e.g. searching while paginated)
|
||||
|
||||
4. **Structure the test plan**
|
||||
|
||||
Each scenario must include:
|
||||
- **TC-NN** title — `TC-NN <short description>` (matches the naming this repo uses for test titles).
|
||||
- Preconditions (what state the test expects — note that the bootstrap creates a fresh stack with **zero dashboards / alerts / etc.**, so plans must seed their own data).
|
||||
- Step-by-step user actions.
|
||||
- Expected outcomes per step.
|
||||
- Cleanup notes (what gets created and how to remove it — usually via API).
|
||||
|
||||
5. **Save the plan**
|
||||
- Default location: `tests/e2e/specs/<feature>-test-plan.md` — create the directory if it doesn't exist.
|
||||
- Use clear headings, numbered steps, and a top-level "Application Overview" section.
|
||||
- At the top of the file, list any pre-existing limitations (e.g. "ascending sort not yet implemented") so the generator emits them as `// known behaviour` comments rather than failing assertions.
|
||||
|
||||
<example-spec>
|
||||
# Dashboards List Page — Test Plan
|
||||
|
||||
## Application Overview
|
||||
|
||||
The dashboards list page (`/dashboard`) lists all dashboards in the workspace. From here a user can:
|
||||
|
||||
- Search by title, description, or tags (real-time, URL-synced via `?search=`)
|
||||
- Sort by last-updated (URL-synced via `?columnKey=&order=`)
|
||||
- Open per-row actions: View, Open in New Tab, Copy Link, Export JSON, Delete dashboard
|
||||
- Create a new dashboard via the "New dashboard" dropdown (Create dashboard / Import JSON / View templates)
|
||||
|
||||
**Bootstrap state:** the pytest harness creates a fresh stack with no pre-seeded dashboards. Every test must seed its own data. The "Enter dashboard name…" inline input is a "request a new template" feedback form — **not** a create flow. The only UI create path is the dropdown.
|
||||
|
||||
**Known limitations:**
|
||||
- Ascending sort is not yet implemented — repeated clicks on the sort button keep `order=descend`.
|
||||
- Cancelling the delete confirmation dialog navigates to the dashboard detail page rather than staying on the list.
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### 1. Page Load and Layout
|
||||
|
||||
#### TC-01 page chrome and core controls render
|
||||
**Preconditions:** at least one dashboard exists (seed via API).
|
||||
|
||||
**Steps:**
|
||||
1. Navigate to `/dashboard`.
|
||||
2. Verify URL is `/dashboard` (no query params).
|
||||
3. Verify the page heading "Dashboards" (level 1) is visible.
|
||||
4. ...
|
||||
|
||||
**Expected:**
|
||||
- All Dashboards section header rendered.
|
||||
- Search input, sort button, and at least one dashboard thumbnail visible.
|
||||
|
||||
**Cleanup:** delete the seeded dashboard via `DELETE /api/v1/dashboards/<id>`.
|
||||
|
||||
#### TC-02 ...
|
||||
</example-spec>
|
||||
|
||||
**Quality bar:**
|
||||
- Steps must be specific enough that any tester (or the generator agent) can follow without ambiguity.
|
||||
- Include negative scenarios — empty state, no-match search, validation errors.
|
||||
- Each scenario must own its preconditions and cleanup. **No global test fixtures** — that's incompatible with the parallel-by-default Playwright config.
|
||||
- Prefer stable `data-testid` attributes when noting locators; fall back to ARIA roles or accessible names; treat CSS selectors as last resort.
|
||||
|
||||
**Output format:** a single Markdown file under `tests/e2e/specs/<feature>-test-plan.md` ready to hand to the generator agent.
|
||||
@@ -7,4 +7,9 @@ deploy
|
||||
sample-apps
|
||||
|
||||
# frontend
|
||||
node_modules
|
||||
**/node_modules
|
||||
frontend/build
|
||||
|
||||
# local env files (tracked example.env templates are unaffected)
|
||||
**/.env
|
||||
**/.env.*
|
||||
@@ -97,7 +97,21 @@ Each spec follows these principles:
|
||||
1. **Directory per feature**: `tests/e2e/tests/<feature>/*.spec.ts`. Cross-resource junction concerns (e.g. cascade-delete) go in their own file, not packed into one giant spec.
|
||||
2. **Test titles use `TC-NN`**: `test('TC-01 alerts page — tabs render', ...)`. Preserves ordering at a glance and maps to external coverage tracking.
|
||||
3. **UI-first**: drive flows through the UI. Playwright traces capture every BE request/response the UI triggers, so asserting on UI outcomes implicitly validates BE contracts. Reach for direct `page.request.*` only when the test's *purpose* is asserting a response contract (use `page.waitForResponse` on a UI click) or when a specific UI step is structurally flaky (e.g. Ant DatePicker calendar-cell indices) — and even then try UI first.
|
||||
4. **Self-contained state**: each spec creates what it needs and cleans up in `try/finally`. No global pre-seeding fixtures.
|
||||
4. **Self-contained state**: each spec seeds its own data and cleans up at suite teardown. The pytest harness creates a fresh stack with **zero** dashboards / alerts / etc. — never assume pre-existing data. Two patterns work:
|
||||
- **Per-test seed + cleanup in `try / finally`** — small specs where each test owns its data.
|
||||
- **Suite-level seed + `afterAll` teardown** — preferred for larger specs. Each `createDashboard(...)` call adds the resulting ID to a module-level `Set<string>`, and one `test.afterAll(...)` deletes everything in the set. See `tests/e2e/tests/dashboards/dashboards-list.spec.ts` for the full pattern. `test.beforeAll` / `test.afterAll` cannot use `authedPage` directly (it's test-scoped); use `newAuthedContext(browser)` from `fixtures/auth.ts` instead — it reuses the per-worker storage cache so no extra login happens.
|
||||
5. **Seed via API when the UI flow is multi-step or brittle.** The frontend stores its JWT in `localStorage` under `AUTH_TOKEN`; `page.request.*` inherits the auth fixture's storage state. A typical pattern:
|
||||
```ts
|
||||
const token = await page.evaluate(
|
||||
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
|
||||
);
|
||||
await page.request.post('/api/v1/dashboards', {
|
||||
data: { title: 'my-name', uploadedGrafana: false },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
```
|
||||
This is faster and more reliable than a multi-step UI seed. Reach for the UI flow only when the test's *purpose* is asserting that flow.
|
||||
6. **Reusable fixture data lives in `tests/e2e/fixtures/`.** For example, `apm-metrics.json` is a real dashboard payload that specs import and `POST` to `/api/v1/dashboards` to seed a richly-tagged dashboard for search/list tests.
|
||||
|
||||
## How to write an E2E test?
|
||||
|
||||
@@ -155,13 +169,23 @@ test('TC-02 alerts list — create, toggle, delete', async ({ authedPage: page }
|
||||
|
||||
### Locator priority
|
||||
|
||||
1. `getByRole('button', { name: 'Submit' })`
|
||||
2. `getByLabel('Email')`
|
||||
3. `getByPlaceholder('...')`
|
||||
4. `getByText('...')`
|
||||
5. `getByTestId('...')`
|
||||
1. `getByTestId('...')` — preferred when the source exposes one. Stable, app-author-provided handle that survives copy-edits.
|
||||
2. `getByRole('button', { name: 'Submit' })`
|
||||
3. `getByLabel('Email')`
|
||||
4. `getByPlaceholder('...')`
|
||||
5. `getByText('...')`
|
||||
6. `locator('.ant-select')` — last resort (Ant Design dropdowns often have no semantic alternative)
|
||||
|
||||
## Agents
|
||||
|
||||
Three Claude agents in `.claude/agents/` accelerate writing and maintaining E2E specs:
|
||||
|
||||
- **`playwright-test-planner`** — explores a feature in a real browser plus the local frontend source and writes a test plan to `tests/e2e/specs/<feature>-test-plan.md`.
|
||||
- **`playwright-test-generator`** — converts a test plan into Playwright spec files under `tests/e2e/tests/<feature>/`. Drives each scenario through MCP browser tools and emits TC-NN-titled tests using the `authedPage` fixture and the API-seed pattern.
|
||||
- **`playwright-test-healer`** — runs failing specs, debugs them with snapshots / console / network introspection, and edits the spec to fix selector drift, timing, or state-leak issues.
|
||||
|
||||
The agents rely on the Playwright-test MCP server (`mcp__playwright-test__*` tools). Configure it in your Claude MCP settings; the permission allowlist lives in [.claude/settings.local.json](../../../.claude/settings.local.json).
|
||||
|
||||
## How to run E2E tests?
|
||||
|
||||
### Running All Tests
|
||||
|
||||
3639
tests/e2e/fixtures/apm-metrics.json
Normal file
3639
tests/e2e/fixtures/apm-metrics.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
||||
844
tests/e2e/tests/dashboards/dashboards-list.spec.ts
Normal file
844
tests/e2e/tests/dashboards/dashboards-list.spec.ts
Normal file
@@ -0,0 +1,844 @@
|
||||
import type { Browser, BrowserContext, Page } from '@playwright/test';
|
||||
|
||||
import apmMetricsTemplate from '../../fixtures/apm-metrics.json';
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
|
||||
// Spec-local login — used only by `beforeAll` / `afterAll` because the
|
||||
// `authedPage` fixture is test-scoped and not visible to suite hooks. Each
|
||||
// hook does one fresh login (~1s) per worker; we deliberately do not import
|
||||
// or modify the per-worker storage cache from `fixtures/auth.ts`.
|
||||
async function newAdminContext(browser: Browser): Promise<BrowserContext> {
|
||||
const email = process.env.SIGNOZ_E2E_USERNAME;
|
||||
const password = process.env.SIGNOZ_E2E_PASSWORD;
|
||||
if (!email || !password) {
|
||||
throw new Error(
|
||||
'SIGNOZ_E2E_USERNAME / SIGNOZ_E2E_PASSWORD must be set ' +
|
||||
'(pytest bootstrap writes them to .env.local).',
|
||||
);
|
||||
}
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
await page.goto('/login?password=Y');
|
||||
await page.getByTestId('email').fill(email);
|
||||
await page.getByTestId('initiate_login').click();
|
||||
await page.getByTestId('password').fill(password);
|
||||
await page.getByRole('button', { name: 'Sign in with Password' }).click();
|
||||
await page.waitForURL((url) => !url.pathname.startsWith('/login'));
|
||||
await page.close();
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// Tests in this file mutate the dashboard list (create / delete). Run them
|
||||
// serially within the worker so state from one test does not leak into
|
||||
// another's assertions. Files still run in parallel via the project-level
|
||||
// fullyParallel setting.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const SEARCH_PLACEHOLDER = 'Search by name, description, or tags...';
|
||||
|
||||
// ─── Suite-level seed & teardown ─────────────────────────────────────────
|
||||
//
|
||||
// The pytest harness creates a fresh stack with zero dashboards. Tests seed
|
||||
// what they need via the API and register the resulting IDs in `seedIds`.
|
||||
// One `afterAll` deletes everything in `seedIds` at the end of the suite —
|
||||
// individual tests do not need their own `try / finally` cleanup blocks.
|
||||
const seedIds = new Set<string>();
|
||||
|
||||
// Names a test wants to assert on. Stable, descriptive, no timestamps.
|
||||
const APM_METRICS_TITLE = (apmMetricsTemplate as { title: string }).title;
|
||||
const BASE_FIXTURE_TITLE = 'dashboards-list-base-fixture';
|
||||
|
||||
// Reads the JWT that the auth fixture stored in localStorage after login.
|
||||
// `page.request.*` requires the page to first navigate to the SigNoz origin
|
||||
// so localStorage is populated from the storageState the context was created
|
||||
// with.
|
||||
async function authToken(page: Page): Promise<string> {
|
||||
if (!page.url().startsWith('http')) {
|
||||
await page.goto('/dashboard');
|
||||
}
|
||||
return page.evaluate(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
|
||||
);
|
||||
}
|
||||
|
||||
async function postDashboard(
|
||||
page: Page,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.post('/api/v1/dashboards', {
|
||||
data: body,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`POST /dashboards ${res.status()}: ${await res.text()}`);
|
||||
}
|
||||
const json = (await res.json()) as { data: { id: string } };
|
||||
const id = json.data.id;
|
||||
seedIds.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
// Seed a minimal named dashboard via API. Tracks the ID for suite teardown.
|
||||
async function createDashboard(page: Page, title: string): Promise<string> {
|
||||
return postDashboard(page, { title, uploadedGrafana: false });
|
||||
}
|
||||
|
||||
// Seed the full APM Metrics dashboard from the JSON fixture (title + tags +
|
||||
// description + panels). Used by tests that want to assert on richer row
|
||||
// content (multi-tag rendering, description, etc.).
|
||||
async function importApmMetricsDashboard(page: Page): Promise<string> {
|
||||
return postDashboard(page, {
|
||||
...(apmMetricsTemplate as Record<string, unknown>),
|
||||
uploadedGrafana: false,
|
||||
});
|
||||
}
|
||||
|
||||
// The page heading is the only element that's always present — independent
|
||||
// of whether the workspace has dashboards (list view) or is empty (zero-state).
|
||||
async function gotoList(page: Page): Promise<void> {
|
||||
await page.goto('/dashboard');
|
||||
await page
|
||||
.getByRole('heading', { name: 'Dashboards', level: 1 })
|
||||
.waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
// Find a dashboard ID by title via the list API.
|
||||
async function findDashboardIdByTitle(
|
||||
page: Page,
|
||||
title: string,
|
||||
): Promise<string | undefined> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.get('/api/v1/dashboards', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
return undefined;
|
||||
}
|
||||
const body = (await res.json()) as {
|
||||
data: Array<{ id: string; data: { title: string } }>;
|
||||
};
|
||||
return body.data.find((d) => d.data.title === title)?.id;
|
||||
}
|
||||
|
||||
// ─── beforeAll / afterAll ────────────────────────────────────────────────
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
// Seed the two persistent fixtures the read-only tests rely on:
|
||||
// - APM Metrics — a richer, real-world dashboard imported from JSON
|
||||
// - A minimal base dashboard — used by tests that just need the list
|
||||
// to be non-empty so the search input / sort button render
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
await importApmMetricsDashboard(page);
|
||||
await createDashboard(page, BASE_FIXTURE_TITLE);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of [...seedIds]) {
|
||||
await ctx.request
|
||||
.delete(`/api/v1/dashboards/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.catch(() => undefined);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Dashboards List Page', () => {
|
||||
// ─── Page load and layout ────────────────────────────────────────────────
|
||||
|
||||
test('TC-01 page chrome and core controls render', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoList(page);
|
||||
|
||||
await expect(page).toHaveURL('/dashboard');
|
||||
await expect(page).toHaveTitle('SigNoz | All Dashboards');
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Create and manage dashboards for your workspace.'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('All Dashboards')).toBeVisible();
|
||||
await expect(page.getByTestId('sort-by')).toBeVisible();
|
||||
|
||||
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Feedback' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 row shows thumbnail and creator email', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoList(page);
|
||||
await page
|
||||
.getByAltText('dashboard-image')
|
||||
.first()
|
||||
.waitFor({ state: 'visible' });
|
||||
|
||||
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
|
||||
// Creator email — admin@integration.test contains '@'
|
||||
await expect(page.getByText(/@/).first()).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Search functionality ────────────────────────────────────────────────
|
||||
|
||||
test('TC-03 search by title returns matching dashboard', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-search-title';
|
||||
await createDashboard(page, name);
|
||||
|
||||
await gotoList(page);
|
||||
const search = page.getByRole('textbox', { name: SEARCH_PLACEHOLDER });
|
||||
|
||||
await search.fill(name);
|
||||
await expect(page).toHaveURL(new RegExp(`search=${name}`));
|
||||
await expect(search).toHaveValue(name);
|
||||
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
|
||||
await expect(page.getByText(name).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-04 search by tag returns the APM Metrics dashboard', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// APM Metrics is imported from JSON and carries multiple tags. We search
|
||||
// by one of them ("apm") and expect the imported dashboard to surface.
|
||||
await gotoList(page);
|
||||
const search = page.getByRole('textbox', { name: SEARCH_PLACEHOLDER });
|
||||
|
||||
await search.fill('apm');
|
||||
await expect(page).toHaveURL(/search=apm/);
|
||||
await expect(page.getByText(APM_METRICS_TITLE).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-05 direct navigation with ?search= pre-fills the input and filters results', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-search-deeplink';
|
||||
await createDashboard(page, name);
|
||||
|
||||
await page.goto(`/dashboard?search=${name}`);
|
||||
await page
|
||||
.getByRole('heading', { name: 'Dashboards', level: 1 })
|
||||
.waitFor({ state: 'visible' });
|
||||
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }),
|
||||
).toHaveValue(name);
|
||||
await expect(page.getByText(name).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-06 clearing search restores the full list', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoList(page);
|
||||
const search = page.getByRole('textbox', { name: SEARCH_PLACEHOLDER });
|
||||
|
||||
await search.fill('apm');
|
||||
await expect(page).toHaveURL(/search=apm/);
|
||||
|
||||
await search.fill('');
|
||||
// The app keeps the empty `search=` param in the URL — assert that no
|
||||
// non-empty value remains, and that rows are rendered again.
|
||||
await expect(page).not.toHaveURL(/search=[^&]/);
|
||||
await expect(search).toHaveValue('');
|
||||
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-07 search with no matching results shows empty state', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoList(page);
|
||||
const search = page.getByRole('textbox', { name: SEARCH_PLACEHOLDER });
|
||||
|
||||
await search.fill('xyznonexistent999');
|
||||
|
||||
await expect(page.getByAltText('dashboard-image')).toHaveCount(0);
|
||||
await expect(search).toBeVisible();
|
||||
await expect(search).toHaveValue('xyznonexistent999');
|
||||
});
|
||||
|
||||
test('TC-08 search is case-insensitive', async ({ authedPage: page }) => {
|
||||
await gotoList(page);
|
||||
const search = page.getByRole('textbox', { name: SEARCH_PLACEHOLDER });
|
||||
|
||||
await search.fill(APM_METRICS_TITLE.toLowerCase());
|
||||
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
|
||||
await expect(page.getByText(APM_METRICS_TITLE).first()).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Sorting ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Known behaviour (verified against the running app):
|
||||
// - Fresh load: no sort params in URL; list is already descending (server default)
|
||||
// - First selection: URL gains ?columnKey=updatedAt&order=descend
|
||||
// - `sortHandle` in DashboardsList.tsx hard-codes `order: 'descend'` —
|
||||
// ascending mode is not yet implemented in any code path
|
||||
|
||||
test('TC-09 default load has no sort params', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoList(page);
|
||||
|
||||
await expect(page).toHaveURL('/dashboard');
|
||||
await expect(page).not.toHaveURL(/columnKey/);
|
||||
await expect(page).not.toHaveURL(/order/);
|
||||
|
||||
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-10 selecting "Last updated" adds columnKey=updatedAt&order=descend to URL', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoList(page);
|
||||
|
||||
await expect(page).not.toHaveURL(/columnKey/);
|
||||
await page.getByTestId('sort-by').click();
|
||||
const lastUpdated = page.getByTestId('sort-by-last-updated');
|
||||
await lastUpdated.waitFor({ state: 'visible' });
|
||||
await lastUpdated.click();
|
||||
|
||||
await expect(page).toHaveURL(/columnKey=updatedAt/);
|
||||
await expect(page).toHaveURL(/order=descend/);
|
||||
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-11 selecting "Last created" also yields order=descend (ascending not yet implemented)', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoList(page);
|
||||
|
||||
await page.getByTestId('sort-by').click();
|
||||
const lastCreated = page.getByTestId('sort-by-last-created');
|
||||
await lastCreated.waitFor({ state: 'visible' });
|
||||
await lastCreated.click();
|
||||
|
||||
await expect(page).toHaveURL(/columnKey=createdAt/);
|
||||
await expect(page).toHaveURL(/order=descend/);
|
||||
await expect(page).not.toHaveURL(/order=ascend/);
|
||||
});
|
||||
|
||||
// ─── Row actions (context menu) ──────────────────────────────────────────
|
||||
|
||||
test('TC-12 admin sees all five options in the action menu', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-actions-menu';
|
||||
await createDashboard(page, name);
|
||||
|
||||
await gotoList(page);
|
||||
await page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }).fill(name);
|
||||
|
||||
const actionIcon = page.getByTestId('dashboard-action-icon').first();
|
||||
await actionIcon.scrollIntoViewIfNeeded();
|
||||
await actionIcon.click();
|
||||
const tooltip = page.getByRole('tooltip');
|
||||
await expect(tooltip).toBeVisible();
|
||||
|
||||
await expect(tooltip.getByRole('button', { name: 'View' })).toBeVisible();
|
||||
await expect(
|
||||
tooltip.getByRole('button', { name: 'Open in New Tab' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
tooltip.getByRole('button', { name: 'Copy Link' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
tooltip.getByRole('button', { name: 'Export JSON' }),
|
||||
).toBeVisible();
|
||||
// Delete is rendered as a generic, not a button
|
||||
await expect(tooltip.getByText('Delete dashboard')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-13 view action navigates to the dashboard detail page', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-action-view';
|
||||
await createDashboard(page, name);
|
||||
|
||||
await gotoList(page);
|
||||
await page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }).fill(name);
|
||||
|
||||
const actionIcon = page.getByTestId('dashboard-action-icon').first();
|
||||
await actionIcon.scrollIntoViewIfNeeded();
|
||||
await actionIcon.click();
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'View' })
|
||||
.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
});
|
||||
|
||||
test('TC-14 open in new tab opens the dashboard in a new browser tab', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-action-newtab';
|
||||
await createDashboard(page, name);
|
||||
|
||||
await gotoList(page);
|
||||
await page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }).fill(name);
|
||||
|
||||
const actionIcon = page.getByTestId('dashboard-action-icon').first();
|
||||
await actionIcon.scrollIntoViewIfNeeded();
|
||||
await actionIcon.click();
|
||||
|
||||
// Use page.context() — the auth fixture creates its own context per
|
||||
// test, which is not the same as the default `context` fixture.
|
||||
const [newPage] = await Promise.all([
|
||||
page.context().waitForEvent('page'),
|
||||
page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'Open in New Tab' })
|
||||
.click(),
|
||||
]);
|
||||
|
||||
await newPage.waitForLoadState();
|
||||
await expect(newPage).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
await newPage.close();
|
||||
});
|
||||
|
||||
test('TC-15 copy link copies the dashboard URL to the clipboard', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-action-copy';
|
||||
await createDashboard(page, name);
|
||||
|
||||
await gotoList(page);
|
||||
await page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }).fill(name);
|
||||
|
||||
await page
|
||||
.context()
|
||||
.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
|
||||
const actionIcon = page.getByTestId('dashboard-action-icon').first();
|
||||
await actionIcon.scrollIntoViewIfNeeded();
|
||||
await actionIcon.click();
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'Copy Link' })
|
||||
.click();
|
||||
|
||||
await expect(page.getByText(/copied|success/i)).toBeVisible();
|
||||
|
||||
const clipboardText = await page.evaluate(async () =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).navigator.clipboard.readText(),
|
||||
);
|
||||
expect(clipboardText).toMatch(/\/dashboard\/[0-9a-f-]+/);
|
||||
});
|
||||
|
||||
test('TC-16 export JSON downloads the dashboard as a JSON file', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-action-export';
|
||||
await createDashboard(page, name);
|
||||
|
||||
await gotoList(page);
|
||||
await page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }).fill(name);
|
||||
|
||||
const actionIcon = page.getByTestId('dashboard-action-icon').first();
|
||||
await actionIcon.scrollIntoViewIfNeeded();
|
||||
await actionIcon.click();
|
||||
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'Export JSON' })
|
||||
.click(),
|
||||
]);
|
||||
|
||||
expect(download.suggestedFilename()).toMatch(/\.json$/);
|
||||
});
|
||||
|
||||
test('TC-17 action menu closes when clicking outside the popover', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-action-dismiss';
|
||||
await createDashboard(page, name);
|
||||
|
||||
await gotoList(page);
|
||||
await page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }).fill(name);
|
||||
|
||||
const actionIcon = page.getByTestId('dashboard-action-icon').first();
|
||||
await actionIcon.scrollIntoViewIfNeeded();
|
||||
await actionIcon.click();
|
||||
await expect(page.getByRole('tooltip')).toBeVisible();
|
||||
|
||||
await page.getByRole('heading', { name: 'Dashboards', level: 1 }).click();
|
||||
await expect(page.getByRole('tooltip')).not.toBeVisible();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard($|\?)/);
|
||||
});
|
||||
|
||||
// ─── Creating dashboards via "New dashboard" dropdown ─────────────────────
|
||||
//
|
||||
// The "Enter dashboard name…" inline input on the list page is a
|
||||
// `RequestDashboardBtn` (template-request feedback form), not a create
|
||||
// flow. The only UI create path is the "New dashboard" dropdown.
|
||||
|
||||
test('TC-18 New dashboard dropdown shows exactly three options', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoList(page);
|
||||
await page.getByTestId('new-dashboard-cta').click();
|
||||
|
||||
const menu = page.getByRole('menu');
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu.getByTestId('create-dashboard-menu-cta')).toBeVisible();
|
||||
await expect(menu.getByTestId('import-json-menu-cta')).toBeVisible();
|
||||
await expect(menu.getByTestId('view-templates-menu-cta')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-19 Create dashboard dropdown option creates a dashboard with the default name', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoList(page);
|
||||
await page.getByTestId('new-dashboard-cta').click();
|
||||
await page.getByTestId('create-dashboard-menu-cta').click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
await expect(
|
||||
page.getByText('Configure your new dashboard'),
|
||||
).toBeVisible();
|
||||
// "Configure" appears twice on the new-dashboard onboarding state — once
|
||||
// in the toolbar and once in the empty-state section. The test only
|
||||
// needs to confirm the onboarding rendered, so .first() is sufficient.
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Configure' }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: /New Panel/ }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Register the UI-created dashboard with the suite teardown.
|
||||
const sampleId = await findDashboardIdByTitle(page, 'Sample Title');
|
||||
if (sampleId) {
|
||||
seedIds.add(sampleId);
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-20 Import JSON dialog opens with code editor and upload button', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoList(page);
|
||||
await page.getByTestId('new-dashboard-cta').click();
|
||||
await page.getByTestId('import-json-menu-cta').click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByText('Import Dashboard JSON')).toBeVisible();
|
||||
// "Upload JSON file" appears twice — once as the Ant Upload's hidden
|
||||
// span wrapper, once as the visible button. .first() is enough to
|
||||
// confirm the upload affordance rendered.
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: 'Upload JSON file' }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: 'Import and Next' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-21 Import JSON dialog closes on Escape without creating a dashboard', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoList(page);
|
||||
await page.getByTestId('new-dashboard-cta').click();
|
||||
await page.getByTestId('import-json-menu-cta').click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// The Monaco editor inside the modal grabs focus on mount and swallows
|
||||
// Escape. Click the modal title first to blur Monaco; then Ant's Modal
|
||||
// `keyboard` handler picks up the Escape and dismisses the dialog.
|
||||
await dialog.getByText('Import Dashboard JSON').click();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(page).toHaveURL(/\/dashboard($|\?)/);
|
||||
});
|
||||
|
||||
test('TC-22 Import JSON dialog closes on clicking the close button', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoList(page);
|
||||
await page.getByTestId('new-dashboard-cta').click();
|
||||
await page.getByTestId('import-json-menu-cta').click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).click();
|
||||
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(page).toHaveURL(/\/dashboard($|\?)/);
|
||||
});
|
||||
|
||||
// ─── Deleting dashboards ─────────────────────────────────────────────────
|
||||
//
|
||||
// Known behaviour: clicking Cancel in the confirmation dialog navigates to
|
||||
// the dashboard detail page rather than staying on the list.
|
||||
|
||||
test('TC-23 delete confirmation dialog shows dashboard name with Cancel and Delete buttons', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-delete-confirm';
|
||||
await createDashboard(page, name);
|
||||
|
||||
await gotoList(page);
|
||||
await page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }).fill(name);
|
||||
// Scroll the row into view before clicking — when the workspace has
|
||||
// accumulated dashboards the search-filtered row can land below the
|
||||
// viewport, and Playwright's auto-scroll on click is not always enough
|
||||
// when there is a sticky header above.
|
||||
const actionIcon = page.getByTestId('dashboard-action-icon').first();
|
||||
await actionIcon.scrollIntoViewIfNeeded();
|
||||
await actionIcon.click();
|
||||
|
||||
// Ant's Popover positions the tooltip below the row — when the row is
|
||||
// near the viewport bottom the option ends up just off-screen. Force
|
||||
// the click; the element is already attached and Ant handles the rest.
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByText('Delete dashboard')
|
||||
.click({ force: true });
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByRole('heading')).toContainText(
|
||||
'Are you sure you want to delete the',
|
||||
);
|
||||
await expect(dialog.getByRole('heading')).toContainText(name);
|
||||
|
||||
await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: 'Delete' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-24 cancelling delete navigates to the dashboard detail page (known behaviour)', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-delete-cancel';
|
||||
await createDashboard(page, name);
|
||||
|
||||
await gotoList(page);
|
||||
await page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }).fill(name);
|
||||
// Scroll the row into view before clicking — when the workspace has
|
||||
// accumulated dashboards the search-filtered row can land below the
|
||||
// viewport, and Playwright's auto-scroll on click is not always enough
|
||||
// when there is a sticky header above.
|
||||
const actionIcon = page.getByTestId('dashboard-action-icon').first();
|
||||
await actionIcon.scrollIntoViewIfNeeded();
|
||||
await actionIcon.click();
|
||||
|
||||
// Ant's Popover positions the tooltip below the row — when the row is
|
||||
// near the viewport bottom the option ends up just off-screen. Force
|
||||
// the click; the element is already attached and Ant handles the rest.
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByText('Delete dashboard')
|
||||
.click({ force: true });
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
});
|
||||
|
||||
test('TC-25 confirming delete removes the dashboard from the list', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-delete-confirmed';
|
||||
const id = await createDashboard(page, name);
|
||||
|
||||
await gotoList(page);
|
||||
await page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }).fill(name);
|
||||
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
|
||||
|
||||
// Scroll the row into view before clicking — when the workspace has
|
||||
// accumulated dashboards the search-filtered row can land below the
|
||||
// viewport, and Playwright's auto-scroll on click is not always enough
|
||||
// when there is a sticky header above.
|
||||
const actionIcon = page.getByTestId('dashboard-action-icon').first();
|
||||
await actionIcon.scrollIntoViewIfNeeded();
|
||||
await actionIcon.click();
|
||||
|
||||
// Ant's Popover positions the tooltip below the row — when the row is
|
||||
// near the viewport bottom the option ends up just off-screen. Force
|
||||
// the click; the element is already attached and Ant handles the rest.
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByText('Delete dashboard')
|
||||
.click({ force: true });
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// The Delete mutation is async — wait for the API response *and* the
|
||||
// dialog to dismiss before navigating away, otherwise React Query's
|
||||
// in-flight mutation gets cancelled by the navigation.
|
||||
const deleteResponse = page.waitForResponse(
|
||||
(r) => r.request().method() === 'DELETE' && /\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await dialog.getByRole('button', { name: 'Delete' }).click();
|
||||
await deleteResponse;
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
// After deletion, searching for the name should return no results.
|
||||
await gotoList(page);
|
||||
await page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }).fill(name);
|
||||
await expect(page.getByAltText('dashboard-image')).toHaveCount(0);
|
||||
|
||||
// The UI delete already removed the resource — drop it from the
|
||||
// suite-cleanup set so afterAll doesn't 404 on it.
|
||||
seedIds.delete(id);
|
||||
});
|
||||
|
||||
// ─── Row click navigation ────────────────────────────────────────────────
|
||||
|
||||
test('TC-26 clicking a dashboard row navigates to the detail page', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-row-click';
|
||||
await createDashboard(page, name);
|
||||
|
||||
await gotoList(page);
|
||||
await page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }).fill(name);
|
||||
|
||||
await page.getByAltText('dashboard-image').first().click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
});
|
||||
|
||||
test('TC-27 dashboard detail page shows the breadcrumb after row click', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-breadcrumb';
|
||||
await createDashboard(page, name);
|
||||
|
||||
await gotoList(page);
|
||||
await page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }).fill(name);
|
||||
|
||||
await page.getByAltText('dashboard-image').first().click();
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Dashboard \// }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-28 sidebar Dashboards link navigates to the list page', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto('/home');
|
||||
// Sidebar items are <div class="nav-item"> with the label as visible
|
||||
// text — they're not <a role="link">, so getByRole won't reach them.
|
||||
// Filter on the exact label to avoid matching nested items that
|
||||
// happen to contain the substring.
|
||||
await page
|
||||
.locator('.nav-item')
|
||||
.filter({ hasText: /^Dashboards$/ })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
await expect(page).toHaveTitle('SigNoz | All Dashboards');
|
||||
});
|
||||
|
||||
// ─── URL state and deep linking ──────────────────────────────────────────
|
||||
|
||||
test('TC-29 search term updates the URL in real time', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoList(page);
|
||||
await page
|
||||
.getByRole('textbox', { name: SEARCH_PLACEHOLDER })
|
||||
.fill('realtime');
|
||||
await expect(page).toHaveURL(/search=realtime/);
|
||||
});
|
||||
|
||||
test('TC-30 browser Back after navigating to a dashboard restores search state', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-back-search';
|
||||
await createDashboard(page, name);
|
||||
|
||||
await page.goto(`/dashboard?search=${name}`);
|
||||
await page
|
||||
.getByRole('heading', { name: 'Dashboards', level: 1 })
|
||||
.waitFor({ state: 'visible' });
|
||||
|
||||
await page.getByAltText('dashboard-image').first().click();
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(new RegExp(`search=${name}`));
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }),
|
||||
).toHaveValue(name);
|
||||
});
|
||||
|
||||
test('TC-31 sort params appear in URL only after interacting with the sort button', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoList(page);
|
||||
await expect(page).not.toHaveURL(/columnKey/);
|
||||
|
||||
await page.getByTestId('sort-by').click();
|
||||
await page.getByTestId('sort-by-last-updated').click();
|
||||
await expect(page).toHaveURL(/columnKey=updatedAt/);
|
||||
await expect(page).toHaveURL(/order=descend/);
|
||||
|
||||
// Direct navigation with sort params should honour them on load.
|
||||
await page.goto('/dashboard?columnKey=updatedAt&order=descend');
|
||||
await page
|
||||
.getByRole('heading', { name: 'Dashboards', level: 1 })
|
||||
.waitFor({ state: 'visible' });
|
||||
await expect(page).toHaveURL(/columnKey=updatedAt/);
|
||||
await expect(page).toHaveURL(/order=descend/);
|
||||
});
|
||||
|
||||
// ─── Page header actions ─────────────────────────────────────────────────
|
||||
|
||||
test('TC-32 feedback button is visible and stays on the page when clicked', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoList(page);
|
||||
const feedback = page.getByRole('button', { name: 'Feedback' });
|
||||
await expect(feedback).toBeVisible();
|
||||
|
||||
await feedback.click();
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
});
|
||||
|
||||
test('TC-33 share button is visible and stays on the page when clicked', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoList(page);
|
||||
const share = page.getByRole('button', { name: 'Share' });
|
||||
await expect(share).toBeVisible();
|
||||
|
||||
await share.click();
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user