55import json
66import sys
77from datetime import datetime , timezone
8- from typing import TYPE_CHECKING , Any , cast
8+ from typing import TYPE_CHECKING , Any
99from unittest import mock
1010from unittest .mock import AsyncMock , Mock
1111
1212import pytest
1313import websockets .asyncio .server
1414
15- from apify_shared .consts import ActorEnvVars , ApifyEnvVars
15+ from apify_shared .consts import ActorEnvVars , ActorExitCodes , ApifyEnvVars
1616from crawlee .events ._types import Event , EventPersistStateData
1717
18- import apify ._actor
1918from apify import Actor
2019
2120if TYPE_CHECKING :
22- from collections .abc import Callable
21+ from collections .abc import AsyncGenerator , Callable
22+
23+ from apify ._actor import _ActorType
24+
25+
26+ @pytest .fixture (
27+ params = [
28+ pytest .param (('instance' , 'manual' ), id = 'instance-manual' ),
29+ pytest .param (('instance' , 'async_with' ), id = 'instance-async-with' ),
30+ pytest .param (('class' , 'manual' ), id = 'class-manual' ),
31+ pytest .param (('class' , 'async_with' ), id = 'class-async-with' ),
32+ ]
33+ )
34+ async def actor (
35+ request : pytest .FixtureRequest ,
36+ ) -> AsyncGenerator [_ActorType , None ]:
37+ """Yield Actor instance or class in different initialization modes.
38+
39+ - instance-manual: Actor() with manual init()/exit()
40+ - instance-async-with: Actor() used as async context manager
41+ - class-manual: Actor class with manual init()/exit()
42+ - class-async-with: Actor class used as async context manager
43+
44+ Each Actor is properly initialized before yielding and cleaned up after.
45+ """
46+ scope , mode = request .param
47+
48+ if scope == 'instance' :
49+ if mode == 'manual' :
50+ instance = Actor ()
51+ await instance .init ()
52+ yield instance
53+ await instance .exit ()
54+ else :
55+ async with Actor () as instance :
56+ yield instance
57+
58+ elif scope == 'class' :
59+ if mode == 'manual' :
60+ await Actor .init ()
61+ yield Actor
62+ await Actor .exit ()
63+ else :
64+ async with Actor :
65+ yield Actor
2366
24- from lazy_object_proxy import Proxy
67+ else :
68+ raise ValueError (f'Unknown scope: { scope } ' )
2569
2670
27- async def test_actor_properly_init_with_async () -> None :
28- async with Actor :
29- assert cast ('Proxy' , apify ._actor .Actor ).__wrapped__ is not None
30- assert cast ('Proxy' , apify ._actor .Actor ).__wrapped__ ._is_initialized
31- assert not cast ('Proxy' , apify ._actor .Actor ).__wrapped__ ._is_initialized
71+ async def test_actor_init_instance_manual () -> None :
72+ """Test that Actor instance can be properly initialized and cleaned up manually."""
73+ actor = Actor ()
74+ await actor .init ()
75+ assert actor ._is_initialized is True
76+ await actor .exit ()
77+ assert actor ._is_initialized is False
3278
3379
34- async def test_actor_init () -> None :
35- my_actor = Actor ()
80+ async def test_actor_init_instance_async_with () -> None :
81+ """Test that Actor instance can be properly initialized and cleaned up using async context manager."""
82+ actor = Actor ()
83+ async with actor :
84+ assert actor ._is_initialized is True
3685
37- await my_actor .init ()
38- assert my_actor ._is_initialized is True
86+ assert actor ._is_initialized is False
3987
40- await my_actor .exit ()
41- assert my_actor ._is_initialized is False
4288
89+ async def test_actor_init_class_manual () -> None :
90+ """Test that Actor class can be properly initialized and cleaned up manually."""
91+ await Actor .init ()
92+ assert Actor ._is_initialized is True
93+ await Actor .exit ()
94+ assert not Actor ._is_initialized
4395
44- async def test_double_init_raises_error (prepare_test_env : Callable ) -> None :
96+
97+ async def test_actor_init_class_async_with () -> None :
98+ """Test that Actor class can be properly initialized and cleaned up using async context manager."""
4599 async with Actor :
46- assert Actor ._is_initialized
47- with pytest .raises (RuntimeError ):
48- await Actor .init ()
100+ assert Actor ._is_initialized is True
49101
50- prepare_test_env ()
102+ assert not Actor . _is_initialized
51103
52- async with Actor () as actor :
53- assert actor ._is_initialized
54- with pytest .raises (RuntimeError ):
55- await actor .init ()
56104
57- prepare_test_env ()
105+ async def test_fail_properly_deinitializes_actor (actor : _ActorType ) -> None :
106+ """Test that fail() method properly deinitializes the Actor."""
107+ assert actor ._is_initialized
108+ await actor .fail ()
109+ assert actor ._is_initialized is False
58110
59- async with Actor () as actor :
60- assert actor ._is_initialized
61- with pytest .raises (RuntimeError ):
62- await actor .init ()
63111
112+ async def test_actor_handles_exceptions_and_cleans_up_properly () -> None :
113+ """Test that Actor properly cleans up when an exception occurs in the async context manager."""
114+ actor = None
115+
116+ with contextlib .suppress (Exception ):
117+ async with Actor () as actor :
118+ assert actor ._is_initialized
119+ raise Exception ('Failed' ) # noqa: TRY002
120+
121+ assert actor is not None
122+ assert actor ._is_initialized is False
123+
124+
125+ async def test_double_init_raises_runtime_error (actor : _ActorType ) -> None :
126+ """Test that attempting to initialize an already initialized Actor raises RuntimeError."""
127+ assert actor ._is_initialized
128+ with pytest .raises (RuntimeError ):
129+ await actor .init ()
130+
131+
132+ async def test_exit_without_init_raises_runtime_error () -> None :
133+ """Test that calling exit() on an uninitialized Actor raises RuntimeError."""
134+ with pytest .raises (RuntimeError ):
135+ await Actor .exit ()
136+
137+ with pytest .raises (RuntimeError ):
138+ await Actor ().exit ()
139+
140+
141+ async def test_fail_without_init_raises_runtime_error () -> None :
142+ """Test that calling fail() on an uninitialized Actor raises RuntimeError."""
143+ with pytest .raises (RuntimeError ):
144+ await Actor .fail ()
145+
146+ with pytest .raises (RuntimeError ):
147+ await Actor ().fail ()
148+
149+
150+ async def test_reboot_in_local_environment_logs_error_message (
151+ actor : _ActorType ,
152+ caplog : pytest .LogCaptureFixture ,
153+ ) -> None :
154+ """Test that reboot() logs an error when not running on the Apify platform."""
155+ await actor .reboot ()
156+
157+ # Check that the error message was logged
158+ assert 'Actor.reboot() is only supported when running on the Apify platform.' in caplog .text
64159
65- async def test_actor_exits_cleanly_with_events (monkeypatch : pytest .MonkeyPatch ) -> None :
160+
161+ async def test_exit_sets_custom_exit_code_and_status_message (actor : _ActorType ) -> None :
162+ """Test that exit() properly sets custom exit code and status message."""
163+ await actor .exit (exit_code = 42 , status_message = 'Exiting with code 42' )
164+ assert actor .exit_code == 42
165+ assert actor .status_message == 'Exiting with code 42'
166+
167+
168+ async def test_fail_sets_custom_exit_code_and_status_message (actor : _ActorType ) -> None :
169+ """Test that fail() properly sets custom exit code and status message."""
170+ await actor .fail (exit_code = 99 , status_message = 'Failing with code 99' )
171+ assert actor .exit_code == 99
172+ assert actor .status_message == 'Failing with code 99'
173+
174+
175+ async def test_unhandled_exception_sets_error_exit_code () -> None :
176+ """Test that unhandled exceptions in context manager set the error exit code."""
177+ actor = Actor (exit_process = False )
178+ with pytest .raises (RuntimeError ):
179+ async with actor :
180+ raise RuntimeError ('Test error' )
181+
182+ assert actor .exit_code == ActorExitCodes .ERROR_USER_FUNCTION_THREW .value
183+
184+
185+ async def test_actor_stops_periodic_events_after_exit (monkeypatch : pytest .MonkeyPatch ) -> None :
186+ """Test that periodic events (PERSIST_STATE and SYSTEM_INFO) stop emitting after Actor exits."""
66187 monkeypatch .setenv (ApifyEnvVars .SYSTEM_INFO_INTERVAL_MILLIS , '100' )
67188 monkeypatch .setenv (ApifyEnvVars .PERSIST_STATE_INTERVAL_MILLIS , '100' )
68189 on_persist = []
@@ -77,11 +198,11 @@ def on_event(event_type: Event) -> Callable:
77198 return lambda data : on_system_info .append (data )
78199 return lambda data : print (data )
79200
80- my_actor = Actor ()
81- async with my_actor :
82- assert my_actor ._is_initialized
83- my_actor .on (Event .PERSIST_STATE , on_event (Event .PERSIST_STATE ))
84- my_actor .on (Event .SYSTEM_INFO , on_event (Event .SYSTEM_INFO ))
201+ actor = Actor ()
202+ async with actor :
203+ assert actor ._is_initialized
204+ actor .on (Event .PERSIST_STATE , on_event (Event .PERSIST_STATE ))
205+ actor .on (Event .SYSTEM_INFO , on_event (Event .SYSTEM_INFO ))
85206 await asyncio .sleep (1 )
86207
87208 on_persist_count = len (on_persist )
@@ -95,43 +216,9 @@ def on_event(event_type: Event) -> Callable:
95216 assert on_system_info_count == len (on_system_info )
96217
97218
98- async def test_exit_without_init_raises_error () -> None :
99- with pytest .raises (RuntimeError ):
100- await Actor .exit ()
101-
102-
103- async def test_actor_fails_cleanly () -> None :
104- async with Actor () as actor :
105- assert actor ._is_initialized
106- await actor .fail ()
107-
108- assert actor ._is_initialized is False
109-
110-
111- async def test_actor_handles_failure_gracefully () -> None :
112- my_actor = None
113-
114- with contextlib .suppress (Exception ):
115- async with Actor () as my_actor :
116- assert my_actor ._is_initialized
117- raise Exception ('Failed' ) # noqa: TRY002
118-
119- assert my_actor is not None
120- assert my_actor ._is_initialized is False
121-
122-
123- async def test_fail_without_init_raises_error () -> None :
124- with pytest .raises (RuntimeError ):
125- await Actor .fail ()
126-
127-
128- async def test_actor_reboot_fails_locally () -> None :
129- with pytest .raises (RuntimeError ):
130- await Actor .reboot ()
131-
132-
133219@pytest .mark .skipif (sys .version_info >= (3 , 13 ), reason = 'Suffers flaky behavior on Python 3.13' )
134220async def test_actor_handles_migrating_event_correctly (monkeypatch : pytest .MonkeyPatch ) -> None :
221+ """Test that Actor handles MIGRATING events correctly by emitting PERSIST_STATE."""
135222 # This should test whether when you get a MIGRATING event,
136223 # the Actor automatically emits the PERSIST_STATE event with data `{'isMigrating': True}`
137224 monkeypatch .setenv (ApifyEnvVars .PERSIST_STATE_INTERVAL_MILLIS , '500' )
0 commit comments