feat: release 2.2.0 with PC-only tests and execution reliability fixes

Unify task scheduling and queue state, fix Android input and observation recovery, preserve screenshot paths, and match final review criteria accurately. Add collapsible copyable results and publish the complete 2.1.0 upgrade notes. Validation: 3189 backend tests, 326 frontend tests, 24 client tests, production build and quality gates.
This commit is contained in:
chenmingxuan committed 2026-10-27 01:13:41 +08:00
1 parent fac4ccb760
commit be6e8c2abd
79 files changed
+2263 -277

No files matched your search

@@ -109,6 +109,20 @@ def test_invalid_archive_and_empty_template():
assert not task_import.parse_workbook(task_import.template())["valid"]
def test_pc_json_import_does_not_require_a_device_and_rejects_unknown_targets():
data = workbook_bytes(
[
["PC-1", "电脑", "", "浏览器", "", "检查桌面", '{"execution_target":"computer"}'],
["BAD-2", "", "", "", "", "检查页面", '{"execution_target":"pc"}'],
]
)
result = task_import.parse_workbook(data)
assert [item["row"] for item in result["errors"]] == [3]
options = result["tasks"][0]["task_config"]
assert options["execution_target"] == "computer"
assert options["device_serial"] is None
def test_batch_archive_preparation_reports_progress_and_reuses_ready_file(
client, isolated, monkeypatch
):
@@ -20,6 +20,7 @@ import pytest
from apps.admin_console.routers import tasks
from apps.admin_console.schemas.task_schema import RunRequest
from apps.admin_console.services.task_import import TaskOptions
from artemis.core.diagnostics.schema import (
ProbeCategory,
ProbeResult,
@@ -27,6 +28,28 @@ from artemis.core.diagnostics.schema import (
)
@pytest.mark.asyncio
@pytest.mark.parametrize("profile", ["flash", "pro"])
async def test_pc_task_skips_android_submission_checks(monkeypatch, profile):
probe = AsyncMock(side_effect=AssertionError("PC submission cannot probe Android"))
validate = AsyncMock(side_effect=AssertionError("PC submission cannot enumerate Android"))
enqueue = AsyncMock(return_value={"status": "started", "tasks": []})
monkeypatch.setattr(tasks.readiness_engine, "run_device_submission_probe", probe)
monkeypatch.setattr(tasks.device_pool, "validate_explicit_serial_async", validate)
monkeypatch.setattr(tasks.task_queue_service, "enqueue_tasks", enqueue)
await tasks.run_task(
RunRequest(
goal="Check browser",
profile=profile,
device_serial="offline-phone",
runtime_overrides=TaskOptions(execution_target="computer"),
)
)
probe.assert_not_awaited()
validate.assert_not_awaited()
assert enqueue.call_args.kwargs["runtime_overrides"]["execution_target"] == "computer"
@pytest.mark.asyncio
async def test_run_task_rejects_locked_device(monkeypatch):
locked_probe = ProbeResult(
@@ -132,6 +132,39 @@ async def test_enqueued_task_keeps_its_adb_endpoint_snapshot():
assert target.lock_key == f"{original.identity}/emulator-5554"
@pytest.mark.asyncio
async def test_pc_queue_uses_desktop_resources_without_android_discovery():
from artemis.plugins.models import PluginSelection
pinned = [PluginSelection(id="cua", version="1.0.0")]
with (
patch.object(TaskQueueService, "ensure_worker_running"),
patch("artemis.plugins.store.PluginStore.pin", return_value=pinned),
patch(
"artemis.plugins.store.PluginStore.reserve", return_value=["desktop:local"]
) as reserve,
patch(
"artemis.runtime.device_pool.device_pool.select_device_async", new_callable=AsyncMock
) as select,
patch(
"artemis.runtime.device_pool.device_pool.validate_explicit_serial_async",
new_callable=AsyncMock,
) as validate,
):
result = await TaskQueueService.enqueue_tasks(
["Check PC"],
device_serial="offline-phone",
runtime_overrides={"execution_target": "computer"},
)
item = result["tasks"][0]
assert item["device_serial"] == "computer:local"
assert item["plugin_resources"] == ["desktop:local"]
assert item["runtime_overrides"]["plugins"][0]["version"] == "1.0.0"
reserve.assert_called_once_with(item["session_id"], pinned, "computer")
select.assert_not_awaited()
validate.assert_not_awaited()
@pytest.mark.parametrize(
("current_status", "returncode", "stopped", "expected"),
[
@@ -1008,6 +1041,63 @@ async def test_enqueue_tasks_deduplicates_by_session_id():
assert res2["tasks"][0]["session_id"] == "sid-dedup-1"
@pytest.mark.asyncio
@pytest.mark.parametrize("targets", [("computer", "android"), ("android", "computer")])
async def test_pc_and_android_dispatch_in_submission_order(monkeypatch, targets):
monkeypatch.setenv("ARTEMIS_CONCURRENCY_MODE", "per_device")
monkeypatch.setenv("ARTEMIS_MAX_CONCURRENT_TASKS", "8")
monkeypatch.setattr(state, "active_runs", {})
monkeypatch.setattr(TaskQueueService, "_run_tasks", set())
monkeypatch.setattr(TaskQueueService, "_sync_control_states", lambda: None)
execute = AsyncMock()
monkeypatch.setattr(TaskQueueService, "_execute_task_item", execute)
state.queue_items = [
{
"session_id": target,
"status": "pending",
"device_serial": target,
"execution_target": "computer" if target == "computer" else "android",
}
for target in targets
]
first = state.queue_items[0]
TaskQueueService._dispatch_pending_tasks()
await asyncio.gather(*TaskQueueService._run_tasks)
assert [item["status"] for item in state.queue_items] == ["running", "pending"]
# A launched worker occupies the slot before process registration.
TaskQueueService._dispatch_pending_tasks()
assert execute.await_count == 1
state.queue_items.remove(first)
TaskQueueService._dispatch_pending_tasks()
await asyncio.gather(*TaskQueueService._run_tasks)
assert [call.args[0]["session_id"] for call in execute.await_args_list] == list(targets)
@pytest.mark.asyncio
async def test_serial_dispatch_does_not_bypass_blocked_queue_head(monkeypatch):
monkeypatch.setattr(state, "active_runs", {})
monkeypatch.setattr(TaskQueueService, "_run_tasks", set())
monkeypatch.setattr(TaskQueueService, "_sync_control_states", lambda: None)
monkeypatch.setattr(
"apps.admin_console.services.task_queue_service.DeviceExecutionLock.get_active_owners",
lambda: {"external": MagicMock(resource_ids=("shared-desktop",))},
)
execute = AsyncMock()
monkeypatch.setattr(TaskQueueService, "_execute_task_item", execute)
state.queue_items = [
{
"session_id": "first",
"status": "pending",
"device_serial": "android",
"plugin_resources": ["shared-desktop"],
},
{"session_id": "second", "status": "pending", "device_serial": "computer"},
]
TaskQueueService._dispatch_pending_tasks()
assert [item["status"] for item in state.queue_items] == ["pending", "pending"]
execute.assert_not_called()
@pytest.mark.asyncio
async def test_manual_stop_of_one_run_does_not_pollute_concurrent_run():
"""Stopping run A must not flip run B's terminal status or its payload.
+72
View File
@@ -144,6 +144,78 @@ def test_normalize_report_downgrades_vague_failures_and_fills_missing():
# --- Assembly: prompt segments x entry x content x config (§8 item 18) ---------------
@pytest.mark.parametrize("kind,when", [("verify", "on_complete"), ("assert", "at_end")])
@pytest.mark.parametrize("status", ["passed", "failed", "inconclusive"])
@pytest.mark.parametrize("bullet", ["", "- "])
def test_rendered_check_label_matches_original_without_changing_verdict(kind, when, status, bullet):
item = _ci(kind=kind, when=when, text="截图路径与回执一致")
verdict = CheckVerdict(
kind=kind,
item_text=f"{bullet}[{kind}] ({when}) {item.text}",
status=status,
evidence="截图与笔记回执",
suggestion="保留原建议",
)
normalized = _normalize_report(CheckReport(verdicts=[verdict]), [item])
assert [v.model_dump() for v in normalized.verdicts] == [
{**verdict.model_dump(), "item_text": item.text}
]
assert verdict.item_text != item.text # The original response is not rewritten.
@pytest.mark.parametrize(
"kind,text",
[
("assert", "[verify] (on_complete) 截图有效"),
("verify", "[assert] (on_complete) 截图有效"),
("verify", "[verify] (at_end) 截图有效"),
("verify", "[verify] (on_complete) 截图无效"),
("verify", "[verify] (on_complete) 截图有效。"),
],
)
def test_unmatched_label_does_not_cover_declared_check(kind, text):
item = _ci(text="截图有效")
verdict = CheckVerdict(kind=kind, item_text=text, status="passed", evidence="e")
normalized = _normalize_report(CheckReport(verdicts=[verdict]), [item])
assert normalized.verdicts[0] == verdict
assert normalized.verdicts[1].item_text == item.text
assert normalized.verdicts[1].status == "inconclusive"
def test_literal_criterion_takes_precedence_over_another_items_display_label():
plain = _ci(text="截图有效")
literal = _ci(text="[verify] (on_complete) 截图有效")
verdict = CheckVerdict(kind="verify", item_text=literal.text, status="passed", evidence="e")
normalized = _normalize_report(CheckReport(verdicts=[verdict]), [plain, literal])
assert [(v.item_text, v.status) for v in normalized.verdicts] == [
(literal.text, "passed"),
(plain.text, "inconclusive"),
]
def test_conflicting_plain_and_label_verdicts_keep_the_failure():
item = _ci(text="截图有效")
normalized = _normalize_report(
CheckReport(
verdicts=[
CheckVerdict(kind="verify", item_text=item.text, status="passed", evidence="e1"),
CheckVerdict(
kind="verify",
item_text="[verify] (on_complete) 截图有效",
status="failed",
evidence="e2",
),
]
),
[item],
)
assert [(v.item_text, v.status) for v in normalized.verdicts] == [
(item.text, "passed"),
(item.text, "failed"),
]
assert not verdicts_allow_release(normalized)
def test_prompt_segments_verify_only_has_no_assert_section():
prompts = {
"base_rules": "BASE",
@@ -139,6 +139,42 @@ def test_empty_evidence_list_does_not_turn_an_unsupported_failure_into_evidence(
assert _normalize_report(report, []).verdicts[0].status == "inconclusive"
@pytest.mark.asyncio
@pytest.mark.parametrize("structured_response", [False, True])
async def test_display_labels_match_in_text_and_structured_verdict_paths(structured_response):
from artemis.agents.checker.checker import _normalize_report
from artemis.utils.plan_grammar import CheckItem
items = [
CheckItem(kind="verify", when="on_complete", text="截图保存成功", parent_key="p"),
CheckItem(kind="verify", when="on_complete", text="路径与回执一致", parent_key="p"),
CheckItem(kind="assert", when="at_end", text="没有交互动作", parent_key=None),
]
payload = {
"verdicts": [
{
"kind": i.kind,
"item_text": f"[{i.kind}] ({i.when}) {i.text}",
"status": "passed",
"evidence": "完整工具回执",
}
for i in items
],
"test_result": _result(),
}
llm, structured = _llm(CheckReport.model_validate(payload))
answer = "已检查,待结构化输出。" if structured_response else json.dumps(payload)
report = await _structured_report(llm, [AIMessage(content=answer)], items)
normalized = _normalize_report(report, items)
assert [v.item_text for v in normalized.verdicts] == [i.text for i in items]
assert [v.status for v in normalized.verdicts] == ["passed"] * 3
assert normalized.test_result.status == "passed"
if structured_response:
structured.ainvoke.assert_awaited_once()
else:
structured.ainvoke.assert_not_awaited()
def test_nested_json_string_result_is_decoded_and_still_validated():
result = _result()
assert (
@@ -145,6 +145,19 @@ def test_render_shifted_and_occupied_evidence():
assert "now covered by 'Sign Up' button" in text
def test_unavailable_pixel_verification_does_not_claim_target_disappeared():
incident = _disappeared(
category="pixel_verification_unavailable",
reason="Failed to acquire live screenshot: device offline",
)
text = render_execution_incident(incident, STEPS)
assert "device offline" in text
assert "was NOT executed" in text
assert "Restore a working observation" in text
assert "no longer on screen" not in text
assert "Your last dispatched action" not in text
def test_render_burst_abort_and_exec_error():
burst = _disappeared(kind=KIND_EXEC_ERROR, category="general", burst_size=3, action_index=1)
burst["reason"] = "Error: tap rejected"
+112 -1
View File
@@ -135,7 +135,12 @@ async def test_completed_exploration_plan_does_not_force_an_extra_action(
result = await node._invoke_llm_loop(
Mock(), messages, [SimpleNamespace(name="save_note")], [], state
)
if finishing:
if scenario == "pro":
assert invoke.await_count == 1
assert result[0] is None
assert state.execution_report is None
assert result[3] is False
elif finishing:
assert invoke.await_count == 2
assert result[0] is None
assert final.content in result[1]
@@ -154,6 +159,112 @@ async def test_completed_exploration_plan_does_not_force_an_extra_action(
assert any(isinstance(m, ToolMessage) and m.tool_call_id == "plan" for m in messages)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("plan", "released", "finishes"),
[
("- [x] Inspect page", False, True),
("- [ ] Inspect page", False, False),
("- [!] Inspect page", False, False),
("- [x] Inspect page\n - [ ] Check input", False, False),
("- [x] Inspect page\n - [/] Check input", False, False),
("- [x] [Loop:continuous] Inspect page", False, False),
("- [x] [Loop:continuous] Inspect page", True, True),
("", False, False),
],
)
async def test_pro_completion_does_not_request_a_synthetic_action(
operator, monkeypatch, plan, released, finishes
):
node, state = operator
node._read_task_plan_text = lambda: plan
state.user_stop_requested = released
result, messages, invoke = await run_loop(operator, monkeypatch, [click()])
assert invoke.await_count == (0 if finishes else 1)
assert bool(result[0]) is not finishes
assert state.execution_report is None
assert result[3] is False
@pytest.mark.asyncio
async def test_pro_completion_on_last_tool_iteration_still_reaches_final_review(
operator, monkeypatch, tmp_path
):
from artemis.context import ExecutionSetup
from artemis.graph.graph import convergence_gate
from artemis.utils.notes import get_note_file_path
node, state = operator
node.ctx.data_engine = Mock(base_dir=str(tmp_path))
node.ctx.execution_setup = ExecutionSetup(disable_checker=False)
path = get_note_file_path(tmp_path, "task_plan")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("- [/] Inspect page\n- assert@end: Input is empty", encoding="utf-8")
async def save_note(**kwargs):
path.write_text("- [x] Inspect page\n- assert@end: Input is empty", encoding="utf-8")
return ToolMessage(content="Saved plan", tool_call_id="plan")
monkeypatch.setattr("artemis.agents.operator.operator.OPERATOR_MAX_TOOL_ITERATIONS", 1)
monkeypatch.setattr("artemis.agents.operator.operator.invoke_tool_with_injection", save_note)
invoke = AsyncMock(
return_value=AIMessage(
content="Input is empty; recording completion.",
tool_calls=[{"name": "save_note", "args": {}, "id": "plan"}],
)
)
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
result = await node._invoke_llm_loop(Mock(), [], [SimpleNamespace(name="save_note")], [], state)
assert result[0] is None
assert result[3] is False
assert invoke.await_count == 1
assert convergence_gate(state, node.ctx) == "exit_settlement"
@pytest.mark.asyncio
@pytest.mark.parametrize("feedback_field", ["injected_instruction", "operator_feedback"])
async def test_completed_pro_plan_still_processes_new_feedback(
operator, monkeypatch, feedback_field
):
node, state = operator
node.ctx.data_engine = None
node._read_task_plan_text = lambda: "- [x] Inspect page"
setattr(state, feedback_field, "Review the saved evidence before finishing.")
monkeypatch.setattr(
"artemis.agents.operator.operator.invoke_tool_with_injection",
AsyncMock(return_value=ToolMessage(content="Saved evidence", tool_call_id="read")),
)
invoke = AsyncMock(
side_effect=[
AIMessage(
content="Reading evidence.",
tool_calls=[
{"name": "read_note", "args": {"key": "evidence"}, "id": "read"},
],
),
AIMessage(content="Reviewed the evidence; input is empty."),
]
)
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
messages = []
result = await node._invoke_llm_loop(
Mock(), messages, [SimpleNamespace(name="read_note")], [], state
)
assert invoke.await_count == 2
assert result[0] is None
assert result[3] is False
assert messages[-1].content == "Reviewed the evidence; input is empty."
def test_pro_completion_does_not_bypass_pending_guidance(operator, monkeypatch, tmp_path):
node, state = operator
node._read_task_plan_text = lambda: "- [x] Inspect page"
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
node.ctx.task_control = task_control.TaskControl("pro-guidance", Mock())
task_control.add_instruction("pro-guidance", "Inspect another input first.")
assert not node._pro_ready_for_settlement(state)
@pytest.mark.asyncio
async def test_final_report_is_discarded_when_guidance_arrives(operator, monkeypatch):
node, state = operator
@@ -21,6 +21,7 @@ from unittest.mock import AsyncMock
import base64
import pytest
from PIL import Image
from PIL import ImageDraw
from artemis.agents.validator import precondition_pixel as pp
from langchain_core.messages import HumanMessage, SystemMessage
@@ -28,6 +29,87 @@ from langchain_core.messages import HumanMessage, SystemMessage
_PNG = b"fake-image-bytes"
def _encode_image(image):
buffer = BytesIO()
image.save(buffer, format="PNG")
return base64.b64encode(buffer.getvalue()).decode()
@pytest.mark.asyncio
@pytest.mark.parametrize("point", [[410, 2310], [1195, 251]])
async def test_pixel_judge_sees_device_target_in_each_resized_image(monkeypatch, point):
monkeypatch.setattr(pp, "_init_llm_and_prompt", lambda *a: (None, "prompt"))
completion = AsyncMock(
return_value={
"is_present": True,
"confidence": 0.99,
"target_kind": "control",
"target_matches": True,
}
)
monkeypatch.setattr(pp, "acomplete_structured", completion)
ctx = MagicMock()
ctx.device.device_width, ctx.device.device_height = 1280, 2856
reference = _encode_image(Image.new("RGB", (918, 2048), "white"))
session = AsyncMock()
session.screenshot_b64.return_value = _encode_image(Image.new("RGB", (640, 1428), "white"))
action = {"action": "tap", "coordinates": point.copy(), "target_text": "Input"}
verdict = await pp.validate_action_precondition_pixel(
ctx,
session,
action,
reference,
get_llm_fn=lambda: None,
)
assert verdict[0] is True
assert action["coordinates"] == point # Dispatch still uses native device pixels.
content = completion.await_args.args[1][1].content
images = [item["image_url"]["url"] for item in content if item["type"] == "image_url"]
assert len(images) == 2
for url in images:
with Image.open(BytesIO(base64.b64decode(url.split(",", 1)[1]))) as marked:
x = round(point[0] * marked.width / 1280)
y = round(point[1] * marked.height / 2856)
red, green, blue = marked.getpixel((x, y))
assert red > 180 and green < 60 and blue < 60
wrong = (min(point[0], marked.width - 1), min(point[1], marked.height - 1))
assert min(marked.getpixel(wrong)) > 200
@pytest.mark.asyncio
async def test_negative_cache_tracks_scaled_target_neighborhood(monkeypatch):
monkeypatch.setattr(pp, "_init_llm_and_prompt", lambda *a: (None, "prompt"))
attempt = AsyncMock(
return_value=(False, pp.ValidationErrorCategory.PIXEL_TARGET_DISAPPEARED, "missing")
)
monkeypatch.setattr(pp, "_run_attempt", attempt)
ctx = MagicMock()
ctx.device.device_width, ctx.device.device_height = 1280, 2856
image = Image.new("RGB", (918, 2048), "white")
frames = [_encode_image(image)]
ImageDraw.Draw(image).rectangle((260, 1620, 330, 1700), fill="blue")
frames.append(_encode_image(image))
ImageDraw.Draw(image).rectangle((0, 0, 20, 20), fill="black")
frames.append(_encode_image(image))
for frame in frames:
await pp.validate_action_precondition_pixel(
ctx,
AsyncMock(),
{"action": "tap", "coordinates": [410, 2310], "target_description": "Input"},
frame,
get_llm_fn=lambda: None,
)
assert attempt.await_count == 2
def test_changed_orientation_does_not_silently_move_marker():
image = base64.b64decode(_encode_image(Image.new("RGB", (2048, 918))))
with pytest.raises(ValueError, match="geometry changed"):
pp._annotate_target(image, [410, 2310], (1280, 2856))
@pytest.mark.asyncio
@pytest.mark.parametrize(
"response",
@@ -80,6 +162,56 @@ async def test_renaming_rejected_point_cannot_dispatch_again(monkeypatch):
assert attempt.await_count == 1
@pytest.mark.asyncio
@pytest.mark.parametrize(
"screenshot_error", [RuntimeError("device offline"), TimeoutError("capture timeout")]
)
async def test_screenshot_failure_is_unavailable_and_preserves_cause(monkeypatch, screenshot_error):
monkeypatch.setattr(pp, "_init_llm_and_prompt", lambda *a: (None, "prompt"))
monkeypatch.setattr(pp.visualization, "crop_and_annotate_target", lambda *a, **k: _PNG)
monkeypatch.setattr(pp, "_RETRY_DELAY_SECONDS", 0)
complete = AsyncMock()
monkeypatch.setattr(pp, "acomplete_structured", complete)
session = AsyncMock()
session.screenshot_b64.side_effect = screenshot_error
ctx = MagicMock()
ctx.rejected_pixel_targets = {}
action = {"action": "tap", "coordinates": [200, 300], "target_text": "Projects"}
for _ in range(2):
passed, category, reason = await pp.validate_action_precondition_pixel(
ctx, session, action, base64.b64encode(_PNG).decode(), get_llm_fn=lambda: None
)
assert passed is False
assert category == pp.ValidationErrorCategory.PIXEL_VERIFICATION_UNAVAILABLE
assert str(screenshot_error) in reason
assert "action was not executed" in reason
assert session.screenshot_b64.await_count == 2 * pp._MAX_ATTEMPTS
assert not ctx.rejected_pixel_targets
complete.assert_not_awaited()
@pytest.mark.asyncio
async def test_model_failure_is_unavailable_without_losing_parse_error(monkeypatch):
monkeypatch.setattr(pp, "_init_llm_and_prompt", lambda *a: (None, "prompt"))
monkeypatch.setattr(pp.visualization, "crop_and_annotate_target", lambda *a, **k: _PNG)
monkeypatch.setattr(pp, "_RETRY_DELAY_SECONDS", 0)
monkeypatch.setattr(
pp, "acomplete_structured", AsyncMock(side_effect=ValueError("invalid response"))
)
session = AsyncMock()
session.screenshot_b64.return_value = base64.b64encode(_PNG).decode()
passed, category, reason = await pp.validate_action_precondition_pixel(
MagicMock(),
session,
{"action": "tap", "coordinates": [200, 300], "target_text": "Projects"},
base64.b64encode(_PNG).decode(),
get_llm_fn=lambda: None,
)
assert passed is False
assert category == pp.ValidationErrorCategory.PIXEL_VERIFICATION_UNAVAILABLE
assert "invalid response" in reason
def _texts(messages: list) -> list[str]:
human = messages[1]
return [part["text"] for part in human.content if part["type"] == "text"]
+2 -2
View File
@@ -638,7 +638,7 @@ async def test_validator_pixel_validation_success(mock_mcp, mock_context, temp_s
# Mock crop and draw helpers to return dummy bytes
with (
patch(
"artemis.utils.visualization.crop_and_annotate_target",
"artemis.agents.validator.precondition_pixel._annotate_target",
return_value=b"dummy_bytes",
),
patch("artemis.agents.validator.validator.get_llm", return_value=mock_llm),
@@ -680,7 +680,7 @@ async def test_validator_pixel_validation_failure(mock_mcp, mock_context, temp_s
with (
patch(
"artemis.utils.visualization.crop_and_annotate_target",
"artemis.agents.validator.precondition_pixel._annotate_target",
return_value=b"dummy_bytes",
),
patch("artemis.agents.validator.validator.get_llm", return_value=mock_llm),
@@ -86,6 +86,28 @@ def test_healthy_helper_never_constructs_uiautomator(composite):
factory.assert_not_called()
def test_clear_reply_loss_never_replays_on_fallback_backend(composite):
client, helper, u2, factory, _ = composite
client.connect()
helper.clear_text.side_effect = HelperUnavailable("reply lost after clear")
with pytest.raises(HelperUnavailable):
client.clear_text()
helper.clear_text.assert_called_once_with()
factory.assert_not_called()
u2.clear_text.assert_not_called()
def test_clear_uses_the_backend_that_observed_the_field(composite):
client, helper, u2, _, clock = composite
helper.connect.side_effect = HelperUnavailable("offline")
client.connect()
clock["now"] = 100.0 # Helper recovery is due, but must wait for observation.
u2.clear_text.return_value = True
assert client.clear_text() is True
helper.clear_text.assert_not_called()
u2.clear_text.assert_called_once_with()
def test_connect_failure_degrades_to_uiautomator(composite):
client, helper, u2, factory, _ = composite
helper.connect.side_effect = HelperUnavailable("not installed")
@@ -12,6 +12,7 @@ from uiautomator2 import base, core
from artemis.clients import ui_automator_client
from artemis.clients.accessibility_client import HelperUnavailable
from artemis.clients.screen_client_factory import FallbackScreenClient
from artemis.runtime import observation_budget
@dataclass
@@ -128,6 +129,53 @@ def test_each_failed_launch_is_closed_before_retry_and_final_failure(server):
assert "3" in str(caught.value)
@pytest.mark.parametrize("helper_seconds", [0, 18, 59])
def test_helper_and_uiautomator_share_observation_deadline(server, monkeypatch, helper_seconds):
monkeypatch.setattr(observation_budget, "monotonic", server.clock.time)
helper = MagicMock()
def unavailable():
server.clock.sleep(helper_seconds)
raise HelperUnavailable("helper snapshot timed out")
helper.get_screen_data.side_effect = unavailable
client = FallbackScreenClient(
"isolated-startup-device",
helper=helper,
state_probe=lambda _serial: "device",
)
budget = observation_budget.ObservationBudget(deadline=60)
token = observation_budget.current_observation.set(budget)
try:
with pytest.raises(TimeoutError, match="预算耗尽"):
client.get_screen_data()
finally:
observation_budget.current_observation.reset(token)
client.disconnect()
assert server.clock.now == 60
assert len(server.connections) <= 2
assert all(c.close_calls == 1 for c in server.connections)
assert client.active_backend is None
def test_disconnect_during_connect_discards_and_stops_late_device(server, monkeypatch):
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
candidate = MagicMock()
def late_connection(_device_id):
client.disconnect(stop_server=True)
return candidate
factory = MagicMock(side_effect=late_connection)
monkeypatch.setattr(ui_automator_client, "_ManagedUIAutomatorDevice", factory)
with pytest.raises(RuntimeError, match="连接已取消"):
client.connect()
factory.assert_called_once()
candidate.stop_uiautomator.assert_called_once()
assert client._device is None
assert client._awake_strategy is None
def test_retry_can_connect_after_failed_launch_was_stopped(server):
server.modes = ["not_ready", "ready"]
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
+78
View File
@@ -103,6 +103,84 @@ async def test_wrap_update_note_tool_valid_status_change(tmp_path):
assert ctx.planner_task is None
@pytest.mark.asyncio
async def test_short_plan_replacement_cannot_duplicate_untouched_tail(tmp_path):
original = "- [/] Enter text\n- [ ] Clear text\n - verify: Input is empty\n"
path = _setup_plan(tmp_path, original)
ctx = _make_ctx(tmp_path, disable_planner_validation=True)
with patch(
"artemis.graph.graph.invoke_tool_with_injection",
side_effect=_fake_update_invoke(path),
):
result = await wrap_update_note_tool(ctx, _mock_tool("update_note")).ainvoke(
{
"key": "task_plan",
"target": "- [/] Enter text",
"replacement": "- [x] Enter text\n- [/] Clear text",
"tool_call_id": "duplicate-tail",
}
)
assert isinstance(result, ToolMessage)
assert result.status == "error"
assert "Clear text" in result.content
assert "target" in result.content
assert path.read_text(encoding="utf-8") == original
assert ctx.planner_task is None
assert ctx.pending_checkpoints == []
@pytest.mark.asyncio
@pytest.mark.parametrize("keep_existing_duplicate", [False, True])
async def test_duplicate_plan_can_be_repaired_with_a_complete_target(
tmp_path, keep_existing_duplicate
):
original = "- [x] Enter text\n- [/] Clear text\n- [ ] Clear text\n"
path = _setup_plan(tmp_path, original)
ctx = _make_ctx(tmp_path, disable_planner_validation=True)
replacement = "- [x] Enter text\n- [/] Clear text\n"
if keep_existing_duplicate:
replacement += "- [x] Clear text\n"
with patch(
"artemis.graph.graph.invoke_tool_with_injection",
side_effect=_fake_update_invoke(path),
):
result = await wrap_update_note_tool(ctx, _mock_tool("update_note")).ainvoke(
{
"key": "task_plan",
"target": original,
"replacement": replacement,
"tool_call_id": "repair-tail",
}
)
assert result == "Success"
assert path.read_text(encoding="utf-8") == replacement
@pytest.mark.asyncio
@pytest.mark.parametrize("tool_name", ["save_note", "append_note"])
async def test_other_plan_writes_cannot_introduce_duplicate_milestones(tmp_path, tool_name):
original = "- [/] Enter text\n- [ ] Clear text\n"
path = _setup_plan(tmp_path, original)
ctx = _make_ctx(tmp_path, disable_planner_validation=True)
async def fake_invoke(tool, args, tool_call_id, state, record_trace=None):
content = args["content"]
if tool_name == "append_note":
content = path.read_text(encoding="utf-8") + content
path.write_text(content, encoding="utf-8")
return "Success"
content = "- [/] Clear text\n"
if tool_name == "save_note":
content = original + content
with patch("artemis.graph.graph.invoke_tool_with_injection", side_effect=fake_invoke):
result = await wrap_note_tool(ctx, _mock_tool(tool_name)).ainvoke(
{"key": "task_plan", "content": content, "tool_call_id": "duplicate"}
)
assert result.status == "error"
assert path.read_text(encoding="utf-8") == original
@pytest.mark.asyncio
async def test_wrap_update_note_tool_intentional_rename_triggers_validation(tmp_path):
task_plan_path = _setup_plan(tmp_path)
+3 -2
View File
@@ -113,8 +113,9 @@ async def test_input_text_clear_erases_then_types_without_reclearing(actuator, m
res = await actuator.input_text("hello", (500, 300), clear_exist=True)
assert res.ok
actions = [h["action"] for h in actuator.action_history]
# The controller's full clear runs as a shell keyevent batch on this driver.
assert actions == ["tap", "execute_shell", "input_text"]
assert actions == ["tap", "input_text", "input_text"]
assert actuator.action_history[-2]["text"] == ""
assert actuator.action_history[-2]["clear_existing"] is True
assert actuator.action_history[-1]["clear_existing"] is False
+96
View File
@@ -0,0 +1,96 @@
"""Pro and Flash must judge clear versus fill against fresh input evidence."""
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from artemis.agents.validator.action_execution import exec_action
from artemis.mcp.action_executor import McpActionExecutor
from artemis.mcp.action_types import ActionResult, ObserveResult
@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ["pro", "flash"])
@pytest.mark.parametrize(
("text", "clear", "observed", "success"),
[
("", True, "", True),
("", True, "AR", False),
("", True, None, False),
("new", True, "new", True),
("new", True, "", False),
("new", False, "oldnew", True),
("new", False, None, False),
("", False, "", True),
("", False, "old", True),
],
)
async def test_input_verification_matches_intent(mode, text, clear, observed, success):
ctx = SimpleNamespace(
data_engine=None, device=SimpleNamespace(device_width=100, device_height=100)
)
element = {
"class": "android.widget.EditText",
"text": observed,
"focused": True,
"bounds": "[0,0][100,100]",
"center": [50, 50],
}
session = Mock()
session.call = AsyncMock(return_value=ActionResult.success("input_text", "Dispatched"))
session.ui_hierarchy = AsyncMock(return_value=[element])
if mode == "pro":
action = {
"action": "focus_and_input_text",
"coordinates": [50, 50],
"text": text,
"clear_before_input": clear,
}
ok, _ = await exec_action(ctx, session, action)
else:
executor = McpActionExecutor(ctx, actuator=Mock())
executor._session_or_start = AsyncMock(return_value=session)
executor._observe_after_action = AsyncMock(
return_value=(ObserveResult(ok=True, elements=[element]), True)
)
result = await executor._execute_device_action(
"input_text",
"input_text",
{"text": text, "clear_exist": clear},
"input",
SimpleNamespace(latest_screenshot=None, indexed_elements=[]),
)
ok = result.status == "success"
assert ok is success
session.call.assert_awaited_once()
@pytest.mark.asyncio
@pytest.mark.parametrize("observation_ok", [True, False])
async def test_flash_cannot_confirm_clear_without_fresh_hierarchy(observation_ok):
ctx = SimpleNamespace(data_engine=None, device=None)
session = Mock()
session.call = AsyncMock(return_value=ActionResult.success("input_text", "Dispatched"))
executor = McpActionExecutor(ctx, actuator=Mock())
executor._session_or_start = AsyncMock(return_value=session)
executor._observe_after_action = AsyncMock(
return_value=(
ObserveResult(
ok=observation_ok,
hierarchy_ok=False,
elements=[{"focused": True, "input_value_state": "empty", "center": [1, 1]}],
),
True,
)
)
result = await executor._execute_device_action(
"input_text",
"input_text",
{"text": "", "clear_exist": True},
"clear",
SimpleNamespace(latest_screenshot=None, indexed_elements=[]),
)
assert result.status == "error"
assert result.metadata["input_verification"] == "unknown"
session.call.assert_awaited_once()
+70 -1
View File
@@ -180,6 +180,41 @@ def test_mobile_run_task_with_device_serial(temp_trace_env):
assert status["device_serial"] == "pixel-11-pro-001"
@pytest.mark.parametrize("model", ["flash", "pro"])
def test_pc_run_task_uses_daemon_without_android_validation(temp_trace_env, monkeypatch, model):
monkeypatch.delenv("ARTEMIS_STANDALONE", raising=False)
with (
patch(
"mcp_server.tools.task_runner.ensure_daemon_running",
return_value=(True, "http://127.0.0.1:8000"),
),
patch(
"mcp_server.tools.task_runner._validate_device_serial",
side_effect=AssertionError("ADB must not be used"),
) as validate,
patch(
"mcp_server.tools.task_runner.submit_task_to_daemon",
side_effect=lambda **kwargs: {
"status": "started",
"tasks": [{"session_id": kwargs["session_id"]}],
},
) as submit,
patch("mcp_server.tools.task_runner.subprocess.Popen") as spawn,
):
result = mobile_run_task(
"Check desktop",
model=model,
device_serial="offline-phone",
runtime_overrides={"execution_target": "computer"},
)
assert result["status"] == "pending"
assert submit.call_args.kwargs["runtime_overrides"]["execution_target"] == "computer"
assert submit.call_args.kwargs["device_serial"] == "computer:local"
assert trace_store.read_status(result["trace_id"])["device_serial"] == "computer:local"
validate.assert_not_called()
spawn.assert_not_called()
def test_mobile_run_task_dispatched_to_daemon(temp_trace_env, monkeypatch):
monkeypatch.delenv("ARTEMIS_STANDALONE", raising=False)
with (
@@ -201,12 +236,46 @@ def test_mobile_run_task_dispatched_to_daemon(temp_trace_env, monkeypatch):
popen.assert_not_called()
assert result["trace_id"] == "daemon-sid-1"
assert result["status"] == "running"
assert result["status"] == "pending"
assert "enqueued via unified Artemis Daemon" in result["message"]
assert "stdout_log" in result
assert "stderr_log" in result
@pytest.mark.parametrize("lost_reply", [False, True])
@pytest.mark.parametrize("worker_status", ["pending", "running", "completed", "failed"])
def test_daemon_receipt_preserves_queue_and_worker_status(
temp_trace_env, monkeypatch, lost_reply, worker_status
):
monkeypatch.delenv("ARTEMIS_STANDALONE", raising=False)
queued = []
def submit(**kwargs):
sid = kwargs["session_id"]
assert trace_store.read_status(sid)["status"] == "pending"
queued.append({"session_id": sid, "status": "pending"})
if worker_status != "pending":
trace_store.update_trace_status(sid, worker_status)
return None if lost_reply else {"status": "started", "tasks": queued}
with (
patch(
"mcp_server.tools.task_runner.ensure_daemon_running",
return_value=(True, "http://127.0.0.1:8000"),
),
patch("mcp_server.tools.task_runner.submit_task_to_daemon", side_effect=submit),
patch(
"mcp_server.tools.task_runner.DeviceExecutionLock.get_queued_tasks",
side_effect=lambda: queued,
),
patch("mcp_server.tools.task_runner.subprocess.Popen") as spawn,
):
result = mobile_run_task("Queue state regression", model="Pro")
assert result["status"] == worker_status
assert trace_store.read_status(result["trace_id"])["status"] == worker_status
spawn.assert_not_called()
@pytest.mark.parametrize(
("kwargs", "match"),
[
+57 -39
View File
@@ -80,18 +80,14 @@ def test_same_serial_on_different_adb_endpoints_has_independent_locks():
)
first.acquire()
second.acquire()
try:
assert first.path != second.path
owners = DeviceExecutionLock.get_active_owners()
assert len(owners) == 2
assert {owner.lock_scope for owner in owners.values()} == {
"tcp:127.0.0.1:5037",
"tcp:127.0.0.1:5038",
}
with pytest.raises(DeviceBusyError, match="concurrency limit"):
second.acquire(blocking=False)
finally:
second.release()
first.release()
second.acquire(blocking=False)
second.release()
def test_device_lock_release_does_not_remove_replacement_owner():
@@ -185,28 +181,19 @@ def test_submission_reservations_preserve_order_before_workers_start():
thread.join(timeout=2.0)
def test_pending_reservation_at_queue_head_does_not_block_other_devices():
"""An unclaimed pending submission must not gate every device's queue head.
Regression: a reservation whose device is still "pending" used to be
counted into every device's FIFO queue, so one unclaimed head-of-queue
ticket blocked all devices at once. It must now park on at most one idle
candidate device (the first in sorted order) and leave the others
schedulable.
"""
def test_pending_reservation_at_queue_head_preserves_global_submission_order():
"""Unresolved earlier submissions retain their place across target devices."""
pending_ticket = DeviceExecutionLock.reserve("unclaimed submission")
# device-alpha is a known idle device (a queued submission targets it), so
# the pending ticket parks there; device-beta must stay schedulable.
alpha_ticket = DeviceExecutionLock.reserve("task for device alpha", device_id="device-alpha")
try:
beta = DeviceExecutionLock("device-beta", "task for device beta")
# Must acquire immediately even though the older pending ticket is at
# the head of the global queue directory.
beta.acquire(blocking=False)
beta.release()
with pytest.raises(DeviceBusyError, match="earlier queued task"):
beta.acquire(blocking=False)
finally:
DeviceExecutionLock.cancel_reservation(pending_ticket)
DeviceExecutionLock.cancel_reservation(alpha_ticket)
beta.acquire(blocking=False)
beta.release()
def test_pending_reservation_is_served_with_original_fifo_position_after_claim():
@@ -326,27 +313,58 @@ def test_active_owner_is_discoverable_and_can_be_annotated(monkeypatch):
owner_lock.release()
def test_multi_device_locks_can_run_concurrently():
lock_a = DeviceExecutionLock("emulator-5554", "task on device A")
lock_b = DeviceExecutionLock("pixel-9-test", "task on device B")
@pytest.mark.parametrize("targets", [("emulator-5554", "computer"), ("computer", "pixel-9-test")])
@pytest.mark.parametrize("legacy_mode", ["per_device", "parallel", "global"])
def test_all_targets_run_serially_even_with_legacy_parallel_options(
monkeypatch, targets, legacy_mode
):
monkeypatch.setenv("ARTEMIS_CONCURRENCY_MODE", legacy_mode)
monkeypatch.setenv("ARTEMIS_MAX_CONCURRENT_TASKS", "8")
lock_a = DeviceExecutionLock(
targets[0], "task A", concurrency_mode=legacy_mode, max_concurrency=0
)
lock_b = DeviceExecutionLock(
targets[1], "task B", concurrency_mode=legacy_mode, max_concurrency=8
)
lock_a.acquire()
try:
# Device B should be acquired without blocking since it's a different device
lock_b.acquire(blocking=False)
try:
active_owners = DeviceExecutionLock.get_active_owners()
assert "emulator-5554" in active_owners
assert "pixel-9-test" in active_owners
# A second task on device A should be blocked
lock_a_second = DeviceExecutionLock("emulator-5554", "second task on device A")
with pytest.raises(DeviceBusyError, match="task on device A"):
lock_a_second.acquire(blocking=False)
finally:
lock_b.release()
with pytest.raises(DeviceBusyError, match="task A"):
lock_b.acquire(blocking=False)
finally:
lock_a.release()
lock_b.acquire(blocking=False)
lock_b.release()
def test_shared_execution_lease_prevents_racing_admission():
"""Both schedulers can see an idle snapshot; only one may create a lease."""
first = DeviceExecutionLock("computer", "PC")
second = DeviceExecutionLock("android", "Android")
assert first._try_acquire_owner_lock()
try:
assert not second._try_acquire_owner_lock()
assert not second.path.exists()
finally:
first.release()
assert second._try_acquire_owner_lock()
second.release()
def test_same_session_nested_lease_does_not_wait_behind_other_tasks():
parent = DeviceExecutionLock("android", "parent", session_id="same-session")
nested = DeviceExecutionLock("computer", "nested", session_id="same-session")
parent.acquire()
earlier = DeviceExecutionLock.reserve("another task", device_id="computer")
try:
nested.acquire(blocking=False)
nested.release()
# Borrowing the shared resource must not delete the parent's lease.
challenger = DeviceExecutionLock("another-device", "challenger")
assert not challenger._try_acquire_owner_lock()
finally:
DeviceExecutionLock.cancel_reservation(earlier)
parent.release()
def test_global_concurrency_serial_mode_blocks_other_devices(monkeypatch):
+35
View File
@@ -14,6 +14,41 @@ import pytest
import artemis.runtime.task_control as task_control
def test_worker_admission_and_lease_acquisition_are_distinct_mcp_states(tmp_path, monkeypatch):
from artemis.runtime import trace_store
from mcp_server.tools import task_manager
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
monkeypatch.setattr(trace_store, "TRACES_DIR", str(tmp_path))
monkeypatch.setattr(task_manager, "_find_data_engine_db", lambda: None)
for sid in ("worker", "waiting"):
trace_store.init_trace(sid, "read-only test", "Pro")
trace_store.update_trace_status(sid, "pending")
task_control.write_control_state("worker", "queued", reset=True)
assert trace_store.read_status("worker")["status"] == "pending"
task_control.TaskControl("worker", object())._state("running")
for sid, expected in (("worker", "running"), ("waiting", "pending")):
stored = trace_store.read_status(sid)
assert stored["status"] == expected
assert task_manager._reconcile_task_state(sid, stored)[0] == expected
@pytest.mark.parametrize("status", ["completed", "failed", "cancelled", "interrupted"])
def test_late_worker_start_preserves_terminal_trace(tmp_path, monkeypatch, status):
from artemis.runtime import trace_store
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
monkeypatch.setattr(trace_store, "TRACES_DIR", str(tmp_path))
trace_store.init_trace("finished", "read-only test", "Pro")
before = trace_store.update_trace_status("finished", status, result={"kept": True})
task_control.TaskControl("finished", object())._state("running")
assert trace_store.read_status("finished") == before
def test_transition_timing_keeps_repeated_states_and_separates_pause_and_resource_wait(
tmp_path, monkeypatch
):
+26
View File
@@ -89,6 +89,32 @@ def test_update_trace_status_normalizes_success_alias(temp_trace_env):
assert persisted["status"] == "completed"
def test_conditional_status_update_checks_state_after_acquiring_lock(temp_trace_env, monkeypatch):
from contextlib import contextmanager
trace_store.init_trace("late-worker", "Test task", "Pro")
trace_store.update_trace_status("late-worker", "pending")
lock = trace_store._status_lock
terminal = {}
@contextmanager
def cancelled_before_lock(path):
with lock(path):
data = trace_store.read_status("late-worker")
data.update(status="cancelled", result="user stopped", end_time=123)
trace_store.write_status("late-worker", data)
terminal.update(data)
yield
monkeypatch.setattr(trace_store, "_status_lock", cancelled_before_lock)
result = trace_store.update_trace_status(
"late-worker", "running", expected_status="pending", result="must not overwrite"
)
assert result == terminal
assert trace_store.read_status("late-worker") == terminal
def test_read_nonexistent_status(temp_trace_env):
non_existent = str(uuid.uuid4())
assert trace_store.read_status(non_existent) is None
+26
View File
@@ -74,6 +74,32 @@ async def test_existing_artifact_is_preserved_unless_explicit_overwrite(ctx, png
assert json.loads(result)["ok"] and path.read_bytes() == png
@pytest.mark.asyncio
async def test_screenshot_receipt_repairs_pro_note_reference_without_recapture(
ctx, png, monkeypatch
):
from artemis.tools.scratchpad import append_note
run = AsyncMock(return_value=png)
monkeypatch.setattr(files.AndroidFiles, "_run", run)
receipt = json.loads(
await files.android_file.execute(ctx=ctx, action="screenshot", filename="round_empty.png")
)
missing = ctx.data_engine.base_dir / "round_empty.png"
error = await append_note.execute(ctx=ctx, key="evidence", content=f"Screenshot: {missing}")
assert "Failed to append" in error
assert receipt["trace_path"] in error
assert not (ctx.data_engine.base_dir / "notes" / "evidence.md").exists()
corrected = await append_note.execute(
ctx=ctx, key="evidence", content=f"Screenshot: {receipt['trace_path']}"
)
assert corrected == "Appended to note 'evidence'."
assert receipt["trace_path"] in (ctx.data_engine.base_dir / "notes" / "evidence.md").read_text(
encoding="utf-8"
)
assert run.await_count == 1
@pytest.mark.asyncio
@pytest.mark.parametrize(
"name", ["../escape.png", "C:\\out.png", "x:y.png", "CON.png", "x.png ", "a\n.png"]
+32 -1
View File
@@ -47,6 +47,35 @@ def _valid_png():
_VALID_PNG = _valid_png()
@pytest.mark.asyncio
async def test_disconnect_prevents_late_helper_failure_from_starting_uiautomator():
from artemis.clients.accessibility_client import HelperUnavailable
from artemis.clients.screen_client_factory import FallbackScreenClient
started, released = threading.Event(), threading.Event()
helper, factory, adb = MagicMock(), MagicMock(), MagicMock()
def capture():
started.set()
released.wait(2)
raise HelperUnavailable("late snapshot failure")
helper.get_screen_data.side_effect = capture
screen = FallbackScreenClient("test-device", helper=helper, uiautomator_factory=factory)
driver = AndroidAdbDriver("test-device", adb, ui_adb_client=screen)
pending = asyncio.create_task(driver.get_screen_data(skip_settling=True))
try:
assert await asyncio.to_thread(started.wait, 1)
await driver.disconnect()
finally:
released.set()
with pytest.raises(RuntimeError, match="观测已取消"):
await asyncio.wait_for(pending, 2)
factory.assert_not_called()
adb.device.assert_not_called()
assert screen.active_backend is None
@pytest.mark.asyncio
async def test_android_driver_rejects_placeholder_without_returning_stale_screen():
client = MagicMock()
@@ -77,7 +106,9 @@ async def test_screen_capture_timeout_does_not_start_overlapping_sdk_calls(monke
assert client.get_screen_data.call_count == 1
finally:
released.set()
await asyncio.wait_for(driver._screen_capture_task, 2)
with pytest.raises(TimeoutError, match="预算耗尽"):
await asyncio.wait_for(driver._screen_capture_task, 2)
client.get_hierarchy.assert_not_called()
def test_screenshot_limit_keeps_small_and_invalid_images_unchanged():
+99
View File
@@ -0,0 +1,99 @@
"""Whole-field clearing must not depend on text length or keyboard shortcuts."""
from unittest.mock import Mock
import pytest
from artemis.controllers.unified_controller import UnifiedMobileController
from artemis.drivers.android.adb_driver import AndroidAdbDriver
from artemis.drivers.android.input_ime import AndroidInputIME
@pytest.mark.asyncio
@pytest.mark.parametrize(
"value",
["ARTEMIS_PRO_R3_20261026", "长文本🙂\n" * 80, ""],
ids=["23-chars", "long-unicode-multiline", "empty"],
)
async def test_full_clear_replaces_the_whole_field(value):
field = {"text": value}
device, ui = Mock(), Mock()
def shell(command, **kwargs):
# A field that ignores Ctrl+A: the previous implementation leaves AR.
if "67" in command:
field["text"] = field["text"][: -command.count("67")]
return ""
def clear():
field["text"] = ""
return True
device.shell.side_effect = shell
ui.clear_text.side_effect = clear
driver = AndroidAdbDriver("test-device", Mock(), ui_adb_client=ui)
driver._device = device
controller = object.__new__(UnifiedMobileController)
controller._driver = driver
assert await controller.erase_text()
assert field["text"] == ""
ui.clear_text.assert_called_once_with()
device.shell.assert_not_called()
ui.set_clipboard.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize("implementation", ["driver", "ime"])
@pytest.mark.parametrize("failure", [False, RuntimeError("write reply lost")])
async def test_failed_clear_never_types_or_retries(implementation, failure):
device, ui = Mock(), Mock()
if isinstance(failure, Exception):
ui.clear_text.side_effect = failure
else:
ui.clear_text.return_value = failure
if implementation == "driver":
driver = AndroidAdbDriver("test-device", Mock(), ui_adb_client=ui)
driver._device = device
result = await driver.input_text("replacement", clear_existing=True)
else:
result = await AndroidInputIME(device, ui).type_text("replacement", clear_existing=True)
assert result is False
ui.clear_text.assert_called_once_with()
device.shell.assert_not_called()
ui.set_clipboard.assert_not_called()
ui.send_text.assert_not_called()
@pytest.mark.asyncio
async def test_missing_clear_backend_is_not_reported_as_success():
driver = AndroidAdbDriver("test-device", Mock())
driver._device = Mock()
assert not await driver.input_text("replacement", clear_existing=True)
driver._device.shell.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize("implementation", ["driver", "ime"])
async def test_empty_append_does_not_paste_or_send_keys(implementation):
device, ui = Mock(), Mock()
if implementation == "driver":
driver = AndroidAdbDriver("test-device", Mock(), ui_adb_client=ui)
driver._device = device
result = await driver.input_text("", clear_existing=False)
else:
result = await AndroidInputIME(device, ui).type_text("", clear_existing=False)
assert result is True
device.shell.assert_not_called()
ui.clear_text.assert_not_called()
ui.set_clipboard.assert_not_called()
@pytest.mark.asyncio
async def test_partial_delete_stops_at_first_failed_key():
from unittest.mock import AsyncMock
controller = object.__new__(UnifiedMobileController)
controller._driver = Mock()
controller._driver.press_key = AsyncMock(return_value=False)
assert not await controller.erase_text(nb_chars=3)
controller._driver.press_key.assert_awaited_once_with("delete")
+10 -5
View File
@@ -280,7 +280,7 @@ server.run()
@pytest.mark.parametrize("scope", ["", "test-adb-endpoint"])
def test_resource_lock_conflicts_across_devices_without_holding_partial_leases(
def test_serial_resource_locks_across_devices_without_holding_partial_leases(
tmp_path, monkeypatch, scope
):
import artemis.runtime.device_lock as locks
@@ -304,16 +304,20 @@ def test_resource_lock_conflicts_across_devices_without_holding_partial_leases(
try:
with pytest.raises(DeviceBusyError):
second.acquire(blocking=False)
other.acquire(blocking=False)
assert len(DeviceExecutionLock.get_active_owners()) == 2
assert DeviceExecutionLock.get_active_owners()["phone-a"].resource_ids == ("desktop:local",)
with pytest.raises(DeviceBusyError):
other.acquire(blocking=False)
assert len(DeviceExecutionLock.get_active_owners()) == 1
resources = (DeviceExecutionLock.EXECUTION_RESOURCE, "desktop:local")
assert DeviceExecutionLock.get_active_owners()["phone-a"].resource_ids == resources
assert DeviceExecutionLock.annotate_active_owner(
session_id="first", device_id="phone-a", ingress="test"
)
assert DeviceExecutionLock.get_active_owners()["phone-a"].resource_ids == ("desktop:local",)
assert DeviceExecutionLock.get_active_owners()["phone-a"].resource_ids == resources
finally:
other.release()
first.release()
other.acquire(blocking=False)
other.release()
second.acquire(blocking=False)
second.release()
assert not DeviceExecutionLock.get_active_owners()
@@ -562,6 +566,7 @@ server.run()
other.release()
assert await asyncio.wait_for(future, timeout=15) == (True, None)
assert DeviceExecutionLock.get_active_owners()["computer-a"].resource_ids == (
DeviceExecutionLock.EXECUTION_RESOURCE,
"desktop:local",
)
result = await runtime.call(next(iter(runtime.bindings)), {})
+54
View File
@@ -23,6 +23,60 @@ from artemis.utils.notes import (
import pytest
@pytest.mark.parametrize("operation", ["save", "append", "update"])
@pytest.mark.parametrize("path_style", ["native", "posix"])
def test_note_rejects_misplaced_artifact_and_accepts_exact_receipt(tmp_path, operation, path_style):
artifact = tmp_path / "artifacts" / "android" / "截图 one.png"
artifact.parent.mkdir(parents=True)
artifact.write_bytes(b"evidence")
save_note_content(tmp_path, "evidence", "original")
note = tmp_path / "notes" / "evidence.md"
before = note.read_bytes()
missing = tmp_path / artifact.name
wrong = str(missing) if path_style == "native" else missing.as_posix()
def write(content):
if operation == "save":
save_note_content(tmp_path, "evidence", content)
elif operation == "append":
append_note_content(tmp_path, "evidence", content)
else:
update_note_content(tmp_path, "evidence", "original", content)
with pytest.raises(ValueError, match="exact trace_path") as error:
write(f"Screenshot: `{wrong}`。")
assert str(artifact) in str(error.value)
assert note.read_bytes() == before
assert artifact.read_bytes() == b"evidence"
write(f"Screenshot: `{artifact}`")
assert str(artifact) in note.read_text(encoding="utf-8")
def test_note_does_not_guess_between_duplicate_artifact_names(tmp_path):
files = [
tmp_path / "artifacts" / "android" / "screen.png",
tmp_path / "artifacts" / "plugins" / "cua-driver" / "screen.png",
]
for path in files:
path.parent.mkdir(parents=True)
path.write_bytes(b"evidence")
with pytest.raises(ValueError) as error:
save_note_content(tmp_path, "evidence", str(tmp_path / "screen.png"))
assert all(str(path) in str(error.value) for path in files)
assert not (tmp_path / "notes" / "evidence.md").exists()
def test_note_allows_existing_root_files_and_unknown_future_paths(tmp_path):
artifact = tmp_path / "artifacts" / "android" / "screen.png"
artifact.parent.mkdir(parents=True)
artifact.write_bytes(b"artifact")
root_file = tmp_path / "screen.png"
root_file.write_bytes(b"different root file")
content = f"{root_file}\n{tmp_path / 'future.png'}\n{tmp_path / 'screen.png.backup'}"
save_note_content(tmp_path, "plan", content)
assert read_note_content(tmp_path, "plan") == content
@pytest.mark.parametrize("content", ["# 报告\n结果\n", "line\r\nsecond", ""])
def test_verified_note_receipt_matches_actual_file_bytes(tmp_path, content):
import hashlib