Files
Artemis-Eapil/tests/unit/data_engine/test_engine.py
T
somew1nd 67248c887e feat: improve task execution, verification and packaging
Add execution incidents, action bursts and shared Flash history.

Expose verification and Explorer settings with streamed check results.

Support graceful cancellation, recording recovery and bundled UI resources.

Add CI quality checks and clean up comments and documentation.
2026-09-02 21:45:51 -07:00

441 lines
15 KiB
Python

# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
import json
from unittest.mock import MagicMock, patch
from artemis.context import ArtemisContext
from artemis.data_engine.engine import DataEngine
def test_ipc_send_reconnects_and_retries_current_event(tmp_path):
"""A stale Windows TCP socket must not make the triggering SSE event disappear."""
mock_ctx = MagicMock(spec=ArtemisContext)
mock_execution_setup = MagicMock()
mock_execution_setup.traces_path = str(tmp_path)
mock_ctx.execution_setup = mock_execution_setup
mock_ctx.device = None
stale_socket = MagicMock()
stale_socket.sendall.side_effect = OSError("connection reset")
replacement_socket = MagicMock()
with (
patch("artemis.data_engine.engine.read_ipc_port", return_value=49152),
patch(
"artemis.data_engine.engine.socket.create_connection",
side_effect=[stale_socket, replacement_socket],
) as create_connection,
):
engine = DataEngine(mock_ctx)
engine._publish("llm_stream", {"session_id": "s1", "chunk": "hello"})
assert create_connection.call_count == 2
stale_socket.close.assert_called_once()
replacement_socket.sendall.assert_called_once()
frame = replacement_socket.sendall.call_args.args[0]
assert frame.endswith(b"\n")
assert json.loads(frame.decode("utf-8"))["data"]["chunk"] == "hello"
def test_ipc_connect_falls_back_to_refreshed_port_file(tmp_path):
"""A UI restart must supersede the worker's stale inherited Windows port."""
mock_ctx = MagicMock(spec=ArtemisContext)
mock_execution_setup = MagicMock()
mock_execution_setup.traces_path = str(tmp_path / "traces")
mock_ctx.execution_setup = mock_execution_setup
mock_ctx.device = None
port_file = tmp_path / ".artemis_ipc_port"
port_file.write_text("51629", encoding="utf-8")
live_socket = MagicMock()
with (
patch("artemis.data_engine.engine.read_ipc_port", return_value=49555),
patch("artemis.data_engine.engine.get_ipc_port_file", return_value=port_file),
patch(
"artemis.data_engine.engine.socket.create_connection",
side_effect=[OSError("stale port"), live_socket],
) as create_connection,
):
engine = DataEngine(mock_ctx)
assert engine.ipc_socket is live_socket
assert create_connection.call_args_list[0].args[0] == ("127.0.0.1", 49555)
assert create_connection.call_args_list[1].args[0] == ("127.0.0.1", 51629)
def test_ipc_does_not_reconnect_after_engine_shutdown(tmp_path):
mock_ctx = MagicMock(spec=ArtemisContext)
mock_execution_setup = MagicMock()
mock_execution_setup.traces_path = str(tmp_path)
mock_ctx.execution_setup = mock_execution_setup
mock_ctx.device = None
connected_socket = MagicMock()
with (
patch("artemis.data_engine.engine.read_ipc_port", return_value=49152),
patch("artemis.data_engine.engine.get_ipc_port_file", return_value=tmp_path / "none"),
patch(
"artemis.data_engine.engine.socket.create_connection",
return_value=connected_socket,
) as create_connection,
):
engine = DataEngine(mock_ctx)
asyncio.run(engine.shutdown())
engine._publish("llm_stream", {"chunk": "after shutdown"})
assert create_connection.call_count == 1
connected_socket.close.assert_called_once()
def test_ipc_connection_failure_is_backed_off(tmp_path):
"""A stale desktop port must not block every emitted trace event."""
mock_ctx = MagicMock(spec=ArtemisContext)
mock_execution_setup = MagicMock()
mock_execution_setup.traces_path = str(tmp_path)
mock_ctx.execution_setup = mock_execution_setup
mock_ctx.device = None
with (
patch("artemis.data_engine.engine.read_ipc_port", return_value=49152),
patch("artemis.data_engine.engine.get_ipc_port_file", return_value=tmp_path / "none"),
patch(
"artemis.data_engine.engine.socket.create_connection",
side_effect=TimeoutError("stale port"),
) as create_connection,
):
engine = DataEngine(mock_ctx)
for index in range(20):
engine._publish("llm_stream", {"chunk": str(index)})
assert create_connection.call_count == 1
def test_get_or_create_image_updates_missing_data(tmp_path):
# Setup mock context
mock_ctx = MagicMock(spec=ArtemisContext)
mock_execution_setup = MagicMock()
mock_execution_setup.traces_path = str(tmp_path)
mock_ctx.execution_setup = mock_execution_setup
mock_ctx.device = None
# Initialize DataEngine
engine = DataEngine(mock_ctx)
image_bytes = b"fake_image_bytes"
# 1. Save image without OCR/UI tree
image_name1 = engine.get_or_create_image(image_bytes)
# Verify it was saved with None for OCR/UI tree
record1 = engine.storage.get_image(image_name1)
assert record1 is not None
assert record1.ocr_result is None
assert record1.ui_tree is None
# 2. Save same image with OCR/UI tree
fake_ocr = [{"text": "hello", "box": [0, 0, 10, 10]}]
fake_ui = [{"resource-id": "button1", "text": "click me"}]
image_name2 = engine.get_or_create_image(image_bytes, ui_tree=fake_ui, ocr_result=fake_ocr)
assert image_name1 == image_name2
# Verify it now has OCR/UI tree
record2 = engine.storage.get_image(image_name2)
assert record2 is not None
assert record2.ocr_result == fake_ocr
assert record2.ui_tree == fake_ui
def test_create_image_duplicate_handling(tmp_path):
from artemis.data_engine.storage import StorageManager
from artemis.data_engine.models import ImageRecord
storage = StorageManager(tmp_path / "data_engine.db", tmp_path)
image_record = ImageRecord(
image_name="test_image_hash",
timestamp=123.456,
ocr_result=None,
ui_tree=None,
extra_metadata={},
)
# First insert
storage.create_image(image_record)
# Second insert with same image_name should not raise IntegrityError due to INSERT OR IGNORE
storage.create_image(image_record)
record = storage.get_image("test_image_hash")
assert record is not None
assert record.image_name == "test_image_hash"
def test_video_recording_persistence(tmp_path):
from uuid import uuid4
from artemis.data_engine.storage import StorageManager
from artemis.data_engine.models import VideoRecordingRecord
storage = StorageManager(tmp_path / "data_engine.db", tmp_path)
video_id = uuid4()
session_id = uuid4()
record = VideoRecordingRecord(
video_id=video_id,
session_id=session_id,
device_id="emulator-5554",
start_time=100.0,
end_time=None,
local_video_path="/tmp/recording.mp4",
)
# Test insert
storage.create_video_recording(record)
persisted = storage.get_video_recording(video_id)
assert persisted is not None
assert persisted.video_id == video_id
assert persisted.session_id == session_id
assert persisted.device_id == "emulator-5554"
assert persisted.start_time == 100.0
assert persisted.end_time is None
assert persisted.local_video_path == "/tmp/recording.mp4"
assert persisted.status == "recording"
# Test update
record.end_time = 150.0
record.local_video_path = "/tmp/recording_final.mp4"
record.status = "ready"
storage.update_video_recording(record)
updated = storage.get_video_recording(video_id)
assert updated is not None
assert updated.end_time == 150.0
assert updated.local_video_path == "/tmp/recording_final.mp4"
assert updated.status == "ready"
def test_record_step_suppresses_identical_post_screenshot(tmp_path):
mock_ctx = MagicMock(spec=ArtemisContext)
mock_execution_setup = MagicMock()
mock_execution_setup.traces_path = str(tmp_path)
mock_ctx.execution_setup = mock_execution_setup
mock_ctx.device = None
engine = DataEngine(mock_ctx)
engine.start_session("test_goal")
same_image_bytes = b"identical_screen_bytes_123"
step_id = engine.record_step(
pre_screenshot_bytes=same_image_bytes,
post_screenshot_bytes=same_image_bytes,
summary="Test step with identical screenshot",
)
for t in list(engine._pending_threads):
t.join()
step_record = engine.storage.get_steps(engine.current_session_id)[0]
assert step_record is not None
assert step_record.pre_image_name is not None
# post_image_name must be None when no visual transition occurred
assert step_record.post_image_name is None
def test_update_step_execution_result_suppresses_identical_post_screenshot(tmp_path):
mock_ctx = MagicMock(spec=ArtemisContext)
mock_execution_setup = MagicMock()
mock_execution_setup.traces_path = str(tmp_path)
mock_ctx.execution_setup = mock_execution_setup
mock_ctx.device = None
engine = DataEngine(mock_ctx)
engine.start_session("test_goal")
image_bytes = b"pre_screen_bytes_456"
step_id = engine.record_step(
pre_screenshot_bytes=image_bytes,
summary="Initial step",
)
for t in list(engine._pending_threads):
t.join()
step_record = engine.storage.get_steps(engine.current_session_id)[0]
assert step_record.post_image_name is None
# Update with post_image_name matching pre_image_name
engine.storage.update_step_execution_result(
step_id,
{"status": "success"},
post_image_name=step_record.pre_image_name,
)
updated_step = engine.storage.get_steps(engine.current_session_id)[0]
assert updated_step.post_image_name is None
# Update with distinct post_image_name
engine.storage.update_step_execution_result(
step_id,
{"status": "success"},
post_image_name="distinct_new_hash_789",
)
updated_step_distinct = engine.storage.get_steps(engine.current_session_id)[0]
assert updated_step_distinct.post_image_name == "distinct_new_hash_789"
def test_resumed_session_monotonic_step_numbering(tmp_path):
mock_ctx = MagicMock(spec=ArtemisContext)
mock_execution_setup = MagicMock()
mock_execution_setup.traces_path = str(tmp_path)
mock_ctx.execution_setup = mock_execution_setup
mock_ctx.device = None
# First runner records Step 1 and Step 2
engine1 = DataEngine(mock_ctx)
sid = engine1.start_session("Monotonic test")
engine1.record_step(summary="Step 1 action")
engine1.record_step(summary="Step 2 action")
for t in list(engine1._pending_threads):
t.join()
# Second runner starts or resumes the same session
engine2 = DataEngine(mock_ctx)
engine2.start_session("Resumed goal", session_id=sid)
assert engine2.current_step_number == 2
# Recording a new step must be Step 3, strictly monotonic
engine2.record_step(summary="Step 3 action")
for t in list(engine2._pending_threads):
t.join()
steps = engine2.storage.get_steps(sid)
assert len(steps) == 3
assert [s.step_number for s in steps] == [1, 2, 3]
def test_end_session_normalizes_legacy_success_status(tmp_path):
mock_ctx = MagicMock(spec=ArtemisContext)
mock_execution_setup = MagicMock()
mock_execution_setup.traces_path = str(tmp_path)
mock_ctx.execution_setup = mock_execution_setup
mock_ctx.device = None
engine = DataEngine(mock_ctx)
sid = engine.start_session("Terminal status normalization test")
engine.end_session("success")
session = engine.storage.get_session(sid)
assert session is not None
assert session.status == "completed"
assert session.end_time is not None
def test_update_step_summary_includes_step_number_in_sse(tmp_path):
mock_ctx = MagicMock(spec=ArtemisContext)
mock_execution_setup = MagicMock()
mock_execution_setup.traces_path = str(tmp_path)
mock_ctx.execution_setup = mock_execution_setup
mock_ctx.device = None
published_events = []
def mock_subscriber(event_type, data):
published_events.append((event_type, data))
engine = DataEngine(mock_ctx)
engine.subscribe(mock_subscriber)
sid = engine.start_session("SSE step_number verification")
step_id = engine.record_step(summary="Initial step 1")
for t in list(engine._pending_threads):
t.join()
assert engine.get_step_number(step_id) == 1
# Call update_step_summary
engine.update_step_summary(step_id, "Updated concise summary for Step 1")
for t in list(engine._pending_threads):
t.join()
# Find the published step_updated event
step_updated_events = [e for e in published_events if e[0] == "step_updated"]
assert len(step_updated_events) >= 1
last_update = step_updated_events[-1][1]
assert last_update["step_id"] == str(step_id)
assert last_update["summary"] == "Updated concise summary for Step 1"
assert last_update["step_number"] == 1
def _foreground_engine(tmp_path):
mock_ctx = MagicMock(spec=ArtemisContext)
mock_execution_setup = MagicMock()
mock_execution_setup.traces_path = str(tmp_path)
mock_ctx.execution_setup = mock_execution_setup
mock_ctx.device = None
engine = DataEngine(mock_ctx)
engine.start_session("foreground_app persistence")
return engine
def test_record_step_persists_explicit_foreground_app(tmp_path):
"""M5: the historically dropped foreground_app parameter is persisted."""
engine = _foreground_engine(tmp_path)
engine.record_step(
foreground_app="com.android.settings",
ui_tree=[{"package": "com.other.app", "bounds": "[0,0][10,10]"}],
summary="Explicit parameter wins",
)
for t in list(engine._pending_threads):
t.join()
step = engine.storage.get_steps(engine.current_session_id)[0]
assert step.extra_metadata["foreground_app"] == "com.android.settings"
def test_record_step_derives_foreground_app_from_ui_tree(tmp_path):
"""Without the parameter, the dominant non-overlay package is stamped."""
engine = _foreground_engine(tmp_path)
engine.record_step(
ui_tree=[
{"package": "com.android.systemui", "bounds": "[0,0][10,10]"},
{"package": "com.google.android.deskclock"},
{
"packageName": "com.google.android.deskclock",
"children": [{"package": "com.google.android.deskclock"}],
},
],
summary="Derived from UI tree",
)
for t in list(engine._pending_threads):
t.join()
step = engine.storage.get_steps(engine.current_session_id)[0]
assert step.extra_metadata["foreground_app"] == "com.google.android.deskclock"
def test_record_step_without_app_data_stamps_nothing(tmp_path):
engine = _foreground_engine(tmp_path)
engine.record_step(
ui_tree=[{"package": "com.android.systemui"}],
summary="Only overlay packages",
)
for t in list(engine._pending_threads):
t.join()
step = engine.storage.get_steps(engine.current_session_id)[0]
assert "foreground_app" not in (step.extra_metadata or {})