# Browser Agent Test Pipeline -- Autonomous Test Customer System

## PRD: Autonomous Browser Agent Testing Pipeline
### GENESIS-QA | End-to-End Product Validation

---

### Vision Statement

Deploy autonomous browser agents that act as real test customers, executing complete purchase-to-use journeys across all Sunaiva products, producing structured pass/fail reports with actionable feedback -- running on schedule with zero human intervention.

### Success Metrics

| Metric | Target | Measurement |
|--------|--------|-------------|
| Journey coverage | 100% of critical paths | All 15 defined journeys execute without skip |
| Defect detection rate | Catch 95% of regressions | Compare agent findings vs manual QA |
| Mean time to feedback | < 5 minutes per journey | Timestamp from launch to report |
| False positive rate | < 5% | Manual review of flagged failures |
| Scheduled run uptime | 99% | Cron/CI runs succeed without infra failure |
| Cost per full suite run | < $0.50 | Temp email + compute only (no LLM cost for basic runs) |

---

## Architecture Overview

```
                        BROWSER AGENT TEST PIPELINE
                        ===========================

    +------------------+      +-------------------+      +------------------+
    |  TRIGGER LAYER   |      |  ORCHESTRATOR     |      |  REPORT LAYER    |
    |  - Cron (daily)  |----->|  - pytest runner   |----->|  - JSON results  |
    |  - CI/CD hook    |      |  - parallel exec   |      |  - HTML report   |
    |  - Manual CLI    |      |  - retry logic     |      |  - Slack/email   |
    |  - Git push hook |      |  - timeout mgmt    |      |  - GitHub issues |
    +------------------+      +-------------------+      +------------------+
                                      |
                                      v
    +----------------------------------------------------------------------+
    |                        TEST AGENT LAYER                              |
    |                                                                      |
    |  +------------------+  +------------------+  +------------------+    |
    |  | ACCOUNT FACTORY  |  | JOURNEY EXECUTOR |  | ASSERTION ENGINE |    |
    |  | - Temp email gen |  | - Page navigation|  | - DOM assertions |    |
    |  | - Form filling   |  | - Form submit    |  | - API response   |    |
    |  | - JWT storage    |  | - File upload    |  | - State machine  |    |
    |  | - Cleanup hooks  |  | - Voice widget   |  | - Screenshot diff|    |
    |  +------------------+  +------------------+  +------------------+    |
    |                              |                                        |
    |                              v                                        |
    |  +----------------------------------------------------------------------+
    |  |                    PRODUCT TARGETS                                    |
    |  |                                                                      |
    |  |  +-------------------------+  +-----------------------------------+  |
    |  |  | SUNAIVA AI MEMORY       |  | SUNAIVA TALKING WIDGET            |  |
    |  |  | - Landing page          |  | - Landing page (Netlify)          |  |
    |  |  | - Signup/login          |  | - Signup/login                    |  |
    |  |  | - Upload chat export    |  | - Onboarding pipeline             |  |
    |  |  | - Vault dashboard       |  | - Widget embed + interaction      |  |
    |  |  | - MCP config download   |  | - Voice call (WebRTC)             |  |
    |  |  | - Query knowledge       |  | - Lead capture verification       |  |
    |  |  | - Billing/Stripe        |  | - Dashboard analytics             |  |
    |  |  +-------------------------+  | - Billing/Stripe                  |  |
    |  |                               +-----------------------------------+  |
    |  +----------------------------------------------------------------------+
    +----------------------------------------------------------------------+
                                      |
                                      v
    +----------------------------------------------------------------------+
    |                        FEEDBACK LOOP                                 |
    |                                                                      |
    |  Test Failure --> Screenshot + DOM snapshot + Network HAR             |
    |       |                                                              |
    |       +--> Auto-create GitHub Issue (label: browser-agent-qa)        |
    |       +--> Slack notification (#genesis-qa channel)                  |
    |       +--> PostgreSQL test_runs table (historical tracking)          |
    +----------------------------------------------------------------------+
```

---

## File Structure

```
tests/
  browser_agents/
    conftest.py                     # Shared fixtures, browser setup, WSL2 workaround
    config.py                       # URLs, credentials, timeouts, feature flags
    account_factory.py              # Disposable test account creation/cleanup
    assertion_helpers.py            # Custom assertion utilities
    screenshot_differ.py            # Visual regression comparison
    report_generator.py             # JSON + HTML report builder
    feedback_loop.py                # GitHub issue creation, Slack notifications
    run_pipeline.py                 # CLI entrypoint (manual + cron)
    requirements.txt                # Dependencies
    pytest.ini                      # Pytest configuration

    fixtures/
      claude_export_sample.json     # Sample Claude chat export (10 conversations)
      chatgpt_export_sample.json    # Sample ChatGPT export
      gemini_export_sample.json     # Sample Gemini export
      generic_csv_sample.csv        # Generic CSV chat log
      large_export_stress.json      # 500-conversation stress test file

    journeys/
      __init__.py
      test_j01_memory_landing.py          # AI Memory landing page validation
      test_j02_memory_signup.py           # Signup with temp email
      test_j03_memory_upload.py           # Upload chat export + processing
      test_j04_memory_vault.py            # Vault dashboard verification
      test_j05_memory_mcp.py              # MCP config generation + download
      test_j06_memory_billing.py          # Stripe checkout flow (test mode)
      test_j07_widget_landing.py          # Talking Widget landing page
      test_j08_widget_signup.py           # Widget signup + onboarding
      test_j09_widget_embed.py            # Widget embed code + rendering
      test_j10_widget_voice.py            # Voice interaction (WebRTC)
      test_j11_widget_leads.py            # Lead capture pipeline
      test_j12_widget_dashboard.py        # Analytics dashboard
      test_j13_widget_billing.py          # Widget Stripe checkout
      test_j14_cross_product.py           # Cross-product journey (memory + widget)
      test_j15_adversarial.py             # Edge cases, abuse patterns, error recovery

    baselines/
      screenshots/                  # Golden screenshots for visual comparison
      har/                          # Baseline HAR files for network comparison
```

---

## Component Specifications

### 1. Account Factory (`account_factory.py`)

Creates disposable test accounts using temporary email services. No real customer data is ever created in production databases.

