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,193 @@
1
+ from kolega_code.llm.models import ContentBlock, Message, ToolCall
2
+ from kolega_code.llm.tool_execution_ids import ToolExecutionIdRegistry, new_tool_execution_id
3
+
4
+
5
+ def test_new_tool_execution_id_uses_internal_prefix_and_is_unique():
6
+ first = new_tool_execution_id()
7
+ second = new_tool_execution_id()
8
+
9
+ assert first.startswith("tool_exec_")
10
+ assert second.startswith("tool_exec_")
11
+ assert first != second
12
+
13
+
14
+ def test_tool_execution_id_registry_reuses_id_within_response():
15
+ registry = ToolExecutionIdRegistry()
16
+
17
+ first = registry.get_or_create("provider_tool_call_id")
18
+ second = registry.get_or_create("provider_tool_call_id")
19
+
20
+ assert first == second
21
+
22
+
23
+ def test_tool_execution_id_registry_is_response_scoped():
24
+ first_registry = ToolExecutionIdRegistry()
25
+ second_registry = ToolExecutionIdRegistry()
26
+
27
+ first = first_registry.get_or_create("provider_tool_call_id")
28
+ second = second_registry.get_or_create("provider_tool_call_id")
29
+
30
+ assert first != second
31
+
32
+
33
+ def test_tool_call_from_dict_preserves_existing_execution_id():
34
+ tool_call = ToolCall.from_dict(
35
+ {
36
+ "type": "tool_call",
37
+ "id": "provider_tool_call_id",
38
+ "name": "read_file",
39
+ "input": {"path": "README.md"},
40
+ "execution_id": "tool_exec_existing",
41
+ }
42
+ )
43
+
44
+ assert tool_call.id == "provider_tool_call_id"
45
+ assert tool_call.execution_id == "tool_exec_existing"
46
+
47
+
48
+ def test_tool_call_from_dict_generates_execution_id_for_legacy_records():
49
+ tool_call = ToolCall.from_dict(
50
+ {
51
+ "type": "tool_call",
52
+ "id": "provider_tool_call_id",
53
+ "name": "read_file",
54
+ "input": {"path": "README.md"},
55
+ }
56
+ )
57
+
58
+ assert tool_call.id == "provider_tool_call_id"
59
+ assert tool_call.execution_id.startswith("tool_exec_")
60
+
61
+
62
+ def test_message_from_anthropic_uses_supplied_execution_id_registry():
63
+ class ToolUseBlock:
64
+ type = "tool_use"
65
+ id = "provider_tool_call_id"
66
+ name = "read_file"
67
+ input = {"path": "README.md"}
68
+
69
+ class AnthropicMessage:
70
+ role = "assistant"
71
+ stop_reason = "tool_use"
72
+ content = [ToolUseBlock()]
73
+
74
+ registry = ToolExecutionIdRegistry()
75
+ execution_id = registry.get_or_create("provider_tool_call_id")
76
+
77
+ message = Message.from_anthropic(AnthropicMessage(), tool_execution_ids=registry)
78
+
79
+ assert message.tool_calls[0].id == "provider_tool_call_id"
80
+ assert message.tool_calls[0].execution_id == execution_id
81
+ assert message.content[0].execution_id == execution_id
82
+
83
+
84
+ def test_message_from_openai_uses_supplied_execution_id_registry():
85
+ class Function:
86
+ name = "read_file"
87
+ arguments = '{"path": "README.md"}'
88
+
89
+ class ToolCall:
90
+ id = "provider_tool_call_id"
91
+ function = Function()
92
+
93
+ class OpenAIMessage:
94
+ content = None
95
+ finish_reason = "tool_calls"
96
+ tool_calls = [ToolCall()]
97
+
98
+ registry = ToolExecutionIdRegistry()
99
+ execution_id = registry.get_or_create("provider_tool_call_id")
100
+
101
+ message = Message.from_openai(OpenAIMessage(), tool_execution_ids=registry)
102
+
103
+ assert message.tool_calls[0].id == "provider_tool_call_id"
104
+ assert message.tool_calls[0].execution_id == execution_id
105
+ assert message.content[0].execution_id == execution_id
106
+
107
+
108
+ def test_message_from_google_uses_supplied_execution_id_registry():
109
+ class Content:
110
+ parts = []
111
+
112
+ class Candidate:
113
+ content = Content()
114
+
115
+ class FunctionCall:
116
+ id = "provider_tool_call_id"
117
+ name = "read_file"
118
+ args = {"path": "README.md"}
119
+
120
+ class GoogleMessage:
121
+ candidates = [Candidate()]
122
+ function_calls = [FunctionCall()]
123
+ finish_reason = "STOP"
124
+
125
+ registry = ToolExecutionIdRegistry()
126
+ execution_id = registry.get_or_create("provider_tool_call_id")
127
+
128
+ message = Message.from_google(GoogleMessage(), tool_execution_ids=registry)
129
+
130
+ assert message.tool_calls[0].id == "provider_tool_call_id"
131
+ assert message.tool_calls[0].execution_id == execution_id
132
+ assert message.content == []
133
+
134
+
135
+ def test_message_from_openai_stream_uses_supplied_execution_id_registry():
136
+ class Function:
137
+ name = "read_file"
138
+ arguments = '{"path": "README.md"}'
139
+
140
+ class ToolCall:
141
+ id = "provider_tool_call_id"
142
+ function = Function()
143
+
144
+ registry = ToolExecutionIdRegistry()
145
+ execution_id = registry.get_or_create("provider_tool_call_id")
146
+
147
+ message = Message.from_openai_stream(
148
+ role="assistant",
149
+ content="",
150
+ tool_calls={0: ToolCall()},
151
+ stop_reason="tool_calls",
152
+ tool_execution_ids=registry,
153
+ )
154
+
155
+ assert message.tool_calls[0].id == "provider_tool_call_id"
156
+ assert message.tool_calls[0].execution_id == execution_id
157
+ assert message.content[0].execution_id == execution_id
158
+
159
+
160
+ def test_message_from_google_stream_uses_supplied_execution_id_registry():
161
+ class ToolCall:
162
+ id = "provider_tool_call_id"
163
+ name = "read_file"
164
+ args = {"path": "README.md"}
165
+
166
+ registry = ToolExecutionIdRegistry()
167
+ execution_id = registry.get_or_create("provider_tool_call_id")
168
+
169
+ message = Message.from_google_stream(
170
+ role="assistant",
171
+ content="",
172
+ tool_calls={0: ToolCall()},
173
+ stop_reason="STOP",
174
+ tool_execution_ids=registry,
175
+ )
176
+
177
+ assert message.tool_calls[0].id == "provider_tool_call_id"
178
+ assert message.tool_calls[0].execution_id == execution_id
179
+ assert message.content[0].execution_id == execution_id
180
+
181
+
182
+ def test_content_block_from_dict_generates_execution_id_for_legacy_tool_call():
183
+ block = ContentBlock.from_dict(
184
+ {
185
+ "type": "tool_call",
186
+ "id": "provider_tool_call_id",
187
+ "name": "read_file",
188
+ "input": {"path": "README.md"},
189
+ }
190
+ )
191
+
192
+ assert isinstance(block, ToolCall)
193
+ assert block.execution_id.startswith("tool_exec_")
@@ -0,0 +1 @@
1
+ # Services test package
@@ -0,0 +1,447 @@
1
+ import datetime
2
+ import os
3
+ import pytest
4
+ from unittest.mock import AsyncMock, MagicMock, patch
5
+ from kolega_code.services.browser import PlaywrightBrowserManager
6
+
7
+ # Check if running in CI environment
8
+ SKIP_IN_CI = bool(os.getenv("CI")) or bool(os.getenv("GITLAB_CI"))
9
+
10
+
11
+ class TestPlaywrightBrowserManager:
12
+ """Test suite for PlaywrightBrowserManager console log filtering functionality."""
13
+
14
+ @pytest.fixture
15
+ def browser_manager(self):
16
+ """Create a browser manager instance for testing."""
17
+ return PlaywrightBrowserManager()
18
+
19
+ @pytest.fixture
20
+ def mock_browser_info(self):
21
+ """Create mock browser info with sample console logs."""
22
+ now = datetime.datetime.now()
23
+ console_logs = [
24
+ {
25
+ "type": "log",
26
+ "text": "Regular log message 1",
27
+ "timestamp": (now - datetime.timedelta(minutes=10)).isoformat(),
28
+ "location": None,
29
+ },
30
+ {
31
+ "type": "error",
32
+ "text": "JavaScript error occurred",
33
+ "timestamp": (now - datetime.timedelta(minutes=8)).isoformat(),
34
+ "location": {"url": "test.js", "lineNumber": 42, "columnNumber": 10},
35
+ },
36
+ {
37
+ "type": "warning",
38
+ "text": "Deprecated API usage",
39
+ "timestamp": (now - datetime.timedelta(minutes=6)).isoformat(),
40
+ "location": None,
41
+ },
42
+ {
43
+ "type": "log",
44
+ "text": "Regular log message 2",
45
+ "timestamp": (now - datetime.timedelta(minutes=4)).isoformat(),
46
+ "location": None,
47
+ },
48
+ {
49
+ "type": "assert",
50
+ "text": "Assertion failed: condition not met",
51
+ "timestamp": (now - datetime.timedelta(minutes=2)).isoformat(),
52
+ "location": None,
53
+ },
54
+ {
55
+ "type": "info",
56
+ "text": "Information message",
57
+ "timestamp": now.isoformat(),
58
+ "location": None,
59
+ },
60
+ ]
61
+
62
+ return {
63
+ "type": "chromium",
64
+ "url": "https://example.com",
65
+ "console_logs": console_logs,
66
+ "launched_at": now.isoformat(),
67
+ }
68
+
69
+ @pytest.mark.asyncio
70
+ async def test_get_browser_console_logs_default_filtering(self, browser_manager, mock_browser_info):
71
+ """Test default console log filtering (errors, warnings, assertions only)."""
72
+ browser_id = "test-browser-id"
73
+ browser_manager.browsers[browser_id] = mock_browser_info
74
+
75
+ result = await browser_manager.get_browser_console_logs(browser_id)
76
+
77
+ assert result["total_logs_count"] == 6
78
+ assert result["returned_count"] == 3 # Only error, warning, assert
79
+ assert result["filters_applied"]["log_types"] == ["error", "warning", "assert"]
80
+ assert result["filters_applied"]["max_logs"] == 50
81
+ assert result["filters_applied"]["max_chars"] == 8000
82
+
83
+ # Check that only the correct log types are returned
84
+ returned_types = [log["type"] for log in result["console_logs"]]
85
+ assert "error" in returned_types
86
+ assert "warning" in returned_types
87
+ assert "assert" in returned_types
88
+ assert "log" not in returned_types
89
+ assert "info" not in returned_types
90
+
91
+ @pytest.mark.asyncio
92
+ async def test_get_browser_console_logs_custom_log_types(self, browser_manager, mock_browser_info):
93
+ """Test filtering by custom log types."""
94
+ browser_id = "test-browser-id"
95
+ browser_manager.browsers[browser_id] = mock_browser_info
96
+
97
+ result = await browser_manager.get_browser_console_logs(browser_id, log_types=["log", "info"])
98
+
99
+ assert result["total_logs_count"] == 6
100
+ assert result["returned_count"] == 3 # 2 log + 1 info
101
+ assert result["filters_applied"]["log_types"] == ["log", "info"]
102
+
103
+ # Check that only the correct log types are returned
104
+ returned_types = [log["type"] for log in result["console_logs"]]
105
+ assert "log" in returned_types
106
+ assert "info" in returned_types
107
+ assert "error" not in returned_types
108
+
109
+ @pytest.mark.asyncio
110
+ async def test_get_browser_console_logs_max_logs_limit(self, browser_manager, mock_browser_info):
111
+ """Test limiting the number of logs returned."""
112
+ browser_id = "test-browser-id"
113
+ browser_manager.browsers[browser_id] = mock_browser_info
114
+
115
+ result = await browser_manager.get_browser_console_logs(
116
+ browser_id, max_logs=2, log_types=[] # Empty list to include all types
117
+ )
118
+
119
+ assert result["total_logs_count"] == 6
120
+ assert result["returned_count"] == 2 # Limited to 2 most recent
121
+ assert result["filters_applied"]["max_logs"] == 2
122
+
123
+ # Should return the 2 most recent logs
124
+ returned_logs = result["console_logs"]
125
+ assert len(returned_logs) == 2
126
+ assert returned_logs[-1]["type"] == "info" # Most recent
127
+ assert returned_logs[-2]["type"] == "assert" # Second most recent
128
+
129
+ @pytest.mark.asyncio
130
+ async def test_get_browser_console_logs_time_filtering(self, browser_manager, mock_browser_info):
131
+ """Test filtering logs by time window."""
132
+ browser_id = "test-browser-id"
133
+ browser_manager.browsers[browser_id] = mock_browser_info
134
+
135
+ result = await browser_manager.get_browser_console_logs(
136
+ browser_id, minutes_back=5, log_types=[] # Include all types, filter by time
137
+ )
138
+
139
+ assert result["total_logs_count"] == 6
140
+ assert result["returned_count"] == 3 # Only logs from last 5 minutes
141
+ assert result["filters_applied"]["minutes_back"] == 5
142
+
143
+ # All returned logs should be within the time window
144
+ cutoff_time = datetime.datetime.now() - datetime.timedelta(minutes=5)
145
+ for log in result["console_logs"]:
146
+ log_time = datetime.datetime.fromisoformat(log["timestamp"])
147
+ assert log_time > cutoff_time
148
+
149
+ @pytest.mark.asyncio
150
+ async def test_get_browser_console_logs_character_limit(self, browser_manager, mock_browser_info):
151
+ """Test limiting logs by character count."""
152
+ browser_id = "test-browser-id"
153
+ browser_manager.browsers[browser_id] = mock_browser_info
154
+
155
+ # Set a very low character limit to test truncation
156
+ result = await browser_manager.get_browser_console_logs(
157
+ browser_id, max_chars=50, log_types=[] # Include all types
158
+ )
159
+
160
+ assert result["total_logs_count"] == 6
161
+ assert result["returned_count"] <= 6 # Should be limited by character count
162
+
163
+ # Calculate total character count of returned logs
164
+ total_chars = sum(len(f"{log['type']}: {log['text']}") for log in result["console_logs"])
165
+ assert total_chars <= 50
166
+
167
+ @pytest.mark.asyncio
168
+ async def test_get_browser_console_logs_combined_filters(self, browser_manager, mock_browser_info):
169
+ """Test applying multiple filters simultaneously."""
170
+ browser_id = "test-browser-id"
171
+ browser_manager.browsers[browser_id] = mock_browser_info
172
+
173
+ result = await browser_manager.get_browser_console_logs(
174
+ browser_id, max_logs=10, log_types=["error", "warning"], minutes_back=10, max_chars=1000
175
+ )
176
+
177
+ assert result["total_logs_count"] == 6
178
+ assert result["filters_applied"]["log_types"] == ["error", "warning"]
179
+ assert result["filters_applied"]["minutes_back"] == 10
180
+ assert result["filters_applied"]["max_logs"] == 10
181
+ assert result["filters_applied"]["max_chars"] == 1000
182
+
183
+ # Should only contain error and warning logs
184
+ returned_types = [log["type"] for log in result["console_logs"]]
185
+ for log_type in returned_types:
186
+ assert log_type in ["error", "warning"]
187
+
188
+ @pytest.mark.asyncio
189
+ async def test_get_browser_console_logs_empty_logs(self, browser_manager):
190
+ """Test behavior when no console logs exist."""
191
+ browser_id = "test-browser-id"
192
+ browser_manager.browsers[browser_id] = {
193
+ "console_logs": [],
194
+ "launched_at": datetime.datetime.now().isoformat(),
195
+ }
196
+
197
+ result = await browser_manager.get_browser_console_logs(browser_id)
198
+
199
+ assert result["total_logs_count"] == 0
200
+ assert result["returned_count"] == 0
201
+ assert result["console_logs"] == []
202
+
203
+ @pytest.mark.asyncio
204
+ async def test_get_browser_console_logs_browser_not_found(self, browser_manager):
205
+ """Test error handling when browser ID doesn't exist."""
206
+ with pytest.raises(KeyError, match="Browser with ID nonexistent not found"):
207
+ await browser_manager.get_browser_console_logs("nonexistent")
208
+
209
+ @pytest.mark.asyncio
210
+ async def test_get_browser_content_with_filtered_logs(self, browser_manager, mock_browser_info):
211
+ """Test that get_browser_content uses filtered console logs."""
212
+ browser_id = "test-browser-id"
213
+
214
+ # Mock the page object
215
+ mock_page = AsyncMock()
216
+ mock_page.url = "https://example.com"
217
+ mock_page.title.return_value = "Test Page"
218
+ mock_page.content.return_value = "<html><body>Test</body></html>"
219
+
220
+ mock_browser_info["page"] = mock_page
221
+ browser_manager.browsers[browser_id] = mock_browser_info
222
+
223
+ result = await browser_manager.get_browser_content(browser_id, max_logs=2, log_types=["error"])
224
+
225
+ assert "current_url" in result
226
+ assert "title" in result
227
+ assert "html" in result
228
+ assert "console_logs" in result
229
+ assert "console_log_metadata" in result
230
+
231
+ metadata = result["console_log_metadata"]
232
+ assert metadata["total_logs_count"] == 6
233
+ assert metadata["returned_count"] == 1 # Only 1 error log
234
+ assert metadata["filters_applied"]["log_types"] == ["error"]
235
+ assert metadata["filters_applied"]["max_logs"] == 2
236
+
237
+ def test_circular_buffer_implementation(self, browser_manager):
238
+ """Test that the circular buffer prevents unlimited log growth."""
239
+ # Set a small buffer size for testing
240
+ browser_manager.max_console_logs_per_browser = 3
241
+
242
+ console_logs = []
243
+
244
+ # Simulate the console log handler behavior
245
+ def simulate_console_log_handler(msg_text, msg_type="log"):
246
+ log_entry = {
247
+ "type": msg_type,
248
+ "text": msg_text,
249
+ "timestamp": datetime.datetime.now().isoformat(),
250
+ "location": None,
251
+ }
252
+ console_logs.append(log_entry)
253
+
254
+ # Implement circular buffer logic
255
+ if len(console_logs) > browser_manager.max_console_logs_per_browser:
256
+ console_logs.pop(0) # Remove oldest log
257
+
258
+ # Add more logs than the buffer size
259
+ simulate_console_log_handler("Log 1")
260
+ simulate_console_log_handler("Log 2")
261
+ simulate_console_log_handler("Log 3")
262
+ assert len(console_logs) == 3
263
+
264
+ simulate_console_log_handler("Log 4")
265
+ assert len(console_logs) == 3 # Should still be 3
266
+ assert console_logs[0]["text"] == "Log 2" # First log should be removed
267
+ assert console_logs[-1]["text"] == "Log 4" # Last log should be the newest
268
+
269
+ simulate_console_log_handler("Log 5")
270
+ assert len(console_logs) == 3
271
+ assert console_logs[0]["text"] == "Log 3"
272
+ assert console_logs[-1]["text"] == "Log 5"
273
+
274
+ @pytest.mark.asyncio
275
+ async def test_no_log_types_filter_includes_all(self, browser_manager, mock_browser_info):
276
+ """Test that passing an empty list for log_types includes all log types."""
277
+ browser_id = "test-browser-id"
278
+ browser_manager.browsers[browser_id] = mock_browser_info
279
+
280
+ result = await browser_manager.get_browser_console_logs(
281
+ browser_id, log_types=[] # Empty list should include all types
282
+ )
283
+
284
+ assert result["total_logs_count"] == 6
285
+ assert result["returned_count"] == 6 # All logs should be included
286
+ assert result["filters_applied"]["log_types"] == []
287
+
288
+ # Should include all log types
289
+ returned_types = [log["type"] for log in result["console_logs"]]
290
+ assert "log" in returned_types
291
+ assert "error" in returned_types
292
+ assert "warning" in returned_types
293
+ assert "assert" in returned_types
294
+ assert "info" in returned_types
295
+
296
+ @pytest.mark.asyncio
297
+ async def test_character_limit_preserves_most_recent(self, browser_manager):
298
+ """Test that character limit preserves the most recent logs."""
299
+ browser_id = "test-browser-id"
300
+
301
+ # Create logs with known character counts
302
+ console_logs = [
303
+ {
304
+ "type": "log",
305
+ "text": "A" * 10, # 10 chars + "log: " = 14 chars
306
+ "timestamp": datetime.datetime.now().isoformat(),
307
+ "location": None,
308
+ },
309
+ {
310
+ "type": "log",
311
+ "text": "B" * 10, # 10 chars + "log: " = 14 chars
312
+ "timestamp": datetime.datetime.now().isoformat(),
313
+ "location": None,
314
+ },
315
+ {
316
+ "type": "log",
317
+ "text": "C" * 10, # 10 chars + "log: " = 14 chars (most recent)
318
+ "timestamp": datetime.datetime.now().isoformat(),
319
+ "location": None,
320
+ },
321
+ ]
322
+
323
+ browser_manager.browsers[browser_id] = {
324
+ "console_logs": console_logs,
325
+ "launched_at": datetime.datetime.now().isoformat(),
326
+ }
327
+
328
+ # Set character limit to allow only 1 log (14 chars) to test the logic
329
+ result = await browser_manager.get_browser_console_logs(browser_id, max_chars=14, log_types=[])
330
+
331
+ assert result["returned_count"] == 1
332
+ # Should preserve the most recent log (C)
333
+ returned_texts = [log["text"] for log in result["console_logs"]]
334
+ assert "C" * 10 in returned_texts
335
+ assert "A" * 10 not in returned_texts
336
+ assert "B" * 10 not in returned_texts
337
+
338
+
339
+ class TestPlaywrightBrowserManagerIntegration:
340
+ """Integration tests for PlaywrightBrowserManager that use real browsers."""
341
+
342
+ @pytest.mark.integration
343
+ @pytest.mark.skipif(SKIP_IN_CI, reason="Skipping network-dependent test in CI environment")
344
+ @pytest.mark.asyncio
345
+ async def test_real_browser_google_screenshot(self):
346
+ """Integration test: Launch real browser, load Google, take screenshot."""
347
+ # Check if BROWSERLESS_API_KEY is available, skip if not
348
+ if not os.getenv("BROWSERLESS_API_KEY"):
349
+ pytest.skip("BROWSERLESS_API_KEY environment variable not set")
350
+
351
+ browser_manager = PlaywrightBrowserManager(browser_backend="browserless")
352
+ browser_id = None
353
+
354
+ try:
355
+ # Launch browser and navigate to Google
356
+ browser_id = await browser_manager.launch_browser("https://www.google.com")
357
+
358
+ # Verify we got a valid browser ID (not an error dict)
359
+ assert isinstance(browser_id, str)
360
+ assert browser_id != ""
361
+
362
+ # Verify browser is in the manager's registry
363
+ assert browser_id in browser_manager.browsers
364
+ browser_info = browser_manager.browsers[browser_id]
365
+ assert browser_info["url"] == "https://www.google.com"
366
+ assert browser_info["backend"] == "browserless"
367
+ assert browser_info["browserstack"] is False
368
+
369
+ # Take a screenshot
370
+ screenshot_result = await browser_manager.take_browser_screenshot(browser_id)
371
+
372
+ # Verify screenshot result structure
373
+ assert "current_url" in screenshot_result
374
+ assert "title" in screenshot_result
375
+ assert "screenshot" in screenshot_result
376
+
377
+ # Verify we're actually on Google
378
+ assert "google" in screenshot_result["current_url"].lower()
379
+ assert "google" in screenshot_result["title"].lower()
380
+
381
+ # Verify screenshot is base64 encoded
382
+ screenshot_data = screenshot_result["screenshot"]
383
+ assert isinstance(screenshot_data, str)
384
+ assert len(screenshot_data) > 0
385
+ # Basic check that it's base64 (starts with image header)
386
+ import base64
387
+
388
+ try:
389
+ decoded = base64.b64decode(screenshot_data)
390
+ assert len(decoded) > 0
391
+ except Exception:
392
+ pytest.fail("Screenshot is not valid base64 data")
393
+
394
+ # Test console logs capture
395
+ console_logs_result = await browser_manager.get_browser_console_logs(browser_id)
396
+ assert "console_logs" in console_logs_result
397
+ assert "total_logs_count" in console_logs_result
398
+ assert "returned_count" in console_logs_result
399
+
400
+ # Test browser content retrieval
401
+ content_result = await browser_manager.get_browser_content(browser_id)
402
+ assert "current_url" in content_result
403
+ assert "title" in content_result
404
+ assert "html" in content_result
405
+ assert "console_logs" in content_result
406
+ assert len(content_result["html"]) > 0
407
+
408
+ # Test interactive elements extraction
409
+ elements_result = await browser_manager.get_browser_interactive_elements(browser_id)
410
+ assert "current_url" in elements_result
411
+ assert "title" in elements_result
412
+ assert "interactive_elements" in elements_result
413
+
414
+ finally:
415
+ # Clean up: close the browser if it was created
416
+ if browser_id and browser_id in browser_manager.browsers:
417
+ await browser_manager.close_browser(browser_id)
418
+
419
+ # Additional cleanup to ensure all browsers are closed
420
+ await browser_manager.cleanup_all_browsers()
421
+
422
+ @pytest.mark.integration
423
+ @pytest.mark.asyncio
424
+ @pytest.mark.skipif(SKIP_IN_CI, reason="Skipping slow test in CI environment")
425
+ async def test_real_browser_error_handling(self):
426
+ """Integration test: Test error handling with real browser for invalid URLs."""
427
+ # Check if BROWSERLESS_API_KEY is available, skip if not
428
+ if not os.getenv("BROWSERLESS_API_KEY"):
429
+ pytest.skip("BROWSERLESS_API_KEY environment variable not set")
430
+
431
+ browser_manager = PlaywrightBrowserManager(browser_backend="browserless")
432
+
433
+ try:
434
+ # Try to launch browser with invalid URL
435
+ result = await browser_manager.launch_browser("not-a-valid-url")
436
+
437
+ # Should return error dict rather than browser ID
438
+ if isinstance(result, dict) and "error" in result:
439
+ assert "error" in result
440
+ assert "Browser Launch Error" in result["error"]
441
+ else:
442
+ # If it somehow succeeds (maybe Playwright handles it), clean up
443
+ if isinstance(result, str) and result in browser_manager.browsers:
444
+ await browser_manager.close_browser(result)
445
+
446
+ finally:
447
+ await browser_manager.cleanup_all_browsers()