agnes-the-ai-analyst/tests/test_web_home_page.py
David Rybar 3167d37a56 feat(web): operator-owned Support callout in welcome hero
New `instance.support` (`AGNES_INSTANCE_SUPPORT` env override)
config field renders operator-authored HTML in a mint-accent
callout panel inside the welcome hero on /home, below the Overview
footnotes. Designed for a one-line invitation pointing at a chat
space, mailing list, or runbook so every user knows where to ask
for help.

- `get_instance_support()` helper mirrors `get_instance_overview()`
  (env > yaml > "" resolution, `| safe` filter trust boundary).
- Wired into the home template context as `config.INSTANCE_SUPPORT`.
- Template renders the callout inside the welcome hero, after the
  Overview footnotes block — empty yaml hides the block so the OSS
  stays vendor-neutral.
- Registered in `_KNOWN_FIELDS["instance"]` so the field appears
  in `/admin/server-config` as "Available but unset" even before
  the operator populates it (discoverability for first-time setup).
- 4 new tests cover the gated render path, the hidden-when-unset
  path, and independence from `instance.overview`.

Operators who want to fill the block via terraform write the body
to `modules/.../assets/support.html` in their infra repo and
include it in the startup.sh yaml heredoc — the OSS template
treats this as one more `| safe`-rendered field, no other plumbing
needed.
2026-05-22 14:04:24 +02:00

459 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""GET /home — state-aware landing page.
The boolean ``users.onboarded`` drives template selection. No
auto-transition: the not-onboarded view stays put until the user reloads
(the brainstorm called this out explicitly — quiet UI is preferable to a
surprise redirect mid-setup).
See origin: docs/brainstorms/home-page-requirements.md.
"""
from __future__ import annotations
import tempfile
import uuid
import pytest
@pytest.fixture
def fresh_db(monkeypatch):
with tempfile.TemporaryDirectory() as tmp:
monkeypatch.setenv("DATA_DIR", tmp)
monkeypatch.setenv("TESTING", "1")
monkeypatch.setenv("JWT_SECRET_KEY", "test-jwt-secret-key-minimum-32-chars!!")
yield tmp
def _make_user_and_session(conn, email="u@example.com", onboarded=False):
from src.repositories.users import UserRepository
from app.auth.jwt import create_access_token
uid = str(uuid.uuid4())
UserRepository(conn).create(id=uid, email=email, name=email.split("@")[0])
if onboarded:
conn.execute("UPDATE users SET onboarded = TRUE WHERE id = ?", [uid])
return uid, create_access_token(user_id=uid, email=email)
def _client(follow_redirects: bool = True):
from fastapi.testclient import TestClient
from app.main import app
return TestClient(app, follow_redirects=follow_redirects)
def test_home_unauth_redirects_to_login(fresh_db):
"""Non-API HTML routes redirect 401→/login per app.main's
StarletteHTTPException handler. /home follows that contract."""
c = _client(follow_redirects=False)
resp = c.get("/home")
assert resp.status_code == 302
assert resp.headers["location"].startswith("/login")
def test_home_not_onboarded_user_sees_setup_view(fresh_db):
"""A FALSE-onboarded user gets the install/setup template, identifiable
by its 'Install Claude Code' heading and the self-mark button."""
from src.db import get_system_db, close_system_db
conn = get_system_db()
try:
_, sess = _make_user_and_session(conn, onboarded=False)
finally:
conn.close()
close_system_db()
c = _client()
resp = c.get("/home", cookies={"access_token": sess})
assert resp.status_code == 200
body = resp.text
assert "install Claude Code" in body # step 1 label
assert "install Agnes" in body # step 2 label
assert "self-mark-btn" in body # self-acknowledged escape hatch
assert "setupClaudeBtn" in body # primary one-click CTA from shared partial
def test_home_onboarded_user_sees_nav_hub(fresh_db):
"""A TRUE-onboarded user gets the post-onboarding view: the blue
install-hero is gone entirely (no welcome banner, no completion
badge, no inline step commands), the offboard escape strip is the
only setup-flow remnant rendered, and the rest of /home (connector
tiles, news, etc.) stays. PR #289 collapsed the dual-state hero
into a single not-onboarded-only render — pre-PR the onboarded
branch reused the same `.install-hero` shell with welcome copy
and a "Steps 14 done" badge."""
from src.db import get_system_db, close_system_db
conn = get_system_db()
try:
_, sess = _make_user_and_session(conn, onboarded=True)
finally:
conn.close()
close_system_db()
c = _client()
resp = c.get("/home", cookies={"access_token": sess})
assert resp.status_code == 200
body = resp.text
# Install hero entirely absent for onboarded users.
assert '<div class="install-hero">' not in body
# Offboard escape strip + its button replace the in-hero self-mark control.
assert '<div class="offboard-strip">' in body
assert "Mark me as offboarded" in body
# All six inline install-blocks are hidden post-onboarding — the
# labels rendered inside the install-block divs go away. Labels
# tracked with the CEO-mock-parity 6-step rename (Step 3 inserted
# as "Open a terminal", Step 6 added as the optional shortcut
# alias; the old Step 3 became Step 4).
assert "Step 1 — Install Claude Code on your computer" not in body
assert "Step 2 — Pick a folder for" not in body
assert "Step 3 — Open a terminal inside that folder" not in body
assert "Step 4 — Launch Claude with auto-approve on" not in body
assert "Step 5 — Get the install script" not in body
assert "Step 6 — Optional: create a one-word shortcut" not in body
def test_connectors_section_removed_from_home(fresh_db):
"""The dedicated `<details data-section="connectors">` block was
dropped from `/home` — the install-hero's Step 4 clipboard payload
(rendered via `_claude_setup_instructions.jinja` inside the manual
fallback) already inlines the same Asana / GWS / Atlassian prompts
from `app/web/connector_prompts.py` via
`app/web/setup_instructions.py::_connectors_block`. Showing them
twice on the same page was duplicate UX. The lead paragraph in the
install-hero now mentions the connectors briefly so users still see
the benefit before they hit the install.
Co-asserts the auto-mode block removal that this test originally
pinned — onboarded users still see neither the connectors block
nor the legacy auto-mode peer section."""
from src.db import get_system_db, close_system_db
conn = get_system_db()
try:
_, sess = _make_user_and_session(conn, onboarded=True)
finally:
conn.close()
close_system_db()
c = _client()
resp = c.get("/home", cookies={"access_token": sess})
assert resp.status_code == 200
body = resp.text
# Auto-mode peer section still gone (legacy guard, not regressed).
assert 'class="automode-card"' not in body
assert 'data-section="step3"' not in body
assert "Step 4 — Launch Claude with auto-approve on" not in body
# Dedicated connectors block is gone from /home in BOTH states.
assert 'class="connector-tiles"' not in body
assert 'data-section="connectors"' not in body
# Server-rendered HTML never carries the data-setup-minimized
# attribute on the .home-mock root — that's a client-side
# localStorage decision applied via JS on load.
assert '<div class="home-mock" data-setup-minimized' not in body
assert 'class="home-mock"\n' in body or '<div class="home-mock">' in body
# Not-onboarded path: same — the section disappears regardless of
# state. Lead-paragraph still surfaces the connector names so users
# know the benefit exists before they kick off the install.
conn = get_system_db()
try:
_, sess2 = _make_user_and_session(
conn, email="not-onboarded@example.com", onboarded=False
)
finally:
conn.close()
close_system_db()
body2 = _client().get("/home", cookies={"access_token": sess2}).text
assert 'class="connector-tiles"' not in body2
assert 'data-section="connectors"' not in body2
# Lead-paragraph mentions the three connector families so the
# benefit isn't lost when the dedicated section disappears.
assert "Asana, Google Workspace, Atlassian" in body2
def test_minimize_toggle_no_longer_rendered(fresh_db):
"""The "Minimize setup view" toggle used to live inside the
onboarded-branch of the install-hero. PR #289 hides the hero
entirely once `users.onboarded=true`, so the minimize toggle
has no rendering site anymore — verify the markup is absent
from both states. (The localStorage `agnes_home_setup_minimized`
flag and its applyMinimize() JS handler stay in the page so a
stale flag from a pre-PR session no-ops cleanly.)"""
from src.db import get_system_db, close_system_db
for onboarded in (False, True):
conn = get_system_db()
try:
_, sess = _make_user_and_session(
conn, email=f"user-{onboarded}@example.com", onboarded=onboarded
)
finally:
conn.close()
close_system_db()
c = _client()
resp = c.get("/home", cookies={"access_token": sess})
assert resp.status_code == 200
assert '<button id="setupMinimizeToggle"' not in resp.text
assert 'class="setup-minimize"' not in resp.text
def test_home_no_auto_transition_after_post_until_reload(fresh_db):
"""POST /api/me/onboarded flips the flag in the DB but the in-flight
/home response from before the POST keeps showing the setup view —
the next GET /home picks up the new state. Verifies the manual-reload
contract called out in the brainstorm."""
from src.db import get_system_db, close_system_db
conn = get_system_db()
try:
_, sess = _make_user_and_session(conn, onboarded=False)
finally:
conn.close()
close_system_db()
c = _client()
pre = c.get("/home", cookies={"access_token": sess})
# `class="install-block"` is the not-onboarded-only structural element
# holding the inline Step-1 install pane. Use it as the discriminator
# instead of a free-form string like "install Claude Code", which now
# also appears in the always-on SETUP_INSTRUCTIONS_TEMPLATE clipboard
# payload's preflight comment after the 2026-05-10 init-report fix.
assert 'class="install-block"' in pre.text # setup view
flip = c.post("/api/me/onboarded", cookies={"access_token": sess})
assert flip.status_code == 200
post = c.get("/home", cookies={"access_token": sess})
# PR #289: hero disappears entirely; offboard strip is the
# only setup-flow remnant. Use either as the nav-hub view marker.
assert '<div class="offboard-strip">' in post.text
assert 'class="install-block"' not in post.text
# ── GWS Email-admin button render tests (admin_email knob coverage) ────────
def test_home_hides_email_admin_button_when_admin_email_unset(fresh_db, monkeypatch):
"""When ``instance.admin_email`` is unset, the GWS connector tile
must NOT render the mailto link (template guards on truthiness;
empty resolver value cleanly hides). Defends against a `mailto:?`
link sneaking out as a render-time artifact."""
monkeypatch.delenv("AGNES_INSTANCE_ADMIN_EMAIL", raising=False)
monkeypatch.delenv("AGNES_GWS_CLIENT_ID", raising=False)
monkeypatch.delenv("AGNES_GWS_CLIENT_SECRET", raising=False)
from src.db import get_system_db, close_system_db
conn = get_system_db()
try:
_, sess = _make_user_and_session(conn)
finally:
conn.close()
close_system_db()
body = _client().get("/home", cookies={"access_token": sess}).text
# No "Email admin" CTA, no mailto: link in the body.
assert "Email admin" not in body
assert "mailto:?" not in body # specifically, no broken empty mailto
def test_home_no_longer_shows_email_admin_button(fresh_db, monkeypatch):
"""The Email-admin mailto CTA used to live inside the /home GWS
connector tile. With the dedicated `<details data-section="connectors">`
block removed (see test_connectors_section_removed_from_home above),
the button has no rendering site even when admin_email is set + GWS
is unconfigured. The escalation path lives inside the install
script's GWS step now — Claude prompts the user with the admin
email when the connector setup hits an OAuth gating wall, so the
affordance moves to the surface where it's actually useful."""
monkeypatch.setenv("AGNES_INSTANCE_ADMIN_EMAIL", "ops@example.com")
monkeypatch.delenv("AGNES_GWS_CLIENT_ID", raising=False)
monkeypatch.delenv("AGNES_GWS_CLIENT_SECRET", raising=False)
from src.db import get_system_db, close_system_db
conn = get_system_db()
try:
_, sess = _make_user_and_session(conn)
finally:
conn.close()
close_system_db()
body = _client().get("/home", cookies={"access_token": sess}).text
assert "Email admin" not in body
assert 'mailto:ops@example.com' not in body
def test_home_hides_email_admin_button_when_gws_configured(fresh_db, monkeypatch):
"""Even with admin_email set, when GWS OAuth is operator-provisioned
(gws_oauth.configured = True), the Email-admin CTA is redundant —
user can just connect. Template gates on `not gws_oauth.configured`."""
monkeypatch.setenv("AGNES_INSTANCE_ADMIN_EMAIL", "ops@example.com")
monkeypatch.setenv("AGNES_GWS_CLIENT_ID", "abc.apps.googleusercontent.com")
monkeypatch.setenv("AGNES_GWS_CLIENT_SECRET", "GOCSPX-secret")
from src.db import get_system_db, close_system_db
conn = get_system_db()
try:
_, sess = _make_user_and_session(conn)
finally:
conn.close()
close_system_db()
body = _client().get("/home", cookies={"access_token": sess}).text
assert "Email admin" not in body
# `test_home_renders_connector_prompts_from_shared_module` was dropped here
# alongside the removal of the /home `<details data-section="connectors">`
# block. The test pinned source-of-truth parity between the home tile
# `<code id="*-prompt">` blocks and `app/web/connector_prompts.py`. With the
# tiles gone, the only surface left for those strings is the install-hero's
# Step 4 clipboard payload (rendered via `_claude_setup_instructions.jinja`
# from `setup_instructions_lines`, which is built in
# `app/web/setup_instructions.py::_connectors_block` calling the same
# `connector_prompts.py` functions). One surface, no drift risk → the
# parity test is redundant. If a second surface ever re-renders these
# prompts, restore a parity test scoped to that new consumer.
# ── Setup section header + Overview + Usage modes ────────────────────────
def test_setup_section_renders_for_not_onboarded(fresh_db):
"""Not-onboarded users land on /home and see the setup section
header (eyebrow + heading + lede) floating above the install hero
card. The dismissible Getting Started shortcut block has been
removed — its two links lived only as in-page jumps and duplicated
the install-hero + /setup-advanced affordances already present on
the page. Onboarded users see neither header nor install hero so
the page reads as a hub, not a setup screen."""
from src.db import get_system_db, close_system_db
# Not-onboarded: setup header + install hero both render.
conn = get_system_db()
try:
_, sess = _make_user_and_session(
conn, email="setup-not-onboarded@example.com", onboarded=False
)
finally:
conn.close()
close_system_db()
body = _client().get("/home", cookies={"access_token": sess}).text
# Header floats above the card with the design spec eyebrow + h2.
assert '<div class="setup-section-header"' in body
assert ">First time here<" in body
assert "Set up" in body and "on your machine" in body
# Install hero card sits below the header.
assert '<div class="install-hero">' in body
# Getting Started shortcut block is gone.
assert "home-getting-started" not in body
assert "agnes_home_gs_dismissed" not in body
# Onboarded: install hero (and the setup header above it) are gone.
conn = get_system_db()
try:
_, sess2 = _make_user_and_session(
conn, email="setup-onboarded@example.com", onboarded=True
)
finally:
conn.close()
close_system_db()
body2 = _client().get("/home", cookies={"access_token": sess2}).text
assert '<div class="install-hero"' not in body2
assert '<div class="setup-section-header"' not in body2
def test_welcome_footnotes_render_overview_when_set(fresh_db, monkeypatch):
"""Setting `AGNES_INSTANCE_OVERVIEW` (mirrors `instance.overview`
yaml) injects raw HTML into the welcome-hero footnotes via the
same `| safe` filter as the previous standalone Overview
section. The marker text MUST appear inside
`.home-hero-footnotes`, and the legacy `<section class="home-overview">`
wrapper MUST stay absent — the operator-owned body now lives
inside the welcome card, not as a separate section between the
walkthrough and surfaces grid."""
monkeypatch.setenv("AGNES_INSTANCE_OVERVIEW", "<p>OVERVIEW_TEST_MARKER</p>")
from src.db import get_system_db, close_system_db
conn = get_system_db()
try:
_, sess = _make_user_and_session(conn)
finally:
conn.close()
close_system_db()
body = _client().get("/home", cookies={"access_token": sess}).text
assert '<section class="home-overview">' not in body
assert '<div class="home-hero-footnotes">' in body
assert "OVERVIEW_TEST_MARKER" in body
def test_welcome_footnotes_hidden_when_overview_unset(fresh_db, monkeypatch):
"""Default empty `instance.overview` (no env override) hides the
welcome-hero footnotes entirely so the OSS ships without a
stray empty footnotes block in the welcome card."""
monkeypatch.delenv("AGNES_INSTANCE_OVERVIEW", raising=False)
from src.db import get_system_db, close_system_db
conn = get_system_db()
try:
_, sess = _make_user_and_session(conn)
finally:
conn.close()
close_system_db()
body = _client().get("/home", cookies={"access_token": sess}).text
assert '<div class="home-hero-footnotes">' not in body
def test_welcome_support_renders_when_set(fresh_db, monkeypatch):
"""Setting `AGNES_INSTANCE_SUPPORT` (mirrors `instance.support`
yaml) injects raw HTML into the mint-accent Support callout
inside the welcome hero. The marker text MUST appear inside
`.home-hero-support-body`. Separate field from
`instance.overview` so support/help pointers can be updated
independently from the operator's product framing."""
monkeypatch.setenv("AGNES_INSTANCE_SUPPORT", "<p>SUPPORT_TEST_MARKER</p>")
from src.db import get_system_db, close_system_db
conn = get_system_db()
try:
_, sess = _make_user_and_session(conn)
finally:
conn.close()
close_system_db()
body = _client().get("/home", cookies={"access_token": sess}).text
assert '<div class="home-hero-support"' in body
assert "SUPPORT_TEST_MARKER" in body
def test_welcome_support_hidden_when_unset(fresh_db, monkeypatch):
"""Default empty `instance.support` (no env override) hides the
Support callout entirely so the OSS ships without a stray
empty mint panel in the welcome card."""
monkeypatch.delenv("AGNES_INSTANCE_SUPPORT", raising=False)
from src.db import get_system_db, close_system_db
conn = get_system_db()
try:
_, sess = _make_user_and_session(conn)
finally:
conn.close()
close_system_db()
body = _client().get("/home", cookies={"access_token": sess}).text
assert '<div class="home-hero-support"' not in body
def test_welcome_support_independent_of_overview(fresh_db, monkeypatch):
"""The Support callout MUST render even when `instance.overview`
is empty — the two fields are independent. Catches a regression
where the Support gate was accidentally wired to
INSTANCE_OVERVIEW instead of INSTANCE_SUPPORT."""
monkeypatch.delenv("AGNES_INSTANCE_OVERVIEW", raising=False)
monkeypatch.setenv("AGNES_INSTANCE_SUPPORT", "<p>SUPPORT_ONLY_MARKER</p>")
from src.db import get_system_db, close_system_db
conn = get_system_db()
try:
_, sess = _make_user_and_session(conn)
finally:
conn.close()
close_system_db()
body = _client().get("/home", cookies={"access_token": sess}).text
assert '<div class="home-hero-footnotes">' not in body
assert '<div class="home-hero-support"' in body
assert "SUPPORT_ONLY_MARKER" in body