```python
"""
Account Factory -- Disposable test customer creation for browser agent testing.

Strategy:
  1. PRIMARY: Guerrilla Mail API (free, no signup, programmatic)
  2. FALLBACK: Mailinator public inboxes (free, no API key needed)
  3. LAST RESORT: Internal test domain (test+{uuid}@sunaiva-test.com)

All accounts are tagged with 'browser_agent_test' metadata for easy cleanup.
"""

import uuid
import time
import httpx
from dataclasses import dataclass, field
from typing import Optional


@dataclass
class TestAccount:
    """Represents a disposable test customer account."""
    email: str
    password: str
    display_name: str
    jwt_token: Optional[str] = None
    user_id: Optional[str] = None
    created_at: float = field(default_factory=time.time)
    provider: str = "guerrillamail"  # guerrillamail | mailinator | internal

    @property
    def is_authenticated(self) -> bool:
        return self.jwt_token is not None


class AccountFactory:
    """Creates and manages disposable test accounts."""

    GUERRILLA_API = "https://api.guerrillamail.com/ajax.php"
    MAILINATOR_DOMAIN = "mailinator.com"
    INTERNAL_DOMAIN = "test.sunaiva-internal.dev"

    def __init__(self, api_base_url: str, prefer_provider: str = "guerrillamail"):
        self.api_base_url = api_base_url
        self.prefer_provider = prefer_provider
        self._accounts: list[TestAccount] = []
        self._guerrilla_sid: Optional[str] = None

    async def create_account(self, prefix: str = "testbot") -> TestAccount:
        """Create a new disposable test account."""
        email = await self._get_temp_email(prefix)
        password = f"TestPass_{uuid.uuid4().hex[:12]}!1"
        display_name = f"Test Bot {uuid.uuid4().hex[:6]}"

        account = TestAccount(
            email=email,
            password=password,
            display_name=display_name,
        )
        self._accounts.append(account)
        return account

    async def signup_account(self, account: TestAccount, product: str = "memory") -> TestAccount:
        """Register the account via the product's signup API."""
        if product == "memory":
            endpoint = f"{self.api_base_url}/api/auth/signup"
            payload = {
                "email": account.email,
                "password": account.password,
                "name": account.display_name,
            }
        elif product == "widget":
            endpoint = f"{self.api_base_url}/auth/signup"
            payload = {
                "email": account.email,
                "password": account.password,
                "business_name": f"{account.display_name} Plumbing",
            }
        else:
            raise ValueError(f"Unknown product: {product}")

        async with httpx.AsyncClient() as client:
            resp = await client.post(endpoint, json=payload, timeout=15)
            resp.raise_for_status()
            data = resp.json()
            account.jwt_token = data.get("token") or data.get("access_token")
            account.user_id = data.get("user_id") or data.get("id")

        return account

    async def login_account(self, account: TestAccount, product: str = "memory") -> TestAccount:
        """Authenticate an existing account and refresh the JWT."""
        if product == "memory":
            endpoint = f"{self.api_base_url}/api/auth/login"
        else:
            endpoint = f"{self.api_base_url}/auth/login"

        async with httpx.AsyncClient() as client:
            resp = await client.post(endpoint, json={
                "email": account.email,
                "password": account.password,
            }, timeout=15)
            resp.raise_for_status()
            data = resp.json()
            account.jwt_token = data.get("token") or data.get("access_token")

        return account

    async def cleanup_all(self):
        """Delete all test accounts from the database."""
        for account in self._accounts:
            try:
                await self._delete_account(account)
            except Exception:
                pass  # Best effort cleanup
        self._accounts.clear()

    async def _get_temp_email(self, prefix: str) -> str:
        """Get a temporary email address."""
        if self.prefer_provider == "guerrillamail":
            return await self._guerrilla_get_email(prefix)
        elif self.prefer_provider == "mailinator":
            return self._mailinator_get_email(prefix)
        else:
            return self._internal_get_email(prefix)

    async def _guerrilla_get_email(self, prefix: str) -> str:
        """Get a Guerrilla Mail temp address (free, no signup)."""
        async with httpx.AsyncClient() as client:
            resp = await client.get(
                self.GUERRILLA_API,
                params={"f": "get_email_address", "lang": "en"},
                timeout=10,
            )
            data = resp.json()
            self._guerrilla_sid = data.get("sid_token")
            return data["email_addr"]

    def _mailinator_get_email(self, prefix: str) -> str:
        """Generate a Mailinator-style public inbox address."""
        unique = uuid.uuid4().hex[:8]
        return f"{prefix}-{unique}@{self.MAILINATOR_DOMAIN}"

    def _internal_get_email(self, prefix: str) -> str:
        """Generate an internal test domain email (requires DNS setup)."""
        unique = uuid.uuid4().hex[:8]
        return f"{prefix}+{unique}@{self.INTERNAL_DOMAIN}"

    async def check_inbox(self, account: TestAccount, subject_contains: str = "",
                          timeout: int = 30) -> Optional[dict]:
        """Poll temp email inbox for a specific message (e.g., verification email)."""
        if account.provider == "guerrillamail" and self._guerrilla_sid:
            return await self._guerrilla_check_inbox(subject_contains, timeout)
        return None  # Mailinator/internal don't support inbox checking without API key

    async def _guerrilla_check_inbox(self, subject_contains: str, timeout: int) -> Optional[dict]:
        """Poll Guerrilla Mail inbox."""
        deadline = time.time() + timeout
        async with httpx.AsyncClient() as client:
            while time.time() < deadline:
                resp = await client.get(
                    self.GUERRILLA_API,
                    params={"f": "check_email", "sid_token": self._guerrilla_sid, "seq": "0"},
                    timeout=10,
                )
                data = resp.json()
                for email in data.get("list", []):
                    if subject_contains.lower() in email.get("mail_subject", "").lower():
                        return email
                await asyncio.sleep(3)
        return None

    async def _delete_account(self, account: TestAccount):
        """Delete a test account from the product database (admin endpoint)."""
        # Uses admin API or direct DB cleanup
        # In TESTING_MODE, accounts with 'browser_agent_test' tag are auto-purged
        pass
```

### 2. Configuration (`config.py`)

```python
"""
Pipeline configuration -- URLs, timeouts, feature flags.
All URLs are configurable via environment variables for staging/production switching.
"""

import os

# ============================================================================
# Product URLs
# ============================================================================

# Sunaiva AI Memory
MEMORY_LANDING_URL = os.environ.get(
    "MEMORY_LANDING_URL", "https://sunaiva-ai-memory.netlify.app"
)
MEMORY_API_URL = os.environ.get(
    "MEMORY_API_URL", "http://localhost:8080"
)

# Sunaiva Talking Widget
WIDGET_LANDING_URL = os.environ.get(
    "WIDGET_LANDING_URL", "https://sunaiva-talking-widget.netlify.app"
)
WIDGET_API_URL = os.environ.get(
    "WIDGET_API_URL", "http://localhost:8080"
)
WIDGET_ONBOARDING_URL = os.environ.get(
    "WIDGET_ONBOARDING_URL", "http://localhost:8081"
)

# ============================================================================
# Test Behavior
# ============================================================================

# Timeouts (seconds)
PAGE_LOAD_TIMEOUT = int(os.environ.get("PAGE_LOAD_TIMEOUT", "15"))
API_RESPONSE_TIMEOUT = int(os.environ.get("API_RESPONSE_TIMEOUT", "30"))
UPLOAD_PROCESSING_TIMEOUT = int(os.environ.get("UPLOAD_PROCESSING_TIMEOUT", "120"))
EMAIL_DELIVERY_TIMEOUT = int(os.environ.get("EMAIL_DELIVERY_TIMEOUT", "60"))
VOICE_CONNECTION_TIMEOUT = int(os.environ.get("VOICE_CONNECTION_TIMEOUT", "20"))

# Retry policy
MAX_RETRIES = int(os.environ.get("MAX_RETRIES", "3"))
RETRY_DELAY_SECONDS = int(os.environ.get("RETRY_DELAY_SECONDS", "5"))

# Feature flags
ENABLE_VOICE_TESTS = os.environ.get("ENABLE_VOICE_TESTS", "false").lower() == "true"
ENABLE_STRIPE_TESTS = os.environ.get("ENABLE_STRIPE_TESTS", "false").lower() == "true"
ENABLE_VISUAL_REGRESSION = os.environ.get("ENABLE_VISUAL_REGRESSION", "false").lower() == "true"
ENABLE_GITHUB_ISSUES = os.environ.get("ENABLE_GITHUB_ISSUES", "false").lower() == "true"
HEADLESS_MODE = os.environ.get("HEADLESS_MODE", "true").lower() == "true"

# Temp email provider: guerrillamail | mailinator | internal
TEMP_EMAIL_PROVIDER = os.environ.get("TEMP_EMAIL_PROVIDER", "guerrillamail")

# ============================================================================
# Stripe Test Mode
# ============================================================================

STRIPE_TEST_CARD = "4242424242424242"
STRIPE_TEST_EXP = "12/28"
STRIPE_TEST_CVC = "123"
STRIPE_TEST_ZIP = "2000"

# ============================================================================
# Reporting
# ============================================================================

REPORT_OUTPUT_DIR = os.environ.get(
    "REPORT_OUTPUT_DIR", "/mnt/e/genesis-system/tests/browser_agents/reports"
)
SCREENSHOT_DIR = os.environ.get(
    "SCREENSHOT_DIR", "/mnt/e/genesis-system/tests/browser_agents/screenshots"
)

# GitHub issue creation
GITHUB_REPO = os.environ.get("GITHUB_REPO", "genesis-system")
GITHUB_OWNER = os.environ.get("GITHUB_OWNER", "")
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "")

# Slack webhook for notifications
SLACK_WEBHOOK_URL = os.environ.get("SLACK_WEBHOOK_URL", "")

# PostgreSQL for test history
TEST_HISTORY_PG_HOST = os.environ.get("TEST_HISTORY_PG_HOST", "152.53.201.152")
TEST_HISTORY_PG_DB = os.environ.get("TEST_HISTORY_PG_DB", "genesis_db")
```

