318 lines
13 KiB
Python
318 lines
13 KiB
Python
"""E2E: responsive Web UI workflow at a phone-sized viewport.
|
||
|
||
``AppShell.test.tsx`` already covers the mobile session-menu state
|
||
machine in jsdom — but jsdom does not apply the Tailwind ``md:`` media
|
||
queries, so it cannot prove the responsive *layout* contract. These
|
||
tests run a real browser at a 390×844 (phone) viewport and assert the
|
||
behaviors only real CSS can produce:
|
||
|
||
- the desktop workspace rail (``md:flex``) is collapsed off-screen;
|
||
- the session-menu FAB (``md:hidden``) is the navigation entry point;
|
||
- the desktop right-panel toggle (``hidden md:inline-flex``) is gone;
|
||
- each rail surface (Changes/Files/Agents) is reachable through the
|
||
FAB and opens a full-screen drawer;
|
||
- the chat composer is usable and streams a response on a phone.
|
||
|
||
Files are seeded via the filesystem PUT endpoint so the navigation
|
||
tests are deterministic (no LLM); the final chat test exercises the
|
||
real streaming path the same way ``test_smoke`` does.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
import shutil
|
||
from collections.abc import Iterator
|
||
from pathlib import Path
|
||
|
||
import httpx
|
||
import pytest
|
||
from playwright.sync_api import Page, ViewportSize, expect
|
||
|
||
from tests.e2e_ui.conftest import configure_mock_llm
|
||
|
||
# iPhone-12-class portrait viewport — comfortably below the Tailwind
|
||
# ``md`` breakpoint (768px) so every ``md:`` rule resolves to its
|
||
# mobile branch.
|
||
_MOBILE_VIEWPORT: ViewportSize = {"width": 390, "height": 844}
|
||
|
||
_SEEDED_FILE_PATH = "mobile_workflow.txt"
|
||
_SEEDED_FILE_CONTENT = "File body opened through the mobile Changes drawer."
|
||
|
||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||
|
||
|
||
@pytest.fixture
|
||
def mobile_session_with_file(
|
||
seeded_session: tuple[str, str],
|
||
) -> Iterator[tuple[str, str]]:
|
||
"""Seed one file and clean up the per-session workdir on teardown.
|
||
|
||
The agent spec's ``os_env.cwd: .`` makes filesystem PUTs land in
|
||
``<repo-root>/<session_id>/``; remove it after the test so no
|
||
untracked files are left behind.
|
||
"""
|
||
base_url, session_id = seeded_session
|
||
resp = httpx.put(
|
||
f"{base_url}/v1/sessions/{session_id}"
|
||
f"/resources/environments/default/filesystem/{_SEEDED_FILE_PATH}",
|
||
json={"content": _SEEDED_FILE_CONTENT, "encoding": "utf-8"},
|
||
timeout=10.0,
|
||
)
|
||
resp.raise_for_status()
|
||
try:
|
||
yield (base_url, session_id)
|
||
finally:
|
||
shutil.rmtree(_REPO_ROOT / session_id, ignore_errors=True)
|
||
|
||
|
||
def test_mobile_collapses_rail_and_surfaces_fab(
|
||
page: Page,
|
||
seeded_session: tuple[str, str],
|
||
) -> None:
|
||
"""At a phone width the rail is hidden and the FAB replaces it.
|
||
|
||
Proves the responsive layout swap that jsdom can't: the desktop
|
||
workspace ``aside`` (``hidden md:flex``) must not be visible, the
|
||
session-menu FAB (``md:hidden``) must be, and the desktop-only
|
||
right-panel collapse toggle (``hidden md:inline-flex``) must be
|
||
absent from the viewport.
|
||
"""
|
||
base_url, session_id = seeded_session
|
||
page.set_viewport_size(_MOBILE_VIEWPORT)
|
||
page.goto(f"{base_url}/c/{session_id}")
|
||
|
||
# The composer renders, confirming the chat surface is the primary
|
||
# mobile view (no rail competing for space).
|
||
expect(page.get_by_placeholder("Ask the agent anything…")).to_be_visible()
|
||
|
||
# Desktop rail is in the DOM but collapsed off-screen (hidden md:flex).
|
||
# A DOM-scoped locator makes not_to_be_visible() genuinely assert
|
||
# display:none rather than passing on a 0-match accessibility locator.
|
||
expect(page.locator('aside[aria-label="Workspace"]')).not_to_be_visible()
|
||
|
||
# FAB present; desktop right-panel toggle present-but-hidden.
|
||
expect(page.get_by_role("button", name="Open session menu")).to_be_visible()
|
||
expect(
|
||
page.locator(
|
||
'button[aria-label="Collapse right panel"], button[aria-label="Expand right panel"]'
|
||
)
|
||
).not_to_be_visible()
|
||
|
||
|
||
@pytest.fixture
|
||
def mobile_session_with_child_agent(
|
||
seeded_session: tuple[str, str],
|
||
) -> Iterator[tuple[str, str]]:
|
||
"""Seed one sub-agent (child) session under the parent.
|
||
|
||
The Agents surface is gated on ``childSessions.length > 0`` — the
|
||
root having at least one child (so "main + child" is genuinely
|
||
multi-agent). A real spawn needs an LLM run, which is slow and
|
||
non-deterministic; instead we create the child directly via the
|
||
JSON ``POST /v1/sessions`` contract with ``parent_session_id`` set,
|
||
reusing the parent's bound ``agent_id``. That makes the server
|
||
record a ``kind="sub_agent"`` conversation, which
|
||
``GET /sessions/{id}/child_sessions`` then lists even with no
|
||
tasks. Cleaned up by deleting the child on teardown.
|
||
"""
|
||
base_url, session_id = seeded_session
|
||
parent = httpx.get(
|
||
f"{base_url}/v1/sessions/{session_id}",
|
||
timeout=10.0,
|
||
)
|
||
parent.raise_for_status()
|
||
agent_id = parent.json()["agent_id"]
|
||
|
||
child = httpx.post(
|
||
f"{base_url}/v1/sessions",
|
||
json={
|
||
"agent_id": agent_id,
|
||
"parent_session_id": session_id,
|
||
"sub_agent_name": "researcher",
|
||
"title": "researcher:auth",
|
||
},
|
||
timeout=30.0,
|
||
)
|
||
child.raise_for_status()
|
||
# JSON POST /v1/sessions returns a SessionResponse (id), unlike the
|
||
# multipart bundled create used by seeded_session (session_id).
|
||
child_id = child.json()["id"]
|
||
try:
|
||
yield (base_url, session_id)
|
||
finally:
|
||
httpx.delete(
|
||
f"{base_url}/v1/sessions/{child_id}",
|
||
timeout=10.0,
|
||
)
|
||
|
||
|
||
def test_mobile_fab_lists_file_surfaces_and_omits_absent_ones(
|
||
page: Page,
|
||
seeded_session: tuple[str, str],
|
||
) -> None:
|
||
"""The FAB menu degrades gracefully to only the available surfaces.
|
||
|
||
A plain ``hello_world`` session has a workspace (os_env), no child
|
||
agents, and is not claude-native — so the menu must list Files (the
|
||
single files surface; its Changed/All scope is an in-panel toggle)
|
||
and Agents (unconditional — the panel lists at least the main
|
||
agent, badge "1"), and must NOT list Shells or Tasks. The session's
|
||
only terminal is the auto-created embedded Omnigent REPL, which is
|
||
plumbing for the Chat/Terminal pill, not inventory — listing it
|
||
read as a phantom "main" terminal on agents that don't run a TUI,
|
||
so ``inventoryTerminals`` excludes it from the FAB/rail.
|
||
"""
|
||
base_url, session_id = seeded_session
|
||
page.set_viewport_size(_MOBILE_VIEWPORT)
|
||
page.goto(f"{base_url}/c/{session_id}")
|
||
|
||
page.get_by_role("button", name="Open session menu").click()
|
||
|
||
expect(page.get_by_role("menuitem", name="Files", exact=True)).to_be_visible()
|
||
# Agents is always present; "1" = just the main agent. "0" means
|
||
# the main agent was dropped from the count.
|
||
expect(page.get_by_role("menuitem", name=re.compile(r"Agents\s*1"))).to_be_visible()
|
||
# No Shells entry: the embedded REPL terminal is excluded from
|
||
# the inventory (reachable via the Chat/Terminal pill instead). An
|
||
# entry here means the REPL leaked back into the FAB gating.
|
||
expect(page.get_by_role("menuitem", name="Shells")).to_have_count(0)
|
||
# No separate Changes entry (merged into Files) and no todos.
|
||
expect(page.get_by_role("menuitem", name="Changes")).to_have_count(0)
|
||
expect(page.get_by_role("menuitem", name="Tasks")).to_have_count(0)
|
||
|
||
|
||
def test_mobile_shells_drawer_exposes_new_shell_before_shells_exist(
|
||
page: Page,
|
||
terminal_session: tuple[str, str],
|
||
) -> None:
|
||
"""Shell-capable agents expose the mobile Shells drawer at zero shells.
|
||
|
||
The desktop rail shows Shells by default when the session agent declares a
|
||
``terminals:`` block, because the empty state is the "+ New shell" entry
|
||
point. Mobile must mirror that behavior: the FAB should list Shells before
|
||
any user shell has been created, and selecting it should open the full-screen
|
||
Shells drawer containing the same "+ New shell" row.
|
||
"""
|
||
base_url, session_id = terminal_session
|
||
page.set_viewport_size(_MOBILE_VIEWPORT)
|
||
page.goto(f"{base_url}/c/{session_id}")
|
||
|
||
page.get_by_role("button", name="Open session menu").click()
|
||
|
||
shells_entry = page.get_by_role("menuitem", name="Shells", exact=True)
|
||
expect(shells_entry).to_be_visible(timeout=10_000)
|
||
shells_entry.click()
|
||
|
||
drawer = page.get_by_test_id("shells-panel-drawer")
|
||
expect(drawer).to_have_attribute("data-state", "open")
|
||
expect(drawer.get_by_role("button", name="New shell")).to_be_visible()
|
||
|
||
|
||
def test_mobile_fab_shows_agents_entry_when_child_agents_exist(
|
||
page: Page,
|
||
mobile_session_with_child_agent: tuple[str, str],
|
||
) -> None:
|
||
"""The Agents entry appears once the session has a child agent.
|
||
|
||
Counterpart to the omits-absent-ones test: with one sub-agent
|
||
seeded under the parent, the multi-agent gate
|
||
(``childSessions.length > 0``) is satisfied, so the FAB now lists
|
||
Agents alongside Files. Selecting it opens the agents drawer.
|
||
"""
|
||
base_url, session_id = mobile_session_with_child_agent
|
||
page.set_viewport_size(_MOBILE_VIEWPORT)
|
||
page.goto(f"{base_url}/c/{session_id}")
|
||
|
||
page.get_by_role("button", name="Open session menu").click()
|
||
|
||
agents_entry = page.get_by_role("menuitem", name="Agents")
|
||
# 10s budget: the rail fetches child_sessions on load, so the entry
|
||
# can take a fetch cycle (plus cold-start latency) to appear.
|
||
expect(agents_entry).to_be_visible(timeout=10_000)
|
||
expect(page.get_by_role("menuitem", name="Files", exact=True)).to_be_visible()
|
||
|
||
# Selecting Agents opens the agents (sub-agents) drawer.
|
||
agents_entry.click()
|
||
drawer = page.get_by_test_id("subagents-panel-drawer")
|
||
expect(drawer).to_have_attribute("data-state", "open")
|
||
|
||
|
||
def test_mobile_files_drawer_opens_seeded_file(
|
||
page: Page,
|
||
mobile_session_with_file: tuple[str, str],
|
||
) -> None:
|
||
"""Open the Files drawer from the FAB and view the seeded file.
|
||
|
||
The full chain a phone user hits: FAB → Files → full-screen files
|
||
drawer → tap a file → full-screen file viewer with real content.
|
||
Uses the Files (folder-tree) surface rather than Changes: the
|
||
session workspace isn't a git repo, so the changed-files diff is
|
||
empty, but the directory listing always includes the seeded file.
|
||
Failure means the mobile drawer routing or the mobile FileViewer
|
||
push-panel regressed.
|
||
"""
|
||
base_url, session_id = mobile_session_with_file
|
||
page.set_viewport_size(_MOBILE_VIEWPORT)
|
||
page.goto(f"{base_url}/c/{session_id}")
|
||
|
||
page.get_by_role("button", name="Open session menu").click()
|
||
page.get_by_role("menuitem", name="Files", exact=True).click()
|
||
|
||
drawer = page.get_by_test_id("files-panel-drawer")
|
||
expect(drawer).to_have_attribute("data-state", "open")
|
||
|
||
# The PUT-seeded file appears in the working-folder directory listing.
|
||
# Anchor the name to the filename so the row button is matched, not the
|
||
# sibling "Download <file>" action button. 30s budget: the panel polls
|
||
# the workspace listing endpoint on an interval, so the seeded file can
|
||
# take a poll cycle (plus cold-start latency) to surface.
|
||
file_button = page.get_by_role("button", name=re.compile(rf"^{re.escape(_SEEDED_FILE_PATH)}"))
|
||
expect(file_button).to_be_visible(timeout=30_000)
|
||
file_button.click()
|
||
|
||
# Selecting a file closes the drawer and opens the mobile FileViewer
|
||
# push-panel. ``:visible`` targets the on-screen instance (the desktop
|
||
# inline viewer carries the same testid but is display:none here).
|
||
file_viewer = page.locator('[data-testid="file-viewer"]:visible')
|
||
expect(file_viewer).to_contain_text(_SEEDED_FILE_PATH)
|
||
expect(file_viewer.get_by_text(_SEEDED_FILE_CONTENT).first).to_be_visible(timeout=20_000)
|
||
|
||
|
||
# The agent turn is dispatched to the in-process harness, which
|
||
# occasionally produces no assistant output on the first turn (the
|
||
# runner goes idle after dispatch — a nondeterministic harness
|
||
# scheduling stall, not a real-LLM artifact since this drives the mock
|
||
# LLM). Rerun on failure rather than widen the already-generous 60s
|
||
# wait, which a stalled turn would never satisfy.
|
||
@pytest.mark.flaky(reruns=2, reruns_delay=5)
|
||
def test_mobile_chat_send_and_response(
|
||
page: Page,
|
||
seeded_session: tuple[str, str],
|
||
mock_llm_server_url: str,
|
||
) -> None:
|
||
"""The composer streams an assistant reply at a phone viewport.
|
||
|
||
Mirrors ``test_smoke`` but at 390px wide, confirming the composer
|
||
and message list stay usable on a phone (no rail stealing layout).
|
||
"""
|
||
base_url, session_id = seeded_session
|
||
prompt = "Say 'mobile-pong-e2e' in one word."
|
||
configure_mock_llm(
|
||
mock_llm_server_url,
|
||
[{"text": "mobile-pong-e2e"}],
|
||
key="mobile-chat-send-and-response",
|
||
match=prompt,
|
||
)
|
||
page.set_viewport_size(_MOBILE_VIEWPORT)
|
||
page.goto(f"{base_url}/c/{session_id}")
|
||
|
||
composer = page.get_by_placeholder("Ask the agent anything…")
|
||
expect(composer).to_be_visible()
|
||
composer.fill(prompt)
|
||
page.get_by_role("button", name="Send", exact=True).click()
|
||
|
||
assistant = page.locator('[data-testid="message-bubble"][data-role="assistant"]').first
|
||
expect(assistant).to_be_visible(timeout=60_000)
|
||
expect(assistant).to_contain_text("mobile-pong-e2e", timeout=60_000)
|