cursorflow 2.1.6__py3-none-any.whl → 2.2.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.
@@ -79,8 +79,8 @@ class BrowserController:
79
79
 
80
80
  self.playwright = await async_playwright().start()
81
81
 
82
- # Browser configuration - works for any framework
83
- browser_config = {
82
+ # Browser configuration - smart defaults with pass-through
83
+ default_browser_config = {
84
84
  "headless": self.config.get("headless", True),
85
85
  "slow_mo": 0 if self.config.get("headless", True) else 100,
86
86
  "args": [
@@ -92,16 +92,42 @@ class BrowserController:
92
92
  ]
93
93
  }
94
94
 
95
+ # Pass-through architecture: Merge user options with defaults
96
+ # Users can override ANY Playwright launch option
97
+ # See: https://playwright.dev/python/docs/api/class-browsertype#browser-type-launch
98
+ user_browser_options = self.config.get("browser_launch_options", {})
99
+
100
+ # Validate user options (warns about typos, validates types)
101
+ from .config_validator import ConfigValidator
102
+ if user_browser_options:
103
+ user_browser_options = ConfigValidator.validate_browser_options(user_browser_options)
104
+
105
+ browser_config = {**default_browser_config, **user_browser_options}
106
+
95
107
  self.browser = await self.playwright.chromium.launch(**browser_config)
96
108
 
97
- # Context configuration
109
+ # Context configuration - smart defaults with pass-through
98
110
  viewport = self.config.get("viewport", {"width": 1440, "height": 900})
99
- context_config = {
111
+ default_context_config = {
100
112
  "viewport": viewport,
101
113
  "ignore_https_errors": True,
102
114
  "record_video_dir": ".cursorflow/artifacts/videos" if self.config.get("record_video") else None
103
115
  }
104
116
 
117
+ # Pass-through architecture: Merge user options with defaults
118
+ # Users can use ANY Playwright context option:
119
+ # - geolocation, permissions, timezone, locale
120
+ # - color_scheme, reduced_motion, http_credentials
121
+ # - user_agent, extra_http_headers, offline
122
+ # See: https://playwright.dev/python/docs/api/class-browser#browser-new-context
123
+ user_context_options = self.config.get("context_options", {})
124
+
125
+ # Validate user options (warns about typos, validates types)
126
+ if user_context_options:
127
+ user_context_options = ConfigValidator.validate_context_options(user_context_options)
128
+
129
+ context_config = {**default_context_config, **user_context_options}
130
+
105
131
  self.context = await self.browser.new_context(**context_config)
106
132
  self.page = await self.context.new_page()
107
133
 
@@ -275,6 +301,9 @@ class BrowserController:
275
301
  }
276
302
  self.network_requests.append(response_data)
277
303
 
304
+ # Capture response body asynchronously (Phase 1.4: Network Response Body Capture)
305
+ asyncio.create_task(self._capture_response_body_async(response, response_data))
306
+
278
307
  # Log failed requests for correlation
279
308
  if response.status >= 400:
280
309
  self.logger.warning(f"Failed Response: {response.status} {response.url}")
@@ -682,8 +711,47 @@ class BrowserController:
682
711
  self.logger.error(f"Browser cleanup failed: {e}")
683
712
  return None
684
713
 
714
+ async def _capture_response_body_async(self, response, response_data: Dict):
715
+ """
716
+ Async wrapper to capture response body without blocking event handlers
717
+
718
+ Phase 1.4: Network Response Body Capture
719
+ Captures request/response bodies for complete debugging data
720
+ """
721
+ try:
722
+ # Get response body
723
+ body = await response.body()
724
+ decoded_body = body.decode('utf-8', errors='ignore')
725
+
726
+ # Update the response_data dict directly (it's already in self.network_requests)
727
+ response_data["response_body"] = decoded_body[:5000] # Capture more for debugging
728
+ response_data["response_body_size"] = len(decoded_body)
729
+ response_data["response_body_truncated"] = len(decoded_body) > 5000
730
+
731
+ # Parse JSON responses automatically
732
+ content_type = response.headers.get("content-type", "")
733
+ if "application/json" in content_type:
734
+ try:
735
+ import json
736
+ response_data["response_body_json"] = json.loads(decoded_body)
737
+
738
+ # Log key data for debugging undefined values
739
+ self.logger.debug(f"JSON Response from {response.url[:50]}: {len(response_data['response_body_json'])} keys")
740
+ except json.JSONDecodeError as e:
741
+ response_data["json_parse_error"] = str(e)
742
+ self.logger.warning(f"Failed to parse JSON response from {response.url}: {e}")
743
+
744
+ # Log error responses
745
+ if response.status >= 400:
746
+ error_preview = decoded_body[:200].replace('\n', ' ')
747
+ self.logger.error(f"Error response ({response.status}) from {response.url}: {error_preview}")
748
+
749
+ except Exception as e:
750
+ self.logger.debug(f"Response body capture failed for {response.url}: {e}")
751
+ response_data["body_capture_error"] = str(e)
752
+
685
753
  async def _capture_response_body(self, response):
686
- """Capture response body for API calls and errors"""
754
+ """Legacy method - captures response body for specific cases"""
687
755
  try:
688
756
  body = await response.body()
689
757
  decoded_body = body.decode('utf-8', errors='ignore')
@@ -723,6 +791,227 @@ class BrowserController:
723
791
  req["body_capture_error"] = str(e)
724
792
  break
725
793
 
794
+ async def _capture_javascript_context(self) -> Dict[str, Any]:
795
+ """
796
+ Phase 2.2: JavaScript Context Capture
797
+
798
+ Captures global JavaScript scope including:
799
+ - Global functions (enumerate window properties that are functions)
800
+ - Global variables (enumerate window properties that are not functions)
801
+ - Specific window objects (configurable list to serialize)
802
+ """
803
+ try:
804
+ # Get list of objects to capture from config
805
+ capture_objects = self.config.get("capture_window_objects", [])
806
+
807
+ context_data = await self.page.evaluate("""
808
+ (captureObjects) => {
809
+ const context = {
810
+ global_functions: [],
811
+ global_variables: [],
812
+ window_property_count: 0,
813
+ window_objects: {}
814
+ };
815
+
816
+ // Enumerate window properties
817
+ const windowProps = Object.getOwnPropertyNames(window);
818
+ context.window_property_count = windowProps.length;
819
+
820
+ // Categorize by type
821
+ windowProps.forEach(prop => {
822
+ try {
823
+ const value = window[prop];
824
+
825
+ // Skip built-in browser objects (too many)
826
+ if (prop.startsWith('webkit') || prop.startsWith('moz') ||
827
+ prop.startsWith('chrome') || prop === 'constructor') {
828
+ return;
829
+ }
830
+
831
+ if (typeof value === 'function') {
832
+ // Skip native functions (toString contains '[native code]')
833
+ const funcStr = value.toString();
834
+ if (!funcStr.includes('[native code]')) {
835
+ context.global_functions.push(prop);
836
+ }
837
+ } else if (value !== null && typeof value !== 'undefined' &&
838
+ typeof value !== 'function' && typeof value !== 'object') {
839
+ // Primitive global variables
840
+ context.global_variables.push({
841
+ name: prop,
842
+ type: typeof value,
843
+ value: String(value).substring(0, 100) // Truncate long values
844
+ });
845
+ }
846
+ } catch (e) {
847
+ // Skip properties that throw on access
848
+ }
849
+ });
850
+
851
+ // Capture specific window objects (configurable)
852
+ captureObjects.forEach(objName => {
853
+ try {
854
+ const obj = window[objName];
855
+ if (obj && typeof obj === 'object') {
856
+ // Serialize object (handle circular references)
857
+ context.window_objects[objName] = JSON.parse(
858
+ JSON.stringify(obj, (key, value) => {
859
+ // Handle circular references
860
+ if (typeof value === 'object' && value !== null) {
861
+ if (key && typeof value === 'object' && Object.keys(value).length > 50) {
862
+ return '[Large Object]';
863
+ }
864
+ }
865
+ // Handle functions
866
+ if (typeof value === 'function') {
867
+ return '[Function]';
868
+ }
869
+ return value;
870
+ })
871
+ );
872
+ }
873
+ } catch (e) {
874
+ context.window_objects[objName] = {
875
+ error: `Failed to serialize: ${e.message}`
876
+ };
877
+ }
878
+ });
879
+
880
+ return context;
881
+ }
882
+ """, capture_objects)
883
+
884
+ return context_data
885
+
886
+ except Exception as e:
887
+ self.logger.error(f"JavaScript context capture failed: {e}")
888
+ return {
889
+ "error": str(e),
890
+ "global_functions": [],
891
+ "global_variables": [],
892
+ "window_objects": {}
893
+ }
894
+
895
+ async def _capture_storage_state(self) -> Dict[str, Any]:
896
+ """
897
+ Phase 2.3: Storage State Capture
898
+
899
+ Captures browser storage state:
900
+ - localStorage
901
+ - sessionStorage
902
+ - cookies
903
+
904
+ Masks sensitive keys based on configuration.
905
+ """
906
+ try:
907
+ # Get masking configuration
908
+ sensitive_keys = self.config.get("sensitive_storage_keys", [
909
+ "authToken", "apiKey", "sessionId", "password", "secret", "token"
910
+ ])
911
+
912
+ storage_data = await self.page.evaluate("""
913
+ (sensitiveKeys) => {
914
+ const storage = {
915
+ localStorage: {},
916
+ sessionStorage: {},
917
+ cookies: []
918
+ };
919
+
920
+ // Capture localStorage
921
+ for (let i = 0; i < localStorage.length; i++) {
922
+ const key = localStorage.key(i);
923
+ const value = localStorage.getItem(key);
924
+
925
+ // Mask sensitive keys
926
+ const isSensitive = sensitiveKeys.some(pattern =>
927
+ key.toLowerCase().includes(pattern.toLowerCase())
928
+ );
929
+
930
+ storage.localStorage[key] = isSensitive ? '****' : value;
931
+ }
932
+
933
+ // Capture sessionStorage
934
+ for (let i = 0; i < sessionStorage.length; i++) {
935
+ const key = sessionStorage.key(i);
936
+ const value = sessionStorage.getItem(key);
937
+
938
+ const isSensitive = sensitiveKeys.some(pattern =>
939
+ key.toLowerCase().includes(pattern.toLowerCase())
940
+ );
941
+
942
+ storage.sessionStorage[key] = isSensitive ? '****' : value;
943
+ }
944
+
945
+ // Capture cookies (just names, not values for security)
946
+ storage.cookies = document.cookie.split(';').map(c => c.trim().split('=')[0]);
947
+
948
+ return storage;
949
+ }
950
+ """, sensitive_keys)
951
+
952
+ return storage_data
953
+
954
+ except Exception as e:
955
+ self.logger.error(f"Storage state capture failed: {e}")
956
+ return {
957
+ "error": str(e),
958
+ "localStorage": {},
959
+ "sessionStorage": {},
960
+ "cookies": []
961
+ }
962
+
963
+ async def _capture_form_state(self) -> Dict[str, Any]:
964
+ """
965
+ Phase 2.4: Form State Capture
966
+
967
+ Captures all form field values at time of capture.
968
+ Automatically masks password fields.
969
+ """
970
+ try:
971
+ form_data = await self.page.evaluate("""
972
+ () => {
973
+ const forms = {};
974
+
975
+ // Get all forms on page
976
+ document.querySelectorAll('form').forEach(form => {
977
+ const formId = form.id || form.name || `form_${forms.length}`;
978
+ const formData = {};
979
+
980
+ // Get all form inputs
981
+ form.querySelectorAll('input, select, textarea').forEach(field => {
982
+ const fieldName = field.name || field.id || `field_${field.type}`;
983
+
984
+ // Mask password fields
985
+ if (field.type === 'password') {
986
+ formData[fieldName] = '****';
987
+ }
988
+ // Checkbox/radio
989
+ else if (field.type === 'checkbox' || field.type === 'radio') {
990
+ formData[fieldName] = field.checked;
991
+ }
992
+ // Select dropdowns
993
+ else if (field.tagName === 'SELECT') {
994
+ formData[fieldName] = field.value;
995
+ }
996
+ // Text inputs, textareas
997
+ else {
998
+ formData[fieldName] = field.value;
999
+ }
1000
+ });
1001
+
1002
+ forms[formId] = formData;
1003
+ });
1004
+
1005
+ return forms;
1006
+ }
1007
+ """)
1008
+
1009
+ return form_data
1010
+
1011
+ except Exception as e:
1012
+ self.logger.error(f"Form state capture failed: {e}")
1013
+ return {"error": str(e)}
1014
+
726
1015
  def _categorize_http_error(self, status_code: int) -> str:
727
1016
  """Categorize HTTP errors for better debugging (v2.0 enhancement)"""
728
1017
  if 400 <= status_code < 500:
@@ -872,6 +1161,9 @@ class BrowserController:
872
1161
  "dom_analysis": dom_analysis,
873
1162
  "network_data": network_data,
874
1163
  "console_data": console_data,
1164
+ "javascript_context": javascript_context,
1165
+ "storage_state": storage_state,
1166
+ "form_state": form_state,
875
1167
  "performance_data": performance_data,
876
1168
  "page_state": page_state,
877
1169
 
@@ -1506,6 +1798,31 @@ class BrowserController:
1506
1798
  return selectors;
1507
1799
  }