### 3. Shared Fixtures (`conftest.py`)

```python
"""
Shared pytest fixtures for browser agent testing.
Extends the existing E2E conftest with agent-specific capabilities.
"""

import os
import sys
import pytest
import asyncio
from playwright.sync_api import sync_playwright, Browser, BrowserContext, Page

# Add parent path so imports work
sys.path.insert(0, os.path.dirname(__file__))

from config import (
    MEMORY_LANDING_URL, MEMORY_API_URL,
    WIDGET_LANDING_URL, WIDGET_API_URL, WIDGET_ONBOARDING_URL,
    HEADLESS_MODE, PAGE_LOAD_TIMEOUT, SCREENSHOT_DIR,
)
from account_factory import AccountFactory, TestAccount


# ============================================================================
# WSL2 Chromium workaround
# ============================================================================

os.environ['LD_LIBRARY_PATH'] = (
    '/mnt/e/genesis-system/.venvs/playwright/lib/python3.12/'
    'site-packages/playwright/driver/package/.local-browsers/'
    'chromium-1148/chrome-linux/'
)


# ============================================================================
# Browser fixtures
# ============================================================================

@pytest.fixture(scope="session")
def playwright_instance():
    with sync_playwright() as p:
        yield p


@pytest.fixture(scope="session")
def browser(playwright_instance) -> Browser:
    browser = playwright_instance.chromium.launch(
        headless=HEADLESS_MODE,
        args=[
            '--no-sandbox',
            '--disable-setuid-sandbox',
            '--disable-dev-shm-usage',
            '--disable-gpu',
            '--disable-web-security',  # Allow cross-origin for widget embed tests
        ]
    )
    yield browser
    browser.close()


@pytest.fixture(scope="function")
def desktop_context(browser) -> BrowserContext:
    ctx = browser.new_context(
        viewport={'width': 1920, 'height': 1080},
        user_agent=(
            'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
            'AppleWebKit/537.36 (KHTML, like Gecko) '
            'Chrome/120.0.0.0 Safari/537.36 SunaivaTestBot/1.0'
        ),
        locale='en-AU',
        timezone_id='Australia/Sydney',
    )
    yield ctx
    ctx.close()


@pytest.fixture(scope="function")
def mobile_context(browser) -> BrowserContext:
    ctx = browser.new_context(
        viewport={'width': 390, 'height': 844},
        user_agent=(
            'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) '
            'AppleWebKit/605.1.15 SunaivaTestBot/1.0'
        ),
        device_scale_factor=3,
        is_mobile=True,
        has_touch=True,
        locale='en-AU',
        timezone_id='Australia/Sydney',
    )
    yield ctx
    ctx.close()


@pytest.fixture(scope="function")
def page(desktop_context) -> Page:
    page = desktop_context.new_page()
    page.set_default_timeout(PAGE_LOAD_TIMEOUT * 1000)
    yield page
    page.close()


@pytest.fixture(scope="function")
def mobile_page(mobile_context) -> Page:
    page = mobile_context.new_page()
    page.set_default_timeout(PAGE_LOAD_TIMEOUT * 1000)
    yield page
    page.close()


# ============================================================================
# Account fixtures
# ============================================================================

@pytest.fixture(scope="session")
def memory_account_factory():
    factory = AccountFactory(api_base_url=MEMORY_API_URL, prefer_provider="guerrillamail")
    yield factory
    # Cleanup runs after all tests in session
    asyncio.get_event_loop().run_until_complete(factory.cleanup_all())


@pytest.fixture(scope="session")
def widget_account_factory():
    factory = AccountFactory(api_base_url=WIDGET_API_URL, prefer_provider="guerrillamail")
    yield factory
    asyncio.get_event_loop().run_until_complete(factory.cleanup_all())


# ============================================================================
# URL fixtures
# ============================================================================

@pytest.fixture
def memory_landing_url():
    return MEMORY_LANDING_URL

@pytest.fixture
def memory_api_url():
    return MEMORY_API_URL

@pytest.fixture
def widget_landing_url():
    return WIDGET_LANDING_URL

@pytest.fixture
def widget_api_url():
    return WIDGET_API_URL

@pytest.fixture
def widget_onboarding_url():
    return WIDGET_ONBOARDING_URL


# ============================================================================
# Evidence capture (screenshots + HAR on failure)
# ============================================================================

@pytest.fixture(autouse=True)
def capture_evidence_on_failure(request, page):
    """Capture screenshot, HAR trace, and console logs on test failure."""
    console_logs = []
    page.on("console", lambda msg: console_logs.append({
        "type": msg.type,
        "text": msg.text,
        "location": str(msg.location) if msg.location else "",
    }))

    yield

    if hasattr(request.node, "rep_call") and request.node.rep_call.failed:
        test_name = request.node.name
        os.makedirs(SCREENSHOT_DIR, exist_ok=True)

        # Screenshot
        page.screenshot(
            path=os.path.join(SCREENSHOT_DIR, f"{test_name}.png"),
            full_page=True,
        )

        # DOM snapshot
        html = page.content()
        with open(os.path.join(SCREENSHOT_DIR, f"{test_name}.html"), "w") as f:
            f.write(html)

        # Console logs
        import json
        with open(os.path.join(SCREENSHOT_DIR, f"{test_name}_console.json"), "w") as f:
            json.dump(console_logs, f, indent=2)


@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
    outcome = yield
    rep = outcome.get_result()
    setattr(item, f"rep_{rep.when}", rep)


def pytest_configure(config):
    os.makedirs(SCREENSHOT_DIR, exist_ok=True)
```

---

