openadapt-flow 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openadapt_flow/__init__.py +17 -0
- openadapt_flow/__main__.py +349 -0
- openadapt_flow/backend.py +33 -0
- openadapt_flow/backends/__init__.py +24 -0
- openadapt_flow/backends/playwright_backend.py +152 -0
- openadapt_flow/bench.py +162 -0
- openadapt_flow/compiler/__init__.py +6 -0
- openadapt_flow/compiler/codegen.py +88 -0
- openadapt_flow/compiler/compile.py +604 -0
- openadapt_flow/demo_driver.py +78 -0
- openadapt_flow/emit/__init__.py +13 -0
- openadapt_flow/emit/l1_artifact.py +151 -0
- openadapt_flow/emit/mcp_tool.py +174 -0
- openadapt_flow/emit/skill.py +145 -0
- openadapt_flow/ir.py +196 -0
- openadapt_flow/mockmed/__init__.py +9 -0
- openadapt_flow/mockmed/server.py +52 -0
- openadapt_flow/mockmed/static/app.js +209 -0
- openadapt_flow/mockmed/static/index.html +18 -0
- openadapt_flow/mockmed/static/styles.css +213 -0
- openadapt_flow/recorder.py +169 -0
- openadapt_flow/report.py +247 -0
- openadapt_flow/runtime/__init__.py +47 -0
- openadapt_flow/runtime/grounder.py +190 -0
- openadapt_flow/runtime/heal.py +262 -0
- openadapt_flow/runtime/replayer.py +419 -0
- openadapt_flow/runtime/resolver.py +297 -0
- openadapt_flow/vision/__init__.py +25 -0
- openadapt_flow/vision/hashing.py +55 -0
- openadapt_flow/vision/match.py +157 -0
- openadapt_flow/vision/ocr.py +147 -0
- openadapt_flow/vision/settle.py +61 -0
- openadapt_flow-0.1.0.dist-info/METADATA +138 -0
- openadapt_flow-0.1.0.dist-info/RECORD +37 -0
- openadapt_flow-0.1.0.dist-info/WHEEL +4 -0
- openadapt_flow-0.1.0.dist-info/entry_points.txt +2 -0
- openadapt_flow-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""openadapt-flow: record once, compile, replay deterministically, heal on drift."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from openadapt_flow.ir import ( # noqa: F401
|
|
6
|
+
ActionKind,
|
|
7
|
+
Anchor,
|
|
8
|
+
HealEvent,
|
|
9
|
+
Landmark,
|
|
10
|
+
Postcondition,
|
|
11
|
+
PostconditionKind,
|
|
12
|
+
Resolution,
|
|
13
|
+
RunReport,
|
|
14
|
+
Step,
|
|
15
|
+
StepResult,
|
|
16
|
+
Workflow,
|
|
17
|
+
)
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""openadapt-flow CLI.
|
|
2
|
+
|
|
3
|
+
Subcommands (thin wrappers over the module APIs; sibling modules are
|
|
4
|
+
imported lazily inside each handler so ``--help`` always works):
|
|
5
|
+
|
|
6
|
+
- ``demo-record`` — serve MockMed locally and record the canonical demo.
|
|
7
|
+
- ``compile`` — compile a recording directory into a workflow bundle.
|
|
8
|
+
- ``replay`` — replay a bundle; serves the bundled MockMed demo app when no
|
|
9
|
+
``--url`` is given (with optional ``--drift`` to demonstrate healing).
|
|
10
|
+
- ``bench`` — replay a bundle N times against MockMed and aggregate.
|
|
11
|
+
- ``emit-skill`` — emit an Agent Skills folder for a bundle.
|
|
12
|
+
- ``emit-mcp`` — emit a standalone MCP ``server.py`` for a bundle.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Sequence
|
|
21
|
+
|
|
22
|
+
_VIEWPORT = {"width": 1280, "height": 800}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parse_params(pairs: Sequence[str] | None) -> dict[str, str]:
|
|
26
|
+
"""Parse repeated ``--param k=v`` flags into a dict."""
|
|
27
|
+
params: dict[str, str] = {}
|
|
28
|
+
for pair in pairs or []:
|
|
29
|
+
if "=" not in pair:
|
|
30
|
+
raise SystemExit(f"--param expects k=v, got {pair!r}")
|
|
31
|
+
key, value = pair.split("=", 1)
|
|
32
|
+
params[key] = value
|
|
33
|
+
return params
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _with_drift(url: str, drift: str | None) -> str:
|
|
37
|
+
"""Append a ``?drift=...`` query to a MockMed base URL."""
|
|
38
|
+
if not drift:
|
|
39
|
+
return url
|
|
40
|
+
return f"{url.rstrip('/')}/?drift={drift}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _cmd_demo_record(args: argparse.Namespace) -> int:
|
|
44
|
+
from openadapt_flow.demo_driver import record_triage_demo
|
|
45
|
+
from openadapt_flow.mockmed.server import serve
|
|
46
|
+
|
|
47
|
+
url, stop = serve(port=0)
|
|
48
|
+
try:
|
|
49
|
+
url = _with_drift(url, args.drift)
|
|
50
|
+
out = record_triage_demo(
|
|
51
|
+
url,
|
|
52
|
+
Path(args.out),
|
|
53
|
+
note_text=args.note_text,
|
|
54
|
+
param_name=args.param_name,
|
|
55
|
+
headed=args.headed,
|
|
56
|
+
)
|
|
57
|
+
print(f"Recording written to {out}")
|
|
58
|
+
finally:
|
|
59
|
+
stop()
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _cmd_compile(args: argparse.Namespace) -> int:
|
|
64
|
+
from openadapt_flow.compiler import compile_recording
|
|
65
|
+
|
|
66
|
+
workflow = compile_recording(
|
|
67
|
+
Path(args.recording), Path(args.out), name=args.name
|
|
68
|
+
)
|
|
69
|
+
print(
|
|
70
|
+
f"Compiled {len(workflow.steps)} steps into {args.out} "
|
|
71
|
+
f"(workflow: {workflow.name!r})"
|
|
72
|
+
)
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _default_run_dir() -> Path:
|
|
77
|
+
"""Timestamped default run directory under ``runs/``."""
|
|
78
|
+
from datetime import datetime, timezone
|
|
79
|
+
|
|
80
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
81
|
+
return Path("runs") / f"replay-{stamp}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _cmd_replay(args: argparse.Namespace) -> int:
|
|
85
|
+
from playwright.sync_api import sync_playwright
|
|
86
|
+
|
|
87
|
+
from openadapt_flow.backends.playwright_backend import PlaywrightBackend
|
|
88
|
+
from openadapt_flow.ir import Workflow
|
|
89
|
+
from openadapt_flow.report import render_run_report
|
|
90
|
+
from openadapt_flow.runtime import Replayer
|
|
91
|
+
|
|
92
|
+
bundle = Path(args.bundle)
|
|
93
|
+
run_dir = Path(args.run_dir) if args.run_dir else _default_run_dir()
|
|
94
|
+
workflow = Workflow.load(bundle)
|
|
95
|
+
params = _parse_params(args.param)
|
|
96
|
+
|
|
97
|
+
if args.url and args.drift:
|
|
98
|
+
raise SystemExit(
|
|
99
|
+
"--drift only applies to the bundled MockMed demo app; "
|
|
100
|
+
"omit --url to use it (drift your own app for real)."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
stop = None
|
|
104
|
+
url = args.url
|
|
105
|
+
if url is None:
|
|
106
|
+
from openadapt_flow.mockmed.server import serve
|
|
107
|
+
|
|
108
|
+
url, stop = serve(port=0)
|
|
109
|
+
url = _with_drift(url, args.drift)
|
|
110
|
+
drift_note = f" (drift: {args.drift})" if args.drift else ""
|
|
111
|
+
print(f"No --url given; replaying against bundled MockMed{drift_note}")
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
with sync_playwright() as p:
|
|
115
|
+
browser = p.chromium.launch(headless=not args.headed)
|
|
116
|
+
page = browser.new_page(viewport=_VIEWPORT)
|
|
117
|
+
page.goto(url)
|
|
118
|
+
try:
|
|
119
|
+
backend = PlaywrightBackend(page)
|
|
120
|
+
report = Replayer(backend).run(
|
|
121
|
+
workflow,
|
|
122
|
+
params=params,
|
|
123
|
+
bundle_dir=bundle,
|
|
124
|
+
run_dir=run_dir,
|
|
125
|
+
save_healed_to=(
|
|
126
|
+
Path(args.save_healed_to)
|
|
127
|
+
if args.save_healed_to
|
|
128
|
+
else None
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
finally:
|
|
132
|
+
browser.close()
|
|
133
|
+
finally:
|
|
134
|
+
if stop is not None:
|
|
135
|
+
stop()
|
|
136
|
+
|
|
137
|
+
report_md = render_run_report(run_dir)
|
|
138
|
+
outcome = "success" if report.success else "FAILED"
|
|
139
|
+
print(f"Replay {outcome}: {report_md}")
|
|
140
|
+
return 0 if report.success else 1
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _cmd_bench(args: argparse.Namespace) -> int:
|
|
144
|
+
from contextlib import contextmanager
|
|
145
|
+
|
|
146
|
+
from openadapt_flow.bench import run_bench
|
|
147
|
+
from openadapt_flow.mockmed.server import serve
|
|
148
|
+
from openadapt_flow.report import render_bench_report
|
|
149
|
+
|
|
150
|
+
url, stop = serve(port=0)
|
|
151
|
+
target_url = _with_drift(url, args.drift)
|
|
152
|
+
|
|
153
|
+
@contextmanager
|
|
154
|
+
def backend_factory():
|
|
155
|
+
from playwright.sync_api import sync_playwright
|
|
156
|
+
|
|
157
|
+
from openadapt_flow.backends.playwright_backend import (
|
|
158
|
+
PlaywrightBackend,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
with sync_playwright() as p:
|
|
162
|
+
browser = p.chromium.launch(headless=not args.headed)
|
|
163
|
+
page = browser.new_page(viewport=_VIEWPORT)
|
|
164
|
+
page.goto(target_url)
|
|
165
|
+
try:
|
|
166
|
+
yield PlaywrightBackend(page)
|
|
167
|
+
finally:
|
|
168
|
+
browser.close()
|
|
169
|
+
|
|
170
|
+
run_root = Path(args.run_root)
|
|
171
|
+
try:
|
|
172
|
+
result = run_bench(
|
|
173
|
+
Path(args.bundle),
|
|
174
|
+
backend_factory,
|
|
175
|
+
args.n,
|
|
176
|
+
params=_parse_params(args.param),
|
|
177
|
+
run_root=run_root,
|
|
178
|
+
)
|
|
179
|
+
finally:
|
|
180
|
+
stop()
|
|
181
|
+
|
|
182
|
+
report_md = render_bench_report(
|
|
183
|
+
run_root / "bench.json", run_root / "BENCH.md"
|
|
184
|
+
)
|
|
185
|
+
print(
|
|
186
|
+
f"Bench: {result['success_count']}/{result['n']} succeeded "
|
|
187
|
+
f"(p50 {result['total_ms_p50']:.0f} ms) — {report_md}"
|
|
188
|
+
)
|
|
189
|
+
return 0 if result["success_count"] == result["n"] else 1
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _cmd_emit_skill(args: argparse.Namespace) -> int:
|
|
193
|
+
from openadapt_flow.emit.skill import emit_skill
|
|
194
|
+
|
|
195
|
+
skill_dir = emit_skill(Path(args.bundle), Path(args.out))
|
|
196
|
+
print(f"Skill written to {skill_dir}")
|
|
197
|
+
return 0
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _cmd_emit_mcp(args: argparse.Namespace) -> int:
|
|
201
|
+
from openadapt_flow.emit.mcp_tool import emit_mcp_server
|
|
202
|
+
|
|
203
|
+
server_path = emit_mcp_server(Path(args.bundle), Path(args.out))
|
|
204
|
+
print(f"MCP server written to {server_path}")
|
|
205
|
+
return 0
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
209
|
+
"""Build the top-level argument parser."""
|
|
210
|
+
parser = argparse.ArgumentParser(
|
|
211
|
+
prog="openadapt-flow",
|
|
212
|
+
description=(
|
|
213
|
+
"Record a workflow once, compile it into a deterministic "
|
|
214
|
+
"vision-anchored script, replay it locally, heal it on drift."
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
218
|
+
|
|
219
|
+
p = sub.add_parser(
|
|
220
|
+
"demo-record",
|
|
221
|
+
help="Serve MockMed and record the canonical triage demo",
|
|
222
|
+
)
|
|
223
|
+
p.add_argument("--out", required=True, help="Recording output directory")
|
|
224
|
+
p.add_argument(
|
|
225
|
+
"--note-text",
|
|
226
|
+
default="Follow-up in 2 weeks; BP recheck.",
|
|
227
|
+
help="Note text typed during the demo (recorded as a parameter)",
|
|
228
|
+
)
|
|
229
|
+
p.add_argument(
|
|
230
|
+
"--param-name", default="note", help="Parameter name for the note"
|
|
231
|
+
)
|
|
232
|
+
p.add_argument(
|
|
233
|
+
"--drift", default=None, help="Comma-separated MockMed drift modes"
|
|
234
|
+
)
|
|
235
|
+
p.add_argument(
|
|
236
|
+
"--headed", action="store_true", help="Run the browser headed"
|
|
237
|
+
)
|
|
238
|
+
p.set_defaults(func=_cmd_demo_record)
|
|
239
|
+
|
|
240
|
+
p = sub.add_parser(
|
|
241
|
+
"compile", help="Compile a recording into a workflow bundle"
|
|
242
|
+
)
|
|
243
|
+
p.add_argument("recording", help="Recording directory")
|
|
244
|
+
p.add_argument("--out", required=True, help="Output bundle directory")
|
|
245
|
+
p.add_argument("--name", required=True, help="Workflow name")
|
|
246
|
+
p.set_defaults(func=_cmd_compile)
|
|
247
|
+
|
|
248
|
+
p = sub.add_parser(
|
|
249
|
+
"replay",
|
|
250
|
+
help=(
|
|
251
|
+
"Replay a bundle (serves the bundled MockMed demo app when "
|
|
252
|
+
"no --url is given)"
|
|
253
|
+
),
|
|
254
|
+
)
|
|
255
|
+
p.add_argument("bundle", help="Workflow bundle directory")
|
|
256
|
+
p.add_argument(
|
|
257
|
+
"--url",
|
|
258
|
+
default=None,
|
|
259
|
+
help=(
|
|
260
|
+
"URL of the target app (default: serve the bundled MockMed "
|
|
261
|
+
"demo app)"
|
|
262
|
+
),
|
|
263
|
+
)
|
|
264
|
+
p.add_argument(
|
|
265
|
+
"--drift",
|
|
266
|
+
default=None,
|
|
267
|
+
help=(
|
|
268
|
+
"Comma-separated MockMed drift modes (theme,move,rename,modal) "
|
|
269
|
+
"to demonstrate self-healing; only valid without --url"
|
|
270
|
+
),
|
|
271
|
+
)
|
|
272
|
+
p.add_argument(
|
|
273
|
+
"--run-dir",
|
|
274
|
+
default=None,
|
|
275
|
+
help=(
|
|
276
|
+
"Run output directory "
|
|
277
|
+
"(default: runs/replay-<UTC timestamp> under the current "
|
|
278
|
+
"directory)"
|
|
279
|
+
),
|
|
280
|
+
)
|
|
281
|
+
p.add_argument(
|
|
282
|
+
"--param",
|
|
283
|
+
action="append",
|
|
284
|
+
metavar="K=V",
|
|
285
|
+
help="Parameter substitution (repeatable)",
|
|
286
|
+
)
|
|
287
|
+
p.add_argument(
|
|
288
|
+
"--save-healed-to",
|
|
289
|
+
default=None,
|
|
290
|
+
help="Write the healed bundle to this directory",
|
|
291
|
+
)
|
|
292
|
+
p.add_argument(
|
|
293
|
+
"--headed", action="store_true", help="Run the browser headed"
|
|
294
|
+
)
|
|
295
|
+
p.set_defaults(func=_cmd_replay)
|
|
296
|
+
|
|
297
|
+
p = sub.add_parser(
|
|
298
|
+
"bench", help="Replay a bundle N times against MockMed and aggregate"
|
|
299
|
+
)
|
|
300
|
+
p.add_argument("bundle", help="Workflow bundle directory")
|
|
301
|
+
p.add_argument("--n", type=int, default=3, help="Number of iterations")
|
|
302
|
+
p.add_argument(
|
|
303
|
+
"--drift",
|
|
304
|
+
default=None,
|
|
305
|
+
help="Comma-separated drift modes forwarded to the MockMed URL",
|
|
306
|
+
)
|
|
307
|
+
p.add_argument(
|
|
308
|
+
"--run-root", required=True, help="Directory for per-iteration runs"
|
|
309
|
+
)
|
|
310
|
+
p.add_argument(
|
|
311
|
+
"--param",
|
|
312
|
+
action="append",
|
|
313
|
+
metavar="K=V",
|
|
314
|
+
help="Parameter substitution (repeatable)",
|
|
315
|
+
)
|
|
316
|
+
p.add_argument(
|
|
317
|
+
"--headed", action="store_true", help="Run the browser headed"
|
|
318
|
+
)
|
|
319
|
+
p.set_defaults(func=_cmd_bench)
|
|
320
|
+
|
|
321
|
+
p = sub.add_parser(
|
|
322
|
+
"emit-skill", help="Emit an Agent Skills folder for a bundle"
|
|
323
|
+
)
|
|
324
|
+
p.add_argument("bundle", help="Workflow bundle directory")
|
|
325
|
+
p.add_argument(
|
|
326
|
+
"--out", required=True, help="Parent directory for the skill folder"
|
|
327
|
+
)
|
|
328
|
+
p.set_defaults(func=_cmd_emit_skill)
|
|
329
|
+
|
|
330
|
+
p = sub.add_parser(
|
|
331
|
+
"emit-mcp", help="Emit a standalone MCP server.py for a bundle"
|
|
332
|
+
)
|
|
333
|
+
p.add_argument("bundle", help="Workflow bundle directory")
|
|
334
|
+
p.add_argument(
|
|
335
|
+
"--out", required=True, help="Path for the generated server.py"
|
|
336
|
+
)
|
|
337
|
+
p.set_defaults(func=_cmd_emit_mcp)
|
|
338
|
+
|
|
339
|
+
return parser
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
343
|
+
"""CLI entry point. Returns a process exit code."""
|
|
344
|
+
args = build_parser().parse_args(argv)
|
|
345
|
+
return args.func(args)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
if __name__ == "__main__":
|
|
349
|
+
sys.exit(main())
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Backend protocol: the only interface the runtime uses to touch a GUI.
|
|
2
|
+
|
|
3
|
+
The runtime is vision-only by construction — it sees PNG bytes and emits
|
|
4
|
+
clicks/keys at pixel coordinates. Anything that can screenshot and inject
|
|
5
|
+
input can be a backend: a Playwright page (reference/test backend), a native
|
|
6
|
+
OS layer (pyautogui/Quartz), or an RDP session.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@runtime_checkable
|
|
15
|
+
class Backend(Protocol):
|
|
16
|
+
@property
|
|
17
|
+
def viewport(self) -> tuple[int, int]:
|
|
18
|
+
"""(width, height) of the screen surface in pixels."""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
def screenshot(self) -> bytes:
|
|
22
|
+
"""Return the current frame as PNG bytes."""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
def click(self, x: int, y: int, *, double: bool = False) -> None: ...
|
|
26
|
+
|
|
27
|
+
def type_text(self, text: str) -> None:
|
|
28
|
+
"""Type text into the currently focused element."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
def press(self, key: str) -> None:
|
|
32
|
+
"""Press a key or chord, e.g. 'Enter', 'Tab', 'Meta+a'."""
|
|
33
|
+
...
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Backend implementations of the `openadapt_flow.backend.Backend` protocol.
|
|
2
|
+
|
|
3
|
+
`PlaywrightBackend` is re-exported lazily so that importing this package does
|
|
4
|
+
not require playwright unless the backend is actually used.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
12
|
+
from openadapt_flow.backends.playwright_backend import PlaywrightBackend
|
|
13
|
+
|
|
14
|
+
__all__ = ["PlaywrightBackend"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def __getattr__(name: str) -> object:
|
|
18
|
+
if name == "PlaywrightBackend":
|
|
19
|
+
from openadapt_flow.backends.playwright_backend import (
|
|
20
|
+
PlaywrightBackend,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
return PlaywrightBackend
|
|
24
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Playwright-driven reference backend (sync API, chromium, headless-capable).
|
|
2
|
+
|
|
3
|
+
Implements the `openadapt_flow.backend.Backend` protocol against a Playwright
|
|
4
|
+
`Page`: full-viewport PNG screenshots, mouse clicks at pixel coordinates,
|
|
5
|
+
keyboard typing, and key/chord presses. Viewport is fixed at 1280x800 with
|
|
6
|
+
deviceScaleFactor=1 so CSS pixels equal screenshot pixels.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING, Callable
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
14
|
+
from playwright.sync_api import Page
|
|
15
|
+
|
|
16
|
+
VIEWPORT: tuple[int, int] = (1280, 800)
|
|
17
|
+
|
|
18
|
+
_MODIFIER_ALIASES = {
|
|
19
|
+
"meta": "Meta",
|
|
20
|
+
"cmd": "Meta",
|
|
21
|
+
"command": "Meta",
|
|
22
|
+
"ctrl": "Control",
|
|
23
|
+
"control": "Control",
|
|
24
|
+
"alt": "Alt",
|
|
25
|
+
"option": "Alt",
|
|
26
|
+
"shift": "Shift",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_NAMED_KEYS = {
|
|
30
|
+
"enter": "Enter",
|
|
31
|
+
"return": "Enter",
|
|
32
|
+
"tab": "Tab",
|
|
33
|
+
"escape": "Escape",
|
|
34
|
+
"esc": "Escape",
|
|
35
|
+
"backspace": "Backspace",
|
|
36
|
+
"delete": "Delete",
|
|
37
|
+
"space": "Space",
|
|
38
|
+
"home": "Home",
|
|
39
|
+
"end": "End",
|
|
40
|
+
"pageup": "PageUp",
|
|
41
|
+
"pagedown": "PageDown",
|
|
42
|
+
"arrowup": "ArrowUp",
|
|
43
|
+
"arrowdown": "ArrowDown",
|
|
44
|
+
"arrowleft": "ArrowLeft",
|
|
45
|
+
"arrowright": "ArrowRight",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _normalize_chord(key: str) -> str:
|
|
50
|
+
"""Normalize a key or chord like ``'Meta+a'`` to Playwright's format.
|
|
51
|
+
|
|
52
|
+
Modifier aliases (``ctrl``, ``cmd``, ...) are canonicalized; common named
|
|
53
|
+
keys are case-corrected; single characters pass through unchanged.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
key: Key name or ``+``-joined chord (e.g. ``'Enter'``, ``'Meta+a'``).
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
The Playwright-compatible key/chord string.
|
|
60
|
+
"""
|
|
61
|
+
parts = [p for p in key.split("+") if p]
|
|
62
|
+
normalized: list[str] = []
|
|
63
|
+
for part in parts:
|
|
64
|
+
lower = part.lower()
|
|
65
|
+
if lower in _MODIFIER_ALIASES:
|
|
66
|
+
normalized.append(_MODIFIER_ALIASES[lower])
|
|
67
|
+
elif lower in _NAMED_KEYS:
|
|
68
|
+
normalized.append(_NAMED_KEYS[lower])
|
|
69
|
+
else:
|
|
70
|
+
normalized.append(part)
|
|
71
|
+
return "+".join(normalized)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class PlaywrightBackend:
|
|
75
|
+
"""`Backend` implementation over a Playwright sync-API `Page`.
|
|
76
|
+
|
|
77
|
+
Attributes:
|
|
78
|
+
page: The underlying Playwright page (public so record-time helpers
|
|
79
|
+
such as the demo driver may use locators; replay never does).
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(self, page: "Page") -> None:
|
|
83
|
+
"""Wrap an existing Playwright page.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
page: A page created with viewport 1280x800, deviceScaleFactor=1.
|
|
87
|
+
"""
|
|
88
|
+
self.page = page
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def viewport(self) -> tuple[int, int]:
|
|
92
|
+
"""(width, height) of the page viewport in pixels."""
|
|
93
|
+
size = self.page.viewport_size
|
|
94
|
+
if size is None: # pragma: no cover - viewport always set by launch()
|
|
95
|
+
return VIEWPORT
|
|
96
|
+
return (size["width"], size["height"])
|
|
97
|
+
|
|
98
|
+
def screenshot(self) -> bytes:
|
|
99
|
+
"""Return the current full-viewport frame as PNG bytes."""
|
|
100
|
+
return self.page.screenshot(type="png", full_page=False)
|
|
101
|
+
|
|
102
|
+
def click(self, x: int, y: int, *, double: bool = False) -> None:
|
|
103
|
+
"""Click (or double-click) at pixel coordinates via the mouse."""
|
|
104
|
+
if double:
|
|
105
|
+
self.page.mouse.dblclick(x, y)
|
|
106
|
+
else:
|
|
107
|
+
self.page.mouse.click(x, y)
|
|
108
|
+
|
|
109
|
+
def type_text(self, text: str) -> None:
|
|
110
|
+
"""Type text into the currently focused element."""
|
|
111
|
+
self.page.keyboard.type(text)
|
|
112
|
+
|
|
113
|
+
def press(self, key: str) -> None:
|
|
114
|
+
"""Press a key or chord, e.g. ``'Enter'`` or ``'Meta+a'``."""
|
|
115
|
+
self.page.keyboard.press(_normalize_chord(key))
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def launch(
|
|
119
|
+
cls, url: str, headless: bool = True
|
|
120
|
+
) -> tuple["PlaywrightBackend", Callable[[], None]]:
|
|
121
|
+
"""Start Playwright + chromium, open ``url``, and return a backend.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
url: URL to navigate the new page to.
|
|
125
|
+
headless: Whether to launch chromium headless.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
``(backend, close)`` where ``close()`` shuts down the browser and
|
|
129
|
+
the Playwright driver.
|
|
130
|
+
"""
|
|
131
|
+
from playwright.sync_api import sync_playwright
|
|
132
|
+
|
|
133
|
+
pw = sync_playwright().start()
|
|
134
|
+
try:
|
|
135
|
+
browser = pw.chromium.launch(headless=headless)
|
|
136
|
+
except Exception:
|
|
137
|
+
pw.stop()
|
|
138
|
+
raise
|
|
139
|
+
page = browser.new_page(
|
|
140
|
+
viewport={"width": VIEWPORT[0], "height": VIEWPORT[1]},
|
|
141
|
+
device_scale_factor=1,
|
|
142
|
+
)
|
|
143
|
+
page.goto(url)
|
|
144
|
+
backend = cls(page)
|
|
145
|
+
|
|
146
|
+
def close() -> None:
|
|
147
|
+
try:
|
|
148
|
+
browser.close()
|
|
149
|
+
finally:
|
|
150
|
+
pw.stop()
|
|
151
|
+
|
|
152
|
+
return backend, close
|