Skip to content

Commit c0959a2

Browse files
fix: prevent deadlock in page navigation and add page stability tests
- Initialize _page_switch_lock in Stagehand constructor - Skip page stability check for navigation methods (goto, reload, go_back, go_forward) - Use lock when switching active pages in context - Add comprehensive tests for LivePageProxy functionality
1 parent 29e34e2 commit c0959a2

File tree

3 files changed

+226
-9
lines changed

3 files changed

+226
-9
lines changed

stagehand/context.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -101,15 +101,16 @@ def _handle_new_page(self, pw_page: Page):
101101

102102
async def _async_handle():
103103
try:
104-
self.stagehand.logger.debug(
105-
f"Creating StagehandPage for new page with URL: {pw_page.url}",
106-
category="context",
107-
)
108-
stagehand_page = await self.create_stagehand_page(pw_page)
109-
self.set_active_page(stagehand_page)
110-
self.stagehand.logger.debug(
111-
"New page detected and initialized", category="context"
112-
)
104+
async with self.stagehand._page_switch_lock:
105+
self.stagehand.logger.debug(
106+
f"Creating StagehandPage for new page with URL: {pw_page.url}",
107+
category="context",
108+
)
109+
stagehand_page = await self.create_stagehand_page(pw_page)
110+
self.set_active_page(stagehand_page)
111+
self.stagehand.logger.debug(
112+
"New page detected and initialized", category="context"
113+
)
113114
except Exception as e:
114115
self.stagehand.logger.error(
115116
f"Failed to initialize new page: {str(e)}", category="context"

stagehand/main.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ def __getattr__(self, name):
6464
# For async operations, make them wait for stability
6565
attr = getattr(active_page, name)
6666
if callable(attr) and asyncio.iscoroutinefunction(attr):
67+
# Don't wait for stability on navigation methods
68+
if name in ["goto", "reload", "go_back", "go_forward"]:
69+
return attr
6770

6871
async def wrapped(*args, **kwargs):
6972
await self._ensure_page_stability()
@@ -266,6 +269,7 @@ def __init__(
266269
self._initialized = False # Flag to track if init() has run
267270
self._closed = False # Flag to track if resources have been closed
268271
self._live_page_proxy = None # Live page proxy
272+
self._page_switch_lock = asyncio.Lock() # Lock for page stability
269273

270274
# Setup LLM client if LOCAL mode
271275
self.llm = None
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
"""Test the LivePageProxy functionality"""
2+
3+
import asyncio
4+
import pytest
5+
from unittest.mock import AsyncMock, MagicMock, patch
6+
from stagehand.main import LivePageProxy, Stagehand
7+
from stagehand.page import StagehandPage
8+
9+
10+
@pytest.mark.asyncio
11+
async def test_live_page_proxy_basic_delegation(mock_stagehand_config):
12+
"""Test that LivePageProxy delegates to the active page"""
13+
# Create a Stagehand instance
14+
stagehand = Stagehand(config=mock_stagehand_config)
15+
16+
# Mock pages
17+
mock_original_page = MagicMock(spec=StagehandPage)
18+
mock_original_page.url = "https://original.com"
19+
mock_original_page.title = AsyncMock(return_value="Original Page")
20+
21+
mock_active_page = MagicMock(spec=StagehandPage)
22+
mock_active_page.url = "https://active.com"
23+
mock_active_page.title = AsyncMock(return_value="Active Page")
24+
25+
# Set up the pages
26+
stagehand._original_page = mock_original_page
27+
stagehand._active_page = mock_active_page
28+
stagehand._initialized = True
29+
30+
# Get the proxy
31+
proxy = stagehand.page
32+
33+
# Test that it delegates to the active page
34+
assert proxy.url == "https://active.com"
35+
title = await proxy.title()
36+
assert title == "Active Page"
37+
38+
39+
@pytest.mark.asyncio
40+
async def test_live_page_proxy_falls_back_to_original(mock_stagehand_config):
41+
"""Test that LivePageProxy falls back to original page when no active page"""
42+
# Create a Stagehand instance
43+
stagehand = Stagehand(config=mock_stagehand_config)
44+
45+
# Mock original page only
46+
mock_original_page = MagicMock(spec=StagehandPage)
47+
mock_original_page.url = "https://original.com"
48+
49+
# Set up the pages
50+
stagehand._original_page = mock_original_page
51+
stagehand._active_page = None
52+
stagehand._initialized = True
53+
54+
# Get the proxy
55+
proxy = stagehand.page
56+
57+
# Test that it delegates to the original page
58+
assert proxy.url == "https://original.com"
59+
60+
61+
@pytest.mark.asyncio
62+
async def test_live_page_proxy_page_stability(mock_stagehand_config):
63+
"""Test that LivePageProxy waits for page stability on async operations"""
64+
# Create a Stagehand instance
65+
stagehand = Stagehand(config=mock_stagehand_config)
66+
67+
# Track lock acquisition
68+
lock_acquired = False
69+
lock_released = False
70+
71+
class TestLock:
72+
async def __aenter__(self):
73+
nonlocal lock_acquired
74+
lock_acquired = True
75+
await asyncio.sleep(0.1) # Simulate some work
76+
return self
77+
78+
async def __aexit__(self, *args):
79+
nonlocal lock_released
80+
lock_released = True
81+
82+
stagehand._page_switch_lock = TestLock()
83+
84+
# Mock page with async method
85+
mock_page = MagicMock(spec=StagehandPage)
86+
mock_page.click = AsyncMock(return_value=None)
87+
88+
# Set up the pages
89+
stagehand._original_page = mock_page
90+
stagehand._active_page = mock_page
91+
stagehand._initialized = True
92+
93+
# Get the proxy
94+
proxy = stagehand.page
95+
96+
# Call an async method (should wait for stability)
97+
await proxy.click("button")
98+
99+
# Verify lock was acquired and released
100+
assert lock_acquired
101+
assert lock_released
102+
mock_page.click.assert_called_once_with("button")
103+
104+
105+
@pytest.mark.asyncio
106+
async def test_live_page_proxy_navigation_no_stability_check(mock_stagehand_config):
107+
"""Test that navigation methods don't wait for page stability"""
108+
# Create a Stagehand instance
109+
stagehand = Stagehand(config=mock_stagehand_config)
110+
111+
# Track lock acquisition (should not happen)
112+
lock_acquired = False
113+
114+
class TestLock:
115+
async def __aenter__(self):
116+
nonlocal lock_acquired
117+
lock_acquired = True
118+
return self
119+
120+
async def __aexit__(self, *args):
121+
pass
122+
123+
stagehand._page_switch_lock = TestLock()
124+
125+
# Mock page with navigation methods
126+
mock_page = MagicMock(spec=StagehandPage)
127+
mock_page.goto = AsyncMock(return_value=None)
128+
mock_page.reload = AsyncMock(return_value=None)
129+
mock_page.go_back = AsyncMock(return_value=None)
130+
mock_page.go_forward = AsyncMock(return_value=None)
131+
132+
# Set up the pages
133+
stagehand._original_page = mock_page
134+
stagehand._active_page = mock_page
135+
stagehand._initialized = True
136+
137+
# Get the proxy
138+
proxy = stagehand.page
139+
140+
# Call navigation methods (should NOT wait for stability)
141+
await proxy.goto("https://example.com")
142+
await proxy.reload()
143+
await proxy.go_back()
144+
await proxy.go_forward()
145+
146+
# Verify lock was NOT acquired
147+
assert not lock_acquired
148+
149+
# Verify methods were called
150+
mock_page.goto.assert_called_once_with("https://example.com")
151+
mock_page.reload.assert_called_once()
152+
mock_page.go_back.assert_called_once()
153+
mock_page.go_forward.assert_called_once()
154+
155+
156+
@pytest.mark.asyncio
157+
async def test_live_page_proxy_dynamic_page_switching(mock_stagehand_config):
158+
"""Test that LivePageProxy dynamically switches between pages"""
159+
# Create a Stagehand instance
160+
stagehand = Stagehand(config=mock_stagehand_config)
161+
162+
# Mock pages
163+
page1 = MagicMock(spec=StagehandPage)
164+
page1.url = "https://page1.com"
165+
166+
page2 = MagicMock(spec=StagehandPage)
167+
page2.url = "https://page2.com"
168+
169+
# Set up initial state
170+
stagehand._original_page = page1
171+
stagehand._active_page = page1
172+
stagehand._initialized = True
173+
174+
# Get the proxy
175+
proxy = stagehand.page
176+
177+
# Initially points to page1
178+
assert proxy.url == "https://page1.com"
179+
180+
# Switch active page
181+
stagehand._active_page = page2
182+
183+
# Now points to page2 without creating a new proxy
184+
assert proxy.url == "https://page2.com"
185+
186+
187+
def test_live_page_proxy_no_page_error(mock_stagehand_config):
188+
"""Test that LivePageProxy raises error when no page is available"""
189+
# Create a Stagehand instance
190+
stagehand = Stagehand(config=mock_stagehand_config)
191+
192+
# No pages set
193+
stagehand._original_page = None
194+
stagehand._active_page = None
195+
stagehand._initialized = True
196+
197+
# Get the proxy
198+
proxy = stagehand.page
199+
200+
# Accessing attributes should raise RuntimeError
201+
with pytest.raises(RuntimeError, match="No active page available"):
202+
_ = proxy.url
203+
204+
205+
def test_live_page_proxy_not_initialized(mock_stagehand_config):
206+
"""Test that page property returns None when not initialized"""
207+
# Create a Stagehand instance
208+
stagehand = Stagehand(config=mock_stagehand_config)
209+
stagehand._initialized = False
210+
211+
# Should return None
212+
assert stagehand.page is None

0 commit comments

Comments
 (0)