## Test Scenarios -- 15 Customer Journeys

### Journey 1: AI Memory Landing Page Validation (`test_j01_memory_landing.py`)

```python
"""
Journey 1: A prospective customer discovers the Sunaiva AI Memory landing page.
Validates: page loads, all sections render, CTAs work, pricing displays correctly,
responsive design, SEO basics, no console errors.
"""

import pytest
import time
from playwright.sync_api import expect


class TestMemoryLandingDiscovery:
    """Simulate a real customer discovering the AI Memory product."""

    def test_page_loads_under_3_seconds(self, page, memory_landing_url):
        start = time.time()
        response = page.goto(memory_landing_url)
        page.wait_for_load_state("networkidle")
        elapsed = time.time() - start

        assert response.status == 200, f"Landing page returned {response.status}"
        assert elapsed < 3.0, f"Page took {elapsed:.2f}s to load (target: <3s)"

    def test_hero_section_visible_with_value_prop(self, page, memory_landing_url):
        page.goto(memory_landing_url)
        hero = page.locator("section.hero, .hero, [class*='hero']").first
        expect(hero).to_be_visible()

        # Headline should communicate the core value proposition
        h1 = page.locator("h1").first
        expect(h1).to_be_visible()
        headline_text = h1.inner_text().lower()
        assert any(word in headline_text for word in ["memory", "remember", "ai", "know"]), \
            f"Hero headline does not mention core value: '{h1.inner_text()}'"

    def test_pricing_tiers_display_correctly(self, page, memory_landing_url):
        page.goto(memory_landing_url)

        # Scroll to pricing section
        pricing_section = page.locator("[id*='pricing'], [class*='pricing']").first
        if pricing_section.count() > 0:
            pricing_section.scroll_into_view_if_needed()
            expect(pricing_section).to_be_visible()

        # Should show at least 3 pricing tiers (Starter, Pro, Power)
        price_cards = page.locator("[class*='price'], [class*='tier'], [class*='plan']")
        assert price_cards.count() >= 3, f"Expected 3+ pricing tiers, found {price_cards.count()}"

    def test_cta_buttons_are_clickable(self, page, memory_landing_url):
        page.goto(memory_landing_url)

        cta_selectors = [
            "a:has-text('Get Started')",
            "a:has-text('Start')",
            "button:has-text('Get Started')",
            "a:has-text('Sign Up')",
            "a:has-text('Try')",
        ]

        for selector in cta_selectors:
            buttons = page.locator(selector)
            if buttons.count() > 0:
                first_btn = buttons.first
                expect(first_btn).to_be_enabled()
                return  # Found at least one working CTA

        pytest.fail("No CTA buttons found on landing page")

    def test_no_console_errors(self, page, memory_landing_url):
        errors = []
        page.on("console", lambda msg: errors.append(msg.text) if msg.type == "error" else None)
        page.goto(memory_landing_url)
        page.wait_for_load_state("networkidle")

        critical = [e for e in errors if "favicon" not in e.lower()]
        assert len(critical) == 0, f"Console errors: {critical}"

    def test_mobile_responsive(self, mobile_page, memory_landing_url):
        mobile_page.goto(memory_landing_url)
        body_scroll_width = mobile_page.evaluate("() => document.body.scrollWidth")
        viewport_width = mobile_page.viewport_size["width"]
        assert body_scroll_width <= viewport_width + 5, \
            f"Horizontal overflow: body={body_scroll_width}px, viewport={viewport_width}px"
```

### Journey 2: AI Memory Signup (`test_j02_memory_signup.py`)

```python
"""
Journey 2: Customer creates an account on Sunaiva AI Memory.
Validates: form rendering, validation, successful registration, JWT issuance,
error handling for duplicate emails, weak passwords.
"""

import pytest
from playwright.sync_api import expect


class TestMemorySignup:
    """Full signup flow as a new customer."""

    @pytest.fixture
    async def test_account(self, memory_account_factory):
        account = await memory_account_factory.create_account(prefix="j02-signup")
        return account

    def test_signup_form_renders(self, page, memory_landing_url):
        page.goto(memory_landing_url)
        # Navigate to signup (click CTA or direct URL)
        signup_link = page.locator("a:has-text('Sign Up'), a:has-text('Get Started')").first
        if signup_link.count() > 0:
            signup_link.click()
            page.wait_for_load_state("networkidle")

        # Check form fields exist
        email_field = page.locator("input[type='email'], input[name*='email']")
        password_field = page.locator("input[type='password']")
        assert email_field.count() > 0, "No email field found on signup form"
        assert password_field.count() > 0, "No password field found on signup form"

    def test_signup_api_creates_account(self, test_account, memory_api_url):
        """Test signup via API (browser agent backend call)."""
        import httpx
        import asyncio

        async def do_signup():
            account = await test_account
            async with httpx.AsyncClient() as client:
                resp = await client.post(f"{memory_api_url}/api/auth/signup", json={
                    "email": account.email,
                    "password": account.password,
                    "name": account.display_name,
                }, timeout=15)
                assert resp.status_code in (200, 201), f"Signup failed: {resp.status_code} {resp.text}"
                data = resp.json()
                assert "token" in data or "access_token" in data, "No JWT in signup response"
                return data

        asyncio.get_event_loop().run_until_complete(do_signup())

    def test_duplicate_email_rejected(self, test_account, memory_api_url):
        """Attempting to sign up with the same email twice should fail."""
        import httpx
        import asyncio

        async def do_duplicate():
            account = await test_account
            async with httpx.AsyncClient() as client:
                # First signup
                await client.post(f"{memory_api_url}/api/auth/signup", json={
                    "email": account.email,
                    "password": account.password,
                    "name": account.display_name,
                }, timeout=15)

                # Second signup with same email
                resp = await client.post(f"{memory_api_url}/api/auth/signup", json={
                    "email": account.email,
                    "password": "DifferentPass123!",
                    "name": "Another Name",
                }, timeout=15)
                assert resp.status_code in (400, 409, 422), \
                    f"Duplicate email not rejected: {resp.status_code}"

        asyncio.get_event_loop().run_until_complete(do_duplicate())

    def test_weak_password_rejected(self, memory_api_url):
        """Weak passwords should be rejected at signup."""
        import httpx
        import asyncio

        async def do_weak_password():
            async with httpx.AsyncClient() as client:
                resp = await client.post(f"{memory_api_url}/api/auth/signup", json={
                    "email": "weak-password-test@mailinator.com",
                    "password": "123",
                    "name": "Weak Pass Test",
                }, timeout=15)
                assert resp.status_code in (400, 422), \
                    f"Weak password not rejected: {resp.status_code}"

        asyncio.get_event_loop().run_until_complete(do_weak_password())
```

### Journey 3: AI Memory Upload (`test_j03_memory_upload.py`)

