fable-engine 1.3.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.
Files changed (104) hide show
  1. fable_compressor.py +356 -0
  2. fable_engine/__init__.py +1 -0
  3. fable_engine/actions/__init__.py +291 -0
  4. fable_engine/actions/cas.py +182 -0
  5. fable_engine/actions/deliberation.py +523 -0
  6. fable_engine/actions/fleet.py +807 -0
  7. fable_engine/actions/lifecycle.py +298 -0
  8. fable_engine/actions/scrapers.py +116 -0
  9. fable_engine/actions/system3.py +815 -0
  10. fable_engine/browser.py +824 -0
  11. fable_engine/cas.py +974 -0
  12. fable_engine/fable_session.json +510 -0
  13. fable_engine/guards.py +283 -0
  14. fable_engine/schema.py +714 -0
  15. fable_engine/scrapers/__init__.py +32 -0
  16. fable_engine/scrapers/arxiv.py +115 -0
  17. fable_engine/scrapers/base.py +386 -0
  18. fable_engine/scrapers/github.py +129 -0
  19. fable_engine/scrapers/reddit.py +154 -0
  20. fable_engine/scrapers/web.py +120 -0
  21. fable_engine/scrapers/x.py +125 -0
  22. fable_engine/scrapers/youtube.py +132 -0
  23. fable_engine/server.py +414 -0
  24. fable_engine/session.py +1819 -0
  25. fable_engine/test_server.py +1362 -0
  26. fable_engine/updater.py +541 -0
  27. fable_engine-1.3.1.dist-info/LICENSE +22 -0
  28. fable_engine-1.3.1.dist-info/METADATA +173 -0
  29. fable_engine-1.3.1.dist-info/RECORD +104 -0
  30. fable_engine-1.3.1.dist-info/WHEEL +5 -0
  31. fable_engine-1.3.1.dist-info/entry_points.txt +5 -0
  32. fable_engine-1.3.1.dist-info/top_level.txt +6 -0
  33. fable_mode/__init__.py +3 -0
  34. fable_mode/__main__.py +4 -0
  35. fable_mode/adapters.py +1014 -0
  36. fable_mode/installer.py +553 -0
  37. fable_mode/launcher.py +437 -0
  38. fable_mode/manifest.py +142 -0
  39. fable_mode/resources.json +114 -0
  40. fable_mode/safety.py +103 -0
  41. fable_mode_entry.py +10 -0
  42. fable_v2/__init__.py +146 -0
  43. fable_v2/adapters.py +151 -0
  44. fable_v2/coder_fleet/__init__.py +100 -0
  45. fable_v2/coder_fleet/ast_tools.py +158 -0
  46. fable_v2/coder_fleet/compute.py +199 -0
  47. fable_v2/coder_fleet/design_engine.py +1316 -0
  48. fable_v2/coder_fleet/diagnostics.py +293 -0
  49. fable_v2/coder_fleet/fleet_dispatcher.py +214 -0
  50. fable_v2/coder_fleet/mock_auditor.py +306 -0
  51. fable_v2/coder_fleet/mutation.py +216 -0
  52. fable_v2/coder_fleet/property_oracle.py +260 -0
  53. fable_v2/coder_fleet/receipt_attestor.py +122 -0
  54. fable_v2/coder_fleet/red_team_swarm.py +908 -0
  55. fable_v2/coder_fleet/test_harness.py +198 -0
  56. fable_v2/coder_fleet/vector_engine.py +1287 -0
  57. fable_v2/coder_fleet/visual.py +357 -0
  58. fable_v2/coder_fleet/workspace.py +153 -0
  59. fable_v2/cortical/__init__.py +20 -0
  60. fable_v2/cortical/plasticity_engine.py +992 -0
  61. fable_v2/execution_broker.py +811 -0
  62. fable_v2/proof_engine.py +1141 -0
  63. fable_v2/protocol.py +485 -0
  64. fable_v2/runtime.py +1010 -0
  65. fable_v2/system3/__init__.py +204 -0
  66. fable_v2/system3/causal.py +558 -0
  67. fable_v2/system3/dialectical.py +577 -0
  68. fable_v2/system3/evolution.py +503 -0
  69. fable_v2/system3/executive.py +338 -0
  70. fable_v2/system3/free_energy.py +479 -0
  71. fable_v2/system3/hyperbolic.py +555 -0
  72. fable_v2/system3/induction.py +336 -0
  73. fable_v2/system3/kripke.py +548 -0
  74. fable_v2/system3/oracle.py +745 -0
  75. fable_v2/verifiers.py +72 -0
  76. tests/__init__.py +1 -0
  77. tests/test_anti_loop_circuit_breaker.py +64 -0
  78. tests/test_auto_updater.py +407 -0
  79. tests/test_coder_fleet.py +535 -0
  80. tests/test_delegation_compiler.py +54 -0
  81. tests/test_descriptor_boundaries.py +126 -0
  82. tests/test_design_engine.py +603 -0
  83. tests/test_epistemic_evidence_validator.py +66 -0
  84. tests/test_execution_broker.py +233 -0
  85. tests/test_fable_v2.py +406 -0
  86. tests/test_fleet_transitions.py +116 -0
  87. tests/test_fsm_redteam_evolution.py +406 -0
  88. tests/test_goal_rubric_and_pipeline.py +367 -0
  89. tests/test_hebbian_plasticity.py +585 -0
  90. tests/test_packaging_runtime.py +194 -0
  91. tests/test_proof_engine.py +259 -0
  92. tests/test_red_team_swarm.py +645 -0
  93. tests/test_redteam_remediation.py +169 -0
  94. tests/test_registration_transaction.py +375 -0
  95. tests/test_requested_regressions.py +467 -0
  96. tests/test_scrapers.py +370 -0
  97. tests/test_server_actions.py +93 -0
  98. tests/test_server_frontier_actions.py +269 -0
  99. tests/test_server_protocol.py +88 -0
  100. tests/test_stealth_browser.py +970 -0
  101. tests/test_system3.py +381 -0
  102. tests/test_system3_deep_integration.py +385 -0
  103. tests/test_system3_frontier.py +436 -0
  104. tests/test_vector_engine.py +608 -0
