capability-compiler 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.
- capability_compiler/__init__.py +76 -0
- capability_compiler/_version.py +8 -0
- capability_compiler/adapters/__init__.py +70 -0
- capability_compiler/adapters/base.py +166 -0
- capability_compiler/adapters/browser/__init__.py +7 -0
- capability_compiler/adapters/browser/adapter.py +474 -0
- capability_compiler/adapters/browser/aria.py +147 -0
- capability_compiler/adapters/browser/elements.py +185 -0
- capability_compiler/adapters/desktop/__init__.py +9 -0
- capability_compiler/adapters/desktop/base.py +90 -0
- capability_compiler/adapters/fake_adapter.py +242 -0
- capability_compiler/benchmark/__init__.py +42 -0
- capability_compiler/benchmark/data/capability_bench.json +81 -0
- capability_compiler/benchmark/data/seed.json +148 -0
- capability_compiler/benchmark/report.py +164 -0
- capability_compiler/benchmark/spec.py +249 -0
- capability_compiler/benchmark/suite.py +396 -0
- capability_compiler/cli/__init__.py +5 -0
- capability_compiler/cli/_common.py +135 -0
- capability_compiler/cli/_register.py +35 -0
- capability_compiler/cli/benchmark_cmd.py +284 -0
- capability_compiler/cli/config_cmds.py +230 -0
- capability_compiler/cli/execute_cmd.py +196 -0
- capability_compiler/cli/learn_cmd.py +134 -0
- capability_compiler/cli/main.py +184 -0
- capability_compiler/cli/registry_cmds.py +127 -0
- capability_compiler/cli/serve_cmd.py +124 -0
- capability_compiler/compiler.py +203 -0
- capability_compiler/config.py +282 -0
- capability_compiler/errors.py +348 -0
- capability_compiler/exploration/__init__.py +33 -0
- capability_compiler/exploration/effects.py +227 -0
- capability_compiler/exploration/engine.py +347 -0
- capability_compiler/exploration/semantics.py +257 -0
- capability_compiler/logging.py +228 -0
- capability_compiler/models/__init__.py +127 -0
- capability_compiler/models/action.py +216 -0
- capability_compiler/models/capability.py +389 -0
- capability_compiler/models/observation.py +281 -0
- capability_compiler/models/state.py +49 -0
- capability_compiler/models/transition.py +124 -0
- capability_compiler/models/verification.py +85 -0
- capability_compiler/models/versioning.py +77 -0
- capability_compiler/perception/__init__.py +22 -0
- capability_compiler/perception/pipeline.py +70 -0
- capability_compiler/perception/semantic.py +245 -0
- capability_compiler/providers/__init__.py +60 -0
- capability_compiler/providers/anthropic.py +132 -0
- capability_compiler/providers/base.py +185 -0
- capability_compiler/providers/http.py +216 -0
- capability_compiler/providers/mock.py +158 -0
- capability_compiler/providers/ollama.py +124 -0
- capability_compiler/providers/openai_compat.py +153 -0
- capability_compiler/recording/__init__.py +6 -0
- capability_compiler/recording/recorder.py +255 -0
- capability_compiler/recording/replay.py +174 -0
- capability_compiler/refinement/__init__.py +11 -0
- capability_compiler/refinement/engine.py +188 -0
- capability_compiler/refinement/repair.py +254 -0
- capability_compiler/registry/__init__.py +24 -0
- capability_compiler/registry/permissions.py +191 -0
- capability_compiler/registry/registry.py +251 -0
- capability_compiler/runtime/__init__.py +17 -0
- capability_compiler/runtime/assertions.py +114 -0
- capability_compiler/runtime/executor.py +422 -0
- capability_compiler/server/__init__.py +114 -0
- capability_compiler/server/auth.py +141 -0
- capability_compiler/server/executor_factory.py +75 -0
- capability_compiler/server/server.py +555 -0
- capability_compiler/server/tool_defs.py +98 -0
- capability_compiler/storage/__init__.py +7 -0
- capability_compiler/storage/base.py +62 -0
- capability_compiler/storage/filesystem.py +236 -0
- capability_compiler/storage/sqlite.py +284 -0
- capability_compiler/synthesis/__init__.py +20 -0
- capability_compiler/synthesis/engine.py +549 -0
- capability_compiler/synthesis/templating.py +84 -0
- capability_compiler/types.py +97 -0
- capability_compiler/verification/__init__.py +42 -0
- capability_compiler/verification/base.py +97 -0
- capability_compiler/verification/file_visual.py +224 -0
- capability_compiler/verification/runner.py +79 -0
- capability_compiler/verification/verifiers.py +292 -0
- capability_compiler-0.1.0.dist-info/METADATA +261 -0
- capability_compiler-0.1.0.dist-info/RECORD +88 -0
- capability_compiler-0.1.0.dist-info/WHEEL +4 -0
- capability_compiler-0.1.0.dist-info/entry_points.txt +2 -0
- capability_compiler-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"""The Playwright browser adapter — Capability Compiler's first adapter.
|
|
2
|
+
|
|
3
|
+
Wraps (never reimplements) Playwright's async API. Responsibilities:
|
|
4
|
+
|
|
5
|
+
* **Observe**: one normalized :class:`Observation` per call — aria snapshot
|
|
6
|
+
tree (state identity), DOM-scan interactive elements with geometry
|
|
7
|
+
(actionability), screenshot artifact, URL/title/DOM hash.
|
|
8
|
+
* **Execute**: map domain actions to Playwright calls through semantic-first
|
|
9
|
+
target resolution (ref → role+name+ordinal → selector → coordinates),
|
|
10
|
+
letting Playwright's five actionability checks do the waiting.
|
|
11
|
+
* **Ref stability**: ``e1..eN`` ids are positional within an observation;
|
|
12
|
+
execution always re-resolves against a fresh observation, so recorded
|
|
13
|
+
capabilities survive DOM churn better than raw handles.
|
|
14
|
+
|
|
15
|
+
Security:
|
|
16
|
+
* Navigation is **deny-by-default for the network**: with
|
|
17
|
+
``security.allow_network = false`` (the default) only ``file:``/``data:``
|
|
18
|
+
``about:`` and loopback URLs are permitted. Remote navigation requires the
|
|
19
|
+
explicit opt-in.
|
|
20
|
+
* Page content (names, DOM, screenshots) is untrusted data. Nothing from the
|
|
21
|
+
page is ever evaluated as code by this adapter.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import contextlib
|
|
27
|
+
import hashlib
|
|
28
|
+
import time
|
|
29
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
30
|
+
from urllib.parse import urlparse
|
|
31
|
+
|
|
32
|
+
from capability_compiler.adapters.browser.aria import parse_aria_snapshot
|
|
33
|
+
from capability_compiler.adapters.browser.elements import _SCAN_JS, parse_scan
|
|
34
|
+
from capability_compiler.errors import (
|
|
35
|
+
ActionError,
|
|
36
|
+
ActionValidationError,
|
|
37
|
+
AdapterError,
|
|
38
|
+
AdapterNotConnectedError,
|
|
39
|
+
FailureCategory,
|
|
40
|
+
ObservationError,
|
|
41
|
+
PermissionDeniedError,
|
|
42
|
+
)
|
|
43
|
+
from capability_compiler.logging import StructuredLogAdapter, get_logger
|
|
44
|
+
from capability_compiler.models import (
|
|
45
|
+
ActionResult,
|
|
46
|
+
EnvironmentMetadata,
|
|
47
|
+
ImageData,
|
|
48
|
+
Observation,
|
|
49
|
+
StructuralObservation,
|
|
50
|
+
)
|
|
51
|
+
from capability_compiler.models.action import (
|
|
52
|
+
ClickAction,
|
|
53
|
+
HoverAction,
|
|
54
|
+
KeyPressAction,
|
|
55
|
+
NavigateAction,
|
|
56
|
+
ScrollAction,
|
|
57
|
+
SelectAction,
|
|
58
|
+
TypeAction,
|
|
59
|
+
WaitAction,
|
|
60
|
+
)
|
|
61
|
+
from capability_compiler.types import new_id
|
|
62
|
+
|
|
63
|
+
if TYPE_CHECKING:
|
|
64
|
+
from capability_compiler.config import CompilerSettings
|
|
65
|
+
from capability_compiler.models import Action
|
|
66
|
+
from capability_compiler.models.action import ActionTarget
|
|
67
|
+
|
|
68
|
+
from playwright.async_api import Error as PlaywrightError
|
|
69
|
+
from playwright.async_api import Page, async_playwright
|
|
70
|
+
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _text_digest(text: str | None) -> str | None:
|
|
74
|
+
"""Whitespace-normalized digest of visible page text (deterministic)."""
|
|
75
|
+
if text is None:
|
|
76
|
+
return None
|
|
77
|
+
normalized = " ".join(text.split())
|
|
78
|
+
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_LOCAL_SCHEMES = {"file", "data", "about", "blob"}
|
|
82
|
+
_LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "[::1]", "::1"}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def url_allowed(url: str, *, allow_network: bool) -> bool:
|
|
86
|
+
"""Navigation policy: local schemes always; loopback always; anything
|
|
87
|
+
else requires ``allow_network``. Unparseable/unknown-scheme URLs deny."""
|
|
88
|
+
try:
|
|
89
|
+
parsed = urlparse(url)
|
|
90
|
+
except ValueError:
|
|
91
|
+
return False
|
|
92
|
+
scheme = (parsed.scheme or "").lower()
|
|
93
|
+
if not scheme:
|
|
94
|
+
return False
|
|
95
|
+
if scheme in _LOCAL_SCHEMES:
|
|
96
|
+
return True
|
|
97
|
+
if scheme in {"http", "https"}:
|
|
98
|
+
if parsed.hostname and parsed.hostname.lower() in _LOOPBACK_HOSTS:
|
|
99
|
+
return True
|
|
100
|
+
return allow_network
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class BrowserAdapter:
|
|
105
|
+
"""Playwright-backed ``EnvironmentAdapter`` (kind ``"browser"``)."""
|
|
106
|
+
|
|
107
|
+
kind = "browser"
|
|
108
|
+
|
|
109
|
+
def __init__(self, settings: CompilerSettings | None = None) -> None:
|
|
110
|
+
from capability_compiler.config import CompilerSettings as _Settings
|
|
111
|
+
|
|
112
|
+
self.settings = settings or _Settings()
|
|
113
|
+
self._log = StructuredLogAdapter(get_logger("adapters.browser"), {})
|
|
114
|
+
self._playwright: Any = None
|
|
115
|
+
self._browser: Any = None
|
|
116
|
+
self._context: Any = None
|
|
117
|
+
self._page: Page | None = None
|
|
118
|
+
self._start_url: str | None = None
|
|
119
|
+
self._last_observation: Observation | None = None
|
|
120
|
+
|
|
121
|
+
# -- properties --------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def connected(self) -> bool:
|
|
125
|
+
return self._browser is not None and self._browser.is_connected()
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def page(self) -> Page:
|
|
129
|
+
if self._page is None:
|
|
130
|
+
raise AdapterNotConnectedError("browser adapter has no page")
|
|
131
|
+
return self._page
|
|
132
|
+
|
|
133
|
+
# -- lifecycle ----------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
async def connect(self, url: str | None = None) -> None:
|
|
136
|
+
"""Launch the browser; optionally navigate to *url*."""
|
|
137
|
+
if self.connected:
|
|
138
|
+
return
|
|
139
|
+
browser_cfg = self.settings.browser
|
|
140
|
+
try:
|
|
141
|
+
self._playwright = await async_playwright().start()
|
|
142
|
+
launch_kwargs: dict[str, Any] = {
|
|
143
|
+
"headless": browser_cfg.headless,
|
|
144
|
+
"slow_mo": browser_cfg.slow_mo_ms or None,
|
|
145
|
+
}
|
|
146
|
+
if browser_cfg.extra_launch_args:
|
|
147
|
+
launch_kwargs["args"] = list(browser_cfg.extra_launch_args)
|
|
148
|
+
self._browser = await getattr(self._playwright, browser_cfg.browser).launch(
|
|
149
|
+
**launch_kwargs
|
|
150
|
+
)
|
|
151
|
+
context_kwargs: dict[str, Any] = {
|
|
152
|
+
"viewport": {
|
|
153
|
+
"width": browser_cfg.viewport_width,
|
|
154
|
+
"height": browser_cfg.viewport_height,
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
if browser_cfg.user_agent:
|
|
158
|
+
context_kwargs["user_agent"] = browser_cfg.user_agent
|
|
159
|
+
self._context = await self._browser.new_context(**context_kwargs)
|
|
160
|
+
self._context.set_default_timeout(browser_cfg.default_timeout_ms)
|
|
161
|
+
self._page = await self._context.new_page()
|
|
162
|
+
except PlaywrightError as exc:
|
|
163
|
+
raise AdapterError(
|
|
164
|
+
f"failed to launch {browser_cfg.browser}: {exc}",
|
|
165
|
+
category=FailureCategory.ENVIRONMENT_ERROR,
|
|
166
|
+
suggestions=["run: python -m playwright install " + browser_cfg.browser],
|
|
167
|
+
) from exc
|
|
168
|
+
target = url or "about:blank"
|
|
169
|
+
await self._goto(target)
|
|
170
|
+
self._start_url = target
|
|
171
|
+
|
|
172
|
+
async def disconnect(self) -> None:
|
|
173
|
+
for closer in (
|
|
174
|
+
getattr(self._context, "close", None),
|
|
175
|
+
getattr(self._browser, "close", None),
|
|
176
|
+
getattr(self._playwright, "stop", None),
|
|
177
|
+
):
|
|
178
|
+
if closer is not None:
|
|
179
|
+
with contextlib.suppress(PlaywrightError): # teardown races
|
|
180
|
+
await closer()
|
|
181
|
+
self._page = None
|
|
182
|
+
self._context = None
|
|
183
|
+
self._browser = None
|
|
184
|
+
self._playwright = None
|
|
185
|
+
|
|
186
|
+
async def reset(self) -> None:
|
|
187
|
+
"""Return to the start URL (best effort; pages with side effects may
|
|
188
|
+
not fully reset — capability preconditions are the real guard)."""
|
|
189
|
+
if self._start_url:
|
|
190
|
+
await self._goto(self._start_url)
|
|
191
|
+
|
|
192
|
+
# -- observation ---------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
async def observe(self, *, screenshot: bool = True) -> Observation:
|
|
195
|
+
"""Capture one normalized observation of the current page."""
|
|
196
|
+
page = self.page
|
|
197
|
+
try:
|
|
198
|
+
snapshot_yaml = await page.aria_snapshot()
|
|
199
|
+
raw_elements = await page.evaluate(_SCAN_JS)
|
|
200
|
+
url = page.url
|
|
201
|
+
title = await page.title()
|
|
202
|
+
content = await page.content()
|
|
203
|
+
visible_text = await page.evaluate("() => document.body.innerText")
|
|
204
|
+
except PlaywrightError as exc:
|
|
205
|
+
raise ObservationError(
|
|
206
|
+
f"failed to observe page: {exc}",
|
|
207
|
+
category=FailureCategory.ENVIRONMENT_ERROR,
|
|
208
|
+
) from exc
|
|
209
|
+
|
|
210
|
+
visual = None
|
|
211
|
+
if screenshot:
|
|
212
|
+
visual = await self._capture_screenshot()
|
|
213
|
+
|
|
214
|
+
observation = Observation(
|
|
215
|
+
observation_id=new_id("obs"),
|
|
216
|
+
sequence_number=self._next_sequence(),
|
|
217
|
+
visual=visual,
|
|
218
|
+
structural=StructuralObservation(
|
|
219
|
+
url=url,
|
|
220
|
+
title=title,
|
|
221
|
+
dom_hash=hashlib.sha256(content.encode("utf-8")).hexdigest(),
|
|
222
|
+
text_digest=_text_digest(visible_text),
|
|
223
|
+
viewport_width=self.settings.browser.viewport_width,
|
|
224
|
+
viewport_height=self.settings.browser.viewport_height,
|
|
225
|
+
),
|
|
226
|
+
accessibility_tree=parse_aria_snapshot(snapshot_yaml),
|
|
227
|
+
interactive_elements=parse_scan(raw_elements),
|
|
228
|
+
environment=EnvironmentMetadata(
|
|
229
|
+
adapter_kind=self.kind,
|
|
230
|
+
adapter_version="playwright",
|
|
231
|
+
extra={"browser": self.settings.browser.browser},
|
|
232
|
+
),
|
|
233
|
+
)
|
|
234
|
+
self._last_observation = observation
|
|
235
|
+
return observation
|
|
236
|
+
|
|
237
|
+
async def _capture_screenshot(self) -> ImageData | None:
|
|
238
|
+
page = self.page
|
|
239
|
+
try:
|
|
240
|
+
png = await page.screenshot(full_page=False)
|
|
241
|
+
except PlaywrightError as exc:
|
|
242
|
+
self._log.warning(
|
|
243
|
+
"screenshot.failed", extra={"extra_fields": {"error": str(exc)[:120]}}
|
|
244
|
+
)
|
|
245
|
+
return None
|
|
246
|
+
path: str | None = None
|
|
247
|
+
artifacts = self.settings.storage.artifacts_dir
|
|
248
|
+
try:
|
|
249
|
+
artifacts.mkdir(parents=True, exist_ok=True)
|
|
250
|
+
target = artifacts / f"{new_id('shot')}.png"
|
|
251
|
+
target.write_bytes(png)
|
|
252
|
+
path = str(target)
|
|
253
|
+
except OSError:
|
|
254
|
+
path = None
|
|
255
|
+
return ImageData.from_bytes(
|
|
256
|
+
png,
|
|
257
|
+
width=self.settings.browser.viewport_width,
|
|
258
|
+
height=self.settings.browser.viewport_height,
|
|
259
|
+
path=path,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
def _next_sequence(self) -> int:
|
|
263
|
+
return (self._last_observation.sequence_number + 1) if self._last_observation else 0
|
|
264
|
+
|
|
265
|
+
# -- execution ------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
async def execute(self, action: Action) -> ActionResult:
|
|
268
|
+
"""Apply *action*; action-level failures are returned, not raised."""
|
|
269
|
+
if not self.connected or self._page is None:
|
|
270
|
+
raise AdapterNotConnectedError("browser adapter is not connected")
|
|
271
|
+
started = time.perf_counter()
|
|
272
|
+
try:
|
|
273
|
+
await self._dispatch(action)
|
|
274
|
+
duration_ms = (time.perf_counter() - started) * 1000.0
|
|
275
|
+
return ActionResult(
|
|
276
|
+
action_id=action.action_id,
|
|
277
|
+
success=True,
|
|
278
|
+
duration_ms=duration_ms,
|
|
279
|
+
adapter_details={"browser": self.settings.browser.browser},
|
|
280
|
+
)
|
|
281
|
+
except PlaywrightTimeoutError as exc:
|
|
282
|
+
return ActionResult.failure(
|
|
283
|
+
action.action_id,
|
|
284
|
+
FailureCategory.TIMEOUT,
|
|
285
|
+
"action.timeout",
|
|
286
|
+
f"action timed out: {str(exc).splitlines()[0][:200]}",
|
|
287
|
+
)
|
|
288
|
+
except (ActionError, ActionValidationError, PermissionDeniedError) as exc:
|
|
289
|
+
return ActionResult.failure(
|
|
290
|
+
action.action_id,
|
|
291
|
+
exc.category,
|
|
292
|
+
exc.code,
|
|
293
|
+
exc.message,
|
|
294
|
+
)
|
|
295
|
+
except PlaywrightError as exc:
|
|
296
|
+
return ActionResult.failure(
|
|
297
|
+
action.action_id,
|
|
298
|
+
FailureCategory.ACTION_FAILED,
|
|
299
|
+
"action.playwright_error",
|
|
300
|
+
str(exc).splitlines()[0][:300],
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
async def _dispatch(self, action: Action) -> None:
|
|
304
|
+
import asyncio
|
|
305
|
+
|
|
306
|
+
if isinstance(action, ClickAction):
|
|
307
|
+
locator = await self.resolve_target(action.target, role_hint="button")
|
|
308
|
+
if action.double:
|
|
309
|
+
await locator.dblclick(button=action.button)
|
|
310
|
+
else:
|
|
311
|
+
await locator.click(button=action.button)
|
|
312
|
+
elif isinstance(action, TypeAction):
|
|
313
|
+
locator = await self.resolve_target(action.target)
|
|
314
|
+
await locator.fill(action.text)
|
|
315
|
+
if action.submit:
|
|
316
|
+
await locator.press("Enter")
|
|
317
|
+
elif isinstance(action, SelectAction):
|
|
318
|
+
locator = await self.resolve_target(action.target)
|
|
319
|
+
await locator.select_option(action.value)
|
|
320
|
+
elif isinstance(action, ScrollAction):
|
|
321
|
+
dy = {"up": -action.amount_px, "down": action.amount_px}.get(action.direction, 0)
|
|
322
|
+
dx = {"left": -action.amount_px, "right": action.amount_px}.get(action.direction, 0)
|
|
323
|
+
if action.target is not None:
|
|
324
|
+
locator = await self.resolve_target(action.target)
|
|
325
|
+
await locator.scroll_into_view_if_needed()
|
|
326
|
+
await self.page.mouse.wheel(dx, dy)
|
|
327
|
+
elif isinstance(action, HoverAction):
|
|
328
|
+
locator = await self.resolve_target(action.target)
|
|
329
|
+
await locator.hover()
|
|
330
|
+
elif isinstance(action, KeyPressAction):
|
|
331
|
+
await self.page.keyboard.press(action.key)
|
|
332
|
+
if action.hold_ms:
|
|
333
|
+
await asyncio.sleep(action.hold_ms / 1000)
|
|
334
|
+
elif isinstance(action, NavigateAction):
|
|
335
|
+
await self._goto(action.url)
|
|
336
|
+
elif isinstance(action, WaitAction):
|
|
337
|
+
await self.page.wait_for_timeout(action.milliseconds)
|
|
338
|
+
else: # pragma: no cover - discriminated union is closed
|
|
339
|
+
raise ActionValidationError(
|
|
340
|
+
f"unsupported action kind: {type(action).__name__}",
|
|
341
|
+
context={"kind": getattr(action, "kind", "?")},
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
async def _goto(self, url: str) -> None:
|
|
345
|
+
if not url_allowed(url, allow_network=self.settings.security.allow_network):
|
|
346
|
+
raise PermissionDeniedError(
|
|
347
|
+
f"navigation to {url!r} requires allow_network=true",
|
|
348
|
+
context={"url": url},
|
|
349
|
+
suggestions=[
|
|
350
|
+
"cc config: set security.allow_network=true",
|
|
351
|
+
"or use a file:/// URL for local content",
|
|
352
|
+
],
|
|
353
|
+
)
|
|
354
|
+
try:
|
|
355
|
+
await self.page.goto(url, wait_until="domcontentloaded")
|
|
356
|
+
except PlaywrightError as exc:
|
|
357
|
+
raise AdapterError(
|
|
358
|
+
f"navigation failed: {str(exc).splitlines()[0][:200]}",
|
|
359
|
+
category=FailureCategory.ENVIRONMENT_ERROR,
|
|
360
|
+
) from exc
|
|
361
|
+
|
|
362
|
+
# -- target resolution ------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
async def resolve_target(
|
|
365
|
+
self,
|
|
366
|
+
target: ActionTarget | None,
|
|
367
|
+
*,
|
|
368
|
+
role_hint: str | None = None,
|
|
369
|
+
_retried: bool = False,
|
|
370
|
+
) -> Any:
|
|
371
|
+
"""Resolve a semantic target to a Playwright locator.
|
|
372
|
+
|
|
373
|
+
Order: observation ``ref`` (role+name+ordinal) → explicit role+name →
|
|
374
|
+
selector → coordinates. When a ``ref`` misses the cached observation
|
|
375
|
+
(stale page, first action after navigation), the observation is
|
|
376
|
+
refreshed once and the lookup retried — the seed of Phase-6
|
|
377
|
+
re-anchoring: refs are positional, roles+names are semantic.
|
|
378
|
+
"""
|
|
379
|
+
if target is None:
|
|
380
|
+
raise ActionValidationError("action has no target")
|
|
381
|
+
page = self.page
|
|
382
|
+
|
|
383
|
+
if target.ref:
|
|
384
|
+
element = (
|
|
385
|
+
self._last_observation.find_element(target.ref)
|
|
386
|
+
if self._last_observation is not None
|
|
387
|
+
else None
|
|
388
|
+
)
|
|
389
|
+
if element is None and not _retried:
|
|
390
|
+
# No cached observation, or it is stale (navigation since).
|
|
391
|
+
await self.observe(screenshot=False)
|
|
392
|
+
return await self.resolve_target(target, role_hint=role_hint, _retried=True)
|
|
393
|
+
if element is not None and self._ref_agrees(target, element):
|
|
394
|
+
locator = self._locator_for_element(page, element)
|
|
395
|
+
if locator is not None:
|
|
396
|
+
return locator
|
|
397
|
+
# fall through when the ref is stale OR disagrees with the
|
|
398
|
+
# recorded semantics (drifted DOM renumbered the positional
|
|
399
|
+
# refs) — role+name below re-anchors to the right element.
|
|
400
|
+
|
|
401
|
+
if target.role or target.name:
|
|
402
|
+
role = str(target.role or role_hint or "button")
|
|
403
|
+
locator = page.get_by_role(cast("Any", role), name=target.name or "")
|
|
404
|
+
return locator.first if not target.name else locator
|
|
405
|
+
|
|
406
|
+
if target.selector:
|
|
407
|
+
return page.locator(target.selector).first
|
|
408
|
+
|
|
409
|
+
if target.coordinates is not None:
|
|
410
|
+
return page.locator("body") # caller uses coordinates via mouse
|
|
411
|
+
|
|
412
|
+
raise ActionValidationError(
|
|
413
|
+
"target cannot be resolved",
|
|
414
|
+
context={"semantic_key": target.semantic_key()},
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
@staticmethod
|
|
418
|
+
def _ref_agrees(target: ActionTarget, element: Any) -> bool:
|
|
419
|
+
"""True when the cached element for a ref matches the recorded
|
|
420
|
+
semantics. Recorded capabilities carry role+name alongside the ref;
|
|
421
|
+
when they disagree the DOM has drifted and the ref must not be
|
|
422
|
+
trusted (it now points at a different element)."""
|
|
423
|
+
if not target.role and not target.name:
|
|
424
|
+
return True # ref-only targeting: nothing to cross-check
|
|
425
|
+
if target.role and element.role.lower() != target.role.lower():
|
|
426
|
+
return False
|
|
427
|
+
return not (target.name and target.name.lower() not in (element.name or "").lower())
|
|
428
|
+
|
|
429
|
+
def _locator_for_element(self, page: Page, element: Any) -> Any:
|
|
430
|
+
"""Build a locator for a scanned element, disambiguated by ordinal."""
|
|
431
|
+
assert self._last_observation is not None
|
|
432
|
+
elements = self._last_observation.interactive_elements
|
|
433
|
+
same = [e for e in elements if e.role == element.role and e.name == element.name]
|
|
434
|
+
ordinal = same.index(element)
|
|
435
|
+
test_id = element.attributes.get("data-testid")
|
|
436
|
+
if test_id:
|
|
437
|
+
base = page.get_by_test_id(test_id)
|
|
438
|
+
if len(same) == 1:
|
|
439
|
+
return base
|
|
440
|
+
if element.name:
|
|
441
|
+
locator = page.get_by_role(cast("Any", element.role), name=element.name, exact=True)
|
|
442
|
+
if len(same) > 1:
|
|
443
|
+
locator = locator.nth(ordinal)
|
|
444
|
+
return locator
|
|
445
|
+
if element.attributes.get("id"):
|
|
446
|
+
return page.locator(f"[id='{element.attributes['id']}']")
|
|
447
|
+
if test_id:
|
|
448
|
+
return (
|
|
449
|
+
page.get_by_test_id(test_id).nth(ordinal)
|
|
450
|
+
if len(same) > 1
|
|
451
|
+
else page.get_by_test_id(test_id)
|
|
452
|
+
)
|
|
453
|
+
# Nameless element: tag + ordinal among same-tag elements.
|
|
454
|
+
tag = element.tag or element.role
|
|
455
|
+
same_tag = [e for e in elements if (e.tag or e.role) == tag]
|
|
456
|
+
return page.locator(tag).nth(same_tag.index(element))
|
|
457
|
+
|
|
458
|
+
def capabilities_description(self) -> list[str]:
|
|
459
|
+
return ["click", "type", "select", "scroll", "hover", "key_press", "navigate", "wait"]
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def register() -> None:
|
|
463
|
+
"""Register the browser adapter (idempotent)."""
|
|
464
|
+
from capability_compiler.adapters.base import available_adapters, register_adapter
|
|
465
|
+
|
|
466
|
+
if "browser" not in available_adapters():
|
|
467
|
+
register_adapter(
|
|
468
|
+
"browser",
|
|
469
|
+
BrowserAdapter,
|
|
470
|
+
description="Playwright browser adapter (chromium/firefox/webkit)",
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
__all__ = ["BrowserAdapter", "register", "url_allowed"]
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Parser for Playwright's ``aria_snapshot()`` YAML format.
|
|
2
|
+
|
|
3
|
+
Empirically validated against Playwright 1.62 (2026-09-01). The format is
|
|
4
|
+
line-oriented YAML, two-space indentation per level::
|
|
5
|
+
|
|
6
|
+
- heading "Documents" [level=1]
|
|
7
|
+
- button "Export"
|
|
8
|
+
- textbox "Search":
|
|
9
|
+
- /placeholder: Search docs
|
|
10
|
+
- combobox:
|
|
11
|
+
- option "PDF" [selected]
|
|
12
|
+
- option "PNG"
|
|
13
|
+
|
|
14
|
+
Grammar per line::
|
|
15
|
+
|
|
16
|
+
"- " <role> [ '"' <name> '"' ] [ '[' <attr>* ']' ] [ ':' [ ' ' <text> ] ]
|
|
17
|
+
|
|
18
|
+
where ``attr`` is ``key=value`` or a bare flag (``selected``, ``disabled``,
|
|
19
|
+
``checked``, ``expanded``, ``invalid``, ``level=N``, ``pressed``). Children
|
|
20
|
+
beginning with ``/`` are pseudo-attributes (``/url``, ``/placeholder``).
|
|
21
|
+
|
|
22
|
+
The parser is dependency-free and deterministic: same input → identical
|
|
23
|
+
:class:`~capability_compiler.models.AccessibilityNode` tree. It never
|
|
24
|
+
executes or evaluates anything from the snapshot (page content is untrusted
|
|
25
|
+
data — see docs/security).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import re
|
|
31
|
+
|
|
32
|
+
from capability_compiler.errors import ObservationError
|
|
33
|
+
from capability_compiler.models import AccessibilityNode
|
|
34
|
+
|
|
35
|
+
_LINE_RE = re.compile(r"""^(?P<prefix>[-\s]*)- (?P<rest>.*)$""")
|
|
36
|
+
_ATTR_RE = re.compile(r"\[([^\]]*)\]")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_aria_snapshot(snapshot_yaml: str) -> AccessibilityNode:
|
|
40
|
+
"""Parse an aria snapshot into a normalized tree (virtual root included).
|
|
41
|
+
|
|
42
|
+
The returned root node has ``role="root"``; children are the top-level
|
|
43
|
+
page nodes in document order.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
ObservationError: on structurally invalid input (never on merely
|
|
47
|
+
unknown roles or attributes — forward compatible).
|
|
48
|
+
"""
|
|
49
|
+
root = AccessibilityNode(role="root", name="")
|
|
50
|
+
# Stack of (indent_level, node). Root sits at level -1 conceptually.
|
|
51
|
+
stack: list[tuple[int, AccessibilityNode]] = [(-1, root)]
|
|
52
|
+
for line_number, raw_line in enumerate(snapshot_yaml.splitlines(), start=1):
|
|
53
|
+
if not raw_line.strip():
|
|
54
|
+
continue
|
|
55
|
+
match = _LINE_RE.match(raw_line)
|
|
56
|
+
if not match or not raw_line.lstrip().startswith("-"):
|
|
57
|
+
msg = f"unparseable aria snapshot line {line_number}: {raw_line[:80]!r}"
|
|
58
|
+
raise ObservationError(msg, context={"line": line_number})
|
|
59
|
+
indent = len(match.group("prefix").replace("-", "", 1))
|
|
60
|
+
if indent % 2 != 0:
|
|
61
|
+
msg = f"odd indentation on line {line_number}"
|
|
62
|
+
raise ObservationError(msg, context={"line": line_number})
|
|
63
|
+
level = indent // 2
|
|
64
|
+
|
|
65
|
+
node, parent = _parse_node(match.group("rest"), line_number)
|
|
66
|
+
while stack and stack[-1][0] >= level:
|
|
67
|
+
stack.pop()
|
|
68
|
+
if not stack:
|
|
69
|
+
msg = f"indentation skips a level on line {line_number}"
|
|
70
|
+
raise ObservationError(msg, context={"line": line_number})
|
|
71
|
+
parent = stack[-1][1]
|
|
72
|
+
parent.children.append(node)
|
|
73
|
+
stack.append((level, node))
|
|
74
|
+
return root
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _parse_node(rest: str, line_number: int) -> tuple[AccessibilityNode, AccessibilityNode]:
|
|
78
|
+
"""Parse the text after ``- `` into a node; returns (node, dummy_parent)."""
|
|
79
|
+
text_after_colon: str | None = None
|
|
80
|
+
if ":" in rest:
|
|
81
|
+
# The colon separates node header from its text content (or a
|
|
82
|
+
# child-bearing marker when nothing follows).
|
|
83
|
+
head, _, tail = rest.partition(":")
|
|
84
|
+
text_after_colon = tail.strip() or None
|
|
85
|
+
rest = head.rstrip()
|
|
86
|
+
|
|
87
|
+
role = rest
|
|
88
|
+
name = ""
|
|
89
|
+
properties: dict[str, object] = {}
|
|
90
|
+
|
|
91
|
+
# Quoted name after the role.
|
|
92
|
+
name_match = re.search(r' "([^"]*)"', rest)
|
|
93
|
+
if name_match:
|
|
94
|
+
name = name_match.group(1)
|
|
95
|
+
role = (rest[: name_match.start()] + rest[name_match.end() :]).strip()
|
|
96
|
+
|
|
97
|
+
# Bracketed attributes (may be several: [level=1][disabled] is unusual
|
|
98
|
+
# but legal; Playwright emits combined [a] [b] forms).
|
|
99
|
+
attr_blob = " ".join(_ATTR_RE.findall(role) or [])
|
|
100
|
+
role = _ATTR_RE.sub(" ", role).strip()
|
|
101
|
+
for part in attr_blob.split():
|
|
102
|
+
if "=" in part:
|
|
103
|
+
key, _, value = part.partition("=")
|
|
104
|
+
properties[key] = _coerce(value)
|
|
105
|
+
else:
|
|
106
|
+
properties[part] = True
|
|
107
|
+
|
|
108
|
+
if not role:
|
|
109
|
+
msg = f"missing role on line {line_number}"
|
|
110
|
+
raise ObservationError(msg, context={"line": line_number})
|
|
111
|
+
|
|
112
|
+
if text_after_colon is not None and "text" not in properties:
|
|
113
|
+
properties["text"] = text_after_colon
|
|
114
|
+
|
|
115
|
+
node = AccessibilityNode(role=role, name=name, properties=properties)
|
|
116
|
+
return node, node
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _coerce(value: str) -> object:
|
|
120
|
+
if value.lower() in {"true", "false"}:
|
|
121
|
+
return value.lower() == "true"
|
|
122
|
+
try:
|
|
123
|
+
return int(value)
|
|
124
|
+
except ValueError:
|
|
125
|
+
return value
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def format_node_line(node: AccessibilityNode) -> str:
|
|
129
|
+
"""Render one node back to snapshot-line form (debugging/tests only)."""
|
|
130
|
+
parts = [node.role]
|
|
131
|
+
if node.name:
|
|
132
|
+
parts.append(f'"{node.name}"')
|
|
133
|
+
flags = []
|
|
134
|
+
for key, value in sorted(node.properties.items()):
|
|
135
|
+
if value is True:
|
|
136
|
+
flags.append(key)
|
|
137
|
+
elif value is not False and key != "text":
|
|
138
|
+
flags.append(f"{key}={value}")
|
|
139
|
+
line = "- " + " ".join(parts)
|
|
140
|
+
if flags:
|
|
141
|
+
line += " [" + " ".join(flags) + "]"
|
|
142
|
+
if node.properties.get("text"):
|
|
143
|
+
line += f": {node.properties['text']}"
|
|
144
|
+
return line
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
__all__ = ["format_node_line", "parse_aria_snapshot"]
|