From b2df23f7184710718d0d18d90393c421513c9155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Volf?= Date: Tue, 10 Nov 2026 15:42:29 +0100 Subject: [PATCH] fix: use run token in metamorph test, as metamorphing from outside is not allowed anymore --- tests/_utils.py | 32 +++++++++- tests/integration/conftest.py | 21 +------ tests/integration/test_run.py | 107 ++++++++++++++++++++++------------ 3 files changed, 104 insertions(+), 56 deletions(-) diff --git a/tests/_utils.py b/tests/_utils.py index 52cd8031..2e50801d 100644 --- a/tests/_utils.py +++ b/tests/_utils.py @@ -2,15 +2,20 @@ import asyncio import inspect +import os import secrets import string import time -from collections.abc import AsyncIterator, Iterator +from collections.abc import AsyncGenerator, AsyncIterator, Iterator +from contextlib import asynccontextmanager from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Protocol, TypeVar, cast, overload import pytest +from apify_client import ApifyClient, ApifyClientAsync +from apify_client._consts import DEFAULT_API_URL + if TYPE_CHECKING: from collections.abc import Awaitable, Callable @@ -65,6 +70,31 @@ class HttpClientClasses: async_: type[HttpClientAsync] +@asynccontextmanager +async def make_client( + token: str, client_type: str, http_client_classes: HttpClientClasses +) -> AsyncGenerator[ApifyClient | ApifyClientAsync]: + """Build a sync or async client with the given token and HTTP client implementation, closing it on exit.""" + api_url = os.getenv(API_URL_ENV_VAR) or DEFAULT_API_URL + if client_type == 'sync': + http_client = http_client_classes.sync() + yield ApifyClient.with_custom_http_client( + token, + api_url=api_url, + http_client=http_client, + ) + http_client.close() + return + + http_client_async = http_client_classes.async_() + yield ApifyClientAsync.with_custom_http_client( + token, + api_url=api_url, + http_client=http_client_async, + ) + await http_client_async.aclose() + + def get_crypto_random_object_id(length: int = 17) -> str: """Generate a cryptographically secure random object ID.""" chars = 'abcdefghijklmnopqrstuvwxyzABCEDFGHIJKLMNOPQRSTUVWXYZ0123456789' diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index b57a8647..29a6f5d4 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -14,6 +14,7 @@ HttpClientClasses, KvsFixture, get_crypto_random_object_id, + make_client, ) from apify_client import ApifyClient, ApifyClientAsync from apify_client._consts import DEFAULT_API_URL @@ -132,24 +133,8 @@ async def client( http_client_classes: HttpClientClasses, ) -> AsyncGenerator[ApifyClient | ApifyClientAsync]: """Return each sync/async and HTTP client implementation combination.""" - api_url = os.getenv(API_URL_ENV_VAR) or DEFAULT_API_URL - if client_type == 'sync': - http_client = http_client_classes.sync() - yield ApifyClient.with_custom_http_client( - api_token, - api_url=api_url, - http_client=http_client, - ) - http_client.close() - return - - http_client_async = http_client_classes.async_() - yield ApifyClientAsync.with_custom_http_client( - api_token, - api_url=api_url, - http_client=http_client_async, - ) - await http_client_async.aclose() + async with make_client(api_token, client_type, http_client_classes) as client: + yield client @pytest.fixture diff --git a/tests/integration/test_run.py b/tests/integration/test_run.py index c5e0a46d..e0697a95 100644 --- a/tests/integration/test_run.py +++ b/tests/integration/test_run.py @@ -6,7 +6,7 @@ from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING -from .._utils import get_random_resource_name, maybe_await, poll_until_condition +from .._utils import HttpClientClasses, get_random_resource_name, make_client, maybe_await, poll_until_condition from apify_client._models import ( ActorResource, Build, @@ -316,48 +316,81 @@ async def test_run_runs_client(client: ApifyClient | ApifyClientAsync) -> None: assert first_run.act_id is not None -async def test_run_metamorph(client: ApifyClient | ApifyClientAsync) -> None: - """Test metamorphing a run into another Actor.""" - # Start an actor that will run long enough to metamorph. We use hello-world and try to metamorph it into itself - actor = client.actor(HELLO_WORLD_ACTOR) - run = await maybe_await(actor.start()) - assert isinstance(run, Run) - assert run.id is not None - - run_client = client.run(run.id) - - try: - # Wait until the run starts (leaves READY), with exponential backoff: container startup time varies widely - async def get_run() -> Run | None: - return await maybe_await(run_client.get()) - - await poll_until_condition( - get_run, lambda run: isinstance(run, Run) and run.status != 'READY', backoff_factor=2 +async def test_run_metamorph(client_type: str, http_client_classes: HttpClientClasses, api_token: str) -> None: + """Test metamorphing a run into another Actor. + + We have to cheat a bit - metamorphing from outside is disallowed, so we need the Actor to leak its run token into + the key-value store, which we then use to construct a new client and do the test we want. + """ + async with make_client(api_token, client_type, http_client_classes) as default_client: + created_actor = await maybe_await( + default_client.actors().create( + name=get_random_resource_name('actor'), + versions=[ + { + 'versionNumber': '0.0', + 'sourceType': 'SOURCE_FILES', + 'buildTag': 'latest', + # The Actor stores its own run token in its key-value store, so the test can metamorph with it. + 'sourceFiles': [ + { + 'name': 'Dockerfile', + 'format': 'TEXT', + 'content': 'FROM apify/actor-node:22\nCOPY . ./\nCMD ["node", "main.js"]\n', + }, + { + 'name': 'main.js', + 'format': 'TEXT', + 'content': """ + const { Actor } = require('apify'); + Actor.main(async () => { + await Actor.setValue( + 'TOKEN', process.env.APIFY_TOKEN, { contentType: 'text/plain' } + ); + await new Promise((resolve) => setTimeout(resolve, 60000)); + }); + """, + }, + ], + } + ], + ) ) + assert isinstance(created_actor, ActorResource) + actor_client = default_client.actor(created_actor.id) + run_client = None - # Metamorph the run into the same actor (allowed) with new input - # Note: hello-world may finish before we can metamorph, so we handle that case try: - metamorphed_run = await maybe_await( - run_client.metamorph( - target_actor_id=HELLO_WORLD_ACTOR, - run_input={'message': 'Hello from metamorph!'}, + # build and run the Actor + started_build = await maybe_await(actor_client.build(version_number='0.0')) + assert isinstance(started_build, Build) + await maybe_await(default_client.build(started_build.id).wait_for_finish()) + run = await maybe_await(actor_client.start()) + assert isinstance(run, Run) + run_client = default_client.run(run.id) + + # get the run token + async def get_token_record() -> dict | None: + return await maybe_await( + default_client.key_value_store(run.default_key_value_store_id).get_record('TOKEN') ) - ) - assert isinstance(metamorphed_run, Run) - assert metamorphed_run.id == run.id # Same run ID - # Wait for the metamorphed run to finish - final_run = await maybe_await(run_client.wait_for_finish()) - assert isinstance(final_run, Run) - except ApifyApiError as exc: - # If the actor finished before we could metamorph, that's OK - the test still verified the API call - if 'already finished' not in str(exc): - raise + record = await poll_until_condition(get_token_record, lambda rec: rec is not None, backoff_factor=2) + assert record is not None - finally: - await maybe_await(run_client.wait_for_finish()) - await maybe_await(run_client.delete()) + # the actual test + async with make_client(record['value'], client_type, http_client_classes) as run_token_client: + metamorphed_run = await maybe_await( + run_token_client.run(run.id).metamorph(target_actor_id=HELLO_WORLD_ACTOR) + ) + assert isinstance(metamorphed_run, Run) + assert metamorphed_run.id == run.id + finally: + if run_client is not None: + await maybe_await(run_client.abort()) + await maybe_await(run_client.wait_for_finish()) + await maybe_await(run_client.delete()) + await maybe_await(actor_client.delete()) async def test_run_reboot(client: ApifyClient | ApifyClientAsync) -> None: