Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
277 changes: 277 additions & 0 deletions .claude/skills/e2e-testing/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
---
name: e2e-testing
description: Write and run Playwright end-to-end tests for the Buggregator frontend. Use when the user asks to create e2e tests, test navigation flows, verify page transitions, test links, or validate UI interactions across pages. Also triggers on "e2e", "playwright", "end-to-end", "integration test", "navigation test".
---

# E2E Testing Skill — Buggregator

## Role

You write and run Playwright e2e tests that verify real user flows against a running Buggregator instance (Docker). Tests live in the **server** repo, not the frontend repo.

## Architecture

```
buggregator/
├── server/
│ ├── docker-compose.yaml # Buggregator + Examples app
│ ├── Dockerfile
│ └── e2e/
│ ├── playwright.config.ts
│ ├── package.json # @playwright/test dependency
│ └── tests/
│ ├── helpers.ts # Shared utilities
│ ├── events.spec.ts # Existing module tests
│ └── *.spec.ts # Your new tests go here
├── frontend/
│ └── dist/ # Built frontend (mounted into Docker)
└── examples/
└── app/ # Laravel demo app with all integrations
```

### Services

| Service | URL | Purpose |
| ----------- | ----------------------- | -------------------------------------- |
| Buggregator | `http://localhost:8000` | Main app (Go server + frontend) |
| Examples | `http://localhost:8080` | Laravel app that generates test events |

### Docker Setup

The `docker-compose.yaml` mounts `frontend/dist` into the container. Always build the frontend before starting Docker:

```bash
# From frontend/
npx vite build

# From server/
docker compose up -d
```

Verify both services:

```bash
curl -s http://localhost:8000 | head -1 # Should return HTML
curl -s http://localhost:8080 | head -1 # Should return HTML
```

## Helpers (`tests/helpers.ts`)

```typescript
import { triggerExample, clearEvents, openBuggregator, waitForEvents, navigateTo } from './helpers'
```

| Function | Purpose |
| ----------------------------------------- | ----------------------------------------------------- |
| `triggerExample(action, route?)` | POST to Examples app to generate an event |
| `clearEvents()` | DELETE all events via API |
| `openBuggregator(page)` | Navigate to `http://localhost:8000` and wait for load |
| `waitForEvents(page, minCount, timeout?)` | Poll API until N events exist |
| `navigateTo(page, section)` | Click sidebar link by text |
| `getEventCount(type?)` | Get event count from API |

## Available Test Actions

Events are generated via the Examples Laravel app using `triggerExample(action)`:

### Sentry

| Action | What it sends |
| -------------------------- | ---------------------------------------------------------------- |
| `sentry:event` | Exception with message (captureMessage) |
| `sentry:report` | Exception with stack trace (report) |
| `sentry:trace` | Transaction with db.query + http.client + cache.get spans |
| `sentry:trace_with_error` | Transaction with error span + RuntimeException |
| `sentry:logs` | Raw Sentry log envelope (5 log items) |
| `sentry:model_not_found` | ModelNotFoundException |
| `sentry:validation` | ValidationException |
| `sentry:with_context` | Exception with user/tags/extra context |
| `sentry:nested_exceptions` | Chained exceptions (InvalidArgumentException → RuntimeException) |
| `sentry:database_error` | Database query error |
| `sentry:type_error` | TypeError |

### Other Modules

| Action | Module |
| --------------------------------------------------- | --------- |
| `ray:string` | Ray |
| `monolog:error` | Monolog |
| `var_dump:array` | VarDumper |
| `smtp:welcome_mail` | SMTP |
| `inspector:request` | Inspector |
| `profiler:report` (route: `/example/call/profiler`) | Profiler |
| `http:post` | HTTP Dump |

SMS events are sent directly via POST to `http://localhost:8000/sms/twilio`.

## Writing Tests

### Test File Template

