Files
optimclaw/tests/e2e/scenarios/test_owner_scope.py
T
Henry ParkandGitHub d3b69e7be3 Fix CI approval flows and stale fixtures (#1478)
* Fix CI approval flows and stale fixtures

* Backfill approval thread mapping across channels
2026-03-20 12:21:46 -07:00

273 lines
8.6 KiB
Python

"""Owner-scope end-to-end scenarios.
These tests exercise the explicit owner model across:
- the web gateway chat UI
- the owner-scoped HTTP webhook channel
- routine tools / routines tab
"""
import asyncio
import json
import uuid
import httpx
from helpers import (
AUTH_TOKEN,
SEL,
api_get,
api_post,
signed_http_webhook_headers,
)
async def _send_and_get_response(
page,
message: str,
*,
expected_fragment: str,
timeout: int = 30000,
) -> str:
"""Send a chat message and return the newest assistant response text."""
chat_input = page.locator(SEL["chat_input"])
await chat_input.wait_for(state="visible", timeout=5000)
assistant_sel = SEL["message_assistant"]
before_count = await page.locator(assistant_sel).count()
await chat_input.fill(message)
await chat_input.press("Enter")
expected = before_count + 1
await page.wait_for_function(
"""({ assistantSelector, expectedCount, expectedFragment }) => {
const messages = document.querySelectorAll(assistantSelector);
if (messages.length < expectedCount) return false;
const text = (messages[messages.length - 1].innerText || '').trim().toLowerCase();
return text.includes(expectedFragment.toLowerCase());
}""",
arg={
"assistantSelector": assistant_sel,
"expectedCount": expected,
"expectedFragment": expected_fragment,
},
timeout=timeout,
)
return await page.locator(assistant_sel).last.inner_text()
async def _post_http_webhook(
http_channel_server: str,
*,
content: str,
sender_id: str,
thread_id: str,
wait_for_response: bool = True,
) -> str | None:
"""Send a signed request to the owner-scoped HTTP webhook channel."""
payload = {
"user_id": sender_id,
"thread_id": thread_id,
"content": content,
"wait_for_response": wait_for_response,
}
body = json.dumps(payload).encode("utf-8")
async with httpx.AsyncClient() as client:
response = await client.post(
f"{http_channel_server}/webhook",
content=body,
headers=signed_http_webhook_headers(body),
timeout=90,
)
assert response.status_code == 200, (
f"HTTP webhook failed: {response.status_code} {response.text[:400]}"
)
data = response.json()
assert data["status"] == "accepted", f"Unexpected webhook response: {data}"
if wait_for_response:
assert data["response"], f"Expected synchronous response body, got: {data}"
return data.get("response")
async def _open_tab(page, tab: str) -> None:
btn = page.locator(SEL["tab_button"].format(tab=tab))
await btn.click()
await page.locator(SEL["tab_panel"].format(tab=tab)).wait_for(
state="visible",
timeout=5000,
)
async def _wait_for_routine(base_url: str, name: str, timeout: float = 20.0) -> dict:
"""Poll the routines API until the named routine exists."""
async with httpx.AsyncClient() as client:
for _ in range(int(timeout * 2)):
response = await client.get(
f"{base_url}/api/routines",
headers={"Authorization": f"Bearer {AUTH_TOKEN}"},
timeout=10,
)
response.raise_for_status()
routines = response.json()["routines"]
for routine in routines:
if routine["name"] == name:
return routine
await _poll_sleep()
raise AssertionError(f"Routine '{name}' was not created within {timeout}s")
async def _wait_for_http_thread(base_url: str, title_fragment: str, timeout: float = 20.0) -> str:
"""Poll the chat thread list until the matching HTTP thread is visible."""
for _ in range(int(timeout * 2)):
response = await api_get(base_url, "/api/chat/threads", timeout=10)
response.raise_for_status()
threads = response.json()["threads"]
for thread in threads:
if thread.get("channel") != "http":
continue
if title_fragment in (thread.get("title") or ""):
return thread["id"]
await _poll_sleep()
raise AssertionError(
f"HTTP thread containing '{title_fragment}' was not visible within {timeout}s"
)
async def _wait_for_pending_approval(
base_url: str,
thread_id: str,
timeout: float = 20.0,
) -> dict:
"""Poll chat history until the thread exposes a pending approval payload."""
for _ in range(int(timeout * 2)):
response = await api_get(
base_url,
f"/api/chat/history?thread_id={thread_id}",
timeout=10,
)
response.raise_for_status()
pending = response.json().get("pending_approval")
if pending:
return pending
await _poll_sleep()
raise AssertionError(f"Thread '{thread_id}' did not expose a pending approval")
async def _approve_pending_request(base_url: str, thread_id: str, request_id: str) -> None:
"""Approve a pending tool request through the web gateway API."""
response = await api_post(
base_url,
"/api/chat/approval",
json={
"request_id": request_id,
"action": "approve",
"thread_id": thread_id,
},
timeout=10,
)
assert response.status_code == 202, (
f"Approval submission failed: {response.status_code} {response.text[:400]}"
)
data = response.json()
assert data["status"] == "accepted", f"Unexpected approval response: {data}"
async def _poll_sleep() -> None:
"""Small shared backoff for API polling loops."""
await asyncio.sleep(0.5)
async def test_http_channel_created_routine_is_visible_in_web_routines_tab(
page,
ironclaw_server,
http_channel_server,
):
"""A routine created from the HTTP channel is visible in the web owner UI."""
routine_name = f"owner-http-{uuid.uuid4().hex[:8]}"
response_text = await _post_http_webhook(
http_channel_server,
content=f"create lightweight owner routine {routine_name}",
sender_id="external-sender-alpha",
thread_id="http-owner-routine-thread",
)
assert routine_name in response_text
await _wait_for_routine(ironclaw_server, routine_name)
await _open_tab(page, "routines")
await page.locator(SEL["routine_row"]).filter(has_text=routine_name).first.wait_for(
state="visible",
timeout=15000,
)
async def test_web_created_routine_is_listed_from_http_channel_across_senders(
page,
ironclaw_server,
http_channel_server,
):
"""Routines created in web chat remain owner-global across HTTP senders/threads."""
routine_name = f"owner-web-{uuid.uuid4().hex[:8]}"
assistant_text = await _send_and_get_response(
page,
f"create lightweight owner routine {routine_name}",
expected_fragment=routine_name,
)
assert routine_name in assistant_text
await _wait_for_routine(ironclaw_server, routine_name)
first_sender_text = await _post_http_webhook(
http_channel_server,
content="list owner routines",
sender_id="http-sender-one",
thread_id="owner-list-thread-a",
)
second_sender_text = await _post_http_webhook(
http_channel_server,
content="list owner routines",
sender_id="http-sender-two",
thread_id="owner-list-thread-b",
)
assert routine_name in first_sender_text, first_sender_text
assert routine_name in second_sender_text, second_sender_text
async def test_http_created_full_job_routine_is_visible_in_web_after_approval(
page,
ironclaw_server,
http_channel_server,
):
"""A full-job routine created via HTTP appears in the web owner UI after approval."""
routine_name = f"owner-job-{uuid.uuid4().hex[:8]}"
await _post_http_webhook(
http_channel_server,
content=f"create full-job owner routine {routine_name}",
sender_id="http-job-sender",
thread_id="owner-job-thread",
wait_for_response=False,
)
thread_id = await _wait_for_http_thread(ironclaw_server, routine_name)
pending = await _wait_for_pending_approval(ironclaw_server, thread_id)
assert pending["tool_name"] == "routine_create"
await _approve_pending_request(
ironclaw_server,
thread_id,
pending["request_id"],
)
routine = await _wait_for_routine(ironclaw_server, routine_name)
assert routine["action_type"] == "full_job"
await _open_tab(page, "routines")
routine_row = page.locator(SEL["routine_row"]).filter(has_text=routine_name).first
await routine_row.wait_for(state="visible", timeout=15000)