Files
signoz/docs/contributing/tests/e2e.md
Vinicius Lourenço a8c04cb563 test(alerts): add e2e for alerts (#12349)
## Pull Request

---

### 📄 Summary
> Why does this change exist?  
> What problem does it solve, and why is this the right approach?

This adds a bunch of E2E tests for alerts, to test v1/v2 create and
edit, and also tests for alert history.

This started as tests only for history but decided to just add tests for
everything, while creating them, I found two bugs inside alerts, so they
already helping us before even landing :)

The changes in the UI are only to add testIds, no change in logic (and
no fix for the incidents)

| Scope | Before (`main`) | After (this branch) | Delta |
|---|---:|---:|---:|
| Alerts E2E tests | 2 | 191 | **+189** |
| Alerts E2E spec files | 1 | 31 | +30 |
| Whole E2E suite | 141 | 330 | **+189** |


#### Alerts page shell (7)

| File | Test | Status |
|---|---|---|
| `page.spec.ts` | AL-01 all four top-level tabs render |  |
| `page.spec.ts` | AL-02 default tab is Alert Rules |  |
| `page.spec.ts` | AL-03 tab switch writes ?tab= and clears subTab |  |
| `page.spec.ts` | AL-04 Configuration deep-link |  |
| `page.spec.ts` | AL-05 Triggered Alerts tab smoke |  |
| `page.spec.ts` | AL-06 Notification Channels tab smoke |  |
| `page.spec.ts` | AL-07 tab state survives reload |  |

#### Alert rules list (19)

| File | Test | Status |
|---|---|---|
| `list/columns.spec.ts` | LR-01 renders all default columns (Status,
Alert Name, Severity, Labels, Actions) | |
| `list/columns.spec.ts` | LR-02 shows empty state when no rules exist |
skipped |
| `list/columns.spec.ts` | LR-10 column selector hides and shows a
column | |
| `list/navigation.spec.ts` | LR-11 row click opens the overview page |
|
| `list/navigation.spec.ts` | LR-12 ctrl/cmd-click opens the overview in
a new tab | |
| `list/navigation.spec.ts` | LR-13 actions menu Edit and Edit in New
Tab navigate correctly | |
| `list/navigation.spec.ts` | LR-17 New Alert button navigates to alert
creation | |
| `list/navigation.spec.ts` | LR-18 shows ErrorEmptyState when list
fails to load | skipped |
| `list/pagination-sort.spec.ts` | LR-07 navigates between pages |  |
| `list/pagination-sort.spec.ts` | LR-08 changes page size |  |
| `list/pagination-sort.spec.ts` | LR-09 sorts by column header click |
|
| `list/row-actions.spec.ts` | LR-14 Disable then Enable toggles the
rule state | |
| `list/row-actions.spec.ts` | LR-15 Clone creates a copy and shows
success toast | |
| `list/row-actions.spec.ts` | LR-16 Delete removes the rule and shows
success toast | |
| `list/search.spec.ts` | LR-03 filters by name |  |
| `list/search.spec.ts` | LR-04 filters by severity and by label |  |
| `list/search.spec.ts` | LR-05 shows no-results state with clear button
| |
| `list/search.spec.ts` | LR-06 resets pagination when searching |  |
| `list/search.spec.ts` | LR-19 state and severity filters intersect,
they do not union | |

#### Create alert (52)

