from __future__ import annotations import tempfile import time import unittest from pathlib import Path from unittest.mock import patch from tests.test_server import FASTAPI_AVAILABLE if FASTAPI_AVAILABLE: from fastapi.testclient import TestClient from tests.browser_tool_server import fixture_application from uvm.server import create_api @unittest.skipUnless(FASTAPI_AVAILABLE, "FastAPI and httpx are required") class ToolFlowTests(unittest.TestCase): def setUp(self): temporary = self.enterContext(tempfile.TemporaryDirectory()) self.application = fixture_application(Path(temporary), delay=0.01) self.enterContext(patch("uvm.tools.require_root")) self.headers = {"X-UVM-Token": "browser-test-token"} def wait_for(self, condition): deadline = time.monotonic() + 5 while time.monotonic() < deadline: if value := condition(): return value time.sleep(0.01) self.fail("background tool worker did not reach the expected state") def test_create_and_later_install_use_real_workers_and_durable_job_responses(self): with TestClient(create_api(self.application, host="127.0.0.1")) as client: response = client.post("/vms", headers=self.headers, json={"name": "tools-vm", "tools": [ {"id": "git"}, {"id": "python", "version": "3.12"}, ]}) self.assertEqual(response.status_code, 201, response.text) vm_id = response.json()["id"] self.wait_for(lambda: self.application.tools.store.read(vm_id)["jobs"][-1]["status"] == "succeeded") snapshot = client.get(f"/vms/{vm_id}/tools?refresh=true", headers=self.headers) self.assertEqual(snapshot.status_code, 200, snapshot.text) installed = {item["key"] for item in snapshot.json()["inventory"] if item["installed"]} self.assertEqual(installed, {"git", "python-3.12", "curl"}) self.assertEqual(snapshot.json()["latest_job"]["source"], "create") later = client.post("/vms/vm-other/tools/install", headers=self.headers, json={"tools": [{"id": "htop"}], "start_if_stopped": True}) self.assertEqual(later.status_code, 202, later.text) job_id = later.json()["id"] self.wait_for(lambda: self.application.tools.store.get_job("vm-other", job_id)["status"] == "succeeded") self.assertEqual(self.application.state_store.get("vm-other").status, "running") self.assertEqual(client.get(f"/vms/{vm_id}/tools/jobs/{job_id}", headers=self.headers).status_code, 404) def test_queued_jobs_are_dispatched_on_server_start(self): with self.application.state_store.operation_lock(): job = self.application.tools.store.enqueue_locked("vm-test", [{"id": "git"}]) with TestClient(create_api(self.application, host="127.0.0.1")): self.wait_for(lambda: self.application.tools.store.get_job("vm-test", job["id"])["status"] == "succeeded") self.assertEqual(self.application.lifecycle.attempts[("vm-test", "git")], 1) def test_shutdown_wakes_command_waiter_and_keeps_an_interrupted_job(self): self.application.lifecycle.delay = 30 with TestClient(create_api(self.application, host="127.0.0.1")) as client: response = client.post("/vms/vm-test/tools/install", headers=self.headers, json={"tools": [{"id": "git"}]}) job_id = response.json()["id"] self.wait_for(lambda: self.application.tools.store.get_job("vm-test", job_id)["status"] == "installing" and bool(self.application.tools._sessions)) started = time.monotonic() self.assertLess(time.monotonic() - started, 3) job = self.application.tools.store.get_job("vm-test", job_id) self.assertEqual(job["status"], "interrupted") self.assertTrue(job["outcome_unknown"]) self.assertTrue(all(not thread.is_alive() for thread in self.application.tools._threads))