# -*- 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())