| File | Test | Status |
|---|---|---|
| `create/edge.spec.ts` | CE-04 a server-side rejection opens the error
modal and keeps the draft | |
| `create/edge.spec.ts` | CE-07 none of the four builder mounts logs a
console error | |
| `create/edge.spec.ts` | CE-09 the v2 Discard button is clickable |
skipped |
| `create/prefill.spec.ts` | CD-01 a compositeQuery alone selects the
alert type | |
| `create/prefill.spec.ts` | CD-02 thresholds prefill from JSON, and a
malformed value falls back | |
| `create/prefill.spec.ts` | CD-03 matchType and compareOp aliases
normalise to the enum | |
| `create/prefill.spec.ts` | CD-04 ruleName and yAxisUnit apply once and
never stomp an edit | |
| `create/prefill.spec.ts` | CD-05 evaluationWindowPreset=meter switches
to the cumulative daily window | |
| `create/prefill.spec.ts` | CD-06 URL prefill is ignored in edit mode |
|
| `create/shell.spec.ts` | CS-01 bare /alerts/new lists exactly the
expected alert-type cards | |
| `create/shell.spec.ts` | CS-02 picking a card writes both params and
mounts the v2 builder | |
| `create/shell.spec.ts` | CS-03 the anomaly card rewrites the rule
type, not the alert type | conditional |
| `create/shell.spec.ts` | CS-04 modifier-clicking a card opens the
builder in a new tab | |
| `create/shell.spec.ts` | CS-05 breadcrumb gains a third crumb after a
type is picked | |
| `create/shell.spec.ts` | CS-06 create renders inside the Alert Rules
tab and leaving drops subTab/search | |
| `create/shell.spec.ts` | CS-07 showClassicCreateAlertsPage=true
renders the v1 form instead | |
| `create/shell.spec.ts` | CS-08 Switch to Classic Experience replaces
history, so Back does not return to v2 | |
| `create/v1.spec.ts` | CV1-01 the classic form renders its steps and
the create-mode labels | |
| `create/v1.spec.ts` | CV1-02 the rendered severity is the default from
the rule, not the select | |
| `create/v1.spec.ts` | CV1-03 one keystroke in the name field is enough
to enable Save | |
| `create/v1.spec.ts` | CV1-04 Save stays disabled until the channel
configuration resolves | |
| `create/v1.spec.ts` | CV1-05 broadcast-to-all saves the rule with the
broadcast flag | skipped |
| `create/v1.spec.ts` | CV1-06 a cleared threshold is coerced to 0, so
the required-threshold branch is dead | |
| `create/v1.spec.ts` | CV1-07 cancelling the confirm dialog does not
save | |
| `create/v1.spec.ts` | CV1-08 the happy path posts the v1 body shape to
the shared endpoint | |
| `create/v1.spec.ts` | CV1-09 CV1-10 description, labels and severity
all land in the payload | |
| `create/v1.spec.ts` | CV1-11 test notification skips the dialog and
reports no matching data | |
| `create/v1.spec.ts` | CV1-12 with no channels the form is a dead end |
|
| `create/v1.spec.ts` | CV1-13 Cancel leaves the form without saving | |
| `create/v1.spec.ts` | CE-05 an empty PromQL expression is rejected
behind the dialog | |
| `create/v1.spec.ts` | CE-06 an empty ClickHouse query is rejected
behind the dialog | |
| `create/v1.spec.ts` | CV1-14 the condition sentence keeps its
selections | |
| `create/v2.spec.ts` | CV2-01 initial state: one critical threshold,
both actions gated | |
| `create/v2.spec.ts` | CV2-02 the save tooltip walks from the name gate
to the channel gate | |
| `create/v2.spec.ts` | CV2-03 clearing a threshold label re-gates the
save | |
| `create/v2.spec.ts` | CV2-04 a label added in the header survives the
save round-trip | |
| `create/v2.spec.ts` | CV2-05 a rejected label key surfaces as a
notification, not an inline message | |
| `create/v2.spec.ts` | CV2-06 CV2-07 the operator and match-type
selects offer the documented options | |
| `create/v2.spec.ts` | CV2-08 the operator is rule-wide: one change
reaches every threshold | |
| `create/v2.spec.ts` | CV2-09 CV2-10 added thresholds take preset
tiers, and the first cannot be removed | |
| `create/v2.spec.ts` | CV2-11 a channel on one threshold is not enough
— the validator loops all of them | |
| `create/v2.spec.ts` | CV2-12 the unit select is disabled while the
query has no y-axis unit | |
| `create/v2.spec.ts` | CV2-13 the recovery threshold control is never
rendered | |
| `create/v2.spec.ts` | CV2-14 CV2-15 the evaluation window and cadence
reach the payload | |
| `create/v2.spec.ts` | CV2-18 with no channels the dropdown offers only
a way to create one | |
| `create/v2.spec.ts` | CV2-19 routing policies unlock the save with
zero channels | |
| `create/v2.spec.ts` | CV2-16 the group-by select is disabled until the
query groups by something | |
| `create/v2.spec.ts` | CV2-17 repeat notifications enable their inputs
and reach the payload | |
| `create/v2.spec.ts` | CV2-20 happy-path save posts the v2 shape and
lands on the list | |
| `create/v2.spec.ts` | CV2-21 test notification reports that a
non-firing rule matched nothing | |
| `create/v2.spec.ts` | CV2-22 discard leaves without posting and resets
the form | |
| `create/v2.spec.ts` | CV2-23 every footer button is disabled while the
save is in flight | |

#### Edit alert (22)

