--- name: webapp-testing description: End-to-end and visual regression testing for Suaveplan React 19 + TypeScript + Tailwind CSS web applications using Playwright and Storybook --- # Webapp Testing for Suaveplan Write and maintain Playwright E2E tests and Storybook visual regression tests for Suaveplan browser packages. All tests are TypeScript `.spec.ts` files executed natively through Playwright -- no Python scripts, no wrapper layers. ## Architecture - **Runtime**: Bun + Turborepo monorepo - **E2E Framework**: Playwright (TypeScript) with `.spec.ts` files in `e2e/` directories - **Unit/Integration**: Vitest with `.test.ts` / `.test.tsx` files co-located with source - **Visual Testing**: Storybook stories serve as E2E test fixtures via `playwright.config.ts` webServer - **UI Library**: `@suaveplan/react-ui` (shadcn/ui-based) with `cn()` utility for Tailwind class merging - **CSS Prefix**: All Suaveplan components use `sp-` prefix in `data-testid` attributes ## Running Tests ```bash # Run E2E tests for a specific package bun run e2e --filter=@suaveplan/ # Run unit tests bun run test --filter=@suaveplan/ # Run all E2E tests across monorepo bunx turbo e2e # Run with coverage (95% minimum required) bun run test:coverage --filter=@suaveplan/ ``` ## File Structure Every testable browser package follows this layout: ```text packages//// playwright.config.ts # Playwright configuration e2e/ .spec.ts # E2E test files accessibility.spec.ts # Dedicated a11y E2E tests .storybook/ main.ts # Storybook configuration preview.ts # Global decorators, Tailwind CSS import src/ components/ / .tsx # Implementation .test.tsx # Unit tests (Vitest) .stories.tsx # Storybook stories (E2E fixtures) .types.ts # TypeScript types .md # Documentation ``` ## Playwright Configuration Pattern Each package has its own `playwright.config.ts` that launches Storybook as the web server: ```typescript import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './e2e', testMatch: '**/*.spec.ts', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: [['html'], ['json', { outputFile: 'test-results.json' }]], use: { baseURL: 'http://localhost:6030', viewport: { width: 1280, height: 720 }, trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', }, projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], webServer: { command: 'bun run storybook', port: 6030, reuseExistingServer: !process.env.CI, timeout: 300 * 1000, stdout: 'pipe', stderr: 'pipe', }, }); ``` ## E2E Test Patterns ### Navigating to Storybook Stories All E2E tests use Storybook iframe URLs as their entry point: ```typescript import { expect, test } from '@playwright/test'; const storyUrl = (story: string) => `/iframe.html?viewMode=story&id=components---${story}`; test.describe(': Default story', () => { test.beforeEach(async ({ page }) => { await page.goto(storyUrl('default')); await page.waitForSelector('[data-testid="sp--container"]'); }); test('renders the component', async ({ page }) => { const container = page.getByTestId('sp--container'); await expect(container).toBeVisible(); }); }); ``` ### Selector Strategy (Priority Order) 1. **`data-testid` with `sp-` prefix** -- Primary selector for Suaveplan components 2. **ARIA roles and labels** -- For accessible elements 3. **Text content** -- For user-visible labels 4. **CSS class selectors** -- Last resort only, prefix with `sp-` ```typescript // Preferred: data-testid with sp- prefix page.getByTestId('sp-timeline-container'); page.getByTestId('sp-event-bar'); page.getByTestId('sp-data-table-row'); // Good: ARIA roles page.getByRole('button', { name: 'Submit' }); page.getByRole('dialog', { name: 'Confirm deletion' }); page.getByRole('application', { name: 'Timeline' }); // Acceptable: text content page.getByText('No results found'); // Avoid unless necessary: CSS classes page.locator('.sp-card-header'); ``` ### Testing shadcn/ui Components shadcn/ui components from `@suaveplan/react-ui` use Radix UI primitives. Test their behavior through ARIA roles: ```typescript // Dialog (from @suaveplan/react-ui) test('opens dialog on trigger click', async ({ page }) => { await page.getByRole('button', { name: 'Open dialog' }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible(); await expect(dialog).toHaveAttribute('aria-modal', 'true'); }); // Select (Radix-based) test('selects an option from dropdown', async ({ page }) => { await page.getByRole('combobox').click(); await page.getByRole('option', { name: 'Option A' }).click(); await expect(page.getByRole('combobox')).toHaveText('Option A'); }); // Toast notifications (@suaveplan/react-notifications) test('shows success toast after save', async ({ page }) => { await page.getByRole('button', { name: 'Save' }).click(); const toast = page.getByTestId('sp-toast'); await expect(toast).toBeVisible(); await expect(toast).toContainText('Saved successfully'); }); // Data table (@suaveplan/react-data-table) test('sorts table by column header', async ({ page }) => { await page.getByRole('columnheader', { name: 'Name' }).click(); const firstRow = page.getByTestId('sp-data-table-row').first(); await expect(firstRow).toContainText('Alpha'); }); ``` ### Accessibility Testing Pattern Every package should have a dedicated `accessibility.spec.ts`: ```typescript import { expect, test } from '@playwright/test'; const storyUrl = (story: string) => `/iframe.html?viewMode=story&id=components---${story}`; test.describe('Accessibility', () => { test('all interactive elements are keyboard-focusable', async ({ page }) => { await page.goto(storyUrl('default')); await page.waitForSelector('[data-testid="sp--container"]'); // Tab through interactive elements await page.keyboard.press('Tab'); const focused = page.locator(':focus'); await expect(focused).toHaveAttribute('tabindex', '0'); }); test('ARIA labels are present on all semantic elements', async ({ page }) => { await page.goto(storyUrl('default')); const container = page.getByTestId('sp--container'); await expect(container).toHaveAttribute('aria-label'); }); test('focus is trapped inside modal dialogs', async ({ page }) => { await page.goto(storyUrl('with-dialog')); await page.getByRole('button', { name: 'Open' }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeFocused(); }); }); ``` ### Visual Regression Pattern Use Playwright screenshot comparison against Storybook stories: ```typescript test('matches visual snapshot', async ({ page }) => { await page.goto(storyUrl('default')); await page.waitForSelector('[data-testid="sp--container"]'); // Wait for animations to settle await page.waitForTimeout(300); await expect(page).toHaveScreenshot('-default.png', { maxDiffPixelRatio: 0.01, }); }); test('matches dark mode snapshot', async ({ page }) => { await page.goto(storyUrl('dark-mode')); await page.waitForSelector('[data-testid="sp--container"]'); await page.waitForTimeout(300); await expect(page).toHaveScreenshot('-dark.png', { maxDiffPixelRatio: 0.01, }); }); ``` ### Form Testing Pattern For `@suaveplan/react-forms` components: ```typescript test('validates required fields on submit', async ({ page }) => { await page.goto(storyUrl('with-validation')); await page.getByRole('button', { name: 'Submit' }).click(); // Check validation messages appear await expect(page.getByText('Name is required')).toBeVisible(); await expect(page.getByText('Email is required')).toBeVisible(); }); test('submits form with valid data', async ({ page }) => { await page.goto(storyUrl('with-validation')); await page.getByLabel('Name').fill('Jane Doe'); await page.getByLabel('Email').fill('jane@example.com'); await page.getByRole('button', { name: 'Submit' }).click(); // Verify success state await expect(page.getByTestId('sp-form-success')).toBeVisible(); }); ``` ## Constraints - **Max 500 lines per test file** -- split large test suites into multiple `.spec.ts` files by feature - **Max 250 lines per implementation file** -- components exceeding this need refactoring - **95% minimum test coverage** -- enforced across unit + E2E combined - **No mocks** -- all tests use real implementations and real Storybook stories - **TypeScript only** -- no `.js` or `.mjs` test files - **Biome linting** -- all test files must pass `bun run lint` - Every component must have `data-testid` attributes with `sp-` prefix - Every `.spec.ts` file must have a corresponding `.stories.tsx` fixture - Accessibility tests are mandatory for interactive components ## Packages with E2E Tests The following packages have Playwright configurations and should always maintain E2E coverage: | Package | Storybook Port | Test Directory | |---------|---------------|----------------| | `@suaveplan/react-ui` | 6006 | `e2e/` | | `@suaveplan/react-forms` | 6007 | `e2e/` | | `@suaveplan/react-data-table` | 6030 | `e2e/` | | `@suaveplan/react-timeline` | 6030 | `e2e/` | | `@suaveplan/react-notifications` | 6030 | `e2e/` | | `@suaveplan/react-charts` | 6030 | `e2e/` | | `@suaveplan/drive-react-sharing` | 6030 | `e2e/` | | `@suaveplan/react-virtual` | 6030 | `e2e/` | | `@suaveplan/react-auth` | 6030 | `e2e/` | | `@suaveplan/react-media` | 6030 | `e2e/` | ## Checklist Before Completing Tests 1. All `.spec.ts` files pass: `bun run e2e --filter=@suaveplan/` 2. All `.test.tsx` files pass: `bun run test --filter=@suaveplan/` 3. Coverage at 95%+: `bun run test:coverage --filter=@suaveplan/` 4. Biome lint clean: `bun run lint --filter=@suaveplan/` 5. TypeScript types pass: `bun run typecheck --filter=@suaveplan/` 6. Accessibility spec exists for interactive components 7. No `vi.mock()` or `jest.mock()` calls 8. All `data-testid` attributes use `sp-` prefix