Developer Docs

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:

  1. Phase 1 โ€” Store Initialization: The authStore reads localStorage.getItem('ngwenya_auth_user') on module load. If present, it sets isAuthenticated = true, preventing the $effect redirect to /auth.
  2. Phase 2 โ€” Server Validation: The root layout calls initAuth() on mount, which calls checkAuth(), which fetches GET /me. If this fails (no auth server in test env), the store flips isAuthenticated back to false โ†’ 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.


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() before page.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() over waitForTimeout()

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.

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.