281 lines
8.3 KiB
Python
281 lines
8.3 KiB
Python
from __future__ import annotations
|
|
|
|
import html
|
|
import json
|
|
import logging
|
|
import threading
|
|
import uuid
|
|
import webbrowser
|
|
from dataclasses import dataclass
|
|
from http import HTTPStatus
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from typing import ClassVar
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.request import Request, urlopen
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
from wf_authoring import node
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
_MAX_SESSION_EVENTS = 10
|
|
|
|
|
|
class Snapshot(BaseModel):
|
|
title: str
|
|
url: str
|
|
button_text: str
|
|
status_text: str
|
|
clicked: bool
|
|
events: list[str] = Field(default_factory=list, max_length=10)
|
|
|
|
|
|
class OpenPageInput(BaseModel):
|
|
button_label: str = "Click to continue"
|
|
open_browser: bool = Field(
|
|
default=False,
|
|
description="Open the page in the default browser for manual runs.",
|
|
)
|
|
|
|
|
|
class OpenPageOutput(BaseModel):
|
|
session_id: str
|
|
url: str
|
|
before: Snapshot
|
|
|
|
|
|
class WaitForClickInput(BaseModel):
|
|
session_id: str
|
|
simulate: bool = Field(
|
|
default=True,
|
|
description="When true, perform a deterministic HTTP click instead of waiting.",
|
|
)
|
|
timeout_seconds: float = Field(default=10.0, gt=0)
|
|
|
|
|
|
class WaitForClickOutput(BaseModel):
|
|
clicked: bool
|
|
after: Snapshot
|
|
|
|
|
|
class CollectSnapshotsInput(BaseModel):
|
|
session_id: str
|
|
before: Snapshot
|
|
after: Snapshot
|
|
|
|
|
|
class CollectSnapshotsOutput(BaseModel):
|
|
before: Snapshot
|
|
after: Snapshot
|
|
closed: bool
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class _ClickSession:
|
|
session_id: str
|
|
button_label: str
|
|
server: ThreadingHTTPServer
|
|
thread: threading.Thread
|
|
clicked: threading.Event
|
|
events: list[str]
|
|
|
|
@property
|
|
def url(self) -> str:
|
|
host = self.server.server_address[0]
|
|
port = self.server.server_address[1]
|
|
return f"http://{host}:{port}/"
|
|
|
|
def snapshot(self) -> Snapshot:
|
|
clicked = self.clicked.is_set()
|
|
return Snapshot(
|
|
title="Workflow Click Fixture",
|
|
url=self.url,
|
|
button_text=self.button_label,
|
|
status_text="Button clicked" if clicked else "Waiting for click",
|
|
clicked=clicked,
|
|
events=list(self.events[-10:]),
|
|
)
|
|
|
|
def add_event(self, event: str) -> None:
|
|
"""Record bounded click-session events for compact snapshots."""
|
|
self.events.append(event)
|
|
if len(self.events) > _MAX_SESSION_EVENTS:
|
|
del self.events[: len(self.events) - _MAX_SESSION_EVENTS]
|
|
|
|
def close(self) -> None:
|
|
self.server.shutdown()
|
|
self.server.server_close()
|
|
self.thread.join(timeout=2)
|
|
if self.thread.is_alive():
|
|
_LOGGER.warning(
|
|
"click session %s server thread did not stop within timeout",
|
|
self.session_id,
|
|
)
|
|
|
|
|
|
_SESSIONS: dict[str, _ClickSession] = {}
|
|
_SESSIONS_LOCK = threading.Lock()
|
|
|
|
|
|
class _ClickHandler(BaseHTTPRequestHandler):
|
|
server: _ClickServer
|
|
|
|
def log_message(self, _format: str, *_args: object) -> None:
|
|
"""Keep example runs quiet; workflow trace carries the useful output."""
|
|
|
|
def do_GET(self) -> None:
|
|
if self.path == "/":
|
|
self._send_html()
|
|
return
|
|
if self.path == "/snapshot":
|
|
self._send_json(self.server.session.snapshot().model_dump(mode="json"))
|
|
return
|
|
self.send_error(HTTPStatus.NOT_FOUND)
|
|
|
|
def do_POST(self) -> None:
|
|
if self.path != "/click":
|
|
self.send_error(HTTPStatus.NOT_FOUND)
|
|
return
|
|
session = self.server.session
|
|
session.clicked.set()
|
|
session.add_event("click")
|
|
self._send_json(session.snapshot().model_dump(mode="json"))
|
|
|
|
def _send_html(self) -> None:
|
|
session = self.server.session
|
|
status = "Button clicked" if session.clicked.is_set() else "Waiting for click"
|
|
html_doc = f"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Workflow Click Fixture</title>
|
|
<style>
|
|
body {{ font-family: Georgia, serif; margin: 3rem; background: #f8f3e8; color: #1f2933; }}
|
|
main {{ max-width: 720px; padding: 2rem; border: 3px solid #1f2933; background: #fffaf0; }}
|
|
button {{ font-size: 1.25rem; padding: 0.9rem 1.3rem; border: 2px solid #1f2933; background: #f2b84b; cursor: pointer; }}
|
|
#status {{ margin-top: 1rem; font-weight: 700; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<h1>Workflow Click Fixture</h1>
|
|
<button id="continue" type="button">{html.escape(session.button_label)}</button>
|
|
<p id="status">{html.escape(status)}</p>
|
|
</main>
|
|
<script>
|
|
document.getElementById("continue").addEventListener("click", async () => {{
|
|
await fetch("/click", {{ method: "POST" }});
|
|
document.getElementById("status").textContent = "Button clicked";
|
|
}});
|
|
</script>
|
|
</body>
|
|
</html>"""
|
|
encoded = html_doc.encode("utf-8")
|
|
self.send_response(HTTPStatus.OK)
|
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(encoded)))
|
|
self.end_headers()
|
|
self.wfile.write(encoded)
|
|
|
|
def _send_json(self, payload: dict[str, object]) -> None:
|
|
encoded = json.dumps(payload).encode("utf-8")
|
|
self.send_response(HTTPStatus.OK)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(encoded)))
|
|
self.end_headers()
|
|
self.wfile.write(encoded)
|
|
|
|
|
|
class _ClickServer(ThreadingHTTPServer):
|
|
session: _ClickSession
|
|
allow_reuse_address: ClassVar[bool] = True
|
|
|
|
|
|
@node(name="open_click_page")
|
|
def open_click_page(payload: OpenPageInput) -> OpenPageOutput:
|
|
return _open_click_page(payload)
|
|
|
|
|
|
@node(name="wait_for_click")
|
|
def wait_for_click(payload: WaitForClickInput) -> WaitForClickOutput:
|
|
return _wait_for_click(payload)
|
|
|
|
|
|
@node(name="collect_snapshots")
|
|
def collect_snapshots(payload: CollectSnapshotsInput) -> CollectSnapshotsOutput:
|
|
return _collect_snapshots(payload)
|
|
|
|
|
|
def _open_click_page(payload: OpenPageInput) -> OpenPageOutput:
|
|
session_id = f"click_{uuid.uuid4().hex}"
|
|
server = _ClickServer(("127.0.0.1", 0), _ClickHandler)
|
|
session = _ClickSession(
|
|
session_id=session_id,
|
|
button_label=payload.button_label,
|
|
server=server,
|
|
thread=threading.Thread(target=server.serve_forever, daemon=True),
|
|
clicked=threading.Event(),
|
|
events=["opened"],
|
|
)
|
|
server.session = session
|
|
with _SESSIONS_LOCK:
|
|
_SESSIONS[session_id] = session
|
|
session.thread.start()
|
|
if payload.open_browser:
|
|
try:
|
|
opened = webbrowser.open(session.url)
|
|
except Exception:
|
|
_LOGGER.exception("failed to open browser for click session %s", session_id)
|
|
else:
|
|
if not opened:
|
|
_LOGGER.warning("browser did not report opening %s", session.url)
|
|
return OpenPageOutput(
|
|
session_id=session_id,
|
|
url=session.url,
|
|
before=session.snapshot(),
|
|
)
|
|
|
|
|
|
def _wait_for_click(payload: WaitForClickInput) -> WaitForClickOutput:
|
|
session = _get_session(payload.session_id)
|
|
if payload.simulate:
|
|
request = Request(f"{session.url}click", method="POST")
|
|
try:
|
|
with urlopen(request, timeout=payload.timeout_seconds) as response:
|
|
response.read()
|
|
except (HTTPError, URLError) as exc:
|
|
raise RuntimeError(
|
|
f"simulated click failed for session {payload.session_id!r}"
|
|
) from exc
|
|
elif not session.clicked.wait(timeout=payload.timeout_seconds):
|
|
raise TimeoutError("timed out waiting for click")
|
|
return WaitForClickOutput(
|
|
clicked=session.clicked.is_set(), after=session.snapshot()
|
|
)
|
|
|
|
|
|
def _collect_snapshots(payload: CollectSnapshotsInput) -> CollectSnapshotsOutput:
|
|
session = _get_session(payload.session_id)
|
|
session.close()
|
|
with _SESSIONS_LOCK:
|
|
_SESSIONS.pop(payload.session_id, None)
|
|
return CollectSnapshotsOutput(
|
|
before=payload.before, after=payload.after, closed=True
|
|
)
|
|
|
|
|
|
def _get_session(session_id: str) -> _ClickSession:
|
|
with _SESSIONS_LOCK:
|
|
session = _SESSIONS.get(session_id)
|
|
if session is None:
|
|
raise ValueError(f"unknown click session {session_id!r}")
|
|
return session
|
|
|
|
|
|
def _active_session_count() -> int:
|
|
with _SESSIONS_LOCK:
|
|
return len(_SESSIONS)
|
|
|
|
|
|
registry = [open_click_page, wait_for_click, collect_snapshots]
|