We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/tbrennem-source/sf-permits-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server
"""Playwright E2E tests for onboarding flows and content pages.
QS8-T3-D: Covers the welcome/onboarding journey, demo page, beta-request form,
methodology, about-data, and portfolio empty state for new users.
Run (standalone — recommended):
pytest tests/e2e/test_onboarding_scenarios.py -v
NOTE: Playwright tests are skipped in the full test suite to avoid asyncio
event loop conflicts. Run them standalone or set E2E_PLAYWRIGHT=1.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
import pytest
# Ensure project root importable
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))))
# ---------------------------------------------------------------------------
# Skip guard — same pattern as other E2E test files
# ---------------------------------------------------------------------------
_playwright_targeted = any(
"test_onboarding" in arg or "e2e" == os.path.basename(arg.rstrip("/"))
for arg in sys.argv
)
_playwright_enabled = (
os.environ.get("E2E_PLAYWRIGHT", "")
or os.environ.get("E2E_BASE_URL", "")
or _playwright_targeted
)
pytestmark = pytest.mark.skipif(
not _playwright_enabled,
reason="Playwright tests skipped in full suite (asyncio conflict). "
"Run: pytest tests/e2e/test_onboarding_scenarios.py -v",
)
SCREENSHOT_DIR = Path("qa-results/screenshots/e2e")
def _screenshot(page, name: str) -> None:
"""Capture a screenshot (best-effort, never fails the test)."""
try:
SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
page.screenshot(path=str(SCREENSHOT_DIR / f"{name}.png"))
except Exception:
pass
# ===========================================================================
# Test D-1-1: Welcome page renders for new user
# SCENARIO: New user onboarding welcome flow
# ===========================================================================
class TestWelcomePage:
"""Welcome/onboarding page renders correctly for authenticated users."""
def test_welcome_page_renders_for_new_user(self, auth_page):
"""SCENARIO: Welcome page loads for an authenticated user who needs onboarding.
Navigates to /welcome and confirms the page renders without redirect
to login. The page should contain onboarding guidance content.
"""
pg = auth_page("homeowner")
resp = pg.goto(f"{pg._base_url}/welcome")
pg.wait_for_load_state("networkidle")
_screenshot(pg, "welcome-new-user")
assert resp is not None
# Must not be redirected to login
url = pg.url
assert "/auth/login" not in url and "/login" not in url, (
"Authenticated user should not be redirected to login on /welcome"
)
# Should return 200 or follow a redirect to a meaningful page
assert resp.status in (200, 302, 301), (
f"Welcome page returned unexpected status {resp.status}"
)
if resp.status == 200 or pg.url.endswith("/welcome"):
body = pg.text_content("body") or ""
lower = body.lower()
# Page should have onboarding content
assert any(kw in lower for kw in [
"welcome", "onboard", "get started", "address", "search",
"permit", "watch", "step",
]), "Welcome page should contain onboarding guidance"
def test_onboarding_dismissible(self, auth_page):
"""SCENARIO: Onboarding page allows users to proceed to the main app.
After landing on /welcome the user should be able to navigate away
to other parts of the app (e.g. /) without being forced to stay.
There should be a skip/dismiss link or the welcome content should
include navigation to main features.
"""
pg = auth_page("homeowner")
pg.goto(f"{pg._base_url}/welcome")
pg.wait_for_load_state("networkidle")
_screenshot(pg, "welcome-dismissible")
# Navigate away — confirm no infinite redirect loop
resp = pg.goto(f"{pg._base_url}/")
_screenshot(pg, "welcome-post-dismiss")
assert resp is not None
assert resp.status in (200, 302, 301), (
"After visiting /welcome, navigating to / should succeed"
)
# Should end up on landing page (not loop back to welcome)
url = pg.url
assert "/welcome" not in url or resp.status == 200, (
"Navigating away from /welcome should not force redirect back"
)
# ===========================================================================
# Test D-1-2: Demo page loads without auth
# SCENARIO: Anonymous demo page access
# ===========================================================================
class TestDemoPageAnonymous:
"""Demo page is publicly accessible and shows property intelligence."""
def test_demo_page_loads_without_auth(self, page, live_server):
"""SCENARIO: Demo page renders for anonymous users with 200 OK.
The /demo route pre-loads 1455 Market St permit data for Zoom demos.
No auth required — this is a public preview page.
"""
resp = page.goto(f"{live_server}/demo")
page.wait_for_load_state("networkidle")
_screenshot(page, "demo-anon-load")
assert resp is not None
assert resp.status == 200, (
f"Demo page should return 200 for anonymous users, got {resp.status}"
)
body = page.text_content("body") or ""
assert len(body.strip()) > 50, "Demo page should render content, not blank page"
def test_demo_page_shows_property_data(self, page, live_server):
"""SCENARIO: Demo page displays permit/property intelligence data.
The demo should show at least one of: permit data, property address,
neighborhood, or timeline data — confirming the pre-loaded dataset
is being rendered.
"""
page.goto(f"{live_server}/demo")
page.wait_for_load_state("networkidle")
_screenshot(page, "demo-property-data")
body = page.text_content("body") or ""
lower = body.lower()
assert any(kw in lower for kw in [
"permit", "market", "1455", "property", "block", "parcel",
"timeline", "neighborhood", "demo",
]), "Demo page should display property intelligence data"
def test_demo_page_has_structured_content(self, page, live_server):
"""SCENARIO: Demo page renders structured HTML sections (headings).
The demo should have headings or sections — not a flat blob of text.
Confirms the template is rendering correctly.
"""
page.goto(f"{live_server}/demo")
page.wait_for_load_state("networkidle")
_screenshot(page, "demo-structured")
try:
headings = page.locator("h1, h2, h3, h4")
assert headings.count() > 0, "Demo page should have at least one heading"
except Exception:
# Fallback — body has substantial content
body = page.text_content("body") or ""
assert len(body.strip()) > 200, "Demo page should have substantial content"
# ===========================================================================
# Test D-1-3: Methodology page has multiple sections
# SCENARIO: Methodology page completeness
# ===========================================================================
class TestMethodologyPage:
"""Methodology page explains data sources and calculations."""
def test_methodology_page_has_multiple_sections(self, page, live_server):
"""SCENARIO: /methodology page renders with multiple content sections.
This page explains how sfpermits.ai calculates severity, timelines,
entity networks, etc. It must have substantive structured content.
"""
resp = page.goto(f"{live_server}/methodology")
page.wait_for_load_state("networkidle")
_screenshot(page, "methodology-sections")
assert resp is not None
assert resp.status == 200, (
f"Methodology page should return 200, got {resp.status}"
)
body = page.text_content("body") or ""
lower = body.lower()
# Should contain methodology-relevant content
assert any(kw in lower for kw in [
"methodology", "how", "data", "permit", "score", "calculation",
"source", "soda", "dbi", "planning",
]), "Methodology page should contain explanatory content"
# Should have multiple headings (multiple sections)
try:
headings = page.locator("h1, h2, h3")
assert headings.count() >= 2, (
"Methodology page should have at least 2 headings (multiple sections)"
)
except Exception:
# Fallback — check for substantial text
assert len(lower) > 300, "Methodology page should have substantial text content"
def test_methodology_page_no_auth_required(self, page, live_server):
"""Edge case: Methodology page must be accessible without login."""
resp = page.goto(f"{live_server}/methodology")
url = page.url
assert resp is not None
# Must not redirect to login
assert "/auth/login" not in url and "/login" not in url, (
"Methodology page should be publicly accessible, not behind auth"
)
assert resp.status == 200, (
f"Methodology page should return 200 without auth, got {resp.status}"
)
# ===========================================================================
# Test D-1-4: About-data page has dataset inventory
# SCENARIO: About-data completeness check
# ===========================================================================
class TestAboutDataPage:
"""About-data page lists data sources and pipeline information."""
def test_about_data_page_has_dataset_inventory(self, page, live_server):
"""SCENARIO: /about-data page renders with dataset inventory.
This page catalogs the 22 SODA datasets and the full pipeline.
Must contain references to data sources and pipeline steps.
"""
resp = page.goto(f"{live_server}/about-data")
page.wait_for_load_state("networkidle")
_screenshot(page, "about-data-inventory")
assert resp is not None
assert resp.status == 200, (
f"About-data page should return 200, got {resp.status}"
)
body = page.text_content("body") or ""
lower = body.lower()
# Must reference datasets, pipeline, or SF data sources
assert any(kw in lower for kw in [
"dataset", "data", "soda", "pipeline", "permit", "source",
"api", "dbi", "planning", "record",
]), "About-data page should contain dataset inventory content"
def test_about_data_no_auth_required(self, page, live_server):
"""Edge case: About-data page must be public."""
resp = page.goto(f"{live_server}/about-data")
url = page.url
assert resp is not None
assert "/auth/login" not in url and "/login" not in url, (
"About-data page should be public, not behind auth"
)
assert resp.status == 200, (
f"About-data page returned {resp.status}"
)
# ===========================================================================
# Test D-1-5: Beta request form submits
# SCENARIO: Organic signup form
# ===========================================================================
class TestBetaRequestForm:
"""Beta request form renders and accepts submissions."""
def test_beta_request_form_renders(self, page, live_server):
"""SCENARIO: /beta-request form renders with email and reason fields.
The form is the organic signup path for users without invite codes.
Must render with required inputs.
"""
resp = page.goto(f"{live_server}/beta-request")
page.wait_for_load_state("networkidle")
_screenshot(page, "beta-request-form")
assert resp is not None
assert resp.status == 200, (
f"Beta-request form returned {resp.status}"
)
# Form must have an email input
email_input = page.locator('input[name="email"], input[type="email"]')
assert email_input.count() > 0, "Beta request form should have an email input"
def test_beta_request_form_submits(self, page, live_server):
"""SCENARIO: Beta request form accepts a valid submission.
Fills out the form with valid data and submits. Should receive either
a success confirmation or a 200 response (not a 500 or raw error).
The form uses honeypot spam protection so the server should always
respond gracefully to well-formed requests.
"""
page.goto(f"{live_server}/beta-request")
page.wait_for_load_state("networkidle")
# Fill the form
try:
email_input = page.locator('input[name="email"], input[type="email"]').first
email_input.fill("e2e-test-user@example.com")
reason_input = page.locator('textarea[name="reason"], input[name="reason"]').first
if reason_input.count() > 0:
reason_input.fill("I want to track building permits for my project.")
name_input = page.locator('input[name="name"]')
if name_input.count() > 0:
name_input.first.fill("E2E Test User")
# Submit the form
submit = page.locator('button[type="submit"], input[type="submit"]').first
submit.click()
page.wait_for_load_state("networkidle")
except Exception:
# If form filling fails (e.g. input not found), skip rather than fail
pytest.skip("Beta request form inputs not found — skipping submission test")
_screenshot(page, "beta-request-submitted")
# Should not return a 500 error
body = page.text_content("body") or ""
lower = body.lower()
assert "traceback" not in lower and "internal server error" not in lower, (
"Beta request form submission should not produce a server error"
)
def test_beta_request_invalid_email_rejected(self, page, live_server):
"""Edge case: Beta request with invalid email returns 400, not 500."""
resp = page.request.post(
f"{live_server}/beta-request",
form={"email": "not-an-email", "reason": "testing"},
)
_screenshot(page, "beta-request-invalid-email")
# Should return 400 (validation error), not 500 (server crash)
assert resp.status in (200, 400, 422), (
f"Invalid email submission should return 400/422, not {resp.status}"
)
# ===========================================================================
# Test D-1-6: Portfolio empty state for new user
# SCENARIO: Portfolio empty state
# ===========================================================================
class TestPortfolioEmptyState:
"""Portfolio page shows empty state for users with no watch items."""
def test_portfolio_empty_state_for_new_user(self, auth_page):
"""SCENARIO: New user sees an empty portfolio state (not an error).
A user who has never added watch items should see a helpful empty
state — not a blank page, crash, or unformatted data.
"""
# Use a persona unlikely to have existing watch items
pg = auth_page("homeowner")
resp = pg.goto(f"{pg._base_url}/portfolio")
pg.wait_for_load_state("networkidle")
_screenshot(pg, "portfolio-empty-state")
assert resp is not None
# Must be on portfolio page (not redirected to login)
url = pg.url
assert "/auth/login" not in url and "/login" not in url, (
"Authenticated user should access portfolio, not be redirected to login"
)
# Page should return 200
assert resp.status in (200, 302, 301), (
f"Portfolio page returned unexpected status {resp.status}"
)
if resp.status == 200:
body = pg.text_content("body") or ""
lower = body.lower()
# Should show some form of content — not a blank page
assert len(body.strip()) > 50, "Portfolio page should render content"
# If empty, should have a helpful message; if not empty, just data
assert any(kw in lower for kw in [
"portfolio", "watch", "permit", "project", "add", "track",
"empty", "get started", "no ", "street",
]), "Portfolio page should show portfolio content or empty-state guidance"
def test_portfolio_anonymous_redirect(self, page, live_server):
"""SCENARIO: Unauthenticated users are redirected away from /portfolio."""
resp = page.goto(f"{live_server}/portfolio")
url = page.url
_screenshot(page, "portfolio-anon-redirect")
# Should redirect to login
redirected = "/auth/login" in url or "/login" in url
assert redirected or resp.status in (302, 301), (
"Anonymous user should be redirected away from /portfolio"
)