#!/bin/bash # Ablauf-Tests mit echten Maus- und Tastatureingaben in der headless Shell: dev/tests/input.sh # Startet die Shell neu, spielt Testdaten ein und klickt/tippt sich durch alle Funktionen. DIR=$(cd "$(dirname "$0")/.." && pwd) E="$DIR/eval.sh" IN="$DIR/input.sh" OUT=/tmp/desktop-app-nested/out.txt SHOTS=/tmp/desktop-app-nested/shots mkdir -p "$SHOTS" FAILED=0 PRE='const Clutter = imports.gi.Clutter; const St = imports.gi.St; const ext = Main.extensionManager.lookup("desktop@marha.de").stateObj; const notes = ext._root.get_child_at_index(0); const cal = ext._root.get_child_at_index(1).get_child_at_index(0); const todos = ext._root.get_child_at_index(1).get_child_at_index(1); const store = ext._store; const grabbed = () => global.stage.get_grab_actor(); const center = a => { const [x, y] = a.get_transformed_position(); const [w, h] = a.get_transformed_size(); return [Math.round(x + w / 2), Math.round(y + h / 2)]; }; const find = (root, text) => { let found = null; const walk = a => { if (found || !a.visible) return; if (a instanceof St.Button && (a.label ?? a.child?.text ?? a.label_actor?.text) === text) found = a; a.get_children().forEach(walk); }; if (root) walk(root); return found; }; const entries = a => { const out = []; const walk = n => { if (n instanceof St.Entry) out.push(n.get_text()); n.get_children().forEach(walk); }; if (a) walk(a); return out; }; const labels = a => { const out = []; const walk = n => { if (n instanceof St.Label && n.text) out.push(n.text); n.get_children().forEach(walk); }; if (a) walk(a); return out; }; const read = p => new TextDecoder().decode(GLib.file_get_contents(p)[1]); const bottom = a => { const [x, y] = a.get_transformed_position(); const [w, h] = a.get_transformed_size(); return [Math.round(x + w / 2), Math.round(y + h - 24)]; }; const today = GLib.DateTime.new_now_local().format("%Y-%m-%d"); ' # JS ausführen, Ergebnis als Text zurückgeben val() { "$E" "$PRE GLib.file_set_contents('$OUT', String($1)); 'w'" | grep -v "^OK '\"w\"')$" cat "$OUT" } # Mittelpunkt eines Actors: xy 'ausdruck' → "x y" xy() { val "center($1).join(' ')" } click() { $IN click $(xy "$1"); sleep 0.3; } rclick() { $IN rclick $(xy "$1"); sleep 0.3; } rclickb() { $IN rclick $(val "bottom($1).join(' ')"); sleep 0.3; } type_() { $IN type "$1"; sleep 0.2; } key() { $IN key "$1"; sleep 0.3; } shot() { "$DIR/shot.sh" "$SHOTS/$1.png" >/dev/null; } check() { local name=$1 actual=$2 expected=$3 if [ "$actual" == "$expected" ]; then echo "ok $name" else echo "FAIL $name"; echo " ist: $actual"; echo " soll: $expected" FAILED=$((FAILED + 1)) fi } echo "=== Start" "$DIR/restart.sh" headless >/dev/null "$E" "$DIR/seed.js" >/dev/null check "Extension aktiv" "$(val 'Main.extensionManager.lookup("desktop@marha.de").state')" "1" check "Notizen halbe Bildschirmbreite" "$(val 'notes.width + " " + Main.layoutManager.primaryMonitor.width / 2')" "960 960" check "Rechte Spalte füllt den Rest, Abstand wie außen" "$(val '(r => [r.x - (notes.x + notes.width), ext._root.width - (r.x + r.width)].join(" "))(ext._root.get_child_at_index(1))')" "24 0" echo "=== Woche: Termin per Drag" click 'cal._viewButtons.week' check "Wochenansicht" "$(val 'cal._view')" "week" $IN drag $(xy 'cal._slots[4][10]') $(xy 'cal._slots[4][12]'); sleep 0.3 check "Popover offen" "$(val 'grabbed()?.style_class')" "desktop-popover form" check "Zeitraum im Popover" "$(val '["Fr, 18.09.2026 ▾", "10:00 ▾", "13:00 ▾"].map(t => find(grabbed(), t) ? "ja" : "nein").join(" ")')" "ja ja ja" check "Auswahl markiert" "$(val 'cal._slots[4].map((s, h) => s.has_style_class_name("selecting") ? h : null).filter(h => h !== null).join(",")')" "10,11,12" type_ "Sport" shot "01-drag-popover" key Return check "Termin gespeichert" "$(val 'JSON.stringify((({title, date, start, end, endDate}) => ({title, date, start, end, endDate}))(store.events.find(e => e.title === "Sport")))')" \ '{"title":"Sport","date":"2026-09-18","start":"10:00","end":"13:00","endDate":null}' check "Popover zu, Markierung weg" "$(val '[grabbed() ? "grab" : "frei", cal._slots[4].filter(s => s.has_style_class_name("selecting")).length].join(" ")')" "frei 0" check "Ein Block über drei Stunden" "$(val '(b => b.length + " " + Math.round(b[0].block.height / cal._rowPitch()))(cal._blocks.filter(b => b.event.title === "Sport"))')" "1 3" shot "02-week" echo "=== Woche: Termin bearbeiten, Escape, Löschen per Rechtsklick" click 'cal._blocks.find(b => b.event.title === "Sport").block' check "Bearbeiten-Popover zeigt Titel" "$(val 'entries(grabbed())[0]')" "Sport" key Escape check "Escape schließt" "$(val 'grabbed() ? "grab" : "frei"')" "frei" rclick 'cal._blocks.find(b => b.event.title === "Sport").block' check "Kontextmenü offen" "$(val 'labels(grabbed()).join("|")')" "Löschen" shot "03-context-menu" click 'find(grabbed(), "Löschen")' check "Termin gelöscht (Tombstone bleibt)" "$(val 'store.events.filter(e => e.title === "Sport").map(e => e.deleted).join(",") + " " + store.eventsOn("2026-09-18").filter(e => e.title === "Sport").length')" "true 0" echo "=== Woche: ganztägiger Termin, Klick daneben schließt" click 'cal._alldaySlots[6]' check "Ganztags-Popover" "$(val '["So, 20.09.2026 ▾", "Keine Uhrzeit ▾"].map(t => find(grabbed(), t) ? "ja" : "nein").join(" ")')" "ja ja" type_ "Frei"; key Return check "Ganztags gespeichert" "$(val 'JSON.stringify(store.events.filter(e => e.title === "Frei").map(e => [e.date, e.start]))')" '[["2026-09-20",null]]' $IN drag $(xy 'cal._slots[1][9]') $(xy 'cal._slots[1][9]'); sleep 0.3 check "Klick ohne Ziehen = eine Stunde" "$(val '["Di, 15.09.2026 ▾", "09:00 ▾", "10:00 ▾"].map(t => find(grabbed(), t) ? "ja" : "nein").join(" ")')" "ja ja ja" click 'todos.get_child_at_index(0).get_child_at_index(0)' check "Klick daneben schließt" "$(val '[grabbed() ? "grab" : "frei", Main.modalCount].join(" ")')" "frei 0" echo "=== Todos: Popover mit Datum- und Zeitauswahl" rclickb 'todos' click 'find(grabbed(), "Erstellen")' check "Todo-Popover" "$(val 'labels(grabbed())[0]')" "Neues Todo" type_ "Milch kaufen" click 'find(grabbed(), "Kein Datum ▾")' check "Datumsauswahl offen" "$(val 'grabbed()?.style_class')" "desktop-popover menu datepicker" shot "04-datepicker" click 'find(grabbed(), "20")' check "Datum übernommen" "$(val 'find(grabbed(), "So, 20.09.2026 ▾") ? "ja" : "nein"')" "ja" click 'find(grabbed(), "Keine Uhrzeit ▾")' check "Zeitauswahl offen" "$(val 'grabbed()?.style_class')" "desktop-popover menu timepicker" click 'find(grabbed(), "09:30")' check "Uhrzeit übernommen" "$(val 'find(grabbed(), "09:30 ▾") ? "ja" : "nein"')" "ja" shot "05-todo-popover" check "Fokus nach Auswahl wieder im Textfeld" "$(val 'global.stage.get_key_focus() === entries(grabbed()) && "x" || (grabbed().contains(global.stage.get_key_focus()) && global.stage.get_key_focus().constructor.name)')" "Clutter_Text" key Return check "Todo gespeichert" "$(val 'JSON.stringify((({title, date, time, done}) => ({title, date, time, done}))(store.todos.find(t => t.title === "Milch kaufen")))')" \ '{"title":"Milch kaufen","date":"2026-09-20","time":"09:30","done":false}' click 'find(todos, "Milch kaufen")' check "Todo bearbeiten zeigt Text" "$(val 'entries(grabbed())[0]')" "Milch kaufen" key Escape rclick 'find(todos, "Milch kaufen")' click 'find(grabbed(), "Löschen")' check "Todo gelöscht (Tombstone bleibt)" "$(val 'store.todos.filter(t => t.title === "Milch kaufen").map(t => t.deleted).join(",") + " " + store.sortedTodos().filter(t => t.title === "Milch kaufen").length')" "true 0" rclickb 'todos' check "Rechtsklick Todo-Fläche" "$(val 'labels(grabbed()).join("|")')" "Erstellen" click 'find(grabbed(), "Erstellen")' key Return check "Leeres Todo abgelehnt" "$(val 'labels(grabbed()).includes("Text fehlt") ? "Fehler gezeigt" : "kein Fehler"')" "Fehler gezeigt" key Escape echo "=== Notizen: anlegen, direkt bearbeiten, umbenennen, löschen" rclickb 'notes.get_child_at_index(0)' check "Rechtsklick im Baum" "$(val 'labels(grabbed()).join("|")')" "Notiz erstellen|Ordner erstellen" click 'find(grabbed(), "Notiz erstellen")' type_ "Testnotiz"; key Return check "Notiz-Datei angelegt" "$(val 'GLib.file_test(store.notesDir + "/Testnotiz.md", GLib.FileTest.EXISTS)')" "true" check "Editor offen mit Fokus" "$(val '[notes._editing, notes._editScroll.visible, global.stage.get_key_focus() === notes._editor.clutter_text].join(" ")')" "true true true" type_ "# Hallo"; key Return; key Return; type_ "Erste **Notiz**" shot "06-inline-editor" sleep 4 check "Autosave nach ein paar Sekunden" "$(val '[notes._editing, JSON.stringify(read(store.notesDir + "/Testnotiz.md"))].join(" ")')" 'true "# Hallo\n\nErste **Notiz**"' click 'cal._viewButtons.month' check "Fokusverlust beendet den Editor, Anzeige gerendert" "$(val '[notes._editing, notes._viewScroll.visible, notes._content.text].join("|")')" "false|true|Hallo Erste Notiz" click 'notes._viewScroll' check "Klick in die Fläche startet den Editor" "$(val '[notes._editing, global.stage.get_key_focus() === notes._editor.clutter_text].join(" ")')" "true true" type_ " mehr" click 'find(notes, "Allgemein")' check "Wechsel speichert sofort" "$(val 'read(store.notesDir + "/Testnotiz.md").endsWith("**Notiz** mehr") + " " + notes._selected.name')" "true Allgemein" rclick 'find(notes, "Testnotiz")' check "Kontextmenü Notiz" "$(val 'labels(grabbed()).join("|")')" "Umbenennen|Löschen" click 'find(grabbed(), "Umbenennen")' type_ "Umbenannt"; key Return check "Notiz umbenannt" "$(val '[GLib.file_test(store.notesDir + "/Testnotiz.md", GLib.FileTest.EXISTS), GLib.file_test(store.notesDir + "/Umbenannt.md", GLib.FileTest.EXISTS)].join(" ")')" "false true" rclick 'find(notes, "▸ Projekte")' check "Voller Ordner nicht löschbar" "$(val '(b => b ? String(b.reactive) : "fehlt")(find(grabbed(), "Löschen (Ordner ist nicht leer)"))')" "false" key Escape rclickb 'notes.get_child_at_index(0)' click 'find(grabbed(), "Ordner erstellen")' type_ "Leer"; key Return check "Ordner angelegt und gewählt" "$(val '[GLib.file_test(store.notesDir + "/Leer", GLib.FileTest.IS_DIR), notes._selected?.name].join(" ")')" "true Leer" rclick 'find(notes, "▾ Leer")' click 'find(grabbed(), "Löschen")' check "Leerer Ordner gelöscht" "$(val 'GLib.file_test(store.notesDir + "/Leer", GLib.FileTest.EXISTS)')" "false" rclick 'find(notes, "Umbenannt")' click 'find(grabbed(), "Löschen")' check "Notiz gelöscht" "$(val '[GLib.file_test(store.notesDir + "/Umbenannt.md", GLib.FileTest.EXISTS), JSON.stringify(notes._noteTitle.text)].join(" ")')" 'false ""' check "Trennlinie zwischen Ordnern und Notizen" "$(val 'notes._tree.get_children().map(c => c.style_class).join("|")')" "tree-row folder|tree-row folder|tree-separator|tree-row note" rclickb 'notes.get_child_at_index(0)' check "Rechtsklick Notiz-Fläche" "$(val 'labels(grabbed()).join("|")')" "Notiz erstellen|Ordner erstellen" click 'find(grabbed(), "Notiz erstellen")' type_ "Wurzel"; key Return check "Notiz im Wurzelordner, Editor offen" "$(val '[GLib.file_test(store.notesDir + "/Wurzel.md", GLib.FileTest.EXISTS), notes._editing].join(" ")')" "true true" click 'cal._viewButtons.month' rclickb 'notes' check "Rechtsklick im Inhaltsbereich" "$(val 'labels(grabbed()).join("|")')" "Notiz erstellen|Ordner erstellen" key Escape rclick 'find(notes, "▸ Privat")' check "Ordnermenü" "$(val 'labels(grabbed()).join("|")')" "Notiz erstellen|Ordner erstellen|Umbenennen|Löschen (Ordner ist nicht leer)" click 'find(grabbed(), "Ordner erstellen")' type_ "Sub"; key Return check "Unterordner im Ordner" "$(val '[GLib.file_test(store.notesDir + "/Privat/Sub", GLib.FileTest.IS_DIR), notes._selected?.name].join(" ")')" "true Sub" rclick 'find(notes, "▾ Privat")' click 'find(grabbed(), "Notiz erstellen")' type_ "InPrivat"; key Return check "Notiz im Ordner" "$(val 'GLib.file_test(store.notesDir + "/Privat/InPrivat.md", GLib.FileTest.EXISTS)')" "true" click 'find(notes, "▾ Privat")' check "Ordner gewählt zeigt nichts" "$(val '[notes._selected.type, JSON.stringify(notes._noteTitle.text), JSON.stringify(notes._content.text)].join(" ")')" 'folder "" ""' echo "=== Woche: Rechtsklick, Termin ohne Ende, mehrtägig" click 'cal._viewButtons.week' rclick 'cal._slots[1][9]' check "Rechtsklick Stundenzelle" "$(val 'labels(grabbed()).join("|")')" "Termin erstellen|Todo erstellen" click 'find(grabbed(), "Todo erstellen")' check "Todo mit Tag und Uhrzeit vorbelegt" "$(val '["Di, 15.09.2026 ▾", "09:00 ▾"].map(t => find(grabbed(), t) ? "ja" : "nein").join(" ")')" "ja ja" key Escape rclick 'cal._slots[1][9]' click 'find(grabbed(), "Termin erstellen")' check "Termin mit Tag und Startzeit vorbelegt" "$(val '["Di, 15.09.2026 ▾", "09:00 ▾", "Keine Uhrzeit ▾"].map(t => find(grabbed(), t) ? "ja" : "nein").join(" ")')" "ja ja ja" type_ "Kurz"; key Return check "Ohne Ende gilt eine Stunde" "$(val 'JSON.stringify((({start, end}) => [start, end])(store.events.find(e => e.title === "Kurz")))')" '["09:00","10:00"]' check "Mehrtägiger Termin als zwei Blöcke" "$(val 'JSON.stringify(cal._blocks.filter(b => b.event.title === "Messe").map(b => [b.dayIndex, b.from, b.to]))')" "[[1,840,1440],[2,0,660]]" $IN drag $(xy 'cal._slots[5][12]') $(xy 'cal._slots[6][12]'); sleep 0.3 check "Ziehen über zwei Tage markiert" "$(val '[cal._slots[5].filter(s => s.has_style_class_name("selecting")).length, cal._slots[6].filter(s => s.has_style_class_name("selecting")).length].join(" ")')" "12 13" check "Mehrtägig vorbelegt" "$(val '["Sa, 19.09.2026 ▾", "12:00 ▾", "So, 20.09.2026 ▾", "13:00 ▾"].map(t => find(grabbed(), t) ? "ja" : "nein").join(" ")')" "ja ja ja ja" shot "08-multiday-drag" type_ "Lang"; key Return check "Mehrtägig gespeichert" "$(val 'JSON.stringify((({date, start, endDate, end}) => [date, start, endDate, end])(store.events.find(e => e.title === "Lang")))')" '["2026-09-19","12:00","2026-09-20","13:00"]' check "Mehrtägig als zwei Blöcke" "$(val 'JSON.stringify(cal._blocks.filter(b => b.event.title === "Lang").map(b => [b.dayIndex, b.from, b.to]))')" "[[5,720,1440],[6,0,780]]" $IN drag $(xy 'cal._alldaySlots[2]') $(xy 'cal._alldaySlots[4]'); sleep 0.3 check "Ganztags über drei Tage markiert" "$(val 'cal._alldaySlots.map((s, d) => s.has_style_class_name("selecting") ? d : null).filter(d => d !== null).join(",")')" "2,3,4" check "Ganztags mehrtägig vorbelegt" "$(val '["Mi, 16.09.2026 ▾", "Keine Uhrzeit ▾", "Fr, 18.09.2026 ▾"].map(t => find(grabbed(), t) ? "ja" : "nein").join(" ")')" "ja ja ja" type_ "Urlaub"; key Return check "Ganztags mehrtägig gespeichert" "$(val 'JSON.stringify((({date, start, endDate}) => [date, start, endDate])(store.events.find(e => e.title === "Urlaub")))')" '["2026-09-16",null,"2026-09-18"]' check "Ganztags als ein Block über drei Tage" "$(val '(b => b.length + " " + b[0].from + "-" + b[0].to + " " + Math.round(b[0].block.width / (cal._alldaySlots[1].x - cal._alldaySlots[0].x)))(cal._alldayBlocks.filter(b => b.event.title === "Urlaub"))')" "1 2-4 3" check "Ganztags-Zellen bündig mit Stundenzellen" "$(val '[0, 3, 6].map(d => Math.round(cal._alldaySlots[d].get_transformed_position()[0] - cal._slots[d][9].get_transformed_position()[0]) + "/" + Math.round(cal._alldaySlots[d].width - cal._slots[d][9].width)).join(" ")')" "0/0 0/0 0/0" check "Ganztags-Block bündig mit der Spalte" "$(val '(b => Math.abs(b.block.x - cal._alldaySlots[2].x) <= 1)(cal._alldayBlocks.find(b => b.event.title === "Urlaub"))')" "true" check "Zwei Ganztags-Termine untereinander" "$(val 'JSON.stringify(cal._alldayBlocks.filter(b => ["Urlaub", "Frei"].includes(b.event.title)).map(b => b.event.title + ":" + b.lane))')" '["Urlaub:0","Frei:0"]' shot "09-allday-blocks" click 'cal._blocks.find(b => b.event.title === "Kurz").block' click 'find(grabbed(), "09:00 ▾")' click 'find(grabbed(), "11:00")' check "Fokus nach Zeitauswahl im Titelfeld" "$(val 'global.stage.get_key_focus()?.constructor.name + " " + grabbed().contains(global.stage.get_key_focus())')" "Clutter_Text true" key Return check "Ende vor Anfang abgelehnt" "$(val 'labels(grabbed()).includes("Ende liegt vor dem Anfang") ? "Fehler gezeigt" : "kein Fehler"')" "Fehler gezeigt" key Escape echo "=== Monat und Tag" click 'cal._viewButtons.month' check "Monat: Uhrzeit nur bei eintägigen Terminen" "$(val '(c => [c.get_child_at_index(17), c.get_child_at_index(19)].map(cell => cell.get_child().get_child_at_index(2).label_actor.text).join("|"))(cal._body.get_child().get_child_at_index(1))')" "09:30 Zahnarzt|Lang" rclick 'cal._body.get_child().get_child_at_index(1).get_child_at_index(21)' check "Rechtsklick Monatszelle" "$(val 'labels(grabbed()).join("|")')" "Termin erstellen|Todo erstellen" click 'find(grabbed(), "Termin erstellen")' check "Tag vorbelegt" "$(val '["Mo, 21.09.2026 ▾", "Keine Uhrzeit ▾"].map(t => find(grabbed(), t) ? "ja" : "nein").join(" ")')" "ja ja" key Escape click 'cal._viewButtons.month' click 'cal._body.get_child().get_child_at_index(1).get_child_at_index(17).get_child().get_child_at_index(2)' check "Monatseintrag öffnet Popover" "$(val 'entries(grabbed())[0]')" "Zahnarzt" key Escape click 'cal._body.get_child().get_child_at_index(1).get_child_at_index(17).get_child().get_child_at_index(0)' check "Tagesansicht" "$(val 'cal._view + " " + cal._cursor')" "day 2026-09-17" check "Keine Buttons in der Tagesansicht" "$(val '[find(cal, "+ Todo"), find(cal, "+ Termin"), find(cal, "Heute")].filter(Boolean).length')" "0" rclickb 'cal._body.get_child()' click 'find(grabbed(), "Todo erstellen")' check "Todo aus Tagesansicht hat Datum" "$(val 'find(grabbed(), "Do, 17.09.2026 ▾") ? "ja" : "nein"')" "ja" key Escape rclickb 'cal._body.get_child()' check "Rechtsklick Tag-Fläche" "$(val 'labels(grabbed()).join("|")')" "Termin erstellen|Todo erstellen" click 'find(grabbed(), "Termin erstellen")' check "Termin erstellen für den Tag" "$(val 'labels(grabbed())[0] + " " + (find(grabbed(), "Do, 17.09.2026 ▾") ? "ja" : "nein")')" "Neuer Termin ja" key Escape shot "07-day" click 'cal._viewButtons.month' click 'cal._title.get_parent().get_child_at_index(2)' check "Pfeil rechts blättert" "$(val 'cal._title.text')" "Oktober 2026" check "Pfeil rechts direkt am Titel" "$(val '(n => n.x - (cal._title.x + cal._title.width) < 20)(cal._title.get_parent().get_child_at_index(2))')" "true" click 'cal._viewButtons.day' check "Tag-Button nutzt Cursor" "$(val 'cal._view')" "day" check "Keine JS-Fehler" "$(grep -c 'JS ERROR' /tmp/desktop-app-nested.log)" "0" echo if [ $FAILED -eq 0 ]; then echo "alle ok"; else echo "$FAILED FEHLER"; grep -n "JS ERROR" -A5 /tmp/desktop-app-nested.log | head -40; fi