from __future__ import annotations import hashlib import json import os import threading import time import zipfile from pathlib import Path import pytest from cognitive_agent.application.firmware_workbench import ( FINAL_RELEASE_MESSAGE, FirmwareWorkbenchError, FirmwareWorkbenchService, _atomic_write_json, ) from cognitive_agent.infrastructure.can.gateway import CanGatewayError ROOT = Path(__file__).resolve().parents[2] def wait_for_terminal(service: FirmwareWorkbenchService, task_id: str) -> dict[str, object]: deadline = time.monotonic() + 5 while time.monotonic() < deadline: task = service.task(task_id) if task["status"] not in {"waiting", "running", "pending_confirmation"}: return task time.sleep(0.02) raise AssertionError("firmware pipeline did not reach a terminal state") def make_image(tmp_path: Path) -> Path: image = tmp_path / "Seat_Controller_demo.hex" image.write_bytes(b":020000040000FA\n" + b":100000000102030405060708090A0B0C0D0E0F1068\n" * 80) return image def test_pipeline_publishes_a_downloadable_ota_package( tmp_path: Path, monkeypatch ) -> None: monkeypatch.setenv( "FIRMWARE_WORKBENCH_OTA_PUBLIC_BASE_URL", "http://192.168.43.130:8000/api/firmware-workbench/releases", ) image = make_image(tmp_path) def build_runner( _task_id: str, log: object, _cancel: threading.Event ) -> Path: log("info", "IAR build: 0 errors, 0 warnings") return image service = FirmwareWorkbenchService( ROOT, artifact_root=tmp_path / "workbench", ota_root=tmp_path / "ota", build_runner=build_runner, ) instruction = "增加支持座椅位置参数控制的能力,增加座椅通风功能,增加座椅加热功能" first = service.start_task(instruction) task = wait_for_terminal(service, str(first["task"]["id"])) assert task["status"] == "success" assert task["message"] == FINAL_RELEASE_MESSAGE assert [stage["status"] for stage in task["stages"]] == ["success"] * 8 assert len(task["test_results"]) == 7 snapshot = service.snapshot() assert snapshot["baselines"] == [ { "id": "time_only", "name": "时间控制基线", "capabilities": ["时间控制"], } ] assert len(snapshot["artifacts"]) == 2 assert len(snapshot["releases"]) == 1 artifact = next(item for item in snapshot["artifacts"] if item["task_id"] == task["id"]) baseline = next(item for item in snapshot["artifacts"] if item.get("is_baseline")) release = snapshot["releases"][0] assert baseline["capabilities"] == ["时间控制"] assert baseline["protected"] is True assert artifact["capabilities"] == ["时间控制", "位置控制", "座椅通风", "座椅加热"] assert artifact["status"] == "published" assert release["download_url"] == ( f"http://192.168.43.130:8000/api/firmware-workbench/releases/" f"{release['id']}/download" ) assert service.latest_release()["download_url"] == release["download_url"] latest_manifest = json.loads((tmp_path / "ota" / "latest.json").read_text("utf-8")) assert latest_manifest["download_url"] == release["download_url"] package, filename = service.resolve_release_download(str(release["id"])) assert filename == artifact["firmware_filename"] assert hashlib.sha256(package.read_bytes()).hexdigest() == artifact["sha256"] with zipfile.ZipFile(package) as archive: assert { "Seat_Controller_demo.hex", "manifest.json", "requirements.json", "validation.json", "feature_profile.json", } <= set(archive.namelist()) assert (tmp_path / "ota" / "latest.json").is_file() assert service.publish_artifact(str(artifact["id"]))["id"] == release["id"] service.close() monkeypatch.setenv( "FIRMWARE_WORKBENCH_OTA_PUBLIC_BASE_URL", "http://192.168.43.131:8000/api/firmware-workbench/releases", ) migrated = FirmwareWorkbenchService( ROOT, artifact_root=tmp_path / "workbench", ota_root=tmp_path / "ota", build_runner=build_runner, ) migrated_release = migrated.latest_release() assert migrated_release["download_url"].startswith("http://192.168.43.131:8000/") migrated_latest = json.loads((tmp_path / "ota" / "latest.json").read_text("utf-8")) assert migrated_latest["download_url"] == migrated_release["download_url"] migrated.close() def test_repeated_instruction_always_creates_and_executes_a_new_task(tmp_path: Path) -> None: image = make_image(tmp_path) def build_runner( _task_id: str, log: object, _cancel: threading.Event ) -> Path: log("info", "IAR build: 0 errors, 0 warnings") return image service = FirmwareWorkbenchService( ROOT, artifact_root=tmp_path / "workbench", ota_root=tmp_path / "ota", build_runner=build_runner, ) first = service.start_task("增加座椅加热功能。") first_task = wait_for_terminal(service, str(first["task"]["id"])) second = service.start_task("增加座椅加热功能。") assert first["task"]["id"] != second["task"]["id"] assert "duplicate" not in first assert "duplicate" not in second second_task = wait_for_terminal(service, str(second["task"]["id"])) assert first_task["status"] == "success" assert second_task["status"] == "success" assert first_task["target_capabilities"] == ["时间控制", "座椅加热"] assert second_task["target_capabilities"] == ["时间控制", "座椅加热"] service.close() def test_non_baseline_artifact_can_be_deleted_but_baseline_is_protected(tmp_path: Path) -> None: image = make_image(tmp_path) def build_runner( _task_id: str, log: object, _cancel: threading.Event ) -> Path: log("info", "IAR build: 0 errors, 0 warnings") return image service = FirmwareWorkbenchService( ROOT, artifact_root=tmp_path / "workbench", ota_root=tmp_path / "ota", build_runner=build_runner, ) started = service.start_task("增加座椅通风功能") task = wait_for_terminal(service, str(started["task"]["id"])) package_path, _ = service.resolve_artifact_download(str(task["artifact_id"])) release_path, _ = service.resolve_release_download(str(task["release_id"])) result = service.delete_artifact(str(task["artifact_id"])) assert result["deleted"] is True assert not package_path.exists() assert not release_path.exists() snapshot = service.snapshot() assert [artifact["id"] for artifact in snapshot["artifacts"]] == [ "fw-art-time-only-baseline" ] assert snapshot["releases"] == [] try: service.delete_artifact("fw-art-time-only-baseline") except Exception as exc: # noqa: BLE001 - public error type is asserted below assert getattr(exc, "code", None) == "baseline_protected" else: raise AssertionError("time-only baseline must not be deletable") service.close() def test_position_instruction_merges_with_time_only_baseline(tmp_path: Path) -> None: image = make_image(tmp_path) def build_runner( _task_id: str, log: object, _cancel: threading.Event ) -> Path: log("info", "IAR build: profile position=1 ventilation=0 heating=0") return image service = FirmwareWorkbenchService( ROOT, artifact_root=tmp_path / "workbench", ota_root=tmp_path / "ota", build_runner=build_runner, ) started = service.start_task("增加支持座椅位置参数控制的能力。", "time_only") task = wait_for_terminal(service, str(started["task"]["id"])) assert task["status"] == "success" assert task["baseline_capabilities"] == ["时间控制"] assert task["requested_capabilities"] == ["位置控制"] assert task["target_capabilities"] == ["时间控制", "位置控制"] artifact = service.snapshot()["artifacts"][0] assert artifact["capabilities"] == ["时间控制", "位置控制"] assert artifact["capability_diff"]["added"] == ["位置控制"] assert "vent" not in artifact["firmware_filename"] assert "heat" not in artifact["firmware_filename"] profile_path = tmp_path / "workbench" / "tasks" / task["id"] / "feature_profile.json" assert json.loads(profile_path.read_text("utf-8"))["rocking_horse"] is False service.close() def test_running_pipeline_can_be_canceled_and_retried(tmp_path: Path) -> None: image = make_image(tmp_path) allow_build = threading.Event() def build_runner( _task_id: str, log: object, cancel: threading.Event ) -> Path: log("info", "IAR build started") while not allow_build.is_set(): if cancel.wait(0.01): raise InterruptedError return image service = FirmwareWorkbenchService( ROOT, artifact_root=tmp_path / "workbench", ota_root=tmp_path / "ota", build_runner=build_runner, ) started = service.start_task("增加座椅通风功能") task_id = str(started["task"]["id"]) deadline = time.monotonic() + 3 while service.task(task_id)["current_stage"] != "build" and time.monotonic() < deadline: time.sleep(0.01) service.cancel_task(task_id) canceled = wait_for_terminal(service, task_id) assert canceled["status"] == "canceled" assert any(stage["status"] == "canceled" for stage in canceled["stages"]) allow_build.set() retried = service.retry_task(task_id) assert retried["id"] == task_id completed = wait_for_terminal(service, task_id) assert completed["status"] == "success" service.close() @pytest.mark.parametrize("instruction", [ "增加动态木马功能", "增加木马功能", "Add rocking_horse", "Add Rocking Horse", "Add rocking-horse", ]) def test_rocking_horse_package_and_notification(tmp_path: Path, instruction: str) -> None: image = make_image(tmp_path) profile = { "position_control": True, "ventilation": False, "heating": False, "rocking_horse": True, } def build_runner(task_id: str, log: object, cancel: threading.Event) -> Path: workspace = tmp_path / "workbench" / "tasks" / task_id assert json.loads((workspace / "feature_profile.json").read_text("utf-8")) == profile return image service = FirmwareWorkbenchService( ROOT, artifact_root=tmp_path / "workbench", ota_root=tmp_path / "ota", build_runner=build_runner, ) try: started = service.start_task(instruction) task = wait_for_terminal(service, started["task"]["id"]) assert task["status"] == "success", task.get("failure_reason") assert [stage["status"] for stage in task["stages"]] == ["success"] * 8 assert task["target_capabilities"] == ["时间控制", "位置控制", "动态木马"] assert any(item["name"] == "动态木马控制与停止保护" for item in task["test_results"]) artifact = service.artifact(task["artifact_id"]) assert artifact["capability_diff"]["added"] == ["位置控制", "动态木马"] assert "rocking-horse" in artifact["firmware_filename"] package, _ = service.resolve_release_download(task["release_id"]) with zipfile.ZipFile(package) as archive: assert json.loads(archive.read("manifest.json"))["capabilities"] == task["target_capabilities"] assert json.loads(archive.read("feature_profile.json")) == profile assert json.loads(archive.read("requirements.json"))["feature_profile"] == profile frames = [] def publish(frame): frames.append(frame) return {"status": "published"} result = service.notify_artifact(artifact["id"], publish) assert result["published_count"] == 3 assert [frame["sequence"] for frame in frames] == [1, 2, 3] assert len({frame["message_id"] for frame in frames}) == 1 assert all(frame["firmware"]["artifact_name"] == artifact["firmware_filename"] for frame in frames) assert all(frame["firmware"]["sha256"] == artifact["sha256"] for frame in frames) assert all(frame["auto_upgrade"] is False for frame in frames) def unavailable(frame): raise CanGatewayError("broker unavailable") with pytest.raises(FirmwareWorkbenchError, match="0/3") as error: service.notify_artifact(artifact["id"], unavailable) assert error.value.code == "notification_publish_failed" assert service.artifact(artifact["id"])["status"] == "published" finally: service.close() def test_rocking_horse_build_failure_does_not_publish(tmp_path: Path) -> None: def fail_build(task_id: str, log: object, cancel: threading.Event) -> Path: raise RuntimeError("IAR build failed") service = FirmwareWorkbenchService( ROOT, artifact_root=tmp_path / "workbench", ota_root=tmp_path / "ota", build_runner=fail_build, ) try: started = service.start_task("增加动态木马功能") task = wait_for_terminal(service, started["task"]["id"]) assert task["status"] == "failed" assert task["failure_reason"] == "IAR build failed" assert service.snapshot()["releases"] == [] assert all(item.get("is_baseline") for item in service.snapshot()["artifacts"]) finally: service.close() def test_atomic_state_write_retries_locks_without_touching_other_temporary_files( tmp_path: Path, monkeypatch, ) -> None: path = tmp_path / "state.json" previous = '{"tasks": [{"id": "previous-task"}]}' path.write_text(previous, encoding="utf-8") legacy_temporary = tmp_path / "state.tmp" legacy_temporary.write_text("another writer", encoding="utf-8") original_replace = Path.replace attempts = [] def blocked_replace(source, destination): attempts.append(source) assert path.read_text(encoding="utf-8") == previous assert json.loads(source.read_text(encoding="utf-8")) == {"tasks": ["新任务"]} if len(attempts) <= 2: raise PermissionError(13, "file temporarily in use", str(destination)) return original_replace(source, destination) monkeypatch.setattr(Path, "replace", blocked_replace) _atomic_write_json(path, {"tasks": ["新任务"]}) assert len(attempts) == 3 assert attempts[0] != legacy_temporary assert len(set(attempts)) == 1 assert legacy_temporary.read_text(encoding="utf-8") == "another writer" assert json.loads(path.read_text(encoding="utf-8")) == {"tasks": ["新任务"]} assert list(tmp_path.glob(".state.json.*.tmp")) == [] @pytest.mark.parametrize("error,expected_attempts", [ (PermissionError(13, "access denied"), 8), (OSError(28, "disk full"), 1), ]) def test_atomic_write_failure_preserves_history_and_cleans_its_temporary_file( tmp_path: Path, monkeypatch, error, expected_attempts, ) -> None: path = tmp_path / "state.json" previous = '{"tasks": [{"id": "preserved-task"}]}' path.write_text(previous, encoding="utf-8") attempts = [] def reject_replace(source, destination): attempts.append(source) raise error monkeypatch.setattr(Path, "replace", reject_replace) with pytest.raises(type(error)): _atomic_write_json(path, {"tasks": []}) assert len(attempts) == expected_attempts assert path.read_text(encoding="utf-8") == previous assert list(tmp_path.glob(".state.json.*.tmp")) == [] def test_build_and_publish_survive_transient_state_and_manifest_locks( tmp_path: Path, monkeypatch, ) -> None: image = make_image(tmp_path) building = threading.Event() failures = {"state.json": 2, "latest.json": 2} original_replace = Path.replace def replace_with_locks(source, destination): name = Path(destination).name if building.is_set() and failures.get(name, 0): failures[name] -= 1 raise PermissionError(13, "temporary Windows file lock", str(destination)) return original_replace(source, destination) def build_runner(task_id, log, cancel): building.set() log("info", "IAR build: 0 errors, 0 warnings") return image service = FirmwareWorkbenchService( ROOT, artifact_root=tmp_path / "workbench", ota_root=tmp_path / "ota", build_runner=build_runner, ) monkeypatch.setattr(Path, "replace", replace_with_locks) try: task = wait_for_terminal(service, service.start_task("增加动态木马功能")["task"]["id"]) assert task["status"] == "success", task.get("failure_reason") assert [stage["status"] for stage in task["stages"]] == ["success"] * 8 assert failures == {"state.json": 0, "latest.json": 0} stored = json.loads((tmp_path / "workbench/state.json").read_text(encoding="utf-8")) assert stored["tasks"][0]["status"] == "success" latest = json.loads((tmp_path / "ota/latest.json").read_text(encoding="utf-8")) assert latest["release_id"] == task["release_id"] finally: service.close() def test_recovery_save_failure_does_not_reinitialize_existing_history(tmp_path: Path, monkeypatch): path = tmp_path / "workbench/state.json" path.parent.mkdir() original = {"tasks": [{"id": "fw-task-recovery", "status": "running", "stages": []}], "artifacts": [], "releases": []} path.write_text(json.dumps(original), encoding="utf-8") save = FirmwareWorkbenchService._save_locked attempts = [] def fail_first_save(service): attempts.append(True) if len(attempts) == 1: raise PermissionError(13, "state is locked") save(service) monkeypatch.setattr(FirmwareWorkbenchService, "_save_locked", fail_first_save) with pytest.raises(PermissionError): FirmwareWorkbenchService(ROOT, artifact_root=path.parent, ota_root=tmp_path / "ota") assert len(attempts) == 1 assert json.loads(path.read_text(encoding="utf-8")) == original def test_unreadable_state_is_not_overwritten_by_baseline_registration(tmp_path: Path, monkeypatch): path = tmp_path / "workbench/state.json" path.parent.mkdir() previous = '{"tasks": [{"id": "preserved-task"}], "artifacts": [], "releases": []}' path.write_text(previous, encoding="utf-8") read = Path.read_text def deny_state_read(source, *args, **kwargs): if source == path: raise PermissionError(13, "state read denied") return read(source, *args, **kwargs) monkeypatch.setattr(Path, "read_text", deny_state_read) with pytest.raises(PermissionError): FirmwareWorkbenchService(ROOT, artifact_root=path.parent, ota_root=tmp_path / "ota") assert path.read_bytes() == previous.encode("utf-8") @pytest.mark.skipif(os.name != "nt", reason="Requires a real Windows file-sharing lock") def test_atomic_write_recovers_from_a_real_windows_reader_lock(tmp_path: Path, monkeypatch): import ctypes from ctypes import wintypes kernel = ctypes.WinDLL("kernel32", use_last_error=True) kernel.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, wintypes.LPVOID, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE] kernel.CreateFileW.restype = wintypes.HANDLE kernel.CloseHandle.argtypes = [wintypes.HANDLE] kernel.CloseHandle.restype = wintypes.BOOL path = tmp_path / "state.json" path.write_text('{"before": true}', encoding="utf-8") # Permit read/write, but intentionally omit FILE_SHARE_DELETE to reproduce WinError 5/32. handle = kernel.CreateFileW(str(path), 0x80000000, 0x00000003, None, 3, 0x80, None) assert handle != ctypes.c_void_p(-1).value, ctypes.get_last_error() blocked = threading.Event() errors = [] observed_winerrors = [] replace = Path.replace def observe_replace(source, destination): try: return replace(source, destination) except PermissionError as exc: observed_winerrors.append(exc.winerror) blocked.set() raise def write(): try: _atomic_write_json(path, {"after": True}) except Exception as exc: errors.append(exc) monkeypatch.setattr(Path, "replace", observe_replace) worker = threading.Thread(target=write) worker.start() try: assert blocked.wait(timeout=2), "Windows did not reproduce the sharing conflict" assert json.loads(path.read_text(encoding="utf-8")) == {"before": True} finally: kernel.CloseHandle(handle) worker.join(timeout=4) assert not worker.is_alive() assert not errors assert observed_winerrors and set(observed_winerrors) <= {5, 32, 33} assert json.loads(path.read_text(encoding="utf-8")) == {"after": True}