```python
"""
Journey 3: Customer uploads a chat export and waits for processing.
Validates: file upload works, processing completes, knowledge extracted,
different formats accepted (Claude, ChatGPT, Gemini, CSV).
"""

import pytest
import os

FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "fixtures")


class TestMemoryUpload:

    def test_upload_claude_export(self, page, memory_api_url):
        """Upload a Claude JSON export and verify processing."""
        fixture_path = os.path.join(FIXTURES_DIR, "claude_export_sample.json")
        # This test would:
        # 1. Authenticate via API (get JWT)
        # 2. Navigate to upload page
        # 3. Use page.set_input_files() to upload the fixture
        # 4. Wait for processing indicator
        # 5. Verify knowledge entities appear in vault
        pass

    def test_upload_chatgpt_export(self, page, memory_api_url):
        """Upload a ChatGPT conversations.json export."""
        pass

    def test_upload_gemini_export(self, page, memory_api_url):
        """Upload a Gemini export."""
        pass

    def test_upload_csv_generic(self, page, memory_api_url):
        """Upload a generic CSV chat log."""
        pass

    def test_upload_oversized_file_rejected(self, page, memory_api_url):
        """Files exceeding the tier limit should be rejected gracefully."""
        pass

    def test_upload_invalid_format_rejected(self, page, memory_api_url):
        """Non-JSON/CSV files should show a clear error message."""
        pass

    def test_upload_progress_indicator(self, page, memory_api_url):
        """A progress bar or spinner should show during processing."""
        pass
```

### Journey 4: Vault Dashboard (`test_j04_memory_vault.py`)

```python
"""
Journey 4: Customer views their vault after uploading conversations.
Validates: dashboard loads, knowledge graph displays, conversation count,
entity list, query interface works.
"""
# Tests would verify:
# - Vault page loads after login
# - Conversation count matches uploads
# - Knowledge entities are listed
# - Search/query returns relevant results
# - Export button generates downloadable file
```

### Journey 5: MCP Config (`test_j05_memory_mcp.py`)

```python
"""
Journey 5: Customer downloads their MCP configuration.
Validates: MCP config generates, contains correct endpoint,
tools are listed, config is valid JSON.
"""
# Tests would verify:
# - MCP config download button exists
# - Downloaded config is valid JSON
# - Config contains correct server endpoint
# - Config lists expected tools (add_memory, search_memory, etc.)
# - Config includes customer's vault/group ID
```

### Journey 6: Memory Billing (`test_j06_memory_billing.py`)

```python
"""
Journey 6: Customer subscribes to a paid plan via Stripe.
Validates: Stripe checkout redirects correctly, test card works,
webhook processes subscription, plan limits update.

NOTE: Only runs when ENABLE_STRIPE_TESTS=true and uses Stripe test mode.
"""

import pytest
from config import ENABLE_STRIPE_TESTS, STRIPE_TEST_CARD, STRIPE_TEST_EXP, STRIPE_TEST_CVC


@pytest.mark.skipif(not ENABLE_STRIPE_TESTS, reason="Stripe tests disabled")
class TestMemoryBilling:

    def test_checkout_redirect_to_stripe(self, page, memory_api_url):
        """Clicking upgrade should redirect to Stripe Checkout."""
        # 1. Login as test user
        # 2. Navigate to billing/upgrade
        # 3. Select "Pro" plan
        # 4. Verify redirect to checkout.stripe.com
        pass

    def test_stripe_test_card_payment(self, page, memory_api_url):
        """Complete payment with Stripe test card 4242...4242."""
        # 1. Reach Stripe checkout page
        # 2. Fill in test card: 4242 4242 4242 4242, exp 12/28, CVC 123
        # 3. Complete payment
        # 4. Verify redirect back to success URL
        # 5. Verify plan upgraded in user profile
        pass

    def test_declined_card_shows_error(self, page, memory_api_url):
        """Declined card (4000000000000002) should show error."""
        pass
```

### Journey 7: Widget Landing Page (`test_j07_widget_landing.py`)

```python
"""
Journey 7: Customer discovers the Talking Widget product page.
Tests the deployed Netlify site: https://sunaiva-talking-widget.netlify.app
"""

class TestWidgetLanding:

    def test_page_loads_from_netlify(self, page):
        response = page.goto("https://sunaiva-talking-widget.netlify.app")
        assert response.status == 200

    def test_hero_headline_communicates_value(self, page):
        page.goto("https://sunaiva-talking-widget.netlify.app")
        h1 = page.locator("h1").first
        headline = h1.inner_text().lower()
        assert any(w in headline for w in ["talk", "voice", "website", "widget"]), \
            f"Hero headline unclear: '{h1.inner_text()}'"

    def test_instant_demo_section_interactive(self, page):
        page.goto("https://sunaiva-talking-widget.netlify.app")
        # The landing page has a "Generate My Demo" section with URL input
        url_input = page.locator("input[placeholder*='website'], input[type='url']").first
        if url_input.count() > 0:
            url_input.fill("https://example-plumbing.com.au")
            generate_btn = page.locator("button:has-text('Generate')").first
            if generate_btn.count() > 0:
                generate_btn.click()
                page.wait_for_timeout(2000)

    def test_three_pricing_tiers_visible(self, page):
        page.goto("https://sunaiva-talking-widget.netlify.app")
        page.locator("[id*='pricing'], [class*='pricing']").first.scroll_into_view_if_needed()
        # Starter $197, Pro $397, Growth $597
        page.wait_for_timeout(500)
        page_text = page.inner_text("body")
        assert "$197" in page_text or "197" in page_text, "Starter price not visible"
        assert "$397" in page_text or "397" in page_text, "Pro price not visible"
        assert "$597" in page_text or "597" in page_text, "Growth price not visible"

    def test_floating_voice_widget_button_present(self, page):
        page.goto("https://sunaiva-talking-widget.netlify.app")
        # The page has a floating voice widget button (bottom-right)
        widget_btn = page.locator(".voice-widget, [class*='floating'], [class*='widget-btn']").first
        if widget_btn.count() > 0:
            expect(widget_btn).to_be_visible()

    def test_faq_section_expandable(self, page):
        page.goto("https://sunaiva-talking-widget.netlify.app")
        faq_items = page.locator("[class*='faq'] [class*='question'], .faq-item")
        if faq_items.count() > 0:
            first_faq = faq_items.first
            first_faq.click()
            page.wait_for_timeout(300)
            # Answer should now be visible
```

### Journey 8: Widget Signup + Onboarding (`test_j08_widget_signup.py`)

```python
"""
Journey 8: Customer signs up and goes through the onboarding pipeline.
Validates: account creation, business details form, website scraping,
AI assistant configuration, Telnyx provisioning, embed code generation.
"""

class TestWidgetOnboarding:

    def test_signup_creates_customer(self, page, widget_api_url):
        """New customer can create an account."""
        pass

    def test_onboarding_captures_business_details(self, page, widget_onboarding_url):
        """Onboarding form captures business name, URL, industry, phone."""
        pass

    def test_website_auto_scraping(self, page, widget_onboarding_url):
        """Pipeline scrapes customer's website and extracts business info."""
        pass

    def test_ai_assistant_generated(self, page, widget_onboarding_url):
        """AI assistant personality, greeting, and FAQ responses generated."""
        pass

    def test_embed_code_provided(self, page, widget_onboarding_url):
        """Customer receives a valid embed code snippet."""
        pass

    def test_installation_email_sent(self, page, widget_onboarding_url):
        """Welcome email with installation guide is dispatched."""
        pass
```

### Journey 9: Widget Embed + Rendering (`test_j09_widget_embed.py`)

