scopedvr-map-editor/tests/test_updater.gd

412 lines
21 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()
# Karte t_1e82f747: Time.get_ticks_msec() ist nur die Zeit seit Godot-Start
# (kleine Zahl, z.B. 115) — bei jedem CLI-Run derselbe Wert! Kollidierte
# Namen mit nicht löschbaren Alt-Läufen → git init lief in einer ALTEN
# Sitzung (remote existiert schon, alte Dateien im Baum → rote Setup-Zeilen).
# Deswegen: Unix-Zeit + ticks_usec = garantiert einzigartig pro Prozess/Run.
base = ProjectSettings.globalize_path(TEST_DIR_PREFIX + "_" +
str(int(Time.get_unix_time_from_system())) + "_" +
str(Time.get_ticks_usec()))
DirAccess.make_dir_recursive_absolute(base)
_run_tests()
_test_ls_remote_warnzeile()
_force_writable(base)
_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"):
var alt := ProjectSettings.globalize_path("user://").path_join(name)
_force_writable(alt)
_cleanup_dir(alt)
## Karte t_1e82f747: git macht .git-Packs read-only, dann schlägt
## DirAccess.remove_absolute fehlt → der Ordner bleibt liegen und KOLLIDIERT
## mit künftigen Läufen (derselbe ticks_msec-Name!). chmod -R u+w macht die
## Bäume löschbar. Linux/OSX-Fallback per /usr/bin/chmod (Tests sind CLI,
## Windows-spezifische ACLs sind hier nicht der Fall).
func _force_writable(path: String) -> void:
if not DirAccess.dir_exists_absolute(path):
return
var chmod := "/usr/bin/chmod"
if not FileAccess.file_exists(chmod):
chmod = "chmod"
# WICHTIG: das 2-Argument-OS.execute ist in Godot 4 ASYNCHRON (gibt nur
# eine PID zurück) — ohne output-Array wartet der Lauf nicht, und die
# Löschung passiert vor dem chmod. Mit output-Array blockiert er.
var out: PackedStringArray = []
OS.execute(chmod, PackedStringArray(["-R", "u+w", path]), out)
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
# Karte t_1e82f747: get_files()/get_directories() überspringen defaultmäßig
# VERSTECKTE Ordner — und .git ist versteckt. Ohne dieses Flag blieb der
# Repo-Kern liegen, der Basisordner war nie leer und löschbar.
d.include_hidden = true
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)
# Rückfallebene: git-Pack-Bäume haben teils noch andere Sonderfälle — wenn
# der Ordner trotzdem liegt, räumt rm -rf (CLI-Umgebung) garantiert ab.
if DirAccess.dir_exists_absolute(base_dir):
var out: PackedStringArray = []
OS.execute("/bin/rm", PackedStringArray(["-rf", base_dir]), out)
func _git(dir: String, args: Array) -> Dictionary:
var packed := PackedStringArray()
for a in args:
packed.append(str(a))
var r := EditorUpdater.git_run("git", dir, packed)
if not bool(r["ok"]):
# Karte t_1e82f747: rote Setup-Nummern ohne Grund sind wertlos —
# der echte git-Fehlertext gehört in die Ausgabe.
print(" git [%s] %s → code=%s: %s" % [dir, " ".join(args),
r["code"], str(r["output"]).strip_edges()])
return r
func _write_file(path: String, text: String) -> void:
var f := FileAccess.open(path, FileAccess.WRITE)
f.store_string(text)
f.close()
func _file_text(path: String) -> String:
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
return "<fehlt>"
var text := f.get_as_text()
f.close()
return text
## Nur getrackte Verschmutzungen: false, wenn eine NICHT-untracked Zeile in
## git status --porcelain auftaucht (halb gepoppter Konflikt hinterlässt
## genau solche Zeilen).
func _clean_tracked(dir: String) -> bool:
var r := _git(dir, ["status", "--porcelain"])
for line in str(r["output"]).split("\n"):
var text := str(line).strip_edges()
if not text.is_empty() and not text.begins_with("??"):
return false
return true
## Anzahl der EIGENEN Auto-Stashes (Kennung "auto: Selbst-Update").
func _auto_stash_count(git: String, dir: String) -> int:
var r := EditorUpdater.git_run(git, dir, PackedStringArray([
"stash", "list", "--pretty=format:%gd|%s"]))
var count := 0
for line in str(r["output"]).split("\n"):
var parts := str(line).strip_edges().split("|", true, 1)
if parts.size() == 2 and str(parts[1]).contains("auto: Selbst-Update"):
count += 1
return count
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")
# Befund 25.10.: Klon VOR origin/main (z. B. Feature-Branch) ist KEIN Update.
_git(work, ["config", "user.email", "t@example.invalid"])
_git(work, ["config", "user.name", "Update-Test"])
_write_file(work.path_join("lokal.txt"), "nur lokal\n")
_git(work, ["add", "lokal.txt"])
_git(work, ["commit", "-m", "lokal voraus", "lokal.txt"])
var ahead := EditorUpdater.check_update(git, work)
_check(str(ahead["status"]) == "up_to_date", "Klon VORAUS (Feature-Branch) → up_to_date, kein Dauer-Banner")
_git(work, ["reset", "--hard", "-q", str(avail["remote"])])
_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("== Selbst-Update: lose Dateien blockieren den Pull nicht (Karte t_f73a1cc5) ==")
# Origin bekommt eine GETRACKTE Import-Datei — lokal existiert sie als
# untracked (Godot erzeugt sie beim Import). Genau die Kollision, die am
# 19.10. den Update-Knopf blockiert hat.
DirAccess.make_dir_recursive_absolute(other.path_join("library/thumbs"))
_write_file(other.path_join("library/thumbs/zz_probe.png.import"), "origin-version\n")
_git(other, ["add", "library/thumbs/zz_probe.png.import"])
_git(other, ["commit", "-m", "v3: Import-Datei getrackt"])
_check(bool(_git(other, ["push", "origin", "main"])["ok"]),
"Setup: v3 gepusht (Import-Datei jetzt getrackt)")
# NEGATIVPROBE: der NACKTE Pull (ohne Fix) bricht an der losen Datei ab.
DirAccess.make_dir_recursive_absolute(work.path_join("library/thumbs"))
_write_file(work.path_join("library/thumbs/zz_probe.png.import"), "lokal-erzeugt\n")
var raw := EditorUpdater.git_run(git, work, PackedStringArray(["pull", "--ff-only"]))
_check(not bool(raw["ok"]), "NEGATIVPROBE: nackter pull --ff-only bricht an der Kollision ab")
_check(not str(raw["output"]).strip_edges().is_empty(),
"NEGATIVPROBE: git liefert die rohe (verwirrende) Fehlerzeile")
# MIT Fix: EditorUpdater.pull legt selbst beiseite und zieht durch.
var fixed := EditorUpdater.pull(git, work)
_check(bool(fixed["ok"]), "MIT Fix: pull läuft ohne Handeingriff durch")
_check(str(fixed["info"]).contains("beiseitegelegt"),
"Klartext-Info fürs Statuszeilen-Fenster gesetzt")
_check(_file_text(work.path_join("library/thumbs/zz_probe.png.import")) == "origin-version\n",
"kein halb gepoppter Zustand: getrackte Version liegt an")
_check(_clean_tracked(work), "Arbeitsbaum (getrackt) sauber — Godot baut die Datei beim Import neu")
_check(_auto_stash_count(git, work) == 1, "Sicherungs-Stash bleibt bei Konflikt liegen")
print("== Selbst-Update: geänderte getrackte Datei bleibt erhalten ==")
# v4 fasst a.txt NICHT an → das Zurückholen der lokalen b.txt-Änderung
# MUSS klappen: apply + drop, kein Stash-Rest.
_write_file(work.path_join("b.txt"), "lokal geändert\n")
_write_file(other.path_join("c.txt"), "neu\n")
_git(other, ["add", "c.txt"])
_git(other, ["commit", "-m", "v4: nur c.txt"])
_check(bool(_git(other, ["push", "origin", "main"])["ok"]), "Setup: v4 gepusht")
var fixed2 := EditorUpdater.pull(git, work)
_check(bool(fixed2["ok"]), "pull mit lokaler Änderung läuft durch")
_check(_file_text(work.path_join("b.txt")) == "lokal geändert\n",
"lokal geänderte Datei ist zurückgeholt (kein Datenverlust)")
_check(_auto_stash_count(git, work) == 0,
"Auto-Stash nach erfolgreichem Rückholen aufgeräumt (keine Anhäufung)")
print("== Selbst-Update: Kollision mit Origin → Stash bleibt als Sicherung ==")
# v5 ändert dieselbe Datei wie lokal → Zurückholen kollidiert; der Stash
# bleibt als Sicherung liegen, der Arbeitsbaum ist sauber auf v5.
_write_file(work.path_join("a.txt"), "lokal-geändert\n")
_write_file(other.path_join("a.txt"), "origin-geändert\n")
_git(other, ["add", "a.txt"])
_git(other, ["commit", "-m", "v5: a.txt geändert"])
_check(bool(_git(other, ["push", "origin", "main"])["ok"]), "Setup: v5 gepusht")
var fixed3 := EditorUpdater.pull(git, work)
_check(bool(fixed3["ok"]), "Update läuft trotzdem durch (Änderung liegt sicher im Stash)")
_check(_file_text(work.path_join("a.txt")) == "origin-geändert\n",
"Arbeitsbaum = Origin-Stand (kein Konflikt-Brei, keine halbe Datei)")
_check(str(fixed3["info"]).contains("neu aufgebaut"),
"ehrliche Meldung: beiseitegelegt, beim nächsten Start neu aufgebaut")
var show := EditorUpdater.git_run(git, work, PackedStringArray([
"stash", "show", "-p", "stash@{0}"]))
_check(str(show["output"]).contains("lokal-geändert"),
"geänderte Datei liegt unverlustfrei im Sicherungs-Stash")
print("== Auto-Stash: alte Kennung wird verworfen, nichts häuft sich an ==")
_write_file(work.path_join("library/thumbs/zwei.png.import"), "alt\n")
var s1 := EditorUpdater._stash_local_changes(git, work)
_write_file(work.path_join("library/thumbs/drei.png.import"), "neu\n")
var s2 := EditorUpdater._stash_local_changes(git, work)
_check(not s1.is_empty() and not s2.is_empty(), "beide Läufe legen einen Auto-Stash an")
_check(_auto_stash_count(git, work) == 1, "genau EIN Auto-Stash übrig (alter verworfen)")
# Für die Folge-Abschnitte aufräumen: Stash zurückholen (klappert, weil
# die zwei untracked Dateien an keine getrackte Datei grenzen) und die
# Probendateien wegwerfen.
_check(bool(EditorUpdater._restore_stash(git, work)["ok"]), "Aufräumen: Stash zurückgeholt")
DirAccess.remove_absolute(work.path_join("library/thumbs/zwei.png.import"))
DirAccess.remove_absolute(work.path_join("library/thumbs/drei.png.import"))
_check(_auto_stash_count(git, work) == 0, "Aufräumen: kein Auto-Stash übrig")
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]) == "--path" and str(self_args[1]) == "/pfad/zum/klon",
"Selbst-Restart: --path <klon> (startet die Map-Editor-APP, nicht die Godot-Editor-Shell)")
## Regression (20.10., Live-Bug im Map-Builder): git schreibt bei einer
## HTTP-Umleitung zuerst "warning: Umleitung nach ..." in die GEMISCHTE
## Ausgabe (git_run mischt stdout+stderr). Die alte Fassung nahm die ERSTE
## Zeile als Ziel-Commit → Banner "Update verfügbar: <sha> → warning" hing
## endlos, obwohl der Klon aktuell war. Erwartung: echte "<sha>\t<ref>"-Zeile
## gewinnt, Warnzeilen werden nie als SHA gelesen.
func _test_ls_remote_warnzeile() -> void:
var sha := "d0d4fbd2213e596d117b779c6d931629bf9b20ed"
var mit_warnung := "warning: Umleitung nach http://127.0.0.1:13020/x.git/\n%s\trefs/heads/main\n" % sha
_check(EditorUpdater.sha_aus_ls_remote(mit_warnung, "main") == sha,
"Warnzeile ignoriert, echter Commit gelesen")
_check(not EditorUpdater.ist_sha("warning"), "'warning' gilt nicht als SHA")
_check(EditorUpdater.sha_aus_ls_remote("warning: Umleitung nach x\n", "main") == "",
"nur Warnzeilen → leerer Commit (kein Update-Banner)")
_check(EditorUpdater.sha_aus_ls_remote("fatal: could not read Username\n", "main") == "",
"fatal-Zeile wird nicht als SHA gelesen")
_check(EditorUpdater.sha_aus_ls_remote("%s\trefs/heads/main" % sha, "main") == sha,
"normale Zeile funktioniert weiterhin")
_check(EditorUpdater.sha_aus_ls_remote("%s\trefs/heads/anderer" % sha, "main") == sha,
"anderer Branch: SHA bleibt als Notnagel nutzbar")