@@ -150,13 +150,19 @@ def __init__(
150150 # Execution state
151151 self .variables = {} # For template variable substitution
152152 self .screenshots = []
153+ self .verification_screenshots = [] # Screenshots marked for inclusion in result
153154 self .escalation_engine : Optional [ProgressiveEscalationEngine ] = None
154155 # Internal counter for externally-driven workflow steps
155156 self ._workflow_step_counter = 0
156157
157158 # Default delay between actions (can be overridden per-step or at script level)
158159 self .default_delay = 0
159160
161+ # Screenshot upload configuration
162+ self .s3_prefix = "verification" # Default S3 prefix for screenshots
163+ self .execution_id = None # Will be set from variables or generated
164+ self .local_screenshot_dir = None # For local testing mode
165+
160166 def _compute_delay (self , delay_config : Union [int , Dict [str , int ], None ], script_default : int = 0 ) -> int :
161167 """
162168 Compute delay in milliseconds from various config formats.
@@ -336,6 +342,82 @@ async def _find_form_field(self, label: str, field_type: str = "input") -> Any:
336342
337343 raise Exception (f"Could not find form field with label '{ label } ' using any strategy" )
338344
345+ async def _upload_single_screenshot (self , filename : str , screenshot_bytes : bytes , step : Dict [str , Any ] = None ) -> str :
346+ """
347+ Upload a single screenshot to S3 and return the URI.
348+
349+ Args:
350+ filename: Name of the screenshot file
351+ screenshot_bytes: PNG image data
352+ step: Optional step config for custom S3 prefix
353+
354+ Returns:
355+ S3 URI of the uploaded screenshot
356+ """
357+ if not self .s3_bucket :
358+ raise Exception ("S3 bucket not configured for screenshot upload" )
359+
360+ s3_client = self .boto_session .client ('s3' )
361+
362+ # Get S3 prefix from step config or use default
363+ s3_prefix = self .s3_prefix
364+ if step and step .get ("s3_prefix" ):
365+ s3_prefix = step ["s3_prefix" ]
366+
367+ # Generate execution ID if not set
368+ if not self .execution_id :
369+ import time
370+ self .execution_id = f"exec_{ int (time .time ())} "
371+
372+ # Build S3 key
373+ s3_key = f"{ s3_prefix } /{ self .execution_id } /{ filename } "
374+
375+ print (f" 📤 Uploading screenshot to s3://{ self .s3_bucket } /{ s3_key } " , file = sys .stderr )
376+
377+ s3_client .put_object (
378+ Bucket = self .s3_bucket ,
379+ Key = s3_key ,
380+ Body = screenshot_bytes ,
381+ ContentType = 'image/png' ,
382+ )
383+
384+ s3_uri = f"s3://{ self .s3_bucket } /{ s3_key } "
385+ print (f" ✓ Screenshot uploaded: { s3_uri } " , file = sys .stderr )
386+
387+ return s3_uri
388+
389+ async def _save_screenshot_locally (self , filename : str , screenshot_bytes : bytes ) -> str :
390+ """
391+ Save a screenshot to the local filesystem (for testing without S3).
392+
393+ Args:
394+ filename: Name of the screenshot file
395+ screenshot_bytes: PNG image data
396+
397+ Returns:
398+ Local file path of the saved screenshot
399+ """
400+ from pathlib import Path
401+
402+ # Use configured directory or default to ./screenshots
403+ local_dir = Path (self .local_screenshot_dir ) if self .local_screenshot_dir else Path ("./screenshots" )
404+ local_dir .mkdir (parents = True , exist_ok = True )
405+
406+ # Generate execution ID subdirectory
407+ if not self .execution_id :
408+ import time
409+ self .execution_id = f"exec_{ int (time .time ())} "
410+
411+ exec_dir = local_dir / self .execution_id
412+ exec_dir .mkdir (parents = True , exist_ok = True )
413+
414+ local_path = exec_dir / filename
415+ local_path .write_bytes (screenshot_bytes )
416+
417+ print (f" 💾 Screenshot saved locally: { local_path } " , file = sys .stderr )
418+
419+ return str (local_path )
420+
339421 async def execute_step (self , step : Dict [str , Any ]) -> Dict [str , Any ]:
340422 """Public step executor used by WorkflowExecutor.
341423
@@ -443,6 +525,22 @@ async def execute_script(self, script: Dict[str, Any]) -> Dict[str, Any]:
443525 # Can be overridden per-step with "delay" field
444526 self .default_delay = script .get ("default_delay" , 0 )
445527
528+ # Screenshot configuration
529+ screenshot_config = script .get ("screenshot_config" , {})
530+ self .s3_prefix = screenshot_config .get ("s3_prefix" , "verification" )
531+ self .local_screenshot_dir = screenshot_config .get ("local_dir" , None )
532+
533+ # Set execution ID from variables, script, or generate one
534+ import time
535+ self .execution_id = (
536+ self .variables .get ("execution_id" ) or
537+ script .get ("execution_id" ) or
538+ f"exec_{ name .replace (' ' , '_' ).lower ()} _{ int (time .time ())} "
539+ )
540+
541+ # Reset verification screenshots for this execution
542+ self .verification_screenshots = []
543+
446544 print (f"\n { '=' * 60 } " , file = sys .stderr )
447545 print (f"OpenAI Playwright Executor" , file = sys .stderr )
448546 print (f"{ '=' * 60 } " , file = sys .stderr )
@@ -451,6 +549,9 @@ async def execute_script(self, script: Dict[str, Any]) -> Dict[str, Any]:
451549 print (f"LLM: { self .llm_provider } ({ self .llm_model } )" , file = sys .stderr )
452550 print (f"Starting Page: { starting_page } " , file = sys .stderr )
453551 print (f"Total Steps: { len (steps )} " , file = sys .stderr )
552+ print (f"Execution ID: { self .execution_id } " , file = sys .stderr )
553+ if self .s3_bucket :
554+ print (f"Screenshots: s3://{ self .s3_bucket } /{ self .s3_prefix } /{ self .execution_id } /" , file = sys .stderr )
454555 print (f"{ '=' * 60 } \n " , file = sys .stderr )
455556
456557 result = {
@@ -635,10 +736,21 @@ async def _execute_linear_steps(self, steps: List[Dict[str, Any]], result: Dict[
635736 result ["error" ] = error_msg
636737 break
637738
638- # Upload screenshots to S3 if configured
739+ # Upload screenshots to S3 if configured (batch upload for non-immediate screenshots)
639740 if self .s3_bucket and self .screenshots :
640741 result ["screenshots" ] = await self ._upload_screenshots (name )
641742
743+ # Include verification screenshots in result (for user verification)
744+ if self .verification_screenshots :
745+ result ["verification_screenshots" ] = self .verification_screenshots
746+ print (f"\n 📸 Verification screenshots: { len (self .verification_screenshots )} " , file = sys .stderr )
747+ for vs in self .verification_screenshots :
748+ loc = vs .get ("s3_uri" ) or vs .get ("local_path" , "unknown" )
749+ print (f" • { vs ['filename' ]} : { loc } " , file = sys .stderr )
750+
751+ # Add execution metadata
752+ result ["execution_id" ] = self .execution_id
753+
642754 print (f"\n { '=' * 60 } " , file = sys .stderr )
643755 print (f"Execution { '✓ Complete' if result ['success' ] else '✗ Failed' } " , file = sys .stderr )
644756 print (f"Steps executed: { result ['steps_executed' ]} /{ result ['steps_total' ]} " , file = sys .stderr )
@@ -1212,22 +1324,76 @@ async def _step_wait_for_load_state(self, step: Dict[str, Any], step_num: int) -
12121324 }
12131325
12141326 async def _step_screenshot (self , step : Dict [str , Any ], step_num : int ) -> Dict [str , Any ]:
1215- """Take screenshot"""
1216- screenshot_bytes = await self .page .screenshot (full_page = True )
1327+ """
1328+ Take screenshot with optional S3 upload.
1329+
1330+ Supports:
1331+ - save_to: Filename for the screenshot (default: step_{num}.png)
1332+ - upload_to_s3: Immediately upload to S3 (default: False)
1333+ - include_in_result: Include S3 URI in final result for verification (default: False)
1334+ - s3_prefix: Custom S3 prefix (default: uses self.s3_prefix)
1335+ - full_page: Take full page screenshot (default: True)
1336+ """
1337+ full_page = step .get ("full_page" , True )
1338+ screenshot_bytes = await self .page .screenshot (full_page = full_page )
12171339
12181340 filename = step .get ("save_to" , f"step_{ step_num } .png" )
1219- self .screenshots .append ({
1341+ upload_to_s3 = step .get ("upload_to_s3" , False )
1342+ include_in_result = step .get ("include_in_result" , False )
1343+
1344+ # Store screenshot data in memory
1345+ screenshot_data = {
12201346 "filename" : filename ,
12211347 "data" : screenshot_bytes ,
12221348 "step" : step_num ,
1223- })
1349+ "upload_to_s3" : upload_to_s3 ,
1350+ "include_in_result" : include_in_result ,
1351+ }
1352+ self .screenshots .append (screenshot_data )
12241353
1225- return {
1354+ result = {
12261355 "success" : True ,
12271356 "action" : "screenshot" ,
12281357 "filename" : filename ,
12291358 }
12301359
1360+ # Immediate upload if requested
1361+ if upload_to_s3 :
1362+ if self .s3_bucket :
1363+ try :
1364+ s3_uri = await self ._upload_single_screenshot (filename , screenshot_bytes , step )
1365+ result ["screenshot_s3_uri" ] = s3_uri
1366+
1367+ # Track for inclusion in final result
1368+ if include_in_result :
1369+ self .verification_screenshots .append ({
1370+ "filename" : filename ,
1371+ "s3_uri" : s3_uri ,
1372+ "step" : step_num ,
1373+ "step_name" : step .get ("description" , f"Step { step_num } " ),
1374+ })
1375+ except Exception as e :
1376+ print (f" ⚠ Failed to upload screenshot: { e } " , file = sys .stderr )
1377+ result ["upload_error" ] = str (e )
1378+ else :
1379+ # Local fallback when S3 not configured
1380+ try :
1381+ local_path = await self ._save_screenshot_locally (filename , screenshot_bytes )
1382+ result ["local_path" ] = local_path
1383+
1384+ if include_in_result :
1385+ self .verification_screenshots .append ({
1386+ "filename" : filename ,
1387+ "local_path" : local_path ,
1388+ "step" : step_num ,
1389+ "step_name" : step .get ("description" , f"Step { step_num } " ),
1390+ })
1391+ except Exception as e :
1392+ print (f" ⚠ Failed to save screenshot locally: { e } " , file = sys .stderr )
1393+ result ["save_error" ] = str (e )
1394+
1395+ return result
1396+
12311397 async def _step_extract (self , step : Dict [str , Any ], step_num : int ) -> Dict [str , Any ]:
12321398 """Extract data using LLM vision and optionally execute action"""
12331399 method = step .get ("method" , "vision" )
@@ -1559,6 +1725,7 @@ async def main():
15591725 parser .add_argument ("--llm-model" , default = "gpt-4o-mini" , help = "LLM model name" )
15601726 parser .add_argument ("--aws-profile" , default = "browser-agent" , help = "AWS profile" )
15611727 parser .add_argument ("--s3-bucket" , help = "S3 bucket for screenshots" )
1728+ parser .add_argument ("--local-screenshots" , help = "Local directory for screenshots (for testing without S3)" )
15621729 parser .add_argument ("--headless" , action = "store_true" , help = "Run in headless mode" )
15631730 parser .add_argument ("--browser-channel" , help = "Browser channel (chrome, msedge, firefox)" )
15641731 parser .add_argument ("--user-data-dir" , help = "User data directory for profile" )
@@ -1580,6 +1747,10 @@ async def main():
15801747 user_data_dir = Path (args .user_data_dir ) if args .user_data_dir else None ,
15811748 )
15821749
1750+ # Set local screenshot directory for testing
1751+ if args .local_screenshots :
1752+ executor .local_screenshot_dir = args .local_screenshots
1753+
15831754 # Execute script
15841755 result = await executor .execute_script (script )
15851756
0 commit comments