"""Unit tests for the step/task archive accumulation logic in ArtifactManager. These tests exercise the in-memory accumulation helpers and the ZIP-building utility without touching S3 or the database. """ import io import zipfile from unittest.mock import AsyncMock, MagicMock, patch import pytest from skyvern.forge.sdk.artifact.manager import ArtifactManager, StepArchiveAccumulator from skyvern.forge.sdk.artifact.models import ArtifactType from skyvern.forge.sdk.artifact.storage.test_helpers import create_fake_step TEST_STEP_ID = "step_archive_test_001" TEST_STEP_ID_2 = "step_archive_test_002" # --------------------------------------------------------------------------- # StepArchiveAccumulator helpers # --------------------------------------------------------------------------- class TestAddToStepArchive: """Tests for ArtifactManager._add_to_step_archive.""" def _make_acc(self, step_id: str = TEST_STEP_ID) -> StepArchiveAccumulator: step = create_fake_step(step_id) return StepArchiveAccumulator( step=step, workflow_run_id=None, workflow_run_block_id=None, run_id=None, ) def test_add_single_entry_returns_stable_artifact_id(self) -> None: manager = ArtifactManager() acc = self._make_acc() aid = manager._add_to_step_archive(acc, "scrape.html", b"", ArtifactType.HTML_SCRAPE) assert isinstance(aid, str) assert len(aid) > 0 assert acc.entries["scrape.html"] == b"" assert acc.member_types[0] == (ArtifactType.HTML_SCRAPE, "scrape.html", aid) def test_add_with_explicit_artifact_id(self) -> None: manager = ArtifactManager() acc = self._make_acc() aid = manager._add_to_step_archive( acc, "scrape.html", b"", ArtifactType.HTML_SCRAPE, artifact_id="custom_id_abc" ) assert aid == "custom_id_abc" assert acc.member_types[0][2] == "custom_id_abc" def test_deduplication_preserves_existing_artifact_id(self) -> None: """Adding the same filename twice should update bytes but keep the original artifact_id.""" manager = ArtifactManager() acc = self._make_acc() aid_first = manager._add_to_step_archive(acc, "scrape.html", b"v1", ArtifactType.HTML_SCRAPE) aid_second = manager._add_to_step_archive(acc, "scrape.html", b"v2", ArtifactType.HTML_SCRAPE) assert aid_first == aid_second assert acc.entries["scrape.html"] == b"v2" # Only one member_types entry for that filename entries_for_filename = [m for m in acc.member_types if m[1] == "scrape.html"] assert len(entries_for_filename) == 1 def test_multiple_distinct_entries(self) -> None: manager = ArtifactManager() acc = self._make_acc() manager._add_to_step_archive(acc, "scrape.html", b"html", ArtifactType.HTML_SCRAPE) manager._add_to_step_archive(acc, "element_tree.json", b"{}", ArtifactType.VISIBLE_ELEMENTS_TREE) assert len(acc.entries) == 2 assert len(acc.member_types) == 2 class TestAccumulateScrapeToArchive: """Tests for ArtifactManager.accumulate_scrape_to_archive.""" def test_adds_six_entries(self) -> None: manager = ArtifactManager() step = create_fake_step(TEST_STEP_ID) manager.accumulate_scrape_to_archive( step=step, html=b"", id_css_map=b'{"a": "b"}', id_frame_map=b'{"c": "d"}', element_tree=b"[]", element_tree_trimmed=b"[]", element_tree_in_prompt=b"prompt text", ) acc = manager._step_archives[step.step_id] assert len(acc.entries) == 6 assert acc.entries["scrape.html"] == b"" assert acc.entries["id_css_map.json"] == b'{"a": "b"}' assert acc.entries["element_tree_in_prompt.txt"] == b"prompt text" def test_member_types_has_correct_artifact_types(self) -> None: manager = ArtifactManager() step = create_fake_step(TEST_STEP_ID) manager.accumulate_scrape_to_archive( step=step, html=b"", id_css_map=b"{}", id_frame_map=b"{}", element_tree=b"[]", element_tree_trimmed=b"[]", element_tree_in_prompt=b"", ) acc = manager._step_archives[step.step_id] types = {m[0] for m in acc.member_types} assert ArtifactType.HTML_SCRAPE in types assert ArtifactType.VISIBLE_ELEMENTS_ID_CSS_MAP in types assert ArtifactType.VISIBLE_ELEMENTS_ID_FRAME_MAP in types assert ArtifactType.VISIBLE_ELEMENTS_TREE in types assert ArtifactType.VISIBLE_ELEMENTS_TREE_TRIMMED in types assert ArtifactType.VISIBLE_ELEMENTS_TREE_IN_PROMPT in types def test_idempotent_on_second_call(self) -> None: """Calling accumulate_scrape_to_archive twice should overwrite, not duplicate.""" manager = ArtifactManager() step = create_fake_step(TEST_STEP_ID) manager.accumulate_scrape_to_archive( step=step, html=b"v1", id_css_map=b"v1", id_frame_map=b"v1", element_tree=b"v1", element_tree_trimmed=b"v1", element_tree_in_prompt=b"v1", ) manager.accumulate_scrape_to_archive( step=step, html=b"v2", id_css_map=b"v2", id_frame_map=b"v2", element_tree=b"v2", element_tree_trimmed=b"v2", element_tree_in_prompt=b"v2", ) acc = manager._step_archives[step.step_id] assert acc.entries["scrape.html"] == b"v2" # member_types should not have duplicates for each filename filenames = [m[1] for m in acc.member_types] assert len(filenames) == len(set(filenames)) class TestAccumulateLlmCallToArchive: """Tests for ArtifactManager.accumulate_llm_call_to_archive.""" def test_adds_all_provided_artifacts(self) -> None: manager = ArtifactManager() step = create_fake_step(TEST_STEP_ID) manager.accumulate_llm_call_to_archive( step=step, hashed_href_map=b'{"href": "url"}', prompt=b"you are a bot", request=b'{"model": "x"}', response=b'{"choices": []}', parsed_response=b'{"actions": []}', rendered_response=b'{"actions": []}', ) acc = manager._step_archives[step.step_id] assert "hashed_href_map_0.json" in acc.entries assert "llm_prompt_0.txt" in acc.entries assert "llm_request_0.json" in acc.entries assert "llm_response_0.json" in acc.entries assert "llm_response_parsed_0.json" in acc.entries assert "llm_response_rendered_0.json" in acc.entries def test_none_values_not_added(self) -> None: manager = ArtifactManager() step = create_fake_step(TEST_STEP_ID) manager.accumulate_llm_call_to_archive( step=step, prompt=b"only prompt", response=None, parsed_response=None, ) acc = manager._step_archives[step.step_id] assert "llm_prompt_0.txt" in acc.entries assert "llm_response_0.json" not in acc.entries assert "llm_response_parsed_0.json" not in acc.entries def test_multiple_llm_calls_use_distinct_indexed_filenames(self) -> None: """Each LLM call within the same step gets its own index — no data overwritten.""" manager = ArtifactManager() step = create_fake_step(TEST_STEP_ID) manager.accumulate_llm_call_to_archive(step=step, prompt=b"call 1") manager.accumulate_llm_call_to_archive(step=step, prompt=b"call 2") acc = manager._step_archives[step.step_id] assert acc.entries["llm_prompt_0.txt"] == b"call 1" assert acc.entries["llm_prompt_1.txt"] == b"call 2" assert acc.llm_call_count == 2 filenames = [m[1] for m in acc.member_types] # Both prompts present — no silent overwrite assert "llm_prompt_0.txt" in filenames assert "llm_prompt_1.txt" in filenames def test_llm_call_count_increments(self) -> None: manager = ArtifactManager() step = create_fake_step(TEST_STEP_ID) assert manager._get_or_create_step_archive(step, None, None, None).llm_call_count == 0 manager.accumulate_llm_call_to_archive(step=step, prompt=b"p1") assert manager._step_archives[step.step_id].llm_call_count == 1 manager.accumulate_llm_call_to_archive(step=step, prompt=b"p2") assert manager._step_archives[step.step_id].llm_call_count == 2 class TestAccumulateActionHtmlToArchive: """Tests for ArtifactManager.accumulate_action_html_to_archive.""" def test_first_action_gets_index_zero(self) -> None: manager = ArtifactManager() step = create_fake_step(TEST_STEP_ID) manager.accumulate_action_html_to_archive(step=step, html_action=b"action0") acc = manager._step_archives[step.step_id] assert "html_action_0.html" in acc.entries assert acc.entries["html_action_0.html"] == b"action0" def test_multiple_actions_get_sequential_indices(self) -> None: manager = ArtifactManager() step = create_fake_step(TEST_STEP_ID) for i in range(3): manager.accumulate_action_html_to_archive(step=step, html_action=f"{i}".encode()) acc = manager._step_archives[step.step_id] assert "html_action_0.html" in acc.entries assert "html_action_1.html" in acc.entries assert "html_action_2.html" in acc.entries assert acc.entries["html_action_1.html"] == b"1" def test_member_types_have_html_action_type(self) -> None: manager = ArtifactManager() step = create_fake_step(TEST_STEP_ID) manager.accumulate_action_html_to_archive(step=step, html_action=b"") acc = manager._step_archives[step.step_id] assert any(m[0] == ArtifactType.HTML_ACTION for m in acc.member_types) class TestAccumulateScreenshotToStepArchive: """Tests for ArtifactManager.accumulate_screenshot_to_step_archive.""" def test_returns_artifact_ids(self) -> None: manager = ArtifactManager() step = create_fake_step(TEST_STEP_ID) ids = manager.accumulate_screenshot_to_step_archive( step=step, screenshots=[b"png1", b"png2"], artifact_type=ArtifactType.SCREENSHOT_LLM, ) assert len(ids) == 2 assert all(isinstance(i, str) and len(i) > 0 for i in ids) assert ids[0] != ids[1] def test_llm_screenshots_use_screenshot_llm_prefix(self) -> None: manager = ArtifactManager() step = create_fake_step(TEST_STEP_ID) manager.accumulate_screenshot_to_step_archive( step=step, screenshots=[b"png"], artifact_type=ArtifactType.SCREENSHOT_LLM ) acc = manager._step_archives[step.step_id] assert "screenshot_llm_0.png" in acc.entries def test_action_screenshots_use_screenshot_action_prefix(self) -> None: manager = ArtifactManager() step = create_fake_step(TEST_STEP_ID) manager.accumulate_screenshot_to_step_archive( step=step, screenshots=[b"png"], artifact_type=ArtifactType.SCREENSHOT_ACTION ) acc = manager._step_archives[step.step_id] assert "screenshot_action_0.png" in acc.entries def test_sequential_indices_across_calls(self) -> None: """Two separate accumulate calls should increment the index.""" manager = ArtifactManager() step = create_fake_step(TEST_STEP_ID) manager.accumulate_screenshot_to_step_archive( step=step, screenshots=[b"a"], artifact_type=ArtifactType.SCREENSHOT_LLM ) manager.accumulate_screenshot_to_step_archive( step=step, screenshots=[b"b"], artifact_type=ArtifactType.SCREENSHOT_LLM ) acc = manager._step_archives[step.step_id] assert "screenshot_llm_0.png" in acc.entries assert "screenshot_llm_1.png" in acc.entries assert acc.entries["screenshot_llm_0.png"] == b"a" assert acc.entries["screenshot_llm_1.png"] == b"b" def test_artifact_ids_stable_across_calls(self) -> None: """The pre-generated ID for index 0 should not change if called again for index 1.""" manager = ArtifactManager() step = create_fake_step(TEST_STEP_ID) ids_first = manager.accumulate_screenshot_to_step_archive( step=step, screenshots=[b"a"], artifact_type=ArtifactType.SCREENSHOT_LLM ) ids_second = manager.accumulate_screenshot_to_step_archive( step=step, screenshots=[b"b"], artifact_type=ArtifactType.SCREENSHOT_LLM ) acc = manager._step_archives[step.step_id] # IDs from first call still intact in member_types assert any(m[2] == ids_first[0] for m in acc.member_types) # New ID from second call is different assert ids_second[0] != ids_first[0] # --------------------------------------------------------------------------- # ZIP building # --------------------------------------------------------------------------- class TestBuildZip: """Tests for ArtifactManager._build_zip.""" def test_zip_contains_all_entries(self) -> None: entries = { "scrape.html": b"", "element_tree.json": b"[]", "screenshot_llm_0.png": b"\x89PNG", } zip_bytes = ArtifactManager._build_zip(entries) with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: assert set(zf.namelist()) == set(entries.keys()) def test_zip_text_entries_are_readable(self) -> None: entries = {"llm_prompt.txt": b"you are a bot", "id_css_map.json": b'{"a":"b"}'} zip_bytes = ArtifactManager._build_zip(entries) with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: assert zf.read("llm_prompt.txt") == b"you are a bot" assert zf.read("id_css_map.json") == b'{"a":"b"}' def test_zip_png_entries_round_trip(self) -> None: fake_png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 entries = {"screenshot_action_0.png": fake_png} zip_bytes = ArtifactManager._build_zip(entries) with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: assert zf.read("screenshot_action_0.png") == fake_png def test_png_entries_use_stored_compression(self) -> None: """PNGs should use ZIP_STORED (no double-compression).""" entries = {"screenshot_llm_0.png": b"\x89PNG" + b"\x00" * 50} zip_bytes = ArtifactManager._build_zip(entries) with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: info = zf.getinfo("screenshot_llm_0.png") assert info.compress_type == zipfile.ZIP_STORED def test_text_entries_use_deflate_compression(self) -> None: """Text entries should be deflate-compressed.""" entries = {"llm_prompt.txt": b"a" * 1000} zip_bytes = ArtifactManager._build_zip(entries) with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: info = zf.getinfo("llm_prompt.txt") assert info.compress_type == zipfile.ZIP_DEFLATED def test_empty_entries_produces_valid_zip(self) -> None: zip_bytes = ArtifactManager._build_zip({}) with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: assert zf.namelist() == [] def test_zip_entry_uses_stored_for_already_zipped(self) -> None: """Nested .zip entries should use ZIP_STORED.""" entries = {"trace.zip": b"PK" + b"\x00" * 20} zip_bytes = ArtifactManager._build_zip(entries) with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: info = zf.getinfo("trace.zip") assert info.compress_type == zipfile.ZIP_STORED # --------------------------------------------------------------------------- # get_or_create isolation # --------------------------------------------------------------------------- class TestGetOrCreateStepArchive: def test_separate_steps_get_separate_accumulators(self) -> None: manager = ArtifactManager() step1 = create_fake_step(TEST_STEP_ID) step2 = create_fake_step(TEST_STEP_ID_2) acc1 = manager._get_or_create_step_archive(step1, None, None, None) acc2 = manager._get_or_create_step_archive(step2, None, None, None) assert acc1 is not acc2 assert acc1.step.step_id == TEST_STEP_ID assert acc2.step.step_id == TEST_STEP_ID_2 def test_same_step_returns_same_accumulator(self) -> None: manager = ArtifactManager() step = create_fake_step(TEST_STEP_ID) acc_first = manager._get_or_create_step_archive(step, None, None, None) acc_second = manager._get_or_create_step_archive(step, None, None, None) assert acc_first is acc_second # --------------------------------------------------------------------------- # Full accumulation → ZIP round-trip (no S3 / DB) # --------------------------------------------------------------------------- class TestArchiveRoundTrip: """Verify that data accumulated across multiple helpers can be retrieved from the built ZIP.""" def test_full_step_archive_round_trip(self) -> None: manager = ArtifactManager() step = create_fake_step(TEST_STEP_ID) manager.accumulate_scrape_to_archive( step=step, html=b"page", id_css_map=b'{"el1": "#id1"}', id_frame_map=b"{}", element_tree=b'[{"tag":"div"}]', element_tree_trimmed=b'[{"tag":"div"}]', element_tree_in_prompt=b"div#id1", ) manager.accumulate_llm_call_to_archive( step=step, prompt=b"what should i click?", response=b'{"choices":[{"message":{"content":"click button"}}]}', parsed_response=b'{"actions":[{"action_type":"click"}]}', ) manager.accumulate_action_html_to_archive(step=step, html_action=b"after click") screenshot_ids = manager.accumulate_screenshot_to_step_archive( step=step, screenshots=[b"\x89PNGscreenshot"], artifact_type=ArtifactType.SCREENSHOT_LLM ) acc = manager._step_archives[step.step_id] zip_bytes = manager._build_zip(acc.entries) with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: names = set(zf.namelist()) assert "scrape.html" in names assert "id_css_map.json" in names assert "llm_prompt_0.txt" in names assert "llm_response_0.json" in names assert "llm_response_parsed_0.json" in names assert "html_action_0.html" in names assert "screenshot_llm_0.png" in names assert zf.read("scrape.html") == b"page" assert zf.read("llm_prompt_0.txt") == b"what should i click?" assert zf.read("screenshot_llm_0.png") == b"\x89PNGscreenshot" assert len(screenshot_ids) == 1 class TestQueueActionScreenshotUpdate: """Tests for ArtifactManager.queue_action_screenshot_update.""" def test_queues_update_when_archive_exists(self) -> None: """Pending update is appended to the accumulator for later flush.""" step = create_fake_step(TEST_STEP_ID) manager = ArtifactManager() manager.accumulate_screenshot_to_step_archive( step=step, screenshots=[b"\x89PNGdata"], artifact_type=ArtifactType.SCREENSHOT_ACTION, ) manager.queue_action_screenshot_update( step=step, organization_id="org_1", action_id="action_1", artifact_id="art_1", ) acc = manager._step_archives[step.step_id] assert acc.pending_action_screenshot_updates == [("org_1", "action_1", "art_1")] def test_multiple_updates_are_ordered(self) -> None: """Each action in the step appends its own pending update in call order.""" step = create_fake_step(TEST_STEP_ID) manager = ArtifactManager() manager.accumulate_screenshot_to_step_archive( step=step, screenshots=[b"png0", b"png1"], artifact_type=ArtifactType.SCREENSHOT_ACTION, ) manager.queue_action_screenshot_update( step=step, organization_id="org_1", action_id="act_0", artifact_id="aid_0" ) manager.queue_action_screenshot_update( step=step, organization_id="org_1", action_id="act_1", artifact_id="aid_1" ) acc = manager._step_archives[step.step_id] assert acc.pending_action_screenshot_updates == [ ("org_1", "act_0", "aid_0"), ("org_1", "act_1", "aid_1"), ] def test_no_crash_when_archive_missing(self, caplog: pytest.LogCaptureFixture) -> None: """If the accumulator is gone (e.g. already flushed), logs a warning and returns.""" import logging step = create_fake_step(TEST_STEP_ID) manager = ArtifactManager() # No archive created — simulates calling after flush or discard with caplog.at_level(logging.WARNING): manager.queue_action_screenshot_update( step=step, organization_id="org_1", action_id="action_1", artifact_id="art_1", ) assert any("no step archive found" in r.message for r in caplog.records) def test_task_archive_entries_helper(self) -> None: """Verify create_task_archive entries dict structure is correct for common types.""" entries: dict[str, tuple[ArtifactType, bytes]] = { "har.har": (ArtifactType.HAR, b'{"log":{}}'), "browser_console.log": (ArtifactType.BROWSER_CONSOLE_LOG, b"[info] loaded"), "trace.zip": (ArtifactType.TRACE, b"PK\x03\x04"), } zip_entries = {filename: data for filename, (_, data) in entries.items()} zip_bytes = ArtifactManager._build_zip(zip_entries) with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: assert set(zf.namelist()) == {"har.har", "browser_console.log", "trace.zip"} assert zf.read("browser_console.log") == b"[info] loaded" # --------------------------------------------------------------------------- # flush_step_archive — per-step early flush # --------------------------------------------------------------------------- def _make_app_mocks() -> tuple[MagicMock, MagicMock]: """Return (mock_storage, mock_database) with async store/create methods.""" mock_storage = MagicMock() mock_storage.build_uri.return_value = "s3://bucket/v1/test/tsk_123/01_0_step_id/archive.zip" mock_storage.store_artifact = AsyncMock() mock_database = MagicMock() mock_database.artifacts = MagicMock() mock_database.artifacts.bulk_create_artifacts = AsyncMock() mock_database.artifacts.update_action_screenshot_artifact_id = AsyncMock() return mock_storage, mock_database class TestFlushStepArchive: """Tests for ArtifactManager.flush_step_archive (per-step early flush).""" @pytest.mark.asyncio async def test_flush_uploads_zip_and_creates_db_rows(self) -> None: """Flushing a populated accumulator should call store_artifact and bulk_create_artifacts.""" mock_storage, mock_database = _make_app_mocks() manager = ArtifactManager() step = create_fake_step(TEST_STEP_ID) manager.accumulate_scrape_to_archive( step=step, html=b"", id_css_map=b"{}", id_frame_map=b"{}", element_tree=b"[]", element_tree_trimmed=b"[]", element_tree_in_prompt=b"", ) with patch("skyvern.forge.sdk.artifact.manager.app") as mock_app: mock_app.STORAGE = mock_storage mock_app.DATABASE = mock_database await manager.flush_step_archive(step.step_id) mock_storage.store_artifact.assert_awaited_once() mock_database.artifacts.bulk_create_artifacts.assert_awaited_once() # The artifact list should include the parent + 6 member rows (scrape produces 6 entries) call_args = mock_database.artifacts.bulk_create_artifacts.call_args[0][0] assert len(call_args) == 7 # 1 parent + 6 members @pytest.mark.asyncio async def test_flush_removes_accumulator_from_dict(self) -> None: """After flush the accumulator should be gone from _step_archives.""" mock_storage, mock_database = _make_app_mocks() manager = ArtifactManager() step = create_fake_step(TEST_STEP_ID) manager.accumulate_scrape_to_archive( step=step, html=b"", id_css_map=b"{}", id_frame_map=b"{}", element_tree=b"[]", element_tree_trimmed=b"[]", element_tree_in_prompt=b"", ) assert step.step_id in manager._step_archives with patch("skyvern.forge.sdk.artifact.manager.app") as mock_app: mock_app.STORAGE = mock_storage mock_app.DATABASE = mock_database await manager.flush_step_archive(step.step_id) assert step.step_id not in manager._step_archives @pytest.mark.asyncio async def test_flush_is_idempotent_noop_on_second_call(self) -> None: """Calling flush_step_archive twice for the same step_id should not error or double-upload.""" mock_storage, mock_database = _make_app_mocks() manager = ArtifactManager() step = create_fake_step(TEST_STEP_ID) manager.accumulate_scrape_to_archive( step=step, html=b"", id_css_map=b"{}", id_frame_map=b"{}", element_tree=b"[]", element_tree_trimmed=b"[]", element_tree_in_prompt=b"", ) with patch("skyvern.forge.sdk.artifact.manager.app") as mock_app: mock_app.STORAGE = mock_storage mock_app.DATABASE = mock_database await manager.flush_step_archive(step.step_id) await manager.flush_step_archive(step.step_id) # second call — no-op # store_artifact and bulk_create_artifacts should only be called once assert mock_storage.store_artifact.await_count == 1 assert mock_database.artifacts.bulk_create_artifacts.await_count == 1 @pytest.mark.asyncio async def test_flush_nonexistent_step_id_is_noop(self) -> None: """Flushing a step_id with no accumulator should do nothing without raising.""" mock_storage, mock_database = _make_app_mocks() manager = ArtifactManager() with patch("skyvern.forge.sdk.artifact.manager.app") as mock_app: mock_app.STORAGE = mock_storage mock_app.DATABASE = mock_database await manager.flush_step_archive("nonexistent_step_id") mock_storage.store_artifact.assert_not_awaited() mock_database.artifacts.bulk_create_artifacts.assert_not_awaited() @pytest.mark.asyncio async def test_flush_applies_pending_screenshot_updates(self) -> None: """Deferred action.screenshot_artifact_id updates are applied during flush.""" mock_storage, mock_database = _make_app_mocks() manager = ArtifactManager() step = create_fake_step(TEST_STEP_ID) manager.accumulate_screenshot_to_step_archive( step=step, screenshots=[b"\x89PNGdata"], artifact_type=ArtifactType.SCREENSHOT_ACTION, ) manager.queue_action_screenshot_update( step=step, organization_id="org_1", action_id="action_1", artifact_id="art_1", ) with patch("skyvern.forge.sdk.artifact.manager.app") as mock_app: mock_app.STORAGE = mock_storage mock_app.DATABASE = mock_database await manager.flush_step_archive(step.step_id) mock_database.artifacts.update_action_screenshot_artifact_id.assert_awaited_once_with( organization_id="org_1", action_id="action_1", screenshot_artifact_id="art_1", ) @pytest.mark.asyncio async def test_wait_for_upload_aiotasks_finds_no_step_archives_after_per_step_flush(self) -> None: """After per-step flushes, wait_for_upload_aiotasks should have nothing left to flush.""" mock_storage, mock_database = _make_app_mocks() manager = ArtifactManager() step = create_fake_step(TEST_STEP_ID) manager.accumulate_scrape_to_archive( step=step, html=b"", id_css_map=b"{}", id_frame_map=b"{}", element_tree=b"[]", element_tree_trimmed=b"[]", element_tree_in_prompt=b"", ) with patch("skyvern.forge.sdk.artifact.manager.app") as mock_app: mock_app.STORAGE = mock_storage mock_app.DATABASE = mock_database # Simulate per-step flush right after step completes await manager.flush_step_archive(step.step_id) # Reset call counts to detect any additional calls from wait_for_upload_aiotasks mock_storage.store_artifact.reset_mock() mock_database.artifacts.bulk_create_artifacts.reset_mock() # Simulate the end-of-task flush fallback await manager.wait_for_upload_aiotasks([step.task_id]) # The fallback should find nothing to flush — no extra uploads mock_storage.store_artifact.assert_not_awaited() mock_database.artifacts.bulk_create_artifacts.assert_not_awaited()