Skyvern/tests/unit/test_navigation_in_forloop_codegen.py

180 lines
8 KiB
Python

"""Tests for loop blocks using dynamic selectors via context.loop_item_selector().
Validates that navigation and download blocks inside for-loops generate
context.loop_item_selector()-based code instead of hardcoded xpaths
from iteration 0's execution trace.
"""
from unittest.mock import MagicMock, patch
import libcst as cst
from skyvern.core.script_generations.generate_script import _build_block_fn
from skyvern.core.script_generations.skyvern_page import RunContext
def _make_navigation_block(label: str = "navigate_to_page") -> dict:
return {
"label": label,
"block_type": "navigation",
"url": "https://example.com",
"navigation_goal": "Click on the document link",
}
def _make_click_action() -> dict:
return {
"action_type": "click",
"xpath": "/html/body/div/a[3]",
"element_id": "elem_123",
"reasoning": "Click on target link",
}
class TestNavigationInForLoopCodegen:
def test_navigation_in_loop_uses_loop_item_selector(self) -> None:
"""Navigation blocks inside for-loops should use context.loop_item_selector()
instead of hardcoded xpath from iteration 0."""
block = _make_navigation_block()
actions = [_make_click_action()]
fn_def = _build_block_fn(block, actions, is_in_for_loop=True)
code = cst.Module(body=[fn_def]).code
assert "context.loop_item_selector()" in code
assert "context.prompt" in code
assert 'ai="fallback"' in code
# Should NOT contain the hardcoded xpath from the action
assert "xpath=" not in code
def test_navigation_outside_loop_uses_hardcoded_selectors(self) -> None:
"""Navigation blocks NOT in a for-loop should still use action-based xpaths."""
block = _make_navigation_block()
actions = [_make_click_action()]
fn_def = _build_block_fn(block, actions, is_in_for_loop=False)
code = cst.Module(body=[fn_def]).code
# Should contain the hardcoded xpath from the action trace
assert "xpath=" in code
# Should NOT use dynamic selector
assert "loop_item_selector" not in code
def test_download_in_loop_uses_loop_item_selector(self) -> None:
"""file_download blocks in loops should also use loop_item_selector()."""
block = {
"label": "download_files",
"block_type": "file_download",
"url": "https://example.com",
}
actions = [_make_click_action()]
fn_def = _build_block_fn(block, actions, is_in_for_loop=True)
code = cst.Module(body=[fn_def]).code
assert "context.loop_item_selector()" in code
assert "context.prompt" in code
def test_extraction_in_loop_not_affected(self) -> None:
"""Extraction blocks in loops should NOT use download_selector (they extract data, not click links)."""
block = {
"label": "extract_documents",
"block_type": "extraction",
"data_extraction_goal": "Extract document titles",
}
actions = [
{
"action_type": "extract",
"data_extraction_goal": "Extract document titles",
"data": {"titles": ["doc1"]},
}
]
fn_def = _build_block_fn(block, actions, is_in_for_loop=True)
code = cst.Module(body=[fn_def]).code
assert "loop_item_selector" not in code
class TestLoopItemSelector:
"""Direct unit tests for RunContext.loop_item_selector()."""
def test_url_path_without_extension(self) -> None:
"""Relative paths like /reports/some-report-2024 should use href matching."""
ctx = RunContext(parameters={}, page=MagicMock(), generated_parameters={})
mock_skyvern_ctx = MagicMock()
mock_skyvern_ctx.loop_metadata = {"current_value": {"title": "Some Report", "uid": "/reports/some-report-2024"}}
with patch("skyvern.core.script_generations.skyvern_page.skyvern_context") as mock_mod:
mock_mod.current.return_value = mock_skyvern_ctx
result = ctx.loop_item_selector()
assert result == 'a[href*="some-report-2024"]'
def test_full_url_with_path(self) -> None:
"""Full URLs should extract the last path segment."""
ctx = RunContext(parameters={}, page=MagicMock(), generated_parameters={})
mock_skyvern_ctx = MagicMock()
mock_skyvern_ctx.loop_metadata = {
"current_value": {"title": "Report", "url": "https://example.com/pub/annual-report-2025/"}
}
with patch("skyvern.core.script_generations.skyvern_page.skyvern_context") as mock_mod:
mock_mod.current.return_value = mock_skyvern_ctx
result = ctx.loop_item_selector()
assert result == 'a[href*="annual-report-2025"]'
def test_bare_domain_falls_to_text(self) -> None:
"""URLs with no meaningful path should NOT produce href selectors."""
ctx = RunContext(parameters={}, page=MagicMock(), generated_parameters={})
mock_skyvern_ctx = MagicMock()
mock_skyvern_ctx.loop_metadata = {
"current_value": {"title": "Annual Report 2024", "url": "https://www.example.gov"}
}
with patch("skyvern.core.script_generations.skyvern_page.skyvern_context") as mock_mod:
mock_mod.current.return_value = mock_skyvern_ctx
result = ctx.loop_item_selector()
assert result == 'a:has-text("Annual Report 2024")'
def test_url_with_file_extension(self) -> None:
"""URLs with file extensions should still work (path segment includes extension)."""
ctx = RunContext(parameters={}, page=MagicMock(), generated_parameters={})
mock_skyvern_ctx = MagicMock()
mock_skyvern_ctx.loop_metadata = {
"current_value": {"title": "Report", "url": "https://example.com/files/report.pdf"}
}
with patch("skyvern.core.script_generations.skyvern_page.skyvern_context") as mock_mod:
mock_mod.current.return_value = mock_skyvern_ctx
result = ctx.loop_item_selector()
assert result == 'a[href*="report.pdf"]'
def test_text_only_no_urls(self) -> None:
"""Plain text values should use text matching."""
ctx = RunContext(parameters={}, page=MagicMock(), generated_parameters={})
mock_skyvern_ctx = MagicMock()
mock_skyvern_ctx.loop_metadata = {"current_value": {"title": "Quarterly Update", "uid": "quarterly"}}
with patch("skyvern.core.script_generations.skyvern_page.skyvern_context") as mock_mod:
mock_mod.current.return_value = mock_skyvern_ctx
result = ctx.loop_item_selector()
assert result == 'a:has-text("Quarterly Update")'
def test_percent_encoded_path_kept_as_is(self) -> None:
"""Percent-encoded characters should be preserved — CSS a[href*=...] matches raw attribute values."""
ctx = RunContext(parameters={}, page=MagicMock(), generated_parameters={})
mock_skyvern_ctx = MagicMock()
mock_skyvern_ctx.loop_metadata = {
"current_value": {"title": "Report", "url": "https://example.com/files/Monthly%20Report%20Issue.pdf"}
}
with patch("skyvern.core.script_generations.skyvern_page.skyvern_context") as mock_mod:
mock_mod.current.return_value = mock_skyvern_ctx
result = ctx.loop_item_selector()
assert result == 'a[href*="Monthly%20Report%20Issue.pdf"]'
def test_short_path_segment_falls_to_text(self) -> None:
"""URLs with short path segments (< 3 chars) should fall through to text matching."""
ctx = RunContext(parameters={}, page=MagicMock(), generated_parameters={})
mock_skyvern_ctx = MagicMock()
mock_skyvern_ctx.loop_metadata = {
"current_value": {"title": "Some Document", "url": "https://example.com/go/42"}
}
with patch("skyvern.core.script_generations.skyvern_page.skyvern_context") as mock_mod:
mock_mod.current.return_value = mock_skyvern_ctx
result = ctx.loop_item_selector()
assert result == 'a:has-text("Some Document")'