7070from dataclasses import dataclass
7171from typing import TYPE_CHECKING , Any
7272
73+ from .failure_artifacts import FailureArtifactBuffer , FailureArtifactsOptions
7374from .models import Snapshot , SnapshotOptions
7475from .verification import AssertContext , AssertOutcome , Predicate
7576
@@ -138,6 +139,10 @@ def __init__(
138139 # Snapshot state
139140 self .last_snapshot : Snapshot | None = None
140141
142+ # Failure artifacts (Phase 1)
143+ self ._artifact_buffer : FailureArtifactBuffer | None = None
144+ self ._artifact_timer_task : asyncio .Task | None = None
145+
141146 # Cached URL (updated on snapshot or explicit get_url call)
142147 self ._cached_url : str | None = None
143148
@@ -250,6 +255,90 @@ async def snapshot(self, **kwargs: Any) -> Snapshot:
250255 self .last_snapshot = await backend_snapshot (self .backend , options = options )
251256 return self .last_snapshot
252257
258+ async def enable_failure_artifacts (
259+ self ,
260+ options : FailureArtifactsOptions | None = None ,
261+ ) -> None :
262+ """
263+ Enable failure artifact buffer (Phase 1).
264+ """
265+ opts = options or FailureArtifactsOptions ()
266+ self ._artifact_buffer = FailureArtifactBuffer (
267+ run_id = self .tracer .run_id ,
268+ options = opts ,
269+ )
270+ if opts .fps > 0 :
271+ self ._artifact_timer_task = asyncio .create_task (self ._artifact_timer_loop ())
272+
273+ def disable_failure_artifacts (self ) -> None :
274+ """
275+ Disable failure artifact buffer and stop background capture.
276+ """
277+ if self ._artifact_timer_task :
278+ self ._artifact_timer_task .cancel ()
279+ self ._artifact_timer_task = None
280+
281+ async def record_action (
282+ self ,
283+ action : str ,
284+ * ,
285+ url : str | None = None ,
286+ ) -> None :
287+ """
288+ Record an action in the artifact timeline and capture a frame if enabled.
289+ """
290+ if not self ._artifact_buffer :
291+ return
292+ self ._artifact_buffer .record_step (
293+ action = action ,
294+ step_id = self .step_id ,
295+ step_index = self .step_index ,
296+ url = url ,
297+ )
298+ if self ._artifact_buffer .options .capture_on_action :
299+ await self ._capture_artifact_frame ()
300+
301+ async def _capture_artifact_frame (self ) -> None :
302+ if not self ._artifact_buffer :
303+ return
304+ try :
305+ image_bytes = await self .backend .screenshot_png ()
306+ except Exception :
307+ return
308+ self ._artifact_buffer .add_frame (image_bytes , fmt = "png" )
309+
310+ async def _artifact_timer_loop (self ) -> None :
311+ if not self ._artifact_buffer :
312+ return
313+ interval = 1.0 / max (0.001 , self ._artifact_buffer .options .fps )
314+ try :
315+ while True :
316+ await self ._capture_artifact_frame ()
317+ await asyncio .sleep (interval )
318+ except asyncio .CancelledError :
319+ return
320+
321+ def finalize_run (self , * , success : bool ) -> None :
322+ """
323+ Finalize artifact buffer at end of run.
324+ """
325+ if not self ._artifact_buffer :
326+ return
327+ if success :
328+ if self ._artifact_buffer .options .persist_mode == "always" :
329+ self ._artifact_buffer .persist (reason = "success" , status = "success" )
330+ self ._artifact_buffer .cleanup ()
331+ else :
332+ self ._persist_failure_artifacts (reason = "finalize_failure" )
333+
334+ def _persist_failure_artifacts (self , * , reason : str ) -> None :
335+ if not self ._artifact_buffer :
336+ return
337+ self ._artifact_buffer .persist (reason = reason , status = "failure" )
338+ self ._artifact_buffer .cleanup ()
339+ if self ._artifact_buffer .options .persist_mode == "onFail" :
340+ self .disable_failure_artifacts ()
341+
253342 def begin_step (self , goal : str , step_index : int | None = None ) -> str :
254343 """
255344 Begin a new step in the verification loop.
@@ -309,6 +398,8 @@ def assert_(
309398 kind = "assert" ,
310399 record_in_step = True ,
311400 )
401+ if required and not outcome .passed :
402+ self ._persist_failure_artifacts (reason = f"assert_failed:{ label } " )
312403 return outcome .passed
313404
314405 def check (self , predicate : Predicate , label : str , required : bool = False ) -> AssertionHandle :
@@ -619,6 +710,10 @@ async def eventually(
619710 "vision_fallback" : True ,
620711 },
621712 )
713+ if self .required and not passed :
714+ self .runtime ._persist_failure_artifacts (
715+ reason = f"assert_eventually_failed:{ self .label } "
716+ )
622717 return passed
623718 except Exception as e :
624719 # If vision fallback fails, fall through to snapshot_exhausted.
@@ -649,6 +744,10 @@ async def eventually(
649744 "exhausted" : True ,
650745 },
651746 )
747+ if self .required :
748+ self .runtime ._persist_failure_artifacts (
749+ reason = f"assert_eventually_failed:{ self .label } "
750+ )
652751 return False
653752
654753 if time .monotonic () >= deadline :
@@ -666,6 +765,10 @@ async def eventually(
666765 "timeout" : True ,
667766 },
668767 )
768+ if self .required :
769+ self .runtime ._persist_failure_artifacts (
770+ reason = f"assert_eventually_timeout:{ self .label } "
771+ )
669772 return False
670773
671774 await asyncio .sleep (poll_s )
@@ -705,6 +808,10 @@ async def eventually(
705808 record_in_step = True ,
706809 extra = {"eventually" : True , "attempt" : attempt , "final" : True , "timeout" : True },
707810 )
811+ if self .required :
812+ self .runtime ._persist_failure_artifacts (
813+ reason = f"assert_eventually_timeout:{ self .label } "
814+ )
708815 return False
709816
710817 await asyncio .sleep (poll_s )
0 commit comments