1508
1800
 
1801
+ // Phase 2.1: Event Handler Capture
1802
+ function getEventHandlers(element) {
1803
+ const handlers = {};
1804
+
1805
+ // Common event handler attributes
1806
+ const eventAttributes = [
1807
+ 'onclick', 'ondblclick', 'onmousedown', 'onmouseup',
1808
+ 'onmouseover', 'onmouseout', 'onmousemove', 'onmouseenter', 'onmouseleave',
1809
+ 'onkeydown', 'onkeyup', 'onkeypress',
1810
+ 'onsubmit', 'onchange', 'oninput', 'onfocus', 'onblur',
1811
+ 'onload', 'onerror', 'onabort',
1812
+ 'ontouchstart', 'ontouchend', 'ontouchmove',
1813
+ 'ondrag', 'ondrop', 'ondragover', 'ondragstart', 'ondragend'
1814
+ ];
1815
+
1816
+ eventAttributes.forEach(attr => {
1817
+ const handler = element.getAttribute(attr);
1818
+ if (handler) {
1819
+ handlers[attr] = handler;
1820
+ }
1821
+ });
1822
+
1823
+ return Object.keys(handlers).length > 0 ? handlers : null;
1824
+ }
1825
+
1509
1826
  // Enhanced accessibility analysis
