@widgex/wgx 0.0.0-stage → 0.4.2
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 +4685 -0
- package/package.json +30 -4
- package/provider-clis.json +1211 -0
- 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 +290659 -0
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
"""ElementaryAPI -- Layer 1.
|
|
2
|
+
|
|
3
|
+
The elementary set of interactions an agent composes to drive a website. It is
|
|
4
|
+
deliberately small: everything here either *locates* something or *acts* on it,
|
|
5
|
+
and the two are separate verbs because the agent, not Layer 0, decides which
|
|
6
|
+
candidate was meant.
|
|
7
|
+
|
|
8
|
+
## The three layers, and what each is for
|
|
9
|
+
|
|
10
|
+
**Layer 0** (TypeScript) receives serial actions whose intent is already
|
|
11
|
+
resolved. It searches when a descriptor asks it to, executes with humanised
|
|
12
|
+
input, and reports. It never chooses between close candidates. *An agent should
|
|
13
|
+
never need to understand Layer 0 to drive a website.*
|
|
14
|
+
|
|
15
|
+
**Layer 1** is this file. It is what the agent composes to turn a general
|
|
16
|
+
intuition about identifiable features -- "there is a button that says something
|
|
17
|
+
like Create, inside the dialog" -- into a concrete reference. The vocabulary
|
|
18
|
+
here must match how Layer 0 resolves, or the agent would be reading one model
|
|
19
|
+
and acting through another.
|
|
20
|
+
|
|
21
|
+
**Layer 2** (widgets) caches those compositions into declared transitions, so a
|
|
22
|
+
modelled site can be driven to an objective in a single pass.
|
|
23
|
+
|
|
24
|
+
## Load a chain; stay out of the loop
|
|
25
|
+
|
|
26
|
+
Actions queue. They do not need to be awaited individually, and awaiting each
|
|
27
|
+
one is the habit this design exists to break::
|
|
28
|
+
|
|
29
|
+
api.click(api.find(role="button", like="create token"))
|
|
30
|
+
api.type_text(api.find(role="textbox", right_of="Name"), "ci-deploy")
|
|
31
|
+
api.click(api.find(role="button", text_exact="Create", under='dialog"Create Token"'))
|
|
32
|
+
report = await api.drain()
|
|
33
|
+
|
|
34
|
+
One report comes back describing every step: what it resolved to, whether it
|
|
35
|
+
worked, and what changed on the page. If step 3 cannot find its target, steps
|
|
36
|
+
after it are *skipped rather than attempted* -- the page is no longer in the
|
|
37
|
+
state the rest was written against -- and the report says so, with the
|
|
38
|
+
candidates that tied.
|
|
39
|
+
|
|
40
|
+
## Time is bounded
|
|
41
|
+
|
|
42
|
+
Two ceilings, so a wedged page cannot swallow a run:
|
|
43
|
+
|
|
44
|
+
- **10s per action**, covering resolving, waiting for the target, and doing the
|
|
45
|
+
thing. An action past it reports ``timeout`` and *stops touching the page*.
|
|
46
|
+
- **30s for the whole chain**. Past that, actions not yet attempted report
|
|
47
|
+
``budget_exhausted`` rather than running.
|
|
48
|
+
|
|
49
|
+
Neither is a target. A click is around 300ms and a twelve-step chain a few
|
|
50
|
+
seconds; these only fire when something is genuinely stuck.
|
|
51
|
+
|
|
52
|
+
**Typing is exempt from both, and you do not size it.** A ``type_text`` sets its
|
|
53
|
+
own ceiling from the string you gave it and the speed it will be typed at --
|
|
54
|
+
about 70ms a character, divided by ``hfactor``, doubled -- and adds that to the
|
|
55
|
+
chain's budget, so a form of long fields cannot exhaust it. A paragraph at
|
|
56
|
+
``hfactor`` 1 simply takes the half-minute it takes -- and a quarter of that at
|
|
57
|
+
``hfactor`` 4, because higher is faster.
|
|
58
|
+
|
|
59
|
+
So a ``timeout`` on a type is never "the string was long": the allowance already
|
|
60
|
+
knew the length. It means the field is not taking the keystrokes. Do not raise
|
|
61
|
+
``timeout_ms`` at it -- read the message, then ``api.wait_stable()``.
|
|
62
|
+
|
|
63
|
+
type_text(field, essay) # no timeout_ms; it sizes itself
|
|
64
|
+
click(btn, batch_ms=60_000) # a deliberately long chain
|
|
65
|
+
|
|
66
|
+
## Newlines are the Enter key
|
|
67
|
+
|
|
68
|
+
Put ``\n`` in the text and it is typed as a real Enter press, not as a
|
|
69
|
+
character: keyCode 13, ``code`` ``Enter``, in the page's own input pipeline.
|
|
70
|
+
``\r\n`` is one press, not two.
|
|
71
|
+
|
|
72
|
+
Which means it does what Enter does *in that field*. In a textarea or a rich
|
|
73
|
+
editor it breaks the line; in a single-line input, or a chat composer bound to
|
|
74
|
+
send, it submits. If the field sends on Enter and you want a line break, type
|
|
75
|
+
the lines separately and press the shifted form between them::
|
|
76
|
+
|
|
77
|
+
type_text(box, "first line")
|
|
78
|
+
press("Shift+Enter", box)
|
|
79
|
+
type_text(box, "second line")
|
|
80
|
+
|
|
81
|
+
## Referring to things
|
|
82
|
+
|
|
83
|
+
An element is named by a **uid** (short, content-derived, e.g. ``e7f3a1``) or by
|
|
84
|
+
a **path** through the page's structure::
|
|
85
|
+
|
|
86
|
+
main/table/row"prod-key"/button"Delete"
|
|
87
|
+
dialog"Create Token"/button"Create"
|
|
88
|
+
form#login/textbox"Username"
|
|
89
|
+
|
|
90
|
+
Both resolve. A path is an assertion about the page as it is now; a uid is a
|
|
91
|
+
memory of something observed. Neither is stamped into the page, so a stale
|
|
92
|
+
reference matches nothing or matches several -- it can never quietly resolve to
|
|
93
|
+
the wrong element.
|
|
94
|
+
|
|
95
|
+
The ``xh_N`` ids from the previous engine are gone and raise if used.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
import asyncio
|
|
99
|
+
import sys
|
|
100
|
+
from typing import Any
|
|
101
|
+
|
|
102
|
+
from . import bridge
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _dbg(msg: str) -> None:
|
|
106
|
+
"""Write debug output to stderr, where it cannot corrupt the JSON-RPC wire."""
|
|
107
|
+
print(f"[xactor:api] {msg}", file=sys.stderr, flush=True)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Ref:
|
|
111
|
+
"""A reference to an element, or a description of how to find one.
|
|
112
|
+
|
|
113
|
+
Returned by :meth:`find` and :meth:`one`, and accepted anywhere an action
|
|
114
|
+
wants a target. It also carries what Layer 0 observed -- the text, the box,
|
|
115
|
+
the declared events -- so the agent can decide without a second call.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
__slots__ = ("uid", "path", "role", "text", "box", "center", "events",
|
|
119
|
+
"value", "state", "score", "in_view")
|
|
120
|
+
|
|
121
|
+
def __init__(self, data: dict[str, Any]) -> None:
|
|
122
|
+
self.uid = data.get("uid")
|
|
123
|
+
self.path = data.get("path")
|
|
124
|
+
self.role = data.get("role", "")
|
|
125
|
+
self.text = data.get("text", "")
|
|
126
|
+
self.box = data.get("box")
|
|
127
|
+
self.center = data.get("center")
|
|
128
|
+
self.events = data.get("events", [])
|
|
129
|
+
self.value = data.get("value")
|
|
130
|
+
self.state = data.get("state", {})
|
|
131
|
+
self.score = data.get("score")
|
|
132
|
+
self.in_view = data.get("inView", True)
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def disabled(self) -> bool:
|
|
136
|
+
return bool(self.state.get("disabled"))
|
|
137
|
+
|
|
138
|
+
def as_target(self) -> str:
|
|
139
|
+
"""How this crosses to Layer 0.
|
|
140
|
+
|
|
141
|
+
The uid *and* the path when both are known: the uid is what identifies
|
|
142
|
+
the element, and the path lets Layer 0 notice it has moved somewhere
|
|
143
|
+
that changes what a click would mean.
|
|
144
|
+
"""
|
|
145
|
+
if self.uid and self.path:
|
|
146
|
+
return f"{self.uid}@{self.path}"
|
|
147
|
+
return self.uid or self.path or ""
|
|
148
|
+
|
|
149
|
+
def __repr__(self) -> str:
|
|
150
|
+
bits = [self.role or "?"]
|
|
151
|
+
if self.text:
|
|
152
|
+
bits.append(repr(self.text))
|
|
153
|
+
if self.score is not None:
|
|
154
|
+
bits.append(f"score={self.score:.2f}")
|
|
155
|
+
return f"<Ref {' '.join(bits)} {self.path or self.uid}>"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _target(t: Any) -> Any:
|
|
159
|
+
"""Normalise whatever an action was given into something Layer 0 accepts."""
|
|
160
|
+
if isinstance(t, Ref):
|
|
161
|
+
return t.as_target()
|
|
162
|
+
if isinstance(t, FindSpec):
|
|
163
|
+
return t.spec
|
|
164
|
+
if isinstance(t, dict):
|
|
165
|
+
return t
|
|
166
|
+
return str(t)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class FindSpec:
|
|
170
|
+
"""A search deferred until the queue reaches the action holding it.
|
|
171
|
+
|
|
172
|
+
This is what makes ``click(find(...))`` work on something that does not
|
|
173
|
+
exist yet -- the dialog button an earlier action in the same chain is about
|
|
174
|
+
to bring into being. Nothing is evaluated when you build it.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
__slots__ = ("spec",)
|
|
178
|
+
|
|
179
|
+
def __init__(self, spec: dict[str, Any]) -> None:
|
|
180
|
+
self.spec = spec
|
|
181
|
+
|
|
182
|
+
def __repr__(self) -> str:
|
|
183
|
+
return f"<FindSpec {self.spec}>"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _spec(
|
|
187
|
+
role: str | list[str] | None = None,
|
|
188
|
+
like: str | None = None,
|
|
189
|
+
text_exact: str | None = None,
|
|
190
|
+
under: Any = None,
|
|
191
|
+
near: Any = None,
|
|
192
|
+
above: Any = None,
|
|
193
|
+
below: Any = None,
|
|
194
|
+
left_of: Any = None,
|
|
195
|
+
right_of: Any = None,
|
|
196
|
+
in_row: Any = None,
|
|
197
|
+
in_section: Any = None,
|
|
198
|
+
attr: dict[str, str] | None = None,
|
|
199
|
+
state: dict[str, bool] | None = None,
|
|
200
|
+
in_view: bool | None = None,
|
|
201
|
+
interactive: bool | None = None,
|
|
202
|
+
include_inert: bool | None = None,
|
|
203
|
+
limit: int | None = None,
|
|
204
|
+
margin: float | None = None,
|
|
205
|
+
) -> dict[str, Any]:
|
|
206
|
+
"""Build the wire form of a search. Snake_case in, camelCase out."""
|
|
207
|
+
out: dict[str, Any] = {}
|
|
208
|
+
if role is not None:
|
|
209
|
+
out["role"] = role
|
|
210
|
+
if like is not None:
|
|
211
|
+
out["like"] = like
|
|
212
|
+
if text_exact is not None:
|
|
213
|
+
out["textExact"] = text_exact
|
|
214
|
+
for key, val in (
|
|
215
|
+
("under", under), ("near", near), ("above", above), ("below", below),
|
|
216
|
+
("leftOf", left_of), ("rightOf", right_of),
|
|
217
|
+
("inRow", in_row), ("inSection", in_section),
|
|
218
|
+
):
|
|
219
|
+
if val is not None:
|
|
220
|
+
out[key] = _target(val)
|
|
221
|
+
if attr is not None:
|
|
222
|
+
out["attr"] = attr
|
|
223
|
+
if state is not None:
|
|
224
|
+
out["state"] = state
|
|
225
|
+
if in_view is not None:
|
|
226
|
+
out["inView"] = in_view
|
|
227
|
+
if interactive is not None:
|
|
228
|
+
out["interactive"] = interactive
|
|
229
|
+
if include_inert is not None:
|
|
230
|
+
out["includeInert"] = include_inert
|
|
231
|
+
if limit is not None:
|
|
232
|
+
out["limit"] = limit
|
|
233
|
+
if margin is not None:
|
|
234
|
+
out["margin"] = margin
|
|
235
|
+
return out
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class ElementaryAPI:
|
|
239
|
+
"""Layer 1. Compose these to locate things; hand the result to an action."""
|
|
240
|
+
|
|
241
|
+
#: Actions scheduled but not yet collected by :meth:`drain`.
|
|
242
|
+
_pending: "list[asyncio.Future[Any]] | None" = None
|
|
243
|
+
#: The most recently scheduled action, so the next one can follow it.
|
|
244
|
+
_tail: "asyncio.Future[Any] | None" = None
|
|
245
|
+
|
|
246
|
+
def _enqueue(self, coro: "Any") -> "asyncio.Future[Any]":
|
|
247
|
+
"""Schedule an action now and hand back something awaitable.
|
|
248
|
+
|
|
249
|
+
This is what makes ``api.click(x)`` work without ``await``.
|
|
250
|
+
|
|
251
|
+
A bare ``async def`` cannot: a coroutine nobody awaits is never
|
|
252
|
+
scheduled, so it simply does not happen — silently, apart from a
|
|
253
|
+
RuntimeWarning nobody sees in a webview. The docstrings below have
|
|
254
|
+
always promised that actions queue and ``drain()`` collects them, and
|
|
255
|
+
that promise needs a task rather than a coroutine behind it.
|
|
256
|
+
|
|
257
|
+
Chained rather than fired in parallel. Actions are ordered by
|
|
258
|
+
construction — you clear a field *then* click Save — and letting the
|
|
259
|
+
event loop interleave two of them would reorder the page's input for
|
|
260
|
+
no gain, because the queue that matters is the host's and it is fed in
|
|
261
|
+
receipt order.
|
|
262
|
+
|
|
263
|
+
Awaiting the returned future gives that action's own record, exactly as
|
|
264
|
+
before; ignoring it leaves the work scheduled and lets ``drain()``
|
|
265
|
+
report on it.
|
|
266
|
+
"""
|
|
267
|
+
if self._pending is None:
|
|
268
|
+
self._pending = []
|
|
269
|
+
prev = self._tail
|
|
270
|
+
|
|
271
|
+
async def _run() -> Any:
|
|
272
|
+
if prev is not None:
|
|
273
|
+
# A failed predecessor must not cancel what follows here: Layer
|
|
274
|
+
# 0 decides what a failure does to the rest of the chain and
|
|
275
|
+
# reports it as `skipped`. This only enforces order.
|
|
276
|
+
try:
|
|
277
|
+
await prev
|
|
278
|
+
except Exception:
|
|
279
|
+
pass
|
|
280
|
+
return await coro
|
|
281
|
+
|
|
282
|
+
task = asyncio.ensure_future(_run())
|
|
283
|
+
self._pending.append(task)
|
|
284
|
+
self._tail = task
|
|
285
|
+
return task
|
|
286
|
+
|
|
287
|
+
# ── Locating ──────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
def find(self, **kw: Any) -> FindSpec:
|
|
290
|
+
"""Describe a search, without running it.
|
|
291
|
+
|
|
292
|
+
Use this **inside an action** to defer the lookup to dispatch time::
|
|
293
|
+
|
|
294
|
+
api.click(api.find(role="button", like="create token"))
|
|
295
|
+
|
|
296
|
+
That is the form to reach for by default: it costs no round trip when
|
|
297
|
+
you build it, and it resolves against the page as it will be when the
|
|
298
|
+
click actually happens rather than as it is now.
|
|
299
|
+
|
|
300
|
+
To *see* the candidates instead, await :meth:`search`.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
role: An ARIA role, or a list of them. Same vocabulary the paths
|
|
304
|
+
use -- ``button``, ``link``, ``textbox``, ``row``, ``dialog``.
|
|
305
|
+
like: Approximate text. Ranked, not filtered.
|
|
306
|
+
text_exact: Exact text, after normalisation.
|
|
307
|
+
under: Restrict to a subtree, by ref or path.
|
|
308
|
+
near / above / below / left_of / right_of: Geometric relations. The
|
|
309
|
+
anchor may be a ref, a path, or just the visible text of a label
|
|
310
|
+
-- ``right_of="Name"`` is how you reach the field beside a
|
|
311
|
+
caption that is not itself addressable.
|
|
312
|
+
in_row / in_section: Containment, for tables and landmarks.
|
|
313
|
+
state: e.g. ``{"enabled": True}``.
|
|
314
|
+
in_view: Default True. Off-screen matches are excluded and counted.
|
|
315
|
+
margin: How decisive the ranking must be before Layer 0 will act.
|
|
316
|
+
``0`` takes the top rank; ``1`` acts only if nothing else
|
|
317
|
+
matched at all. Default 0.15.
|
|
318
|
+
"""
|
|
319
|
+
return FindSpec(_spec(**kw))
|
|
320
|
+
|
|
321
|
+
async def search(self, **kw: Any) -> list[Ref]:
|
|
322
|
+
"""Run a search now and return the ranked candidates.
|
|
323
|
+
|
|
324
|
+
Zero results is an ordinary answer, not an error -- "I cannot find it"
|
|
325
|
+
is the normal case on a graphical UI, and it is information.
|
|
326
|
+
|
|
327
|
+
Each :class:`Ref` carries its ``score``. When the top two are close,
|
|
328
|
+
that is the page telling you the description was not specific enough::
|
|
329
|
+
|
|
330
|
+
cands = await api.search(role="button", like="create")
|
|
331
|
+
# [<Ref button 'Create Token' score=0.90>,
|
|
332
|
+
# <Ref button 'Create Team' score=0.90>]
|
|
333
|
+
"""
|
|
334
|
+
result = await bridge.call("find", find=_spec(**kw))
|
|
335
|
+
if isinstance(result, dict) and result.get("error"):
|
|
336
|
+
_dbg(f"search error: {result}")
|
|
337
|
+
return []
|
|
338
|
+
nodes = (result or {}).get("nodes") or []
|
|
339
|
+
return [Ref(n) for n in nodes]
|
|
340
|
+
|
|
341
|
+
async def one(self, **kw: Any) -> Ref:
|
|
342
|
+
"""Exactly one match, or raise with the candidates attached.
|
|
343
|
+
|
|
344
|
+
Use when you believe the description is unambiguous and want to be told
|
|
345
|
+
immediately if it is not. The exception body lists what tied, which is
|
|
346
|
+
the refinement hint for your next attempt.
|
|
347
|
+
"""
|
|
348
|
+
result = await bridge.call("one", find=_spec(**kw))
|
|
349
|
+
if isinstance(result, dict) and result.get("error"):
|
|
350
|
+
cands = result.get("candidates") or []
|
|
351
|
+
detail = "\n".join(
|
|
352
|
+
f" {c.get('score', 0):.2f} {c.get('path') or c.get('uid')}"
|
|
353
|
+
f" {c.get('text', '')!r}"
|
|
354
|
+
for c in cands
|
|
355
|
+
)
|
|
356
|
+
raise LookupError(
|
|
357
|
+
f"{result['error']}: no single match for {_spec(**kw)}"
|
|
358
|
+
+ (f"\ncandidates:\n{detail}" if detail else "")
|
|
359
|
+
)
|
|
360
|
+
return Ref(result)
|
|
361
|
+
|
|
362
|
+
async def outline(self, under: Any = None, depth: int = 2) -> dict[str, Any]:
|
|
363
|
+
"""The shape of the page -- roles and names, no geometry.
|
|
364
|
+
|
|
365
|
+
The cheapest way to orient. Read this before reaching for a screenshot;
|
|
366
|
+
a screenshot puts you back in the loop for every lookup, which is the
|
|
367
|
+
cost this whole design exists to avoid.
|
|
368
|
+
"""
|
|
369
|
+
return await bridge.call("outline", under=_target(under) if under else None, depth=depth)
|
|
370
|
+
|
|
371
|
+
async def snapshot(self, **kw: Any) -> dict[str, Any]:
|
|
372
|
+
"""The interactive tree, filtered.
|
|
373
|
+
|
|
374
|
+
Conservative by default: in-viewport, interactive elements only, with
|
|
375
|
+
everything omitted reported as a count so you always know what you are
|
|
376
|
+
not being shown.
|
|
377
|
+
"""
|
|
378
|
+
return await bridge.call("tag", **_spec(**kw))
|
|
379
|
+
|
|
380
|
+
async def digest(self) -> dict[str, Any]:
|
|
381
|
+
"""A cheap rollup of the page, for "did anything change?"."""
|
|
382
|
+
return await bridge.call("digest")
|
|
383
|
+
|
|
384
|
+
# ── Acting ────────────────────────────────────────────────────────
|
|
385
|
+
#
|
|
386
|
+
# These queue. Awaiting one is allowed and gives you its record; not
|
|
387
|
+
# awaiting is the point, and `drain()` collects the lot.
|
|
388
|
+
#
|
|
389
|
+
# They return a scheduled task rather than being `async def`, which is what
|
|
390
|
+
# makes the second half of that true — see `_enqueue`. `await api.click(x)`
|
|
391
|
+
# behaves exactly as it always has.
|
|
392
|
+
|
|
393
|
+
def click(self, target: Any, **kw: Any) -> "asyncio.Future[Any]":
|
|
394
|
+
"""Click something. Returns the record of what happened."""
|
|
395
|
+
return self._enqueue(bridge.call("click", selector=_target(target), **_action_kw(kw)))
|
|
396
|
+
|
|
397
|
+
def double_click(self, target: Any, **kw: Any) -> "asyncio.Future[Any]":
|
|
398
|
+
"""Double-click something.
|
|
399
|
+
|
|
400
|
+
The same verb as :meth:`click` with a count, which is what the DOM says
|
|
401
|
+
it is: the two presses land back-to-back at one point, so the page sees
|
|
402
|
+
`dblclick` rather than two separate clicks.
|
|
403
|
+
|
|
404
|
+
Reach for it when single and double click on the same element mean
|
|
405
|
+
different things -- opening versus renaming, a chat versus a card. If
|
|
406
|
+
the interface offers a button for what the double click does, prefer
|
|
407
|
+
the button: it is what a keyboard user has to press anyway.
|
|
408
|
+
"""
|
|
409
|
+
return self._enqueue(
|
|
410
|
+
bridge.call("click", selector=_target(target), click_count=2, **_action_kw(kw)))
|
|
411
|
+
|
|
412
|
+
def type_text(self, target: Any, text: str, **kw: Any) -> "asyncio.Future[Any]":
|
|
413
|
+
"""Type into a field, appending to whatever is there.
|
|
414
|
+
|
|
415
|
+
``\\n`` is typed as a real Enter press -- a line break in a textarea, a
|
|
416
|
+
send in anything bound to send. The action sizes its own timeout from
|
|
417
|
+
the length of ``text``, so do not pass ``timeout_ms``.
|
|
418
|
+
"""
|
|
419
|
+
return self._enqueue(
|
|
420
|
+
bridge.call("type", selector=_target(target), text=text, **_action_kw(kw)))
|
|
421
|
+
|
|
422
|
+
def clear_and_type(self, target: Any, text: str, **kw: Any) -> "asyncio.Future[Any]":
|
|
423
|
+
"""Replace a field's contents. Same newline and timeout rules as
|
|
424
|
+
:meth:`type_text`."""
|
|
425
|
+
return self._enqueue(
|
|
426
|
+
bridge.call("clear_type", selector=_target(target), text=text, **_action_kw(kw)))
|
|
427
|
+
|
|
428
|
+
def hover(self, target: Any, **kw: Any) -> "asyncio.Future[Any]":
|
|
429
|
+
return self._enqueue(bridge.call("hover", selector=_target(target), **_action_kw(kw)))
|
|
430
|
+
|
|
431
|
+
def scroll_to(self, target: Any, **kw: Any) -> "asyncio.Future[Any]":
|
|
432
|
+
return self._enqueue(bridge.call("scroll_to", selector=_target(target), **_action_kw(kw)))
|
|
433
|
+
|
|
434
|
+
def select(self, target: Any, value: str | None = None,
|
|
435
|
+
label: str | None = None, **kw: Any) -> "asyncio.Future[Any]":
|
|
436
|
+
"""Choose an option in a native ``<select>``.
|
|
437
|
+
|
|
438
|
+
A custom ARIA combobox is a different thing and this refuses it rather
|
|
439
|
+
than guessing: click the trigger, then click the option scoped to the
|
|
440
|
+
listbox it opens.
|
|
441
|
+
"""
|
|
442
|
+
return self._enqueue(bridge.call("select", selector=_target(target),
|
|
443
|
+
value=value, label=label, **_action_kw(kw)))
|
|
444
|
+
|
|
445
|
+
def focus(self, target: Any, **kw: Any) -> "asyncio.Future[Any]":
|
|
446
|
+
"""Put keyboard focus on something.
|
|
447
|
+
|
|
448
|
+
The other half of keyboard navigation. :meth:`press` types at whatever
|
|
449
|
+
is focused; this is how you decide what that is.
|
|
450
|
+
|
|
451
|
+
Reach for it when a control cannot be clicked. A custom combobox --
|
|
452
|
+
react-select and everything shaped like it -- hides a 1-2px input under
|
|
453
|
+
a styled div, so a click is correctly refused as *covered*, and the
|
|
454
|
+
wrapper's class is a build hash not worth writing down. The keyboard is
|
|
455
|
+
the way in, and it is also how a person would do it::
|
|
456
|
+
|
|
457
|
+
api.focus('#Project--permission')
|
|
458
|
+
api.press('ArrowDown', times=2) # open, then move
|
|
459
|
+
api.press('Enter') # commit
|
|
460
|
+
|
|
461
|
+
Reports ``not_interactable`` rather than ``ok`` when the element
|
|
462
|
+
refuses focus -- a disabled control, something inert, a div with no
|
|
463
|
+
tabindex. Assuming it took would send the next ``press`` to whatever
|
|
464
|
+
was focused before.
|
|
465
|
+
"""
|
|
466
|
+
return self._enqueue(bridge.call("focus", selector=_target(target), **_action_kw(kw)))
|
|
467
|
+
|
|
468
|
+
def press(self, keys: str, target: Any = None, times: int | None = None,
|
|
469
|
+
**kw: Any) -> "asyncio.Future[Any]":
|
|
470
|
+
"""Press a key or chord, e.g. ``"Enter"`` or ``"Control+a"``.
|
|
471
|
+
|
|
472
|
+
Args:
|
|
473
|
+
keys: The chord. Modifiers join with ``+``: ``"Control+a"``.
|
|
474
|
+
target: Optional. Focus this first, so one step can mean "press
|
|
475
|
+
Enter *in this field*" rather than "press Enter at whatever the
|
|
476
|
+
page last focused".
|
|
477
|
+
times: Repeat the chord. For walking a listbox without writing the
|
|
478
|
+
same step four times. Capped at 50 -- a runaway repeat is a
|
|
479
|
+
held key the page cannot recover from.
|
|
480
|
+
"""
|
|
481
|
+
extra: dict[str, Any] = {}
|
|
482
|
+
if target is not None:
|
|
483
|
+
extra["selector"] = _target(target)
|
|
484
|
+
if times is not None:
|
|
485
|
+
extra["times"] = times
|
|
486
|
+
return self._enqueue(bridge.call("press", keys=keys, **extra, **_action_kw(kw)))
|
|
487
|
+
|
|
488
|
+
async def navigate(self, url: str) -> dict[str, Any]:
|
|
489
|
+
# Not an `_api_method`, so it never passed the seal check `drive` does:
|
|
490
|
+
# `navigate('file:///…/secret')` and then `outline()` read a sealed
|
|
491
|
+
# file straight back. The browser refuses a sealed `file://` load on
|
|
492
|
+
# its own too (see `sealedFileUrl` in the TS browser layer); this is
|
|
493
|
+
# the earlier, clearer refusal.
|
|
494
|
+
from . import seals
|
|
495
|
+
meta = bridge.exec_meta.get() or {}
|
|
496
|
+
seals.check_call("navigate", {"url": url}, meta.get("modelId"))
|
|
497
|
+
return await bridge.call("navigate", url=url)
|
|
498
|
+
|
|
499
|
+
# ── Guiding a person through the app ────────────────────────────
|
|
500
|
+
#
|
|
501
|
+
# Ringing a control and putting a sentence beside it are ways of driving a
|
|
502
|
+
# surface, like clicking and typing, so they are verbs here rather than a
|
|
503
|
+
# feature somewhere above. The agent guides live: ring something, see
|
|
504
|
+
# whether it resolved, decide what to say next. Nothing is predicted in
|
|
505
|
+
# advance, so nothing has to be validated in advance.
|
|
506
|
+
#
|
|
507
|
+
# Awaited rather than queued. A chain of clicks is worth batching because
|
|
508
|
+
# nobody is watching it; these exist precisely because somebody is, and each
|
|
509
|
+
# one is a thing the agent should see the result of before it says the next
|
|
510
|
+
# sentence.
|
|
511
|
+
|
|
512
|
+
async def highlight(self, target: Any = None, label: str | None = None) -> dict[str, Any]:
|
|
513
|
+
"""Ring a control in the app. Pass nothing to take the ring down.
|
|
514
|
+
|
|
515
|
+
Drawn inside the page, so it appears whether the app is in its own
|
|
516
|
+
window or being mirrored — a mirror is a picture of the page, and the
|
|
517
|
+
ring is part of the page.
|
|
518
|
+
|
|
519
|
+
Returns ``{status, box}`` when it drew. When it could not, the reason
|
|
520
|
+
comes back rather than raising: ``that description matches more than one
|
|
521
|
+
control`` is the agent's to narrow, and ``that control is not on this
|
|
522
|
+
screen`` is usually the app being somewhere else, which is worth saying
|
|
523
|
+
to the user rather than failing on.
|
|
524
|
+
"""
|
|
525
|
+
if target is None:
|
|
526
|
+
return await bridge.call("highlight", selector=None)
|
|
527
|
+
return await bridge.call(
|
|
528
|
+
"highlight", selector=_target(target), **({"label": label} if label else {}),
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
async def instruct(
|
|
532
|
+
self,
|
|
533
|
+
text: str | None,
|
|
534
|
+
target: Any = None,
|
|
535
|
+
*,
|
|
536
|
+
expect: str | None = None,
|
|
537
|
+
title: str = "",
|
|
538
|
+
step: int | None = None,
|
|
539
|
+
total: int | None = None,
|
|
540
|
+
) -> dict[str, Any]:
|
|
541
|
+
"""Put a sentence in the app, beside a control when you name one.
|
|
542
|
+
|
|
543
|
+
With a ``target`` the card is placed against that control with a pointer
|
|
544
|
+
and the control is ringed; without one it takes a corner, which is right
|
|
545
|
+
for "watch what happens next" and wrong for anything naming a button.
|
|
546
|
+
|
|
547
|
+
Pass ``text=None`` to take it down.
|
|
548
|
+
|
|
549
|
+
Keep it short and imperative — it sits beside a live app and is skimmed,
|
|
550
|
+
not read. Name the control in the words as well as ringing it: the ring
|
|
551
|
+
is dropped whenever the geometry cannot be proven, and a sentence that
|
|
552
|
+
leans on it breaks exactly when the safety net fires.
|
|
553
|
+
|
|
554
|
+
``step``/``total`` are optional and only draw a counter. Omit them for a
|
|
555
|
+
single instruction; pass them when you are walking somebody through
|
|
556
|
+
several and they will want to know how far along they are.
|
|
557
|
+
"""
|
|
558
|
+
kw: dict[str, Any] = {"text": text, "title": title}
|
|
559
|
+
if target is not None:
|
|
560
|
+
kw["selector"] = _target(target)
|
|
561
|
+
if expect is not None:
|
|
562
|
+
kw["expect"] = expect
|
|
563
|
+
if step is not None:
|
|
564
|
+
kw["step"] = step
|
|
565
|
+
if total is not None:
|
|
566
|
+
kw["total"] = total
|
|
567
|
+
return await bridge.call("instruct", **kw)
|
|
568
|
+
|
|
569
|
+
async def reply(self, text: str) -> dict[str, Any]:
|
|
570
|
+
"""Answer a question without taking the step away.
|
|
571
|
+
|
|
572
|
+
For what comes back as ``asked``. The instruction, the ring and the
|
|
573
|
+
counter all stay exactly as they were and the answer goes underneath —
|
|
574
|
+
a word with somebody about the step they are on, rather than a new step.
|
|
575
|
+
|
|
576
|
+
Do not use :meth:`instruct` for this. That call *is* a new step: it
|
|
577
|
+
moves the ring, resets the counter and replaces the words, so answering
|
|
578
|
+
with it takes away the very sentence the question was about.
|
|
579
|
+
|
|
580
|
+
Then call :meth:`await_user` again on the same target to hand the step
|
|
581
|
+
back to them.
|
|
582
|
+
"""
|
|
583
|
+
return await bridge.call("reply", text=text)
|
|
584
|
+
|
|
585
|
+
async def await_user(
|
|
586
|
+
self, target: Any = None, timeout_seconds: int = 300
|
|
587
|
+
) -> dict[str, Any]:
|
|
588
|
+
"""Wait for the person to do something, and say what they did.
|
|
589
|
+
|
|
590
|
+
One wait, five answers, because a person being guided has more than one
|
|
591
|
+
thing they might do::
|
|
592
|
+
|
|
593
|
+
{"clicked": True} they clicked the control you rang
|
|
594
|
+
{"advanced": True} they pressed Next
|
|
595
|
+
{"reported": True} they marked what is wrong and sent it
|
|
596
|
+
{"asked": "why this one?"} they typed a question
|
|
597
|
+
{"stopped": True} they ended it — Finish, or the chat
|
|
598
|
+
{"clicked": False, "reason": …} nothing, for the whole window
|
|
599
|
+
|
|
600
|
+
``reported`` always carries ``annotations`` and means they stopped to
|
|
601
|
+
say something is wrong rather than doing what the step asked. Deal with
|
|
602
|
+
what is marked before writing another step.
|
|
603
|
+
|
|
604
|
+
Any of them may also carry ``annotations``: boxes somebody drew on the
|
|
605
|
+
app with "Mark what is wrong", each with a note and a picture of the
|
|
606
|
+
screen as it was when they wrote it. **That is the point of showing
|
|
607
|
+
somebody something rather than asking them.** A demo walked end to end
|
|
608
|
+
can still be a demo of six broken screens, so deal with what they
|
|
609
|
+
marked before you claim anything works. Regions are normalised 0-1, so
|
|
610
|
+
they survive the window being resized.
|
|
611
|
+
|
|
612
|
+
``stopped`` is not a failure and not something to argue with. Take the
|
|
613
|
+
card down with ``instruct(None)``, say what you were about to show in a
|
|
614
|
+
sentence, and stop.
|
|
615
|
+
|
|
616
|
+
The tour also raises a row in the chat and the Needs-you queue for as
|
|
617
|
+
long as it runs, so somebody looking at neither the app window nor this
|
|
618
|
+
conversation can still see that a demo is waiting on them. It closes
|
|
619
|
+
when you call ``instruct(None)``. Answering *there* settles this wait —
|
|
620
|
+
whichever surface they reach for wins.
|
|
621
|
+
|
|
622
|
+
A press never reaches this side in a real window — the app handles it —
|
|
623
|
+
so it is reported out of the page and tested the way a mirrored click
|
|
624
|
+
is: both ends inside the control, barely any travel between them, so a
|
|
625
|
+
press that slides off stays the cancelled click it looks like.
|
|
626
|
+
|
|
627
|
+
``target`` is optional. Omit it for a step with nothing to click ("read
|
|
628
|
+
this, then carry on"); Next and the question box are still there.
|
|
629
|
+
|
|
630
|
+
The timeout is **not an error**. Somebody who did something else is not
|
|
631
|
+
a failure, and noticing it is how you say something useful next.
|
|
632
|
+
|
|
633
|
+
Answer a question with :meth:`reply`, then wait again on the same
|
|
634
|
+
target. :meth:`instruct` *is* a new step — it moves the ring, resets the
|
|
635
|
+
counter and replaces the words — so answering with it takes away the
|
|
636
|
+
sentence the question was about. Reach for it only when the answer
|
|
637
|
+
genuinely lives somewhere else; a question is allowed to take the tour
|
|
638
|
+
off course, and ``title="detour"`` is how you say so.
|
|
639
|
+
"""
|
|
640
|
+
return await bridge.call(
|
|
641
|
+
"await_user",
|
|
642
|
+
selector=_target(target) if target is not None else None,
|
|
643
|
+
timeout_ms=timeout_seconds * 1000,
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
async def drain(self) -> dict[str, Any]:
|
|
647
|
+
"""Wait for every queued action and return one report for the chain.
|
|
648
|
+
|
|
649
|
+
The report names each action, what it resolved to, whether it worked,
|
|
650
|
+
and what changed. A failure stops the chain -- the remainder is reported
|
|
651
|
+
as ``skipped`` rather than attempted.
|
|
652
|
+
"""
|
|
653
|
+
# Settle anything scheduled but not awaited before asking the host for
|
|
654
|
+
# the report. Without this, `drain()` races the actions it is meant to
|
|
655
|
+
# be reporting on and returns `{'actions': [], 'ok': 0}` — which reads
|
|
656
|
+
# as "the chain ran and did nothing" rather than "it has not run yet".
|
|
657
|
+
pending, self._pending, self._tail = (self._pending or []), [], None
|
|
658
|
+
records: list[Any] = []
|
|
659
|
+
for task in pending:
|
|
660
|
+
try:
|
|
661
|
+
rec = await task
|
|
662
|
+
if isinstance(rec, dict):
|
|
663
|
+
records.append(rec)
|
|
664
|
+
except Exception as err: # noqa: BLE001 - reported as a failed action
|
|
665
|
+
_dbg(f"queued action failed: {err}")
|
|
666
|
+
records.append({"kind": "unknown", "ok": False,
|
|
667
|
+
"outcome": "error", "error": str(err)})
|
|
668
|
+
|
|
669
|
+
host = await bridge.call("drain")
|
|
670
|
+
host = host if isinstance(host, dict) else {}
|
|
671
|
+
|
|
672
|
+
# The host's queue is empty by now, because every action's own
|
|
673
|
+
# `bridge.call` already resolved with its record — so its report counts
|
|
674
|
+
# nothing. The chain-level report the docstring promises has to be
|
|
675
|
+
# assembled from the records we collected, and the host's is kept only
|
|
676
|
+
# for the fields it alone knows (the url before/after, the elapsed ms).
|
|
677
|
+
if records and not (host.get("actions") or []):
|
|
678
|
+
host = {
|
|
679
|
+
**host,
|
|
680
|
+
"actions": records,
|
|
681
|
+
"ok": sum(1 for r in records if r.get("ok")),
|
|
682
|
+
"failed": sum(1 for r in records if not r.get("ok")),
|
|
683
|
+
"skipped": host.get("skipped", 0),
|
|
684
|
+
}
|
|
685
|
+
return host
|
|
686
|
+
|
|
687
|
+
# ── Waiting ───────────────────────────────────────────────────────
|
|
688
|
+
|
|
689
|
+
async def wait_for(self, target: Any, condition: str = "visible",
|
|
690
|
+
timeout_ms: int | None = None) -> dict[str, Any]:
|
|
691
|
+
"""Sleep until an element is there.
|
|
692
|
+
|
|
693
|
+
Rarely needed explicitly: an action already waits for its own target.
|
|
694
|
+
Reach for it when you want to wait for something you are not about to
|
|
695
|
+
touch -- a spinner to go (``condition="gone"``), or a result to appear.
|
|
696
|
+
"""
|
|
697
|
+
return await bridge.call("wait_for_element", selector=_target(target),
|
|
698
|
+
condition=condition, timeout=timeout_ms)
|
|
699
|
+
|
|
700
|
+
async def wait_stable(self, timeout_ms: int | None = None) -> dict[str, Any]:
|
|
701
|
+
"""Wait for the page to stop loading and stop changing."""
|
|
702
|
+
return await bridge.call("wait_stable", timeout=timeout_ms)
|
|
703
|
+
|
|
704
|
+
# ── DOM-level input, for pages that never paint ───────────────────
|
|
705
|
+
#
|
|
706
|
+
# `click` and `type_text` synthesise real input events, which is what makes
|
|
707
|
+
# them humanised -- and what makes them useless on a background page.
|
|
708
|
+
# Chromium does not deliver synthesised input to a document whose
|
|
709
|
+
# visibilityState is "hidden", and it does not complain either. Layer 0 now
|
|
710
|
+
# detects that and fails with `page_hidden` instead of reporting success,
|
|
711
|
+
# but these two are the way to actually do the work there.
|
|
712
|
+
|
|
713
|
+
async def dom_click(self, target: Any) -> dict[str, Any]:
|
|
714
|
+
"""Click through the DOM, without synthesising input."""
|
|
715
|
+
return await bridge.call("dom_click", selector=_target(target))
|
|
716
|
+
|
|
717
|
+
async def dom_fill(self, target: Any, text: str) -> dict[str, Any]:
|
|
718
|
+
"""Set a field's value so React and Angular notice."""
|
|
719
|
+
return await bridge.call("dom_fill", selector=_target(target), text=text)
|
|
720
|
+
|
|
721
|
+
async def upload(self, target: Any, paths: Any) -> dict[str, Any]:
|
|
722
|
+
"""Attach file(s) from disk to an ``<input type=file>``.
|
|
723
|
+
|
|
724
|
+
Page script cannot do this, so it is a verb of its own. ``target`` may be
|
|
725
|
+
the input or the styled button / drop zone around it; ``paths`` is an
|
|
726
|
+
absolute path or a list of them. The page gets its ``change`` event as
|
|
727
|
+
if the user had picked the files in the dialog. Reports
|
|
728
|
+
``{"error": "no_file_input"}`` when nothing file-shaped is there.
|
|
729
|
+
"""
|
|
730
|
+
if isinstance(paths, (str, bytes)) or not hasattr(paths, "__iter__"):
|
|
731
|
+
paths = [paths]
|
|
732
|
+
return await bridge.call("upload", selector=_target(target), paths=[str(p) for p in paths])
|
|
733
|
+
|
|
734
|
+
# ── Reading ───────────────────────────────────────────────────────
|
|
735
|
+
|
|
736
|
+
async def evaluate(self, expr: str) -> Any:
|
|
737
|
+
"""Evaluate JavaScript in the page. The escape hatch."""
|
|
738
|
+
return await bridge.call("evaluate", expr=expr)
|
|
739
|
+
|
|
740
|
+
async def get_state(self) -> dict[str, Any]:
|
|
741
|
+
"""The page's URL and title. **Not** the widget's state machine.
|
|
742
|
+
|
|
743
|
+
For where you are in a loaded widget, use :meth:`here`.
|
|
744
|
+
"""
|
|
745
|
+
return await bridge.call("get_state")
|
|
746
|
+
|
|
747
|
+
def _surface(self) -> Any:
|
|
748
|
+
"""The `api` a widget should see — the whole one, not just this half.
|
|
749
|
+
|
|
750
|
+
Layer 1 is grafted onto the stdlib `api` as *bound* methods, so `self`
|
|
751
|
+
inside them is this ElementaryAPI rather than the object the agent
|
|
752
|
+
calls. That is harmless for a verb that only reaches `bridge`, and
|
|
753
|
+
wrong the moment one hands `self` onward: a `PageState` built from it
|
|
754
|
+
can dispatch `click` and not `request_operator_action`, so a widget
|
|
755
|
+
step that asks the user to sign in dies with "ElementaryAPI has no
|
|
756
|
+
method 'request_operator_action'" — a page verb and a shell verb
|
|
757
|
+
sitting on the same `api` and only one of them reaching a widget.
|
|
758
|
+
|
|
759
|
+
`_merged` is set by `_graft_browser_l1`. Absent in the browser-only
|
|
760
|
+
shell, where this object really is the whole surface.
|
|
761
|
+
"""
|
|
762
|
+
return getattr(self, "_merged", self)
|
|
763
|
+
|
|
764
|
+
async def here(self) -> Any:
|
|
765
|
+
"""Which of the loaded widget's states the page is on right now.
|
|
766
|
+
|
|
767
|
+
The same thing as ``await registry.here(api)``, reachable by looking at
|
|
768
|
+
``api``.
|
|
769
|
+
|
|
770
|
+
That is the entire reason it exists. ``registry`` is a bare name in the
|
|
771
|
+
shell scope, so it does not appear in ``dir(api)`` — and ``dir(api)`` is
|
|
772
|
+
what a model actually does when it wants to know what it can call. A
|
|
773
|
+
session that had already loaded a widget correctly went on to invent
|
|
774
|
+
``api.widget_action``, then tried ``api.get_state()['transitions']``,
|
|
775
|
+
then ``api.drive('create_token')``, and never once reached the machine
|
|
776
|
+
that was sitting right there.
|
|
777
|
+
|
|
778
|
+
Returns a ``PageState``. ``describe()`` lists its transitions;
|
|
779
|
+
``execute(name, **params)`` runs one — or use :meth:`transition`.
|
|
780
|
+
"""
|
|
781
|
+
from .states import StateRegistry
|
|
782
|
+
return await StateRegistry.here(self._surface())
|
|
783
|
+
|
|
784
|
+
async def transition(self, name: str, /, **params: Any) -> Any:
|
|
785
|
+
"""Run one of the current state's named transitions.
|
|
786
|
+
|
|
787
|
+
Shorthand for ``(await api.here()).execute(name, **params)``, which is
|
|
788
|
+
the whole of a widget-driven task once ``use_widget`` has landed you
|
|
789
|
+
somewhere.
|
|
790
|
+
|
|
791
|
+
Returns the ``PageState`` you end up in, so a flow reads as a chain::
|
|
792
|
+
|
|
793
|
+
await api.use_widget("sentry_tokens")
|
|
794
|
+
await api.transition("create_token")
|
|
795
|
+
await api.transition("configure", token_name="ci-deploy")
|
|
796
|
+
"""
|
|
797
|
+
state = await self.here()
|
|
798
|
+
return await state.execute(name, **params)
|
|
799
|
+
|
|
800
|
+
async def screenshot(self, max_width: int | None = None) -> dict[str, Any]:
|
|
801
|
+
"""Capture the page as an image.
|
|
802
|
+
|
|
803
|
+
An image is *seen, not queryable*, and looking at one costs a round trip
|
|
804
|
+
through you. Prefer :meth:`outline`, :meth:`search` and the spatial
|
|
805
|
+
predicates -- ``right_of="Name"`` locates a field without a picture.
|
|
806
|
+
Use this for genuinely visual questions: layout, rendering, or
|
|
807
|
+
confirming a human did something you asked them to.
|
|
808
|
+
"""
|
|
809
|
+
params: dict[str, Any] = {}
|
|
810
|
+
if max_width is not None:
|
|
811
|
+
params["max_width"] = max_width
|
|
812
|
+
return await bridge.call("screenshot", **params)
|
|
813
|
+
|
|
814
|
+
async def extract_secret(self, target: Any, env_var: str) -> dict[str, Any]:
|
|
815
|
+
"""Write a revealed value to `NAME=value` in the workspace's `.env`,
|
|
816
|
+
without it passing through this layer.
|
|
817
|
+
|
|
818
|
+
It lands in the file, not in any process's environment: `api.bash`
|
|
819
|
+
sees `$env_var` only after loading it (`set -a; . ./.env; set +a`).
|
|
820
|
+
"""
|
|
821
|
+
return await bridge.call("extract_secret", selector=_target(target), env_var=env_var)
|
|
822
|
+
|
|
823
|
+
async def set_hfactor(self, value: float) -> dict[str, Any]:
|
|
824
|
+
"""Humanisation speed. **Higher is faster.** 1.0 is normal.
|
|
825
|
+
|
|
826
|
+
It divides every delay rather than being one, which is the bit that
|
|
827
|
+
gets read backwards: 2.0 halves the pauses and runs twice as fast;
|
|
828
|
+
0.5 doubles them and runs half as fast. To speed a script up, go up.
|
|
829
|
+
|
|
830
|
+
Per-dispatcher, so raising it for an unattended login does not leave a
|
|
831
|
+
global switch flipped for everything else.
|
|
832
|
+
"""
|
|
833
|
+
return await bridge.call("set_hfactor", value=value)
|
|
834
|
+
|
|
835
|
+
# ── Withdrawn ─────────────────────────────────────────────────────
|
|
836
|
+
|
|
837
|
+
async def query(self, *_args: Any, **_kwargs: Any) -> Any:
|
|
838
|
+
"""Withdrawn. Use :meth:`search` or :meth:`find`.
|
|
839
|
+
|
|
840
|
+
Kept as a refusal rather than deleted: an agent calling a method that no
|
|
841
|
+
longer exists gets an AttributeError and guesses, and its guess is
|
|
842
|
+
usually another name that is also missing.
|
|
843
|
+
"""
|
|
844
|
+
raise RuntimeError(
|
|
845
|
+
"api.query() is withdrawn -- it returned a flat list with xh_ ids that "
|
|
846
|
+
"dangled on re-render.\n"
|
|
847
|
+
" await api.search(role=..., like=...) ranked candidates you can look at\n"
|
|
848
|
+
" api.find(role=..., like=...) a search deferred into an action\n"
|
|
849
|
+
" await api.one(role=..., text_exact=...) exactly one, or the candidates"
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
async def hybrid_snapshot(self, *_args: Any, **_kwargs: Any) -> Any:
|
|
853
|
+
"""Withdrawn. Use :meth:`snapshot` or :meth:`outline`."""
|
|
854
|
+
raise RuntimeError(
|
|
855
|
+
"api.hybrid_snapshot() is withdrawn -- it returned a flat list with no "
|
|
856
|
+
"containment, so eight identical 'Delete' buttons were indistinguishable.\n"
|
|
857
|
+
" await api.outline() the shape of the page, cheapest\n"
|
|
858
|
+
" await api.snapshot() the interactive tree, filtered"
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
async def query_one(self, *_args: Any, **_kwargs: Any) -> Any:
|
|
862
|
+
"""Withdrawn. Use :meth:`one`."""
|
|
863
|
+
raise RuntimeError("api.query_one() is withdrawn -- use await api.one(...)")
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def _action_kw(kw: dict[str, Any]) -> dict[str, Any]:
|
|
867
|
+
"""Pass through the per-action knobs, dropping anything unset.
|
|
868
|
+
|
|
869
|
+
``timeout_ms`` is the action's whole ceiling -- resolving, waiting for its
|
|
870
|
+
target, and performing it -- not just one phase of it. ``batch_ms`` sets the
|
|
871
|
+
total for the chain and is read from whichever action opens it.
|
|
872
|
+
"""
|
|
873
|
+
out: dict[str, Any] = {}
|
|
874
|
+
if kw.get("margin") is not None:
|
|
875
|
+
out["margin"] = kw["margin"]
|
|
876
|
+
if kw.get("timeout_ms") is not None:
|
|
877
|
+
out["timeout"] = kw["timeout_ms"]
|
|
878
|
+
if kw.get("batch_ms") is not None:
|
|
879
|
+
out["batch_ms"] = kw["batch_ms"]
|
|
880
|
+
if kw.get("hfactor") is not None:
|
|
881
|
+
out["hfactor"] = kw["hfactor"]
|
|
882
|
+
return out
|