| File | Test | Status |
|---|---|---|
| `edit/edge.spec.ts` | CE-03 an unknown ruleId shows AlertNotFound on
both entry URLs | |
| `edit/edge.spec.ts` | CE-03b /alerts/edit with no ruleId also lands on
AlertNotFound | |
| `edit/v1.spec.ts` | EV1-01 the classic form renders in edit mode
inside the details shell | |
| `edit/v1.spec.ts` | EV1-02 every seeded field prefills the form |  |
| `edit/v1.spec.ts` | EV1-03 preferredChannels decide which channel
control is prefilled | |
| `edit/v1.spec.ts` | EV1-04 the happy-path update PUTs the v1 body and
keeps unrelated params | |
| `edit/v1.spec.ts` | EV1-05 Discard leaves without a PUT and without
changing the rule | |
| `edit/v1.spec.ts` | EV1-06 the header title and the form name field
agree | |
| `edit/v1.spec.ts` | EV1-07 /alerts/edit redirects for a v1 rule
exactly as it does for v2 | |
| `edit/v1.spec.ts` | EV1-08 editing a v1 rule never migrates it to the
v2 schema | |
| `edit/v2.spec.ts` | EV2-01 the v2 editor renders inside the details
shell | |
| `edit/v2.spec.ts` | EV2-02 name and labels prefill from the rule |  |
| `edit/v2.spec.ts` | EV2-03 both thresholds prefill, and the sentence
reads spec[0] | |
| `edit/v2.spec.ts` | EV2-04 the recovery threshold control never
renders | |
| `edit/v2.spec.ts` | EV2-05 the evaluation window prefills, and a
non-preset value collapses to custom | |
| `edit/v2.spec.ts` | EV2-06 repeat notifications prefill from the
seeded renotify block | |
| `edit/v2.spec.ts` | EV2-07 alertOnAbsent prefills the advanced options
| |
| `edit/v2.spec.ts` | EV2-08 the evaluation cadence always reads back in
default mode | |
| `edit/v2.spec.ts` | EV2-09 changing a threshold PUTs the rule and the
change survives a reload | |
| `edit/v2.spec.ts` | EV2-10 the footer save is what persists a rename
made on the Overview tab | |
| `edit/v2.spec.ts` | EV2-11 Discard leaves without a PUT and without
touching the rule | |
| `edit/v2.spec.ts` | EV2-12 /alerts/edit is a legacy alias that
redirects into the details shell | |

#### Alert details (15)

| File | Test | Status |
|---|---|---|
| `details/actions.spec.ts` | AD-06 enable/disable toggle changes the
rule state | |
| `details/actions.spec.ts` | AD-07 Duplicate creates a copy and
navigates to overview | |
| `details/actions.spec.ts` | AD-08 Delete removes the rule and returns
to the list | |
| `details/chrome.spec.ts` | AD-09 copy-link button copies the current
URL to clipboard | conditional |
| `details/chrome.spec.ts` | AD-10 breadcrumb navigates back to the
alert list | |
| `details/chrome.spec.ts` | AD-13 document title updates to show the
rule name | |
| `details/header.spec.ts` | AD-01 v2 header shows editable name input
without Rename menu item | |
| `details/header.spec.ts` | AD-02 v1 header shows static title with
state, severity and labels | |
| `details/not-found.spec.ts` | AD-11 invalid ruleId shows AlertNotFound
page | |
| `details/not-found.spec.ts` | AD-12 missing ruleId on overview shows
AlertNotFound page | |
| `details/rename.spec.ts` | AD-03 v1 rename via modal updates the rule
name | |
| `details/rename.spec.ts` | AD-04 v2 inline rename saves via Overview
footer button | |
| `details/tabs.spec.ts` | AD-05 Overview/History tabs preserve ruleId
and relativeTime | |
| `details/tabs.spec.ts` | AD-05b switching to History tab discards
other history params | |
| `details/threshold-persistence.spec.ts` | TC-02 edit page displays the
saved threshold value | |

#### Alert history (75)

