navcli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- navcli/cli/__init__.py +5 -0
- navcli/cli/app.py +84 -0
- navcli/cli/client.py +271 -0
- navcli/cli/commands/__init__.py +0 -0
- navcli/cli/commands/base.py +46 -0
- navcli/cli/commands/control.py +119 -0
- navcli/cli/commands/explore.py +125 -0
- navcli/cli/commands/interaction.py +129 -0
- navcli/cli/commands/navigation.py +90 -0
- navcli/cli/commands/query.py +171 -0
- navcli/core/__init__.py +0 -0
- navcli/core/models/__init__.py +15 -0
- navcli/core/models/dom.py +33 -0
- navcli/core/models/element.py +22 -0
- navcli/core/models/feedback.py +24 -0
- navcli/core/models/state.py +19 -0
- navcli/server/__init__.py +86 -0
- navcli/server/app.py +48 -0
- navcli/server/browser.py +373 -0
- navcli/server/middleware/__init__.py +0 -0
- navcli/server/routes/__init__.py +11 -0
- navcli/server/routes/control.py +44 -0
- navcli/server/routes/explore.py +382 -0
- navcli/server/routes/interaction.py +317 -0
- navcli/server/routes/navigation.py +133 -0
- navcli/server/routes/query.py +303 -0
- navcli/server/routes/session.py +177 -0
- navcli/utils/__init__.py +20 -0
- navcli/utils/image.py +30 -0
- navcli/utils/js.py +13 -0
- navcli/utils/selector.py +88 -0
- navcli/utils/text.py +17 -0
- navcli/utils/time.py +46 -0
- navcli/utils/url.py +16 -0
- navcli-0.1.0.dist-info/METADATA +79 -0
- navcli-0.1.0.dist-info/RECORD +39 -0
- navcli-0.1.0.dist-info/WHEEL +5 -0
- navcli-0.1.0.dist-info/entry_points.txt +3 -0
- navcli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""Explore routes: find, findall, inspect, wait."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from fastapi import APIRouter, HTTPException, Query
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
import asyncio
|
|
7
|
+
|
|
8
|
+
from navcli.core.models import CommandResult, Feedback, Element
|
|
9
|
+
from navcli.server.browser import get_page, get_current_state, start_browser, wait_for_network_idle
|
|
10
|
+
from navcli.utils import wait_for_condition
|
|
11
|
+
|
|
12
|
+
router = APIRouter()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FindRequest(BaseModel):
|
|
16
|
+
text: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@router.get("/find")
|
|
20
|
+
async def find(text: str = Query(...)) -> CommandResult:
|
|
21
|
+
"""Find first element containing text."""
|
|
22
|
+
try:
|
|
23
|
+
if not text:
|
|
24
|
+
raise HTTPException(status_code=400, detail="text is required")
|
|
25
|
+
|
|
26
|
+
await start_browser()
|
|
27
|
+
page = get_page()
|
|
28
|
+
|
|
29
|
+
# Use page.get_by_text if available
|
|
30
|
+
try:
|
|
31
|
+
locator = page.get_by_text(text, exact=False).first
|
|
32
|
+
count = locator.count()
|
|
33
|
+
if count > 0:
|
|
34
|
+
# Get element info
|
|
35
|
+
tag = await locator.evaluate_handle("el => el.tagName")
|
|
36
|
+
element_text = await locator.inner_text()
|
|
37
|
+
|
|
38
|
+
element = Element(
|
|
39
|
+
selector=f"text={text}",
|
|
40
|
+
tag=tag.lower(),
|
|
41
|
+
text=element_text[:200],
|
|
42
|
+
clickable=True,
|
|
43
|
+
input=False,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
state = await get_current_state()
|
|
47
|
+
state.dom.elements = [element]
|
|
48
|
+
|
|
49
|
+
return CommandResult(
|
|
50
|
+
success=True,
|
|
51
|
+
command="find",
|
|
52
|
+
state=state,
|
|
53
|
+
feedback=Feedback(
|
|
54
|
+
action=f"found element with text: {text}",
|
|
55
|
+
result=f"tag: {tag}",
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
else:
|
|
59
|
+
return CommandResult(
|
|
60
|
+
success=False,
|
|
61
|
+
command="find",
|
|
62
|
+
error=f"no element found with text: {text}",
|
|
63
|
+
)
|
|
64
|
+
except Exception:
|
|
65
|
+
# Fallback: search all elements
|
|
66
|
+
state = await get_current_state()
|
|
67
|
+
for elem in state.dom.elements:
|
|
68
|
+
if text.lower() in elem.text.lower():
|
|
69
|
+
return CommandResult(
|
|
70
|
+
success=True,
|
|
71
|
+
command="find",
|
|
72
|
+
state=state,
|
|
73
|
+
feedback=Feedback(
|
|
74
|
+
action=f"found element with text: {text}",
|
|
75
|
+
result=f"selector: {elem.selector}",
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return CommandResult(
|
|
80
|
+
success=False,
|
|
81
|
+
command="find",
|
|
82
|
+
error=f"no element found with text: {text}",
|
|
83
|
+
)
|
|
84
|
+
except Exception as e:
|
|
85
|
+
return CommandResult(
|
|
86
|
+
success=False,
|
|
87
|
+
command="find",
|
|
88
|
+
error=str(e),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class FindAllRequest(BaseModel):
|
|
93
|
+
text: str
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@router.get("/findall")
|
|
97
|
+
async def findall(text: str = Query(...)) -> CommandResult:
|
|
98
|
+
"""Find all elements containing text."""
|
|
99
|
+
try:
|
|
100
|
+
if not text:
|
|
101
|
+
raise HTTPException(status_code=400, detail="text is required")
|
|
102
|
+
|
|
103
|
+
await start_browser()
|
|
104
|
+
state = await get_current_state()
|
|
105
|
+
|
|
106
|
+
matching = [e for e in state.dom.elements if text.lower() in e.text.lower()]
|
|
107
|
+
|
|
108
|
+
if matching:
|
|
109
|
+
state.dom.elements = matching
|
|
110
|
+
return CommandResult(
|
|
111
|
+
success=True,
|
|
112
|
+
command="findall",
|
|
113
|
+
state=state,
|
|
114
|
+
feedback=Feedback(
|
|
115
|
+
action=f"found {len(matching)} elements with text: {text}",
|
|
116
|
+
result=f"found {len(matching)} elements",
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
else:
|
|
120
|
+
return CommandResult(
|
|
121
|
+
success=False,
|
|
122
|
+
command="findall",
|
|
123
|
+
error=f"no elements found with text: {text}",
|
|
124
|
+
)
|
|
125
|
+
except Exception as e:
|
|
126
|
+
return CommandResult(
|
|
127
|
+
success=False,
|
|
128
|
+
command="findall",
|
|
129
|
+
error=str(e),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class InspectRequest(BaseModel):
|
|
134
|
+
selector: str
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@router.get("/inspect")
|
|
138
|
+
async def inspect(selector: str = Query(...)) -> CommandResult:
|
|
139
|
+
"""Inspect element details."""
|
|
140
|
+
try:
|
|
141
|
+
if not selector:
|
|
142
|
+
raise HTTPException(status_code=400, detail="selector is required")
|
|
143
|
+
|
|
144
|
+
await start_browser()
|
|
145
|
+
page = get_page()
|
|
146
|
+
|
|
147
|
+
locator = page.locator(selector)
|
|
148
|
+
count = locator.count()
|
|
149
|
+
|
|
150
|
+
if count == 0:
|
|
151
|
+
return CommandResult(
|
|
152
|
+
success=False,
|
|
153
|
+
command="inspect",
|
|
154
|
+
error=f"element not found: {selector}",
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Get element details
|
|
158
|
+
first = locator.first
|
|
159
|
+
tag = await first.evaluate_handle("el => el.tagName")
|
|
160
|
+
text = await first.inner_text()
|
|
161
|
+
html = await first.inner_html()
|
|
162
|
+
|
|
163
|
+
# Get attributes
|
|
164
|
+
attributes = {}
|
|
165
|
+
try:
|
|
166
|
+
all_attrs = await first.evaluate_handle("""
|
|
167
|
+
el => {
|
|
168
|
+
const attrs = {};
|
|
169
|
+
for (const attr of el.attributes) {
|
|
170
|
+
attrs[attr.name] = attr.value;
|
|
171
|
+
}
|
|
172
|
+
return attrs;
|
|
173
|
+
}
|
|
174
|
+
""")
|
|
175
|
+
attributes = dict(all_attrs)
|
|
176
|
+
except Exception:
|
|
177
|
+
pass
|
|
178
|
+
|
|
179
|
+
element = Element(
|
|
180
|
+
selector=selector,
|
|
181
|
+
tag=tag.lower(),
|
|
182
|
+
text=text[:500],
|
|
183
|
+
clickable=await first.is_enabled() and await first.is_visible(),
|
|
184
|
+
input=tag.lower() in ("input", "textarea"),
|
|
185
|
+
type=attributes.get("type"),
|
|
186
|
+
href=attributes.get("href"),
|
|
187
|
+
alt=attributes.get("alt"),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
state = await get_current_state()
|
|
191
|
+
state.dom.elements = [element]
|
|
192
|
+
|
|
193
|
+
return CommandResult(
|
|
194
|
+
success=True,
|
|
195
|
+
command="inspect",
|
|
196
|
+
state=state,
|
|
197
|
+
feedback=Feedback(
|
|
198
|
+
action=f"inspected {selector}",
|
|
199
|
+
result=f"tag: {tag}, visible: {await first.is_visible()}",
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
except Exception as e:
|
|
203
|
+
return CommandResult(
|
|
204
|
+
success=False,
|
|
205
|
+
command="inspect",
|
|
206
|
+
error=str(e),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class WaitRequest(BaseModel):
|
|
211
|
+
selector: Optional[str] = None
|
|
212
|
+
seconds: Optional[float] = None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@router.post("/wait")
|
|
216
|
+
async def wait(req: WaitRequest) -> CommandResult:
|
|
217
|
+
"""Wait for selector or timeout."""
|
|
218
|
+
try:
|
|
219
|
+
await start_browser()
|
|
220
|
+
page = get_page()
|
|
221
|
+
|
|
222
|
+
if req.selector:
|
|
223
|
+
# Wait for selector
|
|
224
|
+
try:
|
|
225
|
+
await page.wait_for_selector(req.selector, timeout=10000)
|
|
226
|
+
state = await get_current_state()
|
|
227
|
+
return CommandResult(
|
|
228
|
+
success=True,
|
|
229
|
+
command="wait",
|
|
230
|
+
state=state,
|
|
231
|
+
feedback=Feedback(
|
|
232
|
+
action=f"waited for {req.selector}",
|
|
233
|
+
result="element appeared",
|
|
234
|
+
),
|
|
235
|
+
)
|
|
236
|
+
except asyncio.TimeoutError:
|
|
237
|
+
return CommandResult(
|
|
238
|
+
success=False,
|
|
239
|
+
command="wait",
|
|
240
|
+
error=f"timeout waiting for {req.selector}",
|
|
241
|
+
)
|
|
242
|
+
elif req.seconds:
|
|
243
|
+
# Wait for seconds
|
|
244
|
+
await asyncio.sleep(req.seconds)
|
|
245
|
+
state = await get_current_state()
|
|
246
|
+
return CommandResult(
|
|
247
|
+
success=True,
|
|
248
|
+
command="wait",
|
|
249
|
+
state=state,
|
|
250
|
+
feedback=Feedback(
|
|
251
|
+
action=f"waited {req.seconds}s",
|
|
252
|
+
result="waited",
|
|
253
|
+
),
|
|
254
|
+
)
|
|
255
|
+
else:
|
|
256
|
+
# Default: wait for network idle
|
|
257
|
+
await wait_for_network_idle(3.0)
|
|
258
|
+
state = await get_current_state()
|
|
259
|
+
return CommandResult(
|
|
260
|
+
success=True,
|
|
261
|
+
command="wait",
|
|
262
|
+
state=state,
|
|
263
|
+
feedback=Feedback(
|
|
264
|
+
action="waited for network idle",
|
|
265
|
+
result="ready",
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
except Exception as e:
|
|
269
|
+
return CommandResult(
|
|
270
|
+
success=False,
|
|
271
|
+
command="wait",
|
|
272
|
+
error=str(e),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@router.post("/wait/idle")
|
|
277
|
+
async def wait_idle(timeout: float = 3.0) -> CommandResult:
|
|
278
|
+
"""Wait for network idle."""
|
|
279
|
+
try:
|
|
280
|
+
await start_browser()
|
|
281
|
+
idle = await wait_for_network_idle(timeout)
|
|
282
|
+
|
|
283
|
+
state = await get_current_state()
|
|
284
|
+
|
|
285
|
+
return CommandResult(
|
|
286
|
+
success=True,
|
|
287
|
+
command="wait_idle",
|
|
288
|
+
state=state,
|
|
289
|
+
feedback=Feedback(
|
|
290
|
+
action="waited for network idle",
|
|
291
|
+
result="idle" if idle else "timeout",
|
|
292
|
+
),
|
|
293
|
+
)
|
|
294
|
+
except Exception as e:
|
|
295
|
+
return CommandResult(
|
|
296
|
+
success=False,
|
|
297
|
+
command="wait_idle",
|
|
298
|
+
error=str(e),
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class ScrollRequest(BaseModel):
|
|
303
|
+
direction: Optional[str] = None # top, bottom, up, down
|
|
304
|
+
x: Optional[int] = None
|
|
305
|
+
y: Optional[int] = None
|
|
306
|
+
selector: Optional[str] = None
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@router.post("/scroll")
|
|
310
|
+
async def scroll(req: ScrollRequest) -> CommandResult:
|
|
311
|
+
"""Scroll the page or element.
|
|
312
|
+
|
|
313
|
+
Usage:
|
|
314
|
+
- scroll?direction=top|bottom|up|down
|
|
315
|
+
- scroll?x=<num>&y=<num>
|
|
316
|
+
- scroll?selector=<sel>&into_view=true
|
|
317
|
+
"""
|
|
318
|
+
try:
|
|
319
|
+
await start_browser()
|
|
320
|
+
page = get_page()
|
|
321
|
+
|
|
322
|
+
if req.selector:
|
|
323
|
+
# Scroll element into view
|
|
324
|
+
locator = page.locator(req.selector)
|
|
325
|
+
count = locator.count()
|
|
326
|
+
if count == 0:
|
|
327
|
+
return CommandResult(
|
|
328
|
+
success=False,
|
|
329
|
+
command="scroll",
|
|
330
|
+
error=f"element not found: {req.selector}",
|
|
331
|
+
)
|
|
332
|
+
await locator.first.scroll_into_view_if_needed()
|
|
333
|
+
action = f"scrolled to {req.selector}"
|
|
334
|
+
elif req.direction:
|
|
335
|
+
# Directional scroll
|
|
336
|
+
if req.direction == "top":
|
|
337
|
+
await page.evaluate("window.scrollTo(0, 0)")
|
|
338
|
+
action = "scrolled to top"
|
|
339
|
+
elif req.direction == "bottom":
|
|
340
|
+
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
|
|
341
|
+
action = "scrolled to bottom"
|
|
342
|
+
elif req.direction == "up":
|
|
343
|
+
await page.evaluate("window.scrollBy(0, -window.innerHeight)")
|
|
344
|
+
action = "scrolled up"
|
|
345
|
+
elif req.direction == "down":
|
|
346
|
+
await page.evaluate("window.scrollBy(0, window.innerHeight)")
|
|
347
|
+
action = "scrolled down"
|
|
348
|
+
else:
|
|
349
|
+
return CommandResult(
|
|
350
|
+
success=False,
|
|
351
|
+
command="scroll",
|
|
352
|
+
error=f"invalid direction: {req.direction}. Use: top, bottom, up, down",
|
|
353
|
+
)
|
|
354
|
+
elif req.x is not None and req.y is not None:
|
|
355
|
+
# Coordinate scroll
|
|
356
|
+
await page.evaluate(f"window.scrollTo({req.x}, {req.y})")
|
|
357
|
+
action = f"scrolled to ({req.x}, {req.y})"
|
|
358
|
+
else:
|
|
359
|
+
return CommandResult(
|
|
360
|
+
success=False,
|
|
361
|
+
command="scroll",
|
|
362
|
+
error="provide direction, x/y coordinates, or selector",
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
await asyncio.sleep(0.3) # Brief wait for scroll to complete
|
|
366
|
+
state = await get_current_state()
|
|
367
|
+
|
|
368
|
+
return CommandResult(
|
|
369
|
+
success=True,
|
|
370
|
+
command="scroll",
|
|
371
|
+
state=state,
|
|
372
|
+
feedback=Feedback(
|
|
373
|
+
action=action,
|
|
374
|
+
result="scrolled",
|
|
375
|
+
),
|
|
376
|
+
)
|
|
377
|
+
except Exception as e:
|
|
378
|
+
return CommandResult(
|
|
379
|
+
success=False,
|
|
380
|
+
command="scroll",
|
|
381
|
+
error=str(e),
|
|
382
|
+
)
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Interaction routes: click, type, clear, upload."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from fastapi import APIRouter, HTTPException, Query
|
|
5
|
+
|
|
6
|
+
from navcli.core.models import CommandResult, Feedback
|
|
7
|
+
from navcli.server.browser import get_page, get_current_state, start_browser, wait_for_network_idle
|
|
8
|
+
|
|
9
|
+
router = APIRouter()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@router.post("/click")
|
|
13
|
+
async def click(
|
|
14
|
+
selector: str = Query(..., description="CSS selector"),
|
|
15
|
+
force: bool = Query(False, description="Force click without waiting"),
|
|
16
|
+
) -> CommandResult:
|
|
17
|
+
"""Click an element."""
|
|
18
|
+
try:
|
|
19
|
+
if not selector:
|
|
20
|
+
raise HTTPException(status_code=400, detail="selector is required")
|
|
21
|
+
|
|
22
|
+
await start_browser()
|
|
23
|
+
page = get_page()
|
|
24
|
+
|
|
25
|
+
locator = page.locator(selector)
|
|
26
|
+
|
|
27
|
+
# Check if element exists
|
|
28
|
+
count = locator.count()
|
|
29
|
+
if count == 0:
|
|
30
|
+
return CommandResult(
|
|
31
|
+
success=False,
|
|
32
|
+
command="click",
|
|
33
|
+
error=f"element not found: {selector}",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if count > 1:
|
|
37
|
+
return CommandResult(
|
|
38
|
+
success=False,
|
|
39
|
+
command="click",
|
|
40
|
+
error=f"multiple elements found: {selector}",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Click
|
|
44
|
+
if force:
|
|
45
|
+
await locator.click(force=True)
|
|
46
|
+
else:
|
|
47
|
+
await locator.click()
|
|
48
|
+
|
|
49
|
+
# Wait for network idle
|
|
50
|
+
await wait_for_network_idle(3.0)
|
|
51
|
+
|
|
52
|
+
state = await get_current_state()
|
|
53
|
+
|
|
54
|
+
return CommandResult(
|
|
55
|
+
success=True,
|
|
56
|
+
command="click",
|
|
57
|
+
state=state,
|
|
58
|
+
feedback=Feedback(
|
|
59
|
+
action=f"clicked {selector}",
|
|
60
|
+
result="clicked",
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
return CommandResult(
|
|
65
|
+
success=False,
|
|
66
|
+
command="click",
|
|
67
|
+
error=str(e),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@router.post("/type")
|
|
72
|
+
async def type_text(
|
|
73
|
+
selector: str = Query(..., description="CSS selector"),
|
|
74
|
+
text: str = Query(..., description="Text to type"),
|
|
75
|
+
) -> CommandResult:
|
|
76
|
+
"""Type text into an input."""
|
|
77
|
+
try:
|
|
78
|
+
if not selector:
|
|
79
|
+
raise HTTPException(status_code=400, detail="selector is required")
|
|
80
|
+
if not text:
|
|
81
|
+
raise HTTPException(status_code=400, detail="text is required")
|
|
82
|
+
|
|
83
|
+
await start_browser()
|
|
84
|
+
page = get_page()
|
|
85
|
+
|
|
86
|
+
locator = page.locator(selector)
|
|
87
|
+
|
|
88
|
+
# Check if element exists
|
|
89
|
+
count = locator.count()
|
|
90
|
+
if count == 0:
|
|
91
|
+
return CommandResult(
|
|
92
|
+
success=False,
|
|
93
|
+
command="type",
|
|
94
|
+
error=f"element not found: {selector}",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Clear and type
|
|
98
|
+
await locator.clear()
|
|
99
|
+
await locator.fill(text)
|
|
100
|
+
|
|
101
|
+
state = await get_current_state()
|
|
102
|
+
|
|
103
|
+
return CommandResult(
|
|
104
|
+
success=True,
|
|
105
|
+
command="type",
|
|
106
|
+
state=state,
|
|
107
|
+
feedback=Feedback(
|
|
108
|
+
action=f"typed into {selector}",
|
|
109
|
+
result=f"typed: {text[:20]}...",
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
except Exception as e:
|
|
113
|
+
return CommandResult(
|
|
114
|
+
success=False,
|
|
115
|
+
command="type",
|
|
116
|
+
error=str(e),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@router.post("/clear")
|
|
121
|
+
async def clear_input(
|
|
122
|
+
selector: str = Query(..., description="CSS selector"),
|
|
123
|
+
) -> CommandResult:
|
|
124
|
+
"""Clear an input field."""
|
|
125
|
+
try:
|
|
126
|
+
if not selector:
|
|
127
|
+
raise HTTPException(status_code=400, detail="selector is required")
|
|
128
|
+
|
|
129
|
+
await start_browser()
|
|
130
|
+
page = get_page()
|
|
131
|
+
|
|
132
|
+
locator = page.locator(selector)
|
|
133
|
+
|
|
134
|
+
# Check if element exists
|
|
135
|
+
count = locator.count()
|
|
136
|
+
if count == 0:
|
|
137
|
+
return CommandResult(
|
|
138
|
+
success=False,
|
|
139
|
+
command="clear",
|
|
140
|
+
error=f"element not found: {selector}",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Clear
|
|
144
|
+
await locator.clear()
|
|
145
|
+
|
|
146
|
+
state = await get_current_state()
|
|
147
|
+
|
|
148
|
+
return CommandResult(
|
|
149
|
+
success=True,
|
|
150
|
+
command="clear",
|
|
151
|
+
state=state,
|
|
152
|
+
feedback=Feedback(
|
|
153
|
+
action=f"cleared {selector}",
|
|
154
|
+
result="cleared",
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
except Exception as e:
|
|
158
|
+
return CommandResult(
|
|
159
|
+
success=False,
|
|
160
|
+
command="clear",
|
|
161
|
+
error=str(e),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@router.post("/upload")
|
|
166
|
+
async def upload_file(
|
|
167
|
+
selector: str = Query(..., description="CSS selector"),
|
|
168
|
+
file_path: str = Query(..., description="Path to file"),
|
|
169
|
+
) -> CommandResult:
|
|
170
|
+
"""Upload a file to an input."""
|
|
171
|
+
try:
|
|
172
|
+
if not selector:
|
|
173
|
+
raise HTTPException(status_code=400, detail="selector is required")
|
|
174
|
+
if not file_path:
|
|
175
|
+
raise HTTPException(status_code=400, detail="file_path is required")
|
|
176
|
+
|
|
177
|
+
# Check if file exists
|
|
178
|
+
import os
|
|
179
|
+
if not os.path.exists(file_path):
|
|
180
|
+
return CommandResult(
|
|
181
|
+
success=False,
|
|
182
|
+
command="upload",
|
|
183
|
+
error=f"file not found: {file_path}",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
await start_browser()
|
|
187
|
+
page = get_page()
|
|
188
|
+
|
|
189
|
+
locator = page.locator(selector)
|
|
190
|
+
|
|
191
|
+
# Check if element exists
|
|
192
|
+
count = locator.count()
|
|
193
|
+
if count == 0:
|
|
194
|
+
return CommandResult(
|
|
195
|
+
success=False,
|
|
196
|
+
command="upload",
|
|
197
|
+
error=f"element not found: {selector}",
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Upload file
|
|
201
|
+
await locator.set_input_files(file_path)
|
|
202
|
+
|
|
203
|
+
state = await get_current_state()
|
|
204
|
+
|
|
205
|
+
return CommandResult(
|
|
206
|
+
success=True,
|
|
207
|
+
command="upload",
|
|
208
|
+
state=state,
|
|
209
|
+
feedback=Feedback(
|
|
210
|
+
action=f"uploaded {file_path} to {selector}",
|
|
211
|
+
result="uploaded",
|
|
212
|
+
),
|
|
213
|
+
)
|
|
214
|
+
except Exception as e:
|
|
215
|
+
return CommandResult(
|
|
216
|
+
success=False,
|
|
217
|
+
command="upload",
|
|
218
|
+
error=str(e),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@router.post("/dblclick")
|
|
223
|
+
async def dblclick(
|
|
224
|
+
selector: str = Query(..., description="CSS selector"),
|
|
225
|
+
) -> CommandResult:
|
|
226
|
+
"""Double-click an element."""
|
|
227
|
+
try:
|
|
228
|
+
if not selector:
|
|
229
|
+
raise HTTPException(status_code=400, detail="selector is required")
|
|
230
|
+
|
|
231
|
+
await start_browser()
|
|
232
|
+
page = get_page()
|
|
233
|
+
|
|
234
|
+
locator = page.locator(selector)
|
|
235
|
+
count = locator.count()
|
|
236
|
+
if count == 0:
|
|
237
|
+
return CommandResult(
|
|
238
|
+
success=False,
|
|
239
|
+
command="dblclick",
|
|
240
|
+
error=f"element not found: {selector}",
|
|
241
|
+
)
|
|
242
|
+
if count > 1:
|
|
243
|
+
return CommandResult(
|
|
244
|
+
success=False,
|
|
245
|
+
command="dblclick",
|
|
246
|
+
error=f"multiple elements found: {selector}",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
await locator.dblclick()
|
|
250
|
+
|
|
251
|
+
await wait_for_network_idle(3.0)
|
|
252
|
+
state = await get_current_state()
|
|
253
|
+
|
|
254
|
+
return CommandResult(
|
|
255
|
+
success=True,
|
|
256
|
+
command="dblclick",
|
|
257
|
+
state=state,
|
|
258
|
+
feedback=Feedback(
|
|
259
|
+
action=f"double-clicked {selector}",
|
|
260
|
+
result="clicked",
|
|
261
|
+
),
|
|
262
|
+
)
|
|
263
|
+
except Exception as e:
|
|
264
|
+
return CommandResult(
|
|
265
|
+
success=False,
|
|
266
|
+
command="dblclick",
|
|
267
|
+
error=str(e),
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@router.post("/rightclick")
|
|
272
|
+
async def rightclick(
|
|
273
|
+
selector: str = Query(..., description="CSS selector"),
|
|
274
|
+
) -> CommandResult:
|
|
275
|
+
"""Right-click an element."""
|
|
276
|
+
try:
|
|
277
|
+
if not selector:
|
|
278
|
+
raise HTTPException(status_code=400, detail="selector is required")
|
|
279
|
+
|
|
280
|
+
await start_browser()
|
|
281
|
+
page = get_page()
|
|
282
|
+
|
|
283
|
+
locator = page.locator(selector)
|
|
284
|
+
count = locator.count()
|
|
285
|
+
if count == 0:
|
|
286
|
+
return CommandResult(
|
|
287
|
+
success=False,
|
|
288
|
+
command="rightclick",
|
|
289
|
+
error=f"element not found: {selector}",
|
|
290
|
+
)
|
|
291
|
+
if count > 1:
|
|
292
|
+
return CommandResult(
|
|
293
|
+
success=False,
|
|
294
|
+
command="rightclick",
|
|
295
|
+
error=f"multiple elements found: {selector}",
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
await locator.click(button="right")
|
|
299
|
+
|
|
300
|
+
await wait_for_network_idle(3.0)
|
|
301
|
+
state = await get_current_state()
|
|
302
|
+
|
|
303
|
+
return CommandResult(
|
|
304
|
+
success=True,
|
|
305
|
+
command="rightclick",
|
|
306
|
+
state=state,
|
|
307
|
+
feedback=Feedback(
|
|
308
|
+
action=f"right-clicked {selector}",
|
|
309
|
+
result="clicked",
|
|
310
|
+
),
|
|
311
|
+
)
|
|
312
|
+
except Exception as e:
|
|
313
|
+
return CommandResult(
|
|
314
|
+
success=False,
|
|
315
|
+
command="rightclick",
|
|
316
|
+
error=str(e),
|
|
317
|
+
)
|