1510
1827
  function getAccessibilityData(element) {
1511
1828
  return {
@@ -1750,6 +2067,9 @@ class BrowserController:
1750
2067
  // v2.0 Enhancement: Accessibility data
1751
2068
  accessibility: getAccessibilityData(element),
1752
2069
 
2070
+ // Phase 2.1: Event Handlers
2071
+ event_handlers: getEventHandlers(element),
2072
+
1753
2073
  // v2.0 Enhancement: Visual context
1754
2074
  visual_context: visualContext,
1755
2075
 
@@ -192,6 +192,19 @@ class BrowserEngine:
192
192
  async def _execute_action(self, action: Dict) -> Dict:
193
193
  """Execute a single test action"""
194
194
 
195
+ # Validate action format
196
+ from .action_validator import ActionValidator, ActionValidationError
197
+
198
+ try:
199
+ action = ActionValidator.validate(action)
200
+ except ActionValidationError as e:
201
+ return {
202
+ 'action': 'unknown',
203
+ 'success': False,
204
+ 'error': f"Invalid action format: {e}"
205
+ }
206
+
207
+ # Extract action type safely
195
208
  action_type = action.get('type') or list(action.keys())[0]
196
209
  action_config = action.get(action_type, action)
197
210
 
@@ -0,0 +1,216 @@
1
+ """
2
+ Configuration Validation
3
+
4
+ Validates user-provided configuration against Playwright API.
5
+ Provides clear error messages with links to documentation.
6
+ """
7
+
8
+ from typing import Dict, Any, Set
9
+ import logging
10
+
11
+
12
+ class ConfigValidationError(Exception):
13
+ """Raised when configuration is invalid"""
14
+ pass
15
+
16
+
17
+ class ConfigValidator:
18
+ """
19
+ Validates CursorFlow and Playwright configuration
20
+
21
+ Strategy: We don't strictly validate - we warn about likely errors
22
+ and let Playwright do final validation. This keeps us forward-compatible.
23
+ """
24
+
25
+ # Common browser launch options (for helpful warnings)
26
+ # See: https://playwright.dev/python/docs/api/class-browsertype#browser-type-launch
27
+ KNOWN_BROWSER_OPTIONS = {
28
+ 'args', 'channel', 'chromium_sandbox', 'devtools', 'downloads_path',
29
+ 'env', 'executable_path', 'firefox_user_prefs', 'handle_sigint',
30
+ 'handle_sigterm', 'handle_sighup', 'headless', 'ignore_default_args',
31
+ 'proxy', 'slow_mo', 'timeout', 'traces_dir'
32
+ }
33
+
34
+ # Common context options (for helpful warnings)
35
+ # See: https://playwright.dev/python/docs/api/class-browser#browser-new-context
36
+ KNOWN_CONTEXT_OPTIONS = {
37
+ 'accept_downloads', 'base_url', 'bypass_csp', 'color_scheme',
38
+ 'device_scale_factor', 'extra_http_headers', 'forced_colors',
39
+ 'geolocation', 'has_touch', 'http_credentials', 'ignore_https_errors',
40
+ 'is_mobile', 'java_script_enabled', 'locale', 'no_viewport',
41
+ 'offline', 'permissions', 'proxy', 'record_har_content',
42
+ 'record_har_mode', 'record_har_omit_content', 'record_har_path',
43
+ 'record_har_url_filter', 'record_video_dir', 'record_video_size',
44
+ 'reduced_motion', 'screen', 'service_workers', 'storage_state',
45
+ 'strict_selectors', 'timezone_id', 'user_agent', 'viewport'
46
+ }
47
+
48
+ @classmethod
49
+ def validate_browser_options(cls, options: Dict[str, Any]) -> Dict[str, Any]:
50
+ """
51
+ Validate browser launch options
52
+
53
+ Args:
54
+ options: User-provided browser options
55
+
56
+ Returns:
57
+ Validated options (unchanged - just warnings logged)
58
+ """
59
+ logger = logging.getLogger(__name__)
60
+
61
+ # Warn about unknown options (might be typos)
62
+ for key in options.keys():
63
+ if key not in cls.KNOWN_BROWSER_OPTIONS:
64
+ logger.warning(
65
+ f"Unknown browser option '{key}' - will pass to Playwright anyway. "
66
+ f"Check spelling or see: https://playwright.dev/python/docs/api/class-browsertype#browser-type-launch"
67
+ )
68
+
69
+ # Validate specific option types
70
+ if 'headless' in options and not isinstance(options['headless'], bool):
71
+ raise ConfigValidationError(
72
+ f"'headless' must be boolean, got {type(options['headless']).__name__}: {options['headless']}"
73
+ )
74
+
75
+ if 'timeout' in options and not isinstance(options['timeout'], (int, float)):
76
+ raise ConfigValidationError(
77
+ f"'timeout' must be number, got {type(options['timeout']).__name__}: {options['timeout']}"
78
+ )
79
+
80
+ if 'args' in options and not isinstance(options['args'], list):
81
+ raise ConfigValidationError(
82
+ f"'args' must be list of strings, got {type(options['args']).__name__}"
83
+ )
84
+
85
+ return options
86
+
87
+ @classmethod
88
+ def validate_context_options(cls, options: Dict[str, Any]) -> Dict[str, Any]:
89
+ """
90
+ Validate browser context options
91
+
92
+ Args:
93
+ options: User-provided context options
94
+
95
+ Returns:
96
+ Validated options (unchanged - just warnings logged)
97
+ """
98
+ logger = logging.getLogger(__name__)
99
+
100
+ # Warn about unknown options
101
+ for key in options.keys():
102
+ if key not in cls.KNOWN_CONTEXT_OPTIONS:
103
+ logger.warning(
104
+ f"Unknown context option '{key}' - will pass to Playwright anyway. "
105
+ f"Check spelling or see: https://playwright.dev/python/docs/api/class-browser#browser-new-context"
106
+ )
107
+
108
+ # Validate specific option types
109
+ if 'viewport' in options:
110
+ viewport = options['viewport']
111
+ if not isinstance(viewport, dict):
112
+ raise ConfigValidationError(
113
+ f"'viewport' must be dict with width/height, got {type(viewport).__name__}"
114
+ )
115
+ if 'width' in viewport and not isinstance(viewport['width'], int):
116
+ raise ConfigValidationError(
117
+ f"viewport width must be integer, got {type(viewport['width']).__name__}"
118
+ )
119
+ if 'height' in viewport and not isinstance(viewport['height'], int):
120
+ raise ConfigValidationError(
121
+ f"viewport height must be integer, got {type(viewport['height']).__name__}"
122
+ )
123
+
124
+ if 'geolocation' in options:
125
+ geo = options['geolocation']
126
+ if not isinstance(geo, dict) or 'latitude' not in geo or 'longitude' not in geo:
127
+ raise ConfigValidationError(
128
+ f"'geolocation' must be dict with latitude/longitude: "
129
+ f"{{'latitude': 40.7128, 'longitude': -74.0060}}"
130
+ )
131
+
132
+ if 'timezone_id' in options and not isinstance(options['timezone_id'], str):
133
+ raise ConfigValidationError(
134
+ f"'timezone_id' must be string like 'America/New_York', got {type(options['timezone_id']).__name__}"
135
+ )
136
+
137
+ return options
138
+
139
+ @classmethod
140
+ def get_config_examples(cls) -> str:
141
+ """Get example configurations for documentation"""
142
+ return """
143
+ Browser Configuration Examples:
144
+
145
+ Enable DevTools (non-headless):
146
+ {
147
+ "headless": false,
148
+ "browser_launch_options": {
149
+ "devtools": true
150
+ }
151
+ }
152
+
153
+ Use specific Chrome channel:
154
+ {
155
+ "browser_launch_options": {
156
+ "channel": "chrome"
157
+ }
158
+ }
159
+
160
+ Custom proxy:
161
+ {
162
+ "browser_launch_options": {
163
+ "proxy": {
164
+ "server": "http://myproxy.com:3128",
165
+ "username": "user",
166
+ "password": "pass"
167
+ }
168
+ }
169
+ }
170
+
171
+ Context Configuration Examples:
172
+
173
+ Test in dark mode:
174
+ {
175
+ "context_options": {
176
+ "color_scheme": "dark"
177
+ }
178
+ }
179
+
180
+ Test with geolocation:
181
+ {
182
+ "context_options": {
183
+ "geolocation": {"latitude": 40.7128, "longitude": -74.0060},
184
+ "permissions": ["geolocation"]
185
+ }
186
+ }
187
+
188
+ Test offline behavior:
189
+ {
190
+ "context_options": {
191
+ "offline": true
192
+ }
193
+ }
194
+
195
+ Custom timezone:
196
+ {
197
+ "context_options": {
198
+ "timezone_id": "America/Los_Angeles"
199
+ }
200
+ }
201
+
202
+ HTTP authentication:
203
+ {
204
+ "context_options": {
205
+ "http_credentials": {
206
+ "username": "admin",
207
+ "password": "secret"
208
+ }
209
+ }
210
+ }
211
+
212
+ See Playwright documentation for all available options:
213
+ Browser: https://playwright.dev/python/docs/api/class-browsertype#browser-type-launch
214
+ Context: https://playwright.dev/python/docs/api/class-browser#browser-new-context
215
+ """
216
+