| File | Test | Status |
|---|---|---|
| `history/cross-cutting.spec.ts` | AX-01 full deep-link with all params
is honoured in one load | |
| `history/cross-cutting.spec.ts` | AX-02 page reload preserves all
history params | |
| `history/cross-cutting.spec.ts` | AX-03 browser back/forward restores
correct table state | |
| `history/cross-cutting.spec.ts` | AX-04 no unhandled console errors
across full history session | |
| `history/cross-cutting.spec.ts` | AX-05 no request storm on mount
(exactly one call per endpoint) | |
| `history/cross-cutting.spec.ts` | AX-06 v1 and v2 schema rules both
render history correctly | |
| `history/cross-cutting.spec.ts` | AX-07 no legacy v1 history API calls
during full session | |
| `history/cross-cutting.spec.ts` | AX-08 history API endpoints carry
expected params | |
| `history/empty-and-errors.spec.ts` | AE-01 invalid filter expression
shows syntax error and recovers on fix | |
| `history/empty-and-errors.spec.ts` | AE-02 empty filter_keys response
still mounts editor (no suggestions) | |
| `history/empty-and-errors.spec.ts` | AE-02b bogus ruleId never reaches
history APIs (shows AlertNotFound) | |
| `history/empty-and-errors.spec.ts` | AE-03 rule with no history
renders empty state (not error) | |
| `history/empty-and-errors.spec.ts` | AE-04 time range with no data
renders empty state | |
| `history/empty-and-errors.spec.ts` | AE-05 time-range change resets
pagination to first page | |
| `history/empty-and-errors.spec.ts` | AE-06 absurd time range (90d)
still renders | |
| `history/empty-and-errors.spec.ts` | AE-07 disabled rule history is
still readable | |
| `history/empty-and-errors.spec.ts` | AE-08 deleted rule shows
AlertNotFound on revisit | |
| `history/expression-filter.spec.ts` | AF-06 key suggestions load on
page load | |
| `history/expression-filter.spec.ts` | AF-07 value suggestions fetch
from filter_values endpoint | |
| `history/expression-filter.spec.ts` | AF-08 value suggestions filter
client-side as user types | |
| `history/expression-filter.spec.ts` | AF-09 running equality
expression filters the table | |
| `history/expression-filter.spec.ts` | AF-10 running expression resets
pagination to first page | |
| `history/expression-filter.spec.ts` | AF-11 Run button re-fetches
unchanged expression | |
| `history/expression-filter.spec.ts` | AF-12 in-flight query can be
cancelled | |
| `history/expression-filter.spec.ts` | AF-13 threshold.name and
severity keys filter correctly | |
| `history/expression-filter.spec.ts` | AF-14 unknown key returns 200
with zero rows (not 500) | |
| `history/expression-filter.spec.ts` | AF-15 expression is lost on
Overview→History round-trip (known bug) | |
| `history/expression-filter.spec.ts` | AF-16 expression and state
filter compose in request | |
| `history/expression-filter.spec.ts` | AF-17 clearing expression
restores full unfiltered list | |
| `history/state-filter.spec.ts` | AF-01 All filter sends no state param
in request | |
| `history/state-filter.spec.ts` | AF-02 Fired filter sends state=firing
in request | |
| `history/state-filter.spec.ts` | AF-03 Resolved filter shows empty for
rule with no resolutions | |
| `history/state-filter.spec.ts` | AF-03b Resolved filter shows rows for
rule with resolutions | |
| `history/state-filter.spec.ts` | AF-04 deep-link ?timelineFilter=FIRED
starts on Fired tab | |
| `history/state-filter.spec.ts` | AF-05 changing state filter resets
pagination to first page | |
| `history/statistics.spec.ts` | AS-01 Total Triggered card shows the
firing count | |
| `history/statistics.spec.ts` | AS-02 Avg. Resolution Time card shows
"No Resolutions." when none exist | |
| `history/statistics.spec.ts` | AS-03 empty stats card never renders a
sparkline | |
| `history/statistics.spec.ts` | AS-03b sparkline present with a
multi-point series | skipped |
| `history/statistics.spec.ts` | AS-04 change-vs-past indicator shows
"no previous data" when unavailable | |
| `history/statistics.spec.ts` | AS-09 stats update when time range
changes | |
| `history/statistics.spec.ts` | AS-11 Avg. Resolution Time shows
formatted duration when resolutions exist | |
| `history/statistics.spec.ts` | AS-12 Total Triggered counts only
firing rows (not resolved) | |
| `history/timeline-graph.spec.ts` | AT-03 renders canvas with two
segments (inactive→firing) | |
| `history/timeline-graph.spec.ts` | AT-03b renders canvas with three
segments (inactive→firing→inactive) | |
| `history/timeline-graph.spec.ts` | AT-19 handles nodata state without
console errors | |
| `history/timeline-pagination.spec.ts` | AT-06 next page sends cursor
and shows different rows | |
| `history/timeline-pagination.spec.ts` | AT-07 prev page drops the
cursor from request | |
| `history/timeline-pagination.spec.ts` | AT-08 pagination buttons
disable at first and last page | |
| `history/timeline-pagination.spec.ts` | AT-09 browser back after
paging returns to previous page | |
| `history/timeline-pagination.spec.ts` | AT-10 deep-link ?page=2 loads
second page directly | |
| `history/timeline-pagination.spec.ts` | AT-11 default sort order is
ascending | |
| `history/timeline-pagination.spec.ts` | AT-12 sorting toggles order
and resets to first page | |
| `history/timeline-pagination.spec.ts` | AT-13 single page disables
both pagination buttons | |
| `history/timeline-pagination.spec.ts` | AT-21 all pages together cover
the complete row set | |
| `history/timeline-table.spec.ts` | AT-01 timeline section renders all
chrome elements | |
| `history/timeline-table.spec.ts` | AT-02 Top 5 Contributors tab is
disabled with Coming Soon indicator | |
| `history/timeline-table.spec.ts` | AT-04 table rows display state,
labels and formatted timestamp | |
| `history/timeline-table.spec.ts` | AT-05 footer shows correct row
range | |
| `history/timeline-table.spec.ts` | AT-14 row click does not navigate
away | |
| `history/timeline-table.spec.ts` | AT-15 row actions link navigates to
logs explorer | |
| `history/timeline-table.spec.ts` | AT-15b row actions link navigates
to traces explorer | |
| `history/timeline-table.spec.ts` | AT-16 metrics rule rows show
disabled action (no related links) | |
| `history/timeline-table.spec.ts` | AT-17 CREATED AT column respects
app timezone setting | |
| `history/timeline-table.spec.ts` | AT-18 state cell renders Firing,
Resolved, and No Data correctly | |
| `history/timeline-table.spec.ts` | AT-18b pending/recovering states
render blank (coverage gap) | skipped |
| `history/timeline-table.spec.ts` | AT-18c disabled state renders as
"Muted" (coverage gap) | skipped |
| `history/timeline-table.spec.ts` | AT-20 time-range boundaries
inclusive/exclusive (coverage gap) | skipped |
| `history/top-contributors.spec.ts` | AS-05 card displays max 3 rows
with count ratios | |
| `history/top-contributors.spec.ts` | AS-13 contributor bar width is
the count as a percentage of the total | |
| `history/top-contributors.spec.ts` | AS-06 "View all" button only
appears when more than 3 contributors | |
| `history/top-contributors.spec.ts` | AS-07 View-all drawer shows
paginated list of all contributors | |
| `history/top-contributors.spec.ts` | AS-07b drawer opens from deep
link with ?viewAllTopContributors=true | |
| `history/top-contributors.spec.ts` | AS-08 View-all click adds
?viewAllTopContributors=true to URL | |
| `history/top-contributors.spec.ts` | AS-10 contributor rows show
related-logs link for logs-based rules | |

