cursorflow 2.1.5__py3-none-any.whl → 2.2.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.
@@ -0,0 +1,199 @@
1
+ """
2
+ Action Format Validation
3
+
4
+ Validates action dictionaries before execution to provide clear error messages.
5
+ """
6
+
7
+ from typing import Dict, Any, List, Optional
8
+
9
+
10
+ class ActionValidationError(Exception):
11
+ """Raised when action format is invalid"""
12
+ pass
13
+
14
+
15
+ class ActionValidator:
16
+ """
17
+ Validates action format before execution
18
+
19
+ Actions should be dictionaries with a single key indicating the action type,
20
+ or have an explicit 'type' key.
21
+
22
+ Valid formats:
23
+ {"click": ".selector"}
24
+ {"click": {"selector": ".element"}}
25
+ {"type": "click", "selector": ".element"}
26
+ {"navigate": "/path"}
27
+ {"wait": 2}
28
+ """
29
+
30
+ # CursorFlow-specific action types (not direct Playwright methods)
31
+ CURSORFLOW_ACTION_TYPES = {
32
+ 'navigate', 'screenshot', 'capture', 'authenticate'
33
+ }
34
+
35
+ # Common Playwright Page methods (for documentation/validation)
36
+ COMMON_PLAYWRIGHT_ACTIONS = {
37
+ 'click', 'dblclick', 'hover', 'focus', 'blur',
38
+ 'fill', 'type', 'press', 'select_option',
39
+ 'check', 'uncheck', 'set_checked',
40
+ 'drag_and_drop', 'tap',
41
+ 'wait', 'wait_for_selector', 'wait_for_timeout', 'wait_for_load_state',
42
+ 'goto', 'reload', 'go_back', 'go_forward',
43
+ 'scroll', 'set_viewport_size', 'bring_to_front',
44
+ 'evaluate', 'evaluate_handle', 'query_selector'
45
+ }
46
+
47
+ # All known valid actions (CursorFlow + Playwright)
48
+ # Note: This is not exhaustive - we pass through to Playwright dynamically
49
+ KNOWN_ACTION_TYPES = CURSORFLOW_ACTION_TYPES | COMMON_PLAYWRIGHT_ACTIONS
50
+
51
+ @classmethod
52
+ def validate(cls, action: Any) -> Dict[str, Any]:
53
+ """
54
+ Validate action format and return normalized action
55
+
56
+ Args:
57
+ action: The action to validate (should be dict)
58
+
59
+ Returns:
60
+ Validated and normalized action dict
61
+
62
+ Raises:
63
+ ActionValidationError: If action format is invalid
64
+ """
65
+ # Check if action is a dict
66
+ if not isinstance(action, dict):
67
+ raise ActionValidationError(
68
+ f"Action must be a dictionary, got {type(action).__name__}: {action}\n"
69
+ f"Expected format: {{'click': '.selector'}} or {{'type': 'click', 'selector': '.element'}}"
70
+ )
71
+
72
+ # Check if action is empty
73
+ if not action:
74
+ raise ActionValidationError(
75
+ "Action dictionary is empty\n"
76
+ f"Expected format: {{'click': '.selector'}}"
77
+ )
78
+
79
+ # Get action type
80
+ action_type = cls._extract_action_type(action)
81
+
82
+ # Validate action type (permissive - warns for unknown, doesn't block)
83
+ if action_type not in cls.KNOWN_ACTION_TYPES:
84
+ # Log warning but allow it (might be valid Playwright method)
85
+ import logging
86
+ logger = logging.getLogger(__name__)
87
+ logger.warning(
88
+ f"Unknown action type '{action_type}' - will attempt to pass through to Playwright. "
89
+ f"Common actions: {', '.join(sorted(list(cls.COMMON_PLAYWRIGHT_ACTIONS)[:10]))}... "
90
+ f"See: https://playwright.dev/python/docs/api/class-page"
91
+ )
92
+
93
+ return action
94
+
95
+ @classmethod
96
+ def _extract_action_type(cls, action: dict) -> str:
97
+ """
98
+ Extract action type from action dict
99
+
100
+ Supports:
101
+ {"type": "click", "selector": ".btn"} # Explicit type key with string value
102
+ {"click": ".selector"} # Action type is the key
103
+ {"click": {"selector": ".btn"}} # Action type with config dict
104
+ {"type": {"selector": "#field"}} # 'type' as action (typing), not explicit type
105
+ """
106
+ # Check if 'type' key exists AND has a string value (explicit type specification)
107
+ # If type key has a dict value, it's the action itself (typing action)
108
+ if 'type' in action and isinstance(action['type'], str):
109
+ return action['type']
110
+
111
+ # Otherwise, first key is the action type
112
+ keys = list(action.keys())
113
+ if not keys:
114
+ raise ActionValidationError("Action has no keys")
115
+
116
+ action_type = keys[0]
117
+
118
+ # First key should be the action type (string)
119
+ if not isinstance(action_type, str):
120
+ raise ActionValidationError(
121
+ f"Action type must be a string, got {type(action_type).__name__}: {action_type}"
122
+ )
123
+
124
+ return action_type
125
+
126
+ @classmethod
127
+ def validate_list(cls, actions: Any) -> List[Dict[str, Any]]:
128
+ """
129
+ Validate list of actions
130
+
131
+ Args:
132
+ actions: Should be a list of action dicts
133
+
134
+ Returns:
135
+ List of validated actions
136
+
137
+ Raises:
138
+ ActionValidationError: If format is invalid
139
+ """
140
+ if not isinstance(actions, list):
141
+ raise ActionValidationError(
142
+ f"Actions must be a list, got {type(actions).__name__}: {actions}\n"
143
+ f"Expected format: [{{'click': '.btn'}}, {{'wait': 2}}]"
144
+ )
145
+
146
+ if not actions:
147
+ raise ActionValidationError(
148
+ "Actions list is empty\n"
149
+ f"Expected at least one action like: [{{'navigate': '/'}}]"
150
+ )
151
+
152
+ validated = []
153
+ for i, action in enumerate(actions):
154
+ try:
155
+ validated.append(cls.validate(action))
156
+ except ActionValidationError as e:
157
+ raise ActionValidationError(
158
+ f"Invalid action at index {i}: {e}"
159
+ )
160
+
161
+ return validated
162
+
163
+ @classmethod
164
+ def get_example_actions(cls) -> str:
165
+ """Get example action formats for help text"""
166
+ return """
167
+ Action Format Examples:
168
+
169
+ Common CursorFlow actions:
170
+ {"navigate": "/dashboard"}
171
+ {"click": ".button"}
172
+ {"screenshot": "page-loaded"}
173
+
174
+ Any Playwright Page method:
175
+ {"hover": ".menu-item"}
176
+ {"dblclick": ".editable"}
177
+ {"press": "Enter"}
178
+ {"drag_and_drop": {"source": ".item", "target": ".dropzone"}}
179
+ {"focus": "#input"}
180
+ {"check": "#checkbox"}
181
+ {"evaluate": "window.scrollTo(0, 100)"}
182
+
183
+ See full Playwright API:
184
+ https://playwright.dev/python/docs/api/class-page
185
+
186
+ CursorFlow passes actions directly to Playwright, giving you access
187
+ to 94+ methods without artificial limitations.
188
+
189
+ Complete workflow:
190
+ [
191
+ {"navigate": "/login"},
192
+ {"fill": {"selector": "#username", "value": "admin"}},
193
+ {"fill": {"selector": "#password", "value": "pass123"}},
194
+ {"click": "#submit"},
195
+ {"wait_for_selector": ".dashboard"},
196
+ {"screenshot": "logged-in"}
197
+ ]
198
+ """
199
+
@@ -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
 
@@ -1750,6 +2042,9 @@ class BrowserController:
1750
2042
  // v2.0 Enhancement: Accessibility data
1751
2043
  accessibility: getAccessibilityData(element),
1752
2044
 
2045
+ // Phase 2.1: Event Handlers
2046
+ event_handlers: getEventHandlers(element),
2047
+
1753
2048
  // v2.0 Enhancement: Visual context
1754
2049
  visual_context: visualContext,
1755
2050
 
@@ -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