agnes-the-ai-analyst/tests/test_web_home_page.py
Vojtech 79a958ec26
feat(setup): configurable instance brand + connector setup overhaul (#268)
- instance.brand (env AGNES_INSTANCE_BRAND, default "Agnes") +
  instance.workspace_dir replace hard-coded "Agnes" / "~/Agnes" across
  /home, /setup, /setup-advanced, /login, /install, /me/debug, and the
  Claude Code clipboard setup script. Terraform-friendly env override;
  defaults preserve existing Agnes branding.

- Explicit "create workspace folder" step on /home (OS-tabbed mkdir+cd)
  + same step baked into the clipboard script as step 2. Drops the
  implicit assumption that `agnes init --workspace .` lands in a
  sensibly-cd'd shell.

- Final "Restart Claude Code" step in the setup script (unconditional,
  between connectors and Confirm) so freshly-installed plugins, MCP
  servers, and SessionStart hooks load on the next Claude Code session.

- Asana reverted from hosted Remote MCP back to PAT + raw REST against
  app.asana.com/api/1.0. MCP envelope shape consumed ~5x tokens per
  call; the PAT path lets the agent read flat REST fields. Existing
  MCP registration is detected and the user is asked whether to remove
  it (default Y, with benefits listed: token cost, no third-party hop,
  no OAuth refresh dance, deterministic envelope shape).

- Atlassian connector instructs picking the longest API-token expiry
  (today "1 year") to cut re-mint friction. No public query-parameter
  hook exists on id.atlassian.com to pre-select expiry, so the prompt
  documents the manual click and acknowledges that limitation.

- Uniform  /  per-connector marker contract (Asana, GWS, Atlassian)
  for the Confirm summary to grep. Each connector now ends with a
  Claude-driven end-to-end test that uses Claude Code's own bash to
  exercise the stored credential and prints
  " <Connector> integration verified — ..." (or the failure variant).
2026-05-12 17:10:08 +02:00

335 lines
13 KiB
Python
Raw 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, identifiable by
the 'Welcome back' hero, the 'Step 1 & Step 2 done' completion badge,
the offboard control, and the absence of the inline Step 1 / Step 2
install commands. Step 3 (auto-mode), connectors, and the rest stay
visible — they remain useful after onboarding."""
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
assert "Welcome back" in body
# Banner copy updated when the explicit "create workspace folder"
# step was inserted between auto-mode and install-Agnes — completion
# badge now spans Steps 1-4 (install Claude Code, auto-mode, mkdir
# workspace, install Agnes from Claude Code).
assert "Steps 1&#8211;4 done" in body or "Steps 14 done" in body
assert "Mark me as offboarded" in body # offboard control visible
# All four inline install-blocks are hidden post-onboarding — the
# labels rendered inside the install-block divs go away.
assert "Step 1 — install Claude Code" not in body
assert "Step 2 — turn on auto-mode" not in body
assert "Step 3 — create your workspace folder" not in body
assert "Step 4 — install" not in body
def test_connectors_render_flat_when_onboarded_by_default(fresh_db):
"""Connect-your-tools section must NOT auto-collapse on the
server-side `users.onboarded=TRUE` flip. It renders flat (in <details
open>) by default; only an explicit user click on the in-hero
"Minimize setup view" toggle (persisted in localStorage, not server)
activates the collapsed bar layout.
Auto-mode used to be a peer `setup-collapsible` section
(`data-section="step3"`) outside the install-hero. It moved into the
install-hero as Step 2 of the install flow (so users enable it
BEFORE Step 3's ~20-command install runs), and the standalone
outside-hero copy was dropped to avoid duplicating reference
content. Onboarded users no longer see the auto-mode block at all —
consistent with Step 1 + Step 3 also hiding post-onboarding."""
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 no longer renders for onboarded users — both the
# in-hero install-block and the legacy outside-hero `<details>`
# reference card are gated `{% if not onboarded %}` / removed.
assert 'class="automode-card"' not in body
assert 'data-section="step3"' not in body
assert "Step 2 — turn on auto-mode" not in body
# Connect-your-tools section is still flat-open by default.
assert 'class="connector-tiles"' in body
assert 'class="setup-collapsible" data-section="connectors" open' 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. The token still
# appears in inline CSS selectors and the JS body, which is fine.
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
def test_minimize_toggle_visible_only_when_onboarded(fresh_db):
"""The "Minimize setup view" toggle markup is rendered for onboarded
users (so they can opt into the collapsed view) and absent for
not-onboarded users (where the install steps already dominate)."""
from src.db import get_system_db, close_system_db
# Not-onboarded → no toggle button.
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
assert '<button id="setupMinimizeToggle"' not in resp.text
assert 'class="setup-minimize"' not in resp.text
# Onboarded → toggle button rendered inside the install-hero.
conn = get_system_db()
try:
_, sess2 = _make_user_and_session(conn, email="b@example.com", onboarded=True)
finally:
conn.close()
close_system_db()
c2 = _client()
resp2 = c2.get("/home", cookies={"access_token": sess2})
assert resp2.status_code == 200
assert '<button id="setupMinimizeToggle"' in resp2.text
assert 'class="setup-minimize"' in resp2.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})
assert "Welcome back" in post.text # nav hub view
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_shows_email_admin_button_when_admin_email_set_and_gws_unconfigured(
fresh_db, monkeypatch,
):
"""When admin_email is set AND gws_oauth is unconfigured, the mailto
link renders. (Both conditions required — see template guard
``{% if not gws_oauth.configured and instance_admin_email %}``.)"""
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" in body
assert "mailto:ops@example.com" 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
def test_home_renders_connector_prompts_from_shared_module(fresh_db):
"""Single source of truth check: the prompt text the /home tiles
paste must equal the strings ``app/web/connector_prompts.py`` returns.
The same strings are also inlined into the setup script's step 9, so
if they ever drift the two surfaces would tell users to do different
things — this test catches that early."""
import html as _html
import re
from src.db import get_system_db, close_system_db
from app.web.connector_prompts import (
asana_prompt, gws_prompt, atlassian_prompt,
)
from app.instance_config import (
get_gws_oauth_credentials, get_instance_admin_email,
)
conn = get_system_db()
try:
_, sess = _make_user_and_session(conn)
finally:
conn.close()
close_system_db()
c = _client()
body = c.get("/home", cookies={"access_token": sess}).text
# Resolve the same gws_oauth dict the route uses so the parity check
# exercises whichever branch (configured / manual) is active in the
# current test environment.
gws = get_gws_oauth_credentials()
expected_gws = gws_prompt(
gws_oauth_configured=bool(gws.get("configured")),
gws_client_id=str(gws.get("client_id") or ""),
gws_client_secret=str(gws.get("client_secret") or ""),
gws_project_id=str(gws.get("project_id") or ""),
oauthlib_insecure_transport=str(gws.get("oauthlib_insecure_transport") or "1"),
instance_admin_email=get_instance_admin_email(),
)
for slug, expected in (
("asana", asana_prompt()),
("gws", expected_gws),
("jira", atlassian_prompt()),
):
m = re.search(rf'<code id="{slug}-prompt">(.*?)</code>', body, re.DOTALL)
assert m, f"{slug}-prompt block missing from /home"
actual = _html.unescape(m.group(1))
assert actual == expected, (
f"{slug}-prompt body diverged from connector_prompts module — "
f"the home tile and setup script will paste different text. "
f"len(home)={len(actual)} len(module)={len(expected)}"
)