#### Notification channels (1)

| File | Test | Status |
|---|---|---|
| `channels/edit.spec.ts` | NC-01 an edited recipient persists after
reload | |

#### Skipped tests

| Test | File | Kind | Reason |
|---|---|---|---|
| the v2 Discard button is clickable | `create/edge.spec.ts` | hard
`test.skip(` | Real bug: the button is not clickable. Test written, left
ready to flip. |
| broadcast-to-all saves the rule with the broadcast flag |
`create/v1.spec.ts` | hard `test.skip(` | Real bug: the broadcast flag
is not persisted. |
| sparkline present with a multi-point series |
`history/statistics.spec.ts` | `test.skip(true)` | Flaky by
construction: the sparkline only renders with more than one data point,
and whether the seeded ~2-minute window lands in one stats bucket or two
depends on where it falls relative to the bucket boundary. |
| pending/recovering states render blank |
`history/timeline-table.spec.ts` | `test.skip(true)` | Unreachable:
`pending` and `recovering` are transient states, and no fixture can
reliably catch a rule mid-transition. |
| disabled state renders as "Muted" | `history/timeline-table.spec.ts` |
`test.skip(true)` | Unreachable: a `disabled` history row is
policy-driven, and disabling a rule appends no row (verified). |
| time-range boundaries inclusive/exclusive |
`history/timeline-table.spec.ts` | `test.skip(true)` | Unreachable:
asserting a row exactly at `start` and one at `start-1ms` means
controlling row timestamps, but evaluation times are whatever the ruler
chose. |
| the anomaly card rewrites the rule type, not the alert type |
`create/shell.spec.ts` | conditional | Runs only where the
`ANOMALY_DETECTION` feature flag is active; it is off on this stack. |
| copy-link button copies the current URL to clipboard |
`details/chrome.spec.ts` | conditional | Runs on Chromium only —
Playwright grants `clipboard-read` nowhere else. |


#### Issues closed by this PR
> Reference issues using `Closes #issue-number` to enable automatic
closure on merge.

Closes https://github.com/SigNoz/engineering-pod/issues/4917

---

###  Change Type
_Select all that apply_

- [ ]  Feature
- [ ] 🐛 Bug fix
- [ ] ♻️ Refactor
- [ ] 🛠️ Infra / Tooling
- [x] 🧪 Test-only

---

### ⚠️ Risk & Impact Assessment
> What could break? How do we recover?

- Blast radius: Alerts
- Potential regressions: None, only test ids
- Rollback plan: Find and fix the issue specifically

---

### 📝 Changelog
> Fill only if this affects users, APIs, UI, or documented behavior  
> Use **N/A** for internal or non-user-facing changes

| Field | Value |
|------|-------|
| Deployment Type | Cloud / OSS / Enterprise |
| Change Type | Maintenance |
| Description | We added more E2E tests for Alerts page. |

---

### 📋 Checklist
- [x] Tests added or explicitly not required
- [ ] Manually tested
- [ ] Breaking changes documented
- [ ] Backward compatibility considered
2026-08-26 06:37:32 +00:00

20 KiB

E2E Tests

SigNoz uses end-to-end tests to verify the frontend works correctly against a real backend. These tests use Playwright to drive a real browser against a containerized SigNoz stack that pytest brings up — the same fixture graph integration tests use, with an extra HTTP seeder container for per-spec telemetry seeding.

How to set up the E2E test environment?

Prerequisites

Before running E2E tests, ensure you have the following installed:

  • Python 3.13+
  • uv
  • Docker (for containerized services)
  • Node 18+ and Yarn

Initial Setup

  1. Install Python deps for the shared tests project:
cd tests
uv sync
  1. Install Node deps and Playwright browsers:
cd e2e
yarn install
yarn install:browsers   # one-time Playwright browser install

Starting the Test Environment

To spin up the backend stack (SigNoz, ClickHouse, Postgres, ClickHouse Keeper, Zeus mock, gateway mock, seeder, migrator-with-web) and keep it running:

cd tests
uv run pytest --basetemp=./tmp/ -vv --reuse --rebuild --with-web \
  e2e/bootstrap/setup.py::test_setup

This command will:

  • Bring up all containers via pytest fixtures
  • Register the admin user (admin@integration.test / password123Z$)
  • Apply the enterprise license (via a WireMock stub of Zeus) and dismiss the org-onboarding prompt so specs can navigate directly to feature pages
  • Start the HTTP seeder container (tests/seeder/ — exposing /telemetry/{traces,logs,metrics} POST + DELETE)
  • Write backend coordinates to tests/e2e/.env.local (loaded by playwright.config.ts via dotenv)
  • Keep containers running via the --reuse flag
  • Rebuild the SigNoz container from the current sources via the --rebuild flag

