@widgex/wgx 0.0.0-stage → 0.4.1
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.
- package/LICENSE.txt +49 -0
- package/README.md +33 -2
- package/ThirdPartyNotices.txt +4802 -0
- package/package.json +29 -4
- package/python/XACTOR.md +146 -0
- package/python/browser_actor_host.py +456 -0
- package/python/cdp_page.py +363 -0
- package/python/harness_check.py +77 -0
- package/python/requirements.txt +1 -0
- package/python/test_line_endings.py +185 -0
- package/python/xactor/__init__.py +18 -0
- package/python/xactor/__main__.py +226 -0
- package/python/xactor/api.py +882 -0
- package/python/xactor/bridge.py +784 -0
- package/python/xactor/dom.py +117 -0
- package/python/xactor/man/add_card.md +89 -0
- package/python/xactor/man/agent-vs-fork.md +108 -0
- package/python/xactor/man/agent.md +333 -0
- package/python/xactor/man/ask_model.md +110 -0
- package/python/xactor/man/ask_reference.md +56 -0
- package/python/xactor/man/ask_user.md +103 -0
- package/python/xactor/man/attach_app.md +595 -0
- package/python/xactor/man/attach_ide.md +65 -0
- package/python/xactor/man/attach_ide_surface.md +31 -0
- package/python/xactor/man/await_user.md +214 -0
- package/python/xactor/man/bash.md +142 -0
- package/python/xactor/man/browser-driver.md +137 -0
- package/python/xactor/man/browser_activity.md +53 -0
- package/python/xactor/man/browser_attach.md +84 -0
- package/python/xactor/man/browser_close_tab.md +50 -0
- package/python/xactor/man/browser_detach.md +50 -0
- package/python/xactor/man/browser_exec.md +66 -0
- package/python/xactor/man/browser_focus_tab.md +47 -0
- package/python/xactor/man/browser_hybrid.md +47 -0
- package/python/xactor/man/browser_open.md +50 -0
- package/python/xactor/man/browser_open_tab.md +58 -0
- package/python/xactor/man/browser_plumbing.md +107 -0
- package/python/xactor/man/browser_screenshot.md +56 -0
- package/python/xactor/man/browser_snapshot.md +52 -0
- package/python/xactor/man/browser_tabs.md +67 -0
- package/python/xactor/man/bulletin.md +88 -0
- package/python/xactor/man/capture_page.md +54 -0
- package/python/xactor/man/cd.md +64 -0
- package/python/xactor/man/chess_move.md +53 -0
- package/python/xactor/man/children.md +89 -0
- package/python/xactor/man/close_task.md +14 -0
- package/python/xactor/man/compact.md +75 -0
- package/python/xactor/man/drive.md +140 -0
- package/python/xactor/man/drive_space.md +72 -0
- package/python/xactor/man/driving.md +168 -0
- package/python/xactor/man/edit_file.md +112 -0
- package/python/xactor/man/finding.md +194 -0
- package/python/xactor/man/fork_agent.md +45 -0
- package/python/xactor/man/glob.md +83 -0
- package/python/xactor/man/grep.md +115 -0
- package/python/xactor/man/harness.md +165 -0
- package/python/xactor/man/harness_check.md +70 -0
- package/python/xactor/man/harness_format.md +257 -0
- package/python/xactor/man/highlight.md +66 -0
- package/python/xactor/man/instruct.md +111 -0
- package/python/xactor/man/ls.md +63 -0
- package/python/xactor/man/man.md +86 -0
- package/python/xactor/man/merge_wiz.md +40 -0
- package/python/xactor/man/notebook.md +98 -0
- package/python/xactor/man/overview.md +89 -0
- package/python/xactor/man/permissions.md +137 -0
- package/python/xactor/man/phase.md +150 -0
- package/python/xactor/man/powershell.md +74 -0
- package/python/xactor/man/profiles.md +60 -0
- package/python/xactor/man/project_drive.md +49 -0
- package/python/xactor/man/read_bulletin.md +89 -0
- package/python/xactor/man/read_file.md +90 -0
- package/python/xactor/man/relay.md +216 -0
- package/python/xactor/man/reply.md +72 -0
- package/python/xactor/man/reply_message.md +78 -0
- package/python/xactor/man/report_bug.md +118 -0
- package/python/xactor/man/request_answer.md +142 -0
- package/python/xactor/man/request_merge_decisions.md +121 -0
- package/python/xactor/man/request_operator_action.md +152 -0
- package/python/xactor/man/request_review.md +122 -0
- package/python/xactor/man/request_user_action.md +126 -0
- package/python/xactor/man/return_card.md +71 -0
- package/python/xactor/man/run.md +99 -0
- package/python/xactor/man/sandbox.md +144 -0
- package/python/xactor/man/session_fork.md +74 -0
- package/python/xactor/man/session_harness.md +109 -0
- package/python/xactor/man/session_join.md +102 -0
- package/python/xactor/man/session_land.md +73 -0
- package/python/xactor/man/session_nudge.md +73 -0
- package/python/xactor/man/session_rest.md +73 -0
- package/python/xactor/man/session_scope.md +84 -0
- package/python/xactor/man/session_terminate.md +58 -0
- package/python/xactor/man/session_token.md +78 -0
- package/python/xactor/man/sessions.md +93 -0
- package/python/xactor/man/set_harness.md +15 -0
- package/python/xactor/man/spawn_swarm.md +80 -0
- package/python/xactor/man/submit_evidence.md +54 -0
- package/python/xactor/man/swarm_brief.md +30 -0
- package/python/xactor/man/task_status.md +82 -0
- package/python/xactor/man/tasks.md +47 -0
- package/python/xactor/man/todo_write.md +104 -0
- package/python/xactor/man/update_card.md +70 -0
- package/python/xactor/man/use_profile.md +90 -0
- package/python/xactor/man/use_widget.md +82 -0
- package/python/xactor/man/wait_for_task.md +105 -0
- package/python/xactor/man/widget-model.md +284 -0
- package/python/xactor/man/widgets.md +59 -0
- package/python/xactor/man/write_file.md +76 -0
- package/python/xactor/man/writing-a-widget.md +142 -0
- package/python/xactor/man/xylon.md +120 -0
- package/python/xactor/man/xylon_blame.md +80 -0
- package/python/xactor/man/xylon_read.md +131 -0
- package/python/xactor/man/xylon_search.md +125 -0
- package/python/xactor/manual.py +195 -0
- package/python/xactor/parent_watch.py +115 -0
- package/python/xactor/plan_mode.py +350 -0
- package/python/xactor/sandbox.py +856 -0
- package/python/xactor/seals.py +438 -0
- package/python/xactor/shell_hints.py +210 -0
- package/python/xactor/states/__init__.py +13 -0
- package/python/xactor/states/base.py +523 -0
- package/python/xactor/states/registry.py +274 -0
- package/python/xactor/stdlib_api.py +6345 -0
- package/python/xactor/widgets/__init__.py +1 -0
- package/python/xactor/widgets/github_tokens.py +314 -0
- package/python/xactor/widgets/google_aistudio.py +365 -0
- package/settings-schema.json +403 -0
- package/viewer/index.css +160 -0
- package/viewer/index.js +15 -0
- package/wgx.mjs +273753 -0
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
"""Core Layer 2 classes: Observation, Step, Transition, PageState, LoadingState."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from ..api import ElementaryAPI
|
|
10
|
+
from ..dom import DOMSnapshot
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Observation:
|
|
14
|
+
"""Declarative DOM data extraction.
|
|
15
|
+
|
|
16
|
+
Two modes:
|
|
17
|
+
- Structured: selector + extract (attribute name or list) → compiled to JS
|
|
18
|
+
- Raw: js expression evaluated directly
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
name: str,
|
|
24
|
+
*,
|
|
25
|
+
description: str = "",
|
|
26
|
+
js: str | None = None,
|
|
27
|
+
selector: str | None = None,
|
|
28
|
+
extract: str | list[str] | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self.name = name
|
|
31
|
+
self.description = description
|
|
32
|
+
self._js = js
|
|
33
|
+
self.selector = selector
|
|
34
|
+
self.extract = extract
|
|
35
|
+
|
|
36
|
+
if js is None and selector is None:
|
|
37
|
+
raise ValueError(f"Observation '{name}' needs either 'js' or 'selector'")
|
|
38
|
+
if js is not None and selector is not None:
|
|
39
|
+
raise ValueError(f"Observation '{name}' cannot have both 'js' and 'selector'")
|
|
40
|
+
if selector is not None and re.match(r'^[a-z]+"[^"]*"', selector):
|
|
41
|
+
# `button"Create Token"` is a Layer 0 path, not CSS; interpolated
|
|
42
|
+
# into querySelector it throws a SyntaxError in the page, at run
|
|
43
|
+
# time. Refuse it where the author can still see it.
|
|
44
|
+
raise ValueError(
|
|
45
|
+
f"Observation '{name}': selector {selector!r} looks like a path "
|
|
46
|
+
f"(role\"name\"), but Observation.selector is CSS. Use a CSS "
|
|
47
|
+
f"selector, or js= with an explicit query."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def compile_js(self) -> str:
|
|
51
|
+
"""Compile this observation to a JavaScript expression."""
|
|
52
|
+
if self._js is not None:
|
|
53
|
+
return self._js
|
|
54
|
+
|
|
55
|
+
sel = self.selector
|
|
56
|
+
assert sel is not None
|
|
57
|
+
|
|
58
|
+
if isinstance(self.extract, list):
|
|
59
|
+
# Multiple attributes from all matches
|
|
60
|
+
attrs = self.extract
|
|
61
|
+
props = ", ".join(
|
|
62
|
+
f"{a}: el.getAttribute('{a}') ?? el.textContent?.trim() ?? null"
|
|
63
|
+
if a == "textContent"
|
|
64
|
+
else f"{a}: el.getAttribute('{a}') ?? null"
|
|
65
|
+
for a in attrs
|
|
66
|
+
)
|
|
67
|
+
return (
|
|
68
|
+
f"Array.from(document.querySelectorAll('{sel}')).map(el => "
|
|
69
|
+
f"({{ {props} }}))"
|
|
70
|
+
)
|
|
71
|
+
elif isinstance(self.extract, str):
|
|
72
|
+
# Single attribute from first match
|
|
73
|
+
attr = self.extract
|
|
74
|
+
if attr == "textContent":
|
|
75
|
+
return f"document.querySelector('{sel}')?.textContent?.trim() ?? null"
|
|
76
|
+
return f"document.querySelector('{sel}')?.getAttribute('{attr}') ?? document.querySelector('{sel}')?.textContent?.trim() ?? null"
|
|
77
|
+
else:
|
|
78
|
+
# No extract specified — return textContent of first match
|
|
79
|
+
return f"document.querySelector('{sel}')?.textContent?.trim() ?? null"
|
|
80
|
+
|
|
81
|
+
async def run(self, api: ElementaryAPI) -> Any:
|
|
82
|
+
"""Execute this observation via the bridge."""
|
|
83
|
+
js = self.compile_js()
|
|
84
|
+
return await api.evaluate(js)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ── Legacy target bridge ──────────────────────────────────────────────
|
|
88
|
+
#
|
|
89
|
+
# Widgets written against the old engine name elements the way that engine
|
|
90
|
+
# resolved them: by visible text, by a bare tag, by a CSS selector. Layer 0 no
|
|
91
|
+
# longer guesses -- it takes a uid or a path -- so those targets would resolve to
|
|
92
|
+
# nothing.
|
|
93
|
+
#
|
|
94
|
+
# Rather than let ~90 steps across the shipped widgets fail one at a time, this
|
|
95
|
+
# translates a legacy target into an explicit *search*. That is a deliberate
|
|
96
|
+
# choice about where the guessing lives: Layer 0 stays strict, and Layer 2 opts
|
|
97
|
+
# in visibly, under the same ranking and margin an agent would get.
|
|
98
|
+
#
|
|
99
|
+
# It is a bridge, not a destination. A rewritten widget should name paths from
|
|
100
|
+
# api.search() and skip all of this.
|
|
101
|
+
|
|
102
|
+
# `focus` is here because it takes a target and so needs the same legacy-target
|
|
103
|
+
# translation the others do. `press` is deliberately absent: its first argument
|
|
104
|
+
# is a chord, not an element, and putting it through the target bridge would
|
|
105
|
+
# have "Enter" resolved as a description of something on the page.
|
|
106
|
+
_HUMANISED = frozenset({
|
|
107
|
+
"click", "type_text", "clear_and_type", "hover", "scroll_to", "select",
|
|
108
|
+
"focus",
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
# What the caller is plainly aiming at, when the verb says so.
|
|
112
|
+
_ROLE_FOR_VERB = {
|
|
113
|
+
"type_text": "textbox",
|
|
114
|
+
"clear_and_type": "textbox",
|
|
115
|
+
"select": "combobox",
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
_TAG_ROLE = {
|
|
119
|
+
"button": "button", "a": "link", "input": "textbox", "textarea": "textbox",
|
|
120
|
+
"select": "combobox", "table": "table", "tr": "row", "li": "listitem",
|
|
121
|
+
"ul": "list", "ol": "list", "dialog": "dialog", "form": "form", "nav": "navigation",
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
_STEP_VERB_ALIASES = {"clear_type": "clear_and_type", "type": "type_text"}
|
|
125
|
+
|
|
126
|
+
_UID_RE = re.compile(r"^[0-9a-f]{6}(~\d+)?$")
|
|
127
|
+
_BARE_TAG_RE = re.compile(r"^[a-z][a-z0-9-]*$")
|
|
128
|
+
_ATTR_RE = re.compile(r"""^\[([\w-]+)\s*=\s*["']?([^"'\]]+)["']?\]$""")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _is_new_style(target: str) -> bool:
|
|
132
|
+
"""A uid, or a path. Paths carry a separator, a quoted name, or a #id/@testid.
|
|
133
|
+
|
|
134
|
+
The leading-bracket test comes first and is not redundant: a CSS attribute
|
|
135
|
+
selector like ``[data-testid="x"]`` contains a double quote, which would
|
|
136
|
+
otherwise read as a path's quoted name. A real path never *starts* with a
|
|
137
|
+
bracket -- the index bracket only ever follows a role or a discriminator.
|
|
138
|
+
"""
|
|
139
|
+
if target.startswith("["):
|
|
140
|
+
return False
|
|
141
|
+
return bool(
|
|
142
|
+
_UID_RE.match(target)
|
|
143
|
+
or "/" in target
|
|
144
|
+
or '"' in target
|
|
145
|
+
or target.startswith("#")
|
|
146
|
+
or target.startswith("@")
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def legacy_target(action: str, selector: Any, kwargs: dict[str, Any]) -> Any:
|
|
151
|
+
"""Translate an old-style target into something Layer 0 accepts.
|
|
152
|
+
|
|
153
|
+
Only for the humanised verbs. ``dom_click``, ``dom_fill``,
|
|
154
|
+
``wait_for_element`` and ``extract_secret`` still take CSS directly, which is
|
|
155
|
+
why the three console widgets authored against them need no translation at
|
|
156
|
+
all.
|
|
157
|
+
"""
|
|
158
|
+
if action not in _HUMANISED or not isinstance(selector, str) or not selector.strip():
|
|
159
|
+
return selector
|
|
160
|
+
|
|
161
|
+
s = selector.strip()
|
|
162
|
+
if _is_new_style(s):
|
|
163
|
+
return s
|
|
164
|
+
|
|
165
|
+
spec: dict[str, Any] = {}
|
|
166
|
+
attr_match = _ATTR_RE.match(s)
|
|
167
|
+
if attr_match:
|
|
168
|
+
spec["attr"] = {attr_match.group(1): attr_match.group(2)}
|
|
169
|
+
role = _ROLE_FOR_VERB.get(action)
|
|
170
|
+
if role:
|
|
171
|
+
spec["role"] = role
|
|
172
|
+
elif _BARE_TAG_RE.match(s):
|
|
173
|
+
# A bare tag was always "the only one of these on the page".
|
|
174
|
+
spec["role"] = _TAG_ROLE.get(s, s)
|
|
175
|
+
else:
|
|
176
|
+
# Visible text. Ranked rather than matched exactly, because the old
|
|
177
|
+
# engine did a substring walk and the accessible name the new one
|
|
178
|
+
# computes is not always character-identical to it.
|
|
179
|
+
spec["like"] = s
|
|
180
|
+
role = _ROLE_FOR_VERB.get(action)
|
|
181
|
+
if role:
|
|
182
|
+
spec["role"] = role
|
|
183
|
+
|
|
184
|
+
# `container=` scoped a lookup; `under=` is the same idea by its current name.
|
|
185
|
+
container = kwargs.pop("container", None)
|
|
186
|
+
if container:
|
|
187
|
+
spec["under"] = container
|
|
188
|
+
|
|
189
|
+
return spec
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _search_kw(spec: dict[str, Any]) -> dict[str, Any]:
|
|
193
|
+
"""Turn a wire-form spec back into ``api.search`` keyword arguments."""
|
|
194
|
+
out: dict[str, Any] = {}
|
|
195
|
+
rename = {"textExact": "text_exact", "inView": "in_view",
|
|
196
|
+
"leftOf": "left_of", "rightOf": "right_of",
|
|
197
|
+
"inRow": "in_row", "inSection": "in_section",
|
|
198
|
+
"includeInert": "include_inert"}
|
|
199
|
+
for k, v in spec.items():
|
|
200
|
+
out[rename.get(k, k)] = v
|
|
201
|
+
return out
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
#: `$name` as it appears in a Step's target or kwargs. Bounded by the same
|
|
205
|
+
#: character class `_sub` replaces on, so what is detected and what is
|
|
206
|
+
#: substituted cannot disagree.
|
|
207
|
+
_PARAM_RE = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class Step:
|
|
211
|
+
"""A single Layer 1 action within a transition."""
|
|
212
|
+
|
|
213
|
+
def __init__(
|
|
214
|
+
self,
|
|
215
|
+
action: str,
|
|
216
|
+
selector: str,
|
|
217
|
+
expects_transition: bool = False,
|
|
218
|
+
*,
|
|
219
|
+
when: str | None = None,
|
|
220
|
+
**kwargs: Any,
|
|
221
|
+
) -> None:
|
|
222
|
+
# The wire names `type` and `clear_type` are not the verbs on `api`
|
|
223
|
+
# (`type_text`, `clear_and_type`); the man pages once taught the wire
|
|
224
|
+
# name, and a step so spelled failed only when finally dispatched.
|
|
225
|
+
self.action = _STEP_VERB_ALIASES.get(action, action)
|
|
226
|
+
self.selector = selector
|
|
227
|
+
self.expects_transition = expects_transition
|
|
228
|
+
#: Run this step only if the caller supplied this optional parameter.
|
|
229
|
+
#:
|
|
230
|
+
#: Skipping by `$ref` alone is not enough for a real form. Setting one
|
|
231
|
+
#: react-select takes three steps — focus, fill, Enter — and only the
|
|
232
|
+
#: middle one mentions the value. Leave the other two unconditional and
|
|
233
|
+
#: omitting the argument still opens the menu and presses Enter on it,
|
|
234
|
+
#: which commits whatever was highlighted. `when` ties the whole group
|
|
235
|
+
#: to one argument, so "not supplied" means the field is genuinely
|
|
236
|
+
#: untouched.
|
|
237
|
+
#:
|
|
238
|
+
#: Keyword-only, so it cannot be mistaken for the legacy third
|
|
239
|
+
#: positional (`expects_transition`).
|
|
240
|
+
self.when = when
|
|
241
|
+
self.kwargs = kwargs
|
|
242
|
+
|
|
243
|
+
def resolve(self, params: dict[str, Any]) -> tuple[str, str, dict[str, Any]]:
|
|
244
|
+
"""Resolve $param references, returning (action, selector, kwargs)."""
|
|
245
|
+
selector = self._sub(self.selector, params)
|
|
246
|
+
kwargs = {k: self._sub(v, params) for k, v in self.kwargs.items()}
|
|
247
|
+
return self.action, selector, kwargs
|
|
248
|
+
|
|
249
|
+
def param_refs(self) -> set[str]:
|
|
250
|
+
"""Every `$name` this step substitutes, in its target or its kwargs.
|
|
251
|
+
|
|
252
|
+
Used to decide whether a step is asking for a parameter the caller
|
|
253
|
+
chose not to supply, which is how an optional field is left at whatever
|
|
254
|
+
the page already had — see `Transition.optional`.
|
|
255
|
+
"""
|
|
256
|
+
refs: set[str] = set()
|
|
257
|
+
for value in (self.selector, *self.kwargs.values()):
|
|
258
|
+
if not isinstance(value, str):
|
|
259
|
+
continue
|
|
260
|
+
for token in _PARAM_RE.findall(value):
|
|
261
|
+
refs.add(token)
|
|
262
|
+
return refs
|
|
263
|
+
|
|
264
|
+
@staticmethod
|
|
265
|
+
def _sub(value: Any, params: dict[str, Any]) -> Any:
|
|
266
|
+
if not isinstance(value, str):
|
|
267
|
+
return value
|
|
268
|
+
# Whole-string param ref: "$param" → params["param"] (preserves type)
|
|
269
|
+
if value.startswith("$") and value[1:] in params:
|
|
270
|
+
return params[value[1:]]
|
|
271
|
+
# Inline interpolation: replace $param within larger strings
|
|
272
|
+
if "$" in value and params:
|
|
273
|
+
result = value
|
|
274
|
+
for key, val in params.items():
|
|
275
|
+
result = result.replace(f"${key}", str(val))
|
|
276
|
+
return result
|
|
277
|
+
return value
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class Transition:
|
|
281
|
+
"""A named sequence of Layer 1 steps causing a page change."""
|
|
282
|
+
|
|
283
|
+
def __init__(
|
|
284
|
+
self,
|
|
285
|
+
name: str,
|
|
286
|
+
steps: list[Step],
|
|
287
|
+
*,
|
|
288
|
+
params: dict[str, str] | None = None,
|
|
289
|
+
optional: dict[str, str] | None = None,
|
|
290
|
+
description: str = "",
|
|
291
|
+
) -> None:
|
|
292
|
+
self.name = name
|
|
293
|
+
self.description = description
|
|
294
|
+
self.params = params or {}
|
|
295
|
+
#: Parameters the caller may omit, name -> type, for a form field that
|
|
296
|
+
#: should keep whatever the page already had.
|
|
297
|
+
#:
|
|
298
|
+
#: A configure-style transition wants to set the two things you care
|
|
299
|
+
#: about and leave the other six alone. Without this every parameter is
|
|
300
|
+
#: required (`execute` rejects a missing one), so the only way to leave
|
|
301
|
+
#: a field at its default was a separate transition per combination.
|
|
302
|
+
#:
|
|
303
|
+
#: A step that substitutes an omitted optional parameter is *skipped* —
|
|
304
|
+
#: not run with an empty string, which would clear the field rather
|
|
305
|
+
#: than leave it. See `PageState.execute`.
|
|
306
|
+
self.optional = optional or {}
|
|
307
|
+
self.steps = steps
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class PageState:
|
|
311
|
+
"""A page condition and its action space.
|
|
312
|
+
|
|
313
|
+
Constructed with configuration, bound to a DOM snapshot + API at match time.
|
|
314
|
+
"""
|
|
315
|
+
|
|
316
|
+
def __init__(
|
|
317
|
+
self,
|
|
318
|
+
name: str,
|
|
319
|
+
*,
|
|
320
|
+
url_pattern: str = ".*",
|
|
321
|
+
entry: str | None = None,
|
|
322
|
+
keywords: list[str] | None = None,
|
|
323
|
+
observations: list[Observation] | None = None,
|
|
324
|
+
transitions: list[Transition] | None = None,
|
|
325
|
+
expected_timeout: float | None = None,
|
|
326
|
+
) -> None:
|
|
327
|
+
self.name = name
|
|
328
|
+
self.url_pattern = url_pattern
|
|
329
|
+
# A real URL you can navigate to, as opposed to url_pattern, which is a
|
|
330
|
+
# matcher: r"vercel\.com/[^/]+$" identifies the dashboard but cannot be
|
|
331
|
+
# visited. Without this a widget can only be *recognised* once you are
|
|
332
|
+
# already somewhere, never entered cold — which is why opening a browser
|
|
333
|
+
# at a hand-written URL had to come first.
|
|
334
|
+
#
|
|
335
|
+
# Optional, and most states should leave it unset: "CreateTokenDialog"
|
|
336
|
+
# is reached by acting, not by navigating.
|
|
337
|
+
self.entry = entry
|
|
338
|
+
self.keywords = keywords or []
|
|
339
|
+
self.observations = {o.name: o for o in (observations or [])}
|
|
340
|
+
self.transitions = {t.name: t for t in (transitions or [])}
|
|
341
|
+
self.expected_timeout = expected_timeout # seconds; None = use defaults
|
|
342
|
+
|
|
343
|
+
self._dom: DOMSnapshot | None = None
|
|
344
|
+
self._api: ElementaryAPI | None = None
|
|
345
|
+
self._compiled_url: re.Pattern[str] = re.compile(url_pattern)
|
|
346
|
+
|
|
347
|
+
def matches_url(self, url: str) -> bool:
|
|
348
|
+
"""Check if this state's url_pattern matches the given URL."""
|
|
349
|
+
return bool(self._compiled_url.search(url))
|
|
350
|
+
|
|
351
|
+
def bind(self, dom: DOMSnapshot, api: ElementaryAPI) -> PageState:
|
|
352
|
+
"""Create a bound copy with DOM/API refs."""
|
|
353
|
+
bound = PageState.__new__(PageState)
|
|
354
|
+
bound.name = self.name
|
|
355
|
+
bound.url_pattern = self.url_pattern
|
|
356
|
+
bound.entry = self.entry
|
|
357
|
+
bound.keywords = self.keywords
|
|
358
|
+
bound.observations = self.observations
|
|
359
|
+
bound.transitions = self.transitions
|
|
360
|
+
bound.expected_timeout = self.expected_timeout
|
|
361
|
+
bound._dom = dom
|
|
362
|
+
bound._api = api
|
|
363
|
+
bound._compiled_url = self._compiled_url
|
|
364
|
+
return bound
|
|
365
|
+
|
|
366
|
+
def describe(self) -> dict[str, Any]:
|
|
367
|
+
"""Serializable summary for Claude."""
|
|
368
|
+
desc: dict[str, Any] = {
|
|
369
|
+
"name": self.name,
|
|
370
|
+
"url_pattern": self.url_pattern,
|
|
371
|
+
"entry": self.entry,
|
|
372
|
+
"keywords": self.keywords,
|
|
373
|
+
"observations": [
|
|
374
|
+
{"name": o.name, "description": o.description}
|
|
375
|
+
for o in self.observations.values()
|
|
376
|
+
],
|
|
377
|
+
"transitions": [
|
|
378
|
+
{
|
|
379
|
+
"name": t.name,
|
|
380
|
+
"description": t.description,
|
|
381
|
+
"params": t.params,
|
|
382
|
+
# Advertised separately from `params`, and only when there
|
|
383
|
+
# are any. `describe()` is how an agent learns what a
|
|
384
|
+
# transition accepts, so an optional parameter it cannot
|
|
385
|
+
# see is one it will never pass — and the field silently
|
|
386
|
+
# keeps the page's default while the caller believes it
|
|
387
|
+
# asked for something.
|
|
388
|
+
**({"optional": t.optional} if t.optional else {}),
|
|
389
|
+
}
|
|
390
|
+
for t in self.transitions.values()
|
|
391
|
+
],
|
|
392
|
+
}
|
|
393
|
+
if self.expected_timeout is not None:
|
|
394
|
+
desc["expected_timeout"] = self.expected_timeout
|
|
395
|
+
return desc
|
|
396
|
+
|
|
397
|
+
async def observe(self, name: str) -> Any:
|
|
398
|
+
"""Execute an observation (live DOM query via bridge)."""
|
|
399
|
+
if self._api is None:
|
|
400
|
+
raise RuntimeError("PageState not bound — call bind() first")
|
|
401
|
+
obs = self.observations.get(name)
|
|
402
|
+
if obs is None:
|
|
403
|
+
available = list(self.observations.keys())
|
|
404
|
+
raise KeyError(f"No observation '{name}'. Available: {available}")
|
|
405
|
+
return await obs.run(self._api)
|
|
406
|
+
|
|
407
|
+
async def execute(self, name: str, /, **params: Any) -> PageState:
|
|
408
|
+
"""Run transition steps, resolve new state.
|
|
409
|
+
|
|
410
|
+
`name` is positional-only, and that slash is load-bearing. Without it a
|
|
411
|
+
transition could never have a parameter called `name` — the most
|
|
412
|
+
obvious word for the field on any create form — because
|
|
413
|
+
``execute("name_it", name="x")`` bound both to the same argument and
|
|
414
|
+
raised "got multiple values for argument 'name'". Nothing caught it at
|
|
415
|
+
authoring time: the Transition constructed, the widget loaded,
|
|
416
|
+
`describe()` listed the parameter, and it failed only when finally run,
|
|
417
|
+
as an arity error naming neither the widget nor the transition.
|
|
418
|
+
"""
|
|
419
|
+
if self._api is None:
|
|
420
|
+
raise RuntimeError("PageState not bound — call bind() first")
|
|
421
|
+
|
|
422
|
+
transition = self.transitions.get(name)
|
|
423
|
+
if transition is None:
|
|
424
|
+
available = list(self.transitions.keys())
|
|
425
|
+
raise KeyError(f"No transition '{name}'. Available: {available}")
|
|
426
|
+
|
|
427
|
+
# Validate params
|
|
428
|
+
for pname in transition.params:
|
|
429
|
+
if pname not in params:
|
|
430
|
+
raise ValueError(f"Missing required parameter: {pname}")
|
|
431
|
+
|
|
432
|
+
unknown = set(params) - set(transition.params) - set(transition.optional)
|
|
433
|
+
if unknown:
|
|
434
|
+
# Caught here rather than ignored, because a typo in an optional
|
|
435
|
+
# name is otherwise indistinguishable from deliberately leaving the
|
|
436
|
+
# field alone — the step is skipped and the transition reports
|
|
437
|
+
# success having not done the thing you asked for.
|
|
438
|
+
raise ValueError(
|
|
439
|
+
f"Unknown parameter(s) for '{name}': {sorted(unknown)}. "
|
|
440
|
+
f"Accepts {sorted(transition.params)} "
|
|
441
|
+
f"and optionally {sorted(transition.optional)}."
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
# Execute steps.
|
|
445
|
+
#
|
|
446
|
+
# Every L1 verb now returns an ActionRecord dict rather than sometimes a
|
|
447
|
+
# snapshot and sometimes None, so there is no longer a value to inspect
|
|
448
|
+
# here to decide whether the page moved. A step that fails raises, which
|
|
449
|
+
# is the point: a transition that half-ran must not report the state it
|
|
450
|
+
# was aiming for.
|
|
451
|
+
for step in transition.steps:
|
|
452
|
+
# A step that needs an omitted optional parameter is skipped, which
|
|
453
|
+
# is what "defaults to whatever the form had" means. Substituting
|
|
454
|
+
# an empty string instead would *clear* the field — the opposite of
|
|
455
|
+
# leaving it alone, and silently so.
|
|
456
|
+
if step.when is not None and step.when not in params:
|
|
457
|
+
continue
|
|
458
|
+
missing = {r for r in step.param_refs() if r not in params}
|
|
459
|
+
if missing and missing <= set(transition.optional):
|
|
460
|
+
continue
|
|
461
|
+
|
|
462
|
+
action, selector, kwargs = step.resolve(params)
|
|
463
|
+
method = getattr(self._api, action, None)
|
|
464
|
+
if method is None:
|
|
465
|
+
raise AttributeError(f"ElementaryAPI has no method '{action}'")
|
|
466
|
+
|
|
467
|
+
# `expects_transition` was removed from Layer 0 -- readiness is now
|
|
468
|
+
# intrinsic to each action, so the flag has nothing to select. It is
|
|
469
|
+
# dropped rather than forwarded so an old widget still runs.
|
|
470
|
+
kwargs.pop("expects_transition", None)
|
|
471
|
+
|
|
472
|
+
# `index=` meant "the nth thing matching this". There is no nth in a
|
|
473
|
+
# ranked search, so it costs a lookup: find the candidates, take the
|
|
474
|
+
# one asked for, act on its uid. Slower than a path by one round
|
|
475
|
+
# trip -- which is the price of the old spelling, and the reason a
|
|
476
|
+
# rewritten widget should name the row instead.
|
|
477
|
+
index = kwargs.pop("index", None)
|
|
478
|
+
target = legacy_target(action, selector, kwargs)
|
|
479
|
+
|
|
480
|
+
if index and isinstance(target, dict):
|
|
481
|
+
matches = await self._api.search(**_search_kw(target))
|
|
482
|
+
if len(matches) <= index:
|
|
483
|
+
raise RuntimeError(
|
|
484
|
+
f"transition '{name}' step '{action}' wanted match #{index} of "
|
|
485
|
+
f"{selector!r}, but only {len(matches)} matched"
|
|
486
|
+
)
|
|
487
|
+
target = matches[index].as_target()
|
|
488
|
+
elif index:
|
|
489
|
+
raise RuntimeError(
|
|
490
|
+
f"transition '{name}' step '{action}' used index={index} with a "
|
|
491
|
+
f"path target ({selector!r}); scope the path instead"
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
record = await method(target, **kwargs)
|
|
495
|
+
if isinstance(record, dict) and record.get("ok") is False:
|
|
496
|
+
raise RuntimeError(
|
|
497
|
+
f"transition '{name}' failed at step '{action}' on {selector!r}: "
|
|
498
|
+
f"{record.get('outcome')}"
|
|
499
|
+
+ (f" -- {record['error']}" if record.get("error") else "")
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
# Where did that leave us? Settle, then re-resolve.
|
|
503
|
+
loading = LoadingState(self.name, name, timeout=self.expected_timeout)
|
|
504
|
+
return await loading.wait_until_ready(self._api)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
class LoadingState:
|
|
508
|
+
"""Inert halt between transitions.
|
|
509
|
+
|
|
510
|
+
All it does is wait_until_ready(api) → PageState.
|
|
511
|
+
"""
|
|
512
|
+
|
|
513
|
+
def __init__(self, previous_name: str, trigger: str, timeout: float | None = None) -> None:
|
|
514
|
+
self.previous_name = previous_name
|
|
515
|
+
self.trigger = trigger
|
|
516
|
+
self.timeout = timeout # seconds; None = default 30s
|
|
517
|
+
|
|
518
|
+
async def wait_until_ready(self, api: ElementaryAPI) -> PageState:
|
|
519
|
+
"""Wait for the page to settle, then resolve to a new PageState."""
|
|
520
|
+
timeout_ms = int(self.timeout * 1000) if self.timeout else None
|
|
521
|
+
await api.wait_stable(timeout_ms=timeout_ms)
|
|
522
|
+
from .registry import StateRegistry
|
|
523
|
+
return await StateRegistry.here(api)
|