Files
Deploymentcenter/tests/e2e/harness.py
T
Deploymentcenter BotandClaude Opus 5 1d5ac92051 feat(2.7.0): Roadmap im DC, Testinstanz mit Testtor, Supervisor und MCP
Sammelcommit fuer den ueber mehrere Sitzungen gewachsenen, bisher nicht
eingecheckten Stand. Getrennte Commits waeren ehrlicher gewesen, sind es
nachtraeglich aber nicht mehr: BugRepo, changelog.json und die Doku tragen
Aenderungen aus allen Straengen gleichzeitig, und keine Zwischenstufe waere
lauffaehig.

ROADMAP IM DEPLOYMENTCENTER (2.7.0)

Planungsstand, Reihenfolge und Zustaendigkeit lagen in handgepflegten
Markdown-Dateien neben einem Bugtracker, der genau diese drei Dinge bereits
fuehrt. Zwei Pfleger fuer dasselbe Feld - Drift war eine Frage der Zeit.

Ab jetzt: Zustand im DC, Begruendung in der Datei. Kein Feld hat zwei Pfleger.

  - Migration 016: epic, roadmap_rank, target_build, defer_trigger,
    doc_anchor, Status "deferred", Tabelle roadmap_epics.
    KEINE Roadmap-Tabelle - ein Eintrag ist ein bugtracker_item.
  - Zugehoerigkeit ist ein Feld: Ein Item steht auf der Roadmap, wenn es ein
    epic hat. Ein zweites Kennzeichen daneben koennte auseinanderlaufen.
  - Kennung ist die Item-ID. Handvergebene Kuerzel ("A12") erzeugen die
    schlimmste Fehlerart: Beide Seiten stimmig, Verschiedenes gemeint, weil
    die Kennung nach einer Loeschung neu vergeben wurde.
  - Zurueckstellen verlangt defer_trigger. Ohne benannten Anlass ist ein
    Vorhaben nicht zurueckgestellt, sondern vergessen. Die Regel sitzt im
    Repository, damit sie auf allen drei Schreibwegen gleich greift.
  - Modules/Bugtracker/Roadmap.php, /api/bugtracker/v1/roadmap.php,
    WebUI-Modul mit Filtern, cli/roadmap.php --write/--check,
    dc_roadmap und dc_roadmap_set im MCP-Adapter.
  - cli/roadmap.php --write ERZEUGT den Zustandsteil der Markdown-Datei;
    --check prueft nur die Prosa. Was erzeugt wird, kann nicht driften.
  - docs/ROADMAP_MIGRATION.md: Uebernahme fuer die anderen Projekte.

TESTINSTANZ UND TESTTOR

  - dc-dev.mhdf.de: zweite Installation, eigene DB, eigene Schluessel,
    Basic-Auth davor, Rocket.Chat aus.
  - 124 Testfaelle in tests/e2e gegen API, Rechte, Migrationen, Sperre.
  - scripts/deploy.py bringt vor jedem Upload erst den anstehenden Stand auf
    die Testinstanz und laesst dann den Testlauf laufen. Erst bei
    Rueckgabewert 0 geht etwas auf die Produktion (--ohne-test als Notausgang).
    Andersherum prueft der Lauf aelteren Code als den, der gleich hochgeht.
  - tests/ ist aus dem Produktiv-Deploy ausgeschlossen; testctl.php kann
    nicht versehentlich auf dc.mhdf.de landen.
  - Ein Lauf mit mehr uebersprungenen als geprueften Faellen endet mit
    Rueckgabewert 2 statt 0. Nichts ist kaputt, geprueft wurde es aber auch
    nicht - der Unterschied darf nicht am Rueckgabewert verschwinden.

