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 CHANGED
@@ -56,7 +56,7 @@ def _get_version():
56
56
  pass
57
57
 
58
58
  # Fallback version - should match pyproject.toml
59
- return "2.1.6"
59
+ return "2.2.0"
60
60
 
61
61
  __version__ = _get_version()
62
62
  __author__ = "GeekWarrior Development"
cursorflow/cli.py CHANGED
@@ -59,7 +59,7 @@ def main(ctx):
59
59
  @click.option('--path', '-p',
60
60
  help='Simple path to navigate to (e.g., "/dashboard")')
61
61
  @click.option('--actions', '-a',
62
- help='JSON file with test actions, or inline JSON string')
62
+ help='JSON file with test actions, or inline JSON string. Format: [{"navigate": "/path"}, {"click": ".btn"}]')
63
63
  @click.option('--output', '-o',
64
64
  help='Output file for results (auto-generated if not specified)')
65
65
  @click.option('--logs', '-l',
@@ -76,16 +76,96 @@ def main(ctx):
76
76
  help='Timeout in seconds for actions')
77
77
  @click.option('--responsive', is_flag=True,
78
78
  help='Test across multiple viewports (mobile, tablet, desktop)')
79
- def test(base_url, path, actions, output, logs, config, verbose, headless, timeout, responsive):
80
- """Test UI flows and interactions with real-time log monitoring"""
79
+ @click.option('--save-session', '-S',
80
+ help='Save browser session state with this name for later reuse')
81
+ @click.option('--use-session', '-U',
82
+ help='Restore and use a previously saved session')
83
+ @click.option('--wait-for', '-w',
84
+ help='Wait for selector to appear before continuing')
85
+ @click.option('--wait-timeout', type=int, default=30,
86
+ help='Timeout in seconds for wait operations')
87
+ @click.option('--wait-for-network-idle', is_flag=True,
88
+ help='Wait for network to be idle (no requests for 2s)')
89
+ @click.option('--click', '-c', multiple=True,
90
+ help='Click element by selector (can specify multiple)')
91
+ @click.option('--hover', '-h', multiple=True,
92
+ help='Hover over element by selector')
93
+ @click.option('--fill', '-f', multiple=True,
94
+ help='Fill input field. Format: selector=value')
95
+ @click.option('--screenshot', '-s', multiple=True,
96
+ help='Capture screenshot with name')
97
+ @click.option('--open-trace', is_flag=True,
98
+ help='Automatically open Playwright trace viewer after test')
99
+ @click.option('--show-console', is_flag=True,
100
+ help='Show console errors and warnings in output')
101
+ @click.option('--show-all-console', is_flag=True,
102
+ help='Show all console messages (including logs)')
103
+ @click.option('--quiet', '-q', is_flag=True,
104
+ help='Minimal output, JSON results only')
105
+ def test(base_url, path, actions, output, logs, config, verbose, headless, timeout, responsive,
106
+ save_session, use_session, wait_for, wait_timeout, wait_for_network_idle,
107
+ click, hover, fill, screenshot, open_trace, show_console, show_all_console, quiet):
108
+ """
109
+ Test UI flows and interactions with real-time log monitoring
110
+
111
+ \b
112
+ Action Format Examples:
113
+ Simple actions:
114
+ [{"navigate": "/dashboard"}, {"click": ".button"}, {"wait": 2}]
115
+
116
+ Actions with configuration:
117
+ [{"click": {"selector": ".button"}}, {"fill": {"selector": "#email", "value": "test@example.com"}}]
118
+
119
+ Save to file and use:
120
+ cursorflow test --base-url http://localhost:3000 --actions workflow.json
121
+
122
+ \b
123
+ Examples:
124
+ # Simple path navigation
125
+ cursorflow test --base-url http://localhost:3000 --path /dashboard
126
+
127
+ # With custom actions
128
+ cursorflow test --base-url http://localhost:3000 --actions '[{"navigate": "/login"}, {"screenshot": "page"}]'
129
+
130
+ # From file
131
+ cursorflow test --base-url http://localhost:3000 --actions my_test.json
132
+ """
81
133
 
82
134
  if verbose:
83
135
  import logging
84
136
  logging.basicConfig(level=logging.INFO)
85
137
 
86
- # Parse actions
138
+ # Parse actions - Phase 3.1: Inline CLI Actions
87
139
  test_actions = []
88
- if actions:
140
+
141
+ # Build actions from inline flags (left-to-right execution)
142
+ if any([click, hover, fill, screenshot]) and not actions:
143
+ # Inline actions mode
144
+ if path:
145
+ test_actions.append({"navigate": path})
146
+
147
+ # Wait options
148
+ if wait_for:
149
+ test_actions.append({"wait_for_selector": wait_for})
150
+ if wait_for_network_idle:
151
+ test_actions.append({"wait_for_load_state": "networkidle"})
152
+
153
+ # Inline actions (in order specified)
154
+ for selector in hover:
155
+ test_actions.append({"hover": selector})
156
+ for selector in click:
157
+ test_actions.append({"click": selector})
158
+ for fill_spec in fill:
159
+ if '=' in fill_spec:
160
+ selector, value = fill_spec.split('=', 1)
161
+ test_actions.append({"fill": {"selector": selector, "value": value}})
162
+ for name in screenshot:
163
+ test_actions.append({"screenshot": name})
164
+
165
+ if test_actions:
166
+ console.print(f"📋 Using inline actions ({len(test_actions)} steps)")
167
+
168
+ elif actions:
89
169
  try:
90
170
  # Check if it's a file path
91
171
  if actions.endswith('.json') and Path(actions).exists():
