Skip to content

Jarvis E2E & Screenshot Testing

Audience: developers and AI agents. This file is the single source of truth for the isolated end-to-end (E2E) test + documentation-screenshot pipeline. Read it fully before touching anything under frontend/e2e/, compose.e2e.yml, Containerfile.e2e, or scripts/e2e-run.sh.

TL;DR

bash
make e2e                              # functional tests, ALL 3 auth modes in sequence (CI runs the same modes as parallel jobs)
make e2e-mode MODE=oidc               # functional tests, ONE mode
make e2e-screenshots                  # regenerate ALL doc screenshots (all modes)
make e2e-screenshot NAME=feature-card-view    # regenerate ONE screenshot (MODE=none default)
make e2e-screenshot NAME=auth-setup MODE=internal
make e2e-down                         # force-clean the stack if something is stuck

Everything runs in a fully isolated Podman/Docker stack with its own network (jarvis_e2e) and ephemeral state (tmpfs). It never touches the dev stack, your real Alertmanager, or any persistent database.


Why a dedicated stack?

The old setup screenshotted the Vite dev server with mocked API responses. That was brittle and untestable. The new pipeline runs the real production binary (frontend embedded) against a real Alertmanager, polling real alerts — so functional tests and screenshots exercise the actual system.

Architecture

E2E stack topology

E2E stack topology

(source: docs/diagrams/e2e-stack.mmd, re-render via make diagrams)

ServiceImagePurpose
e2e-jarvisbuilt from Containerfile.e2eProduction binary + e2e-only seed/reset endpoints. Host port 8085.
e2e-alertmanagerprom/alertmanagerReal Alertmanager. Fixtures are fired via its API v2.
e2e-mock-oidcghcr.io/navikt/mock-oauth2-serverMock OIDC IdP (only started for MODE=oidc). Host port 8086.
e2e-playwrightmcr.microsoft.com/playwrightRuns Playwright specs on the jarvis_e2e network.

The e2e build tag

Containerfile.e2e compiles with -tags "prod e2e". The e2e tag enables test-only endpoints that do not exist in production builds:

EndpointEffect
POST /api/v1/test/resetTruncate all history tables + clear in-memory store.
POST /api/v1/test/seedInsert resolved-alert lifecycles directly into the DB (resolved body key), or backfill multi-cycle firing history for the heatmap (heatmapHistory body key — see jarvis.seedHeatmapHistory()).
POST /api/v1/test/claimSet a claim on an alert (bypasses auth). Used by jarvis.setClaim().
POST /api/v1/test/commentAdd a comment to an alert (bypasses auth). Note: does not broadcast a WS event — use the production endpoint /api/v1/alerts/:fingerprint/comments when testing WebSocket behaviour.