```python
"""
Journey 9: Customer embeds the widget on their website (simulated).
Validates: embed code renders widget, widget is interactive,
shadow DOM isolation works, multiple widgets don't conflict.
"""

class TestWidgetEmbed:

    def test_embed_script_loads_widget(self, page):
        """Embedding the script tag on a blank page creates the widget."""
        # Create a minimal HTML page with the widget script
        page.set_content("""
        <!DOCTYPE html>
        <html>
        <body>
            <h1>Test Business Page</h1>
            <script src="https://widget.sunaiva.ai/v1/widget.js"
                    data-widget-id="test-widget-001"
                    data-color="#3B82F6"></script>
        </body>
        </html>
        """)
        page.wait_for_timeout(3000)

        # Widget container should appear
        widget = page.locator("[class*='sunaiva'], [id*='sunaiva']")
        assert widget.count() > 0, "Widget did not render after embedding script"

    def test_widget_shadow_dom_isolation(self, page):
        """Widget styles should not leak into the host page."""
        pass

    def test_widget_click_opens_interface(self, page):
        """Clicking the widget button opens the chat/voice interface."""
        pass
```

### Journey 10: Widget Voice Interaction (`test_j10_widget_voice.py`)

```python
"""
Journey 10: Customer uses the voice widget (WebRTC call).
Validates: microphone permission request, Telnyx WebRTC connection,
call state management, graceful disconnection.

NOTE: Voice tests require special browser permissions and may only work
in headed mode with actual audio devices. Skipped in headless CI by default.
"""

import pytest
from config import ENABLE_VOICE_TESTS


@pytest.mark.skipif(not ENABLE_VOICE_TESTS, reason="Voice tests require headed browser")
class TestWidgetVoice:

    def test_microphone_permission_requested(self, page, widget_landing_url):
        """Clicking voice button should request microphone permission."""
        # Grant microphone permission via browser context
        pass

    def test_telnyx_webrtc_connection_established(self, page, widget_landing_url):
        """WebRTC connection to Telnyx should succeed."""
        pass

    def test_call_state_transitions(self, page, widget_landing_url):
        """Widget UI should reflect: idle -> connecting -> active -> ended."""
        pass

    def test_graceful_call_end(self, page, widget_landing_url):
        """Ending a call should cleanly disconnect and reset UI."""
        pass
```

### Journey 11: Lead Capture Pipeline (`test_j11_widget_leads.py`)

```python
"""
Journey 11: Simulated customer interaction that triggers lead capture.
Validates: name/phone extraction from conversation, lead stored in database,
lead appears in business dashboard.
"""

class TestLeadCapture:

    def test_phone_number_captured_from_chat(self, page, widget_api_url):
        """Sending 'call me on 0412345678' should create a lead."""
        pass

    def test_email_captured_from_chat(self, page, widget_api_url):
        """Sending 'email me at test@example.com' should create a lead."""
        pass

    def test_lead_appears_in_dashboard(self, page, widget_api_url):
        """Captured lead should be visible in the business dashboard."""
        pass

    def test_lead_webhook_fires(self, page, widget_api_url):
        """Lead capture should trigger configured webhook (e.g., to CRM)."""
        pass
```

### Journey 12: Widget Analytics Dashboard (`test_j12_widget_dashboard.py`)

```python
"""
Journey 12: Business owner views analytics after widget interactions.
Validates: conversation count, lead count, response time metrics,
date filtering, CSV export.
"""

class TestWidgetDashboard:

    def test_dashboard_shows_conversation_count(self, page, widget_api_url):
        pass

    def test_analytics_charts_render(self, page, widget_api_url):
        pass

    def test_date_range_filter_works(self, page, widget_api_url):
        pass

    def test_csv_export_downloads(self, page, widget_api_url):
        pass
```

### Journey 13: Widget Billing (`test_j13_widget_billing.py`)

```python
"""
Journey 13: Widget customer subscribes to a paid plan.
Same pattern as Journey 6 but for Widget pricing tiers
(Starter $197, Pro $397, Growth $597).
"""

@pytest.mark.skipif(not ENABLE_STRIPE_TESTS, reason="Stripe tests disabled")
class TestWidgetBilling:

    def test_pricing_page_shows_three_tiers(self, page, widget_landing_url):
        pass

    def test_checkout_flow_starter(self, page, widget_api_url):
        pass

    def test_plan_upgrade_pro_to_growth(self, page, widget_api_url):
        pass

    def test_plan_downgrade(self, page, widget_api_url):
        pass
```

### Journey 14: Cross-Product Journey (`test_j14_cross_product.py`)

```python
"""
Journey 14: Customer uses BOTH AI Memory and Talking Widget.
Validates: single account works across products, memory context
enhances widget responses, unified billing.
"""

class TestCrossProduct:

    def test_single_account_both_products(self, page, memory_api_url, widget_api_url):
        """One Sunaiva account should access both products."""
        pass

    def test_memory_context_available_to_widget(self, page, memory_api_url, widget_api_url):
        """Uploaded memory should enhance widget AI responses."""
        pass
```

### Journey 15: Adversarial / Edge Cases (`test_j15_adversarial.py`)

```python
"""
Journey 15: Adversarial inputs, abuse patterns, error recovery.
Validates: XSS prevention, SQL injection defense, rate limiting,
large payload handling, concurrent session handling, network failure recovery.
"""

class TestAdversarial:

    def test_xss_in_chat_input(self, page, widget_landing_url):
        """Sending <script>alert('xss')</script> should be sanitized."""
        pass

    def test_sql_injection_in_signup(self, page, memory_api_url):
        """SQL injection in email field should be rejected."""
        pass

    def test_rate_limiting_enforced(self, page, widget_api_url):
        """Rapid requests should trigger rate limiting (429)."""
        pass

    def test_oversized_payload(self, page, widget_api_url):
        """Sending a 100MB payload should be rejected, not crash the server."""
        pass

    def test_expired_jwt_handled_gracefully(self, page, memory_api_url):
        """Requests with expired JWT should return 401, not 500."""
        pass

    def test_concurrent_sessions(self, page, memory_api_url):
        """Multiple browser tabs with same account should not conflict."""
        pass

    def test_network_interruption_recovery(self, page, widget_landing_url):
        """Simulated network drop mid-conversation should recover gracefully."""
        pass

    def test_unicode_and_emoji_in_messages(self, page, widget_api_url):
        """Full Unicode range including emoji should be handled correctly."""
        pass
```

---

## Feedback Loop Implementation (`feedback_loop.py`)

