navvi 2.0.0

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,147 @@
1
+ """
2
+ Minimal Firefox Marionette TCP client.
3
+
4
+ Marionette is Firefox's built-in remote protocol (enabled with --marionette).
5
+ It speaks a simple length-prefixed JSON protocol on port 2828.
6
+
7
+ We only need: connect, newSession, navigate, getURL, getTitle, executeJS.
8
+ """
9
+
10
+ import json
11
+ import socket
12
+ import time
13
+
14
+
15
+ class MarionetteError(Exception):
16
+ pass
17
+
18
+
19
+ class Marionette:
20
+ def __init__(self, host="127.0.0.1", port=2828, timeout=10):
21
+ self.host = host
22
+ self.port = port
23
+ self.timeout = timeout
24
+ self.sock = None
25
+ self.session_id = None
26
+
27
+ def connect(self, retries=10, delay=1.0):
28
+ """Connect to Firefox Marionette, retrying until ready."""
29
+ for attempt in range(retries):
30
+ try:
31
+ self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
32
+ self.sock.settimeout(self.timeout)
33
+ self.sock.connect((self.host, self.port))
34
+ # Read server hello
35
+ self._recv()
36
+ return
37
+ except (ConnectionRefusedError, OSError):
38
+ if self.sock:
39
+ self.sock.close()
40
+ self.sock = None
41
+ if attempt < retries - 1:
42
+ time.sleep(delay)
43
+ raise MarionetteError(
44
+ f"Could not connect to Marionette at {self.host}:{self.port} "
45
+ f"after {retries} attempts"
46
+ )
47
+
48
+ def close(self):
49
+ if self.sock:
50
+ try:
51
+ self.sock.close()
52
+ except OSError:
53
+ pass
54
+ self.sock = None
55
+
56
+ def _send(self, msg):
57
+ """Send a length-prefixed JSON message."""
58
+ data = json.dumps(msg)
59
+ packet = f"{len(data)}:{data}"
60
+ self.sock.sendall(packet.encode("utf-8"))
61
+
62
+ def _recv(self):
63
+ """Read a length-prefixed JSON response."""
64
+ # Read length prefix until ':'
65
+ length_str = b""
66
+ while True:
67
+ ch = self.sock.recv(1)
68
+ if not ch:
69
+ raise MarionetteError("Connection closed")
70
+ if ch == b":":
71
+ break
72
+ length_str += ch
73
+
74
+ length = int(length_str)
75
+ data = b""
76
+ while len(data) < length:
77
+ chunk = self.sock.recv(length - len(data))
78
+ if not chunk:
79
+ raise MarionetteError("Connection closed during read")
80
+ data += chunk
81
+
82
+ return json.loads(data.decode("utf-8"))
83
+
84
+ def _command(self, name, params=None):
85
+ """Send a Marionette command and return the result."""
86
+ msg = [0, self._next_id(), name, params or {}]
87
+ self._send(msg)
88
+ resp = self._recv()
89
+ # Response format: [1, id, error, result]
90
+ if len(resp) >= 4:
91
+ if resp[2]: # error present
92
+ err = resp[2]
93
+ raise MarionetteError(
94
+ f"{err.get('error', 'unknown')}: {err.get('message', '')}"
95
+ )
96
+ return resp[3]
97
+ return resp
98
+
99
+ _id_counter = 0
100
+
101
+ def _next_id(self):
102
+ Marionette._id_counter += 1
103
+ return Marionette._id_counter
104
+
105
+ def new_session(self):
106
+ """Create a new Marionette session."""
107
+ result = self._command("WebDriver:NewSession", {
108
+ "capabilities": {
109
+ "alwaysMatch": {
110
+ "acceptInsecureCerts": True
111
+ }
112
+ }
113
+ })
114
+ self.session_id = result.get("sessionId") if isinstance(result, dict) else None
115
+ return result
116
+
117
+ def navigate(self, url):
118
+ """Navigate to a URL."""
119
+ return self._command("WebDriver:Navigate", {"url": url})
120
+
121
+ def get_url(self):
122
+ """Get current page URL."""
123
+ result = self._command("WebDriver:GetCurrentURL")
124
+ return result.get("value", "") if isinstance(result, dict) else str(result)
125
+
126
+ def get_title(self):
127
+ """Get current page title."""
128
+ result = self._command("WebDriver:GetTitle")
129
+ return result.get("value", "") if isinstance(result, dict) else str(result)
130
+
131
+ def execute_script(self, script, args=None):
132
+ """Execute JavaScript in the page context."""
133
+ result = self._command("WebDriver:ExecuteScript", {
134
+ "script": script,
135
+ "args": args or [],
136
+ })
137
+ return result.get("value") if isinstance(result, dict) else result
138
+
139
+ def execute_async_script(self, script, args=None, timeout_ms=30000):
140
+ """Execute async JavaScript (with callback)."""
141
+ # Set script timeout first
142
+ self._command("WebDriver:SetTimeouts", {"script": timeout_ms})
143
+ result = self._command("WebDriver:ExecuteAsyncScript", {
144
+ "script": script,
145
+ "args": args or [],
146
+ })
147
+ return result.get("value") if isinstance(result, dict) else result