feat: add browser click source example
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
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.request import Request, urlopen
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from wf_authoring import node
|
||||
|
||||
|
||||
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 close(self) -> None:
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=2)
|
||||
|
||||
|
||||
_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.events.append("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 = 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">{session.button_label}</button>
|
||||
<p id="status">{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.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:
|
||||
webbrowser.open(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")
|
||||
with urlopen(request, timeout=payload.timeout_seconds) as response:
|
||||
response.read()
|
||||
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]
|
||||
Reference in New Issue
Block a user