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.
@@ -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
+ )