The --with-web flag builds the frontend into the SigNoz container — required for E2E. The build takes ~4 mins on a cold start; later builds are incremental.

Rebuilding After Source Changes

The --with-web image bakes the built frontend in, so neither backend nor frontend changes are picked up while --reuse keeps the container running. --rebuild fixes that for both: it kills the SigNoz container, rebuilds the image incrementally (go build cache + pnpm store — a frontend-only change rebuilds in about a minute), and starts a fresh one while databases, mocks, migrations, and the seeder stay reused. The setup command above passes it, so the iteration loop is: change code → re-run the setup command → re-run your specs. --rebuild requires --reuse and cannot be combined with --teardown or --clean.

Stopping the Test Environment

When you're done writing E2E tests, clean up the environment:

cd tests
uv run pytest --basetemp=./tmp/ -vv --teardown \
  e2e/bootstrap/setup.py::test_teardown

Understanding the E2E Test Framework

Playwright drives a real browser (Chromium / Firefox / WebKit) against the running SigNoz frontend. The backend is brought up by the same pytest fixture graph integration tests use, so both suites share one source of truth for container lifecycle, license seeding, and test-user accounts.

  • Why Playwright? First-class TypeScript support, network interception, automatic wait-for-visibility, built-in trace viewer that captures every request/response the UI triggers — so specs rarely need separate API probes alongside UI clicks.
  • Why pytest for lifecycle? The integration suite already owns container bring-up. Reusing it keeps the E2E stack exactly in sync with the integration stack and avoids a parallel lifecycle framework.
  • Why a separate seeder container? Per-spec telemetry seeding (traces / logs / metrics) needs a thin HTTP wrapper around the ClickHouse insert helpers so a browser spec can POST from inside the test. The seeder lives at tests/seeder/, is built from tests/Dockerfile.seeder, and reuses the same fixtures/{traces,logs,metrics}.py as integration tests.
tests/
├── fixtures/                  # shared with integration (see integration.md)
├── integration/               # pytest integration suite
├── seeder/                    # standalone HTTP seeder container
│   ├── __init__.py
│   ├── Dockerfile
│   └── server.py              # FastAPI app wrapping fixtures.{traces,logs,metrics}
└── e2e/
    ├── package.json
    ├── playwright.config.ts   # loads .env + .env.local via dotenv
    ├── .env.example           # staging-mode template
    ├── .env.local             # generated by bootstrap/setup.py (gitignored)
    ├── bootstrap/
    │   └── setup.py           # test_setup / test_teardown — pytest lifecycle
    ├── fixtures/              # Playwright test fixtures (test.extend) only
    │   └── auth.ts
    ├── helpers/               # function helpers + the constants they share with tests
    │   ├── auth.ts
    │   └── dashboards.ts
    ├── testdata/              # static data files (JSON) used by helpers and tests
    │   └── apm-metrics.json   # (example)
    ├── tests/                 # Playwright .spec.ts files, one dir per feature area
    │   └── alerts/
    │       └── alerts.spec.ts # (example)
    └── artifacts/             # per-run output (gitignored)
        ├── html/              # HTML reporter output
        ├── json/              # JSON reporter output
        └── results/           # per-test traces / screenshots / videos on failure

fixtures/ vs helpers/ — what goes where

These two folders look similar but mean different things:

  • fixtures/ holds Playwright test fixtures (created via test.extend({...})). By the canonical definition, a fixture is "a consistent, predefined set of data, objects, or environmental conditions used to ensure tests run in a stable state" — i.e. setup/teardown that runs automatically around each test or worker. auth.ts matches: it extends Playwright's test with an authedPage that's logged-in before every test runs and torn down after. If the only thing in this folder ever is auth.ts, that's fine — fixtures are a deliberately small surface.
  • helpers/ holds plain function helpers that you call explicitly from a test or hook — they don't extend Playwright's test. This covers both behaviour helpers (e.g. gotoDashboardsList(page)) and the constants those helpers and the tests both refer to (e.g. SEARCH_PLACEHOLDER). Constants live next to the helpers that use them so a single import line in a test covers both.
  • testdata/ holds static data files (typically JSON / YAML) consumed by the helpers — for example, apm-metrics.json, a real dashboard payload uploaded through the UI by an importer helper.

Rule of thumb: if it's a test.extend fixture, put it in fixtures/. If it's a function you call explicitly (or a constant the function uses), put it in helpers/. If it's a static file the helpers read, put it in testdata/.

Extended fixtures

For features needing complex setup (API-seeded data, ruler evaluation waits, cleanup), create domain-specific fixtures that extend auth. Group them in fixtures/<domain>/.

Fixture scopes:

  • test scope — fresh data per test. Use for mutations (edit, delete, rename).
  • worker scope — shared across tests in one worker. Use for read-only data. Worker scope pays the setup cost once per worker instead of once per test.

