diff --git a/docs/tasten.md b/docs/tasten.md index a58cdd7..f0a369f 100644 --- a/docs/tasten.md +++ b/docs/tasten.md @@ -81,6 +81,35 @@ nichts geht versehentlich verloren. | **Strg+O** | Map öffnen | | **Strg+N** | Neue Map | +## Updates (Datei-Menü) + +Der Editor aktualisiert sich selbst aus seinem Git-Klon (Karte t_d95f087a) — +kein Programmdownload, nur „Versionsstände holen“: + +| Weg | Wirkung | +| --- | --- | +| **Beim Start** | Still prüfen. Gibt es eine neuere Version, erscheint eine Bernstei-Leiste unter der Menüleiste: **„🔄 Update verfügbar: `` → ``“** | +| **Banner → „🔄 Jetzt updaten“** | Installiert das Update: neue Version laden (git pull), Import-Lauf, dann startet der Editor von selbst neu. Ungespeicherte Änderungen werden vorher abgefragt (derselbe Dialog wie bei „Neue Map“/„Öffnen“ — **Abbrechen** bricht das Update ab) | +| **Datei → „🔄 Updates prüfen“** | Gleicher Check auf Wunsch, mit Ergebnis-Meldung in der Statuszeile: „Update verfügbar“ / „Du bist auf dem neusten Stand (``)“ / „Nicht erreichbar“ | + +- **Offline / Tunnel weg:** kein Banner, keine Meldung — der Editor bleibt + einfach so, wie er ist. Prüfen kann man trotzdem: die Meldung sagt es dann + in der Statuszeile. +- **Fehlgeschlagener Import:** bricht das Update mit Klartext-Meldung ab — + der Editor läuft UNVERÄNDERT weiter (kein Neustart in einen kaputten Stand, + Lektion 17.10. class_name-Cache). +- **Neustart:** Der Editor startet sich selbst neu (dasselbe Godot-Programm, + derselbe Projektordner, wieder im Editor-Modus). Wer den Editor als + systemd-User-Unit betreibt, setzt in `scopedvr_editor.cfg` (Godot-App-Daten) + Abschnitt `[update]` einen `restart_command` — z. B. + `restart_command="systemd-run --user --on-active=3s systemctl --user restart map-editor"`. + Ohne Eintrag wird unter Linux automatisch erkannt, ob die Unit + `map-editor.service` existiert; sonst Selbst-Restart. +- **Konfiguration** (alle optional, `user://scopedvr_editor.cfg`, Abschnitt + `[update]`): `restart_command` (eigener Neustart-Befehl), `git_command` + (eigener git-Pfad). Der Import-Lauf nutzt dieselbe Godot-Binary wie der + Editor selbst (bzw. `[test] godot_path`). + ## Kartengröße ändern (Datei-Menü) „Kartengröße ändern…“ (Datei-Menü) öffnet einen Dialog mit Live-Vorschau: diff --git a/src/editor/updater.gd b/src/editor/updater.gd new file mode 100644 index 0000000..381ffba --- /dev/null +++ b/src/editor/updater.gd @@ -0,0 +1,182 @@ +class_name EditorUpdater +extends RefCounted +## Editor-Selbstupdate (Karte t_d95f087a) — Update-Check über Git, Update- +## Ausführung (git pull + Godot-Import-Lauf) und plattformneutrale Restart- +## Auflösung. Alles static und ohne UI (tests/test_updater.gd). +## +## Regeln (Karte + Sly-Ansatz „das Programm sich selbst updaten lassen“): +## - Transport = Git (Klon des Editors), KEIN Binary-Download. +## - Keine Linux-Pfade hartkodiert: git (default "git") und ein optionaler +## Restart-Befehl kommen aus user://scopedvr_editor.cfg, Abschnitt [update] +## (git_command / restart_command). Ohne Konfiguration: unter Linux wird +## erkannt, ob eine systemd-User-Unit „map-editor“ existiert — dann klingelt +## ein Transient-Timer-Kick (systemd-run) den Unit-Neustart (der Kick läuft +## im EIGENEN Scope und überlebt die Stop-Phase). Sonst: der Editor startet +## sich selbst neu (mode "self", self_restart_args). +## - Import-Lauf-Lektion 17.10.: ein --import-Lauf kann mit Exit-Code 0 +## ENDEN und trotzdem Parse-/Script-Fehler enthalten (class_name-Cache). +## Die Ausgabe wird deshalb zusätzlich gescannt — bei Fehlern wird NICHT +## neu gestartet (Abbruch mit Meldung), sonst würde Sly einen Editor ohne +## Map-Ansicht sehen. +## - Offline / Tunnel weg → status "unreachable" → die UI bleibt still +## (kein Banner, kein Fehler-Geballer). + +const BRANCH := "main" +const UNIT_NAME := "map-editor" + + +## Ein Git-Kommando im Projektordner ausführen. Rückgabe {ok, code, output}. +static func git_run(git_exe: String, project_dir: String, args: PackedStringArray) -> Dictionary: + var full := PackedStringArray(["-C", project_dir]) + full.append_array(args) + var output: Array = [] + var code := OS.execute(git_exe, full, output, true) + var text := "" + for line in output: + text += str(line) + return {"ok": code == 0, "code": code, "output": text} + + +## Git-Binary: Konfiguration (user://scopedvr_editor.cfg → [update] +## git_command) gewinnt, sonst das Standard-"git" (PATH). +static func git_exe(cfg: ConfigFile = null) -> String: + if cfg != null: + var custom := str(cfg.get_value("update", "git_command", "")) + if not custom.is_empty(): + return custom + return "git" + + +## Lokaler HEAD-SHA. "" = kein Git-Repository (oder git nicht gefunden). +static func head_sha(git_exe: String, project_dir: String) -> String: + var r := git_run(git_exe, project_dir, PackedStringArray(["rev-parse", "HEAD"])) + if not bool(r["ok"]): + return "" + var text := str(r["output"]).strip_edges() + if text.is_empty(): + return "" + return text.split("\n")[0].strip_edges() + + +## Remote-SHA per git ls-remote (cheap, lesend — keine lokalen Referenzen +## verändert). "" = nicht erreichbar / Branch fehlt. +static func remote_sha(git_exe: String, project_dir: String, branch: String) -> String: + var r := git_run(git_exe, project_dir, PackedStringArray([ + "ls-remote", "origin", "refs/heads/%s" % branch])) + if not bool(r["ok"]): + return "" + var text := str(r["output"]).strip_edges() + if text.is_empty(): + return "" + return text.split("\n")[0].strip_edges().split("\t")[0] + + +## Update-Check: lokaler HEAD vs. origin/main. +## Rückgabe {status, local, remote, error} — status: +## "update_available" | "up_to_date" | "unreachable" | "error" +## "error" = kein Repo/git nicht da (Editor-spezifisches Problem) +## "unreachable"= Origin nicht erreichbar (offline, Tunnel weg) +static func check_update(git_exe: String, project_dir: String) -> Dictionary: + var local := head_sha(git_exe, project_dir) + if local.is_empty(): + return {"status": "error", "local": "", "remote": "", + "error": "Kein Git-Repository (oder git nicht gefunden)."} + var remote := remote_sha(git_exe, project_dir, BRANCH) + if remote.is_empty(): + return {"status": "unreachable", "local": local, "remote": "", + "error": "Origin nicht erreichbar (offline oder Tunnel weg)."} + if local == remote: + return {"status": "up_to_date", "local": local, "remote": remote, "error": ""} + return {"status": "update_available", "local": local, "remote": remote, "error": ""} + + +## Kurzer SHA für Anzeigen (7 Zeichen, wie git log --oneline). +static func short_sha(sha: String) -> String: + return sha.substr(0, 7) + + +## Banner-Text (pure, testbar). +static func banner_text(local_sha: String, remote_sha: String) -> String: + return "🔄 Update verfügbar: %s → %s" % [short_sha(local_sha), short_sha(remote_sha)] + + +## Update ausführen: git pull --ff-only (Klon zieht nur nach, dreht nichts +## um). Rückgabe {ok, output, errors}. +static func pull(git_exe: String, project_dir: String) -> Dictionary: + var r := git_run(git_exe, project_dir, PackedStringArray(["pull", "--ff-only"])) + var errors: Array = [] + if not bool(r["ok"]): + errors.append("git pull --ff-only fehlgeschlagen (Exit-Code %s):" % str(r["code"])) + var text := str(r["output"]).strip_edges() + if not text.is_empty(): + errors.append(text) + else: + errors.append("(keine Ausgabe — git nicht gefunden oder Verbindung sofort weg)") + return {"ok": bool(r["ok"]), "output": str(r["output"]), "errors": errors} + + +## Import-Lauf: --headless --path --import. Muss NACH dem +## Pull laufen (neue class_name-Dateien in den Cache), FEHLSCHLAG = Update- +## Abbruch OHNE Neustart (Lektion 17.10.). Rückgabe {ok, errors, output}. +static func run_import(godot_exe: String, project_dir: String) -> Dictionary: + var args := PackedStringArray(["--headless", "--path", project_dir, "--import"]) + var output: Array = [] + var code := OS.execute(godot_exe, args, output, true) + var errors := import_errors(output) + if code != 0: + errors.append("Import-Lauf fehlgeschlagen (Exit-Code %s)" % str(code)) + return {"ok": errors.is_empty(), "errors": errors, "output": _output_text(output)} + + +## Pure Prüfung (testbar): enthält die Import-Ausgabe Parse-/Script-Fehler? +## Exit-Code 0 allein beweist NICHT einen sauberen Import (Lektion 17.10.). +static func import_errors(output: Array) -> Array: + var errors: Array = [] + for line in output: + var text := str(line) + if text.contains("Parse Error") or text.contains("SCRIPT ERROR"): + errors.append(text.strip_edges()) + return errors + + +static func _output_text(output: Array) -> String: + var text := "" + for line in output: + text += str(line) + return text + + +## Restart-Auflösung (plattformneutral, getestet): +## 1. cfg [update] restart_command gesetzt → mode "command", argv = an +## Leerzeichen gesplittet. (z. B. systemd-Unit-Kick, Batch-Skript — jede +## Plattform setzt hier ihren eigenen Befehl ein.) +## 2. Linux + systemd-User-Unit „map-editor“ existiert → Transient-Timer- +## Kick (systemd-run, 3 s): der Kick läuft in einem EIGENEN Scope und +## wird beim Stoppen des Editors (Unit-Neustart) nicht mitgekillt. +## 3. Sonst: mode "self" — der Editor startet sich selbst neu (gleiche +## Godot-Binary, --editor --path ) und beendet sich danach. +static func resolve_restart(cfg: ConfigFile = null) -> Dictionary: + if cfg != null: + var cmd := str(cfg.get_value("update", "restart_command", "")) + if not cmd.is_empty(): + return {"mode": "command", "argv": PackedStringArray(cmd.split(" ", false))} + if OS.has_feature("linux"): + var probe: Array = [] + var code := OS.execute("systemctl", PackedStringArray([ + "--user", "list-unit-files", UNIT_NAME + ".service", "--no-legend"]), + probe, true) + var text := "" + for line in probe: + text += str(line) + if code == 0 and not text.strip_edges().is_empty(): + return {"mode": "command", "argv": PackedStringArray([ + "systemd-run", "--user", "--on-active=3s", + "systemctl", "--user", "restart", UNIT_NAME + ".service"])} + return {"mode": "self", "argv": PackedStringArray()} + + +## Argumente für den Selbst-Restart: dieselbe Godot-Binary öffnet denselben +## Projektordner im Editor-Modus („--editor“ ist nötig — ein nacktes +## „--path“ würde das Projekt als SPIEL starten, nicht als Editor). +static func self_restart_args(project_dir: String) -> PackedStringArray: + return PackedStringArray(["--editor", "--path", project_dir]) diff --git a/src/editor/updater.gd.uid b/src/editor/updater.gd.uid new file mode 100644 index 0000000..3edbb95 --- /dev/null +++ b/src/editor/updater.gd.uid @@ -0,0 +1 @@ +uid://nxdbipruop30 diff --git a/src/ui/help_text.gd b/src/ui/help_text.gd index 6b03afa..43a050c 100644 --- a/src/ui/help_text.gd +++ b/src/ui/help_text.gd @@ -57,6 +57,7 @@ static func text() -> String: ["Strg+Umschalt+S", "Map speichern unter …"], ["Strg+O", "Map öffnen"], ["Strg+N", "Neue Map"], + ["Datei → 🔄 Updates prüfen", "Selbst-Update: prüft auf neue Version; Banner → „Jetzt updaten“ (lädt, importiert, startet neu)"], ])) out += _header("Werkzeuge & Tests") out += _table(_rows([ diff --git a/src/ui/main.gd b/src/ui/main.gd index e079d3a..32388a2 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -132,6 +132,16 @@ var help_overlay_title: Label var help_overlay_text: RichTextLabel var tool_label: Label # permanenter Werkzeugmodus + Flug-Hint +# Editor-Selbstupdate (Karte t_d95f087a): Start-Check + Banner + „Jetzt updaten“ +var update_banner: PanelContainer # Leiste „🔄 Update verfügbar …“ (versteckt) +var update_label: Label +var update_btn: Button # „Jetzt updaten“ im Banner +var check_thread: Thread # Update-Check im Hintergrund (Start/Menü) +var update_thread: Thread # Update-Ausführung (pull + Import-Lauf) +var _update_check_busy := false +var _update_running := false +var _update_result: Dictionary = {} # letzter Check (Status/Banner-Nachfrage) + # Dialoge var new_dialog: AcceptDialog var open_dialog: FileDialog @@ -153,6 +163,10 @@ func _ready() -> void: _update_tool_label() _start_with_new_map() _maybe_start_demo_mode() + # Selbst-Update (Karte t_d95f087a): STILLER Start-Check im Hintergrund — + # nur bei „Update verfügbar“ erscheint das Banner; offline/Tunnel weg + # bleibt alles ruhig (kein Banner, keine Meldung). + _startup_update_check() func _load_manifest() -> Dictionary: @@ -181,6 +195,7 @@ func _build_ui() -> void: top_h.add_theme_constant_override("separation", 10) top.add_child(top_h) vbox.add_child(top) + _update_banner_build(vbox) var file_menu := MenuButton.new() file_menu.text = "Datei" @@ -196,6 +211,7 @@ func _build_ui() -> void: popup.add_separator() popup.add_item("▶ Karte testen", 5) popup.add_separator() + popup.add_item("🔄 Updates prüfen", 7) popup.add_item("Beenden", 4) popup.id_pressed.connect(_on_menu_action) top_h.add_child(file_menu) @@ -993,6 +1009,8 @@ func _on_menu_action(id: int) -> void: _on_test_run() 6: # Kartengröße ändern (R3, t_c863b93b) _open_resize_dialog() + 7: # Updates prüfen (Selbst-Update, t_d95f087a) + _on_update_check_menu() # ------------------------------------------------- Kartengröße ändern (R3) -- @@ -1059,6 +1077,181 @@ func _replace_doc_in_place(result: Dictionary) -> void: doc[key] = source +# --------------------------------------------- Selbst-Update (t_d95f087a) ---- + +## Banner „🔄 Update verfügbar“ (unaufdringliche Leiste unter der Top-Bar) — +## nur sichtbar, wenn ein Check einen Update findet. Offline bleibt alles still. +func _update_banner_build(parent: Container) -> void: + update_banner = PanelContainer.new() + var style := StyleBoxFlat.new() + style.bg_color = Color(0.36, 0.25, 0.06, 0.97) # gedämpftes Bernstein + style.border_color = Color(0.95, 0.72, 0.15) + style.set_border_width_all(2) + style.set_content_margin_all(8) + style.set_corner_radius_all(6) + update_banner.add_theme_stylebox_override("panel", style) + var h := HBoxContainer.new() + h.add_theme_constant_override("separation", 14) + update_banner.add_child(h) + update_label = Label.new() + update_label.add_theme_font_size_override("font_size", 17) + h.add_child(update_label) + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + h.add_child(spacer) + update_btn = Button.new() + update_btn.text = "🔄 Jetzt updaten" + update_btn.custom_minimum_size = Vector2(190, 40) + update_btn.tooltip_text = "Lädt die neue Version (git pull), importiert sie und startet den Editor neu. Bei ungespeicherten Änderungen fragt der Editor vorher." + update_btn.pressed.connect(_on_update_now) + h.add_child(update_btn) + update_banner.visible = false + parent.add_child(update_banner) + + +## Still Start-Check (nur Banner bei „Update verfügbar“ — offline: nichts). +func _startup_update_check() -> void: + _updater_check_async(false) + + +## Datei-Menü „🔄 Updates prüfen“ (ID 7): gleicher Check mit Ergebnis-Meldung. +func _on_update_check_menu() -> void: + if _update_check_busy: + _set_status("Update-Check läuft schon…") + return + _set_status("Prüfe auf Updates…") + _updater_check_async(true) + + +func _updater_check_async(report: bool) -> void: + _update_check_busy = true + check_thread = Thread.new() + check_thread.start(_updater_check_thread.bind(report)) + + +func _updater_check_thread(report: bool) -> void: + var result := EditorUpdater.check_update( + EditorUpdater.git_exe(TestRun.load_cfg()), + ProjectSettings.globalize_path("res://")) + _update_result = result + call_deferred("_updater_check_done", result, report) + + +func _updater_check_done(result: Dictionary, report: bool) -> void: + _update_check_busy = false + _join(check_thread) + check_thread = null + match str(result.get("status")): + "update_available": + _show_update_banner(str(result["local"]), str(result["remote"])) + if report: + _set_status("🔄 Update verfügbar: %s → %s — im Banner „Jetzt updaten“ klicken." % [ + EditorUpdater.short_sha(str(result["local"])), + EditorUpdater.short_sha(str(result["remote"]))]) + "up_to_date": + if report: + _set_status("Du bist auf dem neusten Stand (%s)." % EditorUpdater.short_sha(str(result["local"]))) + "unreachable": + if report: + _set_status("Update-Check nicht erreichbar (offline oder Tunnel weg) — später nochmal probieren.", true) + _: + if report: + _set_status("Update-Check fehlgeschlagen: %s" % str(result.get("error", "")), true) + + +func _show_update_banner(local_sha: String, remote_sha: String) -> void: + update_label.text = EditorUpdater.banner_text(local_sha, remote_sha) + update_banner.visible = true + + +## Thread sauber einsammeln (Ergebnis kam per call_deferred — der Thread ist +## längst fertig, join ist nur Ordnung). +func _join(thread: Thread) -> void: + if thread != null and thread.is_started(): + thread.wait_to_finish() + + +## Banner-Knopf „Jetzt updaten“: Dirty-Schutz wie Strg+O/Strg+N (derselbe +## deutsche Dialog — „Abbrechen“ bricht das Update ab), dann git pull + +## Import-Lauf + Neustart. Import-Fehler = Abbruch mit Meldung, KEIN Neustart +## (Lektion 17.10.: sonst sieht Sly einen Editor ohne Map-Ansicht). +func _on_update_now() -> void: + if _update_running: + _set_status("Ein Update läuft schon…") + return + _request_lossy(_start_update) + + +func _start_update() -> void: + _update_running = true + update_btn.disabled = true + _set_status("Update läuft — neue Version wird geladen (git pull)…") + update_thread = Thread.new() + update_thread.start(_updater_run_thread) + + +func _updater_run_thread() -> void: + var project_dir := ProjectSettings.globalize_path("res://") + var cfg := TestRun.load_cfg() + var pull := EditorUpdater.pull(EditorUpdater.git_exe(cfg), project_dir) + if not bool(pull["ok"]): + call_deferred("_update_finished", false, "Update abgebrochen — git pull fehlgeschlagen:", pull["errors"]) + return + call_deferred("_set_status", "Update geladen — Version wird importiert (Godot-Import-Lauf)…") + var godot := TestRun.find_godot(cfg) + if godot.is_empty(): + call_deferred("_update_finished", false, "Update abgebrochen — kein Godot-Programm für den Import-Lauf:", [ + "Der Editor läuft nicht über ein Godot-Binary und es ist kein godot_path gesetzt.", + "Trage in scopedvr_editor.cfg (Godot-App-Daten) ein:", + " [test]", + " godot_path=\"/pfad/zu/godot\"", + ]) + return + var imp := EditorUpdater.run_import(godot, project_dir) + if not bool(imp["ok"]): + call_deferred("_update_finished", false, "Update ABGEBROCHEN — der Import ist fehlgeschlagen, es wird NICHT neu gestartet:", imp["errors"]) + return + call_deferred("_update_finished", true, "", []) + + +func _update_finished(ok: bool, title: String, details: Array) -> void: + _update_running = false + update_btn.disabled = false + _join(update_thread) + update_thread = null + if not ok: + update_banner.visible = true + _show_errors(title, details) + _set_status("Update fehlgeschlagen — der Editor bleibt unverändert (es ist nichts kaputt gemacht).", true) + return + var restart := EditorUpdater.resolve_restart(TestRun.load_cfg()) + var project_dir := ProjectSettings.globalize_path("res://") + var pid := -1 + if str(restart["mode"]) == "command": + var argv: PackedStringArray = restart["argv"] + pid = OS.create_process(str(argv[0]), argv.slice(1)) + else: + var godot := TestRun.find_godot(TestRun.load_cfg()) + if godot.is_empty(): + _show_errors("Update installiert — Neustart nicht möglich:", [ + "Der neue Stand ist geladen, aber das Godot-Programm für den Neustart ist unbekannt.", + "Editor einfach einmal selbst neu starten.", + ]) + _set_status("Update installiert — bitte den Editor einmal selbst neu starten.") + return + pid = OS.create_process(godot, EditorUpdater.self_restart_args(project_dir)) + if pid <= 0: + _show_errors("Update installiert — Neustart fehlgeschlagen:", [ + "Der neue Stand ist geladen, aber der Neustart wurde vom System abgelehnt.", + "Editor einfach einmal selbst neu starten.", + ]) + _set_status("Update installiert — bitte den Editor einmal selbst neu starten.") + return + _set_status("Update installiert (%s) — Editor startet neu…" % EditorUpdater.short_sha(str(_update_result.get("remote", "")))) + await get_tree().create_timer(1.0).timeout + get_tree().quit() + + ## „▶ Karte testen" (Karte t_b84af40f): aktuelle Map (auch ungespeichert!) ## nach Temp exportieren und den scopedvr-client als EIGENES Fenster starten. ## Der Editor bleibt offen; das Spiel beendet sich selbst sauber (ESC). diff --git a/tests/test_updater.gd b/tests/test_updater.gd new file mode 100644 index 0000000..19dcaf7 --- /dev/null +++ b/tests/test_updater.gd @@ -0,0 +1,199 @@ +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("== 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 (sonst startet das Projekt als SPIEL)") diff --git a/tests/test_updater.gd.uid b/tests/test_updater.gd.uid new file mode 100644 index 0000000..50de8d1 --- /dev/null +++ b/tests/test_updater.gd.uid @@ -0,0 +1 @@ +uid://cfxxi8pjnihka diff --git a/tools/run_tests.sh b/tools/run_tests.sh index 99f2467..8b0ce8a 100755 --- a/tools/run_tests.sh +++ b/tools/run_tests.sh @@ -3,7 +3,7 @@ cd "$(dirname "$0")/.." GODOT=~/bin/godot4 rc=0 -for t in test_ux test_shortcuts test_editor_svmap test_terrain test_library test_glb_view test_spawns test_testrun test_bauen test_resize_dialog test_gui_smoke test_rmb_vorrang; do +for t in test_ux test_shortcuts test_editor_svmap test_terrain test_library test_glb_view test_spawns test_testrun test_bauen test_resize_dialog test_updater test_gui_smoke test_rmb_vorrang; do "$GODOT" --headless --path . --script "res://tests/$t.gd" > "/tmp/$t.log" 2>&1 code=$? line=$(grep -E "ALL OK|FAILED" "/tmp/$t.log" | tail -1)