mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* refactor: add explicit owner scope across channels * fix: tighten routine owner target routing * fix: address owner scope review feedback * Fix owner-scope onboarding and event trigger isolation * Tighten routing fallback and wizard owner validation * fix: address owner-scope follow-up review * fix: tighten owner-scope follow-up details * fix: import Channel trait in telegram test * fix: normalize http webhook sender ids * fix: address remaining owner-scope review issues * fix: reconcile config rebase fallout * fix: reconcile extension manager rebase drift * fix: address current copilot review regressions * fix: restore clippy matrix after rebase
227 lines
7.2 KiB
Python
227 lines
7.2 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
|
|
- job creation via routine execution / jobs tab
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import uuid
|
|
|
|
import httpx
|
|
|
|
from helpers import SEL, AUTH_TOKEN, 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,
|
|
) -> str:
|
|
"""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": True,
|
|
}
|
|
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}"
|
|
assert data["response"], f"Expected synchronous response body, got: {data}"
|
|
return data["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_job(base_url: str, title: str, timeout: float = 30.0) -> dict:
|
|
"""Poll the jobs API until the named job exists."""
|
|
async with httpx.AsyncClient() as client:
|
|
for _ in range(int(timeout * 2)):
|
|
response = await client.get(
|
|
f"{base_url}/api/jobs",
|
|
headers={"Authorization": f"Bearer {AUTH_TOKEN}"},
|
|
timeout=10,
|
|
)
|
|
response.raise_for_status()
|
|
jobs = response.json()["jobs"]
|
|
for job in jobs:
|
|
if job["title"] == title:
|
|
return job
|
|
await _poll_sleep()
|
|
raise AssertionError(f"Job '{title}' was not created within {timeout}s")
|
|
|
|
|
|
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_can_be_run_from_web_and_shows_in_jobs(
|
|
page,
|
|
ironclaw_server,
|
|
http_channel_server,
|
|
):
|
|
"""A full-job routine created via HTTP can be run from the web UI and create a job."""
|
|
routine_name = f"owner-job-{uuid.uuid4().hex[:8]}"
|
|
|
|
response_text = 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",
|
|
)
|
|
assert routine_name in response_text
|
|
|
|
await _wait_for_routine(ironclaw_server, routine_name)
|
|
|
|
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)
|
|
await routine_row.locator('button[data-action="trigger-routine"]').click()
|
|
|
|
await _wait_for_job(ironclaw_server, routine_name, timeout=45.0)
|
|
|
|
await _open_tab(page, "jobs")
|
|
await page.locator(SEL["job_row"]).filter(has_text=routine_name).first.wait_for(
|
|
state="visible",
|
|
timeout=20000,
|
|
)
|