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.
252 lines
9.3 KiB
Python
252 lines
9.3 KiB
Python
# Copyright 2026 Google LLC
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
from fastapi import HTTPException
|
|
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,
|
|
ProbeStatus,
|
|
)
|
|
|
|
|
|
@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(
|
|
id="android_adb",
|
|
category=ProbeCategory.DEVICE,
|
|
title="Device / Emulator Connected",
|
|
status=ProbeStatus.WARN,
|
|
is_blocker=True,
|
|
summary="Device Locked",
|
|
description="Unlock the device.",
|
|
)
|
|
run_probe = AsyncMock(return_value=locked_probe)
|
|
enqueue_tasks = AsyncMock()
|
|
monkeypatch.setattr(tasks.readiness_engine, "run_device_submission_probe", run_probe)
|
|
monkeypatch.setattr(tasks.task_queue_service, "enqueue_tasks", enqueue_tasks)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await tasks.run_task(RunRequest(goal="Open Settings"))
|
|
|
|
assert exc_info.value.status_code == 409
|
|
assert "locked" in exc_info.value.detail.lower()
|
|
enqueue_tasks.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_task_enqueues_when_device_is_unlocked(monkeypatch):
|
|
unlocked_probe = ProbeResult(
|
|
id="android_adb",
|
|
category=ProbeCategory.DEVICE,
|
|
title="Device / Emulator Connected",
|
|
status=ProbeStatus.PASS,
|
|
is_blocker=True,
|
|
summary="Connected",
|
|
description="Ready.",
|
|
)
|
|
run_probe = AsyncMock(return_value=unlocked_probe)
|
|
enqueue_tasks = AsyncMock(return_value={"status": "started", "tasks": []})
|
|
monkeypatch.setattr(tasks.readiness_engine, "run_device_submission_probe", run_probe)
|
|
monkeypatch.setattr(tasks.task_queue_service, "enqueue_tasks", enqueue_tasks)
|
|
|
|
result = await tasks.run_task(RunRequest(goal="Open Settings"))
|
|
|
|
assert result["status"] == "started"
|
|
enqueue_tasks.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_task_binds_probe_verified_device_when_no_serial_requested(monkeypatch):
|
|
"""Without an explicit serial, run_task enqueues on the probe-verified live device."""
|
|
unlocked_probe = ProbeResult(
|
|
id="android_adb",
|
|
category=ProbeCategory.DEVICE,
|
|
title="Device / Emulator Connected",
|
|
status=ProbeStatus.PASS,
|
|
is_blocker=True,
|
|
summary="Connected",
|
|
description="Ready.",
|
|
metadata={
|
|
"installed": True,
|
|
"submission_probe": True,
|
|
"active_device": {"serial": "emulator-5556", "is_locked": False},
|
|
},
|
|
)
|
|
run_probe = AsyncMock(return_value=unlocked_probe)
|
|
enqueue_tasks = AsyncMock(return_value={"status": "started", "tasks": []})
|
|
monkeypatch.setattr(tasks.readiness_engine, "run_device_submission_probe", run_probe)
|
|
monkeypatch.setattr(tasks.task_queue_service, "enqueue_tasks", enqueue_tasks)
|
|
|
|
result = await tasks.run_task(RunRequest(goal="Run on any ready device"))
|
|
|
|
assert result["status"] == "started"
|
|
enqueue_tasks.assert_awaited_once()
|
|
# Check that device_serial passed to enqueue_tasks is the verified unlocked device
|
|
_, kwargs = enqueue_tasks.call_args
|
|
assert kwargs.get("device_serial") == "emulator-5556"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_idempotent_retry_skips_device_probe_for_active_session(monkeypatch):
|
|
run_probe = AsyncMock()
|
|
monkeypatch.setattr(tasks.readiness_engine, "run_device_submission_probe", run_probe)
|
|
monkeypatch.setattr(tasks.state, "active_session_id", "sdk-task-1")
|
|
monkeypatch.setattr(tasks.state, "active_connections", {})
|
|
monkeypatch.setattr(
|
|
tasks.session_repo,
|
|
"get_session_by_id",
|
|
lambda session_id: None,
|
|
)
|
|
|
|
result = await tasks.run_task(
|
|
RunRequest(
|
|
goal="Open Settings",
|
|
session_id="sdk-task-1",
|
|
device_serial="pixel-10",
|
|
ingress="python_sdk",
|
|
)
|
|
)
|
|
|
|
assert result["status"] == "running"
|
|
assert result["enqueued_count"] == 0
|
|
assert result["tasks"][0]["session_id"] == "sdk-task-1"
|
|
run_probe.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_explicit_device_is_rejected_before_readiness_probe(monkeypatch):
|
|
run_probe = AsyncMock()
|
|
monkeypatch.setattr(tasks.readiness_engine, "run_device_submission_probe", run_probe)
|
|
monkeypatch.setattr(tasks.state, "active_session_id", None)
|
|
monkeypatch.setattr(tasks.state, "active_connections", {})
|
|
monkeypatch.setattr(tasks.state, "queue_items", [])
|
|
monkeypatch.setattr(tasks.session_repo, "get_session_by_id", lambda session_id: None)
|
|
monkeypatch.setattr(
|
|
tasks.device_pool,
|
|
"try_list_devices_async",
|
|
AsyncMock(
|
|
return_value=[
|
|
SimpleNamespace(serial="pixel-10", state="device"),
|
|
]
|
|
),
|
|
)
|
|
|
|
result = await tasks.run_task(
|
|
RunRequest(
|
|
goal="Must not run",
|
|
device_serial="missing-device",
|
|
session_id="00000000-0000-4000-8000-000000000099",
|
|
)
|
|
)
|
|
|
|
assert result["status"] == "rejected"
|
|
assert result["tasks"] == []
|
|
assert "not connected" in result["error"]
|
|
run_probe.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_explicit_device_is_rejected_when_attached_but_not_ready(monkeypatch):
|
|
run_probe = AsyncMock()
|
|
monkeypatch.setattr(tasks.readiness_engine, "run_device_submission_probe", run_probe)
|
|
monkeypatch.setattr(tasks.state, "active_session_id", None)
|
|
monkeypatch.setattr(tasks.state, "active_connections", {})
|
|
monkeypatch.setattr(tasks.state, "queue_items", [])
|
|
monkeypatch.setattr(tasks.session_repo, "get_session_by_id", lambda session_id: None)
|
|
monkeypatch.setattr(
|
|
tasks.device_pool,
|
|
"try_list_devices_async",
|
|
AsyncMock(
|
|
return_value=[
|
|
SimpleNamespace(serial="pixel-10", state="unauthorized"),
|
|
]
|
|
),
|
|
)
|
|
|
|
result = await tasks.run_task(
|
|
RunRequest(
|
|
goal="Must not run",
|
|
device_serial="pixel-10",
|
|
session_id="00000000-0000-4000-8000-000000000100",
|
|
)
|
|
)
|
|
|
|
assert result["status"] == "rejected"
|
|
assert "unauthorized" in result["error"]
|
|
run_probe.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.parametrize("enumeration", [None, []])
|
|
@pytest.mark.asyncio
|
|
async def test_explicit_device_proceeds_when_enumeration_is_indeterminate(monkeypatch, enumeration):
|
|
"""A failed (None) or empty enumeration must never hard-reject an explicit
|
|
serial: the submission queues and fails downstream if the device is truly
|
|
absent. This is the startup-storm regression guard."""
|
|
run_probe = AsyncMock(return_value=None)
|
|
enqueue_tasks = AsyncMock(return_value={"status": "queued", "tasks": []})
|
|
monkeypatch.setattr(tasks.readiness_engine, "run_device_submission_probe", run_probe)
|
|
monkeypatch.setattr(tasks.task_queue_service, "enqueue_tasks", enqueue_tasks)
|
|
monkeypatch.setattr(tasks.state, "active_session_id", None)
|
|
monkeypatch.setattr(tasks.state, "active_connections", {})
|
|
monkeypatch.setattr(tasks.state, "queue_items", [])
|
|
monkeypatch.setattr(tasks.session_repo, "get_session_by_id", lambda session_id: None)
|
|
monkeypatch.setattr(
|
|
tasks.device_pool,
|
|
"try_list_devices_async",
|
|
AsyncMock(return_value=enumeration),
|
|
)
|
|
|
|
result = await tasks.run_task(
|
|
RunRequest(
|
|
goal="Queue through the startup storm",
|
|
device_serial="pixel-10",
|
|
session_id="00000000-0000-4000-8000-000000000101",
|
|
)
|
|
)
|
|
|
|
assert result["status"] == "queued"
|
|
enqueue_tasks.assert_awaited_once()
|
|
_, kwargs = enqueue_tasks.call_args
|
|
assert kwargs.get("device_serial") == "pixel-10"
|