128 lines
4.7 KiB
Python
128 lines
4.7 KiB
Python
r"""E2E: AskUserQuestion renders as a web form (mock, no real Claude Code).
|
|
|
|
A background thread POSTs directly to the server's
|
|
``POST /v1/sessions/{session_id}/hooks/permission-request`` endpoint with an
|
|
``AskUserQuestion`` payload. The server parks the elicitation (long-poll), the
|
|
SPA renders it as an ``AskUserQuestionForm`` inside an ``ApprovalCard`` — radio
|
|
inputs for a single-select question and a Submit button. The test selects an
|
|
answer and submits it, confirming the parked HTTP call drains and the verdict
|
|
flowed back through the PermissionRequest round-trip.
|
|
|
|
This approach replaces the original native Claude Code session (real LLM turn)
|
|
with a seeded session and a synthetic hook POST — no real Claude Code needed,
|
|
test completes in seconds rather than minutes.
|
|
|
|
This is the structured-form counterpart to the binary approval card
|
|
(``test_approval_card.py``): the binary card covers a policy ASK, this covers
|
|
Claude's own question tool. It is the sibling of ``test_exit_plan_mode.py``:
|
|
both cover a Claude built-in tool that surfaces a structured card rather than
|
|
the binary policy ASK.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import threading
|
|
import time
|
|
|
|
import httpx
|
|
import pytest
|
|
from playwright.sync_api import Page, expect
|
|
|
|
_log = logging.getLogger(__name__)
|
|
|
|
_APPROVAL_CARD = '[data-testid="approval-card"]'
|
|
_FORM = '[data-testid="ask-user-question-form"]'
|
|
_SUBMIT = '[data-testid="ask-user-question-submit"]'
|
|
|
|
_MOCK_ELICITATION_TIMEOUT_MS = 15_000
|
|
|
|
# The exact option labels used in the hook payload and form assertions.
|
|
_OPTION_ONE = "Alpha"
|
|
_OPTION_TWO = "Bravo"
|
|
|
|
|
|
def _pending_elicitations(base_url: str, session_id: str) -> list[dict]:
|
|
"""Return the session snapshot's pending elicitation events (owner view)."""
|
|
resp = httpx.get(f"{base_url}/v1/sessions/{session_id}", timeout=10.0)
|
|
resp.raise_for_status()
|
|
return resp.json().get("pending_elicitations") or []
|
|
|
|
|
|
def _wait_for(predicate, *, timeout_s: float = 30.0, interval_s: float = 0.5) -> None:
|
|
"""Poll *predicate* until truthy or the deadline passes."""
|
|
deadline = time.monotonic() + timeout_s
|
|
while time.monotonic() < deadline:
|
|
if predicate():
|
|
return
|
|
time.sleep(interval_s)
|
|
raise AssertionError("condition not met within timeout")
|
|
|
|
|
|
@pytest.mark.timeout(90)
|
|
def test_ask_user_question_form_renders_and_submits(
|
|
page: Page,
|
|
seeded_session: tuple[str, str],
|
|
) -> None:
|
|
"""Mock AskUserQuestion permission-request → web form renders → answer → prompt drains."""
|
|
base_url, session_id = seeded_session
|
|
_log.info("seeded session ready: base_url=%s session_id=%s", base_url, session_id)
|
|
|
|
result_holder: dict = {}
|
|
|
|
def _post_hook() -> None:
|
|
try:
|
|
resp = httpx.post(
|
|
f"{base_url}/v1/sessions/{session_id}/hooks/permission-request",
|
|
json={
|
|
"tool_name": "AskUserQuestion",
|
|
"tool_input": {
|
|
"questions": [
|
|
{
|
|
"question": "Which option do you prefer?",
|
|
"options": [_OPTION_ONE, _OPTION_TWO],
|
|
}
|
|
]
|
|
},
|
|
},
|
|
timeout=60.0,
|
|
)
|
|
resp.raise_for_status()
|
|
result_holder["response"] = resp.json()
|
|
except Exception as exc:
|
|
result_holder["error"] = exc
|
|
|
|
hook_thread = threading.Thread(target=_post_hook, daemon=True)
|
|
hook_thread.start()
|
|
|
|
# Let the server park the elicitation before the SPA tries to render it.
|
|
page.wait_for_timeout(500)
|
|
|
|
page.goto(f"{base_url}/c/{session_id}")
|
|
|
|
card = (
|
|
page.locator(f'{_APPROVAL_CARD}[data-state="pending"]')
|
|
.filter(has=page.locator(_FORM))
|
|
.first
|
|
)
|
|
expect(card).to_be_visible(timeout=_MOCK_ELICITATION_TIMEOUT_MS)
|
|
form = card.locator(_FORM)
|
|
expect(form).to_be_visible(timeout=_MOCK_ELICITATION_TIMEOUT_MS)
|
|
expect(form.get_by_text(_OPTION_ONE, exact=True)).to_be_visible()
|
|
expect(form.get_by_text(_OPTION_TWO, exact=True)).to_be_visible()
|
|
assert _pending_elicitations(base_url, session_id), "server has no parked elicitation"
|
|
|
|
form.get_by_role("radio", name=_OPTION_ONE).check()
|
|
form.locator(_SUBMIT).click()
|
|
|
|
responded = page.locator(f'{_APPROVAL_CARD}[data-state="responded"]').first
|
|
expect(responded).to_be_visible(timeout=_MOCK_ELICITATION_TIMEOUT_MS)
|
|
|
|
hook_thread.join(timeout=30)
|
|
if "error" in result_holder:
|
|
raise AssertionError(f"hook thread failed: {result_holder['error']}") from result_holder[
|
|
"error"
|
|
]
|
|
|
|
_wait_for(lambda: not _pending_elicitations(base_url, session_id))
|