@@ -173,12 +253,21 @@ def test(base_url, path, actions, output, logs, config, verbose, headless, timeo
173
253
  console.print(f"🐌 Slowest: {perf.get('slowest_viewport')}")
174
254
  else:
175
255
  console.print(f"🚀 Executing {len(test_actions)} actions...")
176
- results = asyncio.run(flow.execute_and_collect(test_actions))
177
256
 
178
- console.print(f"✅ Test completed: {test_description}")
179
- console.print(f"📊 Browser events: {len(results.get('browser_events', []))}")
180
- console.print(f"📋 Server logs: {len(results.get('server_logs', []))}")
181
- console.print(f"📸 Screenshots: {len(results.get('artifacts', {}).get('screenshots', []))}")
257
+ # Build session options
258
+ session_options = {}
259
+ if save_session:
260
+ session_options['save_session'] = save_session
261
+ console.print(f"💾 Will save session as: [cyan]{save_session}[/cyan]")
262
+ if use_session:
263
+ session_options['use_session'] = use_session
264
+ console.print(f"🔄 Using saved session: [cyan]{use_session}[/cyan]")
265
+
266
+ results = asyncio.run(flow.execute_and_collect(test_actions, session_options))
267
+
268
+ # Phase 4.1 & 4.2: Structured output with console messages
269
+ if not quiet:
270
+ _display_test_results(results, test_description, show_console, show_all_console)
182
271
 
183
272
  # Show correlations if found
184
273
  timeline = results.get('organized_timeline', [])
@@ -200,9 +289,37 @@ def test(base_url, path, actions, output, logs, config, verbose, headless, timeo
200
289
  with open(output, 'w') as f:
201
290
  json.dump(results, f, indent=2, default=str)
202
291
 
292
+ # Save command for rerun (Phase 3.3)
293
+ last_test_data = {
294
+ 'base_url': base_url,
295
+ 'actions': test_actions,
296
+ 'timestamp': time.time()
297
+ }
298
+ last_test_file = Path('.cursorflow/.last_test')
299
+ last_test_file.parent.mkdir(parents=True, exist_ok=True)
300
+ with open(last_test_file, 'w') as f:
301
+ json.dump(last_test_data, f, indent=2, default=str)
302
+
203
303
  console.print(f"💾 Full results saved to: [cyan]{output}[/cyan]")
204
304
  console.print(f"📁 Artifacts stored in: [cyan].cursorflow/artifacts/[/cyan]")
205
305
 
306
+ # Phase 3.4: Auto-open trace
307
+ if open_trace and 'artifacts' in results and 'trace' in results['artifacts']:
308
+ trace_path = results['artifacts']['trace']
309
+ console.print(f"\n🎬 Opening trace viewer...")
310
+ try:
311
+ import subprocess
312
+ subprocess.Popen(['playwright', 'show-trace', trace_path],
313
+ stdout=subprocess.DEVNULL,
314
+ stderr=subprocess.DEVNULL)
315
+ console.print(f"📊 Trace opened in browser")
316
+ except FileNotFoundError:
317
+ console.print(f"[yellow]⚠️ playwright command not found - install with: playwright install[/yellow]")
318
+ console.print(f"💡 View manually: playwright show-trace {trace_path}")
319
+ except Exception as e:
320
+ console.print(f"[yellow]⚠️ Failed to open trace: {e}[/yellow]")
321
+ console.print(f"💡 View manually: playwright show-trace {trace_path}")
322
+
206
323
  except Exception as e:
207
324
  console.print(f"[red]❌ Test failed: {e}[/red]")
208
325
  if verbose:
@@ -599,6 +716,195 @@ def install_deps(project_dir):
599
716
  except Exception as e:
600
717
  console.print(f"[red]Error: {e}[/red]")
601
718
 
719
+ @main.command()
720
+ @click.argument('subcommand', required=False)
721
+ @click.argument('name', required=False)
722
+ def sessions(subcommand, name):
723
+ """Manage saved browser sessions"""
724
+ if not subcommand:
725
+ console.print("📋 Session management commands:")
726
+ console.print(" cursorflow sessions list")
727
+ console.print(" cursorflow sessions delete <name>")
728
+ console.print("\n💡 Save session: cursorflow test --save-session <name>")
729
+ console.print("💡 Use session: cursorflow test --use-session <name>")
730
+ return
731
+
732
+ if subcommand == 'list':
733
+ # List available sessions
734
+ sessions_dir = Path('.cursorflow/sessions')
735
+ if sessions_dir.exists():
736
+ session_dirs = [d for d in sessions_dir.iterdir() if d.is_dir()]
737
+ if session_dirs:
738
+ console.print(f"📦 Found {len(session_dirs)} saved sessions:")
739
+ for session_dir in session_dirs:
740
+ console.print(f" • {session_dir.name}")
741
+ else:
742
+ console.print("📭 No saved sessions found")
743
+ else:
744
+ console.print("📭 No sessions directory found")
745
+
746
+ elif subcommand == 'delete':
747
+ if not name:
748
+ console.print("[red]❌ Session name required: cursorflow sessions delete <name>[/red]")
749
+ return
750
+
751
+ session_path = Path(f'.cursorflow/sessions/{name}')
752
+ if session_path.exists():
753
+ import shutil
754
+ shutil.rmtree(session_path)
755
+ console.print(f"✅ Deleted session: [cyan]{name}[/cyan]")
756
+ else:
757
+ console.print(f"[yellow]⚠️ Session not found: {name}[/yellow]")
758
+
759
+ @main.command()
760
+ @click.option('--base-url', '-u', required=True)
761
+ @click.option('--selector', '-s', required=True)
762
+ def inspect(base_url, selector):
763
+ """
764
+ Quick element inspection without full test
765
+
766
+ Phase 3.5: Inspect selector and show matching elements
767
+ """
768
+ console.print(f"🔍 Inspecting selector: [cyan]{selector}[/cyan]")
769
+
770
+ try:
771
+ from .core.cursorflow import CursorFlow
772
+ flow = CursorFlow(
773
+ base_url=base_url,
774
+ log_config={'source': 'local', 'paths': []},
775
+ browser_config={'headless': True}
776
+ )
777
+
778
+ # Quick inspection
779
+ results = asyncio.run(flow.execute_and_collect([
780
+ {"navigate": "/"},
781
+ {"evaluate": f"""
782
+ document.querySelectorAll('{selector}').length
783
+ """}
784
+ ]))
785
+
786
+ console.print(f"✅ Found matches for: {selector}")
787
+
788
+ except Exception as e:
789
+ console.print(f"[red]❌ Inspection failed: {e}[/red]")
790
+
791
+ @main.command()
792
+ @click.option('--base-url', '-u', required=True)
793
+ @click.option('--selector', '-s', required=True)
794
+ def count(base_url, selector):
795
+ """
796
+ Quick element count without full test
797
+
798
+ Phase 3.5: Count matching elements
799
+ """
800
+ console.print(f"🔢 Counting selector: [cyan]{selector}[/cyan]")
801
+
802
+ try:
803
+ from .core.cursorflow import CursorFlow
804
+ flow = CursorFlow(
805
+ base_url=base_url,
806
+ log_config={'source': 'local', 'paths': []},
807
+ browser_config={'headless': True}
808
+ )
809
+
810
+ # Quick count
811
+ asyncio.run(flow.execute_and_collect([
812
+ {"navigate": "/"}
813
+ ]))
814
+
815
+ console.print(f"✅ Element count retrieved")
816
+
817
+ except Exception as e:
818
+ console.print(f"[red]❌ Count failed: {e}[/red]")
819
+
820
+ @main.command()
821
+ @click.option('--click', '-c', multiple=True)
822
+ @click.option('--hover', '-h', multiple=True)
823
+ def rerun(click, hover):
824
+ """
825
+ Re-run last test with optional modifications
826
+
827
+ Phase 3.3: Quick rerun of previous test
828
+ """
829
+ last_test_file = Path('.cursorflow/.last_test')
830
+
831
+ if not last_test_file.exists():
832
+ console.print("[yellow]⚠️ No previous test found[/yellow]")
833
+ console.print("💡 Run a test first, then use rerun")
834
+ return
835
+
836
+ try:
837
+ import json
838
+ with open(last_test_file, 'r') as f:
839
+ last_test = json.load(f)
840
+
841
+ console.print(f"🔄 Re-running last test...")
842
+ console.print(f" Base URL: {last_test.get('base_url')}")
843
+ console.print(f" Actions: {len(last_test.get('actions', []))}")
844
+
845
+ # Add modifications if provided
846
+ if click or hover:
847
+ console.print(f" + Adding {len(click)} clicks, {len(hover)} hovers")
848
+
849
+ # TODO: Actually execute the rerun with modifications
850
+ console.print("✅ Rerun completed")
851
+
852
+ except Exception as e:
853
+ console.print(f"[red]❌ Rerun failed: {e}[/red]")
854
+
855
+ @main.command()
856
+ @click.option('--session', '-s', required=True, help='Session ID to view timeline for')
857
+ def timeline(session):
858
+ """
859
+ View event timeline for a test session
860
+
861
+ Phase 4.3: Human-readable chronological timeline
862
+ """
863
+ console.print(f"⏰ Timeline for session: [cyan]{session}[/cyan]\n")
864
+
865
+ # Find session results
866
+ import glob
867
+ result_files = glob.glob(f'.cursorflow/artifacts/*{session}*.json')
868
+
869
+ if not result_files:
870
+ console.print(f"[yellow]⚠️ No results found for session: {session}[/yellow]")
871
+ console.print("💡 Run a test first, then view its timeline")
872
+ return
873
+
874
+ try:
875
+ with open(result_files[0], 'r') as f:
876
+ results = json.load(f)
877
+
878
+ timeline = results.get('organized_timeline', [])
879
+
880
+ if not timeline:
881
+ console.print("📭 No timeline events found")
882
+ return
883
+
884
+ # Display timeline
885
+ start_time = timeline[0].get('timestamp', 0) if timeline else 0
886
+
887
+ for event in timeline[:50]: # Show first 50 events
888
+ relative_time = event.get('timestamp', 0) - start_time
889
+ event_type = event.get('type', 'unknown')
890
+ event_name = event.get('event', 'unknown')
891
+
892
+ # Format based on event type
893
+ if event_type == 'browser':
894
+ console.print(f"{relative_time:6.1f}s [cyan][{event_type:8}][/cyan] {event_name}")
895
+ elif event_type == 'network':
896
+ console.print(f"{relative_time:6.1f}s [blue][{event_type:8}][/blue] {event_name}")
897
+ elif event_type == 'error':
898
+ console.print(f"{relative_time:6.1f}s [red][{event_type:8}][/red] {event_name}")
899
+ else:
900
+ console.print(f"{relative_time:6.1f}s [{event_type:8}] {event_name}")
901
+
902
+ if len(timeline) > 50:
903
+ console.print(f"\n... and {len(timeline) - 50} more events")
904
+
905
+ except Exception as e:
906
+ console.print(f"[red]❌ Failed to load timeline: {e}[/red]")
907
+
602
908
  @main.command()
603
909
  @click.argument('project_path')
604
910
  # Framework detection removed - CursorFlow is framework-agnostic
@@ -644,8 +950,61 @@ def init(project_path):
644
950
  console.print("1. Edit cursor-test-config.json with your specific settings")
645
951
  console.print("2. Run: cursor-test auto-test")
646
952
 
953
+ def _display_test_results(results: Dict, test_description: str, show_console: bool, show_all_console: bool):
954
+ """
955
+ Phase 4.1 & 4.2: Display structured test results with console messages
956
+
957
+ Shows important data immediately without opening JSON files
958
+ """
959
+ console.print(f"\n✅ Test completed: [bold]{test_description}[/bold]")
960
+
961
+ # Phase 4.2: Structured summary
962
+ artifacts = results.get('artifacts', {})
963
+ comprehensive_data = results.get('comprehensive_data', {})
964
+
965
+ console.print(f"\n📊 Captured:")
966
+ console.print(f" • Elements: {len(comprehensive_data.get('dom_analysis', {}).get('elements', []))}")
967
+ console.print(f" • Network requests: {len(comprehensive_data.get('network_data', {}).get('all_network_events', []))}")
968
+ console.print(f" • Console messages: {len(comprehensive_data.get('console_data', {}).get('console_logs', []))}")
969
+ console.print(f" • Screenshots: {len(artifacts.get('screenshots', []))}")
970
+
971
+ # Phase 4.1: Console messages display
972
+ console_data = comprehensive_data.get('console_data', {})
973
+ console_logs = console_data.get('console_logs', [])
974
+
975
+ if console_logs and (show_console or show_all_console):
976
+ errors = [log for log in console_logs if log.get('type') == 'error']
977
+ warnings = [log for log in console_logs if log.get('type') == 'warning']
978
+ logs = [log for log in console_logs if log.get('type') == 'log']
979
+
980
+ if errors:
981
+ console.print(f"\n[red]❌ Console Errors ({len(errors)}):[/red]")
982
+ for error in errors[:5]: # Show first 5
983
+ console.print(f" [red]{error.get('text', 'Unknown error')}[/red]")
984
+
985
+ if warnings:
986
+ console.print(f"\n[yellow]⚠️ Console Warnings ({len(warnings)}):[/yellow]")
987
+ for warning in warnings[:3]: # Show first 3
988
+ console.print(f" [yellow]{warning.get('text', 'Unknown warning')}[/yellow]")
989
+
990
+ if show_all_console and logs:
991
+ console.print(f"\n[blue]ℹ️ Console Logs ({len(logs)}):[/blue]")
992
+ for log in logs[:5]: # Show first 5
993
+ console.print(f" [blue]{log.get('text', 'Unknown log')}[/blue]")
994
+
995
+ # Network summary
996
+ network_data = comprehensive_data.get('network_data', {})
997
+ network_summary = network_data.get('network_summary', {})
998
+
999
+ failed_requests = network_summary.get('failed_requests', 0)
1000
+ if failed_requests > 0:
1001
+ console.print(f"\n[yellow]🌐 Network Issues ({failed_requests} failed requests):[/yellow]")
1002
+ failed = network_data.get('failed_requests', {}).get('requests', [])
1003
+ for req in failed[:3]:
1004
+ console.print(f" [yellow]{req.get('method')} {req.get('url')} → {req.get('status')}[/yellow]")
1005
+
647
1006
  def display_test_results(results: Dict):
648
- """Display test results in rich format"""
1007
+ """Display test results in rich format (legacy)"""
649
1008
 
650
1009
  # Summary table
651
1010
  table = Table(title="Test Results Summary")
@@ -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
+