```python
"""
Feedback Loop -- Auto-creates GitHub issues and sends notifications
when browser agent tests fail.
"""

import os
import json
import httpx
from datetime import datetime
from config import GITHUB_OWNER, GITHUB_REPO, GITHUB_TOKEN, SLACK_WEBHOOK_URL


async def create_github_issue(
    test_name: str,
    journey: str,
    error_message: str,
    screenshot_path: str = "",
    console_logs: list = None,
):
    """Create a GitHub issue for a failing test."""
    if not GITHUB_TOKEN:
        return None

    title = f"[Browser Agent QA] {journey}: {test_name} FAILED"
    body = f"""## Automated Browser Agent Test Failure

**Journey**: {journey}
**Test**: `{test_name}`
**Timestamp**: {datetime.utcnow().isoformat()}Z
**Environment**: {os.environ.get('TEST_ENVIRONMENT', 'staging')}

### Error
```
{error_message}
```

### Console Logs
```json
{json.dumps(console_logs or [], indent=2)[:2000]}
```

### Evidence
- Screenshot: `{screenshot_path}`
- Full logs in test report directory

---
*Auto-generated by Browser Agent Test Pipeline*
"""

    async with httpx.AsyncClient() as client:
        resp = await client.post(
            f"https://api.github.com/repos/{GITHUB_OWNER}/{GITHUB_REPO}/issues",
            json={
                "title": title,
                "body": body,
                "labels": ["browser-agent-qa", "automated", "bug"],
            },
            headers={
                "Authorization": f"token {GITHUB_TOKEN}",
                "Accept": "application/vnd.github.v3+json",
            },
            timeout=15,
        )
        if resp.status_code == 201:
            return resp.json().get("html_url")
    return None


async def send_slack_notification(
    test_name: str,
    journey: str,
    status: str,
    summary: str,
):
    """Send a Slack notification for test results."""
    if not SLACK_WEBHOOK_URL:
        return

    color = "#10B981" if status == "passed" else "#EF4444"
    emoji = "white_check_mark" if status == "passed" else "x"

    payload = {
        "attachments": [{
            "color": color,
            "blocks": [
                {
                    "type": "section",
                    "text": {
                        "type": "mrkdwn",
                        "text": (
                            f":{emoji}: *Browser Agent QA*: {journey}\n"
                            f"Test: `{test_name}`\n"
                            f"Status: *{status.upper()}*\n"
                            f"Summary: {summary}"
                        ),
                    },
                }
            ],
        }],
    }

    async with httpx.AsyncClient() as client:
        await client.post(SLACK_WEBHOOK_URL, json=payload, timeout=10)
```

---

## Report Generator (`report_generator.py`)

```python
"""
Generates JSON + HTML test reports with historical comparison.
"""

import json
import os
from datetime import datetime
from config import REPORT_OUTPUT_DIR


def generate_report(pytest_results: dict) -> str:
    """Generate a comprehensive test report."""
    os.makedirs(REPORT_OUTPUT_DIR, exist_ok=True)

    timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")

    # JSON report
    json_path = os.path.join(REPORT_OUTPUT_DIR, f"run_{timestamp}.json")
    with open(json_path, "w") as f:
        json.dump({
            "timestamp": timestamp,
            "summary": {
                "total": pytest_results.get("total", 0),
                "passed": pytest_results.get("passed", 0),
                "failed": pytest_results.get("failed", 0),
                "skipped": pytest_results.get("skipped", 0),
                "duration_seconds": pytest_results.get("duration", 0),
            },
            "journeys": pytest_results.get("journeys", {}),
            "failures": pytest_results.get("failures", []),
        }, f, indent=2)

    # HTML report (uses pytest-html plugin)
    html_path = os.path.join(REPORT_OUTPUT_DIR, f"run_{timestamp}.html")

    return json_path
```

---

## Pipeline Runner (`run_pipeline.py`)

```python
#!/usr/bin/env python3
"""
Browser Agent Test Pipeline -- CLI Entrypoint

Usage:
    python run_pipeline.py                    # Run all journeys
    python run_pipeline.py --journey j01      # Run single journey
    python run_pipeline.py --product memory   # Run all memory journeys
    python run_pipeline.py --product widget   # Run all widget journeys
    python run_pipeline.py --fast             # Skip slow tests (voice, billing)
    python run_pipeline.py --headed           # Run with visible browser
"""

import argparse
import subprocess
import sys
import os

TESTS_DIR = os.path.join(os.path.dirname(__file__), "journeys")


def main():
    parser = argparse.ArgumentParser(description="Browser Agent Test Pipeline")
    parser.add_argument("--journey", type=str, help="Run specific journey (e.g., j01, j07)")
    parser.add_argument("--product", choices=["memory", "widget", "all"], default="all")
    parser.add_argument("--fast", action="store_true", help="Skip voice/billing tests")
    parser.add_argument("--headed", action="store_true", help="Run with visible browser")
    parser.add_argument("--parallel", type=int, default=1, help="Parallel workers")
    parser.add_argument("--report", action="store_true", help="Generate HTML report")
    args = parser.parse_args()

    # Build pytest command
    cmd = [
        sys.executable, "-m", "pytest",
        "-v",
        "--tb=short",
        f"--timeout=120",
    ]

    # Journey filter
    if args.journey:
        cmd.append(os.path.join(TESTS_DIR, f"test_{args.journey}*.py"))
    elif args.product == "memory":
        cmd.extend([
            os.path.join(TESTS_DIR, "test_j01_memory_landing.py"),
            os.path.join(TESTS_DIR, "test_j02_memory_signup.py"),
            os.path.join(TESTS_DIR, "test_j03_memory_upload.py"),
            os.path.join(TESTS_DIR, "test_j04_memory_vault.py"),
            os.path.join(TESTS_DIR, "test_j05_memory_mcp.py"),
            os.path.join(TESTS_DIR, "test_j06_memory_billing.py"),
        ])
    elif args.product == "widget":
        cmd.extend([
            os.path.join(TESTS_DIR, "test_j07_widget_landing.py"),
            os.path.join(TESTS_DIR, "test_j08_widget_signup.py"),
            os.path.join(TESTS_DIR, "test_j09_widget_embed.py"),
            os.path.join(TESTS_DIR, "test_j10_widget_voice.py"),
            os.path.join(TESTS_DIR, "test_j11_widget_leads.py"),
            os.path.join(TESTS_DIR, "test_j12_widget_dashboard.py"),
            os.path.join(TESTS_DIR, "test_j13_widget_billing.py"),
        ])
    else:
        cmd.append(TESTS_DIR)

    # Speed flags
    if args.fast:
        cmd.extend(["-k", "not voice and not billing and not stripe"])

    # Headed mode
    if args.headed:
        os.environ["HEADLESS_MODE"] = "false"

    # Parallel execution
    if args.parallel > 1:
        cmd.extend(["-n", str(args.parallel)])

    # HTML report
    if args.report:
        report_path = os.path.join(
            os.path.dirname(__file__), "reports",
            f"report_{__import__('datetime').datetime.now().strftime('%Y%m%d_%H%M%S')}.html"
        )
        os.makedirs(os.path.dirname(report_path), exist_ok=True)
        cmd.extend(["--html", report_path, "--self-contained-html"])

    print(f"Running: {' '.join(cmd)}")
    result = subprocess.run(cmd, cwd=os.path.dirname(__file__))
    sys.exit(result.returncode)


if __name__ == "__main__":
    main()
```

---

## Automation & Scheduling

### Option A: Cron (Simple, Local)

```bash
# Run full suite daily at 6 AM AEST (20:00 UTC previous day)
# Add to crontab: crontab -e
0 20 * * * cd /mnt/e/genesis-system && /mnt/e/genesis-system/.venvs/playwright/bin/python tests/browser_agents/run_pipeline.py --report >> /mnt/e/genesis-system/tests/browser_agents/logs/cron.log 2>&1
```

### Option B: GitHub Actions CI/CD

```yaml
# .github/workflows/browser-agent-qa.yml
name: Browser Agent QA Pipeline

on:
  push:
    paths:
      - 'Sunaiva/**'
      - 'tests/browser_agents/**'
  schedule:
    - cron: '0 20 * * *'  # Daily at 6 AM AEST
  workflow_dispatch:        # Manual trigger

jobs:
  browser-agent-tests:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install dependencies
        run: |
          pip install -r tests/browser_agents/requirements.txt
          playwright install chromium

      - name: Run browser agent pipeline
        env:
          MEMORY_API_URL: ${{ secrets.MEMORY_API_URL }}
          WIDGET_API_URL: ${{ secrets.WIDGET_API_URL }}
          ENABLE_STRIPE_TESTS: 'false'
          ENABLE_VOICE_TESTS: 'false'
          HEADLESS_MODE: 'true'
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          python tests/browser_agents/run_pipeline.py --fast --report

      - name: Upload test report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: browser-agent-report
          path: tests/browser_agents/reports/

      - name: Upload failure screenshots
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: failure-screenshots
          path: tests/browser_agents/screenshots/
```

### Option C: n8n Workflow (Genesis Native)

```json
{
  "name": "Browser Agent QA Scheduler",
  "nodes": [
    {
      "type": "n8n-nodes-base.cron",
      "parameters": {
        "triggerTimes": { "item": [{ "hour": 6 }] }
      }
    },
    {
      "type": "n8n-nodes-base.executeCommand",
      "parameters": {
        "command": "cd /mnt/e/genesis-system && .venvs/playwright/bin/python tests/browser_agents/run_pipeline.py --report"
      }
    },
    {
      "type": "n8n-nodes-base.slack",
      "parameters": {
        "channel": "#genesis-qa",
        "text": "Browser Agent QA completed. Results: {{ $json.stdout }}"
      }
    }
  ]
}
```

---

## Dependencies (`requirements.txt`)

```
# Core
pytest>=8.0.0
pytest-playwright>=0.5.0
playwright>=1.57.0

# Async support
pytest-asyncio>=0.23.0

# Parallel execution
pytest-xdist>=3.5.0

# Timeouts
pytest-timeout>=2.3.0

# HTTP client for API tests
httpx>=0.27.0

# HTML reports
pytest-html>=4.1.0

# Coverage
pytest-cov>=4.1.0

# Temp email API
guerrillamail>=0.2.0

# Image comparison for visual regression
Pillow>=10.0.0
pixelmatch>=0.3.0
```

---

## Pytest Configuration (`pytest.ini`)

```ini
[pytest]
testpaths = journeys
python_files = test_j*.py
python_classes = Test*
python_functions = test_*
timeout = 120
markers =
    slow: marks tests as slow (voice, billing)
    stripe: requires Stripe test mode
    voice: requires microphone/WebRTC
    visual: visual regression comparison
    adversarial: security and edge case tests
addopts = -v --tb=short
```

---

## Cost Analysis

| Component | Cost | Frequency | Monthly Total |
|-----------|------|-----------|---------------|
| Guerrilla Mail API | Free | Unlimited | $0 |
| Mailinator (fallback) | Free (public inboxes) | Unlimited | $0 |
| Playwright/Chromium | Free (open source) | Unlimited | $0 |
| GitHub Actions (CI) | 2,000 free min/mo | ~30 runs x 10 min | $0 (within free tier) |
| Stripe test mode | Free | Unlimited | $0 |
| Telnyx WebRTC (voice tests) | $0.087/min | ~5 min/run x 30 runs | $13.05 |
| PostgreSQL storage (test history) | Already on Elestio | Minimal rows | $0 |
| Compute (local WSL2) | Existing machine | Daily runs | $0 |

**Total estimated cost: $0-13/month** (voice tests are the only variable cost; set `ENABLE_VOICE_TESTS=false` for $0/month).

---

## Implementation Timeline

| Phase | Tasks | Duration | Dependencies |
|-------|-------|----------|--------------|
| **Phase 1: Foundation** | conftest.py, config.py, account_factory.py, fixtures | 2 days | Playwright venv exists |
| **Phase 2: Landing Pages** | J01 (memory landing), J07 (widget landing) | 1 day | Phase 1 |
| **Phase 3: Auth Flows** | J02 (memory signup), J08 (widget signup) | 1 day | Phase 1, APIs running |
| **Phase 4: Core Journeys** | J03 (upload), J04 (vault), J05 (MCP), J09 (embed) | 2 days | Phase 3 |
| **Phase 5: Business Logic** | J11 (leads), J12 (dashboard), J14 (cross-product) | 2 days | Phase 4 |
| **Phase 6: Billing** | J06 (memory billing), J13 (widget billing) | 1 day | Stripe test keys |
| **Phase 7: Voice** | J10 (WebRTC voice) | 1 day | Telnyx configured |
| **Phase 8: Security** | J15 (adversarial) | 1 day | Phase 4 |
| **Phase 9: Feedback Loop** | feedback_loop.py, report_generator.py, GitHub integration | 1 day | Phase 2+ |
| **Phase 10: CI/CD** | GitHub Actions workflow, cron setup, monitoring | 1 day | Phase 9 |

**Total: 13 days** (can be parallelized to ~8 days with 2 agents working simultaneously).

---

## Risk Assessment

| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Temp email services rate-limit or block | Tests cannot create accounts | Medium | 3-provider fallback chain (Guerrilla, Mailinator, internal) |
| APIs not deployed to staging | All API tests fail | High | Feature flags skip API tests; landing page tests still run |
| Playwright chromium binary breaks on WSL2 | All browser tests fail | Low | LD_LIBRARY_PATH workaround already in place; pin chromium version |
| Stripe test mode keys not configured | Billing tests skip | Medium | Feature flag `ENABLE_STRIPE_TESTS` defaults to false |
| WebRTC tests unreliable in headless | Voice tests flaky | High | Skip voice in CI; run headed locally on schedule |
| Test data accumulates in production DB | Data pollution | Medium | All test accounts tagged; nightly cleanup cron |
| Landing page DOM changes break selectors | Tests fail on CSS updates | High | Use semantic selectors (aria-label, data-testid); visual regression as backup |
| CI runner has no access to local services | API tests cannot reach localhost | High | Deploy staging URLs; use docker-compose in CI |

---

## Integration with Existing Tests

The existing tests in `/mnt/e/genesis-system/tests/e2e/` test individual components against localhost.
This pipeline extends coverage to:

1. **Production/staging URLs** -- tests against deployed Netlify sites and hosted APIs
2. **Full customer journeys** -- multi-step flows (signup -> use -> pay) rather than isolated components
3. **Autonomous accounts** -- creates real test accounts with disposable emails
4. **Automated feedback** -- failures auto-create GitHub issues and Slack notifications
5. **Scheduled runs** -- daily CI/CD execution, not just manual pytest invocation

The existing `conftest.py` fixtures (browser, page, mobile_page) are preserved and extended with new fixtures for account management, URL configuration, and evidence capture.

---

## Verification Checklist

```
[ ] All 15 journey files created and importable
[ ] Account factory can create temp email addresses
[ ] conftest.py fixtures work on WSL2 with headless Chromium
[ ] Landing page tests pass against deployed Netlify URLs
[ ] Signup tests create and clean up accounts
[ ] Upload tests use fixture files (no real customer data)
[ ] Feedback loop creates GitHub issues on failure
[ ] run_pipeline.py CLI works with all flags
[ ] GitHub Actions workflow triggers on push and schedule
[ ] Cost stays under $15/month
```

---

*Generated: 2026-02-16*
*Version: 1.0.0*
*Authority: Genesis QA Pipeline*
