cursorflow 2.1.6__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.
- cursorflow/__init__.py +1 -1
- cursorflow/cli.py +370 -11
- cursorflow/core/action_validator.py +199 -0
- cursorflow/core/browser_controller.py +300 -5
- cursorflow/core/browser_engine.py +13 -0
- cursorflow/core/config_validator.py +216 -0
- cursorflow/core/cursorflow.py +68 -32
- cursorflow/install_cursorflow_rules.py +4 -4
- cursorflow/log_sources/local_file.py +20 -1
- cursorflow/log_sources/ssh_remote.py +19 -0
- cursorflow/rules/cursorflow-installation.mdc +1 -0
- cursorflow/rules/cursorflow-usage.mdc +7 -1
- {cursorflow-2.1.6.dist-info → cursorflow-2.2.0.dist-info}/METADATA +66 -14
- {cursorflow-2.1.6.dist-info → cursorflow-2.2.0.dist-info}/RECORD +18 -16
- {cursorflow-2.1.6.dist-info → cursorflow-2.2.0.dist-info}/WHEEL +0 -0
- {cursorflow-2.1.6.dist-info → cursorflow-2.2.0.dist-info}/entry_points.txt +0 -0
- {cursorflow-2.1.6.dist-info → cursorflow-2.2.0.dist-info}/licenses/LICENSE +0 -0
- {cursorflow-2.1.6.dist-info → cursorflow-2.2.0.dist-info}/top_level.txt +0 -0
@@ -79,8 +79,8 @@ class BrowserController:
|
|
79
79
|
|
80
80
|
self.playwright = await async_playwright().start()
|
81
81
|
|
82
|
-
# Browser configuration -
|
83
|
-
|
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
|
-
|
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
|
-
"""
|
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
|
|
@@ -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
|
+
|