```typescript
import { test, expect, Page } from '@playwright/test'
import { triggerExample, clearEvents, openBuggregator, waitForEvents } from './helpers'

const BUGGREGATOR = 'http://localhost:8000'

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

test.describe('Feature Name', () => {
test.beforeAll(async () => {
await clearEvents()
await triggerExample('sentry:trace_with_error')
await waitForEvents(undefined as unknown as Page, 1)
})

test('description of what user does', async ({ page }) => {
await openBuggregator(page)
// ... test steps
})
})
```

### Key Patterns

#### Wait for events from API (not page)

```typescript
// waitForEvents needs a Page param but only uses API — pass undefined
await waitForEvents(undefined as unknown as Page, 1)
// Or wait with extra time for structured data processing
await new Promise((r) => setTimeout(r, 3000))
```

#### Navigate to a module section

```typescript
await openBuggregator(page)
await page.locator('a').filter({ hasText: 'Sentry' }).first().click()
await page.waitForTimeout(1500)
```

#### Wait for a card to appear

```typescript
await expect(page.locator('.preview-card').first()).toBeVisible({ timeout: 15_000 })
```

#### Find a specific event type card

```typescript
// By CSS class (type is part of the class)
const sentryCard = page.locator('.preview-card--type-sentry').first()
const profilerCard = page.locator('.preview-card--type-profiler').first()
```

#### Click an event to open detail

```typescript
// Body link (inside the card content)
await page.locator('.preview-card a').first().click()

// "Open full event" header button
await page.locator('.preview-card a[title="Open full event"]').first().click()
```

#### Check URL patterns

```typescript
// Sentry event detail (two possible patterns due to named route vs path match)
expect(page.url()).toMatch(/\/sentry\/(event\/)?[0-9a-f-]+/)

// Sentry sub-pages
expect(page.url()).toContain('/sentry/exceptions')
expect(page.url()).toContain('/sentry/traces')
expect(page.url()).toContain('/sentry/logs')

// Trace detail
expect(page.url()).toMatch(/\/sentry\/traces\/[0-9a-f]+/)

// Generic event detail
expect(page.url()).toMatch(/\/(sentry|ray|monolog|profiler)\/[0-9a-f-]+/)
```

#### Conditional tests (data may or may not exist)

```typescript
const tracesTab = page.locator('a').filter({ hasText: 'Traces' }).first()
if (await tracesTab.isVisible().catch(() => false)) {
await tracesTab.click()
// ... assertions
}
```

#### Fetch event IDs from API

```typescript
const res = await fetch(`${BUGGREGATOR}/api/events?type=sentry`)
const json = await res.json()
const eventId = json.data?.[0]?.uuid
```

### Sentry-Specific Selectors

| Element | Selector |
| ----------------------- | ------------------------------------------------------------------ |
| Sub-nav tabs | `a` filtered by `hasText: 'Exceptions'` / `'Traces'` / `'Logs'` |
| Timeline/Grouped toggle | `text=Timeline`, `text=Group by type` |
| Waterfall/Map toggle | `text=Waterfall list`, `text=Service map` |
| Trace card | `.trace-card` |
| Exception group card | `.exc-group` |
| Log filter chips | `button` filtered by `hasText: /^all$/i`, `/^error$/i`, etc. |
| Span detail tabs | `button` filtered by `hasText: 'Related Errors'`, `'Related Logs'` |
| Waterfall op badges | `text=db.query`, `text=http.client`, `text=http.server` |
| Log level badge | `.log-row__level` |

### Sentry Route Map

| URL | What renders |
| ------------------------- | ------------------------------------ |
| `/sentry` | Redirects to `/sentry/exceptions` |
| `/sentry/exceptions` | Exception list (Timeline or Grouped) |
| `/sentry/traces` | Trace list or Service map |
| `/sentry/traces/:traceId` | Trace detail with waterfall |
| `/sentry/logs` | Log list with level filters |
| `/sentry/event/:id` | Event detail page (dedicated route) |
| `/sentry/:id` | Redirects to `/sentry/event/:id` |

## Running Tests

