scopedvr-map-editor/tests/test_updater.gd
slyphax 73847ed1ce Map-Editor Selbst-Update gehärtet (t_1457e935): KEIN Credential-Dialog mehr — git-Läufe nie interaktiv — Root-Cause: der systemd-User-Manager exportiert SSH_ASKPASS (ksshaskpass); ein nackter git-Spawn im Editor hat damit mitten in der Update-Prüfung das KDE-Login-Fenster geöffnet, und der Credential-Store (~/.git-credentials) war leer (0 Bytes). Fix (Belt AND Braces, EditorUpdater.spawn_argv): JEDE git-Ausführung läuft über /usr/bin/env mit GIT_TERMINAL_PROMPT=0 + GIT_ASKPASS=/bin/true + HOME aus der eigenen Umgebung (kein Hardcode), dazu -c credential.helper= (Helper-Reset, sonst öffnet z. B. der Windows-Credential-Manager sein GUI) + -c credential.helper=store + core.askPass=/bin/true (Askpass-Fallback tot, auch ohne env-Wrapper). Askpass-Präzedenz am PC verifiziert: core.askPass/GIT_ASKPASS schlagen geerbtes SSH_ASKPASS — Fake-Askpass startet nicht, sauberes Authentication failed Exit 128 statt GUI. Neuer Status auth_failed mit klarem UI-Text („Updates nicht prüfbar — Zugangsdaten fehlen (kein Login-Dialog geöffnet)“), Auth-Erkennung pure (looks_like_auth_error: could-not-read-Username / Authentication failed / terminal prompts disabled / Unauthorized — Connection refused ist KEIN Auth-Fehler); pull-Fehler bekommen denselben Zusatz-Hinweis. Credential-Store am PC befüllt (Forgejo-Token für 127.0.0.1:13020, chmod 600) — ls-remote/pull/ls-remote-push laufen stimmlos. Tests: test_updater +10 Checks (auth-Erkennung 4, spawn_argv-Zusammensetzung 6) → 47/47 ALL OK; komplette Suite 12/13 grün (test_testrun: 1/14 FAILED — PRE-EXISTING, per git-stash-Verfahren verifiziert, Ursache unabhängig von diesem Diff, Follow-up-Karte folgt). docs/tasten.md: Abschnitt Updates um „Zugangsdaten fehlen“ ergänzt.
2026-10-17 22:49:42 +02:00

227 lines
11 KiB
GDScript