Implementation is split by build tag so production can never expose them:

  • backend/internal/api/testing_routes_e2e.go (//go:build e2e)
  • backend/internal/api/testing_routes_noe2e.go (//go:build !e2e, no-op)
  • backend/internal/history/testing_e2e.go (//go:build e2e, ResetForTesting / SeedResolvedForTesting)

⚠️ Never deploy jarvis-e2e:local / the e2e build outside the test stack.

The three auth modes

The stack is brought up once per mode. scripts/e2e-run.sh sets the env and selects the matching spec folder.

ModeJARVIS_AUTH_PROVIDER / AUTH_MODEFlow exercisedSpec folders
nonenone / noneNo login. NoAuthNotice modal shown.e2e/**/none/
internalinternal / write_protectFirst-run setup wizard + username/password login.e2e/**/internal/
oidcoidc / write_protectFull Authorization-Code-with-PKCE flow against the mock IdP; groups=[Administrator] → admin role.e2e/**/oidc/

Mock OIDC details (gotchas baked in)

  • Config is mounted as a file (scripts/mock-oidc-config.jsonJSON_CONFIG_PATH). Passing it via the JSON_CONFIG env through podman-compose silently drops the value — do not switch back to inline JSON.
  • The claim-injecting requestMapping matches on grant_type=authorization_code, not scope or client_id. Reason: Jarvis is a confidential client and sends client_id/secret via the HTTP Basic header, and scope is only sent to /authorize (not /token). grant_type is the only param reliably present on the token request.
  • interactiveLogin: false → the authorize endpoint auto-approves, so the browser flow needs no manual login page interaction.
  • The OIDC issuer is the internal hostname http://e2e-mock-oidc:8080/default. Both Jarvis (server-side discovery) and the browser (authorize redirect) reach it under that name, so the iss claim stays consistent. Host port 8086 exists only for the readiness probe.

Directory layout

frontend/
  playwright.e2e.config.ts              # functional config; testDir = $E2E_TEST_DIR
  playwright.screenshots.e2e.config.ts  # screenshot config; testDir = $E2E_SCREENSHOT_DIR
  e2e/
    support/
      alertmanager.ts   # AM API v2 client: fire() / clearAll()
      jarvis.ts         # Jarvis client: poll() / reset() / seedResolved() / seedHeatmapHistory()
      auth.ts           # dismissNoAuthNotice, ensureInternalAdmin, loginInternal, loginOIDC
      fixtures.ts       # test.extend (auto reset+clear per test), freezeClock, waitForActiveAlerts
      heatmapHistory.ts # fireWithHeatmapHistory() — screenshot-only, see below
      screenshotData.ts # polished alert fixture + label hiding shared by card-view / home-tour shots
    fixtures/
      alerts.ts         # kubernetesAlerts (4), manyAlerts (~14, for populated screenshots)
    functional/
      none/             # card-view, no-auth-notice
      internal/         # setup + login, no Account entry for local accounts
      oidc/             # oidc login + admin-group mapping, Account panel (groups claim)
    screenshots/
      none/             # feature-*, auth-noauth-notice, screenshot, social-templates (Open Graph 1200×630, square 1080×1080, slide 1920×1080 → docs/assets/social-*.png, from the design tokens + logo)
      internal/         # auth-setup, auth-login-internal, auth-user-menu, auth-admin-panel, auth-login-page
      oidc/             # oidc-authenticated, auth-login-oidc, screenshot (README hero)
    video/              # demo videos: recorder.ts, build-video.mjs, fonts.conf, release + intro storyboard templates
    _video/             # gitignored: <project>.video.ts, <project>.narration.json, <project>/ (frames, audio)
  playwright.video.config.ts            # release-video config; viewport per $VIDEO_FORMAT

The release demo video reuses this stack: scripts/e2e-run.sh video none runs the storyboard once per format. It is produced only on request — workflow, storyboard rules and hand-over in .agents/skills/release-video/SKILL.md (make release-video VERSION=X.Y.Z [PROJECT=release|intro]). The committed release storyboard template also defines the standard cover subtitle; the product-intro template defines the full headline and subtitle used on its cover.

Conventions

  • One screenshot = one named test. The test name is the PNG basename and the -g selector for single regeneration. Output goes to docs/assets/<name>.png. Exception: a test may write a tightly-coupled series sharing one expensive setup — detail-tabs writes all five feature-detail-tab-<tab>.png images (one per detail-panel tab) from a single fire+seed; regenerating the series is still one -g run (make e2e-screenshot NAME=detail-tabs).
  • The Settings documentation uses both feature-settings-panel (the complete two-column sheet) and feature-settings-labels (the Labels section embedded in docs/features.md). The OIDC screenshot README hero persists team as hidden so the “+N” hidden-labels chip is visible.
  • Fixtures are created per test via real APIs (AM for active alerts, Jarvis test endpoints for resolved/history). The page fixture auto-runs am.clearAll() + jarvis.reset() before every test → clean slate, and also forces prefers-reduced-motion: reduce (page.emulateMedia) so the empty-state Owl mesh backdrop (components/common/OwlMeshBackdrop.tsx) paints a single static frame instead of animating — otherwise a screenshot taken on an empty alerts/silences view would be flaky.
  • Screenshots freeze the clock (freezeClock, Playwright page.clock) and pre-dismiss the NoAuthNotice (except the one screenshot that documents it) so output is deterministic.
  • Use stable data-testid selectors for assertions (alert-card, login-button, user-menu). Add new ones as needed rather than relying on text/CSS.
  • Populate screenshots with manyAlerts so they don't look empty.
  • Element screenshots for a single UI area, not the whole page: when a doc image only needs to show one component (e.g. the heatmap), locate it via data-testid and call locator.screenshot(...) instead of page.screenshot({ fullPage: true }). See feature-heatmap-detail / feature-heatmap-card for the pattern.
  • Heatmap/sparkline data needs backfilled history, not just a live fire. Freshly fired alerts have no past firing events, so every screenshot showing alert cards or the detail panel (essentially anything that fires manyAlerts/kubernetesAlerts and doesn't immediately switch to List View) uses fireWithHeatmapHistory() (support/heatmapHistory.ts) instead of am.fire() + waitForActiveAlerts() — an empty sparkline row reads as a rendering bug, not "no data" (the sparkline is always rendered, see AlertCard.tsx). It fires normally through the real poll path, then backfills a firing→resolved history per alert spread over the last ~29 days (so 24h/7d/30d ranges all show something) directly via jarvis.seedHeatmapHistory() — no DB reset in between, since the direct insert path doesn't need a clean fingerprint row and a reset would wipe users, breaking specs that log in first. History is deterministically varied per alert (seeded by fingerprint, see the file's PRNG comment) so cards don't all render an identical pattern, and some cells land multiple hits for varying intensity — same alert reproduces the same pattern on every regeneration. Only ~55% of alerts get a history at all (HISTORY_PROBABILITY) — a real fleet is a mix of chronic recurring alerts and ones firing for the first time, so giving every alert a rich history reads as fake. Because of that mix, a screenshot meant to demo the heatmap specifically (not just look populated) can't blindly grab alerts[0] — it might land on a "no history" alert. Use pickAlertWithHistory() (same file) to pick one that actually got backfilled; see feature-heatmap-detail / feature-heatmap-card for the pattern. Do call any claim/comment/silence setup for that alert afterfireWithHeatmapHistory, not before.

When to run what

SituationCommandNotes
Before pushing a UI/API changemake e2eAll 3 modes, in sequence. Same tests as CI (which runs them as parallel jobs). ~few min.
Iterating on one modemake e2e-mode MODE=internalFast feedback.
Reproducing one CI shardE2E_SHARD=2/4 make e2e-mode MODE=noneRuns only that Playwright shard (--shard=2/4) of the mode's suite. Never run two shards at the same time in the same checkout (fixed container and network names and ports, down -v at start).
You changed a screen and a doc image is stalemake e2e-screenshot NAME=<id> [MODE=<m>]Regenerate just that PNG, commit it.
Refreshing all docs imagesmake e2e-screenshotsCycles all modes.
Stack stuck / port in usemake e2e-downForce down -v.

What runs in CI

  • The functional suite (all modes) runs in .github/workflows/e2e.yml on every PR and push to main, using COMPOSE_CMD="docker compose". The modes run as parallel jobs on separate runners, each with its own stack: none in four Playwright shards (E2E_SHARD=i/4), internal and oidc one job each, all through make e2e-mode. One stack per mode is shared and reset before every test, so the suite parallelises across stacks, never across workers in one stack. The aggregator job Functional E2E (all auth modes) is the required status check and fails if any shard fails. make e2e remains the local equivalent (all modes in sequence).
  • Screenshots are NOT run in CI. They are a documentation artifact; binary PNGs would create noisy diffs and pixel-flake. Regenerate them locally and commit the PNGs when the UI changes. Every screenshot is 1440×900 at device scale 2 (playwright.screenshots.e2e.config.ts), dark by default; only the hero and overview images also exist as a light pair. The social images use CSS-pixel size (scale: 'css').

Adding a new test / screenshot

  1. Pick the auth mode → the matching functional/<mode>/ or screenshots/<mode>/ folder.
  2. Import from ../../support/fixtures (gives you test, expect, am, jarvis, freezeClock, waitForActiveAlerts) and ../../support/auth for login helpers.
  3. Fire fixtures → drive the UI → assert via data-testid. For screenshots, freezeClock, wait for the expected state, then page.screenshot(...).
  4. If you need a new resolved/history scenario, extend the seed payload (jarvis.seedResolved([...])) — backed by POST /api/v1/test/seed.
  5. Run it: make e2e-mode MODE=<m> or make e2e-screenshot NAME=<id> MODE=<m>.

Key environment variables

VarSet byMeaning
COMPOSE_CMDyou / CIpodman compose (default) or docker compose.
E2E_BASE_URLcomposeJarvis URL inside the network (http://e2e-jarvis:8080).
E2E_ALERTMANAGER_URLcomposeAlertmanager URL (http://e2e-alertmanager:9093).
E2E_TEST_DIR / E2E_SCREENSHOT_DIRe2e-run.shSpec folder for the current mode.
E2E_AUTH_PROVIDER / E2E_AUTH_MODEe2e-run.shJarvis auth config per mode.
E2E_OIDC_*e2e-run.shOIDC issuer/client/redirect/groups-claim/admin-group (oidc mode).
SCREENSHOTS_DIRcomposeWhere PNGs are written (../docs/assets).

Troubleshooting

  • jarvis did not become ready — check podman compose -f compose.e2e.yml logs e2e-jarvis. In oidc mode this usually means discovery failed (mock not up first); the script starts e2e-mock-oidc and waits before booting Jarvis.
  • OIDC username empty / role not admin — the token didn't get the injected claims. Verify scripts/mock-oidc-config.json still matches on grant_type.
  • Build error ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY — host node_modules leaked into the build context. .containerignore must exclude **/node_modules.
  • missing services [e2e-playwright] under podman-compose — don't put the playwright service behind a compose profile; podman-compose run can't see profiled services.

Current test inventory

Quick reference: which spec file covers what. Use this to find the right place for a new test or to understand what already exists.

Mode: none

Spec fileGroupsWhat it covers
a11y.spec.tsA13–A16axe scan (WCAG 2 A/AA + 2.1/2.2 AA, critical and serious) of the Alerts and Silences pages in dark and light, with no rule excluded; the alert card opens from the keyboard through its named "Open details" button rather than a role="button" wrapper (A13); the Fast-Silence popover is a named group of plain buttons rather than an ARIA menu (A14) and is operable from the keyboard — focus alone does not open it, Enter does, Tab reaches its options, Escape returns focus to the trigger (A15); in the list view Enter on a button inside a row acts on that button only, not on the row behind it (A16); and the reduced-motion rule (decorative animations stop, spinners keep turning)
app-shell.spec.tsA1–A12Nav-tabs, theme toggle, mobile hamburger, WS indicator, manual refresh, cluster status in header, info popover, keyboard operation of the cluster/user/refresh header popovers (Enter, Escape, focus-out), owl mark before the tabs (also at 375 px), keyboard-reachable info hints (Escape closes the hint, not the sheet)
card-view.spec.tsB1Card view renders polled alerts (smoke test)
alerts-views.spec.tsB2–B6, B9List↔card toggle, severity ordering, card pagination, fullscreen, resolved view including right-aligned top/footer page navigation
resolved-fetch.spec.tsResolved history is fetched only in resolved mode; initial spinner, error/retry and mode-exit cancellation
resolved-regex-hint.spec.tsThe server-side regex (i) on the Resolved view appears only while a regex filter is set, never on Active
resolved-pagination.spec.tsBounded server pages, no legacy full-history fetch, visible stale-page transition and off-page navigation inputs
alerts-views-extended.spec.tsB7–B8, B10Responsive column binning, empty state, suppressed/silenced view
alerts-overview.spec.tsAlert label breakdown/filtering plus shared modal accessibility: name, focus containment, Escape and focus restoration
filters.spec.tsC1, C10, C10b, C11–C13Exact matcher + ?filter= URL (Alertmanager matcher syntax), state restore from URL, legacy ?matchers= JSON link restored and rewritten to ?filter= (C10b), ?q= search, combined search+chips
filters-extended.spec.tsC2–C8 (C9 removed)!=/=~/!~ operators, regex multi-value, label/value suggestions, label chip → filter, AND matchers, draft→promotion, remove-all
copy-alert-link.spec.tsC1–C2Detail-panel "Copy link": minimal link (no search/filter) opens the same alert in a fresh session; works without the Clipboard API (plain http)
detail-panel.spec.tsD1–D2, D5–D11, G2Open/close/URL param, labels/annotations, stats & timeline, claim set/release, comments add/delete, claim note edit, AI prompt, section collapse, extend menu (expiring and long-running silence, configured silenceDurations)
detail-panel-extended.spec.tsD4, D12–D14Runbook/URL links, AI prompt tab copy, section collapse/expand, silence from detail panel
cluster-scoping.spec.tsX1–X3Cross-cluster isolation for identical fingerprint: stats/history, comments, claims
silences-page.spec.tsE1–E7, E4b, G1, G3List view persist, grouping, show/hide expired, sort (expires/created + asc/desc direction), "By:" creator filter, matcher filter, expiry status, re-create, expire single/group
silences-form-extended.spec.tsF3–F16Operator switch, regex tags+escaping, live match count, overlap warning, zero-match warning, duration presets, spinner normalisation, inline calendar, Now/Reset, end-after-start validation, author editability, reason required, preview summary, results step
silences-form-templates.spec.tsF1–F2, F14–F15, F17, G4–G8Form open/close (Cancel/ESC/backdrop), cluster guard, templates CRUD (create/apply/edit/delete)
silence-matching-semantics.spec.tsDifferential tests against the real Alertmanager instance: form preview's affected-alerts count and actual post-submit suppression must agree — anchored =~/!~ regex (not substring), regex-OR matcher escaping with metacharacter label values
settings.spec.tsH1–H4, H6, H7, H9–H15 (H5, H8 removed), H1bAll settings: timeFormat, defaultViewMode, resolvedPageSize, silenceDuration, defaultCreatorName, claimAnimation, reset defaults, persistence; brand footer (logo + version); settings=open survives reload and is removed on close without dropping other URL params (H12); Fast-Silence duration editor — edit/reset/validate and instance defaults from a mocked GET /api/v1/settings global (H13–H15)
label-display.spec.tsL1–L14Settings → Labels (issue #189): hide removes chips in card + list view but never from the detail panel (invariant #19), pin + drag reorder, pin/hide mutual exclusion, search, alphabetical position of hidden rows, "+N" reveal chip, reset all / "Reset labels" scope, palette color apply/remove, config editable with no firing alert, "Hide all"/"Show all" (unpinned only, search-scoped)
saved-filters.spec.tsK1–K13Saved label filters (replaces "Default Filter"): save from current chips, apply replaces matchers but leaves search untouched, "modified" state (base name + unsaved dot on the closed button, "Save changes to …"), rename with live duplicate validation + Enter/Esc, delete needs a second click, default saved filter applied only with no alert-view URL params, default chips are ordinary/removable, migration of a legacy defaultFilters blob into a default saved filter named "Default", save-row hint text, "Reset all settings" clears saved filters, unsaved dot for a filter built from scratch, "modified" survives reload + save-as-new via Enter, two-click overwrite without a base
no-auth-notice.spec.tsI1NoAuth notice appears and dismiss persists
websocket.spec.tsJ1–J4Reconnect indicator (force-close via patched WebSocket), alerts_update / claim_set / claim_released / comment_added live events
ws-reconnect.spec.tsReconnect jitter (P7): initial connect has no delay; a real disconnect reconnects within the 3-6s jitter window (real wall-clock bounds, not exact-ms fake-clock assertions — those interact unreliably with real WebSocket events); a reconnect triggers exactly one alerts refetch; a stale (superseded) socket's late close event schedules no extra reconnect attempt

Mode: internal

Spec fileGroupsWhat it covers
login.spec.tsI2, I4, I6First-run setup + login happy path, write_protect login modal on write attempt, retry flow after modal login
session-resume.spec.tsR1–R2Login (up front or after a mid-task session expiry → 401) opens over the page and the interrupted silence Create completes; Preview stays enabled logged out
admin.spec.tsI10–I14Administration panel user list, add-user password validation, role change, delete confirm flow, self-row guards
account-menu.spec.tsU4A local account gets no Account entry in the user menu

Mode: oidc

Spec fileGroupsWhat it covers
login.spec.tsI3, I8–I9Full PKCE flow against mock IdP, admin-claim mapping, write_protect SSO modal on write attempt
account.spec.tsU1–U3/auth/me reports the groups from JARVIS_OIDC_GROUPS_CLAIM; the user-menu name opens the Account sheet (user, e-mail, Admin badge, groups); Settings no longer carries account details
sso-resume.spec.tsS1–S3SSO in a popup keeps the silence form and completes the Create; return_to returns a full-page login to its page and never leaves the origin

Known gaps (candidates for future cycles)

Functional cases from the original test catalog that are not covered yet:

  • E8 — Silences page live update: silences_update WS push (not covered by websocket.spec.ts, which only asserts alerts_update / claim_set/claim_released/comment_added) and the 60s FALLBACK_REFETCH_INTERVAL_MS safety-net poll.
  • F18 — Precise/Broader/Pattern silence-matcher presets (remove label / ==~).
  • I5full_protect LoginPage gating the whole app (only a screenshot spec exists, no functional assertion).

Partially covered / known caveats:

  • D3 — annotation link interaction tested; full dynamic link-button matrix not exhaustive.
  • D10 — own-comment delete green; two role-based delete cases are skipped.
  • I6/I7 — setup+login happy path green; setup validation details and logout assertion not asserted.
  • J3 — was flaky (WS event lost when a broadcast raced the client registration in the hub loop); fixed by registering clients synchronously in ServeWS — see .agents/lessons.md.

Released under the Apache 2.0 License. Jarvis is not affiliated with Prometheus or Alertmanager.