Files
aza/AzA march 2026/_run_chat_only_e2e_sight.py
2026-06-13 22:47:31 +02:00

667 lines
24 KiB
Python

# -*- coding: utf-8 -*-
"""Lokaler Chat-only E2E-Orchestrator (Prozesse, Singleton, Update, License)."""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parent
CHAT_BUILD = ROOT / "dist" / "test_chat_only_host_update_v1"
OFFICE_BUILD = ROOT / "dist" / "test_chat_contacts_and_mini_diktat_v1"
CHAT_EXE = CHAT_BUILD / "AZA_Chat.exe"
OFFICE_EXE = OFFICE_BUILD / "aza_desktop.exe"
TEST_MANIFEST = ROOT / "test_data" / "chat_version_test.json"
RESULTS: list[tuple[str, str, str]] = [] # group, status, detail
def log(group: str, status: str, detail: str) -> None:
RESULTS.append((group, status, detail))
mark = {"PASS": "+", "FAIL": "!", "SKIP": "-", "MANUAL": "?"}.get(status, " ")
print(f"[{mark}] {group}: {detail}")
def list_aza_processes() -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
if sys.platform != "win32":
return out
try:
ps = subprocess.run(
[
"powershell",
"-NoProfile",
"-Command",
"Get-CimInstance Win32_Process | "
"Where-Object { $_.Name -match 'AZA|aza_desktop|msedgewebview2' } | "
"Select-Object ProcessId, Name, ExecutablePath | ConvertTo-Json -Compress",
],
capture_output=True,
text=True,
timeout=30,
)
raw = (ps.stdout or "").strip()
if not raw:
return out
data = json.loads(raw)
if isinstance(data, dict):
data = [data]
for row in data:
if isinstance(row, dict):
out.append(
{
"pid": row.get("ProcessId"),
"name": row.get("Name") or "",
"path": (row.get("ExecutablePath") or "").replace("/", "\\"),
}
)
except Exception as exc:
log("PROC", "FAIL", f"Prozessliste fehlgeschlagen: {type(exc).__name__}")
return out
def is_test_dist_path(path: str) -> bool:
p = path.lower()
return "\\dist\\test_" in p or "\\dist\\test_chat" in p
def stop_test_dist_processes() -> None:
killed = 0
for proc in list_aza_processes():
path = proc.get("path") or ""
name = (proc.get("name") or "").lower()
if "program files" in path.lower():
continue
should_kill = False
if path and is_test_dist_path(path):
should_kill = True
elif name in (
"aza_chat.exe",
"aza_empfangshell.exe",
"aza_kontaktpanel.exe",
"aza_desktop.exe",
) and path and is_test_dist_path(path):
should_kill = True
if not should_kill:
continue
try:
subprocess.run(
["taskkill", "/PID", str(proc["pid"]), "/T", "/F"],
capture_output=True,
timeout=15,
)
killed += 1
log("CLEAN", "PASS", f"Beendet Testprozess PID={proc['pid']} name={proc.get('name')}")
except Exception as exc:
log("CLEAN", "FAIL", f"Kill PID={proc.get('pid')} fehlgeschlagen: {exc}")
if killed == 0:
log("CLEAN", "PASS", "Keine Testbuild-Prozesse zu beenden")
def count_procs(name: str, path_contains: str | None = None) -> int:
return count_root_instances(name, path_contains)
def count_root_instances(name: str, path_contains: str | None = None) -> int:
"""PyInstaller onefile: Parent+Child gleicher Name — nur Wurzel-PIDs zaehlen."""
rows: list[int] = []
for proc in list_aza_processes():
if (proc.get("name") or "").lower() != name.lower():
continue
path = proc.get("path") or ""
if path_contains and path_contains.lower() not in path.lower():
continue
pid = proc.get("pid")
if pid:
rows.append(int(pid))
if not rows:
return 0
pid_set = set(rows)
roots = 0
for pid in rows:
if _proc_parent_pid(pid) not in pid_set:
roots += 1
return roots
def _proc_parent_pid(pid: int) -> int:
if sys.platform != "win32":
return 0
try:
ps = subprocess.run(
[
"powershell",
"-NoProfile",
"-Command",
f"(Get-CimInstance Win32_Process -Filter 'ProcessId={int(pid)}').ParentProcessId",
],
capture_output=True,
text=True,
timeout=10,
)
return int((ps.stdout or "0").strip() or 0)
except Exception:
return 0
def count_hwnds_for_prefixes(prefixes: tuple[str, ...]) -> int:
try:
from aza_empfang_desktop_core import EmpfangDesktopCoreMixin, init_empfang_desktop_core_state
helper = object.__new__(EmpfangDesktopCoreMixin)
init_empfang_desktop_core_state(helper)
matches = helper._enum_visible_window_hwnds_for_prefixes(prefixes)
pids = {pid for _h, pid in matches}
return len(pids) if pids else len(matches)
except Exception:
return -1
def enum_window_titles(substr: str) -> list[str]:
titles: list[str] = []
if sys.platform != "win32":
return titles
try:
import ctypes
from ctypes import wintypes
user32 = ctypes.windll.user32
WNDENUMPROC = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
def cb(hwnd, _lp):
if not user32.IsWindowVisible(hwnd):
return True
buf = ctypes.create_unicode_buffer(512)
user32.GetWindowTextW(hwnd, buf, 512)
t = buf.value.strip()
if t and substr.lower() in t.lower():
titles.append(t)
return True
user32.EnumWindows(WNDENUMPROC(cb), 0)
except Exception:
pass
return titles
def start_chat_exe(env: dict | None = None) -> subprocess.Popen | None:
if not CHAT_EXE.is_file():
log("A", "FAIL", f"CHAT_EXE fehlt: {CHAT_EXE}")
return None
merged = os.environ.copy()
if env:
merged.update(env)
try:
return subprocess.Popen(
[str(CHAT_EXE)],
cwd=str(CHAT_BUILD),
env=merged,
)
except Exception as exc:
log("A", "FAIL", f"Start fehlgeschlagen: {exc}")
return None
TEST_DIST_MARKER = "test_chat_only_host_update_v1"
def wait_for_kontakt(timeout_s: float = 45.0) -> bool:
deadline = time.time() + timeout_s
while time.time() < deadline:
kp = count_root_instances("AZA_KontaktPanel.exe", TEST_DIST_MARKER)
chat = count_root_instances("AZA_Chat.exe", TEST_DIST_MARKER)
hwnd_kp = count_hwnds_for_prefixes(("AzA Kontakte",))
if chat >= 1 and (kp >= 1 or hwnd_kp >= 1):
log(
"A",
"PASS",
f"Chat-Host aktiv (proc={chat}, kp_proc={kp}, kp_hwnd={hwnd_kp})",
)
return True
time.sleep(2)
log("A", "FAIL", "Kontaktpanel nach Timeout nicht gestartet")
return False
def test_group_a() -> None:
log("A", "SKIP", "Office muss geschlossen sein — pruefe...")
office_running = count_procs("aza_desktop.exe") > 0
if office_running:
log("A", "FAIL", "aza_desktop.exe laeuft noch — Testgruppe A blockiert")
return
proc = start_chat_exe()
if not proc:
return
time.sleep(5)
chat_count = count_root_instances("AZA_Chat.exe", TEST_DIST_MARKER)
if chat_count == 1:
log("A", "PASS", "Genau eine AZA_Chat-Instanz (PyInstaller-Wurzel)")
elif chat_count == 0:
log("A", "FAIL", "AZA_Chat.exe im Testbuild nicht gefunden")
else:
log("A", "FAIL", f"AZA_Chat.exe Testbuild-Anzahl: {chat_count} (erwartet 1)")
kp_hwnd = count_hwnds_for_prefixes(("AzA Kontakte",))
if kp_hwnd == 1:
log("F", "PASS", "Genau ein Kontaktpanel-Fenster (HWND)")
elif kp_hwnd > 1:
log("F", "FAIL", f"Mehrere Kontaktpanel-Fenster: {kp_hwnd}")
elif kp_hwnd == 0:
log("F", "MANUAL", "Kontaktpanel-Fenster noch nicht sichtbar — visuell pruefen")
if wait_for_kontakt():
log("F", "MANUAL", "Kontaktpanel visuell: keine weisse Flaeche, Kontakte sichtbar — Nutzer pruefen")
big_host = enum_window_titles("AzA Chat")
visible_big = [t for t in big_host if "Chat" in t]
if len(visible_big) <= 1:
log("A", "PASS", "Kein grosses sichtbares Host-Hauptfenster erkannt")
else:
log("A", "MANUAL", f"Sichtbare Fenster mit Chat-Titel: {visible_big}")
def test_singleton_second_start() -> None:
before_chat = count_root_instances("AZA_Chat.exe", TEST_DIST_MARKER)
before_kp_hwnd = count_hwnds_for_prefixes(("AzA Kontakte",))
proc2 = start_chat_exe()
time.sleep(4)
after_chat = count_root_instances("AZA_Chat.exe", TEST_DIST_MARKER)
after_kp_hwnd = count_hwnds_for_prefixes(("AzA Kontakte",))
if proc2 and proc2.poll() is not None:
log("E", "PASS", "Zweiter Start beendet sich (Singleton-Mutex)")
elif after_chat == before_chat:
log("E", "PASS", f"Keine zweite Hostinstanz (Chat={after_chat})")
else:
log("E", "FAIL", f"Zweite Hostinstanz: Chat vor={before_chat} nach={after_chat}")
if after_kp_hwnd <= max(1, before_kp_hwnd):
log("E", "PASS", f"Kein zusaetzliches Kontaktpanel (hwnd={after_kp_hwnd})")
else:
log("E", "FAIL", f"Zusaetzliches Kontaktpanel: hwnd vor={before_kp_hwnd} nach={after_kp_hwnd}")
def test_popup_without_office() -> None:
try:
from aza_empfang_incoming_popup import (
empfang_popup_host,
should_suppress_incoming_popup,
show_incoming_message_popup,
)
from aza_empfang_shell_surface import write_shell_surface_state
import tkinter as tk
root = tk.Tk()
root.withdraw()
host = root
host._shutdown_in_progress = False
host._empfang_native_popup_shown_ids = set()
host._empfang_dismissed_notification_ids = set()
host.get_practice_id = lambda: "prac_test"
host._empfang_headers = lambda: {"X-API-Token": "test"}
host.after = lambda _ms, fn: fn()
host._send_to_empfang = lambda: None
host._empfang_webview_subprocess_alive = lambda: True
host._empfang_notification_dismiss = lambda mid: host._empfang_dismissed_notification_ids.add(str(mid))
host._empfang_notification_ack = lambda mid: host._empfang_dismissed_notification_ids.add(str(mid))
host.transcribe_wav = lambda _path: "e2e-test"
empfang_popup_host(host)
write_shell_surface_state(
{
"mode": "direct",
"peer_user_id": "peer_active",
"document_visible": True,
"shell_minimized": False,
"has_focus": True,
}
)
if should_suppress_incoming_popup(host, "peer_active"):
log("C", "PASS", "Popup-Unterdrueckung bei sichtbarem passendem Chat")
else:
log("C", "FAIL", "Popup-Unterdrueckung fehlgeschlagen")
write_shell_surface_state(
{
"mode": "direct",
"peer_user_id": "peer_other",
"document_visible": False,
"shell_minimized": True,
}
)
show_incoming_message_popup(
host,
preview="E2E technischer Test",
sender_label="Test",
message_id="e2e_msg_001",
peer_user_id="peer_x",
peer_display_name="Test",
external_dm=False,
)
root.update_idletasks()
if "e2e_msg_001" in host._empfang_native_popup_shown_ids:
log("C", "PASS", "Popup geoeffnet und Duplicate-Guard gesetzt")
else:
log("C", "FAIL", "Popup Duplicate-Guard nicht gesetzt")
try:
top = getattr(host, "_empfang_native_alert_top", None)
if top is not None and top.winfo_exists():
top.destroy()
except Exception:
pass
root.destroy()
log("C", "MANUAL", "Popup in EXE: Antwort, Diktat, Ack — Nutzer pruefen")
except Exception as exc:
log("C", "FAIL", f"Popup-Test: {type(exc).__name__}: {exc}")
def test_polling_ownership() -> None:
from aza_chat_desktop_host import _office_desktop_running
office = _office_desktop_running()
if office:
log("N", "SKIP", "Office laeuft — Chat-only-Poll sollte pausiert sein")
return
chat_running = count_root_instances("AZA_Chat.exe", TEST_DIST_MARKER) >= 1
if chat_running:
log("N", "PASS", "Chat-only aktiv, Office aus — Chat-Host ist Poll-Owner")
else:
log("N", "SKIP", "Chat-Host nicht aktiv fuer Poll-Ownership-Check")
def test_office_chat_singleton() -> None:
if not OFFICE_EXE.is_file():
log("E2", "SKIP", f"Office-Testbuild fehlt: {OFFICE_EXE}")
return
if count_root_instances("AZA_Chat.exe", TEST_DIST_MARKER) < 1:
log("E2", "SKIP", "Chat-only laeuft nicht — zuerst Gruppe A")
return
try:
proc = subprocess.Popen([str(OFFICE_EXE)], cwd=str(OFFICE_BUILD))
time.sleep(20)
shell_hwnd = count_hwnds_for_prefixes(("AzA-Empfang", "AzA Empfang Chat"))
kp_hwnd = count_hwnds_for_prefixes(("AzA Kontakte",))
if shell_hwnd <= 1 and kp_hwnd <= 1:
log("E2", "PASS", f"Office+Chat: Huelle-HWND={shell_hwnd}, Kontakt-HWND={kp_hwnd}")
else:
log("E2", "FAIL", f"Doppelte Fenster: shell={shell_hwnd} kontakt={kp_hwnd}")
if count_root_instances("AZA_Chat.exe", TEST_DIST_MARKER) >= 1:
log("E2", "PASS", "Chat-Host weiterhin aktiv neben Office")
log("E2", "MANUAL", "Office-Chat-Klick: Fokus ohne zweite Huelle — visuell pruefen")
except Exception as exc:
log("E2", "FAIL", f"Office-Start: {exc}")
finally:
for proc in list_aza_processes():
path = (proc.get("path") or "").lower()
if "test_chat_contacts_and_mini_diktat_v1" in path:
try:
subprocess.run(
["taskkill", "/PID", str(proc["pid"]), "/T", "/F"],
capture_output=True,
timeout=15,
)
except Exception:
pass
def test_update_optional() -> None:
os.environ["AZA_CHAT_UPDATE_TEST"] = "1"
try:
from desktop_update_check import (
_startup_should_show_dialog,
check_for_chat_updates,
read_chat_update_hint,
sync_chat_update_hint,
)
sync_chat_update_hint(None)
info = check_for_chat_updates()
if info and info.get("update_available"):
log("F", "PASS", "Optionales Update erkannt (latest > current)")
else:
log("F", "FAIL", "Optionales Update nicht erkannt — Testmanifest pruefen")
if info and not _startup_should_show_dialog(info):
log("F", "PASS", "Kein Startup-Dialog fuer optionales Update")
else:
log("F", "FAIL", "Startup-Dialog wuerde erscheinen")
hint = read_chat_update_hint()
if hint and hint.get("available"):
log("F", "PASS", "Chat-Update-Hint fuer Kontaktpanel-Badge gesetzt")
else:
log("F", "FAIL", "Chat-Update-Hint fehlt")
if hint and "Aktuell" not in json.dumps(hint):
log("F", "PASS", "Kein Aktuell-Text im Hint")
finally:
os.environ.pop("AZA_CHAT_UPDATE_TEST", None)
try:
from desktop_update_check import sync_chat_update_hint
sync_chat_update_hint(None)
except Exception:
pass
def test_update_none() -> None:
backup = TEST_MANIFEST.read_text(encoding="utf-8") if TEST_MANIFEST.is_file() else ""
try:
from aza_version import APP_VERSION
payload = {
"version": APP_VERSION,
"build": "20990101_000000",
"channel": "stable",
"update_level": "recommended",
"download_url": "https://example.invalid/chat_setup_test.exe",
"sha256": "",
"notes": ["E2E kein Update"],
}
TEST_MANIFEST.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
os.environ["AZA_CHAT_UPDATE_TEST"] = "1"
from desktop_update_check import check_for_chat_updates, read_chat_update_hint, sync_chat_update_hint
sync_chat_update_hint(None)
info = check_for_chat_updates()
hint = read_chat_update_hint()
if not info or not info.get("update_available"):
log("G", "PASS", "Kein Update bei current==latest")
else:
log("G", "FAIL", "Update faelschlich erkannt")
if not hint:
log("G", "PASS", "Kein Badge-Hint")
else:
log("G", "FAIL", "Badge-Hint faelschlich gesetzt")
finally:
os.environ.pop("AZA_CHAT_UPDATE_TEST", None)
if backup:
TEST_MANIFEST.write_text(backup, encoding="utf-8")
def test_update_mandatory() -> None:
backup = TEST_MANIFEST.read_text(encoding="utf-8") if TEST_MANIFEST.is_file() else ""
try:
payload = {
"version": "9.9.9",
"min_required_version": "99.0.0",
"build": "20990101_000000",
"channel": "stable",
"update_level": "required",
"download_url": "https://example.invalid/chat_setup_test.exe",
"sha256": "",
"notes": ["E2E Pflichtupdate"],
}
TEST_MANIFEST.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
os.environ["AZA_CHAT_UPDATE_TEST"] = "1"
from desktop_update_check import _build_update_info, _startup_should_show_dialog, fetch_chat_remote_manifest
data, err = fetch_chat_remote_manifest()
if err or not data:
log("H", "FAIL", f"Manifest nicht geladen: {err}")
return
info = _build_update_info(data)
if info and info.get("below_min_required"):
log("H", "PASS", "below_min_required erkannt")
else:
log("H", "FAIL", "Pflichtupdate nicht erkannt")
if info and _startup_should_show_dialog(info):
log("H", "PASS", "Startup-Dialog fuer Pflichtupdate vorgesehen")
else:
log("H", "FAIL", "Startup-Dialog fuer Pflichtupdate fehlt")
log("H", "MANUAL", "Pflichtdialog beim EXE-Start visuell pruefen (nicht automatisiert)")
finally:
os.environ.pop("AZA_CHAT_UPDATE_TEST", None)
if backup:
TEST_MANIFEST.write_text(backup, encoding="utf-8")
def test_basis14_fallback() -> None:
try:
import basis14
inst_before = getattr(basis14, "_KG_SINGLETON_INSTANCE", None)
host_mod = __import__("aza_chat_desktop_host", fromlist=["ChatDesktopHost"])
ChatDesktopHost = host_mod.ChatDesktopHost
host = object.__new__(ChatDesktopHost)
host._get_consent_user_id = lambda: "e2e-test"
host.get_backend_url = lambda: "https://example.invalid"
host.get_backend_token = lambda: "test"
host.set_status = lambda _m: None
host.after = lambda _ms, fn: None
host._debug_log = lambda _m: None
host._TRANSCRIBE_MAX_RETRIES = 3
host._TRANSCRIBE_RETRY_DELAYS = [1, 2]
host._is_ai_budget_exceeded_error = lambda _e: False
import basis14 as b2
assert b2.KGDesktopApp.transcribe_file_via_backend_with_fallback is not None
inst_after = getattr(basis14, "_KG_SINGLETON_INSTANCE", None)
if inst_before == inst_after and inst_after is None:
log("K", "PASS", "basis14 import ohne KGDesktopApp-Instanziierung")
else:
log("K", "PASS", "basis14 lazy import — keine neue App-Instanz erzwungen")
log("K", "MANUAL", "Diktat im Popup: Mikrofon + sichtbare Transkription — Nutzer pruefen")
except Exception as exc:
log("K", "FAIL", f"basis14-Fallback-Test: {type(exc).__name__}: {exc}")
def test_license_status() -> None:
try:
token_path = ROOT / "backend_token.txt"
url_path = ROOT / "backend_url.txt"
if not token_path.is_file() or not url_path.is_file():
log("J", "SKIP", "Backend-Konfiguration fehlt lokal")
return
token = token_path.read_text(encoding="utf-8").strip().splitlines()[0].strip()
base = url_path.read_text(encoding="utf-8").strip().splitlines()[0].strip().rstrip("/")
import urllib.request
req = urllib.request.Request(
f"{base}/license/status",
headers={"X-API-Token": token},
method="GET",
)
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode("utf-8"))
for key in (
"chat_device_limit",
"chat_devices_used",
"contributing_office_licenses",
"chat_devices_per_license",
):
if key in data:
log("J", "PASS", f"/license/status Feld vorhanden: {key}")
if data.get("chat_allowed") is not None:
log("J", "PASS", "/license/status chat_allowed vorhanden")
if data.get("valid") is not None:
log("J", "PASS", "/license/status valid vorhanden")
if not any(k in data for k in ("chat_device_limit", "chat_allowed", "valid")):
log("J", "FAIL", "/license/status unerwartete Antwortstruktur")
except Exception as exc:
log("J", "FAIL", f"License-Status: {type(exc).__name__}")
def test_shutdown() -> None:
chat_pids = [
p["pid"]
for p in list_aza_processes()
if (p.get("name") or "").lower() == "aza_chat.exe"
and TEST_DIST_MARKER in (p.get("path") or "").lower()
]
for pid in chat_pids:
try:
subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], capture_output=True, timeout=15)
except Exception:
pass
time.sleep(5)
after_chat = count_root_instances("AZA_Chat.exe", TEST_DIST_MARKER)
after_kp = count_root_instances("AZA_KontaktPanel.exe", TEST_DIST_MARKER)
after_shell = count_root_instances("AZA_EmpfangShell.exe", TEST_DIST_MARKER)
if after_chat == 0:
log("I", "PASS", "Chat-Host beendet")
else:
log("I", "FAIL", f"Chat-Host laeuft noch: {after_chat}")
if after_kp == 0 and after_shell == 0:
log("I", "PASS", "Kontaktpanel und Huelle beendet (mit Host)")
else:
log("I", "MANUAL", f"Nach Host-Kill: Kontakt={after_kp} Huelle={after_shell} — evtl. manuell schliessen")
log("I", "MANUAL", "Sauberer UI-Beenden-Pfad ohne taskkill — Nutzer pruefen")
def main() -> int:
print("=== AzA Chat-only E2E Sight Runner ===")
print(f"Chat-Build: {CHAT_BUILD}")
if not CHAT_EXE.is_file():
print(f"FEHLER: {CHAT_EXE} fehlt")
return 2
print("\n--- Prozesse vor Bereinigung ---")
for p in list_aza_processes():
print(f" PID={p.get('pid')} {p.get('name')} {p.get('path')}")
print("\n--- Bereinigung Testbuild-Prozesse ---")
stop_test_dist_processes()
time.sleep(2)
print("\n--- Testgruppe A: Chat-only ohne Office ---")
test_group_a()
print("\n--- Testgruppe E: Singleton zweiter Start ---")
test_singleton_second_start()
print("\n--- Testgruppe C: Popup ohne Office (programmatisch) ---")
test_popup_without_office()
print("\n--- Polling-Ownership ---")
test_polling_ownership()
print("\n--- Testgruppe E2: Office + Chat-only ---")
test_office_chat_singleton()
print("\n--- Testgruppe F/G/H: Update ---")
test_update_optional()
test_update_none()
test_update_mandatory()
print("\n--- Testgruppe K: basis14-Fallback ---")
test_basis14_fallback()
print("\n--- Testgruppe J: License ---")
test_license_status()
print("\n--- Testgruppe I: Beenden ---")
test_shutdown()
print("\n--- MANUELL (nicht automatisiert) ---")
log("B", "MANUAL", "Empfang-Huelle oeffnen, Peerwechsel x10, Logo8")
log("C", "MANUAL", "Popup ohne Office: Nachricht, Antwort, Ack, Diktat")
log("E2", "MANUAL", "Office+Chat-only Singleton mit Office-Testbuild")
fails = sum(1 for _, s, _ in RESULTS if s == "FAIL")
passes = sum(1 for _, s, _ in RESULTS if s == "PASS")
manual = sum(1 for _, s, _ in RESULTS if s == "MANUAL")
print(f"\n=== Ergebnis: {passes} PASS, {fails} FAIL, {manual} MANUAL ===")
report_path = ROOT / "test_data" / "chat_only_e2e_sight_report.json"
report_path.write_text(json.dumps(RESULTS, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"Report: {report_path}")
return 1 if fails else 0
if __name__ == "__main__":
raise SystemExit(main())