```bash
# Install dependencies (first time)
cd server/e2e && npm install

# Build frontend + start Docker
cd frontend && npx vite build
cd server && docker compose up -d

# Run all e2e tests
cd server/e2e && npx playwright test

# Run specific test file
npx playwright test tests/sentry-navigation.spec.ts

# Run with visible browser
npx playwright test --headed

# List tests without running
npx playwright test --list

# Run specific test by name
npx playwright test -g "Related Errors"
```

## Rules

- **ALWAYS** `clearEvents()` in `beforeAll` to start with a clean slate
- **ALWAYS** use `test.describe.configure({ mode: 'serial' })` — tests within a describe share state
- **NEVER** hardcode event UUIDs — they change on every run
- **ALWAYS** wait for events via API polling before checking the page
- **ALWAYS** add `await page.waitForTimeout()` after navigation (1000–2000ms)
- **ALWAYS** use `.catch(() => false)` with `.isVisible()` when element may not exist
- **ALWAYS** build frontend before running tests: `npx vite build`
- **NEVER** test against `localhost:4173` (that's Vite preview, not Docker)
- Test files go in `server/e2e/tests/`, NOT in `frontend/`
- Use `text=` locators for visible text, `.class` for CSS classes
- Prefer conditional assertions (`if visible`) over hard assertions for data-dependent UI
- Keep tests focused on navigation flows and link correctness, not pixel-perfect UI
2 changes: 1 addition & 1 deletion .github/workflows/lint-code-and-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:

strategy:
matrix:
node-version: [20.19.x, 22.13.x, 24.x]
node-version: [24.x]

steps:
- uses: actions/checkout@v3
Expand Down
8 changes: 7 additions & 1 deletion .storybook/screenshots/take-screenshots.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,13 @@ async function main() {
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 })
await page.waitForTimeout(1500)

await page.screenshot({ path: filepath, fullPage: true, clip: { x: 0, y: 0, width: 1920, height: 1080 } })
// Screenshot just the story content, not the full viewport
const root = await page.$('#storybook-root')
if (root) {
await root.screenshot({ path: filepath })
} else {
await page.screenshot({ path: filepath, fullPage: true })
}

const label = `${story.title} / ${story.name}`
console.log(` ✓ ${label}`)
Expand Down
30 changes: 30 additions & 0 deletions src/app/router/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@
import {MetricsPage} from "@/pages/metrics";
import {NotFoundPage} from "@/pages/not-found";
import {ProfilerComparePage} from "@/pages/profiler-compare";
import {
SentryLayoutPage,
SentryExceptionsPage,
SentryTracesPage,
SentryLogsPage,
SentryTraceDetailPage,
} from "@/pages/sentry";
import {SettingsPage} from "@/pages/settings";
import {RouteName} from "@/shared/types";
import {auth, checkType} from "./middlewares";
Expand All @@ -25,6 +32,29 @@
middleware: [auth]
}
},
{
path: '/sentry/event/:id',
name: RouteName.SentryExceptionDetail,
component: EventPage,
meta: {
middleware: [auth]
},
},
{
path: '/sentry',
component: SentryLayoutPage,
meta: {
middleware: [auth]
},
children: [
{ path: '', redirect: '/sentry/exceptions' },
{ path: 'exceptions', name: RouteName.SentryExceptions, component: SentryExceptionsPage },
{ path: 'traces', name: RouteName.SentryTraces, component: SentryTracesPage },
{ path: 'traces/:traceId', name: RouteName.SentryTraceDetail, component: SentryTraceDetailPage },
{ path: 'logs', name: RouteName.SentryLogs, component: SentryLogsPage },
{ path: ':id', redirect: to => `/sentry/event/${to.params.id}` },

Check failure on line 55 in src/app/router/routes.ts

View workflow job for this annotation

GitHub Actions / build (24.x)

Parameter 'to' implicitly has an 'any' type.

Check failure on line 55 in src/app/router/routes.ts

View workflow job for this annotation

GitHub Actions / build (24.x)

Parameter 'to' implicitly has an 'any' type.
]
},
{
path: '/:type',
name: RouteName.EventList,
Expand Down
Loading
Loading