SUPERVISOR, PROJEKTLANDSCHAFT, NACHPRUEFUNG (2.6.0/2.6.1)

  - Migration 013: projektuebergreifend melden (dev_agent, evidence,
    triage_external, affects).
  - Migration 014: supervisor_findings, Katalog, fuenf serverseitige
    Detektoren im Tick.
  - Migration 015: Nachpruefung als Arbeitsschlange statt Benachrichtigung.
  - DEV-Lizenzschluessel zur Selbstbedienung, dc-testkit fuer die Test-VM.
  - MCP-Adapter: 15 Werkzeuge, ohne Fremdabhaengigkeiten, mit Abgrenzung
    von Fremddaten gegen Prompt-Injektion.

BEHOBEN

  - observe und status im Supervisor legten den Zustand des Befundes als
    "status" in die Antwort. Http::ok() merged ['status' => 'success']
    darunter, also gewann der Befundzustand - eine angenommene Meldung kam
    als status="open" an. Heisst jetzt finding_status. War Item #59.

Migrationen 013-016 sind auf dem Produktivserver noch NICHT ausgefuehrt.
Ohne sie bricht das WebUI, weil die Roadmap-Ansicht bei jedem Seitenaufruf
gegen bugtracker_items.epic abfragt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 21:59:20 +02:00

603 lines
22 KiB
Python