The alerts pattern (fixtures/alerts/) demonstrates extending fixtures:

fixtures/alerts/
├── alert-rules.ts   # extends auth — worker-scoped rule list + test-scoped factory
└── alert-history.ts # extends alert-rules — adds history fixtures (waits on ruler)

Specs import from the fixture they need:

// List tests — just need rules, no history
import { test, expect } from '../../../fixtures/alerts/alert-rules';

// History tests — need history rows from ruler evaluation
import { test, expect } from '../../../fixtures/alerts/alert-history';

When creating new fixtures:

  1. Identify scope — Will tests mutate the data? If yes, test-scoped. If read-only, worker-scoped.
  2. Group by domain — Put fixtures in fixtures/<domain>/. Helpers in helpers/<domain>/.
  3. Extend existing fixtures — Chain from auth or another fixture to inherit its setup.
  4. Handle timeouts — Worker-scoped fixtures that wait on backend processing need explicit timeouts.
  5. Clean up — Always delete seeded data in the fixture teardown (after use()).
  6. Extract logic into functions — Keep the test.extend() block lean; move setup/teardown logic to named functions so the extend block reads as a manifest of "what fixtures exist."

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 seeds its own data and cleans up at suite teardown. The pytest harness creates a fresh stack with zero dashboards / alerts / etc. — never assume pre-existing data. Two patterns work:
    • Per-test seed + cleanup in try / finally — small specs where each test owns its data.
    • Suite-level seed + afterAll teardown — preferred for larger specs. Each createDashboard(...) call adds the resulting ID to a module-level Set<string>, and one test.afterAll(...) deletes everything in the set. See tests/e2e/tests/dashboards/list.spec.ts for the full pattern. test.beforeAll / test.afterAll cannot use authedPage directly (it's test-scoped); use newAdminContext(browser) from helpers/auth.ts instead — it performs one fresh login per suite hook.
  5. Seed via API when the UI flow is multi-step or brittle. The frontend stores its JWT in localStorage under AUTH_TOKEN; page.request.* inherits the auth fixture's storage state. A typical pattern:
    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?

Create a new file tests/e2e/tests/alerts/smoke.spec.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();
  await expect(page.getByRole('tab', { name: /configuration/i })).toBeVisible();
});

The authedPage fixture (from tests/e2e/fixtures/auth.ts) gives you a Page whose browser context is already authenticated as the admin user. First use per worker triggers one login; the resulting storageState is held in memory and reused for later requests.

To run just this test (assuming the stack is up via test_setup):

cd tests/e2e
npx playwright test tests/alerts/smoke.spec.ts --project=chromium

Here's a more comprehensive example that exercises a CRUD flow via the UI:

import { test, expect } from '../../fixtures/auth';

test.describe.configure({ mode: 'serial' });

test('TC-02 alerts list — create, toggle, delete', async ({ authedPage: page }) => {
  await page.goto('/alerts?tab=AlertRules');
  const name = 'smoke-rule';

  // Seed via UI — click "New Alert", fill form, save.
  await page.getByRole('button', { name: /new alert/i }).click();
  await page.getByTestId('alert-name-input').fill(name);
  // ... fill metric / threshold / save ...

  // Find the row and exercise the action menu.
  const row = page.locator('tr', { hasText: name });
  await expect(row).toBeVisible();
  await row.locator('[data-testid="alert-actions"] button').first().click();

  // waitForResponse captures the network call the UI triggers — no parallel fetch needed.
  const patchWait = page.waitForResponse(
    (r) => r.url().includes('/rules/') && r.request().method() === 'PATCH',
  );
  await page.getByRole('menuitem').filter({ hasText: /^disable$/i }).click();
  await patchWait;
  await expect(row).toContainText(/disabled/i);
});

Locator priority

  1. getByTestId('...') — preferred when the source exposes one. Stable, app-author-provided handle that survives copy-edits.
  2. getByRole('button', { name: 'Submit' })
  3. getByLabel('Email')
  4. getByPlaceholder('...')
  5. getByText('...')
  6. locator('.ant-select') — last resort (Ant Design dropdowns often have no semantic alternative)

Agents

Three Claude agents in .claude/agents/ accelerate writing and maintaining E2E specs:

  • playwright-test-planner — explores a feature in a real browser plus the local frontend source and writes a test plan as a scratch markdown file (under tests/e2e/specs/, which is gitignored — plans are working artifacts for the generator, not committed docs).
  • playwright-test-generator — converts a test plan into Playwright spec files under tests/e2e/tests/<feature>/. Drives each scenario through MCP browser tools and emits TC-NN-titled tests using the authedPage fixture and the API-seed pattern.
  • playwright-test-healer — runs failing specs, debugs them with snapshots / console / network introspection, and edits the spec to fix selector drift, timing, or state-leak issues.

The agents rely on the Playwright-test MCP server (mcp__playwright-test__* tools). Configure it in your Claude MCP settings; the permission allowlist lives in .claude/settings.local.json.

How to run E2E tests?

