E2E Testing Infrastructure
The Mallnline frontend uses Playwright for end-to-end testing. This document covers the testing architecture, authentication mocking strategy, common pitfalls, and patterns for writing new E2E tests against the SvelteKit application.
Overview
E2E tests run against the dev server (npm run dev) rather than a production build. This is a deliberate architectural decision โ the SSR build serializes the auth store's initial state server-side where browser === false, making localStorage-based auth mocking impossible. The dev server allows full client-side hydration to pick up injected mock state.
Configuration
Path: playwright.config.ts
const config: PlaywrightTestConfig = {
webServer: {
command: 'npm run dev -- --port 4173',
port: 4173,
reuseExistingServer: true
},
testDir: 'e2e',
timeout: 60000,
use: {
baseURL: 'http://localhost:4173'
}
};
Key decisions:
| Setting | Value | Rationale |
|---|---|---|
reuseExistingServer |
true |
Avoids cold-start delays; reuses any already-running dev server |
timeout |
60000 |
60s per test โ wizard flows involve multiple navigation steps |
testDir |
e2e |
Keeps E2E tests separate from unit tests in src/ |
Authentication Mocking
The auth system presents a two-phase challenge for E2E tests:
- Phase 1 โ Store Initialization: The
authStorereadslocalStorage.getItem('ngwenya_auth_user')on module load. If present, it setsisAuthenticated = true, preventing the$effectredirect to/auth. - Phase 2 โ Server Validation: The root layout calls
initAuth()on mount, which callscheckAuth(), which fetchesGET /me. If this fails (no auth server in test env), the store flipsisAuthenticatedback tofalseโ redirect loop.
Both phases must be intercepted. A dual-mocking strategy handles this:
`page.addInitScript()` โ Seed localStorage
Runs before any page JavaScript evaluates:
await page.addInitScript(({ user, orgId }) => {
window.localStorage.setItem('ngwenya_auth_user', JSON.stringify(user));
if (orgId) {
window.localStorage.setItem('currentOrgId', orgId);
}
}, { user: MOCK_USER, orgId: opts?.orgId });
`page.route('**/me')` โ Intercept Session Check
Prevents initAuth() from invalidating the seeded user:
await page.route('**/me', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(MOCK_USER)
});
});
IMPORTANT
Route mocks must be registered BEFORE page.goto(). If registered after, the page JS will have already fired the /me request and triggered the redirect.
Cookie Consent Bypass
The CookieConsentBanner component renders a full-screen backdrop that intercepts all pointer events. Tests that don't dismiss it will fail with element intercepted by cookie-banner-backdrop.
Fix: Seed the mallnline_cookie_consent localStorage key in addInitScript:
window.localStorage.setItem('mallnline_cookie_consent', JSON.stringify({
necessary: true,
analytics: true,
marketing: false,
functional: true,
consentedAt: new Date().toISOString()
}));
This makes the banner's hasConsentBeenRecorded() check return true, suppressing the overlay entirely.
GraphQL Interception
Use page.route('**/graphql') to intercept GraphQL requests. The pattern inspects request.postData() to determine which operation is being called:
await page.route('**/graphql', async (route, request) => {
const body = request.postData() || '';
if (body.includes('checkHandleAvailability')) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: { checkHandleAvailability: true } })
});
} else {
await route.continue(); // Let other queries pass through
}
});
Override Pattern for Mutations
For test-specific behavior (e.g., simulating server errors), use a callback override:
await mockAuth(page, {
graphqlOverride: async (body, route) => {
if (body.includes('createOneMalet')) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
errors: [{
message: "Plan limit reached",
extensions: { code: 'FORBIDDEN' }
}],
data: null
})
});
return true; // Signal that this route was handled
}
return false; // Fall through to default handler
}
});
Wizard Navigation Helper
Multi-step wizards require sequential navigation. The navigateToReviewStep() helper encapsulates the entire Malet Creation Wizard flow:
async function navigateToReviewStep(page, name = 'E2E Test Malet') {
await page.goto('/create-malet');
await page.waitForLoadState('networkidle');
// Step 1: Vertical Picker
await page.locator('.vertical-card').first().click();
await page.locator('.nav-btn.next').click();
// Step 2: Identity Form
await page.locator('#malet-name').fill(name);
await page.locator('#malet-handle').fill('e2e-handle-' + Date.now());
await page.waitForTimeout(1000); // Debounce
await expect(page.locator('.nav-btn.next')).toBeEnabled({ timeout: 5000 });
await page.locator('.nav-btn.next').click();
// Step 3 & 4: Click through
await page.locator('.nav-btn.next').waitFor({ state: 'visible' });
await page.locator('.nav-btn.next').click();
await page.locator('.nav-btn.next').waitFor({ state: 'visible' });
await page.locator('.nav-btn.next').click();
// Step 5: ReviewLaunch
await page.locator('.review-launch').waitFor({ state: 'visible' });
}
Key Timing Considerations
| Step | Gotcha | Solution |
|---|---|---|
| Step 2 (Identity) | Handle availability check has a 500ms debounce | waitForTimeout(1000) + toBeEnabled() assertion |
| Step 2 โ 3 | "Continue" button is disabled until handleAvailable !== false |
Mock checkHandleAvailability to return true |
| All steps | Svelte transitions cause brief DOM detachment | Use waitFor({ state: 'visible' }) before clicking |
Test File Structure
All E2E tests live in the e2e/ directory:
e2e/
โโโ malet-creation.test.ts # Malet Creation Wizard (3 tests)
โโโ ... (future test suites)
Current Test Coverage
| Test | What It Validates |
|---|---|
| Standard creation flow | Full 5-step wizard navigation; "pending verification" note on ReviewLaunch |
| Organizational context | currentOrgId in localStorage triggers "assigned under your active organization" messaging |
| Tier-limit error | GraphQL FORBIDDEN error renders user-friendly message instead of raw JSON |
Writing New Tests
Template
import { test, expect } from '@playwright/test';
test.describe('Feature Name', () => {
test('scenario description', async ({ page }) => {
// 1. Mock auth (required for all authenticated routes)
await mockAuth(page);
// 2. Navigate
await page.goto('/your-route');
await page.waitForLoadState('networkidle');
// 3. Interact & Assert
await expect(page.locator('.some-element')).toBeVisible();
});
});
Checklist for New E2E Tests
- Call
mockAuth()beforepage.goto() - Cookie consent is auto-dismissed via
mockAuth() - Mock any GraphQL queries/mutations the page depends on
- Use
waitFor({ state: 'visible' })before interacting with elements - Set unique identifiers (handles, names) using
Date.now()to avoid collisions - Keep timeouts reasonable โ prefer
toBeEnabled()overwaitForTimeout()
WARNING
Node.js 18+ is required for Playwright. If you see SyntaxError: Unexpected token, ensure nvm use 20 (or equivalent) is active.
Troubleshooting
`page.goto: net::ERR_CONNECTION_REFUSED`
The dev server isn't running or crashed. Check that npm run dev -- --port 4173 is active. If a previous test timed out, the server may have been killed.
`element was detached from the DOM`
Svelte transitions (fade/slide) can temporarily detach and re-attach elements. Add waitFor({ state: 'visible' }) before interacting.
`cookie-banner intercepted pointer events`
The cookie consent banner overlay is blocking clicks. Ensure mockAuth() is seeding the mallnline_cookie_consent localStorage key.
`locator.click: timeout exceeded` on navigation buttons
The button is likely disabled due to validation. Check that the requisite data is filled (e.g., name โฅ 2 chars, handle marked available) and that the corresponding GraphQL mock is returning the right data.
Related
- Onboarding & Malet Creation Architecture โ Component and state management details for the wizard
- Mall Sections & Taxonomy โ Vertical organization and tag system
- Privacy & Security โ Cookie consent architecture
- Notifications โ Alert delivery infrastructure tested via E2E
- Local Development Environment โ Podman setup and Make targets for running the backend stack
- Frontend Debugging Playbook โ Manual console-log debugging methodology when E2E tests can't reproduce an issue
- CI/CD Pipeline & Production Infrastructure โ GitHub Actions pipeline architecture and runner hosting strategy