#!/usr/bin/env python3
"""
Gemeinsamer Unterbau der Testlaeufe gegen die Testinstanz.
Bewusst ohne pytest und ohne requests: Der Testlauf soll auf jedem Rechner
und auf der Test-VM ohne vorherige Installation starten koennen. Sobald ein
Testwerkzeug erst eingerichtet werden muss, wird es im Zweifel nicht
ausgefuehrt - und eine Testsuite, die niemand startet, findet nichts.
Aufbau:
Client - HTTP-Aufrufe gegen die Instanz, mit Basic-Auth und Token
Ctx - was ein Testfall an den naechsten weitergibt (IDs, Tokens)
@test - Registrierung; die Reihenfolge der Registrierung ist die
Reihenfolge der Ausfuehrung
run - Ausfuehrung, Ausgabe, Bericht
"""
import base64
import json
import os
import socket
import ssl
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
PROJECT_DIR = Path(__file__).resolve().parents[2]
DEV_CONFIG = PROJECT_DIR / 'scripts' / 'deploy_dev_config.json'
# ---------------------------------------------------------------------------
# Adressfamilie
#
# Die Hoster-Adressen haben einen AAAA-Eintrag. Ist von einem Arbeitsplatz aus
# keine IPv6-Route vorhanden, laeuft jeder einzelne Aufruf zuerst 21 Sekunden
# in einen TCP-Timeout und faellt erst dann auf IPv4 zurueck - aus einem
# Testlauf von einer Minute werden 40. curl faellt sofort zurueck (Happy
# Eyeballs), Python nicht; deshalb steht das hier und nicht im Netzwerk.
#
# Einmal am Anfang geprueft, danach steht die Entscheidung fuer den ganzen
# Lauf. Sie betrifft nur, wie der Testlauf die Instanz erreicht - nicht, was
# er prueft.
# ---------------------------------------------------------------------------
_ECHTES_GETADDRINFO = socket.getaddrinfo
def erreichbar(host, port, familie, timeout=3.0):
try:
infos = _ECHTES_GETADDRINFO(host, port, familie, socket.SOCK_STREAM)
except socket.gaierror:
return False
for info in infos:
s = socket.socket(info[0], socket.SOCK_STREAM)
s.settimeout(timeout)
try:
s.connect(info[4])
return True
except OSError:
continue
finally:
s.close()
return False
def waehle_adressfamilie(host, port=443, erzwinge=None):
"""
Liefert eine kurze Beschreibung fuer die Ausgabe und schaltet notfalls
auf IPv4 um.
"""
if erzwinge == 'ipv4' or (erzwinge is None and not erreichbar(host, port, socket.AF_INET6)):
def nur_ipv4(*a, **kw):
args = list(a)
if len(args) >= 3:
args[2] = socket.AF_INET
else:
kw['family'] = socket.AF_INET
return _ECHTES_GETADDRINFO(*args, **kw)
socket.getaddrinfo = nur_ipv4
return 'IPv4 (IPv6 nicht erreichbar)' if erzwinge is None else 'IPv4 (erzwungen)'
return 'IPv6/IPv4'
# ---------------------------------------------------------------------------
# Ausgabe
# ---------------------------------------------------------------------------
class Farbe:
"""ANSI-Farben, aber nur wenn die Ausgabe wirklich ein Terminal ist."""
aktiv = sys.stdout.isatty() and os.name != 'nt' or os.environ.get('DC_FARBE') == '1'
@classmethod
def _f(cls, code, text):
return "\033[%sm%s\033[0m" % (code, text) if cls.aktiv else text
@classmethod
def gruen(cls, t):
return cls._f('32', t)
@classmethod
def rot(cls, t):
return cls._f('31', t)
@classmethod
def gelb(cls, t):
return cls._f('33', t)
@classmethod
def grau(cls, t):
return cls._f('90', t)
# ---------------------------------------------------------------------------
# Fehlerarten
#
# Der Unterschied zwischen "die Anwendung ist kaputt" und "dieser Testfall
# laesst sich hier nicht pruefen" muss im Bericht sichtbar bleiben. Sonst
# gewoehnt man sich an rote Zeilen, die keine sind.
# ---------------------------------------------------------------------------
class Fehlschlag(AssertionError):
"""Die Anwendung hat sich anders verhalten als erwartet."""
class Uebersprungen(Exception):
"""Der Testfall ist unter diesen Bedingungen nicht pruefbar."""
# ---------------------------------------------------------------------------
# HTTP
# ---------------------------------------------------------------------------
class Antwort:
def __init__(self, status, headers, body, ms, url, method):
self.status = status
self.headers = {k.lower(): v for k, v in (headers or {}).items()}
self.body = body
self.ms = ms
self.url = url
self.method = method
try:
self.json = json.loads(body) if body else None
except Exception:
self.json = None
@property
def ok(self):
return isinstance(self.json, dict) and self.json.get('status') == 'success'
@property
def fehlercode(self):
if isinstance(self.json, dict) and isinstance(self.json.get('error'), dict):
return self.json['error'].get('code')
return None
def feld(self, *pfad, default=None):
"""Wert aus verschachtelter Antwort holen: resp.feld('state', 'tables')."""
wert = self.json
for teil in pfad:
if isinstance(wert, dict) and teil in wert:
wert = wert[teil]
elif isinstance(wert, list) and isinstance(teil, int) and -len(wert) <= teil < len(wert):
wert = wert[teil]
else:
return default
return wert
def kurz(self, n=400):
text = self.body or ''
return text[:n] + (' ...' if len(text) > n else '')
def __repr__(self):
return "<%s %s -> %d>" % (self.method, self.url, self.status)
class Client:
"""
HTTP-Client fuer die Testinstanz.
Zur Aufteilung der beiden Berechtigungen: Die Basic-Auth der Testinstanz
geht in den Authorization-Header, das Token der Anwendung in
X-Agent-Token. Beide in denselben Header zu packen ginge nicht - und die
Anwendung akzeptiert X-Agent-Token ausdruecklich (Http::bearerToken).
Wer den Bearer-Weg pruefen will, ruft mit basic=False und bearer=True auf;
dann greift die Ausnahme der .htaccess fuer /api/.
"""
def __init__(self, base_url, basic_user=None, basic_pass=None, token=None, timeout=45):
self.base_url = base_url.rstrip('/')
self.basic_user = basic_user
self.basic_pass = basic_pass
self.token = token
self.timeout = timeout
self.aufrufe = 0
# Die Testinstanz laeuft hinter einem regulaeren Zertifikat; ein
# eigener Kontext nur, damit ein Hoster-Zwischenzertifikat den
# Testlauf nicht zum Erliegen bringt.
self.ssl_ctx = ssl.create_default_context()
def basic_header(self):
roh = ("%s:%s" % (self.basic_user, self.basic_pass)).encode('utf-8')
return 'Basic ' + base64.b64encode(roh).decode('ascii')
def request(self, method, pfad, body=None, query=None, token='standard',
bearer=False, basic=True, headers=None, roh_body=None):
url = self.base_url + pfad
if query:
url += ('&' if '?' in url else '?') + urllib.parse.urlencode(query)
kopf = {'Accept': 'application/json', 'User-Agent': 'dc-e2e/1.0'}
if basic and self.basic_user:
kopf['Authorization'] = self.basic_header()
# token='standard' -> das Token des Laufs; None -> keins; sonst der Wert
wert = self.token if token == 'standard' else token
if wert:
if bearer:
kopf['Authorization'] = 'Bearer ' + wert
else:
kopf['X-Agent-Token'] = wert
daten = None
if roh_body is not None:
daten = roh_body if isinstance(roh_body, bytes) else roh_body.encode('utf-8')
kopf.setdefault('Content-Type', 'application/json')
elif body is not None:
daten = json.dumps(body).encode('utf-8')
kopf['Content-Type'] = 'application/json'
kopf.update(headers or {})
req = urllib.request.Request(url, data=daten, headers=kopf, method=method)
start = time.time()
# Ein einziger Wiederholungsversuch, und nur wenn die Verbindung
# selbst gescheitert ist - nie bei einer HTTP-Antwort. Ein
# zurueckgesetztes TCP sagt nichts ueber die Anwendung aus; ein
# HTTP 500 dagegen schon, und das darf kein Wiederholungsversuch
# verschlucken.
for versuch in (1, 2):
try:
with urllib.request.urlopen(req, timeout=self.timeout, context=self.ssl_ctx) as r:
inhalt = r.read().decode('utf-8', 'replace')
antwort = Antwort(r.status, dict(r.headers), inhalt,
int((time.time() - start) * 1000), url, method)
break
except urllib.error.HTTPError as e:
inhalt = e.read().decode('utf-8', 'replace')
antwort = Antwort(e.code, dict(e.headers), inhalt,
int((time.time() - start) * 1000), url, method)
break
except urllib.error.URLError as e:
if versuch == 2:
raise Fehlschlag("%s %s nicht erreichbar: %s" % (method, url, e.reason))
time.sleep(0.5)
self.aufrufe += 1
return antwort
def get(self, pfad, **kw):
return self.request('GET', pfad, **kw)
def post(self, pfad, body=None, **kw):
return self.request('POST', pfad, body=body, **kw)
# ---------------------------------------------------------------------------
# Pruefungen
# ---------------------------------------------------------------------------
def erwarte(bedingung, meldung):
if not bedingung:
raise Fehlschlag(meldung)
def erwarte_status(resp, *codes):
if resp.status not in codes:
raise Fehlschlag("%s %s: HTTP %d erwartet %s | %s" % (
resp.method, resp.url, resp.status,
'/'.join(str(c) for c in codes), resp.kurz(300)))
return resp
def erwarte_erfolg(resp):
erwarte_status(resp, 200, 201)
if not resp.ok:
raise Fehlschlag("%s %s: Antwort ohne status=success | %s" % (
resp.method, resp.url, resp.kurz(300)))
return resp
def erwarte_fehler(resp, http=None, code=None):
if http is not None:
erwarte_status(resp, *(http if isinstance(http, (list, tuple)) else [http]))
erwarte(isinstance(resp.json, dict) and resp.json.get('status') == 'error',
"%s %s: Fehlerantwort erwartet, kam: %s" % (resp.method, resp.url, resp.kurz(300)))
if code is not None:
codes = code if isinstance(code, (list, tuple)) else [code]
erwarte(resp.fehlercode in codes,
"%s %s: error.code %s erwartet, war %r" % (
resp.method, resp.url, '/'.join(codes), resp.fehlercode))
return resp
def ueberspringen(grund):
raise Uebersprungen(grund)
# ---------------------------------------------------------------------------
# Registrierung
# ---------------------------------------------------------------------------
class Fall:
def __init__(self, gruppe, name, fn, doc):
self.gruppe = gruppe
self.name = name
self.fn = fn
self.doc = doc
FAELLE = []
def test(gruppe, name):
"""Registriert einen Testfall. Reihenfolge der Registrierung = Ablauf."""
def deco(fn):
FAELLE.append(Fall(gruppe, name, fn, (fn.__doc__ or '').strip()))
return fn
return deco
class Ctx:
"""
Der Zustand, den Testfaelle teilen.
Ein Bugtracker-Item muss angelegt sein, bevor es sich uebernehmen laesst;
ein Sub-Token muss existieren, bevor sich pruefen laesst, was es nicht
darf. Solche Ketten hier abzubilden ist ehrlicher, als jedem Testfall ein
eigenes Item unterzuschieben - geprueft wird schliesslich der Ablauf, den
ein Agent tatsaechlich nimmt.
"""
def __init__(self, cfg, client):
self.cfg = cfg
self.client = client
self.daten = {}
def __getitem__(self, k):
if k not in self.daten:
ueberspringen("Voraussetzung fehlt: %s (vorheriger Testfall nicht durchgelaufen)" % k)
return self.daten[k]
def __setitem__(self, k, v):
self.daten[k] = v
def get(self, k, default=None):
return self.daten.get(k, default)
def hat(self, k):
return k in self.daten
# -- Steuerung der Instanz ------------------------------------------
def testctl(self, action, **params):
body = dict(params)
body['action'] = action
return self.client.post('/testctl.php', body=body,
token=self.cfg['keys']['shared_key'])
# ---------------------------------------------------------------------------
# Konfiguration
# ---------------------------------------------------------------------------
def lade_bekannte_befunde():
"""
Testfaelle, die einen gemeldeten, noch offenen Befund beschreiben.
Siehe die Begruendung in bekannte_befunde.json selbst: Ein dauerhaft roter
Lauf ist genauso wertlos wie ein geloeschter Testfall - beim einen sieht
niemand mehr hin, beim anderen ist der Befund vergessen.
"""
p = Path(__file__).resolve().parent / 'bekannte_befunde.json'
if not p.exists():
return {}
with open(p, 'r', encoding='utf-8') as f:
roh = json.load(f)
return {k: v for k, v in roh.items() if not k.startswith('_')}
def lade_config(pfad=None):
p = Path(pfad) if pfad else DEV_CONFIG
if not p.exists():
print("Konfiguration fehlt: %s" % p)
print("Vorlage: scripts/deploy_dev_config.example.json")
sys.exit(2)
with open(p, 'r', encoding='utf-8') as f:
return json.load(f)
# ---------------------------------------------------------------------------
# Ausfuehrung
# ---------------------------------------------------------------------------
def run(cfg, gruppen=None, muster=None, reset=True, bericht_datei=None, stoppen=False,
adressfamilie=None):
host = urllib.parse.urlparse(cfg['base_url']).hostname
weg = waehle_adressfamilie(host, 443, adressfamilie)
client = Client(cfg['base_url'],
cfg['basic_auth']['user'], cfg['basic_auth']['pass'])
ctx = Ctx(cfg, client)
faelle = FAELLE
if gruppen:
faelle = [f for f in faelle if f.gruppe in gruppen]
if muster:
m = muster.lower()
faelle = [f for f in faelle if m in f.name.lower() or m in f.gruppe.lower()]
if not faelle:
print("Kein Testfall passt zur Auswahl.")
return 2
print("Testinstanz : %s (%s)" % (cfg['base_url'], weg))
print("Testfaelle : %d in %d Gruppe(n)" % (
len(faelle), len(set(f.gruppe for f in faelle))))
if reset:
print("")
print("Ausgangszustand herstellen ...")
r = ctx.testctl('reset', confirm=cfg['db']['name'],
admin_user=cfg['admin']['user'], admin_pass=cfg['admin']['pass'])
if not r.ok:
print(Farbe.rot(" Zuruecksetzen fehlgeschlagen: %s" % r.kurz(500)))
return 2
ctx['master_token'] = r.feld('master', 'raw_token')
ctx['admin'] = r.feld('admin')
client.token = ctx['master_token']
print(" %d Migration(en), %d Tabellen, Master-Token bereit." % (
len(r.feld('migration', 'applied', default=[])),
r.feld('state', 'tables', default=0)))
else:
if not cfg.get('master_token'):
print(Farbe.gelb(" Ohne --reset wird ein master_token in der Konfiguration erwartet."))
ctx['master_token'] = cfg.get('master_token')
client.token = ctx['master_token']
ergebnisse = []
gruppe_aktiv = None
bekannte = lade_bekannte_befunde()
start_gesamt = time.time()
for fall in faelle:
if fall.gruppe != gruppe_aktiv:
gruppe_aktiv = fall.gruppe
print("")
print(Farbe.grau("--- %s %s" % (gruppe_aktiv, '-' * max(0, 58 - len(gruppe_aktiv)))))
start = time.time()
zustand, meldung = 'ok', ''
try:
fall.fn(ctx)
except Uebersprungen as e:
zustand, meldung = 'skip', str(e)
except Fehlschlag as e:
zustand, meldung = 'fail', str(e)
except Exception as e: # unerwarteter Fehler im Testfall selbst
zustand = 'fail'
meldung = "%s: %s" % (type(e).__name__, e)
ms = int((time.time() - start) * 1000)
# Bekannter, gemeldeter Befund? Dann ist ein Fehlschlag erwartet und
# blockiert nichts - und ein unerwartetes Bestehen ist die Nachricht.
bekannt = bekannte.get("%s/%s" % (fall.gruppe, fall.name))
item = bekannt.get('item') if bekannt else None
if bekannt and zustand == 'fail':
zustand = 'bekannt'
elif bekannt and zustand == 'ok':
zustand = 'behoben'
ergebnisse.append({'gruppe': fall.gruppe, 'name': fall.name,
'zustand': zustand, 'ms': ms, 'meldung': meldung,
'zweck': fall.doc, 'item': item})
if zustand == 'ok':
print(" %s %-52s %s" % (Farbe.gruen('OK '), fall.name, Farbe.grau('%4d ms' % ms)))
elif zustand == 'bekannt':
print(" %s %-52s %s" % (Farbe.gelb('BEK '), fall.name,
Farbe.grau('bekannter Befund, Item #%s' % item)))
elif zustand == 'behoben':
print(" %s %-52s %s" % (Farbe.gruen('NEU!'), fall.name,
Farbe.gruen('besteht wieder - Item #%s schliessen '
'und Eintrag aus bekannte_befunde.json '
'entfernen' % item)))
elif zustand == 'skip':
print(" %s %-52s %s" % (Farbe.gelb('UEBR'), fall.name, Farbe.grau(meldung[:60])))
else:
print(" %s %-52s %s" % (Farbe.rot('FEHL'), fall.name, Farbe.grau('%4d ms' % ms)))
for zeile in meldung.splitlines():
print(" %s" % Farbe.rot(zeile))
if stoppen:
break
dauer = time.time() - start_gesamt
n_ok = sum(1 for e in ergebnisse if e['zustand'] == 'ok')
n_fail = sum(1 for e in ergebnisse if e['zustand'] == 'fail')
n_skip = sum(1 for e in ergebnisse if e['zustand'] == 'skip')
n_bekannt = sum(1 for e in ergebnisse if e['zustand'] == 'bekannt')
n_behoben = sum(1 for e in ergebnisse if e['zustand'] == 'behoben')
# Ein Lauf, in dem fast nichts lief, ist kein bestandener Lauf.
#
# Uebersprungene Faelle zaehlten bisher nirgends hinein. Damit endete
# `run.py --nur roadmap` mit "2 bestanden, 0 fehlgeschlagen, 5
# uebersprungen" und Rueckgabewert 0 - gruen, obwohl die Voraussetzung
# (ctx['freies_item'] aus der Gruppe bugtracker) vom Filter
# ausgeschlossen war und fuenf von sieben Faellen gar nicht liefen.
#
# Das Tor vor der Produktion war davon nie betroffen, dort laeuft alles.
# Betroffen war der Moment, in dem ein Mensch einem einzelnen Ergebnis
# glauben soll - und genau dort muss das Signal ehrlich sein.
#
# Kein Fehlschlag: Nichts ist kaputt. Aber auch kein Erfolg, denn geprueft
# wurde es nicht. Dafuer gibt es den dritten Rueckgabewert, den run.py
# ohnehin schon kennt: 2 = konnte nicht ordentlich laufen.
unbrauchbar = n_fail == 0 and n_skip > (n_ok + n_behoben + n_bekannt)
print("")
print("=" * 70)
zusammenfassung = "%d bestanden, %d fehlgeschlagen, %d uebersprungen" % (
n_ok + n_behoben, n_fail, n_skip)
if n_bekannt:
zusammenfassung += ", %d bekannte Befunde" % n_bekannt
zusammenfassung += " (%.1f s, %d Aufrufe)" % (dauer, client.aufrufe)
if n_fail:
print(Farbe.rot(zusammenfassung))
elif unbrauchbar:
print(Farbe.gelb(zusammenfassung))
else:
print(Farbe.gruen(zusammenfassung))
if unbrauchbar:
print("")
print(Farbe.gelb(
"Mehr uebersprungen als geprueft - dieser Lauf sagt nichts aus."))
print(Farbe.grau(
" Uebersprungen wird, wenn eine Voraussetzung aus einem frueheren"))
print(Farbe.grau(
" Testfall fehlt. Bei --gruppe/--nur ist der meist mit weggefiltert:"))
print(Farbe.grau(
" ctx['freies_item'] etwa entsteht in der Gruppe bugtracker."))
print(Farbe.grau(
" Abhilfe: die Gruppe mit aufnehmen, oder ohne Filter laufen lassen."))
if n_fail:
print("")
print("Fehlgeschlagen:")
for e in ergebnisse:
if e['zustand'] == 'fail':
print(" - %s / %s" % (e['gruppe'], e['name']))
if n_bekannt:
print("")
print("Bekannte Befunde (gemeldet, noch offen - blockieren nichts):")
for e in ergebnisse:
if e['zustand'] == 'bekannt':
print(" - Item #%s %s / %s" % (e['item'], e['gruppe'], e['name']))
if n_behoben:
print("")
print(Farbe.gruen("Wieder bestanden - Eintrag aus bekannte_befunde.json entfernen:"))
for e in ergebnisse:
if e['zustand'] == 'behoben':
print(" - Item #%s %s / %s" % (e['item'], e['gruppe'], e['name']))
if bericht_datei:
bericht = {
'instanz': cfg['base_url'],
'zeit_utc': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
'dauer_s': round(dauer, 2),
'bestanden': n_ok + n_behoben, 'fehlgeschlagen': n_fail,
'uebersprungen': n_skip, 'bekannte_befunde': n_bekannt,
'wieder_bestanden': n_behoben,
# Damit ein ausgewerteter Bericht dieselbe Auskunft gibt wie der
# Rueckgabewert - sonst laese ein Werkzeug "0 fehlgeschlagen" und
# hielte einen Lauf fuer bestanden, der nichts geprueft hat.
'aussagekraeftig': not unbrauchbar,
'faelle': ergebnisse,
}
Path(bericht_datei).write_text(
json.dumps(bericht, indent=2, ensure_ascii=False), encoding='utf-8')
print("")
print("Bericht: %s" % bericht_datei)
if n_fail:
return 1
return 2 if unbrauchbar else 0