from __future__ import annotations import os from pathlib import Path import subprocess import sys import pytest from app.ota.diagnostics import ( MAX_FAILURE_LOG_BYTES, DiagnosticLog, bounded_failure_log, clear_failure_log, failure_log_metadata, failure_log_path, persist_failure_log, read_failure_log, ) from app.ota.state import read_last_result from scripts import ota_worker SOURCE_ROOT = Path(__file__).resolve().parents[1] def test_failure_log_normalizes_invalid_utf8_and_truncates_head_and_tail(): body = b"HEADER\xff\n" + (b"middle\n" * 200_000) + b"LATEST FAILURE\n" rendered = bounded_failure_log(body) assert len(rendered) <= MAX_FAILURE_LOG_BYTES text = rendered.decode("utf-8") assert "HEADER�" in text assert "日志已截断" in text assert text.endswith("LATEST FAILURE\n") def test_failure_log_is_atomically_replaced_gated_by_result_and_cleared(tmp_path): data_root = tmp_path / "data" runtime_log = tmp_path / "ota-worker.log" runtime_log.write_bytes(b"first") target = persist_failure_log(data_root, runtime_log) failed = {"status": "failed"} assert target == failure_log_path(data_root) assert failure_log_metadata(data_root, failed) == (True, 5) assert read_failure_log(data_root, failed) == b"first" assert read_failure_log(data_root, {"status": "success"}) is None runtime_log.write_bytes(b"second failure") persist_failure_log(data_root, runtime_log) assert read_failure_log(data_root, failed) == b"second failure" assert list(target.parent.glob("*")) == [target] clear_failure_log(data_root) assert failure_log_metadata(data_root, failed) == (False, 0) def test_diagnostic_command_captures_success_failure_non_utf8_and_start_error(tmp_path): log = DiagnosticLog(tmp_path / "worker.log") log.reset() result = log.run([sys.executable, "-c", "print('pytest output')"], failure_label="success") assert result.returncode == 0 with pytest.raises(RuntimeError, match="7"): log.run( [sys.executable, "-c", "import os,sys; os.write(1, b'bad-\\xff'); sys.exit(7)"], failure_label="single test", ) with pytest.raises(RuntimeError, match="无法启动"): log.run([str(tmp_path / "missing-command")], failure_label="startup") text = log.path.read_text(encoding="utf-8") assert "pytest output" in text assert "bad-�" in text assert "exit_code=7" in text assert "command_start_failed=" in text secret_log = DiagnosticLog(tmp_path / "redacted.log") secret_log.reset() secret_log.run( [sys.executable, "-c", "print('ssid=private-network ip=192.0.2.10')"], log_output=False, ) redacted = secret_log.path.read_text(encoding="utf-8") assert "private-network" not in redacted assert "192.0.2.10" not in redacted assert "已按脱敏规则隐藏" in redacted def test_legacy_103_worker_environment_keeps_all_test_temporary_paths_in_transaction(tmp_path): transaction = tmp_path / "work.legacy103" environment = os.environ.copy() for name in ("MATRIX_TEST_ROOT", "TMPDIR", "TEMP", "TMP", "PYTEST_ADDOPTS"): environment.pop(name, None) environment.update( MATRIX_DATA_DIR=str(transaction / "test-data"), MATRIX_RUNTIME_DIR=str(transaction / "test-runtime"), MATRIX_SOURCE_ONLY_UPDATE_TESTS="1", MATRIX_LEGACY_ISOLATION_PROBE="1", PYTHONPATH=str(SOURCE_ROOT), ) result = subprocess.run( [ sys.executable, "-m", "pytest", "-p", "no:cacheprovider", "-q", "tests/test_ota_isolation_probe.py", "--tb=short", "--disable-warnings", ], cwd=SOURCE_ROOT, env=environment, capture_output=True, text=True, timeout=60, ) assert result.returncode == 0, result.stdout + result.stderr assert "1 passed" in result.stdout assert not (transaction.parent / ".pytest_cache").exists() strict_environment = dict(environment, MATRIX_STRICT_PYTEST_FAILURE_PROBE="1") strict = subprocess.run( [ sys.executable, "-m", "pytest", "-p", "no:cacheprovider", "-q", "tests/test_ota_isolation_probe.py", "--tb=short", "--disable-warnings", ], cwd=SOURCE_ROOT, env=strict_environment, capture_output=True, text=True, timeout=60, ) assert strict.returncode != 0 assert "strict legacy-worker pytest probe executed" in strict.stdout def test_development_source_contains_no_diagnostic_pytest_bypass(): conftest = (SOURCE_ROOT / "tests" / "conftest.py").read_text(encoding="utf-8") retired_marker = "OTA_" + "DIAGNOSTIC_" + "BOOTSTRAP" retired_symbol = "diagnostic_" + "bootstrap" pytest_early_exit = "pytest" + ".exit" retired_override = "MATRIX_FORCE_" + "LEGACY_ISOLATION_PROBE" assert not (SOURCE_ROOT / retired_marker).exists() assert retired_symbol not in conftest assert pytest_early_exit not in conftest assert retired_override not in conftest def test_worker_and_manual_entry_keep_strict_pytest_and_transaction_paths(): worker = (SOURCE_ROOT / "scripts" / "ota_worker.py").read_text(encoding="utf-8") manual = (SOURCE_ROOT / "scripts" / "update_walnutpi.sh").read_text(encoding="utf-8") for option in ("no:cacheprovider", "--basetemp", "--tb=short", "--disable-warnings"): assert option in worker assert option in manual for variable in ("MATRIX_TEST_ROOT", "TMPDIR", "MATRIX_SOURCE_ONLY_UPDATE_TESTS"): assert variable in worker assert variable in manual assert "check=False" not in worker.split('failure_label="新版本 pytest"')[0].rsplit("self.run", 1)[1] def test_pre_switch_failure_preserves_installed_state_and_records_rollback_problem(tmp_path, monkeypatch): target = tmp_path / "opt" / "matrix-screen-controller" releases = tmp_path / "opt" / "releases" data_root = tmp_path / "var" / "data" runtime_root = tmp_path / "run" work_root = tmp_path / "opt" / "ota" unit = tmp_path / "etc" / "matrix-screen-controller.service" package = tmp_path / "uploaded.ota" for directory in (target, releases, data_root, runtime_root, work_root, unit.parent): directory.mkdir(parents=True, exist_ok=True) (target / "old-program").write_text("unchanged", encoding="utf-8") (data_root / "user-data").write_text("unchanged", encoding="utf-8") unit.write_text("old unit", encoding="utf-8") package.write_bytes(b"candidate") monkeypatch.setattr(ota_worker, "TARGET", target) monkeypatch.setattr(ota_worker, "RELEASES", releases) monkeypatch.setattr(ota_worker, "DATA_ROOT", data_root) monkeypatch.setattr(ota_worker, "RUNTIME_ROOT", runtime_root) monkeypatch.setattr(ota_worker, "OTA_WORK_ROOT", work_root) monkeypatch.setattr(ota_worker, "UNIT_PATH", unit) monkeypatch.setattr(ota_worker, "REQUEST_PATH", runtime_root / "ota-request.json") request = { "job_id": "rollback-test", "current_version": "1.0.4", "target_version": "1.0.5", "package_path": str(package), "status_path": str(runtime_root / "ota-status.json"), "request_path": str(runtime_root / "ota-request.json"), } transaction = ota_worker.Transaction(request) transaction.work_root.mkdir(parents=True) transaction.release.mkdir(parents=True) transaction.data_candidate.mkdir(parents=True) monkeypatch.setattr( transaction, "stop_visual", lambda: (_ for _ in ()).throw(RuntimeError("simulated rollback fault")), ) transaction.rollback(RuntimeError("pytest failed")) assert (target / "old-program").read_text(encoding="utf-8") == "unchanged" assert (data_root / "user-data").read_text(encoding="utf-8") == "unchanged" assert unit.read_text(encoding="utf-8") == "old unit" assert not transaction.release.exists() assert not transaction.data_candidate.exists() assert not transaction.work_root.exists() result = read_last_result(data_root) assert result is not None and result["status"] == "failed" saved = failure_log_path(data_root).read_text(encoding="utf-8") assert "pytest failed" in saved assert "simulated rollback fault" in saved assert "final_recovery_state=" in saved