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]) == "--path" and str(self_args[1]) == "/pfad/zum/klon", "Selbst-Restart: --path (startet die Map-Editor-APP, nicht die Godot-Editor-Shell)")