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:
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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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"),
|
||||
[
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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")
|
||||
@@ -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)), {})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user