extends SceneTree
## Editor-Tests (headless): Selbst-Update — Karte t_d95f087a.
## Ausführen: godot --headless --path . --script res://tests/test_updater.gd
## Exit 0 = alles grün. Läuft OHNE UI (EditorUpdater + echte git-Repos in
## user://test_updater):
## 1. Check-Funktion mit ECHTEN Repos: gleich (up_to_date), divergiert
## (update_available), nicht erreichbar (unreachable) — kein Mock, echte
## git-Prozesse gegen ein Bare-Repo / gegen einen abgelehnten Port.
## 2. Banner-Logik (Text, kurze SHAs).
## 3. Import-Abbruch-Pfad: faked Import-Fehler (Exit-Code != 0 UND Parse-
## -Error-Schreiber mit Exit 0) → kein ok, kein Restart (Restart wird nur
## NACH ok angestoßen — hier wird bewusst NUR der ok-Pfad geprüft).
## 4. Restart-Auflösung: cfg-Kommando gewinnt, Default ist self/command.
var failures := 0
var checks := 0
const TEST_DIR_PREFIX := "user://test_updater"
var base := ""
var work := ""
func _init() -> void:
_cleanup_old()
base = ProjectSettings.globalize_path(
TEST_DIR_PREFIX + "_" + str(Time.get_ticks_msec()))
DirAccess.make_dir_recursive_absolute(base)
_run_tests()
_cleanup_dir(base) # best effort — git-Pack-Dateien können read-only sein
if failures == 0:
print("ALL OK: %d checks" % checks)
quit(0)
else:
print("FAILED: %d/%d checks" % [failures, checks])
quit(1)
func _check(cond: bool, label: String) -> void:
checks += 1
if cond:
print(" ok: %s" % label)
else:
failures += 1
print(" FAIL: %s" % label)
## Best effort: alte Läufe aufräumen (git macht Pack-Dateien read-only —
## dann bleibt der Ordner liegen; der LAUF nutzt deshalb IMMER ein frisches,
## Zeit-stempel-basiertes Verzeichnis und hängt nie an Überbleibseln).
func _cleanup_old() -> void:
var root := DirAccess.open(ProjectSettings.globalize_path("user://"))
if root == null:
return
for name in root.get_directories():
if name.begins_with("test_updater"):
_cleanup_dir(ProjectSettings.globalize_path("user://").path_join(name))
func _cleanup_dir(base_dir: String) -> void:
if not DirAccess.dir_exists_absolute(base_dir):
return
var d := DirAccess.open(base_dir)
if d == null:
return
for file in d.get_files():
DirAccess.remove_absolute(base_dir.path_join(file))
for sub in d.get_directories():
_cleanup_dir(base_dir.path_join(sub))
DirAccess.remove_absolute(base_dir)
func _git(dir: String, args: Array) -> Dictionary:
var packed := PackedStringArray()
for a in args:
packed.append(str(a))
return EditorUpdater.git_run("git", dir, packed)
func _write_file(path: String, text: String) -> void:
var f := FileAccess.open(path, FileAccess.WRITE)
f.store_string(text)
f.close()
func _run_tests() -> void:
var git := "git"
# --- echte Repos aufbauen: origin.git (bare) + work (Klon mit main) ---
var origin := base.path_join("origin.git")
_check(bool(_git(base, ["init", "--bare", "--initial-branch=main", origin])["ok"]),
"Setup: Bare-Repo origin.git angelegt")
work = base.path_join("work")
_check(bool(_git(base, ["init", "--initial-branch=main", work])["ok"]),
"Setup: Arbeits-Klon angelegt")
_check(bool(_git(work, ["remote", "add", "origin", origin])["ok"]),
"Setup: remote origin gesetzt")
_git(work, ["config", "user.email", "test@example.local"])
_git(work, ["config", "user.name", "Update-Test"])
_write_file(work.path_join("a.txt"), "Version 1\n")
_check(bool(_git(work, ["add", "a.txt"])["ok"]), "Setup: Datei gestagt")
_check(bool(_git(work, ["commit", "-m", "v1", "a.txt"])["ok"]), "Setup: Commit v1")
_check(bool(_git(work, ["push", "-u", "origin", "main"])["ok"]),
"Setup: main nach origin gepusht")
var local_sha := EditorUpdater.head_sha(git, work)
_check(local_sha.length() == 40, "head_sha: 40-Zeichen-SHA gelesen")
print("== Check: auf dem neusten Stand ==")
var up := EditorUpdater.check_update(git, work)
_check(str(up["status"]) == "up_to_date", "gleiches SHA → up_to_date")
_check(str(up["local"]) == local_sha and str(up["remote"]) == local_sha,
"beide SHAs befüllt und identisch")
print("== Check: Update verfügbar ==")
# Fremder Klon pusht einen neuen Commit nach origin — work bleibt hinten.
var other := base.path_join("other")
_check(bool(_git(base, ["clone", "-q", origin, other])["ok"]), "Setup: zweiter Klon")
_git(other, ["config", "user.email", "test@example.local"])
_git(other, ["config", "user.name", "Update-Test"])
_write_file(other.path_join("b.txt"), "neue Version\n")
_git(other, ["add", "b.txt"])
_git(other, ["commit", "-m", "v2", "b.txt"])
_check(bool(_git(other, ["push", "origin", "main"])["ok"]), "Setup: v2 gepusht")
var avail := EditorUpdater.check_update(git, work)
_check(str(avail["status"]) == "update_available", "origin voraus → update_available")
_check(str(avail["local"]) == local_sha, "lokaler SHA bleibt (Pull ist NICHT Teil des Checks)")
_check(str(avail["remote"]) != local_sha, "remote SHA ist der neue")
print("== Banner-Logik ==")
var text := EditorUpdater.banner_text(local_sha, str(avail["remote"]))
_check(text.contains("Update verfügbar"), "Banner-Text enthält „Update verfügbar“")
_check(text.contains(local_sha.substr(0, 7)), "Banner-Text enthält kurzen ALT-SHA")
_check(text.contains(EditorUpdater.short_sha(str(avail["remote"]))), "Banner-Text enthält kurzen NEU-SHA")
_check(EditorUpdater.short_sha(local_sha).length() == 7, "short_sha = 7 Zeichen")
print("== Check: nicht erreichbar ==")
# Port 1 (bewusst zu) → Verbindung sofort abgelehnt = der Offline-Pfad.
_git(work, ["remote", "set-url", "origin", "http://127.0.0.1:1/nicht-da.git"])
var dead := EditorUpdater.check_update(git, work)
_check(str(dead["status"]) == "unreachable", "Origin tot → unreachable (still, kein Fehler)")
_check(str(dead["local"]) == local_sha, "lokaler SHA trotzdem bekannt")
# Falsches Binary → error (kein Repo-Check möglich)
var bad := EditorUpdater.check_update("gibt-es-nicht-xyz", work)
_check(str(bad["status"]) == "error", "git nicht gefunden → error (nicht unreachable)")
print("== Credential-Guard (Karte t_1457e935): git wird NIE interaktiv ==")
_check(EditorUpdater.looks_like_auth_error(
"fatal: could not read Username for 'http://127.0.0.1:13020': No such device or address"),
"auth-Erkennung: could-not-read-Username")
_check(EditorUpdater.looks_like_auth_error(
"remote: Unauthorized\nfatal: Authentication failed for 'http://x/'"),
"auth-Erkennung: Authentication failed / Unauthorized")
_check(EditorUpdater.looks_like_auth_error("fatal: could not read Username; terminal prompts disabled"),
"auth-Erkennung: terminal prompts disabled")
_check(not EditorUpdater.looks_like_auth_error(
"fatal: unable to access 'http://127.0.0.1:1/': Connection refused"),
"auth-Erkennung: Connection refused ist KEIN Auth-Fehler")
var spawn := EditorUpdater.spawn_argv("git", "/projekt", PackedStringArray(["ls-remote", "origin"]))
if OS.has_feature("linux"):
_check(str(spawn[0]) == "/usr/bin/env", "argv: POSIX-Spawn über /usr/bin/env")
_check(spawn.has("GIT_TERMINAL_PROMPT=0"), "argv: GIT_TERMINAL_PROMPT=0 (kein Terminal-Prompt)")
_check(spawn.has("GIT_ASKPASS=/bin/true"), "argv: GIT_ASKPASS=/bin/true (überstimmt geerbtes SSH_ASKPASS)")
_check(spawn.has("-c") and spawn.has("credential.helper=") and spawn.has("credential.helper=store"),
"argv: Helper-Reset + ausschließlich store")
_check(spawn.has("core.askPass=/bin/true"), "argv: core.askPass-Fallback gesetzt")
var hat_home := false
for eintrag in spawn:
if str(eintrag).begins_with("HOME="):
hat_home = true
_check(hat_home, "argv: HOME aus der Umgebung mitgegeben (kein Hardcode)")
_check(str(spawn[spawn.size() - 2]) == "ls-remote" and str(spawn[spawn.size() - 1]) == "origin",
"argv: originale git-Argumente bleiben hinten")
print("== Pull ==")
_git(work, ["remote", "set-url", "origin", origin])
var pulled := EditorUpdater.pull(git, work)
_check(bool(pulled["ok"]), "pull --ff-only erfolgreich (v2 wird geladen)")
_check(EditorUpdater.head_sha(git, work) == str(avail["remote"]), "nach Pull ist work auf v2")
_git(work, ["remote", "set-url", "origin", "http://127.0.0.1:1/nicht-da.git"])
var dead_pull := EditorUpdater.pull(git, work)
_check(not bool(dead_pull["ok"]), "pull gegen toten Origin schlägt fehl")
_check((dead_pull["errors"] as Array).size() > 0, "Pull-Fehlerliste fürs Dialog-Fenster")
_git(work, ["remote", "set-url", "origin", origin])
print("== Import-Abbruch-Pfad ==")
_check(EditorUpdater.import_errors([]).is_empty(), "leere Import-Ausgabe = keine Fehler")
_check(EditorUpdater.import_errors(["Eine normale Zeile"]).is_empty(), "normale Ausgabe = keine Fehler")
var errs := EditorUpdater.import_errors(["Parse Error: Identifier not declared in the current scope."])
_check(errs.size() == 1, "Parse Error wird erkannt (Exit-Code-0-Falle, Lektion 17.10.)")
errs = EditorUpdater.import_errors(["SCRIPT ERROR: blub", "und weiter"])
_check(errs.size() == 1, "SCRIPT ERROR wird erkannt")
# Echter Run mit Exit-Code != 0 (Benutzung von "false": endet mit 1).
if FileAccess.file_exists("/bin/false"):
var failed := EditorUpdater.run_import("/bin/false", work)
_check(not bool(failed["ok"]), "Import mit Exit-Code 1 = NICHT ok (kein Restart)")
_check((failed["errors"] as Array).size() > 0, "Import-Fehlerliste befüllt")
# Echter Run, Exit-Code 0, ABER Parse-Error in der Ausgabe (die 17.10.-Falle):
if FileAccess.file_exists("/bin/sh"):
var fake := base.path_join("fake_godot.sh")
_write_file(fake, "#!/bin/sh\necho \"Parse Error: Identifier not declared.\"\nexit 0\n")
var code := OS.execute("chmod", PackedStringArray(["+x", fake]))
if code == 0:
var trap := EditorUpdater.run_import(fake, work)
_check(not bool(trap["ok"]), "Import „grün“ MIT Parse-Error = NICHT ok (kein Restart!)")
print("== Restart-Auflösung ==")
var cfg := ConfigFile.new()
cfg.set_value("update", "restart_command", "echo hallo welt")
var resolved := EditorUpdater.resolve_restart(cfg)
_check(str(resolved["mode"]) == "command", "cfg-Kommando gewinnt (mode command)")
var argv: PackedStringArray = resolved["argv"]
var want := ["echo", "hallo", "welt"]
var ok := argv.size() == want.size()
if ok:
for i in want.size():
if str(argv[i]) != want[i]:
ok = false
_check(ok, "Kommando sauber an Leerzeichen gesplittet")
var resolved2 := EditorUpdater.resolve_restart(ConfigFile.new())
_check(str(resolved2["mode"]) == "self" or str(resolved2["mode"]) == "command",
"ohne cfg: plausibler Default (self ODER systemd-Unit erkannt)")
if str(resolved2["mode"]) == "self":
_check((resolved2["argv"] as PackedStringArray).is_empty(),
"mode self: UI baut die Selbst-Restart-Argumente selbst")
var self_args := EditorUpdater.self_restart_args("/pfad/zum/klon")
_check(str(self_args[0]) == "--editor" and str(self_args[2]) == "/pfad/zum/klon",
"Selbst-Restart: --editor --path <klon> (sonst startet das Projekt als SPIEL)")