ouroboros/tests/test_ui_smoke_playwright.py

3693 lines
193 KiB
Python

from __future__ import annotations
import json
import os
import pathlib
import socket
import subprocess
import sys
import textwrap
import time
import urllib.request
from collections import deque
import pytest
from tests.fixtures_mock_llm import MockLLMServer
from tests.ui_chat_viewport_smoke import _CAPTURE_TEST_SOCKET, _emit_ws_frame
REPO_ROOT = os.path.dirname(os.path.dirname(__file__))
def _open_review_checkpoint(card, *, open_card=True):
if open_card:
card.locator(":scope > [data-live-summary-button]").click()
assert card.is_visible()
section = card.locator(":scope > [data-live-reviews-host] [data-review-section]")
section.wait_for(state="visible", timeout=5_000)
assert section.get_attribute("data-expanded") == "0"
section.locator("[data-review-section-toggle]").click()
assert section.get_attribute("data-expanded") == "1"
group = section.locator("[data-review-group]").first
assert group.locator("[data-review-group-toggle]").get_attribute("aria-expanded") == "false"
group.locator("[data-review-group-toggle]").click()
attempt = group.locator("[data-review-attempt-toggle]").first
attempt.click()
assert attempt.get_attribute("aria-expanded") == "true"
def _free_port() -> int:
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def _wait_health(url: str, timeout_sec: int = 30) -> None:
deadline = time.time() + timeout_sec
last = ""
while time.time() < deadline:
try:
with urllib.request.urlopen(f"{url}/api/health", timeout=2) as resp: # noqa: S310 - local test server
if resp.status == 200:
return
except Exception as exc:
last = str(exc)
time.sleep(0.5)
raise RuntimeError(f"server did not become healthy: {last}")
def _wait_supervisor_ready(url: str, timeout_sec: int = 45) -> None:
"""Wait past port readiness until the direct test runtime can serve history."""
deadline = time.time() + timeout_sec
last = ""
while time.time() < deadline:
try:
with urllib.request.urlopen(f"{url}/api/state", timeout=2) as resp: # noqa: S310 - local test server
payload = json.loads(resp.read().decode("utf-8"))
if payload.get("supervisor_ready") is True:
return
except Exception as exc:
last = str(exc)
time.sleep(0.25)
raise RuntimeError(f"server supervisor did not become ready: {last}")
def _run_core_ui_assertions(url: str) -> None:
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
try:
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 390, "height": 844})
try:
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
page.wait_for_selector("#page-chat", timeout=30_000)
assert page.locator("#page-chat").count() == 1
page.evaluate(
"""() => {
const transfer = new DataTransfer();
transfer.items.add(new File(['hello'], 'drop-check.txt', { type: 'text/plain' }));
const target = document.querySelector('#page-chat');
for (const type of ['dragenter', 'dragover', 'drop']) {
target.dispatchEvent(new DragEvent(type, {
bubbles: true,
cancelable: true,
dataTransfer: transfer,
}));
}
}"""
)
page.wait_for_selector("#chat-attachment-preview.visible .attach-badge", timeout=5_000)
assert "drop-check.txt" in page.locator("#chat-attachment-preview").inner_text(timeout=5_000)
input_area_class = page.locator("#chat-input-area").get_attribute("class", timeout=5_000) or ""
assert "drag-active" not in input_area_class
page.click('[data-mobile-nav-toggle]')
page.wait_for_selector('#primary-sidebar.open', timeout=5_000)
page.click('[data-nav-page="dashboard"]')
page.click('[data-dashboard-tab="updates"]')
assert page.locator("#updates-summary").count() == 1
finally:
browser.close()
except PlaywrightError as exc:
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
pytest.skip(str(exc))
raise
@pytest.mark.ui_browser
def test_ui_projects_sidebar_unread_and_keyboard_menu(direct_server_with_data):
"""Projects stays compact, paint-ACKs unread, and exposes a real keyboard menu."""
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
from ouroboros.projects_registry import create_project, increment_project_visible_revision
url = direct_server_with_data["url"]
data_dir = direct_server_with_data["data_dir"]
create_project(data_dir, "alpha", name="Alpha project")
increment_project_visible_revision(data_dir, project_id="alpha")
try:
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1280, "height": 800})
try:
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
row = page.locator('.nav-project-row[data-project-id="alpha"]')
row.wait_for(state="visible", timeout=30_000)
add = page.locator("#nav-projects-add")
assert add.is_visible()
assert add.get_attribute("aria-label") == "New project"
assert page.locator("#nav-projects-count").inner_text() == "1"
assert row.locator(".nav-unread-dot").count() == 1
row.click()
page.wait_for_selector('#project-panel:not([hidden])', timeout=30_000)
page.wait_for_function(
"() => document.querySelector('#nav-projects-count')?.textContent === ''",
timeout=30_000,
)
page.click("#project-panel-close")
kebab = page.locator('.nav-project-kebab[aria-label="Actions for Alpha project"]')
kebab.focus()
kebab.press("Enter")
menu = page.locator('.project-row-menu[role="menu"]')
menu.wait_for(state="visible", timeout=5_000)
assert page.locator(':focus').get_attribute("data-prm") == "rename"
box = menu.bounding_box()
assert box is not None
assert box["x"] >= 0 and box["y"] >= 0
assert box["x"] + box["width"] <= 1280
assert box["y"] + box["height"] <= 800
page.keyboard.press("Escape")
assert menu.count() == 0
assert page.locator(':focus').get_attribute("aria-label") == "Actions for Alpha project"
toggle = page.locator("#nav-projects-toggle")
toggle.focus()
toggle.press("Space")
assert toggle.get_attribute("aria-expanded") == "false"
assert page.locator("#nav-projects-list").is_hidden()
assert add.is_visible()
finally:
browser.close()
except PlaywrightError as exc:
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
pytest.skip(str(exc))
raise
def _run_docker_ui_assertions(url: str) -> None:
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
try:
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 390, "height": 844})
events = deque(maxlen=40)
page.on("console", lambda msg: events.append(["console", msg.type, msg.text[:1000]]))
page.on("pageerror", lambda error: events.append(["pageerror", str(error)[:1000]]))
page.on("requestfailed", lambda req: events.append(["requestfailed", req.url, req.failure]))
page.on("response", lambda res: events.append(["response", res.status, res.url])
if res.request.resource_type in {"document", "script"} or res.status >= 400 else None)
response = None
try:
response = page.goto(url, wait_until="domcontentloaded", timeout=30_000)
if page.locator("#onboarding-overlay").count():
overlay_text = page.locator("#onboarding-overlay").inner_text(timeout=5_000)
if "Ouroboros" in overlay_text:
return
page.wait_for_selector("#page-chat", timeout=30_000)
assert page.locator("#page-chat").count() == 1
except Exception:
print("DOCKER_UI_BROWSER_EVENTS " + json.dumps(list(events)), file=sys.stderr)
try:
print("DOCKER_UI_PAGE " + json.dumps({
"url": page.url, "title": page.title(),
"document_status": response.status if response is not None else None,
"nodes": {selector: {"count": page.locator(selector).count(),
"visible": page.locator(selector).is_visible()}
for selector in ("#page-chat", "#onboarding-overlay", ".onboarding-frame")},
"body": page.locator("body").inner_text(timeout=1000)[:4000],
"html": page.content()[:8000],
}), file=sys.stderr)
except Exception as diagnostic_error:
print(f"DOCKER_UI_PAGE unavailable: {diagnostic_error}", file=sys.stderr)
raise
finally:
browser.close()
except PlaywrightError as exc:
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
pytest.skip(str(exc))
raise
@pytest.fixture()
def direct_server_with_data(tmp_path):
if os.environ.get("OUROBOROS_RUN_UI_SMOKE") != "1":
pytest.skip("set OUROBOROS_RUN_UI_SMOKE=1 to run browser UI smoke")
with MockLLMServer() as llm:
port = _free_port()
data_dir = tmp_path / "data"
data_dir.mkdir(parents=True)
model = "openai-compatible::mock-model"
(data_dir / "settings.json").write_text(
json.dumps(
{
"OPENAI_COMPATIBLE_API_KEY": "ui-smoke-key",
"OPENAI_COMPATIBLE_BASE_URL": llm.base_url,
"OUROBOROS_MODEL": model,
"OUROBOROS_MODEL_HEAVY": model,
"OUROBOROS_MODEL_LIGHT": model,
"OUROBOROS_MODEL_FALLBACKS": model,
# Every smoke case is single-task or deterministic log replay;
# a ten-process default pool adds only process churn and makes
# sequential browser history fetches flaky on shared hosts.
"OUROBOROS_MAX_WORKERS": 1,
"OUROBOROS_RUNTIME_MODE": "light",
}
),
encoding="utf-8",
)
env = {
**os.environ,
"OUROBOROS_APP_ROOT": str(tmp_path),
"OUROBOROS_DATA_DIR": str(data_dir),
"OUROBOROS_SETTINGS_PATH": str(data_dir / "settings.json"),
"OUROBOROS_REPO_DIR": REPO_ROOT,
"OUROBOROS_SERVER_HOST": "127.0.0.1",
"OUROBOROS_SERVER_PORT": str(port),
"OUROBOROS_HOST_SERVICE_PORT": str(port + 1),
"OUROBOROS_NETWORK_PASSWORD": "ui-smoke-password",
}
url = f"http://127.0.0.1:{port}"
active_proc = active_container = None
def stop_server() -> None:
nonlocal active_proc, active_container
proc, container = active_proc, active_container
active_proc = active_container = None
if container is None:
return
try:
if proc is not None and proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
pass # The container below also owns surviving descendants.
finally:
try:
# Parent exit never proves the entire incarnation is gone.
error = container.reap()
if error:
raise RuntimeError(f"UI fixture process cleanup failed: {error}")
finally:
container.close()
if proc is not None:
proc.wait(timeout=5)
def start_server() -> None:
nonlocal active_proc, active_container
from ouroboros.process_containment import ProcessContainer
# Reap consumes the token/Job: every restart needs fresh containment.
active_container = ProcessContainer()
active_proc = active_container.spawn(
[sys.executable, "server.py"],
cwd=REPO_ROOT,
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
_wait_health(url)
_wait_supervisor_ready(url)
def restart_server() -> None:
stop_server()
start_server()
try:
start_server()
yield {"url": url, "data_dir": data_dir, "restart_server": restart_server}
finally:
stop_server()
@pytest.fixture()
def direct_server(direct_server_with_data):
return direct_server_with_data["url"]
def _write_phase3_widget_smoke_extension(data_dir: pathlib.Path) -> str:
"""Install an exact-hash reviewed extension for the real Widgets flow."""
from ouroboros.skill_loader import (
SkillReviewState,
compute_content_hash,
save_review_state,
)
name = "phase3_widget_smoke"
skill_dir = data_dir / "skills" / "external" / name
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
textwrap.dedent(
f"""\
---
name: {name}
description: Isolated declarative widget visual smoke.
version: 0.1.0
type: extension
entry: plugin.py
permissions: ["route", "widget", "ws_handler"]
---
# Phase 3 widget smoke
"""
),
encoding="utf-8",
)
(skill_dir / "plugin.py").write_text(
textwrap.dedent(
"""\
import asyncio
_STATE = {
"metric": 87.5,
"message": "The nested form completed successfully.",
"rows": [
{"label": "Safe reference", "url": "https://example.com/report", "status": "ready"},
{"label": "Unsafe reference", "url": "javascript:alert(1)", "status": "blocked"},
],
"chart": {
"labels": ["Warm", "Gap", "Hot"],
"datasets": [{"label": "Hit rate", "data": [74, None, 91]}],
},
"long_json": {"token": "x" * 600},
"cards": [
{"id": "card-1", "label": "Inspect visual evidence", "column": "todo"},
{"id": "card-2", "label": "Ship reviewed flow", "column": "done"},
],
}
def _snapshot():
return {
**_STATE,
"rows": [dict(row) for row in _STATE["rows"]],
"chart": {
"labels": list(_STATE["chart"]["labels"]),
"datasets": [
{"label": item["label"], "data": list(item["data"])}
for item in _STATE["chart"]["datasets"]
],
},
"cards": [dict(card) for card in _STATE["cards"]],
}
async def submit(request):
body = await request.json()
await asyncio.sleep(0.25)
_STATE["message"] = f"Submitted {body.get('query') or 'request'} safely."
return _snapshot()
async def move(request):
body = await request.json()
await asyncio.sleep(0.15)
for card in _STATE["cards"]:
if card["id"] == body.get("card_id"):
card["column"] = body.get("column_id")
return _snapshot()
async def save(request):
body = await request.json()
await asyncio.sleep(0.2)
return {"message": f"Saved {body.get('mode') or 'safe'} mode."}
async def tick(request):
await asyncio.sleep(0.4)
data = _STATE["chart"]["datasets"][0]["data"]
data[0] = (data[0] or 0) + 1
return _snapshot()
def register(api):
async def emit_live(_request):
api.send_ws_message("live", {
"count": "42.25",
"progress": 67,
"label": "streaming",
"state": "healthy",
"unknown": {"nested": True},
"nonfinite": "1e999",
"image_src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"gallery_image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"file_src": "/api/extensions/phase3_widget_smoke/live-file?name=report",
})
return {"sent": True}
api.register_route("submit", submit, methods=("POST",))
api.register_route("move", move, methods=("POST",))
api.register_route("save", save, methods=("POST",))
api.register_route("tick", tick, methods=("POST",))
api.register_route("emit-live", emit_live, methods=("POST",))
api.register_ui_tab(
"main",
"Phase 3 design system",
render={
"kind": "declarative",
"schema_version": 1,
"components": [
{
"type": "group",
"id": "operations",
"title": "Operations overview",
"description": "Host-owned composition with stable nested identity.",
"layout": "grid",
"columns": 2,
"components": [
{"type": "metric", "id": "hit-rate", "label": "Cache hit", "path": "metric", "unit": "%", "precision": 1, "tone": "success"},
{"type": "callout", "id": "result-callout", "path": "message", "tone": "warning"},
{
"type": "tabs",
"id": "flows",
"tabs": [
{
"label": "Submit",
"components": [
{
"type": "form",
"id": "query-form",
"title": "Nested request",
"route": "submit",
"method": "POST",
"columns": 2,
"submit_label": "Run request",
"busy_label": "Running…",
"fields": [
{"name": "query", "label": "Query", "placeholder": "Ada", "help": "Rendered and escaped by the host.", "span": 2, "required": True},
{"name": "limit", "label": "Limit", "type": "number", "min": 1, "max": 10, "step": 1, "default": 3},
{"name": "secret", "label": "Ephemeral secret", "type": "password", "placeholder": "not persisted"},
],
}
],
},
{
"label": "Data",
"components": [
{
"type": "table",
"id": "result-table",
"path": "rows",
"columns": [
{"label": "Reference", "path": "label", "presentation": "link", "href_path": "url"},
{"label": "Status", "path": "status", "presentation": "status"},
],
},
{"type": "chart", "id": "gap-chart", "path": "chart", "chart_type": "line", "unit": "%", "aria_label": "Cache hit rate with an intentional gap"},
{"type": "status", "id": "poll-status", "loading": "Loading data"},
{"type": "poll", "id": "chart-poll", "route": "tick", "method": "POST", "interval_ms": 1000, "max_ticks": 3, "label": "Refresh chart"},
],
},
],
},
],
},
{
"type": "subscription",
"id": "live-subscription",
"event": "live",
"target": "live",
"render": [
{
"type": "group",
"id": "live-group",
"title": "Live telemetry",
"layout": "grid",
"columns": 2,
"components": [
{"type": "metric", "id": "live-direct-count", "label": "Direct count", "path": "count", "precision": 1},
{
"type": "tabs",
"id": "live-tabs",
"tabs": [
{
"label": "Stream",
"components": [
{"type": "metric", "id": "live-tab-count", "label": "Tab count", "path": "count", "precision": 1},
{"type": "metric", "id": "live-state", "label": "State", "path": "state"},
{"type": "metric", "id": "live-unknown", "label": "Unknown", "path": "unknown"},
{"type": "metric", "id": "live-nonfinite", "label": "Non-finite", "path": "nonfinite"},
{"type": "progress", "id": "live-progress", "path": "progress", "label_key": "label"},
],
},
{
"label": "Media",
"components": [
{"type": "image", "id": "live-image", "path": "image_src", "label": "Live image", "alt": "Live image"},
{"type": "file", "id": "live-file", "path": "file_src", "label": "Live file", "filename": "live-report.txt"},
{
"type": "gallery",
"id": "live-gallery",
"items": [
{"type": "image", "path": "gallery_image", "label": "Gallery image", "alt": "Gallery image"}
],
},
],
},
],
},
],
}
],
},
{
"type": "markdown",
"id": "notes",
"text": "### Notes\\n\\n- first bullet with an unbroken token abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789\\n- second bullet\\n\\n1. ordered item one\\n2. ordered item two",
},
{"type": "json", "id": "long-json", "path": "long_json", "label": "Long JSON"},
{
"type": "kanban",
"id": "delivery-board",
"path": "cards",
"columns": [
{"id": "todo", "label": "To do"},
{"id": "done", "label": "Done"},
],
"on_move": {"route": "move", "method": "POST"},
},
],
},
)
api.register_settings_section(
"config",
"Phase 3 settings",
schema={
"components": [
{
"type": "form",
"id": "settings-form",
"route": "save",
"method": "POST",
"submit_label": "Save mode",
"busy_label": "Saving mode…",
"fields": [
{"name": "mode", "label": "Mode", "type": "select", "default": "safe", "options": [{"label": "Safe", "value": "safe"}, {"label": "Fast", "value": "fast"}]},
{"name": "token", "label": "Temporary token", "type": "password", "placeholder": "not persisted"},
],
}
]
},
)
"""
),
encoding="utf-8",
)
content_hash = compute_content_hash(skill_dir, manifest_entry="plugin.py")
save_review_state(
data_dir,
name,
SkillReviewState(status="pass", content_hash=content_hash),
)
return name
@pytest.mark.ui_browser
def test_ui_smoke_phase3_declarative_widgets_and_settings(direct_server_with_data):
"""Exercise the real reviewed-extension consumer flow for schema v1."""
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
url = direct_server_with_data["url"]
data_dir = direct_server_with_data["data_dir"]
skill = _write_phase3_widget_smoke_extension(data_dir)
evidence_dir = pathlib.Path(
os.environ.get("OUROBOROS_UI_EVIDENCE_DIR", str(data_dir.parent))
)
evidence_dir.mkdir(parents=True, exist_ok=True)
try:
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1440, "height": 1000})
try:
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
toggled = page.evaluate(
"""async (skill) => {
const response = await fetch(`/api/skills/${encodeURIComponent(skill)}/toggle`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({enabled: true}),
});
return {status: response.status, body: await response.json()};
}""",
skill,
)
assert toggled["status"] == 200, toggled
assert toggled["body"].get("enabled") is True, toggled
page.click('[data-nav-page="widgets"]')
card = page.locator(f'[data-widget-key="{skill}:main"]')
card.wait_for(state="visible", timeout=30_000)
assert "Operations overview" in card.inner_text()
cache_metric = card.locator('.widget-metric').filter(has_text="Cache hit")
assert cache_metric.locator('strong').inner_text() == "—"
form = card.locator('[data-widget-form="id:query-form"]')
form.locator('input[name="query"]').fill("Ada")
form.locator('input[name="limit"]').fill("4")
form.locator('input[name="secret"]').fill("ephemeral")
submit = form.locator('button[type="submit"]')
submit.click()
page.wait_for_function(
"() => document.querySelector('[data-widget-form=\"id:query-form\"] button')?.disabled === true"
)
assert submit.inner_text() == "Running…"
page.wait_for_function(
"() => document.querySelector('[data-widget-form=\"id:query-form\"] button')?.disabled === false",
timeout=10_000,
)
assert "Submitted Ada safely." in card.inner_text()
assert "87.5 %" in cache_metric.locator('strong').inner_text()
metric = cache_metric
callout = card.locator('.widget-callout')
assert metric.get_attribute("data-tone") == "ok"
assert callout.get_attribute("data-tone") == "warn"
assert metric.evaluate("element => getComputedStyle(element).borderLeftColor") == "rgb(110, 231, 183)"
assert callout.evaluate("element => getComputedStyle(element).borderLeftColor") == "rgb(252, 211, 77)"
emitted = page.evaluate(
"""async (skill) => {
const response = await fetch(`/api/extensions/${encodeURIComponent(skill)}/emit-live`, {method: 'POST'});
return {status: response.status, body: await response.json()};
}""",
skill,
)
assert emitted == {"status": 200, "body": {"sent": True}}
page.wait_for_function(
"""() => {
const live = document.querySelector('.widget-subscription-render');
if (!live) return false;
const metrics = [...live.querySelectorAll('.widget-metric')];
const value = (label) => metrics.find((item) => item.querySelector('span')?.textContent === label)?.querySelector('strong')?.textContent.trim();
return value('Direct count') === '42.3'
&& value('Tab count') === '42.3'
&& value('State') === 'healthy'
&& value('Unknown') === '—'
&& value('Non-finite') === '—'
&& live.querySelector('.widget-progress span')?.textContent.includes('67% · streaming');
}""",
timeout=10_000,
)
live = card.locator('.widget-subscription-render')
live.get_by_role("button", name="Media").click()
live_image = live.locator('img[alt="Live image"]')
gallery_image = live.locator('img[alt="Gallery image"]')
live_image.wait_for(state="visible", timeout=5_000)
gallery_image.wait_for(state="visible", timeout=5_000)
assert live_image.get_attribute("src").startswith("data:image/png;base64,")
assert gallery_image.get_attribute("src").startswith("data:image/png;base64,")
assert live.get_by_role("button", name="Live file").get_attribute("data-widget-download-url") == "/api/extensions/phase3_widget_smoke/live-file?name=report"
live.get_by_role("button", name="Stream").click()
card.get_by_role("button", name="Data").click()
chart = card.locator('[data-widget-chart-key="id:gap-chart"]')
chart.wait_for(state="visible", timeout=5_000)
canvas_box = chart.bounding_box()
assert canvas_box and 250 <= canvas_box["height"] <= 370, canvas_box
chart_config = json.loads(chart.get_attribute("data-widget-chart-config"))
assert chart_config["data"]["datasets"][0]["data"] == [74, None, 91]
assert chart_config["data"]["datasets"][0]["spanGaps"] is False
assert chart_config["options"]["spanGaps"] is False
assert chart.get_attribute("aria-label") == "Cache hit rate with an intentional gap"
# Poll refetch adopts the live canvas and preserves stale content.
chart.evaluate("el => { el.__adoptMarker = 42; }")
first_point = chart_config["data"]["datasets"][0]["data"][0]
card.get_by_role("button", name="Refresh chart").click()
page.wait_for_function(
"""() => document.querySelector('.widget-status')?.dataset.state === 'refreshing'""",
timeout=5_000,
)
assert card.locator('.widget-status').inner_text() == "Loading data" # declared loading label reused
assert card.locator('canvas[data-widget-chart-key="id:gap-chart"]').count() == 1 # content kept during refetch
# The fixture declares max_ticks=3. Wait for its final value so
# no scheduled renderAll can detach the geometry probes below.
page.wait_for_function(
"""(prev) => {
const el = document.querySelector('canvas[data-widget-chart-key="id:gap-chart"]');
if (!el) return false;
const cfg = JSON.parse(el.dataset.widgetChartConfig || '{}');
return cfg.data?.datasets?.[0]?.data?.[0] >= prev + 3;
}""",
arg=first_point,
timeout=10_000,
)
assert chart.evaluate("el => el.__adoptMarker") == 42 # SAME canvas node — adopted, not recreated
page.wait_for_function(
"""() => document.querySelector('.widget-status')?.dataset.state === 'success'""",
timeout=10_000,
)
table = card.locator('.widget-chart-data table')
table_text = table.text_content() or ""
assert "Gap" in table_text
assert "—" in table_text
unsafe_row = card.locator('.widget-table tbody tr').filter(has_text="Unsafe reference")
assert unsafe_row.locator('a').count() == 0
page.evaluate("window.Chart = undefined")
card.get_by_role("button", name="Submit").click()
card.get_by_role("button", name="Data").click()
fallback = card.locator('.widget-chart-fallback')
fallback.wait_for(state="visible", timeout=5_000)
assert fallback.locator('canvas').count() == 0
assert fallback.locator('details[open] table').count() == 1
move = card.locator('[data-widget-kanban-card="card-1"] [data-widget-kanban-move]')
move.select_option("done")
page.wait_for_function(
"""() => Boolean(
document.querySelector('[data-widget-kanban-col="done"] [data-widget-kanban-card="card-1"]')
|| document.querySelector('.widget-kanban .widget-status[data-state="error"]')
)""",
timeout=10_000,
)
assert card.locator(
'[data-widget-kanban-col="done"] [data-widget-kanban-card="card-1"]'
).count() == 1, card.inner_text()
long_json = card.locator('.widget-json').filter(has_text="Long JSON")
long_json.locator('summary').click()
json_pre = long_json.locator('pre')
json_pre.wait_for(state="visible", timeout=5_000)
assert json_pre.evaluate(
"element => getComputedStyle(element).maxHeight"
) == "360px"
list_box = page.locator('#widgets-list').bounding_box()
card_box = card.bounding_box()
operations_group = card.locator('.widget-group').filter(has_text="Operations overview")
group_box = operations_group.bounding_box()
tabs_box = operations_group.locator('.widget-tabs').bounding_box()
kanban_columns = card.locator('.widget-kanban-col')
todo_box = kanban_columns.nth(0).bounding_box()
done_box = kanban_columns.nth(1).bounding_box()
assert list_box and card_box and group_box and tabs_box
assert todo_box and done_box
assert card_box["width"] >= list_box["width"] * 0.9
markdown_block = card.locator('.widget-markdown.ui-rich-content')
assert markdown_block.locator('li').count() >= 4
first_li_box = markdown_block.locator('li').first.bounding_box()
assert first_li_box and card_box
assert first_li_box["x"] >= card_box["x"]
assert first_li_box["x"] + first_li_box["width"] <= card_box["x"] + card_box["width"] + 1
assert tabs_box["width"] >= group_box["width"] * 0.9
assert abs(todo_box["y"] - done_box["y"]) < 2
assert done_box["x"] > todo_box["x"] + todo_box["width"]
page.screenshot(
path=str(evidence_dir / "phase3-widgets-desktop.png"),
full_page=True,
)
page.set_viewport_size({"width": 430, "height": 932})
page.wait_for_timeout(100)
assert page.evaluate(
"document.documentElement.scrollWidth <= document.documentElement.clientWidth"
)
narrow_card = card.bounding_box()
narrow_columns = card.locator('.widget-kanban-col')
narrow_todo = narrow_columns.nth(0).bounding_box()
narrow_done = narrow_columns.nth(1).bounding_box()
assert narrow_card and narrow_todo and narrow_done
assert narrow_done["y"] > narrow_todo["y"] + narrow_todo["height"]
empty_column = card.locator('.widget-kanban-col.is-empty')
assert empty_column.count() == 1
empty_box = empty_column.bounding_box()
assert empty_box and empty_box["height"] < 56
json_geometry = long_json.evaluate(
"""element => {
const pre = element.querySelector('pre');
const card = element.closest('[data-widget-key]');
return {
cardClient: card.clientWidth,
cardScroll: card.scrollWidth,
jsonClient: element.clientWidth,
jsonScroll: element.scrollWidth,
preClient: pre.clientWidth,
preScroll: pre.scrollWidth,
};
}"""
)
assert json_geometry["cardScroll"] <= json_geometry["cardClient"]
assert json_geometry["jsonScroll"] <= json_geometry["jsonClient"]
assert json_geometry["preScroll"] <= json_geometry["preClient"]
card.screenshot(
path=str(evidence_dir / "phase3-widgets-narrow.png"),
)
page.locator('.widgets-scroll').evaluate(
"element => { element.scrollTop = element.scrollHeight; }"
)
page.wait_for_timeout(100)
page.screenshot(
path=str(evidence_dir / "phase3-widgets-narrow-kanban.png"),
)
page.set_viewport_size({"width": 1440, "height": 1000})
page.wait_for_timeout(100)
page.click('[data-nav-page="settings"]')
page.locator('[data-settings-tab="advanced"]').click()
section = page.locator('.settings-extension-section').filter(
has_text="Phase 3 settings"
)
section.wait_for(state="visible", timeout=30_000)
settings_form = section.locator('[data-extension-settings-form]')
settings_form.locator('select[name="mode"]').select_option("fast")
settings_form.locator('input[name="token"]').fill("discard-me")
save = settings_form.locator('button[type="submit"]')
save.click()
page.wait_for_function(
"() => [...document.querySelectorAll('[data-extension-settings-form] button')].some((button) => button.disabled && button.textContent === 'Saving mode…')"
)
section.locator('[data-extension-settings-status]').filter(
has_text="Saved fast mode."
).wait_for(state="visible", timeout=10_000)
assert save.is_enabled()
page.screenshot(
path=str(evidence_dir / "phase3-settings-desktop.png"),
full_page=True,
)
finally:
browser.close()
except PlaywrightError as exc:
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
pytest.skip(str(exc))
raise
@pytest.mark.ui_browser
def test_ui_smoke_direct_mode_loads_chat_and_dashboard(direct_server):
_run_core_ui_assertions(direct_server)
@pytest.mark.ui_browser
@pytest.mark.parametrize("browser_engine", ["chromium", "webkit"])
def test_ui_smoke_collapsed_activity_line_named_vs_unnamed(
direct_server_with_data,
browser_engine,
):
"""Collapsed root summaries stay compact without destroying full activity."""
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
url = direct_server_with_data["url"]
data_dir = direct_server_with_data["data_dir"]
logs_dir = data_dir / "logs"
logs_dir.mkdir(parents=True, exist_ok=True)
(logs_dir / "chat.jsonl").write_text("", encoding="utf-8")
unique_tail = "UNIQUE_FULL_ACTIVITY_TAIL"
long_activity = (
"Analyzing the dataset and comparing every source. " * 18
+ "https://example.com/" + "unbroken-segment-" * 18 + unique_tail
)
(logs_dir / "progress.jsonl").write_text(
json.dumps({
"ts": "2026-07-29T10:00:00+00:00",
"chat_id": 1,
"task_id": "named-act",
"content": long_activity,
}) + "\n" + json.dumps({
"ts": "2026-07-29T10:00:01+00:00",
"chat_id": 1,
"task_id": "unnamed-act",
"content": "Doing things without a name",
}) + "\n",
encoding="utf-8",
)
task_results = data_dir / "task_results"
task_results.mkdir(parents=True, exist_ok=True)
(task_results / "named-act.json").write_text(json.dumps({
"_schema_version": 1,
"task_id": "named-act",
"status": "completed",
"suggested_name": "Data Analysis",
"cost_usd": 0.42,
"cost_accounting_status": "available",
"cost_final": True,
}) + "\n", encoding="utf-8")
try:
with sync_playwright() as pw:
browser_type = getattr(pw, browser_engine)
try:
browser = browser_type.launch(headless=True)
except PlaywrightError as exc:
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
pytest.fail(f"required Playwright {browser_engine} browser is not installed: {exc}")
raise
try:
for width, height, mobile in [(1440, 1000, False), (390, 844, True)]:
context = browser.new_context(
viewport={"width": width, "height": height},
is_mobile=mobile,
has_touch=mobile,
)
page = context.new_page()
page.add_init_script(f"({_CAPTURE_TEST_SOCKET})()")
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
page.wait_for_function(
"() => window.__testSockets?.some(socket => socket.readyState === WebSocket.OPEN)",
timeout=30_000,
)
named = page.locator('.chat-live-card[data-task-id="named-act"]')
named.wait_for(state="attached", timeout=30_000)
unnamed = page.locator('.chat-live-card[data-task-id="unnamed-act"]')
unnamed.wait_for(state="attached", timeout=30_000)
page.wait_for_function(
"() => document.querySelector('.chat-live-card[data-task-id=\"named-act\"]"
" [data-live-title]')?.textContent === 'Data Analysis'",
timeout=30_000,
)
_emit_ws_frame(page, {
"type": "chat", "role": "assistant", "is_progress": True,
"chat_id": 1, "task_id": "running-act", "suggested_name": "Short task",
"content": "Short update", "ts": "2026-07-29T10:00:02+00:00",
})
running = page.locator('.chat-live-card[data-task-id="running-act"]')
running.wait_for(state="attached", timeout=30_000)
assert running.locator('[data-live-title]').text_content().strip() == "Short task"
assert named.get_attribute("data-expanded") == "0"
named_activity = named.locator('[data-live-activity]')
activity_text = named_activity.text_content().strip()
assert activity_text
assert len(activity_text) <= 240
assert activity_text.endswith(("…", "..."))
assert unique_tail not in activity_text
assert named_activity.get_attribute("title") is None
geometry = named.evaluate(
"""card => {
const facts = selector => {
const el = card.querySelector(selector);
const style = getComputedStyle(el);
const lineHeight = parseFloat(style.lineHeight);
const rect = el.getBoundingClientRect();
return { lines: rect.height / lineHeight, width: rect.width };
};
return {
title: facts('[data-live-title]'),
activity: facts('[data-live-activity]'),
clientWidth: card.clientWidth,
scrollWidth: card.scrollWidth,
};
}"""
)
assert geometry["title"]["lines"] <= 2.2, geometry
assert geometry["activity"]["lines"] <= 3.2, geometry
assert geometry["scrollWidth"] <= geometry["clientWidth"] + 1, geometry
named_meta = named.locator('[data-live-meta]').inner_text().split()
# Final ledger: the plain amount, never the open-ledger ceiling.
assert "$0.42" in named_meta and "up" not in named_meta, named_meta
unnamed_activity = unnamed.locator('[data-live-activity]')
assert "Doing things without a name" in unnamed.locator('[data-live-title]').text_content()
assert unnamed_activity.text_content().strip() == ""
assert not unnamed_activity.is_visible()
bands = page.evaluate(
"""() => Object.fromEntries(['named-act', 'unnamed-act', 'running-act'].map(id => {
const card = document.querySelector(`.chat-live-card[data-task-id="${id}"]`);
const facts = selector => {
const node = card.querySelector(selector);
const style = getComputedStyle(node);
const height = node.getBoundingClientRect().height;
const lineHeight = parseFloat(style.lineHeight);
return {height, lines: height / lineHeight,
display: style.display, visibility: style.visibility};
};
return [id, {
title: facts('[data-live-title]'),
activity: facts('[data-live-activity]'),
meta: facts('[data-live-meta]'),
finished: card.dataset.finished === '1',
clipped: card.scrollHeight > card.clientHeight + 1,
reviews: card.querySelector('[data-live-review-summary]')?.textContent || '',
}];
}))"""
)
assert bands["named-act"]["finished"] is True, bands
assert bands["unnamed-act"]["finished"] is False, bands
assert bands["running-act"]["finished"] is False, bands
assert all(0.9 <= bands[task_id]["title"]["lines"] <= 1.2 for task_id in bands), bands
assert 2.9 <= bands["named-act"]["activity"]["lines"] <= 3.2, bands
assert 0.9 <= bands["running-act"]["activity"]["lines"] <= 1.2, bands
assert bands["unnamed-act"]["activity"]["display"] == "none", bands
# Useful root activity sizes naturally up to three lines; empty activity
# reserves no band on either running or finished cards.
_emit_ws_frame(page, {
"type": "chat", "role": "assistant", "is_progress": True,
"chat_id": 1, "task_id": "done-empty", "suggested_name": "Quick task",
"content": "", "ts": "2026-07-29T10:00:03+00:00",
})
done_empty = page.locator('.chat-live-card[data-task-id="done-empty"]')
done_empty.wait_for(state="attached", timeout=30_000)
_emit_ws_frame(page, {
"type": "chat", "role": "system", "system_type": "task_summary",
"chat_id": 1, "task_id": "done-empty", "content": "Done",
"ts": "2026-07-29T10:00:04+00:00",
})
page.wait_for_function(
"() => document.querySelector('.chat-live-card[data-task-id=\"done-empty\"]')"
"?.dataset.finished === '1'",
timeout=30_000,
)
fold = done_empty.evaluate(
"""card => { const node = card.querySelector('[data-live-activity]');
return {text: node.textContent, display: getComputedStyle(node).display,
height: card.getBoundingClientRect().height}; }"""
)
assert fold["text"].strip() == "", fold
assert fold["display"] == "none", fold
running_height = running.evaluate("el => el.getBoundingClientRect().height")
assert fold["height"] <= running_height - 20, (fold, running_height)
assert all(bands[task_id]["meta"]["lines"] >= 0.9 for task_id in bands), bands
button_height = "el => el.getBoundingClientRect().height"
before_reviews = running.locator(":scope > [data-live-summary-button]").evaluate(button_height)
# A root card's acceptance evidence rides the log channel (task detail seam).
_emit_ws_frame(page, {"type": "log", "chat_id": 1, "data": {
"type": "task_metrics_event", "task_id": "running-act",
"ts": "2026-07-29T10:00:03+00:00",
"review_projection": {"panels": [{
"panel_id": "act-review", "surface": "task_acceptance",
"aggregate_signal": "PASS", "reason": "smoke", "actors": [],
}]},
}})
page.wait_for_function(
"() => document.querySelector('.chat-live-card[data-task-id=\"running-act\"]"
" [data-live-review-summary]')?.textContent === 'Reviews 1'",
timeout=10_000,
)
row = running.evaluate(
"""card => {
const btn = card.querySelector(':scope > [data-live-summary-button]');
const meta = btn.querySelector('[data-live-meta]').getBoundingClientRect();
const review = btn.querySelector('[data-live-review-summary]').getBoundingClientRect();
return {height: btn.getBoundingClientRect().height, metaTop: meta.top,
reviewTop: review.top, reviewRight: review.right,
buttonRight: btn.getBoundingClientRect().right};
}"""
)
# The quiet count shares the metadata row, docked right, without a new row.
assert abs(row["reviewTop"] - row["metaTop"]) <= 2, row
assert row["buttonRight"] - row["reviewRight"] <= 20, row
assert abs(row["height"] - before_reviews) <= 1, row
assert all(not bands[task_id]["clipped"] for task_id in bands), bands
assert all("Reviews" not in bands[task_id]["reviews"] for task_id in bands), bands
named.locator(':scope > [data-live-summary-button]').click()
line_toggle = named.locator(':scope > [data-live-timeline] .chat-live-line-toggle').first
line_toggle.wait_for(state="visible", timeout=5_000)
line_toggle.click()
page.wait_for_function(
"tail => document.querySelector('.chat-live-card[data-task-id=\"named-act\"]')"
".innerText.includes(tail)",
arg=unique_tail,
)
page.screenshot(
path=str(data_dir.parent / f"compact-activity-{browser_engine}-{width}.png"),
full_page=True,
)
context.close()
finally:
browser.close()
except PlaywrightError as exc:
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
pytest.fail(f"required Playwright {browser_engine} browser is not installed: {exc}")
raise
@pytest.mark.ui_browser
@pytest.mark.parametrize("browser_engine", ["chromium", "webkit"])
def test_ui_smoke_live_card_mutations_preserve_viewport(
direct_server_with_data,
browser_engine,
):
from tests.ui_chat_viewport_smoke import run_chat_viewport_smoke
run_chat_viewport_smoke(direct_server_with_data, browser_engine)
@pytest.mark.ui_browser
def test_ui_smoke_chat_chronology_reconnect_and_plain_answer_marker(direct_server_with_data):
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
url = direct_server_with_data["url"]
data_dir = direct_server_with_data["data_dir"]
logs_dir = data_dir / "logs"
logs_dir.mkdir(parents=True, exist_ok=True)
evidence_dir = pathlib.Path(
os.environ.get("OUROBOROS_UI_EVIDENCE_DIR", str(data_dir.parent))
)
evidence_dir.mkdir(parents=True, exist_ok=True)
anchor_summary = {
"ts": "2025-07-18T10:00:03+00:00",
"direction": "system",
"type": "task_summary",
"system_type": "task_summary",
"task_id": "chronology-anchor",
"chat_id": 1,
"text": "Mounted task card whose earliest event will be backfilled.",
"tool_calls": 1,
"rounds": 2,
"outcome_axes": {
"lifecycle": {"status": "completed"},
"execution": {"status": "ok"},
"objective": {"status": "pass"},
"review": {"status": "pass"},
"artifacts": {"status": "ready"},
},
}
t3 = {
"ts": "2025-07-18T10:00:03.200000+00:00",
"direction": "out",
"chat_id": 1,
"text": "Third historical message.\n" + "\n".join(
f"Scrollable historical detail {index}." for index in range(80)
),
"format": "markdown",
}
(logs_dir / "chat.jsonl").write_text(
json.dumps(anchor_summary) + "\n" + json.dumps(t3) + "\n",
encoding="utf-8",
)
try:
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
context = browser.new_context(viewport={"width": 1280, "height": 800})
page = context.new_page()
try:
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
third = page.locator(".chat-bubble", has_text="Third historical message.").first
third.wait_for(state="attached", timeout=30_000)
assert third.is_visible()
mounted_anchor = page.locator(
'.chat-live-card[data-task-id="chronology-anchor"]'
)
mounted_anchor.wait_for(state="attached", timeout=30_000)
assert mounted_anchor.is_visible()
t1 = {
"ts": "2025-07-18T10:00:01+00:00",
"direction": "out",
"chat_id": 1,
"text": "First historical message.\nFINAL ANSWER: 41",
"format": "markdown",
}
t2 = {
"ts": "2025-07-18T10:00:02+00:00",
"direction": "system",
"type": "notice",
"chat_id": 1,
"text": "Second historical system message.\nFINAL ANSWER: 42",
"format": "markdown",
}
disconnected_summary = {
"ts": "2025-07-18T10:00:02.500000+00:00",
"direction": "system",
"type": "task_summary",
"system_type": "task_summary",
"task_id": "chronology-disconnected",
"chat_id": 1,
"text": "Disconnected summary-only card.",
"tool_calls": 1,
"rounds": 2,
"outcome_axes": {
"lifecycle": {"status": "completed"},
"execution": {"status": "ok"},
"objective": {"status": "pass"},
"review": {"status": "pass"},
"artifacts": {"status": "ready"},
},
}
t4 = {
"ts": "2025-07-18T10:00:04+00:00",
"direction": "out",
"chat_id": 1,
"text": "Fourth new message below the reading anchor.",
"format": "markdown",
}
(logs_dir / "chat.jsonl").write_text(
"".join(
json.dumps(row) + "\n"
for row in (anchor_summary, t3, t1, t2, disconnected_summary, t4)
),
encoding="utf-8",
)
(logs_dir / "progress.jsonl").write_text(
json.dumps({
"ts": "2025-07-18T10:00:01.500000+00:00",
"chat_id": 1,
"task_id": "chronology-progress-only",
"content": "Progress-only terminal card.",
}) + "\n" + json.dumps({
"ts": "2025-07-18T10:00:01.750000+00:00",
"chat_id": 1,
"task_id": "chronology-anchor",
"content": "Earlier progress backfilled for the mounted anchor card.",
}) + "\n",
encoding="utf-8",
)
task_results = data_dir / "task_results"
task_results.mkdir(parents=True, exist_ok=True)
(task_results / "chronology-progress-only.json").write_text(json.dumps({
"_schema_version": 1,
"task_id": "chronology-progress-only",
"status": "completed",
"outcome_axes": {
"lifecycle": {"status": "completed"},
"execution": {"status": "ok"},
"objective": {"status": "best_effort"},
"review": {"status": "degraded"},
"artifacts": {"status": "ready"},
},
}) + "\n", encoding="utf-8")
scroll_before = page.evaluate(
"""() => {
const messages = document.querySelector('#chat-messages');
const anchor = messages.querySelector(
'.chat-live-card[data-task-id="chronology-anchor"]'
);
messages.scrollTop = Math.max(1, anchor.offsetTop - 40);
return {
top: messages.scrollTop,
height: messages.scrollHeight,
remaining: messages.scrollHeight - messages.scrollTop - messages.clientHeight,
anchorTop: anchor?.getBoundingClientRect().top,
};
}"""
)
assert scroll_before["top"] > 0
assert scroll_before["remaining"] > 160
direct_server_with_data["restart_server"]()
page.wait_for_function(
"() => [...document.querySelectorAll('.chat-bubble.system')]"
".some((node) => node.textContent.includes('Reconnected'))",
timeout=20_000,
)
page.wait_for_selector(
'.chat-live-card[data-task-id="chronology-progress-only"][data-finished="1"]',
timeout=30_000,
)
page.wait_for_selector(
'.chat-live-card[data-task-id="chronology-disconnected"][data-finished="1"]',
timeout=30_000,
)
state = page.evaluate(
"""() => [...document.querySelector('#chat-messages').children]
.filter((node) => !node.classList.contains('typing-bubble')
&& !node.textContent.includes('Reconnected'))
.map((node) => ({
text: node.textContent,
ts: node.dataset.ts || '',
card: node.classList.contains('chat-live-card'),
taskId: node.dataset.taskId || '',
}))"""
)
assert [item["card"] for item in state] == [
False, True, True, False, True, False, False,
]
assert "First historical message." in state[0]["text"]
assert "Progress-only terminal card." in state[1]["text"]
assert state[2]["taskId"] == "chronology-anchor"
assert "Earlier progress backfilled" in state[2]["text"]
assert "Second historical system message." in state[3]["text"]
assert state[4]["taskId"] == "chronology-disconnected"
assert "Third historical message." in state[5]["text"]
assert "Fourth new message below the reading anchor." in state[6]["text"]
assert all(item["ts"].isdigit() for item in state)
assert page.locator(".final-answer-chip").count() == 0
assert "FINAL ANSWER: 41" in page.locator("#chat-messages").inner_text()
assert "FINAL ANSWER: 42" in page.locator("#chat-messages").inner_text()
assert "2025" in page.locator(
'.chat-live-card[data-task-id="chronology-progress-only"]'
).inner_text()
scroll_after = page.evaluate(
"""() => {
const messages = document.querySelector('#chat-messages');
const anchor = messages.querySelector(
'.chat-live-card[data-task-id="chronology-anchor"]'
);
return {
top: messages.scrollTop,
height: messages.scrollHeight,
anchorTop: anchor?.getBoundingClientRect().top,
};
}"""
)
assert abs(scroll_after["anchorTop"] - scroll_before["anchorTop"]) <= 6
page.locator("#chat-messages").evaluate("(messages) => { messages.scrollTop = 0; }")
page.screenshot(
path=str(evidence_dir / "phase3-chat-chronology-desktop.png"),
full_page=True,
)
page.set_viewport_size({"width": 390, "height": 844})
page.keyboard.press("Escape")
page.wait_for_selector("#primary-sidebar:not(.open)", timeout=5_000)
backdrop = page.locator(".nav-drawer-backdrop")
backdrop.wait_for(state="attached", timeout=5_000)
assert backdrop.is_hidden()
page.wait_for_timeout(250)
page.locator("#chat-messages").evaluate("(messages) => { messages.scrollTop = 0; }")
narrow_top_geometry = page.evaluate(
"""() => {
const header = document.querySelector('.chat-page-header');
const first = document.querySelector('#chat-messages > :not(.typing-bubble)');
return {
headerBottom: header?.getBoundingClientRect().bottom,
firstTop: first?.getBoundingClientRect().top,
};
}"""
)
assert narrow_top_geometry["firstTop"] >= narrow_top_geometry["headerBottom"] - 2
page.screenshot(
path=str(evidence_dir / "phase3-chat-chronology-narrow.png"),
full_page=True,
)
page.goto(f"{url}/?_ouro_reason=sha-change", wait_until="domcontentloaded", timeout=30_000)
page.get_by_text("Restart complete").wait_for(state="visible", timeout=30_000)
first = page.locator(".chat-bubble", has_text="First historical message.").first
first.wait_for(state="attached", timeout=30_000)
assert first.is_visible()
assert page.locator(".final-answer-chip").count() == 0
page.screenshot(
path=str(evidence_dir / "phase3-chat-chronology-reload.png"),
full_page=True,
)
finally:
context.close()
browser.close()
except PlaywrightError as exc:
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
pytest.skip(str(exc))
raise
@pytest.mark.ui_browser
def test_ui_smoke_direct_mode_creates_task_with_mock_provider(direct_server):
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
try:
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
page = browser.new_page()
try:
page.goto(direct_server, wait_until="domcontentloaded", timeout=30_000)
page.fill("#chat-input", "Respond with exactly OK")
page.click("#chat-send")
page.wait_for_selector(".chat-bubble.assistant", timeout=60_000)
assert "OK" in page.locator("#chat-messages").inner_text(timeout=5_000)
metrics = page.evaluate(
"""() => {
const messages = document.querySelector('#chat-messages');
const remaining = messages.scrollHeight - messages.scrollTop - messages.clientHeight;
return {
scrollTop: messages.scrollTop,
scrollHeight: messages.scrollHeight,
clientHeight: messages.clientHeight,
remaining,
};
}"""
)
assert metrics["remaining"] <= 4, metrics
finally:
browser.close()
except PlaywrightError as exc:
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
pytest.skip(str(exc))
raise
@pytest.mark.ui_browser
def test_ui_smoke_direct_mode_nests_subagent_child_cards(direct_server_with_data):
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
url = direct_server_with_data["url"]
data_dir = direct_server_with_data["data_dir"]
logs_dir = data_dir / "logs"
logs_dir.mkdir(parents=True, exist_ok=True)
child_review_projection = {
"panels": [{
"panel_id": "panel_child_review",
"surface": "task_acceptance",
"authority": "host_root",
"aggregate_signal": "DEGRADED",
"transport_status": "success",
"parse_status": "valid",
"quorum": {"required": 1, "contributed": 0, "configured": 1},
"enforcement_impact": "degrades_completion",
"reason": "Child evidence was incomplete.",
"actors": [{
"slot_id": "child_actor",
"actor_role": "task acceptance",
"provider": "openrouter",
"model": "anthropic/claude-fable-5",
"transport_status": "success",
"parse_status": "valid",
"semantic_verdict": "DEGRADED",
"quorum_contribution": False,
"enforcement_impact": "abstains",
"reason": "Missing child visual evidence.",
}],
}],
}
child_outcome_axes = {
"lifecycle": {"status": "completed"},
"execution": {"status": "ok"},
"objective": {"status": "best_effort"},
"review": {"status": "degraded"},
"artifacts": {"status": "ready"},
}
child_activity_tail = "UNIQUE_CHILD_ACTIVITY_TAIL"
child_activity_early = "UNIQUE_CHILD_ACTIVITY_EARLY"
child_activity = (
child_activity_early + " Searching evidence across repositories.\n"
+ "Comparing every source and preserving the complete routed narration. " * 14
+ "\nhttps://example.com/" + "child-evidence-segment-" * 14 + child_activity_tail
)
rows = [
{
"ts": "2026-05-25T10:00:00+00:00",
"chat_id": 1,
"task_id": "parent1",
"content": "Parent task started",
"is_progress": True,
},
{
"ts": "2026-05-25T10:00:01+00:00",
"chat_id": 1,
"task_id": "child1",
"content": "Scheduled subagent child1",
"is_progress": True,
"delegation_role": "subagent",
"subagent_event": "scheduled",
"subagent_task_id": "child1",
"parent_task_id": "parent1",
"root_task_id": "parent1",
"subagent_role": "researcher",
},
{
# Real rejection carrier shape: task_id addresses the parent chat,
# while subagent_task_id is the child presentation identity.
"ts": "2026-05-25T10:00:01.500000+00:00",
"chat_id": 1,
"task_id": "parent1",
"content": "Rejected child should stay a child",
"is_progress": True,
"delegation_role": "subagent",
"subagent_event": "rejected",
"subagent_task_id": "rejected1",
"parent_task_id": "parent1",
"root_task_id": "parent1",
"subagent_role": "rejected-reader",
"status": "rejected_duplicate",
"error": "Active-subagent cap rejected this child",
},
{
"ts": "2026-05-25T10:00:02+00:00",
"chat_id": 1,
"task_id": "child1",
"content": "Subagent child1 running",
"is_progress": True,
"delegation_role": "subagent",
"subagent_event": "running",
"subagent_task_id": "child1",
"parent_task_id": "parent1",
"root_task_id": "parent1",
"subagent_role": "researcher",
"status": "running",
},
{
"ts": "2026-05-25T10:00:02.500000+00:00",
"chat_id": 1,
"task_id": "child1",
"content": child_activity,
"is_progress": True,
"delegation_role": "subagent",
"subagent_event": "progress",
"subagent_task_id": "child1",
"parent_task_id": "parent1",
"root_task_id": "parent1",
"subagent_role": "researcher",
"status": "running",
},
{
"ts": "2026-05-25T10:00:03+00:00",
"chat_id": 1,
"task_id": "child1",
"content": "Subagent child1 completed",
"is_progress": True,
"delegation_role": "subagent",
"subagent_event": "completed",
"subagent_task_id": "child1",
"parent_task_id": "parent1",
"root_task_id": "parent1",
"subagent_role": "researcher",
"status": "completed",
"cost_usd": 0.125,
"result": "Child result with evidence table\n| source | verdict |\n| A | pass |",
"trace_summary": "searched sources\ncompared output",
"outcome_axes": child_outcome_axes,
"reason_code": "acceptance_degraded",
"review_projection": child_review_projection,
},
{
"ts": "2026-05-25T10:00:03.100000+00:00",
"chat_id": 1,
"task_id": "grandchild1",
"content": "Scheduled nested subagent grandchild1",
"is_progress": True,
"delegation_role": "subagent",
"subagent_event": "scheduled",
"subagent_task_id": "grandchild1",
"parent_task_id": "child1",
"root_task_id": "parent1",
"subagent_role": "evidence-mapper",
},
{
"ts": "2026-05-25T10:00:03.200000+00:00",
"chat_id": 1,
"task_id": "grandchild1",
"content": "Nested subagent grandchild1 completed",
"is_progress": True,
"delegation_role": "subagent",
"subagent_event": "completed",
"subagent_task_id": "grandchild1",
"parent_task_id": "child1",
"root_task_id": "parent1",
"subagent_role": "evidence-mapper",
"status": "completed",
"result": "Nested evidence result",
},
]
(logs_dir / "progress.jsonl").write_text(
"".join(json.dumps(row) + "\n" for row in rows),
encoding="utf-8",
)
(logs_dir / "chat.jsonl").write_text(
json.dumps({
"ts": "2026-05-25T10:00:03.500000+00:00",
"chat_id": 1,
"direction": "out",
"task_id": "child1",
"text": "Final child answer should stay inside the child card.",
"format": "markdown",
"delegation_role": "subagent",
"subagent_event": "completed",
"subagent_task_id": "child1",
"parent_task_id": "parent1",
"root_task_id": "parent1",
"subagent_role": "researcher",
}) + "\n",
encoding="utf-8",
)
try:
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1280, "height": 800})
try:
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
page.wait_for_selector(".chat-live-card", state="attached", timeout=30_000)
assert page.locator(".chat-live-card").first.is_visible()
# Subagents render as always-visible child cards nested under
# the parent card. Child completion must not finish the parent.
page.wait_for_function("() => document.querySelectorAll('.chat-live-card').length === 4", timeout=30_000)
page.wait_for_function(
"() => { const p = document.querySelector('.chat-live-card:not(.subagent)');"
" const c = document.querySelector('.chat-live-card.subagent[data-parent-task-id=\"parent1\"]');"
" const g = document.querySelector('.chat-live-card.subagent[data-parent-task-id=\"child1\"]');"
" return !!p && !!c && c.closest('.chat-subagents') && c.parentElement.closest('.chat-live-card') === p"
" && !!g && g.closest('.chat-subagents') && g.parentElement.closest('.chat-live-card') === c"
" && /researcher/.test(c.innerText)"
" && /evidence-mapper/.test(g.innerText); }",
timeout=30_000,
)
parent = page.locator(".chat-live-card:not(.subagent)").first
child = page.locator('.chat-live-card.subagent[data-task-id="child1"]')
rejected_child = page.locator('.chat-live-card.subagent[data-task-id="rejected1"]')
grandchild = page.locator('.chat-live-card.subagent[data-parent-task-id="child1"]').first
parent_ts = int(parent.get_attribute("data-ts"))
child_ts = int(child.get_attribute("data-ts"))
grandchild_ts = int(grandchild.get_attribute("data-ts"))
assert parent_ts < child_ts < grandchild_ts
parent_count = parent.locator(':scope > [data-live-summary-button] [data-live-count]').first
child_count = child.locator(':scope > [data-live-summary-button] [data-live-count]').first
parent_text = parent.inner_text()
child_text = child.inner_text()
assert "Parent task started" in parent_text
assert "2 children" in parent_count.inner_text()
assert "researcher" in child_text and "(child1)" not in child_text
assert "1 child" in child_count.inner_text()
assert "child=child1" not in child_text
assert "role=researcher" not in child_text
assert "panel_child_review" not in child_text
assert "claude-fable-5" not in child_text
assert "verdict=DEGRADED" not in child_text
assert "evidence-mapper" in grandchild.inner_text()
assert child.get_attribute("data-task-id") == "child1"
assert page.locator(
'.chat-live-card[data-task-id="parent1"] > .chat-subagents > '
'.chat-live-card.subagent[data-task-id="child1"]'
).count() == 1
assert page.locator(
'.chat-live-card.subagent[data-task-id="child1"] > .chat-subagents > '
'.chat-live-card.subagent[data-task-id="grandchild1"]'
).count() == 1
assert page.locator("#chat-messages > .chat-live-card.subagent").count() == 0
assert parent.get_attribute("data-finished") == "0"
assert rejected_child.get_attribute("data-finished") == "1"
assert rejected_child.locator(
":scope > [data-live-summary-button] [data-live-phase]"
).get_attribute("data-phase") == "warn"
assert child.get_attribute("data-finished") == "1"
assert child.locator(":scope > [data-live-summary-button] [data-live-phase]").first.get_attribute("data-phase") == "warn"
assert child.get_attribute("data-subagent-role") == "researcher"
child_activity_el = child.locator(":scope > [data-live-summary-button] [data-live-activity]")
assert len(child_activity_el.text_content().strip()) <= 240
assert child_activity_tail not in child_activity_el.text_content()
assert child_activity_el.get_attribute("title") is None
assert grandchild.get_attribute("data-finished") == "1"
assert grandchild.get_attribute("data-subagent-role") == "evidence-mapper"
assert page.locator(".chat-bubble.progress").count() == 0
assert page.locator(".chat-bubble").filter(
has_text="Final child answer should stay inside the child card."
).count() == 0
assert child.get_attribute("data-expanded") == "0"
assert grandchild.get_attribute("data-expanded") == "0"
child_summary = child.locator(":scope > [data-live-summary-button]").first
child_summary.click()
_open_review_checkpoint(child, open_card=False)
assert "panel_child_review" in child.inner_text()
assert "claude-fable-5" in child.inner_text()
assert "verdict=DEGRADED" in child.inner_text()
progress_line = child.locator(".chat-live-line", has_text="Searching evidence").first
progress_toggle = progress_line.locator(".chat-live-line-toggle")
progress_toggle.wait_for(state="visible", timeout=5_000)
progress_toggle.click()
assert child_activity_early in progress_line.inner_text()
assert child_activity_tail in progress_line.inner_text()
result_line = child.locator(".chat-live-line", has_text="Child result with evidence table").first
result_toggle = result_line.locator(".chat-live-line-toggle")
result_toggle.wait_for(state="visible", timeout=5_000)
result_toggle.click()
expanded_text = child.inner_text(timeout=5_000)
assert "Final child answer should stay inside the child card." in expanded_text
assert "Child result with evidence table" in expanded_text
assert "| source | verdict |" in expanded_text
assert "searched sources" in expanded_text
assert "compared output" in expanded_text
assert "done" in expanded_text.lower()
assert "Scheduled subagent child1" not in expanded_text
assert child_summary.get_attribute("aria-expanded") == "true"
assert child.locator("[data-live-timeline]").first.get_attribute("id")
assert result_toggle.get_attribute("aria-controls")
page.reload(wait_until="domcontentloaded", timeout=30_000)
page.wait_for_function("() => document.querySelectorAll('.chat-live-card').length === 4", timeout=30_000)
page.wait_for_function(
"() => { const p = document.querySelector('.chat-live-card:not(.subagent)');"
" const c = document.querySelector('.chat-live-card.subagent[data-parent-task-id=\"parent1\"]');"
" const g = document.querySelector('.chat-live-card.subagent[data-parent-task-id=\"child1\"]');"
" return !!p && !!c && c.closest('.chat-subagents') && c.parentElement.closest('.chat-live-card') === p"
" && !!g && g.closest('.chat-subagents') && g.parentElement.closest('.chat-live-card') === c; }",
timeout=30_000,
)
replay_parent = page.locator(".chat-live-card:not(.subagent)").first
replay_child = page.locator('.chat-live-card.subagent[data-task-id="child1"]')
replay_rejected = page.locator('.chat-live-card.subagent[data-task-id="rejected1"]')
replay_grandchild = page.locator('.chat-live-card.subagent[data-parent-task-id="child1"]').first
assert replay_parent.get_attribute("data-finished") == "0"
assert replay_rejected.get_attribute("data-finished") == "1"
assert replay_rejected.locator(
":scope > [data-live-summary-button] [data-live-phase]"
).get_attribute("data-phase") == "warn"
assert replay_child.get_attribute("data-finished") == "1"
assert replay_child.locator(":scope > [data-live-summary-button] [data-live-phase]").first.get_attribute("data-phase") == "warn"
assert replay_grandchild.get_attribute("data-finished") == "1"
assert replay_child.get_attribute("data-expanded") == "0"
assert replay_grandchild.get_attribute("data-expanded") == "0"
assert "researcher" in replay_child.inner_text()
assert "child=child1" not in replay_child.inner_text()
assert "role=researcher" not in replay_child.inner_text()
assert page.locator(".chat-bubble").filter(
has_text="Final child answer should stay inside the child card."
).count() == 0
_open_review_checkpoint(replay_child)
assert "Final child answer should stay inside the child card." in replay_child.inner_text()
replay_progress = replay_child.locator(".chat-live-line", has_text="Searching evidence").first
replay_progress.locator(".chat-live-line-toggle").click()
assert child_activity_early in replay_progress.inner_text()
assert child_activity_tail in replay_progress.inner_text()
page.wait_for_timeout(900) # cover the routine background history sync
assert replay_child.locator('.chat-live-line-repeat:not([hidden])').count() == 0
page.screenshot(path=str(data_dir.parent / "review-truth-child-reconnect.png"), full_page=True)
assert page.locator(".chat-bubble.progress").count() == 0
assert page.locator(".chat-bubble", has_text="Final child answer should stay inside the child card.").count() == 0
page.evaluate(
"""async () => {
const resp = await fetch('/api/ui/preferences', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nested_subagents_expanded: true }),
});
if (!resp.ok) throw new Error(await resp.text());
}"""
)
page.reload(wait_until="domcontentloaded", timeout=30_000)
page.wait_for_function("() => document.querySelectorAll('.chat-live-card').length === 4", timeout=30_000)
const_pref_check = (
"() => {"
" const c = document.querySelector('.chat-live-card.subagent[data-parent-task-id=\"parent1\"]');"
" const g = document.querySelector('.chat-live-card.subagent[data-parent-task-id=\"child1\"]');"
" return !!c && !!g && c.dataset.expanded === '1' && g.dataset.expanded === '1';"
" }"
)
page.wait_for_function(const_pref_check, timeout=30_000)
finally:
browser.close()
except PlaywrightError as exc:
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
pytest.skip(str(exc))
raise
@pytest.mark.ui_browser
def test_ui_smoke_desktop_composer_chips_above_input_send_inside(direct_server):
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
try:
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1280, "height": 800})
try:
page.goto(direct_server, wait_until="domcontentloaded", timeout=30_000)
page.wait_for_selector("#chat-input", timeout=30_000)
metrics = page.evaluate(
"""() => {
const rect = (selector) => {
const el = document.querySelector(selector);
const r = el.getBoundingClientRect();
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, height: r.height };
};
return {
input: rect('#chat-input'),
toolbar: rect('.chat-toolbar-row'),
send: rect('.chat-send-group'),
sendButton: rect('.chat-send-inline'),
swarm: rect('.chat-swarm'),
contextMode: rect('.chat-context-mode'),
};
}"""
)
# v6.32.0 composer redesign (owner: "чипы правильнее НАД полем ввода"):
# the chips row (Swarm + Low/Max) sits ABOVE the text input...
assert metrics["toolbar"]["bottom"] <= metrics["input"]["top"] + 4, metrics
assert metrics["swarm"]["bottom"] <= metrics["input"]["top"] + 4, metrics
assert metrics["contextMode"]["bottom"] <= metrics["input"]["top"] + 4, metrics
# ...the two chips share that row (aligned tops)...
assert abs(metrics["swarm"]["top"] - metrics["contextMode"]["top"]) <= 2, metrics
# ...and the Send button stays INSIDE the input's vertical band (same text row).
assert metrics["send"]["top"] >= metrics["input"]["top"] - 4, metrics
assert metrics["send"]["bottom"] <= metrics["input"]["bottom"] + 4, metrics
finally:
browser.close()
except PlaywrightError as exc:
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
pytest.skip(str(exc))
raise
@pytest.mark.ui_browser
def test_ui_smoke_mobile_composer_toolbar_does_not_overlap_input(direct_server):
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
try:
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 390, "height": 844}, is_mobile=True, has_touch=True)
try:
page.goto(direct_server, wait_until="domcontentloaded", timeout=30_000)
page.wait_for_selector("#chat-input", timeout=30_000)
metrics = page.evaluate(
"""() => {
const rect = (selector) => {
const el = document.querySelector(selector);
const r = el.getBoundingClientRect();
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, height: r.height };
};
const inputStyle = getComputedStyle(document.querySelector('#chat-input'));
return {
input: rect('#chat-input'),
toolbar: rect('.chat-toolbar-row'),
pills: rect('.chat-composer-pills'),
send: rect('.chat-send-group'),
sendButton: rect('.chat-send-inline'),
swarm: rect('.chat-swarm'),
contextMode: rect('.chat-context-mode'),
paddingRight: inputStyle.paddingRight,
};
}"""
)
# Mobile (390px): chips ride ABOVE the input row, while the input
# shares its row with the attach button (left) and the Send button
# (right). The usable input width is therefore naturally below the
# old desktop-era 300px target; assert it stays usable (>= half the
# viewport) and never runs under the Send button.
assert metrics["input"]["width"] >= 190, metrics
assert metrics["input"]["right"] <= metrics["send"]["left"] + 2, metrics
assert metrics["toolbar"]["bottom"] <= metrics["input"]["top"] + 1, metrics
assert metrics["send"]["top"] >= metrics["input"]["top"] - 1, metrics
assert metrics["send"]["bottom"] <= metrics["input"]["bottom"] + 1, metrics
assert abs(metrics["swarm"]["height"] - metrics["sendButton"]["height"]) <= 1, metrics
assert abs(metrics["contextMode"]["height"] - metrics["sendButton"]["height"]) <= 1, metrics
assert metrics["paddingRight"] != "256px", metrics
finally:
browser.close()
except PlaywrightError as exc:
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
pytest.skip(str(exc))
raise
def _install_controlled_visual_viewport(page, initial_height: int) -> None:
"""Install a deterministic viewport-height signal before application JS.
This exercises Ouroboros's viewport/focus state machine, not a native OS
keyboard. The assertions below separately inspect the rendered drawer.
"""
page.add_init_script(
f"""(() => {{
let height = {int(initial_height)};
const viewport = new EventTarget();
Object.defineProperty(viewport, 'height', {{ get: () => height }});
Object.defineProperty(window, 'visualViewport', {{
configurable: true,
value: viewport,
}});
window.__setTestVisualViewportHeight = (nextHeight) => {{
height = Number(nextHeight);
viewport.dispatchEvent(new Event('resize'));
}};
}})()"""
)
def _mobile_keyboard_drawer_assertions(page, url: str, screenshot_path: pathlib.Path) -> None:
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
page.wait_for_selector("#chat-input", timeout=30_000)
# A transient Telegram/WebView viewport shrink with no focused editable must
# never claim that the software keyboard is open.
page.evaluate("() => window.__setTestVisualViewportHeight(500)")
page.wait_for_timeout(50)
assert not page.locator("body").evaluate("el => el.classList.contains('keyboard-open')")
# Restore the stable app viewport, then prove the same shrink is recognized
# while the composer really owns focus.
page.evaluate("() => window.__setTestVisualViewportHeight(844)")
page.wait_for_timeout(50)
page.focus("#chat-input")
page.evaluate("() => window.__setTestVisualViewportHeight(500)")
page.wait_for_function("() => document.body.classList.contains('keyboard-open')", timeout=5_000)
toggle = page.locator("#page-chat [data-mobile-nav-toggle]")
toggle.click()
page.wait_for_function(
"() => document.body.classList.contains('nav-drawer-open')"
" && !document.body.classList.contains('keyboard-open')"
" && document.activeElement?.id !== 'chat-input'",
timeout=5_000,
)
# Wait for the drawer to actually arrive instead of sleeping past the 180ms
# transform transition: a fixed 220ms budget left ~40ms of margin and lost
# that race on the Linux WebKit runner, which then measured the drawer at its
# closed position (-105% => left -336) and failed. A drawer that never opens
# still fails here, now naming the cause instead of a stale geometry read.
# The predicate is byte-for-byte the one asserted below, so the wait can
# never pass on a value the assertion would reject (a rounded variant let
# left=-1.017 through and failed one line later).
page.wait_for_function(
"() => document.querySelector('#primary-sidebar')"
".getBoundingClientRect().left >= -1",
timeout=5_000,
)
state = page.evaluate(
"""() => {
const sidebar = document.querySelector('#primary-sidebar');
const backdrop = document.querySelector('#nav-drawer-backdrop');
const toggle = document.querySelector('#page-chat [data-mobile-nav-toggle]');
const rect = sidebar.getBoundingClientRect();
return {
bodyOpen: document.body.classList.contains('nav-drawer-open'),
sidebarOpen: sidebar.classList.contains('open'),
sidebarDisplay: getComputedStyle(sidebar).display,
sidebarVisibility: getComputedStyle(sidebar).visibility,
sidebarRect: {left: rect.left, right: rect.right, width: rect.width, height: rect.height},
backdropHidden: backdrop.hidden,
backdropDisplay: getComputedStyle(backdrop).display,
ariaExpanded: toggle.getAttribute('aria-expanded'),
activeId: document.activeElement?.id || '',
keyboardBody: document.body.classList.contains('keyboard-open'),
keyboardRoot: document.documentElement.classList.contains('keyboard-open'),
};
}"""
)
assert state["bodyOpen"] and state["sidebarOpen"], state
assert state["ariaExpanded"] == "true", state
assert not state["backdropHidden"] and state["backdropDisplay"] != "none", state
assert state["sidebarDisplay"] != "none" and state["sidebarVisibility"] != "hidden", state
assert state["sidebarRect"]["width"] > 200 and state["sidebarRect"]["height"] > 400, state
assert state["sidebarRect"]["left"] >= -1 and state["sidebarRect"]["right"] > 0, state
assert state["activeId"] != "chat-input", state
assert not state["keyboardBody"] and not state["keyboardRoot"], state
# The now-visible drawer must still own a vertically scrollable content
# surface even though the keyboard touch lock was active one frame earlier.
scroll = page.evaluate(
"""() => {
const scroller = document.querySelector('#primary-sidebar .sidebar-scroll');
for (let i = 0; i < 60; i += 1) {
const row = document.createElement('button');
row.className = 'nav-row';
row.type = 'button';
row.textContent = `Drawer overflow probe ${i}`;
scroller.appendChild(row);
}
scroller.scrollTop = scroller.scrollHeight;
return {
scrollTop: scroller.scrollTop,
scrollHeight: scroller.scrollHeight,
clientHeight: scroller.clientHeight,
overflowY: getComputedStyle(scroller).overflowY,
};
}"""
)
assert scroll["scrollHeight"] > scroll["clientHeight"], scroll
assert scroll["scrollTop"] > 0, scroll
assert scroll["overflowY"] in {"auto", "scroll"}, scroll
page.screenshot(path=str(screenshot_path), full_page=True)
# Exercise the real backdrop click in the visible strip to the right of the
# 320px drawer, then require all state/ARIA projections to close together.
page.locator("#nav-drawer-backdrop").click(position={"x": 380, "y": 400})
page.wait_for_function(
"() => !document.body.classList.contains('nav-drawer-open')"
" && !document.querySelector('#primary-sidebar').classList.contains('open')"
" && document.querySelector('#nav-drawer-backdrop').hidden"
" && document.querySelector('#page-chat [data-mobile-nav-toggle]').getAttribute('aria-expanded') === 'false'",
timeout=5_000,
)
@pytest.mark.ui_browser
def test_ui_smoke_mobile_keyboard_state_cannot_hide_open_drawer_chromium(direct_server_with_data):
"""Controlled visualViewport state plus real Chromium drawer geometry."""
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
try:
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 390, "height": 844}, is_mobile=True, has_touch=True)
try:
_install_controlled_visual_viewport(page, 844)
_mobile_keyboard_drawer_assertions(
page,
direct_server_with_data["url"],
direct_server_with_data["data_dir"].parent / "mobile-keyboard-drawer-chromium.png",
)
finally:
browser.close()
except PlaywrightError as exc:
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
pytest.skip(str(exc))
raise
@pytest.mark.ui_browser
def test_ui_smoke_mobile_keyboard_state_cannot_hide_open_drawer_webkit(direct_server_with_data):
"""Same controlled state-machine check in WebKit with an iPhone profile."""
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
try:
with sync_playwright() as pw:
iphone = pw.devices.get("iPhone 13")
if not iphone:
pytest.skip("Playwright iPhone 13 device descriptor unavailable")
try:
browser = pw.webkit.launch(headless=True)
except PlaywrightError as exc:
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
pytest.skip(f"Playwright WebKit browser is not installed: {exc}")
raise
context = browser.new_context(**iphone)
page = context.new_page()
try:
# The controller's threshold is driven by our deterministic app
# viewport; the iPhone descriptor still owns rendering/input.
_install_controlled_visual_viewport(page, 844)
_mobile_keyboard_drawer_assertions(
page,
direct_server_with_data["url"],
direct_server_with_data["data_dir"].parent / "mobile-keyboard-drawer-webkit.png",
)
finally:
browser.close()
except PlaywrightError as exc:
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
pytest.skip(str(exc))
raise
@pytest.mark.ui_browser
def test_ui_smoke_direct_mode_chat_scrolls_on_desktop(direct_server):
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
def scroll_metrics(page):
return page.evaluate(
"""() => {
const messages = document.querySelector('#chat-messages');
if (!messages) return null;
messages.scrollTop = 0;
const top = messages.scrollTop;
messages.scrollTop = messages.scrollHeight;
const bottom = messages.scrollTop;
return {
clientHeight: messages.clientHeight,
scrollHeight: messages.scrollHeight,
top,
bottom,
overflowY: getComputedStyle(messages).overflowY,
runtimeVvh: document.getElementById('runtime-vvh')?.textContent || '',
bodyHeight: Math.round(document.body.getBoundingClientRect().height),
windowHeight: window.innerHeight,
};
}"""
)
try:
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1280, "height": 800})
try:
page.goto(direct_server, wait_until="domcontentloaded", timeout=30_000)
page.get_by_role("button", name="Chat").click()
page.wait_for_selector("#chat-messages", timeout=30_000)
# Wait for the initial history rebuild to finish before injecting
# synthetic rows; otherwise that authoritative rebuild may erase
# the probe immediately after insertion on slower startup paths.
page.wait_for_selector("#chat-messages .chat-bubble.assistant", timeout=30_000)
# A viewport change can re-render the chat from the (empty) real
# history and drop injected probe nodes, so injection is a helper
# re-run before every measurement instead of a one-shot setup.
inject_probe_bubbles = """() => {
const messages = document.querySelector('#chat-messages');
messages.replaceChildren();
for (let i = 0; i < 48; i += 1) {
const bubble = document.createElement('div');
bubble.className = 'chat-bubble assistant';
bubble.textContent = `Desktop scroll probe ${i} `.repeat(16);
bubble.style.minHeight = '48px';
messages.appendChild(bubble);
}
}"""
page.evaluate(inject_probe_bubbles)
metrics = scroll_metrics(page)
assert metrics is not None
assert metrics["overflowY"] in {"auto", "scroll"}
assert metrics["scrollHeight"] > metrics["clientHeight"] + 100
assert metrics["bottom"] > metrics["top"] + 100
assert "--vvh:100dvh" in metrics["runtimeVvh"]
assert abs(metrics["bodyHeight"] - metrics["windowHeight"]) <= 2
page.set_viewport_size({"width": 1280, "height": 400})
page.wait_for_timeout(100)
page.set_viewport_size({"width": 1280, "height": 800})
page.wait_for_timeout(100)
page.evaluate(inject_probe_bubbles)
metrics_after_resize = scroll_metrics(page)
assert metrics_after_resize is not None
assert metrics_after_resize["scrollHeight"] > metrics_after_resize["clientHeight"] + 100
assert metrics_after_resize["bottom"] > metrics_after_resize["top"] + 100
assert "--vvh:100dvh" in metrics_after_resize["runtimeVvh"]
assert abs(metrics_after_resize["bodyHeight"] - metrics_after_resize["windowHeight"]) <= 2
finally:
browser.close()
except PlaywrightError as exc:
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
pytest.skip(str(exc))
raise
@pytest.mark.ui_browser
def test_ui_smoke_finished_cards_keep_height_when_transcript_overflows(direct_server):
"""Regression: live cards / skill_review bubbles use overflow:hidden, which
gives them an automatic flex min-height of 0. When the transcript column
overflows they must NOT be shrunk to a 1px strip — the list scrolls instead.
(rc.1 removed the inline min-height that previously masked this collapse.)"""
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
try:
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1280, "height": 600})
try:
page.goto(direct_server, wait_until="domcontentloaded", timeout=30_000)
page.wait_for_selector("#chat-messages", timeout=30_000)
result = page.evaluate(
"""() => {
const messages = document.querySelector('#chat-messages');
messages.replaceChildren();
// Overflow the column with collapsed, overflow:hidden cards.
for (let i = 0; i < 24; i += 1) {
const card = document.createElement('div');
card.className = 'chat-live-card';
card.dataset.finished = '1';
card.dataset.expanded = '0';
const btn = document.createElement('div');
btn.className = 'chat-live-summary-button';
btn.style.minHeight = '48px';
btn.textContent = `Finished card ${i}`;
card.appendChild(btn);
messages.appendChild(card);
}
const heights = [...messages.querySelectorAll('.chat-live-card')]
.map((el) => Math.round(el.getBoundingClientRect().height));
return {
heights,
scrollHeight: messages.scrollHeight,
clientHeight: messages.clientHeight,
};
}"""
)
assert result["heights"], "no cards rendered"
# Without flex-shrink:0 the overflow:hidden cards collapse to ~1px.
assert min(result["heights"]) >= 40, result
# The column should scroll rather than absorb the overflow.
assert result["scrollHeight"] > result["clientHeight"] + 100, result
finally:
browser.close()
except PlaywrightError as exc:
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
pytest.skip(str(exc))
raise
@pytest.mark.ui_browser_docker
def test_ui_smoke_docker_mode_loads_health():
if os.environ.get("OUROBOROS_RUN_DOCKER_UI_SMOKE") != "1":
pytest.skip("set OUROBOROS_RUN_DOCKER_UI_SMOKE=1 to run Docker UI smoke")
image = os.environ.get("OUROBOROS_DOCKER_UI_IMAGE", "ouroboros-web:test")
probe = subprocess.run(["docker", "image", "inspect", image], capture_output=True, text=True, timeout=20)
if probe.returncode != 0:
pytest.skip(f"Docker image missing: {image}")
port = _free_port()
run = subprocess.run(
["docker", "run", "-d", "--rm", "-p", f"{port}:8765", image],
capture_output=True,
text=True,
timeout=30,
)
if run.returncode != 0:
pytest.skip(f"Docker daemon unavailable or container failed: {run.stderr}")
cid = run.stdout.strip()
try:
url = f"http://127.0.0.1:{port}"
_wait_health(url, timeout_sec=45)
_run_docker_ui_assertions(url)
except Exception:
try:
logs = subprocess.run(["docker", "logs", "--tail", "100", cid],
capture_output=True, text=True, timeout=10)
print(f"DOCKER_UI_CONTAINER {cid} logs_exit={logs.returncode}\n"
f"{logs.stdout[-10000:]}\n{logs.stderr[-10000:]}", file=sys.stderr)
except Exception as diagnostic_error:
print(f"DOCKER_UI_CONTAINER logs unavailable: {diagnostic_error}", file=sys.stderr)
raise
finally:
subprocess.run(["docker", "stop", cid], capture_output=True, text=True, timeout=30)
@pytest.mark.ui_browser
@pytest.mark.parametrize("browser_engine", ["chromium", "webkit"])
def test_ui_smoke_live_cards_keep_usable_geometry_at_depth_and_in_project_panel(
direct_server_with_data,
browser_engine,
):
"""Rendered regression for the one-letter-wide nested-card failure.
A real replayed task tree reaches the configured hard depth of ten. The
narrow checks use geometry instead of CSS declarations, then reload to
cover replay. Launcher-default Main and a narrow Project panel prove the
card-local container responds to its actual consumer width rather than the
viewport.
"""
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
from ouroboros.projects_registry import create_project
data_dir = direct_server_with_data["data_dir"]
url = direct_server_with_data["url"]
project = create_project(data_dir, "layout-project", name="Layout Project")
logs_dir = data_dir / "logs"
logs_dir.mkdir(parents=True, exist_ok=True)
rows = [{
"ts": "2026-05-25T10:00:00+00:00",
"chat_id": 1,
"task_id": "layout-root",
"content": "Root task started",
"suggested_name": "Deep nested layout regression",
"is_progress": True,
}]
long_url = "https://example.com/" + "nested-segment-without-breaks-" * 18 + "final"
parent_id = "layout-root"
for depth in range(1, 11):
child_id = f"layout-child-{depth:02d}"
rows.append({
"ts": f"2026-05-25T10:00:{depth:02d}+00:00",
"chat_id": 1,
"task_id": child_id,
"content": f"Depth {depth} subagent completed",
"is_progress": True,
"delegation_role": "subagent",
"subagent_event": "completed",
"subagent_task_id": child_id,
"parent_task_id": parent_id,
"root_task_id": "layout-root",
"subagent_role": "pty-tests",
"model": "google/gemini-3.6-flash",
"status": "completed",
"result": f"Depth {depth} complete. Full evidence: {long_url}",
})
parent_id = child_id
panel_row = {
"ts": "2026-05-25T10:01:00+00:00",
"chat_id": project["chat_id"],
"task_id": "panel-root",
"content": "Inspecting a narrow Project panel with a long unbroken reference " + long_url,
"suggested_name": "Narrow Project panel keeps a usable title column",
"is_progress": True,
}
(logs_dir / "progress.jsonl").write_text(
"".join(json.dumps(row) + "\n" for row in rows),
encoding="utf-8",
)
mobile_geometry = """() => {
const messages = document.querySelector('#page-chat #chat-messages');
const messagesStyle = getComputedStyle(messages);
const usableMessageWidth = messages.clientWidth
- parseFloat(messagesStyle.paddingInlineStart || messagesStyle.paddingLeft || '0')
- parseFloat(messagesStyle.paddingInlineEnd || messagesStyle.paddingRight || '0');
const cards = [...messages.querySelectorAll('.chat-live-card')];
const root = messages.querySelector(':scope > .chat-live-card[data-task-id="layout-root"]');
const deepest = messages.querySelector('.chat-live-card[data-task-id="layout-child-10"]');
const cardFacts = cards.map((card) => {
const title = card.querySelector(':scope > .chat-live-summary-button [data-live-title]');
const activity = card.querySelector(':scope > .chat-live-summary-button [data-live-activity]');
const style = getComputedStyle(title);
const lineHeight = parseFloat(style.lineHeight);
const titleRect = title.getBoundingClientRect();
const activityStyle = getComputedStyle(activity);
const activityLineHeight = parseFloat(activityStyle.lineHeight);
const activityRect = activity.getBoundingClientRect();
const cardMain = card.querySelector(':scope > .chat-live-summary-button .chat-live-summary-main').getBoundingClientRect();
const cardSide = card.querySelector(':scope > .chat-live-summary-button .chat-live-summary-side').getBoundingClientRect();
return {
id: card.dataset.taskId,
collapsedChild: card.classList.contains('subagent') && card.dataset.expanded !== '1',
clientWidth: card.clientWidth,
scrollWidth: card.scrollWidth,
titleWidth: titleRect.width,
titleHeight: titleRect.height,
titleLines: lineHeight > 0 ? titleRect.height / lineHeight : 99,
titleTop: titleRect.top,
mainTop: cardMain.top,
summaryBottom: Math.max(cardMain.bottom, cardSide.bottom),
activityLines: activityLineHeight > 0 ? activityRect.height / activityLineHeight : 99,
activityTitle: activity.getAttribute('title'),
};
});
const main = root.querySelector(':scope > .chat-live-summary-button .chat-live-summary-main').getBoundingClientRect();
const side = root.querySelector(':scope > .chat-live-summary-button .chat-live-summary-side').getBoundingClientRect();
const title = root.querySelector(':scope > .chat-live-summary-button [data-live-title]').getBoundingClientRect();
return {
messageWidth: usableMessageWidth,
rootWidth: root.getBoundingClientRect().width,
deepestWidth: deepest.getBoundingClientRect().width,
rootMainTop: main.top,
rootMainBottom: main.bottom,
rootSideTop: side.top,
rootSideBottom: side.bottom,
rootTitleTop: title.top,
cardFacts,
};
}"""
def assert_mobile_geometry(page):
page.wait_for_selector(
'#page-chat .chat-live-card[data-task-id="layout-root"]',
state="attached",
timeout=30_000,
)
page.wait_for_timeout(500)
rendered_ids = page.locator("#page-chat .chat-live-card").evaluate_all(
"cards => cards.map(card => card.dataset.taskId)"
)
assert len(rendered_ids) == 11, rendered_ids
facts = page.evaluate(mobile_geometry)
assert facts["rootWidth"] >= facts["messageWidth"] * 0.95, facts
assert facts["rootWidth"] - facts["deepestWidth"] <= 40, facts
# Narrow regime: the side controls share the chip row, the title takes its own
# full-width row below both.
# Main and side controls start on one flex row. A terminal projection may
# make the main box zero-height; their shared top still distinguishes it
# from a real wrap, whose row gap would move the side down.
assert abs(facts["rootSideTop"] - facts["rootMainTop"]) <= 1, facts
assert facts["rootTitleTop"] >= max(facts["rootMainBottom"], facts["rootSideBottom"]) - 1, facts
assert all(card["scrollWidth"] <= card["clientWidth"] + 1 for card in facts["cardFacts"]), facts
assert min(card["titleWidth"] for card in facts["cardFacts"]) >= 160, facts
assert 0.9 <= min(card["titleLines"] for card in facts["cardFacts"]), facts
assert max(card["titleLines"] for card in facts["cardFacts"]) <= 2.2, facts
assert max(card["activityLines"] for card in facts["cardFacts"]
if not card["collapsedChild"]) <= 3.2, facts
assert max(card["activityLines"] for card in facts["cardFacts"]
if card["collapsedChild"]) <= 1.2, facts
# #623: a collapsed child keeps its identity row — the title beside the
# chip, the summary at most two bands (the side wraps under only when a
# 160px title cannot share the row), never a third band for the title.
for card in facts["cardFacts"]:
if card["collapsedChild"]:
assert abs(card["titleTop"] - card["mainTop"]) <= 4, card
assert card["summaryBottom"] - card["mainTop"] <= 2.2 * card["titleHeight"] + 8, card
assert all(card["activityTitle"] is None for card in facts["cardFacts"]), facts
deepest = page.locator('.chat-live-card[data-task-id="layout-child-10"]')
assert deepest.locator(':scope > .chat-live-summary-button [data-live-title]').inner_text() == "pty-tests"
assert "Agent model: gemini-3.6-flash" in deepest.locator(
':scope > .chat-live-summary-button [data-live-meta]').inner_text()
deepest.locator(":scope > [data-live-summary-button]").click()
line_toggle = deepest.locator(":scope > [data-live-timeline] .chat-live-line-toggle").last
line_toggle.wait_for(state="visible", timeout=5_000)
line_toggle.click()
expanded = deepest.evaluate(
"""card => {
const line = card.querySelector(':scope > [data-live-timeline] .chat-live-line');
const title = line.querySelector('.chat-live-line-title');
return {
cardClient: card.clientWidth,
cardScroll: card.scrollWidth,
lineClient: line.clientWidth,
lineScroll: line.scrollWidth,
titleWidth: title.getBoundingClientRect().width,
titleScrollWidth: title.scrollWidth,
text: line.innerText,
};
}"""
)
assert expanded["cardScroll"] <= expanded["cardClient"] + 1, expanded
assert expanded["lineScroll"] <= expanded["lineClient"] + 1, expanded
assert expanded["titleWidth"] >= min(expanded["titleScrollWidth"], 150) - 1, expanded
assert long_url in expanded["text"], expanded
def assert_jump_geometry(page, scope_selector, *, require_overflow=True):
page.evaluate(
"""({scopeSelector, requireOverflow}) => {
const scope = document.querySelector(scopeSelector);
const messages = scope
.querySelector('.chat-messages, #chat-messages');
messages.scrollTop = 0;
messages.dispatchEvent(new Event('scroll'));
}""",
{"scopeSelector": scope_selector, "requireOverflow": require_overflow},
)
page.wait_for_timeout(250)
facts = page.evaluate(
"""({scopeSelector, forceVisible}) => {
const scope = document.querySelector(scopeSelector);
const messages = scope.querySelector('.chat-messages, #chat-messages');
const button = scope.querySelector('.chat-scroll-bottom-btn');
const priorTransition = button.style.transition;
button.style.transition = 'none';
if (forceVisible) button.classList.add('visible');
const wrap = scope.querySelector('.chat-input-wrap');
const toolbar = scope.querySelector('.chat-toolbar-row');
const preview = scope.querySelector('.chat-attachment-preview.visible');
const b = button.getBoundingClientRect();
const w = wrap.getBoundingClientRect();
const t = toolbar.getBoundingClientRect();
const overlap = (a, c) => Math.max(0, Math.min(a.right, c.right) - Math.max(a.left, c.left))
* Math.max(0, Math.min(a.bottom, c.bottom) - Math.max(a.top, c.top));
const facts = {
remaining: messages.scrollHeight - messages.scrollTop - messages.clientHeight,
visible: button.classList.contains('visible'),
width: b.width, height: b.height,
centerDelta: Math.abs((b.left + b.width / 2) - (w.left + w.width / 2)),
gap: w.top - b.bottom,
toolbarOverlap: overlap(b, t),
previewOverlap: preview ? overlap(b, preview.getBoundingClientRect()) : 0,
boxShadow: getComputedStyle(button).boxShadow,
};
if (priorTransition) button.style.transition = priorTransition;
else button.style.removeProperty('transition');
return facts;
}""",
{"scopeSelector": scope_selector, "forceVisible": not require_overflow},
)
assert facts["visible"], facts
if require_overflow:
assert facts["remaining"] > 48, facts
assert abs(facts["width"] - 32) <= 1 and abs(facts["height"] - 32) <= 1, facts
assert facts["centerDelta"] <= 1 and abs(facts["gap"] - 8) <= 1, facts
assert facts["toolbarOverlap"] == 0 and facts["previewOverlap"] == 0, facts
assert facts["boxShadow"] == "none", facts
return facts
try:
with sync_playwright() as pw:
browser_type = getattr(pw, browser_engine)
try:
browser = browser_type.launch(headless=True)
except PlaywrightError as exc:
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
pytest.fail(f"required Playwright {browser_engine} browser is not installed: {exc}")
raise
try:
mobile_context = browser.new_context(
viewport={"width": 390, "height": 844},
is_mobile=True,
has_touch=True,
)
mobile = mobile_context.new_page()
mobile.goto(url, wait_until="domcontentloaded", timeout=30_000)
assert_mobile_geometry(mobile)
assert_jump_geometry(mobile, "#page-chat")
mobile.screenshot(
path=str(data_dir.parent / f"live-card-depth-10-{browser_engine}.png"),
full_page=True,
)
mobile.reload(wait_until="domcontentloaded", timeout=30_000)
assert_mobile_geometry(mobile)
mobile_context.close()
wide = browser.new_page(viewport={"width": 1100, "height": 750})
wide.goto(url, wait_until="domcontentloaded", timeout=30_000)
wide.wait_for_selector(
'#page-chat .chat-live-card[data-task-id="layout-root"]',
state="attached",
timeout=30_000,
)
wide.wait_for_timeout(500)
rendered_ids = wide.locator("#page-chat .chat-live-card").evaluate_all(
"cards => cards.map(card => card.dataset.taskId)"
)
assert len(rendered_ids) == 11, rendered_ids
wide_facts = wide.evaluate(
"""() => {
const ids = ['layout-root', 'layout-child-01', 'layout-child-02'];
return ids.map((id) => {
const card = document.querySelector(`#page-chat .chat-live-card[data-task-id="${id}"]`);
const summary = card.querySelector(':scope > .chat-live-summary-button .chat-live-summary');
const main = summary.querySelector('.chat-live-summary-main').getBoundingClientRect();
const side = summary.querySelector('.chat-live-summary-side').getBoundingClientRect();
const rect = card.getBoundingClientRect();
const title = summary.querySelector('.chat-live-title').getBoundingClientRect();
return {
id,
left: rect.left,
width: rect.width,
wrap: getComputedStyle(summary).flexWrap,
mainTop: main.top,
mainBottom: main.bottom,
sideTop: side.top,
sideBottom: side.bottom,
titleTop: title.top,
client: card.clientWidth,
scroll: card.scrollWidth,
};
});
}"""
)
assert wide_facts[1]["left"] - wide_facts[0]["left"] >= 30, wide_facts
assert wide_facts[2]["left"] - wide_facts[1]["left"] >= 30, wide_facts
assert all(card["scroll"] <= card["client"] + 1 for card in wide_facts), wide_facts
for card in wide_facts[:2]:
assert abs(card["mainTop"] - card["sideTop"]) <= 1, wide_facts
# #623: the depth-2 collapsed child is in the 560px container regime
# (wrap allowed) yet keeps ONE identity row: chip, title, side controls.
deep = wide_facts[2]
assert deep["wrap"] == "wrap", wide_facts
assert abs(deep["titleTop"] - deep["mainTop"]) <= 4, wide_facts
assert abs(deep["mainTop"] - deep["sideTop"]) <= 1, wide_facts
# The 620-700px column (laptop with the project panel open): the root
# card takes up to 620px there and keeps its single-row header.
wide.set_viewport_size({"width": 1004, "height": 750})
wide.wait_for_timeout(250)
owner_facts = wide.evaluate(
"""() => {
const card = document.querySelector('#page-chat .chat-live-card[data-task-id="layout-root"]');
const summary = card.querySelector(':scope > .chat-live-summary-button .chat-live-summary');
return {column: document.querySelector('#page-chat #chat-messages').clientWidth,
width: card.getBoundingClientRect().width,
wrap: getComputedStyle(summary).flexWrap};
}"""
)
assert 700 <= owner_facts["column"] <= 740, owner_facts
# 80% of a 700-740px column is below the 620px floor, so the floor wins.
assert abs(owner_facts["width"] - 620) <= 1 and owner_facts["wrap"] == "nowrap", owner_facts
# The width is monotonic across the 620px chatcol breakpoint: against
# its containing block's content width the card is
# min(content, max(80% of content, 620px)) at every column width.
width_formula = (
"""() => {
const card = document.querySelector('#page-chat .chat-live-card[data-task-id="layout-root"]');
const block = card.parentElement;
const style = getComputedStyle(block);
const content = block.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
return {content, width: card.getBoundingClientRect().width};
}"""
)
for viewport_width in (880, 920, 1100):
wide.set_viewport_size({"width": viewport_width, "height": 750})
wide.wait_for_timeout(250)
sample = wide.evaluate(width_formula)
expected = min(sample["content"], max(0.8 * sample["content"], 620))
assert abs(sample["width"] - expected) <= 1, (viewport_width, sample, expected)
wide.set_viewport_size({"width": 1100, "height": 750})
wide.wait_for_timeout(250)
assert_jump_geometry(wide, "#page-chat")
jump = wide.locator("#page-chat .chat-scroll-bottom-btn")
before_hover = jump.bounding_box()
jump.hover()
after_hover = jump.bounding_box()
assert before_hover is not None and after_hover is not None
for key in ("x", "y", "width", "height"):
assert abs(before_hover[key] - after_hover[key]) <= 1, (before_hover, after_hover)
jump.focus()
focus = jump.evaluate(
"node => ({style: getComputedStyle(node).outlineStyle, "
"width: parseFloat(getComputedStyle(node).outlineWidth)})"
)
assert focus["style"] != "none" and focus["width"] >= 2, focus
wide.emulate_media(reduced_motion="reduce")
assert jump.evaluate(
"node => getComputedStyle(node).transitionDuration.split(',')"
".every(value => parseFloat(value) === 0)"
)
wide.emulate_media(reduced_motion="no-preference")
wide.evaluate(
"""() => {
const transfer = new DataTransfer();
transfer.items.add(new File(['preview'], 'preview.txt', {type: 'text/plain'}));
const target = document.querySelector('#page-chat');
for (const type of ['dragenter', 'dragover', 'drop']) {
target.dispatchEvent(new DragEvent(type, {
bubbles: true, cancelable: true, dataTransfer: transfer,
}));
}
}"""
)
wide.locator("#chat-attachment-preview.visible").wait_for(
state="visible", timeout=5_000
)
assert_jump_geometry(wide, "#page-chat")
with (logs_dir / "progress.jsonl").open("a", encoding="utf-8") as handle:
handle.write(json.dumps(panel_row) + "\n")
wide.evaluate(
"() => document.documentElement.style.setProperty('--project-panel-width', '440px')"
)
project_row = wide.locator('[data-project-id="layout-project"]')
project_row.wait_for(state="visible", timeout=30_000)
project_row.click()
panel_card = wide.locator(
'.chat-instance-panel .chat-live-card[data-task-id="panel-root"]'
)
panel_card.wait_for(state="visible", timeout=30_000)
panel_facts = panel_card.evaluate(
"""card => {
const panel = card.closest('.chat-instance-panel');
const summary = card.querySelector(':scope > .chat-live-summary-button .chat-live-summary');
const main = summary.querySelector('.chat-live-summary-main').getBoundingClientRect();
const side = summary.querySelector('.chat-live-summary-side').getBoundingClientRect();
const title = summary.querySelector('[data-live-title]').getBoundingClientRect();
return {
panelWidth: panel.getBoundingClientRect().width,
cardWidth: card.getBoundingClientRect().width,
cardClient: card.clientWidth,
cardScroll: card.scrollWidth,
titleWidth: title.width,
titleTop: title.top,
mainTop: main.top,
mainBottom: main.bottom,
sideTop: side.top,
sideBottom: side.bottom,
};
}"""
)
assert panel_facts["panelWidth"] <= 560, panel_facts
assert panel_facts["cardWidth"] >= panel_facts["panelWidth"] * 0.9, panel_facts
assert panel_facts["cardScroll"] <= panel_facts["cardClient"] + 1, panel_facts
assert panel_facts["titleWidth"] >= 180, panel_facts
assert abs(panel_facts["mainTop"] - panel_facts["sideTop"]) <= 1, panel_facts
assert panel_facts["titleTop"] >= max(panel_facts["mainBottom"], panel_facts["sideBottom"]) - 1, panel_facts
assert_jump_geometry(
wide, "#panel-pchat-layout-project", require_overflow=False
)
wide.screenshot(
path=str(data_dir.parent / f"live-card-project-panel-{browser_engine}.png"),
full_page=True,
)
finally:
browser.close()
except PlaywrightError as exc:
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
pytest.fail(f"required Playwright {browser_engine} browser is not installed: {exc}")
raise
@pytest.mark.ui_browser
def test_ui_smoke_v639_skip_review_button(direct_server_with_data):
# C1: the owner-only "⚠️ Skip review" action is offered for the owner's OWN (external)
# skill and hash-verified official-hub payloads that still need review, and NEVER for
# native/ClawHub/unverified marketplace payloads.
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
data_dir = direct_server_with_data["data_dir"]
url = direct_server_with_data["url"]
manifest = ("---\nname: {n}\ntype: instruction\ndescription: smoke skill\n"
"version: 0.1.0\n---\n# {n}\nDo a thing.\n")
ext = data_dir / "skills" / "external" / "owntool"
ext.mkdir(parents=True, exist_ok=True)
(ext / "SKILL.md").write_text(manifest.format(n="owntool"), encoding="utf-8")
mk = data_dir / "skills" / "clawhub" / "markettool"
mk.mkdir(parents=True, exist_ok=True)
(mk / "SKILL.md").write_text(manifest.format(n="markettool"), encoding="utf-8")
# A real marketplace skill carries clawhub provenance -> resolves to source=clawhub
# (without it, an unprovenanced clawhub-bucket payload is treated as owner-own external).
(mk / ".clawhub.json").write_text(
json.dumps({"slug": "markettool", "version": "0.1.0"}), encoding="utf-8")
# An already owner-attested skill: must show the distinct 'owner-attested' badge.
att = data_dir / "skills" / "external" / "attestedtool"
att.mkdir(parents=True, exist_ok=True)
(att / "SKILL.md").write_text(manifest.format(n="attestedtool"), encoding="utf-8")
att_state = data_dir / "state" / "skills" / "attestedtool"
att_state.mkdir(parents=True, exist_ok=True)
(att_state / "review.json").write_text(json.dumps({
"status": "clean", "content_hash": "seed", "review_profile": "owner_attested",
"reviewer_models": ["owner_attestation"],
"findings": [{"item": "owner_attestation", "verdict": "PASS", "severity": "info", "reason": "owner attested"}],
}), encoding="utf-8")
(att_state / "owner_attestation.json").write_text(
json.dumps({"attested_at": "now", "content_hash": "seed"}), encoding="utf-8")
try:
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
try:
page = browser.new_page(viewport={"width": 1280, "height": 900})
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
page.click('[data-nav-page="skills"]')
page.wait_for_selector("#page-skills", timeout=30_000)
page.wait_for_selector('.skills-card[data-skill="owntool"]', timeout=30_000)
own = page.locator('.skills-card[data-skill="owntool"]').first
market = page.locator('.skills-card[data-skill="markettool"]').first
# owner-own external skill that still needs review -> Skip review offered.
assert own.locator(".skills-attest-review").count() == 1
assert "Skip review" in (
own.locator(".skills-attest-review").first.text_content() or "")
# ClawHub marketplace skill -> never attestable, no Skip review action.
assert market.locator(".skills-attest-review").count() == 0
# owner-attested skill -> distinct 'owner-attested' badge (review_profile surfaced).
page.wait_for_selector('.skills-card[data-skill="attestedtool"]', timeout=30_000)
att_card = page.locator('.skills-card[data-skill="attestedtool"]').first
assert att_card.locator(".skills-badge").filter(has_text="owner-attested").count() >= 1
# The backend is the sole publication classifier. Owner-attested review is
# not publication-ready, but the selected flow may start an ordinary task
# that repairs/reviews the bytes before opening a PR.
submit_html = page.evaluate(
"""async () => {
const m = await import('/static/modules/skill_card_renderer.js');
return m.renderInstalledSkillCard(
{ name: 'att', type: 'instruction', version: '0.1.0', source: 'external',
is_self_authored: true, review_status: 'clean',
review_gate: { executable_review: true }, review_stale: false,
review_profile: 'owner_attested', grants: {}, permissions: [],
payload_root: 'skills/external/att', enabled: true,
submit_hub: { visible: true, publication_ready: false,
task_start_allowed: true, state: 'needs_attention',
reason: 'Owner-attested review needs attention' } },
new Set(), new Set(), {}, { githubTokenConfigured: true });
}"""
)
assert 'data-submit-disabled="false"' in submit_html
assert 'data-publication-ready="false"' in submit_html
assert 'data-submit-state="needs_attention"' in submit_html
assert "owner-attested" in submit_html.lower()
# Defense-in-depth (mirrors the backend source gate): a marketplace skill
# mislabeled self-authored must STILL NOT offer Skip review.
market_self_html = page.evaluate(
"""async () => {
const m = await import('/static/modules/skill_card_renderer.js');
return m.renderInstalledSkillCard(
{ name: 'mk2', type: 'instruction', version: '0.1.0', source: 'clawhub',
is_self_authored: true, review_status: 'pending',
review_gate: { executable_review: false }, review_stale: false,
review_profile: '', grants: {}, permissions: [],
payload_root: 'skills/clawhub/mk2', enabled: false },
new Set(), new Set(), {}, {});
}"""
)
assert "skills-attest-review" not in market_self_html
# Unverified OuroborosHub payloads also stay blocked; only the official_hub
# profile is a cheap UI hint, and the backend still re-verifies.
hub_html = page.evaluate(
"""async () => {
const m = await import('/static/modules/skill_card_renderer.js');
return {
unverified: m.renderInstalledSkillCard(
{ name: 'hub1', type: 'instruction', version: '0.1.0', source: 'ouroboroshub',
is_self_authored: false, review_status: 'pending',
review_gate: { executable_review: false }, review_stale: false,
review_profile: '', grants: {}, permissions: [],
payload_root: 'skills/ouroboroshub/hub1', enabled: false },
new Set(), new Set(), {}, {}),
verified: m.renderInstalledSkillCard(
{ name: 'hub2', type: 'instruction', version: '0.1.0', source: 'ouroboroshub',
is_self_authored: false, review_status: 'pending',
review_gate: { executable_review: false }, review_stale: false,
review_profile: '', owner_attestable: true, official_hub_verified: true,
grants: {}, permissions: [],
payload_root: 'skills/ouroboroshub/hub2', enabled: false },
new Set(), new Set(), {}, {}),
staleProfile: m.renderInstalledSkillCard(
{ name: 'hub3', type: 'instruction', version: '0.1.0', source: 'ouroboroshub',
is_self_authored: false, review_status: 'pending',
review_gate: { executable_review: false }, review_stale: true,
review_profile: 'official_hub', owner_attestable: false,
official_hub_verified: false, grants: {}, permissions: [],
payload_root: 'skills/ouroboroshub/hub3', enabled: false },
new Set(), new Set(), {}, {})
};
}"""
)
assert "skills-attest-review" not in hub_html["unverified"]
assert "skills-attest-review" in hub_html["verified"]
assert "skills-attest-review" not in hub_html["staleProfile"]
finally:
browser.close()
except PlaywrightError as exc:
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
pytest.skip(str(exc))
raise
@pytest.mark.ui_browser
def test_ui_smoke_v679_subagent_depth_zero_round_trips_through_settings(direct_server_with_data):
"""v6.79.0: the owner can actually reach, save, and re-read a Subagent Depth of 0.
The structural fix (``_bounded_positive_int_setting`` honouring a configured 0) is
unreachable if the visible control refuses the value or the load path rewrites it back to
the fallback. This drives the real consumer flow — Settings -> Advanced -> type -> save ->
full page reload — and pins the three neighbouring states so the fix cannot silently break
them: 0 (no delegation), a normal positive value, and empty (falls back, does not persist
an invalid value). Screenshots are written for vision inspection; a saved screenshot is
not verification on its own (docs/DEVELOPMENT.md "Responsive and accessible
behavior").
"""
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
url = direct_server_with_data["url"]
data_dir = direct_server_with_data["data_dir"]
settings_path = data_dir / "settings.json"
evidence_dir = pathlib.Path(
os.environ.get("OUROBOROS_UI_EVIDENCE_DIR", str(data_dir.parent))
)
evidence_dir.mkdir(parents=True, exist_ok=True)
def saved_depth():
return json.loads(settings_path.read_text(encoding="utf-8")).get(
"OUROBOROS_MAX_SUBAGENT_DEPTH", "<absent>"
)
try:
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1000})
try:
def open_settings_advanced():
"""Reload the whole app the way an owner would, then reopen the field."""
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
page.wait_for_selector('[data-nav-page="settings"]', timeout=30_000)
page.click('[data-nav-page="settings"]')
page.wait_for_selector("#s-subagent-depth", state="attached", timeout=30_000)
# D-10: subagent depth bounds the AGENTS, so it moved out of
# Advanced -> Runtime Limits into Agents -> Delegation.
page.click('[data-settings-tab="agents"]')
depth = page.locator("#s-subagent-depth")
depth.wait_for(state="visible", timeout=30_000)
# Saving is blocked until the settings load succeeds; waiting on the real
# enablement avoids racing the first fetch.
page.wait_for_function(
"() => document.querySelector('#btn-save-settings')?.disabled === false",
timeout=30_000,
)
depth.scroll_into_view_if_needed()
return depth
def type_depth(value):
page.fill("#s-subagent-depth", value)
page.dispatch_event("#s-subagent-depth", "input")
page.dispatch_event("#s-subagent-depth", "change")
def save_and_wait():
page.click("#btn-save-settings")
page.wait_for_function(
"() => (document.querySelector('#settings-status')?.textContent || '')"
".includes('Settings saved')",
timeout=30_000,
)
depth = open_settings_advanced()
# The control must admit 0 at all: a min of 1 would make it unreachable.
assert depth.get_attribute("min") == "0"
assert depth.get_attribute("max") == "10"
assert depth.is_enabled()
assert depth.input_value() == "3" # unset -> visible fallback
page.screenshot(path=str(evidence_dir / "v679-depth-01-initial-unset.png"))
# 0 is a valid value for the control, not a validation error.
type_depth("0")
assert page.evaluate(
"() => document.querySelector('#s-subagent-depth').validity.valid"
) is True
assert page.evaluate(
"() => document.querySelector('#s-subagent-depth').validationMessage"
) == ""
page.screenshot(path=str(evidence_dir / "v679-depth-02-typed-zero.png"))
save_and_wait()
assert page.locator("#s-subagent-depth").input_value() == "0"
assert saved_depth() == 0
page.screenshot(path=str(evidence_dir / "v679-depth-03-saved-zero.png"))
# The round trip is the point: a reload must not rewrite 0 back to 3.
open_settings_advanced()
assert page.locator("#s-subagent-depth").input_value() == "0"
assert saved_depth() == 0
page.screenshot(path=str(evidence_dir / "v679-depth-04-reload-zero.png"))
# Neighbouring state: an ordinary positive value still round-trips.
type_depth("3")
save_and_wait()
assert saved_depth() == 3
open_settings_advanced()
assert page.locator("#s-subagent-depth").input_value() == "3"
page.screenshot(path=str(evidence_dir / "v679-depth-05-reload-three.png"))
# Neighbouring state: empty is not a value — it falls back to 3 rather than
# persisting an unparsable setting.
type_depth("")
page.screenshot(path=str(evidence_dir / "v679-depth-06-empty-typed.png"))
save_and_wait()
assert saved_depth() == 3
open_settings_advanced()
assert page.locator("#s-subagent-depth").input_value() == "3"
page.screenshot(path=str(evidence_dir / "v679-depth-07-reload-after-empty.png"))
finally:
browser.close()
except PlaywrightError as exc:
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
pytest.skip(str(exc))
raise
@pytest.mark.ui_browser
def test_ui_owner_context_mode_and_scope_review_ack(direct_server_with_data):
"""Owner context intent and scope-review ack, driven in a real browser.
Two claimed-complete owner flows that source-string tests cannot certify:
1. OWNER MAX. Switching an explicit Low to Max succeeds without a Main-route
context-window confirmation; the frozen compatibility field remains false.
2. SCOPE-REVIEW CAPABILITY ACK. Saving a scope-review slot whose route has no >=1M evidence
must raise the owner confirm and, on accept, persist a route-scoped capability ack and say
so in the settings status line.
"""
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
url = direct_server_with_data["url"]
data_dir = direct_server_with_data["data_dir"]
settings_path = data_dir / "settings.json"
evidence_dir = pathlib.Path(os.environ.get("OUROBOROS_UI_EVIDENCE_DIR", str(data_dir.parent)))
evidence_dir.mkdir(parents=True, exist_ok=True)
# Boot into explicit owner Low with the one-window false provenance tombstone.
seeded = json.loads(settings_path.read_text(encoding="utf-8"))
seeded["OUROBOROS_CONTEXT_MODE"] = "low"
seeded["OUROBOROS_CONTEXT_MODE_AUTO_LOW"] = "false"
seeded["OUROBOROS_SCOPE_REVIEW_MODELS"] = seeded["OUROBOROS_MODEL"]
settings_path.write_text(json.dumps(seeded), encoding="utf-8")
direct_server_with_data["restart_server"]()
with urllib.request.urlopen(f"{url}/api/state", timeout=5) as resp: # noqa: S310 - local test server
boot_state = json.loads(resp.read().decode("utf-8"))
assert boot_state["context_mode"] == "low"
assert boot_state["context_mode_auto_low"] is False
try:
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1280, "height": 800})
dialogs: list[str] = []
page.on("dialog", lambda dialog: (dialogs.append(dialog.message), dialog.accept()))
try:
page.goto(url, wait_until="domcontentloaded", timeout=60_000)
toggle = page.locator("#chat-context-mode")
toggle.wait_for(state="visible", timeout=60_000)
page.wait_for_function("() => document.querySelector('#chat-context-mode')?.dataset.contextMode === 'low'", timeout=30_000)
assert toggle.get_attribute("data-context-mode") == "low"
page.screenshot(path=str(evidence_dir / "context-mode-low-before.png"))
toggle.locator('.chat-seg[data-mode="max"]').click()
page.wait_for_function(
"() => document.querySelector('#chat-context-mode')?.dataset.contextMode === 'max'",
timeout=30_000,
)
assert page.locator(".confirm-dialog:not([hidden])").count() == 0
page.screenshot(path=str(evidence_dir / "context-mode-max-after.png"))
persisted = json.loads(settings_path.read_text(encoding="utf-8"))
assert persisted["OUROBOROS_CONTEXT_MODE"] == "max"
assert persisted["OUROBOROS_CONTEXT_MODE_AUTO_LOW"] == "false"
with urllib.request.urlopen(f"{url}/api/state", timeout=5) as resp: # noqa: S310
after = json.loads(resp.read().decode("utf-8"))
assert after["context_mode"] == "max"
assert after["context_mode_auto_low"] is False
# 2. Scope-review capability notice -> owner confirm -> route-scoped ack.
# 6.2: the scope route is a review-lane row — pick the API-model
# route in the grouped combobox and type the id. D-10 moved the
# lanes out of Models into their own Agents tab.
page.click('[data-nav-page="settings"]')
page.wait_for_selector("#s-context-mode", state="attached", timeout=30_000)
page.locator('[data-settings-tab="agents"]').click()
page.wait_for_selector("#reviewer-slots-section", timeout=30_000)
scope_route = page.locator(
'#reviewer-scope-rows .reviewer-slot-row [data-slot-route]'
).first
scope_route.wait_for(state="visible", timeout=30_000)
scope_route.select_option("api")
custom_input = page.locator(
'#reviewer-scope-rows .reviewer-slot-row [data-slot-custom-api]'
).first
custom_input.wait_for(state="visible", timeout=30_000)
custom_input.fill("openai-compatible::scope-reviewer-x")
page.locator("#btn-save-settings").click()
# The capability ack is an in-app dialog since the native-dialog
# class ban (tests/test_web_dialogs_static.py); Playwright's
# page.on("dialog") hook only fires for window.alert/confirm/prompt.
ack_dialog = page.locator(".confirm-dialog")
ack_dialog.wait_for(state="visible", timeout=60_000)
ack_text = ack_dialog.inner_text()
page.screenshot(path=str(evidence_dir / "v6800-scope-review-ack.png"), full_page=True)
ack_dialog.locator("[data-confirm-ok]").last.click()
page.wait_for_function(
"() => (document.querySelector('#settings-status')?.textContent || '')"
".includes('scope-review route')",
timeout=60_000,
)
assert "1,000,000-token context window" in ack_text
assert "openai-compatible::scope-reviewer-x" in ack_text, "the ack must name the exact route"
status_text = page.locator("#settings-status").inner_text()
assert "Confirmed the required context window for 1 scope-review route(s)." in status_text
evidence = json.loads((data_dir / "state" / "capability_evidence.json").read_text(encoding="utf-8"))
acked = [
entry for entry in (evidence.get("acks") or evidence.get("probes") or {}).values()
if str(entry.get("model") or "") == "openai-compatible::scope-reviewer-x"
]
assert acked, "no route-scoped capability evidence was stored for the acked reviewer"
finally:
browser.close()
except PlaywrightError as exc:
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
pytest.skip(str(exc))
raise
@pytest.mark.ui_browser
def test_ui_smoke_superseded_input_dialog_resolves_object_result(direct_server_with_data):
"""v6.90.3 dialog contract: superseding an INPUT dialog with a newer dialog
resolves the documented {confirmed: false, value: ''} — never a bare false
the docs do not promise (the supersession close is mode-aware)."""
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
url = direct_server_with_data["url"]
try:
with sync_playwright() as pw:
browser = pw.chromium.launch()
try:
page = browser.new_page()
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
first_result = page.evaluate(
"""
async () => {
const m = await import('/static/modules/confirm_dialog.js');
const first = m.openConfirmDialog({
title: 'first', body: 'input dialog', input: true,
});
const second = m.openConfirmDialog({
title: 'second', body: 'supersedes the first',
});
const r1 = await first;
document.querySelector('[data-confirm-cancel]')?.click();
await second;
return r1;
}
"""
)
assert first_result == {"confirmed": False, "value": ""}
assert page.locator(".confirm-dialog").count() == 0
finally:
browser.close()
except PlaywrightError as exc:
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
pytest.skip(str(exc))
raise
@pytest.mark.ui_browser
def test_ui_smoke_login_recovery_reconcile_detach_and_retry_are_explicit(direct_server_with_data):
"""Recovery lifecycle."""
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
url = direct_server_with_data["url"]
try:
with sync_playwright() as pw:
browser = pw.chromium.launch()
try:
page = browser.new_page()
posts: list[str] = []
deletes: list[str] = []
reconciles: list[str] = []
def handle_create(route):
posts.append(route.request.url)
if len(posts) == 1:
job = '{"state": "running"}'
elif len(posts) == 2:
job = ('{"state": "failed", "outcome": '
'{"reason": "termination_unconfirmed"}}')
else:
job = '{"state": "running"}'
route.fulfill(
status=200,
content_type="application/json",
body='{"job_id":"job-recovery","job":' + job + '}',
)
def handle_job(route):
if route.request.url.endswith("/reconcile"):
reconciles.append(route.request.url)
if len(reconciles) == 1:
route.fulfill(status=409, content_type="application/json", body=(
'{"error":"still present","code":"setup_termination_unconfirmed",'
'"required_actions":["retry_setup_reconciliation"]}'
))
else:
route.fulfill(status=200, content_type="application/json", body=(
'{"job":{"state":"failed","outcome":'
'{"reason":"termination_unconfirmed"},'
'"terminationReconciliation":{"status":"empty"}}}'
))
else:
deletes.append(route.request.url)
route.fulfill(status=200, content_type="application/json", body=(
'{"job":{"state":"failed","outcome":'
'{"reason":"termination_unconfirmed"}}}'
))
page.route("**/api/claudexor/login", handle_create)
page.route("**/api/claudexor/login/*", handle_job)
page.route("**/api/claudexor/login/*/reconcile", handle_job)
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
setup_result = page.evaluate(
"""
async () => {
const loginHost = () =>
document.querySelector('[data-family-login="codex"]')
|| document.getElementById('harness-login-card');
if (!loginHost()) return 'NO-HOST';
const m = await import('/static/modules/harness_accounts.js');
const wait = async (sel) => {
for (let i = 0; i < 100; i++) {
const b = loginHost()?.querySelector(sel);
if (b && !b.disabled) return b;
await new Promise((r) => setTimeout(r, 20));
}
};
const p1 = m.startLogin('codex', 'race-a');
const p2 = m.startLogin('codex', 'race-a');
await Promise.all([p1, p2]);
loginHost()?.querySelector('[data-login-dismiss]')?.click();
(await wait('[data-login-reconcile]'))?.click();
return 'RECONCILE-CLICKED';
}
"""
)
assert setup_result == "RECONCILE-CLICKED"
# Deterministic settle wait (a fixed sleep was flaky under load:
# the card could still say "Checking…"). The reconcile round-trip
# is settled only when the card re-renders the retained-custody
# recovery face: the outcome detail note exists (it is absent
# before the click) and "Check again" is enabled again.
page.wait_for_function(
"() => { const host = document.querySelector('[data-family-login=\"codex\"]')"
" || document.getElementById('harness-login-card');"
" const btn = host?.querySelector('[data-login-reconcile]');"
" return Boolean(host?.querySelector('[data-login-detail]'))"
" && Boolean(btn) && !btn.disabled; }",
timeout=30_000,
)
recovery_html = page.evaluate(
"() => (document.querySelector('[data-family-login=\"codex\"]')"
" || document.getElementById('harness-login-card')).innerHTML"
)
assert len(posts) == 1
assert len(deletes) == 1
assert len(reconciles) == 1
assert "Check again" in recovery_html and "job-recovery" in reconciles[0]
before_detach = (len(posts), len(deletes), len(reconciles))
detached_html = page.evaluate(
"""async () => {
const loginHost = () =>
document.querySelector('[data-family-login="codex"]')
|| document.getElementById('harness-login-card');
loginHost()?.querySelector('[data-login-dismiss]')?.click();
await new Promise((r) => setTimeout(r, 50));
return loginHost()?.innerHTML || '';
}"""
)
assert detached_html == ""
assert (len(posts), len(deletes), len(reconciles)) == before_detach
final_html = page.evaluate(
"""async () => {
const loginHost = () =>
document.querySelector('[data-family-login="codex"]')
|| document.getElementById('harness-login-card');
const m = await import('/static/modules/harness_accounts.js');
await m.startLogin('codex', 'race-a');
loginHost()?.querySelector('[data-login-reconcile]')?.click();
for (let i = 0; i < 100
&& !loginHost()?.querySelector('[data-login-retry]'); i++)
await new Promise((r) => setTimeout(r, 20));
loginHost()?.querySelector('[data-login-retry]')?.click();
await new Promise((r) => setTimeout(r, 100));
return loginHost()?.innerHTML || '';
}"""
)
assert len(posts) == 3
assert len(deletes) == 1
assert len(reconciles) == 2 and all("job-recovery" in u for u in reconciles)
assert "Starting" in final_html or "sign-in" in final_html
finally:
browser.close()
except PlaywrightError as exc:
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
pytest.skip(str(exc))
raise
@pytest.mark.ui_browser
def test_ui_smoke_dismiss_overlapping_start_cannot_drop_a_live_job(direct_server_with_data):
"""Queued start follows slow Dismiss."""
import time as _time
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
url = direct_server_with_data["url"]
try:
with sync_playwright() as pw:
browser = pw.chromium.launch()
try:
page = browser.new_page()
events: list = []
def handle_create(route):
events.append("post")
route.fulfill(
status=200,
content_type="application/json",
body='{"job_id": "job-ov-%d", "job": {"state": "running"},'
' "attach_command": ""}' % len(events),
)
def handle_cancel(route):
events.append("delete-open")
_time.sleep(0.35)
events.append("delete-done")
route.fulfill(status=200, content_type="application/json",
body='{"job":{"state":"cancelled"}}')
page.route("**/api/claudexor/login", handle_create)
page.route("**/api/claudexor/login/*", handle_cancel)
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
result = page.evaluate(
"""
async () => {
const loginHost = () =>
document.querySelector('[data-family-login="codex"]')
|| document.getElementById('harness-login-card');
if (!loginHost()) return { error: 'NO-HOST' };
const m = await import('/static/modules/harness_accounts.js');
await m.startLogin('codex', 'ov-a');
loginHost()?.querySelector('[data-login-dismiss]')?.click();
await m.startLogin('codex', 'ov-b');
await new Promise((r) => setTimeout(r, 600));
const cardAfterQueuedStart = (loginHost()?.innerHTML || '').length > 0;
await m.startLogin('codex', 'ov-c');
return {
cardAfterQueuedStart,
finalHasCard: (loginHost()?.innerHTML || '').length > 0,
};
}
"""
)
assert result.get("error") is None
assert result["cardAfterQueuedStart"] is True
assert result["finalHasCard"] is True
posts = events.count("post")
deletes = events.count("delete-open")
assert posts == 3
assert deletes == 2
first_delete_done = events.index("delete-done")
second_post = [i for i, e in enumerate(events) if e == "post"][1]
assert first_delete_done < second_post
finally:
browser.close()
except PlaywrightError as exc:
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
pytest.skip(str(exc))
raise
@pytest.mark.ui_browser
@pytest.mark.parametrize("face", ["recovery", "reconciled", "unavailable"])
def test_ui_smoke_stale_get_cannot_overwrite_login_terminal_faces(
direct_server_with_data, face,
):
"""Stale GET cannot repaint custody."""
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
url = direct_server_with_data["url"]
try:
with sync_playwright() as pw:
browser = pw.chromium.launch()
try:
page = browser.new_page()
creates: list[str] = []
deletes: list[str] = []
reconciles: list[str] = []
def handle_create(route):
creates.append(route.request.url)
route.fulfill(
status=200,
content_type="application/json",
body='{"job_id": "job-stale", "job": {"state": "running"},'
' "attach_command": ""}',
)
def handle_job(route):
if route.request.method == "DELETE":
deletes.append(route.request.url)
if face == "unavailable":
route.fulfill(status=404, content_type="application/json", body="{}")
else:
route.fulfill(status=200, content_type="application/json", body=(
'{"job":{"state":"failed","outcome":'
'{"reason":"termination_unconfirmed"}}}'
))
return
route.fulfill(status=200, content_type="application/json",
body='{"job": {"state": "running"}}')
def handle_reconcile(route):
reconciles.append(route.request.url)
route.fulfill(status=200, content_type="application/json", body=(
'{"job":{"state":"failed","outcome":'
'{"reason":"termination_unconfirmed"},'
'"terminationReconciliation":{"status":"empty"}}}'
))
page.route("**/api/claudexor/login", handle_create)
page.route("**/api/claudexor/login/*", handle_job)
page.route("**/api/claudexor/login/*/reconcile", handle_reconcile)
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
result = page.evaluate(
"""
async (face) => {
const realFetch = window.fetch.bind(window);
let releaseStale;
const stale = new Promise((resolve) => { releaseStale = resolve; });
let gets = 0;
window.fetch = (input, init = {}) => {
const url = String(input?.url || input);
const method = String(init.method || input?.method || 'GET').toUpperCase();
if (method === 'GET' && url.includes('/api/claudexor/login/job-stale')) {
gets += 1;
return stale;
}
return realFetch(input, init);
};
const loginHost = () =>
document.querySelector('[data-family-login="codex"]')
|| document.getElementById('harness-login-card');
const m = await import('/static/modules/harness_accounts.js');
await m.startLogin('codex', 'stale-' + face);
await new Promise((r) => setTimeout(r, 3200));
loginHost()?.querySelector('[data-login-dismiss]')?.click();
await new Promise((r) => setTimeout(r, 100));
if (face === 'reconciled') {
loginHost()?.querySelector('[data-login-reconcile]')?.click();
await new Promise((r) => setTimeout(r, 100));
}
const before = loginHost()?.innerHTML || '';
releaseStale(new Response('{"job":{"state":"running"}}', {
status: 200, headers: { 'Content-Type': 'application/json' },
}));
await new Promise((r) => setTimeout(r, 3400));
return { before, after: loginHost()?.innerHTML || '', gets };
}
""", face,
)
assert result["gets"] == 1
assert result["before"] == result["after"]
marker = {
"recovery": "could not prove",
"reconciled": "no longer blocking",
"unavailable": "no longer available",
}[face]
assert marker in result["after"]
assert len(creates) == 1 and len(deletes) == 1
assert len(reconciles) == (1 if face == "reconciled" else 0)
finally:
browser.close()
except PlaywrightError as exc:
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
pytest.skip(str(exc))
raise
@pytest.mark.ui_browser
def test_ui_smoke_window_pagehide_detaches_login_without_lifecycle_http(direct_server_with_data):
"""Window pagehide detaches locally."""
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
try:
with sync_playwright() as pw:
browser = pw.chromium.launch()
try:
page = browser.new_page()
page.goto(direct_server_with_data["url"], wait_until="domcontentloaded")
result = page.evaluate(
"""
async () => {
const {createAgentsStep} = await import('/static/modules/onboarding_agents_step.js');
let connect, release;
const pending = new Promise((r) => { release = r; });
const calls = {create: 0, delete: 0, reconcile: 0, get: 0};
const button = {getAttribute: () => 'claude',
addEventListener: (_t, fn) => { connect = fn; }};
const host = {innerHTML: '', querySelector: () => null};
const list = {innerHTML: '', querySelectorAll: () => [button]};
const other = document.createElement('div');
const doc = {defaultView: window, getElementById: (id) =>
id === 'agents-login-host' ? host
: id === 'agents-family-list' ? list : other};
const store = {
accountsKnown: false, snapshot: null, subscribe: () => () => {},
refresh: () => {}, unavailableNote: () => null,
};
const fetchImpl = async (input, init={}) => {
const url=String(input), method=init.method || 'GET';
if (url === '/api/claudexor/login' && method === 'POST') {
calls.create++; return pending;
}
if (url.endsWith('/reconcile')) calls.reconcile++;
else if (method === 'DELETE') calls.delete++; else calls.get++;
return new Response('{"job":{"state":"running"}}', {
status: 200, headers: {'Content-Type':'application/json'}});
};
const step = createAgentsStep({doc, store, fetchImpl});
step.mount(); connect(); await Promise.resolve();
window.dispatchEvent(new PageTransitionEvent('pagehide',{persisted:true}));
const cached=host.innerHTML, before={...calls};
window.dispatchEvent(new PageTransitionEvent('pagehide',{persisted:false}));
const immediate=host.innerHTML;
connect(); await Promise.resolve();
release(new Response('{"job_id":"late","job":{"state":"running"}}',
{status:200,headers:{'Content-Type':'application/json'}}));
await new Promise((r) => setTimeout(r, 50));
return {cached, immediate, final:host.innerHTML, before, after:calls};
}
"""
)
assert result["cached"]
assert result["immediate"] == result["final"] == ""
assert result["before"] == result["after"] == dict(
create=1, delete=0, reconcile=0, get=0)
finally:
browser.close()
except PlaywrightError as exc:
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
pytest.skip(str(exc))
raise
@pytest.mark.ui_browser
def test_ui_smoke_dismiss_overlapping_settle_never_freezes_the_card(direct_server_with_data):
"""Terminal GET wins over slow Dismiss."""
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
url = direct_server_with_data["url"]
try:
with sync_playwright() as pw:
browser = pw.chromium.launch()
try:
page = browser.new_page()
creates: list[str] = []
gets: list[str] = []
reconciles: list[str] = []
def handle_create(route):
creates.append(route.request.url)
route.fulfill(status=200, content_type="application/json",
body='{"job_id": "job-os-1", "job": {"state": "running"},'
' "attach_command": ""}')
def handle_job(route):
gets.append(route.request.url)
route.fulfill(status=200, content_type="application/json",
body='{"job": {"state": "succeeded"}}')
def handle_reconcile(route):
reconciles.append(route.request.url)
route.fulfill(status=500, content_type="application/json", body="{}")
page.route("**/api/claudexor/login", handle_create)
page.route("**/api/claudexor/login/*", handle_job)
page.route("**/api/claudexor/login/*/reconcile", handle_reconcile)
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
result = page.evaluate(
"""
async () => {
const realFetch = window.fetch.bind(window);
let deletes = 0;
window.fetch = (input, init = {}) => {
const url = String(input && input.url ? input.url : input);
const method = String((init && init.method)
|| (input && input.method) || 'GET').toUpperCase();
if (method === 'DELETE' && url.includes('/api/claudexor/login/')) {
deletes += 1;
return new Promise((resolve) => setTimeout(() => resolve(
new Response('{"error": "daemon busy"}',
{ status: 503,
headers: { 'Content-Type': 'application/json' } })
), 4000));
}
return realFetch(input, init);
};
const loginHost = () =>
document.querySelector('[data-family-login="codex"]')
|| document.getElementById('harness-login-card');
const m = await import('/static/modules/harness_accounts.js');
await m.startLogin('codex', 'os-a');
loginHost()?.querySelector('[data-login-dismiss]')?.click();
await new Promise((r) => setTimeout(r, 5200));
const host = loginHost();
return { html: host?.innerHTML || '', deletes,
cardCount: host?.querySelectorAll('[data-login-card]').length || 0,
verdict: host?.querySelector('[data-login-verdict]')?.textContent.trim() };
}
"""
)
assert result["cardCount"] == 1
assert result["verdict"] == "Connected."
assert "Could not cancel" not in result["html"]
assert (len(creates), result["deletes"], len(gets), len(reconciles)) == (1, 1, 1, 0)
finally:
browser.close()
except PlaywrightError as exc:
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
pytest.skip(str(exc))
raise
@pytest.mark.ui_browser
def test_ui_smoke_cancel_run_button_eligibility_and_cancelled_state(direct_server_with_data):
"""v6.82 P5 / S3 Q2: the stop control renders ONLY on live marker-attested
root cards (never marker-less direct-turn cards, subagent children, or the
reusable background slot), opens the dropdown, and a cancelled root
replays as an honest warn-toned "Cancelled" — never a generic "Done"."""
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
url = direct_server_with_data["url"]
data_dir = direct_server_with_data["data_dir"]
logs_dir = data_dir / "logs"
logs_dir.mkdir(parents=True, exist_ok=True)
(logs_dir / "chat.jsonl").write_text("", encoding="utf-8")
rows = [
# Pooled live root: carries the supervisor's host-attested marker.
{"ts": "2026-07-29T10:00:00+00:00", "chat_id": 1, "task_id": "live-root",
"content": "Working on the big thing", "cancelable": True},
# Direct-chat-turn shape: same card shape, NO marker -> no button.
{"ts": "2026-07-29T10:00:01+00:00", "chat_id": 1, "task_id": "direct-turn",
"content": "Inline turn narration"},
# Subagent child of the live root: marker present but child cards never
# offer the action (the root cascade covers them).
{"ts": "2026-07-29T10:00:02+00:00", "chat_id": 1, "task_id": "sub-child1",
"content": "Collecting evidence", "delegation_role": "subagent",
"subagent_event": "scheduled", "subagent_task_id": "sub-child1",
"parent_task_id": "live-root", "subagent_role": "researcher",
"cancelable": True},
# Reusable background-consciousness slot: never eligible.
{"ts": "2026-07-29T10:00:03+00:00", "chat_id": 1, "task_id": "bg-consciousness",
"content": "Background thinking", "cancelable": True},
# A root that was force-cancelled before this reload.
{"ts": "2026-07-29T10:00:04+00:00", "chat_id": 1, "task_id": "gone-root",
"content": "Was working before the cancel", "cancelable": True},
]
(logs_dir / "progress.jsonl").write_text(
"".join(json.dumps(row) + "\n" for row in rows), encoding="utf-8",
)
task_results = data_dir / "task_results"
task_results.mkdir(parents=True, exist_ok=True)
(task_results / "gone-root.json").write_text(json.dumps({
"_schema_version": 1,
"task_id": "gone-root",
"status": "cancelled",
"reason_code": "cancelled",
"outcome_axes": {
"lifecycle": {"status": "cancelled"},
"execution": {"status": "cancelled"},
},
}) + "\n", encoding="utf-8")
try:
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1440, "height": 1000})
try:
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
live = page.locator('.chat-live-card[data-task-id="live-root"]')
live.wait_for(state="attached", timeout=30_000)
cancel_btn = live.locator('[data-cancel-run]')
cancel_btn.wait_for(state="attached", timeout=30_000)
assert cancel_btn.inner_text().strip() == "Stop…"
# Marker-less direct-turn shape, subagent child, reusable slot,
# and the finished cancelled root must NOT offer the action.
for absent_id in ("direct-turn", "sub-child1", "bg-consciousness", "gone-root"):
card = page.locator(f'.chat-live-card[data-task-id="{absent_id}"]')
card.wait_for(state="attached", timeout=30_000)
assert card.locator('[data-cancel-run]').count() == 0, absent_id
# The cancelled root replays as an honest Cancelled state.
gone_phase = page.locator('.chat-live-card[data-task-id="gone-root"] [data-live-phase]')
page.wait_for_function(
"() => document.querySelector('.chat-live-card[data-task-id=\"gone-root\"]"
" [data-live-phase]')?.textContent === 'Cancelled'",
timeout=30_000,
)
assert "cancelled" in (gone_phase.get_attribute("class") or "")
# Dropdown wiring (S3 Q2): open, then dismiss = keep running.
cancel_btn.click()
menu = page.locator('body > .task-control-menu')
menu.wait_for(state="visible", timeout=10_000)
assert "Wrap up" in menu.inner_text()
page.keyboard.press("Escape")
menu.wait_for(state="detached", timeout=10_000)
assert cancel_btn.is_enabled()
page.screenshot(path=str(data_dir.parent / "cancel-run.png"), full_page=True)
finally:
browser.close()
except PlaywrightError as exc:
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
pytest.skip(str(exc))
raise
# The in-flight indicator lifecycle smoke test lives in
# tests/test_ui_smoke_inflight_indicator.py and the Settings → Agents list-editor
# acceptance in tests/test_ui_smoke_agents_panel.py (size-ratchet byte gate on this module).