loom-code 0.1.1__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.
- loom_code/__init__.py +22 -0
- loom_code/_post_commit.py +119 -0
- loom_code/agent.py +544 -0
- loom_code/approval.py +616 -0
- loom_code/browse/__init__.py +291 -0
- loom_code/browse/act.py +467 -0
- loom_code/browse/observe.py +249 -0
- loom_code/browse/session.py +96 -0
- loom_code/browse/verify.py +194 -0
- loom_code/checkpoint.py +283 -0
- loom_code/cli.py +495 -0
- loom_code/code_index.py +703 -0
- loom_code/compact.py +143 -0
- loom_code/consent.py +47 -0
- loom_code/credentials.py +527 -0
- loom_code/edit_tool.py +635 -0
- loom_code/extensions.py +522 -0
- loom_code/file_history.py +322 -0
- loom_code/file_tools.py +93 -0
- loom_code/git_hook.py +200 -0
- loom_code/grep_tool.py +430 -0
- loom_code/hooks.py +297 -0
- loom_code/loominit/__init__.py +23 -0
- loom_code/loominit/_ast_walk.py +429 -0
- loom_code/loominit/_files.py +284 -0
- loom_code/loominit/_graph.py +141 -0
- loom_code/loominit/_resolve.py +392 -0
- loom_code/loominit/_tests_map.py +108 -0
- loom_code/loominit/extractor.py +332 -0
- loom_code/loominit/repomap.py +225 -0
- loom_code/loominit/schema.py +242 -0
- loom_code/lsp_tools.py +396 -0
- loom_code/mcp_host.py +79 -0
- loom_code/operator.py +449 -0
- loom_code/paste.py +97 -0
- loom_code/paths.py +52 -0
- loom_code/permissions.py +177 -0
- loom_code/project.py +104 -0
- loom_code/prompts.py +451 -0
- loom_code/render.py +783 -0
- loom_code/repl.py +4080 -0
- loom_code/rules.py +267 -0
- loom_code/sandboxed_bash.py +176 -0
- loom_code/scribe.py +88 -0
- loom_code/skills/__init__.py +16 -0
- loom_code/skills/graphify/SKILL.md +97 -0
- loom_code/skills/graphify/tools.py +570 -0
- loom_code/trust.py +216 -0
- loom_code/turn.py +169 -0
- loom_code/web_fetch.py +370 -0
- loom_code/workers.py +758 -0
- loom_code/worktree.py +134 -0
- loom_code-0.1.1.dist-info/METADATA +224 -0
- loom_code-0.1.1.dist-info/RECORD +58 -0
- loom_code-0.1.1.dist-info/WHEEL +5 -0
- loom_code-0.1.1.dist-info/entry_points.txt +2 -0
- loom_code-0.1.1.dist-info/licenses/LICENSE +21 -0
- loom_code-0.1.1.dist-info/top_level.txt +1 -0
loom_code/browse/act.py
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"""Act on an element by its stable ``data-loom-id``.
|
|
2
|
+
|
|
3
|
+
Robustness measures (each targets a failure we hit on Google Flights):
|
|
4
|
+
|
|
5
|
+
* Re-select FRESH every call via ``[data-loom-id="N"]`` — never a stale
|
|
6
|
+
handle. (fixes ``Ref e145 not found``)
|
|
7
|
+
* scroll_into_view + wait before acting.
|
|
8
|
+
* On a click that's intercepted by an overlay/dialog (the
|
|
9
|
+
``subtree intercepts pointer events`` timeout), fall back to a direct
|
|
10
|
+
JS ``.click()`` dispatch which ignores pointer-event interception.
|
|
11
|
+
* For typing: focus, clear, type, and for autocompletes optionally press
|
|
12
|
+
Enter — then the caller re-observes + can page_check the value stuck.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _sel(loom_id: int | str) -> str:
|
|
21
|
+
return f'[data-loom-id="{loom_id}"]'
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def act(
|
|
25
|
+
page: Any,
|
|
26
|
+
loom_id: int | str,
|
|
27
|
+
action: str,
|
|
28
|
+
text: str = "",
|
|
29
|
+
) -> str:
|
|
30
|
+
"""Perform ``action`` on the element tagged ``loom_id``.
|
|
31
|
+
|
|
32
|
+
Actions: click | type | clear | press_enter | select (text=option).
|
|
33
|
+
Returns a short human-readable result string."""
|
|
34
|
+
sel = _sel(loom_id)
|
|
35
|
+
locator = page.locator(sel)
|
|
36
|
+
try:
|
|
37
|
+
count = await locator.count()
|
|
38
|
+
except Exception as exc: # noqa: BLE001
|
|
39
|
+
return f"error locating [{loom_id}]: {exc}"
|
|
40
|
+
if count == 0:
|
|
41
|
+
return (
|
|
42
|
+
f"element [{loom_id}] is no longer on the page — call "
|
|
43
|
+
"page_observe to get the current elements + ids."
|
|
44
|
+
)
|
|
45
|
+
el = locator.first
|
|
46
|
+
|
|
47
|
+
# Bring it into view; ignore failures (some elements report unstable).
|
|
48
|
+
try:
|
|
49
|
+
await el.scroll_into_view_if_needed(timeout=3000)
|
|
50
|
+
except Exception: # noqa: BLE001
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
action = action.strip().lower()
|
|
54
|
+
|
|
55
|
+
if action == "click":
|
|
56
|
+
# Try a normal click first (respects real UX); on interception /
|
|
57
|
+
# timeout, fall back to a JS click that bypasses overlays.
|
|
58
|
+
try:
|
|
59
|
+
await el.click(timeout=4000)
|
|
60
|
+
return f"clicked [{loom_id}]"
|
|
61
|
+
except Exception: # noqa: BLE001 — overlay / unstable; JS fallback
|
|
62
|
+
try:
|
|
63
|
+
await el.evaluate("e => e.click()")
|
|
64
|
+
return (
|
|
65
|
+
f"clicked [{loom_id}] "
|
|
66
|
+
"(via JS — an overlay was in the way)"
|
|
67
|
+
)
|
|
68
|
+
except Exception as exc: # noqa: BLE001
|
|
69
|
+
return (
|
|
70
|
+
f"could not click [{loom_id}]: {exc}. An overlay may be "
|
|
71
|
+
"blocking it — try pressing Escape (page_act on a close "
|
|
72
|
+
"button) or re-observe."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if action in ("type", "fill"):
|
|
76
|
+
try:
|
|
77
|
+
await el.click(timeout=3000)
|
|
78
|
+
except Exception: # noqa: BLE001
|
|
79
|
+
try:
|
|
80
|
+
await el.evaluate("e => e.focus()")
|
|
81
|
+
except Exception: # noqa: BLE001
|
|
82
|
+
pass
|
|
83
|
+
try:
|
|
84
|
+
await el.fill("") # clear
|
|
85
|
+
except Exception: # noqa: BLE001
|
|
86
|
+
pass
|
|
87
|
+
try:
|
|
88
|
+
await el.fill(text)
|
|
89
|
+
return (
|
|
90
|
+
f'typed "{text}" into [{loom_id}]. If it is an '
|
|
91
|
+
"autocomplete, "
|
|
92
|
+
"re-observe and CLICK the matching suggestion; "
|
|
93
|
+
"then page_check the field value stuck."
|
|
94
|
+
)
|
|
95
|
+
except Exception: # noqa: BLE001 — fall back to keyboard typing
|
|
96
|
+
try:
|
|
97
|
+
await el.press_sequentially(text, delay=20)
|
|
98
|
+
return f'typed "{text}" into [{loom_id}] (key-by-key)'
|
|
99
|
+
except Exception as exc: # noqa: BLE001
|
|
100
|
+
return f"could not type into [{loom_id}]: {exc}"
|
|
101
|
+
|
|
102
|
+
if action == "clear":
|
|
103
|
+
try:
|
|
104
|
+
await el.fill("")
|
|
105
|
+
return f"cleared [{loom_id}]"
|
|
106
|
+
except Exception as exc: # noqa: BLE001
|
|
107
|
+
return f"could not clear [{loom_id}]: {exc}"
|
|
108
|
+
|
|
109
|
+
if action in ("press_enter", "enter", "submit"):
|
|
110
|
+
try:
|
|
111
|
+
await el.press("Enter")
|
|
112
|
+
return f"pressed Enter on [{loom_id}]"
|
|
113
|
+
except Exception as exc: # noqa: BLE001
|
|
114
|
+
return f"could not press Enter on [{loom_id}]: {exc}"
|
|
115
|
+
|
|
116
|
+
if action == "select":
|
|
117
|
+
try:
|
|
118
|
+
await el.select_option(label=text)
|
|
119
|
+
return f'selected "{text}" in [{loom_id}]'
|
|
120
|
+
except Exception as exc: # noqa: BLE001
|
|
121
|
+
return f"could not select in [{loom_id}]: {exc}"
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
f"unknown action '{action}'. Use: click | type (with text) | clear | "
|
|
125
|
+
"press_enter | select (with text=option)."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def set_date(page: Any, loom_id: int | str, date_text: str) -> str:
|
|
130
|
+
"""Pick a date in a calendar/date-picker widget.
|
|
131
|
+
|
|
132
|
+
Date pickers are a grid of day cells — guessing cell ids fails. The
|
|
133
|
+
robust path: open the picker (click the date field), then find the day
|
|
134
|
+
cell whose accessible name matches the target date and click it. Day
|
|
135
|
+
cells almost always carry an aria-label / data-iso with the FULL date
|
|
136
|
+
("Saturday, June 9, 2026" or "2026-06-09"), so we match on that —
|
|
137
|
+
navigating forward through months if the target isn't visible yet.
|
|
138
|
+
|
|
139
|
+
``date_text`` accepts forms like "2026-06-09", "June 9 2026",
|
|
140
|
+
"9 June 2026". We derive both an ISO and a long-form to match against.
|
|
141
|
+
"""
|
|
142
|
+
import asyncio
|
|
143
|
+
import re
|
|
144
|
+
|
|
145
|
+
# Parse the date loosely into (year, month, day).
|
|
146
|
+
months = {m: i for i, m in enumerate(
|
|
147
|
+
["january","february","march","april","may","june","july","august",
|
|
148
|
+
"september","october","november","december"], start=1)}
|
|
149
|
+
t = date_text.strip().lower()
|
|
150
|
+
y = m = d = None
|
|
151
|
+
iso = re.match(r"(\d{4})-(\d{1,2})-(\d{1,2})", t)
|
|
152
|
+
if iso:
|
|
153
|
+
y, m, d = int(iso[1]), int(iso[2]), int(iso[3])
|
|
154
|
+
else:
|
|
155
|
+
ym = re.search(r"(20\d{2})", t)
|
|
156
|
+
if ym:
|
|
157
|
+
y = int(ym[1])
|
|
158
|
+
for name, num in months.items():
|
|
159
|
+
if name[:3] in t:
|
|
160
|
+
m = num
|
|
161
|
+
break
|
|
162
|
+
dm = re.search(r"\b(\d{1,2})\b", t.replace(str(y or ""), ""))
|
|
163
|
+
if dm:
|
|
164
|
+
d = int(dm[1])
|
|
165
|
+
if not (y and m and d):
|
|
166
|
+
return (
|
|
167
|
+
f'could not parse the date "{date_text}" — use a form like '
|
|
168
|
+
'"2026-06-09" or "June 9 2026".'
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
iso_target = f"{y:04d}-{m:02d}-{d:02d}"
|
|
172
|
+
month_name = [k for k, v in months.items() if v == m][0].capitalize()
|
|
173
|
+
# Long-form fragment most aria-labels contain, e.g. "June 9, 2026".
|
|
174
|
+
long_frag = f"{month_name} {d}, {y}"
|
|
175
|
+
long_frag_alt = f"{month_name} {d} {y}"
|
|
176
|
+
|
|
177
|
+
# 1. Open the picker.
|
|
178
|
+
try:
|
|
179
|
+
await page.locator(_sel(loom_id)).first.click(timeout=4000)
|
|
180
|
+
except Exception: # noqa: BLE001
|
|
181
|
+
pass
|
|
182
|
+
await asyncio.sleep(0.5)
|
|
183
|
+
|
|
184
|
+
# JS to find + click a day cell matching the target across the open
|
|
185
|
+
# calendar; returns "clicked" | "not-found". Tries data-iso / aria.
|
|
186
|
+
_CLICK_DAY_JS = r"""
|
|
187
|
+
(args) => {
|
|
188
|
+
const {iso, longA, longB} = args;
|
|
189
|
+
const cands = document.querySelectorAll(
|
|
190
|
+
'[role="gridcell"], [data-iso], [aria-label], [jsname] div, button');
|
|
191
|
+
for (const el of cands) {
|
|
192
|
+
const al = (el.getAttribute('aria-label') || '').trim();
|
|
193
|
+
const di = (el.getAttribute('data-iso') ||
|
|
194
|
+
el.getAttribute('data-date') || '').trim();
|
|
195
|
+
if (di === iso ||
|
|
196
|
+
(al && (al.includes(longA) || al.includes(longB)))) {
|
|
197
|
+
const r = el.getBoundingClientRect();
|
|
198
|
+
if (r.width > 2 && r.height > 2) { el.click(); return 'clicked'; }
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return 'not-found';
|
|
202
|
+
}
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
# JS to close the open date dialog: click a "Done" (or confirm/search)
|
|
206
|
+
# button that lives inside the calendar dialog. Returns "clicked" |
|
|
207
|
+
# "none". We scope to a dialog/popup so we don't hit the page's main
|
|
208
|
+
# Search prematurely — but a "Done" anywhere visible is fine.
|
|
209
|
+
_CLOSE_CALENDAR_JS = r"""
|
|
210
|
+
() => {
|
|
211
|
+
const norm = (s) => (s || '').replace(/\s+/g,' ').trim().toLowerCase();
|
|
212
|
+
const wants = ['done', 'apply', 'ok', 'select', 'confirm'];
|
|
213
|
+
const els = document.querySelectorAll(
|
|
214
|
+
'button, [role="button"], [jsname]');
|
|
215
|
+
for (const el of els) {
|
|
216
|
+
const t = norm(el.innerText || el.textContent);
|
|
217
|
+
const al = norm(el.getAttribute('aria-label'));
|
|
218
|
+
if (wants.includes(t) || wants.includes(al)) {
|
|
219
|
+
const r = el.getBoundingClientRect();
|
|
220
|
+
if (r.width > 2 && r.height > 2) { el.click(); return 'clicked'; }
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return 'none';
|
|
224
|
+
}
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
# Try to find the day; if not visible, click a "next month" control and
|
|
228
|
+
# retry a few times (the target month may be ahead).
|
|
229
|
+
for _attempt in range(8):
|
|
230
|
+
try:
|
|
231
|
+
res = await page.evaluate(
|
|
232
|
+
_CLICK_DAY_JS,
|
|
233
|
+
{
|
|
234
|
+
"iso": iso_target,
|
|
235
|
+
"longA": long_frag,
|
|
236
|
+
"longB": long_frag_alt,
|
|
237
|
+
},
|
|
238
|
+
)
|
|
239
|
+
except Exception as exc: # noqa: BLE001
|
|
240
|
+
return f"date-pick failed: {exc}"
|
|
241
|
+
if res == "clicked":
|
|
242
|
+
await asyncio.sleep(0.4)
|
|
243
|
+
# AUTO-CLOSE the calendar so the Search button becomes
|
|
244
|
+
# reachable. The model reliably fails to click "Done" itself
|
|
245
|
+
# (it clicks calendar arrows instead and gets stuck), so the
|
|
246
|
+
# tool does it: click a Done/Search/confirm control if one is
|
|
247
|
+
# in the open dialog, else press Escape.
|
|
248
|
+
closed = "no"
|
|
249
|
+
try:
|
|
250
|
+
closed = await page.evaluate(_CLOSE_CALENDAR_JS)
|
|
251
|
+
except Exception: # noqa: BLE001
|
|
252
|
+
pass
|
|
253
|
+
if closed != "clicked":
|
|
254
|
+
try:
|
|
255
|
+
await page.keyboard.press("Escape")
|
|
256
|
+
except Exception: # noqa: BLE001
|
|
257
|
+
pass
|
|
258
|
+
await asyncio.sleep(0.4)
|
|
259
|
+
return (
|
|
260
|
+
f'selected {iso_target} ({long_frag}) and closed the date '
|
|
261
|
+
"picker. page_observe → click the Search button to load "
|
|
262
|
+
"results, then page_read."
|
|
263
|
+
)
|
|
264
|
+
# Advance to the next month and retry.
|
|
265
|
+
try:
|
|
266
|
+
await page.evaluate(r"""
|
|
267
|
+
() => {
|
|
268
|
+
const next = document.querySelector(
|
|
269
|
+
'[aria-label*="Next" i], [aria-label*="next month" i], '
|
|
270
|
+
+ 'button[jsname][aria-label*="forward" i]');
|
|
271
|
+
if (next) next.click();
|
|
272
|
+
}
|
|
273
|
+
""")
|
|
274
|
+
except Exception: # noqa: BLE001
|
|
275
|
+
pass
|
|
276
|
+
await asyncio.sleep(0.4)
|
|
277
|
+
|
|
278
|
+
return (
|
|
279
|
+
f'could not find {iso_target} in the calendar after paging forward. '
|
|
280
|
+
"The date may be too far out, or the picker uses an unusual layout — "
|
|
281
|
+
"page_check to see it, or proceed with flexible dates."
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
async def press_key(page: Any, key: str) -> str:
|
|
286
|
+
"""Press a global key (e.g. Escape to dismiss an overlay)."""
|
|
287
|
+
try:
|
|
288
|
+
await page.keyboard.press(key)
|
|
289
|
+
return f"pressed {key}"
|
|
290
|
+
except Exception as exc: # noqa: BLE001
|
|
291
|
+
return f"could not press {key}: {exc}"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
async def scroll(page: Any, direction: str = "down", amount: int = 1) -> str:
|
|
295
|
+
"""Scroll the page so lazy-loaded content (search results, listings,
|
|
296
|
+
infinite feeds) renders + becomes readable. direction: down | up |
|
|
297
|
+
top | bottom. amount = number of viewport-heights to scroll."""
|
|
298
|
+
import asyncio
|
|
299
|
+
|
|
300
|
+
direction = direction.strip().lower()
|
|
301
|
+
try:
|
|
302
|
+
if direction == "top":
|
|
303
|
+
await page.evaluate("window.scrollTo(0, 0)")
|
|
304
|
+
elif direction == "bottom":
|
|
305
|
+
await page.evaluate(
|
|
306
|
+
"window.scrollTo(0, document.body.scrollHeight)"
|
|
307
|
+
)
|
|
308
|
+
else:
|
|
309
|
+
sign = -1 if direction == "up" else 1
|
|
310
|
+
for _ in range(max(1, amount)):
|
|
311
|
+
await page.evaluate(
|
|
312
|
+
"(s) => window.scrollBy(0, s * window.innerHeight * 0.9)",
|
|
313
|
+
sign,
|
|
314
|
+
)
|
|
315
|
+
await asyncio.sleep(0.4) # let content load between scrolls
|
|
316
|
+
await asyncio.sleep(0.5)
|
|
317
|
+
return f"scrolled {direction}"
|
|
318
|
+
except Exception as exc: # noqa: BLE001
|
|
319
|
+
return f"could not scroll: {exc}"
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# JS: count visible autocomplete-suggestion options currently on screen.
|
|
323
|
+
# Covers the common patterns (role=option, listbox children, *li in a
|
|
324
|
+
# popup). Used to WAIT for the dropdown to render before selecting.
|
|
325
|
+
_COUNT_SUGGESTIONS_JS = r"""
|
|
326
|
+
() => {
|
|
327
|
+
const sels = [
|
|
328
|
+
'[role="option"]',
|
|
329
|
+
'[role="listbox"] li',
|
|
330
|
+
'[role="listbox"] [role="option"]',
|
|
331
|
+
'ul[role="listbox"] *[role]',
|
|
332
|
+
'.autocomplete-suggestion',
|
|
333
|
+
];
|
|
334
|
+
let n = 0;
|
|
335
|
+
const seen = new Set();
|
|
336
|
+
for (const s of sels) {
|
|
337
|
+
for (const el of document.querySelectorAll(s)) {
|
|
338
|
+
if (seen.has(el)) continue;
|
|
339
|
+
seen.add(el);
|
|
340
|
+
const r = el.getBoundingClientRect();
|
|
341
|
+
if (r.width > 2 && r.height > 2) n++;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return n;
|
|
345
|
+
}
|
|
346
|
+
"""
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# JS: type a value into the CURRENTLY FOCUSED element (the real input
|
|
350
|
+
# that appeared after we clicked the field) + dispatch input/keyboard
|
|
351
|
+
# events so the site's JS reacts. This is the key browser-use trick:
|
|
352
|
+
# operate document.activeElement, not the display box we clicked.
|
|
353
|
+
_FOCUSED_VALUE_JS = r"""
|
|
354
|
+
() => {
|
|
355
|
+
const a = document.activeElement;
|
|
356
|
+
if (!a) return "(no focused element)";
|
|
357
|
+
return (a.value != null ? a.value : (a.innerText || ""));
|
|
358
|
+
}
|
|
359
|
+
"""
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
async def fill_combobox(page: Any, loom_id: int | str, value: str) -> str:
|
|
363
|
+
"""Fill an autocomplete/combobox the way a human does — the pattern
|
|
364
|
+
that breaks naive 'type into the box' (flights, maps, address, etc.).
|
|
365
|
+
|
|
366
|
+
The crucial fix (what browser-use does, what we were missing): the
|
|
367
|
+
visible field is often a DISPLAY box that, when CLICKED, opens a
|
|
368
|
+
separate dialog with the REAL <input>. So:
|
|
369
|
+
1. click the field → opens the widget.
|
|
370
|
+
2. WAIT for it to settle (the real input gets focus).
|
|
371
|
+
3. type into the FOCUSED element (page.keyboard), not the display
|
|
372
|
+
box — slowly, so suggestions load.
|
|
373
|
+
4. WAIT for the suggestion dropdown to render.
|
|
374
|
+
5. ArrowDown + Enter to commit the first suggestion.
|
|
375
|
+
6. read back the committed value.
|
|
376
|
+
"""
|
|
377
|
+
import asyncio
|
|
378
|
+
|
|
379
|
+
sel = _sel(loom_id)
|
|
380
|
+
locator = page.locator(sel)
|
|
381
|
+
try:
|
|
382
|
+
if await locator.count() == 0:
|
|
383
|
+
return (
|
|
384
|
+
f"combobox [{loom_id}] not found — page_observe for current "
|
|
385
|
+
"ids."
|
|
386
|
+
)
|
|
387
|
+
except Exception as exc: # noqa: BLE001
|
|
388
|
+
return f"error locating [{loom_id}]: {exc}"
|
|
389
|
+
el = locator.first
|
|
390
|
+
|
|
391
|
+
try:
|
|
392
|
+
await el.scroll_into_view_if_needed(timeout=3000)
|
|
393
|
+
except Exception: # noqa: BLE001
|
|
394
|
+
pass
|
|
395
|
+
|
|
396
|
+
# 1. CLICK to open the widget (this is what reveals the real input).
|
|
397
|
+
try:
|
|
398
|
+
await el.click(timeout=4000)
|
|
399
|
+
except Exception: # noqa: BLE001
|
|
400
|
+
try:
|
|
401
|
+
await el.evaluate("e => e.click()")
|
|
402
|
+
except Exception: # noqa: BLE001
|
|
403
|
+
pass
|
|
404
|
+
|
|
405
|
+
# 2. Wait for the widget to settle + the real input to take focus.
|
|
406
|
+
await asyncio.sleep(0.6)
|
|
407
|
+
|
|
408
|
+
# 3. Clear whatever's focused, then type into the FOCUSED element via
|
|
409
|
+
# the keyboard (so we hit the real input, not the display box).
|
|
410
|
+
try:
|
|
411
|
+
# Select-all + delete clears the focused input cross-platform.
|
|
412
|
+
await page.keyboard.press("Control+A")
|
|
413
|
+
await page.keyboard.press("Meta+A") # mac
|
|
414
|
+
await page.keyboard.press("Backspace")
|
|
415
|
+
except Exception: # noqa: BLE001
|
|
416
|
+
pass
|
|
417
|
+
try:
|
|
418
|
+
await page.keyboard.type(value, delay=70)
|
|
419
|
+
except Exception as exc: # noqa: BLE001
|
|
420
|
+
return f"could not type into the focused input for [{loom_id}]: {exc}"
|
|
421
|
+
|
|
422
|
+
# 4. Wait for the suggestion dropdown to render (poll).
|
|
423
|
+
appeared = False
|
|
424
|
+
for _ in range(16): # ~4s total
|
|
425
|
+
try:
|
|
426
|
+
n = await page.evaluate(_COUNT_SUGGESTIONS_JS)
|
|
427
|
+
except Exception: # noqa: BLE001
|
|
428
|
+
n = 0
|
|
429
|
+
if n and n > 0:
|
|
430
|
+
appeared = True
|
|
431
|
+
break
|
|
432
|
+
await asyncio.sleep(0.25)
|
|
433
|
+
|
|
434
|
+
# 5. Commit the first suggestion via keyboard (robust vs portal DOM).
|
|
435
|
+
try:
|
|
436
|
+
await page.keyboard.press("ArrowDown")
|
|
437
|
+
await asyncio.sleep(0.2)
|
|
438
|
+
await page.keyboard.press("Enter")
|
|
439
|
+
except Exception: # noqa: BLE001
|
|
440
|
+
try:
|
|
441
|
+
await page.keyboard.press("Enter")
|
|
442
|
+
except Exception: # noqa: BLE001
|
|
443
|
+
pass
|
|
444
|
+
|
|
445
|
+
await asyncio.sleep(0.5)
|
|
446
|
+
|
|
447
|
+
# 6. Read what committed — try the original element, else the focused
|
|
448
|
+
# element (the dialog input may differ from the display box).
|
|
449
|
+
committed = ""
|
|
450
|
+
try:
|
|
451
|
+
committed = await el.input_value()
|
|
452
|
+
except Exception: # noqa: BLE001
|
|
453
|
+
pass
|
|
454
|
+
if not committed:
|
|
455
|
+
try:
|
|
456
|
+
committed = await page.evaluate(_FOCUSED_VALUE_JS)
|
|
457
|
+
except Exception: # noqa: BLE001
|
|
458
|
+
committed = "(unknown)"
|
|
459
|
+
|
|
460
|
+
note = "" if appeared else (
|
|
461
|
+
" (no suggestion list detected — if the value is wrong, the field "
|
|
462
|
+
"may need a different element; re-observe and check)"
|
|
463
|
+
)
|
|
464
|
+
return (
|
|
465
|
+
f'filled [{loom_id}] with "{value}" → now shows "{committed}"{note}. '
|
|
466
|
+
"page_check to confirm it stuck before the next field."
|
|
467
|
+
)
|