kolega-code 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.
Files changed (171) hide show
  1. kolega_code/__init__.py +151 -0
  2. kolega_code/agent/__init__.py +42 -0
  3. kolega_code/agent/baseagent.py +998 -0
  4. kolega_code/agent/browseragent.py +123 -0
  5. kolega_code/agent/coder.py +157 -0
  6. kolega_code/agent/common.py +41 -0
  7. kolega_code/agent/compression.py +81 -0
  8. kolega_code/agent/context.py +112 -0
  9. kolega_code/agent/conversation.py +408 -0
  10. kolega_code/agent/generalagent.py +146 -0
  11. kolega_code/agent/investigationagent.py +123 -0
  12. kolega_code/agent/planningagent.py +187 -0
  13. kolega_code/agent/prompt_provider.py +196 -0
  14. kolega_code/agent/prompt_templates/agents/browser.j2 +102 -0
  15. kolega_code/agent/prompt_templates/agents/coder_cli_mode.j2 +127 -0
  16. kolega_code/agent/prompt_templates/agents/general.j2 +68 -0
  17. kolega_code/agent/prompt_templates/agents/investigation.j2 +72 -0
  18. kolega_code/agent/prompt_templates/common/frontend_guidance.md +36 -0
  19. kolega_code/agent/prompt_templates/common/kolega_md_instructions.md +14 -0
  20. kolega_code/agent/prompt_templates/environment_variables/workspace_env_vars.md +11 -0
  21. kolega_code/agent/prompt_templates/template_guidance/expo-template.md +379 -0
  22. kolega_code/agent/prompt_templates/template_guidance/html-website-template.md +3 -0
  23. kolega_code/agent/prompt_templates/template_guidance/mern-stack-template.md +3 -0
  24. kolega_code/agent/prompt_templates/template_guidance/react-vite-shadcdn-template.md +182 -0
  25. kolega_code/agent/prompts.py +192 -0
  26. kolega_code/agent/tests/__init__.py +0 -0
  27. kolega_code/agent/tests/llm/__init__.py +0 -0
  28. kolega_code/agent/tests/llm/test_anthropic_token_counting.py +633 -0
  29. kolega_code/agent/tests/llm/test_billing_openai_cache.py +74 -0
  30. kolega_code/agent/tests/llm/test_client.py +773 -0
  31. kolega_code/agent/tests/llm/test_dashscope_mapping.py +32 -0
  32. kolega_code/agent/tests/llm/test_error_boundary.py +322 -0
  33. kolega_code/agent/tests/llm/test_exceptions.py +249 -0
  34. kolega_code/agent/tests/llm/test_instrumented_client.py +536 -0
  35. kolega_code/agent/tests/llm/test_instrumented_client_integration.py +547 -0
  36. kolega_code/agent/tests/llm/test_langfuse_normalization.py +39 -0
  37. kolega_code/agent/tests/llm/test_model_specs.py +17 -0
  38. kolega_code/agent/tests/llm/test_openai_cached_tokens.py +58 -0
  39. kolega_code/agent/tests/llm/test_openai_cached_tokens_stream.py +74 -0
  40. kolega_code/agent/tests/llm/test_openai_message_conversion.py +30 -0
  41. kolega_code/agent/tests/llm/test_openai_token_counting.py +687 -0
  42. kolega_code/agent/tests/llm/test_tool_execution_ids.py +193 -0
  43. kolega_code/agent/tests/services/__init__.py +1 -0
  44. kolega_code/agent/tests/services/test_browser.py +447 -0
  45. kolega_code/agent/tests/services/test_browser_parity.py +353 -0
  46. kolega_code/agent/tests/services/test_file_system.py +699 -0
  47. kolega_code/agent/tests/services/test_sandbox_terminal_input.py +98 -0
  48. kolega_code/agent/tests/services/test_terminal.py +154 -0
  49. kolega_code/agent/tests/services/test_terminal_command_tracking.py +385 -0
  50. kolega_code/agent/tests/services/test_terminal_state_serializer.py +262 -0
  51. kolega_code/agent/tests/test_agent_tools_inventory.py +267 -0
  52. kolega_code/agent/tests/test_base_agent.py +1942 -0
  53. kolega_code/agent/tests/test_coder_attachments.py +330 -0
  54. kolega_code/agent/tests/test_coder_prompt_extensions.py +61 -0
  55. kolega_code/agent/tests/test_commands.py +179 -0
  56. kolega_code/agent/tests/test_duplicate_tool_results.py +556 -0
  57. kolega_code/agent/tests/test_empty_message_handling.py +48 -0
  58. kolega_code/agent/tests/test_general_agent.py +242 -0
  59. kolega_code/agent/tests/test_html.py +320 -0
  60. kolega_code/agent/tests/test_parallel_tool_calls.py +291 -0
  61. kolega_code/agent/tests/test_planning_agent.py +227 -0
  62. kolega_code/agent/tests/test_prompt_provider.py +271 -0
  63. kolega_code/agent/tests/test_tool_registry.py +102 -0
  64. kolega_code/agent/tests/test_tools.py +549 -0
  65. kolega_code/agent/tests/tool_backend/__init__.py +0 -0
  66. kolega_code/agent/tests/tool_backend/test_agent_tool.py +356 -0
  67. kolega_code/agent/tests/tool_backend/test_base_tool.py +147 -0
  68. kolega_code/agent/tests/tool_backend/test_browser_tool.py +335 -0
  69. kolega_code/agent/tests/tool_backend/test_build_tool.py +93 -0
  70. kolega_code/agent/tests/tool_backend/test_create_file_tool.py +115 -0
  71. kolega_code/agent/tests/tool_backend/test_glob_tool.py +196 -0
  72. kolega_code/agent/tests/tool_backend/test_glob_tool_sandbox_parity.py +230 -0
  73. kolega_code/agent/tests/tool_backend/test_list_directory_tool.py +292 -0
  74. kolega_code/agent/tests/tool_backend/test_read_file_tool.py +173 -0
  75. kolega_code/agent/tests/tool_backend/test_replace_entire_file_tool.py +115 -0
  76. kolega_code/agent/tests/tool_backend/test_replace_lines_tool.py +141 -0
  77. kolega_code/agent/tests/tool_backend/test_search_and_replace_tool.py +174 -0
  78. kolega_code/agent/tests/tool_backend/test_search_codebase_tool.py +228 -0
  79. kolega_code/agent/tests/tool_backend/test_terminal_tool.py +482 -0
  80. kolega_code/agent/tests/tool_backend/test_think_hard_integration.py +189 -0
  81. kolega_code/agent/tests/tool_backend/test_think_hard_streaming.py +445 -0
  82. kolega_code/agent/tests/tool_backend/test_web_fetch_tool.py +194 -0
  83. kolega_code/agent/tool_backend/agent_tool.py +414 -0
  84. kolega_code/agent/tool_backend/apply_edit_tool.py +98 -0
  85. kolega_code/agent/tool_backend/apply_patch_tool.py +514 -0
  86. kolega_code/agent/tool_backend/base_tool.py +217 -0
  87. kolega_code/agent/tool_backend/browser_tool.py +271 -0
  88. kolega_code/agent/tool_backend/build_tool.py +93 -0
  89. kolega_code/agent/tool_backend/create_file_tool.py +52 -0
  90. kolega_code/agent/tool_backend/glob_tool.py +323 -0
  91. kolega_code/agent/tool_backend/list_directory_tool.py +300 -0
  92. kolega_code/agent/tool_backend/memory_tool.py +79 -0
  93. kolega_code/agent/tool_backend/read_file_tool.py +119 -0
  94. kolega_code/agent/tool_backend/replace_entire_file_tool.py +40 -0
  95. kolega_code/agent/tool_backend/replace_lines_tool.py +97 -0
  96. kolega_code/agent/tool_backend/search_and_replace_tool.py +146 -0
  97. kolega_code/agent/tool_backend/search_codebase_tool.py +377 -0
  98. kolega_code/agent/tool_backend/streaming_tool.py +47 -0
  99. kolega_code/agent/tool_backend/terminal_tool.py +643 -0
  100. kolega_code/agent/tool_backend/think_hard_tool.py +211 -0
  101. kolega_code/agent/tool_backend/web_fetch_tool.py +205 -0
  102. kolega_code/agent/tools.py +1704 -0
  103. kolega_code/agent/utils/commands.py +94 -0
  104. kolega_code/cli/__init__.py +1 -0
  105. kolega_code/cli/app.py +2756 -0
  106. kolega_code/cli/config.py +280 -0
  107. kolega_code/cli/connection.py +49 -0
  108. kolega_code/cli/file_index.py +147 -0
  109. kolega_code/cli/main.py +564 -0
  110. kolega_code/cli/mentions.py +155 -0
  111. kolega_code/cli/messages.py +89 -0
  112. kolega_code/cli/provider_registry.py +96 -0
  113. kolega_code/cli/session_store.py +207 -0
  114. kolega_code/cli/settings.py +87 -0
  115. kolega_code/cli/skills.py +409 -0
  116. kolega_code/cli/slash_commands.py +108 -0
  117. kolega_code/cli/tests/__init__.py +1 -0
  118. kolega_code/cli/tests/test_app.py +4251 -0
  119. kolega_code/cli/tests/test_cli_config.py +171 -0
  120. kolega_code/cli/tests/test_connection.py +26 -0
  121. kolega_code/cli/tests/test_file_index.py +103 -0
  122. kolega_code/cli/tests/test_main.py +455 -0
  123. kolega_code/cli/tests/test_mentions.py +108 -0
  124. kolega_code/cli/tests/test_session_store.py +67 -0
  125. kolega_code/cli/tests/test_settings.py +62 -0
  126. kolega_code/cli/tests/test_skills.py +157 -0
  127. kolega_code/cli/tests/test_slash_commands.py +88 -0
  128. kolega_code/cli/theme.py +180 -0
  129. kolega_code/config.py +154 -0
  130. kolega_code/events.py +202 -0
  131. kolega_code/llm/client.py +300 -0
  132. kolega_code/llm/exceptions.py +285 -0
  133. kolega_code/llm/instrumented_client.py +520 -0
  134. kolega_code/llm/models.py +1368 -0
  135. kolega_code/llm/providers/__init__.py +0 -0
  136. kolega_code/llm/providers/anthropic.py +387 -0
  137. kolega_code/llm/providers/base.py +71 -0
  138. kolega_code/llm/providers/google.py +157 -0
  139. kolega_code/llm/providers/models.py +37 -0
  140. kolega_code/llm/providers/openai.py +363 -0
  141. kolega_code/llm/ratelimit.py +40 -0
  142. kolega_code/llm/specs.py +67 -0
  143. kolega_code/llm/tool_execution_ids.py +18 -0
  144. kolega_code/models/__init__.py +9 -0
  145. kolega_code/models/sandbox_terminal_state.py +47 -0
  146. kolega_code/runtime.py +50 -0
  147. kolega_code/sandbox/README.md +200 -0
  148. kolega_code/sandbox/__init__.py +21 -0
  149. kolega_code/sandbox/async_filesystem.py +475 -0
  150. kolega_code/sandbox/base.py +297 -0
  151. kolega_code/sandbox/browser.py +25 -0
  152. kolega_code/sandbox/event_loop.py +43 -0
  153. kolega_code/sandbox/filesystem.py +341 -0
  154. kolega_code/sandbox/local.py +118 -0
  155. kolega_code/sandbox/serializer.py +175 -0
  156. kolega_code/sandbox/terminal.py +868 -0
  157. kolega_code/sandbox/utils.py +216 -0
  158. kolega_code/services/base.py +255 -0
  159. kolega_code/services/browser.py +444 -0
  160. kolega_code/services/file_system.py +749 -0
  161. kolega_code/services/html.py +221 -0
  162. kolega_code/services/terminal.py +903 -0
  163. kolega_code/tools/__init__.py +22 -0
  164. kolega_code/tools/core.py +33 -0
  165. kolega_code/tools/definitions.py +81 -0
  166. kolega_code/tools/registry.py +73 -0
  167. kolega_code-0.1.0.dist-info/METADATA +157 -0
  168. kolega_code-0.1.0.dist-info/RECORD +171 -0
  169. kolega_code-0.1.0.dist-info/WHEEL +4 -0
  170. kolega_code-0.1.0.dist-info/entry_points.txt +2 -0
  171. kolega_code-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,242 @@