@@ -0,0 +1,970 @@
1
+ """
2
+ Unit tests for Stealth Agent Browser Engine and top-level MCP browser tools.
3
+ Verifies memory footprint target, local persistent profiles (~/.fable/browser-profile/),
4
+ navigation history, element clicking, typing, scrolling, and layered PNG screenshot generation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import http.cookiejar
10
+ import http.server
11
+ import io
12
+ import json
13
+ import os
14
+ import shutil
15
+ import stat
16
+ import struct
17
+ import subprocess
18
+ import sys
19
+ import tempfile
20
+ import threading
21
+ import unittest
22
+ import urllib.request
23
+ import urllib.response
24
+ import zlib
25
+ from email.message import Message
26
+ from unittest.mock import Mock, patch
27
+
28
+ from fable_engine.browser import (
29
+ DEFAULT_MAX_RESPONSE_BYTES,
30
+ DEFAULT_VIEWPORT_HEIGHT,
31
+ DEFAULT_VIEWPORT_WIDTH,
32
+ DOMElement,
33
+ MAX_BROWSER_OPEN_TIMEOUT_SECONDS,
34
+ MAX_BROWSER_SESSIONS,
35
+ MAX_DOM_NODES,
36
+ MAX_HISTORY_ENTRIES,
37
+ MAX_RETAINED_PAGE_HTML_BYTES,
38
+ MAX_SCREENSHOT_LAYERS,
39
+ ProfileManager,
40
+ StealthBrowserEngine,
41
+ StealthBrowserSession,
42
+ generate_minimal_png,
43
+ )
44
+ from fable_engine.schema import BROWSER_TOOL_SCHEMAS
45
+ import fable_engine.server as browser_server
46
+
47
+
48
+ class _BrowserTestHandler(http.server.BaseHTTPRequestHandler):
49
+ redirect_history_one = False
50
+ downgrade_requests = 0
51
+ downgrade_cookie = None
52
+
53
+ def do_GET(self):
54
+ if self.path == "/set-cookie":
55
+ self.send_response(200)
56
+ self.send_header("Set-Cookie", "session_token=abc123secret; Path=/; HttpOnly")
57
+ body = b"cookie set"
58
+ elif self.path == "/large":
59
+ self.send_response(200)
60
+ body = b"01234567890"
61
+ elif self.path == "/oversized-dom":
62
+ self.send_response(200)
63
+ body = ("<input>" * (MAX_DOM_NODES + 1)).encode("utf-8")
64
+ elif self.path == "/history-one" and self.redirect_history_one:
65
+ self.send_response(302)
66
+ self.send_header("Location", "/redirected")
67
+ self.end_headers()
68
+ return
69
+ elif self.path == "/downgrade-target":
70
+ type(self).downgrade_requests += 1
71
+ type(self).downgrade_cookie = self.headers.get("Cookie")
72
+ self.send_response(200)
73
+ body = b"downgrade followed"
74
+ else:
75
+ self.send_response(200)
76
+ body = (self.headers.get("Cookie") or "").encode("utf-8")
77
+ self.send_header("Content-Type", "text/html; charset=utf-8")
78
+ self.send_header("Content-Length", str(len(body)))
79
+ self.end_headers()
80
+ self.wfile.write(body)
81
+
82
+ def log_message(self, format, *args):
83
+ pass
84
+
85
+
86
+ class TestStealthAgentBrowser(unittest.TestCase):
87
+
88
+ def setUp(self):
89
+ self.tmp_dir = tempfile.mkdtemp()
90
+ self.profile_dir = os.path.join(self.tmp_dir, "browser-profile")
91
+ _BrowserTestHandler.redirect_history_one = False
92
+ _BrowserTestHandler.downgrade_requests = 0
93
+ _BrowserTestHandler.downgrade_cookie = None
94
+ self.httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _BrowserTestHandler)
95
+ self.http_thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
96
+ self.http_thread.start()
97
+ self.port = self.httpd.server_address[1]
98
+
99
+ def tearDown(self):
100
+ self.httpd.shutdown()
101
+ self.httpd.server_close()
102
+ self.http_thread.join(timeout=2)
103
+ shutil.rmtree(self.tmp_dir, ignore_errors=True)
104
+
105
+ def test_browser_schemas_exist(self):
106
+ tool_names = [t["name"] for t in BROWSER_TOOL_SCHEMAS]
107
+ self.assertIn("browser_open", tool_names)
108
+ self.assertIn("browser_navigate", tool_names)
109
+ self.assertIn("browser_click", tool_names)
110
+ self.assertIn("browser_type", tool_names)
111
+ self.assertIn("browser_scroll", tool_names)
112
+ self.assertIn("browser_snapshot_layers", tool_names)
113
+ self.assertIn("browser_screenshot", tool_names)
114
+ self.assertIn("browser_close", tool_names)
115
+ self.assertIn("browser_back", tool_names)
116
+ self.assertIn("browser_forward", tool_names)
117
+ self.assertIn("browser_wait", tool_names)
118
+ self.assertIn("browser_press", tool_names)
119
+ self.assertIn("browser_reload", tool_names)
120
+
121
+ press_schema = next(
122
+ schema for schema in BROWSER_TOOL_SCHEMAS
123
+ if schema["name"] == "browser_press"
124
+ )
125
+ self.assertNotIn("active window", press_schema["description"])
126
+
127
+ for tool in BROWSER_TOOL_SCHEMAS:
128
+ self.assertTrue(len(tool["description"]) > 0)
129
+ self.assertIn("type", tool["inputSchema"])
130
+
131
+ schemas = {tool["name"]: tool["inputSchema"] for tool in BROWSER_TOOL_SCHEMAS}
132
+ layers_schema = schemas["browser_snapshot_layers"]["properties"]["max_layers"]
133
+ self.assertEqual(layers_schema["minimum"], 1)
134
+ self.assertEqual(layers_schema["maximum"], MAX_SCREENSHOT_LAYERS)
135
+ open_timeout_schema = schemas["browser_open"]["properties"]["timeout"]
136
+ navigate_timeout_schema = schemas["browser_navigate"]["properties"]["timeout"]
137
+ self.assertEqual(open_timeout_schema["maximum"], MAX_BROWSER_OPEN_TIMEOUT_SECONDS)
138
+ self.assertEqual(open_timeout_schema["default"], 15.0)
139
+ self.assertEqual(navigate_timeout_schema["maximum"], MAX_BROWSER_OPEN_TIMEOUT_SECONDS)
140
+ wait_schema = schemas["browser_wait"]["properties"]["seconds"]
141
+ self.assertEqual(wait_schema["maximum"], 10)
142
+ click_schema = next(
143
+ tool for tool in BROWSER_TOOL_SCHEMAS if tool["name"] == "browser_click"
144
+ )
145
+ self.assertIn("href", click_schema["description"])
146
+ self.assertIn(
147
+ "href", click_schema["inputSchema"]["properties"]["element_id"]["description"]
148
+ )
149
+
150
+ def test_profile_manager_persistence(self):
151
+ pm = ProfileManager(profile_dir=self.profile_dir)
152
+ pm.cookies.set_cookie(http.cookiejar.Cookie(
153
+ version=0, name="session_token", value="abc123secret",
154
+ port=None, port_specified=False, domain="localhost.local",
155
+ domain_specified=False, domain_initial_dot=False, path="/",
156
+ path_specified=True, secure=False, expires=None, discard=False,
157
+ comment=None, comment_url=None, rest={}, rfc2109=False,
158
+ ))
159
+ pm.local_storage["user_pref"] = {"theme": "dark"}
160
+ pm.save()
161
+
162
+ # Reload from disk
163
+ pm2 = ProfileManager(profile_dir=self.profile_dir)
164
+ cookies = {cookie.name: cookie.value for cookie in pm2.cookies}
165
+ self.assertEqual(cookies["session_token"], "abc123secret")
166
+ self.assertEqual(pm2.local_storage.get("user_pref"), {"theme": "dark"})
167
+
168
+ def test_profile_permissions_are_explicitly_private(self):
169
+ old_umask = os.umask(0)
170
+ try:
171
+ pm = ProfileManager(profile_dir=self.profile_dir)
172
+ pm.save()
173
+ finally:
174
+ os.umask(old_umask)
175
+
176
+ if os.name != "nt":
177
+ self.assertEqual(stat.S_IMODE(os.stat(self.profile_dir).st_mode), 0o700)
178
+ self.assertEqual(stat.S_IMODE(os.stat(pm.cookie_file).st_mode), 0o600)
179
+ self.assertEqual(stat.S_IMODE(os.stat(pm.storage_file).st_mode), 0o600)
180
+
181
+ def test_cookie_jar_does_not_send_cookie_to_another_origin(self):
182
+ pm = ProfileManager(profile_dir=self.profile_dir)
183
+ session = StealthBrowserSession("test_tab", pm)
184
+
185
+ session.open(f"http://localhost:{self.port}/set-cookie")
186
+ session.open(f"http://localhost:{self.port}/echo")
187
+ self.assertIn("session_token=abc123secret", session.page_html)
188
+ session.open(f"http://127.0.0.1:{self.port}/echo")
189
+
190
+ self.assertNotIn("session_token", session.page_html)
191
+
192
+ def test_remote_http_never_receives_persistent_cookies(self):
193
+ pm = ProfileManager(profile_dir=self.profile_dir)
194
+ pm.cookies.set_cookie(http.cookiejar.Cookie(
195
+ version=0, name="session_token", value="abc123secret",
196
+ port=None, port_specified=False, domain="example.test",
197
+ domain_specified=False, domain_initial_dot=False, path="/",
198
+ path_specified=True, secure=False, expires=None, discard=False,
199
+ comment=None, comment_url=None, rest={}, rfc2109=False,
200
+ ))
201
+
202
+ cleartext_request = urllib.request.Request("http://example.test/path")
203
+ pm.cookies.add_cookie_header(cleartext_request)
204
+ secure_request = urllib.request.Request("https://example.test/path")
205
+ pm.cookies.add_cookie_header(secure_request)
206
+
207
+ self.assertIsNone(cleartext_request.get_header("Cookie"))
208
+ self.assertEqual(secure_request.get_header("Cookie"), "session_token=abc123secret")
209
+
210
+ def test_remote_http_uses_credential_free_opener_for_redirect_chain(self):
211
+ pm = ProfileManager(profile_dir=self.profile_dir)
212
+ session = StealthBrowserSession("test_tab", pm)
213
+ session.opener = Mock()
214
+ session.credential_free_opener = Mock()
215
+ session.credential_free_opener.open.side_effect = OSError(
216
+ "stop before network access"
217
+ )
218
+
219
+ session.open("http://example.test/path")
220
+
221
+ session.opener.open.assert_not_called()
222
+ request = session.credential_free_opener.open.call_args.args[0]
223
+ self.assertEqual(request.full_url, "http://example.test/path")
224
+
225
+ def test_http_redirect_to_remote_target_does_not_receive_cookie(self):
226
+ class FakeHTTPRedirectHandler(urllib.request.BaseHandler):
227
+ handler_order = 100
228
+
229
+ def __init__(inner_self):
230
+ inner_self.requests = []
231
+
232
+ def http_open(inner_self, request):
233
+ inner_self.requests.append(request)
234
+ headers = Message()
235
+ if request.full_url.startswith("http://localhost/"):
236
+ headers.add_header("Location", "http://example.test/final")
237
+ body = io.BytesIO()
238
+ body.msg = headers
239
+ return urllib.response.addinfourl(
240
+ body, headers, request.full_url, 302
241
+ )
242
+ body = io.BytesIO(b"redirect complete")
243
+ headers.add_header("Content-Type", "text/html; charset=utf-8")
244
+ body.msg = headers
245
+ return urllib.response.addinfourl(
246
+ body, headers, request.full_url, 200
247
+ )
248
+
249
+ pm = ProfileManager(profile_dir=self.profile_dir)
250
+ pm.cookies.set_cookie(http.cookiejar.Cookie(
251
+ version=0, name="session_token", value="abc123secret",
252
+ port=None, port_specified=False, domain="example.test",
253
+ domain_specified=False, domain_initial_dot=False, path="/",
254
+ path_specified=True, secure=False, expires=None, discard=False,
255
+ comment=None, comment_url=None, rest={}, rfc2109=False,
256
+ ))
257
+ session = StealthBrowserSession("test_tab", pm)
258
+ handler = FakeHTTPRedirectHandler()
259
+ session.opener.add_handler(handler)
260
+
261
+ result = session.open("http://localhost/start")
262
+
263
+ self.assertEqual(result["url"], "http://example.test/final")
264
+ self.assertEqual(len(handler.requests), 2)
265
+ self.assertIsNone(handler.requests[1].get_header("Cookie"))
266
+
267
+ def test_redirect_hops_receive_remaining_navigation_deadline(self):
268
+ class DelayedRedirectHandler(urllib.request.BaseHandler):
269
+ handler_order = 100
270
+
271
+ def __init__(inner_self):
272
+ inner_self.timeouts = []
273
+
274
+ def http_open(inner_self, request):
275
+ inner_self.timeouts.append(request.timeout)
276
+ headers = Message()
277
+ headers.add_header("Location", f"/hop-{len(inner_self.timeouts)}")
278
+ body = io.BytesIO()
279
+ body.msg = headers
280
+ return urllib.response.addinfourl(
281
+ body, headers, request.full_url, 302
282
+ )
283
+
284
+ pm = ProfileManager(profile_dir=self.profile_dir)
285
+ session = StealthBrowserSession("test_tab", pm)
286
+ handler = DelayedRedirectHandler()
287
+ session.opener.add_handler(handler)
288
+
289
+ with (
290
+ patch(
291
+ "fable_engine.browser.time.monotonic",
292
+ side_effect=(100.0, 100.1, 100.4, 101.1),
293
+ ),
294
+ patch.object(session, "_read_response_body") as read_response_body,
295
+ ):
296
+ result = session.open("http://localhost/start", timeout=1.0)
297
+
298
+ self.assertEqual(result["status"], "error")
299
+ self.assertIn("timed out", result["error"])
300
+ self.assertEqual(len(handler.timeouts), 2)
301
+ self.assertAlmostEqual(handler.timeouts[0], 0.9)
302
+ self.assertAlmostEqual(handler.timeouts[1], 0.6)
303
+ read_response_body.assert_not_called()
304
+
305
+ def test_https_cookie_redirect_to_http_is_not_followed(self):
306
+ class FakeHTTPSRedirectHandler(urllib.request.BaseHandler):
307
+ handler_order = 100
308
+
309
+ def https_open(inner_self, request):
310
+ headers = Message()
311
+ headers.add_header(
312
+ "Location", f"http://localhost:{self.port}/downgrade-target"
313
+ )
314
+ headers.add_header(
315
+ "Set-Cookie", "session_token=should-not-leak; Path=/"
316
+ )
317
+ body = io.BytesIO()
318
+ body.msg = headers
319
+ return urllib.response.addinfourl(
320
+ body, headers, request.full_url, 302
321
+ )
322
+
323
+ pm = ProfileManager(profile_dir=self.profile_dir)
324
+ session = StealthBrowserSession("test_tab", pm)
325
+ session.opener.add_handler(FakeHTTPSRedirectHandler())
326
+
327
+ result = session.open(f"https://localhost:{self.port}/redirect")
328
+
329
+ self.assertEqual(result["status"], "error")
330
+ self.assertIn("HTTP Error 302", result["error"])
331
+ self.assertEqual(
332
+ {cookie.name: cookie.value for cookie in pm.cookies}["session_token"],
333
+ "should-not-leak",
334
+ )
335
+ self.assertEqual(_BrowserTestHandler.downgrade_requests, 0)
336
+ self.assertIsNone(_BrowserTestHandler.downgrade_cookie)
337
+
338
+ def test_browser_session_about_blank(self):
339
+ pm = ProfileManager(profile_dir=self.profile_dir)
340
+ session = StealthBrowserSession("test_tab", pm)
341
+ res = session.open("about:blank")
342
+
343
+ self.assertEqual(res["url"], "about:blank")
344
+ self.assertEqual(res["title"], "Blank Page")
345
+ self.assertEqual(res["viewport"], [DEFAULT_VIEWPORT_WIDTH, DEFAULT_VIEWPORT_HEIGHT])
346
+
347
+ def test_dom_parsing_and_element_interaction(self):
348
+ pm = ProfileManager(profile_dir=self.profile_dir)
349
+ session = StealthBrowserSession("test_tab", pm)
350
+ session.page_html = """
351
+ <html>
352
+ <head><title>Test Localhost Page</title></head>
353
+ <body>
354
+ <h1>Welcome to Localhost App</h1>
355
+ <input id="username_input" type="text" value="" />
356
+ <input id="remember_me" type="checkbox" />
357
+ <textarea id="notes"></textarea>
358
+ <button id="login_btn">Submit Login</button>
359
+ <a id="home_link" href="about:blank">Return Home</a>
360
+ </body>
361
+ </html>
362
+ """
363
+ session._parse_and_layout()
364
+ status = session._build_status()
365
+
366
+ self.assertEqual(status["title"], "Test Localhost Page")
367
+ self.assertIn("username_input", session.elements_by_id)
368
+ self.assertIn("login_btn", session.elements_by_id)
369
+
370
+ # Test type_text
371
+ type_res = session.type_text("username_input", "agent_user")
372
+ self.assertEqual(type_res["status"], "typed")
373
+ self.assertEqual(session.elements_by_id["username_input"].attrs.get("value"), "agent_user")
374
+ self.assertEqual(session.type_text("notes", "editable")["status"], "typed")
375
+ self.assertIn(
376
+ "notes",
377
+ {element["element_id"] for element in status["interactive_elements"]},
378
+ )
379
+
380
+ self.assertEqual(session.type_text("login_btn", "nope")["status"], "error")
381
+ self.assertEqual(session.type_text("home_link", "nope")["status"], "error")
382
+ self.assertEqual(session.type_text("remember_me", "nope")["status"], "error")
383
+
384
+ self.assertEqual(session.click("login_btn")["status"], "error")
385
+ self.assertEqual(session.press_key("!", "username_input")["status"], "pressed")
386
+ self.assertEqual(
387
+ session.elements_by_id["username_input"].attrs["value"], "agent_user!"
388
+ )
389
+ self.assertEqual(session.press_key("Backspace")["status"], "pressed")
390
+ self.assertEqual(
391
+ session.elements_by_id["username_input"].attrs["value"], "agent_user"
392
+ )
393
+ self.assertEqual(session.press_key("Tab", "username_input")["element_id"], "remember_me")
394
+ self.assertEqual(session.press_key("Enter", "username_input")["status"], "error")
395
+ self.assertEqual(session.press_key("Enter", "login_btn")["status"], "error")
396
+ self.assertEqual(session.press_key("Enter", "notes")["status"], "pressed")
397
+ self.assertTrue(session.elements_by_id["notes"].attrs["value"].endswith("\n"))
398
+
399
+ # Test click link
400
+ click_res = session.click("home_link")
401
+ self.assertEqual(click_res["url"], "about:blank")
402
+
403
+ def test_password_values_are_redacted_without_mutating_dom(self):
404
+ password = DOMElement(
405
+ "password", "input", {"type": "PASSWORD", "value": "secret", "name": "password"}
406
+ )
407
+ regular = DOMElement("username", "input", {"type": "text", "value": "agent"})
408
+
409
+ self.assertEqual(password.to_dict()["attrs"]["value"], "[REDACTED]")
410
+ self.assertEqual(password.to_dict()["attrs"]["name"], "password")
411
+ self.assertEqual(password.attrs["value"], "secret")
412
+ self.assertEqual(regular.to_dict()["attrs"]["value"], "agent")
413
+
414
+ def test_oversized_dom_returns_an_explicit_error(self):
415
+ pm = ProfileManager(profile_dir=self.profile_dir)
416
+ session = StealthBrowserSession("test_tab", pm)
417
+
418
+ result = session.open(f"http://127.0.0.1:{self.port}/oversized-dom")
419
+
420
+ self.assertEqual(result["status"], "error")
421
+ self.assertIn(f"{MAX_DOM_NODES} DOM node limit", result["error"])
422
+ self.assertLess(len(session.elements_by_id), MAX_DOM_NODES)
423
+
424
+ def test_failed_navigation_preserves_previous_page_state(self):
425
+ pm = ProfileManager(profile_dir=self.profile_dir)
426
+ session = StealthBrowserSession("test_tab", pm)
427
+ session.open("about:blank")
428
+ session.scroll_y = 275
429
+ previous_state = (
430
+ session.url,
431
+ session.page_html,
432
+ session.dom_root,
433
+ session.elements_by_id,
434
+ session.page_title,
435
+ session.document_height,
436
+ list(session.history),
437
+ session.history_index,
438
+ session.scroll_y,
439
+ )
440
+
441
+ session.opener = Mock()
442
+ session.opener.open.side_effect = OSError("navigation failed")
443
+ result = session.open("example.test")
444
+
445
+ self.assertEqual(result["status"], "error")
446
+ self.assertEqual(result["url"], "https://example.test")
447
+ self.assertEqual(
448
+ (
449
+ session.url,
450
+ session.page_html,
451
+ session.dom_root,
452
+ session.elements_by_id,
453
+ session.page_title,
454
+ session.document_height,
455
+ session.history,
456
+ session.history_index,
457
+ session.scroll_y,
458
+ ),
459
+ previous_state,
460
+ )
461
+
462
+ def test_layout_failure_preserves_previous_page_state(self):
463
+ pm = ProfileManager(profile_dir=self.profile_dir)
464
+ session = StealthBrowserSession("test_tab", pm)
465
+ session.open("about:blank")
466
+ session.scroll_y = 275
467
+ previous_state = (
468
+ session.url,
469
+ session.page_html,
470
+ session.dom_root,
471
+ session.elements_by_id,
472
+ session.page_title,
473
+ session.document_height,
474
+ list(session.history),
475
+ session.history_index,
476
+ session.scroll_y,
477
+ )
478
+
479
+ result = session.open(f"http://127.0.0.1:{self.port}/oversized-dom")
480
+
481
+ self.assertEqual(result["status"], "error")
482
+ self.assertEqual(
483
+ (
484
+ session.url,
485
+ session.page_html,
486
+ session.dom_root,
487
+ session.elements_by_id,
488
+ session.page_title,
489
+ session.document_height,
490
+ session.history,
491
+ session.history_index,
492
+ session.scroll_y,
493
+ ),
494
+ previous_state,
495
+ )
496
+
497
+ def test_navigation_resets_scroll_before_layout(self):
498
+ pm = ProfileManager(profile_dir=self.profile_dir)
499
+ session = StealthBrowserSession("test_tab", pm)
500
+ session.scroll_y = 275
501
+
502
+ session.open("about:blank")
503
+ self.assertEqual(session.scroll_y, 0)
504
+
505
+ session.scroll_y = 275
506
+ session.open(f"http://127.0.0.1:{self.port}/echo")
507
+ self.assertEqual(session.scroll_y, 0)
508
+
509
+ def test_duplicate_explicit_and_generated_element_ids_are_unique(self):
510
+ pm = ProfileManager(profile_dir=self.profile_dir)
511
+ session = StealthBrowserSession("test_tab", pm)
512
+ session.page_html = (
513
+ '<html id="elem_4"><body><div id="duplicate"></div>'
514
+ '<span id="duplicate"></span><p></p></body></html>'
515
+ )
516
+ session._parse_and_layout()
517
+
518
+ ids = [element.element_id for element in session.elements_by_id.values()]
519
+ self.assertEqual(len(ids), len(set(ids)))
520
+ self.assertIn("duplicate", ids)
521
+ self.assertIn("duplicate_2", ids)
522
+ self.assertEqual(len(ids), 6)
523
+
524
+ def test_layered_png_snapshot(self):
525
+ pm = ProfileManager(profile_dir=self.profile_dir)
526
+ session = StealthBrowserSession("test_tab", pm)
527
+ session.page_html = "<html><body>" + "".join([f"<h2>Header {i}</h2><p>Paragraph text {i}</p>" for i in range(50)]) + "</body></html>"
528
+ session._parse_and_layout()
529
+
530
+ snapshot_res = session.snapshot_layers(max_layers=3)
531
+ self.assertEqual(snapshot_res["session_id"], "test_tab")
532
+ self.assertGreaterEqual(snapshot_res["total_layers"], 1)
533
+ self.assertTrue(len(snapshot_res["layers"]) > 0)
534
+
535
+ first_layer = snapshot_res["layers"][0]
536
+ self.assertEqual(first_layer["layer_index"], 1)
537
+ self.assertTrue(first_layer["b64_png"].startswith("iVBORw0KGgo")) # PNG magic header in base64
538
+
539
+ def test_snapshot_limits_layers_and_viewport_uses_current_scroll(self):
540
+ pm = ProfileManager(profile_dir=self.profile_dir)
541
+ session = StealthBrowserSession("test_tab", pm, viewport_width=100, viewport_height=100)
542
+ session.page_html = "<html><body><p>content</p></body></html>"
543
+ session._parse_and_layout()
544
+ session.document_height = 5000
545
+ session.scroll_y = 275
546
+
547
+ result = session.snapshot_layers(max_layers=10_000)
548
+ self.assertEqual(result["total_layers"], MAX_SCREENSHOT_LAYERS)
549
+ self.assertEqual(session.snapshot_viewport()["layers"][0]["scroll_top"], 275)
550
+
551
+ def test_response_body_limit_rejects_oversized_content(self):
552
+ pm = ProfileManager(profile_dir=self.profile_dir)
553
+ session = StealthBrowserSession("test_tab", pm, max_response_bytes=10)
554
+
555
+ result = session.open(f"http://127.0.0.1:{self.port}/large")
556
+
557
+ self.assertEqual(result["status"], "error")
558
+ self.assertIn("10 byte limit", result["error"])
559
+ self.assertEqual(session.history, [])
560
+
561
+ def test_history_traversal_does_not_mutate_history_on_redirect(self):
562
+ pm = ProfileManager(profile_dir=self.profile_dir)
563
+ session = StealthBrowserSession("test_tab", pm)
564
+ one = f"http://127.0.0.1:{self.port}/history-one"
565
+ two = f"http://127.0.0.1:{self.port}/history-two"
566
+ session.open(one)
567
+ session.open(two)
568
+ original_history = list(session.history)
569
+ _BrowserTestHandler.redirect_history_one = True
570
+
571
+ session.back()
572
+ self.assertEqual(session.history, original_history)
573
+ self.assertEqual(session.history_index, 0)
574
+ self.assertTrue(session.url.endswith("/redirected"))
575
+
576
+ session.reload()
577
+ self.assertEqual(session.history, original_history)
578
+ self.assertEqual(session.history_index, 0)
579
+ self.assertTrue(session.url.endswith("/redirected"))
580
+
581
+ session.forward()
582
+ self.assertEqual(session.history, original_history)
583
+ self.assertEqual(session.history_index, 1)
584
+ self.assertEqual(session.url, two)
585
+
586
+ def test_history_is_bounded_and_remains_consistent_after_branching(self):
587
+ pm = ProfileManager(profile_dir=self.profile_dir)
588
+ session = StealthBrowserSession("test_tab", pm)
589
+ urls = [f"about:page-{index}" for index in range(MAX_HISTORY_ENTRIES + 5)]
590
+
591
+ for url in urls:
592
+ session._record_history(url)
593
+
594
+ self.assertEqual(session.history, urls[-MAX_HISTORY_ENTRIES:])
595
+ self.assertEqual(session.history_index, MAX_HISTORY_ENTRIES - 1)
596
+
597
+ session.back()
598
+ session.open("about:branched")
599
+ session.open("about:branched")
600
+
601
+ self.assertEqual(len(session.history), MAX_HISTORY_ENTRIES)
602
+ self.assertEqual(session.history[-1], "about:branched")
603
+ self.assertEqual(session.history_index, len(session.history) - 1)
604
+ self.assertEqual(session.forward()["url"], "about:branched")
605
+
606
+ def test_same_url_navigation_clears_forward_history(self):
607
+ pm = ProfileManager(profile_dir=self.profile_dir)
608
+ session = StealthBrowserSession("test_tab", pm)
609
+ for url in ("about:A", "about:B", "about:C"):
610
+ session.open(url)
611
+
612
+ session.back()
613
+ session.open("about:B")
614
+
615
+ self.assertEqual(session.history, ["about:A", "about:B"])
616
+ self.assertEqual(session.history_index, 1)
617
+ self.assertEqual(session.forward()["url"], "about:B")
618
+
619
+ def test_url_scheme_checks_are_case_insensitive_without_rewriting_url(self):
620
+ pm = ProfileManager(profile_dir=self.profile_dir)
621
+ session = StealthBrowserSession("test_tab", pm)
622
+
623
+ about_url = "AbOuT:blank"
624
+ self.assertEqual(session.open(about_url)["url"], about_url)
625
+
626
+ session.opener = Mock()
627
+ session.opener.open.side_effect = OSError("stop before network access")
628
+ with patch("fable_engine.browser.urllib.request.Request") as request:
629
+ session.open("HtTp://Example.test/Path")
630
+
631
+ request.assert_called_once_with("HtTp://Example.test/Path")
632
+
633
+ def test_bare_urls_use_https_except_for_loopback_targets(self):
634
+ pm = ProfileManager(profile_dir=self.profile_dir)
635
+ session = StealthBrowserSession("test_tab", pm)
636
+ session.opener = Mock()
637
+ session.credential_free_opener = Mock()
638
+ session.opener.open.side_effect = OSError("stop before network access")
639
+ session.credential_free_opener.open.side_effect = OSError(
640
+ "stop before network access"
641
+ )
642
+
643
+ for target, expected in (
644
+ ("example.test/path", "https://example.test/path"),
645
+ ("localhost:3000/path", "http://localhost:3000/path"),
646
+ ("service.localhost:3000/path", "https://service.localhost:3000/path"),
647
+ ("127.0.0.1:3000/path", "http://127.0.0.1:3000/path"),
648
+ ("[::1]:3000/path", "http://[::1]:3000/path"),
649
+ ("http://example.test/path", "http://example.test/path"),
650
+ ):
651
+ with self.subTest(target=target):
652
+ session.opener.reset_mock()
653
+ session.credential_free_opener.reset_mock()
654
+ session.open(target)
655
+ opener = (
656
+ session.credential_free_opener
657
+ if expected.startswith("http://example.test")
658
+ else session.opener
659
+ )
660
+ self.assertEqual(opener.open.call_args.args[0].full_url, expected)
661
+
662
+ def test_actionable_elements_are_not_hidden_by_summary_limit(self):
663
+ pm = ProfileManager(profile_dir=self.profile_dir)
664
+ session = StealthBrowserSession("test_tab", pm)
665
+ session.page_html = (
666
+ "<html><body>"
667
+ + "".join(f"<h2>Heading {index}</h2>" for index in range(60))
668
+ + '<input id="late-input" type="text">'
669
+ + "</body></html>"
670
+ )
671
+ session._parse_and_layout()
672
+
673
+ elements = session._build_status()["interactive_elements"]
674
+
675
+ self.assertEqual(elements[0]["element_id"], "late-input")
676
+ self.assertLessEqual(len(elements), 50)
677
+
678
+ def test_layout_walk_handles_deep_dom_in_preorder(self):
679
+ pm = ProfileManager(profile_dir=self.profile_dir)
680
+ session = StealthBrowserSession("test_tab", pm)
681
+ session.page_html = "".join(
682
+ f'<div id="node-{index}">' for index in range(1_500)
683
+ ) + "</div>" * 1_500
684
+
685
+ session._parse_and_layout()
686
+
687
+ ids = list(session.elements_by_id)
688
+ self.assertEqual(ids[:4], ["elem_0", "node-0", "node-1", "node-2"])
689
+ self.assertEqual(ids[-1], "node-1499")
690
+
691
+ def test_png_generator(self):
692
+ elems = [DOMElement("elem_1", "button", {"id": "elem_1"})]
693
+ elems[0].x = 10
694
+ elems[0].y = 10
695
+ elems[0].width = 100
696
+ elems[0].height = 40
697
+
698
+ png_bytes = generate_minimal_png(200, 100, elems)
699
+ self.assertTrue(png_bytes.startswith(b"\x89PNG\r\n\x1a\n"))
700
+ self.assertTrue(b"IHDR" in png_bytes)
701
+ self.assertTrue(b"IDAT" in png_bytes)
702
+ self.assertTrue(b"IEND" in png_bytes)
703
+
704
+ def test_png_background_scanlines_preserve_rgb_layout(self):
705
+ bg_color = (1, 127, 255)
706
+ png_bytes = generate_minimal_png(137, 103, [], bg_color=bg_color)
707
+ offset = 8
708
+ compressed = bytearray()
709
+ while offset < len(png_bytes):
710
+ chunk_length = struct.unpack(">I", png_bytes[offset:offset + 4])[0]
711
+ chunk_type = png_bytes[offset + 4:offset + 8]
712
+ chunk_data = png_bytes[offset + 8:offset + 8 + chunk_length]
713
+ if chunk_type == b"IDAT":
714
+ compressed.extend(chunk_data)
715
+ offset += 12 + chunk_length
716
+
717
+ expected_scanline = b"\x00" + bytes(bg_color) * 137
718
+ self.assertEqual(zlib.decompress(compressed), expected_scanline * 103)
719
+
720
+ def test_browser_engine_session_lifecycle(self):
721
+ engine = StealthBrowserEngine(profile_dir=self.profile_dir, max_sessions=2)
722
+ session1 = engine.get_or_create_session("tab1")
723
+ session2 = engine.get_or_create_session("tab2")
724
+
725
+ self.assertIn("tab1", engine.sessions)
726
+ self.assertIn("tab2", engine.sessions)
727
+ self.assertIs(engine.get_or_create_session("tab1"), session1)
728
+ self.assertEqual(list(engine.sessions), ["tab1", "tab2"])
729
+
730
+ close_res = engine.close_session("tab1")
731
+ self.assertEqual(close_res["status"], "closed")
732
+ self.assertNotIn("tab1", engine.sessions)
733
+
734
+ session2.page_html = "retained page"
735
+ session2.opener.close = Mock()
736
+ session2.credential_free_opener.close = Mock()
737
+ engine.get_or_create_session("tab3")
738
+ engine.get_or_create_session("tab4")
739
+ self.assertNotIn("tab2", engine.sessions)
740
+ self.assertEqual(list(engine.sessions), ["tab3", "tab4"])
741
+ self.assertLessEqual(len(engine.sessions), MAX_BROWSER_SESSIONS)
742
+ session2.opener.close.assert_called_once_with()
743
+ session2.credential_free_opener.close.assert_called_once_with()
744
+ self.assertEqual(session2.page_html, "")
745
+
746
+ def test_default_session_page_retention_has_a_safe_aggregate_limit(self):
747
+ self.assertEqual(
748
+ DEFAULT_MAX_RESPONSE_BYTES * MAX_BROWSER_SESSIONS,
749
+ MAX_RETAINED_PAGE_HTML_BYTES,
750
+ )
751
+ self.assertLess(MAX_RETAINED_PAGE_HTML_BYTES, 20 * 1024 * 1024)
752
+
753
+ def test_browser_module_import_does_not_create_profile(self):
754
+ script = """
755
+ from unittest.mock import patch
756
+ with patch('pathlib.Path.mkdir', side_effect=PermissionError('read-only home')):
757
+ import fable_engine.browser as browser
758
+ assert browser.GLOBAL_BROWSER_ENGINE._engine is None
759
+ """
760
+ env = os.environ.copy()
761
+ repo_root = str(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
762
+ env["PYTHONPATH"] = repo_root + os.pathsep + env.get("PYTHONPATH", "")
763
+ result = subprocess.run(
764
+ [sys.executable, "-c", script],
765
+ cwd=self.tmp_dir,
766
+ env=env,
767
+ text=True,
768
+ capture_output=True,
769
+ check=False,
770
+ )
771
+ self.assertEqual(result.returncode, 0, result.stderr)
772
+
773
+ def test_browser_close_dispatch_does_not_create_unknown_session(self):
774
+ engine = Mock()
775
+ engine.close_session.return_value = {"status": "not_found", "session_id": "missing"}
776
+ request = json.dumps({
777
+ "jsonrpc": "2.0", "id": 1, "method": "tools/call",
778
+ "params": {"name": "browser_close", "arguments": {"session_id": "missing"}},
779
+ }) + "\n"
780
+ output = io.StringIO()
781
+ with (
782
+ patch.object(browser_server, "GLOBAL_BROWSER_ENGINE", engine),
783
+ patch.object(browser_server, "AutoUpdater") as auto_updater,
784
+ patch("sys.stdin", io.StringIO(request)),
785
+ patch("sys.stdout", output),
786
+ ):
787
+ auto_updater.return_value.trigger_silent_background_update.return_value = None
788
+ browser_server.main()
789
+
790
+ engine.get_or_create_session.assert_not_called()
791
+ engine.close_session.assert_called_once_with("missing")
792
+ result = json.loads(json.loads(output.getvalue())["result"]["content"][0]["text"])
793
+ self.assertEqual(result["status"], "not_found")
794
+
795
+ def test_unknown_browser_tool_does_not_create_session(self):
796
+ engine = Mock()
797
+ request = json.dumps({
798
+ "jsonrpc": "2.0", "id": 1, "method": "tools/call",
799
+ "params": {"name": "browser_typo", "arguments": {"session_id": "unused"}},
800
+ }) + "\n"
801
+ output = io.StringIO()
802
+ with (
803
+ patch.object(browser_server, "GLOBAL_BROWSER_ENGINE", engine),
804
+ patch.object(browser_server, "AutoUpdater") as auto_updater,
805
+ patch("sys.stdin", io.StringIO(request)),
806
+ patch("sys.stdout", output),
807
+ ):
808
+ auto_updater.return_value.trigger_silent_background_update.return_value = None
809
+ browser_server.main()
810
+
811
+ engine.get_or_create_session.assert_not_called()
812
+ engine.close_session.assert_not_called()
813
+ response = json.loads(output.getvalue())
814
+ self.assertEqual(response["error"]["code"], -32601)
815
+
816
+ def test_browser_open_timeout_is_bounded_and_must_be_finite(self):
817
+ engine = Mock()
818
+ session = engine.get_or_create_session.return_value
819
+ session.open.return_value = {"status": "opened"}
820
+ requests = "".join([
821
+ json.dumps({
822
+ "jsonrpc": "2.0", "id": 1, "method": "tools/call",
823
+ "params": {
824
+ "name": "browser_open",
825
+ "arguments": {"url": "about:blank", "timeout": 10_000},
826
+ },
827
+ }) + "\n",
828
+ json.dumps({
829
+ "jsonrpc": "2.0", "id": 2, "method": "tools/call",
830
+ "params": {
831
+ "name": "browser_open",
832
+ "arguments": {"url": "about:blank", "timeout": float("nan")},
833
+ },
834
+ }) + "\n",
835
+ ])
836
+ output = io.StringIO()
837
+ with (
838
+ patch.object(browser_server, "GLOBAL_BROWSER_ENGINE", engine),
839
+ patch.object(browser_server, "AutoUpdater") as auto_updater,
840
+ patch("sys.stdin", io.StringIO(requests)),
841
+ patch("sys.stdout", output),
842
+ ):
843
+ auto_updater.return_value.trigger_silent_background_update.return_value = None
844
+ browser_server.main()
845
+
846
+ session.open.assert_called_once_with(
847
+ "about:blank", timeout=MAX_BROWSER_OPEN_TIMEOUT_SECONDS
848
+ )
849
+ engine.get_or_create_session.assert_called_once_with(None)
850
+ responses = [json.loads(line) for line in output.getvalue().splitlines()]
851
+ self.assertFalse(responses[0]["result"]["isError"])
852
+ self.assertTrue(responses[1]["result"]["isError"])
853
+ self.assertIn("must be finite", responses[1]["result"]["content"][0]["text"])
854
+
855
+ def test_browser_operation_error_results_set_mcp_error_flag(self):
856
+ engine = Mock()
857
+ session = engine.get_or_create_session.return_value
858
+ session.open.side_effect = [
859
+ {"status": "error", "error": "navigation failed"},
860
+ {"status": "opened", "url": "about:blank"},
861
+ ]
862
+ session.click.return_value = {"error": "missing element"}
863
+ session.type_text.return_value = {
864
+ "status": "error",
865
+ "error": "not editable",
866
+ }
867
+ session.scroll.return_value = {"error": "scroll failed"}
868
+ requests = "".join(
869
+ json.dumps({
870
+ "jsonrpc": "2.0",
871
+ "id": index,
872
+ "method": "tools/call",
873
+ "params": {"name": name, "arguments": arguments},
874
+ }) + "\n"
875
+ for index, (name, arguments) in enumerate((
876
+ ("browser_open", {"url": "bad.test"}),
877
+ ("browser_click", {"element_id": "missing"}),
878
+ ("browser_type", {"element_id": "readonly", "text": "value"}),
879
+ ("browser_scroll", {"delta_y": 10}),
880
+ ("browser_open", {"url": "about:blank"}),
881
+ ), start=1)
882
+ )
883
+ output = io.StringIO()
884
+ with (
885
+ patch.object(browser_server, "GLOBAL_BROWSER_ENGINE", engine),
886
+ patch.object(browser_server, "AutoUpdater") as auto_updater,
887
+ patch("sys.stdin", io.StringIO(requests)),
888
+ patch("sys.stdout", output),
889
+ ):
890
+ auto_updater.return_value.trigger_silent_background_update.return_value = None
891
+ browser_server.main()
892
+
893
+ responses = [json.loads(line) for line in output.getvalue().splitlines()]
894
+ self.assertEqual(
895
+ [response["result"]["isError"] for response in responses],
896
+ [True, True, True, True, False],
897
+ )
898
+
899
+ def test_browser_open_enforces_one_deadline_across_reads(self):
900
+ class FakeSocket:
901
+ def __init__(self):
902
+ self.timeouts = []
903
+
904
+ def settimeout(self, timeout):
905
+ self.timeouts.append(timeout)
906
+
907
+ class FakeResponse:
908
+ def __init__(self):
909
+ fake_raw = type("FakeRaw", (), {"_sock": FakeSocket()})()
910
+ self.fp = type("FakeFP", (), {"raw": fake_raw})()
911
+ self.chunks = iter((b"partial", b"response", b""))
912
+
913
+ def __enter__(self):
914
+ return self
915
+
916
+ def __exit__(self, *args):
917
+ return False
918
+
919
+ def read1(self, _size):
920
+ return next(self.chunks)
921
+
922
+ def read(self, _size):
923
+ return next(self.chunks)
924
+
925
+ pm = ProfileManager(profile_dir=self.profile_dir)
926
+ session = StealthBrowserSession("test_tab", pm)
927
+ response = FakeResponse()
928
+ session.opener = Mock()
929
+ session.opener.open.return_value = response
930
+
931
+ with patch(
932
+ "fable_engine.browser.time.monotonic",
933
+ side_effect=(100.0, 100.1, 100.2, 101.1),
934
+ ):
935
+ result = session.open("example.test", timeout=1.0)
936
+
937
+ self.assertEqual(result["status"], "error")
938
+ self.assertIn("timed out", result["error"])
939
+ self.assertAlmostEqual(session.opener.open.call_args.kwargs["timeout"], 0.9)
940
+ self.assertEqual(len(response.fp.raw._sock.timeouts), 1)
941
+
942
+ def test_browser_wait_reports_bounded_duration(self):
943
+ engine = Mock()
944
+ request = json.dumps({
945
+ "jsonrpc": "2.0", "id": 1, "method": "tools/call",
946
+ "params": {
947
+ "name": "browser_wait",
948
+ "arguments": {"seconds": 25, "session_id": "new-session"},
949
+ },
950
+ }) + "\n"
951
+ output = io.StringIO()
952
+ with (
953
+ patch.object(browser_server, "GLOBAL_BROWSER_ENGINE", engine),
954
+ patch.object(browser_server, "AutoUpdater") as auto_updater,
955
+ patch("sys.stdin", io.StringIO(request)),
956
+ patch("sys.stdout", output),
957
+ patch("time.sleep") as sleep,
958
+ ):
959
+ auto_updater.return_value.trigger_silent_background_update.return_value = None
960
+ browser_server.main()
961
+
962
+ sleep.assert_called_once_with(10)
963
+ engine.get_or_create_session.assert_not_called()
964
+ engine.close_session.assert_not_called()
965
+ result = json.loads(json.loads(output.getvalue())["result"]["content"][0]["text"])
966
+ self.assertEqual(result["seconds"], 10)
967
+
968
+
969
+ if __name__ == "__main__":
970
+ unittest.main()