Running All Tests

With the stack already up, from tests/e2e/:

yarn test                 # headless, all projects

Running Specific Projects

yarn test:chromium        # chromium only
yarn test:firefox
yarn test:webkit

Running Specific Tests

cd tests/e2e

# Single feature dir
npx playwright test tests/alerts/ --project=chromium

# Single sub-area
npx playwright test tests/alerts/history/ --project=chromium

# Single file
npx playwright test tests/alerts/page.spec.ts --project=chromium

# Single test by title grep
npx playwright test --project=chromium -g "AL-01"

Iterative modes

yarn test:ui              # Playwright UI mode — watch + step through
yarn test:headed          # headed browser
yarn test:debug           # Playwright inspector, pause-on-breakpoint
yarn codegen              # record-and-replay locator generation
yarn report               # open the last HTML report (artifacts/html)

Staging fallback

Point SIGNOZ_E2E_BASE_URL at a remote env via .env — no local backend bring-up, no .env.local generated, Playwright hits the URL directly:

cd tests/e2e
cp .env.example .env      # fill SIGNOZ_E2E_USERNAME / PASSWORD
yarn test:staging

How to configure different options for E2E tests?

Environment variables

Variable Description
SIGNOZ_E2E_BASE_URL Base URL the browser targets. Written by bootstrap/setup.py for local mode; set manually for staging.
SIGNOZ_E2E_USERNAME Admin email. Bootstrap writes admin@integration.test.
SIGNOZ_E2E_PASSWORD Admin password. Bootstrap writes the integration-test default.
SIGNOZ_E2E_SEEDER_URL Seeder HTTP base URL — hit by specs that need per-test telemetry.

Precedence in playwright.config.ts, lowest to highest: .env (user-provided, staging) → .env.local (bootstrap-generated, local mode) → whatever is already in process.env. The config parses both files itself and only fills in keys the environment does not already define, so exporting a variable always wins:

# runs against a locally served frontend, not whatever .env.local points at
SIGNOZ_E2E_BASE_URL=http://127.0.0.1:3301 pnpm test tests/alerts

This is deliberately not dotenv.config({ override: true }). That flag makes the file beat process.env, which silently discarded exported values — including the SIGNOZ_E2E_BASE_URL in pnpm test:staging, whenever a .env.local happened to exist.

Playwright options

The full playwright.config.ts is the source of truth. Common things to tweak:

  • projects — Chromium / Firefox / WebKit are enabled by default. Disable to speed up iteration.
  • retries2 on CI (process.env.CI), 0 locally.
  • fullyParallel: true — files run in parallel by worker; within a file, use test.describe.configure({ mode: 'serial' }) if tests share list pages / mutate shared state.
  • trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure' — default diagnostic artifacts land in artifacts/results/<test>/.

Pytest options (bootstrap side)

The same pytest flags integration tests expose work here, since E2E reuses the shared fixture graph:

  • --reuse — keep containers warm between runs (required for all iteration).
  • --rebuild — recreate the SigNoz container from the current sources (backend and, with --with-web, frontend) while the rest of the stack stays up. Requires --reuse.
  • --teardown — tear everything down.
  • --clean — prune the docker build caches, forcing the next image build to start cold.
  • --with-web — build the frontend into the SigNoz container. Required for E2E; integration tests don't need it.
  • --sqlstore-provider, --postgres-version, --clickhouse-version, etc. — see docs/contributing/tests/integration.md.

What should I remember?

  • Always use the --reuse flag when setting up the E2E stack. --with-web adds a ~4 min frontend build on a cold start; later builds are incremental.
  • Changed backend or frontend code? Re-run the setup command — it passes --rebuild, swapping the SigNoz container for one built from your current sources while the rest of the stack stays up.
  • Don't teardown before setup. --reuse correctly handles partially-set-up state, so chaining teardown → setup wastes time.
  • Prefer UI-driven flows. Playwright captures BE requests in the trace; a parallel fetch probe is almost always redundant. Drop to page.request.* only when the UI can't reach what you need.
  • Use page.waitForResponse on UI clicks to assert BE contracts — it still exercises the UI trigger path.
  • Title every test TC-NN <short description> — keeps the suite navigable and reportable.
  • Split by resource, not by regression suite. One spec per feature resource; cross-resource junction concerns (cascade-delete, linked-edit) get their own file.
  • Use short descriptive resource names (alerts-list-rule, labels-rule, downtime-once) — no timestamp disambiguation. Each test owns its resources and cleans up in try/finally.
  • Never commit test.only — a pre-commit check or CI runs with forbidOnly: true.
  • Prefer explicit waits over page.waitForTimeout(ms). await expect(locator).toBeVisible() is always better than waitForTimeout(5000).
  • Unique test names won't save you from shared-tenant state. When two tests hit the same list page, either serialize (describe.configure({ mode: 'serial' })) or isolate cleanup religiously.
  • Artifacts go to tests/e2e/artifacts/ — HTML report at artifacts/html, traces at artifacts/results/<test>/. All gitignored; archive the dir in CI.