1
+ """Tests for GeneralAgent dispatch and the enriched sub-agent event contract."""
2
+
3
+ import asyncio
4
+ import os
5
+ import uuid
6
+ from types import SimpleNamespace
7
+ from unittest.mock import AsyncMock, patch
8
+
9
+ import pytest
10
+ from dotenv import load_dotenv
11
+
12
+ from kolega_code.agent.baseagent import BaseAgent
13
+ from kolega_code.config import AgentConfig, ModelConfig, ModelProvider, RateLimitConfig
14
+ from kolega_code.events import AgentConnectionManager
15
+ from kolega_code.llm.models import ToolCall
16
+ from kolega_code.agent.prompt_provider import AgentType, PromptContext, PromptProvider
17
+ from kolega_code.agent.tool_backend.agent_tool import AgentTool
18
+
19
+ # Load environment variables
20
+ load_dotenv()
21
+
22
+
23
+ @pytest.fixture
24
+ def agent_config():
25
+ return AgentConfig(
26
+ anthropic_api_key=os.getenv("ANTHROPIC_API_KEY", "test_key"),
27
+ openai_api_key="test-key",
28
+ long_context_config=ModelConfig(
29
+ provider=ModelProvider.ANTHROPIC,
30
+ model="claude-haiku-4-5-20251001",
31
+ rate_limits=RateLimitConfig(),
32
+ ),
33
+ fast_config=ModelConfig(
34
+ provider=ModelProvider.ANTHROPIC,
35
+ model="claude-haiku-4-5-20251001",
36
+ rate_limits=RateLimitConfig(),
37
+ ),
38
+ thinking_config=ModelConfig(
39
+ provider=ModelProvider.ANTHROPIC,
40
+ model="claude-haiku-4-5-20251001",
41
+ rate_limits=RateLimitConfig(),
42
+ thinking_tokens=1024,
43
+ ),
44
+ )
45
+
46
+
47
+ @pytest.fixture
48
+ def mock_connection_manager():
49
+ return AsyncMock(spec=AgentConnectionManager)
50
+
51
+
52
+ @pytest.fixture
53
+ def base_agent(tmp_path, mock_connection_manager, agent_config):
54
+ agent = BaseAgent(
55
+ project_path=tmp_path,
56
+ workspace_id="test_workspace",
57
+ thread_id=str(uuid.uuid4()),
58
+ connection_manager=mock_connection_manager,
59
+ config=agent_config,
60
+ )
61
+ agent.send_chat_message = AsyncMock()
62
+ agent.log_info = AsyncMock()
63
+ agent.log_error = AsyncMock()
64
+ return agent
65
+
66
+
67
+ class StubGeneralAgent:
68
+ """Stands in for GeneralAgent inside AgentTool._dispatch_agent."""
69
+
70
+ agent_name = "general-agent"
71
+
72
+ def __init__(self, **kwargs):
73
+ self.kwargs = kwargs
74
+ self.parent_tool_call_id = None
75
+ self.conversation_id = None
76
+ self.sub_agent_context = None
77
+
78
+ async def process_message_stream(self, task):
79
+ yield {"type": "response", "content": "working on it", "complete": False, "uuid": "stream-1"}
80
+ await asyncio.sleep(0)
81
+ yield {"type": "response", "content": " done", "complete": True, "uuid": "stream-1"}
82
+
83
+ def dump_message_history(self):
84
+ return []
85
+
86
+ async def recap_agent_outcome(self):
87
+ return "final report"
88
+
89
+
90
+ class RecordingRecorder:
91
+ def __init__(self):
92
+ self.started = []
93
+
94
+ async def start_conversation(self, payload):
95
+ self.started.append(payload)
96
+ return f"conv-{len(self.started)}"
97
+
98
+
99
+ def make_agent_tool(tmp_path, mock_connection_manager, agent_config, caller):
100
+ return AgentTool(
101
+ project_path=tmp_path,
102
+ workspace_id="test_workspace",
103
+ thread_id=str(uuid.uuid4()),
104
+ connection_manager=mock_connection_manager,
105
+ config=agent_config,
106
+ caller=caller,
107
+ )
108
+
109
+
110
+ class TestDispatchGeneralAgent:
111
+ @pytest.mark.asyncio
112
+ async def test_dispatch_general_agent_happy_path(self, tmp_path, mock_connection_manager, agent_config, base_agent):
113
+ agent_tool = make_agent_tool(tmp_path, mock_connection_manager, agent_config, base_agent)
114
+
115
+ with patch("kolega_code.agent.generalagent.GeneralAgent", StubGeneralAgent):
116
+ base_agent.current_tool_execution_id = "exec_123"
117
+ result = await agent_tool.dispatch_general_agent("write a haiku")
118
+
119
+ assert result == "final report"
120
+ assert agent_tool.agents == {} # cleaned up
121
+
122
+ # Every broadcast event carries enriched sub_agent_info
123
+ events = [call.args[0] for call in mock_connection_manager.broadcast_event.call_args_list]
124
+ assert events, "expected broadcast events"
125
+ infos = [e.sub_agent_info for e in events if e.sub_agent_info]
126
+ assert infos, "expected sub_agent_info on dispatch events"
127
+ for info in infos:
128
+ assert info["agent_name"] == "general-agent"
129
+ assert info["task"] == "write a haiku"
130
+ assert info["agent_id"]
131
+ assert info["parent_tool_call_id"] == "exec_123"
132
+
133
+ # Lifecycle events: GENERATING then STOPPED, both with sub_agent_info
134
+ statuses = [e.content.get("status") for e in events if e.content.get("status")]
135
+ assert statuses == ["GENERATING", "STOPPED"]
136
+ for event in events:
137
+ if event.content.get("status"):
138
+ assert event.sub_agent_info is not None
139
+
140
+ @pytest.mark.asyncio
141
+ async def test_dispatch_failure_emits_error_status(
142
+ self, tmp_path, mock_connection_manager, agent_config, base_agent
143
+ ):
144
+ class ExplodingAgent(StubGeneralAgent):
145
+ async def process_message_stream(self, task):
146
+ raise RuntimeError("boom")
147
+ yield # pragma: no cover
148
+
149
+ agent_tool = make_agent_tool(tmp_path, mock_connection_manager, agent_config, base_agent)
150
+
151
+ with patch("kolega_code.agent.generalagent.GeneralAgent", ExplodingAgent):
152
+ with pytest.raises(RuntimeError, match="boom"):
153
+ await agent_tool.dispatch_general_agent("explode")
154
+
155
+ events = [call.args[0] for call in mock_connection_manager.broadcast_event.call_args_list]
156
+ statuses = [e.content.get("status") for e in events if e.content.get("status")]
157
+ assert statuses == ["GENERATING", "ERROR"]
158
+ assert agent_tool.agents == {}
159
+
160
+ @pytest.mark.asyncio
161
+ async def test_parallel_dispatches_record_distinct_parent_tool_call_ids(
162
+ self, tmp_path, mock_connection_manager, agent_config, base_agent
163
+ ):
164
+ recorder = RecordingRecorder()
165
+ base_agent.sub_agent_recorder = recorder
166
+ agent_tool = make_agent_tool(tmp_path, mock_connection_manager, agent_config, base_agent)
167
+
168
+ class Tools:
169
+ def get_tool_list(self):
170
+ return [SimpleNamespace(name="dispatch_general_agent")]
171
+
172
+ def registry(self):
173
+ from kolega_code.agent.tools import ToolCollection
174
+ from kolega_code.llm.models import ToolDefinition
175
+ from kolega_code.tools import Tool, ToolRegistry
176
+
177
+ parallel = set(ToolCollection.read_only_tools) | set(ToolCollection.agent_dispatch_tools)
178
+ registry = ToolRegistry()
179
+ for spec in self.get_tool_list():
180
+ registry.add(
181
+ Tool(
182
+ name=spec.name,
183
+ definition=ToolDefinition(name=spec.name, description="", parameters=[]),
184
+ handler=getattr(self, spec.name),
185
+ parallel_safe=spec.name in parallel,
186
+ )
187
+ )
188
+ return registry
189
+
190
+ async def dispatch_general_agent(self, task: str):
191
+ return await agent_tool.dispatch_general_agent(task)
192
+
193
+ base_agent.tool_collection = Tools()
194
+
195
+ blocks = [
196
+ ToolCall(
197
+ id=f"call_{i}",
198
+ name="dispatch_general_agent",
199
+ input={"task": f"task {i}"},
200
+ execution_id=f"exec_{i}",
201
+ )
202
+ for i in range(2)
203
+ ]
204
+
205
+ with patch("kolega_code.agent.generalagent.GeneralAgent", StubGeneralAgent):
206
+ results = await base_agent.process_tool_calls(blocks)
207
+
208
+ assert [r.content for r in results] == ["final report", "final report"]
209
+ recorded_ids = {payload["parent_tool_call_id"] for payload in recorder.started}
210
+ assert recorded_ids == {"exec_0", "exec_1"}
211
+ recorded_tasks = {payload["initial_task"] for payload in recorder.started}
212
+ assert recorded_tasks == {"task 0", "task 1"}
213
+
214
+ @pytest.mark.asyncio
215
+ async def test_dispatch_sets_sub_agent_context_on_agent(
216
+ self, tmp_path, mock_connection_manager, agent_config, base_agent
217
+ ):
218
+ created = {}
219
+
220
+ class CapturingAgent(StubGeneralAgent):
221
+ def __init__(self, **kwargs):
222
+ super().__init__(**kwargs)
223
+ created["agent"] = self
224
+
225
+ agent_tool = make_agent_tool(tmp_path, mock_connection_manager, agent_config, base_agent)
226
+
227
+ with patch("kolega_code.agent.generalagent.GeneralAgent", CapturingAgent):
228
+ base_agent.current_tool_execution_id = "exec_ctx"
229
+ await agent_tool.dispatch_general_agent("capture context")
230
+
231
+ context = created["agent"].sub_agent_context
232
+ assert context["agent_name"] == "general-agent"
233
+ assert context["task"] == "capture context"
234
+ assert context["parent_tool_call_id"] == "exec_ctx"
235
+ assert context["agent_id"]
236
+
237
+
238
+ def test_general_agent_prompt_renders():
239
+ prompt = PromptProvider().get_system_prompt(agent_type=AgentType.GENERAL, context=PromptContext())
240
+ assert prompt
241
+ assert "FINAL message" in prompt
242
+ assert "general-purpose sub-agent" in prompt
@@ -0,0 +1,320 @@
1
+ import pytest
2
+ from bs4 import BeautifulSoup
3
+
4
+ from kolega_code.services.html import build_css_selector, extract_interactive_elements_from_html, get_associated_text
5
+
6
+
7
+ @pytest.fixture
8
+ def simple_html():
9
+ return """
10
+ <html>
11
+ <body>
12
+ <div id="content">
13
+ <button id="submit-btn" class="primary" onclick="submitForm()">Submit</button>
14
+ <a href="/about" class="nav-link">About Us</a>
15
+ <input id="search" type="text" placeholder="Search..." name="q">
16
+ <input id="password" type="password" value="secret">
17
+ <label for="email">Email:</label>
18
+ <input id="email" type="email" name="email">
19
+ <select id="country" name="country">
20
+ <option value="us">United States</option>
21
+ <option value="ca">Canada</option>
22
+ <option value="mx">Mexico</option>
23
+ </select>
24
+ <div role="button" data-target="modal">Open Modal</div>
25
+ <textarea id="message" placeholder="Enter your message"></textarea>
26
+ <div contenteditable="true">Editable content</div>
27
+ </div>
28
+ </body>
29
+ </html>
30
+ """
31
+
32
+
33
+ @pytest.fixture
34
+ def complex_html():
35
+ return """
36
+ <html>
37
+ <body>
38
+ <nav>
39
+ <a href="/" class="home-link">Home</a>
40
+ <a href="/products" class="nav-link">Products</a>
41
+ <a href="/contact" class="nav-link">Contact</a>
42
+ </nav>
43
+ <div id="main">
44
+ <form id="contact-form">
45
+ <div class="form-group">
46
+ <label for="name">Name:</label>
47
+ <input id="name" type="text" name="name" placeholder="Your name">
48
+ </div>
49
+ <div class="form-group">
50
+ <label for="message">Message:</label>
51
+ <textarea id="message" name="message" placeholder="Your message"></textarea>
52
+ </div>
53
+ <div class="form-group">
54
+ <label for="country">Country:</label>
55
+ <select id="country" name="country">
56
+ <option value="">Select country</option>
57
+ <option value="us">United States</option>
58
+ <option value="uk">United Kingdom</option>
59
+ </select>
60
+ </div>
61
+ <div class="actions">
62
+ <button type="submit" class="btn-primary">Send</button>
63
+ <button type="reset" class="btn-secondary">Reset</button>
64
+ </div>
65
+ </form>
66
+ <div class="modal" aria-hidden="true">
67
+ <div class="modal-content">
68
+ <button class="close" aria-label="Close" data-toggle="modal">×</button>
69
+ <h2>Subscribe to Newsletter</h2>
70
+ <p>Get weekly updates about our products!</p>
71
+ <div role="button" class="subscribe-btn" onclick="subscribe()">Subscribe Now</div>
72
+ </div>
73
+ </div>
74
+ <details>
75
+ <summary>Read more</summary>
76
+ <p>Additional information about our company.</p>
77
+ </details>
78
+ </div>
79
+ </body>
80
+ </html>
81
+ """
82
+
83
+
84
+ class TestExtractInteractiveElements:
85
+ def test_extract_all_element_types(self, simple_html):
86
+ results = extract_interactive_elements_from_html(simple_html)
87
+
88
+ # Check if we have the expected number of elements
89
+ assert len(results) >= 9 # At least all the interactive elements we added
90
+
91
+ # Check if we have all expected element types
92
+ element_types = [result["element_type"] for result in results]
93
+ assert "button" in element_types
94
+ assert "a" in element_types
95
+ assert "input" in element_types
96
+ assert "select" in element_types
97
+ assert "div" in element_types
98
+ assert "textarea" in element_types
99
+
100
+ # Check if specific elements are found
101
+ submit_button = next((el for el in results if el["attributes"].get("id") == "submit-btn"), None)
102
+ assert submit_button is not None
103
+ assert submit_button["text"] == "Submit"
104
+ assert "primary" in submit_button["attributes"].get("class", [])
105
+
106
+ # Check link is found
107
+ about_link = next((el for el in results if el["text"] == "About Us"), None)
108
+ assert about_link is not None
109
+ assert about_link["attributes"].get("href") == "/about"
110
+
111
+ # Check select element has its options text
112
+ country_select = next((el for el in results if el["attributes"].get("id") == "country"), None)
113
+ assert country_select is not None
114
+ assert "United States" in country_select["text"]
115
+ assert "Canada" in country_select["text"]
116
+ assert "Mexico" in country_select["text"]
117
+
118
+ # Check div with role=button is found
119
+ modal_button = next((el for el in results if el["attributes"].get("data-target") == "modal"), None)
120
+ assert modal_button is not None
121
+ assert modal_button["text"] == "Open Modal"
122
+
123
+ def test_extract_from_complex_html(self, complex_html):
124
+ results = extract_interactive_elements_from_html(complex_html)
125
+
126
+ # Check if nav links are found
127
+ nav_links = [el for el in results if el["element_type"] == "a"]
128
+ assert len(nav_links) >= 3
129
+
130
+ # Check if form elements are found
131
+ form_inputs = [el for el in results if el["element_type"] == "input"]
132
+ assert len(form_inputs) >= 1
133
+
134
+ # Check if buttons are found
135
+ buttons = [el for el in results if el["element_type"] == "button"]
136
+ assert len(buttons) >= 3
137
+
138
+ # Check if role=button is found
139
+ role_buttons = [el for el in results if el["attributes"].get("role") == "button"]
140
+ assert len(role_buttons) >= 1
141
+
142
+ # Check if details/summary elements are found
143
+ details_elements = [el for el in results if el["element_type"] in ("details", "summary")]
144
+ assert len(details_elements) >= 1
145
+
146
+ def test_handles_empty_html(self):
147
+ results = extract_interactive_elements_from_html("")
148
+ assert len(results) == 0
149
+
150
+ results = extract_interactive_elements_from_html("<html><body></body></html>")
151
+ assert len(results) == 0
152
+
153
+ def test_handles_malformed_html(self):
154
+ malformed_html = "<button>Incomplete Button<div>Not closed properly"
155
+ results = extract_interactive_elements_from_html(malformed_html)
156
+
157
+ # Even with malformed HTML, BeautifulSoup should find the button
158
+ assert len(results) >= 1
159
+ assert any(result["element_type"] == "button" for result in results)
160
+
161
+
162
+ class TestGetAssociatedText:
163
+ def test_get_text_from_button(self):
164
+ soup = BeautifulSoup("<button>Click Me</button>", "html.parser")
165
+ element = soup.find("button")
166
+ text = get_associated_text(element)
167
+ assert text == "Click Me"
168
+
169
+ # Test with value attribute
170
+ soup = BeautifulSoup('<button value="Submit">Click Me</button>', "html.parser")
171
+ element = soup.find("button")
172
+ text = get_associated_text(element)
173
+ assert text == "Click Me" # Text content takes precedence
174
+
175
+ # Test with empty button
176
+ soup = BeautifulSoup("<button></button>", "html.parser")
177
+ element = soup.find("button")
178
+ text = get_associated_text(element)
179
+ assert text == ""
180
+
181
+ def test_get_text_from_input(self):
182
+ # Test with placeholder
183
+ soup = BeautifulSoup('<input placeholder="Enter name">', "html.parser")
184
+ element = soup.find("input")
185
+ text = get_associated_text(element)
186
+ assert text == "Enter name"
187
+
188
+ # Test with value (non-password)
189
+ soup = BeautifulSoup('<input type="text" value="Default text">', "html.parser")
190
+ element = soup.find("input")
191
+ text = get_associated_text(element)
192
+ assert text == "Default text"
193
+
194
+ # Test with password type (should not return value)
195
+ soup = BeautifulSoup('<input type="password" value="secret">', "html.parser")
196
+ element = soup.find("input")
197
+ text = get_associated_text(element)
198
+ assert text == ""
199
+
200
+ # Test with associated label
201
+ soup = BeautifulSoup('<label for="username">Username:</label><input id="username">', "html.parser")
202
+ element = soup.find("input")
203
+ text = get_associated_text(element)
204
+ assert text == "Username:"
205
+
206
+ def test_get_text_from_link(self):
207
+ soup = BeautifulSoup('<a href="/page">Visit Page</a>', "html.parser")
208
+ element = soup.find("a")
209
+ text = get_associated_text(element)
210
+ assert text == "Visit Page"
211
+
212
+ # Test with title attribute
213
+ soup = BeautifulSoup('<a href="/page" title="Visit our page"></a>', "html.parser")
214
+ element = soup.find("a")
215
+ text = get_associated_text(element)
216
+ assert text == "Visit our page"
217
+
218
+ def test_get_text_from_select(self):
219
+ soup = BeautifulSoup(
220
+ """
221
+ <select>
222
+ <option>Option 1</option>
223
+ <option>Option 2</option>
224
+ </select>
225
+ """,
226
+ "html.parser",
227
+ )
228
+ element = soup.find("select")
229
+ text = get_associated_text(element)
230
+ assert "Option 1" in text
231
+ assert "Option 2" in text
232
+
233
+ def test_get_text_from_generic_element(self):
234
+ soup = BeautifulSoup("<div>Some text</div>", "html.parser")
235
+ element = soup.find("div")
236
+ text = get_associated_text(element)
237
+ assert text == "Some text"
238
+
239
+
240
+ class TestBuildCssSelector:
241
+ def test_selector_with_id(self):
242
+ soup = BeautifulSoup('<div id="unique-id">Content</div>', "html.parser")
243
+ element = soup.find("div")
244
+ selector = build_css_selector(element)
245
+ assert selector == "#unique-id"
246
+
247
+ def test_selector_with_class(self):
248
+ # Simple class
249
+ soup = BeautifulSoup('<div class="unique-class">Content</div>', "html.parser")
250
+ element = soup.find("div")
251
+ selector = build_css_selector(element)
252
+ assert "div:nth-child" in selector
253
+
254
+ # Multiple classes
255
+ soup = BeautifulSoup('<div class="class1 class2">Content</div>', "html.parser")
256
+ element = soup.find("div")
257
+ selector = build_css_selector(element)
258
+ assert "div:nth-child" in selector
259
+
260
+ def test_selector_with_attributes(self):
261
+ soup = BeautifulSoup('<input name="username" type="text">', "html.parser")
262
+ element = soup.find("input")
263
+ selector = build_css_selector(element)
264
+ assert 'input[name="username"]' == selector
265
+
266
+ # Test with data attribute
267
+ soup = BeautifulSoup('<div data-testid="test-div">Content</div>', "html.parser")
268
+ element = soup.find("div")
269
+ selector = build_css_selector(element)
270
+ assert selector == 'div[data-testid="test-div"]'
271
+
272
+ def test_selector_with_nth_child(self):
273
+ soup = BeautifulSoup(
274
+ """
275
+ <div>
276
+ <p>First paragraph</p>
277
+ <p>Second paragraph</p>
278
+ <p>Third paragraph</p>
279
+ </div>
280
+ """,
281
+ "html.parser",
282
+ )
283
+ elements = soup.find_all("p")
284
+
285
+ # Second paragraph should use nth-child
286
+ selector = build_css_selector(elements[1])
287
+ assert "p:nth-child(2)" in selector
288
+
289
+ def test_handles_special_characters(self):
290
+ # Test with single quotes in attribute
291
+ soup = BeautifulSoup('<div data-value="It\'s a test">Content</div>', "html.parser")
292
+ element = soup.find("div")
293
+ selector = build_css_selector(element)
294
+ assert ":nth-child" in selector
295
+
296
+ # Test with double quotes in attribute
297
+ soup = BeautifulSoup("<div data-value='Say \"Hello\"'>Content</div>", "html.parser")
298
+ element = soup.find("div")
299
+ selector = build_css_selector(element)
300
+ assert ":nth-child" in selector
301
+
302
+ def test_handles_tailwind_classes(self):
303
+ # Test with Tailwind-style class names containing colons
304
+ soup = BeautifulSoup(
305
+ '<div class="text-gray-200 hover:text-cyan-400 font-medium px-2">Tailwind styled div</div>', "html.parser"
306
+ )
307
+ element = soup.find("div")
308
+ selector = build_css_selector(element)
309
+
310
+ # The selector should either be the escaped class selector or fallback to a valid selector
311
+ try:
312
+ # Try to use the selector to verify it's valid
313
+ soup.select(selector)
314
+
315
+ # Check if it's using the class-based selector with escaped colons
316
+ if selector.startswith("."):
317
+ assert "\\:" in selector
318
+ except Exception:
319
+ # If it fell back to nth-child, that's acceptable too
320
+ assert ":nth-child" in selector